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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/design-parity.js +8 -0
- package/workspace/guide/layout.md +9 -1
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +8 -6
- package/workspace/public/pm/panel-cockpit.js +14 -0
- package/workspace/public/pm/panel-detail.js +13 -2
- package/workspace/public/pm/panel-tabs.js +4 -2
- package/workspace/public/pm/tokens.css +21 -3
- package/workspace/run-tests.js +148 -0
- package/workspace/server.js +5 -2
- package/workspace/test-files.json +180 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "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
|
|
13
|
-
"test:modules": "node
|
|
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": "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
-
--purple:oklch(0.5 0.2 300);--destructive:oklch(0.
|
|
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; });
|
package/workspace/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|