agentic-team 6.1.0 → 6.2.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/README.md +8 -3
- package/bin/build-template.js +2 -2
- package/bin/index.js +234 -39
- package/bin/platforms.js +510 -0
- package/package.json +4 -4
- package/template/.claude-plugin/plugin.json +2 -2
- package/template/.orchestra/README.md +41 -51
- package/template/.orchestra/config.yml +16 -0
- package/template/.orchestra/roles/product-manager.md +3 -2
- package/template/.orchestra/scripts/compact-diff.awk +1 -1
- package/template/.orchestra/scripts/context.mjs +373 -0
- package/template/.orchestra/stacks/_repo/setup.sh +7 -3
- package/template/.orchestra/stacks/mobile-expo/setup.sh +1 -1
- package/template/CLAUDE.md +1 -1
- package/template/agents/lead.md +171 -116
- package/template/agents/milestone-reviewer.md +5 -3
- package/template/agents/product-manager.md +3 -2
- package/template/agents/re-reviewer.md +25 -0
- package/template/agents/reviewer.md +7 -5
- package/template/agents/scaffolder.md +26 -0
- package/template/commands/help.md +1 -0
- package/template/commands/hotfix.md +3 -1
- package/template/commands/rewind.md +10 -8
- package/template/commands/setup.md +2 -1
- package/template/commands/start.md +2 -2
- package/template/commands/status.md +1 -1
- package/template/commands/sync.md +5 -2
- package/template/commands/verifier.md +2 -2
- package/template/hooks/audit-context.sh +15 -2
- package/template/hooks/enforce-role.sh +21 -3
- package/template/hooks/protect-system-files.sh +2 -2
- package/template/hooks/review-rounds.sh +1 -1
- package/template/rules/code-standards.orchestra.md +3 -2
- package/template/rules/design-parity.orchestra.md +12 -1
- package/template/rules/observability.orchestra.md +3 -0
- package/template/rules/performance.orchestra.md +5 -1
- package/template/rules/phase-limits.orchestra.md +1 -1
- package/template/rules/pull-request.orchestra.md +7 -2
- package/template/rules/role-boundaries.orchestra.md +24 -9
- package/template/rules/security.orchestra.md +1 -1
- package/template/rules/stuck-detection.orchestra.md +1 -1
- package/template/rules/testing-standards.orchestra.md +10 -5
- package/template/rules/tracker-authority.orchestra.md +9 -1
- package/template/skills/push-notifications/SKILL.md +1 -1
- 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 →
|
|
17
|
-
│ (can plan M2 while M1 runs) ├─
|
|
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 #
|
|
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
|
-
→
|
|
80
|
-
→ FIX cycle if changes-requested
|
|
81
|
-
→
|
|
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 →
|
|
102
|
-
| `complex` | Design → Phases →
|
|
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` |
|
|
137
|
-
| `done` |
|
|
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
|
|
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
|
|
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
|
-
-
|
|
174
|
-
-
|
|
175
|
-
-
|
|
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
|
-
|
|
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
|
|
223
|
+
## Review Flow
|
|
224
224
|
|
|
225
|
-
|
|
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
|
-
|
|
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 →
|
|
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
|
|
317
|
-
|
|
318
|
-
-
|
|
319
|
-
-
|
|
320
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 `
|
|
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/
|
|
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
|
|
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
|
+
}
|