create-pmos 0.134.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 (209) 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 +16 -1
  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-capture.js +9 -1
  83. package/workspace/lib/notes-final.js +38 -13
  84. package/workspace/lib/notes-replay.js +125 -0
  85. package/workspace/lib/notes-screen.js +180 -0
  86. package/workspace/lib/notes-whisper.js +10 -3
  87. package/workspace/lib/outbox-store.js +10 -6
  88. package/workspace/lib/pmos-verbs.js +3 -1
  89. package/workspace/lib/proactive-cli.js +7 -0
  90. package/workspace/lib/proactive.js +15 -2
  91. package/workspace/lib/repo-script.js +36 -0
  92. package/workspace/lib/report-dashboards.js +5 -2
  93. package/workspace/lib/report-frame.js +122 -0
  94. package/workspace/lib/report-schedules.js +113 -0
  95. package/workspace/lib/route-modules.js +9 -2
  96. package/workspace/lib/routes-boards.js +734 -0
  97. package/workspace/lib/routes-brief-page.js +13 -4
  98. package/workspace/lib/routes-briefing.js +22 -11
  99. package/workspace/lib/routes-c3-init.js +4 -3
  100. package/workspace/lib/routes-c3-life.js +2 -1
  101. package/workspace/lib/routes-c3-reports.js +96 -13
  102. package/workspace/lib/routes-c3-strategy.js +4 -3
  103. package/workspace/lib/routes-c3-timeline.js +3 -2
  104. package/workspace/lib/routes-calendar-import.js +3 -2
  105. package/workspace/lib/routes-calendar-views.js +9 -4
  106. package/workspace/lib/routes-doc-health.js +2 -1
  107. package/workspace/lib/routes-doc-sheet.js +2 -1
  108. package/workspace/lib/routes-forecast.js +2 -1
  109. package/workspace/lib/routes-hooks.js +3 -2
  110. package/workspace/lib/routes-initiatives.js +2 -1
  111. package/workspace/lib/routes-intake.js +2 -1
  112. package/workspace/lib/routes-notes.js +55 -9
  113. package/workspace/lib/routes-outbox.js +23 -3
  114. package/workspace/lib/routes-pm-items.js +19 -6
  115. package/workspace/lib/routes-records.js +3 -2
  116. package/workspace/lib/routes-releases.js +2 -1
  117. package/workspace/lib/routes-report-facts.js +4 -3
  118. package/workspace/lib/routes-report-families.js +2 -1
  119. package/workspace/lib/routes-roadmap-ops.js +5 -4
  120. package/workspace/lib/routes-runs.js +6 -5
  121. package/workspace/lib/routes-settings.js +51 -0
  122. package/workspace/lib/routes-signals.js +3 -2
  123. package/workspace/lib/routes-stage-tools.js +220 -0
  124. package/workspace/lib/run-answers.js +2 -1
  125. package/workspace/lib/settings-store.js +226 -0
  126. package/workspace/lib/stage-tools.js +208 -0
  127. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  128. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  129. package/workspace/lib/work-evidence.js +73 -0
  130. package/workspace/package-lock.json +2 -2
  131. package/workspace/package.json +1 -1
  132. package/workspace/public/pm/briefing-deck.js +63 -17
  133. package/workspace/public/pm/briefing-model.js +36 -7
  134. package/workspace/public/pm/c3-ambient.js +100 -0
  135. package/workspace/public/pm/c3-brief-needs.js +26 -6
  136. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  137. package/workspace/public/pm/c3-brief-today.js +56 -21
  138. package/workspace/public/pm/c3-brief.js +2 -1
  139. package/workspace/public/pm/c3-calendar.js +161 -42
  140. package/workspace/public/pm/c3-calpop.js +5 -5
  141. package/workspace/public/pm/c3-demo-boards.js +649 -0
  142. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  143. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  144. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  145. package/workspace/public/pm/c3-demo-data.js +17 -27
  146. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  147. package/workspace/public/pm/c3-demo.js +2 -1
  148. package/workspace/public/pm/c3-dock.js +1 -1
  149. package/workspace/public/pm/c3-ib-docs.js +80 -0
  150. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  151. package/workspace/public/pm/c3-ib-items.js +201 -0
  152. package/workspace/public/pm/c3-ib-releases.js +51 -0
  153. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  154. package/workspace/public/pm/c3-ib-triage.js +178 -0
  155. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  156. package/workspace/public/pm/c3-ini-records.js +197 -0
  157. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  158. package/workspace/public/pm/c3-ini-ui.js +229 -0
  159. package/workspace/public/pm/c3-ini-work.js +139 -0
  160. package/workspace/public/pm/c3-initiatives.css +218 -0
  161. package/workspace/public/pm/c3-initiatives.js +741 -0
  162. package/workspace/public/pm/c3-plan.js +2 -2
  163. package/workspace/public/pm/c3-reports.css +47 -47
  164. package/workspace/public/pm/c3-reports.js +223 -185
  165. package/workspace/public/pm/c3-settings.css +74 -0
  166. package/workspace/public/pm/c3-settings.js +703 -0
  167. package/workspace/public/pm/c3-shell.js +102 -36
  168. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  169. package/workspace/public/pm/c3-terminal.js +2 -2
  170. package/workspace/public/pm/calendar-views.js +1 -1
  171. package/workspace/public/pm/components.css +319 -213
  172. package/workspace/public/pm/console3.css +99 -23
  173. package/workspace/public/pm/dest-notes.js +26 -4
  174. package/workspace/public/pm/notes-screen-capture.js +165 -0
  175. package/workspace/public/pm/notes-screen-view.js +98 -0
  176. package/workspace/public/pm/panel-trees.js +5 -2
  177. package/workspace/public/pm/pm-work-view.js +233 -146
  178. package/workspace/public/pm/shell-bell.js +3 -2
  179. package/workspace/public/pm/shell-recording.js +29 -8
  180. package/workspace/public/pm/tokens.css +2 -2
  181. package/workspace/public/pm/view-notes.js +46 -9
  182. package/workspace/public/pm/welcome.js +197 -89
  183. package/workspace/public/welcome.html +6 -4
  184. package/workspace/server.js +105 -6
  185. package/workspace/skills/day-check.skill +1 -1
  186. package/workspace/skills/jira-sync.skill +6 -4
  187. package/workspace/skills/morning-brief.skill +1 -1
  188. package/workspace/test-files.json +12 -3
  189. package/workspace/test-lists/c3-ib-items.json +9 -0
  190. package/workspace/test-lists/c3-ib-more.json +9 -0
  191. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  192. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  193. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  194. package/workspace/test-lists/console3-briefing.json +9 -0
  195. package/workspace/test-lists/console3-foundation.json +9 -0
  196. package/workspace/test-lists/console3-reports.json +3 -3
  197. package/workspace/test-lists/meeting-notes.json +10 -4
  198. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  199. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  200. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  201. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  202. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  203. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  204. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  205. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  206. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  207. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  208. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  209. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -0,0 +1,5 @@
1
+ // Ported verbatim from the PM's sample (prototype/pmos-console-handoff/sample/plan.js on feat/console3-merge); source of truth for this control. Only the fixture path (PF) and this header differ.
2
+ const D=86400000,day=s=>Date.parse(s+'T00:00:00Z'),fmt=ms=>new Date(ms).toISOString().slice(0,10);
3
+ const fold=(events,cut)=>{const s={};for(const e of events){if(cut&&e.snap>cut)continue;if(e.kind==='add')s[e.ticket]={...e.fields,origin:e.by,edits:0};else if(e.kind==='status'&&s[e.ticket]){s[e.ticket].status=e.fields.status}else if(s[e.ticket]){Object.assign(s[e.ticket],e.fields);s[e.ticket].edits++}}return s};
4
+ const plan=(s,start)=>{const end={};const go=(k,seen=[])=>{if(seen.includes(k))throw new Error('dependency cycle at '+k);return end[k]??(end[k]=Math.max(day(start),...(s[k].deps||[]).map(d=>{if(!s[d])throw new Error(k+' depends on unknown '+d);return go(d,[...seen,k])}))+s[k].dur*D)};Object.keys(s).forEach(k=>go(k));return{end,forecast:Math.max(...Object.values(end))}};
5
+ module.exports={fold,plan,day,fmt,D};
@@ -0,0 +1,21 @@
1
+ // Ported verbatim from the PM's sample (prototype/pmos-console-handoff/sample/trust.js on feat/console3-merge); source of truth for this control. Only the fixture path (PF) and this header differ.
2
+ // Claim checker: every claim carries one label and what that label requires. Strictness depends on the destination.
3
+ module.exports.checkReport=function(rep,rules,ctx,dest){
4
+ const R=rules.destinations[dest],rows=[];
5
+ for(const c of rep.sections){let v='ok',why='';const set=(lvl,msg)=>{if(v==='block')return;if(lvl==='block'||v==='ok'){v=lvl;why=msg}};
6
+ const def=rules.basis[c.basis];
7
+ if(!c.basis||!def)set(R.unlabelled,'no label (fact, cited, reported, estimate, assumption or judgement)');
8
+ else if(!R.allow.includes(c.basis))set('block',c.basis+' is not allowed for '+dest);
9
+ else if((c.source||'').startsWith('import:')&&c.basis!=='reported')set('block','source is an untrusted import; the claim must be labelled reported');
10
+ else{const miss=def.needs.filter(k=>!c[k]);if(miss.length)set(R.incomplete,'missing '+miss.join(', '));
11
+ if(c.basis==='fact'||c.basis==='cited'){const m=(c.source||'').split('@');
12
+ if(m[0]==='forecast'||m[0]==='points'){if(!ctx.snaps.includes(m[1]))set('block','no such check '+m[1]);else if(m[1]<ctx.last)set(R.stale,'source is the '+m[1]+' check; latest is '+ctx.last)}
13
+ else if(!ctx.docs[m[0]])set('block','source '+m[0]+' does not exist');
14
+ else if(m[1]&&+m[1]<ctx.docs[m[0]].rev)set(R.stale,m[0]+' is now rev '+ctx.docs[m[0]].rev+'; claim used rev '+m[1])}
15
+ if(c.basis==='assumption'&&c.review_by&&c.review_by<ctx.now)set(R.overdue_assumption,'review date '+c.review_by+' has passed')}
16
+ rows.push({id:c.id,basis:c.basis||'(none)',claim:c.claim,verdict:v,why})}
17
+ // bad news rule: every tripped signal must be covered by a section
18
+ const covered=new Set(rep.sections.flatMap(s=>s.covers||[]));const omitted=(ctx.tripped||[]).filter(x=>!covered.has(x));
19
+ if(omitted.length&&R.unlabelled==='block')rows.push({id:'omitted',basis:'(rule)',claim:'Report omits tripped signal: '+omitted.join(', '),verdict:'block',why:'a report must cover every tripped signal'});
20
+ const blocked=rows.filter(r=>r.verdict==='block').length;
21
+ return{rows,send:blocked===0?'allowed':'blocked ('+blocked+' claim'+(blocked>1?'s':'')+')',label_visible:R.label_visible}};
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+ // console3 wave B4: the who-holds-what truth table. Pure. No I/O, no send.
3
+ // messages: [{ id?, to, at, claims: [{ id, text?, value? }] }] (what each person was told)
4
+ // truth: { [claimId]: { value, text?, changed_at? } } (what is true now)
5
+ // Only claims present in messages are listed. A claim is never invented.
6
+ // State per told claim (the person's latest message on that claim):
7
+ // contradicted the truth has a value and it differs from the value told
8
+ // stale the truth changed after the message, or the truth for the claim is not known
9
+ // in sync the truth holds the value told and has not changed since
10
+ const V = require('./derived/derive.js');
11
+ // BACKED BY THE PM'S SAMPLE: whoHoldsWhatFromPlan below is reader.js section 7 (derive.heldVsTrue): the truth is DERIVED from the
12
+ // plan, 'ship-date' = the forecast and 'ticket:<id>' = that ticket's end, and a claim is in sync or N days apart.
13
+ // REPO-WRITTEN, no sample equivalent: whoHoldsWhat, the generic claim table with the 'stale' state (truth changed after the send).
14
+ const same = (a, b) => JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b);
15
+ const ts = (v) => { const t = Date.parse(v); return Number.isNaN(t) ? null : t; };
16
+
17
+ function stateOf(told, at, now) {
18
+ if (!now || typeof now !== 'object') return 'stale';
19
+ if ('value' in now && told.value !== undefined && !same(told.value, now.value)) return 'contradicted';
20
+ if (told.value === undefined && 'text' in now && told.text !== undefined && told.text !== now.text) return 'contradicted';
21
+ const changed = ts(now.changed_at), sent = ts(at);
22
+ if (changed !== null && (sent === null || changed > sent)) return 'stale';
23
+ return 'in sync';
24
+ }
25
+
26
+ const RANK = { 'in sync': 0, stale: 1, contradicted: 2 };
27
+
28
+ function whoHoldsWhat(messages = [], truth = {}) {
29
+ const people = new Map();
30
+ const list = (Array.isArray(messages) ? messages : []).slice().sort((a, b) => (ts(a && a.at) || 0) - (ts(b && b.at) || 0));
31
+ for (const m of list) {
32
+ if (!m || !m.to || !Array.isArray(m.claims)) continue;
33
+ if (!people.has(m.to)) people.set(m.to, new Map());
34
+ for (const c of m.claims) {
35
+ if (!c || !c.id) continue;
36
+ people.get(m.to).set(c.id, { ...c, at: m.at || null }); // the later message replaces the earlier one
37
+ }
38
+ }
39
+ const t = truth && typeof truth === 'object' ? truth : {};
40
+ return [...people.entries()].map(([person, held]) => {
41
+ const claims = [...held.values()].map((c) => {
42
+ const now = Object.prototype.hasOwnProperty.call(t, c.id) ? t[c.id] : null;
43
+ return {
44
+ id: c.id, text: c.text || null, told_at: c.at, told_value: c.value === undefined ? null : c.value,
45
+ truth_value: now && 'value' in now ? now.value : null, state: stateOf(c, c.at, now),
46
+ };
47
+ });
48
+ const worst = claims.reduce((w, c) => (RANK[c.state] > RANK[w] ? c.state : w), 'in sync');
49
+ return { person, claims, state: worst };
50
+ });
51
+ }
52
+
53
+ // messages: sent.jsonl records { to, claims: [{ subject, value }] }; pl: derived/plan.js plan(state, start).
54
+ // Returns one row per told claim: { to, subject, told, true, gap, state } with state 'in sync' | '<n>d apart' | 'unknown'.
55
+ const whoHoldsWhatFromPlan = (messages, pl) => V.heldVsTrue(Array.isArray(messages) ? messages : [], pl);
56
+
57
+ module.exports = { whoHoldsWhatFromPlan, whoHoldsWhat, STATES: Object.keys(RANK) };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+ /* workspace-lib.js — where a workspace/lib/ module is, for a script that runs in a checkout AND as a copy in a workspace
3
+ * package (incident workspace-package-script-closure).
4
+ *
5
+ * require(workspaceLib('plan-items.js'))
6
+ *
7
+ * Some scripts share a fold with the workspace (the plan items, the item steps). In a checkout that module is at
8
+ * workspace/lib/<name>. In a package the script is a copy that scripts/vendor-workspace-engine.js put in lib/engine/,
9
+ * and this file is lib/engine/workspace-lib.js, so the module is the bundle's own lib/<name>, one folder up. A plain relative
10
+ * require of ../workspace/lib/x.js resolves in the first place only, and the vendor step refuses it.
11
+ *
12
+ * Both places are tried, the checkout first. When neither exists the checkout path is returned, and require() names it.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ function workspaceLib(name) {
18
+ const n = String(name || '');
19
+ if (!/^[a-z0-9][a-z0-9-]*\.js$/.test(n)) throw new Error(`workspaceLib: '${n}' must be a file name in workspace/lib/, such as 'plan-items.js'`);
20
+ const candidates = [path.join(__dirname, '..', 'workspace', 'lib', n), path.join(__dirname, '..', n)];
21
+ return candidates.find((p) => fs.existsSync(p)) || candidates[0];
22
+ }
23
+
24
+ module.exports = { workspaceLib };
@@ -53,7 +53,7 @@
53
53
  const fs = require('fs');
54
54
  const path = require('path');
55
55
 
56
- const WORK_ITEM_PATHS = [path.join(__dirname, '..', '..', 'scripts', 'work-item.js')];
56
+ const WORK_ITEM_PATHS = [path.join(__dirname, '..', '..', 'scripts', 'work-item.js')]; // vendor: checkout-only — its sink guard is workspace/lib/sink.js, outside scripts/; an install reports engine-missing (header)
57
57
  const READY = new Set(['verified', 'integrated']);
58
58
  const PR_RE = /^(?:https?:\/\/\S+\/pull\/\d+\S*|pr:#?\d+)$/i;
59
59
 
@@ -24,8 +24,9 @@ const crypto = require('crypto');
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const repoWatch = require('./repo-watch.js');
27
+ const { repoScript } = require('./repo-script.js');
27
28
 
28
- const CLI = path.resolve(__dirname, '..', '..', 'scripts', 'signals', 'facts-cli.js');
29
+ const CLI = repoScript('signals/facts-cli.js'); // a checkout's scripts/, or the copy vendored into lib/engine/
29
30
  const TTL_MS = 5 * 60 * 1000; // only for a folder with no signature
30
31
  const MAX_AGE_MS = 24 * 3600 * 1000; // an entry this old is rebuilt even when the signature holds
31
32
  const SIG_REUSE_MS = 2000; // a burst of requests asks git once
@@ -41,8 +42,7 @@ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
41
42
  /** The version of the code that derives the facts: a change to it makes every entry stale. */
42
43
  function engineVersion() {
43
44
  if (engineMemo) return engineMemo;
44
- const sig = path.resolve(__dirname, '..', '..', 'scripts');
45
- const parts = [CLI, path.join(sig, 'signals', 'facts.js'), path.join(sig, 'workflow-state.js')].map((f) => {
45
+ const parts = [CLI, repoScript('signals/facts.js'), repoScript('workflow-state.js')].map((f) => {
46
46
  try { return sha1(fs.readFileSync(f)); } catch { return '-'; }
47
47
  });
48
48
  engineMemo = sha1(parts.join('|')).slice(0, 12);
@@ -86,7 +86,7 @@ async function currentSig(root, opts, clock) {
86
86
  }
87
87
 
88
88
  function normalize(facts, today) {
89
- try { return require('../../scripts/signals/facts.js').normalizeFacts(facts, today || undefined); } catch { return facts; }
89
+ try { return require(repoScript('signals/facts.js')).normalizeFacts(facts, today || undefined); } catch { return facts; }
90
90
  }
91
91
 
92
92
  function readFactsAsync(root, opts = {}) {
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+ /* headless.js — which agent jobs run as headless Claude Code (`claude -p`) instead of a terminal pane
3
+ * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
+ * jobs that need no human run headless).
5
+ *
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | null (null: open a pane, as before)
7
+ * argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
8
+ * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
+ * logLines(result, exitCode) → the lines the read-only log pane shows at the end
10
+ *
11
+ * Pure: no I/O, no process. server.js spawnSession starts the child and keeps the session row.
12
+ *
13
+ * A headless run uses the PM's own login, model choice (D75), profile and connectors, exactly as a pane does.
14
+ * It cannot ask for permission, so each kind gets a FIXED tool list here; a connector tool runs only where the
15
+ * PM's own Claude Code settings already allow it, and anything else is denied and reported. No bypass flag is
16
+ * ever passed. `PMOS_HEADLESS=off` sends every job back to a pane.
17
+ */
18
+
19
+ // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
20
+ const HEADLESS_SKILLS = Object.freeze([
21
+ 'day-check', 'calendar-sync', 'jira-sync', 'my-work-read', 'report-biweekly', 'report-template',
22
+ 'tracker-refresh', 'measure', 'memory-groom', 'backlog-triage',
23
+ ]);
24
+ // The note passes that run headless, by the origin prefix lib/routes-notes.js gives them.
25
+ const HEADLESS_ORIGINS = Object.freeze(['notes-bullets:', 'notes-summary:']);
26
+
27
+ // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
28
+ // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
29
+ const TOOLS = Object.freeze({
30
+ notes: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(mv *)']),
31
+ skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
32
+ 'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
33
+ });
34
+
35
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
36
+
37
+ function switchedOff(env) {
38
+ return /^(off|0|false|no)$/i.test(String((env || process.env).PMOS_HEADLESS || '').trim());
39
+ }
40
+
41
+ function kindFor({ agent, origin, skill, env } = {}) {
42
+ if (agent !== 'claude' || switchedOff(env)) return null;
43
+ const o = String(origin || '');
44
+ if (HEADLESS_ORIGINS.some((p) => o.startsWith(p))) return 'notes';
45
+ if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
46
+ return null;
47
+ }
48
+
49
+ function argsFor(kind, { modelArgs = [], addDirs = [] } = {}) {
50
+ const tools = TOOLS[kind];
51
+ if (!tools) throw new Error('unknown headless kind: ' + kind);
52
+ const argv = ['-p', '--output-format', 'json', '--no-session-persistence', ...modelArgs, '--allowedTools', tools.join(',')];
53
+ for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
54
+ return argv;
55
+ }
56
+
57
+ function timeoutMs(env) {
58
+ const n = Number((env || process.env).PMOS_HEADLESS_TIMEOUT_MS);
59
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
60
+ }
61
+
62
+ /** The last JSON object on stdout (`--output-format json` prints one). Anything unreadable is not a result. */
63
+ function parseResult(stdout) {
64
+ const text = String(stdout || '').trim();
65
+ let o = null;
66
+ try { o = JSON.parse(text); } catch {
67
+ const at = text.lastIndexOf('\n{');
68
+ try { o = JSON.parse(at >= 0 ? text.slice(at + 1) : ''); } catch { o = null; }
69
+ }
70
+ if (!o || typeof o !== 'object') return { ok: false, text: text.slice(-2000), isError: true, turns: null, durationMs: null, costUsd: null, denied: [] };
71
+ const denied = Array.isArray(o.permission_denials)
72
+ ? [...new Set(o.permission_denials.map((d) => d && d.tool_name).filter(Boolean).map(String))] : [];
73
+ return {
74
+ ok: true,
75
+ text: typeof o.result === 'string' ? o.result : '',
76
+ isError: !!o.is_error || (typeof o.subtype === 'string' && o.subtype !== 'success'),
77
+ turns: Number.isFinite(o.num_turns) ? o.num_turns : null,
78
+ durationMs: Number.isFinite(o.duration_ms) ? o.duration_ms : null,
79
+ costUsd: Number.isFinite(o.total_cost_usd) ? o.total_cost_usd : null,
80
+ denied,
81
+ };
82
+ }
83
+
84
+ function logLines(r, exitCode) {
85
+ const out = [];
86
+ if (r.text) out.push(...String(r.text).split('\n'));
87
+ const facts = [`exit code ${exitCode}`];
88
+ if (r.durationMs != null) facts.push(`${Math.round(r.durationMs / 1000)} s`);
89
+ if (r.turns != null) facts.push(`${r.turns} turns`);
90
+ out.push('', `[${facts.join(' · ')}]`);
91
+ if (r.denied.length) out.push(`Not allowed in a background run: ${r.denied.join(', ')}. Allow these in Claude Code settings, or set PMOS_HEADLESS=off to use a terminal pane.`);
92
+ return out;
93
+ }
94
+
95
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
@@ -9,7 +9,8 @@ const path = require('path');
9
9
  /** Where scripts/onboarding/ is: the checkout's own, else the copy an installed package vendors under lib/vendor/
10
10
  * (scripts/vendor-workspace-engine.js vendorTree). Null when neither is there. */
11
11
  function onboardingDir() {
12
- for (const d of [path.join(__dirname, '..', '..', 'scripts', 'onboarding'), path.join(__dirname, 'vendor', 'scripts', 'onboarding')]) {
12
+ const inCheckout = path.dirname(path.join(__dirname, '..', '..', 'scripts', 'onboarding', 'scan.js')); // vendor: checkout-only (vendorTree copies the onboarding tree)
13
+ for (const d of [inCheckout, path.join(__dirname, 'vendor', 'scripts', 'onboarding')]) {
13
14
  if (fs.existsSync(path.join(d, 'scan.js'))) return d;
14
15
  }
15
16
  return null;
@@ -13,8 +13,10 @@
13
13
  * readSnapshot(root, initiative) → { fetchedAt, query, tickets: Map<key, reading> } | null
14
14
  * which(root, key) → the initiative and ticket a key belongs to
15
15
  *
16
- * A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed } | null,
17
- * subtasks: { done, total }, fixVersion: [names], flagged, reason, url }. A ticket the search did not return keeps its last reading, marked
16
+ * A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed, id?, start?, end?,
17
+ * completed? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
18
+ * flagged, reason, url }. The sprint's dates, `points` and `resolved` feed the sprint burndown (lib/stage-tools.js); a
19
+ * reading saved before they were carried has none of them, and the burndown says it cannot be drawn. A ticket the search did not return keeps its last reading, marked
18
20
  * stale. Titles and statuses only: never a description, a comment or a person.
19
21
  *
20
22
  * BOUNDARY (J15). No network module, no child process. The only write is the snapshot file.
@@ -83,7 +85,30 @@ function sprintOne(v) {
83
85
  const name = clip(oneLine(v.name), 80);
84
86
  const state = String(v.state || '').toLowerCase();
85
87
  if (!name) return null;
86
- return { name, state: SPRINT_STATES.includes(state) ? state : 'active' };
88
+ const out = { name, state: SPRINT_STATES.includes(state) ? state : 'active' };
89
+ if ((Number.isInteger(v.id) && v.id > 0) || (isStr(v.id) && /^\d{1,12}$/.test(v.id))) out.id = String(v.id);
90
+ for (const [k, from] of [['start', ['startDate', 'start']], ['end', ['endDate', 'end']], ['completed', ['completeDate', 'completed']]]) {
91
+ const d = dayOf(from.map((f) => v[f]).find((x) => x != null));
92
+ if (d) out[k] = d;
93
+ }
94
+ return out;
95
+ }
96
+ /** An ISO date or date-time → YYYY-MM-DD (the date as written, never shifted by a zone), else null. */
97
+ function dayOf(v) {
98
+ if (!isStr(v)) return null;
99
+ const m = /^(\d{4}-\d{2}-\d{2})(?:$|[T ])/.exec(v.trim());
100
+ return m ? m[1] : null;
101
+ }
102
+ /** Story points: a non-negative number, from the flat `points` or a points field Jira names (the custom field id differs per
103
+ * site; these are the common ones). null when none is set: an unpointed ticket, which the burndown counts apart. */
104
+ const POINT_FIELDS = ['points', 'story_points', 'storyPoints', 'Story Points', 'Story point estimate', 'customfield_10016', 'customfield_10026', 'customfield_10028', 'customfield_10002'];
105
+ function pointsIn(o) {
106
+ for (const k of POINT_FIELDS) {
107
+ const v = o[k];
108
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1000) return v;
109
+ if (isStr(v) && /^\d{1,4}(\.\d+)?$/.test(v.trim())) return Number(v);
110
+ }
111
+ return null;
87
112
  }
88
113
  /** The sprint that matters: the active one, else the next, else the last closed. null is the backlog. */
89
114
  function sprintOf(v) {
@@ -134,6 +159,7 @@ function normalizeIssue(x) {
134
159
  const status = nameOf(st) || 'Unknown';
135
160
  const flagged = flaggedIn(f) || /block|impediment|on hold/i.test(status);
136
161
  return { key, summary, status, category: categoryOf(status, st.statusCategory), sprint: sprintIn(f), subtasks: subtasksOf(f.subtasks), fixVersion: fixVersionsOf(f.fixVersions),
162
+ points: pointsIn(f), resolved: dayOf(f.resolutiondate || f.resolved),
137
163
  flagged, reason: flagged ? (flaggedIn(f) ? 'Flagged in Jira' : `Status: ${status}`) : null, url: urlOf(x, key) };
138
164
  }
139
165
  // The flat shape: what the skill tells the agent to write when it is unsure of the connector's shape.
@@ -142,6 +168,7 @@ function normalizeIssue(x) {
142
168
  const status = oneLine(x.status) || 'Unknown';
143
169
  const flagged = x.flagged === true || /block|impediment|on hold/i.test(status);
144
170
  return { key, summary, status, category: categoryOf(status, x.category), sprint: sprintOf(x.sprint), subtasks: subtasksOf(x.subtasks), fixVersion: fixVersionsOf(x.fixVersions != null ? x.fixVersions : x.fixVersion),
171
+ points: pointsIn(x), resolved: dayOf(x.resolved || x.resolutiondate),
145
172
  flagged, reason: flagged ? clip(oneLine(x.reason || x.flag_reason || (x.flagged === true ? 'Flagged in Jira' : `Status: ${status}`)), 160) : null,
146
173
  url: urlOf(x, key) };
147
174
  }
@@ -8,6 +8,8 @@
8
8
  * the live transcription's progress, so a restart transcribes what it had buffered
9
9
  * close(runtimeDir, id) Pause or Stop: the capture ends, and the next Listen starts a new one
10
10
  * tokened(runtimeDir, id) → true when the live capture has sent a chunk with its token
11
+ * owns(runtimeDir, id, { token }) → { ok, base_ms } | { ok: false, status: 409, error } is this the live, not-closed capture's token
12
+ * (a screenshot has no sequence number, so it is checked on the token alone)
11
13
  * remove(runtimeDir, id)
12
14
  *
13
15
  * WHY. The page sends each source in one-second chunks and sends a chunk again when its answer does not come back. Before this
@@ -111,6 +113,12 @@ function untranscribed(runtimeDir, id, { source, before } = {}) {
111
113
  const to = Math.min(before, (s.fed || {})[source] || 0);
112
114
  return to > from ? { from, to } : null;
113
115
  }
116
+ /** Does this token belong to the note's live capture, one that was not closed by Pause or Stop? Changes nothing. */
117
+ function owns(runtimeDir, id, { token } = {}) {
118
+ const s = load(runtimeDir, id);
119
+ if (!s || !s.token || s.closed || s.token !== token) return { ok: false, status: 409, error: 'this meeting is being recorded in another tab or window, or listening stopped' };
120
+ return { ok: true, base_ms: s.base_ms };
121
+ }
114
122
  function tokened(runtimeDir, id, { now = Date.now() } = {}) { const s = load(runtimeDir, id); return isLive(s, now) && !!s.tokened; }
115
123
  function close(runtimeDir, id) {
116
124
  const s = load(runtimeDir, id);
@@ -125,4 +133,4 @@ function remove(runtimeDir, id) {
125
133
  try { fs.unlinkSync(f); } catch { /* none */ }
126
134
  }
127
135
 
128
- module.exports = { start, busy, check, mark, setCut, untranscribed, close, tokened, remove, LIVE_MS };
136
+ module.exports = { start, busy, check, mark, setCut, untranscribed, close, tokened, owns, remove, LIVE_MS };
@@ -6,6 +6,7 @@
6
6
  * createFinal({ runtimeDir, onDone, onState, run?, detect?, qwen? }) → { queue(id, opts), cancel(id), state(id), idle() }
7
7
  * parseWhisper(json) · parseParakeet(json) · splitAtPauses(pcm) · assignSpeakers(segs, diar) · dropEcho(mic, meeting)
8
8
  * mergeSources(parts)
9
+ * transcribeClip(runtimeDir, pcm, { prompt }) → { ok, engine, segments } one stretch through the same engine (notes-live-settle)
9
10
  *
10
11
  * WHY. The live transcript is cut every few seconds so that lines appear while people talk. A cut that short gives Whisper
11
12
  * little context, so names come out wrong and sentences break in two, and nothing in it says who spoke. Meeting tools that
@@ -276,6 +277,40 @@ async function transcribeQwen(qwen, d, pcmFile, tmp, { lang, prompt, live = () =
276
277
  }
277
278
  return { ok: true, segments, engine: 'qwen', vad: true };
278
279
  }
280
+ /** The best engine set up first; when it fails, the next one still can: Qwen3-ASR, Parakeet (English), Whisper. */
281
+ async function bestTranscript({ run, qwen }, d, pcmFile, wav, tmp, { prompt = '', live = () => true } = {}) {
282
+ const order = [];
283
+ if (d.qwen) order.push('qwen');
284
+ if (d.parakeet && d.lang === 'en') order.push('parakeet');
285
+ if (d.whisper) order.push('whisper');
286
+ let r = { ok: false, error: 'no engine is set up for the final pass' };
287
+ for (const e of order) {
288
+ if (!live()) return r;
289
+ r = e === 'qwen' ? await transcribeQwen(qwen, d, pcmFile, tmp, { lang: d.lang, prompt, live })
290
+ : e === 'parakeet' ? await transcribeParakeet(run, d, wav, tmp) : await transcribeWhisper(run, d, wav, tmp, { lang: d.lang, prompt });
291
+ if (r.ok) break;
292
+ }
293
+ return r;
294
+ }
295
+ /**
296
+ * One stretch of audio (16 kHz mono 16-bit samples) through the final pass's engine, with no speakers and nothing stored
297
+ * (notes-live-settle: what a settle tier would cost). → { ok, engine, segments } | { ok: false, error }
298
+ */
299
+ async function transcribeClip(runtimeDir, pcm, { prompt = '', run = runProgram, detect: det = () => detect(runtimeDir),
300
+ qwen = (args) => notesQwen.runQwen(notesQwen.workerFor(runtimeDir), args) } = {}) {
301
+ const d = det();
302
+ if (!d.ready) return { ok: false, error: 'no engine is set up for the final pass' };
303
+ const tmp = path.join(finalDir(runtimeDir), 'tmp-' + crypto.randomBytes(6).toString('hex'));
304
+ try {
305
+ fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
306
+ const pcmFile = path.join(tmp, 'clip.pcm'); const wav = path.join(tmp, 'clip.wav');
307
+ fs.writeFileSync(pcmFile, pcm, { mode: 0o600 });
308
+ writeWav(pcmFile, wav);
309
+ return await bestTranscript({ run, qwen }, d, pcmFile, wav, tmp, { prompt });
310
+ } finally {
311
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* gone */ }
312
+ }
313
+ }
279
314
  async function diarizeFile(run, d, wav) {
280
315
  const r = await run(d.diarize.python, [d.diarize.script, '--segmentation', d.diarize.seg, '--embedding', d.diarize.emb, '--wav', wav, '--threshold', DIARIZE_THRESHOLD]);
281
316
  let j = null;
@@ -319,18 +354,8 @@ function createFinal({ runtimeDir, onDone, onState = () => {}, run = runProgram,
319
354
  const wav = path.join(tmp, s.source + '.wav');
320
355
  writeWav(s.file, wav);
321
356
  set(id, { state: 'running', step: 'transcribing ' + (s.source === 'mic' ? 'your microphone' : 'the meeting') + ' again' });
322
- // the best engine first; when it fails, the next one set up still can: Qwen3-ASR, Parakeet (English), Whisper
323
- const order = [];
324
- if (d.qwen) order.push('qwen');
325
- if (d.parakeet && d.lang === 'en') order.push('parakeet');
326
- if (d.whisper) order.push('whisper');
327
- let r = { ok: false, error: 'no engine is set up for the final pass' };
328
- for (const e of order) {
329
- if (!live()) return;
330
- r = e === 'qwen' ? await transcribeQwen(qwen, d, s.file, tmp, { lang: d.lang, prompt, live })
331
- : e === 'parakeet' ? await transcribeParakeet(run, d, wav, tmp) : await transcribeWhisper(run, d, wav, tmp, { lang: d.lang, prompt });
332
- if (r.ok) break;
333
- }
357
+ const r = await bestTranscript({ run, qwen }, d, s.file, wav, tmp, { prompt, live });
358
+ if (!live()) return;
334
359
  if (!r.ok) { set(id, { state: 'failed', reason: r.error }); return; }
335
360
  engine = r.engine; vad = vad || r.vad;
336
361
  let part = { source: s.source, segments: r.segments, speakers: 1 };
@@ -369,5 +394,5 @@ function createFinal({ runtimeDir, onDone, onState = () => {}, run = runProgram,
369
394
  };
370
395
  }
371
396
 
372
- module.exports = { detect, setupCommand, createFinal, runProgram, parseWhisper, parseParakeet, splitAtPauses, assignSpeakers, dropEcho, mergeSources, finalDir,
397
+ module.exports = { detect, setupCommand, createFinal, transcribeClip, runProgram, parseWhisper, parseParakeet, splitAtPauses, assignSpeakers, dropEcho, mergeSources, finalDir,
373
398
  PARAKEET_MODEL, VAD_FILE, DIARIZE_THRESHOLD };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+ /* notes-replay.js — notes-live-settle Run 1: what a replayed meeting shows, and what a settle tier would cost on this machine.
3
+ *
4
+ * words(text) → [word] lower case, letters and digits only
5
+ * overlap(a, b) → 0..1 the share of the shorter line's words that the other line also has
6
+ * duplicates(lines, { repeatMs, echoMs }) → [{ kind, a, b, overlap }] lines said twice: `repeat` (one source, close together)
7
+ * or `echo` (the microphone and the meeting, at the same moment)
8
+ * lag(seen) → { median_ms, p90_ms, max_ms, n } how long after its audio a live line appeared
9
+ * createSettleMeter({ transcribe, windowMs?, pauseMs?, now? }) → { feed(source, pcm), idle(), report(elapsedMs) }
10
+ *
11
+ * WHY. The PM reported duplicated audio and a duplicated transcript (notes-recording-reliability). That fix was measured on a
12
+ * scripted fixture; bin/notes-replay.js plays a real kept meeting through the live path again, and this module says what came out.
13
+ * The plan's near-realtime correction (console3 feedback plan 3.1, "settle tier") is built only if this machine keeps up: at each
14
+ * settle point the last WINDOW_MS of a source is transcribed again with the final pass's engine. The meter does that while the
15
+ * replay runs and measures the share of the meeting's time the engine was busy (`busy_share`, the real-time factor of the settle
16
+ * tier). The plan's gate: under 0.5, and no settle point skipped because the engine was still busy.
17
+ *
18
+ * WHAT IT NEVER DOES. It stores nothing and makes no request; `transcribe` is lib/notes-final.js transcribeClip, local.
19
+ */
20
+ const whisper = require('./notes-whisper.js');
21
+
22
+ const RATE = whisper.RATE;
23
+ const REPEAT_MS = 15000; // a line said again by the same source within this time is a repeat
24
+ const ECHO_MS = 5000; // a microphone line and a meeting line this close are the same moment
25
+ const MIN_OVERLAP = 0.8;
26
+ const MIN_WORDS = 3; // a shorter line ("Yes.") said twice is a conversation, not a duplicate, unless it is the same text
27
+ const WINDOW_MS = 30000; // the plan's 20 to 40 s
28
+ const PAUSE_MS = 1500; // the plan's settle point: a pause of 1.5 s
29
+ const GATE = 0.5;
30
+
31
+ const words = (text) => String(text || '').toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
32
+ function overlap(a, b) {
33
+ const wa = words(a); const wb = words(b);
34
+ if (!wa.length || !wb.length) return 0;
35
+ const [short, long] = wa.length <= wb.length ? [wa, wb] : [wb, wa];
36
+ const bag = new Map(); for (const w of long) bag.set(w, (bag.get(w) || 0) + 1);
37
+ let hit = 0; for (const w of short) { const c = bag.get(w) || 0; if (c) { hit++; bag.set(w, c - 1); } }
38
+ return hit / short.length;
39
+ }
40
+ const endOf = (l) => (Number.isFinite(l.t_end) ? l.t_end : l.t);
41
+
42
+ /** Lines said twice. `lines` are transcript lines { t, t_end?, text, source }, in any order; dropped lines are left out. */
43
+ function duplicates(lines, { repeatMs = REPEAT_MS, echoMs = ECHO_MS } = {}) {
44
+ const ls = (lines || []).filter((l) => l && !l.dropped && Number.isFinite(l.t) && words(l.text).length).slice().sort((x, y) => x.t - y.t);
45
+ const out = [];
46
+ for (let i = 0; i < ls.length; i++) {
47
+ for (let j = i + 1; j < ls.length && ls[j].t - endOf(ls[i]) <= Math.max(repeatMs, echoMs); j++) {
48
+ const a = ls[i]; const b = ls[j];
49
+ const same = words(a.text).join(' ') === words(b.text).join(' ');
50
+ const o = overlap(a.text, b.text);
51
+ const long = Math.min(words(a.text).length, words(b.text).length) >= MIN_WORDS;
52
+ if (!(same || (long && o >= MIN_OVERLAP))) continue;
53
+ const gap = b.t - endOf(a);
54
+ if (a.source === b.source && gap <= repeatMs) out.push({ kind: 'repeat', a, b, overlap: Math.round(o * 100) / 100 });
55
+ else if (a.source !== b.source && gap <= echoMs) out.push({ kind: 'echo', a, b, overlap: Math.round(o * 100) / 100 });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const pct = (xs, q) => { if (!xs.length) return null; const s = xs.slice().sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor((s.length - 1) * q))]; };
62
+ /** `seen` is [{ late_ms }], one per live line: how long after the end of its audio the line appeared. */
63
+ function lag(seen) {
64
+ const xs = (seen || []).map((s) => s.late_ms).filter(Number.isFinite);
65
+ return { n: xs.length, median_ms: pct(xs, 0.5), p90_ms: pct(xs, 0.9), max_ms: xs.length ? Math.max(...xs) : null };
66
+ }
67
+
68
+ /**
69
+ * The settle tier, measured. `feed` is called with each one-second chunk of a source as it is replayed.
70
+ * A settle point is a pause of PAUSE_MS after speech, or the other source starting to speak; at a settle point the last WINDOW_MS of
71
+ * that source goes to `transcribe(pcm)`. One run at a time: a settle point that comes while the engine is busy is skipped and counted.
72
+ */
73
+ function createSettleMeter({ transcribe, windowMs = WINDOW_MS, pauseMs = PAUSE_MS, now = Date.now } = {}) {
74
+ const st = new Map(); // source → { buf: [Buffer], bytes, voiced: bool, quietMs, floor: [rms] }
75
+ const runs = []; let skipped = 0; let points = 0; let busy = null; let busyMs = 0; let engine = null; const errors = [];
76
+ let lastVoiced = null;
77
+ const keep = Math.round(windowMs * RATE / 1000) * 2;
78
+ const of = (source) => { if (!st.has(source)) st.set(source, { buf: [], bytes: 0, voiced: false, quietMs: 0, floor: [] }); return st.get(source); };
79
+ function point(source) {
80
+ points++;
81
+ if (busy) { skipped++; return; }
82
+ const s = of(source);
83
+ const pcm = Buffer.concat(s.buf).subarray(-keep);
84
+ const audioMs = (pcm.length / 2 / RATE) * 1000;
85
+ const began = now();
86
+ let started; try { started = Promise.resolve(transcribe(pcm)); } catch (e) { started = Promise.reject(e); }
87
+ busy = started.then((r) => {
88
+ const took = now() - began; busyMs += took;
89
+ if (r && r.ok) { engine = r.engine || engine; runs.push({ source, audio_ms: Math.round(audioMs), took_ms: took }); } else errors.push(String((r && r.error) || 'no answer').slice(0, 200));
90
+ }, (e) => { busyMs += now() - began; errors.push(String((e && e.message) || e).slice(0, 200)); }).finally(() => { busy = null; });
91
+ }
92
+ return {
93
+ feed(source, pcm) {
94
+ const s = of(source);
95
+ s.buf.push(pcm); s.bytes += pcm.length;
96
+ while (s.bytes - s.buf[0].length >= keep) s.bytes -= s.buf.shift().length;
97
+ // a 100 ms frame is speech above three times the quiet of the last minute before it (its 10th percentile), and never below
98
+ // 180; until five seconds were heard, above 180
99
+ for (const v of whisper.levels(pcm)) {
100
+ const q = s.floor.length >= 50 ? pct(s.floor.filter((x) => x > 0), 0.1) || 0 : 0;
101
+ s.floor.push(v); if (s.floor.length > 600) s.floor.shift();
102
+ const voiced = v > Math.max(180, q * 3);
103
+ if (voiced) {
104
+ if (lastVoiced && lastVoiced !== source && of(lastVoiced).voiced) { of(lastVoiced).voiced = false; point(lastVoiced); }
105
+ s.voiced = true; s.quietMs = 0; lastVoiced = source;
106
+ } else if (s.voiced) {
107
+ s.quietMs += 100;
108
+ if (s.quietMs >= pauseMs) { s.voiced = false; point(source); }
109
+ }
110
+ }
111
+ },
112
+ idle() { return busy || Promise.resolve(); },
113
+ report(elapsedMs) {
114
+ const rtf = runs.map((r) => r.took_ms / Math.max(1, r.audio_ms));
115
+ const share = elapsedMs > 0 ? busyMs / elapsedMs : null;
116
+ const keepsUp = share !== null && share < GATE && skipped === 0 && runs.length > 0;
117
+ return { engine, points, runs: runs.length, skipped, errors: errors.slice(0, 5), busy_ms: busyMs, elapsed_ms: elapsedMs,
118
+ busy_share: share === null ? null : Math.round(share * 1000) / 1000,
119
+ window_rtf_median: rtf.length ? Math.round(pct(rtf, 0.5) * 1000) / 1000 : null, window_rtf_p90: rtf.length ? Math.round(pct(rtf, 0.9) * 1000) / 1000 : null,
120
+ gate: GATE, keeps_up: keepsUp };
121
+ },
122
+ };
123
+ }
124
+
125
+ module.exports = { words, overlap, duplicates, lag, createSettleMeter, REPEAT_MS, ECHO_MS, WINDOW_MS, PAUSE_MS, GATE };