create-pmos 0.134.0 → 0.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/notes-replay.js +235 -0
- package/workspace/c3-briefing-test.js +130 -0
- package/workspace/c3-foundation-test.js +159 -0
- package/workspace/c3-ib-items-test.js +105 -0
- package/workspace/c3-ib-more-test.js +120 -0
- package/workspace/c3-ib-triage-backlog-test.js +119 -0
- package/workspace/c3-ini-harness.js +66 -0
- package/workspace/c3-initiatives-test.js +140 -0
- package/workspace/c3-one-home-test.js +14 -1
- package/workspace/c3-reports-test.js +57 -18
- package/workspace/c3-settings-test.js +139 -0
- package/workspace/c3-work-test.js +42 -10
- package/workspace/fixtures/one-home-baseline.json +1 -9
- package/workspace/guide/briefing-cards.md +15 -3
- package/workspace/guide/briefing.md +12 -8
- package/workspace/guide/calendar-popup.md +2 -2
- package/workspace/guide/console-calendar.md +39 -10
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +65 -0
- package/workspace/guide/jira.md +3 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/my-work.md +5 -3
- package/workspace/guide/notes.md +16 -1
- package/workspace/guide/reports-dashboards.md +29 -5
- package/workspace/guide/settings.md +68 -0
- package/workspace/guide/welcome.md +10 -10
- package/workspace/lib/calendar-cli.js +3 -2
- package/workspace/lib/calendar-layers.js +21 -10
- package/workspace/lib/calendar-tidy.js +230 -0
- package/workspace/lib/engine/boards.js +542 -0
- package/workspace/lib/engine/breakdown-ops.js +180 -0
- package/workspace/lib/engine/calendar-import.js +84 -0
- package/workspace/lib/engine/calendar-model.js +185 -0
- package/workspace/lib/engine/card-state.js +92 -0
- package/workspace/lib/engine/derived/derive.js +92 -0
- package/workspace/lib/engine/derived/plan.js +3 -0
- package/workspace/lib/engine/derived/trust.js +3 -0
- package/workspace/lib/engine/doc-health.js +142 -0
- package/workspace/lib/engine/doc-sheet.js +75 -0
- package/workspace/lib/engine/intake.js +78 -0
- package/workspace/lib/engine/outbox.js +86 -0
- package/workspace/lib/engine/plan-forecast.js +342 -0
- package/workspace/lib/engine/promises.js +29 -0
- package/workspace/lib/engine/records/index.js +114 -0
- package/workspace/lib/engine/releases.js +96 -0
- package/workspace/lib/engine/replies.js +99 -0
- package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
- package/workspace/lib/engine/report-families.js +175 -0
- package/workspace/lib/engine/roadmap-ops.js +150 -0
- package/workspace/lib/engine/sessions-list.js +27 -0
- package/workspace/lib/engine/signal-rules.js +95 -0
- package/workspace/lib/engine/signals/cards.js +57 -0
- package/workspace/lib/engine/signals/detectors.js +370 -0
- package/workspace/lib/engine/signals/facts-cli.js +8 -0
- package/workspace/lib/engine/signals/facts.js +197 -0
- package/workspace/lib/engine/signals/index.js +33 -0
- package/workspace/lib/engine/signals/kinds.js +110 -0
- package/workspace/lib/engine/signals/layout-paths.js +44 -0
- package/workspace/lib/engine/signals/options.js +24 -0
- package/workspace/lib/engine/signals/score.js +72 -0
- package/workspace/lib/engine/signals/select.js +47 -0
- package/workspace/lib/engine/step-planner.js +65 -0
- package/workspace/lib/engine/trust/claim-rules.json +93 -0
- package/workspace/lib/engine/trust/import-calendar.js +21 -0
- package/workspace/lib/engine/trust/import-guard.js +36 -0
- package/workspace/lib/engine/trust/ledger.js +53 -0
- package/workspace/lib/engine/trust/outbox.js +45 -0
- package/workspace/lib/engine/trust/plan.js +5 -0
- package/workspace/lib/engine/trust/trust.js +21 -0
- package/workspace/lib/engine/truth-table.js +57 -0
- package/workspace/lib/engine/workspace-lib.js +24 -0
- package/workspace/lib/factory-join.js +1 -1
- package/workspace/lib/facts-async.js +4 -4
- package/workspace/lib/headless.js +95 -0
- package/workspace/lib/import-overlay.js +2 -1
- package/workspace/lib/jira-status.js +30 -3
- package/workspace/lib/notes-capture.js +9 -1
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +10 -3
- package/workspace/lib/outbox-store.js +10 -6
- package/workspace/lib/pmos-verbs.js +3 -1
- package/workspace/lib/proactive-cli.js +7 -0
- package/workspace/lib/proactive.js +15 -2
- package/workspace/lib/records-dir.js +29 -0
- package/workspace/lib/repo-script.js +36 -0
- package/workspace/lib/report-dashboards.js +5 -2
- package/workspace/lib/report-frame.js +122 -0
- package/workspace/lib/report-schedules.js +113 -0
- package/workspace/lib/route-modules.js +9 -2
- package/workspace/lib/routes-boards.js +734 -0
- package/workspace/lib/routes-brief-page.js +13 -4
- package/workspace/lib/routes-briefing.js +22 -11
- package/workspace/lib/routes-c3-init.js +9 -8
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +99 -15
- package/workspace/lib/routes-c3-strategy.js +7 -5
- package/workspace/lib/routes-c3-timeline.js +3 -2
- package/workspace/lib/routes-calendar-import.js +3 -2
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-doc-health.js +2 -1
- package/workspace/lib/routes-doc-sheet.js +2 -1
- package/workspace/lib/routes-forecast.js +2 -1
- package/workspace/lib/routes-hooks.js +3 -2
- package/workspace/lib/routes-initiatives.js +2 -1
- package/workspace/lib/routes-intake.js +2 -1
- package/workspace/lib/routes-notes.js +55 -9
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +10 -7
- package/workspace/lib/routes-releases.js +2 -1
- package/workspace/lib/routes-report-facts.js +4 -3
- package/workspace/lib/routes-report-families.js +2 -1
- package/workspace/lib/routes-roadmap-ops.js +17 -14
- package/workspace/lib/routes-runs.js +6 -5
- package/workspace/lib/routes-settings.js +51 -0
- package/workspace/lib/routes-signals.js +3 -2
- package/workspace/lib/routes-stage-tools.js +220 -0
- package/workspace/lib/run-answers.js +2 -1
- package/workspace/lib/settings-store.js +226 -0
- package/workspace/lib/stage-tools.js +208 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
- package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
- package/workspace/lib/work-evidence.js +73 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +63 -17
- package/workspace/public/pm/briefing-model.js +36 -7
- package/workspace/public/pm/c3-ambient.js +100 -0
- package/workspace/public/pm/c3-brief-needs.js +26 -6
- package/workspace/public/pm/c3-brief-pulse.js +12 -4
- package/workspace/public/pm/c3-brief-today.js +56 -21
- package/workspace/public/pm/c3-brief.js +2 -1
- package/workspace/public/pm/c3-calendar.js +161 -42
- package/workspace/public/pm/c3-calpop.js +5 -5
- package/workspace/public/pm/c3-demo-boards.js +649 -0
- package/workspace/public/pm/c3-demo-brief-page.js +1 -1
- package/workspace/public/pm/c3-demo-cal4.js +3 -2
- package/workspace/public/pm/c3-demo-cal5.js +52 -0
- package/workspace/public/pm/c3-demo-data.js +17 -27
- package/workspace/public/pm/c3-demo-rep3.js +46 -5
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-dock.js +1 -1
- package/workspace/public/pm/c3-ib-docs.js +80 -0
- package/workspace/public/pm/c3-ib-initiatives.js +134 -0
- package/workspace/public/pm/c3-ib-items.js +201 -0
- package/workspace/public/pm/c3-ib-releases.js +51 -0
- package/workspace/public/pm/c3-ib-sprints.js +55 -0
- package/workspace/public/pm/c3-ib-triage.js +178 -0
- package/workspace/public/pm/c3-ii-tabs.js +240 -0
- package/workspace/public/pm/c3-ini-records.js +197 -0
- package/workspace/public/pm/c3-ini-timeline.js +278 -0
- package/workspace/public/pm/c3-ini-ui.js +229 -0
- package/workspace/public/pm/c3-ini-work.js +139 -0
- package/workspace/public/pm/c3-initiatives.css +218 -0
- package/workspace/public/pm/c3-initiatives.js +741 -0
- package/workspace/public/pm/c3-plan.js +2 -2
- package/workspace/public/pm/c3-reports.css +47 -47
- package/workspace/public/pm/c3-reports.js +223 -185
- package/workspace/public/pm/c3-settings.css +74 -0
- package/workspace/public/pm/c3-settings.js +703 -0
- package/workspace/public/pm/c3-shell.js +102 -36
- package/workspace/public/pm/c3-terminal-icons.js +2 -2
- package/workspace/public/pm/c3-terminal.js +2 -2
- package/workspace/public/pm/calendar-views.js +1 -1
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +99 -23
- package/workspace/public/pm/dest-notes.js +26 -4
- package/workspace/public/pm/notes-screen-capture.js +165 -0
- package/workspace/public/pm/notes-screen-view.js +98 -0
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/pm-work-view.js +233 -146
- package/workspace/public/pm/shell-bell.js +3 -2
- package/workspace/public/pm/shell-recording.js +29 -8
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +46 -9
- package/workspace/public/pm/welcome.js +197 -89
- package/workspace/public/welcome.html +6 -4
- package/workspace/server.js +105 -6
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/test-files.json +12 -3
- package/workspace/test-lists/c3-ib-items.json +9 -0
- package/workspace/test-lists/c3-ib-more.json +9 -0
- package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
- package/workspace/test-lists/c3-initiative-boards.json +6 -0
- package/workspace/test-lists/calendar-tidy-up.json +6 -0
- package/workspace/test-lists/console3-briefing.json +9 -0
- package/workspace/test-lists/console3-foundation.json +9 -0
- package/workspace/test-lists/console3-reports.json +3 -3
- package/workspace/test-lists/meeting-notes.json +10 -4
- package/workspace/test-lists/workspace-headless-jobs.json +5 -0
- package/workspace/test-lists/workspace-package-script-closure.json +6 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
- package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
- package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
- package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
- package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
- package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
- package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
- package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
- package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-screen.js — the screenshots a note keeps of the screen the PM shared in the meeting (notes-screen-capture). Local only.
|
|
3
|
+
*
|
|
4
|
+
* add(runtimeDir, { id, t, buf, w, h }) → { ok, frame } | { ok: false, status, error } keep one shot at meeting time t (ms)
|
|
5
|
+
* list(runtimeDir, id) → [{ t, file, w, h, bytes, why?, text? }] sorted by t; empty when the note has none
|
|
6
|
+
* serve(req, res, runtimeDir, id, t) the shot at t as its image, cached for good; 404 JSON when there is none
|
|
7
|
+
* remove(runtimeDir, id) → boolean delete the note's screenshots
|
|
8
|
+
* getOn(runtimeDir) · setOn(runtimeDir, bool) "Capture the shared screen", on by default, for this machine
|
|
9
|
+
* ocrDetect({ env? }) → { bin } | null the tesseract the PM installed
|
|
10
|
+
* ocrIdle() → Promise resolves when the text queue is empty (tests)
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT KEEPS. A folder next to the note's audio, `<runtime>/notes/recordings/<id>.screen/`: one image file per shot, named by
|
|
13
|
+
* its meeting time in milliseconds (`000123456.jpg`), and `index.json`, an array of { t, file, w, h, bytes, why?, text? } sorted by t,
|
|
14
|
+
* written whole through a temporary file and renamed, so a stop in the middle never leaves half an index. A shot at the same
|
|
15
|
+
* time as an earlier one replaces it. A note keeps at most MAX_SHOTS shots or MAX_BYTES bytes; a shot past either is refused
|
|
16
|
+
* with 409 and a plain sentence, and the shots already kept stay.
|
|
17
|
+
*
|
|
18
|
+
* WHICH BYTES. Only a JPEG, a PNG or a WebP, found by the file's first bytes and not by what the page says it is, and at most
|
|
19
|
+
* MAX_SHOT_BYTES each. The page sends a JPEG (public/pm), so a shot is far under the limit.
|
|
20
|
+
*
|
|
21
|
+
* ON-SCREEN TEXT. When tesseract is on this machine (PMOS_TESSERACT names it, else the PATH and where Homebrew puts it), each
|
|
22
|
+
* kept shot is read for its text, one at a time and in the background, and the text is added to that shot's row in the index.
|
|
23
|
+
* A failed or timed-out read leaves the shot with no text and is not an error. Without tesseract nothing is read.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The only program it runs is the tesseract the PM installed,
|
|
26
|
+
* on a file in the note's own folder, with no shell and a time limit. The routes (lib/routes-notes.js) keep no shot for a private
|
|
27
|
+
* note, for a note that is not listening, or when the PM switched capturing off, and delete the folder when a note is made
|
|
28
|
+
* private or deleted.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { execFile } = require('child_process');
|
|
33
|
+
|
|
34
|
+
const MAX_SHOT_BYTES = 2 * 1024 * 1024;
|
|
35
|
+
const MAX_SHOTS = 2000;
|
|
36
|
+
const MAX_BYTES = 500 * 1024 * 1024;
|
|
37
|
+
const MAX_T = 999999999; // the file name holds nine digits of milliseconds
|
|
38
|
+
const OCR_TIMEOUT_MS = 30000;
|
|
39
|
+
const OCR_MAX_CHARS = 4000;
|
|
40
|
+
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
41
|
+
const FILE_RE = /^\d{9}\.(jpg|png|webp)$/;
|
|
42
|
+
const TYPES = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
|
|
43
|
+
|
|
44
|
+
const dirOf = (runtimeDir, id) => path.join(runtimeDir, 'notes', 'recordings', id + '.screen');
|
|
45
|
+
const indexOf = (runtimeDir, id) => path.join(dirOf(runtimeDir, id), 'index.json');
|
|
46
|
+
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'screen-settings.json');
|
|
47
|
+
|
|
48
|
+
function readSettings(runtimeDir) { try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; } }
|
|
49
|
+
function writeAtomic(file, text) {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
52
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
53
|
+
fs.renameSync(tmp, file);
|
|
54
|
+
}
|
|
55
|
+
/** "Capture the shared screen": on until the PM turns it off, for this machine. */
|
|
56
|
+
function getOn(runtimeDir) { return readSettings(runtimeDir).on !== false; }
|
|
57
|
+
function setOn(runtimeDir, on) {
|
|
58
|
+
if (typeof on !== 'boolean') return { ok: false, error: 'say screen: true or screen: false' };
|
|
59
|
+
writeAtomic(settingsFile(runtimeDir), JSON.stringify({ ...readSettings(runtimeDir), on }));
|
|
60
|
+
return { ok: true, on };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The image type the first bytes name: 'jpg' | 'png' | 'webp' | null. */
|
|
64
|
+
function kindOf(buf) {
|
|
65
|
+
if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
|
|
66
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpg';
|
|
67
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
|
|
68
|
+
if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'webp';
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readIndex(runtimeDir, id) {
|
|
73
|
+
try {
|
|
74
|
+
const a = JSON.parse(fs.readFileSync(indexOf(runtimeDir, id), 'utf8'));
|
|
75
|
+
if (!Array.isArray(a)) return [];
|
|
76
|
+
return a.filter((f) => f && Number.isFinite(f.t) && typeof f.file === 'string' && FILE_RE.test(f.file)).sort((x, y) => x.t - y.t);
|
|
77
|
+
} catch { return []; }
|
|
78
|
+
}
|
|
79
|
+
const writeIndex = (runtimeDir, id, rows) => writeAtomic(indexOf(runtimeDir, id), JSON.stringify(rows));
|
|
80
|
+
const dimension = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Math.min(20000, Math.round(Number(n))) : 0);
|
|
81
|
+
|
|
82
|
+
/** Keep one shot. `t` is the meeting time in ms, `buf` the image bytes, `w` and `h` its size in pixels. */
|
|
83
|
+
function add(runtimeDir, { id, t, buf, w, h, why } = {}) {
|
|
84
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
85
|
+
const time = typeof t === 'number' ? t : NaN;
|
|
86
|
+
if (!Number.isFinite(time) || time < 0 || time > MAX_T) return { ok: false, status: 400, error: 'a screenshot needs its meeting time' };
|
|
87
|
+
const at = Math.round(time);
|
|
88
|
+
if (!Buffer.isBuffer(buf) || !buf.length) return { ok: false, status: 400, error: 'there is no picture to keep' };
|
|
89
|
+
if (buf.length > MAX_SHOT_BYTES) return { ok: false, status: 413, error: 'that screenshot is larger than 2 MB' };
|
|
90
|
+
const kind = kindOf(buf);
|
|
91
|
+
if (!kind) return { ok: false, status: 400, error: 'a screenshot must be a JPEG, PNG or WebP picture' };
|
|
92
|
+
const rows = readIndex(runtimeDir, id);
|
|
93
|
+
const old = rows.find((f) => f.t === at);
|
|
94
|
+
const others = rows.filter((f) => f !== old);
|
|
95
|
+
if (others.length >= MAX_SHOTS) return { ok: false, status: 409, error: 'this note already keeps ' + MAX_SHOTS + ' screenshots, the most it holds' };
|
|
96
|
+
if (others.reduce((n, f) => n + (f.bytes || 0), 0) + buf.length > MAX_BYTES) return { ok: false, status: 409, error: 'this note\'s screenshots have reached 500 MB, the most it holds' };
|
|
97
|
+
const file = String(at).padStart(9, '0') + '.' + kind;
|
|
98
|
+
const dir = dirOf(runtimeDir, id);
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
const target = path.join(dir, file);
|
|
101
|
+
const tmp = target + '.' + process.pid + '.tmp';
|
|
102
|
+
fs.writeFileSync(tmp, buf, { mode: 0o600 });
|
|
103
|
+
fs.renameSync(tmp, target);
|
|
104
|
+
if (old && old.file !== file) { try { fs.unlinkSync(path.join(dir, old.file)); } catch { /* none */ } }
|
|
105
|
+
const frame = { t: at, file, w: dimension(w), h: dimension(h), bytes: buf.length };
|
|
106
|
+
// why the page kept it (the screen changed, a line pointed at it, a highlight): plain words, kept short
|
|
107
|
+
const reason = typeof why === 'string' ? why.replace(/\s+/g, ' ').trim().slice(0, 120) : '';
|
|
108
|
+
if (reason) frame.why = reason;
|
|
109
|
+
writeIndex(runtimeDir, id, others.concat([frame]).sort((x, y) => x.t - y.t));
|
|
110
|
+
queueOcr(runtimeDir, id, frame);
|
|
111
|
+
return { ok: true, frame };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function list(runtimeDir, id) {
|
|
115
|
+
if (!ID_RE.test(String(id || ''))) return [];
|
|
116
|
+
return readIndex(runtimeDir, id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The shot at meeting time `t` as its image. A shot is never changed once kept, so the browser may keep it for good. */
|
|
120
|
+
function serve(req, res, runtimeDir, id, t) {
|
|
121
|
+
const miss = () => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no screenshot at that time' })); };
|
|
122
|
+
if (!ID_RE.test(String(id || '')) || t === null || t === undefined || t === '' || !Number.isFinite(Number(t))) return miss();
|
|
123
|
+
const at = Math.round(Number(t));
|
|
124
|
+
const f = readIndex(runtimeDir, id).find((x) => x.t === at);
|
|
125
|
+
if (!f) return miss();
|
|
126
|
+
const file = path.join(dirOf(runtimeDir, id), f.file);
|
|
127
|
+
let size;
|
|
128
|
+
try { size = fs.statSync(file).size; } catch { return miss(); }
|
|
129
|
+
res.writeHead(200, { 'Content-Type': TYPES[path.extname(f.file).slice(1)] || 'application/octet-stream', 'Content-Length': size, 'Cache-Control': 'private, max-age=31536000, immutable' });
|
|
130
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
131
|
+
fs.createReadStream(file).on('error', () => res.end()).pipe(res);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Delete the note's screenshots. True when a folder was there. */
|
|
135
|
+
function remove(runtimeDir, id) {
|
|
136
|
+
if (!ID_RE.test(String(id || ''))) return false;
|
|
137
|
+
const dir = dirOf(runtimeDir, id);
|
|
138
|
+
const had = fs.existsSync(dir);
|
|
139
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
140
|
+
return had;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── on-screen text ──────────────────────────────────────────────────────────────────────────────── */
|
|
144
|
+
const isExec = (f) => { try { fs.accessSync(f, fs.constants.X_OK); return fs.statSync(f).isFile(); } catch { return false; } };
|
|
145
|
+
/** The tesseract on this machine: PMOS_TESSERACT, else the PATH, else where Homebrew puts it. */
|
|
146
|
+
function ocrDetect({ env = process.env, fallbacks = ['/opt/homebrew/bin', '/usr/local/bin'] } = {}) {
|
|
147
|
+
const named = env.PMOS_TESSERACT;
|
|
148
|
+
if (named) return isExec(named) ? { bin: named } : null;
|
|
149
|
+
for (const d of String(env.PATH || '').split(path.delimiter).concat(fallbacks)) { if (!d) continue; const f = path.join(d, 'tesseract'); if (isExec(f)) return { bin: f }; }
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const tidy = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, OCR_MAX_CHARS);
|
|
153
|
+
|
|
154
|
+
let queue = Promise.resolve();
|
|
155
|
+
let waiting = 0;
|
|
156
|
+
function queueOcr(runtimeDir, id, frame) {
|
|
157
|
+
const found = ocrDetect();
|
|
158
|
+
if (!found) return;
|
|
159
|
+
waiting++;
|
|
160
|
+
queue = queue.then(() => readText(found.bin, runtimeDir, id, frame)).catch(() => {}).then(() => { waiting--; });
|
|
161
|
+
}
|
|
162
|
+
function readText(bin, runtimeDir, id, frame) {
|
|
163
|
+
const file = path.join(dirOf(runtimeDir, id), frame.file);
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
execFile(bin, [file, 'stdout'], { timeout: OCR_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
166
|
+
const text = err ? '' : tidy(stdout);
|
|
167
|
+
if (text) {
|
|
168
|
+
// patch the row only if the same shot is still there (it may have been replaced or deleted while it was read)
|
|
169
|
+
const rows = readIndex(runtimeDir, id);
|
|
170
|
+
const row = rows.find((f) => f.t === frame.t && f.file === frame.file && f.bytes === frame.bytes);
|
|
171
|
+
if (row) { row.text = text; try { writeIndex(runtimeDir, id, rows); } catch { /* the folder went away */ } }
|
|
172
|
+
}
|
|
173
|
+
resolve();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** Resolves when every text read queued so far has ended. */
|
|
178
|
+
function ocrIdle() { return queue.then(() => (waiting > 0 ? ocrIdle() : undefined)); }
|
|
179
|
+
|
|
180
|
+
module.exports = { add, list, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
@@ -275,11 +275,13 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
275
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 */ }
|
|
276
276
|
|
|
277
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:
|
|
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
|
|
279
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.
|
|
280
281
|
* A line is never held longer than holdMs. A note with no meeting audio holds nothing. */
|
|
281
282
|
const heard = new Map(); // note id → [{ source, t, end, text }] the lines of the last ECHO_KEEP_MS, for the echo check
|
|
282
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
|
|
283
285
|
const inQueue = []; // { id, source, t } of the cuts queued and not yet emitted
|
|
284
286
|
const held = []; // { job, text, until }
|
|
285
287
|
let holdTimer = null;
|
|
@@ -289,6 +291,8 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
289
291
|
heard.set(job.id, xs);
|
|
290
292
|
};
|
|
291
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;
|
|
292
296
|
if (inQueue.some((q) => q.id === id && q.source === 'meeting' && q.t < end)) return true;
|
|
293
297
|
const b = bufs.get(id + '|meeting');
|
|
294
298
|
return !!(b && b.voicedMs >= MIN_VOICED_MS && b.t < end);
|
|
@@ -390,7 +394,10 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
390
394
|
*/
|
|
391
395
|
function push({ id, source, pcm, t = 0, final = false, exact = false }) {
|
|
392
396
|
const k = id + '|' + source;
|
|
393
|
-
if (source === 'meeting' && pcm && pcm.length)
|
|
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
|
+
}
|
|
394
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
|
|
395
402
|
// would be a fragment of a sentence already written. Its audio is kept, and the final pass after Stop reads it.
|
|
396
403
|
if (exact && !final && t + 1 < (cutEnd.get(k) || 0)) return { ok: true, cut: false, late: true };
|
|
@@ -432,7 +439,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
432
439
|
for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
|
|
433
440
|
for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
|
|
434
441
|
for (let i = held.length - 1; i >= 0; i--) if (held[i].job.id === id) held.splice(i, 1);
|
|
435
|
-
heard.delete(id); meetingAt.delete(id);
|
|
442
|
+
heard.delete(id); meetingAt.delete(id); meetingTo.delete(id);
|
|
436
443
|
for (const k of Array.from(cutEnd.keys())) if (k.startsWith(id + '|')) cutEnd.delete(k);
|
|
437
444
|
}
|
|
438
445
|
/** Settled: every queued cut is transcribed and every held line is out. */
|
|
@@ -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,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* records-dir.js — the records folder (planning/records/<kind>.jsonl, scripts/records) of the workspace a request is about.
|
|
3
|
+
*
|
|
4
|
+
* recordsDirFor(ctx, ws) → an absolute path
|
|
5
|
+
*
|
|
6
|
+
* Order: ctx.recordsDir (a test's folder) → the workspace's own folder, from scripts/signals/layout-paths.js (a kit tenant keeps
|
|
7
|
+
* its records in pmos/state/records, the PMOS repo in planning/records) → the PMOS checkout this server runs from, the old
|
|
8
|
+
* default, for a server with no registered workspace. `ws` is the ?ws= id; null is the home workspace (server.js getWs).
|
|
9
|
+
*
|
|
10
|
+
* Before this, every records route read the PMOS checkout's planning/records whatever the workspace, so a tenant's Reports and
|
|
11
|
+
* Signals showed nothing (initiative reports-tenant-records).
|
|
12
|
+
*/
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { repoScript } = require('./repo-script.js');
|
|
15
|
+
|
|
16
|
+
const CHECKOUT_RECORDS = path.join(__dirname, '..', '..', 'planning', 'records');
|
|
17
|
+
|
|
18
|
+
function recordsDirFor(ctx, ws) {
|
|
19
|
+
if (ctx && ctx.recordsDir) return ctx.recordsDir;
|
|
20
|
+
if (ctx && ctx.repoRoot) return path.join(ctx.repoRoot, 'planning', 'records');
|
|
21
|
+
let w = null;
|
|
22
|
+
try { w = ctx && typeof ctx.getWs === 'function' ? ctx.getWs(ws || null) : null; } catch { w = null; }
|
|
23
|
+
if (w && w.root) {
|
|
24
|
+
try { return require(repoScript('signals/layout-paths.js')).pathsFor(w.root).recordsDir; } catch { /* fall through */ }
|
|
25
|
+
}
|
|
26
|
+
return CHECKOUT_RECORDS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { recordsDirFor, CHECKOUT_RECORDS };
|
|
@@ -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
|
+
});
|