@jaybeeuu/agent-cortex 1.43.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +5 -6
  2. package/.github/workflows/ci.yml +4 -11
  3. package/AGENTS.md +13 -17
  4. package/CHANGELOG.md +66 -0
  5. package/README.md +57 -57
  6. package/agents/README.md +9 -23
  7. package/agents/ralph/agent.md +13 -17
  8. package/agents/ralph/claude/polling.md +1 -1
  9. package/agents-native/ralph.md +15 -5
  10. package/bin/agent-cortex.mjs +0 -16
  11. package/bin/installers/claude.mjs +82 -11
  12. package/docs/claude-hooks.md +16 -0
  13. package/docs/ideas/improve-pi-system-prompt.md +9 -3
  14. package/docs/research/disable-model-invocation.md +5 -26
  15. package/docs/skills/skill-anatomy.md +5 -3
  16. package/docs/technical-direction/harness-config.md +6 -14
  17. package/docs/user-preferences.md +2 -2
  18. package/extensions/agent-modes/README.md +3 -3
  19. package/extensions/agent-modes/discover.ts +6 -6
  20. package/extensions/output-style/README.md +58 -0
  21. package/extensions/output-style/index.ts +23 -0
  22. package/extensions/output-style/output-style.md +43 -0
  23. package/extensions/output-style/output-style.test.ts +50 -0
  24. package/extensions/output-style/output-style.ts +32 -0
  25. package/extensions/output-style/package.json +19 -0
  26. package/extensions/output-style/tsconfig.json +12 -0
  27. package/extensions/subagent/README.md +2 -2
  28. package/extensions/subagent/agents.ts +1 -2
  29. package/lib/cli.mjs +5 -7
  30. package/package.json +5 -6
  31. package/scripts/check-skill-frontmatter.mjs +21 -0
  32. package/scripts/lib/compose-agent.mjs +10 -10
  33. package/scripts/lib/skill-frontmatter.mjs +135 -0
  34. package/skills/workflow/ralph/REFERENCE.md +34 -11
  35. package/skills/workflow/ralph/SKILL.md +10 -10
  36. package/skills/workflow/ralph/scripts/pending-gates.mjs +134 -0
  37. package/test/cli.test.mjs +11 -10
  38. package/test/installer.test.mjs +143 -1
  39. package/test/pending-gates.test.mjs +147 -0
  40. package/test/skill-frontmatter.test.mjs +137 -0
  41. package/token-map.README.md +9 -12
  42. package/token-map.json +26 -31
  43. package/agents/plan/copilot/frontmatter.json +0 -6
  44. package/agents/plan.agent.md +0 -59
  45. package/agents/ralph/copilot/frontmatter.json +0 -6
  46. package/agents/ralph/copilot/polling.md +0 -62
  47. package/agents/ralph-plan/copilot/frontmatter.json +0 -6
  48. package/agents/ralph-plan.agent.md +0 -126
  49. package/agents/ralph.agent.md +0 -331
  50. package/agents/strategy/copilot/frontmatter.json +0 -6
  51. package/agents/strategy.agent.md +0 -112
  52. package/bin/installers/copilot.mjs +0 -124
  53. package/hooks.json +0 -10
  54. package/plugin.json +0 -35
  55. package/scripts/build-copilot-agents.mjs +0 -30
  56. package/scripts/install-instructions.sh +0 -82
  57. package/scripts/sync-plugin-version.sh +0 -17
  58. package/test/copilot-installer.test.mjs +0 -308
@@ -74,13 +74,37 @@ When the `document` chore for a parent task completes:
74
74
  3. Report the PR URL in chat as soon as it is created.
75
75
  4. Find the child HITL task bead for this parent with label `lifecycle:feature-pr` (created by `create-task`).
76
76
  5. Update that HITL bead with the PR URL/status (comment or note) so the reviewer has the link.
77
- 6. Do not schedule new parent features while this HITL PR gate bead remains open.
78
- 7. Once the PR is merged, a human closes the HITL PR gate bead. After that, close the parent feature bead:
77
+ 6. Do not schedule new parent features while this gate is **unblocked** — an unblocked gate is a feature genuinely awaiting a human merge (see _Gate predicate_ below).
78
+ 7. Once the PR is merged, a human closes the HITL PR gate bead, which unblocks the scheduling brake. After that, close the parent feature bead:
79
79
  ```bash
80
80
  bd close <parent-id>
81
81
  ```
82
82
  Only then continue with next parent features.
83
83
 
84
+ ### Gate predicate — parked vs pending
85
+
86
+ Ralph's only scheduling brake is a gate that is **unblocked**. `create-chores` pre-creates a `lifecycle:feature-pr` gate for *every* planned feature, blocked behind that feature's own `document` chore; such a gate is **parked** — the feature has not reached its PR stage — and must never stop dispatch. Only when the `document` chore closes does the gate unblock, and that is the state in which a human must merge.
87
+
88
+ Resolve the question with one command instead of ad-hoc `bd` queries:
89
+
90
+ ```bash
91
+ node skills/workflow/ralph/scripts/pending-gates.mjs
92
+ ```
93
+
94
+ | Field | Meaning | Effect on ralph |
95
+ | --- | --- | --- |
96
+ | `pendingHumanAction` | Unblocked `lifecycle:feature-pr` gates (all blockers closed) plus epics tagged `awaiting-epic-pr-merge`, each as `{ id, title, kind }` | Non-empty → do **not** schedule new parent features; the same list is the **Pending Human Action** table |
97
+ | `parked` | Open gates still blocked behind their own pipeline | Informational only — never stops dispatch, never appears in the table |
98
+
99
+ Gate queries match the HITL axis on either `execution:hitl` (current) or `implementation-type:hitl` (legacy), so gates created on either side of the two-axis migration are counted.
100
+
101
+ **Invariant — what may block a gate.** `parked` means "blocked by its own pipeline". A gate blocked by its parent **feature** bead is not parked in any harness: that bead stays open until after the merge, so the gate never reaches `bd ready`, never appears in `pendingHumanAction`, and a human merge is silently never requested. Keep the two shapes apart:
102
+
103
+ - A `create-chores` gate unblocks exactly once, when its `document` chore closes. Until then it is parked and must not stop dispatch.
104
+ - A gate must never carry a `blocks` dependency onto its own feature bead (the native-Claude shape: `bd create ... --labels ...lifecycle:feature-pr` then dep it to `<id>`). Remove it with `bd dep rm <gate> <id>` if present, so the gate is unblocked while its PR awaits merge.
105
+
106
+ So the predicate is sensitive to *what* blocks a gate, not only whether something does. `agents-native/ralph.md` (the Claude harness) keeps the open-gate form for this reason: its gates stay blocked by design, and its `document` chores never close; F10 (agnt-ctx-s5xd.10) unifies the two predicates.
107
+
84
108
  ---
85
109
 
86
110
  ## Fix Loop
@@ -116,7 +140,7 @@ When a verify or review stage fails:
116
140
 
117
141
  ## Log Polling
118
142
 
119
- Run whenever polling is triggered (timer or agent completion):
143
+ Run whenever completion handling is triggered (an agent completion):
120
144
 
121
145
  1. **Query in-flight beads**: `bd list --status=in_progress --type=chore`. Extract each bead's ID and title.
122
146
  2. For each in-flight bead, read new lines from its **parent's** log file — logs are keyed by **parent** bead ID, so all stages of a feature share one file. Track the next unread line number per parent in memory, starting at 1:
@@ -137,7 +161,7 @@ Run whenever polling is triggered (timer or agent completion):
137
161
 
138
162
  ## HITL Pause
139
163
 
140
- Proceed here when no chore beads are in-flight, no `stage:*` chore beads are ready, and HITL gate beads are pending (open `lifecycle:feature-pr` beads or epics tagged `awaiting-epic-pr-merge`). Ralph stops rather than burning requests on idle polls.
164
+ Proceed here when no chore beads are in-flight, no `stage:*` chore beads are ready, and `pending-gates.mjs` reports a non-empty `pendingHumanAction` — an **unblocked** `lifecycle:feature-pr` gate or an epic tagged `awaiting-epic-pr-merge` (see _Gate predicate_). Gates still parked behind their own pipeline do not count. Ralph stops rather than burning requests on idle polls.
141
165
 
142
166
  1. Regenerate `.agent-cortex/ralph/progress.md` one final time — do not delete it.
143
167
  2. For each **multi-feature** epic whose feature beads are all closed but not yet tagged `awaiting-epic-pr-merge`, open/update an epic PR to main (skip single-feature epics — their feature branch PRs already target `main`):
@@ -149,12 +173,11 @@ Proceed here when no chore beads are in-flight, no `stage:*` chore beads are rea
149
173
  ```bash
150
174
  bd dolt push
151
175
  ```
152
- 4. Collect pending HITL gate beads and their PR URLs:
176
+ 4. Collect the table rows from `pendingHumanAction`:
153
177
  ```bash
154
- bd list -l lifecycle:feature-pr -l implementation-type:hitl
155
- bd list -l awaiting-epic-pr-merge
178
+ node skills/workflow/ralph/scripts/pending-gates.mjs
156
179
  ```
157
- For each bead, run `bd show <id>` to retrieve the PR URL from bead notes.
180
+ For each entry, run `bd show <id>` to retrieve the PR URL from bead notes. `kind: "feature-pr"` rows read "review and merge the feature PR"; `kind: "epic-pr"` rows read "review and merge the epic PR into main".
158
181
  5. Output the **Pending Human Action** summary:
159
182
  ```
160
183
  ⏸️ Ralph is paused — human action required before work can continue.
@@ -167,7 +190,7 @@ Proceed here when no chore beads are in-flight, no `stage:*` chore beads are rea
167
190
 
168
191
  When you've completed the above, prompt me to continue.
169
192
  ```
170
- 6. **Stop completely.** Do not restart the poll timer. Do not continue the event loop. Wait for the user to re-prompt before doing any further work.
193
+ 6. **Stop completely.** Do not continue the event loop. Wait for the user to re-prompt before doing any further work.
171
194
 
172
195
  ---
173
196
 
@@ -209,7 +232,7 @@ When `bd list --status=in_progress --type=chore` returns no results AND `bd read
209
232
  | File | Purpose |
210
233
  |------|---------|
211
234
  | `.agent-cortex/ralph/progress.md` | Human-readable snapshot. Regenerate: `workspace="/abs/path"; pnpm --prefix skills/workflow/run-pipeline-stage/scripts exec tsx generate-progress.ts --workspace "$workspace" > "$workspace/.agent-cortex/ralph/progress.md"`. `workspace` must be an **absolute** path — never `.` or `$(pwd)`. **Never hand-edit.** |
212
- | `.agent-cortex/ralph/state.json` | Runtime bookkeeping only: the poll-timer shellId (Copilot) and the in-flight agent-ID→bead mapping. Shape: `{ "timerShellId": null, "inflight": [ { "choreId": "...", "parentId": "...", "title": "...", "agentId": "...", "logLine": 1 } ] }`. Initialise at startup with `{ "timerShellId": null, "inflight": [] }`. Update after every dispatch and every agent completion. **Never hand-edit.** |
235
+ | `.agent-cortex/ralph/state.json` | Runtime bookkeeping only: the in-flight agent-ID→bead mapping. Shape: `{ "inflight": [ { "choreId": "...", "parentId": "...", "title": "...", "agentId": "...", "logLine": 1 } ] }`. Initialise at startup with `{ "inflight": [] }`. Update after every dispatch and every agent completion. **Never hand-edit.** |
213
236
  | `.agent-cortex/ralph/ralph-*.log` | Per-parent log files written by subagents — every stage chore of a feature appends to `.agent-cortex/ralph/ralph-<parent-id>.log` (e.g. `ralph-abc-123.log`). |
214
237
 
215
238
  All task state is derived from beads; `state.json` holds only runtime bookkeeping:
@@ -221,5 +244,5 @@ All task state is derived from beads; `state.json` holds only runtime bookkeepin
221
244
  | What stage is a bead in? | Read the `stage:*` label from `bd show <id>` |
222
245
  | Which agent-ID maps to which bead? | `inflight` entries in `.agent-cortex/ralph/state.json` |
223
246
  | How many fix rounds? | Count chore beads with label `stage:fix` that are children of the parent task. Read `maxFixRounds` from `skills/planning/create-task/pipeline.json`. |
224
- | Which features are review-gated? | Find open child task beads labelled `lifecycle:feature-pr` and `implementation-type:hitl` |
247
+ | Which features are review-gated? | `pendingHumanAction` entries with `kind: "feature-pr"` from `pending-gates.mjs` — unblocked `lifecycle:feature-pr` gates on either HITL axis. A gate in `parked` is pre-created but not yet actionable |
225
248
  | Which epics are review-gated? | `bd list -l awaiting-epic-pr-merge` |
@@ -7,7 +7,7 @@ description: "Run all pending beads end-to-end with up to 5 concurrent pipeline-
7
7
 
8
8
  Ralph is a parallel orchestration loop. It advances AFK task beads through pipeline-stage chores (`code` → `verify` → `review` → `document`), dispatching each stage as a background subagent in the feature's worktree, then opens review-gated feature and epic PRs and pauses for human merges. Ralph never writes code itself — it dispatches, tracks, and reports.
9
9
 
10
- State model: task state lives in beads (ready/in_progress via `bd`); the only local file is `.agent-cortex/ralph/state.json`, holding the poll-timer shellId and the agent-ID→bead mapping. Pipeline configuration comes from `skills/planning/create-task/pipeline.json`, and stage execution belongs to the `run-pipeline-stage` skill.
10
+ State model: task state lives in beads (ready/in_progress via `bd`); the only local file is `.agent-cortex/ralph/state.json`, holding the agent-ID→bead mapping. Pipeline configuration comes from `skills/planning/create-task/pipeline.json`, and stage execution belongs to the `run-pipeline-stage` skill.
11
11
 
12
12
  ## When to use
13
13
 
@@ -28,14 +28,14 @@ State model: task state lives in beads (ready/in_progress via `bd`); the only lo
28
28
  1. Run `bd prime` and hold the output verbatim in memory.
29
29
  2. Ensure `.agent-cortex/` and `.agent-cortex/worktrees/` are in the project's `.gitignore` (append any that are missing).
30
30
  3. Create the workspace: `mkdir -p .agent-cortex/ralph`.
31
- 4. Initialize state: write `.agent-cortex/ralph/state.json` with `{ "timerShellId": null, "inflight": [] }`.
31
+ 4. Initialize state: write `.agent-cortex/ralph/state.json` with `{ "inflight": [] }`.
32
32
  5. Read `skills/planning/create-task/pipeline.json` and hold it in memory — you need its `maxFixRounds` for the fix loop.
33
33
  6. Run `bd ready` and classify each available bead:
34
34
  - **Chore with a `stage:*` label**: pipeline stage bead — eligible for dispatch.
35
35
  - **Task or other type without an `implementation-type:*` label**: unexpected here — classification happens at planning time via `create-task`. Do not invoke `classify-bead`; treat as needs-refinement and note it for the shutdown summary.
36
- - **Task labelled `implementation-type:hitl`**: skip — record the ID for the Pending Human Action summary at shutdown.
36
+ - **Task labelled `execution:hitl`** (or the legacy `implementation-type:hitl`): skip — record the ID for the Pending Human Action summary at shutdown.
37
37
  7. For each ready chore bead (up to 5), dispatch it from its parent's feature worktree (see _Dispatching a chore bead_ in REFERENCE.md — create branches/worktrees first).
38
- 8. Hand over to your harness's completion-detection mechanism: Copilot keeps a `sleep 120` poll timer running in the background; PI keeps a {{TOOL:wait_for_agents}} call outstanding over every in-flight agent ID. If no chore was dispatched and HITL gate beads are pending (`bd list -l lifecycle:feature-pr -l implementation-type:hitl` or `bd list -l awaiting-epic-pr-merge`), go straight to HITL Pause (REFERENCE.md).
38
+ 8. Hand over to your harness's completion-detection mechanism: PI keeps a {{TOOL:wait_for_agents}} call outstanding over every in-flight agent ID. If no chore was dispatched and `pending-gates.mjs` reports a non-empty `pendingHumanAction` (an unblocked `lifecycle:feature-pr` gate, or an epic tagged `awaiting-epic-pr-merge`), go straight to HITL Pause (REFERENCE.md).
39
39
  9. Regenerate `.agent-cortex/ralph/progress.md`:
40
40
  ```bash
41
41
  # workspace must be the absolute path you cd'd into — never . or $(pwd)
@@ -45,7 +45,7 @@ State model: task state lives in beads (ready/in_progress via `bd`); the only lo
45
45
 
46
46
  ### 2. Event loop
47
47
 
48
- On each completion notification (background agent finished, or the poll timer fired):
48
+ On each completion notification (a background agent finished):
49
49
 
50
50
  1. Poll that parent's log for new lines — `tail -n +<nextLine> .agent-cortex/ralph/ralph-<parent-id>.log` (logs are keyed by parent bead ID, so every stage of a feature shares one file) — and post only new key events (stage transitions, test/lint results), never empty updates.
51
51
  2. Read the completed agent's output with {{TOOL:read_agent}} and parse its `---REPORT---` block for `BEAD_ID`, `STAGE_COMPLETED`, and the outcome.
@@ -62,8 +62,8 @@ On each completion notification (background agent finished, or the poll timer fi
62
62
  | `review` | `REVIEW_OUTCOME: CHANGES_REQUESTED` | Run the fix loop (REFERENCE.md) |
63
63
  | `document` | — | Open/update the feature PR now (`feature/<parent-id>` → its base branch), report the URL, and update the child HITL gate bead (`lifecycle:feature-pr`). Wait for a human to close that bead after merge, then `bd close <parent-id>` |
64
64
 
65
- 5. Run `bd ready` and dispatch new ready `stage:*` chore beads while the in-flight count (`bd list --status=in_progress --type=chore`) is under 5. Do not schedule new parent features while any `lifecycle:feature-pr` HITL gate bead is open.
66
- 6. If no chores are in-flight and `bd ready` returns no `stage:*` chores, proceed to HITL Pause (REFERENCE.md) when HITL gate beads are pending, otherwise to Shutdown (REFERENCE.md).
65
+ 5. Run `bd ready` and dispatch new ready `stage:*` chore beads while the in-flight count (`bd list --status=in_progress --type=chore`) is under 5. Do not schedule new parent features while `pending-gates.mjs` reports a non-empty `pendingHumanAction` — that is, while a `lifecycle:feature-pr` gate is **unblocked**. Gates still blocked behind their own pipeline (pre-created for a feature that has not reached its PR stage) are parked and never stop dispatch.
66
+ 6. If no chores are in-flight and `bd ready` returns no `stage:*` chores, proceed to HITL Pause (REFERENCE.md) when `pendingHumanAction` is non-empty, otherwise to Shutdown (REFERENCE.md).
67
67
  7. Regenerate `.agent-cortex/ralph/progress.md`.
68
68
 
69
69
  ### 3. Branching and review model
@@ -75,7 +75,7 @@ On each completion notification (background agent finished, or the poll timer fi
75
75
 
76
76
  ### 4. HITL pause and shutdown
77
77
 
78
- - **HITL Pause** — when no chores are in-flight, none ready, and HITL gate beads are pending: regenerate `progress.md`, open/update epic PRs to `main` for multi-feature epics and tag each epic `awaiting-epic-pr-merge` (single-feature epics need none — their feature PRs already target `main`), run `bd dolt push`, output the Pending Human Action table, then stop completely until re-prompted.
78
+ - **HITL Pause** — when no chores are in-flight, none ready, and `pending-gates.mjs` reports a non-empty `pendingHumanAction`: regenerate `progress.md`, open/update epic PRs to `main` for multi-feature epics and tag each epic `awaiting-epic-pr-merge` (single-feature epics need none — their feature PRs already target `main`), run `bd dolt push`, output the Pending Human Action table, then stop completely until re-prompted.
79
79
  - **Shutdown** — when all work is complete: regenerate `progress.md`, open/update epic PRs to `main` for multi-feature epics and tag them (single-feature epics need none), run `bd dolt push`, then output the Pending Human Action / Needs Refinement / cap-blocked / Pending Epic Review summaries that apply, or "All beads complete."
80
80
 
81
81
  ## Red Flags
@@ -97,7 +97,7 @@ On each completion notification (background agent finished, or the poll timer fi
97
97
 
98
98
  ## Philosophy / rationale
99
99
 
100
- - Orchestration state derives from beads so a crash loses nothing but an in-memory ID mapping: `state.json` holds only the poll-timer shellId and the agent-ID→bead mapping, never task state.
100
+ - Orchestration state derives from beads so a crash loses nothing but an in-memory ID mapping: `state.json` holds only the agent-ID→bead mapping, never task state.
101
101
  - Classification happens up-front at planning time (`create-task`), so the loop can trust `bd ready` instead of re-interpreting each bead's intent.
102
102
  - Human review gates are non-negotiable because agent-authored changes still need a person to merge them.
103
103
 
@@ -109,7 +109,7 @@ On each completion notification (background agent finished, or the poll timer fi
109
109
  ## Cross-skill references
110
110
 
111
111
  - The `run-pipeline-stage` skill owns the stage-runner prompt, per-stage playbooks, the `---REPORT---` format, and fix-loop feedback beads — read it before dispatching any stage.
112
- - The `create-task` skill owns `pipeline.json` and creates the `lifecycle:feature-pr` HITL gate beads.
112
+ - The `create-task` skill owns `pipeline.json` and creates the `lifecycle:feature-pr` HITL gate beads. `skills/workflow/ralph/scripts/pending-gates.mjs` owns the gate predicate — parked vs pending — and is the only place the gate queries live.
113
113
  - Do not invoke `classify-bead` from this loop — classification was already applied at planning time.
114
114
 
115
115
  ## Verification checklist
@@ -0,0 +1,134 @@
1
+ // Resolves which beads are waiting on a human, and which are merely parked
2
+ // behind their own pipeline.
3
+ //
4
+ // Ralph schedules new parent features only when nothing is pending human action.
5
+ // The distinction that matters is *unblocked*, not *open*: `create-chores`
6
+ // pre-creates a `lifecycle:feature-pr` gate for every planned feature, blocked
7
+ // behind that feature's own document chore. Such a gate is parked — the feature
8
+ // has not reached its PR stage — and must never stop dispatch. Only once the
9
+ // document chore closes does the gate unblock, and that is the state in which a
10
+ // feature genuinely awaits a human merge.
11
+ //
12
+ // Zero dependencies: run with `node pending-gates.mjs`.
13
+
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import { pathToFileURL } from 'node:url';
17
+
18
+ /** The structural label every feature PR gate carries, whatever its axis. */
19
+ export const FEATURE_GATE_LABEL = 'lifecycle:feature-pr';
20
+
21
+ /** Epics whose PR into `main` awaits a human merge. */
22
+ export const EPIC_GATE_LABEL = 'awaiting-epic-pr-merge';
23
+
24
+ // Two-axis migration: gates created before the `execution:afk/hitl` migration
25
+ // carry `implementation-type:hitl`, gates created after carry `execution:hitl`.
26
+ // Both mean "a human must act", so the query matches either.
27
+ export const HITL_GATE_LABELS = ['execution:hitl', 'implementation-type:hitl'];
28
+
29
+ /** @typedef {{ id: string, title?: string }} Bead */
30
+ /** @typedef {{ id: string, title: string, kind: 'feature-pr' | 'epic-pr' }} PendingAction */
31
+
32
+ /**
33
+ * @typedef {object} GateState
34
+ * @property {PendingAction[]} pendingHumanAction Beads a human must act on now — non-empty means "do not schedule new parent features".
35
+ * @property {Array<{ id: string, title: string }>} parked Open gates still blocked behind their own pipeline; informational only.
36
+ */
37
+
38
+ /**
39
+ * Split open gates into those awaiting a human now and those still parked.
40
+ *
41
+ * @param {object} input
42
+ * @param {Bead[]} input.readyGates Open gates with no active blockers.
43
+ * @param {Bead[]} input.openGates Every open gate, blocked or not.
44
+ * @param {Bead[]} input.epicsAwaitingMerge Epics tagged `awaiting-epic-pr-merge`.
45
+ * @returns {GateState}
46
+ */
47
+ export function resolveGateState({ readyGates, openGates, epicsAwaitingMerge }) {
48
+ const actionable = new Set(readyGates.map((bead) => bead.id));
49
+
50
+ const pendingHumanAction = [
51
+ ...readyGates.map((bead) => pendingAction(bead, 'feature-pr')),
52
+ ...epicsAwaitingMerge.map((bead) => pendingAction(bead, 'epic-pr')),
53
+ ];
54
+
55
+ const parked = openGates
56
+ .filter((bead) => !actionable.has(bead.id))
57
+ .map((bead) => ({ id: bead.id, title: bead.title ?? '' }));
58
+
59
+ return { pendingHumanAction, parked };
60
+ }
61
+
62
+ /**
63
+ * @param {Bead} bead
64
+ * @param {'feature-pr' | 'epic-pr'} kind
65
+ * @returns {PendingAction}
66
+ */
67
+ function pendingAction(bead, kind) {
68
+ return { id: bead.id, title: bead.title ?? '', kind };
69
+ }
70
+
71
+ // BD_PATH env override lets machines where bd is not on PATH pin a location.
72
+ const BD_PATH = process.env.BD_PATH || 'bd';
73
+ const execFileAsync = promisify(execFile);
74
+
75
+ /** @type {(args: string[]) => Promise<string>} */
76
+ async function defaultExec(args) {
77
+ const { stdout } = await execFileAsync(BD_PATH, args, { encoding: 'utf-8' });
78
+ return stdout;
79
+ }
80
+
81
+ /**
82
+ * Query bd for the current gate state.
83
+ *
84
+ * @param {object} [options]
85
+ * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
86
+ * @returns {Promise<GateState>}
87
+ */
88
+ export async function fetchGateState({ exec = defaultExec } = {}) {
89
+ const gateFilter = ['-l', FEATURE_GATE_LABEL, '--label-any', HITL_GATE_LABELS.join(',')];
90
+
91
+ // `bd ready` is blocker-aware: it returns only gates whose document chore has
92
+ // closed, i.e. the ones genuinely awaiting a human merge.
93
+ const readyGates = JSON.parse(await exec(['ready', '--json', ...gateFilter]));
94
+ const openGates = JSON.parse(await exec(['list', '--json', '--limit', '0', ...gateFilter]));
95
+ const epicsAwaitingMerge = JSON.parse(await exec(['list', '--json', '--limit', '0', '-l', EPIC_GATE_LABEL]));
96
+
97
+ return resolveGateState({ readyGates, openGates, epicsAwaitingMerge });
98
+ }
99
+
100
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
101
+
102
+ const USAGE = 'Usage: node pending-gates.mjs\n\nPrints { pendingHumanAction, parked } as JSON.\n';
103
+
104
+ class UsageError extends Error {}
105
+
106
+ /**
107
+ * @param {object} options
108
+ * @param {string[]} options.argv CLI arguments (without node/script).
109
+ * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
110
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
111
+ * @returns {Promise<number>} process exit code.
112
+ */
113
+ export async function runCli({ argv, exec = defaultExec, write = (chunk) => process.stdout.write(chunk) }) {
114
+ if (argv.includes('--help') || argv.includes('-h')) {
115
+ write(USAGE);
116
+ return 0;
117
+ }
118
+ if (argv.length > 0) throw new UsageError(`unexpected argument: ${argv[0]}`);
119
+
120
+ write(`${JSON.stringify(await fetchGateState({ exec }), null, 2)}\n`);
121
+ return 0;
122
+ }
123
+
124
+ // Only run the CLI when invoked directly, so tests can import the pure resolver.
125
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
126
+ runCli({ argv: process.argv.slice(2) })
127
+ .then((code) => {
128
+ process.exitCode = code;
129
+ })
130
+ .catch((error) => {
131
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
132
+ process.exitCode = error instanceof UsageError ? 2 : 1;
133
+ });
134
+ }
package/test/cli.test.mjs CHANGED
@@ -54,11 +54,6 @@ describe("parseArgs", () => {
54
54
  assert.deepStrictEqual(result, { command: "help" });
55
55
  });
56
56
 
57
- it("returns { command: 'install', harness: 'copilot' } for install copilot", async () => {
58
- const result = parseArgs(["install", "copilot"]);
59
- assert.deepStrictEqual(result, { command: "install", harness: "copilot" });
60
- });
61
-
62
57
  it("returns { command: 'install', harness: 'claude' } for install claude", async () => {
63
58
  const result = parseArgs(["install", "claude"]);
64
59
  assert.deepStrictEqual(result, { command: "install", harness: "claude" });
@@ -169,11 +164,17 @@ describe("buildHelpText", () => {
169
164
 
170
165
  describe("validateHarness", () => {
171
166
  it("returns ok for supported harnesses", async () => {
172
- for (const h of ["copilot", "claude", "pi"]) {
167
+ for (const h of ["claude", "pi"]) {
173
168
  assert.deepStrictEqual(validateHarness(h), { ok: true });
174
169
  }
175
170
  });
176
171
 
172
+ it("returns error for copilot, which is no longer a deploy target", async () => {
173
+ const result = validateHarness("copilot");
174
+ assert.equal(result.ok, false);
175
+ assert.ok(result.error.includes("Unknown harness"));
176
+ });
177
+
177
178
  it("returns error when harness is null", async () => {
178
179
  const result = validateHarness(null);
179
180
  assert.equal(result.ok, false);
@@ -207,10 +208,10 @@ describe("CLI integration", () => {
207
208
  assert.ok(stdout.includes("Usage:"));
208
209
  });
209
210
 
210
- it("exits 0 for install copilot", async () => {
211
- const { exitCode, stdout } = await runCli(["install", "copilot"]);
212
- assert.equal(exitCode, 0);
213
- assert.ok(stdout.includes("copilot"));
211
+ it("exits 1 for install copilot, which is no longer a deploy target", async () => {
212
+ const { exitCode, stderr } = await runCli(["install", "copilot"]);
213
+ assert.equal(exitCode, 1);
214
+ assert.ok(stderr.includes("Unknown harness"));
214
215
  });
215
216
 
216
217
  it("exits 0 for install claude (into a temp output dir)", async () => {
@@ -661,4 +661,146 @@ describe("install claude — settings.json", () => {
661
661
  await fx.cleanup();
662
662
  }
663
663
  });
664
- });
664
+ });
665
+ // ─── installClaude — managed CLAUDE.md section ────────────────────────────────
666
+
667
+ const MANAGED_BEGIN = "<!-- BEGIN agent-cortex:output-style -->";
668
+ const MANAGED_END = "<!-- END agent-cortex:output-style -->";
669
+ const OUTPUT_SOURCE = "## Output style (agent-cortex)\n\nRules for replies.\n";
670
+
671
+ /** Minimal package root carrying the output-style source, enough for
672
+ * installClaude to materialise the managed CLAUDE.md section. */
673
+ async function makeInstructionsFixture({ source = OUTPUT_SOURCE, memory } = {}) {
674
+ const root = await makeTmp();
675
+ await writeFile(
676
+ join(root, "package.json"),
677
+ JSON.stringify({ name: "agent-cortex", version: "1.2.3", license: "MIT" }),
678
+ );
679
+ await writeFile(
680
+ join(root, "token-map.json"),
681
+ JSON.stringify({ tools: {}, paths: { plugin_root: { claude: "PLUGIN_ROOT" } } }),
682
+ );
683
+ await mkdir(join(root, "agents"));
684
+ await mkdir(join(root, "skills"));
685
+ if (source !== null) {
686
+ await mkdir(join(root, "extensions", "output-style"), { recursive: true });
687
+ await writeFile(join(root, "extensions", "output-style", "output-style.md"), source);
688
+ }
689
+ const output = join(root, "out");
690
+ const instructionsPath = join(root, "claude-home", "CLAUDE.md");
691
+ if (memory !== undefined) {
692
+ await mkdir(dirname(instructionsPath), { recursive: true });
693
+ await writeFile(instructionsPath, memory);
694
+ }
695
+ return { root, output, instructionsPath, cleanup: () => rm(root, { recursive: true, force: true }) };
696
+ }
697
+
698
+ const MANAGED_SECTION = `${MANAGED_BEGIN}\n${OUTPUT_SOURCE.trimEnd()}\n${MANAGED_END}\n`;
699
+
700
+ describe("installClaude — managed CLAUDE.md section", () => {
701
+ it("creates CLAUDE.md with the managed section when the file does not exist", async () => {
702
+ const fx = await makeInstructionsFixture();
703
+ try {
704
+ const result = await installClaude({ root: fx.root, output: fx.output, instructionsPath: fx.instructionsPath });
705
+
706
+ assert.equal(result.instructions.action, "written");
707
+ assert.equal(await readFile(fx.instructionsPath, "utf-8"), MANAGED_SECTION);
708
+ } finally {
709
+ await fx.cleanup();
710
+ }
711
+ });
712
+
713
+ it("appends the section to existing memory and keeps the user's text", async () => {
714
+ const fx = await makeInstructionsFixture({ memory: "# My memory\n\nAlways run the tests.\n" });
715
+ try {
716
+ await installClaude({ root: fx.root, output: fx.output, instructionsPath: fx.instructionsPath });
717
+
718
+ const written = await readFile(fx.instructionsPath, "utf-8");
719
+ assert.ok(written.startsWith("# My memory\n\nAlways run the tests.\n\n"), "personal text kept at the top");
720
+ assert.ok(written.endsWith(MANAGED_SECTION), "managed section appended");
721
+ } finally {
722
+ await fx.cleanup();
723
+ }
724
+ });
725
+
726
+ it("replaces only the managed section, keeping memory text on both sides", async () => {
727
+ const memory = `# Top\n\n${MANAGED_BEGIN}\n## Output style (agent-cortex)\n\nStale rules.\n${MANAGED_END}\n\n# Bottom\n\nKeep me.\n`;
728
+ const fx = await makeInstructionsFixture({ memory });
729
+ try {
730
+ await installClaude({ root: fx.root, output: fx.output, instructionsPath: fx.instructionsPath });
731
+
732
+ const written = await readFile(fx.instructionsPath, "utf-8");
733
+ assert.equal(written, `# Top\n\n${MANAGED_SECTION}\n# Bottom\n\nKeep me.\n`);
734
+ assert.equal(written.includes("Stale rules."), false, "stale block replaced");
735
+ } finally {
736
+ await fx.cleanup();
737
+ }
738
+ });
739
+
740
+ it("re-running is idempotent — the second install reports unchanged", async () => {
741
+ const fx = await makeInstructionsFixture({ memory: "# My memory\n" });
742
+ try {
743
+ await installClaude({ root: fx.root, output: fx.output, instructionsPath: fx.instructionsPath });
744
+ const first = await readFile(fx.instructionsPath, "utf-8");
745
+
746
+ const second = await installClaude({ root: fx.root, output: fx.output, instructionsPath: fx.instructionsPath });
747
+ assert.equal(second.instructions.action, "unchanged");
748
+ assert.equal(await readFile(fx.instructionsPath, "utf-8"), first);
749
+ assert.equal(first.split(MANAGED_BEGIN).length - 1, 1, "exactly one managed section");
750
+ } finally {
751
+ await fx.cleanup();
752
+ }
753
+ });
754
+
755
+ it("leaves a file with an unterminated section unchanged and warns", async () => {
756
+ const memory = `# My memory\n\n${MANAGED_BEGIN}\nHand-edited, no end marker.\n`;
757
+ const fx = await makeInstructionsFixture({ memory });
758
+ try {
759
+ const warnings = [];
760
+ const result = await installClaude({
761
+ root: fx.root,
762
+ output: fx.output,
763
+ instructionsPath: fx.instructionsPath,
764
+ warn: (m) => warnings.push(m),
765
+ });
766
+
767
+ assert.equal(result.instructions.action, "skipped-unmanaged");
768
+ assert.equal(await readFile(fx.instructionsPath, "utf-8"), memory, "file untouched");
769
+ assert.ok(warnings.some((w) => w.includes("unterminated")), "unmatched markers are warned about");
770
+ } finally {
771
+ await fx.cleanup();
772
+ }
773
+ });
774
+
775
+ it("dry-run plans the CLAUDE.md write without touching the filesystem", async () => {
776
+ const fx = await makeInstructionsFixture();
777
+ try {
778
+ const result = await installClaude({
779
+ root: fx.root,
780
+ output: fx.output,
781
+ instructionsPath: fx.instructionsPath,
782
+ dryRun: true,
783
+ });
784
+
785
+ assert.equal(result.instructions.action, "would-write");
786
+ assert.equal(await pathExists(fx.instructionsPath), false, "nothing written in dry-run");
787
+ } finally {
788
+ await fx.cleanup();
789
+ }
790
+ });
791
+
792
+ it("ships the real output-style source the pi extension reads", async () => {
793
+ const real = await readFile(join(ROOT, "extensions", "output-style", "output-style.md"), "utf-8");
794
+ assert.ok(real.startsWith("## Output style (agent-cortex)"), "canonical heading");
795
+
796
+ const fx = await makeInstructionsFixture({ source: real });
797
+ try {
798
+ await installClaude({ root: fx.root, output: fx.output, instructionsPath: fx.instructionsPath });
799
+ const written = await readFile(fx.instructionsPath, "utf-8");
800
+ assert.ok(written.includes("### Write Simplified Technical English (ASD-STE100)"));
801
+ assert.ok(written.includes("### Restate the context, never assume it"));
802
+ } finally {
803
+ await fx.cleanup();
804
+ }
805
+ });
806
+ });
@@ -0,0 +1,147 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ EPIC_GATE_LABEL,
6
+ FEATURE_GATE_LABEL,
7
+ HITL_GATE_LABELS,
8
+ fetchGateState,
9
+ resolveGateState,
10
+ runCli,
11
+ } from '../skills/workflow/ralph/scripts/pending-gates.mjs';
12
+
13
+ function createBead({ id, title = `${id} title` }) {
14
+ return { id, title };
15
+ }
16
+
17
+ /**
18
+ * Fake `bd` for the query-contract tests: answers the three queries ralph makes
19
+ * and refuses anything else, so an unexpected query fails loudly.
20
+ */
21
+ function makeExec({ ready = [], open = [], epics = [] } = {}) {
22
+ const calls = [];
23
+
24
+ const exec = async (args) => {
25
+ calls.push(args);
26
+ if (args[0] === 'ready') return JSON.stringify(ready);
27
+ if (args[0] === 'list' && args.includes(EPIC_GATE_LABEL)) return JSON.stringify(epics);
28
+ if (args[0] === 'list') return JSON.stringify(open);
29
+ throw new Error(`unexpected bd call: ${args.join(' ')}`);
30
+ };
31
+
32
+ return { exec, calls };
33
+ }
34
+
35
+ function capture() {
36
+ let output = '';
37
+ return { write: (chunk) => { output += chunk; }, read: () => JSON.parse(output) };
38
+ }
39
+
40
+ describe('resolveGateState', () => {
41
+ it('leaves dispatch unblocked while every gate is still parked behind its own pipeline', () => {
42
+ const state = resolveGateState({
43
+ readyGates: [],
44
+ openGates: [
45
+ createBead({ id: 'proj-1.5' }),
46
+ createBead({ id: 'proj-2.5' }),
47
+ createBead({ id: 'proj-3.5' }),
48
+ createBead({ id: 'proj-4.5' }),
49
+ ],
50
+ epicsAwaitingMerge: [],
51
+ });
52
+
53
+ assert.deepEqual(state.pendingHumanAction, []);
54
+ assert.deepEqual(state.parked, [
55
+ { id: 'proj-1.5', title: 'proj-1.5 title' },
56
+ { id: 'proj-2.5', title: 'proj-2.5 title' },
57
+ { id: 'proj-3.5', title: 'proj-3.5 title' },
58
+ { id: 'proj-4.5', title: 'proj-4.5 title' },
59
+ ]);
60
+ });
61
+
62
+ it('reports an unblocked gate as pending human action', () => {
63
+ const state = resolveGateState({
64
+ readyGates: [createBead({ id: 'proj-1.5', title: 'PR Review and Merge' })],
65
+ openGates: [createBead({ id: 'proj-1.5', title: 'PR Review and Merge' })],
66
+ epicsAwaitingMerge: [],
67
+ });
68
+
69
+ assert.deepEqual(state.pendingHumanAction, [
70
+ { id: 'proj-1.5', title: 'PR Review and Merge', kind: 'feature-pr' },
71
+ ]);
72
+ assert.deepEqual(state.parked, []);
73
+ });
74
+
75
+ it('separates an unblocked gate from gates that are still parked', () => {
76
+ const state = resolveGateState({
77
+ readyGates: [createBead({ id: 'proj-1.5' })],
78
+ openGates: [createBead({ id: 'proj-1.5' }), createBead({ id: 'proj-2.5' })],
79
+ epicsAwaitingMerge: [],
80
+ });
81
+
82
+ assert.deepEqual(state.pendingHumanAction.map((gate) => gate.id), ['proj-1.5']);
83
+ assert.deepEqual(state.parked.map((gate) => gate.id), ['proj-2.5']);
84
+ });
85
+
86
+ it('reports an epic awaiting merge as pending human action alongside feature gates', () => {
87
+ const state = resolveGateState({
88
+ readyGates: [],
89
+ openGates: [],
90
+ epicsAwaitingMerge: [createBead({ id: 'epic-9', title: 'Merge epic into main' })],
91
+ });
92
+
93
+ assert.deepEqual(state.pendingHumanAction, [
94
+ { id: 'epic-9', title: 'Merge epic into main', kind: 'epic-pr' },
95
+ ]);
96
+ assert.deepEqual(state.parked, []);
97
+ });
98
+ });
99
+
100
+ describe('fetchGateState', () => {
101
+ it('asks bd for unblocked gates, all gates, and epics awaiting merge on either HITL axis', async () => {
102
+ const { exec, calls } = makeExec();
103
+
104
+ await fetchGateState({ exec });
105
+
106
+ assert.deepEqual(calls, [
107
+ ['ready', '--json', '-l', FEATURE_GATE_LABEL, '--label-any', HITL_GATE_LABELS.join(',')],
108
+ ['list', '--json', '--limit', '0', '-l', FEATURE_GATE_LABEL, '--label-any', HITL_GATE_LABELS.join(',')],
109
+ ['list', '--json', '--limit', '0', '-l', EPIC_GATE_LABEL],
110
+ ]);
111
+ });
112
+
113
+ it('covers both the current and the legacy HITL label axis', () => {
114
+ assert.deepEqual(HITL_GATE_LABELS, ['execution:hitl', 'implementation-type:hitl']);
115
+ });
116
+
117
+ it('treats a gate that only the list query returns as parked', async () => {
118
+ const { exec } = makeExec({ ready: [], open: [createBead({ id: 'proj-1.5' })] });
119
+
120
+ const state = await fetchGateState({ exec });
121
+
122
+ assert.deepEqual(state.pendingHumanAction, []);
123
+ assert.deepEqual(state.parked, [{ id: 'proj-1.5', title: 'proj-1.5 title' }]);
124
+ });
125
+ });
126
+
127
+ describe('pending-gates CLI', () => {
128
+ it('prints the resolved gate state as JSON', async () => {
129
+ const { exec } = makeExec({
130
+ ready: [createBead({ id: 'proj-1.5' })],
131
+ open: [createBead({ id: 'proj-1.5' }), createBead({ id: 'proj-2.5' })],
132
+ epics: [createBead({ id: 'epic-9' })],
133
+ });
134
+ const out = capture();
135
+
136
+ const code = await runCli({ argv: [], exec, write: out.write });
137
+
138
+ assert.equal(code, 0);
139
+ assert.deepEqual(out.read(), {
140
+ pendingHumanAction: [
141
+ { id: 'proj-1.5', title: 'proj-1.5 title', kind: 'feature-pr' },
142
+ { id: 'epic-9', title: 'epic-9 title', kind: 'epic-pr' },
143
+ ],
144
+ parked: [{ id: 'proj-2.5', title: 'proj-2.5 title' }],
145
+ });
146
+ });
147
+ });