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/second-thought.ts
CHANGED
|
@@ -1,94 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi extension:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* and is
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* check as handing work back. A wrong review tier or low quality gets one
|
|
13
|
-
* advisory continuation per run; a low done score is only shown. The agent is never
|
|
14
|
-
* blocked. Only the request, the trigger, the wake message, and the final reply are sent.
|
|
15
|
-
* A subagent session registers nothing: the agent that spawned it judges its result.
|
|
2
|
+
* pi extension: inside an open loop, asks Jev to check the phase machine's reading of the loop book. The
|
|
3
|
+
* program computes the next phase from the book's evidence; Jev sees the kind, intent, anchor, diff
|
|
4
|
+
* summary, phase (current, next, missing), verify state, open todos, tasks, and friction, and judges only
|
|
5
|
+
* whether the current phase's completion condition truly holds and whether to advance, go back, or ask
|
|
6
|
+
* the user. It runs when a run settles and the book gained an entry since the last judgment, and at a
|
|
7
|
+
* `close` declaration. The advisory is one line leading with `next → <phase>`; it continues the run once
|
|
8
|
+
* when Jev agrees to advance and the next phase is a verify, review, or fix. A run that arranged a wake
|
|
9
|
+
* (a monitor, a loop, a subagent) is waiting, not finished, and is not judged. Outside a loop nothing
|
|
10
|
+
* runs, nor in a `quick` loop, whose only prompt is the close reminder; a subagent session registers
|
|
11
|
+
* nothing, since the agent that spawned it judges its result.
|
|
16
12
|
*/
|
|
17
13
|
|
|
18
|
-
import {
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { currentKind, guarded, openLoop } from "./support/book.ts";
|
|
16
|
+
import { type Answers, classify } from "./support/jev.ts";
|
|
17
|
+
import { type Snapshot, snapshot } from "./support/snapshot.ts";
|
|
18
|
+
import { isSubagent } from "./support/subagent.ts";
|
|
19
|
+
|
|
20
|
+
export { isSubagent, snapshot };
|
|
21
|
+
export type { Snapshot };
|
|
19
22
|
|
|
20
23
|
export const ENTRY_TYPE = "anchorloop-second-thought";
|
|
21
|
-
const WORK_TOOLS = new Set(["write", "edit", "bash"]);
|
|
22
24
|
const WAIT_TOOLS = new Set(["MonitorCreate", "LoopCreate", "goal_wait", "subagent", "subagent_resume", "subagent_send"]);
|
|
23
|
-
const REQUEST_CHARS = 1500;
|
|
24
|
-
const WAKE_CHARS = 1500;
|
|
25
|
-
const REPLY_CHARS = 3000;
|
|
26
|
-
const TIMEOUT_MS = 15_000;
|
|
27
25
|
|
|
28
|
-
export const THRESHOLDS = {
|
|
26
|
+
export const THRESHOLDS = { completeBelow: 0.5, doneBelow: 0.25, qualityBelow: 1.5, reviewTier: "full" };
|
|
27
|
+
|
|
28
|
+
export const MOVES = ["advance", "back", "ask-user"] as const;
|
|
29
|
+
export type Move = (typeof MOVES)[number];
|
|
30
|
+
|
|
31
|
+
/** The phases whose missing evidence the agent can supply by acting, so Jev's agreement continues the run. */
|
|
32
|
+
const ACTIONABLE = new Set(["verify", "check", "review", "fix"]);
|
|
29
33
|
|
|
30
34
|
export const QUESTIONS = {
|
|
31
|
-
|
|
32
|
-
type: "
|
|
35
|
+
move: {
|
|
36
|
+
type: "choice" as const,
|
|
33
37
|
instructions:
|
|
34
|
-
"
|
|
38
|
+
"The program computed `phase` from the loop book: the last complete phase, the next one, and what the next lacks. Given `kind`, `intent`, `anchor`, `diff`, `verify` (check evidence and reviews), `todos`, `tasks`, and `friction`, should the agent move on to the next phase, go back because the evidence of an earlier phase does not hold, or ask the user?",
|
|
35
39
|
criteria: {
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
advance: "the recorded evidence supports the last complete phase and the next phase can proceed",
|
|
41
|
+
back: "evidence recorded for an earlier phase is wrong, stale, or contradicted by a later result",
|
|
42
|
+
"ask-user": "a decision or judgment that belongs to the user blocks progress",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
complete: {
|
|
46
|
+
type: "bool" as const,
|
|
47
|
+
instructions: "Does the evidence in `diff`, `verify`, and `phase` show that the completion condition of the last complete phase (`phase` current) truly holds, beyond the program's mechanical check?",
|
|
48
|
+
criteria: {
|
|
49
|
+
true: "the evidence observably meets what the phase requires",
|
|
50
|
+
false: "the phase counts as complete on a mechanical signal, but its content is empty, stale, or off the intent",
|
|
38
51
|
},
|
|
39
52
|
},
|
|
40
53
|
done: {
|
|
41
54
|
type: "bool" as const,
|
|
42
|
-
instructions:
|
|
43
|
-
"Does `reply` show, with evidence, that the work this run took on is fully met? That work is `request`, or, when `trigger` is `wake`, what `wake` asks in service of `request`.",
|
|
55
|
+
instructions: "Does the `verify` evidence show, with observed results, that `intent` is fully met?",
|
|
44
56
|
criteria: {
|
|
45
|
-
true: "every part of the
|
|
57
|
+
true: "every part of the intent has observed evidence: command output, a test result, a review verdict, or an artifact",
|
|
46
58
|
false: "some part failed, was skipped, is left for later, or rests only on a claim",
|
|
47
59
|
},
|
|
48
60
|
},
|
|
49
61
|
review: {
|
|
50
62
|
type: "choice" as const,
|
|
51
|
-
instructions: "What review should the
|
|
63
|
+
instructions: "What review should the `diff` still get before it is merged or released, given the reviews recorded in `verify`?",
|
|
52
64
|
criteria: {
|
|
53
|
-
none: "trivial, fully covered by a passing check",
|
|
65
|
+
none: "trivial, fully covered by a passing check, or already reviewed with no blocking finding",
|
|
54
66
|
focused: "one independent reviewer on the changed lines",
|
|
55
67
|
full: "independent review plus an adversarial pass: security, data, permissions, migration, or a shared contract",
|
|
56
68
|
},
|
|
57
69
|
},
|
|
58
70
|
quality: {
|
|
59
71
|
type: "score" as const,
|
|
60
|
-
instructions: "How acceptable is the
|
|
72
|
+
instructions: "How acceptable is the work recorded in the loop against its `intent`?",
|
|
61
73
|
criteria: [
|
|
62
74
|
"unacceptable: wrong or broken",
|
|
63
75
|
"weak: works but misses requirements",
|
|
64
|
-
"acceptable: meets the
|
|
76
|
+
"acceptable: meets the intent",
|
|
65
77
|
"strong: meets it with direct evidence and no excess",
|
|
66
78
|
],
|
|
67
79
|
},
|
|
68
80
|
};
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
// the 21 status or liveness replies instead of all 21, and 3 of the 5 delivered results.
|
|
72
|
-
export const WAKE_CHECKPOINT = {
|
|
73
|
-
type: "bool" as const,
|
|
74
|
-
instructions:
|
|
75
|
-
"Is `reply` a point where the agent hands work back to the user? `trigger` is `user` when the user's message started this run and `wake` when an automated message (a monitor, a loop, another agent) did; `wake` is that message. A reply to a routine check that `wake` asked for is not a checkpoint.",
|
|
76
|
-
criteria: {
|
|
77
|
-
true: "the reply delivers a finished result or artifact, reports the user's request finished or blocked, or asks the user to decide",
|
|
78
|
-
false: "the reply acknowledges a message, reports the status, health, load, or progress of work that is still running, or says the agent is waiting on something",
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
type Block = { type: string; text?: string };
|
|
83
|
-
type Answers = Record<string, { type: string; probability?: number; choice?: string; probabilities?: Record<string, number>; score?: number }>;
|
|
84
|
-
export type Verdict = { checkpoint: number; done: number; review: string; reviewProbability: number; quality: number };
|
|
82
|
+
export type Verdict = { move: Move; complete: number; done: number; review: string; reviewProbability: number; quality: number };
|
|
85
83
|
|
|
86
84
|
export function readVerdict(answers: Answers): Verdict | undefined {
|
|
87
|
-
const {
|
|
88
|
-
if (
|
|
85
|
+
const { move, complete, done, review, quality } = answers;
|
|
86
|
+
if (move?.type !== "choice" || complete?.type !== "bool" || done?.type !== "bool" || review?.type !== "choice" || quality?.type !== "score") return undefined;
|
|
87
|
+
if (!(MOVES as readonly string[]).includes(move.choice ?? "")) return undefined;
|
|
89
88
|
const choice = review.choice ?? "";
|
|
90
89
|
return {
|
|
91
|
-
|
|
90
|
+
move: move.choice as Move,
|
|
91
|
+
complete: complete.probability ?? 0,
|
|
92
92
|
done: done.probability ?? 0,
|
|
93
93
|
review: choice,
|
|
94
94
|
reviewProbability: review.probabilities?.[choice] ?? 0,
|
|
@@ -96,147 +96,116 @@ export function readVerdict(answers: Answers): Verdict | undefined {
|
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
export function
|
|
99
|
+
export function assess(verdict: Verdict, snap: Snapshot): { found: string[]; notes: string[] } {
|
|
100
100
|
const found: string[] = [];
|
|
101
|
-
|
|
102
|
-
if (verdict.
|
|
103
|
-
if (verdict.
|
|
104
|
-
if (verdict.
|
|
105
|
-
|
|
101
|
+
const notes: string[] = [];
|
|
102
|
+
if (verdict.complete < THRESHOLDS.completeBelow && snap.current !== "start") found.push(`${snap.current} not shown to be complete`);
|
|
103
|
+
if (verdict.done < THRESHOLDS.doneBelow) found.push("evidence not shown to cover the intent");
|
|
104
|
+
if (verdict.review === THRESHOLDS.reviewTier) {
|
|
105
|
+
if (snap.reviewClear) notes.push("review rated full, but a review recorded after the last change found no blocking finding");
|
|
106
|
+
else found.push("needs full independent review");
|
|
107
|
+
}
|
|
108
|
+
if (verdict.quality < THRESHOLDS.qualityBelow) found.push("result below the intent");
|
|
109
|
+
return { found, notes };
|
|
106
110
|
}
|
|
107
111
|
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
/** True when Jev agrees to advance and the next phase is one the agent can act on now. */
|
|
113
|
+
export function continues(verdict: Verdict, snap: Snapshot): boolean {
|
|
114
|
+
return verdict.move === "advance" && ACTIONABLE.has(snap.next);
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
|
|
117
|
+
/** One line: the program's next phase and its gap, Jev's move and scores, and, away from close, the call to act. */
|
|
118
|
+
export function message(verdict: Verdict, snap: Snapshot, found: string[], notes: string[], atClose = false): string {
|
|
119
|
+
const extra = [...found, ...notes, ...snap.hints];
|
|
113
120
|
return (
|
|
114
|
-
`Second thought (Jev, advisory):
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
`Second thought (Jev, advisory): next → ${snap.next}${snap.missing ? ` (missing: ${snap.missing})` : ""}; ` +
|
|
122
|
+
`${verdict.move}; complete ${verdict.complete.toFixed(2)}, done ${verdict.done.toFixed(2)}, review ${verdict.review}, quality ${verdict.quality.toFixed(2)}/3` +
|
|
123
|
+
`${extra.length ? `; ${extra.join("; ")}` : ""}.` +
|
|
124
|
+
(!atClose && continues(verdict, snap) ? ` Do ${snap.next} now, or say why it does not apply.` : "")
|
|
118
125
|
);
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
function text(content: unknown): string {
|
|
122
|
-
if (typeof content === "string") return content.trim();
|
|
123
|
-
if (!Array.isArray(content)) return "";
|
|
124
|
-
return (content as Block[])
|
|
125
|
-
.filter((block) => block.type === "text")
|
|
126
|
-
.map((block) => block.text ?? "")
|
|
127
|
-
.join("\n")
|
|
128
|
-
.trim();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// pi-herdr-agents marks its children with PI_SUBAGENT_ID, pi-subagents with PI_SUBAGENT_CHILD,
|
|
132
|
-
// and avtc's pi-subagent with PI_SUBAGENT_CHILD_AGENT plus PI_SUBAGENT_PARENT_PID.
|
|
133
|
-
export function isSubagent(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
134
|
-
return Boolean(env.PI_SUBAGENT_ID) || env.PI_SUBAGENT_CHILD === "1" || Boolean(env.PI_SUBAGENT_CHILD_AGENT && env.PI_SUBAGENT_PARENT_PID);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
128
|
export default function (pi: ExtensionAPI) {
|
|
138
129
|
if (isSubagent()) return;
|
|
139
|
-
let request = "";
|
|
140
|
-
let reply = "";
|
|
141
|
-
let worked = false;
|
|
142
130
|
let waiting = false;
|
|
143
|
-
let
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// since another handler may swallow an input or a queue may be cleared before it lands.
|
|
150
|
-
const sent = new Map<string, number>();
|
|
131
|
+
let continued = false;
|
|
132
|
+
// Entries each open book held when it was last judged, and the advisory it last received.
|
|
133
|
+
const judged = new Map<string, number>();
|
|
134
|
+
const told = new Map<string, string>();
|
|
135
|
+
// A close deletes its book, so what Jev needs is read when the call starts.
|
|
136
|
+
const closing = new Map<string, Snapshot>();
|
|
151
137
|
|
|
152
138
|
pi.on("session_start", () => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
pi.on("input", (event) => {
|
|
157
|
-
if (event.source !== "extension") return;
|
|
158
|
-
const said = event.text.trim();
|
|
159
|
-
sent.set(said, (sent.get(said) ?? 0) + 1);
|
|
139
|
+
judged.clear();
|
|
140
|
+
told.clear();
|
|
141
|
+
closing.clear();
|
|
160
142
|
});
|
|
161
143
|
|
|
162
|
-
// Each run is judged once, on its own work. State resets once the session settles, so a
|
|
163
|
-
// continuation (this extension's recheck, or one after compaction) stays in the same run.
|
|
164
|
-
// An extension text still pending then was swallowed or dropped and will not land.
|
|
165
144
|
pi.on("agent_settled", () => {
|
|
166
|
-
sent.clear();
|
|
167
|
-
reply = "";
|
|
168
|
-
worked = false;
|
|
169
145
|
waiting = false;
|
|
170
|
-
|
|
171
|
-
trigger = "wake";
|
|
172
|
-
wake = "";
|
|
146
|
+
continued = false;
|
|
173
147
|
});
|
|
174
148
|
|
|
175
|
-
pi.on(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
149
|
+
pi.on(
|
|
150
|
+
"tool_call",
|
|
151
|
+
guarded((event, ctx) => {
|
|
152
|
+
if (WAIT_TOOLS.has(event.toolName)) waiting = true;
|
|
153
|
+
if (event.toolName !== "anchorloop_stage" || (event.input as { stage?: unknown }).stage !== "close") return;
|
|
154
|
+
const book = openLoop(ctx);
|
|
155
|
+
if (book && currentKind(book.entries) !== "quick") closing.set(event.toolCallId, snapshot(book, ctx));
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
179
158
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
reply = said;
|
|
203
|
-
}
|
|
204
|
-
});
|
|
159
|
+
async function judge(ctx: Parameters<typeof classify>[0], snap: Snapshot) {
|
|
160
|
+
const { answers, reason } = await classify(ctx, snap.state, QUESTIONS);
|
|
161
|
+
if (reason === "no-auth") return undefined;
|
|
162
|
+
return { verdict: answers ? readVerdict(answers) : undefined, reason };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pi.on(
|
|
166
|
+
"tool_result",
|
|
167
|
+
guarded(async (event, ctx) => {
|
|
168
|
+
const called = closing.get(event.toolCallId);
|
|
169
|
+
if (called) closing.delete(event.toolCallId);
|
|
170
|
+
if (event.isError) return;
|
|
171
|
+
// The close tool reports the loop as it stood when it closed, after any reflect that ran beside it.
|
|
172
|
+
const snap = (event.details as { snapshot?: Snapshot } | undefined)?.snapshot ?? called;
|
|
173
|
+
if (!snap) return;
|
|
174
|
+
const judgment = await judge(ctx, snap);
|
|
175
|
+
if (!judgment?.verdict) return;
|
|
176
|
+
const { found, notes } = assess(judgment.verdict, snap);
|
|
177
|
+
pi.appendEntry(ENTRY_TYPE, { ...judgment.verdict, at: "close", concerns: found });
|
|
178
|
+
return { content: [...event.content, { type: "text" as const, text: `\n${message(judgment.verdict, snap, found, notes, true)}` }] };
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
205
181
|
|
|
206
|
-
pi.on(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
entries: [
|
|
236
|
-
record({ ...verdict, concerns: found }),
|
|
237
|
-
{ type: "custom_message" as const, customType: ENTRY_TYPE, content: message(verdict, found, recheck), display: true },
|
|
238
|
-
],
|
|
239
|
-
...(recheck ? { continue: true } : {}),
|
|
240
|
-
};
|
|
241
|
-
});
|
|
182
|
+
pi.on(
|
|
183
|
+
"agent_before_settle",
|
|
184
|
+
guarded(async (event, ctx) => {
|
|
185
|
+
if (waiting || event.outcome !== "completed") return;
|
|
186
|
+
const book = openLoop(ctx);
|
|
187
|
+
// A quick loop has no prompts but the close reminder.
|
|
188
|
+
if (!book || currentKind(book.entries) === "quick") return;
|
|
189
|
+
const id = String(book.header.id);
|
|
190
|
+
if (book.entries.length <= (judged.get(id) ?? 0)) return;
|
|
191
|
+
judged.set(id, book.entries.length);
|
|
192
|
+
const snap = snapshot(book, ctx);
|
|
193
|
+
const judgment = await judge(ctx, snap);
|
|
194
|
+
if (!judgment) return;
|
|
195
|
+
const record = (data: Record<string, unknown>) => ({ type: "custom" as const, customType: ENTRY_TYPE, data });
|
|
196
|
+
// A later handler's boundary result replaces the entries an earlier one returned, so carry them along.
|
|
197
|
+
const earlier = event.entries ?? [];
|
|
198
|
+
if (!judgment.verdict) return { entries: [...earlier, record({ verdict: "unknown", reason: judgment.reason })] };
|
|
199
|
+
const { found, notes } = assess(judgment.verdict, snap);
|
|
200
|
+
const text = message(judgment.verdict, snap, found, notes);
|
|
201
|
+
if (told.get(id) === text) return;
|
|
202
|
+
told.set(id, text);
|
|
203
|
+
const proceed = !continued && continues(judgment.verdict, snap);
|
|
204
|
+
if (proceed) continued = true;
|
|
205
|
+
return {
|
|
206
|
+
entries: [...earlier, record({ ...judgment.verdict, concerns: found }), { type: "custom_message" as const, customType: ENTRY_TYPE, content: text, display: true }],
|
|
207
|
+
...(proceed ? { continue: true } : {}),
|
|
208
|
+
};
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
242
211
|
}
|