agentic-team 6.1.0 → 6.3.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.
Files changed (45) hide show
  1. package/README.md +8 -3
  2. package/bin/build-template.js +2 -2
  3. package/bin/index.js +234 -39
  4. package/bin/platforms.js +510 -0
  5. package/package.json +4 -4
  6. package/template/.claude-plugin/plugin.json +2 -2
  7. package/template/.orchestra/README.md +41 -51
  8. package/template/.orchestra/config.yml +16 -0
  9. package/template/.orchestra/roles/product-manager.md +3 -2
  10. package/template/.orchestra/scripts/compact-diff.awk +1 -1
  11. package/template/.orchestra/scripts/context.mjs +373 -0
  12. package/template/.orchestra/stacks/_repo/setup.sh +7 -3
  13. package/template/.orchestra/stacks/mobile-expo/setup.sh +1 -1
  14. package/template/CLAUDE.md +1 -1
  15. package/template/agents/lead.md +171 -116
  16. package/template/agents/milestone-reviewer.md +5 -3
  17. package/template/agents/product-manager.md +3 -2
  18. package/template/agents/re-reviewer.md +25 -0
  19. package/template/agents/reviewer.md +7 -5
  20. package/template/agents/scaffolder.md +26 -0
  21. package/template/commands/help.md +1 -0
  22. package/template/commands/hotfix.md +3 -1
  23. package/template/commands/rewind.md +10 -8
  24. package/template/commands/setup.md +2 -1
  25. package/template/commands/start.md +2 -2
  26. package/template/commands/status.md +1 -1
  27. package/template/commands/sync.md +5 -2
  28. package/template/commands/verifier.md +2 -2
  29. package/template/hooks/audit-context.sh +15 -2
  30. package/template/hooks/enforce-role.sh +21 -3
  31. package/template/hooks/protect-system-files.sh +2 -2
  32. package/template/hooks/review-rounds.sh +1 -1
  33. package/template/rules/code-standards.orchestra.md +3 -2
  34. package/template/rules/design-parity.orchestra.md +12 -1
  35. package/template/rules/observability.orchestra.md +3 -0
  36. package/template/rules/performance.orchestra.md +5 -1
  37. package/template/rules/phase-limits.orchestra.md +1 -1
  38. package/template/rules/pull-request.orchestra.md +7 -2
  39. package/template/rules/role-boundaries.orchestra.md +24 -9
  40. package/template/rules/security.orchestra.md +1 -1
  41. package/template/rules/stuck-detection.orchestra.md +1 -1
  42. package/template/rules/testing-standards.orchestra.md +10 -5
  43. package/template/rules/tracker-authority.orchestra.md +9 -1
  44. package/template/skills/push-notifications/SKILL.md +1 -1
  45. package/template/skills/testing/SKILL.md +2 -1
@@ -13,8 +13,8 @@ Terminal 1 (PM): Terminal 2 (Lead):
13
13
  ├─ Create milestones ├─ 🏗️ design phase → RFC
14
14
  ├─ Groom phases ├─ 🚦 User approves RFC
15
15
  ├─ Always available ├─ ⚙️ delegate to sub-agent → phase by phase
16
- │ ├─ 🔍 reviewer → review commits
17
- │ (can plan M2 while M1 runs) ├─ git push → milestone done
16
+ │ ├─ 🔍 reviewer → reviews each PR
17
+ │ (can plan M2 while M1 runs) ├─ PR per task → human merges
18
18
  │ └─ Stop (inline) or next milestone (agent)
19
19
  ```
20
20
 
@@ -37,7 +37,9 @@ Terminal 1 (PM): Terminal 2 (Lead):
37
37
  │ ├── milestone.md # Summary, acceptance criteria, status
38
38
  │ ├── grooming.md # Discussion, scope, decisions
39
39
  │ ├── rfc.md # Technical design (lead fills during design phase)
40
- │ ├── context.md # Running log (lead maintains for resume)
40
+ │ ├── context.md # State: status, phases, codebase map (lead, for resume)
41
+ │ ├── decisions.md # Decisions, open until closed (lead)
42
+ │ ├── journal.md # What was tried and why it lost (lead, append-only)
41
43
  │ └── phases/ # Sequential units of work
42
44
  │ ├── phase-1.md
43
45
  │ └── ...
@@ -76,9 +78,9 @@ PM discusses feature with user
76
78
  → Lead runs design phase: writes RFC + validates grooming
77
79
  → [USER APPROVAL GATE: RFC + grooming validation → Implementation]
78
80
  → Lead delegates phases to sub-agents (sequential, each → commit)
79
- → Lead calls reviewer agent (reviews unpushed commits)
80
- → FIX cycle if changes-requested (re-review when the fix is large)
81
- → Lead pushes, PM verifies acceptance criteria, closes milestone
81
+ → Per task: push, open its pull request, reviewer reviews it there
82
+ → FIX cycle if changes-requested — a new engineer, at most stuck_retry_limit rounds
83
+ → The human squash-merges (lead does under --auto); PM verifies, closes milestone
82
84
 
83
85
  Hotfix (production bugs):
84
86
  /orchestra:hotfix {description}
@@ -98,8 +100,8 @@ PM sets `Complexity` on milestone (pipeline) and `complexity` on each phase (mod
98
100
  | Complexity | Pipeline | Use when |
99
101
  |------------|----------|----------|
100
102
  | `trivial`, `quick` | Phases → Commit → Push — no design, no review | Version bumps, config, a single-file fix |
101
- | `standard` | Phases → Review → Push | Typical features, clear requirements |
102
- | `complex` | Design → Phases → Review → Push | New subsystems, unfamiliar territory |
103
+ | `standard` | Phases → PR + Review | Typical features, clear requirements |
104
+ | `complex` | Design → Phases → PR + Review | New subsystems, unfamiliar territory |
103
105
 
104
106
  Default: config.yml `pipeline.default_pipeline`. A phase's `effort` is a
105
107
  separate axis — how hard its sub-agent thinks — mapped through
@@ -133,8 +135,8 @@ Lead (lean dispatcher)
133
135
  |--------|---------|
134
136
  | `planning` | PM is defining scope, grooming phases |
135
137
  | `in-progress` | Phases are being executed |
136
- | `review` | All phases done, reviewer is checking |
137
- | `done` | Pushed to origin, acceptance criteria verified |
138
+ | `review` | Pull requests open, reviewer is checking |
139
+ | `done` | Every task's PR merged, acceptance criteria verified |
138
140
 
139
141
  ### Phase Statuses
140
142
 
@@ -153,7 +155,7 @@ Phases always execute in this order:
153
155
 
154
156
  1. **Design** (RFC) — if technical design is needed
155
157
  2. **Implementation phases** — lead derives sub-agent identity from phase scope
156
- 3. **Reviewer** — reviews all unpushed commits
158
+ 3. **Reviewer** — reviews each task's pull request, inline on its lines
157
159
 
158
160
  Phases run in order: phase-1 → phase-2 → phase-3.
159
161
 
@@ -168,13 +170,11 @@ Lead NEVER commits unless verification passes.
168
170
  ## Git Boundaries
169
171
 
170
172
  - Each phase completion → **one conventional commit** on the current branch
171
- - **One branch per task**, off `main`: `<git-user>/<task-id>-<slug>`
173
+ - **One branch per task**, off `delivery.base_branch` (default `main`): `<git-user>/<task-id>-<slug>`
172
174
  - Never switch to an unrelated branch
173
- - Lead never merges its own PR
174
- - Milestone completion → **push to origin** (automatic after review passes)
175
- - Commits stay local until milestone fully completes — no partial push on failure
176
- - Reviewer reviews unpushed commits: `git log origin/{branch}..HEAD`
177
- - Clean git history: each commit maps to a phase
175
+ - Each task is pushed and opened as its own pull request, against `delivery.base_branch`
176
+ - Lead never merges its own PR, except under `--auto`
177
+ - Clean git history: each commit maps to a phase, and the squash maps to a task
178
178
 
179
179
  ### Conventional Commit Format
180
180
 
@@ -208,7 +208,7 @@ The user must approve before these transitions:
208
208
  - **Milestone creation** — PM discusses and plans, but must get user approval before creating the milestone directory and files
209
209
  - **RFC → Implementation** — lead waits for the user only when `rfc_approval: required`. The default, `optional`, shows the design and continues.
210
210
 
211
- Push is automatic after review passes. All other transitions are automatic.
211
+ Each task is pushed when its pull request opens; review happens on it, and the human merges (lead does under `--auto`). All other transitions are automatic.
212
212
 
213
213
  ### Rejection Handling
214
214
 
@@ -220,26 +220,17 @@ Rejections are normal. The system does not stall — it loops back with feedback
220
220
 
221
221
  ---
222
222
 
223
- ## Review Flow (Git-Native)
223
+ ## Review Flow
224
224
 
225
- Reviewer is a separate agent called by the lead. Review is based on **unpushed commits**.
225
+ Review happens on each task's pull request: the reviewer posts one review with
226
+ every finding inline on its line and sets the verdict as a label
227
+ (`review:approved` or `review:changes-requested`). On changes-requested a new
228
+ engineer with the phase's identity fixes and answers the threads, and the
229
+ re-reviewer checks the fix range — at most `thresholds.stuck_retry_limit`
230
+ rounds, then the user decides. Suggestions are recorded as tech debt.
226
231
 
227
- ```
228
- Lead calls reviewer agent
229
- → Reviewer runs: git log origin/{branch}..HEAD
230
- → Reviewer runs: git diff origin/{branch}...HEAD
231
- → Reviewer applies full checklist
232
- → Returns: approved / approved-with-comments / changes-requested
233
- ```
234
-
235
- **If approved** → push immediately.
236
-
237
- **If approved-with-comments** → push immediately. Each Suggestion is recorded as tech debt — see the Review section of `.claude/agents/lead.md`.
238
-
239
- **If changes-requested** → Lead launches a new engineer with the phase's identity
240
- and effort, with the phase file, the PR number and the diff in its prompt, and
241
- the fix is re-reviewed — at most `thresholds.stuck_retry_limit` rounds, then
242
- the user decides. The steps are in `.claude/agents/lead.md`, stated once.
232
+ The steps are in `.claude/agents/lead.md` (Review) and the commands in
233
+ `.claude/rules/pull-request.orchestra.md`, each stated once.
243
234
 
244
235
  ---
245
236
 
@@ -299,7 +290,7 @@ Each role has exclusive write access to specific directories:
299
290
  | orchestrator | `.orchestra/roles/*`, `.orchestra/config.yml`, `.orchestra/README.md`, `.orchestra/blueprints/`, `.orchestra/stacks/`, `.orchestra/trackers/`, `.orchestra/scripts/`, `CLAUDE.md`, `.claude/agents/`, `.claude/hooks/`, `.claude/skills/*/SKILL.md`, `.claude/rules/*.orchestra.md`, `.claude/commands/orchestra/`, `docs/` | Everything |
300
291
  | product-manager | `.orchestra/milestones/*` (prd.md, milestone.md, grooming.md, phases) | Everything |
301
292
  | sub-agents | Only what phase `## Scope` defines — dynamic per phase | `.orchestra/milestones/*/phases/*` |
302
- | lead | `.orchestra/milestones/*/context.md` | Everything in active milestone |
293
+ | lead | `.orchestra/milestones/*/{context,decisions,journal}.md`, via `.orchestra/scripts/context.mjs` | Everything in active milestone |
303
294
 
304
295
  ---
305
296
 
@@ -308,27 +299,26 @@ Each role has exclusive write access to specific directories:
308
299
  PM and lead run in **separate terminals**. They communicate through milestone files:
309
300
 
310
301
  - **PM writes:** prd.md, grooming.md, milestone.md, phase files
311
- - **Lead reads:** milestone files → executes phases → updates results + context.md
302
+ - **Lead reads:** milestone files → executes phases → records results with `context.mjs`
312
303
  - **No direct messaging** between PM and lead — file system is the interface
313
304
 
314
305
  ### Context Persistence
315
306
 
316
- Lead maintains `context.md` in each milestone directory with a fixed structure:
317
- - `## Status` — milestone id, start date, pipeline type
318
- - `## Phases` — per-phase status, commit hash, files changed, errors
319
- - `## Codebase Map` — scout-generated file map (survives milestone clear)
320
- - `## Decisions` — key choices from each phase that affect later phases
321
- - `## Journal` — append-only: what happened and which alternatives were rejected, and why
322
- - `## Metrics` — phase duration and verification retries (used by `/orchestra:status`)
307
+ Lead keeps three files per milestone, written only through
308
+ `.orchestra/scripts/context.mjs`, split by who reads them:
309
+ - `context.md` — Status, Phases (status, commit, errors), Codebase Map; read at every phase
310
+ - `decisions.md` — one-line decisions, each open until a later line closes it; only open ones reach a phase prompt
311
+ - `journal.md` — append-only: what happened and which alternatives were rejected, and why; read on demand
323
312
 
324
- This enables resume after terminal close/reopen. On restart, lead reads context.md and skips completed phases.
313
+ Metrics are not stored: `context.mjs metrics` computes them when `/orchestra:status` or `/orchestra:rewind` asks.
314
+ On restart, lead reads `context.md`, skips completed phases, and takes the open decisions from `context.mjs live`.
325
315
 
326
316
  ### Approval Gates (Lead Terminal)
327
317
 
328
318
  Lead asks the user directly (not PM) at this point:
329
319
  1. **RFC ready** — "Approve RFC to start implementation?", asked only when `rfc_approval: required`. Under the default `optional` the design is shown and execution continues; under `skip` it is written and never shown.
330
320
 
331
- Push is automatic after review passes — no approval needed.
321
+ Each task is pushed as its pull request opens; nobody approves a push. The merge is the human's, except under `--auto`.
332
322
 
333
323
  ---
334
324
 
@@ -356,13 +346,13 @@ sequenceDiagram
356
346
  C->>C: sub-agent → phase-N → commit
357
347
  end
358
348
 
359
- C->>C: reviewer agent → review commits
349
+ C->>C: per task: push, open PR, reviewer reviews it
360
350
 
361
351
  alt Changes requested
362
352
  C->>C: Fix → commit
363
353
  end
364
354
 
365
- C->>C: git push → milestone done
355
+ C->>C: human merges each PR → milestone done
366
356
 
367
357
  alt --auto mode
368
358
  C->>C: Next milestone → loop or done
@@ -383,7 +373,7 @@ sequenceDiagram
383
373
  C->>C: Scan milestones/
384
374
 
385
375
  C->>MA: Spawn Agent(M1)
386
- MA->>MA: phase-1 → phase-2 → review → push
376
+ MA->>MA: phase-1 → phase-2, each PR reviewed
387
377
  MA-->>C: {status: done, retro: ...}
388
378
  Note over C: ~1-2k tokens retained
389
379
 
@@ -393,7 +383,7 @@ sequenceDiagram
393
383
  C->>C: "Continue to M2?" → user says yes
394
384
  C->>MA: Spawn Agent(M2)
395
385
  end
396
- MA->>MA: phase-1 → phase-2 → review → push
386
+ MA->>MA: phase-1 → phase-2, each PR reviewed
397
387
  MA-->>C: {status: done, retro: ...}
398
388
 
399
389
  C->>C: No more milestones
@@ -1,6 +1,16 @@
1
1
  # Orchestra Configuration
2
2
  # Customize pipeline behavior, thresholds, and verification commands.
3
3
 
4
+ project:
5
+ # What this project builds for: web, expo, ios, android, backend. The
6
+ # installer installs only those platforms' stacks, skills and rule sections,
7
+ # and retires the rest on the next run. Empty means not decided yet: the
8
+ # installer detects the list from the repository and writes it here, and
9
+ # installs everything only when it finds nothing — an empty repo before
10
+ # /orchestra:setup. Edit it, or run `npx agentic-team --platforms ios,backend`,
11
+ # to change it; list all five to install everything on purpose.
12
+ platforms: []
13
+
4
14
  pipeline:
5
15
  # One model for every phase. There used to be a table mapping a phase's
6
16
  # complexity to a model tier, and it stopped earning its place: on the
@@ -186,6 +196,12 @@ delivery:
186
196
  # flag decides whether a human gates the merge; nothing decides whether the
187
197
  # work is visible.
188
198
  #
199
+ # The branch every task branches off and squashes into. Asked at setup,
200
+ # never inferred: a repository's default branch is often not where work
201
+ # lands — one project integrates on `next` and releases from `main`, and a
202
+ # task built off `main` there opens its PR against the release branch.
203
+ base_branch: main
204
+
189
205
  # How many times to iterate on a screen that does not match its design
190
206
  # before giving up. Only reached under --auto, where nobody is watching to
191
207
  # judge it.
@@ -107,7 +107,8 @@ Do NOT ask for approval to write milestone files — they are your ownership sco
107
107
  Write directly, then inform the user what you changed.
108
108
 
109
109
  **Git, for milestone files only.** Stage `.orchestra/milestones/` explicitly,
110
- commit conventionally, push to `main`. Never a branch, never a pull request,
110
+ commit conventionally, push to `delivery.base_branch` (default `main`) — the
111
+ branch lead branches its tasks off, so the phases are in the tree it builds. Never a branch, never a pull request,
111
112
  never `git add -A` or `commit -a`, never a merge or a rebase. A plan is
112
113
  reviewed by the milestone reviewer before it is written, not by a pull
113
114
  request after — and a PR opened by the PM is a PR nobody asked for. The
@@ -319,7 +320,7 @@ effort: standard # light | standard | complex — how hard the
319
320
  # under pipeline.effort. A raw level — max — is
320
321
  # also accepted, for the one phase where
321
322
  # correctness beats cost outright.
322
- skills: [] # .claude/skills/ names
323
+ skills: [] # names under .claude/skills/ — only those installed
323
324
  depends_on: []
324
325
  design: designs/<screen>.png # or a published Claude Design URL. Required for
325
326
  # any phase that builds a screen. Lead resolves
@@ -7,7 +7,7 @@
7
7
  # for degrades to the raw diff rather than to a wrong one.
8
8
  #
9
9
  # Shipped to every project because pull-request.orchestra.md names it. Its
10
- # sibling scripts/compact-test-output.awk stays in the orchestrator repo:
10
+ # sibling scripts/compact-test-output.awk stays in the agentic-team repo:
11
11
  # it parses `node --test`, and scaffolded apps run vitest or jest.
12
12
 
13
13
  BEGIN { in_header = 0 }
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Lead's record of a milestone, in three files with one writer:
4
+ //
5
+ // context.md state: Status, Phases, Codebase Map — read at every phase
6
+ // decisions.md decisions, each open until a later line closes it
7
+ // journal.md what was tried and why it lost — append-only, rarely read
8
+ //
9
+ // The split follows who reads what. One run kept all of it in a single
10
+ // context.md: 72 KB, of which the 43 KB journal was dead weight to anyone
11
+ // reading for the current state. It also had lead edit that file with an
12
+ // ad-hoc script per change — ~555k characters of edit code that stayed in
13
+ // lead's own context — so every write goes through a command here instead.
14
+ //
15
+ // A decision is never edited. It closes when a later line names it, and only
16
+ // open decisions reach a phase prompt (`live`), so a resolved blocker or a
17
+ // superseded choice stops costing anything without being lost.
18
+ //
19
+ // Lead is the only writer, so ids are assigned by read-then-append without a
20
+ // lock. Metrics are not stored: `metrics` computes them from git and the
21
+ // review-round count when asked, because hand-kept numbers went unmaintained.
22
+
23
+ import { execFileSync } from "node:child_process";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ const TYPES = ["binding", "tech_debt", "blocker", "question"];
28
+ const VERDICTS = ["done", "superseded", "dropped", "answered"];
29
+ const STATUSES = ["pending", "in-progress", "done", "failed", "blocked"];
30
+ // One line a reader can take in at a glance. One run's decision lines reached
31
+ // 728 characters because nothing stopped them; the detail belongs in journal.
32
+ const MAX_DECISION = 200;
33
+
34
+ const DECISION =
35
+ /^- (D\d+) (binding|tech_debt|blocker|question|legacy) ?(\S*) ?(\d{4}-\d{2}-\d{2})?: (.*)$/;
36
+ const CLOSE = /^- (D\d+) closed (\S+) (\d{4}-\d{2}-\d{2}): (.*)$/;
37
+
38
+ const root = process.cwd();
39
+ const dir = (m) => path.join(root, ".orchestra", "milestones", m);
40
+ const file = (m, name) => path.join(dir(m), name);
41
+ const today = () => new Date().toISOString().slice(0, 10);
42
+ // CRLF is normalised on read: a section matcher that expects "\n" would
43
+ // otherwise miss a heading and append a second copy of the section.
44
+ const read = (p) => (fs.existsSync(p) ? fs.readFileSync(p, "utf8").replace(/\r\n/g, "\n") : "");
45
+ const PHASE_ID = /^[A-Za-z0-9._-]+$/;
46
+
47
+ function die(msg) {
48
+ process.stderr.write(`context: ${msg}\n`);
49
+ process.exit(1);
50
+ }
51
+
52
+ function writeAtomic(p, text) {
53
+ const tmp = `${p}.${process.pid}`;
54
+ fs.writeFileSync(tmp, text);
55
+ fs.renameSync(tmp, p);
56
+ }
57
+
58
+ function need(m) {
59
+ if (!m) die("name the milestone directory, e.g. M2-design-system");
60
+ if (!fs.existsSync(dir(m))) die(`no milestone directory .orchestra/milestones/${m}`);
61
+ }
62
+
63
+ function oneLine(text, what) {
64
+ const t = String(text || "").trim();
65
+ if (!t) die(`${what} is empty`);
66
+ if (/[\r\n]/.test(t)) die(`${what} must be one line`);
67
+ return t;
68
+ }
69
+
70
+ const STATE_HEADER = (m) =>
71
+ `## Status\nmilestone: ${m}\nstarted: ${today()}\n\n## Phases\n\n## Codebase Map\n`;
72
+
73
+ function init(m) {
74
+ need(m);
75
+ if (!fs.existsSync(file(m, "context.md"))) writeAtomic(file(m, "context.md"), STATE_HEADER(m));
76
+ if (!fs.existsSync(file(m, "decisions.md")))
77
+ writeAtomic(file(m, "decisions.md"), "# Decisions\n\n");
78
+ if (!fs.existsSync(file(m, "journal.md"))) writeAtomic(file(m, "journal.md"), "# Journal\n\n");
79
+ }
80
+
81
+ /** Replace the body of a `## name` section, or add the section at the end. */
82
+ function setSection(text, name, body) {
83
+ const re = new RegExp(`(^|\\n)## ${name}\\n[\\s\\S]*?(?=\\n## |$)`);
84
+ const block = `## ${name}\n${body}${body.endsWith("\n") ? "" : "\n"}`;
85
+ if (re.test(text)) return text.replace(re, (_, lead) => `${lead}${block}`);
86
+ return `${text.replace(/\n*$/, "\n\n")}${block}`;
87
+ }
88
+
89
+ function section(text, name) {
90
+ const m = new RegExp(`(?:^|\\n)## ${name}\\n([\\s\\S]*?)(?=\\n## |$)`).exec(text);
91
+ return m ? m[1] : "";
92
+ }
93
+
94
+ function phase(m, id, status, note) {
95
+ need(m);
96
+ init(m);
97
+ if (!id || !PHASE_ID.test(id)) die("name the phase as its file is named, e.g. phase-3");
98
+ if (!STATUSES.includes(status)) die(`status is one of ${STATUSES.join(", ")}`);
99
+ if (status === "done" && !/commits?:\s*\S+/.test(note || "")) {
100
+ die("a done phase carries its commit: pass a note like 'commit: abc123 | files: 4'");
101
+ }
102
+ const p = file(m, "context.md");
103
+ const text = read(p);
104
+ const lines = section(text, "Phases")
105
+ .split("\n")
106
+ .filter((l) => l.trim());
107
+ const entry = `- ${id}: ${status}${note ? ` | ${oneLine(note, "note")}` : ""}`;
108
+ const at = lines.findIndex((l) => l.startsWith(`- ${id}:`));
109
+ if (at === -1) lines.push(entry);
110
+ else lines[at] = entry;
111
+ writeAtomic(p, setSection(text, "Phases", `${lines.join("\n")}\n`));
112
+ }
113
+
114
+ function map(m) {
115
+ need(m);
116
+ init(m);
117
+ const body = fs.readFileSync(0, "utf8").trim();
118
+ // An empty stdin is a forgotten heredoc, not a request to erase the map.
119
+ if (!body) die("pipe the map in on stdin; nothing came, so the map is unchanged");
120
+ if (body.split("\n").length > 30) die("the codebase map is at most 30 lines");
121
+ const p = file(m, "context.md");
122
+ writeAtomic(p, setSection(read(p), "Codebase Map", `${body}\n`));
123
+ }
124
+
125
+ function parseDecisions(m) {
126
+ const decisions = new Map();
127
+ const closes = new Map();
128
+ for (const line of read(file(m, "decisions.md")).split("\n")) {
129
+ const c = CLOSE.exec(line);
130
+ if (c) {
131
+ closes.set(c[1], { verdict: c[2], date: c[3], text: c[4] });
132
+ continue;
133
+ }
134
+ const d = DECISION.exec(line);
135
+ if (d) decisions.set(d[1], { id: d[1], type: d[2], phase: d[3], line });
136
+ }
137
+ return { decisions, closes };
138
+ }
139
+
140
+ function nextId(m) {
141
+ let max = 0;
142
+ for (const id of parseDecisions(m).decisions.keys()) max = Math.max(max, Number(id.slice(1)));
143
+ return `D${max + 1}`;
144
+ }
145
+
146
+ function decide(m, type, phaseId, text) {
147
+ need(m);
148
+ init(m);
149
+ if (!TYPES.includes(type)) die(`type is one of ${TYPES.join(", ")}`);
150
+ if (!phaseId || !PHASE_ID.test(phaseId)) {
151
+ die("name the phase the decision came from as its file is named, e.g. phase-3, or milestone");
152
+ }
153
+ const t = oneLine(text, "decision");
154
+ if (t.length > MAX_DECISION) {
155
+ die(
156
+ `a decision is at most ${MAX_DECISION} characters (this is ${t.length}); put the detail in the journal`,
157
+ );
158
+ }
159
+ const id = nextId(m);
160
+ fs.appendFileSync(file(m, "decisions.md"), `- ${id} ${type} ${phaseId} ${today()}: ${t}\n`);
161
+ process.stdout.write(`${id}\n`);
162
+ }
163
+
164
+ function close(m, id, verdict, text) {
165
+ need(m);
166
+ const { decisions, closes } = parseDecisions(m);
167
+ if (!decisions.has(id)) die(`no decision ${id} in ${m}`);
168
+ if (closes.has(id)) die(`${id} is already closed`);
169
+ if (!VERDICTS.includes(verdict)) die(`verdict is one of ${VERDICTS.join(", ")}`);
170
+ const t = oneLine(text, "reason");
171
+ if (t.length > MAX_DECISION) die(`a reason is at most ${MAX_DECISION} characters`);
172
+ fs.appendFileSync(file(m, "decisions.md"), `- ${id} closed ${verdict} ${today()}: ${t}\n`);
173
+ }
174
+
175
+ function journal(m, phaseId, text) {
176
+ need(m);
177
+ init(m);
178
+ if (!phaseId || !PHASE_ID.test(phaseId)) die("name the phase, e.g. phase-3, or milestone");
179
+ fs.appendFileSync(file(m, "journal.md"), `- ${today()} ${phaseId}: ${oneLine(text, "entry")}\n`);
180
+ }
181
+
182
+ /**
183
+ * `live`: the open binding decisions — what a phase must follow, so what a
184
+ * phase prompt carries. `open`: every open decision, blockers, questions and
185
+ * tech debt included — lead's to act on, not an engineer's to read.
186
+ */
187
+ function live(m, all) {
188
+ need(m);
189
+ const { decisions, closes } = parseDecisions(m);
190
+ for (const d of decisions.values()) {
191
+ if (closes.has(d.id)) continue;
192
+ if (all || d.type === "binding") process.stdout.write(`${d.line}\n`);
193
+ }
194
+ }
195
+
196
+ function check(m) {
197
+ const found = [];
198
+ const { decisions, closes } = parseDecisions(m);
199
+ const seen = new Set();
200
+ for (const line of read(file(m, "decisions.md")).split("\n")) {
201
+ if (!line.startsWith("- ")) continue;
202
+ if (CLOSE.test(line)) continue;
203
+ const d = DECISION.exec(line);
204
+ if (!d) {
205
+ found.push(`${m}/decisions.md: not a decision or a close: ${line.slice(0, 60)}`);
206
+ continue;
207
+ }
208
+ if (seen.has(d[1])) found.push(`${m}/decisions.md: ${d[1]} is used twice`);
209
+ seen.add(d[1]);
210
+ const migrated = d[2] === "legacy" || d[3] === "legacy";
211
+ if (!migrated && d[5].length > MAX_DECISION) {
212
+ found.push(`${m}/decisions.md: ${d[1]} is ${d[5].length} characters, over ${MAX_DECISION}`);
213
+ }
214
+ }
215
+ for (const id of closes.keys()) {
216
+ if (!decisions.has(id))
217
+ found.push(`${m}/decisions.md: a close names ${id}, which does not exist`);
218
+ }
219
+ return found;
220
+ }
221
+
222
+ function milestones() {
223
+ const base = path.join(root, ".orchestra", "milestones");
224
+ if (!fs.existsSync(base)) return [];
225
+ return fs
226
+ .readdirSync(base, { withFileTypes: true })
227
+ .filter((e) => e.isDirectory())
228
+ .map((e) => e.name);
229
+ }
230
+
231
+ /**
232
+ * Split an older single-file context.md. Lossless: Decisions, Tech debt and
233
+ * Blocked bullets become open `legacy` decisions with their text unchanged;
234
+ * Journal and Metrics move to journal.md verbatim; any other section stays.
235
+ */
236
+ function migrate(m) {
237
+ const p = file(m, "context.md");
238
+ const text = read(p);
239
+ const moves = { Decisions: "legacy", "Tech debt": "legacy", Blocked: "legacy" };
240
+ const toJournal = ["Journal", "Metrics"];
241
+ const present = [...Object.keys(moves), ...toJournal].filter((s) =>
242
+ new RegExp(`(^|\\n)## ${s}\\n`).test(text),
243
+ );
244
+ if (!present.length) return false;
245
+
246
+ let decisionsText = read(file(m, "decisions.md")) || "# Decisions\n\n";
247
+ let n = Number(nextId(m).slice(1));
248
+ for (const s of Object.keys(moves)) {
249
+ // A bullet starts an entry and an indented line continues it. Anything
250
+ // else — prose, a numbered item, a sub-heading — becomes its own entry,
251
+ // so nothing in the section is dropped when the section is removed.
252
+ const entries = [];
253
+ for (const raw of section(text, s).split("\n")) {
254
+ if (!raw.trim()) continue;
255
+ if (raw.startsWith("- ")) entries.push(raw.slice(2).trim());
256
+ else if (/^\s/.test(raw) && entries.length) entries[entries.length - 1] += ` ${raw.trim()}`;
257
+ else entries.push(raw.trim());
258
+ }
259
+ const label = s === "Decisions" ? "" : `[${s}] `;
260
+ for (const e of entries) {
261
+ // An older line that says it is binding still binds the phases left;
262
+ // the rest is history lead can close as it reads them.
263
+ const kind = s === "Decisions" && /^binding\b/i.test(e) ? "binding legacy" : "legacy";
264
+ decisionsText += `- D${n++} ${kind}: ${label}${e}\n`;
265
+ }
266
+ }
267
+
268
+ let journalText = read(file(m, "journal.md")) || "# Journal\n\n";
269
+ for (const s of toJournal) {
270
+ const body = section(text, s).trim();
271
+ if (body) journalText += `${s === "Journal" ? "" : `\n## Migrated from ${s}\n`}${body}\n`;
272
+ }
273
+
274
+ // New files first: if this stops halfway, context.md still holds everything.
275
+ writeAtomic(file(m, "decisions.md"), decisionsText);
276
+ writeAtomic(file(m, "journal.md"), journalText);
277
+ let rest = text;
278
+ for (const s of present)
279
+ rest = rest.replace(new RegExp(`(^|\\n)## ${s}\\n[\\s\\S]*?(?=\\n## |$)`), "");
280
+ writeAtomic(p, `${rest.trim()}\n`);
281
+ return true;
282
+ }
283
+
284
+ function metrics(m) {
285
+ need(m);
286
+ let rounds = {};
287
+ try {
288
+ rounds = JSON.parse(
289
+ read(path.join(root, ".orchestra", ".sessions", "review-rounds.json")) || "{}",
290
+ );
291
+ } catch {
292
+ // An unreadable count reads as no rounds recorded, which is what it says.
293
+ rounds = {};
294
+ }
295
+ const commitTime = (hash) => {
296
+ try {
297
+ return execFileSync("git", ["show", "-s", "--format=%cI", hash], {
298
+ encoding: "utf8",
299
+ stdio: ["ignore", "pipe", "ignore"],
300
+ }).trim();
301
+ } catch {
302
+ return "unknown";
303
+ }
304
+ };
305
+ const lines = section(read(file(m, "context.md")), "Phases")
306
+ .split("\n")
307
+ .filter((l) => l.startsWith("- "));
308
+ for (const l of lines) {
309
+ const [, id, status] = /^- (\S+): ([a-z-]+)/.exec(l) || [];
310
+ if (!id) continue;
311
+ const hashes = [];
312
+ for (const field of l.matchAll(/commits?:\s*([^|]+)/g)) {
313
+ for (const h of field[1].matchAll(/\b[0-9a-f]{7,40}\b/g)) hashes.push(h[0]);
314
+ }
315
+ const when = hashes.length ? commitTime(hashes[hashes.length - 1]) : "no commit";
316
+ process.stdout.write(
317
+ `${id}\t${status}\treview rounds: ${rounds[`${m}/${id}`] || 0}\tlast commit: ${when}\n`,
318
+ );
319
+ }
320
+ }
321
+
322
+ // An agent pipes output into `head`; a closed pipe ends the read, not the script.
323
+ process.stdout.on("error", (e) => {
324
+ if (e.code === "EPIPE") process.exit(0);
325
+ throw e;
326
+ });
327
+
328
+ const [cmd, ...args] = process.argv.slice(2);
329
+ switch (cmd) {
330
+ case "init":
331
+ init(args[0]);
332
+ break;
333
+ case "phase":
334
+ phase(args[0], args[1], args[2], args.slice(3).join(" "));
335
+ break;
336
+ case "map":
337
+ map(args[0]);
338
+ break;
339
+ case "decide":
340
+ decide(args[0], args[1], args[2], args.slice(3).join(" "));
341
+ break;
342
+ case "close":
343
+ close(args[0], args[1], args[2], args.slice(3).join(" "));
344
+ break;
345
+ case "journal":
346
+ journal(args[0], args[1], args.slice(2).join(" "));
347
+ break;
348
+ case "live":
349
+ live(args[0], false);
350
+ break;
351
+ case "open":
352
+ live(args[0], true);
353
+ break;
354
+ case "metrics":
355
+ metrics(args[0]);
356
+ break;
357
+ case "check": {
358
+ const found = (args[0] ? [args[0]] : milestones()).flatMap(check);
359
+ for (const f of found) process.stdout.write(`${f}\n`);
360
+ process.exit(found.length ? 1 : 0);
361
+ break;
362
+ }
363
+ case "migrate": {
364
+ const moved = (args[0] ? [args[0]] : milestones()).filter(migrate);
365
+ for (const m of moved)
366
+ process.stdout.write(`split ${m}/context.md into decisions.md and journal.md\n`);
367
+ break;
368
+ }
369
+ default:
370
+ die(
371
+ "usage: context.mjs init|phase|map|decide|close|journal|live|open|check|migrate|metrics <milestone> …",
372
+ );
373
+ }