create-pmos 0.494.0 → 0.496.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 (80) hide show
  1. package/package.json +75 -27
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +2 -2
  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/payload/state/log.md +1 -1
  8. package/workspace/adler-moments-test.js +3 -1
  9. package/workspace/board-programs-test.js +6 -6
  10. package/workspace/board-shared-ui-test.js +2 -2
  11. package/workspace/briefing-premium-adddoc-test.js +3 -49
  12. package/workspace/briefing-premium-board-test.js +23 -23
  13. package/workspace/briefing-premium-initiative-test.js +2 -1
  14. package/workspace/changelog.json +74 -0
  15. package/workspace/guide/adler.md +1 -1
  16. package/workspace/guide/briefing-premium.md +3 -1
  17. package/workspace/guide/claude-proposes.md +6 -2
  18. package/workspace/guide/data-freshness.md +2 -0
  19. package/workspace/guide/home.md +1 -1
  20. package/workspace/guide/initiatives-screen.md +1 -1
  21. package/workspace/guide/published-board.md +1 -1
  22. package/workspace/guide/report-slides.md +6 -6
  23. package/workspace/guide/reports.md +1 -1
  24. package/workspace/guide/workflows.md +18 -5
  25. package/workspace/ini-workflow-test.js +2 -1
  26. package/workspace/lib/board-premium.js +17 -2
  27. package/workspace/lib/engine/workflows.js +19 -6
  28. package/workspace/lib/initiative-chat.js +4 -1
  29. package/workspace/lib/initiative-cli.js +15 -1
  30. package/workspace/lib/open-mission.js +14 -0
  31. package/workspace/lib/proposals.js +18 -4
  32. package/workspace/lib/report-export.js +5 -3
  33. package/workspace/lib/report-pyramid.js +134 -33
  34. package/workspace/lib/report-templates.js +33 -3
  35. package/workspace/lib/routes-board-premium.js +2 -2
  36. package/workspace/lib/routes-boards.js +35 -2
  37. package/workspace/lib/routes-brief-page.js +2 -1
  38. package/workspace/lib/routes-initiatives.js +1 -0
  39. package/workspace/lib/routes-proposals.js +14 -2
  40. package/workspace/lib/routes-report-design.js +1 -1
  41. package/workspace/lib/routes-report-flow.js +11 -4
  42. package/workspace/lib/routes-report-setups.js +3 -2
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/split-merge-suggest.js +37 -3
  45. package/workspace/lib/split-merge.js +10 -4
  46. package/workspace/lib/task-cli.js +5 -1
  47. package/workspace/lib/workflow-cli.js +70 -15
  48. package/workspace/lib/workflows-store.js +15 -1
  49. package/workspace/metric-drafts-ui-test.js +2 -1
  50. package/workspace/package-lock.json +2 -2
  51. package/workspace/package.json +1 -1
  52. package/workspace/premium-board-actions-test.js +9 -10
  53. package/workspace/premium-core-doc-issue-test.js +9 -38
  54. package/workspace/premium-vw-backlog-test.js +3 -2
  55. package/workspace/premium-vw-item-test.js +3 -2
  56. package/workspace/premium-vw-jira-test.js +3 -2
  57. package/workspace/program-panel-test.js +2 -1
  58. package/workspace/public/pm/cozy-board.css +17 -5
  59. package/workspace/public/pm/cozy-home.css +1 -0
  60. package/workspace/public/pm/home-app.js +15 -5
  61. package/workspace/public/pm/home-chat.js +49 -8
  62. package/workspace/public/pm/home-today.js +11 -4
  63. package/workspace/public/pm/ini-view.js +2 -2
  64. package/workspace/public/pm/load-shapes.css +57 -0
  65. package/workspace/public/pm/load-shapes.js +57 -0
  66. package/workspace/public/pm/premium-board.js +82 -57
  67. package/workspace/public/pm/premium-shell.js +57 -6
  68. package/workspace/public/pm/premium.css +1 -0
  69. package/workspace/public/pm/readonly.js +3 -3
  70. package/workspace/public/pm/split-merge.js +3 -2
  71. package/workspace/public/pm/wf-page.css +8 -0
  72. package/workspace/public/pm/wf-page.js +33 -5
  73. package/workspace/public/premium-board.html +7 -10
  74. package/workspace/public/premium.html +1 -0
  75. package/workspace/skills/report-template.skill +4 -2
  76. package/workspace/split-merge-ui-test.js +4 -3
  77. package/workspace/stage-card-test.js +2 -1
  78. package/workspace/task-press-test.js +2 -1
  79. package/workspace/test-lists/page-open-speed.json +1 -1
  80. package/workspace/tk-tasks-test.js +2 -1
@@ -5,8 +5,14 @@
5
5
  * suggest(rows, items, { dismissed, today }) → Map<initiative id, suggestion>
6
6
  * rows the Board model's open rows (lib/board-premium.js rowOf: id, title, anchor, kr, epics, end, wfStage, status)
7
7
  * items the board's work items (routes-boards itemsOf): PMOS tasks and Jira tickets, each with ini, status, owner, start, end, epic
8
- * dismissed Map<id, { kind, fp }> the PM's ✕ Not now (the boards ledger's ini-suggest lines); the same fingerprint stays hidden
8
+ * dismissed Map<id, [{ kind, fp, moves?, into? }]> the PM's ✕ (dismissedOf(the boards ledger's events): the last of each kind; one
9
+ * entry alone is read too). A REJECTION HOLDS WHILE ITS FACTS HOLD (batch 24 item 6): a split stays hidden while most of the group it
10
+ * would move is the group the PM said no to; a merge stays hidden while it merges into the same initiative. Editing, re-dating,
11
+ * re-sectioning (a workflow switch) or adding a task does not bring it back; a different group or a different partner does.
12
+ * A ✕ written before this rule has no group: it hides that kind on that initiative.
9
13
  * fingerprint(list) → the 16 hex characters a suggestion is known by: it changes when the tasks or tickets it was made from change
14
+ * rejected(s, d) → true when the ✕ d still covers the suggestion s
15
+ * dismissedOf(events) → the dismissed map from the boards ledger's folded events
10
16
  *
11
17
  * It runs on every read of the Board, so it follows the tasks and tickets as they change, never a timer. It only suggests: the card's ✓ is
12
18
  * the PM's approval (lib/split-merge.js writes nothing without a page press). One card per initiative; a merge wins over a split.
@@ -155,8 +161,36 @@ function suggest(rows, items, { dismissed } = {}) {
155
161
  const s = splitOf(r, workBy.get(r.id) || []);
156
162
  if (s) out.set(r.id, s);
157
163
  }
158
- if (dismissed && dismissed.size) for (const [id, s] of [...out]) { const d = dismissed.get(id); if (d && d.fp === s.fp) out.delete(id); }
164
+ if (dismissed && dismissed.size) for (const [id, s] of [...out]) {
165
+ const ds = [].concat(dismissed.get(id) || []);
166
+ if (ds.some((d) => rejected(s, d))) out.delete(id);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** The PM's ✕ lines from the boards ledger's events (fold().events): Map<id, [{ kind, fp, moves?, into? }]>, the last of each kind. */
172
+ function dismissedOf(events) {
173
+ const out = new Map();
174
+ for (const e of events || []) {
175
+ if (!e || e.kind !== 'ini-suggest' || e.answer !== 'dismiss' || typeof e.id !== 'string' || typeof e.fp !== 'string') continue;
176
+ const kind = e.of === 'merge' ? 'merge' : 'split', d = { kind, fp: e.fp };
177
+ if (Array.isArray(e.moves)) d.moves = e.moves.filter((x) => typeof x === 'string' && x);
178
+ if (typeof e.into === 'string' && e.into) d.into = e.into;
179
+ out.set(e.id, (out.get(e.id) || []).filter((x) => x.kind !== kind).concat([d]));
180
+ }
159
181
  return out;
160
182
  }
161
183
 
162
- module.exports = { suggest, fingerprint, openWork };
184
+ /** True when the PM's ✕ d still covers the suggestion s: the same facts, not the same fingerprint. */
185
+ function rejected(s, d) {
186
+ if (!d || !s || d.kind !== s.kind) return false;
187
+ if (d.fp && d.fp === s.fp) return true;
188
+ if (s.kind === 'merge') return d.into ? d.into === s.into : true;
189
+ if (!Array.isArray(d.moves)) return true; // a ✕ from before the rule: it named no group
190
+ const was = new Set(d.moves), now = [...(s.tasks || []), ...(s.tickets || [])];
191
+ if (!now.length) return true;
192
+ const same = now.filter((x) => was.has(x)).length;
193
+ return same * 2 >= now.length; // most of what would move is what the PM said no to
194
+ }
195
+
196
+ module.exports = { suggest, fingerprint, openWork, rejected, dismissedOf };
@@ -7,7 +7,7 @@
7
7
  * merge(ctx, w, body, actor) POST /api/initiatives/merge { ws, from, into, anchor? }
8
8
  * → { ok, from, into, title, counts: { tasks, tickets, docs, chats, milestones }, undo }
9
9
  * undo(ctx, w, body, actor) POST /api/initiatives/sm-undo { ws, undo } → { ok, kind, from, to }
10
- * dismiss(ctx, w, body, actor) POST /api/initiatives/suggest { ws, id, of: split|merge, fp } Claude's suggestion, ✕ Not now
10
+ * dismiss(ctx, w, body, actor) POST /api/initiatives/suggest { ws, id, of: split|merge, fp, moves?, into? } Claude's suggestion, ✕ Not now
11
11
  *
12
12
  * Claude only suggests (lib/split-merge-suggest.js); every write here is a page press (pm-guard requirePagePress in the caller), so nothing
13
13
  * splits or merges without the PM's ✓ or the editor's own button. All of it is file mode:
@@ -204,7 +204,8 @@ function split(ctx, w, body, actor) {
204
204
  const own = folded.iniWf && folded.iniWf.get(k0);
205
205
  const oldTpl = folded.iniWorkflows && folded.iniWorkflows.get(k0);
206
206
  if (oldTpl) R.append(file, actor, { kind: 'ini-workflow', board: board.id, id, template: oldTpl.template, stages: oldTpl.stages, moved: null });
207
- if (own) R.append(file, actor, { kind: 'ini-wf', board: board.id, id, wf: own.wf, from: own.wf, moved: null, why });
207
+ // the stage map goes with it (batch 24 item 5): the tasks it takes keep the stage the last switch put them in
208
+ if (own) R.append(file, actor, { kind: 'ini-wf', board: board.id, id, wf: own.wf, from: own.wf, moved: null, why, ...(own.map ? { map: own.map } : {}) });
208
209
  if (at.st) R.append(file, actor, { kind: 'move', board: board.id, id, from: null, to: own ? at.st.key : at.st.name, why, warnings: [] });
209
210
  for (const t of tasks) { R.append(file, actor, { kind: 'task-update', board: t.board, task: t.id, initiative: id, why }); undo.tasks.push(t.id); }
210
211
  undo.items = copyItems(w.root, items, id, actor);
@@ -364,14 +365,19 @@ function undo(ctx, w, body, actor) {
364
365
  return { ok: true, kind: line.kind === 'ini-split' ? 'split' : 'merge', from, to, fromTitle: line.fromTitle, toTitle: line.toTitle, ...(note ? { note } : {}) };
365
366
  }
366
367
 
367
- /** ✕ Not now on Claude's suggestion: hidden until the tasks or tickets it was made from change (a new fingerprint). */
368
+ /** ✕ on Claude's suggestion: it stays rejected while its facts hold (the same group would move, the same partner to merge into). */
368
369
  function dismiss(ctx, w, body, actor) {
369
370
  const R = Boards(), file = R.ledgerFile(ctx, w);
370
371
  if (!file) return refuse(409, R.NO_STATE || 'this workspace has no state folder');
371
372
  const id = String(body.id || ''), fp = String(body.fp || '');
372
373
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(id)) return refuse(400, 'id names an initiative');
373
374
  if (!/^[0-9a-f]{8,64}$/.test(fp)) return refuse(400, 'fp is the suggestion\'s fingerprint');
374
- R.append(file, actor, { kind: 'ini-suggest', id, of: body.of === 'merge' ? 'merge' : 'split', fp, answer: 'dismiss' });
375
+ const of = body.of === 'merge' ? 'merge' : 'split', line = { kind: 'ini-suggest', id, of, fp, answer: 'dismiss' };
376
+ // the facts the ✕ rests on, so an edit, a new date or a new section does not bring the suggestion back (batch 24 item 6)
377
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/;
378
+ if (of === 'split' && Array.isArray(body.moves)) line.moves = body.moves.map(String).filter((x) => ID.test(x)).slice(0, 2000);
379
+ if (of === 'merge' && typeof body.into === 'string' && ID.test(body.into)) line.into = body.into;
380
+ R.append(file, actor, line);
375
381
  return { ok: true };
376
382
  }
377
383
 
@@ -150,7 +150,11 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
150
150
  const F = boards.foldOf(ctx, w), S = F.sections || new Map(), MS = F.milestones || new Map(), PN = F.pins || new Map();
151
151
  const pinOf = (tid) => (PN.has(tid) ? { pinned: PN.get(tid) } : {}); // the PM's pin on the Board timeline (batch 13 item 4); unset = the rule
152
152
  const msOf = (tid, own) => (MS.has(tid) ? MS.get(tid) : !!own); // the PM's ◇ flag (feedback batch 11 item 3)
153
- const stageOf = (tid, from) => (S.has(tid) ? S.get(tid) : (from && from.stage) || ''); // the group the Tasks section shows it under
153
+ // the group the Tasks section shows it under: a stage of the workflow it ran before a switch reads as the stage that switch put it in
154
+ // (row.wfMap, batch 24 item 5), as on the page
155
+ const keys = r.ok ? (r.row.wfStages || []).map((s) => s.key) : [], M = (r.ok && r.row.wfMap) || {};
156
+ const placed = (k) => (k && !keys.includes(k) && keys.includes(M[k]) ? M[k] : k);
157
+ const stageOf = (tid, from) => placed(S.has(tid) ? S.get(tid) : (from && from.stage) || '');
154
158
  const own = [...(F.tasks || new Map()).values()].filter((t) => t.initiative === id).map((t) => ({ ...t, stage: stageOf(t.id, t.from), milestone: msOf(t.id), ...pinOf(t.id) }));
155
159
  const items = TI.rowsOf(root, new Set([id]), board).map((x) => ({ id: x.uid, board, kind: 'Task', title: x.title, state: x.status, owner: x.owner, start: x.start || null, due: x.end, store: 'item', stage: stageOf(x.uid), milestone: msOf(x.uid, x.milestone), ...pinOf(x.uid) }));
156
160
  return own.concat(items);
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
  /* workflow-cli.js — `pmos workflow …`: what an agent writes about workflows (D99, initiative workflows-per-initiative; canvas WfADraft, WfACond).
3
3
  *
4
- * pmos workflow list the workflows of this repository, Product feature first: id, name, stages
5
- * pmos workflow show <id> one workflow's stages, numbered, with their category, output and when they run
4
+ * pmos workflow list every workflow the PM can pick, Product feature first: id, name, stages. This repository's
5
+ * library, then the home workspace's (Settings › Workflows saves there) and every other open one
6
+ * pmos workflow show <id> one workflow's stages, numbered, with their category, output and when they run (any library above)
6
7
  * pmos workflow draft --file <path|-> [--note <text>]
7
8
  * writes THE draft the New workflow chat shows in the Viewer (one wf-draft line). The JSON is one workflow { name, icon?, decide?,
8
9
  * stages: [...] }, checked as a save is, so the page never shows a draft that Create would refuse. A new draft replaces the one before.
@@ -23,7 +24,14 @@
23
24
  * WHO WRITES. These write through lib/workflows-store.js and lib/routes-boards.js, never over HTTP. An agent may draft, judge, and move or
24
25
  * switch an initiative when the PM asks; it never saves, copies or archives a workflow: Create, Copy and Archive are the PM's presses on the
25
26
  * Workflows page. Who wrote a line is the call path (lib/actor.js), never an
26
- * argument. Exit codes: 0 done, 1 refused, 2 usage. Nothing is sent. */
27
+ * argument. Exit codes: 0 done, 1 refused, 2 usage. Nothing is sent.
28
+ *
29
+ * THE LIBRARY IS NOT IN THE REPOSITORY'S FILES (batch 24 item 8). Settings › Workflows saves to the HOME workspace's workflows.jsonl, and
30
+ * a product repository holds only the workflows its initiatives were switched to (lib/routes-boards.js switchWorkflow adopts them, #1089).
31
+ * An initiative chat in a product repository searched the repo, found no "Simpler Feature" and fell back to the feature LANE (D56) — a
32
+ * different thing from a D99 workflow. So list, show and switch read the other workspaces too: PMOS_SELF_ROOT (the launcher's home), the
33
+ * PMOS checkout this CLI runs from when it is PMOS-self, and every repository in the server's registry (workspaces.json). A caller that
34
+ * passes its own env (a test) gets only the homes it passes. */
27
35
  const fs = require('fs');
28
36
  const path = require('path');
29
37
  const { execFileSync } = require('child_process');
@@ -32,12 +40,14 @@ const driver = require('./driver.js');
32
40
  const WS = require('./workflows-store.js');
33
41
  const { repoScript } = require('./repo-script.js');
34
42
  const WF = require(repoScript('workflows.js'));
43
+ const stateRoot = require('./state-root.js');
35
44
 
36
45
  const COMMANDS = { workflow: ['list', 'show', 'draft', 'show-draft', 'judge', 'of', 'move', 'switch'] };
37
46
  const USAGE = [
38
47
  'usage: pmos workflow list|show|draft|show-draft|judge|of|move|switch — the workflows each kind of initiative goes through (D99)',
39
- ' pmos workflow list the workflows of this repository',
48
+ ' pmos workflow list every workflow the PM can pick: this repository\'s and the ones saved in Settings',
40
49
  ' pmos workflow show <id> one workflow, its stages numbered',
50
+ ' The workflows are not files in the repository: never search the files for one, and never use a lane (feature, chore…) instead.',
41
51
  ' pmos workflow draft --file <path|-> [--note <text>]',
42
52
  ' Write the draft the New workflow chat shows beside it. The PM presses Create workflow; you never save one.',
43
53
  ' pmos workflow show-draft the draft now open',
@@ -81,10 +91,51 @@ function readStdin(stdin) {
81
91
  if (typeof stdin === 'string') return stdin;
82
92
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
83
93
  }
94
+ /** The roots of the other workspaces whose libraries an agent reads: home first, then the server's registry. */
95
+ function homeRoots(env) {
96
+ const out = [];
97
+ const dir = (p) => { try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; } };
98
+ if (env.PMOS_SELF_ROOT && dir(env.PMOS_SELF_ROOT)) out.push(path.resolve(env.PMOS_SELF_ROOT));
99
+ const self = path.resolve(__dirname, '..', '..');
100
+ if (!/[\\/]node_modules[\\/]/.test(__dirname) && (fs.existsSync(path.join(self, 'DECISIONS.md')) || fs.existsSync(path.join(self, 'AGENTS.md')))) out.push(self);
101
+ let reg = [];
102
+ try { reg = JSON.parse(fs.readFileSync(path.join(stateRoot.defaultRoot({ env }), 'workspaces.json'), 'utf8')); } catch { reg = []; }
103
+ if (Array.isArray(reg)) for (const x of reg) if (x && typeof x.root === 'string' && dir(x.root)) out.push(path.resolve(x.root));
104
+ return out;
105
+ }
106
+ /** The other workspaces, as { id, name, root, stateDir }: not this repository, each once, only those with a state folder. */
107
+ function othersOf(root, homes) {
108
+ const seen = new Set([path.resolve(root)]);
109
+ const out = [];
110
+ for (const r of homes || []) {
111
+ const abs = path.resolve(r);
112
+ if (seen.has(abs)) continue;
113
+ seen.add(abs);
114
+ const d = driver.stateDirOf(abs);
115
+ if (d) out.push({ id: driver.wsIdOf(abs), name: path.basename(abs), root: abs, stateDir: d });
116
+ }
117
+ return out;
118
+ }
119
+ /** Every workflow the PM can pick: this library's, then each other workspace's that this one lacks (not archived there), with where it is. */
120
+ function allWorkflows(stateDir, others) {
121
+ const own = WF.list(WS.library(stateDir));
122
+ const ids = new Set(own.map((w) => w.id));
123
+ const more = [];
124
+ for (const o of others) {
125
+ for (const w of WF.list(WS.library(o.stateDir))) if (!ids.has(w.id) && !w.archived) { ids.add(w.id); more.push({ ...w, from: o.name }); }
126
+ }
127
+ return [...own, ...more];
128
+ }
129
+ function workflowById(stateDir, others, id) {
130
+ const own = WS.library(stateDir).byId.get(id);
131
+ if (own) return own;
132
+ for (const o of others) { const w = WS.library(o.stateDir).byId.get(id); if (w) return { ...w, from: o.name }; }
133
+ return null;
134
+ }
84
135
  const WHEN_WORDS = (s) => (s.when && s.when.mode === 'only' ? 'only when ' + s.when.text : s.when && s.when.mode === 'optional' ? 'optional' : 'always');
85
136
  const stageLine = (s, i) => `${String(i + 1).padStart(2)}. ${s.milestone ? '◆ ' : ''}${s.name} [${s.key}] · ${WF.CAT_LABEL[s.cat]} · ${s.output && s.output.name ? s.output.name + ' (' + s.output.kind + ')' : s.output.kind} · ${WHEN_WORDS(s)}`;
86
137
 
87
- async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, stdin } = {}) {
138
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, stdin, homes } = {}) {
88
139
  const args = argv[0] === 'workflow' ? argv.slice(1) : argv.slice();
89
140
  const verb = args[0];
90
141
  const { pos, flags } = parse(args.slice(1));
@@ -102,16 +153,17 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
102
153
  if (!stateDir) return fail('this repository has no PMOS state folder (pmos/state, state or .pmos)');
103
154
  const e = env === undefined ? process.env : env;
104
155
  const actor = actorLib.fromCli({ isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env: e });
156
+ const others = othersOf(root, homes !== undefined ? homes : env === undefined ? homeRoots(e) : []);
105
157
 
106
158
  if (verb === 'list') {
107
- const all = WF.list(WS.library(stateDir));
108
- if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived })) })); return 0; }
109
- for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}`);
159
+ const all = allWorkflows(stateDir, others);
160
+ if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived, default: !!w.default, ...(w.from ? { from: w.from } : {}) })) })); return 0; }
161
+ for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}${w.default ? ' · default for new initiatives' : ''}${w.from ? ' · saved in ' + w.from : ''}`);
110
162
  return 0;
111
163
  }
112
164
  if (verb === 'show') {
113
165
  const id = pos[0]; if (!id) { say('usage: pmos workflow show <id>'); return 2; }
114
- const w = WS.library(stateDir).byId.get(id);
166
+ const w = workflowById(stateDir, others, id);
115
167
  if (!w) return fail(`no workflow ${id} — pmos workflow list names them`);
116
168
  if (json) { say(JSON.stringify({ ok: true, workflow: w })); return 0; }
117
169
  say(`${w.name} (${w.id}) · ${w.decide === 'confirm' ? 'PMOS suggests, the PM confirms' : 'PMOS decides and tells why'}`);
@@ -140,7 +192,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
140
192
  else say(`✓ drafted ${r.draft.name} with ${r.draft.stages.length} stages. The Workflows page shows it beside the chat; the PM presses Create workflow. Nothing was sent.`);
141
193
  return 0;
142
194
  }
143
- if (verb === 'of' || verb === 'move' || verb === 'switch') return onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor });
195
+ if (verb === 'of' || verb === 'move' || verb === 'switch') return onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor, others });
144
196
  // judge
145
197
  if (!flags.board || !flags.id || !flags.stage) { say('usage: pmos workflow judge --board <id> --id <initiative> --stage <key> --runs yes|no|unsure --why <reason> [--cite <where>]'); return 2; }
146
198
  let r;
@@ -158,10 +210,13 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
158
210
 
159
211
  /* ── an initiative's own workflow: of, move, switch ── */
160
212
  const MARK = { done: '✓', current: '▶', skipped: '–', next: ' ' };
161
- /** The Board's routes outside HTTP: this repository as the one workspace, its state folder, and the PMOS checkout as the self board. */
162
- function boardsCtx(root, stateDir) {
213
+ /** The Board's routes outside HTTP: this repository as the workspace, its state folder, and the PMOS checkout as the self board. The other
214
+ * workspaces are listed too, so a switch to a workflow saved in the home library finds it and adopts it here, as the page's switch does. */
215
+ function boardsCtx(root, stateDir, others = []) {
163
216
  const w = { id: driver.wsIdOf(root), name: path.basename(root), root };
164
- return { w, ctx: { getWs: () => w, workspaceList: () => [w], stateDirFor: () => stateDir, selfRoot: path.resolve(__dirname, '..', '..') } };
217
+ const list = [w, ...others.map((o) => ({ id: o.id, name: o.name, root: o.root }))];
218
+ const dirOf = (x) => { if (!x || x.id === w.id) return stateDir; const o = others.find((y) => y.id === x.id); return o ? o.stateDir : null; };
219
+ return { w, ctx: { getWs: () => w, workspaceList: () => list, stateDirFor: dirOf, selfRoot: path.resolve(__dirname, '..', '..') } };
165
220
  }
166
221
  /** The initiative's row, reading the records now when nothing is cached (a CLI process starts with an empty cache). */
167
222
  function rowOf(ctx, w, id) {
@@ -184,7 +239,7 @@ function ofLines(r) {
184
239
  }
185
240
  const ofJson = (r) => ({ initiative: { id: r.row.id, title: r.row.title }, board: { id: r.board.id, name: r.board.name }, wf: r.row.wf, stage: r.row.wfStage, stages: r.row.wfStages, ended: r.row.ended });
186
241
 
187
- function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor }) {
242
+ function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor, others }) {
188
243
  const id = pos[0];
189
244
  const usage = { of: 'pmos workflow of <initiative>', move: 'pmos workflow move <initiative> --stage <key|name|number> [--why <text>]', switch: 'pmos workflow switch <initiative> --to <workflow id> [--stage <key|name>] [--why <text>]' }[verb];
190
245
  if (!id) { say('usage: ' + usage); return 2; }
@@ -193,7 +248,7 @@ function onInitiative(verb, { pos, flags, json, say, fail, root, stateDir, actor
193
248
  for (const k of ['board', 'runs', 'cite', 'file', 'note', 'undo']) if (k in flags) return fail(`--${k} is not an option of ${verb}`, 2);
194
249
  if (verb === 'of') for (const k of ['stage', 'to', 'why']) if (k in flags) return fail(`--${k} is not an option of of`, 2);
195
250
  if (verb === 'move' && 'to' in flags) return fail('--to names a workflow: use pmos workflow switch. Move takes --stage', 2);
196
- const { w, ctx } = boardsCtx(root, stateDir);
251
+ const { w, ctx } = boardsCtx(root, stateDir, others);
197
252
  const before = rowOf(ctx, w, id);
198
253
  if (!before.ok) return fail(before.error);
199
254
  if (verb === 'of') {
@@ -6,6 +6,7 @@
6
6
  * copy(stateDir, id, name, actor) → { ok, workflow }
7
7
  * adopt(stateDir, wf, actor, fromWs) → { ok, workflow } another workspace's workflow written into this library
8
8
  * archive(stateDir, id, archived, actor, { inUse }) → { ok }
9
+ * setDefault(stateDir, id, actor) → { ok, id } the workflow a new initiative starts on (one wf-default line)
9
10
  * saveDraft(stateDir, { draft, note }, actor) → { ok, draft } (the chat's draft; null clears it)
10
11
  * readDraft(stateDir) → { draft, note, at } | null
11
12
  * judge(stateDir, { board, id, stage, runs, why, cite, undo }, actor) → { ok } one stage-judge line in boards.jsonl
@@ -89,9 +90,22 @@ function archive(stateDir, id, archived, actor, { inUse = [] } = {}) {
89
90
  if (!lib.byId.has(id)) return { ok: false, status: 404, error: 'That workflow could not be found. It may have been removed.' };
90
91
  if (archived && inUse.length) return { ok: false, status: 409, error: `${inUse.length} ${inUse.length === 1 ? 'initiative runs' : 'initiatives run'} this workflow. Move ${inUse.length === 1 ? 'it' : 'them'} to another workflow first.`, inUse };
91
92
  append(fileOf(stateDir), actor, { kind: 'wf-archive', id, archived: !!archived });
93
+ // Archiving the default puts Product feature back as the default, so a later Restore does not bring the old default back silently
94
+ if (archived && lib.defaultId === id) append(fileOf(stateDir), actor, { kind: 'wf-default', id: W.PRODUCT_FEATURE_ID });
92
95
  return { ok: true };
93
96
  }
94
97
 
98
+ /** The default workflow for new initiatives in this workspace (batch 24 item 3, canvas DfA). Product feature can be the default. */
99
+ function setDefault(stateDir, id, actor) {
100
+ if (!stateDir) return { ok: false, status: 409, error: NO_STATE };
101
+ const lib = library(stateDir);
102
+ const wf = typeof id === 'string' && id ? lib.byId.get(id) : null;
103
+ if (!wf) return { ok: false, status: 404, error: 'That workflow could not be found. It may have been removed.' };
104
+ if (wf.archived) return { ok: false, status: 409, error: `${wf.name} is archived. Restore it first.` };
105
+ append(fileOf(stateDir), actor, { kind: 'wf-default', id });
106
+ return { ok: true, id };
107
+ }
108
+
95
109
  /** The chat's draft. It is checked like a save (so the Viewer never shows a draft Create would refuse); null clears it. */
96
110
  function saveDraft(stateDir, { draft, note } = {}, actor) {
97
111
  if (!stateDir) return { ok: false, status: 409, error: NO_STATE };
@@ -116,4 +130,4 @@ function judge(stateDir, { board, id, stage, runs, why, cite, undo } = {}, actor
116
130
  return { ok: true };
117
131
  }
118
132
 
119
- module.exports = { FILE, library, save, copy, adopt, archive, saveDraft, readDraft, judge, fileOf };
133
+ module.exports = { FILE, library, save, copy, adopt, archive, setDefault, saveDraft, readDraft, judge, fileOf };
@@ -12,6 +12,7 @@ const path = require('path');
12
12
  const fs = require('fs');
13
13
  const { spawn } = require('child_process');
14
14
  const { findBrowser } = require('./lib/find-browser');
15
+ const { openMission } = require('./lib/open-mission');
15
16
  const T = require('./test-run').configure({ name: 'metric-drafts-ui' });
16
17
  const BP = require('./lib/board-premium.js');
17
18
  const F = require('./fixtures/board-premium-facts.js');
@@ -68,7 +69,7 @@ const INITIATIVE = () => ({
68
69
  const shot = async (p, name) => { if (SHOTS) { await sleep(450); await p.screenshot({ path: path.join(SHOTS, name + '.png') }); } };
69
70
 
70
71
  const open = async ({ w = 1440, h = 900, drafts = { program: PROGRAM(), initiative: INITIATIVE() }, dark = false } = {}) => {
71
- const c = await browser.newContext({ viewport: { width: w, height: h }, colorScheme: dark ? 'dark' : 'light' });
72
+ const c = await openMission(await browser.newContext({ viewport: { width: w, height: h }, colorScheme: dark ? 'dark' : 'light' }));
72
73
  const page = await c.newPage();
73
74
  const errors = [], posts = [], gets = [];
74
75
  page.on('pageerror', (e) => errors.push(String((e && e.stack) || e)));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.494.0",
3
+ "version": "0.496.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.494.0",
9
+ "version": "0.496.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.494.0",
3
+ "version": "0.496.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",
@@ -10,6 +10,7 @@ const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { spawn } = require('child_process');
12
12
  const { findBrowser } = require('./lib/find-browser');
13
+ const { openMission } = require('./lib/open-mission');
13
14
  const T = require('./test-run').configure({ name: 'premium-board-actions' });
14
15
  const { makeRepos, codeRepo, write, git } = require('./plan-test-repos');
15
16
  const BP = require('./lib/board-premium.js');
@@ -17,6 +18,8 @@ const F = require('./fixtures/board-premium-facts.js');
17
18
 
18
19
  const BASE = T.BASE;
19
20
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ /* the one pill for what needs a look (batch 24 item 7, canvas BbB): its rows are pressed after the pill opens them */
22
+ const needOpen = async (pg) => { if (await pg.$('#bdNeedMenu[hidden]')) await pg.click('#chNeed'); };
20
23
  const until = async (fn, ms = 20000, step = 150) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
21
24
  const results = [];
22
25
  const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 500) : ''}`); };
@@ -71,7 +74,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
71
74
 
72
75
  browser = await chromium.launch({ executablePath: exe });
73
76
  const open = async (url, { w = 1280, h = 900 } = {}) => {
74
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
77
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
75
78
  await ctx.addInitScript(() => { try { localStorage.setItem('pmos.premium.openAs', 'popup'); } catch { /* none */ } });
76
79
  const page = await ctx.newPage(), errors = [], posts = [];
77
80
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
@@ -98,7 +101,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
98
101
  const spLife = async (id, label) => { await spOpen(id); await p.click(`.hi-pop [data-sp-life]:has(> span:text-is("${label}"))`); };
99
102
  const closeAll = async () => { for (let i = 0; i < 4; i++) { if (await p.evaluate(() => (!document.querySelector('#czViewer') || document.querySelector('#czViewer').hidden) && (!document.querySelector('#bdPop') || document.querySelector('#bdPop').hidden))) return; await p.keyboard.press('Escape'); await sleep(100); } };
100
103
  await until(() => p.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 30000);
101
- await p.click('#chAsks');
104
+ await needOpen(p); await p.click('#chAsks');
102
105
  ok('A2 the New asks popover lists both asks with Accept and Decline (BdAsks)', await until(() => p.evaluate(() => document.querySelectorAll('#bdPop .bd-acard[data-ask]').length === 2 && document.querySelectorAll('#bdPop [data-askact="accept"]').length === 2 && document.querySelectorAll('#bdPop [data-askact="decline"]').length === 2)), await p.evaluate(() => (document.querySelector('#bdPop') || {}).textContent));
103
106
  ok('A2 an ask shows its source and the product with the reason, not an invented name, and no "Waiting for design" tag', await p.evaluate(() => { const t = document.querySelector('#bdPop').textContent; return /Email from Rina/.test(t) && /Why: came in through Email from Rina/.test(t) && !/demo/i.test(t) && !document.querySelector('#bdPop .bd-wtag'); }));
104
107
 
@@ -120,15 +123,13 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
120
123
  await p.click('#czViewer [data-grill="later"]');
121
124
  ok('T3 Later keeps the open questions as the next step', await until(() => lines('triage-answers.jsonl').some((l) => l.kind === 'later')) && !!(await toastIs(/Kept as the next step/)));
122
125
  await until(() => p.evaluate(() => document.querySelector('#chAsks') && /\b1\b/.test(document.querySelector('#chAsks').textContent)), 30000);
123
- await p.click('#chAsks');
126
+ await needOpen(p); await p.click('#chAsks');
124
127
  ok('T4 the accepted ask leaves the popover', await until(() => p.evaluate((id) => !document.querySelector(`#bdPop [data-askact="accept"][data-id="${id}"]`) && document.querySelectorAll('#bdPop .bd-acard[data-ask]').length === 1, a1.body.ask.id)));
125
128
  await p.click(`#bdPop [data-askact="decline"][data-id="${a2.body.ask.id}"]`);
126
129
  ok('T5 Decline asks why not, with the chips', !!(await popOn(/Why not.*Out of scope.*Already planned.*Not now.*Other/)) && !P.posts.some((x) => x.path === '/api/boards/asks/decline'));
127
130
  await p.click('#bdPop [data-whynot="Not now"]'); await p.fill('#bdPop input[name="why"]', 'Not tied to a goal this quarter'); await p.click('#bdPop [data-askact="decline-go"]');
128
131
  ok('T6 Decline records the reason in the board ledger', !!(await toastIs(/Ask declined/)) && lines('boards.jsonl').some((l) => l.kind === 'ask-decline' && l.why === 'Not now: Not tied to a goal this quarter'), JSON.stringify(lines('boards.jsonl').filter((l) => l.kind === 'ask-decline')));
129
- await until(() => p.evaluate(() => /^No new asks|0 new asks/.test((document.querySelector('#chAsks') || {}).textContent.trim())), 30000);
130
- await p.click('#chAsks');
131
- ok('T7 the popover says nothing is left to triage', !!(await popOn(/No new ask waits for triage/)));
132
+ ok('T7 with nothing left to triage the asks row leaves the pill (batch 24 item 7: zero is silence)', !!(await until(() => p.evaluate(() => !document.querySelector('#chAsks')), 30000)));
132
133
  await closeAll();
133
134
 
134
135
  /* N New initiative */
@@ -280,9 +281,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
280
281
  await S.page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: m, as_of: Date.now() }) }));
281
282
  await S.page.reload();
282
283
  await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0));
283
- await S.page.click('#chAsks');
284
- ok('S1 the PMOS-self board names the router in its asks popover and hides New initiative', await until(() => S.page.evaluate(() => /pmos-router/.test((document.querySelector('#bdPop') || {}).textContent || '') && document.querySelector('#newIni').hidden)));
285
- await S.page.keyboard.press('Escape');
284
+ ok('S1 the PMOS-self board has no asks row (it has no ask source; zero is silence, batch 24 item 7) and hides New initiative', await until(() => S.page.evaluate(() => !document.querySelector('#chAsks') && document.querySelector('#newIni').hidden)));
286
285
  await S.page.click('#ibBody .ini2'); await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-iv-stagepick]'))); await S.page.click('#czViewer [data-iv-stagepick]');
287
286
  const s2 = await until(() => S.page.evaluate(() => { const el = document.querySelector('.hi-pop'); return el ? { go: [...el.querySelectorAll('[data-sp-go]:not([disabled])')].length, all: el.querySelectorAll('[data-sp-go]').length, auto: !!el.querySelector('[data-sp-auto]'), life: [...el.querySelectorAll('[data-sp-life]')].map((x) => x.dataset.spLife) } : null; }));
288
287
  ok('S2 its stage picker moves no stage and changes no workflow, and keeps Put on hold', !!s2 && s2.all > 1 && s2.go <= 1 && s2.life.includes('hold') && !s2.life.includes('wontDo') && !s2.life.includes('archive'), JSON.stringify(s2));
@@ -304,7 +303,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
304
303
  {
305
304
  const S = await open('/premium-board.html', { w: 390, h: 844 });
306
305
  await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 30000);
307
- const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#ibBody .ini2, #chAsks, #newIni')].filter((b) => b.offsetParent && (b.getBoundingClientRect().width < 44 || b.getBoundingClientRect().height < 44)).map((b) => b.className + ':' + Math.round(b.getBoundingClientRect().width) + 'x' + Math.round(b.getBoundingClientRect().height)) }));
306
+ const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#ibBody .ini2, #chNeed, #newIni')].filter((b) => b.offsetParent && (b.getBoundingClientRect().width < 44 || b.getBoundingClientRect().height < 44)).map((b) => b.className + ':' + Math.round(b.getBoundingClientRect().width) + 'x' + Math.round(b.getBoundingClientRect().height)) }));
308
307
  ok('F1 at 390 px there is no sideways scroll and every new control is at least 44 px', !f.wide && !f.small.length, JSON.stringify(f));
309
308
  await S.page.click('#ibBody .ini2'); await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-iv-stagepick]'))); await S.page.click('#czViewer [data-iv-stagepick]');
310
309
  const mi = await until(() => S.page.evaluate(() => { const L = [...document.querySelectorAll('.hi-pop .hi-mi')]; return L.length ? L.every((b) => b.getBoundingClientRect().height >= 44) && document.documentElement.scrollWidth <= innerWidth + 1 : null; }));
@@ -87,28 +87,14 @@ const BODY = '## Purpose\n\nWhy this board exists.\n\n## Scope\n\nWhat it covers
87
87
  const H = await T.pressHeaders(BASE);
88
88
  const nonce = H['x-pmos-nonce'];
89
89
 
90
- /* B the Board reader (premium-uncovered-features, BdDocs: the Documents tab is a table; a row's title opens the document in the side panel,
91
- with its health, edit log, the file's text and the Issue it bar) */
92
- const bToast = (p, re) => until(() => p.evaluate((s) => { const t = document.querySelector('#bdToast'); return t && !t.hidden && new RegExp(s).test(t.textContent) ? t.textContent : null; }, re.source));
93
- const P = await open('/premium-board.html'), p = P.page;
94
- await until(() => p.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 60000);
95
- await p.click('#tabDocs');
96
- ok('B1 a board document is a row whose title opens the reader', await until(() => p.evaluate((k) => !!document.querySelector(`#docBody [data-opendoc="core:${k}"]`), bkey), 60000));
97
- ok('B1 every document row has a title that opens it', await p.evaluate(() => [...document.querySelectorAll('#docBody .bd-dt[data-docrow]')].every((r) => !!r.querySelector('[data-opendoc]'))));
98
- await p.click(`#docBody [data-opendoc="core:${bkey}"]`);
99
- ok('B2 the reader shows the file\'s text and path, and the bar says nothing is sent', await until(() => p.evaluate((pt) => { const o = document.querySelector('#czViewer'); return !o.hidden && /Why this board exists/.test(o.textContent) && o.textContent.includes(pt) && !!o.querySelector('[data-issue-bar="draft"]') && /Nothing is sent/.test(o.querySelector('[data-issue-bar]').textContent) && !!o.querySelector('[data-act="issue"]'); }, d1.path)), await p.evaluate(() => document.querySelector('#czViewer').textContent));
100
- ok('B2 no old dialog opens', await p.evaluate(() => !!!document.querySelector('#dlg.on')));
90
+ /* B a board's own core document. Batch 24 item 7 took the Documents tab off the Board (Search's Documents filter reaches the file), so no Board press
91
+ issues it; the issue route still takes a board scope */
101
92
  const n0 = ledger().filter((l) => l.ev === 'issue').length;
102
- await p.click('#czViewer [data-act="issue"]');
103
- ok('B3 Issue it says it sent nothing', !!(await bToast(p, /Issued\. Nothing was sent to anyone/)));
104
- const sent = P.posts.filter((x) => x.path === '/api/core-docs/issue');
105
- ok('B4 it posted the workspace, the scope, the key and the slot, and nothing else, with the launch nonce', sent.length === 1 && Object.keys(sent[0].body).sort().join() === 'key,scope,slot,ws' && sent[0].body.scope === bscope && sent[0].body.key === bkey && sent[0].body.slot === d1.slot && sent[0].body.ws === B.ws && sent[0].headers['x-pmos-nonce'] === nonce && !!nonce, JSON.stringify(sent));
93
+ const bres = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: H, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
94
+ ok('B4 the issue route takes a board scope with the launch nonce', bres.status === 200, String(bres.status));
106
95
  const il = ledger().filter((l) => l.ev === 'issue');
107
- ok('B5 the ledger has one new issue line for that draft, written by the PM', il.length === n0 + 1 && il[il.length - 1].slot === d1.slot && il[il.length - 1].scope === bscope && /pm/.test(String(il[il.length - 1].by)), JSON.stringify(il));
96
+ ok('B5 the ledger has one new issue line for that draft', il.length === n0 + 1 && il[il.length - 1].slot === d1.slot && il[il.length - 1].scope === bscope, JSON.stringify(il));
108
97
  ok('B5 the file now says state: issued', /^state: issued$/m.test(fs.readFileSync(path.join(team, d1.path), 'utf8')));
109
- ok('B6 the reader says it is issued, the Issue it press is gone, and the row reads Up to date', await until(() => p.evaluate(() => { const o = document.querySelector('#czViewer'); return !o.hidden && !!o.querySelector('[data-issue-bar="done"]') && !o.querySelector('[data-act="issue"]'); }), 30000) && await until(() => p.evaluate((k) => { const r = document.querySelector(`#docBody [data-docrow="core:${k}"]`); return r && /Up to date/.test(r.textContent); }, bkey), 30000), await p.evaluate(() => document.querySelector('#czViewer').textContent + ' // ' + document.querySelector('#docBody').textContent));
110
- await p.screenshot({ path: path.join(R.base, 'board-issued.png') });
111
- await P.ctx.close();
112
98
 
113
99
  /* I the initiative: a layout inside Home (premium-agreed-design). The Board's expand link lands there; the card's Overview lists the core documents (its Documents view),
114
100
  and a draft opens a minimal reader in the Viewer (Waiting for design) with the Issue it bar, Read and Edit */
@@ -163,33 +149,18 @@ const BODY = '## Purpose\n\nWhy this board exists.\n\n## Scope\n\nWhat it covers
163
149
  await jp.screenshot({ path: path.join(R.base, 'ini-issued.png') });
164
150
 
165
151
  /* P the POST allow-list and the refusals */
166
- const all = P.posts.concat(I.posts, J.posts);
152
+ const all = I.posts.concat(J.posts);
167
153
  const other = all.filter((x) => !ALLOWED.test(x.path) && x.path !== '/api/loose-props/rows'); // what Home shows loose, sent when it changed so Claude can propose a clean-up (batch 13 item 3); it changes no work
168
- ok('P1 the pages post only to the issue route (and the conversation note)', all.length >= 3 && !other.length, JSON.stringify(other));
154
+ ok('P1 the pages post only to the issue route (and the conversation note)', all.length >= 2 && !other.length, JSON.stringify(other));
169
155
  const sentAll = all.filter((x) => x.path === '/api/core-docs/issue');
170
- ok('P2 every issue press carried the nonce and no writer field', sentAll.length >= 3 && sentAll.every((x) => x.headers['x-pmos-nonce'] === nonce && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
156
+ ok('P2 every issue press carried the nonce and no writer field', sentAll.length >= 2 && sentAll.every((x) => x.headers['x-pmos-nonce'] === nonce && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
171
157
  const bare = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: BASE }, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
172
158
  ok('P3 a press without the launch nonce is refused', bare.status === 403);
173
159
  const agent = await fetch(BASE + '/api/core-docs/issue', { method: 'POST', headers: { ...H, 'x-pmos-session': 's1' }, body: JSON.stringify({ ws: B.ws, scope: bscope, key: bkey, slot: d1.slot }) });
174
160
  ok('P4 D98: an agent session is not refused as an agent (the press is checked like the PM\'s)', agent.status !== 403, String(agent.status));
175
- ok('P5 no script error on any page', !P.errors.length && !I.errors.length && !J.errors.length, P.errors.concat(I.errors, J.errors).join(' | '));
161
+ ok('P5 no script error on any page', !I.errors.length && !J.errors.length, I.errors.concat(J.errors).join(' | '));
176
162
  await J.ctx.close();
177
163
 
178
- /* F floors and fidelity: a fresh draft, at 390 and 1280, light and dark */
179
- const d4 = CD.writeDraft(team, { scope: bscope, key: (board2.model.docs.rows.filter((d) => d.act && d.act.kind === 'draft' && d.st === 'missing' && d.key !== bkey)[0] || {}).key, markdown: BODY, by: 'agent:test', now: Date.now() });
180
- const k4 = d4.path.split('/').slice(-2)[0];
181
- for (const [w, h, theme] of [[390, 844, 'light'], [390, 844, 'dark'], [1280, 900, 'dark']]) {
182
- const S = await open('/premium-board.html', { w, h, theme });
183
- await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 60000);
184
- await S.page.click(w <= 720 ? '#chDocs' : '#tabDocs');
185
- await until(() => S.page.evaluate((k) => !!document.querySelector(`#docBody [data-opendoc="core:${k}"]`), k4), 60000);
186
- await S.page.click(`#docBody [data-opendoc="core:${k4}"]`);
187
- await until(() => S.page.evaluate(() => !!document.querySelector('#czViewer [data-act="issue"]')));
188
- const f = await S.page.evaluate(() => ({ wide: document.documentElement.scrollWidth > innerWidth + 1, small: [...document.querySelectorAll('#czViewer [data-act="issue"]')].filter((b) => b.getBoundingClientRect().height < (innerWidth <= 720 ? 44 : 34)).map((b) => b.textContent) }));
189
- ok(`F1 ${w} px ${theme}: no sideways scroll and the bar's button is 44 px on a phone`, !f.wide && !f.small.length, JSON.stringify(f));
190
- await S.page.screenshot({ path: path.join(R.base, `bar-${w}-${theme}.png`) });
191
- await S.ctx.close();
192
- }
193
164
  if (process.env.KEEP_SHOTS) { for (const f of fs.readdirSync(R.base).filter((x) => /\.png$/.test(x))) fs.copyFileSync(path.join(R.base, f), path.join(process.env.KEEP_SHOTS, f)); }
194
165
  finish();
195
166
  })();
@@ -7,6 +7,7 @@
7
7
  const path = require('path');
8
8
  const { spawn } = require('child_process');
9
9
  const { findBrowser } = require('./lib/find-browser');
10
+ const { openMission } = require('./lib/open-mission');
10
11
  const T = require('./test-run').configure({ name: 'premium-vw-backlog' });
11
12
  const BP = require('./lib/board-premium.js');
12
13
  const F = require('./fixtures/board-premium-facts.js');
@@ -46,7 +47,7 @@ const SPRINTS = { ok: true, source: 'jira', readAt: '2026-10-19T09:00:00Z', velo
46
47
  browser = await chromium.launch({ executablePath: exe });
47
48
 
48
49
  const open = async ({ w = 1280, h = 900, theme = 'light', sprints = SPRINTS } = {}) => {
49
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
50
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
50
51
  const page = await ctx.newPage();
51
52
  const S = { errors: [], reads: [] };
52
53
  page.on('pageerror', (e) => S.errors.push(String((e && e.message) || e)));
@@ -120,7 +121,7 @@ const SPRINTS = { ok: true, source: 'jira', readAt: '2026-10-19T09:00:00Z', velo
120
121
 
121
122
  /* the Board (premium-uncovered-features, BdSprint) carries no Sprint card and no dialog; its sprint chip opens the Sprints and releases popover, whose Backlog row opens the backlog in the side panel */
122
123
  {
123
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), page = await ctx.newPage(), errs = [];
124
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), page = await ctx.newPage(), errs = [];
124
125
  page.on('pageerror', (e) => errs.push(String((e && e.message) || e)));
125
126
  const model = BP.build(F.facts({ sprints: { source: 'jira', readAt: SPRINTS.readAt, sprints: [SPRINT] } }));
126
127
  await page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model, as_of: Date.now() }) }));
@@ -8,6 +8,7 @@
8
8
  const path = require('path');
9
9
  const { spawn } = require('child_process');
10
10
  const { findBrowser } = require('./lib/find-browser');
11
+ const { openMission } = require('./lib/open-mission');
11
12
  const T = require('./test-run').configure({ name: 'premium-vw-item' });
12
13
  const IP = require('./lib/initiative-premium.js');
13
14
  const F = require('./fixtures/initiative-premium-facts.js');
@@ -41,7 +42,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
41
42
  const MODEL = IP.build(F.facts());
42
43
 
43
44
  const open = async ({ w = 1280, h = 900, theme = 'light', answer = null } = {}) => {
44
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
45
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
45
46
  const page = await ctx.newPage();
46
47
  const S = { errors: [], gets: [], vgets: [] }; // vgets: the Viewer's own reads (no page headers), apart from Home's
47
48
  page.on('pageerror', (e) => S.errors.push(String((e && e.message) || e)));
@@ -106,7 +107,7 @@ const openCard = async (pg, uid, V = '#czViewer') => {
106
107
  /* R9 the Board (premium-agreed-design): the Agreed Board opens an initiative in its side panel and never the dialog; its expand leads to the
107
108
  * initiative (Home's initiative layout), where the record is. R7 floors at 390 in dark: the same record, opened from Home's Overview. */
108
109
  {
109
- const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
110
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 390, height: 844 } }));
110
111
  const page = await ctx.newPage(); const errors = [];
111
112
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
112
113
  await page.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: BP.build(BF.facts()), as_of: Date.now() }) }));
@@ -10,6 +10,7 @@
10
10
  const path = require('path');
11
11
  const { spawn } = require('child_process');
12
12
  const { findBrowser } = require('./lib/find-browser');
13
+ const { openMission } = require('./lib/open-mission');
13
14
  const T = require('./test-run').configure({ name: 'premium-vw-jira' });
14
15
  const BP = require('./lib/board-premium.js');
15
16
  const F = require('./fixtures/board-premium-facts.js');
@@ -75,7 +76,7 @@ const TICKETS = {
75
76
  const overview = async (pg) => { await (async (g) => { const P = '#czViewer:not([hidden]) [data-hi-v="page"]', r = '#hmList [data-hm-sel^="i:"][aria-current="true"]'; for (let i = 0; i < 4 && !(await g.$(P)) && (await g.$('#czViewer:not([hidden]) [data-cz-vw="back"]')); i++) { await g.click('#czViewer [data-cz-vw="back"]'); await g.waitForTimeout(150); } if (await g.$(P)) return; if (await g.$(r)) await g.click(r); else await g.reload(); await g.waitForSelector(P + ' .iv', { timeout: 30000 }); })(pg); /* ← back to the initiative's page, else its row, else the address again (an initiative the list does not show) */ /* the page holds the Tasks (batch 11 item 20) */ return until(() => pg.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-hi-v]'))); };
76
77
  const openSprint = async (pg) => { if (!(await pg.$('#czViewer:not([hidden]) [data-hi-v]'))) await overview(pg); /* open() already showed the Overview */ await viaMore(pg, 'Sprint'); };
77
78
  const open = async ({ w = 1280, h = 900, theme = 'light', sprints = SPRINTS, tickets = TICKETS } = {}) => {
78
- const ctx = await browser.newContext({ viewport: { width: w, height: h }, colorScheme: theme });
79
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h }, colorScheme: theme }));
79
80
  await ctx.addInitScript((t) => { try { localStorage.setItem('pmos.c3.theme', t); } catch { /* none */ } }, theme);
80
81
  const page = await ctx.newPage(), errors = [], posts = [], reads = [];
81
82
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
@@ -193,7 +194,7 @@ const TICKETS = {
193
194
 
194
195
  /* A the Board (premium-uncovered-features, BdSprint): its sprint chip names the sprint and opens the Sprints and releases popover; it opens no dialog */
195
196
  {
196
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
197
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), pg = await ctx.newPage(), errs = [];
197
198
  pg.on('pageerror', (e) => errs.push(String((e && e.message) || e)));
198
199
  await pg.route('**/api/board-premium*', (r) => r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: model({ source: 'jira', readAt: new Date().toISOString(), sprints: [{ name: 'Sprint 14', state: 'active', start: '2026-10-07', end: '2026-10-21', counts: { todo: 2, doing: 1, done: 1 }, issues: [] }] }), as_of: Date.now() }) }));
199
200
  await pg.goto(BASE + '/premium-board.html');