pi-anchorloop 2.2.2 → 2.3.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,7 +25,7 @@ shared grilling · domain-modeling · human-feedback · agent-terse
25
25
 
26
26
  | File | What it does |
27
27
  | --- | --- |
28
- | `pi/book.ts` | The loop book. The first `todo` create 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>`). |
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>`). |
29
29
  | `pi/anchor-gate.ts` | Inside an open loop, shows one warning line when `write` or `edit` starts the `act` phase while an earlier phase of the loop kind is incomplete: no anchor (`anchor` on the `kind` call, a `Reality anchor:` line, or a skip), and in a `durable` loop a missing decision, spec, fresh read, approval, or design, read from the `docs/specs/<folder>/` files (found from the book, the intent, or a todo `ref`). It warns again only when a new phase goes missing; the call still goes through. Outside a loop and in `quick` loops it stays silent. `.md`, `.txt`, and `.html` files, `scratch/`, `docs/specs/`, and temp files outside the project pass silently. Edits made through `bash` are not checked. |
30
30
  | `pi/decision-tool.ts` | The `anchorloop_decision` tool appends single-line, spreadsheet-safe rows to a decision trail TSV, or a `start` row when a run picks up another run's trail. |
31
31
  | `pi/second-thought.ts` | Inside an open loop, asks Jev (TypeSafe's typed-decision model, through pi's classifier registry) to check the phase machine when a run settles and the loop book gained an entry since the last judgment, and at a `close` declaration. The program computes the next phase from the book; Jev sees the kind, intent, anchor, diff summary (≤1200 chars), `phase` (current, next, missing), verify state, open todos, the task list, and friction (a child's under its name), and judges only whether 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. |
package/README.zh-TW.md CHANGED
@@ -25,7 +25,7 @@ anchorloop 核心循環 · 原則 · playbook:autonomous-run、paus
25
25
 
26
26
  | 檔案 | 作用 |
27
27
  | --- | --- |
28
- | `pi/book.ts` | Loop book。第一次 `todo` create 或 `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 — <原因>`)時拒絕。 |
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 — <原因>`)時拒絕。 |
29
29
  | `pi/anchor-gate.ts` | 只在開啟的 loop 內:`write` 或 `edit` 開始 `act` 階段、而這種 loop 前面的階段還沒完成時,顯示一行警告:沒有 anchor(kind 呼叫的 `anchor`、`Reality anchor:` 這一行,或已跳過),`durable` loop 則還缺決策、spec、全新 context 的閱讀、核准或設計(從 `docs/specs/<資料夾>/` 的檔案判斷,資料夾由 book、intent 或 todo 的 `ref` 找出)。只有新缺了一個階段時才會再警告;呼叫仍會放行。沒有 loop 或是 `quick` loop 時保持安靜。`.md`、`.txt`、`.html` 檔、`scratch/`、`docs/specs/` 和專案外的暫存檔不會觸發。透過 `bash` 做的修改不檢查。 |
30
30
  | `pi/decision-tool.ts` | `anchorloop_decision` 工具把一筆決策寫進 TSV 決策軌跡,每筆只佔一行,用試算表開啟也安全;接手別人的軌跡時寫一筆 `start`。 |
31
31
  | `pi/second-thought.ts` | 只在開啟的 loop 內:一次執行結束且 book 自上次判斷後多了條目時,以及宣告 `close` 時,透過 pi 的 classifier registry 請 Jev(TypeSafe 的型別化決策模型)複核階段機。下一個階段由程式從 book 算出;Jev 看到種類、intent、anchor、diff 摘要(≤1200 字元)、`phase`(目前、下一個、缺什麼)、verify 狀態、未完成 todo、任務清單和 friction(子 agent 的以它的名字列出),只判斷要 `advance`、退回(`back`)還是 `ask-user`,另外還問 `done`、還需要哪一級 review 與品質;階段是否完成只由程式依證據判定。一行提醒以 `next → <階段>` 開頭;有 retry pair 卻沒有 friction 條目時,附上 `friction` 提示。只有 Jev 同意前進、而下一個階段是 verify、review 或 fix 時,才讓 agent 多跑一輪;與上一則相同的提醒不重複,最後一次修改之後已有無 blocking 發現的 review 時,`full` 評等只顯示備註、不要求複查。呼叫過 `MonitorCreate`、`LoopCreate`、`goal_wait` 或 `subagent` 工具的執行在等待中,不被判斷。沒有 TypeSafe key 時什麼都不做,沒有 loop 時不會執行。subagent session(pi-herdr-agents 的 `PI_SUBAGENT_ID`,或 pi-subagents 的 `PI_SUBAGENT_CHILD`)不註冊任何東西,因為由派它的 agent 判斷結果。 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anchorloop",
3
- "version": "2.2.2",
3
+ "version": "2.3.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
@@ -13,6 +13,7 @@ import { join, resolve } from "node:path";
13
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import {
15
15
  type Book,
16
+ type Entry,
16
17
  KINDS,
17
18
  allFriction,
18
19
  anchorCommand,
@@ -26,7 +27,7 @@ import {
26
27
  currentKind,
27
28
  deleteBook,
28
29
  diffAgainstBases,
29
- findChildBook,
30
+ findChildBooks,
30
31
  frictionOf,
31
32
  guarded,
32
33
  isOpenTask,
@@ -50,7 +51,7 @@ import {
50
51
  verifyAfterLastAct,
51
52
  } from "./support/book.ts";
52
53
  import { classify } from "./support/jev.ts";
53
- import { SEQUENCES, computePhases, describe, incompleteBefore, outsideWork } from "./support/phases.ts";
54
+ import { REVIEWLESS, SEQUENCES, computePhases, describe, incompleteBefore, outsideWork, reachLine, reachOf, unansweredReach } from "./support/phases.ts";
54
55
  import { type Snapshot, snapshot } from "./support/snapshot.ts";
55
56
  import { isSubagent } from "./support/subagent.ts";
56
57
 
@@ -116,45 +117,126 @@ export function bashExit(event: { isError?: boolean; structuredContent?: unknown
116
117
  return named ? Number(named[1]) : null;
117
118
  }
118
119
 
119
- /** The command with quoted strings blanked out, keeping every index, so quoted text never counts as a run or a separator. */
120
+ /** The command with quoted strings and comments blanked out, keeping every index, so quoted text never counts as a run or a separator. */
120
121
  function unquoted(command: string): string {
121
122
  // One left-to-right pass, so whichever starts first wins: a quote, or a `#` that starts a word and comments out its line.
122
123
  // Newlines inside quotes are kept, since they still separate commands for the scan.
123
124
  return command.replace(/'[^']*'|"(?:\\.|[^"\\])*"|(^|\s)#[^\n]*/g, (span) => span.replace(/[^\n]/g, " "));
124
125
  }
125
126
 
126
- /** Text before a run with `VAR=value` and simple wrappers (`uv run`, `time`, `timeout N`, `env`) peeled off its end. */
127
+ /** Runs of spaces and tabs collapsed, so `pytest -q` is `pytest -q`; applied to the command and the anchor alike. */
128
+ const squeezed = (text: string): string => text.replace(/[ \t]+/g, " ");
129
+
130
+ /** Text before a run with `VAR=value` and simple wrappers (`uv run`, `time`, `timeout N`, `env`, `npx`, `pnpm exec`, `bunx`) peeled off its end. */
127
131
  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+)+$/, "");
132
+ return text.replace(/(\b\w+=\S*\s+|\buv\s+run\s+|\btime\s+|\btimeout\s+[^\s;&|]+\s+|\benv\s+|\b(?:npx|bunx)\s+(?:-\S+\s+)*|\bpnpm\s+exec\s+)+$/, "");
129
133
  }
130
134
 
131
135
  // `cd` and `export` change what a check tests, so they are not setup.
132
136
  const SETUP_SEGMENT = /^\s*(\.|source\b|set\b|\w+=)/;
133
137
 
138
+ // A command position: the start, after a separator, a `{` or `(`, or after `if`, `then`, `while` and the like.
139
+ // A `{` counts only where it opens a group itself, not as a literal argument (`echo { x }`).
140
+ const SEPARATOR_OR_GROUP = String.raw`(?:^|&&|\|\||[;|!\n(])(?:\s*\{)?`;
141
+ const COMMAND_POSITION = new RegExp(String.raw`${SEPARATOR_OR_GROUP}\s*((if|then|elif|else|while|until|do)\s+)*$`);
142
+ // What voids the exit of a run after it: `x || run` may never run it, `! run` inverts its code.
143
+ const VOIDS_EXIT = /(!|\|\|)\s*(\(\s*|cd\s+\S+\s*&&\s*)*$/;
144
+ // `x=$(run)` as a whole command: the command's exit is the substitution's.
145
+ const ASSIGNMENT = new RegExp(String.raw`(${SEPARATOR_OR_GROUP})(\s*(?:\w+=\S*\s+)*\w+=)$`);
146
+
147
+ type Opening = { open: number; sub: boolean };
148
+
149
+ /** The parentheses still open at `at`; `sub` marks `$(`, `<(` and `>(`, whose exit is not the call's. */
150
+ function openings(masked: string, at: number): Opening[] {
151
+ const stack: Opening[] = [];
152
+ for (let i = 0; i < at; i++) {
153
+ if (masked[i] === "(") stack.push({ open: i, sub: i > 0 && "$<>".includes(masked[i - 1]) });
154
+ else if (masked[i] === ")") stack.pop();
155
+ }
156
+ return stack;
157
+ }
158
+
159
+ /** The text with every closed parenthesized span blanked, so a `set -o pipefail` inside a finished subshell is not seen. */
160
+ function withoutClosedGroups(text: string): string {
161
+ const chars = [...text];
162
+ const stack: number[] = [];
163
+ chars.forEach((char, i) => {
164
+ if (char === "(") stack.push(i);
165
+ else if (char === ")" && stack.length > 0) chars.fill(" ", stack.pop() as number, i + 1);
166
+ });
167
+ return chars.join("");
168
+ }
169
+
170
+ /** The index of the `)` that closes the `(` at `open`, or undefined. */
171
+ function closing(masked: string, open: number): number | undefined {
172
+ let depth = 0;
173
+ for (let i = open; i < masked.length; i++) {
174
+ if (masked[i] === "(") depth++;
175
+ else if (masked[i] === ")" && --depth === 0) return i;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ type Located = { at: number; anchor: string; masked: string; leads: number[]; depth: number };
181
+
134
182
  /**
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.
183
+ * Where a run at `at` stands, or undefined when its exit is not the call's: inside `$(`, `<(` or `>(` unless the substitution is the
184
+ * whole of an assignment (`x=$(run)`). `leads` are the positions whose prefix may void the exit: the run, each enclosing `(`, the assignment.
138
185
  */
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()];
186
+ function placement(masked: string, at: number): Pick<Located, "leads" | "depth"> | undefined {
187
+ const stack = openings(masked, at);
188
+ const leads = [at];
189
+ const subs = stack.filter((group) => group.sub);
190
+ if (subs.length > 1 || (subs.length === 1 && stack.at(-1) !== subs[0])) return undefined;
191
+ for (const group of stack) {
192
+ if (!group.sub) {
193
+ leads.push(group.open);
194
+ continue;
195
+ }
196
+ const assignment = masked[group.open - 1] === "$" ? ASSIGNMENT.exec(masked.slice(0, group.open - 1)) : null;
197
+ if (!assignment || beforeRun(masked.slice(group.open + 1, at)).trim() !== "") return undefined;
198
+ // Only redirects may follow the substitution; a further word, assignment, or substitution makes its own exit the call's.
199
+ const close = closing(masked, group.open);
200
+ if (close === undefined) return undefined;
201
+ const tail = masked.slice(close + 1).split(/&&|\|\||[;|\n]|(?<![<>])&(?![>&])/)[0];
202
+ if (!/^(\s*\d*[<>]{1,2}&?\s*[^\s$`(<>]+)*\s*$/.test(tail)) return undefined;
203
+ leads.push(assignment.index + assignment[1].length);
204
+ }
205
+ return { leads, depth: stack.length };
206
+ }
207
+
208
+ function locate(command: string, anchorText: string): Located | undefined {
209
+ const text = squeezed(command);
210
+ const masked = unquoted(text);
211
+ const segments = squeezed(anchorText).split("&&").map((segment) => segment.trim());
212
+ const candidates = [squeezed(anchorText).trim()];
143
213
  if (segments.length > 1 && segments.slice(0, -1).every((segment) => SETUP_SEGMENT.test(segment))) candidates.push(segments.at(-1)!);
144
214
  for (const anchor of candidates) {
145
215
  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));
216
+ let found: Located | undefined;
217
+ for (let at = text.indexOf(anchor); at >= 0; at = text.indexOf(anchor, at + 1)) {
218
+ // The anchor's own quotes match the command's; only its first character must lie outside quotes and comments.
219
+ if (masked.charAt(at) !== text.charAt(at)) continue;
149
220
  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;
221
+ if (!COMMAND_POSITION.test(beforeRun(masked.slice(0, at))) || !(after === "" || /[\s;&|)]/.test(after))) continue;
222
+ const where = placement(masked, at);
223
+ if (where) found = { at, anchor, masked, ...where };
152
224
  }
153
- if (found !== undefined) return { at: found, anchor };
225
+ if (found) return found;
154
226
  }
155
227
  return undefined;
156
228
  }
157
229
 
230
+ /**
231
+ * Where `command` runs `anchor` as a command (at the start, or after `&&`, `||`, `;`, `|`, `!`, `(`, `{`, a newline or `then`, outside quotes,
232
+ * past any `VAR=value` prefixes and wrappers), as the last such run, or undefined. The full anchor is tried first; an anchor whose earlier
233
+ * `&&` segments only set up the environment (`. venv/bin/activate && pytest -q`) also matches on its last segment.
234
+ */
235
+ export function anchorRun(command: string, anchorText: string): { at: number; anchor: string } | undefined {
236
+ const found = locate(command, anchorText);
237
+ return found && { at: found.at, anchor: found.anchor };
238
+ }
239
+
158
240
  /**
159
241
  * The exit to record for a bash call that ran `anchor`: the call's exit code, except `unknown` when something after the anchor
160
242
  * can replace it: `||`, `;`, a newline, `&`, or a pipe straight after the anchor unless `set -o pipefail` is in effect before it.
@@ -162,17 +244,15 @@ export function anchorRun(command: string, anchorText: string): { at: number; an
162
244
  */
163
245
  export function anchorExit(command: string, anchorText: string, exit: number | null): number | "unknown" {
164
246
  if (exit === null) return "unknown";
165
- const run = anchorRun(command, anchorText);
247
+ const run = locate(command, anchorText);
166
248
  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";
249
+ const { at, anchor, masked, leads, depth } = run;
250
+ // `! anchor` inverts the code and `x || anchor` may never run it; the same holds for the group or assignment around it.
251
+ if (leads.some((lead) => VOIDS_EXIT.test(beforeRun(masked.slice(0, lead))))) return "unknown";
171
252
  // 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
253
  const rest = masked.slice(at + anchor.length);
174
254
  // 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)];
255
+ const toggles = [...withoutClosedGroups(masked.slice(0, at)).matchAll(/\bset\b[^;&|\n]*?([-+])\w*o\s+pipefail\b/g)];
176
256
  if (depth > 0 && toggles.length === 0 && /\|(?!\|)/.test(rest)) return "unknown";
177
257
  const pipefail = toggles.at(-1)?.[1] === "-";
178
258
  let chained = false;
@@ -187,6 +267,29 @@ export function anchorExit(command: string, anchorText: string, exit: number | n
187
267
  return exit;
188
268
  }
189
269
 
270
+ const GIT_ARG = String.raw`(?:"[^"]*"|'[^']*'|\S+)`;
271
+ // Global options git accepts before the verb: `-C <dir>`, `-c <k=v>`, `--git-dir=<dir>`, `--no-pager`, and the like.
272
+ const GIT_OPTION = String.raw`\s+(?:-[Cc]\s+${GIT_ARG}|--(?:git-dir|work-tree|namespace|exec-path|config-env)(?:=|\s+)${GIT_ARG}|--(?:no-pager|paginate|no-replace-objects|bare|literal-pathspecs|no-optional-locks)|-p)`;
273
+ const GIT_MERGE = new RegExp(String.raw`\bgit((?:${GIT_OPTION})*)\s+(merge|cherry-pick|pull)(?=\s|$)`, "g");
274
+ // Arguments that make the verb do no work, or end one: only the words up to the next separator count.
275
+ const GIT_NO_WORK = /(^|\s)--(abort|quit|skip|dry-run)\b/;
276
+
277
+ /** The first `git merge`, `cherry-pick` or `pull` at a command position that does work, with the directory it runs in. */
278
+ export function mergeRun(command: string): { verb: string; dir: string } | undefined {
279
+ const masked = unquoted(command);
280
+ for (const match of command.matchAll(GIT_MERGE)) {
281
+ const at = match.index;
282
+ if (masked.slice(at, at + 3) !== "git" || !COMMAND_POSITION.test(beforeRun(masked.slice(0, at)))) continue;
283
+ if (GIT_NO_WORK.test(command.slice(at + match[0].length).split(/[|;&\n]/)[0])) continue;
284
+ // The repo is the nearest `cd` before the verb, moved on by each `-C` of the verb itself.
285
+ const cds = [...command.slice(0, at).matchAll(/\bcd\s+(\S+)/g)];
286
+ const unquote = (word: string) => word.replace(/^(["'])(.*)\1$/, "$2").replace(/^~(?=\/|$)/, homedir());
287
+ const dirs = [...match[1].matchAll(new RegExp(String.raw`-C\s+(${GIT_ARG})`, "g"))].map((found) => unquote(found[1]));
288
+ return { verb: match[2], dir: dirs.reduce((base, dir) => resolve(base, dir), unquote(cds.at(-1)?.[1] ?? ".")) };
289
+ }
290
+ return undefined;
291
+ }
292
+
190
293
  /** The text a `Reality anchor:` line states in an assistant message, if any. */
191
294
  function statedAnchor(message: { role?: string; content?: unknown } | undefined): string | undefined {
192
295
  if (message?.role !== "assistant") return undefined;
@@ -321,8 +424,7 @@ export default function (pi: ExtensionAPI) {
321
424
  const snap = kind === "quick" ? undefined : snapshot(book, ctx);
322
425
  pi.appendEntry(SESSION_ENTRY, summary);
323
426
  for (const id of childIds(book.entries)) {
324
- const child = findChildBook(id);
325
- if (child) deleteBook(child.path);
427
+ for (const { book: child } of findChildBooks(id)) deleteBook(child.path);
326
428
  }
327
429
  deleteBook(book.path);
328
430
  return { text: `${text} The book is deleted. Clear the todo list now (todo action: clear).${after}`, snapshot: snap };
@@ -333,7 +435,8 @@ export default function (pi: ExtensionAPI) {
333
435
  label: "Loop stage",
334
436
  description:
335
437
  "Declare only what the extension cannot observe in the anchorloop loop book. " +
336
- "`kind` (kind, intent, optional anchor command; call again to upgrade the kind or set the anchor): quick (act, check), inline (anchor, act, verify), reviewed (adds review), durable (adds decide, grill, spec, read, approve, design); every kind ends with reflect, close. " +
438
+ "`kind` (kind, intent, optional anchor command; call again to set the anchor, or with `reason` to change the kind or answer a re-classification): quick (act, check), inline (anchor, act, verify), reviewed (adds review), durable (adds decide, grill, spec, read, approve, design); every kind ends with reflect, close. " +
439
+ "When an anchor run or declared verify of a quick or inline loop finds the change reaching 3 source files, 2 modules, or a README, changelog, or *.schema.json beside code, its verify waits for a `kind` call with `reason`. " +
337
440
  "`verify` (note; only for an anchor that is not a command), `fresh-read` (a fresh-context read of the spec is done), `friction` (tried, found, led_by), `reflect` (friction ids, class misdirection|gap|resolved, action), " +
338
441
  "`skip` / `back` (phase, reason), `close` (verdict), `read` (view the book). Edits, anchor runs, reviews, and spec files are read from evidence. " +
339
442
  "A loop opens with the first `todo` create; `close` refuses while a pending or in-progress todo lacks metadata.followup.",
@@ -341,7 +444,7 @@ export default function (pi: ExtensionAPI) {
341
444
  type: "object",
342
445
  properties: {
343
446
  stage: { type: "string", enum: [...STAGES] },
344
- kind: { type: "string", enum: [...KINDS], description: "stage kind: the loop kind; a second call upgrades it" },
447
+ kind: { type: "string", enum: [...KINDS], description: "stage kind: the loop kind; a later call with `reason` changes it" },
345
448
  intent: { type: "string", description: "stage kind: one line on what the loop delivers" },
346
449
  anchor: { type: "string", description: "stage kind: the check command whose exit code observes the result" },
347
450
  note: { type: "string", description: "stage verify: what was observed; stage fresh-read: who read the spec" },
@@ -353,7 +456,7 @@ export default function (pi: ExtensionAPI) {
353
456
  class: { type: "string", enum: ["misdirection", "gap", "resolved"], description: "stage reflect" },
354
457
  action: { type: "string", description: "stage reflect: what was done, such as solid-loop, memory, or a commit" },
355
458
  phase: { type: "string", description: "stage skip or back: the phase" },
356
- reason: { type: "string", description: "stage skip or back: why" },
459
+ reason: { type: "string", description: "stage skip or back: why; stage kind: why the kind changes, or why it still fits the measured reach" },
357
460
  verdict: { type: "string", enum: ["landed", "blocked", "abandoned"], description: "stage close" },
358
461
  view: { type: "string", enum: ["summary", "all"], description: "stage read; default summary" },
359
462
  },
@@ -385,26 +488,33 @@ export default function (pi: ExtensionAPI) {
385
488
  return { content: [{ type: "text" as const, text: closed.text }], details: closed.snapshot ? { snapshot: closed.snapshot } : undefined };
386
489
  }
387
490
  if (stage === "kind") {
388
- if (!params.kind && !params.intent && !params.anchor) throw new Error("kind needs `kind`, `intent`, or `anchor`");
491
+ if (!params.kind && !params.intent && !params.anchor && !params.reason) throw new Error("kind needs `kind`, `intent`, `anchor`, or `reason`");
389
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, "_"));
390
496
  const notes: string[] = [];
391
- if (params.kind || params.intent) {
392
- appendEntry(book.path, "kind", { kind: params.kind ?? had, ...(params.intent ? { intent: params.intent } : {}) });
393
- notes.push(had && params.kind && had !== params.kind ? `kind changed: ${had} → ${params.kind}` : `kind: ${params.kind ?? had}`);
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}`);
394
503
  }
395
504
  if (params.anchor) {
396
505
  appendEntry(book.path, "anchor", { check: params.anchor });
397
506
  notes.push(`anchor: ${params.anchor}`);
398
507
  }
399
- touchRepo(book, join(ctx.cwd, "_"));
400
508
  return reply(notes.join("\n"));
401
509
  }
402
510
  if (stage === "verify") {
403
511
  if (!params.note) throw new Error("verify needs a `note` saying what was observed");
404
512
  const result = params.result ?? "pass";
405
513
  const recorded = childId() !== undefined || had === "quick" ? "check" : "verify";
406
- appendEntry(book.path, recorded, { declared: true, note: params.note, result, exit: result === "pass" ? 0 : 1 });
407
- return reply(`${recorded} declared: ${result} — ${params.note}`);
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}` : ""}`);
408
518
  }
409
519
  if (stage === "fresh-read") {
410
520
  appendEntry(book.path, "goldfish", { source: "declared", ...(params.note ? { note: params.note } : {}) });
@@ -457,34 +567,48 @@ export default function (pi: ExtensionAPI) {
457
567
  }),
458
568
  );
459
569
 
460
- /** Records what a finished bash call shows: runs of the loop anchor, of task anchors, and a first commit as the change. */
461
- function recordBash(book: Book, event: { input: unknown; isError?: boolean; structuredContent?: unknown; content?: unknown }): void {
570
+ /** The reach of an unreviewed loop's diff, recorded on its verify or check entry; a reviewed loop is never measured. */
571
+ function reachField(book: Book, cwd: string): { reach?: ReturnType<typeof reachOf> } {
572
+ return REVIEWLESS.has(currentKind(book.entries) ?? "inline") ? { reach: reachOf(book.entries, cwd) } : {};
573
+ }
574
+
575
+ /** The line that asks the loop to re-classify after a verify or check entry whose reach no `kind` answer covers. */
576
+ function reachNotice(book: Book, entry: Entry): string | undefined {
577
+ const reach = entry.reach as ReturnType<typeof reachOf> | undefined;
578
+ return reach && unansweredReach(book.entries, reach).length > 0 ? `anchorloop: ${reachLine(reach, currentKind(book.entries))}.` : undefined;
579
+ }
580
+
581
+ /**
582
+ * Records what a finished bash call shows: runs of the loop anchor, of task anchors, and a first commit as the change.
583
+ * Returns the re-classification line when an anchor run of an unreviewed loop measured a reach no `kind` answer covers.
584
+ */
585
+ function recordBash(book: Book, event: { input: unknown; isError?: boolean; structuredContent?: unknown; content?: unknown }, cwd: string): string | undefined {
462
586
  const structured = event.structuredContent as { output?: unknown } | undefined;
463
587
  const output = typeof structured?.output === "string" ? structured.output : textOf(event.content);
464
588
  const command = String((event.input as { command?: unknown }).command ?? "");
465
589
  const exit = bashExit(event);
466
590
  const last = lastLine(output);
467
591
  const anchor = anchorCommand(book.entries);
592
+ let notice: string | undefined;
468
593
  if (anchor && anchorRun(command, anchor)) {
469
594
  const recorded = childId() !== undefined || currentKind(book.entries) === "quick" ? "check" : "verify";
470
- book.entries.push(appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: anchorExit(command, anchor, exit), last }));
595
+ const entry = appendEntry(book.path, recorded, { command: anchor.slice(0, 200), exit: anchorExit(command, anchor, exit), last, ...reachField(book, cwd) });
596
+ book.entries.push(entry);
597
+ notice = reachNotice(book, entry);
471
598
  }
472
599
  for (const task of latestTasks(book.entries)) {
473
600
  if (typeof task.anchor === "string" && anchorRun(command, task.anchor)) appendEntry(book.path, "taskrun", { id: task.id, exit: anchorExit(command, task.anchor, exit), last });
474
601
  }
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]);
602
+ const merged = mergeRun(command);
603
+ if (exit === 0 && merged && !/Already up[ -]to[ -]date/i.test(last ?? "") && book.entries.at(-1)?.stage !== "edit") {
604
+ touchRepoBeforeMerge(book, resolve(String(book.header.cwd ?? process.cwd()), merged.dir), merged.verb);
482
605
  book.entries.push(appendEntry(book.path, "edit", { via: "merge" }));
483
606
  }
484
607
  if (exit === 0 && /\bgit\b[^|;&]*\bcommit\b/.test(command) && !book.entries.some((entry) => entry.stage === "edit")) {
485
608
  const cwd = String(book.header.cwd ?? "");
486
609
  if (diffAgainstBases(book.entries).paths.some((path) => !outsideWork(path, cwd))) appendEntry(book.path, "edit", { via: "commit" });
487
610
  }
611
+ return notice;
488
612
  }
489
613
 
490
614
  pi.on(
@@ -492,8 +616,9 @@ export default function (pi: ExtensionAPI) {
492
616
  guarded((event, ctx) => {
493
617
  if (event.toolName === "bash") {
494
618
  const book = openLoop(ctx);
495
- if (book) recordBash(book, event);
496
- return;
619
+ const notice = book ? recordBash(book, event, ctx.cwd) : undefined;
620
+ // The line only appends text, so the bash call's structured output passes on unchanged.
621
+ return notice ? { content: [...((event.content as unknown[]) ?? []), { type: "text", text: notice }], structuredContent: event.structuredContent } : undefined;
497
622
  }
498
623
  if (event.isError) return;
499
624
  if (event.toolName === "todo") {
@@ -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, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
10
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, 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,19 +120,21 @@ 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 {
123
+ /** Opens `path` for appending; only a new book may create the file, so a book swept away is never recreated without its header. */
124
+ function openForAppend(path: string, create: boolean): number {
125
+ const flags = create ? "a" : constants.O_WRONLY | constants.O_APPEND;
124
126
  try {
125
- return openSync(path, "a");
127
+ return openSync(path, flags);
126
128
  } 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
+ // Another session's sweep may remove the directory between mkdir and open; a new book recreates it once.
130
+ if (!create || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
129
131
  mkdirSync(dirname(path), { recursive: true });
130
- return openSync(path, "a");
132
+ return openSync(path, flags);
131
133
  }
132
134
  }
133
135
 
134
- function appendBytes(path: string, text: string): void {
135
- const fd = openForAppend(path);
136
+ function appendBytes(path: string, text: string, create = false): void {
137
+ const fd = openForAppend(path, create);
136
138
  try {
137
139
  const bytes = Buffer.from(text);
138
140
  const written = writeSync(fd, bytes);
@@ -144,7 +146,12 @@ function appendBytes(path: string, text: string): void {
144
146
 
145
147
  export function createBook(who: Who, env: NodeJS.ProcessEnv = process.env): Book {
146
148
  const path = bookPath(who, env);
147
- if (existsSync(path)) deleteBook(path);
149
+ if (existsSync(path)) {
150
+ // A child's closed book is kept for main to read; a new loop of the child must not delete it.
151
+ const old = parseBook(path);
152
+ if (childId(env) && old && isClosed(old)) setAside(path);
153
+ else deleteBook(path);
154
+ }
148
155
  mkdirSync(dirname(path), { recursive: true });
149
156
  const child = childId(env);
150
157
  const header = {
@@ -156,25 +163,58 @@ export function createBook(who: Who, env: NodeJS.ProcessEnv = process.env): Book
156
163
  opened: new Date().toISOString(),
157
164
  };
158
165
  const lines = ["# anchorloop loop book", ...Object.entries(header).map(([key, value]) => `${key}: ${JSON.stringify(value)}`), "entries:", ""];
159
- appendBytes(path, lines.join("\n"));
166
+ appendBytes(path, lines.join("\n"), true);
160
167
  return { path, header, entries: [] };
161
168
  }
162
169
 
163
170
  export function appendEntry(path: string, stage: string, fields: Record<string, unknown> = {}): Entry {
164
171
  const entry: Entry = { ts: new Date().toISOString(), stage, ...fields };
165
- appendBytes(path, `- ${JSON.stringify(entry)}\n`);
172
+ try {
173
+ appendBytes(path, `- ${JSON.stringify(entry)}\n`);
174
+ } catch (error) {
175
+ // The book was swept or deleted under this session; there is nothing left to record in.
176
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
177
+ }
166
178
  return entry;
167
179
  }
168
180
 
169
- /** The book of the child `id` among all projects, or undefined. */
170
- export function findChildBook(id: string, env: NodeJS.ProcessEnv = process.env): Book | undefined {
181
+ const ASIDE = /^(.+)\.closed-(\d+)$/;
182
+
183
+ /** Renames a closed book to the next free `<id>.closed-<n>.yml`, which main reads with the child's later loops. */
184
+ function setAside(path: string): void {
185
+ const stem = basename(path, ".yml");
186
+ let n = 1;
187
+ while (existsSync(join(dirname(path), `${stem}.closed-${n}.yml`))) n++;
188
+ renameSync(path, join(dirname(path), `${stem}.closed-${n}.yml`));
189
+ }
190
+
191
+ export type ChildBook = { book: Book; tag: string };
192
+
193
+ /** The books of the child `id` among all projects: its earlier closed loops (`~1`, `~2`) in order, then the current one (no tag). */
194
+ export function findChildBooks(id: string, env: NodeJS.ProcessEnv = process.env): ChildBook[] {
171
195
  const root = booksRoot(env);
172
- if (!existsSync(root)) return undefined;
196
+ if (!existsSync(root)) return [];
197
+ const found: ChildBook[] = [];
173
198
  for (const project of readdirSync(root)) {
174
- const book = parseBook(join(root, project, "agents", `${safe(id)}.yml`));
175
- if (book) return book;
199
+ const agents = join(root, project, "agents");
200
+ let files: string[];
201
+ try {
202
+ files = readdirSync(agents);
203
+ } catch {
204
+ continue;
205
+ }
206
+ const aside = files
207
+ .map((file) => ASIDE.exec(basename(file, ".yml")))
208
+ .filter((match): match is RegExpExecArray => match !== null && match[1] === safe(id))
209
+ .sort((a, b) => Number(a[2]) - Number(b[2]));
210
+ for (const match of aside) {
211
+ const book = parseBook(join(agents, `${match[0]}.yml`));
212
+ if (book) found.push({ book, tag: `~${match[2]}` });
213
+ }
214
+ const book = parseBook(join(agents, `${safe(id)}.yml`));
215
+ if (book) found.push({ book, tag: "" });
176
216
  }
177
- return undefined;
217
+ return found;
178
218
  }
179
219
 
180
220
  function removeIfEmpty(dir: string): boolean {
@@ -229,15 +269,19 @@ export function sweep(env: NodeJS.ProcessEnv = process.env, cwd?: string): Sweep
229
269
  if (!statSync(dir).isDirectory()) continue;
230
270
  for (const file of readdirSync(dir)) {
231
271
  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;
272
+ try {
273
+ const book = parseBook(join(dir, file));
274
+ if (!book) continue;
275
+ if (sessionGone(book)) {
276
+ deleteBook(book.path);
277
+ result.deleted.push(book.path);
278
+ continue;
279
+ }
280
+ if (!slug || project === slug) result.open.push(`${book.path} (${currentKind(book.entries) ?? "no kind"}: ${currentIntent(book.entries) ?? "no intent"})`);
281
+ for (const entry of book.entries) if (entry.stage === "link" && typeof entry.child === "string") linked.add(safe(entry.child));
282
+ } catch {
283
+ // This book vanished; the rest of the project still counts.
238
284
  }
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));
241
285
  }
242
286
  const agents = join(dir, "agents");
243
287
  if (existsSync(agents)) {
@@ -252,7 +296,8 @@ export function sweep(env: NodeJS.ProcessEnv = process.env, cwd?: string): Sweep
252
296
  }
253
297
  for (const child of children) {
254
298
  try {
255
- if (linked.has(basename(child.path, ".yml")) || !sessionGone(child)) continue;
299
+ const stem = basename(child.path, ".yml");
300
+ if (linked.has(ASIDE.exec(stem)?.[1] ?? stem) || !sessionGone(child)) continue;
256
301
  deleteBook(child.path);
257
302
  result.deleted.push(child.path);
258
303
  } catch {
@@ -304,8 +349,7 @@ export function lastIndex(entries: Entry[], ...stages: string[]): number {
304
349
  export function withChildActs(entries: Entry[], env: NodeJS.ProcessEnv = process.env): Entry[] {
305
350
  const acts: Entry[] = [];
306
351
  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 })));
352
+ for (const { book } of findChildBooks(id, env)) acts.push(...book.entries.filter((entry) => entry.stage === "edit").map((entry) => ({ ...entry, child: id })));
309
353
  }
310
354
  if (acts.length === 0) return entries;
311
355
  acts.sort((a, b) => a.ts.localeCompare(b.ts));
@@ -380,13 +424,13 @@ export function childLines(entries: Entry[], env: NodeJS.ProcessEnv = process.en
380
424
  const lines: string[] = [];
381
425
  for (const id of childIds(entries)) {
382
426
  const link = entries.find((entry) => entry.stage === "link" && entry.child === id);
383
- const child = findChildBook(id, env);
384
- if (!child) continue;
385
- const lastCheck = [...child.entries].reverse().find((entry) => entry.stage === "check" || entry.stage === "verify");
386
- const friction = frictionOf(child.entries).length;
387
- lines.push(
388
- `${link?.name ?? id}: ${currentKind(child.entries) ?? "no kind"}${isClosed(child) ? " (closed)" : ""}; stages ${stagesDone(child.entries).join(", ") || "none"}; ${lastCheck ? evidenceLine(lastCheck) : "no check"}${friction ? `; ${friction} friction` : ""}`,
389
- );
427
+ for (const { book: child, tag } of findChildBooks(id, env)) {
428
+ const lastCheck = [...child.entries].reverse().find((entry) => entry.stage === "check" || entry.stage === "verify");
429
+ const friction = frictionOf(child.entries).length;
430
+ lines.push(
431
+ `${link?.name ?? id}${tag}: ${currentKind(child.entries) ?? "no kind"}${isClosed(child) ? " (closed)" : ""}; stages ${stagesDone(child.entries).join(", ") || "none"}; ${lastCheck ? evidenceLine(lastCheck) : "no check"}${friction ? `; ${friction} friction` : ""}`,
432
+ );
433
+ }
390
434
  }
391
435
  return lines;
392
436
  }
@@ -419,13 +463,13 @@ function item(owner: string, ref: string, entry: Entry, reflect: Entry | undefin
419
463
  export function allFriction(entries: Entry[], env: NodeJS.ProcessEnv = process.env): FrictionItem[] {
420
464
  const items = frictionOf(entries).map((entry) => item("main", String(entry.id), entry, coveringReflect(entries, [String(entry.id)])));
421
465
  for (const id of childIds(entries)) {
422
- const child = findChildBook(id, env);
423
- if (!child) continue;
424
466
  const link = entries.find((entry) => entry.stage === "link" && entry.child === id);
425
- const label = String(link?.name ?? id);
426
- for (const entry of frictionOf(child.entries)) {
427
- const reflect = coveringReflect(entries, [`${label}#${entry.id}`, `${id}#${entry.id}`]) ?? coveringReflect(child.entries, [String(entry.id)]);
428
- items.push(item(label, `${label}#${entry.id}`, entry, reflect));
467
+ for (const { book: child, tag } of findChildBooks(id, env)) {
468
+ const label = `${String(link?.name ?? id)}${tag}`;
469
+ for (const entry of frictionOf(child.entries)) {
470
+ const reflect = coveringReflect(entries, [`${label}#${entry.id}`, `${id}${tag}#${entry.id}`]) ?? coveringReflect(child.entries, [String(entry.id)]);
471
+ items.push(item(label, `${label}#${entry.id}`, entry, reflect));
472
+ }
429
473
  }
430
474
  }
431
475
  return items;
@@ -535,12 +579,34 @@ export function bases(entries: Entry[]): { repo: string; head: string }[] {
535
579
  return entries.filter((entry) => entry.stage === "base").map((entry) => ({ repo: String(entry.repo), head: String(entry.head) }));
536
580
  }
537
581
 
538
- /** Adds the repo of `path` to the book with its HEAD when the book has no base for it yet. */
582
+ /**
583
+ * The content hash of each of `names` in `repo`, `deleted` for a path that is gone, and `unknown` for one git cannot hash
584
+ * (a directory or submodule). One such path fails the batch, so the batch falls back to hashing each path alone.
585
+ */
586
+ export function contentHashes(repo: string, names: string[]): Record<string, string> {
587
+ const found: Record<string, string> = {};
588
+ const present = names.filter((name) => existsSync(join(repo, name)));
589
+ for (const name of names) if (!present.includes(name)) found[name] = "deleted";
590
+ const batch = present.length ? git(repo, ["hash-object", "--", ...present])?.split("\n") : [];
591
+ present.forEach((name, index) => {
592
+ found[name] = (batch ? batch[index] : git(repo, ["hash-object", "--", name])) || "unknown";
593
+ });
594
+ return found;
595
+ }
596
+
597
+ /** The paths already changed or untracked in `repo` against `head`, with their content hashes: work the loop found, not made. */
598
+ function dirtyAt(repo: string, head: string): Record<string, string> {
599
+ const changed = git(repo, ["diff", "--name-only", head])?.split("\n") ?? [];
600
+ const untracked = git(repo, ["ls-files", "--others", "--exclude-standard"])?.split("\n") ?? [];
601
+ return contentHashes(repo, [...new Set([...changed, ...untracked].filter(Boolean))].slice(0, 500));
602
+ }
603
+
604
+ /** Adds the repo of `path` to the book with its HEAD, and the paths already dirty there, when the book has no base for it yet. */
539
605
  export function touchRepo(book: Book, path: string): boolean {
540
606
  const found = repoOf(path);
541
607
  if (!found) return false;
542
608
  if (bases(book.entries).some((base) => base.repo === found.repo)) return false;
543
- const entry = appendEntry(book.path, "base", found);
609
+ const entry = appendEntry(book.path, "base", { ...found, dirty: dirtyAt(found.repo, found.head) });
544
610
  book.entries.push(entry);
545
611
  return true;
546
612
  }
@@ -553,11 +619,14 @@ export function touchRepoBeforeMerge(book: Book, dir: string, verb: string): boo
553
619
  const found = repoOf(join(dir, "_"));
554
620
  if (!found || bases(book.entries).some((base) => base.repo === found.repo)) return false;
555
621
  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;
622
+ // A conflict resolved with `--continue` logs `commit (<verb>)` instead of `<verb>`.
623
+ const ofVerb = (line: string) => line.slice(41).startsWith(verb) || line.slice(41).startsWith(`commit (${verb})`);
624
+ const first = reflog.findIndex((line) => !ofVerb(line));
625
+ const before = reflog.length > 0 && ofVerb(reflog[0]) && first > 0 ? reflog[first].slice(0, 40) : undefined;
558
626
  // A rebasing pull leaves the old HEAD off HEAD's history; their merge base keeps the pulled commits in the diff.
627
+ // The dirty snapshot is the uncommitted work against HEAD, so the merged files stay the loop's change.
559
628
  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 }));
629
+ book.entries.push(appendEntry(book.path, "base", { repo: found.repo, head: base || found.head, dirty: dirtyAt(found.repo, found.head) }));
561
630
  return true;
562
631
  }
563
632
 
@@ -16,7 +16,10 @@ import {
16
16
  allFriction,
17
17
  anchorCommand,
18
18
  artifactOnDisk,
19
+ bases,
20
+ contentHashes,
19
21
  currentKind,
22
+ diffAgainstBases,
20
23
  lastActIndex,
21
24
  lastIndex,
22
25
  repoRootOf,
@@ -87,6 +90,99 @@ export function exempt(filePath: string, cwd: string): boolean {
87
90
  return EXEMPT_EXTENSIONS.includes(extname(filePath).toLowerCase()) || outsideWork(filePath, cwd);
88
91
  }
89
92
 
93
+ // --- reach -----------------------------------------------------------------------------------------
94
+
95
+ /** What a change reaches, measured from its diff: the count an unreviewed loop is re-classified at, not the author's confidence. */
96
+ export type Reach = { source: string[]; modules: string[]; behavior: string[]; crossed: string[] };
97
+
98
+ /** The kinds that never run a review; a change past the reach thresholds asks them to re-classify. */
99
+ export const REVIEWLESS: ReadonlySet<string> = new Set(["quick", "inline"]);
100
+ const REACH_FILES = 3;
101
+ const REACH_MODULES = 2;
102
+
103
+ // `spec/` and `specs/` count as tests, the RSpec and Jasmine layout; a durable spec folder is outside work anyway.
104
+ const TEST_PATH = /(?:^|\/)(?:tests?|__tests__|testdata|fixtures|specs?)\/|\.(?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$|_test\.(?:py|go)$/i;
105
+ const LOCKFILE =
106
+ /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|uv\.lock|poetry\.lock|Pipfile\.lock|Cargo\.lock|go\.sum|Gemfile\.lock|composer\.lock|flake\.lock|pubspec\.lock|mix\.lock|packages\.lock\.json|Package\.resolved)$/;
107
+ /** Files that describe behavior to its users: a change to one beside source code changes what users were told. */
108
+ const BEHAVIOR = /(?:^|\/)(?:(?:README|CHANGELOG)(?:\.[a-z0-9]+)?|\.changelog\/[^/]+|[^/]+\.schema\.json)$/i;
109
+
110
+ /** Each path of the diff with its repo and repo-relative POSIX name, leaving out work outside the loop's code and paths still as the loop found them. */
111
+ function loopPaths(entries: Entry[], cwd: string): { repo: string; rel: string }[] {
112
+ const repos = bases(entries).map((base) => base.repo);
113
+ const found = new Map<string, Record<string, string>>();
114
+ for (const entry of entries) if (entry.stage === "base" && entry.dirty && typeof entry.dirty === "object") found.set(String(entry.repo), entry.dirty as Record<string, string>);
115
+ const paths = diffAgainstBases(entries)
116
+ .paths.filter((path) => !outsideWork(path, cwd))
117
+ .map((path) => {
118
+ const repo = repos.find((root) => within(root, path)) ?? cwd;
119
+ return { repo, rel: relative(repo, path).split("\\").join("/") };
120
+ });
121
+ const unchanged = new Set<string>();
122
+ for (const repo of repos) {
123
+ const dirty = found.get(repo) ?? {};
124
+ const names = paths.filter((path) => path.repo === repo && path.rel in dirty).map((path) => path.rel);
125
+ const now = names.length ? contentHashes(repo, names) : {};
126
+ // An `unknown` hash proves nothing, so that path counts.
127
+ for (const name of names) if (dirty[name] !== "unknown" && now[name] === dirty[name]) unchanged.add(`${repo}\0${name}`);
128
+ }
129
+ return paths.filter((path) => !unchanged.has(`${path.repo}\0${path.rel}`));
130
+ }
131
+
132
+ /**
133
+ * The reach of the loop's diff against its bases: source files (not tests, lockfiles, behavior files, or what `exempt`
134
+ * names), the top-level directories they sit in, and the behavior files changed beside them. Paths already dirty when
135
+ * a base was recorded count once their content changes. A file at a repository root counts as a file, not a module.
136
+ * `crossed` names each threshold passed: `files` at three source files, `modules` at two directories, `behavior` at one
137
+ * behavior file.
138
+ */
139
+ export function reachOf(entries: Entry[], cwd: string): Reach {
140
+ const several = bases(entries).length > 1;
141
+ const source: string[] = [];
142
+ const behavior: string[] = [];
143
+ const modules = new Set<string>();
144
+ for (const { repo, rel } of loopPaths(entries, cwd)) {
145
+ const shown = several ? `${basename(repo)}/${rel}` : rel;
146
+ if (BEHAVIOR.test(rel)) behavior.push(shown);
147
+ else if (!TEST_PATH.test(rel) && !LOCKFILE.test(rel) && !exempt(join(repo, rel), cwd)) {
148
+ source.push(shown);
149
+ if (rel.includes("/")) modules.add(`${several ? `${basename(repo)}/` : ""}${rel.slice(0, rel.indexOf("/"))}`);
150
+ }
151
+ }
152
+ const counted = source.length > 0 ? behavior : [];
153
+ const crossed = [
154
+ ...(source.length >= REACH_FILES ? ["files"] : []),
155
+ ...(modules.size >= REACH_MODULES ? ["modules"] : []),
156
+ ...(counted.length > 0 ? ["behavior"] : []),
157
+ ];
158
+ return { source: source.sort(), modules: [...modules].sort(), behavior: counted.sort(), crossed };
159
+ }
160
+
161
+ /** The thresholds a `kind` call with a `reason` has answered so far. */
162
+ function answeredReach(entries: Entry[]): Set<string> {
163
+ const answered = new Set<string>();
164
+ for (const entry of entries) if (entry.stage === "kind" && typeof entry.reason === "string" && Array.isArray(entry.crossed)) for (const name of entry.crossed) answered.add(String(name));
165
+ return answered;
166
+ }
167
+
168
+ /** The thresholds the reach crossed that no `kind` answer covers yet; empty for a reviewed or durable loop. */
169
+ export function unansweredReach(entries: Entry[], reach: Reach | undefined): string[] {
170
+ if (!reach || !REVIEWLESS.has(currentKind(entries) ?? "inline")) return [];
171
+ const answered = answeredReach(entries);
172
+ return reach.crossed.filter((name) => !answered.has(name));
173
+ }
174
+
175
+ /** The one line that asks an unreviewed loop to re-classify; an undeclared loop is asked to declare its kind. */
176
+ export function reachLine(reach: Reach, kind: string | undefined): string {
177
+ const files = `${reach.source.length} source file${reach.source.length === 1 ? "" : "s"}`;
178
+ const modules = `${reach.modules.length} module${reach.modules.length === 1 ? "" : "s"} (${reach.modules.slice(0, 4).join(", ")}${reach.modules.length > 4 ? ", …" : ""})`;
179
+ const told = reach.behavior.length ? ` and changes ${reach.behavior.slice(0, 3).join(", ")}${reach.behavior.length > 3 ? ", …" : ""}` : "";
180
+ const ask = kind
181
+ ? `call \`kind\` with \`reason\`: upgrade to reviewed, or say why ${kind} still fits`
182
+ : "declare `kind` with `intent` and `reason`: reviewed when it reaches past its files, or inline and why it still fits";
183
+ return `the change reaches ${files} across ${modules}${told}; ${ask}`;
184
+ }
185
+
90
186
  // --- evidence ------------------------------------------------------------------------------------
91
187
 
92
188
  type Ctx = Inputs & { seq: readonly string[]; folder?: string; lastAct: number };
@@ -206,6 +302,8 @@ function evaluate(name: string, ctx: Ctx): Status {
206
302
  if (entry.declared === true && entry.result === "fail") return missing(name, "the declared verify failed");
207
303
  if (entry.declared !== true && entry.exit !== 0) return missing(name, `the anchor exited ${entry.exit ?? "unknown"} after the last change`);
208
304
  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)`);
305
+ const reach = entry.reach as Reach | undefined;
306
+ if (unansweredReach(entries, reach).length > 0) return missing(name, reachLine(reach as Reach, currentKind(entries)));
209
307
  return done(name, entry.declared === true ? "declared" : "");
210
308
  }
211
309
  case "review": {
package/pi/ttsr.ts CHANGED
@@ -84,9 +84,9 @@ User-defined rule matched your previous response. Rule configured not to interru
84
84
 
85
85
  export function render(template: string, rule: Rule): string {
86
86
  return template
87
- .replaceAll("{{name}}", rule.name)
88
- .replaceAll("{{path}}", rule.path)
89
- .replaceAll("{{content}}", rule.content.trim());
87
+ .replaceAll("{{name}}", () => rule.name)
88
+ .replaceAll("{{path}}", () => rule.path)
89
+ .replaceAll("{{content}}", () => rule.content.trim());
90
90
  }
91
91
 
92
92
  /** Unquote a YAML scalar written plainly, in single quotes, or in double quotes. */
@@ -326,11 +326,14 @@ export class TtsrManager {
326
326
  );
327
327
  }
328
328
 
329
- /** Rules whose scope, path globs, condition, and repeat policy all admit `content`. */
329
+ /**
330
+ * Rules whose scope, path globs, condition, and repeat policy all admit `content`. A match that blocks a tool call
331
+ * ignores the repeat policy: the call must never run, however often the rule has fired.
332
+ */
330
333
  check(content: string, context: MatchContext): Rule[] {
331
334
  return this.rules.filter(
332
335
  (rule) =>
333
- this.#eligible(rule) &&
336
+ ((context.source === "tool" && interrupts(rule, "tool")) || this.#eligible(rule)) &&
334
337
  this.#inScope(rule, context) &&
335
338
  rule.conditions.some((condition) => {
336
339
  condition.lastIndex = 0;
@@ -9,7 +9,7 @@ Every entry skill (`answering`, `investigating`, `inspecting`, `coding`, `writin
9
9
 
10
10
  ## Align
11
11
 
12
- Pick the entry's playbook from its index; with none fitting, run the loop bare. `todo` holds real work items only, optionally with `metadata.anchor` (a task's own check) and `metadata.ref` (`<path>#<heading>`). The first `todo` create opens a loop. Declare with `anchorloop_stage` only what the extension cannot observe: `kind` (`quick`, `inline`, `reviewed`, `durable`; `intent`, the task and its outcome in one line; `anchor`, the check command), `verify` (a check not run in bash), `fresh-read`, `friction`, `reflect`, `skip` or `back` (phase, reason), `close`, and the query `read`. Edits, anchor runs, reviews, and spec files are read from evidence; a phase that starts before an earlier one is complete warns once, and `close` refuses only while an open todo lacks `metadata.followup` (a ticket id, `memory`, or `drop — <reason>`).
12
+ Pick the entry's playbook from its index; with none fitting, run the loop bare. `todo` holds real work items only, optionally with `metadata.anchor` (a task's own check) and `metadata.ref` (`<path>#<heading>`). The first `todo` create opens a loop. Declare with `anchorloop_stage` only what the extension cannot observe: `kind` (`quick`, `inline`, `reviewed`, `durable`; `intent`, the task and its outcome in one line; `anchor`, the check command; `reason` when a later call changes the kind or answers the reach an anchor run reports), `verify` (a check not run in bash), `fresh-read`, `friction`, `reflect`, `skip` or `back` (phase, reason), and `close`. Edits, anchor runs, reviews, and spec files are read from evidence; a phase that starts before an earlier one is complete warns once (`read` if you disagree), and `close` refuses only while an open todo lacks `metadata.followup` (a ticket id, `memory`, or `drop — <reason>`).
13
13
 
14
14
  ## Anchor
15
15
 
@@ -45,7 +45,7 @@ Write the reply the playbook's `Reply:` line names, with the anchor's evidence.
45
45
 
46
46
  ### Reflexive Pass
47
47
 
48
- Run only when the book holds friction (`read` lists it); otherwise say nothing. Classify each entry with `reflect`: **misdirection** when the `led_by` line states or implies something `found` contradicts; **gap** when no instruction in use states the `found` fact. A gap the change itself resolved, or a tool slip no instruction owns, is `resolved`. Invoke `solid-loop` with the rest.
48
+ Run only when the book holds friction; otherwise say nothing. Classify each entry with `reflect`: **misdirection** when the `led_by` line states or implies something `found` contradicts; **gap** when no instruction in use states the `found` fact. A gap the change itself resolved, or a tool slip no instruction owns, is `resolved`. Invoke `solid-loop` with the rest.
49
49
 
50
50
  **Complete when:** every requirement has evidence from its anchor or is reported `unknown`, and every friction entry is applied through `solid-loop` or reflected as resolved.
51
51
 
@@ -9,14 +9,17 @@ Run the loop in [../anchorloop/SKILL.md](../anchorloop/SKILL.md) with one of the
9
9
 
10
10
  ## Classify
11
11
 
12
- Before the first production edit, declare the change **Inline** or **Durable**:
12
+ Before the first production edit, classify the change by what it reaches, not by how settled the approach feels:
13
13
 
14
- - **Inline:** requirements and approach are clear, impact is bounded, and implementation and verification fit this run; a lasting behavior or public contract can still be Inline when the change is simple. The `intent` on the `kind` call states the observable behavior after the change; the user's source-change request approves it, and approval already given is not asked again.
15
- - **Durable:** the work needs a persistent design record because of material ambiguity, consequential trade-offs, cross-component coordination, migration risk, or a user-requested spec. Read [MINI-SDD.md](MINI-SDD.md) and create or resume its folder, which keeps no `tasks.md`; Spec waits for the user's approval before any production edit.
14
+ - **Inline:** the change stays inside the files it edits: no export another file uses, no behavior its users are told about, and no behavior the requirement leaves open. The `intent` on the `kind` call states the observable behavior after the change; the user's source-change request approves it, and approval already given is not asked again.
15
+ - **Reviewed:** the change reaches past its files without a decision the user owns: it changes an export other files use, a behavior users see (config keys, output, notices, command flags), or a behavior the requirement leaves open, which the reply names; or it touches a shared contract, authorization or permission data, a migration, or security. Before the first edit, write its premises in the todo's `metadata.premises`: three to five lines on what the change assumes holds, such as where its results land, what names may contain, and how paths are spelled. It needs no spec and no further approval.
16
+ - **Durable:** the work needs a persistent design record because of material ambiguity, a trade-off the user owns, cross-component coordination, migration risk, or a user-requested spec. Read [MINI-SDD.md](MINI-SDD.md) and create or resume its folder, which keeps no `tasks.md`; Spec waits for the user's approval before any production edit.
17
+
18
+ Re-classify when the reach changes: the user adds scope, an anchor run reports the diff's reach, or the change turns out smaller. Call `kind` with a `reason` naming the kind that now fits, or why the current one still fits. Verified work carries over; an upgrade adds only the new kind's phases, the premises and review for Reviewed, and for Durable the record from decide on, with the code so far as prototype evidence.
16
19
 
17
20
  ## Anchor
18
21
 
19
- Point the anchor at what the change touches: a one-line edit anchors on the tests that assert that file; a shared code path anchors on its callers' tests. For Inline work, a green anchor plus a read of your own diff completes Verify; report the contract with its observed output in one line. Durable work verifies into `verification.md` as MINI-SDD describes.
22
+ Point the anchor at what the change touches: a one-line edit anchors on the tests that assert that file; a shared code path anchors on its callers' tests and the repository's tree-wide checks, such as lint, config sweeps, ratchets, and changelog checks. For Inline work, a green anchor plus a read of your own diff completes Verify; report the contract with its observed output in one line. Reviewed work adds an independent review whose brief carries the premises and asks the reviewer to test each premise against the code, to sweep each finding's class across the change, and to confirm the tree-wide checks ran. Durable work verifies into `verification.md` as MINI-SDD describes.
20
23
 
21
24
  ## Playbooks
22
25