@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.
- package/CHANGELOG.md +412 -0
- package/README.md +87 -8
- package/agents/README.md +4 -4
- package/agents/guppi/agent.md +162 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/agent-cortex.mjs +20 -1
- package/bin/installers/claude.mjs +1 -20
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- 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/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- 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 +198 -0
- package/skills/review/review-defects/SKILL.md +121 -0
- package/skills/review/review-report/EXAMPLES.md +114 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +27 -3
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- 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 +37 -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/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +21 -8
- package/token-map.README.md +27 -0
- 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`.
|