ll-skills 2.0.2 → 3.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/CHANGELOG.md +21 -0
- package/README.md +42 -20
- package/agents/ll-executor.md +1 -0
- package/assets/preamble.md +29 -35
- package/bin/install.js +4 -1
- package/hooks/ll-precompact.js +29 -1
- package/hooks/ll-skills-check-update.js +6 -6
- package/hooks/ll-state.js +30 -2
- package/package.json +3 -2
- package/scripts/evals/README.md +57 -0
- package/scripts/evals/cases/auto-dry-run/assert.sh +35 -0
- package/scripts/evals/cases/auto-dry-run/case.json +8 -0
- package/scripts/evals/cases/auto-dry-run/prompt.txt +1 -0
- package/scripts/evals/cases/auto-empty-repo/assert.sh +25 -0
- package/scripts/evals/cases/auto-empty-repo/case.json +8 -0
- package/scripts/evals/cases/auto-empty-repo/fixture/.gitkeep +0 -0
- package/scripts/evals/cases/auto-empty-repo/prompt.txt +1 -0
- package/scripts/evals/cases/decide-final-round/assert.sh +32 -0
- package/scripts/evals/cases/decide-final-round/case.json +8 -0
- package/scripts/evals/cases/decide-final-round/fixture/README.md +3 -0
- package/scripts/evals/cases/decide-final-round/prompt.txt +1 -0
- package/scripts/evals/cases/executor-block/assert.sh +33 -0
- package/scripts/evals/cases/executor-block/case.json +8 -0
- package/scripts/evals/cases/executor-block/prompt.txt +14 -0
- package/scripts/evals/cases/goal-autonomous/assert.sh +35 -0
- package/scripts/evals/cases/goal-autonomous/case.json +8 -0
- package/scripts/evals/cases/goal-autonomous/fixture/PLAN.md +42 -0
- package/scripts/evals/cases/goal-autonomous/fixture/PROGRESS.md +20 -0
- package/scripts/evals/cases/goal-autonomous/fixture/ROADMAP.md +29 -0
- package/scripts/evals/cases/goal-autonomous/fixture/package.json +8 -0
- package/scripts/evals/cases/goal-autonomous/fixture/src/money.js +6 -0
- package/scripts/evals/cases/goal-autonomous/fixture/test/reconcile.test.js +8 -0
- package/scripts/evals/cases/goal-autonomous/prompt.txt +1 -0
- package/scripts/evals/cases/implement-review-gate/assert.sh +35 -0
- package/scripts/evals/cases/implement-review-gate/case.json +8 -0
- package/scripts/evals/cases/implement-review-gate/prompt.txt +1 -0
- package/scripts/evals/cases/implement-stops-at-next/assert.sh +39 -0
- package/scripts/evals/cases/implement-stops-at-next/case.json +9 -0
- package/scripts/evals/cases/implement-stops-at-next/prompt.txt +1 -0
- package/scripts/evals/cases/preamble-no-ritual/assert.sh +17 -0
- package/scripts/evals/cases/preamble-no-ritual/case.json +8 -0
- package/scripts/evals/cases/preamble-no-ritual/fixture/README.md +3 -0
- package/scripts/evals/cases/preamble-no-ritual/fixture/src/a.ts +3 -0
- package/scripts/evals/cases/preamble-no-ritual/prompt.txt +1 -0
- package/scripts/evals/cases/router-execute/assert.sh +12 -0
- package/scripts/evals/cases/router-execute/case.json +8 -0
- package/scripts/evals/cases/router-execute/prompt.txt +1 -0
- package/scripts/evals/cases/router-research/assert.sh +11 -0
- package/scripts/evals/cases/router-research/case.json +8 -0
- package/scripts/evals/cases/router-research/fixture/README.md +3 -0
- package/scripts/evals/cases/router-research/prompt.txt +1 -0
- package/scripts/evals/cases/router-small/assert.sh +21 -0
- package/scripts/evals/cases/router-small/case.json +8 -0
- package/scripts/evals/cases/router-small/fixture/README.md +17 -0
- package/scripts/evals/cases/router-small/prompt.txt +1 -0
- package/scripts/evals/cases/scout-no-plan/assert.sh +41 -0
- package/scripts/evals/cases/scout-no-plan/case.json +8 -0
- package/scripts/evals/cases/scout-no-plan/prompt.txt +8 -0
- package/scripts/evals/cases/verifier-weakened-test/assert.sh +19 -0
- package/scripts/evals/cases/verifier-weakened-test/case.json +8 -0
- package/scripts/evals/cases/verifier-weakened-test/prompt.txt +13 -0
- package/scripts/evals/cases/verifier-weakened-test/setup.sh +19 -0
- package/scripts/evals/fixtures/manual-contract/out.json +29 -0
- package/scripts/evals/fixtures/manual-contract/out.txt +5 -0
- package/scripts/evals/fixtures/manual-contract/with-skill.json +46 -0
- package/scripts/evals/lib/assert.sh +107 -0
- package/scripts/evals/lib/extract.js +73 -0
- package/scripts/evals/run.sh +369 -0
- package/scripts/fixtures/auto-closed/PLAN.md +5 -0
- package/scripts/fixtures/auto-closed/PROGRESS.md +20 -0
- package/scripts/fixtures/auto-closed/ROADMAP.md +6 -0
- package/scripts/fixtures/auto-closed/docs/DELIVERY.md +3 -0
- package/scripts/fixtures/auto-decisions/decisions/DEC-0001-taken-alone.md +13 -0
- package/scripts/fixtures/auto-decisions/decisions/DEC-0002-owner.md +13 -0
- package/scripts/fixtures/auto-noroadmap/PLAN.md +20 -0
- package/scripts/fixtures/auto-noroadmap/PROGRESS.md +11 -0
- package/scripts/fixtures/auto-verify-next/PLAN.md +5 -0
- package/scripts/fixtures/auto-verify-next/PROGRESS.md +18 -0
- package/scripts/fixtures/auto-verify-next/ROADMAP.md +5 -0
- package/scripts/fixtures/auto-verify-next/phases/01/PLAN.md +6 -0
- package/scripts/fixtures/evals-auto/auto-dry-run/pass.txt +18 -0
- package/scripts/fixtures/evals-auto/auto-empty-repo/pass.txt +2 -0
- package/scripts/fixtures/evals-auto/goal-autonomous/pass.txt +29 -0
- package/scripts/fixtures/lint-bad/folded-description/SKILL.md +13 -0
- package/scripts/fixtures/lint-bad/model-invocation-false/SKILL.md +10 -0
- package/scripts/fixtures/next-bad/skills/ll-bad/SKILL.md +30 -0
- package/scripts/fixtures/next-good/skills/ll-good/SKILL.md +26 -0
- package/scripts/fixtures/project/PROGRESS.md +4 -0
- package/scripts/lint-contract.cjs +495 -0
- package/scripts/lint-prompts.sh +396 -0
- package/scripts/ll-tools.js +465 -447
- package/scripts/smoke-test.sh +380 -1
- package/skills/ll-auto/SKILL.md +74 -0
- package/skills/ll-auto/references/run.md +75 -0
- package/skills/ll-auto/references/stages.md +66 -0
- package/skills/ll-auto/scripts/ll-auto.js +345 -0
- package/skills/ll-brainstorm/SKILL.md +5 -4
- package/skills/ll-brainstorm/references/decision-policy.md +3 -0
- package/skills/ll-close/SKILL.md +5 -5
- package/skills/ll-close/references/delivery.md +3 -1
- package/skills/ll-decide/SKILL.md +13 -11
- package/skills/ll-decide/references/decision-policy.md +3 -0
- package/skills/ll-decide/references/interview.md +10 -0
- package/skills/ll-decide/references/plan-skeleton.md +14 -14
- package/skills/ll-decide/references/premise-gate.md +7 -0
- package/skills/ll-goal/SKILL.md +22 -4
- package/skills/ll-goal/references/goal-template.md +57 -0
- package/skills/ll-implement/SKILL.md +4 -2
- package/skills/ll-implement/references/decision-policy.md +3 -0
- package/skills/ll-oncall/SKILL.md +3 -2
- package/skills/ll-refine/SKILL.md +3 -2
- package/skills/ll-research/SKILL.md +3 -2
- package/skills/ll-resume/SKILL.md +4 -3
- package/skills/ll-update/SKILL.md +6 -1
- package/skills/ll-verify/SKILL.md +2 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// lint-contract.cjs — deterministic contract lint between the pieces of the package.
|
|
4
|
+
// Node >= 18, no dependencies, runs from any cwd.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/lint-contract.cjs all rules
|
|
7
|
+
// node scripts/lint-contract.cjs --rule 2 one rule
|
|
8
|
+
// node scripts/lint-contract.cjs --json machine output
|
|
9
|
+
// node scripts/lint-contract.cjs --root <dir> lint another tree (fixtures)
|
|
10
|
+
//
|
|
11
|
+
// Checks the seams that no test covers: SKILL.md <-> references/, helper commands <-> ll-tools.js,
|
|
12
|
+
// skills <-> agents, verdict vocabulary, state-file names, `Next` targets, installer <-> package.
|
|
13
|
+
// Exit 1 when any rule FAILs; WARN never changes the exit code.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// `--root <dir>` replaces the package root for the whole run (the rule fixtures use it).
|
|
19
|
+
// Parsed here, before the consts below are built from ROOT at module load.
|
|
20
|
+
const ROOT = (() => {
|
|
21
|
+
const at = process.argv.indexOf('--root');
|
|
22
|
+
const given = at >= 0 ? process.argv[at + 1] : null;
|
|
23
|
+
return given ? path.resolve(process.cwd(), given) : path.resolve(__dirname, '..');
|
|
24
|
+
})();
|
|
25
|
+
const J = path.join;
|
|
26
|
+
const NEXT_MARK = '▶ Next';
|
|
27
|
+
const MODELS = 'opus|sonnet|haiku|fable';
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// filesystem, read once per file
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const TEXT = new Map();
|
|
34
|
+
function text(file) {
|
|
35
|
+
if (!TEXT.has(file)) {
|
|
36
|
+
let v = null;
|
|
37
|
+
try { v = fs.readFileSync(file, 'utf8'); } catch { v = null; }
|
|
38
|
+
TEXT.set(file, v);
|
|
39
|
+
}
|
|
40
|
+
return TEXT.get(file);
|
|
41
|
+
}
|
|
42
|
+
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
43
|
+
function ls(p) { try { return fs.readdirSync(p).sort(); } catch { return []; } }
|
|
44
|
+
function walk(dir, out = []) {
|
|
45
|
+
for (const name of ls(dir)) {
|
|
46
|
+
const full = J(dir, name);
|
|
47
|
+
if (isDir(full)) walk(full, out); else out.push(full);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function rel(p) { return path.relative(ROOT, p).split(path.sep).join('/'); }
|
|
52
|
+
function uniq(a) { return Array.from(new Set(a)); }
|
|
53
|
+
function esc(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
54
|
+
function word(token) { return new RegExp('\\b' + esc(token).replace(/\\?\s+/g, '\\s+') + '\\b'); }
|
|
55
|
+
function matches(re, s) { return uniq((s || '').match(re) || []); }
|
|
56
|
+
function captures(re, s, group = 1) {
|
|
57
|
+
const out = []; let m;
|
|
58
|
+
const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
59
|
+
while ((m = r.exec(s || '')) !== null) out.push(m[group]);
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// the pieces of the package
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const SKILL_NAMES = ls(J(ROOT, 'skills')).filter((n) => isDir(J(ROOT, 'skills', n)));
|
|
68
|
+
const SKILL_DOCS = SKILL_NAMES.map((n) => J(ROOT, 'skills', n, 'SKILL.md')).filter((f) => text(f) !== null);
|
|
69
|
+
const SKILL_TREE = walk(J(ROOT, 'skills')).filter((f) => f.endsWith('.md'));
|
|
70
|
+
const AGENT_FILES = ls(J(ROOT, 'agents')).filter((n) => n.endsWith('.md')).map((n) => J(ROOT, 'agents', n));
|
|
71
|
+
const HOOK_FILES = ls(J(ROOT, 'hooks')).filter((n) => n.endsWith('.js')).map((n) => J(ROOT, 'hooks', n));
|
|
72
|
+
const ASSET_FILES = walk(J(ROOT, 'assets'));
|
|
73
|
+
const README = J(ROOT, 'README.md');
|
|
74
|
+
const TOOLS = J(ROOT, 'scripts', 'll-tools.js');
|
|
75
|
+
const INSTALLER = J(ROOT, 'bin', 'install.js');
|
|
76
|
+
const PREAMBLE = J(ROOT, 'assets', 'preamble.md');
|
|
77
|
+
const BRIEFS = J(ROOT, 'skills', 'll-implement', 'references', 'briefs.md');
|
|
78
|
+
|
|
79
|
+
function joined(files) { return files.map((f) => text(f) || '').join('\n'); }
|
|
80
|
+
|
|
81
|
+
// One row per SKILL.md and per agent: how many of its outbound references resolve.
|
|
82
|
+
const PIECES = new Map();
|
|
83
|
+
function piece(file) {
|
|
84
|
+
const key = rel(file);
|
|
85
|
+
if (!PIECES.has(key)) PIECES.set(key, { ok: 0, total: 0 });
|
|
86
|
+
return PIECES.get(key);
|
|
87
|
+
}
|
|
88
|
+
for (const f of SKILL_DOCS.concat(AGENT_FILES)) piece(f);
|
|
89
|
+
function refCount(file, ok) {
|
|
90
|
+
const key = rel(file);
|
|
91
|
+
if (!PIECES.has(key)) return;
|
|
92
|
+
const row = PIECES.get(key);
|
|
93
|
+
row.total += 1;
|
|
94
|
+
if (ok) row.ok += 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// rules
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function result() { return { checked: 0, fails: [], warns: [] }; }
|
|
102
|
+
|
|
103
|
+
// 1. Every references/<x>.md cited by a SKILL.md exists; every reference file is cited.
|
|
104
|
+
function rule1() {
|
|
105
|
+
const r = result();
|
|
106
|
+
for (const name of SKILL_NAMES) {
|
|
107
|
+
const doc = J(ROOT, 'skills', name, 'SKILL.md');
|
|
108
|
+
const body = text(doc);
|
|
109
|
+
if (body === null) { r.fails.push(`skills/${name}/SKILL.md is missing`); continue; }
|
|
110
|
+
// `references/x.md` belongs to this skill; `ll-other/references/x.md` is a cross-skill pointer
|
|
111
|
+
// and is resolved against the skill it names.
|
|
112
|
+
const cites = new RegExp('(?:(ll-[a-z][a-z-]*)/)?references/([A-Za-z0-9._-]+\\.md)', 'g');
|
|
113
|
+
let m;
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
while ((m = cites.exec(body)) !== null) {
|
|
116
|
+
const owner = m[1] || name;
|
|
117
|
+
const key = owner + '/' + m[2];
|
|
118
|
+
if (seen.has(key)) continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
r.checked += 1;
|
|
121
|
+
const ok = text(J(ROOT, 'skills', owner, 'references', m[2])) !== null;
|
|
122
|
+
refCount(doc, ok);
|
|
123
|
+
if (!ok) r.fails.push(`skills/${name}/SKILL.md cites ${key.replace(name + '/', '')}, which does not exist`);
|
|
124
|
+
}
|
|
125
|
+
const dir = J(ROOT, 'skills', name, 'references');
|
|
126
|
+
for (const file of ls(dir).filter((f) => f.endsWith('.md'))) {
|
|
127
|
+
r.checked += 1;
|
|
128
|
+
if (!body.includes(file)) r.fails.push(`skills/${name}/references/${file} is not mentioned in SKILL.md`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return r;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The command surface of the helper.
|
|
135
|
+
function helperSurface() {
|
|
136
|
+
const src = text(TOOLS) || '';
|
|
137
|
+
const defined = uniq(
|
|
138
|
+
captures(/^C\['([a-z][a-z-]*)'\]\s*=/gm, src).concat(captures(/^C\.([a-z][a-zA-Z-]*)\s*=/gm, src))
|
|
139
|
+
);
|
|
140
|
+
const fmt = uniq(captures(/^\s{2}'?([a-z][a-z-]*)'?:\s*\(/gm, literal(src, 'FMT') || ''));
|
|
141
|
+
return { defined, fmt };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. Helper commands cited anywhere are defined; every command is cited and has a formatter.
|
|
145
|
+
function rule2() {
|
|
146
|
+
const r = result();
|
|
147
|
+
const { defined, fmt } = helperSurface();
|
|
148
|
+
if (!defined.length) { r.fails.push('no command found in scripts/ll-tools.js'); return r; }
|
|
149
|
+
|
|
150
|
+
const sources = SKILL_TREE.concat(AGENT_FILES, HOOK_FILES, [README]);
|
|
151
|
+
const citedIn = new Map(); // command -> [file]
|
|
152
|
+
for (const file of sources) {
|
|
153
|
+
for (const cmd of captures(/ll-tools\.js\s+([a-z-]+)/g, text(file))) {
|
|
154
|
+
if (!citedIn.has(cmd)) citedIn.set(cmd, []);
|
|
155
|
+
citedIn.get(cmd).push(file);
|
|
156
|
+
r.checked += 1;
|
|
157
|
+
const ok = defined.includes(cmd);
|
|
158
|
+
refCount(file, ok);
|
|
159
|
+
if (!ok) r.fails.push(`${rel(file)} cites \`ll-tools.js ${cmd}\`, which is not a defined command`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const docs = new Set(SKILL_TREE.map(rel));
|
|
163
|
+
for (const cmd of defined) {
|
|
164
|
+
r.checked += 2;
|
|
165
|
+
const where = (citedIn.get(cmd) || []).map(rel);
|
|
166
|
+
if (!where.some((f) => docs.has(f))) r.fails.push(`command \`${cmd}\` is defined but no SKILL.md or reference cites it`);
|
|
167
|
+
if (!fmt.includes(cmd)) r.fails.push(`command \`${cmd}\` has no FMT formatter`);
|
|
168
|
+
}
|
|
169
|
+
return r;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The fixed fields of the executor brief, read from briefs.md itself.
|
|
173
|
+
function executorBriefFields() {
|
|
174
|
+
const body = text(BRIEFS);
|
|
175
|
+
if (body === null) return null;
|
|
176
|
+
const lines = body.split('\n');
|
|
177
|
+
const head = lines.findIndex((l) => /^##\s/.test(l) && l.includes('ll-executor'));
|
|
178
|
+
if (head < 0) return null;
|
|
179
|
+
let open = -1, close = -1;
|
|
180
|
+
for (let i = head + 1; i < lines.length; i++) {
|
|
181
|
+
if (/^\s*```/.test(lines[i])) { if (open < 0) open = i; else { close = i; break; } }
|
|
182
|
+
if (/^##\s/.test(lines[i])) break;
|
|
183
|
+
}
|
|
184
|
+
if (open < 0 || close < 0) return null;
|
|
185
|
+
const fields = [];
|
|
186
|
+
for (const line of lines.slice(open + 1, close)) {
|
|
187
|
+
const m = /^([A-Z]{2,}(?: [A-Z]{2,})*)\s{2,}\S/.exec(line);
|
|
188
|
+
if (m) fields.push(m[1]);
|
|
189
|
+
}
|
|
190
|
+
return uniq(fields);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The model a line declares for an agent, looked for right after the agent name only.
|
|
194
|
+
function declaredModel(line, agent) {
|
|
195
|
+
const at = line.indexOf(agent);
|
|
196
|
+
if (at < 0) return null;
|
|
197
|
+
const win = line.slice(at + agent.length, at + agent.length + 60);
|
|
198
|
+
const re = [
|
|
199
|
+
new RegExp('`?model:\\s*`?(' + MODELS + ')'),
|
|
200
|
+
new RegExp('\\((' + MODELS + ')[,/)\\s]'),
|
|
201
|
+
new RegExp('`?(' + MODELS + ')`?\\s*/\\s*(?:high|medium|low)'),
|
|
202
|
+
];
|
|
203
|
+
for (const p of re) { const m = p.exec(win); if (m) return m[1]; }
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 3. Agents cited by the skills exist; the models the skills declare match; the brief fields land.
|
|
208
|
+
function rule3() {
|
|
209
|
+
const r = result();
|
|
210
|
+
const names = ['ll-executor', 'll-scout', 'll-verifier', 'll-reviewer'];
|
|
211
|
+
const nameRe = new RegExp('\\b(' + names.join('|') + ')\\b', 'g');
|
|
212
|
+
|
|
213
|
+
for (const file of SKILL_TREE) {
|
|
214
|
+
const body = text(file) || '';
|
|
215
|
+
for (const agent of matches(nameRe, body)) {
|
|
216
|
+
r.checked += 1;
|
|
217
|
+
const ok = text(J(ROOT, 'agents', agent + '.md')) !== null;
|
|
218
|
+
refCount(file, ok);
|
|
219
|
+
if (!ok) r.fails.push(`${rel(file)} cites agent ${agent}, but agents/${agent}.md does not exist`);
|
|
220
|
+
}
|
|
221
|
+
for (const line of body.split('\n')) {
|
|
222
|
+
for (const agent of matches(nameRe, line)) {
|
|
223
|
+
const declared = declaredModel(line, agent);
|
|
224
|
+
if (!declared) continue;
|
|
225
|
+
const agentBody = text(J(ROOT, 'agents', agent + '.md'));
|
|
226
|
+
if (agentBody === null) continue;
|
|
227
|
+
r.checked += 1;
|
|
228
|
+
const own = (/^model:\s*(\S+)/m.exec(agentBody) || [])[1];
|
|
229
|
+
const overrides = /override|overridden|passed on the call/i.test(line);
|
|
230
|
+
if (declared !== own && !overrides) {
|
|
231
|
+
r.fails.push(`${rel(file)} declares \`${agent}\` as ${declared}, agents/${agent}.md is ${own} (no override stated)`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const fields = executorBriefFields();
|
|
238
|
+
if (fields === null) {
|
|
239
|
+
r.fails.push('could not read the executor brief block in skills/ll-implement/references/briefs.md');
|
|
240
|
+
return r;
|
|
241
|
+
}
|
|
242
|
+
if (fields.length !== 12) r.warns.push(`the executor brief declares ${fields.length} fields, the contract says 12: ${fields.join(', ')}`);
|
|
243
|
+
const agentBody = text(J(ROOT, 'agents', 'll-executor.md'));
|
|
244
|
+
// A field is acknowledged when the agent names it at all; the brief writes it uppercase, the
|
|
245
|
+
// agent body may name it as a heading or a plan key, so the FAIL is on absence, not on case.
|
|
246
|
+
for (const field of fields) {
|
|
247
|
+
r.checked += 1;
|
|
248
|
+
if (agentBody === null) { r.fails.push(`brief field ${field} does not appear in agents/ll-executor.md`); continue; }
|
|
249
|
+
const strict = word(field);
|
|
250
|
+
const loose = new RegExp(strict.source, 'i');
|
|
251
|
+
if (!loose.test(agentBody)) r.fails.push(`brief field ${field} does not appear in agents/ll-executor.md`);
|
|
252
|
+
else if (!strict.test(agentBody)) r.warns.push(`brief field ${field} appears in agents/ll-executor.md only in another case`);
|
|
253
|
+
}
|
|
254
|
+
return r;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 4. The verdict vocabulary the skills use is emitted by the agents; the board states exist in the helper.
|
|
258
|
+
function rule4() {
|
|
259
|
+
const r = result();
|
|
260
|
+
const verdicts = ['APPROVED', 'APPROVED_WITH_RESERVATIONS', 'REJECTED', 'DEFERRED', 'BLOCKED'];
|
|
261
|
+
const skillsText = joined(SKILL_TREE);
|
|
262
|
+
const agentsText = joined(AGENT_FILES);
|
|
263
|
+
for (const token of verdicts) {
|
|
264
|
+
if (!word(token).test(skillsText)) continue;
|
|
265
|
+
r.checked += 1;
|
|
266
|
+
if (!word(token).test(agentsText)) r.fails.push(`verdict ${token} is used in skills/ but no agent emits it`);
|
|
267
|
+
}
|
|
268
|
+
const board = ['ANSWERED_NO', 'BLOCKED', 'WAITING', 'passes:'];
|
|
269
|
+
const preambleText = joined([PREAMBLE].concat(SKILL_TREE));
|
|
270
|
+
const toolsText = text(TOOLS) || '';
|
|
271
|
+
for (const token of board) {
|
|
272
|
+
const re = token.endsWith(':') ? new RegExp(esc(token)) : word(token);
|
|
273
|
+
if (!re.test(preambleText)) continue;
|
|
274
|
+
r.checked += 1;
|
|
275
|
+
if (!re.test(toolsText)) r.fails.push(`board state ${token} is used in the preamble/skills but not in scripts/ll-tools.js`);
|
|
276
|
+
}
|
|
277
|
+
return r;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 5. Every state path is declared in a `## Deliverables` table; a name used exactly once is suspect.
|
|
281
|
+
function rule5() {
|
|
282
|
+
const r = result();
|
|
283
|
+
const paths = ['PLAN.md', 'ROADMAP.md', 'PROGRESS.md', 'BACKLOG.md', 'VERIFICATION.md', 'decisions/',
|
|
284
|
+
'phases/NN/', 'docs/decide/', 'docs/GOAL.md', 'docs/REQUESTS.md', 'docs/DELIVERY.md',
|
|
285
|
+
'CODE-CONTEXT.md', 'PLAN-REVIEW.md', 'DECISIONS.md'];
|
|
286
|
+
|
|
287
|
+
const rows = [];
|
|
288
|
+
for (const doc of SKILL_DOCS) {
|
|
289
|
+
const body = text(doc) || '';
|
|
290
|
+
const section = /^##\s+Deliverables\s*$([\s\S]*?)(?=^##\s|\Z)/m.exec(body);
|
|
291
|
+
if (!section) continue;
|
|
292
|
+
for (const line of section[1].split('\n')) if (/^\s*\|/.test(line)) rows.push(line);
|
|
293
|
+
}
|
|
294
|
+
const table = rows.join('\n');
|
|
295
|
+
for (const p of paths) {
|
|
296
|
+
r.checked += 1;
|
|
297
|
+
if (!table.includes(p)) r.fails.push(`${p} is in no \`## Deliverables\` table`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const corpus = SKILL_TREE.concat(AGENT_FILES, ASSET_FILES);
|
|
301
|
+
const seen = new Map();
|
|
302
|
+
for (const file of corpus) {
|
|
303
|
+
for (const token of captures(/\b([A-Z][A-Z-]{3,}\.md)\b/g, text(file))) {
|
|
304
|
+
if (!seen.has(token)) seen.set(token, []);
|
|
305
|
+
seen.get(token).push(rel(file));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const [token, where] of seen) {
|
|
309
|
+
r.checked += 1;
|
|
310
|
+
if (where.length === 1) r.warns.push(`${token} appears exactly once (${where[0]}) — likely a misspelling or a leftover`);
|
|
311
|
+
}
|
|
312
|
+
return r;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 6. Every `Next` line hands over in the grammar `▶ Next — /clear, then <cmd>`.
|
|
316
|
+
// `<cmd>` is an existing `ll-<skill>` with optional arguments, `/goal <text>`, or a `<placeholder>`;
|
|
317
|
+
// alternatives live in one trailing parenthetical; a handoff quoted inside backticks may be followed
|
|
318
|
+
// by free prose after the closing backtick.
|
|
319
|
+
const NEXT_OPEN = '/clear, then ';
|
|
320
|
+
function cut(s) { return String(s).trim().slice(0, 60); }
|
|
321
|
+
|
|
322
|
+
// The reasons one handoff breaks the grammar; empty means it holds.
|
|
323
|
+
function handoffFails(tail, wrapped) {
|
|
324
|
+
const fails = [];
|
|
325
|
+
let hand = tail;
|
|
326
|
+
if (wrapped) {
|
|
327
|
+
const close = tail.indexOf('`');
|
|
328
|
+
if (close < 0) return [`opens inside backticks and never closes them —${cut(tail)}`];
|
|
329
|
+
hand = tail.slice(0, close);
|
|
330
|
+
}
|
|
331
|
+
hand = hand.trim();
|
|
332
|
+
// Kept from the first version of this rule: every skill the line cites must exist.
|
|
333
|
+
for (const cmd of matches(/\bll-[a-z]+(?:-[a-z]+)*\b/g, tail).filter((c) => c !== 'll-tools')) {
|
|
334
|
+
if (!text(J(ROOT, 'skills', cmd, 'SKILL.md'))) fails.push(`names \`${cmd}\`, which is not a skill`);
|
|
335
|
+
}
|
|
336
|
+
if (!hand.startsWith(NEXT_OPEN)) {
|
|
337
|
+
let why = 'does not open with `/clear, then `';
|
|
338
|
+
if (/^`?\s*\/clear\s*`?\s*,?\s*then\b/.test(hand)) {
|
|
339
|
+
if (hand.indexOf('`') === 0 || /^\/clear`/.test(hand)) why = 'wraps `/clear` in backticks';
|
|
340
|
+
else if (/^\/clear\s+then\b/.test(hand)) why = 'writes `then` without the comma';
|
|
341
|
+
}
|
|
342
|
+
fails.push(`${why} —${cut(hand)}`);
|
|
343
|
+
return fails;
|
|
344
|
+
}
|
|
345
|
+
const rest = hand.slice(NEXT_OPEN.length).trim();
|
|
346
|
+
const split = /^([^()]*?)\s*(?:\(([^()]*)\))?$/.exec(rest);
|
|
347
|
+
if (!split) { fails.push(`puts text outside a single trailing parenthetical —${cut(rest)}`); return fails; }
|
|
348
|
+
const cmd = split[1].trim();
|
|
349
|
+
if (cmd.includes('`')) fails.push(`wraps the command in backticks —${cut(cmd)}`);
|
|
350
|
+
else if (/\bpaste\b/i.test(cmd)) fails.push(`says "paste" instead of naming the command —${cut(cmd)}`);
|
|
351
|
+
else if (/^\/goal\b/.test(cmd)) {
|
|
352
|
+
if (!cmd.slice('/goal'.length).trim()) fails.push('names /goal with no text after it');
|
|
353
|
+
} else if (!/^<[^<>]+>$/.test(cmd)) {
|
|
354
|
+
if (!/^ll-[a-z]+(?:-[a-z]+)*(?:\s+[^`()]*)?$/.test(cmd)) fails.push(`names no command —${cut(cmd)}`);
|
|
355
|
+
// Every mention counts, not the distinct ones: `ll-x or ll-x --resume` still
|
|
356
|
+
// hands the reader two commands to choose between, outside a parenthetical.
|
|
357
|
+
else if ((cmd.match(/\bll-[a-z]+(?:-[a-z]+)*\b/g) || []).length > 1) {
|
|
358
|
+
fails.push(`lists alternatives outside a parenthetical —${cut(cmd)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return fails;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function rule6() {
|
|
365
|
+
const r = result();
|
|
366
|
+
if (!SKILL_TREE.length) { r.fails.push(`no skills tree under ${ROOT}`); return r; }
|
|
367
|
+
for (const file of SKILL_TREE) {
|
|
368
|
+
const body = text(file);
|
|
369
|
+
if (body === null) continue;
|
|
370
|
+
for (const line of body.split('\n')) {
|
|
371
|
+
// The marker only opens a handoff when the em dash follows; prose *about* the line does not.
|
|
372
|
+
const marker = /▶ Next\s*—/g;
|
|
373
|
+
const hits = [];
|
|
374
|
+
let hit;
|
|
375
|
+
while ((hit = marker.exec(line)) !== null) hits.push({ at: hit.index, end: hit.index + hit[0].length });
|
|
376
|
+
for (let i = 0; i < hits.length; i++) {
|
|
377
|
+
const tail = line.slice(hits[i].end, i + 1 < hits.length ? hits[i + 1].at : line.length);
|
|
378
|
+
r.checked += 1;
|
|
379
|
+
const fails = handoffFails(tail, line[hits[i].at - 1] === '`');
|
|
380
|
+
for (const f of fails) r.fails.push(`${rel(file)}: Next ${f}`);
|
|
381
|
+
refCount(file, fails.length === 0);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return r;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Balanced array/object literal that follows `const <name>`.
|
|
389
|
+
function literal(src, name) {
|
|
390
|
+
const start = src.indexOf('const ' + name);
|
|
391
|
+
if (start < 0) return null;
|
|
392
|
+
const eq = src.indexOf('=', start);
|
|
393
|
+
if (eq < 0) return null;
|
|
394
|
+
let depth = 0;
|
|
395
|
+
for (let i = eq; i < src.length; i++) {
|
|
396
|
+
const c = src[i];
|
|
397
|
+
if (c === '[' || c === '{') depth += 1;
|
|
398
|
+
else if (c === ']' || c === '}') { depth -= 1; if (depth === 0) return src.slice(eq + 1, i + 1); }
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
function quoted(block) { return uniq(captures(/'([^'\n]*)'/g, block || '')); }
|
|
403
|
+
|
|
404
|
+
// 7. The installer names only things the package ships, and `files` covers every shipped directory.
|
|
405
|
+
function rule7() {
|
|
406
|
+
const r = result();
|
|
407
|
+
const src = text(INSTALLER);
|
|
408
|
+
if (src === null) { r.fails.push('bin/install.js is missing'); return r; }
|
|
409
|
+
|
|
410
|
+
for (const skill of quoted(literal(src, 'HELPER_SKILLS'))) {
|
|
411
|
+
r.checked += 1;
|
|
412
|
+
if (!isDir(J(ROOT, 'skills', skill))) r.fails.push(`HELPER_SKILLS names ${skill}, missing under skills/`);
|
|
413
|
+
}
|
|
414
|
+
const hooks = captures(/file:\s*'([^']+)'/g, literal(src, 'SESSION_HOOKS') || '')
|
|
415
|
+
.concat(captures(/file:\s*'([^']+)'/g, literal(src, 'PRECOMPACT_HOOK') || ''));
|
|
416
|
+
for (const hook of uniq(hooks)) {
|
|
417
|
+
r.checked += 1;
|
|
418
|
+
if (text(J(ROOT, 'hooks', hook)) === null) r.fails.push(`hook ${hook} is registered by the installer but missing under hooks/`);
|
|
419
|
+
}
|
|
420
|
+
if (!hooks.length) r.fails.push('no hook file found in SESSION_HOOKS/PRECOMPACT_HOOK');
|
|
421
|
+
|
|
422
|
+
for (const legacy of quoted(literal(src, 'KNOWN_LEGACY'))) {
|
|
423
|
+
r.checked += 1;
|
|
424
|
+
if (fs.existsSync(J(ROOT, legacy))) r.fails.push(`KNOWN_LEGACY names ${legacy}, which still exists in the tree`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let pkg = null;
|
|
428
|
+
try { pkg = JSON.parse(text(J(ROOT, 'package.json')) || '{}'); } catch { pkg = null; }
|
|
429
|
+
const shipped = Array.isArray(pkg && pkg.files) ? pkg.files.map((f) => f.replace(/\/+$/, '')) : [];
|
|
430
|
+
for (const dir of ['skills', 'agents', 'hooks', 'scripts', 'assets', 'bin']) {
|
|
431
|
+
r.checked += 1;
|
|
432
|
+
if (!shipped.includes(dir)) r.fails.push(`package.json "files" does not cover ${dir}/`);
|
|
433
|
+
}
|
|
434
|
+
return r;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const RULES = [
|
|
438
|
+
{ id: 1, name: 'references', run: rule1 },
|
|
439
|
+
{ id: 2, name: 'helper-commands', run: rule2 },
|
|
440
|
+
{ id: 3, name: 'agents', run: rule3 },
|
|
441
|
+
{ id: 4, name: 'verdict-vocabulary', run: rule4 },
|
|
442
|
+
{ id: 5, name: 'state-files', run: rule5 },
|
|
443
|
+
{ id: 6, name: 'next-targets', run: rule6 },
|
|
444
|
+
{ id: 7, name: 'installer-package', run: rule7 },
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// main
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
function main() {
|
|
452
|
+
const argv = process.argv.slice(2);
|
|
453
|
+
const json = argv.includes('--json');
|
|
454
|
+
const at = argv.indexOf('--rule');
|
|
455
|
+
const only = at >= 0 ? Number(argv[at + 1]) : null;
|
|
456
|
+
if (at >= 0 && !RULES.some((r) => r.id === only)) {
|
|
457
|
+
process.stderr.write(`lint-contract: --rule takes one of 1..${RULES.length}\n`);
|
|
458
|
+
process.exit(2);
|
|
459
|
+
}
|
|
460
|
+
const selected = only === null ? RULES : RULES.filter((r) => r.id === only);
|
|
461
|
+
|
|
462
|
+
const report = [];
|
|
463
|
+
let failed = 0;
|
|
464
|
+
for (const rule of selected) {
|
|
465
|
+
const out = rule.run();
|
|
466
|
+
failed += out.fails.length;
|
|
467
|
+
report.push({ rule: `${rule.id} ${rule.name}`, checked: out.checked, fails: out.fails, warns: out.warns });
|
|
468
|
+
}
|
|
469
|
+
// The table is built from every rule, so it is only complete on a full run.
|
|
470
|
+
const table = Array.from(PIECES.entries())
|
|
471
|
+
.map(([name, row]) => ({ piece: name, ok: row.ok, total: row.total }))
|
|
472
|
+
.sort((a, b) => a.piece.localeCompare(b.piece));
|
|
473
|
+
|
|
474
|
+
if (json) {
|
|
475
|
+
process.stdout.write(JSON.stringify({ ok: failed === 0, root: ROOT, rules: report, pieces: table }) + '\n');
|
|
476
|
+
process.exit(failed ? 1 : 0);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const rule of report) {
|
|
480
|
+
if (!rule.fails.length) process.stdout.write(`ok ${rule.rule} (${rule.checked} checked)\n`);
|
|
481
|
+
for (const f of rule.fails) process.stdout.write(`FAIL ${rule.rule}: ${f}\n`);
|
|
482
|
+
for (const w of rule.warns) process.stdout.write(`WARN ${rule.rule}: ${w}\n`);
|
|
483
|
+
}
|
|
484
|
+
if (only === null) {
|
|
485
|
+
const width = table.reduce((n, row) => Math.max(n, row.piece.length), 5);
|
|
486
|
+
process.stdout.write('\npiece' + ' '.repeat(width - 4) + 'refs ok/total\n');
|
|
487
|
+
for (const row of table) {
|
|
488
|
+
process.stdout.write(row.piece + ' '.repeat(width - row.piece.length + 1) + `${row.ok}/${row.total}\n`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
process.stdout.write(`\n${failed ? 'FAIL' : 'ok'} — ${selected.length} rule(s), ${failed} violation(s)\n`);
|
|
492
|
+
process.exit(failed ? 1 : 0);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
main();
|