pi-anchorloop 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 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; a later call changes the kind only with a `reason`), `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: …`. An anchor run or declared verify of a `quick` or `inline` loop also measures the diff's reach: source files (not tests, lockfiles, specs, or prose), the top-level directories they sit in, and a README, changelog, or `*.schema.json` changed beside them. Files already changed when a repo joined the loop count only once their content changes, and a file at a repository root counts as a file, not a directory. At 3 source files, 2 directories, or one such file, the result carries one line asking to re-classify, and verify waits for a `kind` call with `reason` that answers each threshold crossed; a later threshold asks again. 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>`). |
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; a later call changes the kind only with a `reason`), `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 (an anchor that passed, failed, and passed again records its own friction entry: the rerun command and the failed run's key line, at most 120 characters and never a stack trace, still waiting for a `reflect`), 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: …`. The pi footer shows `anchorloop <kind> · <current> ✓ → <next>`, with the friction still to reflect, after every tool call and on `session_start`, and clears when the loop closes. An anchor run or declared verify of a `quick` or `inline` loop also measures the diff's reach: source files (not tests, lockfiles, specs, or prose), the top-level directories they sit in, and a README, changelog, or `*.schema.json` changed beside them. Files already changed when a repo joined the loop count only once their content changes, and a file at a repository root counts as a file, not a directory. At 3 source files, 2 directories, or one such file, the result carries one line asking to re-classify, and verify waits for a `kind` call with `reason` that answers each threshold crossed; a later threshold asks again. 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 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. |
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>`. 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 或 `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 指令;之後要改種類,必須附 `reason`)、`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 <目前> ✓ → <下一個>(缺:…)· 已跳過:…`。`quick` 或 `inline` loop 跑 anchor 或宣告 verify 時,也會量 diff 的影響範圍:原始碼檔案(不含測試、lockfile、spec 與文字檔)、它們所在的頂層目錄,以及跟原始碼一起改的 README、changelog 或 `*.schema.json`。repo 加入 loop 時就已經改過的檔案,要等內容再變動才算;repo 根目錄的檔案只算檔案數,不算一個目錄。達到 3 個原始碼檔案、2 個目錄或 1 個這類檔案時,結果會附一行請 agent 重新分類;verify 要等到以 `reason` 呼叫 `kind`、回應每一個跨過的門檻才算完成,之後再跨過新的門檻會再問一次。前一個階段還沒完成就開始下一個階段,會用一行警告一次並指出 `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 指令;之後要改種類,必須附 `reason`)、`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(anchor 通過後失敗、又再通過時,extension 會自己記一筆 friction:重跑的指令,加上失敗那次最關鍵的一行輸出,最多 120 字元,不放 stack trace;這筆仍要 `reflect` 分類)、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 <目前> ✓ → <下一個>(缺:…)· 已跳過:…`。pi 的 footer 會顯示 `anchorloop <種類> · <目前> ✓ → <下一個>`,以及還沒 reflect 的 friction 數;每次工具呼叫後和 `session_start` 時更新,loop 結案後清掉。`quick` 或 `inline` loop 跑 anchor 或宣告 verify 時,也會量 diff 的影響範圍:原始碼檔案(不含測試、lockfile、spec 與文字檔)、它們所在的頂層目錄,以及跟原始碼一起改的 README、changelog 或 `*.schema.json`。repo 加入 loop 時就已經改過的檔案,要等內容再變動才算;repo 根目錄的檔案只算檔案數,不算一個目錄。達到 3 個原始碼檔案、2 個目錄或 1 個這類檔案時,結果會附一行請 agent 重新分類;verify 要等到以 `reason` 呼叫 `kind`、回應每一個跨過的門檻才算完成,之後再跨過新的門檻會再問一次。前一個階段還沒完成就開始下一個階段,會用一行警告一次並指出 `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 的以它的名字列出),只判斷要 `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 → <階段>` 開頭。只有 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anchorloop",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
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
@@ -35,6 +35,7 @@ import {
35
35
  openLoop,
36
36
  replayTodos,
37
37
  refsOf,
38
+ retryPairs,
38
39
  resolveRef,
39
40
  reviewTrend,
40
41
  reviews,
@@ -98,6 +99,38 @@ function textOf(content: unknown): string {
98
99
  return (content as Block[]).map((block) => (block && typeof block.text === "string" ? block.text : "")).filter(Boolean).join("\n");
99
100
  }
100
101
 
102
+ /** `text` cut to `limit` characters, the cut marked. */
103
+ function clip(text: string, limit = 120): string {
104
+ return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
105
+ }
106
+
107
+ const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
108
+ // Stack frames and traceback headers say where, not what; the key line says what failed.
109
+ const FRAME = /^(?:at\s|File\s"|Traceback\b|\^+$)/;
110
+ const KEY_LINES = [
111
+ /^(?:FAILED|ERROR)\b/,
112
+ /^E\s+\S/,
113
+ /^[\w.]+(?:Error|Exception|Warning)\b:?/,
114
+ /\b(?:error|fatal|panic|exception)\b/i,
115
+ /\bfail(?:ed|ure|s)?\b/i,
116
+ ];
117
+
118
+ /** The one line of a failed run's output that says what failed, at most 120 characters; the last line when none does. */
119
+ function keyLine(text: string): string {
120
+ const raw = text.replace(ANSI, "").split("\n");
121
+ // A Python frame's next line, indented under it, is the source it quotes, not the failure.
122
+ const quoted = (index: number) => /^\s/.test(raw[index]) && /^\s*File\s"/.test(raw[index - 1] ?? "");
123
+ const lines = raw.flatMap((line, index) => {
124
+ const trimmed = line.trim();
125
+ return trimmed && !FRAME.test(trimmed) && !quoted(index) ? [trimmed] : [];
126
+ });
127
+ for (const pattern of KEY_LINES) {
128
+ const hit = lines.find((line) => pattern.test(line));
129
+ if (hit) return clip(hit);
130
+ }
131
+ return clip(lines.at(-1) ?? "");
132
+ }
133
+
101
134
  function lastLine(text: string): string {
102
135
  return (
103
136
  text
@@ -297,6 +330,8 @@ function statedAnchor(message: { role?: string; content?: unknown } | undefined)
297
330
  return found ? found[1].trim().slice(0, 300) || "(stated on the next line)" : undefined;
298
331
  }
299
332
 
333
+ const STATUS_KEY = "anchorloop";
334
+
300
335
  const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
301
336
 
302
337
  export default function (pi: ExtensionAPI) {
@@ -328,7 +363,6 @@ export default function (pi: ExtensionAPI) {
328
363
  `reviews: ${reviewTrend(book.entries)}; open todos: ${open}; friction: ${friction.length} (${friction.filter((item) => !item.reflected).length} unreflected)`,
329
364
  ...childLines(book.entries).map((line) => `child ${line}`),
330
365
  ...friction.filter((item) => item.owner !== "main").map((item) => `friction ${item.ref}: ${item.tried} → ${item.found}${item.reflected ? ` [${item.class}]` : ""}`),
331
- ...phases.hints,
332
366
  ...freshReadLine(book, phases),
333
367
  ];
334
368
  }
@@ -463,88 +497,96 @@ export default function (pi: ExtensionAPI) {
463
497
  required: ["stage"],
464
498
  additionalProperties: false,
465
499
  },
466
- async execute(_toolCallId: string, raw: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
467
- const params = raw as Params;
468
- const { stage } = params;
469
- if (!(STAGES as readonly string[]).includes(stage)) throw new Error(`unknown stage \`${String(stage)}\`; declare one of ${STAGES.join(", ")}`);
470
- let book = openLoop(ctx);
471
- if (!book && stage === "kind") {
472
- // A todo create in the same batch may not have landed yet; whichever comes first opens the loop.
473
- if (!params.kind || !params.intent) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
474
- book = createBook(ctx);
475
- recordPriorAnchor(book, ctx);
476
- }
477
- if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create or `kind`.");
478
- const had = currentKind(book.entries);
479
- const sequence = SEQUENCES[had ?? "inline"];
480
- if (stage === "read") {
481
- const lines = [...summaryLines(book, ctx), ...taskLines(book.entries, todosOf(ctx), ctx.cwd)];
482
- if (params.view === "all") lines.push("entries:", ...book.entries.map((entry) => JSON.stringify(entry).slice(0, 300)));
483
- return reply(lines.join("\n"));
484
- }
485
- if (stage === "close") {
486
- if (!params.verdict) throw new Error("close needs a verdict: landed, blocked, or abandoned");
487
- const closed = close(book, ctx, params.verdict);
488
- return { content: [{ type: "text" as const, text: closed.text }], details: closed.snapshot ? { snapshot: closed.snapshot } : undefined };
489
- }
490
- if (stage === "kind") {
491
- if (!params.kind && !params.intent && !params.anchor && !params.reason) throw new Error("kind needs `kind`, `intent`, `anchor`, or `reason`");
492
- if (!had && (!params.kind || !params.intent)) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
493
- const changed = had !== undefined && params.kind !== undefined && had !== params.kind;
494
- if (changed && !params.reason) throw new Error(`changing the kind from ${had} to ${params.kind} needs \`reason\`: the reach grew, the user added scope, a user-owned decision appeared, or the change turned out smaller`);
495
- touchRepo(book, join(ctx.cwd, "_"));
496
- const notes: string[] = [];
497
- if (params.kind || params.intent || params.reason) {
498
- // A reason answers the reach measured now, so a later threshold asks again.
499
- const answer = params.reason ? { reason: params.reason, crossed: reachOf(book.entries, ctx.cwd).crossed } : {};
500
- appendEntry(book.path, "kind", { kind: params.kind ?? had, ...(params.intent ? { intent: params.intent } : {}), ...answer });
501
- const why = params.reason ? ` (${params.reason})` : "";
502
- notes.push(changed ? `kind changed: ${had} → ${params.kind}${why}` : `kind: ${params.kind ?? had}${why}`);
503
- }
504
- if (params.anchor) {
505
- appendEntry(book.path, "anchor", { check: params.anchor });
506
- notes.push(`anchor: ${params.anchor}`);
507
- }
508
- return reply(notes.join("\n"));
509
- }
510
- if (stage === "verify") {
511
- if (!params.note) throw new Error("verify needs a `note` saying what was observed");
512
- const result = params.result ?? "pass";
513
- const recorded = childId() !== undefined || had === "quick" ? "check" : "verify";
514
- const entry = appendEntry(book.path, recorded, { declared: true, note: params.note, result, exit: result === "pass" ? 0 : 1, ...reachField(book, ctx.cwd) });
515
- book.entries.push(entry);
516
- const notice = reachNotice(book, entry);
517
- return reply(`${recorded} declared: ${result} — ${params.note}${notice ? `\n${notice}` : ""}`);
518
- }
519
- if (stage === "fresh-read") {
520
- appendEntry(book.path, "goldfish", { source: "declared", ...(params.note ? { note: params.note } : {}) });
521
- return reply("fresh-read recorded.");
522
- }
523
- if (stage === "friction") {
524
- if (!params.tried || !params.found) throw new Error("friction needs `tried` and `found`");
525
- const id = frictionOf(book.entries).length + 1;
526
- appendEntry(book.path, "friction", { id, tried: params.tried, found: params.found, led_by: params.led_by || "none" });
527
- return reply(`friction #${id} recorded.`);
528
- }
529
- if (stage === "reflect") {
530
- const known = allFriction(book.entries).map((item) => item.ref);
531
- const ids = (params.friction ?? []).map(String);
532
- if (ids.length === 0 || !params.class) throw new Error(`reflect needs \`friction\` ids (${known.join(", ") || "none recorded"}) and \`class\``);
533
- const unknown = ids.filter((id) => !known.includes(id));
534
- if (unknown.length) throw new Error(`unknown friction ${unknown.join(", ")}; known: ${known.join(", ") || "none"}`);
535
- appendEntry(book.path, "reflect", { friction: ids, class: params.class, ...(params.action ? { action: params.action } : {}) });
536
- return reply(`reflect recorded for ${ids.join(", ")} (${params.class}).`);
500
+ async execute(toolCallId: string, raw: unknown, signal: unknown, onUpdate: unknown, ctx: ExtensionContext) {
501
+ try {
502
+ return await stageTool(toolCallId, raw, signal, onUpdate, ctx);
503
+ } finally {
504
+ guarded(showStatus)(ctx);
537
505
  }
538
- // skip and back
539
- const phase = params.phase;
540
- const known = sequence.filter((name) => name !== "close");
541
- if (!phase || !known.includes(phase)) throw new Error(`${stage} needs \`phase\`, one of ${known.join(", ")} for a ${had ?? "undeclared"} loop`);
542
- if (!params.reason) throw new Error(`${stage} needs a \`reason\``);
543
- appendEntry(book.path, stage, { phase, reason: params.reason });
544
- return reply(stage === "skip" ? `${phase}: skipped (${params.reason}).` : `back to ${phase} (${params.reason}); later phases count as incomplete until their conditions hold again.`);
545
506
  },
546
507
  });
547
508
 
509
+ async function stageTool(_toolCallId: string, raw: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
510
+ const params = raw as Params;
511
+ const { stage } = params;
512
+ if (!(STAGES as readonly string[]).includes(stage)) throw new Error(`unknown stage \`${String(stage)}\`; declare one of ${STAGES.join(", ")}`);
513
+ let book = openLoop(ctx);
514
+ if (!book && stage === "kind") {
515
+ // A todo create in the same batch may not have landed yet; whichever comes first opens the loop.
516
+ if (!params.kind || !params.intent) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
517
+ book = createBook(ctx);
518
+ recordPriorAnchor(book, ctx);
519
+ }
520
+ if (!book) return reply("No loop is open, so nothing was recorded. A loop opens with the first `todo` create or `kind`.");
521
+ const had = currentKind(book.entries);
522
+ const sequence = SEQUENCES[had ?? "inline"];
523
+ if (stage === "read") {
524
+ const lines = [...summaryLines(book, ctx), ...taskLines(book.entries, todosOf(ctx), ctx.cwd)];
525
+ if (params.view === "all") lines.push("entries:", ...book.entries.map((entry) => JSON.stringify(entry).slice(0, 300)));
526
+ return reply(lines.join("\n"));
527
+ }
528
+ if (stage === "close") {
529
+ if (!params.verdict) throw new Error("close needs a verdict: landed, blocked, or abandoned");
530
+ const closed = close(book, ctx, params.verdict);
531
+ return { content: [{ type: "text" as const, text: closed.text }], details: closed.snapshot ? { snapshot: closed.snapshot } : undefined };
532
+ }
533
+ if (stage === "kind") {
534
+ if (!params.kind && !params.intent && !params.anchor && !params.reason) throw new Error("kind needs `kind`, `intent`, `anchor`, or `reason`");
535
+ if (!had && (!params.kind || !params.intent)) throw new Error("the first kind declaration needs `kind` and a one-line `intent`");
536
+ const changed = had !== undefined && params.kind !== undefined && had !== params.kind;
537
+ if (changed && !params.reason) throw new Error(`changing the kind from ${had} to ${params.kind} needs \`reason\`: the reach grew, the user added scope, a user-owned decision appeared, or the change turned out smaller`);
538
+ touchRepo(book, join(ctx.cwd, "_"));
539
+ const notes: string[] = [];
540
+ if (params.kind || params.intent || params.reason) {
541
+ // A reason answers the reach measured now, so a later threshold asks again.
542
+ const answer = params.reason ? { reason: params.reason, crossed: reachOf(book.entries, ctx.cwd).crossed } : {};
543
+ appendEntry(book.path, "kind", { kind: params.kind ?? had, ...(params.intent ? { intent: params.intent } : {}), ...answer });
544
+ const why = params.reason ? ` (${params.reason})` : "";
545
+ notes.push(changed ? `kind changed: ${had} → ${params.kind}${why}` : `kind: ${params.kind ?? had}${why}`);
546
+ }
547
+ if (params.anchor) {
548
+ appendEntry(book.path, "anchor", { check: params.anchor });
549
+ notes.push(`anchor: ${params.anchor}`);
550
+ }
551
+ return reply(notes.join("\n"));
552
+ }
553
+ if (stage === "verify") {
554
+ if (!params.note) throw new Error("verify needs a `note` saying what was observed");
555
+ const result = params.result ?? "pass";
556
+ const recorded = childId() !== undefined || had === "quick" ? "check" : "verify";
557
+ const entry = appendEntry(book.path, recorded, { declared: true, note: params.note, result, exit: result === "pass" ? 0 : 1, ...reachField(book, ctx.cwd) });
558
+ book.entries.push(entry);
559
+ const notice = reachNotice(book, entry);
560
+ return reply(`${recorded} declared: ${result} — ${params.note}${notice ? `\n${notice}` : ""}`);
561
+ }
562
+ if (stage === "fresh-read") {
563
+ appendEntry(book.path, "goldfish", { source: "declared", ...(params.note ? { note: params.note } : {}) });
564
+ return reply("fresh-read recorded.");
565
+ }
566
+ if (stage === "friction") {
567
+ if (!params.tried || !params.found) throw new Error("friction needs `tried` and `found`");
568
+ const id = frictionOf(book.entries).length + 1;
569
+ appendEntry(book.path, "friction", { id, tried: params.tried, found: params.found, led_by: params.led_by || "none" });
570
+ return reply(`friction #${id} recorded.`);
571
+ }
572
+ if (stage === "reflect") {
573
+ const known = allFriction(book.entries).map((item) => item.ref);
574
+ const ids = (params.friction ?? []).map(String);
575
+ if (ids.length === 0 || !params.class) throw new Error(`reflect needs \`friction\` ids (${known.join(", ") || "none recorded"}) and \`class\``);
576
+ const unknown = ids.filter((id) => !known.includes(id));
577
+ if (unknown.length) throw new Error(`unknown friction ${unknown.join(", ")}; known: ${known.join(", ") || "none"}`);
578
+ appendEntry(book.path, "reflect", { friction: ids, class: params.class, ...(params.action ? { action: params.action } : {}) });
579
+ return reply(`reflect recorded for ${ids.join(", ")} (${params.class}).`);
580
+ }
581
+ // skip and back
582
+ const phase = params.phase;
583
+ const known = sequence.filter((name) => name !== "close");
584
+ if (!phase || !known.includes(phase)) throw new Error(`${stage} needs \`phase\`, one of ${known.join(", ")} for a ${had ?? "undeclared"} loop`);
585
+ if (!params.reason) throw new Error(`${stage} needs a \`reason\``);
586
+ appendEntry(book.path, stage, { phase, reason: params.reason });
587
+ return reply(stage === "skip" ? `${phase}: skipped (${params.reason}).` : `back to ${phase} (${params.reason}); later phases count as incomplete until their conditions hold again.`);
588
+ }
589
+
548
590
  pi.on(
549
591
  "session_start",
550
592
  guarded((_event, ctx) => {
@@ -554,6 +596,7 @@ export default function (pi: ExtensionAPI) {
554
596
  ...swept.open.map((path) => `open loop book: ${path}`),
555
597
  ];
556
598
  if (lines.length > 0) ctx.ui.notify(`anchorloop books\n${lines.join("\n")}`, "info");
599
+ showStatus(ctx);
557
600
  }),
558
601
  );
559
602
 
@@ -589,16 +632,23 @@ export default function (pi: ExtensionAPI) {
589
632
  const exit = bashExit(event);
590
633
  const last = lastLine(output);
591
634
  const anchor = anchorCommand(book.entries);
592
- let notice: string | undefined;
635
+ const notices: string[] = [];
636
+ // A failed run keeps its key line, so a rerun that passes can name what failed without the whole output.
637
+ const keyOf = (code: unknown) => (typeof code === "number" && code !== 0 ? { key: keyLine(output) } : {});
593
638
  if (anchor && anchorRun(command, anchor)) {
594
639
  const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
595
- const entry = appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: anchorExit(command, anchor, exit), last, ...reachField(book, cwd) });
640
+ const code = anchorExit(command, anchor, exit);
641
+ const entry = appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: code, last, ...keyOf(code), ...reachField(book, cwd) });
596
642
  book.entries.push(entry);
597
- notice = reachNotice(book, entry);
643
+ const reach = reachNotice(book, entry);
644
+ if (reach) notices.push(reach);
598
645
  }
599
646
  for (const task of latestTasks(book.entries)) {
600
- if (typeof task.anchor === "string" && anchorRun(command, task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit: anchorExit(command, task.anchor, exit), last });
647
+ if (typeof task.anchor !== "string" || !anchorRun(command, task.anchor)) continue;
648
+ const code = anchorExit(command, task.anchor, exit);
649
+ book.entries.push(appendEntry(book.path, "taskrun", { id: task.id, command: task.anchor.slice(0, 200), exit: code, last, ...keyOf(code) }));
601
650
  }
651
+ notices.push(...autoFriction(book));
602
652
  const merged = mergeRun(command);
603
653
  if (exit === 0 && merged && !/Already up[ -]to[ -]date/i.test(last ?? "") && book.entries.at(-1)?.stage !== "edit") {
604
654
  touchRepoBeforeMerge(book, resolve(String(book.header.cwd ?? process.cwd()), merged.dir), merged.verb);
@@ -608,7 +658,54 @@ export default function (pi: ExtensionAPI) {
608
658
  const cwd = String(book.header.cwd ?? "");
609
659
  if (diffAgainstBases(book.entries).paths.some((path) => !outsideWork(path, cwd))) appendEntry(book.path, "edit", { via: "commit" });
610
660
  }
611
- return notice;
661
+ return notices.join("\n") || undefined;
662
+ }
663
+
664
+ /**
665
+ * Records one friction line for each anchor that passed again after it had passed and then failed. A pair is recorded
666
+ * once, and not at all when the agent recorded a friction entry itself after the failure. The pairs one command
667
+ * completes in one bash call (it is both the loop and a task anchor) share one entry. Returns the lines that tell the agent.
668
+ */
669
+ function autoFriction(book: Book): string[] {
670
+ const lines: string[] = [];
671
+ const open = new Map<string, string[]>();
672
+ for (const pair of retryPairs(book.entries)) {
673
+ const id = `${pair.key}#${pair.fail}`;
674
+ const recorded = book.entries.some(
675
+ (entry, index) =>
676
+ entry.stage === "friction" && ((Array.isArray(entry.pairs) && entry.pairs.includes(id)) || (index > pair.fail && entry.auto !== true)),
677
+ );
678
+ if (recorded) continue;
679
+ const command = String(book.entries[pair.fail].command ?? pair.key);
680
+ open.set(command, [...(open.get(command) ?? []), id]);
681
+ }
682
+ for (const [command, pairs] of open) {
683
+ // The newest failure speaks for the entry; an older pair it backfills is listed in `pairs`.
684
+ const newest = pairs.at(-1) as string;
685
+ const failed = book.entries[Number(newest.slice(newest.lastIndexOf("#") + 1))];
686
+ const tried = clip(`rerun of ${command}`, 80);
687
+ // A failure recorded before 2.4 has no key line; its last line gets the same filter.
688
+ const found = clip(String(failed.key ?? keyLine(String(failed.last ?? ""))) || "failed with no output");
689
+ const number = frictionOf(book.entries).length + 1;
690
+ book.entries.push(appendEntry(book.path, "friction", { id: number, tried, found, led_by: "none", auto: true, pairs }));
691
+ lines.push(`anchorloop: friction #${number} recorded from the ${tried}: ${found}. Classify it with \`reflect\` before close.`);
692
+ }
693
+ return lines;
694
+ }
695
+
696
+ /** The footer line: the loop's kind, its last complete phase, the next one, and the friction still to reflect. */
697
+ function showStatus(ctx: ExtensionContext): void {
698
+ if (typeof ctx.ui?.setStatus !== "function") return;
699
+ const book = openLoop(ctx);
700
+ if (!book) {
701
+ ctx.ui.setStatus(STATUS_KEY, undefined);
702
+ return;
703
+ }
704
+ const phases = phasesOf(book, ctx);
705
+ const open = allFriction(book.entries).filter((item) => !item.reflected).length;
706
+ const kind = phases.kind === "undeclared" ? "no kind" : phases.kind;
707
+ const at = `${phases.current}${phases.current === "start" ? "" : " ✓"} → ${phases.next}`;
708
+ ctx.ui.setStatus(STATUS_KEY, `anchorloop ${kind} · ${at}${open ? ` · ${open} friction to reflect` : ""}`);
612
709
  }
613
710
 
614
711
  pi.on(
@@ -661,6 +758,14 @@ export default function (pi: ExtensionAPI) {
661
758
  }),
662
759
  );
663
760
 
761
+ // Registered after the recording handler, so the footer reads what that tool call just recorded.
762
+ pi.on(
763
+ "tool_result",
764
+ guarded((_event, ctx) => {
765
+ showStatus(ctx);
766
+ }),
767
+ );
768
+
664
769
  async function recordReview(ctx: ExtensionContext, book: Book, name: string, text: string, failed: boolean): Promise<void> {
665
770
  const counts = parseBlocking(text);
666
771
  let blocking: boolean | null = null;
@@ -106,7 +106,7 @@ export function continues(verdict: Verdict, snap: Snapshot): boolean {
106
106
 
107
107
  /** One line: the program's next phase and its gap, Jev's move and scores, and, away from close, the call to act. */
108
108
  export function message(verdict: Verdict, snap: Snapshot, found: string[], notes: string[], atClose = false): string {
109
- const extra = [...found, ...notes, ...snap.hints];
109
+ const extra = [...found, ...notes];
110
110
  return (
111
111
  `Second thought (Jev, advisory): next → ${snap.next}${snap.missing ? ` (missing: ${snap.missing})` : ""}; ` +
112
112
  `${verdict.move}; done ${verdict.done.toFixed(2)}, review ${verdict.review}, quality ${verdict.quality.toFixed(2)}/3` +
@@ -499,12 +499,6 @@ export function retryPairs(entries: Entry[]): RetryPair[] {
499
499
  return pairs;
500
500
  }
501
501
 
502
- /** A retry pair that no friction entry follows. */
503
- export function retryWithoutFriction(entries: Entry[]): boolean {
504
- const lastFriction = lastIndex(entries, "friction");
505
- return retryPairs(entries).some((pair) => pair.pass > lastFriction);
506
- }
507
-
508
502
  // --- todos ---------------------------------------------------------------------------------------
509
503
 
510
504
  export type Task = { id: number; subject: string; status: string; metadata?: Record<string, unknown> };
@@ -23,7 +23,6 @@ import {
23
23
  lastActIndex,
24
24
  lastIndex,
25
25
  repoRootOf,
26
- retryWithoutFriction,
27
26
  specFolder,
28
27
  withChildActs,
29
28
  } from "./book.ts";
@@ -47,8 +46,6 @@ export type Phases = {
47
46
  missing: string;
48
47
  skipped: string[];
49
48
  backs: string[];
50
- /** Advisory hints that are not phases. */
51
- hints: string[];
52
49
  };
53
50
 
54
51
  export type Inputs = { entries: Entry[]; cwd: string; todos: Task[]; env?: NodeJS.ProcessEnv };
@@ -347,7 +344,6 @@ export function computePhases(book: Pick<Book, "entries">, inputs: Omit<Inputs,
347
344
  if (next === "fix") note = first?.note ?? "";
348
345
  const skipped = statuses.filter((status) => status.state === "skipped" && status.note !== "no friction").map((status) => `${status.name} (${status.note})`);
349
346
  const backs = book.entries.filter((entry) => entry.stage === "back").map((entry) => `${entry.phase} (${entry.reason})`);
350
- const hints = retryWithoutFriction(book.entries) ? ["friction: a failed run was retried and passed, with no friction entry after it"] : [];
351
347
  return {
352
348
  kind: kind ?? "undeclared",
353
349
  statuses,
@@ -356,7 +352,6 @@ export function computePhases(book: Pick<Book, "entries">, inputs: Omit<Inputs,
356
352
  missing: note,
357
353
  skipped,
358
354
  backs,
359
- hints,
360
355
  };
361
356
  }
362
357
 
@@ -25,8 +25,6 @@ export type Snapshot = {
25
25
  /** The program's next phase and what it lacks, in a few words. */
26
26
  next: string;
27
27
  missing: string;
28
- /** Advisory hints that are not phases, such as an unrecorded friction. */
29
- hints: string[];
30
28
  /** The last phase the book shows complete. */
31
29
  current: string;
32
30
  /** A review recorded after the last change with no blocking finding on it. */
@@ -64,7 +62,6 @@ export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBr
64
62
  },
65
63
  next: phases.next,
66
64
  missing: phases.missing,
67
- hints: phases.hints,
68
65
  current: phases.current,
69
66
  reviewClear: reviewClearAfterLastAct(entries),
70
67
  };
@@ -29,7 +29,7 @@ The reply names a [principle](PRINCIPLES.md) only with the decision it changed.
29
29
 
30
30
  ### Friction Notes
31
31
 
32
- Each time an attempt fails and a later attempt reveals why, record it with `anchorloop_stage friction` before continuing.
32
+ Each time an attempt fails and a later attempt reveals why, record it with `anchorloop_stage friction` before continuing. An anchor that passed, failed, and passed again is recorded by the extension; reflect on that entry like any other.
33
33
 
34
34
  ### Decision Trail
35
35