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,206 @@
1
+ 'use strict';
2
+ /* initiative-rows.js — the workflow rows of every initiative record, and what each anchor names (pm-os-absorb Run 2,
3
+ * slice 2C: the Initiatives API, `pmos initiative` and initiative-needs read them from here).
4
+ *
5
+ * deriveSync(root) → { ok, rows } | { ok: false, error } every record, derived now (the CLI, the tests)
6
+ * rowsFor(root) → { pending: true } | { ok, rows, sig, as_of, stale } | { ok: false, error }
7
+ * the request path: never derives in the server process (see below)
8
+ * peek(root) → the same answer from the cache alone; it never starts a derivation. The needs provider
9
+ * reads this, so the Today poll never pays for a derivation: only a read of the
10
+ * Initiatives API (rowsFor) starts one.
11
+ * answerSig(r) → the records part of a GET's sig, from the answer used (a stale answer is marked)
12
+ * cachedSig(root) → the cache entry as a string; it changes each time a derivation lands
13
+ * recordsSig(root) → sha1 of lib/board-cache.js signature (HEAD, the records, lifecycle and acceptances)
14
+ * anchorsOf(root) → { resolve(raw) → anchor info, period, error }
15
+ * workGroupOf(root) → { [lane]: group } from scripts/lane-defs.js through the engine (never a list here)
16
+ *
17
+ * A ROW is one line of `node scripts/workflow-state.js --json` (every record, uncapped), narrowed to what the planning
18
+ * layer reads: { ok, id, title, lane, group, anchor, stage, accepted, stopped, last_activity (ISO), profile, record,
19
+ * next }. `stopped` is the lifecycle fold ({ state, reason, at }) or null.
20
+ *
21
+ * WHY A CHILD PROCESS. The engine resolves each artifact's first appearance by running git: about 260 ms per record,
22
+ * so a repository of 100 records takes half a minute (lib/board-cache.js measured it). The board cache is capped at
23
+ * the in-flight rows and has no last activity, so it cannot answer the Roadmap. rowsFor() runs the engine's own CLI in
24
+ * a child, keyed by the board cache's signature, and answers `pending` until the first derivation lands, like the
25
+ * board cache does. The CLI and the tests call deriveSync(), the same command, synchronously.
26
+ */
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+ const { spawn, execFileSync } = require('child_process');
31
+ const consoleState = require('./console-state');
32
+ const boardCache = require('./board-cache');
33
+
34
+ const MAX_AGE_MS = 10 * 60 * 1000;
35
+ const TIMEOUT_MS = 180000;
36
+ const cache = new Map(); // root -> { sig, at, value, error }
37
+ const inflight = new Map(); // root -> true
38
+ let background = true; // false in a one-shot process (`pmos today` with no server): nothing would read the answer
39
+
40
+ function enginePath() {
41
+ const loaded = consoleState.loadEngine();
42
+ if (!loaded.ok) throw new Error(loaded.error);
43
+ return loaded;
44
+ }
45
+
46
+ /** { [lane]: 'work' | 'document' | … } for the repository's profile. */
47
+ function workGroupOf(root) {
48
+ const { engine } = enginePath();
49
+ const { defs } = engine.lanesFor(root);
50
+ const out = {};
51
+ for (const [lane, d] of Object.entries(defs || {})) out[lane] = (d && d.group) || null;
52
+ return out;
53
+ }
54
+
55
+ const iso = (v) => {
56
+ if (typeof v === 'number' && Number.isFinite(v)) return new Date(v < 1e11 ? v * 1000 : v).toISOString();
57
+ const t = Date.parse(String(v || ''));
58
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
59
+ };
60
+
61
+ /** The engine's JSON rows → the narrowed rows, each with its lane group. */
62
+ function narrow(root, raw) {
63
+ let groups = {};
64
+ try { groups = workGroupOf(root); } catch { groups = {}; }
65
+ return (Array.isArray(raw) ? raw : []).map((r) => (r && r.ok !== false ? {
66
+ ok: true, id: r.id, title: r.title || r.id, lane: r.lane, group: groups[r.lane] || null, anchor: r.anchor || null,
67
+ stage: r.stage || null, accepted: !!r.accepted,
68
+ stopped: r.stopped ? { state: r.stopped.state, reason: r.stopped.reason || '', at: r.stopped.at || null } : null,
69
+ last_activity: iso(r.last_activity), profile: r.profile || null, record: r.record || null, next: r.next || null,
70
+ } : { ok: false, id: r && r.id, error: (r && r.error) || 'the row could not be derived' }));
71
+ }
72
+
73
+ function deriveSync(root) {
74
+ try {
75
+ const { from } = enginePath();
76
+ const out = execFileSync(process.execPath, [from, '--json', '--root', root], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], timeout: TIMEOUT_MS });
77
+ return { ok: true, rows: narrow(root, JSON.parse(out || '[]')) };
78
+ } catch (e) {
79
+ return { ok: false, error: 'the initiative records could not be read: ' + String((e && e.message) || e).slice(0, 160) };
80
+ }
81
+ }
82
+
83
+ const sha1 = (s) => crypto.createHash('sha1').update(String(s)).digest('hex');
84
+ function recordsSig(root) {
85
+ try { return sha1(boardCache.signature(root)); } catch { return sha1('unreadable'); }
86
+ }
87
+
88
+ function refresh(root, sig) {
89
+ if (inflight.get(root)) return;
90
+ inflight.set(root, true);
91
+ let child;
92
+ let out = '';
93
+ const done = (entry) => {
94
+ inflight.delete(root);
95
+ // a failed re-derivation keeps the last good answer (it is stale, so the next read tries again): rows read from it
96
+ // must not vanish from Today for one poll and come back on the next
97
+ const prev = cache.get(root);
98
+ if (entry.error && prev && prev.value) return;
99
+ cache.set(root, { sig, at: Date.now(), ...entry });
100
+ };
101
+ try {
102
+ const { from } = enginePath();
103
+ child = spawn(process.execPath, [from, '--json', '--root', root], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] });
104
+ } catch (e) { done({ value: null, error: 'could not start reading the initiative records: ' + String((e && e.message) || e).slice(0, 120) }); return; }
105
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, TIMEOUT_MS);
106
+ if (timer.unref) timer.unref();
107
+ // the child never holds its parent open: `pmos today` reads the cache and exits, whatever is still being derived
108
+ try { child.unref(); if (child.stdout && child.stdout.unref) child.stdout.unref(); } catch { /* still runs */ }
109
+ child.stdout.on('data', (c) => { out += c; });
110
+ child.on('error', (e) => { clearTimeout(timer); done({ value: null, error: 'reading the initiative records failed: ' + (e.code || e.message) }); });
111
+ child.on('close', (code) => {
112
+ clearTimeout(timer);
113
+ if (code !== 0 && !out) { done({ value: null, error: 'reading the initiative records stopped without a result' }); return; }
114
+ try { done({ value: narrow(root, JSON.parse(out || '[]')), error: null }); }
115
+ catch { done({ value: null, error: 'the initiative records came back unreadable' }); }
116
+ });
117
+ }
118
+
119
+ /** The request path's read. Always returns at once; see the header. */
120
+ function rowsFor(root) { return read(root, true); }
121
+ /** Whether a read may start a background derivation. `pmos today` without a server turns it off: the process exits
122
+ * before the child would land, so the child is pure cost. Returns the previous setting. */
123
+ function setBackground(on) { const was = background; background = !!on; return was; }
124
+ /** Whether a background derivation of `root` is running now. */
125
+ function isDeriving(root) { return !!inflight.get(root); }
126
+ /** Derive `root` now, synchronously, into the cache (`pmos today --wait`): about 260 ms per record. */
127
+ function deriveNow(root) {
128
+ const sig = recordsSig(root);
129
+ const d = deriveSync(root);
130
+ cache.set(root, { sig, at: Date.now(), value: d.ok ? d.rows : null, error: d.ok ? null : d.error });
131
+ return d;
132
+ }
133
+ /** The same answer from the cache alone: it never starts a derivation (the needs provider; see peek in the header). */
134
+ function peek(root) { return read(root, false); }
135
+ function read(root, derive) {
136
+ const sig = recordsSig(root);
137
+ const hit = cache.get(root);
138
+ const fresh = hit && hit.sig === sig && Date.now() - hit.at < MAX_AGE_MS;
139
+ if (!fresh && derive && background) refresh(root, sig);
140
+ if (!hit) return { pending: true };
141
+ if (hit.error) return { ok: false, error: hit.error };
142
+ return { ok: true, rows: hit.value, sig: hit.sig, as_of: new Date(hit.at).toISOString(), stale: !fresh };
143
+ }
144
+ /** The records part of a GET's `sig`, from the answer actually used: a stale answer reads differently from the fresh
145
+ * one that replaces it, so a poll sees the derivation land even when the records did not change again. */
146
+ function answerSig(r) {
147
+ if (!r || r.pending) return 'pending';
148
+ if (!r.ok) return 'error';
149
+ return r.stale ? `${r.sig}~stale` : r.sig;
150
+ }
151
+ /** The cache entry of a repository as a string: it changes each time a derivation lands. Starts nothing. */
152
+ function cachedSig(root) {
153
+ const hit = cache.get(root);
154
+ return hit ? `${hit.sig}@${hit.at}${hit.error ? '!' : ''}` : 'none';
155
+ }
156
+ /** Test and maintenance seam. */
157
+ function invalidate(root) { if (root) { cache.delete(root); inflight.delete(root); } else { cache.clear(); inflight.clear(); } }
158
+
159
+ // ── anchors ──────────────────────────────────────────────────────────────────────────────────────
160
+ function anchorLib(from) {
161
+ try { return require(path.join(path.dirname(from), 'anchor-resolve.js')); } catch { return null; }
162
+ }
163
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
164
+ /** "q4-2026" → "Q4 2026". */
165
+ const periodLabel = (p) => { const m = /^(q[1-4])-(\d{4})$/i.exec(String(p || '')); return m ? `${m[1].toUpperCase()} ${m[2]}` : (p || null); };
166
+
167
+ /**
168
+ * What each anchor names, read from the OKR files and the health budgets (scripts/anchor-resolve.js, the gate's own
169
+ * resolver). resolve(raw) → { anchor, kind: 'kr'|'objective'|'health'|'domain'|'none', key, objective: { id, title,
170
+ * period } | null, title, token }. `key` groups two spellings of one KR together. Never throws.
171
+ */
172
+ function anchorsOf(root) {
173
+ let from = null; let engine = null;
174
+ try { ({ from, engine } = enginePath()); } catch { /* no engine: every anchor reads as written */ }
175
+ const A = from ? anchorLib(from) : null;
176
+ let index = null; let budgets = null; let error = null;
177
+ if (A) {
178
+ try { index = A.parseOkrIndex(root); } catch (e) { error = String((e && e.message) || e); }
179
+ try {
180
+ const file = engine.layout(root).budgets;
181
+ budgets = { ok: true, text: fs.readFileSync(file, 'utf8'), path: path.relative(root, file) };
182
+ } catch { budgets = A.readBudgets(root); }
183
+ } else error = 'the anchor resolver is not present in this install';
184
+ const period = index && index.ok ? periodLabel(index.period) : null;
185
+ function resolve(raw) {
186
+ const text = raw == null ? '' : String(raw).trim();
187
+ if (!text) return { anchor: null, kind: 'none', key: 'none', objective: null, title: 'No anchor', token: null };
188
+ const r = A ? A.resolveAnchor(text, index, budgets) : null;
189
+ if (r && r.kind === 'health') return { anchor: `health:${r.budget}`, kind: 'health', key: `health:${r.budget}`, objective: null, title: `${cap(r.budget)} health`, token: `health:${r.budget}`, status: r.status };
190
+ if (r && r.kind === 'domain') return { anchor: `domain:${r.domain}`, kind: 'domain', key: `domain:${r.domain}`, objective: null, title: r.domain_goal || `${cap(r.domain)} domain`, token: `domain:${r.domain}`, status: r.status };
191
+ if (r && r.kind === 'okr' && r.status === 'resolved' && index && index.ok) {
192
+ const o = index.objectives.find((x) => x.id === r.objective);
193
+ const objective = o ? { id: o.token, title: o.name || o.token, period } : null;
194
+ if (r.kr) {
195
+ const k = o ? o.krs.find((x) => x.id === r.kr) : null;
196
+ return { anchor: k ? k.token : text, kind: 'kr', key: `${r.objective}::${r.kr}`, objective, title: (k && k.name) || text, token: k ? k.token : text, status: r.status };
197
+ }
198
+ return { anchor: o ? o.token : text, kind: 'objective', key: r.objective, objective, title: (o && o.name) || text, token: o ? o.token : text, status: r.status };
199
+ }
200
+ // Written but not resolved (or no resolver): shown as written, in a group of its own, never dropped.
201
+ return { anchor: text, kind: /^health:/i.test(text) ? 'health' : 'kr', key: `raw:${text}`, objective: null, title: text, token: text, status: r ? r.status : 'unknown' };
202
+ }
203
+ return { resolve, period, error, ok: !!(index && index.ok) };
204
+ }
205
+
206
+ module.exports = { deriveSync, rowsFor, peek, setBackground, isDeriving, deriveNow, answerSig, cachedSig, recordsSig, invalidate, anchorsOf, workGroupOf, periodLabel, narrow };
@@ -0,0 +1,141 @@
1
+ 'use strict';
2
+ /* initiative-writer.js — THE ONE WRITER of a new initiative record (pm-os-absorb Run 2, PRD R16, R23; D59).
3
+ *
4
+ * createInitiative(root, input, { actor, now }) → { ok: true, id, path, written }
5
+ * | { ok: false, status, error, field }
6
+ * input = { title, lane, anchor, id?, intent?, specimen_test?, surface_check?, from? }
7
+ * slugOf(title) → the id a title gives
8
+ *
9
+ * THREE CALLERS: the page's New initiative form (POST /api/initiatives, lib/routes-initiatives.js, behind
10
+ * pm-guard.requirePagePress), `pmos initiative new` (lib/initiative-cli.js, actor.fromCli) and the triage card's Add
11
+ * (the same route, `from: capture-…`). Agents reach it only through `pmos initiative new`. Every require of this file
12
+ * is spelled with `.js`, so scripts/check-separation.js lists each caller; pmos/graph-membership.json names it as the
13
+ * writer of `planning/initiatives/`.
14
+ *
15
+ * WHAT IT WRITES. One file, `<initiatives>/<id>.md` in the tree of `root` (planning/initiatives/ for PMOS-self,
16
+ * pmos/state/initiatives/ in a kit repository, from the engine's layout): the frontmatter the gates read (`type`,
17
+ * `title`, `timestamp`, `lane`, `parent_kr`, and `status` — the chore lane's intent — plus the router's
18
+ * `specimen_test` and `surface_check` when the caller has them). No new frontmatter field (R24). Then ONE `created`
19
+ * line in placements.jsonl (lib/placements.js): the initiative lands at the top of Later, whoever made it (R16, R23).
20
+ * The record is not committed: a commit is the caller's (the PM's) act.
21
+ *
22
+ * WHAT IT REFUSES, whoever calls:
23
+ * - no anchor, or an anchor that the OKR files and the health budgets do not declare (400; D59: unanchored work is
24
+ * rejected, and the gate's anchor rung would reject it later anyway);
25
+ * - a lane that is not a WORK lane of the repository's profile (400; D86 point 4: document lanes are files);
26
+ * - a chore with no intent (400; the Initiative Gate reads it from `status:`);
27
+ * - an id that is not `^[a-z0-9][a-z0-9._-]*$`, or that contains `..` (400; the gate's own rule), or a record that
28
+ * exists already (409);
29
+ * - a writer field (`by`, `via`, `at`, …) in the input (400), and a call without an actor (500).
30
+ * The file is opened O_CREAT|O_EXCL|O_NOFOLLOW inside the resolved initiatives folder, so an existing file or a
31
+ * symlink is never written through. When the placement line cannot be written, the new record is removed again and
32
+ * the refusal is returned: a record never lands without its place in Later.
33
+ */
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const consoleState = require('./console-state');
37
+ const placements = require('./placements.js');
38
+ const rows = require('./initiative-rows.js');
39
+ const actorLib = require('./actor.js');
40
+
41
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
42
+ const MAX_ID = 60;
43
+ const WRITER_SET = Object.freeze(['v', 'eid', 'by', 'via', 'at', 'override', 'pm', 'actor', 'nonce', 'timestamp', 'type']);
44
+ const INPUT_KEYS = Object.freeze(['id', 'title', 'lane', 'anchor', 'intent', 'specimen_test', 'surface_check', 'from']);
45
+ const ONE_LINE = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
46
+
47
+ const refuse = (status, error, field) => ({ ok: false, status, error, field: field || null });
48
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
49
+ /** One YAML double-quoted scalar on one line. */
50
+ // A value that is a plain YAML scalar as written: no `: ` or ` #`, no quote, bracket, brace or other indicator at the
51
+ // start, no quote anywhere. The resolver accepts text after a declared anchor, so anything else is written quoted.
52
+ const PLAIN = (s) => !/: |:$| #|["\\]/.test(s) && !/^[-?:,[\]{}#&*!|>'%@`]/.test(s);
53
+ const q = (s) => '"' + String(s).trim().replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
54
+
55
+ function slugOf(title) {
56
+ return String(title || '').toLowerCase().normalize('NFKD').replace(/[^\x00-\x7f]/g, '')
57
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, MAX_ID).replace(/-+$/, '');
58
+ }
59
+
60
+ function createInitiative(root, input, { actor, now } = {}) {
61
+ if (!actorLib.valid(actor)) return refuse(500, 'initiative-writer: the caller must pass the actor from pm-guard.js or actor.js');
62
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return refuse(400, 'the initiative must be a JSON object');
63
+ const sent = WRITER_SET.find((k) => has(input, k));
64
+ if (sent) return refuse(400, `the writer sets "${sent}"; do not send it`, sent);
65
+ const extra = Object.keys(input).filter((k) => !INPUT_KEYS.includes(k));
66
+ if (extra.length) return refuse(400, `an initiative has no field ${extra.join(', ')}`, extra[0]);
67
+
68
+ if (!ONE_LINE(input.title, 120)) return refuse(400, 'give a title of 1 to 120 characters on one line', 'title');
69
+ const title = input.title.trim();
70
+ const anchorText = typeof input.anchor === 'string' ? input.anchor.trim() : '';
71
+ if (!anchorText) return refuse(400, 'an initiative needs an anchor: a KR, or health:<id> from the health budgets (D59)', 'anchor');
72
+ if (!ONE_LINE(anchorText, 120)) return refuse(400, 'the anchor must be one line', 'anchor');
73
+
74
+ const loaded = consoleState.loadEngine();
75
+ if (!loaded.ok) return refuse(500, loaded.error);
76
+ const engine = loaded.engine;
77
+ let groups;
78
+ try { groups = rows.workGroupOf(root); } catch (e) { return refuse(500, 'the lanes could not be read: ' + String((e && e.message) || e).slice(0, 120)); }
79
+ const lane = typeof input.lane === 'string' ? input.lane.trim() : '';
80
+ if (!has(groups, lane)) return refuse(400, `lane must be one of ${Object.keys(groups).filter((l) => groups[l] === 'work').join(', ')}`, 'lane');
81
+ if (groups[lane] !== 'work') return refuse(400, `${lane} is a document lane: a document is a file, not an initiative row (D86)`, 'lane');
82
+
83
+ const anchor = rows.anchorsOf(root).resolve(anchorText);
84
+ if (anchor.status !== 'resolved') return refuse(400, `the anchor "${anchorText}" is not declared in the OKR files or the health budgets (D59)`, 'anchor');
85
+
86
+ const intent = has(input, 'intent') && input.intent != null ? String(input.intent) : '';
87
+ if (intent && !ONE_LINE(intent, 400)) return refuse(400, 'the intent must be one paragraph of at most 400 characters', 'intent');
88
+ if (lane === 'chore' && !intent.trim()) return refuse(400, 'a chore needs a one-paragraph intent', 'intent');
89
+ for (const k of ['specimen_test', 'surface_check']) {
90
+ if (has(input, k) && input[k] != null && !ONE_LINE(input[k], 200)) return refuse(400, `${k} must be one line of at most 200 characters`, k);
91
+ }
92
+ if (has(input, 'from') && input.from != null && !(typeof input.from === 'string' && placements.CAPTURE_RE.test(input.from))) {
93
+ return refuse(400, 'from must name a capture note, like capture-2026-10-04-1', 'from');
94
+ }
95
+
96
+ const id = has(input, 'id') && input.id != null && input.id !== '' ? String(input.id) : slugOf(title);
97
+ if (!id || !ID_RE.test(id) || id.includes('..') || id.length > MAX_ID || id === 'readme' || id === 'index') {
98
+ return refuse(400, 'the id must be lower-case letters, digits, dot, underscore or hyphen, start with a letter or digit, and be at most 60 characters', 'id');
99
+ }
100
+
101
+ let dir;
102
+ try { dir = engine.layout(root).initiatives; fs.mkdirSync(dir, { recursive: true }); }
103
+ catch { return refuse(500, 'the initiatives folder could not be made — nothing was saved'); }
104
+ let realDir;
105
+ try { realDir = fs.realpathSync(dir); } catch { return refuse(500, 'the initiatives folder could not be resolved — nothing was saved'); }
106
+ const realRoot = fs.realpathSync(path.resolve(root));
107
+ if (realDir !== realRoot && !realDir.startsWith(realRoot + path.sep)) return refuse(500, 'the initiatives folder resolves outside the repository — nothing was saved');
108
+ const file = path.join(realDir, `${id}.md`);
109
+
110
+ const at = new Date(Number.isFinite(now) ? now : Date.now()).toISOString().replace(/\.\d{3}Z$/, 'Z');
111
+ const fm = ['---', 'type: Initiative', `title: ${q(title)}`, `timestamp: ${at}`, `lane: ${lane}`, `parent_kr: ${PLAIN(anchorText) ? anchorText : q(anchorText)}`];
112
+ if (intent.trim()) fm.push(`status: ${q(intent)}`);
113
+ if (input.specimen_test) fm.push(`specimen_test: ${q(input.specimen_test)}`);
114
+ if (input.surface_check) fm.push(`surface_check: ${q(input.surface_check)}`);
115
+ fm.push('---', '', `# ${title}`, '');
116
+ if (intent.trim()) fm.push(intent.trim(), '');
117
+ const text = fm.join('\n');
118
+
119
+ let fd;
120
+ try { fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o644); }
121
+ catch (e) {
122
+ if (e && e.code === 'EEXIST') return refuse(409, `an initiative ${id} exists already`, 'id');
123
+ return refuse(500, 'the record could not be written — nothing was saved');
124
+ }
125
+ try { fs.writeSync(fd, text); } catch {
126
+ try { fs.closeSync(fd); fs.unlinkSync(file); } catch { /* best effort */ }
127
+ return refuse(500, 'the record could not be written — nothing was saved');
128
+ }
129
+ fs.closeSync(fd);
130
+
131
+ const landing = { ev: 'created', initiative: id };
132
+ if (input.from) landing.from = input.from;
133
+ const placed = (actor.via === 'ui' ? placements.appendFromUi : placements.appendFromCli)(root, [landing], { actor, now });
134
+ if (!placed.ok) {
135
+ try { fs.unlinkSync(file); } catch { /* best effort */ }
136
+ return refuse(placed.status || 500, `the initiative was not created: ${placed.error}`);
137
+ }
138
+ return { ok: true, id, path: path.relative(realRoot, file).split(path.sep).join('/'), written: 1 + placed.written };
139
+ }
140
+
141
+ module.exports = { createInitiative, slugOf, ID_RE, INPUT_KEYS };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+ /* issues.js — THE ONE WRITER and THE ONE FOLD of issues.jsonl: which report was issued, when, and the git blob sha
3
+ * of the file as issued (pm-os-absorb, PRD R22.5, R24, R25; D86 point 3).
4
+ *
5
+ * readIssues(root) → {
6
+ * ok: true,
7
+ * issues: [{ cadence, slot, path, sha, at, by, via }], oldest first; `slot` is the report date YYYY-MM-DD
8
+ * flags: [{ kind, eid, why }], unreadable, unknown-event, malformed, invalid, not-pm,
9
+ * duplicate-slot: each line named has no effect
10
+ * sig, ledgerSig taken BEFORE the read (F2)
11
+ * }
12
+ * lastIssue(result, cadence) → the newest issue of that cadence, or null
13
+ * isIssued(result, cadence, slot) → true when that slot is frozen (Redraft is refused)
14
+ * appendFromUi(root, events, { actor, now }) → { ok, written } | { ok: false, status, error, index }
15
+ * appendFromCli(root, events, { actor, now }) → the same
16
+ *
17
+ * Issue is a PM-only act (R25): the route takes its actor from pm-guard.js requirePmPress({ act: 'issue' }), and the
18
+ * CLI from pm-guard.js requirePmCli({ act: 'issue' }). The biweekly's window is `since_last_issue`: from lastIssue's
19
+ * `at`, or `report_cycle_days` back (lib/plan-rules.js) when there is none.
20
+ *
21
+ * THE `issued` LINE (the shared contract):
22
+ * { v: 1, eid, ev: 'issued', cadence: 'biweekly'|…, slot: 'YYYY-MM-DD', path: '<repo-relative path of the report>',
23
+ * sha: '<git blob sha of the file as issued, 40 hex>', by, via, at }
24
+ * pmOnly(rec) → true for every `issued` line (R25).
25
+ *
26
+ * THE WRITER (Run 5, slice 5A; the sink guard and the refusal shape are lib/plan-items.js's). It refuses, writing
27
+ * nothing: an actor that is not the PM or not on its own call path (403); a writer field or an unknown field (400); a
28
+ * bad cadence, slot or sha, or a path that is not the slot's own draft file (report-draft.js draftPath) (400); a slot
29
+ * already issued (409); a file that does not exist (404) or whose blob sha is not the one sent (409), so an issue
30
+ * always freezes the bytes the PM read. `at` is strictly increasing.
31
+ * blobSha(buf) → what `git hash-object` prints for those bytes; scripts/check-issues.js and report-draft.js use it.
32
+ * lineErr(rec) → why a line breaks the schema, or null; the checker's schema is this one.
33
+ * The ledger is one per repository: lib/plan-ledgers.js ledgerPath(root, 'issues'). The issued FILE is read in
34
+ * `root`, the tree the report was drafted in.
35
+ */
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const crypto = require('crypto');
39
+ const { createSinkGuard } = require('./sink.js');
40
+ const ledgers = require('./plan-ledgers.js');
41
+ const actorLib = require('./actor.js');
42
+
43
+ const MAX_LINES = 10;
44
+ const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
45
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
46
+ const SHA_RE = /^[0-9a-f]{40}$/;
47
+ const EID_RE = /^[0-9a-z]{12}$/;
48
+ const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
49
+ const EVENT_FIELDS = ['ev', 'cadence', 'slot', 'path', 'sha'];
50
+ const WRITER_FIELDS = ['v', 'eid', 'by', 'via', 'at'];
51
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
52
+
53
+ const isStr = (v) => typeof v === 'string';
54
+ const refuse = (status, error, index) => ({ ok: false, status, error, index: index == null ? null : index });
55
+ /** A real calendar day, not only the shape of one. */
56
+ const isDate = (s) => isStr(s) && DATE_RE.test(s) && new Date(s + 'T00:00:00Z').toISOString().slice(0, 10) === s;
57
+
58
+ /** The git blob sha of a file's bytes (what `git hash-object` prints), without running git. */
59
+ function blobSha(buf) {
60
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf), 'utf8');
61
+ return crypto.createHash('sha1').update(`blob ${b.length}\0`).update(b).digest('hex');
62
+ }
63
+
64
+ /** A repository-relative path a report can have: `…/reports/<cadence>/<slot>.md`, inside the tree, no `..`. */
65
+ function reportPathOk(rel, cadence, slot) {
66
+ if (!isStr(rel) || !rel || rel.startsWith('/') || rel.includes('\\') || rel.split('/').some((p) => p === '..' || p === '.' || p === '')) return false;
67
+ const tail = `reports/${cadence}/${slot}.md`;
68
+ return rel === tail || rel.endsWith(`/${tail}`);
69
+ }
70
+
71
+ /** The schema of one ledger line, as the fold and the checker read it. null when it is valid, else why. */
72
+ function lineErr(rec) {
73
+ if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return 'not a JSON object';
74
+ if (rec.v !== 1) return 'v must be 1';
75
+ if (!isStr(rec.eid) || !EID_RE.test(rec.eid)) return 'eid must be 12 characters of [0-9a-z]';
76
+ if (rec.ev !== 'issued') return `unknown event ${JSON.stringify(rec.ev)}`;
77
+ if (!isStr(rec.cadence) || !CADENCE_RE.test(rec.cadence)) return 'cadence must be a report id such as biweekly';
78
+ if (!isDate(rec.slot)) return 'slot must be a date, YYYY-MM-DD';
79
+ if (!reportPathOk(rec.path, rec.cadence, rec.slot)) return `path must be the report file …/reports/${rec.cadence}/${rec.slot}.md`;
80
+ if (!isStr(rec.sha) || !SHA_RE.test(rec.sha)) return 'sha must be the 40-character git blob sha';
81
+ if (!isStr(rec.by) || !actorLib.BY_RE.test(rec.by)) return 'by must be pm:<slug>, agent:unknown or agent:<session>';
82
+ if (!actorLib.VIA.includes(rec.via)) return 'via must be ui or cli';
83
+ if (!isStr(rec.at) || !AT_RE.test(rec.at) || !Number.isFinite(Date.parse(rec.at))) return 'at must be an ISO UTC time';
84
+ return null;
85
+ }
86
+
87
+ function readIssues(root) {
88
+ // F2: the signature is taken BEFORE the read, so a line appended during the read costs one more read, never a missed one.
89
+ const sig = ledgers.ledgerSig(root, 'issues');
90
+ const out = { ok: true, issues: [], flags: [], sig };
91
+ const { lines, bad } = ledgers.readLines(root, 'issues');
92
+ for (const b of bad) out.flags.push({ kind: 'unreadable', eid: null, why: `line ${b.n}: ${b.why}` });
93
+ const seen = new Map();
94
+ for (const { n, rec } of lines) {
95
+ if (rec.ev !== 'issued') { out.flags.push({ kind: 'unknown-event', eid: rec.eid || null, why: `line ${n}: ${JSON.stringify(rec.ev)} is not an event of this ledger` }); continue; }
96
+ if (typeof rec.cadence !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(String(rec.slot)) || typeof rec.path !== 'string') {
97
+ out.flags.push({ kind: 'malformed', eid: rec.eid || null, why: 'an issued line needs a cadence, a slot date and a path' });
98
+ continue;
99
+ }
100
+ const why = lineErr(rec);
101
+ if (why) { out.flags.push({ kind: 'invalid', eid: rec.eid || null, why: `line ${n}: ${why}` }); continue; }
102
+ if (!actorLib.isPm({ by: rec.by, via: rec.via })) { out.flags.push({ kind: 'not-pm', eid: rec.eid, why: `line ${n}: ${rec.by} issued ${rec.cadence} ${rec.slot}; only the PM issues a report (R25), so the line has no effect` }); continue; }
103
+ const key = `${rec.cadence}\u0000${rec.slot}`;
104
+ if (seen.has(key)) { out.flags.push({ kind: 'duplicate-slot', eid: rec.eid, why: `line ${n}: ${rec.cadence} ${rec.slot} was issued on line ${seen.get(key)}; the later line has no effect` }); continue; }
105
+ seen.set(key, n);
106
+ out.issues.push({ cadence: rec.cadence, slot: rec.slot, path: rec.path, sha: rec.sha || null, at: rec.at, by: rec.by, via: rec.via });
107
+ }
108
+ return out;
109
+ }
110
+
111
+ const pmOnly = (rec) => !!rec && rec.ev === 'issued';
112
+
113
+ function lastIssue(result, cadence) {
114
+ const rows = (result && Array.isArray(result.issues) ? result.issues : []).filter((r) => r && r.cadence === cadence);
115
+ return rows.length ? rows.reduce((a, b) => (String(b.at) >= String(a.at) ? b : a)) : null;
116
+ }
117
+
118
+ function isIssued(result, cadence, slot) {
119
+ return (result && Array.isArray(result.issues) ? result.issues : []).some((r) => r && r.cadence === cadence && r.slot === slot);
120
+ }
121
+
122
+ // ── the writer ──────────────────────────────────────────────────────────────────────────────────────
123
+ function randomChars(n) {
124
+ let out = '';
125
+ while (out.length < n) {
126
+ for (const b of crypto.randomBytes(n * 2)) {
127
+ if (b >= 252) continue; // 252 = 7 × 36: keep the modulo unbiased
128
+ out += ALPHABET[b % 36];
129
+ if (out.length === n) break;
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * One `issued` line per event. The actor is the caller's guard result (pm-guard.js), never a field; only the PM
137
+ * issues, and the call path must match the actor (`ui` from the page, `cli` from a terminal). Each event names a file
138
+ * that exists in `root` with exactly the sha sent, so a line can never freeze bytes nobody read.
139
+ */
140
+ function append(root, events, actor, via, { now } = {}) {
141
+ if (!actorLib.isPm(actor) || actor.via !== via) return refuse(403, `only the PM issues a report, from the ${via === 'ui' ? 'page' : 'terminal'}; nothing was written`, null);
142
+ if (!Array.isArray(events) || !events.length) return refuse(400, 'send at least one line', null);
143
+ if (events.length > MAX_LINES) return refuse(413, `send at most ${MAX_LINES} lines at once`, null);
144
+ let read;
145
+ try { read = readIssues(root); } catch (e) { return refuse(500, 'could not read the issues ledger; nothing was written: ' + String((e && e.message) || e).slice(0, 160), null); }
146
+ if (read.flags.some((f) => f.kind === 'unreadable' && /^line 0:/.test(f.why))) return refuse(500, 'the workflow engine is missing, so the issues ledger cannot be found; nothing was written', null);
147
+ const clock = now == null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now));
148
+ if (!Number.isFinite(clock)) return refuse(400, 'now must be a time', null);
149
+ let last = 0;
150
+ for (const r of read.issues) { const t = Date.parse(r.at); if (Number.isFinite(t) && t <= clock + 1000 && t > last) last = t; }
151
+ const taken = new Set(read.issues.map((r) => `${r.cadence}\u0000${r.slot}`));
152
+ const recs = [];
153
+ for (let i = 0; i < events.length; i++) {
154
+ const ev = events[i];
155
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev)) return refuse(400, 'each line must be a JSON object', i);
156
+ const sent = WRITER_FIELDS.find((k) => Object.prototype.hasOwnProperty.call(ev, k));
157
+ if (sent) return refuse(400, `the writer sets "${sent}"; do not send it`, i);
158
+ const extra = Object.keys(ev).find((k) => !EVENT_FIELDS.includes(k));
159
+ if (extra) return refuse(400, `an issued line has no field "${extra}"`, i);
160
+ if (ev.ev !== 'issued') return refuse(400, 'the only event is issued', i);
161
+ if (!isStr(ev.cadence) || !CADENCE_RE.test(ev.cadence)) return refuse(400, 'cadence must be a report id such as biweekly', i);
162
+ if (!isDate(ev.slot)) return refuse(400, 'slot must be a date, YYYY-MM-DD', i);
163
+ if (!reportPathOk(ev.path, ev.cadence, ev.slot)) return refuse(400, `path must be the report file …/reports/${ev.cadence}/${ev.slot}.md`, i);
164
+ // The writer pins the path to the slot's own draft file (report-draft.js draftPath, the one layout rule), so the
165
+ // sha it records guards the bytes of that draft and no other file. Required here: report-draft.js requires this file.
166
+ let own;
167
+ try { own = require('./report-draft.js').draftPath(root, ev.cadence, ev.slot); }
168
+ catch { return refuse(500, 'the workflow engine is missing, so the report file cannot be found; nothing was written', i); }
169
+ if (ev.path !== own) return refuse(400, `path must be ${own}, the draft file of ${ev.cadence} ${ev.slot}`, i);
170
+ if (!isStr(ev.sha) || !SHA_RE.test(ev.sha)) return refuse(400, 'sha must be the 40-character git blob sha of the report', i);
171
+ const key = `${ev.cadence}\u0000${ev.slot}`;
172
+ if (taken.has(key)) return refuse(409, `${ev.cadence} ${ev.slot} is already issued; an issued report is frozen`, i);
173
+ let buf;
174
+ try { buf = fs.readFileSync(path.join(path.resolve(String(root)), ev.path)); }
175
+ catch { return refuse(404, `${ev.path} does not exist; draft the report first`, i); }
176
+ if (blobSha(buf) !== ev.sha) return refuse(409, `${ev.path} changed after it was read; read it again, then issue`, i);
177
+ taken.add(key);
178
+ const t = Math.max(clock, last + 1);
179
+ last = t;
180
+ recs.push({ v: 1, eid: randomChars(12), ev: 'issued', cadence: ev.cadence, slot: ev.slot, path: ev.path, sha: ev.sha,
181
+ by: actor.by, via, at: new Date(t).toISOString() });
182
+ }
183
+ let file;
184
+ try { file = ledgers.ledgerPath(root, 'issues'); fs.mkdirSync(path.dirname(file), { recursive: true }); }
185
+ catch { return refuse(500, 'could not record; nothing was written', null); }
186
+ const guard = createSinkGuard({ sink: file, what: 'report issues' });
187
+ let wrote = 0;
188
+ for (const rec of recs) {
189
+ try { guard.appendLine(file, rec); wrote++; }
190
+ catch {
191
+ return refuse(500, wrote ? `could not record; ${wrote} of ${recs.length} lines were written before the write failed` : 'could not record; nothing was written', wrote);
192
+ }
193
+ }
194
+ return { ok: true, written: recs.map((r) => ({ eid: r.eid, ev: r.ev, cadence: r.cadence, slot: r.slot, path: r.path, sha: r.sha, at: r.at })) };
195
+ }
196
+
197
+ /** POST /api/report/issue (5C), after pm-guard.js requirePmPress({ act: 'issue' }) passed: `actor` is its result. */
198
+ function appendFromUi(root, events, { actor, now } = {}) {
199
+ return append(root, events, actor, 'ui', { now });
200
+ }
201
+ /** `pmos report issue` (lib/report-cli.js), after pm-guard.js requirePmCli({ act: 'issue' }) passed. */
202
+ function appendFromCli(root, events, { actor, now } = {}) {
203
+ return append(root, events, actor, 'cli', { now });
204
+ }
205
+
206
+ module.exports = { readIssues, lastIssue, isIssued, pmOnly, appendFromUi, appendFromCli, lineErr, blobSha, reportPathOk, MAX_LINES };