@jaybeeuu/agent-cortex 2.7.0 → 2.9.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 (87) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +87 -8
  3. package/agents/README.md +4 -4
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph/agent.md +17 -16
  9. package/agents/ralph/claude/wake.md +4 -4
  10. package/agents/ralph/pi/wake.md +15 -20
  11. package/agents/ralph-plan/agent.md +6 -0
  12. package/bin/agent-cortex.mjs +20 -1
  13. package/bin/installers/claude.mjs +1 -20
  14. package/bin/installers/pi.mjs +8 -2
  15. package/extensions/interrupt-bookmark/README.md +129 -0
  16. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  17. package/extensions/interrupt-bookmark/index.ts +354 -0
  18. package/extensions/interrupt-bookmark/package.json +20 -0
  19. package/extensions/post-merge-watch/README.md +133 -0
  20. package/extensions/post-merge-watch/index.test.ts +247 -0
  21. package/extensions/post-merge-watch/index.ts +123 -0
  22. package/extensions/post-merge-watch/package.json +16 -0
  23. package/extensions/post-merge-watch/watch.test.ts +469 -0
  24. package/extensions/post-merge-watch/watch.ts +431 -0
  25. package/extensions/session-clear/README.md +74 -0
  26. package/extensions/session-clear/index.test.ts +524 -0
  27. package/extensions/session-clear/index.ts +159 -0
  28. package/extensions/session-clear/package.json +16 -0
  29. package/extensions/skill-stats/README.md +84 -3
  30. package/extensions/skill-stats/index.ts +62 -3
  31. package/extensions/skill-stats/package.json +1 -1
  32. package/extensions/skill-stats/rollup.test.ts +643 -0
  33. package/extensions/skill-stats/rollup.ts +415 -0
  34. package/extensions/skill-stats/timeline.ts +101 -0
  35. package/lib/cli.mjs +42 -1
  36. package/lib/self-update.mjs +553 -0
  37. package/package.json +1 -1
  38. package/scripts/lib/manifest.mjs +29 -0
  39. package/skills/engineering/implement-task/SKILL.md +124 -0
  40. package/skills/engineering/tdd/SKILL.md +2 -0
  41. package/skills/planning/create-task/REFERENCE.md +15 -6
  42. package/skills/planning/create-task/SKILL.md +1 -1
  43. package/skills/planning/create-task/pipeline.json +8 -0
  44. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  45. package/skills/planning/create-task/templates/code.md +8 -3
  46. package/skills/planning/create-task/templates/document.md +8 -3
  47. package/skills/planning/create-task/templates/fix.md +8 -3
  48. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  49. package/skills/planning/create-task/templates/post-merge.md +67 -0
  50. package/skills/planning/create-task/templates/review.md +8 -3
  51. package/skills/planning/create-task/templates/verify.md +8 -3
  52. package/skills/productivity/bd-tool/SKILL.md +55 -0
  53. package/skills/review/review-defects/EXAMPLES.md +198 -0
  54. package/skills/review/review-defects/SKILL.md +121 -0
  55. package/skills/review/review-report/EXAMPLES.md +114 -0
  56. package/skills/review/review-report/SKILL.md +138 -0
  57. package/skills/review/review-report/scripts/report.mjs +1032 -0
  58. package/skills/style/style-tests/EXAMPLES.md +30 -0
  59. package/skills/style/style-tests/SKILL.md +10 -6
  60. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  61. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  62. package/skills/workflow/plan/SKILL.md +27 -3
  63. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  64. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  65. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  66. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  67. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  68. package/skills/workflow/ralph/REFERENCE.md +124 -27
  69. package/skills/workflow/ralph/SKILL.md +24 -15
  70. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  71. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  72. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  73. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  74. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  75. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  76. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  80. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  81. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  82. package/skills/workflow/score-bead/FORMAT.md +73 -0
  83. package/skills/workflow/score-bead/SKILL.md +112 -0
  84. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  85. package/skills/workflow/using-agent-skills/SKILL.md +21 -8
  86. package/token-map.README.md +27 -0
  87. package/token-map.json +16 -4
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Interrupt bookmark extension.
3
+ *
4
+ * An interruption mid-task — a meeting note, PR feedback to pass on, a Slack
5
+ * todo — drags its turns into the context that the original task then has to
6
+ * carry. PI can rewind a session tree and summarise the abandoned branch, but
7
+ * nothing exposes that as a deliberate operation. This extension adds two tools:
8
+ *
9
+ * - `interrupt_checkpoint` captures the current leaf id and returns it, so the
10
+ * caller holds a resume point;
11
+ * - `interrupt_resume` rewinds to that id, keeps the abandoned branch under a
12
+ * summary, and labels the summary entry `interrupt`.
13
+ *
14
+ * The summary comes from pi's own tree-navigation summariser
15
+ * (`navigateTree(..., { summarize: true })`). This extension makes no model call
16
+ * of its own. Nothing is deleted: the abandoned branch stays in the session
17
+ * file, reachable from the session tree.
18
+ *
19
+ * ## Why the rewind runs in two steps
20
+ *
21
+ * PI exposes `waitForIdle()` and `navigateTree()` on `ExtensionCommandContext`
22
+ * only, because they can deadlock when called from a handler that runs inside a
23
+ * turn (`docs/extensions.md`, "ExtensionCommandContext"). A tool receives
24
+ * `ExtensionToolContext`, and the session throws on navigation while it is
25
+ * streaming — which it always is while a tool call is running. A tool therefore
26
+ * cannot navigate the tree, and waiting for idle inside a tool call could never
27
+ * finish.
28
+ *
29
+ * So the resume tool validates the request, records it, and ends the turn. An
30
+ * `agent_settled` handler then dispatches the command that performs the rewind,
31
+ * which is the pattern pi documents for a tool that must reach a command-only
32
+ * API (`docs/extensions.md`, "ctx.reload()"). One consequence is worth stating:
33
+ * the summary entry is reported to the human and written to the session as a
34
+ * custom entry, not returned in the tool result — the tool result belongs to the
35
+ * branch that the rewind summarises.
36
+ */
37
+
38
+ import type {
39
+ ExtensionAPI,
40
+ ExtensionCommandContext,
41
+ ExtensionToolContext,
42
+ } from "@earendil-works/pi-coding-agent";
43
+ import { Type } from "typebox";
44
+
45
+ /** The tool that captures a resume point. */
46
+ export const CHECKPOINT_TOOL = "interrupt_checkpoint";
47
+
48
+ /** The tool that asks for a rewind. */
49
+ export const RESUME_TOOL = "interrupt_resume";
50
+
51
+ /** The command that performs the rewind, dispatched once the turn settles. */
52
+ export const REWIND_COMMAND = "interrupt-rewind";
53
+
54
+ /** The label pi attaches to the branch-summary entry a rewind leaves behind. */
55
+ export const SUMMARY_LABEL = "interrupt";
56
+
57
+ /** Custom-entry type recording the outcome. Never part of the model's context. */
58
+ export const REWIND_ENTRY = "interrupt-rewind";
59
+
60
+ /**
61
+ * How many times a rewind is attempted: the first try, then exactly one retry.
62
+ * A second failure is reported, never retried again.
63
+ */
64
+ export const REWIND_ATTEMPTS = 2;
65
+
66
+ /** A rewind waiting for the turn to settle. */
67
+ export interface RewindRequest {
68
+ /** The entry id the caller wants to return to. */
69
+ checkpointId: string;
70
+ /** What the interruption was about, as summariser instructions. */
71
+ summary: string;
72
+ }
73
+
74
+ /** What a rewind did, or why it did nothing. */
75
+ export interface RewindReport {
76
+ ok: boolean;
77
+ /** How many navigation attempts were made. */
78
+ attempts: number;
79
+ /** The `branch_summary` entry the rewind left behind, when there is one. */
80
+ summaryEntryId?: string;
81
+ /** True when pi cancelled the navigation (an extension or the user declined). */
82
+ cancelled?: boolean;
83
+ /** Why the last attempt failed. */
84
+ reason?: string;
85
+ }
86
+
87
+ /** The parts of the command context a rewind uses. */
88
+ type RewindContext = Pick<
89
+ ExtensionCommandContext,
90
+ "waitForIdle" | "navigateTree" | "sessionManager"
91
+ >;
92
+
93
+ /** The parts of the tool context the two tools read. */
94
+ type BookmarkContext = Pick<ExtensionToolContext, "sessionManager">;
95
+
96
+ /** Instructions for pi's summariser: what the interruption was, and why it is summarised. */
97
+ export function summariserInstructions(summary: string): string {
98
+ return [
99
+ "The task was interrupted. Record what the interruption was about, and what the abandoned turns",
100
+ "were doing, so the task can resume without them.",
101
+ `The interruption: ${summary}`,
102
+ ].join(" ");
103
+ }
104
+
105
+ function errorMessage(error: unknown): string {
106
+ return error instanceof Error ? error.message : String(error);
107
+ }
108
+
109
+ /**
110
+ * The branch-summary entry a navigation from `previousLeaf` appended, if any.
111
+ *
112
+ * `navigateTree()` returns this entry, but `ExtensionCommandContext` declares
113
+ * only `cancelled` in its result type, and after the call the leaf is the label
114
+ * entry pi appends on top of the summary. The entry is therefore found by the
115
+ * `fromId` pi records on it — no cast, and no assumption about the leaf.
116
+ */
117
+ function summaryAfter(
118
+ sessionManager: RewindContext["sessionManager"],
119
+ previousLeaf: string | null,
120
+ ): string | undefined {
121
+ const entries = sessionManager.getEntries();
122
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
123
+ const entry = entries[index];
124
+ if (entry.type === "branch_summary" && entry.fromId === previousLeaf) return entry.id;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * Rewind to `checkpointId`, summarising the abandoned branch.
131
+ *
132
+ * Navigation rejects while a response, a compaction or another navigation is
133
+ * active, so each attempt waits for idle first. Waits and attempts are
134
+ * deliberately paired: an attempt that skips the wait fails for the reason the
135
+ * wait exists.
136
+ */
137
+ export async function rewind(ctx: RewindContext, request: RewindRequest): Promise<RewindReport> {
138
+ let reason = "";
139
+
140
+ for (let attempt = 1; attempt <= REWIND_ATTEMPTS; attempt += 1) {
141
+ await ctx.waitForIdle();
142
+
143
+ // Captured before the navigation, so the summary it writes can be found by
144
+ // the branch it abandoned.
145
+ const previousLeaf = ctx.sessionManager.getLeafId();
146
+
147
+ try {
148
+ const result = await ctx.navigateTree(request.checkpointId, {
149
+ summarize: true,
150
+ customInstructions: summariserInstructions(request.summary),
151
+ label: SUMMARY_LABEL,
152
+ });
153
+
154
+ return {
155
+ ok: true,
156
+ attempts: attempt,
157
+ cancelled: result.cancelled,
158
+ summaryEntryId: summaryAfter(ctx.sessionManager, previousLeaf),
159
+ };
160
+ } catch (error) {
161
+ reason = errorMessage(error);
162
+ }
163
+ }
164
+
165
+ return { ok: false, attempts: REWIND_ATTEMPTS, reason };
166
+ }
167
+
168
+ /**
169
+ * Why this request cannot be rewound, or undefined when it can.
170
+ *
171
+ * An unknown id is refused here, before anything is queued or written, so the
172
+ * session is untouched and the message names the id that failed.
173
+ */
174
+ export function refusal(ctx: BookmarkContext, request: RewindRequest): string | undefined {
175
+ if (ctx.sessionManager.getEntry(request.checkpointId) === undefined) {
176
+ return `Unknown checkpoint id: ${request.checkpointId}. Call ${CHECKPOINT_TOOL} to record a resume point.`;
177
+ }
178
+
179
+ if (ctx.sessionManager.getLeafId() === request.checkpointId) {
180
+ return `Checkpoint ${request.checkpointId} is the current position, so there are no interrupt turns to summarise. Nothing was rewound.`;
181
+ }
182
+
183
+ return undefined;
184
+ }
185
+
186
+ export default function interruptBookmarkExtension(pi: ExtensionAPI): void {
187
+ /**
188
+ * The rewind the next settle must dispatch. At most one: a second would
189
+ * navigate from a position the first has already replaced.
190
+ */
191
+ let pending: RewindRequest | undefined;
192
+
193
+ /**
194
+ * The summary waiting for its dispatch, keyed by checkpoint id. The command
195
+ * receives the id in its argument text and the summary from here, so a summary
196
+ * with spaces or newlines never has to survive a command line. One entry at a
197
+ * time: a new request replaces the previous one.
198
+ */
199
+ const handoff = new Map<string, string>();
200
+
201
+ pi.registerTool({
202
+ name: CHECKPOINT_TOOL,
203
+ label: "Interrupt Checkpoint",
204
+ description:
205
+ "Record the current session position and return its id, so an interruption can be rewound later.",
206
+ promptSnippet: `${CHECKPOINT_TOOL}: record a resume point before handling an interruption`,
207
+ promptGuidelines: [
208
+ `Use ${CHECKPOINT_TOOL} before an interruption is handled, and ${RESUME_TOOL} to return to it afterwards.`,
209
+ ],
210
+ parameters: Type.Object({}),
211
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
212
+ const leafId = ctx.sessionManager.getLeafId();
213
+
214
+ if (leafId === null) {
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text" as const,
219
+ text: "The session has no entries yet, so there is no position to record.",
220
+ },
221
+ ],
222
+ details: { checkpointId: null },
223
+ };
224
+ }
225
+
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text" as const,
230
+ text: `Checkpoint ${leafId}. Pass this id to ${RESUME_TOOL} to rewind here.`,
231
+ },
232
+ ],
233
+ details: { checkpointId: leafId },
234
+ };
235
+ },
236
+ });
237
+
238
+ pi.registerTool({
239
+ name: RESUME_TOOL,
240
+ label: "Interrupt Resume",
241
+ description:
242
+ "Rewind to a checkpoint, keep the abandoned branch under a summary, then continue from the checkpoint. Runs after the current turn ends.",
243
+ promptSnippet: `${RESUME_TOOL}: rewind to a checkpoint and summarise the interruption away`,
244
+ parameters: Type.Object({
245
+ checkpointId: Type.String({
246
+ description: `The id ${CHECKPOINT_TOOL} returned.`,
247
+ }),
248
+ summary: Type.String({
249
+ description: "What the interruption was about, so the summariser records it.",
250
+ }),
251
+ }),
252
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
253
+ if (pending !== undefined) {
254
+ throw new Error(
255
+ `A rewind to ${pending.checkpointId} is already scheduled. Nothing was changed.`,
256
+ );
257
+ }
258
+
259
+ const problem = refusal(ctx, params);
260
+ if (problem !== undefined) {
261
+ // A thrown message reaches the model as the tool's error result, and
262
+ // nothing has been written or queued at this point.
263
+ throw new Error(problem);
264
+ }
265
+
266
+ pending = { checkpointId: params.checkpointId, summary: params.summary };
267
+
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text" as const,
272
+ text:
273
+ `Rewind to ${params.checkpointId} scheduled. The abandoned branch is kept under a ` +
274
+ `summary labelled "${SUMMARY_LABEL}" once this turn ends; this tool call is part of it.`,
275
+ },
276
+ ],
277
+ details: { checkpointId: params.checkpointId, scheduled: true },
278
+ // The rewind needs the turn over, so end it rather than letting the model
279
+ // keep working in a context that is about to be rewound.
280
+ terminate: true,
281
+ };
282
+ },
283
+ });
284
+
285
+ // Command handlers are the only place pi allows tree navigation. The dispatch
286
+ // uses `expandPromptTemplates`, which runs the command instead of prompting the
287
+ // model, so nothing is sent to a provider here.
288
+ pi.registerCommand(REWIND_COMMAND, {
289
+ description: "Rewind to a scheduled interrupt checkpoint and summarise the abandoned branch",
290
+ handler: async (args, ctx) => {
291
+ const checkpointId = args.trim();
292
+
293
+ if (checkpointId === "") {
294
+ ctx.ui.notify("No checkpoint was named, so nothing was rewound.", "warning");
295
+ return;
296
+ }
297
+
298
+ const summary = handoff.get(checkpointId);
299
+ if (summary === undefined) {
300
+ ctx.ui.notify(
301
+ `No interrupt rewind is pending for ${checkpointId}, so nothing was rewound.`,
302
+ "warning",
303
+ );
304
+ return;
305
+ }
306
+ handoff.delete(checkpointId);
307
+
308
+ const report = await rewind(ctx, { checkpointId, summary });
309
+ pi.appendEntry(REWIND_ENTRY, {
310
+ checkpointId,
311
+ attempts: report.attempts,
312
+ ok: report.ok,
313
+ cancelled: report.cancelled ?? false,
314
+ summaryEntryId: report.summaryEntryId,
315
+ reason: report.reason,
316
+ });
317
+
318
+ if (!report.ok) {
319
+ ctx.ui.notify(
320
+ `Interrupt rewind to ${checkpointId} failed after ${report.attempts} attempts: ${report.reason}. The conversation is unchanged; continue in the current context and say the rewind failed.`,
321
+ "error",
322
+ );
323
+ return;
324
+ }
325
+
326
+ if (report.cancelled === true) {
327
+ ctx.ui.notify(
328
+ `Interrupt rewind to ${checkpointId} was cancelled. The conversation is unchanged.`,
329
+ "warning",
330
+ );
331
+ return;
332
+ }
333
+
334
+ const summaryEntry =
335
+ report.summaryEntryId === undefined ? "" : ` (summary entry ${report.summaryEntryId})`;
336
+ ctx.ui.notify(
337
+ `Rewound to ${checkpointId}${summaryEntry}. The abandoned branch is kept, labelled "${SUMMARY_LABEL}".`,
338
+ "info",
339
+ );
340
+ },
341
+ });
342
+
343
+ pi.on("agent_settled", () => {
344
+ const request = pending;
345
+ if (request === undefined) return;
346
+
347
+ // Taken here, so a second settle cannot dispatch the same rewind twice. A
348
+ // dispatch that never runs leaves no pending request behind.
349
+ pending = undefined;
350
+ handoff.clear();
351
+ handoff.set(request.checkpointId, request.summary);
352
+ pi.sendUserMessage(`/${REWIND_COMMAND} ${request.checkpointId}`, { expandPromptTemplates: true });
353
+ });
354
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@agent-cortex/interrupt-bookmark",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test --import tsx/esm index.test.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "devDependencies": {
11
+ "@earendil-works/pi-coding-agent": "^1.5.2",
12
+ "@types/node": "^22.0.0",
13
+ "tsx": "^4.0.0",
14
+ "typebox": "^1.1.38",
15
+ "typescript": "^5.0.0"
16
+ },
17
+ "peerDependencies": {
18
+ "typebox": "*"
19
+ }
20
+ }
@@ -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`.