pi-anchorloop 2.4.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/pi/book.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * pi extension: the loop book. The first `todo` create or `kind` declaration opens a loop and its book. The agent declares only
3
- * what the program cannot observe (`kind` with its intent and anchor command, a non-command `verify`,
2
+ * pi extension: the loop book. The first `todo` create opens a loop and its book as `inline`, its intent the first todo's subject
3
+ * and its anchor a stated `Reality anchor:` line or the todo's `metadata.anchor`; a `kind` declaration opens one too. The agent declares only
4
+ * what the program cannot observe (`kind` to choose `reviewed` or `durable`, a non-command `verify`,
4
5
  * `fresh-read`, `friction`, `reflect`, `skip`, `back`, `close`); the extension derives the rest from
5
6
  * evidence: production edits, the exit code of the anchor command, reviewer results, durable artifacts,
6
7
  * and the fresh-reader subagent. A phase machine over that evidence warns once per jump and never blocks;
@@ -19,6 +20,7 @@ import {
19
20
  anchorCommand,
20
21
  appendEntry,
21
22
  artifactKind,
23
+ bases,
22
24
  childId,
23
25
  childIds,
24
26
  childLines,
@@ -35,6 +37,7 @@ import {
35
37
  openLoop,
36
38
  replayTodos,
37
39
  refsOf,
40
+ repoRootOf,
38
41
  retryPairs,
39
42
  resolveRef,
40
43
  reviewTrend,
@@ -50,6 +53,8 @@ import {
50
53
  touchRepoBeforeMerge,
51
54
  validFollowup,
52
55
  verifyAfterLastAct,
56
+ workingTrees,
57
+ adoptableRun,
53
58
  } from "./support/book.ts";
54
59
  import { classify } from "./support/jev.ts";
55
60
  import { REVIEWLESS, SEQUENCES, computePhases, describe, incompleteBefore, outsideWork, reachLine, reachOf, unansweredReach } from "./support/phases.ts";
@@ -62,9 +67,6 @@ const REVIEWERS = new Set(["reviewer", "adversarial-reviewer"]);
62
67
  const BLOCKING = /Blocking\s*:?\**\s*:?\s*P0\s*=\s*(\d+)\D{0,5}P1\s*=\s*(\d+)/gi;
63
68
  const ANCHOR_LINE = /Reality anchor:[ \t]*(.*)/;
64
69
 
65
- export const OPEN_LINE =
66
- "anchorloop: a loop is open. Declare it with `anchorloop_stage` {stage: kind, kind: quick|inline|reviewed|durable, intent: <one line>, anchor: <check command>}.";
67
-
68
70
  type Params = {
69
71
  stage: (typeof STAGES)[number];
70
72
  kind?: (typeof KINDS)[number];
@@ -330,7 +332,7 @@ function statedAnchor(message: { role?: string; content?: unknown } | undefined)
330
332
  return found ? found[1].trim().slice(0, 300) || "(stated on the next line)" : undefined;
331
333
  }
332
334
 
333
- const STATUS_KEY = "anchorloop";
335
+ const WIDGET_KEY = "anchorloop";
334
336
 
335
337
  const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
336
338
 
@@ -402,6 +404,23 @@ export default function (pi: ExtensionAPI) {
402
404
  }
403
405
  }
404
406
 
407
+ /** True while no `kind` call has chosen the kind: the loop still holds the `inline` a todo create opened it with. */
408
+ function kindIsDefault(entries: Entry[]): boolean {
409
+ return !entries.some((entry) => entry.stage === "kind" && typeof entry.kind === "string" && entry.auto !== true);
410
+ }
411
+
412
+ /** Opens the loop at the first todo create as `inline`: the first todo's subject is the intent, a stated `Reality anchor:` line or its `metadata.anchor` the anchor. */
413
+ function openFromTodo(ctx: ExtensionContext, details: unknown): Book {
414
+ const book = createBook(ctx);
415
+ touchRepo(book, join(ctx.cwd, "_"));
416
+ const first = (taskDetails(details) ?? [])[0];
417
+ book.entries.push(appendEntry(book.path, "kind", { kind: "inline", auto: true, ...(first?.subject ? { intent: clip(first.subject.replace(/\s+/g, " ").trim(), 200) } : {}) }));
418
+ recordPriorAnchor(book, ctx);
419
+ const anchor = first?.metadata?.anchor;
420
+ if (typeof anchor === "string" && anchor.trim() && !anchorCommand(book.entries)) book.entries.push(appendEntry(book.path, "anchor", { check: anchor.trim() }));
421
+ return book;
422
+ }
423
+
405
424
  function close(book: Book, ctx: ExtensionContext, verdict: string): { text: string; snapshot?: Snapshot } {
406
425
  const todos = replayTodos(ctx.sessionManager.getBranch());
407
426
  const undisposed = todos.filter((task) => isOpenTask(task) && !validFollowup(task.metadata?.followup));
@@ -415,7 +434,6 @@ export default function (pi: ExtensionAPI) {
415
434
  const kind = currentKind(book.entries);
416
435
  const phases = phasesOf(book, ctx);
417
436
  const missing = incompleteBefore(phases, "close");
418
- const notice = verdict === "landed" && missing.length ? `anchorloop: closed with phases not shown complete: ${missing.map((status) => `${status.name} (${status.note})`).join("; ")}.` : undefined;
419
437
  const diff = diffAgainstBases(book.entries);
420
438
  const friction = allFriction(book.entries);
421
439
  const phaseList = phases.statuses.map((status) => ({ phase: status.name, state: status.state === "skipped" && status.auto ? "auto-skipped" : status.state === "missing" ? "not shown complete" : status.state, ...(status.state === "skipped" ? { note: status.note } : {}) }));
@@ -434,25 +452,16 @@ export default function (pi: ExtensionAPI) {
434
452
  followups: todos.filter((task) => isOpenTask(task)).map((task) => ({ task: task.subject, followup: task.metadata?.followup })),
435
453
  };
436
454
  const unreflected = friction.filter((item) => !item.reflected);
437
- const tail = unreflected.length
438
- ? ` Unreflected friction for the Reflexive Pass: ${unreflected.map((item) => `${item.ref} (${item.found})`).join("; ")}.`
439
- : "";
440
- const named = (item: (typeof phaseList)[number]) => `${item.phase}${item.note ? ` (${item.note})` : ""}`;
441
- const doneNames = phaseList.filter((item) => item.state === "done").map((item) => item.phase);
442
- const skippedNames = phaseList.filter((item) => item.state === "skipped" || item.state === "auto-skipped").map(named);
443
- const openNames = phaseList.filter((item) => item.state === "not shown complete").map((item) => item.phase);
444
- const shown = [
445
- `done: ${doneNames.join(", ") || "none"}`,
446
- ...(skippedNames.length ? [`skipped: ${skippedNames.join(", ")}`] : []),
447
- ...(openNames.length ? [`${verdict === "landed" ? "not complete" : "not reached"}: ${openNames.join(", ")}`] : []),
448
- ].join("; ");
449
- const back = summary.back.length ? `; back ${summary.back.join(", ")}` : "";
450
- const text = `Loop closed (${verdict}): phases ${shown}; ${summary.reviewTrend}; diff ${diff.summary.split("\n").join("; ")}${back}.${tail}`;
451
- const after = notice ? `\n${notice}` : "";
455
+ // The agent has just done the finished phases, so the result names only what is left.
456
+ const text = [
457
+ `closed (${verdict}).`,
458
+ ...(missing.length ? [`${verdict === "landed" ? "not complete" : "not reached"}: ${missing.map((status) => (verdict === "landed" ? `${status.name} (${status.note})` : status.name)).join("; ")}.`] : []),
459
+ ...(unreflected.length ? [`unreflected friction: ${unreflected.map((item) => `${item.ref} (${item.found})`).join("; ")}.`] : []),
460
+ ].join(" ");
452
461
  if (kept) {
453
462
  // A child's book stays for the main session to read and delete.
454
463
  appendEntry(book.path, "close", { verdict, summary: text.slice(0, 500) });
455
- return { text: `${text} The book stays for the main session.${after}` };
464
+ return { text: `${text} The book stays for the main session.` };
456
465
  }
457
466
  // Jev judges the loop as it stood at close; the book is gone by the time the result is judged.
458
467
  const snap = kind === "quick" ? undefined : snapshot(book, ctx);
@@ -461,38 +470,33 @@ export default function (pi: ExtensionAPI) {
461
470
  for (const { book: child } of findChildBooks(id)) deleteBook(child.path);
462
471
  }
463
472
  deleteBook(book.path);
464
- return { text: `${text} The book is deleted. Clear the todo list now (todo action: clear).${after}`, snapshot: snap };
473
+ return { text: `${text} Clear the todo list.`, snapshot: snap };
465
474
  }
466
475
 
467
476
  pi.registerTool({
468
477
  name: "anchorloop_stage",
469
478
  label: "Loop stage",
470
479
  description:
471
- "Declare only what the extension cannot observe in the anchorloop loop book. " +
472
- "`kind` (kind, intent, optional anchor command; call again to set the anchor, or with `reason` to change the kind or answer a re-classification): quick (act, check), inline (anchor, act, verify), reviewed (adds review), durable (adds decide, grill, spec, read, approve, design); every kind ends with reflect, close. " +
473
- "When an anchor run or declared verify of a quick or inline loop finds the change reaching 3 source files, 2 modules, or a README, changelog, or *.schema.json beside code, its verify waits for a `kind` call with `reason`. " +
474
- "`verify` (note; only for an anchor that is not a command), `fresh-read` (a fresh-context read of the spec is done), `friction` (tried, found, led_by), `reflect` (friction ids, class misdirection|gap|resolved, action), " +
475
- "`skip` / `back` (phase, reason), `close` (verdict), `read` (view the book). Edits, anchor runs, reviews, and spec files are read from evidence. " +
476
- "A loop opens with the first `todo` create; `close` refuses while a pending or in-progress todo lacks metadata.followup.",
480
+ "Loop book; the first `todo` create opens a loop as inline. stage: kind (kind, intent, anchor; reason to change the kind or answer a reach notice), verify (note; only for a check not run in bash), fresh-read, friction (tried, found, led_by), reflect (friction ids, class, action), skip/back (phase, reason), close (verdict; refuses while an open todo lacks metadata.followup), read. Edits, anchor runs, reviews, and specs are read from evidence.",
477
481
  parameters: {
478
482
  type: "object",
479
483
  properties: {
480
484
  stage: { type: "string", enum: [...STAGES] },
481
- kind: { type: "string", enum: [...KINDS], description: "stage kind: the loop kind; a later call with `reason` changes it" },
482
- intent: { type: "string", description: "stage kind: one line on what the loop delivers" },
483
- anchor: { type: "string", description: "stage kind: the check command whose exit code observes the result" },
484
- note: { type: "string", description: "stage verify: what was observed; stage fresh-read: who read the spec" },
485
- result: { type: "string", enum: ["pass", "fail"], description: "stage verify; default pass" },
486
- tried: { type: "string", description: "stage friction: the attempt that failed" },
487
- found: { type: "string", description: "stage friction: what the failure revealed" },
488
- led_by: { type: "string", description: "stage friction: the skill or instruction line that prompted the attempt, or `none`" },
489
- friction: { type: "array", items: { type: ["string", "number"] }, description: "stage reflect: friction ids (`3`, or `worker#2` for a child's)" },
490
- class: { type: "string", enum: ["misdirection", "gap", "resolved"], description: "stage reflect" },
491
- action: { type: "string", description: "stage reflect: what was done, such as solid-loop, memory, or a commit" },
492
- phase: { type: "string", description: "stage skip or back: the phase" },
493
- reason: { type: "string", description: "stage skip or back: why; stage kind: why the kind changes, or why it still fits the measured reach" },
494
- verdict: { type: "string", enum: ["landed", "blocked", "abandoned"], description: "stage close" },
495
- view: { type: "string", enum: ["summary", "all"], description: "stage read; default summary" },
485
+ kind: { type: "string", enum: [...KINDS] },
486
+ intent: { type: "string" },
487
+ anchor: { type: "string", description: "the check command" },
488
+ note: { type: "string" },
489
+ result: { type: "string", enum: ["pass", "fail"] },
490
+ tried: { type: "string" },
491
+ found: { type: "string" },
492
+ led_by: { type: "string", description: "the instruction that misled the attempt, or `none`" },
493
+ friction: { type: "array", items: { type: ["string", "number"] }, description: "ids: `3`, `worker#2`" },
494
+ class: { type: "string", enum: ["misdirection", "gap", "resolved"] },
495
+ action: { type: "string" },
496
+ phase: { type: "string" },
497
+ reason: { type: "string" },
498
+ verdict: { type: "string", enum: ["landed", "blocked", "abandoned"] },
499
+ view: { type: "string", enum: ["summary", "all"] },
496
500
  },
497
501
  required: ["stage"],
498
502
  additionalProperties: false,
@@ -533,22 +537,17 @@ export default function (pi: ExtensionAPI) {
533
537
  if (stage === "kind") {
534
538
  if (!params.kind && !params.intent && !params.anchor && !params.reason) throw new Error("kind needs `kind`, `intent`, `anchor`, or `reason`");
535
539
  if (!had && (!params.kind || !params.intent)) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
540
+ // The kind a todo create defaulted to is not a choice yet, so choosing another needs no reason.
536
541
  const changed = had !== undefined && params.kind !== undefined && had !== params.kind;
537
- if (changed && !params.reason) throw new Error(`changing the kind from ${had} to ${params.kind} needs \`reason\`: the reach grew, the user added scope, a user-owned decision appeared, or the change turned out smaller`);
542
+ if (changed && !params.reason && !kindIsDefault(book.entries)) throw new Error(`changing the kind from ${had} to ${params.kind} needs \`reason\`: the reach grew, the user added scope, a user-owned decision appeared, or the change turned out smaller`);
538
543
  touchRepo(book, join(ctx.cwd, "_"));
539
- const notes: string[] = [];
540
544
  if (params.kind || params.intent || params.reason) {
541
545
  // A reason answers the reach measured now, so a later threshold asks again.
542
546
  const answer = params.reason ? { reason: params.reason, crossed: reachOf(book.entries, ctx.cwd).crossed } : {};
543
547
  appendEntry(book.path, "kind", { kind: params.kind ?? had, ...(params.intent ? { intent: params.intent } : {}), ...answer });
544
- const why = params.reason ? ` (${params.reason})` : "";
545
- notes.push(changed ? `kind changed: ${had} → ${params.kind}${why}` : `kind: ${params.kind ?? had}${why}`);
546
- }
547
- if (params.anchor) {
548
- appendEntry(book.path, "anchor", { check: params.anchor });
549
- notes.push(`anchor: ${params.anchor}`);
550
548
  }
551
- return reply(notes.join("\n"));
549
+ if (params.anchor) appendEntry(book.path, "anchor", { check: params.anchor });
550
+ return reply("ok");
552
551
  }
553
552
  if (stage === "verify") {
554
553
  if (!params.note) throw new Error("verify needs a `note` saying what was observed");
@@ -557,17 +556,17 @@ export default function (pi: ExtensionAPI) {
557
556
  const entry = appendEntry(book.path, recorded, { declared: true, note: params.note, result, exit: result === "pass" ? 0 : 1, ...reachField(book, ctx.cwd) });
558
557
  book.entries.push(entry);
559
558
  const notice = reachNotice(book, entry);
560
- return reply(`${recorded} declared: ${result} — ${params.note}${notice ? `\n${notice}` : ""}`);
559
+ return reply(notice ? `ok\n${notice}` : "ok");
561
560
  }
562
561
  if (stage === "fresh-read") {
563
562
  appendEntry(book.path, "goldfish", { source: "declared", ...(params.note ? { note: params.note } : {}) });
564
- return reply("fresh-read recorded.");
563
+ return reply("ok");
565
564
  }
566
565
  if (stage === "friction") {
567
566
  if (!params.tried || !params.found) throw new Error("friction needs `tried` and `found`");
568
567
  const id = frictionOf(book.entries).length + 1;
569
568
  appendEntry(book.path, "friction", { id, tried: params.tried, found: params.found, led_by: params.led_by || "none" });
570
- return reply(`friction #${id} recorded.`);
569
+ return reply(`friction #${id}`);
571
570
  }
572
571
  if (stage === "reflect") {
573
572
  const known = allFriction(book.entries).map((item) => item.ref);
@@ -576,7 +575,7 @@ export default function (pi: ExtensionAPI) {
576
575
  const unknown = ids.filter((id) => !known.includes(id));
577
576
  if (unknown.length) throw new Error(`unknown friction ${unknown.join(", ")}; known: ${known.join(", ") || "none"}`);
578
577
  appendEntry(book.path, "reflect", { friction: ids, class: params.class, ...(params.action ? { action: params.action } : {}) });
579
- return reply(`reflect recorded for ${ids.join(", ")} (${params.class}).`);
578
+ return reply("ok");
580
579
  }
581
580
  // skip and back
582
581
  const phase = params.phase;
@@ -584,7 +583,7 @@ export default function (pi: ExtensionAPI) {
584
583
  if (!phase || !known.includes(phase)) throw new Error(`${stage} needs \`phase\`, one of ${known.join(", ")} for a ${had ?? "undeclared"} loop`);
585
584
  if (!params.reason) throw new Error(`${stage} needs a \`reason\``);
586
585
  appendEntry(book.path, stage, { phase, reason: params.reason });
587
- return reply(stage === "skip" ? `${phase}: skipped (${params.reason}).` : `back to ${phase} (${params.reason}); later phases count as incomplete until their conditions hold again.`);
586
+ return reply("ok");
588
587
  }
589
588
 
590
589
  pi.on(
@@ -621,6 +620,38 @@ export default function (pi: ExtensionAPI) {
621
620
  return reach && unansweredReach(book.entries, reach).length > 0 ? `anchorloop: ${reachLine(reach, currentKind(book.entries))}.` : undefined;
622
621
  }
623
622
 
623
+ /** The repos the loop works in: the repos it changed and the one it runs in. */
624
+ function loopRepos(book: Book, cwd: string): string[] {
625
+ const here = repoRootOf(join(cwd, "_"));
626
+ return [...new Set([...bases(book.entries).map((base) => base.repo), ...(here ? [here] : [])])];
627
+ }
628
+
629
+ function loopTrees(book: Book, cwd: string): string[] {
630
+ return workingTrees(loopRepos(book, cwd));
631
+ }
632
+
633
+ /** A child's anchor run carries the trees it ran on, so the coordinator can tell whether it holds that same state. */
634
+ function treesField(book: Book, cwd: string): { trees?: string[] } {
635
+ const trees = loopTrees(book, cwd);
636
+ return trees.length ? { trees } : {};
637
+ }
638
+
639
+ /**
640
+ * Records a linked child's passing run of the loop anchor as this loop's verify when the loop now holds exactly the state
641
+ * that run checked (after a merge lands a worker's branch, or when a child that worked in this checkout returns), so the
642
+ * coordinator does not run the anchor a second time. Any other state, or another command, leaves verify to its own run.
643
+ */
644
+ function adoptChildRun(book: Book, cwd: string): void {
645
+ const anchor = anchorCommand(book.entries);
646
+ if (!anchor || verifyAfterLastAct(book.entries) || !book.entries.some((entry) => entry.stage === "link")) return;
647
+ const repos = loopRepos(book, cwd);
648
+ const trees = workingTrees(repos);
649
+ const found = adoptableRun(book.entries, anchor, trees, repos, cwd);
650
+ if (!found) return;
651
+ const last = `adopted from ${found.name}${found.run.last ? `: ${String(found.run.last)}` : ""}`.slice(0, 200);
652
+ book.entries.push(appendEntry(book.path, "verify", { command: anchor.slice(0, 200), exit: 0, last, adopted: found.name, trees, ...reachField(book, cwd) }));
653
+ }
654
+
624
655
  /**
625
656
  * Records what a finished bash call shows: runs of the loop anchor, of task anchors, and a first commit as the change.
626
657
  * Returns the re-classification line when an anchor run of an unreviewed loop measured a reach no `kind` answer covers.
@@ -636,9 +667,10 @@ export default function (pi: ExtensionAPI) {
636
667
  // A failed run keeps its key line, so a rerun that passes can name what failed without the whole output.
637
668
  const keyOf = (code: unknown) => (typeof code === "number" && code !== 0 ? { key: keyLine(output) } : {});
638
669
  if (anchor && anchorRun(command, anchor)) {
639
- const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
670
+ const child = childId() !== undefined;
671
+ const recorded = child || currentKind(book.entries) === "quick" ? "check" : "verify";
640
672
  const code = anchorExit(command, anchor, exit);
641
- const entry = appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: code, last, ...keyOf(code), ...reachField(book, cwd) });
673
+ const entry = appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: code, last, ...keyOf(code), ...reachField(book, cwd), ...(child ? treesField(book, cwd) : {}) });
642
674
  book.entries.push(entry);
643
675
  const reach = reachNotice(book, entry);
644
676
  if (reach) notices.push(reach);
@@ -648,11 +680,14 @@ export default function (pi: ExtensionAPI) {
648
680
  const code = anchorExit(command, task.anchor, exit);
649
681
  book.entries.push(appendEntry(book.path, "taskrun", { id: task.id, command: task.anchor.slice(0, 200), exit: code, last, ...keyOf(code) }));
650
682
  }
651
- notices.push(...autoFriction(book));
683
+ autoFriction(book);
652
684
  const merged = mergeRun(command);
653
- if (exit === 0 && merged && !/Already up[ -]to[ -]date/i.test(last ?? "") && book.entries.at(-1)?.stage !== "edit") {
654
- touchRepoBeforeMerge(book, resolve(String(book.header.cwd ?? process.cwd()), merged.dir), merged.verb);
655
- book.entries.push(appendEntry(book.path, "edit", { via: "merge" }));
685
+ if (exit === 0 && merged && !/Already up[ -]to[ -]date/i.test(last ?? "")) {
686
+ if (book.entries.at(-1)?.stage !== "edit") {
687
+ touchRepoBeforeMerge(book, resolve(String(book.header.cwd ?? process.cwd()), merged.dir), merged.verb);
688
+ book.entries.push(appendEntry(book.path, "edit", { via: "merge" }));
689
+ }
690
+ adoptChildRun(book, cwd);
656
691
  }
657
692
  if (exit === 0 && /\bgit\b[^|;&]*\bcommit\b/.test(command) && !book.entries.some((entry) => entry.stage === "edit")) {
658
693
  const cwd = String(book.header.cwd ?? "");
@@ -664,10 +699,10 @@ export default function (pi: ExtensionAPI) {
664
699
  /**
665
700
  * Records one friction line for each anchor that passed again after it had passed and then failed. A pair is recorded
666
701
  * once, and not at all when the agent recorded a friction entry itself after the failure. The pairs one command
667
- * completes in one bash call (it is both the loop and a task anchor) share one entry. Returns the lines that tell the agent.
702
+ * completes in one bash call (it is both the loop and a task anchor) share one entry. No instruction owns such a slip, so
703
+ * the entry is reflected as `resolved` at once and the agent is told nothing.
668
704
  */
669
- function autoFriction(book: Book): string[] {
670
- const lines: string[] = [];
705
+ function autoFriction(book: Book): void {
671
706
  const open = new Map<string, string[]>();
672
707
  for (const pair of retryPairs(book.entries)) {
673
708
  const id = `${pair.key}#${pair.fail}`;
@@ -688,24 +723,23 @@ export default function (pi: ExtensionAPI) {
688
723
  const found = clip(String(failed.key ?? keyLine(String(failed.last ?? ""))) || "failed with no output");
689
724
  const number = frictionOf(book.entries).length + 1;
690
725
  book.entries.push(appendEntry(book.path, "friction", { id: number, tried, found, led_by: "none", auto: true, pairs }));
691
- lines.push(`anchorloop: friction #${number} recorded from the ${tried}: ${found}. Classify it with \`reflect\` before close.`);
726
+ book.entries.push(appendEntry(book.path, "reflect", { friction: [String(number)], class: "resolved", action: "auto-recorded rerun" }));
692
727
  }
693
- return lines;
694
728
  }
695
729
 
696
- /** The footer line: the loop's kind, its last complete phase, the next one, and the friction still to reflect. */
730
+ /** The line below the editor: the loop's kind, its last complete phase, the next one, and the friction still to reflect; cleared when no loop is open. */
697
731
  function showStatus(ctx: ExtensionContext): void {
698
- if (typeof ctx.ui?.setStatus !== "function") return;
732
+ if (typeof ctx.ui?.setWidget !== "function") return;
699
733
  const book = openLoop(ctx);
700
734
  if (!book) {
701
- ctx.ui.setStatus(STATUS_KEY, undefined);
735
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
702
736
  return;
703
737
  }
704
738
  const phases = phasesOf(book, ctx);
705
739
  const open = allFriction(book.entries).filter((item) => !item.reflected).length;
706
740
  const kind = phases.kind === "undeclared" ? "no kind" : phases.kind;
707
741
  const at = `${phases.current}${phases.current === "start" ? "" : " ✓"} → ${phases.next}`;
708
- ctx.ui.setStatus(STATUS_KEY, `anchorloop ${kind} · ${at}${open ? ` · ${open} friction to reflect` : ""}`);
742
+ ctx.ui.setWidget(WIDGET_KEY, [`⚓ ${kind} · ${at}${open ? ` · ${open} friction` : ""}`], { placement: "belowEditor" });
709
743
  }
710
744
 
711
745
  pi.on(
@@ -721,14 +755,10 @@ export default function (pi: ExtensionAPI) {
721
755
  if (event.toolName === "todo") {
722
756
  const input = event.input as { action?: unknown; id?: unknown };
723
757
  let open = openLoop(ctx);
724
- const opens = !open && input.action === "create";
725
- if (!open && opens) {
726
- open = createBook(ctx);
727
- recordPriorAnchor(open, ctx);
728
- }
758
+ if (!open && input.action === "create") open = openFromTodo(ctx, event.details);
729
759
  if (!open) return;
730
760
  recordTasks(open, event.details, input, ctx.cwd);
731
- return opens ? { content: [...event.content, { type: "text" as const, text: OPEN_LINE }] } : undefined;
761
+ return;
732
762
  }
733
763
  const book = openLoop(ctx);
734
764
  if (!book) return;
@@ -833,8 +863,12 @@ function headAndTail(text: string): string {
833
863
  const book = openLoop(ctx);
834
864
  if (!book) return;
835
865
  const stated = statedAnchor(message);
836
- if (stated && !book.entries.some((entry) => entry.stage === "anchor" && entry.check === stated)) appendEntry(book.path, "anchor", { check: stated, line: true });
866
+ if (stated && !book.entries.some((entry) => entry.stage === "anchor" && entry.check === stated)) {
867
+ appendEntry(book.path, "anchor", { check: stated, line: true });
868
+ showStatus(ctx);
869
+ }
837
870
  if (message.role !== "custom" || message.customType !== "subagent_result" || subagent) return;
871
+ adoptChildRun(book, ctx.cwd);
838
872
  const agent = String(message.details?.agent ?? "");
839
873
  if (REVIEWERS.has(agent)) {
840
874
  const d = message.details ?? {};
@@ -55,18 +55,16 @@ export default function (pi: ExtensionAPI) {
55
55
  pi.registerTool({
56
56
  name: "anchorloop_decision",
57
57
  label: "Decision trail",
58
- description:
59
- "Append one row to a decision trail TSV (scratch/decisions-<task-slug>.tsv): a fork chosen, a unit completed, a pivot, or a blocker. " +
60
- "Pass `start` with your run id instead of the row fields when picking up a trail another run wrote.",
58
+ description: "Append one row to scratch/decisions-<slug>.tsv for a fork, pivot, or blocker (git log holds completed units). `start` = your run id when picking up a trail.",
61
59
  parameters: {
62
60
  type: "object",
63
61
  properties: {
64
- log: { type: "string", description: "Trail path, relative to the working directory" },
65
- start: { type: "string", description: "Run id; writes a start row and ignores the row fields" },
66
- decision: { type: "string", description: "What was chosen or done, one line" },
67
- why: { type: "string", description: "The reason in plain words" },
68
- evidence: { type: "string", description: "A pointer: commit, file:line, or artifact path" },
69
- result: { type: "string", description: "Outcome or predicate state" },
62
+ log: { type: "string" },
63
+ start: { type: "string" },
64
+ decision: { type: "string" },
65
+ why: { type: "string" },
66
+ evidence: { type: "string", description: "commit, file:line, or path" },
67
+ result: { type: "string" },
70
68
  },
71
69
  required: ["log"],
72
70
  additionalProperties: false,
@@ -4,8 +4,13 @@
4
4
  * summary, phase (current, next, missing), verify state, open todos, tasks, and friction, and judges only
5
5
  * whether the current phase's completion condition truly holds and whether to advance, go back, or ask
6
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
7
+ * `close` declaration. The advisory is `next: <phase> (<missing>)` plus Jev's concerns, and is sent only when it
8
+ * changes the agent's next action: Jev has a concern, todos are still open without a followup, or the run
9
+ * continues. It continues the run once when Jev agrees to advance and the next phase is a verify, review, or
10
+ * fix, or when todos are still open without a followup (unless Jev says to ask the user). Away from close the
11
+ * advisory names up to three of those todos and asks the agent to continue, complete, or settle each with
12
+ * `metadata.followup`. An advisory with the same next phase, concerns, and open todo ids is sent once. At
13
+ * close only concerns are sent. A run that arranged a wake
9
14
  * (a monitor, a loop, a subagent) is waiting, not finished, and is not judged. Outside a loop nothing
10
15
  * runs, nor in a `quick` loop, whose only prompt is the close reminder; a subagent session registers
11
16
  * nothing, since the agent that spawned it judges its result.
@@ -14,7 +19,7 @@
14
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
20
  import { currentKind, guarded, openLoop } from "./support/book.ts";
16
21
  import { type Answers, classify } from "./support/jev.ts";
17
- import { type Snapshot, snapshot } from "./support/snapshot.ts";
22
+ import { type Snapshot, listTodos, snapshot } from "./support/snapshot.ts";
18
23
  import { isSubagent } from "./support/subagent.ts";
19
24
 
20
25
  export { isSubagent, snapshot };
@@ -87,32 +92,52 @@ export function readVerdict(answers: Answers): Verdict | undefined {
87
92
  };
88
93
  }
89
94
 
90
- export function assess(verdict: Verdict, snap: Snapshot): { found: string[]; notes: string[] } {
95
+ export function assess(verdict: Verdict, snap: Snapshot): { found: string[] } {
91
96
  const found: string[] = [];
92
- const notes: string[] = [];
93
97
  if (verdict.done < THRESHOLDS.doneBelow) found.push("evidence not shown to cover the intent");
94
- if (verdict.review === THRESHOLDS.reviewTier) {
95
- if (snap.reviewClear) notes.push("review rated full, but a review recorded after the last change found no blocking finding");
96
- else found.push("needs full independent review");
97
- }
98
+ // A review recorded after the last change with no blocking finding already covers the full-review rating.
99
+ if (verdict.review === THRESHOLDS.reviewTier && !snap.reviewClear) found.push("needs full independent review");
98
100
  if (verdict.quality < THRESHOLDS.qualityBelow) found.push("result below the intent");
99
- return { found, notes };
101
+ return { found };
100
102
  }
101
103
 
102
- /** True when Jev agrees to advance and the next phase is one the agent can act on now. */
104
+ /** True when Jev agrees to advance and the next phase is one the agent can act on now, or open todos wait and the user is not needed. */
103
105
  export function continues(verdict: Verdict, snap: Snapshot): boolean {
106
+ if (snap.unsettled.length && verdict.move !== "ask-user") return true;
104
107
  return verdict.move === "advance" && ACTIONABLE.has(snap.next);
105
108
  }
106
109
 
107
- /** One line: the program's next phase and its gap, Jev's move and scores, and, away from close, the call to act. */
108
- export function message(verdict: Verdict, snap: Snapshot, found: string[], notes: string[], atClose = false): string {
109
- const extra = [...found, ...notes];
110
- return (
111
- `Second thought (Jev, advisory): next → ${snap.next}${snap.missing ? ` (missing: ${snap.missing})` : ""}; ` +
112
- `${verdict.move}; done ${verdict.done.toFixed(2)}, review ${verdict.review}, quality ${verdict.quality.toFixed(2)}/3` +
113
- `${extra.length ? `; ${extra.join("; ")}` : ""}.` +
114
- (!atClose && continues(verdict, snap) ? ` Do ${snap.next} now, or say why it does not apply.` : "")
115
- );
110
+ /** What Jev's move adds to the program's reading when it is not "advance". */
111
+ const MOVE_CONCERN: Record<Move, string | undefined> = { advance: undefined, back: "earlier evidence does not hold", "ask-user": "needs the user's decision" };
112
+
113
+ /** Jev's concerns: a move against the program's reading, then the assessment findings. */
114
+ export function concerns(verdict: Verdict, found: string[]): string[] {
115
+ const move = MOVE_CONCERN[verdict.move];
116
+ return move ? [move, ...found] : found;
117
+ }
118
+
119
+ /** The open todos the agent must answer, or the call to put them to the user. */
120
+ function todoCall(verdict: Verdict, snap: Snapshot): string {
121
+ const list = `open: ${listTodos(snap.unsettled)}`;
122
+ return verdict.move === "ask-user"
123
+ ? `${list} — ask the user about them with ask_user_question before ending the turn`
124
+ : `${list} — continue each, complete it, or set metadata.followup (a ticket id, \`memory\`, or \`drop — <reason>\`)`;
125
+ }
126
+
127
+ /** The advisory: the program's next phase and its gap, Jev's concerns, and, away from close, the open todos. Empty at close without concerns. */
128
+ export function message(verdict: Verdict, snap: Snapshot, found: string[], atClose = false): string {
129
+ const list = concerns(verdict, found);
130
+ if (atClose) return list.length ? `Jev: ${list.join("; ")}.` : "";
131
+ return [
132
+ `next: ${snap.next}${snap.missing ? ` (${snap.missing})` : ""}`,
133
+ ...(list.length ? [`concerns: ${list.join("; ")}`] : []),
134
+ ...(snap.unsettled.length ? [todoCall(verdict, snap)] : []),
135
+ ].join("\n");
136
+ }
137
+
138
+ /** What makes two advisories the same: the next phase, the concerns, and the open todo ids; the wording of the gap may change. */
139
+ function adviceKey(verdict: Verdict, snap: Snapshot, found: string[]): string {
140
+ return JSON.stringify([snap.next, concerns(verdict, found), snap.unsettled.map((task) => task.id)]);
116
141
  }
117
142
 
118
143
  export default function (pi: ExtensionAPI) {
@@ -163,9 +188,10 @@ export default function (pi: ExtensionAPI) {
163
188
  if (!snap) return;
164
189
  const judgment = await judge(ctx, snap);
165
190
  if (!judgment?.verdict) return;
166
- const { found, notes } = assess(judgment.verdict, snap);
191
+ const { found } = assess(judgment.verdict, snap);
167
192
  pi.appendEntry(ENTRY_TYPE, { ...judgment.verdict, at: "close", concerns: found });
168
- return { content: [...event.content, { type: "text" as const, text: `\n${message(judgment.verdict, snap, found, notes, true)}` }] };
193
+ const text = message(judgment.verdict, snap, found, true);
194
+ return text ? { content: [...event.content, { type: "text" as const, text: `\n${text}` }] } : undefined;
169
195
  }),
170
196
  );
171
197
 
@@ -186,14 +212,17 @@ export default function (pi: ExtensionAPI) {
186
212
  // A later handler's boundary result replaces the entries an earlier one returned, so carry them along.
187
213
  const earlier = event.entries ?? [];
188
214
  if (!judgment.verdict) return { entries: [...earlier, record({ verdict: "unknown", reason: judgment.reason })] };
189
- const { found, notes } = assess(judgment.verdict, snap);
190
- const text = message(judgment.verdict, snap, found, notes);
191
- if (told.get(id) === text) return;
192
- told.set(id, text);
215
+ const { found } = assess(judgment.verdict, snap);
216
+ const verdictEntry = record({ ...judgment.verdict, concerns: found });
193
217
  const proceed = !continued && continues(judgment.verdict, snap);
218
+ // Jev agrees and nothing needs the agent: the verdict is recorded and nothing is said.
219
+ const speaks = proceed || snap.unsettled.length > 0 || concerns(judgment.verdict, found).length > 0;
220
+ const key = adviceKey(judgment.verdict, snap, found);
221
+ if (!speaks || told.get(id) === key) return { entries: [...earlier, verdictEntry] };
222
+ told.set(id, key);
194
223
  if (proceed) continued = true;
195
224
  return {
196
- entries: [...earlier, record({ ...judgment.verdict, concerns: found }), { type: "custom_message" as const, customType: ENTRY_TYPE, content: text, display: true }],
225
+ entries: [...earlier, verdictEntry, { type: "custom_message" as const, customType: ENTRY_TYPE, content: message(judgment.verdict, snap, found), display: true }],
197
226
  ...(proceed ? { continue: true } : {}),
198
227
  };
199
228
  }),