From d626a8126019ec2d2b7fc322d7a3916d75c0b258 Mon Sep 17 00:00:00 2001 From: Diego Andres Rabaioli Date: Mon, 5 Oct 2026 22:09:11 +0200 Subject: [PATCH 1/3] Keep the installed CDD helpers current: sync on pull, self-reload, update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install is a copy, so a merged helper fix changed nothing on the machine until someone re-ran install (the #124/#125 incident), open shells kept the old functions, and a curl install had no way to update. - Sync on pull: install, run from a CDD git checkout, writes a managed post-merge + post-rewrite hook into it. A pull that lands on the default branch with different helpers or adapters (cmp, never a version stamp) reinstalls both and prints one line. Other branches, amends and unchanged pulls do nothing. A foreign hook is never overwritten; under core.hooksPath nothing is written (both noted). - Self-reload: each public cdd-worktree* / cdd-state call re-sources its file when it changed on disk and re-dispatches, silently. - `bash ~/.cdd/tools/cdd-worktree.sh update`: on demand, a sparse blobless clone of upstream main's tools/; installs when it differs. cdd-state's unknown-verb / invalid-stage errors and the plan_written skew warning name it. The dispatch now exits explicitly, since update overwrites the script bash is running. New toolchain-sync gate (27 gates). install-smoke and adapter-bindings install from a non-git copy of tools/, so a check run never hooks the developer's checkout. Process doc §2.8, shell-helpers.md, ADR 0014, template BOOTSTRAP.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JyhD5C5M5Vsi6k7jzdzEwS --- .claude/commands/cdd-pre-pr.md | 2 +- CLAUDE.md | 6 +- ...-the-install-current-by-syncing-on-pull.md | 76 +++++ doc/architecture/index.md | 1 + doc/architecture/shell-helpers.md | 20 +- doc/features/template.md | 2 +- .../claude-driven-development.md | 2 +- doc/knowledge_base/engineering-practices.md | 3 +- scripts/adapter-bindings-assert.sh | 6 +- scripts/ci.sh | 5 + scripts/install-smoke-assert.sh | 13 +- scripts/toolchain-sync-assert.sh | 309 ++++++++++++++++++ template/BOOTSTRAP.md | 2 + tools/cdd-state.sh | 35 ++ tools/cdd-worktree.sh | 203 +++++++++++- 15 files changed, 667 insertions(+), 18 deletions(-) create mode 100644 doc/architecture/adr/0014-keep-the-install-current-by-syncing-on-pull.md create mode 100755 scripts/toolchain-sync-assert.sh diff --git a/.claude/commands/cdd-pre-pr.md b/.claude/commands/cdd-pre-pr.md index cd64101..7ef887b 100644 --- a/.claude/commands/cdd-pre-pr.md +++ b/.claude/commands/cdd-pre-pr.md @@ -57,7 +57,7 @@ If the project has no single runner yet, run each gate command in sequence inste ``` -**In this repo the runner is `./scripts/ci.sh`** — 26 gates, listed with what each checks in the registry at the top of the script; `./scripts/ci.sh list` names them and `./scripts/ci.sh ` reruns one. CI runs it on Ubuntu and macOS, so a gate leaning on one host's sed/grep/awk can pass here and fail there. Not fail-fast. Two of its gates need interpretation rather than a rerun; see the sections below. +**In this repo the runner is `./scripts/ci.sh`** — 27 gates, listed with what each checks in the registry at the top of the script; `./scripts/ci.sh list` names them and `./scripts/ci.sh ` reruns one. CI runs it on Ubuntu and macOS, so a gate leaning on one host's sed/grep/awk can pass here and fail there. Not fail-fast. Two of its gates need interpretation rather than a rerun; see the sections below. ## 3. Code review diff --git a/CLAUDE.md b/CLAUDE.md index f69bc7e..a1aca91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ This repo is documentation and shell scripts; there is no build step. Every chec ./scripts/ci.sh [] # rerun one gate while iterating on a failure ``` -`scripts/ci.sh` is the **single source of the gate sequence** (process doc §2.14): CI (Ubuntu and macOS) and `/cdd-pre-pr` both call it, and neither holds a gate list. The 26 gates are the registry at the top of the script, one line each saying what it checks; each gate's script under `scripts/` also runs standalone. Needs bash >= 4, `shellcheck`, `jq` and `tar` — **a missing tool fails its gate, never skips it**. The run is not fail-fast. Mechanics: `doc/architecture/overview.md`. +`scripts/ci.sh` is the **single source of the gate sequence** (process doc §2.14): CI (Ubuntu and macOS) and `/cdd-pre-pr` both call it, and neither holds a gate list. The 27 gates are the registry at the top of the script, one line each saying what it checks; each gate's script under `scripts/` also runs standalone. Needs bash >= 4, `shellcheck`, `jq` and `tar` — **a missing tool fails its gate, never skips it**. The run is not fail-fast. Mechanics: `doc/architecture/overview.md`. When `/cdd-pre-pr` runs in this repo, the "build / format / lint / test" gates collapse into `./scripts/ci.sh` plus a doc reconciliation pass. @@ -69,11 +69,11 @@ When `/cdd-pre-pr` runs in this repo, the "build / format / lint / test" gates c | `demo/` | Demo / dogfooding subsystem (third artifact) | | `demo/seed/` | Filled-in "Markdown Renderer" project content (not template) | | `demo/{setup,teardown}.sh` | Create/teardown demo & dogfood instances; `lib.sh` shared | -| `scripts/` | `ci.sh` (the check runner: the gate registry) + the gate scripts it calls — smoke assertions, install smoke, host-tool portability check, command-set drift check, prompt-seam check, roadmap-length check (with whitelists), adapter-conformance check, code-host ladder assertion, adapter-bindings assertion | +| `scripts/` | `ci.sh` (the check runner: the gate registry) + the gate scripts it calls — smoke assertions, install smoke, toolchain-sync assertion, host-tool portability check, command-set drift check, prompt-seam check, roadmap-length check (with whitelists), adapter-conformance check, code-host ladder assertion, adapter-bindings assertion | | `.github/workflows/` | CI: `template-smoke.yml` delegates to `scripts/ci.sh` | | `.claude/commands/` | This repo's own slash commands | | `.cdd/` | This repo's own capability-adapter bindings: `code-host` and `tracker`, relative symlinks to the GitHub adapters under `tools/adapters/` | -| `tools/` | Bootstrap script + the canonical shared helpers (`cdd-worktree.sh`, `cdd-state.sh`, both self-installing) + the capability adapters under `adapters//.sh` (mirroring `~/.cdd/adapters/`; none installs as a rung — `cdd-worktree.sh install` copies them to the `~/.cdd/tools/adapters/` library that downstream `.cdd/` shims exec) | +| `tools/` | Bootstrap script + the canonical shared helpers (`cdd-worktree.sh`, `cdd-state.sh`, both self-installing and self-updating) + the capability adapters under `adapters//.sh` (mirroring `~/.cdd/adapters/`; none installs as a rung — `cdd-worktree.sh install` copies them to the `~/.cdd/tools/adapters/` library that downstream `.cdd/` shims exec) | ## Architecture diff --git a/doc/architecture/adr/0014-keep-the-install-current-by-syncing-on-pull.md b/doc/architecture/adr/0014-keep-the-install-current-by-syncing-on-pull.md new file mode 100644 index 0000000..3f5e9d9 --- /dev/null +++ b/doc/architecture/adr/0014-keep-the-install-current-by-syncing-on-pull.md @@ -0,0 +1,76 @@ +# 0014: Keep the install current by syncing on pull + +**Status:** Accepted + +## Context + +The shared helpers install as a *copy* under `~/.cdd/tools/` (process doc §2.8), so that the +commands keep working when no CDD checkout is around. The cost of a copy is that it goes stale. +Merging a helper fix updates nothing on a machine until someone re-runs `install`, and nothing +says so. Issue #124 was fixed by PR #125, and `cdd-worktree-done` then hit the fixed bug again +about 40 seconds after the merge: the install still held the old script. Three helper PRs in a +row (#122, #123, #125) had touched the helpers, so this is the normal case, not a rare one. + +Two more gaps sit next to it: + +- **Open shells.** A shell (or tmux pane) that sourced the helpers keeps the functions it + sourced, even after a reinstall. +- **Machines without a checkout.** A machine that installed with curl can only update by + re-running three long curl incantations, and has nothing to compare against. + +The rule to respect is §2.8's: the helper "probes the ground truth rather than assuming or +versioning", and installs are "newest wins, always from latest `main`". + +## Decision + +Three small pieces, none of which runs in the background or reaches the network on its own: + +1. **Sync on pull.** `install`, run from a CDD git checkout, writes a managed `post-merge` and + `post-rewrite` hook into that checkout (between them, every `git pull` variant). After a + pull that lands on the default branch, the hook runs the just-pulled checkout's own + `cdd-worktree.sh sync`, which reinstalls both helpers and the adapter library when any of + them differs from the install — by content (`cmp`), never a recorded version — and prints + one line. Pulls on other branches never install. The hook **reinstalls**, it does not just + warn: reinstalling is a mechanical step with nothing to decide, and §2.8 already says the + newest always wins. A hook the user wrote is never overwritten; under `core.hooksPath` + nothing is written. Both cases print a note. `cdd-worktree-done` already pulls the default + branch, so finishing a task updates the install. +2. **Self-reloading shells.** Each helper records its file's `cksum` when sourced. Each public + command re-sources the file when it has changed and re-dispatches to the new definition, in + the same shell, silently. +3. **One update command.** `bash ~/.cdd/tools/cdd-worktree.sh update` fetches upstream `main`'s + `tools/` (a shallow, blobless, sparse clone), compares it with the install, and installs it + when anything differs. It runs only when asked. `cdd-state`'s unknown-verb and invalid-stage + errors, the usual symptom of a project newer than its helper, name it. + +Rejected: + +- **A version stamp** in the install. It violates §2.8, and a content comparison costs nothing. +- **A symlink install** (`~/.cdd/tools/*` pointing into the checkout). It needs no code, but it + breaks "works without a live checkout", follows whatever branch the main worktree has checked + out, and dangles once `cdd-worktree-done` removes the feature worktree it came from. +- **Warning at every command** (the first plan): `install` records its source checkout, and + every `cdd-worktree*` command compares that checkout with the install. It also had a + throttled background fetch of upstream for curl installs, a scan of the project's command + prompts for `cdd-state` verbs the helper lacks, and hooks written into downstream clones. It + worked, but it was rejected at plan approval as too heavy and too invasive. The hook makes the + per-command comparison redundant, and the loud `cdd-state` error now explains itself. +- **A `post-checkout` hook.** No pull needs it, and it fires on every branch switch. +- **Re-running `done` after its own pull** to pick up a fix mid-run. `done` is not re-entrant + once it has moved to the main worktree. + +## Consequences + +- Merging a helper PR and pulling `main` is enough: the next command anywhere on the machine + runs the new code. Nothing prints while the install is current. +- The hook writes `~/.cdd/tools` during a `git pull`. That is its purpose, and it happens only + on the default branch of a checkout the user installed from. +- Known limits: the one command whose own pull brings a fix (`done`) still runs the old code to + the end. Shells opened before this change have no reload guard and need one restart. A + checkout first needs one manual `install` to get its hook. A curl install stays as old as its + last `update`. +- Downstream projects get nothing new: no hooks, no command changes. The template tells them + how the install stays current. +- The gates that run `install` now install from a copy of `tools/` outside any repo, so a check + run never hooks the developer's checkout. A new `toolchain-sync` gate covers the hook, the + reload and `update` (against a local stub upstream). diff --git a/doc/architecture/index.md b/doc/architecture/index.md index 6e35e58..b4fd637 100644 --- a/doc/architecture/index.md +++ b/doc/architecture/index.md @@ -23,3 +23,4 @@ How this repo is structured. This index is a pointer list — the content lives - [`0011-bind-adapters-through-a-machine-global-library.md`](adr/0011-bind-adapters-through-a-machine-global-library.md) — how an installer binds a project to a shipped adapter: `cdd-worktree.sh install` copies the adapters to a machine-global library that is not a ladder rung, and the committed `.cdd/` shim execs it through `$HOME`; a missing library is a broken adapter; the installers write the project rung only, never a secret - [`0012-drop-the-builtin-gh-rung.md`](adr/0012-drop-the-builtin-gh-rung.md) — dropping the built-in `gh` rung now that installers bind adapters: the ladder ends at "nothing", a missing adapter skips the feature with one `/cdd-retrofit` line (or stops where the feature is the invocation's point), per-call-site behaviour for the prompts and `done`/`gc`/`list`/`resume`, and the read-only adapter verbs that replace the `gh` settings allowlist - [`0013-same-list-same-scripts-and-host-tool-portability.md`](adr/0013-same-list-same-scripts-and-host-tool-portability.md) — "same list, same scripts", not "same command"; a `portability` gate and Ubuntu + macOS CI; bash >= 4; a missing tool fails its gate + - [`0014-keep-the-install-current-by-syncing-on-pull.md`](adr/0014-keep-the-install-current-by-syncing-on-pull.md) — keeping the installed helpers current: a sync hook in the CDD checkout reinstalls on a default-branch pull (content compare, never a version stamp), shells self-reload, `cdd-worktree.sh update` for checkout-less machines; why not a symlink install or per-command warnings diff --git a/doc/architecture/shell-helpers.md b/doc/architecture/shell-helpers.md index 26802d6..d359bce 100644 --- a/doc/architecture/shell-helpers.md +++ b/doc/architecture/shell-helpers.md @@ -4,7 +4,7 @@ How the two project-independent helpers — `tools/cdd-worktree.sh` (worktree li ## Install model -Both scripts are dual-mode: sourced, they define their shell functions; run directly with `install`, they set themselves up. `install` copies the script to a stable home under `~/.cdd/tools/` that does not depend on a live CDD checkout, appends a marker-guarded `source` line to `~/.bashrc` and `~/.zshrc`, and drops PATH shims into `~/.local/bin` (next section). The rc block is idempotent and self-repairing: re-running `install` re-enables a disabled block rather than skipping it because the marker is still present. `cdd-worktree.sh` needs bash >= 4 and refuses an older bash up front with one line: sourced, it returns before defining any function (so a dispatching shim reports the function missing); run as `install`, it exits before writing anything. `install-smoke-assert.sh` pins the guard's position on every host and its behaviour wherever a bash 3.2 exists (macOS CI). +Both scripts are dual-mode: sourced, they define their shell functions; run directly with `install`, they set themselves up. `install` copies the script to a stable home under `~/.cdd/tools/` that does not depend on a live CDD checkout, appends a marker-guarded `source` line to `~/.bashrc` and `~/.zshrc`, and drops PATH shims into `~/.local/bin` (next section). The rc block is idempotent and self-repairing: re-running `install` re-enables a disabled block rather than skipping it because the marker is still present. `cdd-worktree.sh` needs bash >= 4 and refuses an older bash up front with one line: sourced, it returns before defining any function (so a dispatching shim reports the function missing); run as `install`, it exits before writing anything. `install-smoke-assert.sh` pins the guard's position on every host and its behaviour wherever a bash 3.2 exists (macOS CI). Run from a CDD git checkout, `install` also hooks that checkout so its pulls keep the install current, and `cdd-worktree.sh update` refreshes an install that has no checkout; see [Staying current](#staying-current). On a machine without the CDD repo checked out (a fresh clone of only a downstream project), the same one-time install is a single command — fetched to disk first, since `curl … | bash` can't work (the installer copies itself from its own file path, which a piped stdin does not provide): @@ -33,11 +33,27 @@ The rc `source` line only reaches interactive shells (`~/.bashrc` returns early - **Dispatching shims** for the commands that change no cwd: `cdd-worktree-list`, `cdd-worktree-gc` and `cdd-state`. The shim sources the installed helper and dispatches to the function, so the command works in non-interactive shells too. It first checks that the helper file exists and then that sourcing it actually defined the function, failing with exit 127 and a reinstall hint if either is false — without those guards a missing or broken helper leaves the function undefined and the shim's own dispatch re-resolves through `PATH` to the shim, recursing without bound instead of erroring. The `cdd-state` shim is load-bearing for the workflow: the slash commands invoke `cdd-state set` from the non-interactive Bash tool, and without the shim every state write would silently no-op. - **Refuse-loudly shims** for the three commands that `cd` the caller's shell (`cdd-worktree`, `cdd-worktree-resume`, `cdd-worktree-done`). A shim runs in a subshell and cannot change its parent's cwd, so dispatching would strand the caller in the old directory while claiming success; the shim instead fails with an explanation to run the command as a sourced shell function. +## Staying current + +The install is a copy, so merging a helper fix changes nothing on a machine until something reinstalls it — and a shell that sourced the helpers keeps the old functions even then. Three pieces close that gap ([ADR 0014](adr/0014-keep-the-install-current-by-syncing-on-pull.md)). None runs in the background or reaches the network on its own, and none prints anything while the install is current. + +**The sync hook.** Run from a CDD git checkout, `install` writes a `post-merge` and a `post-rewrite` hook into the checkout's common hooks dir (`git rev-parse --git-path hooks`), which every worktree of the clone shares. "From a checkout" means `/tools/cdd-worktree.sh` is the running script (`-ef`, since the script path is logical and `--show-toplevel` physical) — never the installed copy, nor a copy of `tools/` that merely sits inside some repo. Between them the two hooks cover every `git pull`: a fast-forward or merge fires `post-merge`, a rebase over local commits fires `post-rewrite`. Each hook is a few stable lines calling the *just-pulled* checkout's own `tools/cdd-worktree.sh sync`, so the logic is always the current one. The hook ignores `post-rewrite`'s `amend` trigger (no pull), unsets the `GIT_DIR` git exports to a hook in a linked worktree, skips a checkout whose helper predates `sync`, and always exits 0. + +`sync` acts only when the checked-out branch is the default branch, so a feature worktree's `/cdd-merge-base`, which fires the same shared hook, never installs. The default branch comes from `origin/HEAD` via git alone: `cdd-worktree-default-branch` would resolve the code-host adapter and announce it inside every pull. Then, if any shipped helper or adapter differs (`cmp`) from its installed counterpart — a missing counterpart counts, an extra installed file never does — it re-runs both installs from the checkout with their output suppressed and prints one line; a failed reinstall prints one line naming the two install commands. A hook without the marker line is the user's: `install` leaves it byte-identical and prints the line to add to it. Under `core.hooksPath` it writes nothing and says so. Hooks are replaced by rename, never rewritten in place, because the running hook may be the one whose `sync` is reinstalling. `cdd-worktree-done` pulls the default branch in the main worktree, so finishing a task updates the install too. + +**Self-reload.** Each helper records its absolute path and `cksum` when it is sourced. Every public entry point — `cdd-worktree`, `cdd-worktree-done`, `-list`, `-gc`, `-resume`, and `cdd-state` — first calls `cdd-worktree-reload` (or `cdd-state-reload`): if the file changed on disk, it re-sources it and re-dispatches to the new definition in the same shell, so a `cd` still reaches the caller. Bash finishes the in-flight old body (it executes a copy of the function), and the new sum is recorded before the source, so a reload cannot loop. It is silent. The PATH shims source fresh and never reload; zsh records nothing and never reloads. A shell still holding code from before this mechanism has no reload guard and needs one restart. + +**`update`.** `bash ~/.cdd/tools/cdd-worktree.sh update` serves a machine with no checkout (the curl install), or one that wants the newest now. It makes a shallow, blobless, sparse clone of upstream `main`'s `tools/` into a temp dir (about 1.5 s), compares it with the install as `sync` does, and runs both installs from the clone when anything differs, printing "up to date" or "updated"; an unreachable upstream is one line, exit 1, nothing changed. `CDD_UPSTREAM_URL` overrides the upstream as a test knob. Because `update` overwrites the very script bash is executing, the dual-mode dispatch ends in an explicit `exit`: bash reads a script as it runs it, and after the overwrite it must never read past the dispatch block. `cdd-state`'s unknown-verb and invalid-stage errors (still exit 2) add one line naming `update`, since a project's commands outrunning the installed helper is their usual cause. + +**Known limit.** The command whose own pull brings a fix still runs the old code: `cdd-worktree-done` pulls mid-run and is not re-entrant once it has `cd`'d to the main worktree, so it is not re-executed. The next command runs the new code. + +`scripts/toolchain-sync-assert.sh` (the `toolchain-sync` gate) pins all of this offline, against a bare "upstream", a clone of it as the checkout, and a throwaway `HOME`. The gates that run `install` themselves (`install-smoke`, `adapter-bindings`) install from a copy of `tools/` outside any repo, so a check run never hooks the developer's own checkout. + ## The first prompt and its capability probe `cdd-worktree` is the one place where behaviour depends on the project's baseline: which prompt it hands the new session. It resolves that by **probing the ground truth** — whether the worktree it just created contains `.claude/commands/cdd-plan.md` — rather than by consulting a recorded version or marker, neither of which can be kept honest across a fleet. A retrofitted project is launched on `/cdd-plan`; one that is not gets the pre-split prose prompt naming the handoff's `## Implementation prompt` heading, which is why that heading is frozen. The `else` branch is the deprecation seam, removable once every project is retrofitted. -The probe covers only the helper-newer-than-project direction. For the reverse — a retrofitted project meeting a `cdd-state` that predates `plan_written`, which would reject the write and stall the task invisibly — the helper asks `cdd-state stages` (a read-only accessor answered before `cdd-state`'s own `jq` guard, so a host without `jq` still gets the true answer) and prints one warning line if the stage is missing. One direction it cannot cover at all is an outdated `cdd-worktree` itself: it has no code with which to warn about its own age. Both cases are pinned by `scripts/worktree-launch-assert.sh`. +The probe covers only the helper-newer-than-project direction. For the reverse — a retrofitted project meeting a `cdd-state` that predates `plan_written`, which would reject the write and stall the task invisibly — the helper asks `cdd-state stages` (a read-only accessor answered before `cdd-state`'s own `jq` guard, so a host without `jq` still gets the true answer) and prints one warning line, naming `update`, if the stage is missing. An outdated `cdd-worktree` cannot warn about its own age; that direction is closed instead by keeping the install current ([Staying current](#staying-current)). Both probe cases are pinned by `scripts/worktree-launch-assert.sh`. Inside the retrofitted branch the helper makes a second, independent decision: which **lane** the task takes. It reads `.lane` from the state record with the same `jq` idiom it already uses for `base_branch`, and launches `/cdd-small-change` only when the marker says `small` *and* `.claude/commands/cdd-small-change.md` exists in the worktree it just created. The two conditions are deliberately both required — the marker crosses machines on the task ref, so it can arrive in a project that does not ship the command. Every other outcome (no `jq`, no record, no marker, an unrecognised value, a helper predating this code) leaves the `/cdd-plan` default: a lost marker costs a window and can never skip a gate. `$handoff_dir` is absolute, so the read still resolves after the `cd` into the new worktree. Pinned by `scripts/worktree-launch-assert.sh` in all four directions. diff --git a/doc/features/template.md b/doc/features/template.md index f81d741..bc06826 100644 --- a/doc/features/template.md +++ b/doc/features/template.md @@ -11,7 +11,7 @@ A copy-paste directory (`template/`) plus a non-interactive bootstrap script (`t - `doc/knowledge_base/engineering-practices.md`: the engineering-practices contract skeleton — each practice marked *enforced* (a CDD gate guarantees it) or *expected* (committed, tracked on the roadmap until mechanized), with placeholders for the project's own test/CI/lint commands. Resolved at project start: through `/cdd-bootstrap`'s discovery on the guided path, through the template roadmap's pre-filled bootstrap phase on a files-only start. - `BOOTSTRAP.md`: meta-documentation for the bootstrap recipe. Not copied into the bootstrapped tree. -The template ships no shell helpers: the worktree helper (`tools/cdd-worktree.sh`) and the task-state helper (`tools/cdd-state.sh`) are project-independent scripts in the CDD repo that the user installs once, not per-project copies. +The template ships no shell helpers: the worktree helper (`tools/cdd-worktree.sh`) and the task-state helper (`tools/cdd-state.sh`) are project-independent scripts in the CDD repo that the user installs once, not per-project copies. Installed from a CDD checkout, they keep themselves current whenever that checkout's default branch is pulled; an install without a checkout updates with `bash ~/.cdd/tools/cdd-worktree.sh update` (ADR 0014). The bootstrap script substitutes the two identifiers (``, ``), writes the baseline marker `.claude/cdd-baseline` (the CDD repo commit the template was rendered from), runs `git init`, and creates the scaffold commit. With the opt-in `--tracker` / `--code-host` flags (and `--jira-site` / `--jira-key` for Jira, `--gitlab-project` / `--gitlab-url` for GitLab) it also writes `.cdd/` bindings: small shims that run a shipped adapter from the machine-global library `cdd-worktree.sh install` provides, so they carry no machine path and work from any clone. It also offers a render-only mode (`--stage`, with `--dir` and `--template-dir` overrides) that skips the git steps; `/cdd-retrofit` drives it. The `template-smoke` GitHub Actions workflow exercises the script on every PR, on Ubuntu and on macOS — including a staged render — and asserts that the bootstrapped tree has no stale placeholders, no dangling internal links, and a well-formed marker. It does so through `scripts/ci.sh`, the check runner that is the single source of this repo's gate sequence and is also what `/cdd-pre-pr` invokes locally. diff --git a/doc/knowledge_base/claude-driven-development.md b/doc/knowledge_base/claude-driven-development.md index 77eebbc..a5416e8 100644 --- a/doc/knowledge_base/claude-driven-development.md +++ b/doc/knowledge_base/claude-driven-development.md @@ -121,7 +121,7 @@ Being machine-global has a consequence worth stating as a rule, because it appli One version of the helper serves every project on the machine, while `.claude/commands/` and `CLAUDE.md` are retrofitted one project at a time, so a global artifact cannot be staged per project. Two rules follow. Where behaviour must differ, the helper **probes the ground truth rather than assuming or versioning** — it asks the worktree it is standing in whether that project has a given command file, which cannot go stale the way a recorded baseline or version field can. Everything else stays **additive**, so it is inert where no project uses it yet. A workflow change that needs both sides therefore ships global-first and additive, then per-project, in any order and with no flag day. Where a skew is nonetheless possible in the direction a probe cannot cover, the helper prints one warning line rather than degrading silently. Generalizing this into a real versioning and fleet-migration mechanism is deferred design (§6). -The helper installs itself once per machine (`tools/cdd-worktree.sh install`) to a stable home that does not depend on a live CDD checkout; after that the commands work in every CDD project — including ones bootstrapped later — with no per-project setup. It is a machine-global toolchain dependency, like `git` or `gh`: newest wins, install idempotent, always from latest `main` — never pinned per project, since pinning would reintroduce the very conflict a single shared helper avoids. Its contract with projects is deliberately tiny and grows only additively: the command names above plus the `~/.cdd/handoffs//.md` layout; when that shared state must evolve, the change ships as a one-shot migration inside `install`, re-homing every project at once. The same install also provides the machine-global adapter library that a project's committed capability bindings run (§2.16). The install and dispatch mechanics live in `doc/architecture/shell-helpers.md` and the script's own comments. +The helper installs itself once per machine (`tools/cdd-worktree.sh install`) to a stable home that does not depend on a live CDD checkout; after that the commands work in every CDD project — including ones bootstrapped later — with no per-project setup. It is a machine-global toolchain dependency, like `git` or `gh`: newest wins, install idempotent, always from latest `main` — never pinned per project, since pinning would reintroduce the very conflict a single shared helper avoids. Its contract with projects is deliberately tiny and grows only additively: the command names above plus the `~/.cdd/handoffs//.md` layout; when that shared state must evolve, the change ships as a one-shot migration inside `install`, re-homing every project at once. The same install also provides the machine-global adapter library that a project's committed capability bindings run (§2.16). Staying newest is a mechanism, not a chore: an install made from a CDD checkout keeps itself in step with that checkout's default branch on every pull; a shell that loaded the helpers before an update runs the new ones from its next command; a machine without a checkout updates with one command. Freshness is judged by content, never by a recorded version, consistent with the probe rule above. The install, update and dispatch mechanics live in `doc/architecture/shell-helpers.md` and the script's own comments. These helpers encode an invariant worth stating explicitly: diff --git a/doc/knowledge_base/engineering-practices.md b/doc/knowledge_base/engineering-practices.md index fec671c..8104d8c 100644 --- a/doc/knowledge_base/engineering-practices.md +++ b/doc/knowledge_base/engineering-practices.md @@ -21,6 +21,7 @@ There is no unit-test suite; behaviour is exercised by integration-style smoke a - `./scripts/prompt-seam-check.sh` — deterministic seam contracts between the repo's own prompts: `/cdd-*` references resolve to a command file, the issue refs `/cdd-next-step` records are read back and turned into close lines by `/cdd-pre-pr`, backticked file paths resolve, each command keeps its load-bearing headings (matched by title, so renumbering a step is a no-op), the gate count stated in prose matches `./scripts/ci.sh list`, every open row of the template's engineering-practices contract is named in `/cdd-bootstrap`'s engineering-floor question, every plan-file section `/cdd-plan` writes is still named by `/cdd-implement`, the small-change lane's routing marker is still written and still read on both routing paths, the lane's eligibility heuristic is stated verbatim everywhere it is applied, and the seam-check count restated in prose in four files matches `./scripts/prompt-seam-check.sh list`. - `./scripts/roadmap-length-check.sh` — every item in all three roadmaps the repo ships (its own, the template's, the demo seed's), pending and completed alike, fits in 200 characters. The roadmap is loaded by every session, so an over-long item is a cost paid forever; the cap had been convention-only and 46 of 94 items had drifted past it. Carries an inline self-check (three fixtures either side of the cap, one multibyte) so a matcher that stopped matching cannot report clean. See ADR 0005. - `./scripts/install-smoke-assert.sh` — the worktree/state helpers' `install` contract against a throwaway HOME: the copied helper, the marker-guarded rc block and its self-repair, the PATH shims (including the loud refusal the cwd-changing ones must give), the legacy handoff migration, the option-shaped-branch rejection in all three commands that take a branch positionally, and three properties of `cdd-state seed` — the recorded session, the synced `refs/cdd/`, and `repo.json` naming the MAIN worktree. Offline: the ref push is redirected at a throwaway bare origin, so the gate has no network dependency and no side effects. Its shim probes run `bash --norc --noprofile = 4** (`mapfile`, `${var,,}`) — Homebrew bash on macOS, which ships 3.2. -- **git**, and **POSIX awk, sed and grep** — in any of the families in play: gawk, mawk and BWK awk; GNU and BSD sed/grep. +- **git**, and **POSIX awk, sed and grep** — in any of the families in play: gawk, mawk and BWK awk; GNU and BSD sed/grep. Also POSIX `cmp` and `cksum` (the helpers' freshness checks). - **jq**, **shellcheck** and **tar** — required by the gates; a missing one fails its gate. The shipped helpers still treat `jq` as advisory (issue #108). - **gh** or **curl** — the capability adapters, per backend. - **timeout** — one `install-smoke` assertion; falls back to `gtimeout`, then to none. diff --git a/scripts/adapter-bindings-assert.sh b/scripts/adapter-bindings-assert.sh index a24772a..4f3a095 100755 --- a/scripts/adapter-bindings-assert.sh +++ b/scripts/adapter-bindings-assert.sh @@ -27,7 +27,6 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -HELPER="$REPO_ROOT/tools/cdd-worktree.sh" BOOTSTRAP="$REPO_ROOT/tools/bootstrap-cdd-project.sh" fail() { echo "FAIL: $*" >&2; exit 1; } @@ -40,6 +39,11 @@ command -v jq >/dev/null 2>&1 || fail "jq is required and not installed" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT +# Install from a copy of tools/ outside any git repo: run from the checkout itself, +# install would also write its sync hooks into this repo's real .git/hooks. +cp -R "$REPO_ROOT/tools" "$WORK/tools" || fail "could not copy tools/ to scratch" +HELPER="$WORK/tools/cdd-worktree.sh" + export GIT_CONFIG_SYSTEM=/dev/null export GIT_CONFIG_GLOBAL="$WORK/gitconfig" cat > "$GIT_CONFIG_GLOBAL" <<'EOF' diff --git a/scripts/ci.sh b/scripts/ci.sh index 395a129..c7f7de5 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -88,6 +88,7 @@ GATES=( "seams-contract||the prompt-seam checker's own contract (mutation-tested)" "roadmap-length||roadmap item length: the 200-char cap" "install-smoke|jq|worktree/state helper install, against a throwaway HOME" + "toolchain-sync|jq|keeping the install current: the checkout's sync hook, self-reloading shells, update (stub upstream)" "worktree-resume|jq|worktree resume on an existing remote branch" "ref-sync|jq|refs/cdd/ handoff + plan + state round-trip" "gc|jq|worktree GC: reap merged tasks, keep scoped ones" @@ -167,6 +168,10 @@ gate_install_smoke() { ./scripts/install-smoke-assert.sh } +gate_toolchain_sync() { + ./scripts/toolchain-sync-assert.sh +} + gate_worktree_resume() { ./scripts/worktree-resume-assert.sh } diff --git a/scripts/install-smoke-assert.sh b/scripts/install-smoke-assert.sh index 69bd9c9..81833db 100755 --- a/scripts/install-smoke-assert.sh +++ b/scripts/install-smoke-assert.sh @@ -40,10 +40,17 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -HELPER="$REPO_ROOT/tools/cdd-worktree.sh" -STATE_HELPER="$REPO_ROOT/tools/cdd-state.sh" fail() { echo "FAIL: $*" >&2; exit 1; } + +# Install from a COPY of tools/ outside any git repo: run from the checkout itself, +# install would also write its sync hooks into this repo's real .git/hooks +# (toolchain-sync-assert.sh covers that path, against a throwaway clone). +TOOLS_COPY="$(mktemp -d)" +trap 'rm -rf "$TOOLS_COPY"' EXIT +cp -R "$REPO_ROOT/tools" "$TOOLS_COPY/tools" || fail "could not copy tools/ to scratch" +HELPER="$TOOLS_COPY/tools/cdd-worktree.sh" +STATE_HELPER="$TOOLS_COPY/tools/cdd-state.sh" pass() { echo "ok: $*"; } # Disable a managed rc block by prefixing every line from BEGIN to END with "# ", @@ -75,7 +82,7 @@ bash -n "$STATE_HELPER" || fail "state helper does not parse: $STATE_HELPER (tru FAKE_HOME="$(mktemp -d)" # Broken-install scratch, outside FAKE_HOME so copying FAKE_HOME never recurses. BROKEN_ROOT="$(mktemp -d)" -trap 'rm -rf "$FAKE_HOME" "$BROKEN_ROOT"' EXIT +trap 'rm -rf "$FAKE_HOME" "$BROKEN_ROOT" "$TOOLS_COPY"' EXIT # Seed a legacy handoff to exercise the migration branch. mkdir -p "$FAKE_HOME/.claude-handoffs/someproj" diff --git a/scripts/toolchain-sync-assert.sh b/scripts/toolchain-sync-assert.sh new file mode 100755 index 0000000..f4242c9 --- /dev/null +++ b/scripts/toolchain-sync-assert.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# Smoke for keeping the installed CDD toolchain current (ADR 0014), offline. +# +# Sync hook (install from a CDD checkout writes post-merge + post-rewrite into it): +# 1. install from a checkout writes both hooks, executable, carrying the marker; from a +# copy of tools/ outside any repo, or inside a repo that is not its checkout, none +# 2. a pull on the default branch that changes a helper reinstalls it: one `cdd:` line, +# the pull still exits 0 +# 3. a pull that changes nothing under tools/ prints nothing and touches no installed file +# 4. `git pull --rebase` over a local commit (the post-rewrite path) syncs too; a +# `commit --amend` (post-rewrite too, but no pull) never installs +# 5. a pull into a linked worktree on a feature branch (the hooks dir is shared) changes +# nothing — and, with that branch made the default, the same pull does sync, so the +# silence is the branch check rather than a hook that never ran there +# 6. a foreign hook is left byte-identical (with a note); under core.hooksPath no hook +# is written (with a note) +# 7. an adapter-only change on the default branch syncs the adapter library +# Self-reload (a shell that sourced the helpers before a reinstall): +# 8. a changed file is re-sourced on the next public call, which runs the new +# definition; an unchanged one is not re-sourced — for cdd-worktree* and cdd-state +# Update (`cdd-worktree.sh update`, upstream stubbed with a local file:// repo): +# 9. behind -> "Updated", installed files now upstream's; again -> "up to date"; +# unreachable -> one line, exit 1, nothing changed. Run from the installed copy it +# overwrites, so it also pins that bash never reads past the dispatch afterwards. +# cdd-state's loud failures: +# 10. an unknown verb and an invalid stage still exit 2, and name the update command +# +# Usage: scripts/toolchain-sync-assert.sh (provisions and tears down its own temp tree) + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "ok: $*"; } + +# jq is required: cdd-state answers an unknown verb or stage only past its jq guard +# (case 10). A missing tool is a failure, never a skip (scripts/ci.sh). +command -v jq >/dev/null 2>&1 || fail "jq is required and not installed" + +# Physical path: git hands hooks the physical top level. +WORK="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$WORK"' EXIT + +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_GLOBAL="$WORK/gitconfig" +cat > "$GIT_CONFIG_GLOBAL" <<'EOF' +[user] + name = CDD Smoke + email = smoke@example.com +[init] + defaultBranch = main +[commit] + gpgsign = false +EOF +# Everything below, hooks included, installs into this HOME. +export HOME="$WORK/home" +mkdir -p "$HOME" +unset CDD_UPSTREAM_URL + +INST="$HOME/.cdd/tools" +UPSTREAM="$WORK/upstream.git" +SEED="$WORK/seed" # where "upstream" commits are made +CHECKOUT="$WORK/cdd" # the machine's CDD checkout + +# --- fixture: an upstream carrying this tree's tools/, and a checkout of it ----- +git init -q --bare "$UPSTREAM" +# A blobless clone (update's fetch) asks the server for a filter. +git -C "$UPSTREAM" config uploadpack.allowFilter true +git clone -q "$UPSTREAM" "$SEED" 2>/dev/null +cp -R "$REPO_ROOT/tools" "$SEED/tools" +echo "# cdd" > "$SEED/README.md" +git -C "$SEED" add -A +git -C "$SEED" commit -q -m seed +git -C "$SEED" push -q -u origin main +git clone -q "$UPSTREAM" "$CHECKOUT" + +N=0 +# upstream_change ...: append a line to each file in the seed, commit, push. +upstream_change() { + N=$((N + 1)) + local f + for f in "$@"; do printf '# upstream change %s\n' "$N" >> "$SEED/$f"; done + git -C "$SEED" add -- "$@" + git -C "$SEED" commit -q -m "change $N" + git -C "$SEED" push -q origin main +} + +# pull ...: output to $WORK/pull.out; sets RC. +pull() { + local dir="$1"; shift + RC=0 + git -C "$dir" pull -q "$@" >"$WORK/pull.out" 2>&1 || RC=$? +} +cdd_lines() { grep -c '^cdd: ' "$WORK/pull.out" || true; } + +# The installed tree, as one comparable string. +snapshot() { + ( cd "$INST" && find . -type f | LC_ALL=C sort | while IFS= read -r f; do cksum "$f"; done ) +} + +# --- 1. install writes the hooks only into a checkout --------------------------- +bash "$CHECKOUT/tools/cdd-worktree.sh" install >"$WORK/install.out" 2>&1 \ + || fail "install from the checkout failed: $(cat "$WORK/install.out")" +bash "$CHECKOUT/tools/cdd-state.sh" install >/dev/null 2>&1 || fail "cdd-state install failed" +HOOKS="$CHECKOUT/.git/hooks" +for h in post-merge post-rewrite; do + [[ -f "$HOOKS/$h" && -x "$HOOKS/$h" ]] || fail "install did not write an executable $h hook" + grep -qF "# Managed by cdd-worktree.sh install (CDD toolchain sync)" "$HOOKS/$h" \ + || fail "$h hook carries no marker line" +done +grep -qF "Installed sync hooks" "$WORK/install.out" || fail "install did not say it wrote the hooks" + +mkdir -p "$WORK/plain" "$WORK/other" +cp -R "$REPO_ROOT/tools" "$WORK/plain/tools" +git init -q "$WORK/other" +mkdir -p "$WORK/other/vendor" +cp -R "$REPO_ROOT/tools" "$WORK/other/vendor/tools" +for src in "$WORK/plain/tools" "$WORK/other/vendor/tools"; do + out="$(HOME="$WORK/home-x" bash "$src/cdd-worktree.sh" install 2>&1)" \ + || fail "install from $src failed: $out" + grep -qF "sync hook" <<<"$out" && fail "install from a non-checkout copy ($src) touched hooks: $out" +done +[[ ! -e "$WORK/other/.git/hooks/post-merge" && ! -e "$WORK/other/.git/hooks/post-rewrite" ]] \ + || fail "install from a copy of tools/ inside an unrelated repo hooked that repo" +pass "install from a checkout writes the sync hooks; from a copy of tools/ it writes none" + +# --- 2. a helper change on the default branch reinstalls ------------------------- +upstream_change tools/cdd-state.sh +pull "$CHECKOUT" --no-rebase +(( RC == 0 )) || fail "pull exited $RC: $(cat "$WORK/pull.out")" +cmp -s "$INST/cdd-state.sh" "$CHECKOUT/tools/cdd-state.sh" \ + || fail "pull of a cdd-state.sh change did not reinstall it. Output: $(cat "$WORK/pull.out")" +[[ "$(cdd_lines)" -eq 1 ]] || fail "expected exactly one cdd: line. Output: $(cat "$WORK/pull.out")" +pass "a pull on the default branch that changes a helper reinstalls it, in one line" + +# --- 3. a pull that leaves tools/ alone is silent and changes nothing ------------ +before="$(snapshot)" +upstream_change README.md +pull "$CHECKOUT" --no-rebase +(( RC == 0 )) || fail "pull exited $RC: $(cat "$WORK/pull.out")" +[[ "$(cdd_lines)" -eq 0 ]] || fail "a README-only pull printed a cdd: line: $(cat "$WORK/pull.out")" +[[ "$(snapshot)" == "$before" ]] || fail "a README-only pull changed the install" +pass "a pull that changes no helper prints nothing and changes nothing" + +# --- 4. pull --rebase over a local commit (post-rewrite) -------------------------- +echo local > "$CHECKOUT/local.txt" +git -C "$CHECKOUT" add local.txt +git -C "$CHECKOUT" commit -q -m local +upstream_change tools/cdd-worktree.sh +pull "$CHECKOUT" --rebase +(( RC == 0 )) || fail "pull --rebase exited $RC: $(cat "$WORK/pull.out")" +cmp -s "$INST/cdd-worktree.sh" "$CHECKOUT/tools/cdd-worktree.sh" \ + || fail "pull --rebase did not reinstall cdd-worktree.sh. Output: $(cat "$WORK/pull.out")" +[[ "$(cdd_lines)" -eq 1 ]] || fail "expected exactly one cdd: line. Output: $(cat "$WORK/pull.out")" +pass "pull --rebase over a local commit syncs too (post-rewrite)" + +# post-rewrite also fires on `commit --amend`, which is no pull: a helper edit amended +# into main must not install. Then drop the amend again (reset runs no hook). +before="$(snapshot)" +echo "# local edit" >> "$CHECKOUT/tools/cdd-state.sh" +out="$(git -C "$CHECKOUT" commit -q -a --amend --no-edit 2>&1)" || fail "amend failed: $out" +[[ "$(snapshot)" == "$before" ]] || fail "commit --amend on the default branch changed the install" +grep -q '^cdd: ' <<<"$out" && fail "commit --amend printed a cdd: line: $out" +git -C "$CHECKOUT" reset -q --hard 'HEAD@{1}' +pass "commit --amend (post-rewrite, no pull) never installs" + +# --- 5a. a feature worktree's merge never installs ------------------------------ +FEAT="$WORK/cdd-feat" +git -C "$CHECKOUT" worktree add -q -b feat "$FEAT" main 2>/dev/null +echo feat > "$FEAT/feat.txt" +git -C "$FEAT" add feat.txt +git -C "$FEAT" commit -q -m feat +before="$(snapshot)" +upstream_change tools/adapters/tracker/github.sh +pull "$FEAT" --no-rebase --no-edit origin main +(( RC == 0 )) || fail "feature pull exited $RC: $(cat "$WORK/pull.out")" +cmp -s "$FEAT/tools/adapters/tracker/github.sh" "$SEED/tools/adapters/tracker/github.sh" \ + || fail "precondition: the feature worktree did not merge the adapter change" +[[ "$(cdd_lines)" -eq 0 ]] || fail "a feature-branch merge printed a cdd: line: $(cat "$WORK/pull.out")" +[[ "$(snapshot)" == "$before" ]] || fail "a feature-branch merge changed the install" +pass "a merge into a feature worktree (shared hooks dir) changes nothing" + +# --- 7. an adapter-only change on the default branch syncs the library ----------- +pull "$CHECKOUT" --no-rebase --no-edit +(( RC == 0 )) || fail "pull exited $RC: $(cat "$WORK/pull.out")" +cmp -s "$INST/adapters/tracker/github.sh" "$CHECKOUT/tools/adapters/tracker/github.sh" \ + || fail "an adapter-only pull did not sync the library. Output: $(cat "$WORK/pull.out")" +[[ -x "$INST/adapters/tracker/github.sh" ]] || fail "synced adapter is not executable" +[[ "$(cdd_lines)" -eq 1 ]] || fail "expected exactly one cdd: line. Output: $(cat "$WORK/pull.out")" +pass "an adapter-only change on the default branch syncs the adapter library" + +# --- 5b. the same feature pull syncs once its branch is the default --------------- +# Makes 5a non-vacuous: the hook does run in a linked worktree (where git exports +# GIT_DIR to it), and only the branch check kept it quiet there. +git -C "$CHECKOUT" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/feat +upstream_change tools/adapters/tracker/github.sh +pull "$FEAT" --no-rebase --no-edit origin main +git -C "$CHECKOUT" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main +(( RC == 0 )) || fail "feature pull exited $RC: $(cat "$WORK/pull.out")" +cmp -s "$INST/adapters/tracker/github.sh" "$FEAT/tools/adapters/tracker/github.sh" \ + || fail "the hook did not sync from a linked worktree on the default branch. Output: $(cat "$WORK/pull.out")" +[[ "$(cdd_lines)" -eq 1 ]] || fail "expected exactly one cdd: line. Output: $(cat "$WORK/pull.out")" +pass "the hook runs in a linked worktree too: only the branch check keeps 5a silent" + +# --- 6. foreign hooks and core.hooksPath ------------------------------------------ +git clone -q "$UPSTREAM" "$WORK/cdd2" +printf '#!/bin/sh\necho mine\n' > "$WORK/cdd2/.git/hooks/post-merge" +cp "$WORK/cdd2/.git/hooks/post-merge" "$WORK/foreign.orig" +out="$(HOME="$WORK/home-6" bash "$WORK/cdd2/tools/cdd-worktree.sh" install 2>&1)" \ + || fail "install beside a foreign hook failed: $out" +cmp -s "$WORK/cdd2/.git/hooks/post-merge" "$WORK/foreign.orig" \ + || fail "install modified a foreign post-merge hook" +grep -qF "is not CDD's" <<<"$out" || fail "no note about the foreign hook: $out" +grep -qF "# Managed by cdd-worktree.sh install" "$WORK/cdd2/.git/hooks/post-rewrite" \ + || fail "the free post-rewrite slot was not written beside a foreign post-merge" + +git clone -q "$UPSTREAM" "$WORK/cdd3" +git -C "$WORK/cdd3" config core.hooksPath "$WORK/userhooks" +out="$(HOME="$WORK/home-6" bash "$WORK/cdd3/tools/cdd-worktree.sh" install 2>&1)" \ + || fail "install under core.hooksPath failed: $out" +for h in post-merge post-rewrite; do + [[ ! -e "$WORK/cdd3/.git/hooks/$h" && ! -e "$WORK/userhooks/$h" ]] \ + || fail "install wrote a $h hook under core.hooksPath" +done +grep -qF "core.hooksPath" <<<"$out" || fail "no note about core.hooksPath: $out" +pass "a foreign hook is left byte-identical, and core.hooksPath gets no hook (each with a note)" + +# --- 8. self-reload --------------------------------------------------------------- +RELOAD="$WORK/reload" +mkdir -p "$RELOAD" "$HOME/.cdd/handoffs/cdd" +: > "$HOME/.cdd/handoffs/cdd/probe.md" + +# reload_probe : source a fresh copy of the +# installed , stub in the shell (a re-source restores the real one), +# apply to the copy ($1 inside it), then run . Prints its output. +reload_probe() { + local file="$1" fn="$2" stub="$3" edit="$4" call="$5" + cp "$INST/$file" "$RELOAD/$file" + # shellcheck disable=SC2016 # $1 expands in the probe shell + ( cd "$CHECKOUT" && bash --norc --noprofile -c \ + 'source "$1"; '"$fn"'() { '"$stub"'; }; '"$edit"'; '"$call" _ "$RELOAD/$file" &1 ) +} + +# shellcheck disable=SC2016 # the edits expand in the probe shell +{ + out="$(reload_probe cdd-worktree.sh cdd-worktree-no-adapter 'echo SHELL-STUB >&2' ':' cdd-worktree-list)" + grep -qx SHELL-STUB <<<"$out" || fail "an unchanged helper was re-sourced (the stub is gone): $out" + out="$(reload_probe cdd-worktree.sh cdd-worktree-no-adapter 'echo SHELL-STUB >&2' 'echo "# touched" >> "$1"' cdd-worktree-list)" + grep -qF "SHELL-STUB" <<<"$out" && fail "a changed helper was not re-sourced: $out" + grep -qF "code-host: no adapter installed" <<<"$out" || fail "the reloaded cdd-worktree-list did not run: $out" + out="$(reload_probe cdd-worktree.sh cdd-worktree-no-adapter : 'echo "cdd-worktree-list() { echo SENTINEL-WT; }" >> "$1"' cdd-worktree-list)" + [[ "$out" == "SENTINEL-WT" ]] || fail "cdd-worktree-list did not re-dispatch to the new definition, silently; got: $out" + + out="$(reload_probe cdd-state.sh cdd-state-stages 'echo SHELL-STUB' ':' 'cdd-state stages')" + [[ "$out" == "SHELL-STUB" ]] || fail "an unchanged cdd-state was re-sourced: $out" + out="$(reload_probe cdd-state.sh cdd-state-stages 'echo SHELL-STUB' 'echo "# touched" >> "$1"' 'cdd-state stages')" + grep -qx plan_written <<<"$out" || fail "a changed cdd-state was not re-sourced: $out" + out="$(reload_probe cdd-state.sh cdd-state-stages : 'echo "cdd-state() { echo SENTINEL-ST; }" >> "$1"' 'cdd-state stages')" + [[ "$out" == "SENTINEL-ST" ]] || fail "cdd-state did not re-dispatch to the new definition, silently; got: $out" +} +pass "a changed helper is re-sourced on the next call and runs the new code; an unchanged one is not" + +# --- 9. update against a stub upstream -------------------------------------------- +# The appended line runs only if bash reads the installed script past its dispatch +# after update has overwritten it, so its absence pins the dispatch's explicit exit. +N=$((N + 1)) +printf 'echo READ-PAST-DISPATCH\n' >> "$SEED/tools/cdd-worktree.sh" +printf '# upstream change %s\n' "$N" >> "$SEED/tools/cdd-state.sh" +git -C "$SEED" commit -q -am "change $N" +git -C "$SEED" push -q origin main + +RC=0 +out="$(CDD_UPSTREAM_URL="file://$UPSTREAM" bash "$INST/cdd-worktree.sh" update 2>&1)" || RC=$? +(( RC == 0 )) || fail "update exited $RC: $out" +grep -qF "Updated the CDD helpers from upstream main" <<<"$out" || fail "update did not say it updated: $out" +grep -qF READ-PAST-DISPATCH <<<"$out" && fail "bash read the overwritten script past its dispatch: $out" +cmp -s "$INST/cdd-worktree.sh" "$SEED/tools/cdd-worktree.sh" || fail "update did not install upstream's cdd-worktree.sh" +cmp -s "$INST/cdd-state.sh" "$SEED/tools/cdd-state.sh" || fail "update did not install upstream's cdd-state.sh" + +RC=0 +out="$(CDD_UPSTREAM_URL="file://$UPSTREAM" bash "$INST/cdd-worktree.sh" update 2>&1)" || RC=$? +(( RC == 0 )) || fail "update (current) exited $RC: $out" +[[ "$out" == "CDD helpers are up to date with upstream main." ]] || fail "update (current) said: $out" + +before="$(snapshot)" +RC=0 +out="$(CDD_UPSTREAM_URL="file://$WORK/no-such.git" bash "$INST/cdd-worktree.sh" update 2>&1)" || RC=$? +(( RC != 0 )) || fail "update from an unreachable upstream exited 0: $out" +if [[ "$(wc -l <<<"$out")" -ne 1 ]] || ! grep -qF "could not fetch" <<<"$out"; then + fail "update from an unreachable upstream should print one line; got: $out" +fi +[[ "$(snapshot)" == "$before" ]] || fail "update from an unreachable upstream changed the install" +pass "update: behind -> updated, current -> up to date, unreachable -> one line and nothing changed" + +# --- 10. cdd-state's loud failures name the update command ------------------------- +state_fails() { # state_fails ... + local out + # shellcheck disable=SC2016 # $1 expands in the probe shell + out="$(cd "$CHECKOUT" && bash --norc --noprofile -c \ + 'source "$1"; shift; cdd-state "$@"; echo "STATUS:$?"' _ "$REPO_ROOT/tools/cdd-state.sh" "$@" &1)" + grep -qx "STATUS:2" <<<"$out" || fail "cdd-state $* did not exit 2: $out" + grep -qF "cdd-worktree.sh update" <<<"$out" || fail "cdd-state $* did not name the update command: $out" +} +state_fails frobnicate +state_fails set no_such_stage +pass "cdd-state's unknown-verb and invalid-stage errors still exit 2 and name the update command" + +echo "all toolchain sync checks passed" diff --git a/template/BOOTSTRAP.md b/template/BOOTSTRAP.md index 4b7b37b..88eb6dd 100644 --- a/template/BOOTSTRAP.md +++ b/template/BOOTSTRAP.md @@ -117,6 +117,8 @@ The script will: Run from the CDD repo checkout, the install also copies the adapter library, `~/.cdd/tools/adapters/`, that the project's `.cdd/` bindings run (re-run it if you installed the helper before this CDD version). The curl form fetches only the helper; it prints the per-adapter curl line to fetch the rest. Until the library is there, a bound capability stops with that command rather than falling back to `gh`. + **Keeping them current.** Installed from a CDD repo checkout, the helpers update themselves whenever that checkout's default branch is pulled, and open shells pick up the new code on their next command. Without a checkout, update the helpers and the adapter library on demand with `bash ~/.cdd/tools/cdd-worktree.sh update`. Nothing checks for updates in the background. + 2. **Fill in `CLAUDE.md`**: the one-paragraph description, the critical constraints, the build/test commands, the module layout. Anything still wrapped in `<...>` is a stub waiting for you. Likewise fill in the project charter at `doc/knowledge_base/project-overview.md` — what the project is, its goals, what it does and explicitly does not do, its constraints and architecture intentions. (The Phase 1 bootstrap tasks also cover this; doing the thin version now is fine.) 3. **Look at the roadmap** in `doc/knowledge_base/roadmap.md`. The template ships Phase 1 pre-filled with the CDD bootstrap tasks (codebase survey, initial architecture and feature docs, CLAUDE.md stubs, roadmap fill) plus a suggested-infrastructure task list (CI, linting, tests, …) to slot into the real phases; the phases after Phase 1 are placeholders for the project's actual plan. You can write that plan by hand now, or let the Phase 1 "fill in this roadmap" task drive it through the workflow. diff --git a/tools/cdd-state.sh b/tools/cdd-state.sh index 04698a5..af1b174 100755 --- a/tools/cdd-state.sh +++ b/tools/cdd-state.sh @@ -23,6 +23,11 @@ # --create-dirs -o ~/.cdd/tools/cdd-state.sh \ # && bash ~/.cdd/tools/cdd-state.sh install # +# Kept current alongside cdd-worktree.sh (ADR 0014): a pull of a CDD checkout that +# install hooked reinstalls both, and `bash ~/.cdd/tools/cdd-worktree.sh update` +# updates both from upstream main. A shell that sourced this file before a reinstall +# re-sources it on its next `cdd-state` call (cdd-state-reload), silently. +# # Provides (when sourced): # cdd-state seed Create the record beside the handoff, at stage # `scoped`. Used by /cdd-next-step on the @@ -99,11 +104,38 @@ CDD_STATE_SCHEMA_VERSION=1 # record's: the two files carry unrelated shapes and can evolve apart. CDD_REPO_MARKER_SCHEMA_VERSION=1 +# Self-reload bookkeeping (bash only), as in cdd-worktree.sh: the absolute path this +# file was sourced from and its cksum then, so `cdd-state` can pick up a reinstall in a +# long-lived shell. Must not fail: bootstrap-cdd-project.sh sources this under set -e. +if [[ -n "${BASH_VERSION:-}" && -f "${BASH_SOURCE[0]:-}" ]]; then + _CDD_STATE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" + _CDD_STATE_SUM="$(cksum <"$_CDD_STATE_FILE" 2>/dev/null)" || true +fi + +# Re-source this file when it changed on disk since this shell sourced it; returns 0 +# when it reloaded (the caller re-dispatches), 1 otherwise. Same contract as +# cdd-worktree-reload: the new sum is recorded before the source, so it cannot loop. +cdd-state-reload() { + local sum + [[ -n "${_CDD_STATE_FILE:-}" && -f "$_CDD_STATE_FILE" ]] || return 1 + sum="$(cksum <"$_CDD_STATE_FILE" 2>/dev/null)" || return 1 + [[ "$sum" != "${_CDD_STATE_SUM:-}" ]] || return 1 + _CDD_STATE_SUM="$sum" + # shellcheck source=/dev/null + source "$_CDD_STATE_FILE" || return 1 +} + cdd-state-stages() { printf '%s\n' scoped plan_written implementation_done merged checks_passed \ pr_open addressed } +# The one line after an unknown verb or stage: the usual cause is a project's commands +# that are newer than this installed helper, so name the remedy. +cdd-state-update-hint() { + echo "cdd-state: if this project's commands need it, your installed helper may be older than them; update: bash ~/.cdd/tools/cdd-worktree.sh update" >&2 +} + # The MAIN worktree of the current repo — the dirname of git's common dir, NOT # `--show-toplevel`, which names the feature worktree when a task session asks. # Its basename is the repo name that namespaces ~/.cdd/handoffs//. @@ -215,6 +247,7 @@ cdd-state-push-ref() { } cdd-state() { + if cdd-state-reload; then cdd-state "$@"; return; fi # `stages` is a pure read of the lifecycle enum — no record, no jq. It is answered # BEFORE the jq guard below so that a capability probe (cdd-worktree's skew check, # §2.8) reads the real answer on a host without jq instead of an empty one. @@ -451,6 +484,7 @@ cdd-state() { done if [[ -z "$stage" ]] || ! cdd-state-stages | grep -qx "$stage"; then echo "cdd-state set: invalid stage '$stage' (one of: $(cdd-state-stages | paste -sd' '))" >&2 + cdd-state-update-hint return 2 fi # Refresh the per-repo marker first, deliberately BEFORE the absent-record @@ -518,6 +552,7 @@ cdd-state() { ;; *) echo "usage: cdd-state {seed [--base ] | lane | issue-refs ... | set [--pr N] | set-field [--branch ] | get | stages | install}" >&2 + cdd-state-update-hint return 2 ;; esac diff --git a/tools/cdd-worktree.sh b/tools/cdd-worktree.sh index ac30878..0a80010 100755 --- a/tools/cdd-worktree.sh +++ b/tools/cdd-worktree.sh @@ -30,6 +30,16 @@ # committed .cdd/ bindings exec, never a resolution-ladder rung (ADR 0011). The # curl form above fetches only this file, so it prints the per-adapter curl form. # +# Staying current (ADR 0014): run from a CDD git checkout, install also writes a +# post-merge + post-rewrite hook into it, so a pull that lands on its default branch +# with different helpers reinstalls them (`cdd-worktree.sh sync`, one line). Without a +# checkout, update on demand from upstream main (both helpers and the adapter library): +# +# bash ~/.cdd/tools/cdd-worktree.sh update +# +# A shell that sourced the helpers before a reinstall re-sources them on its next +# cdd-worktree* call (cdd-worktree-reload), silently. +# # The helper is a machine-global toolchain dependency, like git or gh: one install # per machine, newest wins, install is idempotent (re-run to upgrade). Its contract # with projects is frozen and deliberately tiny -- the three command names below @@ -103,6 +113,33 @@ if [[ -n "${BASH_VERSION:-}" ]] && (( BASH_VERSINFO[0] < 4 )); then return 1 2>/dev/null || exit 1 fi +# Self-reload bookkeeping (bash only, like the guard above): the absolute path this file +# was sourced from and its cksum at that moment. A long-lived shell keeps the functions +# it sourced, so after a reinstall each public command re-sources the file and +# re-dispatches (cdd-worktree-reload). -f, not -e: a file sourced from a pipe has no +# stable path to come back to. +if [[ -n "${BASH_VERSION:-}" && -f "${BASH_SOURCE[0]:-}" ]]; then + _CDD_WORKTREE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" + _CDD_WORKTREE_SUM="$(cksum <"$_CDD_WORKTREE_FILE" 2>/dev/null)" || true +fi + +# Re-source this file when it changed on disk since this shell sourced it (a reinstall), +# so an open shell runs current code. Returns 0 when it reloaded — the caller then +# re-dispatches to the fresh definition — and 1 when nothing changed or this shell has +# no recorded source (zsh, a PATH shim's fresh source never differs). The new sum is +# recorded BEFORE the source, so even a file without this bookkeeping cannot loop. Bash +# runs the caller's in-flight (old) body to completion, so the re-dispatch happens in +# the same shell and a `cd` still reaches it. Silent. +cdd-worktree-reload() { + local sum + [[ -n "${_CDD_WORKTREE_FILE:-}" && -f "$_CDD_WORKTREE_FILE" ]] || return 1 + sum="$(cksum <"$_CDD_WORKTREE_FILE" 2>/dev/null)" || return 1 + [[ "$sum" != "${_CDD_WORKTREE_SUM:-}" ]] || return 1 + _CDD_WORKTREE_SUM="$sum" + # shellcheck source=/dev/null + source "$_CDD_WORKTREE_FILE" || return 1 +} + # Resolve the capability adapter for down the ladder: the project's # .cdd/, then the machine's ~/.cdd/adapters/; the first FILE # present wins, so a broken project adapter never falls through to a machine one. @@ -382,6 +419,7 @@ cdd-worktree-default-branch() { } cdd-worktree() { + if cdd-worktree-reload; then cdd-worktree "$@"; return; fi local branch="$1" if [[ -z "$branch" ]]; then echo "usage: cdd-worktree " >&2 @@ -458,7 +496,7 @@ cdd-worktree() { # would reject `set plan_written` and stall the task silently. if ! cdd-state stages 2>/dev/null | grep -qx plan_written; then echo "This project uses the plan/implement split, but your cdd-state helper is" >&2 - echo "missing or predates it. Reinstall: ./tools/cdd-state.sh install" >&2 + echo "missing or predates it. Update: bash ~/.cdd/tools/cdd-worktree.sh update (or re-run install from your CDD checkout)" >&2 fi # Lane routing (§2.13): a task the human declared small at scoping replaces # plan+implement with the single /cdd-small-change session. Every miss — no jq, @@ -480,6 +518,7 @@ cdd-worktree() { } cdd-worktree-done() { + if cdd-worktree-reload; then cdd-worktree-done "$@"; return; fi # Resolve the code-host adapter before anything else: a broken one must stop the # command before the cd, the pull, or the worktree removal below. local CDD_ADAPTER="" CDD_ADAPTER_DESCRIBE="" CDD_ADAPTER_OUT="" CDD_ADAPTER_ERR="" rc=0 @@ -709,6 +748,7 @@ cdd-worktree-handoff-branches() { } cdd-worktree-list() { + if cdd-worktree-reload; then cdd-worktree-list "$@"; return; fi # Derive repo name from the main worktree so this works from any worktree. local repo_name repo_name="$(basename "$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")")" @@ -810,6 +850,7 @@ cdd-worktree-list() { # without one a merged task can't be told from a fresh one, so it reaps nothing (one # advisory line, ADR 0012). See shell-helpers.md. cdd-worktree-gc() { + if cdd-worktree-reload; then cdd-worktree-gc "$@"; return; fi local force=0 case "${1:-}" in --force|-f) force=1 ;; @@ -1077,6 +1118,7 @@ cdd-worktree-materialize-ref() { # (/cdd-process-pr, /cdd-merge-base, /cdd-pre-pr) read PR/branch state from git and # the code host, not the handoff, so its absence is still fine. cdd-worktree-resume() { + if cdd-worktree-reload; then cdd-worktree-resume "$@"; return; fi local branch="${1:-}" # No argument is the discovery mode, so only option-shaped input is rejected here. case "$branch" in @@ -1230,11 +1272,145 @@ cdd-worktree-resume() { fi } +# Whether the helpers under differ from the install: returns 0 iff any of its +# cdd-worktree.sh, cdd-state.sh or adapters/*/*.sh (those present) is not byte-identical +# to its counterpart under ~/.cdd/tools/ — a missing counterpart counts as different. +# Content, never a recorded version (§2.8). Extra installed files never count: install +# deletes nothing, so they would differ forever. +cdd-worktree-tools-differ() { + local dir="$1" inst="$HOME/.cdd/tools" f + shopt -s nullglob + for f in "$dir/cdd-worktree.sh" "$dir/cdd-state.sh" "$dir"/adapters/*/*.sh; do + [[ -f "$f" ]] || continue + if ! cmp -s "$f" "$inst/${f#"$dir"/}"; then + shopt -u nullglob + return 0 + fi + done + shopt -u nullglob + return 1 +} + +# `cdd-worktree.sh sync`: what the checkout's post-merge / post-rewrite hook runs (see +# cdd-worktree-install-hooks), with cwd = the worktree's top level. When the pull landed +# on the default branch and left this checkout's tools/ differing from the install, it +# re-runs both installs from here and prints one line; otherwise it prints nothing and +# changes nothing. Pulls on other branches (a feature worktree's /cdd-merge-base shares +# the hook) never install. Always returns 0: a hook must never fail the git command. +# +# The default branch comes from git alone, not cdd-worktree-default-branch: that one +# resolves the code-host adapter and would announce it inside every pull. +cdd-worktree-sync() { + local top ref default_branch + top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + # Nothing installed, nothing to keep in step. + [[ -n "$top" && -f "$HOME/.cdd/tools/cdd-worktree.sh" ]] || return 0 + if ref="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)"; then + default_branch="${ref#origin/}" + else + default_branch=main + fi + [[ "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" == "$default_branch" ]] || return 0 + cdd-worktree-tools-differ "$top/tools" || return 0 + if bash "$top/tools/cdd-worktree.sh" install >/dev/null 2>&1 \ + && bash "$top/tools/cdd-state.sh" install >/dev/null 2>&1; then + echo "cdd: updated the installed CDD helpers from this pull ($top); open shells pick it up on their next command." >&2 + else + echo "cdd: this pull changed the CDD helpers, but reinstalling failed; run: bash $top/tools/cdd-worktree.sh install && bash $top/tools/cdd-state.sh install" >&2 + fi + return 0 +} + +# Write the managed sync hooks (post-merge, post-rewrite: between them every `git pull` +# variant) into the CDD checkout at , so a pull of the default branch keeps the +# install current. Called by install only when it runs from a CDD git checkout. The +# hooks dir is the common one, shared by every worktree of the clone. Never overwrites a +# hook it did not write (no marker line): it says what to add instead. Under +# core.hooksPath it writes nothing and says so. A hook is replaced by rename, never +# rewritten in place, so a hook running this very install (via sync) keeps reading +# its old file. +cdd-worktree-install-hooks() { + local top="$1" hooks_dir name hook tmp + local marker="# Managed by cdd-worktree.sh install (CDD toolchain sync)" + local -a written=() + if [[ -n "$(git -C "$top" config core.hooksPath 2>/dev/null)" ]]; then + echo "Note: core.hooksPath is set for $top, so no sync hook was written; pulls there will not update the install (after one, run: bash $top/tools/cdd-worktree.sh install)." >&2 + return 0 + fi + hooks_dir="$(git -C "$top" rev-parse --path-format=absolute --git-path hooks 2>/dev/null)" || return 0 + mkdir -p "$hooks_dir" || return 0 + for name in post-merge post-rewrite; do + hook="$hooks_dir/$name" + if [[ -e "$hook" ]] && ! grep -qF "$marker" "$hook" 2>/dev/null; then + echo "Note: $hook is not CDD's, so it was left alone; to keep the install current on pull, add to it: bash \"\$(git rev-parse --show-toplevel)/tools/cdd-worktree.sh\" sync" >&2 + continue + fi + tmp="$(mktemp "$hook.XXXXXX")" || continue + # The hook stays a stable few lines: all logic is in the checkout's own `sync`, so a + # pull always runs the code it just pulled. + cat >"$tmp" </dev/null \\ + && bash "\$top/tools/cdd-worktree.sh" sync +exit 0 +HOOK + chmod +x "$tmp" + if cmp -s "$tmp" "$hook"; then + rm -f "$tmp" + else + mv -f "$tmp" "$hook" + fi + written+=("$name") + done + (( ${#written[@]} )) \ + && echo "Installed sync hooks (${written[*]}) in $hooks_dir: a pull of the default branch keeps the install current." + return 0 +} + +# `cdd-worktree.sh update`: bring the install up to date with upstream main, for a +# machine with no CDD checkout (or one that wants it now). On demand only — no command +# ever reaches the network on its own. Fetches just tools/ (a shallow, blobless, sparse +# clone into a temp dir), compares it with the install, and runs the normal installs +# from that clone when anything differs. One result line; an unreachable upstream is +# one line, exit 1, and changes nothing. CDD_UPSTREAM_URL overrides the upstream (a test +# knob: the toolchain-sync gate points it at a local stub). +cdd-worktree-update() { + local url="${CDD_UPSTREAM_URL:-https://github.com/drabaioli/cdd.git}" tmp rc=0 + tmp="$(mktemp -d)" || return 1 + if ! GIT_TERMINAL_PROMPT=0 git clone -q --depth 1 --filter=blob:none --sparse "$url" "$tmp/cdd" >/dev/null 2>&1 \ + || ! git -C "$tmp/cdd" sparse-checkout set tools >/dev/null 2>&1; then + echo "cdd: could not fetch $url; nothing changed." >&2 + rm -rf "$tmp" + return 1 + fi + if ! cdd-worktree-tools-differ "$tmp/cdd/tools"; then + echo "CDD helpers are up to date with upstream main." + elif bash "$tmp/cdd/tools/cdd-worktree.sh" install >/dev/null 2>&1 \ + && bash "$tmp/cdd/tools/cdd-state.sh" install >/dev/null 2>&1; then + # Installing from the clone also hooks the clone itself; harmless, it is deleted below. + echo "Updated the CDD helpers from upstream main; open shells pick it up on their next command." + else + echo "cdd: fetched upstream main, but installing it failed, so the install may be partly updated; re-run: bash ~/.cdd/tools/cdd-worktree.sh update" >&2 + rc=1 + fi + rm -rf "$tmp" + return "$rc" +} + # Install this helper to its stable home and wire it into the user's shells. # Run directly (`tools/cdd-worktree.sh install`), never sourced. Idempotent. cdd-worktree-install() { if [[ $# -gt 0 && "$1" != "install" ]]; then - echo "usage: cdd-worktree.sh [install]" >&2 + echo "usage: cdd-worktree.sh [install|update|sync]" >&2 return 2 fi @@ -1278,6 +1454,16 @@ cdd-worktree-install() { echo " curl -fsSL https://raw.githubusercontent.com/drabaioli/cdd/main/tools/adapters//.sh --create-dirs -o ~/.cdd/tools/adapters//.sh && chmod +x ~/.cdd/tools/adapters//.sh" >&2 fi + # Run from a CDD git checkout, hook it so a pull of its default branch keeps this + # install current (cdd-worktree-install-hooks). -ef, not a string compare: $src is + # built from the logical pwd, --show-toplevel answers the physical path. Never from + # the installed copy, nor from a copy of tools/ that merely sits inside some repo. + local src_top + src_top="$(git -C "$src_dir" rev-parse --show-toplevel 2>/dev/null)" || src_top="" + if [[ -n "$src_top" && "$src_top/tools/cdd-worktree.sh" -ef "$src" ]]; then + cdd-worktree-install-hooks "$src_top" + fi + # Wire each shell rc that exists; create ~/.bashrc if neither exists so there # is always at least one entry point. local marker_begin="# --- CDD worktree helper (managed by cdd-worktree.sh install) BEGIN ---" @@ -1395,8 +1581,15 @@ SHIM echo "Done. Open a new shell (or 'source' your rc) so cdd-worktree* are available." } -# Dual-mode: when executed directly, run the installer; when sourced, only the -# functions above are defined. +# Dual-mode: when executed directly, run the installer (or `update` / `sync`); when +# sourced, only the functions above are defined. The explicit exit is load-bearing: +# `update` run as `bash ~/.cdd/tools/cdd-worktree.sh update` overwrites this very file, +# and bash reads a script as it goes, so it must never read past this block afterwards. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - cdd-worktree-install "$@" + case "${1:-}" in + sync) cdd-worktree-sync ;; + update) cdd-worktree-update ;; + *) cdd-worktree-install "$@" ;; + esac + exit $? fi From 6f607a9445435cf37dbebe362a6703654c9007bc Mon Sep 17 00:00:00 2001 From: Diego Andres Rabaioli Date: Mon, 5 Oct 2026 22:24:35 +0200 Subject: [PATCH 2/3] Reconcile README: how the installed helpers stay current Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JyhD5C5M5Vsi6k7jzdzEwS --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91c1424..cf098b8 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ curl -fsSL https://raw.githubusercontent.com/drabaioli/cdd/main/tools/cdd-worktr CDD's scripts need bash >= 4; macOS ships 3.2, so install Homebrew bash first (`brew install bash`). On an older bash the helper refuses to load, with a one-line message. -Either form wires `~/.bashrc` and `~/.zshrc` (idempotent); open a new shell afterwards. A project whose `.cdd/` binds a tracker or code host also needs the adapter library at `~/.cdd/tools/adapters/`: the checkout form installs it, while the curl form fetches only the helper and prints the one-line fetch for each adapter (see `template/BOOTSTRAP.md`). It spins up and tears down the per-task git worktree that a task's plan and implementation sessions run in, and `cdd-worktree-resume []` recreates that worktree on a second machine — tracking the existing remote branch — so a task started elsewhere can be picked up to run `/cdd-implement`, `/cdd-process-pr`, `/cdd-merge-base`, or `/cdd-pre-pr`. The task's handoff, plan file and state record ride along too, synced through a per-task git ref (advisory — resume still works without them). `cdd-worktree-gc` periodically reaps the handoff, plan file, state record, and synced ref of tasks whose PR has merged (dry-run unless `--force`), so those artifacts don't accumulate across machines. Once a task's PR has merged, `cdd-worktree-done` (or, as backstop, `cdd-worktree-gc`) also closes the issues the task was sourced from, through the tracker, and links the merged PR on each it closes. +Either form wires `~/.bashrc` and `~/.zshrc` (idempotent); open a new shell afterwards. A project whose `.cdd/` binds a tracker or code host also needs the adapter library at `~/.cdd/tools/adapters/`: the checkout form installs it, while the curl form fetches only the helper and prints the one-line fetch for each adapter (see `template/BOOTSTRAP.md`). The checkout form keeps itself current: a pull of the checkout's default branch reinstalls the helpers when they changed, and open shells pick up the new code on their next command. Without a checkout, `bash ~/.cdd/tools/cdd-worktree.sh update` brings the helpers and the adapter library up to date with upstream `main`. It spins up and tears down the per-task git worktree that a task's plan and implementation sessions run in, and `cdd-worktree-resume []` recreates that worktree on a second machine — tracking the existing remote branch — so a task started elsewhere can be picked up to run `/cdd-implement`, `/cdd-process-pr`, `/cdd-merge-base`, or `/cdd-pre-pr`. The task's handoff, plan file and state record ride along too, synced through a per-task git ref (advisory — resume still works without them). `cdd-worktree-gc` periodically reaps the handoff, plan file, state record, and synced ref of tasks whose PR has merged (dry-run unless `--force`), so those artifacts don't accumulate across machines. Once a task's PR has merged, `cdd-worktree-done` (or, as backstop, `cdd-worktree-gc`) also closes the issues the task was sourced from, through the tracker, and links the merged PR on each it closes. ## Questions? From 5e2369d8f2761091288b171d0bbe437470523fe1 Mon Sep 17 00:00:00 2001 From: Diego Andres Rabaioli Date: Tue, 6 Oct 2026 07:57:41 +0200 Subject: [PATCH 3/3] Compress shell-helpers.md "Staying current" (PR #126 review) Trim the section to its invariants; mechanics stay in the code comments and the rationale in ADR 0014. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JyhD5C5M5Vsi6k7jzdzEwS --- doc/architecture/shell-helpers.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/doc/architecture/shell-helpers.md b/doc/architecture/shell-helpers.md index d359bce..1303c47 100644 --- a/doc/architecture/shell-helpers.md +++ b/doc/architecture/shell-helpers.md @@ -4,7 +4,7 @@ How the two project-independent helpers — `tools/cdd-worktree.sh` (worktree li ## Install model -Both scripts are dual-mode: sourced, they define their shell functions; run directly with `install`, they set themselves up. `install` copies the script to a stable home under `~/.cdd/tools/` that does not depend on a live CDD checkout, appends a marker-guarded `source` line to `~/.bashrc` and `~/.zshrc`, and drops PATH shims into `~/.local/bin` (next section). The rc block is idempotent and self-repairing: re-running `install` re-enables a disabled block rather than skipping it because the marker is still present. `cdd-worktree.sh` needs bash >= 4 and refuses an older bash up front with one line: sourced, it returns before defining any function (so a dispatching shim reports the function missing); run as `install`, it exits before writing anything. `install-smoke-assert.sh` pins the guard's position on every host and its behaviour wherever a bash 3.2 exists (macOS CI). Run from a CDD git checkout, `install` also hooks that checkout so its pulls keep the install current, and `cdd-worktree.sh update` refreshes an install that has no checkout; see [Staying current](#staying-current). +Both scripts are dual-mode: sourced, they define their shell functions; run directly with `install`, they set themselves up. `install` copies the script to a stable home under `~/.cdd/tools/` that does not depend on a live CDD checkout, appends a marker-guarded `source` line to `~/.bashrc` and `~/.zshrc`, and drops PATH shims into `~/.local/bin` (next section). The rc block is idempotent and self-repairing: re-running `install` re-enables a disabled block rather than skipping it because the marker is still present. `cdd-worktree.sh` needs bash >= 4 and refuses an older bash up front with one line: sourced, it returns before defining any function (so a dispatching shim reports the function missing); run as `install`, it exits before writing anything. `install-smoke-assert.sh` pins the guard's position on every host and its behaviour wherever a bash 3.2 exists (macOS CI). How the install stays current: [Staying current](#staying-current). On a machine without the CDD repo checked out (a fresh clone of only a downstream project), the same one-time install is a single command — fetched to disk first, since `curl … | bash` can't work (the installer copies itself from its own file path, which a piped stdin does not provide): @@ -35,19 +35,17 @@ The rc `source` line only reaches interactive shells (`~/.bashrc` returns early ## Staying current -The install is a copy, so merging a helper fix changes nothing on a machine until something reinstalls it — and a shell that sourced the helpers keeps the old functions even then. Three pieces close that gap ([ADR 0014](adr/0014-keep-the-install-current-by-syncing-on-pull.md)). None runs in the background or reaches the network on its own, and none prints anything while the install is current. +The install is a copy, so a merged helper fix reaches nothing until something reinstalls it, and an open shell keeps its old functions even then. Three pieces close the gap ([ADR 0014](adr/0014-keep-the-install-current-by-syncing-on-pull.md)); none runs in the background, reaches the network unasked, or prints while the install is current. -**The sync hook.** Run from a CDD git checkout, `install` writes a `post-merge` and a `post-rewrite` hook into the checkout's common hooks dir (`git rev-parse --git-path hooks`), which every worktree of the clone shares. "From a checkout" means `/tools/cdd-worktree.sh` is the running script (`-ef`, since the script path is logical and `--show-toplevel` physical) — never the installed copy, nor a copy of `tools/` that merely sits inside some repo. Between them the two hooks cover every `git pull`: a fast-forward or merge fires `post-merge`, a rebase over local commits fires `post-rewrite`. Each hook is a few stable lines calling the *just-pulled* checkout's own `tools/cdd-worktree.sh sync`, so the logic is always the current one. The hook ignores `post-rewrite`'s `amend` trigger (no pull), unsets the `GIT_DIR` git exports to a hook in a linked worktree, skips a checkout whose helper predates `sync`, and always exits 0. +**Sync hook.** Run from a CDD git checkout, `install` writes `post-merge` and `post-rewrite` hooks into the clone's shared hooks dir — between them, every `git pull`. Each hook calls the just-pulled checkout's own `tools/cdd-worktree.sh sync`, so the logic is always current, and always exits 0. `sync` acts only on the default branch (so `/cdd-merge-base` in a feature worktree never installs), and reinstalls both helpers when any shipped helper or adapter differs (`cmp`) from the install, printing one line. A hook the user wrote is left untouched; under `core.hooksPath` nothing is written; both say so. Hooks are replaced by rename, since the running hook may be the one being replaced. `cdd-worktree-done` pulls the default branch, so finishing a task updates the install. -`sync` acts only when the checked-out branch is the default branch, so a feature worktree's `/cdd-merge-base`, which fires the same shared hook, never installs. The default branch comes from `origin/HEAD` via git alone: `cdd-worktree-default-branch` would resolve the code-host adapter and announce it inside every pull. Then, if any shipped helper or adapter differs (`cmp`) from its installed counterpart — a missing counterpart counts, an extra installed file never does — it re-runs both installs from the checkout with their output suppressed and prints one line; a failed reinstall prints one line naming the two install commands. A hook without the marker line is the user's: `install` leaves it byte-identical and prints the line to add to it. Under `core.hooksPath` it writes nothing and says so. Hooks are replaced by rename, never rewritten in place, because the running hook may be the one whose `sync` is reinstalling. `cdd-worktree-done` pulls the default branch in the main worktree, so finishing a task updates the install too. +**Self-reload.** Each helper records its path and `cksum` when sourced. Every public command first checks it and, if the file changed, re-sources and re-dispatches in the same shell (so a `cd` still lands), silently. The sum is recorded before sourcing, so it cannot loop. PATH shims source fresh; zsh never reloads. Shells from before this mechanism need one restart. -**Self-reload.** Each helper records its absolute path and `cksum` when it is sourced. Every public entry point — `cdd-worktree`, `cdd-worktree-done`, `-list`, `-gc`, `-resume`, and `cdd-state` — first calls `cdd-worktree-reload` (or `cdd-state-reload`): if the file changed on disk, it re-sources it and re-dispatches to the new definition in the same shell, so a `cd` still reaches the caller. Bash finishes the in-flight old body (it executes a copy of the function), and the new sum is recorded before the source, so a reload cannot loop. It is silent. The PATH shims source fresh and never reload; zsh records nothing and never reloads. A shell still holding code from before this mechanism has no reload guard and needs one restart. +**`update`.** `bash ~/.cdd/tools/cdd-worktree.sh update` serves a machine without a checkout: it sparse-clones upstream `main`'s `tools/`, compares as `sync` does, and installs on any difference; an unreachable upstream is one line, exit 1, nothing changed. Since it overwrites the script bash is running, the dual-mode dispatch ends in an explicit `exit`. `cdd-state`'s unknown-verb and invalid-stage errors name `update`. -**`update`.** `bash ~/.cdd/tools/cdd-worktree.sh update` serves a machine with no checkout (the curl install), or one that wants the newest now. It makes a shallow, blobless, sparse clone of upstream `main`'s `tools/` into a temp dir (about 1.5 s), compares it with the install as `sync` does, and runs both installs from the clone when anything differs, printing "up to date" or "updated"; an unreachable upstream is one line, exit 1, nothing changed. `CDD_UPSTREAM_URL` overrides the upstream as a test knob. Because `update` overwrites the very script bash is executing, the dual-mode dispatch ends in an explicit `exit`: bash reads a script as it runs it, and after the overwrite it must never read past the dispatch block. `cdd-state`'s unknown-verb and invalid-stage errors (still exit 2) add one line naming `update`, since a project's commands outrunning the installed helper is their usual cause. +**Known limit.** The command whose own pull brings a fix (`cdd-worktree-done`) still runs the old code to the end; the next command runs the new. -**Known limit.** The command whose own pull brings a fix still runs the old code: `cdd-worktree-done` pulls mid-run and is not re-entrant once it has `cd`'d to the main worktree, so it is not re-executed. The next command runs the new code. - -`scripts/toolchain-sync-assert.sh` (the `toolchain-sync` gate) pins all of this offline, against a bare "upstream", a clone of it as the checkout, and a throwaway `HOME`. The gates that run `install` themselves (`install-smoke`, `adapter-bindings`) install from a copy of `tools/` outside any repo, so a check run never hooks the developer's own checkout. +The `toolchain-sync` gate (`scripts/toolchain-sync-assert.sh`) pins all of this offline. Gates that run `install` install from a copy of `tools/` outside any repo, so a check run never hooks the developer's checkout. ## The first prompt and its capability probe