@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.
- package/CHANGELOG.md +239 -0
- package/README.md +61 -5
- package/agents/README.md +2 -2
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/bin/agent-cortex.mjs +20 -1
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +122 -0
- package/skills/review/review-defects/SKILL.md +91 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/plan/SKILL.md +14 -3
- package/skills/workflow/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/using-agent-skills/SKILL.md +15 -7
- package/token-map.README.md +27 -0
- 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
|
+
}
|