create-pmos 0.495.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 (45) hide show
  1. package/package.json +38 -14
  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 +1 -48
  12. package/workspace/briefing-premium-board-test.js +18 -19
  13. package/workspace/changelog.json +37 -0
  14. package/workspace/guide/adler.md +1 -1
  15. package/workspace/guide/briefing-premium.md +1 -0
  16. package/workspace/guide/claude-proposes.md +6 -2
  17. package/workspace/guide/data-freshness.md +2 -0
  18. package/workspace/guide/home.md +1 -1
  19. package/workspace/guide/published-board.md +1 -1
  20. package/workspace/guide/workflows.md +4 -3
  21. package/workspace/lib/board-premium.js +17 -2
  22. package/workspace/lib/initiative-chat.js +4 -1
  23. package/workspace/lib/proposals.js +18 -4
  24. package/workspace/lib/routes-board-premium.js +2 -2
  25. package/workspace/lib/routes-proposals.js +14 -2
  26. package/workspace/lib/split-merge-suggest.js +37 -3
  27. package/workspace/lib/split-merge.js +10 -4
  28. package/workspace/lib/task-cli.js +5 -1
  29. package/workspace/lib/workflow-cli.js +70 -15
  30. package/workspace/package-lock.json +2 -2
  31. package/workspace/package.json +1 -1
  32. package/workspace/premium-board-actions-test.js +7 -9
  33. package/workspace/premium-core-doc-issue-test.js +9 -38
  34. package/workspace/public/pm/cozy-board.css +17 -5
  35. package/workspace/public/pm/home-app.js +12 -4
  36. package/workspace/public/pm/home-today.js +11 -4
  37. package/workspace/public/pm/load-shapes.css +57 -0
  38. package/workspace/public/pm/load-shapes.js +57 -0
  39. package/workspace/public/pm/premium-board.js +82 -57
  40. package/workspace/public/pm/readonly.js +3 -3
  41. package/workspace/public/pm/split-merge.js +3 -2
  42. package/workspace/public/premium-board.html +7 -10
  43. package/workspace/public/premium.html +1 -0
  44. package/workspace/split-merge-ui-test.js +1 -1
  45. package/workspace/test-lists/page-open-speed.json +1 -1
@@ -66,7 +66,7 @@ To use fewer stages, copy Product feature and make the copy simpler. For example
66
66
  - **Claude's proposal.** Claude shows where each old stage goes, once, as a ✦ in **New for you**. For example: Triage, Discover, Plan and Prioritize go into Discovery. Each row shows how many initiatives and tasks go into that stage. Press ✓ to move all the initiatives. Press ✕ to leave them where they are.
67
67
  - **The bridge.** The page of the new workflow shows **Replaces Product feature**: each old stage, with the stage it goes into. Change a stage there. The change is saved at once, and every later switch from the old workflow uses it.
68
68
  - **One initiative.** On the initiative, open the workflow picker and pick the new workflow. The dialog shows the old stages in each new stage. **Change where a stage goes** shows one picker for each old stage. **This initiative** or **All N** sets which initiatives move.
69
- - **What moves.** An initiative's stage and its tasks move together. Ticked items from the old stages count in the new stage. The new stage counts its days from the day its first old stage began. The stage card shows the days in each old stage.
69
+ - **What moves.** An initiative's stage and its tasks move together. Each task goes into the stage that its old stage goes into, on Home, on the Board and in `pmos task list`. A task goes under **Not in a stage** only when it had no stage before the move. Ticked items from the old stages count in the new stage. The new stage counts its days from the day its first old stage began. The stage card shows the days in each old stage.
70
70
  - **Jira tickets.** The tickets go under the stage whose output is Jira tickets, whatever its name.
71
71
  - **Undo.** The toast has **Undo**, and the workflow picker keeps **Undo the move** for 7 days. Undo puts back the workflow, each stage and each task together. An initiative that changed stage after the move stays where it is.
72
72
 
@@ -74,8 +74,9 @@ To use fewer stages, copy Product feature and make the copy simpler. For example
74
74
 
75
75
  An agent writes a draft or a reading with these commands, and moves an initiative along its workflow when you ask. It never saves, copies or archives a workflow: those are your presses on the page.
76
76
 
77
- - `pmos workflow list` names the workflows of this repository and marks the default one.
78
- - `pmos workflow show <id>` shows one workflow, its stages numbered.
77
+ - `pmos workflow list` names every workflow you can pick and marks the default one: the ones of this repository, then the ones you saved in Settings › Workflows (marked **saved in** and the workspace name) and in your other open workspaces.
78
+ - `pmos workflow show <id>` shows one workflow, its stages numbered. It finds a workflow in any of those libraries.
79
+ - An initiative chat reads workflows only through these commands. It does not search the repository's files for a workflow, and it does not use a lane, such as `feature`, in place of the initiative's workflow. When it cannot find a workflow, it says so and asks you.
79
80
  - `pmos workflow draft --file <path>` writes the draft that the New workflow chat shows. `--note` adds a line.
80
81
  - `pmos workflow show-draft` shows the draft that is open.
81
82
  - `pmos workflow judge --board <id> --id <initiative> --stage <key> --runs yes|no|unsure --why <reason> --cite <where>` records PMOS's reading of an Only when stage. **unsure** makes PMOS ask you. `--undo` takes a reading back.
@@ -239,7 +239,20 @@ const stageFields = (s) => (s && typeof s === 'object' && s.key ? { key: String(
239
239
  function wfFields(r) {
240
240
  const wf = r.wf && r.wf.id ? { id: String(r.wf.id), name: clip(r.wf.name || r.wf.id, 60), icon: r.wf.icon || 'flag', ...(r.wf.setBy ? { setBy: r.wf.setBy, why: clip(r.wf.why || '', 300) } : {}) } : PF;
241
241
  const ended = r.ended && (r.ended.as === 'wontdo' || r.ended.as === 'archived') ? { as: r.ended.as, reason: r.ended.reason || '', note: clip(r.ended.note || '', 300), at: r.ended.at || null } : null;
242
- return { wf, wfStage: stageFields(r.wfStage), wfStages: Array.isArray(r.wfStages) ? r.wfStages.map(stageFields).filter(Boolean).slice(0, 40) : null, ended };
242
+ // batch 24 item 5: the stage map of the last workflow switch (old stage key → stage now) and its Undo. Without them a Home or Board view put
243
+ // every task of an old stage under "Not in a stage" after the PM agreed the map (the full row had them; this copy dropped them).
244
+ const wfMap = mapFields(r.wfMap);
245
+ const wfSwitch = r.wfSwitch && typeof r.wfSwitch.batch === 'string' ? { batch: r.wfSwitch.batch, at: r.wfSwitch.at || null, from: r.wfSwitch.from || null } : null;
246
+ return { wf, wfStage: stageFields(r.wfStage), wfStages: Array.isArray(r.wfStages) ? r.wfStages.map(stageFields).filter(Boolean).slice(0, 40) : null, ended,
247
+ ...(wfMap ? { wfMap } : {}), ...(wfSwitch ? { wfSwitch } : {}) };
248
+ }
249
+ /** A stage map as the row carries it: { old key: stage key }, short strings only (a key is never cut: a cut key matches nothing), at most 60
250
+ * keys; null when empty. */
251
+ function mapFields(m) {
252
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return null;
253
+ const out = {};
254
+ for (const [k, v] of Object.entries(m).slice(0, 60)) if (k && k.length <= 80 && typeof v === 'string' && v && v.length <= 80) out[k] = v;
255
+ return Object.keys(out).length ? out : null;
243
256
  }
244
257
  /** The holds of a row as the timeline draws them (feedback batch 9 item 8): from, to (null while open), the return date and the reason. */
245
258
  const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -333,7 +346,9 @@ function build(f) {
333
346
  }
334
347
  // every task, not the first 60: the Board's panel and Home count the same tasks (feedback batch 6 item 1: Board said 56 done, Home 61)
335
348
  for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.tasks) r.tasks = (taskBy.get(r.id) || []).slice(0, TASK_MAX);
336
- for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.milestones) r.milestones = (msBy.get(r.id) || []).sort((a, b) => a.date.localeCompare(b.date)).slice(0, 40);
349
+ // a milestone under a stage of the workflow the initiative ran before a switch sits in the stage that switch put it in (row.wfMap)
350
+ const msIn = (r, m) => { const keys = (r.wfStages || []).map((s) => s.key); return m.sec && r.wfMap && !keys.includes(m.sec) && keys.includes(r.wfMap[m.sec]) ? { ...m, sec: r.wfMap[m.sec] } : m; };
351
+ for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.milestones) r.milestones = (msBy.get(r.id) || []).map((m) => msIn(r, m)).sort((a, b) => a.date.localeCompare(b.date)).slice(0, 40);
337
352
  for (const r of rows.concat(stageRows, gone.cancelled, gone.archived)) if (!r.track) {
338
353
  r.track = trackOf(r, tkBy.get(r.id), todayIso, relOfIni.get(r.id)); r.theme = themeOf(r.anchor);
339
354
  if (epicBy.has(r.id) && !r.epics.includes(epicBy.get(r.id))) r.epics.unshift(epicBy.get(r.id));
@@ -13,7 +13,9 @@
13
13
  * from lib/routes-boards.js initiativeWorkflow's answer
14
14
  * rule(info, id) → the rule's lines, with the workflow and stage when info is known
15
15
  * isAsk(text) → whether a message is about a stage, a workflow, a move or a task
16
- * wrap(text, info, id) → the message as PMOS types it into a live session */
16
+ * wrap(text, info, id) → the message as PMOS types it into a live session
17
+ * Batch 24 item 8: a chat in a product repository searched the repo for "Simpler Feature", found nothing (the library is the home
18
+ * workspace's workflows.jsonl, lib/workflow-cli.js) and used the feature LANE (D56) instead. The rule now says where workflows are read. */
17
19
 
18
20
  // The first line of every message PMOS wraps. The page reads it to show the PM's turn as typed (public/pm/goal-chat.js shownUser).
19
21
  const MARK = '[PMOS initiative]';
@@ -38,6 +40,7 @@ function rule(info, id) {
38
40
  out.push('Its stages: ' + info.stages.map((s, i) => `${i + 1}. ${s.name} [${s.key}]${s.status === 'done' ? ' done' : s.status === 'current' ? ' current' : s.status === 'skipped' ? ' skipped' : ''}`).join('; ') + '.');
39
41
  } else out.push(`Run \`pmos workflow of ${ini}\` to see the workflow this initiative runs and its current stage.`);
40
42
  out.push(
43
+ `Read workflows only through pmos: \`pmos workflow of ${ini}\` gives this initiative's workflow and its stages, \`pmos workflow show <workflow id>\` gives any workflow's stages, and \`pmos workflow list\` names every workflow the PM can pick, the ones saved in Settings included. The workflows are not files in this repository: never search the files for one. If pmos cannot find a workflow, tell the PM so and ask. A workflow is not a lane: never use a lane (feature, discovery, chore, incident, content, prototype) in place of the initiative's workflow.`,
41
44
  `The workflow stage is on the initiative: the stage card and the Board show it. \`pmos workflow of ${ini}\` reads it. When the PM asks to move the initiative, run \`pmos workflow move ${ini} --stage <key> --why "<reason>"\`. When the PM asks to change its workflow, run \`pmos workflow switch ${ini} --to <workflow id>\`. Both write what the page's press writes, under your name.`,
42
45
  'There is one kind of task: the ones the PM sees in the initiative\'s Tasks section. The stage card\'s To finish entries and the older breakdown items are tasks too. '
43
46
  + 'A task\'s state is To do, In progress, Done, Won\'t do or Archived. The Tasks section groups the tasks under the stages of this initiative\'s workflow: a task\'s group is its place in the breakdown, never its state. When the PM asks to add, close, group or change a task, '
@@ -24,7 +24,10 @@
24
24
  * end { ini, run, ok, stopped? } the run ended (stopped: the PM pressed ■)
25
25
  * propose { ini, board, run, id, group, type, section, title, why, sources: [{ label, ref }], items: [item], data }
26
26
  * an agent filed it (`pmos propose …`); the last line for an id wins, so a newer run replaces what it files again
27
- * drop { ini, sig } ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date passes again
27
+ * drop { ini, sig, facts? } ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date
28
+ * passes again. `facts` (batch 24 item 6) are what the ✕ rests on, not where the work sits: a date proposal's facts are its tasks
29
+ * and the due dates they missed, a grill finding's are its angle and its sources. A ✕ holds while they hold, so a workflow switch
30
+ * (new section keys, so new ids and signatures), a reworded reason or a new day (new computed dates) does not bring it back
28
31
  * keep { ini, board, batch, ids, wrote: [write] } ✓: what was written, each write with what it was before, for Undo and History
29
32
  * undo { batch }
30
33
  * epoch { } the first scan of lib/proposal-runs.js: only initiatives made after it are looked for
@@ -148,6 +151,16 @@ function clean(group, type, input) {
148
151
  const idOf = (ini, group, type, key) => 'pr-' + hash([ini, group, type, key || ''].join('|'));
149
152
  /** A proposal's signature: what it says and what it rests on. ✕ drops this; new evidence (a new signature) is proposed again. */
150
153
  function sigOf(p) { return 's:' + hash([p.id, p.why, JSON.stringify((p.sources || []).map((s) => s.ref || s.label)), JSON.stringify((p.items || []).map((x) => [x.id, x.due, x.start, x.to]))].join('|')); }
154
+ /** What a ✕ rests on, by fact rather than by id (batch 24 item 6): [string]. Dates: each task and the due date it missed (not its section,
155
+ * not the computed new date). Grill: the angle, its key and its sources (not the wording). Other groups: [] (their signature is already
156
+ * their content). */
157
+ function factsOf(p, x) {
158
+ if (!p) return [];
159
+ if (p.group === 'dates') return (x ? [x] : p.items || []).filter((y) => y && y.task).map((y) => factOfTask(p.ini, y.task, y.from && y.from.due));
160
+ if (p.group === 'grill') return ['f:' + hash(['grill', p.ini, p.type, p.id, JSON.stringify((p.sources || []).map((s) => s.ref || s.label).sort())].join('|'))];
161
+ return [];
162
+ }
163
+ const factOfTask = (ini, task, due) => 'f:' + hash(['dates', ini, task, due || ''].join('|'));
151
164
  function itemSig(p, x) { return 'i:' + hash([p.id, x.id || x.task || x.ref || x.title || '', x.due || '', x.start || '', JSON.stringify(x.to == null ? '' : x.to)].join('|')); }
152
165
 
153
166
  /* ── rule 5: one standard for a task's name ── */
@@ -182,7 +195,7 @@ function fold(lines) {
182
195
  else if (l.kind === 'src' && ini) { const L = looks.get(ini); if (L && L.run === l.run && str(l.source)) L.sources.set(l.source, { state: l.state === 'done' ? 'done' : 'reading', n: Number(l.n) || 0, what: str(l.what) }); }
183
196
  else if (l.kind === 'end' && ini) { const L = looks.get(ini); if (L && L.run === l.run) { L.ended = true; L.ok = !!l.ok; L.stopped = !!l.stopped; L.endAt = l.at || ''; } }
184
197
  else if (l.kind === 'propose' && ini && str(l.id)) props.set(l.id, { id: l.id, ini, board: str(l.board), run: str(l.run), group: l.group, type: l.type, section: str(l.section) || 'sure', title: str(l.title), why: str(l.why), sources: Array.isArray(l.sources) ? l.sources : [], items: Array.isArray(l.items) ? l.items.filter(isObj) : [], data: isObj(l.data) ? l.data : {}, at: l.at || '', by: l.by || null });
185
- else if (l.kind === 'drop' && str(l.sig)) drops.add(l.sig);
198
+ else if (l.kind === 'drop' && str(l.sig)) { drops.add(l.sig); for (const f of Array.isArray(l.facts) ? l.facts : []) if (/^f:[0-9a-f]{10}$/.test(String(f))) drops.add(f); }
186
199
  else if (l.kind === 'keep' && str(l.batch)) kept.push({ batch: l.batch, ini, board: str(l.board), ids: Array.isArray(l.ids) ? l.ids : [], wrote: Array.isArray(l.wrote) ? l.wrote.filter(isObj) : [], at: l.at || '', by: l.by || null });
187
200
  else if (l.kind === 'undo' && str(l.batch)) undone.add(l.batch);
188
201
  }
@@ -292,7 +305,7 @@ function datesOf(ini, T, o = {}) {
292
305
  sources: lead ? lead.p.sources : [], ...(noReason && owners.length ? { ask: { owner: owners[0] } } : {}) };
293
306
  p.sig = 'd:' + hash([id, ...items.map((x) => x.task + x.from.due)].join('|')); // rule 7: the date passing again is a new signature
294
307
  if (o.drops && o.drops.has(p.sig)) continue;
295
- p.items = p.items.filter((x) => !(o.drops && o.drops.has(itemSig(p, x))));
308
+ p.items = p.items.filter((x) => !(o.drops && (o.drops.has(itemSig(p, x)) || o.drops.has(factOfTask(ini, x.task, x.from.due)))));
296
309
  if (!p.items.length) continue;
297
310
  out.push(p);
298
311
  }
@@ -320,6 +333,7 @@ function viewOf(F, ini, o = {}) {
320
333
  if (p.group === 'status') p.items = p.items.filter((x) => T.has(x.task) && !T.get(x.task).closed);
321
334
  if (p.group === 'names') p.items = p.items.filter((x) => T.has(x.task) && T.get(x.task).title !== x.to).map((x) => ({ ...x, from: T.get(x.task).title, ...(T.get(x.task).jira ? { jira: true } : {}) }));
322
335
  if (!p.items.length && p.type !== 'stage' && p.group !== 'grill') continue;
336
+ if (p.group === 'grill' && factsOf(p).some((f) => F.drops.has(f))) continue;
323
337
  props.push(p);
324
338
  }
325
339
  // Grill findings (lib/grill.js): 3 show, the best first (rank); the rest wait folded and come up as the PM keeps or drops one
@@ -373,6 +387,6 @@ function decorate(rows, F, items, o = {}) {
373
387
  }
374
388
 
375
389
  module.exports = {
376
- LEDGER, GROUPS, TYPES, SOURCES, SOURCE_NAMES, RISK_DAYS, ledgerFile, read, readLines, append, fold, clean, idOf, sigOf, itemSig, tidyName, taskOf, lateOf,
390
+ LEDGER, GROUPS, TYPES, SOURCES, SOURCE_NAMES, RISK_DAYS, ledgerFile, read, readLines, append, fold, clean, idOf, sigOf, itemSig, factsOf, tidyName, taskOf, lateOf,
377
391
  datesOf, viewOf, foundOf, historyOf, decorate, keptIds, newBatch, dn, isoN, md, hash, workday,
378
392
  };
@@ -155,11 +155,11 @@ module.exports = {
155
155
  }
156
156
  } catch { missing.push('Claude\'s proposals'); }
157
157
  // Claude's suggestion to split an initiative or merge two (batch 11 item 14): made from the tasks and tickets on every read, never a timer;
158
- // the PM's ✕ Not now hides one until its tasks change. A failure here only leaves the suggestions out.
158
+ // the PM's ✕ hides one while its facts hold (the same group, the same partner). A failure here only leaves the suggestions out.
159
159
  try {
160
160
  const w = ctx.getWs ? ctx.getWs(pick.ws) : null;
161
161
  let dismissed = null;
162
- try { const f = w ? boards.foldOf(ctx, w) : null; dismissed = f ? f.suggestNo : null; } catch { dismissed = null; }
162
+ try { const f = w ? boards.foldOf(ctx, w) : null; dismissed = f ? Suggest.dismissedOf(f.events) : null; } catch { dismissed = null; }
163
163
  const items = its && its.body && Array.isArray(its.body.items) ? its.body.items : [];
164
164
  const open = ((model.initiatives && model.initiatives.list && model.initiatives.list.rows) || []).filter((r) => r && r.id && r.status !== 'done' && r.status !== 'on hold');
165
165
  const got = Suggest.suggest(open, items, { dismissed });
@@ -8,7 +8,8 @@
8
8
  * POST /api/proposals/keep { ws, board, initiative, ids?: [id] | all: true, choices?: { <from>><to>: 'keep' | 'ignore' } }
9
9
  * → { ok, batch, said, counts, props } ✓ one proposal, or ✓ in the header (all: every one Claude is sure of;
10
10
  * Check these and a Jira ticket's comment never ride on it). One batch, one toast, one Undo.
11
- * POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7)
11
+ * POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7); the line
12
+ * also keeps its facts (PR.factsOf), so a re-sectioned task or a new day does not bring it back
12
13
  * POST /api/proposals/undo { ws, board, batch } → { ok, back, kept } the whole batch; a task changed since keeps its change
13
14
  * POST /api/proposals/stop { ws, board, initiative } → { ok } ■ Stop: the look ends; what was found so far stays proposed
14
15
  * POST /api/proposals/look { ws, board, initiative, why?: 'populate' | 'check' } → { ok, started } Claude looks now (the New initiative
@@ -258,7 +259,18 @@ const WRITES = {
258
259
  '/api/proposals/drop': (ctx, res, body, g, p) => {
259
260
  const sig = String(body.sig || ''), ini = String(body.initiative || '');
260
261
  if (!/^[sdi]:[0-9a-f]{10}$/.test(sig) || !ini) return fail(ctx, res, 400, 'sig names a proposal or one item of it; initiative is required');
261
- PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig });
262
+ // what the ✕ rests on (batch 24 item 6): a date proposal's tasks and the dates they missed, a grill finding's angle and sources, so a
263
+ // workflow switch, a reworded reason or a new day does not bring it back. A failure here keeps the plain signature.
264
+ let facts = [];
265
+ try {
266
+ const v = view(ctx, p, ini, null, { grillAll: true });
267
+ for (const x of v ? v.groups.flatMap((gr) => gr.props) : []) {
268
+ if (x.sig === sig) { facts = PR.factsOf(x); break; }
269
+ const it = (x.items || []).find((y) => y && y.sig === sig);
270
+ if (it) { facts = PR.factsOf(x, it); break; }
271
+ }
272
+ } catch { facts = []; }
273
+ PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig, ...(facts.length ? { facts } : {}) });
262
274
  ctx.json(res, { ok: true });
263
275
  },
264
276
  '/api/proposals/undo': (ctx, res, body, g, p) => {
@@ -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, default: !!w.default })) })); 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' : ''}${w.default ? ' · default for new initiatives' : ''}`);
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') {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.495.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.495.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.495.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",
@@ -18,6 +18,8 @@ const F = require('./fixtures/board-premium-facts.js');
18
18
 
19
19
  const BASE = T.BASE;
20
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'); };
21
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); } };
22
24
  const results = [];
23
25
  const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 500) : ''}`); };
@@ -99,7 +101,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
99
101
  const spLife = async (id, label) => { await spOpen(id); await p.click(`.hi-pop [data-sp-life]:has(> span:text-is("${label}"))`); };
100
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); } };
101
103
  await until(() => p.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 30000);
102
- await p.click('#chAsks');
104
+ await needOpen(p); await p.click('#chAsks');
103
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));
104
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'); }));
105
107
 
@@ -121,15 +123,13 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
121
123
  await p.click('#czViewer [data-grill="later"]');
122
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/)));
123
125
  await until(() => p.evaluate(() => document.querySelector('#chAsks') && /\b1\b/.test(document.querySelector('#chAsks').textContent)), 30000);
124
- await p.click('#chAsks');
126
+ await needOpen(p); await p.click('#chAsks');
125
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)));
126
128
  await p.click(`#bdPop [data-askact="decline"][data-id="${a2.body.ask.id}"]`);
127
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'));
128
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"]');
129
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')));
130
- await until(() => p.evaluate(() => /^No new asks|0 new asks/.test((document.querySelector('#chAsks') || {}).textContent.trim())), 30000);
131
- await p.click('#chAsks');
132
- 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)));
133
133
  await closeAll();
134
134
 
135
135
  /* N New initiative */
@@ -281,9 +281,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
281
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() }) }));
282
282
  await S.page.reload();
283
283
  await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0));
284
- await S.page.click('#chAsks');
285
- 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)));
286
- 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)));
287
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]');
288
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; }));
289
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));
@@ -305,7 +303,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
305
303
  {
306
304
  const S = await open('/premium-board.html', { w: 390, h: 844 });
307
305
  await until(() => S.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0), 30000);
308
- 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)) }));
309
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));
310
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]');
311
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; }));