@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,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
+ }
@@ -0,0 +1,469 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ import { createTempRoot } from "../../test/helpers/temp-root.mjs";
7
+ import {
8
+ PostMergeWatch,
9
+ buildWake,
10
+ readVerdict,
11
+ stateFileFor,
12
+ verdictOf,
13
+ WAKE_CUSTOM_TYPE,
14
+ } from "./watch.ts";
15
+ import type { CheckRun, Scheduler, WakeMessage, WatchState } from "./watch.ts";
16
+
17
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
18
+
19
+ const tempRoot = createTempRoot("post-merge-watch-");
20
+
21
+ /** The JSON the real check script prints, cut down to the fields the watch reads. */
22
+ function report({ reason, runUrl }: { reason: string; runUrl?: string }): string {
23
+ return JSON.stringify({
24
+ outcome: reason,
25
+ reason,
26
+ runs: runUrl === undefined ? [] : [{ databaseId: 1, url: runUrl, status: "completed" }],
27
+ });
28
+ }
29
+
30
+ function run(code: number, stdout = ""): CheckRun {
31
+ return { code, stdout, stderr: "", killed: false };
32
+ }
33
+
34
+ /**
35
+ * A scheduler the test steps by hand: nothing fires on its own, and `fire()`
36
+ * awaits the poll it triggers, so no test sleeps or polls for async work.
37
+ */
38
+ function createStepScheduler() {
39
+ let pending: Array<() => void | Promise<void>> = [];
40
+ let clock = Date.parse("2026-01-01T00:00:00.000Z");
41
+
42
+ const scheduler: Scheduler = {
43
+ now: () => new Date(clock),
44
+ schedule: (callback) => {
45
+ pending.push(callback);
46
+ return () => {
47
+ pending = pending.filter((queued) => queued !== callback);
48
+ };
49
+ },
50
+ };
51
+
52
+ return {
53
+ scheduler,
54
+ /** How many timers are waiting. Zero means no orphan. */
55
+ pending: () => pending.length,
56
+ /** Run the waiting timer and await the poll it starts. */
57
+ async fire(): Promise<void> {
58
+ const [next, ...rest] = pending;
59
+ pending = rest;
60
+ await next?.();
61
+ clock += 15_000;
62
+ },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * A stepped scheduler that runs `onClockRead` before it returns the time.
68
+ *
69
+ * Both `start()` and `#writeState` read the clock before the file write reaches
70
+ * the filesystem, so a watch stopped from `onClockRead` stops with its write
71
+ * still to run: the remove lands inside the write window on every run, with no
72
+ * sleep and no dependence on I/O timing.
73
+ */
74
+ function createWriteWindowScheduler(onClockRead: () => void) {
75
+ const step = createStepScheduler();
76
+ const scheduler: Scheduler = {
77
+ now: () => {
78
+ onClockRead();
79
+ return step.scheduler.now();
80
+ },
81
+ schedule: (callback, delayMs) => step.scheduler.schedule(callback, delayMs),
82
+ };
83
+
84
+ return { scheduler, step };
85
+ }
86
+
87
+ /** A watch over one project and bead, with only the values a test cares about set. */
88
+ function createWatch(overrides: {
89
+ runs: CheckRun[];
90
+ stateDir: string;
91
+ project?: string;
92
+ bead?: string;
93
+ maxAttempts?: number;
94
+ }) {
95
+ const step = createStepScheduler();
96
+ const deliveries: WakeMessage[] = [];
97
+ const warnings: string[] = [];
98
+ const scripted = [...overrides.runs];
99
+
100
+ const watch = new PostMergeWatch({
101
+ bead: overrides.bead ?? "agnt-ctx-86u4.2",
102
+ project: overrides.project ?? "/projects/one",
103
+ script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
104
+ stateDir: overrides.stateDir,
105
+ maxAttempts: overrides.maxAttempts,
106
+ scheduler: step.scheduler,
107
+ runCheck: async () => {
108
+ const next = scripted.shift();
109
+ if (next === undefined) throw new Error("no scripted run left");
110
+ return next;
111
+ },
112
+ deliver: (message) => deliveries.push(message),
113
+ warn: (message) => warnings.push(message),
114
+ });
115
+
116
+ return { watch, step, deliveries, warnings };
117
+ }
118
+
119
+ /** Read a state file the watch wrote, as data. */
120
+ async function readState(path: string): Promise<WatchState> {
121
+ return JSON.parse(await readFile(path, "utf-8")) as WatchState;
122
+ }
123
+
124
+ // ─── Verdict ─────────────────────────────────────────────────────────────────
125
+
126
+ describe("readVerdict", () => {
127
+ it("takes the verdict from the exit code, not from the report body", () => {
128
+ const verdict = readVerdict(run(1, report({ reason: "run-failed", runUrl: "https://gh/run/9" })));
129
+
130
+ assert.equal(verdict.verdict, "failed");
131
+ assert.equal(verdict.exitCode, 1);
132
+ assert.equal(verdict.runUrl, "https://gh/run/9");
133
+ });
134
+
135
+ it("reads a killed run as inconclusive, whatever its partial output says", () => {
136
+ const verdict = readVerdict({ code: 143, stdout: "", stderr: "", killed: true });
137
+
138
+ assert.equal(verdict.verdict, "inconclusive");
139
+ assert.equal(verdict.reason, "check-timed-out");
140
+ assert.equal(verdict.runUrl, undefined);
141
+ });
142
+
143
+ it("reads an unreadable report as inconclusive without inventing a run", () => {
144
+ const verdict = readVerdict(run(2, "gh: not logged in"));
145
+
146
+ assert.deepEqual(
147
+ { verdict: verdict.verdict, reason: verdict.reason, runUrl: verdict.runUrl },
148
+ { verdict: "inconclusive", reason: "unreadable-report", runUrl: undefined },
149
+ );
150
+ });
151
+
152
+ it("maps every non-zero, non-one exit code to inconclusive", () => {
153
+ assert.deepEqual([0, 1, 2, 127, 143].map(verdictOf), [
154
+ "passed",
155
+ "failed",
156
+ "inconclusive",
157
+ "inconclusive",
158
+ "inconclusive",
159
+ ]);
160
+ });
161
+ });
162
+
163
+ // ─── Wake payload ────────────────────────────────────────────────────────────
164
+
165
+ describe("buildWake", () => {
166
+ it("names the bead, the verdict and the run URL", () => {
167
+ const wake = buildWake({
168
+ bead: "agnt-ctx-86u4.2",
169
+ verdict: "failed",
170
+ runUrl: "https://github.com/o/r/actions/runs/9",
171
+ exitCode: 1,
172
+ reason: "run-failed",
173
+ attempts: 3,
174
+ project: "/projects/one",
175
+ });
176
+
177
+ assert.equal(wake.customType, WAKE_CUSTOM_TYPE);
178
+ assert.equal(wake.display, true);
179
+ assert.match(wake.content, /agnt-ctx-86u4\.2/);
180
+ assert.match(wake.content, /Verdict: failed/);
181
+ assert.match(wake.content, /https:\/\/github\.com\/o\/r\/actions\/runs\/9/);
182
+ assert.deepEqual(wake.details, {
183
+ bead: "agnt-ctx-86u4.2",
184
+ verdict: "failed",
185
+ runUrl: "https://github.com/o/r/actions/runs/9",
186
+ exitCode: 1,
187
+ reason: "run-failed",
188
+ attempts: 3,
189
+ project: "/projects/one",
190
+ });
191
+ });
192
+
193
+ it("says no run was reported rather than leaving the line empty", () => {
194
+ const wake = buildWake({
195
+ bead: "agnt-ctx-86u4.2",
196
+ verdict: "inconclusive",
197
+ runUrl: null,
198
+ exitCode: 2,
199
+ reason: "no-run",
200
+ attempts: 40,
201
+ project: "/projects/one",
202
+ });
203
+
204
+ assert.match(wake.content, /Run: none reported/);
205
+ });
206
+ });
207
+
208
+ // ─── Watch lifecycle ─────────────────────────────────────────────────────────
209
+
210
+ describe("PostMergeWatch", () => {
211
+ it("polls while the run is in progress and wakes once when the verdict lands", async () => {
212
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
213
+ const { watch, step, deliveries } = createWatch({
214
+ stateDir,
215
+ runs: [
216
+ run(2, report({ reason: "still-running" })),
217
+ run(2, report({ reason: "still-running" })),
218
+ run(1, report({ reason: "run-failed", runUrl: "https://gh/run/9" })),
219
+ ],
220
+ });
221
+
222
+ await watch.start();
223
+ await step.fire();
224
+ await step.fire();
225
+
226
+ assert.equal(deliveries.length, 1);
227
+ assert.deepEqual(deliveries[0].details, {
228
+ bead: "agnt-ctx-86u4.2",
229
+ verdict: "failed",
230
+ runUrl: "https://gh/run/9",
231
+ exitCode: 1,
232
+ reason: "run-failed",
233
+ attempts: 3,
234
+ project: "/projects/one",
235
+ });
236
+ assert.equal(step.pending(), 0);
237
+ assert.equal(watch.active, false);
238
+ await assert.rejects(stat(watch.stateFile), "a watch that settled on a verdict leaves no state file");
239
+ });
240
+
241
+ it("gives up at the attempt cap and reports an inconclusive verdict", async () => {
242
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
243
+ const inProgress = run(2, report({ reason: "still-running" }));
244
+ const { watch, step, deliveries } = createWatch({
245
+ stateDir,
246
+ maxAttempts: 3,
247
+ runs: [inProgress, inProgress, inProgress, inProgress],
248
+ });
249
+
250
+ await watch.start();
251
+ await step.fire();
252
+ await step.fire();
253
+
254
+ assert.equal(deliveries.length, 1);
255
+ assert.deepEqual(
256
+ {
257
+ verdict: deliveries[0].details.verdict,
258
+ reason: deliveries[0].details.reason,
259
+ runUrl: deliveries[0].details.runUrl,
260
+ attempts: deliveries[0].details.attempts,
261
+ },
262
+ { verdict: "inconclusive", reason: "still-running", runUrl: null, attempts: 3 },
263
+ );
264
+ assert.equal(step.pending(), 0);
265
+ await assert.rejects(
266
+ stat(watch.stateFile),
267
+ "a watch that settled on the attempt cap leaves no state file",
268
+ );
269
+ });
270
+
271
+ it("keeps polling when the check script cannot run, then reports inconclusive", async () => {
272
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
273
+ const step = createStepScheduler();
274
+ const deliveries: WakeMessage[] = [];
275
+ const warnings: string[] = [];
276
+
277
+ const watch = new PostMergeWatch({
278
+ bead: "agnt-ctx-86u4.2",
279
+ project: "/projects/one",
280
+ script: "/missing/post-merge-check.mjs",
281
+ stateDir,
282
+ maxAttempts: 2,
283
+ scheduler: step.scheduler,
284
+ runCheck: async () => {
285
+ throw new Error("spawn ENOENT");
286
+ },
287
+ deliver: (message) => deliveries.push(message),
288
+ warn: (message) => warnings.push(message),
289
+ });
290
+
291
+ await watch.start();
292
+ await step.fire();
293
+
294
+ assert.equal(deliveries.length, 1);
295
+ assert.equal(deliveries[0].details.verdict, "inconclusive");
296
+ assert.equal(deliveries[0].details.reason, "no-report");
297
+ assert.match(warnings.join("\n"), /spawn ENOENT/);
298
+ });
299
+
300
+ it("records one state file per project and bead, and removes only the stopped watch's", async () => {
301
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
302
+ const inProgress = () => [run(2, report({ reason: "still-running" }))];
303
+ const sameBeadOtherProject = createWatch({ stateDir, project: "/projects/two", runs: inProgress() });
304
+ const otherBeadSameProject = createWatch({ stateDir, bead: "agnt-ctx-other.1", runs: inProgress() });
305
+
306
+ const first = createWatch({ stateDir, runs: inProgress() });
307
+ await first.watch.start();
308
+ await sameBeadOtherProject.watch.start();
309
+ await otherBeadSameProject.watch.start();
310
+
311
+ const paths = [
312
+ first.watch.stateFile,
313
+ sameBeadOtherProject.watch.stateFile,
314
+ otherBeadSameProject.watch.stateFile,
315
+ ];
316
+ assert.equal(new Set(paths).size, 3, "one file per project and bead");
317
+ assert.deepEqual(
318
+ { bead: (await readState(paths[0])).bead, project: (await readState(paths[0])).project },
319
+ { bead: "agnt-ctx-86u4.2", project: "/projects/one" },
320
+ );
321
+ assert.deepEqual(
322
+ { bead: (await readState(paths[1])).bead, project: (await readState(paths[1])).project },
323
+ { bead: "agnt-ctx-86u4.2", project: "/projects/two" },
324
+ );
325
+
326
+ await first.watch.stop();
327
+
328
+ await assert.rejects(stat(paths[0]), "a settled watch leaves no state file");
329
+ assert.equal((await stat(paths[1])).isFile(), true, "another project's watch is untouched");
330
+
331
+ await Promise.all([sameBeadOtherProject.watch.stop(), otherBeadSameProject.watch.stop()]);
332
+ });
333
+
334
+ it("stops polling and removes its state file when the session shuts down", async () => {
335
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
336
+ const { watch, step, deliveries } = createWatch({
337
+ stateDir,
338
+ runs: [run(2, report({ reason: "still-running" }))],
339
+ });
340
+
341
+ await watch.start();
342
+ const stateFile = watch.stateFile;
343
+ assert.equal((await readState(stateFile)).attempts, 1);
344
+
345
+ await watch.stop();
346
+
347
+ await assert.rejects(stat(stateFile));
348
+ assert.equal(step.pending(), 0);
349
+ assert.equal(watch.active, false);
350
+ assert.deepEqual(deliveries, []);
351
+ });
352
+
353
+ it("removes the state file when the session stops before the first write lands", async () => {
354
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
355
+ const deliveries: WakeMessage[] = [];
356
+ let armed = true;
357
+ let stopping: Promise<void> = Promise.resolve();
358
+ let watch: PostMergeWatch;
359
+
360
+ const { scheduler, step } = createWriteWindowScheduler(() => {
361
+ if (!armed) return;
362
+ armed = false;
363
+ stopping = watch.stop();
364
+ });
365
+
366
+ watch = new PostMergeWatch({
367
+ bead: "agnt-ctx-86u4.2",
368
+ project: "/projects/one",
369
+ script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
370
+ stateDir,
371
+ scheduler,
372
+ runCheck: async () => run(2, report({ reason: "still-running" })),
373
+ deliver: (message) => deliveries.push(message),
374
+ });
375
+
376
+ const starting = watch.start();
377
+ await stopping; // the remove lands before the write it is racing with
378
+ await starting;
379
+
380
+ await assert.rejects(
381
+ stat(watch.stateFile),
382
+ "a watch stopped before its first state write lands leaves no state file",
383
+ );
384
+ assert.equal(step.pending(), 0);
385
+ assert.deepEqual(deliveries, []);
386
+ });
387
+
388
+ it("removes the state file and schedules nothing when the session stops during a poll's write", async () => {
389
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
390
+ const deliveries: WakeMessage[] = [];
391
+ let armed = false;
392
+ let stopping: Promise<void> = Promise.resolve();
393
+ let watch: PostMergeWatch;
394
+
395
+ const { scheduler, step } = createWriteWindowScheduler(() => {
396
+ if (!armed) return;
397
+ armed = false;
398
+ stopping = watch.stop();
399
+ });
400
+
401
+ watch = new PostMergeWatch({
402
+ bead: "agnt-ctx-86u4.2",
403
+ project: "/projects/one",
404
+ script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
405
+ stateDir,
406
+ scheduler,
407
+ runCheck: async () => run(2, report({ reason: "still-running" })),
408
+ deliver: (message) => deliveries.push(message),
409
+ });
410
+
411
+ await watch.start();
412
+ armed = true;
413
+ await step.fire(); // the poll's state write reads the clock, so stop() lands inside it
414
+ await stopping;
415
+
416
+ await assert.rejects(
417
+ stat(watch.stateFile),
418
+ "a watch stopped during a poll's state write leaves no state file",
419
+ );
420
+ assert.equal(step.pending(), 0, "a stopped watch schedules no further check");
421
+ assert.deepEqual(deliveries, []);
422
+ });
423
+
424
+ it("does not wake a session that stopped while a check was in flight", async () => {
425
+ const stateDir = join(await tempRoot.createWorkspace(), "state");
426
+ const step = createStepScheduler();
427
+ const deliveries: WakeMessage[] = [];
428
+ const gate = createDeferred<CheckRun>();
429
+
430
+ const watch = new PostMergeWatch({
431
+ bead: "agnt-ctx-86u4.2",
432
+ project: "/projects/one",
433
+ script: "/agent-cortex/skills/workflow/ralph/scripts/post-merge-check.mjs",
434
+ stateDir,
435
+ scheduler: step.scheduler,
436
+ runCheck: () => gate.promise,
437
+ deliver: (message) => deliveries.push(message),
438
+ });
439
+
440
+ const starting = watch.start();
441
+ await watch.stop();
442
+ gate.resolve(run(1, report({ reason: "run-failed", runUrl: "https://gh/run/9" })));
443
+ await starting;
444
+
445
+ assert.deepEqual(deliveries, []);
446
+ await assert.rejects(stat(watch.stateFile));
447
+ });
448
+ });
449
+
450
+ describe("stateFileFor", () => {
451
+ it("keeps two projects that share a directory name apart", () => {
452
+ const stateDir = "/state";
453
+ const left = stateFileFor(stateDir, "/work/one/project", "agnt-ctx-86u4.2");
454
+ const right = stateFileFor(stateDir, "/work/two/project", "agnt-ctx-86u4.2");
455
+
456
+ assert.notEqual(left, right);
457
+ assert.match(left, /agnt-ctx-86u4\.2/);
458
+ assert.equal(stateFileFor(stateDir, "/work/one/project", "agnt-ctx-86u4.2"), left);
459
+ });
460
+ });
461
+
462
+ /** A promise the test settles itself, so an in-flight check can be held open. */
463
+ function createDeferred<T>() {
464
+ let settle: (value: T) => void = () => {};
465
+ const promise = new Promise<T>((resolve) => {
466
+ settle = resolve;
467
+ });
468
+ return { promise, resolve: settle };
469
+ }