create-pmos 0.131.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/data-freshness.md +40 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/calendar-cli.js +84 -0
- package/workspace/lib/facts-async.js +144 -35
- package/workspace/lib/freshness.js +167 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/offloop.js +94 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +2 -0
- package/workspace/lib/routes-board.js +3 -1
- package/workspace/lib/routes-my-work.js +27 -9
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/routes-sources.js +64 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-shell.js +4 -1
- package/workspace/public/pm/components.css +313 -0
- package/workspace/public/pm/dest-settings.js +10 -1
- package/workspace/public/pm/freshness-line.js +123 -0
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +41 -4
- package/workspace/skills/calendar-sync.skill +30 -0
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
- package/workspace/test-lists/workspace-data-freshness.json +6 -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 };
|