@jaybeeuu/agent-cortex 2.0.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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Output style for the PI system prompt.
3
+ *
4
+ * The second slice of docs/ideas/improve-pi-system-prompt.md: the reply shape, not
5
+ * the task discipline. Three concerns, in order — lead with the action, write in
6
+ * Simplified Technical English, restate the context the reader cannot remember.
7
+ *
8
+ * The prose lives in output-style.md so one file feeds both harnesses: this
9
+ * extension appends it to the PI system prompt, and the Claude installer wraps it
10
+ * in a managed CLAUDE.md section. It is read once at load, so the appended text is
11
+ * stable across turns and keeps the prompt-cache prefix intact. The marker doubles
12
+ * as an idempotency guard: a chained prompt that already carries the block is
13
+ * returned unchanged.
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+
18
+ export const OUTPUT_STYLE_MARKER = "## Output style (agent-cortex)";
19
+
20
+ export const OUTPUT_STYLE = readFileSync(new URL("./output-style.md", import.meta.url), "utf-8");
21
+
22
+ /**
23
+ * Append the output-style block to a system prompt.
24
+ *
25
+ * The base prompt is preserved verbatim (minus trailing whitespace) so callers can
26
+ * chain this after other `before_agent_start` handlers. Idempotent: a prompt that
27
+ * already contains the marker is returned unchanged.
28
+ */
29
+ export function appendOutputStyle(systemPrompt: string): string {
30
+ if (systemPrompt.includes(OUTPUT_STYLE_MARKER)) return systemPrompt;
31
+ return `${systemPrompt.trimEnd()}\n\n${OUTPUT_STYLE}`;
32
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@agent-cortex/output-style",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test --import tsx/esm output-style.test.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "devDependencies": {
11
+ "@earendil-works/pi-coding-agent": "^0.78.1",
12
+ "@types/node": "^22.0.0",
13
+ "tsx": "^4.0.0",
14
+ "typescript": "^5.0.0"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "^0.78.1"
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["*.ts"]
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -35,8 +35,9 @@
35
35
  },
36
36
  "scripts": {
37
37
  "build": "echo 'Build complete — no compilation needed for PI package'",
38
- "lint": "pnpm typecheck && pnpm check:sync",
38
+ "lint": "pnpm typecheck && pnpm check:sync && pnpm check:skills",
39
39
  "check:sync": "node scripts/check-no-sync.mjs",
40
+ "check:skills": "node scripts/check-skill-frontmatter.mjs",
40
41
  "test": "node --test test/*.test.mjs && pnpm -r test",
41
42
  "typecheck": "pnpm -r typecheck",
42
43
  "changeset": "changeset",
@@ -0,0 +1,21 @@
1
+ // Repo convention: every SKILL.md frontmatter must parse and declare a name and
2
+ // description, because PI silently drops a skill whose frontmatter throws. Runs
3
+ // via `pnpm lint` (CI) and locally. Scans the repository root, so both skills/
4
+ // and repo-local .agents/skills/ are covered.
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { checkSkills } from "./lib/skill-frontmatter.mjs";
8
+
9
+ const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..");
10
+
11
+ const { checked, failures } = await checkSkills({ root: ROOT });
12
+
13
+ if (failures.length > 0) {
14
+ console.error("✗ Invalid SKILL.md frontmatter — PI would silently drop these skills:");
15
+ for (const { file, errors } of failures) {
16
+ for (const error of errors) console.error(` ${file}: ${error}`);
17
+ }
18
+ process.exit(1);
19
+ }
20
+
21
+ console.log(`✓ SKILL.md frontmatter valid (${checked} files)`);
@@ -0,0 +1,135 @@
1
+ // SKILL.md frontmatter validation, shared by `pnpm lint` (via
2
+ // scripts/check-skill-frontmatter.mjs) and its tests.
3
+ //
4
+ // PI reads a skill's frontmatter with `parse` from the `yaml` package and
5
+ // silently drops the skill when that parse throws — only a warning diagnostic
6
+ // is emitted. A skill whose frontmatter is broken therefore disappears from the
7
+ // prompt with no visible failure. This module makes that failure loud: every
8
+ // SKILL.md must parse, declare a non-empty `name` and `description`, and keep
9
+ // the description within PI's 1024-character limit.
10
+ //
11
+ // Frontmatter extraction mirrors PI's parseFrontmatter (dist/utils/frontmatter.js), so a
12
+ // SKILL.md that PI drops — frontmatter that will not parse, or no description — fails here
13
+ // too. The check is stricter than PI in two places PI only warns about: `name` must be a
14
+ // non-empty frontmatter field (PI falls back to the directory name), and a description over
15
+ // 1024 characters is an error.
16
+ //
17
+ // Known gaps, each verified against PI's loader and left unhandled on purpose:
18
+ // - A symlinked SKILL.md is skipped: `entry.isFile()` is false for a symlink, while PI
19
+ // stats through the link and loads it. No symlinked SKILL.md exists in this repo.
20
+ // - Duplicate `name` values across skill roots are not detected: validation is per-file
21
+ // with no name index, while PI drops the loser of a name collision.
22
+ import { readdir, readFile } from "node:fs/promises";
23
+ import { join, relative } from "node:path";
24
+ import { parse } from "yaml";
25
+
26
+ /** PI's maximum description length — a longer description is rejected. */
27
+ export const MAX_DESCRIPTION_LENGTH = 1024;
28
+
29
+ const SKILL_FILE = "SKILL.md";
30
+ const SKIPPED_DIRS = new Set([".git", "node_modules"]);
31
+
32
+ /**
33
+ * Return the YAML text between the leading `---` delimiters, or null when the
34
+ * document has no frontmatter block. Mirrors PI's extraction step, including
35
+ * newline normalisation and BOM stripping.
36
+ */
37
+ function extractYaml(content) {
38
+ const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
39
+ if (!normalized.startsWith("---")) return null;
40
+
41
+ const endIndex = normalized.indexOf("\n---", 3);
42
+ if (endIndex === -1) return null;
43
+
44
+ return normalized.slice(4, endIndex);
45
+ }
46
+
47
+ /** True when `value` is a string with at least one non-whitespace character. */
48
+ function isNonEmptyString(value) {
49
+ return typeof value === "string" && value.trim() !== "";
50
+ }
51
+
52
+ /**
53
+ * Validate one SKILL.md document.
54
+ *
55
+ * @param {string} content - Raw SKILL.md contents.
56
+ * @returns {string[]} Human-readable problems; empty when the frontmatter is valid.
57
+ */
58
+ export function validateSkillFrontmatter(content) {
59
+ const yamlText = extractYaml(content);
60
+ if (yamlText === null) {
61
+ return ["frontmatter block is missing (expected '---' delimiters)"];
62
+ }
63
+
64
+ let frontmatter;
65
+ try {
66
+ frontmatter = parse(yamlText);
67
+ } catch (error) {
68
+ const message = error instanceof Error ? error.message.split("\n")[0] : "unknown parse error";
69
+ return [`frontmatter is not valid YAML: ${message}`];
70
+ }
71
+
72
+ if (frontmatter === null || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
73
+ return ["frontmatter is not a YAML mapping of name/description fields"];
74
+ }
75
+
76
+ const errors = [];
77
+ if (!isNonEmptyString(frontmatter.name)) {
78
+ errors.push("frontmatter is missing a non-empty name");
79
+ }
80
+ if (!isNonEmptyString(frontmatter.description)) {
81
+ errors.push("frontmatter is missing a non-empty description");
82
+ } else if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) {
83
+ errors.push(
84
+ `description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${frontmatter.description.length})`,
85
+ );
86
+ }
87
+
88
+ return errors;
89
+ }
90
+
91
+ /** Recursively collect every SKILL.md below `dir`, skipping vendored trees. */
92
+ async function findSkillFiles(dir) {
93
+ const found = [];
94
+ let entries;
95
+ try {
96
+ entries = await readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return found;
99
+ }
100
+
101
+ for (const entry of entries) {
102
+ const full = join(dir, entry.name);
103
+ if (entry.isDirectory()) {
104
+ if (SKIPPED_DIRS.has(entry.name)) continue;
105
+ found.push(...(await findSkillFiles(full)));
106
+ } else if (entry.isFile() && entry.name === SKILL_FILE) {
107
+ found.push(full);
108
+ }
109
+ }
110
+
111
+ return found;
112
+ }
113
+
114
+ /**
115
+ * Validate every SKILL.md under `root`.
116
+ *
117
+ * @param {{ root: string }} options
118
+ * @returns {Promise<{ checked: number, failures: Array<{ file: string, errors: string[] }> }>}
119
+ * `checked` counts the SKILL.md files examined; `failures` holds one entry per
120
+ * invalid file, with `file` relative to `root`.
121
+ */
122
+ export async function checkSkills({ root }) {
123
+ const files = await findSkillFiles(root);
124
+ const failures = [];
125
+
126
+ for (const file of files) {
127
+ const content = await readFile(file, "utf-8");
128
+ const errors = validateSkillFrontmatter(content);
129
+ if (errors.length > 0) {
130
+ failures.push({ file: relative(root, file), errors });
131
+ }
132
+ }
133
+
134
+ return { checked: files.length, failures };
135
+ }
@@ -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
@@ -137,7 +161,7 @@ Run whenever completion handling is triggered (an 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.
@@ -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` |
@@ -33,9 +33,9 @@ State model: task state lives in beads (ready/in_progress via `bd`); the only lo
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: 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)
@@ -62,8 +62,8 @@ On each completion notification (a background agent finished):
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 (a background agent finished):
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
@@ -109,7 +109,7 @@ On each completion notification (a background agent finished):
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
+ }
@@ -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
+ });