diff --git a/.abcd/development/plans/2026-07-08-v0.8.0.md b/.abcd/development/plans/2026-07-08-v0.8.0.md new file mode 100644 index 0000000..cf3817b --- /dev/null +++ b/.abcd/development/plans/2026-07-08-v0.8.0.md @@ -0,0 +1,530 @@ +# Plan: v0.8.0 — CLI-surface simplification + +Date: 2026-07-08 · Status: shipped in v0.8.0 +Owner: REPPL. Type: implementation plan (Diátaxis: one plan, no mixing). +Executor: a fresh agent session (no prior conversation context is assumed; +everything needed is in this repo, this plan, and the linked source). + +## Why + +v0.8.0 shrinks and clarifies the command surface before it hardens toward 1.0. +Two verbs (`export`/`import`) fold under one `bundle` noun; a redundant flag +(`apply --dry-run`, whose job `ferry diff` already does) is removed; the seven +`init` flags collapse toward a single `--wizard` mode; and the first-run/reconcile +directionality is made legible. The auto-release workflow is hardened against a +wedged version, and the dev-line version is bumped. + +**Policy for this release (maintainer decision, already made): HARD breaking +changes, NO deprecation aliases.** A removed/renamed flag or verb is gone — there +is no hidden alias, no "did you mean", no compatibility shim. Every break is +pre-1.0 and is called out in a **Breaking** CHANGELOG section (see +`docs/reference/compatibility.md` for the pre-1.0 rule). + +## Conventions for every PR below + +- One purpose per branch/PR; conventional-commit prefix with **no scope** + (`feat`/`fix`/`chore`/`refactor`/`docs`/`test`/`ci`). +- **No AI attribution** in any commit: no `Co-Authored-By`, no `Assisted-by`, no + "Generated with" line, no session link (per `AGENTS.md`). +- Definition of done for each code PR: `make build`, `gofmt -l .` (empty), + `go vet ./...`, `go test ./...` all clean, and the full eval suite green with + `FERRY_BIN` set. A PR that changes any command surface **must** re-run + `make gen-docs` and commit the regenerated `docs/reference/cli/` (CI fails on + drift), and hand-update the non-generated docs it touches. +- `make gen-docs` (`tools/gendocs`) `RemoveAll`s `docs/reference/cli/` and + regenerates the whole tree from `cmd.Root()`, so renamed/removed commands' pages + are deleted automatically and new ones appear — never hand-edit these files. + +--- + +## PR-1 — `chore`: bump the development-line version + +**Nature.** Trivial version bump; no behaviour change. + +**Files/edits.** +- `cmd/root.go:17` — `var version = "v0.4.0-dev"` → `var version = "v0.7.3-dev"`. + (The build overrides this at release via `-ldflags -X .../cmd.version=vX.Y.Z`; + the source constant just marks the in-progress line.) +- `docs/how-to/cutting-a-release.md:16` — prose names the "current development + line (`v0.4.0-dev`)"; update it to `v0.7.3-dev` so the how-to is not stale. + +**Tests/evals.** None assert the version string (verified: no test references +`0.4.0-dev`/`0.7.3-dev` and none reads `cmd.version`/`rootCmd.Version`). No new +test needed; `go test ./...` must still pass. + +**Docs.** The how-to edit above is the only doc touch. `make gen-docs` is not +required (the generated pages carry no version string). + +**CHANGELOG.** No user-facing entry required for a dev-line bump; optionally a +one-line `Changed` note. Not Breaking. + +--- + +## PR-2 — `ci`: harden `auto-release.yml` against a wedged version + +**Problem.** `detect` sets `should_release=true` only when the git **tag** is +missing (`git rev-parse refs/tags/$tag`, `auto-release.yml:70`). If the tag exists +but the GitHub **Release** failed to publish (a transient CI failure), the version +is permanently wedged: the tag blocks re-tagging and nothing re-attempts the +release. + +**Fix (exact shape).** `detect` computes and emits two independent booleans +instead of one `should_release`: +- `need_tag` — true when `refs/tags/$tag` does not exist (the current + `git rev-parse` check). +- `need_release` — true when either the tag is missing **or** the tag exists but + no published GitHub Release exists for it. + +Then: +- The `tag` job gates on `needs.detect.outputs.need_tag == 'true'` (so it never + tries to recreate an existing tag). +- The `release` job gates on `needs.detect.outputs.need_release == 'true'` and + keeps `needs: [detect, tag]`. Because `tag` may be skipped (tag already exists, + release missing), add `always()` to the release `if` so a skipped-but-not-failed + `tag` still lets `release` run, e.g. + `if: always() && needs.detect.outputs.need_release == 'true' && (needs.tag.result == 'success' || needs.tag.result == 'skipped')`. + This is a required review point — see the note below. + +**Release-existence probe + token model.** Checking whether a published Release +exists needs the API: `gh release view "$tag"` (exit 0 = published, non-zero = +absent) or `gh api repos/${{ github.repository }}/releases/tags/$tag`. `gh` needs +`GH_TOKEN`/`GITHUB_TOKEN` in the environment; `gh release view` reads fine under +`contents: read`, which `detect` already has — so **no permission elevation** on +`detect` is required, only exporting `GH_TOKEN: ${{ github.token }}` (or +`secrets.GITHUB_TOKEN`) into the probe step's `env`. + +**Injection-safety.** Keep the current no-`${{ }}`-in-script discipline: pass +`$tag` via the script's own shell var (already computed from the checkout) and the +token via `env:`, never interpolate untrusted values into the `run:` body. + +**This is a CI + token-model change and MUST get a `security-reviewer` pass before +merge** (it grants a job a token it did not previously use in a `run:` step and +changes the release-gating logic). A BLOCK verdict stops the change. + +**Files/edits.** `.github/workflows/auto-release.yml`: +- `detect` step `id: detect` (lines 52-76): after computing `version`/`tag`, keep + the tag check but branch it into `need_tag`; add the `gh release view` probe and + emit `need_release`. Update the `outputs:` map (lines 40-42) from `should_release` + to `need_tag` + `need_release` (keep `version`). Add `env: GH_TOKEN:` to the + probe step. +- `tag` job `if:` (line 84): `needs.detect.outputs.need_tag == 'true'`. +- `release` job `if:` (line 124) and `needs:` (line 123): as above. + +**Tests/evals.** No Go tests cover the workflow. Verify by dry-reading the YAML +and, if practical, a rehearsal on a throwaway tag in a fork/branch (do NOT tag a +real version). Document the manual verification in the PR body. + +**Docs.** `docs/how-to/cutting-a-release.md` describes the release path; if it +states the "tag missing ⇒ release" rule, update it to the "tag-or-release missing" +rule. `make gen-docs` not applicable (no CLI change). + +**CHANGELOG.** `### Changed` — e.g. "Auto-release now re-attempts a release when +the tag exists but no published GitHub Release does, so a transient publish +failure no longer wedges a version." Not Breaking (CI-internal). + +--- + +## PR-3 — `feat`: fold `export`/`import` under a `bundle` noun (**Breaking**) + +**Shape.** Mirror `agentsCmd` in `cmd/agents.go` (a parent noun with no `RunE`, +subcommands attached in `init()`). `export` becomes `ferry bundle export`; +`import` becomes `ferry bundle import `. All existing flags and behaviour +are preserved verbatim — only the invocation path changes. + +**Files/edits.** +- New parent command. Add a `bundleCmd` (`cobra.Command{Use: "bundle", Short: …, + Long: …}`, **no `RunE`**) — place it in `cmd/export.go` or a new `cmd/bundle.go`. + Model the `Short`/`Long` on `agentsCmd` (`cmd/agents.go:21-32`): a one-paragraph + "companion commands for moving the config repo offline as a portable bundle". +- `cmd/export.go`: + - `exportCmd.Use` stays `"export"` (it is now the leaf under `bundle`; the doc + filename becomes `ferry_bundle_export.md`). + - `init()` (lines 301-305): keep the two `exportCmd.Flags()` registrations + (`--out`, `--include-local`); **remove** `rootCmd.AddCommand(exportCmd)`. +- `cmd/import.go`: + - `importCmd.Use` stays `"import "`. + - `init()` (lines 251-256): keep the three flag registrations (`--out`, + `--expect-sha256`, `--include-local`); **remove** `rootCmd.AddCommand(importCmd)`. +- Wire the parent: in `bundleCmd`'s `init()`, + `bundleCmd.AddCommand(exportCmd, importCmd)` then `rootCmd.AddCommand(bundleCmd)`. + (Keep exactly one `rootCmd.AddCommand(bundleCmd)`; do not leave the old + root-level adds.) + +**Tests/evals.** +- `evals/bundle_test.go` — every `s.Ferry("export", …)` and `s.Ferry("import", …)` + becomes `s.Ferry("bundle", "export", …)` / `s.Ferry("bundle", "import", …)`. + This is the whole file, not two sites: export calls at lines 465, 507, 565, 591, + 626, 707, 733, 794, 1440, 1551, 1591; import calls at 828, 864, 893, 927, 951, + 966, 995, 1020, 1049, 1093, 1122, 1163, 1198, 1234, 1291, 1323, 1366, 1377, 1397, + 1476, 1505, 1534, 1615. (A sandbox-helper wrapper or a sed pass across the file + is fine, but keep the flag arguments intact.) +- `evals/wizard_test.go:1816`/`1848` and `evals/github_test.go` are `init --github` + paths — untouched by this PR. +- `evals/commands_test.go` `documentedCommands` (lines 14-16) does **not** list + `export`/`import`, so it needs no change for the rename. Optional: add `"bundle"` + to that list if you want `ferry --help` to assert the new top-level noun is + listed (`TestTopLevelHelpListsAllCommands`); this is additive, not required. +- `cmd/import_rollback_test.go:27` builds `&cobra.Command{Use: "import"}` for an + isolated flag/rollback unit — the string is load-bearing only as a local test + fixture, not the wired command; it can stay `"import"` (the test constructs its + own command). No change required, but verify the test still compiles/passes. +- `cmd/export_classify_test.go` calls `classifyExportEntry(…)` directly and does + **not** reference the command `Use` string — unaffected. Verify only. +- Add one eval asserting the OLD paths are gone: `s.Ferry("export")` and + `s.Ferry("import", x)` now exit non-zero ("unknown command"), and + `ferry bundle export`/`ferry bundle import` work. Watch it fail before, pass + after. + +**Docs.** +- `make gen-docs`: regenerates `ferry_bundle.md`, `ferry_bundle_export.md`, + `ferry_bundle_import.md`; deletes `ferry_export.md`, `ferry_import.md`. Commit + the regenerated `docs/reference/cli/` tree. +- Hand-written `docs/reference/commands.md`: rows at **line 22** (`export`) and + **line 23** (`import`) become `bundle export` / `bundle import` (update the row + key and any `ferry export`/`ferry import` prose in the cells). Consider a short + `bundle` intro row. +- `docs/tutorials/getting-started.md`: prose/commands at lines **191, 192, 196, + 199, 205, 207, 211** — `ferry export` → `ferry bundle export`, `ferry import` → + `ferry bundle import` (leave the shell-`export` lines 52 and the "PATH exports" + prose at 97 alone — not ferry commands). +- `docs/reference/configuration.md`: lines **483, 491, 493, 498** — `ferry export`/ + `import` prose → `bundle export`/`bundle import` (leave the `defaults import` and + iTerm2 lines 102/136/144/145/156/162 and the shell `export NPM_TOKEN` at 272 — + not ferry commands). + +**CHANGELOG.** `### Breaking` — "`ferry export` and `ferry import` are now +`ferry bundle export` and `ferry bundle import`. The flags and behaviour are +unchanged; only the command path moved. There is no alias — the old spellings +error." Not a deprecation. + +--- + +## PR-4 — `feat`: drop `apply --dry-run` entirely (**Breaking**) + +**Rationale.** The read-only preview is `ferry diff`, which shares `buildPlan`/ +`printPlan` with apply. `apply --dry-run` is a redundant second spelling. +`buildPlan`, `printPlan`, and the `diff` command stay intact. + +**Files/edits.** `cmd/apply.go`: +- Remove the flag registration `applyCmd.Flags().Bool("dry-run", …)` at + `cmd/commands.go:113`. +- In `runApply` (`cmd/apply.go:128-180`): remove `dryRun, _ := c.Flags().GetBool("dry-run")` + (line 130) and the whole `if dryRun { … }` preview branch (lines 142-160). Leave + the mutating path (`applyPlan(…)`, deps) exactly as is. +- Sweep the `apply.go` doc comments that mention "dry-run" as an entry point + (e.g. `buildPlan` comment at ~189, `buildPlanWithEngine`/`printPlan` comments) + so they read "diff / status" without "apply --dry-run". Comment-only; no logic. + +**Tests/evals.** +- `evals/commands_test.go`: `TestApplyDryRunFlagDocumented_AC_cmd_apply_dryrun_flag` + (line 252, comment from 243) is NON-GATING today — it `t.Skipf`s (line 261) when + the flag is absent and asserts `AssertUnchanged` (line 274) when present. With the + flag removed the skip path is dead. **Remove this test** (its acceptance is + subsumed by the `ferry diff` coverage, AC-cmd-diff). Do not leave a permanently + skipping test. +- `evals/terminal_config_test.go:182` **executes** `s.Ferry("apply", "--dry-run")` + and asserts on its output (lines 182-195) — this is a hard breakage. Convert it + to `s.Ferry("diff")` (same `buildPlan`/`printPlan` output, so the wezterm-drift + assertions at 188-199 hold). Verify the drift line wording still matches. +- `evals/terminal_test.go:76,95` mention "dry-run" only in comments — reword to + "diff" for accuracy (no code change). +- Add/adjust an eval asserting `apply --dry-run` now exits non-zero ("unknown flag") + and that `ferry diff` still previews without writing (`AssertUnchanged`). Watch + it fail before, pass after. + +**Docs.** `make gen-docs` regenerates `ferry_apply.md` without the flag — commit +it. `docs/reference/commands.md` apply row (line 13) does not name `--dry-run` +(verify); `diff` row (line 20) already covers preview. No prose in +getting-started/configuration references `--dry-run` (verified). + +**CHANGELOG.** `### Breaking` — "`apply --dry-run` is removed. Use `ferry diff` +for a read-only preview of what apply would change (it always did the same thing)." + +--- + +## PR-0 note — `sync` is FROZEN (no code change) + +Decision: leave `sync` exactly as it is for v0.8.0 — add no flags, change no +behaviour, and revisit only on real usage evidence. Not a Breaking entry; no code, +test, doc, or CHANGELOG change. **This decision belongs in `.abcd/work/DECISIONS.md` +via `note decision "…"`** (record it there when the release work starts — this plan +does not write that file). No `ferry_sync.md` regeneration (unchanged). + +--- + +## PR-5 — `docs`: apply/capture legibility one-liners + conflict-resolution doc + +Two non-breaking legibility changes in one themed PR (no behaviour change). + +**(a) Direction one-liners in command help.** `cmd/commands.go`: +- `applyCmd` (lines 26-38): add the direction to the `Short` and/or the first + `Long` line — apply reconciles **repo → this machine**. +- `captureCmd` (lines 40-50): add — capture flows **this machine → repo**. + Keep the wording terse; these strings surface in `ferry --help` and the generated + pages. + +**(b) New explanation doc.** `docs/explanation/` currently holds `agents.md`, +`ssh.md`. Add one Diátaxis **explanation** page (understanding, not task) — e.g. +`docs/explanation/reconciliation-and-conflicts.md`: +- Explain the four target states from `internal/dotfile/status.go`: `StateClean`, + `StateRepoAhead` (incl. first-touch adoption), `StateLocallyDrifted`, + `StateConflict` (and mention `StateMissing`). Quote the state semantics from the + `status.go` constants (lines 10-43) — do not re-derive. +- Explain **where a conflict resolves in each direction**: `apply` refuses a + conflicting target (repo and live both moved past last-applied) and leaves it + unchanged — the remedy is `ferry capture` (adopt the local edit) or + `apply --force` (take the repo copy); `capture` routes each approved change + shared or local, or skips it. Cross-reference the per-domain wording in + `cmd/apply.go` `fileConflictMessage`/`fileSkippedMessage` (lines 972-997) so the + doc matches what the tool prints. +- Present tense, British English (user-facing prose), one Diátaxis type only. +- Register it in `docs/README.md` "Pages" table (after the two existing + explanation rows, ~line 17) as an Explanation row. + +**Tests/evals.** Help-text changes are covered by existing help evals only if they +assert substrings; `TestTopLevelHelpListsAllCommands` checks command presence, not +help wording, so no eval breaks. No new behaviour ⇒ no new behavioural test; a docs +page needs none. Run `docs-currency-lint` to catch broken links/tense. + +**Docs.** `make gen-docs` regenerates `ferry_apply.md`/`ferry_capture.md` with the +new `Short`/`Long` — commit them. Add the new page + the `docs/README.md` row. + +**CHANGELOG.** `### Added` — the conflict-resolution explanation doc; and `### Changed` +— apply/capture help now states direction. Not Breaking. + +--- + +## PR-6 — `feat`: reshape `init`'s flags into a `--wizard` mode (**Breaking**, riskiest) + +**Current 7 flags** (`cmd/init.go:36-42`): `--fresh`, `--yes`, `--apply`, +`--github`, `--no-wizard`, `--repair`, `--wizard-answers `. + +**Target.** Fold the wizard-surface selectors into one string mode flag and narrow +`--yes`: + +- `--wizard=off|interactive|answers:` (default `interactive`): + - `interactive` (default) — paint the TUI when **both** stdin and stdout are ttys, + else fall back to the non-interactive adopt-and-extract (today's default). + - `off` — force the non-interactive fallback even on a tty (today's `--no-wizard`). + - `answers:` — drive the wizard data model from the TOML file (today's + `--wizard-answers `); the inline `` replaces the separate flag. +- `--repair` — **stays as an orthogonal boolean modifier** (see recommendation). +- `--yes` — **narrows to confirmation-assent only**: assume yes for the `--github` + create-confirm and the closing `--apply` confirm. It no longer means "skip the + wizard" — that meaning moves to `--wizard=off`. + +**Resulting flag count: 7 → 6** (`fresh`, `apply`, `github`, `yes`, `wizard`, +`repair`), not 5. See the recommendation below for why `--repair` is kept separate +rather than folded into `--wizard=repair`. + +### Recommendation on `--repair` (READ before implementing) + +`--repair` today is an **orthogonal modifier**, not a mode: `buildInitSeedPlan` +passes `repair` into **both** the interactive TUI path (`runWizardTUI`) and the +answers path (`buildAnswersSeedPlan`), and `validateRepairFlags` (`cmd/init.go:527-544`) +explicitly lets a `--wizard-answers` file satisfy repair's interactivity +requirement — i.e. `ferry init --repair --wizard-answers file.toml` is a supported, +tested combination (`evals/wizard_test.go:1179,1230,1348`). Folding repair into a +mode **value** (`--wizard=repair`) would make it mutually exclusive with +`answers:` and regress that scripted-repair capability. + +**Recommended:** keep `--wizard` as the mode (`off|interactive|answers:`) and +keep `--repair` as a boolean modifier that is **valid with `interactive` or +`answers:` and rejected with `--wizard=off`** (repairs are consent-per-finding +and cannot run in the non-interactive fallback). Net 7→6, no behaviour regression. +Folding to hit 5 is possible but sacrifices `repair + answers`; flag this to the +maintainer as an explicit decision (see RISKS). + +### Files/edits + +- `cmd/init.go:36-42` (`init()`): replace the `--yes`(-as-skip-wizard) doc, drop + `--no-wizard` and `--wizard-answers`, add `--wizard` (string, default + `"interactive"`). Keep `--fresh`, `--apply`, `--github`, `--repair`, `--yes` + (with the narrowed help text). Update every flag's help string. +- New parse helper (in `cmd/init.go`): parse the `--wizard` value into a small mode + enum + optional answers path. `""`/`"interactive"` → interactive; `"off"` → off; + `"answers:"` → answers with path=`` (empty path errors). Any other + value errors, naming the valid forms. Validate external input (this is a CLI arg). +- `freshInitOpts` (`cmd/init.go:311-317`): replace `{yes, noWizard, repair, + answersPath}` with `{mode, answersPath, repair}` (drop `yes` — it no longer steers + the wizard). `--yes` is still read directly where it means assent + (`finishWithApply` at `cmd/init.go:902-903`; `initGitHub` at + `cmd/init_github.go:81`). +- `buildInitSeedPlan` (`cmd/init.go:327-360`): rewrite the mode switch to key off + the parsed mode instead of `opts.yes`/`opts.noWizard`/`stdin&stdout ttys`: + - answers mode → `buildAnswersSeedPlan` (as today, outranks tty). + - off mode → `buildFallbackSeedPlan` (as today's `--no-wizard`/non-tty fallback). + - interactive mode → `runWizardTUI` when `stdinIsTerminal() && stdoutIsTerminal()`, + else `buildFallbackSeedPlan`. +- `initFresh` (`cmd/init.go:291-309`): read `--wizard`/`--repair`, build the new + `freshInitOpts`; drop the `yes`/`noWizard`/`answersPath`-from-separate-flag reads. +- `cmd/init_github.go:147-155` (`initGitHub`): same — read `--wizard`/`--repair` + into the new opts; keep the separate `--yes` read at line 81 for the create-confirm. +- `validateRepairFlags` (`cmd/init.go:527-544`): rewrite against the new model — + `--repair` requires `interactive` or `answers:` and is rejected with + `--wizard=off`; the tty requirement still applies to interactive-without-answers. + It no longer references `--yes`/`--no-wizard` by name. **Decide** whether + `--repair --yes` (now non-conflicting under the narrowed `--yes`) is allowed on a + tty — see RISKS. +- Sweep the block comment at `cmd/init.go:22-42` and `cmd/init_github.go:76-79,128-131` + to describe the new flags. + +### Tests/evals (this is where the risk concentrates) + +Unit (`cmd/`): +- `cmd/wizard_test.go` — asserts `--repair`/`[repairs]` applicability by name at + lines 250-254; these exercise `validateChoicesApplicability`/`wizardChoices` + (internal), largely unaffected by the flag rename, but re-verify after the + `freshInitOpts`/`validateRepairFlags` signature changes. +- `cmd/wizard_gate_test.go`, `cmd/init_github_gate_test.go`, + `cmd/init_github_origin_test.go` — do not assert the reshaped flag strings, but + compile against the changed helpers; rebuild and re-run. + +Evals (`evals/`) — every `ferry init` invocation with a reshaped flag changes: +- `--wizard-answers ` → `--wizard=answers:` at: + `evals/wizard_capture_test.go:154,337`; `evals/wizard_test.go:494,590,646,712,747, + 787,839,866,959,1001,1032,1089,1441,1480,1546,1880`; and the `--github` + + answers combo at `evals/wizard_test.go:1848`. +- `--repair --wizard-answers ` → `--repair --wizard=answers:` at + `evals/wizard_test.go:1179,1230,1348`. +- `--no-wizard` → `--wizard=off` at `evals/wizard_test.go:1415,1731`. +- The `--repair` conflict matrix at `evals/wizard_test.go:1386-1415` (`with_yes`, + `with_no_wizard`) must be rebuilt against the new rule: `--repair` + `--wizard=off` + must error; the `--yes` arm's expectation changes because `--yes` no longer skips + the wizard (decide per RISKS). +- The secret-bearing arms at `evals/wizard_test.go:1730-1731` + (`--yes`, `--no-wizard`): the `--no-wizard` arm becomes `--wizard=off`; the + `--yes` arm still exercises the fallback **because the sandbox has no tty** (default + interactive falls back), so it should pass unchanged — verify it asserts secret + extraction, not the flag's skip-effect. +- `evals/github_test.go` `init --github … --yes` (many sites: 566, 582, 620, 686, + 708, 743, 778, 837, 1012, 1111, 1173, 1216, 1247, 1274, 1340, 1387, 1421, 1503, + 1543): `--yes` here is the **create-confirm assent**, which is preserved — these + run in a non-tty sandbox, so the default `interactive` mode already falls back and + the wizard is not painted. Expect them to pass **unchanged**; confirm by running + the github eval group. + +Add new evals: `ferry init --wizard=off`, `--wizard=answers:`, `--wizard=bogus` +(errors), `--repair --wizard=off` (errors). Watch fail-before/pass-after. + +### Docs + +- `make gen-docs` regenerates `ferry_init.md` with the new flags — commit it. +- `docs/reference/commands.md`: the `init --yes` (line 8), `init --no-wizard` + (line 9), `init --repair` (line 10), `init --wizard-answers` (line 11) rows must + be rewritten to the `--wizard=…`/narrowed-`--yes` model. The main `init` cell + (line 7) references `--yes`/`--no-wizard` inline — update it too. +- `docs/tutorials/getting-started.md`: the wizard/`--repair` prose around line 97 + references the repair flow; align it with `--wizard`. + +### CHANGELOG + +`### Breaking` — "`ferry init` flag surface reshaped: `--no-wizard` and +`--wizard-answers ` are replaced by `--wizard=off|interactive|answers:`, +and `--yes` no longer skips the wizard (it now only assumes-yes for confirmations — +use `--wizard=off` to skip the wizard). `--repair` is unchanged but is now rejected +with `--wizard=off`. No aliases." Call out each removed spelling explicitly. + +--- + +## PR-7 — release: cut v0.8.0 + +Not a code change per se; the mechanical release. **Gates that must all pass before +tagging** (from `AGENTS.md` and the release tooling): + +- `make build`; `gofmt -l .` (empty); `go vet ./...`; `go test ./...`. +- `FERRY_BIN="$PWD/bin/ferry-$(go env GOOS)-$(go env GOARCH)" go test ./evals/`. +- `bash scripts/consistency-lint.sh`. +- `docs-currency-lint` (deterministic: tenses, broken links, stray root markdown) + **and** the `docs-currency-reviewer` agent (semantic: every user-facing claim + verified against the code) — fix findings before the tag. +- `bash scripts/check-plan-shipped.sh v0.8.0` — this requires the literal + `shipped in v0.8.0` in **this plan's** `Status:` line. Flip + `Status: planned for v0.8.0` → `Status: shipped in v0.8.0` as the last step. +- `make gen-docs` re-run and the regenerated `docs/reference/cli/` committed (CI + fails on drift): the tree must show `ferry_bundle*.md` (no `ferry_export.md`/ + `ferry_import.md`), a dry-run-free `ferry_apply.md`, and the reshaped + `ferry_init.md`. + +**Not auto-checked — must be hand-updated** (no lint/gen covers these): the +hand-written `docs/reference/commands.md` rows (apply, sync, export/import→bundle, +init flags) and the prose in `docs/tutorials/getting-started.md` and +`docs/reference/configuration.md`. The `docs-currency-reviewer` agent is the +backstop, but do the edits in the PRs that cause them, not at release time. + +**Release step.** Promote the CHANGELOG `[Unreleased]` section to +`## [0.8.0] - ` (keeping the `### Breaking`/`### Changed`/`### Added` +subsections accumulated by PR-2..PR-6), merge to `main`; `auto-release.yml` detects +the newest dated CHANGELOG version, tags it, and `release.yml` publishes it. + +--- + +## Recommended branch/PR ordering + +Land low-risk, independent PRs first; the risky `init` reshape last, well-tested. + +1. **PR-1** `chore`: version bump — independent, trivial. +2. **PR-2** `ci`: auto-release hardening — independent; needs a `security-reviewer` + pass before merge. +3. **PR-3** `feat`: bundle fold — self-contained; large but mechanical test/doc sweep. +4. **PR-4** `feat`: drop `apply --dry-run` — small; watch the `terminal_config_test` + conversion. +5. **PR-5** `docs`: apply/capture legibility + conflict doc — non-breaking; can land + any time after PR-3/PR-4 so its help/doc wording matches the final surface. +6. **PR-6** `feat`: `init` flag reshape — riskiest (first-run UX); land last with the + fullest eval coverage. +7. **PR-7** release: promote CHANGELOG, flip this plan's Status, cut v0.8.0. + +Each PR is its own branch, deleted after merge. `docs:`/`chore:`/`ci:` PRs may be +armed for auto-merge once checks pass; `feat:` PRs (PR-3/4/6) wait for the user's +merge. + +## STOP conditions + +Hitting any of these means **stop and report**, do not push through: + +- **PR-6 breaks an eval that cannot be cleanly updated** to the new `--wizard` + model (e.g. an assertion that depends on `--yes` skipping the wizard on a tty in a + way the narrowed semantics can no longer produce). Stop; the flag mapping needs a + maintainer decision before proceeding. +- **The `--repair`/`--yes` conflict semantics become ambiguous** (see RISKS): if + making `--repair` compose with the narrowed `--yes` changes an existing eval's + expected error, stop and get the intended behaviour confirmed rather than guessing. +- **`security-reviewer` returns BLOCK on PR-2** (the CI token-model change) — stop + and fix the finding before merge. +- **`make gen-docs` produces a diff that does not match the intended surface** + (e.g. an orphaned `ferry_export.md` remains, or `ferry_bundle*.md` is missing) — + stop; the command wiring is wrong. +- **Any eval that cannot be made to fail-before/pass-after** for a new behaviour — + do not mark the change done (per `AGENTS.md`: watched-fail-then-pass is required). + +## RISKS / ambiguities for the maintainer to decide + +1. **`--repair` folding (flag count 6 vs 5).** Recommended: keep `--repair` a + separate modifier (7→6) to preserve the `--repair --wizard=answers:` + scripted-repair combo (currently tested at `evals/wizard_test.go:1179/1230/1348`). + Folding it into `--wizard=repair` reaches 5 flags but regresses that combination. + **Decision needed.** +2. **`--repair` + narrowed `--yes`.** Today `--repair` conflicts with `--yes` + (because `--yes` meant skip-wizard). Under the narrowed `--yes` (assent only), + `--repair --yes` on a tty is coherent (interactive repair wizard that auto-confirms + the closing apply prompt). The conflict test at `evals/wizard_test.go:1414` + (`with_yes`) encodes the old rule. **Decide** whether to (a) allow `--repair --yes` + on a tty and update that test, or (b) keep rejecting the pair for continuity. + Recommended: (a), since the two flags no longer overlap in meaning. +3. **Default-mode behaviour shift on a tty.** After the change, `ferry init` (no + flags) still runs the interactive TUI on a tty; but `ferry init --yes` on a tty + now **runs the wizard** (previously it skipped it). Non-tty callers (all evals, + CI, scripts) are unaffected because default `interactive` already falls back + without a tty — so the observable break is interactive-terminal only. Confirm this + is the intended first-run UX. +4. **`auto-release.yml` `release` gating with a skipped `tag` job.** When the tag + exists but the release is missing, `tag` is skipped and `release` must still run; + the `if:` needs `always()` plus an explicit `needs.tag.result in (success, + skipped)` guard. Confirm the exact expression during `security-reviewer`. +5. **Scope of the `bundle_test.go` rewrite.** The task brief cited two call sites; + the file actually has ~24 `export`/`import` invocations (listed in PR-3). Confirm + a mechanical whole-file rewrite (not a two-line patch) is acceptable. +6. **`documentedCommands` / `bundle` in help.** Optional: add `"bundle"` to + `evals/commands_test.go:14-16` so `ferry --help` is asserted to list the new noun. + Not required by any current AC. Decide whether to include it. diff --git a/.abcd/development/research/2026-07-08-hunk-identity-sota.md b/.abcd/development/research/2026-07-08-hunk-identity-sota.md new file mode 100644 index 0000000..6e0ac6a --- /dev/null +++ b/.abcd/development/research/2026-07-08-hunk-identity-sota.md @@ -0,0 +1,219 @@ +# Deterministic, routable hunk identity in hand-edited dotfiles + +Date: 2026-07-08 · Type: explanation / research + +This note studies how ferry should identify a change "hunk" in a hand-edited +dotfile so that the identity is stable across machines and across unrelated +edits, routable to shared-vs-local, and friendly to both humans and agents. It +changes no code: it is an approved SOTA study whose recommendation the +maintainer can accept, reject, or defer, family by family. + +The headline: ferry's `capture` command identifies hunks via `diffHunks` in +`cmd/capture.go:1225` — a hand-rolled LCS line diff whose hunk identity **is** +its line range (`repoStart`/`repoEnd`) computed against the last-applied +baseline. That identity drifts the moment nearby lines change, because a line +range is a position and positions move. The key reframe is that **this is a +naming problem, not a diffing problem.** Stable identity comes only from +regions that are explicitly *named* (markers) or that *are files* (drop-ins). +No diff algorithm recovers it, because every diff family — line, structural, +content-hash — derives identity from position or content, and both of those +move when the file is edited. The good news: two of the four families below +give ferry a genuinely stable, routable identity for cheap, and the field has +shipped both patterns for years. + +## The reframe: identity comes from naming, not diffing + +A diff answers "what changed between version A and version B." It does not +answer "which managed region is this, independent of what else changed" — that +is an identity question, and identity for a region of text has only two durable +sources: + +- The region **is a file.** A file has a name, and the name is stable across + machines and across edits to every *other* file. This is drop-in sharding. +- The region **is explicitly named in place.** A sentinel marker pair around + the region gives it a content-independent, position-independent handle. This + is the managed-block pattern. + +Everything else — line ranges, AST node paths, content hashes — is derived +from position or content, so it is not identity: it drifts. That is the whole +thesis. The families below are ranked by value-for-effort against ferry's +actual constraints: stable, routable, shared-vs-local aware, agent-friendly, +and cheap to implement in a Go CLI. + +## Ranked approach families + +### 1. Drop-in directory sharding — highest value-for-effort + +Each small file **is** the hunk; identity is the filename. That identity is +stable across machines and stable across edits to every other file, which is +exactly the property ferry's line-range identity lacks. The entire +implementation cost is `filepath.Glob` over a directory — no diff, no +matching, no baseline arithmetic. Routing shared-vs-local becomes directory +placement: `zshrc.d/shared/` versus `zshrc.d/local/`, and the shard name is +the routing key. + +The pattern is deeply established as the operating-system convention for +composable configuration: `/etc/profile.d`, systemd drop-in directories, +`conf.d` layouts across countless daemons, oh-my-zsh's `custom/` directory, +and the community `~/.zshrc.d/NN-name.zsh` modularization pattern. chezmoi's +own guidance for machine-to-machine differences leans on splitting config into +separately-managed pieces rather than diffing one monolith. + +Cost and caveat: it requires a one-time restructure of a user's monolithic +`.zshrc`/`.bashrc` into a directory the shell sources, and it only works for +files that *can* source a directory. Files that cannot (a single-file config +with no include mechanism) fall back to family 2. + +### 2. Sentinel / marker-delimited managed blocks — best for in-place monoliths + +When the file must stay a single file, a named marker pair gives the region a +content-independent, position-independent identity: + +``` +# >>> ferry: >>> +...managed content... +# <<< ferry: <<< +``` + +The identity is the ``, not the line range, so it survives unrelated edits +above or below. Detection is a cheap regex scan in Go, and the operation is +idempotent: a re-run replaces only the bytes between the two markers, touching +nothing the user wrote outside them. + +The reference design is Ansible's `blockinfile` module: markers are *required* +for idempotency, and unique markers let multiple managed blocks coexist in one +file. The same pattern is what every shell-init installer already writes into +your rc files — conda `init`, nvm, rbenv, pyenv, and Homebrew's shellenv block +all delimit their managed region with sentinels. + +The failure modes are well documented and must be designed out: + +- **User edits inside a block.** ferry must detect this (the content between + markers no longer matches the last-applied managed content) and + refuse-and-report or re-route — never silently overwrite a user's in-block + edit. +- **Marker collisions.** Ansible modules-extras issue #1968 is the cautionary + case: identical markers make blocks clobber each other. ferry must generate + stable, unique IDs it owns, not reuse a human-chosen string. +- **Crude enclosing that comments across user code.** conda issue #11030 shows + an init routine that commented across a region the user had opened, breaking + it. ferry must never comment across a block boundary a user may have opened, + and must treat the managed region as bytes-between-markers only. + +### 3. The `.ferry/` "agent-ready description" — a composition layer, adopt but not as a hunking mechanism + +A sidecar `.ferry/` contract that declares how a file is structured and how an +agent should write into it is SOTA-supported as a *form*, and worth adopting — +but it has **zero hunking power on its own**. It is a composition layer over +families 1 and 2, not a substitute for them. + +The precedents are for the form, not for segmentation: + +- `.editorconfig` — a sidecar that declares how a file is *treated*, consumed + by tooling rather than the runtime, centralizing per-file policy instead of + scattering modelines. This is the shape of a formatting contract. +- `AGENTS.md` — a machine-read contract that instructs an agent how to + structure its work. Open spec since August 2025, moved under the Linux + Foundation in December 2025, adopted across 60k+ repositories. This is the + precedent for a prescriptive, agent-read instruction file. + +Verdict: the contract is SOTA-supported **and does not stand alone.** Its +payload must *mandate* markers (family 2) and/or sharding (family 1). A +contract that merely says "write tidy sections" buys nothing deterministic, +because free-form agent output is no more segmentable than free-form human +output — tidiness is not identity. + +This is the one place to be honest about the evidence. **CONTESTED / +weak-evidence:** there is no published study showing that a formatting contract +measurably improves segmentation *reliability*. The `.editorconfig` and +`AGENTS.md` precedents are evidence for *adoption and consistency*, not for +*parse-reliability*. So the `.ferry/` contract should be presented as a +reasoned design **bet**, not an evidence-backed certainty. Also flag the +framing: "AGENTS.md as a governance/telemetry contract" is marketing gloss; the +durable, real part is "agent-read prescriptive instructions that tell the agent +to emit marked or sharded regions." + +### 4. Positional line-diff (histogram + indent heuristic) — keep only as fallback + +Keep the line diff strictly as the fallback path for un-contracted, legacy, or +un-restructured files, and pin the algorithm rather than leaving it +hand-rolled. Its identity is positional and therefore drifts — this family +does not solve the problem, it only degrades gracefully when families 1–3 do +not apply. + +The evidence for *how* to pin it: + +- **Histogram over Myers.** Nugroho et al. (arXiv 1902.02467) find Myers and + Histogram produce identical output for over 95% of commits and diverge on + 1.7–8.2%; where they diverge, histogram tends to place cleaner hunk + boundaries. Prefer histogram-style matching. +- **Indent heuristic.** git's indentation heuristic (git commit 433860f) + reduces boundary-placement errors to roughly 1/30 of the default. But note + precisely what it fixes: it stabilizes *where* a boundary lands within one + diff, not *what* a hunk is across versions. It improves the fallback; it does + not confer identity. +- **`git rerere` as the cautionary tale.** rerere identifies conflict + resolutions by content, depends on markers, and deliberately refuses to + auto-finalize because the surrounding context may have changed. That is + directly relevant to ferry's auto-routing: when identity is uncertain, prefer + refuse-and-report over silent auto-merge. + +## Rejected — investigated, not worth adopting + +- **AST / structural diff for shell** (difftastic, tree-sitter, GumTree). Shell + barely parses; difftastic itself falls back to line diff on parse errors, so + for the exact hand-edited rc files ferry targets it degrades to family 4 with + more machinery. GumTree-style move detection is NP-hard and heuristic + (arXiv 2403.05939) and cannot reason across files, which is where ferry's + shared-vs-local routing actually lives. High complexity, no identity gain. +- **augeas lenses.** There is no general shell lens — only `Shellvars.lns` for + `KEY=value` assignments — and augeas refuses to serialize trees that do not + match its lens, so arbitrary hand-edited shell is out of scope by + construction. +- **Content-hash / rerere-style identity as the *primary* scheme.** Fine only + as a *tiebreaker* to re-locate a marker that moved; useless as the primary + identity because editing the content changes the hash, which is the drift we + are trying to escape. + +## Recommendation for ferry, in order + +1. **Ship the `.ferry/` contract whose payload is marker blocks with + ferry-owned stable IDs** as the baseline mechanism, and prefer `*.d/` + sharding as the target wherever the file can source a directory. The + contract is the composition layer; markers and shards are what make it + deterministic. +2. **Make the marker/shard name the routing key** for shared-vs-local, + replacing today's line-range identity in `diffHunks`. +3. **Keep the LCS line-diff strictly as fallback** for un-contracted files, + switch its intent to histogram-style matching, and treat an edit *inside* a + managed block as refuse-and-report, never silent overwrite. +4. **Explicitly reject AST / augeas / difftastic** for the core identity path; + allow content-hash only as a marker-relocation tiebreaker. + +## Scope note + +This is a capture-engine redesign with a real failure surface — in-block user +edits, marker collisions, one-time monolith restructuring, and an auto-routing +decision that can silently corrupt a hand-edited file if it guesses wrong. It +warrants its own gated design round and is **not** part of the v0.8.0 +CLI-surface tidy. Keep the two efforts separate. + +## Sources + +All tier-1 (official docs, primary specs, peer-reviewed or archival papers, +canonical commits): + +- [Ansible `blockinfile` module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/blockinfile_module.html) +- [Ansible modules-extras issue #1968 (marker collisions)](https://github.com/ansible/ansible-modules-extras/issues/1968) +- [conda issue #11030 (init comments across user code)](https://github.com/conda/conda/issues/11030) +- [conda issue #10249 (init block management)](https://github.com/conda/conda/issues/10249) +- [difftastic](https://github.com/Wilfred/difftastic) and its [Structural Diffs wiki](https://github.com/Wilfred/difftastic/wiki/Structural-Diffs) +- [Augeas Lenses documentation](https://augeas.net/docs/references/lenses.html) and [FAQ](https://augeas.net/faq.html) +- [Nugroho et al., "How Different Are Different diff Algorithms in Git?" (arXiv 1902.02467)](https://arxiv.org/abs/1902.02467) +- [git commit 433860f (indent heuristic)](https://github.com/git/git/commit/433860f3d0beb0c6f205290bd16cda413148f098) and [git-diff docs](https://git-scm.com/docs/git-diff) +- [git-rerere docs](https://git-scm.com/docs/git-rerere) +- [Falleri et al. / GumTree AST-diff benchmark (arXiv 2403.05939)](https://arxiv.org/abs/2403.05939) +- [EditorConfig specification](https://spec.editorconfig.org/) +- [AGENTS.md guide and standard](https://agents.md/) +- [chezmoi: manage machine-to-machine differences](https://www.chezmoi.io/user-guide/manage-machine-to-machine-differences/) +- The zsh drop-in modularization pattern (`~/.zshrc.d/NN-name.zsh`; oh-my-zsh `custom/`; `/etc/profile.d`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e6165..79843dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ called out in a **Breaking** section. See ## [Unreleased] +## [0.8.0] - 2026-07-08 + ### Breaking - **`ferry export` and `ferry import` are now `ferry bundle export` and diff --git a/docs/explanation/reconciling-drift-and-conflicts.md b/docs/explanation/reconciling-drift-and-conflicts.md index 3881aad..3ef5435 100644 --- a/docs/explanation/reconciling-drift-and-conflicts.md +++ b/docs/explanation/reconciling-drift-and-conflicts.md @@ -53,7 +53,7 @@ Two details worth calling out: treated as repo-ahead, not conflict. The backup keeps it reversible. - **A conflict is reserved for the genuine case:** ferry *has* a last-applied record for the file, your live copy differs from it (you edited a file ferry - previously managed, without capturing), *and* the repo has also moved on. Only + manages, without capturing), *and* the repo has also moved on. Only then does `apply` refuse. ## Where conflicts resolve, per direction