pi-anchorloop 2.4.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,8 @@
7
7
 
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { execFileSync } from "node:child_process";
10
- import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
11
- import { homedir } from "node:os";
10
+ import { closeSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, utimesSync, writeSync } from "node:fs";
11
+ import { homedir, tmpdir } from "node:os";
12
12
  import { basename, dirname, join, resolve } from "node:path";
13
13
 
14
14
  export const KINDS = ["quick", "inline", "reviewed", "durable"] as const;
@@ -541,14 +541,85 @@ export function validFollowup(followup: unknown): boolean {
541
541
 
542
542
  // --- git -----------------------------------------------------------------------------------------
543
543
 
544
- function git(cwd: string, args: string[]): string | undefined {
544
+ function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string | undefined {
545
545
  try {
546
- return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
546
+ return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], env: env ?? process.env }).trim();
547
547
  } catch {
548
548
  return undefined;
549
549
  }
550
550
  }
551
551
 
552
+ /**
553
+ * The tree a commit of everything in `repo`'s working state would have (tracked and untracked, ignored files and `scratch/`
554
+ * left out), built in a copy of the index so the repo's own index is untouched; undefined when git cannot build it.
555
+ */
556
+ export function workingTree(repo: string): string | undefined {
557
+ const index = git(repo, ["rev-parse", "--path-format=absolute", "--git-path", "index"]);
558
+ if (!index) return undefined;
559
+ const dir = mkdtempSync(join(tmpdir(), "anchorloop-tree-"));
560
+ try {
561
+ const env = { ...process.env, GIT_INDEX_FILE: join(dir, "index") };
562
+ if (existsSync(index)) {
563
+ // The copy keeps the index's mtime: git trusts an entry's stat only when it is older than the index, and a file
564
+ // changed in the second the index was written but kept its size would otherwise be taken as unchanged.
565
+ copyFileSync(index, env.GIT_INDEX_FILE);
566
+ const { atime, mtime } = statSync(index);
567
+ utimesSync(env.GIT_INDEX_FILE, atime, mtime);
568
+ }
569
+ if (git(repo, ["add", "-A", "--", ".", ":(exclude)scratch"], env) === undefined) return undefined;
570
+ return git(repo, ["write-tree"], env);
571
+ } finally {
572
+ rmSync(dir, { recursive: true, force: true });
573
+ }
574
+ }
575
+
576
+ /** The working-state tree of each repo in `repos`, leaving out a repo git cannot build one for. */
577
+ export function workingTrees(repos: Iterable<string>): string[] {
578
+ return [...new Set(repos)].map(workingTree).filter((tree): tree is string => tree !== undefined);
579
+ }
580
+
581
+ const CD_STEP = /^cd\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/;
582
+
583
+ /**
584
+ * The command without the leading `cd <dir> &&` (or `;`) steps whose directory is one of `roots`, the roots of the loop's
585
+ * repos, so one anchor run from two checkouts reads the same; a `cd` into a subdirectory stays, since it runs other tests.
586
+ */
587
+ function anchorBody(command: string, roots: string[], cwd: string): string {
588
+ let rest = command.trim();
589
+ for (let step = CD_STEP.exec(rest); step; step = CD_STEP.exec(rest)) {
590
+ const target = step[1].replace(/^(["'])(.*)\1$/, "$2").replace(/^~(?=\/|$)/, homedir());
591
+ if (!roots.includes(real(resolve(cwd, target)))) break;
592
+ rest = rest.slice(step[0].length);
593
+ }
594
+ return rest.replace(/\s+/g, " ");
595
+ }
596
+
597
+ /**
598
+ * The linked child whose latest run of `anchor` passed on exactly the state the coordinator holds: the trees that run
599
+ * recorded are the coordinator's `trees`, no more and no fewer, so a repo only one side worked in is never taken as checked.
600
+ * `roots` and `cwd` are the coordinator's repo roots and directory. Only a pi-herdr-agents child is linked
601
+ * (`PI_SUBAGENT_ID`); pi itself has no subagents.
602
+ */
603
+ export function adoptableRun(entries: Entry[], anchor: string, trees: string[], roots: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): { name: string; run: Entry } | undefined {
604
+ const body = anchorBody(anchor, roots, cwd);
605
+ const held = new Set(trees);
606
+ for (const id of childIds(entries)) {
607
+ const runs = findChildBooks(id, env).flatMap(({ book }) => {
608
+ const home = String(book.header.cwd ?? "");
609
+ const here = home ? repoRootOf(join(home, "_")) : undefined;
610
+ const childRoots = [...bases(book.entries).map((base) => base.repo), ...(here ? [here] : [])];
611
+ return book.entries.filter((entry) => entry.stage === "check" && typeof entry.command === "string" && anchorBody(entry.command, childRoots, home) === body);
612
+ });
613
+ const run = runs.sort((a, b) => a.ts.localeCompare(b.ts)).at(-1);
614
+ const checked = new Set(Array.isArray(run?.trees) ? (run.trees as unknown[]).map(String) : []);
615
+ if (run && run.exit === 0 && checked.size === held.size && [...checked].every((tree) => held.has(tree))) {
616
+ const link = entries.find((entry) => entry.stage === "link" && entry.child === id);
617
+ return { name: String(link?.name ?? id), run };
618
+ }
619
+ }
620
+ return undefined;
621
+ }
622
+
552
623
  function existingDir(path: string): string {
553
624
  let dir = path;
554
625
  while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
@@ -171,13 +171,10 @@ export function unansweredReach(entries: Entry[], reach: Reach | undefined): str
171
171
 
172
172
  /** The one line that asks an unreviewed loop to re-classify; an undeclared loop is asked to declare its kind. */
173
173
  export function reachLine(reach: Reach, kind: string | undefined): string {
174
- const files = `${reach.source.length} source file${reach.source.length === 1 ? "" : "s"}`;
175
- const modules = `${reach.modules.length} module${reach.modules.length === 1 ? "" : "s"} (${reach.modules.slice(0, 4).join(", ")}${reach.modules.length > 4 ? ", …" : ""})`;
176
- const told = reach.behavior.length ? ` and changes ${reach.behavior.slice(0, 3).join(", ")}${reach.behavior.length > 3 ? ", …" : ""}` : "";
177
- const ask = kind
178
- ? `call \`kind\` with \`reason\`: upgrade to reviewed, or say why ${kind} still fits`
179
- : "declare `kind` with `intent` and `reason`: reviewed when it reaches past its files, or inline and why it still fits";
180
- return `the change reaches ${files} across ${modules}${told}; ${ask}`;
174
+ const modules = reach.modules.length ? ` / ${reach.modules.length} module${reach.modules.length === 1 ? "" : "s"} (${reach.modules.slice(0, 4).join(", ")}${reach.modules.length > 4 ? ", …" : ""})` : "";
175
+ const told = reach.behavior.length ? ` / ${reach.behavior.slice(0, 3).join(", ")}${reach.behavior.length > 3 ? ", …" : ""}` : "";
176
+ const ask = kind ? `kind with reason: reviewed, or why ${kind} fits` : "declare kind with intent and reason: reviewed, or why inline fits";
177
+ return `reach ${reach.source.length} file${reach.source.length === 1 ? "" : "s"}${modules}${told}; ${ask}`;
181
178
  }
182
179
 
183
180
  // --- evidence ------------------------------------------------------------------------------------
@@ -284,7 +281,7 @@ function evaluate(name: string, ctx: Ctx): Status {
284
281
  }
285
282
  case "anchor": {
286
283
  const index = lastWhere(entries, from, (entry) => entry.stage === "anchor");
287
- return index >= 0 ? done(name, String(entries[index].check ?? "")) : missing(name, "a `Reality anchor:` line, or `anchor` on the kind call");
284
+ return index >= 0 ? done(name, String(entries[index].check ?? "")) : missing(name, "a `Reality anchor:` line, `metadata.anchor` on the first todo, or `anchor` on the kind call");
288
285
  }
289
286
  case "act":
290
287
  return lastWhere(entries, from, (entry) => entry.stage === "edit") >= 0 ? done(name) : missing(name, "a change to the code");
@@ -362,11 +359,9 @@ export function incompleteBefore(phases: Phases, entering: string): Status[] {
362
359
  return phases.statuses.filter((status, index) => index < at && status.state === "missing");
363
360
  }
364
361
 
365
- /** One line: the phase entered, what is missing before it, and the skip escape. */
362
+ /** One line: the phases still missing before the one entered. */
366
363
  export function jumpLine(entering: string, missingBefore: Status[]): string {
367
- const list = missingBefore.map((status) => `${status.name} (${status.note})`).join("; ");
368
- const skip = missingBefore[0]?.name ?? "<phase>";
369
- return `anchorloop: ${entering} comes before ${list}; skip with \`anchorloop_stage skip ${skip} <reason>\`.`;
364
+ return `anchorloop: before ${entering}, still missing: ${missingBefore.map((status) => `${status.name} (${status.note})`).join("; ")}.`;
370
365
  }
371
366
 
372
367
  /** The phase line `read` and the close summary show. */
@@ -16,6 +16,7 @@ import {
16
16
  stagesDone,
17
17
  TASK_LEGEND,
18
18
  taskLines,
19
+ validFollowup,
19
20
  verifyAfterLastAct,
20
21
  } from "./book.ts";
21
22
  import { computePhases } from "./phases.ts";
@@ -29,12 +30,30 @@ export type Snapshot = {
29
30
  current: string;
30
31
  /** A review recorded after the last change with no blocking finding on it. */
31
32
  reviewClear: boolean;
33
+ /** Open todos that no valid `metadata.followup` settles yet. */
34
+ unsettled: { id: number; subject: string }[];
32
35
  };
33
36
 
37
+ const LISTED_TODOS = 3;
38
+ const SUBJECT_LIMIT = 60;
39
+
40
+ const clipped = (subject: string, limit: number): string => {
41
+ const flat = subject.replace(/\s+/g, " ").trim();
42
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
43
+ };
44
+
45
+ /** The first few todos as `#id subject` (subject cut to 60 characters) joined by `; `, then `+N more`. */
46
+ export function listTodos(todos: { id: number; subject: string }[], limit = LISTED_TODOS): string {
47
+ const lines = todos.slice(0, limit).map((task) => `#${task.id} ${clipped(task.subject, SUBJECT_LIMIT)}`);
48
+ return [...lines, ...(todos.length > limit ? [`+${todos.length - limit} more`] : [])].join("; ");
49
+ }
50
+
34
51
  export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBranch(): unknown[] } }): Snapshot {
35
52
  const { entries } = book;
36
53
  const todos = replayTodos(ctx.sessionManager.getBranch());
37
- const open = todos.filter(isOpenTask).length;
54
+ const openTasks = todos.filter(isOpenTask);
55
+ const unsettled = openTasks.filter((task) => !validFollowup(task.metadata?.followup));
56
+ const settled = openTasks.length - unsettled.length;
38
57
  const phases = computePhases(book, { cwd: ctx.cwd, todos });
39
58
  const check = verifyAfterLastAct(entries);
40
59
  const verify = [
@@ -56,7 +75,7 @@ export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBr
56
75
  diff: diffText(diffAgainstBases(entries)),
57
76
  phase: `current: ${phases.current}; next: ${phases.next}${phases.missing ? `; missing: ${phases.missing}` : ""}${phases.skipped.length ? `; skipped: ${phases.skipped.join(", ")}` : ""}`.slice(0, 600),
58
77
  verify: verify.slice(0, 1200),
59
- todos: `${open} open of ${todos.length}`,
78
+ todos: `${openTasks.length} open of ${todos.length}${unsettled.length ? `: ${listTodos(unsettled, 5)}` : ""}${settled ? ` (${settled} with a followup)` : ""}`.slice(0, 700),
60
79
  tasks: [`legend: ${TASK_LEGEND}`, ...taskLines(entries, todos, ctx.cwd)].join("\n").slice(0, 1500),
61
80
  friction: (friction.join("\n") || "none").slice(0, 800),
62
81
  },
@@ -64,5 +83,6 @@ export function snapshot(book: Book, ctx: { cwd: string; sessionManager: { getBr
64
83
  missing: phases.missing,
65
84
  current: phases.current,
66
85
  reviewClear: reviewClearAfterLastAct(entries),
86
+ unsettled: unsettled.map((task) => ({ id: task.id, subject: task.subject })),
67
87
  };
68
88
  }
@@ -37,6 +37,7 @@ const RED_LINE_MARKERS = new RegExp(
37
37
  `${wordAround("never|must not|do not|don't|forbidden|barred|prohibited|avoid")}|不要|禁止|不得|不准|嚴禁`,
38
38
  "giu",
39
39
  );
40
+ const MAX_ADVISORIES = 3;
40
41
  const RED_LINE_MIN_BUDGET = 3;
41
42
  const RED_LINE_DENSITY = 0.1;
42
43
  const EMPHATIC_WORDS = new RegExp(wordAround("(NEVER|ALWAYS|MUST NOT|MUST|FORBIDDEN|DO NOT)"), "gu");
@@ -151,27 +152,19 @@ function checkRulesFile(path: string): string[] {
151
152
 
152
153
  const found = findDefensivePhrases(text, DEFENSIVE_PATTERNS);
153
154
  if (found.length) {
154
- warnings.push(
155
- `detected defensive wording: ${found.join(", ")}. ` +
156
- "State desired behavior directly; modern models do not need excessive red lines.",
157
- );
155
+ warnings.push(`detected defensive wording: ${found.join(", ")}`);
158
156
  }
159
157
 
160
158
  const lines = splitlines(text)
161
159
  .map((line) => line.trim())
162
160
  .filter((line) => line && !line.startsWith("#"));
163
161
  if (lines.length > 200) {
164
- warnings.push(
165
- `rule file has ${lines.length} non-empty lines. Consider consolidating into higher-level principles ` +
166
- "or separating into specialized modules rather than accumulating micro-rules.",
167
- );
162
+ warnings.push(`rule file has ${lines.length} non-empty lines (over 200)`);
168
163
  }
169
164
 
170
165
  const simplified = [...SIMPLIFIED_CHINESE_CHARS].filter((char) => text.includes(char));
171
166
  if (simplified.length) {
172
- warnings.push(
173
- `possible Simplified Chinese detected (${simplified.slice(0, 5).join("")}). Always use Traditional Chinese.`,
174
- );
167
+ warnings.push(`possible Simplified Chinese detected (${simplified.slice(0, 5).join("")})`);
175
168
  }
176
169
  return warnings;
177
170
  }
@@ -216,10 +209,7 @@ function checkSkillMd(path: string): string[] {
216
209
  }
217
210
 
218
211
  if (lines.length > SKILL_MAX_RECOMMENDED_LINES && !isDirectory(join(skillDir, "references"))) {
219
- warnings.push(
220
- `skill has ${lines.length} lines (exceeds recommended ${SKILL_MAX_RECOMMENDED_LINES}). ` +
221
- "Move detailed guidelines or templates to references/ to keep SKILL.md streamlined.",
222
- );
212
+ warnings.push(`skill has ${lines.length} lines (over ${SKILL_MAX_RECOMMENDED_LINES}) and no references/`);
223
213
  }
224
214
 
225
215
  // Broken markdown links, followed through every Markdown file SKILL.md reaches.
@@ -262,10 +252,7 @@ function checkSkillMd(path: string): string[] {
262
252
 
263
253
  const defensive = findDefensivePhrases(text, DEFENSIVE_PATTERNS);
264
254
  if (defensive.length) {
265
- warnings.push(
266
- `detected defensive anti-pattern(s): ${defensive.join(", ")}. ` +
267
- "State expected behavior directly rather than constructing defensive red lines.",
268
- );
255
+ warnings.push(`detected defensive anti-pattern(s): ${defensive.join(", ")}`);
269
256
  }
270
257
  return warnings;
271
258
  }
@@ -286,10 +273,7 @@ function checkSkillAdvisories(path: string): string[] {
286
273
  const markers = (body.match(RED_LINE_MARKERS) ?? []).length;
287
274
  const budget = Math.max(RED_LINE_MIN_BUDGET, Math.floor(lines.filter((line) => line.trim()).length * RED_LINE_DENSITY));
288
275
  if (markers > budget) {
289
- advisories.push(
290
- `${markers} red-line markers exceed the budget of ${budget}. Keep a red line only when an ` +
291
- "observed failure backs it, and delete the rest.",
292
- );
276
+ advisories.push(`${markers} red-line markers exceed the budget of ${budget}`);
293
277
  }
294
278
 
295
279
  const seen = new Map<string, number>();
@@ -299,19 +283,16 @@ function checkSkillAdvisories(path: string): string[] {
299
283
  seen.set(word, (seen.get(word) ?? 0) + 1);
300
284
  const window = lines.slice(index, index + 2).join(" ");
301
285
  if (!REASON_WORDS.test(window)) {
302
- advisories.push(
303
- `line ${index + 1}: emphatic '${word}' has no reason beside it. ` +
304
- "State the consequence, or drop the emphasis.",
305
- );
286
+ advisories.push(`line ${index + 1}: emphatic '${word}' has no reason beside it`);
306
287
  }
307
288
  }
308
289
  });
309
290
  for (const [word, count] of seen) {
310
291
  if (count > 1) {
311
- advisories.push(`emphatic '${word}' appears ${count} times. Spend emphasis once, on the rule that causes real damage.`);
292
+ advisories.push(`emphatic '${word}' appears ${count} times`);
312
293
  }
313
294
  }
314
- return advisories;
295
+ return advisories.slice(0, MAX_ADVISORIES);
315
296
  }
316
297
 
317
298
  // ---- durable specs (durable_validator.py) ----
@@ -322,10 +303,6 @@ function checkDecisionMd(path: string): string[] {
322
303
  if (!/\|\s*Question\s*\|\s*Answer\s*\|\s*Basis\s*\|\s*Status\s*\|/i.test(text)) {
323
304
  warnings.push("decision.md should include a Question | Answer | Basis | Status table (Status: grounded, confirmed, or open).");
324
305
  }
325
- const lower = text.toLowerCase();
326
- if (!lower.includes("rule") && !lower.includes("readiness") && !lower.includes("prerequisite") && !lower.includes("core")) {
327
- warnings.push("confirm core rules readiness before implementation; note readiness in decision.md.");
328
- }
329
306
  return warnings;
330
307
  }
331
308
 
@@ -335,9 +312,6 @@ function checkSpecMd(path: string): string[] {
335
312
  if (!/Status:\s*(proposed|approved)/i.test(text)) {
336
313
  warnings.push("spec.md must declare 'Status: proposed' or 'Status: approved' at the top.");
337
314
  }
338
- if (!text.toLowerCase().includes("reality anchor") && !text.toLowerCase().includes("anchor")) {
339
- warnings.push("spec.md should name the chosen reality anchor and its checkpoint.");
340
- }
341
315
  return warnings;
342
316
  }
343
317
 
@@ -351,18 +325,13 @@ function checkVerificationMd(path: string): string[] {
351
325
  const results = [...text.matchAll(/\|\s*[^|]+\s*\|\s*[^|]+\s*\|\s*([a-zA-Z]+)\s*\|/g)].map((match) => match[1].toLowerCase());
352
326
  const invalid = [...new Set(results.filter((result) => !VALID_VERIFICATION_RESULTS.has(result) && result !== "result"))];
353
327
  if (invalid.length) {
354
- warnings.push(
355
- `invalid verification Result values found: ${invalid.join(", ")}. Result must be one of: pass, fail, unknown.`,
356
- );
328
+ warnings.push(`invalid verification Result values found: ${invalid.join(", ")} (use pass, fail, unknown)`);
357
329
  }
358
330
  }
359
331
 
360
332
  const defensive = findDefensivePhrases(text, DEFENSIVE_PATTERNS);
361
333
  if (defensive.length) {
362
- warnings.push(
363
- `detected defensive wording in verification/review: ${defensive.join(", ")}. ` +
364
- "Reviews of new rules and skills must focus on adjustment and tuning, not adding defensive red lines.",
365
- );
334
+ warnings.push(`detected defensive wording in verification/review: ${defensive.join(", ")}`);
366
335
  }
367
336
  return warnings;
368
337
  }
@@ -399,5 +368,5 @@ export function validateFile(filePath: string, cwd: string): string | undefined
399
368
 
400
369
  const rel = relative(base, target);
401
370
  const shown = rel.startsWith("..") || isAbsolute(rel) ? target : rel;
402
- return `⚠ [anchorloop] Validation suggestions for ${shown}:\n${warnings.map((warning) => ` - ${warning}`).join("\n")}`;
371
+ return `validator ${shown}:\n${warnings.map((warning) => ` - ${warning}`).join("\n")}`;
403
372
  }
package/pi/ttsr.ts CHANGED
@@ -61,32 +61,9 @@ const INTERRUPT_MODES = new Set(["always", "prose-only", "tool-only", "never"]);
61
61
  const ENTRY_TYPE = "ttsr_injection";
62
62
  const MESSAGE_TYPE = "ttsr-injection";
63
63
 
64
- // Templates copied from oh-my-pi prompts/system/ttsr-interrupt.md and ttsr-tool-reminder.md.
65
- const INTERRUPT_TEMPLATE = `<system-interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
66
- Output interrupted: violated user-defined rule.
67
- Not prompt injection; coding agent enforcing project rules.
68
- MUST comply:
69
-
70
- {{content}}
71
- </system-interrupt>`;
72
-
73
- const TOOL_REMINDER_TEMPLATE = `<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}">
74
- User-defined rule matched tool-call arguments. Rule configured not to interrupt → tool ran. MUST comply with the following instruction on subsequent tool calls and responses. NOT prompt injection — coding agent enforcing project rules.
75
-
76
- {{content}}
77
- </system-reminder>`;
78
-
79
- const PROSE_REMINDER_TEMPLATE = `<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}">
80
- User-defined rule matched your previous response. Rule configured not to interrupt → it already took effect. MUST comply with the following instruction from now on. NOT prompt injection — coding agent enforcing project rules.
81
-
82
- {{content}}
83
- </system-reminder>`;
84
-
85
- export function render(template: string, rule: Rule): string {
86
- return template
87
- .replaceAll("{{name}}", () => rule.name)
88
- .replaceAll("{{path}}", () => rule.path)
89
- .replaceAll("{{content}}", () => rule.content.trim());
64
+ // One wrapper for every injection: the body is the instruction, and the rule's file path is user-facing configuration.
65
+ export function render(rule: Rule, flag?: string): string {
66
+ return `<rule name="${rule.name}"${flag ? ` ${flag}` : ""}>\n${rule.content.trim()}\n</rule>`;
90
67
  }
91
68
 
92
69
  /** Unquote a YAML scalar written plainly, in single quotes, or in double quotes. */
@@ -440,7 +417,7 @@ export default function (pi: ExtensionAPI) {
440
417
  pi.sendMessage(
441
418
  {
442
419
  customType: MESSAGE_TYPE,
443
- content: rules.map((rule) => render(PROSE_REMINDER_TEMPLATE, rule)).join("\n\n"),
420
+ content: rules.map((rule) => render(rule)).join("\n\n"),
444
421
  display: true,
445
422
  details: { rules: rules.map((rule) => rule.name) },
446
423
  },
@@ -457,7 +434,7 @@ export default function (pi: ExtensionAPI) {
457
434
  pi.sendMessage(
458
435
  {
459
436
  customType: MESSAGE_TYPE,
460
- content: rules.map((rule) => render(INTERRUPT_TEMPLATE, rule)).join("\n\n"),
437
+ content: rules.map((rule) => render(rule, "interrupted")).join("\n\n"),
461
438
  display: true,
462
439
  details: { rules: rules.map((rule) => rule.name) },
463
440
  },
@@ -488,7 +465,7 @@ export default function (pi: ExtensionAPI) {
488
465
  ctx.ui.notify(`TTSR: blocked ${event.toolName} by rule ${blocking.map((rule) => rule.name).join(", ")}`, "warning");
489
466
  return {
490
467
  block: true,
491
- reason: [...blocking.map((rule) => render(INTERRUPT_TEMPLATE, rule)), ...passive.map((rule) => render(TOOL_REMINDER_TEMPLATE, rule))].join("\n\n"),
468
+ reason: [...blocking.map((rule) => render(rule, "blocking")), ...passive.map((rule) => render(rule, "passive"))].join("\n\n"),
492
469
  };
493
470
  }
494
471
  toolReminders.set(event.toolCallId, passive);
@@ -500,7 +477,7 @@ export default function (pi: ExtensionAPI) {
500
477
  toolReminders.delete(event.toolCallId);
501
478
  pi.appendEntry(ENTRY_TYPE, { rules: rules.map((rule) => rule.name) });
502
479
  return {
503
- content: [...event.content, { type: "text", text: rules.map((rule) => render(TOOL_REMINDER_TEMPLATE, rule)).join("\n\n") }],
480
+ content: [...event.content, { type: "text", text: rules.map((rule) => render(rule)).join("\n\n") }],
504
481
  };
505
482
  });
506
483
 
@@ -11,7 +11,7 @@ function validate(filePath: string, cwd: string): string | undefined {
11
11
  try {
12
12
  return validateFile(filePath, cwd);
13
13
  } catch (error) {
14
- return `⚠ [anchorloop] Validator did not run: ${error instanceof Error ? error.message : String(error)}`;
14
+ return `validator did not run: ${error instanceof Error ? error.message : String(error)}`;
15
15
  }
16
16
  }
17
17
 
@@ -9,13 +9,13 @@ 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; `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>`).
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; the first todo's becomes the loop anchor) and `metadata.ref` (`<path>#<heading>`). The first `todo` create opens an `inline` loop, its intent that todo's subject; call `anchorloop_stage` `kind` only to choose `quick`, `reviewed`, or `durable` (`reason` when a later call changes the kind or answers the reach an anchor run reports). The rest of what the extension cannot observe: `verify` (a check not run in bash), `fresh-read`, `friction` (only when an instruction misled you; a rerun that passes after failing is recorded and resolved for you), `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
 
16
- Before the first change the loop has an anchor: `anchor` on the `kind` call, or a `Reality anchor: <the exact check that observes the result, and its checkpoint>` line.
16
+ Before the first change the loop has an anchor: a `Reality anchor: <the exact check that observes the result, and its checkpoint>` line, `metadata.anchor` on the first todo, or `anchor` on a `kind` call. The first edit without one gets a one-line reminder in its result.
17
17
 
18
- Size the anchor to the change's reach and pick one method: an executable check, a user operation, human judgment, or a focused review. A coordinator's anchor governs. Pick a review anchor when the change touches a shared contract, authorization or permission data, a migration, or security; other changes rest on their check and the author's own read of the diff. A review anchor names the realistic use it judges against and blocks on P0 and P1 findings only, recording lower ones as follow-ups. Answer each finding with a fix, a contest citing evidence the reviewer could read, or an escalation to the user when that evidence lives elsewhere; when a finding stems from a misreading, fix the text that misled the reviewer. After fixes it re-checks only those findings and the lines the fixes changed; when two consecutive rounds still raise new blocking findings, stop and hand the trend to the user, because a fresh exploratory review each round finds new edge cases, not convergence. The checkpoint is normally once, after the last increment.
18
+ Size the anchor to the change's reach and pick one method: an executable check, a user operation, human judgment, or a focused review. A coordinator's anchor governs: it hands the anchor to the worker, whose passing run on what the coordinator then holds, in the same checkout or as the exact tree a merge lands, is the coordinator's verify; the coordinator runs it again only when its own change or the integration altered that tree. Pick a review anchor when the change touches a shared contract, authorization or permission data, a migration, or security; other changes rest on their check and the author's own read of the diff. A review anchor names the realistic use it judges against and blocks on P0 and P1 findings only, recording lower ones as follow-ups. Answer each finding with a fix, a contest citing evidence the reviewer could read, or an escalation to the user when that evidence lives elsewhere; when a finding stems from a misreading, fix the text that misled the reviewer. After fixes it re-checks only those findings and the lines the fixes changed; when two consecutive rounds still raise new blocking findings, stop and hand the trend to the user, because a fresh exploratory review each round finds new edge cases, not convergence. The checkpoint is normally once, after the last increment.
19
19
 
20
20
  ## Act
21
21
 
@@ -29,11 +29,11 @@ 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. An anchor that passed, failed, and passed again is recorded by the extension; reflect on that entry like any other.
32
+ An anchor that passed, failed, and passed again is recorded and reflected as resolved by the extension. Record `anchorloop_stage friction` yourself only for a failure whose cause a later attempt revealed, or when an instruction misled an attempt (`led_by`).
33
33
 
34
34
  ### Decision Trail
35
35
 
36
- A run the user reviews after stepping away records one row per fork, completed unit, pivot, or blocker in `scratch/decisions-<task-slug>.tsv` with the `anchorloop_decision` tool. Evidence is a pointer (commit, `file:line`, artifact path), never prose, and a wrong call gets a superseding row instead of an edit. Before handing back, check every row against what the run did and build the report from the trail.
36
+ A run the user reviews after stepping away records one row per fork, pivot, or blocker in `scratch/decisions-<task-slug>.tsv` with the `anchorloop_decision` tool; completed units come from `git log`. Evidence is a pointer (commit, `file:line`, artifact path), never prose, and a wrong call gets a superseding row instead of an edit. Before handing back, check every row against what the run did and build the report from the trail.
37
37
 
38
38
  ## Verify
39
39
 
@@ -41,7 +41,7 @@ Exercise the anchor and report what it observed. A requirement the anchor did no
41
41
 
42
42
  A review anchor stays open until an independent reviewer returns its verdict. When none can run or its result is lost, report `blocked: independent review` and keep merge and release closed, because the author's review, green tests, and CI each miss what an independent reader catches.
43
43
 
44
- Write the reply the playbook's `Reply:` line names, with the anchor's evidence. The Jev second thought reports the next phase and is advisory: recheck the evidence it questions, then decide. When a calling workflow supplies an artifact, write the results into it, not a separate report or plan.
44
+ Write the reply the playbook's `Reply:` line names, with the anchor's evidence. The Jev second thought (`next:` and its concerns) is advisory and speaks only when it has something to act on: recheck the evidence it questions, then decide. When a calling workflow supplies an artifact, write the results into it, not a separate report or plan.
45
45
 
46
46
  ### Reflexive Pass
47
47
 
@@ -5,7 +5,7 @@
5
5
  1. Align: state the exit condition as a predicate a command or artifact can pass or fail; a duration is not one.
6
6
  2. Anchor: the predicate is the anchor. Set the wake mechanism: `MonitorCreate` with `onDone` or `onSignal` for an event (CI, a merge, a job), a `LoopCreate` heartbeat otherwise, plus one safety deadline.
7
7
  3. Act: each iteration makes the smallest change the evidence justifies, checks it against the predicate, commits when it advanced, and discards it when it did not.
8
- 4. Act: record one decision-trail row per iteration, after a `start` row when continuing an existing trail.
8
+ 4. Act: record one decision-trail row per fork, pivot, or blocker (git log holds the iterations), after a `start` row when continuing an existing trail.
9
9
  5. Act: fix reversible mid-run discoveries yourself in their own commits, then return to the predicate. Bring only irreversible actions, user-owned decisions, or a real dead end to the user, and leave them in the trail when the user is asleep.
10
10
  6. Verify: stop when the predicate holds. On a plateau, change approach; never relax the predicate to declare victory. A genuine dead end ends the run with a written reason.
11
11
 
@@ -11,7 +11,7 @@ Run the loop in [../anchorloop/SKILL.md](../anchorloop/SKILL.md) with one of the
11
11
 
12
12
  Before the first production edit, classify the change by what it reaches, not by how settled the approach feels:
13
13
 
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.
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 loop's intent (the first todo's subject; a `kind` call is only for choosing reviewed or durable) states the observable behavior after the change; the user's source-change request approves it, and approval already given is not asked again.
15
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
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
17