create-pmos 0.50.0 → 0.52.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.50.0",
3
+ "version": "0.52.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@121b0a2
1
+ kernel-source: pmos@a6425d1
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-05T02:52:09Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T21:40: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-05T02:52:09Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T21:40: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-05T02:52:09Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T21:40:45Z"}
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.50.0",
3
+ "version": "0.52.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.50.0",
3
+ "version": "0.52.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",
@@ -19,6 +19,7 @@
19
19
  "test:panels": "node run-tests.js panels",
20
20
  "test:offline": "node offline-tarball.test.js",
21
21
  "test:browser": "node run-tests.js browser",
22
+ "test:changed": "node run-tests.js all --changed",
22
23
  "test:list-check": "node run-tests.js --check"
23
24
  },
24
25
  "engines": {
@@ -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}
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
3
  /* run-tests.js — run the workspace suites side by side.
4
4
  *
5
- * node run-tests.js <group> [--jobs=N] [--shard=I/N] [--list] [--verbose]
5
+ * node run-tests.js <group> [--jobs=N] [--shard=I/N] [--changed[=REF]] [--list] [--verbose]
6
6
  * node run-tests.js --check
7
7
  *
8
8
  * Groups come from test-files.json: unit (test.js + modules), modules, panels, ui, browser
@@ -17,6 +17,11 @@
17
17
  * file in exactly one shard), and --check fails when a suite on disk is in no list, so a new suite
18
18
  * that nobody wired in is a red build rather than a suite that runs nowhere.
19
19
  *
20
+ * --changed is for the LOCAL inner loop only. It narrows the group to the suites a diff against REF
21
+ * (default origin/main) can reach, and runs the whole group whenever it cannot tell. CI never passes
22
+ * it: Workspace CI runs every suite on every PR, so a suite --changed leaves out still runs before
23
+ * the merge. See changedSuites() for the rule.
24
+ *
20
25
  * Output: a suite's own output is buffered and printed whole when it finishes, so lines from two
21
26
  * suites never interleave. A passing suite prints its last lines; --verbose prints all of it.
22
27
  */
@@ -65,6 +70,53 @@ function shardOf(list, spec) {
65
70
  return out[i - 1];
66
71
  }
67
72
 
73
+ // Files every page or every suite loads. A change to one can break any suite, so --changed runs
74
+ // the whole group. A changed file no suite can be traced to is treated the same way (see below).
75
+ const WIDE = new Set([
76
+ 'server.js', 'test-run.js', 'run-tests.js', 'test-files.json', 'package.json', 'package-lock.json',
77
+ 'public/pm/app.js', 'public/pm/state.js', 'public/pm/dom.js', 'public/pm/data.js',
78
+ 'public/pm/tokens.css', 'public/pm/fixture.js', 'public/pm/mobile.css', 'lib/find-browser.js',
79
+ ]);
80
+
81
+ /* The suites in `list` that the diff against `ref` can reach. A suite is selected when its own file
82
+ * changed, when its source names a changed file (`panel-tabs` in pm-tabs-test.js), or when its file
83
+ * name carries the changed file's stem with any panel-/view-/tab- prefix removed (view-memory.js ->
84
+ * pm-r29-memory-test.js). It errs toward running MORE: a WIDE file, or a changed workspace file that
85
+ * no suite can be traced to, selects the whole list. Returns { files, suites, why }. */
86
+ function changedSuites(list, ref) {
87
+ const git = (...a) => {
88
+ const r = require('child_process').spawnSync('git', a, { cwd: DIR, encoding: 'utf8' });
89
+ return r.status === 0 ? r.stdout : null;
90
+ };
91
+ const top = (git('rev-parse', '--show-toplevel') || '').trim();
92
+ if (!top) return { files: [], suites: list, why: 'not a git checkout, so every suite runs' };
93
+ const base = (git('merge-base', 'HEAD', ref) || '').trim();
94
+ if (!base) return { files: [], suites: list, why: `no merge base with ${ref} (fetch it first), so every suite runs` };
95
+ const prefix = path.relative(top, DIR).split(path.sep).join('/') + '/';
96
+ const names = [
97
+ git('diff', '--name-only', base), // committed on this branch and uncommitted changes
98
+ git('ls-files', '--others', '--exclude-standard', '--full-name', DIR),
99
+ ].join('\n').split('\n').map((f) => f.trim()).filter(Boolean);
100
+ const files = [...new Set(names)].filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
101
+ if (!files.length) return { files, suites: [], why: `nothing under workspace/ differs from ${ref}` };
102
+ const wide = files.filter((f) => WIDE.has(f));
103
+ if (wide.length) return { files, suites: list, why: `${wide.join(', ')} changed, and every suite depends on it` };
104
+ const source = new Map(list.map((f) => [f, fs.existsSync(path.join(DIR, f)) ? fs.readFileSync(path.join(DIR, f), 'utf8') : '']));
105
+ const picked = new Set();
106
+ const untraced = [];
107
+ for (const f of files) {
108
+ if (source.has(f)) { picked.add(f); continue; }
109
+ if (/^(guide|docs)\/|\.md$/.test(f)) continue; // prose: no suite asserts on it except guide.test.js, a unit suite
110
+ const stem = path.basename(f).replace(/\.[^.]+$/, '');
111
+ const key = stem.replace(/^(panel|view|tab)-/, '');
112
+ const hits = list.filter((s) => source.get(s).includes(stem) || s.includes(`-${key}-`) || s.includes(`-${key}.`));
113
+ if (!hits.length) untraced.push(f);
114
+ for (const h of hits) picked.add(h);
115
+ }
116
+ if (untraced.length) return { files, suites: list, why: `no suite names ${untraced.slice(0, 3).join(', ')}${untraced.length > 3 ? ' …' : ''}, so every suite runs` };
117
+ return { files, suites: list.filter((f) => picked.has(f)), why: `${files.length} changed file(s) reach these suites` };
118
+ }
119
+
68
120
  function check() {
69
121
  const listed = new Set(GROUPS.all);
70
122
  const problems = [];
@@ -114,6 +166,14 @@ async function main() {
114
166
  let list = GROUPS[group];
115
167
  const shard = flag('shard');
116
168
  list = shard ? shardOf(list, shard) : bySlowest(list);
169
+ const changed = flag('changed');
170
+ if (changed) {
171
+ const ref = changed === true ? 'origin/main' : changed;
172
+ const pick = changedSuites(list, ref);
173
+ console.log(`run-tests: --changed against ${ref}: ${pick.why}. ${pick.suites.length} of ${list.length} suites run here; CI runs all ${list.length}.`);
174
+ list = pick.suites;
175
+ if (!list.length) return;
176
+ }
117
177
  if (flag('list')) { console.log(list.join('\n')); return; }
118
178
  const cpus = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
119
179
  // A browser suite runs a server, a tmux daemon and a Chromium of its own, and several of them assert
@@ -131,7 +131,7 @@
131
131
  "pm-repos-test.js": 3,
132
132
  "pm-initiatives-test.js": 15,
133
133
  "pm-detail-test.js": 16,
134
- "pm-tabs-test.js": 59,
134
+ "pm-tabs-test.js": 50,
135
135
  "pm-term-test.js": 28,
136
136
  "pm-modes-test.js": 18,
137
137
  "pm-r1-test.js": 6,