pi-anchorloop 2.2.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/package.json +1 -1
- package/pi/book.ts +81 -27
- package/pi/second-thought.ts +10 -72
- package/pi/support/book.ts +133 -40
- package/pi/support/phases.ts +21 -10
- package/pi/support/snapshot.ts +71 -0
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ shared grilling · domain-modeling · human-feedback · agent-terse
|
|
|
25
25
|
|
|
26
26
|
| File | What it does |
|
|
27
27
|
| --- | --- |
|
|
28
|
-
| `pi/book.ts` | The loop book. The first `todo` create opens a loop
|
|
28
|
+
| `pi/book.ts` | The loop book. The first `todo` create or `kind` call opens a loop; task records need the `todo` tool of [`@juicesharp/rpiv-todo`](https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-todo#readme) (its `tool_result` details `{tasks, nextId}` and `rpiv-todo-state` entries). The agent declares only what the extension cannot observe with `anchorloop_stage`: `kind` (`quick`, `inline`, `reviewed`, `durable`, with an intent and the anchor command), `verify` (a check not run in bash), `fresh-read`, `friction` (`tried`, `found`, `led_by`), `reflect`, `skip`, `back`, `close`, and the query `read`. Everything else is read from evidence: production edits (a write or edit in any git repo, outside its `scratch/` and `docs/specs/`; a linked child's edits and a successful `git merge`, `cherry-pick`, or `pull` count too), the exit code of the bash call that ran the anchor command (failed-then-passed runs become retry pairs), reviewer `Blocking: P0=<n> P1=<m>` counts, summaries of `docs/specs/*/` artifacts as they are edited, and a returned subagent that Jev judges to have been a fresh-context read of the spec. A phase machine over that evidence gives each kind a fixed sequence (`quick` act → check; `inline` anchor → act → verify; `reviewed` adds review; `durable` adds decide → grill → spec → read → approve → design; every kind ends with reflect → close), and `read` shows `<kind> · phase <current> ✓ → <next> (missing: …) · skipped: …`. A phase that starts before an earlier one is complete warns once in one line and names the `skip` escape; phases regress when evidence changes or on `back`. The book also records each todo with its `anchor`, `ref` (`<path>#<heading>`, resolved to line ranges), and `followup` metadata. One YAML book per open loop lives under `~/.pi/agent/anchorloop/books/` (`ANCHORLOOP_BOOKS_DIR` overrides it; empty project and `agents/` directories are removed), subagent books under `agents/` (a child that closes keeps its book for the main session); `session_start` sweeps books whose session is gone or that have not changed in 7 days. `close` lists every phase as done, skipped, or auto-skipped and never refuses over phases or friction, only while a pending or in-progress todo lacks `metadata.followup` (a ticket id, `memory`, or `drop — <reason>`). |
|
|
29
29
|
| `pi/anchor-gate.ts` | Inside an open loop, shows one warning line when `write` or `edit` starts the `act` phase while an earlier phase of the loop kind is incomplete: no anchor (`anchor` on the `kind` call, a `Reality anchor:` line, or a skip), and in a `durable` loop a missing decision, spec, fresh read, approval, or design, read from the `docs/specs/<folder>/` files (found from the book, the intent, or a todo `ref`). It warns again only when a new phase goes missing; the call still goes through. Outside a loop and in `quick` loops it stays silent. `.md`, `.txt`, and `.html` files, `scratch/`, `docs/specs/`, and temp files outside the project pass silently. Edits made through `bash` are not checked. |
|
|
30
30
|
| `pi/decision-tool.ts` | The `anchorloop_decision` tool appends single-line, spreadsheet-safe rows to a decision trail TSV, or a `start` row when a run picks up another run's trail. |
|
|
31
31
|
| `pi/second-thought.ts` | Inside an open loop, asks Jev (TypeSafe's typed-decision model, through pi's classifier registry) to check the phase machine when a run settles and the loop book gained an entry since the last judgment, and at a `close` declaration. The program computes the next phase from the book; Jev sees the kind, intent, anchor, diff summary (≤1200 chars), `phase` (current, next, missing), verify state, open todos, the task list, and friction (a child's under its name), and judges only whether the current phase's completion truly holds (`complete`) and whether to `advance`, go `back`, or `ask-user`, plus `done`, the review tier still needed, and quality. The one-line advisory leads with `next → <phase>`; a retry pair with no friction entry adds a `friction` hint. The run continues once, only when Jev agrees to advance and the next phase is a verify, review, or fix; an advisory identical to the last one is not repeated, and a `full` rating after a clean review that follows the last change shows a note instead of a recheck. A run that called `MonitorCreate`, `LoopCreate`, `goal_wait`, or a `subagent` tool is waiting and is not judged. Without a TypeSafe key it does nothing, and outside a loop it never runs. In a subagent session (`PI_SUBAGENT_ID` from pi-herdr-agents, or `PI_SUBAGENT_CHILD` from pi-subagents) it registers nothing, since the agent that spawned it judges the result. |
|
package/README.zh-TW.md
CHANGED
|
@@ -25,7 +25,7 @@ anchorloop 核心循環 · 原則 · playbook:autonomous-run、paus
|
|
|
25
25
|
|
|
26
26
|
| 檔案 | 作用 |
|
|
27
27
|
| --- | --- |
|
|
28
|
-
| `pi/book.ts` | Loop book。第一次 `todo` create
|
|
28
|
+
| `pi/book.ts` | Loop book。第一次 `todo` create 或 `kind` 呼叫會開啟一個 loop;任務紀錄需要 [`@juicesharp/rpiv-todo`](https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-todo#readme) 的 `todo` 工具(它的 `tool_result` details `{tasks, nextId}` 和 `rpiv-todo-state` 條目)。agent 只用 `anchorloop_stage` 宣告 extension 看不到的事:`kind`(`quick`、`inline`、`reviewed`、`durable`,附 intent 和 anchor 指令)、`verify`(不是指令的檢查)、`fresh-read`、`friction`(`tried`、`found`、`led_by`)、`reflect`、`skip`、`back`、`close`,以及查詢用的 `read`。其餘都從證據讀出:production 的修改(任何 git repo 內、`scratch/` 與 `docs/specs/` 以外的 write 或 edit;已連結 child 的修改,以及成功的 `git merge`、`cherry-pick`、`pull` 也算)、執行過 anchor 指令的那次 bash 的 exit code(失敗後又通過的執行會成為 retry pair)、reviewer 的 `Blocking: P0=<n> P1=<m>` 數字、`docs/specs/*/` 產出物每次編輯後的摘要,以及 Jev 判定為「全新 context 讀過 spec」的 subagent 回報。階段機依這些證據給每種 loop 固定的階段順序(`quick` 為 act → check;`inline` 為 anchor → act → verify;`reviewed` 加 review;`durable` 再加 decide → grill → spec → read → approve → design;每一種都以 reflect → close 結尾),`read` 顯示 `<種類> · phase <目前> ✓ → <下一個>(缺:…)· 已跳過:…`。前一個階段還沒完成就開始下一個階段,會用一行警告一次並指出 `skip` 的出口;證據改變或用 `back` 時階段會退回。book 也記錄每個 todo 的 `anchor`、`ref`(`<path>#<heading>`,解析成行號範圍)和 `followup` metadata。每個開啟中的 loop 有一份 YAML book,放在 `~/.pi/agent/anchorloop/books/`(可用 `ANCHORLOOP_BOOKS_DIR` 覆寫),subagent 的 book 放在 `agents/`(subagent 自己 close 時 book 留給主 session);`session_start` 會清掉 session 已不存在或 7 天沒有更新的 book。`close` 不會因為階段或 friction 而拒絕,只在還有 pending 或 in-progress 的 todo 沒有 `metadata.followup`(票號、`memory` 或 `drop — <原因>`)時拒絕。 |
|
|
29
29
|
| `pi/anchor-gate.ts` | 只在開啟的 loop 內:`write` 或 `edit` 開始 `act` 階段、而這種 loop 前面的階段還沒完成時,顯示一行警告:沒有 anchor(kind 呼叫的 `anchor`、`Reality anchor:` 這一行,或已跳過),`durable` loop 則還缺決策、spec、全新 context 的閱讀、核准或設計(從 `docs/specs/<資料夾>/` 的檔案判斷,資料夾由 book、intent 或 todo 的 `ref` 找出)。只有新缺了一個階段時才會再警告;呼叫仍會放行。沒有 loop 或是 `quick` loop 時保持安靜。`.md`、`.txt`、`.html` 檔、`scratch/`、`docs/specs/` 和專案外的暫存檔不會觸發。透過 `bash` 做的修改不檢查。 |
|
|
30
30
|
| `pi/decision-tool.ts` | `anchorloop_decision` 工具把一筆決策寫進 TSV 決策軌跡,每筆只佔一行,用試算表開啟也安全;接手別人的軌跡時寫一筆 `start`。 |
|
|
31
31
|
| `pi/second-thought.ts` | 只在開啟的 loop 內:一次執行結束且 book 自上次判斷後多了條目時,以及宣告 `close` 時,透過 pi 的 classifier registry 請 Jev(TypeSafe 的型別化決策模型)複核階段機。下一個階段由程式從 book 算出;Jev 看到種類、intent、anchor、diff 摘要(≤1200 字元)、`phase`(目前、下一個、缺什麼)、verify 狀態、未完成 todo、任務清單和 friction(子 agent 的以它的名字列出),只判斷目前階段的完成條件是否真的達到(`complete`),以及要 `advance`、退回(`back`)還是 `ask-user`,另外還問 `done`、還需要哪一級 review 與品質。一行提醒以 `next → <階段>` 開頭;有 retry pair 卻沒有 friction 條目時,附上 `friction` 提示。只有 Jev 同意前進、而下一個階段是 verify、review 或 fix 時,才讓 agent 多跑一輪;與上一則相同的提醒不重複,最後一次修改之後已有無 blocking 發現的 review 時,`full` 評等只顯示備註、不要求複查。呼叫過 `MonitorCreate`、`LoopCreate`、`goal_wait` 或 `subagent` 工具的執行在等待中,不被判斷。沒有 TypeSafe key 時什麼都不做,沒有 loop 時不會執行。subagent session(pi-herdr-agents 的 `PI_SUBAGENT_ID`,或 pi-subagents 的 `PI_SUBAGENT_CHILD`)不註冊任何東西,因為由派它的 agent 判斷結果。 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anchorloop",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "Agent workflow skills for pi: one entry skill per task type, each with playbooks, over a shared Align → Anchor → Act → Verify loop, plus loop extensions (loop book, anchor gate, decision trail tool, Jev second thought, skill validator, stream rules).",
|
|
5
5
|
"author": "Wei Hung",
|
|
6
6
|
"license": "MIT",
|
package/pi/book.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi extension: the loop book. The first `todo` create opens a loop and its book. The agent declares only
|
|
2
|
+
* pi extension: the loop book. The first `todo` create or `kind` declaration opens a loop and its book. The agent declares only
|
|
3
3
|
* what the program cannot observe (`kind` with its intent and anchor command, a non-command `verify`,
|
|
4
4
|
* `fresh-read`, `friction`, `reflect`, `skip`, `back`, `close`); the extension derives the rest from
|
|
5
5
|
* evidence: production edits, the exit code of the anchor command, reviewer results, durable artifacts,
|
|
@@ -48,7 +48,8 @@ import {
|
|
|
48
48
|
verifyAfterLastAct,
|
|
49
49
|
} from "./support/book.ts";
|
|
50
50
|
import { classify } from "./support/jev.ts";
|
|
51
|
-
import {
|
|
51
|
+
import { SEQUENCES, computePhases, describe, incompleteBefore, outsideWork } from "./support/phases.ts";
|
|
52
|
+
import { type Snapshot, snapshot } from "./support/snapshot.ts";
|
|
52
53
|
import { isSubagent } from "./support/subagent.ts";
|
|
53
54
|
|
|
54
55
|
export const SESSION_ENTRY = "anchorloop-loop";
|
|
@@ -113,6 +114,30 @@ export function bashExit(event: { isError?: boolean; structuredContent?: unknown
|
|
|
113
114
|
return named ? Number(named[1]) : null;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The exit to record for a bash call that ran `anchor`: the call's exit code, except `unknown` when something after the anchor
|
|
119
|
+
* can replace it: `||`, `;`, a newline, or a pipe straight after the anchor unless the command starts with `set -o pipefail`.
|
|
120
|
+
* A chain `anchor && other` keeps a failed anchor's code, so it stands.
|
|
121
|
+
*/
|
|
122
|
+
export function anchorExit(command: string, anchor: string, exit: number | null): number | "unknown" {
|
|
123
|
+
if (exit === null) return "unknown";
|
|
124
|
+
const at = command.indexOf(anchor);
|
|
125
|
+
// `! anchor` inverts the code and `x || anchor` may never run it.
|
|
126
|
+
if (at < 0 || /(!|\|\|)\s*$/.test(command.slice(0, at))) return "unknown";
|
|
127
|
+
const rest = command.slice(at + anchor.length);
|
|
128
|
+
const pipefail = /^\s*set\s+-o\s+pipefail\b/.test(command);
|
|
129
|
+
let chained = false;
|
|
130
|
+
// `&&` keeps a failed anchor's exit; `||`, `;`, and a newline can replace it, and so can a pipe
|
|
131
|
+
// straight after the anchor unless pipefail is set.
|
|
132
|
+
for (const [separator] of rest.matchAll(/&&|\|\||\||(?<![<>&])&(?![&>])|;|\n/g)) {
|
|
133
|
+
if (separator === "&&") chained = true;
|
|
134
|
+
else if (separator === "|") {
|
|
135
|
+
if (!chained && !pipefail) return "unknown";
|
|
136
|
+
} else return "unknown";
|
|
137
|
+
}
|
|
138
|
+
return exit;
|
|
139
|
+
}
|
|
140
|
+
|
|
116
141
|
/** The text a `Reality anchor:` line states in an assistant message, if any. */
|
|
117
142
|
function statedAnchor(message: { role?: string; content?: unknown } | undefined): string | undefined {
|
|
118
143
|
if (message?.role !== "assistant") return undefined;
|
|
@@ -124,8 +149,6 @@ const reply = (text: string) => ({ content: [{ type: "text" as const, text }], d
|
|
|
124
149
|
|
|
125
150
|
export default function (pi: ExtensionAPI) {
|
|
126
151
|
const subagent = isSubagent();
|
|
127
|
-
const jumps = new JumpMemory();
|
|
128
|
-
|
|
129
152
|
function todosOf(ctx: ExtensionContext) {
|
|
130
153
|
return replayTodos(ctx.sessionManager.getBranch());
|
|
131
154
|
}
|
|
@@ -134,6 +157,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
134
157
|
return computePhases(book, { cwd: ctx.cwd, todos: todosOf(ctx) });
|
|
135
158
|
}
|
|
136
159
|
|
|
160
|
+
/** The line that tells the agent Jev did not accept a returned subagent as the fresh read, while the read is still open. */
|
|
161
|
+
function freshReadLine(book: Book, phases: ReturnType<typeof phasesOf>): string[] {
|
|
162
|
+
const last = [...book.entries].reverse().find((entry) => entry.stage === "goldfish");
|
|
163
|
+
const open = phases.statuses.find((status) => status.name === "read")?.state === "missing";
|
|
164
|
+
return open && last?.source === "jev" && last.ok === false ? [`fresh read by ${String(last.child || "a subagent")} judged not fresh — declare \`fresh-read\` if it was`] : [];
|
|
165
|
+
}
|
|
166
|
+
|
|
137
167
|
function summaryLines(book: Book, ctx: ExtensionContext): string[] {
|
|
138
168
|
const phases = phasesOf(book, ctx);
|
|
139
169
|
const verify = [...book.entries].reverse().find((entry) => entry.stage === "verify" || entry.stage === "check");
|
|
@@ -147,6 +177,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
147
177
|
...childLines(book.entries).map((line) => `child ${line}`),
|
|
148
178
|
...friction.filter((item) => item.owner !== "main").map((item) => `friction ${item.ref}: ${item.tried} → ${item.found}${item.reflected ? ` [${item.class}]` : ""}`),
|
|
149
179
|
...phases.hints,
|
|
180
|
+
...freshReadLine(book, phases),
|
|
150
181
|
];
|
|
151
182
|
}
|
|
152
183
|
|
|
@@ -185,7 +216,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
216
|
}
|
|
186
217
|
}
|
|
187
218
|
|
|
188
|
-
function close(book: Book, ctx: ExtensionContext, verdict: string): string {
|
|
219
|
+
function close(book: Book, ctx: ExtensionContext, verdict: string): { text: string; snapshot?: Snapshot } {
|
|
189
220
|
const todos = replayTodos(ctx.sessionManager.getBranch());
|
|
190
221
|
const undisposed = todos.filter((task) => isOpenTask(task) && !validFollowup(task.metadata?.followup));
|
|
191
222
|
if (undisposed.length > 0) {
|
|
@@ -198,21 +229,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
198
229
|
const kind = currentKind(book.entries);
|
|
199
230
|
const phases = phasesOf(book, ctx);
|
|
200
231
|
const missing = incompleteBefore(phases, "close");
|
|
201
|
-
const
|
|
202
|
-
const fresh = jumps.fresh(bookId, "close", missing.map((status) => status.name));
|
|
203
|
-
const warning =
|
|
204
|
-
verdict !== "landed" || !missing.length || !fresh
|
|
205
|
-
? undefined
|
|
206
|
-
: kind === "quick"
|
|
207
|
-
? `anchorloop: closing a quick loop with ${missing.map((status) => `${status.name} (${status.note})`).join("; ")} open.`
|
|
208
|
-
: jumpLine("close", missing);
|
|
232
|
+
const notice = verdict === "landed" && missing.length ? `anchorloop: closed with phases not shown complete: ${missing.map((status) => `${status.name} (${status.note})`).join("; ")}.` : undefined;
|
|
209
233
|
const diff = diffAgainstBases(book.entries);
|
|
210
234
|
const friction = allFriction(book.entries);
|
|
235
|
+
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 } : {}) }));
|
|
211
236
|
const summary = {
|
|
212
237
|
intent: currentIntent(book.entries) ?? null,
|
|
213
238
|
kind: kind ?? null,
|
|
214
239
|
verdict,
|
|
215
240
|
stages: stagesDone(book.entries),
|
|
241
|
+
phases: phaseList,
|
|
216
242
|
reviewRounds: reviews(book.entries).length,
|
|
217
243
|
reviewTrend: reviewTrend(book.entries),
|
|
218
244
|
diff: diff.summary,
|
|
@@ -225,20 +251,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
225
251
|
const tail = unreflected.length
|
|
226
252
|
? ` Unreflected friction for the Reflexive Pass: ${unreflected.map((item) => `${item.ref} (${item.found})`).join("; ")}.`
|
|
227
253
|
: "";
|
|
228
|
-
const
|
|
229
|
-
const
|
|
254
|
+
const shown = phaseList.map((item) => `${item.phase} ${item.state}${item.note ? ` (${item.note})` : ""}`).join(", ");
|
|
255
|
+
const back = summary.back.length ? `; back ${summary.back.join(", ")}` : "";
|
|
256
|
+
const text = `Loop closed (${verdict}): phases ${shown}; ${summary.reviewTrend}; diff ${diff.summary.split("\n").join("; ")}${back}.${tail}`;
|
|
257
|
+
const after = notice ? `\n${notice}` : "";
|
|
230
258
|
if (kept) {
|
|
231
259
|
// A child's book stays for the main session to read and delete.
|
|
232
260
|
appendEntry(book.path, "close", { verdict, summary: text.slice(0, 500) });
|
|
233
|
-
return `${text} The book stays for the main session.${
|
|
261
|
+
return { text: `${text} The book stays for the main session.${after}` };
|
|
234
262
|
}
|
|
263
|
+
// Jev judges the loop as it stood at close; the book is gone by the time the result is judged.
|
|
264
|
+
const snap = kind === "quick" ? undefined : snapshot(book, ctx);
|
|
235
265
|
pi.appendEntry(SESSION_ENTRY, summary);
|
|
236
266
|
for (const id of childIds(book.entries)) {
|
|
237
267
|
const child = findChildBook(id);
|
|
238
268
|
if (child) deleteBook(child.path);
|
|
239
269
|
}
|
|
240
270
|
deleteBook(book.path);
|
|
241
|
-
return `${text} The book is deleted. Clear the todo list now (todo action: clear).${
|
|
271
|
+
return { text: `${text} The book is deleted. Clear the todo list now (todo action: clear).${after}`, snapshot: snap };
|
|
242
272
|
}
|
|
243
273
|
|
|
244
274
|
pi.registerTool({
|
|
@@ -275,10 +305,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
275
305
|
},
|
|
276
306
|
async execute(_toolCallId: string, raw: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
277
307
|
const params = raw as Params;
|
|
278
|
-
const book = openLoop(ctx);
|
|
279
|
-
if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create.");
|
|
280
308
|
const { stage } = params;
|
|
281
309
|
if (!(STAGES as readonly string[]).includes(stage)) throw new Error(`unknown stage \`${String(stage)}\`; declare one of ${STAGES.join(", ")}`);
|
|
310
|
+
let book = openLoop(ctx);
|
|
311
|
+
if (!book && stage === "kind") {
|
|
312
|
+
// A todo create in the same batch may not have landed yet; whichever comes first opens the loop.
|
|
313
|
+
if (!params.kind || !params.intent) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
|
|
314
|
+
book = createBook(ctx);
|
|
315
|
+
recordPriorAnchor(book, ctx);
|
|
316
|
+
}
|
|
317
|
+
if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create or `kind`.");
|
|
282
318
|
const had = currentKind(book.entries);
|
|
283
319
|
const sequence = SEQUENCES[had ?? "inline"];
|
|
284
320
|
if (stage === "read") {
|
|
@@ -288,7 +324,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
288
324
|
}
|
|
289
325
|
if (stage === "close") {
|
|
290
326
|
if (!params.verdict) throw new Error("close needs a verdict: landed, blocked, or abandoned");
|
|
291
|
-
|
|
327
|
+
const closed = close(book, ctx, params.verdict);
|
|
328
|
+
return { content: [{ type: "text" as const, text: closed.text }], details: closed.snapshot ? { snapshot: closed.snapshot } : undefined };
|
|
292
329
|
}
|
|
293
330
|
if (stage === "kind") {
|
|
294
331
|
if (!params.kind && !params.intent && !params.anchor) throw new Error("kind needs `kind`, `intent`, or `anchor`");
|
|
@@ -373,11 +410,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
373
410
|
const anchor = anchorCommand(book.entries);
|
|
374
411
|
if (anchor && command.includes(anchor)) {
|
|
375
412
|
const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
|
|
376
|
-
|
|
377
|
-
book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: piped ? "unknown" : (exit ?? "unknown"), last }));
|
|
413
|
+
book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: anchorExit(command, anchor, exit), last }));
|
|
378
414
|
}
|
|
379
415
|
for (const task of latestTasks(book.entries)) {
|
|
380
|
-
if (typeof task.anchor === "string" && command.includes(task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit:
|
|
416
|
+
if (typeof task.anchor === "string" && command.includes(task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit: anchorExit(command, task.anchor, exit), last });
|
|
417
|
+
}
|
|
418
|
+
if (exit === 0 && /\bgit(\s+-C\s+\S+)?\s+(merge|cherry-pick|pull)(?=\s|$)(?![^|;&]*--(abort|quit)\b)/.test(command) && !/Already up to date/i.test(last ?? "") && book.entries.at(-1)?.stage !== "edit") {
|
|
419
|
+
book.entries.push(appendEntry(book.path, "edit", { via: "merge" }));
|
|
381
420
|
}
|
|
382
421
|
if (exit === 0 && /\bgit\b[^|;&]*\bcommit\b/.test(command) && !book.entries.some((entry) => entry.stage === "edit")) {
|
|
383
422
|
const cwd = String(book.header.cwd ?? "");
|
|
@@ -459,7 +498,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
459
498
|
if (reviewsStalled(book.entries)) ctx.ui.notify("anchorloop: two consecutive review rounds still raised blocking findings; stop and hand the trend to the user.", "warning");
|
|
460
499
|
}
|
|
461
500
|
|
|
462
|
-
|
|
501
|
+
const HOST_LINE = /^\s*(Parent action:|Requested model|Session:|Resume:)/i;
|
|
502
|
+
|
|
503
|
+
/** The text without the lines pi-herdr adds around a subagent brief or result. */
|
|
504
|
+
function strip(text: string): string {
|
|
505
|
+
return text
|
|
506
|
+
.split("\n")
|
|
507
|
+
.filter((line) => !HOST_LINE.test(line))
|
|
508
|
+
.join("\n")
|
|
509
|
+
.trim();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function headAndTail(text: string): string {
|
|
513
|
+
return text.length <= 2500 ? text : `${text.slice(0, 1000)}\n…\n${text.slice(-1500)}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Jev judges whether a returned subagent was a fresh-context read of the spec; the spec path in its brief is only a pre-filter. */
|
|
463
517
|
async function detectFreshRead(ctx: ExtensionContext, book: Book, details: { name?: unknown; task?: unknown; resultContent?: unknown }): Promise<void> {
|
|
464
518
|
const phases = phasesOf(book, ctx);
|
|
465
519
|
const read = phases.statuses.find((status) => status.name === "read");
|
|
@@ -467,11 +521,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
467
521
|
const folder = specFolder(book.entries, todosOf(ctx), ctx.cwd);
|
|
468
522
|
const brief = String(details.task ?? "");
|
|
469
523
|
if (!folder || !(brief.includes(`${folder}/spec.md`) || brief.includes(resolve(ctx.cwd, folder, "spec.md")))) return;
|
|
470
|
-
const { answers } = await classify(ctx, { brief: brief.slice(0, 1500), result: textOf(details.resultContent)
|
|
524
|
+
const { answers } = await classify(ctx, { brief: strip(brief).slice(0, 1500), result: headAndTail(strip(textOf(details.resultContent))) }, {
|
|
471
525
|
fresh_read: {
|
|
472
526
|
type: "bool",
|
|
473
|
-
instructions: "
|
|
474
|
-
criteria: { true: "the result restates the
|
|
527
|
+
instructions: "Did this subagent read the spec and report on it: does its `result` restate the spec, or list gaps, guesses, or questions about it? The `brief` only needs to have handed it the spec.",
|
|
528
|
+
criteria: { true: "the result restates the spec or lists gaps, guesses, or questions about it", false: "the subagent did other work and its result does not report on the spec" },
|
|
475
529
|
},
|
|
476
530
|
});
|
|
477
531
|
const probability = answers?.fresh_read?.type === "bool" ? answers.fresh_read.probability : undefined;
|
package/pi/second-thought.ts
CHANGED
|
@@ -12,31 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
16
|
-
type Book,
|
|
17
|
-
allFriction,
|
|
18
|
-
childLines,
|
|
19
|
-
currentIntent,
|
|
20
|
-
currentKind,
|
|
21
|
-
diffAgainstBases,
|
|
22
|
-
diffText,
|
|
23
|
-
guarded,
|
|
24
|
-
isOpenTask,
|
|
25
|
-
latestAnchor,
|
|
26
|
-
openLoop,
|
|
27
|
-
replayTodos,
|
|
28
|
-
reviewClearAfterLastAct,
|
|
29
|
-
reviewTrend,
|
|
30
|
-
stagesDone,
|
|
31
|
-
TASK_LEGEND,
|
|
32
|
-
taskLines,
|
|
33
|
-
verifyAfterLastAct,
|
|
34
|
-
} from "./support/book.ts";
|
|
15
|
+
import { currentKind, guarded, openLoop } from "./support/book.ts";
|
|
35
16
|
import { type Answers, classify } from "./support/jev.ts";
|
|
36
|
-
import {
|
|
17
|
+
import { type Snapshot, snapshot } from "./support/snapshot.ts";
|
|
37
18
|
import { isSubagent } from "./support/subagent.ts";
|
|
38
19
|
|
|
39
|
-
export { isSubagent };
|
|
20
|
+
export { isSubagent, snapshot };
|
|
21
|
+
export type { Snapshot };
|
|
40
22
|
|
|
41
23
|
export const ENTRY_TYPE = "anchorloop-second-thought";
|
|
42
24
|
const WAIT_TOOLS = new Set(["MonitorCreate", "LoopCreate", "goal_wait", "subagent", "subagent_resume", "subagent_send"]);
|
|
@@ -114,52 +96,6 @@ export function readVerdict(answers: Answers): Verdict | undefined {
|
|
|
114
96
|
};
|
|
115
97
|
}
|
|
116
98
|
|
|
117
|
-
export type Snapshot = {
|
|
118
|
-
state: Record<string, string>;
|
|
119
|
-
/** The program's next phase and what it lacks, in a few words. */
|
|
120
|
-
next: string;
|
|
121
|
-
missing: string;
|
|
122
|
-
/** Advisory hints that are not phases, such as an unrecorded friction. */
|
|
123
|
-
hints: string[];
|
|
124
|
-
/** The last phase the book shows complete. */
|
|
125
|
-
current: string;
|
|
126
|
-
/** A review recorded after the last change with no blocking finding on it. */
|
|
127
|
-
reviewClear: boolean;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBranch(): unknown[] } }): Snapshot {
|
|
131
|
-
const { entries } = book;
|
|
132
|
-
const todos = replayTodos(ctx.sessionManager.getBranch());
|
|
133
|
-
const open = todos.filter(isOpenTask).length;
|
|
134
|
-
const phases = computePhases(book, { cwd: ctx.cwd, todos });
|
|
135
|
-
const check = verifyAfterLastAct(entries);
|
|
136
|
-
const verify = [
|
|
137
|
-
`stages: ${stagesDone(entries).join(", ") || "none"}`,
|
|
138
|
-
`last check: ${check ? `${check.declared ? `declared ${check.result}` : `exit ${check.exit ?? "unknown"}`}${check.last ? ` — ${check.last}` : ""}` : "none after the last change"}`,
|
|
139
|
-
`reviews: ${reviewTrend(entries)}`,
|
|
140
|
-
...childLines(entries).map((line) => `child ${line}`),
|
|
141
|
-
].join("; ");
|
|
142
|
-
const friction = allFriction(entries).map((item) => `${item.ref}: ${item.tried} → ${item.found} [${item.reflected ? item.class : "unreflected"}]`);
|
|
143
|
-
return {
|
|
144
|
-
state: {
|
|
145
|
-
kind: currentKind(entries) ?? "not declared",
|
|
146
|
-
intent: (currentIntent(entries) ?? "not declared").slice(0, 500),
|
|
147
|
-
anchor: (latestAnchor(entries) ?? "none").slice(0, 500),
|
|
148
|
-
diff: diffText(diffAgainstBases(entries)),
|
|
149
|
-
phase: `current: ${phases.current}; next: ${phases.next}${phases.missing ? `; missing: ${phases.missing}` : ""}${phases.skipped.length ? `; skipped: ${phases.skipped.join(", ")}` : ""}`.slice(0, 600),
|
|
150
|
-
verify: verify.slice(0, 1200),
|
|
151
|
-
todos: `${open} open of ${todos.length}`,
|
|
152
|
-
tasks: [`legend: ${TASK_LEGEND}`, ...taskLines(entries, todos, ctx.cwd)].join("\n").slice(0, 1500),
|
|
153
|
-
friction: (friction.join("\n") || "none").slice(0, 800),
|
|
154
|
-
},
|
|
155
|
-
next: phases.next,
|
|
156
|
-
missing: phases.missing,
|
|
157
|
-
hints: phases.hints,
|
|
158
|
-
current: phases.current,
|
|
159
|
-
reviewClear: reviewClearAfterLastAct(entries),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
99
|
export function assess(verdict: Verdict, snap: Snapshot): { found: string[]; notes: string[] } {
|
|
164
100
|
const found: string[] = [];
|
|
165
101
|
const notes: string[] = [];
|
|
@@ -229,15 +165,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
229
165
|
pi.on(
|
|
230
166
|
"tool_result",
|
|
231
167
|
guarded(async (event, ctx) => {
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
234
|
-
closing.delete(event.toolCallId);
|
|
168
|
+
const called = closing.get(event.toolCallId);
|
|
169
|
+
if (called) closing.delete(event.toolCallId);
|
|
235
170
|
if (event.isError) return;
|
|
171
|
+
// The close tool reports the loop as it stood when it closed, after any reflect that ran beside it.
|
|
172
|
+
const snap = (event.details as { snapshot?: Snapshot } | undefined)?.snapshot ?? called;
|
|
173
|
+
if (!snap) return;
|
|
236
174
|
const judgment = await judge(ctx, snap);
|
|
237
175
|
if (!judgment?.verdict) return;
|
|
238
176
|
const { found, notes } = assess(judgment.verdict, snap);
|
|
239
177
|
pi.appendEntry(ENTRY_TYPE, { ...judgment.verdict, at: "close", concerns: found });
|
|
240
|
-
return { content: [...event.content, { type: "text" as const, text: message(judgment.verdict, snap, found, notes, true) }] };
|
|
178
|
+
return { content: [...event.content, { type: "text" as const, text: `\n${message(judgment.verdict, snap, found, notes, true)}` }] };
|
|
241
179
|
}),
|
|
242
180
|
);
|
|
243
181
|
|
package/pi/support/book.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
|
-
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
10
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { basename, dirname, join, resolve } from "node:path";
|
|
13
13
|
|
|
@@ -120,8 +120,19 @@ export function openLoop(who: Who, env: NodeJS.ProcessEnv = process.env): Book |
|
|
|
120
120
|
return book && !isClosed(book) ? book : undefined;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
function openForAppend(path: string): number {
|
|
124
|
+
try {
|
|
125
|
+
return openSync(path, "a");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// Another session's sweep may remove the directory between mkdir and open; recreate it once.
|
|
128
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
130
|
+
return openSync(path, "a");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
function appendBytes(path: string, text: string): void {
|
|
124
|
-
const fd =
|
|
135
|
+
const fd = openForAppend(path);
|
|
125
136
|
try {
|
|
126
137
|
const bytes = Buffer.from(text);
|
|
127
138
|
const written = writeSync(fd, bytes);
|
|
@@ -133,8 +144,8 @@ function appendBytes(path: string, text: string): void {
|
|
|
133
144
|
|
|
134
145
|
export function createBook(who: Who, env: NodeJS.ProcessEnv = process.env): Book {
|
|
135
146
|
const path = bookPath(who, env);
|
|
136
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
137
147
|
if (existsSync(path)) deleteBook(path);
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
138
149
|
const child = childId(env);
|
|
139
150
|
const header = {
|
|
140
151
|
// Unique per loop: the file name follows the session, so a reopened loop must not reuse an old id.
|
|
@@ -166,12 +177,29 @@ export function findChildBook(id: string, env: NodeJS.ProcessEnv = process.env):
|
|
|
166
177
|
return undefined;
|
|
167
178
|
}
|
|
168
179
|
|
|
180
|
+
function removeIfEmpty(dir: string): boolean {
|
|
181
|
+
try {
|
|
182
|
+
rmdirSync(dir);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Removes the `agents/` and project directories a deleted book leaves empty. */
|
|
190
|
+
function tidy(dir: string): void {
|
|
191
|
+
if (!removeIfEmpty(dir)) return;
|
|
192
|
+
const parent = dirname(dir);
|
|
193
|
+
if (basename(dir) === "agents") removeIfEmpty(parent);
|
|
194
|
+
}
|
|
195
|
+
|
|
169
196
|
export function deleteBook(path: string): void {
|
|
170
197
|
try {
|
|
171
198
|
unlinkSync(path);
|
|
172
199
|
} catch {
|
|
173
200
|
// Already gone.
|
|
174
201
|
}
|
|
202
|
+
tidy(dirname(path));
|
|
175
203
|
}
|
|
176
204
|
|
|
177
205
|
export type Sweep = { deleted: string[]; open: string[] };
|
|
@@ -194,33 +222,47 @@ export function sweep(env: NodeJS.ProcessEnv = process.env, cwd?: string): Sweep
|
|
|
194
222
|
const slug = cwd ? projectSlug(cwd) : undefined;
|
|
195
223
|
const linked = new Set<string>();
|
|
196
224
|
const children: Book[] = [];
|
|
225
|
+
// Other sessions create and tidy books concurrently, so a directory or file can vanish mid-walk; skip it.
|
|
197
226
|
for (const project of readdirSync(root)) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
try {
|
|
228
|
+
const dir = join(root, project);
|
|
229
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
230
|
+
for (const file of readdirSync(dir)) {
|
|
231
|
+
if (!file.endsWith(".yml")) continue;
|
|
232
|
+
const book = parseBook(join(dir, file));
|
|
233
|
+
if (!book) continue;
|
|
234
|
+
if (sessionGone(book)) {
|
|
235
|
+
deleteBook(book.path);
|
|
236
|
+
result.deleted.push(book.path);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!slug || project === slug) result.open.push(`${book.path} (${currentKind(book.entries) ?? "no kind"}: ${currentIntent(book.entries) ?? "no intent"})`);
|
|
240
|
+
for (const entry of book.entries) if (entry.stage === "link" && typeof entry.child === "string") linked.add(safe(entry.child));
|
|
208
241
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const child = file.endsWith(".yml") ? parseBook(join(agents, file)) : undefined;
|
|
216
|
-
if (child) children.push(child);
|
|
242
|
+
const agents = join(dir, "agents");
|
|
243
|
+
if (existsSync(agents)) {
|
|
244
|
+
for (const file of readdirSync(agents)) {
|
|
245
|
+
const child = file.endsWith(".yml") ? parseBook(join(agents, file)) : undefined;
|
|
246
|
+
if (child) children.push(child);
|
|
247
|
+
}
|
|
217
248
|
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Vanished under a concurrent tidy.
|
|
218
251
|
}
|
|
219
252
|
}
|
|
220
253
|
for (const child of children) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
254
|
+
try {
|
|
255
|
+
if (linked.has(basename(child.path, ".yml")) || !sessionGone(child)) continue;
|
|
256
|
+
deleteBook(child.path);
|
|
257
|
+
result.deleted.push(child.path);
|
|
258
|
+
} catch {
|
|
259
|
+
// Vanished under a concurrent tidy.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const project of readdirSync(root)) {
|
|
263
|
+
const dir = join(root, project);
|
|
264
|
+
removeIfEmpty(join(dir, "agents"));
|
|
265
|
+
removeIfEmpty(dir);
|
|
224
266
|
}
|
|
225
267
|
return result;
|
|
226
268
|
}
|
|
@@ -255,6 +297,26 @@ export function lastIndex(entries: Entry[], ...stages: string[]): number {
|
|
|
255
297
|
return -1;
|
|
256
298
|
}
|
|
257
299
|
|
|
300
|
+
/**
|
|
301
|
+
* The book's entries with the change entries of its linked children merged in by time, each marked `child`, so a coordinator
|
|
302
|
+
* whose edits are all made by children still shows an act. Indexes into the result differ from indexes into `entries`.
|
|
303
|
+
*/
|
|
304
|
+
export function withChildActs(entries: Entry[], env: NodeJS.ProcessEnv = process.env): Entry[] {
|
|
305
|
+
const acts: Entry[] = [];
|
|
306
|
+
for (const id of childIds(entries)) {
|
|
307
|
+
const child = findChildBook(id, env);
|
|
308
|
+
if (child) acts.push(...child.entries.filter((entry) => entry.stage === "edit").map((entry) => ({ ...entry, child: id })));
|
|
309
|
+
}
|
|
310
|
+
if (acts.length === 0) return entries;
|
|
311
|
+
acts.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
312
|
+
const merged: Entry[] = [];
|
|
313
|
+
for (const entry of entries) {
|
|
314
|
+
while (acts.length && acts[0].ts < entry.ts) merged.push(acts.shift() as Entry);
|
|
315
|
+
merged.push(entry);
|
|
316
|
+
}
|
|
317
|
+
return [...merged, ...acts];
|
|
318
|
+
}
|
|
319
|
+
|
|
258
320
|
/** The index of the latest change entry (a production write or edit, a commit), or -1. */
|
|
259
321
|
export function lastActIndex(entries: Entry[]): number {
|
|
260
322
|
return lastIndex(entries, "edit");
|
|
@@ -281,7 +343,8 @@ export function reviewTrend(entries: Entry[]): string {
|
|
|
281
343
|
}
|
|
282
344
|
|
|
283
345
|
/** A review recorded after the last change with no blocking finding on it. */
|
|
284
|
-
export function reviewClearAfterLastAct(
|
|
346
|
+
export function reviewClearAfterLastAct(raw: Entry[]): boolean {
|
|
347
|
+
const entries = withChildActs(raw);
|
|
285
348
|
const review = lastIndex(entries, "review");
|
|
286
349
|
return review >= 0 && review > lastActIndex(entries) && entries[review].blocking === false;
|
|
287
350
|
}
|
|
@@ -302,7 +365,8 @@ function evidenceLine(entry: Entry): string {
|
|
|
302
365
|
}
|
|
303
366
|
|
|
304
367
|
/** The latest check or verify entry after the last change, or undefined when none follows it. */
|
|
305
|
-
export function verifyAfterLastAct(
|
|
368
|
+
export function verifyAfterLastAct(raw: Entry[]): Entry | undefined {
|
|
369
|
+
const entries = withChildActs(raw);
|
|
306
370
|
const index = lastIndex(entries, "check", "verify");
|
|
307
371
|
return index >= 0 && index > lastActIndex(entries) ? entries[index] : undefined;
|
|
308
372
|
}
|
|
@@ -369,18 +433,23 @@ export function allFriction(entries: Entry[], env: NodeJS.ProcessEnv = process.e
|
|
|
369
433
|
|
|
370
434
|
export type RetryPair = { key: string; fail: number; pass: number };
|
|
371
435
|
|
|
372
|
-
/**
|
|
436
|
+
/** Regressions: a run that failed after the same command had passed in this loop, and then passed again. A failure before any pass is red-first work, not a retry. */
|
|
373
437
|
export function retryPairs(entries: Entry[]): RetryPair[] {
|
|
374
438
|
const pairs: RetryPair[] = [];
|
|
439
|
+
const passed = new Set<string>();
|
|
375
440
|
const failed = new Map<string, number>();
|
|
376
441
|
entries.forEach((entry, index) => {
|
|
377
|
-
|
|
442
|
+
if (entry.declared === true) return;
|
|
443
|
+
const key = entry.stage === "taskrun" ? `task#${entry.id}` : entry.stage === "verify" || entry.stage === "check" ? `anchor:${String(entry.command ?? "")}` : undefined;
|
|
378
444
|
if (!key || typeof entry.exit !== "number") return;
|
|
379
445
|
if (entry.exit !== 0) {
|
|
380
|
-
if (!failed.has(key)) failed.set(key, index);
|
|
381
|
-
} else
|
|
382
|
-
|
|
383
|
-
|
|
446
|
+
if (passed.has(key) && !failed.has(key)) failed.set(key, index);
|
|
447
|
+
} else {
|
|
448
|
+
if (failed.has(key)) {
|
|
449
|
+
pairs.push({ key, fail: failed.get(key) as number, pass: index });
|
|
450
|
+
failed.delete(key);
|
|
451
|
+
}
|
|
452
|
+
passed.add(key);
|
|
384
453
|
}
|
|
385
454
|
});
|
|
386
455
|
return pairs;
|
|
@@ -456,6 +525,12 @@ export function repoOf(path: string): { repo: string; head: string } | undefined
|
|
|
456
525
|
return repo && head ? { repo: real(repo), head } : undefined;
|
|
457
526
|
}
|
|
458
527
|
|
|
528
|
+
/** The root of the git repo containing `path`, or undefined outside a repo. */
|
|
529
|
+
export function repoRootOf(path: string): string | undefined {
|
|
530
|
+
const repo = git(existingDir(dirname(resolve(path))), ["rev-parse", "--show-toplevel"]);
|
|
531
|
+
return repo ? real(repo) : undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
459
534
|
export function bases(entries: Entry[]): { repo: string; head: string }[] {
|
|
460
535
|
return entries.filter((entry) => entry.stage === "base").map((entry) => ({ repo: String(entry.repo), head: String(entry.head) }));
|
|
461
536
|
}
|
|
@@ -485,11 +560,11 @@ export function diffAgainstBases(entries: Entry[]): Diff {
|
|
|
485
560
|
lines.push(`${basename(repo)}: diff unavailable`);
|
|
486
561
|
continue;
|
|
487
562
|
}
|
|
488
|
-
lines.push(`${basename(repo)}: ${stat
|
|
563
|
+
if (stat) lines.push(`${basename(repo)}: ${stat}`);
|
|
489
564
|
files.push(...names.map((name) => `${basename(repo)}/${name}`), ...untracked.map((name) => `${basename(repo)}/${name}`));
|
|
490
565
|
paths.push(...[...names, ...untracked].map((name) => join(repo, name)));
|
|
491
566
|
}
|
|
492
|
-
return { summary: lines.join("\n") || "no repository", files, paths };
|
|
567
|
+
return { summary: lines.join("\n") || (bases(entries).length ? "no tracked changes" : "no repository"), files, paths };
|
|
493
568
|
}
|
|
494
569
|
|
|
495
570
|
export function diffText(diff: Diff, limit = 1200): string {
|
|
@@ -633,19 +708,33 @@ export function artifactOnDisk(folder: string, name: string, cwd: string): Entry
|
|
|
633
708
|
|
|
634
709
|
// --- todo links ----------------------------------------------------------------------------------
|
|
635
710
|
|
|
636
|
-
|
|
711
|
+
const MARKDOWN = /\.(md|markdown)$/i;
|
|
712
|
+
|
|
713
|
+
const isMarkdownRef = (ref: string): boolean => MARKDOWN.test(ref.split("#")[0]);
|
|
714
|
+
|
|
715
|
+
/** The `ref` metadata of a task as pointers: `<path>#<heading>` into markdown, or the path of any other file. */
|
|
637
716
|
export function refsOf(metadata: Record<string, unknown> | undefined): string[] {
|
|
638
717
|
const ref = metadata?.ref;
|
|
639
718
|
const list = Array.isArray(ref) ? ref : [ref];
|
|
640
|
-
return list.filter((item): item is string => typeof item === "string" && item.includes("#"));
|
|
719
|
+
return list.filter((item): item is string => typeof item === "string" && item.trim() !== "" && (item.includes("#") || !isMarkdownRef(item)));
|
|
641
720
|
}
|
|
642
721
|
|
|
643
|
-
/**
|
|
722
|
+
/**
|
|
723
|
+
* The line range of the section `<path>#<heading>` names, such as `L88-L101`, or `missing` when the file or heading is gone.
|
|
724
|
+
* A ref to a file that is not markdown has no headings to resolve and returns "" (shown as given). Relative paths resolve against
|
|
725
|
+
* the cwd, the loop's repos, and the folders of recorded artifacts.
|
|
726
|
+
*/
|
|
644
727
|
export function resolveRef(ref: string, cwd: string, entries: Entry[]): string {
|
|
728
|
+
if (!isMarkdownRef(ref)) return "";
|
|
645
729
|
const split = ref.indexOf("#");
|
|
730
|
+
if (split < 0) return "";
|
|
646
731
|
const path = ref.slice(0, split);
|
|
647
732
|
const heading = ref.slice(split + 1).trim();
|
|
648
|
-
const dirs = [
|
|
733
|
+
const dirs = [
|
|
734
|
+
cwd,
|
|
735
|
+
...bases(entries).map((base) => base.repo),
|
|
736
|
+
...new Set(entries.filter((entry) => typeof entry.path === "string").map((entry) => dirname(resolve(cwd, entry.path as string)))),
|
|
737
|
+
];
|
|
649
738
|
for (const dir of dirs) {
|
|
650
739
|
let lines: string[];
|
|
651
740
|
try {
|
|
@@ -692,7 +781,11 @@ export function taskLines(entries: Entry[], todos: Task[], cwd: string): string[
|
|
|
692
781
|
const task = live.get(entry.id as number);
|
|
693
782
|
const run = exits.get(entry.id as number);
|
|
694
783
|
const anchor = typeof entry.anchor === "string" ? `${entry.anchor}${run ? ` (exit ${run.exit})` : ""}` : "loop anchor";
|
|
695
|
-
const refs = ((entry.refs ?? []) as { ref: string }[]).map((item) =>
|
|
696
|
-
|
|
784
|
+
const refs = ((entry.refs ?? []) as { ref: string }[]).map((item) => {
|
|
785
|
+
const range = resolveRef(item.ref, cwd, entries);
|
|
786
|
+
return range ? `${item.ref} → ${range}` : item.ref;
|
|
787
|
+
});
|
|
788
|
+
const followup = task?.metadata?.followup ?? entry.followup;
|
|
789
|
+
return [`#${entry.id} ${task?.status} ${task?.subject}`, anchor, ...(refs.length ? [refs.join(", ")] : []), ...(followup !== undefined ? [`followup: ${String(followup)}`] : [])].join(" — ");
|
|
697
790
|
});
|
|
698
791
|
}
|
package/pi/support/phases.ts
CHANGED
|
@@ -19,8 +19,10 @@ import {
|
|
|
19
19
|
currentKind,
|
|
20
20
|
lastActIndex,
|
|
21
21
|
lastIndex,
|
|
22
|
+
repoRootOf,
|
|
22
23
|
retryWithoutFriction,
|
|
23
24
|
specFolder,
|
|
25
|
+
withChildActs,
|
|
24
26
|
} from "./book.ts";
|
|
25
27
|
|
|
26
28
|
export const SEQUENCES: Record<Kind, readonly string[]> = {
|
|
@@ -30,7 +32,7 @@ export const SEQUENCES: Record<Kind, readonly string[]> = {
|
|
|
30
32
|
durable: ["decide", "grill", "spec", "read", "approve", "design", "anchor", "act", "verify", "review", "reflect", "close"],
|
|
31
33
|
};
|
|
32
34
|
|
|
33
|
-
export type Status = { name: string; state: "done" | "skipped" | "missing"; note: string };
|
|
35
|
+
export type Status = { name: string; state: "done" | "skipped" | "missing"; note: string; /** Skipped by the evidence itself (no open rows, no friction), not by a `skip` declaration. */ auto?: boolean };
|
|
34
36
|
|
|
35
37
|
export type Phases = {
|
|
36
38
|
kind: Kind | "undeclared";
|
|
@@ -66,11 +68,17 @@ function within(root: string, target: string): boolean {
|
|
|
66
68
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* True for a file that is not code work: scratch and spec folders of the project or of any git repo the file lives in, and
|
|
73
|
+
* temp files outside every repo. A file inside a git repo counts as work wherever the repo is, even under a temp dir.
|
|
74
|
+
*/
|
|
70
75
|
export function outsideWork(filePath: string, cwd: string): boolean {
|
|
71
76
|
const root = real(cwd);
|
|
72
77
|
const target = real(resolve(cwd, filePath));
|
|
73
|
-
|
|
78
|
+
const inFolders = (base: string) => EXEMPT_IN_PROJECT.some((dir) => within(join(base, dir), target));
|
|
79
|
+
if (within(root, target)) return inFolders(root);
|
|
80
|
+
const repo = repoRootOf(target);
|
|
81
|
+
if (repo) return inFolders(repo);
|
|
74
82
|
return EXEMPT_ROOTS.some((tmp) => within(real(tmp), target));
|
|
75
83
|
}
|
|
76
84
|
|
|
@@ -155,7 +163,7 @@ function evaluate(name: string, ctx: Ctx): Status {
|
|
|
155
163
|
const open = Number((decision.counts as Record<string, number>).open ?? 0);
|
|
156
164
|
if (open > 0) return missing(name, `${open} open decision row${open === 1 ? "" : "s"}`);
|
|
157
165
|
const first = entries.find((entry) => entry.stage === "decision" && entry.auto === true);
|
|
158
|
-
return first && Number((first.counts as Record<string, number>).open ?? 0) === 0 ? { name, state: "skipped", note: "no open rows" } : done(name);
|
|
166
|
+
return first && Number((first.counts as Record<string, number>).open ?? 0) === 0 ? { name, state: "skipped", note: "no open rows", auto: true } : done(name);
|
|
159
167
|
}
|
|
160
168
|
case "spec": {
|
|
161
169
|
const spec = artifact(ctx, "spec", from);
|
|
@@ -194,8 +202,10 @@ function evaluate(name: string, ctx: Ctx): Status {
|
|
|
194
202
|
return missing(name, command ? `a passing run of \`${command}\` after the last change` : "a verify declaration or a run of the anchor");
|
|
195
203
|
}
|
|
196
204
|
const entry = entries[index];
|
|
197
|
-
if (entry.declared === true
|
|
198
|
-
|
|
205
|
+
if (entry.declared === true && entry.result === "fail") return missing(name, "the declared verify failed");
|
|
206
|
+
if (entry.declared !== true && entry.exit !== 0) return missing(name, `the anchor exited ${entry.exit ?? "unknown"} after the last change`);
|
|
207
|
+
if (ctx.seq.includes("decide") && !artifact(ctx, "verification", from)) return missing(name, `${ctx.folder ?? "docs/specs/<folder>"}/verification.md (a durable loop's verify also needs it)`);
|
|
208
|
+
return done(name, entry.declared === true ? "declared" : "");
|
|
199
209
|
}
|
|
200
210
|
case "review": {
|
|
201
211
|
const index = lastWhere(entries, Math.max(from, ctx.lastAct + 1), (entry) => entry.stage === "review");
|
|
@@ -208,7 +218,7 @@ function evaluate(name: string, ctx: Ctx): Status {
|
|
|
208
218
|
}
|
|
209
219
|
case "reflect": {
|
|
210
220
|
const items = allFriction(entries, ctx.env);
|
|
211
|
-
if (items.length === 0) return { name, state: "skipped", note: "no friction" };
|
|
221
|
+
if (items.length === 0) return { name, state: "skipped", note: "no friction", auto: true };
|
|
212
222
|
const open = items.filter((item) => !item.reflected).length;
|
|
213
223
|
return open === 0 ? done(name) : missing(name, `${open} friction entr${open === 1 ? "y" : "ies"} without a reflect`);
|
|
214
224
|
}
|
|
@@ -221,7 +231,8 @@ function evaluate(name: string, ctx: Ctx): Status {
|
|
|
221
231
|
export function computePhases(book: Pick<Book, "entries">, inputs: Omit<Inputs, "entries">): Phases {
|
|
222
232
|
const kind = currentKind(book.entries);
|
|
223
233
|
const seq = SEQUENCES[kind ?? "inline"];
|
|
224
|
-
const
|
|
234
|
+
const entries = withChildActs(book.entries);
|
|
235
|
+
const ctx: Ctx = { ...inputs, entries, seq, folder: specFolder(entries, inputs.todos, inputs.cwd), lastAct: lastActIndex(entries) };
|
|
225
236
|
const statuses = seq.filter((name) => name !== "close").map((name) => evaluate(name, ctx));
|
|
226
237
|
let prefix = 0;
|
|
227
238
|
while (prefix < statuses.length && statuses[prefix].state !== "missing") prefix++;
|
|
@@ -230,8 +241,8 @@ export function computePhases(book: Pick<Book, "entries">, inputs: Omit<Inputs,
|
|
|
230
241
|
let next = first ? first.name : "close";
|
|
231
242
|
let note = first ? first.note : "";
|
|
232
243
|
const last = ctx.lastAct;
|
|
233
|
-
const verify =
|
|
234
|
-
const review =
|
|
244
|
+
const verify = entries.map((entry, index) => ({ entry, index })).filter(({ entry, index }) => index > last && (entry.stage === "verify" || entry.stage === "check")).at(-1)?.entry;
|
|
245
|
+
const review = entries.map((entry, index) => ({ entry, index })).filter(({ entry, index }) => index > last && entry.stage === "review").at(-1)?.entry;
|
|
235
246
|
if ((next === "verify" || next === "check") && last >= 0 && verify && verify.exit !== 0 && verify.declared !== true) next = "fix";
|
|
236
247
|
else if (next === "review" && last >= 0 && review?.blocking === true) next = "fix";
|
|
237
248
|
if (next === "fix") note = first?.note ?? "";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** The loop state Jev judges, read from the book, the todo list, and the phase machine. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type Book,
|
|
5
|
+
allFriction,
|
|
6
|
+
childLines,
|
|
7
|
+
currentIntent,
|
|
8
|
+
currentKind,
|
|
9
|
+
diffAgainstBases,
|
|
10
|
+
diffText,
|
|
11
|
+
isOpenTask,
|
|
12
|
+
latestAnchor,
|
|
13
|
+
replayTodos,
|
|
14
|
+
reviewClearAfterLastAct,
|
|
15
|
+
reviewTrend,
|
|
16
|
+
stagesDone,
|
|
17
|
+
TASK_LEGEND,
|
|
18
|
+
taskLines,
|
|
19
|
+
verifyAfterLastAct,
|
|
20
|
+
} from "./book.ts";
|
|
21
|
+
import { computePhases } from "./phases.ts";
|
|
22
|
+
|
|
23
|
+
export type Snapshot = {
|
|
24
|
+
state: Record<string, string>;
|
|
25
|
+
/** The program's next phase and what it lacks, in a few words. */
|
|
26
|
+
next: string;
|
|
27
|
+
missing: string;
|
|
28
|
+
/** Advisory hints that are not phases, such as an unrecorded friction. */
|
|
29
|
+
hints: string[];
|
|
30
|
+
/** The last phase the book shows complete. */
|
|
31
|
+
current: string;
|
|
32
|
+
/** A review recorded after the last change with no blocking finding on it. */
|
|
33
|
+
reviewClear: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBranch(): unknown[] } }): Snapshot {
|
|
37
|
+
const { entries } = book;
|
|
38
|
+
const todos = replayTodos(ctx.sessionManager.getBranch());
|
|
39
|
+
const open = todos.filter(isOpenTask).length;
|
|
40
|
+
const phases = computePhases(book, { cwd: ctx.cwd, todos });
|
|
41
|
+
const check = verifyAfterLastAct(entries);
|
|
42
|
+
const verify = [
|
|
43
|
+
`stages: ${stagesDone(entries).join(", ") || "none"}`,
|
|
44
|
+
`last check: ${check ? `${check.declared ? `declared ${check.result}` : `exit ${check.exit ?? "unknown"}`}${check.last ? ` — ${check.last}` : ""}` : "none after the last change"}`,
|
|
45
|
+
`reviews: ${reviewTrend(entries)}`,
|
|
46
|
+
...childLines(entries).map((line) => `child ${line}`),
|
|
47
|
+
].join("; ");
|
|
48
|
+
const items = allFriction(entries);
|
|
49
|
+
const friction = [
|
|
50
|
+
...(items.length ? [`${items.length} recorded, ${items.filter((item) => item.reflected).length} reflected`] : []),
|
|
51
|
+
...items.map((item) => `${item.ref}: ${item.tried} → ${item.found} [${item.reflected ? `${item.class}${item.action ? `: ${item.action}` : ""}` : "unreflected"}]`),
|
|
52
|
+
];
|
|
53
|
+
return {
|
|
54
|
+
state: {
|
|
55
|
+
kind: currentKind(entries) ?? "not declared",
|
|
56
|
+
intent: (currentIntent(entries) ?? "not declared").slice(0, 500),
|
|
57
|
+
anchor: (latestAnchor(entries) ?? "none").slice(0, 500),
|
|
58
|
+
diff: diffText(diffAgainstBases(entries)),
|
|
59
|
+
phase: `current: ${phases.current}; next: ${phases.next}${phases.missing ? `; missing: ${phases.missing}` : ""}${phases.skipped.length ? `; skipped: ${phases.skipped.join(", ")}` : ""}`.slice(0, 600),
|
|
60
|
+
verify: verify.slice(0, 1200),
|
|
61
|
+
todos: `${open} open of ${todos.length}`,
|
|
62
|
+
tasks: [`legend: ${TASK_LEGEND}`, ...taskLines(entries, todos, ctx.cwd)].join("\n").slice(0, 1500),
|
|
63
|
+
friction: (friction.join("\n") || "none").slice(0, 800),
|
|
64
|
+
},
|
|
65
|
+
next: phases.next,
|
|
66
|
+
missing: phases.missing,
|
|
67
|
+
hints: phases.hints,
|
|
68
|
+
current: phases.current,
|
|
69
|
+
reviewClear: reviewClearAfterLastAct(entries),
|
|
70
|
+
};
|
|
71
|
+
}
|