create-pmos 0.134.0 → 0.136.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/notes-replay.js +235 -0
- package/workspace/c3-briefing-test.js +130 -0
- package/workspace/c3-foundation-test.js +159 -0
- package/workspace/c3-ib-items-test.js +105 -0
- package/workspace/c3-ib-more-test.js +120 -0
- package/workspace/c3-ib-triage-backlog-test.js +119 -0
- package/workspace/c3-ini-harness.js +66 -0
- package/workspace/c3-initiatives-test.js +140 -0
- package/workspace/c3-one-home-test.js +14 -1
- package/workspace/c3-reports-test.js +57 -18
- package/workspace/c3-settings-test.js +139 -0
- package/workspace/c3-work-test.js +42 -10
- package/workspace/fixtures/one-home-baseline.json +1 -9
- package/workspace/guide/briefing-cards.md +15 -3
- package/workspace/guide/briefing.md +12 -8
- package/workspace/guide/calendar-popup.md +2 -2
- package/workspace/guide/console-calendar.md +39 -10
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +65 -0
- package/workspace/guide/jira.md +3 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/my-work.md +5 -3
- package/workspace/guide/notes.md +16 -1
- package/workspace/guide/reports-dashboards.md +29 -5
- package/workspace/guide/settings.md +68 -0
- package/workspace/guide/welcome.md +10 -10
- package/workspace/lib/calendar-cli.js +3 -2
- package/workspace/lib/calendar-layers.js +21 -10
- package/workspace/lib/calendar-tidy.js +230 -0
- package/workspace/lib/engine/boards.js +542 -0
- package/workspace/lib/engine/breakdown-ops.js +180 -0
- package/workspace/lib/engine/calendar-import.js +84 -0
- package/workspace/lib/engine/calendar-model.js +185 -0
- package/workspace/lib/engine/card-state.js +92 -0
- package/workspace/lib/engine/derived/derive.js +92 -0
- package/workspace/lib/engine/derived/plan.js +3 -0
- package/workspace/lib/engine/derived/trust.js +3 -0
- package/workspace/lib/engine/doc-health.js +142 -0
- package/workspace/lib/engine/doc-sheet.js +75 -0
- package/workspace/lib/engine/intake.js +78 -0
- package/workspace/lib/engine/outbox.js +86 -0
- package/workspace/lib/engine/plan-forecast.js +342 -0
- package/workspace/lib/engine/promises.js +29 -0
- package/workspace/lib/engine/records/index.js +114 -0
- package/workspace/lib/engine/releases.js +96 -0
- package/workspace/lib/engine/replies.js +99 -0
- package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
- package/workspace/lib/engine/report-families.js +175 -0
- package/workspace/lib/engine/roadmap-ops.js +150 -0
- package/workspace/lib/engine/sessions-list.js +27 -0
- package/workspace/lib/engine/signal-rules.js +95 -0
- package/workspace/lib/engine/signals/cards.js +57 -0
- package/workspace/lib/engine/signals/detectors.js +370 -0
- package/workspace/lib/engine/signals/facts-cli.js +8 -0
- package/workspace/lib/engine/signals/facts.js +197 -0
- package/workspace/lib/engine/signals/index.js +33 -0
- package/workspace/lib/engine/signals/kinds.js +110 -0
- package/workspace/lib/engine/signals/layout-paths.js +44 -0
- package/workspace/lib/engine/signals/options.js +24 -0
- package/workspace/lib/engine/signals/score.js +72 -0
- package/workspace/lib/engine/signals/select.js +47 -0
- package/workspace/lib/engine/step-planner.js +65 -0
- package/workspace/lib/engine/trust/claim-rules.json +93 -0
- package/workspace/lib/engine/trust/import-calendar.js +21 -0
- package/workspace/lib/engine/trust/import-guard.js +36 -0
- package/workspace/lib/engine/trust/ledger.js +53 -0
- package/workspace/lib/engine/trust/outbox.js +45 -0
- package/workspace/lib/engine/trust/plan.js +5 -0
- package/workspace/lib/engine/trust/trust.js +21 -0
- package/workspace/lib/engine/truth-table.js +57 -0
- package/workspace/lib/engine/workspace-lib.js +24 -0
- package/workspace/lib/factory-join.js +1 -1
- package/workspace/lib/facts-async.js +4 -4
- package/workspace/lib/headless.js +95 -0
- package/workspace/lib/import-overlay.js +2 -1
- package/workspace/lib/jira-status.js +30 -3
- package/workspace/lib/notes-capture.js +9 -1
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +10 -3
- package/workspace/lib/outbox-store.js +10 -6
- package/workspace/lib/pmos-verbs.js +3 -1
- package/workspace/lib/proactive-cli.js +7 -0
- package/workspace/lib/proactive.js +15 -2
- package/workspace/lib/records-dir.js +29 -0
- package/workspace/lib/repo-script.js +36 -0
- package/workspace/lib/report-dashboards.js +5 -2
- package/workspace/lib/report-frame.js +122 -0
- package/workspace/lib/report-schedules.js +113 -0
- package/workspace/lib/route-modules.js +9 -2
- package/workspace/lib/routes-boards.js +734 -0
- package/workspace/lib/routes-brief-page.js +13 -4
- package/workspace/lib/routes-briefing.js +22 -11
- package/workspace/lib/routes-c3-init.js +9 -8
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +99 -15
- package/workspace/lib/routes-c3-strategy.js +7 -5
- package/workspace/lib/routes-c3-timeline.js +3 -2
- package/workspace/lib/routes-calendar-import.js +3 -2
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-doc-health.js +2 -1
- package/workspace/lib/routes-doc-sheet.js +2 -1
- package/workspace/lib/routes-forecast.js +2 -1
- package/workspace/lib/routes-hooks.js +3 -2
- package/workspace/lib/routes-initiatives.js +2 -1
- package/workspace/lib/routes-intake.js +2 -1
- package/workspace/lib/routes-notes.js +55 -9
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +10 -7
- package/workspace/lib/routes-releases.js +2 -1
- package/workspace/lib/routes-report-facts.js +4 -3
- package/workspace/lib/routes-report-families.js +2 -1
- package/workspace/lib/routes-roadmap-ops.js +17 -14
- package/workspace/lib/routes-runs.js +6 -5
- package/workspace/lib/routes-settings.js +51 -0
- package/workspace/lib/routes-signals.js +3 -2
- package/workspace/lib/routes-stage-tools.js +220 -0
- package/workspace/lib/run-answers.js +2 -1
- package/workspace/lib/settings-store.js +226 -0
- package/workspace/lib/stage-tools.js +208 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
- package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
- package/workspace/lib/work-evidence.js +73 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +63 -17
- package/workspace/public/pm/briefing-model.js +36 -7
- package/workspace/public/pm/c3-ambient.js +100 -0
- package/workspace/public/pm/c3-brief-needs.js +26 -6
- package/workspace/public/pm/c3-brief-pulse.js +12 -4
- package/workspace/public/pm/c3-brief-today.js +56 -21
- package/workspace/public/pm/c3-brief.js +2 -1
- package/workspace/public/pm/c3-calendar.js +161 -42
- package/workspace/public/pm/c3-calpop.js +5 -5
- package/workspace/public/pm/c3-demo-boards.js +649 -0
- package/workspace/public/pm/c3-demo-brief-page.js +1 -1
- package/workspace/public/pm/c3-demo-cal4.js +3 -2
- package/workspace/public/pm/c3-demo-cal5.js +52 -0
- package/workspace/public/pm/c3-demo-data.js +17 -27
- package/workspace/public/pm/c3-demo-rep3.js +46 -5
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-dock.js +1 -1
- package/workspace/public/pm/c3-ib-docs.js +80 -0
- package/workspace/public/pm/c3-ib-initiatives.js +134 -0
- package/workspace/public/pm/c3-ib-items.js +201 -0
- package/workspace/public/pm/c3-ib-releases.js +51 -0
- package/workspace/public/pm/c3-ib-sprints.js +55 -0
- package/workspace/public/pm/c3-ib-triage.js +178 -0
- package/workspace/public/pm/c3-ii-tabs.js +240 -0
- package/workspace/public/pm/c3-ini-records.js +197 -0
- package/workspace/public/pm/c3-ini-timeline.js +278 -0
- package/workspace/public/pm/c3-ini-ui.js +229 -0
- package/workspace/public/pm/c3-ini-work.js +139 -0
- package/workspace/public/pm/c3-initiatives.css +218 -0
- package/workspace/public/pm/c3-initiatives.js +741 -0
- package/workspace/public/pm/c3-plan.js +2 -2
- package/workspace/public/pm/c3-reports.css +47 -47
- package/workspace/public/pm/c3-reports.js +223 -185
- package/workspace/public/pm/c3-settings.css +74 -0
- package/workspace/public/pm/c3-settings.js +703 -0
- package/workspace/public/pm/c3-shell.js +102 -36
- package/workspace/public/pm/c3-terminal-icons.js +2 -2
- package/workspace/public/pm/c3-terminal.js +2 -2
- package/workspace/public/pm/calendar-views.js +1 -1
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +99 -23
- package/workspace/public/pm/dest-notes.js +26 -4
- package/workspace/public/pm/notes-screen-capture.js +165 -0
- package/workspace/public/pm/notes-screen-view.js +98 -0
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/pm-work-view.js +233 -146
- package/workspace/public/pm/shell-bell.js +3 -2
- package/workspace/public/pm/shell-recording.js +29 -8
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +46 -9
- package/workspace/public/pm/welcome.js +197 -89
- package/workspace/public/welcome.html +6 -4
- package/workspace/server.js +105 -6
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/test-files.json +12 -3
- package/workspace/test-lists/c3-ib-items.json +9 -0
- package/workspace/test-lists/c3-ib-more.json +9 -0
- package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
- package/workspace/test-lists/c3-initiative-boards.json +6 -0
- package/workspace/test-lists/calendar-tidy-up.json +6 -0
- package/workspace/test-lists/console3-briefing.json +9 -0
- package/workspace/test-lists/console3-foundation.json +9 -0
- package/workspace/test-lists/console3-reports.json +3 -3
- package/workspace/test-lists/meeting-notes.json +10 -4
- package/workspace/test-lists/workspace-headless-jobs.json +5 -0
- package/workspace/test-lists/workspace-package-script-closure.json +6 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
- package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
- package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
- package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
- package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
- package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
- package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
- package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
- 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 =
|
|
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
|
|
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('
|
|
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
|
-
|
|
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
|
|
17
|
-
* subtasks: { done, total }, fixVersion: [names],
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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 };
|