pi-anchorloop 2.1.1 → 2.2.1
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/README.md +4 -3
- package/README.zh-TW.md +4 -3
- package/package.json +3 -2
- package/pi/anchor-gate.ts +21 -77
- package/pi/book.ts +554 -0
- package/pi/second-thought.ts +141 -172
- package/pi/support/book.ts +791 -0
- package/pi/support/jev.ts +28 -0
- package/pi/support/phases.ts +301 -0
- package/pi/support/snapshot.ts +71 -0
- package/pi/support/subagent.ts +5 -0
- package/skills/anchorloop/SKILL.md +14 -28
- package/skills/coding/MINI-SDD.md +2 -2
- package/skills/coding/SKILL.md +2 -2
- package/skills/coding/playbooks/feature.md +1 -1
package/pi/book.ts
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi extension: the loop book. The first `todo` create or `kind` declaration 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 { SEQUENCES, computePhases, describe, incompleteBefore, outsideWork } from "./support/phases.ts";
|
|
52
|
+
import { type Snapshot, snapshot } from "./support/snapshot.ts";
|
|
53
|
+
import { isSubagent } from "./support/subagent.ts";
|
|
54
|
+
|
|
55
|
+
export const SESSION_ENTRY = "anchorloop-loop";
|
|
56
|
+
const STAGES = ["kind", "verify", "fresh-read", "friction", "reflect", "skip", "back", "close", "read"] as const;
|
|
57
|
+
const REVIEWERS = new Set(["reviewer", "adversarial-reviewer"]);
|
|
58
|
+
const BLOCKING = /Blocking\s*:?\**\s*:?\s*P0\s*=\s*(\d+)\D{0,5}P1\s*=\s*(\d+)/gi;
|
|
59
|
+
const ANCHOR_LINE = /Reality anchor:[ \t]*(.*)/;
|
|
60
|
+
|
|
61
|
+
export const OPEN_LINE =
|
|
62
|
+
"anchorloop: a loop is open. Declare it with `anchorloop_stage` {stage: kind, kind: quick|inline|reviewed|durable, intent: <one line>, anchor: <check command>}.";
|
|
63
|
+
|
|
64
|
+
type Params = {
|
|
65
|
+
stage: (typeof STAGES)[number];
|
|
66
|
+
kind?: (typeof KINDS)[number];
|
|
67
|
+
intent?: string;
|
|
68
|
+
anchor?: string;
|
|
69
|
+
note?: string;
|
|
70
|
+
result?: "pass" | "fail";
|
|
71
|
+
tried?: string;
|
|
72
|
+
found?: string;
|
|
73
|
+
led_by?: string;
|
|
74
|
+
friction?: (string | number)[];
|
|
75
|
+
class?: "misdirection" | "gap" | "resolved";
|
|
76
|
+
action?: string;
|
|
77
|
+
phase?: string;
|
|
78
|
+
reason?: string;
|
|
79
|
+
verdict?: "landed" | "blocked" | "abandoned";
|
|
80
|
+
view?: "summary" | "all";
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type Block = { type?: string; text?: string };
|
|
84
|
+
type BranchEntry = { type?: string; message?: { role?: string; content?: unknown } };
|
|
85
|
+
|
|
86
|
+
/** The P0 and P1 counts of the last `Blocking: P0=<n> P1=<m>` line in a reviewer report, tolerant of markdown emphasis. */
|
|
87
|
+
export function parseBlocking(text: string): { p0: number; p1: number } | undefined {
|
|
88
|
+
const found = [...text.matchAll(BLOCKING)].at(-1);
|
|
89
|
+
return found ? { p0: Number(found[1]), p1: Number(found[2]) } : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function textOf(content: unknown): string {
|
|
93
|
+
if (typeof content === "string") return content;
|
|
94
|
+
if (!Array.isArray(content)) return "";
|
|
95
|
+
return (content as Block[]).map((block) => (block && typeof block.text === "string" ? block.text : "")).filter(Boolean).join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function lastLine(text: string): string {
|
|
99
|
+
return (
|
|
100
|
+
text
|
|
101
|
+
.split("\n")
|
|
102
|
+
.map((line) => line.trim())
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.at(-1) ?? ""
|
|
105
|
+
).slice(0, 200);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 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. */
|
|
109
|
+
export function bashExit(event: { isError?: boolean; structuredContent?: unknown; content?: unknown }): number | null {
|
|
110
|
+
const code = (event.structuredContent as { exit_code?: unknown } | undefined)?.exit_code;
|
|
111
|
+
if (typeof code === "number") return code;
|
|
112
|
+
if (event.isError !== true) return 0;
|
|
113
|
+
const named = /Command exited with code (\d+)/.exec(textOf(event.content));
|
|
114
|
+
return named ? Number(named[1]) : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The exit to record for a bash call that ran `anchor`: the call's exit code, except `unknown` when something after the anchor
|
|
119
|
+
* can replace it: `||`, `;`, a newline, or a pipe straight after the anchor unless the command starts with `set -o pipefail`.
|
|
120
|
+
* A chain `anchor && other` keeps a failed anchor's code, so it stands.
|
|
121
|
+
*/
|
|
122
|
+
export function anchorExit(command: string, anchor: string, exit: number | null): number | "unknown" {
|
|
123
|
+
if (exit === null) return "unknown";
|
|
124
|
+
const at = command.indexOf(anchor);
|
|
125
|
+
// `! anchor` inverts the code and `x || anchor` may never run it.
|
|
126
|
+
if (at < 0 || /(!|\|\|)\s*$/.test(command.slice(0, at))) return "unknown";
|
|
127
|
+
const rest = command.slice(at + anchor.length);
|
|
128
|
+
const pipefail = /^\s*set\s+-o\s+pipefail\b/.test(command);
|
|
129
|
+
let chained = false;
|
|
130
|
+
// `&&` keeps a failed anchor's exit; `||`, `;`, and a newline can replace it, and so can a pipe
|
|
131
|
+
// straight after the anchor unless pipefail is set.
|
|
132
|
+
for (const [separator] of rest.matchAll(/&&|\|\||\||(?<![<>&])&(?![&>])|;|\n/g)) {
|
|
133
|
+
if (separator === "&&") chained = true;
|
|
134
|
+
else if (separator === "|") {
|
|
135
|
+
if (!chained && !pipefail) return "unknown";
|
|
136
|
+
} else return "unknown";
|
|
137
|
+
}
|
|
138
|
+
return exit;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The text a `Reality anchor:` line states in an assistant message, if any. */
|
|
142
|
+
function statedAnchor(message: { role?: string; content?: unknown } | undefined): string | undefined {
|
|
143
|
+
if (message?.role !== "assistant") return undefined;
|
|
144
|
+
const found = ANCHOR_LINE.exec(textOf(message.content));
|
|
145
|
+
return found ? found[1].trim().slice(0, 300) || "(stated on the next line)" : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
|
|
149
|
+
|
|
150
|
+
export default function (pi: ExtensionAPI) {
|
|
151
|
+
const subagent = isSubagent();
|
|
152
|
+
function todosOf(ctx: ExtensionContext) {
|
|
153
|
+
return replayTodos(ctx.sessionManager.getBranch());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function phasesOf(book: Book, ctx: ExtensionContext) {
|
|
157
|
+
return computePhases(book, { cwd: ctx.cwd, todos: todosOf(ctx) });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The line that tells the agent Jev did not accept a returned subagent as the fresh read, while the read is still open. */
|
|
161
|
+
function freshReadLine(book: Book, phases: ReturnType<typeof phasesOf>): string[] {
|
|
162
|
+
const last = [...book.entries].reverse().find((entry) => entry.stage === "goldfish");
|
|
163
|
+
const open = phases.statuses.find((status) => status.name === "read")?.state === "missing";
|
|
164
|
+
return open && last?.source === "jev" && last.ok === false ? [`fresh read by ${String(last.child || "a subagent")} judged not fresh — declare \`fresh-read\` if it was`] : [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function summaryLines(book: Book, ctx: ExtensionContext): string[] {
|
|
168
|
+
const phases = phasesOf(book, ctx);
|
|
169
|
+
const verify = [...book.entries].reverse().find((entry) => entry.stage === "verify" || entry.stage === "check");
|
|
170
|
+
const open = todosOf(ctx).filter(isOpenTask).length;
|
|
171
|
+
const friction = allFriction(book.entries);
|
|
172
|
+
return [
|
|
173
|
+
describe(phases),
|
|
174
|
+
`intent: ${currentIntent(book.entries) ?? "not declared"}`,
|
|
175
|
+
`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"}`,
|
|
176
|
+
`reviews: ${reviewTrend(book.entries)}; open todos: ${open}; friction: ${friction.length} (${friction.filter((item) => !item.reflected).length} unreflected)`,
|
|
177
|
+
...childLines(book.entries).map((line) => `child ${line}`),
|
|
178
|
+
...friction.filter((item) => item.owner !== "main").map((item) => `friction ${item.ref}: ${item.tried} → ${item.found}${item.reflected ? ` [${item.class}]` : ""}`),
|
|
179
|
+
...phases.hints,
|
|
180
|
+
...freshReadLine(book, phases),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function recordTasks(book: Book, details: unknown, input: { action?: unknown; id?: unknown }, cwd: string): void {
|
|
185
|
+
const tasks = taskDetails(details) ?? [];
|
|
186
|
+
const known = new Set(latestTasks(book.entries).map((entry) => entry.id));
|
|
187
|
+
const touched = input.action === "create" ? tasks.filter((task) => !known.has(task.id)) : tasks.filter((task) => task.id === Number(input.id));
|
|
188
|
+
for (const task of touched) {
|
|
189
|
+
const refs = refsOf(task.metadata).map((ref) => ({ ref, range: resolveRef(ref, cwd, book.entries) }));
|
|
190
|
+
const { anchor, followup } = task.metadata ?? {};
|
|
191
|
+
book.entries.push(
|
|
192
|
+
appendEntry(book.path, "task", {
|
|
193
|
+
id: task.id,
|
|
194
|
+
subject: task.subject,
|
|
195
|
+
status: task.status,
|
|
196
|
+
...(typeof anchor === "string" ? { anchor } : {}),
|
|
197
|
+
...(refs.length ? { refs } : {}),
|
|
198
|
+
...(followup !== undefined ? { followup } : {}),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A `Reality anchor:` line stated since the last user message, recorded when a loop opens after it. */
|
|
205
|
+
function recordPriorAnchor(book: Book, ctx: ExtensionContext): void {
|
|
206
|
+
const branch = ctx.sessionManager.getBranch() as BranchEntry[];
|
|
207
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
208
|
+
const message = branch[i].type === "message" ? branch[i].message : undefined;
|
|
209
|
+
if (!message) continue;
|
|
210
|
+
if (message.role === "user") return;
|
|
211
|
+
const stated = statedAnchor(message);
|
|
212
|
+
if (stated) {
|
|
213
|
+
book.entries.push(appendEntry(book.path, "anchor", { check: stated, line: true }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function close(book: Book, ctx: ExtensionContext, verdict: string): { text: string; snapshot?: Snapshot } {
|
|
220
|
+
const todos = replayTodos(ctx.sessionManager.getBranch());
|
|
221
|
+
const undisposed = todos.filter((task) => isOpenTask(task) && !validFollowup(task.metadata?.followup));
|
|
222
|
+
if (undisposed.length > 0) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Cannot close: ${undisposed.length} todo(s) are pending or in progress without metadata.followup (a ticket id, \`memory\`, or \`drop — <reason>\`):\n` +
|
|
225
|
+
undisposed.map((task) => `- #${task.id} ${task.subject}`).join("\n"),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const kept = childId() !== undefined;
|
|
229
|
+
const kind = currentKind(book.entries);
|
|
230
|
+
const phases = phasesOf(book, ctx);
|
|
231
|
+
const missing = incompleteBefore(phases, "close");
|
|
232
|
+
const notice = verdict === "landed" && missing.length ? `anchorloop: closed with phases not shown complete: ${missing.map((status) => `${status.name} (${status.note})`).join("; ")}.` : undefined;
|
|
233
|
+
const diff = diffAgainstBases(book.entries);
|
|
234
|
+
const friction = allFriction(book.entries);
|
|
235
|
+
const phaseList = phases.statuses.map((status) => ({ phase: status.name, state: status.state === "skipped" && status.auto ? "auto-skipped" : status.state === "missing" ? "not shown complete" : status.state, ...(status.state === "skipped" ? { note: status.note } : {}) }));
|
|
236
|
+
const summary = {
|
|
237
|
+
intent: currentIntent(book.entries) ?? null,
|
|
238
|
+
kind: kind ?? null,
|
|
239
|
+
verdict,
|
|
240
|
+
stages: stagesDone(book.entries),
|
|
241
|
+
phases: phaseList,
|
|
242
|
+
reviewRounds: reviews(book.entries).length,
|
|
243
|
+
reviewTrend: reviewTrend(book.entries),
|
|
244
|
+
diff: diff.summary,
|
|
245
|
+
skipped: phases.skipped,
|
|
246
|
+
back: phases.backs,
|
|
247
|
+
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 })),
|
|
248
|
+
followups: todos.filter((task) => isOpenTask(task)).map((task) => ({ task: task.subject, followup: task.metadata?.followup })),
|
|
249
|
+
};
|
|
250
|
+
const unreflected = friction.filter((item) => !item.reflected);
|
|
251
|
+
const tail = unreflected.length
|
|
252
|
+
? ` Unreflected friction for the Reflexive Pass: ${unreflected.map((item) => `${item.ref} (${item.found})`).join("; ")}.`
|
|
253
|
+
: "";
|
|
254
|
+
const shown = phaseList.map((item) => `${item.phase} ${item.state}${item.note ? ` (${item.note})` : ""}`).join(", ");
|
|
255
|
+
const back = summary.back.length ? `; back ${summary.back.join(", ")}` : "";
|
|
256
|
+
const text = `Loop closed (${verdict}): phases ${shown}; ${summary.reviewTrend}; diff ${diff.summary.split("\n").join("; ")}${back}.${tail}`;
|
|
257
|
+
const after = notice ? `\n${notice}` : "";
|
|
258
|
+
if (kept) {
|
|
259
|
+
// A child's book stays for the main session to read and delete.
|
|
260
|
+
appendEntry(book.path, "close", { verdict, summary: text.slice(0, 500) });
|
|
261
|
+
return { text: `${text} The book stays for the main session.${after}` };
|
|
262
|
+
}
|
|
263
|
+
// Jev judges the loop as it stood at close; the book is gone by the time the result is judged.
|
|
264
|
+
const snap = kind === "quick" ? undefined : snapshot(book, ctx);
|
|
265
|
+
pi.appendEntry(SESSION_ENTRY, summary);
|
|
266
|
+
for (const id of childIds(book.entries)) {
|
|
267
|
+
const child = findChildBook(id);
|
|
268
|
+
if (child) deleteBook(child.path);
|
|
269
|
+
}
|
|
270
|
+
deleteBook(book.path);
|
|
271
|
+
return { text: `${text} The book is deleted. Clear the todo list now (todo action: clear).${after}`, snapshot: snap };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
pi.registerTool({
|
|
275
|
+
name: "anchorloop_stage",
|
|
276
|
+
label: "Loop stage",
|
|
277
|
+
description:
|
|
278
|
+
"Declare only what the extension cannot observe in the anchorloop loop book. " +
|
|
279
|
+
"`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. " +
|
|
280
|
+
"`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), " +
|
|
281
|
+
"`skip` / `back` (phase, reason), `close` (verdict), `read` (view the book). Edits, anchor runs, reviews, and spec files are read from evidence. " +
|
|
282
|
+
"A loop opens with the first `todo` create; `close` refuses while a pending or in-progress todo lacks metadata.followup.",
|
|
283
|
+
parameters: {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
stage: { type: "string", enum: [...STAGES] },
|
|
287
|
+
kind: { type: "string", enum: [...KINDS], description: "stage kind: the loop kind; a second call upgrades it" },
|
|
288
|
+
intent: { type: "string", description: "stage kind: one line on what the loop delivers" },
|
|
289
|
+
anchor: { type: "string", description: "stage kind: the check command whose exit code observes the result" },
|
|
290
|
+
note: { type: "string", description: "stage verify: what was observed; stage fresh-read: who read the spec" },
|
|
291
|
+
result: { type: "string", enum: ["pass", "fail"], description: "stage verify; default pass" },
|
|
292
|
+
tried: { type: "string", description: "stage friction: the attempt that failed" },
|
|
293
|
+
found: { type: "string", description: "stage friction: what the failure revealed" },
|
|
294
|
+
led_by: { type: "string", description: "stage friction: the skill or instruction line that prompted the attempt, or `none`" },
|
|
295
|
+
friction: { type: "array", items: { type: ["string", "number"] }, description: "stage reflect: friction ids (`3`, or `worker#2` for a child's)" },
|
|
296
|
+
class: { type: "string", enum: ["misdirection", "gap", "resolved"], description: "stage reflect" },
|
|
297
|
+
action: { type: "string", description: "stage reflect: what was done, such as solid-loop, memory, or a commit" },
|
|
298
|
+
phase: { type: "string", description: "stage skip or back: the phase" },
|
|
299
|
+
reason: { type: "string", description: "stage skip or back: why" },
|
|
300
|
+
verdict: { type: "string", enum: ["landed", "blocked", "abandoned"], description: "stage close" },
|
|
301
|
+
view: { type: "string", enum: ["summary", "all"], description: "stage read; default summary" },
|
|
302
|
+
},
|
|
303
|
+
required: ["stage"],
|
|
304
|
+
additionalProperties: false,
|
|
305
|
+
},
|
|
306
|
+
async execute(_toolCallId: string, raw: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
307
|
+
const params = raw as Params;
|
|
308
|
+
const { stage } = params;
|
|
309
|
+
if (!(STAGES as readonly string[]).includes(stage)) throw new Error(`unknown stage \`${String(stage)}\`; declare one of ${STAGES.join(", ")}`);
|
|
310
|
+
let book = openLoop(ctx);
|
|
311
|
+
if (!book && stage === "kind") {
|
|
312
|
+
// A todo create in the same batch may not have landed yet; whichever comes first opens the loop.
|
|
313
|
+
if (!params.kind || !params.intent) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
|
|
314
|
+
book = createBook(ctx);
|
|
315
|
+
recordPriorAnchor(book, ctx);
|
|
316
|
+
}
|
|
317
|
+
if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create or `kind`.");
|
|
318
|
+
const had = currentKind(book.entries);
|
|
319
|
+
const sequence = SEQUENCES[had ?? "inline"];
|
|
320
|
+
if (stage === "read") {
|
|
321
|
+
const lines = [...summaryLines(book, ctx), ...taskLines(book.entries, todosOf(ctx), ctx.cwd)];
|
|
322
|
+
if (params.view === "all") lines.push("entries:", ...book.entries.map((entry) => JSON.stringify(entry).slice(0, 300)));
|
|
323
|
+
return reply(lines.join("\n"));
|
|
324
|
+
}
|
|
325
|
+
if (stage === "close") {
|
|
326
|
+
if (!params.verdict) throw new Error("close needs a verdict: landed, blocked, or abandoned");
|
|
327
|
+
const closed = close(book, ctx, params.verdict);
|
|
328
|
+
return { content: [{ type: "text" as const, text: closed.text }], details: closed.snapshot ? { snapshot: closed.snapshot } : undefined };
|
|
329
|
+
}
|
|
330
|
+
if (stage === "kind") {
|
|
331
|
+
if (!params.kind && !params.intent && !params.anchor) throw new Error("kind needs `kind`, `intent`, or `anchor`");
|
|
332
|
+
if (!had && (!params.kind || !params.intent)) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
|
|
333
|
+
const notes: string[] = [];
|
|
334
|
+
if (params.kind || params.intent) {
|
|
335
|
+
appendEntry(book.path, "kind", { kind: params.kind ?? had, ...(params.intent ? { intent: params.intent } : {}) });
|
|
336
|
+
notes.push(had && params.kind && had !== params.kind ? `kind changed: ${had} → ${params.kind}` : `kind: ${params.kind ?? had}`);
|
|
337
|
+
}
|
|
338
|
+
if (params.anchor) {
|
|
339
|
+
appendEntry(book.path, "anchor", { check: params.anchor });
|
|
340
|
+
notes.push(`anchor: ${params.anchor}`);
|
|
341
|
+
}
|
|
342
|
+
touchRepo(book, join(ctx.cwd, "_"));
|
|
343
|
+
return reply(notes.join("\n"));
|
|
344
|
+
}
|
|
345
|
+
if (stage === "verify") {
|
|
346
|
+
if (!params.note) throw new Error("verify needs a `note` saying what was observed");
|
|
347
|
+
const result = params.result ?? "pass";
|
|
348
|
+
const recorded = childId() !== undefined || had === "quick" ? "check" : "verify";
|
|
349
|
+
appendEntry(book.path, recorded, { declared: true, note: params.note, result, exit: result === "pass" ? 0 : 1 });
|
|
350
|
+
return reply(`${recorded} declared: ${result} — ${params.note}`);
|
|
351
|
+
}
|
|
352
|
+
if (stage === "fresh-read") {
|
|
353
|
+
appendEntry(book.path, "goldfish", { source: "declared", ...(params.note ? { note: params.note } : {}) });
|
|
354
|
+
return reply("fresh-read recorded.");
|
|
355
|
+
}
|
|
356
|
+
if (stage === "friction") {
|
|
357
|
+
if (!params.tried || !params.found) throw new Error("friction needs `tried` and `found`");
|
|
358
|
+
const id = frictionOf(book.entries).length + 1;
|
|
359
|
+
appendEntry(book.path, "friction", { id, tried: params.tried, found: params.found, led_by: params.led_by || "none" });
|
|
360
|
+
return reply(`friction #${id} recorded.`);
|
|
361
|
+
}
|
|
362
|
+
if (stage === "reflect") {
|
|
363
|
+
const known = allFriction(book.entries).map((item) => item.ref);
|
|
364
|
+
const ids = (params.friction ?? []).map(String);
|
|
365
|
+
if (ids.length === 0 || !params.class) throw new Error(`reflect needs \`friction\` ids (${known.join(", ") || "none recorded"}) and \`class\``);
|
|
366
|
+
const unknown = ids.filter((id) => !known.includes(id));
|
|
367
|
+
if (unknown.length) throw new Error(`unknown friction ${unknown.join(", ")}; known: ${known.join(", ") || "none"}`);
|
|
368
|
+
appendEntry(book.path, "reflect", { friction: ids, class: params.class, ...(params.action ? { action: params.action } : {}) });
|
|
369
|
+
return reply(`reflect recorded for ${ids.join(", ")} (${params.class}).`);
|
|
370
|
+
}
|
|
371
|
+
// skip and back
|
|
372
|
+
const phase = params.phase;
|
|
373
|
+
const known = sequence.filter((name) => name !== "close");
|
|
374
|
+
if (!phase || !known.includes(phase)) throw new Error(`${stage} needs \`phase\`, one of ${known.join(", ")} for a ${had ?? "undeclared"} loop`);
|
|
375
|
+
if (!params.reason) throw new Error(`${stage} needs a \`reason\``);
|
|
376
|
+
appendEntry(book.path, stage, { phase, reason: params.reason });
|
|
377
|
+
return reply(stage === "skip" ? `${phase}: skipped (${params.reason}).` : `back to ${phase} (${params.reason}); later phases count as incomplete until their conditions hold again.`);
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
pi.on(
|
|
382
|
+
"session_start",
|
|
383
|
+
guarded((_event, ctx) => {
|
|
384
|
+
const swept = sweep(process.env, ctx.cwd);
|
|
385
|
+
const lines = [
|
|
386
|
+
...swept.deleted.map((path) => `deleted stale loop book: ${path}`),
|
|
387
|
+
...swept.open.map((path) => `open loop book: ${path}`),
|
|
388
|
+
];
|
|
389
|
+
if (lines.length > 0) ctx.ui.notify(`anchorloop books\n${lines.join("\n")}`, "info");
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
pi.on(
|
|
394
|
+
"tool_call",
|
|
395
|
+
guarded((event, ctx) => {
|
|
396
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
397
|
+
const path = (event.input as { path?: unknown }).path;
|
|
398
|
+
const book = typeof path === "string" ? openLoop(ctx) : undefined;
|
|
399
|
+
if (book && typeof path === "string") touchRepo(book, resolve(ctx.cwd, path));
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
/** Records what a finished bash call shows: runs of the loop anchor, of task anchors, and a first commit as the change. */
|
|
404
|
+
function recordBash(book: Book, event: { input: unknown; isError?: boolean; structuredContent?: unknown; content?: unknown }): void {
|
|
405
|
+
const structured = event.structuredContent as { output?: unknown } | undefined;
|
|
406
|
+
const output = typeof structured?.output === "string" ? structured.output : textOf(event.content);
|
|
407
|
+
const command = String((event.input as { command?: unknown }).command ?? "");
|
|
408
|
+
const exit = bashExit(event);
|
|
409
|
+
const last = lastLine(output);
|
|
410
|
+
const anchor = anchorCommand(book.entries);
|
|
411
|
+
if (anchor && command.includes(anchor)) {
|
|
412
|
+
const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
|
|
413
|
+
book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: anchorExit(command, anchor, exit), last }));
|
|
414
|
+
}
|
|
415
|
+
for (const task of latestTasks(book.entries)) {
|
|
416
|
+
if (typeof task.anchor === "string" && command.includes(task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit: anchorExit(command, task.anchor, exit), last });
|
|
417
|
+
}
|
|
418
|
+
if (exit === 0 && /\bgit(\s+-C\s+\S+)?\s+(merge|cherry-pick|pull)(?=\s|$)(?![^|;&]*--(abort|quit)\b)/.test(command) && !/Already up to date/i.test(last ?? "") && book.entries.at(-1)?.stage !== "edit") {
|
|
419
|
+
book.entries.push(appendEntry(book.path, "edit", { via: "merge" }));
|
|
420
|
+
}
|
|
421
|
+
if (exit === 0 && /\bgit\b[^|;&]*\bcommit\b/.test(command) && !book.entries.some((entry) => entry.stage === "edit")) {
|
|
422
|
+
const cwd = String(book.header.cwd ?? "");
|
|
423
|
+
if (diffAgainstBases(book.entries).paths.some((path) => !outsideWork(path, cwd))) appendEntry(book.path, "edit", { via: "commit" });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
pi.on(
|
|
428
|
+
"tool_result",
|
|
429
|
+
guarded((event, ctx) => {
|
|
430
|
+
if (event.toolName === "bash") {
|
|
431
|
+
const book = openLoop(ctx);
|
|
432
|
+
if (book) recordBash(book, event);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (event.isError) return;
|
|
436
|
+
if (event.toolName === "todo") {
|
|
437
|
+
const input = event.input as { action?: unknown; id?: unknown };
|
|
438
|
+
let open = openLoop(ctx);
|
|
439
|
+
const opens = !open && input.action === "create";
|
|
440
|
+
if (!open && opens) {
|
|
441
|
+
open = createBook(ctx);
|
|
442
|
+
recordPriorAnchor(open, ctx);
|
|
443
|
+
}
|
|
444
|
+
if (!open) return;
|
|
445
|
+
recordTasks(open, event.details, input, ctx.cwd);
|
|
446
|
+
return opens ? { content: [...event.content, { type: "text" as const, text: OPEN_LINE }] } : undefined;
|
|
447
|
+
}
|
|
448
|
+
const book = openLoop(ctx);
|
|
449
|
+
if (!book) return;
|
|
450
|
+
if (event.toolName === "subagent") {
|
|
451
|
+
const id = (event.details as { id?: unknown } | undefined)?.id;
|
|
452
|
+
if (typeof id === "string") {
|
|
453
|
+
const details = event.details as { name?: unknown; agent?: unknown };
|
|
454
|
+
appendEntry(book.path, "link", { child: id, name: details.name, agent: details.agent });
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
459
|
+
const path = (event.input as { path?: unknown }).path;
|
|
460
|
+
if (typeof path !== "string") return;
|
|
461
|
+
if (!artifactKind(path)) {
|
|
462
|
+
if (!outsideWork(path, ctx.cwd) && book.entries.at(-1)?.stage !== "edit") appendEntry(book.path, "edit", { path });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
let text: string;
|
|
466
|
+
try {
|
|
467
|
+
text = readFileSync(resolve(ctx.cwd, path), "utf-8");
|
|
468
|
+
} catch {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const summary = summarizeArtifact(book.entries, path, text);
|
|
472
|
+
if (summary) appendEntry(book.path, summary.stage, summary.fields);
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
async function recordReview(ctx: ExtensionContext, book: Book, name: string, text: string, failed: boolean): Promise<void> {
|
|
477
|
+
const counts = parseBlocking(text);
|
|
478
|
+
let blocking: boolean | null = null;
|
|
479
|
+
let source = `reviewer ${name}`;
|
|
480
|
+
if (failed || /\bStatus:?\*{0,2}\s*INCOMPLETE\b/i.test(text)) {
|
|
481
|
+
source = "failed";
|
|
482
|
+
} else if (!counts) {
|
|
483
|
+
source = "jev";
|
|
484
|
+
const { answers } = await classify(ctx, { review: text.slice(-3000) }, {
|
|
485
|
+
blocking: {
|
|
486
|
+
type: "bool",
|
|
487
|
+
instructions: "Does `review` report any blocking finding, a P0 or P1 defect that must be fixed before the change is merged?",
|
|
488
|
+
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" },
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
const probability = answers?.blocking?.type === "bool" ? answers.blocking.probability : undefined;
|
|
492
|
+
blocking = probability === undefined ? null : probability >= 0.5;
|
|
493
|
+
} else {
|
|
494
|
+
blocking = counts.p0 + counts.p1 > 0;
|
|
495
|
+
}
|
|
496
|
+
const entry = appendEntry(book.path, "review", { round: reviews(book.entries).length + 1, ...(source === "failed" ? {} : (counts ?? {})), blocking, source });
|
|
497
|
+
book.entries.push(entry);
|
|
498
|
+
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");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const HOST_LINE = /^\s*(Parent action:|Requested model|Session:|Resume:)/i;
|
|
502
|
+
|
|
503
|
+
/** The text without the lines pi-herdr adds around a subagent brief or result. */
|
|
504
|
+
function strip(text: string): string {
|
|
505
|
+
return text
|
|
506
|
+
.split("\n")
|
|
507
|
+
.filter((line) => !HOST_LINE.test(line))
|
|
508
|
+
.join("\n")
|
|
509
|
+
.trim();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function headAndTail(text: string): string {
|
|
513
|
+
return text.length <= 2500 ? text : `${text.slice(0, 1000)}\n…\n${text.slice(-1500)}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 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. */
|
|
517
|
+
async function detectFreshRead(ctx: ExtensionContext, book: Book, details: { name?: unknown; task?: unknown; resultContent?: unknown }): Promise<void> {
|
|
518
|
+
const phases = phasesOf(book, ctx);
|
|
519
|
+
const read = phases.statuses.find((status) => status.name === "read");
|
|
520
|
+
if (read?.state !== "missing" || !read.note.startsWith("a fresh-context")) return;
|
|
521
|
+
const folder = specFolder(book.entries, todosOf(ctx), ctx.cwd);
|
|
522
|
+
const brief = String(details.task ?? "");
|
|
523
|
+
if (!folder || !(brief.includes(`${folder}/spec.md`) || brief.includes(resolve(ctx.cwd, folder, "spec.md")))) return;
|
|
524
|
+
const { answers } = await classify(ctx, { brief: strip(brief).slice(0, 1500), result: headAndTail(strip(textOf(details.resultContent))) }, {
|
|
525
|
+
fresh_read: {
|
|
526
|
+
type: "bool",
|
|
527
|
+
instructions: "Did this subagent read the spec and report on it: does its `result` restate the spec, or list gaps, guesses, or questions about it? The `brief` only needs to have handed it the spec.",
|
|
528
|
+
criteria: { true: "the result restates the spec or lists gaps, guesses, or questions about it", false: "the subagent did other work and its result does not report on the spec" },
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
const probability = answers?.fresh_read?.type === "bool" ? answers.fresh_read.probability : undefined;
|
|
532
|
+
if (probability === undefined) return;
|
|
533
|
+
appendEntry(book.path, "goldfish", { source: "jev", ok: probability >= 0.5, child: String(details.name ?? "") });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
pi.on(
|
|
537
|
+
"message_end",
|
|
538
|
+
guarded(async (event, ctx) => {
|
|
539
|
+
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 } };
|
|
540
|
+
const book = openLoop(ctx);
|
|
541
|
+
if (!book) return;
|
|
542
|
+
const stated = statedAnchor(message);
|
|
543
|
+
if (stated && !book.entries.some((entry) => entry.stage === "anchor" && entry.check === stated)) appendEntry(book.path, "anchor", { check: stated, line: true });
|
|
544
|
+
if (message.role !== "custom" || message.customType !== "subagent_result" || subagent) return;
|
|
545
|
+
const agent = String(message.details?.agent ?? "");
|
|
546
|
+
if (REVIEWERS.has(agent)) {
|
|
547
|
+
const d = message.details ?? {};
|
|
548
|
+
const failed = Boolean(d.error) || Boolean(d.errorMessage) || (typeof d.exitCode === "number" && d.exitCode !== 0);
|
|
549
|
+
await recordReview(ctx, book, String(d.name ?? agent), textOf(d.resultContent), failed);
|
|
550
|
+
}
|
|
551
|
+
else await detectFreshRead(ctx, book, message.details ?? {});
|
|
552
|
+
}),
|
|
553
|
+
);
|
|
554
|
+
}
|