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