diff --git a/.abcd/README.md b/.abcd/README.md index 3e3dd8e..5a5c4ed 100644 --- a/.abcd/README.md +++ b/.abcd/README.md @@ -7,8 +7,11 @@ tree written for the reader of the CLI. - **[`development/`](development/)** — the durable record (committed): plans, research, and architecture decisions. The specification the build works from. +- **[`work/`](work/)** — working memory, committed shared orientation: + `CONTEXT.md` (load-first standing facts) and `DECISIONS.md` (one-line decision + log). -Working memory (`.work/`, committed shared orientation, and `.work.local/`, -git-ignored local ephemera) currently sits at the repo root; a phase-2 migration -folds it under this namespace (`.abcd/work/`, `.abcd/.work.local/`). Until then, -see [`../AGENTS.md`](../AGENTS.md) for the working-memory split. +Local, ephemeral working memory — the `NEXT.md` handover, scratch output, and +logs — lives in the git-ignored `.work.local/` alongside these, hidden via +`.git/info/exclude` and never committed. See [`../AGENTS.md`](../AGENTS.md) for +the working-memory split. diff --git a/.abcd/development/plans/2026-07-08-v0.7.2.md b/.abcd/development/plans/2026-07-08-v0.7.2.md new file mode 100644 index 0000000..93c67da --- /dev/null +++ b/.abcd/development/plans/2026-07-08-v0.7.2.md @@ -0,0 +1,49 @@ +# ferry v0.7.2 — working memory folds into the `.abcd/` namespace + +**Status:** shipped in v0.7.2 — the working-memory fold and its scaffold ripple +are delivered. +**Owner:** REPPL. **Type:** implementation plan (Diátaxis: one plan, no mixing). + +## 1. Goal and scope + +Complete the `.abcd/` alignment begun in v0.7.1 (which moved the durable record — +plans, research, decisions — into `.abcd/development/`). v0.7.1 deliberately +deferred the working-memory tier; v0.7.2 folds it in, so the whole +developer-facing surface lives under one namespace and `ferry agents scaffold` +stamps that same layout into every repo it onboards. + +In scope: + +- Fold ferry's own committed working memory: `git mv .work/CONTEXT.md` and + `.work/DECISIONS.md` into `.abcd/work/`, with a lean `.abcd/work/README.md` + and an updated `.abcd/README.md`. +- Change the scaffold generator (`internal/agents/scaffold.go`) so a scaffolded + repo gets a committed `.abcd/work/` (`DECISIONS.md`, `CONTEXT.md`) and a + git-ignored `.abcd/.work.local/` (`NEXT.md`, `scratch/`, `logs/`, plus + `DECISIONS.md`/`CONTEXT.md`/`ISSUES.md` in `--private` mode). The + `info/exclude` entry becomes `.abcd/.work.local/`. +- Regenerate the golden fixtures (`scaffold_golden_test.go`, `scaffold_test.go`) + in lockstep — they fail loudly until they match the new layout. +- Repoint the convention docs and the `agents scaffold` help/CLI reference at the + new paths. + +## 2. Out of scope (deferred) + +- The physical move of ferry's own local `.work.local/` contents to + `.abcd/.work.local/`, and the matching `scripts/release.sh` / CI update — these + touch the live session and harness hooks, so they land separately. +- The sibling terminal/emacs/tmux domains (v0.7.3). +- Any cross-repo convention change to the shared `ABCDevelopment/CLAUDE.md`. + +## 3. Approach + +The move mirrors the v0.7.1 docs migration: `git mv` plus text edits, no runtime +behaviour change. The scaffold generator centralises every working-memory dest in +one declarative table (`scaffoldLayout`) plus the `ensureWorkLocalDirs` and +`excludeWorkLocal` helpers, so retargeting is a handful of edits; the golden +characterisation tests then pin the exact created-path set, the exact stdout, and +the exact `info/exclude` line, so any drift from the new layout is a loud test +failure — that lockstep is the safety net. + +The change is fully reversible: `git mv` plus text edits, no data migration and no +release cutover. diff --git a/.work/CONTEXT.md b/.abcd/work/CONTEXT.md similarity index 100% rename from .work/CONTEXT.md rename to .abcd/work/CONTEXT.md diff --git a/.work/DECISIONS.md b/.abcd/work/DECISIONS.md similarity index 100% rename from .work/DECISIONS.md rename to .abcd/work/DECISIONS.md diff --git a/.abcd/work/README.md b/.abcd/work/README.md new file mode 100644 index 0000000..b409c7a --- /dev/null +++ b/.abcd/work/README.md @@ -0,0 +1,19 @@ +# Working memory (committed) + +The shared, committed tier of ferry's working memory — the standing facts the +next session needs before it can act, kept in the repo so every clone and every +agent starts from the same orientation. Mirrors the sibling `abcd-cli`'s +`.abcd/work/`. + +| File | What it holds | +|---|---| +| [`CONTEXT.md`](CONTEXT.md) | Curated, load-first summary of ferry's standing facts and invariants. Read it first. | +| [`DECISIONS.md`](DECISIONS.md) | One-line-per-entry log of session decisions — rejected alternatives and chosen tradeoffs a future session would otherwise re-litigate. | + +Because this tier is committed, its contents follow the repository's privacy +rules: no local absolute paths, secrets, or live data. Local, ephemeral working +memory — the `NEXT.md` handover, scratch output, and logs — lives in the +git-ignored [`../.work.local/`](../.work.local/) instead, hidden via +`.git/info/exclude` and never committed. See +[ADR 0002](../development/decisions/0002-work-memory-public-private-split.md) +for the public/private split. diff --git a/AGENTS.md b/AGENTS.md index a3cb6b4..a87e00a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ on the internal packages, and the full eval suite against a real Linux binary. ## Working memory -Load `.work/CONTEXT.md` first — it is the curated, load-first summary of ferry's -standing facts and invariants. Record session handoff in `.work.local/NEXT.md` -(git-ignored, local-only), not in `.work/`. See +Load `.abcd/work/CONTEXT.md` first — it is the curated, load-first summary of +ferry's standing facts and invariants. Record session handoff in +`.abcd/.work.local/NEXT.md` (git-ignored, local-only), not in `.abcd/work/`. See [ADR 0002](.abcd/development/decisions/0002-work-memory-public-private-split.md) for the split. diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e3b26..8287368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ called out in a **Breaking** section. See ## [Unreleased] +## [0.7.2] - 2026-07-08 + +### Changed + +- **Working memory moves into the `.abcd/` namespace.** ferry's committed + working memory moves from `.work/` to `.abcd/work/` (holding `CONTEXT.md`, the + load-first standing facts, and `DECISIONS.md`, the one-line decision log), and + `ferry agents scaffold` now creates the `.abcd/{work,.work.local}` layout: a + scaffolded repo gets a committed `.abcd/work/` and a git-ignored + `.abcd/.work.local/` (`NEXT.md`, `scratch/`, `logs/`, hidden via + `.git/info/exclude`) instead of the root-level `.work/` and `.work.local/`. + This completes the `.abcd/` alignment begun in 0.7.1. + ## [0.7.1] - 2026-07-08 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0e70ff..4110012 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,15 +65,15 @@ sequential number reservation registry. - If two branches mint the same `NNNN`, a duplicate-`NNNN` lint fails CI and the second branch to merge renumbers its ADR. Do not resolve the clash by force. -- One-line session decisions live in `.work/DECISIONS.md`; promote a decision to - a `.abcd/development/decisions/` ADR when it shapes architecture or is - expensive to reverse. +- One-line session decisions live in `.abcd/work/DECISIONS.md`; promote a + decision to a `.abcd/development/decisions/` ADR when it shapes architecture or + is expensive to reverse. ## Working memory -Curated standing facts load first from `.work/CONTEXT.md` (committed). Session -handoff, scratch output, and logs live under `.work.local/` (git-ignored via -`.git/info/exclude`, never committed). See +Curated standing facts load first from `.abcd/work/CONTEXT.md` (committed). +Session handoff, scratch output, and logs live under `.abcd/.work.local/` +(git-ignored via `.git/info/exclude`, never committed). See [ADR 0002](.abcd/development/decisions/0002-work-memory-public-private-split.md) -for the rationale. Because `.work/` is committed, its contents follow the repository's -privacy rules: no local absolute paths, secrets, or live data. +for the rationale. Because `.abcd/work/` is committed, its contents follow the +repository's privacy rules: no local absolute paths, secrets, or live data. diff --git a/cmd/agents.go b/cmd/agents.go index e01bcb9..1262035 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -36,14 +36,14 @@ var agentsScaffoldCmd = &cobra.Command{ Short: "Set a project repo up for the multi-tool agent pipeline", Long: `Set a project repo up for the multi-tool agent pipeline. -Both modes create the local-only runtime layout: .work.local/{scratch,logs}, +Both modes create the local-only runtime layout: .abcd/.work.local/{scratch,logs}, hidden via the git info/exclude mechanism (never committed; .gitignore is never touched). Default mode (your own repo — tracked files): an AGENTS.md router stamped from the config repo's template ({{PROJECT}}/{{DATE}} substituted), CLAUDE.md and GEMINI.md as relative symlinks to AGENTS.md inside the repo, a committed -.work/ (NEXT.md, DECISIONS.md), the docs/ hierarchy with its map +.abcd/work/ (DECISIONS.md, CONTEXT.md), the docs/ hierarchy with its map (docs/README.md), and a pre-commit config when the repo has none. --attribution (tracked mode only) marks a repo that REQUIRES AI disclosure, @@ -52,9 +52,9 @@ prepare-commit-msg hook (a kernel-style Assisted-by trailer on agent-authored commits — never Co-Authored-By), sets core.hooksPath to .githooks (per clone), and appends the AI-attribution section to AGENTS.md. ---private mode (a repo you don't own — zero tracked trace): .work.local/ only -(NEXT.md, DECISIONS.md, ISSUES.md). No AGENTS.md, no symlinks, no docs, no -tracked file touched. Not combinable with --attribution. +--private mode (a repo you don't own — zero tracked trace): .abcd/.work.local/ +only (NEXT.md, DECISIONS.md, CONTEXT.md, ISSUES.md). No AGENTS.md, no symlinks, +no docs, no tracked file touched. Not combinable with --attribution. Idempotent; never overwrites an existing file.`, Args: cobra.RangeArgs(1, 2), @@ -79,7 +79,7 @@ Requires the agents domain to be enabled ("agents = true" under [manage]).`, } func init() { - agentsScaffoldCmd.Flags().Bool("private", false, "leave no tracked trace: .work.local/ only, excluded via git info/exclude") + agentsScaffoldCmd.Flags().Bool("private", false, "leave no tracked trace: .abcd/.work.local/ only, excluded via git info/exclude") agentsScaffoldCmd.Flags().Bool("attribution", false, "this repo requires AI disclosure: install the Assisted-by commit hook and AGENTS.md policy section") agentsScaffoldCmd.MarkFlagsMutuallyExclusive("private", "attribution") agentsCmd.AddCommand(agentsScaffoldCmd, agentsAdoptCmd) diff --git a/docs/how-to/scaffold-a-repo.md b/docs/how-to/scaffold-a-repo.md index f92f1ca..0e141de 100644 --- a/docs/how-to/scaffold-a-repo.md +++ b/docs/how-to/scaffold-a-repo.md @@ -3,8 +3,8 @@ ## Goal Set a **project** repo up for the multi-tool agent pipeline — the AGENTS.md router, the -`CLAUDE.md`/`GEMINI.md` bridges, the committed `.work/` memory, the `docs/` map, and the -local-only runtime layout — in one idempotent command. For the model behind the agents +`CLAUDE.md`/`GEMINI.md` bridges, the committed `.abcd/work/` memory, the `docs/` map, and +the local-only runtime layout — in one idempotent command. For the model behind the agents domain, see [The agents domain](../explanation/agents.md). ## Command @@ -19,11 +19,11 @@ directory's base name. Idempotent; never overwrites an existing file. ## What it creates The layout separates **committed project memory** from **local-only runtime -artefacts**: everything under `.work/` is meant to be committed, while -scratch output and logs always live in `.work.local/` and never reach git. -Both modes create `.work.local/scratch/` and `.work.local/logs/` and hide the -whole `.work.local/` directory via the checkout-local git `info/exclude` — -`.gitignore` is never touched. +artefacts**: everything under `.abcd/work/` is meant to be committed, while +scratch output and logs always live in `.abcd/.work.local/` and never reach git. +Both modes create `.abcd/.work.local/scratch/` and `.abcd/.work.local/logs/` and +hide the whole `.abcd/.work.local/` directory via the checkout-local git +`info/exclude` — `.gitignore` is never touched. Default (tracked) mode, for a repo you own: @@ -33,8 +33,8 @@ Default (tracked) mode, for a repo you own: | `CLAUDE.md`, `GEMINI.md` | Relative symlinks to `AGENTS.md` **inside the project repo** (project-tracked content — ferry does not deploy these to `$HOME`) | | `docs/README.md` | The user-facing documentation map, stamped from `agents/templates/docs-README.md`: the four Diátaxis directories and the root-markdown allowlist | | `.abcd/development/plans/`, `.abcd/development/research/`, `.abcd/development/decisions/` | Developer-record directories, created up front — dated plans and research (`YYYY-MM-DD-topic.md`) and ADRs (MADR, `NNNN-title.md`); `docs/` stays user-facing Diátaxis, whose content directories are created on first use | -| `.work/DECISIONS.md`, `.work/CONTEXT.md` | Committed decision log and curated standing facts (the load-first summary) | -| `.work.local/NEXT.md`, `.work.local/scratch/`, `.work.local/logs/` | Local-only session handoff and runtime artefacts, hidden via git `info/exclude` | +| `.abcd/work/DECISIONS.md`, `.abcd/work/CONTEXT.md` | Committed decision log and curated standing facts (the load-first summary) | +| `.abcd/.work.local/NEXT.md`, `.abcd/.work.local/scratch/`, `.abcd/.work.local/logs/` | Local-only session handoff and runtime artefacts, hidden via git `info/exclude` | | `.pre-commit-config.yaml` | Copied from the template, only when the repo has none | `--attribution` (tracked mode only) marks a repo that **requires AI @@ -54,8 +54,8 @@ refused: a repo you do not own is not yours to set attribution policy in. | Item | Role | |---|---| -| `.work.local/NEXT.md`, `DECISIONS.md`, `ISSUES.md` | The same logs plus a private observation list, alongside the `scratch/` and `logs/` dirs | -| git `info/exclude` entry | Hides `.work.local/` locally; never committed or pushed | +| `.abcd/.work.local/NEXT.md`, `DECISIONS.md`, `CONTEXT.md`, `ISSUES.md` | The same logs plus a private observation list, alongside the `scratch/` and `logs/` dirs | +| git `info/exclude` entry | Hides `.abcd/.work.local/` locally; never committed or pushed | Anything already sitting where a bridge symlink would go is left untouched: a real file is skipped with a message (merge it into `AGENTS.md` first), and a @@ -70,7 +70,7 @@ directory's `info/exclude`, which is where git reads it. ## Result The repo carries the full agent-pipeline scaffold, committable in one go (bar the -git-ignored `.work.local/`). Re-running the command is safe: it only fills in what is +git-ignored `.abcd/.work.local/`). Re-running the command is safe: it only fills in what is missing and never overwrites an existing file. ## Related documentation diff --git a/docs/reference/cli/ferry_agents_scaffold.md b/docs/reference/cli/ferry_agents_scaffold.md index 7cb3c43..033752a 100644 --- a/docs/reference/cli/ferry_agents_scaffold.md +++ b/docs/reference/cli/ferry_agents_scaffold.md @@ -6,14 +6,14 @@ Set a project repo up for the multi-tool agent pipeline Set a project repo up for the multi-tool agent pipeline. -Both modes create the local-only runtime layout: .work.local/{scratch,logs}, +Both modes create the local-only runtime layout: .abcd/.work.local/{scratch,logs}, hidden via the git info/exclude mechanism (never committed; .gitignore is never touched). Default mode (your own repo — tracked files): an AGENTS.md router stamped from the config repo's template ({{PROJECT}}/{{DATE}} substituted), CLAUDE.md and GEMINI.md as relative symlinks to AGENTS.md inside the repo, a committed -.work/ (NEXT.md, DECISIONS.md), the docs/ hierarchy with its map +.abcd/work/ (DECISIONS.md, CONTEXT.md), the docs/ hierarchy with its map (docs/README.md), and a pre-commit config when the repo has none. --attribution (tracked mode only) marks a repo that REQUIRES AI disclosure, @@ -22,9 +22,9 @@ prepare-commit-msg hook (a kernel-style Assisted-by trailer on agent-authored commits — never Co-Authored-By), sets core.hooksPath to .githooks (per clone), and appends the AI-attribution section to AGENTS.md. ---private mode (a repo you don't own — zero tracked trace): .work.local/ only -(NEXT.md, DECISIONS.md, ISSUES.md). No AGENTS.md, no symlinks, no docs, no -tracked file touched. Not combinable with --attribution. +--private mode (a repo you don't own — zero tracked trace): .abcd/.work.local/ +only (NEXT.md, DECISIONS.md, CONTEXT.md, ISSUES.md). No AGENTS.md, no symlinks, +no docs, no tracked file touched. Not combinable with --attribution. Idempotent; never overwrites an existing file. @@ -37,7 +37,7 @@ ferry agents scaffold [name] [flags] ``` --attribution this repo requires AI disclosure: install the Assisted-by commit hook and AGENTS.md policy section -h, --help help for scaffold - --private leave no tracked trace: .work.local/ only, excluded via git info/exclude + --private leave no tracked trace: .abcd/.work.local/ only, excluded via git info/exclude ``` ### SEE ALSO diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 33c0b74..52a8a1f 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -21,7 +21,7 @@ Every command is run as `ferry ` (e.g. `ferry init`). | `restore` | Reverse ferry's changes, returning the machine to its pre-ferry state from an automatic backup. | | `export` | Write a portable, secret-scanned `.zip` bundle of the repo's tracked shared files for an offline move. Prints the bundle's reproducible SHA256 — exporting the same tracked sources always yields the same digest (no timestamps or randomness are bundled), so the digest can be recomputed and verified with `import --expect-sha256`. Never includes secrets, `~/.ssh`, or the local layer (unless `--include-local`). | | `import` | Ingest a bundle into a fresh config repo (`~/.config/ferry/repo` by default), validate it fully, then write ferry's config. Refuses a non-empty target. `--expect-sha256 ` verifies integrity. | -| `agents scaffold` | Set up a project repo for AI-agent work from the templates in the config repo's `agents/` area: an `AGENTS.md` router, `CLAUDE.md`/`GEMINI.md` bridges, and committed `.work/` handoff files. `--private` instead creates a `.work.local/` layer hidden via `.git/info/exclude` — for repos you don't own, it leaves zero tracked trace. `--attribution` (mutually exclusive with `--private`) instead installs a `prepare-commit-msg` hook that appends a kernel-style `Assisted-by:` trailer to agent-authored commits, for repos that require AI disclosure. Idempotent; never overwrites or repoints anything it didn't create. Works in linked worktrees and submodules. | +| `agents scaffold` | Set up a project repo for AI-agent work from the templates in the config repo's `agents/` area: an `AGENTS.md` router, `CLAUDE.md`/`GEMINI.md` bridges, and committed `.abcd/work/` memory files. `--private` instead creates a `.abcd/.work.local/` layer hidden via `.git/info/exclude` — for repos you don't own, it leaves zero tracked trace. `--attribution` (mutually exclusive with `--private`) instead installs a `prepare-commit-msg` hook that appends a kernel-style `Assisted-by:` trailer to agent-authored commits, for repos that require AI disclosure. Idempotent; never overwrites or repoints anything it didn't create. Works in linked worktrees and submodules. | | `agents adopt` | One-time migration of an existing symlink-based instruction setup into the config repo: imports the source files (never modifying the source directory), then swaps each `$HOME` bridge symlink for a ferry-managed copy in a single journalled transaction — any failure rolls back and the symlinks return. Refuses directory-level bridges with exact instructions rather than writing through them. | | `version` | Print the version; `--verbose` adds the Go version and platform. | diff --git a/internal/agents/doc.go b/internal/agents/doc.go index 20b97c4..2970f4a 100644 --- a/internal/agents/doc.go +++ b/internal/agents/doc.go @@ -15,9 +15,9 @@ // reverses every deploy from the same file baseline as any other domain. // // The package also carries the two repo-onboarding operations: Scaffold (stamp -// the standard AGENTS.md router, .work/ skeleton, and in-repo bridge symlinks -// into a project repo — in-repo symlinks are the project's own and are never -// deployed to $HOME) and the adopt helpers (import an existing symlink-based +// the standard AGENTS.md router, .abcd/work/ skeleton, and in-repo bridge +// symlinks into a project repo — in-repo symlinks are the project's own and are +// never deployed to $HOME) and the adopt helpers (import an existing symlink-based // instruction directory into the config repo and retire its $HOME bridge // symlinks in favour of ferry-managed copies). package agents diff --git a/internal/agents/scaffold.go b/internal/agents/scaffold.go index 6baec5d..1c06797 100644 --- a/internal/agents/scaffold.go +++ b/internal/agents/scaffold.go @@ -39,9 +39,9 @@ type ScaffoldOptions struct { // Scaffold sets a project repo up for the multi-tool agent pipeline. It is // idempotent and NEVER overwrites an existing file. // -// Runtime artefacts (scratch output, logs) live in `.work.local/` in BOTH +// Runtime artefacts (scratch output, logs) live in `.abcd/.work.local/` in BOTH // modes and are never committed: every scaffold creates -// .work.local/{scratch,logs} and hides the whole directory via the git +// .abcd/.work.local/{scratch,logs} and hides the whole directory via the git // info/exclude mechanism (local to the checkout — no tracked file is ever // touched for it; there is no .gitignore editing). // @@ -52,12 +52,12 @@ type ScaffoldOptions struct { // - CLAUDE.md and GEMINI.md as RELATIVE symlinks to AGENTS.md INSIDE the // project repo (these are the project's own tracked bridges — ferry never // deploys them to $HOME, so the copy-not-symlink invariant is untouched); -// - a committed .work/ holding the standing-facts memory DECISIONS.md and -// CONTEXT.md (NEXT.md, the volatile handoff note, lives in .work.local/); +// - a committed .abcd/work/ holding the standing-facts memory DECISIONS.md and +// CONTEXT.md (NEXT.md, the volatile handoff note, lives in .abcd/.work.local/); // - the pre-commit template, only when the repo has none. // // --private mode — a repo you don't own, zero tracked trace: -// - .work.local/ only (NEXT.md, DECISIONS.md, CONTEXT.md, ISSUES.md +// - .abcd/.work.local/ only (NEXT.md, DECISIONS.md, CONTEXT.md, ISSUES.md // alongside the scratch/logs dirs). No AGENTS.md, no symlinks, no tracked // file touched. func Scaffold(opts ScaffoldOptions, out io.Writer) error { @@ -101,8 +101,8 @@ func Scaffold(opts ScaffoldOptions, out io.Writer) error { return nil } - // Runtime artefacts live in .work.local/ in BOTH modes: create the dirs - // and hide the directory from git before anything mode-specific happens. + // Runtime artefacts live in .abcd/.work.local/ in BOTH modes: create the + // dirs and hide the directory from git before anything mode-specific happens. if err := ensureWorkLocalDirs(repo); err != nil { return err } @@ -115,11 +115,11 @@ func Scaffold(opts ScaffoldOptions, out io.Writer) error { } // ensureWorkLocalDirs creates the runtime-artefact layout every scaffold -// guarantees: .work.local/scratch and .work.local/logs. +// guarantees: .abcd/.work.local/scratch and .abcd/.work.local/logs. func ensureWorkLocalDirs(repo string) error { for _, d := range []string{ - filepath.Join(repo, ".work.local", "scratch"), - filepath.Join(repo, ".work.local", "logs"), + filepath.Join(repo, ".abcd", ".work.local", "scratch"), + filepath.Join(repo, ".abcd", ".work.local", "logs"), } { if err := os.MkdirAll(d, 0o755); err != nil { return err @@ -128,23 +128,23 @@ func ensureWorkLocalDirs(repo string) error { return nil } -// excludeWorkLocal hides .work.local/ from git without touching any tracked -// file: the entry goes into the repo's REAL git dir's info/exclude — +// excludeWorkLocal hides .abcd/.work.local/ from git without touching any +// tracked file: the entry goes into the repo's REAL git dir's info/exclude — // resolveGitDir follows a gitfile (linked worktree, submodule) and the // worktree commondir, since git reads info/exclude from the common dir. A // non-git directory only warns (there is nothing to exclude from). func excludeWorkLocal(repo string, out io.Writer) { gitDir, ok := resolveGitDir(repo) if !ok { - fmt.Fprintln(out, "WARN: not a git repo — .work.local/ cannot be excluded locally") + fmt.Fprintln(out, "WARN: not a git repo — .abcd/.work.local/ cannot be excluded locally") return } exclude := filepath.Join(gitDir, "info", "exclude") - if err := appendLineOnce(exclude, ".work.local/"); err != nil { - fmt.Fprintf(out, "WARN: could not write %s (%v) — exclude .work.local/ yourself\n", exclude, err) + if err := appendLineOnce(exclude, ".abcd/.work.local/"); err != nil { + fmt.Fprintf(out, "WARN: could not write %s (%v) — exclude .abcd/.work.local/ yourself\n", exclude, err) return } - fmt.Fprintln(out, "excluded: .work.local/ via git info/exclude (local-only, not committed)") + fmt.Fprintln(out, "excluded: .abcd/.work.local/ via git info/exclude (local-only, not committed)") } // scaffoldMode records which scaffold(s) a layout entry belongs to. @@ -158,8 +158,8 @@ const ( // scaffoldFile is one templated put() target in the scaffold layout. dest // paths are repo-relative. mode says which scaffold(s) create the file; the -// matching dest gives where. For modeBoth the two dests differ (`.work/` vs -// `.work.local/`); for a single-mode file only that mode's dest is set. +// matching dest gives where. For modeBoth the two dests differ (`.abcd/work/` +// vs `.abcd/.work.local/`); for a single-mode file only that mode's dest is set. type scaffoldFile struct { template string mode scaffoldMode @@ -191,17 +191,17 @@ func (f scaffoldFile) destFor(private bool) string { // scaffoldLayout is the single source of truth for the templated put() files, // in the EXACT order the scaffolds stamp them. Tracked mode walks the entries -// with a trackedDest (DECISIONS + CONTEXT in the committed .work/, AGENTS, -// docs/README, and NEXT in the private .work.local/); private mode walks the -// entries with a privateDest (NEXT, DECISIONS, CONTEXT, ISSUES, all in -// .work.local/). The mode-specific steps (the CLAUDE/GEMINI symlinks, the +// with a trackedDest (DECISIONS + CONTEXT in the committed .abcd/work/, AGENTS, +// docs/README, and NEXT in the private .abcd/.work.local/); private mode walks +// the entries with a privateDest (NEXT, DECISIONS, CONTEXT, ISSUES, all in +// .abcd/.work.local/). The mode-specific steps (the CLAUDE/GEMINI symlinks, the // pre-commit copy, the --attribution hook, and excludeWorkLocal) are // imperative and live outside this table. var scaffoldLayout = []scaffoldFile{ - {template: "NEXT.md", mode: modeBoth, trackedDest: ".work.local/NEXT.md", privateDest: ".work.local/NEXT.md"}, - {template: "DECISIONS.md", mode: modeBoth, trackedDest: ".work/DECISIONS.md", privateDest: ".work.local/DECISIONS.md"}, - {template: "CONTEXT.md", mode: modeBoth, trackedDest: ".work/CONTEXT.md", privateDest: ".work.local/CONTEXT.md"}, - {template: "ISSUES.md", mode: modePrivate, privateDest: ".work.local/ISSUES.md"}, + {template: "NEXT.md", mode: modeBoth, trackedDest: ".abcd/.work.local/NEXT.md", privateDest: ".abcd/.work.local/NEXT.md"}, + {template: "DECISIONS.md", mode: modeBoth, trackedDest: ".abcd/work/DECISIONS.md", privateDest: ".abcd/.work.local/DECISIONS.md"}, + {template: "CONTEXT.md", mode: modeBoth, trackedDest: ".abcd/work/CONTEXT.md", privateDest: ".abcd/.work.local/CONTEXT.md"}, + {template: "ISSUES.md", mode: modePrivate, privateDest: ".abcd/.work.local/ISSUES.md"}, {template: "AGENTS.md", mode: modeTracked, trackedDest: "AGENTS.md"}, {template: "docs-README.md", mode: modeTracked, trackedDest: "docs/README.md"}, } @@ -234,10 +234,10 @@ func putLayout(repo string, private bool, put func(templateName, dest string) er return nil } -// scaffoldPrivate creates the zero-tracked-trace layout: .work.local/ holds -// the memory files (NEXT.md, DECISIONS.md, CONTEXT.md, ISSUES.md) alongside -// the runtime-artefact dirs the caller already created; nothing tracked is -// created or modified. +// scaffoldPrivate creates the zero-tracked-trace layout: .abcd/.work.local/ +// holds the memory files (NEXT.md, DECISIONS.md, CONTEXT.md, ISSUES.md) +// alongside the runtime-artefact dirs the caller already created; nothing +// tracked is created or modified. func scaffoldPrivate(repo, name string, put func(templateName, dest string) error, out io.Writer) error { if err := putLayout(repo, true, put); err != nil { return err @@ -247,14 +247,14 @@ func scaffoldPrivate(repo, name string, put func(templateName, dest string) erro } // scaffoldTracked creates the committed layout on top of the shared -// .work.local/ runtime dirs: a .work/ holding the committed standing-facts -// memory (DECISIONS.md, CONTEXT.md), the AGENTS.md router, the user-facing -// docs/ map (docs/README.md), the .abcd/development/ developer-record dirs, -// the in-repo CLAUDE.md/GEMINI.md symlinks, and the pre-commit config when -// absent. The volatile handoff note -// NEXT.md lands in .work.local/ (uncommitted) in both modes. It never touches -// .gitignore — runtime artefacts are excluded via .work.local/ + info/exclude, -// and .work/ is meant to be committed whole. +// .abcd/.work.local/ runtime dirs: a .abcd/work/ holding the committed +// standing-facts memory (DECISIONS.md, CONTEXT.md), the AGENTS.md router, the +// user-facing docs/ map (docs/README.md), the .abcd/development/ +// developer-record dirs, the in-repo CLAUDE.md/GEMINI.md symlinks, and the +// pre-commit config when absent. The volatile handoff note NEXT.md lands in +// .abcd/.work.local/ (uncommitted) in both modes. It never touches .gitignore +// — runtime artefacts are excluded via .abcd/.work.local/ + info/exclude, and +// .abcd/work/ is meant to be committed whole. func scaffoldTracked(opts ScaffoldOptions, repo, name string, put func(templateName, dest string) error, out io.Writer) error { // Developer-record directories that hold dated plans/research and // sequential ADRs, under the .abcd/development/ namespace (the user-facing @@ -267,9 +267,9 @@ func scaffoldTracked(opts ScaffoldOptions, repo, name string, put func(templateN } } - // The put files, in table order: the volatile .work.local/NEXT.md, the - // committed .work/ memory (DECISIONS.md, CONTEXT.md), the AGENTS.md - // router, and the docs map (docs/README.md, never overwriting an + // The put files, in table order: the volatile .abcd/.work.local/NEXT.md, + // the committed .abcd/work/ memory (DECISIONS.md, CONTEXT.md), the + // AGENTS.md router, and the docs map (docs/README.md, never overwriting an // existing one). if err := putLayout(repo, false, put); err != nil { return err diff --git a/internal/agents/scaffold_golden_test.go b/internal/agents/scaffold_golden_test.go index cd06ffd..d53d4e0 100644 --- a/internal/agents/scaffold_golden_test.go +++ b/internal/agents/scaffold_golden_test.go @@ -20,7 +20,8 @@ import ( // symlink under the repo, excluding .git/), and // 2. the EXACT stdout, including every created:/exists:/linked:/… line and // their order, with the repo's absolute path normalised to , and -// 3. the EXACT line excludeWorkLocal writes into .git/info/exclude. +// 3. the EXACT line excludeWorkLocal writes into .git/info/exclude +// (.abcd/.work.local/). // // Every mode runs in a REAL git repo so the exclude write and (for // --attribution) the core.hooksPath config are exercised for real; the tests @@ -79,9 +80,9 @@ func normaliseOut(out, repo string) string { return strings.ReplaceAll(out, repo, "") } -// excludeWorkLocalLines returns the count of exact ".work.local/" lines in the -// repo's .git/info/exclude — the line excludeWorkLocal is contracted to write -// exactly once. +// excludeWorkLocalLines returns the count of exact ".abcd/.work.local/" lines +// in the repo's .git/info/exclude — the line excludeWorkLocal is contracted to +// write exactly once. func excludeWorkLocalLines(t *testing.T, repo string) int { t.Helper() data, err := os.ReadFile(filepath.Join(repo, ".git", "info", "exclude")) @@ -90,7 +91,7 @@ func excludeWorkLocalLines(t *testing.T, repo string) int { } n := 0 for _, line := range strings.Split(string(data), "\n") { - if line == ".work.local/" { + if line == ".abcd/.work.local/" { n++ } } @@ -107,7 +108,7 @@ func assertGolden(t *testing.T, repo, gotOut string, wantPaths []string, wantOut t.Errorf("stdout changed (behaviour NOT preserved):\n got:\n%s\nwant:\n%s", norm, wantOut) } if n := excludeWorkLocalLines(t, repo); n != 1 { - t.Errorf("info/exclude has %d %q lines, want exactly 1", n, ".work.local/") + t.Errorf("info/exclude has %d %q lines, want exactly 1", n, ".abcd/.work.local/") } } @@ -120,28 +121,28 @@ func TestScaffoldGolden_Tracked(t *testing.T) { wantPaths := []string{ ".abcd", + ".abcd/.work.local", + ".abcd/.work.local/NEXT.md", + ".abcd/.work.local/logs", + ".abcd/.work.local/scratch", ".abcd/development", ".abcd/development/decisions", ".abcd/development/plans", ".abcd/development/research", + ".abcd/work", + ".abcd/work/CONTEXT.md", + ".abcd/work/DECISIONS.md", ".pre-commit-config.yaml", - ".work", - ".work.local", - ".work.local/NEXT.md", - ".work.local/logs", - ".work.local/scratch", - ".work/CONTEXT.md", - ".work/DECISIONS.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "docs", "docs/README.md", } - wantOut := "excluded: .work.local/ via git info/exclude (local-only, not committed)\n" + - "created: /.work.local/NEXT.md\n" + - "created: /.work/DECISIONS.md\n" + - "created: /.work/CONTEXT.md\n" + + wantOut := "excluded: .abcd/.work.local/ via git info/exclude (local-only, not committed)\n" + + "created: /.abcd/.work.local/NEXT.md\n" + + "created: /.abcd/work/DECISIONS.md\n" + + "created: /.abcd/work/CONTEXT.md\n" + "created: /AGENTS.md\n" + "created: /docs/README.md\n" + "linked: CLAUDE.md -> AGENTS.md\n" + @@ -152,7 +153,7 @@ func TestScaffoldGolden_Tracked(t *testing.T) { assertGolden(t, repo, out, wantPaths, wantOut) } -// TestScaffoldGolden_Private pins --private (the .work.local/ layer only). +// TestScaffoldGolden_Private pins --private (the .abcd/.work.local/ layer only). func TestScaffoldGolden_Private(t *testing.T) { templates, repo := goldenRepo(t) out := runScaffold(t, ScaffoldOptions{ @@ -160,19 +161,20 @@ func TestScaffoldGolden_Private(t *testing.T) { }) wantPaths := []string{ - ".work.local", - ".work.local/CONTEXT.md", - ".work.local/DECISIONS.md", - ".work.local/ISSUES.md", - ".work.local/NEXT.md", - ".work.local/logs", - ".work.local/scratch", + ".abcd", + ".abcd/.work.local", + ".abcd/.work.local/CONTEXT.md", + ".abcd/.work.local/DECISIONS.md", + ".abcd/.work.local/ISSUES.md", + ".abcd/.work.local/NEXT.md", + ".abcd/.work.local/logs", + ".abcd/.work.local/scratch", } - wantOut := "excluded: .work.local/ via git info/exclude (local-only, not committed)\n" + - "created: /.work.local/NEXT.md\n" + - "created: /.work.local/DECISIONS.md\n" + - "created: /.work.local/CONTEXT.md\n" + - "created: /.work.local/ISSUES.md\n" + + wantOut := "excluded: .abcd/.work.local/ via git info/exclude (local-only, not committed)\n" + + "created: /.abcd/.work.local/NEXT.md\n" + + "created: /.abcd/.work.local/DECISIONS.md\n" + + "created: /.abcd/.work.local/CONTEXT.md\n" + + "created: /.abcd/.work.local/ISSUES.md\n" + "done: goldenproj (private mode — no tracked files were created or modified)\n" assertGolden(t, repo, out, wantPaths, wantOut) @@ -188,30 +190,30 @@ func TestScaffoldGolden_Attribution(t *testing.T) { wantPaths := []string{ ".abcd", + ".abcd/.work.local", + ".abcd/.work.local/NEXT.md", + ".abcd/.work.local/logs", + ".abcd/.work.local/scratch", ".abcd/development", ".abcd/development/decisions", ".abcd/development/plans", ".abcd/development/research", + ".abcd/work", + ".abcd/work/CONTEXT.md", + ".abcd/work/DECISIONS.md", ".githooks", ".githooks/prepare-commit-msg", ".pre-commit-config.yaml", - ".work", - ".work.local", - ".work.local/NEXT.md", - ".work.local/logs", - ".work.local/scratch", - ".work/CONTEXT.md", - ".work/DECISIONS.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "docs", "docs/README.md", } - wantOut := "excluded: .work.local/ via git info/exclude (local-only, not committed)\n" + - "created: /.work.local/NEXT.md\n" + - "created: /.work/DECISIONS.md\n" + - "created: /.work/CONTEXT.md\n" + + wantOut := "excluded: .abcd/.work.local/ via git info/exclude (local-only, not committed)\n" + + "created: /.abcd/.work.local/NEXT.md\n" + + "created: /.abcd/work/DECISIONS.md\n" + + "created: /.abcd/work/CONTEXT.md\n" + "created: /AGENTS.md\n" + "created: /docs/README.md\n" + "linked: CLAUDE.md -> AGENTS.md\n" + diff --git a/internal/agents/scaffold_test.go b/internal/agents/scaffold_test.go index a325df8..b31ffad 100644 --- a/internal/agents/scaffold_test.go +++ b/internal/agents/scaffold_test.go @@ -75,13 +75,13 @@ func TestScaffoldTrackedMode(t *testing.T) { t.Errorf("AGENTS.md = %q, want %q", agentsMD, want) } - // Committed memory in .work/, runtime artefacts in .work.local/ — both - // modes share the .work.local layout. docs/ carries the user-facing map; - // the dated developer records live under .abcd/development/. The tracked put - // files come from the shared scaffoldLayout table (the single source of - // truth), so this loop tracks the layout automatically. - want := layoutDests(false) // tracked dests: .work.local/NEXT.md, .work/DECISIONS.md, .work/CONTEXT.md, AGENTS.md, docs/README.md - want = append(want, ".work.local/scratch", ".work.local/logs", ".pre-commit-config.yaml") + // Committed memory in .abcd/work/, runtime artefacts in .abcd/.work.local/ + // — both modes share the .abcd/.work.local layout. docs/ carries the + // user-facing map; the dated developer records live under .abcd/development/. + // The tracked put files come from the shared scaffoldLayout table (the single + // source of truth), so this loop tracks the layout automatically. + want := layoutDests(false) // tracked dests: .abcd/.work.local/NEXT.md, .abcd/work/DECISIONS.md, .abcd/work/CONTEXT.md, AGENTS.md, docs/README.md + want = append(want, ".abcd/.work.local/scratch", ".abcd/.work.local/logs", ".pre-commit-config.yaml") for _, d := range scaffoldDevDocsDirs { want = append(want, ".abcd/development/"+d) } @@ -97,20 +97,20 @@ func TestScaffoldTrackedMode(t *testing.T) { if want := "# docs — map for " + name + "\n"; string(docsMap) != want { t.Errorf("docs/README.md = %q, want %q (substituted)", docsMap, want) } - // .work/ holds ONLY the committed standing-facts memory (DECISIONS.md, - // CONTEXT.md): no scratch/logs, and NEXT.md now lives in .work.local/. - for _, rel := range []string{".work/scratch", ".work/logs", ".work/NEXT.md"} { + // .abcd/work/ holds ONLY the committed standing-facts memory (DECISIONS.md, + // CONTEXT.md): no scratch/logs, and NEXT.md now lives in .abcd/.work.local/. + for _, rel := range []string{".abcd/work/scratch", ".abcd/work/logs", ".abcd/work/NEXT.md"} { if _, err := os.Lstat(filepath.Join(repo, rel)); err == nil { - t.Errorf("%s exists; runtime artefacts belong in .work.local/", rel) + t.Errorf("%s exists; runtime artefacts belong in .abcd/.work.local/", rel) } } // The volatile handoff note and the committed CONTEXT.md landed in their // new homes (layoutDests above already asserts them; this pins the split). - if _, err := os.Stat(filepath.Join(repo, ".work.local", "NEXT.md")); err != nil { - t.Errorf(".work.local/NEXT.md missing: %v", err) + if _, err := os.Stat(filepath.Join(repo, ".abcd", ".work.local", "NEXT.md")); err != nil { + t.Errorf(".abcd/.work.local/NEXT.md missing: %v", err) } - if _, err := os.Stat(filepath.Join(repo, ".work", "CONTEXT.md")); err != nil { - t.Errorf(".work/CONTEXT.md missing: %v", err) + if _, err := os.Stat(filepath.Join(repo, ".abcd", "work", "CONTEXT.md")); err != nil { + t.Errorf(".abcd/work/CONTEXT.md missing: %v", err) } for _, link := range []string{"CLAUDE.md", "GEMINI.md"} { @@ -124,8 +124,8 @@ func TestScaffoldTrackedMode(t *testing.T) { } } - // Tracked mode never touches .gitignore; .work.local/ is hidden via the - // checkout-local git info/exclude instead. + // Tracked mode never touches .gitignore; .abcd/.work.local/ is hidden via + // the checkout-local git info/exclude instead. if _, err := os.Lstat(filepath.Join(repo, ".gitignore")); err == nil { t.Error(".gitignore was created; scaffold must not touch it") } @@ -133,8 +133,8 @@ func TestScaffoldTrackedMode(t *testing.T) { if err != nil { t.Fatalf("info/exclude not written: %v", err) } - if !strings.Contains(string(exclude), ".work.local/") { - t.Errorf("info/exclude missing the .work.local/ entry: %q", exclude) + if !strings.Contains(string(exclude), ".abcd/.work.local/") { + t.Errorf("info/exclude missing the .abcd/.work.local/ entry: %q", exclude) } if !strings.Contains(out, "done: "+name) { t.Errorf("output missing done line: %q", out) @@ -417,15 +417,17 @@ func TestScaffoldPrivateMode(t *testing.T) { // The private put files come from the shared scaffoldLayout table (the // single source of truth), alongside the shared runtime dirs. - want := layoutDests(true) // private dests: .work.local/{NEXT,DECISIONS,CONTEXT,ISSUES}.md - want = append(want, ".work.local/scratch", ".work.local/logs") + want := layoutDests(true) // private dests: .abcd/.work.local/{NEXT,DECISIONS,CONTEXT,ISSUES}.md + want = append(want, ".abcd/.work.local/scratch", ".abcd/.work.local/logs") for _, rel := range want { if _, err := os.Stat(filepath.Join(repo, rel)); err != nil { t.Errorf("%s missing: %v", rel, err) } } - // Zero tracked trace: none of the tracked-mode artefacts may exist. - for _, rel := range []string{"AGENTS.md", "CLAUDE.md", "GEMINI.md", ".gitignore", ".work", "docs", ".pre-commit-config.yaml"} { + // Zero tracked trace: none of the tracked-mode artefacts may exist. (.abcd/ + // itself now exists to hold the excluded .work.local/, but the COMMITTED + // .abcd/work/ memory must not.) + for _, rel := range []string{"AGENTS.md", "CLAUDE.md", "GEMINI.md", ".gitignore", ".abcd/work", "docs", ".pre-commit-config.yaml"} { if _, err := os.Lstat(filepath.Join(repo, rel)); err == nil { t.Errorf("private mode created tracked artefact %s", rel) } @@ -434,7 +436,7 @@ func TestScaffoldPrivateMode(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(exclude), ".work.local/") { + if !strings.Contains(string(exclude), ".abcd/.work.local/") { t.Errorf(".git/info/exclude missing entry: %q", exclude) } if !strings.Contains(out, "private mode") { @@ -447,7 +449,7 @@ func TestScaffoldPrivateMode(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.Count(string(exclude2), ".work.local/") != 1 { + if strings.Count(string(exclude2), ".abcd/.work.local/") != 1 { t.Errorf("exclude entry duplicated: %q", exclude2) } } @@ -553,14 +555,14 @@ func TestScaffoldPrivateModeInLinkedWorktree(t *testing.T) { if err != nil { t.Fatalf("common-dir exclude not written: %v", err) } - if !strings.Contains(string(exclude), ".work.local/") { + if !strings.Contains(string(exclude), ".abcd/.work.local/") { t.Errorf("common-dir exclude missing entry: %q", exclude) } } -// TestScaffoldTrackedExcludeWithGitfile: TRACKED mode hides .work.local/ via -// the same gitfile-aware exclude machinery as private mode — the entry lands -// in the RESOLVED git dir's info/exclude, and .gitignore is never touched. +// TestScaffoldTrackedExcludeWithGitfile: TRACKED mode hides .abcd/.work.local/ +// via the same gitfile-aware exclude machinery as private mode — the entry +// lands in the RESOLVED git dir's info/exclude, and .gitignore is never touched. func TestScaffoldTrackedExcludeWithGitfile(t *testing.T) { templates, repo := scaffoldFixture(t, false) gitDir := filepath.Join(t.TempDir(), "gitdir") @@ -572,13 +574,13 @@ func TestScaffoldTrackedExcludeWithGitfile(t *testing.T) { if err != nil { t.Fatalf("resolved git dir's info/exclude not written: %v", err) } - if !strings.Contains(string(exclude), ".work.local/") { - t.Errorf("info/exclude missing the .work.local/ entry: %q", exclude) + if !strings.Contains(string(exclude), ".abcd/.work.local/") { + t.Errorf("info/exclude missing the .abcd/.work.local/ entry: %q", exclude) } if _, err := os.Lstat(filepath.Join(repo, ".gitignore")); err == nil { t.Error(".gitignore was created; scaffold must not touch it") } - for _, rel := range []string{".work.local/scratch", ".work.local/logs"} { + for _, rel := range []string{".abcd/.work.local/scratch", ".abcd/.work.local/logs"} { if _, err := os.Stat(filepath.Join(repo, rel)); err != nil { t.Errorf("%s missing: %v", rel, err) }