cohorte 2.0.1 → 2.1.0
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 +76 -0
- package/core/agents/release.md +6 -0
- package/core/commands/cohorte-brainstorm.md +17 -5
- package/core/commands/cohorte-build.md +6 -2
- package/core/commands/cohorte-doctor.md +9 -0
- package/core/commands/cohorte-fix.md +5 -2
- package/core/commands/cohorte-review.md +5 -1
- package/core/commands/cohorte-ship.md +60 -17
- package/core/commands/cohorte-spec.md +6 -2
- package/core/commands/cohorte-update-pipeline.md +12 -6
- package/core/hooks/gate.py +50 -7
- package/core/templates/steps/init-pipeline/01-detect-stack.md +6 -2
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +6 -0
- package/package.json +1 -1
- package/profile/PIPELINE.template.md +20 -0
- package/profile/SCHEMA.md +79 -11
- package/scripts/kanban-move.sh +201 -10
- package/scripts/test-gate.mjs +30 -0
- package/scripts/test-kanban.mjs +304 -0
- package/scripts/validate-core.mjs +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,82 @@ 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.1.0 — 2026-08-09
|
|
11
|
+
|
|
12
|
+
- **A repo that gates merges on a per-feature release note shipped red PRs, and the flow reported
|
|
13
|
+
success.** Changesets' `changeset` job fails any PR that touches product code without a
|
|
14
|
+
`.changeset/*.md`, but that requirement lives in the project's `CLAUDE.md` — which the ship flow
|
|
15
|
+
never reads. So `/cohorte-ship` committed, pushed, opened the PR and moved the kanban card to
|
|
16
|
+
**Shipped** while the PR was unmergeable, red on a job nobody was watching.
|
|
17
|
+
|
|
18
|
+
The profile gains a `release_notes` block (`enabled`, `tool`, `dir`/`filename`,
|
|
19
|
+
`anchor_package`, `language`, `forbid_levels`, `empty_cmd`, `ci_job`, `guidance` — SCHEMA.md
|
|
20
|
+
§Release notes), and `/cohorte-ship` gains **§2b**: the lead writes the note itself, next to the
|
|
21
|
+
`status: shipped` flip, so it lands *inside* the release commit rather than in a second one after
|
|
22
|
+
the PR is already open. The bump level is project policy, not a git ritual — the release agent is
|
|
23
|
+
now explicitly forbidden from authoring or editing a note, and only stages the one it is handed.
|
|
24
|
+
§4 verifies the note is in the commit; §5 routes a red `ci_job` back to §2b instead of through
|
|
25
|
+
`/cohorte-fix`, which would treat a missing note as a code finding.
|
|
26
|
+
|
|
27
|
+
Two things the block encodes that the tool itself does not: `forbid_levels`, for the `0.x` repo
|
|
28
|
+
where a `major` changeset silently jumps to `1.0.0` with no human deciding it, and the rule to
|
|
29
|
+
**ask** rather than guess between two defensible levels — a wrong bump is a published version
|
|
30
|
+
number, not a fixable draft. `empty_cmd` covers the honest no-op, for a PR that must move no
|
|
31
|
+
version at all.
|
|
32
|
+
|
|
33
|
+
- **Existing projects are asked, not defaulted.** `/cohorte-init-pipeline` detects a versioning tool
|
|
34
|
+
or note-enforcing CI job in Phase 1 and asks the anchor package, language and bump policy in Phase
|
|
35
|
+
2; `/cohorte-update-pipeline` treats `release_notes` as a genuine human decision during reconcile
|
|
36
|
+
rather than topping it up blind. No tool found ⇒ `enabled: false`, and §2b is a silent no-op.
|
|
37
|
+
|
|
38
|
+
## 2.0.2 — 2026-08-08
|
|
39
|
+
|
|
40
|
+
- **Kanban cards stopped moving mid-pipeline, and every stage still reported success.** The
|
|
41
|
+
stages only *described* the move — "move card `#<id>` → Building, no-op silently if no board" —
|
|
42
|
+
without saying where a board is even declared. Each phase runs in its own session after a
|
|
43
|
+
`/clear`, so the cheapest reading consistent with that instruction is "I see no board, so there
|
|
44
|
+
is none": a `/cohorte-ship` run announced "no kanban board configured", having opened neither
|
|
45
|
+
`~/.claude/cohorte.config.yaml` nor `PIPELINE.md`, and a feature that shipped and merged left
|
|
46
|
+
its card sitting in "Ready to build". Silence was indistinguishable from correctness, so this
|
|
47
|
+
went unnoticed across several features.
|
|
48
|
+
|
|
49
|
+
Resolution is not a judgment call, so it is no longer made by a judge. `kanban-move.sh auto <id>
|
|
50
|
+
<stage>` now reads the profile `name`, the config's `kanban.enabled` / `obsidian.vault_path` /
|
|
51
|
+
`boards[name]`, and maps the **stage key** (`ideas` … `shipped`) to that board's heading through
|
|
52
|
+
`boards[name].columns` → `kanban.columns` → a built-in default. Every stage calls exactly that,
|
|
53
|
+
and reports the line it printed: `moved #<id> -> <column>`, or `kanban: <reason>` naming the
|
|
54
|
+
missing link. Both exit 0 and they are not interchangeable. A board that *is* configured but
|
|
55
|
+
unmovable stays loud (exit 2 usage, exit 3 missing board / unknown column). Explicit board paths
|
|
56
|
+
and literal headings still work.
|
|
57
|
+
|
|
58
|
+
`validate-core` now fails any funnel command that moves a card without a literal
|
|
59
|
+
`kanban-move.sh auto …` call, or that omits the instruction not to conclude "no board" without
|
|
60
|
+
running it — the prose-only form is what regressed, so the prose-only form is what is banned.
|
|
61
|
+
New `scripts/test-kanban.mjs` (29 checks) pins the resolution table and the move semantics.
|
|
62
|
+
|
|
63
|
+
- **Renaming a project silently unlinks its board.** `kanban.boards` is keyed by the profile
|
|
64
|
+
`name`, so editing `name:` orphans the old entry and no lookup matches the new one — a genuine
|
|
65
|
+
"not configured", identical from the outside to never having had a board. `/cohorte-doctor`
|
|
66
|
+
gains check **7b**, which reports the resolved board (or the exact missing link) and flags an
|
|
67
|
+
orphaned entry as a rename; `/cohorte-update-pipeline` offers to re-key it instead of creating a
|
|
68
|
+
second board. New `kanban-move.sh --check` does the resolution alone, for both.
|
|
69
|
+
|
|
70
|
+
- **The gate judged `cd <other repo> && git commit` against the wrong checkout.** Branch-conditional
|
|
71
|
+
patterns resolved the branch at the payload cwd, but the tool's shell keeps no cwd between calls,
|
|
72
|
+
so agents write the `cd` into the command itself. A commit on a feature branch in another repo was
|
|
73
|
+
therefore refused as if it were on the default branch — and the refusal named a branch the command
|
|
74
|
+
was never going to run on, so no confirmation could lift it (in an unattended run, where `ask`
|
|
75
|
+
escalates to `deny`, it was a hard wall). The branch is now resolved per segment at the cwd that
|
|
76
|
+
segment actually runs in, cached so the common case is still one `git` call. Deliberately narrow:
|
|
77
|
+
a plain absolute/relative/quoted `cd`, never `cd -`, `$(…)`, variables or globs — anything
|
|
78
|
+
unresolvable keeps the previous cwd, which is the direction that keeps gating. `cd` never softens
|
|
79
|
+
the unconditional `deny`/`ask` tiers; 10 new cases in `test-gate.mjs` pin all of it.
|
|
80
|
+
|
|
81
|
+
- **`/cohorte-brainstorm` tags an Ideas card before moving it.** The join key is the `#<id>` tag,
|
|
82
|
+
and an Ideas card a human typed by hand has none — so the move found nothing, created a second
|
|
83
|
+
card, and stranded the original in Ideas. It now appends the tag first, located by `grep -n`,
|
|
84
|
+
never a full board read.
|
|
85
|
+
|
|
10
86
|
## 2.0.1 — 2026-08-03
|
|
11
87
|
|
|
12
88
|
Three fixes, one failure: an autonomous `/cohorte-loop` run that built 1 surface of 3, stamped
|
package/core/agents/release.md
CHANGED
|
@@ -23,11 +23,17 @@ committing, pushing, and opening the PR. You do **not** write features.
|
|
|
23
23
|
on pushed commits), or delete branches.
|
|
24
24
|
- Run anything in `PIPELINE.md` §`gate.deny` (destructive DB/history).
|
|
25
25
|
- Commit secrets — inspect `git status`/`git diff` and refuse if `.env` or credentials are staged.
|
|
26
|
+
- **Author or edit a release note** (`.changeset/*.md` or whatever `PIPELINE.md` §`release_notes`
|
|
27
|
+
declares). The lead writes it before dispatching you; picking a bump level is project policy, not a
|
|
28
|
+
git ritual. You only **stage** it. If `release_notes.enabled` and the file is absent, say so in your
|
|
29
|
+
report instead of inventing one — the lead fixes it.
|
|
26
30
|
|
|
27
31
|
## Your inputs
|
|
28
32
|
|
|
29
33
|
1. The spec path `specs/<id>.md` (title, goal, contract — for the PR body).
|
|
30
34
|
2. `feature_id` and the branch `<vcs.feature_branch_prefix><id>`.
|
|
35
|
+
3. If `PIPELINE.md` §`release_notes.enabled`, the already-written note at
|
|
36
|
+
`<release_notes.dir>/<release_notes.filename>` — stage it with everything else.
|
|
31
37
|
|
|
32
38
|
## Steps
|
|
33
39
|
|
|
@@ -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):
|
|
24
|
-
>
|
|
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.
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
14
|
-
>
|
|
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):
|
|
15
|
-
>
|
|
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):
|
|
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.
|
|
@@ -6,12 +6,17 @@ argument-hint: <feature_id>
|
|
|
6
6
|
|
|
7
7
|
You are the **lead**. Ship feature **$ARGUMENTS**. This is the outward-facing gate.
|
|
8
8
|
|
|
9
|
-
> Read `PIPELINE.md` §`vcs` (host, remote, default_branch, feature_branch_prefix)
|
|
9
|
+
> Read `PIPELINE.md` §`vcs` (host, remote, default_branch, feature_branch_prefix) **and
|
|
10
|
+
> §`release_notes`** — §2b below is skipped or required based on it, and skipping it when it is
|
|
11
|
+
> required opens a PR that CI fails on arrival.
|
|
10
12
|
>
|
|
11
13
|
> **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.
|
|
13
|
-
>
|
|
14
|
-
>
|
|
14
|
+
> §1 moves the card → **Ship**; §4 moves it → **Shipped** and writes the PR number. Both are one
|
|
15
|
+
> call to `<core>/pipeline/scripts/kanban-move.sh auto …` (`<core>` = `.claude` bundled / `~/.claude`
|
|
16
|
+
> global — probe with `test -x`), which resolves the board from the config itself and exits 0 with a
|
|
17
|
+
> `kanban: <reason>` line when there is none. **Never decide "no board is configured" without running
|
|
18
|
+
> it** — a ship session that inferred that, having opened neither the config nor `PIPELINE.md`, is
|
|
19
|
+
> exactly how a merged feature's card stayed in "Ready to build". Do not skip §4's move either.
|
|
15
20
|
|
|
16
21
|
## 1. Pre-flight (confirm before doing anything irreversible)
|
|
17
22
|
|
|
@@ -29,7 +34,8 @@ You are the **lead**. Ship feature **$ARGUMENTS**. This is the outward-facing ga
|
|
|
29
34
|
All `- [x]` ⇒ proceed silently.
|
|
30
35
|
- Show `git status` + `git diff --stat`; confirm the branch is `<feature_branch_prefix>$ARGUMENTS`.
|
|
31
36
|
- **Ask the human to confirm** they want to commit, push, and open the PR. Wait for yes.
|
|
32
|
-
- After the yes:
|
|
37
|
+
- After the yes: `<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS ship`. Report what it
|
|
38
|
+
printed — `moved #…` or `kanban: <reason>` — never a guess about which happened.
|
|
33
39
|
|
|
34
40
|
## 2. Mark the spec shipped (BEFORE dispatch, so it ships in the same commit)
|
|
35
41
|
|
|
@@ -37,28 +43,63 @@ Once the human confirms, edit `specs/$ARGUMENTS.md` front-matter `status: → sh
|
|
|
37
43
|
dispatching the release agent, so the status flip is part of the tree it commits (otherwise it lands
|
|
38
44
|
uncommitted after the PR opens). Only flip after the human's "yes"; if they decline, leave it.
|
|
39
45
|
|
|
46
|
+
## 2b. Write the release note (only if `release_notes.enabled` — same reason: it must ship in the commit)
|
|
47
|
+
|
|
48
|
+
`release_notes.enabled: false` (or `tool: none`) ⇒ skip this section entirely, silently.
|
|
49
|
+
|
|
50
|
+
Otherwise **you** write it — never the release agent, never the implementers. Picking the bump is
|
|
51
|
+
project policy and the prose is outward-facing copy; both are the lead's, exactly like the contract.
|
|
52
|
+
See SCHEMA.md §Release notes.
|
|
53
|
+
|
|
54
|
+
- Write `<release_notes.dir>/<release_notes.filename>` (`<feature_id>` substituted), front-matter
|
|
55
|
+
carrying the **single** key `release_notes.anchor_package` and the chosen level, then the prose body
|
|
56
|
+
in `release_notes.language`.
|
|
57
|
+
- **Choose the level** against `release_notes.guidance`, and refuse any level in
|
|
58
|
+
`release_notes.forbid_levels` (a `0.x` repo forbidding `major` declares the rupture `minor`).
|
|
59
|
+
- **Ambiguous between two defensible levels?** State your reading in one line and **ask the human to
|
|
60
|
+
pick** before writing. A wrong bump becomes a published version number.
|
|
61
|
+
- The body describes what changed **for the user**, from the spec §1/§2 — no client names, no internal
|
|
62
|
+
paths, no exploitable attack vector, no file lists.
|
|
63
|
+
- If the feature genuinely must move no version, use `release_notes.empty_cmd` instead. Prefer that to
|
|
64
|
+
skipping: the CI job wants a file, not a version.
|
|
65
|
+
- Then say in one line which level you chose and why — this is the human's last chance to correct it
|
|
66
|
+
before it is committed.
|
|
67
|
+
|
|
68
|
+
> **Why this is its own gate.** The requirement usually lives in the project's `CLAUDE.md`, which this
|
|
69
|
+
> flow never reads. Skip it and everything below still "succeeds": commit, push, PR opened, kanban card
|
|
70
|
+
> moved to **Shipped** — and CI red on a job nobody watched. The feature reads as shipped while being
|
|
71
|
+
> unmergeable.
|
|
72
|
+
|
|
40
73
|
## 3. Dispatch the `release` agent
|
|
41
74
|
|
|
42
75
|
Spawn one agent (`subagent_type: release`): "Release feature `$ARGUMENTS` on branch
|
|
43
76
|
`<feature_branch_prefix>$ARGUMENTS`. Read `PIPELINE.md` §vcs first. Spec: `specs/$ARGUMENTS.md` (already
|
|
44
77
|
`status: shipped` — stage it). Write conventional commit(s), push (no force), open the PR (use `gh` if
|
|
45
78
|
`host: github` + available; else emit the compare URL + drafted PR body from `.claude/templates/pr-body.md`).
|
|
46
|
-
Stage **all** the feature's changes including `specs/$ARGUMENTS.md
|
|
47
|
-
|
|
79
|
+
Stage **all** the feature's changes including `specs/$ARGUMENTS.md` and, if `release_notes.enabled`, the
|
|
80
|
+
release note at `<release_notes.dir>/<release_notes.filename>` — it is already written, stage it as-is and
|
|
81
|
+
never author or edit one yourself. Never edit source, never force-push, never run migrations."
|
|
48
82
|
|
|
49
83
|
## 4. Relay + move the card to Shipped (do not skip)
|
|
50
84
|
|
|
51
85
|
Print the release agent's report: commit SHA(s), pushed branch, PR URL (or compare URL + drafted body).
|
|
52
|
-
Confirm `specs/$ARGUMENTS.md` was committed as `status: shipped` (part of the release commit)
|
|
86
|
+
Confirm `specs/$ARGUMENTS.md` was committed as `status: shipped` (part of the release commit), and — if
|
|
87
|
+
`release_notes.enabled` — that the release note is in that same commit
|
|
88
|
+
(`git show <sha> --stat | grep <release_notes.dir>`). Missing ⇒ commit and push it now, before §5's CI
|
|
89
|
+
watch, rather than letting the job go red.
|
|
90
|
+
|
|
91
|
+
**Move the card to Shipped — required, and verify it actually moved.** Run
|
|
92
|
+
`<core>/pipeline/scripts/kanban-move.sh auto $ARGUMENTS shipped --pr <num>`, which **appends the PR
|
|
93
|
+
number** so the line reads `- [ ] <title> #$ARGUMENTS — PR #<num>`. Take `<num>` from the PR URL
|
|
94
|
+
(`…/pull/13` ⇒ `13`); **always pass it when a PR was created** (the `gh` path) — it is what the
|
|
95
|
+
dashboard turns into a PR link. If only a compare URL was emitted (no PR yet), drop `--pr`.
|
|
53
96
|
|
|
54
|
-
**
|
|
55
|
-
`
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
97
|
+
Then **read the script's own output**, which is the verification: `moved #$ARGUMENTS -> Shipped
|
|
98
|
+
(PR #<num>)` means done, and a `kanban: <reason>` line means the mirror is off and says why. Both are
|
|
99
|
+
exit 0 and they are not interchangeable — say which one you got. Only if it moved, confirm placement
|
|
100
|
+
with a **grep for `#$ARGUMENTS`** on the board it named (with surrounding heading context —
|
|
101
|
+
`grep -B20 '#$ARGUMENTS' | grep '^##'`, or an offset-limited Read around the match): exactly one card,
|
|
102
|
+
under the `shipped` heading — never re-read the whole board into context.
|
|
62
103
|
|
|
63
104
|
**Telemetry — the usage ping that closes the funnel.** Chain it onto the verify call above
|
|
64
105
|
(`/cohorte-build` §4's shared form, `<phase>` = `ship`, `<seconds>` = `0` — the release agent's duration is
|
|
@@ -70,7 +111,9 @@ call. Silent no-op without consent; never ask about consent here.
|
|
|
70
111
|
## 5. After the PR — CI gate + teardown
|
|
71
112
|
|
|
72
113
|
- If `host: github` and `gh` is available, watch the PR's checks (`gh pr checks <url> --watch`) and
|
|
73
|
-
report the result — the human merges only on green. A red check ⇒ back to `/cohorte-fix $ARGUMENTS
|
|
114
|
+
report the result — the human merges only on green. A red check ⇒ back to `/cohorte-fix $ARGUMENTS`,
|
|
115
|
+
**except** a red `release_notes.ci_job`: that one is this command's own miss, not a code finding —
|
|
116
|
+
write the note per §2b, commit, push, and re-watch. Never send a missing release note through `/cohorte-fix`.
|
|
74
117
|
- Once the human confirms the PR is **merged**: if `isolation.enabled`, propose the teardown —
|
|
75
118
|
`scripts/remove-feature.sh $ARGUMENTS` (add `--drop-db` to also drop the feature db; kept by
|
|
76
119
|
default). It removes the worktree, deletes the merged branch, frees the slot. Never run it before
|
|
@@ -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,
|
|
21
|
-
>
|
|
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
|
|
106
|
-
|
|
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` —
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
package/core/hooks/gate.py
CHANGED
|
@@ -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.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
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
|
|
|
@@ -24,6 +24,10 @@ Gather evidence, then summarize what you found. Look for:
|
|
|
24
24
|
`package.json` scripts + the workspace filter syntax (e.g. `pnpm --filter <pkg> test`).
|
|
25
25
|
- **Contract mechanism:** a shared types/schema package (`packages/shared-types`, Zod/`z.`),
|
|
26
26
|
an `openapi.*`/`swagger.*` file, `.proto` files, or none.
|
|
27
|
+
- **Release notes / versioning:** a `.changeset/` directory (+ `config.json`, and whether it declares a
|
|
28
|
+
`fixed`/lockstep group — that names the anchor package), `changesets` in devDependencies, or a CI job
|
|
29
|
+
that fails a PR lacking a note (grep `.github/workflows/*` for `changeset`). Also note the root
|
|
30
|
+
`version`: a `0.y.z` product usually forbids `major`. None of these ⇒ `release_notes.enabled: false`.
|
|
27
31
|
- **DB / migrations:** migration tooling (`node ace make:migration`, `knex`, `prisma`, `alembic`,
|
|
28
32
|
`golang-migrate`), a `docker-compose.yml`, DB service.
|
|
29
33
|
- **Design system:** an existing `design-reference/` snapshot, `components/ui`, a DesignSync MCP
|
|
@@ -36,5 +40,5 @@ Gather evidence, then summarize what you found. Look for:
|
|
|
36
40
|
- **Existing `CLAUDE.md`** — read it; it may already state stack/conventions to fold in. **Existing
|
|
37
41
|
`PIPELINE.md`** — if present, this is a re-run: load it as the starting draft and only reconcile deltas.
|
|
38
42
|
|
|
39
|
-
Print a compact **Detection Report**: layout, surfaces (with framework + commands), contract,
|
|
40
|
-
design, vcs. Mark each field `detected` / `guessed` / `unknown`.
|
|
43
|
+
Print a compact **Detection Report**: layout, surfaces (with framework + commands), contract, release
|
|
44
|
+
notes, DB, design, vcs. Mark each field `detected` / `guessed` / `unknown`.
|
|
@@ -24,6 +24,12 @@ Ask ONLY what you couldn't confidently detect. Batch related questions. Cover:
|
|
|
24
24
|
the runner has no such flag, leave `""` — consumers then fall back to `<cmd> 2>&1 | tail -40`.
|
|
25
25
|
- **Contract** — mechanism (`shared-types-zod` / `openapi` / `protobuf` / `json-schema` / `none`) and
|
|
26
26
|
where feature contracts are authored. If `none`, surfaces sync by the spec prose alone.
|
|
27
|
+
- **Release notes (only if Phase 1 found a versioning tool or a note-enforcing CI job)** — confirm the
|
|
28
|
+
anchor package (a `fixed`/lockstep group means ONE key propagates to all), the note's language, and the
|
|
29
|
+
**bump policy** the lead will apply at `/cohorte-ship`: what counts as major/minor/patch here, and
|
|
30
|
+
whether any level is forbidden (a `0.y.z` product normally forbids `major` — Changesets would jump it
|
|
31
|
+
to `1.0.0` with no human deciding). Policy prose lands in `release_notes.guidance`. Nothing detected ⇒
|
|
32
|
+
don't ask; leave `release_notes.enabled: false`.
|
|
27
33
|
- **UI language** — language of all user-facing copy.
|
|
28
34
|
- **RBAC** — is there a role hierarchy? If yes, list it highest→lowest.
|
|
29
35
|
- **Design system** — enabled? provider (Claude Design / Figma / none) + project ids + kit/token paths.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cohorte",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
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"
|
|
@@ -97,6 +97,26 @@ contract:
|
|
|
97
97
|
index: packages/shared-types/src/index.ts # barrel to export from, or "" if none
|
|
98
98
|
authored_by: lead # NEVER the implementer agents
|
|
99
99
|
|
|
100
|
+
# ── release notes (optional) ────────────────────────────────────────────────
|
|
101
|
+
# A per-feature release note the project's versioning tool consumes. When
|
|
102
|
+
# enabled, /cohorte-ship authors it BEFORE dispatching the release agent, so it
|
|
103
|
+
# ships inside the release commit. Repos whose CI fails a PR that lacks one
|
|
104
|
+
# (Changesets' `changeset` job) MUST set this — otherwise every ship goes red.
|
|
105
|
+
release_notes:
|
|
106
|
+
enabled: <true | false> # false ⇒ /cohorte-ship §2b is a no-op
|
|
107
|
+
tool: <changesets | none> # what consumes the file
|
|
108
|
+
dir: .changeset # where the note is authored
|
|
109
|
+
filename: "<feature_id>.md" # one file per feature
|
|
110
|
+
anchor_package: shared-types # sole key in the front-matter; a lockstep/fixed group propagates the bump
|
|
111
|
+
language: <ui_language | English> # language of the note's prose
|
|
112
|
+
forbid_levels: [] # bumps project policy refuses, e.g. [major] while 0.x
|
|
113
|
+
empty_cmd: "pnpm changeset --empty" # when no version should move; "" if the tool has no such escape hatch
|
|
114
|
+
ci_job: changeset # CI job that fails when the note is missing (name it so /cohorte-ship §5 recognizes the red)
|
|
115
|
+
guidance: | # rules the lead follows when writing the note (project policy)
|
|
116
|
+
major = the deploy needs an operator action; minor = a user-visible capability;
|
|
117
|
+
patch = fix/perf/refactor with no change of use. No client names, no internal
|
|
118
|
+
paths, no exploitable attack vector.
|
|
119
|
+
|
|
100
120
|
# ── repo-wide commands (root unless noted) ──────────────────────────────────
|
|
101
121
|
commands:
|
|
102
122
|
install: pnpm install
|
package/profile/SCHEMA.md
CHANGED
|
@@ -33,6 +33,15 @@ generic pipeline uses it, so a stateless agent can read/regenerate the profile c
|
|
|
33
33
|
| `contract.mechanism` | enum | build, lead | `shared-types-zod`/`openapi`/`protobuf`/`json-schema`/`none`. |
|
|
34
34
|
| `contract.path` `.ext` `.index` | string | build | Where `<feature_id>` contract is authored + barrel. |
|
|
35
35
|
| `contract.authored_by` | const `lead` | build | Implementers import it read-only, never edit. |
|
|
36
|
+
| `release_notes.enabled` | bool | ship | `false` ⇒ skip note authoring (§2b of /cohorte-ship). See §Release notes. |
|
|
37
|
+
| `release_notes.tool` | enum | ship | `changesets`/`none` — what consumes the file. |
|
|
38
|
+
| `release_notes.dir` `.filename` | string | ship | Where the per-feature note is authored, e.g. `.changeset/<feature_id>.md`. |
|
|
39
|
+
| `release_notes.anchor_package` | string | ship | Sole key in the note's front-matter; a lockstep/`fixed` group propagates the bump. |
|
|
40
|
+
| `release_notes.language` | string | ship | Language of the note's prose — usually `ui_language`. |
|
|
41
|
+
| `release_notes.forbid_levels` | list | ship | Bump levels project policy refuses, e.g. `[major]` while `0.x`. |
|
|
42
|
+
| `release_notes.empty_cmd` | string | ship | Escape hatch when no version should move; `""` if none. |
|
|
43
|
+
| `release_notes.ci_job` | string | ship | CI job that fails on a missing note — lets §5 name the red check. |
|
|
44
|
+
| `release_notes.guidance` | string | ship | Project policy the lead follows when picking the bump + writing the prose. |
|
|
36
45
|
| `commands.*` | string | all | Repo-wide install/dev/lint/format/typecheck/test + migrate. |
|
|
37
46
|
| `commands.test_quiet` `.lint_quiet` | string | review, audit, workflows | Repo-wide bridled variants — what the `/cohorte-review` pre-flight runs. Same fallback as the per-surface ones. |
|
|
38
47
|
| `rbac.enabled` | bool | brainstorm, review | Toggle RBAC personas + authz audit. |
|
|
@@ -412,6 +421,36 @@ this exact procedure so a surface is always defined the same way. To add surface
|
|
|
412
421
|
Removing/merging a surface is the reverse: drop the `surfaces[]` entry, delete its agent file, fold its
|
|
413
422
|
conventions. Never leave an agent file with no matching `surfaces[]` entry (orphan) or vice-versa.
|
|
414
423
|
|
|
424
|
+
## Release notes — the per-feature note the versioning tool consumes
|
|
425
|
+
|
|
426
|
+
Many repos gate merges on a **per-feature release note**: Changesets' `changeset` CI job fails any PR
|
|
427
|
+
that touches product code without a `.changeset/*.md`. That file is **not** something the release
|
|
428
|
+
agent can invent — picking the bump level is project policy (semver over an API vs. over a product,
|
|
429
|
+
a `0.x` rule forbidding `major`, what counts as user-visible), and the prose is outward-facing copy.
|
|
430
|
+
It is the **lead's** to write, exactly like the contract.
|
|
431
|
+
|
|
432
|
+
The failure mode this block exists to prevent is silent and reproducible: the requirement lives in the
|
|
433
|
+
project's `CLAUDE.md`, which the ship flow never reads, so `/cohorte-ship` completes, opens the PR,
|
|
434
|
+
moves the kanban card to **Shipped** — and CI goes red on a job nobody looked at. The feature reads as
|
|
435
|
+
shipped while being unmergeable. Encoding it in the profile is what makes the step survive a stateless
|
|
436
|
+
lead.
|
|
437
|
+
|
|
438
|
+
- **Authored before the dispatch**, next to the `status: shipped` flip (`/cohorte-ship` §2b), so the note
|
|
439
|
+
lands **inside** the release commit. Written after the fact it needs a second commit, and the PR is
|
|
440
|
+
already open and red.
|
|
441
|
+
- **One key in the front-matter** — `anchor_package`. Lockstep/`fixed` version groups propagate the bump
|
|
442
|
+
from it to every other workspace; listing more is how a repo ends up with a package bumped twice.
|
|
443
|
+
- **`forbid_levels`** encodes policy the tool itself may not enforce. The common one: while the product
|
|
444
|
+
is `0.y.z`, a `major` changeset makes Changesets jump to `1.0.0` with no human deciding it — so `0.x`
|
|
445
|
+
repos forbid `major` and declare a breaking change as `minor`.
|
|
446
|
+
- **Ask, don't guess, on an ambiguous bump.** Between two defensible levels (a refactor that also changes
|
|
447
|
+
what the user sees), state the reading and let the human pick — a wrong bump is a published version
|
|
448
|
+
number, not a fixable draft.
|
|
449
|
+
- **`empty_cmd`** covers the honest no-op: a PR that touches product code but must move no version.
|
|
450
|
+
Prefer it to skipping the step; the CI job wants a file, not a version.
|
|
451
|
+
|
|
452
|
+
`enabled: false` (or `tool: none`) ⇒ §2b is a no-op and nothing below applies.
|
|
453
|
+
|
|
415
454
|
## Reconcile — bringing generated files up to the current core
|
|
416
455
|
|
|
417
456
|
`/cohorte-init-pipeline` is **one-time per project**. Afterwards, `/cohorte-update-pipeline` runs this procedure so a
|
|
@@ -423,7 +462,10 @@ files automatically. It works because every generated artifact is a **determinis
|
|
|
423
462
|
`pipeline/PIPELINE.template.md`: every block/field the template has and the profile lacks is added
|
|
424
463
|
with its documented default (e.g. `surfaces[].model: sonnet`, `retrieval.provider: serena`).
|
|
425
464
|
**Ask only when a new field is a genuine human decision** (batch into ONE question set); never
|
|
426
|
-
change a value the profile already sets; never rewrite the prose sections.
|
|
465
|
+
change a value the profile already sets; never rewrite the prose sections. `release_notes` is one
|
|
466
|
+
such decision and must not be defaulted blind: detect a versioning tool / note-enforcing CI job per
|
|
467
|
+
`/cohorte-init-pipeline` Phase 1, and if there is one, ask Phase 2's release-notes question (anchor
|
|
468
|
+
package, language, bump policy, forbidden levels). No tool found ⇒ top up with `enabled: false`.
|
|
427
469
|
2. **Re-render agent frontmatter + body.** For each `surfaces[]` entry, re-render
|
|
428
470
|
`.claude/agents/<agent>.md` from the current `implementer.template.md` per §Rendering above. Safe by
|
|
429
471
|
doctrine: rendered agents are regenerable artifacts — hand-written rules belong in `PIPELINE.md`
|
|
@@ -536,16 +578,42 @@ Once shipped, `/cohorte-ship` appends the **PR number** to the card — `- [ ] <
|
|
|
536
578
|
The bare `#<num>` is what the dashboard renders as a clickable link to the GitHub PR, so `/cohorte-ship` always
|
|
537
579
|
writes it when a PR was actually created.
|
|
538
580
|
|
|
539
|
-
**Move a card (the core op).**
|
|
540
|
-
context (find, dedupe, sub-notes carried along, settings block preserved):
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
581
|
+
**Move a card (the core op).** One call — the script does resolution AND the move outside the
|
|
582
|
+
agent's context (find, dedupe, sub-notes carried along, settings block preserved):
|
|
583
|
+
|
|
584
|
+
```
|
|
585
|
+
<core>/pipeline/scripts/kanban-move.sh auto <id> <stage> [--pr <num>] [--title <title>]
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
`<core>` is `~/.claude` (global install) or `.claude` (bundled) — probe with `test -x`. It creates
|
|
589
|
+
the card in the target column when none exists, keeps the first and drops duplicates, and appends
|
|
590
|
+
` — PR #<num>` with `--pr`.
|
|
591
|
+
|
|
592
|
+
**`auto` is not a convenience, it is the contract.** It reads `name` from `PIPELINE.md`, then
|
|
593
|
+
`kanban.enabled` / `obsidian.vault_path` / `boards[name]` from `~/.claude/cohorte.config.yaml`
|
|
594
|
+
(override with `COHORTE_CONFIG`, or skip the profile with `--project <name>`), and it maps the
|
|
595
|
+
**stage key** (`ideas` · `brainstorm` · `spec` · `ready` · `building` · `review` · `fix` · `ship` ·
|
|
596
|
+
`shipped`) to that board's heading through `boards[name].columns` → `kanban.columns` → the built-in
|
|
597
|
+
default. An explicit `<board.md>` path and a literal heading both still work, for one-off and
|
|
598
|
+
non-pipeline moves.
|
|
599
|
+
|
|
600
|
+
**Never conclude "no board is configured" without running it.** The command that resolves nothing
|
|
601
|
+
prints `kanban: <reason>` — naming the missing link (no config file, `enabled: false`, no entry for
|
|
602
|
+
this project, vault unset, board file gone) — and exits **0**. A configured board that cannot be
|
|
603
|
+
moved is loud instead: exit 2 on usage, exit 3 on a missing board file or an unknown column. Both
|
|
604
|
+
readings are on stdout, so a caller reports which one it got. This exists because inference was the
|
|
605
|
+
actual failure mode: with only "no-op silently if no board" to go on, a fresh phase session (every
|
|
606
|
+
phase runs after a `/clear`) decided there was no board without ever opening the config, and cards
|
|
607
|
+
stopped moving mid-pipeline while every command still reported success.
|
|
608
|
+
|
|
609
|
+
**Fallback when the script is absent** (older core): do it by hand, but never read the whole board
|
|
610
|
+
into context — it grows with every feature ever tracked: `grep -n` for `#<id>` and the `## ` headings
|
|
611
|
+
to locate lines, then use offset-limited Reads + targeted Edits around the matches. Either way: one
|
|
612
|
+
card per `#<id>`, whole line moved tag-preserved, card created in the target column if missing.
|
|
613
|
+
|
|
614
|
+
**Tag before you move.** The join key is the `#<id>` tag, and an **Ideas** card a human typed by hand
|
|
615
|
+
does not have one. Moving it first finds nothing, creates a second card, and strands the original in
|
|
616
|
+
Ideas — so `/cohorte-brainstorm` appends the tag to the picked line before its first move.
|
|
549
617
|
|
|
550
618
|
**Stage → column**, used both by each pipeline command (to move its card live) and by backfill:
|
|
551
619
|
|
package/scripts/kanban-move.sh
CHANGED
|
@@ -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> <
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
23
|
-
--title)
|
|
24
|
-
|
|
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/test-gate.mjs
CHANGED
|
@@ -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");
|
|
@@ -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
|