create-pmos 0.521.0 → 0.522.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/bin/create-pmos.js +81 -42
  2. package/package.json +26 -16
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/bin/pmos.js +2 -1
  8. package/workspace/board-publish-ui-test.js +1 -1
  9. package/workspace/changelog.json +25 -0
  10. package/workspace/guide/home.md +2 -2
  11. package/workspace/guide/published-board.md +15 -3
  12. package/workspace/lib/board-pack.js +44 -1
  13. package/workspace/lib/board-publish-choices.js +22 -6
  14. package/workspace/lib/board-publish-parts.js +95 -0
  15. package/workspace/lib/boards-host.js +3 -3
  16. package/workspace/lib/boards-received.js +105 -0
  17. package/workspace/lib/kit-update.js +51 -21
  18. package/workspace/lib/routes-board-publish.js +42 -12
  19. package/workspace/package-lock.json +2 -2
  20. package/workspace/package.json +1 -1
  21. package/workspace/public/pm/bd-publish.css +3 -0
  22. package/workspace/public/pm/bd-publish.js +39 -9
  23. package/workspace/public/pm/bd-shared.css +5 -0
  24. package/workspace/public/pm/bd-shared.js +72 -10
  25. package/workspace/public/pm/chat-term.js +22 -0
  26. package/workspace/public/pm/cozy-board.css +10 -0
  27. package/workspace/public/pm/cozy-home.css +3 -0
  28. package/workspace/public/pm/home-chat.js +3 -0
  29. package/workspace/public/pm/premium-board.js +8 -2
  30. package/workspace/public/pm/premium-shell.js +2 -1
  31. package/workspace/public/pm/readonly-boot.js +4 -1
  32. package/workspace/public/pm/readonly.js +40 -5
  33. package/workspace/public/pm/term-view.js +39 -8
@@ -21,7 +21,7 @@
21
21
  const os = require('os');
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
- const { spawnSync } = require('child_process');
24
+ const cp = require('child_process'); // cp.spawnSync, so a test can stand in for it
25
25
 
26
26
  const PAYLOAD = path.join(__dirname, '..', 'payload'); // the assembled pmos/ kit (file-mode)
27
27
  const WORKSPACE = path.join(__dirname, '..', 'workspace'); // the bundled Terminal Workspace app (D62)
@@ -173,13 +173,18 @@ function update(dirArg, opts) {
173
173
  // global install can't run (locked-down npm prefix), we DON'T fail — we launch straight from the
174
174
  // bundled copy (its own bin/pmos.js installs runtime deps on first run) and print the one manual
175
175
  // line to get the bare `pmos` word for next time.
176
+ // No console window on Windows when nobody watches a terminal (batch 27 item 7). The in-app update (Settings > Updates)
177
+ // runs this with its output in a log file; then each child here starts hidden. In a terminal the children share it, as
178
+ // before: windowsHide there would give each one a console of its own, away from the one the person reads.
179
+ const hide = () => ({ windowsHide: !process.stdout.isTTY });
180
+
176
181
  function onPath(cmd) {
177
182
  // `command -v` (POSIX) and `where` (Windows) are shell BUILTINS — neither has a standalone binary on
178
183
  // Linux, so the probe MUST run through a shell or it ENOENTs and always reports "not found" (which,
179
184
  // after a successful install, prints a false PATH warning and defeats the idempotent fast path). cmd
180
185
  // is always a literal, so the string form is injection-safe.
181
186
  const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
182
- try { return spawnSync(probe, { stdio: 'ignore', shell: true }).status === 0; }
187
+ try { return cp.spawnSync(probe, { stdio: 'ignore', shell: true, ...hide() }).status === 0; }
183
188
  catch { return false; }
184
189
  }
185
190
 
@@ -204,14 +209,41 @@ function launchStatus(r) {
204
209
 
205
210
  // --no-open: the in-app update (Settings > Updates) reinstalls through here while the PM's tab waits and reloads itself;
206
211
  // the restarted server then opens no second tab (batch 18 item 5). It is pmos's own flag, so the server does not inherit it.
207
- function runInstalledPmos(args) {
208
- if (process.platform !== 'win32') return spawnSync('pmos', args, { stdio: 'inherit' });
212
+ // { capture: true } returns the output in r.stdout instead of printing it.
213
+ function runInstalledPmos(args, { capture = false } = {}) {
214
+ const io = capture ? { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } : { stdio: 'inherit' };
215
+ if (process.platform !== 'win32') return cp.spawnSync('pmos', args, { ...io, ...hide() });
209
216
  // cmd.exe does not preserve spawnSync argument boundaries. Run Node directly so a
210
217
  // repository under a Windows user name with spaces still reaches the app intact.
211
- const root = spawnSync('npm.cmd', ['root', '-g'], { encoding: 'utf8', shell: true });
218
+ const root = globalRoot();
212
219
  if (root.error || root.status !== 0) return root;
213
- if (!root.stdout || !root.stdout.trim()) return { status: 1, error: new Error('npm did not report its global install folder') };
214
- return spawnSync(process.execPath, [path.join(root.stdout.trim(), 'pmos-workspace', 'bin', 'pmos.js'), ...args], { stdio: 'inherit' });
220
+ if (!root.dir) return { status: 1, error: new Error('npm did not report its global install folder') };
221
+ return cp.spawnSync(process.execPath, [path.join(root.dir, 'pmos-workspace', 'bin', 'pmos.js'), ...args], { ...io, ...hide() });
222
+ }
223
+
224
+ // `npm root -g`, asked once per run: every Windows launch step needs it, and each ask starts npm again.
225
+ let rootMemo = null;
226
+ function globalRoot() {
227
+ if (rootMemo) return rootMemo;
228
+ const win = process.platform === 'win32';
229
+ const r = cp.spawnSync(win ? 'npm.cmd' : 'npm', ['root', '-g'], { encoding: 'utf8', shell: win, ...hide() });
230
+ const res = { status: r.status, error: r.error, dir: r.status === 0 && r.stdout ? r.stdout.trim() : '' };
231
+ if (res.status === 0 && !res.error) rootMemo = res;
232
+ return res;
233
+ }
234
+
235
+ // Is the launcher already set up for this repository? `pmos launcher status` says so with exit 0 when the app and its
236
+ // node and pmos.js are in place, and names the repository. Then `launcher on` would write the same files again, and on
237
+ // Windows run PowerShell twice, so it is skipped. Any other answer (no launcher, a moved node, another repository, an
238
+ // older pmos without the command) runs `launcher on` as before.
239
+ function launcherCurrent(targetDir) {
240
+ const r = runInstalledPmos(['launcher', 'status'], { capture: true });
241
+ if (r.error || r.status !== 0) return false;
242
+ const m = /^\s*repository: (.+?)\s*$/m.exec(String(r.stdout || ''));
243
+ if (!m || /✗/.test(m[1])) return false;
244
+ const win = process.platform === 'win32';
245
+ const norm = (p) => (win ? path.win32.resolve(p).toLowerCase() : path.resolve(p));
246
+ return norm(m[1]) === norm(targetDir);
215
247
  }
216
248
 
217
249
  function launchWorkspace(targetDir, viaGlobal) {
@@ -223,8 +255,11 @@ function launchWorkspace(targetDir, viaGlobal) {
223
255
  console.error('create-pmos: launcher setup needs the installed pmos command. Fix the global install and run workspace again.');
224
256
  return 1;
225
257
  }
226
- const launcher = runInstalledPmos(['launcher', 'on', targetDir]);
227
- if (launchStatus(launcher) !== 0) return 1;
258
+ if (launcherCurrent(targetDir)) console.log('✓ PMOS app and login item already set up — kept as they are.');
259
+ else {
260
+ const launcher = runInstalledPmos(['launcher', 'on', targetDir]);
261
+ if (launchStatus(launcher) !== 0) return 1;
262
+ }
228
263
  }
229
264
 
230
265
  if (viaGlobal && onPath('pmos')) {
@@ -233,8 +268,8 @@ function launchWorkspace(targetDir, viaGlobal) {
233
268
  }
234
269
  // Fallback: run the bundled launcher directly. Its ensureInstalled() pulls runtime deps on first run.
235
270
  console.log('Launching from the bundled copy (the `pmos` command is not on PATH).');
236
- return launchStatus(spawnSync(process.execPath, [path.join(WORKSPACE, 'bin', 'pmos.js'), targetDir, ...NO_OPEN],
237
- { stdio: 'inherit', env: process.env }));
271
+ return launchStatus(cp.spawnSync(process.execPath, [path.join(WORKSPACE, 'bin', 'pmos.js'), targetDir, ...NO_OPEN],
272
+ { stdio: 'inherit', env: process.env, ...hide() }));
238
273
  }
239
274
 
240
275
  // Compare two dotted numeric versions. Returns -1 if a<b, 0 if equal, 1 if a>b. Missing/non-numeric
@@ -257,10 +292,9 @@ function bundledWorkspaceVersion() {
257
292
  // Version of the globally-installed `pmos-workspace`, or null if absent/unreadable. Read from the
258
293
  // npm global root rather than the app's CLI — no dependency on the old binary supporting --version.
259
294
  function installedWorkspaceVersion() {
260
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
261
- const r = spawnSync(npmCmd, ['root', '-g'], { encoding: 'utf8', shell: process.platform === 'win32' });
262
- if (r.status !== 0 || !r.stdout) return null;
263
- const dir = path.join(r.stdout.trim(), 'pmos-workspace');
295
+ const r = globalRoot();
296
+ if (r.status !== 0 || !r.dir) return null;
297
+ const dir = path.join(r.dir, 'pmos-workspace');
264
298
  try {
265
299
  // A SYMLINK is not an install. `npm install -g <folder>` links to the folder and installs no
266
300
  // dependencies, so the link reads as the bundled version (it IS the bundled folder, here the npx
@@ -278,12 +312,12 @@ function installWorkspaceGlobal() {
278
312
  const shell = process.platform === 'win32';
279
313
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmos-ws-'));
280
314
  try {
281
- const pack = spawnSync(npmCmd, ['pack', WORKSPACE, '--pack-destination', tmp, '--silent'],
282
- { encoding: 'utf8', shell });
315
+ const pack = cp.spawnSync(npmCmd, ['pack', WORKSPACE, '--pack-destination', tmp, '--silent'],
316
+ { encoding: 'utf8', shell, ...hide() });
283
317
  const tgz = pack.status === 0 ? (pack.stdout || '').trim().split('\n').pop() : '';
284
318
  if (!tgz) { console.error(`create-pmos: could not pack the workspace app${pack.stderr ? ' — ' + pack.stderr.trim() : ''}`); return 1; }
285
- return spawnSync(npmCmd, ['install', '-g', path.join(tmp, tgz), '--no-audit', '--no-fund'],
286
- { stdio: 'inherit', shell }).status;
319
+ return cp.spawnSync(npmCmd, ['install', '-g', path.join(tmp, tgz), '--no-audit', '--no-fund'],
320
+ { stdio: 'inherit', shell, ...hide() }).status;
287
321
  } finally {
288
322
  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
289
323
  }
@@ -291,7 +325,7 @@ function installWorkspaceGlobal() {
291
325
  // A persistent server started by the OLD code keeps serving OLD assets after an upgrade — stop it so
292
326
  // the next launch brings the server up on the freshly installed code. Best-effort; ignore failures.
293
327
  function stopWorkspaceServer() {
294
- try { spawnSync('pmos', ['stop'], { stdio: 'ignore', shell: process.platform === 'win32' }); }
328
+ try { cp.spawnSync('pmos', ['stop'], { stdio: 'ignore', shell: process.platform === 'win32', ...hide() }); }
295
329
  catch { /* no server running, or pmos not launchable — nothing to stop */ }
296
330
  }
297
331
 
@@ -347,26 +381,31 @@ function workspace(targetArg, opts) {
347
381
  process.exit(launchWorkspace(targetDir, false));
348
382
  }
349
383
 
350
- const args = process.argv.slice(2);
351
- const positional = args.filter((a) => !a.startsWith('-'));
352
- if (args.includes('--help') || args.includes('-h')) {
353
- console.log('usage:');
354
- console.log(' create-pmos [target-dir] vendor the file-mode kit into a repo (pm-artifacts profile, D68)');
355
- console.log(' create-pmos [target-dir] --code …as a product-code repository instead (code lanes and ladder)');
356
- console.log(' create-pmos [target-dir] --no-mod …without offering the pmos-cockpit mod in .claude/settings.json');
357
- console.log(' create-pmos --update [kit-or-repo] refresh only the kit kernel (add --offer-mod to offer the mod in .claude/settings.json)');
358
- console.log(' create-pmos workspace [repo-dir] install/upgrade + launch the Terminal Workspace GUI');
359
- console.log(' create-pmos workspace --reinstall force-reinstall the workspace app (drop a stale global copy)');
360
- console.log(' create-pmos workspace --no-launcher skip the default Mac/Windows app and login item');
361
- console.log(' create-pmos --version print version + bundled kernel provenance');
362
- } else if (args.includes('--version') || args.includes('-v')) {
363
- console.log(`create-pmos ${PKG.version}`);
364
- console.log(kitVersion());
365
- console.log(`workspace bundled: ${fs.existsSync(path.join(WORKSPACE, 'bin', 'pmos.js')) ? 'yes' : 'no'}`);
366
- } else if (positional[0] === 'workspace') {
367
- workspace(positional[1], { reinstall: args.includes('--reinstall') });
368
- } else if (args.includes('--update')) {
369
- update(positional[0], { offerMod: args.includes('--offer-mod') });
370
- } else {
371
- vendor(path.resolve(positional[0] || '.'), { code: args.includes('--code'), noMod: args.includes('--no-mod') });
384
+ // Run as the create-pmos command; required (by workspace/kit-update.test.js), it only lends its launch steps.
385
+ function main() {
386
+ const args = process.argv.slice(2);
387
+ const positional = args.filter((a) => !a.startsWith('-'));
388
+ if (args.includes('--help') || args.includes('-h')) {
389
+ console.log('usage:');
390
+ console.log(' create-pmos [target-dir] vendor the file-mode kit into a repo (pm-artifacts profile, D68)');
391
+ console.log(' create-pmos [target-dir] --code …as a product-code repository instead (code lanes and ladder)');
392
+ console.log(' create-pmos [target-dir] --no-mod …without offering the pmos-cockpit mod in .claude/settings.json');
393
+ console.log(' create-pmos --update [kit-or-repo] refresh only the kit kernel (add --offer-mod to offer the mod in .claude/settings.json)');
394
+ console.log(' create-pmos workspace [repo-dir] install/upgrade + launch the Terminal Workspace GUI');
395
+ console.log(' create-pmos workspace --reinstall force-reinstall the workspace app (drop a stale global copy)');
396
+ console.log(' create-pmos workspace --no-launcher skip the default Mac/Windows app and login item');
397
+ console.log(' create-pmos --version print version + bundled kernel provenance');
398
+ } else if (args.includes('--version') || args.includes('-v')) {
399
+ console.log(`create-pmos ${PKG.version}`);
400
+ console.log(kitVersion());
401
+ console.log(`workspace bundled: ${fs.existsSync(path.join(WORKSPACE, 'bin', 'pmos.js')) ? 'yes' : 'no'}`);
402
+ } else if (positional[0] === 'workspace') {
403
+ workspace(positional[1], { reinstall: args.includes('--reinstall') });
404
+ } else if (args.includes('--update')) {
405
+ update(positional[0], { offerMod: args.includes('--offer-mod') });
406
+ } else {
407
+ vendor(path.resolve(positional[0] || '.'), { code: args.includes('--code'), noMod: args.includes('--no-mod') });
408
+ }
372
409
  }
410
+ if (require.main === module) main();
411
+ else module.exports = { hide, globalRoot, runInstalledPmos, launcherCurrent, launchWorkspace, installedWorkspaceVersion, installWorkspaceGlobal, stopWorkspaceServer, resetRoot: () => { rootMemo = null; } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.521.0",
3
+ "version": "0.522.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": {
@@ -32,6 +32,31 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.522.0",
37
+ "at": "2026-11-04T07:22:01.639Z",
38
+ "f": 4,
39
+ "prs": [
40
+ {
41
+ "n": 1218,
42
+ "t": "The in-app update on Windows is fast and opens no console window",
43
+ "s": "The in-app update on Windows is fast and opens no console window",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1219,
48
+ "t": "Chat keeps its answers when the terminal opens; terminal fills the Viewer",
49
+ "s": "Chat keeps its answers when the terminal opens; terminal fills the Viewer",
50
+ "k": "fix"
51
+ },
52
+ {
53
+ "n": 1220,
54
+ "t": "Share several products or programs in one link, and a way back to PMOS",
55
+ "s": "Share several products or programs in one link, and a way back to PMOS",
56
+ "k": "new"
57
+ }
58
+ ]
59
+ },
35
60
  {
36
61
  "v": "0.521.0",
37
62
  "at": "2026-11-04T02:17:43.880Z",
@@ -379,21 +404,6 @@
379
404
  "d": "A recording showed everything at once. Now it opens on a short summary, each point has one check, and you can fix a misheard word in place."
380
405
  }
381
406
  ]
382
- },
383
- {
384
- "v": "0.502.0",
385
- "at": "2026-11-03T04:57:38.850Z",
386
- "f": 4,
387
- "prs": [
388
- {
389
- "n": 1185,
390
- "t": "Full task names in the Timeline too",
391
- "s": "Full task names in the Timeline too",
392
- "k": "fix",
393
- "h": "Full task names in the Timeline",
394
- "d": "The Timeline still cut long task names short. Now they wrap in full there too."
395
- }
396
- ]
397
407
  }
398
408
  ]
399
409
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@2ac0d2f5
1
+ kernel-source: pmos@60230431
2
2
  assembled: 2026-11-04
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-11-04T02:17:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T07:21:58Z"}
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-11-04T02:17:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T07:21:58Z"}
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-11-04T02:17:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T07:21:58Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -200,7 +200,8 @@ function openBrowser(url) {
200
200
  const cmd = process.platform === 'darwin' ? ['open', [url]]
201
201
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
202
202
  : ['xdg-open', [url]];
203
- try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); } catch {}
203
+ // windowsHide: `cmd /c start` from a launcher with no console would otherwise flash a console window (batch 27 item 7).
204
+ try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore', windowsHide: true }).unref(); } catch {}
204
205
  }
205
206
 
206
207
  function npm(args) {
@@ -113,7 +113,7 @@ const ok = (name, cond, extra = '') => { n++; if (!cond) bad++; console.log(`${c
113
113
  await s.p.click('[data-bp-sel="program:project-ascent"]'); await s.p.fill('#bpTeam', 'Product'); await s.p.click('[data-bp-days="30"]'); await s.p.fill('#bpPass', 'my-own-password-1');
114
114
  await s.p.click('[data-bp="make"]'); await s.p.waitForSelector('[data-bp="copy"]');
115
115
  const sent = W.posts[0] && W.posts[0].body;
116
- ok('D1 a press sends what was picked, the team, the typed password and the days', sent && sent.ws === 'idpf' && sent.team === 'Product' && sent.password === 'my-own-password-1' && sent.days === 30 && sent.select.some((x) => x.kind === 'program' && x.id === 'project-ascent') && sent.select.some((x) => x.kind === 'initiative' && x.id === first), JSON.stringify(sent));
116
+ ok('D1 a press sends what was picked, the team, the typed password and the days', sent && sent.ws === 'idpf' && sent.team === 'Product' && sent.password === 'my-own-password-1' && sent.days === 30 && sent.parts.some((x) => x.kind === 'program' && x.id === 'project-ascent') && sent.parts.some((x) => x.kind === 'initiative' && x.id === first && x.ws === 'idpf'), JSON.stringify(sent));
117
117
  const t = await pop(s.p).textContent();
118
118
  ok('D2 the link is ready: the link, the password once, one Copy button, and the note says it is shown once', /boards\.test\/b\/link00000003/.test(t) && /my-own-password-1/.test(t) && (await s.p.locator('[data-bp="copy"]').getAttribute('aria-label')) === 'Copy link and password' && /It is shown once/.test(t) && /for the Product team/.test(t));
119
119
  await s.ctx.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {});
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "v": "0.522.0",
4
+ "at": "2026-11-04T07:22:01.639Z",
5
+ "f": 4,
6
+ "prs": [
7
+ {
8
+ "n": 1218,
9
+ "t": "The in-app update on Windows is fast and opens no console window",
10
+ "s": "The in-app update on Windows is fast and opens no console window",
11
+ "k": "fix"
12
+ },
13
+ {
14
+ "n": 1219,
15
+ "t": "Chat keeps its answers when the terminal opens; terminal fills the Viewer",
16
+ "s": "Chat keeps its answers when the terminal opens; terminal fills the Viewer",
17
+ "k": "fix"
18
+ },
19
+ {
20
+ "n": 1220,
21
+ "t": "Share several products or programs in one link, and a way back to PMOS",
22
+ "s": "Share several products or programs in one link, and a way back to PMOS",
23
+ "k": "new"
24
+ }
25
+ ]
26
+ },
2
27
  {
3
28
  "v": "0.521.0",
4
29
  "at": "2026-11-04T02:17:43.880Z",
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Home: your list, the chat and the Viewer"
4
4
  description: "Home: the list grouped by product, the Viewer, the place menu, Your day and its New for you fold, Search, and the initiative, meeting, report, Board and Insights features designed after it."
5
- timestamp: 2026-11-03
5
+ timestamp: 2026-11-04
6
6
  ---
7
7
 
8
8
  # Home
@@ -193,7 +193,7 @@ There is no My work page. The list on Home is your list:
193
193
  - **Sessions.** A session row names its agent. The coloured dot beside it is its state: blue waits on you, amber may be blocked, green is running. A session PMOS started itself (a skill or a background job) shows on Home only while it works or asks you something. Once it finishes its prompt, it leaves the list. On an initiative's page, a session that waits on you shows once, under **Needs you**, not again under **Sessions**. **Finished sessions** does not list an old run of a chat that is open again.
194
194
  - **One chat box.** Every conversation has the same box: **+** (attach a file, notes, the terminal, and **Link to an initiative** for a loose chat), **/** for commands, **@** inside an initiative, the To or In chip, the mic, and Send. A chat opens at its newest message.
195
195
  - **While Claude works.** A line at the top of the chat shows what Claude is trying to do. If Claude keeps a to-do list, the line shows the to-do in progress and how many are done, for example **2 of 5 done**. Tap the line to see the full list. If Claude has no to-do list, the line shows Claude's newest one-line status and the time since your message. The line never shows raw commands. To see the commands, tap the terminal icon.
196
- - **A session's terminal.** **Watch its terminal** in **+**, or a session row such as **Claude sign-in**, opens the session's terminal in the Viewer. While the session runs, it is a real terminal: type in it to answer Claude or paste a code. When the session has ended, the Viewer shows it read only and says so.
196
+ - **A session's terminal.** **Watch its terminal** in **+**, or a session row such as **Claude sign-in**, opens the session's terminal in the Viewer. While the session runs, it is a real terminal: type in it to answer Claude or paste a code. It fills the Viewer and fits its lines again when you resize the Viewer or the window. The chat goes on showing every answer while the terminal is open and after you close it: the terminal is a second view of the same session. When the session has ended, the Viewer shows it read only and says so.
197
197
  - **Setting up your first project opens by itself** until a registered folder holds an initiative or setup has finished. The `PMOS` folder that the one-line installer makes holds no initiative yet, so a new user who opens PMOS from the Start menu or the Dock still gets the setup conversation, not an empty Home.
198
198
  - **Signing in to Claude when you set up your first project.** If the login you choose is signed in already, PMOS does not open a terminal. It tests the login at once. Otherwise PMOS runs `claude auth login`: Claude opens your browser to sign in, and the terminal under the question shows only Claude's request for a code, if it needs one. Claude's theme and trust-this-folder screens do not appear. When the sign-in ends, PMOS tests the login by itself. **The Claude login on this computer** is the login your usual terminal uses (`~/.claude.json`); PMOS no longer points it at a different file. You choose between two logins only: **The Claude login on this computer** and **A separate login just for PMOS**. The test passes for a login that works in your terminal. Claude updates its own files when it runs, and the test does not count that as a changed setup.
199
199
  - **Documents not written yet.** A document that does not exist yet shows **Draft it**, in its stage or ahead of it. The agent writes a draft. Nothing is issued until you read it.
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Published board: a read-only copy of a Board"
4
4
  description: "What a published board is, how to publish one from the Board, what a reader sees and cannot do, and how to preview one."
5
- timestamp: 2026-11-01
5
+ timestamp: 2026-11-04
6
6
  ---
7
7
 
8
8
  # Published board: a read-only copy of a Board
@@ -18,7 +18,10 @@ Press the share icon on the Board's toolbar. A popover opens under it, inside th
18
18
  The button is not on a narrow screen (720 px or less) and not on the All products page. Open one product to publish from it.
19
19
 
20
20
  1. **Set the host.** The first time, the popover asks for the address of the board host. Ask the person who runs it. Write it as `https://boards.example.com`. The **Change** button on the form sets it again. If the computer has the `PMOS_BOARDS_HOST` variable, the variable is used.
21
- 2. **What to share.** The product of this board is picked. Press a program to add it. Press **An initiative** to pick one initiative. Press a picked chip again to remove it. **Publish** stays off while nothing is picked.
21
+ 2. **What to share.** The product of this board is picked. Your other products and your programs are chips under **Products** and **Programs**. A program chip names the products it reaches. Press a chip to add it. Press **Initiative** to pick one initiative of this board. Press a picked chip again to remove it. **Publish** stays off while nothing is picked.
22
+ - **Several picks are one link.** The reader sees one tab for each pick, in the order you picked them. A program tab opens the program's page with all its products. One pick shows no tabs.
23
+ - **The name.** With two or more picks, the link's name is made from them: three names, then "+ N more". Press the pencil to change it. **Republish** keeps every pick and the name.
24
+ - **The size.** The whole link can be at most 2 MB. When your picks are more, PMOS says so and sends nothing. Pick less.
22
25
  3. **Team.** Type the name of a team, or leave it empty. The host tells you when there is no team with that name.
23
26
  4. **Password.** It is on, with a password that PMOS made. Press **New** for another one, or write your own. Turn the switch off for a link with no password.
24
27
  5. **Ends.** The link ends after 1, 7 or 30 days.
@@ -54,6 +57,14 @@ The chips are **All**, **Yours** and one for each team. **Yours** shows only whe
54
57
  - **Yours.** Your own links have **Republish** and **Turn off**, the same as in the Publish popover. **Turn off** asks you to press a second time.
55
58
  - **A date in red.** The copy ends within a day and a half.
56
59
 
60
+ **Shared with you.** Paste a shared board link in the field at the top of **Shared** and press **+**. PMOS asks the host first. When the board has a password, PMOS asks for it once. PMOS keeps the link and its password on this computer only, the same as a team password. The board then shows under **Shared with you** and on the **With you** chip.
61
+
62
+ - Press the name of a board to open it in a new browser tab. You do not type its password again.
63
+ - A board that ends within 7 days shows how many days are left.
64
+ - A board that has ended, was turned off, or has a new password stays as one dimmed line with the reason. Press the name of a board with a new password to type it. Press **×** to forget a board.
65
+
66
+ **Open in my PMOS.** A shared board has two icons at the end of its "shared by" line. **Open in my PMOS** opens Board › Shared in the PMOS on the reader's own computer, with the link already in the field. Nothing is added until the reader presses **+**. The link never carries the password. It works when PMOS runs at its usual address, `http://127.0.0.1:7433`. **Get PMOS** opens pm-os.app/welcome in a new tab.
67
+
57
68
  **A team that cannot be read.** One line shows in its place. It says if the team password was changed, if the host locked the team after too many wrong tries, or if the host did not answer. Press **Paste a new invite link** or **Refresh**. The other teams still show. PMOS shows no old rows for a team it cannot read.
58
69
 
59
70
  **Leave a team.** Press the chip of the team. Then press **Leave team**, and press it a second time. PMOS forgets the team and its password on this computer. The copies that you published stay on the host. Join again with the invite link.
@@ -66,7 +77,8 @@ The tab is not in the read-only Board. A reader sees no Shared tab.
66
77
  - On the Timeline, the view opens on today.
67
78
  - On the Timeline, a milestone ◇ sits on its date, the same as on your own Board. A reader cannot press it.
68
79
  - The span picker, the Deliveries lane, and the folds of a program and a product.
69
- - One line above the Board. It names the selection, the date of the copy, who shared it and when the link ends.
80
+ - One line above the Board. It names the selection, the date of the copy, who shared it and when the link ends. It ends with **Open in my PMOS** and **Get PMOS**.
81
+ - One tab for each pick, when the link holds two or more products or programs.
70
82
  - A viewer for each initiative. Press the name of an initiative to open it, on the stage track or on the Timeline. The viewer shows the stage card, About and Tasks. Tasks show for every initiative, also when a stage has a note such as "The rest, by work package". Tasks show as a List or as a Timeline, grouped by stage, owner or status.
71
83
  - The panel divider and the panel layout: full screen, split or slide over.
72
84
 
@@ -190,4 +190,47 @@ function buildPack({ ws, board, selection, answers, meta }) {
190
190
  return pack;
191
191
  }
192
192
 
193
- module.exports = { VIEWS, SCHEMA, MAX_BYTES, KINDS, MODEL_KEYS, buildPack, assertSize, teammateDash };
193
+ /** The name of a link of several picks (batch 27 item 1, ShA): the first three names, then "+ N more". */
194
+ function nameOf(labels) {
195
+ const l = (labels || []).map((x) => String(x || '').trim()).filter(Boolean);
196
+ return l.length > 3 ? l.slice(0, 3).join(' + ') + ' + ' + (l.length - 3) + ' more' : l.join(' + ');
197
+ }
198
+
199
+ /** One pack for several products and programs (batch 27 item 1, ShA): one link, one tab per pick.
200
+ * packs [pack] one per product, each made by buildPack for that product's picks, so each is swept as it is today
201
+ * parts [{ label, ws, board, program?, kind? }] the tabs, in pick order: a product's Board, or a program's page opened on its home product
202
+ * name the link's name (nameOf the parts' labels when the PM did not rename it)
203
+ * The answers are joined: each product's keys name its ws, so they do not meet. The programs list is the one answer every product carries:
204
+ * a program is joined by id, its members being the union of what each product's pack kept. The 2 MB check runs on the whole. */
205
+ function combinePacks(packs, parts, name) {
206
+ const list = (packs || []).filter((p) => p && p.schema === SCHEMA && p.responses);
207
+ if (!list.length) plain('There is nothing to publish.');
208
+ const first = list[0];
209
+ const responses = {}; const progs = new Map();
210
+ for (const pk of list) {
211
+ for (const k of Object.keys(pk.responses)) {
212
+ if (k === '/api/programs') {
213
+ for (const p of (pk.responses[k] && pk.responses[k].programs) || []) {
214
+ if (!p || !p.id) continue;
215
+ const had = progs.get(p.id);
216
+ if (!had) { progs.set(p.id, clone(p)); continue; }
217
+ const seen = new Set((had.members || []).map((m) => m.ws + '|' + m.initiative));
218
+ for (const m of p.members || []) if (m && !seen.has(m.ws + '|' + m.initiative)) { had.members.push(clone(m)); seen.add(m.ws + '|' + m.initiative); }
219
+ }
220
+ } else if (!(k in responses)) responses[k] = pk.responses[k];
221
+ }
222
+ }
223
+ responses['/api/programs'] = { ok: true, programs: [...progs.values()] };
224
+ const sorted = {}; for (const k of Object.keys(responses).sort()) sorted[k] = responses[k];
225
+ const tabs = (parts || []).filter((t) => t && t.ws).map((t) => Object.assign({ label: String(t.label || t.ws), ws: String(t.ws), board: String(t.board || t.ws) }, t.program ? { program: String(t.program) } : {}));
226
+ const label = String(name || '').trim() || nameOf(tabs.map((t) => t.label));
227
+ const pack = { schema: SCHEMA, viewer: first.viewer || '', scope: { kind: 'multi', id: tabs.map((t) => t.ws + (t.program ? ':' + t.program : '')).join(','), label }, by: first.by || '', at: first.at || '', ends: first.ends || '' };
228
+ if (first.hub) pack.hub = first.hub;
229
+ if (first.view) pack.view = first.view;
230
+ if (tabs.length) pack.parts = tabs; // the read-only Board opens on the first, and draws the tab row only for two or more
231
+ pack.responses = sorted;
232
+ assertSize(pack);
233
+ return pack;
234
+ }
235
+
236
+ module.exports = { VIEWS, SCHEMA, MAX_BYTES, KINDS, MODEL_KEYS, buildPack, assertSize, teammateDash, combinePacks, nameOf };
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
  /* board-publish-choices.js — what can be published from a board (initiative board-publish, slice 2, page side; PRD P1): its product, the programs that hold
3
3
  * one of its initiatives, and its initiatives. Read from the Board's own routes, the way lib/board-pack-route.js reads them.
4
- * choicesOf(req, ctx, ws, board) -> { products, programs, initiatives } | null while the board is still being read */
4
+ * choicesOf(req, ctx, ws, board) -> { products, programs, initiatives } | null while the board is still being read
5
+ * contextOf(req, ctx, ws, board) -> { model, programs, boards } | null what a publish of several picks needs (lib/board-publish-parts.js) */
5
6
  const BoardPremium = require('./routes-board-premium.js');
6
7
  const Programs = require('./routes-programs.js');
7
8
 
@@ -13,13 +14,28 @@ async function via(mod, pathAndQuery, req, ctx) {
13
14
  return handled === false ? null : out;
14
15
  }
15
16
 
16
- async function choicesOf(req, ctx, ws, board) {
17
+ /** What a publish needs to know besides the picks: every program (with its members), and the products (boards) this workspace has, from the
18
+ * board on screen's own switcher. { model, programs, boards } | null while the board is still being read. */
19
+ async function contextOf(req, ctx, ws, board) {
17
20
  const b = await via(BoardPremium, '/api/board-premium?ws=' + encodeURIComponent(ws) + '&board=' + encodeURIComponent(board), req, ctx);
18
21
  if (!b || b.ok === false || !b.model) return null;
19
- const rows = (b.model.rows || []).filter((r) => r && r.id); const have = new Set(rows.map((r) => r.id));
20
22
  const pg = await via(Programs, '/api/programs', req, ctx);
21
- const programs = ((pg && pg.programs) || []).filter((p) => p && p.id && (p.members || []).some((m) => m && m.ws === ws && have.has(m.initiative))).map((p) => ({ id: p.id, label: p.title || p.id }));
22
- return { products: [{ id: board, label: (b.model.board && b.model.board.name) || board }], programs, initiatives: rows.map((r) => ({ id: r.id, label: r.title || r.id })) };
23
+ const here = { id: board, ws, name: (b.model.board && b.model.board.name) || board };
24
+ const boards = [here].concat(((b.model.board && b.model.board.switcher) || []).filter((x) => x && x.ws && !(x.ws === ws && x.id === board)));
25
+ return { model: b.model, programs: ((pg && pg.programs) || []).filter((p) => p && p.id && !p.archived), boards };
26
+ }
27
+
28
+ /** The Share sheet's chips (batch 27 item 1, ShA): the board on screen first, then the other products; every program that holds an initiative of a
29
+ * product here, with the products it reaches; and the initiatives of the board on screen. A program says which products it reaches (`reach`). */
30
+ async function choicesOf(req, ctx, ws, board) {
31
+ const c = await contextOf(req, ctx, ws, board);
32
+ if (!c) return null;
33
+ const rows = (c.model.rows || []).filter((r) => r && r.id); const have = new Set(rows.map((r) => r.id));
34
+ const nameOf = new Map(c.boards.map((x) => [x.ws, x.name]));
35
+ const programs = c.programs.filter((p) => (p.members || []).some((m) => m && (m.ws === ws ? have.has(m.initiative) : nameOf.has(m.ws))))
36
+ .map((p) => ({ id: p.id, label: p.title || p.id, reach: [...new Set((p.members || []).map((m) => m && nameOf.get(m.ws)).filter(Boolean))].join(', ') }));
37
+ const products = c.boards.map((x, i) => Object.assign({ id: x.id, ws: x.ws, label: x.name || x.id }, i === 0 ? { n: rows.length } : {}));
38
+ return { products, programs, initiatives: rows.map((r) => ({ id: r.id, label: r.title || r.id })) };
23
39
  }
24
40
 
25
- module.exports = { choicesOf };
41
+ module.exports = { choicesOf, contextOf };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+ /* board-publish-parts.js — a publish of several products and programs in one link (batch 27 item 1, ShA; initiative board-share-several).
3
+ *
4
+ * A PART is one pick of the Share sheet, in pick order: { kind: 'product', ws, board } · { kind: 'program', id } · { kind: 'initiative', id, ws, board }.
5
+ * A program can reach several products: it is picked by id, and its products come from its members (GET /api/programs).
6
+ *
7
+ * groupsOf(parts, programs) -> { groups: [{ ws, board, select: 'kind:id,…' }], tabs: [{ label?, ws, board, program?, kind, id }] } | { error }
8
+ * one group per product the picks touch (its selection is the union of the picks there), one tab per pick. Pure.
9
+ * packOf(parts, { programs, boards, name, make }) -> { ok, pack, kind, label } | { ok: false, status, error }
10
+ * make(group) -> { ok, pack } is lib/routes-board-publish.js makePack: each product's pack is made and swept as today,
11
+ * then lib/board-pack.js combinePacks joins them. A product that cannot be read stops the publish with its sentence.
12
+ * parseParts(raw) -> [part] | null the parts of a request, checked; null when the request is not one of parts */
13
+ const Pack = require('./board-pack.js');
14
+
15
+ const MAX_PARTS = 12;
16
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
17
+
18
+ function parseParts(raw) {
19
+ if (!Array.isArray(raw) || !raw.length) return null;
20
+ const out = [];
21
+ for (const p of raw.slice(0, MAX_PARTS + 1)) {
22
+ if (!p || typeof p !== 'object') return null;
23
+ const kind = String(p.kind || '');
24
+ if (kind === 'program') { if (!ID.test(String(p.id || ''))) return null; out.push({ kind, id: String(p.id) }); continue; }
25
+ if (kind !== 'product' && kind !== 'initiative') return null;
26
+ const ws = String(p.ws || ''); const board = String(p.board || ws);
27
+ if (!ID.test(ws) || !ID.test(board)) return null;
28
+ if (kind === 'initiative' && !ID.test(String(p.id || ''))) return null;
29
+ out.push(kind === 'product' ? { kind, ws, board } : { kind, id: String(p.id), ws, board });
30
+ }
31
+ return out.length > MAX_PARTS ? null : out;
32
+ }
33
+
34
+ function groupsOf(parts, programs, boards) {
35
+ const G = new Map(); const tabs = [];
36
+ const boardOf = (ws) => ((boards || []).find((b) => b && b.ws === ws) || {}).id || ws;
37
+ const add = (ws, board, sel) => { const g = G.get(ws) || { ws, board, select: [] }; if (!g.select.includes(sel)) g.select.push(sel); G.set(ws, g); return g; };
38
+ for (const p of parts) {
39
+ if (p.kind === 'product') { add(p.ws, p.board, 'product:' + p.board); tabs.push({ kind: 'product', ws: p.ws, board: p.board }); continue; }
40
+ if (p.kind === 'initiative') { add(p.ws, p.board, 'initiative:' + p.id); tabs.push({ kind: 'initiative', id: p.id, ws: p.ws, board: p.board }); continue; }
41
+ const prog = (programs || []).find((x) => x && x.id === p.id);
42
+ if (!prog) return { error: 'The program "' + p.id + '" is not in this workspace.' };
43
+ const wss = [...new Set((prog.members || []).map((m) => m && m.ws).filter(Boolean))];
44
+ if (!wss.length) return { error: prog.title ? prog.title + ' has no initiatives to share yet.' : 'This program has no initiatives to share yet.' };
45
+ for (const ws of wss) add(ws, boardOf(ws), 'program:' + p.id);
46
+ const home = prog.home && wss.includes(prog.home) ? prog.home : wss[0];
47
+ tabs.push({ kind: 'program', id: p.id, ws: home, board: boardOf(home), program: p.id, label: prog.title || p.id });
48
+ }
49
+ return { groups: [...G.values()].map((g) => Object.assign({}, g, { select: g.select.join(',') })), tabs };
50
+ }
51
+
52
+ /** The label of a product or initiative tab, read from that product's own pack: the board's name, or the initiative's title. */
53
+ function labelFrom(pack, tab) {
54
+ const key = Object.keys((pack && pack.responses) || {}).find((k) => k.startsWith('/api/board-premium?') && new URLSearchParams(k.split('?')[1]).get('ws') === tab.ws);
55
+ const model = key && pack.responses[key] && pack.responses[key].model;
56
+ if (tab.kind === 'initiative') { const r = ((model && model.rows) || []).find((x) => x && x.id === tab.id); return (r && r.title) || tab.id; }
57
+ return (model && model.board && (model.board.product || model.board.name)) || tab.ws;
58
+ }
59
+
60
+ async function packOf(parts, o) {
61
+ const g = groupsOf(parts, o.programs, o.boards);
62
+ if (g.error) return { ok: false, status: 400, error: g.error };
63
+ const packs = new Map();
64
+ for (const grp of g.groups) {
65
+ const r = await o.make(grp);
66
+ if (!r.ok) {
67
+ // a product a program only reaches, with nothing of the program on it now: left out, the program's other products still go
68
+ if (r.status === 400 && /not on this board/.test(r.error) && !parts.some((p) => p.ws === grp.ws)) continue;
69
+ return r;
70
+ }
71
+ packs.set(grp.ws, r.pack);
72
+ }
73
+ if (!packs.size) return { ok: false, status: 400, error: 'Nothing in these picks can be shared yet.' };
74
+ // one tab per pick; an initiative joins its product's tab when that product is picked too, and initiatives of one product share a tab
75
+ const tabs = []; const seen = new Map();
76
+ for (const t of g.tabs) {
77
+ if (!packs.has(t.ws)) continue;
78
+ const key = t.ws + '|' + (t.program || '');
79
+ const label = t.label || labelFrom(packs.get(t.ws), t);
80
+ if (seen.has(key)) { const was = seen.get(key); if (was.kind === 'initiative' && t.kind === 'product') { was.kind = 'product'; was.label = label; } continue; }
81
+ const tab = { kind: t.kind, label, ws: t.ws, board: t.board }; if (t.program) tab.program = t.program;
82
+ seen.set(key, tab); tabs.push(tab);
83
+ }
84
+ if (packs.size === 1 && tabs.length === 1 && !tabs[0].program) { // one pick on one product: the pack as it is today, renamed when the PM did
85
+ const pack = [...packs.values()][0]; if (String(o.name || '').trim()) pack.scope = Object.assign({}, pack.scope, { label: String(o.name).trim().slice(0, 120) });
86
+ return { ok: true, pack, kind: (pack.scope && pack.scope.kind) || tabs[0].kind, label: pack.scope.label };
87
+ }
88
+ try {
89
+ const pack = Pack.combinePacks([...packs.values()], tabs, o.name);
90
+ const kind = tabs.length > 1 ? 'multi' : (tabs[0] && tabs[0].kind) || 'multi';
91
+ return { ok: true, pack, kind, label: pack.scope.label };
92
+ } catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
93
+ }
94
+
95
+ module.exports = { MAX_PARTS, parseParts, groupsOf, packOf };