pi-anchorloop 2.2.0 → 2.2.2

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 CHANGED
@@ -25,10 +25,10 @@ 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, so it needs 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); without it no loop opens. 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, 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), 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` never refuses over phases or friction, only while a pending or in-progress todo lacks `metadata.followup` (a ticket id, `memory`, or `drop — <reason>`). |
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
- | `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. |
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 to `advance`, go `back`, or `ask-user`, plus `done`, the review tier still needed, and quality; phase completion comes from the program's evidence alone. 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. |
32
32
  | `pi/validate-tool-use.ts` | After each `write` or `edit`, validates the file in-process (`pi/support/validators.ts`, a port of `hooks/validators/`) and appends suggestions for `SKILL.md`, instruction files (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `rules/`), and `docs/specs/` artifacts. Needs no Python; the Python validators in `hooks/` remain for `validate_all.py` and the parity test. |
33
33
  | `pi/ttsr.ts` | A port of oh-my-pi's Time Traveling Stream Rules. A Markdown rule in `~/.pi/agent/rules/` or `<project>/.pi/rules/` whose frontmatter has a `condition` regex is watched during the run. A match in streamed assistant text aborts the response, drops it, and retries with the rule injected; a match in tool arguments (the command for `bash`, the new text for `write` and `edit`) blocks the call with the rule as its reason. Frontmatter fields follow oh-my-pi: `condition`, `scope` (`text`, `thinking`, `tool`, `tool:bash`, `tool:edit(*.ts)`), `globs`, and `interruptMode` (`always`, `prose-only`, `tool-only`, `never`), plus `repeatMode` (`once` or `after-gap`) and `repeatGap`. A rule fires once per session by default. `astCondition`, `question`, and `agents` are not supported; a rule that uses them is skipped and reported. `/ttsr` lists the loaded rules. |
34
34
 
package/README.zh-TW.md CHANGED
@@ -25,10 +25,10 @@ anchorloop 核心循環 · 原則 · playbook:autonomous-run、paus
25
25
 
26
26
  | 檔案 | 作用 |
27
27
  | --- | --- |
28
- | `pi/book.ts` | Loop book。第一次 `todo` create 會開啟一個 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` 條目);沒有它就不會開啟 loop。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 的修改、執行過 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 — <原因>`)時拒絕。 |
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
- | `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 判斷結果。 |
31
+ | `pi/second-thought.ts` | 只在開啟的 loop 內:一次執行結束且 book 自上次判斷後多了條目時,以及宣告 `close` 時,透過 pi 的 classifier registry 請 Jev(TypeSafe 的型別化決策模型)複核階段機。下一個階段由程式從 book 算出;Jev 看到種類、intent、anchor、diff 摘要(≤1200 字元)、`phase`(目前、下一個、缺什麼)、verify 狀態、未完成 todo、任務清單和 friction(子 agent 的以它的名字列出),只判斷要 `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 判斷結果。 |
32
32
  | `pi/validate-tool-use.ts` | 每次 `write` 或 `edit` 後在行程內驗證該檔(`pi/support/validators.ts`,移植自 `hooks/validators/`),替 `SKILL.md`、指令檔(`AGENTS.md`、`CLAUDE.md`、`GEMINI.md`、`rules/`)和 `docs/specs/` 產出物附上建議。不需要 Python;`hooks/` 的 Python 驗證器仍供 `validate_all.py` 和對照測試使用。 |
33
33
  | `pi/ttsr.ts` | 移植自 oh-my-pi 的 Time Traveling Stream Rules。`~/.pi/agent/rules/` 或 `<專案>/.pi/rules/` 裡 frontmatter 帶有 `condition` regex 的 Markdown 規則,會在執行過程中被監看。assistant 串流文字命中時,中斷回應、丟棄那段輸出,並注入規則後重試;工具參數命中時(`bash` 看指令,`write`/`edit` 看新寫入的文字),直接擋下呼叫,以規則內容作為理由。frontmatter 欄位沿用 oh-my-pi:`condition`、`scope`(`text`、`thinking`、`tool`、`tool:bash`、`tool:edit(*.ts)`)、`globs`、`interruptMode`(`always`、`prose-only`、`tool-only`、`never`),另加 `repeatMode`(`once` 或 `after-gap`)與 `repeatGap`。預設每條規則每個 session 只觸發一次。不支援 `astCondition`、`question`、`agents`,用到的規則會被跳過並回報。`/ttsr` 列出已載入的規則。 |
34
34
 
@@ -14,7 +14,7 @@ def check_decision_md(path: Path) -> list[str]:
14
14
 
15
15
  # Check for decision table
16
16
  if not re.search(r"\|\s*Question\s*\|\s*Answer\s*\|\s*Basis\s*\|\s*Status\s*\|", text, re.IGNORECASE):
17
- warnings.append("decision.md should include a Question | Answer | Basis | Status table.")
17
+ warnings.append("decision.md should include a Question | Answer | Basis | Status table (Status: grounded, confirmed, or open).")
18
18
 
19
19
  # Check for pre-flight readiness (core rules)
20
20
  lower = text.lower()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anchorloop",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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,
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
11
12
  import { join, resolve } from "node:path";
12
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
13
14
  import {
@@ -44,11 +45,13 @@ import {
44
45
  taskDetails,
45
46
  sweep,
46
47
  touchRepo,
48
+ touchRepoBeforeMerge,
47
49
  validFollowup,
48
50
  verifyAfterLastAct,
49
51
  } from "./support/book.ts";
50
52
  import { classify } from "./support/jev.ts";
51
- import { JumpMemory, SEQUENCES, computePhases, describe, incompleteBefore, jumpLine, outsideWork } from "./support/phases.ts";
53
+ import { SEQUENCES, computePhases, describe, incompleteBefore, outsideWork } from "./support/phases.ts";
54
+ import { type Snapshot, snapshot } from "./support/snapshot.ts";
52
55
  import { isSubagent } from "./support/subagent.ts";
53
56
 
54
57
  export const SESSION_ENTRY = "anchorloop-loop";
@@ -113,6 +116,77 @@ export function bashExit(event: { isError?: boolean; structuredContent?: unknown
113
116
  return named ? Number(named[1]) : null;
114
117
  }
115
118
 
119
+ /** The command with quoted strings blanked out, keeping every index, so quoted text never counts as a run or a separator. */
120
+ function unquoted(command: string): string {
121
+ // One left-to-right pass, so whichever starts first wins: a quote, or a `#` that starts a word and comments out its line.
122
+ // Newlines inside quotes are kept, since they still separate commands for the scan.
123
+ return command.replace(/'[^']*'|"(?:\\.|[^"\\])*"|(^|\s)#[^\n]*/g, (span) => span.replace(/[^\n]/g, " "));
124
+ }
125
+
126
+ /** Text before a run with `VAR=value` and simple wrappers (`uv run`, `time`, `timeout N`, `env`) peeled off its end. */
127
+ function beforeRun(text: string): string {
128
+ return text.replace(/(\b\w+=\S*\s+|\buv\s+run\s+|\btime\s+|\btimeout\s+[^\s;&|]+\s+|\benv\s+)+$/, "");
129
+ }
130
+
131
+ // `cd` and `export` change what a check tests, so they are not setup.
132
+ const SETUP_SEGMENT = /^\s*(\.|source\b|set\b|\w+=)/;
133
+
134
+ /**
135
+ * Where `command` runs `anchor` as a command (at the start, or after `&&`, `||`, `;`, `|`, `!`, `(` or a newline, outside quotes,
136
+ * past any `VAR=value` prefixes), as the last such run, or undefined. The full anchor is tried first; an anchor whose earlier
137
+ * `&&` segments only set up the environment (`. venv/bin/activate && pytest -q`) also matches on its last segment.
138
+ */
139
+ export function anchorRun(command: string, anchorText: string): { at: number; anchor: string } | undefined {
140
+ const masked = unquoted(command);
141
+ const segments = anchorText.split("&&").map((segment) => segment.trim());
142
+ const candidates = [anchorText.trim()];
143
+ if (segments.length > 1 && segments.slice(0, -1).every((segment) => SETUP_SEGMENT.test(segment))) candidates.push(segments.at(-1)!);
144
+ for (const anchor of candidates) {
145
+ if (!anchor) continue;
146
+ let found: number | undefined;
147
+ for (let at = masked.indexOf(anchor); at >= 0; at = masked.indexOf(anchor, at + 1)) {
148
+ const before = beforeRun(masked.slice(0, at));
149
+ const after = masked.charAt(at + anchor.length);
150
+ // `$(`, `<(`, `>(` substitute output; their exit is not the call's.
151
+ if (/(^|&&|\|\||[;|!\n]|(^|[^$<>])\()\s*$/.test(before) && (after === "" || /[\s;&|)]/.test(after))) found = at;
152
+ }
153
+ if (found !== undefined) return { at: found, anchor };
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ /**
159
+ * The exit to record for a bash call that ran `anchor`: the call's exit code, except `unknown` when something after the anchor
160
+ * can replace it: `||`, `;`, a newline, `&`, or a pipe straight after the anchor unless `set -o pipefail` is in effect before it.
161
+ * A chain `anchor && other` keeps a failed anchor's code, so it stands.
162
+ */
163
+ export function anchorExit(command: string, anchorText: string, exit: number | null): number | "unknown" {
164
+ if (exit === null) return "unknown";
165
+ const run = anchorRun(command, anchorText);
166
+ if (!run) return "unknown";
167
+ const masked = unquoted(command);
168
+ const { at, anchor } = run;
169
+ // `! anchor` inverts the code and `x || anchor` may never run it.
170
+ if (/(!|\|\|)\s*(\(\s*|cd\s+\S+\s*&&\s*)*$/.test(beforeRun(masked.slice(0, at)))) return "unknown";
171
+ // Inside a subshell, a pipefail set or the exit of what follows is not this shell's.
172
+ const depth = [...masked.slice(0, at)].reduce((open, char) => open + (char === "(" ? 1 : char === ")" ? -1 : 0), 0);
173
+ const rest = masked.slice(at + anchor.length);
174
+ // The last `set -o pipefail` or `set +o pipefail` before the anchor decides.
175
+ const toggles = [...masked.slice(0, at).replace(/\([^()]*\)/g, (sub) => " ".repeat(sub.length)).matchAll(/\bset\b[^;&|\n]*?([-+])\w*o\s+pipefail\b/g)];
176
+ if (depth > 0 && toggles.length === 0 && /\|(?!\|)/.test(rest)) return "unknown";
177
+ const pipefail = toggles.at(-1)?.[1] === "-";
178
+ let chained = false;
179
+ // `&&` keeps a failed anchor's exit; `||`, `;`, and a newline can replace it, and so can a pipe
180
+ // straight after the anchor unless pipefail is set.
181
+ for (const [separator] of rest.matchAll(/&&|\|\||\||(?<![<>&])&(?![&>])|;|\n/g)) {
182
+ if (separator === "&&") chained = true;
183
+ else if (separator === "|") {
184
+ if (!chained && !pipefail) return "unknown";
185
+ } else return "unknown";
186
+ }
187
+ return exit;
188
+ }
189
+
116
190
  /** The text a `Reality anchor:` line states in an assistant message, if any. */
117
191
  function statedAnchor(message: { role?: string; content?: unknown } | undefined): string | undefined {
118
192
  if (message?.role !== "assistant") return undefined;
@@ -124,8 +198,6 @@ const reply = (text: string) => ({ content: [{ type: "text" as const, text }], d
124
198
 
125
199
  export default function (pi: ExtensionAPI) {
126
200
  const subagent = isSubagent();
127
- const jumps = new JumpMemory();
128
-
129
201
  function todosOf(ctx: ExtensionContext) {
130
202
  return replayTodos(ctx.sessionManager.getBranch());
131
203
  }
@@ -134,6 +206,13 @@ export default function (pi: ExtensionAPI) {
134
206
  return computePhases(book, { cwd: ctx.cwd, todos: todosOf(ctx) });
135
207
  }
136
208
 
209
+ /** The line that tells the agent Jev did not accept a returned subagent as the fresh read, while the read is still open. */
210
+ function freshReadLine(book: Book, phases: ReturnType<typeof phasesOf>): string[] {
211
+ const last = [...book.entries].reverse().find((entry) => entry.stage === "goldfish");
212
+ const open = phases.statuses.find((status) => status.name === "read")?.state === "missing";
213
+ 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`] : [];
214
+ }
215
+
137
216
  function summaryLines(book: Book, ctx: ExtensionContext): string[] {
138
217
  const phases = phasesOf(book, ctx);
139
218
  const verify = [...book.entries].reverse().find((entry) => entry.stage === "verify" || entry.stage === "check");
@@ -147,6 +226,7 @@ export default function (pi: ExtensionAPI) {
147
226
  ...childLines(book.entries).map((line) => `child ${line}`),
148
227
  ...friction.filter((item) => item.owner !== "main").map((item) => `friction ${item.ref}: ${item.tried} → ${item.found}${item.reflected ? ` [${item.class}]` : ""}`),
149
228
  ...phases.hints,
229
+ ...freshReadLine(book, phases),
150
230
  ];
151
231
  }
152
232
 
@@ -185,7 +265,7 @@ export default function (pi: ExtensionAPI) {
185
265
  }
186
266
  }
187
267
 
188
- function close(book: Book, ctx: ExtensionContext, verdict: string): string {
268
+ function close(book: Book, ctx: ExtensionContext, verdict: string): { text: string; snapshot?: Snapshot } {
189
269
  const todos = replayTodos(ctx.sessionManager.getBranch());
190
270
  const undisposed = todos.filter((task) => isOpenTask(task) && !validFollowup(task.metadata?.followup));
191
271
  if (undisposed.length > 0) {
@@ -198,21 +278,16 @@ export default function (pi: ExtensionAPI) {
198
278
  const kind = currentKind(book.entries);
199
279
  const phases = phasesOf(book, ctx);
200
280
  const missing = incompleteBefore(phases, "close");
201
- const bookId = String(book.header.id);
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);
281
+ const notice = verdict === "landed" && missing.length ? `anchorloop: closed with phases not shown complete: ${missing.map((status) => `${status.name} (${status.note})`).join("; ")}.` : undefined;
209
282
  const diff = diffAgainstBases(book.entries);
210
283
  const friction = allFriction(book.entries);
284
+ 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
285
  const summary = {
212
286
  intent: currentIntent(book.entries) ?? null,
213
287
  kind: kind ?? null,
214
288
  verdict,
215
289
  stages: stagesDone(book.entries),
290
+ phases: phaseList,
216
291
  reviewRounds: reviews(book.entries).length,
217
292
  reviewTrend: reviewTrend(book.entries),
218
293
  diff: diff.summary,
@@ -225,20 +300,32 @@ export default function (pi: ExtensionAPI) {
225
300
  const tail = unreflected.length
226
301
  ? ` Unreflected friction for the Reflexive Pass: ${unreflected.map((item) => `${item.ref} (${item.found})`).join("; ")}.`
227
302
  : "";
228
- const extra = [summary.skipped.length ? `skipped ${summary.skipped.join(", ")}` : "", summary.back.length ? `back ${summary.back.join(", ")}` : ""].filter(Boolean).join("; ");
229
- const text = `Loop closed (${verdict}): ${summary.stages.join(", ")}; ${summary.reviewTrend}; diff ${diff.summary}${extra ? `; ${extra}` : ""}.${tail}`;
303
+ const named = (item: (typeof phaseList)[number]) => `${item.phase}${item.note ? ` (${item.note})` : ""}`;
304
+ const doneNames = phaseList.filter((item) => item.state === "done").map((item) => item.phase);
305
+ const skippedNames = phaseList.filter((item) => item.state === "skipped" || item.state === "auto-skipped").map(named);
306
+ const openNames = phaseList.filter((item) => item.state === "not shown complete").map((item) => item.phase);
307
+ const shown = [
308
+ `done: ${doneNames.join(", ") || "none"}`,
309
+ ...(skippedNames.length ? [`skipped: ${skippedNames.join(", ")}`] : []),
310
+ ...(openNames.length ? [`${verdict === "landed" ? "not complete" : "not reached"}: ${openNames.join(", ")}`] : []),
311
+ ].join("; ");
312
+ const back = summary.back.length ? `; back ${summary.back.join(", ")}` : "";
313
+ const text = `Loop closed (${verdict}): phases ${shown}; ${summary.reviewTrend}; diff ${diff.summary.split("\n").join("; ")}${back}.${tail}`;
314
+ const after = notice ? `\n${notice}` : "";
230
315
  if (kept) {
231
316
  // A child's book stays for the main session to read and delete.
232
317
  appendEntry(book.path, "close", { verdict, summary: text.slice(0, 500) });
233
- return `${text} The book stays for the main session.${warning ? `\n${warning}` : ""}`;
318
+ return { text: `${text} The book stays for the main session.${after}` };
234
319
  }
320
+ // Jev judges the loop as it stood at close; the book is gone by the time the result is judged.
321
+ const snap = kind === "quick" ? undefined : snapshot(book, ctx);
235
322
  pi.appendEntry(SESSION_ENTRY, summary);
236
323
  for (const id of childIds(book.entries)) {
237
324
  const child = findChildBook(id);
238
325
  if (child) deleteBook(child.path);
239
326
  }
240
327
  deleteBook(book.path);
241
- return `${text} The book is deleted. Clear the todo list now (todo action: clear).${warning ? `\n${warning}` : ""}`;
328
+ return { text: `${text} The book is deleted. Clear the todo list now (todo action: clear).${after}`, snapshot: snap };
242
329
  }
243
330
 
244
331
  pi.registerTool({
@@ -275,10 +362,16 @@ export default function (pi: ExtensionAPI) {
275
362
  },
276
363
  async execute(_toolCallId: string, raw: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
277
364
  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
365
  const { stage } = params;
281
366
  if (!(STAGES as readonly string[]).includes(stage)) throw new Error(`unknown stage \`${String(stage)}\`; declare one of ${STAGES.join(", ")}`);
367
+ let book = openLoop(ctx);
368
+ if (!book && stage === "kind") {
369
+ // A todo create in the same batch may not have landed yet; whichever comes first opens the loop.
370
+ if (!params.kind || !params.intent) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
371
+ book = createBook(ctx);
372
+ recordPriorAnchor(book, ctx);
373
+ }
374
+ if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create or `kind`.");
282
375
  const had = currentKind(book.entries);
283
376
  const sequence = SEQUENCES[had ?? "inline"];
284
377
  if (stage === "read") {
@@ -288,7 +381,8 @@ export default function (pi: ExtensionAPI) {
288
381
  }
289
382
  if (stage === "close") {
290
383
  if (!params.verdict) throw new Error("close needs a verdict: landed, blocked, or abandoned");
291
- return reply(close(book, ctx, params.verdict));
384
+ const closed = close(book, ctx, params.verdict);
385
+ return { content: [{ type: "text" as const, text: closed.text }], details: closed.snapshot ? { snapshot: closed.snapshot } : undefined };
292
386
  }
293
387
  if (stage === "kind") {
294
388
  if (!params.kind && !params.intent && !params.anchor) throw new Error("kind needs `kind`, `intent`, or `anchor`");
@@ -371,13 +465,21 @@ export default function (pi: ExtensionAPI) {
371
465
  const exit = bashExit(event);
372
466
  const last = lastLine(output);
373
467
  const anchor = anchorCommand(book.entries);
374
- if (anchor && command.includes(anchor)) {
468
+ if (anchor && anchorRun(command, anchor)) {
375
469
  const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
376
- const piped = command.slice(command.indexOf(anchor) + anchor.length).includes("|");
377
- book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: piped ? "unknown" : (exit ?? "unknown"), last }));
470
+ book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: anchorExit(command, anchor, exit), last }));
378
471
  }
379
472
  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: exit ?? "unknown", last });
473
+ if (typeof task.anchor === "string" && anchorRun(command, task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit: anchorExit(command, task.anchor, exit), last });
474
+ }
475
+ const merged = /\bgit(\s+-C\s+\S+)?\s+(merge|cherry-pick|pull)(?=\s|$)(?![^|;&]*--(abort|quit)\b)/.exec(command);
476
+ if (exit === 0 && merged && !/Already up to date/i.test(last ?? "") && book.entries.at(-1)?.stage !== "edit") {
477
+ // The repo is the `-C` of the merge itself, else the nearest `cd` before it.
478
+ const cds = [...command.slice(0, merged.index).matchAll(/\bcd\s+(\S+)/g)];
479
+ const named = merged[1]?.trim().replace(/^-C\s+/, "") ?? cds.at(-1)?.[1];
480
+ const dir = named?.replace(/^~(?=\/|$)/, homedir());
481
+ touchRepoBeforeMerge(book, resolve(String(book.header.cwd ?? process.cwd()), dir ?? "."), merged[2]);
482
+ book.entries.push(appendEntry(book.path, "edit", { via: "merge" }));
381
483
  }
382
484
  if (exit === 0 && /\bgit\b[^|;&]*\bcommit\b/.test(command) && !book.entries.some((entry) => entry.stage === "edit")) {
383
485
  const cwd = String(book.header.cwd ?? "");
@@ -459,7 +561,22 @@ export default function (pi: ExtensionAPI) {
459
561
  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
562
  }
461
563
 
462
- /** 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. */
564
+ const HOST_LINE = /^\s*(Parent action:|Requested model|Session:|Resume:)/i;
565
+
566
+ /** The text without the lines pi-herdr adds around a subagent brief or result. */
567
+ function strip(text: string): string {
568
+ return text
569
+ .split("\n")
570
+ .filter((line) => !HOST_LINE.test(line))
571
+ .join("\n")
572
+ .trim();
573
+ }
574
+
575
+ function headAndTail(text: string): string {
576
+ return text.length <= 2500 ? text : `${text.slice(0, 1000)}\n…\n${text.slice(-1500)}`;
577
+ }
578
+
579
+ /** 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
580
  async function detectFreshRead(ctx: ExtensionContext, book: Book, details: { name?: unknown; task?: unknown; resultContent?: unknown }): Promise<void> {
464
581
  const phases = phasesOf(book, ctx);
465
582
  const read = phases.statuses.find((status) => status.name === "read");
@@ -467,11 +584,11 @@ export default function (pi: ExtensionAPI) {
467
584
  const folder = specFolder(book.entries, todosOf(ctx), ctx.cwd);
468
585
  const brief = String(details.task ?? "");
469
586
  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).slice(-2000) }, {
587
+ const { answers } = await classify(ctx, { brief: strip(brief).slice(0, 1500), result: headAndTail(strip(textOf(details.resultContent))) }, {
471
588
  fresh_read: {
472
589
  type: "bool",
473
- instructions: "Was this subagent a fresh-context read of the spec: did its `brief` hand it the spec without the author's reasoning and ask it to restate the contract and list its guesses, and does its `result` do so?",
474
- criteria: { true: "the result restates the contract in its own words and lists what it had to guess", false: "the subagent did other work, had the author's context, or only summarized the spec" },
590
+ 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.",
591
+ 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
592
  },
476
593
  });
477
594
  const probability = answers?.fresh_read?.type === "bool" ? answers.fresh_read.probability : undefined;
@@ -12,36 +12,18 @@
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 { computePhases } from "./support/phases.ts";
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"]);
43
25
 
44
- export const THRESHOLDS = { completeBelow: 0.5, doneBelow: 0.25, qualityBelow: 1.5, reviewTier: "full" };
26
+ export const THRESHOLDS = { doneBelow: 0.25, qualityBelow: 1.5, reviewTier: "full" };
45
27
 
46
28
  export const MOVES = ["advance", "back", "ask-user"] as const;
47
29
  export type Move = (typeof MOVES)[number];
@@ -60,14 +42,6 @@ export const QUESTIONS = {
60
42
  "ask-user": "a decision or judgment that belongs to the user blocks progress",
61
43
  },
62
44
  },
63
- complete: {
64
- type: "bool" as const,
65
- instructions: "Does the evidence in `diff`, `verify`, and `phase` show that the completion condition of the last complete phase (`phase` current) truly holds, beyond the program's mechanical check?",
66
- criteria: {
67
- true: "the evidence observably meets what the phase requires",
68
- false: "the phase counts as complete on a mechanical signal, but its content is empty, stale, or off the intent",
69
- },
70
- },
71
45
  done: {
72
46
  type: "bool" as const,
73
47
  instructions: "Does the `verify` evidence show, with observed results, that `intent` is fully met?",
@@ -97,16 +71,15 @@ export const QUESTIONS = {
97
71
  },
98
72
  };
99
73
 
100
- export type Verdict = { move: Move; complete: number; done: number; review: string; reviewProbability: number; quality: number };
74
+ export type Verdict = { move: Move; done: number; review: string; reviewProbability: number; quality: number };
101
75
 
102
76
  export function readVerdict(answers: Answers): Verdict | undefined {
103
- const { move, complete, done, review, quality } = answers;
104
- if (move?.type !== "choice" || complete?.type !== "bool" || done?.type !== "bool" || review?.type !== "choice" || quality?.type !== "score") return undefined;
77
+ const { move, done, review, quality } = answers;
78
+ if (move?.type !== "choice" || done?.type !== "bool" || review?.type !== "choice" || quality?.type !== "score") return undefined;
105
79
  if (!(MOVES as readonly string[]).includes(move.choice ?? "")) return undefined;
106
80
  const choice = review.choice ?? "";
107
81
  return {
108
82
  move: move.choice as Move,
109
- complete: complete.probability ?? 0,
110
83
  done: done.probability ?? 0,
111
84
  review: choice,
112
85
  reviewProbability: review.probabilities?.[choice] ?? 0,
@@ -114,56 +87,9 @@ export function readVerdict(answers: Answers): Verdict | undefined {
114
87
  };
115
88
  }
116
89
 
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
90
  export function assess(verdict: Verdict, snap: Snapshot): { found: string[]; notes: string[] } {
164
91
  const found: string[] = [];
165
92
  const notes: string[] = [];
166
- if (verdict.complete < THRESHOLDS.completeBelow && snap.current !== "start") found.push(`${snap.current} not shown to be complete`);
167
93
  if (verdict.done < THRESHOLDS.doneBelow) found.push("evidence not shown to cover the intent");
168
94
  if (verdict.review === THRESHOLDS.reviewTier) {
169
95
  if (snap.reviewClear) notes.push("review rated full, but a review recorded after the last change found no blocking finding");
@@ -183,7 +109,7 @@ export function message(verdict: Verdict, snap: Snapshot, found: string[], notes
183
109
  const extra = [...found, ...notes, ...snap.hints];
184
110
  return (
185
111
  `Second thought (Jev, advisory): next → ${snap.next}${snap.missing ? ` (missing: ${snap.missing})` : ""}; ` +
186
- `${verdict.move}; complete ${verdict.complete.toFixed(2)}, done ${verdict.done.toFixed(2)}, review ${verdict.review}, quality ${verdict.quality.toFixed(2)}/3` +
112
+ `${verdict.move}; done ${verdict.done.toFixed(2)}, review ${verdict.review}, quality ${verdict.quality.toFixed(2)}/3` +
187
113
  `${extra.length ? `; ${extra.join("; ")}` : ""}.` +
188
114
  (!atClose && continues(verdict, snap) ? ` Do ${snap.next} now, or say why it does not apply.` : "")
189
115
  );
@@ -229,15 +155,17 @@ export default function (pi: ExtensionAPI) {
229
155
  pi.on(
230
156
  "tool_result",
231
157
  guarded(async (event, ctx) => {
232
- const snap = closing.get(event.toolCallId);
233
- if (!snap) return;
234
- closing.delete(event.toolCallId);
158
+ const called = closing.get(event.toolCallId);
159
+ if (called) closing.delete(event.toolCallId);
235
160
  if (event.isError) return;
161
+ // The close tool reports the loop as it stood when it closed, after any reflect that ran beside it.
162
+ const snap = (event.details as { snapshot?: Snapshot } | undefined)?.snapshot ?? called;
163
+ if (!snap) return;
236
164
  const judgment = await judge(ctx, snap);
237
165
  if (!judgment?.verdict) return;
238
166
  const { found, notes } = assess(judgment.verdict, snap);
239
167
  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) }] };
168
+ return { content: [...event.content, { type: "text" as const, text: `\n${message(judgment.verdict, snap, found, notes, true)}` }] };
241
169
  }),
242
170
  );
243
171
 
@@ -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 = openSync(path, "a");
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
- const dir = join(root, project);
199
- if (!statSync(dir).isDirectory()) continue;
200
- for (const file of readdirSync(dir)) {
201
- if (!file.endsWith(".yml")) continue;
202
- const book = parseBook(join(dir, file));
203
- if (!book) continue;
204
- if (sessionGone(book)) {
205
- deleteBook(book.path);
206
- result.deleted.push(book.path);
207
- continue;
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
- if (!slug || project === slug) result.open.push(`${book.path} (${currentKind(book.entries) ?? "no kind"}: ${currentIntent(book.entries) ?? "no intent"})`);
210
- for (const entry of book.entries) if (entry.stage === "link" && typeof entry.child === "string") linked.add(safe(entry.child));
211
- }
212
- const agents = join(dir, "agents");
213
- if (existsSync(agents)) {
214
- for (const file of readdirSync(agents)) {
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
- if (linked.has(basename(child.path, ".yml")) || !sessionGone(child)) continue;
222
- deleteBook(child.path);
223
- result.deleted.push(child.path);
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(entries: Entry[]): boolean {
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(entries: Entry[]): Entry | undefined {
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
- /** Runs that failed and later passed, per anchor and per task anchor, found from the recorded exit codes. */
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
- const key = entry.stage === "taskrun" ? `task#${entry.id}` : entry.stage === "verify" || entry.stage === "check" ? "anchor" : undefined;
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 if (failed.has(key)) {
382
- pairs.push({ key, fail: failed.get(key) as number, pass: index });
383
- failed.delete(key);
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
  }
@@ -470,6 +545,22 @@ export function touchRepo(book: Book, path: string): boolean {
470
545
  return true;
471
546
  }
472
547
 
548
+ /**
549
+ * Adds the repo at `dir` with its pre-command HEAD after a `verb` (merge, pull, or cherry-pick) first seen through bash: the
550
+ * reflog entry before the run of `verb` entries on top. ORIG_HEAD is not used, since cherry-pick leaves an old one in place.
551
+ */
552
+ export function touchRepoBeforeMerge(book: Book, dir: string, verb: string): boolean {
553
+ const found = repoOf(join(dir, "_"));
554
+ if (!found || bases(book.entries).some((base) => base.repo === found.repo)) return false;
555
+ const reflog = git(found.repo, ["reflog", "-n", "50", "--format=%H %gs"])?.split("\n").filter(Boolean) ?? [];
556
+ const first = reflog.findIndex((line) => !line.slice(41).startsWith(verb));
557
+ const before = reflog.length > 0 && reflog[0].slice(41).startsWith(verb) && first > 0 ? reflog[first].slice(0, 40) : undefined;
558
+ // A rebasing pull leaves the old HEAD off HEAD's history; their merge base keeps the pulled commits in the diff.
559
+ const base = before === undefined ? undefined : git(found.repo, ["merge-base", before, "HEAD"]);
560
+ book.entries.push(appendEntry(book.path, "base", { repo: found.repo, head: base || found.head }));
561
+ return true;
562
+ }
563
+
473
564
  export type Diff = { summary: string; files: string[]; paths: string[] };
474
565
 
475
566
  /** The diff of each base's repo against its recorded HEAD, as one summary line per repo and the changed files. */
@@ -485,11 +576,11 @@ export function diffAgainstBases(entries: Entry[]): Diff {
485
576
  lines.push(`${basename(repo)}: diff unavailable`);
486
577
  continue;
487
578
  }
488
- lines.push(`${basename(repo)}: ${stat || "no tracked changes"}${untracked.length ? `; ${untracked.length} untracked` : ""}`);
579
+ if (stat) lines.push(`${basename(repo)}: ${stat}`);
489
580
  files.push(...names.map((name) => `${basename(repo)}/${name}`), ...untracked.map((name) => `${basename(repo)}/${name}`));
490
581
  paths.push(...[...names, ...untracked].map((name) => join(repo, name)));
491
582
  }
492
- return { summary: lines.join("\n") || "no repository", files, paths };
583
+ return { summary: lines.join("\n") || (bases(entries).length ? "no tracked changes" : "no repository"), files, paths };
493
584
  }
494
585
 
495
586
  export function diffText(diff: Diff, limit = 1200): string {
@@ -633,19 +724,33 @@ export function artifactOnDisk(folder: string, name: string, cwd: string): Entry
633
724
 
634
725
  // --- todo links ----------------------------------------------------------------------------------
635
726
 
636
- /** The `ref` metadata of a task as a list of `<path>#<heading>` pointers. */
727
+ const MARKDOWN = /\.(md|markdown)$/i;
728
+
729
+ const isMarkdownRef = (ref: string): boolean => MARKDOWN.test(ref.split("#")[0]);
730
+
731
+ /** The `ref` metadata of a task as pointers: `<path>#<heading>` into markdown, or the path of any other file. */
637
732
  export function refsOf(metadata: Record<string, unknown> | undefined): string[] {
638
733
  const ref = metadata?.ref;
639
734
  const list = Array.isArray(ref) ? ref : [ref];
640
- return list.filter((item): item is string => typeof item === "string" && item.includes("#"));
735
+ return list.filter((item): item is string => typeof item === "string" && item.trim() !== "" && (item.includes("#") || !isMarkdownRef(item)));
641
736
  }
642
737
 
643
- /** The line range of the section `<path>#<heading>` names, such as `L88-L101`, or `missing` when the file or heading is gone. */
738
+ /**
739
+ * The line range of the section `<path>#<heading>` names, such as `L88-L101`, or `missing` when the file or heading is gone.
740
+ * A ref to a file that is not markdown has no headings to resolve and returns "" (shown as given). Relative paths resolve against
741
+ * the cwd, the loop's repos, and the folders of recorded artifacts.
742
+ */
644
743
  export function resolveRef(ref: string, cwd: string, entries: Entry[]): string {
744
+ if (!isMarkdownRef(ref)) return "";
645
745
  const split = ref.indexOf("#");
746
+ if (split < 0) return "";
646
747
  const path = ref.slice(0, split);
647
748
  const heading = ref.slice(split + 1).trim();
648
- const dirs = [cwd, ...new Set(entries.filter((entry) => typeof entry.path === "string").map((entry) => dirname(resolve(cwd, entry.path as string))))];
749
+ const dirs = [
750
+ cwd,
751
+ ...bases(entries).map((base) => base.repo),
752
+ ...new Set(entries.filter((entry) => typeof entry.path === "string").map((entry) => dirname(resolve(cwd, entry.path as string)))),
753
+ ];
649
754
  for (const dir of dirs) {
650
755
  let lines: string[];
651
756
  try {
@@ -692,7 +797,11 @@ export function taskLines(entries: Entry[], todos: Task[], cwd: string): string[
692
797
  const task = live.get(entry.id as number);
693
798
  const run = exits.get(entry.id as number);
694
799
  const anchor = typeof entry.anchor === "string" ? `${entry.anchor}${run ? ` (exit ${run.exit})` : ""}` : "loop anchor";
695
- const refs = ((entry.refs ?? []) as { ref: string }[]).map((item) => `${item.ref} → ${resolveRef(item.ref, cwd, entries)}`);
696
- return [`#${entry.id} ${task?.status} ${task?.subject}`, anchor, ...(refs.length ? [refs.join(", ")] : [])].join(" — ");
800
+ const refs = ((entry.refs ?? []) as { ref: string }[]).map((item) => {
801
+ const range = resolveRef(item.ref, cwd, entries);
802
+ return range ? `${item.ref} → ${range}` : item.ref;
803
+ });
804
+ const followup = task?.metadata?.followup ?? entry.followup;
805
+ return [`#${entry.id} ${task?.status} ${task?.subject}`, anchor, ...(refs.length ? [refs.join(", ")] : []), ...(followup !== undefined ? [`followup: ${String(followup)}`] : [])].join(" — ");
697
806
  });
698
807
  }
@@ -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
- /** Scratch and spec folders in the project and temp files outside it; a project that lives in a temp dir is still counted. */
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
- if (within(root, target)) return EXEMPT_IN_PROJECT.some((dir) => within(join(root, dir), target));
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
 
@@ -115,10 +123,11 @@ function skipEntry(entries: Entry[], from: number, name: string): Entry | undefi
115
123
 
116
124
  /** The latest recorded summary of an artifact since `from`; a loop that never wrote it falls back to the file on disk. */
117
125
  function artifact(ctx: Ctx, name: string, from: number): Entry | undefined {
126
+ // The file on disk is the current state (a `sed` edit through bash is not observed); entries are history.
127
+ const disk = ctx.folder && from === 0 ? artifactOnDisk(ctx.folder, name, ctx.cwd) : undefined;
128
+ if (disk) return disk;
118
129
  const index = lastWhere(ctx.entries, from, (entry) => entry.stage === name && entry.auto === true);
119
- if (index >= 0) return ctx.entries[index];
120
- const wrote = ctx.entries.some((entry) => entry.stage === name && entry.auto === true);
121
- return from === 0 && !wrote && ctx.folder ? artifactOnDisk(ctx.folder, name, ctx.cwd) : undefined;
130
+ return index >= 0 ? ctx.entries[index] : undefined;
122
131
  }
123
132
 
124
133
  const total = (counts: unknown): number => Object.values((counts ?? {}) as Record<string, number>).reduce((sum, n) => sum + Number(n), 0);
@@ -147,7 +156,7 @@ function evaluate(name: string, ctx: Ctx): Status {
147
156
  switch (name) {
148
157
  case "decide": {
149
158
  const decision = artifact(ctx, "decision", from);
150
- return total(decision?.counts) > 0 ? done(name) : missing(name, ctx.folder ? `${ctx.folder}/decision.md with decision rows` : "decision.md with decision rows (no spec folder: name it in `intent` by calling `kind` again)");
159
+ return total(decision?.counts) > 0 ? done(name) : missing(name, ctx.folder ? `${ctx.folder}/decision.md with decision rows (Status grounded, confirmed, or open)` : "decision.md with decision rows with Status grounded, confirmed, or open (no spec folder: name it in `intent` by calling `kind` again)");
151
160
  }
152
161
  case "grill": {
153
162
  const decision = artifact(ctx, "decision", from);
@@ -155,7 +164,7 @@ function evaluate(name: string, ctx: Ctx): Status {
155
164
  const open = Number((decision.counts as Record<string, number>).open ?? 0);
156
165
  if (open > 0) return missing(name, `${open} open decision row${open === 1 ? "" : "s"}`);
157
166
  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);
167
+ return first && Number((first.counts as Record<string, number>).open ?? 0) === 0 ? { name, state: "skipped", note: "no open rows", auto: true } : done(name);
159
168
  }
160
169
  case "spec": {
161
170
  const spec = artifact(ctx, "spec", from);
@@ -194,8 +203,10 @@ function evaluate(name: string, ctx: Ctx): Status {
194
203
  return missing(name, command ? `a passing run of \`${command}\` after the last change` : "a verify declaration or a run of the anchor");
195
204
  }
196
205
  const entry = entries[index];
197
- if (entry.declared === true) return entry.result === "fail" ? missing(name, "the declared verify failed") : done(name, "declared");
198
- return entry.exit === 0 ? done(name) : missing(name, `the anchor exited ${entry.exit ?? "unknown"} after the last change`);
206
+ if (entry.declared === true && entry.result === "fail") return missing(name, "the declared verify failed");
207
+ if (entry.declared !== true && entry.exit !== 0) return missing(name, `the anchor exited ${entry.exit ?? "unknown"} after the last change`);
208
+ 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)`);
209
+ return done(name, entry.declared === true ? "declared" : "");
199
210
  }
200
211
  case "review": {
201
212
  const index = lastWhere(entries, Math.max(from, ctx.lastAct + 1), (entry) => entry.stage === "review");
@@ -208,7 +219,7 @@ function evaluate(name: string, ctx: Ctx): Status {
208
219
  }
209
220
  case "reflect": {
210
221
  const items = allFriction(entries, ctx.env);
211
- if (items.length === 0) return { name, state: "skipped", note: "no friction" };
222
+ if (items.length === 0) return { name, state: "skipped", note: "no friction", auto: true };
212
223
  const open = items.filter((item) => !item.reflected).length;
213
224
  return open === 0 ? done(name) : missing(name, `${open} friction entr${open === 1 ? "y" : "ies"} without a reflect`);
214
225
  }
@@ -221,7 +232,8 @@ function evaluate(name: string, ctx: Ctx): Status {
221
232
  export function computePhases(book: Pick<Book, "entries">, inputs: Omit<Inputs, "entries">): Phases {
222
233
  const kind = currentKind(book.entries);
223
234
  const seq = SEQUENCES[kind ?? "inline"];
224
- const ctx: Ctx = { ...inputs, entries: book.entries, seq, folder: specFolder(book.entries, inputs.todos, inputs.cwd), lastAct: lastActIndex(book.entries) };
235
+ const entries = withChildActs(book.entries);
236
+ const ctx: Ctx = { ...inputs, entries, seq, folder: specFolder(entries, inputs.todos, inputs.cwd), lastAct: lastActIndex(entries) };
225
237
  const statuses = seq.filter((name) => name !== "close").map((name) => evaluate(name, ctx));
226
238
  let prefix = 0;
227
239
  while (prefix < statuses.length && statuses[prefix].state !== "missing") prefix++;
@@ -230,8 +242,8 @@ export function computePhases(book: Pick<Book, "entries">, inputs: Omit<Inputs,
230
242
  let next = first ? first.name : "close";
231
243
  let note = first ? first.note : "";
232
244
  const last = ctx.lastAct;
233
- const verify = book.entries.map((entry, index) => ({ entry, index })).filter(({ entry, index }) => index > last && (entry.stage === "verify" || entry.stage === "check")).at(-1)?.entry;
234
- const review = book.entries.map((entry, index) => ({ entry, index })).filter(({ entry, index }) => index > last && entry.stage === "review").at(-1)?.entry;
245
+ const verify = entries.map((entry, index) => ({ entry, index })).filter(({ entry, index }) => index > last && (entry.stage === "verify" || entry.stage === "check")).at(-1)?.entry;
246
+ const review = entries.map((entry, index) => ({ entry, index })).filter(({ entry, index }) => index > last && entry.stage === "review").at(-1)?.entry;
235
247
  if ((next === "verify" || next === "check") && last >= 0 && verify && verify.exit !== 0 && verify.declared !== true) next = "fix";
236
248
  else if (next === "review" && last >= 0 && review?.blocking === true) next = "fix";
237
249
  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
+ }
@@ -320,7 +320,7 @@ function checkDecisionMd(path: string): string[] {
320
320
  const warnings: string[] = [];
321
321
  const text = readText(path);
322
322
  if (!/\|\s*Question\s*\|\s*Answer\s*\|\s*Basis\s*\|\s*Status\s*\|/i.test(text)) {
323
- warnings.push("decision.md should include a Question | Answer | Basis | Status table.");
323
+ warnings.push("decision.md should include a Question | Answer | Basis | Status table (Status: grounded, confirmed, or open).");
324
324
  }
325
325
  const lower = text.toLowerCase();
326
326
  if (!lower.includes("rule") && !lower.includes("readiness") && !lower.includes("prerequisite") && !lower.includes("core")) {