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.
Files changed (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +35 -2
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-audio.js +28 -9
  83. package/workspace/lib/notes-capture.js +136 -0
  84. package/workspace/lib/notes-final.js +38 -13
  85. package/workspace/lib/notes-live-guard.js +117 -0
  86. package/workspace/lib/notes-replay.js +125 -0
  87. package/workspace/lib/notes-screen.js +180 -0
  88. package/workspace/lib/notes-whisper.js +139 -16
  89. package/workspace/lib/notes.js +4 -2
  90. package/workspace/lib/outbox-store.js +10 -6
  91. package/workspace/lib/pmos-verbs.js +3 -1
  92. package/workspace/lib/proactive-cli.js +7 -0
  93. package/workspace/lib/proactive.js +15 -2
  94. package/workspace/lib/repo-script.js +36 -0
  95. package/workspace/lib/report-dashboards.js +5 -2
  96. package/workspace/lib/report-frame.js +122 -0
  97. package/workspace/lib/report-schedules.js +113 -0
  98. package/workspace/lib/route-modules.js +9 -2
  99. package/workspace/lib/routes-boards.js +734 -0
  100. package/workspace/lib/routes-brief-page.js +13 -4
  101. package/workspace/lib/routes-briefing.js +22 -11
  102. package/workspace/lib/routes-c3-init.js +4 -3
  103. package/workspace/lib/routes-c3-life.js +2 -1
  104. package/workspace/lib/routes-c3-reports.js +96 -13
  105. package/workspace/lib/routes-c3-strategy.js +4 -3
  106. package/workspace/lib/routes-c3-timeline.js +3 -2
  107. package/workspace/lib/routes-calendar-import.js +3 -2
  108. package/workspace/lib/routes-calendar-views.js +9 -4
  109. package/workspace/lib/routes-doc-health.js +2 -1
  110. package/workspace/lib/routes-doc-sheet.js +2 -1
  111. package/workspace/lib/routes-forecast.js +2 -1
  112. package/workspace/lib/routes-hooks.js +3 -2
  113. package/workspace/lib/routes-initiatives.js +2 -1
  114. package/workspace/lib/routes-intake.js +2 -1
  115. package/workspace/lib/routes-notes.js +113 -15
  116. package/workspace/lib/routes-outbox.js +23 -3
  117. package/workspace/lib/routes-pm-items.js +19 -6
  118. package/workspace/lib/routes-records.js +3 -2
  119. package/workspace/lib/routes-releases.js +2 -1
  120. package/workspace/lib/routes-report-facts.js +4 -3
  121. package/workspace/lib/routes-report-families.js +2 -1
  122. package/workspace/lib/routes-roadmap-ops.js +5 -4
  123. package/workspace/lib/routes-runs.js +6 -5
  124. package/workspace/lib/routes-settings.js +51 -0
  125. package/workspace/lib/routes-signals.js +3 -2
  126. package/workspace/lib/routes-stage-tools.js +220 -0
  127. package/workspace/lib/run-answers.js +2 -1
  128. package/workspace/lib/settings-store.js +226 -0
  129. package/workspace/lib/stage-tools.js +208 -0
  130. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  131. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  132. package/workspace/lib/work-evidence.js +73 -0
  133. package/workspace/package-lock.json +2 -2
  134. package/workspace/package.json +1 -1
  135. package/workspace/public/pm/briefing-deck.js +63 -17
  136. package/workspace/public/pm/briefing-model.js +36 -7
  137. package/workspace/public/pm/c3-ambient.js +100 -0
  138. package/workspace/public/pm/c3-brief-needs.js +26 -6
  139. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  140. package/workspace/public/pm/c3-brief-today.js +56 -21
  141. package/workspace/public/pm/c3-brief.js +2 -1
  142. package/workspace/public/pm/c3-calendar.js +161 -42
  143. package/workspace/public/pm/c3-calpop.js +5 -5
  144. package/workspace/public/pm/c3-demo-boards.js +649 -0
  145. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  146. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  147. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  148. package/workspace/public/pm/c3-demo-data.js +17 -27
  149. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  150. package/workspace/public/pm/c3-demo.js +2 -1
  151. package/workspace/public/pm/c3-dock.js +1 -1
  152. package/workspace/public/pm/c3-ib-docs.js +80 -0
  153. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  154. package/workspace/public/pm/c3-ib-items.js +201 -0
  155. package/workspace/public/pm/c3-ib-releases.js +51 -0
  156. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  157. package/workspace/public/pm/c3-ib-triage.js +178 -0
  158. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  159. package/workspace/public/pm/c3-ini-records.js +197 -0
  160. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  161. package/workspace/public/pm/c3-ini-ui.js +229 -0
  162. package/workspace/public/pm/c3-ini-work.js +139 -0
  163. package/workspace/public/pm/c3-initiatives.css +218 -0
  164. package/workspace/public/pm/c3-initiatives.js +741 -0
  165. package/workspace/public/pm/c3-plan.js +2 -2
  166. package/workspace/public/pm/c3-reports.css +47 -47
  167. package/workspace/public/pm/c3-reports.js +223 -185
  168. package/workspace/public/pm/c3-settings.css +74 -0
  169. package/workspace/public/pm/c3-settings.js +703 -0
  170. package/workspace/public/pm/c3-shell.js +102 -36
  171. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  172. package/workspace/public/pm/c3-terminal.js +2 -2
  173. package/workspace/public/pm/calendar-views.js +26 -13
  174. package/workspace/public/pm/components.css +319 -213
  175. package/workspace/public/pm/console3.css +118 -23
  176. package/workspace/public/pm/dest-notes.js +26 -4
  177. package/workspace/public/pm/notes-screen-capture.js +165 -0
  178. package/workspace/public/pm/notes-screen-view.js +98 -0
  179. package/workspace/public/pm/panel-trees.js +5 -2
  180. package/workspace/public/pm/pm-work-view.js +233 -146
  181. package/workspace/public/pm/shell-bell.js +3 -2
  182. package/workspace/public/pm/shell-recording.js +148 -17
  183. package/workspace/public/pm/tokens.css +2 -2
  184. package/workspace/public/pm/view-notes.js +53 -11
  185. package/workspace/public/pm/welcome.js +197 -89
  186. package/workspace/public/welcome.html +6 -4
  187. package/workspace/server.js +105 -6
  188. package/workspace/skills/day-check.skill +1 -1
  189. package/workspace/skills/jira-sync.skill +6 -4
  190. package/workspace/skills/morning-brief.skill +1 -1
  191. package/workspace/test-files.json +14 -3
  192. package/workspace/test-lists/c3-ib-items.json +9 -0
  193. package/workspace/test-lists/c3-ib-more.json +9 -0
  194. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  195. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  196. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  197. package/workspace/test-lists/console3-briefing.json +9 -0
  198. package/workspace/test-lists/console3-foundation.json +9 -0
  199. package/workspace/test-lists/console3-reports.json +3 -3
  200. package/workspace/test-lists/meeting-notes.json +12 -4
  201. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  202. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  203. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  204. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  205. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  206. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  207. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  208. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  209. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  210. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  211. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  212. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.133.0",
3
+ "version": "0.135.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@c3d0fb4
1
+ kernel-source: pmos@62ceee4
2
2
  assembled: 2026-10-11
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-11T04:26:11Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T23:50:45Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-11T04:26:11Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T23:50:45Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-11T04:26:11Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T23:50:45Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ /* notes-replay — notes-live-settle Run 1: play a kept meeting through the live transcription again, on a private server, so the PM
3
+ * can listen to it and read what came out; and measure whether this machine could also correct the transcript during a call.
4
+ *
5
+ * node bin/notes-replay.js list the kept meetings: id, date, length, title
6
+ * node bin/notes-replay.js <note-id> [--speed 1] [--minutes <n>] [--from <min>] [--port 7460] [--settle] [--exit]
7
+ *
8
+ * WHAT IT DOES.
9
+ * 1. Reads the note's two kept sources (`<id>.mic.pcm` and `<id>.meeting.pcm`, lib/notes-audio.js). It never writes to that note.
10
+ * 2. Starts a second PMOS server on its own port (7460 by default) with its own empty state folder in the system temp folder.
11
+ * Its engines are the PM's own (the Whisper model and the final pass's engines are linked, not copied). In that server Jev,
12
+ * Wispr Flow and the bullets agent are off, so nothing is sent anywhere.
13
+ * 3. Makes a note "Replay of <title>", presses Listen, and sends both sources in one-second chunks as the page does, at the
14
+ * meeting's own pace (`--speed 2` is twice as fast; the measurements need 1).
15
+ * 4. Presses Stop, waits for the live lines and the final pass, and writes a report: lines said twice (a source repeating
16
+ * itself, or the microphone's echo of the meeting), how late the live lines came, and with `--settle` what the settle tier
17
+ * would cost. The report goes to `<state>/notes/replays/<note-id>-<time>.json` and is printed.
18
+ * 5. Keeps the server up so the PM can open http://127.0.0.1:<port>, go to Notes and play the replay; Ctrl-C stops it and
19
+ * deletes its temp folder. `--exit` stops at once after the report.
20
+ *
21
+ * --settle: while the replay runs, at each settle point (a 1.5 s pause, or the other source starting to talk) the last 30 s of
22
+ * that source is transcribed again with the final pass's engine (lib/notes-replay.js createSettleMeter). The report says the
23
+ * share of the meeting's time that engine was busy; the plan builds correction during the call only when it is under 0.5.
24
+ *
25
+ * It never uses port 7433 and never stops another server.
26
+ */
27
+ 'use strict';
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+ const net = require('net');
32
+ const { spawn } = require('child_process');
33
+ const { defaultRoot } = require('../lib/state-root');
34
+ const notes = require('../lib/notes');
35
+ const notesAudio = require('../lib/notes-audio');
36
+ const notesFinal = require('../lib/notes-final');
37
+ const R = require('../lib/notes-replay');
38
+
39
+ const argv = process.argv.slice(2);
40
+ const opt = (n, d = null) => { const i = argv.indexOf('--' + n); return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
41
+ const flag = (n) => argv.includes('--' + n);
42
+ const die = (m, c = 2) => { console.error('notes-replay: ' + m); process.exit(c); };
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+ const RATE = notesAudio.RATE;
45
+ const SEC = RATE * 2;
46
+ const mmss = (ms) => { const s = Math.max(0, Math.round(ms / 1000)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
47
+ const NEVER_PORT = 7433;
48
+
49
+ const stateDir = path.resolve(opt('state', defaultRoot()));
50
+ const cmd = argv[0];
51
+ if (!cmd || cmd === '--help' || cmd === '-h') { console.log(fs.readFileSync(__filename, 'utf8').split('\n').slice(1, 22).join('\n')); process.exit(0); }
52
+
53
+ if (cmd === 'list') {
54
+ const rows = [];
55
+ for (const n of (notes.list(stateDir).notes || [])) {
56
+ const srcs = notesAudio.sources(stateDir, n.id);
57
+ if (!srcs.length) continue;
58
+ rows.push({ id: n.id, at: String(n.created_at || '').slice(0, 16).replace('T', ' '), ms: Math.max(...srcs.map((s) => s.ms)), sources: srcs.map((s) => s.source).join('+'), title: n.title || '' });
59
+ }
60
+ if (!rows.length) { console.log('No meeting with kept audio in ' + stateDir); process.exit(0); }
61
+ for (const r of rows) console.log(`${r.id} ${r.at} ${mmss(r.ms).padStart(6)} ${r.sources.padEnd(12)} ${r.title}`);
62
+ process.exit(0);
63
+ }
64
+
65
+ const srcId = cmd;
66
+ const orig = notes.read(stateDir, srcId);
67
+ if (!orig.ok) die(`no note ${srcId} in ${stateDir} (node bin/notes-replay.js list shows the kept meetings)`);
68
+ if (orig.note.private) die('that note is marked private, so it is not replayed');
69
+ const srcs = notesAudio.sources(stateDir, srcId);
70
+ if (!srcs.length) die('that note kept no audio of its own sources, so there is nothing to replay');
71
+ const speed = Number(opt('speed', '1'));
72
+ if (!(speed > 0 && speed <= 50)) die('--speed is a number from 0.1 to 50');
73
+ const port = Number(opt('port', '7460'));
74
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) die('--port is a port number from 1024');
75
+ if (port === NEVER_PORT) die('port 7433 is never used by this command');
76
+ const settle = flag('settle');
77
+ if (settle && speed !== 1) console.log('notes-replay: --settle measures at the meeting\'s own pace; with --speed ' + speed + ' its numbers are not the real ones');
78
+
79
+ const audio = Object.fromEntries(srcs.map((s) => [s.source, fs.readFileSync(s.file)]));
80
+ const totalSec = Math.ceil(Math.max(...Object.values(audio).map((b) => b.length)) / SEC);
81
+ const fromSec = Math.max(0, Math.round(Number(opt('from', '0')) * 60));
82
+ const minutes = opt('minutes');
83
+ const toSec = Math.min(totalSec, minutes ? fromSec + Math.round(Number(minutes) * 60) : totalSec);
84
+ if (!(toSec > fromSec)) die('nothing to replay in that stretch: the meeting is ' + mmss(totalSec * 1000) + ' long');
85
+
86
+ function portFree(p) {
87
+ return new Promise((resolve) => { const s = net.createServer(); s.once('error', () => resolve(false)); s.listen(p, '127.0.0.1', () => s.close(() => resolve(true))); });
88
+ }
89
+
90
+ /* ── the private server ───────────────────────────────────────────────────────────────────────────── */
91
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-replay-'));
92
+ const rt = path.join(scratch, 'state');
93
+ let srv = null;
94
+ function cleanup() {
95
+ if (srv) { try { srv.kill('SIGTERM'); } catch { /* gone */ } srv = null; }
96
+ try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ }
97
+ }
98
+ process.on('SIGINT', () => { console.log('\nnotes-replay: stopped; the replay server and its temp folder are gone.'); cleanup(); process.exit(130); });
99
+ process.on('exit', cleanup);
100
+
101
+ function linkEngines() {
102
+ const from = path.join(stateDir, 'notes'); const to = path.join(rt, 'notes');
103
+ fs.mkdirSync(path.join(to, 'final'), { recursive: true });
104
+ // the Whisper models and the final pass's engines are the PM's own: linked, never copied or changed
105
+ const w = path.join(from, 'whisper');
106
+ if (fs.existsSync(w)) fs.symlinkSync(w, path.join(to, 'whisper'));
107
+ let es = []; try { es = fs.readdirSync(path.join(from, 'final')); } catch { /* none set up */ }
108
+ for (const e of es) if (!e.startsWith('tmp-')) fs.symlinkSync(path.join(from, 'final', e), path.join(to, 'final', e));
109
+ // the PM's language, model and accuracy choices; not settings.json (Jev), so the replay sends nothing
110
+ for (const f of ['whisper-settings.json', 'audio-settings.json']) { try { fs.copyFileSync(path.join(from, f), path.join(to, f)); } catch { /* not set */ } }
111
+ }
112
+
113
+ async function boot(base) {
114
+ const home = path.join(scratch, 'home'); fs.mkdirSync(home, { recursive: true });
115
+ const hf = process.env.HF_HOME || path.join(os.homedir(), '.cache', 'huggingface');
116
+ srv = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { cwd: path.join(__dirname, '..'), stdio: 'ignore',
117
+ env: { ...process.env, PMOS_STATE_DIR: rt, PMOS_WS_PORT: String(port), HOME: home, USERPROFILE: home, HF_HOME: hf,
118
+ PMOS_NOTES_WISPR: 'off', PMOS_NOTES_BULLETS_AGENT: 'off' } });
119
+ srv.once('exit', (c) => { if (srv) { srv = null; console.error('notes-replay: the replay server stopped (' + c + ')'); } });
120
+ for (const t0 = Date.now(); ;) {
121
+ const r = await fetch(base + '/pm/tokens.css').catch(() => null);
122
+ if (r && r.status === 200) break;
123
+ if (!srv || Date.now() - t0 > 60000) die('the replay server did not start on ' + base, 1);
124
+ await sleep(200);
125
+ }
126
+ // the replay server is this command's own; it is pressed the way its page presses it (pm-guard: Origin and the launch nonce)
127
+ const html = await (await fetch(base + '/')).text();
128
+ const m = /<meta name="pmos-nonce" content="([^"]*)"/.exec(html);
129
+ return { 'Content-Type': 'application/json', Origin: base, ...(m && m[1] ? { 'x-pmos-nonce': m[1] } : {}) };
130
+ }
131
+
132
+ async function main() {
133
+ if (!await portFree(port)) die(`port ${port} is in use; pick another with --port`);
134
+ linkEngines();
135
+ const base = `http://127.0.0.1:${port}`;
136
+ console.log(`notes-replay: starting a replay server on ${base} (its own state, in ${scratch})`);
137
+ const H = await boot(base);
138
+ const call = async (m, p, body) => { const r = await fetch(base + p, { method: m, headers: H, body: body === undefined ? undefined : JSON.stringify(body) }); let j = null; try { j = await r.json(); } catch { /* not JSON */ } return { status: r.status, body: j }; };
139
+ const eng = (await call('GET', '/api/notes/engine')).body || {};
140
+ if (!eng.whisper || eng.whisper.ready === false) die('local transcription is not set up on this machine (Settings, Notes), so there is nothing to replay through', 1);
141
+
142
+ const title = 'Replay of ' + (orig.note.title || srcId) + (fromSec || toSec < totalSec ? ` (${mmss(fromSec * 1000)} to ${mmss(toSec * 1000)})` : '');
143
+ const made = await call('POST', '/api/notes/new', { title });
144
+ if (!made.body || !made.body.ok) die('the replay note was not made: ' + JSON.stringify(made.body), 1);
145
+ const id = made.body.note.id;
146
+ const rec = await call('POST', '/api/notes/record', { id });
147
+ if (!rec.body || !rec.body.ok || !rec.body.capture) die('Listen was refused: ' + JSON.stringify(rec.body), 1);
148
+ const cap = rec.body.capture;
149
+
150
+ const meter = settle ? R.createSettleMeter({ transcribe: (pcm) => notesFinal.transcribeClip(rt, pcm) }) : null;
151
+ const seen = new Map(); // live line index → the wall time it was first seen
152
+ const began = Date.now();
153
+ const wallOf = (meetingMs) => began + (meetingMs - fromSec * 1000) / speed;
154
+ let polling = true;
155
+ const poll = (async () => {
156
+ while (polling) {
157
+ const n = (await call('GET', '/api/notes/one?id=' + id).catch(() => ({ body: null }))).body;
158
+ const ts = (n && n.note && n.note.transcript) || [];
159
+ ts.forEach((l, i) => { if (!seen.has(i)) seen.set(i, { at: Date.now(), line: l }); });
160
+ await sleep(1000);
161
+ }
162
+ })();
163
+ console.log(`notes-replay: replaying ${mmss((toSec - fromSec) * 1000)} of "${orig.note.title || srcId}" (${Object.keys(audio).join(' + ')}) at ${speed}x${settle ? ', measuring the settle tier' : ''}`);
164
+ const seq = { mic: -1, meeting: -1 };
165
+ for (let sec = fromSec; sec < toSec; sec++) {
166
+ const due = began + ((sec - fromSec) * 1000) / speed;
167
+ if (due > Date.now()) await sleep(due - Date.now());
168
+ await Promise.all(Object.keys(audio).map(async (source) => {
169
+ const pcm = audio[source].subarray(sec * SEC, (sec + 1) * SEC);
170
+ if (!pcm.length) return;
171
+ seq[source]++;
172
+ if (meter) meter.feed(source, pcm);
173
+ const r = await call('POST', '/api/notes/audio', { id, source, pcm: pcm.toString('base64'), final: sec === toSec - 1, cap: cap.token, seq: seq[source], off: (sec - fromSec) * RATE });
174
+ if (!r.body || !r.body.ok) console.error(`notes-replay: ${source} at ${mmss(sec * 1000)} was refused: ${(r.body && r.body.error) || r.status}`);
175
+ }));
176
+ if ((sec - fromSec) % 60 === 59) console.log(` ${mmss((sec - fromSec + 1) * 1000)} of ${mmss((toSec - fromSec) * 1000)} sent, ${seen.size} live lines so far`);
177
+ }
178
+ const elapsed = Date.now() - began;
179
+ await call('POST', '/api/notes/stop', { id });
180
+ // the page asks for the notes after Stop, which queues the final pass; the replay asks for the pass itself
181
+ const fq = await call('POST', '/api/notes/final', { id });
182
+ if (!fq.body || !fq.body.ok) console.log('notes-replay: the final pass was not queued: ' + ((fq.body && fq.body.error) || fq.status));
183
+ console.log('notes-replay: Stop pressed; waiting for the last live lines and the final pass');
184
+ // the live lines are done when the transcript has not changed for 10 s; then the final pass, for as long as it runs
185
+ let last = ''; let still = 0; let note = null;
186
+ for (let i = 0; i < 60 * 60 * 2; i++) {
187
+ note = (await call('GET', '/api/notes/one?id=' + id)).body.note;
188
+ const k = JSON.stringify(note.transcript || []);
189
+ still = k === last ? still + 1 : 0; last = k;
190
+ const fs0 = note.final_state && note.final_state.state;
191
+ if (still >= 10 && (!fs0 || fs0 === 'done' || fs0 === 'failed')) break;
192
+ if (i % 60 === 59) console.log(' final pass: ' + (fs0 || 'not started') + (note.final_state && note.final_state.step ? ', ' + note.final_state.step : ''));
193
+ await sleep(1000);
194
+ }
195
+ polling = false; await poll;
196
+ if (meter) await meter.idle();
197
+
198
+ const live = (note.transcript || []).filter((l) => !l.dropped);
199
+ const dupLive = R.duplicates(live);
200
+ const finalSegs = ((note.final && note.final.segments) || []).map((g) => ({ t: g.t, t_end: g.end, text: g.text, source: g.source }));
201
+ const dupFinal = R.duplicates(finalSegs);
202
+ const lateOf = [...seen.values()].filter((s) => !s.line.dropped && Number.isFinite(s.line.t)).map((s) => ({ late_ms: s.at - wallOf(Number.isFinite(s.line.t_end) ? s.line.t_end : s.line.t) }));
203
+ const show = (d) => ({ kind: d.kind, at: mmss(d.a.t), a: `${d.a.source}: ${d.a.text}`, b: `${d.b.source} at ${mmss(d.b.t)}: ${d.b.text}`, overlap: d.overlap });
204
+ const report = {
205
+ at: new Date().toISOString(), note: srcId, title: orig.note.title || '', replay_note: id, from: mmss(fromSec * 1000), to: mmss(toSec * 1000), speed,
206
+ live: { lines: live.length, by_source: Object.fromEntries(['mic', 'meeting'].map((s) => [s, live.filter((l) => l.source === s).length])),
207
+ repeats: dupLive.filter((d) => d.kind === 'repeat').length, echoes: dupLive.filter((d) => d.kind === 'echo').length, lag: R.lag(lateOf), said_twice: dupLive.map(show) },
208
+ final: note.final ? { engine: note.final.engine, lines: finalSegs.length, took_ms: note.final.took_ms, repeats: dupFinal.filter((d) => d.kind === 'repeat').length,
209
+ echoes: dupFinal.filter((d) => d.kind === 'echo').length, said_twice: dupFinal.map(show) } : { state: note.final_state || null },
210
+ settle: meter ? meter.report(elapsed) : null,
211
+ };
212
+ const outDir = path.join(stateDir, 'notes', 'replays'); fs.mkdirSync(outDir, { recursive: true });
213
+ const outFile = path.join(outDir, `${srcId}-${report.at.replace(/[:.]/g, '-')}.json`);
214
+ fs.writeFileSync(outFile, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 });
215
+
216
+ console.log('\n── Replay report ──');
217
+ console.log(`Live lines: ${report.live.lines} (microphone ${report.live.by_source.mic}, meeting ${report.live.by_source.meeting}).`);
218
+ console.log(`Said twice in the live transcript: ${report.live.repeats} repeats, ${report.live.echoes} echoes of the meeting on the microphone.`);
219
+ for (const d of report.live.said_twice.slice(0, 10)) console.log(` ${d.at} ${d.kind}: "${d.a}" / "${d.b}"`);
220
+ if (report.live.lag.n) console.log(`Live lines appeared ${Math.round(report.live.lag.median_ms / 100) / 10} s after their audio (median), ${Math.round(report.live.lag.p90_ms / 100) / 10} s at the 90th percentile.`);
221
+ if (note.final) console.log(`Final pass (${report.final.engine}): ${report.final.lines} lines, ${report.final.repeats} repeats, ${report.final.echoes} echoes.`);
222
+ else console.log('Final pass: ' + JSON.stringify(report.final.state));
223
+ if (report.settle) {
224
+ const s = report.settle;
225
+ console.log(`Settle tier (${s.engine || 'no engine'}): ${s.runs} of ${s.points} settle points transcribed again, ${s.skipped} skipped because the engine was busy.`);
226
+ console.log(` The engine was busy ${s.busy_share === null ? '?' : Math.round(s.busy_share * 100)}% of the meeting's time (the gate is under ${s.gate * 100}%); one 30 s window took ${s.window_rtf_median} of its length (median).`);
227
+ console.log(s.keeps_up ? ' This machine keeps up: correction at pauses can be built.' : ' This machine does not keep up: correction during the call is not built as planned.');
228
+ if (s.errors.length) console.log(' Errors: ' + s.errors.join(' | '));
229
+ }
230
+ console.log(`\nReport: ${outFile}`);
231
+ if (flag('exit')) { cleanup(); process.exit(0); }
232
+ console.log(`To listen: open ${base}, go to Notes, and open "${title}". Press Ctrl-C here when you are done; that deletes the replay.`);
233
+ }
234
+
235
+ main().catch((e) => { console.error('notes-replay: ' + ((e && e.stack) || e)); cleanup(); process.exit(1); });
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-briefing-test.js — the console3 Briefing page (initiative console3-briefing-parity; planning/prd/console3-briefing-parity-prd.md R1 to R6).
4
+ * Loads /c3.html?demo=1 (demo mode answers every /api call, so nothing reaches the server) and holds:
5
+ * R1 two columns: stance + deck + Needs you on the left; Today + Portfolio pulse on the right
6
+ * R2 Review, Handled, What I could stop and Data you can lean on are not on the Briefing
7
+ * R3 the greeting follows the clock; five day-part buttons (Night included) preview a part and the clock's part comes back on a tap
8
+ * R4 Today: a lead line with a ring, "Open calendar", rows with chips (nrow), no old two-column prepared/before layout
9
+ * R5 Needs you: "N decisions . M of yours" header, decision tags, a Work home chip on own items
10
+ * R6 Portfolio pulse: "N need attention", rows, "All initiatives" goes to Initiatives; 400 wide has no sideways scroll
11
+ * The page must raise no script error. Every wait polls the page; no fixed sleep stands in for a state.
12
+ */
13
+ const path = require('path');
14
+ const { spawn } = require('child_process');
15
+ const { findBrowser } = require('./lib/find-browser');
16
+ const T = require('./test-run').configure({ name: 'c3-briefing' });
17
+
18
+ const BASE = T.BASE;
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+ const until = async (fn, ms = 10000, step = 120) => {
21
+ const t0 = Date.now();
22
+ for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); }
23
+ };
24
+ const results = [];
25
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
26
+ let srv = null; let browser = null;
27
+ const finish = async (code) => {
28
+ if (browser) await browser.close().catch(() => {});
29
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
30
+ T.killTmux();
31
+ const bad = results.filter((x) => !x.cond).length;
32
+ console.log(`\n${results.length - bad}/${results.length} c3-briefing assertions passed`);
33
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
34
+ };
35
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
36
+
37
+ (async () => {
38
+ const exe = findBrowser();
39
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-briefing suite cannot run'); return finish(1); }
40
+ let chromium;
41
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-briefing suite cannot run'); return finish(1); }
42
+
43
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
44
+ await T.waitForServer(BASE);
45
+ browser = await chromium.launch({ executablePath: exe });
46
+
47
+ const open = async (ctxOpts) => {
48
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ...(ctxOpts || {}) });
49
+ const page = await ctx.newPage();
50
+ const errors = [];
51
+ page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
52
+ await page.goto(BASE + '/c3.html?demo=1');
53
+ const up = await until(() => page.evaluate(() => !!document.querySelector('[data-c3-demo="1"]') && document.querySelectorAll('#c3-root .deck .fc').length > 0), 45000, 250);
54
+ return { ctx, page, errors, up };
55
+ };
56
+
57
+ const A = await open();
58
+ ok('0 the demo page draws the Briefing deck', A.up);
59
+ if (!A.up) return finish(1);
60
+ const p = A.page;
61
+ await until(() => p.evaluate(() => document.querySelectorAll('[data-c3pg="today"] .nrow').length > 0 && document.querySelectorAll('[data-c3pg="pulse"] [data-pulse-row]').length > 0 && !!document.querySelector('[data-needs-summary]')), 20000);
62
+
63
+ /* R1 */
64
+ const lay = await p.evaluate(() => {
65
+ const b = document.querySelector('.c3 .brief'); const l = b.querySelector('.bcols > .bcol.l'); const r = b.querySelector('.bcols > .bcol.r');
66
+ if (!l || !r) return null;
67
+ return { sideBySide: l.getBoundingClientRect().right <= r.getBoundingClientRect().left + 1, one: b.classList.contains('one'),
68
+ left: ['deck-host', 'needs'].every((k) => !!l.querySelector(`[data-c3pg="${k}"]`)), right: ['today', 'pulse'].every((k) => !!r.querySelector(`[data-c3pg="${k}"]`)), stance: !!l.querySelector('.stance') };
69
+ });
70
+ ok('R1 two columns side by side, no .one', !!lay && lay.sideBySide && !lay.one, JSON.stringify(lay));
71
+ ok('R1 left holds the stance, the deck and Needs you; right holds Today and Portfolio pulse', !!lay && lay.left && lay.right && lay.stance, JSON.stringify(lay));
72
+
73
+ /* R2 */
74
+ ok('R2 no Review, Handled, Stop or Data trust on the Briefing', await p.evaluate(() => !document.querySelector('[data-c3pg="review-host"], [data-c3pg="handled-host"], [data-c3pg="stop-host"], [data-c3pg="trust-host"]') && !/Data you can lean on|What I could stop|handled for you/i.test(document.querySelector('.c3 .brief').textContent)));
75
+
76
+ /* R3 */
77
+ const dp = () => p.evaluate(() => ({ greet: document.querySelector('.stance b').textContent, line: document.querySelector('.stance .dim').textContent, keys: [...document.querySelectorAll('.stance [data-pg-dp]')].map((b) => b.dataset.pgDp), on: [...document.querySelectorAll('.stance [data-pg-dp]')].filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.dataset.pgDp) }));
78
+ const d0 = await dp();
79
+ ok('R3 five day-part buttons, Night last', d0.keys.join() === 'morning,midday,afternoon,evening,night', d0.keys.join());
80
+ ok('R3 the greeting names the part of the day and the PM', /^Good (morning|afternoon|evening|night), \w+/.test(d0.greet) && d0.on.length === 1, JSON.stringify(d0));
81
+ const clockPart = d0.on[0];
82
+ const other = clockPart === 'night' ? 'midday' : 'night';
83
+ await p.click(`.stance [data-pg-dp="${other}"]`);
84
+ const d1 = await until(async () => { const d = await dp(); return d.on[0] === other ? d : null; }, 4000);
85
+ ok('R3 a tap previews that part: pressed, its greeting', !!d1 && d1.greet !== d0.greet && /^Good /.test(d1.greet), JSON.stringify(d1));
86
+ await p.click(`.stance [data-pg-dp="night"]`);
87
+ const dn = await until(async () => { const d = await dp(); return d.on[0] === 'night' ? d : null; }, 4000);
88
+ ok('R3 Night previews: "Good night" and the Reflect line', !!dn && /^Good night/.test(dn.greet) && /^Reflect/.test(dn.line), JSON.stringify(dn));
89
+ ok('R3 the Night preview names Needs you "Carries over"', await p.evaluate(() => /Carries over/i.test(document.querySelector('[data-c3pg="needs"] .sh2 h2').textContent)));
90
+ await p.click(`.stance [data-pg-dp="${clockPart}"]`);
91
+ const d2 = await until(async () => { const d = await dp(); return d.on[0] === clockPart ? d : null; }, 4000);
92
+ ok('R3 a tap on the clock part brings it back', !!d2 && d2.greet === d0.greet, JSON.stringify(d2));
93
+
94
+ /* R4 */
95
+ const td = await p.evaluate(() => {
96
+ const t = document.querySelector('[data-c3pg="today"]'); const lh = t.querySelector('.lh');
97
+ const row = t.querySelector('.nrow[data-today-row]');
98
+ return { lead: !!lh && /meetings? left today\. \d+ of \d+ prepped/.test(lh.textContent), ring: !!(lh && lh.querySelector('svg circle.rg')), btn: !!t.querySelector('button[data-pg-opencal]'), rows: t.querySelectorAll('.nrow[data-today-row]').length,
99
+ chips: !!(row && row.querySelector('.chips .chip2')), old: !!t.querySelector('.trow, .tcols'), ref: !!(row && row.hasAttribute('data-ref-row')) };
100
+ });
101
+ ok('R4 Today has the lead line with a ring and an Open calendar button', td.lead && td.ring && td.btn, JSON.stringify(td));
102
+ ok('R4 Today rows are nrow rows with chips and no old tcols layout', td.rows > 0 && td.chips && !td.old && td.ref, JSON.stringify(td));
103
+ await p.click('[data-c3pg="today"] button[data-pg-opencal]');
104
+ ok('R4 Open calendar opens the calendar popup', await until(() => p.evaluate(() => !!document.querySelector('[role="dialog"]')), 4000));
105
+ await p.keyboard.press('Escape');
106
+
107
+ /* R5 */
108
+ const nd = await p.evaluate(() => {
109
+ const n = document.querySelector('[data-c3pg="needs"]'); const s = n.querySelector('[data-needs-summary]');
110
+ const own = n.querySelector('.nrow[data-item-id]');
111
+ return { sum: s ? s.textContent : null, decTag: !!n.querySelector('.nrow:not([data-item-id]) .tag.acc'), home: !!(own && [...own.querySelectorAll('.chip2')].some((c) => c.textContent.trim() === 'Work')), actions: !!n.querySelector('[data-act="done"]') };
112
+ });
113
+ ok('R5 Needs you says "N decisions . M of yours"', !!nd.sum && /^\d+ decisions? · \d+ of yours/.test(nd.sum), JSON.stringify(nd));
114
+ ok('R5 decision rows carry the decision tag; own items carry the Work home chip', nd.decTag && nd.home, JSON.stringify(nd));
115
+
116
+ /* R6 */
117
+ const pu = await p.evaluate(() => { const c = document.querySelector('[data-c3pg="pulse"]'); return { attn: (c.querySelector('[data-pulse-attn]') || {}).textContent, bar: !!c.querySelector('.pbs'), rows: c.querySelectorAll('[data-pulse-row]').length, all: !!c.querySelector('button[data-go="initiatives"]') }; });
118
+ ok('R6 Portfolio pulse: "N need attention", the bar, rows and All initiatives', /^\d+ need attention$/.test(pu.attn || '') && pu.bar && pu.rows > 0 && pu.all, JSON.stringify(pu));
119
+ await p.click('[data-c3pg="pulse"] button[data-go="initiatives"]');
120
+ ok('R6 All initiatives opens the Initiatives tab', await until(() => p.evaluate(() => { const t = document.querySelector('[data-c3-tab="init"]'); return !!t && (t.getAttribute('aria-selected') === 'true' || t.classList.contains('on')); }), 4000));
121
+ ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
122
+ await A.ctx.close();
123
+
124
+ const M = await open({ viewport: { width: 400, height: 900 } });
125
+ ok('R6 at 400 wide the Briefing does not scroll sideways', M.up && await M.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 && document.getElementById('c3-root').scrollWidth <= innerWidth + 1), 'scrollWidth');
126
+ ok('R6 at 400 wide the day-part switch and Needs you are on the page', await M.page.evaluate(() => document.querySelectorAll('.stance [data-pg-dp]').length === 5 && !!document.querySelector('[data-c3pg="needs"]')));
127
+ ok('0 no page error (400 wide)', M.errors.length === 0, M.errors.join(' | '));
128
+ await M.ctx.close();
129
+ return finish();
130
+ })().catch((e) => { console.error(e); finish(1); });
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-foundation-test.js — the console3 shell foundation (initiative console3-foundation; planning/prd/console3-foundation-prd.md R1 to R7).
4
+ * Loads /c3.html?demo=1 (demo mode answers every /api call, so nothing reaches the server) and holds:
5
+ * R1 the page is white: no day-part wash on the page, no radial on the Briefing; data-daypart stays
6
+ * R2 one ambient canvas (.c3amb) sits behind the grid; reduced motion draws one still frame; dark theme draws; no canvas, no error
7
+ * R3 the build-fix rules: a primary button label is the page background colour; .sect > .sh2 is a flex row outside .brief
8
+ * R4 the card front shows number tiles and the kind label; the flipped back holds its options and the confirm control; a decision
9
+ * adds .ok and a toast with Undo, and Undo takes it back
10
+ * R5 the Briefing and Initiatives tabs carry count badges beside Work's
11
+ * R6 the Sessions pill and the dock button draw the bolt, with the mockup's tooltip copy
12
+ * R7 the Record control shows when idle in demo mode, starts the simulated recorder and returns to idle on stop
13
+ * The page must raise no script error. Every wait polls the page; no fixed sleep stands in for a state.
14
+ */
15
+ const path = require('path');
16
+ const { spawn } = require('child_process');
17
+ const { findBrowser } = require('./lib/find-browser');
18
+ const T = require('./test-run').configure({ name: 'c3-foundation' });
19
+
20
+ const BASE = T.BASE;
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ const until = async (fn, ms = 10000, step = 120) => {
23
+ const t0 = Date.now();
24
+ for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); }
25
+ };
26
+ const results = [];
27
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
28
+ let srv = null; let browser = null;
29
+ const finish = async (code) => {
30
+ if (browser) await browser.close().catch(() => {});
31
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
32
+ T.killTmux();
33
+ const bad = results.filter((x) => !x.cond).length;
34
+ console.log(`\n${results.length - bad}/${results.length} c3-foundation assertions passed`);
35
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
36
+ };
37
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
38
+
39
+ (async () => {
40
+ const exe = findBrowser();
41
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-foundation suite cannot run'); return finish(1); }
42
+ let chromium;
43
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-foundation suite cannot run'); return finish(1); }
44
+
45
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
46
+ await T.waitForServer(BASE);
47
+ browser = await chromium.launch({ executablePath: exe });
48
+
49
+ const open = async (ctxOpts, url, init) => {
50
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ...(ctxOpts || {}) });
51
+ const page = await ctx.newPage();
52
+ const errors = [];
53
+ page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
54
+ if (init) await page.addInitScript(init);
55
+ await page.goto(BASE + (url || '/c3.html?demo=1'));
56
+ const up = await until(() => page.evaluate(() => !!document.querySelector('[data-c3-demo="1"]') && document.querySelectorAll('#c3-root .deck .fc').length > 0), 45000, 250);
57
+ return { ctx, page, errors, up };
58
+ };
59
+ /* the largest alpha on the ambient canvas: 0 means nothing was drawn */
60
+ const maxAlpha = (page) => page.evaluate(() => { const c = document.querySelector('#c3-root > canvas.c3amb'); if (!c) return -1; const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; let m = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > m) m = d[i]; return m; });
61
+
62
+ const A = await open();
63
+ ok('0 the demo page draws the Briefing deck', A.up);
64
+ if (!A.up) return finish(1);
65
+ const p = A.page;
66
+
67
+ /* R1 */
68
+ ok('R1 the page has no day-part wash and keeps data-daypart', await p.evaluate(() => { const r = document.getElementById('c3-root'); return !!r.getAttribute('data-daypart') && getComputedStyle(r).backgroundImage === 'none'; }));
69
+ ok('R1 the Briefing has no blue radial', await p.evaluate(() => { const b = document.querySelector('.c3 .brief'); const s = getComputedStyle(b, '::before'); return !b || s.content === 'none' || s.display === 'none'; }));
70
+ ok('R1 the daylight strip still has its warm token', await p.evaluate(() => getComputedStyle(document.getElementById('c3-root')).getPropertyValue('--dp-warm').trim() !== ''));
71
+
72
+ /* R2 */
73
+ ok('R2 exactly one ambient canvas, fixed and behind the grid', await p.evaluate(() => { const l = document.querySelectorAll('#c3-root canvas.c3amb'); if (l.length !== 1) return false; const s = getComputedStyle(l[0]); return s.position === 'fixed' && s.pointerEvents === 'none' && s.zIndex === '0' && getComputedStyle(document.querySelector('.c3grid')).zIndex === '1'; }));
74
+ ok('R2 the canvas gets a frame drawn (light theme)', await until(async () => (await maxAlpha(p)) > 0, 6000));
75
+ ok('R2 mounted once even after tab changes', await p.evaluate(async () => { document.querySelector('[data-c3-tab="work"]').click(); await new Promise((r) => setTimeout(r, 200)); document.querySelector('[data-c3-tab="brief"]').click(); return document.querySelectorAll('canvas.c3amb').length === 1; }));
76
+
77
+ /* R3 */
78
+ ok('R3 a primary button label takes the page background colour', await p.evaluate(() => {
79
+ const r = document.getElementById('c3-root'); const b = document.createElement('button'); b.className = 'pmc-btn pmc-btn--primary'; const probe = document.createElement('div'); probe.style.color = 'var(--background)';
80
+ r.append(b, probe); const same = getComputedStyle(b).color === getComputedStyle(probe).color; b.remove(); probe.remove(); return same;
81
+ }));
82
+ ok('R3 .sect > .sh2 is a flex row outside .brief', await p.evaluate(() => {
83
+ const r = document.getElementById('c3-root'); const s = document.createElement('section'); s.className = 'sect'; s.innerHTML = '<div class="sh2"><h2>Title</h2></div>'; r.append(s);
84
+ const d = getComputedStyle(s.firstChild).display; const tt = getComputedStyle(s.firstChild.firstChild).textTransform; s.remove(); return d === 'flex' && tt === 'uppercase';
85
+ }));
86
+
87
+ /* R4 */
88
+ ok('R4 the front shows number tiles and a kind label', await p.evaluate(() => { const c = document.querySelector('#c3-root .deck .fc'); return c.querySelectorAll('.ff .cfh .kp').length >= 1 && !!c.querySelector('.ff .tag.pov') && !c.querySelector('.ff .cfk'); }));
89
+ ok('R4 the kind label sits beside the tag', await p.evaluate(() => { const c = document.querySelector('#c3-root .deck .fc .ff'); const t = c.querySelector('.tg'); const k = c.querySelector('.tag.pov'); return !!t && !!k && t.parentNode === k.parentNode; }));
90
+ const key = await p.evaluate(() => document.querySelector('#c3-root .deck .fc').getAttribute('data-bf-card'));
91
+ const card = `#c3-root .deck .fc[data-bf-card="${key}"]`;
92
+ const h0 = await p.evaluate((s) => document.querySelector(s).offsetHeight, card);
93
+ await p.evaluate((s) => document.querySelector(s).click(), card);
94
+ ok('R4 the card flips', await until(() => p.evaluate((s) => document.querySelector(s).classList.contains('back'), card), 4000));
95
+ await sleep(500);
96
+ const fit = await p.evaluate((s) => {
97
+ const c = document.querySelector(s); const cb = c.getBoundingClientRect(); const fb = c.querySelector('.fb');
98
+ const inside = (e) => !e || e.getBoundingClientRect().bottom <= cb.bottom + 1;
99
+ return { grew: c.offsetHeight, noClip: fb.scrollHeight <= fb.clientHeight + 1, opts: inside(c.querySelector('.opts')), conf: inside(c.querySelector('.c3slide, .c3hold')), foot: inside(c.querySelector('.cft')), hasOpts: !!c.querySelector('.opt') };
100
+ }, card);
101
+ ok('R4 the flipped back grows to fit its content', fit.grew >= h0 && fit.noClip && fit.opts && fit.conf && fit.foot && fit.hasOpts, JSON.stringify({ h0, fit }));
102
+ /* decide: a hold button (fine pointer) or a click; the slide where the pointer is coarse */
103
+ const act = await p.evaluate((s) => { const c = document.querySelector(s); const hb = c.querySelector('.c3hold .hb'); if (hb) { const r = hb.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, mode: hb.getAttribute('data-hold') }; } return null; }, card);
104
+ if (act) {
105
+ await p.mouse.move(act.x, act.y); await p.mouse.down();
106
+ if (act.mode === 'hold') await sleep(750);
107
+ await p.mouse.up();
108
+ }
109
+ ok('R4 deciding adds .ok to the card', !!act && await until(() => p.evaluate((s) => document.querySelector(s).classList.contains('ok'), card), 5000), JSON.stringify(act));
110
+ const undo = await until(() => p.evaluate(() => !!document.querySelector('.toast button, [data-c3-toast-action]')), 4000);
111
+ ok('R4 the decision shows a toast with Undo', undo);
112
+ if (undo) await p.evaluate(() => (document.querySelector('[data-c3-toast-action]') || document.querySelector('.toast button')).click());
113
+ ok('R4 Undo takes .ok off', undo && await until(() => p.evaluate((s) => !document.querySelector(s).classList.contains('ok'), card), 4000));
114
+ await p.keyboard.press('Escape');
115
+
116
+ /* R5 */
117
+ ok('R5 Briefing, Work and Initiatives carry count badges', await until(() => p.evaluate(() => ['brief-count', 'work-count', 'init-count'].every((k) => { const b = document.querySelector(`[data-c3pg="${k}"]`); return !!b && !b.hidden && /^\d+$/.test(b.textContent) && Number(b.textContent) > 0; })), 8000));
118
+ ok('R5 the badge titles say what they count', await p.evaluate(() => document.querySelector('[data-c3pg="brief-count"]').title === 'Needs you' && document.querySelector('[data-c3pg="init-count"]').title === 'New asks to triage'));
119
+ ok('R5 the Briefing badge matches the Needs you count', await until(() => p.evaluate(() => { const n = document.querySelector('[data-c3pg="needs"] .sh2 .bdg'); return !!n && n.textContent === document.querySelector('[data-c3pg="brief-count"]').textContent; }), 5000));
120
+
121
+ /* R6 */
122
+ ok('R6 the Sessions pill draws the bolt, with the Agent surfaces tooltip', await p.evaluate(() => { const b = document.querySelector('[data-c3pg="agent-button"]'); const u = b.querySelector('use'); return !!u && /#c3i-bolt$/.test(u.getAttribute('href')) && /^Agent surfaces: /.test(b.title); }));
123
+ ok('R6 the dock button draws the bolt, with the mockup tooltip', await p.evaluate(() => { const b = document.querySelector('[data-c3pg="dock-agent"]'); const u = b && b.querySelector('use'); return !!u && /#c3i-bolt$/.test(u.getAttribute('href')) && b.title === 'Agent surfaces: the full page of sessions that need you'; }));
124
+
125
+ /* R7 */
126
+ ok('R7 the idle Record control shows in demo mode', await p.evaluate(() => { const g = document.querySelector('.c3rec .recg.idle'); if (!g) return false; const r = g.getBoundingClientRect(); return r.width > 0 && !!g.querySelector('[data-fr-rec-new]') && !!g.querySelector('[data-fr-rec-upcoming]'); }));
127
+ await p.evaluate(() => document.querySelector('[data-fr-rec-new]').click());
128
+ ok('R7 Record starts the simulated recorder', await until(() => p.evaluate(() => !!document.querySelector('[data-shell-rec-stop]') && !!document.querySelector('[data-shell-rec-clock]')), 4000));
129
+ await p.evaluate(() => document.querySelector('[data-shell-rec-stop]').click());
130
+ ok('R7 Stop returns to the idle control', await until(() => p.evaluate(() => !!document.querySelector('.c3rec .recg.idle [data-fr-rec-new]') && !document.querySelector('[data-shell-rec-stop]')), 4000));
131
+ ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
132
+ await A.ctx.close();
133
+
134
+ /* R2: reduced motion draws one still frame, dark theme draws, no canvas means no error */
135
+ const R = await open({ reducedMotion: 'reduce' });
136
+ ok('R2 reduced motion: one still frame is drawn', R.up && await until(async () => (await maxAlpha(R.page)) > 0, 6000));
137
+ /* a still frame redraws only when the layout moves (content arriving); once the page is quiet it holds the same picture */
138
+ const snap = () => R.page.evaluate(() => document.querySelector('canvas.c3amb').toDataURL());
139
+ let prev = await snap();
140
+ ok('R2 reduced motion: the frame does not change once the page is quiet', await until(async () => { await sleep(700); const now = await snap(); const same = now === prev; prev = now; return same; }, 12000, 0));
141
+ ok('0 no page error (reduced motion)', R.errors.length === 0, R.errors.join(' | '));
142
+ await R.ctx.close();
143
+
144
+ const D = await open({}, '/c3.html?demo=1&theme=dark');
145
+ ok('R2 dark theme: the canvas draws', D.up && await until(async () => (await maxAlpha(D.page)) > 0, 6000));
146
+ ok('0 no page error (dark)', D.errors.length === 0, D.errors.join(' | '));
147
+ await D.ctx.close();
148
+
149
+ const N = await open({}, '/c3.html?demo=1', () => { HTMLCanvasElement.prototype.getContext = function () { return null; }; });
150
+ ok('R2 where canvas is unavailable the page still draws and raises no error', N.up && N.errors.length === 0 && await N.page.evaluate(() => document.querySelectorAll('canvas.c3amb').length === 0), N.errors.join(' | '));
151
+ await N.ctx.close();
152
+
153
+ /* phone width: no horizontal scroll */
154
+ const M = await open({ viewport: { width: 400, height: 900 } });
155
+ ok('0 at 400 wide the page does not scroll sideways', M.up && await M.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'scrollWidth');
156
+ ok('0 no page error (400 wide)', M.errors.length === 0, M.errors.join(' | '));
157
+ await M.ctx.close();
158
+ return finish();
159
+ })().catch((e) => { console.error(e); finish(1); });