create-pmos 0.133.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- 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/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 +35 -2
- 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-audio.js +28 -9
- package/workspace/lib/notes-capture.js +136 -0
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +139 -16
- package/workspace/lib/notes.js +4 -2
- 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/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 +4 -3
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +96 -13
- package/workspace/lib/routes-c3-strategy.js +4 -3
- 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 +113 -15
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +3 -2
- 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 +5 -4
- 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 +26 -13
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +118 -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 +148 -17
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +53 -11
- 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 +14 -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 +12 -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
|
}
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* place and with nothing added, so the final pass after Stop (lib/notes-final.js) can tell the PM's microphone from the
|
|
19
19
|
* meeting. remove deletes all three.
|
|
20
20
|
*
|
|
21
|
+
* A chunk from a page that names its place (`sample`, from lib/notes-capture.js) is written exactly there, so a chunk that arrives
|
|
22
|
+
* late, or after a restart, lands where it belongs (notes-recording-reliability).
|
|
23
|
+
*
|
|
21
24
|
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
|
|
22
25
|
* private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
|
|
23
26
|
*/
|
|
@@ -80,20 +83,23 @@ function softLimit(x) {
|
|
|
80
83
|
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, then limited by softLimit. With
|
|
81
84
|
* `mixed: false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done.
|
|
82
85
|
* The per-source files are never limited: they are what the transcription reads. */
|
|
83
|
-
function write(runtimeDir, { id, source, pcm, pcm24 = null, t, mixed = true }) {
|
|
86
|
+
function write(runtimeDir, { id, source, pcm, pcm24 = null, t, sample = null, mixed = true }) {
|
|
84
87
|
if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
|
|
85
88
|
const f = fileOf(runtimeDir, id);
|
|
86
89
|
const key = f + '|' + source;
|
|
87
|
-
|
|
90
|
+
// `sample` (notes-recording-reliability): the chunk's own place, from the capture's start and its offset, so it is written there
|
|
91
|
+
// and nowhere else, whenever it arrives. Without it the chunk is placed by its arrival time, as before.
|
|
92
|
+
const exact = Number.isInteger(sample) && sample >= 0;
|
|
93
|
+
let at = exact ? sample * 2 : Math.max(0, Math.round((Number(t) || 0) * BYTES_PER_MS / 2) * 2);
|
|
88
94
|
const prev = next.get(key);
|
|
89
95
|
// a source never overlaps itself: a chunk that would start before its source left off, or just after, goes straight after it
|
|
90
|
-
if (prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
|
|
96
|
+
if (!exact && prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
|
|
91
97
|
if (at >= MAX_BYTES) return { ok: true, kept: false };
|
|
92
98
|
const len = Math.min(pcm.length, MAX_BYTES - at);
|
|
93
99
|
fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
|
|
94
100
|
// With 24 kHz audio the replay is the 24 kHz mix and no 16 kHz mix is written; the 16 kHz source files are written either way
|
|
95
101
|
const hiOk = mixed && pcm24 && pcm24.length && pcm24.length % 2 === 0;
|
|
96
|
-
if (hiOk) writeHi(runtimeDir, id, source, pcm24, t);
|
|
102
|
+
if (hiOk) writeHi(runtimeDir, id, source, pcm24, t, exact ? Math.round(sample * HI_RATE / RATE) : null);
|
|
97
103
|
if (mixed && !hiOk) {
|
|
98
104
|
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
99
105
|
try {
|
|
@@ -114,12 +120,13 @@ function write(runtimeDir, { id, source, pcm, pcm24 = null, t, mixed = true }) {
|
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/** Add one chunk of 24 kHz audio to the note's 24 kHz mix at meeting time `t`, placed as write() places the 16 kHz audio. */
|
|
117
|
-
function writeHi(runtimeDir, id, source, pcm24, t) {
|
|
123
|
+
function writeHi(runtimeDir, id, source, pcm24, t, sample = null) {
|
|
118
124
|
const f = hiFileOf(runtimeDir, id);
|
|
119
125
|
const key = f + '|' + source;
|
|
120
|
-
|
|
126
|
+
const exact = Number.isInteger(sample) && sample >= 0;
|
|
127
|
+
let at = exact ? sample * 2 : Math.max(0, Math.round((Number(t) || 0) * HI_BYTES_PER_MS / 2) * 2);
|
|
121
128
|
const prev = next.get(key);
|
|
122
|
-
if (prev !== undefined && at <= prev + ON_TIME_MS * HI_BYTES_PER_MS) at = prev;
|
|
129
|
+
if (!exact && prev !== undefined && at <= prev + ON_TIME_MS * HI_BYTES_PER_MS) at = prev;
|
|
123
130
|
if (at >= HI_MAX_BYTES) return;
|
|
124
131
|
const len = Math.min(pcm24.length, HI_MAX_BYTES - at);
|
|
125
132
|
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
@@ -155,12 +162,24 @@ function remove(runtimeDir, id) {
|
|
|
155
162
|
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|') || k.startsWith(hiFileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
156
163
|
for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
|
|
157
164
|
let removed = false;
|
|
158
|
-
for (const extra of ['.echo.json', '.pcm.echo.tmp', '.hi.pcm.echo.tmp', '.hi.pcm', '.clean.pcm', '.clean.pcm.tmp']) {
|
|
165
|
+
for (const extra of ['.capture.json', '.echo.json', '.pcm.echo.tmp', '.hi.pcm.echo.tmp', '.hi.pcm', '.clean.pcm', '.clean.pcm.tmp']) {
|
|
159
166
|
try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); if (extra === '.hi.pcm') removed = true; } catch { /* none */ }
|
|
160
167
|
}
|
|
161
168
|
try { fs.unlinkSync(fileOf(runtimeDir, id)); removed = true; } catch { /* none */ }
|
|
162
169
|
return removed;
|
|
163
170
|
}
|
|
171
|
+
/** Samples [from, to) (16 kHz) of one source's own file, or null. At most a minute. */
|
|
172
|
+
function readSource(runtimeDir, id, source, from, to) {
|
|
173
|
+
if (!ID_RE.test(String(id || '')) || !SOURCES.includes(source)) return null;
|
|
174
|
+
const a = Math.max(0, from, to - RATE * 60); const n = (to - a) * 2;
|
|
175
|
+
if (n <= 0) return null;
|
|
176
|
+
let fd;
|
|
177
|
+
try {
|
|
178
|
+
fd = fs.openSync(sourceFileOf(runtimeDir, id, source), 'r');
|
|
179
|
+
const b = Buffer.alloc(n); const got = fs.readSync(fd, b, 0, n, a * 2);
|
|
180
|
+
return got > 0 ? { pcm: b.subarray(0, got - (got % 2)), from: a } : null;
|
|
181
|
+
} catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
182
|
+
}
|
|
164
183
|
/** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
|
|
165
184
|
function sources(runtimeDir, id) {
|
|
166
185
|
if (!ID_RE.test(String(id || ''))) return [];
|
|
@@ -220,4 +239,4 @@ function serve(req, res, runtimeDir, id) {
|
|
|
220
239
|
fs.createReadStream(inf.file, { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
221
240
|
}
|
|
222
241
|
|
|
223
|
-
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: (dir, id) => (baseOf(dir, id) || { file: fileOf(dir, id) }).file, mixFile: fileOf, hiFile: hiFileOf, sideFile: sideFileOf, baseOf, cleanFile: cleanFileOf, HI_RATE, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
242
|
+
module.exports = { write, readSource, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: (dir, id) => (baseOf(dir, id) || { file: fileOf(dir, id) }).file, mixFile: fileOf, hiFile: hiFileOf, sideFile: sideFileOf, baseOf, cleanFile: cleanFileOf, HI_RATE, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-capture.js — which page is capturing a note, and which chunks of it are already written (notes-recording-reliability). Local only.
|
|
3
|
+
*
|
|
4
|
+
* start(runtimeDir, id, { baseMs, now }) → { ok, token, base_ms } | { ok: false, status: 409, error }
|
|
5
|
+
* check(runtimeDir, id, { token, source, seq, now }) → { ok, dup, base_ms } | { ok: false, status: 409, error }
|
|
6
|
+
* mark(runtimeDir, id, { source, seq, now }) the chunk is written: a second copy of it is a duplicate
|
|
7
|
+
* setCut(runtimeDir, id, { source, sample }) · untranscribed(runtimeDir, id, { source, before }) → { from, to } | null
|
|
8
|
+
* the live transcription's progress, so a restart transcribes what it had buffered
|
|
9
|
+
* close(runtimeDir, id) Pause or Stop: the capture ends, and the next Listen starts a new one
|
|
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)
|
|
13
|
+
* remove(runtimeDir, id)
|
|
14
|
+
*
|
|
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
|
|
16
|
+
* module, the server placed each chunk at its arrival time and remembered in memory where each source ended. So a chunk written
|
|
17
|
+
* twice was kept twice, a restart lost the memory and the next chunk was added onto audio already written, a late chunk moved
|
|
18
|
+
* the rest of its source, and two tabs could capture one note into the same file (measured by notes-duplication.test.js).
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT KEEPS. Listen hands the page a token and the meeting time the capture starts at (base_ms). Each chunk carries the
|
|
21
|
+
* token, a sequence number per source and its sample offset in the capture, so the route places it at base_ms + offset, and
|
|
22
|
+
* the sequence numbers already written are kept as ranges. All of it is in `<runtime>/notes/recordings/<id>.capture.json`,
|
|
23
|
+
* next to the audio, so a restart keeps it.
|
|
24
|
+
*
|
|
25
|
+
* ONE CAPTURE PER NOTE. Listen is refused while another capture is live: one that sent a chunk (or started) in the last
|
|
26
|
+
* LIVE_MS. A chunk with a token that is not the note's capture is refused, and the page stops listening on that answer.
|
|
27
|
+
*
|
|
28
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
|
|
34
|
+
const LIVE_MS = 5000;
|
|
35
|
+
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
36
|
+
const fileOf = (runtimeDir, id) => path.join(runtimeDir, 'notes', 'recordings', id + '.capture.json');
|
|
37
|
+
const cache = new Map(); // file → state, written through to the file
|
|
38
|
+
|
|
39
|
+
function load(runtimeDir, id) {
|
|
40
|
+
const f = fileOf(runtimeDir, id);
|
|
41
|
+
if (cache.has(f)) return cache.get(f);
|
|
42
|
+
let s = null;
|
|
43
|
+
try { s = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { s = null; }
|
|
44
|
+
if (!s || typeof s !== 'object') s = null;
|
|
45
|
+
cache.set(f, s);
|
|
46
|
+
return s;
|
|
47
|
+
}
|
|
48
|
+
function save(runtimeDir, id, s) {
|
|
49
|
+
const f = fileOf(runtimeDir, id);
|
|
50
|
+
cache.set(f, s);
|
|
51
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
52
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
53
|
+
fs.writeFileSync(tmp, JSON.stringify(s), { mode: 0o600 });
|
|
54
|
+
fs.renameSync(tmp, f);
|
|
55
|
+
}
|
|
56
|
+
const liveAt = (s) => Math.max(Date.parse(s.started_at) || 0, Date.parse(s.last_at) || 0);
|
|
57
|
+
const isLive = (s, now) => !!(s && s.token && !s.closed && now - liveAt(s) < LIVE_MS);
|
|
58
|
+
|
|
59
|
+
/** A new capture, unless another one is live. */
|
|
60
|
+
function start(runtimeDir, id, { baseMs = 0, now = Date.now() } = {}) {
|
|
61
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
62
|
+
const s = load(runtimeDir, id);
|
|
63
|
+
if (isLive(s, now)) return { ok: false, status: 409, error: 'this meeting is already being recorded in another tab or window' };
|
|
64
|
+
const token = crypto.randomBytes(9).toString('hex');
|
|
65
|
+
const base = Math.max(0, Math.round(Number(baseMs) || 0));
|
|
66
|
+
save(runtimeDir, id, { token, base_ms: base, started_at: new Date(now).toISOString(), last_at: null, closed: false, tokened: false, seqs: {} });
|
|
67
|
+
return { ok: true, token, base_ms: base };
|
|
68
|
+
}
|
|
69
|
+
/** Refuse true when another check would refuse the next Listen: used before the note's own record step changes anything. */
|
|
70
|
+
function busy(runtimeDir, id, { now = Date.now() } = {}) { return isLive(load(runtimeDir, id), now); }
|
|
71
|
+
|
|
72
|
+
const has = (ranges, n) => (ranges || []).some(([a, b]) => n >= a && n <= b);
|
|
73
|
+
function add(ranges, n) {
|
|
74
|
+
const r = (ranges || []).concat([[n, n]]).sort((x, y) => x[0] - y[0]);
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const [a, b] of r) { const last = out[out.length - 1]; if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b); else out.push([a, b]); }
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** May this chunk be written? `dup` when its sequence number is already written. */
|
|
81
|
+
function check(runtimeDir, id, { token, source, seq } = {}) {
|
|
82
|
+
const s = load(runtimeDir, id);
|
|
83
|
+
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' };
|
|
84
|
+
const n = Number(seq);
|
|
85
|
+
if (!Number.isInteger(n) || n < 0) return { ok: false, status: 400, error: 'a chunk needs a sequence number' };
|
|
86
|
+
return { ok: true, dup: has((s.seqs || {})[source], n), base_ms: s.base_ms };
|
|
87
|
+
}
|
|
88
|
+
function mark(runtimeDir, id, { source, seq, end = null, now = Date.now() } = {}) {
|
|
89
|
+
const s = load(runtimeDir, id);
|
|
90
|
+
if (!s) return;
|
|
91
|
+
s.seqs = s.seqs || {};
|
|
92
|
+
s.seqs[source] = add(s.seqs[source], Number(seq));
|
|
93
|
+
if (Number.isInteger(end)) { s.fed = s.fed || {}; s.fed[source] = Math.max(s.fed[source] || 0, end); }
|
|
94
|
+
s.last_at = new Date(now).toISOString(); s.tokened = true;
|
|
95
|
+
save(runtimeDir, id, s);
|
|
96
|
+
}
|
|
97
|
+
/** The live transcription cut this source up to `sample` (16 kHz). */
|
|
98
|
+
function setCut(runtimeDir, id, { token, source, sample } = {}) {
|
|
99
|
+
const s = load(runtimeDir, id);
|
|
100
|
+
if (!s || s.closed || (token && s.token !== token) || !Number.isInteger(sample)) return;
|
|
101
|
+
s.cut = s.cut || {};
|
|
102
|
+
if (sample <= (s.cut[source] || 0)) return;
|
|
103
|
+
s.cut[source] = sample;
|
|
104
|
+
save(runtimeDir, id, s);
|
|
105
|
+
}
|
|
106
|
+
/** After a restart the live transcription lost what it had buffered: the stretch [from, to) of a source that was written but not
|
|
107
|
+
* transcribed, ending where the incoming chunk starts. null when there is none. */
|
|
108
|
+
function untranscribed(runtimeDir, id, { source, before } = {}) {
|
|
109
|
+
const s = load(runtimeDir, id);
|
|
110
|
+
if (!s || s.closed) return null;
|
|
111
|
+
const base = Math.round((s.base_ms || 0) * 16);
|
|
112
|
+
const from = Math.max(base, (s.cut || {})[source] || 0);
|
|
113
|
+
const to = Math.min(before, (s.fed || {})[source] || 0);
|
|
114
|
+
return to > from ? { from, to } : null;
|
|
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
|
+
}
|
|
122
|
+
function tokened(runtimeDir, id, { now = Date.now() } = {}) { const s = load(runtimeDir, id); return isLive(s, now) && !!s.tokened; }
|
|
123
|
+
function close(runtimeDir, id) {
|
|
124
|
+
const s = load(runtimeDir, id);
|
|
125
|
+
if (!s || s.closed) return;
|
|
126
|
+
s.closed = true;
|
|
127
|
+
save(runtimeDir, id, s);
|
|
128
|
+
}
|
|
129
|
+
function remove(runtimeDir, id) {
|
|
130
|
+
if (!ID_RE.test(String(id || ''))) return;
|
|
131
|
+
const f = fileOf(runtimeDir, id);
|
|
132
|
+
cache.delete(f);
|
|
133
|
+
try { fs.unlinkSync(f); } catch { /* none */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
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 };
|