@jaybeeuu/agent-cortex 2.7.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 (56) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/README.md +61 -5
  3. package/agents/README.md +2 -2
  4. package/agents/ralph/agent.md +17 -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 +20 -1
  8. package/extensions/post-merge-watch/README.md +133 -0
  9. package/extensions/post-merge-watch/index.test.ts +247 -0
  10. package/extensions/post-merge-watch/index.ts +123 -0
  11. package/extensions/post-merge-watch/package.json +16 -0
  12. package/extensions/post-merge-watch/watch.test.ts +469 -0
  13. package/extensions/post-merge-watch/watch.ts +431 -0
  14. package/extensions/session-clear/README.md +74 -0
  15. package/extensions/session-clear/index.test.ts +524 -0
  16. package/extensions/session-clear/index.ts +159 -0
  17. package/extensions/session-clear/package.json +16 -0
  18. package/lib/cli.mjs +42 -1
  19. package/lib/self-update.mjs +553 -0
  20. package/package.json +1 -1
  21. package/skills/engineering/implement-task/SKILL.md +124 -0
  22. package/skills/engineering/tdd/SKILL.md +2 -0
  23. package/skills/planning/create-task/REFERENCE.md +15 -6
  24. package/skills/planning/create-task/SKILL.md +1 -1
  25. package/skills/planning/create-task/pipeline.json +8 -0
  26. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  27. package/skills/planning/create-task/templates/code.md +8 -3
  28. package/skills/planning/create-task/templates/document.md +8 -3
  29. package/skills/planning/create-task/templates/fix.md +8 -3
  30. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  31. package/skills/planning/create-task/templates/post-merge.md +67 -0
  32. package/skills/planning/create-task/templates/review.md +8 -3
  33. package/skills/planning/create-task/templates/verify.md +8 -3
  34. package/skills/productivity/bd-tool/SKILL.md +55 -0
  35. package/skills/review/review-defects/EXAMPLES.md +122 -0
  36. package/skills/review/review-defects/SKILL.md +91 -0
  37. package/skills/style/style-tests/EXAMPLES.md +30 -0
  38. package/skills/style/style-tests/SKILL.md +10 -6
  39. package/skills/workflow/plan/SKILL.md +14 -3
  40. package/skills/workflow/ralph/REFERENCE.md +124 -27
  41. package/skills/workflow/ralph/SKILL.md +24 -15
  42. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  43. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  44. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  45. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  46. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  47. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  48. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  49. package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
  50. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  51. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  52. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  53. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  54. package/skills/workflow/using-agent-skills/SKILL.md +15 -7
  55. package/token-map.README.md +27 -0
  56. package/token-map.json +10 -4
@@ -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
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Post-merge watch extension.
3
+ *
4
+ * A human merges the feature PR, and the CI run for the merge commit decides
5
+ * what happens next. Nothing tells the agent when that run concludes: the merge
6
+ * happens outside the session, and by the time the run settles the agent is idle.
7
+ * This extension makes the session wake up with the verdict.
8
+ *
9
+ * It polls the deterministic check script
10
+ * (`skills/workflow/ralph/scripts/post-merge-check.mjs`) on a timer and, when the
11
+ * script reports a verdict, sends one follow-up message — `deliverAs:
12
+ * "followUp"`, `triggerTurn: true` — so an idle session starts a turn with the
13
+ * result. The script owns the verdict; this extension runs it and forwards the
14
+ * exit code. It makes no model call.
15
+ *
16
+ * Opt-in per session through the `--post-merge-watch <bead>` flag. State lives
17
+ * under `~/.pi/agent-cortex/post-merge-watch/`, one file per project and bead,
18
+ * and is removed when the watch settles or the session shuts down.
19
+ */
20
+
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+
26
+ import { PostMergeWatch, DEFAULT_INTERVAL_MS, DEFAULT_MAX_ATTEMPTS } from "./watch.ts";
27
+ import type { CheckRun, CheckInvocation, WakeMessage } from "./watch.ts";
28
+
29
+ /** The CLI flag that starts a watch: `--post-merge-watch <bead>`. */
30
+ export const WATCH_FLAG = "post-merge-watch";
31
+
32
+ /**
33
+ * Environment override for the check script path. The default resolves against
34
+ * this module, so the staged pi package (`extensions/` beside `skills/`) and a
35
+ * checkout of this repository both find the shipped script without a hardcoded
36
+ * absolute path.
37
+ */
38
+ export const CHECK_SCRIPT_ENV = "AGENT_CORTEX_POST_MERGE_CHECK";
39
+
40
+ /** The check script's location in a staged install and in this repository. */
41
+ const DEFAULT_CHECK_SCRIPT = new URL(
42
+ "../../skills/workflow/ralph/scripts/post-merge-check.mjs",
43
+ import.meta.url,
44
+ );
45
+
46
+ /** How long one check invocation may take before pi kills it. */
47
+ const CHECK_TIMEOUT_MS = 120_000;
48
+
49
+ /** The state directory, under the agent-cortex global area, never in the project. */
50
+ export function stateDir(): string {
51
+ return join(homedir(), ".pi", "agent-cortex", "post-merge-watch");
52
+ }
53
+
54
+ /** The check script this extension polls. */
55
+ export function checkScript(): string {
56
+ const override = process.env[CHECK_SCRIPT_ENV];
57
+ return override !== undefined && override !== "" ? override : fileURLToPath(DEFAULT_CHECK_SCRIPT);
58
+ }
59
+
60
+ export default function postMergeWatchExtension(pi: ExtensionAPI): void {
61
+ // Registered here, in the factory: pi matches parsed CLI flags against the
62
+ // extensions loaded at startup, so a flag registered from `session_start` is
63
+ // rejected as an unknown option.
64
+ pi.registerFlag(WATCH_FLAG, {
65
+ description: "Watch the post-merge check for a bead and wake the session with the verdict",
66
+ type: "string",
67
+ });
68
+
69
+ /** One watch per bead, so a reloaded session does not start a second one. */
70
+ const watches = new Map<string, PostMergeWatch>();
71
+
72
+ const runCheck = async (invocation: CheckInvocation): Promise<CheckRun> => {
73
+ const result = await pi.exec(process.execPath, [invocation.script, ...invocation.args], {
74
+ cwd: invocation.cwd,
75
+ timeout: CHECK_TIMEOUT_MS,
76
+ });
77
+ return { code: result.code, stdout: result.stdout, stderr: result.stderr, killed: result.killed };
78
+ };
79
+
80
+ /**
81
+ * The verdict goes out as a follow-up message with `triggerTurn`, so a session
82
+ * that is idle starts a turn and one that is busy reads it after the turn in
83
+ * progress instead of interrupting it.
84
+ */
85
+ const deliver = (message: WakeMessage): void => {
86
+ pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
87
+ };
88
+
89
+ const watchedBead = (): string | undefined => {
90
+ const value = pi.getFlag(WATCH_FLAG);
91
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
92
+ };
93
+
94
+ pi.on("session_start", async (_event, ctx) => {
95
+ for (const [bead, watch] of watches) {
96
+ if (!watch.active) watches.delete(bead);
97
+ }
98
+
99
+ const bead = watchedBead();
100
+ if (bead === undefined || watches.has(bead)) return;
101
+
102
+ const watch = new PostMergeWatch({
103
+ bead,
104
+ project: ctx.cwd,
105
+ script: checkScript(),
106
+ stateDir: stateDir(),
107
+ runCheck,
108
+ deliver,
109
+ intervalMs: DEFAULT_INTERVAL_MS,
110
+ maxAttempts: DEFAULT_MAX_ATTEMPTS,
111
+ warn: (message) => ctx.ui.notify(message, "warning"),
112
+ });
113
+
114
+ watches.set(bead, watch);
115
+ await watch.start();
116
+ });
117
+
118
+ pi.on("session_shutdown", async () => {
119
+ const stopping = [...watches.values()].map((watch) => watch.stop());
120
+ watches.clear();
121
+ await Promise.all(stopping);
122
+ });
123
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@agent-cortex/post-merge-watch",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test --import tsx/esm watch.test.ts 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
+ "typescript": "^5.0.0"
15
+ }
16
+ }