ll-skills 1.0.0 → 2.0.2

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +90 -2
  2. package/README.md +134 -69
  3. package/agents/ll-executor.md +96 -0
  4. package/agents/ll-reviewer.md +76 -0
  5. package/agents/ll-scout.md +83 -0
  6. package/agents/ll-verifier.md +127 -0
  7. package/assets/preamble.md +70 -0
  8. package/assets/settings.suggested.json +19 -0
  9. package/bin/install.js +311 -54
  10. package/hooks/ll-precompact.js +49 -0
  11. package/hooks/ll-skills-check-update.js +1 -1
  12. package/hooks/ll-state.js +158 -0
  13. package/package.json +4 -2
  14. package/scripts/fixtures/empty/.gitkeep +0 -0
  15. package/scripts/fixtures/git-history.sh +49 -0
  16. package/scripts/fixtures/project/BACKLOG.md +11 -0
  17. package/scripts/fixtures/project/PLAN.md +66 -0
  18. package/scripts/fixtures/project/PROGRESS.md +57 -0
  19. package/scripts/fixtures/project/ROADMAP.md +28 -0
  20. package/scripts/fixtures/project/VERIFICATION.md +17 -0
  21. package/scripts/fixtures/project/decisions/DEC-0041-cents.md +16 -0
  22. package/scripts/fixtures/project/phases/07/PLAN.md +145 -0
  23. package/scripts/fixtures/project/src/a.ts +3 -0
  24. package/scripts/fixtures/project/src/pay.ts +5 -0
  25. package/scripts/fixtures/project/test/a.test.ts +5 -0
  26. package/scripts/ll-tools.js +664 -0
  27. package/scripts/smoke-test.sh +340 -0
  28. package/skills/ll-brainstorm/SKILL.md +180 -0
  29. package/skills/ll-brainstorm/references/decision-policy.md +118 -0
  30. package/skills/ll-brainstorm/references/techniques.md +75 -0
  31. package/skills/ll-close/SKILL.md +69 -0
  32. package/skills/ll-close/references/delivery.md +75 -0
  33. package/skills/ll-close/references/retrospective.md +48 -0
  34. package/skills/ll-decide/SKILL.md +135 -0
  35. package/skills/ll-decide/references/decision-policy.md +118 -0
  36. package/skills/ll-decide/references/decision-room.md +62 -0
  37. package/skills/ll-decide/references/disarm.md +79 -0
  38. package/skills/ll-decide/references/feedback-ingestion.md +90 -0
  39. package/skills/ll-decide/references/interview.md +102 -0
  40. package/skills/ll-decide/references/plan-skeleton.md +150 -0
  41. package/skills/ll-decide/references/premise-gate.md +83 -0
  42. package/skills/ll-decide/references/premortem.md +90 -0
  43. package/skills/ll-decide/references/review-spec.md +91 -0
  44. package/skills/ll-goal/SKILL.md +64 -0
  45. package/skills/ll-goal/references/goal-template.md +90 -0
  46. package/skills/ll-implement/SKILL.md +118 -0
  47. package/skills/ll-implement/references/briefs.md +123 -0
  48. package/skills/ll-implement/references/decision-policy.md +118 -0
  49. package/skills/ll-implement/references/phase-conversation.md +76 -0
  50. package/skills/ll-implement/references/phase-plan.md +111 -0
  51. package/skills/ll-oncall/SKILL.md +123 -0
  52. package/skills/ll-oncall/references/deploy-preflight.md +57 -0
  53. package/skills/ll-oncall/references/federation.md +110 -0
  54. package/skills/ll-oncall/references/watch-brief.md +39 -0
  55. package/skills/ll-refine/SKILL.md +116 -0
  56. package/skills/ll-refine/references/production-access.md +48 -0
  57. package/skills/ll-refine/references/visual-gate.md +100 -0
  58. package/skills/ll-research/SKILL.md +87 -0
  59. package/skills/ll-research/references/citation-check.md +38 -0
  60. package/skills/ll-research/references/front-brief.md +41 -0
  61. package/skills/ll-research/references/market-mode.md +83 -0
  62. package/skills/ll-resume/SKILL.md +75 -0
  63. package/skills/ll-update/SKILL.md +75 -0
  64. package/skills/ll-verify/SKILL.md +75 -0
  65. package/skills/ll-verify/references/verifier-briefs.md +116 -0
  66. package/agents/ll-implementador.md +0 -23
  67. package/skills/ll-atualizar/SKILL.md +0 -68
  68. package/skills/ll-decidir-antes/SKILL.md +0 -81
  69. package/skills/ll-decidir-antes/referencias/protocolo-entrevista.md +0 -112
  70. package/skills/ll-decidir-antes/referencias/template-spec.md +0 -238
  71. package/skills/ll-desarmar/SKILL.md +0 -254
  72. package/skills/ll-desarmar/referencias/execucao-adversarial.md +0 -217
  73. package/skills/ll-desarmar/referencias/humanos-e-substitutos.md +0 -116
  74. package/skills/ll-desarmar/referencias/placar-e-realimentacao.md +0 -140
  75. package/skills/ll-orquestrar/SKILL.md +0 -100
  76. package/skills/ll-pesquisar/SKILL.md +0 -159
  77. package/skills/ll-pesquisar/referencias/frente-de-pesquisa.md +0 -147
  78. package/skills/ll-pesquisar/referencias/sintese-e-fontes.md +0 -148
  79. package/skills/ll-pesquisar-mercado/SKILL.md +0 -112
  80. package/skills/ll-pesquisar-mercado/referencias/dossie.md +0 -375
  81. package/skills/ll-pesquisar-mercado/referencias/indice-e-fechamento.md +0 -122
  82. package/skills/ll-pesquisar-mercado/referencias/padroes-de-pesquisa.md +0 -149
  83. package/skills/ll-verificar-entrega/SKILL.md +0 -73
  84. package/skills/ll-verificar-entrega/referencias/briefs-auditoria.md +0 -291
  85. package/skills/ll-voltar-do-futuro/SKILL.md +0 -239
  86. package/skills/ll-voltar-do-futuro/referencias/anti-padroes-e-fundamentos.md +0 -201
  87. package/skills/ll-voltar-do-futuro/referencias/vetores-e-testes.md +0 -228
@@ -0,0 +1,664 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // ll-tools.js — ll-skills helper. Node >= 18, no deps. Reads always exit 0
4
+ // ({"ok":false,"reason":...} on failure); writes exit 1 on error.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { execFileSync, spawnSync } = require('child_process');
10
+ const J = path.join, REL = path.relative;
11
+ const JUNK = new RegExp('[\\u0000-\\u0008\\u000B-\\u001F\\u007F-\\u009F\\u200B-\\u200F'
12
+ + '\\u2028\\u2029\\u202A-\\u202E\\u2060-\\u2064\\uFEFF]', 'g'); // control + zero-width
13
+ const NUL = String.fromCharCode(0), DASH = '—';
14
+
15
+ function sz(v) {
16
+ if (v === null || typeof v !== 'object') return typeof v === 'string' ? v.replace(JUNK, '').trim() : v;
17
+ if (Array.isArray(v)) return v.map(sz);
18
+ const o = {};
19
+ for (const k of Object.keys(v)) o[k] = sz(v[k]);
20
+ return o;
21
+ }
22
+ function die(m) { throw new Error(m); }
23
+ function shaFile(f) { try { return crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'); } catch { return null; } }
24
+ function readText(f) { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } }
25
+ function writeAtomic(f, t) { const p = f + '.ll-tmp-' + process.pid; fs.writeFileSync(p, t); fs.renameSync(p, f); }
26
+ function nowIso() { return new Date().toISOString().replace(/\.\d+Z$/, 'Z'); }
27
+ function uniq(a) { return Array.from(new Set(a)); }
28
+ function has(d, n) { return fs.existsSync(J(d, n)); }
29
+ function blank(s) { return !s || /^[-—]+$/.test(s); }
30
+ function lines(s) { return s.split('\n').filter((l) => l.trim()); }
31
+ function listOf(m, k) { return Array.isArray(m[k]) ? m[k].map(String) : []; }
32
+
33
+ const VALF = ['since', 'files', 'prefix', 'reason', 'commit', 'state', 'file', 'dir', 'cwd'];
34
+ const BOOLF = ['json', 'run'];
35
+ function parseArgv(argv) {
36
+ const r = { _: [], flags: {} };
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const t = argv[i];
39
+ if (t === '--') { r._.push(...argv.slice(i + 1)); break; }
40
+ if (!t.startsWith('--')) { r._.push(t); continue; }
41
+ let k = t.slice(2), v = null;
42
+ const eq = k.indexOf('=');
43
+ if (eq >= 0) { v = k.slice(eq + 1); k = k.slice(0, eq); }
44
+ if (BOOLF.indexOf(k) >= 0) r.flags[k] = true;
45
+ else if (VALF.indexOf(k) < 0) die('unknown flag --' + k);
46
+ else if (v === null && (v = argv[++i]) === undefined) die('flag --' + k + ' needs a value');
47
+ else r.flags[k] = v;
48
+ }
49
+ return r;
50
+ }
51
+
52
+ // --- repo root
53
+ const SKIP_DOWN = new Set(['node_modules', '.git', 'dist', 'build', 'vendor']);
54
+ function findDown(base, name, maxDepth) { // dirs containing `name`, depth <= maxDepth, skipping junk
55
+ const out = [];
56
+ const walk = (dir, depth) => {
57
+ if (has(dir, name)) out.push(dir);
58
+ if (depth >= maxDepth) return;
59
+ let ents;
60
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
61
+ for (const e of ents) {
62
+ if (!e.isDirectory() || SKIP_DOWN.has(e.name)) continue;
63
+ const rel = REL(base, J(dir, e.name));
64
+ if (rel === J('docs', 'history') || rel.indexOf(J('docs', 'history') + path.sep) === 0) continue;
65
+ walk(J(dir, e.name), depth + 1);
66
+ }
67
+ };
68
+ walk(base, 0);
69
+ return out;
70
+ }
71
+ function rootOf(a, strict) {
72
+ const cwd = a.flags.cwd ? path.resolve(a.flags.cwd) : process.cwd();
73
+ const proj = (d) => d && (has(d, 'PROGRESS.md') || has(d, 'phases'));
74
+ let dir = cwd, root = null;
75
+ for (; !root; dir = path.dirname(dir)) {
76
+ if (proj(dir)) root = dir;
77
+ else if (path.dirname(dir) === dir) break;
78
+ }
79
+ let cands = [];
80
+ if (!root) {
81
+ const top = git(['rev-parse', '--show-toplevel'], cwd).trim() || null;
82
+ cands = findDown(top || cwd, 'PROGRESS.md', 3);
83
+ root = cands.length === 1 ? cands[0] : top || null;
84
+ }
85
+ if (!root || (strict && !proj(root))) {
86
+ die(cands.length > 1 ? 'no PROGRESS.md at ' + cwd + '; displaced candidates: ' + cands.join(', ')
87
+ : 'no PROGRESS.md and no phases/ in ' + cwd);
88
+ }
89
+ return root;
90
+ }
91
+ function resolveIn(root, p, fb) {
92
+ const t = p || fb || die('missing file argument');
93
+ return path.isAbsolute(t) ? t : J(root, t);
94
+ }
95
+
96
+ function git(args, cwd) {
97
+ try { return execFileSync('git', args, { cwd, timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch { return ''; }
98
+ }
99
+ function gitTop(root) { return git(['rev-parse', '--show-toplevel'], root).trim() || root; } // root may be under git top
100
+ function gitLog(root, extra) {
101
+ const args = ['log', '--no-merges', '--date=short', '--format=%H%x00%ad%x00%s'].concat(extra || []);
102
+ return lines(git(args, root)).map((l) => l.split(NUL)).filter((p) => p.length >= 3)
103
+ .map((p) => ({ sha7: p[0].slice(0, 7), date: p[1], subject: p.slice(2).join(NUL) }));
104
+ }
105
+ function gitInfo(root) {
106
+ return { branch: git(['rev-parse', '--abbrev-ref', 'HEAD'], root).trim() || null,
107
+ dirty: lines(git(['status', '--porcelain'], root)).length,
108
+ worktrees: lines(git(['worktree', 'list'], root)).length };
109
+ }
110
+
111
+ // <ll-shared:state>
112
+ // Copied verbatim into hooks/ll-state.js. Self-contained: only `fs` and `path`.
113
+ function readAnchoredBlock(file, name) {
114
+ let txt;
115
+ try { txt = fs.readFileSync(file, 'utf8'); } catch { return null; }
116
+ const all = txt.split('\n');
117
+ const open = new RegExp('^<!--\\s*' + name + '\\s*-->\\s*$');
118
+ const close = new RegExp('^<!--\\s*/' + name + '\\s*-->\\s*$');
119
+ let start = -1, end = -1;
120
+ for (let i = 0; i < all.length; i++) {
121
+ if (start < 0) { if (open.test(all[i])) start = i; } else if (close.test(all[i])) { end = i; break; }
122
+ }
123
+ if (start < 0 || end < 0) return null;
124
+ return { file, start, end, all, lines: all.slice(start + 1, end) };
125
+ }
126
+ function llSplitFlow(str) { // top-level commas only
127
+ const out = [];
128
+ let cur = '', depth = 0, q = null;
129
+ for (const ch of str) {
130
+ if (q) { cur += ch; if (ch === q) q = null; continue; }
131
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
132
+ if (ch === '[' || ch === '{') depth++;
133
+ else if (ch === ']' || ch === '}') depth--;
134
+ else if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
135
+ cur += ch;
136
+ }
137
+ if (cur.trim() !== '') out.push(cur);
138
+ return out;
139
+ }
140
+ function llScalar(raw) {
141
+ const s = String(raw).trim();
142
+ if (/^\[[\s\S]*\]$/.test(s)) return llSplitFlow(s.slice(1, -1)).map(llScalar);
143
+ if (/^\{[\s\S]*\}$/.test(s)) {
144
+ const o = {};
145
+ for (const part of llSplitFlow(s.slice(1, -1))) {
146
+ const m = /^\s*([A-Za-z_][\w-]*)\s*:\s*([\s\S]*)$/.exec(part);
147
+ if (m) o[m[1]] = llScalar(m[2]);
148
+ }
149
+ return o;
150
+ }
151
+ if (/^(true|yes)$/i.test(s)) return true;
152
+ if (/^(false|no)$/i.test(s)) return false;
153
+ if (/^".*"$/.test(s) || /^'.*'$/.test(s)) return s.slice(1, -1);
154
+ return s;
155
+ }
156
+ function parseStateBlockLite(bl) {
157
+ const st = { phase: null, milestones: {}, order: [] };
158
+ let inM = false;
159
+ for (let i = 0; i < bl.length; i++) {
160
+ const line = bl[i].replace(/\s+$/, '');
161
+ if (!line.trim() || /^\s*#/.test(line)) continue;
162
+ let m = /^phase:\s*(.*)$/.exec(line);
163
+ if (m) { st.phase = String(llScalar(m[1])); inM = false; continue; }
164
+ if (/^milestones:\s*$/.test(line)) { inM = true; continue; }
165
+ m = /^(\s*)(M\d+|G-\d+):\s*\{(.*)\}\s*$/.exec(line);
166
+ if (m && inM) {
167
+ st.milestones[m[2]] = llScalar('{' + m[3] + '}');
168
+ st.order.push({ id: m[2], index: i, indent: m[1] });
169
+ } else if (/^\S/.test(line)) inM = false;
170
+ }
171
+ return st;
172
+ }
173
+ function boardFromState(st) {
174
+ const ids = Object.keys((st && st.milestones) || {});
175
+ const passed = ids.filter((i) => st.milestones[i].passes === true).length;
176
+ return { phase: st ? st.phase : null, passed, total: ids.length, list: ids,
177
+ text: 'phase ' + ((st && st.phase) || '?') + ' · milestones ' + passed + '/' + ids.length };
178
+ }
179
+ // </ll-shared:state>
180
+
181
+ // --- mini-YAML
182
+ function parseMiniYaml(src) {
183
+ const root = {};
184
+ let seq = null, item = null, ind = -1;
185
+ for (let i = 0; i < src.length; i++) {
186
+ const raw = src[i].replace(/\s+$/, '');
187
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
188
+ const at = raw.length - raw.replace(/^\s+/, '').length;
189
+ const body = raw.trim(), isItem = body.startsWith('- ');
190
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*([\s\S]*)$/.exec(isItem ? body.slice(2).trim() : body);
191
+ if (!kv) die('yaml line ' + (i + 1) + ': unparsable');
192
+ if (isItem) {
193
+ if (!seq) die('yaml line ' + (i + 1) + ': item outside a key');
194
+ item = { __line: i + 1 };
195
+ seq.push(item); ind = at;
196
+ } else if (at === 0) {
197
+ seq = null; item = null;
198
+ if (kv[2].trim() === '') { root[kv[1]] = []; seq = root[kv[1]]; } else root[kv[1]] = llScalar(kv[2]);
199
+ continue;
200
+ } else if (!item || at <= ind) die('yaml line ' + (i + 1) + ': bad indentation');
201
+ item[kv[1]] = llScalar(kv[2]);
202
+ }
203
+ return root;
204
+ }
205
+
206
+ function loadMilestones(f) {
207
+ const blk = readAnchoredBlock(f, 'll-milestones') || die('no ll-milestones block in ' + f);
208
+ const list = parseMiniYaml(blk.lines).milestones;
209
+ if (!Array.isArray(list) || !list.length) die('no milestones in the block');
210
+ return { block: blk, milestones: list };
211
+ }
212
+ function loadStateBlock(root) {
213
+ const file = J(root, 'PROGRESS.md');
214
+ const blk = readAnchoredBlock(file, 'll-state');
215
+ return blk ? { file, block: blk, state: parseStateBlockLite(blk.lines) } : null;
216
+ }
217
+ function mdTable(text) {
218
+ let header = null;
219
+ const rows = [];
220
+ text.split('\n').forEach((l, i) => {
221
+ if (!/^\s*\|/.test(l)) return;
222
+ const cells = l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
223
+ if (cells.every((c) => /^:?-{2,}:?$/.test(c))) return;
224
+ if (!header) header = cells.map((c) => c.toLowerCase());
225
+ else rows.push({ cells, index: i });
226
+ });
227
+ return { header: header || [], rows };
228
+ }
229
+ function col(h, n) { return h.findIndex((x) => x.indexOf(n) >= 0); }
230
+ function waiting(root) {
231
+ let names = [];
232
+ try { names = fs.readdirSync(J(root, 'decisions')).sort(); } catch { return []; }
233
+ return names.filter((n) => /\.md$/.test(n) && /WAITING/.test(readText(J(root, 'decisions', n)) || ''))
234
+ .map((n) => ({ id: (/^([A-Za-z]+-\d+)/.exec(n) || [null, n])[1], file: J('decisions', n) }));
235
+ }
236
+ function commitRe(id) { // anchored: (M1) never matches feat(M10)
237
+ const e = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
238
+ return { full: new RegExp('^(test|feat|refactor)\\(' + e + '\\): '), loose: new RegExp('\\(' + e + '\\)') };
239
+ }
240
+ function kahn(dep) { // topological layers
241
+ const layers = [], done = new Set();
242
+ let pool = Array.from(dep.keys());
243
+ while (pool.length) {
244
+ const layer = pool.filter((i) => dep.get(i).every((d) => done.has(d)));
245
+ if (!layer.length) break;
246
+ layer.forEach((i) => done.add(i));
247
+ layers.push(layer);
248
+ pool = pool.filter((i) => !done.has(i));
249
+ }
250
+ return { layers, left: pool };
251
+ }
252
+
253
+ const C = {};
254
+
255
+ C.waves = (a) => {
256
+ const root = rootOf(a), by = new Map(), dx = [];
257
+ for (const m of loadMilestones(resolveIn(root, a._[0])).milestones) {
258
+ const id = m.id === undefined ? null : String(m.id);
259
+ if (!id) dx.push({ type: 'id-missing', line: m.__line });
260
+ else if (by.has(id)) dx.push({ type: 'id-duplicate', milestone: id, line: m.__line });
261
+ else by.set(id, m);
262
+ }
263
+ const deps = new Map();
264
+ for (const id of by.keys()) {
265
+ const keep = [];
266
+ for (const d of listOf(by.get(id), 'depends_on')) {
267
+ if (d === id) dx.push({ type: 'cycle', milestone: id, depends_on: d });
268
+ else if (!by.has(d)) dx.push({ type: 'unknown_depends_on', milestone: id, depends_on: d });
269
+ else keep.push(d);
270
+ }
271
+ deps.set(id, uniq(keep));
272
+ }
273
+ const { layers, left } = kahn(deps);
274
+ if (left.length) dx.push({ type: 'cycle', milestones: left });
275
+ const st = (loadStateBlock(root) || { state: { milestones: {} } }).state.milestones;
276
+ const isRoot = (id) => { // ANSWERED_NO/BLOCKED, or `stop: owner` unaccepted
277
+ const e = st[id];
278
+ if (e && (e.state === 'ANSWERED_NO' || e.state === 'BLOCKED')) return true;
279
+ return by.get(id).stop === 'owner' && !(e && e.passes === true);
280
+ };
281
+ const bl = new Map(), blocked_by = {};
282
+ for (const layer of layers) for (const id of layer) {
283
+ const set = new Set();
284
+ for (const d of deps.get(id)) for (const r of bl.get(d) || []) set.add(r);
285
+ if (isRoot(id)) set.add(id);
286
+ bl.set(id, Array.from(set).sort());
287
+ if (bl.get(id).length) blocked_by[id] = bl.get(id);
288
+ }
289
+ const files = (m) => listOf(m, 'files').map((f) => path.posix.normalize(f.trim()));
290
+ const waves = layers.map((layer, w) => {
291
+ for (let x = 0; x < layer.length; x++) for (let y = x + 1; y < layer.length; y++) {
292
+ const A = by.get(layer[x]), B = by.get(layer[y]), pair = [layer[x], layer[y]];
293
+ const bf = files(B), sf = files(A).filter((f) => bf.indexOf(f) >= 0);
294
+ if (sf.length) dx.push({ type: 'file_overlap', wave: w + 1, milestones: pair, files: sf });
295
+ const be = listOf(B, 'exclusive'), se = listOf(A, 'exclusive').filter((f) => be.indexOf(f) >= 0);
296
+ if (se.length) dx.push({ type: 'exclusive_conflict', wave: w + 1, milestones: pair, resources: se });
297
+ }
298
+ return { wave: w + 1, milestones: layer, builds: layer.map((id) => by.get(id).name || id),
299
+ blocked: layer.filter((id) => blocked_by[id]) };
300
+ });
301
+ return { ok: true, waves, defects: dx, blocked_by, unscheduled: left };
302
+ };
303
+
304
+ C['tdd-gate'] = (a) => {
305
+ const root = rootOf(a), id = a._[0] || die('missing milestone id'), re = commitRe(id).full;
306
+ let test = null, feat = null;
307
+ gitLog(root, a.flags.since ? [a.flags.since + '..HEAD'] : null).reverse().forEach((e, i) => {
308
+ const m = re.exec(e.subject);
309
+ if (!m) return;
310
+ if (m[1] !== 'feat' && !test) test = { sha7: e.sha7, type: m[1], pos: i };
311
+ if (m[1] === 'feat' && !feat) feat = { sha7: e.sha7, type: m[1], pos: i };
312
+ });
313
+ let tdd = 'fail', reason;
314
+ if (!test && !feat) reason = 'no anchored commit for ' + id;
315
+ else if (!test) reason = 'feat with no preceding test commit';
316
+ else if (feat && feat.pos < test.pos) reason = 'feat lands before test';
317
+ else { tdd = 'pass'; reason = feat ? 'test precedes feat' : 'test present, no feat yet'; }
318
+ const s = (c) => (c ? { sha7: c.sha7, type: c.type } : null); // never echo the subject
319
+ return { ok: true, milestone: id, tdd, test: s(test), feat: s(feat), reason };
320
+ };
321
+
322
+ C['spot-check'] = (a) => {
323
+ const root = rootOf(a), top = gitTop(root), id = a._[0] || die('missing milestone id');
324
+ const exp = String(a.flags.files || '').split(',').map((s) => s.trim()).filter(Boolean);
325
+ if (!exp.length) die('missing --files a,b');
326
+ const miss = exp.filter((f) => !fs.existsSync(resolveIn(top, f))), re = commitRe(id).loose;
327
+ const shas = gitLog(root).filter((e) => re.test(e.subject)).map((e) => e.sha7);
328
+ return { ok: true, milestone: id, verdict: !miss.length && shas.length ? 'pass' : 'fail',
329
+ files: { expected: exp.length, found: exp.length - miss.length, missing: miss },
330
+ commits: { count: shas.length, shas } };
331
+ };
332
+
333
+ C['dec-reserve'] = (a) => {
334
+ const root = rootOf(a), n = parseInt(a._[0], 10);
335
+ if (!Number.isInteger(n) || n < 1 || n > 50) die('count must be 1..50');
336
+ const rel = a.flags.dir || 'decisions';
337
+ fs.mkdirSync(J(root, rel), { recursive: true });
338
+ const names = fs.readdirSync(J(root, rel));
339
+ let pre = a.flags.prefix;
340
+ if (!pre) {
341
+ // detect the dominant prefix in use: most frequent, ties -> shortest, none -> DEC
342
+ const counts = new Map();
343
+ for (const name of names) {
344
+ const m = /^([A-Z]+(?:-[A-Z]+)*)-(\d+)/.exec(name);
345
+ if (m) counts.set(m[1], (counts.get(m[1]) || 0) + 1);
346
+ }
347
+ let best = null;
348
+ for (const [p, c] of counts) if (!best || c > best.c || (c === best.c && p.length < best.p.length)) best = { p, c };
349
+ pre = best ? best.p : 'DEC';
350
+ }
351
+ const re = new RegExp('^' + pre + '-(\\d+)');
352
+ let max = 0, width = 4, hit = false;
353
+ for (const name of names) {
354
+ const m = re.exec(name);
355
+ if (!m) continue;
356
+ const num = parseInt(m[1], 10);
357
+ if (!hit || num >= max) { max = num; width = a.flags.prefix ? 4 : m[1].length; hit = true; }
358
+ }
359
+ const ids = [], files = [];
360
+ for (let i = 1; i <= n; i++) {
361
+ const id = pre + '-' + String(max + i).padStart(width, '0'), r = J(rel, id + '-reserved.md');
362
+ if (has(root, r)) die('id collision: ' + r);
363
+ writeAtomic(J(root, r), `# ${id} ${DASH} reserved\n\n- status: RESERVED at ${nowIso()}\n`);
364
+ ids.push(id); files.push(r);
365
+ }
366
+ return { ok: true, ids, files, prefix: pre, width };
367
+ };
368
+
369
+ const ORDER = ['passes', 'commit', 'accepted_at', 'state', 'reason', 'propagates_to'];
370
+ function outScalar(v) {
371
+ if (Array.isArray(v)) return '[' + v.map(outScalar).join(', ') + ']';
372
+ if (typeof v === 'boolean') return String(v);
373
+ return /[\s,{}[\]]/.test(String(v)) ? JSON.stringify(String(v)) : String(v);
374
+ }
375
+ function stateLine(indent, id, f) { // fixed field order
376
+ const keys = ORDER.concat(Object.keys(f).filter((k) => ORDER.indexOf(k) < 0));
377
+ return indent + id + ': { ' + keys.filter((k) => f[k] !== undefined && f[k] !== null && f[k] !== '')
378
+ .map((k) => k + ': ' + outScalar(f[k])).join(', ') + ' }';
379
+ }
380
+
381
+ C.passes = (a) => {
382
+ const root = rootOf(a, true), id = a._[0] || die('missing milestone id');
383
+ if (!/^(M\d+|G-\d+)$/.test(id)) die('bad milestone id: ' + id);
384
+ const arg = String(a._[1] || '').toLowerCase();
385
+ if (arg !== 'true' && arg !== 'false') die('expected true|false');
386
+ const value = arg === 'true';
387
+ if (!value && !a.flags.reason) die('passes <M> false requires --reason');
388
+ const { block, state } = loadStateBlock(root) || die('no ll-state block in PROGRESS.md');
389
+ const all = block.all.slice(), old = state.order.find((o) => o.id === id);
390
+ const f = old ? Object.assign({}, state.milestones[id]) : {};
391
+ f.passes = value;
392
+ if (value) {
393
+ delete f.reason; delete f.state;
394
+ f.commit = a.flags.commit || git(['rev-parse', '--short=7', 'HEAD'], root).trim() || 'unknown';
395
+ f.accepted_at = nowIso();
396
+ } else {
397
+ delete f.commit; delete f.accepted_at;
398
+ f.reason = a.flags.reason;
399
+ if (a.flags.state) f.state = a.flags.state;
400
+ }
401
+ let created = false, at;
402
+ if (old) {
403
+ at = block.start + 1 + old.index;
404
+ all[at] = stateLine(old.indent, id, f);
405
+ } else {
406
+ created = true;
407
+ let anchor = -1;
408
+ for (let i = block.start + 1; i < block.end; i++) if (/^milestones:\s*$/.test(all[i].trim())) { anchor = i; break; }
409
+ if (anchor < 0) die('no `milestones:` key in the ll-state block');
410
+ at = state.order.reduce((acc, o) => Math.max(acc, block.start + 2 + o.index), anchor + 1);
411
+ all.splice(at, 0, stateLine(state.order.length ? state.order[0].indent : ' ', id, f));
412
+ }
413
+ writeAtomic(block.file, all.join('\n'));
414
+ const b = boardFromState(loadStateBlock(root).state);
415
+ return { ok: true, milestone: id, passes: value, line: at + 1, created, board: { passed: b.passed, total: b.total } };
416
+ };
417
+
418
+ C.state = (a) => {
419
+ const root = rootOf(a, true), top = gitTop(root), sb = loadStateBlock(root);
420
+ const b = boardFromState(sb ? sb.state : null), log = gitLog(root);
421
+ const plan = b.phase ? J('phases', b.phase, 'PLAN.md') : null;
422
+ return { ok: true, root, git_top: top, phase: b.phase,
423
+ milestones: { total: b.total, passed: b.passed, list: b.list },
424
+ last_commit: log.length ? { sha7: log[0].sha7, date: log[0].date } : null,
425
+ git: gitInfo(root), waiting: waiting(root),
426
+ epilogue_present: /^## Epilogue/m.test(readText(J(root, 'PROGRESS.md')) || ''),
427
+ active_phase_plan: plan && has(root, plan) ? plan : null };
428
+ };
429
+
430
+ C.heartbeat = (a) => {
431
+ const root = rootOf(a, true), text = sz(a._.join(' ')) || die('missing text');
432
+ const file = J(root, a.flags.file || 'PROGRESS.md'), body = readText(file);
433
+ if (body === null) die('cannot read ' + file);
434
+ const out = body.split('\n'), line = '- [' + nowIso() + '] ' + text;
435
+ let at = out.findIndex((l) => /^## Epilogue/.test(l));
436
+ if (at >= 0) out.splice(at, 0, line, '');
437
+ else {
438
+ while (out.length && out[out.length - 1].trim() === '') out.pop();
439
+ out.push(line, '');
440
+ at = out.length - 2;
441
+ }
442
+ writeAtomic(file, out.join('\n'));
443
+ return { ok: true, file: REL(root, file), line: at + 1, text };
444
+ };
445
+
446
+ C.ledger = (a) => {
447
+ const root = rootOf(a), top = gitTop(root), file = resolveIn(root, a._[0] || a.flags.file, 'VERIFICATION.md');
448
+ const txt = readText(file);
449
+ if (txt === null) die('cannot read ' + file);
450
+ const { header, rows } = mdTable(txt);
451
+ const iF = col(header, 'file'), iS = col(header, 'sha'), iT = col(header, 'state');
452
+ if (!rows.length || iF < 0 || iS < 0) die('no ledger table with file/sha columns');
453
+ const criteria = [], summary = { FRESH: 0, STALE: 0, UNKNOWN: 0 };
454
+ for (const r of rows) {
455
+ const id = sz(r.cells[0]);
456
+ if (blank(id)) continue;
457
+ const ref = sz(r.cells[iF] || ''), m = /^(.*?):(\d+)$/.exec(ref);
458
+ const rel = m ? m[1] : blank(ref) ? null : ref;
459
+ let rec = sz(r.cells[iS] || '').replace(/^sha256:/i, '').replace(/[….]+$/, '');
460
+ if (!/^[0-9a-f]{8,64}$/i.test(rec)) rec = null;
461
+ const act = rel ? shaFile(resolveIn(top, rel)) : null;
462
+ const fr = rec && act ? (act.slice(0, rec.length) === rec.toLowerCase() ? 'FRESH' : 'STALE') : 'UNKNOWN';
463
+ summary[fr]++;
464
+ criteria.push({ id, file: rel, line: m ? parseInt(m[2], 10) : null, recorded: rec,
465
+ actual: act ? act.slice(0, 12) : null, freshness: fr, state: iT >= 0 ? sz(r.cells[iT]) : null });
466
+ }
467
+ return { ok: true, file: REL(root, file), criteria, summary };
468
+ };
469
+
470
+ C['backlog-reconcile'] = (a) => { // condition: `cmd` exit N | `cmd` = <stdout>
471
+ const root = rootOf(a), top = gitTop(root), file = resolveIn(root, a._[0] || a.flags.file, 'BACKLOG.md');
472
+ const txt = readText(file);
473
+ if (txt === null) die('cannot read ' + file);
474
+ const { header, rows } = mdTable(txt);
475
+ const iC = col(header, 'condition'), iS = col(header, 'state');
476
+ if (!rows.length || iC < 0 || iS < 0) die('no backlog table with condition/state cols');
477
+ const run = !!a.flags.run, out = txt.split('\n'), items = [], closed = [];
478
+ let spent = 0;
479
+ for (const r of rows) {
480
+ const id = sz(r.cells[0]);
481
+ if (blank(id)) continue;
482
+ const state = sz(r.cells[iS]).toUpperCase(), raw = sz(r.cells[iC]);
483
+ const m = /^`([^`]+)`\s*(?:exit\s+(-?\d+)|=\s*(.+))$/.exec(raw);
484
+ const it = { id, state, condition: m ? m[1] : raw, ran: false, exit: null,
485
+ expect: m ? (m[2] !== undefined ? { kind: 'exit', value: Number(m[2]) } : { kind: 'stdout', value: sz(m[3]) }) : null,
486
+ result: m ? null : 'unparsable-condition' };
487
+ if (run && state === 'OPEN' && m) {
488
+ if (spent >= 600000) it.skipped = 'budget';
489
+ else {
490
+ const t0 = Date.now();
491
+ const p = spawnSync('bash', ['-c', it.condition], { cwd: top, timeout: 120000, encoding: 'utf8' });
492
+ spent += Date.now() - t0;
493
+ it.ran = true;
494
+ it.exit = p.status === null ? 124 : p.status;
495
+ const green = it.expect.kind === 'exit' ? it.exit === it.expect.value : sz(String(p.stdout || '')) === String(it.expect.value);
496
+ it.result = green ? 'closed' : 'still-open';
497
+ if (green) closed.push({ id, index: r.index });
498
+ }
499
+ }
500
+ items.push(it);
501
+ }
502
+ if (run && closed.length) {
503
+ for (const c of closed) {
504
+ const parts = out[c.index].split('|');
505
+ if (parts[iS + 1] !== undefined) parts[iS + 1] = parts[iS + 1].replace(/\bOPEN\b/, 'CLOSED');
506
+ out[c.index] = parts.join('|');
507
+ }
508
+ writeAtomic(file, out.join('\n'));
509
+ }
510
+ return { ok: true, file: REL(root, file), run, items, closed: closed.map((c) => c.id),
511
+ open: items.filter((i) => i.state === 'OPEN' && i.result !== 'closed').length };
512
+ };
513
+
514
+ C.epilogue = (a) => {
515
+ const root = rootOf(a, true);
516
+ const sb = loadStateBlock(root) || die('no ll-state block in PROGRESS.md');
517
+ const phase = String(a._[0] || sb.state.phase || die('missing phase argument'));
518
+ const ms = sb.state.milestones, pad = phase.padStart(2, '0');
519
+ const plan = J(root, 'phases', pad, 'PLAN.md');
520
+ let blocked_by = {}, backlog = [], next = null;
521
+ try { if (fs.existsSync(plan)) blocked_by = C.waves({ _: [plan], flags: { cwd: root } }).blocked_by; } catch { /* see `waves` */ }
522
+ try {
523
+ backlog = C['backlog-reconcile']({ _: [], flags: { cwd: root } }).items
524
+ .filter((i) => i.state === 'OPEN').map((i) => i.id);
525
+ } catch { /* no backlog yet */ }
526
+ const roadmap = readText(J(root, 'ROADMAP.md'));
527
+ if (roadmap) {
528
+ const ns = mdTable(roadmap).rows.map((r) => Number(sz(r.cells[0]))).filter((n) => Number.isFinite(n) && n > Number(phase));
529
+ if (ns.length) next = Math.min(...ns);
530
+ }
531
+ const log = gitLog(root);
532
+ const why = (k) => sz(ms[k].state ? ms[k].state + ': ' + (ms[k].reason || '') : ms[k].reason || 'not accepted yet');
533
+ return { ok: true, phase: pad,
534
+ passed: Object.keys(ms).filter((k) => ms[k].passes === true),
535
+ left: Object.keys(ms).filter((k) => ms[k].passes !== true).map((k) => ({ id: k, why: why(k) })),
536
+ waiting: waiting(root), new_backlog: backlog, dirty: gitInfo(root).dirty,
537
+ head: log.length ? log[0].sha7 : null, blocked_by,
538
+ next_command: next !== null ? 'll-implement ' + next : 'll-close' };
539
+ };
540
+
541
+ C['phase-stats'] = (a) => {
542
+ const es = gitLog(rootOf(a), a.flags.since ? ['--since=' + a.flags.since] : null);
543
+ const days = uniq(es.map((e) => e.date)).sort(), by_type = {};
544
+ for (const e of es) {
545
+ const m = /^([a-z]+)(\([^)]*\))?!?:/.exec(e.subject), t = m ? m[1] : 'other';
546
+ by_type[t] = (by_type[t] || 0) + 1;
547
+ }
548
+ const day = (d) => Date.parse(d + 'T00:00:00Z');
549
+ const span = days.length >= 2 ? Math.round((day(days[days.length - 1]) - day(days[0])) / 864e5) + 1 : days.length;
550
+ const t = by_type.test || 0, f = by_type.feat || 0;
551
+ return { ok: true, days_with_work: days.length, span_days: span, commits: es.length, by_type,
552
+ idle_days: Math.max(0, span - days.length), test_feat_ratio: f ? Math.round((t / f) * 100) / 100 : null };
553
+ };
554
+
555
+ const E = 'error', W = 'warn';
556
+ const CMD_RE = /^(`|\.\/|npm|pnpm|yarn|node|bash|sh |make|cargo|go |python|pytest|jest|vitest|docker|curl|git)/;
557
+ const ENUMS = { stop: 'none|owner', verification: 'internal|external' };
558
+ const RULES = [ // rule, severity, predicate
559
+ ['files-missing', E, (m) => m.files === undefined],
560
+ ['files-not-list', E, (m) => m.files !== undefined && !Array.isArray(m.files)],
561
+ ['files-too-many', W, (m) => Array.isArray(m.files) && m.files.length > 5],
562
+ ['acceptance-missing', E, (m) => !m.acceptance],
563
+ ['acceptance-not-command', W, (m) => m.acceptance && !CMD_RE.test(String(m.acceptance).trim())],
564
+ ['tdd-invalid', E, (m) => m.tdd !== undefined && typeof m.tdd !== 'boolean'],
565
+ ['model-format', W, (m) => m.model !== undefined && !/^[a-z0-9.-]+\/[a-z]+$/.test(m.model)],
566
+ ['truths-missing', W, (m) => !Array.isArray(m.truths) || !m.truths.length],
567
+ ];
568
+
569
+ C['plan-lint'] = (a) => {
570
+ const root = rootOf(a), pf = resolveIn(root, a._[0], 'PLAN.md'), dx = [], skipped = [];
571
+ const add = (rule, severity, milestone, line, message) => dx.push({ rule, severity, milestone, line, message });
572
+ const done = () => {
573
+ const error = dx.filter((d) => d.severity === E).length;
574
+ return { ok: true, file: REL(root, pf), defects: dx, skipped,
575
+ verdict: error ? 'fail' : dx.length ? W : 'pass', summary: { error, warn: dx.length - error } };
576
+ };
577
+ const blk = readAnchoredBlock(pf, 'll-milestones');
578
+ if (!blk) { add('block-missing', E, null, null, 'no ll-milestones block'); return done(); }
579
+ if (blk.lines.length > 80) add('block-too-long', W, null, blk.start + 1, blk.lines.length + ' lines (>80)');
580
+ let list;
581
+ try { list = parseMiniYaml(blk.lines).milestones; } catch (e) { add('block-unparsable', E, null, null, sz(e.message)); return done(); }
582
+ if (!Array.isArray(list) || !list.length) { add('block-unparsable', E, null, null, 'no milestones'); return done(); }
583
+ const body = readText(pf) || '';
584
+ const ids = new Set(list.map((m) => String(m.id))), seen = new Set(), dep = new Map();
585
+ for (const m of list) {
586
+ const id = m.id === undefined ? null : String(m.id), ln = blk.start + 1 + (m.__line || 0);
587
+ if (!id || !/^(M\d+|G-\d+)$/.test(id)) { add('id-format', E, id, ln, 'id is not M<n>/G-<n>'); continue; }
588
+ if (seen.has(id)) add('id-duplicate', E, id, ln, 'duplicate id');
589
+ seen.add(id);
590
+ for (const r of RULES) if (r[2](m)) add(r[0], r[1], id, ln, r[0]);
591
+ for (const k of Object.keys(ENUMS)) {
592
+ if (m[k] !== undefined && !new RegExp('^(' + ENUMS[k] + ')$').test(String(m[k]))) add(k + '-invalid', E, id, ln, k + ' is not ' + ENUMS[k]);
593
+ }
594
+ if (m.depends_on !== undefined && !Array.isArray(m.depends_on)) add('depends_on-unknown', E, id, ln, 'depends_on not a list');
595
+ for (const d of listOf(m, 'depends_on')) {
596
+ if (d === id) add('depends_on-self', E, id, ln, 'depends on itself');
597
+ else if (!ids.has(d)) add('depends_on-unknown', E, id, ln, 'unknown depends_on ' + d);
598
+ }
599
+ dep.set(id, listOf(m, 'depends_on').filter((d) => ids.has(d)));
600
+ if (!new RegExp('^###\\s+' + id.replace(/-/g, '\\-') + '\\b', 'm').test(body)) add('body-section-missing', W, id, ln, 'no `### ' + id + '` section');
601
+ }
602
+ for (const id of kahn(dep).left) add('depends_on-cycle', E, id, null, 'inside a dependency cycle');
603
+ const proj = readText(J(root, 'PLAN.md'));
604
+ if (!proj || path.resolve(pf) === path.resolve(root, 'PLAN.md')) skipped.push('truth-orphan');
605
+ else {
606
+ const sec = /^##\s*§?1[\s\S]*?(?=^##\s)/m.exec(proj);
607
+ const known = new Set((sec ? sec[0] : proj).match(/\bT\d+\b/g) || []);
608
+ for (const m of list) for (const t of listOf(m, 'truths')) {
609
+ if (!known.has(t)) add('truth-orphan', E, String(m.id), null, 'unknown truth ' + t);
610
+ }
611
+ }
612
+ return done();
613
+ };
614
+
615
+ const S = ' · ';
616
+ const j = (...p) => p.filter((x) => x !== null && x !== '').join(S);
617
+ const or = (v) => (v && v.length ? v : 'none');
618
+ const FMT = {
619
+ waves: (r) => j(`waves ${r.waves.length}`, r.waves.map((w) => `w${w.wave} ${w.milestones}`).join(S),
620
+ `defects ${r.defects.length}`, `blocked ${Object.keys(r.blocked_by).length}`,
621
+ r.unscheduled.length ? `unscheduled ${r.unscheduled}` : ''),
622
+ 'tdd-gate': (r) => j(`tdd ${r.tdd}`, r.milestone, `test ${r.test ? r.test.sha7 : DASH}`,
623
+ `feat ${r.feat ? r.feat.sha7 : DASH}`, r.reason),
624
+ 'spot-check': (r) => j(r.verdict, `files ${r.files.found}/${r.files.expected}`, `commits ${r.commits.count}`,
625
+ r.files.missing.length ? `missing ${r.files.missing}` : ''),
626
+ 'dec-reserve': (r) => `reserved ${r.ids.join(' ')}`,
627
+ passes: (r) => j(`${r.milestone} passes ${r.passes}`, r.created ? 'created' : 'updated', `board ${r.board.passed}/${r.board.total}`),
628
+ state: (r) => j(`phase ${r.phase || '?'}`, `milestones ${r.milestones.passed}/${r.milestones.total}`,
629
+ `${r.git.branch || '?'} ${r.git.dirty ? r.git.dirty + ' dirty' : 'clean'}`,
630
+ `last ${r.last_commit ? r.last_commit.sha7 : DASH}`, `WAITING ${r.waiting.length}`,
631
+ r.git_top && r.git_top !== r.root ? `git_top ${r.git_top}` : ''),
632
+ heartbeat: (r) => j(`heartbeat ${r.file}:${r.line}`, r.text),
633
+ ledger: (r) => j(`ledger ${r.criteria.length}`, `FRESH ${r.summary.FRESH}`, `STALE ${r.summary.STALE}`, `UNKNOWN ${r.summary.UNKNOWN}`),
634
+ 'backlog-reconcile': (r) => j(`backlog ${r.items.length}`, `open ${r.open}`, r.run ? `closed ${or(r.closed)}` : 'dry-run'),
635
+ epilogue: (r) => [j(`phase ${r.phase}`, `head ${r.head || DASH}`, r.dirty ? `${r.dirty} dirty` : 'clean'),
636
+ `passed: ${or(r.passed.join(' '))}${S}left: ${or(r.left.map((l) => `${l.id} (${l.why})`).join(S))}`,
637
+ `waiting: ${or(r.waiting.map((w) => w.id).join(' '))}${S}backlog: ${or(r.new_backlog.join(' '))}`,
638
+ `blocked_by: ${or(Object.keys(r.blocked_by).map((k) => `${k}<-${r.blocked_by[k]}`).join(' '))}`,
639
+ `next: ${r.next_command}`].join('\n'),
640
+ 'phase-stats': (r) => j(`days ${r.days_with_work}/${r.span_days}`, `idle ${r.idle_days}`,
641
+ `commits ${r.commits}`, `test/feat ${r.test_feat_ratio === null ? DASH : r.test_feat_ratio}`),
642
+ 'plan-lint': (r) => j(r.verdict, `errors ${r.summary.error}`, `warnings ${r.summary.warn}`)
643
+ + r.defects.map((d) => `\n ${d.severity} ${d.rule} ${d.milestone || '-'}` + (d.message === d.rule ? '' : ` ${DASH} ${d.message}`)).join(''),
644
+ };
645
+
646
+ const WRITE = new Set(['dec-reserve', 'passes', 'heartbeat']);
647
+
648
+ function main() {
649
+ const argv = process.argv.slice(2), cmd = argv[0];
650
+ let write = WRITE.has(cmd) || (cmd === 'backlog-reconcile' && argv.indexOf('--run') > 0);
651
+ try {
652
+ if (!C[cmd]) die('usage: ll-tools.js <' + Object.keys(C).join('|') + '> [args] [--json]');
653
+ const a = parseArgv(argv.slice(1));
654
+ write = WRITE.has(cmd) || (cmd === 'backlog-reconcile' && !!a.flags.run);
655
+ const res = sz(C[cmd](a));
656
+ process.stdout.write((a.flags.json || !FMT[cmd] ? JSON.stringify(res) : FMT[cmd](res)) + '\n');
657
+ process.exit(0);
658
+ } catch (e) {
659
+ process.stdout.write(JSON.stringify({ ok: false, reason: sz((e && e.message) || String(e)) || 'error' }) + '\n');
660
+ process.exit(write ? 1 : 0);
661
+ }
662
+ }
663
+
664
+ main();