create-pmos 0.132.0 → 0.133.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 (51) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/pmos.js +18 -2
  7. package/workspace/guide/index.md +1 -0
  8. package/workspace/guide/welcome.md +38 -0
  9. package/workspace/lib/import-overlay.js +32 -0
  10. package/workspace/lib/route-modules.js +1 -0
  11. package/workspace/lib/routes-onboarding.js +215 -0
  12. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  13. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  14. package/workspace/lib/vendor/scripts/intake.js +78 -0
  15. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  16. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  17. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  18. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  19. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  20. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  21. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  22. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  23. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  24. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  25. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  26. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  27. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  28. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  29. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  30. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  31. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  32. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  33. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  34. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  35. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  36. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  37. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  38. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  39. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  40. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  41. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  42. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  43. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  44. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  45. package/workspace/package-lock.json +2 -2
  46. package/workspace/package.json +1 -1
  47. package/workspace/public/pm/components.css +280 -0
  48. package/workspace/public/pm/welcome.js +283 -0
  49. package/workspace/public/welcome.html +20 -0
  50. package/workspace/server.js +2 -2
  51. package/workspace/test-lists/onboarding-firstrun.json +15 -0
@@ -0,0 +1,227 @@
1
+ 'use strict';
2
+ /* doc-category.js — what KIND of document is this, in the PM's words? (jev-doc-categorizer Run 1)
3
+ *
4
+ * `doc-profiles.js` answers a narrower question for the claims ledger: which documents is this one compared with.
5
+ * This module answers the PM's: is it a PRD, meeting notes, a report, a ticket, knowledge or something else. The category is
6
+ * DERIVED data kept beside the frontmatter `type`. It never replaces `type`, adds nothing to the registry, and is never
7
+ * written into a document.
8
+ *
9
+ * Run 1 has NO network and NO model. It is pure: it reads no file and requires nothing outside Node. Evidence, strongest first,
10
+ * and the FIRST basis that decides wins:
11
+ * 1. author key a `category:` line the author wrote (and `ticket_kind:` for a ticket)
12
+ * 2. frontmatter type a `type` that maps to a category (TYPE_TO_CATEGORY). `Reference` and `Research` map to nothing,
13
+ * because each is a bucket, and fall through to the next basis.
14
+ * 3. named by initiative an initiative record that names the file as its prd_path or rubric_path
15
+ * 4. file name a file-name suffix or a folder name
16
+ * 5. headings a heading signature
17
+ * A correction the PM set (Run 3, C7) is stronger than all five. The caller reads it from the per-user store
18
+ * (lib/doc-category-corrections.js) and passes it in as `correction`, so this module still reads no file. A valid correction
19
+ * decides with basis `pm correction`; an invalid one is ignored and the five bases decide as before.
20
+ * A file no basis decides is `undetermined` with `decided: false`. It is not rounded to `other`: `other` is an answer
21
+ * ("this is none of the kinds"), `undetermined` is the absence of one. Later bases that disagree with the winner are listed
22
+ * in `disagrees`, so the PM can see the conflict and the winner is never silently chosen.
23
+ */
24
+ const path = require('path');
25
+
26
+ const CATEGORIES = ['prd', 'meeting-notes', 'report', 'ticket', 'knowledge', 'spec-artifact', 'other'];
27
+ const TICKET_KINDS = ['design', 'engineering', 'other'];
28
+ const UNDETERMINED = 'undetermined';
29
+ /* Every basis name a result can carry. `pm correction` is the strongest. The next five are the local bases in their order.
30
+ * `jev` is reserved for the measured Jev pass of a later run, which sets its place in the order. */
31
+ const BASES = ['pm correction', 'author key', 'frontmatter type', 'named by initiative', 'file name', 'headings', 'jev'];
32
+
33
+ /** A correction is valid when its category is in the vocabulary, and its ticket kind is a known kind for a ticket and null
34
+ * (or absent) for every other category. Returns the normalized correction or null. */
35
+ function validCorrection(c) {
36
+ if (!c || typeof c !== 'object') return null;
37
+ const category = typeof c.category === 'string' ? c.category.trim().toLowerCase() : '';
38
+ if (!CATEGORIES.includes(category)) return null;
39
+ const k = c.ticket_kind === null || c.ticket_kind === undefined ? null : String(c.ticket_kind).trim().toLowerCase();
40
+ if (category === 'ticket') return TICKET_KINDS.includes(k) ? { category, ticket_kind: k } : null;
41
+ return k === null || k === '' ? { category, ticket_kind: null } : null;
42
+ }
43
+
44
+ /* Every type in scripts/frontmatter_registry.txt has a row here (a test enforces it). `null` means the type alone does not
45
+ * decide: it is a bucket, and a sharper basis may. Rows are the vocabulary's one editable decision; change one with a reason. */
46
+ const TYPE_TO_CATEGORY = {
47
+ Concept: 'knowledge', ADR: 'spec-artifact', Template: 'other', Playbook: 'knowledge', Reference: null,
48
+ Initiative: 'spec-artifact', PRD: 'prd', Story: 'spec-artifact', Rubric: 'spec-artifact', OKR: 'spec-artifact',
49
+ Radar: 'report', Checklist: 'other', Calibration: 'report', Research: null, Design: 'spec-artifact',
50
+ Prototype: 'other', Handover: 'report',
51
+ Decision: 'spec-artifact', Principle: 'knowledge', Debt: 'report', Discovery: 'report', Guide: 'knowledge',
52
+ };
53
+
54
+ /* A tenant repository is not bound to PMOS's registry, so a few spellings a PM would write for the same kinds are read too. */
55
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
56
+ const TYPE_ALIASES = {
57
+ prd: ['prd', 'productrequirements', 'productrequirementsdocument'],
58
+ 'meeting-notes': ['meetingnotes', 'meetingnote', 'meeting', 'minutes', 'meetingminutes'],
59
+ report: ['report', 'statusreport', 'postmortem'],
60
+ ticket: ['ticket', 'issue', 'bug', 'task'],
61
+ knowledge: ['knowledge', 'article', 'kb', 'howto'],
62
+ 'spec-artifact': [],
63
+ other: ['other'],
64
+ };
65
+ const ALIAS = new Map();
66
+ for (const [cat, list] of Object.entries(TYPE_ALIASES)) for (const a of list) ALIAS.set(a, cat);
67
+ ALIAS.set('designticket', ['ticket', 'design']);
68
+ ALIAS.set('engineeringticket', ['ticket', 'engineering']);
69
+ ALIAS.set('engticket', ['ticket', 'engineering']);
70
+
71
+ // What a record's path key says about the file it names. scan-consistency R3 adds the keys records use for their other
72
+ // artifacts, so an explainer or a design a record names is categorized by that tie, not left to its file name.
73
+ const ROLE_CATEGORY = { prd_path: 'prd', rubric_path: 'spec-artifact', brief_path: 'spec-artifact', design_path: 'spec-artifact', explainer_path: 'knowledge' };
74
+
75
+ const RESERVED = /(^|\/)(index|log)\.md$/i;
76
+ const TEMPLATE = /(^|\/)templates?\//i;
77
+
78
+ function typeDecision(fmType) {
79
+ const t = String(fmType || '').trim();
80
+ if (!t) return null;
81
+ if (Object.prototype.hasOwnProperty.call(TYPE_TO_CATEGORY, t)) {
82
+ const c = TYPE_TO_CATEGORY[t];
83
+ return c ? { category: c } : null;
84
+ }
85
+ const a = ALIAS.get(norm(t));
86
+ if (!a) return null;
87
+ return Array.isArray(a) ? { category: a[0], ticket_kind: a[1] } : { category: a };
88
+ }
89
+
90
+ function ticketKindFrom(text) {
91
+ const s = String(text || '').toLowerCase();
92
+ if (/(^|[^a-z])(design|ux|ui|figma|mockup|wireframe)([^a-z]|$)/.test(s)) return 'design';
93
+ if (/(^|[^a-z])(eng|engineering|bug|backend|frontend|api|steps to reproduce|regression)([^a-z]|$)/.test(s)) return 'engineering';
94
+ return 'other';
95
+ }
96
+
97
+ /* File-name and folder evidence. Suffixes the repository already uses come first because they are the strongest. */
98
+ const NAME_RULES = [
99
+ [/-eval-rubric\.md$/i, 'spec-artifact'],
100
+ [/-prd\.md$/i, 'prd'],
101
+ [/-user-stor(?:y|ies)\.md$/i, 'spec-artifact'],
102
+ [/-surface-explainer\.md$/i, 'knowledge'],
103
+ [/(^|\/)d\d{1,3}-[^/]+\.md$/i, 'spec-artifact'],
104
+ [/(^|\/)(meetings?|minutes|standups?)\//i, 'meeting-notes'],
105
+ [/(^|[-_/ ])(meeting|minutes|standup|1[-_ ]?on[-_ ]?1)([-_. ]|notes|\.md$)/i, 'meeting-notes'],
106
+ [/(^|\/)(initiatives|adr)\//i, 'spec-artifact'],
107
+ [/(^|\/)(explainers|guide|guides|playbooks|concepts)\//i, 'knowledge'],
108
+ [/(^|\/)readme\.md$/i, 'knowledge'],
109
+ [/(^|\/)(tickets?|issues?|bugs?)\//i, 'ticket'],
110
+ // a tracker key such as PROJ-123, but not a date: `sync-2026-09-12.md` is a dated note, not ticket SYNC-2026
111
+ [/(^|\/)[a-z][a-z0-9]{1,9}-\d{1,6}(?!-\d)([-_.][^/]*)?\.md$/i, 'ticket'],
112
+ [/(^|[-_/ ])ticket([-_. ]|\.md$)/i, 'ticket'],
113
+ [/(^|\/)reports?\//i, 'report'],
114
+ [/(^|[-_/ ])(report|postmortem|post-mortem|status-update)([-_. ]|\.md$)/i, 'report'],
115
+ ];
116
+
117
+ function headings(text) {
118
+ const out = [];
119
+ for (const m of String(text).matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) out.push(m[1].toLowerCase());
120
+ return out;
121
+ }
122
+
123
+ function fileNameDecision(rel) {
124
+ if (TEMPLATE.test(rel) || /-template\.md$/i.test(rel)) return { category: 'other', via: 'a template path' };
125
+ for (const [re, category] of NAME_RULES) {
126
+ if (re.test(rel)) return { category, via: 'the name or folder matches ' + String(re) };
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function headingsDecision(text) {
132
+ const h = headings(text);
133
+ const has = (s) => h.some((x) => x.includes(s));
134
+ if (has('requirements') && has('identity')) return { category: 'prd', via: 'headings: requirements and identity' };
135
+ if (has('dimensions') && has('sprint contract')) return { category: 'spec-artifact', via: 'headings: dimensions and sprint contract' };
136
+ if ((has('attendees') || has('agenda')) && (has('action items') || has('decisions') || has('notes') || has('discussion'))) {
137
+ return { category: 'meeting-notes', via: 'headings: attendees or agenda with notes, decisions or action items' };
138
+ }
139
+ if (has('acceptance criteria') && (has('steps to reproduce') || has('expected') || has('description') || has('actual'))) {
140
+ return { category: 'ticket', via: 'headings: acceptance criteria with a description or reproduction steps' };
141
+ }
142
+ if ((has('executive summary') || has('summary')) && (has('findings') || has('results') || has('recommendations') || has('metrics'))) {
143
+ return { category: 'report', via: 'headings: a summary with findings, results, recommendations or metrics' };
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Categorize one file. Pure: no file is read, no network is used, nothing is written.
150
+ * @param {object} a
151
+ * @param {string} a.rel repo-relative path with forward slashes
152
+ * @param {object} [a.fm] parsed frontmatter ({} when none)
153
+ * @param {string} [a.text] file text
154
+ * @param {Map} [a.named] path → [{role, id, lane}] from initiative records
155
+ * @param {object} [a.correction] the PM's stored correction {category, ticket_kind}; wins over every basis when valid
156
+ * @returns {{category:string, ticket_kind:string|null, basis:string|null, via:string, decided:boolean, disagrees:Array}}
157
+ */
158
+ function categorize({ rel, fm = {}, text = '', named = new Map(), correction = null }) {
159
+ const r = String(rel || '').replace(/\\/g, '/');
160
+ if (path.posix.extname(r).toLowerCase() !== '.md') {
161
+ return { category: UNDETERMINED, ticket_kind: null, basis: null, via: 'not a Markdown document', decided: false, disagrees: [] };
162
+ }
163
+ const found = []; // every basis that has something to say, in order
164
+ const push = (basis, via, d) => { if (d && d.category) found.push({ basis, via, category: d.category, kind: d.ticket_kind || null, }); };
165
+
166
+ const corr = validCorrection(correction);
167
+ if (corr) push('pm correction', 'a correction the PM set', corr);
168
+ // index.md and log.md are reserved by OKF: they are directory listings and histories, not documents of a kind.
169
+ if (RESERVED.test(r)) push('file name', 'a reserved index or log file', { category: 'other' });
170
+ else collect(r, fm, text, named, push);
171
+ return decide(found, r, fm, text);
172
+ }
173
+
174
+ function collect(r, fm, text, named, push) {
175
+ const authorCat = String(fm.category || '').trim().toLowerCase();
176
+ if (CATEGORIES.includes(authorCat)) {
177
+ const k = String(fm.ticket_kind || '').trim().toLowerCase();
178
+ push('author key', 'a category line the author wrote', { category: authorCat, ticket_kind: TICKET_KINDS.includes(k) ? k : null });
179
+ }
180
+ const t = typeDecision(fm.type);
181
+ if (t) push('frontmatter type', 'type: ' + String(fm.type).trim(), t);
182
+ const roles = named.get(r);
183
+ if (roles && roles.length) {
184
+ // In every lane but `feature` the prd_path holds that lane's brief (a spike brief, an intent), not a PRD.
185
+ const lane = roles[0].lane;
186
+ const c = roles[0].role === 'prd_path' && lane && lane !== 'feature' ? 'spec-artifact' : ROLE_CATEGORY[roles[0].role];
187
+ if (c) push('named by initiative', 'named by initiative ' + roles[0].id, { category: c });
188
+ }
189
+ const n = fileNameDecision(r);
190
+ if (n) push('file name', n.via, n);
191
+ const h = headingsDecision(text);
192
+ if (h) push('headings', h.via, h);
193
+ }
194
+
195
+ function decide(found, r, fm, text) {
196
+ if (!found.length) {
197
+ return { category: UNDETERMINED, ticket_kind: null, basis: null, via: 'no evidence', decided: false, disagrees: [] };
198
+ }
199
+ const win = found[0];
200
+ let kind = null;
201
+ if (win.category === 'ticket') kind = win.kind || ticketKindFrom(String(fm.title || '') + ' ' + r + ' ' + headings(text).join(' '));
202
+ return {
203
+ category: win.category, ticket_kind: kind, basis: win.basis, via: win.via, decided: true,
204
+ disagrees: found.slice(1).filter((f) => f.category !== win.category).map((f) => ({ basis: f.basis, category: f.category })),
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Build the `named` map from initiative records: path → [{role, id, lane}]. Pure; the caller supplies each record's path and
210
+ * parsed frontmatter, so no file is read here.
211
+ * @param {Array<{rel:string, fm:object}>} records
212
+ */
213
+ function namedFromRecords(records) {
214
+ const named = new Map();
215
+ for (const { rel, fm } of records || []) {
216
+ if (!/(^|\/)initiatives\//.test(String(rel || '').replace(/\\/g, '/')) || !fm) continue;
217
+ for (const role of Object.keys(ROLE_CATEGORY)) {
218
+ if (!fm[role]) continue;
219
+ const k = String(fm[role]).replace(/^\//, '');
220
+ if (!named.has(k)) named.set(k, []);
221
+ named.get(k).push({ role, id: fm.id || path.posix.basename(String(rel), '.md'), lane: fm.lane || null });
222
+ }
223
+ }
224
+ return named;
225
+ }
226
+
227
+ module.exports = { namedFromRecords, categorize, validCorrection, CATEGORIES, TICKET_KINDS, UNDETERMINED, BASES, TYPE_TO_CATEGORY, ROLE_CATEGORY };
@@ -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 };