@matthewfl/pi-jtodo 0.0.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.
@@ -0,0 +1,1040 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression test for the pi-jtodo extension.
4
+ *
5
+ * Suite A: write-time gates, driven deterministically by invoking the todo
6
+ * tool definition directly (no LLM).
7
+ *
8
+ * Suite B: turn-end state machine, driven end-to-end with a scripted mock
9
+ * provider registered via an inline extension factory. Exercises auto-poke,
10
+ * the confidence-spike gate, and cycle completion against a real session.
11
+ *
12
+ * Usage: node test-todo.cjs
13
+ */
14
+ "use strict";
15
+
16
+ const os = require("node:os");
17
+ const path = require("node:path");
18
+ const fs = require("node:fs");
19
+
20
+ let PI_PKG;
21
+ try {
22
+ PI_PKG = path.dirname(require.resolve("@earendil-works/pi-coding-agent/package.json"));
23
+ } catch {
24
+ // local fallback (pi installed via a node distribution outside this repo)
25
+ PI_PKG = "/home/matthew/down/node-v24.18.0-linux-x64/lib/node_modules/@earendil-works/pi-coding-agent";
26
+ }
27
+ const pi = require(`${PI_PKG}/dist/index.js`);
28
+ const piai = require(`${PI_PKG}/node_modules/@earendil-works/pi-ai/dist/index.js`);
29
+
30
+ const EXT_DIR = path.join(__dirname, "..", "src");
31
+ const EXT_INDEX = path.join(EXT_DIR, "index.ts");
32
+
33
+ let pass = 0, fail = 0;
34
+ const results = [];
35
+ function ok(name, detail) { pass++; results.push(` ✓ ${name}${detail ? " — " + detail : ""}`); }
36
+ function bad(name, detail) { fail++; results.push(` ✗ ${name}${detail ? " — " + detail : ""}`); }
37
+
38
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
39
+
40
+ async function freshSession(extraLoaderOptions) {
41
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pi-jtodo-test-"));
42
+ const loader = new pi.DefaultResourceLoader({
43
+ cwd,
44
+ agentDir: pi.getAgentDir(),
45
+ noExtensions: true, // only our extension keeps the "todo" name unambiguous
46
+ additionalExtensionPaths: [EXT_INDEX],
47
+ ...(extraLoaderOptions ?? {}),
48
+ });
49
+ await loader.reload();
50
+ const { session } = await pi.createAgentSession({
51
+ cwd,
52
+ agentDir: pi.getAgentDir(),
53
+ resourceLoader: loader,
54
+ tools: ["todo"],
55
+ });
56
+ return {
57
+ session,
58
+ exec: (params, signal) =>
59
+ session
60
+ .getToolDefinition("todo")
61
+ .execute("test-call", params, undefined, undefined, { sessionManager: session.sessionManager }),
62
+ dispose: () => { try { session.dispose(); } catch {} },
63
+ cwd,
64
+ };
65
+ }
66
+
67
+ async function run(name, fn, extraLoaderOptions) {
68
+ let sess;
69
+ try { sess = await freshSession(extraLoaderOptions); }
70
+ catch (e) { bad(name, `session create failed: ${e.stack ?? e.message}`); return null; }
71
+ try {
72
+ const out = await fn(sess);
73
+ return out;
74
+ } catch (e) {
75
+ bad(name, `threw: ${e.stack ?? e.message}`);
76
+ return null;
77
+ } finally { sess.dispose(); }
78
+ }
79
+
80
+ function lastText(r) { return r.content?.[0]?.text ?? ""; }
81
+
82
+ // ============================================================================
83
+ // Suite A — write-time gates
84
+ // ============================================================================
85
+
86
+ async function suiteA() {
87
+ console.log("\nSuite A: write-time gates (direct tool execute)");
88
+
89
+ // T1: bare read on empty state -> empty list, operation read.
90
+ await run("T1 bare read on empty state", async ({ exec }, ) => {
91
+ const r = await exec({});
92
+ const t = lastText(r);
93
+ if (r.details?.operation === "read" && t.trim() === "[]") ok("T1 bare read", "returns []");
94
+ else bad("T1 bare read", `op=${r.details?.operation} text=${t.slice(0, 60)}`);
95
+ });
96
+
97
+ // T2: first write saves todos+plan+goals; history seeds once.
98
+ await run("T2 first write saves state + seeds histories", async ({ exec }) => {
99
+ const r = await exec({
100
+ todos: [{ content: "task a", status: "in_progress", priority: "high", id: "a", group: "g", confidence: 70 }],
101
+ plan: { user_intention: "test", understands_user_intent: 85 },
102
+ goals: [{ group: "g", closed_feedback_loop: 80, feedback_loop: "run tests" }],
103
+ });
104
+ const d = r.details;
105
+ const checks = [
106
+ d?.operation === "write",
107
+ d?.todos?.length === 1,
108
+ d?.todos?.[0]?.confidence_history?.join(",") === "70",
109
+ d?.plan?.understands_user_intent_history?.join(",") === "85",
110
+ d?.goals?.[0]?.closed_feedback_loop_history?.join(",") === "80",
111
+ ];
112
+ if (checks.every(Boolean)) ok("T2 first write", "state + histories recorded");
113
+ else bad("T2 first write", JSON.stringify({ checks, hist: d?.todos?.[0]?.confidence_history }));
114
+ });
115
+
116
+ // T3: replace semantics — omitted ids are gone from the stored list.
117
+ await run("T3 todos replace the list", async ({ exec }) => {
118
+ await exec({ todos: [
119
+ { content: "a", status: "pending", priority: "low", id: "a", confidence: 50 },
120
+ { content: "b", status: "pending", priority: "low", id: "b", confidence: 50 },
121
+ ] });
122
+ const r = await exec({ todos: [{ content: "b", status: "pending", priority: "low", id: "b", confidence: 50 }] });
123
+ const ids = (r.details?.todos ?? []).map((t) => t.id);
124
+ if (ids.join(",") === "b") ok("T3 replace", "only [b] remains");
125
+ else bad("T3 replace", `ids=${ids.join(",")}`);
126
+ });
127
+
128
+ // T4: plan/goals-only write keeps todos, inherits fields, appends history.
129
+ await run("T4 assessment-only write inherits + appends", async ({ exec }) => {
130
+ await exec({
131
+ todos: [{ content: "a", status: "in_progress", priority: "low", id: "a", confidence: 60 }],
132
+ plan: { user_intention: "keep me", understands_user_intent: 70 },
133
+ goals: [{ group: undefined, closed_feedback_loop: 50, feedback_loop: "loop" }],
134
+ });
135
+ const r = await exec({ plan: { user_intention: "", understands_user_intent: 88 } });
136
+ const d = r.details;
137
+ const p = d?.plan;
138
+ const text = lastText(r);
139
+ // "" clears intention per jcode; score history appends; "Plan updates:" shows.
140
+ const checks = [
141
+ d?.todos?.length === 1 && d.todos[0].status === "in_progress",
142
+ p?.user_intention === "",
143
+ p?.understands_user_intent === 88,
144
+ p?.understands_user_intent_history?.join(",") === "70,88",
145
+ text.includes("Plan updates:"),
146
+ ];
147
+ if (checks.every(Boolean)) ok("T4 assessment-only", "inherit/append/updates all good");
148
+ else bad("T4 assessment-only", JSON.stringify({ plan: p, hasUpdates: text.includes("Plan updates:") }));
149
+ });
150
+
151
+ // T5: ownership gate rejects completing a named group below ownership 96.
152
+ await run("T5 ownership gate rejects whole write", async ({ exec }) => {
153
+ await exec({
154
+ todos: [{ content: "a", status: "in_progress", priority: "low", id: "a", group: "ship", confidence: 90 }],
155
+ goals: [{ group: "ship", closed_feedback_loop: 97, feedback_loop: "tests", end_to_end_ownership: 90 }],
156
+ });
157
+ const r = await exec({ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "ship", confidence: 90, completion_confidence: 97 }] });
158
+ const text = lastText(r);
159
+ const after = (await exec({})).details.todos[0];
160
+ const checks = [
161
+ r.details?.operation === "rejected",
162
+ text.includes("end_to_end_ownership"),
163
+ text.includes("unchanged"),
164
+ after?.status === "in_progress",
165
+ ];
166
+ if (checks.every(Boolean)) ok("T5 ownership reject", "stored list unchanged, field named");
167
+ else bad("T5 ownership reject", JSON.stringify({ op: r.details?.operation, status: after?.status }));
168
+ });
169
+
170
+ // T6: ownership 96 lets the group completion through.
171
+ await run("T6 ownership=96 accepted", async ({ exec }) => {
172
+ await exec({
173
+ todos: [{ content: "a", status: "in_progress", priority: "low", id: "a", group: "ship", confidence: 97 }],
174
+ goals: [{ group: "ship", closed_feedback_loop: 97, feedback_loop: "tests", end_to_end_ownership: 96 }],
175
+ });
176
+ const r = await exec({ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "ship", confidence: 97, completion_confidence: 97 }] });
177
+ if (r.details?.operation === "write" && r.details?.todos?.[0]?.status === "completed") ok("T6 ownership pass", "completed");
178
+ else bad("T6 ownership pass", `op=${r.details?.operation} status=${r.details?.todos?.[0]?.status}`);
179
+ });
180
+
181
+ // T7: ungrouped completion needs the implicit (group-less) goal's ownership.
182
+ await run("T7 implicit goal ownership for flat list", async ({ exec }) => {
183
+ await exec({ todos: [{ content: "a", status: "in_progress", priority: "low", id: "a", confidence: 97 }] });
184
+ const r1 = await exec({ todos: [{ content: "a", status: "completed", priority: "low", id: "a", confidence: 97, completion_confidence: 97 }] });
185
+ const rejected = r1.details?.operation === "rejected";
186
+ const r2 = await exec({ goals: [{ closed_feedback_loop: 97, feedback_loop: "tests", end_to_end_ownership: 96 }] });
187
+ const r3 = await exec({ todos: [{ content: "a", status: "completed", priority: "low", id: "a", confidence: 97, completion_confidence: 97 }] });
188
+ if (rejected && r2.details?.operation === "write" && r3.details?.operation === "write" && r3.details?.todos?.[0]?.status === "completed") {
189
+ ok("T7 implicit goal", "flat list rejected until implicit ownership");
190
+ } else bad("T7 implicit goal", `r1=${r1.details?.operation} r3=${r3.details?.operation}`);
191
+ });
192
+
193
+ // T8: grandfathered groups stay writable (already complete before this write).
194
+ await run("T8 grandfathered completed group", async ({ exec }) => {
195
+ await exec({ goals: [{ group: "done", closed_feedback_loop: 97, feedback_loop: "tests", end_to_end_ownership: 96 }] });
196
+ await exec({ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "done", confidence: 97, completion_confidence: 97 }] });
197
+ const r = await exec({
198
+ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "done", confidence: 97, completion_confidence: 97 }],
199
+ goals: [{ group: "done", closed_feedback_loop: 97, feedback_loop: "tests" }], // ownership omitted
200
+ });
201
+ // merged goal keeps stored ownership 96 anyway; must also pass even without it
202
+ if (r.details?.operation === "write") ok("T8 grandfathered", "rewrite passes");
203
+ else bad("T8 grandfathered", `op=${r.details?.operation}`);
204
+ });
205
+
206
+ // T17: the rejection message is actionable — names the failing group(s),
207
+ // explains what ownership means, and calls out retained goals when the
208
+ // write omitted goal entries (the exact confusion that made agents flail).
209
+ await run("T17 ownership guidance message", async ({ exec }) => {
210
+ await exec({
211
+ todos: [{ content: "a", status: "in_progress", priority: "low", id: "a", group: "alpha", confidence: 90 }],
212
+ });
213
+ // No goals sent at all → rejected, names alpha, warns about retention.
214
+ const r1 = await exec({
215
+ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "alpha", confidence: 90, completion_confidence: 97 }],
216
+ });
217
+ const t1 = lastText(r1);
218
+ const named = t1.includes('"alpha"');
219
+ const kept = t1.includes("retained from the previous write");
220
+ const actionable = t1.includes("evidence") && t1.includes("end_to_end_ownership");
221
+ // Goal entry submitted with a low ownership → rejected, names alpha,
222
+ // but does NOT claim it was retained (it was submitted this time).
223
+ const r2 = await exec({
224
+ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "alpha", confidence: 90, completion_confidence: 97 }],
225
+ goals: [{ group: "alpha", closed_feedback_loop: 97, feedback_loop: "tests", end_to_end_ownership: 40 }],
226
+ });
227
+ const t2 = lastText(r2);
228
+ const submittedNotBlamed = t2.includes('"alpha"') && !t2.includes("retained from the previous write");
229
+ const r3 = await exec({
230
+ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "alpha", confidence: 90, completion_confidence: 97 }],
231
+ goals: [{ group: "alpha", closed_feedback_loop: 97, feedback_loop: "tests", end_to_end_ownership: 96 }],
232
+ });
233
+ if (named && kept && actionable && submittedNotBlamed && r3.details?.operation === "write") {
234
+ ok("T17 ownership guidance", "names group, retention warning conditional, accepts honest claim");
235
+ } else {
236
+ bad("T17 ownership guidance", JSON.stringify({ named, kept, actionable, submittedNotBlamed, final: r3.details?.operation, t1: t1.slice(-360) }, null, 1));
237
+ }
238
+ });
239
+
240
+ await run("T18 update-style field inheritance (group + completion_confidence, '' clears)", async ({ exec }) => {
241
+ // proj gets an ownership-96 goal so the gate stays quiet when proj
242
+ // "closes" by move-out in the clear-step write.
243
+ await exec({
244
+ todos: [
245
+ { id: "g1", content: "grouped", status: "pending", priority: "high", confidence: 90, group: "proj" },
246
+ { id: "g2", content: "flat", status: "pending", priority: "high", confidence: 90 },
247
+ { id: "g3", content: "clear me", status: "pending", priority: "high", confidence: 90, group: "proj" },
248
+ ],
249
+ goals: [{ group: "proj", closed_feedback_loop: 90, feedback_loop: "x", end_to_end_ownership: 96 }],
250
+ });
251
+ // update-style resend: only status/confidence changed; group omitted
252
+ const r1 = await exec({
253
+ todos: [
254
+ { id: "g1", content: "grouped", status: "completed", priority: "high", confidence: 92, completion_confidence: 95 },
255
+ { id: "g2", content: "flat", status: "pending", priority: "high", confidence: 91 },
256
+ { id: "g3", content: "clear me", status: "pending", priority: "high", confidence: 91 },
257
+ ],
258
+ });
259
+ const d1 = r1.details.todos;
260
+ const inherited =
261
+ d1.find((t) => t.id === "g1").group === "proj" &&
262
+ d1.find((t) => t.id === "g1").completion_confidence === 95;
263
+ // later write omits completion_confidence → inherits; "" clears g3's group
264
+ const r2 = await exec({
265
+ todos: [
266
+ { id: "g1", content: "grouped", status: "completed", priority: "high", confidence: 93 },
267
+ { id: "g2", content: "flat", status: "pending", priority: "high", confidence: 91 },
268
+ { id: "g3", content: "clear me", status: "pending", priority: "high", confidence: 91, group: "" },
269
+ ],
270
+ });
271
+ const d2 = r2.details.todos;
272
+ const ok2 =
273
+ d2.find((t) => t.id === "g1").completion_confidence === 95 &&
274
+ d2.find((t) => t.id === "g1").group === "proj" &&
275
+ d2.find((t) => t.id === "g2").group === undefined &&
276
+ d2.find((t) => t.id === "g3").group === undefined;
277
+ if (inherited && ok2) ok("T18 field inheritance", "omit inherits, '' clears group");
278
+ else bad("T18 field inheritance", JSON.stringify({ inherited, ok2, d2 }, null, 1).slice(0, 400));
279
+ });
280
+
281
+ await run("T19 accepted-write Changes digest", async ({ exec }) => {
282
+ const r1 = await exec({
283
+ todos: [
284
+ { id: "k1", content: "one", status: "pending", priority: "high", confidence: 90, group: "kg" },
285
+ { id: "k2", content: "two", status: "pending", priority: "high", confidence: 90, group: "kg" },
286
+ { id: "k3", content: "three", status: "pending", priority: "high", confidence: 90 },
287
+ ],
288
+ });
289
+ const c1 = lastText(r1).includes("Changes: 3 new");
290
+ // edit k1's content, drop k3, resend k2 identical (same confidence →
291
+ // history dedup → zero field delta → not counted as changed)
292
+ const r2 = await exec({
293
+ todos: [
294
+ { id: "k1", content: "one edited", status: "pending", priority: "high", confidence: 90 },
295
+ { id: "k2", content: "two", status: "pending", priority: "high", confidence: 90 },
296
+ ],
297
+ });
298
+ const t2 = lastText(r2);
299
+ const c2 = t2.includes("1 updated") && t2.includes("removed #k3") && !t2.includes("#k2");
300
+ // clear k1's group (kg stays alive via k2) → the clear is named
301
+ const r3 = await exec({
302
+ todos: [
303
+ { id: "k1", content: "one edited", status: "pending", priority: "high", confidence: 90, group: "" },
304
+ { id: "k2", content: "two", status: "pending", priority: "high", confidence: 90 },
305
+ ],
306
+ });
307
+ const t3 = lastText(r3);
308
+ const clearedNamed = t3.includes("group cleared on #k1");
309
+ // identical resend → no Changes line at all (quiet on no-op)
310
+ const r4 = await exec({
311
+ todos: [
312
+ { id: "k1", content: "one edited", status: "pending", priority: "high", confidence: 90 },
313
+ { id: "k2", content: "two", status: "pending", priority: "high", confidence: 90 },
314
+ ],
315
+ });
316
+ const quiet = !lastText(r4).includes("Changes:");
317
+ const detailCarried = r3.details?.item_changes === "group cleared on #k1";
318
+ if (c1 && c2 && clearedNamed && quiet && detailCarried)
319
+ ok("T19 changes digest", "counts updates/additions, names removals/clears, quiet on no-op");
320
+ else
321
+ bad("T19 changes digest", JSON.stringify({ c1, c2, clearedNamed, quiet, detailCarried, t2: t2.slice(-220) }, null, 1));
322
+ });
323
+
324
+ // T9: severe first intent -> in-band immediate continuation; second severe write is quiet.
325
+ await run("T9 severe first intent immediate, then deferred", async ({ exec }) => {
326
+ const r1 = await exec({
327
+ todos: [{ content: "a", status: "pending", priority: "low", id: "a", confidence: 50 }],
328
+ plan: { user_intention: "guessing", understands_user_intent: 40 },
329
+ });
330
+ const t1 = lastText(r1);
331
+ const hasImmediate = t1.includes("understanding of the user's intent is not high enough");
332
+ const savedAnyWay = r1.details?.operation === "write";
333
+ const r2 = await exec({ plan: { user_intention: "guessing", understands_user_intent: 41 } });
334
+ const t2 = lastText(r2);
335
+ const quietNow = !t2.includes("understanding of the user's intent is not high enough");
336
+ if (hasImmediate && savedAnyWay && quietNow) ok("T9 severe intent", "immediate once, then deferred");
337
+ else bad("T9 severe intent", JSON.stringify({ hasImmediate, savedAnyWay, quietNow }));
338
+ });
339
+
340
+ // T10: confidence history — completion write contributes exactly one entry.
341
+ await run("T10 one history entry per write", async ({ exec }) => {
342
+ await exec({
343
+ todos: [{ content: "a", status: "in_progress", priority: "low", id: "a", group: "g", confidence: 70 }],
344
+ goals: [{ group: "g", closed_feedback_loop: 97, feedback_loop: "x", end_to_end_ownership: 97 }],
345
+ });
346
+ const r = await exec({ todos: [{ content: "a", status: "completed", priority: "low", id: "a", group: "g", confidence: 90, completion_confidence: 97 }] });
347
+ const hist = r.details?.todos?.[0]?.confidence_history;
348
+ if (JSON.stringify(hist) === "[70,97]") ok("T10 history", "[70,97]");
349
+ else bad("T10 history", `hist=${JSON.stringify(hist)}`);
350
+ });
351
+
352
+ // T11: lenient normalization via prepareArguments.
353
+ await run("T11 prepareArguments leniency", async ({ session }) => {
354
+ const td = session.getToolDefinition("todo");
355
+ const n = td.prepareArguments({
356
+ todos: "[{\"content\":\"x\",\"status\":\"pending\",\"priority\":\"low\",\"id\":\"x\",\"confidence\":\"85\"}]",
357
+ plan: { user_intention: "y", user_intention_alignment: "97" },
358
+ goals: [{ group: "g", hill_climbability: "88", feedback_loop: "bench" }],
359
+ });
360
+ const checks = [
361
+ Array.isArray(n.todos),
362
+ n.todos[0].confidence === 85,
363
+ n.plan.understands_user_intent === 97,
364
+ n.plan.user_intention_alignment === undefined,
365
+ n.goals[0].closed_feedback_loop === 88,
366
+ n.goals[0].hill_climbability === undefined,
367
+ ];
368
+ if (checks.every(Boolean)) ok("T11 normalization", "stringified/aliases coerced");
369
+ else bad("T11 normalization", JSON.stringify(n));
370
+ // empty-string todos becomes absent (a read)
371
+ const n2 = td.prepareArguments({ todos: "" });
372
+ if (n2.todos === undefined) ok("T11b empty string todos -> read", "");
373
+ else bad("T11b empty string todos -> read", JSON.stringify(n2));
374
+ });
375
+
376
+ // T12: state survives a fresh session via details replay (reconstruction).
377
+ await run("T12 state reconstruction from branch", async ({ session }) => {
378
+ // state reconstruction is covered implicitly by Suite B sessions; here
379
+ // just assert the tool def exists and is the jcode-style one.
380
+ const td = session.getToolDefinition("todo");
381
+ if (td && typeof td.prepareArguments === "function") ok("T12 tool registered", "pi-jtodo tool present");
382
+ else bad("T12 tool registered", "missing or wrong todo tool");
383
+ });
384
+
385
+ // T14: starvation watchdog state machine (fake clock + dep counters).
386
+ await run("T14 watchdog", async () => {
387
+ const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
388
+ const jiti = createJiti(__filename, {
389
+ alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
390
+ });
391
+ const { createWatchdog } = await jiti.import(path.join(EXT_DIR, "watchdog.ts"));
392
+ let t = 0, armed = true, idle = true, open = 3, aborted = false;
393
+ const fires = [], starves = [];
394
+ const wd = createWatchdog({
395
+ now: () => t,
396
+ isArmed: () => armed,
397
+ isIdle: () => idle,
398
+ incompleteCount: () => open,
399
+ wasAborted: () => aborted,
400
+ idleMs: 90_000,
401
+ maxRePokes: 3,
402
+ onFire: (n) => fires.push(n),
403
+ onStarve: (r) => starves.push(r),
404
+ });
405
+ // fresh start: tick at t0 does nothing (window starts at creation)
406
+ wd.tick();
407
+ // wait past idleMs with everything open+idle+armed -> first re-poke
408
+ t += 91_000; wd.tick();
409
+ // another window -> second re-poke (no activity in between)
410
+ t += 91_000; wd.tick();
411
+ // busy agent postpones
412
+ idle = false; t += 200_000; wd.tick();
413
+ const preBusy = fires.length;
414
+ idle = true; t += 50_000; wd.tick(); // window restarted by busy tick
415
+ const postponed = fires.length === preBusy;
416
+ t += 91_000; wd.tick(); // third re-poke
417
+ t += 91_000; wd.tick(); // cap reached -> starve(cap)
418
+ const capped = starves.join(",") === "cap" && fires.length === 3;
419
+ // activity resets the counter
420
+ starves.length = 0; t += 5_000; wd.notifyActivity(); t += 91_000; wd.tick();
421
+ const resetWorks = fires.length === 4 && fires.at(-1) === 1 && starves.length === 0;
422
+ // settled list means nothing to guard
423
+ open = 0; t += 400_000; wd.tick();
424
+ const settledQuiet = fires.length === 4;
425
+ // abort wins even mid-starvation
426
+ open = 2; aborted = true; t += 400_000; wd.tick();
427
+ const abortWins = starves.join(",") === "aborted";
428
+ // unarmed never fires
429
+ armed = false; aborted = false; t += 400_000; wd.tick();
430
+ const unarmedQuiet = fires.length === 4;
431
+ if (fires[0] === 1 && fires[1] === 2 && postponed && capped && resetWorks && settledQuiet && abortWins && unarmedQuiet) {
432
+ ok("T14 watchdog", "fire/postpone/cap/reset/settled-quiet/abort/unarmed all correct");
433
+ } else {
434
+ bad("T14 watchdog", JSON.stringify({ fires, starves, postponed, capped, resetWorks, settledQuiet, abortWins, unarmedQuiet }));
435
+ }
436
+ });
437
+
438
+ // T15: cycle flags derived from the branch walk (reload/tree stability).
439
+ await run("T15 deriveBranchRuntime flags", async () => {
440
+ const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
441
+ const jiti = createJiti(__filename, {
442
+ alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
443
+ });
444
+ const gates = await jiti.import(path.join(EXT_DIR, "gates.ts"));
445
+ const c = await jiti.import(path.join(EXT_DIR, "constants.ts"));
446
+ const fu = (content) => ({ kind: "followup", content });
447
+ const poke = fu(gates.buildAutoPokeMessage(2));
448
+ const spike = fu(c.TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE);
449
+ const completion = fu(c.TODO_COMPLETION_CONTINUATION_MESSAGE);
450
+ const digest = fu(c.TODO_GATE_DIGEST_PREFIX + " Review.");
451
+ const F = (events) => gates.deriveBranchRuntime(events).flags;
452
+ const emptyOk = JSON.stringify(F([])) === JSON.stringify({ digestDelivered: false, spikeChallenged: false, gateAttempts: 0 });
453
+ const pokesQuiet = JSON.stringify(F([poke, poke])) === JSON.stringify({ digestDelivered: false, spikeChallenged: false, gateAttempts: 0 });
454
+ const f3 = F([spike]);
455
+ const spikeChallenged = f3.spikeChallenged && f3.gateAttempts === 1 && !f3.digestDelivered;
456
+ const f4 = F([digest, completion, completion]);
457
+ const digestAndAttempts = f4.digestDelivered && f4.gateAttempts === 2 && !f4.spikeChallenged;
458
+ // a poke (armed incomplete settle) resets the attempt budget, not the challenge flag
459
+ const f5 = F([spike, poke]);
460
+ const pokeResetsAttempts = f5.spikeChallenged && f5.gateAttempts === 0;
461
+ // real flow: spike challenged twice across two reloads — stays challenged
462
+ const f6 = F([spike, spike, poke, completion]);
463
+ const fullFlow = f6.spikeChallenged && f6.gateAttempts === 1;
464
+ // arbitrary noise (idle nudge / other texts) must not move the flags
465
+ const f7 = F([fu("whatever"), poke]);
466
+ const noiseIgnored = !f7.digestDelivered && !f7.spikeChallenged && f7.gateAttempts === 0;
467
+ // cycle markers (write re-arm / poke on / every disarm) reset the gate
468
+ // flags but not observations: cycle 1's digest+spike must not suppress
469
+ // cycle 2's after a reload mid-cycle-2
470
+ const mk = { kind: "cycle", armed: true };
471
+ const f8 = F([digest, spike, mk, poke]);
472
+ const markerResets = !f8.digestDelivered && !f8.spikeChallenged && f8.gateAttempts === 0;
473
+ const f9 = F([digest, mk, spike]);
474
+ const secondCycleSpikeFires = f9.spikeChallenged && f9.gateAttempts === 1 && !f9.digestDelivered;
475
+ if (emptyOk && pokesQuiet && spikeChallenged && digestAndAttempts && pokeResetsAttempts && fullFlow && noiseIgnored && markerResets && secondCycleSpikeFires) {
476
+ ok("T15 branch flags", "challenge/attempt/digest/marker reconstruction correct");
477
+ } else {
478
+ bad("T15 branch flags", JSON.stringify({ emptyOk, pokesQuiet, spikeChallenged, digestAndAttempts, pokeResetsAttempts, fullFlow, noiseIgnored, markerResets, secondCycleSpikeFires }));
479
+ }
480
+ });
481
+
482
+ // T16: pending observations via delta walk — pre-digest scores must not leak back in.
483
+ await run("T16 observation delta walk", async () => {
484
+ const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
485
+ const jiti = createJiti(__filename, {
486
+ alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
487
+ });
488
+ const gates = await jiti.import(path.join(EXT_DIR, "gates.ts"));
489
+ const c = await jiti.import(path.join(EXT_DIR, "constants.ts"));
490
+ const snap = (openAny, intentHistory, goals = [], confidenceSignature = "sig") => ({ kind: "snapshot", openAny, intentHistory, goals, confidenceSignature });
491
+ const digest = { kind: "followup", content: c.TODO_GATE_DIGEST_PREFIX + " Review." };
492
+ // write1 records intent 88 (<96, open) → 1 observation; write2 climbs to 97 → no new
493
+ let rt = gates.deriveBranchRuntime([snap(true, [88]), snap(true, [88, 97])]);
494
+ const step1 = rt.observations.length === 1 && rt.observations[0].kind === "intent_understanding" && rt.observations[0].score === 88;
495
+ // digest consumes; then write3 dips to 50 and a goal loop scores 80 → 2 observations.
496
+ // CRITICAL: the consumed 88 must NOT come back (contemplator probe).
497
+ rt = gates.deriveBranchRuntime([
498
+ snap(true, [88]), digest,
499
+ snap(true, [88, 97, 50], [{ key: "g", loopHistory: [80, 96] }]),
500
+ ]);
501
+ const noLeak = rt.observations.length === 2 &&
502
+ rt.observations.every((o) => o.score !== 88) &&
503
+ rt.observations.some((o) => o.kind === "intent_understanding" && o.score === 50) &&
504
+ rt.observations.some((o) => o.kind === "closed_feedback_loop" && o.score === 80 && o.group === "g") &&
505
+ rt.flags.digestDelivered;
506
+ // settled list (openAny false) records no intent observations
507
+ rt = gates.deriveBranchRuntime([snap(false, [40])]);
508
+ const settledQuiet = rt.observations.length === 0;
509
+ // loop scores recorded even on their own group-snapshot, 96 threshold respected
510
+ rt = gates.deriveBranchRuntime([snap(false, [99], [{ key: null, loopHistory: [95, 96] }])]);
511
+ const loopEdge = rt.observations.length === 1 && rt.observations[0].score === 95;
512
+ if (step1 && noLeak && settledQuiet && loopEdge) {
513
+ ok("T16 observation delta", "append-only diffs, digest clears, no pre-boundary leak");
514
+ } else {
515
+ bad("T16 observation delta", JSON.stringify({ step1, noLeak, settledQuiet, loopEdge }));
516
+ }
517
+ });
518
+
519
+ // T17: completion-gate signature — the deadlock detector's identity check.
520
+ await run("T17 completionConfidenceSignature", async () => {
521
+ const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
522
+ const jiti = createJiti(__filename, {
523
+ alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
524
+ });
525
+ const gates = await jiti.import(path.join(EXT_DIR, "gates.ts"));
526
+ const mk = (id, status, cc) => ({ id, status, completion_confidence: cc });
527
+ const a = [mk("t1", "completed", 95), mk("t2", "pending"), mk("t3", "cancelled", undefined)];
528
+ const sameScoresDifferentOrder = [mk("t3", "cancelled", undefined), mk("t2", "pending"), mk("t1", "completed", 95)];
529
+ const moved = [mk("t1", "completed", 96), mk("t2", "pending"), mk("t3", "cancelled", undefined)];
530
+ const completedChanged = [mk("t1", "in_progress", undefined), mk("t2", "pending"), mk("t3", "cancelled", undefined)];
531
+ const s1 = gates.completionConfidenceSignature(a);
532
+ const orderOK = s1 === gates.completionConfidenceSignature(sameScoresDifferentOrder);
533
+ const movedDiffers = s1 !== gates.completionConfidenceSignature(moved);
534
+ const statusDiffers = s1 !== gates.completionConfidenceSignature(completedChanged);
535
+ const missingMarked = s1.includes("t3:cancelled:-"); // missing score is part of identity
536
+ const openExcluded = !s1.includes("t2:");
537
+ // log-derived challenged signature: a challenge is recoverable after
538
+ // reload when the scores it targeted never moved.
539
+ const c = await jiti.import(path.join(EXT_DIR, "constants.ts"));
540
+ const snapS = (sig) => ({ kind: "snapshot", openAny: false, intentHistory: [], goals: [], confidenceSignature: sig });
541
+ const completion = { kind: "followup", content: c.TODO_COMPLETION_CONTINUATION_MESSAGE };
542
+ const challenged = gates.deriveBranchRuntime([snapS("A"), completion]).lastChallengedSignature === "A";
543
+ const notAfterMove = gates.deriveBranchRuntime([snapS("A"), completion, snapS("B")]).lastChallengedSignature === undefined;
544
+ const resendKeeps = gates.deriveBranchRuntime([snapS("A"), completion, snapS("A"), snapS("A")]).lastChallengedSignature === "A";
545
+ const noChallenge = gates.deriveBranchRuntime([snapS("A")]).lastChallengedSignature === undefined;
546
+ if (orderOK && movedDiffers && statusDiffers && missingMarked && openExcluded && challenged && notAfterMove && resendKeeps && noChallenge) {
547
+ ok("T17 gate signature", "stable under reorder, changes on any gate input; challenged state log-derived");
548
+ } else {
549
+ bad("T17 gate signature", JSON.stringify({ orderOK, movedDiffers, statusDiffers, missingMarked, openExcluded, challenged, notAfterMove, resendKeeps, noChallenge }));
550
+ }
551
+ });
552
+
553
+ // T13: above-editor widget line builder (pure, stub theme).
554
+ await run("T13 widget line builder", async () => {
555
+ const { createJiti } = require(`${PI_PKG}/node_modules/jiti/lib/jiti.cjs`);
556
+ const jiti = createJiti(__filename, {
557
+ alias: { "@earendil-works/pi-tui": `${PI_PKG}/node_modules/@earendil-works/pi-tui/dist/index.js` },
558
+ });
559
+ const widget = await jiti.import(path.join(EXT_DIR, "widget.ts"));
560
+ const stubTheme = { fg: (_c, s) => s, bold: (s) => s };
561
+ const mk = (id, status, extra = {}) => ({
562
+ id, content: `content of ${id} purposefully padded to exceed narrow widths`,
563
+ status, priority: "medium", confidence_history: [], ...extra,
564
+ });
565
+ const state = {
566
+ todos: [
567
+ mk("c1", "completed"), mk("a", "in_progress", { group: "g" }), mk("b", "pending"),
568
+ mk("c2", "cancelled"), mk("d", "pending"), mk("e", "completed"), mk("f", "pending"),
569
+ ],
570
+ plan: {}, goals: [],
571
+ };
572
+ const rtOn = { armed: true, gateAttempts: 0, gateMaxAttempts: 5 };
573
+ const rtOff = { armed: false, gateAttempts: 0, gateMaxAttempts: 5 };
574
+ const lines = widget.renderTodoWidgetLines(state, rtOn, 6, 60, stubTheme);
575
+ const unescape = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
576
+ // header carries the plan intention (or bare "▣ Todos" when no plan);
577
+ // settled+status moved to the bottom line
578
+ const firstIsHeader = unescape(lines[0]).startsWith("▣ Todos") && !unescape(lines[0]).includes("settled");
579
+ const bottomStatus =
580
+ unescape(lines.at(-1)).includes("3/7 settled") && unescape(lines.at(-1)).includes("auto-poke");
581
+ const inProgressOnTop = unescape(lines[1]).includes("#a");
582
+ const capped = lines.length === 6 && unescape(lines.at(-1)).includes("3 more");
583
+ const hiddenOpenLines = widget.renderTodoWidgetLines(
584
+ { todos: [1,2,3,4,5,6].map((n) => mk(`p${n}`, "pending")), plan: {}, goals: [] },
585
+ rtOn, 4, 60, stubTheme,
586
+ );
587
+ const openBreakdown = unescape(hiddenOpenLines.at(-1)).includes("(4 open)");
588
+ const withinWidth = lines.every((l) => unescape(l).length <= 60);
589
+ const armedMarker = unescape(lines.at(-1)).includes("auto-poke");
590
+ const empty = widget.renderTodoWidgetLines({ todos: [], plan: {}, goals: [] }, rtOn, 6, 60, stubTheme);
591
+ const unarmed = widget.renderTodoWidgetLines(state, rtOff, 6, 60, stubTheme);
592
+ const offMarker = unescape(unarmed.at(-1)).includes("poke off");
593
+ // value-rank: shown item lines are exactly a(in_progress), then pending
594
+ // in declaration order; settled items are pushed into the overflow.
595
+ const shownIds = lines.slice(1, -1).map((l) => (unescape(l).match(/#(\w+)/) || [])[1]);
596
+ const ranked = JSON.stringify(shownIds) === JSON.stringify(["a", "b", "d", "f"]);
597
+ // in_progress leads even when declared after pendings/settleds.
598
+ const indented = lines.slice(1).every((l) => l.startsWith(" ")) && !lines[0].startsWith(" ");
599
+ const late = widget.renderTodoWidgetLines(
600
+ { todos: [mk("p1", "pending"), mk("p2", "pending"), mk("done", "completed"), mk("hot", "in_progress")], plan: {}, goals: [] },
601
+ rtOn, 6, 60, stubTheme,
602
+ );
603
+ const lateLeads = unescape(late[1]).includes("#hot") && unescape(late[2]).includes("#p1");
604
+ // per-item confidence tail: completed shows completion_confidence, open shows confidence
605
+ const confState = { todos: [mk("c", "completed", { completion_confidence: 91 }), mk("o", "pending", { confidence: 72 })], plan: {}, goals: [] };
606
+ const confLines = widget.renderTodoWidgetLines(confState, rtOn, 6, 80, stubTheme).map(unescape);
607
+ const confTails = confLines.some((l) => l.includes("#c") && l.includes("conf 91")) && confLines.some((l) => l.includes("#o") && l.includes("conf 72"));
608
+ // gate countdown on the bottom line while all-settled-but-invalid; done once valid
609
+ const invalidState = { todos: [mk("x", "completed", { completion_confidence: 88 })], plan: {}, goals: [] };
610
+ const cycling = widget.renderTodoWidgetLines(invalidState, { armed: true, gateAttempts: 2, gateMaxAttempts: 5 }, 6, 80, stubTheme).map(unescape);
611
+ const gateShown = cycling.at(-1).includes("gate 2/5");
612
+ const doneState = { todos: [mk("x", "completed", { completion_confidence: 98, priority: "medium" })], plan: {}, goals: [] };
613
+ const doneLines = widget.renderTodoWidgetLines(doneState, rtOff, 6, 80, stubTheme).map(unescape);
614
+ const doneShown = doneLines.at(-1).includes("· done") && !doneLines.at(-1).includes("done · conf");
615
+ // header carries the plan's user intention, truncated to the column,
616
+ // with machine status/counts on the bottom line
617
+ const intentState = { todos: [mk("w1", "pending")], plan: { user_intention: "Review the jcode plan and port the todo extension with tests" }, goals: [] };
618
+ const intentLines = widget.renderTodoWidgetLines(intentState, rtOn, 6, 34, stubTheme).map(unescape);
619
+ const intentShown =
620
+ intentLines[0].startsWith("▣ Todos Review") && intentLines[0].includes("…") && intentLines[0].length <= 34 - 12 &&
621
+ intentLines.at(-1).includes("0/1 settled") && intentLines.at(-1).includes("auto-poke");
622
+ // multiline / whitespace-heavy intentions collapse onto the single header line
623
+ const multiState = { todos: [mk("w1", "pending")], plan: { user_intention: "line one\nline two spaced" }, goals: [] };
624
+ const multiHeader = unescape(widget.renderTodoWidgetLines(multiState, rtOn, 6, 80, stubTheme)[0]);
625
+ const multiClean = multiHeader.includes("line one line two spaced") && !multiHeader.includes("\n");
626
+ // status icons: passed ✔️, below-the-bar ⚠️, in-progress 🚧
627
+ const iconState = { todos: [mk("ok", "completed", { completion_confidence: 97 }), mk("bad", "completed", { completion_confidence: 88 }), mk("hot", "in_progress")], plan: {}, goals: [] };
628
+ const iconLines = widget.renderTodoWidgetLines(iconState, rtOn, 6, 90, stubTheme).map(unescape);
629
+ const icons = iconLines.some((l) => l.includes("#ok") && l.includes("✔")) &&
630
+ iconLines.some((l) => l.includes("#bad") && l.includes("🟡")) &&
631
+ iconLines.some((l) => l.includes("#hot") && l.includes("🚧"));
632
+ // 👉 finger marks the ids that caused the most recent poke
633
+ const rtPoke = { armed: true, gateAttempts: 0, gateMaxAttempts: 5, pokeTargets: new Set(["b"]) };
634
+ const pokeLines = widget.renderTodoWidgetLines(state, rtPoke, 6, 60, stubTheme).map(unescape);
635
+ const finger = pokeLines.some((l) => l.includes("#b") && l.includes("👉")) &&
636
+ !pokeLines.some((l) => l.includes("#d") && l.includes("👉"));
637
+ // right-hand goals table: spelled-out headers, active group leads,
638
+ // spelled-out score cells, overflow footer, narrow widths drop it
639
+ const goalFixture = {
640
+ todos: [mk("a", "completed", { completion_confidence: 97, group: "verify" }), mk("b", "pending", { group: "builder", confidence: 70 })],
641
+ plan: {},
642
+ goals: [
643
+ { group: "verify", closed_feedback_loop: 93, end_to_end_ownership: 97, closed_feedback_loop_history: [], end_to_end_ownership_history: [], feedback_loop: "x" },
644
+ { group: "builder", closed_feedback_loop: 88, feedback_loop: "y", closed_feedback_loop_history: [], end_to_end_ownership_history: [] },
645
+ ],
646
+ };
647
+ const tableLines = widget.renderTodoWidgetLines(goalFixture, rtOn, 6, 120, stubTheme).map(unescape);
648
+ const builderRow = tableLines.findIndex((l) => l.includes("builder"));
649
+ const verifyRow = tableLines.findIndex((l) => l.includes("verify"));
650
+ const tableHeader = tableLines.some((l) => l.includes("Todo Goal") && l.includes("Settled") && l.includes("Conf") && l.includes("Own") && l.includes("Feedback"));
651
+ const activeFirst = builderRow > 0 && verifyRow > 0 && builderRow < verifyRow;
652
+ const verifyCells = tableLines[verifyRow].includes("1/1") && tableLines[verifyRow].includes("97%") && tableLines[verifyRow].includes("93%") && tableLines[verifyRow].includes("✔");
653
+ const builderCells = tableLines[builderRow].includes("0/1") && tableLines[builderRow].includes("88%") && tableLines[builderRow].includes("🔲");
654
+ const manyGroups = {
655
+ todos: [1,2,3,4,5,6,7].map((n) => mk(`t${n}`, n === 1 ? "in_progress" : "pending", { group: `grp${n}` })),
656
+ plan: {},
657
+ goals: [1,2,3,4,5,6,7].map((n) => ({ group: `grp${n}`, closed_feedback_loop: 80, feedback_loop: "z", closed_feedback_loop_history: [], end_to_end_ownership_history: [] })),
658
+ };
659
+ const manyLines = widget.renderTodoWidgetLines(manyGroups, rtOn, 6, 120, stubTheme).map(unescape);
660
+ const tableFooter = manyLines.some((l) => l.includes("more group") && l.includes("open"));
661
+ const narrowLines = widget.renderTodoWidgetLines(goalFixture, rtOn, 6, 46, stubTheme).map(unescape);
662
+ const tableDropped = !narrowLines.some((l) => l.includes("Settled")) && narrowLines.every((l) => l.length <= 46);
663
+ // headers and cells share column edges: every table line is the same visible width —
664
+ // across ALL marker classes (✔ pass, 🔲 pending, 🟡 gate-bait, 🚧 active)
665
+ const rawTable = widget.buildGoalsTable(goalFixture, 6, stubTheme).map(unescape);
666
+ const aligned = rawTable.length > 1 && new Set(rawTable.map((l) => l.length)).size === 1;
667
+ const mixedTbl = widget.buildGoalsTable({
668
+ todos: [
669
+ mk("c1", "completed", { completion_confidence: 97, group: "g-ok" }),
670
+ mk("c2", "completed", { completion_confidence: 88, group: "g-warn" }),
671
+ mk("c3", "in_progress", { group: "g-act" }),
672
+ mk("c4", "pending", { group: "g-wait" }),
673
+ mk("c5", "pending", { group: "g-nogoal" }), // group with NO goal entry: must still render ("–" scores)
674
+ ], plan: {}, goals: [
675
+ { group: "g-ok", closed_feedback_loop: 97, end_to_end_ownership: 97, closed_feedback_loop_history: [], end_to_end_ownership_history: [], feedback_loop: "x" },
676
+ { group: "g-warn", closed_feedback_loop: 97, end_to_end_ownership: 97, closed_feedback_loop_history: [], end_to_end_ownership_history: [], feedback_loop: "x" },
677
+ { group: "g-act", closed_feedback_loop: 90, closed_feedback_loop_history: [], end_to_end_ownership_history: [], feedback_loop: "x" },
678
+ { group: "g-wait", closed_feedback_loop: 90, closed_feedback_loop_history: [], end_to_end_ownership_history: [], feedback_loop: "x" },
679
+ ] }, 6, stubTheme).map(unescape);
680
+ const markRow = (frag) => mixedTbl.find((l) => l.includes(frag));
681
+ const allAligned = mixedTbl.length === 6 && new Set(mixedTbl.map((l) => l.length)).size === 1 &&
682
+ markRow("g-warn")?.includes("🟡") && markRow("g-ok")?.includes("✔") &&
683
+ markRow("g-act")?.includes("🚧") && markRow("g-wait")?.includes("🔲") &&
684
+ markRow("g-nogoal")?.includes("–");
685
+ if (firstIsHeader && bottomStatus && inProgressOnTop && capped && openBreakdown && withinWidth && armedMarker && ranked && lateLeads && indented && empty.length === 0 && !unescape(unarmed.at(-1)).includes("auto-poke") && offMarker && confTails && gateShown && doneShown && intentShown && multiClean && icons && finger && tableHeader && activeFirst && verifyCells && builderCells && tableFooter && tableDropped && aligned && allAligned) {
686
+ ok("T13 widget", "intention header + bottom status + table (Todo Goal) correct");
687
+ } else {
688
+ bad("T13 widget", JSON.stringify({ firstIsHeader, bottomStatus, inProgressOnTop, capped, withinWidth, offMarker, confTails, gateShown, doneShown, intentShown, multiClean, icons, finger, tableHeader, activeFirst, verifyCells, builderCells, tableFooter, tableDropped, aligned, allAligned, intentLines, mixedTbl }, null, 1));
689
+ }
690
+ });
691
+ }
692
+
693
+ // ============================================================================
694
+ // Suite B — turn-end state machine (scripted mock provider)
695
+ // ============================================================================
696
+
697
+ function makeMockStreamSimple(calls, script) {
698
+ let callIndex = 0;
699
+ return function streamSimple(model, context, options) {
700
+ callIndex += 1;
701
+ calls.push({ index: callIndex, context });
702
+ const step = callIndex;
703
+ const scriptCall = script.call;
704
+ const scriptText = script.text;
705
+ const stream = piai.createAssistantMessageEventStream();
706
+ (async () => {
707
+ const output = {
708
+ role: "assistant",
709
+ content: [],
710
+ api: model.api,
711
+ provider: model.provider,
712
+ model: model.id,
713
+ usage: {
714
+ input: 10, output: 10, cacheRead: 0, cacheWrite: 0, totalTokens: 20,
715
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
716
+ },
717
+ stopReason: "pending",
718
+ timestamp: Date.now(),
719
+ };
720
+ try {
721
+ stream.push({ type: "start", partial: output });
722
+ const toolCall = scriptCall(step);
723
+ if (toolCall) {
724
+ output.content.push(toolCall);
725
+ stream.push({ type: "toolcall_start", contentIndex: 0, partial: output });
726
+ stream.push({ type: "toolcall_delta", contentIndex: 0, delta: JSON.stringify(toolCall.arguments), partial: output });
727
+ stream.push({ type: "toolcall_end", contentIndex: 0, toolCall, partial: output });
728
+ output.stopReason = "toolUse";
729
+ } else {
730
+ const reply = scriptText(step);
731
+ output.content.push({ type: "text", text: reply });
732
+ stream.push({ type: "text_start", contentIndex: 0, partial: output });
733
+ stream.push({ type: "text_delta", contentIndex: 0, delta: reply, partial: output });
734
+ stream.push({ type: "text_end", contentIndex: 0, content: reply, partial: output });
735
+ output.stopReason = "stop";
736
+ }
737
+ stream.push({ type: "done", reason: output.stopReason, message: output });
738
+ stream.end();
739
+ } catch (err) {
740
+ output.stopReason = "error";
741
+ output.errorMessage = err.message;
742
+ stream.push({ type: "error", reason: "error", error: output });
743
+ stream.end();
744
+ }
745
+ })();
746
+ return stream;
747
+ };
748
+ }
749
+
750
+ const MOCK_MODEL = {
751
+ provider: "mock-pi-jtodo",
752
+ id: "mock-1",
753
+ name: "Mock",
754
+ api: "openai-completions",
755
+ baseUrl: "http://localhost",
756
+ reasoning: false,
757
+ input: ["text"],
758
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
759
+ contextWindow: 1_000_000,
760
+ maxTokens: 8192,
761
+ };
762
+
763
+ // Scripted conversations (per scenario).
764
+ // B1 — poke + spike gate:
765
+ // call 1: create one in_progress todo (aligned plan+goal, spike seed 80)
766
+ // call 2: "done for now" -> settle#1: auto-poke fires
767
+ // call 3: complete it, cc 97, ownership 97
768
+ // call 4: "all done" -> settle#2: spike gate fires (Δ80→97 ≥ 15)
769
+ // call 5: "re-validated" -> settle#3: gates clear, cycle completes
770
+ const SCRIPT_B1 = {
771
+ call(step) {
772
+ if (step === 1) {
773
+ return {
774
+ type: "toolCall", id: "call-create", name: "todo",
775
+ arguments: {
776
+ todos: [{ content: "verify rendering pipeline", status: "in_progress", priority: "high", id: "t1", group: "render", confidence: 80 }],
777
+ plan: { user_intention: "e2e turn-end verification", understands_user_intent: 97 },
778
+ goals: [{ group: "render", closed_feedback_loop: 97, feedback_loop: "run the mock suite" }],
779
+ },
780
+ };
781
+ }
782
+ if (step === 3) {
783
+ return {
784
+ type: "toolCall", id: "call-complete", name: "todo",
785
+ arguments: {
786
+ todos: [{ content: "verify rendering pipeline", status: "completed", priority: "high", id: "t1", group: "render", confidence: 80, completion_confidence: 97 }],
787
+ goals: [{ group: "render", closed_feedback_loop: 97, feedback_loop: "run the mock suite", end_to_end_ownership: 97 }],
788
+ },
789
+ };
790
+ }
791
+ return null;
792
+ },
793
+ text(step) {
794
+ if (step === 2) return "Done for now.";
795
+ if (step === 4) return "All wrapped up.";
796
+ if (step === 5) return "Re-ran the suite; results unchanged.";
797
+ return "ok";
798
+ },
799
+ calls: 5,
800
+ };
801
+
802
+ // B2 — deferred digest with late-climb wording:
803
+ // call 1: create in_progress todo with intent 80 (<96, observation)
804
+ // call 2: "working" -> settle#1: poke
805
+ // call 3: complete + ownership 97 + intent climbs to 97 (late climb)
806
+ // call 4: "done" -> settle#2: digest (once, late-climb wording)
807
+ // call 5: "rechecked" -> settle#3: spike gate (Δ80→97)
808
+ // call 6: "verified" -> settle#4: cycle completes (no 2nd digest)
809
+ const SCRIPT_B2 = {
810
+ call(step) {
811
+ if (step === 1) {
812
+ return {
813
+ type: "toolCall", id: "call-create", name: "todo",
814
+ arguments: {
815
+ todos: [{ content: "ambiguous cleanup", status: "in_progress", priority: "medium", id: "t1", confidence: 80 }],
816
+ plan: { user_intention: "half-understood request", understands_user_intent: 80 },
817
+ },
818
+ };
819
+ }
820
+ if (step === 3) {
821
+ return {
822
+ type: "toolCall", id: "call-complete", name: "todo",
823
+ arguments: {
824
+ todos: [{ content: "ambiguous cleanup", status: "completed", priority: "medium", id: "t1", confidence: 80, completion_confidence: 97 }],
825
+ plan: { user_intention: "fully understood now", understands_user_intent: 97 },
826
+ goals: [{ closed_feedback_loop: 97, feedback_loop: "diff the output snapshot", end_to_end_ownership: 97 }],
827
+ },
828
+ };
829
+ }
830
+ return null;
831
+ },
832
+ text(step) {
833
+ if (step === 2) return "Working.";
834
+ if (step === 4) return "Done.";
835
+ if (step === 5) return "Re-checked the earlier work against the request.";
836
+ if (step === 6) return "Verified with fresh evidence.";
837
+ return "ok";
838
+ },
839
+ calls: 6,
840
+ };
841
+
842
+ function contextUserTexts(call) {
843
+ return call.context.messages
844
+ .filter((m) => m.role === "user")
845
+ .map((m) =>
846
+ typeof m.content === "string" ? m.content : (m.content ?? []).map((c) => c.text ?? "").join(""),
847
+ );
848
+ }
849
+
850
+ // B3: a clean cycle disarms; a later write adding open work RE-ARMS the poke
851
+ // (the pi-specific deviation that fixed the live 'agent stopped forever' fail).
852
+ const SCRIPT_B3 = {
853
+ call(step) {
854
+ if (step === 1) {
855
+ return {
856
+ type: "toolCall", id: "call-open", name: "todo",
857
+ arguments: {
858
+ todos: [{ content: "first wave", status: "in_progress", priority: "high", id: "w1", group: "wave", confidence: 96 }],
859
+ plan: { user_intention: "rearm e2e", understands_user_intent: 97 },
860
+ goals: [{ group: "wave", closed_feedback_loop: 97, feedback_loop: "script asserts pokes" }],
861
+ },
862
+ };
863
+ }
864
+ if (step === 3) {
865
+ return {
866
+ type: "toolCall", id: "call-done", name: "todo",
867
+ arguments: {
868
+ todos: [{ content: "first wave", status: "completed", priority: "high", id: "w1", group: "wave", confidence: 96, completion_confidence: 96 }],
869
+ goals: [{ group: "wave", closed_feedback_loop: 97, feedback_loop: "script asserts pokes", end_to_end_ownership: 96 }],
870
+ },
871
+ };
872
+ }
873
+ if (step === 5) {
874
+ return {
875
+ type: "toolCall", id: "call-reopen", name: "todo",
876
+ arguments: {
877
+ todos: [
878
+ { content: "first wave", status: "completed", priority: "high", id: "w1", group: "wave", confidence: 96, completion_confidence: 96 },
879
+ { content: "second wave", status: "in_progress", priority: "high", id: "w2", group: "wave", confidence: 96 },
880
+ ],
881
+ },
882
+ };
883
+ }
884
+ if (step === 7) {
885
+ return {
886
+ type: "toolCall", id: "call-done2", name: "todo",
887
+ arguments: {
888
+ todos: [
889
+ { content: "first wave", status: "completed", priority: "high", id: "w1", group: "wave", confidence: 96, completion_confidence: 96 },
890
+ { content: "second wave", status: "completed", priority: "high", id: "w2", group: "wave", confidence: 96, completion_confidence: 96 },
891
+ ],
892
+ goals: [{ group: "wave", closed_feedback_loop: 97, feedback_loop: "script asserts pokes", end_to_end_ownership: 96 }],
893
+ },
894
+ };
895
+ }
896
+ return null;
897
+ },
898
+ text(step) {
899
+ if (step === 2) return "Wave one done.";
900
+ if (step === 4) return "All wrapped.";
901
+ if (step === 6) return "Adding more work.";
902
+ if (step === 8) return "Really done now.";
903
+ return "ok";
904
+ },
905
+ promptAfter: 4, // clean all-done disarms; the user starts wave two
906
+ promptText: "one more thing to do",
907
+ calls: 8,
908
+ };
909
+
910
+ async function runScenario(name, script, assertions) {
911
+ const calls = [];
912
+ const mockFactory = {
913
+ name: "mock-provider",
914
+ factory: (api) => {
915
+ api.registerProvider(MOCK_MODEL.provider, {
916
+ name: "Mock",
917
+ baseUrl: MOCK_MODEL.baseUrl,
918
+ apiKey: "mock-key",
919
+ api: MOCK_MODEL.api,
920
+ models: [MOCK_MODEL],
921
+ streamSimple: makeMockStreamSimple(calls, script),
922
+ });
923
+ },
924
+ };
925
+ await run(name, async ({ session }) => {
926
+ await session.setModel(MOCK_MODEL);
927
+ await session.prompt("begin");
928
+
929
+ const deadline = Date.now() + 20_000;
930
+ let prompted = false;
931
+ while (Date.now() < deadline) {
932
+ if (!prompted && script.promptAfter && calls.length >= script.promptAfter) {
933
+ prompted = true;
934
+ void session.prompt(script.promptText ?? "continue");
935
+ }
936
+ if (calls.length >= script.calls) {
937
+ await sleep(800);
938
+ if (calls.length === script.calls) break; // no further provider activity
939
+ }
940
+ await sleep(100);
941
+ }
942
+ const entries = session.sessionManager.getEntries();
943
+ const customs = entries
944
+ .filter((e) => e.type === "custom_message" && e.customType === "pi-jtodo/followup")
945
+ .map((e) => (typeof e.content === "string" ? e.content : ""));
946
+ if (calls.length !== script.calls) {
947
+ bad(`${name} flow`, `expected ${script.calls} provider calls, got ${calls.length}; customs=${JSON.stringify(customs.map((t) => t.slice(0, 60)))}`);
948
+ return;
949
+ }
950
+
951
+ const todoResults = entries.filter(
952
+ (e) => e.type === "message" && e.message?.role === "toolResult" && e.message?.toolName === "todo",
953
+ );
954
+ const lastDetails = todoResults.at(-1)?.message?.details;
955
+ await assertions({ calls, customs, lastDetails });
956
+ }, { extensionFactories: [mockFactory] });
957
+ }
958
+
959
+ async function suiteB() {
960
+ console.log("\nSuite B: turn-end state machine (scripted provider)");
961
+
962
+ await runScenario("B1 poke -> spike gate -> cycle completes", SCRIPT_B1, async ({ calls, customs, lastDetails }) => {
963
+ const poke = customs.find((t) => t.includes("You have 1 incomplete todo."));
964
+ const spike = customs.find((t) => t.includes("rose too sharply"));
965
+ const digestLeak = customs.find((t) => t.includes("todo quality review"));
966
+ if (!poke) bad("B1 auto-poke followup", `customs=${JSON.stringify(customs)}`);
967
+ else ok("B1 auto-poke followup", "exact poke text as custom message");
968
+ if (!spike) bad("B1 spike gate followup", `customs=${JSON.stringify(customs)}`);
969
+ else ok("B1 spike gate followup", "spike continuation fired once");
970
+ if (digestLeak) bad("B1 no digest without observations", "digest leaked");
971
+ else ok("B1 no digest without observations", "aligned plan recorded nothing");
972
+
973
+ // The mock must have SEEN the gate follow-ups as role:"user" messages
974
+ // (jcode's load-bearing user-role property via convertToLlm).
975
+ const users = contextUserTexts(calls[4]);
976
+ if (users.some((t) => t.includes("rose too sharply"))) {
977
+ ok("B1 user-role parity", "gate text reached the model as a user message");
978
+ } else {
979
+ bad("B1 user-role parity", `users=${JSON.stringify(users.slice(-3))}`);
980
+ }
981
+
982
+ if (lastDetails?.todos?.[0]?.status === "completed") ok("B1 final todo state", "completed");
983
+ else bad("B1 final todo state", JSON.stringify(lastDetails?.todos));
984
+ });
985
+
986
+ await runScenario("B2 deferred digest, late-climb wording, once", SCRIPT_B2, async ({ calls, customs, lastDetails }) => {
987
+ const digests = customs.filter((t) => t.includes("todo quality review"));
988
+ if (digests.length === 1 && digests[0].includes("started this work without understanding")) {
989
+ ok("B2 digest delivered once, late-climb", "exactly one, climb wording");
990
+ } else {
991
+ bad("B2 digest delivered once, late-climb", `digests=${JSON.stringify(digests)}`);
992
+ }
993
+ if (digests.some((t) => /\d+/.test(t.replace(/flagged \d+ times/, "")))) {
994
+ bad("B2 digest hides scores", "numeric leak in digest");
995
+ } else {
996
+ ok("B2 digest hides scores", "no score/threshold numbers");
997
+ }
998
+ const spike = customs.find((t) => t.includes("rose too sharply"));
999
+ if (spike) ok("B2 spike gate after digest", "ordering: digest before gates");
1000
+ else bad("B2 spike gate after digest", `customs=${JSON.stringify(customs)}`);
1001
+ // digest reached the model as a user message (call 5 is the post-digest turn)
1002
+ const users5 = contextUserTexts(calls[4]);
1003
+ if (users5.some((t) => t.includes("todo quality review"))) ok("B2 digest user-role", "visible to model");
1004
+ else bad("B2 digest user-role", "not in provider context");
1005
+ if (lastDetails?.plan?.understands_user_intent_history?.join(",") === "80,97") {
1006
+ ok("B2 intent history persisted", "[80,97]");
1007
+ } else {
1008
+ bad("B2 intent history persisted", JSON.stringify(lastDetails?.plan));
1009
+ }
1010
+ });
1011
+
1012
+ await runScenario("B3 new open work re-arms the poke", SCRIPT_B3, async ({ customs, lastDetails }) => {
1013
+ const pokes = customs.filter((t) => t.includes("You have 1 incomplete todo."));
1014
+ if (pokes.length === 2) ok("B3 re-arm poke", "poke #1 after create, poke #2 after re-open");
1015
+ else bad("B3 re-arm poke", `poke count=${pokes.length} customs=${JSON.stringify(customs)}`);
1016
+ const noisy = customs.filter((t) => t.includes("rose too sharply") || t.includes("not high enough") || t.includes("todo quality review"));
1017
+ if (noisy.length === 0) ok("B3 no spurious gates", "clean scores produced no challenges/digest");
1018
+ else bad("B3 no spurious gates", JSON.stringify(noisy));
1019
+ const done = (lastDetails?.todos ?? []).every((t) => t.status === "completed")
1020
+ && (lastDetails?.todos ?? []).length === 2;
1021
+ if (done) ok("B3 final state", "both waves completed");
1022
+ else bad("B3 final state", JSON.stringify(lastDetails?.todos));
1023
+ });
1024
+ }
1025
+
1026
+ // ============================================================================
1027
+
1028
+ async function main() {
1029
+ console.log("pi-jtodo regression suite");
1030
+ console.log(`extension: ${EXT_INDEX}`);
1031
+ await suiteA();
1032
+ await suiteB();
1033
+ console.log("\n" + "═".repeat(60));
1034
+ console.log(` PASS: ${pass} FAIL: ${fail}`);
1035
+ console.log("═".repeat(60));
1036
+ for (const r of results) console.log(r);
1037
+ process.exit(fail > 0 ? 1 : 0);
1038
+ }
1039
+
1040
+ main().catch((e) => { console.error("FATAL:", e); process.exit(1); });