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.
- package/README.md +6 -6
- package/README.zh-TW.md +6 -6
- package/hooks/validate_tool_use.py +1 -1
- package/hooks/validators/constants.py +1 -0
- package/hooks/validators/durable_validator.py +4 -17
- package/hooks/validators/rule_validator.py +3 -5
- package/hooks/validators/skill_validator.py +7 -14
- package/package.json +1 -1
- package/pi/anchor-gate.ts +15 -2
- package/pi/book.ts +115 -81
- package/pi/decision-tool.ts +7 -9
- package/pi/second-thought.ts +56 -27
- package/pi/support/book.ts +75 -4
- package/pi/support/phases.ts +7 -12
- package/pi/support/snapshot.ts +22 -2
- package/pi/support/validators.ts +13 -44
- package/pi/ttsr.ts +7 -30
- package/pi/validate-tool-use.ts +1 -1
- package/skills/anchorloop/SKILL.md +6 -6
- package/skills/anchorloop/playbooks/autonomous-run.md +1 -1
- package/skills/coding/SKILL.md +1 -1
package/pi/support/book.ts
CHANGED
|
@@ -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);
|
package/pi/support/phases.ts
CHANGED
|
@@ -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
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
|
|
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
|
|
362
|
+
/** One line: the phases still missing before the one entered. */
|
|
366
363
|
export function jumpLine(entering: string, missingBefore: Status[]): string {
|
|
367
|
-
|
|
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. */
|
package/pi/support/snapshot.ts
CHANGED
|
@@ -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
|
|
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: `${
|
|
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
|
}
|
package/pi/support/validators.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
480
|
+
content: [...event.content, { type: "text", text: rules.map((rule) => render(rule)).join("\n\n") }],
|
|
504
481
|
};
|
|
505
482
|
});
|
|
506
483
|
|
package/pi/validate-tool-use.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
package/skills/coding/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|