pi-anchorloop 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +63 -2
- package/README.zh-TW.md +64 -0
- package/hooks/validate_tool_use.py +88 -0
- package/hooks/validators/__init__.py +1 -0
- package/hooks/validators/constants.py +47 -0
- package/hooks/validators/durable_validator.py +89 -0
- package/hooks/validators/rule_validator.py +42 -0
- package/hooks/validators/skill_validator.py +144 -0
- package/hooks/validators/utils.py +71 -0
- package/package.json +44 -4
- package/pi/anchor-gate.ts +84 -0
- package/pi/decision-tool.ts +88 -0
- package/pi/second-thought.ts +242 -0
- package/pi/support/validators.ts +403 -0
- package/pi/ttsr.ts +516 -0
- package/pi/validate-tool-use.ts +27 -0
- package/skills/agent-terse/SKILL.md +46 -0
- package/skills/anchorloop/PRINCIPLES.md +34 -0
- package/skills/anchorloop/SKILL.md +76 -0
- package/skills/anchorloop/playbooks/autonomous-run.md +12 -0
- package/skills/anchorloop/playbooks/pause.md +10 -0
- package/skills/anchorloop/playbooks/pickup.md +11 -0
- package/skills/answering/SKILL.md +19 -0
- package/skills/answering/playbooks/compare.md +9 -0
- package/skills/answering/playbooks/explain.md +9 -0
- package/skills/coding/MINI-SDD.md +61 -0
- package/skills/coding/SKILL.md +27 -0
- package/skills/coding/playbooks/bug-fix.md +11 -0
- package/skills/coding/playbooks/feature.md +15 -0
- package/skills/coding/playbooks/perf.md +10 -0
- package/skills/coding/playbooks/prototype.md +14 -0
- package/skills/coding/playbooks/refactoring.md +12 -0
- package/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +48 -0
- package/skills/grilling/SKILL.md +30 -0
- package/skills/human-feedback/SKILL.md +83 -0
- package/skills/inspecting/SKILL.md +49 -0
- package/skills/inspecting/playbooks/diff-review.md +57 -0
- package/skills/inspecting/playbooks/qa-release.md +9 -0
- package/skills/investigating/SKILL.md +22 -0
- package/skills/investigating/playbooks/diagnose.md +9 -0
- package/skills/investigating/playbooks/research.md +9 -0
- package/skills/solid-loop/SKILL.md +50 -0
- package/skills/solid-loop/playbooks/apply-friction.md +15 -0
- package/skills/solid-loop/playbooks/author-skill.md +11 -0
- package/skills/solid-loop/playbooks/bootstrap.md +10 -0
- package/skills/solid-loop/references/template-skill.md +26 -0
- package/skills/writing/SKILL.md +16 -0
- package/skills/writing/playbooks/comments.md +10 -0
- package/skills/writing/playbooks/draft.md +10 -0
- package/skills/writing/playbooks/tighten.md +9 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of hooks/validators/*.py and the routing in hooks/validate_tool_use.py:
|
|
3
|
+
* checks an edited file against skill compactness, direct expected behavior, and
|
|
4
|
+
* durable review standards, so the pi extension needs no Python on the machine.
|
|
5
|
+
* Behavior mirrors the Python validators; tests/test_validator_parity.py compares them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
// ---- constants (hooks/validators/constants.py) ----
|
|
12
|
+
|
|
13
|
+
const SKILL_ALLOWED_FIELDS = new Set(["name", "description"]);
|
|
14
|
+
const SKILL_MAX_RECOMMENDED_LINES = 300;
|
|
15
|
+
|
|
16
|
+
const DEFENSIVE_PATTERNS = [
|
|
17
|
+
"never under any circumstances",
|
|
18
|
+
"under no circumstances",
|
|
19
|
+
"you must never ever",
|
|
20
|
+
"do not under any condition",
|
|
21
|
+
"strict red line",
|
|
22
|
+
"absolute red line",
|
|
23
|
+
"prohibited at all costs",
|
|
24
|
+
"zero tolerance for any",
|
|
25
|
+
"absolutely forbidden to ever",
|
|
26
|
+
"無限多的紅線",
|
|
27
|
+
"千萬不要",
|
|
28
|
+
"嚴禁在任何情況下",
|
|
29
|
+
"絕對不可",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Python's \b treats every letter, digit, and underscore as a word character; JS's \b is ASCII only.
|
|
33
|
+
const WORD = "[\\p{L}\\p{N}_]";
|
|
34
|
+
const wordAround = (alternatives: string) => `(?<!${WORD})(?:${alternatives})(?!${WORD})`;
|
|
35
|
+
|
|
36
|
+
const RED_LINE_MARKERS = new RegExp(
|
|
37
|
+
`${wordAround("never|must not|do not|don't|forbidden|barred|prohibited|avoid")}|不要|禁止|不得|不准|嚴禁`,
|
|
38
|
+
"giu",
|
|
39
|
+
);
|
|
40
|
+
const RED_LINE_MIN_BUDGET = 3;
|
|
41
|
+
const RED_LINE_DENSITY = 0.1;
|
|
42
|
+
const EMPHATIC_WORDS = new RegExp(wordAround("(NEVER|ALWAYS|MUST NOT|MUST|FORBIDDEN|DO NOT)"), "gu");
|
|
43
|
+
const REASON_WORDS = new RegExp(`${wordAround("because|otherwise|so that|since")}|否則|因為|以免`, "iu");
|
|
44
|
+
|
|
45
|
+
const VALID_VERIFICATION_RESULTS = new Set(["pass", "fail", "unknown"]);
|
|
46
|
+
|
|
47
|
+
// ---- Python-compatible string and file helpers ----
|
|
48
|
+
|
|
49
|
+
/** Python str.splitlines(): splits on the same line boundaries and drops a trailing empty line. */
|
|
50
|
+
function splitlines(text: string): string[] {
|
|
51
|
+
const lines = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
|
|
52
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Python str.strip(chars): removes any of `chars` from both ends. */
|
|
57
|
+
function stripChars(text: string, chars: string): string {
|
|
58
|
+
let start = 0;
|
|
59
|
+
let end = text.length;
|
|
60
|
+
while (start < end && chars.includes(text[start])) start++;
|
|
61
|
+
while (end > start && chars.includes(text[end - 1])) end--;
|
|
62
|
+
return text.slice(start, end);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Path.read_text(encoding="utf-8"): strict UTF-8, BOM kept, newlines translated to \n. */
|
|
66
|
+
function readText(path: string): string {
|
|
67
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
68
|
+
return decoder.decode(readFileSync(path)).replace(/\r\n?/g, "\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Path.resolve(): absolute, symlinks resolved through the nearest existing parent. */
|
|
72
|
+
function real(path: string): string {
|
|
73
|
+
const absolute = resolve(path);
|
|
74
|
+
if (existsSync(absolute)) return realpathSync(absolute);
|
|
75
|
+
const parent = dirname(absolute);
|
|
76
|
+
return parent === absolute ? absolute : join(real(parent), basename(absolute));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function within(root: string, target: string): boolean {
|
|
80
|
+
const rel = relative(root, target);
|
|
81
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const isDirectory = (path: string) => {
|
|
85
|
+
try {
|
|
86
|
+
return statSync(path).isDirectory();
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
|
|
93
|
+
|
|
94
|
+
// ---- utils (hooks/validators/utils.py) ----
|
|
95
|
+
|
|
96
|
+
function parseFrontmatter(text: string): Map<string, string> | undefined {
|
|
97
|
+
if (!text.startsWith("---")) return undefined;
|
|
98
|
+
const end = text.indexOf("\n---", 3);
|
|
99
|
+
if (end === -1) return undefined;
|
|
100
|
+
const block = text.slice(3, end).trim();
|
|
101
|
+
const result = new Map<string, string>();
|
|
102
|
+
let currentKey: string | undefined;
|
|
103
|
+
let currentVal: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const line of splitlines(block)) {
|
|
106
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
107
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
108
|
+
if (match && !line.startsWith(" ")) {
|
|
109
|
+
if (currentKey !== undefined) result.set(currentKey, stripChars(currentVal.join("\n").trim(), "\"'"));
|
|
110
|
+
currentKey = match[1];
|
|
111
|
+
const rawVal = match[2].trim();
|
|
112
|
+
currentVal = rawVal ? [rawVal] : [];
|
|
113
|
+
} else if (currentKey !== undefined) {
|
|
114
|
+
currentVal.push(line.trim());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (currentKey !== undefined) result.set(currentKey, stripChars(currentVal.join("\n").trim(), "\"'"));
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extractMarkdownLinks(text: string): string[] {
|
|
122
|
+
const stripped = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]+`/g, "");
|
|
123
|
+
const links: string[] = [];
|
|
124
|
+
for (const match of stripped.matchAll(/\[(?:[^\]]*)\]\(([^)#\s]+)(?:#[^)]*)?\)/g)) links.push(match[1]);
|
|
125
|
+
return links.filter((link) => !/^[a-zA-Z]+:\/\//.test(link) && !link.startsWith("mailto:"));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Blank out fenced and inline code while keeping line numbers stable. */
|
|
129
|
+
function stripCode(text: string): string {
|
|
130
|
+
const fenced = text.replace(/```[\s\S]*?```/g, (block) => "\n".repeat(block.split("\n").length - 1));
|
|
131
|
+
return fenced.replace(/`[^`\n]+`/g, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function findDefensivePhrases(text: string, patterns: string[]): string[] {
|
|
135
|
+
const stripped = stripCode(text);
|
|
136
|
+
return patterns.filter((pattern) => new RegExp(pattern, "iu").test(stripped));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- rule files (rule_validator.py) ----
|
|
140
|
+
|
|
141
|
+
const SIMPLIFIED_CHINESE_CHARS = "为这就从时会过发对门书见长经说样开";
|
|
142
|
+
|
|
143
|
+
function checkRulesFile(path: string): string[] {
|
|
144
|
+
const warnings: string[] = [];
|
|
145
|
+
let text: string;
|
|
146
|
+
try {
|
|
147
|
+
text = readText(path);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return [`failed to read rule file: ${errorMessage(error)}`];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const found = findDefensivePhrases(text, DEFENSIVE_PATTERNS);
|
|
153
|
+
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
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const lines = splitlines(text)
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
163
|
+
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
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const simplified = [...SIMPLIFIED_CHINESE_CHARS].filter((char) => text.includes(char));
|
|
171
|
+
if (simplified.length) {
|
|
172
|
+
warnings.push(
|
|
173
|
+
`possible Simplified Chinese detected (${simplified.slice(0, 5).join("")}). Always use Traditional Chinese.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return warnings;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- SKILL.md (skill_validator.py) ----
|
|
180
|
+
|
|
181
|
+
function checkSkillMd(path: string): string[] {
|
|
182
|
+
const warnings: string[] = [];
|
|
183
|
+
let text: string;
|
|
184
|
+
try {
|
|
185
|
+
text = readText(path);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return [`failed to read SKILL.md: ${errorMessage(error)}`];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const skillDir = dirname(path);
|
|
191
|
+
const lines = splitlines(text);
|
|
192
|
+
|
|
193
|
+
const fields = parseFrontmatter(text);
|
|
194
|
+
if (!fields) {
|
|
195
|
+
warnings.push("missing or invalid YAML frontmatter (--- ... ---)");
|
|
196
|
+
} else {
|
|
197
|
+
const extra = [...fields.keys()].filter((key) => !SKILL_ALLOWED_FIELDS.has(key)).sort();
|
|
198
|
+
if (extra.length) warnings.push(`extra frontmatter fields: ${extra.join(", ")}`);
|
|
199
|
+
|
|
200
|
+
if (!fields.has("name") || !fields.get("name")!.trim()) warnings.push("missing required frontmatter field: 'name'");
|
|
201
|
+
|
|
202
|
+
// The regex parser accepts what pi's YAML parser rejects, and pi then skips the whole skill.
|
|
203
|
+
for (const key of ["name", "description"]) {
|
|
204
|
+
const line = lines.slice(1).find((candidate) => candidate.startsWith(`${key}:`));
|
|
205
|
+
const raw = line === undefined ? "" : line.slice(line.indexOf(":") + 1).trim();
|
|
206
|
+
if (raw && !"\"'|>".includes(raw[0]) && (raw.includes(": ") || raw.includes(" #"))) {
|
|
207
|
+
warnings.push(`quote the ${key}: YAML reads an unquoted ': ' or ' #' as syntax, and pi skips the skill`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!fields.has("description") || !fields.get("description")!.trim()) {
|
|
212
|
+
warnings.push("missing required frontmatter field: 'description'");
|
|
213
|
+
} else if (!fields.get("description")!.toLowerCase().includes("use when")) {
|
|
214
|
+
warnings.push("description should specify trigger criteria (include 'Use when...')");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
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
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Broken markdown links, followed through every Markdown file SKILL.md reaches.
|
|
226
|
+
const root = real(skillDir);
|
|
227
|
+
const self = real(path);
|
|
228
|
+
const reached = new Map<string, string>([[self, text]]);
|
|
229
|
+
const linked = new Set<string>();
|
|
230
|
+
const pending = [self];
|
|
231
|
+
while (pending.length) {
|
|
232
|
+
const source = pending.pop()!;
|
|
233
|
+
for (const link of extractMarkdownLinks(reached.get(source)!)) {
|
|
234
|
+
const target = real(join(dirname(source), link));
|
|
235
|
+
if (!existsSync(target)) {
|
|
236
|
+
const where = source === self ? "" : ` (in ${relative(root, source).replaceAll("\\", "/")})`;
|
|
237
|
+
warnings.push(`broken local link: ${link}${where}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
linked.add(target);
|
|
241
|
+
if (target.endsWith(".md") && within(root, target) && !reached.has(target)) {
|
|
242
|
+
reached.set(target, readText(target));
|
|
243
|
+
pending.push(target);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Orphaned files.
|
|
249
|
+
const corpus = [...reached.values()].join("\n");
|
|
250
|
+
const walk = (dir: string): string[] =>
|
|
251
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
252
|
+
const full = join(dir, entry.name);
|
|
253
|
+
return entry.isDirectory() ? walk(full) : [full];
|
|
254
|
+
});
|
|
255
|
+
for (const file of walk(skillDir)) {
|
|
256
|
+
if (file === path || isDirectory(file)) continue;
|
|
257
|
+
const rel = relative(skillDir, file).replaceAll("\\", "/");
|
|
258
|
+
if (!(linked.has(real(file)) || corpus.includes(rel) || corpus.includes(basename(file)))) {
|
|
259
|
+
warnings.push(`orphaned file not referenced in SKILL.md: ${rel}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const defensive = findDefensivePhrases(text, DEFENSIVE_PATTERNS);
|
|
264
|
+
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
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return warnings;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Red-line advisories for the author; they never count as validation failures. */
|
|
274
|
+
function checkSkillAdvisories(path: string): string[] {
|
|
275
|
+
let text: string;
|
|
276
|
+
try {
|
|
277
|
+
text = readText(path);
|
|
278
|
+
} catch {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const body = stripCode(text);
|
|
283
|
+
const lines = splitlines(body);
|
|
284
|
+
const advisories: string[] = [];
|
|
285
|
+
|
|
286
|
+
const markers = (body.match(RED_LINE_MARKERS) ?? []).length;
|
|
287
|
+
const budget = Math.max(RED_LINE_MIN_BUDGET, Math.floor(lines.filter((line) => line.trim()).length * RED_LINE_DENSITY));
|
|
288
|
+
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
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const seen = new Map<string, number>();
|
|
296
|
+
lines.forEach((line, index) => {
|
|
297
|
+
for (const match of line.matchAll(EMPHATIC_WORDS)) {
|
|
298
|
+
const word = match[1];
|
|
299
|
+
seen.set(word, (seen.get(word) ?? 0) + 1);
|
|
300
|
+
const window = lines.slice(index, index + 2).join(" ");
|
|
301
|
+
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
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
for (const [word, count] of seen) {
|
|
310
|
+
if (count > 1) {
|
|
311
|
+
advisories.push(`emphatic '${word}' appears ${count} times. Spend emphasis once, on the rule that causes real damage.`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return advisories;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- durable specs (durable_validator.py) ----
|
|
318
|
+
|
|
319
|
+
function checkDecisionMd(path: string): string[] {
|
|
320
|
+
const warnings: string[] = [];
|
|
321
|
+
const text = readText(path);
|
|
322
|
+
if (!/\|\s*Question\s*\|\s*Answer\s*\|\s*Basis\s*\|\s*Status\s*\|/i.test(text)) {
|
|
323
|
+
warnings.push("decision.md should include a Question | Answer | Basis | Status table.");
|
|
324
|
+
}
|
|
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
|
+
return warnings;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function checkSpecMd(path: string): string[] {
|
|
333
|
+
const warnings: string[] = [];
|
|
334
|
+
const text = readText(path);
|
|
335
|
+
if (!/Status:\s*(proposed|approved)/i.test(text)) {
|
|
336
|
+
warnings.push("spec.md must declare 'Status: proposed' or 'Status: approved' at the top.");
|
|
337
|
+
}
|
|
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
|
+
return warnings;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function checkVerificationMd(path: string): string[] {
|
|
345
|
+
const warnings: string[] = [];
|
|
346
|
+
const text = readText(path);
|
|
347
|
+
|
|
348
|
+
if (!/\|\s*Requirement\s*\|\s*Evidence\s*\|\s*Result\s*\|/i.test(text)) {
|
|
349
|
+
warnings.push("verification.md must include a 'Requirement | Evidence | Result' table.");
|
|
350
|
+
} else {
|
|
351
|
+
const results = [...text.matchAll(/\|\s*[^|]+\s*\|\s*[^|]+\s*\|\s*([a-zA-Z]+)\s*\|/g)].map((match) => match[1].toLowerCase());
|
|
352
|
+
const invalid = [...new Set(results.filter((result) => !VALID_VERIFICATION_RESULTS.has(result) && result !== "result"))];
|
|
353
|
+
if (invalid.length) {
|
|
354
|
+
warnings.push(
|
|
355
|
+
`invalid verification Result values found: ${invalid.join(", ")}. Result must be one of: pass, fail, unknown.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const defensive = findDefensivePhrases(text, DEFENSIVE_PATTERNS);
|
|
361
|
+
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
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return warnings;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function checkDurableSpec(path: string): string[] {
|
|
371
|
+
const name = basename(path).toLowerCase();
|
|
372
|
+
if (name === "decision.md" || name === "requirements.md") return checkDecisionMd(path);
|
|
373
|
+
if (name === "spec.md") return checkSpecMd(path);
|
|
374
|
+
if (name === "verification.md") return checkVerificationMd(path);
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---- routing (validate_tool_use.py) ----
|
|
379
|
+
|
|
380
|
+
/** Route a file to the validator its name and location call for. */
|
|
381
|
+
export function runValidation(path: string): string[] {
|
|
382
|
+
const name = basename(path).toLowerCase();
|
|
383
|
+
const pathStr = path.replaceAll("\\", "/");
|
|
384
|
+
|
|
385
|
+
if (name === "skill.md") return [...checkSkillMd(path), ...checkSkillAdvisories(path)];
|
|
386
|
+
if (["agents.md", "claude.md", "gemini.md"].includes(name) || pathStr.includes("/rules/")) return checkRulesFile(path);
|
|
387
|
+
if (pathStr.includes("/docs/specs/") && name.endsWith(".md")) return checkDurableSpec(path);
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Validation suggestions for the edited file, or undefined when it is clean, missing, or not validated. */
|
|
392
|
+
export function validateFile(filePath: string, cwd: string): string | undefined {
|
|
393
|
+
const base = cwd || process.cwd();
|
|
394
|
+
const target = isAbsolute(filePath) ? filePath : resolve(base, filePath);
|
|
395
|
+
if (!existsSync(target)) return undefined;
|
|
396
|
+
|
|
397
|
+
const warnings = runValidation(target);
|
|
398
|
+
if (!warnings.length) return undefined;
|
|
399
|
+
|
|
400
|
+
const rel = relative(base, target);
|
|
401
|
+
const shown = rel.startsWith("..") || isAbsolute(rel) ? target : rel;
|
|
402
|
+
return `⚠ [anchorloop] Validation suggestions for ${shown}:\n${warnings.map((warning) => ` - ${warning}`).join("\n")}`;
|
|
403
|
+
}
|