cohorte 1.0.0 → 1.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 +41 -0
- package/README.md +32 -0
- package/core/agents/implementer.template.md +37 -14
- package/core/agents/release.md +1 -1
- package/core/agents/review.md +43 -17
- package/core/agents/smoke.md +61 -0
- package/core/commands/align-ds.md +1 -0
- package/core/commands/audit.md +9 -5
- package/core/commands/brainstorm.md +7 -4
- package/core/commands/build.md +37 -24
- package/core/commands/doctor.md +15 -1
- package/core/commands/fix.md +16 -10
- package/core/commands/refactor.md +17 -4
- package/core/commands/review.md +42 -21
- package/core/commands/ship.md +7 -3
- package/core/commands/smoke.md +32 -48
- package/core/commands/spec.md +33 -19
- package/core/commands/update-pipeline.md +1 -0
- package/core/templates/agent-handoff.md +7 -13
- package/core/templates/pr-body.md +1 -5
- package/core/templates/review-feedback.md +14 -18
- package/dashboard/dist/assets/{index-DN5OGW9g.css → index-Cj0SpgEY.css} +1 -1
- package/dashboard/dist/assets/index-YvkzH-yF.js +42 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/server/index.js +6 -0
- package/dashboard/server/metrics.js +94 -0
- package/install.ps1 +3 -1
- package/install.sh +3 -0
- package/package.json +1 -1
- package/profile/PIPELINE.template.md +4 -1
- package/profile/SCHEMA.md +31 -16
- package/scripts/kanban-move.sh +88 -0
- package/scripts/validate-core.mjs +118 -0
- package/core/hooks/__pycache__/gate.cpython-312.pyc +0 -0
- package/dashboard/dist/assets/index-CoBuEdy-.js +0 -42
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Read a project's `.claude/pipeline-metrics.jsonl` (one line per phase batch, appended by
|
|
3
|
+
// /build, /review, /fix and /smoke) and aggregate it per feature: wall-clock per phase, fix
|
|
4
|
+
// rounds, and per-surface results. Dependency-free; a missing file is simply "no data yet".
|
|
5
|
+
//
|
|
6
|
+
// Two line formats coexist in the file:
|
|
7
|
+
// new {"ts","feature","phase","seconds",surfaces:{"<key>":"ok|error"|"<verdict>:<count>"}}
|
|
8
|
+
// legacy {"ts","feature","phase","surface","seconds","result"} — one line PER surface;
|
|
9
|
+
// legacy lines are grouped by ts+feature+phase into one batch (wall-clock = max).
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const PHASES = ['build', 'review', 'fix', 'smoke'];
|
|
15
|
+
|
|
16
|
+
// Parse the raw JSONL into normalized batches ({ts, feature, phase, seconds, surfaces}),
|
|
17
|
+
// skipping malformed lines. Legacy per-surface lines are folded into their batch.
|
|
18
|
+
function parseBatches(raw) {
|
|
19
|
+
const batches = [];
|
|
20
|
+
const legacy = new Map(); // "ts|feature|phase" → batch (legacy lines share the key)
|
|
21
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
22
|
+
if (!line.trim()) continue;
|
|
23
|
+
let e;
|
|
24
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
25
|
+
if (!e || typeof e !== 'object' || !e.feature || !e.phase) continue;
|
|
26
|
+
const seconds = Number(e.seconds) || 0;
|
|
27
|
+
if (e.surfaces && typeof e.surfaces === 'object') {
|
|
28
|
+
batches.push({ ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds, surfaces: e.surfaces });
|
|
29
|
+
} else if (e.surface) {
|
|
30
|
+
const key = `${e.ts}|${e.feature}|${e.phase}`;
|
|
31
|
+
let b = legacy.get(key);
|
|
32
|
+
if (!b) {
|
|
33
|
+
b = { ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds: 0, surfaces: {} };
|
|
34
|
+
legacy.set(key, b);
|
|
35
|
+
batches.push(b);
|
|
36
|
+
}
|
|
37
|
+
b.surfaces[String(e.surface)] = e.result == null ? '' : String(e.result);
|
|
38
|
+
b.seconds = Math.max(b.seconds, seconds); // batch wall-clock = the slowest surface
|
|
39
|
+
}
|
|
40
|
+
// lines with neither `surfaces` nor `surface` are malformed → skipped
|
|
41
|
+
}
|
|
42
|
+
return batches;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A surface result string counts as a failure when it is "error" or a BLOCK/REVISE verdict
|
|
46
|
+
// (verdict lines look like "REVISE:2"), or a non-ok/pass word.
|
|
47
|
+
function isFailure(result) {
|
|
48
|
+
const head = String(result).split(':')[0].trim().toLowerCase();
|
|
49
|
+
return head !== '' && head !== 'ok' && head !== 'ship' && head !== 'pass';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Aggregate batches per feature (newest feature first).
|
|
53
|
+
function aggregate(batches) {
|
|
54
|
+
const byFeature = new Map();
|
|
55
|
+
for (const b of batches) {
|
|
56
|
+
let f = byFeature.get(b.feature);
|
|
57
|
+
if (!f) {
|
|
58
|
+
f = {
|
|
59
|
+
feature: b.feature,
|
|
60
|
+
firstTs: b.ts, lastTs: b.ts,
|
|
61
|
+
totalSeconds: 0,
|
|
62
|
+
fixRounds: 0,
|
|
63
|
+
phases: {}, // phase → { seconds, rounds }
|
|
64
|
+
surfaces: {}, // surface → { phase → latest result }, plus failure count
|
|
65
|
+
};
|
|
66
|
+
byFeature.set(b.feature, f);
|
|
67
|
+
}
|
|
68
|
+
if (b.ts && (!f.firstTs || b.ts < f.firstTs)) f.firstTs = b.ts;
|
|
69
|
+
if (b.ts && b.ts > f.lastTs) f.lastTs = b.ts;
|
|
70
|
+
f.totalSeconds += b.seconds;
|
|
71
|
+
const ph = f.phases[b.phase] || (f.phases[b.phase] = { seconds: 0, rounds: 0 });
|
|
72
|
+
ph.seconds += b.seconds;
|
|
73
|
+
ph.rounds += 1;
|
|
74
|
+
if (b.phase === 'fix') f.fixRounds += 1;
|
|
75
|
+
for (const [key, result] of Object.entries(b.surfaces)) {
|
|
76
|
+
const s = f.surfaces[key] || (f.surfaces[key] = { results: {}, failures: 0 });
|
|
77
|
+
s.results[b.phase] = result; // batches are appended in order → last write is the latest
|
|
78
|
+
if (isFailure(result)) s.failures += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...byFeature.values()].sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function metrics({ projectRoot }) {
|
|
85
|
+
const file = path.join(projectRoot, '.claude', 'pipeline-metrics.jsonl');
|
|
86
|
+
let raw;
|
|
87
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
88
|
+
catch { return { present: false, phases: PHASES, features: [], batches: 0 }; }
|
|
89
|
+
|
|
90
|
+
const batches = parseBatches(raw);
|
|
91
|
+
return { present: true, phases: PHASES, features: aggregate(batches), batches: batches.length };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { metrics };
|
package/install.ps1
CHANGED
|
@@ -143,6 +143,7 @@ try {
|
|
|
143
143
|
Copy-Item (Join-Path $src 'profile\SCHEMA.md') (Join-Path $dest 'pipeline') -Force
|
|
144
144
|
Copy-Item (Join-Path $src 'profile\cohorte.config.template.yaml') (Join-Path $dest 'pipeline') -Force
|
|
145
145
|
Copy-Item (Join-Path $src 'scripts\*.template') (Join-Path $dest 'pipeline\scripts') -Force
|
|
146
|
+
Copy-Item (Join-Path $src 'scripts\kanban-move.sh') (Join-Path $dest 'pipeline\scripts') -Force
|
|
146
147
|
Copy-Item (Join-Path $src 'core\agents\implementer.template.md') (Join-Path $dest 'pipeline') -Force
|
|
147
148
|
if (Test-Path (Join-Path $src 'CHANGELOG.md')) { Copy-Item (Join-Path $src 'CHANGELOG.md') (Join-Path $dest 'pipeline') -Force }
|
|
148
149
|
[System.IO.File]::WriteAllText((Join-Path $dest 'pipeline\VERSION'), "$ver`n", [System.Text.UTF8Encoding]::new($false))
|
|
@@ -179,7 +180,8 @@ try {
|
|
|
179
180
|
function Copy-FixedAgents {
|
|
180
181
|
New-Item -ItemType Directory -Force -Path (Join-Path $dest 'agents') | Out-Null
|
|
181
182
|
Copy-Item (Join-Path $src 'core\agents\review.md'),
|
|
182
|
-
(Join-Path $src 'core\agents\release.md')
|
|
183
|
+
(Join-Path $src 'core\agents\release.md'),
|
|
184
|
+
(Join-Path $src 'core\agents\smoke.md') (Join-Path $dest 'agents') -Force
|
|
183
185
|
# 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
|
|
184
186
|
# copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
|
|
185
187
|
Remove-Item -LiteralPath (Join-Path $dest 'agents\questionnaire-researcher.md') -Force -ErrorAction SilentlyContinue
|
package/install.sh
CHANGED
|
@@ -85,6 +85,8 @@ copy_core() {
|
|
|
85
85
|
cp "$src/profile/SCHEMA.md" "$dest/pipeline/"
|
|
86
86
|
cp "$src/profile/cohorte.config.template.yaml" "$dest/pipeline/"
|
|
87
87
|
cp "$src"/scripts/*.template "$dest/pipeline/scripts/"
|
|
88
|
+
cp "$src/scripts/kanban-move.sh" "$dest/pipeline/scripts/"
|
|
89
|
+
chmod +x "$dest/pipeline/scripts/kanban-move.sh" 2>/dev/null || true
|
|
88
90
|
cp "$src/core/agents/implementer.template.md" "$dest/pipeline/"
|
|
89
91
|
[ -f "$src/CHANGELOG.md" ] && cp "$src/CHANGELOG.md" "$dest/pipeline/"
|
|
90
92
|
printf '%s\n' "$ver" > "$dest/pipeline/VERSION"
|
|
@@ -125,6 +127,7 @@ PY
|
|
|
125
127
|
copy_fixed_agents() {
|
|
126
128
|
mkdir -p "$dest/agents"
|
|
127
129
|
cp "$src/core/agents/review.md" "$src/core/agents/release.md" \
|
|
130
|
+
"$src/core/agents/smoke.md" \
|
|
128
131
|
"$dest/agents/"
|
|
129
132
|
# 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
|
|
130
133
|
# copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cohorte",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /init-pipeline, and it adapts to your project's stack.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cohorte": "bin/cli.js"
|
|
@@ -69,7 +69,10 @@ surfaces:
|
|
|
69
69
|
label: frontend (React/TanStack)
|
|
70
70
|
agent: frontend
|
|
71
71
|
tools: [Read, Write, Edit, Bash, Grep, Glob, DesignSync, mcp__serena]
|
|
72
|
-
model:
|
|
72
|
+
model: sonnet # default even for design surfaces — designs + contract are
|
|
73
|
+
# frozen inputs the agent applies; `inherit` (bills at the
|
|
74
|
+
# lead's tier, often Opus) ONLY if this surface must make
|
|
75
|
+
# novel design decisions
|
|
73
76
|
test_cmd: pnpm --filter web test
|
|
74
77
|
lint_cmd: pnpm --filter web lint
|
|
75
78
|
format_cmd: pnpm --filter web format
|
package/profile/SCHEMA.md
CHANGED
|
@@ -155,16 +155,16 @@ the frozen contract as the only cross-surface channel**. So specialization means
|
|
|
155
155
|
|
|
156
156
|
Coarse first, specialize on evidence: start with one `frontend` / `backend` surface each; split only a
|
|
157
157
|
surface that's proven slow and cleanly separable. The evidence lives in
|
|
158
|
-
`.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per
|
|
159
|
-
(`ts`/`feature`/`phase`/`
|
|
160
|
-
`/smoke`. Read it before proposing a split: split the surface that actually dominates wall-clock,
|
|
158
|
+
`.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
|
|
159
|
+
(`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`, `/fix`
|
|
160
|
+
and `/smoke`. Read it before proposing a split: split the surface that actually dominates wall-clock,
|
|
161
161
|
not the one that feels big.
|
|
162
162
|
|
|
163
163
|
## Measuring cost — what's slow vs what's expensive
|
|
164
164
|
|
|
165
|
-
`pipeline-metrics.jsonl` records **wall-clock seconds** per
|
|
166
|
-
SLOW. It deliberately does NOT record tokens: the lead can't reliably read a subagent's token count
|
|
167
|
-
it. For what's EXPENSIVE, use Claude Code's own accounting:
|
|
165
|
+
`pipeline-metrics.jsonl` records **wall-clock seconds** per phase batch (§Specialization) — it tells you
|
|
166
|
+
what's SLOW. It deliberately does NOT record tokens: the lead can't reliably read a subagent's token count
|
|
167
|
+
to log it. For what's EXPENSIVE, use Claude Code's own accounting:
|
|
168
168
|
|
|
169
169
|
- **`/cost`** (built-in, zero setup) — reports per-**subagent** and per-**slash-command** share of your usage
|
|
170
170
|
over the last 24 h / 7 d (e.g. _"Top subagents: frontend 7 %, backend 4 % · Top skills: /build 1 %,
|
|
@@ -176,6 +176,14 @@ it. For what's EXPENSIVE, use Claude Code's own accounting:
|
|
|
176
176
|
`session.id` + model + type (input/output/cacheRead). Subagent tokens roll into the session total;
|
|
177
177
|
per-subagent attribution needs traces (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`, beta).
|
|
178
178
|
|
|
179
|
+
**Lead context discipline — the silent bill.** The lead session's conversation history is re-sent as
|
|
180
|
+
input on EVERY turn; a session that spans spec→build→smoke→review→fix without clearing re-pays the
|
|
181
|
+
accumulated spec walk-through, handoffs, and reports on each turn. The pipeline is built so this is
|
|
182
|
+
never necessary: every phase handoff (spec, contract, diff, staged reports) lives on disk, so `/clear`
|
|
183
|
+
at each phase boundary is always safe — each command's closing line recommends it. Corollaries the
|
|
184
|
+
commands enforce: never paste a diff into a dispatch (agents compute their own, scoped); never echo a
|
|
185
|
+
staged report or design brief into chat; redirect bulky command output to a file and grep it.
|
|
186
|
+
|
|
179
187
|
## Rendering / reconciling a surface agent (shared procedure)
|
|
180
188
|
|
|
181
189
|
Both `/init-pipeline` (initial render) and `/build` (auto-reconcile when a spec needs a new agent) use
|
|
@@ -196,16 +204,18 @@ this exact procedure so a surface is always defined the same way. To add surface
|
|
|
196
204
|
ones empty unless `uses_design`). For a `uses_design` surface, fill them **link-based** (never with a
|
|
197
205
|
stored `design_project` id — that goes stale on a DS rebuild):
|
|
198
206
|
- `<SURFACE_DESIGN_INPUT>` — a 4th input bullet: _"The **feature design** — the pages this feature
|
|
199
|
-
touches, listed in
|
|
200
|
-
(`https://claude.ai/design/p/<projectId>?file=<file>`)
|
|
207
|
+
touches, listed in your dispatch's design slot as full links
|
|
208
|
+
(`https://claude.ai/design/p/<projectId>?file=<file>`); a slot saying `none` means a fix loop with
|
|
209
|
+
no visual work — skip DesignSync entirely. For each link, extract the `<projectId>` (the
|
|
201
210
|
`/p/…` segment) and `<file>` (the `?file=` query) from the URL and read it read-only via `DesignSync
|
|
202
211
|
get_file(<projectId>, <file>)`; `list_files(<projectId>)` to catch linked pages (shared nav/modals)
|
|
203
212
|
this feature also changes. The link is self-contained — no stored project id. Build with the code UI
|
|
204
213
|
kit (the `design_system_project`'s materialization: `@/components/ui/*` + tokens); read a primitive
|
|
205
214
|
via `get_file` only if it's missing/stale in code. Mobile-first."_
|
|
206
|
-
- `<SURFACE_TDD_STEP1>` — _"**Pull the feature design first
|
|
207
|
-
<file>)` for each
|
|
208
|
-
(`@/components/ui/*`, `cn()` + CVA), mobile-first — never ad-hoc
|
|
215
|
+
- `<SURFACE_TDD_STEP1>` — _"**Pull the feature design first** (skip if your dispatch's design slot
|
|
216
|
+
says `none`): `DesignSync get_file(<projectId>, <file>)` for each link in the slot and translate
|
|
217
|
+
each into the code design system (`@/components/ui/*`, `cn()` + CVA), mobile-first — never ad-hoc
|
|
218
|
+
CSS. Then:"_
|
|
209
219
|
3. **Add a §Conventions + §Testing stanza** for `S` in `PIPELINE.md` (mirror a sibling surface; keep it
|
|
210
220
|
rule-shaped). If `S` is a shared-code surface, its convention is "single owner of shared X; slices
|
|
211
221
|
consume, never redefine."
|
|
@@ -270,11 +280,16 @@ Once shipped, `/ship` appends the **PR number** to the card — `- [ ] <title> #
|
|
|
270
280
|
The bare `#<num>` is what the dashboard renders as a clickable link to the GitHub PR, so `/ship` always
|
|
271
281
|
writes it when a PR was actually created.
|
|
272
282
|
|
|
273
|
-
**Move a card (the core op).**
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
283
|
+
**Move a card (the core op).** Use the shipped script — it does the whole op outside the agent's
|
|
284
|
+
context (find, dedupe, sub-notes carried along, settings block preserved):
|
|
285
|
+
`<core>/pipeline/scripts/kanban-move.sh <board.md> <id> <column> [--pr <num>] [--title <title>]`
|
|
286
|
+
where `<core>` is `~/.claude` (global install) or `.claude` (bundled) — probe with `test -x`. It
|
|
287
|
+
creates the card in the target column when none exists, keeps the first and drops duplicates, and
|
|
288
|
+
appends ` — PR #<num>` with `--pr`. **Fallback when the script is absent** (older core): do it by
|
|
289
|
+
hand, but never read the whole board into context — it grows with every feature ever tracked:
|
|
290
|
+
`grep -n` for `#<id>` and the `## ` headings to locate lines, then use offset-limited Reads +
|
|
291
|
+
targeted Edits around the matches. Either way: one card per `#<id>`, whole line moved tag-preserved,
|
|
292
|
+
card created in the target column if missing.
|
|
278
293
|
|
|
279
294
|
**Stage → column**, used both by each pipeline command (to move its card live) and by backfill:
|
|
280
295
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# kanban-move.sh — move a feature card on an Obsidian Kanban board without
|
|
3
|
+
# loading the board into an agent's context (SCHEMA.md §Kanban "Move a card").
|
|
4
|
+
#
|
|
5
|
+
# kanban-move.sh <board.md> <feature-id> <target-column> [--pr <num>] [--title <title>]
|
|
6
|
+
#
|
|
7
|
+
# Behavior (mirrors the documented manual op):
|
|
8
|
+
# - finds the list item tagged #<feature-id> (plus its indented sub-notes),
|
|
9
|
+
# removes it from its current column, appends it under `## <target-column>`
|
|
10
|
+
# - no card yet ⇒ creates `- [ ] <title|id> #<id>` in the target column
|
|
11
|
+
# - duplicates ⇒ keeps the first, drops the rest
|
|
12
|
+
# - --pr N ⇒ ensures the card line ends with ` — PR #N`
|
|
13
|
+
# - never touches the front-matter or the trailing `%% kanban:settings %%` block
|
|
14
|
+
# Exit codes: 0 ok · 2 usage · 3 board/column not found.
|
|
15
|
+
set -eu
|
|
16
|
+
|
|
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=""
|
|
20
|
+
while [ $# -gt 0 ]; do
|
|
21
|
+
case "$1" in
|
|
22
|
+
--pr) pr="$2"; shift 2 ;;
|
|
23
|
+
--title) title="$2"; shift 2 ;;
|
|
24
|
+
*) echo "error: unknown flag $1" >&2; exit 2 ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
[ -f "$board" ] || { echo "error: board not found: $board" >&2; exit 3; }
|
|
28
|
+
|
|
29
|
+
tmp="${board}.kanban-move.$$"
|
|
30
|
+
ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
|
|
31
|
+
BEGIN {
|
|
32
|
+
id = ENVIRON["ID"]; col = tolower(ENVIRON["COL"])
|
|
33
|
+
pr = ENVIRON["PR"]; title = ENVIRON["TITLE"]
|
|
34
|
+
n = 0; card = ""; incard = 0; found = 0; colseen = 0
|
|
35
|
+
}
|
|
36
|
+
{ lines[++n] = $0 }
|
|
37
|
+
END {
|
|
38
|
+
# pass 1: extract the FIRST card block tagged #id; mark every block for removal
|
|
39
|
+
for (i = 1; i <= n; i++) {
|
|
40
|
+
l = lines[i]
|
|
41
|
+
if (l ~ /^- \[.\] / && index(l, "#" id) > 0) {
|
|
42
|
+
# word-boundary check: char after the tag must not extend the id
|
|
43
|
+
rest = substr(l, index(l, "#" id) + length(id) + 1, 1)
|
|
44
|
+
if (rest != "" && rest ~ /[A-Za-z0-9_-]/) continue
|
|
45
|
+
del[i] = 1
|
|
46
|
+
if (!found) { found = 1; card = l }
|
|
47
|
+
for (j = i + 1; j <= n && lines[j] ~ /^[ \t]/; j++) {
|
|
48
|
+
del[j] = 1
|
|
49
|
+
if (found && card == lines[i]) sub_notes = sub_notes lines[j] "\n"
|
|
50
|
+
}
|
|
51
|
+
i = j - 1
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!found) card = "- [ ] " title " #" id
|
|
55
|
+
if (pr != "" && index(card, "PR #") == 0) card = card " — PR #" pr
|
|
56
|
+
|
|
57
|
+
# pass 2: emit, skipping removed blocks; append the card at the end of the
|
|
58
|
+
# target column (before the next ## heading / settings block / EOF)
|
|
59
|
+
intarget = 0; placed = 0
|
|
60
|
+
for (i = 1; i <= n; i++) {
|
|
61
|
+
if (del[i]) continue
|
|
62
|
+
l = lines[i]
|
|
63
|
+
atheading = (l ~ /^## /)
|
|
64
|
+
atsettings = (l ~ /^%% kanban:settings/)
|
|
65
|
+
if (intarget && (atheading || atsettings)) {
|
|
66
|
+
# back up over trailing blank lines already printed is not possible;
|
|
67
|
+
# instead print card just before this boundary
|
|
68
|
+
print card
|
|
69
|
+
if (sub_notes != "") printf "%s", sub_notes
|
|
70
|
+
print ""
|
|
71
|
+
intarget = 0; placed = 1
|
|
72
|
+
}
|
|
73
|
+
if (atheading) {
|
|
74
|
+
h = tolower(l); sub(/^## +/, "", h); gsub(/ +$/, "", h)
|
|
75
|
+
if (h == col) { intarget = 1; colseen = 1 }
|
|
76
|
+
}
|
|
77
|
+
print l
|
|
78
|
+
}
|
|
79
|
+
if (intarget && !placed) {
|
|
80
|
+
print card
|
|
81
|
+
if (sub_notes != "") printf "%s", sub_notes
|
|
82
|
+
placed = 1
|
|
83
|
+
}
|
|
84
|
+
if (!colseen) exit 3
|
|
85
|
+
}
|
|
86
|
+
' "$board" > "$tmp" || { rc=$?; rm -f "$tmp"; [ "$rc" = 3 ] && echo "error: column \"$col\" not found in $board" >&2; exit "$rc"; }
|
|
87
|
+
mv "$tmp" "$board"
|
|
88
|
+
echo "moved #$id -> $col${pr:+ (PR #$pr)}"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Validates the structural invariants of the cohorte core — the things a
|
|
3
|
+
// prose refactor can silently break. Run by CI on every push/PR; run it
|
|
4
|
+
// locally with `node scripts/validate-core.mjs`.
|
|
5
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const root = new URL("..", import.meta.url).pathname;
|
|
9
|
+
const errors = [];
|
|
10
|
+
const fail = (file, msg) => errors.push(`${file}: ${msg}`);
|
|
11
|
+
|
|
12
|
+
const read = (p) => readFileSync(join(root, p), "utf8");
|
|
13
|
+
const frontmatter = (text) => {
|
|
14
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
15
|
+
return m ? m[1] : null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ── commands ────────────────────────────────────────────────────────────────
|
|
19
|
+
// Mechanical commands must pin model: sonnet (otherwise the lead's
|
|
20
|
+
// orchestration turn silently bills at the session model — Opus/Fable).
|
|
21
|
+
// Interactive commands must stay unpinned (they inherit on purpose).
|
|
22
|
+
const PINNED = ["build", "review", "fix", "smoke", "ship", "audit",
|
|
23
|
+
"refactor", "doctor", "align-ds", "update-pipeline"];
|
|
24
|
+
const UNPINNED = ["brainstorm", "spec", "init-pipeline"];
|
|
25
|
+
|
|
26
|
+
for (const f of readdirSync(join(root, "core/commands"))) {
|
|
27
|
+
const path = `core/commands/${f}`;
|
|
28
|
+
const fm = frontmatter(read(path));
|
|
29
|
+
if (!fm) { fail(path, "missing or malformed YAML frontmatter"); continue; }
|
|
30
|
+
if (!/^description:\s*\S/m.test(fm)) fail(path, "frontmatter lacks a description");
|
|
31
|
+
const name = f.replace(/\.md$/, "");
|
|
32
|
+
const pinned = /^model:\s*sonnet\s*$/m.test(fm);
|
|
33
|
+
if (PINNED.includes(name) && !pinned)
|
|
34
|
+
fail(path, "mechanical command must carry `model: sonnet` in frontmatter");
|
|
35
|
+
if (UNPINNED.includes(name) && /^model:/m.test(fm))
|
|
36
|
+
fail(path, "interactive command must NOT pin a model (inherits the session)");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── fixed agents ────────────────────────────────────────────────────────────
|
|
40
|
+
// Every non-template agent needs name/tools/model, and must be shipped by
|
|
41
|
+
// both installers (a new agent that install.sh doesn't copy never reaches
|
|
42
|
+
// a global install — the exact bug that motivated this check).
|
|
43
|
+
const AGENT_MODEL = { review: "sonnet", release: "haiku", smoke: "sonnet" };
|
|
44
|
+
const installSh = read("install.sh");
|
|
45
|
+
const installPs1 = read("install.ps1");
|
|
46
|
+
|
|
47
|
+
for (const f of readdirSync(join(root, "core/agents"))) {
|
|
48
|
+
const path = `core/agents/${f}`;
|
|
49
|
+
const text = read(path);
|
|
50
|
+
const fm = frontmatter(text);
|
|
51
|
+
if (!fm) { fail(path, "missing or malformed YAML frontmatter"); continue; }
|
|
52
|
+
if (f === "implementer.template.md") {
|
|
53
|
+
for (const ph of ["<SURFACE_AGENT>", "<SURFACE_LABEL>", "<SURFACE_PATH>",
|
|
54
|
+
"<SURFACE_TOOLS>", "<SURFACE_MODEL>", "<PROJECT_NAME>",
|
|
55
|
+
"<SURFACE_EXTRA_NEVER>", "<SURFACE_DESIGN_INPUT>", "<SURFACE_TDD_STEP1>"])
|
|
56
|
+
if (!text.includes(ph)) fail(path, `render placeholder ${ph} disappeared`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const name = f.replace(/\.md$/, "");
|
|
60
|
+
if (!/^name:\s*\S/m.test(fm)) fail(path, "frontmatter lacks name");
|
|
61
|
+
if (!/^tools:\s*\S/m.test(fm)) fail(path, "frontmatter lacks tools");
|
|
62
|
+
const want = AGENT_MODEL[name];
|
|
63
|
+
if (want && !new RegExp(`^model:\\s*${want}\\s*$`, "m").test(fm))
|
|
64
|
+
fail(path, `frontmatter must pin \`model: ${want}\``);
|
|
65
|
+
if (text.includes("<SURFACE_"))
|
|
66
|
+
fail(path, "unrendered <SURFACE_*> placeholder in a non-template agent");
|
|
67
|
+
if (!installSh.includes(`core/agents/${f}`))
|
|
68
|
+
fail("install.sh", `does not copy core/agents/${f} (copy_fixed_agents)`);
|
|
69
|
+
if (!installPs1.includes(`core\\agents\\${f}`))
|
|
70
|
+
fail("install.ps1", `does not copy core\\agents\\${f} (Copy-FixedAgents)`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── cross-references ────────────────────────────────────────────────────────
|
|
74
|
+
// Any `.claude/templates/<x>.md` referenced by a command or agent must exist
|
|
75
|
+
// in core/templates (they resolve to the installed copy of exactly that file).
|
|
76
|
+
const allDocs = [];
|
|
77
|
+
for (const dir of ["core/commands", "core/agents", "core/templates"])
|
|
78
|
+
for (const f of readdirSync(join(root, dir)))
|
|
79
|
+
if (f.endsWith(".md")) allDocs.push(`${dir}/${f}`);
|
|
80
|
+
|
|
81
|
+
for (const path of allDocs) {
|
|
82
|
+
const text = read(path);
|
|
83
|
+
for (const m of text.matchAll(/\.claude\/templates\/([a-z0-9.-]+\.md)/g)) {
|
|
84
|
+
if (!existsSync(join(root, "core/templates", m[1])))
|
|
85
|
+
fail(path, `references .claude/templates/${m[1]} which is not in core/templates/`);
|
|
86
|
+
}
|
|
87
|
+
for (const m of text.matchAll(/subagent_type:\s*(?:`|)([a-z-]+)(?:`|)/g)) {
|
|
88
|
+
const t = m[1];
|
|
89
|
+
if (["review", "release", "smoke"].includes(t)) continue;
|
|
90
|
+
if (t.startsWith("<")) continue; // <surface.agent> placeholder
|
|
91
|
+
if (!existsSync(join(root, "core/agents", `${t}.md`)))
|
|
92
|
+
fail(path, `dispatches subagent_type ${t} with no core/agents/${t}.md`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── profile ─────────────────────────────────────────────────────────────────
|
|
97
|
+
for (const p of ["profile/PIPELINE.template.md", "profile/SCHEMA.md",
|
|
98
|
+
"profile/cohorte.config.template.yaml"])
|
|
99
|
+
if (!existsSync(join(root, p))) fail(p, "missing");
|
|
100
|
+
|
|
101
|
+
const tpl = read("profile/PIPELINE.template.md");
|
|
102
|
+
if (/^\s*model:\s*inherit\b/m.test(tpl))
|
|
103
|
+
fail("profile/PIPELINE.template.md",
|
|
104
|
+
"a surfaces[] example pins `model: inherit` — examples must default to sonnet " +
|
|
105
|
+
"(inherit bills at the lead session's model)");
|
|
106
|
+
|
|
107
|
+
// ── init-pipeline router steps ──────────────────────────────────────────────
|
|
108
|
+
const steps = join(root, "core/templates/steps/init-pipeline");
|
|
109
|
+
if (!existsSync(steps) || readdirSync(steps).length === 0)
|
|
110
|
+
fail("core/templates/steps/init-pipeline", "router step files missing/empty");
|
|
111
|
+
|
|
112
|
+
// ── report ──────────────────────────────────────────────────────────────────
|
|
113
|
+
if (errors.length) {
|
|
114
|
+
console.error(`validate-core: ${errors.length} error(s)\n`);
|
|
115
|
+
for (const e of errors) console.error(" ✗ " + e);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
console.log("validate-core: OK");
|
|
Binary file
|