create-pmos 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/README.md +13 -0
  4. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
  6. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
  7. package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/plan-items-stage.js +271 -0
  11. package/payload/scripts/pmos-log.sh +18 -7
  12. package/payload/scripts/workflow-state.js +28 -2
  13. package/payload/state/discoveries.md +1 -1
  14. package/payload/state/log.md +1 -1
  15. package/workspace/bin/pmos.js +15 -1
  16. package/workspace/cut-stub.js +40 -0
  17. package/workspace/first-paint-audit.js +145 -0
  18. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  19. package/workspace/fixtures/initiatives-api/list.json +176 -0
  20. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  21. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  22. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  23. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  24. package/workspace/fixtures/report-api/draft.json +177 -0
  25. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  26. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  27. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  28. package/workspace/guide/board.md +81 -0
  29. package/workspace/guide/breakdown.md +154 -0
  30. package/workspace/guide/command-palette.md +75 -0
  31. package/workspace/guide/index.md +27 -17
  32. package/workspace/guide/initiatives-roadmap.md +130 -0
  33. package/workspace/guide/initiatives-screen.md +89 -0
  34. package/workspace/guide/initiatives-timeline.md +57 -0
  35. package/workspace/guide/integrated-work.md +94 -0
  36. package/workspace/guide/knowledge-graph.md +3 -68
  37. package/workspace/guide/layout.md +98 -32
  38. package/workspace/guide/live-notes.md +3 -3
  39. package/workspace/guide/pm-only-acts.md +89 -0
  40. package/workspace/guide/pm-only-lines.md +61 -0
  41. package/workspace/guide/reports-page.md +80 -0
  42. package/workspace/guide/reports.md +73 -0
  43. package/workspace/guide/timeline.md +70 -0
  44. package/workspace/guide/today.md +85 -0
  45. package/workspace/guide/validate-with-typesafe.md +1 -1
  46. package/workspace/lib/actor.js +91 -0
  47. package/workspace/lib/board-cache.js +32 -2
  48. package/workspace/lib/board-cli.js +108 -0
  49. package/workspace/lib/board-fold.js +232 -0
  50. package/workspace/lib/breakdown-export.js +80 -0
  51. package/workspace/lib/capture.js +98 -0
  52. package/workspace/lib/engine/plan-items-stage.js +271 -0
  53. package/workspace/lib/engine/workflow-state.js +28 -2
  54. package/workspace/lib/factory-join.js +158 -0
  55. package/workspace/lib/grant-mint.js +113 -0
  56. package/workspace/lib/health-rules-factory.js +92 -0
  57. package/workspace/lib/health-rules-plan.js +164 -0
  58. package/workspace/lib/health.js +79 -0
  59. package/workspace/lib/initiative-cli.js +222 -0
  60. package/workspace/lib/initiative-display.js +183 -0
  61. package/workspace/lib/initiative-needs.js +127 -0
  62. package/workspace/lib/initiative-rows.js +206 -0
  63. package/workspace/lib/initiative-writer.js +141 -0
  64. package/workspace/lib/issues.js +206 -0
  65. package/workspace/lib/item-cli.js +195 -0
  66. package/workspace/lib/item-spans.js +137 -0
  67. package/workspace/lib/measure-cli.js +100 -0
  68. package/workspace/lib/measurements.js +189 -0
  69. package/workspace/lib/mobile-nav.js +57 -53
  70. package/workspace/lib/needs-providers.js +120 -0
  71. package/workspace/lib/placements.js +440 -0
  72. package/workspace/lib/plan-items.js +922 -0
  73. package/workspace/lib/plan-ledgers.js +85 -0
  74. package/workspace/lib/plan-rules.js +72 -0
  75. package/workspace/lib/plan-tree.js +367 -0
  76. package/workspace/lib/pm-guard.js +174 -0
  77. package/workspace/lib/pm-lines-needs.js +331 -0
  78. package/workspace/lib/pmos-verbs.js +66 -0
  79. package/workspace/lib/proactive.js +7 -4
  80. package/workspace/lib/report-cli.js +152 -0
  81. package/workspace/lib/report-draft.js +579 -0
  82. package/workspace/lib/report-needs.js +213 -0
  83. package/workspace/lib/route-modules.js +128 -0
  84. package/workspace/lib/routes-board.js +91 -0
  85. package/workspace/lib/routes-capture.js +52 -0
  86. package/workspace/lib/routes-initiatives.js +371 -0
  87. package/workspace/lib/routes-reports.js +163 -0
  88. package/workspace/lib/routes-timeline.js +197 -0
  89. package/workspace/lib/routes-today.js +32 -0
  90. package/workspace/lib/today-cli.js +99 -0
  91. package/workspace/lib/today.js +333 -0
  92. package/workspace/lib/triage.js +163 -0
  93. package/workspace/lib/verify-work-cli.js +81 -0
  94. package/workspace/lib/work-check-needs.js +99 -0
  95. package/workspace/package-lock.json +1 -1
  96. package/workspace/package.json +4 -5
  97. package/workspace/public/index.html +29 -19
  98. package/workspace/public/pm/app.js +299 -30
  99. package/workspace/public/pm/bd-board.js +113 -0
  100. package/workspace/public/pm/bd-drag.js +59 -0
  101. package/workspace/public/pm/bd-model.js +152 -0
  102. package/workspace/public/pm/bd-timeline.js +233 -0
  103. package/workspace/public/pm/bd-tree.js +244 -0
  104. package/workspace/public/pm/board-copy.js +57 -0
  105. package/workspace/public/pm/breakdown-api.js +58 -0
  106. package/workspace/public/pm/breakdown-copy.js +138 -0
  107. package/workspace/public/pm/components.css +3054 -0
  108. package/workspace/public/pm/cycle-review.js +125 -0
  109. package/workspace/public/pm/data.js +40 -187
  110. package/workspace/public/pm/dest-board.js +438 -0
  111. package/workspace/public/pm/dest-initiatives.js +134 -0
  112. package/workspace/public/pm/dest-reports.js +270 -0
  113. package/workspace/public/pm/dest-settings.js +217 -0
  114. package/workspace/public/pm/dest-today.js +176 -0
  115. package/workspace/public/pm/dialogs.js +88 -0
  116. package/workspace/public/pm/display-word.js +56 -0
  117. package/workspace/public/pm/dock.js +105 -0
  118. package/workspace/public/pm/fixture.js +8 -227
  119. package/workspace/public/pm/frame-copy.js +94 -0
  120. package/workspace/public/pm/handoff-box.js +82 -0
  121. package/workspace/public/pm/http.js +88 -0
  122. package/workspace/public/pm/icons.js +53 -0
  123. package/workspace/public/pm/initiative-list.js +218 -0
  124. package/workspace/public/pm/initiative-sheet.js +261 -0
  125. package/workspace/public/pm/initiatives-api.js +59 -0
  126. package/workspace/public/pm/initiatives-copy.js +215 -0
  127. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  128. package/workspace/public/pm/initiatives-timeline.js +410 -0
  129. package/workspace/public/pm/initiatives-ui.js +123 -0
  130. package/workspace/public/pm/item-sheet.js +251 -0
  131. package/workspace/public/pm/keep-card.js +204 -0
  132. package/workspace/public/pm/lifecycle.js +7 -1
  133. package/workspace/public/pm/mobile.css +49 -20
  134. package/workspace/public/pm/mobile.js +157 -29
  135. package/workspace/public/pm/modes-live.js +7 -883
  136. package/workspace/public/pm/modes.js +32 -1024
  137. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  138. package/workspace/public/pm/next-step-ui.js +3 -2
  139. package/workspace/public/pm/palette-copy.js +51 -0
  140. package/workspace/public/pm/palette.js +312 -0
  141. package/workspace/public/pm/panel-breakdown.js +702 -0
  142. package/workspace/public/pm/panel-cockpit.js +63 -295
  143. package/workspace/public/pm/panel-detail.js +25 -172
  144. package/workspace/public/pm/panel-initiatives.js +1 -19
  145. package/workspace/public/pm/panel-repos.js +31 -81
  146. package/workspace/public/pm/panel-tabs.js +197 -110
  147. package/workspace/public/pm/panel-trees.js +5 -2
  148. package/workspace/public/pm/portfolio-report.js +61 -0
  149. package/workspace/public/pm/primitives.js +656 -0
  150. package/workspace/public/pm/print.css +33 -0
  151. package/workspace/public/pm/provider-cards.js +136 -0
  152. package/workspace/public/pm/registry.js +194 -0
  153. package/workspace/public/pm/release-card.js +110 -0
  154. package/workspace/public/pm/report-view.js +309 -0
  155. package/workspace/public/pm/reports-copy.js +114 -0
  156. package/workspace/public/pm/roadmap.js +271 -0
  157. package/workspace/public/pm/sig-watch.js +41 -0
  158. package/workspace/public/pm/state.js +8 -4
  159. package/workspace/public/pm/status-banner.js +13 -5
  160. package/workspace/public/pm/tab-doc.js +2 -4
  161. package/workspace/public/pm/tab-term.js +12 -2
  162. package/workspace/public/pm/tab-web.js +3 -2
  163. package/workspace/public/pm/timeline-copy.js +48 -0
  164. package/workspace/public/pm/today-cards.js +244 -0
  165. package/workspace/public/pm/today-copy.js +97 -0
  166. package/workspace/public/pm/tokens.css +16 -18
  167. package/workspace/public/pm/topbar.js +102 -0
  168. package/workspace/public/pm/triage-card.js +60 -0
  169. package/workspace/public/pm/undo-hold.js +103 -0
  170. package/workspace/public/pm/view-addrepo.js +2 -1
  171. package/workspace/public/pm/view-livenotes.js +3 -1
  172. package/workspace/public/pm/view-today.js +7 -162
  173. package/workspace/public/vendor/Sortable.min.js +2 -0
  174. package/workspace/public/vendor/lucide-sprite.js +79 -0
  175. package/workspace/replaced-tests.json +2864 -0
  176. package/workspace/run-tests.js +56 -5
  177. package/workspace/server.js +312 -36
  178. package/workspace/sheet-answer.js +61 -0
  179. package/workspace/skills/measure.skill +41 -0
  180. package/workspace/skills/report-biweekly.skill +23 -25
  181. package/workspace/skills/tracker-refresh.skill +50 -0
  182. package/workspace/test-files.json +19 -3
  183. package/workspace/test-lists/base.json +6 -0
  184. package/workspace/test-lists/run2-a.json +6 -0
  185. package/workspace/test-lists/run2-b.json +13 -0
  186. package/workspace/test-lists/run2-c.json +19 -0
  187. package/workspace/test-lists/run2-d.json +22 -0
  188. package/workspace/test-lists/run3-0.json +5 -0
  189. package/workspace/test-lists/run3-a.json +5 -0
  190. package/workspace/test-lists/run3-b.json +6 -0
  191. package/workspace/test-lists/run3-c.json +15 -0
  192. package/workspace/test-lists/run3-d.json +6 -0
  193. package/workspace/test-lists/run4-a.json +8 -0
  194. package/workspace/test-lists/run4-b.json +6 -0
  195. package/workspace/test-lists/run4-c.json +13 -0
  196. package/workspace/test-lists/run4-d.json +9 -0
  197. package/workspace/test-lists/run5-a.json +6 -0
  198. package/workspace/test-lists/run5-b.json +6 -0
  199. package/workspace/test-lists/run5-c.json +15 -0
  200. package/workspace/test-run.js +27 -1
  201. package/workspace/third-party/README.md +44 -0
  202. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  203. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  204. package/workspace/design-parity.js +0 -673
  205. package/workspace/public/pm/tab-task.js +0 -86
  206. package/workspace/public/pm/view-guide.js +0 -141
  207. package/workspace/public/pm/view-knowledge.js +0 -225
  208. package/workspace/public/pm/view-memory.js +0 -122
  209. package/workspace/public/pm/view-portfolio.js +0 -153
  210. package/workspace/public/pm/view-reports.js +0 -190
  211. package/workspace/public/pm/view-scheduled.js +0 -161
  212. package/workspace/public/pm/view-skills.js +0 -216
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ /* breakdown-export.js — an initiative's breakdown as markdown, for a document or a message (pm-os-absorb Run 2, D86
3
+ * point 4.3, map-spec X7).
4
+ *
5
+ * exportBreakdown(root, id) → { ok: true, id, markdown } | { ok: false, status, error }
6
+ *
7
+ * D86 point 4: a project document is an EXPORT of the Breakdown tree, so the document and the board it describes have
8
+ * one source and cannot disagree. The output is deterministic — the same ledger gives the same bytes, with no clock
9
+ * and no random part — so two exports can be compared with diff. Stage headers come in the stage order of the
10
+ * initiative's lane (lib/plan-tree.js), each with "n of m"; under each header the items in tree order, each with its
11
+ * state, owner, dates and the milestone mark. Proposed items (an agent's batch not kept yet) are listed with
12
+ * "proposed"; withdrawn items are left out. Items whose stage the lane does not have are listed under "Other".
13
+ *
14
+ * Nothing is written: the CLI prints the markdown, the route answers it, and the PM saves it where they choose.
15
+ * Work lanes only (D86 point 4): a document-lane initiative is a file already, so it is refused with 404.
16
+ * No heading or label says "plan" (glossary rule `plan`).
17
+ */
18
+ const fs = require('fs');
19
+ const planItems = require('./plan-items.js');
20
+ const planTree = require('./plan-tree.js');
21
+ const rows = require('./initiative-rows.js');
22
+ const consoleState = require('./console-state');
23
+
24
+ const STATE_LABEL = { todo: 'Todo', doing: 'Doing', review: 'Review', done: 'Done' };
25
+ const refuse = (status, error) => ({ ok: false, status, error });
26
+
27
+ function ownerLabel(owner) {
28
+ const s = String(owner || '');
29
+ if (s === 'you') return 'you';
30
+ if (s === 'agent') return 'agent';
31
+ const m = /^(person|tracker):(.+)$/.exec(s);
32
+ return m ? m[2] : (s || 'no owner');
33
+ }
34
+
35
+ function itemLine(it) {
36
+ const bits = [STATE_LABEL[it.state] || it.state, ownerLabel(it.owner)];
37
+ if (it.start && it.due) bits.push(`${it.start} → ${it.due}`);
38
+ else if (it.due) bits.push(`due ${it.due}`);
39
+ else if (it.start) bits.push(`from ${it.start}`);
40
+ if (it.milestone) bits.push('milestone');
41
+ if (it.blocked) bits.push(`blocked: ${String(it.blocked.reason || '').replace(/\s+/g, ' ').trim()}`);
42
+ if (it.proposed) bits.push('proposed');
43
+ return `- [${it.state === 'done' ? 'x' : ' '}] ${it.title.replace(/\s+/g, ' ').trim()} · ${bits.join(' · ')}`;
44
+ }
45
+
46
+ function exportBreakdown(root, id) {
47
+ if (typeof id !== 'string' || !planTree.INIT_RE.test(id)) return refuse(400, 'an initiative id is required');
48
+ const file = planTree.recordPath(root, id);
49
+ if (!file) return refuse(404, `no initiative ${id} in this repository`);
50
+ const loaded = consoleState.loadEngine();
51
+ if (!loaded.ok) return refuse(500, loaded.error);
52
+ let fm = {};
53
+ try { fm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {}; } catch { fm = {}; }
54
+ const { lane } = planTree.profileAndLane(root, id);
55
+ let groups;
56
+ try { groups = rows.workGroupOf(root); } catch { groups = {}; }
57
+ const declared = String(fm.lane || '').trim();
58
+ if (groups[Object.prototype.hasOwnProperty.call(groups, declared) ? declared : lane] !== 'work') {
59
+ return refuse(404, `${id} is not on a work lane; a document is a file and has no breakdown to export`);
60
+ }
61
+ const fold = planItems.readFold(root, { now: 0 });
62
+ const lay = planTree.layout({ root, initiative: id, fold, ladder: null, now: 0 });
63
+ const title = String(fm.title || id).replace(/\s+/g, ' ').trim();
64
+ const out = [`# ${title}`, '', `Initiative \`${id}\` · ${lay.lane} lane · anchor ${String(fm.parent_kr || 'none').trim()} · ${lay.progress.label}`, ''];
65
+ const section = (heading, ids) => {
66
+ out.push(heading, '');
67
+ if (!ids.length) out.push('No items.');
68
+ for (const itemId of ids) out.push(itemLine(lay.items[itemId]));
69
+ out.push('');
70
+ };
71
+ for (const st of lay.stages) {
72
+ if (!st.items.length) continue;
73
+ section(`## ${st.label}${st.count ? ` · ${st.count}` : ''}`, st.items);
74
+ }
75
+ if (lay.other.items.length) section('## Other', lay.other.items);
76
+ if (!lay.stages.some((s) => s.items.length) && !lay.other.items.length) out.push('No breakdown yet.', '');
77
+ return { ok: true, id, markdown: out.join('\n') };
78
+ }
79
+
80
+ module.exports = { exportBreakdown, itemLine };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ /* capture.js — the ⌘K "Capture as note" writer (pm-os-absorb Run 3, PRD R11 and R16, X16).
3
+ *
4
+ * write(stateDir, { text, actor, now? }) → { ok: true, id, file, path, n, day } | { ok: false, status, error }
5
+ * list(stateDir) → [{ id, file, n, day, timestamp, by, text }], oldest first
6
+ * render({ text, day, n, timestamp, by }) → the file's whole text
7
+ *
8
+ * A capture is ONE NOTE FILE, `<state>/requirements/capture-YYYY-MM-DD-<n>.md`, with `<n>` counting from 1 per UTC day.
9
+ * There is no ledger: the folder is the one the day's live-notes note and the report skill already read, and the next
10
+ * step (triage, Run 2, slice 2C) reads the notes newer than its last run and files each as a proposal. Capture itself
11
+ * decides nothing: it keeps the words, which is why it is the row ⌘K always offers (D71).
12
+ *
13
+ * The frontmatter is `type: Reference` (`Requirement` is not in scripts/frontmatter_registry.txt, and the Frontmatter
14
+ * Gate would refuse it), `title`, `timestamp` (ISO UTC), `source: capture` and `by`. `by` comes from the CALL PATH (the
15
+ * `actor` the route got from pm-guard), never from a field the caller sends. The body is the captured text, unchanged.
16
+ *
17
+ * THE FILE IS NEVER OVERWRITTEN. The write is exclusive (`wx`): when two captures race for one number, the loser takes
18
+ * the next. Nothing is read from outside the folder, and no capture is deleted or edited here.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const DIR = 'requirements';
24
+ const FILE_RE = /^capture-(\d{4}-\d{2}-\d{2})-(\d+)\.md$/;
25
+ const MAX_TEXT = 8000;
26
+ const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
27
+
28
+ const refuse = (status, error) => ({ ok: false, status, error });
29
+
30
+ /** The whole file. Pure, so the test can hold it to the fixture byte for byte. */
31
+ function render({ text, day, n, timestamp, by }) {
32
+ const body = String(text).replace(/\r\n?/g, '\n').trim();
33
+ return `---\ntype: Reference\ntitle: "Capture ${day} ${n}"\ntimestamp: ${timestamp}\nsource: capture\nby: ${by}\n---\n\n${body}\n`;
34
+ }
35
+
36
+ /** The text a capture may carry: a non-empty string of at most MAX_TEXT characters, with no NUL. Null when it passes. */
37
+ function textProblem(text) {
38
+ if (typeof text !== 'string') return 'The capture needs text.';
39
+ if (!text.trim()) return 'The capture is empty. Type the idea first.';
40
+ if (text.length > MAX_TEXT) return `The capture is too long. The limit is ${MAX_TEXT} characters.`;
41
+ if (text.includes('\u0000')) return 'The capture holds a character that a note cannot keep.';
42
+ return null;
43
+ }
44
+
45
+ /** The numbers already taken on `day`, from the file names in the folder. */
46
+ function taken(dir, day) {
47
+ let names = [];
48
+ try { names = fs.readdirSync(dir); } catch { return []; }
49
+ const out = [];
50
+ for (const f of names) { const m = FILE_RE.exec(f); if (m && m[1] === day) out.push(Number(m[2])); }
51
+ return out;
52
+ }
53
+
54
+ function write(stateDir, { text, actor, now } = {}) {
55
+ if (!stateDir || typeof stateDir !== 'string') return refuse(409, 'This repository has no state folder to keep the capture in.');
56
+ const bad = textProblem(text);
57
+ if (bad) return refuse(400, bad);
58
+ if (!actor || typeof actor.by !== 'string' || !BY_RE.test(actor.by)) return refuse(500, 'capture: the writer needs the actor from the call path.');
59
+ const at = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
60
+ const timestamp = at.toISOString().replace(/\.\d{3}Z$/, 'Z');
61
+ const day = timestamp.slice(0, 10);
62
+ const dir = path.join(stateDir, DIR);
63
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return refuse(500, 'The capture could not be saved: ' + String((e && e.message) || e).slice(0, 160)); }
64
+ let n = Math.max(0, ...taken(dir, day)) + 1;
65
+ for (let tries = 0; tries < 50; tries++, n++) {
66
+ const file = `capture-${day}-${n}.md`;
67
+ try {
68
+ fs.writeFileSync(path.join(dir, file), render({ text, day, n, timestamp, by: actor.by }), { flag: 'wx' });
69
+ return { ok: true, id: file.replace(/\.md$/, ''), file, path: path.join(dir, file), n, day, timestamp };
70
+ } catch (e) {
71
+ if (e && e.code === 'EEXIST') continue; // another capture took this number first
72
+ return refuse(500, 'The capture could not be saved: ' + String((e && e.message) || e).slice(0, 160));
73
+ }
74
+ }
75
+ return refuse(500, 'The capture could not be saved: no free number today.');
76
+ }
77
+
78
+ /** Every capture note in the folder, oldest first. A file that does not parse as a capture is left out, not an error. */
79
+ function list(stateDir) {
80
+ const dir = path.join(stateDir, DIR);
81
+ let names = [];
82
+ try { names = fs.readdirSync(dir); } catch { return []; }
83
+ const rows = [];
84
+ for (const file of names) {
85
+ const m = FILE_RE.exec(file);
86
+ if (!m) continue;
87
+ let raw = '';
88
+ try { raw = fs.readFileSync(path.join(dir, file), 'utf8'); } catch { continue; }
89
+ const f = /^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/.exec(raw);
90
+ if (!f) continue;
91
+ const fm = {};
92
+ for (const line of f[1].split('\n')) { const k = /^([a-z_]+): ?(.*)$/.exec(line); if (k) fm[k[1]] = k[2].replace(/^"(.*)"$/, '$1'); }
93
+ rows.push({ id: file.replace(/\.md$/, ''), file, day: m[1], n: Number(m[2]), timestamp: fm.timestamp || '', by: fm.by || '', text: f[2].replace(/\n$/, '') });
94
+ }
95
+ return rows.sort((a, b) => (a.day === b.day ? a.n - b.n : a.day < b.day ? -1 : 1));
96
+ }
97
+
98
+ module.exports = { DIR, MAX_TEXT, FILE_RE, render, write, list, textProblem };
@@ -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 };
@@ -1067,8 +1067,24 @@ function computeWorkflowDerived(id, opts) {
1067
1067
  * commits in this range" is a statement about the RANGE, and on a branch level with `main` that
1068
1068
  * range is legitimately empty. A reader who cannot tell those apart calls the second one "never
1069
1069
  * built", which is the console's half of this defect.
1070
+ *
1071
+ * THE BUILD RULE (PRD R6, pm-os-absorb Run 4). Items never complete a stage, with one exception: when plan items
1072
+ * under Build were materialized (a D65 `declared` line names them as `plan_item`, D86 point 1), the step is done
1073
+ * when every one of those work items is integrated, and pending until then, whatever the commits say. With no
1074
+ * materialized item the commits decide, as above. The rule reads both ledgers through plan-items-stage.js, the
1075
+ * engine's own pure fold (X29: the engine cannot require workspace/lib). A broken delivered_by stays blocked first.
1070
1076
  */
1071
- steps.push(build.unknown
1077
+ const buildRule = require('./plan-items-stage').buildRuleFor(root, id);
1078
+ const ruleStep = build.failedDeclaration || !(buildRule.applies || buildRule.unknown) ? null
1079
+ : buildRule.unknown
1080
+ ? step('build', 'build', 'unknown', buildRule.reason)
1081
+ : buildRule.pending.length === 0
1082
+ ? step('build', 'build', 'done',
1083
+ `every materialized item under Build is integrated (${buildRule.integrated.length} of ${buildRule.materialized.length} in planning/work-items.jsonl)`)
1084
+ : step('build', 'build', 'pending',
1085
+ `${buildRule.integrated.length} of ${buildRule.materialized.length} materialized item(s) under Build integrated — waiting on ${buildRule.pending.join(', ')}`,
1086
+ 'merge each work item with a `Work-Item: <id>` trailer on the trunk, then run `pmos verify-work`');
1087
+ steps.push(ruleStep || (build.unknown
1072
1088
  ? step('build', 'build', 'unknown', build.reason || range.reason)
1073
1089
  : build.failedDeclaration
1074
1090
  // REPORTED, never CI-blocking — the same stance R1 takes on an unresolvable anchor. Making a
@@ -1087,7 +1103,7 @@ function computeWorkflowDerived(id, opts) {
1087
1103
  : 'no implementation commits in this range')
1088
1104
  : step('build', 'build', 'done', build.signal
1089
1105
  ? `${build.count} commit(s) on implementation paths — ${delivery.attributionEvidence(build.signal, id, build.declaredSha)}`
1090
- : `${build.count} commit(s) on implementation paths`));
1106
+ : `${build.count} commit(s) on implementation paths`)));
1091
1107
 
1092
1108
  // ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
1093
1109
  steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
@@ -1325,9 +1341,19 @@ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, re
1325
1341
  const accepted = cachedJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
1326
1342
  .filter((a) => a && a.initiative_id === id && a.accepted === true);
1327
1343
  const acceptedOn = accepted.length ? (accepted[accepted.length - 1].accepted_at || accepted[accepted.length - 1].at) : null;
1344
+ // D86 point 3 — a report is approved when the PM issues it: an `issued` line in issues.jsonl naming its doc_path.
1345
+ // Its own minimal parse (X29: the vendored engine never requires workspace/lib); a line counts only if the PM wrote it.
1346
+ const norm = (p) => String(p || '').trim().replace(/^\.\//, '');
1347
+ const issued = effLane !== 'report' || !docPath ? []
1348
+ : cachedJsonl(path.join(L.runsDir, 'issues.jsonl')).filter((r) => r && r.ev === 'issued' && norm(r.path) === norm(docPath)
1349
+ && /^pm:/.test(String(r.by || '')) && (r.via === 'ui' || r.via === 'cli') && /^[0-9a-f]{40}$/.test(String(r.sha || '')));
1328
1350
  steps.push(!flows('approved') ? skipped('approved', 'approved')
1351
+ : issued.length
1352
+ ? step('approved', 'approved', 'done', `issued by the PM on ${String(issued[0].at || '').slice(0, 10)} (issues.jsonl)`)
1329
1353
  : accepted.length
1330
1354
  ? step('approved', 'approved', 'done', `accepted by the PM${acceptedOn ? ' on ' + String(acceptedOn).slice(0, 10) : ''}`)
1355
+ : effLane === 'report'
1356
+ ? step('approved', 'approved', 'pending', 'awaiting the PM\'s Issue (D86 point 3)', 'the PM issues the report (pmos report issue, or Issue on Reports) — never automatic')
1331
1357
  : step('approved', 'approved', 'pending', 'awaiting the PM\'s acceptance (D12)', 'the PM accepts the document — never automatic'));
1332
1358
 
1333
1359
  if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));