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