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/README.md +6 -6
- package/README.zh-TW.md +6 -6
- package/hooks/validate_tool_use.py +1 -1
- package/hooks/validators/constants.py +1 -0
- package/hooks/validators/durable_validator.py +4 -17
- package/hooks/validators/rule_validator.py +3 -5
- package/hooks/validators/skill_validator.py +7 -14
- package/package.json +1 -1
- package/pi/anchor-gate.ts +15 -2
- package/pi/book.ts +115 -81
- package/pi/decision-tool.ts +7 -9
- package/pi/second-thought.ts +56 -27
- package/pi/support/book.ts +75 -4
- package/pi/support/phases.ts +7 -12
- package/pi/support/snapshot.ts +22 -2
- package/pi/support/validators.ts +13 -44
- package/pi/ttsr.ts +7 -30
- package/pi/validate-tool-use.ts +1 -1
- package/skills/anchorloop/SKILL.md +6 -6
- package/skills/anchorloop/playbooks/autonomous-run.md +1 -1
- package/skills/coding/SKILL.md +1 -1
package/pi/book.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi extension: the loop book. The first `todo` create
|
|
3
|
-
*
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
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}
|
|
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
|
-
"
|
|
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]
|
|
482
|
-
intent: { type: "string"
|
|
483
|
-
anchor: { type: "string", description: "
|
|
484
|
-
note: { type: "string"
|
|
485
|
-
result: { type: "string", enum: ["pass", "fail"]
|
|
486
|
-
tried: { type: "string"
|
|
487
|
-
found: { type: "string"
|
|
488
|
-
led_by: { type: "string", description: "
|
|
489
|
-
friction: { type: "array", items: { type: ["string", "number"] }, description: "
|
|
490
|
-
class: { type: "string", enum: ["misdirection", "gap", "resolved"]
|
|
491
|
-
action: { type: "string"
|
|
492
|
-
phase: { type: "string"
|
|
493
|
-
reason: { type: "string"
|
|
494
|
-
verdict: { type: "string", enum: ["landed", "blocked", "abandoned"]
|
|
495
|
-
view: { type: "string", enum: ["summary", "all"]
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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}
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
683
|
+
autoFriction(book);
|
|
652
684
|
const merged = mergeRun(command);
|
|
653
|
-
if (exit === 0 && merged && !/Already up[ -]to[ -]date/i.test(last ?? "")
|
|
654
|
-
|
|
655
|
-
|
|
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.
|
|
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):
|
|
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
|
-
|
|
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
|
|
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?.
|
|
732
|
+
if (typeof ctx.ui?.setWidget !== "function") return;
|
|
699
733
|
const book = openLoop(ctx);
|
|
700
734
|
if (!book) {
|
|
701
|
-
ctx.ui.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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))
|
|
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 ?? {};
|
package/pi/decision-tool.ts
CHANGED
|
@@ -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"
|
|
65
|
-
start: { type: "string"
|
|
66
|
-
decision: { type: "string"
|
|
67
|
-
why: { type: "string"
|
|
68
|
-
evidence: { type: "string", description: "
|
|
69
|
-
result: { type: "string"
|
|
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,
|
package/pi/second-thought.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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[]
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
191
|
+
const { found } = assess(judgment.verdict, snap);
|
|
167
192
|
pi.appendEntry(ENTRY_TYPE, { ...judgment.verdict, at: "close", concerns: found });
|
|
168
|
-
|
|
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
|
|
190
|
-
const
|
|
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,
|
|
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
|
}),
|