@jaybeeuu/agent-cortex 2.6.0 → 2.8.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 (64) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +102 -8
  3. package/agents/README.md +2 -2
  4. package/agents/ralph/agent.md +19 -16
  5. package/agents/ralph/claude/wake.md +4 -4
  6. package/agents/ralph/pi/wake.md +15 -20
  7. package/bin/agent-cortex.mjs +176 -67
  8. package/bin/installers/claude.mjs +33 -8
  9. package/bin/installers/pi.mjs +11 -1
  10. package/docs/skills/skill-anatomy.md +212 -0
  11. package/extensions/post-merge-watch/README.md +133 -0
  12. package/extensions/post-merge-watch/index.test.ts +247 -0
  13. package/extensions/post-merge-watch/index.ts +123 -0
  14. package/extensions/post-merge-watch/package.json +16 -0
  15. package/extensions/post-merge-watch/watch.test.ts +469 -0
  16. package/extensions/post-merge-watch/watch.ts +431 -0
  17. package/extensions/session-clear/README.md +74 -0
  18. package/extensions/session-clear/index.test.ts +524 -0
  19. package/extensions/session-clear/index.ts +159 -0
  20. package/extensions/session-clear/package.json +16 -0
  21. package/lib/cli.mjs +88 -1
  22. package/lib/harness-update.mjs +254 -0
  23. package/lib/harness-versions.mjs +36 -2
  24. package/lib/self-update.mjs +553 -0
  25. package/package.json +2 -1
  26. package/scripts/lib/docs.mjs +67 -0
  27. package/skills/engineering/implement-task/SKILL.md +124 -0
  28. package/skills/engineering/tdd/SKILL.md +2 -0
  29. package/skills/planning/create-task/REFERENCE.md +20 -3
  30. package/skills/planning/create-task/SKILL.md +1 -1
  31. package/skills/planning/create-task/pipeline.json +16 -0
  32. package/skills/planning/create-task/scripts/create-chores.mjs +51 -24
  33. package/skills/planning/create-task/templates/code.md +8 -3
  34. package/skills/planning/create-task/templates/document.md +8 -3
  35. package/skills/planning/create-task/templates/fix.md +8 -3
  36. package/skills/planning/create-task/templates/housekeeping.md +69 -0
  37. package/skills/planning/create-task/templates/post-merge.md +67 -0
  38. package/skills/planning/create-task/templates/review.md +8 -3
  39. package/skills/planning/create-task/templates/verify.md +8 -3
  40. package/skills/productivity/bd-tool/SKILL.md +55 -0
  41. package/skills/productivity/write-a-skill/SKILL.md +2 -2
  42. package/skills/review/refactor-skill/SKILL.md +2 -2
  43. package/skills/review/review-defects/EXAMPLES.md +122 -0
  44. package/skills/review/review-defects/SKILL.md +91 -0
  45. package/skills/style/style-tests/EXAMPLES.md +30 -0
  46. package/skills/style/style-tests/SKILL.md +10 -6
  47. package/skills/workflow/plan/SKILL.md +14 -3
  48. package/skills/workflow/ralph/REFERENCE.md +137 -30
  49. package/skills/workflow/ralph/SKILL.md +26 -16
  50. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  51. package/skills/workflow/run-pipeline-stage/SKILL.md +41 -23
  52. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  53. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  54. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  55. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +58 -0
  56. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  57. package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
  58. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  59. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +66 -10
  60. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  61. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  62. package/skills/workflow/using-agent-skills/SKILL.md +15 -7
  63. package/token-map.README.md +41 -4
  64. package/token-map.json +16 -5
@@ -0,0 +1,212 @@
1
+ # Skill Anatomy
2
+
3
+ This document is the **canonical skill anatomy** for `agent-cortex` skills.
4
+ Use it as the source of truth for `SKILL.md` structure, language, and review quality gates.
5
+
6
+ ## Front-matter spec
7
+
8
+ Required fields:
9
+ - `name`: kebab-case skill identifier.
10
+ - `description`: max 1024 characters.
11
+
12
+ Frontmatter is machine-validated: `pnpm lint` runs `check:skills`, which fails on a
13
+ frontmatter block that would make pi silently drop the skill.
14
+
15
+ Optional fields:
16
+ - `argument-hint`: input prompt shown to users at invocation.
17
+ - `disable-model-invocation`: hides the skill from automatic model routing; explicit
18
+ `/skill-name` invocation still works. Set it when the danger is a fuzzy description
19
+ match, e.g. a skill whose steps are destructive. Preserved and honoured by pi and
20
+ Claude Code. See `docs/research/disable-model-invocation.md`.
21
+
22
+ ## Description format rules
23
+
24
+ - Target ≤200 characters even though max is 1024.
25
+ - Write in third-person declarative voice.
26
+ - Sentence 1: what the skill does.
27
+ - Sentence 2: starts with `Use when ...`.
28
+ - Include 2-4 natural user trigger phrases (quoted) such as "diagnose this" or "something is broken".
29
+ - Prefer user vocabulary over internal jargon.
30
+
31
+ ## Voice and tone rules
32
+
33
+ - Write the body in imperative second-person: "Run X", "Check Y", "Report Z".
34
+ - Use positive framing; reserve "do not" only for hard safety/correctness boundaries.
35
+ - Add one rationale sentence for non-obvious rules so behavior generalizes in novel cases.
36
+ - Avoid ALL-CAPS emphasis.
37
+
38
+ ## Tool references
39
+
40
+ Reference agent tools by token, never by a hardcoded harness-specific name such as `bash`,
41
+ `task`, or `read_agent`. A tool token is the tool's canonical authoring name prefixed with
42
+ `TOOL:` and wrapped in doubled curly braces — two `{` before it and two `}` after it. The
43
+ wrapper is literal syntax: installers match it and substitute the per-harness name from
44
+ `token-map.json` at install time. Add any new tool to the map's `tools` section in the same
45
+ change; unknown tokens are hard errors. See `token-map.README.md` for the full authoring rules.
46
+
47
+ ## Required sections
48
+
49
+ Every skill must include:
50
+ 1. `## When to use`
51
+ 2. `## Workflow` (numbered steps)
52
+ 3. `## Verification checklist` (`- [ ]` items)
53
+
54
+ ## Optional sections
55
+
56
+ For each optional section, define **what it is**, include an **example snippet**, and state **when to include** and **when to omit**.
57
+
58
+ | Section | What it is | Example snippet | When to include | When to omit |
59
+ |---|---|---|---|---|
60
+ | `When NOT to use` | Explicit out-of-scope invocation boundaries | `- When NOT to use: single-line typo fixes with no process risk.` | Skills with similar neighbors where invocation precision matters | Truly unique one-purpose utility skills |
61
+ | `Red Flags` | Detectable anti-patterns the agent should self-correct during execution | `- Red flag: skipping boundary validation to save time.` | Discipline-enforcement skills (security, TDD, review) | Simple utility wrappers |
62
+ | `Common Rationalizations` | Excuses models use to skip process with rebuttals | `| "This is small" | Small changes still need invariants checked. |` | Process-critical skills where shortcut pressure is common | Mechanical one-step skills |
63
+ | `Philosophy / rationale` | Why the method exists and what trade-offs it protects | `- We front-load validation because downstream rollback cost is higher.` | Opinionated skills expected to handle novel variants | Purely mechanical instructions |
64
+ | `Phase-gate checklists` | Mid-workflow gates that must pass before proceeding | `- [ ] Phase 1 complete: baseline tests captured.` | Multi-phase workflows (3+ distinct phases) | Linear single-phase tasks |
65
+ | `See Also` | Explicit handoff to another skill for a real sub-task: a relative link to its `SKILL.md` plus the reason to load it | `- [review-security](../../review/review-security/SKILL.md) — run before committing.` | Another skill owns a sub-domain better than restating it | Self-contained skills |
66
+ | `Examples` | Full worked sample of expected outcome/output | `- Input: ... Output: ...` | Any non-trivial output contract | Trivially self-evident skills |
67
+ | `Workflow diagrams` | Inline ASCII diagram showing step flow and decision points | Fenced block of `[Box]`, `|`, `-`, `>`, `v` arrows, e.g. `[Start] --> [Validate] --> [Run]` | Multi-step or branching workflows where a glanceable flow improves comprehension | Simple linear workflows already clear from numbered steps |
68
+
69
+ `## See Also` entries are one-line handoffs: a relative link to the related skill's `SKILL.md`, an
70
+ em-dash, and the reason a reader of this skill would load that one. A bare skill name leaves the
71
+ reader guessing whether the link matters; the reason is what turns a link list into routing.
72
+
73
+ Write the path against the authored tree (`../../<group>/<skill>/SKILL.md` from a skill directory).
74
+ The Claude Code installer flattens skills and rewrites these targets at copy time, so a hand-written
75
+ link that resolves here resolves in both install layouts. List only genuine handoffs — inline calls
76
+ inside `## Workflow` already cover skills used as a step, and `using-agent-skills` already maps the
77
+ whole corpus, so a duplicate entry adds noise.
78
+
79
+ ## Workflow diagram style guide
80
+
81
+ When a skill includes a workflow diagram, follow these rules so it renders
82
+ reliably in any CLI:
83
+
84
+ - **ASCII only** — never Mermaid, images, or unicode box-drawing characters.
85
+ Keep to `|`, `-`, `>`, `+`, and `v`.
86
+ - **Fenced block** — wrap the diagram in a fenced code block so monospace is
87
+ preserved.
88
+ - **At most 80 characters per line** — narrow enough for terminal-width readers.
89
+ - **Short box labels** — imperative verbs or nouns (e.g. `[Validate scope]`),
90
+ never full sentences.
91
+ - **Read left-to-right, top-down** — prefer one branch level over dense joins.
92
+ - **Mirror the numbered workflow** — the diagram is a glanceable summary of the
93
+ `## Workflow` steps, never a source of truth that contradicts them.
94
+
95
+ ## Section ordering
96
+
97
+ ### When to use
98
+
99
+ State concrete invocation triggers using natural language phrases users actually type.
100
+
101
+ ### When NOT to use
102
+
103
+ Place this section immediately after `When to use` whenever present.
104
+
105
+ ### Workflow
106
+
107
+ Provide ordered, deterministic steps with observable outcomes.
108
+
109
+ ### Verification checklist
110
+
111
+ Provide completion gates that are testable and stage-scoped.
112
+
113
+ ## File layout and progressive disclosure
114
+
115
+ - Target ~150 lines for `SKILL.md`.
116
+ - Split long docs into purpose-named support files:
117
+ - `EXAMPLES.md` for worked examples.
118
+ - `FORMAT.md` for schemas/templates.
119
+ - `REFERENCE.md` for rare edge cases.
120
+ - Use slash-command references when another skill should be invoked.
121
+
122
+ ## Acceptance criteria checklist
123
+
124
+ A skill passes anatomy review when:
125
+ - [ ] Front matter uses required fields and valid description format.
126
+ - [ ] Tool references use doubled-brace `TOOL:` tokens, not hardcoded tool names.
127
+ - [ ] Required sections exist and follow canonical ordering.
128
+ - [ ] Optional sections (if present) include what/when/example guidance.
129
+ - [ ] `See Also` entries link the target `SKILL.md` relatively and state why to load it.
130
+ - [ ] Workflow diagrams (if present) are ASCII-only and follow the workflow diagram style guide.
131
+ - [ ] Verification checklist contains concrete, machine-checkable items.
132
+ - [ ] Supporting docs are split by purpose when `SKILL.md` would exceed ~150 lines.
133
+
134
+ ## Annotated template
135
+
136
+ ```md
137
+ ---
138
+ name: skill-name
139
+ description: This skill performs a specific capability. Use when a user asks for a matching workflow such as "triage this", "investigate this", or "prepare a handoff".
140
+ argument-hint: Optional prompt shown to users when extra input is needed.
141
+ disable-model-invocation: false
142
+ ---
143
+
144
+ # Skill Name
145
+
146
+ ## When to use
147
+
148
+ - Trigger phrases users actually say, e.g. "triage this failure".
149
+ - Include 2-4 concrete patterns.
150
+
151
+ ## When NOT to use
152
+
153
+ - Out-of-scope cases that should route elsewhere.
154
+
155
+ ## Workflow
156
+
157
+ 1. Gather required context and validate scope boundaries.
158
+ 2. Reject early with the reason when validation fails; otherwise continue.
159
+ 3. Execute deterministic steps in order, recording key decisions.
160
+ 4. Produce the required output artifact or terminal response.
161
+
162
+ Workflow at a glance (include only when the flow branches):
163
+
164
+ [Trigger]
165
+ |
166
+ v
167
+ [Gather context]
168
+ |
169
+ v
170
+ [Validate scope] --(invalid)--> [Reject early]
171
+ |
172
+ (valid)
173
+ v
174
+ [Run deterministic steps]
175
+ |
176
+ v
177
+ [Produce output artifact]
178
+
179
+ ## Red Flags
180
+
181
+ - Red flag: skipping validation of unknown external input.
182
+
183
+ ## Common Rationalizations
184
+
185
+ | Rationalization | Rebuttal |
186
+ |---|---|
187
+ | "This is too small for process" | Process protects against regressions regardless of size. |
188
+
189
+ ## Philosophy / rationale
190
+
191
+ - We optimize for explicit boundaries because they reduce rework and improve reliability.
192
+
193
+ ## Phase-gate checklist
194
+
195
+ - [ ] Phase 1 complete and baseline checks captured.
196
+ - [ ] Phase 2 complete and contract validation passed.
197
+
198
+ ## See Also
199
+
200
+ - [review-security](../../review/review-security/SKILL.md) — run before committing any externally exposed change.
201
+
202
+ ## Examples
203
+
204
+ Input: "Investigate this flaky suite"
205
+ Output: concise root-cause summary, failing tests list, and next action.
206
+
207
+ ## Verification checklist
208
+
209
+ - [ ] Output matches requested format.
210
+ - [ ] Required tests/lints were run and results recorded.
211
+ - [ ] No unrelated files were changed.
212
+ ```
@@ -0,0 +1,133 @@
1
+ # Post-Merge Watch Extension
2
+
3
+ Wakes an idle PI session with the verdict of a post-merge CI run.
4
+
5
+ A human merges the feature PR, and the CI run for the merge commit decides what
6
+ happens next: nothing, a fix, or a revert. That merge lands outside the session,
7
+ so nothing tells the agent when the run concludes. This extension polls the
8
+ deterministic post-merge check and delivers the verdict as a follow-up message,
9
+ which starts a turn in an idle session.
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ pi --post-merge-watch <bead>
15
+ ```
16
+
17
+ | Input | Meaning |
18
+ | --- | --- |
19
+ | `--post-merge-watch <bead>` | Watch the post-merge check for this bead and wake the session with the verdict |
20
+ | `AGENT_CORTEX_POST_MERGE_CHECK` | Path of the check script, when the shipped one is not the one to run |
21
+
22
+ The flag is registered at load time, so it is accepted on the command line. A
23
+ session without the flag starts no watch, spawns no process, and writes no state.
24
+
25
+ The default script is `skills/workflow/ralph/scripts/post-merge-check.mjs`,
26
+ resolved relative to this extension's own file. In a staged install
27
+ (`extensions/` beside `skills/`) and in a checkout of this repository the same
28
+ relative path resolves, so no absolute path is baked in.
29
+
30
+ ## What it does
31
+
32
+ 1. At session start, with the flag set, it records the watch and runs the check.
33
+ 2. It runs the check every 15 seconds, up to 40 times (about 10 minutes).
34
+ 3. When the check exits `0` (passed) or `1` (failed), or the attempt cap is
35
+ reached, it sends one message and stops watching.
36
+
37
+ Each invocation passes `--wait 0`, so the script reports once instead of waiting
38
+ inside itself: the poll interval and the bound belong to this extension, and a
39
+ stopped watch never blocks on a running child process.
40
+
41
+ The wake uses the confirmed PI signature
42
+ `pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true })`: an idle
43
+ session starts a turn from it, and a session in the middle of a turn reads it
44
+ after that turn instead of being interrupted.
45
+
46
+ The message names the bead, the verdict, the exit code, the reason, the run URL,
47
+ the project, and the number of checks made. It carries the same fields as message
48
+ `details`, so a renderer can read them without parsing the text.
49
+
50
+ ## What it never does
51
+
52
+ - **No verdict logic.** The check's exit code is the verdict: `0` passed, `1`
53
+ failed, every other code (including a timeout kill) inconclusive. The run URL
54
+ and the reason are read from the script's JSON report and forwarded verbatim.
55
+ The extension never decides whether a run passed and never re-runs a workflow.
56
+ - **No model call.** No prompt, no summarisation, no title generation. One turn
57
+ happens only because the delivered message starts one.
58
+ - **No `@earendil-works/*` value import.** Types only, so the staged install
59
+ loads with pi absent from the extension's own `node_modules`
60
+ (`test/pi-extensions-load.test.mjs` proves this).
61
+ - **No second watch for the same bead.** A session reload re-fires
62
+ `session_start`; the extension starts one watch per bead.
63
+
64
+ ## State
65
+
66
+ | Item | Value |
67
+ | --- | --- |
68
+ | Directory | `~/.pi/agent-cortex/post-merge-watch/` |
69
+ | File name | `<project dir name>-<bead>-<digest of full project path and bead>.json` |
70
+ | Written | At watch start, then after each poll |
71
+ | Removed | When the watch settles, when the session shuts down, and again if a write was still in flight |
72
+
73
+ The digest covers the whole project path, so two checkouts whose directory names
74
+ match — or two projects watching the same bead — never share a file. A watch that
75
+ is still polling is the only thing the directory should hold.
76
+
77
+ The write and the removal can race: a stop removes the file, and a write that is
78
+ still in flight then recreates it. The write therefore re-checks whether the
79
+ watch stopped once it completes, and removes the file again, so a stop cannot
80
+ leave a state file behind. One narrow case survives: a write that **fails** while
81
+ a stop is in progress returns before that re-check, so it can leave a partial
82
+ file. No code reads the directory, so a partial file is cosmetic; do not read it
83
+ as a live watch.
84
+
85
+ The in-flight record is diagnostic: it names the bead, the project, the script,
86
+ the pid, the attempt count, and the last exit code and reason. Nothing reads it
87
+ back, so a watch never resumes from it; a new session with the same flag starts a
88
+ new watch.
89
+
90
+ ## Bounds and shutdown
91
+
92
+ | Bound | Value |
93
+ | --- | --- |
94
+ | Poll interval | 15 s |
95
+ | Checks | 40 |
96
+ | One check invocation | 120 s, then pi kills it |
97
+
98
+ The pending timer is unref'd, so a watch never holds the host process open. On
99
+ `session_shutdown` the timer is cancelled, a check already in flight is ignored,
100
+ and the state file is removed: an abandoned watch wakes nobody and leaves nothing
101
+ behind.
102
+
103
+ ## What has been proven
104
+
105
+ The wake path has been run once end to end against a stub check script: the RPC
106
+ wake itself was reproduced (`agent_settled` → `agent_start`, the custom message
107
+ arriving with no client prompt), and a real model turn ran on it. That run did not
108
+ read a real CI run, because the shipped `post-merge-check.mjs` was replaced by a
109
+ stub: the poll-and-wake path is proven, and the check's own judgement is covered
110
+ by `test/post-merge-check.test.mjs`, not by any test here. Treat the pair as one
111
+ path only after a watch has read a genuine merge-commit run.
112
+
113
+ ## Claude Code parity
114
+
115
+ PI-only. Claude Code has its own watch tools, which the Claude side uses instead of
116
+ an extension.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ pnpm test # the poll lifecycle, the wake payload, the state file, the wiring
122
+ pnpm typecheck # tsc --noEmit
123
+ ```
124
+
125
+ `watch.test.ts` drives the whole lifecycle with a scheduler it steps by hand and a
126
+ check runner that returns scripted exit codes, so no test sleeps or waits on a
127
+ timer. `index.test.ts` drives the extension through a partial `ExtensionAPI`
128
+ double; the complete-literal guard that fails `tsc` when pi's interface grows is
129
+ `extensions/agent-modes/index.test.ts`.
130
+
131
+ To watch the poll end to end against a script that is not the shipped one, point
132
+ `AGENT_CORTEX_POST_MERGE_CHECK` at any executable that follows the check's
133
+ contract: JSON on stdout and exit `0`/`1`/`2`.
@@ -0,0 +1,247 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readdir, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { createTempRoot } from "../../test/helpers/temp-root.mjs";
8
+ import postMergeWatchExtension, { CHECK_SCRIPT_ENV, WATCH_FLAG, checkScript } from "./index.ts";
9
+
10
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
11
+
12
+ const tempRoot = createTempRoot("post-merge-watch-index-");
13
+
14
+ interface FlagRegistration {
15
+ name: string;
16
+ options: { description?: string; type: "string" | "boolean"; default?: string | boolean };
17
+ }
18
+
19
+ interface SentMessage {
20
+ message: { customType: string; content: string | unknown[]; display: boolean; details?: unknown };
21
+ options: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" } | undefined;
22
+ }
23
+
24
+ interface ExecCall {
25
+ command: string;
26
+ args: string[];
27
+ options: { cwd?: string; timeout?: number } | undefined;
28
+ }
29
+
30
+ /** An event handler as `pi.on` recorded it, called through `Reflect.apply`. */
31
+ type RecordedHandler = (...args: never[]) => unknown;
32
+
33
+ interface FakePi {
34
+ pi: ExtensionAPI;
35
+ flags: FlagRegistration[];
36
+ sent: SentMessage[];
37
+ execs: ExecCall[];
38
+ notifications: Array<{ message: string; type: string | undefined }>;
39
+ /** Fire every handler pi registered for `event`. */
40
+ fire(event: string, payload: unknown, context: unknown): Promise<void>;
41
+ }
42
+
43
+ /**
44
+ * A double for the five `ExtensionAPI` members this extension reaches.
45
+ *
46
+ * Deliberately partial: the complete-literal guard is
47
+ * `extensions/agent-modes/index.test.ts`, whose fake fails `tsc` when pi's
48
+ * interface grows a member.
49
+ */
50
+ function createFakePi(options: {
51
+ flag: string | undefined;
52
+ scriptExitCode: number;
53
+ scriptStdout?: string;
54
+ }): FakePi {
55
+ const flags: FlagRegistration[] = [];
56
+ const sent: SentMessage[] = [];
57
+ const execs: ExecCall[] = [];
58
+ const notifications: Array<{ message: string; type: string | undefined }> = [];
59
+ const handlers = new Map<string, RecordedHandler[]>();
60
+
61
+ const pi = {
62
+ registerFlag: (name: string, registration: FlagRegistration["options"]) => {
63
+ flags.push({ name, options: registration });
64
+ },
65
+ getFlag: (name: string) => (name === WATCH_FLAG ? options.flag : undefined),
66
+ on: (event: string, handler: RecordedHandler) => {
67
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
68
+ return () => {};
69
+ },
70
+ exec: async (command: string, args: string[], execOptions: ExecCall["options"]) => {
71
+ execs.push({ command, args, options: execOptions });
72
+ return {
73
+ stdout: options.scriptStdout ?? "",
74
+ stderr: "",
75
+ code: options.scriptExitCode,
76
+ killed: false,
77
+ truncated: false,
78
+ };
79
+ },
80
+ sendMessage: (message: SentMessage["message"], sendOptions?: SentMessage["options"]) => {
81
+ sent.push({ message, options: sendOptions });
82
+ },
83
+ } as unknown as ExtensionAPI;
84
+
85
+ return {
86
+ pi,
87
+ flags,
88
+ sent,
89
+ execs,
90
+ notifications,
91
+ async fire(event, payload, context) {
92
+ for (const handler of handlers.get(event) ?? []) {
93
+ await Reflect.apply(handler, undefined, [
94
+ payload,
95
+ {
96
+ cwd: "/projects/one",
97
+ ui: { notify: (message: string, type?: string) => notifications.push({ message, type }) },
98
+ ...(context as object),
99
+ },
100
+ ]);
101
+ }
102
+ },
103
+ };
104
+ }
105
+
106
+ /** The startup event pi sends before a session is usable. */
107
+ const SESSION_START = { type: "session_start", reason: "startup" } as const;
108
+
109
+ /** Run one session start with `HOME` pointed at a temp root, so no test reads the real home. */
110
+ async function withTempHome<T>(run: (home: string) => Promise<T>): Promise<T> {
111
+ const home = await tempRoot.createWorkspace();
112
+ const previousHome = process.env.HOME;
113
+ process.env.HOME = home;
114
+ try {
115
+ return await run(home);
116
+ } finally {
117
+ process.env.HOME = previousHome;
118
+ }
119
+ }
120
+
121
+ /** The state directory the extension uses for a given home. */
122
+ function stateDirFor(home: string): string {
123
+ return join(home, ".pi", "agent-cortex", "post-merge-watch");
124
+ }
125
+
126
+ async function exists(path: string): Promise<boolean> {
127
+ try {
128
+ await stat(path);
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ // ─── Flag registration ───────────────────────────────────────────────────────
136
+
137
+ describe("post-merge-watch extension", () => {
138
+ it("registers its CLI flag in the factory, before any session starts", async () => {
139
+ const fake = createFakePi({ flag: undefined, scriptExitCode: 2 });
140
+
141
+ postMergeWatchExtension(fake.pi);
142
+
143
+ assert.deepEqual(
144
+ fake.flags.map((flag) => ({ name: flag.name, type: flag.options.type })),
145
+ [{ name: "post-merge-watch", type: "string" }],
146
+ );
147
+ });
148
+
149
+ it("resolves the shipped check script without a hardcoded absolute path", () => {
150
+ const previous = process.env[CHECK_SCRIPT_ENV];
151
+ delete process.env[CHECK_SCRIPT_ENV];
152
+ try {
153
+ assert.match(checkScript(), /skills[/\\]workflow[/\\]ralph[/\\]scripts[/\\]post-merge-check\.mjs$/);
154
+ } finally {
155
+ if (previous !== undefined) process.env[CHECK_SCRIPT_ENV] = previous;
156
+ }
157
+ });
158
+
159
+ it("polls the script the environment names, so an install can be pointed at a stub", async () => {
160
+ const previous = process.env[CHECK_SCRIPT_ENV];
161
+ process.env[CHECK_SCRIPT_ENV] = "/stub/post-merge-check.mjs";
162
+ try {
163
+ assert.equal(checkScript(), "/stub/post-merge-check.mjs");
164
+ } finally {
165
+ if (previous === undefined) delete process.env[CHECK_SCRIPT_ENV];
166
+ else process.env[CHECK_SCRIPT_ENV] = previous;
167
+ }
168
+ });
169
+
170
+ // ─── Session lifecycle ─────────────────────────────────────────────────────
171
+
172
+ it("starts one watch at session start and wakes the session when the check settles", async () => {
173
+ await withTempHome(async (home) => {
174
+ const fake = createFakePi({
175
+ flag: "agnt-ctx-86u4.2",
176
+ scriptExitCode: 1,
177
+ scriptStdout: JSON.stringify({ reason: "run-failed", runs: [{ url: "https://gh/run/9" }] }),
178
+ });
179
+ postMergeWatchExtension(fake.pi);
180
+
181
+ await fake.fire("session_start", SESSION_START, {});
182
+
183
+ assert.equal(fake.execs.length, 1);
184
+ assert.deepEqual(
185
+ { args: fake.execs[0].args.slice(1), cwd: fake.execs[0].options?.cwd },
186
+ { args: ["--wait", "0"], cwd: "/projects/one" },
187
+ );
188
+ assert.equal(fake.sent.length, 1);
189
+ assert.deepEqual(fake.sent[0].options, { deliverAs: "followUp", triggerTurn: true });
190
+ assert.match(fake.sent[0].message.content as string, /agnt-ctx-86u4\.2/);
191
+ assert.deepEqual(fake.sent[0].message.details, {
192
+ bead: "agnt-ctx-86u4.2",
193
+ verdict: "failed",
194
+ runUrl: "https://gh/run/9",
195
+ exitCode: 1,
196
+ reason: "run-failed",
197
+ attempts: 1,
198
+ project: "/projects/one",
199
+ });
200
+ });
201
+ });
202
+
203
+ it("starts no watch when the flag is absent", async () => {
204
+ await withTempHome(async (home) => {
205
+ const fake = createFakePi({ flag: undefined, scriptExitCode: 1 });
206
+ postMergeWatchExtension(fake.pi);
207
+
208
+ await fake.fire("session_start", SESSION_START, {});
209
+
210
+ assert.deepEqual(fake.execs, []);
211
+ assert.equal(await exists(stateDirFor(home)), false);
212
+ });
213
+ });
214
+
215
+ it("records the watch under the agent-cortex state directory, then clears it on session shutdown", async () => {
216
+ await withTempHome(async (home) => {
217
+ const fake = createFakePi({ flag: "agnt-ctx-86u4.2", scriptExitCode: 2 });
218
+ postMergeWatchExtension(fake.pi);
219
+
220
+ await fake.fire("session_start", SESSION_START, {});
221
+
222
+ assert.deepEqual(
223
+ (await readdir(stateDirFor(home))).length,
224
+ 1,
225
+ "the in-flight watch is recorded under ~/.pi/agent-cortex",
226
+ );
227
+ assert.equal(fake.sent.length, 0, "no verdict yet, so no wake");
228
+
229
+ await fake.fire("session_shutdown", { type: "session_shutdown", reason: "quit" }, {});
230
+
231
+ assert.deepEqual(await readdir(stateDirFor(home)), [], "an abandoned watch leaves no state file");
232
+ assert.deepEqual(fake.sent, [], "an abandoned watch wakes nobody");
233
+ });
234
+ });
235
+
236
+ it("starts no second watch when the session reloads for the same bead", async () => {
237
+ await withTempHome(async () => {
238
+ const fake = createFakePi({ flag: "agnt-ctx-86u4.2", scriptExitCode: 2 });
239
+ postMergeWatchExtension(fake.pi);
240
+
241
+ await fake.fire("session_start", SESSION_START, {});
242
+ await fake.fire("session_start", { type: "session_start", reason: "reload" }, {});
243
+
244
+ assert.equal(fake.execs.length, 1);
245
+ });
246
+ });
247
+ });