create-pmos 0.51.0 → 0.53.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 (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/README.md +13 -0
  4. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
  6. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
  7. package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/plan-items-stage.js +271 -0
  11. package/payload/scripts/pmos-log.sh +18 -7
  12. package/payload/scripts/workflow-state.js +28 -2
  13. package/payload/state/discoveries.md +1 -1
  14. package/payload/state/log.md +1 -1
  15. package/workspace/bin/pmos.js +15 -1
  16. package/workspace/cut-stub.js +40 -0
  17. package/workspace/first-paint-audit.js +145 -0
  18. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  19. package/workspace/fixtures/initiatives-api/list.json +176 -0
  20. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  21. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  22. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  23. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  24. package/workspace/fixtures/report-api/draft.json +177 -0
  25. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  26. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  27. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  28. package/workspace/guide/board.md +81 -0
  29. package/workspace/guide/breakdown.md +154 -0
  30. package/workspace/guide/command-palette.md +75 -0
  31. package/workspace/guide/index.md +27 -17
  32. package/workspace/guide/initiatives-roadmap.md +130 -0
  33. package/workspace/guide/initiatives-screen.md +89 -0
  34. package/workspace/guide/initiatives-timeline.md +57 -0
  35. package/workspace/guide/integrated-work.md +94 -0
  36. package/workspace/guide/knowledge-graph.md +3 -68
  37. package/workspace/guide/layout.md +98 -32
  38. package/workspace/guide/live-notes.md +3 -3
  39. package/workspace/guide/pm-only-acts.md +89 -0
  40. package/workspace/guide/pm-only-lines.md +61 -0
  41. package/workspace/guide/reports-page.md +80 -0
  42. package/workspace/guide/reports.md +73 -0
  43. package/workspace/guide/timeline.md +70 -0
  44. package/workspace/guide/today.md +85 -0
  45. package/workspace/guide/validate-with-typesafe.md +1 -1
  46. package/workspace/lib/actor.js +91 -0
  47. package/workspace/lib/board-cache.js +32 -2
  48. package/workspace/lib/board-cli.js +108 -0
  49. package/workspace/lib/board-fold.js +232 -0
  50. package/workspace/lib/breakdown-export.js +80 -0
  51. package/workspace/lib/capture.js +98 -0
  52. package/workspace/lib/engine/plan-items-stage.js +271 -0
  53. package/workspace/lib/engine/workflow-state.js +28 -2
  54. package/workspace/lib/factory-join.js +158 -0
  55. package/workspace/lib/grant-mint.js +113 -0
  56. package/workspace/lib/health-rules-factory.js +92 -0
  57. package/workspace/lib/health-rules-plan.js +164 -0
  58. package/workspace/lib/health.js +79 -0
  59. package/workspace/lib/initiative-cli.js +222 -0
  60. package/workspace/lib/initiative-display.js +183 -0
  61. package/workspace/lib/initiative-needs.js +127 -0
  62. package/workspace/lib/initiative-rows.js +206 -0
  63. package/workspace/lib/initiative-writer.js +141 -0
  64. package/workspace/lib/issues.js +206 -0
  65. package/workspace/lib/item-cli.js +195 -0
  66. package/workspace/lib/item-spans.js +137 -0
  67. package/workspace/lib/measure-cli.js +100 -0
  68. package/workspace/lib/measurements.js +189 -0
  69. package/workspace/lib/mobile-nav.js +57 -53
  70. package/workspace/lib/needs-providers.js +120 -0
  71. package/workspace/lib/placements.js +440 -0
  72. package/workspace/lib/plan-items.js +922 -0
  73. package/workspace/lib/plan-ledgers.js +85 -0
  74. package/workspace/lib/plan-rules.js +72 -0
  75. package/workspace/lib/plan-tree.js +367 -0
  76. package/workspace/lib/pm-guard.js +174 -0
  77. package/workspace/lib/pm-lines-needs.js +331 -0
  78. package/workspace/lib/pmos-verbs.js +66 -0
  79. package/workspace/lib/proactive.js +7 -4
  80. package/workspace/lib/report-cli.js +152 -0
  81. package/workspace/lib/report-draft.js +579 -0
  82. package/workspace/lib/report-needs.js +213 -0
  83. package/workspace/lib/route-modules.js +128 -0
  84. package/workspace/lib/routes-board.js +91 -0
  85. package/workspace/lib/routes-capture.js +52 -0
  86. package/workspace/lib/routes-initiatives.js +371 -0
  87. package/workspace/lib/routes-reports.js +163 -0
  88. package/workspace/lib/routes-timeline.js +197 -0
  89. package/workspace/lib/routes-today.js +32 -0
  90. package/workspace/lib/today-cli.js +99 -0
  91. package/workspace/lib/today.js +333 -0
  92. package/workspace/lib/triage.js +163 -0
  93. package/workspace/lib/verify-work-cli.js +81 -0
  94. package/workspace/lib/work-check-needs.js +99 -0
  95. package/workspace/package-lock.json +1 -1
  96. package/workspace/package.json +5 -5
  97. package/workspace/public/index.html +29 -19
  98. package/workspace/public/pm/app.js +299 -30
  99. package/workspace/public/pm/bd-board.js +113 -0
  100. package/workspace/public/pm/bd-drag.js +59 -0
  101. package/workspace/public/pm/bd-model.js +152 -0
  102. package/workspace/public/pm/bd-timeline.js +233 -0
  103. package/workspace/public/pm/bd-tree.js +244 -0
  104. package/workspace/public/pm/board-copy.js +57 -0
  105. package/workspace/public/pm/breakdown-api.js +58 -0
  106. package/workspace/public/pm/breakdown-copy.js +138 -0
  107. package/workspace/public/pm/components.css +3054 -0
  108. package/workspace/public/pm/cycle-review.js +125 -0
  109. package/workspace/public/pm/data.js +40 -187
  110. package/workspace/public/pm/dest-board.js +438 -0
  111. package/workspace/public/pm/dest-initiatives.js +134 -0
  112. package/workspace/public/pm/dest-reports.js +270 -0
  113. package/workspace/public/pm/dest-settings.js +217 -0
  114. package/workspace/public/pm/dest-today.js +176 -0
  115. package/workspace/public/pm/dialogs.js +88 -0
  116. package/workspace/public/pm/display-word.js +56 -0
  117. package/workspace/public/pm/dock.js +105 -0
  118. package/workspace/public/pm/fixture.js +8 -227
  119. package/workspace/public/pm/frame-copy.js +94 -0
  120. package/workspace/public/pm/handoff-box.js +82 -0
  121. package/workspace/public/pm/http.js +88 -0
  122. package/workspace/public/pm/icons.js +53 -0
  123. package/workspace/public/pm/initiative-list.js +218 -0
  124. package/workspace/public/pm/initiative-sheet.js +261 -0
  125. package/workspace/public/pm/initiatives-api.js +59 -0
  126. package/workspace/public/pm/initiatives-copy.js +215 -0
  127. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  128. package/workspace/public/pm/initiatives-timeline.js +410 -0
  129. package/workspace/public/pm/initiatives-ui.js +123 -0
  130. package/workspace/public/pm/item-sheet.js +251 -0
  131. package/workspace/public/pm/keep-card.js +204 -0
  132. package/workspace/public/pm/lifecycle.js +7 -1
  133. package/workspace/public/pm/mobile.css +49 -20
  134. package/workspace/public/pm/mobile.js +157 -29
  135. package/workspace/public/pm/modes-live.js +7 -883
  136. package/workspace/public/pm/modes.js +32 -1024
  137. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  138. package/workspace/public/pm/next-step-ui.js +3 -2
  139. package/workspace/public/pm/palette-copy.js +51 -0
  140. package/workspace/public/pm/palette.js +312 -0
  141. package/workspace/public/pm/panel-breakdown.js +702 -0
  142. package/workspace/public/pm/panel-cockpit.js +63 -295
  143. package/workspace/public/pm/panel-detail.js +25 -172
  144. package/workspace/public/pm/panel-initiatives.js +1 -19
  145. package/workspace/public/pm/panel-repos.js +31 -81
  146. package/workspace/public/pm/panel-tabs.js +197 -110
  147. package/workspace/public/pm/panel-trees.js +5 -2
  148. package/workspace/public/pm/portfolio-report.js +61 -0
  149. package/workspace/public/pm/primitives.js +656 -0
  150. package/workspace/public/pm/print.css +33 -0
  151. package/workspace/public/pm/provider-cards.js +136 -0
  152. package/workspace/public/pm/registry.js +194 -0
  153. package/workspace/public/pm/release-card.js +110 -0
  154. package/workspace/public/pm/report-view.js +309 -0
  155. package/workspace/public/pm/reports-copy.js +114 -0
  156. package/workspace/public/pm/roadmap.js +271 -0
  157. package/workspace/public/pm/sig-watch.js +41 -0
  158. package/workspace/public/pm/state.js +8 -4
  159. package/workspace/public/pm/status-banner.js +13 -5
  160. package/workspace/public/pm/tab-doc.js +2 -4
  161. package/workspace/public/pm/tab-term.js +12 -2
  162. package/workspace/public/pm/tab-web.js +3 -2
  163. package/workspace/public/pm/timeline-copy.js +48 -0
  164. package/workspace/public/pm/today-cards.js +244 -0
  165. package/workspace/public/pm/today-copy.js +97 -0
  166. package/workspace/public/pm/tokens.css +16 -18
  167. package/workspace/public/pm/topbar.js +102 -0
  168. package/workspace/public/pm/triage-card.js +60 -0
  169. package/workspace/public/pm/undo-hold.js +103 -0
  170. package/workspace/public/pm/view-addrepo.js +2 -1
  171. package/workspace/public/pm/view-livenotes.js +3 -1
  172. package/workspace/public/pm/view-today.js +7 -162
  173. package/workspace/public/vendor/Sortable.min.js +2 -0
  174. package/workspace/public/vendor/lucide-sprite.js +79 -0
  175. package/workspace/replaced-tests.json +2864 -0
  176. package/workspace/run-tests.js +117 -6
  177. package/workspace/server.js +312 -36
  178. package/workspace/sheet-answer.js +61 -0
  179. package/workspace/skills/measure.skill +41 -0
  180. package/workspace/skills/report-biweekly.skill +23 -25
  181. package/workspace/skills/tracker-refresh.skill +50 -0
  182. package/workspace/test-files.json +20 -4
  183. package/workspace/test-lists/base.json +6 -0
  184. package/workspace/test-lists/run2-a.json +6 -0
  185. package/workspace/test-lists/run2-b.json +13 -0
  186. package/workspace/test-lists/run2-c.json +19 -0
  187. package/workspace/test-lists/run2-d.json +22 -0
  188. package/workspace/test-lists/run3-0.json +5 -0
  189. package/workspace/test-lists/run3-a.json +5 -0
  190. package/workspace/test-lists/run3-b.json +6 -0
  191. package/workspace/test-lists/run3-c.json +15 -0
  192. package/workspace/test-lists/run3-d.json +6 -0
  193. package/workspace/test-lists/run4-a.json +8 -0
  194. package/workspace/test-lists/run4-b.json +6 -0
  195. package/workspace/test-lists/run4-c.json +13 -0
  196. package/workspace/test-lists/run4-d.json +9 -0
  197. package/workspace/test-lists/run5-a.json +6 -0
  198. package/workspace/test-lists/run5-b.json +6 -0
  199. package/workspace/test-lists/run5-c.json +15 -0
  200. package/workspace/test-run.js +27 -1
  201. package/workspace/third-party/README.md +44 -0
  202. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  203. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  204. package/workspace/design-parity.js +0 -673
  205. package/workspace/public/pm/tab-task.js +0 -86
  206. package/workspace/public/pm/view-guide.js +0 -141
  207. package/workspace/public/pm/view-knowledge.js +0 -225
  208. package/workspace/public/pm/view-memory.js +0 -122
  209. package/workspace/public/pm/view-portfolio.js +0 -153
  210. package/workspace/public/pm/view-reports.js +0 -190
  211. package/workspace/public/pm/view-scheduled.js +0 -161
  212. package/workspace/public/pm/view-skills.js +0 -216
@@ -0,0 +1,158 @@
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 };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+ /* grant-mint.js — a PM press in the workspace mints ONE D81 grant through the repository's own grant writer
3
+ * (pm-os-absorb, D86 point 2; PRD R25; map-spec X5, X6, X25).
4
+ *
5
+ * PRESS_SCOPES ['release', 'lifecycle-bulk']
6
+ * mintFromPress(root, { scope, target, ids, state, instruction, initiative }, { actor })
7
+ * → { ok: true, grant_id, scope, target } | { ok: false, status, error }
8
+ * bulkTarget({ target, ids, state }) → '<state>:<id>,<id>,…' | null
9
+ *
10
+ * Two presses mint a grant, and only after pm-guard.js requirePmPress has passed, so `actor` is the guard's actor:
11
+ * - Release on a batch of factory items (Run 4, slice 4D: POST /api/board/release). Scope 'release', target
12
+ * '<initiative>:<batch-id>'. One grant for each batch.
13
+ * - A bulk lifecycle change (Run 2, slice 2A: the bulk body of POST /api/lifecycle). Scope 'lifecycle-bulk', target the
14
+ * explicit ids (20 at most) and one state. Each lifecycle line that the press writes cites grant_id (X6).
15
+ * The writers spell the target '<state>:<id>,<id>,…'. This file builds that spelling, so a caller cannot get it
16
+ * wrong: pass { ids, state } (preferred), or a target in either order ('<state>:<ids>' or '<ids>:<state>'). Any
17
+ * other target goes to the writer unchanged, and the writer refuses it with 400.
18
+ *
19
+ * The grant is written by the repository's OWN writer, run as a child process with an argv and no shell, resolved the
20
+ * way the cockpit mod resolves it (kit/mods/pmos-cockpit/hooks/lib/commands.mjs writerOf): PMOS-self runs
21
+ * `node scripts/record-grant.js grant …`, and a kit install runs `bash pmos/scripts/pmos-log.sh grant …`. The workspace
22
+ * never requires scripts/ (the engine is vendored, X29) and never writes grants.jsonl itself. So each scope has one
23
+ * rule, kept in the writers (grants.mjs, record-grant.js, pmos-log.sh) and compared by scripts/tests/record-grant.test.js.
24
+ *
25
+ * Refusals that hold before and after the fill: a scope a press cannot mint is 400; an actor that is not the PM is 403
26
+ * (a grant is the person's permission, D81).
27
+ *
28
+ * Filled by Run 2 (slice 2B). The repository is the MAIN working tree of `root` (lib/plan-ledgers.js), so the grant lands
29
+ * in the same runs directory as the lifecycle lines that cite it. The child gets a minimal environment: no
30
+ * PMOS_STATE_DIR or KIT_ROOT from this process can point the writer at another folder, and nothing of the server's own
31
+ * environment reaches it. A refusal by the writer (a target the scope rule does not allow) is 400 with the writer's
32
+ * reason; a writer that is missing or fails in another way is 500. Nothing is retried, and nothing is written here.
33
+ */
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const { spawnSync } = require('child_process');
37
+ const actor = require('./actor.js');
38
+ const planTree = require('./plan-tree.js');
39
+
40
+ const PRESS_SCOPES = Object.freeze(['release', 'lifecycle-bulk']);
41
+ const TTL_MINUTES = '120';
42
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
43
+ const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
44
+
45
+ /**
46
+ * The grants writer of the repository at `repo`, as commands.mjs writerOf resolves it: PMOS-self (planning/initiatives)
47
+ * runs scripts/record-grant.js; a kit (pmos/state/initiatives and no planning/initiatives) runs pmos/scripts/pmos-log.sh.
48
+ * Returns { argv } or null.
49
+ */
50
+ function writerOf(repo) {
51
+ const self = isDir(path.join(repo, 'planning', 'initiatives'));
52
+ const kit = !self && isDir(path.join(repo, 'pmos', 'state', 'initiatives'));
53
+ if (self && isFile(path.join(repo, 'scripts', 'record-grant.js'))) return { argv: [process.execPath, path.join(repo, 'scripts', 'record-grant.js')] };
54
+ if (kit && isFile(path.join(repo, 'pmos', 'scripts', 'pmos-log.sh'))) return { argv: ['bash', path.join(repo, 'pmos', 'scripts', 'pmos-log.sh')] };
55
+ return null;
56
+ }
57
+
58
+ /** The row a writer printed after it appended: the last line of its output that holds a JSON object. */
59
+ function rowFrom(stdout) {
60
+ const lines = String(stdout || '').split('\n').filter((l) => l.includes('{'));
61
+ for (let i = lines.length - 1; i >= 0; i--) {
62
+ try { const r = JSON.parse(lines[i].slice(lines[i].indexOf('{'))); if (r && r.type === 'grant' && typeof r.id === 'string') return r; } catch { /* the next one */ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ const BULK_STATES = Object.freeze(['active', 'closed', 'on-hold', 'cancelled']);
68
+ /**
69
+ * The one spelling of a lifecycle-bulk target: '<state>:<id>,<id>,…'. From { ids, state }, or from a target in either
70
+ * order. Null when neither gives one state and a list of ids; the writer then judges the target as it was given.
71
+ */
72
+ function bulkTarget({ target, ids, state } = {}) {
73
+ if (Array.isArray(ids) && ids.length && typeof state === 'string' && BULK_STATES.includes(state)) return state + ':' + ids.map(String).join(',');
74
+ if (typeof target !== 'string') return null;
75
+ const t = target.trim();
76
+ const head = t.slice(0, t.indexOf(':'));
77
+ const tail = t.slice(t.lastIndexOf(':') + 1);
78
+ if (BULK_STATES.includes(head)) return t;
79
+ if (t.includes(':') && BULK_STATES.includes(tail)) return tail + ':' + t.slice(0, t.lastIndexOf(':'));
80
+ return null;
81
+ }
82
+
83
+ function mintFromPress(root, { scope, target, ids, state, instruction, initiative } = {}, { actor: who } = {}) {
84
+ if (!PRESS_SCOPES.includes(scope)) return { ok: false, status: 400, error: 'a press cannot mint a grant of scope ' + JSON.stringify(scope) };
85
+ if (!actor.isPm(who)) return { ok: false, status: 403, error: 'only the PM mints a grant' };
86
+ if (scope === 'lifecycle-bulk') target = bulkTarget({ target, ids, state }) ?? target;
87
+ if (typeof target !== 'string' || !target.trim()) return { ok: false, status: 400, error: 'a ' + scope + ' grant names its target' };
88
+ const repo = planTree.mainTree(path.resolve(String(root)));
89
+ const w = writerOf(repo);
90
+ if (!w) return { ok: false, status: 500, error: 'this repository has no grants writer (scripts/record-grant.js or pmos/scripts/pmos-log.sh)' };
91
+ const words = typeof instruction === 'string' && instruction.trim() ? instruction.trim() : 'the PM pressed ' + scope + ' in the workspace';
92
+ const argv = [...w.argv, 'grant', scope + ':' + target, who.by.slice('pm:'.length), words, TTL_MINUTES];
93
+ if (typeof initiative === 'string' && initiative) argv.push(initiative);
94
+ const env = { PATH: process.env.PATH || '/usr/bin:/bin', HOME: process.env.HOME || repo, LANG: 'C.UTF-8' };
95
+ const r = spawnSync(argv[0], argv.slice(1), { cwd: repo, env, encoding: 'utf8', timeout: 20000, shell: false });
96
+ if (r.error) return { ok: false, status: 500, error: 'the grants writer did not run: ' + String(r.error.message || r.error).slice(0, 160) };
97
+ const said = String(r.stderr || '').trim().replace(/^(record-grant|pmos-log): ERROR — /, '').slice(0, 400);
98
+ if (r.status !== 0) {
99
+ const refused = /invalid grant|is not a single|unknown scope|has no record|not a plain lowercase slug|must not be empty|ttl_minutes/.test(said);
100
+ return { ok: false, status: refused ? 400 : 500, error: said || 'the grants writer exited ' + r.status };
101
+ }
102
+ const row = rowFrom(r.stdout);
103
+ if (!row) return { ok: false, status: 500, error: 'the grants writer wrote no row it could name' };
104
+ return { ok: true, grant_id: row.id, scope: row.scope, target: row.target };
105
+ }
106
+
107
+ /**
108
+ * The scopes a press can mint NOW: PRESS_SCOPES, since 2B filled mintFromPress. The page reads it as /api/pm-modules
109
+ * `grantScopes`, so 2D shows the bulk lifecycle act enabled.
110
+ */
111
+ function mintableScopes() { return PRESS_SCOPES.slice(); }
112
+
113
+ module.exports = { PRESS_SCOPES, mintFromPress, mintableScopes, bulkTarget };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+ /* health-rules-factory.js — the factory's health rules (pm-os-absorb Run 4, PRD R8). lib/health.js loads this file from
3
+ * its RULE_FILES slot; each rule answers null (nothing to say) or { level, code, text, source }.
4
+ *
5
+ * factory-failed OFF TRACK — a factory item failed its check and nobody resolved it. "Resolved" is the fold's
6
+ * word: a later `verified` revokes the failure (D65(d)), and a `withdrawn` ends the item. So an
7
+ * item whose folded state is `failed` is unresolved. UNKNOWN (code factory-unreadable) when the
8
+ * factory ledger exists but cannot be read or folded (the join's `unreadable`, R8): a failure
9
+ * the rule cannot see must not read as on track.
10
+ * factory-outside-now AT RISK — building outside Now: a factory item is claimed (a live claim) while the initiative
11
+ * is in Next or Later, or the trunk holds commits that scripts/delivery-index.js attributes to
12
+ * it while it is in Next or Later and it is not accepted. An initiative with no placement is in
13
+ * Later (lib/placements.js horizonOf).
14
+ *
15
+ * What a rule reads (lib/health.js ctx): `factory` = lib/factory-join.js factoryOf(joinFactory(root), id), `placement`
16
+ * = lib/placements.js byInit[id], `row` = the workflow row (scripts/workflow-state.js). A rule whose input is missing
17
+ * says nothing: a missing factory join is "no factory work", never a failure. An UNREADABLE join is not missing.
18
+ *
19
+ * THE COMMIT SIGNAL is the engine's own. The workflow row's Build step says why it is done; when the reason is
20
+ * delivery-index's attribution sentence (a merged initiative/<id> branch, or a trunk commit that names the initiative
21
+ * and changes build files), the commits are attributed. The sentences come from delivery-index.attributionEvidence,
22
+ * not from a copy here, so a change of wording there cannot silence this rule (factory-join.test.js holds it).
23
+ *
24
+ * Pure. No clock, no file reads after the first load of delivery-index.
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const SOURCE = 'planning/work-items.jsonl';
30
+ const HORIZON_WORD = { next: 'Next', later: 'Later' };
31
+ const DELIVERY_PATHS = [
32
+ path.join(__dirname, '..', '..', 'scripts', 'delivery-index.js'),
33
+ path.join(__dirname, 'engine', 'delivery-index.js'),
34
+ ];
35
+ let delivery;
36
+ function attributionSentences(id) {
37
+ if (delivery === undefined) {
38
+ delivery = null;
39
+ for (const p of DELIVERY_PATHS) { if (fs.existsSync(p)) { try { delivery = require(p); } catch { delivery = null; } break; } }
40
+ }
41
+ if (!delivery || typeof delivery.attributionEvidence !== 'function') return [];
42
+ return ['branch', 'mention'].map((s) => delivery.attributionEvidence(s, id));
43
+ }
44
+
45
+ const horizonOf = (ctx) => (ctx && ctx.placement && ctx.placement.horizon) || 'later';
46
+ const list = (ids) => (ids.length > 3 ? `${ids.slice(0, 3).join(', ')} and ${ids.length - 3} more` : ids.join(', '));
47
+
48
+ module.exports = [
49
+ {
50
+ id: 'factory-failed',
51
+ check(ctx) {
52
+ if (ctx && ctx.factory && typeof ctx.factory.unreadable === 'string') {
53
+ return {
54
+ level: 'unknown', code: 'factory-unreadable', source: SOURCE,
55
+ text: `The factory ledger cannot be read, so the state of factory work is not known. ${ctx.factory.unreadable}`,
56
+ };
57
+ }
58
+ const items = (ctx && ctx.factory && Array.isArray(ctx.factory.items)) ? ctx.factory.items : [];
59
+ const failed = items.filter((it) => it && it.state === 'failed').map((it) => it.id);
60
+ if (!failed.length) return null;
61
+ return {
62
+ level: 'off', code: 'factory-failed', source: SOURCE,
63
+ text: `${failed.length === 1 ? 'Factory item' : 'Factory items'} ${list(failed)} failed ${failed.length === 1 ? 'its check' : 'their checks'}. Fix or withdraw ${failed.length === 1 ? 'it' : 'them'}.`,
64
+ };
65
+ },
66
+ },
67
+ {
68
+ id: 'factory-outside-now',
69
+ check(ctx) {
70
+ const horizon = horizonOf(ctx);
71
+ if (horizon === 'now' || !HORIZON_WORD[horizon]) return null;
72
+ const where = HORIZON_WORD[horizon];
73
+ const items = (ctx && ctx.factory && Array.isArray(ctx.factory.items)) ? ctx.factory.items : [];
74
+ const claimed = items.filter((it) => it && it.state === 'claimed').map((it) => it.id);
75
+ if (claimed.length) {
76
+ return {
77
+ level: 'at-risk', code: 'factory-outside-now', source: SOURCE,
78
+ text: `Factory work is claimed (${list(claimed)}), but the initiative is in ${where}. Factory work starts only from Now.`,
79
+ };
80
+ }
81
+ const row = ctx && ctx.row;
82
+ if (!row || row.accepted || !Array.isArray(row.steps)) return null;
83
+ const build = row.steps.find((s) => s && s.key === 'build');
84
+ const said = build && build.status === 'done' ? String(build.evidence || '') : '';
85
+ if (!said || !attributionSentences(ctx.id || row.id).some((s) => said.includes(s))) return null;
86
+ return {
87
+ level: 'at-risk', code: 'factory-outside-now', source: 'git (scripts/delivery-index.js)',
88
+ text: `Commits on the trunk are attributed to this initiative, but it is in ${where}. Building starts only from Now.`,
89
+ };
90
+ },
91
+ },
92
+ ];
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+ /* health-rules-plan.js — the health rules of the plan: items, placements and the ladder (pm-os-absorb, PRD R8, slice 2D).
3
+ *
4
+ * lib/health.js loads this file (RULE_FILES) and folds each rule's answer: the worst level wins and on track shows
5
+ * nothing. Each rule is `{ id, check(ctx) }` and answers null or `{ level, code, text, source }`, where `text` is the
6
+ * reason a person reads next to the dot. ctx is `{ id, row, fold, placement, measurement, factory, rules, now }`
7
+ * (seams §5.4). A field a rule needs and ctx does not carry makes that rule say nothing.
8
+ *
9
+ * off track an overdue milestone; a predecessor from another initiative that was withdrawn
10
+ * at risk a Now initiative idle for stall_days; a PM step owed longer than that; a passed check-by date; an
11
+ * overdue item; an item that starts before an open predecessor ends
12
+ * unknown a source could not be read (the workflow row, or lines of plan-items.jsonl)
13
+ *
14
+ * Not here: an unresolved failed factory item and building outside Now (Run 4, health-rules-factory.js). A finished
15
+ * initiative (Accepted, Handed off) has no health: every rule here says nothing for it. A PM step's age is the age of the
16
+ * initiative's last evidence, because the ladder does not record when a step became owed. A stopped initiative (On hold, Closed,
17
+ * Cancelled) has none either: it is off the board. A check-by date belongs to Now, so one that a card keeps in Next or Later is
18
+ * not read.
19
+ *
20
+ * Pure: no I/O, no clock beyond ctx.now, no cache.
21
+ */
22
+ const display = require('./initiative-display.js');
23
+
24
+ const DAY = 86400000;
25
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
26
+ const ITEMS = 'planning/runs/plan-items.jsonl';
27
+ const PLACEMENTS = 'planning/runs/placements.jsonl';
28
+
29
+ const nowOf = (ctx) => (typeof ctx.now === 'number' ? ctx.now : Date.now());
30
+ const todayOf = (ctx) => new Date(nowOf(ctx)).toISOString().slice(0, 10);
31
+ /** '2026-10-01' -> '1 Oct' (a calendar day, never shifted by the time zone). */
32
+ function shortDate(ymd) {
33
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(ymd || ''));
34
+ return m ? `${Number(m[3])} ${MON[Number(m[2]) - 1]}` : '';
35
+ }
36
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
37
+ const recordOf = (ctx) => (ctx.row && ctx.row.record) || (ctx.id ? `planning/initiatives/${ctx.id}.md` : 'planning/initiatives');
38
+
39
+ /** The live items of this initiative in ctx.fold (an object map or a list), proposals and withdrawn items left out. */
40
+ function itemsOf(ctx) {
41
+ const raw = ctx.fold && ctx.fold.items;
42
+ const all = Array.isArray(raw) ? raw : raw && typeof raw === 'object' ? Object.values(raw) : [];
43
+ return all.filter((it) => it && (!ctx.id || !it.initiative || it.initiative === ctx.id) && it.state !== 'withdrawn' && !it.proposed);
44
+ }
45
+ const open = (it) => it.state !== 'done';
46
+
47
+ const displayCtx = (ctx) => ({ fold: ctx.fold, placement: ctx.placement, rules: ctx.rules, now: nowOf(ctx) });
48
+ /** The state of a stopped initiative (on-hold, closed or cancelled): ctx.lifecycle when the caller passes one, else the workflow row's `stopped`. */
49
+ function stoppedState(ctx) {
50
+ const s = (ctx.lifecycle && ctx.lifecycle.state) || (ctx.row && ctx.row.stopped && ctx.row.stopped.state);
51
+ return typeof s === 'string' && s && s !== 'active' ? s : null;
52
+ }
53
+ /** A finished initiative (Accepted, Handed off) and a stopped one (On hold, Closed, Cancelled: off the board, D70) have no health. */
54
+ function finishedWord(ctx) {
55
+ if (stoppedState(ctx)) return true;
56
+ if (!ctx.row) return false;
57
+ const w = display.displayOf(ctx.row, displayCtx(ctx)).word;
58
+ return w === 'Accepted' || w === 'Handed off';
59
+ }
60
+ const stallDays = (ctx) => (ctx.rules && Number.isInteger(ctx.rules.stall_days) && ctx.rules.stall_days > 0 ? ctx.rules.stall_days : 5);
61
+
62
+ const RULES = [
63
+ {
64
+ id: 'milestone-overdue',
65
+ check(ctx) {
66
+ if (!ctx.fold || finishedWord(ctx)) return null;
67
+ const today = todayOf(ctx);
68
+ const late = itemsOf(ctx).filter((it) => it.milestone && it.due && it.due < today && open(it)).sort((a, b) => (a.due < b.due ? -1 : 1));
69
+ if (!late.length) return null;
70
+ return {
71
+ level: 'off', code: 'milestone-overdue', source: ITEMS,
72
+ text: late.length === 1 ? `Milestone "${late[0].title}" was due ${shortDate(late[0].due)}` : `${late.length} milestones are overdue, the first due ${shortDate(late[0].due)}`,
73
+ };
74
+ },
75
+ },
76
+ {
77
+ id: 'withdrawn-predecessor',
78
+ check(ctx) {
79
+ if (!ctx.fold || !ctx.id || finishedWord(ctx)) return null;
80
+ const gone = new Set(Array.isArray(ctx.fold.withdrawnIds) ? ctx.fold.withdrawnIds : []);
81
+ if (!gone.size) return null;
82
+ const foreign = (id) => { const k = String(id).lastIndexOf('.'); return k > 0 && String(id).slice(0, k) !== ctx.id; };
83
+ const hit = itemsOf(ctx).find((it) => open(it) && (it.after || []).some((p) => gone.has(p) && foreign(p)));
84
+ if (!hit) return null;
85
+ return { level: 'off', code: 'withdrawn-predecessor', source: ITEMS, text: `"${hit.title}" waits on an item of another initiative that was withdrawn` };
86
+ },
87
+ },
88
+ {
89
+ id: 'idle-in-now',
90
+ check(ctx) {
91
+ if (!ctx.row || !ctx.placement || ctx.placement.horizon !== 'now' || finishedWord(ctx)) return null;
92
+ const label = display.idleLabel(ctx.row, displayCtx(ctx));
93
+ if (!label) return null;
94
+ return { level: 'at-risk', code: 'idle', text: label, source: recordOf(ctx) };
95
+ },
96
+ },
97
+ {
98
+ id: 'pm-step-owed',
99
+ check(ctx) {
100
+ if (!ctx.row || finishedWord(ctx)) return null;
101
+ const d = display.displayOf(ctx.row, displayCtx(ctx));
102
+ if (d.waitingOn !== 'pm') return null;
103
+ const ev = display.evidenceOf(ctx.row, displayCtx(ctx));
104
+ if (ev.moving || ev.at === null) return null;
105
+ const days = Math.floor((nowOf(ctx) - ev.at) / DAY);
106
+ if (days < stallDays(ctx)) return null;
107
+ return { level: 'at-risk', code: 'pm-step-owed', text: `Your ${d.act} is owed. The last evidence is ${days} d old`, source: recordOf(ctx) };
108
+ },
109
+ },
110
+ {
111
+ id: 'check-by-passed',
112
+ check(ctx) {
113
+ const by = ctx.placement && ctx.placement.horizon === 'now' && ctx.placement.check_by;
114
+ if (!by || by >= todayOf(ctx) || finishedWord(ctx)) return null;
115
+ return { level: 'at-risk', code: 'check-by-passed', text: `check-by ${shortDate(by)} passed`, source: PLACEMENTS };
116
+ },
117
+ },
118
+ {
119
+ id: 'item-overdue',
120
+ check(ctx) {
121
+ if (!ctx.fold || finishedWord(ctx)) return null;
122
+ const today = todayOf(ctx);
123
+ const late = itemsOf(ctx).filter((it) => !it.milestone && it.due && it.due < today && open(it)).sort((a, b) => (a.due < b.due ? -1 : 1));
124
+ if (!late.length) return null;
125
+ return {
126
+ level: 'at-risk', code: 'item-overdue', source: ITEMS,
127
+ text: late.length === 1 ? `"${late[0].title}" was due ${shortDate(late[0].due)}` : `${plural(late.length, 'item is', 'items are')} overdue, the first due ${shortDate(late[0].due)}`,
128
+ };
129
+ },
130
+ },
131
+ {
132
+ id: 'starts-before-predecessor',
133
+ check(ctx) {
134
+ if (!ctx.fold || finishedWord(ctx)) return null;
135
+ const mine = itemsOf(ctx);
136
+ const byId = new Map(mine.map((it) => [it.id, it]));
137
+ const all = ctx.fold.items;
138
+ const lookup = (id) => byId.get(id) || (all && !Array.isArray(all) ? all[id] : null);
139
+ for (const it of mine) {
140
+ if (!it.start || !open(it)) continue;
141
+ for (const pid of it.after || []) {
142
+ const p = lookup(pid);
143
+ if (p && p.due && p.due > it.start && p.state !== 'withdrawn' && open(p)) {
144
+ return { level: 'at-risk', code: 'starts-before-predecessor', source: ITEMS, text: `"${it.title}" starts ${shortDate(it.start)}, before "${p.title}" ends ${shortDate(p.due)}` };
145
+ }
146
+ }
147
+ }
148
+ return null;
149
+ },
150
+ },
151
+ {
152
+ id: 'source-unreadable',
153
+ check(ctx) {
154
+ if (ctx.row && ctx.row.ok === false) {
155
+ return { level: 'unknown', code: 'workflow-unreadable', text: 'The workflow could not be read for this initiative', source: recordOf(ctx) };
156
+ }
157
+ const bad = ((ctx.fold && ctx.fold.flags) || []).filter((f) => f && f.kind === 'unparseable');
158
+ if (!bad.length) return null;
159
+ return { level: 'unknown', code: 'items-unreadable', text: `plan-items.jsonl has ${plural(bad.length, 'line', 'lines')} that could not be read`, source: ITEMS };
160
+ },
161
+ },
162
+ ];
163
+
164
+ module.exports = RULES;
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ /* health.js — ONE definition of an initiative's health: on track, at risk, off track or unknown, each with its reason
3
+ * (pm-os-absorb, PRD R8).
4
+ *
5
+ * healthOf(ctx) → { level: 'on'|'at-risk'|'off'|'unknown', reasons: [{ level, code, text, source }] }
6
+ * LEVELS · RULE_FILES · rules() · loadErrors()
7
+ *
8
+ * `ctx` is what a rule may read, assembled by the caller; any field may be missing and a rule that needs it says
9
+ * nothing:
10
+ * { id, row, fold, placement, measurement, factory, rules, now }
11
+ * row the workflow row of the initiative (scripts/workflow-state.js computeWorkflow)
12
+ * fold lib/plan-items.js readFold of the repository, or null
13
+ * placement lib/placements.js byInit[id], or null
14
+ * measurement lib/measurements.js latest(…, anchor), or null
15
+ * factory the factory join (Run 4), or null
16
+ * rules lib/plan-rules.js readRules(root)
17
+ * now ms since the epoch
18
+ *
19
+ * RULES LIVE IN THEIR OWN FILES, not here, so the runs that add them never edit this one. Each file in RULE_FILES
20
+ * exports an array of `{ id, check(ctx) }`; `check` returns null (nothing to say) or `{ level, code, text, source }`.
21
+ * A file that is not on disk yet is skipped. A file that fails to load, or a rule that throws, gives an `unknown`
22
+ * reason that names it: a broken rule must not read as on track.
23
+ *
24
+ * The worst level wins: off, then at-risk, then unknown, then on. On track shows no reason. Pure; nothing is cached
25
+ * except the loaded rule list (D33 applies to data, and rules are code).
26
+ */
27
+ const path = require('path');
28
+
29
+ const LEVELS = Object.freeze(['on', 'unknown', 'at-risk', 'off']); // ascending severity
30
+ const RULE_FILES = Object.freeze([
31
+ 'health-rules-plan.js', // Run 2 (slice 2D): items, placements, the ladder
32
+ 'health-rules-factory.js', // Run 4 (slice 4A): factory items, building outside Now
33
+ ]);
34
+
35
+ let loaded = null;
36
+ function load() {
37
+ if (loaded) return loaded;
38
+ const list = [];
39
+ const errors = [];
40
+ for (const f of RULE_FILES) {
41
+ const full = path.join(__dirname, f);
42
+ let mod;
43
+ try { mod = require(full); }
44
+ catch (e) {
45
+ if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(full)) continue; // not built yet
46
+ errors.push({ file: f, error: String((e && e.message) || e).slice(0, 200) });
47
+ continue;
48
+ }
49
+ if (!Array.isArray(mod)) { errors.push({ file: f, error: 'does not export an array of rules' }); continue; }
50
+ for (const r of mod) {
51
+ if (r && typeof r.id === 'string' && typeof r.check === 'function') list.push({ ...r, file: f });
52
+ else errors.push({ file: f, error: 'a rule needs an id and a check function' });
53
+ }
54
+ }
55
+ loaded = { list, errors };
56
+ return loaded;
57
+ }
58
+
59
+ const rules = () => load().list.map((r) => ({ id: r.id, file: r.file }));
60
+ const loadErrors = () => load().errors.slice();
61
+ const rank = (level) => LEVELS.indexOf(level);
62
+
63
+ function healthOf(ctx = {}) {
64
+ const { list, errors } = load();
65
+ const reasons = errors.map((e) => ({ level: 'unknown', code: 'rule-file', text: `The health rules in ${e.file} could not be read: ${e.error}`, source: `workspace/lib/${e.file}` }));
66
+ for (const r of list) {
67
+ let out;
68
+ try { out = r.check(ctx); }
69
+ catch (e) { out = { level: 'unknown', code: 'rule-error', text: `The health rule ${r.id} failed: ${String((e && e.message) || e).slice(0, 160)}`, source: `workspace/lib/${r.file}` }; }
70
+ if (!out) continue;
71
+ const level = LEVELS.includes(out.level) && out.level !== 'on' ? out.level : 'unknown';
72
+ reasons.push({ level, code: String(out.code || r.id), text: String(out.text || r.id), source: String(out.source || `workspace/lib/${r.file}`) });
73
+ }
74
+ reasons.sort((a, b) => rank(b.level) - rank(a.level));
75
+ const level = reasons.length ? reasons[0].level : 'on';
76
+ return { level, reasons };
77
+ }
78
+
79
+ module.exports = { LEVELS, RULE_FILES, healthOf, rules, loadErrors };