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.
- package/package.json +38 -14
- 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 +1 -48
- package/workspace/briefing-premium-board-test.js +18 -19
- package/workspace/changelog.json +37 -0
- package/workspace/guide/adler.md +1 -1
- package/workspace/guide/briefing-premium.md +1 -0
- 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/published-board.md +1 -1
- package/workspace/guide/workflows.md +4 -3
- package/workspace/lib/board-premium.js +17 -2
- package/workspace/lib/initiative-chat.js +4 -1
- package/workspace/lib/proposals.js +18 -4
- package/workspace/lib/routes-board-premium.js +2 -2
- package/workspace/lib/routes-proposals.js +14 -2
- 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/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-board-actions-test.js +7 -9
- package/workspace/premium-core-doc-issue-test.js +9 -38
- package/workspace/public/pm/cozy-board.css +17 -5
- package/workspace/public/pm/home-app.js +12 -4
- package/workspace/public/pm/home-today.js +11 -4
- 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/readonly.js +3 -3
- package/workspace/public/pm/split-merge.js +3 -2
- package/workspace/public/premium-board.html +7 -10
- package/workspace/public/premium.html +1 -0
- package/workspace/split-merge-ui-test.js +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
|
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 ✕
|
|
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.
|
|
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
|
-
|
|
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 ✕
|
|
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, 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 =
|
|
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') {
|
|
@@ -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
|
@@ -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(() =>
|
|
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.
|
|
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, #
|
|
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; }));
|