diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bc6923d..aa18f24 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,13 +4,13 @@ "name": "pasichDev", "url": "https://github.com/pasichDev" }, - "description": "Marketplace for the docket claim/release workflow skill.", + "description": "Marketplace for the docket skill.", "plugins": [ { - "name": "docket-claim", + "name": "docket", "source": "./", - "description": "Claim/release workflow skill for the docket MCP server's shared backlog.", - "version": "1.0.0" + "description": "Field and tool reference for docket, the shared list every AI tool and project writes to.", + "version": "2.0.0" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index c973337..ef76af2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "docket-claim", - "description": "Claim/release workflow skill for the docket MCP server's shared backlog.", - "version": "1.0.0", + "name": "docket", + "description": "Field and tool reference for docket, the shared list every AI tool and project writes to.", + "version": "2.0.0", "author": { "name": "pasichDev", "url": "https://github.com/pasichDev" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f3e410..7dfb302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,38 @@ on: branches: [main] jobs: - build: + # The engines field claims ">=18". Until this matrix existed, that claim was evidenced by + # exactly one moving `lts/*` on Ubuntu — so a syntax or API that only exists in a newer + # Node would ship green, and the first person to hear about it would be a user on 18. + test: + name: test (node ${{ matrix.node }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: ["18", "20", "22", "24"] + include: + # macOS is the other platform this is actually used on daily. One version is + # enough: what differs there is the filesystem and process behaviour the lock, + # the atomic writes and the daemon probes depend on — not the language level. + - os: macos-latest + node: "20" + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node }} + - run: npm ci + - run: npm run build + # `npm test`, not a copy of its glob. The duplicate silently stopped running the + # browser-client tests the moment they moved into dist/web/client/app, and CI stayed + # green while a whole directory went unexecuted. + - run: npm test + + # What a user actually installs, rather than what the repository happens to contain: the + # packed tarball, unpacked into an isolated HOME, exercised through its published bins. + pack-smoke: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -16,4 +47,58 @@ jobs: node-version: "lts/*" - run: npm ci - run: npm run build - - run: node --test dist/*.test.js dist/web/*.test.js dist/server/*.test.js dist/remote/*.test.js + - name: Install the packed artifact into an isolated HOME + run: | + set -euo pipefail + # Output is captured and matched in memory, never piped into `grep -q` or `head`. + # Those exit as soon as they have what they need, which closes the pipe under a + # `docket` that is still writing — SIGPIPE, exit 141, and with `pipefail` that is + # a failed step reporting nothing about what actually went wrong. + tarball="$(npm pack --silent | tail -1)" + scratch="$(mktemp -d)" + export HOME="$scratch" + export DOCKET_DATA_DIR="$scratch/data" + npm install -g "$PWD/$tarball" + docket help > /dev/null + docket import /dev/stdin <<'EOF' + # Docket + - [ ] packed artifact smoke test + EOF + listed="$(docket list --all)" + grep -q "packed artifact smoke test" <<< "$listed" + exported="$(docket export --format json)" + grep -q "packed artifact smoke test" <<< "$exported" + status="$(docket status)" + grep -q "^Mode: local" <<< "$status" + echo "packed artifact works end to end" + + # The documented build path — `npm run build` inside the image — was never executed by CI, + # which is how the Dockerfile came to copy one of the three tsconfigs it needs. + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Build the image + run: docker build -t docket:ci . + - name: The documented commands must work inside it + run: | + set -euo pipefail + docker run -d --name docket-ci -v docket-ci-data:/data docket:ci + for i in $(seq 1 30); do + if docker exec docket-ci wget -q --spider http://127.0.0.1:8788/api/v1/health; then break; fi + sleep 1 + done + health="$(docker exec docket-ci wget -qO- http://127.0.0.1:8788/api/v1/health)" + grep -q '"ok":true' <<< "$health" + # docs/headless.md's first instruction to a new self-hoster. It needs `docket` on + # PATH inside the runtime image, which the image did not have. + # + # Captured whole, then matched: `… | head -1` closes the pipe after the first of + # this command's four lines, and the SIGPIPE that kills the writer is exit 141. + pairing="$(docker exec docket-ci docket devices pair)" + grep -qE '^[A-Z0-9]{6}$' <<< "$(head -1 <<< "$pairing")" + # Non-root, on the volume the compose file actually uses. + whoami_out="$(docker exec docket-ci id)" + grep -q 'uid=100(docket)' <<< "$whoami_out" + docker rm -f docket-ci + docker volume rm docket-ci-data diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6f00d1..08c29f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,35 +5,152 @@ on: tags: ["v*"] # e.g. git tag v1.0.0 && git push origin v1.0.0 jobs: + # Nothing reaches npm before this passes. The previous version of this workflow built the + # package and published it: a tag pointing at any commit on any branch — a fork's, an + # abandoned experiment's, one whose tests had never run — became a published release, and + # the only gate was that `tsc` succeeded. + verify: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + dist_tag: ${{ steps.version.outputs.dist_tag }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 # the ancestry check below needs history, not a shallow tip + + - name: The tag must point at a commit that is on main + run: | + set -euo pipefail + git fetch origin main --quiet + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::${GITHUB_REF_NAME} points at ${GITHUB_SHA}, which is not an ancestor of origin/main." + echo "Releases are cut from reviewed main. Merge first, then tag the merged commit." + exit 1 + fi + echo "${GITHUB_SHA} is on main." + + - name: The tag must match the version it claims to publish + id: version + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + if [ "v$version" != "$GITHUB_REF_NAME" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match package.json version $version." + exit 1 + fi + # A prerelease MUST NOT become `latest`. npm's default dist-tag is latest, so + # publishing 3.0.0-rc.1 from this workflow used to make every `npm install + # @pasichdev/docket` and every unpinned `npx` in the world resolve to a release + # candidate — including the update checker, which would then offer it to stable + # users as an upgrade. + if [[ "$version" == *-* ]]; then dist_tag=next; else dist_tag=latest; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "dist_tag=$dist_tag" >> "$GITHUB_OUTPUT" + echo "publishing $version to the '$dist_tag' dist-tag" + + - uses: actions/setup-node@v5 + with: + node-version: "lts/*" + - run: npm ci + + # The full gate, re-run on the exact tagged tree rather than trusted from whatever CI + # ran on the branch. + - run: npm run build + - run: npm test + - name: Dependency audit + run: npm audit --omit=dev --audit-level=high + + - name: The packed artifact must work in an isolated HOME + run: | + set -euo pipefail + # Captured and matched in memory rather than piped into `grep -q`, which exits on + # its first match and SIGPIPEs the still-writing `docket` — exit 141 under pipefail. + tarball="$(npm pack --silent | tail -1)" + scratch="$(mktemp -d)" + export HOME="$scratch" + export DOCKET_DATA_DIR="$scratch/data" + npm install -g "$PWD/$tarball" + installed="$(docket --version)" + if [ "$installed" != "${{ steps.version.outputs.version }}" ]; then + echo "::error::the packed artifact reports $installed, not ${{ steps.version.outputs.version }}." + exit 1 + fi + docket import /dev/stdin <<'EOF' + # Docket + - [ ] release gate smoke test + EOF + listed="$(docket list --all)" + grep -q "release gate smoke test" <<< "$listed" + status="$(docket status)" + grep -q "^Mode: local" <<< "$status" + + - name: The published server metadata must match this version + run: | + set -euo pipefail + if [ -f server.json ]; then + metadata_version="$(node -p "require('./server.json').version ?? ''")" + if [ -n "$metadata_version" ] && [ "$metadata_version" != "${{ steps.version.outputs.version }}" ]; then + echo "::error::server.json says $metadata_version but package.json says ${{ steps.version.outputs.version }}." + exit 1 + fi + fi + + - name: The image must build and answer on the exact tagged tree + run: | + set -euo pipefail + docker build -t docket:release . + docker run -d --name docket-release -v docket-release-data:/data docket:release + for i in $(seq 1 30); do + if docker exec docket-release wget -q --spider http://127.0.0.1:8788/api/v1/health; then break; fi + sleep 1 + done + health="$(docker exec docket-release wget -qO- http://127.0.0.1:8788/api/v1/health)" + grep -q '"ok":true' <<< "$health" + pairing="$(docker exec docket-release docket devices pair)" + grep -qE '^[A-Z0-9]{6}$' <<< "$(head -1 <<< "$pairing")" + docker rm -f docket-release + docker volume rm docket-release-data + publish: + needs: verify runs-on: ubuntu-latest + # A protected environment: the release credentials are only reachable from a job that + # required an approval, so a pushed tag alone cannot spend them. + environment: release permissions: contents: read id-token: write # required for `npm publish --provenance` (Sigstore-backed attestation) steps: - - name: Checkout code - uses: actions/checkout@v5 - - # --- Publish the npm package --- - - - name: Set up Node.js - uses: actions/setup-node@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: node-version: "lts/*" registry-url: "https://registry.npmjs.org" + - run: npm ci + - run: npm run build - - name: Install dependencies - run: npm ci - - - name: Build package - run: npm run build - - - name: Publish package to npm - run: npm publish --provenance + - name: Publish to npm + run: npm publish --provenance --tag "${{ needs.verify.outputs.dist_tag }}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Confirm the dist-tags landed where they were meant to + run: | + set -euo pipefail + version="${{ needs.verify.outputs.version }}" + for attempt in $(seq 1 10); do + latest="$(npm view @pasichdev/docket dist-tags.latest 2>/dev/null || echo '')" + if [ -n "$latest" ]; then break; fi + sleep 5 + done + echo "latest is now $latest" + if [[ "$version" == *-* ]] && [ "$latest" = "$version" ]; then + echo "::error::prerelease $version became the 'latest' dist-tag. Fix with: npm dist-tag add @pasichdev/docket@ latest" + exit 1 + fi + # --- Publish server metadata to the official MCP Registry --- # Requires a server.json in the repo root (generate once locally with # `mcp-publisher init` + `mcp-publisher login github`, see README) and diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f402ab --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,441 @@ +# Changelog + +## 3.0.0-rc.2 + +Two review passes and a full release-readiness audit. The audit's verdict on the +previous build was a plain no-go: not because anything visible was broken, but +because the failures that were left all sat at the seams where state moves — +between processes, between machines, between local and self-hosted — and every +one of them reported success while losing something. + +Twenty-seven blockers, all closed, each with a regression test that was checked +to fail against the code it replaces. + +### Security + +**Device management no longer trusts a source address.** The admin routes behind +`docket devices …` were gated on the request arriving from `127.0.0.1`, on the +reasoning that the operator on the server machine is the trust boundary. A +reverse proxy — which `docs/headless.md` recommends for HTTPS, with a worked +Caddy example — breaks that reasoning completely: every request it forwards +arrives from `127.0.0.1`. Anyone on the internet could mint a pairing code, +approve their own request, and end up with a fully authorised device against the +authoritative store. + +They now require a secret written to `admin-token` in the data directory (mode +0600), which the CLI reads because it runs as the same user on the same machine. +The loopback check stays as defence in depth rather than as the boundary, and +`X-Forwarded-For` is deliberately not consulted — it is set by whoever spoke to +the proxy. + +### Data correctness + +- **A server backup now includes `devices.json.enc`.** The documented + disaster-recovery path restored the todos and the server's identity but not the + registry of authorised devices, so every paired client silently stopped + authenticating at exactly the wrong moment. + +- **The delivery cursor no longer steps over records the sanitiser refused.** A + record rejected for a malformed timestamp still occupied a position in the + peer's delivery order, and the cursor was computed from the raw page — so the + next request started above it and it was never asked for again. The merge now + reports where it refused; the cursor stops below that, and the peer record says + why syncing is held. + +- **A tombstone's `deletedAt` must be a real timestamp.** Deletions are compared + by string ordering, so `"zzzz"` sorted above every ISO date and produced a + deletion no later edit from any device could beat. + +- **Timestamps at the ISO boundary are refused.** `9999-12-31T23:59:59.999Z` was + accepted, and one millisecond past it is year 10000, which serialises as + `+010000-01-01T…` — a shape no Docket accepts. This device could manufacture a + record the next device would refuse. + +- **Releasing a file lock claims it by rename first.** Read-then-unlink had a + window: a process suspended between the two steps woke to find its lock reaped + and deleted the new holder's lock instead. + +- **Workspace slugs keep the full repository path.** `team-a/platform/backend` + and `team-b/platform/backend` both collapsed to `platform/backend`, merging two + teams' lists. **This changes existing slugs again** for nested groups only. + +### Durability and concurrency + +- **Every persistent write is now durable, not just atomic.** "Temp file plus + rename" guarantees that no reader sees half a file. It guarantees nothing about + whether the data or the directory entry reached the disk, so a crash seconds + after a successful write could leave the old contents, the new contents, or an + empty file where the store had been. Both fsyncs are in place, and every write + goes through one function — including three that had no temp file at all: your + Claude `settings.json`, each MCP host's config, and the backup bundle itself. + +- **First-run secrets can no longer be minted twice.** Two processes starting + against an empty data directory — an MCP session and the dashboard it spawns, + which is the ordinary case — both read nothing, both generated a key, and both + wrote. Whichever lost then held a key that was not on disk, and everything it + encrypted afterwards was unreadable by anyone, itself included, from the next + restart. The at-rest key, the store epoch and the server's admin token are all + settled by an exclusive create now. + +- **A suspended process can no longer overwrite newer state.** The advisory lock + cannot stop a laptop that sleeps mid-write from having its lock reaped and + waking up still inside its own critical section. The todo store detected that; + the peer list, the viewer list, the server's device registry, the remote + credentials and this device's own identity did not — so a stale writer could + silently unpair a device that had just been added. They all carry the same two + guards now: the lock's identity, then a hash of the bytes. + +- **That content check is a hash rather than a size and a timestamp.** Two + encrypted stores very often share a length, and several filesystems keep + modification times to the nearest second. + +- **The audit log no longer records edits that did not happen.** History was + written to its side file before the store commit, so an attempt that lost a + race left its entries behind and then re-ran — a permanent record of an edit + that was rolled back, in the one file whose entire job is to be trustworthy. + +### Sync + +- **An item that outlives a peer's deletion is delivered back to that peer.** When a + deletion loses to a newer edit the item correctly stays alive — but the device + that deleted it was never told, because our copy still sat at the sequence + number it had when that device last saw it, below its cursor. One device showed + the item, the other showed it deleted, both reported a healthy sync, and no + amount of further syncing repaired it: there was nothing left to send. Reachable + with two devices whenever one clock runs behind the other. + +- **The convergence property test now controls its own clock.** It generated + topologies, operations and clock skew from a seed, then read the real clock for + the timestamps — so whether two operations landed in the same millisecond + depended on how fast the machine was, and a seed that failed on a CI runner + passed everywhere else. A failure that cannot be reproduced is indistinguishable + from noise, and this one was dismissed as flaky more than once. The clock is part + of the seed now, and the sweep runs deeper on demand + (`DOCKET_CONVERGENCE_SEEDS`, `DOCKET_CONVERGENCE_CLOCK_STEP`). + +### Backup and restore + +- **A backup is one moment.** It read the data directory's files one after + another while the rest of the machine kept working, so a bundle could pair a + store from before a sync with a peer list from after it. Each file was + individually valid; the mixture surfaced much later as a peer that had gone + quiet. Backups are now read under every relevant lock and carry a per-file + checksum, so a damaged bundle is refused before anything is touched. + +- **Restore is a transaction.** It replaced the encryption key and then each + encrypted file in turn, so a crash in the middle left the new key beside some + of the old ciphertext — unreadable, and unreadable in a way no later run could + diagnose. Everything is now validated and staged first, the operation is + journalled before the first file moves, and an interrupted restore is finished + automatically on the next start. + +- **Nothing that predates a restore can write into what it produced.** Every + long-running process caches the key, this device's identity, the store epoch + and the admin token, and all four are silently wrong the moment the directory + underneath is replaced. The directory now carries a generation id that every + write re-checks; a process whose generation has moved stops and says so. + `docket restore` also names what is still running and asks you to stop it. + +### Moving a workspace between local and self-hosted + +- **`docket backend use` and `backend localize` preserve the workspace.** They + re-created every item through the ordinary "add a todo" path, which meant new + identities (so every paired device saw the whole list deleted and a different + one appear), today's timestamps, no history, and — since v3 made projects the + centre of the product — no project either: everything landed in Unfiled. None + of it was reported. A migration now carries item identity, project, chronology, + completion, revision, provenance, full history and deletions. + +- **A migration that fails halfway can simply be run again.** It used to leave + both sides populated and refuse to continue, telling you to repair it by hand. + +- **Switching to a server stops the local dashboard and its sync loop.** It kept + running, kept pulling from paired devices, and kept writing to a store that was + no longer the source of truth. + +- **`docket setup --remote` no longer hides an existing local workspace.** It + wrote remote mode without looking at what was already there: a year of todos + stayed on disk and stopped being part of the product, with nothing saying so. + It now offers to upload, to keep them where they are, or to cancel. + +### Configuration + +- **`~/.config/docket/config.json` is the source of truth.** Setup wrote + `DOCKET_MODE` into every MCP host's config, and the environment beats the + config file — so `docket backend localize` would report a switch to local mode + while every agent carried on talking to the server, and the only way out was + hand-editing four host config files you never knew existed. Setup writes no + deployment environment at all now; the override still exists for a container or + a single command. + +- **A custom data directory is recorded in one place.** It lived only in host + configs and a shell startup file, so `docket backup` typed in a terminal that + had not sourced that file backed up an empty `~/.docket` and reported success. + `docket status` now says which source the directory came from. + +- **In remote mode the CLI reads the server.** `list`, `stats`, `workspaces` and + `export` read the local store, and `import` wrote to it — the terminal showed + an empty list while the editor showed the real one, and an import reported + success for items that existed nowhere you could reach. + +### Identity, installation and containers + +- **Two items can no longer share a short id in silence.** The six-character id + is a hash, and the lookup returned the first match — so on a list that has run + for a while, `todo_complete T-XXXXXX` could quietly complete somebody else's + task. An ambiguous id is now refused, naming both items. + +- **Setup no longer discards MCP configuration it cannot parse.** A trailing + comma in `~/.cursor/mcp.json` was treated as "there is nothing here", and every + other MCP server you had configured was replaced by a file containing only + Docket. Unreadable configs are now left exactly as they are, readable ones keep + their unknown fields, and the previous version is saved beside them. + +- **An old dashboard left over from a previous version is replaced, not + adopted.** Auto-start accepted any answer on the port as "already running", so + after an upgrade the old process kept serving the same data directory. + +- **The container image builds, and the documented commands work inside it.** The + image copied one of the three TypeScript configs its own build needs, and had + no `docket` on `PATH` at all — so `docker compose exec docket docket devices + pair`, the first instruction given to a new self-hoster, could not have worked. + The default `docker compose up -d` also no longer needs a `chown` you would + have to guess at, and the published port is overridable for a machine already + running `docket serve`. + +### Release plumbing + +- **A pre-release can no longer be published as `latest`.** npm's default tag is + `latest`, so publishing a release candidate would have pointed every + unpinned install, every `npx`, and the update checker itself at it. +- **A tag alone no longer publishes.** The release job built and published; a tag + on any commit on any branch became a release. Publishing now requires proof + that the tag is on `main`, that it matches the version it claims, and that the + full test suite, a dependency audit, an install of the packed artifact, and a + container build-and-pair all pass on that exact commit. +- **Generated host configs pin the version that generated them.** Running a + release candidate's own setup configured every agent to launch whatever + `latest` resolved to — 2.x code against a 3.x data directory. +- **CI covers what the package claims.** Node 18, 20, 22 and 24 on Linux plus + macOS, the packed artifact, and the container image. +- CI runs `npm test` rather than a hand-copied glob of it. The copy stopped + running the browser-client tests the moment they moved, and CI stayed green. +- `docket check-update` follows the channel it was installed from, so an RC hears + about the next RC instead of being told 2.3.1 is the newest build. +- A protocol-v1 peer with more records than one merge can accept is reported as + incompatible instead of "syncing" forever without converging. +- The sync error told users to `npm install -g docket@latest`; the package is + `@pasichdev/docket`. +- `qs` is pinned past a moderate advisory. It reaches us only through the MCP + SDK's `express` dependency, which Docket never loads — but a red audit trains + people to ignore audits. + +## 3.0.0-rc.1 + +Release candidate. Publish under `npm publish --tag next` so `latest` keeps +pointing at 2.3.1 until this has run on machines that are not the author's. + +### Fixed since the first 3.0.0 branch cut + +Six issues from PR review, each with a regression test: + +- **A peer's `maxSeq` is no longer taken on trust.** The delivery cursor is the + one piece of sync state where a wrong value is silent *and* permanent: advance + it past records that were never sent and this device stops asking for that + range forever. A page that carried records can no longer promise more than its + highest record, and a `maxSeq` that is not a sequence number is rejected + outright. +- **Peer timestamps are validated before they enter the store.** `createdAt` and + `updatedAt` must parse; a record whose timestamps do not is refused. Shape + alone was not enough — `2026-13-45T99:99:99Z` looks right and still parses to + `NaN`, which made `new Date(Date.parse(x) + 1).toISOString()` in `mutations.ts` + throw `RangeError` on the next ordinary edit, long after the sync that accepted + it. Optional timestamps (`completedAt`, the working-lease pair, per-field + stamps) degrade to `null` instead of failing the record. +- **History pruning moved after the store commit.** `withStore`'s write is + optimistic and retries; a prune done before the commit could delete the audit + log of an item the winning write had kept alive. Appending still happens + before the commit, which is what buys the crash-safety ordering. +- **Restoring a backup without `history.json.enc` no longer leaves the current + one in place.** An old store paired with a newer sidecar produces an audit log + describing edits the store does not contain. Only that file is swept aside — + `peers.json.enc` is independent state, and clearing it would silently unpair + every device. +- **Workspace slugs now include the git host.** `owner/repo` alone collided + whenever two forges shared a namespace. **This changes existing slugs:** + `acme/backend` becomes `gitlab.com/acme/backend`, so items filed under the old + name stay under it and appear as a separate project in the switcher. Rename + them with `.docket.json` or `DOCKET_WORKSPACE` if you have any. +- **`compareVersions` implements SemVer §11.** It split on `.` and ran `Number()` + over the parts, so `0-rc` became `NaN` and every comparison against it fell + through to the "greater" branch — `3.0.0-rc.1` compared as *newer* than + `3.0.0-rc.2`, which would have offered an RC user a downgrade as an update. + +### Dashboard + +- Cards lead with the title. The meta row moved below it and stopped repeating + what the filter row above already says: the Todo/Backlog badge disappears once + you have filtered to one, and `via web` is gone entirely — web is where you are + looking. +- The project switcher is a select in the toolbar rather than a second row of + pills that opened with its own "All". +- Long descriptions are previewed at 300 characters with a **Read more**; the + full item and its history open in a modal. Editing moved into a modal too, with + a markdown editor (formatting bar, Write/Preview, `Ctrl`/`⌘`+`B`/`I`/`K`). +- Descriptions render markdown. Emphasis follows CommonMark's flanking rule, so + ordinary prose like `rename *.js to *.ts` is left alone, and four-space indented + blocks keep their shape. +- Clicking an item's id copies it. + +## 3.0.0 + +Three silent data-loss bugs in the sync layer, one latent lock-corruption bug, +and the feature the tool was missing: **workspaces**. + +### Breaking / migration + +**Data format v7 → v8.** Migration is automatic and one-way, applied by the +single locked write `migrateLegacyFields()` already performed at startup. + +- Every item and tombstone gains **`localSeq`**, a per-device delivery counter. + Existing records are numbered in a stable order (`createdAt` ascending, + `uuid` as tiebreak) so two devices migrating the same exported store agree. +- Every item gains **`workspace`**, set to `null` for existing items. It is + never guessed: there is no honest way to know which project a v7 item came + from, and a wrong workspace hides an item where its author will never look. +- History moves to **`history.json.enc`**. Items keep their last 5 entries + inline for card previews; the full log is read only by `todo_history` and the + web UI's detail panel. + +**Rolling back to 2.x — read before downgrading.** A v8 store is refused by +older builds rather than misread, so 2.3.1 will not *read* your data. It will +still **write** it: 2.3.1's `saveStore` serialises from its own v7 shape, so +its first write strips `localSeq`, `workspace` and `seqCounter` from every +item, silently. A later re-upgrade then assigns fresh sequence numbers and +every paired device's cursor means something different than it did. 2.3.1 is +published and cannot be patched. + +So 3.0 copies your store aside once, immediately before the v7 → v8 write: + +```text +~/.docket/todos.v7-pre-upgrade.enc +``` + +Written once, never overwritten, and its path is printed on the run that +creates it. To downgrade: + +```sh +docket restore --from-v7 # restores that copy; moves the v8 store aside +npm install -g @pasichdev/docket@2.3.1 +``` + +In that order. Nothing is deleted either way — `restore --from-v7` renames the +v8 store aside, and `docket backup` before upgrading gives you a portable copy +including identity and paired peers. + +Restoring works in both directions, and paired devices notice. `localSeq` is a +counter and a peer's cursor is a number in it, so a restored (lower) counter +would otherwise leave every paired device deaf to this one. Each store now has +a **`store-epoch`** — a plaintext id beside the store, re-minted by `restore`, +and deliberately excluded from backups so that restoring onto *new hardware* +mints a fresh one too (that case brings `peers.json.enc` back with it, so +remote peers still hold cursors into the old machine's sequence space). The +epoch travels in the sync payload; a peer whose recorded epoch no longer +matches discards its cursor and re-syncs from scratch. The check runs on the +side that owns the cursor, which is the only side that can be wrong about it. + +**Sync protocol v1 → v2.** `MIN_COMPATIBLE_SYNC_PROTOCOL_VERSION` stays at 1, +so a mesh does not have to be upgraded atomically. A peer still on v1 syncs in +a degraded mode and says so on its own peer record: +*"peer is on sync protocol v1 — updates from a third device may not reach this +one; update that peer."* + +**Renamed:** the `docket-claim` skill and plugin are now `docket` +(`/plugin install docket@docket`). `skills/docket-claim/` →`skills/docket/`. + +### Fixed + +- **Transitive propagation.** `updatedAt` was doing two different jobs — + merge resolution and delivery cursor — and merging copies the *author's* + `updatedAt` onto the local record. An item reaching B second-hand landed in + B's store already timestamped in A's past, below A's cursor for B, and A + never heard about it. With A↔B↔C paired and A↔C not, an edit made on C was + silently lost. Delivery now has its own counter (`localSeq`), stamped on + every local write *including accepting a peer's change*. +- **Silent truncation on first sync.** `mergeSyncPayload` capped the payload at + 2000 items and the caller then advanced its cursor to the peer's clock + regardless — a first sync of a larger store permanently lost the remainder, + with no error and no log line. Sync now pages, and the cursor advances only + to what was actually merged. +- **Two processes could hold the file lock at once.** Both saw a stale lock, + both removed it, and the second removed the *first's* brand-new lock. Two + concurrent read-modify-writes on the store, one silently discarded. Reaping + is now an atomic rename with a compare-and-swap on the holder record and a + re-check of its age. This is still an advisory lock between cooperating + processes on one machine, not a proof of exclusion: a much narrower window + remains, and release and heartbeat now verify ownership so a process that + lost its lock can't take the new holder's down with it. +- **A lock held longer than 10s was reaped out from under its holder** (a + suspended laptop, a network filesystem, a debugger). Held locks now heartbeat. +- **A nested `withStore` waited 5 seconds and then timed out.** It now fails + immediately, naming both call sites. + +### Added + +- **Workspaces.** Items are filed under the project they were captured in, + resolved from the git remote where possible so the same repo on two machines + is one workspace. `todo_list` defaults to the current project plus unfiled + items. `docket workspaces`, `docket list -w `, `docket list --all`, a + workspace switcher in the web UI, per-project counts in `docket stats`. + See [`docs/workspaces.md`](docs/workspaces.md). +- **Live session registry.** `docket sessions`, an "Active sessions" panel in + the web UI, and a one-line routing hint on capture when another session is + already live in that project. +- **`docket hook install | uninstall | doctor`** — a Claude Code `SessionStart` + hook that injects what's open in the current project (compact, ≤7 items, + ≤120 tokens), and nothing when there's nothing open. Merges into existing + hooks, never overwrites; removes only its own entries; **fails open always**. + `doctor` runs the configured command as a real subprocess, so it catches the + most likely failure — an executable that isn't on `PATH` — instead of testing + a copy of the hook in-process. Disable with `DOCKET_HOOKS=off`. + +### Changed + +- **A peer that restored a backup is no longer invisible.** Its sequence + counter goes backwards, leaving every cursor into it pointing past records + that were never seen. See `store-epoch` above. +- **Sync merges no longer trim an item's history.** A peer sends only recent + entries; trimming the merged result destroyed local entries that had not yet + been flushed to the side file. +- **A newer deletion of an already-deleted item now propagates.** A second + deletion of an item that a later edit had resurrected was applied locally but + never sequenced, so a third device kept comparing against the original, + older deletion and resurrected the item again — permanently. +- **`todo_list` is compact by default** — one line per item, `verbose: true` + for full records. It used to return everything, including history, on every + call. +- **Claim renewals no longer write a history entry.** A renewal is the absence + of an event, and at one heartbeat every few minutes per active item it was + the main driver of history growth that every unrelated write then paid for. +- **P2P sync is deprecated**, and the deployment-mode table now says so + plainly: claims are **advisory** in Local/P2P mode and **atomic** in + Self-hosted mode. The 15-second pull interval means P2P cannot deliver an + atomic guarantee. It still works in 3.0; nothing is removed. + +## Not in this release, and why + +Recorded so a later reader can tell a decision from an oversight. + +- **File leases and blocking hooks** — reserving a path so two agents can't edit it at once. + There is no evidence of file-level collisions in the usage this is built for: one person + across several *unrelated* projects, different trees, nothing overlapping. What gets lost + is the thread, not the file, which is what workspace scoping addresses. `docket sessions` + now makes real collisions visible; repeated ones on the same paths would change this. +- **Hook adapters for other hosts** — four mutually incompatible output shapes, each of + which shifts between host versions. With blocking deferred, the only hook worth having is + `SessionStart`, and only Claude Code has one stable enough to depend on. +- **Cross-vendor dispatch** — "hand this item to the Codex terminal that's already open" + isn't possible over stdio MCP: the server cannot wake an agent, and an agent only acts + inside a turn a human starts. Headless spawn *is* possible and buys the whole orchestrator + problem set — process ownership, output routing, crash handling, result collection — which + is a separate product. The routing hint shipped instead. diff --git a/Dockerfile b/Dockerfile index 61d26fe..2329e14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,11 @@ FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY tsconfig.json ./ +# Every tsconfig, not just the root one. `npm run build` runs three compilers — the server, +# the browser client, and the client's tests — so copying only tsconfig.json meant the image +# built the documented build command with two of its three configs missing. The build failed; +# nothing in CI ever ran it, so nothing noticed. +COPY tsconfig*.json ./ COPY src ./src RUN npm run build @@ -32,6 +36,11 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist COPY skills ./skills +# docs/headless.md tells operators to run `docker compose exec docket docket devices pair`. +# Without this the image has no `docket` on PATH at all and the documented command fails +# with "executable file not found" — the first thing a new self-hoster is asked to type. +RUN ln -s /app/dist/launcher.js /usr/local/bin/docket && chmod +x /app/dist/launcher.js + # Runs as an unprivileged user, not root — same posture a systemd `User=docket` deployment # gets (see docs/docket.service), just expressed the container way. RUN addgroup -S docket && adduser -S docket -G docket \ diff --git a/README.md b/README.md index 6672358..3d7079d 100644 --- a/README.md +++ b/README.md @@ -5,146 +5,138 @@ [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.pasichDev%2Fdocket-blue)](https://registry.modelcontextprotocol.io) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**One shared workspace for your AI coding agents. Local-first and -self-hostable.** Claude Code, Claude Desktop, Cursor, Windsurf, Warp, Codex — -add an item in one, see it in all of them, plus a real-time web dashboard and -your phone. Nothing gets lost switching tools or starting a new session. - -Docket talks to every one of those tools over [MCP](https://modelcontextprotocol.io) -(Model Context Protocol) — that's the integration mechanism, not the product. -The product is one shared workspace: what's claimed, what's done, who did it, -and when. **Run it entirely on this machine, or self-host it on infrastructure -you control — no SaaS account either way.** - -**What it adds beyond "just a list":** - -- **Local or self-hosted** — keep everything on this machine, or run one - always-on Docket Server and point every device at it (see - [Deployment modes](#deployment-modes)). -- **See who's doing what.** Claim an item before starting on it — other - sessions see it's taken instead of duplicating the work; atomic (`409` on a - race) in Self-hosted Mode. -- **Full history** — every create/edit/claim/complete is logged with who and - when. -- **Todo vs. backlog** — keep near-term work separate from things you want to - park without losing them. -- **Private by default** — the data file is encrypted on disk (see - [Security](#security)). -- **Real-time Web UI** — light/dark theme, search, sort, inline edit, and a - Viewer Gate for opening it from your phone. -- **Optional multi-device P2P sync**, entirely separate from Self-hosted Mode - — see [Devices & P2P sync](#devices--p2p-sync). +**One list every AI tool you use can write to — Claude Code, Codex, Cursor, +Warp — across every project, before the work is worth a ticket. Local-first, +self-hostable, no SaaS account.** -

- Docket web dashboard, dark theme, showing a claimed in-progress item - Docket web dashboard, light theme, same workspace -

+A thought that shows up mid-session is worth capturing but not worth the +ceremony: a Notion template, a GitLab issue format, a ticket id you have to +invent. So today it evaporates. Docket is the layer underneath all of that — +work lands here at the speed an agent can type, from any tool, in any +project, and **graduates** to Notion/GitLab/Obsidian when it's earned it. -## Architecture +Nobody else occupies this space, and it isn't an accident: Anthropic will not +integrate Claude Code with Cursor, and Cursor will not integrate with Codex. +Every vendor optimises its own closed loop. The space *between* the tools is +structurally nobody's. -Docket runs in one of two deployment modes. Both give every client the exact -same MCP tools and Web UI; only *where the authoritative state lives* -differs. +## One list, many projects -**Local Mode** (the default — nothing here requires any setup beyond -[Quick start](#quick-start)): +Docket files every item under the project it was captured in, automatically — +resolved from the git remote of wherever the agent is running. You never type +it, and no agent has to remember to. ```text -Claude · Codex · Cursor · Windsurf · Warp - │ - MCP - │ - Docket - │ - encrypted state - (this machine, ~/.docket) +~/work/backend claude-code, codex ─┐ +~/side/tracker claude-code ─┼──▶ docket ──▶ one list, three scopes +~/side/notes codex, warp ─┘ ``` -**Self-hosted Mode** (opt-in): +- `todo_list` in `~/work/backend` shows **that project's** open items, not all + three projects' — compact, one line each. +- The web dashboard has a workspace switcher with per-project open counts. +- Items with no project context land under **Unfiled** and stay visible, never + guessed at. -```text -Claude / Codex / Cursor - │ - stdio MCP - │ - local Docket client - │ - authenticated remote transport - │ - Docket Server - (an always-on machine you control) - │ - authoritative state +Using the git *remote* rather than the path means the same repo cloned to +`~/src/backend` on a laptop and `/work/backend` on a desktop is **one** +workspace — which matters precisely because sync exists. Full resolution +order and the `.docket.json` override: **[`docs/workspaces.md`](docs/workspaces.md)**. + +

+ Docket web dashboard, dark theme: the top card is claimed by claude-code and shows a Markdown description clipped with a Read more link + The same list in the light theme +

+

+ One item opened: its full Markdown description rendered, and its history showing who claimed it and who created it + The same item being edited, with a Markdown editor and Write/Preview tabs alongside category, priority and due date +

+

Regenerate these with node docs/assets/demo-seed.mjs — it builds the workspace they show, so they stay a picture of the real dashboard rather than a staged one.

+ +## Quick start + +**You need:** [Claude Code](https://claude.com/claude-code) (or another MCP host) +and Node.js 18+ (`node --version`; get it from [nodejs.org](https://nodejs.org)). + +```sh +npx -y @pasichdev/docket setup # one shared data dir, detected MCP hosts configured +claude mcp add docket -- npx -y @pasichdev/docket ``` -The Docket Server is authoritative — **not** another P2P replica. Every -client becomes a thin, authenticated forwarder to it; there's no local -writable copy in this mode. A third, independent topology — -[P2P sync](#devices--p2p-sync) — replicates a full copy onto each of your own -paired devices instead; see [Deployment modes](#deployment-modes) for how all -three fit together. +Restart Claude Code and ask it *"add a todo: buy milk"*. The web dashboard is +at **http://localhost:8787** — it started itself the moment the first client +connected. -## Deployment modes +Optionally, to see what's open in a project when a session starts: -| | Local Mode | Self-hosted Mode | -|---|---|---| -| **Default?** | Yes — zero config | Opt-in | -| **Where state lives** | This machine (`~/.docket`) | The Docket Server you run | -| **Setup** | `docket setup` | `docket setup`, choose "Self-hosted", or `docket pair ` | -| **Web UI** | Runs on this machine | Served by the Docket Server | -| **Multi-machine** | Optional [P2P sync](#devices--p2p-sync) between your own devices | Every paired device talks to one server | -| **Good for** | A single machine, or a few you personally use | An always-on Raspberry Pi, mini PC, NAS, home server, or VPS | -| **If the connection drops** | N/A | Every read/write/claim fails clearly — never a silent local fallback | +```sh +npm install -g @pasichdev/docket # the hook runs a command, so it needs one on PATH +docket hook install # then: docket hook doctor +``` -Both modes install from the same package and expose the same MCP tools — the -difference is entirely in `docket`'s configuration, not in what your AI agent -can do. Full self-hosted setup, CLI, and what it deliberately doesn't do -(offline writes, combining with P2P sync, automatic conflict merge, hosted -accounts): **[`docs/self-hosting.md`](docs/self-hosting.md)**. +`hook install` works without the global install too — it pins the command to +this exact copy of docket and tells you it did — but the short form survives +moving or reinstalling, and `npx` leaves nothing on `PATH`. -## Quick start +Using Claude Desktop, Cursor, Windsurf, Zed, or Warp instead? Same MCP config +shape — see [Supported hosts](#supported-hosts). -Five minutes, no prior MCP experience needed. This is the **Local Mode** -path — the simplest default. Want an always-on shared workspace instead? See -[`docs/self-hosting.md`](docs/self-hosting.md). +## Upgrading from 2.x -**You need:** [Claude Code](https://claude.com/claude-code) (or another MCP host) already -installed, and Node.js 18+ (`node --version`; get it from [nodejs.org](https://nodejs.org) if missing). +**Read this before you upgrade if you have existing items.** -**1. Run the interactive setup wizard.** Creates and verifies one shared data -directory, configures detected MCP hosts, optionally installs the claim -skill: +3.0 migrates your store from data format v7 to v8 on first run, automatically. +The migration itself is safe and is not the risk. **Downgrading afterwards +is.** -```sh -npx -y @pasichdev/docket setup +docket 2.3.1 writes the store from its own v7 shape: it has never heard of the +fields v8 adds, so its very first write after a reinstall **silently strips +them from every item**. Nothing errors, and a later re-upgrade hands out fresh +sequence numbers that no longer mean what your paired devices think they mean. +2.3.1 is published and cannot be patched, so 3.0 defends the only way it can — +by keeping a copy of your pre-migration store: + +```text +~/.docket/todos.v7-pre-upgrade.enc ``` -**2. Register the server.** +It is written once, before the first v8 write, and never overwritten. The +upgrade prints its path on the run that creates it. + +**To go back to 2.x, restore it first:** ```sh -claude mcp add docket -- npx -y @pasichdev/docket +docket restore --from-v7 # puts the v7 store back; moves the v8 one aside +npm install -g @pasichdev/docket@2.3.1 ``` -**3. Restart Claude Code**, then **try it** — ask Claude *"add a todo: buy -milk"*. If it uses the tool and confirms, you're set. +In that order. `restore --from-v7` deletes nothing — your v8 store is renamed +aside, so you can come forward again later. -**4. Open the web UI** at **http://localhost:8787** — it started itself the -moment step 3 ran. +If you would rather have a portable copy as well, `docket backup ./pre-v3.backup` +before upgrading gives you one that includes your identity and paired peers. -**5. (Optional) Install the claim-tracking skill** — teaches Claude Code to -mark items in progress and check before duplicating work: +## Bridges, not replaces -```sh -/plugin marketplace add pasichDev/docket -/plugin install docket-claim@docket -``` +Docket is deliberately not where work lives forever. It's where work lands +*first*, before anyone knows whether it deserves a ticket. Most of it doesn't +and gets closed by hand; the rest graduates. + +`sourceUrl` is the bridge in both directions. Set it whenever an item maps to +something with a URL — a GitLab issue, a Notion page, an Obsidian +share link, a Slack thread, a GitHub PR — and the card carries a clickable +link straight back to it. -Using Claude Desktop, Cursor, Windsurf, Zed, or Warp instead? See -[MCP integrations](#mcp-integrations) below. +> *Why not just use GitHub Issues?* Because an issue costs a title you have to +> phrase for an audience, a repo you have to pick, and labels you have to +> maintain — and because your Cursor session can't write one for you while +> you're mid-thought in a different project. Docket costs one sentence, from +> whichever tool you already have open. When the item turns out to matter, it +> becomes an issue, and `sourceUrl` remembers where it went. -## MCP integrations +## Supported hosts -Add to your host's MCP config — same `command`/`args` shape everywhere: +Any MCP host works — the tools are identical everywhere: ```json { @@ -156,9 +148,11 @@ Add to your host's MCP config — same `command`/`args` shape everywhere: | Host | Config file | |---|---| +| Claude Code | `claude mcp add docket -- npx -y @pasichdev/docket` | | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) / `%APPDATA%\Claude\claude_desktop_config.json` (Windows) | | Cursor | `.cursor/mcp.json` or Global MCP settings | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | +| Codex | `~/.codex/config.toml` (`mcp_servers`) | | Zed | `~/.config/zed/settings.json` — uses `context_servers`, see below |
@@ -173,32 +167,72 @@ Add to your host's MCP config — same `command`/`args` shape everywhere: ```
-Any of these hosts can also point at a **self-hosted** Docket — pair the -device first (`docket pair ` or `docket setup --remote `), then -register the server exactly as above; nothing about the MCP host config -itself changes. +### The one hook -**From source:** +Claude Code additionally gets a **`SessionStart` hook**, and only that one: ```sh -git clone https://github.com/pasichDev/docket.git && cd docket -npm install && npm run build -claude mcp add docket -- node "$(pwd)/dist/index.js" +docket hook install # writes .claude/settings.json (--global for user-wide) +docket hook doctor # proves it fires, and reports measured latency +docket hook uninstall # removes only the entries docket owns ``` -**Non-Claude-Code agents** (Codex, Cursor, Windsurf, Warp, ...): the MCP -tools work identically everywhere; the claim-workflow *guidance* ships as an -installable plugin for Claude Code only — for every other agent, copy -[`skills/docket-claim/SKILL.md`](skills/docket-claim/SKILL.md) (everything -below the `---` frontmatter) into whichever file your agent reads -(`AGENTS.md` for Codex, `.cursor/rules/docket.mdc` for Cursor, -`.windsurfrules` for Windsurf, `CLAUDE.md` for Claude Desktop/web, or Warp's -custom-instructions setting). +When a session starts it injects the items open **in that project** — compact, +at most 7, under 120 tokens. Nothing at all when the project has nothing open. +Its whole job is continuity: you come back to a terminal and the thread is +already there. + +**Don't want it?** `export DOCKET_HOOKS=off` disables it immediately, without +editing any config or uninstalling anything. `docket hook doctor` measures the +real round trip and says so if it is slow enough to notice. + +**It fails open, always.** Server not running, request timed out, malformed +response, `DOCKET_HOOKS=off` — every one of those exits 0 and prints nothing. +A tool that degrades your session when the tool itself is broken gets +uninstalled, at which point it helps nobody. `docket hook doctor` runs the +configured command for real and reports what a session would actually see, so +a hook that never fires is visible rather than merely silent. + +No `PreToolUse`, no blocking, no other hosts — the hook's only job in this +release is continuity, not enforcement. + +### Guidance for non-Claude-Code agents + +The tools work everywhere; the *guidance* ships as a Claude Code plugin. For +any other agent, copy [`skills/docket/SKILL.md`](skills/docket/SKILL.md) +(everything below the `---` frontmatter) into whichever file your agent reads +— `AGENTS.md` for Codex, `.cursor/rules/docket.mdc` for Cursor, +`.windsurfrules` for Windsurf, `CLAUDE.md` for Claude Desktop, or Warp's +custom-instructions setting. + +```sh +/plugin marketplace add pasichDev/docket +/plugin install docket@docket +``` + +## Tools + +| Tool | Description | +|---|---| +| `todo_add(title, description?, list?, category?, priority?, dueDate?, sourceUrl?, workspace?)` | Capture an item. Filed under the current project automatically. | +| `todo_list(filter?, list?, category?, agent?, session?, inProgress?, workspace?, verbose?, limit?, offset?)` | Scoped to the current project and compact by default. `workspace:"*"` for everything. | +| `todo_edit(id, ...)` | Edit any subset of fields by id. Pass `""` to clear an optional field. | +| `todo_claim(id)` / `todo_release(id)` | Mark an item in progress, or drop the claim. Auto-expires after 15 minutes. | +| `todo_complete(id)` | Mark done (also clears any claim). | +| `todo_history(id)` | Full change log for one item. | +| `todo_delete(id)` | Permanently remove an item. | +| `todo_version()` / `todo_check_update()` | Data-format version; read-only npm version check. | + +Full field and workflow reference: [`skills/docket/SKILL.md`](skills/docket/SKILL.md). ## CLI ```text -docket list | stats | export | import Workspace inspection +docket list [-w ] [--all] What's open, scoped like the MCP default +docket workspaces Projects, with open counts and last activity +docket sessions Agent sessions open right now, and where +docket stats | export | import Inspection and round-tripping +docket hook install | uninstall | doctor Claude Code SessionStart hook docket serve | pair | devices | status Self-hosted server & devices docket backend use | localize Switch deployment mode docket backup | restore Encrypted full-device backup @@ -206,134 +240,109 @@ docket web Ensure the Web UI is running docket check-update | update Version management ``` -`docket help` prints the canonical, always-current list. Full flag-by-flag -reference and the complete environment-variable table: +`docket help` prints the canonical, always-current list. Full reference: **[`docs/cli.md`](docs/cli.md)**. -## Tools +## Deployment modes -| Tool | Description | -|---|---| -| `todo_add(title, description?, list?, category?, priority?, dueDate?, sourceUrl?)` | Add an item. `list` is `"todo"` (default) or `"backlog"`. | -| `todo_edit(id, ...)` | Edit any subset of fields by id. Pass `""` to clear an optional field. | -| `todo_claim(id)` | Mark an item as actively worked on. Advisory in Local Mode (warns/lets you take over); atomic in Self-hosted Mode. Auto-expires after 15 minutes. | -| `todo_release(id)` | Clear your claim without completing the item. | -| `todo_list(filter?, list?, category?, agent?, session?, inProgress?, limit?, offset?)` | List with filtering and token-saving pagination. | -| `todo_complete(id)` | Mark done (also clears any claim). | -| `todo_history(id)` | Full change log for one item. | -| `todo_version()` | Data-format version and process start time. | -| `todo_delete(id)` | Permanently remove an item. | -| `todo_check_update()` | Check npm for a newer version (read-only). | +| | Local Mode | Self-hosted Mode | +|---|---|---| +| **Default?** | Yes — zero config | Opt-in | +| **Where state lives** | This machine (`~/.docket`) | The Docket Server you run | +| **Setup** | `docket setup` | `docket setup`, choose "Self-hosted", or `docket pair ` | +| **Web UI** | Runs on this machine | Served by the Docket Server | +| **Multi-machine** | Optional [P2P sync](docs/p2p-sync.md) between your own devices | Every paired device talks to one server | +| **Claims** | **Advisory** — a claim can be taken over, and across P2P it can be up to one pull interval stale | **Atomic** — a racing claim gets a `409`, decided by one authority | +| **Good for** | A single machine, or a few you personally use | An always-on Raspberry Pi, mini PC, NAS, home server, or VPS | +| **If the connection drops** | N/A | Every read/write/claim fails clearly — never a silent local fallback | + +Both modes install from the same package and expose the same MCP tools. Full +self-hosted setup and what it deliberately doesn't do: +**[`docs/self-hosting.md`](docs/self-hosting.md)**. -Identical behavior in both deployment modes. Full field/workflow reference: -[`skills/docket-claim/SKILL.md`](skills/docket-claim/SKILL.md). +> **P2P sync is deprecated as of v3.0.** It still works, and nothing is +> removed in this release — but its 15-second pull interval means claims are +> advisory across it, so it cannot deliver an atomic guarantee. Self-hosted +> Mode is the supported multi-machine path. See +> [`docs/p2p-sync.md`](docs/p2p-sync.md). ## Web UI -A real-time read/write dashboard — `http://localhost:8787` by default in -Local Mode (override with `DOCKET_WEB_PORT`), or the Docket Server's own URL -in Self-hosted Mode. Light/dark theme, search, sort, inline edit, -undo-delete, responsive mobile layout. +A real-time read/write dashboard — `http://localhost:8787` by default in Local +Mode (override with `DOCKET_WEB_PORT`), or the Docket Server's own URL in +Self-hosted Mode. Workspace switcher with per-project open counts, an active- +sessions panel, light/dark theme, search, sort, inline edit, undo-delete, +responsive mobile layout. In Local Mode it starts itself: the first MCP client to connect spawns it -detached in the background if nothing's listening yet, and it keeps running -after that short-lived MCP connection exits — no separate install step, zero -overhead until it's actually used. Updates push live over Server-Sent Events -(`/api/events`) whenever an agent or peer changes a task, no polling. +detached in the background if nothing's listening yet. Updates push live over +Server-Sent Events (`/api/events`), no polling. Opening it from another device on your LAN (phone, tablet) requires an explicit **Viewer Gate** approval from the host machine first — see [Security](#security). -## Devices & P2P sync - -Pair a second computer and both keep the same list, entirely within **Local -Mode** — off by default, nothing connects until you open the Devices panel -and start a pairing with an explicit Approve/Deny on the host device. This is -a *different topology* from Self-hosted Mode (see [Architecture](#architecture)): -P2P sync replicates a full writable copy onto each paired device, merged -field-by-field on reconnect; a Docket Server instead owns the one -authoritative copy every client forwards to. - -Full pairing steps, the host/guest model, the X25519+HMAC+AES-GCM trust -model, and how the merge algorithm actually works: -**[`docs/p2p-sync.md`](docs/p2p-sync.md)**. - ## Security -Docket has **four separate threat models** (encrypted local storage, P2P -sync, the LAN Viewer Gate, and self-hosted client/server traffic) — a -guarantee from one does not apply to another. The basics: +Docket has **four separate threat models** (encrypted local storage, P2P sync, +the LAN Viewer Gate, and self-hosted client/server traffic) — a guarantee from +one does not apply to another. The basics: -- **At rest**: the data file is AES-256-GCM encrypted with a locally - generated key — protects against accidental exposure, not against someone - with read access to your own user account. +- **At rest**: `todos.json.enc` and `history.json.enc` are AES-256-GCM + encrypted with a locally generated key — protects against accidental + exposure, not against someone with read access to your own user account. - **P2P sync**: X25519 ECDH + HKDF-derived per-pair secrets, HMAC-signed - requests with replay protection, AES-256-GCM encrypted responses. Nothing - usable to a passive LAN listener. + requests with replay protection, AES-256-GCM encrypted responses. - **LAN Viewer Gate**: any browser other than the host machine needs explicit human approval before it can open the dashboard; that local traffic itself is plain HTTP, not TLS (documented tradeoff, not an oversight). -- **Self-hosted mode**: every request is authenticated with a per-device - HMAC signature (domain-separated from the P2P secret) with timestamp+nonce - replay protection; a non-loopback `http://` server URL is refused unless - explicitly opted into. The server itself is **not** end-to-end encrypted - against its own operator — it holds the authoritative plaintext workspace - while running. -- **Updates**: every release is published with Sigstore-backed npm - provenance, verifiable with `npm audit signatures`. - -None of this is simplified into vague "military-grade encryption" claims — -the full threat model, exact primitives, replay-protection details, and -self-hosted-specific limitations are in **[`docs/security.md`](docs/security.md)**. +- **Self-hosted mode**: per-device HMAC signatures with timestamp+nonce replay + protection. The server is **not** end-to-end encrypted against its own + operator. +- **Updates**: every release is published with Sigstore-backed npm provenance, + verifiable with `npm audit signatures`. + +Full threat model, exact primitives and limitations: +**[`docs/security.md`](docs/security.md)**. ## Data & encryption -Authoritative data lives on this client machine (`~/.docket`) in Local Mode, -or on the Docket Server in Self-hosted Mode — either way, on infrastructure -you control, never a hosted Docket account. +Authoritative data lives on this machine (`~/.docket`) in Local Mode, or on +the Docket Server in Self-hosted Mode — either way on infrastructure you +control, never a hosted account. - `todos.json.enc` — the store, AES-256-GCM encrypted -- `key` — a locally generated 256-bit key, `chmod 600` (owner-read-only) -- `device.json` — this machine's id, name, and X25519 identity keypair (private half never leaves this file) +- `history.json.enc` — the full audit log, kept off the store's write path +- `key` — a locally generated 256-bit key, `chmod 600` +- `device.json` — this machine's id, name, and X25519 identity keypair - `peers.json.enc` — paired P2P devices and their derived sync secrets +- `sessions.json` — which agent sessions are open right now (plain, local-only, + never synced; it holds process metadata, not content) -Set `DOCKET_DATA_DIR` to relocate/share the directory explicitly; startup -refuses to silently split an existing store rather than guessing. If you -upgrade from a version before encryption existed, the old plaintext -`todos.json` is migrated automatically and kept as `todos.json.bak`. +Set `DOCKET_DATA_DIR` to relocate the directory; startup refuses to silently +split an existing store rather than guessing. -## Backup +## Backup & updating -`docket backup ` bundles the whole data directory — identity, at-rest -key, todos, paired P2P peers — into one password-protected file (AES-256-GCM, -key derived with scrypt). `docket restore ` decrypts and writes it -back, renaming what's currently on disk aside rather than overwriting it. -Store the file and its password separately — losing either makes it useless, -losing both makes it unrecoverable. Refuses in Self-hosted Mode; back up on -the server itself instead. - -## Updating +`docket backup ` bundles the whole data directory into one +password-protected file (AES-256-GCM, scrypt-derived key); `docket restore` +writes it back, renaming what's on disk aside rather than overwriting. ```sh docket check-update # read-only — reports current vs. latest docket update # checks, confirms, installs, self-tests, rolls back on failure ``` -Applies to a **global npm install** only; `npx` always runs latest, and a -`git clone` checkout updates with `git pull && npm run build`. Every release -ships with Sigstore npm provenance — see [Security](#security). - ## Testing ```sh npm test ``` -Runs the full `node:test` suite — P2P sync merge, encryption round-trips, -pairing handshake verification, export/import, and (Self-hosted Mode) the -device HMAC auth scheme plus `docket serve`'s full `/api/v1` lifecycle -end-to-end. +Runs the full `node:test` suite — sync delivery and pagination, the +cross-process file lock under real contention, workspace resolution and +scoping, the session registry, agent-facing token budgets, and the hook's +fail-open behaviour. ## License diff --git a/docker-compose.yml b/docker-compose.yml index 164de8c..036c1be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,27 @@ services: build: . restart: unless-stopped ports: - - "8788:8788" + # The published host port is overridable, because 8788 is exactly the port a machine + # already running `docket serve` outside Docker has taken — and "address already in + # use" on the first command of the quickstart is a bad first impression of a tool whose + # whole promise is that it stays out of your way. + # DOCKET_HOST_PORT=18788 docker compose up -d + - "${DOCKET_HOST_PORT:-8788}:8788" volumes: - - ./data:/data + # A NAMED volume, not a bind mount to ./data. + # + # The image runs as an unprivileged user and chowns /data to it at build time. A bind + # mount covers that directory with a host one — which `mkdir -p data` creates owned by + # the host user at 0755 — so the container user cannot write to it, and docket exits at + # startup complaining it has no writable data directory. A named volume inherits the + # image's ownership, which is what makes the documented quickstart work on a fresh + # Linux host without a chown step nobody would guess at. + # + # To keep the data somewhere you can see it, bind-mount instead AND make it writable by + # the container's user first: + # mkdir -p data && sudo chown -R 100:101 data # the image's docket:docket + # volumes: [./data:/data] + - docket-data:/data environment: DOCKET_DATA_DIR: /data DOCKET_SERVER_HOST: 0.0.0.0 @@ -23,3 +41,6 @@ services: timeout: 5s start_period: 10s retries: 3 + +volumes: + docket-data: diff --git a/docs/assets/demo-dark.jpg b/docs/assets/demo-dark.jpg index 50751f5..272ae8d 100644 Binary files a/docs/assets/demo-dark.jpg and b/docs/assets/demo-dark.jpg differ diff --git a/docs/assets/demo-detail.jpg b/docs/assets/demo-detail.jpg new file mode 100644 index 0000000..fadf3cb Binary files /dev/null and b/docs/assets/demo-detail.jpg differ diff --git a/docs/assets/demo-edit.jpg b/docs/assets/demo-edit.jpg index 545b4f6..5e5df87 100644 Binary files a/docs/assets/demo-edit.jpg and b/docs/assets/demo-edit.jpg differ diff --git a/docs/assets/demo-light.jpg b/docs/assets/demo-light.jpg index fafcd9c..09c4350 100644 Binary files a/docs/assets/demo-light.jpg and b/docs/assets/demo-light.jpg differ diff --git a/docs/assets/demo-seed.mjs b/docs/assets/demo-seed.mjs new file mode 100644 index 0000000..8ae7168 --- /dev/null +++ b/docs/assets/demo-seed.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/** + * Builds the workspace the dashboard screenshots are taken of. + * + * It exists so the screenshots can be regenerated after any UI change without anyone + * inventing plausible-looking content again, and so what they show is the real dashboard + * rendering real records through the real API — not a mock. Everything lands in an isolated + * data directory on a non-default port, so a real install is never touched. + * + * node docs/assets/demo-seed.mjs # seed, then start the dashboard and print its URL + * node docs/assets/demo-seed.mjs --clean # remove the scratch directory + * + * The content is deliberately ordinary: a backend project mid-migration, a side project, and + * one unfiled thought. Categories, priorities, due dates, markdown descriptions, a claimed + * item and a couple of completed ones — because every one of those is a thing the card + * layout has to handle, and a screenshot of six identical one-line items proves none of it. + */ +import { spawn } from "node:child_process"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const ROOT = join(tmpdir(), "docket-demo-shots"); +const PORT = 8799; +const DATA_DIR = join(ROOT, "data"); + +if (process.argv.includes("--clean")) { + const running = await fetch(`http://127.0.0.1:${PORT}/api/version`, { signal: AbortSignal.timeout(500) }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null); + if (running?.pid) process.kill(running.pid, "SIGTERM"); + await rm(ROOT, { recursive: true, force: true }); + console.log(`Removed ${ROOT}`); + process.exit(0); +} + +/* + * Stop a previous run's dashboard BEFORE the directory goes. + * + * Skipping this is how a dashboard from the last run — still holding the old at-rest key — + * ended up writing the store into the freshly recreated directory, beside a new key that + * could not decrypt it. Docket now refuses that write rather than performing it (the + * generation check in storage.ts), which turns a corrupted scratch directory into a clear + * error; this makes the script not produce the error in the first place. + */ +const previous = await fetch(`http://127.0.0.1:${PORT}/api/version`, { signal: AbortSignal.timeout(500) }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null); +if (previous?.pid) { + process.kill(previous.pid, "SIGTERM"); + const gone = Date.now() + 5_000; + while (Date.now() < gone) { + const up = await fetch(`http://127.0.0.1:${PORT}/api/version`, { signal: AbortSignal.timeout(300) }).then(() => true).catch(() => false); + if (!up) break; + await new Promise((r) => setTimeout(r, 100)); + } +} + +await rm(ROOT, { recursive: true, force: true }); +await mkdir(DATA_DIR, { recursive: true }); + +// A git remote is what the workspace resolver actually reads, so the projects below are +// real ones as far as docket is concerned — no git binary required. +async function project(dir, remote) { + await mkdir(join(ROOT, dir, ".git"), { recursive: true }); + await writeFile(join(ROOT, dir, ".git", "config"), `[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = ${remote}\n`); +} +await project("backend", "git@github.com:acme/backend.git"); +await project("tracker", "https://github.com/you/tracker.git"); + +const web = spawn(process.execPath, [join(REPO, "dist", "web.js")], { + env: { ...process.env, DOCKET_DATA_DIR: DATA_DIR, DOCKET_WEB_PORT: String(PORT) }, + // Fully detached with no inherited pipes: this script has to EXIT once the dashboard is + // up, and an open stdio pipe to a child keeps the parent's event loop alive regardless of + // unref(). The child's own log goes to /server.log. + stdio: "ignore", + detached: true, +}); +web.unref(); + +const base = `http://127.0.0.1:${PORT}`; +const until = Date.now() + 15_000; +for (;;) { + const up = await fetch(`${base}/api/version`, { signal: AbortSignal.timeout(500) }).then((r) => r.ok).catch(() => false); + if (up) break; + if (Date.now() > until) throw new Error("the dashboard did not start"); + await new Promise((r) => setTimeout(r, 200)); +} + +const api = async (path, method, body) => { + const res = await fetch(`${base}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${await res.text()}`); + return res.json(); +}; + +const day = (offset) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + +const items = [ + { + title: "Token refresh races on the first request after a cold start", + workspace: "acme/backend", + category: "auth", + priority: "high", + dueDate: day(1), + list: "todo", + description: + "Two requests arriving together both see an expired token and both refresh, and the second one **invalidates the first's**.\n\n" + + "- Reproduces reliably with `--concurrency 2` against a cold pod\n" + + "- Only on the first request after a restart, which is why staging never caught it\n" + + "- Single-flight around `refresh()` in `src/auth/session.ts` is probably the fix\n" + + "- Needs a regression test that starts cold and fires two requests at once", + claim: true, + }, + { + title: "Drop the legacy /v1/login path", + workspace: "acme/backend", + category: "auth", + priority: "medium", + list: "todo", + description: "Nothing has called it in six weeks. Check the access logs once more, then remove the route and its tests.", + }, + { + title: "Migration notes for the 3.0 store format", + workspace: "acme/backend", + category: "docs", + priority: "low", + dueDate: day(5), + list: "backlog", + description: "Cover what changes on disk, what a downgrade does, and the one command that undoes it.", + }, + { + title: "Ship the new navigation", + workspace: "you/tracker", + category: "ui", + priority: "medium", + dueDate: day(3), + list: "todo", + description: "Keyboard focus order is still wrong on the collapsed sidebar.", + }, + { + title: "Rate-limit the public search endpoint", + workspace: "you/tracker", + category: "backend", + priority: "high", + list: "backlog", + }, + { + title: "Try the new profiler on the slow import path", + category: "ideas", + priority: "low", + list: "backlog", + description: "No project yet — just something worth an hour.", + }, + { + title: "Rotate the staging database credentials", + workspace: "acme/backend", + category: "ops", + priority: "medium", + list: "todo", + done: true, + }, + { + title: "Pin the CI Node version matrix", + workspace: "acme/backend", + category: "ci", + priority: "low", + list: "todo", + done: true, + }, +]; + +const claimed = []; +for (const { claim, done, ...input } of items) { + const created = await api("/api/todos", "POST", input); + const id = created.todo?.id ?? created.id; + if (done) await api(`/api/todos/${id}/complete`, "POST", {}); + if (claim) claimed.push(id); +} + +// Claiming is an MCP operation — there is no web route for it, because "an agent is working +// on this" is a statement only an agent gets to make. Done here the same way a tool call +// does it, against the same on-disk store the dashboard is reading. +if (claimed.length > 0) { + process.env.DOCKET_DATA_DIR = DATA_DIR; + const { LocalTodoRepository } = await import(join(REPO, "dist", "repository.js")); + const repository = new LocalTodoRepository(); + for (const id of claimed) { + await repository.claim(id, { agent: "claude-code", session: "demo", deviceId: "demo-device", deviceName: "MacBook" }); + } +} + +console.log(`Dashboard: ${base}`); +console.log(`Data dir: ${DATA_DIR}`); +console.log(`Projects: ${join(ROOT, "backend")}, ${join(ROOT, "tracker")}`); +console.log(`Tear down: node docs/assets/demo-seed.mjs --clean`); diff --git a/docs/assets/demo-setup.sh b/docs/assets/demo-setup.sh new file mode 100755 index 0000000..34311b5 --- /dev/null +++ b/docs/assets/demo-setup.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Builds the three-project environment for the workspace demo, so recording it is a matter +# of pressing record and typing four commands rather than staging anything. +# +# The cast itself is NOT generated here, and deliberately so: the README's central claim is +# "three projects, several agents, one list, correctly scoped", and the thing that backs a +# claim like that is a recording of it actually happening. A synthetic cast would look +# identical and mean nothing. +# +# ./docs/assets/demo-setup.sh # build the environment, print what to type +# ./docs/assets/demo-setup.sh --clean # tear it down +# +# Everything lives under a temp directory and a non-default port, so your real data +# directory and your running server are never touched. + +set -euo pipefail + +ROOT="${TMPDIR:-/tmp}/docket-demo" +export DOCKET_DATA_DIR="$ROOT/data" +export DOCKET_WEB_PORT=8799 +DOCKET="${DOCKET:-node $(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/dist/launcher.js}" + +if [[ "${1:-}" == "--clean" ]]; then + # Stop THIS demo's dashboard by asking the port which process is behind it — never + # `pkill -f dist/web.js`, which is indiscriminate: it also kills the dashboard serving the + # user's real data directory, on a different port, that has nothing to do with the demo. + pid="$(curl -sS --max-time 1 "http://127.0.0.1:$DOCKET_WEB_PORT/api/version" 2>/dev/null | sed -n 's/.*"pid":\([0-9]*\).*/\1/p')" + [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true + rm -rf "$ROOT" + echo "Removed $ROOT" + exit 0 +fi + +rm -rf "$ROOT" +mkdir -p "$DOCKET_DATA_DIR" + +# Three projects that resolve to three different workspaces — via their git remotes, which +# is the mechanism the demo is meant to show. No git binary needed: the resolver reads +# .git/config directly. +make_project() { + local dir="$ROOT/$1" remote="$2" + mkdir -p "$dir/.git" + printf '[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = %s\n' "$remote" > "$dir/.git/config" +} +make_project work-backend "git@gitlab.com:acme/backend.git" +make_project side-tracker "https://github.com/you/tracker.git" +make_project side-notes "https://github.com/you/notes.git" + +# Seed each project with a couple of items, filed the way an agent would file them. +seed() { + local workspace="$1"; shift + for title in "$@"; do + curl -sS -X POST "http://127.0.0.1:$DOCKET_WEB_PORT/api/todos" \ + -H 'Content-Type: application/json' \ + -d "$(printf '{"title":%s,"workspace":%s}' "$(printf '%s' "$title" | sed 's/"/\\"/g;s/.*/"&"/')" "\"$workspace\"")" > /dev/null + done +} + +node "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/dist/web.js" > "$ROOT/web.log" 2>&1 & +sleep 2 + +seed "acme/backend" "fix token refresh race" "drop the legacy auth path" +seed "you/tracker" "ship the new nav" +seed "you/notes" "write up the migration notes" +curl -sS -X POST "http://127.0.0.1:$DOCKET_WEB_PORT/api/todos" -H 'Content-Type: application/json' \ + -d '{"title":"a thought with no project yet"}' > /dev/null + +cat < scope to one project instead + --all every project, unscoped +docket workspaces Projects, with open/total counts and last activity +docket sessions Agent sessions open right now: agent, project, idle time, pid +docket stats Terminal stats widget with active claims, broken down per project docket export [options] Export to stdout or a file --format, -f "json" (default) or "markdown"/"md" --out, -o write directly to file instead of stdout docket import Import from a JSON or Markdown file ``` +`docket list` with no flags scopes to the current directory's project, exactly +like the MCP default — the CLI and the agents have to agree about what "the +list" means, or the tool teaches two different mental models. See +[`workspaces.md`](workspaces.md) for how a project is resolved. + +## Claude Code hook + +```text +docket hook install [--global] Add the SessionStart hook to .claude/settings.json +docket hook uninstall [--global] Remove only the entries docket owns +docket hook doctor Config, resolved project, server reachability, measured latency +``` + +`install` merges into whatever is already in that file, prints a diff, and asks +before writing. It is idempotent — running it twice does not stack duplicate +entries — and `uninstall` touches only entries it wrote, recognised by the hook's own +argument string rather than by the executable — the command is written as +`docket hook …` when docket is on PATH and as an absolute interpreter + +launcher path when it isn't. The hook itself fails open: if the local web server isn't +running, or anything else goes wrong, it exits 0 and prints nothing. + ## Setup ```text @@ -26,8 +51,8 @@ docket setup [options] Interactive local/self-hosted setup wizard `docket setup` (no flags) asks interactively whether to use Local or Self-hosted Mode, creates/verifies the data directory or drives pairing -accordingly, configures every detected MCP host, and offers the claim-skill -install. See the [README's Installation](../README.md#installation) for the +accordingly, configures every detected MCP host, and offers the skill +install. See the [README's Installation](../README.md#quick-start) for the Local Mode walkthrough and [`self-hosting.md`](self-hosting.md) for Self-hosted. @@ -88,7 +113,7 @@ docket update Check, confirm, install, self-test the new versi ``` Only applies to a global npm install (`npm install -g @pasichdev/docket`). -See the [README's Updating section](../README.md#updating). +See the [README's Updating section](../README.md#backup--updating). ## Environment variables @@ -96,6 +121,8 @@ See the [README's Updating section](../README.md#updating). |---|---|---| | `DOCKET_DATA_DIR` | Where local (or, on a server, authoritative) state lives | `~/.docket` | | `DOCKET_WEB_PORT` | Local Web UI port | `8787` | +| `DOCKET_WORKSPACE` | Override the project this session files items under | derived — see [`workspaces.md`](workspaces.md) | +| `DOCKET_HOOKS` | Set to `off` to disable the SessionStart hook without editing any config | unset (enabled) | | `DOCKET_MODE` | `local` or `remote` | `local` | | `DOCKET_SERVER_URL` | Server URL to use when `DOCKET_MODE=remote` | — | | `DOCKET_ALLOW_INSECURE_REMOTE` | Allow a non-HTTPS remote server URL (trusted-LAN dev only) | unset (HTTPS required) | diff --git a/docs/headless.md b/docs/headless.md index c16f286..44d8d19 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -59,21 +59,49 @@ todo.home.example { } ``` +Note what a proxy does to source addresses: every request it forwards reaches docket from +`127.0.0.1`. Device management is therefore **not** gated on the request looking local — it +requires a secret that `docket serve` writes to `admin-token` in the data directory, mode +0600, which `docket devices …` reads because it runs as the same user on the same machine. +A proxied request cannot obtain it. Do not forward `/api/v1/admin/` through the proxy, and +do not add the token to a proxy configuration. + For trusted-LAN-only development, plain HTTP is allowed but requires an explicit opt-in on every client — see [`security.md`](security.md#4-self-hosted-clientserver-traffic). ## Option B: Docker / docker-compose ```sh -mkdir -p data docker compose up -d ``` +That is the whole quickstart — there is no directory to create first. The compose file uses +a **named volume** (`docket-data`) rather than a bind mount, and the reason matters if you +change it: the image runs as an unprivileged `docket` user and owns `/data` at build time. A +bind mount covers that with a host directory, which `mkdir -p data` creates owned by *you* +at 0755, so the container user cannot write to it and docket exits at startup saying it has +no writable data directory. A named volume inherits the image's ownership. + +To keep the data somewhere you can browse it, bind-mount **and** hand it to the container's +user first: + +```sh +mkdir -p data && sudo chown -R 100:101 data # the image's docket:docket +# then in docker-compose.yml: volumes: ["./data:/data"] +``` + +Already running something on 8788 (a `docket serve` outside Docker, say)? The published host +port is overridable: + +```sh +DOCKET_HOST_PORT=18788 docker compose up -d +``` + This builds the image locally from this checkout (`build: .` in [`docker-compose.yml`](../docker-compose.yml)) — no image is published yet, so there's -nothing to pull. The compose file maps port 8788 and a `./data` volume, and includes a -container healthcheck against `/api/v1/health`. The `Dockerfile` (repo root) is a -multi-stage build targeting `linux/amd64` and `linux/arm64`. +nothing to pull. The compose file also includes a container healthcheck against +`/api/v1/health`. The `Dockerfile` (repo root) is a multi-stage build targeting +`linux/amd64` and `linux/arm64`. If you'd rather publish an image once and pull it on multiple machines instead of building locally on each one: @@ -133,6 +161,8 @@ init/tini layer needed to forward the signal. 3. **Upgrade and restart:** - systemd: `sudo npm install -g @pasichdev/docket@latest && sudo systemctl restart docket` - Docker (local build): `git pull && docker compose up -d --build` + (the named volume survives a rebuild — `docker compose down` alone never touches it; + only `down -v` removes it) - Docker (published image): `docker compose pull && docker compose up -d` 4. **Verify** with `docket status` (exit code 0, `Status: connected`/local health all green) before considering the upgrade done. If something looks wrong, `docket restore diff --git a/docs/index.html b/docs/index.html index ea1e147..fd26a03 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,8 +3,8 @@ -Docket — one workspace for every AI coding agent - +Docket — one list every AI tool can write to + -
-

Docket

-
-
syncing…
- - - -
-
- - - -
- - - -
- - - - - - -
- - - -
- -
-
-

Import from file

-

Select a Markdown (.md) or JSON (.json) file to add items into your store.

-
- - - -
-
-
-
- -
-
- - - - -
- -
-
- -
- -
- -
- -
    -
    - Done -
      -
      - - -
      -
      - - -
      - - -
      - - -
      -
      - - -
      -
      - - -
      -
      -
      - -
      loading version…
      - -
      - - -
      - - +${MARKUP} + `; + /** * Served instead of the app to any browser that isn't this machine and doesn't * already carry an approved viewer cookie — nothing about the list (not even @@ -1856,3 +139,4 @@ btn.addEventListener("click", async () => { `; + diff --git a/src/workspace.resolve.test.ts b/src/workspace.resolve.test.ts new file mode 100644 index 0000000..0fb73f6 --- /dev/null +++ b/src/workspace.resolve.test.ts @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { normalizeGitRemote, resolveWorkspace, slugifyWorkspace, WORKSPACE_CONFIG_FILE } from "./workspace.js"; + +const root = await mkdtemp(join(tmpdir(), "docket-workspace-test-")); +test.after(() => rm(root, { recursive: true, force: true })); + +/** A repo on disk, with as much or as little of a real one as the case under test needs. */ +async function makeRepo(name: string, options: { remote?: string; config?: string; git?: boolean } = {}): Promise { + const dir = join(root, name); + await mkdir(dir, { recursive: true }); + if (options.git !== false) { + await mkdir(join(dir, ".git"), { recursive: true }); + const remoteBlock = options.remote ? `[remote "origin"]\n\turl = ${options.remote}\n\tfetch = +refs/heads/*\n` : ""; + await writeFile(join(dir, ".git", "config"), `[core]\n\trepositoryformatversion = 0\n${remoteBlock}`); + } + if (options.config) await writeFile(join(dir, WORKSPACE_CONFIG_FILE), options.config); + return dir; +} + +test("resolve: DOCKET_WORKSPACE wins over everything else", async () => { + const dir = await makeRepo("env-repo", { remote: "git@gitlab.com:acme/backend.git", config: '{"workspace":"from-file"}' }); + const resolved = await resolveWorkspace(dir, { DOCKET_WORKSPACE: "explicit" }); + assert.equal(resolved.workspace, "explicit"); + assert.equal(resolved.source, "env"); +}); + +test("resolve: .docket.json at the repo root wins over the git remote", async () => { + const dir = await makeRepo("config-repo", { remote: "git@gitlab.com:acme/backend.git", config: '{"workspace":"monorepo-api"}' }); + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "monorepo-api"); + assert.equal(resolved.source, "config"); +}); + +test("resolve: a malformed .docket.json falls through instead of failing startup", async () => { + const dir = await makeRepo("broken-config-repo", { remote: "git@gitlab.com:acme/backend.git", config: "{not json" }); + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "gitlab.com/acme/backend"); + assert.equal(resolved.source, "git-remote"); +}); + +test("resolve: the git remote is used, and reached from a subdirectory too", async () => { + const dir = await makeRepo("remote-repo", { remote: "git@gitlab.com:acme/backend.git" }); + const nested = join(dir, "src", "deep"); + await mkdir(nested, { recursive: true }); + const resolved = await resolveWorkspace(nested, {}); + assert.equal(resolved.workspace, "gitlab.com/acme/backend"); + assert.equal(resolved.source, "git-remote"); + assert.equal(resolved.root, dir, "resolution anchors on the git root, not the directory it was called from"); +}); + +test("resolve: SSH and HTTPS clones of the same repo normalise to the same workspace", async () => { + // The reason the remote is preferred over the path at all: two machines, two clone URLs, + // two directory layouts — one workspace, or sync produces two half-lists. + const ssh = await makeRepo("clone-ssh", { remote: "git@gitlab.com:acme/backend.git" }); + const https = await makeRepo("clone-https", { remote: "https://someone@gitlab.com/acme/backend.git" }); + const a = await resolveWorkspace(ssh, {}); + const b = await resolveWorkspace(https, {}); + assert.equal(a.workspace, b.workspace); + assert.equal(a.workspace, "gitlab.com/acme/backend"); +}); + +test("resolve: a repo with no remote falls back to the git root's basename", async () => { + const dir = await makeRepo("Local Only Repo"); + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "local-only-repo"); + assert.equal(resolved.source, "git-root"); +}); + +test("resolve: outside a git repo, the cwd basename is used", async () => { + const dir = join(root, "no-git-here"); + await mkdir(dir, { recursive: true }); + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "no-git-here"); + assert.equal(resolved.source, "cwd"); +}); + +test("resolve: no cwd at all means no workspace — never a guess", async () => { + const resolved = await resolveWorkspace("", {}); + assert.equal(resolved.workspace, null); + assert.equal(resolved.source, "none"); +}); + +test("normalizeGitRemote: keeps host/owner/repo across every URL shape", () => { + const cases: Array<[string, string | null]> = [ + ["git@gitlab.com:acme/backend.git", "gitlab.com/acme/backend"], + ["git@github.com:Acme/Backend", "github.com/acme/backend"], + ["https://github.com/acme/backend.git", "github.com/acme/backend"], + ["https://user:token@github.com/acme/backend.git", "github.com/acme/backend"], + ["ssh://git@ssh.github.com:443/acme/backend.git", "ssh.github.com/acme/backend"], + // A nested GitLab group is part of the identity, not noise to trim: two teams whose + // "platform/backend" both collapsed to the same slug had their lists silently merged. + ["https://gitlab.com/acme/group/backend.git", "gitlab.com/acme/group/backend"], + ["https://gitlab.company/team-a/platform/backend.git", "gitlab.company/team-a/platform/backend"], + ["https://gitlab.company/team-b/platform/backend.git", "gitlab.company/team-b/platform/backend"], + // The whole point of carrying the host: these two are different projects. + ["git@gitlab.com:acme/backend.git", "gitlab.com/acme/backend"], + ["git@github.com:acme/backend.git", "github.com/acme/backend"], + // A remote with no host at all has nothing better to key on. + ["/srv/git/backend.git", "git/backend"], + ["", null], + ]; + for (const [input, expected] of cases) { + assert.equal(normalizeGitRemote(input), expected, `normalizeGitRemote(${JSON.stringify(input)})`); + } +}); + +test("slugifyWorkspace: the same project named two ways lands on one slug", () => { + assert.equal(slugifyWorkspace("Acme Backend"), "acme-backend"); + assert.equal(slugifyWorkspace(" my_project "), "my_project"); + assert.equal(slugifyWorkspace("acme//backend"), "acme/backend"); + assert.equal(slugifyWorkspace("!!!"), null); +}); + +// --- 2.4: the resolution table, exhaustively ------------------------------------------- + +test("resolve: an empty or whitespace-only DOCKET_WORKSPACE falls through instead of blanking the project", async () => { + const dir = await makeRepo("env-blank", { remote: "git@gitlab.com:acme/backend.git" }); + for (const value of ["", " ", "\t\n"]) { + const resolved = await resolveWorkspace(dir, { DOCKET_WORKSPACE: value }); + assert.equal(resolved.workspace, "gitlab.com/acme/backend", `"${value}" should not have won the resolution`); + assert.equal(resolved.source, "git-remote"); + } +}); + +test("resolve: an env value that slugifies to nothing falls through rather than unfiling everything", async () => { + const dir = await makeRepo("env-junk", { remote: "git@gitlab.com:acme/backend.git" }); + const resolved = await resolveWorkspace(dir, { DOCKET_WORKSPACE: "!!!" }); + assert.equal(resolved.workspace, "gitlab.com/acme/backend"); +}); + +test("resolve: a .docket.json without a workspace key is ignored, not treated as null", async () => { + const dir = await makeRepo("config-no-key", { remote: "git@gitlab.com:acme/backend.git", config: '{"somethingElse":true}' }); + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "gitlab.com/acme/backend"); + assert.equal(resolved.source, "git-remote"); +}); + +test("resolve: a .docket.json with a non-string workspace is ignored", async () => { + const dir = await makeRepo("config-wrong-type", { remote: "git@gitlab.com:acme/backend.git", config: '{"workspace":42}' }); + assert.equal((await resolveWorkspace(dir, {})).workspace, "gitlab.com/acme/backend"); +}); + +test("resolve: every URL shape of the same remote normalises to one workspace", async () => { + // The promise the feature makes: the same project on two machines is ONE workspace, + // however each machine happens to have cloned it. + const shapes = [ + "git@gitlab.com:acme/backend.git", + "git@gitlab.com:acme/backend", + "https://gitlab.com/acme/backend.git", + "https://gitlab.com/acme/backend", + "https://gitlab.com/acme/backend/", + "https://user:token@gitlab.com/acme/backend.git", + "ssh://git@gitlab.com:2222/acme/backend.git", + "https://GitLab.COM/Acme/Backend.git", + ]; + const resolved = new Set(); + for (const [i, remote] of shapes.entries()) { + const dir = await makeRepo(`shape-${i}`, { remote }); + resolved.add((await resolveWorkspace(dir, {})).workspace); + } + assert.deepEqual([...resolved], ["gitlab.com/acme/backend"], `these clone URLs split into ${resolved.size} workspaces: ${[...resolved].join(", ")}`); +}); + +test("resolve: with several remotes, origin wins; without origin, the first defined one is used", async () => { + const dir = await makeRepo("multi-remote", { git: false }); + await mkdir(join(dir, ".git"), { recursive: true }); + await writeFile( + join(dir, ".git", "config"), + '[remote "upstream"]\n\turl = git@gitlab.com:upstream/project.git\n[remote "origin"]\n\turl = git@gitlab.com:acme/backend.git\n', + ); + assert.equal((await resolveWorkspace(dir, {})).workspace, "gitlab.com/acme/backend", "origin must win however late it appears"); + + const noOrigin = await makeRepo("no-origin", { git: false }); + await mkdir(join(noOrigin, ".git"), { recursive: true }); + await writeFile(join(noOrigin, ".git", "config"), '[remote "fork"]\n\turl = git@gitlab.com:someone/fork.git\n'); + assert.equal((await resolveWorkspace(noOrigin, {})).workspace, "gitlab.com/someone/fork"); +}); + +test("resolve: a worktree resolves to the same workspace as the checkout it belongs to", async () => { + // `.git` is a file in a linked worktree, and the remotes live in the main checkout's + // common directory. Landing in a different workspace would split one project in two. + const main = await makeRepo("wt-main", { remote: "git@gitlab.com:acme/backend.git" }); + await mkdir(join(main, ".git", "worktrees", "feature"), { recursive: true }); + await writeFile(join(main, ".git", "worktrees", "feature", "commondir"), "../..\n"); + + const worktree = join(root, "wt-feature"); + await mkdir(worktree, { recursive: true }); + await writeFile(join(worktree, ".git"), `gitdir: ${join(main, ".git", "worktrees", "feature")}\n`); + + const resolved = await resolveWorkspace(worktree, {}); + assert.equal(resolved.workspace, "gitlab.com/acme/backend", "a worktree must not become its own workspace"); +}); + +test("resolve: a submodule uses its own remote, not its parent's", async () => { + const parent = await makeRepo("sub-parent", { remote: "git@gitlab.com:acme/parent.git" }); + await mkdir(join(parent, ".git", "modules", "lib"), { recursive: true }); + await writeFile(join(parent, ".git", "modules", "lib", "config"), '[remote "origin"]\n\turl = git@gitlab.com:acme/lib.git\n'); + + const submodule = join(parent, "lib"); + await mkdir(submodule, { recursive: true }); + await writeFile(join(submodule, ".git"), `gitdir: ${join(parent, ".git", "modules", "lib")}\n`); + + assert.equal((await resolveWorkspace(submodule, {})).workspace, "gitlab.com/acme/lib"); +}); + +test("resolve: a .git file pointing nowhere degrades to the directory name, not a crash", async () => { + const dir = await makeRepo("broken-gitdir", { git: false }); + await writeFile(join(dir, ".git"), "gitdir: /nowhere/at/all\n"); + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "broken-gitdir"); + assert.equal(resolved.source, "git-root", "it is still a repo root, just one whose config is unreadable"); +}); + +test("resolve: an unreadable .git/config degrades to the repo's directory name", async () => { + const dir = await makeRepo("unreadable-config", { git: false }); + await mkdir(join(dir, ".git", "config"), { recursive: true }); // a directory where a file belongs + const resolved = await resolveWorkspace(dir, {}); + assert.equal(resolved.workspace, "unreadable-config"); +}); + +/** + * The collision the `.docket.json` override exists for. Two unrelated projects that happen + * to share a directory name, neither with a remote, resolve to the SAME workspace — items + * from one appear in the other's list. That is the documented limitation, so it is pinned + * here rather than left to be discovered, along with the way out. + */ +test("resolve: two projects sharing a basename collide, and .docket.json resolves it", async () => { + const a = join(root, "clientA", "api"); + const b = join(root, "clientB", "api"); + await mkdir(join(a, ".git"), { recursive: true }); + await mkdir(join(b, ".git"), { recursive: true }); + await writeFile(join(a, ".git", "config"), "[core]\n"); + await writeFile(join(b, ".git", "config"), "[core]\n"); + + assert.equal((await resolveWorkspace(a, {})).workspace, (await resolveWorkspace(b, {})).workspace, "precondition: they collide"); + + await writeFile(join(b, WORKSPACE_CONFIG_FILE), '{"workspace":"clientB-api"}'); + assert.notEqual((await resolveWorkspace(a, {})).workspace, (await resolveWorkspace(b, {})).workspace); + assert.equal((await resolveWorkspace(b, {})).workspace, "clientb-api"); +}); + +test("resolve: the reported source names which rule actually fired, for every rule", async () => { + const cases: Array<[string, Record, string]> = [ + [(await makeRepo("src-env", { remote: "git@x.com:a/b.git" })), { DOCKET_WORKSPACE: "chosen" }, "env"], + [(await makeRepo("src-config", { remote: "git@x.com:a/b.git", config: '{"workspace":"c"}' })), {}, "config"], + [(await makeRepo("src-remote", { remote: "git@x.com:a/b.git" })), {}, "git-remote"], + [(await makeRepo("src-root")), {}, "git-root"], + ]; + for (const [dir, env, expected] of cases) { + assert.equal((await resolveWorkspace(dir, env)).source, expected, `${dir} reported the wrong rule`); + } +}); diff --git a/src/workspace.scoping.test.ts b/src/workspace.scoping.test.ts new file mode 100644 index 0000000..cb73f62 --- /dev/null +++ b/src/workspace.scoping.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +const originalDataDirectory = process.env.DOCKET_DATA_DIR; +const dataDirectory = await mkdtemp(join(tmpdir(), "docket-scoping-test-")); +process.env.DOCKET_DATA_DIR = dataDirectory; +const { filterTodos, LocalTodoRepository } = await import("./repository.js"); + +const repo = new LocalTodoRepository(); +const inWorkspace = (workspace: string | null) => ({ agent: "codex", session: "s1", deviceId: "d1", deviceName: "Dev", workspace }); + +test.after(() => { + if (originalDataDirectory === undefined) delete process.env.DOCKET_DATA_DIR; + else process.env.DOCKET_DATA_DIR = originalDataDirectory; + return rm(dataDirectory, { recursive: true, force: true }); +}); + +const backend = await repo.create({ title: "fix token refresh" }, inWorkspace("acme/backend")); +const web = await repo.create({ title: "ship the new nav" }, inWorkspace("acme/web")); +const unfiled = await repo.create({ title: "thought with no home" }, inWorkspace(null)); + +test("todo_add files an item under the caller's project without being asked", () => { + assert.equal(backend.workspace, "acme/backend"); + assert.equal(web.workspace, "acme/web"); + assert.equal(unfiled.workspace, null, "a caller with no project context files an honest null, not a guess"); +}); + +test("an explicit workspace on the input overrides the caller's own", async () => { + const moved = await repo.create({ title: "belongs elsewhere", workspace: "acme/web" }, inWorkspace("acme/backend")); + assert.equal(moved.workspace, "acme/web"); +}); + +test("the default scope is this project plus unfiled — never another project's items", async () => { + const scoped = await repo.list({ workspace: "acme/backend" }); + const titles = scoped.map((t) => t.title); + assert.ok(titles.includes("fix token refresh"), "this project's items are in"); + assert.ok(titles.includes("thought with no home"), "unfiled items stay reachable rather than becoming invisible"); + assert.ok(!titles.includes("ship the new nav"), "another project's items are out — this is the whole feature"); +}); + +test('workspace "*" returns everything', async () => { + const all = await repo.list({ workspace: "*" }); + assert.ok(all.length >= 4); + assert.ok(all.some((t) => t.workspace === "acme/web")); + assert.ok(all.some((t) => t.workspace === "acme/backend")); + assert.ok(all.some((t) => t.workspace === null)); +}); + +test("an omitted workspace means no restriction at all — the web UI's unfiltered list is unchanged", async () => { + const everything = await repo.list({}); + const explicit = await repo.list({ workspace: "*" }); + assert.equal(everything.length, explicit.length); +}); + +test("an id from another workspace still resolves — ids are global, scoping is a filter", async () => { + const fetched = await repo.get(web.id); + assert.ok(fetched, "serving a cross-project id is deliberate: an agent that has an id should get the item"); + assert.equal(fetched.workspace, "acme/web", "and the item reports where it actually lives, so the agent isn't misled"); +}); + +test("scoping composes with the other filters instead of replacing them", () => { + const todos = [backend, web, unfiled]; + const open = filterTodos(todos, { workspace: "acme/backend", filter: "open" }); + assert.deepEqual(open.map((t) => t.title).sort(), ["fix token refresh", "thought with no home"]); + const done = filterTodos(todos, { workspace: "acme/backend", filter: "done" }); + assert.deepEqual(done, []); +}); diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..4ee61e4 --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,277 @@ +import { readFile, stat } from "node:fs/promises"; +import type { Todo } from "./types.js"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +/** Where a resolved workspace came from. Reported by `docket status` and logged once at startup, so a mis-resolution is visible instead of mysterious. */ +export type WorkspaceSource = "env" | "config" | "git-remote" | "git-root" | "cwd" | "none"; + +export interface WorkspaceResolution { + /** The slug items get stamped with, or null when there is no project context at all. */ + workspace: string | null; + source: WorkspaceSource; + /** The directory the resolution was anchored at — the git root when there is one, otherwise cwd. */ + root: string | null; +} + +/** Project-root override file. One key, deliberately: this is an escape hatch, not a config system. */ +export const WORKSPACE_CONFIG_FILE = ".docket.json"; + +/** + * Folds the many ways to write the same project name onto one slug: lowercase, with runs of + * anything outside `[a-z0-9._-]` collapsed to a single dash. `/` survives because git + * remotes are naturally `owner/repo` and that separator carries real meaning. + * + * Applied to EVERY source, including the explicit env var and `.docket.json`, so that + * "Acme Backend" typed on one machine and "acme-backend" on another don't quietly become + * two workspaces. The cost is that an explicit name is not preserved byte-for-byte; the + * benefit is that the one thing this feature promises — the same project is one workspace — + * doesn't depend on typing it identically everywhere. + */ +export function slugifyWorkspace(raw: string): string | null { + const slug = raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9._/-]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/\/{2,}/g, "/"); + return slug || null; +} + +/** + * Turns a git remote URL into a stable workspace slug: `host/full/repo/path`. + * + * The remote, not the path, is what makes this feature work across machines: the same + * project cloned to ~/src/backend on a laptop and /work/backend on a desktop has to land in + * ONE workspace, or sync produces two half-lists. Credentials, ports and the ssh-vs-https + * spelling are all discarded, so the same repo fetched either way normalises to one slug. + */ +export function normalizeGitRemote(url: string): string | null { + const trimmed = url.trim().replace(/\.git\/?$/, ""); + if (!trimmed) return null; + + // scp-style `[user@]host:path`, which is not a URL and never parses as one. + const scp = /^[^/\s]+@([^/:\s]+):(.+)$/.exec(trimmed); + let path: string; + let host: string | null = null; + if (scp) { + host = scp[1]; + path = scp[2]; + } else { + try { + const parsed = new URL(trimmed); + host = parsed.hostname || null; + path = parsed.pathname; + } catch { + path = trimmed; + } + } + const segments = path.split("/").filter(Boolean); + if (segments.length === 0) return null; + /* + * Host plus the WHOLE path. Both halves earn their place. + * + * The host, because "owner/repo" alone collides the moment two forges share a namespace — + * a GitLab group and a GitHub org with the same name, a self-hosted mirror of a public + * repo, a fork on a company server. + * + * The whole path, not the last two segments, because GitLab nests groups, so + * "gitlab.company/team-a/platform/backend" and "gitlab.company/team-b/platform/backend" + * were different projects that both collapsed to "gitlab.company/platform/backend" — two + * teams' lists silently merged into one. + * + * A remote with no host at all (a plain local path) still takes the last two segments: a + * filesystem path has no namespace to preserve, and its leading directories differ per + * machine, which is the thing this function exists to see past. + */ + const parts = host ? [host, ...segments] : segments.slice(-2); + return slugifyWorkspace(parts.join("/")); +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +/** + * Nearest ancestor containing `.git`. Walks the tree rather than shelling out to + * `git rev-parse`: this runs at MCP startup, in a process a host is waiting on, and + * spawning a subprocess to answer a question the filesystem already answers is a cost paid + * on every session for nothing. + */ +export async function findGitRoot(startDir: string): Promise { + let dir = resolve(startDir); + for (;;) { + try { + await stat(join(dir, ".git")); + return dir; + } catch { + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } + } +} + +/** + * `.git` is usually a directory, but in a linked worktree or a submodule it is a file + * pointing elsewhere, and the config that holds the remotes lives in the COMMON directory + * shared with the main checkout. Following both hops means a worktree resolves to the same + * workspace as the checkout it belongs to, which is the whole point. + */ +async function gitConfigPath(gitRoot: string): Promise { + const dotGit = join(gitRoot, ".git"); + if (await isDirectory(dotGit)) return join(dotGit, "config"); + let gitDir: string; + try { + const pointer = await readFile(dotGit, "utf8"); + const match = /^gitdir:\s*(.+)$/m.exec(pointer); + if (!match) return null; + gitDir = isAbsolute(match[1].trim()) ? match[1].trim() : resolve(gitRoot, match[1].trim()); + } catch { + return null; + } + try { + const common = (await readFile(join(gitDir, "commondir"), "utf8")).trim(); + gitDir = isAbsolute(common) ? common : resolve(gitDir, common); + } catch { + // No commondir: `gitDir` is already the real git directory (a submodule, typically). + } + return join(gitDir, "config"); +} + +/** + * Reads the remote URL straight out of `.git/config` — `origin` if present, otherwise the + * first remote defined. A hand-rolled read of two INI keys, not a general git config parser: + * anything more would be a dependency or a bug farm, and this is the only question asked. + */ +export async function readGitRemote(gitRoot: string): Promise { + const configPath = await gitConfigPath(gitRoot); + if (!configPath) return null; + let text: string; + try { + text = await readFile(configPath, "utf8"); + } catch { + return null; + } + const remotes = new Map(); + let current: string | null = null; + for (const line of text.split("\n")) { + const section = /^\s*\[remote\s+"([^"]+)"\]\s*$/.exec(line); + if (section) { + current = section[1]; + continue; + } + if (/^\s*\[/.test(line)) { + current = null; + continue; + } + const url = current && /^\s*url\s*=\s*(.+?)\s*$/.exec(line); + if (url && !remotes.has(current!)) remotes.set(current!, url[1]); + } + return remotes.get("origin") ?? [...remotes.values()][0] ?? null; +} + +async function readWorkspaceConfig(root: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(root, WORKSPACE_CONFIG_FILE), "utf8")) as { workspace?: unknown }; + return typeof parsed.workspace === "string" ? parsed.workspace : null; + } catch { + // Missing is the common case; malformed is the user's own file and not worth failing + // startup over — the next resolution step gives a workable answer either way. + return null; + } +} + +/** + * Resolution order, first hit wins: + * + * 1. `DOCKET_WORKSPACE` — explicit, always wins. + * 2. `.docket.json` at the git root (or cwd): the escape hatch for monorepos, and for two + * unrelated projects whose directories happen to share a basename. + * 3. The git remote, normalised — the only source that is stable across machines. + * 4. The git root's basename — a repo with no remote is still a project. + * 5. cwd's basename. + * 6. null — no project context at all (a bare Claude Desktop session, say). Deliberately + * not guessed: an item filed under a wrong workspace is hidden somewhere its author + * will never look, which is strictly worse than a visible "Unfiled". + */ +export async function resolveWorkspace(cwd: string, env: NodeJS.ProcessEnv = process.env): Promise { + const fromEnv = env.DOCKET_WORKSPACE ? slugifyWorkspace(env.DOCKET_WORKSPACE) : null; + if (fromEnv) return { workspace: fromEnv, source: "env", root: cwd || null }; + if (!cwd) return { workspace: null, source: "none", root: null }; + + const gitRoot = await findGitRoot(cwd); + const root = gitRoot ?? cwd; + + const configured = await readWorkspaceConfig(root); + const fromConfig = configured ? slugifyWorkspace(configured) : null; + if (fromConfig) return { workspace: fromConfig, source: "config", root }; + + if (gitRoot) { + const remote = await readGitRemote(gitRoot); + const fromRemote = remote ? normalizeGitRemote(remote) : null; + if (fromRemote) return { workspace: fromRemote, source: "git-remote", root }; + const fromRoot = slugifyWorkspace(basename(gitRoot)); + if (fromRoot) return { workspace: fromRoot, source: "git-root", root }; + } + + const fromCwd = slugifyWorkspace(basename(resolve(cwd))); + if (fromCwd) return { workspace: fromCwd, source: "cwd", root }; + return { workspace: null, source: "none", root }; +} + +/** + * Process-wide resolution, computed once. Every MCP host spawns its own `node dist/index.js` + * per session, so the answer is fixed for the life of a session — except when the host tells + * us its roots changed, which is what `invalidateWorkspace` is for. Re-resolving on every + * tool call would mean a filesystem walk per call for an answer that essentially never moves. + */ +let cached: Promise | null = null; +let anchorDir: string | null = null; + +export function currentWorkspace(): Promise { + cached ??= resolveWorkspace(anchorDir ?? process.cwd()); + return cached; +} + +/** Re-anchor on a directory the host told us about (MCP `roots`), and re-resolve on next use. */ +export function setWorkspaceRoot(dir: string | null): void { + anchorDir = dir; + cached = null; +} + +/** What an item with no project is called, everywhere it is shown. It used to be spelled three different ways. */ +export const UNFILED_LABEL = "unfiled"; + +export interface WorkspaceSummary { + name: string; + open: number; + total: number; + /** The most recent `updatedAt` in this workspace — "which project did I last touch?". */ + lastActivity: string; +} + +/** + * Groups items by project, busiest first. One fold, used by `docket stats`, `docket + * workspaces` and the status line — they previously each rebuilt it, and had already + * drifted on what to call an item with no project. + */ +export function summarizeWorkspaces(todos: Todo[]): WorkspaceSummary[] { + const byName = new Map(); + for (const todo of todos) { + const name = todo.workspace ?? UNFILED_LABEL; + const entry = byName.get(name) ?? { name, open: 0, total: 0, lastActivity: "" }; + entry.total += 1; + if (!todo.done) entry.open += 1; + if (todo.updatedAt > entry.lastActivity) entry.lastActivity = todo.updatedAt; + byName.set(name, entry); + } + // Unfiled sorts last whatever its size: it is a holding pen, not a project. + return [...byName.values()].sort( + (a, b) => + Number(a.name === UNFILED_LABEL) - Number(b.name === UNFILED_LABEL) || b.open - a.open || a.name.localeCompare(b.name), + ); +} diff --git a/tsconfig.client-test.json b/tsconfig.client-test.json new file mode 100644 index 0000000..6026a5b --- /dev/null +++ b/tsconfig.client-test.json @@ -0,0 +1,17 @@ +{ + "//": "Tests FOR the browser half. They drive DOM-typed code but run under node's test runner, so this is the one place both type sets are legitimately in scope. Keeping it separate is what stops `document` leaking into server code (tsconfig.json) or `process` into client code (tsconfig.client.json).", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "outDir": "dist/web/client/app", + "rootDir": "src/web/client/app", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmitOnError": true + }, + "include": ["src/web/client/app/**/*.test.ts"] +} diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 0000000..95aa9ff --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "//": "The browser half. Separate from tsconfig.json because it needs the DOM lib and must NOT see node types — a client module that reaches for `process` or `fs` should fail here, not at runtime in someone's tab.", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist/web/client/app", + "rootDir": "src/web/client/app", + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmitOnError": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/web/client/app"], + "exclude": ["src/web/client/app/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 927fb94..6072b1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,7 @@ "declaration": false, "sourceMap": false }, - "include": ["src"] + "include": ["src"], + "//": "The browser half compiles separately, against the DOM lib — see tsconfig.client.json.", + "exclude": ["src/web/client/app"] }