create-pmos 0.133.0 → 0.135.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 +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +35 -2
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-audio.js +28 -9
  83. package/workspace/lib/notes-capture.js +136 -0
  84. package/workspace/lib/notes-final.js +38 -13
  85. package/workspace/lib/notes-live-guard.js +117 -0
  86. package/workspace/lib/notes-replay.js +125 -0
  87. package/workspace/lib/notes-screen.js +180 -0
  88. package/workspace/lib/notes-whisper.js +139 -16
  89. package/workspace/lib/notes.js +4 -2
  90. package/workspace/lib/outbox-store.js +10 -6
  91. package/workspace/lib/pmos-verbs.js +3 -1
  92. package/workspace/lib/proactive-cli.js +7 -0
  93. package/workspace/lib/proactive.js +15 -2
  94. package/workspace/lib/repo-script.js +36 -0
  95. package/workspace/lib/report-dashboards.js +5 -2
  96. package/workspace/lib/report-frame.js +122 -0
  97. package/workspace/lib/report-schedules.js +113 -0
  98. package/workspace/lib/route-modules.js +9 -2
  99. package/workspace/lib/routes-boards.js +734 -0
  100. package/workspace/lib/routes-brief-page.js +13 -4
  101. package/workspace/lib/routes-briefing.js +22 -11
  102. package/workspace/lib/routes-c3-init.js +4 -3
  103. package/workspace/lib/routes-c3-life.js +2 -1
  104. package/workspace/lib/routes-c3-reports.js +96 -13
  105. package/workspace/lib/routes-c3-strategy.js +4 -3
  106. package/workspace/lib/routes-c3-timeline.js +3 -2
  107. package/workspace/lib/routes-calendar-import.js +3 -2
  108. package/workspace/lib/routes-calendar-views.js +9 -4
  109. package/workspace/lib/routes-doc-health.js +2 -1
  110. package/workspace/lib/routes-doc-sheet.js +2 -1
  111. package/workspace/lib/routes-forecast.js +2 -1
  112. package/workspace/lib/routes-hooks.js +3 -2
  113. package/workspace/lib/routes-initiatives.js +2 -1
  114. package/workspace/lib/routes-intake.js +2 -1
  115. package/workspace/lib/routes-notes.js +113 -15
  116. package/workspace/lib/routes-outbox.js +23 -3
  117. package/workspace/lib/routes-pm-items.js +19 -6
  118. package/workspace/lib/routes-records.js +3 -2
  119. package/workspace/lib/routes-releases.js +2 -1
  120. package/workspace/lib/routes-report-facts.js +4 -3
  121. package/workspace/lib/routes-report-families.js +2 -1
  122. package/workspace/lib/routes-roadmap-ops.js +5 -4
  123. package/workspace/lib/routes-runs.js +6 -5
  124. package/workspace/lib/routes-settings.js +51 -0
  125. package/workspace/lib/routes-signals.js +3 -2
  126. package/workspace/lib/routes-stage-tools.js +220 -0
  127. package/workspace/lib/run-answers.js +2 -1
  128. package/workspace/lib/settings-store.js +226 -0
  129. package/workspace/lib/stage-tools.js +208 -0
  130. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  131. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  132. package/workspace/lib/work-evidence.js +73 -0
  133. package/workspace/package-lock.json +2 -2
  134. package/workspace/package.json +1 -1
  135. package/workspace/public/pm/briefing-deck.js +63 -17
  136. package/workspace/public/pm/briefing-model.js +36 -7
  137. package/workspace/public/pm/c3-ambient.js +100 -0
  138. package/workspace/public/pm/c3-brief-needs.js +26 -6
  139. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  140. package/workspace/public/pm/c3-brief-today.js +56 -21
  141. package/workspace/public/pm/c3-brief.js +2 -1
  142. package/workspace/public/pm/c3-calendar.js +161 -42
  143. package/workspace/public/pm/c3-calpop.js +5 -5
  144. package/workspace/public/pm/c3-demo-boards.js +649 -0
  145. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  146. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  147. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  148. package/workspace/public/pm/c3-demo-data.js +17 -27
  149. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  150. package/workspace/public/pm/c3-demo.js +2 -1
  151. package/workspace/public/pm/c3-dock.js +1 -1
  152. package/workspace/public/pm/c3-ib-docs.js +80 -0
  153. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  154. package/workspace/public/pm/c3-ib-items.js +201 -0
  155. package/workspace/public/pm/c3-ib-releases.js +51 -0
  156. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  157. package/workspace/public/pm/c3-ib-triage.js +178 -0
  158. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  159. package/workspace/public/pm/c3-ini-records.js +197 -0
  160. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  161. package/workspace/public/pm/c3-ini-ui.js +229 -0
  162. package/workspace/public/pm/c3-ini-work.js +139 -0
  163. package/workspace/public/pm/c3-initiatives.css +218 -0
  164. package/workspace/public/pm/c3-initiatives.js +741 -0
  165. package/workspace/public/pm/c3-plan.js +2 -2
  166. package/workspace/public/pm/c3-reports.css +47 -47
  167. package/workspace/public/pm/c3-reports.js +223 -185
  168. package/workspace/public/pm/c3-settings.css +74 -0
  169. package/workspace/public/pm/c3-settings.js +703 -0
  170. package/workspace/public/pm/c3-shell.js +102 -36
  171. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  172. package/workspace/public/pm/c3-terminal.js +2 -2
  173. package/workspace/public/pm/calendar-views.js +26 -13
  174. package/workspace/public/pm/components.css +319 -213
  175. package/workspace/public/pm/console3.css +118 -23
  176. package/workspace/public/pm/dest-notes.js +26 -4
  177. package/workspace/public/pm/notes-screen-capture.js +165 -0
  178. package/workspace/public/pm/notes-screen-view.js +98 -0
  179. package/workspace/public/pm/panel-trees.js +5 -2
  180. package/workspace/public/pm/pm-work-view.js +233 -146
  181. package/workspace/public/pm/shell-bell.js +3 -2
  182. package/workspace/public/pm/shell-recording.js +148 -17
  183. package/workspace/public/pm/tokens.css +2 -2
  184. package/workspace/public/pm/view-notes.js +53 -11
  185. package/workspace/public/pm/welcome.js +197 -89
  186. package/workspace/public/welcome.html +6 -4
  187. package/workspace/server.js +105 -6
  188. package/workspace/skills/day-check.skill +1 -1
  189. package/workspace/skills/jira-sync.skill +6 -4
  190. package/workspace/skills/morning-brief.skill +1 -1
  191. package/workspace/test-files.json +14 -3
  192. package/workspace/test-lists/c3-ib-items.json +9 -0
  193. package/workspace/test-lists/c3-ib-more.json +9 -0
  194. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  195. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  196. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  197. package/workspace/test-lists/console3-briefing.json +9 -0
  198. package/workspace/test-lists/console3-foundation.json +9 -0
  199. package/workspace/test-lists/console3-reports.json +3 -3
  200. package/workspace/test-lists/meeting-notes.json +12 -4
  201. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  202. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  203. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  204. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  205. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  206. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  207. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  208. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  209. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  210. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  211. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  212. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -25,9 +25,14 @@
25
25
  * The cuts are long (a pause of 1 s, at least 4 s) so that a cut holds a whole sentence more often than a fragment;
26
26
  * lib/notes.js joins a cut that still ends mid-sentence to the one before it.
27
27
  *
28
- * VOCABULARY AND CONTEXT. Each run gets `--prompt` with the names and terms of the repository (the `vocab` callback, or the
29
- * `prompt` string) and the last line heard from the same source, at most PROMPT_MAX characters. Whisper then spells the
30
- * repository's people and product terms as the repository does, and reads the cut as the continuation of the line before.
28
+ * VOCABULARY. Each run gets `--prompt` with the names and terms of the repository (the `vocab` callback, or the `prompt`
29
+ * string), at most PROMPT_MAX characters, so Whisper spells the repository's people and product terms as the repository does.
30
+ * The line before is no longer passed (notes-recording-reliability): it made Whisper repeat it, and a line in another language
31
+ * pulled the next cut into that language. promptFor still takes a previous line for the final pass.
32
+ *
33
+ * GUARDS (notes-recording-reliability, lib/notes-live-guard.js). A cut that repeats the line before it is dropped, a sentence
34
+ * repeated back to back is kept once, the meeting's words are taken out of the microphone's line (the echo through the PM's
35
+ * speakers), and with English chosen a cut that comes back in another language is run again in English.
31
36
  *
32
37
  * INVENTED LINES. whisper.cpp's no-speech threshold (`-nth`) drops segments it thinks are silence. A cut that is mostly
33
38
  * quiet and comes back as only "Thank you." or "you" is a known invention and is dropped (cleanText). A program too old
@@ -38,6 +43,7 @@ const os = require('os');
38
43
  const path = require('path');
39
44
  const crypto = require('crypto');
40
45
  const { execFile } = require('child_process');
46
+ const liveGuard = require('./notes-live-guard.js');
41
47
 
42
48
  const RATE = 16000;
43
49
  const FRAME_MS = 100;
@@ -53,6 +59,9 @@ const LEAD_MS = 300; // speech that starts a cut keeps this much
53
59
  const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
54
60
  const MAX_QUEUE = 24;
55
61
  const RUN_TIMEOUT_MS = 120000;
62
+ const HOLD_MS = 12000; // a microphone line waits at most this long for the meeting's line of the same time
63
+ const MEETING_LIVE_MS = 30000; // meeting audio this recent means the meeting is being captured
64
+ const ECHO_KEEP_MS = 60000; // the lines kept for the echo check
56
65
  const MODEL_BASE = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/';
57
66
  // The faster model is the same one quantized to 5 bits: a third of the size, so it loads and runs faster on an M1, and it
58
67
  // makes a few more mistakes. Every cut starts the program again, so the time to load the model is paid on every line.
@@ -253,9 +262,10 @@ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE, accuracy = DEFA
253
262
  * @param {Function} [a.run] runWhisper, replaceable in the suites
254
263
  * @param {Function} [a.detect] detect, replaceable in the suites
255
264
  */
256
- function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir), vocab = null, prompt = '' }) {
265
+ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir), vocab = null, prompt = '',
266
+ echo = true, holdMs = HOLD_MS, recent = null, lastLine = null }) {
257
267
  const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
258
- const lastText = new Map(); // `${id}|${source}` → the last words heard there, the context of the next cut
268
+ const lastText = new Map(); // `${id}|${source}` → { text, t }: the last line heard there, for the repetition guard
259
269
  const vocabOf = (job) => { if (typeof vocab !== 'function') return prompt || ''; try { return String(vocab({ id: job.id, source: job.source }) || ''); } catch { return prompt || ''; } };
260
270
  const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
261
271
  let queue = Promise.resolve();
@@ -264,13 +274,102 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
264
274
  // a cut left by a process that ended while the program ran is deleted when the next transcriber starts
265
275
  try { for (const f of fs.readdirSync(tmpDir)) if (/^cut-[0-9a-f]+\.wav$/.test(f)) fs.unlinkSync(path.join(tmpDir, f)); } catch { /* no folder yet */ }
266
276
 
277
+ /* LIVE ECHO (notes-recording-reliability). A microphone line is held until the meeting source has nothing left to say about the
278
+ * same time: the meeting's audio has arrived up to the line's end, no meeting cut queued that starts before the line ends, and
279
+ * no meeting speech buffered from before then. Then
280
+ * the meeting's words are taken out of it (lib/notes-live-guard.js stripEcho), and what is left, if anything, is the PM's.
281
+ * A line is never held longer than holdMs. A note with no meeting audio holds nothing. */
282
+ const heard = new Map(); // note id → [{ source, t, end, text }] the lines of the last ECHO_KEEP_MS, for the echo check
283
+ const meetingAt = new Map();// note id → when meeting audio last arrived (Date.now())
284
+ const meetingTo = new Map();// note id → how far (ms) the meeting audio placed by its own clock has arrived
285
+ const inQueue = []; // { id, source, t } of the cuts queued and not yet emitted
286
+ const held = []; // { job, text, until }
287
+ let holdTimer = null;
288
+ const remember = (job, text) => {
289
+ const xs = (heard.get(job.id) || []).filter((x) => x.end >= job.t - ECHO_KEEP_MS);
290
+ xs.push({ source: job.source, t: job.t, end: job.t + job.ms, text });
291
+ heard.set(job.id, xs);
292
+ };
293
+ const meetingBusyBefore = (id, end) => {
294
+ // the meeting's audio of this time has not arrived yet: its line may still be coming, whichever source the page sent first
295
+ if (meetingTo.has(id) && meetingTo.get(id) < end) return true;
296
+ if (inQueue.some((q) => q.id === id && q.source === 'meeting' && q.t < end)) return true;
297
+ const b = bufs.get(id + '|meeting');
298
+ return !!(b && b.voicedMs >= MIN_VOICED_MS && b.t < end);
299
+ };
300
+ const emit = async (job, text, extra) => {
301
+ const key = job.id + '|' + job.source;
302
+ let prev = lastText.get(key);
303
+ if (!prev && typeof lastLine === 'function') { try { prev = lastLine(job.id, job.source) || null; } catch { prev = null; } }
304
+ if (liveGuard.repeats(text, prev, job.t)) return;
305
+ lastText.set(key, { text, t: job.t });
306
+ remember(job, text);
307
+ await onText({ ...job, text, ...(extra || {}) });
308
+ };
309
+ // the meeting lines heard here, and the note's own meeting lines (`recent`), so a restart still knows what the meeting said
310
+ const meetingLines = (id) => {
311
+ let stored = [];
312
+ try { stored = typeof recent === 'function' ? (recent(id) || []) : []; } catch { stored = []; }
313
+ return stored.concat((heard.get(id) || []).filter((x) => x.source === 'meeting'));
314
+ };
315
+ const ownWords = (job, text) => liveGuard.stripEcho({ t: job.t, end: job.t + job.ms, text }, meetingLines(job.id));
316
+ function release(force = false) {
317
+ const now = Date.now();
318
+ for (let i = 0; i < held.length;) {
319
+ const h = held[i];
320
+ if (force || now >= h.until || !meetingBusyBefore(h.job.id, h.job.t + h.job.ms)) {
321
+ held.splice(i, 1);
322
+ queue = queue.then(async () => { const own = ownWords(h.job, h.text); if (own) await emit(h.job, own, h.extra); }).catch(() => {});
323
+ } else i++;
324
+ }
325
+ if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
326
+ if (held.length) { holdTimer = setTimeout(() => release(), 250); if (holdTimer.unref) holdTimer.unref(); }
327
+ }
328
+ async function deliver(job, text, extra) {
329
+ // only cuts placed by their capture's own clock (`exact`) are compared: a time taken from arrival says nothing about overlap
330
+ const meetingLive = echo && job.exact && job.source === 'mic' && Date.now() - (meetingAt.get(job.id) || 0) < MEETING_LIVE_MS;
331
+ if (!meetingLive) { await emit(job, text, extra); return; }
332
+ const own = ownWords(job, text);
333
+ if (!own) return;
334
+ if (!meetingBusyBefore(job.id, job.t + job.ms)) { await emit(job, own, extra); return; }
335
+ held.push({ job, text: own, extra, until: Date.now() + holdMs });
336
+ release();
337
+ }
338
+
339
+ /* LANGUAGE (decision 8: accented English is always English). With English (or detect) chosen, a cut that comes back in
340
+ * another language is run again forced to English, with the vocabulary alone as its prompt. The English text is kept and the
341
+ * first text goes with it as `heard_as`. When the run in English still is not English, the first text is kept. */
342
+ async function transcribe(d, job, file, voicedRatio) {
343
+ const lang = getLanguage(runtimeDir);
344
+ const args = { engine: d.engine || 'whisper', bin: d.bin, model: d.model, file, accuracy: getAccuracy(runtimeDir), prompt: promptFor(vocabOf(job), '') };
345
+ const r = await run({ ...args, lang });
346
+ if (!r.ok) return r;
347
+ const text = liveGuard.onceEach(cleanText(r.text, { voicedRatio }));
348
+ if (!text || !(lang === 'en' || lang === 'auto') || !liveGuard.looksForeign(text, r.language)) return { ok: true, text };
349
+ const again = await run({ ...args, lang: 'en' });
350
+ const en = again.ok ? liveGuard.onceEach(cleanText(again.text, { voicedRatio })) : '';
351
+ if (!en || liveGuard.looksForeign(en, again.language)) return { ok: true, text };
352
+ return { ok: true, text: en, extra: { heard_as: { text, language: r.language || '' } } };
353
+ }
354
+
355
+ const cutEnd = new Map(); // `${id}|${source}` → where the last cut of that source ended (ms)
267
356
  function cut(b) {
268
357
  bufs.delete(b.id + '|' + b.source);
269
- if (b.voicedMs < MIN_VOICED_MS || !b.chunks.length) return false;
358
+ cutEnd.set(b.id + '|' + b.source, Math.max(cutEnd.get(b.id + '|' + b.source) || 0, b.t + b.ms));
359
+ // the caller learns how far this source is transcribed once the cut is done, in order, so a restart redoes a cut it lost
360
+ // A microphone line still held for the echo check is not written yet, so the progress stops at its start: a restart then
361
+ // transcribes it again rather than losing it (and the repetition guard drops the lines after it that were written).
362
+ const done = () => {
363
+ const waitFor = held.filter((h) => h.job.id === b.id && h.job.source === b.source).map((h) => h.job.t);
364
+ try { onCut({ id: b.id, source: b.source, until: Math.min(b.t + b.ms, ...waitFor) }); } catch { /* the caller's bookkeeping */ }
365
+ };
366
+ if (b.voicedMs < MIN_VOICED_MS || !b.chunks.length) { queue = queue.then(done).catch(() => {}); if (held.length) release(); return false; }
270
367
  if (waiting >= MAX_QUEUE) { onError({ id: b.id, source: b.source, error: 'transcription is falling behind, so a few seconds were skipped' }); return false; }
271
368
  const pcm = Buffer.concat(b.chunks);
272
- const job = { id: b.id, source: b.source, t: b.t, ms: b.ms };
369
+ const job = { id: b.id, source: b.source, t: b.t, ms: b.ms, ...(b.ts ? { exact: true } : {}) };
273
370
  const voicedRatio = b.ms > 0 ? b.voicedMs / b.ms : 1;
371
+ const q = { id: job.id, source: job.source, t: job.t };
372
+ inQueue.push(q);
274
373
  waiting++;
275
374
  queue = queue.then(async () => {
276
375
  const d = det();
@@ -279,15 +378,13 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
279
378
  const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
280
379
  try {
281
380
  fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
282
- const key = job.id + '|' + job.source;
283
381
  // `engine` is 'qwen' when the caller's detect chose Qwen3-ASR (lib/notes-qwen.js); `run` then sends the cut there
284
- const r = await run({ engine: d.engine || 'whisper', bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir), accuracy: getAccuracy(runtimeDir), prompt: promptFor(vocabOf(job), lastText.get(key)) });
382
+ const r = await transcribe(d, job, file, voicedRatio);
285
383
  if (!r.ok) { onError({ ...job, error: r.error }); return; }
286
- const text = cleanText(r.text, { voicedRatio });
287
- if (text) { lastText.set(key, text); await onText({ ...job, text }); }
384
+ if (r.text) await deliver(job, r.text, r.extra);
288
385
  } catch (e) { onError({ ...job, error: String((e && e.message) || e) }); }
289
386
  finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
290
- }).catch(() => {}).finally(() => { waiting--; });
387
+ }).catch(() => {}).finally(() => { waiting--; done(); const i = inQueue.indexOf(q); if (i >= 0) inQueue.splice(i, 1); if (held.length) release(); });
291
388
  return true;
292
389
  }
293
390
 
@@ -295,24 +392,37 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
295
392
  * One chunk from the page. `t` is the chunk's start, in ms from the note's recording start.
296
393
  * @returns {{ ok:true, cut:boolean }}
297
394
  */
298
- function push({ id, source, pcm, t = 0, final = false }) {
395
+ function push({ id, source, pcm, t = 0, final = false, exact = false }) {
299
396
  const k = id + '|' + source;
397
+ if (source === 'meeting' && pcm && pcm.length) {
398
+ meetingAt.set(id, Date.now());
399
+ if (exact) meetingTo.set(id, Math.max(meetingTo.get(id) || 0, t + (pcm.length / 2 / RATE) * 1000));
400
+ }
401
+ // a chunk that names its own place and belongs before audio already cut comes too late for the live transcript: on its own it
402
+ // would be a fragment of a sentence already written. Its audio is kept, and the final pass after Stop reads it.
403
+ if (exact && !final && t + 1 < (cutEnd.get(k) || 0)) return { ok: true, cut: false, late: true };
300
404
  let b = bufs.get(k);
301
405
  const lv = pcm && pcm.length ? levels(pcm) : [];
302
- if (!b) { b = { id, source, chunks: [], ms: 0, voicedMs: 0, quietMs: 0, t, noise: noiseOf.has(source) ? noiseOf.get(source) : null }; bufs.set(k, b); }
406
+ if (!b) { b = { id, source, chunks: [], ts: exact ? [] : null, ms: 0, voicedMs: 0, quietMs: 0, t, noise: noiseOf.has(source) ? noiseOf.get(source) : null }; bufs.set(k, b); }
303
407
  for (const v of lv) {
304
408
  // the quiet level is learnt from quiet frames only, so speech at the very start is not taken for the room's noise
305
409
  const voiced = v > Math.max(MIN_RMS, (b.noise == null ? 0 : b.noise) * 3);
306
410
  if (voiced) { b.voicedMs += FRAME_MS; b.quietMs = 0; }
307
411
  else { b.quietMs += FRAME_MS; b.noise = b.noise == null ? v : b.noise * 0.9 + v * 0.1; }
308
412
  }
309
- if (pcm && pcm.length) { b.chunks.push(pcm); b.ms += (pcm.length / 2 / RATE) * 1000; }
413
+ if (pcm && pcm.length) {
414
+ // a chunk that names its own place (`exact`) and arrives after a later one goes back where it belongs in the buffer
415
+ const at = exact && b.ts && t < b.t + b.ms - 1 && t >= b.t ? b.ts.findIndex((x) => x > t) : -1;
416
+ if (at >= 0) { b.chunks.splice(at, 0, pcm); b.ts.splice(at, 0, t); } else { b.chunks.push(pcm); if (b.ts) b.ts.push(t); }
417
+ b.ms += (pcm.length / 2 / RATE) * 1000;
418
+ }
310
419
  if (b.noise != null) noiseOf.set(source, b.noise);
311
420
  // quiet before anyone speaks is not kept: only the last LEAD_MS of it stays in front of the speech
312
421
  if (!b.voicedMs && b.ms > LEAD_MS + 1000) {
313
422
  const keepBytes = Math.round(RATE * LEAD_MS / 1000) * 2;
314
423
  const all = Buffer.concat(b.chunks);
315
424
  b.chunks = [all.subarray(Math.max(0, all.length - keepBytes))];
425
+ if (b.ts) b.ts = [b.t + b.ms - LEAD_MS];
316
426
  b.t += b.ms - LEAD_MS; b.ms = LEAD_MS;
317
427
  }
318
428
  let did = false;
@@ -322,12 +432,25 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
322
432
  }
323
433
  /** Cut whatever is buffered for a note (the page stopped). */
324
434
  function flush(id) { for (const b of Array.from(bufs.values())) if (b.id === id) cut(b); }
435
+ /** Is audio of this note and source buffered and not cut yet? */
436
+ const has = (id, source) => bufs.has(id + '|' + source);
325
437
  /** Drop a note's buffers without transcribing them (the note was marked private or deleted). */
326
438
  function drop(id) {
327
439
  for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
328
440
  for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
441
+ for (let i = held.length - 1; i >= 0; i--) if (held[i].job.id === id) held.splice(i, 1);
442
+ heard.delete(id); meetingAt.delete(id); meetingTo.delete(id);
443
+ for (const k of Array.from(cutEnd.keys())) if (k.startsWith(id + '|')) cutEnd.delete(k);
444
+ }
445
+ /** Settled: every queued cut is transcribed and every held line is out. */
446
+ async function idle() {
447
+ for (;;) {
448
+ const q = queue; await q;
449
+ if (held.length) { release(true); continue; }
450
+ if (q === queue) return;
451
+ }
329
452
  }
330
- return { push, flush, drop, pending: () => waiting, idle: () => queue };
453
+ return { push, flush, drop, has, pending: () => waiting + held.length, idle };
331
454
  }
332
455
 
333
456
  module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL,
@@ -520,7 +520,7 @@ function joinText(prev, next, names) {
520
520
  const tail = KEEP_CAP.test(next) || (names && names.has(word)) || PROPER_DAYS.test(word) ? next : next.charAt(0).toLowerCase() + next.slice(1);
521
521
  return askMark(head.replace(/[,\s]+$/, '') + ' ' + tail);
522
522
  }
523
- function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms = null, now = Date.now() } = {}) {
523
+ function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms = null, heard_as = null, now = Date.now() } = {}) {
524
524
  const note = load(dir, id);
525
525
  if (!note) return err(404, 'no such note');
526
526
  if (note.private) return err(409, 'this note is marked private, so nothing more is recorded in it');
@@ -536,6 +536,8 @@ function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms =
536
536
  const tr = note.transcript || (note.transcript = []);
537
537
  const tEnd = Number.isFinite(ms) && ms > 0 ? t + Math.round(ms) : null;
538
538
  const open = JOINS.has(src) && endsOpen(raw);
539
+ // the first text of a line that came back in another language and was run again in English (notes-recording-reliability, D8)
540
+ const heardAs = heard_as && typeof heard_as.text === 'string' && heard_as.text ? { text: heard_as.text.slice(0, MAX_PHRASE), language: String(heard_as.language || '').slice(0, 40) } : null;
539
541
  const out = [];
540
542
  // the first sentence may finish the line before it, from the same source
541
543
  if (JOINS.has(src)) {
@@ -558,7 +560,7 @@ function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms =
558
560
  const base = tr.length;
559
561
  const added = sentences.map((s, k) => { const isOpen = open && k === sentences.length - 1;
560
562
  return { at: iso(now), t, ...(tEnd != null ? { t_end: tEnd } : {}), text: s, source: src, cat: isOpen ? null : categoryOf(s, names), cat_by: 'local',
561
- ...(isOpen ? { open: true } : {}), index: base + k }; });
563
+ ...(isOpen ? { open: true } : {}), ...(heardAs ? { heard_as: heardAs } : {}), index: base + k }; });
562
564
  note.transcript = tr.concat(added.map(({ index, ...x }) => x));
563
565
  note.updated_at = iso(now);
564
566
  store(dir, note);
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
  /* outbox-store.js — the file layer over scripts/outbox.js (console3 wave B3). Sends NOTHING.
3
3
  *
4
- * <runtime>/outbox/outbox.jsonl append-only; one line per event: queued | edited | pressed | sent
4
+ * <runtime>/outbox/outbox.jsonl append-only; one line per event: queued | edited | pressed | sent | discarded | restored
5
5
  * <runtime>/outbox/items/<id>.json the scripts/trust/outbox.js item, written only when a sender is registered
6
6
  * <runtime>/outbox/sent.jsonl the trust ledger of sends (one 'send' line per send, with the override flag)
7
7
  * <runtime>/outbox/promises.jsonl the promise ledger, through scripts/promises.js (scripts/trust/ledger.js)
@@ -13,10 +13,11 @@
13
13
  */
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
- const outbox = require('../../scripts/outbox.js');
17
- const promises = require('../../scripts/promises.js');
18
- const T = require('../../scripts/trust/outbox.js');
19
- const L = require('../../scripts/trust/ledger.js');
16
+ const { repoScript } = require('./repo-script.js');
17
+ const outbox = require(repoScript('outbox.js'));
18
+ const promises = require(repoScript('promises.js'));
19
+ const T = require(repoScript('trust/outbox.js'));
20
+ const L = require(repoScript('trust/ledger.js'));
20
21
 
21
22
  const dirOf = (runtimeDir) => path.join(runtimeDir, 'outbox');
22
23
  const outboxFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'outbox.jsonl');
@@ -37,8 +38,11 @@ function readLines(file) {
37
38
 
38
39
  function foldOutbox(lines = []) {
39
40
  const byId = new Map();
41
+ const gone = new Set(); // ids the PM discarded; a 'restored' line (the Undo) brings one back
40
42
  for (const ev of lines) {
41
43
  if (!ev || !ev.id) continue;
44
+ if (ev.event === 'discarded') { gone.add(ev.id); continue; }
45
+ if (ev.event === 'restored') { gone.delete(ev.id); continue; }
42
46
  if (ev.event === 'queued' || ev.event === 'edited') {
43
47
  const { event, ...entry } = ev;
44
48
  byId.set(ev.id, { ...entry, state: ev.state === 'blocked' ? 'blocked' : 'queued', last_result: null, refused: null, created_at: (byId.get(ev.id) || {}).created_at || ev.at });
@@ -49,7 +53,7 @@ function foldOutbox(lines = []) {
49
53
  else byId.set(ev.id, { ...cur, last_result: ev.reason || null });
50
54
  }
51
55
  }
52
- return [...byId.values()];
56
+ return [...byId.values()].filter((e) => !gone.has(e.id));
53
57
  }
54
58
 
55
59
  const readOutbox = (runtimeDir) => foldOutbox(readLines(outboxFile(runtimeDir)));
@@ -63,7 +63,9 @@ function load(verb) {
63
63
  const full = path.join(__dirname, VERBS[verb].module);
64
64
  try { return require(full); }
65
65
  catch (e) {
66
- if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).includes(full)) return null;
66
+ // Only the module itself missing means "not built". The message also lists the requiring file ("Require stack"), so a
67
+ // module whose own require fails must not read as absent (incident workspace-package-script-closure).
68
+ if (e && e.code === 'MODULE_NOT_FOUND' && String(e.message).split('\n')[0].includes(full)) return null;
67
69
  throw e;
68
70
  }
69
71
  }
@@ -11,6 +11,7 @@
11
11
  * pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed [--note TEXT]
12
12
  * pmos proactive feedback --missed TEXT
13
13
  * pmos proactive brief [show]
14
+ * pmos proactive settings [--name TEXT] [--role TEXT] [--work TEXT] [--duties TEXT] what the PM says about themselves
14
15
  * pmos proactive settings [--brief-source NAME=on|off] [--scope SERVICE:NAME=watch|search|ignore|none] [--day-check 30|60|off]
15
16
  * [--website URL] [--no-website URL]
16
17
  * pmos proactive brief submit --file PATH [--merge]
@@ -80,6 +81,7 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
80
81
  patch.triggers = { [m[1]]: m[2] === 'on' };
81
82
  }
82
83
  if ('name' in flags) patch.name = flags.name;
84
+ for (const k of ['role', 'work', 'duties']) if (k in flags) patch.about = { ...(patch.about || {}), [k]: String(flags[k]) };
83
85
  if ('background-prepare' in flags) { const v = onOff(flags['background-prepare'], '--background-prepare'); if (v.err) return fail(v.err); patch.backgroundPrepare = v; }
84
86
  if ('brief-scoring' in flags) { const v = onOff(flags['brief-scoring'], '--brief-scoring'); if (v.err) return fail(v.err); patch.briefScoring = v; }
85
87
  if ('brief-source' in flags) {
@@ -116,6 +118,11 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
116
118
  }
117
119
  say(`engine ${s.enabled ? 'on' : 'off'} · stalled after ${s.stalledDays} days · quiet ${s.quiet ? s.quiet.start + '-' + s.quiet.end : 'none'} · learning ${s.pauseLearning ? 'paused' : 'on'}`);
118
120
  say('brief sources: ' + BRIEF_SOURCES.map((k) => `${k} ${s.briefSources && s.briefSources[k] ? 'on' : 'off'}`).join(', ') + ' · scoring ' + (s.briefScoring ? 'on' : 'off') + (s.name ? ' · name ' + s.name : ''));
121
+ const ab = s.about || {};
122
+ if (ab.role || ab.work || ab.duties) {
123
+ say('about the PM: ' + [ab.role && 'role ' + ab.role, ab.work && 'work ' + ab.work].filter(Boolean).join(' · '));
124
+ if (ab.duties) say('job description: ' + ab.duties.replace(/\n+/g, ' / '));
125
+ }
119
126
  say('background prepare ' + (s.backgroundPrepare ? 'on' : 'off'));
120
127
  const dc = s.dayCheck || { every: 60, start: '09:00', end: '18:00' };
121
128
  say('day check ' + (dc.every ? `every ${dc.every} min, ${dc.start}-${dc.end}` : 'off') + ' · websites: ' + ((s.websites || []).join(', ') || 'none'));
@@ -43,7 +43,12 @@ const MAX_WEBSITES = 10;
43
43
  // G22: the daytime check — every 30 or 60 minutes, or off (0), between start and end of the working day.
44
44
  const DAY_CHECK_EVERY = [0, 30, 60];
45
45
  const DAY_CHECK_DEFAULT = Object.freeze({ every: 60, start: '09:00', end: '18:00' });
46
- const DEFAULTS = Object.freeze({ name: '', briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false,
46
+ // What the PM says about themselves on the first-run page (onboarding-scan-and-map): the role, the work in one line and the
47
+ // job description. The brief, the day check and requirement gathering read it from `pmos proactive settings` to judge
48
+ // what matters to this PM. Plain text only, clipped to these lengths.
49
+ const ABOUT_MAX = Object.freeze({ role: 80, work: 200, duties: 1000 });
50
+ const ABOUT_DEFAULT = Object.freeze({ role: '', work: '', duties: '' });
51
+ const DEFAULTS = Object.freeze({ name: '', about: ABOUT_DEFAULT, briefSources: BRIEF_DEFAULT, briefScoring: false, backgroundPrepare: false, enabled: true, triggers: Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t, true]))), stalledDays: 5, quiet: null, pauseLearning: false,
47
52
  scopes: Object.freeze({ slack: Object.freeze({}), jira: Object.freeze({}), confluence: Object.freeze({}) }), placeRepos: Object.freeze({ slack: Object.freeze({}), jira: Object.freeze({}), confluence: Object.freeze({}) }), dayCheck: DAY_CHECK_DEFAULT, websites: Object.freeze([]) });
48
53
 
49
54
  const idOf = (key) => crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
@@ -177,10 +182,18 @@ const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
177
182
  /** @returns {{ok:true, settings:object}|{ok:false, error:string}} — `raw` is merged over the defaults; nothing is partly applied. */
178
183
  function normalizeSettings(raw) {
179
184
  const r = raw && typeof raw === 'object' ? raw : {};
180
- const s = { name: DEFAULTS.name, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, backgroundPrepare: DEFAULTS.backgroundPrepare, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning,
185
+ const s = { name: DEFAULTS.name, about: { ...ABOUT_DEFAULT }, briefSources: { ...BRIEF_DEFAULT }, briefScoring: DEFAULTS.briefScoring, backgroundPrepare: DEFAULTS.backgroundPrepare, enabled: DEFAULTS.enabled, triggers: { ...DEFAULTS.triggers }, stalledDays: DEFAULTS.stalledDays, quiet: DEFAULTS.quiet, pauseLearning: DEFAULTS.pauseLearning,
181
186
  scopes: { slack: {}, jira: {}, confluence: {} }, placeRepos: { slack: {}, jira: {}, confluence: {} }, dayCheck: { ...DAY_CHECK_DEFAULT }, websites: [] };
182
187
  if (r.enabled !== undefined) { if (typeof r.enabled !== 'boolean') return { ok: false, error: 'enabled must be true or false' }; s.enabled = r.enabled; }
183
188
  if (r.name !== undefined) { if (typeof r.name !== 'string' || r.name.length > 40) return { ok: false, error: 'name must be text of at most 40 characters' }; s.name = r.name.replace(/\s+/g, ' ').trim(); }
189
+ if (r.about !== undefined) {
190
+ if (!r.about || typeof r.about !== 'object' || Array.isArray(r.about)) return { ok: false, error: 'about must be an object of role, work and duties → text' };
191
+ for (const [k, v] of Object.entries(r.about)) {
192
+ if (!(k in ABOUT_MAX)) return { ok: false, error: `unknown about field "${k}" — one of ${Object.keys(ABOUT_MAX).join(', ')}` };
193
+ if (typeof v !== 'string' || v.length > ABOUT_MAX[k]) return { ok: false, error: `about.${k} must be text of at most ${ABOUT_MAX[k]} characters` };
194
+ s.about[k] = k === 'duties' ? v.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() : v.replace(/\s+/g, ' ').trim();
195
+ }
196
+ }
184
197
  if (r.briefScoring !== undefined) { if (typeof r.briefScoring !== 'boolean') return { ok: false, error: 'briefScoring must be true or false' }; s.briefScoring = r.briefScoring; }
185
198
  if (r.backgroundPrepare !== undefined) { if (typeof r.backgroundPrepare !== 'boolean') return { ok: false, error: 'backgroundPrepare must be true or false' }; s.backgroundPrepare = r.backgroundPrepare; }
186
199
  if (r.briefSources !== undefined) {
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ /* repo-script.js — where a module from the repo's scripts/ tree is, in a checkout AND in an install
3
+ * (incident workspace-package-script-closure).
4
+ *
5
+ * repoScript('trust/ledger.js') → an absolute path; pass it to require(), or to a child process
6
+ *
7
+ * The workspace shares code with the CLI agents run: the ledger, the records, the calendar import, the signal engine.
8
+ * That code lives in scripts/ at the repo root, and the package ships workspace/ only. So each module is looked for in
9
+ * two places, in this order, the same order lib/console-state.js uses for the workflow engine:
10
+ *
11
+ * 1. ../../scripts/<rel> — a repo checkout (PMOS-self, any dev clone). Preferred, so a developer's workspace runs
12
+ * the very file CI and the CLI run.
13
+ * 2. lib/engine/<rel> — the copy scripts/vendor-workspace-engine.js makes when it builds the package. It mirrors
14
+ * the scripts/ tree, so the module's own relative requires resolve there too.
15
+ *
16
+ * When neither exists the second path is returned, and require() names it in its error: that is the path the vendor step
17
+ * did not fill.
18
+ *
19
+ * THE ARGUMENT IS A STRING LITERAL. The vendor step finds what to copy by reading the bundle's source for every
20
+ * repoScript('<rel>') call, so the list of vendored files is derived from the code and never written down. A call with
21
+ * any other argument fails the vendor step.
22
+ */
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const CHECKOUT = path.join(__dirname, '..', '..', 'scripts');
27
+ const VENDORED = path.join(__dirname, 'engine');
28
+
29
+ function repoScript(rel) {
30
+ const r = String(rel || '');
31
+ if (!r || path.isAbsolute(r) || r.split(/[\\/]/).includes('..')) throw new Error(`repoScript: '${r}' must be a path inside scripts/, such as 'trust/ledger.js'`);
32
+ const inCheckout = path.join(CHECKOUT, r);
33
+ return fs.existsSync(inCheckout) ? inCheckout : path.join(VENDORED, r);
34
+ }
35
+
36
+ module.exports = { repoScript, CHECKOUT, VENDORED };
@@ -13,7 +13,8 @@
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const crypto = require('crypto');
16
- const model = require('../../scripts/calendar-model.js');
16
+ const { repoScript } = require('./repo-script.js');
17
+ const model = require(repoScript('calendar-model.js'));
17
18
 
18
19
  const ALL = ['portfolio', 'board', 'initiative'];
19
20
  const LIBRARY = Object.freeze([
@@ -105,7 +106,9 @@ function scopeFilter(body, facts, scope, id, today, opts = {}) {
105
106
  if (scope === 'portfolio') return { ...base, delivery: { rows: deliveryRows(inis, range, today) } };
106
107
 
107
108
  let onScope;
108
- if (scope === 'board') onScope = (i) => boardOf(i, slug) === id;
109
+ // opts.boardOf is the Initiatives board model (routes-boards.js boardIndex) when the server has it; boardOf is the fallback.
110
+ const boardFn = typeof opts.boardOf === 'function' ? opts.boardOf : (i) => boardOf(i, slug);
111
+ if (scope === 'board') onScope = (i) => boardFn(i) === id;
109
112
  else if (scope === 'initiative') onScope = (i) => i.id === id;
110
113
  else throw new Error('scope must be portfolio, board or initiative');
111
114
  const iniIds = new Set(inis.filter(onScope).map((i) => i.id));
@@ -0,0 +1,122 @@
1
+ /* report-frame.js — the text of a dashboard: one sentence per report, the frame (Headline, Sections, Needs your call, Sources)
2
+ * and the draft body (initiative console3-dashboard-schedules, PRD R6).
3
+ *
4
+ * modelOf(raw) · builderOf(rep) · plural(n, one, many)
5
+ * readingOf(rep, raw) → { line, nums, needs }
6
+ * frameOf({ title, reports, raw, audience, today }) → { title, audience, today, headline, sections, needs, sources }
7
+ * draftBody(frame) → the outbox draft text
8
+ *
9
+ * ONE FILE, BOTH SIDES. The Reports page (public/pm/c3-reports.js) loads it as /lib/report-frame.js (self.ReportFrame), and the
10
+ * scheduled run (lib/report-schedules.js) requires it, so a scheduled draft reads exactly like a "Make a draft now" draft. Pure: no
11
+ * DOM, no I/O, no clock.
12
+ */
13
+ 'use strict';
14
+ (function (root, factory) {
15
+ const api = factory();
16
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
17
+ else root.ReportFrame = api;
18
+ })(typeof self !== 'undefined' ? self : this, function () {
19
+ /* The model the builders read, from the data answer, the way c3-strategy-panels.js build() makes it. */
20
+ function modelOf(raw) {
21
+ const M = { ...raw, outcomes: raw.outcomes || [], signals: raw.signals || [] };
22
+ if (!M.signalsAll) M.signalsAll = M.signals.map((s) => ({ ...s }));
23
+ return M;
24
+ }
25
+ const builderOf = (rep) => (rep && (rep.builder || rep.id)) || '';
26
+
27
+ /* ── the text of a report, for the share frame: one sentence, the numbers behind it, and what needs a call ─────────────── */
28
+ const fmt = (v) => (v == null ? '' : Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100));
29
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
30
+ const ST_TXT = { done: 'done', ok: 'on time', risk: 'at risk', late: 'delayed', over: 'overdue', park: 'parked', prop: 'proposed', none: 'no status' };
31
+ const SEV_TXT = { pivot: 'at the pivot line', drift: 'drifting', watch: 'on watch', up: 'upside' };
32
+ function readingOf(rep, raw) {
33
+ const M = modelOf(raw || {}); const b = builderOf(rep);
34
+ const out = { line: '', nums: [], needs: [] };
35
+ if (b === 'north') {
36
+ const N = M.north; const P = N && N.points;
37
+ if (!P || P.length < 2) { out.line = 'No north-star metric is recorded.'; return out; }
38
+ const p = P.find((x) => x.at === N.now) || P[P.length - 1]; const u = N.unit || '';
39
+ if (p.actual == null || !p.plan) { out.line = (N.label || 'The north star') + ' has no reading yet.'; return out; }
40
+ const v = Math.round((p.actual / p.plan - 1) * 100);
41
+ out.line = (N.label || 'The north star') + (v === 0 ? ' is on plan.' : ' is ' + Math.abs(v) + '% ' + (v < 0 ? 'behind' : 'ahead of') + ' plan.');
42
+ out.nums.push('Now ' + fmt(p.actual) + u + ', plan ' + fmt(p.plan) + u + '.');
43
+ return out;
44
+ }
45
+ if (b === 'outcomes') {
46
+ const L = M.outcomes;
47
+ if (!L.length) { out.line = 'No key result is scored.'; return out; }
48
+ const on = L.filter((k) => k.status === 'ok').length;
49
+ out.line = on + ' of ' + L.length + ' outcomes ' + (on === 1 ? 'is' : 'are') + ' on course.';
50
+ for (const k of L) {
51
+ const u = k.unit || '';
52
+ out.nums.push(k.status === 'unscored' ? k.name + ': not scored.' : k.name + ': now ' + fmt(k.now) + u + ', should be ' + fmt(k.expected) + u + ', target ' + fmt(k.target) + u + '.');
53
+ if (k.status === 'off') out.needs.push(k.name + ' is off course.');
54
+ }
55
+ return out;
56
+ }
57
+ if (b === 'signals') {
58
+ const L = M.signals;
59
+ if (!L.length) { out.line = 'No signal is open.'; return out; }
60
+ const by = {}; for (const s of L) by[s.sev] = (by[s.sev] || 0) + 1;
61
+ out.line = plural(L.length, 'signal is', 'signals are') + ' open: ' + Object.keys(by).map((k) => by[k] + ' ' + (SEV_TXT[k] || k)).join(', ') + '.';
62
+ for (const s of L) { out.nums.push(s.t + '. ' + (s.ev || '')); if (s.sev === 'pivot' || s.sev === 'drift') out.needs.push(s.t + '.'); }
63
+ return out;
64
+ }
65
+ if (b === 'delivery') {
66
+ const R = (M.delivery && M.delivery.rows) || [];
67
+ if (!R.length) { out.line = 'No commitment has a promised date this quarter.'; return out; }
68
+ const n = (f) => R.filter(f).length;
69
+ out.line = n((r) => r.st === 'ok' || r.st === 'done') + ' of ' + R.length + ' commitments are on time or done, ' + n((r) => r.st === 'risk') + ' at risk, ' + n((r) => r.st === 'late' || r.st === 'over') + ' delayed or overdue.';
70
+ for (const r of R) { out.nums.push(r.n + ': ' + (ST_TXT[r.st] || r.st) + '.'); if (r.st === 'late' || r.st === 'over') out.needs.push(r.n + ' is ' + ST_TXT[r.st] + '.'); }
71
+ return out;
72
+ }
73
+ if (b === 'maturity') {
74
+ const T = M.maturity;
75
+ if (!T || !T.pillars || !T.pillars.length) { out.line = 'No pillar maturity is recorded.'; return out; }
76
+ out.line = T.pillars.filter((p) => p.proj >= p.tgt).length + ' of ' + T.pillars.length + ' pillars are on course for ' + T.target_label + '.';
77
+ for (const p of T.pillars) out.nums.push(p.n + ': stage ' + p.now + ', target ' + p.tgt + ', projected ' + p.proj + '.');
78
+ return out;
79
+ }
80
+ if (b === 'bets') {
81
+ const T = M.timeline;
82
+ if (!T || !T.rows || !T.rows.length) { out.line = 'No bet is recorded.'; return out; }
83
+ const cp = (T.checkpoints || [])[0];
84
+ out.line = plural(T.rows.length, 'bet is', 'bets are') + ' on the plan. ' + (cp ? 'The next checkpoint is ' + cp.label + '.' : 'No checkpoint is recorded.');
85
+ for (const r of T.rows) out.nums.push(r.title + ': ' + (ST_TXT[r.st] || 'no status') + '.');
86
+ return out;
87
+ }
88
+ if (b === 'synergy') {
89
+ const S = M.synergy;
90
+ if (!S || !S.rows || !S.rows.length) { out.line = 'No bet is recorded.'; return out; }
91
+ if (!S.columns || !S.columns.length) { out.line = 'No key result exists to measure the bets against.'; return out; }
92
+ const active = S.rows.filter((r) => !r.parked);
93
+ const gaps = S.columns.filter((_, j) => !active.some((r) => r.weights[j] > 0));
94
+ out.line = plural(active.length, 'active bet covers', 'active bets cover') + ' ' + (S.columns.length - gaps.length) + ' of ' + S.columns.length + ' ' + (S.unit || 'key results') + '.';
95
+ for (const r of S.rows) out.nums.push(r.title + (r.parked ? ': parked.' : ': active.'));
96
+ for (const g of gaps) out.needs.push('No active bet covers ' + g.n + '.');
97
+ return out;
98
+ }
99
+ out.line = 'This report has no text form.';
100
+ return out;
101
+ }
102
+
103
+ /* The report frame of a dashboard, as data (the sheet and the draft body are both drawn from it). */
104
+ function frameOf({ title, reports, raw, audience, today }) {
105
+ const lead = audience !== 'Team';
106
+ const sections = reports.map((r) => { const t = readingOf(r, raw); return { id: r.id, title: r.title, line: t.line, nums: lead ? [] : t.nums, needs: t.needs }; });
107
+ const needs = [...new Set(sections.flatMap((s) => s.needs))];
108
+ const headline = !sections.length ? 'This dashboard has no reports yet.'
109
+ : needs.length ? needs[0] + (needs.length > 1 ? ' ' + plural(needs.length - 1, 'more thing needs', 'more things need') + ' your call.' : '') : sections[0].line;
110
+ const sources = [...new Set(reports.map((r) => r.source).filter(Boolean))];
111
+ return { title, audience: lead ? 'Leadership' : 'Team', today, headline, sections, needs, sources };
112
+ }
113
+ function draftBody(F) {
114
+ const L = [F.title + ' for ' + F.audience + ', ' + F.today, 'Headline: ' + F.headline, '', 'Sections'];
115
+ for (const s of F.sections) { L.push(s.title, '- ' + s.line + ' [reported]'); for (const n of s.nums) L.push('- ' + n + ' [reported]'); }
116
+ L.push('', 'Needs your call');
117
+ if (F.needs.length) for (const n of F.needs) L.push('- ' + n + ' [reported]'); else L.push('Nothing needs your call in this scope.');
118
+ L.push('', 'Sources: ' + (F.sources.length ? F.sources.join('; ') : 'none') + '. Read on ' + F.today + '.');
119
+ return L.join('\n');
120
+ }
121
+ return { modelOf, builderOf, plural, readingOf, frameOf, draftBody };
122
+ });