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