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,217 @@
1
+ 'use strict';
2
+ /* structure.js: the proposed portfolio (slice 2). structure(scan, { brief }) -> { scan_id, boards, unboarded, unsorted_total, questions }.
3
+ * Board { key, name, domain, template: product|project|simple, initiatives: [Row], unsorted: [Doc] }
4
+ * Row { proposal_id, title, id, lane, anchor, anchor_source: record|proposed|none, stage: discover|plan|do|learn,
5
+ * confidence, checked, open, docs: [Doc] (at most 50), docs_total, evidence, merged: [proposal ids folded in] }
6
+ * Doc { file, title }
7
+ * Pure over the scan result (no model, no network, no write). The only read is of the files the scan already named, from scan.root
8
+ * (or opts.reader), to see a document's frontmatter and links; a file that cannot be read is still listed, just not placed by them.
9
+ * The scan lists a document only when it would gain frontmatter or when a finding cites it; that is the universe placed here.
10
+ * Everything is sorted, so the same scan gives the same structure. */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const S = require('./scan.js');
14
+ const briefLib = require('./brief.js');
15
+
16
+ const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
17
+ const CONF_RANK = { high: 0, medium: 1, low: 2 };
18
+ const MAX_DOCS = 50;
19
+ const MAX_QUESTIONS = 3;
20
+ const MAX_READ = 1024 * 1024;
21
+
22
+ const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
23
+ const slug = (s) => norm(s).replace(/ /g, '-');
24
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
25
+ const stem = (f) => path.posix.basename(f).replace(/\.[^.]+$/, '');
26
+ const mdFile = (f) => /\.(md|markdown)$/i.test(f);
27
+ const productOf = (file) => { const m = /^products\/([^/]+)\//.exec(file || ''); return m ? m[1] : null; };
28
+ const has = (hay, needle) => needle.length >= 4 && (' ' + hay + ' ').includes(' ' + needle + ' ');
29
+
30
+ function readText(scan, opts, rel) {
31
+ try {
32
+ if (opts.reader) return opts.reader(rel);
33
+ if (!scan.root) return null;
34
+ const f = path.join(scan.root, rel);
35
+ if (fs.statSync(f).size > MAX_READ) return null;
36
+ const t = fs.readFileSync(f, 'utf8');
37
+ return t.includes('\0') ? null : t;
38
+ } catch { return null; }
39
+ }
40
+
41
+ const SKIP_DIR = /^(\.git|node_modules)$/;
42
+ const MAX_WALK = 5000;
43
+ /** The markdown files under scan.root, honouring the brief's doNotRead. The scan lists a document only when it would gain frontmatter,
44
+ * so the full list comes from a read-only walk. Sorted; capped. */
45
+ function walkDocs(scan, brief) {
46
+ const out = []; if (!scan.root) return out;
47
+ const doNot = (brief && brief.doNotRead) || (scan.brief && scan.brief.doNotRead) || [];
48
+ (function rec(dir, rel) {
49
+ let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
50
+ ents.sort((a, b) => cmp(a.name, b.name));
51
+ for (const e of ents) {
52
+ if (out.length >= MAX_WALK) return;
53
+ const r = rel ? rel + '/' + e.name : e.name;
54
+ if (e.isDirectory()) { if (!SKIP_DIR.test(e.name) && !briefLib.matchesAny(doNot, r)) rec(path.join(dir, e.name), r); }
55
+ else if (e.isFile() && mdFile(e.name) && !/^(index|log)\.md$/i.test(e.name) && !/(^|\/)state\/(reports|live-notes)\//.test(r) && !briefLib.matchesAny(doNot, r)) out.push(r);
56
+ }
57
+ })(scan.root, '');
58
+ return out;
59
+ }
60
+
61
+ function structure(scan, opts = {}) {
62
+ const brief = opts.brief || null;
63
+ const cands = scan.candidates || [];
64
+ const recFiles = new Set(cands.filter((c) => c.kind === 'INITIATIVE' && c.detail && c.detail.record).map((c) => c.detail.record));
65
+
66
+ // — the documents that can be placed —
67
+ const docs = new Map();
68
+ const addDoc = (file, title, attachment) => { if (file && file !== '(brief)' && !recFiles.has(file) && !docs.has(file)) docs.set(file, { file, title: title || stem(file), attachment: !!attachment }); };
69
+ if (!opts.reader) for (const f of walkDocs(scan, brief)) addDoc(f);
70
+ for (const c of cands) if (c.kind === 'DOCUMENT') addDoc(c.evidence[0].file, c.title, c.detail && c.detail.attachment);
71
+ for (const c of cands) if (c.kind !== 'DOCUMENT') for (const e of c.evidence) if (mdFile(e.file)) addDoc(e.file);
72
+ const D = [...docs.values()].sort((a, b) => cmp(a.file, b.file));
73
+ let quarterMention = false;
74
+ for (const d of D) {
75
+ d.text = d.attachment ? null : readText(scan, opts, d.file);
76
+ d.info = d.text ? S.fmInfo(d.text) : null;
77
+ d.fm = (d.info && d.info.fm) || {};
78
+ const h = d.text ? S.docTitle(d.text) : null;
79
+ d.heading = h ? h.title : '';
80
+ if (d.text) d.title = d.fm.title || d.heading || d.title;
81
+ if (d.text && /\bQ[1-4]\b/.test(d.text)) quarterMention = true;
82
+ d.names = norm(stem(d.file)) ; d.headNorm = norm(d.heading || d.fm.title || '');
83
+ }
84
+ if (!quarterMention) quarterMention = cands.some((c) => c.evidence.some((e) => /\bQ[1-4]\b/.test(e.quote || '')));
85
+
86
+ // — rows: one per distinct initiative —
87
+ const raw = [];
88
+ for (const c of cands) {
89
+ if (c.kind !== 'INITIATIVE') continue;
90
+ const d = c.detail || {}; const f = (c.proposed && c.proposed.fields) || {};
91
+ const isRec = !!d.record && d.id; const isKnown = !!d.known;
92
+ const closed = isRec ? !d.open : false;
93
+ const id = isRec ? d.id : (f.id || (d.via === 'initiative_id' ? c.title : null));
94
+ const title = isRec ? c.title.replace(/ \(archived\)$/, '') : (d.name || c.title);
95
+ const lane = LANES.includes(d.lane) ? d.lane : LANES.includes(f.lane) ? f.lane : LANES.includes(d.lane_guess) ? d.lane_guess : 'feature';
96
+ raw.push({ c, id, title, lane, record: isRec ? d.record : (isKnown && d.record) || null, isRec, parent_kr: isRec ? d.parent_kr : (f.parent_kr || null), status: isRec ? d.status : null, closed, open: !closed });
97
+ }
98
+ raw.sort((a, b) => CONF_RANK[a.c.confidence] - CONF_RANK[b.c.confidence] || (b.isRec - a.isRec) || cmp(norm(a.title), norm(b.title)) || cmp(a.c.id, b.c.id));
99
+ const rows = [];
100
+ for (const r of raw) {
101
+ const nt = norm(r.title);
102
+ const hit = rows.find((x) => (r.id && x.id && x.id.toLowerCase() === r.id.toLowerCase()) || x._nt === nt || (x._ni && x._ni === nt) || (r.id && norm(r.id) === x._nt));
103
+ if (hit) { hit.merged.push(r.c.id); if (!hit.id && r.id) hit.id = r.id; if (!hit.parent_kr && r.parent_kr) hit.parent_kr = r.parent_kr; if (hit._record === null && r.record) hit._record = r.record; continue; }
104
+ const files = new Set(); const dirs = [];
105
+ if (!r.isRec) {
106
+ for (const e of r.c.evidence) if (e.file !== '(brief)') files.add(e.file);
107
+ if (r.c.detail && r.c.detail.via === 'folder' && r.c.evidence[0]) dirs.push(path.posix.dirname(r.c.evidence[0].file));
108
+ }
109
+ rows.push({ proposal_id: r.c.id, title: r.title, id: r.id, lane: r.lane, parent_kr: r.parent_kr, status: r.status, confidence: r.c.confidence, open: r.open, evidence: r.c.evidence, merged: [], _nt: nt, _ni: r.id ? norm(r.id) : '', _record: r.record, _files: files, _dirs: dirs, _docs: [] });
110
+ }
111
+ rows.sort((a, b) => cmp(a._nt, b._nt) || cmp(a.id || '', b.id || '') || cmp(a.proposal_id, b.proposal_id));
112
+
113
+ // — place documents, in order: (a) record links, (b) frontmatter names it, (c) its folder, (d) file name or heading —
114
+ const byId = new Map(); const byTitle = new Map();
115
+ for (const r of rows) { if (r.id && !byId.has(r.id.toLowerCase())) byId.set(r.id.toLowerCase(), r); if (!byTitle.has(r._nt)) byTitle.set(r._nt, r); }
116
+ const fileSet = new Set(D.map((d) => d.file));
117
+ const linked = new Map();
118
+ for (const r of rows) {
119
+ if (!r._record) continue;
120
+ const t = readText(scan, opts, r._record); if (!t) continue;
121
+ for (const p of S.recordRefs(t, r._record, fileSet)) if (!linked.has(p)) linked.set(p, r);
122
+ }
123
+ const placedBy = new Map();
124
+ for (const d of D) {
125
+ let row = linked.get(d.file) || null;
126
+ if (!row && d.text) {
127
+ const names = [d.fm.initiative, d.fm.initiative_id, (S.extractDeclaredInitiativeId(d.text) || {}).id].filter(Boolean);
128
+ for (const n of names) { row = byId.get(String(n).toLowerCase()) || byTitle.get(norm(n)) || null; if (row) break; }
129
+ }
130
+ if (!row) row = rows.find((r) => r._files.has(d.file) || r._dirs.some((x) => d.file.startsWith(x + '/'))) || null;
131
+ if (!row) {
132
+ const hay = d.names + ' ' + d.headNorm; let best = null; let bl = 0;
133
+ for (const r of rows) {
134
+ const l = Math.max(has(hay, r._ni) ? r._ni.length : 0, has(hay, r._nt) ? r._nt.length : 0);
135
+ if (l > bl) { best = r; bl = l; }
136
+ }
137
+ row = best;
138
+ }
139
+ if (row) { row._docs.push(d); placedBy.set(d.file, row); }
140
+ }
141
+
142
+ // — boards —
143
+ const prods = new Set();
144
+ for (const f of [...D.map((d) => d.file), ...cands.flatMap((c) => c.evidence.map((e) => e.file))]) { const p = productOf(f); if (p) prods.add(p); }
145
+ const rootDomain = scan.domain || slug(scan.tenant_name) || 'root';
146
+ const boards = new Map();
147
+ const board = (key) => {
148
+ if (!boards.has(key)) boards.set(key, key === 'root' ? { key, name: scan.tenant_name, domain: rootDomain, template: 'product', initiatives: [], unsorted: [] } : { key, name: key, domain: key, template: 'product', initiatives: [], unsorted: [] });
149
+ return boards.get(key);
150
+ };
151
+ if (!prods.size) board('root'); else for (const p of [...prods].sort()) board(p);
152
+ const unboarded = [];
153
+ const rowBoard = (r) => {
154
+ if (!prods.size) return 'root';
155
+ const own = productOf(r.evidence[0] && r.evidence[0].file) || productOf(r._record);
156
+ if (own) return own;
157
+ const votes = {}; for (const d of r._docs) { const p = productOf(d.file); if (p) votes[p] = (votes[p] || 0) + 1; }
158
+ const top = Object.keys(votes).sort((a, b) => votes[b] - votes[a] || cmp(a, b))[0];
159
+ return top || 'root';
160
+ };
161
+ const CLOSED_STAGE = (r) => !r.open;
162
+ const planDoc = (d) => /(^|\s)(prd|spec|specification|plan)(\s|$)/.test(d.names) || /\b(prd|spec|specification)\b/i.test(d.fm.type || '') || /(^|\s)(prd|spec|specification)(\s|$)/.test(d.headNorm);
163
+ const ACTIVE = /active|in.?progress|build|doing|started|wip|develop/i;
164
+ for (const r of rows) {
165
+ const bk = rowBoard(r); const b = board(bk); r._board = bk;
166
+ const stage = CLOSED_STAGE(r) ? 'learn' : r._docs.some(planDoc) ? 'plan' : ACTIVE.test(r.status || '') ? 'do' : 'discover';
167
+ const anchor = r.parent_kr ? { anchor: r.parent_kr, source: 'record' } : r.open ? { anchor: 'domain:' + b.domain, source: 'proposed' } : { anchor: null, source: 'none' };
168
+ const list = r._docs.map((d) => ({ file: d.file, title: d.title })).sort((a, b2) => cmp(a.file, b2.file));
169
+ b.initiatives.push({
170
+ proposal_id: r.proposal_id, title: r.title, id: r.id, lane: r.lane, anchor: anchor.anchor, anchor_source: anchor.source, stage,
171
+ confidence: r.confidence, checked: r.confidence === 'high' || r.confidence === 'medium', open: r.open,
172
+ docs: list.slice(0, MAX_DOCS), docs_total: list.length, evidence: r.evidence, merged: r.merged.slice().sort(),
173
+ });
174
+ }
175
+ let unsortedTotal = 0;
176
+ for (const d of D) {
177
+ if (placedBy.has(d.file)) continue;
178
+ unsortedTotal++;
179
+ const p = productOf(d.file); const dd = { file: d.file, title: d.title };
180
+ if (!prods.size) boards.get('root').unsorted.push(dd);
181
+ else if (p) boards.get(p).unsorted.push(dd);
182
+ else if (boards.has('root')) boards.get('root').unsorted.push(dd);
183
+ else unboarded.push(dd);
184
+ }
185
+
186
+ // — template guess —
187
+ const PROJ = /project|client|engagement|migration|rollout/i;
188
+ for (const b of boards.values()) {
189
+ const n = b.initiatives.length; const recs = b.initiatives.filter((r) => r.id && r.evidence.some((e) => recFiles.has(e.file))).length;
190
+ const projy = b.initiatives.filter((r) => PROJ.test(r.title)).length;
191
+ if (PROJ.test(b.key) || PROJ.test(b.name) || (n > 0 && projy * 2 > n)) b.template = 'project';
192
+ else if (!recs && n < 3) b.template = 'simple';
193
+ b.initiatives.sort((x, y) => cmp(norm(x.title), norm(y.title)) || cmp(x.id || '', y.id || '') || cmp(x.proposal_id, y.proposal_id));
194
+ b.unsorted.sort((x, y) => cmp(x.file, y.file));
195
+ }
196
+ const out = [...boards.values()].filter((b) => b.initiatives.length || b.unsorted.length || prods.size === 0 || boards.size === 1).sort((a, b) => cmp(a.key, b.key));
197
+ unboarded.sort((x, y) => cmp(x.file, y.file));
198
+
199
+ // — questions: only what the scan could not decide, at most three —
200
+ const questions = [];
201
+ const proposed = out.flatMap((b) => b.initiatives.filter((r) => r.open && r.anchor_source === 'proposed'));
202
+ if (proposed.length) {
203
+ const one = out.length === 1 ? out[0].domain : null;
204
+ const def = one ? 'domain:' + one : 'per-board';
205
+ questions.push({ id: 'anchor', text: `${proposed.length} open initiative${proposed.length === 1 ? ' has' : 's have'} no anchor yet. PMOS needs one for each, and never invents a KR.`, default: def, count: proposed.length,
206
+ options: [{ value: def, label: one ? `Use domain:${one} (recommended)` : 'Use each board\'s domain anchor (recommended)' }], free: 'Or name one KR or health budget for all of them' });
207
+ }
208
+ const fy = (brief && brief.vocabulary && brief.vocabulary.fiscalYearStartMonth) || (scan.brief && scan.brief.vocabulary && scan.brief.vocabulary.fiscalYearStartMonth);
209
+ if (quarterMention && !fy) {
210
+ questions.push({ id: 'fiscal', text: 'Your documents mention quarters (Q1 to Q4). Which month does your fiscal year start?', default: 'January', options: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] });
211
+ }
212
+ const odd = out.find((b) => b.template !== 'product');
213
+ if (odd) questions.push({ id: 'template', board: odd.key, text: `Board "${odd.name}" does not look like a product. Which template fits it?`, default: odd.template, options: ['product', 'project', 'simple'] });
214
+ return { scan_id: scan.scan_id, boards: out, unboarded, unsorted_total: unsortedTotal, questions: questions.slice(0, MAX_QUESTIONS) };
215
+ }
216
+
217
+ module.exports = { structure };
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+ /* plan-items-stage.js — the stage of each plan item, and the Build rule, for the workflow engine (pm-os-absorb Run 4,
3
+ * PRD R6, map-spec X29).
4
+ *
5
+ * foldStages(text) → { items: { [id]: { initiative, stage } }, withdrawn: [id…] }
6
+ * readFactory(text) → { items: { [workItemId]: { initiative, plan_item, integrated, withdrawn } } }
7
+ * mainTree(root) → the main working tree of root's repository (root itself when it is not linked)
8
+ * buildRule({ planText, workText, initiative })
9
+ * → { applies, materialized: [id…], integrated: [id…], pending: [id…] }
10
+ * buildRuleFor(root, id) → the same, read from the repository's two ledgers; { applies: false } when neither
11
+ * file is there
12
+ * planItemsPath(root) · workItemsPath(root)
13
+ *
14
+ * THE BUILD RULE (R6). Items never complete a stage, with one exception: a code initiative's Build stage is done when
15
+ * every MATERIALIZED item under it is integrated. A materialized item is a work item whose `declared` line names a
16
+ * plan item (`plan_item`, D86 point 1) that sits in the Build stage of the same initiative and is not withdrawn. A
17
+ * work item that is withdrawn in the factory ledger is not counted. When an initiative has no materialized item under
18
+ * Build, the rule does not apply and the engine decides the Build step from the commits, as before.
19
+ *
20
+ * WHY THIS FILE EXISTS (X29). The engine (scripts/workflow-state.js) is vendored into the workspace bundle and into
21
+ * the kit, so it cannot require workspace/lib/plan-items.js. This module reads the same ledger with a small pure fold
22
+ * of its own. workspace/plan-items-stage-parity.test.js holds it to plan-items.js's fold (stage, withdrawn) and to
23
+ * scripts/work-item.js's fold (integrated, withdrawn) on every fixture, so a rule added to one fold and not the
24
+ * other fails a test instead of making CI and the workspace disagree.
25
+ *
26
+ * DELIBERATELY FLAT in scripts/, not scripts/lib/ (read-jsonl.js has the same note): the kit and the workspace bundle
27
+ * vendor the engine's require closure by BASENAME into one directory, so a `./lib/` require from the engine would
28
+ * load in this checkout and break in every vendored copy.
29
+ *
30
+ * The factory half is D65's ORDER, restated small: lines sorted by instant (ledger order breaks a tie), the type's
31
+ * chain (scripts/work-item.js CHAIN, chainFor), and a two-outcome node satisfied by its LATEST outcome (failed revokes
32
+ * verified, released revokes claimed). A line that skips its predecessor is not counted, the same as deriveItems
33
+ * discards it, so a hand-appended `integrated` with no `verified` before it never makes the Build step done. It does
34
+ * not restate the per-field schema: scripts/check-work-items.js fails CI on a schema-invalid line. Restated, not
35
+ * required, because work-item.js cannot be vendored (its sink guard requires ./lib/sink.js); the parity test holds the
36
+ * two folds to one answer, on valid and on invalid ledgers.
37
+ *
38
+ * BOTH LEDGERS ARE READ FROM THE MAIN WORKING TREE, as lib/plan-items.js, lib/plan-ledgers.js and lib/factory-join.js
39
+ * read them. From a linked worktree (where every factory agent works) the engine, check-work-items and the workspace
40
+ * then read the same two files. mainTree is the filesystem-only walk of lib/plan-tree.js mainTree, restated here.
41
+ *
42
+ * Pure apart from the two file reads in buildRuleFor. No network, no git, no clock. Zero dependencies, stock Node.
43
+ */
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+
47
+ // ── the plan-items schema, as workspace/lib/plan-items.js lineErr states it ──────────────────────────────────────
48
+ // Every line that lib/plan-items.js ignores must be ignored here too, or the two folds disagree about which line of
49
+ // a repeated event id wins. So the whole stored-line schema is restated, not only the stage fields.
50
+ const EVENTS = ['planned', 'set', 'moved', 'flagged', 'withdrawn', 'kept'];
51
+ const STATES = ['todo', 'doing', 'review', 'done'];
52
+ const SET_FIELDS = ['title', 'stage', 'order', 'owner', 'after', 'start', 'due', 'milestone', 'note'];
53
+ const EVIDENCE_KINDS = ['commit', 'pr', 'file', 'session', 'note'];
54
+ const ID_RE = /^(.+)\.([0-9a-z]{10})$/;
55
+ const INIT_RE = /^[A-Za-z0-9][\w.-]{0,119}$/;
56
+ const EID_RE = /^[0-9a-z]{12}$/;
57
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
58
+ const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
59
+ const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
60
+
61
+ const isStr = (v) => typeof v === 'string';
62
+ const oneLine = (v, max) => isStr(v) && v.trim().length >= 1 && v.length <= max && !/[\r\n]/.test(v);
63
+ const initiativeOf = (id) => { const m = ID_RE.exec(String(id || '')); return m ? m[1] : null; };
64
+ function ownerOk(v) {
65
+ if (v === 'you' || v === 'agent') return true;
66
+ const m = /^(person|tracker):(.+)$/s.exec(String(v));
67
+ return !!m && m[2].length >= 1 && m[2].length <= 60 && !/[\r\n]/.test(m[2]);
68
+ }
69
+ const dateOk = (v) => v === null || (isStr(v) && DATE_RE.test(v) && (() => {
70
+ const t = Date.parse(v + 'T00:00:00Z');
71
+ return Number.isFinite(t) && new Date(t).toISOString().slice(0, 10) === v;
72
+ })());
73
+ function evidenceBad(e) {
74
+ if (!e || typeof e !== 'object' || Array.isArray(e) || !EVIDENCE_KINDS.includes(e.kind) || !isStr(e.ref)) return true;
75
+ const ref = e.ref;
76
+ if (e.kind === 'commit') return !/^[0-9a-f]{7,40}$/i.test(ref);
77
+ if (e.kind === 'pr') return !/^#?\d+$/.test(ref);
78
+ if (e.kind === 'file') return !ref || ref.length > 300 || path.isAbsolute(ref) || ref.split(/[\\/]/).includes('..') || /[\r\n]/.test(ref);
79
+ if (e.kind === 'session') return !/^sess-\d+$/.test(ref);
80
+ return !oneLine(ref, 200);
81
+ }
82
+ function fieldBad(k, v) {
83
+ switch (k) {
84
+ case 'title': return !oneLine(v, 200);
85
+ case 'stage': return !(isStr(v) && v.trim());
86
+ case 'order': return !(typeof v === 'number' && Number.isFinite(v));
87
+ case 'owner': return !ownerOk(v);
88
+ case 'after': return !(Array.isArray(v) && v.every((x) => isStr(x) && ID_RE.test(x)));
89
+ case 'start': case 'due': return !dateOk(v);
90
+ case 'milestone': return typeof v !== 'boolean';
91
+ case 'note': return !(v === null || (isStr(v) && v.length <= 1000));
92
+ default: return true;
93
+ }
94
+ }
95
+ /** true when lib/plan-items.js would ignore this stored line as schema-invalid. */
96
+ function lineBad(r) {
97
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return true;
98
+ if (r.v !== 1 || !isStr(r.eid) || !EID_RE.test(r.eid) || !EVENTS.includes(r.ev)) return true;
99
+ if (!isStr(r.by) || !BY_RE.test(r.by) || (r.via !== 'ui' && r.via !== 'cli')) return true;
100
+ if (!isStr(r.at) || !AT_RE.test(r.at) || Number.isNaN(Date.parse(r.at))) return true;
101
+ if (!isStr(r.initiative) || !INIT_RE.test(r.initiative)) return true;
102
+ if (r.ev !== 'kept' && (!isStr(r.item) || !ID_RE.test(r.item) || initiativeOf(r.item) !== r.initiative)) return true;
103
+ switch (r.ev) {
104
+ case 'planned': return SET_FIELDS.some((k) => fieldBad(k, r[k]));
105
+ case 'set': {
106
+ const f = r.fields;
107
+ if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.keys(f).length) return true;
108
+ return Object.keys(f).some((k) => !SET_FIELDS.includes(k)
109
+ || (!((k === 'start' || k === 'due' || k === 'note') && f[k] === null) && fieldBad(k, f[k])));
110
+ }
111
+ case 'moved':
112
+ if (!STATES.includes(r.to)) return true;
113
+ if (r.evidence !== undefined && evidenceBad(r.evidence)) return true;
114
+ if (r.override !== undefined && (r.override !== true || !String(r.by).startsWith('pm:'))) return true;
115
+ return false;
116
+ case 'flagged': return r.blocked === true ? !oneLine(r.reason, 200) : r.blocked !== false;
117
+ case 'withdrawn': return false;
118
+ case 'kept':
119
+ return r.item !== undefined || !isStr(r.batch) || !/^agent:/.test(r.batch)
120
+ || !Array.isArray(r.items) || !r.items.length || !r.items.every((x) => isStr(x) && ID_RE.test(x));
121
+ default: return true;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The plan-items ledger text → each live item's initiative and stage, and the withdrawn ids. The same order-free
127
+ * fold as lib/plan-items.js: drop byte-identical duplicates, sort by (at, eid, line), keep the first line of an event
128
+ * id, ignore a second `planned` of one id and every line after a withdraw.
129
+ */
130
+ function foldStages(text) {
131
+ const seenRaw = new Set();
132
+ const lines = [];
133
+ for (const raw0 of String(text == null ? '' : text).split('\n')) {
134
+ const raw = raw0.replace(/\r$/, '').trim();
135
+ if (!raw || seenRaw.has(raw)) continue;
136
+ let rec;
137
+ try { rec = JSON.parse(raw); } catch { continue; }
138
+ if (lineBad(rec)) continue;
139
+ seenRaw.add(raw);
140
+ lines.push({ raw, rec });
141
+ }
142
+ lines.sort((a, b) => (Date.parse(a.rec.at) - Date.parse(b.rec.at))
143
+ || (a.rec.eid < b.rec.eid ? -1 : a.rec.eid > b.rec.eid ? 1 : 0)
144
+ || (a.raw < b.raw ? -1 : a.raw > b.raw ? 1 : 0));
145
+ const seenEid = new Set();
146
+ const items = {};
147
+ const withdrawn = new Set();
148
+ for (const { rec } of lines) {
149
+ if (seenEid.has(rec.eid)) continue;
150
+ seenEid.add(rec.eid);
151
+ if (rec.ev === 'kept') continue;
152
+ if (rec.ev === 'planned') {
153
+ if (items[rec.item] || withdrawn.has(rec.item)) continue;
154
+ items[rec.item] = { initiative: rec.initiative, stage: rec.stage };
155
+ continue;
156
+ }
157
+ const it = items[rec.item];
158
+ if (!it || withdrawn.has(rec.item)) continue;
159
+ if (rec.ev === 'set' && Object.prototype.hasOwnProperty.call(rec.fields, 'stage')) it.stage = rec.fields.stage;
160
+ if (rec.ev === 'withdrawn') { delete items[rec.item]; withdrawn.add(rec.item); }
161
+ }
162
+ return { items, withdrawn: [...withdrawn].sort() };
163
+ }
164
+
165
+ /* D65(d)'s chain per type (scripts/work-item.js CHAIN, chainFor, NEGATED_BY, standsSatisfied), restated. */
166
+ const FACTORY_EVENTS = ['declared', 'claimed', 'released', 'verified', 'failed', 'integrated', 'shipped', 'withdrawn'];
167
+ const CHAIN_DEFAULT = ['claimed', 'verified', 'integrated', 'shipped'];
168
+ const CHAIN_HOTFIX = ['claimed', 'verified', 'shipped', 'integrated'];
169
+ const chainFor = (type) => (type === 'spike-item' ? CHAIN_DEFAULT.filter((e) => e !== 'verified') : type === 'hotfix' ? CHAIN_HOTFIX : CHAIN_DEFAULT);
170
+ const NEGATED_BY = { verified: 'failed', claimed: 'released' };
171
+ function standsSatisfied(history, need) {
172
+ const negator = NEGATED_BY[need];
173
+ if (!negator) return history.includes(need);
174
+ for (let i = history.length - 1; i >= 0; i -= 1) {
175
+ if (history[i] === need) return true;
176
+ if (history[i] === negator) return false;
177
+ }
178
+ return false;
179
+ }
180
+
181
+ /** The factory ledger text → per work item: its initiative, its plan item, and whether it is integrated or withdrawn. */
182
+ function readFactory(text) {
183
+ const lines = [];
184
+ String(text == null ? '' : text).split('\n').forEach((raw, idx) => {
185
+ if (!raw.trim()) return;
186
+ let rec;
187
+ try { rec = JSON.parse(raw); } catch { return; }
188
+ if (!rec || typeof rec !== 'object' || !isStr(rec.item_id) || !isStr(rec.initiative_id) || !FACTORY_EVENTS.includes(rec.event)) return;
189
+ const t = typeof rec.at === 'string' ? Date.parse(rec.at) : NaN;
190
+ if (Number.isNaN(t)) return;
191
+ lines.push({ rec, idx, t });
192
+ });
193
+ lines.sort((a, b) => a.t - b.t || a.idx - b.idx);
194
+ const items = {}; const fold = {};
195
+ for (const { rec } of lines) {
196
+ const id = rec.item_id;
197
+ if (rec.event === 'declared') {
198
+ if (items[id]) continue; // declared twice: the fold keeps the first
199
+ items[id] = { initiative: rec.initiative_id, plan_item: isStr(rec.plan_item) ? rec.plan_item : null, integrated: false, withdrawn: false };
200
+ fold[id] = { type: rec.type, history: ['declared'] };
201
+ continue;
202
+ }
203
+ const it = items[id];
204
+ if (!it || it.withdrawn) continue;
205
+ const chain = chainFor(fold[id].type);
206
+ const pos = chain.indexOf(rec.event === 'failed' ? 'verified' : rec.event);
207
+ if (pos > 0 && !standsSatisfied(fold[id].history, chain[pos - 1])) continue; // out of order: deriveItems discards it
208
+ fold[id].history.push(rec.event);
209
+ if (rec.event === 'integrated') it.integrated = true;
210
+ if (rec.event === 'withdrawn') it.withdrawn = true;
211
+ }
212
+ return { items };
213
+ }
214
+
215
+ /** The Build rule over the two ledgers' text, for one initiative. */
216
+ function buildRule({ planText, workText, initiative }) {
217
+ const plan = foldStages(planText);
218
+ const work = readFactory(workText);
219
+ const materialized = []; const integrated = []; const pending = [];
220
+ for (const [wid, w] of Object.entries(work.items)) {
221
+ if (w.initiative !== initiative || w.withdrawn || !w.plan_item) continue;
222
+ const p = plan.items[w.plan_item];
223
+ if (!p || p.initiative !== initiative || p.stage !== 'build') continue;
224
+ materialized.push(wid);
225
+ (w.integrated ? integrated : pending).push(wid);
226
+ }
227
+ const sort = (a) => a.sort();
228
+ return { applies: materialized.length > 0, materialized: sort(materialized), integrated: sort(integrated), pending: sort(pending) };
229
+ }
230
+
231
+ /** The main working tree of root's repository: root itself, unless root is a linked worktree. Filesystem only. */
232
+ function mainTree(root) {
233
+ try {
234
+ const dotgit = path.join(root, '.git');
235
+ if (fs.statSync(dotgit).isDirectory()) return root;
236
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotgit, 'utf8'));
237
+ if (!m) return root;
238
+ const gitdir = path.resolve(root, m[1].trim());
239
+ const common = path.resolve(gitdir, fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim());
240
+ if (path.basename(common) !== '.git') return root;
241
+ const main = path.dirname(common);
242
+ return fs.statSync(path.join(main, '.git')).isDirectory() ? main : root;
243
+ } catch { return root; }
244
+ }
245
+
246
+ /** The plan-items ledger of a repository: planning/runs/ for PMOS-self, pmos/state/ in a kit (workflow-state layout). */
247
+ function planItemsPath(root) {
248
+ const main = mainTree(path.resolve(String(root)));
249
+ const self = path.join(main, 'planning', 'initiatives');
250
+ const kit = path.join(main, 'pmos', 'state', 'initiatives');
251
+ const isKit = !fs.existsSync(self) && fs.existsSync(kit);
252
+ return isKit ? path.join(main, 'pmos', 'state', 'plan-items.jsonl') : path.join(main, 'planning', 'runs', 'plan-items.jsonl');
253
+ }
254
+ /** The D65 factory ledger of a repository (the one scripts/check-work-items.js reads), in the main working tree. */
255
+ const workItemsPath = (root) => path.join(mainTree(path.resolve(String(root))), 'planning', 'work-items.jsonl');
256
+
257
+ function readText(file) {
258
+ try { return { ok: true, text: fs.readFileSync(file, 'utf8') }; }
259
+ catch (e) { return { ok: e && e.code === 'ENOENT', text: '', missing: true, error: e && e.code !== 'ENOENT' ? String(e.message || e) : null }; }
260
+ }
261
+
262
+ function buildRuleFor(root, initiative) {
263
+ const work = readText(workItemsPath(root));
264
+ if (work.missing && work.ok) return { applies: false, materialized: [], integrated: [], pending: [] };
265
+ if (!work.ok) return { applies: false, unknown: true, reason: `the factory ledger could not be read: ${work.error}`, materialized: [], integrated: [], pending: [] };
266
+ const plan = readText(planItemsPath(root));
267
+ if (!plan.ok) return { applies: false, unknown: true, reason: `the plan-items ledger could not be read: ${plan.error}`, materialized: [], integrated: [], pending: [] };
268
+ return buildRule({ planText: plan.text, workText: work.text, initiative });
269
+ }
270
+
271
+ module.exports = { foldStages, readFactory, buildRule, buildRuleFor, mainTree, planItemsPath, workItemsPath, lineBad };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ /*
3
+ * scripts/read-jsonl.js — THE JSONL reader for the scripts/ checkers and the workflow engine.
4
+ *
5
+ * Six near-identical readers grew here, with three different torn-line contracts, and the repo's
6
+ * own comments record the cost twice: a silently-dropped torn line re-opened a verified entry
7
+ * (check-verify-queue.js) and made an accepted initiative read as never accepted (workflow-state.js).
8
+ * Each new checker re-decided the torn-line question from scratch and got it wrong at least twice.
9
+ * One reader, one contract — the strictest one found:
10
+ *
11
+ * - a torn line is DROPPED, never guessed at — a truncated row parsed "as far as it goes" would
12
+ * fabricate a record nobody wrote;
13
+ * - and it is REPORTED, never swallowed: every defect comes back with its file, 1-based line,
14
+ * parse reason and a bounded slice of the text, so the caller can say out loud what it could
15
+ * not read. "Assuming someone else counts is how nobody counts."
16
+ * - a MISSING (or unreadable) file is its own state, distinguishable from an empty one — the
17
+ * cadence checker needs the difference and a gate must not confuse "no ledger" with "no rows".
18
+ *
19
+ * Returns { rows, defects, torn, missing }: rows are the parsed objects in file order; defects is
20
+ * [{ file, line, reason, text }]; torn === defects.length, kept as a count because most callers
21
+ * only need the number; missing is true when the file could not be read at all (rows/defects empty).
22
+ *
23
+ * DELIBERATELY FLAT in scripts/, not scripts/lib/: the kit and the workspace bundle both vendor the
24
+ * engine's require closure by BASENAME into a flat directory (kit/build-kit.sh, scripts/
25
+ * vendor-workspace-engine.js), so a `./lib/` require would resolve in the checkout and break in
26
+ * every vendored copy — the exact incident class the scripts/lib/sink.js re-export note records.
27
+ * workspace/lib/file-mode-parse.js does NOT delegate here for the mirrored reason: the workspace
28
+ * bundle ships alone, cannot require across the bundle boundary, and its silent-drop reader is the
29
+ * locked mirror of kit/scripts/metrics.py (kit/fixtures/test_parsers.py holds that agreement).
30
+ *
31
+ * Zero dependencies, stock Node.
32
+ */
33
+ const fs = require('fs');
34
+
35
+ function readJsonl(file) {
36
+ let text;
37
+ try { text = fs.readFileSync(file, 'utf8'); }
38
+ catch { return { rows: [], defects: [], torn: 0, missing: true }; }
39
+ const rows = [];
40
+ const defects = [];
41
+ const lines = text.split('\n');
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const t = lines[i].trim();
44
+ if (!t) continue;
45
+ try { rows.push(JSON.parse(t)); }
46
+ catch (e) {
47
+ defects.push({ file, line: i + 1, reason: String((e && e.message) || 'not valid JSON').slice(0, 120), text: t.slice(0, 120) });
48
+ }
49
+ }
50
+ return { rows, defects, torn: defects.length, missing: false };
51
+ }
52
+
53
+ module.exports = { readJsonl };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+ // layout-paths.js: where a repo keeps the files the console reads, for BOTH layouts (the PMOS repo itself and a kit tenant).
3
+ // The workflow engine already decides which layout a repo has (scripts/workflow-state.js layout()): the PMOS repo keeps its
4
+ // ledgers under planning/runs, a kit tenant keeps them under pmos/state. Readers must ask here and never hard-code planning/.
5
+ const path = require('path');
6
+
7
+ function pathsFor(root) {
8
+ const base = path.resolve(root || process.cwd());
9
+ const L = require('../workflow-state.js').layout(base);
10
+ const kit = !!L.kit;
11
+ return {
12
+ root: base,
13
+ kit,
14
+ initiativesDir: L.initiatives,
15
+ ledgerDir: L.runsDir,
16
+ // The factory's work-item ledger exists only in the PMOS repo; a tenant's work items are its plan items.
17
+ workItemsFile: kit ? path.join(L.runsDir, 'work-items.jsonl') : path.join(base, 'planning', 'work-items.jsonl'),
18
+ planItemsFile: path.join(L.runsDir, 'plan-items.jsonl'),
19
+ recordsDir: kit ? path.join(L.runsDir, 'records') : path.join(base, 'planning', 'records'),
20
+ };
21
+ }
22
+
23
+ // A folded plan item (workspace/lib/plan-items.js fold) as the console's work-item fact. Proposed items are not the PM's yet.
24
+ function planItemToFact(it) {
25
+ if (!it || it.proposed) return null;
26
+ const st = String(it.state || 'todo');
27
+ return {
28
+ key: it.id, initiative: it.initiative || null, title: it.title || it.id,
29
+ status: st === 'done' ? 'done' : (st === 'doing' || st === 'review') ? 'doing' : 'todo',
30
+ owner: it.owner || null, start: it.start || null, due: it.due || null, after: Array.isArray(it.after) ? it.after : [],
31
+ source: 'plan-items',
32
+ };
33
+ }
34
+
35
+ function planItemFacts(text) {
36
+ const P = require('../../workspace/lib/plan-items.js');
37
+ const parsed = P.parseText(text);
38
+ const folded = P.fold(parsed.lines || parsed);
39
+ const list = Array.isArray(folded.items) ? folded.items : Object.values(folded.items || {});
40
+ return list.map(planItemToFact).filter(Boolean);
41
+ }
42
+
43
+ module.exports = { pathsFor, planItemToFact, planItemFacts };