create-pmos 0.38.4 → 0.38.5
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/scripts/lane-defs.js +226 -0
- package/payload/scripts/workflow-state.js +108 -24
- package/payload/scripts/workflow-sweep.js +155 -3
- package/payload/state/discoveries.md +1 -1
- package/workspace/lib/audit.js +54 -0
- package/workspace/lib/engine/lane-defs.js +226 -0
- package/workspace/lib/engine/workflow-state.js +108 -24
- package/workspace/lib/engine/workflow-sweep.js +934 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +67 -24
- package/workspace/public/pm/panel-detail.js +12 -2
- package/workspace/public/pm/panel-repos.js +36 -5
- package/workspace/public/pm/tab-task.js +4 -1
- package/workspace/server.js +22 -1
|
@@ -76,6 +76,11 @@ const CLASSES = {
|
|
|
76
76
|
'missing-record': { severity: DEFECT, repairable: true, why: 'work merged with no record is invisible to every board and to the gate' },
|
|
77
77
|
'unresolvable-anchor': { severity: DEFECT, repairable: false, why: 'the anchor names something the OKR documents do not declare (D59)' },
|
|
78
78
|
'unverifiable-delivery': { severity: DEFECT, repairable: false, why: 'delivered_by names a commit the trunk cannot reach, or one that changes no build path (R-2b)' },
|
|
79
|
+
'unpointed-artifact': { severity: DEFECT, repairable: true, why: 'a document names this initiative and no path key in the record points at it, so the ladder reads the step as open over finished work (D77)' },
|
|
80
|
+
'lane-outside-profile': { severity: DEFECT, repairable: false, why: 'the record names a lane its repository profile does not offer, so the engine applies a different lane than the one written (D68, D77)' },
|
|
81
|
+
'acceptance-not-counted': { severity: DEFECT, repairable: false, why: 'an acceptances.jsonl row names an initiative and the ladder does not count it, so the ladder shows the gate open over a row that looks like an approval (D12)' },
|
|
82
|
+
'done-without-basis': { severity: DEFECT, repairable: false, why: 'a step reads done and names no file, ledger or commit history, so the pane has nothing to open (D77)' },
|
|
83
|
+
'status-claims-ahead': { severity: OWED, repairable: false, why: 'the record\'s status says accepted or complete while the ladder shows the acceptance step open' },
|
|
79
84
|
'delivered-unlogged': { severity: OWED, repairable: false, why: 'the trunk attributes work to this initiative and no run was ever logged (AGENTS.md step 7)' },
|
|
80
85
|
'delivered-unreviewed': { severity: OWED, repairable: false, why: 'delivered against a rubric with no Review-Gate verdict recorded — a skipped gate is recorded as skipped, never left absent' },
|
|
81
86
|
'delivered-unaccepted': { severity: OWED, repairable: false, why: 'delivered with no acceptance row — the D12 gate is open' },
|
|
@@ -169,6 +174,43 @@ function candidatesFor(root, L, key, id) {
|
|
|
169
174
|
return [...new Set(out)];
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
/*
|
|
178
|
+
* D77 — documents that name an initiative. The engine reads only the paths a record points at and
|
|
179
|
+
* never searches, so a finished PRD that no record points at leaves its step open. This is the search
|
|
180
|
+
* the engine does not do. It reads Markdown under the conventional document folders, skips the
|
|
181
|
+
* folders that hold control records, ledgers, rubrics and explainers, and matches on the file's
|
|
182
|
+
* `initiative_id` (frontmatter or a leading bullet) or on a name that starts with the id.
|
|
183
|
+
*/
|
|
184
|
+
const DOC_ROOTS = ['planning', 'docs', 'doc', 'prd', 'prds', 'briefs', 'specs', 'strategy', 'plans', 'reports', 'decisions', 'requirements', 'product'];
|
|
185
|
+
const DOC_SKIP = new Set(['node_modules', '.git', 'initiatives', 'runs', 'state', 'explainers', 'evals', 'okf']);
|
|
186
|
+
function buildDocIndex(root) {
|
|
187
|
+
const out = [];
|
|
188
|
+
const walk = (dirRel, depth) => {
|
|
189
|
+
if (depth > 4 || out.length > 5000) return;
|
|
190
|
+
let ents;
|
|
191
|
+
try { ents = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true }); } catch { return; }
|
|
192
|
+
for (const e of ents) {
|
|
193
|
+
const rel = dirRel ? `${dirRel}/${e.name}` : e.name;
|
|
194
|
+
if (e.isDirectory()) { if (!DOC_SKIP.has(e.name)) walk(rel, depth + 1); continue; }
|
|
195
|
+
if (!/\.md$/i.test(e.name) || /^(index|log)\.md$/i.test(e.name)) continue;
|
|
196
|
+
let head = '';
|
|
197
|
+
try {
|
|
198
|
+
const fd = fs.openSync(path.join(root, rel), 'r');
|
|
199
|
+
const buf = Buffer.alloc(3000); const n = fs.readSync(fd, buf, 0, 3000, 0); fs.closeSync(fd);
|
|
200
|
+
head = buf.slice(0, n).toString('utf8');
|
|
201
|
+
} catch { continue; }
|
|
202
|
+
const m = head.match(/^[-\s]*initiative_id:\s*["']?([\w.-]+)/m);
|
|
203
|
+
out.push({ rel, base: e.name, iid: m ? m[1] : null });
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
for (const d of DOC_ROOTS) walk(d, 0);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
function documentsNaming(index, id) {
|
|
210
|
+
return index.filter((f) => f.iid === id || f.base === `${id}.md` || f.base.startsWith(`${id}-`)).map((f) => f.rel);
|
|
211
|
+
}
|
|
212
|
+
const isBriefName = (rel) => /brief/i.test(path.basename(rel));
|
|
213
|
+
|
|
172
214
|
// ── the sweep ──────────────────────────────────────────────────────────────────────────────────
|
|
173
215
|
|
|
174
216
|
function sweep(opts) {
|
|
@@ -197,6 +239,11 @@ function sweep(opts) {
|
|
|
197
239
|
};
|
|
198
240
|
}
|
|
199
241
|
const idset = new Set(ids);
|
|
242
|
+
const profileInfo = engine.lanesFor(root);
|
|
243
|
+
const pmProfile = profileInfo.profile === 'pm-artifacts';
|
|
244
|
+
let docIndex = null;
|
|
245
|
+
let wfRange = null;
|
|
246
|
+
try { wfRange = engine.historyRange(root); } catch { wfRange = null; }
|
|
200
247
|
|
|
201
248
|
// ── the ledgers, read once ────────────────────────────────────────────────────────────────────
|
|
202
249
|
const ledgerFiles = {
|
|
@@ -260,9 +307,23 @@ function sweep(opts) {
|
|
|
260
307
|
}
|
|
261
308
|
const evalIds = new Set(ledgers.evals.rows.map(({ row }) => row && row.initiative_id).filter(Boolean));
|
|
262
309
|
const acceptedIds = new Set(ledgers.acceptances.rows
|
|
263
|
-
.filter(({ row }) => row &&
|
|
310
|
+
.filter(({ row }) => row && row.accepted === true)
|
|
264
311
|
.map(({ row }) => row.initiative_id).filter(Boolean));
|
|
265
312
|
|
|
313
|
+
// D77 — a row that looks like an approval and that the ladder does not count
|
|
314
|
+
for (const { row, line } of ledgers.acceptances.rows) {
|
|
315
|
+
if (!row || !row.initiative_id || !idset.has(row.initiative_id)) continue;
|
|
316
|
+
if (only && row.initiative_id !== only) continue;
|
|
317
|
+
if (row.accepted === true) continue;
|
|
318
|
+
if (row.action !== 'approve') continue;
|
|
319
|
+
findings.push(finding('acceptance-not-counted', {
|
|
320
|
+
id: row.initiative_id, file: path.relative(root, ledgerFiles.acceptances), line,
|
|
321
|
+
what: `an acceptances.jsonl row has action 'approve' and accepted '${row.accepted}' — the ladder counts only accepted: true`,
|
|
322
|
+
evidence: JSON.stringify(row).slice(0, 140),
|
|
323
|
+
hand: 'the sweep never writes a ledger: if this was an acceptance, the PM records it with scripts/record-acceptance.js; if it was something else, no action',
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
266
327
|
// ── delivery, read once ───────────────────────────────────────────────────────────────────────
|
|
267
328
|
const del = delivery.deliveryIndex(root, ids);
|
|
268
329
|
if (!del.ok) {
|
|
@@ -313,11 +374,18 @@ function sweep(opts) {
|
|
|
313
374
|
const lane = (fm.lane || 'feature').trim() || 'feature';
|
|
314
375
|
|
|
315
376
|
// dangling declared paths
|
|
316
|
-
for (const key of
|
|
377
|
+
for (const key of pmProfile
|
|
378
|
+
? ['prd_path', 'brief_path', 'doc_path', 'rubric_path', 'explainer_path']
|
|
379
|
+
: ['prd_path', 'rubric_path', 'explainer_path']) {
|
|
317
380
|
const v = String(fm[key] || '').trim();
|
|
318
381
|
if (!v) continue;
|
|
319
382
|
if (fs.existsSync(path.join(root, v))) continue;
|
|
320
|
-
|
|
383
|
+
let cands;
|
|
384
|
+
if (key === 'brief_path' || key === 'doc_path') {
|
|
385
|
+
if (!docIndex) docIndex = buildDocIndex(root);
|
|
386
|
+
const named = documentsNaming(docIndex, id);
|
|
387
|
+
cands = named.filter((f) => (key === 'brief_path') === isBriefName(f));
|
|
388
|
+
} else cands = candidatesFor(root, L, key, id);
|
|
321
389
|
findings.push(finding('dangling-path', {
|
|
322
390
|
id, file: recRel,
|
|
323
391
|
what: `${key} points at '${v}', which is not in the repository`,
|
|
@@ -332,6 +400,72 @@ function sweep(opts) {
|
|
|
332
400
|
}));
|
|
333
401
|
}
|
|
334
402
|
|
|
403
|
+
// D77 — a lane the profile does not offer
|
|
404
|
+
{
|
|
405
|
+
const declared = String(fm.lane || '').trim();
|
|
406
|
+
if (declared && !profileInfo.lanes.includes(declared)) {
|
|
407
|
+
const applied = pmProfile ? 'prd' : 'feature';
|
|
408
|
+
findings.push(finding('lane-outside-profile', {
|
|
409
|
+
id, file: recRel,
|
|
410
|
+
what: `lane '${declared}' is not a ${profileInfo.profile} lane`,
|
|
411
|
+
evidence: `the engine applies '${applied}' instead; this repository offers: ${profileInfo.lanes.join(', ')}`,
|
|
412
|
+
hand: `choose one of: ${profileInfo.lanes.join(', ')} — the lane is the PM's choice, so it is reported and never rewritten`,
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// D77 — a finished document that no record points at (document profile)
|
|
418
|
+
if (pmProfile) {
|
|
419
|
+
if (!docIndex) docIndex = buildDocIndex(root);
|
|
420
|
+
const named = documentsNaming(docIndex, id);
|
|
421
|
+
const declaredKeys = ['brief_path', 'prd_path', 'doc_path'].map((k) => String(fm[k] || '').trim()).filter(Boolean);
|
|
422
|
+
const unref = named.filter((f) => !declaredKeys.includes(f));
|
|
423
|
+
const briefEmpty = !String(fm.brief_path || fm.prd_path || '').trim();
|
|
424
|
+
const docEmpty = !String(fm.doc_path || '').trim();
|
|
425
|
+
if (unref.length && (briefEmpty || docEmpty)) {
|
|
426
|
+
let repair = null;
|
|
427
|
+
let hand = null;
|
|
428
|
+
if (unref.length === 1) {
|
|
429
|
+
const key = isBriefName(unref[0]) ? (briefEmpty ? 'brief_path' : null) : (docEmpty ? 'doc_path' : null);
|
|
430
|
+
if (key) repair = { kind: 'add-key', file: recRel, key, from: '', to: unref[0] };
|
|
431
|
+
else hand = `${unref[0]} names this initiative, but the key it would fill is already set — decide by hand`;
|
|
432
|
+
} else hand = `${unref.length} documents name this initiative (${unref.join(', ')}) — choosing which is the brief and which the deliverable is a judgement, so it is reported rather than guessed`;
|
|
433
|
+
findings.push(finding('unpointed-artifact', {
|
|
434
|
+
id, file: recRel,
|
|
435
|
+
what: `${unref.length} document${unref.length > 1 ? 's name' : ' names'} this initiative and no path key points at ${unref.length > 1 ? 'them' : 'it'}: ${unref.join(', ')}`,
|
|
436
|
+
evidence: `brief_path/prd_path ${briefEmpty ? 'is empty' : 'is set'}; doc_path ${docEmpty ? 'is empty' : 'is set'}`,
|
|
437
|
+
repair, hand,
|
|
438
|
+
}));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// D77 — what the ladder says against the record's own words, and against its own basis rule
|
|
443
|
+
if (wfRange) {
|
|
444
|
+
let wf = null;
|
|
445
|
+
try { wf = engine.computeWorkflow(id, { root, range: wfRange }); } catch { wf = null; }
|
|
446
|
+
if (wf && wf.ok && Array.isArray(wf.steps)) {
|
|
447
|
+
for (const st of wf.steps) {
|
|
448
|
+
if (st.status === 'done' && st.basis && st.basis.source === 'none' && st.key !== 'quality') {
|
|
449
|
+
findings.push(finding('done-without-basis', {
|
|
450
|
+
id, file: recRel,
|
|
451
|
+
what: `step '${st.key}' reads done and names no file, ledger or commit history`,
|
|
452
|
+
evidence: String(st.evidence || '').slice(0, 120),
|
|
453
|
+
hand: `the engine decided '${st.key}' from something the detail pane cannot open — fix the rung to name what it read`,
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const claim = String(fm.status || '').trim();
|
|
458
|
+
const gate = wf.steps.find((st) => st.key === 'accept' || st.key === 'approved');
|
|
459
|
+
if (/^(accepted|approved|complete|completed|done|delivered|shipped)\b/i.test(claim) && gate && gate.status !== 'done' && gate.status !== 'na') {
|
|
460
|
+
findings.push(finding('status-claims-ahead', {
|
|
461
|
+
id, file: recRel,
|
|
462
|
+
what: `status says '${claim.slice(0, 60)}' while the ${gate.key} step is ${gate.status}`,
|
|
463
|
+
evidence: String(gate.evidence || '').slice(0, 120),
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
335
469
|
// damaged frontmatter values
|
|
336
470
|
for (const [key, v] of Object.entries(fm)) {
|
|
337
471
|
const dmg = damagedValue(v);
|
|
@@ -553,6 +687,15 @@ function applyRepairs(res, opts) {
|
|
|
553
687
|
for (const f of group) {
|
|
554
688
|
const r = f.repair;
|
|
555
689
|
const before = frontmatterValue(next, r.key);
|
|
690
|
+
if (r.kind === 'add-key') {
|
|
691
|
+
// One key, only when it is still absent or empty. A value the PM wrote since the sweep ran wins.
|
|
692
|
+
if (before !== null && before.trim() !== '') { skipped.push({ finding: f, why: `${r.key} was set since the sweep ran` }); continue; }
|
|
693
|
+
const edited = before === null ? insertFrontmatterKey(next, r.key, r.to) : setFrontmatterValue(next, r.key, r.to);
|
|
694
|
+
if (edited === next) { skipped.push({ finding: f, why: `${key} has no frontmatter to add ${r.key} to` }); continue; }
|
|
695
|
+
next = edited;
|
|
696
|
+
applied.push({ class: f.class, file: key, key: r.key, before: '(not set)', after: r.to.slice(0, 90) });
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
556
699
|
if (before === null) { skipped.push({ finding: f, why: `${r.key} is no longer in the frontmatter` }); continue; }
|
|
557
700
|
let after;
|
|
558
701
|
if (r.kind === 'repoint') {
|
|
@@ -580,6 +723,15 @@ function frontmatterValue(text, key) {
|
|
|
580
723
|
}
|
|
581
724
|
return null;
|
|
582
725
|
}
|
|
726
|
+
/** Insert `key: value` as the last line of the leading frontmatter; the text is returned unchanged when there is none. */
|
|
727
|
+
function insertFrontmatterKey(text, key, value) {
|
|
728
|
+
const lines = String(text).split('\n');
|
|
729
|
+
if (lines[0] !== '---') return text;
|
|
730
|
+
for (let i = 1; i < lines.length; i++) {
|
|
731
|
+
if (lines[i] === '---') { lines.splice(i, 0, `${key}: ${value}`); return lines.join('\n'); }
|
|
732
|
+
}
|
|
733
|
+
return text;
|
|
734
|
+
}
|
|
583
735
|
function setFrontmatterValue(text, key, value) {
|
|
584
736
|
const lines = String(text).split('\n');
|
|
585
737
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-02T23:30:04Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* audit.js — the workspace's door to the workflow sweep (D77).
|
|
3
|
+
*
|
|
4
|
+
* The sweep is ONE file, scripts/workflow-sweep.js, shared with the CLI an agent runs. It is loaded the
|
|
5
|
+
* way the workflow engine is: from the checkout two directories up, or from the copy the packaging step
|
|
6
|
+
* vendors into lib/engine/. This door only READS: it runs the sweep and reports. Repairs are made by
|
|
7
|
+
* `node scripts/workflow-sweep.js --fix`, on the PM's word, because they write the repository.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const PATHS = [
|
|
13
|
+
path.join(__dirname, '..', '..', 'scripts', 'workflow-sweep.js'),
|
|
14
|
+
path.join(__dirname, 'engine', 'workflow-sweep.js'),
|
|
15
|
+
];
|
|
16
|
+
function core() {
|
|
17
|
+
for (const p of PATHS) {
|
|
18
|
+
if (!fs.existsSync(p)) continue;
|
|
19
|
+
try { return { ok: true, core: require(p) }; }
|
|
20
|
+
catch (e) { return { ok: false, error: `the workflow sweep at ${p} failed to load — ${String((e && e.message) || e).slice(0, 160)}` }; }
|
|
21
|
+
}
|
|
22
|
+
return { ok: false, error: 'the workflow sweep is not present in this install' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFECT_CAP = 40;
|
|
26
|
+
const OWED_CAP = 12;
|
|
27
|
+
|
|
28
|
+
/* Run the sweep on `root` and return a compact report. Defects are listed in full up to a cap, owed work as
|
|
29
|
+
* counts per class plus the first few, because a defect is the board lying and owed work is an open loop. */
|
|
30
|
+
function run(root) {
|
|
31
|
+
const c = core(); if (!c.ok) return c;
|
|
32
|
+
let res;
|
|
33
|
+
try { res = c.core.sweep({ root }); }
|
|
34
|
+
catch (e) { return { ok: false, error: 'the sweep could not run — ' + String((e && e.message) || e).slice(0, 160) }; }
|
|
35
|
+
if (!res || res.ok === false) return { ok: false, error: (res && res.error) || 'the sweep could not read this repository' };
|
|
36
|
+
const pick = (f) => ({
|
|
37
|
+
class: f.class, id: f.id || null, file: f.file || null, what: f.what, evidence: f.evidence || null,
|
|
38
|
+
repair: f.repair ? { key: f.repair.key || null, to: f.repair.to || null } : null,
|
|
39
|
+
hand: f.hand || null,
|
|
40
|
+
command: f.repair ? 'node scripts/workflow-sweep.js --fix' : null,
|
|
41
|
+
});
|
|
42
|
+
const owedBy = {};
|
|
43
|
+
for (const f of res.owed || []) owedBy[f.class] = (owedBy[f.class] || 0) + 1;
|
|
44
|
+
return {
|
|
45
|
+
ok: true, root, initiatives: res.initiatives,
|
|
46
|
+
counts: { defects: (res.defects || []).length, owed: (res.owed || []).length, repairable: res.repairable || 0 },
|
|
47
|
+
defects: (res.defects || []).slice(0, DEFECT_CAP).map(pick),
|
|
48
|
+
owed_by_class: owedBy,
|
|
49
|
+
owed: (res.owed || []).slice(0, OWED_CAP).map(pick),
|
|
50
|
+
notes: res.notes || [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { run, core, PATHS };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Lane definitions — the one place a workflow is defined (D77).
|
|
4
|
+
*
|
|
5
|
+
* A lane is a kind of work. This file says, for each lane: what it is for, what goes in, which
|
|
6
|
+
* rungs the work passes, what comes out, and when it is done. The workflow engine, the lane
|
|
7
|
+
* picker, the lane write and the workspace detail pane all read THIS file. Change a lane here and
|
|
8
|
+
* every one of them changes; there is no second list to keep in step.
|
|
9
|
+
*
|
|
10
|
+
* Two kinds of field:
|
|
11
|
+
* · prose (label, when, input, output, done) — shown to the PM, never read by the engine's rules;
|
|
12
|
+
* · rules (track, rungs, labels) — read by the engine, so a change here changes the ladder.
|
|
13
|
+
*
|
|
14
|
+
* The engine runs under stock Node with no dependencies, and the kit vendors it by walking its
|
|
15
|
+
* relative requires, so this is a plain CommonJS module rather than a JSON file.
|
|
16
|
+
*
|
|
17
|
+
* `rungs` lists the pm-artifacts rungs a lane passes through. A rung left out reads `n/a` with the
|
|
18
|
+
* lane named as the reason, so a lane that skips a rung says so instead of showing it pending.
|
|
19
|
+
* The code profile's ladder is fixed by D56/D58/D60; its definitions here are descriptive.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const PM_ALL_RUNGS = ['brief', 'draft', 'review', 'approved', 'handoff'];
|
|
23
|
+
const PM_NO_HANDOFF = ['brief', 'draft', 'review', 'approved'];
|
|
24
|
+
|
|
25
|
+
const PM_ARTIFACTS = {
|
|
26
|
+
'new-feature': {
|
|
27
|
+
label: 'New feature',
|
|
28
|
+
group: 'work',
|
|
29
|
+
when: 'A capability that does not exist yet.',
|
|
30
|
+
input: 'The problem, who has it, and the evidence that it is real.',
|
|
31
|
+
output: 'A product requirements document (PRD).',
|
|
32
|
+
done: 'The PRD exists, a reviewer has judged it, the PM has accepted it, and it is handed to the team or tracker.',
|
|
33
|
+
track: 'governed', rungs: PM_ALL_RUNGS, brief_label: 'problem brief', draft_label: 'PRD',
|
|
34
|
+
},
|
|
35
|
+
improvement: {
|
|
36
|
+
label: 'Feature improvement',
|
|
37
|
+
group: 'work',
|
|
38
|
+
when: 'A change to a feature that already exists.',
|
|
39
|
+
input: 'The feature as it is now, what is wrong or missing, and the measure the change should move.',
|
|
40
|
+
output: 'A change specification: what changes, what stays the same, and how to tell it worked.',
|
|
41
|
+
done: 'The change specification exists, a reviewer has judged it, the PM has accepted it, and it is handed off.',
|
|
42
|
+
track: 'governed', rungs: PM_ALL_RUNGS, brief_label: 'change brief', draft_label: 'change specification',
|
|
43
|
+
},
|
|
44
|
+
'bug-fix': {
|
|
45
|
+
label: 'Bug fix',
|
|
46
|
+
group: 'work',
|
|
47
|
+
when: 'Behaviour that is wrong against what was promised.',
|
|
48
|
+
input: 'A bug report: steps to reproduce, expected result, actual result, severity.',
|
|
49
|
+
output: 'A fix specification: the cause, the behaviour after the fix, and the check that proves it.',
|
|
50
|
+
done: 'The fix specification exists, a reviewer has judged it, the PM has accepted it, and it is handed off.',
|
|
51
|
+
track: 'light', rungs: PM_ALL_RUNGS, brief_label: 'bug report', draft_label: 'fix specification',
|
|
52
|
+
},
|
|
53
|
+
discovery: {
|
|
54
|
+
label: 'New product discovery',
|
|
55
|
+
group: 'work',
|
|
56
|
+
when: 'A question that must be answered before anyone commits to build.',
|
|
57
|
+
input: 'The question, what is unknown, and the evidence that would make this worth picking up or dropping.',
|
|
58
|
+
output: 'Findings and a recommendation: go, drop, or learn more.',
|
|
59
|
+
done: 'The findings exist, a reviewer has judged them, the PM has accepted them, and the recommendation is handed off.',
|
|
60
|
+
track: 'governed', rungs: PM_ALL_RUNGS, brief_label: 'question brief', draft_label: 'findings',
|
|
61
|
+
},
|
|
62
|
+
chore: {
|
|
63
|
+
label: 'Chore',
|
|
64
|
+
group: 'work',
|
|
65
|
+
when: 'Upkeep that needs no requirements document.',
|
|
66
|
+
input: 'One paragraph that states the intent.',
|
|
67
|
+
output: 'The change itself, with the paragraph in the record.',
|
|
68
|
+
done: 'The change is made and the PM has accepted it. Nothing is handed off.',
|
|
69
|
+
track: 'light', rungs: PM_NO_HANDOFF, brief_label: 'intent', draft_label: 'change',
|
|
70
|
+
},
|
|
71
|
+
prd: {
|
|
72
|
+
label: 'PRD (document)',
|
|
73
|
+
group: 'document',
|
|
74
|
+
when: 'A standalone requirements document that is not a new feature, an improvement or a bug fix.',
|
|
75
|
+
input: 'The brief: the question, the reader, the decision it informs.',
|
|
76
|
+
output: 'A product requirements document.',
|
|
77
|
+
done: 'The PRD exists, a reviewer has judged it, the PM has accepted it, and it is handed off.',
|
|
78
|
+
track: 'governed', rungs: PM_ALL_RUNGS, brief_label: 'brief', draft_label: 'PRD',
|
|
79
|
+
},
|
|
80
|
+
strategy: {
|
|
81
|
+
label: 'Strategy (document)',
|
|
82
|
+
group: 'document',
|
|
83
|
+
when: 'A statement of direction: where to play, how to win, what not to do.',
|
|
84
|
+
input: 'The brief: the question, the reader, the decision it informs.',
|
|
85
|
+
output: 'A strategy document.',
|
|
86
|
+
done: 'The document exists, a reviewer has judged it, the PM has accepted it, and it is handed off.',
|
|
87
|
+
track: 'governed', rungs: PM_ALL_RUNGS, brief_label: 'brief', draft_label: 'strategy document',
|
|
88
|
+
},
|
|
89
|
+
'project-plan': {
|
|
90
|
+
label: 'Project plan (document)',
|
|
91
|
+
group: 'document',
|
|
92
|
+
when: 'Who does what, by when, in what order.',
|
|
93
|
+
input: 'The scope, the people and the dates that are fixed.',
|
|
94
|
+
output: 'A project plan.',
|
|
95
|
+
done: 'The plan exists, a reviewer has judged it, the PM has accepted it, and it is handed off.',
|
|
96
|
+
track: 'governed', rungs: PM_ALL_RUNGS, brief_label: 'brief', draft_label: 'project plan',
|
|
97
|
+
},
|
|
98
|
+
'decision-record': {
|
|
99
|
+
label: 'Decision record (document)',
|
|
100
|
+
group: 'document',
|
|
101
|
+
when: 'A decision that was made and must not be re-argued.',
|
|
102
|
+
input: 'The options, the choice, and the reason.',
|
|
103
|
+
output: 'A decision record.',
|
|
104
|
+
done: 'The record exists, a reviewer has judged it, and the PM has accepted it. Nothing is handed off.',
|
|
105
|
+
track: 'light', rungs: PM_NO_HANDOFF, brief_label: 'brief', draft_label: 'decision record',
|
|
106
|
+
},
|
|
107
|
+
report: {
|
|
108
|
+
label: 'Report (document)',
|
|
109
|
+
group: 'document',
|
|
110
|
+
when: 'An account of what happened or where things stand.',
|
|
111
|
+
input: 'The period or subject, and the reader.',
|
|
112
|
+
output: 'A report.',
|
|
113
|
+
done: 'The report exists, a reviewer has judged it, and the PM has accepted it. Nothing is handed off.',
|
|
114
|
+
track: 'light', rungs: PM_NO_HANDOFF, brief_label: 'brief', draft_label: 'report',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* The code profile's lanes. They share ONE ladder; a lane changes only the artifact the spec rung
|
|
120
|
+
* requires (`requires`), whether a rubric is needed, the prototype rule, and the build-path rule. Those
|
|
121
|
+
* rules are fixed by D56/D58/D60 and live in workflow-state.js; what is here is what the PM reads.
|
|
122
|
+
*/
|
|
123
|
+
const PRODUCT_CODE = {
|
|
124
|
+
feature: {
|
|
125
|
+
label: 'Feature',
|
|
126
|
+
group: 'work',
|
|
127
|
+
when: 'New or changed behaviour in the product.',
|
|
128
|
+
input: 'A product requirements document and an evaluation rubric, written before the build.',
|
|
129
|
+
requires: 'A PRD and a rubric, both written before the build.',
|
|
130
|
+
output: 'A pull request that satisfies the rubric.',
|
|
131
|
+
done: 'The build passes the rubric, an explainer is written, and the PM has accepted it.',
|
|
132
|
+
},
|
|
133
|
+
discovery: {
|
|
134
|
+
label: 'Discovery',
|
|
135
|
+
group: 'work',
|
|
136
|
+
when: 'A question to answer before building.',
|
|
137
|
+
input: 'A spike brief: the question and the evidence that would end the spike.',
|
|
138
|
+
requires: 'A spike brief.',
|
|
139
|
+
output: 'Findings that answer the question.',
|
|
140
|
+
done: 'The findings answer the brief and the PM has accepted them.',
|
|
141
|
+
},
|
|
142
|
+
chore: {
|
|
143
|
+
label: 'Chore',
|
|
144
|
+
group: 'work',
|
|
145
|
+
when: 'Upkeep that needs no requirements document.',
|
|
146
|
+
input: 'One paragraph that states the intent.',
|
|
147
|
+
requires: 'One paragraph of intent, written in the record\'s status.',
|
|
148
|
+
output: 'The change.',
|
|
149
|
+
done: 'The change is made and the PM has accepted it.',
|
|
150
|
+
},
|
|
151
|
+
incident: {
|
|
152
|
+
label: 'Incident',
|
|
153
|
+
group: 'work',
|
|
154
|
+
when: 'Something broke and was fixed first.',
|
|
155
|
+
input: 'A record written after the fix: what broke, the cause, the repair.',
|
|
156
|
+
requires: 'An incident record.',
|
|
157
|
+
output: 'The fix and its post-hoc record.',
|
|
158
|
+
done: 'The record states the cause and the repair, and the PM has accepted it.',
|
|
159
|
+
},
|
|
160
|
+
content: {
|
|
161
|
+
label: 'Content',
|
|
162
|
+
group: 'work',
|
|
163
|
+
when: 'Writing or media that ships with the product.',
|
|
164
|
+
input: 'A content brief. A rubric is recommended.',
|
|
165
|
+
requires: 'A content brief. A rubric is optional.',
|
|
166
|
+
output: 'The content.',
|
|
167
|
+
done: 'The content meets the brief and the PM has accepted it.',
|
|
168
|
+
},
|
|
169
|
+
prototype: {
|
|
170
|
+
label: 'Prototype',
|
|
171
|
+
group: 'work',
|
|
172
|
+
when: 'An idea the PM cannot judge from a description.',
|
|
173
|
+
input: 'A quick brief.',
|
|
174
|
+
requires: 'A quick prototype brief.',
|
|
175
|
+
output: '2–3 specimens the PM can look at. A prototype is never scored.',
|
|
176
|
+
done: 'The PM has looked at the specimens and chosen what to do with them.',
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* What decides each rung. This is the answer to "how does PMOS know this step is done" — one
|
|
182
|
+
* sentence per rung, shown beside the evidence in the detail pane. It restates the engine's test;
|
|
183
|
+
* the engine remains the authority, and the suite checks that every rung the engine emits has a
|
|
184
|
+
* sentence here.
|
|
185
|
+
*/
|
|
186
|
+
const RUNG_RULES = {
|
|
187
|
+
'pm-artifacts': {
|
|
188
|
+
route: 'The lane named in the record is one this repository offers.',
|
|
189
|
+
anchor: 'parent_kr names a KR, a declared health budget, or a domain goal that resolves.',
|
|
190
|
+
brief: 'The file named in brief_path (or prd_path) exists.',
|
|
191
|
+
draft: 'The file named in doc_path exists. On the governed track it did not first appear before the brief.',
|
|
192
|
+
review: 'evals.jsonl holds a verdict for this initiative, or the record names a reviewer in reviewed_by.',
|
|
193
|
+
approved: 'acceptances.jsonl holds an accepted line for this initiative. The PM records it; it is never automatic.',
|
|
194
|
+
handoff: 'handed_off in the record names where the work went.',
|
|
195
|
+
},
|
|
196
|
+
'product-code': {
|
|
197
|
+
route: 'The lane is one this repository offers, and its specimen and surface questions are answered.',
|
|
198
|
+
anchor: 'parent_kr names a KR, a declared health budget, or a domain goal that resolves.',
|
|
199
|
+
prototype: 'A prototype brief first appeared before the PRD, where the specimen test calls for one.',
|
|
200
|
+
understand: 'An explainer for the existing surface first appeared before the PRD.',
|
|
201
|
+
spec: 'The PRD and rubric named in the record exist and first appeared at or before the first build commit.',
|
|
202
|
+
priors: 'The record cites a prior run or states that none applies.',
|
|
203
|
+
contract: 'A run in agent-runs.jsonl holds done criteria and a test method with PM_approved set.',
|
|
204
|
+
build: 'Commits attributed to this initiative change a build path.',
|
|
205
|
+
quality: 'CI decides on the pull request. The engine never claims a test result it did not run.',
|
|
206
|
+
review: 'evals.jsonl holds a Review-Gate verdict for this initiative, or a skip.',
|
|
207
|
+
explain: 'An explainer exists for the run.',
|
|
208
|
+
accept: 'acceptances.jsonl holds an accepted line for this initiative. The PM records it.',
|
|
209
|
+
outcome: 'outcome_checks holds a validated, refuted or inconclusive disposition with evidence.',
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function defsFor(profile) {
|
|
214
|
+
return profile === 'pm-artifacts' ? PM_ARTIFACTS : PRODUCT_CODE;
|
|
215
|
+
}
|
|
216
|
+
function laneNames(profile) {
|
|
217
|
+
return Object.keys(defsFor(profile));
|
|
218
|
+
}
|
|
219
|
+
function laneDef(profile, lane) {
|
|
220
|
+
return defsFor(profile)[lane] || null;
|
|
221
|
+
}
|
|
222
|
+
function rungRule(profile, key) {
|
|
223
|
+
return (RUNG_RULES[profile] || {})[key] || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { PM_ARTIFACTS, PRODUCT_CODE, RUNG_RULES, defsFor, laneNames, laneDef, rungRule };
|