create-pmos 0.52.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- 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 };
|