create-pmos 0.49.0 → 0.51.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.49.0",
3
+ "version": "0.51.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@676b0dc
1
+ kernel-source: pmos@4689fc3
2
2
  assembled: 2026-10-05
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-05T00:43:02Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T04:02:12Z"}
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-05T00:43:02Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T04:02:12Z"}
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-05T00:43:02Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T04:02:12Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -201,6 +201,14 @@ const EXCLUSIONS = {
201
201
  { what: 'the height of the artifacts section', only: ['box'], design: { own: 'Artifacts', up: 1 }, app: { sel: '[data-cockpit="artifacts"]' } },
202
202
  ],
203
203
  },
204
+ {
205
+ name: 'column fold controls',
206
+ why: 'The PM could not fold the list and the detail away (2026-10-05, "i cannot minimize column 2 and 3"). Each column has a '
207
+ + '"⟨" control that folds it to the 36px strip, which opens it again. The board draws no fold control. Both float over the '
208
+ + 'column (the list\'s over the right end of "New initiative", the detail\'s at its top right), so nothing in the column moves.',
209
+ design: null,
210
+ app: { sel: '[data-fold]' },
211
+ },
204
212
  {
205
213
  name: 'tab: second line',
206
214
  why: 'A tab names its initiative and session under its title ("<initiative> · session <id>"): after a reload the PM could not '
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: layout"
4
4
  description: "The three workspace columns: navigation, detail and tabs, and what each one holds."
5
- timestamp: 2026-10-04
5
+ timestamp: 2026-10-05
6
6
  ---
7
7
 
8
8
  # Guide: layout
@@ -92,12 +92,20 @@ What a row in a list opens:
92
92
 
93
93
  ## Folding
94
94
 
95
+ On a desktop screen, the list and the detail each have a `⟨` button. The list's button is at the right of `+ New initiative`. The detail's button is at its top right. Press it to fold that column to a thin strip. Press the strip to open the column again. The rail does not fold.
96
+
97
+ In the older layout:
98
+
95
99
  - Choose the arrow in the header of the list to fold it to one line under the repositories. Choose the line to open it again.
96
100
  - Fold the navigation column to a thin strip. The list folds with it. Choose the strip to open it again.
97
101
  - Fold the detail column to its strip in the same way.
98
102
 
99
103
  The workspace remembers which columns are open and how wide the navigation column is.
100
104
 
105
+ ## Light theme
106
+
107
+ In the light theme, the tabs column stays dark, because a terminal is dark in both themes. The tab strip and the bar under the tabs use light text on that dark band.
108
+
101
109
  ## On a phone
102
110
 
103
111
  A phone shows one panel at a time. The bottom bar moves between them.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -9,15 +9,17 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "test": "node test.js && npm run test:modules",
13
- "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node status-facts-panel.test.js && node jev-claims.test.js && node jev-categorize.test.js && node jev-status.test.js && node jev-route.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js && node doc-category.test.js && node doc-category-corrections.test.js && node proactive-prepare.test.js && node learn.test.js && node session-flag.test.js && node live-notes.test.js",
12
+ "test": "node run-tests.js unit",
13
+ "test:modules": "node run-tests.js modules",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --board=WsA",
16
16
  "test:parity:four-panel": "node design-parity.js --states",
17
- "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
+ "test:all": "node run-tests.js all && npm run test:parity && npm run test:offline",
18
18
  "doctor": "node doctor.js",
19
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js && node pm-r25-test.js && node pm-r26-test.js && node pm-doc-category-test.js && node pm-r27-test.js && node pm-r28-test.js && node pm-r29-today-test.js && node pm-r29-portfolio-test.js && node pm-r29-knowledge-test.js && node pm-r29-memory-test.js && node pm-r29-skills-scheduled-test.js && node pm-r29-validate-guide-test.js && node pm-r29-reports-addrepo-test.js && node pm-r29-filespad-test.js && node pm-r29-newsession-alignment-test.js && node pm-live-notes-test.js && node pm-r30-test.js",
20
- "test:offline": "node offline-tarball.test.js"
19
+ "test:panels": "node run-tests.js panels",
20
+ "test:offline": "node offline-tarball.test.js",
21
+ "test:browser": "node run-tests.js browser",
22
+ "test:list-check": "node run-tests.js --check"
21
23
  },
22
24
  "engines": {
23
25
  "node": ">=18"
@@ -446,15 +446,29 @@ export function renderWorkList(host) {
446
446
  // repository, so it has no files to list and does not offer the toggle (Files would only say it read nothing).
447
447
  const filesOn = !FIXTURE && !!state.filesOpen;
448
448
  if (filesOn) renderFiles(filesHost);
449
+ host.style.position = 'relative'; // anchors the fold control (foldList)
449
450
  setChildren(host, [
450
451
  el('div', { style: 'flex:none;display:flex;flex-direction:column;gap:12px' }, stateHealthBanner(), header, manageRow, switcher, FIXTURE ? null : filesToggle(), filesOn ? null : filterBox),
451
452
  // the rows take their own height and scroll only when they outgrow the column; "New initiative" sits at the bottom either way
452
453
  filesOn ? filesHost : el('div', { 'data-list': 'rows', style: 'flex:0 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:2px' }, groupsNodes),
453
454
  el('div', { style: 'flex:none;margin-top:auto;display:flex;flex-direction:column;gap:8px' }, el('div', { style: 'height:1px;background:var(--border)' }), addInit),
455
+ foldList(),
454
456
  ]);
455
457
  return host;
456
458
  }
457
459
 
460
+ /** The list's fold control. The column folds to the 36px strip app.js draws ("initiatives ⟩"), which opens it again. It floats over
461
+ * the right end of the "New initiative" row (the host's 12px padding, then that 40px row), so nothing in the column moves. The
462
+ * board has no fold control; design-parity names it as an exclusion. */
463
+ function foldList() {
464
+ return el('button', {
465
+ 'data-fold': 'list', 'aria-label': 'hide the list', title: 'hide the list',
466
+ onClick: () => setState({ col1: false }),
467
+ style: 'position:absolute;right:12px;bottom:17px;height:30px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--muted-foreground)',
468
+ hover: 'color:var(--foreground);background:var(--accent)',
469
+ }, '⟨');
470
+ }
471
+
458
472
  /** The tab to show when the PM goes back to Work from a view: the last one that is not a view, else none. */
459
473
  function lastNonView() {
460
474
  const t = [...(state.tabs || [])].reverse().find((x) => x.kind !== 'view');
@@ -1170,7 +1170,8 @@ const CK_SEP = () => el('div', { style: 'height:1px;background:var(--border)' })
1170
1170
  /** Title, summary and chips: the lane, the anchor and the flow, as pills. */
1171
1171
  function cockpitTitle(name, tag, pills) {
1172
1172
  return [
1173
- el('div', { style: 'font:600 17px var(--sans);overflow-wrap:anywhere' }, iv(name)),
1173
+ // the right padding keeps a long name clear of the column's fold control (renderDetail)
1174
+ el('div', { style: 'font:600 17px var(--sans);overflow-wrap:anywhere;padding-right:30px' }, iv(name)),
1174
1175
  el('div', { style: 'color:var(--muted-foreground)' }, iv(tag)),
1175
1176
  el('div', { style: 'display:flex;gap:6px;flex-wrap:wrap' }, pills.map((pl) => el('span', { style: CK_BADGE }, iv(pl.t)))),
1176
1177
  ];
@@ -1643,7 +1644,17 @@ export function renderDetail(host) {
1643
1644
  el('button', { 'data-focus': 'navigate', onClick: () => setState({ layoutFocus: false }), style: 'margin-left:auto;font:500 11px var(--sans);border:1px solid var(--border);border-radius:6px;padding:2px 8px', hover: 'background:var(--accent)' }, 'Navigate'));
1644
1645
  return setChildren(host, [bar, ...body]);
1645
1646
  }
1646
- return setChildren(host, bare ? body : [stickyHeader, ...body]);
1647
+ /* The cockpit's initiative has no title bar, so its fold control floats at the column's top right. The column folds to the 36px
1648
+ * strip app.js draws ("initiative ⟩"), which opens it again. Nothing in the column moves (design-parity names the control). */
1649
+ host.style.position = bare ? 'relative' : '';
1650
+ return setChildren(host, bare ? [...body, foldDetail] : [stickyHeader, ...body]);
1647
1651
  }
1648
1652
 
1653
+ const foldDetail = el('button', {
1654
+ 'data-fold': 'detail', 'aria-label': 'hide the detail', title: 'hide the detail',
1655
+ onClick: () => setState({ col2: false }),
1656
+ style: 'position:absolute;top:10px;right:10px;height:28px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--muted-foreground)',
1657
+ hover: 'color:var(--foreground);background:var(--accent)',
1658
+ }, '⟨');
1659
+
1649
1660
  export default renderDetail;
@@ -335,7 +335,9 @@ const CHIP_STYLE = 'flex:none;align-self:center;font-family:var(--mono);font-siz
335
335
  const SPLIT_STYLE = 'flex:none;margin-left:auto;align-self:center;font-family:var(--mono);font-size:11px;color:oklch(0.85 0.01 214);padding:5px 12px;min-height:30px;background:oklch(1 0 0 / 6%);border:1px solid oklch(1 0 0 / 12%);white-space:nowrap';
336
336
  const CHIP_HOVER = 'border-color:oklch(0.56 0.021 213.5)';
337
337
 
338
- const tabBar = el('div', { 'data-tabs': 'bar', style: BAR_STYLE, onWheel: stripWheel });
338
+ /* `data-term-chrome`: the strip and the bottom bar sit on --term-bg, which is dark in BOTH themes (D41), so they take the dark
339
+ * theme's tokens in light mode too (tokens.css). Without it the light theme's dark text was drawn on the dark band, unreadable. */
340
+ const tabBar = el('div', { 'data-tabs': 'bar', 'data-term-chrome': '', style: BAR_STYLE, onWheel: stripWheel });
339
341
 
340
342
  /* ── the cockpit's tab strip (workspace-cockpit-nav Run 4, planning/design/cockpit-mockup/WsA.dc.html) ─────────────────────────
341
343
  * On a desktop the strip is the mockup's: a 44px band whose tabs sit on its floor, the tab in front a card with rounded top corners,
@@ -676,7 +678,7 @@ const colTitleLabel = iv('scratchpad');
676
678
  const copyLabel = iv('copy');
677
679
 
678
680
  const bottomBar = el('div', {
679
- 'data-tabs': 'bottom',
681
+ 'data-tabs': 'bottom', 'data-term-chrome': '',
680
682
  style: 'display:flex;gap:10px;align-items:center;padding:5px 12px;border-top:1px solid oklch(1 0 0 / 10%);font-size:11px;color:oklch(0.6 0.02 213)',
681
683
  },
682
684
  el('button', {
@@ -4,6 +4,11 @@
4
4
  * VERBATIM from the design prototype. Do not "improve" a value here: the acceptance bar for this
5
5
  * surface is a mechanical diff against that prototype, so an edit to a token is a defect.
6
6
  *
7
+ * One deliberate exception, asked for by the PM (2026-10-05, "some font not visible on light mode"): five LIGHT-theme text tokens
8
+ * are darker than the prototype's — --muted-foreground (0.56 → 0.48), --success, --warning, --info and --destructive — so that
9
+ * secondary text, status badges and warnings reach about 4.5:1 on the light surfaces. The dark theme, which the parity board
10
+ * compares, is untouched.
11
+ *
7
12
  * The faces are VENDORED (D62: the workspace is a local companion app that must render correctly
8
13
  * with no network at all). The prototype linked Google Fonts; a CDN font is network egress and is
9
14
  * forbidden here, so the same three IBM Plex Mono weights and the Space Grotesk variable face are
@@ -57,14 +62,27 @@
57
62
  [data-pm-theme="light"]{
58
63
  --background:oklch(1 0 0);--foreground:oklch(0.148 0.004 228.8);
59
64
  --card:oklch(1 0 0);--secondary:oklch(0.967 0.001 286.375);
60
- --muted:oklch(0.963 0.002 197.1);--muted-foreground:oklch(0.56 0.021 213.5);
65
+ --muted:oklch(0.963 0.002 197.1);--muted-foreground:oklch(0.48 0.021 213.5);
61
66
  --accent:oklch(0.963 0.002 197.1);--border:oklch(0.925 0.005 214.3);--input:oklch(0.925 0.005 214.3);
62
67
  --ring:oklch(0.723 0.014 214.4);--sidebar:oklch(0.987 0.002 197.1);
63
68
  --brand:oklch(0.488 0.243 264.376);--brand-wash:color-mix(in oklab, oklch(0.488 0.243 264.376) 7%, transparent);
64
- --success:oklch(0.52 0.14 152);--warning:oklch(0.55 0.12 72);--info:oklch(0.52 0.09 195);
65
- --purple:oklch(0.5 0.2 300);--destructive:oklch(0.577 0.245 27.325);
69
+ --success:oklch(0.46 0.13 152);--warning:oklch(0.48 0.11 72);--info:oklch(0.47 0.09 195);
70
+ --purple:oklch(0.5 0.2 300);--destructive:oklch(0.5 0.21 27.325);
66
71
  --term-bg:oklch(0.148 0.004 228.8);
67
72
  }
73
+ /* The tab strip and the bottom bar of the tabs panel sit on --term-bg, dark in BOTH themes (D41). In the light theme they take the
74
+ dark theme's tokens, so their text and controls stay readable. Copied from the :root block above; keep the two in step. */
75
+ [data-pm-theme="light"] [data-term-chrome]{
76
+ --background:oklch(0.148 0.004 228.8);--foreground:oklch(0.987 0.002 197.1);
77
+ --card:oklch(0.218 0.008 223.9);--secondary:oklch(0.274 0.006 286.033);
78
+ --muted:oklch(0.275 0.011 216.9);--muted-foreground:oklch(0.723 0.014 214.4);
79
+ --accent:oklch(0.275 0.011 216.9);--border:oklch(1 0 0 / 10%);--input:oklch(1 0 0 / 15%);
80
+ --ring:oklch(0.56 0.021 213.5);--sidebar:oklch(0.218 0.008 223.9);
81
+ --brand:oklch(0.623 0.214 259.815);--brand-wash:color-mix(in oklab, oklch(0.623 0.214 259.815) 12%, transparent);
82
+ --success:oklch(0.76 0.17 152);--warning:oklch(0.82 0.15 82);--info:oklch(0.79 0.11 195);
83
+ --purple:oklch(0.72 0.15 300);--destructive:oklch(0.704 0.191 22.216);
84
+ color:var(--foreground);
85
+ }
68
86
  @keyframes pmblink{0%,49%{opacity:1}50%,100%{opacity:0}}
69
87
  *{box-sizing:border-box}
70
88
  body{margin:0;background:oklch(0.148 0.004 228.8);-webkit-font-smoothing:antialiased}
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* run-tests.js — run the workspace suites side by side.
4
+ *
5
+ * node run-tests.js <group> [--jobs=N] [--shard=I/N] [--list] [--verbose]
6
+ * node run-tests.js --check
7
+ *
8
+ * Groups come from test-files.json: unit (test.js + modules), modules, panels, ui, browser
9
+ * (panels + ui), all. The npm scripts call this file, so `npm test` and CI run the same lists.
10
+ *
11
+ * Running in parallel is safe because every suite claims its own ephemeral port, scratch state
12
+ * directory and private tmux socket (test-run.js, or the same three lines written out in the
13
+ * suite). It was checked rather than assumed: on 2026-10-05 all 48 browser suites ran four at a
14
+ * time with no failure that the serial run did not also show.
15
+ *
16
+ * Nothing is skipped. --shard splits a group across CI jobs; the shards partition the list (every
17
+ * file in exactly one shard), and --check fails when a suite on disk is in no list, so a new suite
18
+ * that nobody wired in is a red build rather than a suite that runs nowhere.
19
+ *
20
+ * Output: a suite's own output is buffered and printed whole when it finishes, so lines from two
21
+ * suites never interleave. A passing suite prints its last lines; --verbose prints all of it.
22
+ */
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const { spawn } = require('child_process');
27
+
28
+ const DIR = __dirname;
29
+ const FILES = JSON.parse(fs.readFileSync(path.join(DIR, 'test-files.json'), 'utf8'));
30
+ const GROUPS = {
31
+ unit: [...FILES.unit, ...FILES.modules],
32
+ modules: FILES.modules,
33
+ panels: FILES.panels,
34
+ ui: FILES.ui,
35
+ browser: [...FILES.panels, ...FILES.ui],
36
+ all: [...FILES.unit, ...FILES.modules, ...FILES.panels, ...FILES.ui],
37
+ };
38
+ // Suites that run elsewhere on purpose, so --check does not count them as unwired.
39
+ const ELSEWHERE = {
40
+ 'offline-tarball.test.js': 'npm run test:offline (needs a packed tarball)',
41
+ };
42
+ const PER_FILE_TIMEOUT_MS = 10 * 60 * 1000;
43
+
44
+ const args = process.argv.slice(2);
45
+ const flag = (name) => { const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`)); return a === undefined ? undefined : (a.includes('=') ? a.slice(a.indexOf('=') + 1) : true); };
46
+ const group = args.find((a) => !a.startsWith('--'));
47
+
48
+ const secs = (f) => Number((FILES.seconds || {})[f]) || 10;
49
+ // Slowest first, so a long suite never starts last and holds the run open on its own.
50
+ const bySlowest = (list) => list.map((f, k) => ({ f, k })).sort((a, b) => secs(b.f) - secs(a.f) || a.k - b.k).map((x) => x.f);
51
+
52
+ // Deal the suites, slowest first, each to the shard with the least time so far (ties to the lower
53
+ // shard). Deterministic, so every CI job computes the same split, and a partition by construction:
54
+ // each suite goes to exactly one shard. --check proves the partition for every shard count it tries.
55
+ function shardOf(list, spec) {
56
+ const m = /^(\d+)\/(\d+)$/.exec(String(spec));
57
+ if (!m) throw new Error(`--shard must look like 1/4, got "${spec}"`);
58
+ const i = Number(m[1]); const n = Number(m[2]);
59
+ if (n < 1 || i < 1 || i > n) throw new Error(`--shard ${spec}: the index must be between 1 and ${n}`);
60
+ const load = new Array(n).fill(0); const out = Array.from({ length: n }, () => []);
61
+ for (const f of bySlowest(list)) {
62
+ let best = 0; for (let k = 1; k < n; k++) if (load[k] < load[best]) best = k;
63
+ out[best].push(f); load[best] += secs(f);
64
+ }
65
+ return out[i - 1];
66
+ }
67
+
68
+ function check() {
69
+ const listed = new Set(GROUPS.all);
70
+ const problems = [];
71
+ const dupes = GROUPS.all.filter((f, k) => GROUPS.all.indexOf(f) !== k);
72
+ for (const f of new Set(dupes)) problems.push(`${f} is listed more than once in test-files.json`);
73
+ for (const f of listed) if (!fs.existsSync(path.join(DIR, f))) problems.push(`${f} is listed in test-files.json but does not exist`);
74
+ for (const f of FILES.exclusive || []) if (!listed.has(f)) problems.push(`${f} is marked exclusive but is in no test list`);
75
+ const onDisk = fs.readdirSync(DIR).filter((f) => f === 'test.js' || /(\.test\.(js|mjs)|-test\.js)$/.test(f));
76
+ for (const f of onDisk) if (!listed.has(f) && !ELSEWHERE[f]) problems.push(`${f} is a suite on disk that no test list runs. Add it to test-files.json.`);
77
+ for (const n of [2, 3, 4, 6, 8]) for (const g of ['unit', 'browser']) {
78
+ const parts = Array.from({ length: n }, (_, k) => shardOf(GROUPS[g], `${k + 1}/${n}`)).flat();
79
+ if (parts.length !== GROUPS[g].length || new Set(parts).size !== GROUPS[g].length) problems.push(`--shard does not partition "${g}" into ${n}`);
80
+ }
81
+ if (problems.length) { for (const p of problems) console.error(`✗ ${p}`); process.exitCode = 1; return; }
82
+ console.log(`✓ ${listed.size} suites listed, each once, each on disk; ${onDisk.length - Object.keys(ELSEWHERE).length} suites on disk, all listed; shards partition every group.`);
83
+ }
84
+
85
+ function runOne(file, verbose) {
86
+ return new Promise((resolve) => {
87
+ const t0 = Date.now();
88
+ const chunks = [];
89
+ const child = spawn(process.execPath, [file], { cwd: DIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
90
+ child.stdout.on('data', (d) => chunks.push(d));
91
+ child.stderr.on('data', (d) => chunks.push(d));
92
+ let timedOut = false;
93
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, PER_FILE_TIMEOUT_MS);
94
+ child.on('close', (code, signal) => {
95
+ clearTimeout(timer);
96
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
97
+ const out = Buffer.concat(chunks).toString('utf8').replace(/\s+$/, '');
98
+ const ok = code === 0 && !timedOut;
99
+ const why = timedOut ? `timed out after ${PER_FILE_TIMEOUT_MS / 60000} min` : (signal ? `killed by ${signal}` : `exit ${code}`);
100
+ const lines = out.split('\n');
101
+ const body = ok && !verbose ? lines.slice(-3).join('\n') : out;
102
+ process.stdout.write(`\n── ${ok ? 'PASS' : 'FAIL'} ${file} (${secs}s${ok ? '' : ', ' + why}) ──\n${body}\n`);
103
+ resolve({ file, ok, secs: Number(secs), why });
104
+ });
105
+ });
106
+ }
107
+
108
+ async function main() {
109
+ if (flag('check')) return check();
110
+ if (!group || !GROUPS[group]) {
111
+ console.error(`usage: node run-tests.js <${Object.keys(GROUPS).join('|')}> [--jobs=N] [--shard=I/N] [--list] [--verbose]\n node run-tests.js --check`);
112
+ process.exitCode = 2; return;
113
+ }
114
+ let list = GROUPS[group];
115
+ const shard = flag('shard');
116
+ list = shard ? shardOf(list, shard) : bySlowest(list);
117
+ if (flag('list')) { console.log(list.join('\n')); return; }
118
+ const cpus = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
119
+ // A browser suite runs a server, a tmux daemon and a Chromium of its own, and several of them assert
120
+ // on timing. At one per CPU they starved each other: 2 of 48 failed per run on a 4-CPU machine, a
121
+ // different 2 each time, all green alone. Half the CPUs for any group with browser suites in it
122
+ // kept every run green. --jobs or PMOS_TEST_JOBS still overrides.
123
+ const hasBrowser = list.some((f) => FILES.panels.includes(f) || FILES.ui.includes(f));
124
+ const auto = hasBrowser ? Math.max(1, Math.floor(cpus / 2)) : cpus;
125
+ const jobs = Math.max(1, Number(flag('jobs')) || Number(process.env.PMOS_TEST_JOBS) || auto);
126
+ const verbose = !!flag('verbose');
127
+ console.log(`run-tests: ${group}${shard ? ` shard ${shard}` : ''} — ${list.length} suites, ${jobs} at a time`);
128
+
129
+ const t0 = Date.now();
130
+ const results = [];
131
+ // An exclusive suite writes the checkout's own state, so it runs alone: first the shared suites side
132
+ // by side, then each exclusive one with nothing beside it.
133
+ const exclusive = new Set(FILES.exclusive || []);
134
+ const shared = list.filter((f) => !exclusive.has(f));
135
+ let next = 0;
136
+ const worker = async () => { while (next < shared.length) { const f = shared[next++]; results.push(await runOne(f, verbose)); } };
137
+ await Promise.all(Array.from({ length: Math.min(jobs, shared.length) }, worker));
138
+ for (const f of list.filter((x) => exclusive.has(x))) results.push(await runOne(f, verbose));
139
+
140
+ const failed = results.filter((r) => !r.ok);
141
+ const slowest = [...results].sort((a, b) => b.secs - a.secs).slice(0, 5).map((r) => `${r.file} ${r.secs}s`).join(', ');
142
+ console.log(`\nrun-tests: ${results.length - failed.length}/${results.length} suites passed in ${((Date.now() - t0) / 1000).toFixed(1)}s wall. Slowest: ${slowest}.`);
143
+ // exitCode, never process.exit(): stdout to a pipe is asynchronous, and exiting here cut the CI log
144
+ // off mid-suite, before the summary that names what failed.
145
+ if (failed.length) { console.log(`FAILED: ${failed.map((r) => `${r.file} (${r.why})`).join(', ')}`); process.exitCode = 1; }
146
+ }
147
+
148
+ main().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -2296,8 +2296,11 @@ function checkDueSchedules() {
2296
2296
  } catch {}
2297
2297
  return fired;
2298
2298
  }
2299
- // the tick — the persistent server checks on an interval; unref'd so it never blocks exit
2300
- try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } }, 60000); if (_tick.unref) _tick.unref(); } catch {}
2299
+ // the tick — the persistent server checks on an interval; unref'd so it never blocks exit.
2300
+ // PMOS_TICK_MS exists for the suites (same footing as PMOS_LIVENESS_MS): proactive-prepare.test.js
2301
+ // used to wait on two real minutes of this clock. Unset, the interval is the 60 seconds it always was.
2302
+ const TICK_MS = Math.max(200, Number(process.env.PMOS_TICK_MS) || 60000);
2303
+ try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2301
2304
 
2302
2305
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2303
2306
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -0,0 +1,180 @@
1
+ {
2
+ "_comment": "The test files each npm test script runs. run-tests.js reads this file; add a new suite here and every runner, local and CI, picks it up. Every suite claims its own port, state directory and tmux socket (test-run.js or its own equivalent), which is what makes running them side by side safe.",
3
+ "_comment_exclusive": "Suites that write the checkout's own state (workspace/state/, the home workspace's ledgers) while they run. run-tests.js runs each of them with nothing else beside it, because suites that read the checkout as their home workspace (proactive.test.js's scan, for one) would see those writes mid-run.",
4
+ "exclusive": [
5
+ "test.js"
6
+ ],
7
+ "unit": [
8
+ "test.js"
9
+ ],
10
+ "modules": [
11
+ "proactive.test.js",
12
+ "focus.test.js",
13
+ "handoff.test.js",
14
+ "repo-scan.test.js",
15
+ "profiles.test.js",
16
+ "external-linking.test.js",
17
+ "story-artifacts.test.js",
18
+ "no-network.test.js",
19
+ "typesafe-validation.test.js",
20
+ "claims.test.js",
21
+ "status-facts-panel.test.js",
22
+ "jev-claims.test.js",
23
+ "jev-categorize.test.js",
24
+ "jev-status.test.js",
25
+ "jev-route.test.js",
26
+ "repo-watch.test.js",
27
+ "fixture-parity.test.mjs",
28
+ "markdown.test.mjs",
29
+ "start-policy.test.js",
30
+ "state-isolation.test.js",
31
+ "sync.test.js",
32
+ "schedule-cli.test.js",
33
+ "initiative-surface.test.js",
34
+ "guide.test.js",
35
+ "next-step-actions.test.js",
36
+ "kg.test.js",
37
+ "doc-category.test.js",
38
+ "doc-category-corrections.test.js",
39
+ "proactive-prepare.test.js",
40
+ "learn.test.js",
41
+ "session-flag.test.js",
42
+ "live-notes.test.js"
43
+ ],
44
+ "panels": [
45
+ "pm-shell-test.js",
46
+ "pm-repos-test.js",
47
+ "pm-initiatives-test.js",
48
+ "pm-detail-test.js",
49
+ "pm-tabs-test.js",
50
+ "pm-term-test.js",
51
+ "pm-modes-test.js",
52
+ "pm-r1-test.js",
53
+ "pm-r2-test.js",
54
+ "pm-r3-test.js",
55
+ "pm-r4-test.js",
56
+ "pm-r5-test.js",
57
+ "pm-r6-test.js",
58
+ "pm-r7-test.js",
59
+ "pm-r8-test.js",
60
+ "pm-r9-test.js",
61
+ "pm-r10-test.js",
62
+ "pm-r11-test.js",
63
+ "pm-r12-test.js",
64
+ "pm-r13-test.js",
65
+ "pm-r14-test.js",
66
+ "pm-r15-test.js",
67
+ "pm-r16-test.js",
68
+ "pm-r17-test.js",
69
+ "pm-r18-test.js",
70
+ "pm-r19-test.js",
71
+ "pm-r20-test.js",
72
+ "pm-r21-test.js",
73
+ "pm-r22-test.js",
74
+ "pm-r23-test.js",
75
+ "pm-r24-test.js",
76
+ "pm-r25-test.js",
77
+ "pm-r26-test.js",
78
+ "pm-doc-category-test.js",
79
+ "pm-r27-test.js",
80
+ "pm-r28-test.js",
81
+ "pm-r29-today-test.js",
82
+ "pm-r29-portfolio-test.js",
83
+ "pm-r29-knowledge-test.js",
84
+ "pm-r29-memory-test.js",
85
+ "pm-r29-skills-scheduled-test.js",
86
+ "pm-r29-validate-guide-test.js",
87
+ "pm-r29-reports-addrepo-test.js",
88
+ "pm-r29-filespad-test.js",
89
+ "pm-r29-newsession-alignment-test.js",
90
+ "pm-live-notes-test.js",
91
+ "pm-r30-test.js"
92
+ ],
93
+ "ui": [
94
+ "ui-test.js"
95
+ ],
96
+ "seconds": {
97
+ "test.js": 48,
98
+ "proactive.test.js": 4,
99
+ "focus.test.js": 1,
100
+ "handoff.test.js": 1,
101
+ "repo-scan.test.js": 1,
102
+ "profiles.test.js": 1,
103
+ "external-linking.test.js": 1,
104
+ "story-artifacts.test.js": 1,
105
+ "no-network.test.js": 1,
106
+ "typesafe-validation.test.js": 1,
107
+ "claims.test.js": 1,
108
+ "status-facts-panel.test.js": 1,
109
+ "jev-claims.test.js": 3,
110
+ "jev-categorize.test.js": 1,
111
+ "jev-status.test.js": 1,
112
+ "jev-route.test.js": 1,
113
+ "repo-watch.test.js": 1,
114
+ "fixture-parity.test.mjs": 1,
115
+ "markdown.test.mjs": 1,
116
+ "start-policy.test.js": 1,
117
+ "state-isolation.test.js": 4,
118
+ "sync.test.js": 4,
119
+ "schedule-cli.test.js": 3,
120
+ "initiative-surface.test.js": 3,
121
+ "guide.test.js": 1,
122
+ "next-step-actions.test.js": 5,
123
+ "kg.test.js": 1,
124
+ "doc-category.test.js": 1,
125
+ "doc-category-corrections.test.js": 1,
126
+ "proactive-prepare.test.js": 9,
127
+ "learn.test.js": 3,
128
+ "session-flag.test.js": 1,
129
+ "live-notes.test.js": 3,
130
+ "pm-shell-test.js": 6,
131
+ "pm-repos-test.js": 3,
132
+ "pm-initiatives-test.js": 15,
133
+ "pm-detail-test.js": 16,
134
+ "pm-tabs-test.js": 59,
135
+ "pm-term-test.js": 28,
136
+ "pm-modes-test.js": 18,
137
+ "pm-r1-test.js": 6,
138
+ "pm-r2-test.js": 3,
139
+ "pm-r3-test.js": 3,
140
+ "pm-r4-test.js": 4,
141
+ "pm-r5-test.js": 3,
142
+ "pm-r6-test.js": 2,
143
+ "pm-r7-test.js": 1,
144
+ "pm-r8-test.js": 1,
145
+ "pm-r9-test.js": 2,
146
+ "pm-r10-test.js": 1,
147
+ "pm-r11-test.js": 1,
148
+ "pm-r12-test.js": 6,
149
+ "pm-r13-test.js": 2,
150
+ "pm-r14-test.js": 7,
151
+ "pm-r15-test.js": 35,
152
+ "pm-r16-test.js": 7,
153
+ "pm-r17-test.js": 3,
154
+ "pm-r18-test.js": 4,
155
+ "pm-r19-test.js": 18,
156
+ "pm-r20-test.js": 4,
157
+ "pm-r21-test.js": 4,
158
+ "pm-r22-test.js": 32,
159
+ "pm-r23-test.js": 13,
160
+ "pm-r24-test.js": 4,
161
+ "pm-r25-test.js": 9,
162
+ "pm-r26-test.js": 9,
163
+ "pm-doc-category-test.js": 4,
164
+ "pm-r27-test.js": 26,
165
+ "pm-r28-test.js": 9,
166
+ "pm-r29-today-test.js": 4,
167
+ "pm-r29-portfolio-test.js": 3,
168
+ "pm-r29-knowledge-test.js": 3,
169
+ "pm-r29-memory-test.js": 4,
170
+ "pm-r29-skills-scheduled-test.js": 6,
171
+ "pm-r29-validate-guide-test.js": 20,
172
+ "pm-r29-reports-addrepo-test.js": 4,
173
+ "pm-r29-filespad-test.js": 5,
174
+ "pm-r29-newsession-alignment-test.js": 8,
175
+ "pm-live-notes-test.js": 24,
176
+ "pm-r30-test.js": 4,
177
+ "ui-test.js": 30
178
+ },
179
+ "_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."
180
+ }