From 3b61fde456a6cb20bd8bddb341f673e62a0a273e Mon Sep 17 00:00:00 2001 From: Kevin Wolf Date: Thu, 3 Sep 2026 19:21:31 -0600 Subject: [PATCH 1/6] feat(kit)!: the worktree belongs to the operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/dobby:scope` used to preflight a slug, create `worktree-` under `.claude/worktrees/` with the native `EnterWorktree`, and enter it; `/dobby:finish` assumed that exact worktree existed and tore it down by name. That ceremony — slug composition, collision and nesting detection, an orphan mode keyed by slug — duplicated what every host already does (`claude --worktree`, Claude Desktop, t3 code, an IDE's own worktrees, plain `git worktree add`), and it forbade running the lifecycle without a worktree at all: a goal on a branch in the main checkout had no path through the kit. dobby now runs wherever the session stands. `/dobby:scope` normalises the goal, writes `STATE.md` at the current workroot, and brings it up with `bunx dobby up` when `dobby.config.json` is present — it never creates, names, enters or preflights a worktree. The CLI's `scope preflight` command is gone with its helpers. `/dobby:finish` becomes symmetric: it merges behind the same gate, runs `bunx dobby down`, and only when the session stands in a LINKED worktree — whoever made it — offers to remove that worktree and its branch, trying `ExitWorktree` first (it restores the cwd when this session entered the worktree) and falling back to raw git from the main root; on a plain checkout it returns to `main`, pulls, and deletes the goal's branch. `finish --preflight` loses `--slug`, `candidates`, `mode` and `removeMechanism` and reports `inWorktree`, `worktreePath`, `mainRoot` and `branch` instead. The slug the kit derives elsewhere stays `basename(workroot)`: a checkout without worktrees has exactly one active goal, so its basename is the goal's name, and parallel goals are parallel worktrees the operator opens. BREAKING CHANGE: `/dobby:scope` no longer creates or enters a worktree — open one yourself or work on a branch. `dobby scope preflight` is removed. `dobby finish --preflight` no longer accepts `--slug` and its payload changed shape (see above). `/dobby:finish` on a plain checkout deletes the goal's branch after returning to `main`. `v0.16.md` walks it. This change was itself built without a kit-made worktree: a branch on the main checkout, `/dobby:dispatch` with three tasks, `bunx dobby up` reporting `slug: "dobby"` — the dogfood the decision promises. ADR-0033 records it with the rejected alternatives. --- CLAUDE.md | 2 +- CONTEXT.md | 10 +- README.md | 23 +- cli/CONTEXT.md | 11 +- cli/README.md | 10 +- cli/src/preflight.test.ts | 907 +++++++++--------- cli/src/preflight.ts | 412 ++------ cli/src/registry.test.ts | 19 +- cli/src/run.ts | 16 +- cli/src/tasks.ts | 8 +- ...33-the-worktree-belongs-to-the-operator.md | 15 + plugin/CONTEXT.md | 2 +- plugin/skills/finish/SKILL.md | 67 +- plugin/skills/learn/SKILL.md | 2 +- .../skills/learn/scripts/digest-transcript.py | 5 +- plugin/skills/mark/SKILL.md | 2 +- plugin/skills/mark/scripts/mark.sh | 5 +- plugin/skills/migrate-config/SKILL.md | 2 +- plugin/skills/onboard/SKILL.md | 2 +- plugin/skills/scope/SKILL.md | 68 +- plugin/skills/upgrade/SKILL.md | 2 +- plugin/skills/upgrade/references/v0.16.md | 21 + 22 files changed, 648 insertions(+), 963 deletions(-) create mode 100644 docs/adr/0033-the-worktree-belongs-to-the-operator.md create mode 100644 plugin/skills/upgrade/references/v0.16.md diff --git a/CLAUDE.md b/CLAUDE.md index 380e1fc..eb38485 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This repo is a Bun monorepo shipping two surfaces: **`plugin/`** — the Claude The domain glossary lives in [`CONTEXT.md`](./CONTEXT.md) — use those terms exactly. -**The kit runs on a single execution host: the terminal** — a plain interactive `claude` session (incl. ssh, and inside cmux) where Claude Code owns tools, sessions, hooks, worktrees, and background agents. `/dobby:scope` creates+enters the per-goal worktree (native `EnterWorktree`) and brings it up via `bunx dobby up` (which runs the setup phase — install + env-file materialization — probes the workroot, and returns the instructions the model carries out per `plugin/skills/execute/references/bring-up.md`; idempotent, so `/dobby:execute` Step 2 re-runs it without double-starting), and `/dobby:finish` closes the goal — it merges the goal's PR behind an explicit gate (squash, only on the user's "Merge & finish" selection and only once `dobby pr watch` reports merge-ready) when the PR is still open, then tears the worktree down via `bunx dobby down`. **cmux enrichment** (named run/browser panes, cmux-browser UI driver) kicks in when `CMUX_WORKSPACE_ID` is set — the model opens/renames/closes those panes itself, following the instructions `up`/`down` hand back — degrading gracefully to a background `Bash` job otherwise. The mechanical layer is the `@kvnwolf/dobby` CLI — each consumer's single devDependency — which the skills invoke via `bunx dobby`: `env` (environment snapshot), `instructions` (the per-topic instruction catalogue for the detected environment — `start`/`stop`/`browser`/`rename`), `check` (the quality gate + edit-time hook — the hook also reports type errors scoped to the file just edited; `check --fix` is the pre-commit gate, `check --fix --baseline` is the implementor's own Exit gate), `up`/`down`/`dev` (the run lifecycle — `up` folds in worktree setup), and the inferred `db:*` / `update` tasks. The coordinator + QA reach the running app via the devUrl `bunx dobby up` reports. The Architect dispatches the five worker agents directly (Agent tool, named `subagent_type`) following the shared dispatch protocol; Dobby never launches `claude -p`, a native Workflow, or another agent CLI to execute workers. (Conductor, the former second host, was removed; [ADR-0010](./docs/adr/0010-single-terminal-host.md) documents what it did, for a possible future re-add.) +**The kit runs on a single execution host: the terminal** — a plain interactive `claude` session (incl. ssh, and inside cmux) where Claude Code owns tools, sessions, hooks, worktrees, and background agents. `/dobby:scope` grounds the goal wherever the session already stands — a worktree the operator opened, or a plain checkout on a branch — writes `STATE.md` there, and brings it up via `bunx dobby up` (which runs the setup phase — install + env-file materialization — probes the workroot, and returns the instructions the model carries out per `plugin/skills/execute/references/bring-up.md`; idempotent, so `/dobby:execute` Step 2 re-runs it without double-starting), and `/dobby:finish` closes the goal — it merges the goal's PR behind an explicit gate (squash, only on the user's "Merge & finish" selection and only once `dobby pr watch` reports merge-ready) when the PR is still open, tears the run down via `bunx dobby down`, and — only when the session stands in a worktree — offers to remove it. **cmux enrichment** (named run/browser panes, cmux-browser UI driver) kicks in when `CMUX_WORKSPACE_ID` is set — the model opens/renames/closes those panes itself, following the instructions `up`/`down` hand back — degrading gracefully to a background `Bash` job otherwise. The mechanical layer is the `@kvnwolf/dobby` CLI — each consumer's single devDependency — which the skills invoke via `bunx dobby`: `env` (environment snapshot), `instructions` (the per-topic instruction catalogue for the detected environment — `start`/`stop`/`browser`/`rename`), `check` (the quality gate + edit-time hook — the hook also reports type errors scoped to the file just edited; `check --fix` is the pre-commit gate, `check --fix --baseline` is the implementor's own Exit gate), `up`/`down`/`dev` (the run lifecycle — `up` folds in worktree setup), and the inferred `db:*` / `update` tasks. The coordinator + QA reach the running app via the devUrl `bunx dobby up` reports. The Architect dispatches the five worker agents directly (Agent tool, named `subagent_type`) following the shared dispatch protocol; Dobby never launches `claude -p`, a native Workflow, or another agent CLI to execute workers. (Conductor, the former second host, was removed; [ADR-0010](./docs/adr/0010-single-terminal-host.md) documents what it did, for a possible future re-add.) ## Structure diff --git a/CONTEXT.md b/CONTEXT.md index bc16052..3181c31 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,8 +2,8 @@ The vocabulary of the dobby kit. Use these terms exactly — in skills, agents, docs, and conversation. -- **Execution host** — WHERE a work session runs. There is a SINGLE host: the **terminal host** (see its own entry). The KIT owns the worktree + run lifecycle; the coordinator and **QA** reach the running app via the devUrl `dobby env` resolves (portless-based, distinct per worktree) plus a curl liveness check — not by reading a run-script terminal — and drive the UI per **instruction catalogue** (`dobby instructions browser`, which is cmux's own ladder — cmux-browser → claude-in-chrome → curl — under cmux, and a different guide under Claude Desktop / t3 code). (Conductor, the former second host, was removed; everything it did is captured for a possible future re-add in [ADR-0010](./docs/adr/0010-single-terminal-host.md).) -- **Terminal host** — the kit's single execution host: a plain `claude` session (incl. ssh, and inside cmux), where the KIT owns the worktree + run lifecycle. `/dobby:scope` creates+enters the per-goal worktree (native `EnterWorktree`, branch `worktree-` under `.claude/worktrees/`) and brings it up via `bunx dobby up` (setup phase + probe), which `/dobby:execute` Step 2 re-runs idempotently, and `/dobby:finish` closes the goal — merging its PR behind an explicit gate when it is still open, then tearing it all down via `bunx dobby down`. The kit is OPERATED inside the session; environment mechanics are no longer fully delegated to the CLI — `dobby` detects the environment and hands the model an **instruction catalogue** entry for what it cannot do itself (starting the run, opening a browser, renaming the workspace, stopping the run), while `up`/`down` still execute the setup phase, the liveness probe, pidfile registration, Neon branch provisioning, and config `setup[]`/`teardown[]` extras. See also **cmux enrichment**, **Instruction catalogue**. +- **Execution host** — WHERE a work session runs. There is a SINGLE host: the **terminal host** (see its own entry). The KIT owns the run lifecycle — the session itself runs inside whatever checkout or worktree the operator opened; the coordinator and **QA** reach the running app via the devUrl `dobby env` resolves (portless-based, distinct per worktree) plus a curl liveness check — not by reading a run-script terminal — and drive the UI per **instruction catalogue** (`dobby instructions browser`, which is cmux's own ladder — cmux-browser → claude-in-chrome → curl — under cmux, and a different guide under Claude Desktop / t3 code). (Conductor, the former second host, was removed; everything it did is captured for a possible future re-add in [ADR-0010](./docs/adr/0010-single-terminal-host.md).) +- **Terminal host** — the kit's single execution host: a plain `claude` session (incl. ssh, and inside cmux), operated inside whatever checkout or worktree the operator already opened — the KIT owns the run lifecycle, not the worktree itself. `/dobby:scope` grounds the goal where the session stands, writes `STATE.md` there, and brings it up via `bunx dobby up` (setup phase + probe), which `/dobby:execute` Step 2 re-runs idempotently; `/dobby:finish` closes the goal — merging its PR behind an explicit gate when it is still open, tearing the run down via `bunx dobby down`, and, only when the session stands in a linked worktree, offering to remove it. Environment mechanics are no longer fully delegated to the CLI — `dobby` detects the environment and hands the model an **instruction catalogue** entry for what it cannot do itself (starting the run, opening a browser, renaming the workspace, stopping the run), while `up`/`down` still execute the setup phase, the liveness probe, pidfile registration, Neon branch provisioning, and config `setup[]`/`teardown[]` extras. See also **cmux enrichment**, **Instruction catalogue**. - **cmux enrichment** — the optional layer that activates when `CMUX_WORKSPACE_ID` is present (auto-set in every cmux pane; cmux = the manaflow-ai native macOS terminal). `bunx dobby up` no longer opens the kit panes itself — its `start`/`rename` **instructions** tell the MODEL to open them (a named run pane `dobby-run-`, a named browser pane `dobby-browser-`, and renaming the cmux workspace itself to the goal slug), embedding the refs its `list-panes`/`list-pane-surfaces` discovery found so the model reuses an open pane instead of always opening one; `bunx dobby down`'s `stop` instruction likewise tells the model which panes to close. `bunx dobby env` still surfaces the pane refs (`runPane`/`browserPane`, rediscovered by title) for a caller that only needs to read them. A plain terminal (ssh/tmux, no `CMUX_WORKSPACE_ID`) degrades gracefully: its `start` instruction is a background `Bash` job instead of a cmux pane, and `stop`/`rename` never apply. - **Instruction catalogue** — what an **environment adapter** delivers: per-**topic** instructions the MODEL executes because `dobby` cannot act there on its own behalf. This is the "instruction half" of the environment-adapter seam; the "mechanics half" — the setup phase, the liveness probe, pidfile registration, Neon branch provisioning, config `setup[]`/`teardown[]` extras — is what `dobby` still executes itself. `dobby instructions [--json]` reads one entry directly; `up`/`down` embed the applicable ones in their own `instructions[]`. - **Topic** — a unit of the **instruction catalogue**: `start` (bring the run up), `stop` (tear it down), `browser` (drive the UI), or `rename` (retitle the workspace to the goal slug). A topic that does not apply in the detected environment (e.g. `rename` on a plain terminal, which has no workspace to rename) is a valid answer — `applies: false` — never an error. @@ -15,9 +15,9 @@ The vocabulary of the dobby kit. Use these terms exactly — in skills, agents, - **Worker** — one of the five custom hands-on agents: `researcher`, `implementor`, `reviewer`, `qa`, and `test-author`. Each has a fixed role and toolset, with its prompt body authoritative in `plugin/agents/`, and model/effort declared directly in that same frontmatter — there is no external recipe to mirror. - **Work session** — one end-to-end run over a single goal, moving through stages: scope → interview → research → spec → execute → wrap. - **Stage** — one step of a work session. Each stage is a skill that does its job and ends at a Next-step AskUserQuestion gate (the recommended next `/dobby:*` command, alternatives, Stop here); the chosen skill is invoked on selection. Skills carry no per-skill model/effort — the session's tier applies throughout (ADR-0004). -- **Finish** — the goal-closing stage (`/dobby:finish`): a manual command run once the PR is merged — or merge-ready, since its confirm gate offers **Merge & finish** on an OPEN PR with a clean tree (a squash `gh pr merge`, fired ONLY on that explicit user selection and only after `dobby pr watch` answers `merge-ready`, then a preflight re-read). It checks the PR is MERGED (confirming before merging or destroying anything otherwise), runs **`bunx dobby down`** (which kills the registered run process, deletes the per-worktree Neon branch, and runs the config's `teardown[]` extras — then hands back the `stop` instruction for the model to close any kit-opened cmux pane with), removes the worktree + branch (native `ExitWorktree` when this session created it, raw-git fallback for orphans), and `git pull`s the main checkout. -- **One session per goal** — the invariant `/dobby:scope` enforces on the terminal host: each session/pane owns ONE goal plus its kit-created worktree on a goal-named branch. It is NOT "one worktree per machine" — MULTIPLE goals run in PARALLEL worktrees, one per cmux pane/session (the cmux value-prop). Scope guards only NESTING (a session already inside a worktree must start the next goal in a new pane, since the native tool can't nest) and slug collisions — it does NOT refuse merely because other sessions' worktrees exist in `.claude/worktrees/`. Teardown is per goal via `/dobby:finish`. -- **Goal slug** — the kebab-case token a work session is identified BY, and the only place that identity lives: the worktree directory `.claude/worktrees//`, the branch `worktree-`, the cmux workspace title, the kit pane titles and the per-worktree neon branch all derive from it. A free-text goal slugs its own text; an ISSUE goal LEADS with the tracker's id — `issue-42-add-csv-export`, `von-123-fix-stale-session-cache` — so the issue stays recognizable wherever the name gets clipped. +- **Finish** — the goal-closing stage (`/dobby:finish`): a manual command run once the PR is merged — or merge-ready, since its confirm gate offers **Merge & finish** on an OPEN PR with a clean tree (a squash `gh pr merge`, fired ONLY on that explicit user selection and only after `dobby pr watch` answers `merge-ready`, then a preflight re-read). It checks the PR is MERGED (confirming before merging or destroying anything otherwise), runs **`bunx dobby down`** (which kills the registered run process, deletes the per-worktree Neon branch, and runs the config's `teardown[]` extras — then hands back the `stop` instruction for the model to close any kit-opened cmux pane with), and then: when the session stands in a linked worktree (whoever made it), offers to remove that worktree + branch (native `ExitWorktree` first, then raw git from the main root for an orphan); on a plain checkout, returns to `main` and deletes the goal's branch instead. `git pull` always runs on the main checkout. +- **One session per goal** — each session/pane owns ONE goal at a time, worked wherever that session stands. It is NOT "one worktree per machine" — MULTIPLE goals run in PARALLEL worktrees, one per cmux pane/session (the cmux value-prop), each opened by the operator (or the host) rather than by the kit. Teardown is per goal via `/dobby:finish`. +- **Goal slug** — the kebab-case identity a work session is known BY: `basename(workroot)` — the name of whatever checkout or worktree the session already stands in, never a kit-assigned one. The cmux workspace title, the kit pane titles and the per-worktree neon branch all derive from it. A free-text goal slugs its own text; an ISSUE goal LEADS with the tracker's id — `issue-42-add-csv-export`, `von-123-fix-stale-session-cache` — so the issue stays recognizable wherever the name gets clipped. - **Context trim** — the inference-only `/dobby:trim-context` sweep that lowers the token/context cost of human-authored repository guidance and comments without changing behavior. It ranks the whole Git workroot by weight, obtains one up-front approval of scope and aggressiveness, runs unattended lots against that approval, and independently reviews every approved write. It is the sole owner of comment changes. It is not a **Gate** and never adds CLI, configuration, tooling, or executable behavior. - **AI slop** — generic AI-writing patterns that weaken a specific prose or user-facing-copy unit's clarity, specificity, voice, or purpose. `/dobby:anti-slop` identifies and makes minimum contextual fixes to AI slop without inferring authorship, assigning scores, banning occurrences, or editing comments. When both inference sweeps apply, **Context trim** runs before AI slop. - **Sweep ledger** — the tracked state file `.dobby/sweeps.json` that records reviewed sweep coverage with a rules version and SHA-256 hashes of exact final file bytes, keyed per file **and per skill**: each file's entry may hold a `trim-context` sub-key, an `anti-slop` sub-key, or both, and coverage accrues incrementally — an unresolved file simply carries no sub-key for that skill rather than voiding the sweep. Each skill reads and writes only its own so the two sweeps coexist without one invalidating the other's coverage. It is the deliberate exception to the inference-only/no-Gate boundary: persistent coverage state, not a configurable or executable surface. diff --git a/README.md b/README.md index d5cbac9..adaecb1 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Then start your first session from any project: ### The CLI -The repo also ships **`@kvnwolf/dobby`**, the kit's **mechanical execution layer** — a Bun CLI installed as each project's single devDependency (added by `/dobby:onboard`). It detects a project's capabilities from its dependencies and infers every task zero-config (à la Vercel): the quality gate (`dobby check` — also the edit hook, `dobby check --fix` the pre-commit gate, and `dobby check --pre-push` the git-hook backstop that refuses a red push) and the run lifecycle (`dobby up` / `dobby down` / `dobby dev`), where `dobby up` also brings a fresh worktree up (installs deps, materializes the env files, installs the pre-push hook) and probes it, handing back the instructions to start it (`dobby instructions `) — the two-step protocol every skill/agent follows. It mechanizes the kit's ceremonies too, so the skills keep the judgment and hand the mechanics over: `dobby ship` (stage → gate in-process → commit → push → PR), `dobby release` (the whole publish spine behind a per-target adapter), `dobby state` (the `STATE.md` engine), `dobby build-plan`, `dobby review` / `dobby pr watch`, the scope/finish/migrate preflights, the tracker + KB + ADR writers, and the artifact linters. It bundles the toolchain (Biome, TypeScript, knip, taze, portless) and ships every tool's config as a default: for biome, vite, vitest, and drizzle-kit, dobby passes its shipped, capability-picked preset through the tool's native config flag when the repo has no file of its own (**override by presence**), so a delta-less project carries only `package.json`, `tsconfig.json`, and `dobby.config.json` — you write a tool config file only to carry a real delta, and deleting it restores the default (a biome delta extends dobby's two FLAT presets, `biome/core` + `biome/react`, directly — biome's `extends` is one-level, so a react consumer lists both; without deltas, no `biome.jsonc` ships at all). External builders go through dobby too: a Vercel project sets its Build Command to `bunx dobby build`. Skills invoke it via `bunx dobby` (the local pinned bin). Full command reference: **[`cli/README.md`](./cli/README.md)** (the npm package front page). Releases are cut with `/dobby:release`, which runs `dobby release` and answers the two judgments it stops for (the version question and the notes). +The repo also ships **`@kvnwolf/dobby`**, the kit's **mechanical execution layer** — a Bun CLI installed as each project's single devDependency (added by `/dobby:onboard`). It detects a project's capabilities from its dependencies and infers every task zero-config (à la Vercel): the quality gate (`dobby check` — also the edit hook, `dobby check --fix` the pre-commit gate, and `dobby check --pre-push` the git-hook backstop that refuses a red push) and the run lifecycle (`dobby up` / `dobby down` / `dobby dev`), where `dobby up` also brings a fresh worktree up (installs deps, materializes the env files, installs the pre-push hook) and probes it, handing back the instructions to start it (`dobby instructions `) — the two-step protocol every skill/agent follows. It mechanizes the kit's ceremonies too, so the skills keep the judgment and hand the mechanics over: `dobby ship` (stage → gate in-process → commit → push → PR), `dobby release` (the whole publish spine behind a per-target adapter), `dobby state` (the `STATE.md` engine), `dobby build-plan`, `dobby review` / `dobby pr watch`, the finish/migrate preflights, the tracker + KB + ADR writers, and the artifact linters. It bundles the toolchain (Biome, TypeScript, knip, taze, portless) and ships every tool's config as a default: for biome, vite, vitest, and drizzle-kit, dobby passes its shipped, capability-picked preset through the tool's native config flag when the repo has no file of its own (**override by presence**), so a delta-less project carries only `package.json`, `tsconfig.json`, and `dobby.config.json` — you write a tool config file only to carry a real delta, and deleting it restores the default (a biome delta extends dobby's two FLAT presets, `biome/core` + `biome/react`, directly — biome's `extends` is one-level, so a react consumer lists both; without deltas, no `biome.jsonc` ships at all). External builders go through dobby too: a Vercel project sets its Build Command to `bunx dobby build`. Skills invoke it via `bunx dobby` (the local pinned bin). Full command reference: **[`cli/README.md`](./cli/README.md)** (the npm package front page). Releases are cut with `/dobby:release`, which runs `dobby release` and answers the two judgments it stops for (the version question and the notes). ## The mental model @@ -56,11 +56,11 @@ Each of the five agent prompt bodies is authoritative in `plugin/agents/`, and e ## Where it runs: the terminal host -dobby runs in a plain `claude` session — your terminal, including over ssh, and inside **cmux** (the manaflow-ai native macOS terminal). The kit owns the whole worktree + run lifecycle itself, mechanized by the `@kvnwolf/dobby` CLI: +dobby runs in a plain `claude` session — your terminal, including over ssh, and inside **cmux** (the manaflow-ai native macOS terminal) — operated inside whatever checkout or worktree you already opened. The kit owns the run lifecycle itself, mechanized by the `@kvnwolf/dobby` CLI: -- `/dobby:scope` creates and enters a per-goal git worktree, brings it up with `bunx dobby up`, then grounds the goal through researchers so the main-thread architect can plan from evidence. +- `/dobby:scope` grounds the goal wherever the session already stands — a worktree you opened (`claude --worktree`, an IDE, `git worktree add`) or a plain checkout on a branch — brings it up with `bunx dobby up`, then grounds the goal through researchers so the main-thread architect can plan from evidence. - `/dobby:execute` re-runs `bunx dobby up` — idempotent and liveness-first, so a re-run never double-starts — then dispatches the plan's workers directly, following the shared dispatch protocol. -- `/dobby:finish` merges the goal's PR when it's still open (gated — your explicit call), then tears it all down with `bunx dobby down`. +- `/dobby:finish` merges the goal's PR when it's still open (gated — your explicit call), tears the run down with `bunx dobby down`, and — only when the session stands in a worktree — offers to remove it. `dobby up` no longer starts anything itself — it prepares the workspace, probes it, and hands back the instructions the model carries out: under **cmux** (`CMUX_WORKSPACE_ID` is set in every cmux pane) that means opening a named run pane and, once the app reports live (never on a booting 404), a named browser pane; on a plain ssh/tmux session it means a background `Bash` job instead. `dobby instructions ` answers the same catalogue directly (`start`, `stop`, `browser`, `rename`), and QA drives the UI by following `dobby instructions browser` — cmux's own browser CLI ladder under cmux, a different guide under Claude Desktop or t3 code. @@ -72,7 +72,7 @@ The coordinator and QA reach the running app the same way everywhere: `bunx dobb - **Node 24+** — required by `portless`. - **`portless`** — bundled inside `@kvnwolf/dobby` (your single devDependency, added by `/dobby:onboard`), plus a one-time `portless trust` (it needs sudo once to install a local CA and bind `:443`). -- **Claude Code** recent enough for native worktrees: `EnterWorktree`/`ExitWorktree` land in **≥ 2.1.72**; transcript relocation (so `/dobby:mark`/`/dobby:learn` still resolve a session after the worktree moves) lands in **≥ 2.1.198**. +- **Claude Code** recent enough for native worktrees, if you use them to isolate a goal (`claude --worktree`, `ExitWorktree`): **≥ 2.1.72**; transcript relocation (so `/dobby:mark`/`/dobby:learn` still resolve a session after the worktree moves) lands in **≥ 2.1.198**. ## The lifecycle @@ -94,10 +94,10 @@ A work session moves through six stages. Each stage ends by asking which command /dobby:commit docs synced, message + PR body authored, then `dobby ship` │ (gate → commit → push → PR) and the watch to a verdict │ -/dobby:finish merge the PR (your call), tear down the worktree +/dobby:finish merge the PR (your call), tear down the run ``` -`/dobby:finish` is the closing step: if the PR is still open, it offers to **merge** it first — your explicit selection at its gate, squash-merged, and only once `dobby pr watch` says merge-ready — and then `bunx dobby down` runs the config's teardown, kills the registered run process, deletes the per-worktree Neon branch, and hands back the instruction to close any cmux pane it opened; finally it removes the per-goal worktree + branch and pulls the main checkout. It's gated like every other stage: `/dobby:commit`'s handoff question offers it once the PR is merge-ready, and nothing — the merge included — runs until you pick it. +`/dobby:finish` is the closing step: if the PR is still open, it offers to **merge** it first — your explicit selection at its gate, squash-merged, and only once `dobby pr watch` says merge-ready — and then `bunx dobby down` runs the config's teardown, kills the registered run process, deletes the per-worktree Neon branch, and hands back the instruction to close any cmux pane it opened; then, only when the session stands in a worktree, it offers to remove that worktree + branch — otherwise it returns to `main` and deletes the goal's branch — and pulls the main checkout either way. It's gated like every other stage: `/dobby:commit`'s handoff question offers it once the PR is merge-ready, and nothing — the merge included — runs until you pick it. **The push is guarded twice.** `/dobby:commit` never runs the gate by hand — `dobby ship` composes it in-process, and a red gate commits nothing — and the **pre-push backstop** (the git hook `dobby up` installs) re-runs it on `git push`, so a red tree can't reach the remote even when the commit happened outside the kit. The mechanized half of the convention rules rides the same path: they fire on every Edit/Write through the edit hook and again at push, so conformance no longer depends on a skill having been read. @@ -195,13 +195,13 @@ Then comes `/dobby:commit`: it syncs the docs and authors the conventional-commi /dobby:finish ``` -The whole session ran inside a per-goal worktree that `/dobby:scope` created — so once your PR is merge-ready, one more step merges it and retires the worktree: +If the whole session ran inside a worktree you opened for this goal, once your PR is merge-ready one more step merges it and retires that worktree: ``` /dobby:scope … → interview → research → spec → execute → wrap → commit → /dobby:finish (merges, then tears down) ``` -`/dobby:finish` confirms the PR is actually **merged** (if it's still open, closed, or the tree is dirty, it shows the state and asks before merging or destroying anything — on an open PR with a clean tree, "Merge & finish" is one of the options: it squash-merges once `dobby pr watch` reports merge-ready, then re-checks and continues), then runs `bunx dobby down` (teardown extras, kills the registered run process, deletes the Neon branch, hands back the instruction to close any cmux pane it opened), removes the worktree and its branch, and pulls your main checkout. If the original session died and left an **orphaned** worktree behind, run `/dobby:finish` anyway — it falls back to a raw-git cleanup after verifying the branch was merged and confirming with you. +`/dobby:finish` confirms the PR is actually **merged** (if it's still open, closed, or the tree is dirty, it shows the state and asks before merging or destroying anything — on an open PR with a clean tree, "Merge & finish" is one of the options: it squash-merges once `dobby pr watch` reports merge-ready, then re-checks and continues), then runs `bunx dobby down` (teardown extras, kills the registered run process, deletes the Neon branch, hands back the instruction to close any cmux pane it opened); if the session stands in a worktree it then offers to remove that worktree and its branch, and if it's an **orphaned** worktree from a session that died before running `/dobby:finish`, it falls back to a raw-git cleanup after verifying the branch was merged and confirming with you — otherwise, on a plain checkout, it returns to `main` and deletes the goal's branch. Either way it pulls your main checkout. ## When to use what @@ -225,7 +225,7 @@ The whole session ran inside a per-goal worktree that `/dobby:scope` created — | A brand-new empty repo | `/dobby:onboard` — scaffolds it and picks the issue tracker (GitHub Issues by default, or Linear / local `BACKLOG.md`) | | A repo on an older dobby — or still on vite-plus / the legacy `.claude/commit.config.yml` | `/dobby:upgrade` — bumps to the latest and walks the per-version upgrade notes; a legacy repo is routed through `/dobby:migrate-config` (the one-time move onto `@kvnwolf/dobby` + `dobby.config.json`) | | Work is done, ship it | `/dobby:commit` | -| The PR is merge-ready (or already merged) and the worktree needs retiring | `/dobby:finish` — it offers the merge, then cleans up | +| The PR is merge-ready (or already merged) and the run/worktree needs retiring | `/dobby:finish` — it offers the merge, then cleans up | | A merged version ready to publish | `/dobby:release` — from the main checkout; npm or a Homebrew cask, per `dobby.config.json`'s `release` key | | A review bot or reviewer left comments on your PR | `/dobby:address-review` | | Structuring or refactoring a module's files | `/dobby:module-conventions` (auto-activates) | @@ -297,8 +297,7 @@ These couple to Claude Code's session storage (`~/.claude/projects`) on purpose | A hook blocked my `git push` | The pre-push backstop found a red gate on the tree being pushed | Read the findings it printed (they're the whole list, not a sample) and fix them — or, when you're pushing a WIP branch on purpose, `git push --no-verify` as a conscious bypass | | Execute drifted from the loop logic | The dispatch protocol must be followed as written, not paraphrased | Re-run `/dobby:execute`; the skill's `references/build-protocol.md` is the canonical protocol | | `portless` prompts for sudo / fails to bind `:443` on first run | First-time CA install + privileged port | Run `portless trust` once (surfaced by `/dobby:onboard`); it's a one-time setup, later runs don't need it | -| An old session died and left a worktree in `.claude/worktrees/` | The session couldn't run `/dobby:finish` before exiting | Run `/dobby:finish` anyway — it detects the orphan, checks the PR (offering the merge if it's still open), confirms with you, and cleans up via raw git | -| `/dobby:scope` stops ("open a new pane") | Nesting — THIS session is already inside a worktree, and the native tool can't nest (parallel worktrees from OTHER sessions are fine and don't trigger this) | Open a new cmux pane / `claude` session for the new goal and run `/dobby:scope ` there — one goal per pane, no nesting | +| An old session died and left its worktree behind | The session couldn't run `/dobby:finish` before exiting | Run `/dobby:finish` anyway — it detects the orphan, checks the PR (offering the merge if it's still open), confirms with you, and cleans up via raw git | ## Recovery quick reference diff --git a/cli/CONTEXT.md b/cli/CONTEXT.md index 906a0bd..de5f516 100644 --- a/cli/CONTEXT.md +++ b/cli/CONTEXT.md @@ -32,20 +32,20 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `src/buildplan.ts` (+ `src/buildplan.test.ts`) — the **build plan**, derived MECHANICALLY from the spec's task table (`dobby build-plan [--file ] [--task ] [--json]`), a domain module behind the `command.ts` contract. It replaces the per-session judgment call the coordinator used to make over a markdown grid, and emits ONE payload: `tasks[]` — the per-task instruction data VERBATIM (`{id, title, spec, decisions, constraints, areas[], verifyRecipe, testFirst}` + `destructive` + `dependsOn[]`), the exact shape `plugin/skills/execute/references/build-protocol.md` consumes, with `decisions`/`constraints` deliberately EMPTY (plan-level decisions stay coordinator-distributed) and `devUrl` deliberately ABSENT (the coordinator merges it), and `dependsOn` carrying the row's `Depends on` ids VERBATIM (`—`/empty → `[]`) — the ONLY thing that says WHO a task waits for, now that there is no batch grouping saying WHEN it runs: a task is ready the moment every id in its own `dependsOn` has reached `done`, which is what lets the Architect skip a task whose dependency ended needs-human without touching anything independent of it; `preconditions` — `{missing[{taskId,field}], danglingDeps[{taskId,dependsOn}], cycles[[ids]], ok}`, where not-ok exits 1 **with the payload still on stdout** (the `up --json` convention: the verdict fields ARE the fix list); plus the two gates `/dobby:execute` reads before launching — `hasTestSuite` (`value` from the repo's `vitest` capability, `specSays` from the Testing Decisions' test-first claim — null when the section is absent — and their `disagreement`) and `manualVerifySetup` (the `Manual verify setup:` field's steps, or `none`). PARSING IS TOLERANT BY CONTRACT: the task table is found by its HEADER ROW (never a `### Tasks` anchor — the sub-heading spec format is new and older specs must still plan), `Description`/`Test-first`/`Destructive` are each optional (absent → the title stands in as the spec, the flags read false), a non-task table inside the spec is skipped, and `—` reads as "no dependency". `--task ` plans ONE ad-hoc task from JSON and reads no STATE.md at all (the `/dobby:dispatch` path); it carries the ad-hoc surface the spec named `--task-file`, since the dispatcher's flag set has no such option. An ACTION command (`requireWorkroot`; the throw is folded into the failure shape). `node:*` only (ADR-0008). - `src/build-protocol.test.ts` — a GUARD with no module of its own: what it tests lives OUTSIDE the CLI, as the **dispatch protocol** document at `plugin/skills/execute/references/build-protocol.md` (the shared build-loop component `/dobby:execute`, `/dobby:dispatch`, and `/dobby:address-review` all read and follow). The protocol is prose the Architect follows directly, not a runtime the CLI can execute, so the suite reads that markdown as TEXT and pins its RULES per document section: every worker is dispatched NAMED (`dobby:test-author` / `dobby:implementor` / `dobby:qa`), `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` must stay unset, the deferred `SendMessage` tool is loaded via `ToolSearch` before first use, a task starts the moment its own dependencies are done with no fixed batch to wait on, the Exit gate is serialised to one implementor at a time, a dead task stops only its dependents while independent work keeps being dispatched (and what died is reported), each worker appends its own `dobby state append-worklog` entry and returns only a short verdict, `STATE.md` stays current enough to reconstruct progress after a compaction, and the run closes with a summary table of rounds / first-attempt success / deaths / wall clock — plus a repo-wide scan proving the rename off the OLD filenames (the protocol document and its old test harness both previously carried) left no live surface still pointing at either one. It lives here because `cli/src` is the tree vitest covers; it imports nothing from the CLI. - `src/repro.ts` (+ `src/repro.test.ts`) — the **red/green capture harness** (`dobby repro [--expect red|green] [--repeat N] [--bench] [--json] -- `), a domain module behind the `command.ts` contract. Everything after `--` is the command (node's `parseArgs` drops the `--` and hands the rest through as positionals), spawned through `runner.runCapture` with cwd pinned to the workroot — so the SAME loop reproduces identically from any subdirectory, and outside a git repo it fails hard like every action command (`requireWorkroot`'s throw is CAUGHT here, since `run()` does not catch handler exceptions). One run yields `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` is red on ANY nonzero exit (a child that never started / was killed records POSIX 127 / 128 and its spawn error folded into stderr, never a silent green — the exit code is extracted by TYPE, `typeof status === "number"`, never by `!== null`: node spells "no exit code" as `null` while Bun, the runtime the `dobby` bin runs on, spells it `undefined`, and an `undefined` exitCode would be DROPPED by `JSON.stringify` out of both the payload and the persisted record), and `matched` is `verdict === --expect` — explicitly NULL (never absent) without `--expect`, because "nothing was judged" is an answer. The HARNESS judges, so the model never derives a verdict by reading output: exit 1 ONLY on a mismatch (the "your loop is not red-capable" signal); a failing command with no `--expect` still exits 0 (repro REPORTS an exit code, never inherits one). `reproId` = a 12-hex-char sha256 of the workroot + the command argv and NOTHING else (repro's own flags are deliberately out, so `--repeat`/`--bench` runs key the same record), and every run persists `{baseline, latest, reproId}` to `/.dobby/repro/.json` (`.dobby/` gitignore-ensured, as `up` does for its pidfile) — a write failure is a stderr WARNING, never the outcome. `--repeat N` runs sequentially and adds `{runs, redCount, greenCount, reproductionRate (red/runs), deterministic, firstDivergent:{run (1-BASED), stdout, stderr}|null, durations}`; the BASE record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), which makes `--expect red --repeat N` a red-CAPABILITY probe instead of a coin flip on run 1. `--bench` adds `{samples, min, median (over SORTED samples), mean, max}` plus `baseline` (the PREVIOUS bench of this reproId, null on the first) and `delta` (current MINUS baseline per timing stat, so faster reads negative), and stores its own stats as the next baseline — a non-bench run never clobbers it. `--json` prints the full payload; the default text render is a compact summary (verdict + reproId headline, `cmd:`/`cwd:`, the repeat/bench lines, the record path) plus a labelled TAIL of stdout/stderr — repro itself NEVER truncates what it captures or stores. `node:*` only (ADR-0008). -- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `scope preflight --slug ` reports what the goal WOULD take (branch `worktree-`, path `.claude/worktrees//`), whether either is taken (+ a collision-free `suggestedSlug`), whether this session is already INSIDE a kit worktree (the native EnterWorktree cannot nest), and whether the repo carries the dobby contract; parallel worktrees are INFORMATIONAL, never a refusal. `finish --preflight [--slug]` is the teardown verdict for ONE goal — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed, so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus WHICH removal mechanism applies (`ExitWorktree` same-session vs raw git for an orphan) and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). +- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed, so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`) and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). - `src/release.ts` (+ `src/release.test.ts`) — the **release SPINE** (`dobby release [--bump patch|minor|major] [--notes-file ] [--dry-run] [--json]`), a domain module behind the `command.ts` contract and the CLI's one CONFIG-GATED command: without a `release` key in `dobby.config.json` the command does not exist (`run.ts` hides it; the spine repeats the refusal as defense in depth for every non-dispatcher caller). It owns the phases EVERY release target shares and nothing target-specific: those live behind the exported `ReleaseAdapter` seam — `{id, preflight, packGate, publish, smoke}`, each taking a `ReleaseContext` (`{currentVersion, dir, notesFile, release, root, tag, version}` — `version`/`tag` are NULL during `preflight`, which runs before the version is decided) and returning `ReleasePhaseResult` DATA, plus three OPTIONAL members: `primaryManifest(root, release)` (a target whose version does not live in `/package.json`), `bumpExtras(context, version)` (the version-carrying files the JSON-only bump cannot edit — run INSIDE the bump phase, after the manifests and BEFORE the gate and the commit, so the edit is gated and lands in the `release: v` commit) and `postRelease(context)` (work that can only happen once the GitHub release exists — run after `gh release create` and before `smoke`, PAST the publish line, so a failure is reported and never rolled back). A target that needs none of them is unaffected — the npm one defines none. Adapters register themselves through `registerReleaseAdapter(type, adapter)` into a MUTABLE registry keyed by `release.type` (a `switch` would make the spine import every target; a lazy `await import()` is impossible — `run.ts` dispatches handlers synchronously), and an unregistered type is a clean error naming what IS registered. **Two-phase invocation** (the model keeps authorship of both judgements): (1) `needsDecision` — without `--bump`, a FIRST release or an inferred major while still below 1.0.0 exits 1 with `{needsDecision: "first-release"|"0x-major", context:{currentVersion, commits[]}}` having touched NOTHING, and the skill answers with `--bump`; (2) `needsNotes` — without `--notes-file` the run does everything mechanical and STOPS with `{needsNotes: true, version, changelog}` (exit 1, the bump commit kept LOCAL, nothing pushed or published), the skill authors the notes and re-runs with `--notes-file` (which must live OUTSIDE the repo — a release refuses a dirty tree). The five phases, each emitting a `phases[]` record: **preflight** (main checkout only via `lifecycle.linkedWorktreeMain`, branch `main` read with `git branch --show-current` — never `rev-parse --abbrev-ref HEAD`, which is `fatal:` on an unborn branch — a clean `git status --porcelain`, `git pull --ff-only`, CI green ASSERTED IN CODE from `gh run list --branch main --limit 1 --json headSha,status,conclusion` (an ARRAY, completed + success AND `headSha` equal to the commit the release is cut from — a green run for some OTHER commit proves nothing; on the RESUME run that commit is `HEAD~1`, since HEAD is then the local, deliberately UNPUSHED `release: v` commit no CI run can ever name), then `adapter.preflight`); **version** (`git describe --tags --abbrev=0 --match v*`, whose exit 128 means a FIRST RELEASE for both "no tags" and "no matching tags" — its `fatal:` stderr is captured and dropped; the tag re-validated with `rev-parse --verify`; `git rev-list --count ..HEAD == 0` → `nothing to release`; per-commit classification from ONE `git log -z --pretty=format:%H%x00%s%x00%B` chunked by 3 with NO trailing NUL, rules: `!` before the `:` or a BREAKING CHANGE body → major, any `feat` → minor, else patch); **bump** (each manifest's indentation MEASURED from its own first indented line and only the version VALUE rewritten — the v0.5.1 field bug was a hardcoded `"\t"` that reformatted every 2-space manifest and turned CI red; the primary manifest is `release.dir ?? "."` + `/package.json` unless the adapter overrides it, plus every `release.lockstep[]` entry as a repo-relative FILE path — non-JSON lockstep files are left to the adapter's `bumpExtras` (which runs here, before the gate) and reported on the phase note, never silently skipped; then the gate runs IN-PROCESS over the BUMPED tree (`check([], root, {}, true)`, never a `dobby` subprocess) and a red gate restores the manifests and exits with the gate's own code and its FULL findings; finally `git add -u` + `git commit -m "release: v"`, and NOTHING is pushed); **changelog** (the commits grouped by `release.surfaces` name→GLOB when configured — a commit that spans surfaces is listed under EACH — else by type: Breaking changes / Features / Fixes / Other); **publish** (`adapter.packGate` → `adapter.publish` → `git tag v` → `git push origin main v` → `gh release create v --title v --notes-file ` → `adapter.postRelease` → `adapter.smoke`). A RE-RUN recognizes the local `release: v` commit (HEAD subject + the manifest agreeing + NO `v` tag yet) and skips re-bumping. `node:*` only (ADR-0008). - `src/release-npm.ts` (+ `src/release-npm.test.ts`) — the **npm release TARGET**: the `ReleaseAdapter` behind `release.type: "npm"`, and the home of every npm-specific field scar. Four moments, each spawned through the runner with the cwd pinned to the RELEASE DIR (`context.dir` = `release.dir ?? "."` resolved against the workroot — publishing the workroot ships the wrong tree), each answering DATA and never throwing. **preflight** — `npm whoami`; a failure refuses in npm's own words, and the comment records the thing whoami CANNOT prove: an interactive-login token authenticates and then fails at publish with `EOTP` (the account's 2FA wants a per-publish OTP), so the working setup is a GRANULAR access token with write access in `~/.npmrc` (field-proven on v0.1.0). **packGate** — `bun pm pack --dry-run --ignore-scripts` (nothing written, no lifecycle script run as a side effect of INSPECTING a package), its `packed ` listing parsed and matched against the DENY globs `**/*.test.ts`, `**/__fixtures__/**`, `dist/**` (GLOBS, never substrings — `src/latest.ts`, `src/fixtures/`, a root `distribute.ts` must all ship, and `dist/**` is rooted at the PACKAGE root so a `src/dist/` source directory ships while `**/` matches zero directories so a ROOT-level `index.test.ts` is caught); ANY hit refuses and names EVERY denied file, and a listing the gate parses NO files out of refuses too — quoting the packer's own stdout back, because zero `packed` lines means either an allowlist that ships nothing or a listing shape that drifted, and those have opposite fixes. **publish** — `npm publish --access public` (a scoped package defaults to restricted), PLAIN npm and never `bun publish` (bun 1.3.x ignores `~/.npmrc`'s `_authToken` and dies with "missing authentication" — field-hit on v0.1.0; this module spawns `bun` for the pack dry run alone); an `EOTP` in the output comes back with the granular-token fix, every other failure with npm's own words. **smoke** — `npm view version` polled until the registry serves `context.version` (propagation can lag MINUTES on a first publish: a 404 right after `+ pkg@` printed is NOT a failure, only an exhausted budget is; the budget is 15 polls × 20s by default and INJECTABLE via `createNpmAdapter({attempts, delayMs})` — the module's second export, which exists so tests need not wait), then the optional `release.smoke` argv (an ARRAY, never a shell string), the only step that proves the published ARTIFACT works. The package name is read from the release dir's own `package.json`. The adapter is registered by the SPINE (`registerReleaseAdapter("npm", npmAdapter)` in release.ts) rather than self-registering: a side-effect import of a self-registering target evaluates the target BEFORE the spine's registry const exists (a TDZ `ReferenceError` at import time, verified), while this direction leaves the target importing only TYPES — no runtime cycle. `node:*` only (ADR-0008). - `src/release-cask.ts` (+ `src/release-cask.test.ts`) — the **homebrew-cask release TARGET**: the `ReleaseAdapter` behind `release.type: "homebrew-cask"`, for a Tauri macOS app shipped through a Homebrew tap. It uses SIX of the seam's moments (the four every target has, plus BOTH optional hooks). **preflight** — `/src-tauri/tauri.conf.json` exists (else this is not a Tauri app), `rustup target list --installed` carries BOTH `aarch64-apple-darwin` and `x86_64-apple-darwin` (a missing one refuses with the literal `rustup target add …` fix), `gh auth status`, and `release.tap` + `release.cask` are configured (each refusal names the missing key) — plus, ONLY when the OPTIONAL `release.notaryProfile` is set, the two one-time human setups notarization needs: `security find-identity -v -p codesigning` listing a `Developer ID Application` certificate (the tool EXITS 0 while listing none, so the verdict is its OUTPUT; the refusal names Xcode → Settings → Accounts as where the certificate is made, and records that SIGNING is `tauri.conf.json`'s `signingIdentity`, never dobby's job) and `xcrun notarytool history --keychain-profile

` exiting 0 (the refusal carries the one-time `xcrun notarytool store-credentials

--apple-id … --team-id …`). **bumpExtras** — `src-tauri/Cargo.toml`'s version, rewritten byte-surgically and SCOPED to the `[package]` section (the window from the `[package]` header to the NEXT `[section]`: `version = ` also sits at the start of a line under `[dependencies.]`, and a whole-file regex bumps a dependency instead), then `cargo check` in the crate — ANY cargo command reconciles `Cargo.lock`, whose stale version would otherwise ride along in the release commit. **packGate** — `bun tauri build --bundles app,dmg --target universal-apple-darwin`, then exactly ONE `*.dmg` under `src-tauri/target/universal-apple-darwin/release/bundle/dmg/` (zero and many are separate refusals) and `PlistBuddy -c "Print :CFBundleShortVersionString"` on the built `.app` equal to the version being released (a bundle that predates the bump would ship an app reporting the old number while the cask advertises the new one) — PlistBuddy is spawned BARE with `/usr/libexec` APPENDED to the child's PATH, never by absolute path. With a `release.notaryProfile` configured, THREE more steps run here (last, AFTER the version gate — a stale bundle must never cost an Apple round trip — and still before any tag, the last place a release can be refused for free): `xcrun notarytool submit --keychain-profile

--wait` whose stdout must carry `status: Accepted` (the tool exits 0 on a REJECTED submission, and a refusal quotes its FULL log, never truncated), `xcrun stapler staple `, then `spctl -a -t open --context context:primary-signature -vv ` whose output must carry `Notarized Developer ID` (spctl exits 0 for a signed-but-un-notarized build and writes its assessment to STDERR, so the gate reads BOTH streams and matches CASE-SENSITIVELY — Gatekeeper's refusal reads `Unnotarized Developer ID`); each step gates the next, so a rejected submission is never stapled and an unstapled dmg is never assessed. **publish** — a NO-OP: the dmg is a local file until the GitHub release exists, so there is nothing this target could half-publish. **postRelease** — `gh release upload v `, `shasum -a 256` on that same file, then the TAP: `gh repo clone ` into a mkdtemp dir (a failed clone is the probe — `gh repo create --public` then clone again), the cask's `version` + `sha256` lines replaced in place (indentation captured, never assumed) or the whole file SCAFFOLDED from the module's template when the tap carries none (its `url` templates Homebrew's `#{version}` and SANITIZES the asset name the way GitHub serves it — spaces become dots — so later releases only ever move two lines), `ruby -c` before the commit (an absent ruby is a NOTE, a rejection is a refusal), then `git add` + `git commit -m " "` + `git push -u origin HEAD` in the tap checkout. **smoke** — REPORT-ONLY: it runs NOTHING and hands back `brew install --cask /` (Homebrew's own rule: `/homebrew-` is referred to as `/`), plus — CONDITIONALLY, only when nothing was notarized — the quarantine caveat (`xattr -dr com.apple.quarantine …`), which next to a notarized build would simply be a lie. **The credentials are keychain-only**: `notaryProfile` is the NAME of a notarytool keychain profile and the only credential fact dobby ever holds; there is deliberately NO `APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` env-var path (mad-eye ADR 0005 — an app-specific password in the environment is inherited by every child, shell history and CI log). `security`, `xcrun` and `spctl` are spawned BARE like the rest, which is also what keeps them stubbable in tests. Registered by the SPINE like the npm one (`registerReleaseAdapter("homebrew-cask", caskAdapter)` in release.ts), so this module imports only TYPES from it — no runtime cycle, and the target is reachable from `run.ts`'s graph through the spine. `node:*` only (ADR-0008). - `src/review.ts` (+ `src/review.test.ts`) — the **review domain**: `dobby review fetch|apply` + `dobby pr watch`, the whole `gh` surface of the address-review stage, behind the `command.ts` contract (JUDGMENT — validity triage, fix briefs, merge gates — stays in the skills; these commands only move DATA). Every gh call goes out as an ARGV ARRAY through `runner.runCapture` with cwd pinned to `requireWorkroot` (so a reply body carrying quotes/newlines/`$(…)` reaches gh as ONE argument and the skills' shell-hardening prose becomes structurally unnecessary). Five external-system incompatibilities are baked in and commented at their call sites: (1) `gh api graphql --paginate` REQUIRES the cursor variable to be named literally `$endCursor` (the predecessor `$cursor` hand-loop silently returns page 1), used with `--slurp` so the pages arrive as ONE parseable array; (2) `gh pr checks --watch --json` is a hard error and `--json` mode never signals the CHECKS' state through the exit code (the 1/8 exits live in the non-JSON path), so `pr watch` owns its OWN polling loop and derives every verdict from BUCKET COUNTS, never exit codes — while a nonzero exit THERE means "gh could not report at all" and is surfaced as a hard error rather than read as an empty (green) check list, gh's own "no checks reported on the '' branch" being the one nonzero that legitimately means zero checks; (3) `reviewThreads` exists only in GraphQL while the summary comment must be read over REST `issues/{n}/comments` (gh's comments JSON has no `updatedAt`, and the bot EDITS one comment in place, so only `updated_at` finds the freshest body); (4) bot logins differ by API (`greptile-apps` vs `greptile-apps[bot]`), so matching STRIPS a trailing `[bot]` on both sides as a plain string op — never a regex `test()`, where `[bot]` is a character class that matches nothing. The **adapter registry is DATA** (`ADAPTERS` + the `human_or_unknown` fallback: botLogins · reTrigger · intentionalReply · confidence) — adding a review tool is one entry and nothing else. `fetch` returns `{pr, adapter{id,matchedLogins,reTrigger,intentionalReply,confidence}, candidates (null unless several matched), threads[] (every page, `isResolved` filtered CLIENT-side, each carrying `comments(last: 5)`), summary{author,body,confidence,reviewedHeadOid,updatedAt}|null}`; a clean PR is `threads: []` + exit 0, never an error, and a FAILED thread read is never degraded to an empty list (inventing "no findings" would tell the kit to merge unreviewed work). Adapter detection reads the OPEN-THREAD authors first and falls back to the ISSUE-COMMENT authors only when they name nobody — a clean review posts a summary and NO threads, but Greptile additionally requires `reviewedHeadOid` to match the current PR HEAD before `merge-ready`. `apply --plan |--stdin` consumes `{pr, plan:[{threadId, disposition: fix|defer|dismiss|outdated, reply?}], reTrigger}` and answers `{resolved, replied, skipped, retriggered, failures, dryRun}` (arrays of THREAD IDS): fix/dismiss/outdated resolve through ONE batched mutation with `t1:`/`t2:` aliases while **defer stays unresolved ON PURPOSE**, replies go out over REST `pulls/{n}/comments` with `in_reply_to` = the thread's first comment id, a reply whose body already appears in the thread's last-5 comments is SKIPPED (the idempotency the `last: 5` selection exists for), a reply that FAILED blocks its thread's resolve (closing it would bury the finding with no answer), a threadId absent from the open set is skipped (so re-running a plan is a no-op), and `--dry-run` decides everything and writes nothing. `pr watch [--pr N] [--await-review] [--deadline ]` polls `gh pr checks --json name,state,bucket,link` → `ci-failed` (any `fail`/`cancel` bucket, with the failing `[{name, link}]`) · `ci-green` · `ci-pending` (deadline hit while pending), then — only when green and asked — polls the fetch until current review evidence lands → `merge-ready` | `feedback-present` | `open-unreviewed`, exposing `reviewFresh` and a diagnostic `reason` for Greptile; no PR (on main, or nobody opened one) is `skipped` + exit 0, and every verdict exits 0 (the watch REPORTS, it never inherits an outcome) — but a gh call that FAILED is not a verdict at all and exits 1, so "green" is only ever printed about checks that were actually read. `--deadline` (300s) is the budget for EACH wait PHASE, each starting a fresh clock: one shared ceiling would let a slow CI run eat the whole budget and answer `open-unreviewed` without ever having waited for the review. `ci-pending` is the one verdict beyond the spec's list — the CI wait's timeout answer, because the spec's unbounded "poll until terminal" would let the command hang forever on a queued run. There is NO merge path in this module, structurally. `DOBBY_POLL_INTERVAL_MS` is the documented poll-interval TEST SEAM (sibling of `DOBBY_LIVENESS_RETRIES`; 0 is valid) for both loops, which sleep via `Atomics.wait` because the handler seam is synchronous. `node:*` + the shared runner only (ADR-0008). - `src/kb.ts` + `src/adr.ts` (+ the shared `src/kb-adr.test.ts`) — the two DURABLE-ARTIFACT writers (`docs/` is where the kit's decisions outlive a session), both domain modules behind the `command.ts` contract and both ACTION commands resolving their directory under `runner.requireWorkroot`. **`kb list|record`** is ONE engine for BOTH knowledge bases — `docs/out-of-scope/` (triage's rejected concepts) and `docs/learn-discarded/` (learn's discarded frictions) — parameterized by `--kind`: the `KINDS` table is the whole difference (a directory + two heading strings, `## Why this is out of scope`/`## Prior requests` vs `## Why this was NOT turned into a skill edit`/`## Prior occurrences`), which is the "two real adapters, only the strings vary" bar the one-module decision was taken against (Research R5). `record` writes the kit's OWN published skeleton (`plugin/skills/triage/references/out-of-scope-kb.md`, "File format") and dedups BY CONCEPT — an existing concept file is APPENDED to (one more bullet after the last content line of its prior-section, every byte before that heading untouched), never replaced and never duplicated under a second filename — while `list` parses the same documents TOLERANTLY (the committed KB files lead with bold inline markers, not the skeleton, so each field degrades to its empty value rather than failing the scan). The `--concept` value is slug-normalized because it becomes a PATH (which is also what makes `../` impossible). **`adr new`** owns ADR NUMBERING, the part parallelism breaks: the max scan covers the local `docs/adr/` AND `origin/HEAD:docs/adr` via `runner.runCapture` (a sibling worktree's ADR is pushed before it ever lands here — tolerant of a missing origin/HEAD/git), and the create loop claims a NUMBER rather than a filename — each attempt re-reads the directory and skips the number if ANY `NNNN-*.md` already carries it (a sibling's ADR almost never shares our slug, so an exact-filename check alone would mint a second 0017), with `O_EXCL` (`flag: "wx"`) as the last-resort arbiter for the window in which the sibling picked our slug too; both guards retry at the next number. It writes a SKELETON only (`# NNNN. `, the optional `**Status:**` line, a placeholder paragraph) — the CLI never authors an ADR body, and never DECIDES to record one; wrap/address-review decide, the CLI executes. Both answer the spec's BARE payloads (`[]`/`{path, created, appended}`/`{number, slug, path}`) rather than `state`'s `{ok, …}` envelope, so refusals live only on stderr with exit 1. `node:*` + the shared runner only (ADR-0008). - `src/artifact-lint.ts` (+ `src/artifact-lint.test.ts`, `src/artifact-lint-b.test.ts`) — the **ARTIFACT LINTERS**, ONE module for the whole family (`dobby spec lint`, `dobby map next|claim|lint`, `dobby skill lint`, `dobby wizard verify`, `dobby arch-report verify`, `dobby handoff finalize`, `dobby brief lint`), a domain module behind the `command.ts` contract. The command TOKEN is on the `CommandContext`, so each linter is a BRANCH inside this module — never a new dispatch arm in run.ts. They share ONE report contract: findings print one per line as `<where>: <message>` (`path:line`, 1-based, wherever a line is knowable), ANY finding exits 1, a clean artifact prints `ok` and exits 0, and `--json` renders `{ok, findings: [{check, message, where}]}` as the SOLE stdout line; a malformed INVOCATION (a target that does not exist, a slug the map never had) is NOT a finding but a hard error on stderr, so "I could not judge this" can never read as "this is clean". Two ADDITIVE extensions the second family needed: **notes** — a check that could not RUN (shellcheck absent, a repo with no `.github/workflows`) is never a finding and never touches the exit code, but rides along in BOTH channels so a caller can tell a shellcheck-checked wizard from an unchecked one — and **payload keys**, the extra `--json` keys a command answers with (`handoff finalize`'s `path`). What they judge is the MECHANICAL subset only — whether a decision is right, an answer good, or prose worth its tokens stays the architect's and the reviewer's call. Targets are POSITIONAL (`--file` accepted as an alias), and the default resolutions read the workroot (`runner.resolveWorkroot`, DEGRADING to the caller's cwd outside a repo: these READ, so unlike the action commands they never fail hard). **`spec lint [<file>]`** judges `<workroot>/STATE.md`'s `## Spec` section (a document with `## ` sections but no `## Spec` is ONE finding; a file with no `## ` at all is a spec fragment and is linted whole; every heading scan skips FENCED lines, so a spec that quotes markdown under `### Decisions` is not truncated at its own snippet): the nine required `###` sub-headings (`User flow` optional, extra ones free), EXACTLY one table under `### Tasks` carrying `#`/`Task`/`Depends on`/`Affected areas`/`Verify recipe` (plus `Test-first` exactly when the repo has the vitest capability — the SAME `detect.ts` the gate reads, so the column rule can never disagree with the gate about whether this repo has a suite; `Description`/`Destructive` optional), non-empty `Task`/`Affected areas`/`Verify recipe` cells, `Depends on` edges pointing BACKWARDS by ROW POSITION (a dangling ref and a forward ref are two different findings, and a cycle is unrepresentable), every `Affected areas` entry a REAL repository path (split on comma/semicolon the SAME way `Depends on` is, but WITHOUT dropping the `—`/`none` no-dependency spellings — this column has no "nothing here" value, so one fails like any other non-path; a fragment outside the `[\w./-]` path shape — a parenthetical mangled apart by that same comma — is one finding, a shaped fragment that resolves to neither an existing path NOR an existing parent directory is another, so a path the task will CREATE stays legal as long as its parent already exists; this is what makes the dispatch protocol's area-overlap check mean anything — a prose label for the same file no longer evades it), verify recipes free of the banned commands (`lint|format|typecheck|tsc|biome|knip|vitest|jest|npm test|bun test|dobby check|build`, WORD-BOUNDARY + case-insensitive, so an honest `rebuild the fixture` stays writable), the literal `Manual verify setup:` label under `### Testing Decisions` — found ANYWHERE on its line, since the spec skill also writes it inline at the end of the paragraph (`… no test suite involvement. **Manual verify setup: none.**`), and its value read past any trailing sentence punctuation (`none` or followed by numbered steps — `/dobby:execute`'s pre-verification gate reads it, so an omitted field is an unanswered question, not "nothing needed"), and fenced blocks ONLY under `### Decisions` (the snippet exception). **`map lint|next|claim [<file>]`** parses `## <slug>: Title` tickets (`Blocked by:`/`Status:`/`Type:` + `### Question`/`### Answer`; a `## ` section without a colon is prose, not a ticket; FENCED lines are prose too, so an `### Answer` quoting a ticket cannot invent a phantom one) from the named map or the NEWEST `docs/maps/*.md`: `lint` reports non-kebab and duplicate slugs, a Status outside `open|in-progress|resolved`, a Type outside `Research|Prototype|Grilling`, dangling `Blocked by` edges, blocking CYCLES (naming every ticket caught in one — none of them can ever unblock) and a `resolved` ticket with an empty `### Answer` (only `resolved` obliges one: the whole open frontier is answerless by definition); `next` answers `{path, question, slug, title, type}` for the FIRST `open` ticket in DOCUMENT order whose every blocker is `resolved` (explicit nulls when there is none) and NEVER writes — claiming is its own command, so two sessions asking "what's next" cannot both think they own the answer; `claim <slug>` is the family's ONE write: it rewrites that ticket's `Status:` line IN PLACE (indentation AND line ending preserved — the edit is made on the RAW text, so a CRLF-authored map stays CRLF; every other byte carried over, so the claim that makes parallel map sessions safe can never reformat someone else's ticket), writing for ANY non-`in-progress` status (a hand-typed `pending`, an empty value) rather than only `open` — the claim it reports is always the claim the file carries — and refusing an unknown slug (naming the known ones), an already-resolved ticket, and one with no `Status:` line. **`skill lint <dir>`** judges a skill directory against create-skill's craft rules: `SKILL.md` present, frontmatter keys ⊆ the VENDORED whitelist (copied as DATA from `plugin/skills/create-skill/references/frontmatter.md`, because a consumer has no plugin dir to read — the same reason the wizard template is vendored; `model`/`effort` ARE valid skill fields and are NOT findings here, since "kit skills carry no model/effort" is a DOBBY convention (ADR-0004) enforced by this repo's own `checks[]`, not a rule about skills in general), a kebab `name` ≤64 chars without `claude`/`anthropic`, a `description` ≤1024, an `effort` in `low|medium|high|xhigh|max`, ≤500 lines (decision 18 settled 500 over 200), a closing `## Acceptance checklist` as the LAST H2 (headings inside fences ignored), and the RESOURCE GRAPH — every `references/`/`examples/`/`scripts/` path the body cites exists on disk, every file on disk is cited (an orphan is sediment nothing loads), no resource points at another resource (one level deep — a reference behind a reference is never reliably reached), and paths are cited as inline code, never markdown links. Only THIS skill's paths are judged: a mention carrying a path prefix counts as local when the prefix spells this very directory (`skills/improve-architecture/references/…` from inside it), and a citation of another skill's material (`../backlog/references/trackers.md`) is a link, not a pointer into this directory — reading it as one made every cross-skill citation a false "does not exist". **`wizard verify <script>`** judges a generated wizard against the VENDORED template (`wizard/template.sh`, shipped in the `files` allowlist — a consumer install has no plugin dir to read, the same reason the frontmatter whitelist is vendored): the LIBRARY REGION (`set -euo pipefail` up to the `# STAGES` marker) must hash-equal the template's own region — measured from `set -euo pipefail` rather than byte 0 so the copy's provenance header cannot make an honest script mismatch — plus `bash -n` parses, `shellcheck` when it is installed (absent → a NOTE, never a finding), the executable bit, `TOTAL_STAGES` (the value assigned BELOW the marker) equal to the number of `stage` calls, every `ask`/`ask_secret` key persisted (`write_env`/`set_secret`/`set_var`) in the SAME stage (a stage is where a Ctrl-C lands), a secret-shaped key (`SECRET|TOKEN|KEY|PASSWORD|DSN`, `PUBLIC`/`PUBLISHABLE` exempt) never read with a visible `ask`, `open_url` before the stage's first `ask`, and the BIDIRECTIONAL `set_secret` ↔ `.github/workflows/*.yml` `secrets.*` diff (`GITHUB_TOKEN` exempt — Actions injects it; no workflows dir → a NOTE). `bash`/`shellcheck` are spawned BARE through the runner, so a missing tool comes back as a spawn error, which is a SKIP; `shellcheck` is asked for `--format=gcc`, so ONE diagnostic is one finding carrying the line it happened on (its default tty output spreads a single issue over five lines). A VENDORED template that lost its own library region is a hard ERROR naming the asset (reinstall dobby), never a silent pass — otherwise a corrupted install would answer `ok` for every script. **`arch-report verify <file>`** judges the architecture review's HTML against `improve-architecture/references/html-report.md`: the two modern CDNs present (`cdn.jsdelivr.net/npm/@tailwindcss/browser@4`, a `mermaid@11` ESM import INSIDE a `<script type="module">`), the three stale spellings absent (`cdn.tailwindcss.com`, `tailwind.config`, `mermaid.min.js`), no `integrity=` (the CDN URLs are version RANGES that move under a pinned hash), no external script/stylesheet beyond those two, a `#candidates` section carrying ≥1 `<article>`, a `#top-recommendation` section whose anchor resolves to an id the report actually has, the banned prose ("easier to maintain", "cleaner code", "it's worth noting"), and the LOCATION rule it shares with the handoff: the report is ephemeral, so living inside the git workroot (or being git-tracked) is a finding. **`handoff finalize <file> [--focus <s>]`** judges the fork doc `handoff/SKILL.md` writes: the same ephemeral-location rule, the five sections (`Focus`, `Where we are`, `Artifacts`, `Open questions`, `Suggested skills` — matched as PREFIXES, since the skill itself writes "Open questions / next moves"), a ONE-line `## Focus` that must mention `--focus` when it was given, NO fenced block anywhere (a handoff REFERENCES by path or URL and never pastes), every local artifact reference resolving against the WORKROOT (the doc lives in the OS temp dir, so its own directory could never resolve `STATE.md`), every `/dobby:<skill>` suggestion naming a skill the plugin carries (the list VENDORED as data), and the SECRET SCAN — `sk-`, GitHub `ghp_`/`github_pat_`, `AKIA…`, a credentialed `postgres://` DSN, an inlined private key, `xox…`, and the generic `api_key|token|password|secret = …` assignment — which FAILS CLOSED (a false positive costs one rewording; a miss costs a rotation) and reports the LINE and the SHAPE, never the value. On a clean doc the command PRINTS the absolute path (its output IS the skill's "echo the path" step) and carries it in the `--json` payload. **`brief lint (--file <f> | --issue N)`** judges a triage agent brief either as a draft on disk or as the NEWEST comment on an issue (read through `gh api --paginate --slurp repos/{owner}/{repo}/issues/N/comments`, the last comment picked HERE rather than in a `--jq` filter; a gh failure is a refusal, never a clean verdict): the AI disclaimer as the first line, the `## Agent Brief` heading, the seven `**Label:**` fields, a `Category` of `bug|enhancement`, a ONE-line `Summary`, ≥2 `- [ ]` acceptance criteria none of which is vacuous ("it should work", "works correctly", "no regressions"), ≥1 `Out of scope` bullet, no procedural `Files to change`/`What to do` section in either spelling, and `agent-brief.md`'s two durability rules — NO file paths (extension-gated so prose is not mistaken for a path; `dobby.config.json`/`package.json` allowlisted as contracts that cannot go stale) and NO line references (`line 150`, `file.ts:150`). `node:*` + the shared runner only (ADR-0008). -- `src/tracker.ts` (+ `src/tracker.test.ts`) — the **issue-tracker surface** (`dobby tracker info|search|create|close`, `dobby claim`, `dobby goal parse`): ONE backend-agnostic contract over three backends — `github` (the `gh` CLI), `linear` (the MCP), `local` (a `BACKLOG.md` at the repo root) — selected by `dobby.config.json#tracker` (key ABSENT → `github`), mechanizing `plugin/skills/backlog/references/trackers.md` verbatim. A domain module behind the `command.ts` contract; every verb is an ACTION command (`runner.requireWorkroot` — outside a git repo it fails hard, and a MALFORMED config throws too since its `tracker` key is exactly what dispatch reads). THREE properties it exists to hold. (1) **No shell, ever**: every `gh` call is an ARGV ARRAY through `runner.runCapture` (cwd pinned to the workroot), so user-derived text — a search concept, an issue title — lands as ONE argv element whatever bytes it holds; the single-quote-binding / heredoc-escaping prose the skills used to carry is RETIRED, not simplified, and the body never reaches argv at all (`--body-file`, read by gh itself). (2) **Linear is never spawned**: a `linear` backend returns a DELEGATION DESCRIPTOR (`{delegate:"mcp", op, …}`) the SKILL executes through whichever tool it resolves via ToolSearch — naming a tool here would break trackers.md's tool-name agnosticism. (3) **Degradation is REPORTED, never performed**: D8 lives in `tracker info` (`available` from `gh auth status`, `degradedTo:"local"` + a reason naming gh) and in `goal parse`'s hard stop; `search`/`create`/`close`/`claim` never silently re-route a github call into a `BACKLOG.md` write nobody asked for. Two argument ORDERS are load-bearing and commented as such: `gh label create <role>` BEFORE `gh issue create`, and `gh label create status:in-progress` BEFORE `gh issue edit` (on a fresh repo an unknown label makes the edit fail outright and the whole in-progress signal is lost) — both ignore the label create's nonzero "Name has already been taken", and neither uses `--force`, which would overwrite a colour the repo's maintainers chose. `goal parse` emits the `lifecycleLink` (`Closes #<n>` / `Fixes <KEY>`) so commit/scope never re-derive it, plus the `slug` + `slugCollision` early warning (`scope preflight` stays the authority). The local backend parses/writes ONE line format (`- [ ] <title> — <body> (<role>)`), projects matches onto the github result shape, and marks a close by rewriting only the checkbox. `node:*` + the shared runner only (ADR-0008). +- `src/tracker.ts` (+ `src/tracker.test.ts`) — the **issue-tracker surface** (`dobby tracker info|search|create|close`, `dobby claim`, `dobby goal parse`): ONE backend-agnostic contract over three backends — `github` (the `gh` CLI), `linear` (the MCP), `local` (a `BACKLOG.md` at the repo root) — selected by `dobby.config.json#tracker` (key ABSENT → `github`), mechanizing `plugin/skills/backlog/references/trackers.md` verbatim. A domain module behind the `command.ts` contract; every verb is an ACTION command (`runner.requireWorkroot` — outside a git repo it fails hard, and a MALFORMED config throws too since its `tracker` key is exactly what dispatch reads). THREE properties it exists to hold. (1) **No shell, ever**: every `gh` call is an ARGV ARRAY through `runner.runCapture` (cwd pinned to the workroot), so user-derived text — a search concept, an issue title — lands as ONE argv element whatever bytes it holds; the single-quote-binding / heredoc-escaping prose the skills used to carry is RETIRED, not simplified, and the body never reaches argv at all (`--body-file`, read by gh itself). (2) **Linear is never spawned**: a `linear` backend returns a DELEGATION DESCRIPTOR (`{delegate:"mcp", op, …}`) the SKILL executes through whichever tool it resolves via ToolSearch — naming a tool here would break trackers.md's tool-name agnosticism. (3) **Degradation is REPORTED, never performed**: D8 lives in `tracker info` (`available` from `gh auth status`, `degradedTo:"local"` + a reason naming gh) and in `goal parse`'s hard stop; `search`/`create`/`close`/`claim` never silently re-route a github call into a `BACKLOG.md` write nobody asked for. Two argument ORDERS are load-bearing and commented as such: `gh label create <role>` BEFORE `gh issue create`, and `gh label create status:in-progress` BEFORE `gh issue edit` (on a fresh repo an unknown label makes the edit fail outright and the whole in-progress signal is lost) — both ignore the label create's nonzero "Name has already been taken", and neither uses `--force`, which would overwrite a colour the repo's maintainers chose. `goal parse` emits the `lifecycleLink` (`Closes #<n>` / `Fixes <KEY>`) so commit/scope never re-derive it, plus the `slug` + `slugCollision` early warning (informational only — `scope` no longer computes a collision verdict of its own). The local backend parses/writes ONE line format (`- [ ] <title> — <body> (<role>)`), projects matches onto the github result shape, and marks a close by rewriting only the checkbox. `node:*` + the shared runner only (ADR-0008). - `src/run.test.ts` + `__fixtures__/` — the co-located vitest suite (run via `vitest`, discovered from the repo root by vitest's default globs) and its hand-written sample projects. Tests call `run()` in-process with a fixture path (or a throwaway temp git repo) as `cwd`; they never import `detect.ts`/`config.ts`/`envinfo.ts`/`check.ts` directly. The `check` integration slices build a throwaway git repo (inline biome.jsonc + tsconfig + hand-written lint/type errors) and run the REAL biome + tsc. New behavior goes into PER-DOMAIN suites beside it (`src/<domain>.test.ts`) rather than growing this file. - `src/test-helpers.ts` (+ `src/test-helpers.test.ts`) — the SHARED test scaffolding the per-domain suites import; imported ONLY by `*.test.ts`, never by production code, and it NEVER ships (see the `files` allowlist below). Two seams. (1) **Stub bins on PATH** — the established way to test a tool dobby spawns BARE (`gh`, `npm`, `cmux`, `curl`): `mkStubBins({ gh: [...], npm: [] })` creates a temp dir of executable `/bin/sh` stubs to prepend to PATH (`stubPath` / `withStubPath`, which restores PATH even on a throw — the in-process `run(argv, cwd)` seam spawns children off the PARENT's env, so that is how a stub reaches the code under test); each stub RECORDS its full argv and then answers the first matching `StubResponse` (patterns are LITERAL substrings of the space-joined argv — the generated `case` arm single-quotes them, so a `*`/`?`/`[…]` inside a pattern matches ITSELF and only the wildcards wrapped around it float the substring; several patterns = AND; canned stdout/stderr/exit code — e.g. `gh pr view --json …` → a fixture payload), with `mkStubBin` as the hand-written-script escape hatch and `mkRecorderBin` for one bin at a time. `readStubLog(dir, name)` replays the recorded argv VECTORS in order: the log is `<argc>\n` + argc NUL-terminated arguments per invocation, so an argument holding spaces, quotes or newlines round-trips verbatim — which is what makes "the injection string landed in argv unmangled" a real assertion instead of a shell-quoting artifact. (2) **Scratch git repos** — `makeScratchRepo({ branch, pkg, config, files, commit })` builds a throwaway real repo under the pinned `gitEnv()` (fixed identity + `GIT_CONFIG_GLOBAL`/`SYSTEM` → `/dev/null`, no prompts, so ambient signing/hooks/templates can never derail a commit), `gitIn` reads git facts back as the independent observer, `cleanupDirs` is the afterAll counterpart. `run.test.ts` deliberately KEEPS its own local copies of the git-repo/stub-bin makers (untouched by the extraction). - `scripts/vendor-biome.ts` + `src/vendor-biome.test.ts` — the VENDORING generator for the flat biome presets + its drift guard. `generateVendoredBiome()` reads the INSTALLED ultracite (`require.resolve('ultracite/biome/core' | '…/react')`), parses the JSONC with a tiny STRING-AWARE regex stripper (the bundled TypeScript 7 native port exposes NO classic config-parser API — `ts.parseConfigFileTextToJson` is `undefined` — so there is no `ts.*` call and NO new dependency; the stripper runs identically under Bun and Node/vitest), applies dobby's modifications (`$schema` → the versioned biome URL; the house rules-off set — `noArrayIndexKey` in both, plus round-2's `noUnnecessaryConditions`/`noVoid`/`noNamespaceImport`/`noAwaitInLoops` in core and an added `noJsxPropsBind: off` in react; core appends the common consumer ignores incl. the TanStack/nitro force-excludes and `!**/*.css`; react adds the `src/routes/**` override AND the tier-(a) CONVENTION RULES — `noProcessEnv: error` with the three out-of-Vite files allowlisted, plus `noRestrictedImports` options in three scopes, each built by a named helper so the emitted key order — which the injected rationale comments anchor on — is fixed by the code), and emits `biome/core.jsonc` + `biome/react.jsonc` DETERMINISTICALLY (`JSON.stringify` 2-space + injected mod comments) with a `// Vendored from ultracite@<version> — regenerate: bun cli/scripts/vendor-biome.ts` header. It ALSO emits the third, non-vendored file `biome/configless.react.jsonc` — the internal `--config-path` wrapper — which is generator-owned precisely because it carries the TIER-(c) `plugins` array (the `GRIT_PLUGINS` list: one entry per `grit/*.grit` rule, path + the per-rule `includes` scope); a GritQL rule hand-edited in, or silently dropped out, would otherwise escape every gate. The script's write path is `import.meta`/argv-guarded so importing it in the test NEVER writes; the drift test regenerates all THREE in-memory and byte-compares against the committed files, so an ultracite upgrade or a hand-edit fails the gate until someone reruns the generator. Emitted files are EXCLUDED from the repo's own biome (`!cli/biome`) — biome's JSON formatter would collapse their arrays and fight the deterministic layout. - `grit/*.grit` (+ `grit/CONTEXT.md`) — the TIER-(c) convention rules: the house conventions biome's NATIVE rules cannot express, written as GritQL linter plugins (one file per inventory row, `c<NN>-<slug>.grit`; `a2-…` for the rule demoted OUT of tier (a) into this tier). Data, not code — nothing imports them; their only consumer is biome, through the `plugins` array of `biome/configless.react.jsonc`. **Declared in the WRAPPER, never in `react.jsonc`**: biome resolves a plugin path relative to the ROOT config — the file `--config-path` names, or the consumer's own `biome.jsonc` — NOT relative to the config that declares it, so from the wrapper `../grit/x.grit` lands inside dobby's package and the rules fire, while the same path declared in `react.jsonc` would be resolved from a CONSUMER's project root, miss, and abort their ENTIRE biome run with `Error(s) during loading of plugins: Cannot read file` (lab-verified, bundled biome 2.5.4). That makes the config-less path the shipped delivery mode; a consumer with their own `biome.jsonc` opts in by declaring the plugins themselves (documented in `README.md`). Path SCOPE always rides the per-plugin `includes` globs (`**/`-prefixed like every override glob), never the pattern — a GritQL pattern sees a file's syntax, never its location. Every rule ends in `register_diagnostic(span, message, severity: "error")` with NO rewrite, so `check --fix` never rewrites convention code, and `error` is required because `check` counts only error/warning severities. Two engine gotchas are load-bearing and recorded in `grit/CONTEXT.md` with the rest of the per-rule verdicts (shipped / partial / parked-with-evidence): a `\"` inside a message MANGLES the remainder of the string (downstream `r`/`t`/`n` letters emerge as CR/TAB/LF), so messages quote with apostrophes; and regexes are ANCHORED full-matches in which a CAPTURING group is a runtime error (use `(?:…)`). - `wizard/template.sh` — the **vendored wizard template**: a VERBATIM copy of `plugin/skills/wizard/references/template.sh` (only a provenance comment block is added, and it sits ABOVE the `set -euo pipefail` line where the hash comparison starts, so a script copied from EITHER file hashes the same). Data, not code — its only consumer is `wizard verify`, which hashes a generated wizard's library region against it, and `/dobby:wizard` points its implementor here. It is vendored for the same reason the skill-frontmatter whitelist is: the CLI ships to CONSUMERS, where no plugin directory exists. Shipped via the `wizard` entry in the `files` allowlist; re-copy it whenever the canonical template changes (the two must stay byte-identical from `set -euo pipefail` down, or every honest wizard starts failing verification). -- `tsconfig.base.json` + `tsconfig.vite.json` + `biome/core.jsonc` + `biome/react.jsonc` + `vite.base.mjs` + `vite.tanstack.mjs` + `vitest.base.mjs` + `vitest.react.mjs` + `drizzle.base.mjs` + the sibling `*.d.mts` type declarations + `knip.base.jsonc` — the exported PRESETS (thin-file model): consumers of the house stack (TanStack Start + Drizzle/Neon + vite + vitest) carry ONLY deltas. The four CONFIG presets ship as plain `.mjs` (never `.ts`): a consumer's vite/vitest/drizzle config re-exports them and NODE loads the preset file at runtime, but Node ≥23 refuses to type-strip `.ts` under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`) — which broke every preset-consuming repo under `dobby check` (the gate now runs vitest under node-if-present); `.mjs` loads natively everywhere (node, bun, esbuild loaders). `tsconfig.base.json` is the strict bundler base consumers extend (`strict`, `noUncheckedIndexedAccess`, `noUncheckedSideEffectImports`, `allowImportingTsExtensions`, `noEmit`, `skipLibCheck`, `isolatedModules`, `esModuleInterop`, `resolveJsonModule`, `module: preserve`, `moduleResolution: bundler`); `tsconfig.vite.json` (`@kvnwolf/dobby/tsconfig/vite`) is the vite-app variant — `extends` the base, adds `types: ["vite/client"]` (mirrors the biome core/react split). `biome/core.jsonc` + `biome/react.jsonc` are VENDORED FLAT — each is ultracite's config (core / react) inlined VERBATIM plus dobby's modifications: the HOUSE RULES-OFF set (`noArrayIndexKey` in both; core also disables `noUnnecessaryConditions` — biome's control-flow analysis doesn't model tsc's `noUncheckedIndexedAccess`, so it flags guards tsc REQUIRES — plus `noVoid`/`noNamespaceImport`/`noAwaitInLoops`, and react ADDS `noJsxPropsBind: off`, each off because the rule's cost exceeds its value for AI-written code; `noLeakedRender` deliberately stays ON); the COMMON CONSUMER IGNORES core appends — the TanStack Start/nitro build dirs FORCE-excluded (`!!**/.nitro`, `!!**/.vinxi`, `!!**/.tanstack`, ultracite's `!!**/dist` class), the tooling/agent dirs `!.claude`, `!.dobby`, `!.github`, `!.agents`, `!.hallmark`, `!skills-lock.json`, `!**/*.md`, `!**/*.css` (Tailwind-only stack: biome's CSS parser aborts on Tailwind 4 `@apply`/`@theme`, so CSS is out of biome's scope), plus the house-convention generated/vendored consumer dirs `!convex/**`, `!src/components/ui/**`, `!src/shadcn/**`, `!src/**/database.types.ts`; the react preset's `**/src/routes/**` OVERRIDE (relaxing `useFilenamingConvention` + `useSortedKeys` — TanStack Router route filenames ARE the route tree, and sorting Route keys collapses `head()`'s textual-order loaderData inference to `never`; the glob is `**/`-prefixed so it anchors under BOTH the consumer's extends chain AND the config-less `--config-path` wrapper, where biome resolves override `includes` relative to the config's OWN dir not the project cwd — lab-verified against bundled biome 2.5.4); the react preset's TIER-(a) CONVENTION RULES — the house stack's conventions as NATIVE biome rules, shipped in the REACT preset ONLY, which IS the capability gate (`biomeConfigSpec` picks the react wrapper only when the `react` capability is detected, so a non-stack consumer never sees a convention about `@/shared`/TanStack/the `.server` boundary): `noProcessEnv` flipped ON (core ships it off) with an override allowlisting the ENV MODULE ITSELF (`**/src/shared/env.ts`, `**/src/lib/env.ts` — the file that VALIDATES `process.env` via t3-env's `createEnv({ runtimeEnv: process.env })`; without it the rule is unsatisfiable for every house app, because the single blessed source of env would itself fail the gate) plus the three files that load OUTSIDE Vite (`**/src/router.tsx`, `**/drizzle.config.ts`, `**/src/emails/**/*.tsx`), and `noRestrictedImports` OPTIONS (core enables the rule with none — a no-op) banning the raw `@tanstack/react-form` hooks (use `useAppForm`), the raw `@tanstack/react-db` live-query hooks (render `<LiveQuery>`), anything but `db` out of `@/shared/db.server`, and the `getDb`/`getAuth`/`getResend` lazy accessors (an `importNamePattern` — biome's is IMPLICITLY anchored, `^`/`$` are a hard config error), with the whole server graph (`**/*.server`, the db instance, `drizzle-orm`/`better-auth`/`pg`/`@neondatabase/serverless`) banned under `**/src/routes/**`; failure messages are the convention skills' own wording. INVARIANT: a biome override REPLACES a rule's options instead of merging them (lab-verified), so the `**/src/shared/**` scope (where the raw hooks are legitimately wrapped) and the `**/src/routes/**` scope each RE-STATE every ban that still applies there — dropping a repeat silently legalises it in that scope. `schema.gen.ts` needs no rule of its own: core's `!!**/*.gen.*` force-exclude already keeps generated schema out of biome entirely (and a plain `!` negation could not weaken a `!!` force-exclude anyway), same class as the already-enabled `noBarrelFile`/`useImportType`/`useFilenamingConvention` — verified, never re-added; three convention rules are DEMOTED to the GritQL tier because biome 2.5.4 cannot express them (each verdict lab-verified and recorded beside the tier-(a) block in `scripts/vendor-biome.ts`): the `import.meta.env` ban (no `noRestrictedSyntax`-class rule; `noRestrictedGlobals` matches bare identifiers only), the `*.browser.ts` type-only-server-import rule (`noRestrictedImports` has no `allowTypeImports` and flags `import type` identically to a value import — it would ban exactly the legal usage), and the query-alias shadowing rule (native `noShadow` is a blanket superset that cannot be scoped to the `q.from({ … })` construct); and both replace ultracite's relative `$schema` with the versioned biome URL — because biome's `extends` is ONE-LEVEL / non-transitive (extending a config loads its OWN content but NOT the `extends` it declares), so WRAPPING ultracite would silently drop it. A react consumer therefore extends BOTH (`["@kvnwolf/dobby/biome/core", "@kvnwolf/dobby/biome/react"]`); the react preset RE-disables `noArrayIndexKey` (ultracite/react re-enables it as an error) so the last-in-chain value wins. The config-less biome default points `--config-path` at an INTERNAL root wrapper `biome/configless.react.jsonc` (`{ extends: ["./core.jsonc", "./react.jsonc"], plugins: [ …the tier-(c) GritQL rules… ] }` — GENERATED, see the `grit/` bullet for why the plugins live in the wrapper and not in `react.jsonc`) for react apps (extends resolves ONE level from the ROOT config, and both flat targets survive), else flat `biome/core.jsonc` DIRECTLY (`root: false` works as a `--config-path` target — lab-verified against bundled biome 2.5.4). The wrapper is NOT a consumer extends target — package.json `exports` expose ONLY `./biome/core` + `./biome/react`, so a consumer can never `extends` it by specifier (extending a wrapper is the very bug this vendoring kills). The preset dir ALSO ships an internal PROJECT-ROOT MARKER `biome/biome.jsonc` (`{ root: true }`, likewise unexported): biome derives a project root by walking UP from the `--config-path` file and then SCANS that project for nested root configurations, so WITHOUT the marker it adopts whatever tree dobby's package sits in (in the dev repo: dobby itself) and ONE unrelated nested root `biome.jsonc` in it — a git worktree under `.claude/worktrees/` (which the kit itself creates), a monorepo sibling — aborts the run with `Found a nested root configuration` and NO JSON, hard-failing EVERY config-less `dobby check` in a project that has nothing to do with it; neither `files.includes` negations nor the consumer's `--vcs-root` ignore file suppress that scan (the ignore file belongs to the consumer's repo, not the adopted foreign project). The marker stops the walk inside dobby's own preset dir, so the config-less biome default is HERMETIC (checked paths still come from the spawn cwd — findings unchanged). All three facts lab-verified against bundled biome 2.5.4. The vendored files are GENERATED (`cli/scripts/vendor-biome.ts`; regenerate with `bun cli/scripts/vendor-biome.ts`) and byte-for-byte DRIFT-GATED (`cli/src/vendor-biome.test.ts` regenerates in-memory + compares), so an ultracite upgrade screams at the gate until someone regenerates; `ultracite` is a DEV-only dependency (the script + test resolve it via `require.resolve('ultracite/biome/core')`), no longer runtime — consumers need nothing from it. `cli/biome` is EXCLUDED from the repo's own root `biome.jsonc` (`!cli/biome`): biome's JSON formatter would collapse the vendored short arrays onto one line and fight the generator's deterministic layout, breaking the drift compare — that same exclusion is what keeps the `biome/biome.jsonc` marker from tripping the repo's OWN bare biome run as a nested root config (on the native-discovery path a root config's `files.includes` DOES suppress nested-config discovery; under `--config-path` it does not). An extending config must NOT re-declare `**` in `files.includes` (Biome's `noBiomeFirstException`): consumer negations MERGE with the preset's (biome UNIONS `includes` across extends), which is why PROGRESSIVE migration uses a DENYLIST (`"!legacy/**"`) to SUBTRACT paths from the preset's `**` — an ALLOWLIST cannot survive it (`"!**"` would exclude everything). `vitest.base.mjs` is the universal test wiring (`@kvnwolf/dobby/vitest`) — a `defineConfig`-built default export carrying exactly two ingredients: `server.deps.inline: ["zod"]` (so vitest-under-bun's module runner can't mangle zod v4's dual export map — the same field bug the `check` runtime rule dodges) and `exclude: [...configDefaults.exclude, ".claude/**"]` (full worktree copies would else be double-discovered). Consumers merge app-specific bits on top (`mergeConfig(dobbyVitest, defineConfig({ plugins, test.env, … }))`); it is deliberately data-minimal — NO plugins/env/resolve (those are consumer-specific). It imports `vitest/config`, which resolves from the CONSUMER's tree at config-load time — `vitest` is NEVER a dobby dependency (the dual-Vite invariant). `vite.base.mjs` (`@kvnwolf/dobby/vite`) is the universal vite-app config — `resolve.tsconfigPaths: true` (vite@8 native, never the `vite-tsconfig-paths` plugin) + `server.allowedHosts: true` (portless serves the app through per-worktree custom hostnames — this key is dobby-lifecycle-coupled, hence preset), NO plugins (consumer-owned + version-coupled); consumers `mergeConfig` their plugins on top. `vitest.react.mjs` (`@kvnwolf/dobby/vitest/react`) is the react-app vitest variant — `mergeConfig` of the base plus `@vitejs/plugin-react`, native tsconfig paths, and `test.env: loadEnv("test", cwd, "")` (the `""` prefix loads EVERY var — house apps validate the full env at import time via `src/lib/env.ts`); it lives in a SEPARATE file from `vitest.base.mjs` precisely because it imports vite/`@vitejs` (the base must stay importable in repos WITHOUT vite — this repo). `drizzle.base.mjs` (`@kvnwolf/dobby/drizzle`) is the house drizzle-kit config — the UNPOOLED URL for DDL (`DATABASE_URL_UNPOOLED` ?? `POSTGRES_URL_NON_POOLING`; DDL through PgBouncer breaks migration tooling, the app runtime uses pooled `DATABASE_URL`), a guarded `.env.local` load (CI/Vercel have no file), a CI-safe missing-URL guard (static analysis + CI load the config just to read `schema`, never to run DDL), `dialect: "postgresql"`, `out: "./drizzle"`, and `schema` globbed from co-located `./src/**/schema.ts` + `./src/**/schema.gen.ts`. `vite`/`@vitejs/plugin-react`/`drizzle-kit` all resolve from the CONSUMER's tree — NONE is a dobby dependency (the base/react split exists so `vitest.base.mjs` stays importable vite-free). `vite.tanstack.mjs` (`@kvnwolf/dobby/vite/tanstack-start`) is the house TanStack Start app stack layered on the vite base via `mergeConfig` — the five house plugins (`@tanstack/devtools-vite`, `@tailwindcss/vite`, `@tanstack/react-start`'s vite plugin with `routeFileIgnorePattern: "\\.test\\."` for test co-location, `nitro`, `@vitejs/plugin-react`), ALL consumer-resolved (same rule; NONE is a dobby dep, and NO `@tanstack/*`/`@tailwindcss/vite`/`nitro` may ever enter cli deps). A no-delta TanStack Start consumer needs NO `vite.config.ts` at all (dobby's dev/build/check pass `--config` to this preset when the file is absent); a consumer with deltas writes `mergeConfig` on top. Each `.mjs` preset ships a SIBLING `.d.mts` type declaration (`vite.base.d.mts`, `vitest.base.d.mts`, `vitest.react.d.mts`, `drizzle.base.d.mts`, `vite.tanstack.d.mts`) typing its default export from the CONSUMER-resolved package (`UserConfig` from `vite`/`vitest/config`, `Config` from `drizzle-kit`) — because a consumer's STRICT tsc hits TS7016 (implicit any) re-exporting the untyped `.mjs`; each `exports` object carries a `types` condition FIRST (before `default`) pointing at that declaration. `knip.base.jsonc` is dobby's DEFAULT knip config for consumers WITHOUT their own (the CLI passes `--config`-when-absent) — VERIFIED semantics: specifying `entry` REPLACES knip's default entry globs (it is NOT additive), so the file re-states knip's own defaults (`{index,cli,main}` at root and under `src/`) PLUS `src/**/*.test.{ts,tsx}` (the field-bug fix — knip's vitest plugin can't see `test.include` through a consumer's `.mjs` re-export, so 10 real test files were flagged "unused file"); `project` and plugin entries stay knip's defaults, so genuine orphans are STILL caught. These preset files are themselves biome-formatted config assets — the repo's own root `biome.jsonc` (extending `@kvnwolf/dobby/biome/core`) and root `package.json#knip` carry real deltas so they stay (the override path), but the root `vitest.config.ts` was DELETED (ADR-0015 dogfood): it was a pure re-export = zero deltas, so the repo's own gate now runs vitest through the config-less default `--config <@kvnwolf/dobby vitest.base.mjs>` — the LIVE proof of the override-by-presence mechanism (vitest keeps `root = cwd`, so `.claude/**` exclusion + discovery are unchanged). There is no `vite.config.ts`. +- `tsconfig.base.json` + `tsconfig.vite.json` + `biome/core.jsonc` + `biome/react.jsonc` + `vite.base.mjs` + `vite.tanstack.mjs` + `vitest.base.mjs` + `vitest.react.mjs` + `drizzle.base.mjs` + the sibling `*.d.mts` type declarations + `knip.base.jsonc` — the exported PRESETS (thin-file model): consumers of the house stack (TanStack Start + Drizzle/Neon + vite + vitest) carry ONLY deltas. The four CONFIG presets ship as plain `.mjs` (never `.ts`): a consumer's vite/vitest/drizzle config re-exports them and NODE loads the preset file at runtime, but Node ≥23 refuses to type-strip `.ts` under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`) — which broke every preset-consuming repo under `dobby check` (the gate now runs vitest under node-if-present); `.mjs` loads natively everywhere (node, bun, esbuild loaders). `tsconfig.base.json` is the strict bundler base consumers extend (`strict`, `noUncheckedIndexedAccess`, `noUncheckedSideEffectImports`, `allowImportingTsExtensions`, `noEmit`, `skipLibCheck`, `isolatedModules`, `esModuleInterop`, `resolveJsonModule`, `module: preserve`, `moduleResolution: bundler`); `tsconfig.vite.json` (`@kvnwolf/dobby/tsconfig/vite`) is the vite-app variant — `extends` the base, adds `types: ["vite/client"]` (mirrors the biome core/react split). `biome/core.jsonc` + `biome/react.jsonc` are VENDORED FLAT — each is ultracite's config (core / react) inlined VERBATIM plus dobby's modifications: the HOUSE RULES-OFF set (`noArrayIndexKey` in both; core also disables `noUnnecessaryConditions` — biome's control-flow analysis doesn't model tsc's `noUncheckedIndexedAccess`, so it flags guards tsc REQUIRES — plus `noVoid`/`noNamespaceImport`/`noAwaitInLoops`, and react ADDS `noJsxPropsBind: off`, each off because the rule's cost exceeds its value for AI-written code; `noLeakedRender` deliberately stays ON); the COMMON CONSUMER IGNORES core appends — the TanStack Start/nitro build dirs FORCE-excluded (`!!**/.nitro`, `!!**/.vinxi`, `!!**/.tanstack`, ultracite's `!!**/dist` class), the tooling/agent dirs `!.claude`, `!.dobby`, `!.github`, `!.agents`, `!.hallmark`, `!skills-lock.json`, `!**/*.md`, `!**/*.css` (Tailwind-only stack: biome's CSS parser aborts on Tailwind 4 `@apply`/`@theme`, so CSS is out of biome's scope), plus the house-convention generated/vendored consumer dirs `!convex/**`, `!src/components/ui/**`, `!src/shadcn/**`, `!src/**/database.types.ts`; the react preset's `**/src/routes/**` OVERRIDE (relaxing `useFilenamingConvention` + `useSortedKeys` — TanStack Router route filenames ARE the route tree, and sorting Route keys collapses `head()`'s textual-order loaderData inference to `never`; the glob is `**/`-prefixed so it anchors under BOTH the consumer's extends chain AND the config-less `--config-path` wrapper, where biome resolves override `includes` relative to the config's OWN dir not the project cwd — lab-verified against bundled biome 2.5.4); the react preset's TIER-(a) CONVENTION RULES — the house stack's conventions as NATIVE biome rules, shipped in the REACT preset ONLY, which IS the capability gate (`biomeConfigSpec` picks the react wrapper only when the `react` capability is detected, so a non-stack consumer never sees a convention about `@/shared`/TanStack/the `.server` boundary): `noProcessEnv` flipped ON (core ships it off) with an override allowlisting the ENV MODULE ITSELF (`**/src/shared/env.ts`, `**/src/lib/env.ts` — the file that VALIDATES `process.env` via t3-env's `createEnv({ runtimeEnv: process.env })`; without it the rule is unsatisfiable for every house app, because the single blessed source of env would itself fail the gate) plus the three files that load OUTSIDE Vite (`**/src/router.tsx`, `**/drizzle.config.ts`, `**/src/emails/**/*.tsx`), and `noRestrictedImports` OPTIONS (core enables the rule with none — a no-op) banning the raw `@tanstack/react-form` hooks (use `useAppForm`), the raw `@tanstack/react-db` live-query hooks (render `<LiveQuery>`), anything but `db` out of `@/shared/db.server`, and the `getDb`/`getAuth`/`getResend` lazy accessors (an `importNamePattern` — biome's is IMPLICITLY anchored, `^`/`$` are a hard config error), with the whole server graph (`**/*.server`, the db instance, `drizzle-orm`/`better-auth`/`pg`/`@neondatabase/serverless`) banned under `**/src/routes/**`; failure messages are the convention skills' own wording. INVARIANT: a biome override REPLACES a rule's options instead of merging them (lab-verified), so the `**/src/shared/**` scope (where the raw hooks are legitimately wrapped) and the `**/src/routes/**` scope each RE-STATE every ban that still applies there — dropping a repeat silently legalises it in that scope. `schema.gen.ts` needs no rule of its own: core's `!!**/*.gen.*` force-exclude already keeps generated schema out of biome entirely (and a plain `!` negation could not weaken a `!!` force-exclude anyway), same class as the already-enabled `noBarrelFile`/`useImportType`/`useFilenamingConvention` — verified, never re-added; three convention rules are DEMOTED to the GritQL tier because biome 2.5.4 cannot express them (each verdict lab-verified and recorded beside the tier-(a) block in `scripts/vendor-biome.ts`): the `import.meta.env` ban (no `noRestrictedSyntax`-class rule; `noRestrictedGlobals` matches bare identifiers only), the `*.browser.ts` type-only-server-import rule (`noRestrictedImports` has no `allowTypeImports` and flags `import type` identically to a value import — it would ban exactly the legal usage), and the query-alias shadowing rule (native `noShadow` is a blanket superset that cannot be scoped to the `q.from({ … })` construct); and both replace ultracite's relative `$schema` with the versioned biome URL — because biome's `extends` is ONE-LEVEL / non-transitive (extending a config loads its OWN content but NOT the `extends` it declares), so WRAPPING ultracite would silently drop it. A react consumer therefore extends BOTH (`["@kvnwolf/dobby/biome/core", "@kvnwolf/dobby/biome/react"]`); the react preset RE-disables `noArrayIndexKey` (ultracite/react re-enables it as an error) so the last-in-chain value wins. The config-less biome default points `--config-path` at an INTERNAL root wrapper `biome/configless.react.jsonc` (`{ extends: ["./core.jsonc", "./react.jsonc"], plugins: [ …the tier-(c) GritQL rules… ] }` — GENERATED, see the `grit/` bullet for why the plugins live in the wrapper and not in `react.jsonc`) for react apps (extends resolves ONE level from the ROOT config, and both flat targets survive), else flat `biome/core.jsonc` DIRECTLY (`root: false` works as a `--config-path` target — lab-verified against bundled biome 2.5.4). The wrapper is NOT a consumer extends target — package.json `exports` expose ONLY `./biome/core` + `./biome/react`, so a consumer can never `extends` it by specifier (extending a wrapper is the very bug this vendoring kills). The preset dir ALSO ships an internal PROJECT-ROOT MARKER `biome/biome.jsonc` (`{ root: true }`, likewise unexported): biome derives a project root by walking UP from the `--config-path` file and then SCANS that project for nested root configurations, so WITHOUT the marker it adopts whatever tree dobby's package sits in (in the dev repo: dobby itself) and ONE unrelated nested root `biome.jsonc` in it — a git worktree the operator opened, a monorepo sibling — aborts the run with `Found a nested root configuration` and NO JSON, hard-failing EVERY config-less `dobby check` in a project that has nothing to do with it; neither `files.includes` negations nor the consumer's `--vcs-root` ignore file suppress that scan (the ignore file belongs to the consumer's repo, not the adopted foreign project). The marker stops the walk inside dobby's own preset dir, so the config-less biome default is HERMETIC (checked paths still come from the spawn cwd — findings unchanged). All three facts lab-verified against bundled biome 2.5.4. The vendored files are GENERATED (`cli/scripts/vendor-biome.ts`; regenerate with `bun cli/scripts/vendor-biome.ts`) and byte-for-byte DRIFT-GATED (`cli/src/vendor-biome.test.ts` regenerates in-memory + compares), so an ultracite upgrade screams at the gate until someone regenerates; `ultracite` is a DEV-only dependency (the script + test resolve it via `require.resolve('ultracite/biome/core')`), no longer runtime — consumers need nothing from it. `cli/biome` is EXCLUDED from the repo's own root `biome.jsonc` (`!cli/biome`): biome's JSON formatter would collapse the vendored short arrays onto one line and fight the generator's deterministic layout, breaking the drift compare — that same exclusion is what keeps the `biome/biome.jsonc` marker from tripping the repo's OWN bare biome run as a nested root config (on the native-discovery path a root config's `files.includes` DOES suppress nested-config discovery; under `--config-path` it does not). An extending config must NOT re-declare `**` in `files.includes` (Biome's `noBiomeFirstException`): consumer negations MERGE with the preset's (biome UNIONS `includes` across extends), which is why PROGRESSIVE migration uses a DENYLIST (`"!legacy/**"`) to SUBTRACT paths from the preset's `**` — an ALLOWLIST cannot survive it (`"!**"` would exclude everything). `vitest.base.mjs` is the universal test wiring (`@kvnwolf/dobby/vitest`) — a `defineConfig`-built default export carrying exactly two ingredients: `server.deps.inline: ["zod"]` (so vitest-under-bun's module runner can't mangle zod v4's dual export map — the same field bug the `check` runtime rule dodges) and `exclude: [...configDefaults.exclude, ".claude/**"]` (full worktree copies would else be double-discovered). Consumers merge app-specific bits on top (`mergeConfig(dobbyVitest, defineConfig({ plugins, test.env, … }))`); it is deliberately data-minimal — NO plugins/env/resolve (those are consumer-specific). It imports `vitest/config`, which resolves from the CONSUMER's tree at config-load time — `vitest` is NEVER a dobby dependency (the dual-Vite invariant). `vite.base.mjs` (`@kvnwolf/dobby/vite`) is the universal vite-app config — `resolve.tsconfigPaths: true` (vite@8 native, never the `vite-tsconfig-paths` plugin) + `server.allowedHosts: true` (portless serves the app through per-worktree custom hostnames — this key is dobby-lifecycle-coupled, hence preset), NO plugins (consumer-owned + version-coupled); consumers `mergeConfig` their plugins on top. `vitest.react.mjs` (`@kvnwolf/dobby/vitest/react`) is the react-app vitest variant — `mergeConfig` of the base plus `@vitejs/plugin-react`, native tsconfig paths, and `test.env: loadEnv("test", cwd, "")` (the `""` prefix loads EVERY var — house apps validate the full env at import time via `src/lib/env.ts`); it lives in a SEPARATE file from `vitest.base.mjs` precisely because it imports vite/`@vitejs` (the base must stay importable in repos WITHOUT vite — this repo). `drizzle.base.mjs` (`@kvnwolf/dobby/drizzle`) is the house drizzle-kit config — the UNPOOLED URL for DDL (`DATABASE_URL_UNPOOLED` ?? `POSTGRES_URL_NON_POOLING`; DDL through PgBouncer breaks migration tooling, the app runtime uses pooled `DATABASE_URL`), a guarded `.env.local` load (CI/Vercel have no file), a CI-safe missing-URL guard (static analysis + CI load the config just to read `schema`, never to run DDL), `dialect: "postgresql"`, `out: "./drizzle"`, and `schema` globbed from co-located `./src/**/schema.ts` + `./src/**/schema.gen.ts`. `vite`/`@vitejs/plugin-react`/`drizzle-kit` all resolve from the CONSUMER's tree — NONE is a dobby dependency (the base/react split exists so `vitest.base.mjs` stays importable vite-free). `vite.tanstack.mjs` (`@kvnwolf/dobby/vite/tanstack-start`) is the house TanStack Start app stack layered on the vite base via `mergeConfig` — the five house plugins (`@tanstack/devtools-vite`, `@tailwindcss/vite`, `@tanstack/react-start`'s vite plugin with `routeFileIgnorePattern: "\\.test\\."` for test co-location, `nitro`, `@vitejs/plugin-react`), ALL consumer-resolved (same rule; NONE is a dobby dep, and NO `@tanstack/*`/`@tailwindcss/vite`/`nitro` may ever enter cli deps). A no-delta TanStack Start consumer needs NO `vite.config.ts` at all (dobby's dev/build/check pass `--config` to this preset when the file is absent); a consumer with deltas writes `mergeConfig` on top. Each `.mjs` preset ships a SIBLING `.d.mts` type declaration (`vite.base.d.mts`, `vitest.base.d.mts`, `vitest.react.d.mts`, `drizzle.base.d.mts`, `vite.tanstack.d.mts`) typing its default export from the CONSUMER-resolved package (`UserConfig` from `vite`/`vitest/config`, `Config` from `drizzle-kit`) — because a consumer's STRICT tsc hits TS7016 (implicit any) re-exporting the untyped `.mjs`; each `exports` object carries a `types` condition FIRST (before `default`) pointing at that declaration. `knip.base.jsonc` is dobby's DEFAULT knip config for consumers WITHOUT their own (the CLI passes `--config`-when-absent) — VERIFIED semantics: specifying `entry` REPLACES knip's default entry globs (it is NOT additive), so the file re-states knip's own defaults (`{index,cli,main}` at root and under `src/`) PLUS `src/**/*.test.{ts,tsx}` (the field-bug fix — knip's vitest plugin can't see `test.include` through a consumer's `.mjs` re-export, so 10 real test files were flagged "unused file"); `project` and plugin entries stay knip's defaults, so genuine orphans are STILL caught. These preset files are themselves biome-formatted config assets — the repo's own root `biome.jsonc` (extending `@kvnwolf/dobby/biome/core`) and root `package.json#knip` carry real deltas so they stay (the override path), but the root `vitest.config.ts` was DELETED (ADR-0015 dogfood): it was a pure re-export = zero deltas, so the repo's own gate now runs vitest through the config-less default `--config <@kvnwolf/dobby vitest.base.mjs>` — the LIVE proof of the override-by-presence mechanism (vitest keeps `root = cwd`, so `.claude/**` exclusion + discovery are unchanged). There is no `vite.config.ts`. - `package.json` — bin `dobby` → `./src/index.ts`, `type: module`, and the preset `exports` (`./tsconfig` → `./tsconfig.base.json`, `./tsconfig/vite` → `./tsconfig.vite.json`, `./biome/core` → `./biome/core.jsonc`, `./biome/react` → `./biome/react.jsonc`; and the five `.mjs` presets each as a `{ types, default }` CONDITION object — `./vite`, `./vite/tanstack-start`, `./vitest`, `./vitest/react`, `./drizzle` — with the `types` condition FIRST pointing at the sibling `.d.mts` and `default` at the `.mjs`). A `files` ALLOWLIST bounds the published tarball to what consumers need — `src` (MINUS the co-located tests, negated by GLOB — `!src/*.test.ts` — plus `!src/test-helpers.ts`, so a new per-domain suite is excluded the moment it is written, never one negation per file), `biome`, `grit` (the tier-(c) `.grit` rule assets — unpacked, every tier-(c) rule is silently gone in the field, which `src/grit-rules.test.ts` guards by checking each declared plugin path against this allowlist), the two `tsconfig.*.json` presets, the five `.mjs` presets (incl. `vite.tanstack.mjs`), the `*.d.mts` glob (the type declarations — note `.d.mts` does NOT match a `.mjs` entry), and `knip.base.jsonc`; `__fixtures__/` and the tests never ship (package.json/README are auto-included by npm/bun) — verified with both packers (`bun pm pack --dry-run` and `npm pack --dry-run` honor the negations identically). The bundled toolchain (`@biomejs/biome`, `ultracite`, `typescript`, `knip`, `taze`, `portless`) sits in **runtime `dependencies`** so consumers inherit the tool bins + preset resolution transitively; `vitest`/`vite`/`@vitejs/plugin-react`/`drizzle-kit` are NOT among them (consumer-provided — the dual-Vite invariant; the vite/vitest-react/drizzle presets import those CONSUMER-resolved packages, resolved from the consumer's tree at config-load time). Every package a shipped preset imports — `vite`, `vitest`, `drizzle-kit`, `@vitejs/plugin-react`, `@tanstack/react-start`, `@tanstack/devtools-vite`, `@tailwindcss/vite`, `nitro` — is instead declared as an OPTIONAL `peerDependency` (permissive `"*"` range — dobby tracks the house fleet, not a version policy — each marked `optional: true` in `peerDependenciesMeta`): the peer declaration is what lets STRICT non-hoisted layouts (pnpm/isolated linkers) resolve the presets' imports from dobby's OWN package location, while `optional: true` keeps hoisted/bun consumers and non-users of a given preset free of install pressure and warnings (ADR-0015 world). NONE ever enters `dependencies` (never bundled — the dual-Vite invariant stands). - `README.md` — the **npm package front page** (consumer-facing, English): what `@kvnwolf/dobby` is (zero-config toolchain + env-aware run lifecycle), the single-devDep install, the thin-config `extends` model, the FULL command reference with per-command examples + flags, the canonical conventions (`src/emails`, `.env.local` NEON creds), the `dobby.config.json` schema, and the per-capability inferred defaults. It documents the WHOLE surface and states that the live `dobby` help is capability-filtered (shows only the applicable subset). The repo-root `README.md` covers the plugin/kit; this one is the CLI's. @@ -77,16 +77,15 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `update` → `taze --interactive` from DOBBY's OWN bundled taze, inheriting stdio (the picker is user-driven and terminates with them). Fails hard outside a git repo. Not auto-invoked in tests (interactive) — covered by usage-text presence + the QA's live recipe. - `up [--dry-run] [--json]` → prepares the workspace and PROBES it — it no longer starts anything itself. **(0)** fail hard outside a git repo (the git precondition wins over every gate). **(1)** SETUP PHASE (the folded former `setup` command): `bun install` (always — the inferred default; `DOBBY_SKIP_INSTALL=1` skips only this), then the PRE-PUSH BACKSTOP install (idempotent, into the repository's COMMON hooks dir so one install covers every worktree; a hook dobby did not write is NAMED in the plan and left alone, and neither that nor an unwritable hooks dir ever fails the phase), then `.worktreeinclude` re-materialization (LINKED-worktree only — copy each main-only match MISSING at the worktree, idempotent), then config `setup[]` extras (`sh -c`, sequential, FAIL-FAST) — any setup-phase failure exits nonzero and **the run phase never starts**. **(1b)** resolve the `rename` INSTRUCTION unconditionally (empty outside cmux) — INDEPENDENT of the app gate (a no-app project's `phase:"noop"` report still carries it); the model runs `cmux rename-workspace --workspace <id> <slug>`, and the panes it opens carry the `dobby-` prefix, the workspace title IS the goal identity. **(2)** no vite → a graceful exit-0 no-op (`no app to run`), reached only AFTER the setup phase, `instructions` still carrying `rename` when applicable. **(3)** RUN PHASE (now PROBE-AND-INSTRUCT, not START), in this fixed order: a single liveness probe FIRST — already up → `live:true`, `instructions:[]` (just `rename` when applicable), exit 0, starting nothing, nothing below runs; not up → a neon project missing either cred in `.env.local` → hard exit 1 (guaranteed branch isolation, NO silent main-DB fallback), else provision the `dobby/<slug>` neon branch when the neon capability is present (`bunx neonctl branches create --name … --project-id … --output json`, idempotent, then rewrite `.env.local` DATABASE_URL / DATABASE_URL_UNPOOLED from the branch connection strings — a failed provision is the `neon-branch-failed` reason) — THEN check for a LIVE registered twin already in flight (`pidfile.liveRegisteredPid`): found → wait out the liveness retry loop instead of handing back another `start` instruction; none found → return `instructions:[rename?, start]` with `live:false`, exit 0 — the `start` text is `environment.instruction("start", ctx)` (under cmux: reuse a discovered run pane via `cmux send`, or instruct creating + naming one; under terminal/Desktop/t3-code: a background `Bash` job with `run_in_background`). A liveness wait that never answers (6×5s, capped by the `DOBBY_LIVENESS_RETRIES` test seam) is the `liveness-timeout` reason — exit 1 with the `portless trust` hint. `--dry-run` renders the FULL ordered plan (`Up plan (dry-run):` + the setup-phase lines, THEN the run-phase ACTION lines — probe / neon-branch / wait, in that order — or the `no app to run` skip line, THEN every applicable INSTRUCTION together — `rename` before `start` — under one trailing `agent:` label, never interleaved with the action lines above it) WITHOUT installing / touching neon / running anything. **`--json`** replaces every human rendering above with ONE flat JSON object as the SOLE stdout (a skill parses stdout whole) — `{browserPane, cmux, degradedCommand, devUrl, instructions, live, ok, phase: setup|run|noop, reason, slug, verifyMode: url|programmatic, workroot}`, EnvSnapshot style (explicit nulls, never omitted keys). `instructions` is an `Instruction[]` (`{applies, text, topic}`, `applies:true` entries only), ALWAYS present (possibly empty), ordered `rename` then `start`. `reason` is a closed ENUM — `not-a-git-repo` · `config-unreadable` · `install-failed` · `worktree-copy-failed` · `setup-extra-failed` · `neon-creds-missing` · `neon-branch-failed` · `liveness-timeout` — never prose (the human message still goes to STDERR, and so does the setup phase's child output: under `--json` every setup child's stdout streams to fd 2, keeping stdout clean while a long install still streams live); there is no member for a failed start — `up` starts nothing itself anymore, so a failed start is something the model sees in the `start` instruction's own command output, not something `up` reports. `verifyMode` is derived from `devUrl` (a URL to hit, else verify programmatically); `degradedCommand` is `DOBBY_SKIP_INSTALL=1 bunx dobby up` for an INSTALL-phase failure and null otherwise. Exit code follows the action-command convention: 0 on `ok:true`, nonzero on `ok:false` (never both). `--json --dry-run` reports the run it PLANNED (`ok:true`, no reason) while still executing nothing. See `plugin/skills/execute/references/bring-up.md` for the full two-step protocol (`up` → carry out `instructions[]` → `up` again) every skill/agent that brings a worktree up follows. - `down [--dry-run] [--json]` → tear the run down (best-effort), mechanizing finish teardown: kill the `.dobby/dev.pid` process (SIGTERM the BARE pid, never the process group — a model-launched `dobby dev` is not guaranteed to lead one — ONLY when the pid passes BOTH ownership checks: the `ps` command-line `dobby dev` signature AND a start-time match, the process's `ps` etime start no later than the pidfile mtime + a 15s tolerance — so a recycled pid running ANOTHER worktree's dobby dev, or any stale pid, → remove the file silently, signal nothing), delete the `dobby/<slug>` neon branch (`bunx neonctl branches delete`, missing → ok), run config `teardown[]` extras (`sh -c`, sequential) — this is `down`'s OWN mechanics, complete by the time it returns; the only thing left for the model is the `stop` INSTRUCTION (`environment.instruction("stop", ctx)` — under cmux, `cmux close-surface` on each discovered kit pane, applicable only when at least one is discovered; never applicable elsewhere, since the pidfile kill above already tore the process down). Nothing to clean AND no `stop` instruction → exit 0 no-op. `--dry-run` renders the plan (`Down plan (dry-run):` + the action lines — kill-pidfile / neon-delete / extra, in that order — or `(nothing to clean)` when BOTH the actions AND the instructions are empty, THEN the `stop` instruction under a trailing `agent:` label when applicable). **`--json`** → `{cmux, instructions, ok, reason, slug, workroot}` — `instructions` carries `stop` only when applicable; `reason` is a closed ENUM — `not-a-git-repo` · `neon-delete-failed` (reserved — the neon delete stays best-effort, so this member is not yet produced by any path) · `teardown-extra-failed` — or `null` on success. Fails hard outside a git repo. -- `scope preflight --slug <slug>` (`--json`) → the READ-ONLY verdict `/dobby:scope` gates on, computing what the goal WOULD take without creating anything: `{branch: "worktree-<slug>", path: "<mainRoot>/.claude/worktrees/<slug>/", collision:{branchExists, dirExists}, suggestedSlug (a free `<slug>-N` variant, null when there is no collision), nested:{insideWorktree, currentSlug, worktreeRoot}, existingWorktrees[], configPresent, dobbyInstalled, mainRoot, slug}`. `--slug` is REQUIRED (its absence is a refusal naming the kebab-case slug the branch and directory are named after). `existingWorktrees` is INFORMATIONAL — parallel goals in parallel worktrees are normal and never a refusal signal; the ONE structural blocker is `nested.insideWorktree` (the native `EnterWorktree` cannot nest), and the skill still owns the decision. Entering the worktree stays NATIVE — this command never creates, enters, or removes one. Fails hard outside a git repo. - `state init [--goal <s>] [--source <s>] | set <Section> (--file <f>|--stdin) | append-worklog --task <id> (--file <f>|--stdin) | lint` → the STATE.md section engine at `<workroot>/STATE.md` (fails hard outside a git repo). `init` → the canonical 7-section skeleton + the `.gitignore` entry, refusing an existing STATE.md. `set` → replace ONE section body, every other byte (unknown sections included) preserved; `## Goal`/`## Source` are write-once, `## Work log` is never settable (use `append-worklog`), `## Spec` is re-settable, and the same input twice is a no-op. `append-worklog` → append `### Task <id>` under `## Work log`, demoting the entry's `## ` headings to `### `. `lint` → the structural report (H1, required sections present, canonical sections in order + unduplicated, gitignored) on stdout, exit 1 on any violation, `ok` + exit 0 when clean. Unknown legacy sections are preserved and tolerated: in particular, an old `## Execution profile` remains byte-for-byte but `init` never creates one and no runtime reads it. `--json` on any command answers `{ok, path, action}` (lint adds `violations[]`) as the ONLY stdout; every refusal goes to stderr with exit 1. - `build-plan [--file <doc>] [--task <task.json>] [--json]` → the task-dependency plan for `/dobby:execute` (and, with `--task`, for `/dobby:dispatch`). Default source: the `## Spec` body of `<workroot>/STATE.md` (`--file` overrides the document), whose task table is located by its HEADER ROW — `#`/`Task`/`Depends on`/`Affected areas`/`Verify recipe`, with `Description`, `Test-first` and `Destructive` all OPTIONAL (no Description → the title is the task's `spec`; an absent flag column → false) — so a spec written before the `### ` sub-heading format still plans, and a non-task table inside the spec is skipped. `--task <file>` reads ONE ad-hoc task from JSON instead and never touches STATE.md (it carries the surface the spec named `--task-file`, which the dispatcher's flag set has no option for). Answers `{tasks[{id,title,spec,decisions,constraints,areas[],verifyRecipe,testFirst,destructive,dependsOn[]}], hasTestSuite{value,specSays,disagreement}, manualVerifySetup: "none"|string[], preconditions{ok,missing[{taskId,field}],danglingDeps[{taskId,dependsOn}],cycles[[id…]]}, workRoot}` — `tasks` VERBATIM for the Architect to dispatch directly (`decisions`/`constraints` empty by contract, `devUrl` merged by the coordinator). There is NO wave/batch grouping in the payload: `dependsOn` (the row's `Depends on` ids — `[]` for `—`/empty) is the ONLY thing that says who a task waits for, and a task is ready the moment every id it names has reached `done` — which is what lets the caller SKIP a task whose blocker never passed without holding back anything independent of it. Ids are STRINGS, the same ones `dependsOn` references. Failing preconditions exit 1 **with the payload still on stdout** (the refusal names each task and cell on stderr); a missing document / unparseable `--task` file / table-less spec is a hard error with no payload. Fails hard outside a git repo. - `ship [--message-file <f>] [--pr-body-file <f>] [--json]` → the COMMIT CEREMONY in ONE call, the mechanized half of `/dobby:commit`. `--message-file` is REQUIRED and validated FIRST (present, readable, non-blank; resolved against the CALLER's cwd) — a ceremony that cannot produce a message leaves the tree exactly as it found it (unstaged, un-formatted, un-gated). Then: stage when nothing is staged → the **GATE IN-PROCESS** (`check([], root, {}, fix=true)`, never a `dobby` subprocess) → `.dobby/` exclude-ensured (in `.git/info/exclude`) and the WHOLE tree re-staged (the gate judges the working tree, so committing a caller-staged SUBSET would record a green verdict for a tree that was never checked) → the gate cache → `git commit -F` → push pinned to ORIGIN (`-u origin HEAD` when the branch tracks nothing; a non-origin upstream still pushes to origin, reported via `pushNote`) → the pull request. A detached HEAD is refused before any mutation. **The exit code decides**: a nonzero gate returns the gate's OWN code with every finding printed WHOLE (uncapped, never `formatCheck`'s 50-per-tool sample) and commits nothing. The PR is opened ONLY off a NON-TRUNK branch (`main`/`master` have nowhere to open one from) and ONLY with a `--pr-body-file` (the body is the caller's to author); an EXISTING PR for the branch is reported, not duplicated; a failed push short-circuits it, and a PR gh could not open is a NOTE, not a failure (the commit already landed). Answers `{cacheNote, cacheWritten, committed, gateExitCode, gateNote, prNote, prUrl, pushNote, pushed, sha}` — the notes distinguish "skipped by policy" from "could not be done", and `gateNote` carries the `gate skipped: inputs unchanged since last green (…)` line when the in-process gate was served from the per-check cache (null when it really ran). Fails hard outside a git repo. - `review fetch [--pr N] [--json]` · `review apply (--plan <f>|--stdin) [--pr N] [--dry-run] [--json]` · `pr watch [--pr N] [--deadline <sec>] [--await-review] [--json]` → the `gh` surface of the address-review stage; `--pr` defaults to the CURRENT branch's PR. `fetch` → `{pr, adapter, candidates, threads, summary}`: the open review THREADS over GraphQL (drained with gh's mandatory `$endCursor` pagination contract, each thread carrying its last comments so a re-run sees its own prior replies) plus the bot's summary comment over REST (sorted by `updated_at`, since the bot EDITS one comment in place, and bot logins matched by BARE slug because GraphQL and REST disagree about the `[bot]` suffix). A PR with nothing to address is `threads: []` at exit 0 — an ANSWER, not an error. `apply` consumes a disposition plan (`{pr, reTrigger, plan:[{threadId, disposition: fix|dismiss|outdated|defer, reply}]}`) from `--plan`/`--stdin`, replies + resolves in batches, SKIPS threads it already answered (idempotent by construction) and re-triggers when asked; `defer` deliberately does NOT resolve (a deferred finding stays open) and `--dry-run` makes the same decisions with zero writes. Any failure exits 1 with `{failures[], replied[], resolved[], retriggered, skipped[], dryRun}`. `pr watch` owns its OWN polling loop and derives the verdict from check BUCKET COUNTS (`gh pr checks --json` always exits 0, and `--watch --json` is a hard error) — `ci-failed|ci-green|ci-pending|merge-ready|feedback-present|open-unreviewed|skipped`, with `--deadline` (default 300s) budgeting EACH wait phase separately (CI, then the review under `--await-review`) so a slow CI run can never eat the review wait. NO merge path — every judgment stays in `/dobby:address-review`. All three fail hard outside a git repo, and a gh that could not report at all is surfaced, never read as an empty (green) check list. -- `finish --preflight [--slug <slug>] [--json]` → the READ-ONLY teardown verdict for ONE goal, computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], mode: "same-session"|"orphan", removeMechanism: "ExitWorktree"|"raw-git", branch, branchDeleteSafe, candidates[], dirty, dobbyInstalled, mainRoot, pr, slug, worktreePath}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed, so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. Without `--slug` it resolves the goal from the enclosing worktree (`candidates[]` lists the orphans a caller can pick from). Removal itself stays native/manual in the skill — this command removes nothing. Fails hard outside a git repo. +- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed, so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it); removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. - `repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>` → the red/green capture harness. Everything after `--` is the command, spawned with cwd pinned to the workroot (fails hard outside a git repo) and its stdout/stderr captured WHOLE — repro never truncates. One run answers `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` = red on any nonzero exit, `matched` = `verdict === --expect` (explicitly `null`, never absent, without `--expect`). Exit code: **1 ONLY on a mismatch** (the "your loop is not red-capable" signal); a failing command with NO `--expect` exits 0 — the harness reports an exit code, it never inherits one. `reproId` is a short hash of the workroot + the command argv ONLY (repro's own flags are excluded, so every run of one loop keys the same record), and each run persists `{baseline, latest, reproId}` at `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured); a failed write is a stderr warning, not a failure. `--repeat N` runs the loop N times sequentially and ADDS `{runs, redCount, greenCount, reproductionRate (redCount/runs), deterministic, firstDivergent:{run (1-based), stdout, stderr}|null, durations}` — the base record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), so `--expect red --repeat N` is a red-CAPABILITY probe and the pasted output is the failing run's. `--bench` ADDS `{samples, min, median (over sorted samples), mean, max}` plus `baseline` (the previous bench of this reproId, `null` on the first) and `delta` (current MINUS baseline per timing stat — faster reads negative), then stores its own stats as the next baseline; a non-bench run leaves the stored baseline alone. `--json` prints the full payload as the sole stdout; the default render is a compact human summary (headline + `cmd:`/`cwd:` + the repeat/bench lines + the record path) with a labelled TAIL of the output. - `kb list --kind <k> | kb record --kind <k> --concept <kebab> --title <t> --reason-file <f> --entry <line>` → the durable knowledge bases at `<workroot>/docs/out-of-scope/` and `<workroot>/docs/learn-discarded/` (fails hard outside a git repo). `--kind` is REQUIRED for both and is the module's only parameter; an unknown one is a hard error naming both KBs (a typo must never read as "that KB is empty" — dedup would silently stop working). `list` → a bare JSON ARRAY (under `--json`) of `{concept (filename stem), path, title (the H1), statement (the first paragraph, wrapped lines joined), priorEntries (the `- ` bullets under the kind's prior-section, markers stripped)}`, one per `*.md`, sorted by filename; an ABSENT directory is `[]` at exit 0, never an error. `record` → ONE file per concept: an existing concept's file gets the entry APPENDED as a bullet under its prior-section (every byte before that heading untouched — the rationale written the first time wins over this call's `--title`/`--reason-file`), an absent one is created after a lazy `mkdir`, carrying the canonical skeleton (H1, the one-line statement, the kind's why-heading + the reason body, the kind's prior-heading + the first bullet). `--reason-file` is split at its FIRST LINE (the statement) with the REST as the reason body; a file with no body is refused. Answers the bare `{path, created, appended}`. Every refusal goes to stderr with exit 1 (no `ok` envelope — the payloads are the spec's bare shapes). - `adr new "<title>" [--status proposed|accepted|deprecated]` → allocate the next ADR number and create `<workroot>/docs/adr/NNNN-<slug>.md` (fails hard outside a git repo; the dir is created lazily, and only after the inputs validate — a refusal never leaves an empty `docs/adr/` behind). The title is a POSITIONAL (every positional after the token, joined); the slug is DERIVED from it. Numbering is `max + 1` over the local directory AND `git ls-tree -r origin/HEAD --name-only -- docs/adr` (a sibling worktree's ADR is pushed long before it lands here; no origin / no git / no upstream `docs/adr` all score 0, so a remote-less repo still files ADRs), and the number is claimed — not merely the filename: each attempt re-reads `docs/adr/` and moves to the next number if anything already carries the `NNNN-` prefix, whatever its slug, with `O_EXCL` (`flag: "wx"`) behind it so an `EEXIST` retries instead of truncating an identically-named ADR. The scan is a read and is stale the instant it returns, so the claim happens at WRITE time, never at scan time. Writes a SKELETON only — `# NNNN. <title>`, the optional `**Status:** <status>` line (omitted without `--status`), and a placeholder paragraph; body authorship stays with the architect. Answers the bare `{number, slug, path}`; a missing title / an unknown status is a refusal on stderr with exit 1, naming what IS valid. -- `tracker info | tracker search <concept> | tracker create --title <t> --body-file <f> [--label <role>] | tracker close <id> --rejected` · `claim <id>` · `goal parse <arg>` (all `--json`) → the ISSUE-TRACKER surface, one contract over `github`/`linear`/`local` (config `tracker`, absent → github; every verb fails hard outside a git repo). `info` → `{available, degradedTo, reason, team, type}`: github probes `gh auth status` (D8 — unavailable ⇒ `available:false`, `degradedTo:"local"`, a reason naming gh), local is always available, linear reports `available:null` (reachability is MCP-side) and spawns NOTHING. `search` → `{backend, concept, results}` — github passes the concept as ONE argv element to `gh issue list --state open --search <concept> --json number,title,url,state,labels` and hands the answer back UNCHANGED; local scans open `BACKLOG.md` lines (absent file → `[]`) projected onto that same shape; linear → `{delegate:"mcp", op:"search", args:{team, query}}`. `create` → `{number, url}` (the number parsed off the URL gh prints — `gh issue create` has no `--json`), ensuring the role label FIRST (`gh label create <role>`, its "already taken" nonzero deliberately ignored; never `--force`, which would overwrite a customized colour) and handing the body over as a FILE (`--body-file` is resolved against the CALLER's cwd — the CLI's convention for every path flag — and gh is handed that ABSOLUTE path, since its own cwd is the workroot); local appends `- [ ] <title> — <first body line> (<role>)` to a lazily created `BACKLOG.md`; linear → an `op:"create"` descriptor. `close <id> --rejected` (the flag is REQUIRED — this command only ever closes AS REJECTED; a completed goal is closed by its merged PR) → github `gh issue close <id> --reason "not planned"`, local marks the matching line `- [x]` leaving every other byte alone, linear → `{op:"setState", state:"Canceled"}`. `claim <id>` → github creates `status:in-progress` BEFORE `gh issue edit <id> --add-assignee @me --add-label status:in-progress` (order load-bearing: a fresh repo's unknown label makes the edit fail outright), linear → `{op:"claim", assignee:"me", state:"In Progress"}` (the kit's ONLY Linear state write besides Canceled — everything after it rides the PR body's `Fixes` link), local → `{claimed:false, reason:"local"}` (no state machine). `goal parse <arg>` → `{hardStop, id, lifecycleLink, slug, slugCollision, source, url}`: the pattern set is GATED by the configured tracker (github reads `#42` + github.com issue URLs and NEVER `VON-123`; linear reads UPPERCASE `VON-123` + linear.app URLs and NEVER `#42`; local has no pattern), so anything unmatched is a `prompt` goal; `id` is bare (`"42"`, no sigil), `lifecycleLink` is `Closes #<n>` / `Fixes <KEY>` emitted HERE so commit/scope never re-derive it, `slug` is a few kebab words (an issue goal starts from `issue-<n>` / the key), `slugCollision` checks the branch `worktree-<slug>` AND `.claude/worktrees/<slug>/`, and `hardStop` is set ONLY for a github issue goal while gh is unreachable (an issue goal has no free-text fallback — D8's one hard stop; free text always continues). A missing operand (concept / issue id / goal reference / `--title` / an unreadable `--body-file`) is a refusal on stderr with exit 1 naming what is missing, and nothing is created. +- `tracker info | tracker search <concept> | tracker create --title <t> --body-file <f> [--label <role>] | tracker close <id> --rejected` · `claim <id>` · `goal parse <arg>` (all `--json`) → the ISSUE-TRACKER surface, one contract over `github`/`linear`/`local` (config `tracker`, absent → github; every verb fails hard outside a git repo). `info` → `{available, degradedTo, reason, team, type}`: github probes `gh auth status` (D8 — unavailable ⇒ `available:false`, `degradedTo:"local"`, a reason naming gh), local is always available, linear reports `available:null` (reachability is MCP-side) and spawns NOTHING. `search` → `{backend, concept, results}` — github passes the concept as ONE argv element to `gh issue list --state open --search <concept> --json number,title,url,state,labels` and hands the answer back UNCHANGED; local scans open `BACKLOG.md` lines (absent file → `[]`) projected onto that same shape; linear → `{delegate:"mcp", op:"search", args:{team, query}}`. `create` → `{number, url}` (the number parsed off the URL gh prints — `gh issue create` has no `--json`), ensuring the role label FIRST (`gh label create <role>`, its "already taken" nonzero deliberately ignored; never `--force`, which would overwrite a customized colour) and handing the body over as a FILE (`--body-file` is resolved against the CALLER's cwd — the CLI's convention for every path flag — and gh is handed that ABSOLUTE path, since its own cwd is the workroot); local appends `- [ ] <title> — <first body line> (<role>)` to a lazily created `BACKLOG.md`; linear → an `op:"create"` descriptor. `close <id> --rejected` (the flag is REQUIRED — this command only ever closes AS REJECTED; a completed goal is closed by its merged PR) → github `gh issue close <id> --reason "not planned"`, local marks the matching line `- [x]` leaving every other byte alone, linear → `{op:"setState", state:"Canceled"}`. `claim <id>` → github creates `status:in-progress` BEFORE `gh issue edit <id> --add-assignee @me --add-label status:in-progress` (order load-bearing: a fresh repo's unknown label makes the edit fail outright), linear → `{op:"claim", assignee:"me", state:"In Progress"}` (the kit's ONLY Linear state write besides Canceled — everything after it rides the PR body's `Fixes` link), local → `{claimed:false, reason:"local"}` (no state machine). `goal parse <arg>` → `{hardStop, id, lifecycleLink, slug, slugCollision, source, url}`: the pattern set is GATED by the configured tracker (github reads `#42` + github.com issue URLs and NEVER `VON-123`; linear reads UPPERCASE `VON-123` + linear.app URLs and NEVER `#42`; local has no pattern), so anything unmatched is a `prompt` goal; `id` is bare (`"42"`, no sigil), `lifecycleLink` is `Closes #<n>` / `Fixes <KEY>` emitted HERE so commit/scope never re-derive it, `slug` is a few kebab words (an issue goal starts from `issue-<n>` / the key), `slugCollision` is an informational check for an already-taken branch/worktree name for that slug, and `hardStop` is set ONLY for a github issue goal while gh is unreachable (an issue goal has no free-text fallback — D8's one hard stop; free text always continues). A missing operand (concept / issue id / goal reference / `--title` / an unreadable `--body-file`) is a refusal on stderr with exit 1 naming what is missing, and nothing is created. - `release [--bump patch|minor|major] [--notes-file <f>] [--dry-run] [--json]` → cut ONE release, through the target adapter `dobby.config.json#release.type` selects. CONFIG-GATED: without a `release` key the command does not exist at all (absent from the help, unknown to the dispatcher). Phases, each recorded in `phases[]`: preflight (MAIN checkout only, on `main`, clean tree, `git pull --ff-only`, CI green **on the commit being released** (HEAD, or its parent on the resume run, where HEAD is the unpushed release commit), `adapter.preflight`) → version (inferred from the commits since the last `v*` tag) → bump (indentation-preserving manifest rewrite + lockstep, the gate over the bumped tree, a LOCAL `release: v<V>` commit) → changelog → publish (`adapter.packGate` → `adapter.publish` → tag → push → `gh release create` → `adapter.smoke`). Three stops, all exit **1** so none can read as a finished release: `{needsDecision: "first-release"|"0x-major", context:{currentVersion, commits[]}}` (nothing touched — answer with `--bump`), `nothing to release` (the last tag is already on HEAD), and `{needsNotes: true, version, changelog:{groupedBy, groups, since}}` (everything mechanical done, the bump commit LOCAL, nothing pushed — author the notes and re-run with `--notes-file`, from a path OUTSIDE the repo). A completed release exits 0 with `{changelog, phases[], published: true, tag, version}`; `--dry-run` exits 0 with the version + changelog it WOULD cut, having touched nothing. The `npm` target lives in `release-npm.ts`; it reads two `release` keys of its own — `dir` (the publishable package dir, which every npm/bun spawn is pinned to) and the optional `smoke`, an ARGV ARRAY (`["bun", "install", "-g", "…"]`, never a shell string) run after the registry serves the published version, whose nonzero exit fails the smoke phase. The `homebrew-cask` target lives in `release-cask.ts`; it reads `tap` (the tap REPOSITORY, e.g. `kvnwolf/homebrew-tap`), `cask` (the cask TOKEN, i.e. the `Casks/<cask>.rb` it writes) and the OPTIONAL `notaryProfile` (a notarytool KEYCHAIN PROFILE name — its presence adds the Developer ID + profile preflights and the submit/staple/spctl gates to packGate; its ABSENCE is the un-notarized flow, which spawns no Apple tool at all; a present-but-EMPTY value is a config mistake and is refused by name, never read as "no"), adds the two optional phases to the run (`bumpExtras` in the bump, `post-release` between the GitHub release and the smoke), and its smoke REPORTS the `brew install --cask` command instead of running it. - `migrate preflight | migrate verify` (`--json`) → the two mechanized ends of `/dobby:migrate-config` (fails hard outside a git repo; both are READ-ONLY and touch nothing). `preflight` answers `{verdict: "already-migrated"|"migration-needed", signals:{legacyYaml, oldEraConfig:{path,hasRun,vpExtras}|null, viteplusDep, aliases[], viteHooks, vpTaskTable, prepareScript, conductor}, snapshot:{toolchainDeps[], preservedKeys[], worktreeinclude, scripts[], toolConfigs:{biome,vite,vitest,drizzle}, envTest, ci[], vercelJson, tracker:{source,type,team}}}` — `already-migrated` iff NO legacy signal fired AND `dobby.config.json` is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`. `verify` runs the gate IN-PROCESS and answers `{ok, check:{exitCode, failingSteps[]}, env:{capabilities[], config, devUrl, dbTasks[]}, residual:{legacyFilesRemaining[], deltaConfigsKept[], trackerIncomplete}}` — `failingSteps[]` is never empty for a nonzero `exitCode` — `ok` = a green gate AND no legacy artifact left AND a fully-pinned tracker (a KEPT delta config is reported, never held against the repo). Every path in both payloads is REPO-RELATIVE. Both exit **0 with the payload for EVERY verdict** (they inform, they never refuse); exit 1 is reserved for the two unanswerable cases — outside a git repository, and a gate that could not START. - `spec lint [<file>]` · `map lint|next|claim <slug> [<file>]` · `skill lint <dir>` · `wizard verify <script>` · `arch-report verify <file>` · `handoff finalize <file> [--focus <s>]` · `brief lint (--file <f>|--issue N)` (all `--json`) → the ARTIFACT LINTERS. One report contract: findings one per line as `<where>: <message>`, exit 1 on ANY finding, `ok` + exit 0 when clean, `--json` → `{ok, findings: [{check, message, where}], notes}` as the sole stdout line; an unresolvable TARGET (a file that is not there, a slug the map never had, a gh call that failed) is a hard error on stderr (never a clean verdict). A check that could not RUN — shellcheck absent, a repo with no `.github/workflows` — is a NOTE, never a finding and never an exit code. `spec lint` defaults to `<workroot>/STATE.md`'s `## Spec`, `map` to the newest `docs/maps/*.md`. `map next` answers `{path, question, slug, title, type}` (nulls when nothing is claimable) and writes nothing; `map claim <slug>` rewrites that ticket's `Status:` line to `in-progress` in place and answers `{claimed, ok, path, status}`. `handoff finalize` prints the document's ABSOLUTE PATH as its clean verdict (and carries it as the `path` payload key) — its output IS the skill's "echo the path" step; `brief lint --issue N` reads the newest comment on the issue through `gh`. diff --git a/cli/README.md b/cli/README.md index ad8ae51..7b03e83 100644 --- a/cli/README.md +++ b/cli/README.md @@ -442,21 +442,17 @@ dobby build-plan --task /tmp/one-task.json --json # one ad-hoc task, no STATE. The table is found by its **header row** (`#` / `Task` / `Depends on` / `Affected areas` / `Verify recipe`, with `Description`, `Test-first` and `Destructive` optional), so a non-task table inside the spec is skipped. The answer carries `tasks[]` — the per-task instruction data verbatim (`id`, `title`, `spec`, `decisions`, `constraints`, `areas[]`, `verifyRecipe`, `testFirst`, `destructive`, `dependsOn[]`), the exact shape `build-protocol.md` consumes — plus `hasTestSuite` (`{value, specSays, disagreement}`, the repo's own `vitest` capability against what the spec claims), `manualVerifySetup`, `preconditions` (`{missing, danglingDeps, cycles, ok}`), and `workRoot`. There is no wave grouping: `dependsOn` carries each row's dependency ids unchanged, so a task can start the moment its own dependencies are done, and a `destructive` task is flagged rather than isolated — the coordinator serializes it at dispatch time. **Failing preconditions exit 1 with the payload still on stdout**, so the caller can show exactly which task and which cell. -### `dobby scope preflight` · `dobby finish --preflight` · `dobby migrate` +### `dobby finish --preflight` · `dobby migrate` -The read-only verdicts a destructive or planning step asks for **before** it acts. None of them creates, enters, or removes anything — they compute the predicate, the decision stays with the caller. +The read-only verdicts a destructive or planning step asks for **before** it acts. Neither creates, enters, or removes anything — they compute the predicate, the decision stays with the caller. ```sh -dobby scope preflight --slug csv-export --json dobby finish --preflight --json -dobby finish --preflight --slug csv-export --json dobby migrate preflight --json dobby migrate verify --json ``` -`scope preflight` reports what the goal would take (branch `worktree-<slug>`, directory `.claude/worktrees/<slug>/`), whether either is already taken (plus a collision-free `suggestedSlug`), whether this session is already **inside** a worktree, and whether the repo carries the dobby contract. Parallel worktrees are informational — never a refusal. - -`finish --preflight` answers the teardown verdict for one goal: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed, so the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports which removal mechanism applies and whether the branch is safe to delete. +`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed, so the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`) and whether the branch is safe to delete. `dobby migrate preflight` says whether a repo still needs the config migration (naming each legacy signal and snapshotting what the migration must carry across); `dobby migrate verify` runs the gate in-process and reports the environment read back plus whatever was left behind. Both **exit 0 with a payload for every verdict** — they inform, they never refuse. diff --git a/cli/src/preflight.test.ts b/cli/src/preflight.test.ts index 6227b11..e0b4996 100644 --- a/cli/src/preflight.test.ts +++ b/cli/src/preflight.test.ts @@ -1,10 +1,8 @@ import { chmodSync, - existsSync, mkdirSync, mkdtempSync, realpathSync, - rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -22,48 +20,65 @@ import { } from "./test-helpers.ts"; // =========================================================================== -// The SESSION PREFLIGHTS — `dobby scope preflight --slug <s>` and -// `dobby finish --preflight`. +// The SESSION PREFLIGHT — `dobby finish --preflight` — plus the contract that +// `dobby scope` NO LONGER EXISTS. // -// Both are READ-ONLY verdicts a stage asks for BEFORE it acts: they compute the -// predicate, and every destructive AskUserQuestion gate stays in the SKILL. A -// preflight therefore never creates, removes, or enters anything — these tests -// assert facts about a tree they built themselves, and the tree must survive. +// The worktree belongs to the OPERATOR. Two consequences pinned here: +// - dobby never creates, names, enters or preflights one, so `scope preflight` +// is deleted outright and its invocation is an ordinary unknown command. +// - finish is SYMMETRIC: it no longer assumes a kit-made `worktree-<slug>` and +// no longer takes a `--slug` at all. It reports where the session STANDS — +// inside a linked worktree (whoever made it) or on a plain checkout — and +// tears a worktree down only in the first case. There is therefore no +// `candidates[]`, no `mode`, no `removeMechanism` and no `slug` in the +// payload; a worktree the kit did not make is as valid a subject as one it +// did, and a plain checkout is a first-class answer rather than an error. +// +// The finish preflight stays READ-ONLY: a verdict the stage asks for BEFORE it +// acts, with every destructive AskUserQuestion gate left in the SKILL. It never +// creates, removes, or enters anything — these tests assert facts about a tree +// they built themselves, and the tree must survive. // // The seam is the in-process `run(argv, cwd)` contract (ADR-0008): the domain // module is never imported directly, so its internals can be restructured freely // without touching this file. // // Where every expected value comes from (all INDEPENDENT of the code): -// - `worktree-<slug>` (the branch) and `.claude/worktrees/<slug>/` (the path) are -// the kit's literal naming, stated verbatim in the task spec and in the scope / -// finish SKILL.md ("branch `worktree-<slug>`", "path `.claude/worktrees/<slug>/`"). -// - Every slug in the fixtures is a literal WE choose, and every worktree/branch -// that exists is one WE created with real `git worktree add` — so collision, -// nesting, and the parallel-worktree listing are all facts of a tree whose exact -// contents we wrote down, never facts recomputed the way the code computes them. -// - The `pr` payload is exactly what our stub `gh` printed (the external boundary's -// answer): the assertion is that the CLI passes the boundary's answer THROUGH -// unchanged, keyed on the branch it asked about. -// - The verdict rules (`safe` = MERGED + clean, `blocked` = dobby not installed, -// else `confirm-required`), `branchDeleteSafe` = (pr.state === "MERGED"), the -// `same-session|orphan` modes and their `ExitWorktree|raw-git` mechanisms are the -// spec's / the finish SKILL.md's literal wording. +// - `inWorktree` is checked against fixtures whose nature WE chose: a repo made +// by `git init` alone (plain checkout — its git dir IS its common dir) versus +// one added with real `git worktree add` (a LINKED worktree — its git dir is +// an admin dir under the main checkout's common dir). Git's own definition of +// a linked worktree, not a rule read off the implementation. +// - Every branch name is a literal WE pass to `git switch -c` / `worktree add`, +// and every uncommitted file is one WE wrote — so the branch, the dirty count +// and the dirty file list are facts of a tree whose exact contents we wrote +// down, never facts recomputed the way the code computes them. +// - `worktreePath`/`mainRoot` are the temp dirs WE created, compared +// realpath-normalized (macOS resolves /tmp through /private/tmp). +// - The `pr` payload is exactly what our stub `gh` printed (the external +// boundary's answer): the assertion is that the CLI passes the boundary's +// answer THROUGH unchanged, keyed on the branch it asked about. +// - The verdict rules (`safe` = MERGED + clean, `blocked` = dobby not installed +// and nothing else, else `confirm-required`) and `branchDeleteSafe` = +// (pr.state === "MERGED") are the spec's literal wording. +// - "unknown flag --<name>" is the CLI's established literal for a flag a +// command does not take (the same one `env --baseline` answers with), and +// "unknown command: <name>" plus its `bun update @kvnwolf/dobby` second line +// are its literals for a command it does not have. // - "dobby must run inside a git repository" is the CLI's established hard-error // literal for an action command outside a repo. // // The ONE boundary that is mocked is `gh` (an external API over the network), -// stubbed as an executable on PATH — the repo's established stub-bin seam. `git` is -// REAL throughout: temp repos + real `git worktree add` fixtures are what make the -// nesting/collision/candidate facts trustworthy. +// stubbed as an executable on PATH — the repo's established stub-bin seam. `git` +// is REAL throughout: temp repos + real `git worktree add` fixtures are what make +// the inWorktree/dirty facts trustworthy. // =========================================================================== // The shared seams (`test-helpers.ts`) supply everything generic here: the pinned // git env + `gitIn` observer (so fixtures build identically on any machine, // whatever the developer's signing/hooks/templates config), the scratch-repo -// maker, and the stub-bin-on-PATH seam. Only the KIT-SHAPED fixture helpers below -// (a worktree at `.claude/worktrees/<slug>`, a local dobby install) are local — -// they encode this domain's layout, not generic plumbing. +// maker, and the stub-bin-on-PATH seam. Only the fixture helpers below (a local +// dobby install, a linked worktree cut to an arbitrary path) are local. // // The stub dir prepended to PATH holds ONLY `gh`, so every `git` spawn — the // fixtures' and the CLI's alike — still resolves to the real binary. @@ -72,13 +87,24 @@ import { // absolute-path comparison (top-level per the repo's useTopLevelRegex convention). const TRAILING_SLASH = /\/$/; -const scratchDirs: string[] = []; +// Text mode prints the same facts as the JSON, in whatever prose the CLI likes: +// `inWorktree`, `in worktree`, `in-worktree` all carry the fact. +const IN_WORKTREE_LABEL = /in.?worktree/i; +const PR_STATE_OPEN = /open/i; +const UNCOMMITTED_WORK = /uncommitted|dirty/i; -// The kit's worktree location for a slug — the literal `.claude/worktrees/<slug>` -// under the main checkout (scope SKILL.md), used to BUILD the fixtures. -function worktreePathFor(mainRoot: string, slug: string): string { - return join(mainRoot, ".claude", "worktrees", slug); -} +// The usage block lists ONE command per line, indented; matching line-anchored is +// what makes "no longer advertised" checkable — a bare substring would also hit +// the word inside another command's description. +const USAGE_SCOPE_LINE = /^\s*scope\b/m; +const USAGE_FINISH_LINE = /^\s*finish\b/m; +const USAGE_MIGRATE_LINE = /^\s*migrate\b/m; + +// The four fields the operator-owned worktree deletes outright. A payload that +// still carries any of them is answering the OLD, kit-made-worktree question. +const REMOVED_FIELDS = ["candidates", "mode", "removeMechanism", "slug"]; + +const scratchDirs: string[] = []; // Mark a root as carrying a local dobby install, the way the finish skill probes it // (`node_modules/.bin/dobby` executable) AND the way a manifest reader would @@ -94,9 +120,9 @@ function installDobby(root: string): void { // A throwaway MAIN checkout: a real git repo on `main` whose tracked tree is // COMMITTED up front (README, .gitignore, package.json, and — when asked — a -// dobby.config.json), so every worktree cut from it starts CLEAN. node_modules/ and -// .claude/ are gitignored, so the local dobby install and the kit worktrees we add -// under `.claude/worktrees/` never register as uncommitted changes. +// dobby.config.json), so it starts CLEAN and so does every worktree cut from it. +// node_modules/ is gitignored, so the local dobby install never registers as an +// uncommitted change. function makeMainCheckout(opts: { config?: boolean; dobby?: boolean }): string { const dir = makeScratchRepo({ branch: "main", @@ -119,21 +145,31 @@ function makeMainCheckout(opts: { config?: boolean; dobby?: boolean }): string { return dir; } -// Add a real kit worktree: branch `worktree-<slug>` checked out at -// `<main>/.claude/worktrees/<slug>` — the exact shape the native EnterWorktree -// creates. Returns its absolute path. -function addKitWorktree( - mainRoot: string, - slug: string, - opts: { dobby?: boolean } = {} +// A PLAIN checkout standing on a goal branch: `git init` + a commit + a branch +// the operator switched to by hand. Its git dir IS its common dir, which is what +// makes it NOT a worktree. +function makePlainCheckout( + branch: string, + opts: { config?: boolean; dobby?: boolean } = { config: true, dobby: true } ): string { - mkdirSync(join(mainRoot, ".claude", "worktrees"), { recursive: true }); - const path = worktreePathFor(mainRoot, slug); - gitIn(mainRoot, ["worktree", "add", "-q", "-b", `worktree-${slug}`, path]); - if (opts.dobby) { - installDobby(path); - } - return path; + const dir = makeMainCheckout(opts); + gitIn(dir, ["switch", "-q", "-c", branch]); + return dir; +} + +// Add a LINKED worktree with plain git, at a path of the operator's choosing +// OUTSIDE the main checkout — deliberately nothing like the kit's old +// `.claude/worktrees/<slug>` layout, since the kit no longer owns the naming. +// Returns its realpath-normalized absolute path. +function addLinkedWorktree(mainRoot: string, branch: string): string { + const parent = realpathSync( + mkdtempSync(join(tmpdir(), "dobby-preflight-wt-")) + ); + scratchDirs.push(parent); + const path = join(parent, "wt"); + gitIn(mainRoot, ["worktree", "add", "-q", "-b", branch, path]); + installDobby(path); + return realpathSync(path); } // A plain temp dir that is NOT a git repo — the "outside a repo" case. @@ -152,17 +188,17 @@ function makeNonGitDir(): string { // which is precisely what a branch with no PR looks like. Keyed on the branch so a // preflight that asked about the WRONG branch gets a different (or no) answer. -const PR_SHIP_IT = { +const PR_GOAL = { mergedAt: "2026-07-20T10:00:00Z", state: "MERGED", url: "https://github.com/acme/scratch/pull/7", }; -const PR_WIP_GOAL = { +const PR_STILL_OPEN = { mergedAt: null, state: "OPEN", url: "https://github.com/acme/scratch/pull/8", }; -const PR_ORPHAN_GOAL = { +const PR_DIRTY_TREE = { mergedAt: "2026-07-21T11:30:00Z", state: "MERGED", url: "https://github.com/acme/scratch/pull/9", @@ -172,15 +208,21 @@ const PR_NO_DOBBY = { state: "MERGED", url: "https://github.com/acme/scratch/pull/10", }; +const PR_GOAL2 = { + mergedAt: "2026-07-23T09:45:00Z", + state: "MERGED", + url: "https://github.com/acme/scratch/pull/11", +}; -// Matching is a literal substring of the joined argv, first-wins in order: each -// branch answers with its own PR, and the trailing catch-all is gh's real -// behavior for a branch with no pull request (exit 1 on stderr). +// Matching is a literal substring of the joined argv, FIRST-WINS in order — hence +// `goal2` ahead of `goal`, whose name it contains. The trailing catch-all is gh's +// real behavior for a branch with no pull request (exit 1 on stderr). const GH_ANSWERS: StubResponse[] = [ - { match: "worktree-no-dobby", stdout: JSON.stringify(PR_NO_DOBBY) }, - { match: "worktree-orphan-goal", stdout: JSON.stringify(PR_ORPHAN_GOAL) }, - { match: "worktree-ship-it", stdout: JSON.stringify(PR_SHIP_IT) }, - { match: "worktree-wip-goal", stdout: JSON.stringify(PR_WIP_GOAL) }, + { match: "goal2", stdout: JSON.stringify(PR_GOAL2) }, + { match: "still-open", stdout: JSON.stringify(PR_STILL_OPEN) }, + { match: "dirty-tree", stdout: JSON.stringify(PR_DIRTY_TREE) }, + { match: "no-dobby", stdout: JSON.stringify(PR_NO_DOBBY) }, + { match: "goal", stdout: JSON.stringify(PR_GOAL) }, { exitCode: 1, stderr: "no pull requests found for branch\n" }, ]; @@ -198,598 +240,547 @@ afterAll(() => { scratchDirs.length = 0; }); -// --- The payload shapes (the spec's field list, nothing more) --------------- - -interface WorktreeRef { - branch: string; - path: string; - slug: string; -} - -interface ScopePreflight { - branch: string; - collision: { branchExists: boolean; dirExists: boolean }; - configPresent: boolean; - dobbyInstalled: boolean; - existingWorktrees: WorktreeRef[]; - mainRoot: string; - nested: { - currentSlug: string | null; - insideWorktree: boolean; - worktreeRoot: string | null; - }; - path: string; - slug: string; - suggestedSlug: string | null; -} +// --- The payload shape (the spec's field list, nothing more) ---------------- interface FinishPreflight { branch: string; branchDeleteSafe: boolean; - candidates: WorktreeRef[]; dirty: { count: number; files: string[] }; dobbyInstalled: boolean; + inWorktree: boolean; mainRoot: string; - mode: string; pr: { mergedAt: string | null; state: string; url: string } | null; reasons: string[]; - removeMechanism: string; - slug: string; verdict: string; - worktreePath: string; + worktreePath: string | null; } -async function scopePreflight( - cwd: string, - slug: string -): Promise<ScopePreflight> { - const result = await run( - ["scope", "preflight", "--slug", slug, "--json"], - cwd - ); - expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); - return JSON.parse(result.stdout) as ScopePreflight; +// The preflight ANSWERS (a payload on stdout) for every verdict; only the +// outside-a-repo hard error refuses. A non-safe verdict may carry a nonzero exit +// code, which is why the accepted set is {0, 1} — the exact code per verdict is +// pinned where the spec states it (the safe path) and mirrored between JSON and +// text mode below. +async function finishPreflight(cwd: string): Promise<FinishPreflight> { + const result = await run(["finish", "--preflight", "--json"], cwd); + expect([0, 1], `stderr: ${result.stderr}`).toContain(result.exitCode); + return JSON.parse(result.stdout) as FinishPreflight; } -async function finishPreflight( - cwd: string, - slug?: string -): Promise<FinishPreflight> { - const argv = ["finish", "--preflight", "--json"]; - if (slug !== undefined) { - argv.push("--slug", slug); - } - const result = await run(argv, cwd); - expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); - return JSON.parse(result.stdout) as FinishPreflight; +// A returned absolute path, comparable against a fixture path on macOS (where +// /tmp resolves through /private/tmp). `null` passes through untouched, so a +// missing path shows up as an assertion DIFF rather than an ENOENT throw. +function normalizePath(path: string | null): string | null { + return path === null ? null : realpathSync(path.replace(TRAILING_SLASH, "")); } // =========================================================================== -// Slice 1 (tracer bullet) — `scope preflight` projects the goal's identity. -// -// The very first thing scope needs before it calls the native EnterWorktree: for -// slug `add-csv-export`, WHICH branch and WHICH directory this goal would take, -// whether either already exists, and where the main checkout is. Run from a clean -// main checkout that has NO worktrees at all. +// Slice 1 (the tracer bullet) — a PLAIN checkout, PR merged, tree clean. The +// case the operator-owned worktree makes possible at all: the session never +// stood in a worktree, so there is nothing to tear down, and finish still has a +// complete, safe answer — merge done, branch deletable, working tree clean. // =========================================================================== -describe("scope preflight — the goal's branch and worktree path", () => { - let mainRoot: string; +describe("finish --preflight — merged PR, clean plain checkout", () => { + let checkout: string; beforeAll(() => { - mainRoot = makeMainCheckout({ config: true, dobby: true }); + checkout = makePlainCheckout("goal"); }); - it("names the branch the goal would take as worktree-<slug>", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.branch).toBe("worktree-add-csv-export"); - }); - - it("echoes the requested slug", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.slug).toBe("add-csv-export"); - }); - - it("names the worktree path the goal would take under .claude/worktrees", async () => { - // The spec's literal, trailing slash included: `.claude/worktrees/<s>/` - // (relative to mainRoot, which the payload also carries). - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.path).toBe(".claude/worktrees/add-csv-export/"); + it("verdicts the close safe, exiting zero", async () => { + const result = await run(["finish", "--preflight", "--json"], checkout); + const payload = JSON.parse(result.stdout) as FinishPreflight; + expect({ exitCode: result.exitCode, verdict: payload.verdict }).toEqual({ + exitCode: 0, + verdict: "safe", + }); }); - it("reports the main checkout root", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.mainRoot).toBe(mainRoot); + it("carries no reasons on the safe path", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.reasons).toEqual([]); }); - it("reports no collision for a slug no branch or directory uses", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.collision).toEqual({ - branchExists: false, - dirExists: false, - }); + it("reports a session standing on a plain checkout, with no worktree", async () => { + const preflight = await finishPreflight(checkout); + expect({ + inWorktree: preflight.inWorktree, + worktreePath: preflight.worktreePath, + }).toEqual({ inWorktree: false, worktreePath: null }); }); - it("suggests no alternative slug when there is no collision", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.suggestedSlug).toBe(null); + it("reports the workroot itself as the main checkout root", async () => { + const preflight = await finishPreflight(checkout); + expect(normalizePath(preflight.mainRoot)).toBe(checkout); }); - it("reports no nesting when run from the main checkout", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.nested).toEqual({ - currentSlug: null, - insideWorktree: false, - worktreeRoot: null, - }); + it("names the branch the session stands on", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.branch).toBe("goal"); }); - it("reports the dobby contract as present (config file + local install)", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect({ - configPresent: preflight.configPresent, - dobbyInstalled: preflight.dobbyInstalled, - }).toEqual({ configPresent: true, dobbyInstalled: true }); + it("passes the PR state through for that branch", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.pr).toEqual(PR_GOAL); }); - it("lists no existing kit worktrees in a repo that has none", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect(preflight.existingWorktrees).toEqual([]); + it("declares the branch safe to force-delete once the PR is MERGED", async () => { + // Squash-merge rationale: after a squash the branch tip is NOT an ancestor of + // main, so git's own ancestry check calls a legitimately-merged branch + // unmerged. gh's MERGED verdict is the authoritative signal, not git. + const preflight = await finishPreflight(checkout); + expect(preflight.branchDeleteSafe).toBe(true); }); -}); - -describe("scope preflight — a repo that was never onboarded", () => { - let mainRoot: string; - beforeAll(() => { - mainRoot = makeMainCheckout({ config: false, dobby: false }); + it("reports a clean tree as no uncommitted changes", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.dirty).toEqual({ count: 0, files: [] }); }); - it("reports the dobby contract as absent (no config file, no local install)", async () => { - const preflight = await scopePreflight(mainRoot, "add-csv-export"); - expect({ - configPresent: preflight.configPresent, - dobbyInstalled: preflight.dobbyInstalled, - }).toEqual({ configPresent: false, dobbyInstalled: false }); + it("no longer answers the kit-made-worktree question at all", async () => { + const preflight = await finishPreflight(checkout); + const present = Object.keys(preflight).filter((key) => + REMOVED_FIELDS.includes(key) + ); + expect(present).toEqual([]); }); }); // =========================================================================== -// Slice 2 — collision: the guard that keeps a new goal from clobbering another's -// worktree. `collide-me` is a slug we ALREADY spent on a real worktree (branch -// `worktree-collide-me` + `.claude/worktrees/collide-me/`); `ghost` is a branch we -// created WITHOUT a worktree — the case only `git show-ref` can see. +// Slice 2 — the PR is still OPEN. Nothing is destroyed on an unmerged goal +// without the user saying so, and the reason has to name the PR the skill will +// offer to merge, so its confirm gate can show it. // =========================================================================== -describe("scope preflight — slug collision", () => { - let mainRoot: string; +describe("finish --preflight — the PR is still open", () => { + let checkout: string; beforeAll(() => { - mainRoot = makeMainCheckout({ config: true, dobby: true }); - addKitWorktree(mainRoot, "collide-me"); - gitIn(mainRoot, ["branch", "worktree-ghost"]); + checkout = makePlainCheckout("still-open"); }); - it("reports both the branch and the directory as taken for a slug already in use", async () => { - const preflight = await scopePreflight(mainRoot, "collide-me"); - expect(preflight.collision).toEqual({ - branchExists: true, - dirExists: true, - }); + it("requires confirmation rather than verdicting safe", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.verdict).toBe("confirm-required"); }); - it("reports a branch-only collision when the branch exists without a worktree directory", async () => { - const preflight = await scopePreflight(mainRoot, "ghost"); - expect(preflight.collision).toEqual({ - branchExists: true, - dirExists: false, - }); + it("refuses to call the branch safe to delete while the PR is OPEN", async () => { + const preflight = await finishPreflight(checkout); + expect({ + branchDeleteSafe: preflight.branchDeleteSafe, + state: preflight.pr?.state, + }).toEqual({ branchDeleteSafe: false, state: "OPEN" }); + }); + + it("passes the OPEN PR through, mergedAt and url alike", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.pr).toEqual(PR_STILL_OPEN); }); - it("suggests a collision-free variant of the colliding slug", async () => { - const preflight = await scopePreflight(mainRoot, "collide-me"); - const suggested = preflight.suggestedSlug; - // The only kit branch/dir in this fixture is `collide-me`, so "collision-free" - // is checkable outright: anything else is free, and its directory must not exist. - expect(suggested).not.toBe(null); - expect(suggested).not.toBe("collide-me"); - expect(suggested).toContain("collide-me"); - expect(existsSync(worktreePathFor(mainRoot, suggested ?? ""))).toBe(false); + it("states the open PR and its url as the reason", async () => { + const preflight = await finishPreflight(checkout); + const naming = preflight.reasons.filter( + (reason) => + PR_STATE_OPEN.test(reason) && reason.includes(PR_STILL_OPEN.url) + ); + expect(naming).toHaveLength(1); }); }); // =========================================================================== -// Slice 3 — nesting + parallelism. A session already inside a kit worktree cannot -// create another (the native tool can't nest), so scope must SEE that it is inside -// `.claude/worktrees/<x>/` and which goal owns it. Parallel worktrees are normal — -// they are reported as information, never as a refusal (exit stays 0). +// Slice 3 — merged, but the tree carries work the user has not committed. The +// merge and the tree are INDEPENDENT signals: this asks for confirmation +// (uncommitted work would be lost) while the branch stays safe to delete. // =========================================================================== -describe("scope preflight — run from inside another goal's worktree", () => { - let mainRoot: string; - let otherWorktree: string; +describe("finish --preflight — merged PR over a dirty tree", () => { + let checkout: string; beforeAll(() => { - mainRoot = makeMainCheckout({ config: true, dobby: true }); - otherWorktree = addKitWorktree(mainRoot, "other-goal"); + checkout = makePlainCheckout("dirty-tree"); + writeFileSync(join(checkout, "notes.txt"), "untracked work\n"); }); - it("detects that the session is already inside a kit worktree and names its goal", async () => { - const preflight = await scopePreflight(otherWorktree, "new-goal"); - expect(preflight.nested).toEqual({ - currentSlug: "other-goal", - insideWorktree: true, - worktreeRoot: otherWorktree, - }); + it("requires confirmation rather than verdicting safe", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.verdict).toBe("confirm-required"); }); - it("detects nesting from a subdirectory of the worktree, reporting the worktree ROOT", async () => { - const nested = join(otherWorktree, "src", "deep"); - mkdirSync(nested, { recursive: true }); - const preflight = await scopePreflight(nested, "new-goal"); - expect(preflight.nested).toEqual({ - currentSlug: "other-goal", - insideWorktree: true, - worktreeRoot: otherWorktree, - }); + it("counts the one uncommitted file", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.dirty.count).toBe(1); }); - it("reports the MAIN checkout root, not the worktree, from inside a worktree", async () => { - const preflight = await scopePreflight(otherWorktree, "new-goal"); - expect(preflight.mainRoot).toBe(mainRoot); + it("names the uncommitted file so the skill can show it", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.dirty.files.join("\n")).toContain("notes.txt"); }); - it("lists the other goal's worktree as information (parallel worktrees are normal)", async () => { - const preflight = await scopePreflight(mainRoot, "new-goal"); - expect(preflight.existingWorktrees).toHaveLength(1); - const [entry] = preflight.existingWorktrees; - expect({ branch: entry?.branch, slug: entry?.slug }).toEqual({ - branch: "worktree-other-goal", - slug: "other-goal", - }); - expect(entry?.path).toContain(join(".claude", "worktrees", "other-goal")); + it("states the uncommitted work as the reason", async () => { + const preflight = await finishPreflight(checkout); + const naming = preflight.reasons.filter((reason) => + UNCOMMITTED_WORK.test(reason) + ); + expect(naming.length).toBeGreaterThan(0); }); - it("never refuses over an existing parallel worktree (exit 0)", async () => { - const result = await run( - ["scope", "preflight", "--slug", "new-goal", "--json"], - mainRoot - ); - expect(result.exitCode).toBe(0); + it("still calls the branch safe to delete, the PR being MERGED", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.branchDeleteSafe).toBe(true); }); }); // =========================================================================== -// Slice 4 — `finish --preflight`, same-session, the clean happy path: the PR -// merged and the worktree has nothing uncommitted, so teardown is SAFE and the -// skill can proceed without a destructive-action prompt. +// Slice 4 — the session stands in a LINKED worktree the OPERATOR made with plain +// git, at a path of their own choosing and on a branch of their own naming. +// Nothing about it follows the kit's old `worktree-<slug>` / +// `.claude/worktrees/<slug>` convention, and it is still a full subject: finish +// recognises it, resolves both roots, and can verdict the close safe. // =========================================================================== -describe("finish --preflight — merged PR, clean worktree (same session)", () => { +describe("finish --preflight — inside a worktree plain git made", () => { let mainRoot: string; let worktree: string; beforeAll(() => { mainRoot = makeMainCheckout({ config: true, dobby: true }); - worktree = addKitWorktree(mainRoot, "ship-it", { dobby: true }); - }); - - it("verdicts the teardown safe", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.verdict).toBe("safe"); - }); - - it("carries no reasons on the safe path", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.reasons).toEqual([]); + worktree = addLinkedWorktree(mainRoot, "goal2"); }); - it("identifies the target from the current worktree as same-session", async () => { + it("reports the session as standing in a worktree", async () => { const preflight = await finishPreflight(worktree); - expect({ - branch: preflight.branch, - mode: preflight.mode, - slug: preflight.slug, - }).toEqual({ - branch: "worktree-ship-it", - mode: "same-session", - slug: "ship-it", - }); + expect(preflight.inWorktree).toBe(true); }); - it("resolves the target worktree path and the main checkout root", async () => { + it("resolves both the worktree it stands in and the main checkout it hangs off", async () => { const preflight = await finishPreflight(worktree); expect({ - mainRoot: preflight.mainRoot, - worktreePath: preflight.worktreePath.replace(TRAILING_SLASH, ""), + mainRoot: normalizePath(preflight.mainRoot), + worktreePath: normalizePath(preflight.worktreePath), }).toEqual({ mainRoot, worktreePath: worktree }); }); - it("removes a same-session worktree through the native ExitWorktree", async () => { + it("names the worktree's own branch, not the main checkout's", async () => { const preflight = await finishPreflight(worktree); - expect(preflight.removeMechanism).toBe("ExitWorktree"); + expect(preflight.branch).toBe("goal2"); }); - it("passes the PR state through for the goal's branch", async () => { + it("verdicts the close safe over a merged PR and a clean worktree", async () => { const preflight = await finishPreflight(worktree); - expect(preflight.pr).toEqual(PR_SHIP_IT); - }); - - it("declares the branch safe to force-delete once the PR is MERGED", async () => { - // Squash-merge rationale: after a squash the branch tip is NOT an ancestor of - // main, so git's own ancestry check calls a legitimately-merged branch - // unmerged. gh's MERGED verdict is the authoritative signal, not git. - const preflight = await finishPreflight(worktree); - expect(preflight.branchDeleteSafe).toBe(true); + expect(preflight.verdict).toBe("safe"); }); - it("reports a clean worktree as no uncommitted changes", async () => { + it("passes the PR state through for the worktree's branch", async () => { const preflight = await finishPreflight(worktree); - expect(preflight.dirty).toEqual({ count: 0, files: [] }); + expect(preflight.pr).toEqual(PR_GOAL2); }); }); // =========================================================================== -// Slice 5 — `finish --preflight` over work that is NOT safe to destroy: the PR is -// still OPEN and the worktree carries uncommitted changes (one modified tracked -// file + one untracked file). Both are things the user would lose, so the verdict -// must route the skill to its destructive-action confirmation. +// Slice 5 — `--slug` is GONE. The kit no longer names worktrees, so there is no +// slug to target one by: the flag is refused the way the CLI refuses any flag a +// command does not take, and nothing is answered on stdout. // =========================================================================== -describe("finish --preflight — open PR and a dirty worktree", () => { - let worktree: string; +describe("finish --preflight — the --slug target is removed", () => { + let checkout: string; beforeAll(() => { - const mainRoot = makeMainCheckout({ config: true, dobby: true }); - worktree = addKitWorktree(mainRoot, "wip-goal", { dobby: true }); - writeFileSync(join(worktree, "README"), "scratch — edited, uncommitted\n"); - writeFileSync(join(worktree, "notes.txt"), "untracked work\n"); + checkout = makePlainCheckout("goal"); }); - it("requires confirmation rather than verdicting safe", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.verdict).toBe("confirm-required"); + it("rejects --slug as an unknown flag", async () => { + const result = await run( + ["finish", "--preflight", "--slug", "x", "--json"], + checkout + ); + expect(result.stderr).toContain("unknown flag --slug"); }); - it("refuses to call the branch safe to delete while the PR is OPEN", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.branchDeleteSafe).toBe(false); + it("exits 1 when given --slug, the way it does for any unknown flag", async () => { + const result = await run( + ["finish", "--preflight", "--slug", "x", "--json"], + checkout + ); + expect(result.exitCode).toBe(1); }); - it("passes the OPEN PR state through", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.pr).toEqual(PR_WIP_GOAL); + it("answers no payload at all for a rejected --slug", async () => { + const result = await run( + ["finish", "--preflight", "--slug", "x", "--json"], + checkout + ); + expect(result.stdout).toBe(""); }); +}); - it("counts the uncommitted changes, tracked and untracked alike", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.dirty.count).toBe(2); +// =========================================================================== +// Slice 6 — dobby is not installed in the repo. `dobby down` is the mandatory +// pre-close teardown and there is no fallback, so this BLOCKS — even over a +// merged PR and a clean tree, which would otherwise be the safest possible case. +// It is the ONLY blocking condition left. +// =========================================================================== + +describe("finish --preflight — dobby not installed", () => { + let checkout: string; + + beforeAll(() => { + checkout = makePlainCheckout("no-dobby", { config: false, dobby: false }); }); - it("names the uncommitted files so the skill can show them", async () => { - const preflight = await finishPreflight(worktree); - const listed = preflight.dirty.files.join("\n"); - expect(listed).toContain("README"); - expect(listed).toContain("notes.txt"); + it("blocks even though the PR is merged and the tree is clean", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.verdict).toBe("blocked"); }); - it("states why confirmation is required", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.reasons.length).toBeGreaterThan(0); + it("reports dobby as not installed", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.dobbyInstalled).toBe(false); + }); + + it("names dobby in the reason it is blocked", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.reasons.join(" ").toLowerCase()).toContain("dobby"); + }); + + it("still reports where the session stands", async () => { + const preflight = await finishPreflight(checkout); + expect({ + branch: preflight.branch, + inWorktree: preflight.inWorktree, + }).toEqual({ branch: "no-dobby", inWorktree: false }); }); }); // =========================================================================== -// Slice 6 — no PR at all for the branch (our stub gh exits 1, exactly as gh does -// when it finds no pull request). An absent PR is never a merge signal. +// Slice 7 — no PR at all for the branch (our stub gh exits 1, exactly as gh does +// when it finds no pull request). An absent PR is never a merge signal, so the +// close is never safe — but it is not blocked either: the user may legitimately +// close a goal that never opened one. // =========================================================================== describe("finish --preflight — no PR for the branch", () => { - let worktree: string; + let checkout: string; beforeAll(() => { - const mainRoot = makeMainCheckout({ config: true, dobby: true }); - worktree = addKitWorktree(mainRoot, "no-pr", { dobby: true }); + checkout = makePlainCheckout("no-pr"); }); it("reports no PR rather than failing", async () => { - const preflight = await finishPreflight(worktree); + const preflight = await finishPreflight(checkout); expect(preflight.pr).toBe(null); }); it("requires confirmation when there is no PR to merge-check", async () => { - const preflight = await finishPreflight(worktree); + const preflight = await finishPreflight(checkout); expect(preflight.verdict).toBe("confirm-required"); }); it("refuses to call the branch safe to delete with no PR", async () => { - const preflight = await finishPreflight(worktree); + const preflight = await finishPreflight(checkout); expect(preflight.branchDeleteSafe).toBe(false); }); + + it("still names the branch it asked about", async () => { + const preflight = await finishPreflight(checkout); + expect(preflight.branch).toBe("no-pr"); + }); }); // =========================================================================== -// Slice 7 — dobby is not installed in the repo. `dobby down` is the mandatory -// pre-removal teardown and there is no fallback, so this BLOCKS — even over a -// merged PR and a clean tree, which would otherwise be the safest possible case. +// Slice 8 — TEXT mode. Without `--json` the same facts reach a human reader, and +// the two modes agree on the exit code for one and the same tree (a skill that +// reads the text must never see a different outcome than one that reads the +// JSON). The removed `candidates` list is absent from the prose too. // =========================================================================== -describe("finish --preflight — dobby not installed", () => { - let worktree: string; +describe("finish --preflight without --json", () => { + let safeCheckout: string; + let openCheckout: string; beforeAll(() => { - const mainRoot = makeMainCheckout({ config: false, dobby: false }); - worktree = addKitWorktree(mainRoot, "no-dobby"); - }); - - it("blocks even though the PR is merged and the worktree is clean", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.verdict).toBe("blocked"); + // `text-goal` still answers the MERGED stub (the `goal` pattern is a literal + // substring of it) while being a name no prose could print by accident. + safeCheckout = makePlainCheckout("text-goal"); + openCheckout = makePlainCheckout("still-open"); }); - it("reports dobby as not installed", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.dobbyInstalled).toBe(false); + it("exits 0 on the safe path", async () => { + const result = await run(["finish", "--preflight"], safeCheckout); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); }); - it("names dobby in the reason it is blocked", async () => { - const preflight = await finishPreflight(worktree); - expect(preflight.reasons.join(" ").toLowerCase()).toContain("dobby"); + it("states whether the session stands in a worktree", async () => { + const result = await run(["finish", "--preflight"], safeCheckout); + expect(result.stdout).toMatch(IN_WORKTREE_LABEL); }); -}); -// =========================================================================== -// Slice 8 — orphan mode: the session that created the worktree is gone and finish -// runs from the MAIN checkout against a named slug. Native ExitWorktree only -// removes worktrees the CURRENT session made, so teardown falls back to raw git — -// and the candidate list is what lets the skill confirm the target with the user. -// =========================================================================== - -describe("finish --preflight — from the main checkout (orphan mode)", () => { - let mainRoot: string; - let worktree: string; - - beforeAll(() => { - mainRoot = makeMainCheckout({ config: true, dobby: true }); - worktree = addKitWorktree(mainRoot, "orphan-goal", { dobby: true }); + it("names the branch the session stands on", async () => { + const result = await run(["finish", "--preflight"], safeCheckout); + expect(result.stdout).toContain("text-goal"); }); - it("identifies the mode as orphan when the session is not inside the worktree", async () => { - const preflight = await finishPreflight(mainRoot, "orphan-goal"); - expect(preflight.mode).toBe("orphan"); + it("names the verdict on stdout", async () => { + const result = await run(["finish", "--preflight"], openCheckout); + expect(result.stdout).toContain("confirm-required"); }); - it("falls back to raw git for removal in orphan mode", async () => { - const preflight = await finishPreflight(mainRoot, "orphan-goal"); - expect(preflight.removeMechanism).toBe("raw-git"); + it("offers no candidate list to choose from", async () => { + const result = await run(["finish", "--preflight"], openCheckout); + expect(result.stdout).not.toContain("candidates"); }); - it("resolves the named slug to its branch and worktree path", async () => { - const preflight = await finishPreflight(mainRoot, "orphan-goal"); + it("reports the same outcome as --json, exit code and verdict alike", async () => { + const text = await run(["finish", "--preflight"], openCheckout); + const json = await run(["finish", "--preflight", "--json"], openCheckout); + const payload = JSON.parse(json.stdout) as FinishPreflight; expect({ - branch: preflight.branch, - slug: preflight.slug, - worktreePath: preflight.worktreePath.replace(TRAILING_SLASH, ""), - }).toEqual({ - branch: "worktree-orphan-goal", - slug: "orphan-goal", - worktreePath: worktree, - }); + exitCode: text.exitCode, + namesTheVerdict: text.stdout.includes(payload.verdict), + }).toEqual({ exitCode: json.exitCode, namesTheVerdict: true }); }); +}); - it("lists the kit worktrees as candidates for the user to confirm", async () => { - const preflight = await finishPreflight(mainRoot, "orphan-goal"); - expect(preflight.candidates).toHaveLength(1); - const [candidate] = preflight.candidates; - expect({ branch: candidate?.branch, slug: candidate?.slug }).toEqual({ - branch: "worktree-orphan-goal", - slug: "orphan-goal", - }); - }); +// =========================================================================== +// Slice 9 — the finish preflight fails HARD outside a git repository (the +// action-command contract): there is no session to reason about, so it never +// answers with a degraded verdict a skill might act on. +// =========================================================================== - it("still passes the PR state through for the named branch", async () => { - const preflight = await finishPreflight(mainRoot, "orphan-goal"); - expect(preflight.pr).toEqual(PR_ORPHAN_GOAL); +describe("the finish preflight outside a git repository", () => { + it("fails finish --preflight with the git-repository error", async () => { + const result = await run( + ["finish", "--preflight", "--json"], + makeNonGitDir() + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("git repository"); }); }); // =========================================================================== -// Slice 8b — the two edges around WHICH goal is being torn down, both reached -// from the main checkout: -// - NO target at all (no `--slug`, and the session is not inside a worktree): -// nothing is resolvable, so the preflight refuses to guess and hands back the -// candidate list the skill can ask the user about. -// - A target whose worktree DIRECTORY is already gone (a leftover branch from a -// worktree removed by hand): the only blocker is a missing dobby, so this is -// NOT blocked — cleaning up the leftover is legitimate and there is no -// uncommitted work left to lose. +// Slice 10 — `dobby scope` is GONE. The worktree belongs to the operator: dobby +// never creates, names, enters or preflights one, so the command is deleted +// outright rather than emptied out. Every invocation of it — the full preflight +// form the scope skill used to send, and the bare command — is answered the way +// the CLI answers any command it does not have, and the help block no longer +// advertises it. +// +// Expected values are the CLI's own established literals: `unknown command: <x>` +// plus the `bun update @kvnwolf/dobby` second line (the exact pair the removed +// `capabilities` command answers with), and the usage block's one-command-per-line +// layout. The fixture is a real repo, so a refusal can never be the outside-a-repo +// hard error wearing the same exit code. // =========================================================================== -// The no-target payload, whose target fields are the ONLY ones the spec's shape -// allows to be null (a resolved target always names all three). -interface UnresolvedFinishPreflight { - branch: string | null; - candidates: WorktreeRef[]; - slug: string | null; - verdict: string; - worktreePath: string | null; -} +describe("scope preflight is removed", () => { + let mainRoot: string; -describe("finish --preflight — no target to resolve", () => { - let payload: UnresolvedFinishPreflight; + beforeAll(() => { + mainRoot = makeMainCheckout({ config: true, dobby: true }); + }); - beforeAll(async () => { - const mainRoot = makeMainCheckout({ config: true, dobby: true }); - addKitWorktree(mainRoot, "pick-me", { dobby: true }); - const result = await run(["finish", "--preflight", "--json"], mainRoot); - expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); - payload = JSON.parse(result.stdout) as UnresolvedFinishPreflight; + it("answers the full scope preflight invocation as an unknown command", async () => { + const result = await run( + ["scope", "preflight", "--slug", "x", "--json"], + mainRoot + ); + expect(result.stderr).toMatch(/unknown command/i); }); - it("blocks rather than guessing which goal to tear down", () => { - expect(payload.verdict).toBe("blocked"); + it("names scope as the unknown command rather than failing anonymously", async () => { + const result = await run( + ["scope", "preflight", "--slug", "x", "--json"], + mainRoot + ); + expect(result.stderr).toContain("unknown command: scope"); }); - it("leaves every target field empty", () => { - expect({ - branch: payload.branch, - slug: payload.slug, - worktreePath: payload.worktreePath, - }).toEqual({ branch: null, slug: null, worktreePath: null }); + it("exits nonzero on the full scope preflight invocation", async () => { + const result = await run( + ["scope", "preflight", "--slug", "x", "--json"], + mainRoot + ); + expect(result.exitCode).not.toBe(0); }); - it("hands back the kit worktrees for the skill to choose from", () => { - expect(payload.candidates.map((ref) => ref.slug)).toEqual(["pick-me"]); + it("prints nothing on stdout for the removed scope preflight", async () => { + const result = await run( + ["scope", "preflight", "--slug", "x", "--json"], + mainRoot + ); + expect(result.stdout).toBe(""); + }); + + it("answers the bare `scope` command as an unknown command", async () => { + const result = await run(["scope"], mainRoot); + expect(result.stderr).toContain("unknown command: scope"); + }); + + it("exits nonzero on the bare `scope` command", async () => { + const result = await run(["scope"], mainRoot); + expect(result.exitCode).not.toBe(0); + }); + + it("offers the upgrade hint, the way it does for any command it lacks", async () => { + const result = await run(["scope"], mainRoot); + expect(result.stderr).toContain( + "if this command is expected, run `bun update @kvnwolf/dobby`" + ); }); }); -describe("finish --preflight — the worktree directory is already gone", () => { - let preflight: FinishPreflight; +describe("the help block after scope is removed", () => { + let mainRoot: string; - beforeAll(async () => { - const mainRoot = makeMainCheckout({ config: true, dobby: true }); - const worktree = addKitWorktree(mainRoot, "left-over", { dobby: true }); - // Removed by hand, the way a user (or a crashed session) leaves a branch and - // a stale `git worktree` registration behind. - rmSync(worktree, { force: true, recursive: true }); - preflight = await finishPreflight(mainRoot, "left-over"); + beforeAll(() => { + mainRoot = makeMainCheckout({ config: true, dobby: true }); }); - it("does not block on the missing directory (dobby is the only blocker)", () => { - expect(preflight.verdict).toBe("confirm-required"); + // The CLI's help seam is the BARE invocation (usage on stdout, exit 0) — there + // is no `--help` flag, and this task adds none. + it("no longer advertises a scope command", async () => { + const result = await run([], mainRoot); + expect(result.stdout).not.toMatch(USAGE_SCOPE_LINE); }); - it("still finds dobby through the main checkout", () => { - expect(preflight.dobbyInstalled).toBe(true); + it("still advertises finish", async () => { + const result = await run([], mainRoot); + expect(result.stdout).toMatch(USAGE_FINISH_LINE); }); - it("reports nothing uncommitted, there being no worktree left", () => { - expect(preflight.dirty).toEqual({ count: 0, files: [] }); + it("still advertises migrate", async () => { + const result = await run([], mainRoot); + expect(result.stdout).toMatch(USAGE_MIGRATE_LINE); }); }); // =========================================================================== -// Slice 9 — both preflights fail HARD outside a git repository (the action-command -// contract): there is no worktree to reason about, so they never answer with a -// degraded verdict a skill might act on. +// Slice 11 — the two preflights that must keep ANSWERING through both of this +// goal's cuts (scope's removal and finish's rewrite). The pin here is +// deliberately shallow: each still answers its own JSON payload, keyed on the +// field that names its verdict. The deep contracts stay in the slices above and +// in `migrate.test.ts`. // =========================================================================== -describe("the preflights outside a git repository", () => { - it("fails scope preflight with the git-repository error", async () => { - const result = await run( - ["scope", "preflight", "--slug", "add-csv-export", "--json"], - makeNonGitDir() - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("git repository"); +describe("the surviving preflights still answer", () => { + let mainRoot: string; + + beforeAll(() => { + mainRoot = makeMainCheckout({ config: true, dobby: true }); }); - it("fails finish --preflight with the git-repository error", async () => { - const result = await run( - ["finish", "--preflight", "--json"], - makeNonGitDir() - ); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("git repository"); + it("still answers a finish --preflight verdict payload", async () => { + const result = await run(["finish", "--preflight", "--json"], mainRoot); + expect([0, 1]).toContain(result.exitCode); + const payload = JSON.parse(result.stdout) as Record<string, unknown>; + expect(Object.hasOwn(payload, "verdict")).toBe(true); + }); + + it("still answers a migrate preflight verdict payload", async () => { + const result = await run(["migrate", "preflight", "--json"], mainRoot); + expect([0, 1]).toContain(result.exitCode); + const payload = JSON.parse(result.stdout) as Record<string, unknown>; + expect(Object.hasOwn(payload, "verdict")).toBe(true); }); }); diff --git a/cli/src/preflight.ts b/cli/src/preflight.ts index f50c7e1..b93758d 100644 --- a/cli/src/preflight.ts +++ b/cli/src/preflight.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { join } from "node:path"; import { type CheckGroup, type CheckNote, check } from "./check.ts"; import type { CommandContext, @@ -23,19 +23,14 @@ import { // gate stays in the SKILL, so a preflight never creates, enters, or removes // anything itself (it only computes the predicate the skill gates on). // -// - `scope preflight --slug <slug>` — what the goal WOULD take (branch -// `worktree-<slug>`, path `.claude/worktrees/<slug>/`), whether either is -// already taken (and a collision-free variant when so), whether this session -// is already INSIDE a kit worktree (the native EnterWorktree cannot nest), -// and whether the repo carries the dobby contract. Parallel worktrees are -// INFORMATIONAL — `existingWorktrees` is never a refusal signal, and entering -// the worktree stays native (EnterWorktree). -// - `finish --preflight [--slug <slug>]` — the teardown verdict for ONE goal: -// `safe` (MERGED PR + clean tree), `blocked` (dobby is not installed, so the -// mandatory `dobby down` cannot run — it outranks every other signal), else -// `confirm-required`. It also reports WHICH removal mechanism applies -// (`ExitWorktree` same-session, raw git for an orphan) and whether the branch -// is safe to force-delete. Both stay native/manual in the skill. +// - `finish --preflight` — the teardown verdict for the goal the session +// CURRENTLY stands on: `safe` (MERGED PR + clean tree), `blocked` (dobby is +// not installed, so the mandatory `dobby down` cannot run — it outranks +// every other signal), else `confirm-required`. It also reports whether the +// session stands inside a linked worktree (`inWorktree`, whoever made it) +// and whether the branch is safe to force-delete. The worktree belongs to +// the OPERATOR — this preflight never creates, names, or targets one by +// slug; it only reports where the session already stands. // - `migrate preflight|verify` — whether a repo still needs the config // migration (naming each legacy signal and snapshotting the facts the // migration must carry across), and whether a migrated repo is healthy (the @@ -44,33 +39,14 @@ import { // EVERY preflight here is an ACTION command: it fails HARD outside a git // repository (`requireWorkroot`) rather than answering with a degraded verdict a // skill might act on. Every child process goes through `runner.ts` with its cwd -// pinned to the workroot; `git -C <path>` targets another worktree WITHOUT ever -// unpinning the spawn. +// pinned to the workroot. // // node:*-only (ADR-0008) — vitest imports this under Node, Bun runs it in prod. -// The kit's literal naming, shared by both preflights: branch `worktree-<slug>` -// at `<mainRoot>/.claude/worktrees/<slug>/`. Stated verbatim in scope/SKILL.md -// and finish/SKILL.md — the ONE place this CLI spells it. -const BRANCH_PREFIX = "worktree-"; -const WORKTREES_SEGMENTS = [".claude", "worktrees"] as const; - -// How far `suggestedSlug` probes for a free variant (`<slug>-2`, `-3`, …) before -// giving up. A repo with 98 colliding variants of one slug is not a real case. -const MAX_SLUG_SUFFIX = 99; - // The two `git status --porcelain` status columns plus their trailing space: the // path starts at index 3 of every line. const PORCELAIN_PATH_OFFSET = 3; -// One kit worktree as both preflights report it (scope's `existingWorktrees`, -// finish's `candidates`): the goal slug, its branch, and its absolute path. -interface WorktreeRef { - branch: string; - path: string; - slug: string; -} - // The `gh pr view` answer, projected to the three fields the skills read. Passed // THROUGH unchanged — the preflight never re-derives a merge verdict from git. interface PullRequest { @@ -88,93 +64,22 @@ interface DirtyTree { type Verdict = "blocked" | "confirm-required" | "safe"; -interface ScopePreflight { - branch: string; - collision: { branchExists: boolean; dirExists: boolean }; - configPresent: boolean; - dobbyInstalled: boolean; - existingWorktrees: WorktreeRef[]; - mainRoot: string; - nested: { - currentSlug: string | null; - insideWorktree: boolean; - worktreeRoot: string | null; - }; - path: string; - slug: string; - suggestedSlug: string | null; -} - interface FinishPreflight { - // Null ONLY when no target could be resolved (run from the main checkout with - // no `--slug`): the payload then carries the candidates and blocks. - branch: string | null; + branch: string; branchDeleteSafe: boolean; - candidates: WorktreeRef[]; dirty: DirtyTree; dobbyInstalled: boolean; + // True iff the session's workroot is a LINKED worktree (git's own definition, + // via `linkedWorktreeMain`) — whoever made it: `claude --worktree`, an IDE, a + // bare `git worktree add`. The kit no longer makes or names worktrees itself. + inWorktree: boolean; mainRoot: string; - mode: "orphan" | "same-session"; pr: PullRequest | null; reasons: string[]; - removeMechanism: "ExitWorktree" | "raw-git"; - slug: string | null; verdict: Verdict; worktreePath: string | null; } -// Where the invocation is standing, resolved ONCE per preflight: -// - `workroot` — the git top-level of the cwd (every spawn is pinned here). -// - `mainRoot` — the MAIN checkout: the workroot itself, or (in a linked -// worktree) the parent of the common `.git` dir via `linkedWorktreeMain`. -// - `current` — the KIT worktree the cwd sits in, or null. Kit-ness is -// positional: a linked worktree whose parent directory is exactly -// `<mainRoot>/.claude/worktrees`. A linked worktree somewhere else is a -// worktree the kit did not make, so it is not "inside a goal". -interface Location { - current: { root: string; slug: string } | null; - mainRoot: string; - workroot: string; -} - -// Resolve the location, or THROW (requireWorkroot) outside a git repository — -// the action-command contract both preflights open with. -function resolveLocation(cwd: string): Location { - const workroot = requireWorkroot(cwd); - const main = linkedWorktreeMain(workroot); - const mainRoot = main ?? workroot; - // `workroot` is the git TOP-LEVEL, so a cwd deep inside a worktree still - // resolves to that worktree's ROOT — which is what `nested.worktreeRoot` is. - const inKitDir = - main !== null && dirname(workroot) === worktreesDir(mainRoot); - return { - current: inKitDir ? { root: workroot, slug: basename(workroot) } : null, - mainRoot, - workroot, - }; -} - -// The directory the kit's worktrees live in: `<mainRoot>/.claude/worktrees`. -function worktreesDir(mainRoot: string): string { - return join(mainRoot, ...WORKTREES_SEGMENTS); -} - -// Where the goal's worktree lives on disk (absolute). -function worktreeDir(mainRoot: string, slug: string): string { - return join(worktreesDir(mainRoot), slug); -} - -// The branch a goal takes. -function branchFor(slug: string): string { - return `${BRANCH_PREFIX}${slug}`; -} - -// A string option's value, or null when absent (a bare boolean flag counts as -// absent — `--slug` without a value cannot name a goal). -function stringOption(value: boolean | string | undefined): string | null { - return typeof value === "string" && value !== "" ? value : null; -} - // The hard-error result for a thrown precondition (outside a git repo). function hardError(error: unknown): CommandResult { return { @@ -202,159 +107,30 @@ function dobbyInstalledAt(root: string): boolean { return existsSync(join(root, "node_modules", ".bin", "dobby")); } -// Whether the branch exists, via `git show-ref --verify --quiet -// refs/heads/<branch>` (scope/SKILL.md's own check): exit 0 = exists. Refs are -// shared across a repo's worktrees, so the workroot-pinned spawn sees them all — -// including a branch with NO worktree directory, which nothing on disk reveals. -function branchExists(location: Location, branch: string): boolean { +// The branch HEAD is on, or "HEAD" when it is DETACHED — pointing straight at a +// commit, with no branch to move. `git symbolic-ref --quiet HEAD` asks exactly +// that question: it exits nonzero precisely when HEAD is not a symbolic ref — +// unlike `rev-parse --abbrev-ref HEAD` (answers the literal string "HEAD") or +// `branch --show-current` (answers an empty line), which both answer a detached +// HEAD with something branch-shaped. +function currentBranch(root: string): string { const result = runCapture( "git", - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], - { root: location.workroot } + ["symbolic-ref", "--quiet", "--short", "HEAD"], + { root } ); - return !result.error && result.status === 0; -} - -// The KIT worktrees this repo currently has, from `git worktree list -// --porcelain`. Filtered POSITIONALLY to direct children of -// `<mainRoot>/.claude/worktrees` — which drops the main checkout (git lists it -// too) and any worktree made outside the kit's layout. Tolerant: a failed git -// call yields an empty list (the field is informational in both payloads). -function listKitWorktrees(location: Location): WorktreeRef[] { - const result = runCapture("git", ["worktree", "list", "--porcelain"], { - root: location.workroot, - }); - if (result.error || result.status !== 0) { - return []; - } - - const kitDir = worktreesDir(location.mainRoot); - const refs: WorktreeRef[] = []; - let path: string | null = null; - let branch: string | null = null; - // Each record is `worktree <path>` + optional `HEAD`/`branch` lines; a new - // `worktree` line (or the end of the output) closes the previous record. - const flush = (): void => { - if (path !== null && dirname(path) === kitDir) { - const slug = basename(path); - // A DETACHED kit worktree has no `branch` line — fall back to the branch - // the slug implies rather than dropping the worktree from the list. - refs.push({ branch: branch ?? branchFor(slug), path, slug }); - } - path = null; - branch = null; - }; - - for (const line of result.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - flush(); - path = line.slice("worktree ".length).trim(); - } else if (line.startsWith("branch refs/heads/")) { - branch = line.slice("branch refs/heads/".length).trim(); - } - } - flush(); - return refs; -} - -// --------------------------------------------------------------------------- -// `dobby scope preflight --slug <slug>` -// --------------------------------------------------------------------------- - -export function runScopePreflight(context: CommandContext): CommandResult { - let location: Location; - try { - location = resolveLocation(context.cwd); - } catch (error) { - return hardError(error); - } - - // The git precondition comes FIRST (the action-command contract), the flag - // check second — outside a repo there is nothing to preflight for any slug. - const slug = stringOption(context.options.slug); - if (slug === null) { - return { - error: - "scope preflight requires --slug <slug> — the kebab-case goal slug the branch (worktree-<slug>) and worktree directory are named after", - exitCode: 1, - }; - } - - const collision = { - branchExists: branchExists(location, branchFor(slug)), - dirExists: existsSync(worktreeDir(location.mainRoot, slug)), - }; - const collides = collision.branchExists || collision.dirExists; - - const payload: ScopePreflight = { - branch: branchFor(slug), - collision, - // The contract signals are read at the MAIN checkout: that is the repo the - // new worktree is cut FROM, and the root `/dobby:onboard` establishes. - configPresent: existsSync(join(location.mainRoot, "dobby.config.json")), - dobbyInstalled: dobbyInstalledAt(location.mainRoot), - // INFORMATIONAL — parallel goals in parallel worktrees are normal and this - // list is never a refusal signal (scope only blocks on NESTING). - existingWorktrees: listKitWorktrees(location), - mainRoot: location.mainRoot, - nested: { - currentSlug: location.current?.slug ?? null, - insideWorktree: location.current !== null, - worktreeRoot: location.current?.root ?? null, - }, - // The spec's literal display form: relative to `mainRoot`, trailing slash. - path: `.claude/worktrees/${slug}/`, - slug, - suggestedSlug: collides ? suggestSlug(location, slug) : null, - }; - - return rendered(payload, context.options, formatScopeText); -} - -// The first collision-free variant of `slug` (`<slug>-2`, `<slug>-3`, …): free -// means NEITHER the branch nor the directory exists. Keeps the original slug as -// a prefix so the suggestion still reads as the same goal. Null in the absurd -// case where every probed variant is taken. -function suggestSlug(location: Location, slug: string): string | null { - for (let suffix = 2; suffix <= MAX_SLUG_SUFFIX; suffix += 1) { - const candidate = `${slug}-${suffix}`; - const taken = - branchExists(location, branchFor(candidate)) || - existsSync(worktreeDir(location.mainRoot, candidate)); - if (!taken) { - return candidate; - } - } - return null; -} - -function formatScopeText(payload: ScopePreflight): string { - const nested = payload.nested.insideWorktree - ? `inside ${payload.nested.currentSlug} (${payload.nested.worktreeRoot})` - : "no"; - const slugs = payload.existingWorktrees.map((ref) => ref.slug); - return `${[ - `slug: ${payload.slug}`, - `branch: ${payload.branch}`, - `path: ${payload.path}`, - `mainRoot: ${payload.mainRoot}`, - `collision: branch=${payload.collision.branchExists} dir=${payload.collision.dirExists}`, - `suggestedSlug: ${payload.suggestedSlug ?? "-"}`, - `nested: ${nested}`, - `configPresent: ${payload.configPresent}`, - `dobbyInstalled: ${payload.dobbyInstalled}`, - `existingWorktrees: ${slugs.length === 0 ? "-" : slugs.join(", ")}`, - ].join("\n")}\n`; + const name = result.stdout.trim(); + return result.status === 0 && name !== "" && name !== "HEAD" ? name : "HEAD"; } // --------------------------------------------------------------------------- -// `dobby finish --preflight [--slug <slug>]` +// `dobby finish --preflight` // --------------------------------------------------------------------------- export function runFinishPreflight(context: CommandContext): CommandResult { - let location: Location; + let workroot: string; try { - location = resolveLocation(context.cwd); + workroot = requireWorkroot(context.cwd); } catch (error) { return hardError(error); } @@ -362,29 +138,24 @@ export function runFinishPreflight(context: CommandContext): CommandResult { if (context.options.preflight !== true) { return { error: - "finish takes --preflight — the CLI computes the teardown verdict; the removal itself stays in the skill (native ExitWorktree, or raw git for an orphan)", + "finish takes --preflight — the CLI computes the teardown verdict; the removal itself stays in the skill (native ExitWorktree when the session entered the worktree, or raw git otherwise)", exitCode: 1, }; } - const candidates = listKitWorktrees(location); - const target = resolveTarget(location, stringOption(context.options.slug)); - if (target === null) { - return rendered( - unresolvedTarget(location, candidates), - context.options, - formatFinishText - ); - } + // A LINKED worktree (git's own definition — its git dir differs from the + // common git dir) puts the session INSIDE a goal; `mainRoot` is the checkout + // it hangs off. A plain checkout is its own main root, with nothing to tear + // down but the branch. + const linkedMain = linkedWorktreeMain(workroot); + const inWorktree = linkedMain !== null; + const mainRoot = linkedMain ?? workroot; + const worktreePath = inWorktree ? workroot : null; - const branch = branchFor(target.slug); - const worktreePath = worktreeDir(location.mainRoot, target.slug); - const pr = readPullRequest(location, branch); - const dirty = readDirtyTree(location, worktreePath); - // `dobby down` runs INSIDE the target worktree, where module resolution walks - // up: the worktree sits under the main checkout, so main's install serves it. - const dobbyInstalled = - dobbyInstalledAt(worktreePath) || dobbyInstalledAt(location.mainRoot); + const branch = currentBranch(workroot); + const pr = readPullRequest(workroot, branch); + const dirty = readDirtyTree(workroot); + const dobbyInstalled = dobbyInstalledAt(mainRoot); const { reasons, verdict } = judgeTeardown({ branch, @@ -401,19 +172,12 @@ export function runFinishPreflight(context: CommandContext): CommandResult { // authoritative signal — which is why this is derived from `pr.state` and // never from git, and why the skill force-deletes (`-D`) once it holds. branchDeleteSafe: pr?.state === "MERGED", - candidates, dirty, dobbyInstalled, - mainRoot: location.mainRoot, - mode: target.mode, + inWorktree, + mainRoot, pr, reasons, - // Native ExitWorktree removes ONLY worktrees the CURRENT session created (and - // restores the cwd afterwards); an orphan must be torn down with raw git from - // the main checkout instead. - removeMechanism: - target.mode === "same-session" ? "ExitWorktree" : "raw-git", - slug: target.slug, verdict, worktreePath, }; @@ -421,56 +185,6 @@ export function runFinishPreflight(context: CommandContext): CommandResult { return rendered(payload, context.options, formatFinishText); } -// Which goal this finish targets, and in which mode: -// - SAME-SESSION — the cwd is inside a kit worktree and `--slug` either agrees -// or was omitted. This is the session that owns the worktree, so the native -// ExitWorktree can remove it. -// - ORPHAN — a `--slug` naming some OTHER goal (the session that created it is -// gone, or this session sits elsewhere). Teardown falls back to raw git. -// Null when neither applies (the main checkout with no `--slug`): nothing is -// resolvable, so the payload blocks and hands back the candidate list. -function resolveTarget( - location: Location, - requested: string | null -): { mode: FinishPreflight["mode"]; slug: string } | null { - const { current } = location; - if (current !== null && (requested === null || requested === current.slug)) { - return { mode: "same-session", slug: current.slug }; - } - if (requested !== null) { - return { mode: "orphan", slug: requested }; - } - return null; -} - -// The payload for "no target": every fact that needs a target is null/empty, the -// verdict blocks, and `candidates` carries what the skill can offer the user. -// This is the ONE verdict outside `judgeTeardown`'s three rules — not a teardown -// judgement at all, but the answer to "which goal?": there is nothing to compute -// until the skill picks a target, so the payload refuses instead of guessing. -function unresolvedTarget( - location: Location, - candidates: WorktreeRef[] -): FinishPreflight { - return { - branch: null, - branchDeleteSafe: false, - candidates, - dirty: { count: 0, files: [] }, - dobbyInstalled: dobbyInstalledAt(location.mainRoot), - mainRoot: location.mainRoot, - mode: "orphan", - pr: null, - reasons: [ - "no target worktree — run finish from inside the goal's worktree, or name it with --slug <slug> (see `candidates`)", - ], - removeMechanism: "raw-git", - slug: null, - verdict: "blocked", - worktreePath: null, - }; -} - // The verdict + the reasons behind it — exactly the three spec rules, nothing // widened: // - BLOCKED on ONE thing only: dobby is not installed, so the mandatory @@ -479,9 +193,6 @@ function unresolvedTarget( // - SAFE is the ONLY reason-less outcome: a MERGED PR and a clean tree. // - CONFIRM-REQUIRED otherwise, each reason naming exactly what the skill must // show at its destructive-action gate. -// A worktree DIRECTORY that is already gone is deliberately NOT a blocker: the -// leftover branch (and the stale `git worktree` registration) is still a -// legitimate thing to clean up, and there is nothing left to lose. function judgeTeardown(input: { branch: string; dirty: DirtyTree; @@ -523,14 +234,11 @@ function judgeTeardown(input: { // no repo remote, or gh's "no pull requests found" exit 1 — because an ABSENT PR // must read as "unknown", never as a merge signal. The three fields are passed // through as gh reported them; nothing here re-derives merge state from git. -function readPullRequest( - location: Location, - branch: string -): PullRequest | null { +function readPullRequest(root: string, branch: string): PullRequest | null { const result = runCapture( "gh", ["pr", "view", branch, "--json", "state,mergedAt,url"], - { root: location.workroot } + { root } ); if (result.error || result.status !== 0) { return null; @@ -555,21 +263,12 @@ function readPullRequest( } } -// The target worktree's uncommitted work: bare `git status --porcelain` (the -// finish skill's literal), so UNTRACKED files count too — losing those is exactly -// what the destructive gate protects against. The spawn stays pinned to the -// workroot; `-C <path>` is what points git at the (possibly foreign) worktree. -// Tolerant: a missing directory or a failed git call reads as clean — a worktree -// that is no longer on disk has no uncommitted work left to lose. -function readDirtyTree(location: Location, worktreePath: string): DirtyTree { - if (!existsSync(worktreePath)) { - return { count: 0, files: [] }; - } - const result = runCapture( - "git", - ["-C", worktreePath, "status", "--porcelain"], - { root: location.workroot } - ); +// The session's uncommitted work: bare `git status --porcelain` at the workroot +// itself (the finish skill's literal), so UNTRACKED files count too — losing +// those is exactly what the destructive gate protects against. Tolerant: a +// failed git call reads as clean. +function readDirtyTree(root: string): DirtyTree { + const result = runCapture("git", ["status", "--porcelain"], { root }); if (result.error || result.status !== 0) { return { count: 0, files: [] }; } @@ -595,17 +294,14 @@ function formatFinishText(payload: FinishPreflight): string { : `${payload.pr.state} (${payload.pr.url}${payload.pr.mergedAt === null ? "" : `, merged ${payload.pr.mergedAt}`})`; const lines = [ `verdict: ${payload.verdict}`, - `mode: ${payload.mode}`, - `slug: ${payload.slug ?? "-"}`, - `branch: ${payload.branch ?? "-"}`, + `inWorktree: ${payload.inWorktree}`, + `branch: ${payload.branch}`, `worktreePath: ${payload.worktreePath ?? "-"}`, `mainRoot: ${payload.mainRoot}`, `pr: ${pr}`, `dirty: ${payload.dirty.count}${payload.dirty.count === 0 ? "" : ` (${payload.dirty.files.join(", ")})`}`, `dobbyInstalled: ${payload.dobbyInstalled}`, - `removeMechanism: ${payload.removeMechanism}`, `branchDeleteSafe:${payload.branchDeleteSafe}`, - `candidates: ${payload.candidates.length === 0 ? "-" : payload.candidates.map((ref) => ref.slug).join(", ")}`, ]; for (const reason of payload.reasons) { lines.push(` - ${reason}`); diff --git a/cli/src/registry.test.ts b/cli/src/registry.test.ts index 9e89c6e..cb2f5f7 100644 --- a/cli/src/registry.test.ts +++ b/cli/src/registry.test.ts @@ -17,9 +17,10 @@ import { run } from "./run.ts"; // // Where every expected value comes from (all INDEPENDENT of the code): // - The command list (ship, release, state, build-plan, repro, review, pr, -// tracker, claim, goal, kb, adr, finish, scope, migrate, and the artifact-lint +// tracker, claim, goal, kb, adr, finish, migrate, and the artifact-lint // family) and the option list (23 string options + 5 booleans) are enumerated -// by the spec verbatim. +// by the spec verbatim. `scope` is NOT among them: the worktree belongs to the +// operator, so dobby has no scope command to advertise or dispatch. // - `Usage: dobby` and the `Commands:` header are the CLI's established, already // contracted help literals. // - "an out-of-place flag is an error naming the flag and the command, plus the @@ -346,12 +347,13 @@ const dispatchedCommands: { // registry entries (flag allowlist, subcommand token validation, usage listing) // are still covered by the slices around this one, all of which reject before // any handler runs. - // `finish --preflight`, `scope preflight` and `migrate preflight|verify` are - // IMPLEMENTED (see `preflight.test.ts` and `migrate.test.ts`, which own their - // contracts) — they are no longer stubs, so their rows are gone from this - // table. Their registry entries (flag allowlist, subcommand token validation, - // usage listing) stay covered by the slices around this one, all of which - // reject before any handler runs. + // `finish --preflight` and `migrate preflight|verify` are IMPLEMENTED (see + // `preflight.test.ts` and `migrate.test.ts`, which own their contracts) — they + // are no longer stubs, so their rows are gone from this table. Their registry + // entries (flag allowlist, subcommand token validation, usage listing) stay + // covered by the slices around this one, all of which reject before any handler + // runs. `scope preflight` is DELETED outright — `preflight.test.ts` owns that + // contract too (the invocation is now an ordinary unknown command). // `spec lint` and `map lint` both DEFAULT to a document inside the workroot // (the live `STATE.md`, the newest `docs/maps/*.md`) — this repo's own, from an // in-repo fixture — so each is handed a target that does not exist instead: the @@ -594,7 +596,6 @@ const advertisedCommands = [ "kb", "adr", "finish", - "scope", "migrate", "spec", "map", diff --git a/cli/src/run.ts b/cli/src/run.ts index bf67f9b..303416a 100644 --- a/cli/src/run.ts +++ b/cli/src/run.ts @@ -34,11 +34,7 @@ import { type UpPlan, type UpReport, } from "./lifecycle.ts"; -import { - runFinishPreflight, - runMigrate, - runScopePreflight, -} from "./preflight.ts"; +import { runFinishPreflight, runMigrate } from "./preflight.ts"; import { runRelease } from "./release.ts"; import { runRepro } from "./repro.ts"; import { runPr, runReview } from "./review.ts"; @@ -217,7 +213,7 @@ const COMMANDS: Readonly<Record<string, CommandEntry>> = { down: { flags: ["dry-run", "json"] }, env: { flags: JSON_FLAG }, finish: { - flags: ["json", "preflight", "slug"], + flags: ["json", "preflight"], handler: runFinishPreflight, }, goal: { @@ -282,11 +278,6 @@ const COMMANDS: Readonly<Record<string, CommandEntry>> = { handler: runReview, subcommands: { apply: ["plan", "stdin", "dry-run"], fetch: [] }, }, - scope: { - flags: JSON_FLAG, - handler: runScopePreflight, - subcommands: { preflight: ["slug", "goal", "source"] }, - }, ship: { flags: ["json", "message-file", "pr-body-file"], handler: runShip, @@ -307,8 +298,7 @@ const COMMANDS: Readonly<Record<string, CommandEntry>> = { subcommands: { "append-worklog": ["file", "stdin", "task"], // `--goal` / `--source` fill the Goal and Source bodies of the skeleton - // `init` writes (the surface `/dobby:scope` calls); both options already - // exist globally (allowlisted for `scope preflight`). + // `init` writes (the surface `/dobby:scope` calls). init: ["goal", "source"], // No `--file` (unlike the artifact-lint commands): `state lint` judges the // ONE document the engine owns, `<workroot>/STATE.md`, so a target flag diff --git a/cli/src/tasks.ts b/cli/src/tasks.ts index be3f008..fc94a06 100644 --- a/cli/src/tasks.ts +++ b/cli/src/tasks.ts @@ -538,16 +538,12 @@ export interface UsageCommand { name: string; } -// The SESSION commands — the mechanized kit surface, in workflow order (scope → -// state → build-plan → ship → review/pr → finish), then the tracker/KB/ADR +// The SESSION commands — the mechanized kit surface, in workflow order (state → +// build-plan → ship → review/pr → finish), then the tracker/KB/ADR // helpers, then the diagnosis + migration commands, then the artifact linters. // Each description names the command's subcommands inline (`init|set|…`) so the // name column stays one token wide and the help keeps its narrow layout. const SESSION_COMMANDS: readonly UsageCommand[] = [ - { - description: "Preflight a new goal worktree: preflight (--slug)", - name: "scope", - }, { description: "Edit STATE.md sections: init|set|append-worklog|lint", name: "state", diff --git a/docs/adr/0033-the-worktree-belongs-to-the-operator.md b/docs/adr/0033-the-worktree-belongs-to-the-operator.md new file mode 100644 index 0000000..cb74ccb --- /dev/null +++ b/docs/adr/0033-the-worktree-belongs-to-the-operator.md @@ -0,0 +1,15 @@ +# 0033. The worktree belongs to the operator + +`/dobby:scope` used to create and enter the per-goal worktree itself (native `EnterWorktree`, branch `worktree-<slug>` under `.claude/worktrees/`), gated by a `scope preflight` that computed collision, nesting and slug-suggestion facts before anything ran. That ceremony duplicated work every host already does — `claude --worktree`, Claude Desktop, t3 code, an IDE, or a plain `git worktree add` — and it forbade running the kit's lifecycle at all without a worktree, even on a plain checkout with one goal per branch. `/dobby:scope` now grounds the goal wherever the session already stands, writes `STATE.md` there, and brings it up with `bunx dobby up`. `/dobby:finish` merges behind its gate, runs `bunx dobby down`, and: when the session stands in a linked worktree (whoever made it) offers to remove that worktree and its branch — trying native `ExitWorktree` first, then raw git from the main root — and on a plain checkout returns to `main` and deletes the goal's branch. `git pull` always runs. The slug everywhere is `basename(workroot)`. + +## Considered options + +**Keep creating the worktree behind an opt-out flag.** Preserve the default and let a session that already stands in a worktree skip creation. Rejected: it leaves two paths to maintain, and the default still assumed a worktree existed to create — a plain checkout with no worktree at all was still unsupported without threading the flag through every call site. + +**Detect-and-adopt, worktree creation dropped entirely (chosen).** `scope` stops creating anything; it reads where the session stands and proceeds. `finish` detects a linked worktree and offers to remove it, or detects a plain checkout and returns to `main`. This is the smallest surface that covers every host's own worktree convention without dobby inventing one of its own. + +**Never touch worktrees at all, even in `finish`.** Leave removal entirely to the operator. Rejected: a merged goal's worktree left behind is the single most common piece of leftover state, and the session already knows — from `git rev-parse --git-dir` vs `--git-common-dir` — that it is standing in one; refusing to offer cleanup it can see is a worse default than offering it behind a confirm gate. + +## Consequences + +The goal slug is `basename(workroot)` everywhere, not a kit-assigned name — a plain checkout without worktrees has exactly one active goal at a time, which is the natural corollary of dropping the nesting guard. `finish` tries `ExitWorktree` then falls back to raw git from the main root for an orphaned worktree the current session didn't create. `scope preflight` and the nesting/collision protection it computed are gone outright — the host (or the operator, on a plain checkout) now owns that judgment, not dobby. `up`'s setup phase is unchanged: `.worktreeinclude` re-materialization still runs, and still applies only to a linked worktree, whoever created it. diff --git a/plugin/CONTEXT.md b/plugin/CONTEXT.md index 9879864..e4c3108 100644 --- a/plugin/CONTEXT.md +++ b/plugin/CONTEXT.md @@ -58,7 +58,7 @@ CLI at runtime (`bunx dobby …`), never imported. directly for every task. - **Hooks** need no invocation — `hooks/hooks.json` is auto-loaded whenever the plugin is enabled, and fires on every `Edit`/`Write` PostToolUse event. -- Everything else (worktree lifecycle, `dobby check`/`up`/`down`/`dev`, `dobby env` +- Everything else (run lifecycle, `dobby check`/`up`/`down`/`dev`, `dobby env` for the environment snapshot, `dobby instructions <topic>` for the per-topic instruction catalogue a skill or agent carries out itself) is reached through `bunx dobby …`, never imported code. The two-step bring-up — `up`, carry out diff --git a/plugin/skills/finish/SKILL.md b/plugin/skills/finish/SKILL.md index eb78c37..76515c5 100644 --- a/plugin/skills/finish/SKILL.md +++ b/plugin/skills/finish/SKILL.md @@ -1,25 +1,23 @@ --- name: finish -description: Closes the goal end-to-end — merges the goal's PR when it is still open (gated, on your explicit selection), then tears its worktree down. Use when the current goal's PR is merged OR merge-ready and you want to clean up and return to main. +description: Closes the goal end-to-end — merges the goal's PR when it is still open (gated, on your explicit selection), then tears the run down. Use when the current goal's PR is merged OR merge-ready and you want to clean up and return to main. --- -The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/dobby:finish` offers to **merge it first** — always as an explicit selection at the gate in Step 1, never automatically. Once it is merged, tear down its worktree: run `bunx dobby down --json` to kill the run and run the project's cleanup, carry out any `stop` instruction it hands back (closing the now-empty kit cmux panes), then delete the branch and pull the main checkout up to date — closing the goal so the tree is ready for the next one. +The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/dobby:finish` offers to **merge it first** — always as an explicit selection at the gate in Step 1, never automatically. Once it is merged, tear down the run: run `bunx dobby down --json` to kill it and run the project's cleanup, carry out any `stop` instruction it hands back (closing the now-empty kit cmux panes). Then, **only when the session stands inside a linked worktree** — whoever made it: `claude --worktree`, an IDE, a bare `git worktree add` — offer to remove that worktree and its branch. On a plain checkout there is no worktree to remove: return to `main`, delete the goal's branch, and pull — closing the goal so the tree is ready for the next one. -**One session per goal.** Each goal gets its own worktree; parallel goals run in parallel worktrees (one per cmux pane/session — legitimate and encouraged). `/dobby:finish` tears down THIS goal's worktree once its PR is merged — it does not touch other goals' worktrees. Run it (typed, manually) when the PR is merged, or when it is merge-ready and you want finish to merge it. +**One session per goal.** Each goal's work happens in its own session; parallel goals run in parallel sessions (one per cmux pane/session — legitimate and encouraged, whether or not each stands in its own worktree). `/dobby:finish` closes THIS goal — it does not touch other goals' worktrees or checkouts. Run it (typed, manually) when the PR is merged, or when it is merge-ready and you want finish to merge it. **The verdict is the CLI's; every destructive gate is yours.** `bunx dobby finish --preflight` computes what the teardown would destroy and whether it is safe; nothing about it removes anything. You branch on the verdict, ask the user at every gate, and perform the removal. ## Step 1: Preflight — never blind-destroy ```bash -bunx dobby finish --preflight --json # from inside the goal's worktree -bunx dobby finish --preflight --slug <slug> --json # orphan: from the main checkout +bunx dobby finish --preflight --json ``` -One call resolves the target (`slug`, plus the kit's naming for it — `branch` = `worktree-<slug>`, `worktreePath` = `<mainRoot>/.claude/worktrees/<slug>/`), the mode (`same-session` when the session is standing in the worktree it owns, `orphan` otherwise), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the two mechanics Step 3 reads (`removeMechanism`, `branchDeleteSafe`). Branch on `verdict`: +One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: -- **`blocked` with `slug: null`** — no target could be resolved (you're in the main checkout and named none). `candidates[]` holds this repo's kit worktrees: **confirm the target with the user** via an `AskUserQuestion` (one option per candidate, plus a Cancel) — pick the one whose branch matches the merged PR — then re-run the preflight with `--slug <slug>`. Never guess. -- **`blocked` with `dobbyInstalled: false`** — `dobby down` is the mandatory pre-removal teardown and has no fallback. **STOP** and point the user at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract). +- **`blocked`** — `dobbyInstalled: false`: `dobby down` is the mandatory pre-removal teardown and has no fallback. **STOP** and point the user at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract). This is the ONLY blocking condition. - **`safe`** — a MERGED PR and a clean tree. Proceed to Step 2 without a prompt. - **`confirm-required`** — this is a destructive-action gate. **Show the exact state**: every entry of `reasons[]` (an open/closed/absent PR, uncommitted changes), the PR state + `pr.url`, and the `dirty.files` list. Then require **explicit user confirmation** with an `AskUserQuestion` — an in-stage destructive-action gate, NOT a stage handoff: - **Merge & finish** *(Recommended when the PR is open and its checks are green)* — merge the goal's PR right here, then finish (see "Merging inside the gate" below). Offer this option **only** when the PR is OPEN (`pr.state: "OPEN"`) and the tree is otherwise clean — the open PR is the only entry in `reasons[]` and `dirty.count` is `0`. A closed/absent PR or a dirty tree gets the two options below and nothing else. @@ -34,7 +32,7 @@ One call resolves the target (`slug`, plus the kit's naming for it — `branch` bunx dobby pr watch [--adapter <selected id>] --await-review --deadline 60 --json ``` - Run it where the goal's PR resolves: same-session, the worktree's own branch answers for it; in `orphan` mode name the PR explicitly (`--pr <the number ending pr.url>`), because the main checkout's branch has no PR of its own. If the watch refuses because several adapters matched, run `bunx dobby review fetch --json`, ask which adapter is the gate, and re-run with `--adapter <chosen id>`. When several adapters are required gates, run every one in turn and retain every `merge-ready` payload: they are valid as a set ONLY when all `pr.headRefOid` values are the same. A mismatch means a push landed between gates—discard the entire set and restart from the first adapter until every gate validates one common SHA. Merge on verdict **`merge-ready`** from every required adapter and on nothing else. Retain that common exact SHA as the validated SHA; never re-derive it later. For Greptile, the verdict proves BOTH that its status check passed on that commit and that the summary footer's `Last reviewed commit` SHA exactly matches it. For CodeRabbit, it proves the commit-scoped CodeRabbit check passed; an old summary without that check is unreviewed. Stale/missing evidence ends as `open-unreviewed`; report `reason` and `summary.reviewedHeadOid` when present, and do NOT substitute an old clean summary or bot silence for review. On any other verdict, report it and do NOT merge: `feedback-present` → invoke **`/dobby:address-review`** via the Skill tool (it owns triage, the fixes, thread resolution and the re-trigger); `ci-failed` / `ci-pending` / `open-unreviewed` / `skipped` → report the verdict as it is and stop, worktree intact. A nonzero exit means the watch could not produce an unambiguous observation (gh failure or missing adapter selection) — surface stderr and stop; an unreadable or ambiguous pipeline is never a merge. + Run it where the session stands — the current branch's own PR answers for it. If the watch refuses because several adapters matched, run `bunx dobby review fetch --json`, ask which adapter is the gate, and re-run with `--adapter <chosen id>`. When several adapters are required gates, run every one in turn and retain every `merge-ready` payload: they are valid as a set ONLY when all `pr.headRefOid` values are the same. A mismatch means a push landed between gates—discard the entire set and restart from the first adapter until every gate validates one common SHA. Merge on verdict **`merge-ready`** from every required adapter and on nothing else. Retain that common exact SHA as the validated SHA; never re-derive it later. For Greptile, the verdict proves BOTH that its status check passed on that commit and that the summary footer's `Last reviewed commit` SHA exactly matches it. For CodeRabbit, it proves the commit-scoped CodeRabbit check passed; an old summary without that check is unreviewed. Stale/missing evidence ends as `open-unreviewed`; report `reason` and `summary.reviewedHeadOid` when present, and do NOT substitute an old clean summary or bot silence for review. On any other verdict, report it and do NOT merge: `feedback-present` → invoke **`/dobby:address-review`** via the Skill tool (it owns triage, the fixes, thread resolution and the re-trigger); `ci-failed` / `ci-pending` / `open-unreviewed` / `skipped` → report the verdict as it is and stop, nothing torn down. A nonzero exit means the watch could not produce an unambiguous observation (gh failure or missing adapter selection) — surface stderr and stop; an unreadable or ambiguous pipeline is never a merge. 2. Merge it **squashed** — the kit's convention, and the reason `branchDeleteSafe` / `-D` exist in Step 3: @@ -44,48 +42,57 @@ One call resolves the target (`slug`, plus the kit's naming for it — `branch` `--match-head-commit` closes the gap between watch and merge: if another push changed HEAD after validation, gh refuses instead of merging unreviewed bytes. On that refusal, report the drift and stop; re-run the watch before offering another merge. If gh refuses for branch protection, a merge conflict, or missing permissions, likewise report its words and stop — nothing is torn down. -3. Re-run the preflight exactly as you ran it above (same `--slug`, same cwd). It now reads the PR as MERGED and answers `safe`: continue to Step 2 with no further prompts. If it answers anything else, show what it says and stop. +3. Re-run the preflight exactly as you ran it above (same cwd). It now reads the PR as MERGED and answers `safe`: continue to Step 2 with no further prompts. If it answers anything else, show what it says and stop. Do not proceed to teardown on anything but `safe` — either read straight from the preflight, or re-read after the gated merge — or an explicit "destroy anyway". ## Step 2: Tear down the run — `bunx dobby down --json` -Run `bunx dobby down --json` (see `../execute/references/bring-up.md`'s "Tearing down" section) to tear the run down. Its mechanics already ran by the time it returns: killing the detached run by pidfile, deleting the per-worktree Neon branch, and running the project's `teardown[]` extras from `dobby.config.json`. You NEVER hunt for a background job by hand — `dobby down` owns all of it. Read `ok`/`reason` from the payload, then carry out `instructions[]` yourself: a non-empty `stop` entry (present only when a kit pane was discovered under cmux) names the now-empty kit panes to close — carry it out same as you would `up`'s `start`/`rename`. A no-app project (no run script, no panes, no `teardown` extras) no-ops cleanly, with an empty `instructions[]`. +Run `bunx dobby down --json` from the workroot the session already stands in (see `../execute/references/bring-up.md`'s "Tearing down" section) to tear the run down. Its mechanics already ran by the time it returns: killing the detached run by pidfile, deleting the per-worktree Neon branch, and running the project's `teardown[]` extras from `dobby.config.json`. You NEVER hunt for a background job by hand — `dobby down` owns all of it. Read `ok`/`reason` from the payload, then carry out `instructions[]` yourself: a non-empty `stop` entry (present only when a kit pane was discovered under cmux) names the now-empty kit panes to close — carry it out same as you would `up`'s `start`/`rename`. A no-app project (no run script, no panes, no `teardown` extras) no-ops cleanly, with an empty `instructions[]`. -- `mode: "same-session"` — the cwd is already inside the worktree: run it there. -- `mode: "orphan"` — run it with the preflight's `worktreePath` as the working directory (e.g. `bash -c 'cd <worktreePath> && bunx dobby down --json'`), never from the main checkout. +If `dobby down` reports a failure (`ok: false`, `reason`), report it and let the user decide whether to continue with removal — a half-cleaned resource is the user's call, not an auto-force. -If `dobby down` reports a failure (`ok: false`, `reason`), report it and let the user decide whether to continue removing the worktree — a half-cleaned resource is the user's call, not an auto-force. +## Step 3: Return to main — remove the worktree only if you're standing in one -## Step 3: Remove the worktree + branch - -The mechanism is the preflight's `removeMechanism`; the safety of the branch delete is its `branchDeleteSafe`. +Branch on the preflight's `inWorktree`. **`branchDeleteSafe` is true exactly when the PR is MERGED — that, not git's ancestry check, is the authoritative signal.** Most repos **squash-merge**: after a squash the feature branch tip is a different commit (new SHA/tree) that is NOT an ancestor of main, so git's own "is this branch merged?" test (`git branch -d`) reports a legitimately-merged branch as **unmerged**. Following `-d` would strand the user on every normal finish, which is why the force delete below is the default path and not an escape hatch. -- **`removeMechanism: "ExitWorktree"`** (same-session) → native **`ExitWorktree`** with `remove`: it deletes the worktree directory and its branch AND restores the cwd to the main checkout (this is why native is preferred same-session — raw git leaves you stranded inside a directory it just deleted). Pass `discard_changes: true` ONLY if the user explicitly confirmed "destroy anyway" over uncommitted changes in Step 1; on the `safe` path, no discard. If `ExitWorktree(remove)` reports the branch as unmerged and refuses to delete it (the squash-merge case above), that's expected — it does NOT contradict `branchDeleteSafe`; delete the leftover branch with the force delete below. -- **`removeMechanism: "raw-git"`** (orphan) → raw git, run **from the main checkout** (`mainRoot`; never from inside the target worktree — you'd be removing the ground under your feet): +- **`inWorktree: true`** — try native **`ExitWorktree`** with `remove` first: it deletes the worktree directory and its branch AND restores the cwd to the main checkout (this is why native is tried first — raw git leaves you stranded inside a directory it just deleted). Pass `discard_changes: true` ONLY if the user explicitly confirmed "destroy anyway" over uncommitted changes in Step 1; on the `safe` path, no discard. Two distinct refusals fall back to raw git, run **from `mainRoot`** (never from inside the worktree — you'd be removing the ground under your feet): + - **No active worktree session** (this session did not enter it — an IDE or a bare `git worktree add` did): the directory is still there, so run both lines below. + + ```bash + git worktree remove <worktreePath> # add --force ONLY if the user confirmed destroying a dirty tree in Step 1 + git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch + ``` + - **Branch refused as unmerged** (the squash-merge case above, which does NOT contradict `branchDeleteSafe`): `ExitWorktree` already removed the directory and restored the cwd — only the branch is left. Run the `-D` line ONLY; running `git worktree remove` here would target a path that's already gone. + + ```bash + git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch + ``` + +- **`inWorktree: false`** — a plain checkout has no worktree to remove; return to the default branch and delete the goal's branch: ```bash - git worktree remove <worktreePath> # add --force ONLY if the user confirmed destroying a dirty tree in Step 1 + git switch main git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch ``` -`-D` is deliberate: `branchDeleteSafe: true` IS the safe-to-delete signal. When it is false, the only thing authorizing the delete is the user's explicit "destroy anyway" from Step 1 — carry that acceptance forward, and if they cancelled, nothing here runs at all. +`-D` is deliberate in both cases: `branchDeleteSafe: true` IS the safe-to-delete signal. When it is false, the only thing authorizing the delete is the user's explicit "destroy anyway" from Step 1 — carry that acceptance forward, and if they cancelled, nothing here runs at all. ## Step 4: Update main -Bring the main checkout up to date with the merge: +Bring `mainRoot` up to date with the merge: ```bash -git pull # on the main checkout +git pull # on mainRoot ``` On a conflict or divergence (the pull doesn't fast-forward cleanly), **report it and stop — never force.** Show what git said and let the user reconcile; `/dobby:finish` does not rebase, reset, or force-pull. ## Next step — terminal -The goal is closed: its worktree and branch are gone, the dev server is down, and main is current. `/dobby:finish` is **terminal** — there is no next stage to hand off to. +The goal is closed: the run is down, the worktree (if any) and its branch are gone, and main is current. `/dobby:finish` is **terminal** — there is no next stage to hand off to. Note the goal is done, then present an **AskUserQuestion** (one question) that restates the goal is closed and offers: @@ -98,13 +105,13 @@ Interact with the user in their language. Write any note you persist in English; ## Acceptance checklist -- [ ] `bunx dobby finish --preflight --json` run FIRST (with `--slug` when finishing an orphan from the main checkout); no fact re-derived by hand (no separate `gh pr view`, `git status`, or install probe) -- [ ] `blocked` handled by cause: `slug: null` → target confirmed with the user from `candidates[]` and the preflight re-run with `--slug`; `dobbyInstalled: false` → STOPPED pointing at `/dobby:onboard` / `/dobby:migrate-config` +- [ ] `bunx dobby finish --preflight --json` run FIRST, from wherever the session stands; no fact re-derived by hand (no separate `gh pr view`, `git status`, or install probe) +- [ ] `blocked` (`dobbyInstalled: false`, the only cause) → STOPPED pointing at `/dobby:onboard` / `/dobby:migrate-config` - [ ] `safe` proceeded without a prompt; `confirm-required` showed the exact state (`reasons[]`, PR state + url, `dirty.files`) and got explicit confirmation via AskUserQuestion (Merge & finish only when offered / Cancel / Destroy anyway) before anything was merged or destroyed - [ ] The **Merge & finish** option offered ONLY on an OPEN PR with an otherwise-clean tree (open PR the only `reasons[]` entry, `dirty.count: 0`) — never on a closed/absent PR or a dirty tree - [ ] The PR merged ONLY on the user's explicit "Merge & finish" selection, and only after `bunx dobby pr watch [--adapter <selected id>] --await-review --deadline 60 --json` answered `merge-ready` with commit-scoped evidence (multi-adapter ambiguity selected mechanically; every required adapter validated the SAME `pr.headRefOid`, with the whole set restarted on mismatch; Greptile: passing review check AND `summary.reviewedHeadOid == pr.headRefOid`; CodeRabbit: passing current-commit review check; stale/missing evidence remained `open-unreviewed`, never review-by-silence); any other verdict reported and NOT merged, `feedback-present` routed to `/dobby:address-review`; squash merge pinned to the common validated SHA (`gh pr merge <pr.url> --match-head-commit <pr.headRefOid> --squash`) -- [ ] After the merge, the preflight re-run (same `--slug`/cwd) and read as MERGED / `safe` before Step 2 — never assumed -- [ ] `bunx dobby down --json` run before removal — inside the worktree same-session, with `worktreePath` as cwd for an orphan; kills the detached run, deletes the Neon branch, runs `teardown[]` extras; `ok`/`reason` read and any `instructions[]` (`stop`) carried out to close the now-empty kit panes; a no-app project no-ops cleanly; a reported failure surfaced for the user's call, not auto-forced -- [ ] Worktree + branch removed by `removeMechanism`: `ExitWorktree(remove)` same-session (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation) / raw `git worktree remove <worktreePath>` + `git branch -D <branch>` for `raw-git`, run from `mainRoot` — `-D` because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal -- [ ] `git pull` on the main checkout; on conflict/divergence reported and stopped — never forced +- [ ] After the merge, the preflight re-run (same cwd) and read as MERGED / `safe` before Step 2 — never assumed +- [ ] `bunx dobby down --json` run before removal, from the workroot the session stands in; kills the detached run, deletes the Neon branch, runs `teardown[]` extras; `ok`/`reason` read and any `instructions[]` (`stop`) carried out to close the now-empty kit panes; a no-app project no-ops cleanly; a reported failure surfaced for the user's call, not auto-forced +- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → `git switch main` then `git branch -D <branch>` — `-D` in every case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal +- [ ] `git pull` on `mainRoot`; on conflict/divergence reported and stopped — never forced - [ ] Ended with an AskUserQuestion gate (goal closed; start the next goal via `/dobby:scope` recommended, or stop here); `/dobby:scope` invoked through the Skill tool on selection diff --git a/plugin/skills/learn/SKILL.md b/plugin/skills/learn/SKILL.md index 8a206e7..3c48974 100644 --- a/plugin/skills/learn/SKILL.md +++ b/plugin/skills/learn/SKILL.md @@ -16,7 +16,7 @@ bash scripts/resolve-session.sh "<$ARGUMENTS>" Spell the script by its absolute path under this skill's base directory rather than `cd`-ing to it: `/dobby:learn` runs with the cwd at the dobby repo root, which has no `scripts/` dir, so the literal relative form above just fails. Nothing else about the call depends on the cwd — the pointer comes in as the argument (or on stdin). -It prints the resolved transcript on stdout and its reasoning on stderr; **exit 1 means the pointer is dead** — say so and stop, don't hunt for a substitute session. The ladder it walks (so you can read its stderr): the `transcript:` line first, else the first bare `.jsonl` path in the input → `test -f` → **moved-transcript fallback**. That fallback matters because Claude Code relocates a live session's transcript when the session enters a worktree (`EnterWorktree` changes the cwd, which changes the project slug dir), so an indicator emitted BEFORE entering points at a path that no longer exists; the `.jsonl` basename is the immutable session uuid, so the file is recovered by searching `~/.claude/projects` for it, taking the largest/newest match (the live one keeps accreting). +It prints the resolved transcript on stdout and its reasoning on stderr; **exit 1 means the pointer is dead** — say so and stop, don't hunt for a substitute session. The ladder it walks (so you can read its stderr): the `transcript:` line first, else the first bare `.jsonl` path in the input → `test -f` → **moved-transcript fallback**. That fallback matters because Claude Code relocates a live session's transcript when the session enters a worktree (entering one changes the cwd, which changes the project slug dir), so an indicator emitted BEFORE entering points at a path that no longer exists; the `.jsonl` basename is the immutable session uuid, so the file is recovered by searching `~/.claude/projects` for it, taking the largest/newest match (the live one keeps accreting). Also note the user's improvement intent — which skill or area (the `note:` field, or what they typed). If the target skill is ambiguous, ask once; otherwise proceed. diff --git a/plugin/skills/learn/scripts/digest-transcript.py b/plugin/skills/learn/scripts/digest-transcript.py index 1a9d0c5..c5f9a2a 100755 --- a/plugin/skills/learn/scripts/digest-transcript.py +++ b/plugin/skills/learn/scripts/digest-transcript.py @@ -51,8 +51,9 @@ # # The segment right before `skills/` is ANCHORED to `dobby` or `plugin`, never an # open `.*`. The kit moved its skills under `plugin/`, so a live banner reads -# `…/dobby/plugin/skills/<name>` (and `…/dobby/.claude/worktrees/<x>/plugin/ -# skills/<name>` from a worktree), while the pre-plugin era read +# `…/dobby/plugin/skills/<name>` (and the same banner one directory deeper from +# a worktree Claude Code opened natively — the segment before `skills/` is +# still `plugin`), while the pre-plugin era read # `…/dobby/skills/<name>`; both are covered. An open `…/dobby/.*skills/` would # ALSO swallow `…/dobby/.claude/skills/<name>` — a PROJECT skill of the dobby # checkout, not a kit skill — and the header must name only the /dobby:* skills diff --git a/plugin/skills/mark/SKILL.md b/plugin/skills/mark/SKILL.md index 9f00247..8099818 100644 --- a/plugin/skills/mark/SKILL.md +++ b/plugin/skills/mark/SKILL.md @@ -20,7 +20,7 @@ Leave the cwd where the **session** is — spell the script by its absolute path What the fields mean, and which of them carry weight: -- `transcript:` — **the load-bearing field**; the only thing `/dobby:learn` strictly needs. The path is correct AT EMISSION, but if the session later enters a worktree (`EnterWorktree` changes the cwd → new slug dir) the transcript MOVES there. The indicator stays valid anyway: `learn` recovers it by the immutable `.jsonl` uuid basename, not by this slug-derived path. +- `transcript:` — **the load-bearing field**; the only thing `/dobby:learn` strictly needs. The path is correct AT EMISSION, but if the session later enters a worktree (entering one changes the cwd → new slug dir) the transcript MOVES there. The indicator stays valid anyway: `learn` recovers it by the immutable `.jsonl` uuid basename, not by this slug-derived path. - `skills:` — the `/dobby:*` skills this session actually invoked, keyed off each skill's launch banner rather than incidental mentions, so future-you knows which internal skill to open. The banner pattern lives in `scripts/mark.sh` and is shared verbatim with `/dobby:learn`'s digest — one definition, so an indicator and its digest can't disagree about what ran. - `cwd:` — the worktree root, the durable anchor for *where* this ran. `state:` points at dobby's ephemeral `STATE.md` (repo root, gitignored: goal + decisions + plan + work-log) and is **best-effort** — `/dobby:wrap` deletes it, so it's only on disk if you mark mid-session. Either way `learn` can recover its content from the transcript. - `note:` — your `$ARGUMENTS`. The most valuable field: the intent that would otherwise be buried in a huge transcript. diff --git a/plugin/skills/mark/scripts/mark.sh b/plugin/skills/mark/scripts/mark.sh index 2d4af36..10903e4 100755 --- a/plugin/skills/mark/scripts/mark.sh +++ b/plugin/skills/mark/scripts/mark.sh @@ -95,8 +95,9 @@ WHEN=$(stat -f '%Sm' -t '%Y-%m-%d %H:%M' "$TX" 2>/dev/null || stat -c '%y' "$TX" # # The segment right before `skills/` is ANCHORED to `dobby` or `plugin`, never # an open `.*`. The kit moved its skills under `plugin/`, so a live banner reads -# `…/dobby/plugin/skills/<name>` (and `…/dobby/.claude/worktrees/<x>/plugin/ -# skills/<name>` from a worktree), while the pre-plugin era read +# `…/dobby/plugin/skills/<name>` (and the same banner one directory deeper from +# a worktree Claude Code opened natively — the segment before `skills/` is +# still `plugin`), while the pre-plugin era read # `…/dobby/skills/<name>`; both are covered. An open `…/dobby/.*skills/` would # ALSO swallow `…/dobby/.claude/skills/<name>` — a PROJECT skill of the dobby # checkout, not a kit skill — and `skills:` must list only the /dobby:* skills diff --git a/plugin/skills/migrate-config/SKILL.md b/plugin/skills/migrate-config/SKILL.md index a18296b..9181705 100644 --- a/plugin/skills/migrate-config/SKILL.md +++ b/plugin/skills/migrate-config/SKILL.md @@ -166,7 +166,7 @@ Rewrite the config to the shrunken schema (`../onboard/references/dobby-config.m ## Step 8: Delete `.conductor/` — HUMAN GATE -The kit **dropped Conductor support** — one execution host remains (terminal, with cmux enrichment). Everything `.conductor/` did is now absorbed elsewhere: workspace-as-worktree and the env-file copy are handled by native `EnterWorktree` + `dobby up`'s setup phase; `auto_run_after_setup` and the run lifecycle are `dobby up`/`down`; the Neon branch-per-worktree provisioning (admin's `setup.sh`/`archive.sh` `neonctl` glue) now lives in `dobby up`/`down`, reading `NEON_API_KEY` + `NEON_PROJECT_ID` from `.env.local`. +The kit **dropped Conductor support** — one execution host remains (terminal, with cmux enrichment). Everything `.conductor/` did is now absorbed elsewhere: workspace-as-worktree is the operator's own call (whatever worktree they open, or a plain checkout), and the env-file copy is handled by `dobby up`'s setup phase; `auto_run_after_setup` and the run lifecycle are `dobby up`/`down`; the Neon branch-per-worktree provisioning (admin's `setup.sh`/`archive.sh` `neonctl` glue) now lives in `dobby up`/`down`, reading `NEON_API_KEY` + `NEON_PROJECT_ID` from `.env.local`. > **HUMAN GATE — this is destructive and irreversible.** Note the rationale (Conductor removal is recorded in the kit's Conductor-removal ADR, which supersedes ADR-0005 — everything Conductor did is documented there for a possible future re-add) and confirm before running `rm -rf .conductor`. Running the kit under Conductor becomes unsupported after this. diff --git a/plugin/skills/onboard/SKILL.md b/plugin/skills/onboard/SKILL.md index c38b3d1..3ed6dbb 100644 --- a/plugin/skills/onboard/SKILL.md +++ b/plugin/skills/onboard/SKILL.md @@ -91,7 +91,7 @@ Persist the tracker chosen in Step 1 into `dobby.config.json`'s optional top-lev ### .worktreeinclude -Scaffold `.worktreeinclude` at the repo root (gitignore syntax) — one glob per line listing the gitignored env/config files a fresh worktree needs (e.g. `.env`, `.env.local`). Claude Code copies these into each new `EnterWorktree` worktree so the app can run there, and `dobby up`'s setup phase re-materializes them if the native copy didn't run. Discover the set the same way as the rest of onboard's discovery (inspect the repo's `.gitignore` + `.env*` files); apply the no-clobber rule if the file already exists. Skip if the project has no gitignored env files. If the app validates its env at import (a `src/lib/env.ts` calling `@t3-oss/env-core`'s `createEnv`), also commit a placeholder `.env.test` alongside — dummy values for every validated var — so CI can run the suite envless; the vitest preset loads it via mode `test`, and `.env.local` still wins locally. +Scaffold `.worktreeinclude` at the repo root (gitignore syntax) — one glob per line listing the gitignored env/config files a fresh worktree needs (e.g. `.env`, `.env.local`). Claude Code copies these into each new worktree it opens natively so the app can run there, and `dobby up`'s setup phase re-materializes them if the native copy didn't run. Discover the set the same way as the rest of onboard's discovery (inspect the repo's `.gitignore` + `.env*` files); apply the no-clobber rule if the file already exists. Skip if the project has no gitignored env files. If the app validates its env at import (a `src/lib/env.ts` calling `@t3-oss/env-core`'s `createEnv`), also commit a placeholder `.env.test` alongside — dummy values for every validated var — so CI can run the suite envless; the vitest preset loads it via mode `test`, and `.env.local` still wins locally. ### External-service setup (offer the wizard) diff --git a/plugin/skills/scope/SKILL.md b/plugin/skills/scope/SKILL.md index b5c2c15..97bbee9 100644 --- a/plugin/skills/scope/SKILL.md +++ b/plugin/skills/scope/SKILL.md @@ -4,11 +4,11 @@ description: Start a work session — normalize the goal (free-text prompt, or t argument-hint: "[goal, or tracker issue — GitHub #/URL or Linear VON-123]" --- -The front door of a work session. Normalize the goal, put the session in its own worktree, create the shared work-session doc, and map the relevant code — so every later stage (interview → research → spec → execute → wrap) runs isolated on a goal-named branch with grounding and one place to persist its output. +The front door of a work session. Normalize the goal, create the shared work-session doc, bring the workspace up, and map the relevant code — so every later stage (interview → research → spec → execute → wrap) runs grounded with one place to persist its output. **The worktree belongs to the operator**: scope no longer creates, names, or enters one — the operator opens it before invoking `/dobby:scope` (`claude --worktree`, an IDE, `git worktree add`), or works directly on a branch. Scope runs from wherever it is invoked. **One session per goal** stays good advice — parallel goals want parallel worktrees, opened by the operator, not stacked into one session. -**The mechanics belong to the CLI; the judgment and every gate belong here.** `bunx dobby goal parse` resolves the goal, `bunx dobby scope preflight` says what the worktree would take, `bunx dobby up` brings it up, `bunx dobby state` owns `STATE.md`. Read each `--json` payload and BRANCH on it — never re-derive a fact one of them already reports. +**The mechanics belong to the CLI; the judgment and every gate belong here.** `bunx dobby goal parse` resolves the goal, `bunx dobby up` brings the workspace up, `bunx dobby state` owns `STATE.md`. Read each `--json` payload and BRANCH on it — never re-derive a fact one of them already reports. -Each of them runs the repo's **LOCAL** `dobby`. Before Step 1's FIRST `bunx dobby`, require the executable marker `node_modules/.bin/dobby` under the current repo root. Missing → **STOP before running `bunx` or creating anything** and point at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract); never let `bunx` resolve an npm package remotely. Do **not** require `dobby.config.json` at this front door: config absence is a valid pre-onboarding scope path. The local bin can still normalize/preflight and create `STATE.md`; later, `configPresent:false` skips only bring-up and records the `/dobby:onboard` follow-up. A defensive `dobbyInstalled:false` from preflight also STOPs. There is no fallback. +Each of them runs the repo's **LOCAL** `dobby`. Before Step 1's FIRST `bunx dobby`, require the executable marker `node_modules/.bin/dobby` under the current repo root. Missing → **STOP before running `bunx` or creating anything** and point at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract); never let `bunx` resolve an npm package remotely. Do **not** require `dobby.config.json` at this front door: config absence is a valid pre-onboarding scope path. The local bin can still normalize the goal and create `STATE.md`; later, an absent `dobby.config.json` skips only bring-up and records the `/dobby:onboard` follow-up. There is no fallback. ## Step 1: Normalize the input into a goal @@ -22,7 +22,7 @@ The payload settles everything this step used to reason about: - **`source`** — `prompt`, `github` or `linear`. The pattern set is **gated by the configured tracker** (`dobby.config.json#tracker`, absent → github), so there is no config to read yourself and no cross-pattern ambiguity: a github project parses `#123`/github.com issue URLs and never `VON-123`; a linear project parses `VON-123`/linear.app URLs and never `#123`; free text is always a goal. - **`id` / `url`** — the bare id (`42`, `VON-123`) and the URL when the goal was one. -- **`slug`** — the starting kebab-case slug for the worktree (Step 2), with `slugCollision` as an early warning. +- **`slug`** — the starting kebab-case slug `goal parse` derives from the goal. Scope does not act on it — the worktree, if any, is the operator's — but the field stays in the payload. - **`lifecycleLink`** — `Closes #42` / `Fixes VON-123`, the PR-body magic word. Record it in `## Source` next to the backend and id — the id is what makes the session's goal traceable later: `/dobby:commit` sources the goal reference from that line and hands it back to `goal parse` to re-resolve the magic word against the configured tracker. The link itself is there so a human reads `## Source` and lands on the issue. - **`hardStop`** — non-null means the goal names an issue this session cannot read (D8): **STOP the stage** and report it verbatim. An issue goal has no free-text fallback; a free-text goal always continues. @@ -35,61 +35,35 @@ bunx dobby claim <id> --json - **github** — done when the payload says `claimed: true` (the CLI creates `status:in-progress` before assigning, so a fresh repo never loses the in-progress signal). - **linear** — the payload is a **delegation descriptor** (`delegate: "mcp"`, `op: "claim"`, `assignee`, `state: "In Progress"`, `team`) instead of an action: execute it through whichever Linear MCP tool ToolSearch resolves, per the `claim` row of the delegation table in `../backlog/references/trackers.md` — never hardcode a tool name. **This claim is the kit's one and only Linear-MCP write point**; In Review (on PR open) and Done (on merge) come from Linear's native GitHub integration off the PR body's `lifecycleLink`, never from the kit. If the MCP cannot read the issue, **STOP the stage** — that is the Linear half of the D8 hard stop, which `goal parse` cannot see from outside the MCP. -## Step 2: Set up the work-session worktree +## Step 2: Bring the workspace up -Before anything else touches the codebase, put the session in its own worktree so the whole goal — every stage after this — runs isolated on a goal-named branch. This step runs entirely before `STATE.md` is created (it lands at the worktree root, which becomes the session's repo root once you enter it). - -### 2a. Preflight the slug - -Settle the slug first — the worktree dir and the branch are named after it, so it has to read like the goal (no prompt, no confirmation either way): - -- **Free-text goal** — take `slug` from Step 1 as-is; it is already a few kebab-case words off the goal text. -- **Issue goal** — compose it: Step 1's `slug` LITERALLY as the PREFIX, then up to FOUR kebab-case words off the issue TITLE you fetched in Step 1 — `issue-42-add-csv-export` (github), `von-123-fix-stale-session-cache` (linear). The id leads because it is the part that must survive clipping in tab and pane titles; never rebuild it from `id`/`source` (no `gh-42`, no `#42`, no `VON-123`) — the CLI is the only authority on an issue's slug stem. A title that was unreadable, or that slugifies to nothing (emoji-, CJK- or punctuation-only), degrades to the prefix alone — never a trailing `-`. - -Then ask what that slug would take: +From the current workroot — wherever the operator put this session (a worktree, a branch, whatever) — check for the dobby contract: ```bash -bunx dobby scope preflight --slug <slug> --json +test -f dobby.config.json ``` -Branch on the payload in this order: - -- **`nested.insideWorktree: true`** — this session already owns a goal's worktree (`nested.currentSlug`), and the native `EnterWorktree` cannot nest. **Soft-STOP the stage** with a plain-text note (not AskUserQuestion): open a **new cmux pane / `claude` session** and run `/dobby:scope <new goal>` there — one goal per pane, no nesting. Do **not** auto-exit, auto-remove, or stack a second worktree. -- **`collision.branchExists` or `collision.dirExists`** — the name belongs to another goal. Re-run the preflight with `suggestedSlug` (or your own distinguishing word) so this goal gets its own worktree instead of clobbering an existing one. -- **`existingWorktrees`** — INFORMATIONAL, never a refusal. The invariant is **one session per goal**, not "one worktree on the machine": parallel worktrees for independent goals are fine and expected (cmux runs one goal per pane), so `.claude/worktrees/` legitimately holds worktrees from OTHER sessions/panes. Do not refuse because the list is non-empty — nesting is the only thing 2a blocks. -- **`configPresent` / `dobbyInstalled`** — the dobby contract, read at the main checkout the worktree is cut from. `dobbyInstalled: false` is the hard stop stated above (nothing has been created yet — stop here, not after a worktree exists); `configPresent` decides 2c vs 2d. - -### 2b. Create and enter the worktree - -**Use the `EnterWorktree` tool** with the collision-free slug as its `name` (this native tool must be invoked explicitly — call it, don't shell out to `git worktree add`): - -- `EnterWorktree({ name: "<slug>" })` creates and enters `.claude/worktrees/<slug>/` on branch `worktree-<slug>` — the `path` and `branch` the preflight reported — based on the default `fresh` ref (`origin/HEAD`). The session's working directory is now the worktree root. - -### 2c. Bring the workspace up (blocking) - -When the preflight reported **`configPresent: true`**, run from the worktree root: +**Present:** run ```bash bunx dobby up --json ``` -Bring the workspace up per **`../execute/references/bring-up.md`** — the shared two-step protocol: `dobby up` owns the **setup phase** end-to-end (installs dependencies, re-materializes the gitignored env/config files a fresh worktree needs — the `.worktreeinclude` set, idempotently — then runs any `setup[]` extras from the config), then probes the **run phase** once. Run it directly (Bash), in parallel with the exploration researcher you dispatch in Step 4. This is the NORMAL path with an app, not a degraded one: when `instructions[]` comes back non-empty, follow the reference yourself — carry out `rename` (cmux only) then `start`, then re-run `up --json` — no gate here, no AskUserQuestion; that only happens on `ok: false` below. (`up` is idempotent, so `/dobby:execute` Step 2 re-runs it later without double-starting.) +Bring the workspace up per **`../execute/references/bring-up.md`** — the shared two-step protocol: `dobby up` owns the **setup phase** end-to-end (installs dependencies, re-materializes the gitignored env/config files this workroot needs — the `.worktreeinclude` set, idempotently — then runs any `setup[]` extras from the config), then probes the **run phase** once. Run it directly (Bash), in parallel with the exploration researcher you dispatch in Step 4. This is the NORMAL path with an app, not a degraded one: when `instructions[]` comes back non-empty, follow the reference yourself — carry out `rename` (cmux only) then `start`, then re-run `up --json` — no gate here, no AskUserQuestion; that only happens on `ok: false` below. (`up` is idempotent, so `/dobby:execute` Step 2 re-runs it later without double-starting.) Read the payload: -- **`ok: true`** — the worktree comes up at `devUrl` once you've carried out any `instructions[]` (`browserPane` reports the kit browser pane as a discovered fact — null until something opens one; `verifyMode` tells later stages how they will verify). `phase: "noop"` means a no-app project (a library / CLI / plugin like dobby itself): the setup phase ran, there is nothing to serve, and that is a clean success — under cmux it still carries a `rename` instruction to carry out. +- **`ok: true`** — the workspace comes up at `devUrl` once you've carried out any `instructions[]` (`browserPane` reports the kit browser pane as a discovered fact — null until something opens one; `verifyMode` tells later stages how they will verify). `phase: "noop"` means a no-app project (a library / CLI / plugin like dobby itself): the setup phase ran, there is nothing to serve, and that is a clean success — under cmux it still carries a `rename` instruction to carry out. - **`ok: false`** — bring-up FAILED. `reason` is the machine-readable cause (`install-failed`, `worktree-copy-failed`, `setup-extra-failed`, `neon-creds-missing`, `neon-branch-failed`, `liveness-timeout`, `config-unreadable`, `not-a-git-repo`); the prose is on stderr. -**Bring-up failure blocks the stage** — "worktree usable or nothing." Report the failing command, the `reason`, and its stderr first. Then present an **AskUserQuestion** — legitimate here, not a mid-flow interruption: a bring-up failure BLOCKS the stage, so the gate is the handoff, and it fires ONLY on `ok: false` — never on a non-empty `instructions[]`, which is the normal path above. Two options, and only these two: +**Bring-up failure blocks the stage** — "workspace usable or nothing." Report the failing command, the `reason`, and its stderr first. Then present an **AskUserQuestion** — legitimate here, not a mid-flow interruption: a bring-up failure BLOCKS the stage, so the gate is the handoff, and it fires ONLY on `ok: false` — never on a non-empty `instructions[]`, which is the normal path above. Two options, and only these two: -- **(a) "Abort & fix" (Recommended)** — the default. Remove the just-created worktree via the **`ExitWorktree` tool** in `remove` mode (this same session created it and the tree is clean, so removal tears down the dir + branch and restores the original working directory; the tool guards destructive removal via its `discard_changes` flag — set it since there's nothing to keep), then **STOP the stage**. The user fixes the underlying problem and re-runs `/dobby:scope` fresh (a clean removal here means no leftover to trip the Step 2a nesting/collision checks). -- **(b) "Continue degraded"** — keep the worktree and proceed without carrying out the bring-up protocol. Name explicitly what is lost: the model did not carry out the `start` (or `rename`) instruction, so there is no registered run and no cmux surface — the app runs only if the user starts it by hand (`/dobby:finish`'s `down` still cleans up whatever DID register). When the payload carries a non-null `degradedCommand` (install-phase failures only), name it as the mechanical degraded bring-up: it skips just the install; the model still carries out any `instructions[]` afterward. +- **(a) "Abort & fix" (Recommended)** — the default. There is no worktree the kit made to remove — just **STOP the stage**. The user fixes the underlying problem and re-runs `/dobby:scope`. +- **(b) "Continue degraded"** — proceed without carrying out the bring-up protocol. Name explicitly what is lost: the model did not carry out the `start` (or `rename`) instruction, so there is no registered run and no cmux surface — the app runs only if the user starts it by hand (`/dobby:finish`'s `down` still cleans up whatever DID register). When the payload carries a non-null `degradedCommand` (install-phase failures only), name it as the mechanical degraded bring-up: it skips just the install; the model still carries out any `instructions[]` afterward. **Transparency rule (non-negotiable): a degraded bring-up is never silent.** If (b) is chosen, surface it in all three places — record it as an **Environment note** in `STATE.md`, state it plainly in the Step 5 scope checkpoint, AND restate it in the Next-step handoff line — never buried where the user must ask "didn't you say you'd open a browser/server?". Deviating from the abort default WITHOUT the user's explicit (b) selection is a stage violation. The note's home in `STATE.md` is the **`## Exploration` body**, written in Step 5 — see there. -### 2d. No config to run from - -**`configPresent: false`** (the repo has `dobby` but was never onboarded) — skip the bring-up; there is nothing for `dobby` to run. Say plainly that it was skipped and that `/dobby:onboard` establishes the contract for next time, then **continue the stage** — the worktree is still valid and `state init` still works. +**Absent** (no `dobby.config.json` — the repo has `dobby` but was never onboarded): skip the bring-up; there is nothing for `dobby` to run. Say plainly that it was skipped and that `/dobby:onboard` establishes the contract for next time, then **continue the stage** — `state init` still works. ## Step 3: Create the work-session doc @@ -97,7 +71,7 @@ Read the payload: bunx dobby state init --goal "<the goal>" --source "<source>" ``` -`state init` owns the document end to end: it writes `STATE.md` at the repo root (the worktree root you just entered) with the canonical skeleton — the `# Work session:` title (the `--goal` value verbatim) plus the seven sections every later stage appends to, in fixed order: `## Goal`, `## Source`, `## Exploration`, `## Findings (interview)`, `## Research`, `## Spec`, `## Work log`, each body `_pending_` except the two the flags fill — and it ensures `STATE.md` is in `.gitignore` (working memory, never a committed artifact; `/dobby:wrap` disposes of it at the end). Never hand-write the skeleton, never add the gitignore line yourself, never rename or re-order a section. +`state init` owns the document end to end: it writes `STATE.md` at the session's workroot — the git top-level scope was invoked from (the directory `up` just operated in, when Step 2 ran it), wherever the operator put this session — with the canonical skeleton — the `# Work session:` title (the `--goal` value verbatim) plus the seven sections every later stage appends to, in fixed order: `## Goal`, `## Source`, `## Exploration`, `## Findings (interview)`, `## Research`, `## Spec`, `## Work log`, each body `_pending_` except the two the flags fill — and it ensures `STATE.md` is in `.gitignore` (working memory, never a committed artifact; `/dobby:wrap` disposes of it at the end). Never hand-write the skeleton, never add the gitignore line yourself, never rename or re-order a section. `--source` carries what `goal parse` reported: `prompt`, or the backend plus the id and its lifecycle link (e.g. `github #123 — Closes #123`). `## Goal` and `## Source` are **write-once** — fill them here or they stay `_pending_` for the whole session. @@ -113,7 +87,7 @@ Dispatch a `researcher` agent (Agent tool, `subagent_type: "dobby:researcher"`) ## Step 5: Checkpoint and record -Present a concise summary to the user (relevant code areas, patterns, how the goal fits) so they can correct misunderstandings early — and, if Step 2c ended degraded, what is NOT running. Then write that same summary into the doc: +Present a concise summary to the user (relevant code areas, patterns, how the goal fits) so they can correct misunderstandings early — and, if Step 2 ended degraded, what is NOT running. Then write that same summary into the doc: ```bash bunx dobby state set Exploration --stdin <<'MD' @@ -146,13 +120,11 @@ Interact with the user in their language. Write what you persist — `STATE.md` - [ ] Goal normalized via `bunx dobby goal parse "<arg>" --json` (never by reading `dobby.config.json#tracker` or matching issue patterns by hand); asked in plain text if the input was empty - [ ] `hardStop` honored: non-null → stage STOPPED and reported (D8); a free-text goal always continues - [ ] If `source` is an issue: fetched per **view goal — the exception** in `../backlog/references/trackers.md`, then claimed with `bunx dobby claim <id> --json` — github when `claimed: true`; linear by executing the returned `{delegate:"mcp", op:"claim"}` descriptor through the ToolSearch-resolved tool (the kit's ONLY Linear-MCP write point; In Review / Done stay Linear-native), stage STOPPED if the MCP cannot read it -- [ ] Slug settled before the preflight: taken as-is from `goal parse` for a free-text goal; for an issue goal, the parse's `slug` used literally as the PREFIX plus up to four kebab-case words off the fetched TITLE (`issue-42-add-csv-export`, `von-123-fix-stale-session-cache`), degraded to the prefix alone when no usable title was readable -- [ ] `bunx dobby scope preflight --slug <slug> --json` run before touching the tree; `nested.insideWorktree` → soft-STOP ("open a new pane"), collision → retried with `suggestedSlug`, `existingWorktrees` treated as informational (parallel goals never refused) -- [ ] Worktree created + entered via the `EnterWorktree` tool with the collision-free slug (branch `worktree-<slug>`, `.claude/worktrees/<slug>/`) -- [ ] `bunx dobby up --json` run per `../execute/references/bring-up.md` when `configPresent`; `ok:true` with non-empty `instructions[]` followed (rename then start) as the NORMAL path, no gate; `ok:true`/`phase:"noop"` reported as clean success (rename instruction still carried out under cmux); `ok:false` → `reason` + stderr reported, then the two-option AskUserQuestion — **(a) abort (default/recommended)** `ExitWorktree(remove)` → stopped, or **(b) continue degraded** only on the explicit selection, naming `degradedCommand` when non-null +- [ ] No preflight run, no worktree created or entered — the worktree (if any) belongs to the operator, and scope ran wherever it was invoked +- [ ] `dobby.config.json` presence decided bring-up: present → `bunx dobby up --json` run per `../execute/references/bring-up.md`; `ok:true` with non-empty `instructions[]` followed (rename then start) as the NORMAL path, no gate; `ok:true`/`phase:"noop"` reported as clean success (rename instruction still carried out under cmux); `ok:false` → `reason` + stderr reported, then the two-option AskUserQuestion — **(a) abort (default/recommended)** → stage stopped (no worktree to remove), or **(b) continue degraded** only on the explicit selection, naming `degradedCommand` when non-null - [ ] Degradation surfaced in all three places (STATE.md Environment note + Step 5 checkpoint + Next-step handoff), the note FOLDED into `## Exploration` as a bold `**Environment note:**` lead — no new heading -- [ ] Defensive `dobbyInstalled:false` → stage STOPPED before anything was created; `configPresent:false` → bring-up skipped with an `/dobby:onboard` note and the stage CONTINUED through local `state init` -- [ ] `STATE.md` created with `bunx dobby state init --goal … --source …` (the engine owns the seven-section skeleton AND the gitignore entry); `--source` carries the backend, id and lifecycle link; a refusal (existing `STATE.md`) stopped the stage +- [ ] Absent `dobby.config.json` → bring-up skipped with an `/dobby:onboard` note and the stage CONTINUED through local `state init` +- [ ] `STATE.md` created at the session's workroot with `bunx dobby state init --goal … --source …` (the engine owns the seven-section skeleton AND the gitignore entry); `--source` carries the backend, id and lifecycle link; a refusal (existing `STATE.md`) stopped the stage - [ ] Codebase explored with a `researcher` agent; `CONTEXT.md` + ADRs read if present - [ ] Researcher cross-referenced the goal's claims against the code and surfaced contradictions (not just a file map) - [ ] Exploration returned as a compressed, context-budgeted digest (depth on what matters; pointer for a full map if needed) diff --git a/plugin/skills/upgrade/SKILL.md b/plugin/skills/upgrade/SKILL.md index 65c06c3..94473a3 100644 --- a/plugin/skills/upgrade/SKILL.md +++ b/plugin/skills/upgrade/SKILL.md @@ -35,7 +35,7 @@ bun update @kvnwolf/dobby --latest Each release that asks something of a consumer ships a note at `references/v<minor>.md`. Read, in ascending order, ONLY the files whose version falls inside the jump (above the previously installed version, up to and including the latest), and execute their actions. A version with no file asks nothing of a consumer — don't hunt for missing files. -Notes so far: `references/v0.7.md`, `references/v0.8.md`, `references/v0.9.md`, `references/v0.12.md`, `references/v0.14.md`, `references/v0.15.md`. +Notes so far: `references/v0.7.md`, `references/v0.8.md`, `references/v0.9.md`, `references/v0.12.md`, `references/v0.14.md`, `references/v0.15.md`, `references/v0.16.md`. ## Step 4: Re-run the gate diff --git a/plugin/skills/upgrade/references/v0.16.md b/plugin/skills/upgrade/references/v0.16.md new file mode 100644 index 0000000..c1043b2 --- /dev/null +++ b/plugin/skills/upgrade/references/v0.16.md @@ -0,0 +1,21 @@ +# v0.16.0 — the worktree belongs to the operator + +`/dobby:scope` no longer creates or enters a worktree on your behalf. It grounds the goal wherever the session already stands — a worktree you opened (`claude --worktree`, Claude Desktop, t3 code, an IDE, `git worktree add`) or a plain checkout on a branch — writes `STATE.md` there, and brings it up with `bunx dobby up`, exactly as before. `/dobby:finish` still merges the PR behind its gate and runs `bunx dobby down`; teardown now branches on where the session stands: in a linked worktree (whoever made it) it offers to remove that worktree and its branch — native `ExitWorktree` first, then raw git from the main root — and on a plain checkout it returns to `main` and deletes the goal's branch. `git pull` always runs. See ADR-0033 (`docs/adr/0033-the-worktree-belongs-to-the-operator.md`). + +## Breaking: `/dobby:scope` opens no worktree + +Open one yourself before running `/dobby:scope` if you want the goal isolated — `claude --worktree`, or `git worktree add` from the main checkout — or just work on a branch in a plain checkout. Scope no longer refuses on nesting (there is nothing left for it to nest), and it no longer names a worktree path or branch of its own; the goal slug everywhere is `basename(workroot)`. + +## Breaking: `dobby scope preflight` removed + +The command, its collision/nesting detection, and its `suggestedSlug` computation are gone outright — there is no replacement. A repo/branch collision is now whatever your host or `git` itself reports. + +## Breaking: `dobby finish --preflight` payload and flags changed + +`--slug` and `candidates[]` are gone (there is no kit-assigned slug to disambiguate — the goal is resolved from where the session stands). `mode` (`same-session`/`orphan`) and `removeMechanism` (`ExitWorktree`/`raw-git`) are replaced by three facts the caller now derives the removal path from: `inWorktree` (is the session standing in a linked worktree at all), `worktreePath`, and `mainRoot`. `branch` is still reported. The verdict set is unchanged: `safe` (a merged PR and a clean tree), `blocked` (dobby not installed), else `confirm-required`. + +## Upgrade steps + +1. `bun update @kvnwolf/dobby` in each consumer repo. +2. If your `CLAUDE.md` tells `/dobby:scope` to expect (or create) a worktree, drop that line — scope now works identically in a worktree you opened yourself or in a plain checkout. +3. If anything scripts against `dobby scope preflight` or reads `finish --preflight`'s old `mode`/`removeMechanism`/`candidates` keys, update it to the new payload shape above. From 7ed0a82f8dc81ff81e67e7bdbb877224dd58bc8e Mon Sep 17 00:00:00 2001 From: Kevin Wolf <hi@kvnwolf.com> Date: Thu, 3 Sep 2026 20:56:54 -0600 Subject: [PATCH 2/6] fix(kit): finish probes dobby at the workroot and follows the real default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #54 — two P1s on the finish preflight, both inherited from the model this PR removes. `dobbyInstalled` was still checked at the main checkout — a leftover from when the worktree was the kit's and the main checkout its origin. An operator-made linked worktree with its own `node_modules` (where `up` installed and where `down` runs) could be `blocked` while a bare main checkout was consulted instead. The probe now follows the workroot, in every shape; a plain checkout is unchanged because there the two roots coincide. The plain-checkout teardown said `git switch main`. On a repository whose trunk is `master` or anything else, that either fails and leaves the goal's branch behind or switches to an unrelated `main`. The preflight now reports `defaultBranch`, resolved from `origin/HEAD` with `main` as the fallback when there is no remote head, and the skill switches to and pulls that branch. The trunk's name is a fact the CLI resolves once, never a literal in prose. --- cli/CONTEXT.md | 4 +- cli/README.md | 2 +- cli/src/preflight.test.ts | 226 ++++++++++++++++++++++++++++++++-- cli/src/preflight.ts | 34 ++++- plugin/skills/finish/SKILL.md | 22 ++-- 5 files changed, 266 insertions(+), 22 deletions(-) diff --git a/cli/CONTEXT.md b/cli/CONTEXT.md index de5f516..5a14836 100644 --- a/cli/CONTEXT.md +++ b/cli/CONTEXT.md @@ -32,7 +32,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `src/buildplan.ts` (+ `src/buildplan.test.ts`) — the **build plan**, derived MECHANICALLY from the spec's task table (`dobby build-plan [--file <doc>] [--task <task.json>] [--json]`), a domain module behind the `command.ts` contract. It replaces the per-session judgment call the coordinator used to make over a markdown grid, and emits ONE payload: `tasks[]` — the per-task instruction data VERBATIM (`{id, title, spec, decisions, constraints, areas[], verifyRecipe, testFirst}` + `destructive` + `dependsOn[]`), the exact shape `plugin/skills/execute/references/build-protocol.md` consumes, with `decisions`/`constraints` deliberately EMPTY (plan-level decisions stay coordinator-distributed) and `devUrl` deliberately ABSENT (the coordinator merges it), and `dependsOn` carrying the row's `Depends on` ids VERBATIM (`—`/empty → `[]`) — the ONLY thing that says WHO a task waits for, now that there is no batch grouping saying WHEN it runs: a task is ready the moment every id in its own `dependsOn` has reached `done`, which is what lets the Architect skip a task whose dependency ended needs-human without touching anything independent of it; `preconditions` — `{missing[{taskId,field}], danglingDeps[{taskId,dependsOn}], cycles[[ids]], ok}`, where not-ok exits 1 **with the payload still on stdout** (the `up --json` convention: the verdict fields ARE the fix list); plus the two gates `/dobby:execute` reads before launching — `hasTestSuite` (`value` from the repo's `vitest` capability, `specSays` from the Testing Decisions' test-first claim — null when the section is absent — and their `disagreement`) and `manualVerifySetup` (the `Manual verify setup:` field's steps, or `none`). PARSING IS TOLERANT BY CONTRACT: the task table is found by its HEADER ROW (never a `### Tasks` anchor — the sub-heading spec format is new and older specs must still plan), `Description`/`Test-first`/`Destructive` are each optional (absent → the title stands in as the spec, the flags read false), a non-task table inside the spec is skipped, and `—` reads as "no dependency". `--task <file>` plans ONE ad-hoc task from JSON and reads no STATE.md at all (the `/dobby:dispatch` path); it carries the ad-hoc surface the spec named `--task-file`, since the dispatcher's flag set has no such option. An ACTION command (`requireWorkroot`; the throw is folded into the failure shape). `node:*` only (ADR-0008). - `src/build-protocol.test.ts` — a GUARD with no module of its own: what it tests lives OUTSIDE the CLI, as the **dispatch protocol** document at `plugin/skills/execute/references/build-protocol.md` (the shared build-loop component `/dobby:execute`, `/dobby:dispatch`, and `/dobby:address-review` all read and follow). The protocol is prose the Architect follows directly, not a runtime the CLI can execute, so the suite reads that markdown as TEXT and pins its RULES per document section: every worker is dispatched NAMED (`dobby:test-author` / `dobby:implementor` / `dobby:qa`), `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` must stay unset, the deferred `SendMessage` tool is loaded via `ToolSearch` before first use, a task starts the moment its own dependencies are done with no fixed batch to wait on, the Exit gate is serialised to one implementor at a time, a dead task stops only its dependents while independent work keeps being dispatched (and what died is reported), each worker appends its own `dobby state append-worklog` entry and returns only a short verdict, `STATE.md` stays current enough to reconstruct progress after a compaction, and the run closes with a summary table of rounds / first-attempt success / deaths / wall clock — plus a repo-wide scan proving the rename off the OLD filenames (the protocol document and its old test harness both previously carried) left no live surface still pointing at either one. It lives here because `cli/src` is the tree vitest covers; it imports nothing from the CLI. - `src/repro.ts` (+ `src/repro.test.ts`) — the **red/green capture harness** (`dobby repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>`), a domain module behind the `command.ts` contract. Everything after `--` is the command (node's `parseArgs` drops the `--` and hands the rest through as positionals), spawned through `runner.runCapture` with cwd pinned to the workroot — so the SAME loop reproduces identically from any subdirectory, and outside a git repo it fails hard like every action command (`requireWorkroot`'s throw is CAUGHT here, since `run()` does not catch handler exceptions). One run yields `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` is red on ANY nonzero exit (a child that never started / was killed records POSIX 127 / 128 and its spawn error folded into stderr, never a silent green — the exit code is extracted by TYPE, `typeof status === "number"`, never by `!== null`: node spells "no exit code" as `null` while Bun, the runtime the `dobby` bin runs on, spells it `undefined`, and an `undefined` exitCode would be DROPPED by `JSON.stringify` out of both the payload and the persisted record), and `matched` is `verdict === --expect` — explicitly NULL (never absent) without `--expect`, because "nothing was judged" is an answer. The HARNESS judges, so the model never derives a verdict by reading output: exit 1 ONLY on a mismatch (the "your loop is not red-capable" signal); a failing command with no `--expect` still exits 0 (repro REPORTS an exit code, never inherits one). `reproId` = a 12-hex-char sha256 of the workroot + the command argv and NOTHING else (repro's own flags are deliberately out, so `--repeat`/`--bench` runs key the same record), and every run persists `{baseline, latest, reproId}` to `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured, as `up` does for its pidfile) — a write failure is a stderr WARNING, never the outcome. `--repeat N` runs sequentially and adds `{runs, redCount, greenCount, reproductionRate (red/runs), deterministic, firstDivergent:{run (1-BASED), stdout, stderr}|null, durations}`; the BASE record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), which makes `--expect red --repeat N` a red-CAPABILITY probe instead of a coin flip on run 1. `--bench` adds `{samples, min, median (over SORTED samples), mean, max}` plus `baseline` (the PREVIOUS bench of this reproId, null on the first) and `delta` (current MINUS baseline per timing stat, so faster reads negative), and stores its own stats as the next baseline — a non-bench run never clobbers it. `--json` prints the full payload; the default text render is a compact summary (verdict + reproId headline, `cmd:`/`cwd:`, the repeat/bench lines, the record path) plus a labelled TAIL of stdout/stderr — repro itself NEVER truncates what it captures or stores. `node:*` only (ADR-0008). -- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed, so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`) and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). +- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch`, resolved at `mainRoot` from `refs/remotes/origin/HEAD`, falling back to the constant `"main"` when there is no remote head — never the local trunk), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). - `src/release.ts` (+ `src/release.test.ts`) — the **release SPINE** (`dobby release [--bump patch|minor|major] [--notes-file <f>] [--dry-run] [--json]`), a domain module behind the `command.ts` contract and the CLI's one CONFIG-GATED command: without a `release` key in `dobby.config.json` the command does not exist (`run.ts` hides it; the spine repeats the refusal as defense in depth for every non-dispatcher caller). It owns the phases EVERY release target shares and nothing target-specific: those live behind the exported `ReleaseAdapter` seam — `{id, preflight, packGate, publish, smoke}`, each taking a `ReleaseContext` (`{currentVersion, dir, notesFile, release, root, tag, version}` — `version`/`tag` are NULL during `preflight`, which runs before the version is decided) and returning `ReleasePhaseResult` DATA, plus three OPTIONAL members: `primaryManifest(root, release)` (a target whose version does not live in `<dir>/package.json`), `bumpExtras(context, version)` (the version-carrying files the JSON-only bump cannot edit — run INSIDE the bump phase, after the manifests and BEFORE the gate and the commit, so the edit is gated and lands in the `release: v<V>` commit) and `postRelease(context)` (work that can only happen once the GitHub release exists — run after `gh release create` and before `smoke`, PAST the publish line, so a failure is reported and never rolled back). A target that needs none of them is unaffected — the npm one defines none. Adapters register themselves through `registerReleaseAdapter(type, adapter)` into a MUTABLE registry keyed by `release.type` (a `switch` would make the spine import every target; a lazy `await import()` is impossible — `run.ts` dispatches handlers synchronously), and an unregistered type is a clean error naming what IS registered. **Two-phase invocation** (the model keeps authorship of both judgements): (1) `needsDecision` — without `--bump`, a FIRST release or an inferred major while still below 1.0.0 exits 1 with `{needsDecision: "first-release"|"0x-major", context:{currentVersion, commits[]}}` having touched NOTHING, and the skill answers with `--bump`; (2) `needsNotes` — without `--notes-file` the run does everything mechanical and STOPS with `{needsNotes: true, version, changelog}` (exit 1, the bump commit kept LOCAL, nothing pushed or published), the skill authors the notes and re-runs with `--notes-file` (which must live OUTSIDE the repo — a release refuses a dirty tree). The five phases, each emitting a `phases[]` record: **preflight** (main checkout only via `lifecycle.linkedWorktreeMain`, branch `main` read with `git branch --show-current` — never `rev-parse --abbrev-ref HEAD`, which is `fatal:` on an unborn branch — a clean `git status --porcelain`, `git pull --ff-only`, CI green ASSERTED IN CODE from `gh run list --branch main --limit 1 --json headSha,status,conclusion` (an ARRAY, completed + success AND `headSha` equal to the commit the release is cut from — a green run for some OTHER commit proves nothing; on the RESUME run that commit is `HEAD~1`, since HEAD is then the local, deliberately UNPUSHED `release: v<V>` commit no CI run can ever name), then `adapter.preflight`); **version** (`git describe --tags --abbrev=0 --match v*`, whose exit 128 means a FIRST RELEASE for both "no tags" and "no matching tags" — its `fatal:` stderr is captured and dropped; the tag re-validated with `rev-parse --verify`; `git rev-list --count <tag>..HEAD == 0` → `nothing to release`; per-commit classification from ONE `git log <range> -z --pretty=format:%H%x00%s%x00%B` chunked by 3 with NO trailing NUL, rules: `!` before the `:` or a BREAKING CHANGE body → major, any `feat` → minor, else patch); **bump** (each manifest's indentation MEASURED from its own first indented line and only the version VALUE rewritten — the v0.5.1 field bug was a hardcoded `"\t"` that reformatted every 2-space manifest and turned CI red; the primary manifest is `release.dir ?? "."` + `/package.json` unless the adapter overrides it, plus every `release.lockstep[]` entry as a repo-relative FILE path — non-JSON lockstep files are left to the adapter's `bumpExtras` (which runs here, before the gate) and reported on the phase note, never silently skipped; then the gate runs IN-PROCESS over the BUMPED tree (`check([], root, {}, true)`, never a `dobby` subprocess) and a red gate restores the manifests and exits with the gate's own code and its FULL findings; finally `git add -u` + `git commit -m "release: v<V>"`, and NOTHING is pushed); **changelog** (the commits grouped by `release.surfaces` name→GLOB when configured — a commit that spans surfaces is listed under EACH — else by type: Breaking changes / Features / Fixes / Other); **publish** (`adapter.packGate` → `adapter.publish` → `git tag v<V>` → `git push origin main v<V>` → `gh release create v<V> --title v<V> --notes-file <f>` → `adapter.postRelease` → `adapter.smoke`). A RE-RUN recognizes the local `release: v<V>` commit (HEAD subject + the manifest agreeing + NO `v<V>` tag yet) and skips re-bumping. `node:*` only (ADR-0008). - `src/release-npm.ts` (+ `src/release-npm.test.ts`) — the **npm release TARGET**: the `ReleaseAdapter` behind `release.type: "npm"`, and the home of every npm-specific field scar. Four moments, each spawned through the runner with the cwd pinned to the RELEASE DIR (`context.dir` = `release.dir ?? "."` resolved against the workroot — publishing the workroot ships the wrong tree), each answering DATA and never throwing. **preflight** — `npm whoami`; a failure refuses in npm's own words, and the comment records the thing whoami CANNOT prove: an interactive-login token authenticates and then fails at publish with `EOTP` (the account's 2FA wants a per-publish OTP), so the working setup is a GRANULAR access token with write access in `~/.npmrc` (field-proven on v0.1.0). **packGate** — `bun pm pack --dry-run --ignore-scripts` (nothing written, no lifecycle script run as a side effect of INSPECTING a package), its `packed <size> <path>` listing parsed and matched against the DENY globs `**/*.test.ts`, `**/__fixtures__/**`, `dist/**` (GLOBS, never substrings — `src/latest.ts`, `src/fixtures/`, a root `distribute.ts` must all ship, and `dist/**` is rooted at the PACKAGE root so a `src/dist/` source directory ships while `**/` matches zero directories so a ROOT-level `index.test.ts` is caught); ANY hit refuses and names EVERY denied file, and a listing the gate parses NO files out of refuses too — quoting the packer's own stdout back, because zero `packed` lines means either an allowlist that ships nothing or a listing shape that drifted, and those have opposite fixes. **publish** — `npm publish --access public` (a scoped package defaults to restricted), PLAIN npm and never `bun publish` (bun 1.3.x ignores `~/.npmrc`'s `_authToken` and dies with "missing authentication" — field-hit on v0.1.0; this module spawns `bun` for the pack dry run alone); an `EOTP` in the output comes back with the granular-token fix, every other failure with npm's own words. **smoke** — `npm view <name> version` polled until the registry serves `context.version` (propagation can lag MINUTES on a first publish: a 404 right after `+ pkg@<V>` printed is NOT a failure, only an exhausted budget is; the budget is 15 polls × 20s by default and INJECTABLE via `createNpmAdapter({attempts, delayMs})` — the module's second export, which exists so tests need not wait), then the optional `release.smoke` argv (an ARRAY, never a shell string), the only step that proves the published ARTIFACT works. The package name is read from the release dir's own `package.json`. The adapter is registered by the SPINE (`registerReleaseAdapter("npm", npmAdapter)` in release.ts) rather than self-registering: a side-effect import of a self-registering target evaluates the target BEFORE the spine's registry const exists (a TDZ `ReferenceError` at import time, verified), while this direction leaves the target importing only TYPES — no runtime cycle. `node:*` only (ADR-0008). - `src/release-cask.ts` (+ `src/release-cask.test.ts`) — the **homebrew-cask release TARGET**: the `ReleaseAdapter` behind `release.type: "homebrew-cask"`, for a Tauri macOS app shipped through a Homebrew tap. It uses SIX of the seam's moments (the four every target has, plus BOTH optional hooks). **preflight** — `<dir>/src-tauri/tauri.conf.json` exists (else this is not a Tauri app), `rustup target list --installed` carries BOTH `aarch64-apple-darwin` and `x86_64-apple-darwin` (a missing one refuses with the literal `rustup target add …` fix), `gh auth status`, and `release.tap` + `release.cask` are configured (each refusal names the missing key) — plus, ONLY when the OPTIONAL `release.notaryProfile` is set, the two one-time human setups notarization needs: `security find-identity -v -p codesigning` listing a `Developer ID Application` certificate (the tool EXITS 0 while listing none, so the verdict is its OUTPUT; the refusal names Xcode → Settings → Accounts as where the certificate is made, and records that SIGNING is `tauri.conf.json`'s `signingIdentity`, never dobby's job) and `xcrun notarytool history --keychain-profile <p>` exiting 0 (the refusal carries the one-time `xcrun notarytool store-credentials <p> --apple-id … --team-id …`). **bumpExtras** — `src-tauri/Cargo.toml`'s version, rewritten byte-surgically and SCOPED to the `[package]` section (the window from the `[package]` header to the NEXT `[section]`: `version = ` also sits at the start of a line under `[dependencies.<crate>]`, and a whole-file regex bumps a dependency instead), then `cargo check` in the crate — ANY cargo command reconciles `Cargo.lock`, whose stale version would otherwise ride along in the release commit. **packGate** — `bun tauri build --bundles app,dmg --target universal-apple-darwin`, then exactly ONE `*.dmg` under `src-tauri/target/universal-apple-darwin/release/bundle/dmg/` (zero and many are separate refusals) and `PlistBuddy -c "Print :CFBundleShortVersionString"` on the built `.app` equal to the version being released (a bundle that predates the bump would ship an app reporting the old number while the cask advertises the new one) — PlistBuddy is spawned BARE with `/usr/libexec` APPENDED to the child's PATH, never by absolute path. With a `release.notaryProfile` configured, THREE more steps run here (last, AFTER the version gate — a stale bundle must never cost an Apple round trip — and still before any tag, the last place a release can be refused for free): `xcrun notarytool submit <dmg> --keychain-profile <p> --wait` whose stdout must carry `status: Accepted` (the tool exits 0 on a REJECTED submission, and a refusal quotes its FULL log, never truncated), `xcrun stapler staple <dmg>`, then `spctl -a -t open --context context:primary-signature -vv <dmg>` whose output must carry `Notarized Developer ID` (spctl exits 0 for a signed-but-un-notarized build and writes its assessment to STDERR, so the gate reads BOTH streams and matches CASE-SENSITIVELY — Gatekeeper's refusal reads `Unnotarized Developer ID`); each step gates the next, so a rejected submission is never stapled and an unstapled dmg is never assessed. **publish** — a NO-OP: the dmg is a local file until the GitHub release exists, so there is nothing this target could half-publish. **postRelease** — `gh release upload v<V> <dmg>`, `shasum -a 256` on that same file, then the TAP: `gh repo clone <tap>` into a mkdtemp dir (a failed clone is the probe — `gh repo create <tap> --public` then clone again), the cask's `version` + `sha256` lines replaced in place (indentation captured, never assumed) or the whole file SCAFFOLDED from the module's template when the tap carries none (its `url` templates Homebrew's `#{version}` and SANITIZES the asset name the way GitHub serves it — spaces become dots — so later releases only ever move two lines), `ruby -c` before the commit (an absent ruby is a NOTE, a rejection is a refusal), then `git add` + `git commit -m "<cask> <V>"` + `git push -u origin HEAD` in the tap checkout. **smoke** — REPORT-ONLY: it runs NOTHING and hands back `brew install --cask <tap-short>/<cask>` (Homebrew's own rule: `<user>/homebrew-<name>` is referred to as `<user>/<name>`), plus — CONDITIONALLY, only when nothing was notarized — the quarantine caveat (`xattr -dr com.apple.quarantine …`), which next to a notarized build would simply be a lie. **The credentials are keychain-only**: `notaryProfile` is the NAME of a notarytool keychain profile and the only credential fact dobby ever holds; there is deliberately NO `APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` env-var path (mad-eye ADR 0005 — an app-specific password in the environment is inherited by every child, shell history and CI log). `security`, `xcrun` and `spctl` are spawned BARE like the rest, which is also what keeps them stubbable in tests. Registered by the SPINE like the npm one (`registerReleaseAdapter("homebrew-cask", caskAdapter)` in release.ts), so this module imports only TYPES from it — no runtime cycle, and the target is reachable from `run.ts`'s graph through the spine. `node:*` only (ADR-0008). @@ -81,7 +81,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `build-plan [--file <doc>] [--task <task.json>] [--json]` → the task-dependency plan for `/dobby:execute` (and, with `--task`, for `/dobby:dispatch`). Default source: the `## Spec` body of `<workroot>/STATE.md` (`--file` overrides the document), whose task table is located by its HEADER ROW — `#`/`Task`/`Depends on`/`Affected areas`/`Verify recipe`, with `Description`, `Test-first` and `Destructive` all OPTIONAL (no Description → the title is the task's `spec`; an absent flag column → false) — so a spec written before the `### ` sub-heading format still plans, and a non-task table inside the spec is skipped. `--task <file>` reads ONE ad-hoc task from JSON instead and never touches STATE.md (it carries the surface the spec named `--task-file`, which the dispatcher's flag set has no option for). Answers `{tasks[{id,title,spec,decisions,constraints,areas[],verifyRecipe,testFirst,destructive,dependsOn[]}], hasTestSuite{value,specSays,disagreement}, manualVerifySetup: "none"|string[], preconditions{ok,missing[{taskId,field}],danglingDeps[{taskId,dependsOn}],cycles[[id…]]}, workRoot}` — `tasks` VERBATIM for the Architect to dispatch directly (`decisions`/`constraints` empty by contract, `devUrl` merged by the coordinator). There is NO wave/batch grouping in the payload: `dependsOn` (the row's `Depends on` ids — `[]` for `—`/empty) is the ONLY thing that says who a task waits for, and a task is ready the moment every id it names has reached `done` — which is what lets the caller SKIP a task whose blocker never passed without holding back anything independent of it. Ids are STRINGS, the same ones `dependsOn` references. Failing preconditions exit 1 **with the payload still on stdout** (the refusal names each task and cell on stderr); a missing document / unparseable `--task` file / table-less spec is a hard error with no payload. Fails hard outside a git repo. - `ship [--message-file <f>] [--pr-body-file <f>] [--json]` → the COMMIT CEREMONY in ONE call, the mechanized half of `/dobby:commit`. `--message-file` is REQUIRED and validated FIRST (present, readable, non-blank; resolved against the CALLER's cwd) — a ceremony that cannot produce a message leaves the tree exactly as it found it (unstaged, un-formatted, un-gated). Then: stage when nothing is staged → the **GATE IN-PROCESS** (`check([], root, {}, fix=true)`, never a `dobby` subprocess) → `.dobby/` exclude-ensured (in `.git/info/exclude`) and the WHOLE tree re-staged (the gate judges the working tree, so committing a caller-staged SUBSET would record a green verdict for a tree that was never checked) → the gate cache → `git commit -F` → push pinned to ORIGIN (`-u origin HEAD` when the branch tracks nothing; a non-origin upstream still pushes to origin, reported via `pushNote`) → the pull request. A detached HEAD is refused before any mutation. **The exit code decides**: a nonzero gate returns the gate's OWN code with every finding printed WHOLE (uncapped, never `formatCheck`'s 50-per-tool sample) and commits nothing. The PR is opened ONLY off a NON-TRUNK branch (`main`/`master` have nowhere to open one from) and ONLY with a `--pr-body-file` (the body is the caller's to author); an EXISTING PR for the branch is reported, not duplicated; a failed push short-circuits it, and a PR gh could not open is a NOTE, not a failure (the commit already landed). Answers `{cacheNote, cacheWritten, committed, gateExitCode, gateNote, prNote, prUrl, pushNote, pushed, sha}` — the notes distinguish "skipped by policy" from "could not be done", and `gateNote` carries the `gate skipped: inputs unchanged since last green (…)` line when the in-process gate was served from the per-check cache (null when it really ran). Fails hard outside a git repo. - `review fetch [--pr N] [--json]` · `review apply (--plan <f>|--stdin) [--pr N] [--dry-run] [--json]` · `pr watch [--pr N] [--deadline <sec>] [--await-review] [--json]` → the `gh` surface of the address-review stage; `--pr` defaults to the CURRENT branch's PR. `fetch` → `{pr, adapter, candidates, threads, summary}`: the open review THREADS over GraphQL (drained with gh's mandatory `$endCursor` pagination contract, each thread carrying its last comments so a re-run sees its own prior replies) plus the bot's summary comment over REST (sorted by `updated_at`, since the bot EDITS one comment in place, and bot logins matched by BARE slug because GraphQL and REST disagree about the `[bot]` suffix). A PR with nothing to address is `threads: []` at exit 0 — an ANSWER, not an error. `apply` consumes a disposition plan (`{pr, reTrigger, plan:[{threadId, disposition: fix|dismiss|outdated|defer, reply}]}`) from `--plan`/`--stdin`, replies + resolves in batches, SKIPS threads it already answered (idempotent by construction) and re-triggers when asked; `defer` deliberately does NOT resolve (a deferred finding stays open) and `--dry-run` makes the same decisions with zero writes. Any failure exits 1 with `{failures[], replied[], resolved[], retriggered, skipped[], dryRun}`. `pr watch` owns its OWN polling loop and derives the verdict from check BUCKET COUNTS (`gh pr checks --json` always exits 0, and `--watch --json` is a hard error) — `ci-failed|ci-green|ci-pending|merge-ready|feedback-present|open-unreviewed|skipped`, with `--deadline` (default 300s) budgeting EACH wait phase separately (CI, then the review under `--await-review`) so a slow CI run can never eat the review wait. NO merge path — every judgment stays in `/dobby:address-review`. All three fail hard outside a git repo, and a gh that could not report at all is surfaced, never read as an empty (green) check list. -- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed, so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it); removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. +- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch` is the repository's own trunk — `refs/remotes/origin/HEAD` resolved at `mainRoot`, stripped of its `origin/` prefix, falling back to the constant `"main"` when there is no remote head to read (never the local trunk). Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. - `repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>` → the red/green capture harness. Everything after `--` is the command, spawned with cwd pinned to the workroot (fails hard outside a git repo) and its stdout/stderr captured WHOLE — repro never truncates. One run answers `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` = red on any nonzero exit, `matched` = `verdict === --expect` (explicitly `null`, never absent, without `--expect`). Exit code: **1 ONLY on a mismatch** (the "your loop is not red-capable" signal); a failing command with NO `--expect` exits 0 — the harness reports an exit code, it never inherits one. `reproId` is a short hash of the workroot + the command argv ONLY (repro's own flags are excluded, so every run of one loop keys the same record), and each run persists `{baseline, latest, reproId}` at `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured); a failed write is a stderr warning, not a failure. `--repeat N` runs the loop N times sequentially and ADDS `{runs, redCount, greenCount, reproductionRate (redCount/runs), deterministic, firstDivergent:{run (1-based), stdout, stderr}|null, durations}` — the base record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), so `--expect red --repeat N` is a red-CAPABILITY probe and the pasted output is the failing run's. `--bench` ADDS `{samples, min, median (over sorted samples), mean, max}` plus `baseline` (the previous bench of this reproId, `null` on the first) and `delta` (current MINUS baseline per timing stat — faster reads negative), then stores its own stats as the next baseline; a non-bench run leaves the stored baseline alone. `--json` prints the full payload as the sole stdout; the default render is a compact human summary (headline + `cmd:`/`cwd:` + the repeat/bench lines + the record path) with a labelled TAIL of the output. - `kb list --kind <k> | kb record --kind <k> --concept <kebab> --title <t> --reason-file <f> --entry <line>` → the durable knowledge bases at `<workroot>/docs/out-of-scope/` and `<workroot>/docs/learn-discarded/` (fails hard outside a git repo). `--kind` is REQUIRED for both and is the module's only parameter; an unknown one is a hard error naming both KBs (a typo must never read as "that KB is empty" — dedup would silently stop working). `list` → a bare JSON ARRAY (under `--json`) of `{concept (filename stem), path, title (the H1), statement (the first paragraph, wrapped lines joined), priorEntries (the `- ` bullets under the kind's prior-section, markers stripped)}`, one per `*.md`, sorted by filename; an ABSENT directory is `[]` at exit 0, never an error. `record` → ONE file per concept: an existing concept's file gets the entry APPENDED as a bullet under its prior-section (every byte before that heading untouched — the rationale written the first time wins over this call's `--title`/`--reason-file`), an absent one is created after a lazy `mkdir`, carrying the canonical skeleton (H1, the one-line statement, the kind's why-heading + the reason body, the kind's prior-heading + the first bullet). `--reason-file` is split at its FIRST LINE (the statement) with the REST as the reason body; a file with no body is refused. Answers the bare `{path, created, appended}`. Every refusal goes to stderr with exit 1 (no `ok` envelope — the payloads are the spec's bare shapes). - `adr new "<title>" [--status proposed|accepted|deprecated]` → allocate the next ADR number and create `<workroot>/docs/adr/NNNN-<slug>.md` (fails hard outside a git repo; the dir is created lazily, and only after the inputs validate — a refusal never leaves an empty `docs/adr/` behind). The title is a POSITIONAL (every positional after the token, joined); the slug is DERIVED from it. Numbering is `max + 1` over the local directory AND `git ls-tree -r origin/HEAD --name-only -- docs/adr` (a sibling worktree's ADR is pushed long before it lands here; no origin / no git / no upstream `docs/adr` all score 0, so a remote-less repo still files ADRs), and the number is claimed — not merely the filename: each attempt re-reads `docs/adr/` and moves to the next number if anything already carries the `NNNN-` prefix, whatever its slug, with `O_EXCL` (`flag: "wx"`) behind it so an `EEXIST` retries instead of truncating an identically-named ADR. The scan is a read and is stale the instant it returns, so the claim happens at WRITE time, never at scan time. Writes a SKELETON only — `# NNNN. <title>`, the optional `**Status:** <status>` line (omitted without `--status`), and a placeholder paragraph; body authorship stays with the architect. Answers the bare `{number, slug, path}`; a missing title / an unknown status is a refusal on stderr with exit 1, naming what IS valid. diff --git a/cli/README.md b/cli/README.md index 7b03e83..7827a35 100644 --- a/cli/README.md +++ b/cli/README.md @@ -452,7 +452,7 @@ dobby migrate preflight --json dobby migrate verify --json ``` -`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed, so the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`) and whether the branch is safe to delete. +`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch`, resolved from `origin/HEAD` at `mainRoot`, falling back to `"main"` when there is no remote head), and whether the branch is safe to delete. `dobby migrate preflight` says whether a repo still needs the config migration (naming each legacy signal and snapshotting what the migration must carry across); `dobby migrate verify` runs the gate in-process and reports the environment read back plus whatever was left behind. Both **exit 0 with a payload for every verdict** — they inform, they never refuse. diff --git a/cli/src/preflight.test.ts b/cli/src/preflight.test.ts index e0b4996..66fd6b1 100644 --- a/cli/src/preflight.test.ts +++ b/cli/src/preflight.test.ts @@ -108,8 +108,11 @@ const scratchDirs: string[] = []; // Mark a root as carrying a local dobby install, the way the finish skill probes it // (`node_modules/.bin/dobby` executable) AND the way a manifest reader would -// (`@kvnwolf/dobby` in devDependencies) — both signals agree in every fixture, so no -// assertion here depends on which one the implementation reads. +// (`@kvnwolf/dobby` in devDependencies) — both signals agree in every fixture EXCEPT +// the two worktree-split slices at the end, where they CANNOT: `package.json` is a +// tracked file, so a linked worktree and its main checkout necessarily share it, +// and the only per-tree signal a real install leaves is the gitignored +// `node_modules/.bin/dobby`. Those slices therefore pin the marker the spec names. function installDobby(root: string): void { const binDir = join(root, "node_modules", ".bin"); mkdirSync(binDir, { recursive: true }); @@ -123,9 +126,19 @@ function installDobby(root: string): void { // dobby.config.json), so it starts CLEAN and so does every worktree cut from it. // node_modules/ is gitignored, so the local dobby install never registers as an // uncommitted change. -function makeMainCheckout(opts: { config?: boolean; dobby?: boolean }): string { +// `branch` names the trunk the repo is born on (default `main`) — the remote-head +// slices need a repo whose trunk is `master`/`trunk`. `bin: false` keeps the +// manifest devDependency while leaving this tree WITHOUT the local +// `node_modules/.bin/dobby`, which is the only install split a main checkout and +// its linked worktree can actually express. +function makeMainCheckout(opts: { + bin?: boolean; + branch?: string; + config?: boolean; + dobby?: boolean; +}): string { const dir = makeScratchRepo({ - branch: "main", + branch: opts.branch ?? "main", config: opts.config === true ? { files: [] } : undefined, files: { ".gitignore": "node_modules/\n.claude/\n", @@ -139,7 +152,7 @@ function makeMainCheckout(opts: { config?: boolean; dobby?: boolean }): string { prefix: "dobby-preflight-", track: scratchDirs, }); - if (opts.dobby === true) { + if (opts.dobby === true && opts.bin !== false) { installDobby(dir); } return dir; @@ -160,15 +173,23 @@ function makePlainCheckout( // Add a LINKED worktree with plain git, at a path of the operator's choosing // OUTSIDE the main checkout — deliberately nothing like the kit's old // `.claude/worktrees/<slug>` layout, since the kit no longer owns the naming. -// Returns its realpath-normalized absolute path. -function addLinkedWorktree(mainRoot: string, branch: string): string { +// Returns its realpath-normalized absolute path. `dobby: false` leaves the new +// worktree WITHOUT its own local install — the operator cut the tree but never ran +// the install in it. +function addLinkedWorktree( + mainRoot: string, + branch: string, + opts: { dobby?: boolean } = {} +): string { const parent = realpathSync( mkdtempSync(join(tmpdir(), "dobby-preflight-wt-")) ); scratchDirs.push(parent); const path = join(parent, "wt"); gitIn(mainRoot, ["worktree", "add", "-q", "-b", branch, path]); - installDobby(path); + if (opts.dobby !== false) { + installDobby(path); + } return realpathSync(path); } @@ -245,6 +266,7 @@ afterAll(() => { interface FinishPreflight { branch: string; branchDeleteSafe: boolean; + defaultBranch: string; dirty: { count: number; files: string[] }; dobbyInstalled: boolean; inWorktree: boolean; @@ -784,3 +806,191 @@ describe("the surviving preflights still answer", () => { expect(Object.hasOwn(payload, "verdict")).toBe(true); }); }); + +// =========================================================================== +// Slice 12 — `dobbyInstalled` is a fact about the WORKROOT the command runs in, +// not about the main checkout it hangs off. The operator owns the worktree, so +// the two trees are installed INDEPENDENTLY: `node_modules/` is gitignored and +// per-tree, and `bun install` is run wherever the operator chose to work. A +// preflight that answered for `mainRoot` would call an installed worktree +// uninstalled (and block a perfectly safe close), and would call a bare worktree +// installed (and green-light a `dobby down` that cannot run). +// +// Both fixtures share one tracked manifest — that is what a linked worktree IS — +// so the install split is expressed the only way it can be: the local +// `node_modules/.bin/dobby` marker exists in exactly one of the two trees. The +// expected values are the spec's own wording (installed → not blocked; not +// installed → `blocked`, the only blocking condition). +// =========================================================================== + +describe("finish --preflight — installed in the worktree, bare main checkout", () => { + let worktree: string; + + beforeAll(() => { + // The manifest declares dobby; only the main checkout never had the install + // run in it. The worktree the operator cut and installed into is where the + // session stands. + const mainRoot = makeMainCheckout({ + bin: false, + config: true, + dobby: true, + }); + worktree = addLinkedWorktree(mainRoot, "goal2"); + }); + + it("counts the install in the worktree the session stands in", async () => { + const preflight = await finishPreflight(worktree); + expect(preflight.dobbyInstalled).toBe(true); + }); + + it("verdicts the close safe from an installed worktree over a merged PR", async () => { + const preflight = await finishPreflight(worktree); + expect({ + inWorktree: preflight.inWorktree, + verdict: preflight.verdict, + }).toEqual({ inWorktree: true, verdict: "safe" }); + }); +}); + +describe("finish --preflight — bare worktree, installed main checkout", () => { + let worktree: string; + + beforeAll(() => { + // The mirror image: the main checkout carries the install, the worktree the + // operator cut never had one run in it. + const mainRoot = makeMainCheckout({ config: true, dobby: true }); + worktree = addLinkedWorktree(mainRoot, "no-dobby", { dobby: false }); + }); + + it("reports dobby as not installed where the session stands", async () => { + const preflight = await finishPreflight(worktree); + expect(preflight.dobbyInstalled).toBe(false); + }); + + it("blocks the close, the main checkout's install being no help", async () => { + const preflight = await finishPreflight(worktree); + expect(preflight.verdict).toBe("blocked"); + }); +}); + +// =========================================================================== +// Slice 13 — `defaultBranch`. The finish skill switches to the trunk before it +// deletes a goal branch, and `main` is an assumption, not a fact: plenty of repos +// still trunk on `master`, and some on a house name entirely. The answer is the +// repository's OWN remote head (`refs/remotes/origin/HEAD` → `origin/<name>`), +// with `"main"` as the fallback when there is no remote head to read. +// +// Every expected value is a name WE chose and wrote into the fixture with plain +// git (`--initial-branch` + `git remote set-head`), never a name read back the way +// the code reads it — and each fixture stands on a goal branch of a DIFFERENT +// name, so a preflight that echoed the current branch fails these outright. +// =========================================================================== + +// A main checkout whose trunk is `trunk`, published to a throwaway BARE origin +// with an explicit remote head, then left standing on a `goal` branch — the +// session shape finish actually meets. Returns the checkout's path. +function makeRemoteHeadCheckout(trunk: string): string { + const mainRoot = makeMainCheckout({ + branch: trunk, + config: true, + dobby: true, + }); + const bare = realpathSync( + mkdtempSync(join(tmpdir(), "dobby-preflight-origin-")) + ); + scratchDirs.push(bare); + gitIn(bare, ["init", "-q", "--bare"]); + gitIn(mainRoot, ["remote", "add", "origin", bare]); + gitIn(mainRoot, ["push", "-q", "-u", "origin", trunk]); + gitIn(mainRoot, ["remote", "set-head", "origin", trunk]); + gitIn(mainRoot, ["switch", "-q", "-c", "goal"]); + return mainRoot; +} + +// Text mode prints the same fact in whatever prose the CLI likes: `defaultBranch`, +// `default branch`, `default-branch` all carry it. +const DEFAULT_BRANCH_LABEL = /default.?branch/i; + +describe("finish --preflight — the repository's default branch", () => { + let masterTrunk: string; + let houseTrunk: string; + let noRemote: string; + + beforeAll(() => { + masterTrunk = makeRemoteHeadCheckout("master"); + houseTrunk = makeRemoteHeadCheckout("trunk"); + // Born on `develop` and never pushed anywhere: there is no remote head to + // read, and the LOCAL trunk is deliberately not `main` — so the fallback can + // only be answered by the spec's constant, never by reading this repo. + noRemote = makeMainCheckout({ + branch: "develop", + config: true, + dobby: true, + }); + gitIn(noRemote, ["switch", "-q", "-c", "goal"]); + }); + + it("reports master when the remote head points at master", async () => { + const preflight = await finishPreflight(masterTrunk); + expect(preflight.defaultBranch).toBe("master"); + }); + + it("reports a house trunk name rather than assuming main", async () => { + const preflight = await finishPreflight(houseTrunk); + expect(preflight.defaultBranch).toBe("trunk"); + }); + + it("names the trunk, never the goal branch the session stands on", async () => { + const preflight = await finishPreflight(houseTrunk); + expect({ + branch: preflight.branch, + defaultBranch: preflight.defaultBranch, + }).toEqual({ branch: "goal", defaultBranch: "trunk" }); + }); + + it("falls back to main when there is no remote head to read", async () => { + const preflight = await finishPreflight(noRemote); + expect(preflight.defaultBranch).toBe("main"); + }); + + it("prints the default branch for a human reader too", async () => { + const result = await run(["finish", "--preflight"], houseTrunk); + expect(result.stdout).toMatch(DEFAULT_BRANCH_LABEL); + expect(result.stdout).toContain("trunk"); + }); +}); + +// =========================================================================== +// Slice 14 — the payload's field list is EXACT: the ten fields the finish skill +// already reads plus `defaultBranch`, and nothing else. Pinned as a whole set +// (not field-by-field) so both halves of the contract hold — a dropped field is +// caught as surely as a stray one, and the removed kit-made-worktree fields can +// never creep back in under a new name. +// =========================================================================== + +const FINISH_PAYLOAD_KEYS = [ + "branch", + "branchDeleteSafe", + "defaultBranch", + "dirty", + "dobbyInstalled", + "inWorktree", + "mainRoot", + "pr", + "reasons", + "verdict", + "worktreePath", +]; + +describe("the finish preflight payload's field list", () => { + let checkout: string; + + beforeAll(() => { + checkout = makePlainCheckout("goal"); + }); + + it("carries exactly the eleven fields the finish skill reads", async () => { + const preflight = await finishPreflight(checkout); + expect(Object.keys(preflight).sort()).toEqual(FINISH_PAYLOAD_KEYS); + }); +}); diff --git a/cli/src/preflight.ts b/cli/src/preflight.ts index b93758d..0e8a1f9 100644 --- a/cli/src/preflight.ts +++ b/cli/src/preflight.ts @@ -67,6 +67,10 @@ type Verdict = "blocked" | "confirm-required" | "safe"; interface FinishPreflight { branch: string; branchDeleteSafe: boolean; + // The repository's own trunk — `refs/remotes/origin/HEAD`, resolved at + // `mainRoot` — never the goal branch the session stands on. Falls back to the + // constant `"main"` when there is no remote head to read. + defaultBranch: string; dirty: DirtyTree; dobbyInstalled: boolean; // True iff the session's workroot is a LINKED worktree (git's own definition, @@ -123,6 +127,29 @@ function currentBranch(root: string): string { return result.status === 0 && name !== "" && name !== "HEAD" ? name : "HEAD"; } +// The repository's own trunk — the fallback the finish skill switches to +// before it force-deletes a goal branch. `"main"` is an assumption, not a +// fact, so this reads the repo's own remote head (`refs/remotes/origin/HEAD`, +// which `git symbolic-ref` answers as `origin/<name>`) and strips the +// `origin/` prefix. Any failure — no remote, no remote head set — falls back +// to the constant below, never to the LOCAL trunk (which may not exist, or +// may not even be the repo's real trunk). +const DEFAULT_BRANCH_FALLBACK = "main"; +const REMOTE_HEAD_PREFIX = "origin/"; + +function defaultBranchAt(root: string): string { + const result = runCapture( + "git", + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + { root } + ); + const ref = result.stdout.trim(); + if (result.status !== 0 || !ref.startsWith(REMOTE_HEAD_PREFIX)) { + return DEFAULT_BRANCH_FALLBACK; + } + return ref.slice(REMOTE_HEAD_PREFIX.length); +} + // --------------------------------------------------------------------------- // `dobby finish --preflight` // --------------------------------------------------------------------------- @@ -155,7 +182,10 @@ export function runFinishPreflight(context: CommandContext): CommandResult { const branch = currentBranch(workroot); const pr = readPullRequest(workroot, branch); const dirty = readDirtyTree(workroot); - const dobbyInstalled = dobbyInstalledAt(mainRoot); + // Per-tree, not per-checkout: `node_modules/` is gitignored, so a linked + // worktree and its main checkout are installed INDEPENDENTLY of each other. + const dobbyInstalled = dobbyInstalledAt(workroot); + const defaultBranch = defaultBranchAt(mainRoot); const { reasons, verdict } = judgeTeardown({ branch, @@ -172,6 +202,7 @@ export function runFinishPreflight(context: CommandContext): CommandResult { // authoritative signal — which is why this is derived from `pr.state` and // never from git, and why the skill force-deletes (`-D`) once it holds. branchDeleteSafe: pr?.state === "MERGED", + defaultBranch, dirty, dobbyInstalled, inWorktree, @@ -296,6 +327,7 @@ function formatFinishText(payload: FinishPreflight): string { `verdict: ${payload.verdict}`, `inWorktree: ${payload.inWorktree}`, `branch: ${payload.branch}`, + `defaultBranch: ${payload.defaultBranch}`, `worktreePath: ${payload.worktreePath ?? "-"}`, `mainRoot: ${payload.mainRoot}`, `pr: ${pr}`, diff --git a/plugin/skills/finish/SKILL.md b/plugin/skills/finish/SKILL.md index 76515c5..77f9ce4 100644 --- a/plugin/skills/finish/SKILL.md +++ b/plugin/skills/finish/SKILL.md @@ -1,9 +1,9 @@ --- name: finish -description: Closes the goal end-to-end — merges the goal's PR when it is still open (gated, on your explicit selection), then tears the run down. Use when the current goal's PR is merged OR merge-ready and you want to clean up and return to main. +description: Closes the goal end-to-end — merges the goal's PR when it is still open (gated, on your explicit selection), then tears the run down. Use when the current goal's PR is merged OR merge-ready and you want to clean up and return to the default branch. --- -The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/dobby:finish` offers to **merge it first** — always as an explicit selection at the gate in Step 1, never automatically. Once it is merged, tear down the run: run `bunx dobby down --json` to kill it and run the project's cleanup, carry out any `stop` instruction it hands back (closing the now-empty kit cmux panes). Then, **only when the session stands inside a linked worktree** — whoever made it: `claude --worktree`, an IDE, a bare `git worktree add` — offer to remove that worktree and its branch. On a plain checkout there is no worktree to remove: return to `main`, delete the goal's branch, and pull — closing the goal so the tree is ready for the next one. +The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/dobby:finish` offers to **merge it first** — always as an explicit selection at the gate in Step 1, never automatically. Once it is merged, tear down the run: run `bunx dobby down --json` to kill it and run the project's cleanup, carry out any `stop` instruction it hands back (closing the now-empty kit cmux panes). Then, **only when the session stands inside a linked worktree** — whoever made it: `claude --worktree`, an IDE, a bare `git worktree add` — offer to remove that worktree and its branch. On a plain checkout there is no worktree to remove: return to `defaultBranch`, delete the goal's branch, and pull — closing the goal so the tree is ready for the next one. **One session per goal.** Each goal's work happens in its own session; parallel goals run in parallel sessions (one per cmux pane/session — legitimate and encouraged, whether or not each stands in its own worktree). `/dobby:finish` closes THIS goal — it does not touch other goals' worktrees or checkouts. Run it (typed, manually) when the PR is merged, or when it is merge-ready and you want finish to merge it. @@ -15,7 +15,7 @@ The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/ bunx dobby finish --preflight --json ``` -One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: +One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch` — Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: - **`blocked`** — `dobbyInstalled: false`: `dobby down` is the mandatory pre-removal teardown and has no fallback. **STOP** and point the user at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract). This is the ONLY blocking condition. - **`safe`** — a MERGED PR and a clean tree. Proceed to Step 2 without a prompt. @@ -52,11 +52,11 @@ Run `bunx dobby down --json` from the workroot the session already stands in (se If `dobby down` reports a failure (`ok: false`, `reason`), report it and let the user decide whether to continue with removal — a half-cleaned resource is the user's call, not an auto-force. -## Step 3: Return to main — remove the worktree only if you're standing in one +## Step 3: Return to the default branch — remove the worktree only if you're standing in one Branch on the preflight's `inWorktree`. -**`branchDeleteSafe` is true exactly when the PR is MERGED — that, not git's ancestry check, is the authoritative signal.** Most repos **squash-merge**: after a squash the feature branch tip is a different commit (new SHA/tree) that is NOT an ancestor of main, so git's own "is this branch merged?" test (`git branch -d`) reports a legitimately-merged branch as **unmerged**. Following `-d` would strand the user on every normal finish, which is why the force delete below is the default path and not an escape hatch. +**`branchDeleteSafe` is true exactly when the PR is MERGED — that, not git's ancestry check, is the authoritative signal.** Most repos **squash-merge**: after a squash the feature branch tip is a different commit (new SHA/tree) that is NOT an ancestor of the default branch, so git's own "is this branch merged?" test (`git branch -d`) reports a legitimately-merged branch as **unmerged**. Following `-d` would strand the user on every normal finish, which is why the force delete below is the default path and not an escape hatch. - **`inWorktree: true`** — try native **`ExitWorktree`** with `remove` first: it deletes the worktree directory and its branch AND restores the cwd to the main checkout (this is why native is tried first — raw git leaves you stranded inside a directory it just deleted). Pass `discard_changes: true` ONLY if the user explicitly confirmed "destroy anyway" over uncommitted changes in Step 1; on the `safe` path, no discard. Two distinct refusals fall back to raw git, run **from `mainRoot`** (never from inside the worktree — you'd be removing the ground under your feet): - **No active worktree session** (this session did not enter it — an IDE or a bare `git worktree add` did): the directory is still there, so run both lines below. @@ -74,13 +74,13 @@ Branch on the preflight's `inWorktree`. - **`inWorktree: false`** — a plain checkout has no worktree to remove; return to the default branch and delete the goal's branch: ```bash - git switch main + git switch <defaultBranch> git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch ``` `-D` is deliberate in both cases: `branchDeleteSafe: true` IS the safe-to-delete signal. When it is false, the only thing authorizing the delete is the user's explicit "destroy anyway" from Step 1 — carry that acceptance forward, and if they cancelled, nothing here runs at all. -## Step 4: Update main +## Step 4: Update the default branch Bring `mainRoot` up to date with the merge: @@ -88,11 +88,13 @@ Bring `mainRoot` up to date with the merge: git pull # on mainRoot ``` +On a plain checkout, Step 3 already switched `mainRoot` onto `defaultBranch`, so this pulls that branch. Inside a linked worktree, `mainRoot` was never switched — this pulls whatever branch the main checkout already has checked out. + On a conflict or divergence (the pull doesn't fast-forward cleanly), **report it and stop — never force.** Show what git said and let the user reconcile; `/dobby:finish` does not rebase, reset, or force-pull. ## Next step — terminal -The goal is closed: the run is down, the worktree (if any) and its branch are gone, and main is current. `/dobby:finish` is **terminal** — there is no next stage to hand off to. +The goal is closed: the run is down, the worktree (if any) and its branch are gone, and the default branch is current. `/dobby:finish` is **terminal** — there is no next stage to hand off to. Note the goal is done, then present an **AskUserQuestion** (one question) that restates the goal is closed and offers: @@ -112,6 +114,6 @@ Interact with the user in their language. Write any note you persist in English; - [ ] The PR merged ONLY on the user's explicit "Merge & finish" selection, and only after `bunx dobby pr watch [--adapter <selected id>] --await-review --deadline 60 --json` answered `merge-ready` with commit-scoped evidence (multi-adapter ambiguity selected mechanically; every required adapter validated the SAME `pr.headRefOid`, with the whole set restarted on mismatch; Greptile: passing review check AND `summary.reviewedHeadOid == pr.headRefOid`; CodeRabbit: passing current-commit review check; stale/missing evidence remained `open-unreviewed`, never review-by-silence); any other verdict reported and NOT merged, `feedback-present` routed to `/dobby:address-review`; squash merge pinned to the common validated SHA (`gh pr merge <pr.url> --match-head-commit <pr.headRefOid> --squash`) - [ ] After the merge, the preflight re-run (same cwd) and read as MERGED / `safe` before Step 2 — never assumed - [ ] `bunx dobby down --json` run before removal, from the workroot the session stands in; kills the detached run, deletes the Neon branch, runs `teardown[]` extras; `ok`/`reason` read and any `instructions[]` (`stop`) carried out to close the now-empty kit panes; a no-app project no-ops cleanly; a reported failure surfaced for the user's call, not auto-forced -- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → `git switch main` then `git branch -D <branch>` — `-D` in every case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal -- [ ] `git pull` on `mainRoot`; on conflict/divergence reported and stopped — never forced +- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → `git switch <defaultBranch>` then `git branch -D <branch>` — `-D` in every case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal +- [ ] `git pull` on `mainRoot` — the branch Step 3 left it on (`defaultBranch` on a plain checkout, unchanged inside a linked worktree); on conflict/divergence reported and stopped — never forced - [ ] Ended with an AskUserQuestion gate (goal closed; start the next goal via `/dobby:scope` recommended, or stop here); `/dobby:scope` invoked through the Skill tool on selection From 9f42ec220bc83b8287d6b49962a2c25ec040da3c Mon Sep 17 00:00:00 2001 From: Kevin Wolf <hi@kvnwolf.com> Date: Thu, 3 Sep 2026 21:17:45 -0600 Subject: [PATCH 3/6] fix(kit): resolve the default branch by cascade and stop when it is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 on #54. Round 1 replaced the hard-coded `main` with `origin/HEAD`, but fell back to the constant `main` whenever that symbolic ref was unreadable — which is exactly the case on a `master` or `trunk` repository that was never cloned (a `git remote add` leaves `origin/HEAD` unset). finish would then `git switch main`: a failing switch that leaves the goal's branch behind, or a switch to an unrelated `main`. `defaultBranch` is now resolved by an ordered cascade at the main root — `origin/HEAD`; then whichever of `origin/main` / `origin/master` exists; then whichever of local `main` / `master` exists — and is `null` when none does. finish's plain-checkout teardown reads it before switching: a name proceeds as before; `null` stops right there, with the PR merged and the run torn down, and tells the operator the two commands to run with the trunk they know. A wrong switch on the operator's own checkout is worse than asking. --- cli/CONTEXT.md | 4 +- cli/README.md | 2 +- cli/src/preflight.test.ts | 212 ++++++++++++++++++++++++++++++---- cli/src/preflight.ts | 69 ++++++++--- plugin/skills/finish/SKILL.md | 28 +++-- 5 files changed, 263 insertions(+), 52 deletions(-) diff --git a/cli/CONTEXT.md b/cli/CONTEXT.md index 5a14836..b99b4ea 100644 --- a/cli/CONTEXT.md +++ b/cli/CONTEXT.md @@ -32,7 +32,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `src/buildplan.ts` (+ `src/buildplan.test.ts`) — the **build plan**, derived MECHANICALLY from the spec's task table (`dobby build-plan [--file <doc>] [--task <task.json>] [--json]`), a domain module behind the `command.ts` contract. It replaces the per-session judgment call the coordinator used to make over a markdown grid, and emits ONE payload: `tasks[]` — the per-task instruction data VERBATIM (`{id, title, spec, decisions, constraints, areas[], verifyRecipe, testFirst}` + `destructive` + `dependsOn[]`), the exact shape `plugin/skills/execute/references/build-protocol.md` consumes, with `decisions`/`constraints` deliberately EMPTY (plan-level decisions stay coordinator-distributed) and `devUrl` deliberately ABSENT (the coordinator merges it), and `dependsOn` carrying the row's `Depends on` ids VERBATIM (`—`/empty → `[]`) — the ONLY thing that says WHO a task waits for, now that there is no batch grouping saying WHEN it runs: a task is ready the moment every id in its own `dependsOn` has reached `done`, which is what lets the Architect skip a task whose dependency ended needs-human without touching anything independent of it; `preconditions` — `{missing[{taskId,field}], danglingDeps[{taskId,dependsOn}], cycles[[ids]], ok}`, where not-ok exits 1 **with the payload still on stdout** (the `up --json` convention: the verdict fields ARE the fix list); plus the two gates `/dobby:execute` reads before launching — `hasTestSuite` (`value` from the repo's `vitest` capability, `specSays` from the Testing Decisions' test-first claim — null when the section is absent — and their `disagreement`) and `manualVerifySetup` (the `Manual verify setup:` field's steps, or `none`). PARSING IS TOLERANT BY CONTRACT: the task table is found by its HEADER ROW (never a `### Tasks` anchor — the sub-heading spec format is new and older specs must still plan), `Description`/`Test-first`/`Destructive` are each optional (absent → the title stands in as the spec, the flags read false), a non-task table inside the spec is skipped, and `—` reads as "no dependency". `--task <file>` plans ONE ad-hoc task from JSON and reads no STATE.md at all (the `/dobby:dispatch` path); it carries the ad-hoc surface the spec named `--task-file`, since the dispatcher's flag set has no such option. An ACTION command (`requireWorkroot`; the throw is folded into the failure shape). `node:*` only (ADR-0008). - `src/build-protocol.test.ts` — a GUARD with no module of its own: what it tests lives OUTSIDE the CLI, as the **dispatch protocol** document at `plugin/skills/execute/references/build-protocol.md` (the shared build-loop component `/dobby:execute`, `/dobby:dispatch`, and `/dobby:address-review` all read and follow). The protocol is prose the Architect follows directly, not a runtime the CLI can execute, so the suite reads that markdown as TEXT and pins its RULES per document section: every worker is dispatched NAMED (`dobby:test-author` / `dobby:implementor` / `dobby:qa`), `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` must stay unset, the deferred `SendMessage` tool is loaded via `ToolSearch` before first use, a task starts the moment its own dependencies are done with no fixed batch to wait on, the Exit gate is serialised to one implementor at a time, a dead task stops only its dependents while independent work keeps being dispatched (and what died is reported), each worker appends its own `dobby state append-worklog` entry and returns only a short verdict, `STATE.md` stays current enough to reconstruct progress after a compaction, and the run closes with a summary table of rounds / first-attempt success / deaths / wall clock — plus a repo-wide scan proving the rename off the OLD filenames (the protocol document and its old test harness both previously carried) left no live surface still pointing at either one. It lives here because `cli/src` is the tree vitest covers; it imports nothing from the CLI. - `src/repro.ts` (+ `src/repro.test.ts`) — the **red/green capture harness** (`dobby repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>`), a domain module behind the `command.ts` contract. Everything after `--` is the command (node's `parseArgs` drops the `--` and hands the rest through as positionals), spawned through `runner.runCapture` with cwd pinned to the workroot — so the SAME loop reproduces identically from any subdirectory, and outside a git repo it fails hard like every action command (`requireWorkroot`'s throw is CAUGHT here, since `run()` does not catch handler exceptions). One run yields `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` is red on ANY nonzero exit (a child that never started / was killed records POSIX 127 / 128 and its spawn error folded into stderr, never a silent green — the exit code is extracted by TYPE, `typeof status === "number"`, never by `!== null`: node spells "no exit code" as `null` while Bun, the runtime the `dobby` bin runs on, spells it `undefined`, and an `undefined` exitCode would be DROPPED by `JSON.stringify` out of both the payload and the persisted record), and `matched` is `verdict === --expect` — explicitly NULL (never absent) without `--expect`, because "nothing was judged" is an answer. The HARNESS judges, so the model never derives a verdict by reading output: exit 1 ONLY on a mismatch (the "your loop is not red-capable" signal); a failing command with no `--expect` still exits 0 (repro REPORTS an exit code, never inherits one). `reproId` = a 12-hex-char sha256 of the workroot + the command argv and NOTHING else (repro's own flags are deliberately out, so `--repeat`/`--bench` runs key the same record), and every run persists `{baseline, latest, reproId}` to `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured, as `up` does for its pidfile) — a write failure is a stderr WARNING, never the outcome. `--repeat N` runs sequentially and adds `{runs, redCount, greenCount, reproductionRate (red/runs), deterministic, firstDivergent:{run (1-BASED), stdout, stderr}|null, durations}`; the BASE record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), which makes `--expect red --repeat N` a red-CAPABILITY probe instead of a coin flip on run 1. `--bench` adds `{samples, min, median (over SORTED samples), mean, max}` plus `baseline` (the PREVIOUS bench of this reproId, null on the first) and `delta` (current MINUS baseline per timing stat, so faster reads negative), and stores its own stats as the next baseline — a non-bench run never clobbers it. `--json` prints the full payload; the default text render is a compact summary (verdict + reproId headline, `cmd:`/`cwd:`, the repeat/bench lines, the record path) plus a labelled TAIL of stdout/stderr — repro itself NEVER truncates what it captures or stores. `node:*` only (ADR-0008). -- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch`, resolved at `mainRoot` from `refs/remotes/origin/HEAD`, falling back to the constant `"main"` when there is no remote head — never the local trunk), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). +- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `refs/remotes/origin/HEAD`, then remote-tracking `refs/remotes/origin/main`/`master`, then local `refs/heads/main`/`master`, else `null` when nothing in the repo names one, an honest "I don't know" rather than a guessed `"main"`), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). - `src/release.ts` (+ `src/release.test.ts`) — the **release SPINE** (`dobby release [--bump patch|minor|major] [--notes-file <f>] [--dry-run] [--json]`), a domain module behind the `command.ts` contract and the CLI's one CONFIG-GATED command: without a `release` key in `dobby.config.json` the command does not exist (`run.ts` hides it; the spine repeats the refusal as defense in depth for every non-dispatcher caller). It owns the phases EVERY release target shares and nothing target-specific: those live behind the exported `ReleaseAdapter` seam — `{id, preflight, packGate, publish, smoke}`, each taking a `ReleaseContext` (`{currentVersion, dir, notesFile, release, root, tag, version}` — `version`/`tag` are NULL during `preflight`, which runs before the version is decided) and returning `ReleasePhaseResult` DATA, plus three OPTIONAL members: `primaryManifest(root, release)` (a target whose version does not live in `<dir>/package.json`), `bumpExtras(context, version)` (the version-carrying files the JSON-only bump cannot edit — run INSIDE the bump phase, after the manifests and BEFORE the gate and the commit, so the edit is gated and lands in the `release: v<V>` commit) and `postRelease(context)` (work that can only happen once the GitHub release exists — run after `gh release create` and before `smoke`, PAST the publish line, so a failure is reported and never rolled back). A target that needs none of them is unaffected — the npm one defines none. Adapters register themselves through `registerReleaseAdapter(type, adapter)` into a MUTABLE registry keyed by `release.type` (a `switch` would make the spine import every target; a lazy `await import()` is impossible — `run.ts` dispatches handlers synchronously), and an unregistered type is a clean error naming what IS registered. **Two-phase invocation** (the model keeps authorship of both judgements): (1) `needsDecision` — without `--bump`, a FIRST release or an inferred major while still below 1.0.0 exits 1 with `{needsDecision: "first-release"|"0x-major", context:{currentVersion, commits[]}}` having touched NOTHING, and the skill answers with `--bump`; (2) `needsNotes` — without `--notes-file` the run does everything mechanical and STOPS with `{needsNotes: true, version, changelog}` (exit 1, the bump commit kept LOCAL, nothing pushed or published), the skill authors the notes and re-runs with `--notes-file` (which must live OUTSIDE the repo — a release refuses a dirty tree). The five phases, each emitting a `phases[]` record: **preflight** (main checkout only via `lifecycle.linkedWorktreeMain`, branch `main` read with `git branch --show-current` — never `rev-parse --abbrev-ref HEAD`, which is `fatal:` on an unborn branch — a clean `git status --porcelain`, `git pull --ff-only`, CI green ASSERTED IN CODE from `gh run list --branch main --limit 1 --json headSha,status,conclusion` (an ARRAY, completed + success AND `headSha` equal to the commit the release is cut from — a green run for some OTHER commit proves nothing; on the RESUME run that commit is `HEAD~1`, since HEAD is then the local, deliberately UNPUSHED `release: v<V>` commit no CI run can ever name), then `adapter.preflight`); **version** (`git describe --tags --abbrev=0 --match v*`, whose exit 128 means a FIRST RELEASE for both "no tags" and "no matching tags" — its `fatal:` stderr is captured and dropped; the tag re-validated with `rev-parse --verify`; `git rev-list --count <tag>..HEAD == 0` → `nothing to release`; per-commit classification from ONE `git log <range> -z --pretty=format:%H%x00%s%x00%B` chunked by 3 with NO trailing NUL, rules: `!` before the `:` or a BREAKING CHANGE body → major, any `feat` → minor, else patch); **bump** (each manifest's indentation MEASURED from its own first indented line and only the version VALUE rewritten — the v0.5.1 field bug was a hardcoded `"\t"` that reformatted every 2-space manifest and turned CI red; the primary manifest is `release.dir ?? "."` + `/package.json` unless the adapter overrides it, plus every `release.lockstep[]` entry as a repo-relative FILE path — non-JSON lockstep files are left to the adapter's `bumpExtras` (which runs here, before the gate) and reported on the phase note, never silently skipped; then the gate runs IN-PROCESS over the BUMPED tree (`check([], root, {}, true)`, never a `dobby` subprocess) and a red gate restores the manifests and exits with the gate's own code and its FULL findings; finally `git add -u` + `git commit -m "release: v<V>"`, and NOTHING is pushed); **changelog** (the commits grouped by `release.surfaces` name→GLOB when configured — a commit that spans surfaces is listed under EACH — else by type: Breaking changes / Features / Fixes / Other); **publish** (`adapter.packGate` → `adapter.publish` → `git tag v<V>` → `git push origin main v<V>` → `gh release create v<V> --title v<V> --notes-file <f>` → `adapter.postRelease` → `adapter.smoke`). A RE-RUN recognizes the local `release: v<V>` commit (HEAD subject + the manifest agreeing + NO `v<V>` tag yet) and skips re-bumping. `node:*` only (ADR-0008). - `src/release-npm.ts` (+ `src/release-npm.test.ts`) — the **npm release TARGET**: the `ReleaseAdapter` behind `release.type: "npm"`, and the home of every npm-specific field scar. Four moments, each spawned through the runner with the cwd pinned to the RELEASE DIR (`context.dir` = `release.dir ?? "."` resolved against the workroot — publishing the workroot ships the wrong tree), each answering DATA and never throwing. **preflight** — `npm whoami`; a failure refuses in npm's own words, and the comment records the thing whoami CANNOT prove: an interactive-login token authenticates and then fails at publish with `EOTP` (the account's 2FA wants a per-publish OTP), so the working setup is a GRANULAR access token with write access in `~/.npmrc` (field-proven on v0.1.0). **packGate** — `bun pm pack --dry-run --ignore-scripts` (nothing written, no lifecycle script run as a side effect of INSPECTING a package), its `packed <size> <path>` listing parsed and matched against the DENY globs `**/*.test.ts`, `**/__fixtures__/**`, `dist/**` (GLOBS, never substrings — `src/latest.ts`, `src/fixtures/`, a root `distribute.ts` must all ship, and `dist/**` is rooted at the PACKAGE root so a `src/dist/` source directory ships while `**/` matches zero directories so a ROOT-level `index.test.ts` is caught); ANY hit refuses and names EVERY denied file, and a listing the gate parses NO files out of refuses too — quoting the packer's own stdout back, because zero `packed` lines means either an allowlist that ships nothing or a listing shape that drifted, and those have opposite fixes. **publish** — `npm publish --access public` (a scoped package defaults to restricted), PLAIN npm and never `bun publish` (bun 1.3.x ignores `~/.npmrc`'s `_authToken` and dies with "missing authentication" — field-hit on v0.1.0; this module spawns `bun` for the pack dry run alone); an `EOTP` in the output comes back with the granular-token fix, every other failure with npm's own words. **smoke** — `npm view <name> version` polled until the registry serves `context.version` (propagation can lag MINUTES on a first publish: a 404 right after `+ pkg@<V>` printed is NOT a failure, only an exhausted budget is; the budget is 15 polls × 20s by default and INJECTABLE via `createNpmAdapter({attempts, delayMs})` — the module's second export, which exists so tests need not wait), then the optional `release.smoke` argv (an ARRAY, never a shell string), the only step that proves the published ARTIFACT works. The package name is read from the release dir's own `package.json`. The adapter is registered by the SPINE (`registerReleaseAdapter("npm", npmAdapter)` in release.ts) rather than self-registering: a side-effect import of a self-registering target evaluates the target BEFORE the spine's registry const exists (a TDZ `ReferenceError` at import time, verified), while this direction leaves the target importing only TYPES — no runtime cycle. `node:*` only (ADR-0008). - `src/release-cask.ts` (+ `src/release-cask.test.ts`) — the **homebrew-cask release TARGET**: the `ReleaseAdapter` behind `release.type: "homebrew-cask"`, for a Tauri macOS app shipped through a Homebrew tap. It uses SIX of the seam's moments (the four every target has, plus BOTH optional hooks). **preflight** — `<dir>/src-tauri/tauri.conf.json` exists (else this is not a Tauri app), `rustup target list --installed` carries BOTH `aarch64-apple-darwin` and `x86_64-apple-darwin` (a missing one refuses with the literal `rustup target add …` fix), `gh auth status`, and `release.tap` + `release.cask` are configured (each refusal names the missing key) — plus, ONLY when the OPTIONAL `release.notaryProfile` is set, the two one-time human setups notarization needs: `security find-identity -v -p codesigning` listing a `Developer ID Application` certificate (the tool EXITS 0 while listing none, so the verdict is its OUTPUT; the refusal names Xcode → Settings → Accounts as where the certificate is made, and records that SIGNING is `tauri.conf.json`'s `signingIdentity`, never dobby's job) and `xcrun notarytool history --keychain-profile <p>` exiting 0 (the refusal carries the one-time `xcrun notarytool store-credentials <p> --apple-id … --team-id …`). **bumpExtras** — `src-tauri/Cargo.toml`'s version, rewritten byte-surgically and SCOPED to the `[package]` section (the window from the `[package]` header to the NEXT `[section]`: `version = ` also sits at the start of a line under `[dependencies.<crate>]`, and a whole-file regex bumps a dependency instead), then `cargo check` in the crate — ANY cargo command reconciles `Cargo.lock`, whose stale version would otherwise ride along in the release commit. **packGate** — `bun tauri build --bundles app,dmg --target universal-apple-darwin`, then exactly ONE `*.dmg` under `src-tauri/target/universal-apple-darwin/release/bundle/dmg/` (zero and many are separate refusals) and `PlistBuddy -c "Print :CFBundleShortVersionString"` on the built `.app` equal to the version being released (a bundle that predates the bump would ship an app reporting the old number while the cask advertises the new one) — PlistBuddy is spawned BARE with `/usr/libexec` APPENDED to the child's PATH, never by absolute path. With a `release.notaryProfile` configured, THREE more steps run here (last, AFTER the version gate — a stale bundle must never cost an Apple round trip — and still before any tag, the last place a release can be refused for free): `xcrun notarytool submit <dmg> --keychain-profile <p> --wait` whose stdout must carry `status: Accepted` (the tool exits 0 on a REJECTED submission, and a refusal quotes its FULL log, never truncated), `xcrun stapler staple <dmg>`, then `spctl -a -t open --context context:primary-signature -vv <dmg>` whose output must carry `Notarized Developer ID` (spctl exits 0 for a signed-but-un-notarized build and writes its assessment to STDERR, so the gate reads BOTH streams and matches CASE-SENSITIVELY — Gatekeeper's refusal reads `Unnotarized Developer ID`); each step gates the next, so a rejected submission is never stapled and an unstapled dmg is never assessed. **publish** — a NO-OP: the dmg is a local file until the GitHub release exists, so there is nothing this target could half-publish. **postRelease** — `gh release upload v<V> <dmg>`, `shasum -a 256` on that same file, then the TAP: `gh repo clone <tap>` into a mkdtemp dir (a failed clone is the probe — `gh repo create <tap> --public` then clone again), the cask's `version` + `sha256` lines replaced in place (indentation captured, never assumed) or the whole file SCAFFOLDED from the module's template when the tap carries none (its `url` templates Homebrew's `#{version}` and SANITIZES the asset name the way GitHub serves it — spaces become dots — so later releases only ever move two lines), `ruby -c` before the commit (an absent ruby is a NOTE, a rejection is a refusal), then `git add` + `git commit -m "<cask> <V>"` + `git push -u origin HEAD` in the tap checkout. **smoke** — REPORT-ONLY: it runs NOTHING and hands back `brew install --cask <tap-short>/<cask>` (Homebrew's own rule: `<user>/homebrew-<name>` is referred to as `<user>/<name>`), plus — CONDITIONALLY, only when nothing was notarized — the quarantine caveat (`xattr -dr com.apple.quarantine …`), which next to a notarized build would simply be a lie. **The credentials are keychain-only**: `notaryProfile` is the NAME of a notarytool keychain profile and the only credential fact dobby ever holds; there is deliberately NO `APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` env-var path (mad-eye ADR 0005 — an app-specific password in the environment is inherited by every child, shell history and CI log). `security`, `xcrun` and `spctl` are spawned BARE like the rest, which is also what keeps them stubbable in tests. Registered by the SPINE like the npm one (`registerReleaseAdapter("homebrew-cask", caskAdapter)` in release.ts), so this module imports only TYPES from it — no runtime cycle, and the target is reachable from `run.ts`'s graph through the spine. `node:*` only (ADR-0008). @@ -81,7 +81,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `build-plan [--file <doc>] [--task <task.json>] [--json]` → the task-dependency plan for `/dobby:execute` (and, with `--task`, for `/dobby:dispatch`). Default source: the `## Spec` body of `<workroot>/STATE.md` (`--file` overrides the document), whose task table is located by its HEADER ROW — `#`/`Task`/`Depends on`/`Affected areas`/`Verify recipe`, with `Description`, `Test-first` and `Destructive` all OPTIONAL (no Description → the title is the task's `spec`; an absent flag column → false) — so a spec written before the `### ` sub-heading format still plans, and a non-task table inside the spec is skipped. `--task <file>` reads ONE ad-hoc task from JSON instead and never touches STATE.md (it carries the surface the spec named `--task-file`, which the dispatcher's flag set has no option for). Answers `{tasks[{id,title,spec,decisions,constraints,areas[],verifyRecipe,testFirst,destructive,dependsOn[]}], hasTestSuite{value,specSays,disagreement}, manualVerifySetup: "none"|string[], preconditions{ok,missing[{taskId,field}],danglingDeps[{taskId,dependsOn}],cycles[[id…]]}, workRoot}` — `tasks` VERBATIM for the Architect to dispatch directly (`decisions`/`constraints` empty by contract, `devUrl` merged by the coordinator). There is NO wave/batch grouping in the payload: `dependsOn` (the row's `Depends on` ids — `[]` for `—`/empty) is the ONLY thing that says who a task waits for, and a task is ready the moment every id it names has reached `done` — which is what lets the caller SKIP a task whose blocker never passed without holding back anything independent of it. Ids are STRINGS, the same ones `dependsOn` references. Failing preconditions exit 1 **with the payload still on stdout** (the refusal names each task and cell on stderr); a missing document / unparseable `--task` file / table-less spec is a hard error with no payload. Fails hard outside a git repo. - `ship [--message-file <f>] [--pr-body-file <f>] [--json]` → the COMMIT CEREMONY in ONE call, the mechanized half of `/dobby:commit`. `--message-file` is REQUIRED and validated FIRST (present, readable, non-blank; resolved against the CALLER's cwd) — a ceremony that cannot produce a message leaves the tree exactly as it found it (unstaged, un-formatted, un-gated). Then: stage when nothing is staged → the **GATE IN-PROCESS** (`check([], root, {}, fix=true)`, never a `dobby` subprocess) → `.dobby/` exclude-ensured (in `.git/info/exclude`) and the WHOLE tree re-staged (the gate judges the working tree, so committing a caller-staged SUBSET would record a green verdict for a tree that was never checked) → the gate cache → `git commit -F` → push pinned to ORIGIN (`-u origin HEAD` when the branch tracks nothing; a non-origin upstream still pushes to origin, reported via `pushNote`) → the pull request. A detached HEAD is refused before any mutation. **The exit code decides**: a nonzero gate returns the gate's OWN code with every finding printed WHOLE (uncapped, never `formatCheck`'s 50-per-tool sample) and commits nothing. The PR is opened ONLY off a NON-TRUNK branch (`main`/`master` have nowhere to open one from) and ONLY with a `--pr-body-file` (the body is the caller's to author); an EXISTING PR for the branch is reported, not duplicated; a failed push short-circuits it, and a PR gh could not open is a NOTE, not a failure (the commit already landed). Answers `{cacheNote, cacheWritten, committed, gateExitCode, gateNote, prNote, prUrl, pushNote, pushed, sha}` — the notes distinguish "skipped by policy" from "could not be done", and `gateNote` carries the `gate skipped: inputs unchanged since last green (…)` line when the in-process gate was served from the per-check cache (null when it really ran). Fails hard outside a git repo. - `review fetch [--pr N] [--json]` · `review apply (--plan <f>|--stdin) [--pr N] [--dry-run] [--json]` · `pr watch [--pr N] [--deadline <sec>] [--await-review] [--json]` → the `gh` surface of the address-review stage; `--pr` defaults to the CURRENT branch's PR. `fetch` → `{pr, adapter, candidates, threads, summary}`: the open review THREADS over GraphQL (drained with gh's mandatory `$endCursor` pagination contract, each thread carrying its last comments so a re-run sees its own prior replies) plus the bot's summary comment over REST (sorted by `updated_at`, since the bot EDITS one comment in place, and bot logins matched by BARE slug because GraphQL and REST disagree about the `[bot]` suffix). A PR with nothing to address is `threads: []` at exit 0 — an ANSWER, not an error. `apply` consumes a disposition plan (`{pr, reTrigger, plan:[{threadId, disposition: fix|dismiss|outdated|defer, reply}]}`) from `--plan`/`--stdin`, replies + resolves in batches, SKIPS threads it already answered (idempotent by construction) and re-triggers when asked; `defer` deliberately does NOT resolve (a deferred finding stays open) and `--dry-run` makes the same decisions with zero writes. Any failure exits 1 with `{failures[], replied[], resolved[], retriggered, skipped[], dryRun}`. `pr watch` owns its OWN polling loop and derives the verdict from check BUCKET COUNTS (`gh pr checks --json` always exits 0, and `--watch --json` is a hard error) — `ci-failed|ci-green|ci-pending|merge-ready|feedback-present|open-unreviewed|skipped`, with `--deadline` (default 300s) budgeting EACH wait phase separately (CI, then the review under `--await-review`) so a slow CI run can never eat the review wait. NO merge path — every judgment stays in `/dobby:address-review`. All three fail hard outside a git repo, and a gh that could not report at all is surfaced, never read as an empty (green) check list. -- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch` is the repository's own trunk — `refs/remotes/origin/HEAD` resolved at `mainRoot`, stripped of its `origin/` prefix, falling back to the constant `"main"` when there is no remote head to read (never the local trunk). Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. +- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch: string | null` is the repository's own trunk, resolved at `mainRoot` by an ordered cascade, first hit wins — `refs/remotes/origin/HEAD` (stripped of its `origin/` prefix), then remote-tracking `origin/main`/`origin/master`, then local `main`/`master`, else `null` when nothing in the repo names a trunk. Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. - `repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>` → the red/green capture harness. Everything after `--` is the command, spawned with cwd pinned to the workroot (fails hard outside a git repo) and its stdout/stderr captured WHOLE — repro never truncates. One run answers `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` = red on any nonzero exit, `matched` = `verdict === --expect` (explicitly `null`, never absent, without `--expect`). Exit code: **1 ONLY on a mismatch** (the "your loop is not red-capable" signal); a failing command with NO `--expect` exits 0 — the harness reports an exit code, it never inherits one. `reproId` is a short hash of the workroot + the command argv ONLY (repro's own flags are excluded, so every run of one loop keys the same record), and each run persists `{baseline, latest, reproId}` at `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured); a failed write is a stderr warning, not a failure. `--repeat N` runs the loop N times sequentially and ADDS `{runs, redCount, greenCount, reproductionRate (redCount/runs), deterministic, firstDivergent:{run (1-based), stdout, stderr}|null, durations}` — the base record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), so `--expect red --repeat N` is a red-CAPABILITY probe and the pasted output is the failing run's. `--bench` ADDS `{samples, min, median (over sorted samples), mean, max}` plus `baseline` (the previous bench of this reproId, `null` on the first) and `delta` (current MINUS baseline per timing stat — faster reads negative), then stores its own stats as the next baseline; a non-bench run leaves the stored baseline alone. `--json` prints the full payload as the sole stdout; the default render is a compact human summary (headline + `cmd:`/`cwd:` + the repeat/bench lines + the record path) with a labelled TAIL of the output. - `kb list --kind <k> | kb record --kind <k> --concept <kebab> --title <t> --reason-file <f> --entry <line>` → the durable knowledge bases at `<workroot>/docs/out-of-scope/` and `<workroot>/docs/learn-discarded/` (fails hard outside a git repo). `--kind` is REQUIRED for both and is the module's only parameter; an unknown one is a hard error naming both KBs (a typo must never read as "that KB is empty" — dedup would silently stop working). `list` → a bare JSON ARRAY (under `--json`) of `{concept (filename stem), path, title (the H1), statement (the first paragraph, wrapped lines joined), priorEntries (the `- ` bullets under the kind's prior-section, markers stripped)}`, one per `*.md`, sorted by filename; an ABSENT directory is `[]` at exit 0, never an error. `record` → ONE file per concept: an existing concept's file gets the entry APPENDED as a bullet under its prior-section (every byte before that heading untouched — the rationale written the first time wins over this call's `--title`/`--reason-file`), an absent one is created after a lazy `mkdir`, carrying the canonical skeleton (H1, the one-line statement, the kind's why-heading + the reason body, the kind's prior-heading + the first bullet). `--reason-file` is split at its FIRST LINE (the statement) with the REST as the reason body; a file with no body is refused. Answers the bare `{path, created, appended}`. Every refusal goes to stderr with exit 1 (no `ok` envelope — the payloads are the spec's bare shapes). - `adr new "<title>" [--status proposed|accepted|deprecated]` → allocate the next ADR number and create `<workroot>/docs/adr/NNNN-<slug>.md` (fails hard outside a git repo; the dir is created lazily, and only after the inputs validate — a refusal never leaves an empty `docs/adr/` behind). The title is a POSITIONAL (every positional after the token, joined); the slug is DERIVED from it. Numbering is `max + 1` over the local directory AND `git ls-tree -r origin/HEAD --name-only -- docs/adr` (a sibling worktree's ADR is pushed long before it lands here; no origin / no git / no upstream `docs/adr` all score 0, so a remote-less repo still files ADRs), and the number is claimed — not merely the filename: each attempt re-reads `docs/adr/` and moves to the next number if anything already carries the `NNNN-` prefix, whatever its slug, with `O_EXCL` (`flag: "wx"`) behind it so an `EEXIST` retries instead of truncating an identically-named ADR. The scan is a read and is stale the instant it returns, so the claim happens at WRITE time, never at scan time. Writes a SKELETON only — `# NNNN. <title>`, the optional `**Status:** <status>` line (omitted without `--status`), and a placeholder paragraph; body authorship stays with the architect. Answers the bare `{number, slug, path}`; a missing title / an unknown status is a refusal on stderr with exit 1, naming what IS valid. diff --git a/cli/README.md b/cli/README.md index 7827a35..34f1523 100644 --- a/cli/README.md +++ b/cli/README.md @@ -452,7 +452,7 @@ dobby migrate preflight --json dobby migrate verify --json ``` -`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch`, resolved from `origin/HEAD` at `mainRoot`, falling back to `"main"` when there is no remote head), and whether the branch is safe to delete. +`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `origin/HEAD`, then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one), and whether the branch is safe to delete. `dobby migrate preflight` says whether a repo still needs the config migration (naming each legacy signal and snapshotting what the migration must carry across); `dobby migrate verify` runs the gate in-process and reports the environment read back plus whatever was left behind. Both **exit 0 with a payload for every verdict** — they inform, they never refuse. diff --git a/cli/src/preflight.test.ts b/cli/src/preflight.test.ts index 66fd6b1..7f45b2f 100644 --- a/cli/src/preflight.test.ts +++ b/cli/src/preflight.test.ts @@ -266,7 +266,7 @@ afterAll(() => { interface FinishPreflight { branch: string; branchDeleteSafe: boolean; - defaultBranch: string; + defaultBranch: string | null; dirty: { count: number; files: string[] }; dobbyInstalled: boolean; inWorktree: boolean; @@ -876,14 +876,25 @@ describe("finish --preflight — bare worktree, installed main checkout", () => // =========================================================================== // Slice 13 — `defaultBranch`. The finish skill switches to the trunk before it // deletes a goal branch, and `main` is an assumption, not a fact: plenty of repos -// still trunk on `master`, and some on a house name entirely. The answer is the -// repository's OWN remote head (`refs/remotes/origin/HEAD` → `origin/<name>`), -// with `"main"` as the fallback when there is no remote head to read. +// still trunk on `master`, and some on a house name entirely. The answer is +// resolved AT THE MAIN CHECKOUT by an ordered cascade, FIRST HIT WINS: +// 1. the repository's own remote head — `refs/remotes/origin/HEAD` → +// `origin/<name>`, the `origin/` prefix stripped; +// 2. failing that, the remote-tracking refs: `origin/main` → `"main"`, else +// `origin/master` → `"master"`; +// 3. failing that (no remote refs at all), the LOCAL branches: `main` → +// `"main"`, else `master` → `"master"`; +// 4. failing all of it, `null` — the preflight ADMITS it does not know rather +// than guessing a trunk the finish skill would then try to switch to. +// The verdict never depends on the answer: an unknown trunk is a fact reported, +// not a reason to block or ask for confirmation. // // Every expected value is a name WE chose and wrote into the fixture with plain -// git (`--initial-branch` + `git remote set-head`), never a name read back the way -// the code reads it — and each fixture stands on a goal branch of a DIFFERENT -// name, so a preflight that echoed the current branch fails these outright. +// git (`--initial-branch`, `git push <local>:<remote>`, `git remote set-head`), +// never a name read back the way the code reads it — and each fixture stands on a +// goal branch of a DIFFERENT name, so a preflight that echoed the current branch +// fails these outright. `null` / `unknown` are the spec's own literals for the +// last step. // =========================================================================== // A main checkout whose trunk is `trunk`, published to a throwaway BARE origin @@ -907,27 +918,95 @@ function makeRemoteHeadCheckout(trunk: string): string { return mainRoot; } +// Whether the repo carries a remote head at all — git's own answer to the very +// question step 1 asks. The step-2 fixtures assert this is FALSE before they +// assert anything about the cascade, so a passing case can never be step 1 in +// disguise. +function hasRemoteHead(root: string): boolean { + try { + gitIn(root, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]); + return true; + } catch { + return false; + } +} + +// Leave the repo with NO remote head, whatever git built the fixture: recent git +// may materialize `refs/remotes/origin/HEAD` on a plain `fetch`, and a fixture +// meant to exercise the cascade's SECOND step must not carry one. A repo that +// never had one throws here — same end state, so the throw is swallowed. +function clearRemoteHead(root: string): void { + try { + gitIn(root, ["symbolic-ref", "--delete", "refs/remotes/origin/HEAD"]); + } catch { + // no remote head to clear — already the state we want + } +} + +// A main checkout PUBLISHED to a throwaway bare origin under a remote branch name +// of our choosing and with NO remote head, then left standing on `goal`: the shape +// that makes the cascade's second step observable. `pushAs` defaults to the local +// name; passing a DIFFERENT one is what makes the remote and local answers +// disagree, so the step that answered is visible in the result. +function makePushedCheckout(opts: { local: string; pushAs?: string }): string { + const mainRoot = makeMainCheckout({ + branch: opts.local, + config: true, + dobby: true, + }); + const bare = realpathSync( + mkdtempSync(join(tmpdir(), "dobby-preflight-pushed-")) + ); + scratchDirs.push(bare); + gitIn(bare, ["init", "-q", "--bare"]); + gitIn(mainRoot, ["remote", "add", "origin", bare]); + gitIn(mainRoot, [ + "push", + "-q", + "origin", + `${opts.local}:${opts.pushAs ?? opts.local}`, + ]); + gitIn(mainRoot, ["fetch", "-q", "origin"]); + clearRemoteHead(mainRoot); + gitIn(mainRoot, ["switch", "-q", "-c", "goal"]); + return mainRoot; +} + +// A main checkout with NO remote at all, born on `trunk` and left standing on +// `goal` — the cascade's third and fourth steps, where the only branch names in +// the repository are LOCAL ones. +function makeLocalOnlyCheckout(trunk: string): string { + const mainRoot = makeMainCheckout({ + branch: trunk, + config: true, + dobby: true, + }); + gitIn(mainRoot, ["switch", "-q", "-c", "goal"]); + return mainRoot; +} + // Text mode prints the same fact in whatever prose the CLI likes: `defaultBranch`, // `default branch`, `default-branch` all carry it. const DEFAULT_BRANCH_LABEL = /default.?branch/i; -describe("finish --preflight — the repository's default branch", () => { +// The unknown trunk has to reach a human reader as ONE statement — the label and +// the word `unknown` together on one line, not two substrings that merely both +// occur somewhere in the report. Column padding between them is presentational. +const DEFAULT_BRANCH_UNKNOWN = /default.?branch:?[ \t]*unknown/i; + +describe("finish --preflight — the default branch from the remote head", () => { let masterTrunk: string; let houseTrunk: string; - let noRemote: string; + let houseTrunkOverLocalMain: string; beforeAll(() => { masterTrunk = makeRemoteHeadCheckout("master"); houseTrunk = makeRemoteHeadCheckout("trunk"); - // Born on `develop` and never pushed anywhere: there is no remote head to - // read, and the LOCAL trunk is deliberately not `main` — so the fallback can - // only be answered by the spec's constant, never by reading this repo. - noRemote = makeMainCheckout({ - branch: "develop", - config: true, - dobby: true, - }); - gitIn(noRemote, ["switch", "-q", "-c", "goal"]); + // The remote head says `trunk`, and a local `main` sits right next to it — + // the two conventional-name steps below would answer `main`. Only the ORDER + // of the cascade decides which of the two names comes back. + houseTrunkOverLocalMain = makeRemoteHeadCheckout("trunk"); + gitIn(houseTrunkOverLocalMain, ["branch", "main"]); }); it("reports master when the remote head points at master", async () => { @@ -948,9 +1027,9 @@ describe("finish --preflight — the repository's default branch", () => { }).toEqual({ branch: "goal", defaultBranch: "trunk" }); }); - it("falls back to main when there is no remote head to read", async () => { - const preflight = await finishPreflight(noRemote); - expect(preflight.defaultBranch).toBe("main"); + it("prefers the remote head over a local branch of a conventional name", async () => { + const preflight = await finishPreflight(houseTrunkOverLocalMain); + expect(preflight.defaultBranch).toBe("trunk"); }); it("prints the default branch for a human reader too", async () => { @@ -960,6 +1039,97 @@ describe("finish --preflight — the repository's default branch", () => { }); }); +describe("finish --preflight — the default branch from the remote branches", () => { + let pushedMaster: string; + let pushedMain: string; + let pushedAsMaster: string; + + beforeAll(() => { + pushedMaster = makePushedCheckout({ local: "master" }); + pushedMain = makePushedCheckout({ local: "main" }); + // Published as `master` from a local `main`: the remote knows one trunk name + // and the working copy another, so the answer names WHICH ref the cascade + // read. Local-branches-first — or no remote step at all — answers `main`. + pushedAsMaster = makePushedCheckout({ local: "main", pushAs: "master" }); + }); + + it("reports master from the remote branches when no remote head is set", async () => { + expect( + hasRemoteHead(pushedMaster), + "fixture must leave origin/HEAD unset" + ).toBe(false); + const preflight = await finishPreflight(pushedMaster); + expect(preflight.defaultBranch).toBe("master"); + }); + + it("reports main from the remote branches when no remote head is set", async () => { + expect( + hasRemoteHead(pushedMain), + "fixture must leave origin/HEAD unset" + ).toBe(false); + const preflight = await finishPreflight(pushedMain); + expect(preflight.defaultBranch).toBe("main"); + }); + + it("prefers the remote branch over a local branch of a conventional name", async () => { + expect( + hasRemoteHead(pushedAsMaster), + "fixture must leave origin/HEAD unset" + ).toBe(false); + const preflight = await finishPreflight(pushedAsMaster); + expect(preflight.defaultBranch).toBe("master"); + }); +}); + +describe("finish --preflight — the default branch from the local branches", () => { + let localMaster: string; + let localMain: string; + + beforeAll(() => { + localMaster = makeLocalOnlyCheckout("master"); + localMain = makeLocalOnlyCheckout("main"); + }); + + it("reports master from the local branches when the repo has no remote", async () => { + const preflight = await finishPreflight(localMaster); + expect(preflight.defaultBranch).toBe("master"); + }); + + it("reports main from the local branches when the repo has no remote", async () => { + const preflight = await finishPreflight(localMain); + expect(preflight.defaultBranch).toBe("main"); + }); +}); + +describe("finish --preflight — a default branch nothing in the repo names", () => { + let noTrunkNames: string; + + beforeAll(() => { + // Born on `develop` and never pushed anywhere: no remote head, no remote + // branches, and NEITHER `main` NOR `master` locally. There is no trunk to + // find, so the only honest answer is that there is none. + noTrunkNames = makeLocalOnlyCheckout("develop"); + }); + + it("admits it does not know the trunk rather than guessing main", async () => { + const preflight = await finishPreflight(noTrunkNames); + expect(preflight.defaultBranch).toBe(null); + }); + + it("still verdicts the close safe, the trunk being no part of the verdict", async () => { + const preflight = await finishPreflight(noTrunkNames); + expect({ + defaultBranch: preflight.defaultBranch, + verdict: preflight.verdict, + }).toEqual({ defaultBranch: null, verdict: "safe" }); + }); + + it("tells a human reader the default branch is unknown", async () => { + const result = await run(["finish", "--preflight"], noTrunkNames); + expect(result.stdout).toMatch(DEFAULT_BRANCH_UNKNOWN); + }); +}); + // =========================================================================== // Slice 14 — the payload's field list is EXACT: the ten fields the finish skill // already reads plus `defaultBranch`, and nothing else. Pinned as a whole set diff --git a/cli/src/preflight.ts b/cli/src/preflight.ts index 0e8a1f9..bf39539 100644 --- a/cli/src/preflight.ts +++ b/cli/src/preflight.ts @@ -67,10 +67,12 @@ type Verdict = "blocked" | "confirm-required" | "safe"; interface FinishPreflight { branch: string; branchDeleteSafe: boolean; - // The repository's own trunk — `refs/remotes/origin/HEAD`, resolved at - // `mainRoot` — never the goal branch the session stands on. Falls back to the - // constant `"main"` when there is no remote head to read. - defaultBranch: string; + // The repository's own trunk — resolved AT `mainRoot`, never the goal branch + // the session stands on — by an ordered cascade, first hit wins: the remote + // head, then the remote-tracking `main`/`master`, then the LOCAL `main`/ + // `master`, else `null` when nothing in the repo names one. `null` is an + // honest "I don't know", never a guess. + defaultBranch: string | null; dirty: DirtyTree; dobbyInstalled: boolean; // True iff the session's workroot is a LINKED worktree (git's own definition, @@ -127,27 +129,56 @@ function currentBranch(root: string): string { return result.status === 0 && name !== "" && name !== "HEAD" ? name : "HEAD"; } -// The repository's own trunk — the fallback the finish skill switches to -// before it force-deletes a goal branch. `"main"` is an assumption, not a -// fact, so this reads the repo's own remote head (`refs/remotes/origin/HEAD`, -// which `git symbolic-ref` answers as `origin/<name>`) and strips the -// `origin/` prefix. Any failure — no remote, no remote head set — falls back -// to the constant below, never to the LOCAL trunk (which may not exist, or -// may not even be the repo's real trunk). -const DEFAULT_BRANCH_FALLBACK = "main"; +// The repository's own trunk — the branch the finish skill switches to before +// it force-deletes a goal branch. `"main"` is an assumption, not a fact, so +// this reads the repo itself through an ORDERED cascade, first hit wins: +// 1. `refs/remotes/origin/HEAD` (`git symbolic-ref` answers `origin/<name>`; +// the `origin/` prefix is stripped); +// 2. failing that, the remote-tracking refs: `origin/main`, else +// `origin/master`; +// 3. failing that (no remote refs at all), the LOCAL branches: `main`, else +// `master`; +// 4. failing all of it, `null` — an honest "I don't know" rather than a +// guessed `"main"` the finish skill would then try to switch to. +// Remote names are checked BEFORE local ones at every tier: a repo whose local +// `main` was pushed under a different remote name must answer with what the +// REMOTE actually calls trunk, not the local convention. const REMOTE_HEAD_PREFIX = "origin/"; +const CONVENTIONAL_TRUNK_NAMES = ["main", "master"] as const; -function defaultBranchAt(root: string): string { - const result = runCapture( +function defaultBranchAt(root: string): string | null { + const head = runCapture( "git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { root } ); - const ref = result.stdout.trim(); - if (result.status !== 0 || !ref.startsWith(REMOTE_HEAD_PREFIX)) { - return DEFAULT_BRANCH_FALLBACK; + const ref = head.stdout.trim(); + if (head.status === 0 && ref.startsWith(REMOTE_HEAD_PREFIX)) { + return ref.slice(REMOTE_HEAD_PREFIX.length); + } + + for (const name of CONVENTIONAL_TRUNK_NAMES) { + if (refExists(root, `refs/remotes/origin/${name}`)) { + return name; + } } - return ref.slice(REMOTE_HEAD_PREFIX.length); + + for (const name of CONVENTIONAL_TRUNK_NAMES) { + if (refExists(root, `refs/heads/${name}`)) { + return name; + } + } + + return null; +} + +// Whether `ref` exists in `root`'s repository — `git show-ref --verify` reads +// a fully-qualified ref name and exits nonzero when it does not resolve. +function refExists(root: string, ref: string): boolean { + const result = runCapture("git", ["show-ref", "--verify", "--quiet", ref], { + root, + }); + return result.status === 0; } // --------------------------------------------------------------------------- @@ -327,7 +358,7 @@ function formatFinishText(payload: FinishPreflight): string { `verdict: ${payload.verdict}`, `inWorktree: ${payload.inWorktree}`, `branch: ${payload.branch}`, - `defaultBranch: ${payload.defaultBranch}`, + `defaultBranch: ${payload.defaultBranch ?? "unknown"}`, `worktreePath: ${payload.worktreePath ?? "-"}`, `mainRoot: ${payload.mainRoot}`, `pr: ${pr}`, diff --git a/plugin/skills/finish/SKILL.md b/plugin/skills/finish/SKILL.md index 77f9ce4..c4bcfe0 100644 --- a/plugin/skills/finish/SKILL.md +++ b/plugin/skills/finish/SKILL.md @@ -15,7 +15,7 @@ The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/ bunx dobby finish --preflight --json ``` -One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch` — Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: +One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch: string | null` — resolved by an ordered cascade: remote head, then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one; Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: - **`blocked`** — `dobbyInstalled: false`: `dobby down` is the mandatory pre-removal teardown and has no fallback. **STOP** and point the user at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract). This is the ONLY blocking condition. - **`safe`** — a MERGED PR and a clean tree. Proceed to Step 2 without a prompt. @@ -71,12 +71,22 @@ Branch on the preflight's `inWorktree`. git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch ``` -- **`inWorktree: false`** — a plain checkout has no worktree to remove; return to the default branch and delete the goal's branch: +- **`inWorktree: false`** — a plain checkout has no worktree to remove; read `defaultBranch` before switching: + - **a string** — return to it and delete the goal's branch: - ```bash - git switch <defaultBranch> - git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch - ``` + ```bash + git switch <defaultBranch> + git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch + ``` + - **`null`** — do NOT guess: the preflight could not determine the trunk (`origin/HEAD` is unset and neither `main` nor `master` exists). STOP the teardown here — no `AskUserQuestion`, just a plain-text note naming exactly what the operator runs once they know the trunk name: + + ``` + git switch <trunk> + git branch -D <branch> + git pull + ``` + + End the stage there: the PR is merged and the run is torn down — nothing here is half-way, only the branch cleanup is left for the operator to finish by hand. `-D` is deliberate in both cases: `branchDeleteSafe: true` IS the safe-to-delete signal. When it is false, the only thing authorizing the delete is the user's explicit "destroy anyway" from Step 1 — carry that acceptance forward, and if they cancelled, nothing here runs at all. @@ -88,7 +98,7 @@ Bring `mainRoot` up to date with the merge: git pull # on mainRoot ``` -On a plain checkout, Step 3 already switched `mainRoot` onto `defaultBranch`, so this pulls that branch. Inside a linked worktree, `mainRoot` was never switched — this pulls whatever branch the main checkout already has checked out. +On a plain checkout where Step 3 switched `mainRoot` onto a known `defaultBranch`, this pulls that branch. When Step 3 stopped for an unknown `defaultBranch` (`null`), there was no switch to follow — Step 4 does not run; the operator's own `git pull`, named in Step 3's note, closes it once they've picked a trunk. Inside a linked worktree, `mainRoot` was never switched — this pulls whatever branch the main checkout already has checked out. On a conflict or divergence (the pull doesn't fast-forward cleanly), **report it and stop — never force.** Show what git said and let the user reconcile; `/dobby:finish` does not rebase, reset, or force-pull. @@ -114,6 +124,6 @@ Interact with the user in their language. Write any note you persist in English; - [ ] The PR merged ONLY on the user's explicit "Merge & finish" selection, and only after `bunx dobby pr watch [--adapter <selected id>] --await-review --deadline 60 --json` answered `merge-ready` with commit-scoped evidence (multi-adapter ambiguity selected mechanically; every required adapter validated the SAME `pr.headRefOid`, with the whole set restarted on mismatch; Greptile: passing review check AND `summary.reviewedHeadOid == pr.headRefOid`; CodeRabbit: passing current-commit review check; stale/missing evidence remained `open-unreviewed`, never review-by-silence); any other verdict reported and NOT merged, `feedback-present` routed to `/dobby:address-review`; squash merge pinned to the common validated SHA (`gh pr merge <pr.url> --match-head-commit <pr.headRefOid> --squash`) - [ ] After the merge, the preflight re-run (same cwd) and read as MERGED / `safe` before Step 2 — never assumed - [ ] `bunx dobby down --json` run before removal, from the workroot the session stands in; kills the detached run, deletes the Neon branch, runs `teardown[]` extras; `ok`/`reason` read and any `instructions[]` (`stop`) carried out to close the now-empty kit panes; a no-app project no-ops cleanly; a reported failure surfaced for the user's call, not auto-forced -- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → `git switch <defaultBranch>` then `git branch -D <branch>` — `-D` in every case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal -- [ ] `git pull` on `mainRoot` — the branch Step 3 left it on (`defaultBranch` on a plain checkout, unchanged inside a linked worktree); on conflict/divergence reported and stopped — never forced +- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → read `defaultBranch`: a string → `git switch <defaultBranch>` then `git branch -D <branch>`; `null` → STOPPED with a plain-text note (no AskUserQuestion) naming `git switch <trunk>` / `git branch -D <branch>` / `git pull` for the operator, ending the stage with the PR merged and the run torn down — `-D` in every completed case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal +- [ ] `git pull` on `mainRoot` run ONLY when a switch happened — the branch Step 3 left it on (`defaultBranch` on a plain checkout, unchanged inside a linked worktree); skipped when Step 3 stopped for a `null` `defaultBranch`; on conflict/divergence reported and stopped — never forced - [ ] Ended with an AskUserQuestion gate (goal closed; start the next goal via `/dobby:scope` recommended, or stop here); `/dobby:scope` invoked through the Skill tool on selection From 9c57936ca36bbf8b8adf899558b681771b412585 Mon Sep 17 00:00:00 2001 From: Kevin Wolf <hi@kvnwolf.com> Date: Thu, 3 Sep 2026 21:33:28 -0600 Subject: [PATCH 4/6] fix(kit): a stale origin/HEAD is not a default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3 on #54. The cascade that resolves `defaultBranch` proved its fallback refs exist but trusted the `origin/HEAD` symbolic target as-is. Nothing refreshes that ref: after a remote renames its trunk (the `master` → `main` migration) and a pruning fetch, `origin/HEAD` can still point at `refs/remotes/origin/master`, which is gone — and finish would switch into nothing, or into a stale local `master`. The symbolic head now counts only when the remote-tracking ref it names still exists; otherwise the cascade continues through `origin/main` / `origin/master`, the local pair, and `null`. Every step proves the ref it answers with. A symbolic pointer is a hint, not a fact. --- cli/CONTEXT.md | 4 +- cli/README.md | 2 +- cli/src/preflight.test.ts | 176 ++++++++++++++++++++++++++++++++++ cli/src/preflight.ts | 17 +++- plugin/skills/finish/SKILL.md | 2 +- 5 files changed, 195 insertions(+), 6 deletions(-) diff --git a/cli/CONTEXT.md b/cli/CONTEXT.md index b99b4ea..90b3668 100644 --- a/cli/CONTEXT.md +++ b/cli/CONTEXT.md @@ -32,7 +32,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `src/buildplan.ts` (+ `src/buildplan.test.ts`) — the **build plan**, derived MECHANICALLY from the spec's task table (`dobby build-plan [--file <doc>] [--task <task.json>] [--json]`), a domain module behind the `command.ts` contract. It replaces the per-session judgment call the coordinator used to make over a markdown grid, and emits ONE payload: `tasks[]` — the per-task instruction data VERBATIM (`{id, title, spec, decisions, constraints, areas[], verifyRecipe, testFirst}` + `destructive` + `dependsOn[]`), the exact shape `plugin/skills/execute/references/build-protocol.md` consumes, with `decisions`/`constraints` deliberately EMPTY (plan-level decisions stay coordinator-distributed) and `devUrl` deliberately ABSENT (the coordinator merges it), and `dependsOn` carrying the row's `Depends on` ids VERBATIM (`—`/empty → `[]`) — the ONLY thing that says WHO a task waits for, now that there is no batch grouping saying WHEN it runs: a task is ready the moment every id in its own `dependsOn` has reached `done`, which is what lets the Architect skip a task whose dependency ended needs-human without touching anything independent of it; `preconditions` — `{missing[{taskId,field}], danglingDeps[{taskId,dependsOn}], cycles[[ids]], ok}`, where not-ok exits 1 **with the payload still on stdout** (the `up --json` convention: the verdict fields ARE the fix list); plus the two gates `/dobby:execute` reads before launching — `hasTestSuite` (`value` from the repo's `vitest` capability, `specSays` from the Testing Decisions' test-first claim — null when the section is absent — and their `disagreement`) and `manualVerifySetup` (the `Manual verify setup:` field's steps, or `none`). PARSING IS TOLERANT BY CONTRACT: the task table is found by its HEADER ROW (never a `### Tasks` anchor — the sub-heading spec format is new and older specs must still plan), `Description`/`Test-first`/`Destructive` are each optional (absent → the title stands in as the spec, the flags read false), a non-task table inside the spec is skipped, and `—` reads as "no dependency". `--task <file>` plans ONE ad-hoc task from JSON and reads no STATE.md at all (the `/dobby:dispatch` path); it carries the ad-hoc surface the spec named `--task-file`, since the dispatcher's flag set has no such option. An ACTION command (`requireWorkroot`; the throw is folded into the failure shape). `node:*` only (ADR-0008). - `src/build-protocol.test.ts` — a GUARD with no module of its own: what it tests lives OUTSIDE the CLI, as the **dispatch protocol** document at `plugin/skills/execute/references/build-protocol.md` (the shared build-loop component `/dobby:execute`, `/dobby:dispatch`, and `/dobby:address-review` all read and follow). The protocol is prose the Architect follows directly, not a runtime the CLI can execute, so the suite reads that markdown as TEXT and pins its RULES per document section: every worker is dispatched NAMED (`dobby:test-author` / `dobby:implementor` / `dobby:qa`), `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` must stay unset, the deferred `SendMessage` tool is loaded via `ToolSearch` before first use, a task starts the moment its own dependencies are done with no fixed batch to wait on, the Exit gate is serialised to one implementor at a time, a dead task stops only its dependents while independent work keeps being dispatched (and what died is reported), each worker appends its own `dobby state append-worklog` entry and returns only a short verdict, `STATE.md` stays current enough to reconstruct progress after a compaction, and the run closes with a summary table of rounds / first-attempt success / deaths / wall clock — plus a repo-wide scan proving the rename off the OLD filenames (the protocol document and its old test harness both previously carried) left no live surface still pointing at either one. It lives here because `cli/src` is the tree vitest covers; it imports nothing from the CLI. - `src/repro.ts` (+ `src/repro.test.ts`) — the **red/green capture harness** (`dobby repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>`), a domain module behind the `command.ts` contract. Everything after `--` is the command (node's `parseArgs` drops the `--` and hands the rest through as positionals), spawned through `runner.runCapture` with cwd pinned to the workroot — so the SAME loop reproduces identically from any subdirectory, and outside a git repo it fails hard like every action command (`requireWorkroot`'s throw is CAUGHT here, since `run()` does not catch handler exceptions). One run yields `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` is red on ANY nonzero exit (a child that never started / was killed records POSIX 127 / 128 and its spawn error folded into stderr, never a silent green — the exit code is extracted by TYPE, `typeof status === "number"`, never by `!== null`: node spells "no exit code" as `null` while Bun, the runtime the `dobby` bin runs on, spells it `undefined`, and an `undefined` exitCode would be DROPPED by `JSON.stringify` out of both the payload and the persisted record), and `matched` is `verdict === --expect` — explicitly NULL (never absent) without `--expect`, because "nothing was judged" is an answer. The HARNESS judges, so the model never derives a verdict by reading output: exit 1 ONLY on a mismatch (the "your loop is not red-capable" signal); a failing command with no `--expect` still exits 0 (repro REPORTS an exit code, never inherits one). `reproId` = a 12-hex-char sha256 of the workroot + the command argv and NOTHING else (repro's own flags are deliberately out, so `--repeat`/`--bench` runs key the same record), and every run persists `{baseline, latest, reproId}` to `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured, as `up` does for its pidfile) — a write failure is a stderr WARNING, never the outcome. `--repeat N` runs sequentially and adds `{runs, redCount, greenCount, reproductionRate (red/runs), deterministic, firstDivergent:{run (1-BASED), stdout, stderr}|null, durations}`; the BASE record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), which makes `--expect red --repeat N` a red-CAPABILITY probe instead of a coin flip on run 1. `--bench` adds `{samples, min, median (over SORTED samples), mean, max}` plus `baseline` (the PREVIOUS bench of this reproId, null on the first) and `delta` (current MINUS baseline per timing stat, so faster reads negative), and stores its own stats as the next baseline — a non-bench run never clobbers it. `--json` prints the full payload; the default text render is a compact summary (verdict + reproId headline, `cmd:`/`cwd:`, the repeat/bench lines, the record path) plus a labelled TAIL of stdout/stderr — repro itself NEVER truncates what it captures or stores. `node:*` only (ADR-0008). -- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `refs/remotes/origin/HEAD`, then remote-tracking `refs/remotes/origin/main`/`master`, then local `refs/heads/main`/`master`, else `null` when nothing in the repo names one, an honest "I don't know" rather than a guessed `"main"`), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). +- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `refs/remotes/origin/HEAD` (only while the ref it names still exists — a stale/dangling head is treated exactly like an unset one and the cascade continues), then remote-tracking `refs/remotes/origin/main`/`master`, then local `refs/heads/main`/`master`, else `null` when nothing in the repo names one, an honest "I don't know" rather than a guessed `"main"`), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). - `src/release.ts` (+ `src/release.test.ts`) — the **release SPINE** (`dobby release [--bump patch|minor|major] [--notes-file <f>] [--dry-run] [--json]`), a domain module behind the `command.ts` contract and the CLI's one CONFIG-GATED command: without a `release` key in `dobby.config.json` the command does not exist (`run.ts` hides it; the spine repeats the refusal as defense in depth for every non-dispatcher caller). It owns the phases EVERY release target shares and nothing target-specific: those live behind the exported `ReleaseAdapter` seam — `{id, preflight, packGate, publish, smoke}`, each taking a `ReleaseContext` (`{currentVersion, dir, notesFile, release, root, tag, version}` — `version`/`tag` are NULL during `preflight`, which runs before the version is decided) and returning `ReleasePhaseResult` DATA, plus three OPTIONAL members: `primaryManifest(root, release)` (a target whose version does not live in `<dir>/package.json`), `bumpExtras(context, version)` (the version-carrying files the JSON-only bump cannot edit — run INSIDE the bump phase, after the manifests and BEFORE the gate and the commit, so the edit is gated and lands in the `release: v<V>` commit) and `postRelease(context)` (work that can only happen once the GitHub release exists — run after `gh release create` and before `smoke`, PAST the publish line, so a failure is reported and never rolled back). A target that needs none of them is unaffected — the npm one defines none. Adapters register themselves through `registerReleaseAdapter(type, adapter)` into a MUTABLE registry keyed by `release.type` (a `switch` would make the spine import every target; a lazy `await import()` is impossible — `run.ts` dispatches handlers synchronously), and an unregistered type is a clean error naming what IS registered. **Two-phase invocation** (the model keeps authorship of both judgements): (1) `needsDecision` — without `--bump`, a FIRST release or an inferred major while still below 1.0.0 exits 1 with `{needsDecision: "first-release"|"0x-major", context:{currentVersion, commits[]}}` having touched NOTHING, and the skill answers with `--bump`; (2) `needsNotes` — without `--notes-file` the run does everything mechanical and STOPS with `{needsNotes: true, version, changelog}` (exit 1, the bump commit kept LOCAL, nothing pushed or published), the skill authors the notes and re-runs with `--notes-file` (which must live OUTSIDE the repo — a release refuses a dirty tree). The five phases, each emitting a `phases[]` record: **preflight** (main checkout only via `lifecycle.linkedWorktreeMain`, branch `main` read with `git branch --show-current` — never `rev-parse --abbrev-ref HEAD`, which is `fatal:` on an unborn branch — a clean `git status --porcelain`, `git pull --ff-only`, CI green ASSERTED IN CODE from `gh run list --branch main --limit 1 --json headSha,status,conclusion` (an ARRAY, completed + success AND `headSha` equal to the commit the release is cut from — a green run for some OTHER commit proves nothing; on the RESUME run that commit is `HEAD~1`, since HEAD is then the local, deliberately UNPUSHED `release: v<V>` commit no CI run can ever name), then `adapter.preflight`); **version** (`git describe --tags --abbrev=0 --match v*`, whose exit 128 means a FIRST RELEASE for both "no tags" and "no matching tags" — its `fatal:` stderr is captured and dropped; the tag re-validated with `rev-parse --verify`; `git rev-list --count <tag>..HEAD == 0` → `nothing to release`; per-commit classification from ONE `git log <range> -z --pretty=format:%H%x00%s%x00%B` chunked by 3 with NO trailing NUL, rules: `!` before the `:` or a BREAKING CHANGE body → major, any `feat` → minor, else patch); **bump** (each manifest's indentation MEASURED from its own first indented line and only the version VALUE rewritten — the v0.5.1 field bug was a hardcoded `"\t"` that reformatted every 2-space manifest and turned CI red; the primary manifest is `release.dir ?? "."` + `/package.json` unless the adapter overrides it, plus every `release.lockstep[]` entry as a repo-relative FILE path — non-JSON lockstep files are left to the adapter's `bumpExtras` (which runs here, before the gate) and reported on the phase note, never silently skipped; then the gate runs IN-PROCESS over the BUMPED tree (`check([], root, {}, true)`, never a `dobby` subprocess) and a red gate restores the manifests and exits with the gate's own code and its FULL findings; finally `git add -u` + `git commit -m "release: v<V>"`, and NOTHING is pushed); **changelog** (the commits grouped by `release.surfaces` name→GLOB when configured — a commit that spans surfaces is listed under EACH — else by type: Breaking changes / Features / Fixes / Other); **publish** (`adapter.packGate` → `adapter.publish` → `git tag v<V>` → `git push origin main v<V>` → `gh release create v<V> --title v<V> --notes-file <f>` → `adapter.postRelease` → `adapter.smoke`). A RE-RUN recognizes the local `release: v<V>` commit (HEAD subject + the manifest agreeing + NO `v<V>` tag yet) and skips re-bumping. `node:*` only (ADR-0008). - `src/release-npm.ts` (+ `src/release-npm.test.ts`) — the **npm release TARGET**: the `ReleaseAdapter` behind `release.type: "npm"`, and the home of every npm-specific field scar. Four moments, each spawned through the runner with the cwd pinned to the RELEASE DIR (`context.dir` = `release.dir ?? "."` resolved against the workroot — publishing the workroot ships the wrong tree), each answering DATA and never throwing. **preflight** — `npm whoami`; a failure refuses in npm's own words, and the comment records the thing whoami CANNOT prove: an interactive-login token authenticates and then fails at publish with `EOTP` (the account's 2FA wants a per-publish OTP), so the working setup is a GRANULAR access token with write access in `~/.npmrc` (field-proven on v0.1.0). **packGate** — `bun pm pack --dry-run --ignore-scripts` (nothing written, no lifecycle script run as a side effect of INSPECTING a package), its `packed <size> <path>` listing parsed and matched against the DENY globs `**/*.test.ts`, `**/__fixtures__/**`, `dist/**` (GLOBS, never substrings — `src/latest.ts`, `src/fixtures/`, a root `distribute.ts` must all ship, and `dist/**` is rooted at the PACKAGE root so a `src/dist/` source directory ships while `**/` matches zero directories so a ROOT-level `index.test.ts` is caught); ANY hit refuses and names EVERY denied file, and a listing the gate parses NO files out of refuses too — quoting the packer's own stdout back, because zero `packed` lines means either an allowlist that ships nothing or a listing shape that drifted, and those have opposite fixes. **publish** — `npm publish --access public` (a scoped package defaults to restricted), PLAIN npm and never `bun publish` (bun 1.3.x ignores `~/.npmrc`'s `_authToken` and dies with "missing authentication" — field-hit on v0.1.0; this module spawns `bun` for the pack dry run alone); an `EOTP` in the output comes back with the granular-token fix, every other failure with npm's own words. **smoke** — `npm view <name> version` polled until the registry serves `context.version` (propagation can lag MINUTES on a first publish: a 404 right after `+ pkg@<V>` printed is NOT a failure, only an exhausted budget is; the budget is 15 polls × 20s by default and INJECTABLE via `createNpmAdapter({attempts, delayMs})` — the module's second export, which exists so tests need not wait), then the optional `release.smoke` argv (an ARRAY, never a shell string), the only step that proves the published ARTIFACT works. The package name is read from the release dir's own `package.json`. The adapter is registered by the SPINE (`registerReleaseAdapter("npm", npmAdapter)` in release.ts) rather than self-registering: a side-effect import of a self-registering target evaluates the target BEFORE the spine's registry const exists (a TDZ `ReferenceError` at import time, verified), while this direction leaves the target importing only TYPES — no runtime cycle. `node:*` only (ADR-0008). - `src/release-cask.ts` (+ `src/release-cask.test.ts`) — the **homebrew-cask release TARGET**: the `ReleaseAdapter` behind `release.type: "homebrew-cask"`, for a Tauri macOS app shipped through a Homebrew tap. It uses SIX of the seam's moments (the four every target has, plus BOTH optional hooks). **preflight** — `<dir>/src-tauri/tauri.conf.json` exists (else this is not a Tauri app), `rustup target list --installed` carries BOTH `aarch64-apple-darwin` and `x86_64-apple-darwin` (a missing one refuses with the literal `rustup target add …` fix), `gh auth status`, and `release.tap` + `release.cask` are configured (each refusal names the missing key) — plus, ONLY when the OPTIONAL `release.notaryProfile` is set, the two one-time human setups notarization needs: `security find-identity -v -p codesigning` listing a `Developer ID Application` certificate (the tool EXITS 0 while listing none, so the verdict is its OUTPUT; the refusal names Xcode → Settings → Accounts as where the certificate is made, and records that SIGNING is `tauri.conf.json`'s `signingIdentity`, never dobby's job) and `xcrun notarytool history --keychain-profile <p>` exiting 0 (the refusal carries the one-time `xcrun notarytool store-credentials <p> --apple-id … --team-id …`). **bumpExtras** — `src-tauri/Cargo.toml`'s version, rewritten byte-surgically and SCOPED to the `[package]` section (the window from the `[package]` header to the NEXT `[section]`: `version = ` also sits at the start of a line under `[dependencies.<crate>]`, and a whole-file regex bumps a dependency instead), then `cargo check` in the crate — ANY cargo command reconciles `Cargo.lock`, whose stale version would otherwise ride along in the release commit. **packGate** — `bun tauri build --bundles app,dmg --target universal-apple-darwin`, then exactly ONE `*.dmg` under `src-tauri/target/universal-apple-darwin/release/bundle/dmg/` (zero and many are separate refusals) and `PlistBuddy -c "Print :CFBundleShortVersionString"` on the built `.app` equal to the version being released (a bundle that predates the bump would ship an app reporting the old number while the cask advertises the new one) — PlistBuddy is spawned BARE with `/usr/libexec` APPENDED to the child's PATH, never by absolute path. With a `release.notaryProfile` configured, THREE more steps run here (last, AFTER the version gate — a stale bundle must never cost an Apple round trip — and still before any tag, the last place a release can be refused for free): `xcrun notarytool submit <dmg> --keychain-profile <p> --wait` whose stdout must carry `status: Accepted` (the tool exits 0 on a REJECTED submission, and a refusal quotes its FULL log, never truncated), `xcrun stapler staple <dmg>`, then `spctl -a -t open --context context:primary-signature -vv <dmg>` whose output must carry `Notarized Developer ID` (spctl exits 0 for a signed-but-un-notarized build and writes its assessment to STDERR, so the gate reads BOTH streams and matches CASE-SENSITIVELY — Gatekeeper's refusal reads `Unnotarized Developer ID`); each step gates the next, so a rejected submission is never stapled and an unstapled dmg is never assessed. **publish** — a NO-OP: the dmg is a local file until the GitHub release exists, so there is nothing this target could half-publish. **postRelease** — `gh release upload v<V> <dmg>`, `shasum -a 256` on that same file, then the TAP: `gh repo clone <tap>` into a mkdtemp dir (a failed clone is the probe — `gh repo create <tap> --public` then clone again), the cask's `version` + `sha256` lines replaced in place (indentation captured, never assumed) or the whole file SCAFFOLDED from the module's template when the tap carries none (its `url` templates Homebrew's `#{version}` and SANITIZES the asset name the way GitHub serves it — spaces become dots — so later releases only ever move two lines), `ruby -c` before the commit (an absent ruby is a NOTE, a rejection is a refusal), then `git add` + `git commit -m "<cask> <V>"` + `git push -u origin HEAD` in the tap checkout. **smoke** — REPORT-ONLY: it runs NOTHING and hands back `brew install --cask <tap-short>/<cask>` (Homebrew's own rule: `<user>/homebrew-<name>` is referred to as `<user>/<name>`), plus — CONDITIONALLY, only when nothing was notarized — the quarantine caveat (`xattr -dr com.apple.quarantine …`), which next to a notarized build would simply be a lie. **The credentials are keychain-only**: `notaryProfile` is the NAME of a notarytool keychain profile and the only credential fact dobby ever holds; there is deliberately NO `APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` env-var path (mad-eye ADR 0005 — an app-specific password in the environment is inherited by every child, shell history and CI log). `security`, `xcrun` and `spctl` are spawned BARE like the rest, which is also what keeps them stubbable in tests. Registered by the SPINE like the npm one (`registerReleaseAdapter("homebrew-cask", caskAdapter)` in release.ts), so this module imports only TYPES from it — no runtime cycle, and the target is reachable from `run.ts`'s graph through the spine. `node:*` only (ADR-0008). @@ -81,7 +81,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `build-plan [--file <doc>] [--task <task.json>] [--json]` → the task-dependency plan for `/dobby:execute` (and, with `--task`, for `/dobby:dispatch`). Default source: the `## Spec` body of `<workroot>/STATE.md` (`--file` overrides the document), whose task table is located by its HEADER ROW — `#`/`Task`/`Depends on`/`Affected areas`/`Verify recipe`, with `Description`, `Test-first` and `Destructive` all OPTIONAL (no Description → the title is the task's `spec`; an absent flag column → false) — so a spec written before the `### ` sub-heading format still plans, and a non-task table inside the spec is skipped. `--task <file>` reads ONE ad-hoc task from JSON instead and never touches STATE.md (it carries the surface the spec named `--task-file`, which the dispatcher's flag set has no option for). Answers `{tasks[{id,title,spec,decisions,constraints,areas[],verifyRecipe,testFirst,destructive,dependsOn[]}], hasTestSuite{value,specSays,disagreement}, manualVerifySetup: "none"|string[], preconditions{ok,missing[{taskId,field}],danglingDeps[{taskId,dependsOn}],cycles[[id…]]}, workRoot}` — `tasks` VERBATIM for the Architect to dispatch directly (`decisions`/`constraints` empty by contract, `devUrl` merged by the coordinator). There is NO wave/batch grouping in the payload: `dependsOn` (the row's `Depends on` ids — `[]` for `—`/empty) is the ONLY thing that says who a task waits for, and a task is ready the moment every id it names has reached `done` — which is what lets the caller SKIP a task whose blocker never passed without holding back anything independent of it. Ids are STRINGS, the same ones `dependsOn` references. Failing preconditions exit 1 **with the payload still on stdout** (the refusal names each task and cell on stderr); a missing document / unparseable `--task` file / table-less spec is a hard error with no payload. Fails hard outside a git repo. - `ship [--message-file <f>] [--pr-body-file <f>] [--json]` → the COMMIT CEREMONY in ONE call, the mechanized half of `/dobby:commit`. `--message-file` is REQUIRED and validated FIRST (present, readable, non-blank; resolved against the CALLER's cwd) — a ceremony that cannot produce a message leaves the tree exactly as it found it (unstaged, un-formatted, un-gated). Then: stage when nothing is staged → the **GATE IN-PROCESS** (`check([], root, {}, fix=true)`, never a `dobby` subprocess) → `.dobby/` exclude-ensured (in `.git/info/exclude`) and the WHOLE tree re-staged (the gate judges the working tree, so committing a caller-staged SUBSET would record a green verdict for a tree that was never checked) → the gate cache → `git commit -F` → push pinned to ORIGIN (`-u origin HEAD` when the branch tracks nothing; a non-origin upstream still pushes to origin, reported via `pushNote`) → the pull request. A detached HEAD is refused before any mutation. **The exit code decides**: a nonzero gate returns the gate's OWN code with every finding printed WHOLE (uncapped, never `formatCheck`'s 50-per-tool sample) and commits nothing. The PR is opened ONLY off a NON-TRUNK branch (`main`/`master` have nowhere to open one from) and ONLY with a `--pr-body-file` (the body is the caller's to author); an EXISTING PR for the branch is reported, not duplicated; a failed push short-circuits it, and a PR gh could not open is a NOTE, not a failure (the commit already landed). Answers `{cacheNote, cacheWritten, committed, gateExitCode, gateNote, prNote, prUrl, pushNote, pushed, sha}` — the notes distinguish "skipped by policy" from "could not be done", and `gateNote` carries the `gate skipped: inputs unchanged since last green (…)` line when the in-process gate was served from the per-check cache (null when it really ran). Fails hard outside a git repo. - `review fetch [--pr N] [--json]` · `review apply (--plan <f>|--stdin) [--pr N] [--dry-run] [--json]` · `pr watch [--pr N] [--deadline <sec>] [--await-review] [--json]` → the `gh` surface of the address-review stage; `--pr` defaults to the CURRENT branch's PR. `fetch` → `{pr, adapter, candidates, threads, summary}`: the open review THREADS over GraphQL (drained with gh's mandatory `$endCursor` pagination contract, each thread carrying its last comments so a re-run sees its own prior replies) plus the bot's summary comment over REST (sorted by `updated_at`, since the bot EDITS one comment in place, and bot logins matched by BARE slug because GraphQL and REST disagree about the `[bot]` suffix). A PR with nothing to address is `threads: []` at exit 0 — an ANSWER, not an error. `apply` consumes a disposition plan (`{pr, reTrigger, plan:[{threadId, disposition: fix|dismiss|outdated|defer, reply}]}`) from `--plan`/`--stdin`, replies + resolves in batches, SKIPS threads it already answered (idempotent by construction) and re-triggers when asked; `defer` deliberately does NOT resolve (a deferred finding stays open) and `--dry-run` makes the same decisions with zero writes. Any failure exits 1 with `{failures[], replied[], resolved[], retriggered, skipped[], dryRun}`. `pr watch` owns its OWN polling loop and derives the verdict from check BUCKET COUNTS (`gh pr checks --json` always exits 0, and `--watch --json` is a hard error) — `ci-failed|ci-green|ci-pending|merge-ready|feedback-present|open-unreviewed|skipped`, with `--deadline` (default 300s) budgeting EACH wait phase separately (CI, then the review under `--await-review`) so a slow CI run can never eat the review wait. NO merge path — every judgment stays in `/dobby:address-review`. All three fail hard outside a git repo, and a gh that could not report at all is surfaced, never read as an empty (green) check list. -- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch: string | null` is the repository's own trunk, resolved at `mainRoot` by an ordered cascade, first hit wins — `refs/remotes/origin/HEAD` (stripped of its `origin/` prefix), then remote-tracking `origin/main`/`origin/master`, then local `main`/`master`, else `null` when nothing in the repo names a trunk. Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. +- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch: string | null` is the repository's own trunk, resolved at `mainRoot` by an ordered cascade, first hit wins — `refs/remotes/origin/HEAD` (stripped of its `origin/` prefix, and counted only while `refs/remotes/origin/<that name>` still exists — a stale/dangling head falls through as if unset), then remote-tracking `origin/main`/`origin/master`, then local `main`/`master`, else `null` when nothing in the repo names a trunk. Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. - `repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>` → the red/green capture harness. Everything after `--` is the command, spawned with cwd pinned to the workroot (fails hard outside a git repo) and its stdout/stderr captured WHOLE — repro never truncates. One run answers `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` = red on any nonzero exit, `matched` = `verdict === --expect` (explicitly `null`, never absent, without `--expect`). Exit code: **1 ONLY on a mismatch** (the "your loop is not red-capable" signal); a failing command with NO `--expect` exits 0 — the harness reports an exit code, it never inherits one. `reproId` is a short hash of the workroot + the command argv ONLY (repro's own flags are excluded, so every run of one loop keys the same record), and each run persists `{baseline, latest, reproId}` at `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured); a failed write is a stderr warning, not a failure. `--repeat N` runs the loop N times sequentially and ADDS `{runs, redCount, greenCount, reproductionRate (redCount/runs), deterministic, firstDivergent:{run (1-based), stdout, stderr}|null, durations}` — the base record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), so `--expect red --repeat N` is a red-CAPABILITY probe and the pasted output is the failing run's. `--bench` ADDS `{samples, min, median (over sorted samples), mean, max}` plus `baseline` (the previous bench of this reproId, `null` on the first) and `delta` (current MINUS baseline per timing stat — faster reads negative), then stores its own stats as the next baseline; a non-bench run leaves the stored baseline alone. `--json` prints the full payload as the sole stdout; the default render is a compact human summary (headline + `cmd:`/`cwd:` + the repeat/bench lines + the record path) with a labelled TAIL of the output. - `kb list --kind <k> | kb record --kind <k> --concept <kebab> --title <t> --reason-file <f> --entry <line>` → the durable knowledge bases at `<workroot>/docs/out-of-scope/` and `<workroot>/docs/learn-discarded/` (fails hard outside a git repo). `--kind` is REQUIRED for both and is the module's only parameter; an unknown one is a hard error naming both KBs (a typo must never read as "that KB is empty" — dedup would silently stop working). `list` → a bare JSON ARRAY (under `--json`) of `{concept (filename stem), path, title (the H1), statement (the first paragraph, wrapped lines joined), priorEntries (the `- ` bullets under the kind's prior-section, markers stripped)}`, one per `*.md`, sorted by filename; an ABSENT directory is `[]` at exit 0, never an error. `record` → ONE file per concept: an existing concept's file gets the entry APPENDED as a bullet under its prior-section (every byte before that heading untouched — the rationale written the first time wins over this call's `--title`/`--reason-file`), an absent one is created after a lazy `mkdir`, carrying the canonical skeleton (H1, the one-line statement, the kind's why-heading + the reason body, the kind's prior-heading + the first bullet). `--reason-file` is split at its FIRST LINE (the statement) with the REST as the reason body; a file with no body is refused. Answers the bare `{path, created, appended}`. Every refusal goes to stderr with exit 1 (no `ok` envelope — the payloads are the spec's bare shapes). - `adr new "<title>" [--status proposed|accepted|deprecated]` → allocate the next ADR number and create `<workroot>/docs/adr/NNNN-<slug>.md` (fails hard outside a git repo; the dir is created lazily, and only after the inputs validate — a refusal never leaves an empty `docs/adr/` behind). The title is a POSITIONAL (every positional after the token, joined); the slug is DERIVED from it. Numbering is `max + 1` over the local directory AND `git ls-tree -r origin/HEAD --name-only -- docs/adr` (a sibling worktree's ADR is pushed long before it lands here; no origin / no git / no upstream `docs/adr` all score 0, so a remote-less repo still files ADRs), and the number is claimed — not merely the filename: each attempt re-reads `docs/adr/` and moves to the next number if anything already carries the `NNNN-` prefix, whatever its slug, with `O_EXCL` (`flag: "wx"`) behind it so an `EEXIST` retries instead of truncating an identically-named ADR. The scan is a read and is stale the instant it returns, so the claim happens at WRITE time, never at scan time. Writes a SKELETON only — `# NNNN. <title>`, the optional `**Status:** <status>` line (omitted without `--status`), and a placeholder paragraph; body authorship stays with the architect. Answers the bare `{number, slug, path}`; a missing title / an unknown status is a refusal on stderr with exit 1, naming what IS valid. diff --git a/cli/README.md b/cli/README.md index 34f1523..6ee9704 100644 --- a/cli/README.md +++ b/cli/README.md @@ -452,7 +452,7 @@ dobby migrate preflight --json dobby migrate verify --json ``` -`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `origin/HEAD`, then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one), and whether the branch is safe to delete. +`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `origin/HEAD` (only while the ref it names still exists — a stale/dangling head is treated as unset), then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one), and whether the branch is safe to delete. `dobby migrate preflight` says whether a repo still needs the config migration (naming each legacy signal and snapshotting what the migration must carry across); `dobby migrate verify` runs the gate in-process and reports the environment read back plus whatever was left behind. Both **exit 0 with a payload for every verdict** — they inform, they never refuse. diff --git a/cli/src/preflight.test.ts b/cli/src/preflight.test.ts index 7f45b2f..e274705 100644 --- a/cli/src/preflight.test.ts +++ b/cli/src/preflight.test.ts @@ -1130,6 +1130,182 @@ describe("finish --preflight — a default branch nothing in the repo names", () }); }); +// =========================================================================== +// Slice 13b — a STALE remote head is NOT an answer. `refs/remotes/origin/HEAD` +// is a plain symbolic ref: renaming the trunk on the forge, or pruning the +// branch it named, leaves it pointing at an `origin/<name>` that no longer +// exists — and git keeps reading that DANGLING pointer back quite happily. So +// step 1 of the cascade counts ONLY while `refs/remotes/origin/<target>` still +// exists; a dangling head falls through to steps (2)–(4) exactly as if it had +// never been set. Everything else is unchanged. +// +// The fixtures are built with plain git: the head is set with +// `git remote set-head origin <name>` and made stale with +// `git update-ref -d refs/remotes/origin/<name>`, which removes the TARGET and +// leaves the symbolic ref standing. Every case states that precondition first — +// the head still reads `origin/<name>`, the target ref is gone — so a passing +// case can never be a fixture that quietly lost its head instead. +// =========================================================================== + +// A main checkout published to a throwaway BARE origin under remote names of OUR +// choosing, with an explicit remote head, optionally left dangling, then left +// standing on `goal`. `push` takes `<local>:<remote>` refspecs — publishing one +// local branch under a SECOND remote name is what lets a fixture carry +// `origin/main` with no local `main` anywhere. The order is load-bearing: push, +// then fetch (so `set-head` has a valid ref to point at), then set the head +// explicitly (recent git may materialize one on fetch — ours must be the last +// word), then delete the target it names. +function makeRemoteHeadFixture(opts: { + head: string; + push: string[]; + stale?: boolean; + trunk: string; +}): string { + const mainRoot = makeMainCheckout({ + branch: opts.trunk, + config: true, + dobby: true, + }); + const bare = realpathSync( + mkdtempSync(join(tmpdir(), "dobby-preflight-stale-")) + ); + scratchDirs.push(bare); + gitIn(bare, ["init", "-q", "--bare"]); + gitIn(mainRoot, ["remote", "add", "origin", bare]); + gitIn(mainRoot, ["push", "-q", "origin", ...opts.push]); + gitIn(mainRoot, ["fetch", "-q", "origin"]); + gitIn(mainRoot, ["remote", "set-head", "origin", opts.head]); + if (opts.stale === true) { + gitIn(mainRoot, ["update-ref", "-d", `refs/remotes/origin/${opts.head}`]); + } + gitIn(mainRoot, ["switch", "-q", "-c", "goal"]); + return mainRoot; +} + +// Git's own answer to BOTH halves of "the head is stale": what the head names, +// and whether that name still resolves. `symbolic-ref` reads a dangling symref +// perfectly well, and `show-ref --verify` is precisely "does this ref exist" — +// neither is the way the cascade reads them. +function remoteHead(root: string): { head: string; target: boolean } { + const head = gitIn(root, [ + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ]); + try { + gitIn(root, ["show-ref", "--verify", "--quiet", `refs/remotes/${head}`]); + return { head, target: true }; + } catch { + return { head, target: false }; + } +} + +describe("finish --preflight — a dangling remote head", () => { + let staleOverRemoteMain: string; + let staleWithNothingElse: string; + let staleOverLocalMaster: string; + let staleOverLocalTrunk: string; + let liveHead: string; + + beforeAll(() => { + // The head names `origin/master`, whose ref is gone, while `origin/main` — + // the same commit published under a SECOND remote name, with no local `main` + // anywhere — remains. Three answers are told apart at once: trusting the + // dangling pointer says `master`, skipping the remote branches for the local + // ones says `master` too, and only reading `origin/main` says `main`. + staleOverRemoteMain = makeRemoteHeadFixture({ + head: "master", + push: ["master:master", "master:main"], + stale: true, + trunk: "master", + }); + // Born on `develop`, published once as `master`, and that remote-tracking ref + // then deleted: past the head there is NOTHING — no remote branch left, and + // neither `main` nor `master` among the local ones. + staleWithNothingElse = makeRemoteHeadFixture({ + head: "master", + push: ["develop:master"], + stale: true, + trunk: "develop", + }); + // The remote has only the (now deleted) `master`, and a local `master` is + // still there — the cascade's third step. The answer coincides with the + // dangling head's own name, which is exactly why the pair below exists. + staleOverLocalMaster = makeRemoteHeadFixture({ + head: "master", + push: ["master:master"], + stale: true, + trunk: "master", + }); + // The SAME shape with the local branch named `trunk` instead of `master`: + // the head still dangles at `origin/master` and nothing else in the repo + // names a trunk. `null` here is what proves the case above answered from the + // local branches rather than from the pointer. + staleOverLocalTrunk = makeRemoteHeadFixture({ + head: "master", + push: ["trunk:master"], + stale: true, + trunk: "trunk", + }); + // The regression pin: the very same builder, head NOT made stale. + liveHead = makeRemoteHeadFixture({ + head: "trunk", + push: ["trunk:trunk"], + trunk: "trunk", + }); + }); + + it("falls through to the remote branches when the head dangles", async () => { + expect( + remoteHead(staleOverRemoteMain), + "fixture must leave origin/HEAD naming a ref that is gone" + ).toEqual({ head: "origin/master", target: false }); + const preflight = await finishPreflight(staleOverRemoteMain); + expect(preflight.defaultBranch).toBe("main"); + }); + + it("admits it does not know the trunk when a dangling head is all there is", async () => { + expect( + remoteHead(staleWithNothingElse), + "fixture must leave origin/HEAD naming a ref that is gone" + ).toEqual({ head: "origin/master", target: false }); + const preflight = await finishPreflight(staleWithNothingElse); + expect(preflight.defaultBranch).toBe(null); + }); + + it("tells a human reader the trunk is unknown behind a dangling head", async () => { + const result = await run(["finish", "--preflight"], staleWithNothingElse); + expect(result.stdout).toMatch(DEFAULT_BRANCH_UNKNOWN); + }); + + it("falls through to the local branches when the head dangles", async () => { + expect( + remoteHead(staleOverLocalMaster), + "fixture must leave origin/HEAD naming a ref that is gone" + ).toEqual({ head: "origin/master", target: false }); + const preflight = await finishPreflight(staleOverLocalMaster); + expect(preflight.defaultBranch).toBe("master"); + }); + + it("never answers the dangling head's own name when nothing else names a trunk", async () => { + expect( + remoteHead(staleOverLocalTrunk), + "fixture must leave origin/HEAD naming a ref that is gone" + ).toEqual({ head: "origin/master", target: false }); + const preflight = await finishPreflight(staleOverLocalTrunk); + expect(preflight.defaultBranch).toBe(null); + }); + + it("still reports a remote head whose target is right where it was", async () => { + expect( + remoteHead(liveHead), + "fixture must leave origin/HEAD naming a ref that exists" + ).toEqual({ head: "origin/trunk", target: true }); + const preflight = await finishPreflight(liveHead); + expect(preflight.defaultBranch).toBe("trunk"); + }); +}); + // =========================================================================== // Slice 14 — the payload's field list is EXACT: the ten fields the finish skill // already reads plus `defaultBranch`, and nothing else. Pinned as a whole set diff --git a/cli/src/preflight.ts b/cli/src/preflight.ts index bf39539..0a15172 100644 --- a/cli/src/preflight.ts +++ b/cli/src/preflight.ts @@ -133,7 +133,12 @@ function currentBranch(root: string): string { // it force-deletes a goal branch. `"main"` is an assumption, not a fact, so // this reads the repo itself through an ORDERED cascade, first hit wins: // 1. `refs/remotes/origin/HEAD` (`git symbolic-ref` answers `origin/<name>`; -// the `origin/` prefix is stripped); +// the `origin/` prefix is stripped) — but ONLY while `refs/remotes/ +// origin/<name>` still exists. A DANGLING head (the symbolic ref +// resolves to a branch this clone no longer tracks — the remote's +// default was renamed/deleted and `origin/HEAD` was never refreshed) +// reads exactly like an unset head: the cascade continues to tier 2 +// rather than answering with a name nothing tracks; // 2. failing that, the remote-tracking refs: `origin/main`, else // `origin/master`; // 3. failing that (no remote refs at all), the LOCAL branches: `main`, else @@ -154,7 +159,15 @@ function defaultBranchAt(root: string): string | null { ); const ref = head.stdout.trim(); if (head.status === 0 && ref.startsWith(REMOTE_HEAD_PREFIX)) { - return ref.slice(REMOTE_HEAD_PREFIX.length); + const target = ref.slice(REMOTE_HEAD_PREFIX.length); + // A dangling `origin/HEAD` — the symbolic ref resolves, but the branch it + // names no longer exists as a remote-tracking ref (the remote's default + // branch was renamed/deleted and this clone's HEAD was never updated) — + // reads exactly like an UNSET head: the cascade continues to tier 2 + // rather than answering with a name nothing tracks. + if (refExists(root, `refs/remotes/origin/${target}`)) { + return target; + } } for (const name of CONVENTIONAL_TRUNK_NAMES) { diff --git a/plugin/skills/finish/SKILL.md b/plugin/skills/finish/SKILL.md index c4bcfe0..9d69fe5 100644 --- a/plugin/skills/finish/SKILL.md +++ b/plugin/skills/finish/SKILL.md @@ -15,7 +15,7 @@ The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/ bunx dobby finish --preflight --json ``` -One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch: string | null` — resolved by an ordered cascade: remote head, then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one; Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: +One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch: string | null` — resolved by an ordered cascade: remote head (only while the ref it names still exists — a stale/dangling `origin/HEAD` counts as unset and falls through), then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one; Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: - **`blocked`** — `dobbyInstalled: false`: `dobby down` is the mandatory pre-removal teardown and has no fallback. **STOP** and point the user at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract). This is the ONLY blocking condition. - **`safe`** — a MERGED PR and a clean tree. Proceed to Step 2 without a prompt. From 0549cc32490b9cd9d2c22a963240fa19d0df2ff4 Mon Sep 17 00:00:00 2001 From: Kevin Wolf <hi@kvnwolf.com> Date: Thu, 3 Sep 2026 22:03:16 -0600 Subject: [PATCH 5/6] fix(kit): the merged PR's base branch is the trunk; the cascade is for a branch with no PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4 on #54. A stale `origin/HEAD` beside a legacy `origin/main` while the real trunk has a custom name made the cascade pick `main` on existence alone, and finish would switch to the wrong branch. No git-only heuristic can tell that apart — but finish runs after the goal's PR was merged, and the preflight already asks GitHub about that PR. Its base branch IS the trunk this goal merged into, by definition. `defaultBranch` now comes from the PR's `baseRefName` first; the git cascade (validated `origin/HEAD`, then `origin/main`/`origin/master`, then local `main`/`master`, then `null`) answers only for a branch that has no PR at all. The payload also reports `defaultBranchSource` so the operator sees which rule answered. The skill's plain-checkout teardown creates the local trunk from `origin/<trunk>` when it does not exist yet, and still stops with instructions when neither exists. Four rounds on this one field — an unreadable remote head, a blind constant, a dangling symref, an unrelated conventional name — and the resolution is the same each time: the trunk is a fact to resolve, never a name to assume. After the forge's own answer there is no higher one. --- cli/CONTEXT.md | 4 +- cli/README.md | 2 +- cli/src/preflight.test.ts | 289 +++++++++++++++++++++++++++++++++- cli/src/preflight.ts | 115 +++++++++++--- plugin/skills/finish/SKILL.md | 33 +++- 5 files changed, 408 insertions(+), 35 deletions(-) diff --git a/cli/CONTEXT.md b/cli/CONTEXT.md index 90b3668..b32ebdf 100644 --- a/cli/CONTEXT.md +++ b/cli/CONTEXT.md @@ -32,7 +32,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `src/buildplan.ts` (+ `src/buildplan.test.ts`) — the **build plan**, derived MECHANICALLY from the spec's task table (`dobby build-plan [--file <doc>] [--task <task.json>] [--json]`), a domain module behind the `command.ts` contract. It replaces the per-session judgment call the coordinator used to make over a markdown grid, and emits ONE payload: `tasks[]` — the per-task instruction data VERBATIM (`{id, title, spec, decisions, constraints, areas[], verifyRecipe, testFirst}` + `destructive` + `dependsOn[]`), the exact shape `plugin/skills/execute/references/build-protocol.md` consumes, with `decisions`/`constraints` deliberately EMPTY (plan-level decisions stay coordinator-distributed) and `devUrl` deliberately ABSENT (the coordinator merges it), and `dependsOn` carrying the row's `Depends on` ids VERBATIM (`—`/empty → `[]`) — the ONLY thing that says WHO a task waits for, now that there is no batch grouping saying WHEN it runs: a task is ready the moment every id in its own `dependsOn` has reached `done`, which is what lets the Architect skip a task whose dependency ended needs-human without touching anything independent of it; `preconditions` — `{missing[{taskId,field}], danglingDeps[{taskId,dependsOn}], cycles[[ids]], ok}`, where not-ok exits 1 **with the payload still on stdout** (the `up --json` convention: the verdict fields ARE the fix list); plus the two gates `/dobby:execute` reads before launching — `hasTestSuite` (`value` from the repo's `vitest` capability, `specSays` from the Testing Decisions' test-first claim — null when the section is absent — and their `disagreement`) and `manualVerifySetup` (the `Manual verify setup:` field's steps, or `none`). PARSING IS TOLERANT BY CONTRACT: the task table is found by its HEADER ROW (never a `### Tasks` anchor — the sub-heading spec format is new and older specs must still plan), `Description`/`Test-first`/`Destructive` are each optional (absent → the title stands in as the spec, the flags read false), a non-task table inside the spec is skipped, and `—` reads as "no dependency". `--task <file>` plans ONE ad-hoc task from JSON and reads no STATE.md at all (the `/dobby:dispatch` path); it carries the ad-hoc surface the spec named `--task-file`, since the dispatcher's flag set has no such option. An ACTION command (`requireWorkroot`; the throw is folded into the failure shape). `node:*` only (ADR-0008). - `src/build-protocol.test.ts` — a GUARD with no module of its own: what it tests lives OUTSIDE the CLI, as the **dispatch protocol** document at `plugin/skills/execute/references/build-protocol.md` (the shared build-loop component `/dobby:execute`, `/dobby:dispatch`, and `/dobby:address-review` all read and follow). The protocol is prose the Architect follows directly, not a runtime the CLI can execute, so the suite reads that markdown as TEXT and pins its RULES per document section: every worker is dispatched NAMED (`dobby:test-author` / `dobby:implementor` / `dobby:qa`), `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` must stay unset, the deferred `SendMessage` tool is loaded via `ToolSearch` before first use, a task starts the moment its own dependencies are done with no fixed batch to wait on, the Exit gate is serialised to one implementor at a time, a dead task stops only its dependents while independent work keeps being dispatched (and what died is reported), each worker appends its own `dobby state append-worklog` entry and returns only a short verdict, `STATE.md` stays current enough to reconstruct progress after a compaction, and the run closes with a summary table of rounds / first-attempt success / deaths / wall clock — plus a repo-wide scan proving the rename off the OLD filenames (the protocol document and its old test harness both previously carried) left no live surface still pointing at either one. It lives here because `cli/src` is the tree vitest covers; it imports nothing from the CLI. - `src/repro.ts` (+ `src/repro.test.ts`) — the **red/green capture harness** (`dobby repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>`), a domain module behind the `command.ts` contract. Everything after `--` is the command (node's `parseArgs` drops the `--` and hands the rest through as positionals), spawned through `runner.runCapture` with cwd pinned to the workroot — so the SAME loop reproduces identically from any subdirectory, and outside a git repo it fails hard like every action command (`requireWorkroot`'s throw is CAUGHT here, since `run()` does not catch handler exceptions). One run yields `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` is red on ANY nonzero exit (a child that never started / was killed records POSIX 127 / 128 and its spawn error folded into stderr, never a silent green — the exit code is extracted by TYPE, `typeof status === "number"`, never by `!== null`: node spells "no exit code" as `null` while Bun, the runtime the `dobby` bin runs on, spells it `undefined`, and an `undefined` exitCode would be DROPPED by `JSON.stringify` out of both the payload and the persisted record), and `matched` is `verdict === --expect` — explicitly NULL (never absent) without `--expect`, because "nothing was judged" is an answer. The HARNESS judges, so the model never derives a verdict by reading output: exit 1 ONLY on a mismatch (the "your loop is not red-capable" signal); a failing command with no `--expect` still exits 0 (repro REPORTS an exit code, never inherits one). `reproId` = a 12-hex-char sha256 of the workroot + the command argv and NOTHING else (repro's own flags are deliberately out, so `--repeat`/`--bench` runs key the same record), and every run persists `{baseline, latest, reproId}` to `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured, as `up` does for its pidfile) — a write failure is a stderr WARNING, never the outcome. `--repeat N` runs sequentially and adds `{runs, redCount, greenCount, reproductionRate (red/runs), deterministic, firstDivergent:{run (1-BASED), stdout, stderr}|null, durations}`; the BASE record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), which makes `--expect red --repeat N` a red-CAPABILITY probe instead of a coin flip on run 1. `--bench` adds `{samples, min, median (over SORTED samples), mean, max}` plus `baseline` (the PREVIOUS bench of this reproId, null on the first) and `delta` (current MINUS baseline per timing stat, so faster reads negative), and stores its own stats as the next baseline — a non-bench run never clobbers it. `--json` prints the full payload; the default text render is a compact summary (verdict + reproId headline, `cmd:`/`cwd:`, the repeat/bench lines, the record path) plus a labelled TAIL of stdout/stderr — repro itself NEVER truncates what it captures or stores. `node:*` only (ADR-0008). -- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `refs/remotes/origin/HEAD` (only while the ref it names still exists — a stale/dangling head is treated exactly like an unset one and the cascade continues), then remote-tracking `refs/remotes/origin/main`/`master`, then local `refs/heads/main`/`master`, else `null` when nothing in the repo names one, an honest "I don't know" rather than a guessed `"main"`), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). +- `src/preflight.ts` (+ `src/preflight.test.ts`, `src/migrate.test.ts`) — the **PREFLIGHTS**: the READ-ONLY verdicts a destructive or planning stage asks for BEFORE it acts. Each returns FACTS plus a verdict and NEVER creates, enters, removes or edits anything — every AskUserQuestion gate stays in the skill — and each is an ACTION command that fails HARD outside a git repository rather than answering with a degraded verdict. `finish --preflight` is the teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is (no `--slug` — the kit no longer creates, names, or targets a worktree) — `safe` (MERGED PR via `gh` + a clean tree), `blocked` (dobby not installed AT THE WORKROOT the session stands in — a linked worktree and its main checkout are installed independently, `node_modules/` being gitignored — so the mandatory `dobby down` cannot run — it outranks every other signal), else `confirm-required` with a reason per risk — plus whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, with `defaultBranchSource` naming what answered it — the PR's own base branch FIRST, when the PR exists and its `baseRefName` is a non-empty string (`defaultBranchSource: "pr-base"`); only when there is no usable base — no PR, or one whose answer carries no base — does the git cascade run, resolved at `mainRoot` by an ordered cascade — `refs/remotes/origin/HEAD` (only while the ref it names still exists — a stale/dangling head is treated exactly like an unset one and the cascade continues; `"origin-head"`), then remote-tracking `refs/remotes/origin/main`/`master` (`"remote-conventional"`), then local `refs/heads/main`/`master` (`"local-conventional"`), else `null`/`null` when nothing in the repo names one, an honest "I don't know" rather than a guessed `"main"`), and whether the branch is force-delete safe (`pr.state === "MERGED"`: a squash-merge makes gh authoritative over git ancestry). `migrate preflight|verify` mechanizes the two ends of `/dobby:migrate-config`: **preflight** = Step 0 — the legacy (vite-plus era) `signals` (`.claude/commit.config.yml`; an old-era `dobby.config.json` carrying a `run` key or `setup`/`teardown`/`checks` extras that shell out to vp/vpr — the offending command STRINGS only, so a genuine `docker compose down` is never listed; the `vite-plus` dep; the packages aliased onto it in `overrides`/`resolutions`; `.vite-hooks/`; a vp task table INSIDE `vite.config.*` (a config with no vp/vpr line is NOT a signal); a `prepare` script; `.conductor/`) plus the `snapshot` the migration must carry across (the bundled-toolchain deps still declared, the preserved package keys `portless`/`trustedDependencies`, `.worktreeinclude`, the script names, where each tool config lives — resolved through the SAME `tasks.ts` own-file sets override-by-presence counts, so "present" means exactly "this would override dobby's default" — `.env.test`, the workflow files carrying a vp/vpr line, `vercel.json`, and the `tracker` line read from `dobby.config.json`), and ONE verdict: `already-migrated` iff NO legacy signal fired AND the config is new-schema AND `tsconfig.json` extends `@kvnwolf/dobby`, else `migration-needed`. **verify** = Step 10 — it runs the gate IN-PROCESS (`check.ts`, never a re-entered `bunx dobby check`) and reports `{check:{exitCode, failingSteps}}` (the step LABELS `check` prints, from the findings groups + the failure notes — a TOTAL channel: the ADR-0015 BLOCKED build names `build`, and any note shape left unrecognized still falls back to `check`, so a red gate can never name nothing), the environment read back through `collectEnv` (`capabilities`, `config`, `devUrl`, the inferred `dbTasks`), and the `residual` — `legacyFilesRemaining` (the three artifacts Step 10's "Removed" bucket names), `deltaConfigsKept` (a KEPT tool config is a legitimate outcome, so it is REPORTED and never held against the repo), and `trackerIncomplete` (no tracker, or a Linear line whose `team` was deferred). `ok` = green gate AND nothing legacy left AND a pinned tracker. PATH CONVENTION: every path either migrate payload reports is REPO-RELATIVE. EXIT CODES: both migrate arms are INFORMATIONAL — exit 0 with the payload for EVERY verdict (a `migration-needed` repo is not a refusal, an unhealthy one is the answer `verify` was asked for), reserving exit 1 for the two cases with no answer at all (outside a git repo; a gate that could not START). `node:*` only (ADR-0008). - `src/release.ts` (+ `src/release.test.ts`) — the **release SPINE** (`dobby release [--bump patch|minor|major] [--notes-file <f>] [--dry-run] [--json]`), a domain module behind the `command.ts` contract and the CLI's one CONFIG-GATED command: without a `release` key in `dobby.config.json` the command does not exist (`run.ts` hides it; the spine repeats the refusal as defense in depth for every non-dispatcher caller). It owns the phases EVERY release target shares and nothing target-specific: those live behind the exported `ReleaseAdapter` seam — `{id, preflight, packGate, publish, smoke}`, each taking a `ReleaseContext` (`{currentVersion, dir, notesFile, release, root, tag, version}` — `version`/`tag` are NULL during `preflight`, which runs before the version is decided) and returning `ReleasePhaseResult` DATA, plus three OPTIONAL members: `primaryManifest(root, release)` (a target whose version does not live in `<dir>/package.json`), `bumpExtras(context, version)` (the version-carrying files the JSON-only bump cannot edit — run INSIDE the bump phase, after the manifests and BEFORE the gate and the commit, so the edit is gated and lands in the `release: v<V>` commit) and `postRelease(context)` (work that can only happen once the GitHub release exists — run after `gh release create` and before `smoke`, PAST the publish line, so a failure is reported and never rolled back). A target that needs none of them is unaffected — the npm one defines none. Adapters register themselves through `registerReleaseAdapter(type, adapter)` into a MUTABLE registry keyed by `release.type` (a `switch` would make the spine import every target; a lazy `await import()` is impossible — `run.ts` dispatches handlers synchronously), and an unregistered type is a clean error naming what IS registered. **Two-phase invocation** (the model keeps authorship of both judgements): (1) `needsDecision` — without `--bump`, a FIRST release or an inferred major while still below 1.0.0 exits 1 with `{needsDecision: "first-release"|"0x-major", context:{currentVersion, commits[]}}` having touched NOTHING, and the skill answers with `--bump`; (2) `needsNotes` — without `--notes-file` the run does everything mechanical and STOPS with `{needsNotes: true, version, changelog}` (exit 1, the bump commit kept LOCAL, nothing pushed or published), the skill authors the notes and re-runs with `--notes-file` (which must live OUTSIDE the repo — a release refuses a dirty tree). The five phases, each emitting a `phases[]` record: **preflight** (main checkout only via `lifecycle.linkedWorktreeMain`, branch `main` read with `git branch --show-current` — never `rev-parse --abbrev-ref HEAD`, which is `fatal:` on an unborn branch — a clean `git status --porcelain`, `git pull --ff-only`, CI green ASSERTED IN CODE from `gh run list --branch main --limit 1 --json headSha,status,conclusion` (an ARRAY, completed + success AND `headSha` equal to the commit the release is cut from — a green run for some OTHER commit proves nothing; on the RESUME run that commit is `HEAD~1`, since HEAD is then the local, deliberately UNPUSHED `release: v<V>` commit no CI run can ever name), then `adapter.preflight`); **version** (`git describe --tags --abbrev=0 --match v*`, whose exit 128 means a FIRST RELEASE for both "no tags" and "no matching tags" — its `fatal:` stderr is captured and dropped; the tag re-validated with `rev-parse --verify`; `git rev-list --count <tag>..HEAD == 0` → `nothing to release`; per-commit classification from ONE `git log <range> -z --pretty=format:%H%x00%s%x00%B` chunked by 3 with NO trailing NUL, rules: `!` before the `:` or a BREAKING CHANGE body → major, any `feat` → minor, else patch); **bump** (each manifest's indentation MEASURED from its own first indented line and only the version VALUE rewritten — the v0.5.1 field bug was a hardcoded `"\t"` that reformatted every 2-space manifest and turned CI red; the primary manifest is `release.dir ?? "."` + `/package.json` unless the adapter overrides it, plus every `release.lockstep[]` entry as a repo-relative FILE path — non-JSON lockstep files are left to the adapter's `bumpExtras` (which runs here, before the gate) and reported on the phase note, never silently skipped; then the gate runs IN-PROCESS over the BUMPED tree (`check([], root, {}, true)`, never a `dobby` subprocess) and a red gate restores the manifests and exits with the gate's own code and its FULL findings; finally `git add -u` + `git commit -m "release: v<V>"`, and NOTHING is pushed); **changelog** (the commits grouped by `release.surfaces` name→GLOB when configured — a commit that spans surfaces is listed under EACH — else by type: Breaking changes / Features / Fixes / Other); **publish** (`adapter.packGate` → `adapter.publish` → `git tag v<V>` → `git push origin main v<V>` → `gh release create v<V> --title v<V> --notes-file <f>` → `adapter.postRelease` → `adapter.smoke`). A RE-RUN recognizes the local `release: v<V>` commit (HEAD subject + the manifest agreeing + NO `v<V>` tag yet) and skips re-bumping. `node:*` only (ADR-0008). - `src/release-npm.ts` (+ `src/release-npm.test.ts`) — the **npm release TARGET**: the `ReleaseAdapter` behind `release.type: "npm"`, and the home of every npm-specific field scar. Four moments, each spawned through the runner with the cwd pinned to the RELEASE DIR (`context.dir` = `release.dir ?? "."` resolved against the workroot — publishing the workroot ships the wrong tree), each answering DATA and never throwing. **preflight** — `npm whoami`; a failure refuses in npm's own words, and the comment records the thing whoami CANNOT prove: an interactive-login token authenticates and then fails at publish with `EOTP` (the account's 2FA wants a per-publish OTP), so the working setup is a GRANULAR access token with write access in `~/.npmrc` (field-proven on v0.1.0). **packGate** — `bun pm pack --dry-run --ignore-scripts` (nothing written, no lifecycle script run as a side effect of INSPECTING a package), its `packed <size> <path>` listing parsed and matched against the DENY globs `**/*.test.ts`, `**/__fixtures__/**`, `dist/**` (GLOBS, never substrings — `src/latest.ts`, `src/fixtures/`, a root `distribute.ts` must all ship, and `dist/**` is rooted at the PACKAGE root so a `src/dist/` source directory ships while `**/` matches zero directories so a ROOT-level `index.test.ts` is caught); ANY hit refuses and names EVERY denied file, and a listing the gate parses NO files out of refuses too — quoting the packer's own stdout back, because zero `packed` lines means either an allowlist that ships nothing or a listing shape that drifted, and those have opposite fixes. **publish** — `npm publish --access public` (a scoped package defaults to restricted), PLAIN npm and never `bun publish` (bun 1.3.x ignores `~/.npmrc`'s `_authToken` and dies with "missing authentication" — field-hit on v0.1.0; this module spawns `bun` for the pack dry run alone); an `EOTP` in the output comes back with the granular-token fix, every other failure with npm's own words. **smoke** — `npm view <name> version` polled until the registry serves `context.version` (propagation can lag MINUTES on a first publish: a 404 right after `+ pkg@<V>` printed is NOT a failure, only an exhausted budget is; the budget is 15 polls × 20s by default and INJECTABLE via `createNpmAdapter({attempts, delayMs})` — the module's second export, which exists so tests need not wait), then the optional `release.smoke` argv (an ARRAY, never a shell string), the only step that proves the published ARTIFACT works. The package name is read from the release dir's own `package.json`. The adapter is registered by the SPINE (`registerReleaseAdapter("npm", npmAdapter)` in release.ts) rather than self-registering: a side-effect import of a self-registering target evaluates the target BEFORE the spine's registry const exists (a TDZ `ReferenceError` at import time, verified), while this direction leaves the target importing only TYPES — no runtime cycle. `node:*` only (ADR-0008). - `src/release-cask.ts` (+ `src/release-cask.test.ts`) — the **homebrew-cask release TARGET**: the `ReleaseAdapter` behind `release.type: "homebrew-cask"`, for a Tauri macOS app shipped through a Homebrew tap. It uses SIX of the seam's moments (the four every target has, plus BOTH optional hooks). **preflight** — `<dir>/src-tauri/tauri.conf.json` exists (else this is not a Tauri app), `rustup target list --installed` carries BOTH `aarch64-apple-darwin` and `x86_64-apple-darwin` (a missing one refuses with the literal `rustup target add …` fix), `gh auth status`, and `release.tap` + `release.cask` are configured (each refusal names the missing key) — plus, ONLY when the OPTIONAL `release.notaryProfile` is set, the two one-time human setups notarization needs: `security find-identity -v -p codesigning` listing a `Developer ID Application` certificate (the tool EXITS 0 while listing none, so the verdict is its OUTPUT; the refusal names Xcode → Settings → Accounts as where the certificate is made, and records that SIGNING is `tauri.conf.json`'s `signingIdentity`, never dobby's job) and `xcrun notarytool history --keychain-profile <p>` exiting 0 (the refusal carries the one-time `xcrun notarytool store-credentials <p> --apple-id … --team-id …`). **bumpExtras** — `src-tauri/Cargo.toml`'s version, rewritten byte-surgically and SCOPED to the `[package]` section (the window from the `[package]` header to the NEXT `[section]`: `version = ` also sits at the start of a line under `[dependencies.<crate>]`, and a whole-file regex bumps a dependency instead), then `cargo check` in the crate — ANY cargo command reconciles `Cargo.lock`, whose stale version would otherwise ride along in the release commit. **packGate** — `bun tauri build --bundles app,dmg --target universal-apple-darwin`, then exactly ONE `*.dmg` under `src-tauri/target/universal-apple-darwin/release/bundle/dmg/` (zero and many are separate refusals) and `PlistBuddy -c "Print :CFBundleShortVersionString"` on the built `.app` equal to the version being released (a bundle that predates the bump would ship an app reporting the old number while the cask advertises the new one) — PlistBuddy is spawned BARE with `/usr/libexec` APPENDED to the child's PATH, never by absolute path. With a `release.notaryProfile` configured, THREE more steps run here (last, AFTER the version gate — a stale bundle must never cost an Apple round trip — and still before any tag, the last place a release can be refused for free): `xcrun notarytool submit <dmg> --keychain-profile <p> --wait` whose stdout must carry `status: Accepted` (the tool exits 0 on a REJECTED submission, and a refusal quotes its FULL log, never truncated), `xcrun stapler staple <dmg>`, then `spctl -a -t open --context context:primary-signature -vv <dmg>` whose output must carry `Notarized Developer ID` (spctl exits 0 for a signed-but-un-notarized build and writes its assessment to STDERR, so the gate reads BOTH streams and matches CASE-SENSITIVELY — Gatekeeper's refusal reads `Unnotarized Developer ID`); each step gates the next, so a rejected submission is never stapled and an unstapled dmg is never assessed. **publish** — a NO-OP: the dmg is a local file until the GitHub release exists, so there is nothing this target could half-publish. **postRelease** — `gh release upload v<V> <dmg>`, `shasum -a 256` on that same file, then the TAP: `gh repo clone <tap>` into a mkdtemp dir (a failed clone is the probe — `gh repo create <tap> --public` then clone again), the cask's `version` + `sha256` lines replaced in place (indentation captured, never assumed) or the whole file SCAFFOLDED from the module's template when the tap carries none (its `url` templates Homebrew's `#{version}` and SANITIZES the asset name the way GitHub serves it — spaces become dots — so later releases only ever move two lines), `ruby -c` before the commit (an absent ruby is a NOTE, a rejection is a refusal), then `git add` + `git commit -m "<cask> <V>"` + `git push -u origin HEAD` in the tap checkout. **smoke** — REPORT-ONLY: it runs NOTHING and hands back `brew install --cask <tap-short>/<cask>` (Homebrew's own rule: `<user>/homebrew-<name>` is referred to as `<user>/<name>`), plus — CONDITIONALLY, only when nothing was notarized — the quarantine caveat (`xattr -dr com.apple.quarantine …`), which next to a notarized build would simply be a lie. **The credentials are keychain-only**: `notaryProfile` is the NAME of a notarytool keychain profile and the only credential fact dobby ever holds; there is deliberately NO `APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` env-var path (mad-eye ADR 0005 — an app-specific password in the environment is inherited by every child, shell history and CI log). `security`, `xcrun` and `spctl` are spawned BARE like the rest, which is also what keeps them stubbable in tests. Registered by the SPINE like the npm one (`registerReleaseAdapter("homebrew-cask", caskAdapter)` in release.ts), so this module imports only TYPES from it — no runtime cycle, and the target is reachable from `run.ts`'s graph through the spine. `node:*` only (ADR-0008). @@ -81,7 +81,7 @@ under `plugin/agents/`; this CLI carries no worker-consumption recipe. - `build-plan [--file <doc>] [--task <task.json>] [--json]` → the task-dependency plan for `/dobby:execute` (and, with `--task`, for `/dobby:dispatch`). Default source: the `## Spec` body of `<workroot>/STATE.md` (`--file` overrides the document), whose task table is located by its HEADER ROW — `#`/`Task`/`Depends on`/`Affected areas`/`Verify recipe`, with `Description`, `Test-first` and `Destructive` all OPTIONAL (no Description → the title is the task's `spec`; an absent flag column → false) — so a spec written before the `### ` sub-heading format still plans, and a non-task table inside the spec is skipped. `--task <file>` reads ONE ad-hoc task from JSON instead and never touches STATE.md (it carries the surface the spec named `--task-file`, which the dispatcher's flag set has no option for). Answers `{tasks[{id,title,spec,decisions,constraints,areas[],verifyRecipe,testFirst,destructive,dependsOn[]}], hasTestSuite{value,specSays,disagreement}, manualVerifySetup: "none"|string[], preconditions{ok,missing[{taskId,field}],danglingDeps[{taskId,dependsOn}],cycles[[id…]]}, workRoot}` — `tasks` VERBATIM for the Architect to dispatch directly (`decisions`/`constraints` empty by contract, `devUrl` merged by the coordinator). There is NO wave/batch grouping in the payload: `dependsOn` (the row's `Depends on` ids — `[]` for `—`/empty) is the ONLY thing that says who a task waits for, and a task is ready the moment every id it names has reached `done` — which is what lets the caller SKIP a task whose blocker never passed without holding back anything independent of it. Ids are STRINGS, the same ones `dependsOn` references. Failing preconditions exit 1 **with the payload still on stdout** (the refusal names each task and cell on stderr); a missing document / unparseable `--task` file / table-less spec is a hard error with no payload. Fails hard outside a git repo. - `ship [--message-file <f>] [--pr-body-file <f>] [--json]` → the COMMIT CEREMONY in ONE call, the mechanized half of `/dobby:commit`. `--message-file` is REQUIRED and validated FIRST (present, readable, non-blank; resolved against the CALLER's cwd) — a ceremony that cannot produce a message leaves the tree exactly as it found it (unstaged, un-formatted, un-gated). Then: stage when nothing is staged → the **GATE IN-PROCESS** (`check([], root, {}, fix=true)`, never a `dobby` subprocess) → `.dobby/` exclude-ensured (in `.git/info/exclude`) and the WHOLE tree re-staged (the gate judges the working tree, so committing a caller-staged SUBSET would record a green verdict for a tree that was never checked) → the gate cache → `git commit -F` → push pinned to ORIGIN (`-u origin HEAD` when the branch tracks nothing; a non-origin upstream still pushes to origin, reported via `pushNote`) → the pull request. A detached HEAD is refused before any mutation. **The exit code decides**: a nonzero gate returns the gate's OWN code with every finding printed WHOLE (uncapped, never `formatCheck`'s 50-per-tool sample) and commits nothing. The PR is opened ONLY off a NON-TRUNK branch (`main`/`master` have nowhere to open one from) and ONLY with a `--pr-body-file` (the body is the caller's to author); an EXISTING PR for the branch is reported, not duplicated; a failed push short-circuits it, and a PR gh could not open is a NOTE, not a failure (the commit already landed). Answers `{cacheNote, cacheWritten, committed, gateExitCode, gateNote, prNote, prUrl, pushNote, pushed, sha}` — the notes distinguish "skipped by policy" from "could not be done", and `gateNote` carries the `gate skipped: inputs unchanged since last green (…)` line when the in-process gate was served from the per-check cache (null when it really ran). Fails hard outside a git repo. - `review fetch [--pr N] [--json]` · `review apply (--plan <f>|--stdin) [--pr N] [--dry-run] [--json]` · `pr watch [--pr N] [--deadline <sec>] [--await-review] [--json]` → the `gh` surface of the address-review stage; `--pr` defaults to the CURRENT branch's PR. `fetch` → `{pr, adapter, candidates, threads, summary}`: the open review THREADS over GraphQL (drained with gh's mandatory `$endCursor` pagination contract, each thread carrying its last comments so a re-run sees its own prior replies) plus the bot's summary comment over REST (sorted by `updated_at`, since the bot EDITS one comment in place, and bot logins matched by BARE slug because GraphQL and REST disagree about the `[bot]` suffix). A PR with nothing to address is `threads: []` at exit 0 — an ANSWER, not an error. `apply` consumes a disposition plan (`{pr, reTrigger, plan:[{threadId, disposition: fix|dismiss|outdated|defer, reply}]}`) from `--plan`/`--stdin`, replies + resolves in batches, SKIPS threads it already answered (idempotent by construction) and re-triggers when asked; `defer` deliberately does NOT resolve (a deferred finding stays open) and `--dry-run` makes the same decisions with zero writes. Any failure exits 1 with `{failures[], replied[], resolved[], retriggered, skipped[], dryRun}`. `pr watch` owns its OWN polling loop and derives the verdict from check BUCKET COUNTS (`gh pr checks --json` always exits 0, and `--watch --json` is a hard error) — `ci-failed|ci-green|ci-pending|merge-ready|feedback-present|open-unreviewed|skipped`, with `--deadline` (default 300s) budgeting EACH wait phase separately (CI, then the review under `--await-review`) so a slow CI run can never eat the review wait. NO merge path — every judgment stays in `/dobby:address-review`. All three fail hard outside a git repo, and a gh that could not report at all is surfaced, never read as an empty (green) check list. -- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch: string | null` is the repository's own trunk, resolved at `mainRoot` by an ordered cascade, first hit wins — `refs/remotes/origin/HEAD` (stripped of its `origin/` prefix, and counted only while `refs/remotes/origin/<that name>` still exists — a stale/dangling head falls through as if unset), then remote-tracking `origin/main`/`origin/master`, then local `main`/`master`, else `null` when nothing in the repo names a trunk. Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. +- `finish --preflight [--json]` → the READ-ONLY teardown verdict for the goal the session is CURRENTLY standing on, resolved from wherever that is and computed but never acted on: `{verdict: "safe"|"blocked"|"confirm-required", reasons[], inWorktree, worktreePath, mainRoot, branch, defaultBranch, defaultBranchSource, branchDeleteSafe, dirty, dobbyInstalled, pr}`. `safe` = a MERGED PR + a clean tree; `blocked` = dobby is not installed AT THE WORKROOT the command runs in (not `mainRoot` — a linked worktree's install is independent of its main checkout's), so the mandatory `dobby down` cannot run — it OUTRANKS every other signal; everything else is `confirm-required`. There is no `--slug` to disambiguate — the goal is always the one the session is standing in. `inWorktree` says whether the session stands in a linked worktree at all (with `worktreePath`/`mainRoot` alongside it). `defaultBranch: string | null` is the repository's own trunk, with `defaultBranchSource` naming what answered it: the PR's own base branch FIRST — when the PR exists and its `baseRefName` is a non-empty string, `defaultBranch` is that name and `defaultBranchSource` is `"pr-base"`. Only when there is no usable base (no PR, or one whose answer carries no `baseRefName`) does the git cascade run, resolved at `mainRoot`, first hit wins — `refs/remotes/origin/HEAD` (stripped of its `origin/` prefix, and counted only while `refs/remotes/origin/<that name>` still exists — a stale/dangling head falls through as if unset; `"origin-head"`), then remote-tracking `origin/main`/`origin/master` (`"remote-conventional"`), then local `main`/`master` (`"local-conventional"`), else `null`/`null` when nothing in the repo names a trunk. Removal itself stays native/manual in the skill (`ExitWorktree` in a worktree, a plain-checkout branch delete otherwise) — this command removes nothing. Fails hard outside a git repo. - `repro [--expect red|green] [--repeat N] [--bench] [--json] -- <cmd…>` → the red/green capture harness. Everything after `--` is the command, spawned with cwd pinned to the workroot (fails hard outside a git repo) and its stdout/stderr captured WHOLE — repro never truncates. One run answers `{invocation:{argv,cwd}, exitCode, stdout, stderr, durationMs, verdict, matched, reproId}`: `verdict` = red on any nonzero exit, `matched` = `verdict === --expect` (explicitly `null`, never absent, without `--expect`). Exit code: **1 ONLY on a mismatch** (the "your loop is not red-capable" signal); a failing command with NO `--expect` exits 0 — the harness reports an exit code, it never inherits one. `reproId` is a short hash of the workroot + the command argv ONLY (repro's own flags are excluded, so every run of one loop keys the same record), and each run persists `{baseline, latest, reproId}` at `<workroot>/.dobby/repro/<reproId>.json` (`.dobby/` gitignore-ensured); a failed write is a stderr warning, not a failure. `--repeat N` runs the loop N times sequentially and ADDS `{runs, redCount, greenCount, reproductionRate (redCount/runs), deterministic, firstDivergent:{run (1-based), stdout, stderr}|null, durations}` — the base record is then the first run whose verdict equals the SET's (red as soon as ANY run went red), so `--expect red --repeat N` is a red-CAPABILITY probe and the pasted output is the failing run's. `--bench` ADDS `{samples, min, median (over sorted samples), mean, max}` plus `baseline` (the previous bench of this reproId, `null` on the first) and `delta` (current MINUS baseline per timing stat — faster reads negative), then stores its own stats as the next baseline; a non-bench run leaves the stored baseline alone. `--json` prints the full payload as the sole stdout; the default render is a compact human summary (headline + `cmd:`/`cwd:` + the repeat/bench lines + the record path) with a labelled TAIL of the output. - `kb list --kind <k> | kb record --kind <k> --concept <kebab> --title <t> --reason-file <f> --entry <line>` → the durable knowledge bases at `<workroot>/docs/out-of-scope/` and `<workroot>/docs/learn-discarded/` (fails hard outside a git repo). `--kind` is REQUIRED for both and is the module's only parameter; an unknown one is a hard error naming both KBs (a typo must never read as "that KB is empty" — dedup would silently stop working). `list` → a bare JSON ARRAY (under `--json`) of `{concept (filename stem), path, title (the H1), statement (the first paragraph, wrapped lines joined), priorEntries (the `- ` bullets under the kind's prior-section, markers stripped)}`, one per `*.md`, sorted by filename; an ABSENT directory is `[]` at exit 0, never an error. `record` → ONE file per concept: an existing concept's file gets the entry APPENDED as a bullet under its prior-section (every byte before that heading untouched — the rationale written the first time wins over this call's `--title`/`--reason-file`), an absent one is created after a lazy `mkdir`, carrying the canonical skeleton (H1, the one-line statement, the kind's why-heading + the reason body, the kind's prior-heading + the first bullet). `--reason-file` is split at its FIRST LINE (the statement) with the REST as the reason body; a file with no body is refused. Answers the bare `{path, created, appended}`. Every refusal goes to stderr with exit 1 (no `ok` envelope — the payloads are the spec's bare shapes). - `adr new "<title>" [--status proposed|accepted|deprecated]` → allocate the next ADR number and create `<workroot>/docs/adr/NNNN-<slug>.md` (fails hard outside a git repo; the dir is created lazily, and only after the inputs validate — a refusal never leaves an empty `docs/adr/` behind). The title is a POSITIONAL (every positional after the token, joined); the slug is DERIVED from it. Numbering is `max + 1` over the local directory AND `git ls-tree -r origin/HEAD --name-only -- docs/adr` (a sibling worktree's ADR is pushed long before it lands here; no origin / no git / no upstream `docs/adr` all score 0, so a remote-less repo still files ADRs), and the number is claimed — not merely the filename: each attempt re-reads `docs/adr/` and moves to the next number if anything already carries the `NNNN-` prefix, whatever its slug, with `O_EXCL` (`flag: "wx"`) behind it so an `EEXIST` retries instead of truncating an identically-named ADR. The scan is a read and is stale the instant it returns, so the claim happens at WRITE time, never at scan time. Writes a SKELETON only — `# NNNN. <title>`, the optional `**Status:** <status>` line (omitted without `--status`), and a placeholder paragraph; body authorship stays with the architect. Answers the bare `{number, slug, path}`; a missing title / an unknown status is a refusal on stderr with exit 1, naming what IS valid. diff --git a/cli/README.md b/cli/README.md index 6ee9704..6db2ff0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -452,7 +452,7 @@ dobby migrate preflight --json dobby migrate verify --json ``` -`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, resolved at `mainRoot` by an ordered cascade — `origin/HEAD` (only while the ref it names still exists — a stale/dangling head is treated as unset), then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one), and whether the branch is safe to delete. +`finish --preflight` answers the teardown verdict for one goal, resolved from wherever the session stands: `safe` (a **merged** PR and a clean tree), `blocked` (dobby is not installed **at the workroot the session stands in** — the mandatory `dobby down` cannot run — that outranks every other signal), else `confirm-required`; it also reports whether the session stands in a linked worktree at all (`inWorktree`, `worktreePath`, `mainRoot`), the repository's own default branch (`defaultBranch: string | null`, with `defaultBranchSource` naming what answered it — the PR's own base branch FIRST, when the PR exists and named one (`"pr-base"`); only when there is no usable base does the git cascade run, resolved at `mainRoot` — `origin/HEAD` (only while the ref it names still exists — a stale/dangling head is treated as unset; `"origin-head"`), then remote-tracking `main`/`master` (`"remote-conventional"`), then local `main`/`master` (`"local-conventional"`), else `null`/`null` when nothing names one), and whether the branch is safe to delete. `dobby migrate preflight` says whether a repo still needs the config migration (naming each legacy signal and snapshotting what the migration must carry across); `dobby migrate verify` runs the gate in-process and reports the environment read back plus whatever was left behind. Both **exit 0 with a payload for every verdict** — they inform, they never refuse. diff --git a/cli/src/preflight.test.ts b/cli/src/preflight.test.ts index e274705..20cc043 100644 --- a/cli/src/preflight.test.ts +++ b/cli/src/preflight.test.ts @@ -235,10 +235,74 @@ const PR_GOAL2 = { url: "https://github.com/acme/scratch/pull/11", }; +// --- The base branch the PR was opened against (slice 13c) ------------------ +// `gh pr view --json <fields>` answers the fields the CALLER ASKED FOR and no +// others, so `prAnswers` reproduces that half of the boundary's behavior: a PR +// carrying a `baseRefName` installs TWO answers — the full JSON for an argv that +// requested the field, and the old three-field JSON for one that did not. A CLI +// that never widened its `--json` list therefore reads exactly what today's list +// yields, and the base-branch cases stay red until the REQUEST is right too. +// A PR with no `baseRefName` (an older gh, a malformed answer) installs one +// answer with the key simply ABSENT — which is also what every case written +// before this slice keeps getting, unchanged. +interface PrAnswer { + baseRefName?: string; + mergedAt: string | null; + state: string; + url: string; +} + +const PR_BASE_DEVELOP: PrAnswer = { + baseRefName: "develop", + mergedAt: "2026-07-24T14:05:00Z", + state: "MERGED", + url: "https://github.com/acme/scratch/pull/12", +}; +const PR_BASE_RELEASE: PrAnswer = { + baseRefName: "release", + mergedAt: "2026-07-25T16:20:00Z", + state: "MERGED", + url: "https://github.com/acme/scratch/pull/13", +}; +const PR_BASE_EMPTY: PrAnswer = { + baseRefName: "", + mergedAt: "2026-07-26T08:40:00Z", + state: "MERGED", + url: "https://github.com/acme/scratch/pull/14", +}; +const PR_BASE_ABSENT: PrAnswer = { + mergedAt: "2026-07-27T09:10:00Z", + state: "MERGED", + url: "https://github.com/acme/scratch/pull/15", +}; + +function prAnswers(branch: string, pr: PrAnswer): StubResponse[] { + const { baseRefName, ...asked } = pr; + const withoutBase: StubResponse = { + match: branch, + stdout: JSON.stringify(asked), + }; + if (baseRefName === undefined) { + return [withoutBase]; + } + return [ + { + match: [branch, "baseRefName"], + stdout: JSON.stringify({ ...asked, baseRefName }), + }, + withoutBase, + ]; +} + // Matching is a literal substring of the joined argv, FIRST-WINS in order — hence // `goal2` ahead of `goal`, whose name it contains. The trailing catch-all is gh's // real behavior for a branch with no pull request (exit 1 on stderr). +// The slice-13c branches come first and share no substring with any name below. const GH_ANSWERS: StubResponse[] = [ + ...prAnswers("pr-base-develop", PR_BASE_DEVELOP), + ...prAnswers("pr-base-release", PR_BASE_RELEASE), + ...prAnswers("pr-base-empty", PR_BASE_EMPTY), + ...prAnswers("pr-base-absent", PR_BASE_ABSENT), { match: "goal2", stdout: JSON.stringify(PR_GOAL2) }, { match: "still-open", stdout: JSON.stringify(PR_STILL_OPEN) }, { match: "dirty-tree", stdout: JSON.stringify(PR_DIRTY_TREE) }, @@ -267,6 +331,7 @@ interface FinishPreflight { branch: string; branchDeleteSafe: boolean; defaultBranch: string | null; + defaultBranchSource: string | null; dirty: { count: number; files: string[] }; dobbyInstalled: boolean; inWorktree: boolean; @@ -974,14 +1039,16 @@ function makePushedCheckout(opts: { local: string; pushAs?: string }): string { // A main checkout with NO remote at all, born on `trunk` and left standing on // `goal` — the cascade's third and fourth steps, where the only branch names in -// the repository are LOCAL ones. -function makeLocalOnlyCheckout(trunk: string): string { +// the repository are LOCAL ones. `stand` names the goal branch the session sits +// on: the stub `gh` is keyed on it, so a fixture that must meet a branch with NO +// PR stands on a name the stub answers the catch-all for. +function makeLocalOnlyCheckout(trunk: string, stand = "goal"): string { const mainRoot = makeMainCheckout({ branch: trunk, config: true, dobby: true, }); - gitIn(mainRoot, ["switch", "-q", "-c", "goal"]); + gitIn(mainRoot, ["switch", "-q", "-c", stand]); return mainRoot; } @@ -1159,6 +1226,7 @@ function makeRemoteHeadFixture(opts: { head: string; push: string[]; stale?: boolean; + stand?: string; trunk: string; }): string { const mainRoot = makeMainCheckout({ @@ -1178,7 +1246,7 @@ function makeRemoteHeadFixture(opts: { if (opts.stale === true) { gitIn(mainRoot, ["update-ref", "-d", `refs/remotes/origin/${opts.head}`]); } - gitIn(mainRoot, ["switch", "-q", "-c", "goal"]); + gitIn(mainRoot, ["switch", "-q", "-c", opts.stand ?? "goal"]); return mainRoot; } @@ -1306,9 +1374,217 @@ describe("finish --preflight — a dangling remote head", () => { }); }); +// =========================================================================== +// Slice 13c — the PR's own BASE BRANCH is the trunk, and the git cascade is what +// answers only when there is no usable base to read. A goal branch with an open +// or merged PR knows its trunk as a FACT: the forge recorded which branch the PR +// targets. Guessing from refs is the fallback for a branch that never opened one. +// +// So step 0 of the resolution is the forge: +// 0. the PR exists and its `baseRefName` is a non-empty string → +// `defaultBranch` = that name, `defaultBranchSource` = `"pr-base"`; +// otherwise the git cascade of slice 13/13b runs exactly as before, now +// NAMING which step answered: `"origin-head"` (1), `"remote-conventional"` +// (2), `"local-conventional"` (3), and `null` for both fields when nothing in +// the repository names a trunk. +// "No usable base" covers BOTH a branch with no PR at all and a PR whose answer +// carries no `baseRefName` — an older gh, or a malformed one. The verdict still +// never depends on any of it. +// +// Independence: `develop` and `release` — the two answers the forge supplies — +// appear in NO ref of the fixtures that expect them (not the remote head, not a +// remote branch, not a local one, not the branch the session stands on). They +// can only have come from the stub gh's answer, so an implementation that +// "validates" the forge's base against the local refs, or that reads the refs at +// all when a base is there, fails both. Conversely the cascade cases stand on +// branches the stub answers NO PR for, so their names come from the refs WE +// wrote with plain git. Each pair — (a)/(b) and (c)/(g) — is the SAME git +// fixture shape under a different gh answer, which is what isolates the forge +// from the refs. +// =========================================================================== + +// The trunk and the step that answered reach a human reader as two statements, +// each a label and its value on ONE line — not two words that merely both occur +// somewhere in the report. `unknown` is the literal for a source nothing +// supplied, mirroring the `defaultBranch: unknown` line beside it. +const DEFAULT_BRANCH_SOURCE_UNKNOWN = /default.?branch.?source:?[ \t]*unknown/i; +const DEFAULT_BRANCH_IS_DEVELOP = /default.?branch:?[ \t]*develop/i; +const DEFAULT_BRANCH_SOURCE_IS_PR_BASE = + /default.?branch.?source:?[ \t]*pr-base/i; + +// The round-4 repository, verbatim: a remote head left DANGLING at +// `origin/master`, a legacy `origin/main` still published beside it, and a local +// `main` too — so every step of the git cascade has something to say, and what +// it says (`main`) is not what the PR says. Built with the slice-13b builder; +// only the branch the session stands on changes, which is what selects the stub +// gh's answer. +function makeLegacyRemoteFixture(stand: string): string { + const mainRoot = makeRemoteHeadFixture({ + head: "master", + push: ["master:master", "master:main"], + stale: true, + stand, + trunk: "master", + }); + gitIn(mainRoot, ["branch", "main"]); + return mainRoot; +} + +// The live-head repository: `refs/remotes/origin/HEAD` → `origin/trunk`, target +// intact, nothing else naming a trunk. The cascade's FIRST step answers here, +// which is the strongest thing a PR base can be asked to beat. +function makeLiveHeadFixture(stand: string): string { + return makeRemoteHeadFixture({ + head: "trunk", + push: ["trunk:trunk"], + stand, + trunk: "trunk", + }); +} + +describe("finish --preflight — the default branch from the PR's base", () => { + let prBaseOverCascade: string; + let cascadeWithNoPr: string; + let prBaseOverLiveHead: string; + + beforeAll(() => { + prBaseOverCascade = makeLegacyRemoteFixture("pr-base-develop"); + cascadeWithNoPr = makeLegacyRemoteFixture("no-pr-cascade"); + prBaseOverLiveHead = makeLiveHeadFixture("pr-base-release"); + }); + + it("names the branch the PR was opened against, not the one the refs suggest", async () => { + expect( + remoteHead(prBaseOverCascade), + "fixture must leave origin/HEAD naming a ref that is gone" + ).toEqual({ head: "origin/master", target: false }); + const preflight = await finishPreflight(prBaseOverCascade); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ defaultBranch: "develop", defaultBranchSource: "pr-base" }); + }); + + it("falls back to the git cascade for a branch that opened no PR", async () => { + expect( + remoteHead(cascadeWithNoPr), + "fixture must leave origin/HEAD naming a ref that is gone" + ).toEqual({ head: "origin/master", target: false }); + const preflight = await finishPreflight(cascadeWithNoPr); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + pr: preflight.pr, + }).toEqual({ + defaultBranch: "main", + defaultBranchSource: "remote-conventional", + pr: null, + }); + }); + + it("prefers the PR's base over a remote head that is right where it was", async () => { + expect( + remoteHead(prBaseOverLiveHead), + "fixture must leave origin/HEAD naming a ref that exists" + ).toEqual({ head: "origin/trunk", target: true }); + const preflight = await finishPreflight(prBaseOverLiveHead); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ defaultBranch: "release", defaultBranchSource: "pr-base" }); + }); + + it("tells a human reader the trunk and where it came from", async () => { + const result = await run(["finish", "--preflight"], prBaseOverCascade); + expect(result.stdout).toMatch(DEFAULT_BRANCH_IS_DEVELOP); + expect(result.stdout).toMatch(DEFAULT_BRANCH_SOURCE_IS_PR_BASE); + }); +}); + +describe("finish --preflight — a PR answer with no base branch", () => { + let baseAbsent: string; + let baseEmpty: string; + + beforeAll(() => { + // The same live-head fixture as above under a gh answer whose JSON carries no + // `baseRefName` key at all — an older gh, or one asked a narrower question. + baseAbsent = makeLiveHeadFixture("pr-base-absent"); + // …and under one whose `baseRefName` is the empty string: a malformed answer + // that is present but names nothing. `""` is not a branch, so it is not an + // answer either. + baseEmpty = makeLiveHeadFixture("pr-base-empty"); + }); + + it("falls back to the git cascade when the PR answer omits the base branch", async () => { + const preflight = await finishPreflight(baseAbsent); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ defaultBranch: "trunk", defaultBranchSource: "origin-head" }); + }); + + it("falls back to the git cascade when the PR names an empty base branch", async () => { + const preflight = await finishPreflight(baseEmpty); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ defaultBranch: "trunk", defaultBranchSource: "origin-head" }); + }); +}); + +describe("finish --preflight — the step that answered is named", () => { + let fromRemoteHead: string; + let fromLocalBranches: string; + let fromNothingAtAll: string; + + beforeAll(() => { + fromRemoteHead = makeLiveHeadFixture("no-pr-head"); + // No remote at all, a local `master` beside the goal branch: the cascade's + // third step, and the only one a repo like this can reach. + fromLocalBranches = makeLocalOnlyCheckout("master", "no-pr-local"); + // Born on `develop`, never pushed, no `main` and no `master` anywhere: no + // step of the cascade answers and no PR supplies a base either. + fromNothingAtAll = makeLocalOnlyCheckout("develop", "no-pr-nothing"); + }); + + it("names the remote head as the source when the head answered", async () => { + const preflight = await finishPreflight(fromRemoteHead); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ defaultBranch: "trunk", defaultBranchSource: "origin-head" }); + }); + + it("names the local branches as the source when a local name answered", async () => { + const preflight = await finishPreflight(fromLocalBranches); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ + defaultBranch: "master", + defaultBranchSource: "local-conventional", + }); + }); + + it("admits to no trunk and no source when nothing names one", async () => { + const preflight = await finishPreflight(fromNothingAtAll); + expect({ + defaultBranch: preflight.defaultBranch, + defaultBranchSource: preflight.defaultBranchSource, + }).toEqual({ defaultBranch: null, defaultBranchSource: null }); + }); + + it("tells a human reader that both the trunk and its source are unknown", async () => { + const result = await run(["finish", "--preflight"], fromNothingAtAll); + expect(result.stdout).toMatch(DEFAULT_BRANCH_UNKNOWN); + expect(result.stdout).toMatch(DEFAULT_BRANCH_SOURCE_UNKNOWN); + }); +}); + // =========================================================================== // Slice 14 — the payload's field list is EXACT: the ten fields the finish skill -// already reads plus `defaultBranch`, and nothing else. Pinned as a whole set +// already reads plus `defaultBranch` and `defaultBranchSource`, and nothing +// else. Pinned as a whole set // (not field-by-field) so both halves of the contract hold — a dropped field is // caught as surely as a stray one, and the removed kit-made-worktree fields can // never creep back in under a new name. @@ -1318,6 +1594,7 @@ const FINISH_PAYLOAD_KEYS = [ "branch", "branchDeleteSafe", "defaultBranch", + "defaultBranchSource", "dirty", "dobbyInstalled", "inWorktree", @@ -1335,7 +1612,7 @@ describe("the finish preflight payload's field list", () => { checkout = makePlainCheckout("goal"); }); - it("carries exactly the eleven fields the finish skill reads", async () => { + it("carries exactly the twelve fields the finish skill reads", async () => { const preflight = await finishPreflight(checkout); expect(Object.keys(preflight).sort()).toEqual(FINISH_PAYLOAD_KEYS); }); diff --git a/cli/src/preflight.ts b/cli/src/preflight.ts index 0a15172..124d8cb 100644 --- a/cli/src/preflight.ts +++ b/cli/src/preflight.ts @@ -55,6 +55,24 @@ interface PullRequest { url: string; } +// The raw `gh pr view` answer BEFORE it is projected down to `PullRequest`: +// `baseRefName` drives `defaultBranch` resolution but is never itself part of +// the payload's `pr` field (the skills only ever read the original three). +interface PullRequestAnswer extends PullRequest { + baseRefName: string | null; +} + +// Which step of the resolution answered `defaultBranch` — reported ALONGSIDE the +// name so the skill (and a human reader) can tell a forge-recorded fact from a +// guess: `"pr-base"` beats every git-cascade step, which is why it is checked +// FIRST and, when it fires, none of the cascade runs at all. +type DefaultBranchSource = + | "pr-base" + | "origin-head" + | "remote-conventional" + | "local-conventional" + | null; + // The uncommitted work a teardown would destroy: `git status --porcelain` lines, // UNTRACKED files included (losing those is exactly what the gate protects). interface DirtyTree { @@ -67,12 +85,18 @@ type Verdict = "blocked" | "confirm-required" | "safe"; interface FinishPreflight { branch: string; branchDeleteSafe: boolean; - // The repository's own trunk — resolved AT `mainRoot`, never the goal branch - // the session stands on — by an ordered cascade, first hit wins: the remote - // head, then the remote-tracking `main`/`master`, then the LOCAL `main`/ - // `master`, else `null` when nothing in the repo names one. `null` is an - // honest "I don't know", never a guess. + // The repository's own trunk. The PR's own `baseRefName` is the FORGE-recorded + // fact and is checked first; only when there is no usable base (no PR, or one + // whose answer carries no base) does the git cascade — resolved AT `mainRoot`, + // never the goal branch the session stands on — run: the remote head, then the + // remote-tracking `main`/`master`, then the LOCAL `main`/`master`, else `null` + // when nothing in the repo names one. `null` is an honest "I don't know", + // never a guess. defaultBranch: string | null; + // WHICH step answered `defaultBranch`: `"pr-base"` (the forge), `"origin-head"`, + // `"remote-conventional"`, `"local-conventional"` (the three cascade steps), or + // `null` alongside a `null` `defaultBranch` when nothing answered. + defaultBranchSource: DefaultBranchSource; dirty: DirtyTree; dobbyInstalled: boolean; // True iff the session's workroot is a LINKED worktree (git's own definition, @@ -130,8 +154,10 @@ function currentBranch(root: string): string { } // The repository's own trunk — the branch the finish skill switches to before -// it force-deletes a goal branch. `"main"` is an assumption, not a fact, so -// this reads the repo itself through an ORDERED cascade, first hit wins: +// it force-deletes a goal branch. The PR's own `baseRefName` is the FORGE's +// recorded fact and is checked first (`resolveDefaultBranch`); this function is +// the FALLBACK the forge cannot answer, so `"main"` stays an assumption, never a +// guess: it reads the repo itself through an ORDERED cascade, first hit wins: // 1. `refs/remotes/origin/HEAD` (`git symbolic-ref` answers `origin/<name>`; // the `origin/` prefix is stripped) — but ONLY while `refs/remotes/ // origin/<name>` still exists. A DANGLING head (the symbolic ref @@ -151,7 +177,25 @@ function currentBranch(root: string): string { const REMOTE_HEAD_PREFIX = "origin/"; const CONVENTIONAL_TRUNK_NAMES = ["main", "master"] as const; -function defaultBranchAt(root: string): string | null { +interface ResolvedDefaultBranch { + defaultBranch: string | null; + defaultBranchSource: DefaultBranchSource; +} + +// The forge FIRST, the git cascade only when there is no usable base: `pr` is +// null, or its `baseRefName` is missing/empty (an older gh, or a malformed +// answer) — both read as "no usable base" and fall through identically. +function resolveDefaultBranch( + mainRoot: string, + pr: PullRequestAnswer | null +): ResolvedDefaultBranch { + if (pr !== null && pr.baseRefName !== null && pr.baseRefName !== "") { + return { defaultBranch: pr.baseRefName, defaultBranchSource: "pr-base" }; + } + return defaultBranchAt(mainRoot); +} + +function defaultBranchAt(root: string): ResolvedDefaultBranch { const head = runCapture( "git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], @@ -166,23 +210,26 @@ function defaultBranchAt(root: string): string | null { // reads exactly like an UNSET head: the cascade continues to tier 2 // rather than answering with a name nothing tracks. if (refExists(root, `refs/remotes/origin/${target}`)) { - return target; + return { defaultBranch: target, defaultBranchSource: "origin-head" }; } } for (const name of CONVENTIONAL_TRUNK_NAMES) { if (refExists(root, `refs/remotes/origin/${name}`)) { - return name; + return { + defaultBranch: name, + defaultBranchSource: "remote-conventional", + }; } } for (const name of CONVENTIONAL_TRUNK_NAMES) { if (refExists(root, `refs/heads/${name}`)) { - return name; + return { defaultBranch: name, defaultBranchSource: "local-conventional" }; } } - return null; + return { defaultBranch: null, defaultBranchSource: null }; } // Whether `ref` exists in `root`'s repository — `git show-ref --verify` reads @@ -224,12 +271,19 @@ export function runFinishPreflight(context: CommandContext): CommandResult { const worktreePath = inWorktree ? workroot : null; const branch = currentBranch(workroot); - const pr = readPullRequest(workroot, branch); + const prAnswer = readPullRequest(workroot, branch); const dirty = readDirtyTree(workroot); // Per-tree, not per-checkout: `node_modules/` is gitignored, so a linked // worktree and its main checkout are installed INDEPENDENTLY of each other. const dobbyInstalled = dobbyInstalledAt(workroot); - const defaultBranch = defaultBranchAt(mainRoot); + const { defaultBranch, defaultBranchSource } = resolveDefaultBranch( + mainRoot, + prAnswer + ); + // The payload's `pr` field is the SAME three-field projection the skills have + // always read — `baseRefName` feeds `defaultBranch` resolution above and is + // never itself part of the payload. + const pr = projectPullRequest(prAnswer); const { reasons, verdict } = judgeTeardown({ branch, @@ -247,6 +301,7 @@ export function runFinishPreflight(context: CommandContext): CommandResult { // never from git, and why the skill force-deletes (`-D`) once it holds. branchDeleteSafe: pr?.state === "MERGED", defaultBranch, + defaultBranchSource, dirty, dobbyInstalled, inWorktree, @@ -304,15 +359,21 @@ function judgeTeardown(input: { : { reasons, verdict: "confirm-required" }; } -// The branch's pull request via `gh pr view <branch> --json state,mergedAt,url` -// (finish/SKILL.md's own call). NULL whenever gh cannot answer — no gh on PATH, -// no repo remote, or gh's "no pull requests found" exit 1 — because an ABSENT PR -// must read as "unknown", never as a merge signal. The three fields are passed -// through as gh reported them; nothing here re-derives merge state from git. -function readPullRequest(root: string, branch: string): PullRequest | null { +// The branch's pull request via `gh pr view <branch> --json +// state,mergedAt,url,baseRefName` (finish/SKILL.md's own call). NULL whenever gh +// cannot answer — no gh on PATH, no repo remote, or gh's "no pull requests +// found" exit 1 — because an ABSENT PR must read as "unknown", never as a merge +// signal. The four fields are passed through as gh reported them; nothing here +// re-derives merge state from git. `baseRefName` drives `defaultBranch` +// resolution ONLY — the payload's own `pr` field never carries it +// (`projectPullRequest`). +function readPullRequest( + root: string, + branch: string +): PullRequestAnswer | null { const result = runCapture( "gh", - ["pr", "view", branch, "--json", "state,mergedAt,url"], + ["pr", "view", branch, "--json", "state,mergedAt,url,baseRefName"], { root } ); if (result.error || result.status !== 0) { @@ -320,6 +381,7 @@ function readPullRequest(root: string, branch: string): PullRequest | null { } try { const data = JSON.parse(result.stdout) as { + baseRefName?: unknown; mergedAt?: unknown; state?: unknown; url?: unknown; @@ -328,6 +390,8 @@ function readPullRequest(root: string, branch: string): PullRequest | null { return null; } return { + baseRefName: + typeof data.baseRefName === "string" ? data.baseRefName : null, // gh reports `mergedAt: null` for anything not merged. mergedAt: typeof data.mergedAt === "string" ? data.mergedAt : null, state: data.state, @@ -338,6 +402,14 @@ function readPullRequest(root: string, branch: string): PullRequest | null { } } +// The payload's `pr` field: the SAME three fields the skills have always read, +// `baseRefName` dropped — it exists only to drive `defaultBranch` resolution. +function projectPullRequest(pr: PullRequestAnswer | null): PullRequest | null { + return pr === null + ? null + : { mergedAt: pr.mergedAt, state: pr.state, url: pr.url }; +} + // The session's uncommitted work: bare `git status --porcelain` at the workroot // itself (the finish skill's literal), so UNTRACKED files count too — losing // those is exactly what the destructive gate protects against. Tolerant: a @@ -372,6 +444,7 @@ function formatFinishText(payload: FinishPreflight): string { `inWorktree: ${payload.inWorktree}`, `branch: ${payload.branch}`, `defaultBranch: ${payload.defaultBranch ?? "unknown"}`, + `defaultBranchSource: ${payload.defaultBranchSource ?? "unknown"}`, `worktreePath: ${payload.worktreePath ?? "-"}`, `mainRoot: ${payload.mainRoot}`, `pr: ${pr}`, diff --git a/plugin/skills/finish/SKILL.md b/plugin/skills/finish/SKILL.md index 9d69fe5..95e7c64 100644 --- a/plugin/skills/finish/SKILL.md +++ b/plugin/skills/finish/SKILL.md @@ -15,7 +15,7 @@ The end of a work session, closed end-to-end. If the goal's PR is still OPEN, `/ bunx dobby finish --preflight --json ``` -One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch: string | null` — resolved by an ordered cascade: remote head (only while the ref it names still exists — a stale/dangling `origin/HEAD` counts as unset and falls through), then remote-tracking `main`/`master`, then local `main`/`master`, else `null` when nothing names one; Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: +One call, run from wherever the session already stands, reports where that is (`inWorktree`, `worktreePath`, `mainRoot`), the branch (`branch`), the repository's own trunk (`defaultBranch: string | null`, with `defaultBranchSource` naming what answered it — the PR's own base branch FIRST, when the PR exists and named one (`defaultBranchSource: "pr-base"`); only when there is no usable base does the git cascade run: remote head (only while the ref it names still exists — a stale/dangling `origin/HEAD` counts as unset and falls through, `"origin-head"`), then remote-tracking `main`/`master` (`"remote-conventional"`), then local `main`/`master` (`"local-conventional"`), else `null`/`null` when nothing names one; Step 3 switches to it, never to a hard-coded `main`), the PR (`pr.state` / `pr.mergedAt` / `pr.url`, via `gh`), the uncommitted work a teardown would lose (`dirty.count` / `dirty.files`, untracked included), the contract (`dobbyInstalled`), and the mechanic Step 3 reads (`branchDeleteSafe`). Branch on `verdict`: - **`blocked`** — `dobbyInstalled: false`: `dobby down` is the mandatory pre-removal teardown and has no fallback. **STOP** and point the user at `/dobby:onboard` (or `/dobby:migrate-config` for a repo moving off an old contract). This is the ONLY blocking condition. - **`safe`** — a MERGED PR and a clean tree. Proceed to Step 2 without a prompt. @@ -72,13 +72,36 @@ Branch on the preflight's `inWorktree`. ``` - **`inWorktree: false`** — a plain checkout has no worktree to remove; read `defaultBranch` before switching: - - **a string** — return to it and delete the goal's branch: + - **a string** — return to it and delete the goal's branch. `defaultBranch` may be the PR's own base branch (`defaultBranchSource: "pr-base"`), which this checkout may never have fetched as a local branch, so check locally first: + + ```bash + git show-ref --verify --quiet refs/heads/<defaultBranch> + ``` + + - **exists locally** — switch directly: + + ```bash + git switch <defaultBranch> + ``` + - **no local branch, but `refs/remotes/origin/<defaultBranch>` does** — check that: + + ```bash + git show-ref --verify --quiet refs/remotes/origin/<defaultBranch> + ``` + + then switch onto it, tracking the remote: + + ```bash + git switch --track origin/<defaultBranch> + ``` + - **neither exists** — treat this exactly like the `null` case below: STOP with the plain-text note rather than guessing at a branch this checkout has no way to reach. + + Either switch succeeding, finish the branch cleanup: ```bash - git switch <defaultBranch> git branch -D <branch> # force-delete: after a squash-merge, -d always refuses a legitimately-merged branch ``` - - **`null`** — do NOT guess: the preflight could not determine the trunk (`origin/HEAD` is unset and neither `main` nor `master` exists). STOP the teardown here — no `AskUserQuestion`, just a plain-text note naming exactly what the operator runs once they know the trunk name: + - **`null`** — do NOT guess: the preflight could not determine the trunk (no usable PR base, and `origin/HEAD` is unset with neither `main` nor `master` anywhere in the repo). STOP the teardown here — no `AskUserQuestion`, just a plain-text note naming exactly what the operator runs once they know the trunk name: ``` git switch <trunk> @@ -124,6 +147,6 @@ Interact with the user in their language. Write any note you persist in English; - [ ] The PR merged ONLY on the user's explicit "Merge & finish" selection, and only after `bunx dobby pr watch [--adapter <selected id>] --await-review --deadline 60 --json` answered `merge-ready` with commit-scoped evidence (multi-adapter ambiguity selected mechanically; every required adapter validated the SAME `pr.headRefOid`, with the whole set restarted on mismatch; Greptile: passing review check AND `summary.reviewedHeadOid == pr.headRefOid`; CodeRabbit: passing current-commit review check; stale/missing evidence remained `open-unreviewed`, never review-by-silence); any other verdict reported and NOT merged, `feedback-present` routed to `/dobby:address-review`; squash merge pinned to the common validated SHA (`gh pr merge <pr.url> --match-head-commit <pr.headRefOid> --squash`) - [ ] After the merge, the preflight re-run (same cwd) and read as MERGED / `safe` before Step 2 — never assumed - [ ] `bunx dobby down --json` run before removal, from the workroot the session stands in; kills the detached run, deletes the Neon branch, runs `teardown[]` extras; `ok`/`reason` read and any `instructions[]` (`stop`) carried out to close the now-empty kit panes; a no-app project no-ops cleanly; a reported failure surfaced for the user's call, not auto-forced -- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → read `defaultBranch`: a string → `git switch <defaultBranch>` then `git branch -D <branch>`; `null` → STOPPED with a plain-text note (no AskUserQuestion) naming `git switch <trunk>` / `git branch -D <branch>` / `git pull` for the operator, ending the stage with the PR merged and the run torn down — `-D` in every completed case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal +- [ ] Branched on `inWorktree`: TRUE → native `ExitWorktree(remove)` tried first (cwd restored to main; `discard_changes` only after the explicit Step 1 confirmation); on "no active worktree session" fell back to raw `git worktree remove <worktreePath>` + `git branch -D <branch>` from `mainRoot`; on "branch refused as unmerged" (the directory is already gone) fell back to `git branch -D <branch>` ONLY, never `git worktree remove` on a path ExitWorktree already deleted; FALSE → read `defaultBranch`: a string → checked `git show-ref --verify --quiet refs/heads/<defaultBranch>` first — exists locally → `git switch <defaultBranch>`; no local branch but `refs/remotes/origin/<defaultBranch>` does → `git switch --track origin/<defaultBranch>`; neither exists → treated as the `null` case below — then (on a successful switch) `git branch -D <branch>`; `null` (or neither ref existing) → STOPPED with a plain-text note (no AskUserQuestion) naming `git switch <trunk>` / `git branch -D <branch>` / `git pull` for the operator, ending the stage with the PR merged and the run torn down — `-D` in every completed case because `branchDeleteSafe` (gh MERGED), not git ancestry, is the safe-to-delete signal - [ ] `git pull` on `mainRoot` run ONLY when a switch happened — the branch Step 3 left it on (`defaultBranch` on a plain checkout, unchanged inside a linked worktree); skipped when Step 3 stopped for a `null` `defaultBranch`; on conflict/divergence reported and stopped — never forced - [ ] Ended with an AskUserQuestion gate (goal closed; start the next goal via `/dobby:scope` recommended, or stop here); `/dobby:scope` invoked through the Skill tool on selection From 03b864637fe6bc797f1b60cb08cd4b9546773397 Mon Sep 17 00:00:00 2001 From: Kevin Wolf <hi@kvnwolf.com> Date: Thu, 3 Sep 2026 22:08:03 -0600 Subject: [PATCH 6/6] docs(adr): finish returns the operator to the goal's base branch, by decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 5 on #54 asked finish not to return to the merged PR's base branch when that base is a long-lived non-trunk branch such as `develop`. That inverts round 4, which asked not to trust a conventional name over the real target. The two cannot both hold, and the PR base is the authoritative one: finish closes a goal, and the branch the operator comes back to is the one the goal started from and merged into. A goal against `develop` belongs back on `develop`; moving it to a repository-wide trunk would take the operator away from where they were working. ADR-0033 now records this as the decision — the return branch is the PR base, reported with its source; the conventional cascade serves only a branch that never had a PR — so the next reader finds the reasoning instead of the code and a question. --- docs/adr/0033-the-worktree-belongs-to-the-operator.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/0033-the-worktree-belongs-to-the-operator.md b/docs/adr/0033-the-worktree-belongs-to-the-operator.md index cb74ccb..8c41fbf 100644 --- a/docs/adr/0033-the-worktree-belongs-to-the-operator.md +++ b/docs/adr/0033-the-worktree-belongs-to-the-operator.md @@ -13,3 +13,5 @@ ## Consequences The goal slug is `basename(workroot)` everywhere, not a kit-assigned name — a plain checkout without worktrees has exactly one active goal at a time, which is the natural corollary of dropping the nesting guard. `finish` tries `ExitWorktree` then falls back to raw git from the main root for an orphaned worktree the current session didn't create. `scope preflight` and the nesting/collision protection it computed are gone outright — the host (or the operator, on a plain checkout) now owns that judgment, not dobby. `up`'s setup phase is unchanged: `.worktreeinclude` re-materialization still runs, and still applies only to a linked worktree, whoever created it. + +**The branch `finish` returns to is the goal's base, not a repository trunk.** On a plain checkout, `finish` switches to and pulls the branch the goal's pull request was merged into — its `baseRefName`, reported as `defaultBranch` with `defaultBranchSource: "pr-base"`. A goal opened against `develop` or `release` belongs back on `develop` or `release` when it closes; returning the operator to a repository-wide trunk would move them away from where they were working. Review asked for both readings in consecutive rounds — do not trust a conventional name over the real target, then do not trust the real target over a conventional name — and they cannot both hold; the PR base is the authoritative one. The git cascade (a validated `origin/HEAD`, then `origin/main`/`origin/master`, then local `main`/`master`, then `null`) answers only for a branch that never had a pull request, where there is no base to return to and the conventional names are the best available heuristic; `null` stops the teardown with instructions rather than guessing.