cohorte 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,87 @@ short, user-facing, most recent first. One `## <version> — <YYYY-MM-DD>` secti
7
7
  > They are history and are deliberately not rewritten — every command gained a `cohorte-` prefix
8
8
  > in 2.0.0.
9
9
 
10
+ ## 2.0.2 — 2026-08-08
11
+
12
+ - **Kanban cards stopped moving mid-pipeline, and every stage still reported success.** The
13
+ stages only *described* the move — "move card `#<id>` → Building, no-op silently if no board" —
14
+ without saying where a board is even declared. Each phase runs in its own session after a
15
+ `/clear`, so the cheapest reading consistent with that instruction is "I see no board, so there
16
+ is none": a `/cohorte-ship` run announced "no kanban board configured", having opened neither
17
+ `~/.claude/cohorte.config.yaml` nor `PIPELINE.md`, and a feature that shipped and merged left
18
+ its card sitting in "Ready to build". Silence was indistinguishable from correctness, so this
19
+ went unnoticed across several features.
20
+
21
+ Resolution is not a judgment call, so it is no longer made by a judge. `kanban-move.sh auto <id>
22
+ <stage>` now reads the profile `name`, the config's `kanban.enabled` / `obsidian.vault_path` /
23
+ `boards[name]`, and maps the **stage key** (`ideas` … `shipped`) to that board's heading through
24
+ `boards[name].columns` → `kanban.columns` → a built-in default. Every stage calls exactly that,
25
+ and reports the line it printed: `moved #<id> -> <column>`, or `kanban: <reason>` naming the
26
+ missing link. Both exit 0 and they are not interchangeable. A board that *is* configured but
27
+ unmovable stays loud (exit 2 usage, exit 3 missing board / unknown column). Explicit board paths
28
+ and literal headings still work.
29
+
30
+ `validate-core` now fails any funnel command that moves a card without a literal
31
+ `kanban-move.sh auto …` call, or that omits the instruction not to conclude "no board" without
32
+ running it — the prose-only form is what regressed, so the prose-only form is what is banned.
33
+ New `scripts/test-kanban.mjs` (29 checks) pins the resolution table and the move semantics.
34
+
35
+ - **Renaming a project silently unlinks its board.** `kanban.boards` is keyed by the profile
36
+ `name`, so editing `name:` orphans the old entry and no lookup matches the new one — a genuine
37
+ "not configured", identical from the outside to never having had a board. `/cohorte-doctor`
38
+ gains check **7b**, which reports the resolved board (or the exact missing link) and flags an
39
+ orphaned entry as a rename; `/cohorte-update-pipeline` offers to re-key it instead of creating a
40
+ second board. New `kanban-move.sh --check` does the resolution alone, for both.
41
+
42
+ - **The gate judged `cd <other repo> && git commit` against the wrong checkout.** Branch-conditional
43
+ patterns resolved the branch at the payload cwd, but the tool's shell keeps no cwd between calls,
44
+ so agents write the `cd` into the command itself. A commit on a feature branch in another repo was
45
+ therefore refused as if it were on the default branch — and the refusal named a branch the command
46
+ was never going to run on, so no confirmation could lift it (in an unattended run, where `ask`
47
+ escalates to `deny`, it was a hard wall). The branch is now resolved per segment at the cwd that
48
+ segment actually runs in, cached so the common case is still one `git` call. Deliberately narrow:
49
+ a plain absolute/relative/quoted `cd`, never `cd -`, `$(…)`, variables or globs — anything
50
+ unresolvable keeps the previous cwd, which is the direction that keeps gating. `cd` never softens
51
+ the unconditional `deny`/`ask` tiers; 10 new cases in `test-gate.mjs` pin all of it.
52
+
53
+ - **`/cohorte-brainstorm` tags an Ideas card before moving it.** The join key is the `#<id>` tag,
54
+ and an Ideas card a human typed by hand has none — so the move found nothing, created a second
55
+ card, and stranded the original in Ideas. It now appends the tag first, located by `grep -n`,
56
+ never a full board read.
57
+
58
+ ## 2.0.1 — 2026-08-03
59
+
60
+ Three fixes, one failure: an autonomous `/cohorte-loop` run that built 1 surface of 3, stamped
61
+ itself green, and then hung asking a human to approve its own pre-flight. All in `loop.sh` — no
62
+ repo files change, so `npx cohorte@latest update --global` (or `update`) is the whole migration.
63
+
64
+ - **Child sessions now run in `bypassPermissions`, not `acceptEdits`.** `acceptEdits` auto-approves
65
+ Write/Edit and *nothing else*, so the first `Bash` call no `allow` prefix in `settings.json`
66
+ covers raises a permission prompt — and a `claude -p` child has nobody to answer it. It stalls,
67
+ prints prose asking you to approve, and **exits 0**, which the driver scores as a clean phase.
68
+ That is the whole content of one observed run: the review child blocked on invoking
69
+ `preflight.sh` and the loop logged `▶ /cohorte-review … ok`. It is also backwards from what the
70
+ gate is built for — `hooks/gate.py` escalates every `ask` match to a hard **deny** under
71
+ `bypassPermissions` precisely because an unattended run cannot confirm. So the dangerous commands
72
+ from PIPELINE.md `gate` stay blocked deterministically, while typecheck/lint/tests/`git diff` stop
73
+ needing a human. `CLAUDE_FLAGS` still overrides it for a watched run.
74
+
75
+ - **A build phase that reported nothing is no longer treated as a build that found nothing.** The
76
+ driver checked `dead[]` in `specs/reports/<id>.build.json` but accepted the file being **absent** —
77
+ and a phase cut short never reaches the step that writes it, so there was no file to grep and no
78
+ surface to name while the child still exited 0. A 3-surface build that lost 2 of them mid-write
79
+ stamped `<id>.built` and sent reviewers at the result. A missing `build.json` after a build now
80
+ aborts as **exit 2**, naming the cause, and leaves no stamp so a re-run rebuilds.
81
+
82
+ - **`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` is exported for every child.** Print mode otherwise
83
+ *terminates* still-running background tasks at its ceiling ("Background tasks still running after
84
+ 600s; terminating") — which is 25–40 min short of a real implementer batch, and killed the two
85
+ surfaces above mid-write. The phase is bounded by its own completion (and by the `caffeinate`
86
+ assertion the driver already holds), not by a stopwatch that fires inside the longest phase.
87
+
88
+ `test-loop.mjs` pins all three, including the fixture bug that hid the second one: its `ready`
89
+ build wrote no `build.json` either.
90
+
10
91
  ## 2.0.0 — 2026-08-03
11
92
 
12
93
  > **Breaking: every command is renamed.** `/build` → `/cohorte-build`, `/review` →
@@ -20,8 +20,13 @@ at Finish, when a board is configured.
20
20
  > Template paths below (`.claude/templates/…`) resolve to `~/.claude/templates/…` when the core is
21
21
  > installed globally — read whichever exists.
22
22
  >
23
- > **Kanban** (SCHEMA.md §Kanban): resolve this project's board from `~/.claude/cohorte.config.yaml`
24
- > `kanban.boards[<PIPELINE name>]`. Everything kanban below no-ops silently if none resolves.
23
+ > **Kanban** (SCHEMA.md §Kanban): every card move below is one call —
24
+ > `<core>/pipeline/scripts/kanban-move.sh auto <feature_id> <stage> [--title "<human title>"]`, with
25
+ > `<core>` = `.claude` bundled / `~/.claude` global (probe with `test -x`). `auto` resolves the
26
+ > board from `~/.claude/cohorte.config.yaml` itself and exits 0 with a `kanban: <reason>` line when
27
+ > none resolves — so **never decide "no board is configured" without running it**. Reading the Ideas
28
+ > column at Start still needs the board path: get it from a `kanban-move.sh` run, or grep the config
29
+ > for `boards[<PIPELINE name>]`.
25
30
 
26
31
  Idea (may be empty): **$ARGUMENTS**
27
32
 
@@ -52,9 +57,16 @@ when invoked with no paste). In chat print only a 3-line summary + the path. Tel
52
57
  — **recommend a `/clear` first**, the return is staged on disk (pasting it remains a fallback).
53
58
 
54
59
  **Kanban:** settle the `feature_id` (kebab-case slug) the return carries — it is the card's join key
55
- downstream. If a board is configured, **move** the card into the **Brainstorm** column tagged
56
- `#<feature_id>` (per §Kanban): the picked Ideas card if the human chose one, else a new card. No-op if
57
- no board.
60
+ downstream. Then, in this order:
61
+
62
+ 1. **If the human picked an Ideas card, tag it first.** Ideas cards are free text a human typed, with
63
+ no `#<feature_id>` on them, and the move script joins on that tag: move before tagging and it
64
+ finds nothing, creates a second card, and leaves the untagged original sitting in Ideas forever.
65
+ One targeted Edit appending ` #<feature_id>` to that line, located by `grep -n`, never a full
66
+ board read.
67
+ 2. `<core>/pipeline/scripts/kanban-move.sh auto <feature_id> brainstorm --title "<human title>"` —
68
+ which moves the (now tagged) card, or creates one under `--title` if the human typed a fresh
69
+ idea. Read its output: `moved #…` or `kanban: <reason>`. Never assume either.
58
70
 
59
71
  **Telemetry:** chain the opt-in usage ping onto that same Bash call — `/cohorte-build` §4's shared form,
60
72
  `<phase>` = `brainstorm`, `<seconds>` = `0` (this phase is human thinking time, not pipeline
@@ -10,8 +10,12 @@ You are the **lead**. Build feature **$ARGUMENTS** from its frozen spec.
10
10
  > dispatch + their agent names), `contract` (mechanism + path), and the `design` flag. _Skip the
11
11
  > re-read if it's already in your context this session and unmodified since._
12
12
  >
13
- > **Kanban** (SCHEMA.md §Kanban): once §1 confirms the frozen spec, move card `#$ARGUMENTS` →
14
- > **Building**. No-op silently if no board is configured.
13
+ > **Kanban** (SCHEMA.md §Kanban): once §1 confirms the frozen spec, run
14
+ > `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS building` (`<core>` = `.claude` bundled /
15
+ > `~/.claude` global — probe with `test -x`). `auto` resolves the board from the config itself and
16
+ > exits 0 with a `kanban: <reason>` line when there is none — so **never decide "no board is
17
+ > configured" without running it**. That inference, not a missing board, is what used to freeze
18
+ > cards mid-pipeline.
15
19
 
16
20
  ## 1. Load & check
17
21
 
@@ -76,6 +76,15 @@ fix only with the human's go-ahead (or hand them the command).
76
76
  incoherent state: `enabled: true` with no `install_id` or no `consent_date` (sending without
77
77
  recorded consent — fix: set `enabled: false` until the consent question is re-run), or a
78
78
  `telemetry:` block missing entirely on a current core (top up via `/cohorte-update-pipeline`).
79
+ 7b. **Kanban** (the board mirror — SCHEMA.md §Kanban). Run
80
+ `<core>/pipeline/scripts/kanban-move.sh --check` and report its one line verbatim: the resolved
81
+ board path, or the exact link that is missing. A board mirror is optional, so "not configured" is
82
+ ℹ️, never ❌ — but it must be **stated**, because the whole class of bug here is a card that
83
+ quietly stopped moving while every command still reported success. Two states earn a ⚠️ with the
84
+ fix named: `boards` has no entry for this profile's `name` while it does have an entry for some
85
+ other key whose board file exists (a **rename** orphaned the link — re-key it via
86
+ `/cohorte-update-pipeline`), and an entry whose board file no longer exists at
87
+ `vault_path`-relative `board` (moved or renamed in the vault).
79
88
  8. **Workflows** (the opt-in execution path — SCHEMA.md §Workflows; the conversational commands
80
89
  stay the default, so failures here are ⚠️ at most, never ❌). Report which path this machine will
81
90
  take and why:
@@ -11,8 +11,11 @@ that change the *contract*; `/cohorte-fix` is for everything else.
11
11
  > Read `PIPELINE.md` §`pipeline-profile` first: `surfaces` (paths + agent names) and `contract`.
12
12
  > _Skip the re-read if it's already in your context this session and unmodified since._
13
13
  >
14
- > **Kanban** (SCHEMA.md §Kanban): move card `#$ARGUMENTS` → **Fix** on ingest (it returns to **Review**
15
- > when `/cohorte-review` re-runs). No-op silently if no board.
14
+ > **Kanban** (SCHEMA.md §Kanban): on ingest, run
15
+ > `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS fix` (`<core>` = `.claude` bundled /
16
+ > `~/.claude` global — probe with `test -x`); the card returns to **Review** when `/cohorte-review`
17
+ > re-runs. `auto` resolves the board from the config itself and exits 0 with a `kanban: <reason>`
18
+ > line when there is none — so **never decide "no board is configured" without running it**.
16
19
 
17
20
  ## 1. Ingest the report
18
21
 
@@ -9,7 +9,11 @@ You are the **lead**. Dispatch the review for feature **$ARGUMENTS**.
9
9
  > Read `PIPELINE.md` §`vcs.default_branch` (diff base) and the `surfaces`/`contract`/`commands` fields.
10
10
  > _Skip the re-read if it's already in your context this session and unmodified since._
11
11
  >
12
- > **Kanban** (SCHEMA.md §Kanban): move card `#$ARGUMENTS` → **Review**. No-op silently if no board.
12
+ > **Kanban** (SCHEMA.md §Kanban): run
13
+ > `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS review` (`<core>` = `.claude` bundled /
14
+ > `~/.claude` global — probe with `test -x`). `auto` resolves the board from the config itself and
15
+ > exits 0 with a `kanban: <reason>` line when there is none — so **never decide "no board is
16
+ > configured" without running it**.
13
17
  >
14
18
  > **Workflow variant** (opt-in — SCHEMA.md §Workflows): on Claude Code ≥ 2.1.154 with workflows
15
19
  > enabled, the human can ask to "run the review workflow" (`<core>/workflows/review.js`) instead.
@@ -9,9 +9,12 @@ You are the **lead**. Ship feature **$ARGUMENTS**. This is the outward-facing ga
9
9
  > Read `PIPELINE.md` §`vcs` (host, remote, default_branch, feature_branch_prefix).
10
10
  >
11
11
  > **Kanban** (SCHEMA.md §Kanban) is mirrored in **explicit steps** below, not as an afterthought:
12
- > §1 moves the card → **Ship**; §4 moves it → **Shipped** and writes the PR number. No-op silently if
13
- > no board. Do not skip §4's move a shipped feature whose card is stuck in an earlier column is the
14
- > bug this ordering prevents.
12
+ > §1 moves the card → **Ship**; §4 moves it → **Shipped** and writes the PR number. Both are one
13
+ > call to `<core>/pipeline/scripts/kanban-move.sh auto …` (`<core>` = `.claude` bundled / `~/.claude`
14
+ > global probe with `test -x`), which resolves the board from the config itself and exits 0 with a
15
+ > `kanban: <reason>` line when there is none. **Never decide "no board is configured" without running
16
+ > it** — a ship session that inferred that, having opened neither the config nor `PIPELINE.md`, is
17
+ > exactly how a merged feature's card stayed in "Ready to build". Do not skip §4's move either.
15
18
 
16
19
  ## 1. Pre-flight (confirm before doing anything irreversible)
17
20
 
@@ -29,7 +32,8 @@ You are the **lead**. Ship feature **$ARGUMENTS**. This is the outward-facing ga
29
32
  All `- [x]` ⇒ proceed silently.
30
33
  - Show `git status` + `git diff --stat`; confirm the branch is `<feature_branch_prefix>$ARGUMENTS`.
31
34
  - **Ask the human to confirm** they want to commit, push, and open the PR. Wait for yes.
32
- - After the yes: **move card `#$ARGUMENTS` → the `ship` column** (SCHEMA.md §Kanban "Move a card"). No-op if no board.
35
+ - After the yes: `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS ship`. Report what it
36
+ printed — `moved #…` or `kanban: <reason>` — never a guess about which happened.
33
37
 
34
38
  ## 2. Mark the spec shipped (BEFORE dispatch, so it ships in the same commit)
35
39
 
@@ -51,14 +55,18 @@ never run migrations."
51
55
  Print the release agent's report: commit SHA(s), pushed branch, PR URL (or compare URL + drafted body).
52
56
  Confirm `specs/$ARGUMENTS.md` was committed as `status: shipped` (part of the release commit).
53
57
 
54
- **Move the card to Shipped — required, and verify it actually moved.** Move card `#$ARGUMENTS` → the
55
- `shipped` column (SCHEMA.md §Kanban "Move a card") and **append the PR number** so the line reads
56
- `- [ ] <title> #$ARGUMENTS — PR #<num>`. Take `<num>` from the PR URL (`…/pull/13` ⇒ `13`); **always write
57
- it when a PR was created** (the `gh` path) — it is what the dashboard turns into a PR link. If only a
58
- compare URL was emitted (no PR yet), move the card without a number. Then verify with a **grep for
59
- `#$ARGUMENTS`** on the board (with surrounding heading context — `grep -B20 '#$ARGUMENTS' | grep '^##'`
60
- or an offset-limited Read around the match): exactly one card, under the `shipped` heading never
61
- re-read the whole board into context. No board skip silently.
58
+ **Move the card to Shipped — required, and verify it actually moved.** Run
59
+ `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS shipped --pr <num>`, which **appends the PR
60
+ number** so the line reads `- [ ] <title> #$ARGUMENTS — PR #<num>`. Take `<num>` from the PR URL
61
+ (`…/pull/13` ⇒ `13`); **always pass it when a PR was created** (the `gh` path) — it is what the
62
+ dashboard turns into a PR link. If only a compare URL was emitted (no PR yet), drop `--pr`.
63
+
64
+ Then **read the script's own output**, which is the verification: `moved #$ARGUMENTS -> Shipped
65
+ (PR #<num>)` means done, and a `kanban: <reason>` line means the mirror is off and says why. Both are
66
+ exit 0 and they are not interchangeable — say which one you got. Only if it moved, confirm placement
67
+ with a **grep for `#$ARGUMENTS`** on the board it named (with surrounding heading context —
68
+ `grep -B20 '#$ARGUMENTS' | grep '^##'`, or an offset-limited Read around the match): exactly one card,
69
+ under the `shipped` heading — never re-read the whole board into context.
62
70
 
63
71
  **Telemetry — the usage ping that closes the funnel.** Chain it onto the verify call above
64
72
  (`/cohorte-build` §4's shared form, `<phase>` = `ship`, `<seconds>` = `0` — the release agent's duration is
@@ -17,8 +17,12 @@ You run the **spec** step in the main thread — interactive, with the human. Pa
17
17
  > line per standing decision, so it is cheap. It is the ONLY place the project's transverse rules
18
18
  > live; a spec that contradicts one silently un-decides it. Absent ⇒ nothing to honour yet.
19
19
  >
20
- > **Kanban** (SCHEMA.md §Kanban): when the spec opens, move card `#<feature_id>` → **Spec**; on freeze
21
- > (`status: frozen`, Mode A) **Ready to build**. No-op silently if no board is configured.
20
+ > **Kanban** (SCHEMA.md §Kanban): when the spec opens, run
21
+ > `<core>/pipeline/scripts/kanban-move.sh auto <feature_id> spec --title "<human title>"`; on freeze
22
+ > (`status: frozen`, Mode A), the same call with `ready`. `<core>` = `.claude` bundled / `~/.claude`
23
+ > global — probe with `test -x`. `auto` resolves the board from the config itself and exits 0 with a
24
+ > `kanban: <reason>` line when there is none — so **never decide "no board is configured" without
25
+ > running it**.
22
26
 
23
27
  Detect the mode from the pasted content:
24
28
 
@@ -102,14 +102,20 @@ Four of the §Reconcile steps matter specifically here:
102
102
  (pre-telemetry install), top up the block from the template and ask the ONE opt-in consent
103
103
  question defined in `templates/steps/init-pipeline/02-interview-gaps.md` §Telemetry — record the
104
104
  answer either way so it is never re-asked. Consent is strictly opt-in; "No" is the default.
105
- - **Kanban sync** (§Reconcile step 6): resolve this project's board from `kanban.boards[<PIPELINE
106
- name>]`. **Not linked** offer to link/create a board (confirm the vault + `<folder>/Tasks.md`,
105
+ - **Kanban sync** (§Reconcile step 6): resolve this project's board with
106
+ `<core>/pipeline/scripts/kanban-move.sh --check` it prints either the board path or the exact
107
+ missing link. **Not linked** → offer to link/create a board (confirm the vault + `<folder>/Tasks.md`,
107
108
  write the `boards` entry, create the board file per §Kanban). **Linked** → verify the board file
108
109
  exists (recreate if the human confirms) and its columns match `kanban.columns` (repair drift). Either
109
- way, run the §Kanban **full sync/backfill** from `specs/*.md` — this is what adds every
110
- already-developed feature to the board and repositions cards to match each spec's `status`. Report
111
- cards added / moved / already-correct. Skip silently if `kanban.enabled` is false and the human
112
- doesn't want to turn it on.
110
+ way, run the §Kanban **full sync/backfill** from `specs/*.md` — one
111
+ `kanban-move.sh auto <id> <stage>` per spec, `<stage>` from the status mapping this is what adds
112
+ every already-developed feature to the board and repositions cards to match each spec's `status`.
113
+ Report cards added / moved / already-correct. Skip silently if `kanban.enabled` is false and the
114
+ human doesn't want to turn it on.
115
+ - **A project renamed since its last update loses its board silently** — `boards` is keyed by the
116
+ profile `name`, so a `name:` edit orphans the old entry and no lookup matches the new one. When
117
+ `--check` finds no entry for `<name>` but `boards` holds exactly one other key whose board file
118
+ exists, say so and offer to re-key it rather than creating a second board.
113
119
 
114
120
  ## 4. Tell the human the follow-ups
115
121
 
@@ -4,7 +4,9 @@
4
4
  Forces a confirmation prompt (or hard-denies) on dangerous Bash commands,
5
5
  inspecting the FULL command string so chained forms like
6
6
  `cd apps/api && node ace migration:run` are caught — something prefix-based
7
- permission rules in settings.json miss.
7
+ permission rules in settings.json miss. Branch-conditional patterns follow that
8
+ same chain: a leading `cd <dir>` moves where the branch is resolved, so
9
+ `cd <other repo> && git commit` is judged against the checkout it lands in.
8
10
 
9
11
  Patterns come from `.claude/gate-config.json` (generated by /cohorte-init-pipeline from
10
12
  the PIPELINE.md `gate` block):
@@ -91,6 +93,36 @@ def session_cwd(payload: dict) -> str:
91
93
  return payload.get("cwd") or project_root()
92
94
 
93
95
 
96
+ CD = re.compile(r"^cd\s+(?!-\s*$)(?P<path>.+)$")
97
+
98
+
99
+ def cd_target(seg: str, cwd: str):
100
+ """The directory a `cd <path>` segment lands in, or None if this segment is
101
+ not a plain `cd` we can resolve. Chained commands are the norm here — the
102
+ tool's shell has no memory between calls, so agents write
103
+ `cd <other repo> && git commit`. Resolving git state at the payload cwd then
104
+ judges that commit against the WRONG checkout: a commit on a feature branch
105
+ in another repo was refused as if it were on the default branch, and no
106
+ approval could lift it because the branch it named was never the branch the
107
+ command would run on.
108
+
109
+ Deliberately narrow: one unquoted-or-quoted path, no `cd -`, no `$(…)`, no
110
+ variables. Anything else returns None and the caller keeps the cwd it had —
111
+ the conservative direction, since an unresolvable branch still gates."""
112
+ m = CD.match(seg)
113
+ if not m:
114
+ return None
115
+ path = m.group("path").strip()
116
+ if len(path) >= 2 and path[0] == path[-1] and path[0] in "\"'":
117
+ path = path[1:-1]
118
+ elif any(c in path for c in "$`\"'*?"):
119
+ return None # expansion or globbing — we cannot know where it lands
120
+ path = os.path.expanduser(path)
121
+ target = path if os.path.isabs(path) else os.path.join(cwd, path)
122
+ target = os.path.normpath(target)
123
+ return target if os.path.isdir(target) else None
124
+
125
+
94
126
  def current_branch(cwd: str):
95
127
  """The checked-out branch at `cwd`, or None (not a repo / detached / git absent)."""
96
128
  try:
@@ -296,11 +328,17 @@ def main() -> int:
296
328
 
297
329
  # Branch-conditional patterns (e.g. git/docker) are gated only on the default branch;
298
330
  # on a feature branch they run freely. Unknown branch (no repo / detached / no git) ⇒
299
- # be conservative and gate. Resolve the branch once, lazily.
300
- on_default = False
301
- if branch_gated:
302
- branch = current_branch(session_cwd(payload))
303
- on_default = branch is None or branch == default
331
+ # be conservative and gate. The branch is resolved per segment at the cwd that
332
+ # segment runs in — a leading `cd <dir>` in the same chain moves it — and cached,
333
+ # so the common case is still one git call.
334
+ cwd = session_cwd(payload)
335
+ branch_cache = {}
336
+
337
+ def on_default_at(directory: str) -> bool:
338
+ if directory not in branch_cache:
339
+ branch = current_branch(directory)
340
+ branch_cache[directory] = branch is None or branch == default
341
+ return branch_cache[directory]
304
342
 
305
343
  for raw in SPLIT.split(command):
306
344
  seg = norm(raw)
@@ -314,13 +352,18 @@ def main() -> int:
314
352
  return decide(ask_decision,
315
353
  f"`{pat}` is a gated command — confirm first (PIPELINE.md gate)."
316
354
  f"{ask_suffix}")
317
- if on_default:
355
+ if branch_gated and on_default_at(cwd):
318
356
  for pat in branch_gated:
319
357
  if norm(pat) in seg:
320
358
  return decide(ask_decision,
321
359
  f"`{pat}` is gated on the default branch `{default}` — confirm "
322
360
  f"(PIPELINE.md gate). It runs freely on feature branches."
323
361
  f"{ask_suffix}")
362
+ # Apply the `cd` AFTER matching this segment: a `cd` is not itself gated,
363
+ # and every later segment in the chain runs in the new directory.
364
+ moved = cd_target(seg, cwd)
365
+ if moved:
366
+ cwd = moved
324
367
 
325
368
  return 0
326
369
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /cohorte-init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
package/profile/SCHEMA.md CHANGED
@@ -536,16 +536,42 @@ Once shipped, `/cohorte-ship` appends the **PR number** to the card — `- [ ] <
536
536
  The bare `#<num>` is what the dashboard renders as a clickable link to the GitHub PR, so `/cohorte-ship` always
537
537
  writes it when a PR was actually created.
538
538
 
539
- **Move a card (the core op).** Use the shipped script it does the whole op outside the agent's
540
- context (find, dedupe, sub-notes carried along, settings block preserved):
541
- `<core>/pipeline/scripts/kanban-move.sh <board.md> <id> <column> [--pr <num>] [--title <title>]`
542
- where `<core>` is `~/.claude` (global install) or `.claude` (bundled) — probe with `test -x`. It
543
- creates the card in the target column when none exists, keeps the first and drops duplicates, and
544
- appends ` — PR #<num>` with `--pr`. **Fallback when the script is absent** (older core): do it by
545
- hand, but never read the whole board into context — it grows with every feature ever tracked:
546
- `grep -n` for `#<id>` and the `## ` headings to locate lines, then use offset-limited Reads +
547
- targeted Edits around the matches. Either way: one card per `#<id>`, whole line moved tag-preserved,
548
- card created in the target column if missing.
539
+ **Move a card (the core op).** One call — the script does resolution AND the move outside the
540
+ agent's context (find, dedupe, sub-notes carried along, settings block preserved):
541
+
542
+ ```
543
+ <core>/pipeline/scripts/kanban-move.sh auto <id> <stage> [--pr <num>] [--title <title>]
544
+ ```
545
+
546
+ `<core>` is `~/.claude` (global install) or `.claude` (bundled) probe with `test -x`. It creates
547
+ the card in the target column when none exists, keeps the first and drops duplicates, and appends
548
+ ` PR #<num>` with `--pr`.
549
+
550
+ **`auto` is not a convenience, it is the contract.** It reads `name` from `PIPELINE.md`, then
551
+ `kanban.enabled` / `obsidian.vault_path` / `boards[name]` from `~/.claude/cohorte.config.yaml`
552
+ (override with `COHORTE_CONFIG`, or skip the profile with `--project <name>`), and it maps the
553
+ **stage key** (`ideas` · `brainstorm` · `spec` · `ready` · `building` · `review` · `fix` · `ship` ·
554
+ `shipped`) to that board's heading through `boards[name].columns` → `kanban.columns` → the built-in
555
+ default. An explicit `<board.md>` path and a literal heading both still work, for one-off and
556
+ non-pipeline moves.
557
+
558
+ **Never conclude "no board is configured" without running it.** The command that resolves nothing
559
+ prints `kanban: <reason>` — naming the missing link (no config file, `enabled: false`, no entry for
560
+ this project, vault unset, board file gone) — and exits **0**. A configured board that cannot be
561
+ moved is loud instead: exit 2 on usage, exit 3 on a missing board file or an unknown column. Both
562
+ readings are on stdout, so a caller reports which one it got. This exists because inference was the
563
+ actual failure mode: with only "no-op silently if no board" to go on, a fresh phase session (every
564
+ phase runs after a `/clear`) decided there was no board without ever opening the config, and cards
565
+ stopped moving mid-pipeline while every command still reported success.
566
+
567
+ **Fallback when the script is absent** (older core): do it by hand, but never read the whole board
568
+ into context — it grows with every feature ever tracked: `grep -n` for `#<id>` and the `## ` headings
569
+ to locate lines, then use offset-limited Reads + targeted Edits around the matches. Either way: one
570
+ card per `#<id>`, whole line moved tag-preserved, card created in the target column if missing.
571
+
572
+ **Tag before you move.** The join key is the `#<id>` tag, and an **Ideas** card a human typed by hand
573
+ does not have one. Moving it first finds nothing, creates a second card, and strands the original in
574
+ Ideas — so `/cohorte-brainstorm` appends the tag to the picked line before its first move.
549
575
 
550
576
  **Stage → column**, used both by each pipeline command (to move its card live) and by backfill:
551
577
 
@@ -2,28 +2,219 @@
2
2
  # kanban-move.sh — move a feature card on an Obsidian Kanban board without
3
3
  # loading the board into an agent's context (SCHEMA.md §Kanban "Move a card").
4
4
  #
5
- # kanban-move.sh <board.md> <feature-id> <target-column> [--pr <num>] [--title <title>]
5
+ # kanban-move.sh <board.md|auto> <feature-id> <column|stage> [options]
6
+ # kanban-move.sh --check resolve only: print the board this
7
+ # project maps to, or why it does not
6
8
  #
7
- # Behavior (mirrors the documented manual op):
9
+ # --pr <num> ensure the card line ends with ` — PR #<num>`
10
+ # --title <title> text to use when the card has to be created
11
+ # --project <name> profile name to look up in kanban.boards (default: read
12
+ # `name:` from ./PIPELINE.md)
13
+ # --profile <path> where to read that name from (default: ./PIPELINE.md)
14
+ #
15
+ # `auto` as the board resolves the board FROM THE CONFIG — vault path, this
16
+ # project's board file, and the stage→heading mapping — instead of making the
17
+ # caller do it. This exists because the callers are agents: told only "no-op
18
+ # silently if no board", a fresh session (every phase runs after a /clear)
19
+ # concluded "no board is configured" without ever opening the config, and the
20
+ # card silently stopped moving mid-pipeline. Resolution is not a judgment call,
21
+ # so it is not left to one. When nothing resolves the script says which link is
22
+ # missing on stdout and exits 0 — a no-op you can read, never one you infer.
23
+ #
24
+ # Behavior of the move itself (mirrors the documented manual op):
8
25
  # - finds the list item tagged #<feature-id> (plus its indented sub-notes),
9
26
  # removes it from its current column, appends it under `## <target-column>`
10
27
  # - no card yet ⇒ creates `- [ ] <title|id> #<id>` in the target column
11
28
  # - duplicates ⇒ keeps the first, drops the rest
12
- # - --pr N ⇒ ensures the card line ends with ` — PR #N`
13
29
  # - never touches the front-matter or the trailing `%% kanban:settings %%` block
14
- # Exit codes: 0 ok · 2 usage · 3 board/column not found.
30
+ #
31
+ # Exit codes: 0 ok (incl. "kanban not configured") · 2 usage · 3 board/column
32
+ # not found. A configured board that cannot be moved is loud; an unconfigured
33
+ # one is quiet — the pipeline never blocks on the board.
15
34
  set -eu
16
35
 
17
- [ $# -ge 3 ] || { echo "usage: kanban-move.sh <board.md> <feature-id> <column> [--pr <num>] [--title <title>]" >&2; exit 2; }
18
- board="$1"; id="$2"; col="$3"; shift 3
19
- pr=""; title=""
36
+ usage='usage: kanban-move.sh <board.md|auto> <feature-id> <column|stage> [--pr <num>] [--title <title>] [--project <name>] [--profile <path>]
37
+ kanban-move.sh --check [--project <name>] [--profile <path>]'
38
+ check_only=false
39
+ if [ "${1-}" = --check ]; then
40
+ check_only=true; board=auto; id=""; col=""; shift
41
+ else
42
+ [ $# -ge 3 ] || { echo "$usage" >&2; exit 2; }
43
+ board="$1"; id="$2"; col="$3"; shift 3
44
+ fi
45
+ pr=""; title=""; project=""; profile="PIPELINE.md"
20
46
  while [ $# -gt 0 ]; do
21
47
  case "$1" in
22
- --pr) pr="$2"; shift 2 ;;
23
- --title) title="$2"; shift 2 ;;
24
- *) echo "error: unknown flag $1" >&2; exit 2 ;;
48
+ --pr) pr="${2-}"; shift 2 ;;
49
+ --title) title="${2-}"; shift 2 ;;
50
+ --project) project="${2-}"; shift 2 ;;
51
+ --profile) profile="${2-}"; shift 2 ;;
52
+ *) echo "error: unknown flag $1" >&2; echo "$usage" >&2; exit 2 ;;
25
53
  esac
26
54
  done
55
+
56
+ TAB=$(printf '\t')
57
+ CONFIG="${COHORTE_CONFIG:-$HOME/.claude/cohorte.config.yaml}"
58
+
59
+ # The nine pipeline stages and their default headings. A stage key given as the
60
+ # target column is mapped through the config (per-board `columns` first, then the
61
+ # global `kanban.columns`) and falls back to these — so a stage key works even
62
+ # with an explicit board path and no config at all. Anything not in this list is
63
+ # taken as a literal heading, which keeps hand-written columns working.
64
+ default_heading() {
65
+ case "$1" in
66
+ ideas) echo "Ideas" ;;
67
+ brainstorm) echo "Brainstorm" ;;
68
+ spec) echo "Spec" ;;
69
+ ready) echo "Ready to build" ;;
70
+ building) echo "Building" ;;
71
+ review) echo "Review" ;;
72
+ fix) echo "Fix" ;;
73
+ ship) echo "Ship" ;;
74
+ shipped) echo "Shipped" ;;
75
+ *) echo "" ;;
76
+ esac
77
+ }
78
+
79
+ # Read `name:` from the profile's `yaml pipeline-profile` block. Only the block —
80
+ # a `name:` under some other key (a surface, a persona) is not the project name.
81
+ read_project_name() {
82
+ [ -f "$1" ] || return 0
83
+ awk '
84
+ /^```[ \t]*yaml[ \t]+pipeline-profile/ { inblock = 1; next }
85
+ /^```/ { if (inblock) exit }
86
+ inblock && /^name:[ \t]/ {
87
+ v = $0; sub(/^name:[ \t]*/, "", v)
88
+ sub(/[ \t]+#.*$/, "", v); sub(/[ \t]+$/, "", v)
89
+ gsub(/^["\047]|["\047]$/, "", v)
90
+ print v; exit
91
+ }
92
+ ' "$1"
93
+ }
94
+
95
+ # Minimal YAML reader for the fixed shape the installer writes. Not a general
96
+ # parser: it walks an indent stack and emits only the handful of paths the kanban
97
+ # mirror needs, resolved against the project name we were given (so a name with a
98
+ # dot in it still matches — the comparison is on whole strings, not a joined key).
99
+ read_config() {
100
+ PROJ="$1" awk '
101
+ function clean(v, c, q, i, ch, out) {
102
+ sub(/^[ \t]+/, "", v)
103
+ if (v == "") return ""
104
+ c = substr(v, 1, 1)
105
+ if (c == "\"" || c == "\047") {
106
+ q = c; out = ""
107
+ for (i = 2; i <= length(v); i++) {
108
+ ch = substr(v, i, 1)
109
+ if (ch == q) break
110
+ out = out ch
111
+ }
112
+ return out
113
+ }
114
+ sub(/[ \t]+#.*$/, "", v); sub(/[ \t]+$/, "", v)
115
+ return v
116
+ }
117
+ # `columns: { ideas: "Idées", ready: "Prêt" }` — the flow form the config
118
+ # template documents for a per-board override.
119
+ function flow(path, v, n, i, parts, p, k) {
120
+ sub(/^\{[ \t]*/, "", v); sub(/[ \t]*\}[ \t]*$/, "", v)
121
+ n = split(v, parts, ",")
122
+ for (i = 1; i <= n; i++) {
123
+ p = index(parts[i], ":")
124
+ if (p == 0) continue
125
+ k = substr(parts[i], 1, p - 1)
126
+ gsub(/^[ \t]+|[ \t]+$/, "", k)
127
+ emit(path "." k, clean(substr(parts[i], p + 1)))
128
+ }
129
+ }
130
+ function emit(path, v, stage) {
131
+ if (v == "") return
132
+ if (path == "enabled") { print "ENABLED\t" v; return }
133
+ if (path == "kanban.enabled") { print "KENABLED\t" v; return }
134
+ if (path == "obsidian.vault_path") { print "VAULT\t" v; return }
135
+ if (path == "kanban.boards." proj ".board") { print "BOARD\t" v; return }
136
+ if (index(path, "kanban.columns.") == 1) {
137
+ print "COL." substr(path, length("kanban.columns.") + 1) "\t" v; return
138
+ }
139
+ if (index(path, "kanban.boards." proj ".columns.") == 1) {
140
+ print "BCOL." substr(path, length("kanban.boards." proj ".columns.") + 1) "\t" v; return
141
+ }
142
+ }
143
+ BEGIN { proj = ENVIRON["PROJ"]; top = 0 }
144
+ {
145
+ line = $0; sub(/\r$/, "", line)
146
+ if (line ~ /^[ \t]*$/ || line ~ /^[ \t]*#/) next
147
+ match(line, /^[ ]*/); ind = RLENGTH
148
+ rest = substr(line, ind + 1)
149
+ if (rest ~ /^- /) next
150
+ p = index(rest, ":")
151
+ if (p == 0) next
152
+ key = substr(rest, 1, p - 1)
153
+ if (key ~ /[ \t]/) next
154
+ raw = substr(rest, p + 1)
155
+ while (top > 0 && indent[top] >= ind) top--
156
+ top++; indent[top] = ind; keyat[top] = key
157
+ path = ""
158
+ for (i = 1; i <= top; i++) path = path (i > 1 ? "." : "") keyat[i]
159
+ sub(/^[ \t]+/, "", raw)
160
+ if (substr(raw, 1, 1) == "{") { flow(path, raw); next }
161
+ emit(path, clean(raw))
162
+ }
163
+ ' "$CONFIG"
164
+ }
165
+
166
+ off() { echo "kanban: $1"; exit 0; }
167
+
168
+ cfg=""
169
+ if [ "$board" = auto ] || [ -n "$(default_heading "$col")" ]; then
170
+ [ -n "$project" ] || project=$(read_project_name "$profile")
171
+ [ -f "$CONFIG" ] && cfg=$(read_config "$project") || cfg=""
172
+ fi
173
+
174
+ # ── resolve the board ───────────────────────────────────────────────────────
175
+ if [ "$board" = auto ]; then
176
+ [ -f "$CONFIG" ] || off "no config at $CONFIG — board mirror off"
177
+ vault=""; rel=""; enabled=""; kenabled=""
178
+ OLDIFS=$IFS; IFS=$TAB
179
+ while read -r k v; do
180
+ case "$k" in
181
+ ENABLED) enabled=$v ;;
182
+ KENABLED) kenabled=$v ;;
183
+ VAULT) vault=$v ;;
184
+ BOARD) rel=$v ;;
185
+ esac
186
+ done <<EOF
187
+ $cfg
188
+ EOF
189
+ IFS=$OLDIFS
190
+ [ "$enabled" != false ] || off "cohorte capabilities disabled (enabled: false in $CONFIG)"
191
+ [ "$kenabled" != false ] || off "disabled in $CONFIG (kanban.enabled: false)"
192
+ [ -n "$project" ] || off "no project name in $profile — cannot resolve a board"
193
+ [ -n "$rel" ] || off "no board configured for project \"$project\" in $CONFIG"
194
+ [ -n "$vault" ] || off "obsidian.vault_path not set in $CONFIG"
195
+ board="$vault/$rel"
196
+ [ -f "$board" ] || off "board file not found: $board"
197
+ if $check_only; then echo "kanban: project \"$project\" -> $board"; exit 0; fi
198
+ fi
199
+
200
+ # ── resolve the column ──────────────────────────────────────────────────────
201
+ # A stage key becomes a heading; a literal heading passes through untouched.
202
+ if [ -n "$(default_heading "$col")" ]; then
203
+ heading=""
204
+ OLDIFS=$IFS; IFS=$TAB
205
+ while read -r k v; do
206
+ case "$k" in
207
+ "BCOL.$col") heading=$v ;;
208
+ "COL.$col") [ -n "$heading" ] || heading=$v ;;
209
+ esac
210
+ done <<EOF
211
+ $cfg
212
+ EOF
213
+ IFS=$OLDIFS
214
+ [ -n "$heading" ] || heading=$(default_heading "$col")
215
+ col="$heading"
216
+ fi
217
+
27
218
  [ -f "$board" ] || { echo "error: board not found: $board" >&2; exit 3; }
28
219
 
29
220
  tmp="${board}.kanban-move.$$"
package/scripts/loop.sh CHANGED
@@ -99,7 +99,7 @@ usage: loop.sh <feature-id> [--max=N] [--no-build] [--rebuild] [--resume]
99
99
  (loop_pass), instead of starting over at pass 1
100
100
 
101
101
  env CLAUDE_FLAGS flags for every child session
102
- (default: --permission-mode acceptEdits)
102
+ (default: --permission-mode bypassPermissions)
103
103
  EOF
104
104
  exit 64
105
105
  }
@@ -194,7 +194,32 @@ fm_set() { # fm_set <key> <value> (replace, else append)
194
194
  mv "$spec.loop.tmp" "$spec" 2>/dev/null || rm -f "$spec.loop.tmp"
195
195
  }
196
196
 
197
- : "${CLAUDE_FLAGS:=--permission-mode acceptEdits}"
197
+ # --- child session flags -----------------------------------------------------
198
+ # bypassPermissions, NOT acceptEdits. acceptEdits auto-approves Write/Edit and
199
+ # NOTHING else, so every Bash call in a child falls back to the settings.json
200
+ # rules — and the first one no `allow` prefix covers raises a permission prompt.
201
+ # In `claude -p` there is nobody to answer it: the child stalls, eventually
202
+ # prints prose asking the human to approve, and EXITS 0. The driver then reads
203
+ # that as a clean phase. Observed on a real run: the review child hung on its own
204
+ # preflight.sh call ("could you approve the pending tool-call prompt") and the
205
+ # loop scored the phase `ok`.
206
+ #
207
+ # This is also what the gate hook is built for: hooks/gate.py escalates every
208
+ # `ask` match to a hard DENY under bypassPermissions, precisely because an
209
+ # unattended run has nobody to confirm. The dangerous commands stay blocked — by
210
+ # the gate, deterministically, from PIPELINE.md `gate` — while the mechanical
211
+ # ones (typecheck, lint, tests, git diff) stop needing a human. Driving the loop
212
+ # in acceptEdits gets this backwards: nothing is auto-denied and everything is
213
+ # auto-hung. Override with CLAUDE_FLAGS to run in a stricter mode interactively.
214
+ : "${CLAUDE_FLAGS:=--permission-mode bypassPermissions}"
215
+
216
+ # A `/cohorte-build` implementer batch runs 25–40 min as background tasks. In print
217
+ # mode the harness waits a bounded time for background work and then TERMINATES it
218
+ # ("Background tasks still running after 600s; terminating"), which cuts implementers
219
+ # off mid-write and still exits the child 0. 0 = wait indefinitely; the caffeinate
220
+ # assertion above and the phase's own completion are what bound a phase, not a
221
+ # stopwatch that fires in the middle of the longest one.
222
+ export CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0
198
223
 
199
224
  : >"$log"
200
225
  {
@@ -309,6 +334,14 @@ spawned no agent; see $readiness, then /cohorte-spec $id"
309
334
  finish 2 "✗ an implementer died — the surface(s) in \"dead\" were never built; see $buildjson and $log"
310
335
  fi
311
336
  [ "$build_ok" -eq 1 ] || finish 2 "✗ /cohorte-build failed — see $log"
337
+ # An ABSENT build.json is the same class of lie as a dead implementer, and the `dead`
338
+ # check above cannot see it: a phase cut short (harness background-task ceiling, a
339
+ # Claude Code teardown, a crash) never reaches §3's report, so there is no file to
340
+ # grep and no surface to name — while the child still exits 0. Treating "no report"
341
+ # as "nothing to report" is what let a build of 3 surfaces stamp itself green with 2
342
+ # of them never written, and sent reviewers at the result.
343
+ [ -f "$buildjson" ] || finish 2 "✗ /cohorte-build wrote no $buildjson — the phase was cut short \
344
+ (background-task ceiling, teardown or crash) and the surfaces it never reported are unbuilt; see $log"
312
345
  date -u +%Y-%m-%dT%H:%M:%SZ >"$stamp"
313
346
  fi
314
347
 
@@ -155,6 +155,36 @@ console.log("gate.py — branch-conditional gating");
155
155
  const norepo = scratch(); writeConfig(norepo, GATE_CFG);
156
156
  check("unknown branch (not a repo) ⇒ gated, to stay safe",
157
157
  run({ ...bash("git commit -m x"), cwd: norepo }, { projectDir: norepo }).decision === "ask");
158
+
159
+ // 2.0.2: the tool's shell keeps no cwd between calls, so agents write
160
+ // `cd <other repo> && git commit`. Resolving the branch at the payload cwd
161
+ // judged that commit against the wrong checkout — and the refusal named a
162
+ // branch the command was never going to run on, so no approval could lift it.
163
+ const chained = run({ ...bash(`cd ${feat} && git commit -m x`), cwd: main }, { projectDir: main });
164
+ check("`cd <feature checkout> && git commit` follows the cd ⇒ free",
165
+ chained.decision === null, `got ${chained.decision}: ${chained.reason}`);
166
+ check("`cd <default-branch checkout> && git commit` still gated",
167
+ run({ ...bash(`cd ${main} && git commit -m x`), cwd: feat }, { projectDir: feat }).decision === "ask");
168
+ check("a cd only affects segments AFTER it",
169
+ run({ ...bash(`git commit -m x && cd ${feat}`), cwd: main }, { projectDir: main }).decision === "ask");
170
+ check("relative cd resolves against the payload cwd",
171
+ run({ ...bash("cd .. && git commit -m x"), cwd: join(feat, ".claude") },
172
+ { projectDir: feat }).decision === null);
173
+ check("cd into a missing dir keeps the current cwd (still gated)",
174
+ run({ ...bash("cd /definitely/not/here && git commit -m x"), cwd: main },
175
+ { projectDir: main }).decision === "ask");
176
+ check("an unresolvable cd ($(…)) keeps the current cwd (still gated)",
177
+ run({ ...bash("cd $(mktemp -d) && git commit -m x"), cwd: main },
178
+ { projectDir: main }).decision === "ask");
179
+ check("`cd -` is never treated as a resolvable move",
180
+ run({ ...bash("cd - && git commit -m x"), cwd: main }, { projectDir: main }).decision === "ask");
181
+ check("a quoted cd path with a space is followed",
182
+ run({ ...bash(`cd "${feat}" && git commit -m x`), cwd: main }, { projectDir: main }).decision === null);
183
+ // A cd must not soften the unconditional tiers — those are branch-independent.
184
+ check("cd does not exempt a `deny` pattern",
185
+ run({ ...bash(`cd ${feat} && node ace db:wipe`), cwd: main }, { projectDir: main }).decision === "deny");
186
+ check("cd does not exempt an always-`ask` pattern",
187
+ run({ ...bash(`cd ${feat} && psql`), cwd: main }, { projectDir: main }).decision === "ask");
158
188
  }
159
189
 
160
190
  // ── config robustness ────────────────────────────────────────────────────────
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ // Behavioural tests for scripts/kanban-move.sh — the board mirror's only writer.
3
+ //
4
+ // The mirror's failure mode is not a crash, it is silence: every call site chains
5
+ // `|| true`, so a card that stops moving looks exactly like a project with no
6
+ // board. That is how a shipped feature sat in "Ready to build" for a day — the
7
+ // ship session decided, without opening the config, that no board was configured.
8
+ // `auto` moved that decision out of the agent and into here, so here is where it
9
+ // has to be pinned: what resolves, what refuses to resolve, and the fact that a
10
+ // refusal SAYS WHY on stdout instead of exiting quietly.
11
+ //
12
+ // node scripts/test-kanban.mjs
13
+
14
+ import { spawnSync } from "node:child_process";
15
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const root = fileURLToPath(new URL("..", import.meta.url));
21
+ const SCRIPT = join(root, "scripts", "kanban-move.sh");
22
+
23
+ let failures = 0;
24
+ const check = (name, cond, detail = "") => {
25
+ if (cond) console.log(` ✓ ${name}`);
26
+ else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
27
+ };
28
+
29
+ const tmps = [];
30
+ function scratch() {
31
+ const d = mkdtempSync(join(tmpdir(), "kanban-"));
32
+ tmps.push(d);
33
+ return d;
34
+ }
35
+
36
+ const COLUMNS = ["Ideas", "Brainstorm", "Spec", "Ready to build", "Building",
37
+ "Review", "Fix", "Ship", "Shipped"];
38
+
39
+ function board(dir, cards = {}, name = "Tasks.md") {
40
+ const body = [
41
+ "---", "", "kanban-plugin: board", "", "---", "",
42
+ ...COLUMNS.flatMap((c) => ["## " + c, "", ...(cards[c] || []), ""]),
43
+ "%% kanban:settings", "```",
44
+ JSON.stringify({ "kanban-plugin": "board", "list-collapse": COLUMNS.map(() => false) }),
45
+ "```", "%%", "",
46
+ ].join("\n");
47
+ const p = join(dir, name);
48
+ writeFileSync(p, body);
49
+ return p;
50
+ }
51
+
52
+ function config(dir, { project = "demo", vault, boardRel = "Demo/Tasks.md",
53
+ enabled = true, kanbanEnabled = true, columns, boardColumns } = {}) {
54
+ const lines = [
55
+ `enabled: ${enabled} # cfg:enabled`,
56
+ "",
57
+ "obsidian:",
58
+ ` vault_path: "${vault}" # cfg:vault_path`,
59
+ "",
60
+ "kanban:",
61
+ ` enabled: ${kanbanEnabled} # cfg:kanban_enabled`,
62
+ " columns:",
63
+ ...Object.entries(columns || {
64
+ ideas: "Ideas", brainstorm: "Brainstorm", spec: "Spec", ready: "Ready to build",
65
+ building: "Building", review: "Review", fix: "Fix", ship: "Ship", shipped: "Shipped",
66
+ }).map(([k, v]) => ` ${k}: "${v}"`),
67
+ " boards:",
68
+ ` ${project}:`,
69
+ ` board: "${boardRel}"`,
70
+ ...(boardColumns ? [` columns: { ${boardColumns} }`] : []),
71
+ ];
72
+ const p = join(dir, "cohorte.config.yaml");
73
+ writeFileSync(p, lines.join("\n") + "\n");
74
+ return p;
75
+ }
76
+
77
+ function profile(dir, name) {
78
+ writeFileSync(join(dir, "PIPELINE.md"), [
79
+ "# PIPELINE.md", "",
80
+ "```yaml pipeline-profile",
81
+ "# ── identity ──",
82
+ `name: ${name}`,
83
+ "ui_language: French",
84
+ "surfaces:",
85
+ " - key: backend",
86
+ " name: not-the-project-name",
87
+ "```", "",
88
+ ].join("\n"));
89
+ }
90
+
91
+ function run(args, { cwd, config: cfg } = {}) {
92
+ const r = spawnSync("sh", [SCRIPT, ...args], {
93
+ cwd, encoding: "utf8",
94
+ env: { ...process.env, ...(cfg ? { COHORTE_CONFIG: cfg } : {}) },
95
+ });
96
+ return { status: r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
97
+ }
98
+
99
+ const columnOf = (path, id) => {
100
+ let col = null;
101
+ for (const line of readFileSync(path, "utf8").split("\n")) {
102
+ if (line.startsWith("## ")) col = line.slice(3).trim();
103
+ else if (line.includes("#" + id)) return col;
104
+ }
105
+ return null;
106
+ };
107
+
108
+ // ── explicit board path (the pre-existing contract) ─────────────────────────
109
+ console.log("explicit board");
110
+ {
111
+ const d = scratch();
112
+ const b = board(d, { "Ready to build": ["- [ ] Some feature #feat-a"] });
113
+ const r = run([b, "feat-a", "Shipped", "--pr", "42"]);
114
+ check("moves a card to a literal heading", r.status === 0 && columnOf(b, "feat-a") === "Shipped", r.err);
115
+ check("appends the PR number", readFileSync(b, "utf8").includes("#feat-a — PR #42"));
116
+ }
117
+ {
118
+ const d = scratch();
119
+ const b = board(d);
120
+ const r = run([b, "feat-new", "Spec", "--title", "Brand new"]);
121
+ check("creates a missing card", r.status === 0 && columnOf(b, "feat-new") === "Spec", r.err);
122
+ check("uses --title for the new card", readFileSync(b, "utf8").includes("- [ ] Brand new #feat-new"));
123
+ }
124
+ {
125
+ const d = scratch();
126
+ const b = board(d, { Ideas: ["- [ ] Dup #dup", "- [ ] Dup again #dup"] });
127
+ run([b, "dup", "Building"]);
128
+ const hits = readFileSync(b, "utf8").split("\n").filter((l) => l.includes("#dup")).length;
129
+ check("collapses duplicates to one card", hits === 1, `found ${hits}`);
130
+ }
131
+ {
132
+ const d = scratch();
133
+ const b = board(d, { Ideas: ["- [ ] Parent #par", " - a sub-note", " - another"] });
134
+ run([b, "par", "Brainstorm"]);
135
+ const txt = readFileSync(b, "utf8");
136
+ check("carries sub-notes along", columnOf(b, "par") === "Brainstorm" && txt.includes(" - a sub-note"));
137
+ check("leaves the settings block intact", txt.includes("%% kanban:settings"));
138
+ }
139
+ {
140
+ const d = scratch();
141
+ const b = board(d, { Ideas: ["- [ ] X #x"] });
142
+ const r = run([b, "x", "Nonexistent column"]);
143
+ check("unknown literal column is a loud failure (exit 3)", r.status === 3, `exit ${r.status}`);
144
+ const r2 = run(["/nope/board.md", "x", "Ideas"]);
145
+ check("missing explicit board is a loud failure (exit 3)", r2.status === 3, `exit ${r2.status}`);
146
+ }
147
+ {
148
+ const d = scratch();
149
+ const b = board(d, { Ideas: ["- [ ] X #x"] });
150
+ for (let i = 0; i < 6; i++) run([b, "x", i % 2 ? "Ideas" : "Building"]);
151
+ const blanks = (readFileSync(b, "utf8").match(/\n\n\n/g) || []).length;
152
+ check("repeated moves do not pad the board with blank lines", blanks === 0,
153
+ `${blanks} runs of blank lines after 6 moves`);
154
+ }
155
+
156
+ // ── auto resolution (the fix) ───────────────────────────────────────────────
157
+ console.log("auto resolution");
158
+ {
159
+ const d = scratch();
160
+ const vault = join(d, "vault");
161
+ mkdirSync(join(vault, "Demo"), { recursive: true });
162
+ const b = board(join(vault, "Demo"), { "Ready to build": ["- [ ] Feature #feat-b"] });
163
+ const cfg = config(d, { project: "demo", vault });
164
+ const repo = scratch();
165
+ profile(repo, "demo");
166
+ const r = run(["auto", "feat-b", "shipped", "--pr", "7"], { cwd: repo, config: cfg });
167
+ check("resolves vault + board + stage from the config",
168
+ r.status === 0 && columnOf(b, "feat-b") === "Shipped", `${r.err} ${r.out}`);
169
+ check("reports the move", r.out.startsWith("moved #feat-b"), r.out);
170
+ }
171
+ {
172
+ const d = scratch();
173
+ const vault = join(d, "vault");
174
+ mkdirSync(join(vault, "Demo"), { recursive: true });
175
+ const b = board(join(vault, "Demo"));
176
+ const cfg = config(d, { project: "demo", vault });
177
+ const repo = scratch();
178
+ profile(repo, "demo");
179
+ const stages = { ideas: "Ideas", brainstorm: "Brainstorm", spec: "Spec", ready: "Ready to build",
180
+ building: "Building", review: "Review", fix: "Fix", ship: "Ship", shipped: "Shipped" };
181
+ let ok = true, bad = "";
182
+ for (const [stage, heading] of Object.entries(stages)) {
183
+ run(["auto", "st", stage], { cwd: repo, config: cfg });
184
+ if (columnOf(b, "st") !== heading) { ok = false; bad = `${stage} → ${columnOf(b, "st")}`; }
185
+ }
186
+ check("every one of the nine stage keys maps to its heading", ok, bad);
187
+ }
188
+ {
189
+ const d = scratch();
190
+ const vault = join(d, "vault");
191
+ mkdirSync(join(vault, "Demo"), { recursive: true });
192
+ const b = board(join(vault, "Demo"), { Ideas: [], Prêt: [] }, "Tasks.md");
193
+ // a board whose headings are localised, declared per-board in the config
194
+ writeFileSync(b, readFileSync(b, "utf8").replace("## Ready to build", "## Prêt"));
195
+ const cfg = config(d, { project: "demo", vault, boardColumns: 'ready: "Prêt"' });
196
+ const repo = scratch();
197
+ profile(repo, "demo");
198
+ const r = run(["auto", "loc", "ready", "--title", "Localisé"], { cwd: repo, config: cfg });
199
+ check("per-board column override beats the global mapping",
200
+ r.status === 0 && columnOf(b, "loc") === "Prêt", `${r.err} ${r.out}`);
201
+ }
202
+ {
203
+ const repo = scratch();
204
+ profile(repo, "demo");
205
+ const r = run(["auto", "x", "shipped"], { cwd: repo, config: join(repo, "nope.yaml") });
206
+ check("no config ⇒ exit 0", r.status === 0, `exit ${r.status}`);
207
+ check("no config ⇒ says why on stdout", /^kanban: no config at /.test(r.out), r.out);
208
+ }
209
+ {
210
+ const d = scratch();
211
+ const cfg = config(d, { project: "other", vault: d });
212
+ const repo = scratch();
213
+ profile(repo, "demo");
214
+ const r = run(["auto", "x", "shipped"], { cwd: repo, config: cfg });
215
+ check("project not in boards ⇒ exit 0", r.status === 0, `exit ${r.status}`);
216
+ check("project not in boards ⇒ names the project it looked for",
217
+ r.out.includes('no board configured for project "demo"'), r.out);
218
+ }
219
+ {
220
+ const d = scratch();
221
+ const cfg = config(d, { project: "demo", vault: d, kanbanEnabled: false });
222
+ const repo = scratch();
223
+ profile(repo, "demo");
224
+ const r = run(["auto", "x", "shipped"], { cwd: repo, config: cfg });
225
+ check("kanban.enabled: false ⇒ exit 0 with a reason",
226
+ r.status === 0 && r.out.includes("kanban.enabled: false"), `${r.status} ${r.out}`);
227
+ }
228
+ {
229
+ const d = scratch();
230
+ const cfg = config(d, { project: "demo", vault: d, enabled: false });
231
+ const repo = scratch();
232
+ profile(repo, "demo");
233
+ const r = run(["auto", "x", "shipped"], { cwd: repo, config: cfg });
234
+ check("master enabled: false ⇒ exit 0 with a reason",
235
+ r.status === 0 && r.out.includes("enabled: false"), `${r.status} ${r.out}`);
236
+ }
237
+ {
238
+ const d = scratch();
239
+ const cfg = config(d, { project: "demo", vault: join(d, "vault"), boardRel: "Demo/Gone.md" });
240
+ const repo = scratch();
241
+ profile(repo, "demo");
242
+ const r = run(["auto", "x", "shipped"], { cwd: repo, config: cfg });
243
+ check("configured board file missing ⇒ exit 0, names the path",
244
+ r.status === 0 && r.out.includes("board file not found"), `${r.status} ${r.out}`);
245
+ }
246
+ {
247
+ const repo = scratch(); // no PIPELINE.md at all
248
+ const d = scratch();
249
+ const cfg = config(d, { project: "demo", vault: d });
250
+ const r = run(["auto", "x", "shipped"], { cwd: repo, config: cfg });
251
+ check("no profile ⇒ exit 0 with a reason",
252
+ r.status === 0 && r.out.includes("no project name"), `${r.status} ${r.out}`);
253
+ }
254
+ {
255
+ // The profile's `name:` is the one in the pipeline-profile block — not a
256
+ // `name:` nested under a surface, which is what a naive grep would find.
257
+ const d = scratch();
258
+ const vault = join(d, "vault");
259
+ mkdirSync(join(vault, "Demo"), { recursive: true });
260
+ const b = board(join(vault, "Demo"));
261
+ const cfg = config(d, { project: "demo", vault });
262
+ const repo = scratch();
263
+ profile(repo, "demo");
264
+ const r = run(["auto", "nested", "spec"], { cwd: repo, config: cfg });
265
+ check("reads the profile name from the pipeline-profile block only",
266
+ r.status === 0 && columnOf(b, "nested") === "Spec", `${r.err} ${r.out}`);
267
+ }
268
+ {
269
+ const d = scratch();
270
+ const vault = join(d, "vault");
271
+ mkdirSync(join(vault, "Demo"), { recursive: true });
272
+ const b = board(join(vault, "Demo"));
273
+ const cfg = config(d, { project: "demo", vault });
274
+ const r = run(["auto", "ov", "ship", "--project", "demo"], { cwd: scratch(), config: cfg });
275
+ check("--project overrides profile lookup",
276
+ r.status === 0 && columnOf(b, "ov") === "Ship", `${r.err} ${r.out}`);
277
+ }
278
+ {
279
+ const d = scratch();
280
+ const vault = join(d, "vault");
281
+ mkdirSync(join(vault, "Demo"), { recursive: true });
282
+ const b = board(join(vault, "Demo"));
283
+ const cfg = config(d, { project: "demo", vault });
284
+ const repo = scratch();
285
+ profile(repo, "demo");
286
+ const before = readFileSync(b, "utf8");
287
+ const r = run(["--check"], { cwd: repo, config: cfg });
288
+ check("--check names the resolved board",
289
+ r.status === 0 && r.out === `kanban: project "demo" -> ${b}`, r.out);
290
+ check("--check does not touch the board", readFileSync(b, "utf8") === before);
291
+ const r2 = run(["--check"], { cwd: scratch(), config: cfg });
292
+ check("--check reports the reason when nothing resolves",
293
+ r2.status === 0 && r2.out.startsWith("kanban: no project name"), r2.out);
294
+ }
295
+ {
296
+ const r = run(["auto", "x"]);
297
+ check("too few arguments is a usage error (exit 2)", r.status === 2, `exit ${r.status}`);
298
+ const r2 = run(["auto", "x", "shipped", "--bogus", "1"]);
299
+ check("unknown flag is a usage error (exit 2)", r2.status === 2, `exit ${r2.status}`);
300
+ }
301
+
302
+ for (const d of tmps) rmSync(d, { recursive: true, force: true });
303
+ if (failures) { console.error(`\n${failures} failing check(s)`); process.exit(1); }
304
+ console.log("\nall kanban checks passed");
@@ -59,6 +59,9 @@ case "$cmd" in
59
59
  *) echo "fake claude: expected a /cohorte-* command, got '$cmd'" >&2; exit 9 ;;
60
60
  esac
61
61
  cmd="\${cmd#/cohorte-}" # scenarios are keyed on the PHASE, which stays unprefixed
62
+ # Echoed so the tests can assert what the driver hands its children: an unattended child
63
+ # that cannot answer a permission prompt, and a background ceiling that must not fire.
64
+ echo "fake claude: bgceil=\${CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS:-unset}"
62
65
  n=0; [ -f "$SCEN_DIR/count" ] && n=$(cat "$SCEN_DIR/count")
63
66
  n=$((n + 1)); echo "$n" >"$SCEN_DIR/count"
64
67
  step=$(sed -n "\${n}p" "$SCEN_DIR/phases")
@@ -72,6 +75,13 @@ case "$do_what" in
72
75
  >specs/reports/feat-x.readiness.json ;;
73
76
  ready)
74
77
  printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
78
+ >specs/reports/feat-x.readiness.json
79
+ printf '{"id":"feat-x","phase":"build","ts":"t","surfaces":{"backend":"ok"},"dead":[]}' \\
80
+ >specs/reports/feat-x.build.json ;;
81
+ # READY, dispatched, and then cut short before §3's report — the harness terminating
82
+ # background implementers, a teardown, a crash. No build.json, and exit 0 anyway.
83
+ cutshort)
84
+ printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend","frontend"]}' \\
75
85
  >specs/reports/feat-x.readiness.json ;;
76
86
  clean)
77
87
  printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"SHIP","findings":2,"blocking":0,"deferred":2,"unreviewed":[],"fingerprint":""}' \\
@@ -114,24 +124,26 @@ function scenario(phases, { frontmatter = FM } = {}) {
114
124
  }
115
125
 
116
126
  function runLoop({ dir, bin }, args) {
117
- const r = spawnSync("bash", [LOOP, ...args], {
118
- cwd: dir,
119
- encoding: "utf8",
120
- env: {
121
- ...process.env,
122
- PATH: `${bin}:${process.env.PATH}`,
123
- SCEN_DIR: dir,
124
- CLAUDE_FLAGS: "--permission-mode acceptEdits",
125
- GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
126
- GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
127
- },
128
- });
127
+ const env = {
128
+ ...process.env,
129
+ PATH: `${bin}:${process.env.PATH}`,
130
+ SCEN_DIR: dir,
131
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
132
+ GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
133
+ };
134
+ // Never inherit these from whoever runs the suite: the default child flags and the
135
+ // background ceiling are exactly what the assertions below are about.
136
+ delete env.CLAUDE_FLAGS;
137
+ delete env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS;
138
+ const r = spawnSync("bash", [LOOP, ...args], { cwd: dir, encoding: "utf8", env });
129
139
  const spec = readFileSync(join(dir, "specs/feat-x.md"), "utf8");
130
140
  const fm = k => {
131
141
  const m = spec.match(new RegExp(`^${k}:\\s*([^#\\n]*)`, "m"));
132
142
  return m ? m[1].trim() : null;
133
143
  };
134
- return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm };
144
+ const logPath = join(dir, "specs/reports/feat-x.loop.log");
145
+ const log = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
146
+ return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm, log };
135
147
  }
136
148
 
137
149
  console.log("loop.sh — readiness gate");
@@ -189,6 +201,55 @@ console.log("loop.sh — a dead subagent is never a clean result");
189
201
  check("dead reviewer ⇒ spec is NOT left in-review", r.fm("status") === "blocked", r.fm("status"));
190
202
  }
191
203
 
204
+ console.log("loop.sh — a build that never reported is never a built build");
205
+ {
206
+ // The absent-file twin of the dead implementer, and the one the `dead[]` grep cannot
207
+ // see: a phase cut short never reaches the step that writes build.json, so there is no
208
+ // file to read and no surface to name — while the child still exits 0. Scoring that as
209
+ // a clean build stamps `.built` over a half-written tree and sends reviewers at it.
210
+ const s = scenario(["build:cutshort"]);
211
+ const r = runLoop(s, ["feat-x"]);
212
+ check("no build.json ⇒ exit 2, not a review pass", r.code === 2, `got ${r.code}: ${r.out}`);
213
+ check("no build.json ⇒ no reviewer was spawned", !/phase=review/.test(r.out));
214
+ check("no build.json ⇒ names the cut-short phase", /wrote no .*build\.json/.test(r.out),
215
+ r.out.trim().split("\n").pop());
216
+ check("no build.json ⇒ the build stamp is NOT written (a re-run must rebuild)",
217
+ !existsSync(join(s.dir, "specs/reports/feat-x.built")));
218
+ check("no build.json ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
219
+ }
220
+
221
+ console.log("loop.sh — what the children are handed");
222
+ {
223
+ // acceptEdits auto-approves Write/Edit and NOTHING else, so the first child Bash call no
224
+ // `allow` rule covers raises a prompt no `claude -p` can answer: the child stalls, asks
225
+ // the human in prose, and exits 0 — which the driver scores `ok`. Seen on a real run,
226
+ // where the review child hung on its own preflight.sh call. gate.py is built for the
227
+ // other mode: it escalates `ask` to a hard deny under bypassPermissions.
228
+ const s = scenario(["build:ready", "review:clean"]);
229
+ const r = runLoop(s, ["feat-x"]);
230
+ check("default child flags are bypassPermissions, not acceptEdits",
231
+ /# flags: --permission-mode bypassPermissions/.test(r.log) && !/acceptEdits/.test(r.log),
232
+ r.log.split("\n")[1]);
233
+ // Print mode TERMINATES still-running background tasks at its ceiling ("Background tasks
234
+ // still running after 600s"), which cuts a 25–40 min implementer batch off mid-write.
235
+ check("children inherit an unbounded background-task ceiling",
236
+ /fake claude: bgceil=0/.test(r.log), (r.log.match(/bgceil=\S*/) || ["absent"])[0]);
237
+ }
238
+ {
239
+ // The override is the escape hatch for a watched run — it must not have been hard-coded away.
240
+ const s = scenario(["review:clean"]);
241
+ const r = spawnSync("bash", [LOOP, "feat-x", "--no-build"], {
242
+ cwd: s.dir, encoding: "utf8",
243
+ env: { ...process.env, PATH: `${s.bin}:${process.env.PATH}`, SCEN_DIR: s.dir,
244
+ CLAUDE_FLAGS: "--permission-mode acceptEdits",
245
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
246
+ GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t" },
247
+ });
248
+ const log = readFileSync(join(s.dir, "specs/reports/feat-x.loop.log"), "utf8");
249
+ check("CLAUDE_FLAGS overrides the default", /# flags: --permission-mode acceptEdits/.test(log),
250
+ `${r.status}: ${log.split("\n")[1]}`);
251
+ }
252
+
192
253
  console.log("loop.sh — non-convergent + resume");
193
254
  {
194
255
  const s = scenario(["build:ready", "review:blocking", "fix:noop", "review:blocking"]);
@@ -150,6 +150,27 @@ for (const f of readdirSync(join(root, "core/commands"))) {
150
150
  fail(`core/commands/${f}`, "non-funnel command pings telemetry — outside the consented scope");
151
151
  }
152
152
 
153
+ // ── kanban call sites ───────────────────────────────────────────────────────
154
+ // Every pipeline stage moves a card, and a stage that only *describes* the move
155
+ // ("move card #<id> → Building, no-op silently if no board") leaves the agent to
156
+ // decide whether a board exists — which it does by not looking. That is not
157
+ // hypothetical: a /cohorte-ship session declared "no kanban board configured",
158
+ // having opened neither the config nor PIPELINE.md, and a merged feature's card
159
+ // stayed in "Ready to build". `kanban-move.sh auto` moved resolution into the
160
+ // script; this keeps it there. Prose is not a call site — the literal invocation is.
161
+ const KANBAN_STAGES = ["brainstorm", "spec", "build", "review", "fix", "ship"];
162
+ for (const c of KANBAN_STAGES) {
163
+ const path = `core/commands/${PREFIX}${c}.md`;
164
+ const text = read(path);
165
+ if (!/kanban-move\.sh\s+auto\s+\S/.test(text))
166
+ fail(path, "moves a kanban card without a literal `kanban-move.sh auto …` call — the agent is left to infer whether a board exists");
167
+ // The one sentence that turns an unread config into a reported no-op. Match on
168
+ // unwrapped text: these live in `>` blockquotes and wrap mid-sentence.
169
+ const flat = text.replace(/\n>?\s*/g, " ");
170
+ if (!/without\s+running\s+it/i.test(flat))
171
+ fail(path, "no instruction to run the resolver before concluding there is no board");
172
+ }
173
+
153
174
  // ── shipped scripts ─────────────────────────────────────────────────────────
154
175
  // Every scripts/*.sh must be copied by BOTH shell installers. Callers chain these
155
176
  // with `|| true`, so one an installer forgets is a silent no-op forever — no kanban