create-pmos 0.51.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 +5 -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 +117 -6
  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 +20 -4
  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,213 @@
1
+ 'use strict';
2
+ /* report-needs.js — the needs-you rows that Reports makes: a report to issue, the monthly kill-condition check, a
3
+ * schedule that left no output (pm-os-absorb Run 5, slice 5C; PRD R12 kinds 1, 7 and 8, R22.5, R29).
4
+ *
5
+ * kinds · rowsFor(w, { lead }) · sig(w) the needs-provider contract (lib/needs-providers.js)
6
+ * rowsAt(w, { lead, now, schedules }) rowsFor with the clock and the schedules injected, for tests
7
+ * reportsOf(root) → { ok, reports: [{ cadence, slot, path, status, bytes, mtimeMs, issued, edited }],
8
+ * flags, sig } the report files joined to issues.jsonl; the routes read it too
9
+ * cadenceOfSkill(skill) 'report-biweekly' → 'biweekly', else null
10
+ *
11
+ * THE THREE ROW KINDS (rows are held to fixtures/today-api/provider-rows.json):
12
+ * report-issue kind 1. The newest draft of a cadence that the report script wrote, that is not issued, and that is
13
+ * newer than the cadence's last issued slot. Needs `lead`: a report belongs to the repository, not to
14
+ * one working tree. The script's own frontmatter marks its draft (isScriptDraft below): `cadence`,
15
+ * `slot` and `window`. Run 5A's drafter writes no `status` key, because an issued file is frozen byte
16
+ * for byte and a `status: draft` would lie once it is issued; the issues ledger says what is issued.
17
+ * `status: draft` still counts, for a file an earlier drafter wrote. A draft a skill wrote by hand
18
+ * has neither, so it never makes a card; the Reports page still lists it and can issue it (X36).
19
+ * cycle-kill-check kind 7. Once a month, from the 1st: planning/okrs/kill-conditions.md has not been touched this
20
+ * month. The file's own `timestamp` is the record of the check (D64: file mode, "this file").
21
+ * 2D draws it with the one kind-7 card; this file supplies the row only.
22
+ * schedule-no-output kind 8. An enabled report schedule fired and no report file for its cadence is newer than the
23
+ * fire, after a grace period for an agent that is still writing.
24
+ *
25
+ * A SCHEDULE JOINS ITS REPORT BY ITS `skill` FIELD, NEVER BY ITS `cadence`. The schedule's cadence is how often it runs
26
+ * ('bi-weekly'); the report's cadence is the skill id's suffix ('report-biweekly' → 'biweekly'). The two spellings
27
+ * differ, and a schedule whose cadence says weekly may feed the monthly report.
28
+ *
29
+ * Local files only (D70): the repository's report folders and ledger, and the schedules store the app already reads.
30
+ * Nothing here calls out, and nothing here writes. Every read answers; a ledger that cannot be read throws, which
31
+ * needs-providers turns into one row that names it, never an empty list (R10).
32
+ */
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const crypto = require('crypto');
36
+ const issues = require('./issues.js');
37
+ const ledgers = require('./plan-ledgers.js');
38
+ const stateRoot = require('./state-root.js');
39
+
40
+ const kinds = ['report-issue', 'cycle-kill-check', 'schedule-no-output'];
41
+ const KILL_FILE = 'planning/okrs/kill-conditions.md';
42
+ const SLOT_FILE = /^(\d{4}-\d{2}-\d{2})\.md$/;
43
+ const CADENCE_DIR = /^[a-z][a-z0-9-]{0,39}$/;
44
+ const GRACE_MS = 2 * 3600 * 1000; // an agent report session needs time to write its draft
45
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
46
+
47
+ /**
48
+ * The one folder this repository keeps its report files in: `pmos/state/reports` in a kit repository, `planning/reports` in
49
+ * PMOS-self. It is the folder report-draft.js writes to, and the only path the issues writer accepts, so a file in the other
50
+ * folder is not listed: it would offer Issue and every press would be refused.
51
+ */
52
+ function reportsDir(root) {
53
+ try { return /[\\/]pmos[\\/]state$/.test(ledgers.runsDir(path.resolve(String(root)))) ? 'pmos/state/reports' : 'planning/reports'; } catch { return 'planning/reports'; }
54
+ }
55
+
56
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
57
+ const short = (ymd) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(ymd || '')); return m ? `${Number(m[3])} ${MONTHS[Number(m[2]) - 1]}` : String(ymd || ''); };
58
+ const cadenceOfSkill = (skill) => { const m = /^report-([a-z][a-z0-9-]{0,39})$/.exec(String(skill || '')); return m ? m[1] : null; };
59
+ /** What `git hash-object` prints for these bytes, without running git: the sha issues.jsonl records. */
60
+ const blobSha = (buf) => crypto.createHash('sha1').update(`blob ${buf.length}\0`).update(buf).digest('hex');
61
+
62
+ /** The first frontmatter block of a file, as { key: value }. Only the leading 4 KB are read. */
63
+ function frontmatter(file) {
64
+ let head = '';
65
+ try {
66
+ const fd = fs.openSync(file, 'r');
67
+ try { const b = Buffer.alloc(4096); head = b.toString('utf8', 0, fs.readSync(fd, b, 0, 4096, 0)); } finally { fs.closeSync(fd); }
68
+ } catch { return {}; }
69
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(head);
70
+ const out = {};
71
+ if (!m) return out;
72
+ for (const line of m[1].split(/\r?\n/)) { const kv = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line); if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
73
+ return out;
74
+ }
75
+
76
+ /** The report script's own mark: its frontmatter names the cadence, the slot and the window. `status: draft` is the older mark. */
77
+ const isScriptDraft = (fm) => fm.status === 'draft' || !!(fm.cadence && fm.slot && fm.window);
78
+
79
+ /* ── the report files, joined to the issues ledger ─────────────────────────────────────────────────────────────────── */
80
+
81
+ function reportsOf(root) {
82
+ const abs = path.resolve(String(root));
83
+ const out = { ok: true, reports: [], flags: [], sig: '' };
84
+ // The signature is taken BEFORE the ledger is read (F2): a line appended in between shows on the next read.
85
+ const ledgerSig = ledgers.ledgerSig(abs, 'issues');
86
+ let iss;
87
+ try { iss = issues.readIssues(abs); } catch (e) { iss = { ok: false, issues: [], flags: [{ kind: 'unreadable', eid: null, why: String((e && e.message) || e).slice(0, 160) }] }; }
88
+ out.flags = Array.isArray(iss.flags) ? iss.flags.slice() : [];
89
+ const issuedOf = new Map((iss.issues || []).map((i) => [`${i.cadence}\u0000${i.slot}`, i]));
90
+ const rel = reportsDir(abs);
91
+ const base = path.join(abs, rel);
92
+ if (isDir(base)) {
93
+ let cadences = [];
94
+ try { cadences = fs.readdirSync(base); } catch { cadences = []; }
95
+ for (const cadence of cadences.sort()) {
96
+ if (!CADENCE_DIR.test(cadence) || cadence === 'definitions' || !isDir(path.join(base, cadence))) continue;
97
+ let files = [];
98
+ try { files = fs.readdirSync(path.join(base, cadence)); } catch { files = []; }
99
+ for (const f of files.sort()) {
100
+ const m = SLOT_FILE.exec(f);
101
+ if (!m) continue;
102
+ const file = path.join(base, cadence, f);
103
+ let st; try { st = fs.statSync(file); } catch { continue; }
104
+ if (!st.isFile()) continue;
105
+ const slot = m[1];
106
+ const hit = issuedOf.get(`${cadence}\u0000${slot}`) || null;
107
+ let edited = false;
108
+ if (hit && hit.sha) { try { edited = blobSha(fs.readFileSync(file)) !== hit.sha; } catch { edited = false; } }
109
+ out.reports.push({
110
+ cadence, slot, path: `${rel}/${cadence}/${f}`, status: isScriptDraft(frontmatter(file)) ? 'draft' : null,
111
+ bytes: st.size, mtimeMs: st.mtimeMs, issued: hit ? { at: hit.at, by: hit.by, via: hit.via, sha: hit.sha, path: hit.path } : null, edited,
112
+ });
113
+ }
114
+ }
115
+ }
116
+ out.reports.sort((a, b) => (a.slot < b.slot ? 1 : a.slot > b.slot ? -1 : a.cadence < b.cadence ? -1 : a.cadence > b.cadence ? 1 : 0));
117
+ out.sig = crypto.createHash('sha1').update(ledgerSig + '|' + out.reports.map((r) => `${r.path}:${r.bytes}:${r.mtimeMs}`).join(',')).digest('hex');
118
+ return out;
119
+ }
120
+
121
+ /* ── the schedules store ───────────────────────────────────────────────────────────────────────────────────────────── */
122
+
123
+ /** The app's own schedules.json, read-only: the per-user state root, else the legacy workspace/state (as server.js does). */
124
+ function schedulesFile() {
125
+ const here = path.join(stateRoot.defaultRoot({ env: process.env }), 'schedules.json');
126
+ if (fs.existsSync(here)) return here;
127
+ const legacy = path.join(__dirname, '..', 'state', 'schedules.json');
128
+ return fs.existsSync(legacy) ? legacy : here;
129
+ }
130
+ function readSchedules() {
131
+ try { const a = JSON.parse(fs.readFileSync(schedulesFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
132
+ }
133
+ const schedulesStamp = () => { try { const st = fs.statSync(schedulesFile()); return `${st.size}:${st.mtimeMs}`; } catch { return 'none'; } };
134
+
135
+ /* ── the rows ──────────────────────────────────────────────────────────────────────────────────────────────────────── */
136
+
137
+ const row = (run_id, kind, question, today) => ({ run_id, initiative_id: null, kind, question, doc: null, today: { source: 'report-needs', ...today } });
138
+
139
+ function issueRows(root) {
140
+ const r = reportsOf(root);
141
+ const unreadable = r.flags.find((f) => f.kind === 'unreadable' && /^line 0:/.test(f.why));
142
+ if (unreadable) throw new Error('the issues ledger cannot be read: ' + unreadable.why);
143
+ const lastSlot = {};
144
+ for (const x of r.reports) if (x.issued && (!lastSlot[x.cadence] || x.slot > lastSlot[x.cadence])) lastSlot[x.cadence] = x.slot;
145
+ const seen = new Set();
146
+ const out = [];
147
+ for (const x of r.reports) { // newest slot first
148
+ if (seen.has(x.cadence)) continue;
149
+ if (x.status !== 'draft' || x.issued || (lastSlot[x.cadence] && x.slot <= lastSlot[x.cadence])) continue;
150
+ seen.add(x.cadence);
151
+ out.push(row(`report-needs:issue:${x.cadence}:${x.slot}`, 'report-issue', `The ${x.cadence} of ${short(x.slot)} is ready to issue`,
152
+ { kind: 1, cause: 'A draft is ready to issue', evidence: x.path, act: { label: 'Issue', cli: `pmos report issue ${x.cadence} ${x.slot}` } }));
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function killRow(root, now) {
158
+ const file = path.join(path.resolve(String(root)), KILL_FILE);
159
+ if (!fs.existsSync(file)) return [];
160
+ const month = new Date(now).toISOString().slice(0, 7);
161
+ const stamp = String(frontmatter(file).timestamp || '').slice(0, 10);
162
+ if (/^\d{4}-\d{2}-\d{2}$/.test(stamp) && stamp.slice(0, 7) >= month) return []; // touched this month: the check is recorded
163
+ return [row(`report-needs:cycle:kill-check:${month}`, 'cycle-kill-check', `The kill-condition check for ${month} is due`,
164
+ { kind: 7, cause: 'The month ended', evidence: KILL_FILE, act: { label: 'Review', cli: null } })];
165
+ }
166
+
167
+ /** The newest modification time of any report file of `cadence`, or 0. */
168
+ function newestOutput(root, cadence) {
169
+ return reportsOf(root).reports.filter((x) => x.cadence === cadence).reduce((m, x) => Math.max(m, x.mtimeMs), 0);
170
+ }
171
+
172
+ function noOutputRows(w, now, schedules) {
173
+ const out = [];
174
+ const seen = new Set();
175
+ for (const s of schedules) {
176
+ const cadence = cadenceOfSkill(s && s.skill);
177
+ if (!cadence || s.ws !== w.id || s.enabled === false || !s.lastRun) continue;
178
+ const fired = Date.parse(s.lastRun);
179
+ if (!Number.isFinite(fired)) continue;
180
+ const failed = s.lastStatus === 'fail';
181
+ if (!failed && now - fired < GRACE_MS) continue;
182
+ if (newestOutput(w.root, cadence) + 1000 >= fired) continue;
183
+ const day = new Date(fired).toISOString().slice(0, 10);
184
+ const id = `report-needs:no-output:${cadence}:${day}`;
185
+ if (seen.has(id)) continue;
186
+ seen.add(id);
187
+ const dir = reportsDir(w.root);
188
+ out.push(row(id, 'schedule-no-output', `The ${cadence} schedule ran on ${short(day)} and left no report`,
189
+ { kind: 8, cause: `No draft for the slot of ${short(day)}`, evidence: `${dir}/${cadence}`, act: { label: 'Draft now', cli: `pmos report draft ${cadence}` } }));
190
+ }
191
+ return out;
192
+ }
193
+
194
+ function rowsAt(w, { lead = true, now = Date.now(), schedules } = {}) {
195
+ if (!w || !w.root) return [];
196
+ const rows = [];
197
+ if (lead) { rows.push(...issueRows(w.root)); rows.push(...killRow(w.root, now)); }
198
+ rows.push(...noOutputRows(w, now, Array.isArray(schedules) ? schedules : readSchedules()));
199
+ return rows;
200
+ }
201
+
202
+ const rowsFor = (w, { lead } = {}) => rowsAt(w, { lead: !!lead });
203
+
204
+ /** Everything the rows read, so /api/needs is never stale: the issues ledger, the report files, the schedules, the kill-condition file and the hour. */
205
+ function sig(w) {
206
+ if (!w || !w.root) return '';
207
+ const hour = Math.floor(Date.now() / 3600000);
208
+ let kill = 'none';
209
+ try { const st = fs.statSync(path.join(path.resolve(String(w.root)), KILL_FILE)); kill = `${st.size}:${st.mtimeMs}`; } catch { /* no file, no row */ }
210
+ return [reportsOf(w.root).sig, schedulesStamp(), kill, hour].join(':');
211
+ }
212
+
213
+ module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, cadenceOfSkill, blobSha, reportsDir, KILL_FILE, GRACE_MS };
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+ /* route-modules.js — where the planning layer's HTTP routes plug into server.js (pm-os-absorb Runs 2 to 5).
3
+ *
4
+ * dispatch(req, res, url, ctx) → Promise<boolean> true when a module answered the request
5
+ * MODULES · status() · ownerOf(pathname)
6
+ *
7
+ * server.js answers requests with one long if-chain. Four runs adding blocks to it at once would collide on the same
8
+ * lines, so each run's routes live in ONE file of their own, workspace/lib/routes-<name>.js, named in MODULES below
9
+ * before it exists. server.js calls dispatch() once, as the last step before its 404, so a module can never shadow a
10
+ * route above it.
11
+ *
12
+ * A ROUTE MODULE exports:
13
+ * module.exports = {
14
+ * prefixes: ['/api/initiatives', '/api/placements'], // paths it owns: the prefix itself or prefix + '/…'
15
+ * handle: async (req, res, url, ctx) => boolean, // true when it wrote the response
16
+ * };
17
+ * `ctx` is what server.js hands over, plus `readJson`:
18
+ * { json(res, obj, code), readBody(req), readJson(req) → { ok, body } | { ok: false },
19
+ * getWs(id), invalidateNeedsAgg(id), boardCache, consoleState, stateDirFor(w), sessionList() }
20
+ * A module needs nothing else from server.js; it requires the lib/ modules it uses itself (spelled with `.js` when the
21
+ * module is a ledger writer, so scripts/check-separation.js lists the caller).
22
+ *
23
+ * A module file that is not on disk yet is skipped. One that fails to load, or claims a prefix another module claims,
24
+ * answers its prefixes with 500 and says why: a broken module must not read as a missing route.
25
+ *
26
+ * Built in: GET /api/pm-modules?want=a.js,b.js → { ok, present: [...], grantScopes: [...] }, which of the named public/pm modules exist,
27
+ * so public/pm/registry.js imports only those (a missing optional module then costs no 404 in the console).
28
+ */
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+
32
+ const MODULES = Object.freeze([
33
+ 'routes-initiatives', // Run 2 (2C): Roadmap, List, placements, new initiative, measurements
34
+ 'routes-today', // Run 3 (3B): GET /api/today
35
+ 'routes-capture', // Run 3 (3C): ⌘K capture
36
+ 'routes-timeline', // Run 4 (4C): GET /api/timeline
37
+ 'routes-board', // Run 4 (4D): the cross-initiative Board, Release
38
+ 'routes-reports', // Run 5 (5C): drafts, Issue, notes
39
+ ]);
40
+ const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
41
+ const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
42
+
43
+ let table = null; // { owners: [{ prefix, name }], broken: { [name]: error }, loaded: [name] }
44
+ function load() {
45
+ if (table) return table;
46
+ const owners = [];
47
+ const broken = {};
48
+ const loaded = [];
49
+ const mods = {};
50
+ for (const name of MODULES) {
51
+ const full = path.join(__dirname, name + '.js');
52
+ let mod;
53
+ try { mod = require(full); }
54
+ catch (e) {
55
+ if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(full)) continue; // not built yet
56
+ broken[name] = `${name}.js failed to load: ${String((e && e.message) || e).slice(0, 200)}`;
57
+ continue;
58
+ }
59
+ const prefixes = mod && Array.isArray(mod.prefixes) ? mod.prefixes.filter((x) => typeof x === 'string' && /^\/api\/[a-z0-9/-]+[a-z0-9]$/.test(x)) : [];
60
+ if (!mod || typeof mod.handle !== 'function' || !prefixes.length || prefixes.length !== mod.prefixes.length) {
61
+ broken[name] = `${name}.js must export prefixes (each "/api/…") and a handle function`;
62
+ continue;
63
+ }
64
+ mods[name] = mod;
65
+ loaded.push(name);
66
+ for (const prefix of prefixes) owners.push({ prefix, name });
67
+ }
68
+ owners.push({ prefix: '/api/pm-modules', name: '(built in)' });
69
+ for (const o of owners) {
70
+ const twin = owners.find((x) => x !== o && x.prefix === o.prefix);
71
+ if (twin) broken[o.name] = `${o.prefix} is claimed by both ${o.name} and ${twin.name}`;
72
+ }
73
+ table = { owners, broken, loaded, mods };
74
+ return table;
75
+ }
76
+
77
+ /** The module that owns `pathname`: the longest prefix it equals or begins with, on a segment boundary. */
78
+ function ownerOf(pathname) {
79
+ let best = null;
80
+ for (const o of load().owners) {
81
+ if (pathname !== o.prefix && !pathname.startsWith(o.prefix + '/')) continue;
82
+ if (!best || o.prefix.length > best.prefix.length) best = o;
83
+ }
84
+ return best;
85
+ }
86
+
87
+ function status() {
88
+ const t = load();
89
+ return { loaded: t.loaded.slice(), broken: { ...t.broken }, prefixes: t.owners.map((o) => ({ ...o })) };
90
+ }
91
+
92
+ function pmModules(url) {
93
+ const want = String(url.searchParams.get('want') || '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 40);
94
+ const present = [];
95
+ for (const name of want) {
96
+ if (!MODULE_NAME.test(name)) continue;
97
+ try { if (fs.statSync(path.join(PM_DIR, name)).isFile()) present.push(name); } catch { /* absent */ }
98
+ }
99
+ // grantScopes: the D81 scopes a press can mint now (lib/grant-mint.js mintableScopes; [] until Run 2's 2B).
100
+ let grantScopes = [];
101
+ try { grantScopes = require('./grant-mint.js').mintableScopes(); } catch { /* none */ }
102
+ return { ok: true, present, grantScopes };
103
+ }
104
+
105
+ async function dispatch(req, res, url, ctx) {
106
+ const p = url.pathname;
107
+ if (!p.startsWith('/api/')) return false;
108
+ const owner = ownerOf(p);
109
+ if (!owner) return false;
110
+ const t = load();
111
+ if (t.broken[owner.name]) { ctx.json(res, { ok: false, error: t.broken[owner.name] }, 500); return true; }
112
+ if (owner.name === '(built in)') {
113
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
114
+ ctx.json(res, pmModules(url));
115
+ return true;
116
+ }
117
+ const readJson = async (r) => {
118
+ try { return { ok: true, body: JSON.parse((await ctx.readBody(r)) || '{}') }; } catch { return { ok: false }; }
119
+ };
120
+ try { return (await t.mods[owner.name].handle(req, res, url, { ...ctx, readJson })) === true; }
121
+ catch (e) {
122
+ if (res.headersSent) { try { res.end(); } catch { /* already closed */ } return true; }
123
+ ctx.json(res, { ok: false, error: `${owner.name} failed: ${String((e && e.message) || e).slice(0, 200)}` }, 500);
124
+ return true;
125
+ }
126
+ }
127
+
128
+ module.exports = { MODULES, dispatch, ownerOf, status };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ /* routes-board.js — the Board destination's two routes (pm-os-absorb Run 4, slice 4D; PRD R9, R19, R12 kind 4, R25).
3
+ *
4
+ * GET /api/board?ws=<id>&filter=active|mine|init:<initiative>
5
+ * → { ok, ws, filter, now, sig, initiatives, items, board, counts, releases } lib/board-fold.js, one fold for all
6
+ * GET /api/board?ws=<id>&only=sig → { ok, ws, sig } the hash alone, which the page's watcher reads
7
+ * POST /api/board/release { ws, initiative, batch }
8
+ * → { ok: true, grant_id, scope, target, items } or the refusal of the guard, the batch check or the grant mint
9
+ *
10
+ * THE PAGE WATCHES `sig` (public/pm/sig-watch.js watchSig): it is a hash of the plan-items and placements ledgers and of
11
+ * the factory join, taken before they are read (lib/board-fold.js). Nothing is cached (D33).
12
+ *
13
+ * RELEASE IS A PM-ONLY ACT (R25, D86 point 2). `requirePmPress` runs first, with act `release`, so a press with no
14
+ * Origin, a foreign Origin, a missing nonce or a writer field in the body is refused before the batch is looked at. The
15
+ * batch must exist in the factory join (lib/factory-join.js) and be ready: every item passed its checks. ONE grant covers
16
+ * the whole batch: lib/grant-mint.js mintFromPress mints a `release` grant with target `<initiative>:<batch id>`, and the
17
+ * route never writes grants.jsonl itself. Until Run 2 fills the mint, its 501 is passed through unchanged, and the page
18
+ * says "arrives in Run 2" from /api/pm-modules grantScopes before the press. The factory stays propose-only: this route
19
+ * grants the PM's permission; it merges and pushes nothing. A batch that already holds a release grant (unexpired, or used)
20
+ * is 409 "already released" with that grant's id, and the Board and Today list no card for it: one batch, one grant.
21
+ */
22
+ const guard = require('./pm-guard.js');
23
+ const grantMint = require('./grant-mint.js');
24
+ const factoryJoin = require('./factory-join.js');
25
+ const planItems = require('./plan-items.js');
26
+ const boardFold = require('./board-fold.js');
27
+
28
+ const BATCH_RE = /^[A-Za-z0-9][\w.-]{0,79}$/;
29
+ const text = (v) => (typeof v === 'string' ? v : '');
30
+
31
+ function wsOf(ctx, id) {
32
+ const w = ctx.getWs(id || '');
33
+ return w && (!id || w.id === id) ? w : null;
34
+ }
35
+
36
+ async function handle(req, res, url, ctx) {
37
+ const p = url.pathname;
38
+ if (p === '/api/board') {
39
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
40
+ const w = wsOf(ctx, url.searchParams.get('ws'));
41
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
42
+ if (url.searchParams.get('only') === 'sig') {
43
+ // the page's watcher reads this every 10 seconds: the hash alone, with no item read
44
+ try { ctx.json(res, { ok: true, ws: w.id, sig: boardFold.sigFor({ id: w.id, root: w.root }) }); }
45
+ catch (e) { ctx.json(res, { ok: false, error: 'the Board could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500); }
46
+ return true;
47
+ }
48
+ let out;
49
+ try { out = boardFold.boardFor({ id: w.id, root: w.root }, { filter: url.searchParams.get('filter') || 'active' }); }
50
+ catch (e) { ctx.json(res, { ok: false, error: 'the Board could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500); return true; }
51
+ if (!out.ok) { ctx.json(res, { ok: false, error: out.error }, out.status || 400); return true; }
52
+ ctx.json(res, { ...out, ws: w.id });
53
+ return true;
54
+ }
55
+ if (p === '/api/board/release') {
56
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
57
+ const read = await ctx.readJson(req);
58
+ if (!read.ok) { ctx.json(res, { ok: false, error: 'the body is not JSON' }, 400); return true; }
59
+ const body = read.body;
60
+ const g = guard.requirePmPress(req, body, { act: 'release' });
61
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
62
+ const w = wsOf(ctx, text(body.ws));
63
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
64
+ const initiative = text(body.initiative);
65
+ const batchId = text(body.batch);
66
+ if (!planItems.INIT_RE.test(initiative) || !BATCH_RE.test(batchId)) { ctx.json(res, { ok: false, error: 'initiative and batch are required' }, 400); return true; }
67
+ let works;
68
+ try { works = boardFold.workInitiatives(w.root); } catch (e) { ctx.json(res, { ok: false, error: 'the initiatives could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500); return true; }
69
+ if (!works.some((i) => i.id === initiative)) { ctx.json(res, { ok: false, error: `no initiative ${initiative} on the Board` }, 404); return true; }
70
+ const join = factoryJoin.joinFactory(w.root, { initiative });
71
+ const entry = factoryJoin.factoryOf(join, initiative);
72
+ const batch = entry && (entry.batches || []).find((b) => b && b.id === batchId);
73
+ if (!batch) { ctx.json(res, { ok: false, error: `${initiative} has no batch ${batchId} to release` }, 404); return true; }
74
+ if (!batch.ready) { ctx.json(res, { ok: false, error: `batch ${batchId} is not ready: an item has not passed its checks` }, 409); return true; }
75
+ // ONE release grant covers a batch (D86 point 2): a batch that holds a grant is not minted again
76
+ const held = boardFold.releaseGrants(w.root).get(`${initiative}:${batchId}`);
77
+ if (held) { ctx.json(res, { ok: false, error: `batch ${batchId} is already released`, grant_id: held.grant_id, scope: 'release', target: `${initiative}:${batchId}` }, 409); return true; }
78
+ const n = (batch.items || []).length;
79
+ const r = grantMint.mintFromPress(w.root, {
80
+ scope: 'release', target: `${initiative}:${batchId}`, initiative,
81
+ instruction: `Release ${n === 1 ? '1 work item' : n + ' work items'} of ${initiative}, batch ${batchId}`,
82
+ }, { actor: g.actor });
83
+ if (!r.ok) { ctx.json(res, { ok: false, error: r.error, scope: 'release', target: `${initiative}:${batchId}` }, r.status || 500); return true; }
84
+ ctx.invalidateNeedsAgg(w.id);
85
+ ctx.json(res, { ok: true, grant_id: r.grant_id, scope: r.scope, target: r.target, items: n });
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ module.exports = { prefixes: ['/api/board'], handle };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+ /* routes-capture.js — POST /api/capture, the ⌘K "Capture as note" press (pm-os-absorb Run 3, slice 3C; lib/route-modules.js).
3
+ *
4
+ * POST /api/capture { ws, text } → { ok: true, id, file, written: 1 }
5
+ *
6
+ * `id` is `capture-YYYY-MM-DD-<n>` and `file` is `requirements/<id>.md`, relative to the repository's state folder; the
7
+ * answer never carries an absolute path. A write that is not a PM-only act goes through pm-guard `requirePagePress`
8
+ * (a body that is not an object or carries a writer field: 400; no Origin, a foreign Origin or a wrong nonce: 403), and
9
+ * the note's `by` comes from the actor that call returns, never from the body (lib/capture.js).
10
+ *
11
+ * The state folder is the one Run 2's triage reads: the folder that holds the initiatives' folder in the workflow engine's
12
+ * layout (`pmos/state/` in a kit repository, else `planning/`; none is made). It is NOT server.js `stateDirFor`, which also
13
+ * picks `state/` and `.pmos/` and would file a note where triage never looks. A body with no `ws` is 400 and a `ws` the server
14
+ * does not know is 404, so a capture is never filed in another repository.
15
+ */
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const guard = require('./pm-guard.js');
19
+ const capture = require('./capture.js');
20
+
21
+ const prefixes = ['/api/capture'];
22
+
23
+ /** The folder that holds the initiatives' folder (the layout rule triage.js `stateDir` uses); the same rule by hand when the engine is absent. */
24
+ function captureStateDir(ctx, root) {
25
+ try {
26
+ const loaded = ctx.consoleState && ctx.consoleState.loadEngine();
27
+ if (loaded && loaded.ok) return path.dirname(loaded.engine.layout(root).initiatives);
28
+ } catch { /* fall through to the same rule by hand */ }
29
+ const kit = !fs.existsSync(path.join(root, 'planning', 'initiatives')) && fs.existsSync(path.join(root, 'pmos', 'state', 'initiatives'));
30
+ return kit ? path.join(root, 'pmos', 'state') : path.join(root, 'planning');
31
+ }
32
+
33
+ async function handle(req, res, url, ctx) {
34
+ if (url.pathname !== '/api/capture') return false;
35
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
36
+ const parsed = await ctx.readJson(req);
37
+ if (!parsed.ok) { ctx.json(res, { ok: false, error: 'The body is not valid JSON.' }, 400); return true; }
38
+ const body = parsed.body;
39
+ const g = guard.requirePagePress(req, body);
40
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
41
+ const wsId = body && typeof body.ws === 'string' ? body.ws : '';
42
+ if (!wsId) { ctx.json(res, { ok: false, error: 'Select a repository first. A note is kept in one repository.' }, 400); return true; }
43
+ const w = ctx.getWs(wsId);
44
+ if (!w || w.id !== wsId) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
45
+ const stateDir = captureStateDir(ctx, w.root);
46
+ const r = capture.write(stateDir, { text: body.text, actor: g.actor });
47
+ if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status); return true; }
48
+ ctx.json(res, { ok: true, id: r.id, file: `${capture.DIR}/${r.file}`, written: 1 });
49
+ return true;
50
+ }
51
+
52
+ module.exports = { prefixes, handle };