From 987953088f701e76e8c24fe006bfcd1a4772a607 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Thu, 8 Oct 2026 12:15:25 -0400 Subject: [PATCH 1/6] Add design spec: Cursor section of the Bash upgrade guide, proven on the saved cases --- ...pgrade-guide-proven-on-the-saved-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md diff --git a/docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md b/docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md new file mode 100644 index 000000000..85a570f94 --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-cursor-section-of-the-bash-upgrade-guide-proven-on-the-saved-design.md @@ -0,0 +1,118 @@ +# Cursor section of the Bash upgrade guide, proven on the saved cases + +Change 0543 — Cursor section of the Bash upgrade guide, proven on the saved cases + +**Change:** 0543 · **Type:** docs · **Priority:** high · **Groomed:** 2026-10-08 · **Status:** Approved design + +## Purpose + +`docs/release/upgrading-from-bash.md` (change 0511) moves a Bash docket install to the Go binary +for Claude Code only. This change extends it to Cursor, proven by the same +`internal/bashupgrade` integration test against the saved `v0.9.2` and `v0.9.3` cases, which +already hold the Bash Cursor files. It must merge before the `v1.0.0-alpha.2` candidate is cut +(change 0512), so the test runs inside that candidate's source gate. + +No product code changes. If the test exposes something the binary does wrong, rather than +something the guide can explain, the build halts and that bug gets its own fix change, as in 0511. + +## What the saved cases show (traced 2026-10-08) + +A scratch reproduction ran the current `docket install --harness cursor` against each restored +saved home: + +| Path | v0.9.2 | v0.9.3 | +|---|---|---| +| `~/.cursor/agents/docket-*.md` | taken over by the installer (legacy byte match) | taken over, except `docket-plan-writer.md`: conflict | +| `~/.cursor/skills/docket-*` (links into the old checkout) | conflict | conflict | +| `~/.cursor/rules/docket-dispatch.mdc` (Bash's user-level dispatch rule) | removed by the installer (matches the frozen v0.9.2 rule) | conflict (v0.9.3's bytes differ from the frozen floor) | + +After `rm ~/.cursor/skills/docket-*` and +`rm -f ~/.cursor/agents/docket-plan-writer.md ~/.cursor/rules/docket-dispatch.mdc`, the second +install reports `applied` on both cases. `install check` is then clean except the `runtime.bash` +`obsolete-setting` warning, which the guide's existing global-config step clears. Nothing under +`~/.cursor` links into the old checkout. The installer also writes `~/.cursor/hooks.json`. + +The v0.9.3 rule conflict is by design (ADR-0096: the legacy reproducer is a frozen v0.9.2 +floor), so it is a guide remedy, the same way `docket-plan-writer.md` already is for Claude Code. + +There is no Cursor counterpart of the repository `CLAUDE.md` dispatch block: Bash wrote its Cursor +rule at user level. The Go binary writes `.cursor/rules/docket-dispatch.mdc` per repository when +`agent_harnesses` lists `cursor`, and the guide's `.gitignore` block already ignores it. + +## Design (human, 2026-10-08) + +**One combined path.** The guide has a single flow for Claude Code and Cursor. Each harness's +lines are labelled, and the reader drops the harness they don't use. The test runs each saved case +once, through both harnesses. A separate Claude-only run is not kept: it would differ by one +`--harness` flag, and each harness's install targets are separate. Every existing Claude Code step +and assertion stays, because the test is what keeps the guide's Claude Code half true against each +new binary. + +### Guide changes + +- **§1 Who this is for.** "You use docket with Claude Code, Cursor, or both." Cursor is covered; + OpenCode follows in a later pre-release; Codex is not supported. +- **§3 Install the docket binary.** `VERSION=v1.0.0-alpha.2`. The installer line becomes + `sh install.sh --harness claude --harness cursor`, with a sentence to drop the `--harness` you + don't use. The text about the first run stopping with `conflict` lines is unchanged. +- **§4** becomes "Take over the old Claude Code and Cursor install". + - The existing Claude Code text and its marked `takeover-remedy` step stay. Its re-run line + becomes the same two-harness `sh install.sh` line as §3. + - A Cursor paragraph: the installer takes over Bash's agent files under `~/.cursor/agents/` by + itself. It reports as conflicts every `docket-*` link under `~/.cursor/skills/`, and on `v0.9.3` + `~/.cursor/agents/docket-plan-writer.md` and Bash's user-level rule + `~/.cursor/rules/docket-dispatch.mdc`. On `v0.9.2` the installer removes that rule itself. + - A new marked step, `cursor-takeover-remedy`, run before the re-run: + + ```sh + rm ~/.cursor/skills/docket-* + rm -f ~/.cursor/agents/docket-plan-writer.md ~/.cursor/rules/docket-dispatch.mdc + ``` + + - The global-config cleanup and `confirm-install` steps are unchanged. +- **§5 Upgrade each repository.** The final `configure-harnesses` example becomes + `--harnesses claude,cursor`. For Cursor, it writes `.cursor/rules/docket-dispatch.mdc` into the + repository, which the `.gitignore` block keeps out of commits. +- **§7 Leftovers you can delete.** + - Nothing under `~/.claude` or `~/.cursor` points into the old checkout after the upgrade. The + links Bash made under `~/.codex` and `~/.agents` still do. Using docket from OpenCode on an + upgraded repository is not supported until its section arrives; Codex is not supported. + - The marked `repo-agent-files` step also runs `rm -f .cursor/agents/docket-*.md`. The saved + cases carry no repository Cursor agent files, so the guide says the test does not prove that + line. + - A sentence that docket must run outside Cursor's sandbox, linking `docs/install/cursor.md`. +- **§8** becomes "Restart Claude Code and Cursor": quit each and start it again; both load agents + and skills at startup. + +### Test changes (`internal/bashupgrade`) + +The guide-driven run (`TestIntegrationBashUpgradeGuide`) executes the new and changed marked steps +like every other. New assertions: + +- The first install's conflicts include every `~/.cursor/skills/docket-*` link on both cases, and on + `v0.9.3` also `~/.cursor/agents/docket-plan-writer.md` and `~/.cursor/rules/docket-dispatch.mdc`. +- After the guide, `~/.cursor/rules/docket-dispatch.mdc` does not exist on either case. +- The clean end state covers both harnesses: `docket install check --json` reports `cursor` among + its harnesses with no error or warning finding. +- Nothing under `~/.cursor` links into the old checkout. The existing assertion that `~/.cursor` + still has such links flips to require zero; `~/.codex` and `~/.agents` keep requiring at least one. +- The clone has `.cursor/rules/docket-dispatch.mdc` after the final `configure-harnesses` step, + with any guide-shape check the test already applies to marked steps. + +Existing assertions stay, including the guide-shape and finding/settings table checks. The test +stays in the integration build tag and inside the whole-suite gate. + +## Out of scope + +- OpenCode (alpha.3, change 0513) and Codex. +- The release itself (change 0512). +- Retiring the saved cases and the test (change 0514). +- A Claude-only test run, and new saved cases (for example repository `.cursor/agents` files). +- Any change to the binary's takeover rules, including teaching the frozen floor v0.9.3's rule. + +## Acceptance + +- The guide reads as above, and every marked step runs in the test. +- `TestBashUpgrade` passes on both saved cases with the new Cursor assertions, inside the whole + suite. +- Mutation check: deleting the `cursor-takeover-remedy` step from the guide makes the test fail. From 99e0c509dedf1b903ddd5d9ed1e5d287e24d0ee5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Thu, 8 Oct 2026 12:27:47 -0400 Subject: [PATCH 2/6] test(bashupgrade): add install-line, prose and pending-path helpers for the Cursor guide path --- internal/bashupgrade/registry_test.go | 68 ++++++++++++++++++++++ internal/bashupgrade/registry_unit_test.go | 68 ++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 internal/bashupgrade/registry_unit_test.go diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index 4aab7e125..ca7391978 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -190,6 +190,74 @@ func mustContain(t *testing.T, what, text, want string) { } } +// guideHarnesses are the harnesses the guide's combined path covers; the test runs +// every saved case once, through all of them. +var guideHarnesses = []string{"claude", "cursor"} + +// installLineHarnesses parses the guide's installer invocation: exactly `sh install.sh` +// followed by one or more `--harness ` pairs, each name one the downloader +// accepts and none repeated. It returns the names in line order. +func installLineHarnesses(line string) ([]string, bool) { + f := strings.Fields(line) + if len(f) < 4 || len(f)%2 != 0 || f[0] != "sh" || f[1] != "install.sh" { + return nil, false + } + known := map[string]bool{"claude": true, "codex": true, "cursor": true, "opencode": true} + seen := map[string]bool{} + var out []string + for i := 2; i < len(f); i += 2 { + if f[i] != "--harness" || !known[f[i+1]] || seen[f[i+1]] { + return nil, false + } + seen[f[i+1]] = true + out = append(out, f[i+1]) + } + return out, true +} + +// containsProse reports whether text contains want once every whitespace run in both +// is collapsed to one space, so a re-flowed guide paragraph still matches the claim. +func containsProse(text, want string) bool { + return strings.Contains(strings.Join(strings.Fields(text), " "), strings.Join(strings.Fields(want), " ")) +} + +func mustContainProse(t *testing.T, what, text, want string) { + t.Helper() + if !containsProse(text, want) { + t.Fatalf("%s does not contain %q (whitespace collapsed):\n%s", what, want, text) + } +} + +// pendingPathsLead introduces the paths `docket repository configure-harnesses` asks +// the reader to review and commit, in its human output. +const pendingPathsLead = "review and commit the pending paths: " + +// pendingPathsFrom returns, sorted, the paths after pendingPathsLead up to the end of +// that line, or nil when the output carries no such list. +func pendingPathsFrom(out string) []string { + _, rest, ok := strings.Cut(out, pendingPathsLead) + if !ok { + return nil + } + line, _, _ := strings.Cut(rest, "\n") + var paths []string + for _, p := range strings.Split(line, ",") { + if p = strings.TrimSpace(p); p != "" { + paths = append(paths, p) + } + } + sort.Strings(paths) + return paths +} + +// sameSet reports whether a and b hold the same strings, ignoring order. +func sameSet(a, b []string) bool { + x, y := append([]string(nil), a...), append([]string(nil), b...) + sort.Strings(x) + sort.Strings(y) + return strings.Join(x, "\x00") == strings.Join(y, "\x00") && len(x) == len(y) +} + // findingObjects decodes one JSON document and returns, in document order, every // object carrying string code and severity fields (the shape findingCodes reads). func findingObjects(t *testing.T, stdout string) []map[string]any { diff --git a/internal/bashupgrade/registry_unit_test.go b/internal/bashupgrade/registry_unit_test.go new file mode 100644 index 000000000..f3b745d24 --- /dev/null +++ b/internal/bashupgrade/registry_unit_test.go @@ -0,0 +1,68 @@ +package bashupgrade + +import ( + "strings" + "testing" +) + +func TestInstallLineHarnesses(t *testing.T) { + for line, want := range map[string][]string{ + "sh install.sh --harness claude --harness cursor": {"claude", "cursor"}, + "sh install.sh --harness cursor": {"cursor"}, + "sh install.sh --harness claude": {"claude"}, + "sh install.sh --harness cursor --harness claude": {"cursor", "claude"}, + } { + got, ok := installLineHarnesses(line) + if !ok || strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("%q: got %v, %v; want %v", line, got, ok, want) + } + } + for _, line := range []string{ + "sh install.sh", + "sh install.sh --harness", + "sh install.sh --harness claude extra", + "sh install.sh --harness claude --harness claude", + "sh install.sh --harness vim", + "sh install.sh --harnesses claude", + "bash install.sh --harness claude", + "sh ./install.sh --harness claude", + } { + if got, ok := installLineHarnesses(line); ok { + t.Errorf("%q was accepted as an installer line (%v)", line, got) + } + } +} + +func TestContainsProse(t *testing.T) { + if !containsProse("one two\n three\tfour", "two three four") { + t.Error("a wrapped sentence did not match its one-line form") + } + if !containsProse("On `v0.9.2` the installer\nremoves that rule itself.", "On `v0.9.2` the installer removes that rule itself.") { + t.Error("a wrapped guide sentence did not match") + } + if containsProse("one two three", "two four") { + t.Error("different words matched") + } + if containsProse("onetwo", "one two") { + t.Error("collapsing must not delete whitespace between words") + } +} + +func TestPendingPathsFrom(t *testing.T) { + out := "agent harnesses set to `[claude, cursor]` (needs-review); review and commit the pending paths: CLAUDE.md, .docket.yml\nnext line\n" + if got := pendingPathsFrom(out); strings.Join(got, ",") != ".docket.yml,CLAUDE.md" { + t.Fatalf("got %v", got) + } + if got := pendingPathsFrom("agent harnesses set to `[claude]` (healthy)\n"); got != nil { + t.Fatalf("no lead must give nil, got %v", got) + } +} + +func TestSameSet(t *testing.T) { + if !sameSet([]string{"b", "a"}, []string{"a", "b"}) { + t.Error("order mattered") + } + if sameSet([]string{"a"}, []string{"a", "b"}) || sameSet([]string{"a", "a"}, []string{"a", "b"}) { + t.Error("different sets compared equal") + } +} From abbd7a93844512cc1e38246f82b12a00334faf41 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Thu, 8 Oct 2026 12:31:01 -0400 Subject: [PATCH 3/6] docs(release): take over Bash's Cursor install in the upgrade guide, proven on the saved cases --- docs/release/upgrading-from-bash.md | 56 ++++-- .../bashupgrade_integration_test.go | 25 ++- internal/bashupgrade/registry_test.go | 188 ++++++++++++++---- 3 files changed, 210 insertions(+), 59 deletions(-) diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 4664a1561..5734237d2 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -12,11 +12,11 @@ Use this guide if all of these are true: - You installed Bash docket from the `v0.9.2` or `v0.9.3` tag. - Your repositories keep their docket records on a separate `docket` branch. -- You use docket with Claude Code. +- You use docket with Claude Code, Cursor, or both. Repositories that keep their records on the default branch (the single-branch layout) are not -covered. The guide covers Claude Code only. Sections for Cursor and OpenCode arrive in later -pre-releases. Codex is not supported. +covered. The guide covers Claude Code and Cursor. OpenCode follows in a later pre-release. Codex is +not supported. ## 2. Before you start @@ -28,16 +28,17 @@ pre-releases. Codex is not supported. ## 3. Install the docket binary Run these commands outside any repository. They download the installer and the checksum file into -a new folder, check the installer against the checksum, then run it for Claude Code. +a new folder, check the installer against the checksum, then run it for Claude Code and Cursor. If +you use only one of them, drop the other one's `--harness` flag, here and in section 4. ```sh mkdir -p ~/docket-download && cd ~/docket-download -VERSION=v1.0.0-alpha.1 +VERSION=v1.0.0-alpha.2 curl -fsSLO "https://github.com/danielhanold/docket/releases/download/$VERSION/install.sh" curl -fsSLO "https://github.com/danielhanold/docket/releases/download/$VERSION/checksums.txt" grep ' install.sh$' checksums.txt | shasum -a 256 -c - -sh install.sh --harness claude +sh install.sh --harness claude --harness cursor ``` The `shasum` line must print `install.sh: OK`. If it prints anything else, stop and do not run @@ -53,7 +54,7 @@ clears the conflicts. If you build docket from a source checkout instead, see [Installing docket](../install/install.md). That route needs Go and is not part of this guide. -## 4. Take over the old Claude Code install +## 4. Take over the old Claude Code and Cursor install The installer takes over the agent files Bash docket wrote under `~/.claude/agents/` by itself, as long as they still hold exactly what the Bash installer wrote. @@ -65,14 +66,30 @@ you to move or delete it. For a Bash `v0.9.2` or `v0.9.3` install, that list is: Deleting a link leaves the checkout alone. - on `v0.9.3` only, `~/.claude/agents/docket-plan-writer.md`. -Delete them, then run the installer again from the same folder. The failed run left no `docket` -command, so run `sh install.sh` again, not `docket install`. +For Cursor, the installer takes over Bash's agent files under `~/.cursor/agents/` by itself in the +same way. It reports these Cursor paths as conflicts: + +- every `docket-*` link under `~/.cursor/skills/`. These also point into your old Bash checkout. +- on `v0.9.3` only, `~/.cursor/agents/docket-plan-writer.md` and Bash's user-level rule + `~/.cursor/rules/docket-dispatch.mdc`. On `v0.9.2` the installer removes that rule itself. + +Delete the Cursor paths first. If you don't use Cursor, skip this block. + + +```sh +rm ~/.cursor/skills/docket-* +rm -f ~/.cursor/agents/docket-plan-writer.md ~/.cursor/rules/docket-dispatch.mdc +``` + +Then delete the Claude Code paths and run the installer again from the same folder, with the same +`--harness` flags as in section 3. If you don't use Claude Code, leave out the two `rm` lines. The +failed run left no `docket` command, so run `sh install.sh` again, not `docket install`. ```sh rm ~/.claude/skills/docket-* rm -f ~/.claude/agents/docket-plan-writer.md -sh install.sh --harness claude +sh install.sh --harness claude --harness cursor ``` This time the installer reports `install: applied`. @@ -277,11 +294,14 @@ does not mention them. Delete them: The test covers zsh, where those lines are in `~/.zshenv`. Other shells keep them in a different startup file, which the test does not cover. -Your old Bash checkout, usually `~/dev/docket`, is no longer used by Claude Code: nothing under -`~/.claude` points into it after the upgrade. The links Bash docket made for other tools, under -`~/.cursor`, `~/.codex` and `~/.agents`, still do. The guide does not cover those tools. Using -docket from Cursor or OpenCode on an upgraded repository is not supported until their sections -arrive, and Codex is not supported at all (see section 1). +Your old Bash checkout, usually `~/dev/docket`, is no longer used by Claude Code or Cursor: nothing +under `~/.claude` or `~/.cursor` points into it after the upgrade. The links Bash docket made for +other tools, under `~/.codex` and `~/.agents`, still do. The guide does not cover those tools. +Using docket from OpenCode on an upgraded repository is not supported until its section arrives, +and Codex is not supported at all (see section 1). + +In Cursor, docket must run outside Cursor's sandbox. [Running docket under Cursor](../install/cursor.md) +shows the permission setup that allows it. Bash docket also wrote agent files into a repository when its `.docket.yml` has an `agents:` setting: one `docket-*.md` file per agent under the repository's `.claude/agents/` folder. Claude @@ -304,10 +324,10 @@ default branch, such as `Bash(git -C * push origin HEAD:main)`, and it can also your own. This guide does not cover that file: the test leaves it in place and does not show whether deleting it is safe. -## 8. Restart Claude Code +## 8. Restart Claude Code and Cursor -Quit Claude Code and start it again. Claude Code loads agents and skills when it starts, so -clearing a conversation is not enough. +Quit Claude Code and Cursor and start each one again. Both load agents and skills when they start, +so clearing a conversation is not enough. ## 9. If something goes wrong diff --git a/internal/bashupgrade/bashupgrade_integration_test.go b/internal/bashupgrade/bashupgrade_integration_test.go index 4040244a7..a666e0e6a 100644 --- a/internal/bashupgrade/bashupgrade_integration_test.go +++ b/internal/bashupgrade/bashupgrade_integration_test.go @@ -4,6 +4,8 @@ package bashupgrade import ( "encoding/json" + "errors" + "io/fs" "os" "path/filepath" "reflect" @@ -106,6 +108,9 @@ func TestIntegrationBashUpgradeGuide(t *testing.T) { t.Fatalf("the remedy did not lead to a second install run") } assertCleanEndState(t, c) + if _, err := os.Lstat(filepath.Join(c.Home, ".cursor", "rules", "docket-dispatch.mdc")); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Bash's user-level Cursor rule ~/.cursor/rules/docket-dispatch.mdc is still there after the guide (%v)", err) + } assertRecordsSurvive(t, c, before) if after, _ := os.ReadFile(filepath.Join(c.Clone, "CLAUDE.md")); strings.Contains(string(after), "docket:dispatch:") { t.Errorf("the repository CLAUDE.md still carries the Bash dispatch block after the guide") @@ -148,7 +153,8 @@ func TestIntegrationBashUpgradeGuide(t *testing.T) { } } -// assertCleanEndState: install check, repository check and status all clean. +// assertCleanEndState: install check (for every harness the guide installs), +// repository check and status all clean. func assertCleanEndState(t *testing.T, c *upgradeCase) { t.Helper() for _, args := range [][]string{{"--json", "install", "check"}, {"--json", "repository", "check"}} { @@ -157,6 +163,23 @@ func assertCleanEndState(t *testing.T, c *upgradeCase) { t.Errorf("docket %s exited %d\nstdout:\n%s\nstderr:\n%s", strings.Join(args, " "), r.Code, r.Stdout, r.Stderr) continue } + if args[1] == "install" { + var doc struct { + Harnesses []string `json:"harnesses"` + } + if err := json.Unmarshal([]byte(r.Stdout), &doc); err != nil { + t.Errorf("decode install check JSON: %v\n%s", err, r.Stdout) + } + have := map[string]bool{} + for _, h := range doc.Harnesses { + have[h] = true + } + for _, h := range guideHarnesses { + if !have[h] { + t.Errorf("docket install check reports harnesses %v; the guide installs %s", doc.Harnesses, h) + } + } + } for _, f := range findingCodes(t, r.Stdout) { if f.Severity == "error" || f.Severity == "warning" { t.Errorf("docket %s still reports %s (%s)", strings.Join(args, " "), f.Code, f.Severity) diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index ca7391978..c7e1adfa7 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -43,13 +43,14 @@ type runState struct { Conflicts []string Observed map[string][]findingRef - Guide string // the full guide text, for prose and table checks - Cwd string // the reader's terminal directory after the last block - DownloadDir string // where install-binary downloaded install.sh - InstallLine string // install-binary's installer invocation, re-run by takeover-remedy - CheckJSON string // repo-check's --json stdout - Settings []string // settings-table patterns config-cleanup matched to a finding - Removed []string // settings config-cleanup removed from .docket.yml + Guide string // the full guide text, for prose and table checks + Cwd string // the reader's terminal directory after the last block + DownloadDir string // where install-binary downloaded install.sh + InstallLine string // install-binary's installer invocation, re-run by takeover-remedy + InstallHarnesses []string // the --harness names of InstallLine, in line order + CheckJSON string // repo-check's --json stdout + Settings []string // settings-table patterns config-cleanup matched to a finding + Removed []string // settings config-cleanup removed from .docket.yml DispatchBlockRemoved bool // dispatch-block found and removed the CLAUDE.md block RepoAgentFilesShadow int // repository agent files that hid an installed agent before repo-agent-files @@ -59,24 +60,25 @@ type runState struct { type stepAction func(t *testing.T, c *upgradeCase, st *runState, body string) var stepRegistry = map[string]stepAction{ - "install-binary": mirrorInstallBinary, - "takeover-remedy": mirrorTakeoverRemedy, - "global-config-cleanup": mirrorGlobalConfigCleanup, - "confirm-install": runConfirmInstall, - "repo-prepare": runRepoPrepare, - "repo-check": observeRepoCheck, - "fix-gitignore": mirrorFixGitignore, - "config-cleanup": mirrorConfigCleanup, - "commit-fixes": runCommitFixes, - "configure-tests": runPlain, - "configure-harnesses": runPlain, - "commit-config": runCommit, - "repair-preview": observeRepairPreview, - "repair-apply": runRepairApply, - "repo-confirm": runRepoConfirm, - "dispatch-block": mirrorDispatchBlock, - "leftovers": mirrorLeftovers, - "repo-agent-files": runRepoAgentFiles, + "install-binary": mirrorInstallBinary, + "cursor-takeover-remedy": mirrorCursorTakeoverRemedy, + "takeover-remedy": mirrorTakeoverRemedy, + "global-config-cleanup": mirrorGlobalConfigCleanup, + "confirm-install": runConfirmInstall, + "repo-prepare": runRepoPrepare, + "repo-check": observeRepoCheck, + "fix-gitignore": mirrorFixGitignore, + "config-cleanup": mirrorConfigCleanup, + "commit-fixes": runCommitFixes, + "configure-tests": runPlain, + "configure-harnesses": runPlain, + "commit-config": runCommit, + "repair-preview": observeRepairPreview, + "repair-apply": runRepairApply, + "repo-confirm": runRepoConfirm, + "dispatch-block": mirrorDispatchBlock, + "leftovers": mirrorLeftovers, + "repo-agent-files": runRepoAgentFiles, } // Guide headings the table checks anchor on. @@ -441,6 +443,18 @@ func snapshotTree(t *testing.T, dir string) map[string]string { return out } +// harnessHomesSnapshot snapshots every harness folder the guide's install writes. +func harnessHomesSnapshot(t *testing.T, c *upgradeCase) map[string]string { + t.Helper() + out := map[string]string{} + for _, d := range []string{".claude", ".cursor"} { + for k, v := range snapshotTree(t, filepath.Join(c.Home, d)) { + out[k] = v + } + } + return out +} + func gitRev(t *testing.T, c *upgradeCase, ref string) string { t.Helper() return strings.TrimSpace(c.mustGit(t, c.Origin, "rev-parse", ref)) @@ -481,21 +495,25 @@ func mirrorInstallBinary(t *testing.T, c *upgradeCase, st *runState, body string sawChecksums = true case strings.Contains(line, "checksums.txt") && strings.Contains(line, "shasum -a 256 -c"): sawVerify = true - case line == "sh install.sh --harness claude": - st.InstallLine = line + case f[0] == "sh": + hs, ok := installLineHarnesses(line) + if !ok || !sameSet(hs, guideHarnesses) { + t.Fatalf("install-binary: installer line %q must be `sh install.sh` with one --harness per covered harness %v", line, guideHarnesses) + } + st.InstallLine, st.InstallHarnesses = line, hs default: t.Fatalf("install-binary: line %q is not a shape the test mirrors", line) } } if st.DownloadDir == "" || !sawInstallSh || !sawChecksums || !sawVerify || st.InstallLine == "" { - t.Fatalf("install-binary block must make a download folder, fetch install.sh and checksums.txt, verify with shasum -a 256 -c, and run `sh install.sh --harness claude`:\n%s", body) + t.Fatalf("install-binary block must make a download folder, fetch install.sh and checksums.txt, verify with shasum -a 256 -c, and run the installer for every covered harness:\n%s", body) } installHandoff(t, c, st) } // installHandoff mirrors internal/release/downloader/install.sh from the point it // has a verified binary: stage it beside the destination, run the staged binary's -// `install --harness claude`, and only on success move it into place, place the +// `install` with one `--harness` per harness on the guide's installer line, and only on success move it into place, place the // relative dckt alias beside it, write the ownership record (naming the alias), and // run `docket install check`. func installHandoff(t *testing.T, c *upgradeCase, st *runState) { @@ -518,10 +536,14 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) { if err := os.WriteFile(stage, bin, 0o755); err != nil { t.Fatal(err) } - before := snapshotTree(t, filepath.Join(c.Home, ".claude")) + args := []string{"install"} + for _, h := range st.InstallHarnesses { + args = append(args, "--harness", h) + } + before := harnessHomesSnapshot(t, c) // The downloader runs the staged binary in human mode, so the reader sees these // lines; the guide quotes them. - r := c.run(t, st.DownloadDir, stage, "install", "--harness", "claude") + r := c.run(t, st.DownloadDir, stage, args...) st.InstallAttempts = append(st.InstallAttempts, r) human := r.Stdout + r.Stderr if r.Code != 0 { @@ -532,7 +554,7 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) { // A failed run changes nothing, so a --json re-run reads the same conflict // set in a form the test can parse; the human output must list each path on // a conflict line. - j := c.run(t, st.DownloadDir, stage, "--json", "install", "--harness", "claude") + j := c.run(t, st.DownloadDir, stage, append([]string{"--json"}, args...)...) _ = os.Remove(stage) var conflicts []string var doc struct { @@ -567,8 +589,8 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) { if _, err := os.Lstat(dest); err == nil { t.Fatalf("a failed install left %s behind", dest) } - if after := snapshotTree(t, filepath.Join(c.Home, ".claude")); !mapsEqual(before, after) { - t.Fatalf("a failed install changed ~/.claude") + if after := harnessHomesSnapshot(t, c); !mapsEqual(before, after) { + t.Fatalf("a failed install changed ~/.claude or ~/.cursor") } st.Conflicts = conflicts return @@ -679,6 +701,84 @@ func mirrorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body strin } } +// The Cursor paths the guide's cursor-takeover-remedy block deletes, and the guide's +// claims about them. +const ( + cursorSkillsGlob = "~/.cursor/skills/docket-*" + cursorPlanWriter = "~/.cursor/agents/docket-plan-writer.md" + cursorUserRule = "~/.cursor/rules/docket-dispatch.mdc" + cursorRuleSelfRemovedProse = "On `v0.9.2` the installer removes that rule itself." + cursorRemedySkipProse = "If you don't use Cursor, skip this block." + cursorSandboxLink = "](../install/cursor.md)" +) + +// mirrorCursorTakeoverRemedy runs the block's Cursor deletes after proving the first +// install's Cursor conflicts are exactly the ones the guide lists: every +// ~/.cursor/skills/docket-* link, plus, on v0.9.3 only, docket-plan-writer.md and Bash's +// user-level rule. On v0.9.2 the rule is present but no conflict, which is the +// installer's side of the guide's "removes that rule itself" (the block's rm -f gets +// there first; internal/install's TestRetireCursorFileLegacyBytes proves the removal). +// The installer re-run stays in takeover-remedy, so this block must come first. +func mirrorCursorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body string) { + t.Helper() + lines := blockLines(body) + if len(lines) == 0 { + t.Fatalf("cursor-takeover-remedy block is empty") + } + for _, l := range lines { + if !strings.HasPrefix(l, "rm ") { + t.Fatalf("cursor-takeover-remedy line %q is not a delete the test mirrors; the installer re-run belongs to takeover-remedy", l) + } + } + for _, p := range []string{cursorSkillsGlob, cursorPlanWriter, cursorUserRule} { + mustContain(t, "cursor-takeover-remedy block", body, p) + } + mustContainProse(t, "guide", st.Guide, cursorRuleSelfRemovedProse) + mustContainProse(t, "guide", st.Guide, cursorRemedySkipProse) + if len(st.Conflicts) == 0 { + t.Fatalf("cursor-takeover-remedy: the first install reported no conflict; the guide says every %s link conflicts", cursorSkillsGlob) + } + links, _ := filepath.Glob(c.expandHome(cursorSkillsGlob)) + if len(links) == 0 { + t.Fatalf("population floor: the saved %s home has no %s link", c.Tag, cursorSkillsGlob) + } + planWriter, rule := c.expandHome(cursorPlanWriter), c.expandHome(cursorUserRule) + want := map[string]bool{} + for _, l := range links { + want[l] = true + } + if c.Tag == "v0.9.3" { + want[planWriter], want[rule] = true, true + } else if _, err := os.Lstat(rule); err != nil { + t.Errorf("guide says the installer removes Bash's rule itself on %s, but the saved home has no %s: %v", c.Tag, rule, err) + } + got := map[string]bool{} + for _, p := range st.Conflicts { + if strings.HasPrefix(p, filepath.Join(c.Home, ".cursor")+"/") { + got[p] = true + } + } + for p := range want { + if !got[p] { + t.Errorf("guide says %s conflicts on %s; the installer did not report it", p, c.Tag) + } + } + for p := range got { + if !want[p] { + t.Errorf("the installer reported %s as a conflict on %s, which the guide does not list", p, c.Tag) + } + } + if t.Failed() { + t.FailNow() + } + runBlock(t, c, st, body) + for p := range want { + if _, err := os.Lstat(p); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("cursor-takeover-remedy left the conflict path %s in place", p) + } + } +} + // mirrorGlobalConfigCleanup deletes the marked runtime.bash block from the global // config, after checking it is the block the guide shows. func mirrorGlobalConfigCleanup(t *testing.T, c *upgradeCase, st *runState, body string) { @@ -1169,22 +1269,30 @@ func mirrorLeftovers(t *testing.T, c *upgradeCase, st *runState, body string) { t.Fatalf("leftovers line %q names a path the test does not mirror", line) } } - // Guide: nothing under ~/.claude points into the old checkout; the other tools' - // links under ~/.cursor, ~/.codex and ~/.agents still do. The guide states that + // Guide: nothing under ~/.claude or ~/.cursor points into the old checkout; the + // other tools' links under ~/.codex and ~/.agents still do. The guide states that // using docket from those tools on an upgraded repository is not supported, rather // than advising to keep using Bash docket there: the test observes only the links. mustContain(t, "guide", st.Guide, "`~/dev/docket`") - mustContain(t, "guide", st.Guide, "on an upgraded repository is not supported") + mustContainProse(t, "guide", st.Guide, "on an upgraded repository is not supported") checkout := filepath.Join(c.Home, "dev", "docket") - if n := linksInto(t, filepath.Join(c.Home, ".claude"), checkout); n != 0 { - t.Errorf("guide says nothing under ~/.claude points into the old checkout; %d links do", n) + for _, d := range []string{".claude", ".cursor"} { + mustContain(t, "guide", st.Guide, "`~/"+d+"`") + if n := linksInto(t, filepath.Join(c.Home, d), checkout); n != 0 { + t.Errorf("guide says nothing under ~/%s points into the old checkout; %d links do", d, n) + } } - for _, d := range []string{".cursor", ".codex", ".agents"} { + for _, d := range []string{".codex", ".agents"} { mustContain(t, "guide", st.Guide, "`~/"+d+"`") if linksInto(t, filepath.Join(c.Home, d), checkout) == 0 { t.Errorf("guide says links under ~/%s still point into the old checkout; none do", d) } } + // Guide: docket must run outside Cursor's sandbox, with a link to the Cursor page. + mustContain(t, "guide", st.Guide, cursorSandboxLink) + if _, err := os.Stat(filepath.Join(repoRoot(t), "docs", "install", "cursor.md")); err != nil { + t.Errorf("the guide links %s, which does not exist: %v", cursorSandboxLink, err) + } } // The per-repository leftovers the guide names. repoAgentGlob is the exact block line From ffea3467fb7aa748262cdd4f9a078a3a678bb287 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Thu, 8 Oct 2026 12:34:37 -0400 Subject: [PATCH 4/6] docs(release): choose Claude Code and Cursor per repository in the upgrade guide, proven on the saved cases --- docs/release/upgrading-from-bash.md | 32 ++++-- .../bashupgrade_integration_test.go | 7 +- internal/bashupgrade/registry_test.go | 100 ++++++++++++++++-- 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 5734237d2..0216b499c 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -194,9 +194,8 @@ docket repository configure-tests Next, choose the coding agents docket writes instructions for. On a terminal, `docket repository configure-harnesses` shows a checklist. The command below records "none yet", -because Bash docket's `CLAUDE.md` block would get in the way. After you remove that block (the last -step), run `docket repository configure-harnesses --harnesses claude` (or your agents) and commit -the result. +because Bash docket's `CLAUDE.md` block would get in the way. The last step of this section, after +you remove that block, chooses your agents. ```sh @@ -253,9 +252,19 @@ git commit -am "Remove the Bash docket block from CLAUDE.md" git push ``` -Last, choose your coding agents, which the configure-harnesses step left as "none yet": run -`docket repository configure-harnesses --harnesses ` (for example `claude`) and commit the -paths it lists. +Last, choose your coding agents, which the configure-harnesses step left as "none yet". The command +lists the paths to review and commit. For Claude Code, it writes docket's own block into `CLAUDE.md`. +For Cursor, it writes `.cursor/rules/docket-dispatch.mdc` into the repository, which the +`.gitignore` block keeps out of commits. Drop the agent you don't use from `--harnesses`, and if you +don't use Claude Code, leave `CLAUDE.md` off the `git add` line. + + +```sh +docket repository configure-harnesses --harnesses claude,cursor +git add .docket.yml CLAUDE.md +git commit -m "Choose docket's agents" +git push +``` ## 6. Settings that changed @@ -304,15 +313,18 @@ In Cursor, docket must run outside Cursor's sandbox. [Running docket under Curso shows the permission setup that allows it. Bash docket also wrote agent files into a repository when its `.docket.yml` has an `agents:` -setting: one `docket-*.md` file per agent under the repository's `.claude/agents/` folder. Claude -Code uses an agent file in the repository instead of the one with the same name in -`~/.claude/agents/`, so these old files hide the agents the installer just wrote. Delete them in -every repository. If a repository has none, the command does nothing. +setting: one `docket-*.md` file per agent under the repository's `.claude/agents/` folder, and for +Cursor under `.cursor/agents/`. Claude Code uses an agent file in the repository instead of the one +with the same name in `~/.claude/agents/`, so these old files hide the agents the installer just +wrote. Delete them in every repository. If a repository has none, the command does nothing. The +saved installs the test runs carry no repository Cursor agent files, so the test does not prove the +`.cursor/agents` line. ```sh cd rm -f .claude/agents/docket-*.md +rm -f .cursor/agents/docket-*.md ``` The `.gitignore` block from section 5 keeps these files out of commits, so there is nothing to diff --git a/internal/bashupgrade/bashupgrade_integration_test.go b/internal/bashupgrade/bashupgrade_integration_test.go index a666e0e6a..d6b76957d 100644 --- a/internal/bashupgrade/bashupgrade_integration_test.go +++ b/internal/bashupgrade/bashupgrade_integration_test.go @@ -112,8 +112,11 @@ func TestIntegrationBashUpgradeGuide(t *testing.T) { t.Errorf("Bash's user-level Cursor rule ~/.cursor/rules/docket-dispatch.mdc is still there after the guide (%v)", err) } assertRecordsSurvive(t, c, before) - if after, _ := os.ReadFile(filepath.Join(c.Clone, "CLAUDE.md")); strings.Contains(string(after), "docket:dispatch:") { - t.Errorf("the repository CLAUDE.md still carries the Bash dispatch block after the guide") + if after, _ := os.ReadFile(filepath.Join(c.Clone, "CLAUDE.md")); strings.Contains(string(after), bashDispatchSignature) { + t.Errorf("the repository CLAUDE.md still carries Bash docket's dispatch block (it runs docket.sh) after the guide") + } + if fi, err := os.Lstat(filepath.Join(c.Clone, ".cursor", "rules", "docket-dispatch.mdc")); err != nil || !fi.Mode().IsRegular() { + t.Errorf("the repository has no .cursor/rules/docket-dispatch.mdc after the guide (%v)", err) } dispatchRemoved = dispatchRemoved || st.DispatchBlockRemoved repoAgentShadow = repoAgentShadow || st.RepoAgentFilesShadow > 0 diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index c7e1adfa7..7d7cc858f 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -77,6 +77,7 @@ var stepRegistry = map[string]stepAction{ "repair-apply": runRepairApply, "repo-confirm": runRepoConfirm, "dispatch-block": mirrorDispatchBlock, + "choose-harnesses": runChooseHarnesses, "leftovers": mirrorLeftovers, "repo-agent-files": runRepoAgentFiles, } @@ -1145,6 +1146,9 @@ const ( // repository without the block, which mirrorDispatchBlock proves. dispatchBlockSkip = "If there is no such block, skip this step." dispatchBlockSkipWhy = "There is nothing to commit, and `git commit` stops with an error." + // bashDispatchSignature is what marks a dispatch block as Bash docket's: its body + // runs docket.sh. docket's own block shares the marker lines but never this. + bashDispatchSignature = "/docket.sh " ) // dispatchBlockSpan validates the dispatch block's markers in lines and returns the @@ -1218,7 +1222,7 @@ func mirrorDispatchBlock(t *testing.T, c *upgradeCase, st *runState, body string // Guide: the upgrade leaves the block in place and `docket repository check` // does not report it. repo-confirm already saw `healthy` with the block present. mustContain(t, "guide", st.Guide, "`docket.sh`") - if !strings.Contains(strings.Join(lines[start:end+1], "\n"), "/docket.sh ") { + if !strings.Contains(strings.Join(lines[start:end+1], "\n"), bashDispatchSignature) { t.Errorf("the guide says the block tells Claude to run Bash docket's docket.sh; it does not") } out := append(append([]string{}, lines[:start]...), lines[end+1:]...) @@ -1245,6 +1249,73 @@ func mirrorDispatchBlock(t *testing.T, c *upgradeCase, st *runState, body string st.DispatchBlockRemoved = true } +// The guide's final section-5 step: choose the agents, then commit what the command +// lists. The Cursor rule it writes is ignored, so only the listed paths are committed. +const ( + chooseHarnessesLine = "docket repository configure-harnesses --harnesses claude,cursor" + cursorRepoRule = ".cursor/rules/docket-dispatch.mdc" + cursorRepoRuleProse = "For Cursor, it writes `.cursor/rules/docket-dispatch.mdc` into the repository, which the `.gitignore` block keeps out of commits." + claudeRepoBlockProse = "For Claude Code, it writes docket's own block into `CLAUDE.md`." +) + +// runChooseHarnesses runs the block and checks the guide's claims: the git add line +// names exactly the paths configure-harnesses lists, the commit reaches origin main and +// leaves nothing behind, .docket.yml there records both harnesses, CLAUDE.md there +// carries docket's dispatch block (not Bash's), and the repository Cursor rule exists +// and is ignored by git. +func runChooseHarnesses(t *testing.T, c *upgradeCase, st *runState, body string) { + t.Helper() + lines := blockLines(body) + if len(lines) != 4 || lines[0] != chooseHarnessesLine || !strings.HasPrefix(lines[1], "git add ") || + !strings.HasPrefix(lines[2], "git commit -m ") || lines[3] != "git push" { + t.Fatalf("choose-harnesses block must be `%s`, `git add `, `git commit -m …` and `git push`:\n%s", chooseHarnessesLine, body) + } + mustContainProse(t, "guide", st.Guide, cursorRepoRuleProse) + mustContainProse(t, "guide", st.Guide, claudeRepoBlockProse) + if st.Cwd != c.Clone { + t.Fatalf("choose-harnesses runs in the repository; the reader is in %s", st.Cwd) + } + if left := strings.TrimSpace(c.mustGit(t, st.Cwd, "status", "--porcelain")); left != "" { + t.Fatalf("choose-harnesses starts from a clean repository; git status shows:\n%s", left) + } + before := gitRev(t, c, "main") + r := runBlock(t, c, st, body) + pending := pendingPathsFrom(r.Stdout + r.Stderr) + added := strings.Fields(strings.TrimPrefix(lines[1], "git add ")) + if len(pending) == 0 || !sameSet(pending, added) { + t.Fatalf("the guide's git add line names %v; configure-harnesses asked to commit %v\nstdout:\n%s\nstderr:\n%s", added, pending, r.Stdout, r.Stderr) + } + if gitRev(t, c, "main") == before { + t.Fatalf("choose-harnesses did not push a new commit to main") + } + if left := strings.TrimSpace(c.mustGit(t, st.Cwd, "status", "--porcelain")); left != "" { + t.Fatalf("choose-harnesses left changes behind:\n%s", left) + } + if fi, err := os.Lstat(filepath.Join(c.Clone, filepath.FromSlash(cursorRepoRule))); err != nil || !fi.Mode().IsRegular() { + t.Fatalf("configure-harnesses did not write %s into the repository (%v)", cursorRepoRule, err) + } + if ig := c.run(t, c.Clone, "git", "check-ignore", "-q", "--", cursorRepoRule); ig.Code != 0 { + t.Errorf("guide says the .gitignore block keeps %s out of commits; git does not ignore it", cursorRepoRule) + } + var cfg struct { + AgentHarnesses []string `yaml:"agent_harnesses"` + } + if err := yaml.Unmarshal([]byte(c.mustGit(t, c.Origin, "show", "main:.docket.yml")), &cfg); err != nil { + t.Fatalf("parse main:.docket.yml: %v", err) + } + if !sameSet(cfg.AgentHarnesses, guideHarnesses) { + t.Errorf("origin main:.docket.yml records agent_harnesses %v; the guide chooses %v", cfg.AgentHarnesses, guideHarnesses) + } + claudeLines := strings.Split(c.mustGit(t, c.Origin, "show", "main:CLAUDE.md"), "\n") + start, end, err := dispatchBlockSpan(claudeLines) + switch { + case err != nil || start < 0: + t.Errorf("origin main:CLAUDE.md has no docket dispatch block after choose-harnesses (%v)", err) + case strings.Contains(strings.Join(claudeLines[start:end+1], "\n"), bashDispatchSignature): + t.Errorf("origin main:CLAUDE.md carries Bash docket's dispatch block after choose-harnesses, not docket's") + } +} + // ---- section 7: leftovers ---------------------------------------------------------- // mirrorLeftovers deletes each leftover the block lists, after proving it exists and @@ -1298,8 +1369,10 @@ func mirrorLeftovers(t *testing.T, c *upgradeCase, st *runState, body string) { // The per-repository leftovers the guide names. repoAgentGlob is the exact block line // the test requires; repoSettings is the file the guide says it does not cover. const ( - repoAgentGlob = "rm -f .claude/agents/docket-*.md" - repoSettings = ".claude/settings.local.json" + repoAgentGlob = "rm -f .claude/agents/docket-*.md" + repoSettings = ".claude/settings.local.json" + repoCursorAgentGlob = "rm -f .cursor/agents/docket-*.md" + repoCursorAgentUnproven = "so the test does not prove the `.cursor/agents` line" ) // runRepoAgentFiles runs the block that deletes the repository's Bash agent files. @@ -1311,14 +1384,19 @@ const ( func runRepoAgentFiles(t *testing.T, c *upgradeCase, st *runState, body string) { t.Helper() lines := blockLines(body) - if len(lines) != 2 || lines[0] != "cd "+c.Clone || lines[1] != repoAgentGlob { - t.Fatalf("repo-agent-files block must be exactly `cd ` and `%s`:\n%s", repoAgentGlob, body) + if len(lines) != 3 || lines[0] != "cd "+c.Clone || lines[1] != repoAgentGlob || lines[2] != repoCursorAgentGlob { + t.Fatalf("repo-agent-files block must be exactly `cd `, `%s` and `%s`:\n%s", repoAgentGlob, repoCursorAgentGlob, body) } - mustContain(t, "guide", st.Guide, "these old files hide the agents the installer just wrote") - mustContain(t, "guide", st.Guide, "`"+repoSettings+"`") - mustContain(t, "guide", st.Guide, "the test leaves it in place") + mustContainProse(t, "guide", st.Guide, "these old files hide the agents the installer just wrote") + mustContainProse(t, "guide", st.Guide, "`"+repoSettings+"`") + mustContainProse(t, "guide", st.Guide, "the test leaves it in place") + mustContainProse(t, "guide", st.Guide, repoCursorAgentUnproven) glob := filepath.Join(c.Clone, ".claude", "agents", "docket-*.md") + cursorGlob := filepath.Join(c.Clone, ".cursor", "agents", "docket-*.md") + if found, _ := filepath.Glob(cursorGlob); len(found) > 0 { + t.Errorf("the saved %s clone carries repository Cursor agent files %v; the guide says the test does not prove the .cursor/agents line, so prove it and drop that sentence", c.Tag, found) + } before, _ := filepath.Glob(glob) for _, p := range before { rel := strings.TrimPrefix(p, c.Clone+"/") @@ -1341,8 +1419,10 @@ func runRepoAgentFiles(t *testing.T, c *upgradeCase, st *runState, body string) runBlock(t, c, st, body) - if left, _ := filepath.Glob(glob); len(left) > 0 { - t.Errorf("repo-agent-files left %v behind", left) + for _, g := range []string{glob, cursorGlob} { + if left, _ := filepath.Glob(g); len(left) > 0 { + t.Errorf("repo-agent-files left %v behind", left) + } } if left := strings.TrimSpace(c.mustGit(t, c.Clone, "status", "--porcelain")); left != "" { t.Errorf("guide says there is nothing to commit after repo-agent-files; git status shows:\n%s", left) From 972c142ea6e6a27dff56fe39b1e2057c3361c71c Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Thu, 8 Oct 2026 12:44:25 -0400 Subject: [PATCH 5/6] docs(upgrade): say the v0.9.2 Cursor rule is not a conflict and the block deletes it (review finding: the guide claims the installer removes the rule itself, but the first install fails and the block's rm -f deletes it first) --- docs/release/upgrading-from-bash.md | 3 ++- internal/bashupgrade/registry_test.go | 22 +++++++++++----------- internal/bashupgrade/registry_unit_test.go | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 0216b499c..2cefce815 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -71,7 +71,8 @@ same way. It reports these Cursor paths as conflicts: - every `docket-*` link under `~/.cursor/skills/`. These also point into your old Bash checkout. - on `v0.9.3` only, `~/.cursor/agents/docket-plan-writer.md` and Bash's user-level rule - `~/.cursor/rules/docket-dispatch.mdc`. On `v0.9.2` the installer removes that rule itself. + `~/.cursor/rules/docket-dispatch.mdc`. On `v0.9.2` it is not reported as a conflict; the block + below deletes it either way. Delete the Cursor paths first. If you don't use Cursor, skip this block. diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index 7d7cc858f..4d9a6a958 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -705,20 +705,20 @@ func mirrorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body strin // The Cursor paths the guide's cursor-takeover-remedy block deletes, and the guide's // claims about them. const ( - cursorSkillsGlob = "~/.cursor/skills/docket-*" - cursorPlanWriter = "~/.cursor/agents/docket-plan-writer.md" - cursorUserRule = "~/.cursor/rules/docket-dispatch.mdc" - cursorRuleSelfRemovedProse = "On `v0.9.2` the installer removes that rule itself." - cursorRemedySkipProse = "If you don't use Cursor, skip this block." - cursorSandboxLink = "](../install/cursor.md)" + cursorSkillsGlob = "~/.cursor/skills/docket-*" + cursorPlanWriter = "~/.cursor/agents/docket-plan-writer.md" + cursorUserRule = "~/.cursor/rules/docket-dispatch.mdc" + cursorRuleNoConflictProse = "On `v0.9.2` it is not reported as a conflict; the block below deletes it either way." + cursorRemedySkipProse = "If you don't use Cursor, skip this block." + cursorSandboxLink = "](../install/cursor.md)" ) // mirrorCursorTakeoverRemedy runs the block's Cursor deletes after proving the first // install's Cursor conflicts are exactly the ones the guide lists: every // ~/.cursor/skills/docket-* link, plus, on v0.9.3 only, docket-plan-writer.md and Bash's -// user-level rule. On v0.9.2 the rule is present but no conflict, which is the -// installer's side of the guide's "removes that rule itself" (the block's rm -f gets -// there first; internal/install's TestRetireCursorFileLegacyBytes proves the removal). +// user-level rule. On v0.9.2 the rule is present but no conflict, which is what the +// guide's "not reported as a conflict; the block below deletes it either way" claims: +// the first install fails on the skills conflicts, so the block's rm -f is what removes it. // The installer re-run stays in takeover-remedy, so this block must come first. func mirrorCursorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body string) { t.Helper() @@ -734,7 +734,7 @@ func mirrorCursorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body for _, p := range []string{cursorSkillsGlob, cursorPlanWriter, cursorUserRule} { mustContain(t, "cursor-takeover-remedy block", body, p) } - mustContainProse(t, "guide", st.Guide, cursorRuleSelfRemovedProse) + mustContainProse(t, "guide", st.Guide, cursorRuleNoConflictProse) mustContainProse(t, "guide", st.Guide, cursorRemedySkipProse) if len(st.Conflicts) == 0 { t.Fatalf("cursor-takeover-remedy: the first install reported no conflict; the guide says every %s link conflicts", cursorSkillsGlob) @@ -751,7 +751,7 @@ func mirrorCursorTakeoverRemedy(t *testing.T, c *upgradeCase, st *runState, body if c.Tag == "v0.9.3" { want[planWriter], want[rule] = true, true } else if _, err := os.Lstat(rule); err != nil { - t.Errorf("guide says the installer removes Bash's rule itself on %s, but the saved home has no %s: %v", c.Tag, rule, err) + t.Errorf("guide says the block deletes Bash's rule on %s, but the saved home has no %s: %v", c.Tag, rule, err) } got := map[string]bool{} for _, p := range st.Conflicts { diff --git a/internal/bashupgrade/registry_unit_test.go b/internal/bashupgrade/registry_unit_test.go index f3b745d24..4d39d60b1 100644 --- a/internal/bashupgrade/registry_unit_test.go +++ b/internal/bashupgrade/registry_unit_test.go @@ -37,7 +37,7 @@ func TestContainsProse(t *testing.T) { if !containsProse("one two\n three\tfour", "two three four") { t.Error("a wrapped sentence did not match its one-line form") } - if !containsProse("On `v0.9.2` the installer\nremoves that rule itself.", "On `v0.9.2` the installer removes that rule itself.") { + if !containsProse("On `v0.9.2` it is not reported as a conflict; the block\n below deletes it either way.", "On `v0.9.2` it is not reported as a conflict; the block below deletes it either way.") { t.Error("a wrapped guide sentence did not match") } if containsProse("one two three", "two four") { From 5669d3d998ec80d85912a15f3225d212a6e365a7 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Thu, 8 Oct 2026 12:45:24 -0400 Subject: [PATCH 6/6] docs(upgrade): address two review findings - section 7: say that a harness skipped in section 4 keeps its Bash links under ~/.claude or ~/.cursor, and point to that harness's rm lines - registry_test.go: re-flow the installHandoff doc comment to the column limit --- docs/release/upgrading-from-bash.md | 3 +++ internal/bashupgrade/registry_test.go | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 2cefce815..ff3d68450 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -310,6 +310,9 @@ other tools, under `~/.codex` and `~/.agents`, still do. The guide does not cove Using docket from OpenCode on an upgraded repository is not supported until its section arrives, and Codex is not supported at all (see section 1). +If you skipped a harness in section 4, its Bash links under `~/.claude` or `~/.cursor` still point +into the checkout; delete them with that harness's `rm` lines from section 4. + In Cursor, docket must run outside Cursor's sandbox. [Running docket under Cursor](../install/cursor.md) shows the permission setup that allows it. diff --git a/internal/bashupgrade/registry_test.go b/internal/bashupgrade/registry_test.go index 4d9a6a958..a75ea6a0f 100644 --- a/internal/bashupgrade/registry_test.go +++ b/internal/bashupgrade/registry_test.go @@ -514,9 +514,9 @@ func mirrorInstallBinary(t *testing.T, c *upgradeCase, st *runState, body string // installHandoff mirrors internal/release/downloader/install.sh from the point it // has a verified binary: stage it beside the destination, run the staged binary's -// `install` with one `--harness` per harness on the guide's installer line, and only on success move it into place, place the -// relative dckt alias beside it, write the ownership record (naming the alias), and -// run `docket install check`. +// `install` with one `--harness` per harness on the guide's installer line, and +// only on success move it into place, place the relative dckt alias beside it, +// write the ownership record (naming the alias), and run `docket install check`. func installHandoff(t *testing.T, c *upgradeCase, st *runState) { t.Helper() dest := filepath.Join(c.BinDir, "docket")