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.
- package/package.json +75 -27
- package/payload/AGENTS.md +2 -0
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/adler-moments-test.js +3 -1
- package/workspace/board-programs-test.js +6 -6
- package/workspace/board-shared-ui-test.js +2 -2
- package/workspace/briefing-premium-adddoc-test.js +3 -49
- package/workspace/briefing-premium-board-test.js +23 -23
- package/workspace/briefing-premium-initiative-test.js +2 -1
- package/workspace/changelog.json +74 -0
- package/workspace/guide/adler.md +1 -1
- package/workspace/guide/briefing-premium.md +3 -1
- package/workspace/guide/claude-proposes.md +6 -2
- package/workspace/guide/data-freshness.md +2 -0
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/initiatives-screen.md +1 -1
- package/workspace/guide/published-board.md +1 -1
- package/workspace/guide/report-slides.md +6 -6
- package/workspace/guide/reports.md +1 -1
- package/workspace/guide/workflows.md +18 -5
- package/workspace/ini-workflow-test.js +2 -1
- package/workspace/lib/board-premium.js +17 -2
- package/workspace/lib/engine/workflows.js +19 -6
- package/workspace/lib/initiative-chat.js +4 -1
- package/workspace/lib/initiative-cli.js +15 -1
- package/workspace/lib/open-mission.js +14 -0
- package/workspace/lib/proposals.js +18 -4
- package/workspace/lib/report-export.js +5 -3
- package/workspace/lib/report-pyramid.js +134 -33
- package/workspace/lib/report-templates.js +33 -3
- package/workspace/lib/routes-board-premium.js +2 -2
- package/workspace/lib/routes-boards.js +35 -2
- package/workspace/lib/routes-brief-page.js +2 -1
- package/workspace/lib/routes-initiatives.js +1 -0
- package/workspace/lib/routes-proposals.js +14 -2
- package/workspace/lib/routes-report-design.js +1 -1
- package/workspace/lib/routes-report-flow.js +11 -4
- package/workspace/lib/routes-report-setups.js +3 -2
- package/workspace/lib/routes-workflows.js +6 -1
- package/workspace/lib/split-merge-suggest.js +37 -3
- package/workspace/lib/split-merge.js +10 -4
- package/workspace/lib/task-cli.js +5 -1
- package/workspace/lib/workflow-cli.js +70 -15
- package/workspace/lib/workflows-store.js +15 -1
- package/workspace/metric-drafts-ui-test.js +2 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +9 -10
- package/workspace/premium-core-doc-issue-test.js +9 -38
- package/workspace/premium-vw-backlog-test.js +3 -2
- package/workspace/premium-vw-item-test.js +3 -2
- package/workspace/premium-vw-jira-test.js +3 -2
- package/workspace/program-panel-test.js +2 -1
- package/workspace/public/pm/cozy-board.css +17 -5
- package/workspace/public/pm/cozy-home.css +1 -0
- package/workspace/public/pm/home-app.js +15 -5
- package/workspace/public/pm/home-chat.js +49 -8
- package/workspace/public/pm/home-today.js +11 -4
- package/workspace/public/pm/ini-view.js +2 -2
- package/workspace/public/pm/load-shapes.css +57 -0
- package/workspace/public/pm/load-shapes.js +57 -0
- package/workspace/public/pm/premium-board.js +82 -57
- package/workspace/public/pm/premium-shell.js +57 -6
- package/workspace/public/pm/premium.css +1 -0
- package/workspace/public/pm/readonly.js +3 -3
- package/workspace/public/pm/split-merge.js +3 -2
- package/workspace/public/pm/wf-page.css +8 -0
- package/workspace/public/pm/wf-page.js +33 -5
- package/workspace/public/premium-board.html +7 -10
- package/workspace/public/premium.html +1 -0
- package/workspace/skills/report-template.skill +4 -2
- package/workspace/split-merge-ui-test.js +4 -3
- package/workspace/stage-card-test.js +2 -1
- package/workspace/task-press-test.js +2 -1
- package/workspace/test-lists/page-open-speed.json +1 -1
- 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 ✕
|
|
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]) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/** ✕
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
162
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.496.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -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(() =>
|
|
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.
|
|
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, #
|
|
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
|
|
91
|
-
|
|
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
|
|
103
|
-
ok('
|
|
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
|
|
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 =
|
|
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 >=
|
|
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 >=
|
|
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', !
|
|
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');
|