pi-anchorloop 2.1.1 → 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/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 +500 -0
- package/pi/second-thought.ts +203 -172
- package/pi/support/book.ts +698 -0
- package/pi/support/jev.ts +28 -0
- package/pi/support/phases.ts +290 -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,112 @@
|
|
|
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 {
|
|
16
|
+
type Book,
|
|
17
|
+
allFriction,
|
|
18
|
+
childLines,
|
|
19
|
+
currentIntent,
|
|
20
|
+
currentKind,
|
|
21
|
+
diffAgainstBases,
|
|
22
|
+
diffText,
|
|
23
|
+
guarded,
|
|
24
|
+
isOpenTask,
|
|
25
|
+
latestAnchor,
|
|
26
|
+
openLoop,
|
|
27
|
+
replayTodos,
|
|
28
|
+
reviewClearAfterLastAct,
|
|
29
|
+
reviewTrend,
|
|
30
|
+
stagesDone,
|
|
31
|
+
TASK_LEGEND,
|
|
32
|
+
taskLines,
|
|
33
|
+
verifyAfterLastAct,
|
|
34
|
+
} from "./support/book.ts";
|
|
35
|
+
import { type Answers, classify } from "./support/jev.ts";
|
|
36
|
+
import { computePhases } from "./support/phases.ts";
|
|
37
|
+
import { isSubagent } from "./support/subagent.ts";
|
|
38
|
+
|
|
39
|
+
export { isSubagent };
|
|
19
40
|
|
|
20
41
|
export const ENTRY_TYPE = "anchorloop-second-thought";
|
|
21
|
-
const WORK_TOOLS = new Set(["write", "edit", "bash"]);
|
|
22
42
|
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
43
|
|
|
28
|
-
export const THRESHOLDS = {
|
|
44
|
+
export const THRESHOLDS = { completeBelow: 0.5, doneBelow: 0.25, qualityBelow: 1.5, reviewTier: "full" };
|
|
45
|
+
|
|
46
|
+
export const MOVES = ["advance", "back", "ask-user"] as const;
|
|
47
|
+
export type Move = (typeof MOVES)[number];
|
|
48
|
+
|
|
49
|
+
/** The phases whose missing evidence the agent can supply by acting, so Jev's agreement continues the run. */
|
|
50
|
+
const ACTIONABLE = new Set(["verify", "check", "review", "fix"]);
|
|
29
51
|
|
|
30
52
|
export const QUESTIONS = {
|
|
31
|
-
|
|
32
|
-
type: "
|
|
53
|
+
move: {
|
|
54
|
+
type: "choice" as const,
|
|
33
55
|
instructions:
|
|
34
|
-
"
|
|
56
|
+
"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?",
|
|
57
|
+
criteria: {
|
|
58
|
+
advance: "the recorded evidence supports the last complete phase and the next phase can proceed",
|
|
59
|
+
back: "evidence recorded for an earlier phase is wrong, stale, or contradicted by a later result",
|
|
60
|
+
"ask-user": "a decision or judgment that belongs to the user blocks progress",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
complete: {
|
|
64
|
+
type: "bool" as const,
|
|
65
|
+
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?",
|
|
35
66
|
criteria: {
|
|
36
|
-
true: "the
|
|
37
|
-
false: "the
|
|
67
|
+
true: "the evidence observably meets what the phase requires",
|
|
68
|
+
false: "the phase counts as complete on a mechanical signal, but its content is empty, stale, or off the intent",
|
|
38
69
|
},
|
|
39
70
|
},
|
|
40
71
|
done: {
|
|
41
72
|
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`.",
|
|
73
|
+
instructions: "Does the `verify` evidence show, with observed results, that `intent` is fully met?",
|
|
44
74
|
criteria: {
|
|
45
|
-
true: "every part of the
|
|
75
|
+
true: "every part of the intent has observed evidence: command output, a test result, a review verdict, or an artifact",
|
|
46
76
|
false: "some part failed, was skipped, is left for later, or rests only on a claim",
|
|
47
77
|
},
|
|
48
78
|
},
|
|
49
79
|
review: {
|
|
50
80
|
type: "choice" as const,
|
|
51
|
-
instructions: "What review should the
|
|
81
|
+
instructions: "What review should the `diff` still get before it is merged or released, given the reviews recorded in `verify`?",
|
|
52
82
|
criteria: {
|
|
53
|
-
none: "trivial, fully covered by a passing check",
|
|
83
|
+
none: "trivial, fully covered by a passing check, or already reviewed with no blocking finding",
|
|
54
84
|
focused: "one independent reviewer on the changed lines",
|
|
55
85
|
full: "independent review plus an adversarial pass: security, data, permissions, migration, or a shared contract",
|
|
56
86
|
},
|
|
57
87
|
},
|
|
58
88
|
quality: {
|
|
59
89
|
type: "score" as const,
|
|
60
|
-
instructions: "How acceptable is the
|
|
90
|
+
instructions: "How acceptable is the work recorded in the loop against its `intent`?",
|
|
61
91
|
criteria: [
|
|
62
92
|
"unacceptable: wrong or broken",
|
|
63
93
|
"weak: works but misses requirements",
|
|
64
|
-
"acceptable: meets the
|
|
94
|
+
"acceptable: meets the intent",
|
|
65
95
|
"strong: meets it with direct evidence and no excess",
|
|
66
96
|
],
|
|
67
97
|
},
|
|
68
98
|
};
|
|
69
99
|
|
|
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 };
|
|
100
|
+
export type Verdict = { move: Move; complete: number; done: number; review: string; reviewProbability: number; quality: number };
|
|
85
101
|
|
|
86
102
|
export function readVerdict(answers: Answers): Verdict | undefined {
|
|
87
|
-
const {
|
|
88
|
-
if (
|
|
103
|
+
const { move, complete, done, review, quality } = answers;
|
|
104
|
+
if (move?.type !== "choice" || complete?.type !== "bool" || done?.type !== "bool" || review?.type !== "choice" || quality?.type !== "score") return undefined;
|
|
105
|
+
if (!(MOVES as readonly string[]).includes(move.choice ?? "")) return undefined;
|
|
89
106
|
const choice = review.choice ?? "";
|
|
90
107
|
return {
|
|
91
|
-
|
|
108
|
+
move: move.choice as Move,
|
|
109
|
+
complete: complete.probability ?? 0,
|
|
92
110
|
done: done.probability ?? 0,
|
|
93
111
|
review: choice,
|
|
94
112
|
reviewProbability: review.probabilities?.[choice] ?? 0,
|
|
@@ -96,147 +114,160 @@ export function readVerdict(answers: Answers): Verdict | undefined {
|
|
|
96
114
|
};
|
|
97
115
|
}
|
|
98
116
|
|
|
99
|
-
export
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
export type Snapshot = {
|
|
118
|
+
state: Record<string, string>;
|
|
119
|
+
/** The program's next phase and what it lacks, in a few words. */
|
|
120
|
+
next: string;
|
|
121
|
+
missing: string;
|
|
122
|
+
/** Advisory hints that are not phases, such as an unrecorded friction. */
|
|
123
|
+
hints: string[];
|
|
124
|
+
/** The last phase the book shows complete. */
|
|
125
|
+
current: string;
|
|
126
|
+
/** A review recorded after the last change with no blocking finding on it. */
|
|
127
|
+
reviewClear: boolean;
|
|
128
|
+
};
|
|
107
129
|
|
|
108
|
-
export function
|
|
109
|
-
|
|
130
|
+
export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBranch(): unknown[] } }): Snapshot {
|
|
131
|
+
const { entries } = book;
|
|
132
|
+
const todos = replayTodos(ctx.sessionManager.getBranch());
|
|
133
|
+
const open = todos.filter(isOpenTask).length;
|
|
134
|
+
const phases = computePhases(book, { cwd: ctx.cwd, todos });
|
|
135
|
+
const check = verifyAfterLastAct(entries);
|
|
136
|
+
const verify = [
|
|
137
|
+
`stages: ${stagesDone(entries).join(", ") || "none"}`,
|
|
138
|
+
`last check: ${check ? `${check.declared ? `declared ${check.result}` : `exit ${check.exit ?? "unknown"}`}${check.last ? ` — ${check.last}` : ""}` : "none after the last change"}`,
|
|
139
|
+
`reviews: ${reviewTrend(entries)}`,
|
|
140
|
+
...childLines(entries).map((line) => `child ${line}`),
|
|
141
|
+
].join("; ");
|
|
142
|
+
const friction = allFriction(entries).map((item) => `${item.ref}: ${item.tried} → ${item.found} [${item.reflected ? item.class : "unreflected"}]`);
|
|
143
|
+
return {
|
|
144
|
+
state: {
|
|
145
|
+
kind: currentKind(entries) ?? "not declared",
|
|
146
|
+
intent: (currentIntent(entries) ?? "not declared").slice(0, 500),
|
|
147
|
+
anchor: (latestAnchor(entries) ?? "none").slice(0, 500),
|
|
148
|
+
diff: diffText(diffAgainstBases(entries)),
|
|
149
|
+
phase: `current: ${phases.current}; next: ${phases.next}${phases.missing ? `; missing: ${phases.missing}` : ""}${phases.skipped.length ? `; skipped: ${phases.skipped.join(", ")}` : ""}`.slice(0, 600),
|
|
150
|
+
verify: verify.slice(0, 1200),
|
|
151
|
+
todos: `${open} open of ${todos.length}`,
|
|
152
|
+
tasks: [`legend: ${TASK_LEGEND}`, ...taskLines(entries, todos, ctx.cwd)].join("\n").slice(0, 1500),
|
|
153
|
+
friction: (friction.join("\n") || "none").slice(0, 800),
|
|
154
|
+
},
|
|
155
|
+
next: phases.next,
|
|
156
|
+
missing: phases.missing,
|
|
157
|
+
hints: phases.hints,
|
|
158
|
+
current: phases.current,
|
|
159
|
+
reviewClear: reviewClearAfterLastAct(entries),
|
|
160
|
+
};
|
|
110
161
|
}
|
|
111
162
|
|
|
112
|
-
export function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
163
|
+
export function assess(verdict: Verdict, snap: Snapshot): { found: string[]; notes: string[] } {
|
|
164
|
+
const found: string[] = [];
|
|
165
|
+
const notes: string[] = [];
|
|
166
|
+
if (verdict.complete < THRESHOLDS.completeBelow && snap.current !== "start") found.push(`${snap.current} not shown to be complete`);
|
|
167
|
+
if (verdict.done < THRESHOLDS.doneBelow) found.push("evidence not shown to cover the intent");
|
|
168
|
+
if (verdict.review === THRESHOLDS.reviewTier) {
|
|
169
|
+
if (snap.reviewClear) notes.push("review rated full, but a review recorded after the last change found no blocking finding");
|
|
170
|
+
else found.push("needs full independent review");
|
|
171
|
+
}
|
|
172
|
+
if (verdict.quality < THRESHOLDS.qualityBelow) found.push("result below the intent");
|
|
173
|
+
return { found, notes };
|
|
119
174
|
}
|
|
120
175
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return (content as Block[])
|
|
125
|
-
.filter((block) => block.type === "text")
|
|
126
|
-
.map((block) => block.text ?? "")
|
|
127
|
-
.join("\n")
|
|
128
|
-
.trim();
|
|
176
|
+
/** True when Jev agrees to advance and the next phase is one the agent can act on now. */
|
|
177
|
+
export function continues(verdict: Verdict, snap: Snapshot): boolean {
|
|
178
|
+
return verdict.move === "advance" && ACTIONABLE.has(snap.next);
|
|
129
179
|
}
|
|
130
180
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return
|
|
181
|
+
/** One line: the program's next phase and its gap, Jev's move and scores, and, away from close, the call to act. */
|
|
182
|
+
export function message(verdict: Verdict, snap: Snapshot, found: string[], notes: string[], atClose = false): string {
|
|
183
|
+
const extra = [...found, ...notes, ...snap.hints];
|
|
184
|
+
return (
|
|
185
|
+
`Second thought (Jev, advisory): next → ${snap.next}${snap.missing ? ` (missing: ${snap.missing})` : ""}; ` +
|
|
186
|
+
`${verdict.move}; complete ${verdict.complete.toFixed(2)}, done ${verdict.done.toFixed(2)}, review ${verdict.review}, quality ${verdict.quality.toFixed(2)}/3` +
|
|
187
|
+
`${extra.length ? `; ${extra.join("; ")}` : ""}.` +
|
|
188
|
+
(!atClose && continues(verdict, snap) ? ` Do ${snap.next} now, or say why it does not apply.` : "")
|
|
189
|
+
);
|
|
135
190
|
}
|
|
136
191
|
|
|
137
192
|
export default function (pi: ExtensionAPI) {
|
|
138
193
|
if (isSubagent()) return;
|
|
139
|
-
let request = "";
|
|
140
|
-
let reply = "";
|
|
141
|
-
let worked = false;
|
|
142
194
|
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>();
|
|
195
|
+
let continued = false;
|
|
196
|
+
// Entries each open book held when it was last judged, and the advisory it last received.
|
|
197
|
+
const judged = new Map<string, number>();
|
|
198
|
+
const told = new Map<string, string>();
|
|
199
|
+
// A close deletes its book, so what Jev needs is read when the call starts.
|
|
200
|
+
const closing = new Map<string, Snapshot>();
|
|
151
201
|
|
|
152
202
|
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);
|
|
203
|
+
judged.clear();
|
|
204
|
+
told.clear();
|
|
205
|
+
closing.clear();
|
|
160
206
|
});
|
|
161
207
|
|
|
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
208
|
pi.on("agent_settled", () => {
|
|
166
|
-
sent.clear();
|
|
167
|
-
reply = "";
|
|
168
|
-
worked = false;
|
|
169
209
|
waiting = false;
|
|
170
|
-
|
|
171
|
-
trigger = "wake";
|
|
172
|
-
wake = "";
|
|
210
|
+
continued = false;
|
|
173
211
|
});
|
|
174
212
|
|
|
175
|
-
pi.on(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
213
|
+
pi.on(
|
|
214
|
+
"tool_call",
|
|
215
|
+
guarded((event, ctx) => {
|
|
216
|
+
if (WAIT_TOOLS.has(event.toolName)) waiting = true;
|
|
217
|
+
if (event.toolName !== "anchorloop_stage" || (event.input as { stage?: unknown }).stage !== "close") return;
|
|
218
|
+
const book = openLoop(ctx);
|
|
219
|
+
if (book && currentKind(book.entries) !== "quick") closing.set(event.toolCallId, snapshot(book, ctx));
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
179
222
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (msg.customType !== ENTRY_TYPE && !reply && said) wake = said;
|
|
186
|
-
} else if (msg.role === "user") {
|
|
187
|
-
const pending = sent.get(said) ?? 0;
|
|
188
|
-
if (pending > 0) {
|
|
189
|
-
if (pending === 1) sent.delete(said);
|
|
190
|
-
else sent.set(said, pending - 1);
|
|
191
|
-
if (!reply) wake = said;
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const skill = parseSkillBlock(said);
|
|
195
|
-
request = skill ? (skill.userMessage ?? `/skill:${skill.name}`) : said;
|
|
196
|
-
reply = "";
|
|
197
|
-
worked = false;
|
|
198
|
-
asked = false;
|
|
199
|
-
trigger = "user";
|
|
200
|
-
wake = "";
|
|
201
|
-
} else if (msg.role === "assistant" && said) {
|
|
202
|
-
reply = said;
|
|
203
|
-
}
|
|
204
|
-
});
|
|
223
|
+
async function judge(ctx: Parameters<typeof classify>[0], snap: Snapshot) {
|
|
224
|
+
const { answers, reason } = await classify(ctx, snap.state, QUESTIONS);
|
|
225
|
+
if (reason === "no-auth") return undefined;
|
|
226
|
+
return { verdict: answers ? readVerdict(answers) : undefined, reason };
|
|
227
|
+
}
|
|
205
228
|
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
229
|
+
pi.on(
|
|
230
|
+
"tool_result",
|
|
231
|
+
guarded(async (event, ctx) => {
|
|
232
|
+
const snap = closing.get(event.toolCallId);
|
|
233
|
+
if (!snap) return;
|
|
234
|
+
closing.delete(event.toolCallId);
|
|
235
|
+
if (event.isError) return;
|
|
236
|
+
const judgment = await judge(ctx, snap);
|
|
237
|
+
if (!judgment?.verdict) return;
|
|
238
|
+
const { found, notes } = assess(judgment.verdict, snap);
|
|
239
|
+
pi.appendEntry(ENTRY_TYPE, { ...judgment.verdict, at: "close", concerns: found });
|
|
240
|
+
return { content: [...event.content, { type: "text" as const, text: message(judgment.verdict, snap, found, notes, true) }] };
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
pi.on(
|
|
245
|
+
"agent_before_settle",
|
|
246
|
+
guarded(async (event, ctx) => {
|
|
247
|
+
if (waiting || event.outcome !== "completed") return;
|
|
248
|
+
const book = openLoop(ctx);
|
|
249
|
+
// A quick loop has no prompts but the close reminder.
|
|
250
|
+
if (!book || currentKind(book.entries) === "quick") return;
|
|
251
|
+
const id = String(book.header.id);
|
|
252
|
+
if (book.entries.length <= (judged.get(id) ?? 0)) return;
|
|
253
|
+
judged.set(id, book.entries.length);
|
|
254
|
+
const snap = snapshot(book, ctx);
|
|
255
|
+
const judgment = await judge(ctx, snap);
|
|
256
|
+
if (!judgment) return;
|
|
257
|
+
const record = (data: Record<string, unknown>) => ({ type: "custom" as const, customType: ENTRY_TYPE, data });
|
|
258
|
+
// A later handler's boundary result replaces the entries an earlier one returned, so carry them along.
|
|
259
|
+
const earlier = event.entries ?? [];
|
|
260
|
+
if (!judgment.verdict) return { entries: [...earlier, record({ verdict: "unknown", reason: judgment.reason })] };
|
|
261
|
+
const { found, notes } = assess(judgment.verdict, snap);
|
|
262
|
+
const text = message(judgment.verdict, snap, found, notes);
|
|
263
|
+
if (told.get(id) === text) return;
|
|
264
|
+
told.set(id, text);
|
|
265
|
+
const proceed = !continued && continues(judgment.verdict, snap);
|
|
266
|
+
if (proceed) continued = true;
|
|
267
|
+
return {
|
|
268
|
+
entries: [...earlier, record({ ...judgment.verdict, concerns: found }), { type: "custom_message" as const, customType: ENTRY_TYPE, content: text, display: true }],
|
|
269
|
+
...(proceed ? { continue: true } : {}),
|
|
270
|
+
};
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
242
273
|
}
|