pi-anchorloop 2.1.0 → 2.2.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/pi/book.ts ADDED
@@ -0,0 +1,500 @@
1
+ /**
2
+ * pi extension: the loop book. The first `todo` create opens a loop and its book. The agent declares only
3
+ * what the program cannot observe (`kind` with its intent and anchor command, a non-command `verify`,
4
+ * `fresh-read`, `friction`, `reflect`, `skip`, `back`, `close`); the extension derives the rest from
5
+ * evidence: production edits, the exit code of the anchor command, reviewer results, durable artifacts,
6
+ * and the fresh-reader subagent. A phase machine over that evidence warns once per jump and never blocks;
7
+ * only `close` refuses, while a todo is neither done nor disposed of.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ type Book,
15
+ KINDS,
16
+ allFriction,
17
+ anchorCommand,
18
+ appendEntry,
19
+ artifactKind,
20
+ childId,
21
+ childIds,
22
+ childLines,
23
+ createBook,
24
+ currentIntent,
25
+ currentKind,
26
+ deleteBook,
27
+ diffAgainstBases,
28
+ findChildBook,
29
+ frictionOf,
30
+ guarded,
31
+ isOpenTask,
32
+ latestTasks,
33
+ openLoop,
34
+ replayTodos,
35
+ refsOf,
36
+ resolveRef,
37
+ reviewTrend,
38
+ reviews,
39
+ reviewsStalled,
40
+ specFolder,
41
+ stagesDone,
42
+ summarizeArtifact,
43
+ taskLines,
44
+ taskDetails,
45
+ sweep,
46
+ touchRepo,
47
+ validFollowup,
48
+ verifyAfterLastAct,
49
+ } from "./support/book.ts";
50
+ import { classify } from "./support/jev.ts";
51
+ import { JumpMemory, SEQUENCES, computePhases, describe, incompleteBefore, jumpLine, outsideWork } from "./support/phases.ts";
52
+ import { isSubagent } from "./support/subagent.ts";
53
+
54
+ export const SESSION_ENTRY = "anchorloop-loop";
55
+ const STAGES = ["kind", "verify", "fresh-read", "friction", "reflect", "skip", "back", "close", "read"] as const;
56
+ const REVIEWERS = new Set(["reviewer", "adversarial-reviewer"]);
57
+ const BLOCKING = /Blocking\s*:?\**\s*:?\s*P0\s*=\s*(\d+)\D{0,5}P1\s*=\s*(\d+)/gi;
58
+ const ANCHOR_LINE = /Reality anchor:[ \t]*(.*)/;
59
+
60
+ export const OPEN_LINE =
61
+ "anchorloop: a loop is open. Declare it with `anchorloop_stage` {stage: kind, kind: quick|inline|reviewed|durable, intent: <one line>, anchor: <check command>}.";
62
+
63
+ type Params = {
64
+ stage: (typeof STAGES)[number];
65
+ kind?: (typeof KINDS)[number];
66
+ intent?: string;
67
+ anchor?: string;
68
+ note?: string;
69
+ result?: "pass" | "fail";
70
+ tried?: string;
71
+ found?: string;
72
+ led_by?: string;
73
+ friction?: (string | number)[];
74
+ class?: "misdirection" | "gap" | "resolved";
75
+ action?: string;
76
+ phase?: string;
77
+ reason?: string;
78
+ verdict?: "landed" | "blocked" | "abandoned";
79
+ view?: "summary" | "all";
80
+ };
81
+
82
+ type Block = { type?: string; text?: string };
83
+ type BranchEntry = { type?: string; message?: { role?: string; content?: unknown } };
84
+
85
+ /** The P0 and P1 counts of the last `Blocking: P0=<n> P1=<m>` line in a reviewer report, tolerant of markdown emphasis. */
86
+ export function parseBlocking(text: string): { p0: number; p1: number } | undefined {
87
+ const found = [...text.matchAll(BLOCKING)].at(-1);
88
+ return found ? { p0: Number(found[1]), p1: Number(found[2]) } : undefined;
89
+ }
90
+
91
+ function textOf(content: unknown): string {
92
+ if (typeof content === "string") return content;
93
+ if (!Array.isArray(content)) return "";
94
+ return (content as Block[]).map((block) => (block && typeof block.text === "string" ? block.text : "")).filter(Boolean).join("\n");
95
+ }
96
+
97
+ function lastLine(text: string): string {
98
+ return (
99
+ text
100
+ .split("\n")
101
+ .map((line) => line.trim())
102
+ .filter(Boolean)
103
+ .at(-1) ?? ""
104
+ ).slice(0, 200);
105
+ }
106
+
107
+ /** The exit code of a bash tool result: the structured code, else 0 when the call did not error, else the code the message names. */
108
+ export function bashExit(event: { isError?: boolean; structuredContent?: unknown; content?: unknown }): number | null {
109
+ const code = (event.structuredContent as { exit_code?: unknown } | undefined)?.exit_code;
110
+ if (typeof code === "number") return code;
111
+ if (event.isError !== true) return 0;
112
+ const named = /Command exited with code (\d+)/.exec(textOf(event.content));
113
+ return named ? Number(named[1]) : null;
114
+ }
115
+
116
+ /** The text a `Reality anchor:` line states in an assistant message, if any. */
117
+ function statedAnchor(message: { role?: string; content?: unknown } | undefined): string | undefined {
118
+ if (message?.role !== "assistant") return undefined;
119
+ const found = ANCHOR_LINE.exec(textOf(message.content));
120
+ return found ? found[1].trim().slice(0, 300) || "(stated on the next line)" : undefined;
121
+ }
122
+
123
+ const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
124
+
125
+ export default function (pi: ExtensionAPI) {
126
+ const subagent = isSubagent();
127
+ const jumps = new JumpMemory();
128
+
129
+ function todosOf(ctx: ExtensionContext) {
130
+ return replayTodos(ctx.sessionManager.getBranch());
131
+ }
132
+
133
+ function phasesOf(book: Book, ctx: ExtensionContext) {
134
+ return computePhases(book, { cwd: ctx.cwd, todos: todosOf(ctx) });
135
+ }
136
+
137
+ function summaryLines(book: Book, ctx: ExtensionContext): string[] {
138
+ const phases = phasesOf(book, ctx);
139
+ const verify = [...book.entries].reverse().find((entry) => entry.stage === "verify" || entry.stage === "check");
140
+ const open = todosOf(ctx).filter(isOpenTask).length;
141
+ const friction = allFriction(book.entries);
142
+ return [
143
+ describe(phases),
144
+ `intent: ${currentIntent(book.entries) ?? "not declared"}`,
145
+ `last ${verify?.stage ?? "verify"}: ${verify ? `${verify.declared ? `declared ${verify.result}` : `exit ${verify.exit ?? "unknown"}`}${verify.last ? ` — ${verify.last}` : ""}${verifyAfterLastAct(book.entries) ? "" : " (before the last change)"}` : "none"}`,
146
+ `reviews: ${reviewTrend(book.entries)}; open todos: ${open}; friction: ${friction.length} (${friction.filter((item) => !item.reflected).length} unreflected)`,
147
+ ...childLines(book.entries).map((line) => `child ${line}`),
148
+ ...friction.filter((item) => item.owner !== "main").map((item) => `friction ${item.ref}: ${item.tried} → ${item.found}${item.reflected ? ` [${item.class}]` : ""}`),
149
+ ...phases.hints,
150
+ ];
151
+ }
152
+
153
+ function recordTasks(book: Book, details: unknown, input: { action?: unknown; id?: unknown }, cwd: string): void {
154
+ const tasks = taskDetails(details) ?? [];
155
+ const known = new Set(latestTasks(book.entries).map((entry) => entry.id));
156
+ const touched = input.action === "create" ? tasks.filter((task) => !known.has(task.id)) : tasks.filter((task) => task.id === Number(input.id));
157
+ for (const task of touched) {
158
+ const refs = refsOf(task.metadata).map((ref) => ({ ref, range: resolveRef(ref, cwd, book.entries) }));
159
+ const { anchor, followup } = task.metadata ?? {};
160
+ book.entries.push(
161
+ appendEntry(book.path, "task", {
162
+ id: task.id,
163
+ subject: task.subject,
164
+ status: task.status,
165
+ ...(typeof anchor === "string" ? { anchor } : {}),
166
+ ...(refs.length ? { refs } : {}),
167
+ ...(followup !== undefined ? { followup } : {}),
168
+ }),
169
+ );
170
+ }
171
+ }
172
+
173
+ /** A `Reality anchor:` line stated since the last user message, recorded when a loop opens after it. */
174
+ function recordPriorAnchor(book: Book, ctx: ExtensionContext): void {
175
+ const branch = ctx.sessionManager.getBranch() as BranchEntry[];
176
+ for (let i = branch.length - 1; i >= 0; i--) {
177
+ const message = branch[i].type === "message" ? branch[i].message : undefined;
178
+ if (!message) continue;
179
+ if (message.role === "user") return;
180
+ const stated = statedAnchor(message);
181
+ if (stated) {
182
+ book.entries.push(appendEntry(book.path, "anchor", { check: stated, line: true }));
183
+ return;
184
+ }
185
+ }
186
+ }
187
+
188
+ function close(book: Book, ctx: ExtensionContext, verdict: string): string {
189
+ const todos = replayTodos(ctx.sessionManager.getBranch());
190
+ const undisposed = todos.filter((task) => isOpenTask(task) && !validFollowup(task.metadata?.followup));
191
+ if (undisposed.length > 0) {
192
+ throw new Error(
193
+ `Cannot close: ${undisposed.length} todo(s) are pending or in progress without metadata.followup (a ticket id, \`memory\`, or \`drop — <reason>\`):\n` +
194
+ undisposed.map((task) => `- #${task.id} ${task.subject}`).join("\n"),
195
+ );
196
+ }
197
+ const kept = childId() !== undefined;
198
+ const kind = currentKind(book.entries);
199
+ const phases = phasesOf(book, ctx);
200
+ const missing = incompleteBefore(phases, "close");
201
+ const bookId = String(book.header.id);
202
+ const fresh = jumps.fresh(bookId, "close", missing.map((status) => status.name));
203
+ const warning =
204
+ verdict !== "landed" || !missing.length || !fresh
205
+ ? undefined
206
+ : kind === "quick"
207
+ ? `anchorloop: closing a quick loop with ${missing.map((status) => `${status.name} (${status.note})`).join("; ")} open.`
208
+ : jumpLine("close", missing);
209
+ const diff = diffAgainstBases(book.entries);
210
+ const friction = allFriction(book.entries);
211
+ const summary = {
212
+ intent: currentIntent(book.entries) ?? null,
213
+ kind: kind ?? null,
214
+ verdict,
215
+ stages: stagesDone(book.entries),
216
+ reviewRounds: reviews(book.entries).length,
217
+ reviewTrend: reviewTrend(book.entries),
218
+ diff: diff.summary,
219
+ skipped: phases.skipped,
220
+ back: phases.backs,
221
+ friction: friction.map((item) => ({ ref: item.ref, owner: item.owner, tried: item.tried, found: item.found, led_by: item.led_by, class: item.class ?? null, action: item.action ?? null })),
222
+ followups: todos.filter((task) => isOpenTask(task)).map((task) => ({ task: task.subject, followup: task.metadata?.followup })),
223
+ };
224
+ const unreflected = friction.filter((item) => !item.reflected);
225
+ const tail = unreflected.length
226
+ ? ` Unreflected friction for the Reflexive Pass: ${unreflected.map((item) => `${item.ref} (${item.found})`).join("; ")}.`
227
+ : "";
228
+ const extra = [summary.skipped.length ? `skipped ${summary.skipped.join(", ")}` : "", summary.back.length ? `back ${summary.back.join(", ")}` : ""].filter(Boolean).join("; ");
229
+ const text = `Loop closed (${verdict}): ${summary.stages.join(", ")}; ${summary.reviewTrend}; diff ${diff.summary}${extra ? `; ${extra}` : ""}.${tail}`;
230
+ if (kept) {
231
+ // A child's book stays for the main session to read and delete.
232
+ appendEntry(book.path, "close", { verdict, summary: text.slice(0, 500) });
233
+ return `${text} The book stays for the main session.${warning ? `\n${warning}` : ""}`;
234
+ }
235
+ pi.appendEntry(SESSION_ENTRY, summary);
236
+ for (const id of childIds(book.entries)) {
237
+ const child = findChildBook(id);
238
+ if (child) deleteBook(child.path);
239
+ }
240
+ deleteBook(book.path);
241
+ return `${text} The book is deleted. Clear the todo list now (todo action: clear).${warning ? `\n${warning}` : ""}`;
242
+ }
243
+
244
+ pi.registerTool({
245
+ name: "anchorloop_stage",
246
+ label: "Loop stage",
247
+ description:
248
+ "Declare only what the extension cannot observe in the anchorloop loop book. " +
249
+ "`kind` (kind, intent, optional anchor command; call again to upgrade the kind or set the anchor): quick (act, check), inline (anchor, act, verify), reviewed (adds review), durable (adds decide, grill, spec, read, approve, design); every kind ends with reflect, close. " +
250
+ "`verify` (note; only for an anchor that is not a command), `fresh-read` (a fresh-context read of the spec is done), `friction` (tried, found, led_by), `reflect` (friction ids, class misdirection|gap|resolved, action), " +
251
+ "`skip` / `back` (phase, reason), `close` (verdict), `read` (view the book). Edits, anchor runs, reviews, and spec files are read from evidence. " +
252
+ "A loop opens with the first `todo` create; `close` refuses while a pending or in-progress todo lacks metadata.followup.",
253
+ parameters: {
254
+ type: "object",
255
+ properties: {
256
+ stage: { type: "string", enum: [...STAGES] },
257
+ kind: { type: "string", enum: [...KINDS], description: "stage kind: the loop kind; a second call upgrades it" },
258
+ intent: { type: "string", description: "stage kind: one line on what the loop delivers" },
259
+ anchor: { type: "string", description: "stage kind: the check command whose exit code observes the result" },
260
+ note: { type: "string", description: "stage verify: what was observed; stage fresh-read: who read the spec" },
261
+ result: { type: "string", enum: ["pass", "fail"], description: "stage verify; default pass" },
262
+ tried: { type: "string", description: "stage friction: the attempt that failed" },
263
+ found: { type: "string", description: "stage friction: what the failure revealed" },
264
+ led_by: { type: "string", description: "stage friction: the skill or instruction line that prompted the attempt, or `none`" },
265
+ friction: { type: "array", items: { type: ["string", "number"] }, description: "stage reflect: friction ids (`3`, or `worker#2` for a child's)" },
266
+ class: { type: "string", enum: ["misdirection", "gap", "resolved"], description: "stage reflect" },
267
+ action: { type: "string", description: "stage reflect: what was done, such as solid-loop, memory, or a commit" },
268
+ phase: { type: "string", description: "stage skip or back: the phase" },
269
+ reason: { type: "string", description: "stage skip or back: why" },
270
+ verdict: { type: "string", enum: ["landed", "blocked", "abandoned"], description: "stage close" },
271
+ view: { type: "string", enum: ["summary", "all"], description: "stage read; default summary" },
272
+ },
273
+ required: ["stage"],
274
+ additionalProperties: false,
275
+ },
276
+ async execute(_toolCallId: string, raw: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
277
+ const params = raw as Params;
278
+ const book = openLoop(ctx);
279
+ if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create.");
280
+ const { stage } = params;
281
+ if (!(STAGES as readonly string[]).includes(stage)) throw new Error(`unknown stage \`${String(stage)}\`; declare one of ${STAGES.join(", ")}`);
282
+ const had = currentKind(book.entries);
283
+ const sequence = SEQUENCES[had ?? "inline"];
284
+ if (stage === "read") {
285
+ const lines = [...summaryLines(book, ctx), ...taskLines(book.entries, todosOf(ctx), ctx.cwd)];
286
+ if (params.view === "all") lines.push("entries:", ...book.entries.map((entry) => JSON.stringify(entry).slice(0, 300)));
287
+ return reply(lines.join("\n"));
288
+ }
289
+ if (stage === "close") {
290
+ if (!params.verdict) throw new Error("close needs a verdict: landed, blocked, or abandoned");
291
+ return reply(close(book, ctx, params.verdict));
292
+ }
293
+ if (stage === "kind") {
294
+ if (!params.kind && !params.intent && !params.anchor) throw new Error("kind needs `kind`, `intent`, or `anchor`");
295
+ if (!had && (!params.kind || !params.intent)) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
296
+ const notes: string[] = [];
297
+ if (params.kind || params.intent) {
298
+ appendEntry(book.path, "kind", { kind: params.kind ?? had, ...(params.intent ? { intent: params.intent } : {}) });
299
+ notes.push(had && params.kind && had !== params.kind ? `kind changed: ${had} → ${params.kind}` : `kind: ${params.kind ?? had}`);
300
+ }
301
+ if (params.anchor) {
302
+ appendEntry(book.path, "anchor", { check: params.anchor });
303
+ notes.push(`anchor: ${params.anchor}`);
304
+ }
305
+ touchRepo(book, join(ctx.cwd, "_"));
306
+ return reply(notes.join("\n"));
307
+ }
308
+ if (stage === "verify") {
309
+ if (!params.note) throw new Error("verify needs a `note` saying what was observed");
310
+ const result = params.result ?? "pass";
311
+ const recorded = childId() !== undefined || had === "quick" ? "check" : "verify";
312
+ appendEntry(book.path, recorded, { declared: true, note: params.note, result, exit: result === "pass" ? 0 : 1 });
313
+ return reply(`${recorded} declared: ${result} — ${params.note}`);
314
+ }
315
+ if (stage === "fresh-read") {
316
+ appendEntry(book.path, "goldfish", { source: "declared", ...(params.note ? { note: params.note } : {}) });
317
+ return reply("fresh-read recorded.");
318
+ }
319
+ if (stage === "friction") {
320
+ if (!params.tried || !params.found) throw new Error("friction needs `tried` and `found`");
321
+ const id = frictionOf(book.entries).length + 1;
322
+ appendEntry(book.path, "friction", { id, tried: params.tried, found: params.found, led_by: params.led_by || "none" });
323
+ return reply(`friction #${id} recorded.`);
324
+ }
325
+ if (stage === "reflect") {
326
+ const known = allFriction(book.entries).map((item) => item.ref);
327
+ const ids = (params.friction ?? []).map(String);
328
+ if (ids.length === 0 || !params.class) throw new Error(`reflect needs \`friction\` ids (${known.join(", ") || "none recorded"}) and \`class\``);
329
+ const unknown = ids.filter((id) => !known.includes(id));
330
+ if (unknown.length) throw new Error(`unknown friction ${unknown.join(", ")}; known: ${known.join(", ") || "none"}`);
331
+ appendEntry(book.path, "reflect", { friction: ids, class: params.class, ...(params.action ? { action: params.action } : {}) });
332
+ return reply(`reflect recorded for ${ids.join(", ")} (${params.class}).`);
333
+ }
334
+ // skip and back
335
+ const phase = params.phase;
336
+ const known = sequence.filter((name) => name !== "close");
337
+ if (!phase || !known.includes(phase)) throw new Error(`${stage} needs \`phase\`, one of ${known.join(", ")} for a ${had ?? "undeclared"} loop`);
338
+ if (!params.reason) throw new Error(`${stage} needs a \`reason\``);
339
+ appendEntry(book.path, stage, { phase, reason: params.reason });
340
+ return reply(stage === "skip" ? `${phase}: skipped (${params.reason}).` : `back to ${phase} (${params.reason}); later phases count as incomplete until their conditions hold again.`);
341
+ },
342
+ });
343
+
344
+ pi.on(
345
+ "session_start",
346
+ guarded((_event, ctx) => {
347
+ const swept = sweep(process.env, ctx.cwd);
348
+ const lines = [
349
+ ...swept.deleted.map((path) => `deleted stale loop book: ${path}`),
350
+ ...swept.open.map((path) => `open loop book: ${path}`),
351
+ ];
352
+ if (lines.length > 0) ctx.ui.notify(`anchorloop books\n${lines.join("\n")}`, "info");
353
+ }),
354
+ );
355
+
356
+ pi.on(
357
+ "tool_call",
358
+ guarded((event, ctx) => {
359
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
360
+ const path = (event.input as { path?: unknown }).path;
361
+ const book = typeof path === "string" ? openLoop(ctx) : undefined;
362
+ if (book && typeof path === "string") touchRepo(book, resolve(ctx.cwd, path));
363
+ }),
364
+ );
365
+
366
+ /** Records what a finished bash call shows: runs of the loop anchor, of task anchors, and a first commit as the change. */
367
+ function recordBash(book: Book, event: { input: unknown; isError?: boolean; structuredContent?: unknown; content?: unknown }): void {
368
+ const structured = event.structuredContent as { output?: unknown } | undefined;
369
+ const output = typeof structured?.output === "string" ? structured.output : textOf(event.content);
370
+ const command = String((event.input as { command?: unknown }).command ?? "");
371
+ const exit = bashExit(event);
372
+ const last = lastLine(output);
373
+ const anchor = anchorCommand(book.entries);
374
+ if (anchor && command.includes(anchor)) {
375
+ const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
376
+ const piped = command.slice(command.indexOf(anchor) + anchor.length).includes("|");
377
+ book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: piped ? "unknown" : (exit ?? "unknown"), last }));
378
+ }
379
+ for (const task of latestTasks(book.entries)) {
380
+ if (typeof task.anchor === "string" && command.includes(task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit: exit ?? "unknown", last });
381
+ }
382
+ if (exit === 0 && /\bgit\b[^|;&]*\bcommit\b/.test(command) && !book.entries.some((entry) => entry.stage === "edit")) {
383
+ const cwd = String(book.header.cwd ?? "");
384
+ if (diffAgainstBases(book.entries).paths.some((path) => !outsideWork(path, cwd))) appendEntry(book.path, "edit", { via: "commit" });
385
+ }
386
+ }
387
+
388
+ pi.on(
389
+ "tool_result",
390
+ guarded((event, ctx) => {
391
+ if (event.toolName === "bash") {
392
+ const book = openLoop(ctx);
393
+ if (book) recordBash(book, event);
394
+ return;
395
+ }
396
+ if (event.isError) return;
397
+ if (event.toolName === "todo") {
398
+ const input = event.input as { action?: unknown; id?: unknown };
399
+ let open = openLoop(ctx);
400
+ const opens = !open && input.action === "create";
401
+ if (!open && opens) {
402
+ open = createBook(ctx);
403
+ recordPriorAnchor(open, ctx);
404
+ }
405
+ if (!open) return;
406
+ recordTasks(open, event.details, input, ctx.cwd);
407
+ return opens ? { content: [...event.content, { type: "text" as const, text: OPEN_LINE }] } : undefined;
408
+ }
409
+ const book = openLoop(ctx);
410
+ if (!book) return;
411
+ if (event.toolName === "subagent") {
412
+ const id = (event.details as { id?: unknown } | undefined)?.id;
413
+ if (typeof id === "string") {
414
+ const details = event.details as { name?: unknown; agent?: unknown };
415
+ appendEntry(book.path, "link", { child: id, name: details.name, agent: details.agent });
416
+ }
417
+ return;
418
+ }
419
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
420
+ const path = (event.input as { path?: unknown }).path;
421
+ if (typeof path !== "string") return;
422
+ if (!artifactKind(path)) {
423
+ if (!outsideWork(path, ctx.cwd) && book.entries.at(-1)?.stage !== "edit") appendEntry(book.path, "edit", { path });
424
+ return;
425
+ }
426
+ let text: string;
427
+ try {
428
+ text = readFileSync(resolve(ctx.cwd, path), "utf-8");
429
+ } catch {
430
+ return;
431
+ }
432
+ const summary = summarizeArtifact(book.entries, path, text);
433
+ if (summary) appendEntry(book.path, summary.stage, summary.fields);
434
+ }),
435
+ );
436
+
437
+ async function recordReview(ctx: ExtensionContext, book: Book, name: string, text: string, failed: boolean): Promise<void> {
438
+ const counts = parseBlocking(text);
439
+ let blocking: boolean | null = null;
440
+ let source = `reviewer ${name}`;
441
+ if (failed || /\bStatus:?\*{0,2}\s*INCOMPLETE\b/i.test(text)) {
442
+ source = "failed";
443
+ } else if (!counts) {
444
+ source = "jev";
445
+ const { answers } = await classify(ctx, { review: text.slice(-3000) }, {
446
+ blocking: {
447
+ type: "bool",
448
+ instructions: "Does `review` report any blocking finding, a P0 or P1 defect that must be fixed before the change is merged?",
449
+ criteria: { true: "the review names at least one defect it says must be fixed before merge", false: "the review finds no blocking defect, or only lower-priority notes" },
450
+ },
451
+ });
452
+ const probability = answers?.blocking?.type === "bool" ? answers.blocking.probability : undefined;
453
+ blocking = probability === undefined ? null : probability >= 0.5;
454
+ } else {
455
+ blocking = counts.p0 + counts.p1 > 0;
456
+ }
457
+ const entry = appendEntry(book.path, "review", { round: reviews(book.entries).length + 1, ...(source === "failed" ? {} : (counts ?? {})), blocking, source });
458
+ book.entries.push(entry);
459
+ if (reviewsStalled(book.entries)) ctx.ui.notify("anchorloop: two consecutive review rounds still raised blocking findings; stop and hand the trend to the user.", "warning");
460
+ }
461
+
462
+ /** Jev judges whether a returned subagent was a fresh-context read of the spec; the spec path in its brief is only a pre-filter. */
463
+ async function detectFreshRead(ctx: ExtensionContext, book: Book, details: { name?: unknown; task?: unknown; resultContent?: unknown }): Promise<void> {
464
+ const phases = phasesOf(book, ctx);
465
+ const read = phases.statuses.find((status) => status.name === "read");
466
+ if (read?.state !== "missing" || !read.note.startsWith("a fresh-context")) return;
467
+ const folder = specFolder(book.entries, todosOf(ctx), ctx.cwd);
468
+ const brief = String(details.task ?? "");
469
+ if (!folder || !(brief.includes(`${folder}/spec.md`) || brief.includes(resolve(ctx.cwd, folder, "spec.md")))) return;
470
+ const { answers } = await classify(ctx, { brief: brief.slice(0, 1500), result: textOf(details.resultContent).slice(-2000) }, {
471
+ fresh_read: {
472
+ type: "bool",
473
+ instructions: "Was this subagent a fresh-context read of the spec: did its `brief` hand it the spec without the author's reasoning and ask it to restate the contract and list its guesses, and does its `result` do so?",
474
+ criteria: { true: "the result restates the contract in its own words and lists what it had to guess", false: "the subagent did other work, had the author's context, or only summarized the spec" },
475
+ },
476
+ });
477
+ const probability = answers?.fresh_read?.type === "bool" ? answers.fresh_read.probability : undefined;
478
+ if (probability === undefined) return;
479
+ appendEntry(book.path, "goldfish", { source: "jev", ok: probability >= 0.5, child: String(details.name ?? "") });
480
+ }
481
+
482
+ pi.on(
483
+ "message_end",
484
+ guarded(async (event, ctx) => {
485
+ const message = event.message as { role?: string; content?: unknown; customType?: string; details?: { agent?: unknown; name?: unknown; task?: unknown; resultContent?: unknown; error?: unknown; errorMessage?: unknown; exitCode?: unknown } };
486
+ const book = openLoop(ctx);
487
+ if (!book) return;
488
+ const stated = statedAnchor(message);
489
+ if (stated && !book.entries.some((entry) => entry.stage === "anchor" && entry.check === stated)) appendEntry(book.path, "anchor", { check: stated, line: true });
490
+ if (message.role !== "custom" || message.customType !== "subagent_result" || subagent) return;
491
+ const agent = String(message.details?.agent ?? "");
492
+ if (REVIEWERS.has(agent)) {
493
+ const d = message.details ?? {};
494
+ const failed = Boolean(d.error) || Boolean(d.errorMessage) || (typeof d.exitCode === "number" && d.exitCode !== 0);
495
+ await recordReview(ctx, book, String(d.name ?? agent), textOf(d.resultContent), failed);
496
+ }
497
+ else await detectFreshRead(ctx, book, message.details ?? {});
498
+ }),
499
+ );
500
+ }