create-pmos 0.52.0 → 0.53.1

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 (229) 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/README.md +2 -0
  16. package/workspace/REMOTE.md +24 -63
  17. package/workspace/bin/pmos.js +19 -3
  18. package/workspace/cut-stub.js +40 -0
  19. package/workspace/first-paint-audit.js +145 -0
  20. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  21. package/workspace/fixtures/initiatives-api/list.json +176 -0
  22. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  23. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  24. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  25. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  26. package/workspace/fixtures/report-api/draft.json +177 -0
  27. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  28. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  29. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  30. package/workspace/guide/board.md +81 -0
  31. package/workspace/guide/breakdown.md +154 -0
  32. package/workspace/guide/command-palette.md +75 -0
  33. package/workspace/guide/index.md +28 -17
  34. package/workspace/guide/initiatives-roadmap.md +130 -0
  35. package/workspace/guide/initiatives-screen.md +89 -0
  36. package/workspace/guide/initiatives-timeline.md +57 -0
  37. package/workspace/guide/integrated-work.md +94 -0
  38. package/workspace/guide/knowledge-graph.md +3 -68
  39. package/workspace/guide/launcher.md +51 -0
  40. package/workspace/guide/layout.md +102 -32
  41. package/workspace/guide/live-notes.md +3 -3
  42. package/workspace/guide/pm-only-acts.md +89 -0
  43. package/workspace/guide/pm-only-lines.md +61 -0
  44. package/workspace/guide/proactive-engine.md +21 -5
  45. package/workspace/guide/reports-page.md +80 -0
  46. package/workspace/guide/reports.md +73 -0
  47. package/workspace/guide/timeline.md +70 -0
  48. package/workspace/guide/today.md +85 -0
  49. package/workspace/guide/validate-with-typesafe.md +1 -1
  50. package/workspace/lib/actor.js +91 -0
  51. package/workspace/lib/board-cache.js +32 -2
  52. package/workspace/lib/board-cli.js +108 -0
  53. package/workspace/lib/board-fold.js +232 -0
  54. package/workspace/lib/breakdown-export.js +75 -0
  55. package/workspace/lib/brief.js +20 -2
  56. package/workspace/lib/capture.js +98 -0
  57. package/workspace/lib/engine/plan-items-stage.js +271 -0
  58. package/workspace/lib/engine/workflow-state.js +28 -2
  59. package/workspace/lib/factory-join.js +158 -0
  60. package/workspace/lib/goals.js +165 -0
  61. package/workspace/lib/grant-mint.js +113 -0
  62. package/workspace/lib/health-rules-factory.js +92 -0
  63. package/workspace/lib/health-rules-plan.js +164 -0
  64. package/workspace/lib/health.js +79 -0
  65. package/workspace/lib/initiative-cli.js +222 -0
  66. package/workspace/lib/initiative-display.js +183 -0
  67. package/workspace/lib/initiative-needs.js +127 -0
  68. package/workspace/lib/initiative-rows.js +210 -0
  69. package/workspace/lib/initiative-writer.js +141 -0
  70. package/workspace/lib/issues.js +206 -0
  71. package/workspace/lib/item-cli.js +195 -0
  72. package/workspace/lib/item-spans.js +137 -0
  73. package/workspace/lib/launcher-cli.js +207 -0
  74. package/workspace/lib/measure-cli.js +100 -0
  75. package/workspace/lib/measurements.js +189 -0
  76. package/workspace/lib/mobile-nav.js +57 -53
  77. package/workspace/lib/needs-providers.js +120 -0
  78. package/workspace/lib/placements.js +440 -0
  79. package/workspace/lib/plan-items.js +922 -0
  80. package/workspace/lib/plan-ledgers.js +85 -0
  81. package/workspace/lib/plan-rules.js +72 -0
  82. package/workspace/lib/plan-tree.js +380 -0
  83. package/workspace/lib/pm-guard.js +174 -0
  84. package/workspace/lib/pm-lines-needs.js +331 -0
  85. package/workspace/lib/pmos-verbs.js +69 -0
  86. package/workspace/lib/proactive-cli.js +58 -2
  87. package/workspace/lib/proactive.js +9 -6
  88. package/workspace/lib/qr.js +293 -0
  89. package/workspace/lib/remote-access.js +93 -1
  90. package/workspace/lib/report-cli.js +152 -0
  91. package/workspace/lib/report-draft.js +578 -0
  92. package/workspace/lib/report-needs.js +213 -0
  93. package/workspace/lib/route-modules.js +128 -0
  94. package/workspace/lib/routes-board.js +91 -0
  95. package/workspace/lib/routes-capture.js +52 -0
  96. package/workspace/lib/routes-initiatives.js +371 -0
  97. package/workspace/lib/routes-reports.js +163 -0
  98. package/workspace/lib/routes-timeline.js +197 -0
  99. package/workspace/lib/routes-today.js +32 -0
  100. package/workspace/lib/today-cli.js +99 -0
  101. package/workspace/lib/today.js +333 -0
  102. package/workspace/lib/triage.js +163 -0
  103. package/workspace/lib/verify-work-cli.js +81 -0
  104. package/workspace/lib/work-check-needs.js +99 -0
  105. package/workspace/package-lock.json +1 -1
  106. package/workspace/package.json +4 -5
  107. package/workspace/public/icons/apple-touch-icon.png +0 -0
  108. package/workspace/public/icons/icon-192.png +0 -0
  109. package/workspace/public/icons/icon-512.png +0 -0
  110. package/workspace/public/index.html +40 -19
  111. package/workspace/public/pm/app.js +330 -30
  112. package/workspace/public/pm/bd-board.js +113 -0
  113. package/workspace/public/pm/bd-drag.js +59 -0
  114. package/workspace/public/pm/bd-model.js +152 -0
  115. package/workspace/public/pm/bd-timeline.js +233 -0
  116. package/workspace/public/pm/bd-tree.js +244 -0
  117. package/workspace/public/pm/board-copy.js +57 -0
  118. package/workspace/public/pm/breakdown-api.js +58 -0
  119. package/workspace/public/pm/breakdown-copy.js +138 -0
  120. package/workspace/public/pm/components.css +3135 -0
  121. package/workspace/public/pm/cycle-review.js +125 -0
  122. package/workspace/public/pm/data.js +40 -187
  123. package/workspace/public/pm/dest-board.js +438 -0
  124. package/workspace/public/pm/dest-initiatives.js +134 -0
  125. package/workspace/public/pm/dest-reports.js +270 -0
  126. package/workspace/public/pm/dest-settings.js +217 -0
  127. package/workspace/public/pm/dest-today.js +182 -0
  128. package/workspace/public/pm/dialogs.js +88 -0
  129. package/workspace/public/pm/display-word.js +56 -0
  130. package/workspace/public/pm/dock.js +175 -0
  131. package/workspace/public/pm/fixture.js +8 -227
  132. package/workspace/public/pm/frame-copy.js +99 -0
  133. package/workspace/public/pm/handoff-box.js +82 -0
  134. package/workspace/public/pm/http.js +88 -0
  135. package/workspace/public/pm/icons.js +53 -0
  136. package/workspace/public/pm/initiative-list.js +218 -0
  137. package/workspace/public/pm/initiative-sheet.js +261 -0
  138. package/workspace/public/pm/initiatives-api.js +59 -0
  139. package/workspace/public/pm/initiatives-copy.js +215 -0
  140. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  141. package/workspace/public/pm/initiatives-timeline.js +410 -0
  142. package/workspace/public/pm/initiatives-ui.js +123 -0
  143. package/workspace/public/pm/item-sheet.js +251 -0
  144. package/workspace/public/pm/keep-card.js +204 -0
  145. package/workspace/public/pm/lifecycle.js +7 -1
  146. package/workspace/public/pm/mobile.css +49 -20
  147. package/workspace/public/pm/mobile.js +157 -29
  148. package/workspace/public/pm/modes-live.js +7 -883
  149. package/workspace/public/pm/modes.js +32 -1024
  150. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  151. package/workspace/public/pm/next-step-ui.js +3 -2
  152. package/workspace/public/pm/palette-copy.js +51 -0
  153. package/workspace/public/pm/palette.js +312 -0
  154. package/workspace/public/pm/panel-breakdown.js +702 -0
  155. package/workspace/public/pm/panel-cockpit.js +63 -295
  156. package/workspace/public/pm/panel-detail.js +25 -172
  157. package/workspace/public/pm/panel-initiatives.js +1 -19
  158. package/workspace/public/pm/panel-repos.js +31 -81
  159. package/workspace/public/pm/panel-tabs.js +200 -111
  160. package/workspace/public/pm/panel-trees.js +5 -2
  161. package/workspace/public/pm/portfolio-report.js +61 -0
  162. package/workspace/public/pm/primitives.js +656 -0
  163. package/workspace/public/pm/print.css +33 -0
  164. package/workspace/public/pm/provider-cards.js +136 -0
  165. package/workspace/public/pm/registry.js +194 -0
  166. package/workspace/public/pm/release-card.js +110 -0
  167. package/workspace/public/pm/report-view.js +309 -0
  168. package/workspace/public/pm/reports-copy.js +114 -0
  169. package/workspace/public/pm/roadmap.js +271 -0
  170. package/workspace/public/pm/sig-watch.js +41 -0
  171. package/workspace/public/pm/state.js +8 -4
  172. package/workspace/public/pm/status-banner.js +13 -5
  173. package/workspace/public/pm/tab-doc.js +2 -4
  174. package/workspace/public/pm/tab-term.js +12 -2
  175. package/workspace/public/pm/tab-web.js +3 -2
  176. package/workspace/public/pm/timeline-copy.js +48 -0
  177. package/workspace/public/pm/today-cards.js +244 -0
  178. package/workspace/public/pm/today-copy.js +126 -0
  179. package/workspace/public/pm/today-goals.js +172 -0
  180. package/workspace/public/pm/tokens.css +16 -18
  181. package/workspace/public/pm/topbar.js +102 -0
  182. package/workspace/public/pm/triage-card.js +60 -0
  183. package/workspace/public/pm/undo-hold.js +103 -0
  184. package/workspace/public/pm/view-addrepo.js +2 -1
  185. package/workspace/public/pm/view-livenotes.js +3 -1
  186. package/workspace/public/pm/view-today.js +7 -162
  187. package/workspace/public/vendor/Sortable.min.js +2 -0
  188. package/workspace/public/vendor/lucide-sprite.js +79 -0
  189. package/workspace/replaced-tests.json +2877 -0
  190. package/workspace/run-tests.js +56 -5
  191. package/workspace/server.js +446 -40
  192. package/workspace/sheet-answer.js +61 -0
  193. package/workspace/skills/measure.skill +41 -0
  194. package/workspace/skills/morning-brief.skill +15 -9
  195. package/workspace/skills/report-biweekly.skill +23 -25
  196. package/workspace/skills/tracker-refresh.skill +50 -0
  197. package/workspace/test-files.json +21 -3
  198. package/workspace/test-lists/base.json +6 -0
  199. package/workspace/test-lists/run2-a.json +6 -0
  200. package/workspace/test-lists/run2-b.json +13 -0
  201. package/workspace/test-lists/run2-c.json +19 -0
  202. package/workspace/test-lists/run2-d.json +22 -0
  203. package/workspace/test-lists/run3-0.json +5 -0
  204. package/workspace/test-lists/run3-a.json +5 -0
  205. package/workspace/test-lists/run3-b.json +6 -0
  206. package/workspace/test-lists/run3-c.json +15 -0
  207. package/workspace/test-lists/run3-d.json +6 -0
  208. package/workspace/test-lists/run4-a.json +8 -0
  209. package/workspace/test-lists/run4-b.json +6 -0
  210. package/workspace/test-lists/run4-c.json +13 -0
  211. package/workspace/test-lists/run4-d.json +9 -0
  212. package/workspace/test-lists/run5-a.json +6 -0
  213. package/workspace/test-lists/run5-b.json +6 -0
  214. package/workspace/test-lists/run5-c.json +15 -0
  215. package/workspace/test-lists/workspace-launcher.json +5 -0
  216. package/workspace/test-lists/workspace-phone-app.json +5 -0
  217. package/workspace/test-run.js +27 -1
  218. package/workspace/third-party/README.md +44 -0
  219. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  220. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  221. package/workspace/design-parity.js +0 -673
  222. package/workspace/public/pm/tab-task.js +0 -86
  223. package/workspace/public/pm/view-guide.js +0 -141
  224. package/workspace/public/pm/view-knowledge.js +0 -225
  225. package/workspace/public/pm/view-memory.js +0 -122
  226. package/workspace/public/pm/view-portfolio.js +0 -153
  227. package/workspace/public/pm/view-reports.js +0 -190
  228. package/workspace/public/pm/view-scheduled.js +0 -161
  229. package/workspace/public/pm/view-skills.js +0 -216
@@ -0,0 +1,371 @@
1
+ 'use strict';
2
+ /* routes-initiatives.js — the Initiatives API: the Roadmap, the List, the initiative sheet, the export, and the three
3
+ * writes behind them (pm-os-absorb Run 2, slice 2C; PRD R13, R16, R17, R20 A, R23, R25, R26; D86 point 4).
4
+ *
5
+ * GET /api/initiatives?ws=&view=roadmap|list → fixtures/initiatives-api/roadmap.json | list.json
6
+ * GET /api/initiatives/<id>?ws= → fixtures/initiatives-api/sheet.json
7
+ * GET /api/initiatives/<id>/export?ws= → { ok, id, markdown } (lib/breakdown-export.js; nothing is written)
8
+ * POST /api/initiatives { ws, title, lane, anchor, intent?, id?, from?, specimen_test?, surface_check? }
9
+ * → { ok, id, path, written, sig } New initiative (requirePagePress)
10
+ * POST /api/placements { ws, events: [placed | created | adopted | dropped] }
11
+ * → { ok, written, sig } fixtures/initiatives-api/post-ok.json
12
+ * POST /api/measurements { ws, events: [measured] } → { ok, written, sig }
13
+ *
14
+ * GUARDS (seams §3, §5.1). A placement into Now and `adopted` are PM-only acts: requirePmPress with act `now` or
15
+ * `adopt`, and one initiative per body (a bulk Move to Now is refused with 400, R17). Every other write — Next and Later
16
+ * placements, a new initiative, a measurement — passes requirePagePress. The actor goes from the guard to the writer;
17
+ * a body never names it. Refusals answer { ok: false, status, error } with that status (post-403.json).
18
+ *
19
+ * READS. The signature is taken BEFORE any ledger or record is read (the F2 rule of lib/plan-items.js), so a write that
20
+ * lands during a read changes the next poll's answer. `sig` = { placements, items, measurements, records }: the
21
+ * ledgerSig of each ledger read, and the records signature of lib/initiative-rows.js answerSig (the derivation the rows
22
+ * came from; a stale one is marked, so the poll after a re-derivation sees a new sig). The page watches it with
23
+ * public/pm/sig-watch.js. The rows come from lib/initiative-rows.js rowsFor(): until the first derivation of a
24
+ * repository lands, a GET answers 202 { ok: false, pending: true, error } and the page asks again.
25
+ * Work lanes only (D86 point 4): a document-lane initiative is in no answer; its sheet and its export are 404.
26
+ * Every `by` in an answer goes through actor.pageName, so no session token reaches the page (F5).
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const guard = require('./pm-guard.js');
31
+ const actorLib = require('./actor.js');
32
+ const ledgers = require('./plan-ledgers.js');
33
+ const placements = require('./placements.js');
34
+ const measurements = require('./measurements.js');
35
+ const planItems = require('./plan-items.js');
36
+ const planTree = require('./plan-tree.js');
37
+ const planRules = require('./plan-rules.js');
38
+ const health = require('./health.js');
39
+ const factoryJoin = require('./factory-join.js');
40
+ const display = require('./initiative-display.js');
41
+ const rowsLib = require('./initiative-rows.js');
42
+ const writer = require('./initiative-writer.js');
43
+ const breakdownExport = require('./breakdown-export.js');
44
+ const consoleState = require('./console-state');
45
+
46
+ const HORIZON_RANK = { now: 0, next: 1, later: 2 };
47
+ const FILE_KEYS = [['brief_path', 'brief', 'Brief'], ['prd_path', 'prd', 'PRD'], ['rubric_path', 'rubric', 'Rubric'], ['doc_path', 'doc', 'Document']];
48
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
49
+
50
+ /** The signature of everything a read answers from, taken before the read. */
51
+ function sigOf(root) {
52
+ return {
53
+ placements: ledgers.ledgerSig(root, 'placements'),
54
+ items: ledgers.ledgerSig(root, 'plan-items'),
55
+ measurements: ledgers.ledgerSig(root, 'measurements'),
56
+ records: rowsLib.recordsSig(root),
57
+ };
58
+ }
59
+
60
+ /** The lifecycle sub-shape of initiative-display.js: { state: active|held|closed|cancelled, reason, at } | null. */
61
+ function lifecycleOf(rec) {
62
+ if (!rec.stopped) return null;
63
+ const state = rec.stopped.state === 'on-hold' ? 'held' : rec.stopped.state;
64
+ return { state, reason: rec.stopped.reason || '', at: rec.stopped.at || null };
65
+ }
66
+
67
+ /** The newest acceptance line of each initiative in the repository's runs directory: { [id]: { at, by } }. */
68
+ function acceptancesOf(root) {
69
+ const out = {};
70
+ let text = '';
71
+ try { text = fs.readFileSync(path.join(ledgers.runsDir(root), 'acceptances.jsonl'), 'utf8'); } catch { return out; }
72
+ for (const line of text.split('\n')) {
73
+ if (!line.trim()) continue;
74
+ let r; try { r = JSON.parse(line); } catch { continue; }
75
+ if (!r || typeof r.initiative_id !== 'string') continue;
76
+ const at = r.at || r.date || null;
77
+ const prev = out[r.initiative_id];
78
+ if (prev && prev.at && (!at || at < prev.at)) continue;
79
+ const by = typeof r.by === 'string' ? r.by : (r.pm ? `pm:${r.pm}` : null);
80
+ out[r.initiative_id] = { at, by: actorLib.pageName(by) };
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Everything one GET reads, once: the frame each row is built from. */
86
+ function frameOf(w, rows, { now, sessions = [] } = {}) {
87
+ const root = w.root;
88
+ const rules = planRules.readRules(root);
89
+ const placed = placements.readPlacements(root, { now, rules, records: rows });
90
+ const fold = planItems.readFold(root, { now });
91
+ const meas = measurements.readMeasurements(root);
92
+ const join = factoryJoin.joinFactory(root);
93
+ const anchors = rowsLib.anchorsOf(root);
94
+ const accepted = acceptancesOf(root);
95
+ const byId = new Map(rows.filter((r) => r.ok !== false).map((r) => [r.id, r]));
96
+ return { root, rules, placed, fold, meas, join, anchors, accepted, byId, now, sessions };
97
+ }
98
+
99
+ /** The stage of a workflow row as the R4 stage group it falls in: { id, label }. */
100
+ function stageOf(root, rec) {
101
+ const raw = rec.stage || null;
102
+ if (!raw) return { id: null, label: 'Unknown' };
103
+ try {
104
+ const st = planTree.stagesFor(root, rec.id).find((s) => s.id === raw || s.steps.includes(raw));
105
+ if (st) return { id: st.id, label: st.label };
106
+ } catch { /* fall through */ }
107
+ return { id: raw, label: cap(raw) };
108
+ }
109
+
110
+ /** The display ctx of initiative-display.js, with every frozen sub-shape (its header). */
111
+ function displayCtx(F, rec, id) {
112
+ const items = {};
113
+ for (const [k, it] of Object.entries(F.fold.items)) if (it.initiative === id) items[k] = it;
114
+ const fold = { items, order: F.fold.order.filter((k) => items[k]), batches: (F.fold.batches || []).filter((b) => b.initiative === id), flags: (F.fold.flags || []).filter((f) => !f.initiative || f.initiative === id) };
115
+ const live = F.sessions.find((s) => typeof s.plan_item === 'string' && s.plan_item.startsWith(id + '.') && s.state !== 'exited');
116
+ return {
117
+ lifecycle: lifecycleOf(rec),
118
+ acceptance: rec.accepted ? (F.accepted[id] || { at: null, by: null }) : null,
119
+ handoff: live ? { item: live.plan_item, session: actorLib.pageName(`agent:${live.id}`) } : null,
120
+ fold,
121
+ placement: F.placed.byInit[id] || null,
122
+ rules: F.rules,
123
+ now: F.now,
124
+ };
125
+ }
126
+
127
+ function nextDue(F, id) {
128
+ let best = null;
129
+ for (const it of Object.values(F.fold.items)) {
130
+ if (it.initiative !== id || it.proposed || it.state === 'done' || !it.due) continue;
131
+ if (!best || it.due < best) best = it.due;
132
+ }
133
+ return best;
134
+ }
135
+
136
+ /** One initiative row: the key set every golden shares (d86-seams §10). */
137
+ function rowOf(F, rec) {
138
+ const id = rec.id;
139
+ const p = F.placed.byInit[id];
140
+ const a = F.anchors.resolve(rec.anchor);
141
+ const ctx = displayCtx(F, rec, id);
142
+ const d = display.displayOf({ ...rec, last_activity: rec.last_activity }, ctx);
143
+ const measurement = measurements.latest(F.meas, a.token || '') || (rec.anchor ? measurements.latest(F.meas, String(rec.anchor).trim()) : null);
144
+ const h = health.healthOf({ id, row: rec, fold: ctx.fold, placement: ctx.placement, measurement, factory: factoryJoin.factoryOf(F.join, id), rules: F.rules, now: F.now });
145
+ const pr = planItems.progressOf(F.fold, id);
146
+ return {
147
+ id, title: rec.title || id, lane: rec.lane, anchor: a.anchor, anchor_kind: a.kind,
148
+ horizon: p.horizon, rank: p.rank, confirmed: p.confirmed, dormant: p.dormant, check_by: p.check_by,
149
+ stage: stageOf(F.root, rec),
150
+ display: { word: d.word, stage: d.stage, waitingOn: d.waitingOn, act: d.act, idleDays: d.idleDays, line2: d.line2 },
151
+ health: { level: h.level, reasons: h.reasons },
152
+ progress: { done: pr.done, total: pr.total, label: pr.label },
153
+ next_due: nextDue(F, id),
154
+ last_activity: rec.last_activity || null,
155
+ };
156
+ }
157
+
158
+ const flagsOf = (F) => F.placed.flags.map((f) => ({ kind: f.kind, init: f.init, why: actorLib.pageName(f.why) }));
159
+
160
+ function roadmapOf(w, F) {
161
+ const rowsBy = (h) => F.placed.order[h].filter((id) => F.byId.has(id)).map((id) => rowOf(F, F.byId.get(id)));
162
+ const now = rowsBy('now');
163
+ const next = rowsBy('next');
164
+ const laterAll = rowsBy('later');
165
+ const later = laterAll.filter((r) => !r.dormant);
166
+ const dormant = laterAll.filter((r) => r.dormant);
167
+ const title = (id) => (F.byId.get(id) || {}).title || id;
168
+ return {
169
+ header: {
170
+ now: { count: now.length, kr: now.filter((r) => r.anchor_kind === 'kr' || r.anchor_kind === 'objective').length, health: now.filter((r) => r.anchor_kind === 'health').length, cap: F.rules.now_cap },
171
+ next: { count: next.length },
172
+ later: { count: later.length, dormant: dormant.length },
173
+ },
174
+ horizons: { now, next, later },
175
+ dormant,
176
+ proposals: F.placed.proposals.map((p) => ({ eid: p.eid, initiative: p.init, title: title(p.init), horizon: p.horizon, after: p.after, by: actorLib.pageName(p.by), at: p.at })),
177
+ flags: flagsOf(F),
178
+ };
179
+ }
180
+
181
+ function listOf(w, F) {
182
+ const all = Object.values(F.placed.order).flat().filter((id) => F.byId.has(id)).map((id) => rowOf(F, F.byId.get(id)));
183
+ const groups = new Map(); // objective key -> { objective, anchors: Map(anchorKey -> group) }
184
+ const objOrder = [];
185
+ for (const r of all) {
186
+ const a = F.anchors.resolve(F.byId.get(r.id).anchor);
187
+ const okey = a.objective ? `o:${a.objective.id}` : 'none';
188
+ if (!groups.has(okey)) { groups.set(okey, { objective: a.objective, anchors: new Map() }); objOrder.push(okey); }
189
+ const g = groups.get(okey);
190
+ if (!g.anchors.has(a.key)) {
191
+ const raw = String(F.byId.get(r.id).anchor || '').trim();
192
+ const m = (a.token && measurements.latest(F.meas, a.token)) || (a.anchor && measurements.latest(F.meas, a.anchor)) || (raw && measurements.latest(F.meas, raw)) || null;
193
+ g.anchors.set(a.key, { anchor: a.anchor, kind: a.kind, title: a.title, measurement: m ? { value: m.value, target: m.target, unit: m.unit, as_of: m.as_of, source: m.source } : null, rows: [] });
194
+ }
195
+ g.anchors.get(a.key).rows.push(r);
196
+ }
197
+ const byDue = (x, y) => (HORIZON_RANK[x.horizon] - HORIZON_RANK[y.horizon])
198
+ || ((x.next_due || '9999') < (y.next_due || '9999') ? -1 : (x.next_due || '9999') > (y.next_due || '9999') ? 1 : 0) || (x.rank - y.rank);
199
+ // Objectives first, in the order their rows first appear; the anchors with no objective (health, domain, none) last.
200
+ const ordered = [...objOrder.filter((k) => k !== 'none'), ...objOrder.filter((k) => k === 'none')];
201
+ return {
202
+ groups: ordered.map((k) => {
203
+ const g = groups.get(k);
204
+ return { objective: g.objective, anchors: [...g.anchors.values()].map((x) => ({ ...x, rows: x.rows.sort(byDue) })) };
205
+ }),
206
+ flags: flagsOf(F),
207
+ };
208
+ }
209
+
210
+ function sheetOf(w, F, rec) {
211
+ const root = F.root;
212
+ const row = rowOf(F, rec);
213
+ let lad = null;
214
+ try { lad = consoleState.ladder({ root, id: rec.id }); } catch (e) { lad = { ok: false, error: String((e && e.message) || e) }; }
215
+ const lay = planTree.layout({ root, initiative: rec.id, fold: F.fold, ladder: lad, now: F.now });
216
+ const stepper = lay.stages.map((s) => ({ id: s.id, label: s.label, state: s.state === 'current' ? 'current' : s.state === 'done' ? 'done' : 'todo' }));
217
+ let waiting = null;
218
+ if (row.display.waitingOn) {
219
+ waiting = { on: row.display.waitingOn, act: row.display.act, action: row.display.waitingOn === 'pm' ? { label: 'Open gate', kind: 'gate', target: null } : { label: 'Hand to agent', kind: 'handoff', target: rec.id } };
220
+ } else {
221
+ const cur = lay.stages.find((s) => s.current && s.owed);
222
+ if (cur) {
223
+ const on = cur.owed.actor === 'you' ? 'pm' : 'agent';
224
+ waiting = { on, act: cur.owed.noun, action: on === 'pm' ? { label: 'Open gate', kind: 'gate', target: cur.owed.key } : { label: 'Hand to agent', kind: 'handoff', target: rec.id } };
225
+ }
226
+ }
227
+ let fm = {};
228
+ try {
229
+ const loaded = consoleState.loadEngine();
230
+ const file = planTree.recordPath(root, rec.id);
231
+ if (loaded.ok && file) fm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {};
232
+ } catch { fm = {}; }
233
+ const files = FILE_KEYS.filter(([k]) => typeof fm[k] === 'string' && fm[k].trim()).map(([k, kind, label]) => ({ kind, label, path: String(fm[k]).trim() }));
234
+ const verb = { now: 'Moved to Now', next: 'Moved to Next', later: 'Moved to Later' };
235
+ const history = F.placed.moves.filter((m) => m.init === rec.id)
236
+ .map((m) => ({ at: m.at, by: actorLib.pageName(m.by), text: m.from === null && m.to === 'later' ? 'Added to Later' : verb[m.to] }))
237
+ .reverse();
238
+ return { initiative: row, stepper, waiting, files, history, cli: { now: `pmos initiative now ${rec.id}`, export: `pmos initiative export ${rec.id}` } };
239
+ }
240
+
241
+ // ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
242
+ function wsOf(ctx, url) {
243
+ const wsParam = url.searchParams.get('ws');
244
+ const w = ctx.getWs(wsParam);
245
+ if (!w || (wsParam && w.id !== wsParam)) return null;
246
+ return w;
247
+ }
248
+ const answer = (ctx, res, r) => ctx.json(res, { ok: false, status: r.status, error: r.error }, r.status);
249
+
250
+ /** The rowsFor answer of a repository ({ rows, sig, stale, … }), or null when an answer was already sent (pending or
251
+ * unreadable). */
252
+ function rowsOrAnswer(ctx, res, w, extra) {
253
+ const r = rowsLib.rowsFor(w.root);
254
+ if (r.pending) { ctx.json(res, { ok: false, pending: true, ws: w.id, ...extra, error: 'The initiatives are being read. Ask again in a few seconds.' }, 202); return null; }
255
+ if (!r.ok) { ctx.json(res, { ok: false, ws: w.id, ...extra, error: r.error }, 500); return null; }
256
+ return r;
257
+ }
258
+
259
+ async function readBody(ctx, req, res) {
260
+ const r = await ctx.readJson(req);
261
+ if (!r.ok) { ctx.json(res, { ok: false, status: 400, error: 'invalid JSON body' }, 400); return null; }
262
+ return r.body;
263
+ }
264
+
265
+ /** POST /api/placements: which guard, and the one-initiative rule for the PM-only acts. */
266
+ function placementGuard(req, body, root) {
267
+ const events = Array.isArray(body.events) ? body.events : null;
268
+ if (!events) return { ok: false, status: 400, error: 'send { ws, events: [ … ] }' };
269
+ const isNow = (e) => e && e.ev === 'placed' && e.horizon === 'now';
270
+ const isAdopt = (e) => e && e.ev === 'adopted';
271
+ const nowLines = events.filter(isNow);
272
+ const adoptLines = events.filter(isAdopt);
273
+ if (!nowLines.length && !adoptLines.length) return guard.requirePagePress(req, body);
274
+ if (nowLines.length && adoptLines.length) return { ok: false, status: 400, error: 'send Move to Now and adopt as two presses' };
275
+ const g = guard.requirePmPress(req, body, { act: adoptLines.length ? 'adopt' : 'now' });
276
+ if (!g.ok) return g;
277
+ // R17: Move to Now and adopt are never bulk. One initiative, and every line of the body about it.
278
+ const inits = new Set();
279
+ for (const e of events) {
280
+ if (isAdopt(e)) {
281
+ const p = placements.readPlacements(root).proposals.find((x) => x.eid === e.proposal);
282
+ inits.add(p ? p.init : `proposal:${e.proposal}`);
283
+ } else inits.add(e && e.initiative);
284
+ }
285
+ if (inits.size > 1 || adoptLines.length > 1) return { ok: false, status: 400, error: `${adoptLines.length ? 'adopt' : 'Move to Now'} takes one initiative per press; this body names ${Math.max(inits.size, adoptLines.length)}` };
286
+ return g;
287
+ }
288
+
289
+ async function handle(req, res, url, ctx) {
290
+ const p = url.pathname;
291
+ if (req.method === 'GET' && p.startsWith('/api/initiatives')) {
292
+ const w = wsOf(ctx, url);
293
+ if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
294
+ const m = /^\/api\/initiatives(?:\/([^/]+)(\/export)?)?$/.exec(p);
295
+ if (!m) { ctx.json(res, { ok: false, error: 'not found' }, 404); return true; }
296
+ const id = m[1] ? decodeURIComponent(m[1]) : null;
297
+ if (id && !planTree.INIT_RE.test(id)) { ctx.json(res, { ok: false, error: 'an initiative id must be a record name' }, 400); return true; }
298
+ if (id && m[2]) {
299
+ const got = rowsOrAnswer(ctx, res, w, { id });
300
+ if (!got) return true;
301
+ const rec = got.rows.find((r) => r.id === id && r.ok !== false);
302
+ if (!rec || rec.group !== 'work') { ctx.json(res, { ok: false, error: `no work-lane initiative ${id} in this repository` }, 404); return true; }
303
+ const r = breakdownExport.exportBreakdown(w.root, id);
304
+ if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status); return true; }
305
+ ctx.json(res, { ok: true, ws: w.id, id, markdown: r.markdown });
306
+ return true;
307
+ }
308
+ const view = id ? 'sheet' : (url.searchParams.get('view') || 'roadmap');
309
+ if (!['roadmap', 'list', 'sheet'].includes(view)) { ctx.json(res, { ok: false, error: 'view is roadmap or list' }, 400); return true; }
310
+ const now = Date.now();
311
+ const sig = sigOf(w.root); // BEFORE the reads (F2)
312
+ const got = rowsOrAnswer(ctx, res, w, id ? {} : { view });
313
+ if (!got) return true;
314
+ const rows = got.rows;
315
+ // The records part names the derivation the rows came from, not the records now: a stale answer must read
316
+ // differently from the fresh one that replaces it, or the page's watchSig never sees the derivation land.
317
+ sig.records = rowsLib.answerSig(got);
318
+ let sessions = [];
319
+ try { sessions = (ctx.sessionList ? ctx.sessionList() : []).filter((s) => s.ws === w.id); } catch { sessions = []; }
320
+ try {
321
+ const F = frameOf(w, rows, { now, sessions });
322
+ const head = { ok: true, ws: w.id };
323
+ if (id) {
324
+ const rec = F.byId.get(id);
325
+ if (!rec || !F.placed.byInit[id]) { ctx.json(res, { ok: false, error: `no work-lane initiative ${id} in this repository` }, 404); return true; }
326
+ ctx.json(res, { ...head, sig, now: new Date(now).toISOString(), ...sheetOf(w, F, rec) });
327
+ return true;
328
+ }
329
+ const body = view === 'list' ? listOf(w, F) : roadmapOf(w, F);
330
+ ctx.json(res, { ...head, view, sig, now: new Date(now).toISOString(), rules: F.rules, ...body });
331
+ } catch (e) {
332
+ ctx.json(res, { ok: false, error: 'the initiatives could not be read: ' + String((e && e.message) || e).slice(0, 160) }, 500);
333
+ }
334
+ return true;
335
+ }
336
+
337
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'GET or POST only' }, 405); return true; }
338
+ const body = await readBody(ctx, req, res);
339
+ if (!body) return true;
340
+ if (typeof body !== 'object' || Array.isArray(body)) { ctx.json(res, { ok: false, status: 400, error: 'the body must be a JSON object' }, 400); return true; }
341
+ const wsParam = typeof body.ws === 'string' ? body.ws : null;
342
+ const w = ctx.getWs(wsParam);
343
+ if (!w || (wsParam && w.id !== wsParam)) { ctx.json(res, { ok: false, status: 404, error: 'no such workspace' }, 404); return true; }
344
+
345
+ if (p === '/api/initiatives') {
346
+ const g = guard.requirePagePress(req, body);
347
+ if (!g.ok) return answer(ctx, res, g), true;
348
+ const { ws: _ws, ...input } = body;
349
+ const r = writer.createInitiative(w.root, input, { actor: g.actor });
350
+ if (!r.ok) { ctx.json(res, { ok: false, status: r.status, error: r.error, field: r.field }, r.status); return true; }
351
+ rowsLib.invalidate(w.root);
352
+ if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
353
+ ctx.json(res, { ok: true, id: r.id, path: r.path, written: r.written, sig: sigOf(w.root) });
354
+ return true;
355
+ }
356
+ if (p === '/api/placements' || p === '/api/measurements') {
357
+ const g = p === '/api/placements' ? placementGuard(req, body, w.root) : guard.requirePagePress(req, body);
358
+ if (!g.ok) return answer(ctx, res, g), true;
359
+ if (!Array.isArray(body.events)) { ctx.json(res, { ok: false, status: 400, error: 'send { ws, events: [ … ] }' }, 400); return true; }
360
+ const lib = p === '/api/placements' ? placements : measurements;
361
+ const r = lib.appendFromUi(w.root, body.events, { actor: g.actor });
362
+ if (!r.ok) { ctx.json(res, { ok: false, status: r.status, error: r.error, index: r.index }, r.status); return true; }
363
+ if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
364
+ ctx.json(res, { ok: true, written: r.written, sig: sigOf(w.root) });
365
+ return true;
366
+ }
367
+ ctx.json(res, { ok: false, error: 'not found' }, 404);
368
+ return true;
369
+ }
370
+
371
+ module.exports = { prefixes: ['/api/initiatives', '/api/placements', '/api/measurements'], handle, sigOf, rowOf, frameOf, roadmapOf, listOf, sheetOf };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ /* routes-reports.js — the Reports destination's HTTP routes (pm-os-absorb Run 5, slice 5C; PRD R9, R22.5, R25).
3
+ *
4
+ * GET /api/report?ws= the report files of the repository, drafts and issued, of every cadence,
5
+ * with the `sig` the page watches (public/pm/sig-watch.js, 10 s)
6
+ * GET /api/report/draft?ws=&cadence=&slot= one report file as it is now: its markdown, its note texts, its blob sha
7
+ * POST /api/report/draft { ws, cadence, slot?, redraft? } Draft now, and Redraft (the notes are kept)
8
+ * POST /api/report/note { ws, cadence, slot, id, text } one note, by initiative id (or report.headline, report.asks)
9
+ * POST /api/report/issue { ws, cadence, slot, sha } Issue: the PM's act (R25)
10
+ *
11
+ * WHICH GUARD. A draft and a note are page writes, not PM-only acts: both call pm-guard.js requirePagePress. Issue is a
12
+ * PM-only act (D86 point 2) and calls requirePmPress with act 'issue', then issues.appendFromUi with the guard's actor.
13
+ * Nothing here checks an Origin or a nonce by hand, and nothing reads `by` from a body: the writers take the actor from
14
+ * the guard. Every `by` in an answer goes through actor.pageName, so a session token never reaches the page (F5).
15
+ *
16
+ * THE FILE IS WRITTEN IN ONE PLACE. Only report-draft.js writeDraft writes a report file. A note save is a Redraft with
17
+ * one note changed, folded at the same `now` as the draft it edits (the draft's own `timestamp`). That pins the window and
18
+ * the lines bound to it. It does not freeze a value the drafter reads from the repository as it is now (a stage, a health),
19
+ * so a note save can move such a value, and the page says so (issues/run5-c.md, 5A).
20
+ * Issue freezes the bytes the PM read: the body carries the sha the page was shown, and the writer refuses a file that
21
+ * changed since (409).
22
+ *
23
+ * Nothing here sends a report anywhere. There is no route that emails, posts or shares one.
24
+ */
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const guard = require('./pm-guard.js');
28
+ const actorLib = require('./actor.js');
29
+ const RD = require('./report-draft.js');
30
+ const issues = require('./issues.js');
31
+ const RN = require('./report-needs.js');
32
+
33
+ const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
34
+ const SLOT_RE = /^\d{4}-\d{2}-\d{2}$/;
35
+ const NOTE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
36
+ const SHA_RE = /^[0-9a-f]{40}$/;
37
+ const MAX_NOTE = 20000;
38
+
39
+ const prefixes = ['/api/report'];
40
+
41
+ const refuse = (ctx, res, status, error) => { ctx.json(res, { ok: false, error }, status); return true; };
42
+ /** The registered workspace named by `ws`. An unknown id is a 404: getWs would answer the home workspace instead. */
43
+ const wsFrom = (ctx, id) => { const w = ctx.getWs(id || null); return w && (!id || w.id === id) ? w : null; };
44
+
45
+ /** A report as the page sees it: the session token never leaves, and the sort key and file time stay here. */
46
+ function pageReport(r) {
47
+ return {
48
+ cadence: r.cadence, slot: r.slot, path: r.path, status: r.status, bytes: r.bytes, edited: r.edited,
49
+ issued: r.issued ? { at: r.issued.at, by: actorLib.pageName(r.issued.by), via: r.issued.via, sha: r.issued.sha } : null,
50
+ };
51
+ }
52
+ /** A flag names the line it is about, and 5A's text carries that line's `by`: the session token is cut there too (F5). */
53
+ const pageText = (s) => String(s == null ? '' : s).replace(/agent:sess-\d+\.[0-9a-f]{8}/g, (m) => actorLib.pageName(m));
54
+ const pageFlags = (flags) => (flags || []).map((f) => ({ kind: f.kind, why: pageText(f.why) }));
55
+
56
+ function findReport(list, cadence, slot) { return list.reports.find((x) => x.cadence === cadence && x.slot === slot) || null; }
57
+
58
+ function readFile(root, rel) {
59
+ try { const buf = fs.readFileSync(path.join(path.resolve(String(root)), rel)); return { buf, text: buf.toString('utf8') }; } catch { return null; }
60
+ }
61
+
62
+ /** The time a draft was folded at: its frontmatter `timestamp` (report-draft.js writes the window's end there). */
63
+ function foldedAt(text) {
64
+ const m = /^---\r?\n[\s\S]*?\r?\ntimestamp:\s*(\S+)[\s\S]*?\r?\n---/.exec(String(text || ''));
65
+ return m && Number.isFinite(Date.parse(m[1])) ? m[1] : null;
66
+ }
67
+
68
+ const cadenceSlot = (src) => {
69
+ const cadence = String(src.cadence || '');
70
+ const slot = src.slot == null ? null : String(src.slot);
71
+ if (!CADENCE_RE.test(cadence)) return { error: 'cadence must be a report id such as biweekly' };
72
+ if (slot !== null && !SLOT_RE.test(slot)) return { error: 'slot must be a date, YYYY-MM-DD' };
73
+ return { cadence, slot };
74
+ };
75
+
76
+ async function handle(req, res, url, ctx) {
77
+ const p = url.pathname;
78
+ if (p !== '/api/report' && p !== '/api/report/draft' && p !== '/api/report/note' && p !== '/api/report/issue') return refuse(ctx, res, 404, 'no such report route');
79
+
80
+ /* ── reads ─────────────────────────────────────────────────────────────────────────────────────────────────── */
81
+ if (req.method === 'GET') {
82
+ if (p === '/api/report/note' || p === '/api/report/issue') return refuse(ctx, res, 405, `${p} takes POST`);
83
+ const w = wsFrom(ctx, url.searchParams.get('ws'));
84
+ if (!w) return refuse(ctx, res, 404, 'no such workspace');
85
+ let list;
86
+ try { list = RN.reportsOf(w.root); } catch (e) { return refuse(ctx, res, 500, 'the reports could not be read: ' + String((e && e.message) || e).slice(0, 160)); }
87
+ if (p === '/api/report') {
88
+ ctx.json(res, { ok: true, ws: w.id, sig: list.sig, reports: list.reports.map(pageReport), flags: pageFlags(list.flags) });
89
+ return true;
90
+ }
91
+ const cs = cadenceSlot({ cadence: url.searchParams.get('cadence'), slot: url.searchParams.get('slot') });
92
+ if (cs.error || !cs.slot) return refuse(ctx, res, 400, cs.error || 'slot must be a date, YYYY-MM-DD');
93
+ const hit = findReport(list, cs.cadence, cs.slot);
94
+ if (!hit) { ctx.json(res, { ok: true, ws: w.id, sig: list.sig, cadence: cs.cadence, slot: cs.slot, exists: false, flags: pageFlags(list.flags) }); return true; }
95
+ const file = readFile(w.root, hit.path);
96
+ if (!file) return refuse(ctx, res, 404, `${hit.path} could not be read`);
97
+ ctx.json(res, {
98
+ ok: true, ws: w.id, sig: list.sig, cadence: cs.cadence, slot: cs.slot, exists: true, path: hit.path, status: hit.status,
99
+ sha: RN.blobSha(file.buf), markdown: file.text, notes: RD.readNotes(file.text), issued: pageReport(hit).issued, edited: hit.edited, flags: pageFlags(list.flags),
100
+ });
101
+ return true;
102
+ }
103
+ if (req.method !== 'POST') return refuse(ctx, res, 405, `${p} takes GET or POST`);
104
+ if (p === '/api/report') return refuse(ctx, res, 405, '/api/report takes GET');
105
+
106
+ /* ── writes: the guard first, then the one writer ───────────────────────────────────────────────────────────── */
107
+ const read = await ctx.readJson(req);
108
+ if (!read.ok) return refuse(ctx, res, 400, 'invalid JSON body');
109
+ const body = read.body == null ? {} : read.body;
110
+ const g = p === '/api/report/issue' ? guard.requirePmPress(req, body, { act: 'issue' }) : guard.requirePagePress(req, body);
111
+ if (!g.ok) { ctx.json(res, g, g.status); return true; }
112
+ const w = body.ws ? wsFrom(ctx, String(body.ws)) : null;
113
+ if (!w) return refuse(ctx, res, body.ws ? 404 : 400, body.ws ? 'no such workspace' : 'ws is required');
114
+ const cs = cadenceSlot(body);
115
+ if (cs.error) return refuse(ctx, res, 400, cs.error);
116
+ const root = w.root;
117
+ const done = (extra) => { ctx.invalidateNeedsAgg(); ctx.json(res, { ok: true, ws: w.id, cadence: cs.cadence, ...extra, sig: RN.reportsOf(root).sig }); return true; };
118
+ const failed = (r) => { ctx.json(res, { ok: false, error: r.error || 'refused', index: r.index == null ? null : r.index }, r.status || 400); return true; };
119
+
120
+ if (p === '/api/report/draft') {
121
+ // Draft now, or Redraft. A Redraft keeps every note, byte for byte, by the initiative id it was written under.
122
+ const draft = RD.draftOf(root, { cadence: cs.cadence, slot: cs.slot || undefined });
123
+ if (!draft || draft.ok === false) return failed(draft || { status: 500, error: 'the drafter answered nothing' });
124
+ let notes;
125
+ if (body.redraft === true) {
126
+ const old = findReport(RN.reportsOf(root), cs.cadence, draft.slot);
127
+ const file = old ? readFile(root, old.path) : null;
128
+ notes = file ? RD.readNotes(file.text) : {};
129
+ }
130
+ const r = RD.writeDraft(root, draft, { actor: g.actor, notes, fresh: body.redraft !== true });
131
+ if (!r || r.ok === false) return failed(r || { status: 500, error: 'the writer answered nothing' });
132
+ return done({ slot: draft.slot, path: r.path, sha: r.sha });
133
+ }
134
+
135
+ if (p === '/api/report/note') {
136
+ if (!cs.slot) return refuse(ctx, res, 400, 'slot must be a date, YYYY-MM-DD');
137
+ const id = String(body.id || '');
138
+ if (!NOTE_ID_RE.test(id)) return refuse(ctx, res, 400, 'id must be an initiative id, report.headline or report.asks');
139
+ if (typeof body.text !== 'string') return refuse(ctx, res, 400, 'text must be a string');
140
+ if (body.text.length > MAX_NOTE) return refuse(ctx, res, 413, `a note is at most ${MAX_NOTE} characters`);
141
+ if (/<!--\s*\/?pmos:note/.test(body.text)) return refuse(ctx, res, 400, 'a note cannot hold a note marker');
142
+ const old = findReport(RN.reportsOf(root), cs.cadence, cs.slot);
143
+ const file = old ? readFile(root, old.path) : null;
144
+ if (!file) return refuse(ctx, res, 404, `there is no ${cs.cadence} draft for ${cs.slot}; press Draft now first`);
145
+ const notes = { ...RD.readNotes(file.text), [id]: body.text };
146
+ const draft = RD.draftOf(root, { cadence: cs.cadence, slot: cs.slot, now: foldedAt(file.text) || undefined });
147
+ if (!draft || draft.ok === false) return failed(draft || { status: 500, error: 'the drafter answered nothing' });
148
+ const r = RD.writeDraft(root, draft, { actor: g.actor, notes });
149
+ if (!r || r.ok === false) return failed(r || { status: 500, error: 'the writer answered nothing' });
150
+ return done({ slot: cs.slot, path: r.path, sha: r.sha, id });
151
+ }
152
+
153
+ // Issue. The path comes from the listing, never from the body; the sha is the one the page showed the PM.
154
+ if (!cs.slot) return refuse(ctx, res, 400, 'slot must be a date, YYYY-MM-DD');
155
+ if (typeof body.sha !== 'string' || !SHA_RE.test(body.sha)) return refuse(ctx, res, 400, 'sha must be the 40-character sha of the report as it was read');
156
+ const hit = findReport(RN.reportsOf(root), cs.cadence, cs.slot);
157
+ if (!hit) return refuse(ctx, res, 404, `there is no ${cs.cadence} report for ${cs.slot}; nothing was issued`);
158
+ const r = issues.appendFromUi(root, [{ ev: 'issued', cadence: cs.cadence, slot: cs.slot, path: hit.path, sha: body.sha }], { actor: g.actor });
159
+ if (!r || r.ok === false) return failed(r || { status: 500, error: 'the writer answered nothing' });
160
+ return done({ slot: cs.slot, path: hit.path, written: (r.written || []).map((x) => ({ cadence: x.cadence, slot: x.slot, path: x.path, sha: x.sha, at: x.at })) });
161
+ }
162
+
163
+ module.exports = { prefixes, handle };