create-pmos 0.134.0 → 0.135.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 (209) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +16 -1
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-capture.js +9 -1
  83. package/workspace/lib/notes-final.js +38 -13
  84. package/workspace/lib/notes-replay.js +125 -0
  85. package/workspace/lib/notes-screen.js +180 -0
  86. package/workspace/lib/notes-whisper.js +10 -3
  87. package/workspace/lib/outbox-store.js +10 -6
  88. package/workspace/lib/pmos-verbs.js +3 -1
  89. package/workspace/lib/proactive-cli.js +7 -0
  90. package/workspace/lib/proactive.js +15 -2
  91. package/workspace/lib/repo-script.js +36 -0
  92. package/workspace/lib/report-dashboards.js +5 -2
  93. package/workspace/lib/report-frame.js +122 -0
  94. package/workspace/lib/report-schedules.js +113 -0
  95. package/workspace/lib/route-modules.js +9 -2
  96. package/workspace/lib/routes-boards.js +734 -0
  97. package/workspace/lib/routes-brief-page.js +13 -4
  98. package/workspace/lib/routes-briefing.js +22 -11
  99. package/workspace/lib/routes-c3-init.js +4 -3
  100. package/workspace/lib/routes-c3-life.js +2 -1
  101. package/workspace/lib/routes-c3-reports.js +96 -13
  102. package/workspace/lib/routes-c3-strategy.js +4 -3
  103. package/workspace/lib/routes-c3-timeline.js +3 -2
  104. package/workspace/lib/routes-calendar-import.js +3 -2
  105. package/workspace/lib/routes-calendar-views.js +9 -4
  106. package/workspace/lib/routes-doc-health.js +2 -1
  107. package/workspace/lib/routes-doc-sheet.js +2 -1
  108. package/workspace/lib/routes-forecast.js +2 -1
  109. package/workspace/lib/routes-hooks.js +3 -2
  110. package/workspace/lib/routes-initiatives.js +2 -1
  111. package/workspace/lib/routes-intake.js +2 -1
  112. package/workspace/lib/routes-notes.js +55 -9
  113. package/workspace/lib/routes-outbox.js +23 -3
  114. package/workspace/lib/routes-pm-items.js +19 -6
  115. package/workspace/lib/routes-records.js +3 -2
  116. package/workspace/lib/routes-releases.js +2 -1
  117. package/workspace/lib/routes-report-facts.js +4 -3
  118. package/workspace/lib/routes-report-families.js +2 -1
  119. package/workspace/lib/routes-roadmap-ops.js +5 -4
  120. package/workspace/lib/routes-runs.js +6 -5
  121. package/workspace/lib/routes-settings.js +51 -0
  122. package/workspace/lib/routes-signals.js +3 -2
  123. package/workspace/lib/routes-stage-tools.js +220 -0
  124. package/workspace/lib/run-answers.js +2 -1
  125. package/workspace/lib/settings-store.js +226 -0
  126. package/workspace/lib/stage-tools.js +208 -0
  127. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  128. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  129. package/workspace/lib/work-evidence.js +73 -0
  130. package/workspace/package-lock.json +2 -2
  131. package/workspace/package.json +1 -1
  132. package/workspace/public/pm/briefing-deck.js +63 -17
  133. package/workspace/public/pm/briefing-model.js +36 -7
  134. package/workspace/public/pm/c3-ambient.js +100 -0
  135. package/workspace/public/pm/c3-brief-needs.js +26 -6
  136. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  137. package/workspace/public/pm/c3-brief-today.js +56 -21
  138. package/workspace/public/pm/c3-brief.js +2 -1
  139. package/workspace/public/pm/c3-calendar.js +161 -42
  140. package/workspace/public/pm/c3-calpop.js +5 -5
  141. package/workspace/public/pm/c3-demo-boards.js +649 -0
  142. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  143. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  144. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  145. package/workspace/public/pm/c3-demo-data.js +17 -27
  146. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  147. package/workspace/public/pm/c3-demo.js +2 -1
  148. package/workspace/public/pm/c3-dock.js +1 -1
  149. package/workspace/public/pm/c3-ib-docs.js +80 -0
  150. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  151. package/workspace/public/pm/c3-ib-items.js +201 -0
  152. package/workspace/public/pm/c3-ib-releases.js +51 -0
  153. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  154. package/workspace/public/pm/c3-ib-triage.js +178 -0
  155. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  156. package/workspace/public/pm/c3-ini-records.js +197 -0
  157. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  158. package/workspace/public/pm/c3-ini-ui.js +229 -0
  159. package/workspace/public/pm/c3-ini-work.js +139 -0
  160. package/workspace/public/pm/c3-initiatives.css +218 -0
  161. package/workspace/public/pm/c3-initiatives.js +741 -0
  162. package/workspace/public/pm/c3-plan.js +2 -2
  163. package/workspace/public/pm/c3-reports.css +47 -47
  164. package/workspace/public/pm/c3-reports.js +223 -185
  165. package/workspace/public/pm/c3-settings.css +74 -0
  166. package/workspace/public/pm/c3-settings.js +703 -0
  167. package/workspace/public/pm/c3-shell.js +102 -36
  168. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  169. package/workspace/public/pm/c3-terminal.js +2 -2
  170. package/workspace/public/pm/calendar-views.js +1 -1
  171. package/workspace/public/pm/components.css +319 -213
  172. package/workspace/public/pm/console3.css +99 -23
  173. package/workspace/public/pm/dest-notes.js +26 -4
  174. package/workspace/public/pm/notes-screen-capture.js +165 -0
  175. package/workspace/public/pm/notes-screen-view.js +98 -0
  176. package/workspace/public/pm/panel-trees.js +5 -2
  177. package/workspace/public/pm/pm-work-view.js +233 -146
  178. package/workspace/public/pm/shell-bell.js +3 -2
  179. package/workspace/public/pm/shell-recording.js +29 -8
  180. package/workspace/public/pm/tokens.css +2 -2
  181. package/workspace/public/pm/view-notes.js +46 -9
  182. package/workspace/public/pm/welcome.js +197 -89
  183. package/workspace/public/welcome.html +6 -4
  184. package/workspace/server.js +105 -6
  185. package/workspace/skills/day-check.skill +1 -1
  186. package/workspace/skills/jira-sync.skill +6 -4
  187. package/workspace/skills/morning-brief.skill +1 -1
  188. package/workspace/test-files.json +12 -3
  189. package/workspace/test-lists/c3-ib-items.json +9 -0
  190. package/workspace/test-lists/c3-ib-more.json +9 -0
  191. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  192. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  193. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  194. package/workspace/test-lists/console3-briefing.json +9 -0
  195. package/workspace/test-lists/console3-foundation.json +9 -0
  196. package/workspace/test-lists/console3-reports.json +3 -3
  197. package/workspace/test-lists/meeting-notes.json +10 -4
  198. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  199. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  200. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  201. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  202. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  203. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  204. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  205. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  206. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  207. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  208. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  209. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -1,156 +0,0 @@
1
- 'use strict';
2
- /*
3
- * board-cache.js — the workflow board, cached and derived OFF the event loop.
4
- *
5
- * WHY THIS EXISTS. `console-state.workflow()` runs `scripts/workflow-state.js` over every
6
- * initiative record, and the engine resolves each artifact's FIRST APPEARANCE by shelling `git`.
7
- * Measured on the PMOS repo, 2026-07-29: ~260 ms per initiative × 109 records = **~30 seconds**, and
8
- * every millisecond of it is SYNCHRONOUS. Today that cost is paid only when the PM opens the
9
- * `workflow` lens, where it freezes the server — and with it every PTY WebSocket — for half a minute.
10
- *
11
- * PRD R4 puts the same board behind EVERY sidebar row, so an uncached in-process derivation would
12
- * turn a 30-second freeze into a freeze on every render. Two rules follow, and they are the whole
13
- * design:
14
- *
15
- * 1. NEVER derive on the request path. A miss spawns a CHILD PROCESS and returns immediately.
16
- * The event loop is never blocked, however cold the cache is.
17
- * 2. NEVER serve a stale board as if it were fresh. Every hit carries `as_of` and `stale`, and a
18
- * cold cache returns `pending: true` rather than an empty board. A surface that cannot say how
19
- * old its answer is has no business showing a stage — that is R5's rule applied to time
20
- * instead of to branch.
21
- *
22
- * The signature is HEAD + the initiative records' own mtimes, so editing a record refreshes the
23
- * board without a commit, and switching branch refreshes it without an edit.
24
- */
25
- const fs = require('fs');
26
- const path = require('path');
27
- const { spawnSync, spawn } = require('child_process');
28
-
29
- const MAX_AGE_MS = 10 * 60 * 1000; // a board older than this is refreshed even if the signature holds
30
- const REFRESH_TIMEOUT_MS = 120000; // a derivation that cannot finish in 2 minutes is reported, not awaited
31
-
32
- const cache = new Map(); // root -> { sig, at, value, error }
33
- const inflight = new Map(); // root -> true
34
-
35
- // headSha runs on every signature, and the needs poll asks for a signature per registered tree several times per
36
- // request, so a `git rev-parse` each time held the server for seconds with twenty trees registered. The answer is
37
- // kept per root while the files a commit, checkout or reset touches (HEAD, its ref, packed-refs, the HEAD reflog)
38
- // are unchanged; anything unreadable falls back to asking git.
39
- const headMemo = new Map(); // root -> { key, sha }
40
- function gitDirOf(root) {
41
- const dotgit = path.join(root, '.git');
42
- const st = fs.statSync(dotgit);
43
- if (st.isDirectory()) return { dir: dotgit, common: dotgit };
44
- const m = /^gitdir:\s*(.+)\s*$/m.exec(fs.readFileSync(dotgit, 'utf8'));
45
- if (!m) throw new Error('no gitdir');
46
- const dir = path.resolve(root, m[1].trim());
47
- let common = dir;
48
- try { common = path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()); } catch { /* not a linked worktree */ }
49
- return { dir, common };
50
- }
51
- function headKey(root) {
52
- const { dir, common } = gitDirOf(root);
53
- const head = fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim();
54
- const stat = (f) => { try { const st = fs.statSync(f); return `${st.size}:${st.mtimeMs}:${st.ino}`; } catch { return '-'; } };
55
- const ref = /^ref:\s*(.+)$/.exec(head);
56
- return [head, ref ? stat(path.join(common, ref[1])) : '', stat(path.join(common, 'packed-refs')), stat(path.join(dir, 'logs', 'HEAD'))].join('|');
57
- }
58
- function headSha(root) {
59
- let key = null;
60
- try { key = headKey(root); } catch { key = null; }
61
- const hit = headMemo.get(root);
62
- if (key && hit && hit.key === key) return hit.sha;
63
- const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 });
64
- const sha = r.status === 0 && r.stdout ? r.stdout.trim() : 'no-head';
65
- if (key) headMemo.set(root, { key, sha });
66
- return sha;
67
- }
68
-
69
- // The records' own state, so an UNCOMMITTED edit still invalidates. Cheap: one readdir + one stat
70
- // per record, no git, no file contents.
71
- function recordsSig(root) {
72
- // Both layouts (pmos-self `planning/`, a PM repository's `pmos/state/`), and the ledgers the
73
- // board's rows depend on: an uncommitted lifecycle or acceptance line must not wait out the TTL (D70).
74
- // pm-os-absorb: plan-items.jsonl (Run 4's Build rule reads items) and issues.jsonl (Run 5's report lane reads issues).
75
- const dirs = [path.join(root, 'planning', 'initiatives'), path.join(root, 'pmos', 'state', 'initiatives')];
76
- const ledgers = ['lifecycle.jsonl', 'acceptances.jsonl', 'plan-items.jsonl', 'issues.jsonl'].flatMap((f) => [
77
- path.join(root, 'planning', 'runs', f), path.join(root, 'pmos', 'state', f)]);
78
- let total = 0; let newest = 0; let any = false;
79
- for (const dir of dirs) {
80
- let names = [];
81
- try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { continue; }
82
- any = true; total += names.length;
83
- for (const n of names) {
84
- try { const st = fs.statSync(path.join(dir, n)); if (st.mtimeMs > newest) newest = st.mtimeMs; }
85
- catch { /* a record that vanished mid-scan just does not raise the watermark */ }
86
- }
87
- }
88
- if (!any) return 'none';
89
- let led = 0;
90
- for (const f of ledgers) { try { const st = fs.statSync(f); led = Math.max(led, Math.round(st.mtimeMs)) + st.size; } catch { /* absent */ } }
91
- return `${total}:${Math.round(newest)}:${led}`;
92
- }
93
-
94
- function signature(root) { return `${headSha(root)}|${recordsSig(root)}`; }
95
-
96
- // The child runs the SAME reader the lens uses — no second implementation of the board to drift.
97
- function refresh(root, sig) {
98
- if (inflight.get(root)) return;
99
- inflight.set(root, true);
100
- const script = `
101
- const cs = require(${JSON.stringify(path.join(__dirname, 'console-state.js'))});
102
- process.stdout.write(JSON.stringify(cs.workflow({ root: ${JSON.stringify(root)} })));
103
- `;
104
- let out = '';
105
- let child;
106
- try { child = spawn(process.execPath, ['-e', script], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); }
107
- catch (e) {
108
- inflight.delete(root);
109
- cache.set(root, { sig, at: Date.now(), value: null, error: 'could not start the board derivation: ' + (e.code || e.message) });
110
- return;
111
- }
112
- const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, REFRESH_TIMEOUT_MS);
113
- if (timer.unref) timer.unref();
114
- child.stdout.on('data', (c) => { out += c; });
115
- child.on('error', (e) => {
116
- clearTimeout(timer); inflight.delete(root);
117
- cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation failed to run: ' + (e.code || e.message) });
118
- });
119
- child.on('close', (code, signal) => {
120
- clearTimeout(timer); inflight.delete(root);
121
- if (signal === 'SIGKILL') {
122
- cache.set(root, { sig, at: Date.now(), value: null, error: `the board took longer than ${Math.round(REFRESH_TIMEOUT_MS / 1000)}s to derive and was stopped` });
123
- return;
124
- }
125
- if (code !== 0 || !out) {
126
- cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation exited without a result' });
127
- return;
128
- }
129
- try { cache.set(root, { sig, at: Date.now(), value: JSON.parse(out), error: null }); }
130
- catch { cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation returned unreadable output' }); }
131
- });
132
- }
133
-
134
- /*
135
- * The read the request path uses. Always returns immediately.
136
- * { pending: true } nothing cached yet; a derivation was started
137
- * { ok:false, error, as_of, stale } the derivation failed — render RED, never a calm empty
138
- * { ...workflowPayload, as_of, stale } a board; `stale` means a refresh is running behind it
139
- */
140
- function board(root, { refreshNow = false } = {}) {
141
- if (!root) return { ok: false, error: 'no workspace root resolved for the workflow board', as_of: null, stale: false };
142
- let sig;
143
- try { sig = signature(root); } catch { sig = 'unreadable'; }
144
- const hit = cache.get(root);
145
- const fresh = hit && hit.sig === sig && (Date.now() - hit.at) < MAX_AGE_MS;
146
- if (!fresh || refreshNow) refresh(root, sig);
147
- if (!hit) return { pending: true, refreshing: true, as_of: null, stale: false };
148
- const envelope = { as_of: new Date(hit.at).toISOString(), stale: !fresh, refreshing: !!inflight.get(root) };
149
- if (hit.error) return { ok: false, error: hit.error, ...envelope };
150
- return { ...hit.value, ...envelope };
151
- }
152
-
153
- // Test/maintenance seam: drop everything, or one root.
154
- function invalidate(root) { if (root) { cache.delete(root); inflight.delete(root); } else { cache.clear(); inflight.clear(); } }
155
-
156
- module.exports = { board, invalidate, signature, MAX_AGE_MS, REFRESH_TIMEOUT_MS };
@@ -1,295 +0,0 @@
1
- 'use strict';
2
- /*
3
- * Read-only console state readers (D64 / pivot-workspace-first, Track B).
4
- *
5
- * The workspace console surfaces — the "needs you" queue, a radar-style health summary, and a
6
- * metrics view — derive ONLY from local file-mode state: the kit's `pmos/state/*.jsonl` and, as a
7
- * seed/demo source when no live state exists yet, the committed hosted snapshot under
8
- * `planning/data-export/<date>-hosted-snapshot/*.json`. Live file-mode state is ALWAYS preferred.
9
- *
10
- * HONESTY BY CONSTRUCTION (the anti-watermelon rule): every reader returns a discriminated result —
11
- * { ok:true, empty:false, source, data } data present
12
- * { ok:true, empty:true, source } genuinely nothing (calm empty state on the client)
13
- * { ok:false, error } a missing source / parse failure — the client MUST
14
- * render this RED, never a green "all clear".
15
- * A missing DATA source (no live file AND no snapshot) is an ERROR, not an empty — a health surface
16
- * that cannot read its inputs is broken, and a broken honesty surface must read red.
17
- */
18
- const fs = require('fs');
19
- const path = require('path');
20
- const reviewGate = require('./review-gate');
21
- const gitInfo = require('./git-info'); // R5: the ladder is branch-scoped — name the branch it came from
22
-
23
- function loadJson(file) {
24
- let text;
25
- try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
26
- try { return { ok: true, data: JSON.parse(text) }; }
27
- catch { return { ok: false, error: `${path.basename(file)} is not valid JSON — the file-mode source is corrupt, not empty` }; }
28
- }
29
- function loadJsonl(file) {
30
- let text;
31
- try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
32
- const rows = []; let bad = 0;
33
- for (const line of text.split('\n')) {
34
- const t = line.trim(); if (!t) continue;
35
- try { rows.push(JSON.parse(t)); } catch { bad++; }
36
- }
37
- // A file that is entirely unparseable is corrupt state — surface it red rather than call it empty.
38
- if (bad > 0 && rows.length === 0) return { ok: false, error: `every line in ${path.basename(file)} is malformed — corrupt file-mode state` };
39
- return { ok: true, data: rows, bad };
40
- }
41
-
42
- function computeMetricsFromRuns(runs) {
43
- const corr = runs.map((r) => Number(r.human_corrections) || 0);
44
- const total = corr.reduce((a, b) => a + b, 0);
45
- const needing = corr.filter((c) => c > 0).length;
46
- const n = runs.length;
47
- return {
48
- total_runs: n,
49
- avg_corrections_per_run: n ? +(total / n).toFixed(3) : 0,
50
- runs_needing_correction: needing,
51
- share_runs_corrected: n ? +(needing / n).toFixed(3) : 0,
52
- per_initiative: [],
53
- };
54
- }
55
-
56
- // ── metrics: prefer live pmos/state/runs.jsonl; fall back to the snapshot's metrics.json ──
57
- function metrics({ stateDir, snapshotDir }) {
58
- if (stateDir) {
59
- const live = loadJsonl(path.join(stateDir, 'runs.jsonl'));
60
- if (live.ok) {
61
- if (!live.data.length) return { ok: true, empty: true, source: 'file-mode' };
62
- return { ok: true, empty: false, source: 'file-mode', data: computeMetricsFromRuns(live.data) };
63
- }
64
- if (live.ok === false) return { ok: false, error: live.error };
65
- // live.missing → fall through to the snapshot
66
- }
67
- if (!snapshotDir) return { ok: false, error: 'no metrics source — no live runs.jsonl and no hosted snapshot for this workspace' };
68
- const snap = loadJson(path.join(snapshotDir, 'metrics.json'));
69
- if (snap.missing) return { ok: false, error: 'metrics source missing — neither live runs.jsonl nor the hosted snapshot is present' };
70
- if (!snap.ok) return { ok: false, error: snap.error };
71
- const m = snap.data || {};
72
- return { ok: true, empty: false, source: 'snapshot', data: {
73
- total_runs: m.total_runs, avg_corrections_per_run: m.avg_corrections_per_run,
74
- runs_needing_correction: m.runs_needing_correction, share_runs_corrected: m.share_runs_corrected,
75
- per_initiative: Array.isArray(m.per_initiative) ? m.per_initiative.slice(0, 12) : [],
76
- } };
77
- }
78
-
79
- // ── radar: prefer a live pmos/state/radar.json; fall back to the snapshot's radar_all_sections.json ──
80
- function radar({ stateDir, snapshotDir }) {
81
- let src = null, source = null;
82
- if (stateDir) {
83
- const live = loadJson(path.join(stateDir, 'radar.json'));
84
- if (live.ok) { src = live.data; source = 'file-mode'; }
85
- else if (live.ok === false) return { ok: false, error: live.error };
86
- }
87
- if (!src) {
88
- if (!snapshotDir) return { ok: false, error: 'no radar source — no live radar.json and no hosted snapshot for this workspace' };
89
- const snap = loadJson(path.join(snapshotDir, 'radar_all_sections.json'));
90
- if (snap.missing) return { ok: false, error: 'radar source missing — neither live radar.json nor the hosted snapshot is present' };
91
- if (!snap.ok) return { ok: false, error: snap.error };
92
- src = snap.data; source = 'snapshot';
93
- }
94
- const d = src || {};
95
- const gates = Array.isArray(d.eval_gates) ? d.eval_gates : [];
96
- const passing = gates.filter((g) => reviewGate.isReviewPass(g.verdict, g.score)).length;
97
- const wms = (Array.isArray(d.watermelons) ? d.watermelons : []).map((w) => ({
98
- id: w.id, title: w.title, rag: w.rag,
99
- verdict: w.latest_eval_verdict, score: w.latest_eval_score, corrections: w.avg_human_corrections,
100
- }));
101
- const calRaw = Array.isArray(d.calibration) ? d.calibration[0] : d.calibration;
102
- const calibration = calRaw ? {
103
- n: calRaw.n, kappa: calRaw.kappa, tnr: calRaw.tnr, status: calRaw.status,
104
- meets_d40: !!calRaw.meets_d40_kappa, caveat: calRaw.leakage_caveat || null,
105
- } : null;
106
- const okr = (Array.isArray(d.okr_pulse) ? d.okr_pulse : []).slice(0, 8).map((o) => ({
107
- objective: o.objective, kr: o.kr, progress: o.progress_pct, pulse: o.pulse,
108
- }));
109
- return { ok: true, empty: false, source, data: {
110
- // watermelon flags = anything not green (amber/red) leads; the whole point is to surface drift
111
- watermelons: wms,
112
- flags: wms.filter((w) => w.rag && String(w.rag).toLowerCase() !== 'green').length,
113
- eval_gate: { total: gates.length, passing, pass_rate: gates.length ? +(passing / gates.length).toFixed(3) : null },
114
- calibration, okr_pulse: okr,
115
- } };
116
- }
117
-
118
- /*
119
- * ── workflow: where each initiative is, what is next, what is blocking ────────────────────────
120
- *
121
- * The one surface that answers "where am I in the loop" without the PM reconstructing it from
122
- * files. It renders the SAME ladder the CLI and the Initiative Gate print, from the same engine —
123
- * `scripts/workflow-state.js` — so the panel can never disagree with what CI is about to say.
124
- *
125
- * The engine is the one BUNDLED WITH THE WORKSPACE, pointed at the opened repo via its `root`
126
- * option. It is deliberately not `require`d out of the opened directory: the engine only reads
127
- * files and runs `git -C <root> log`, so no code from a workspace the PM merely opened is ever
128
- * executed. An install that does not carry the engine gets an honest error, never a blank panel.
129
- *
130
- * WHERE THE ENGINE ACTUALLY LIVES — the defect this list exists to close (incident
131
- * kit-workflow-engine-missing). "Bundled with the workspace" was the INTENT above; packaging never
132
- * implemented it. `npm-pkg/package.json` ships only bin/ payload/ workspace/, so in a published
133
- * install there is no repo two levels up and `../../scripts/workflow-state.js` resolves to nothing.
134
- * Every adopter row read "ladder unreadable" from 0.11.0 back to whenever this surface shipped —
135
- * the ladder had never once run outside a PMOS checkout. Two locations, tried in order:
136
- *
137
- * 1. `../../scripts/workflow-state.js` — a repo checkout (PMOS-self, any dev clone). Preferred on
138
- * purpose: it is the very file `.github/scripts/initiative-gate.sh` executes, so a developer's
139
- * panel can never disagree with what CI is about to print on their PR.
140
- * 2. `lib/engine/workflow-state.js` — the copy the publish step vendors into the bundle. This is
141
- * what an adopter runs, and the only reason the ladder derives at all outside this repo.
142
- *
143
- * ABSENT and BROKEN are answered differently, because they are different facts (R10). A path that
144
- * does not exist means "try the next one"; a path that exists and throws is a real fault in an
145
- * engine that IS installed, and reporting that as "not present" would send the PM looking for a
146
- * packaging bug they do not have.
147
- */
148
- const ENGINE_PATHS = [
149
- path.join(__dirname, '..', '..', 'scripts', 'workflow-state.js'),
150
- path.join(__dirname, 'engine', 'workflow-state.js'),
151
- ];
152
- function loadEngine() {
153
- const tried = [];
154
- for (const p of ENGINE_PATHS) {
155
- if (!fs.existsSync(p)) { tried.push(p); continue; }
156
- try { return { ok: true, engine: require(p), from: p }; }
157
- catch (e) {
158
- return { ok: false, error: `the workflow engine at ${p} is installed but failed to load — ${String((e && e.message) || e).slice(0, 160)}` };
159
- }
160
- }
161
- return { ok: false, error: `the workflow engine is not present in this install — the ladder cannot be derived here (looked in: ${tried.join(', ')})` };
162
- }
163
- function workflow({ root }) {
164
- if (!root) return { ok: false, error: 'no workspace root resolved for the workflow surface' };
165
- const loaded = loadEngine();
166
- if (!loaded.ok) return { ok: false, error: loaded.error };
167
- const engine = loaded.engine;
168
- let ids;
169
- try { ids = engine.listInitiatives(root); }
170
- catch (e) { return { ok: false, error: 'could not list initiative records: ' + String((e && e.message) || 'error').slice(0, 120) }; }
171
- if (!ids.length) return { ok: true, empty: true, source: 'file-mode' };
172
-
173
- /*
174
- * THE TRUNK, not the pull request (workflow-status-sweep R-3).
175
- *
176
- * This used to be `engine.commitRange(root)` — merge-base(origin/main, HEAD)..HEAD — and the trap
177
- * is documented three lines below in words that were written and then not acted on: on `main`, or
178
- * on a branch level with it, that range is EMPTY with `range.ok === true`. Nothing reads as
179
- * degraded, every `build` rung legitimately reports `pending`, and the four-panel fold rolls
180
- * BUILD/EVAL/ACCEPTED to 0/3 for every initiative in the repository — including ones that shipped
181
- * months ago. The PM opens the panel to ask what is delivered and is told nothing is.
182
- *
183
- * So the panel asks the question the CLI board already asks — "where does this initiative stand?",
184
- * which only the trunk can answer — and passes the PR range alongside as `changeRange`, which is
185
- * what `● in this change` means and the one thing the trunk range genuinely cannot answer. Same
186
- * split, same reasons, stated in `historyRange`'s own header. The `build` rung is attributed per
187
- * initiative now (R-2), so a trunk-wide range no longer means a trunk-wide answer.
188
- */
189
- const range = engine.historyRange(root);
190
- const changeRange = engine.commitRange(root);
191
- // ONE delivery index for the board, built once and shared, exactly as the CLI board does.
192
- let deliveryIndex = null;
193
- try { deliveryIndex = require(path.join(path.dirname(loaded.from), 'delivery-index.js')).deliveryIndex(root, ids, { recordsDir: engine.layout(root).initiatives }); }
194
- catch { deliveryIndex = null; } // absent → each ladder builds its own; the engine handles it
195
- const bi = gitInfo.branchInfo(root);
196
- const scope = {
197
- branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
198
- branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
199
- commits: range.ok ? range.shas.length : null,
200
- whole_history: range.ok ? !!range.wholeHistory : null,
201
- // Kept, and now describing `changeRange` rather than the derivation range. It is no longer the
202
- // trap it names — the derivation cannot land in it — but a consumer that wants to say "nothing
203
- // in this branch touches these initiatives" still needs the fact, and deleting a field because
204
- // the bug it documented was fixed is how the next reader loses the reason.
205
- empty_range: changeRange.ok ? changeRange.shas.length === 0 : null,
206
- change_commits: changeRange.ok ? changeRange.shas.length : null,
207
- };
208
- const rows = ids.map((id) => {
209
- try { return engine.computeWorkflow(id, { root, range, changeRange, deliveryIndex }); }
210
- catch (e) { return { ok: false, id, error: String((e && e.message) || 'derivation failed').slice(0, 160) }; }
211
- });
212
- // Same `inFlight` the CLI and the gate use — the panel must not invent its own idea of what is
213
- // open. Filtering by `legacy` here was a real bug: on main every record is legacy, so the panel
214
- // read "0 in flight" against 93 initiatives, blank at exactly the moment someone opens it.
215
- const { shown, hidden, total } = engine.inFlight(rows);
216
- const shape = (r) => (r.ok ? {
217
- id: r.id, title: r.title, lane: r.lane, stage: r.stage, legacy: r.legacy,
218
- in_change: r.in_change, anchor: r.anchor,
219
- record: r.record, blockers: r.blockers, next: r.next,
220
- steps: r.steps.map((s) => ({ key: s.key, status: s.status, evidence: s.evidence, next: s.next, blocking: s.blocking, rule: s.rule })),
221
- } : { id: r.id, error: r.error });
222
- return {
223
- ok: true, empty: false, source: 'file-mode',
224
- data: {
225
- live: shown.map(shape),
226
- hidden, // reported on the client — a silent cap reads as "that is all"
227
- total,
228
- accepted: rows.filter((r) => r.ok && r.accepted).length,
229
- // D70 — stopped initiatives are off the board and not gating, but listed, never dropped silently.
230
- stopped: rows.filter((r) => r.ok && r.stopped).map((r) => ({ id: r.id, title: r.title, state: r.stopped.state, reason: r.stopped.reason, at: r.stopped.at, pm: r.stopped.pm })),
231
- unreadable: rows.filter((r) => !r.ok).map(shape),
232
- blocked: shown.filter((r) => r.ok && r.blockers.length).length,
233
- degraded: range.ok ? null : range.reason,
234
- scope, // R5 — the branch this ladder was derived from; never omit it
235
- enforced_from: engine.ENFORCED_FROM,
236
- },
237
- };
238
- }
239
-
240
- // ENGINE_PATHS/loadEngine are exported for the packaging guard in workspace/test.js: the incident
241
- // that made them a list was invisible to every unit test precisely because resolution was inline.
242
- /* ONE initiative's ladder, derived on demand.
243
- *
244
- * WHY THIS IS SEPARATE FROM `workflow`. The board caps what it returns (`inFlight`) and reports the
245
- * remainder as `hidden` — 12 shown against 93 hidden in this repository. That cap is right for a
246
- * board, which is a list of what is in flight. It is wrong for a PANEL, which is asked about one
247
- * initiative the PM picked by name: reading the capped list and finding nothing there means "not in
248
- * the top twelve", and a panel that renders that as "no initiative record" states something false
249
- * about 93 of 105 records. This derives the one that was asked for, so the answer is about the
250
- * initiative rather than about its rank.
251
- *
252
- * It is safe on the request path for the same reason the board is not: this shells git once for the
253
- * commit range and runs the engine ONCE, rather than 105 times. `workflow` still owns the board.
254
- */
255
- function ladder({ root, id }) {
256
- if (!root) return { ok: false, error: 'no workspace root resolved for the ladder' };
257
- if (!id) return { ok: false, error: 'no initiative id given' };
258
- const loaded = loadEngine();
259
- if (!loaded.ok) return { ok: false, error: loaded.error };
260
- const engine = loaded.engine;
261
- // ABSENT and BROKEN are different facts and must not share an answer. The engine returns
262
- // `ok:false` for BOTH "there is no record with this id" and "the record is there and unreadable",
263
- // and collapsing them makes the panel paint a red "read failed" over an initiative that simply
264
- // does not exist — overstating a missing file as a fault. The initiative list is the arbiter:
265
- // an id the repository does not know is an empty read, and only an id it DOES know can fail one.
266
- let known = null;
267
- try { known = engine.listInitiatives(root); } catch { known = null; }
268
- if (Array.isArray(known) && !known.includes(id)) return { ok: true, empty: true, row: null };
269
- // The trunk, for the reason `workflow` above states at length: this is asked about ONE initiative
270
- // the PM named, and "where does it stand" is a question the pull-request range cannot answer — on
271
- // main it is empty, so the panel reported `BUILD 0/3` on delivered work (R-3).
272
- let range, changeRange, row;
273
- try { range = engine.historyRange(root); }
274
- catch (e) { return { ok: false, error: 'could not resolve the commit range: ' + String((e && e.message) || 'error').slice(0, 120) }; }
275
- try { changeRange = engine.commitRange(root); } catch { changeRange = { ok: false }; }
276
- try { row = engine.computeWorkflow(id, { root, range, changeRange }); }
277
- catch (e) { return { ok: false, error: `could not derive the ladder for ${id}: ` + String((e && e.message) || 'error').slice(0, 160) }; }
278
- if (row && row.ok === false) return { ok: false, error: row.error || `the record for ${id} could not be read` };
279
- const bi = gitInfo.branchInfo(root);
280
- return {
281
- ok: true,
282
- empty: !row, // no such record — distinct from a read that failed
283
- row: row || null,
284
- scope: {
285
- branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
286
- branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
287
- commits: range.ok ? range.shas.length : null,
288
- whole_history: range.ok ? !!range.wholeHistory : null,
289
- empty_range: changeRange.ok ? changeRange.shas.length === 0 : null,
290
- change_commits: changeRange.ok ? changeRange.shas.length : null,
291
- },
292
- };
293
- }
294
-
295
- module.exports = { metrics, radar, workflow, ladder, computeMetricsFromRuns, loadJson, loadJsonl, ENGINE_PATHS, loadEngine };
@@ -1,158 +0,0 @@
1
- 'use strict';
2
- /* factory-join.js — THE ONE JOIN of the factory graph's work items onto plan items and initiatives (pm-os-absorb Run 4,
3
- * PRD R6, R7, R8, R19; D65, D86 point 1).
4
- *
5
- * joinFactory(root, { initiative, fold } = {}) → {
6
- * ok: true,
7
- * byInit: {
8
- * [initiativeId]: {
9
- * items: [{ id, plan_item, state, pr, integrated, integrated_at }],
10
- * id = work-item id; plan_item = plan item id | null; state = the work item's folded state
11
- * (scripts/work-item.js deriveItems: declared, claimed, verified, failed, integrated, shipped,
12
- * withdrawn); pr = a pull-request reference from its evidence | null; integrated = true once
13
- * `pmos verify-work` saw its trailer on the default branch; integrated_at = that line's time | null
14
- * batches: [{ id, ready, items: [<work-item id>] }], a D65 Release batch; ready = every item passed its checks
15
- * }
16
- * },
17
- * flags: [{ kind, id, why }], a dangling plan_item, an unreadable line, a missing engine
18
- * unreadable?: why only when the ledger EXISTS but cannot be read or folded
19
- * }
20
- * factoryOf(join, id) → that initiative's entry, or null; when the join is `unreadable`, { items: [], batches: [],
21
- * unreadable: why } for every id, so a health rule says Unknown (R8) and never "on track"
22
- *
23
- * `initiative` narrows byInit to that one id. `fold` is an optional lib/plan-items.js fold the caller already holds
24
- * (lib/plan-tree.js layout passes its own), so the dangling check does not read the plan-items ledger twice. Readers
25
- * never throw; a bad line is a flag.
26
- *
27
- * THE FOLD IS D65's, NOT A COPY. Work-item state comes from scripts/work-item.js deriveItems, the fold the writer and
28
- * the CI checker (scripts/check-work-items.js) use, so this join cannot call an item verified that the gate calls
29
- * failed. That module lives in scripts/ and is not vendored into a published workspace (its sink guard requires a
30
- * directory the vendoring walk refuses). In an install without it, a repository that HAS a factory ledger gets the
31
- * flag `engine-missing` and an empty join; one without a ledger gets an empty join and no flag. Only PMOS-self runs a
32
- * factory today.
33
- *
34
- * WHERE THE LEDGER IS. `planning/work-items.jsonl` of the MAIN working tree of `root`, the same tree the plan-items
35
- * ledger resolves to (lib/plan-items.js ledgerRoot), so the two halves of the join are read from one tree.
36
- *
37
- * A BATCH (D65 Release, D86 point 2) is the work items one materialization declared: one Commit covers the whole batch
38
- * (R14), and the materialization writes their `declared` lines with ONE `at`. The ledger has no batch field (D86 point
39
- * 1 adds `plan_item` and nothing else), so the batch is that shared `at`. Only materialized items (a `plan_item`) form
40
- * batches; a withdrawn or shipped item has left its batch. The batch is keyed on that `at` as an INSTANT, and its id is
41
- * `b<UTC digits of that at>` — 14 digits to the second, and the 3 millisecond digits after them when they are not
42
- * 000 — so two materializations in one second have two ids, and one Release grant names `<initiative>:<batch id>`. `ready` = every item in it is verified or integrated, and none failed.
43
- *
44
- * Consumers (all read through this module, never work-items.jsonl directly):
45
- * - health ctx.factory = factoryOf(joinFactory(root), id) (lib/health-rules-factory.js reads it; 2C's routes and
46
- * 5A's drafter assemble it);
47
- * - lib/plan-tree.js layout (a materialized item reads Done once integrated, R7);
48
- * - 4D's Board and work-check-needs (Review and Release rows);
49
- * - 5A's blocks (factory progress when present).
50
- *
51
- * Nothing is cached (D33). No network, no git.
52
- */
53
- const fs = require('fs');
54
- const path = require('path');
55
-
56
- const WORK_ITEM_PATHS = [path.join(__dirname, '..', '..', 'scripts', 'work-item.js')];
57
- const READY = new Set(['verified', 'integrated']);
58
- const PR_RE = /^(?:https?:\/\/\S+\/pull\/\d+\S*|pr:#?\d+)$/i;
59
-
60
- function loadWorkItem() {
61
- for (const p of WORK_ITEM_PATHS) {
62
- if (!fs.existsSync(p)) continue;
63
- try { return { ok: true, mod: require(p) }; }
64
- catch (e) { return { ok: false, error: `scripts/work-item.js is present but failed to load — ${String((e && e.message) || e).slice(0, 160)}` }; }
65
- }
66
- return { ok: false, error: 'the work-item fold (scripts/work-item.js) is not in this install, so factory work cannot be read here' };
67
- }
68
-
69
- function mainTreeOf(root) {
70
- try { return require('./plan-tree.js').mainTree(path.resolve(String(root))); } catch { return path.resolve(String(root)); }
71
- }
72
-
73
- /** Per work item, from the raw lines: the `at` of its declaration, its last integrated time and its last PR evidence. */
74
- function lineFacts(text) {
75
- const facts = new Map();
76
- for (const raw of String(text).split('\n')) {
77
- if (!raw.trim()) continue;
78
- let r;
79
- try { r = JSON.parse(raw); } catch { continue; }
80
- if (!r || typeof r !== 'object' || typeof r.item_id !== 'string') continue;
81
- const f = facts.get(r.item_id) || { declaredAt: null, integratedAt: null, pr: null };
82
- if (r.event === 'declared' && !f.declaredAt && typeof r.at === 'string') f.declaredAt = r.at;
83
- if (r.event === 'integrated' && typeof r.at === 'string') f.integratedAt = r.at;
84
- if (typeof r.evidence === 'string' && PR_RE.test(r.evidence.trim())) f.pr = r.evidence.trim();
85
- facts.set(r.item_id, f);
86
- }
87
- return facts;
88
- }
89
-
90
- /** The batch id of one declared instant: b + 14 digits, + the milliseconds when they are not 000. Unique per instant. */
91
- function batchId(at) {
92
- const t = Date.parse(at);
93
- if (!Number.isFinite(t)) return `b${String(at).replace(/\D/g, '')}`;
94
- const digits = new Date(t).toISOString().replace(/\D/g, ''); // YYYYMMDDHHMMSSmmm
95
- return `b${digits.endsWith('000') ? digits.slice(0, 14) : digits}`;
96
- }
97
-
98
- function joinFactory(root, { initiative, fold } = {}) {
99
- const out = { ok: true, byInit: {}, flags: [] };
100
- const unreadable = (kind, why) => { out.flags.push({ kind, id: null, why }); out.unreadable = why; return out; };
101
- const file = path.join(mainTreeOf(root), 'planning', 'work-items.jsonl');
102
- let text = '';
103
- try { text = fs.readFileSync(file, 'utf8'); }
104
- catch (e) {
105
- if (!(e && e.code === 'ENOENT')) return unreadable('unreadable', `planning/work-items.jsonl cannot be read (${(e && e.code) || e})`);
106
- return out;
107
- }
108
- if (!text.trim()) return out;
109
- const W = loadWorkItem();
110
- if (!W.ok) return unreadable('engine-missing', W.error);
111
-
112
- let derived;
113
- try { derived = W.mod.deriveItems(text, { now: new Date().toISOString() }); }
114
- catch (e) { return unreadable('unreadable', `the work-item fold failed: ${String((e && e.message) || e).slice(0, 160)}`); }
115
- for (const d of derived.defects) out.flags.push({ kind: d.kind, id: d.item_id || null, why: `line ${d.line}: ${d.detail}` });
116
-
117
- let planFold = fold || null;
118
- if (!planFold && derived.items.some((it) => typeof it.plan_item === 'string')) {
119
- try { planFold = require('./plan-items.js').readFold(root); }
120
- catch (e) { out.flags.push({ kind: 'plan-items-unreadable', id: null, why: `the plan-items ledger cannot be read, so no plan_item is checked: ${String((e && e.message) || e).slice(0, 160)}` }); }
121
- }
122
- const known = planFold ? new Set([...Object.keys(planFold.items || {}), ...(planFold.withdrawnIds || [])]) : null;
123
-
124
- const facts = lineFacts(text);
125
- const batchesOf = new Map();
126
- for (const it of derived.items) {
127
- if (initiative && it.initiative_id !== initiative) continue;
128
- const id = it.item_id;
129
- const f = facts.get(id) || {};
130
- const planItem = typeof it.plan_item === 'string' ? it.plan_item : null;
131
- if (planItem && known && !known.has(planItem)) {
132
- out.flags.push({ kind: 'dangling-plan-item', id, why: `plan_item ${planItem} names no plan item` });
133
- }
134
- const integrated = (it.history || []).includes('integrated');
135
- const entry = out.byInit[it.initiative_id] || (out.byInit[it.initiative_id] = { items: [], batches: [] });
136
- entry.items.push({ id, plan_item: planItem, state: it.state, pr: f.pr || null, integrated, integrated_at: integrated ? f.integratedAt || null : null });
137
- if (planItem && it.state !== 'withdrawn' && it.state !== 'shipped' && f.declaredAt) {
138
- const t = Date.parse(f.declaredAt);
139
- const key = `${it.initiative_id}\u0000${Number.isFinite(t) ? t : f.declaredAt}`;
140
- if (!batchesOf.has(key)) batchesOf.set(key, { init: it.initiative_id, at: f.declaredAt, items: [], states: [] });
141
- const b = batchesOf.get(key);
142
- b.items.push(id); b.states.push(it.state);
143
- }
144
- }
145
- for (const b of batchesOf.values()) {
146
- out.byInit[b.init].batches.push({ id: batchId(b.at), ready: b.states.every((s) => READY.has(s)), items: b.items.slice().sort() });
147
- }
148
- for (const e of Object.values(out.byInit)) e.batches.sort((a, b) => a.id.localeCompare(b.id));
149
- return out;
150
- }
151
-
152
- /** The health ctx.factory value for one initiative: its join entry, or null. */
153
- function factoryOf(join, id) {
154
- if (join && typeof join.unreadable === 'string') return { items: [], batches: [], unreadable: join.unreadable };
155
- return (join && join.byInit && Object.prototype.hasOwnProperty.call(join.byInit, id)) ? join.byInit[id] : null;
156
- }
157
-
158
- module.exports = { joinFactory, factoryOf };