create-pmos 0.102.0 → 0.104.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.102.0",
3
+ "version": "0.104.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@6b369e8
1
+ kernel-source: pmos@dd22f56
2
2
  assembled: 2026-10-08
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-08T00:36:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T00:58:10Z"}
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-08T00:36:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T00:58:10Z"}
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-08T00:36:21Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T00:58:10Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -39,7 +39,7 @@ Settings holds settings only, in eight sections. On a wide screen they are cards
39
39
  - **Updates**: the version of the workspace app and the version of the kit in the current repository. Press **Check for updates** at the top of the page to compare them with the latest published version of PMOS. This is the only time the workspace asks the internet for a version, and it asks only when you press. When a newer version is available, one button shows: **Update to** and the version. It updates every part that is older, in this order:
40
40
  - **The kit** in the current repository, when it is older. This does what `npx create-pmos@latest --update` does in a terminal. It changes only the kit's own files under `pmos/` (skills, scripts, templates and guides). Your records in `state/`, `planning/` and `okf/product/` do not change. The last lines of the output show under the button. The changed files are under `pmos/`, which Sync treats as records, so Sync (or automatic sync) sends them with your other records. To read them first, look at `git diff pmos/` before you sync.
41
41
  - **The workspace app**, when it is older. This does what `pmos upgrade` does in a terminal. The button asks you first. The workspace stops, installs the new version and starts again, and the page reloads when the new version starts. The reloaded page runs the new version's code: each start gives the page files a new address, so neither the browser nor the Online address's tunnel can keep the old ones. You do not need a terminal or a hard refresh. Sessions keep running in tmux. It works on the computer that runs the workspace and at its Online address, because the page comes back there after the restart. It also works on a phone or public address that signs in with a token set in `PMOS_TOKEN`, which the workspace keeps. When the workspace made its own token at start, the token changes on the restart, so the page refuses and says where to press it. When the workspace runs from a PMOS checkout, update it with `git pull` instead.
42
- - **Testing an update before it is published.** In a PMOS checkout, run `bash scripts/build-local-package.sh`. It builds the package that a publish would build, in `~/.pmos-local-package`, with a version newer than the published one, and prints the command that starts the workspace on it. That command sets `PMOS_UPDATE_SOURCE` to the package on the same computer: its folder, or the `.tgz` that `npm pack` makes. Then **Check for updates** reads that package's version and does not ask npm, and both updates install that package. The Updates card names the package it checks. A checkout may update the app this way. When the path is wrong, the card says why and does not fall back to npm. Unset the variable, or set it to `npm`, to use the registry again.
42
+ - **Testing an update before it is published.** In a PMOS checkout, run `bash scripts/build-local-package.sh`. It builds the package that a publish would build, in `~/.pmos-local-package`, with a version newer than the published one, and prints the command that starts the workspace on it. That command sets `PMOS_UPDATE_SOURCE` to the package on the same computer: its folder, or the `.tgz` that `npm pack` makes. Then **Check for updates** reads that package's version and does not ask npm, and both updates install that package. The Updates card names the package it checks, and the line under the buttons says "from the local package" after a check. There is no checkbox for this; the variable is the only switch. A checkout may update the app this way. When the path is wrong, the card says why and does not fall back to npm. Unset the variable, or set it to `npm`, to use the registry again.
43
43
 
44
44
  A new screen or a renamed menu comes with the workspace app, not with the kit.
45
45
 
@@ -166,20 +166,6 @@ async function latest({ exec = cp.execFile, source: src = source() } = {}) {
166
166
  return { ok: true, version: v };
167
167
  }
168
168
 
169
- /** Check the local repository's package version (for dev/testing without npm). Reads npm-pkg/package.json for kit. */
170
- function latestLocal({ repoRoot = null } = {}) {
171
- try {
172
- const pkgPath = path.join(String(repoRoot || ''), 'npm-pkg', 'package.json');
173
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
174
- if (!pkg.version || !validVersion(pkg.version)) {
175
- return { ok: false, error: 'Local package.json has no valid version' };
176
- }
177
- return { ok: true, version: pkg.version, source: 'local-git' };
178
- } catch (e) {
179
- return { ok: false, error: `Could not read local version: ${e.message}` };
180
- }
181
- }
182
-
183
169
  /**
184
170
  * npm lists a new version in the check a moment before its download is served everywhere. A download asked for in that
185
171
  * gap answers 404, and npm's CDN keeps the 404 for up to five minutes (Cache-Control max-age=300), so the same press
@@ -264,4 +250,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
264
250
  }
265
251
  }
266
252
 
267
- module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, latestLocal, updateKit, updateEnv, upgradeApp, whereFrom };
253
+ module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, npxArgs, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.102.0",
3
+ "version": "0.104.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.102.0",
9
+ "version": "0.104.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.102.0",
3
+ "version": "0.104.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",
@@ -38,7 +38,6 @@ import { state, setState, subscribe, flush, FIXTURE, LEGACY_LAYOUT_FLAG } from '
38
38
  import { isMobile, mountMobile, applyMobile } from './mobile.js';
39
39
  /* The frame's parts (Run 3, slice 3A). Small modules with no effect at import: a fixture page loads them and calls none. */
40
40
  import { createTopbar, unsyncedOf } from './topbar.js';
41
- import { openView } from './panel-cockpit.js';
42
41
  import { createDockRail } from './dock.js';
43
42
  import { SEL, ensureComponentsCss, iconButton, menu, toast, sheet, button } from './primitives.js';
44
43
  import { ensureSprite } from './icons.js';
@@ -608,6 +607,8 @@ const frEnd = el('span', { class: 'fr-end', 'aria-hidden': 'true' });
608
607
  function setupFrame() {
609
608
  ensureComponentsCss();
610
609
  ensureSprite();
610
+ // panel-cockpit.js loads on first use: importing it statically here broke the legacy detail column's fold control (ui-test AC1)
611
+ const openView = (v) => import('./panel-cockpit.js').then((m) => m.openView(v));
611
612
  bar = createTopbar({ go, openPalette, openView, repoMenu, sync: runSync });
612
613
  document.addEventListener('pointerdown', (e) => { fr.downAt = e.target; }, true);
613
614
  dockRail = createDockRail({ open: openDock, resize: dockWidth });
@@ -7421,7 +7421,7 @@
7421
7421
  margin-bottom: 16px;
7422
7422
  padding: 12px;
7423
7423
  background: var(--secondary);
7424
- border-radius: 4px;
7424
+ border-radius: 8px;
7425
7425
  font-size: 13px;
7426
7426
  }
7427
7427
  .nt-detail-summary pre {
@@ -7444,7 +7444,7 @@
7444
7444
  padding: 12px;
7445
7445
  margin-bottom: 8px;
7446
7446
  background: var(--secondary);
7447
- border-radius: 4px;
7447
+ border-radius: 8px;
7448
7448
  border-left: 3px solid var(--primary);
7449
7449
  }
7450
7450
  .nt-detail-action-text {
@@ -395,9 +395,9 @@ function readKitStatus(ws, force) {
395
395
 
396
396
  const say = (fields) => { Object.assign(upd, fields); setState({}); };
397
397
 
398
- async function checkUpdates(ws, local) {
398
+ async function checkUpdates(ws) {
399
399
  say({ phase: 'check', message: '', error: '', output: '' });
400
- const j = await pmPost('/api/kit/check', { ws, local });
400
+ const j = await pmPost('/api/kit/check', { ws });
401
401
  if (j && j.ok !== false) {
402
402
  kitByWs.set(ws, { st: j, at: Date.now() });
403
403
  say({ phase: null, latest: j.latest, compare: { appBehind: !!j.appBehind, kitBehind: !!j.kitBehind }, source: j.source });
@@ -480,13 +480,9 @@ function updatesSection(scope) {
480
480
  onClick: () => updateAll(ws, app, { appBehind: !!cmp.appBehind, kitBehind: !!cmp.kitBehind && kitPresent }),
481
481
  })));
482
482
  }
483
- kids.push(el('div', { class: 'fr-set__line' }, el('label', { class: 'fr-set__checkbox' },
484
- el('input', { type: 'checkbox', checked: !!upd.checkLocal, onChange: (e) => { upd.checkLocal = e.target.checked; setState({}); } }),
485
- el('span', null, 'Check local git version')
486
- )));
487
483
  const line = upd.phase === 'check' ? P.updatesChecking : upd.phase === 'kit' ? P.updateKitBusy
488
484
  : upd.message ? upd.message : upd.latest ? (cmp.appBehind || cmp.kitBehind ? P.updatesLatest(upd.latest) : P.updatesCurrent(upd.latest)) : P.updatesNever;
489
- const sourceNote = upd.source === 'local-git' ? ' (from local git)' : upd.source ? ` (from ${upd.source})` : '';
485
+ const sourceNote = upd.source === 'local' ? ' (from the local package)' : '';
490
486
  kids.push(el('p', { class: 'fr-set__note', role: 'status', 'aria-live': 'polite', 'data-fr': 'update-state' }, line + sourceNote));
491
487
  if (upd.error) kids.push(el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'update-error' }, upd.error));
492
488
  if (upd.output) kids.push(el('pre', { class: 'fr-set__output', 'data-fr': 'update-output' }, upd.output));
@@ -500,7 +496,7 @@ function checkButton(scope) {
500
496
  const ws = wsOfRepo(repo) || wsOfRepo(activeProduct());
501
497
  return button({
502
498
  label: P.updatesCheck, variant: 'outline', size: 'sm', icon: 'refresh-cw', disabled: !!upd.phase || !ws,
503
- attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws, upd.checkLocal),
499
+ attrs: { 'data-fr': 'check-updates' }, onClick: () => checkUpdates(ws),
504
500
  });
505
501
  }
506
502
 
@@ -124,12 +124,9 @@ export function mountView(host, tab) {
124
124
  if (!v) inner.appendChild(el('div', { style: 'color:var(--muted-foreground);font-size:12px' }, 'this view is not known to this version'));
125
125
  else if (board) {
126
126
  inner.style.maxWidth = 'none';
127
- const ctx = { tab, view: tab.view, renderMode, ready: !v.mode || !!m, el, setChildren, state, setState, FIXTURE, openView, repo: currentProduct() };
128
- board.renderBoard(inner, ctx)
129
- .then((boardEl) => {
130
- if (boardEl) setChildren(inner, [boardEl]);
131
- })
132
- .catch((e) => { console.error('[pm] board ' + tab.view + ' failed:', e); renderMode(inner); });
127
+ // a board draws into `inner` and returns nothing (view-notes.js, view-today.js …); calling .then on that threw
128
+ try { board.renderBoard(inner, { tab, view: tab.view, renderMode, ready: !v.mode || !!m, el, setChildren, state, setState, FIXTURE, openView, repo: currentProduct() }); }
129
+ catch (e) { console.error('[pm] board ' + tab.view + ' failed:', e); renderMode(inner); }
133
130
  } else if (boardMods[tab.view] === null) {
134
131
  // the board module is still loading: draw nothing rather than flash the old list
135
132
  } else if (m && typeof m.renderMode === 'function') {
@@ -89,7 +89,7 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
89
89
 
90
90
  const notes = button({
91
91
  label: 'Notes', variant: 'outline', icon: 'notebook-pen',
92
- attrs: { class: 'fr-notes', 'data-fr-view': 'notes', title: 'Open notes menu (all notes by initiative)' },
92
+ attrs: { class: 'fr-notes', 'data-fr': 'notes', 'data-fr-view': 'notes', title: 'Open notes menu (all notes by initiative)' },
93
93
  onClick: () => openView && openView('notesmenu'),
94
94
  });
95
95
 
@@ -42,7 +42,8 @@ async function loadNote(id) {
42
42
  function groupByInitiative(notes) {
43
43
  const groups = {};
44
44
  notes.forEach((n) => {
45
- const key = n.initiative || '(no initiative)';
45
+ // a note's initiative is a link object ({ id, title, by }), never a bare string
46
+ const key = n.initiative ? (n.initiative.title || n.initiative.id) : '(no initiative)';
46
47
  if (!groups[key]) groups[key] = [];
47
48
  groups[key].push(n);
48
49
  });
@@ -54,11 +55,8 @@ function groupByInitiative(notes) {
54
55
  }
55
56
 
56
57
  function renderNoteRow(note, isSelected, onClick) {
57
- const summaryLines = (note.summary_lines || []);
58
- const summaryText = summaryLines
59
- .slice(0, 2)
60
- .join(' ')
61
- .substring(0, 60);
58
+ // the list row carries summary_lines (up to three plain lines); its `summary` is only a has-a-summary flag
59
+ const summaryText = (note.summary_lines || []).slice(0, 2).join(' ').substring(0, 60);
62
60
 
63
61
  return el('div', {
64
62
  class: 'nt-menu-row' + (isSelected ? ' nt-menu-row--selected' : ''),
@@ -71,6 +69,13 @@ function renderNoteRow(note, isSelected, onClick) {
71
69
  );
72
70
  }
73
71
 
72
+ /** The note's summary as plain text: the agent's markdown, else the local overview lines. */
73
+ function summaryOf(note) {
74
+ if (note.agent && note.agent.markdown) return String(note.agent.markdown);
75
+ const ov = note.summary && note.summary.sections && note.summary.sections.overview;
76
+ return Array.isArray(ov) ? ov.map((x) => String((x && x.text) || '')).filter(Boolean).join('\n') : '';
77
+ }
78
+
74
79
  function renderLeftPanel(notes, selectedId, onSelectNote, onToggleInitiative) {
75
80
  const groups = groupByInitiative(notes);
76
81
 
@@ -95,9 +100,11 @@ function renderLeftPanel(notes, selectedId, onSelectNote, onToggleInitiative) {
95
100
  );
96
101
  }
97
102
 
98
- export async function renderBoard(inner, ctx) {
103
+ // the cockpit calls renderBoard(inner, ctx) and ignores what it returns: the view draws into `inner`
104
+ export function renderBoard(inner, ctx) {
99
105
  ensureComponentsCss();
100
106
  const root = el('div', { class: 'nt-menu' });
107
+ inner.appendChild(root);
101
108
 
102
109
  async function render() {
103
110
  if (!list) await loadList();
@@ -139,7 +146,7 @@ export async function renderBoard(inner, ctx) {
139
146
  el('div', { class: 'nt-detail-date' }, new Date(note.updated_at).toLocaleDateString()),
140
147
  ),
141
148
  el('div', { class: 'nt-detail-summary' },
142
- note.summary ? el('pre', {}, note.summary) : el('p', { class: 'nt-hint' }, 'No summary yet'),
149
+ summaryOf(note) ? el('pre', {}, summaryOf(note)) : el('p', { class: 'nt-hint' }, 'No summary yet'),
143
150
  ),
144
151
  actions.length > 0 && el('div', { class: 'nt-detail-actions' },
145
152
  el('div', { class: 'nt-detail-actions-title' }, 'Next steps'),
@@ -181,7 +188,5 @@ export async function renderBoard(inner, ctx) {
181
188
  setChildren(root, [leftPanel, rightPanel]);
182
189
  }
183
190
 
184
- await render();
185
- ctx.setState = (state) => { render(); };
186
- return root;
191
+ render();
187
192
  }
@@ -23,6 +23,11 @@
23
23
  * CI for the browser group on a PR (ci-browser-after-merge): every browser suite still runs after the
24
24
  * merge on main, and kit-publish waits for that run. See changedSuites() for the rule.
25
25
  *
26
+ * --skip-passed=FILE|DIR --since=SHA skips a suite FILE (or any file in DIR) lists as passed (an earlier run of the same PR wrote it with
27
+ * --passed-out) when the diff SHA..HEAD cannot reach it, by the same rule as --changed. So the next push of a PR runs
28
+ * the suites that failed and the suites its new commits reach, not the whole group again. A suite it skips is written
29
+ * to --passed-out too, so a pass carries forward to the run after. An empty or unknown SHA skips nothing.
30
+ *
26
31
  * Output: a suite's own output is buffered and printed whole when it finishes, so lines from two
27
32
  * suites never interleave. A passing suite prints its last lines; --verbose prints all of it.
28
33
  *
@@ -143,14 +148,15 @@ const WIDE = new Set([
143
148
  * name carries the changed file's stem with any panel-/view-/tab- prefix removed (view-memory.js ->
144
149
  * pm-r29-memory-test.js). It errs toward running MORE: a WIDE file, or a changed workspace file that
145
150
  * no suite can be traced to, selects the whole list. Returns { files, suites, why }. */
146
- function changedSuites(list, ref) {
151
+ function changedSuites(list, ref, { exact = false } = {}) {
147
152
  const git = (...a) => {
148
153
  const r = require('child_process').spawnSync('git', a, { cwd: DIR, encoding: 'utf8' });
149
154
  return r.status === 0 ? r.stdout : null;
150
155
  };
151
156
  const top = (git('rev-parse', '--show-toplevel') || '').trim();
152
157
  if (!top) return { files: [], suites: list, why: 'not a git checkout, so every suite runs' };
153
- const base = (git('merge-base', 'HEAD', ref) || '').trim();
158
+ // exact: diff from `ref` itself (an earlier head of this branch), not from its merge base
159
+ const base = exact ? ((git('rev-parse', '--verify', '--quiet', `${ref}^{commit}`) || '').trim()) : (git('merge-base', 'HEAD', ref) || '').trim();
154
160
  if (!base) return { files: [], suites: list, why: `no merge base with ${ref} (fetch it first), so every suite runs` };
155
161
  const prefix = path.relative(top, DIR).split(path.sep).join('/') + '/';
156
162
  const names = [
@@ -260,6 +266,24 @@ async function main() {
260
266
  list = pick.suites;
261
267
  if (!list.length) return;
262
268
  }
269
+ // --skip-passed / --since: suites that passed on an earlier head of this PR and that the new commits cannot reach
270
+ let carried = [];
271
+ const passedIn = flag('skip-passed'); const since = flag('since');
272
+ if (passedIn && passedIn !== true && since && since !== true) {
273
+ let before = [];
274
+ // a file, or a directory of the files every job of the earlier run wrote (CI downloads them into one)
275
+ const read = (f) => { try { return fs.readFileSync(f, 'utf8').split('\n').map((x) => x.trim()).filter(Boolean); } catch { return []; } };
276
+ try { before = fs.statSync(passedIn).isDirectory() ? fs.readdirSync(passedIn).flatMap((n) => read(path.join(passedIn, n))) : read(passedIn); } catch { /* no earlier run: skip nothing */ }
277
+ const reach = changedSuites(list, since, { exact: true });
278
+ const reached = new Set(reach.suites);
279
+ const known = !/no merge base|not a git checkout/.test(reach.why);
280
+ carried = known ? list.filter((f) => before.includes(f) && !reached.has(f)) : [];
281
+ console.log(`run-tests: --skip-passed since ${since.slice(0, 12)}: ${known ? reach.why : 'that commit is not in this checkout, so nothing is skipped'}. ${carried.length} suite(s) passed before and are not reached; ${list.length - carried.length} run.`);
282
+ list = list.filter((f) => !carried.includes(f));
283
+ }
284
+ const passedOut = flag('passed-out');
285
+ const writePassed = (ran) => { if (passedOut && passedOut !== true) fs.writeFileSync(passedOut, [...carried, ...ran.filter((r) => r.ok).map((r) => r.file)].join('\n') + '\n'); };
286
+ if (!list.length) { writePassed([]); console.log('run-tests: nothing left to run.'); return; }
263
287
  if (flag('list')) { console.log(list.join('\n')); return; }
264
288
  const cpus = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
265
289
  // A browser suite runs a server, a tmux daemon and a Chromium of its own, and several of them assert
@@ -286,6 +310,7 @@ async function main() {
286
310
  const failed = results.filter((r) => !r.ok);
287
311
  const slowest = [...results].sort((a, b) => b.secs - a.secs).slice(0, 5).map((r) => `${r.file} ${r.secs}s`).join(', ');
288
312
  const timingsOut = flag('timings-out');
313
+ writePassed(results);
289
314
  if (timingsOut && timingsOut !== true) fs.writeFileSync(timingsOut, JSON.stringify(Object.fromEntries(results.map((r) => [r.file, r.secs])), null, 2));
290
315
  console.log(`\nrun-tests: ${results.length - failed.length}/${results.length} suites passed in ${((Date.now() - t0) / 1000).toFixed(1)}s wall. Slowest: ${slowest}.`);
291
316
  // exitCode, never process.exit(): stdout to a pipe is asynchronous, and exiting here cut the CI log
@@ -7334,7 +7334,7 @@ async function handleRequest(req, res) {
7334
7334
  const r = await kitUpdate.latest({ source: kitUpdate.source() });
7335
7335
  const now = st();
7336
7336
  if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
7337
- return json(res, { ok: true, ws: w.id, ...now, latest: r.version, source: r.source || 'npm', ...kitUpdate.compare(now, r.version) });
7337
+ return json(res, { ok: true, ws: w.id, ...now, latest: r.version, source: r.source || 'registry', ...kitUpdate.compare(now, r.version) });
7338
7338
  }
7339
7339
  if (kitBusy) return json(res, { ok: false, error: kitBusy === 'app' ? 'The workspace is already updating' : 'A kit update is already running' }, 409);
7340
7340
  if (p === '/api/kit/update') {