create-pmos 0.493.0 → 0.495.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 +99 -27
- package/payload/KIT-VERSION +1 -1
- 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/workspace/briefing-premium-adddoc-test.js +2 -1
- package/workspace/briefing-premium-board-test.js +5 -4
- package/workspace/briefing-premium-initiative-test.js +4 -2
- package/workspace/changelog.json +98 -0
- package/workspace/guide/briefing-premium.md +4 -1
- package/workspace/guide/claude-proposes.md +2 -2
- package/workspace/guide/data-freshness.md +3 -1
- package/workspace/guide/initiatives-screen.md +3 -1
- package/workspace/guide/report-slides.md +14 -1
- package/workspace/guide/reports.md +2 -0
- package/workspace/guide/workflows.md +15 -3
- package/workspace/guide/your-impact.md +6 -7
- package/workspace/ini-workflow-test.js +2 -1
- package/workspace/lib/board-trail.js +30 -3
- package/workspace/lib/engine/workflows.js +19 -6
- package/workspace/lib/git-shape.js +21 -0
- package/workspace/lib/grill-runs.js +21 -6
- package/workspace/lib/grill.js +1 -1
- package/workspace/lib/impact.js +29 -16
- package/workspace/lib/initiative-cli.js +15 -1
- package/workspace/lib/open-mission.js +14 -0
- package/workspace/lib/page-warm.js +68 -0
- package/workspace/lib/report-design.js +6 -1
- package/workspace/lib/report-export.js +150 -9
- package/workspace/lib/report-pyramid.js +535 -0
- package/workspace/lib/report-structure.js +5 -1
- package/workspace/lib/report-templates.js +111 -20
- package/workspace/lib/routes-boards.js +36 -3
- package/workspace/lib/routes-brief-page.js +21 -3
- package/workspace/lib/routes-grill.js +10 -4
- package/workspace/lib/routes-impact.js +12 -4
- package/workspace/lib/routes-initiatives.js +1 -0
- package/workspace/lib/routes-programs.js +6 -4
- package/workspace/lib/routes-report-design.js +15 -0
- package/workspace/lib/routes-report-flow.js +115 -19
- package/workspace/lib/routes-report-setups.js +4 -2
- package/workspace/lib/routes-report-structure.js +3 -0
- package/workspace/lib/routes-workflows.js +6 -1
- package/workspace/lib/wf-bridge.js +5 -3
- package/workspace/lib/workflow-cli.js +2 -2
- 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 +2 -1
- package/workspace/premium-chat-report-tab-test.js +5 -1
- 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-home.css +1 -0
- package/workspace/public/pm/cozy-vw-rp.css +12 -0
- package/workspace/public/pm/fetch-share.js +38 -0
- package/workspace/public/pm/home-app.js +24 -4
- package/workspace/public/pm/home-chat.js +53 -11
- package/workspace/public/pm/home-impact.css +23 -13
- package/workspace/public/pm/home-impact.js +49 -32
- package/workspace/public/pm/home-ini.css +2 -0
- package/workspace/public/pm/ini-view.css +55 -0
- package/workspace/public/pm/ini-view.js +7 -4
- package/workspace/public/pm/premium-board.js +1 -0
- package/workspace/public/pm/premium-insights.js +1 -0
- package/workspace/public/pm/premium-report-read.js +25 -6
- package/workspace/public/pm/premium-report-tab.js +69 -14
- package/workspace/public/pm/premium-shell.js +57 -6
- package/workspace/public/pm/premium.css +1 -0
- package/workspace/public/pm/report-flow.js +93 -10
- package/workspace/public/pm/wf-page.css +8 -0
- package/workspace/public/pm/wf-page.js +33 -5
- package/workspace/server.js +24 -3
- package/workspace/skills/report-template.skill +10 -0
- package/workspace/split-merge-ui-test.js +3 -2
- package/workspace/stage-card-test.js +2 -1
- package/workspace/task-press-test.js +2 -1
- package/workspace/test-lists/page-open-speed.json +5 -0
- package/workspace/test-lists/report-pyramid.json +9 -0
- package/workspace/tk-tasks-test.js +2 -1
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /api/report-flow?ws&id → { ok, id, name, audience, output, schedule, status, template: { current, versions: [{ n, settings, words,
|
|
5
5
|
* why, at, current, cycles }] }, cycles: [{ path, slot, state }] (newest first), latest, drafting, session,
|
|
6
|
-
* review } the report's current state: what the chat chip opens
|
|
6
|
+
* review, sections, look } the report's current state: what the chat chip opens.
|
|
7
|
+
* sections: { saved: S<n> | null, titles } the sections the next cycle follows
|
|
8
|
+
* look: { saved: D<n> | null, words, follows: [sample], newer: { n, words } | null } the look, and a newer
|
|
9
|
+
* design version not saved yet (made in Design): the template panel offers it as a change
|
|
7
10
|
* GET /api/report-flow/review?ws&path → { ok, review: { fix: [{ id, title, why }], passed: [label] } } Claude's review of one draft
|
|
8
11
|
* POST /api/report-flow/start { ws, id, chat? } → { ok, n } T1 of a report just set up, and the conversation it came from
|
|
9
|
-
* POST /api/report-flow/template { ws, id, settings }
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* POST /api/report-flow/template { ws, id, settings?, sections?, design? } → { ok, n } the PM's ✓ on a changed template (canvas TplA): applied,
|
|
13
|
+
* and ONE T<n+1> holding all of it. sections: the titles in order (a new S version, saved); design: a D
|
|
14
|
+
* version made in Design (saved), or null for the standard look. The open draft is not written again.
|
|
15
|
+
* POST /api/report-flow/restore { ws, id, n } → { ok, n, restored } Go back to T<n>: its format, readers and schedule,
|
|
16
|
+
* and its sections and look when it recorded them, are applied; nothing is deleted. Cycles keep
|
|
17
|
+
* the look they were made with; the open draft stays as it is
|
|
18
|
+
* POST /api/report-flow/ask { ws, id, text, path? } → { ok, session, path, passed | started } a change for the report's own session
|
|
19
|
+
* Without a path the change goes to the report's latest draft; an issued latest cycle (or path) is refused
|
|
20
|
+
* 409, as an issued report is frozen. The session always gets the file and its slot, and "do not draft a new
|
|
21
|
+
* slot", so a change edits the draft that exists (report-building brief 1). A change to the words edits only
|
|
22
|
+
* the named notes and keeps every caveat; it redrafts only when the PM asks for fresh numbers (brief 4).
|
|
12
23
|
*
|
|
13
24
|
* THE REPORT'S OWN SESSION. Each template drafts in a session of its own, never in the PM's chat (the agreed note). A report with a saved
|
|
14
25
|
* structure drafts in the session its schedule starts (lib/routes-report-structure.js draftNow, origin schedule:<id>). A change the PM asks for
|
|
@@ -25,6 +36,8 @@ const guard = require('./pm-guard.js');
|
|
|
25
36
|
const RT = require('./report-templates.js');
|
|
26
37
|
const RS = require('./report-setups.js');
|
|
27
38
|
const RN = require('./report-needs.js');
|
|
39
|
+
const RSt = require('./report-structure.js');
|
|
40
|
+
const RD = require('./report-design.js');
|
|
28
41
|
const issues = require('./issues.js');
|
|
29
42
|
|
|
30
43
|
const prefixes = ['/api/report-flow'];
|
|
@@ -50,8 +63,14 @@ function draftingOf(ctx, w, id) { try { return typeof ctx.structureDraftState ==
|
|
|
50
63
|
function stateOf(ctx, w, id, now = Date.now()) {
|
|
51
64
|
const root = w.root, s = setupOf(root, id);
|
|
52
65
|
const cycles = cyclesOf(root, id);
|
|
53
|
-
|
|
66
|
+
// a report set up before the template flow, with a saved look or sections, gets its T1 now (brief 4 item 10): its template is what is saved
|
|
67
|
+
const live0 = RT.liveRefs(root, id);
|
|
68
|
+
if (s && s.status !== 'removed' && (live0.design != null || live0.structure != null) && RT.fold(RT.readLines(root, id)).current == null) {
|
|
69
|
+
try { RT.ensureFirst(root, id, s, { refs: live0, why: 'The template as it was set up, with its saved look and sections.' }); } catch { /* shown with no versions */ }
|
|
70
|
+
}
|
|
71
|
+
const f = RT.backfill(root, id, RT.fold(RT.readLines(root, id)));
|
|
54
72
|
const counts = RT.cyclesOf(f, cycles.filter((c) => c.issued_at).map((c) => ({ at: c.issued_at })));
|
|
73
|
+
const names = RT.namesOf(root, id), live = RT.liveRefs(root, id);
|
|
55
74
|
const sess = readSession(root, id);
|
|
56
75
|
const drafting = draftingOf(ctx, w, id);
|
|
57
76
|
const latest = cycles[0] || null;
|
|
@@ -59,12 +78,59 @@ function stateOf(ctx, w, id, now = Date.now()) {
|
|
|
59
78
|
if (latest && latest.state === 'draft') { const t = readText(root, latest.path); if (t != null) review = RT.review(t, { slot: latest.slot, now, listed: s && Array.isArray(s.sections) ? s.sections : [] }); }
|
|
60
79
|
return {
|
|
61
80
|
ok: true, id, name: s ? s.name : id, audience: s ? s.audience : '', output: s ? s.output : '', schedule: s ? s.schedule : null, status: s ? s.status : 'removed',
|
|
62
|
-
template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings), cycles: counts.get(v.n) || 0 })) },
|
|
81
|
+
template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings, names), cycles: counts.get(v.n) || 0 })) },
|
|
63
82
|
cycles, latest, drafting,
|
|
64
83
|
session: sess ? { id: sess.session, alive: alive(ctx, sess.session), since: sess.since || null } : null,
|
|
65
84
|
review,
|
|
85
|
+
sections: { saved: live.structure, titles: names.sections(live.structure) || (s && Array.isArray(s.sections) ? s.sections.slice() : []) },
|
|
86
|
+
look: lookOf(root, id, live.design, names),
|
|
66
87
|
};
|
|
67
88
|
}
|
|
89
|
+
/** The look the next cycle follows, the samples it follows, and the newest design version made after it and not saved yet. */
|
|
90
|
+
function lookOf(root, id, saved, names) {
|
|
91
|
+
let st = null; try { st = RD.stateOf(root, id); } catch { st = null; }
|
|
92
|
+
const follows = st ? [...st.samples.values()].map((x) => x.name).filter(Boolean).slice(0, 3) : [];
|
|
93
|
+
const top = st && st.versions.size ? Math.max(...st.versions.keys()) : null;
|
|
94
|
+
const newer = top != null && top !== saved && (saved == null || top > saved) ? { n: top, words: names.look(top) } : null;
|
|
95
|
+
return { saved, words: names.look(saved), follows, newer };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Save a template's sections and look in their own stores, as the studios' accept does, when they differ from what is saved now. A ref that is
|
|
99
|
+
* undefined (a T line from before TplA) is left as it is. */
|
|
100
|
+
function applyRefs(root, id, setup, want, actor) {
|
|
101
|
+
const live = RT.liveRefs(root, id), done = {};
|
|
102
|
+
if (want.structure !== undefined && want.structure !== live.structure) {
|
|
103
|
+
if (want.structure == null) { RSt.append(root, id, { ev: 'unsave' }, { actor }); try { fs.unlinkSync(RSt.paths(root, id).brief); } catch { /* none written */ } }
|
|
104
|
+
else {
|
|
105
|
+
if (!RSt.stateOf(root, id).versions.has(want.structure) || !RSt.readVersion(root, id, want.structure)) return { ok: false, status: 409, error: `S${want.structure} is not on disk, so its sections cannot come back` };
|
|
106
|
+
RSt.append(root, id, { ev: 'accept', n: want.structure, charts: [] }, { actor });
|
|
107
|
+
RSt.writeBrief(root, id, setup);
|
|
108
|
+
}
|
|
109
|
+
done.structure = true;
|
|
110
|
+
}
|
|
111
|
+
if (want.design !== undefined && want.design !== live.design) {
|
|
112
|
+
if (want.design == null) { RD.append(root, id, { ev: 'unsave' }, { actor }); try { fs.unlinkSync(RD.paths(root, id).brief); } catch { /* none written */ } }
|
|
113
|
+
else {
|
|
114
|
+
if (!RD.stateOf(root, id).versions.has(want.design)) return { ok: false, status: 409, error: `D${want.design} is not on disk, so its look cannot come back` };
|
|
115
|
+
RD.append(root, id, { ev: 'accept', n: want.design, rules: RD.rulesFor(root, id, want.design) }, { actor });
|
|
116
|
+
RD.writeBrief(root, id, setup);
|
|
117
|
+
}
|
|
118
|
+
done.design = true;
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, done };
|
|
121
|
+
}
|
|
122
|
+
/** The sections the PM named, as a new S version (planFromSections keeps the saved plan's purpose, sources and charts). → { ok, n } | refusal.
|
|
123
|
+
* The same titles as saved write nothing (n is the saved one). */
|
|
124
|
+
function sectionsVersion(root, id, setup, titles, actor) {
|
|
125
|
+
const live = RT.liveRefs(root, id);
|
|
126
|
+
const was = live.structure != null ? RSt.readVersion(root, id, live.structure) : null;
|
|
127
|
+
const shaped = RSt.planFromSections(titles, { readers: (setup && setup.audience) || '', decision: (setup && setup.purpose) || '', base: was });
|
|
128
|
+
if (!shaped.ok) return shaped;
|
|
129
|
+
const same = was && Array.isArray(was.outline) && was.outline.map((o) => o.title).join('\n') === shaped.plan.outline.map((o) => o.title).join('\n');
|
|
130
|
+
if (same) return { ok: true, n: live.structure, same: true };
|
|
131
|
+
const v = RSt.landVersion(root, id, shaped.plan, { turn: 'The sections named in the chat.', answer: `Made the sections you named, for the next template.`, from: 'template', actor });
|
|
132
|
+
return v.ok ? { ok: true, n: v.n } : v;
|
|
133
|
+
}
|
|
68
134
|
|
|
69
135
|
/** Apply a template's settings to the setup line (report-setups change and reschedule): the live values follow the current version. */
|
|
70
136
|
function apply(root, id, settings, opts) {
|
|
@@ -79,10 +145,17 @@ function apply(root, id, settings, opts) {
|
|
|
79
145
|
return { ok: true };
|
|
80
146
|
}
|
|
81
147
|
|
|
148
|
+
const slotOfRel = (rel) => { const m = /(\d{4}-\d{2}-\d{2})\.md$/.exec(String(rel || '')); return m ? m[1] : null; };
|
|
149
|
+
/** The line that names the draft a change is for: its file, its slot, and the rule that no new slot is drafted. */
|
|
150
|
+
function draftWords(id, rel) {
|
|
151
|
+
const slot = slotOfRel(rel);
|
|
152
|
+
return rel && slot ? `The draft: ${rel} (slot ${slot}). Change that file in place: edit only the note or notes the change is about, and keep every caveat. `
|
|
153
|
+
+ `Do not redraft for a change to the words. Only when the PM asks for fresh numbers, run \`pmos report draft ${id} ${slot} --redraft\`; never \`pmos report draft ${id}\` without the slot, and do not draft a new slot.` : '';
|
|
154
|
+
}
|
|
82
155
|
/** The words the report's session gets for one change. */
|
|
83
156
|
function askPrompt(skill, id, rel, change) {
|
|
84
157
|
return `${skill}\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${id}\n\n`
|
|
85
|
-
+ `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ?
|
|
158
|
+
+ `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ? draftWords(id, rel) + ' ' : ''}`
|
|
86
159
|
+ `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page.`;
|
|
87
160
|
}
|
|
88
161
|
function ask(ctx, w, id, change, rel) {
|
|
@@ -90,10 +163,10 @@ function ask(ctx, w, id, change, rel) {
|
|
|
90
163
|
const d = draftingOf(ctx, w, id);
|
|
91
164
|
const sess = readSession(root, id);
|
|
92
165
|
const target = d && d.state === 'drafting' && alive(ctx, d.session) ? d.session : sess && alive(ctx, sess.session) ? sess.session : null;
|
|
93
|
-
const msg = `[PMOS report change] ${rel ?
|
|
166
|
+
const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it.`;
|
|
94
167
|
if (target) {
|
|
95
168
|
const sent = typeof ctx.sendToSession === 'function' && ctx.sendToSession(target, msg);
|
|
96
|
-
if (sent) return { ok: true, session: target, passed: true };
|
|
169
|
+
if (sent) return { ok: true, session: target, path: rel, passed: true };
|
|
97
170
|
}
|
|
98
171
|
if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'This workspace cannot start a session for the report.' };
|
|
99
172
|
const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt('report-template', w) : null;
|
|
@@ -104,7 +177,7 @@ function ask(ctx, w, id, change, rel) {
|
|
|
104
177
|
const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change) + '\r' });
|
|
105
178
|
if (!sp || sp.state === 'exited' || !sp.id) return { ok: false, status: 409, error: 'The report\'s session did not start: ' + ((sp && sp.note) || 'no session') };
|
|
106
179
|
try { fs.mkdirSync(path.dirname(sessionFile(root, id)), { recursive: true }); fs.writeFileSync(sessionFile(root, id), JSON.stringify({ session: sp.id, since: new Date().toISOString() }) + '\n'); } catch { /* shown as no session */ }
|
|
107
|
-
return { ok: true, session: sp.id, started: true };
|
|
180
|
+
return { ok: true, session: sp.id, path: rel, started: true };
|
|
108
181
|
}
|
|
109
182
|
|
|
110
183
|
async function handle(req, res, url, ctx) {
|
|
@@ -152,33 +225,56 @@ async function handle(req, res, url, ctx) {
|
|
|
152
225
|
return r.ok ? send({ status: 200, body: { ok: true, n: r.n } }) : fail(r);
|
|
153
226
|
}
|
|
154
227
|
if (sub === 'template') {
|
|
155
|
-
const want = RT.cleanSettings(body.settings);
|
|
156
|
-
|
|
228
|
+
const want = { ...(RT.cleanSettings(body.settings) || {}) };
|
|
229
|
+
delete want.structure; delete want.design; // these come only as sections (titles) and design (a D version), checked below
|
|
230
|
+
const titles = Array.isArray(body.sections) ? body.sections : null;
|
|
231
|
+
const hasDesign = body.design === null || Number.isInteger(body.design);
|
|
232
|
+
if (!Object.keys(want).length && !titles && !hasDesign) return send(bad(400, 'say what changes: the format, who reads it, when it goes out, its sections or its look'));
|
|
233
|
+
if (Number.isInteger(body.design) && !RD.stateOf(w.root, id).versions.has(body.design)) return send(bad(404, 'no such design version'));
|
|
157
234
|
RT.ensureFirst(w.root, id, s);
|
|
235
|
+
const live = RT.liveRefs(w.root, id);
|
|
158
236
|
const f = RT.fold(RT.readLines(w.root, id)), cur = f.versions.find((v) => v.current);
|
|
159
|
-
|
|
160
|
-
|
|
237
|
+
// a T line from before TplA did not record its sections or look: it is compared as the ones saved now
|
|
238
|
+
const was = { ...live, ...(cur ? cur.settings : RT.settingsOf(s)) };
|
|
239
|
+
if (titles) {
|
|
240
|
+
const sv = sectionsVersion(w.root, id, s, titles, g.actor);
|
|
241
|
+
if (!sv.ok) return fail(sv);
|
|
242
|
+
want.structure = sv.n;
|
|
243
|
+
}
|
|
244
|
+
if (hasDesign) want.design = body.design;
|
|
245
|
+
const next = { ...was, ...want };
|
|
246
|
+
if (cur && RT.sameSettings(was, next)) return send({ status: 200, body: { ok: true, n: cur.n, unchanged: true } });
|
|
161
247
|
const a = apply(w.root, id, next, opts);
|
|
162
248
|
if (!a.ok) return fail(a);
|
|
163
|
-
const
|
|
249
|
+
const ar = applyRefs(w.root, id, s, next, g.actor);
|
|
250
|
+
if (!ar.ok) return fail(ar);
|
|
251
|
+
const names = RT.namesOf(w.root, id);
|
|
252
|
+
const v = RT.addVersion(w.root, id, next, { names, why: RT.diffWords(was, next, names).join('; ').slice(0, 200) });
|
|
164
253
|
return v.ok ? send({ status: 200, body: { ok: true, n: v.n, state: stateOf(ctx, w, id) } }) : fail(v);
|
|
165
254
|
}
|
|
166
255
|
if (sub === 'restore') {
|
|
167
|
-
const f = RT.fold(RT.readLines(w.root, id)), v = f.versions.find((x) => x.n === +body.n);
|
|
256
|
+
const f = RT.backfill(w.root, id, RT.fold(RT.readLines(w.root, id))), v = f.versions.find((x) => x.n === +body.n);
|
|
168
257
|
if (!v) return send(bad(404, 'no such template version'));
|
|
169
258
|
const a = apply(w.root, id, v.settings, opts);
|
|
170
259
|
if (!a.ok) return fail(a);
|
|
260
|
+
const ar = applyRefs(w.root, id, s, v.settings, g.actor);
|
|
261
|
+
if (!ar.ok) return fail(ar);
|
|
171
262
|
const r = RT.restore(w.root, id, v.n);
|
|
172
|
-
return r.ok ? send({ status: 200, body: { ok: true, n: r.n, state: stateOf(ctx, w, id) } }) : fail(r);
|
|
263
|
+
return r.ok ? send({ status: 200, body: { ok: true, n: r.n, restored: RT.settingsWords(v.settings, RT.namesOf(w.root, id)), state: stateOf(ctx, w, id) } }) : fail(r);
|
|
173
264
|
}
|
|
174
265
|
// ask
|
|
175
266
|
const change = text(body.text).replace(/\s+/g, ' ').trim();
|
|
176
267
|
if (!change) return send(bad(400, 'say the change'));
|
|
177
268
|
if (change.length > MAX_ASK) return send(bad(413, 'a change is at most ' + MAX_ASK + ' characters'));
|
|
178
|
-
|
|
269
|
+
let rel = text(body.path);
|
|
179
270
|
if (rel && !(new RegExp('(?:^|/)reports/' + id + '/\\d{4}-\\d{2}-\\d{2}\\.md$').test(rel) && !rel.split('/').includes('..'))) return send(bad(400, 'path must be a draft of this report'));
|
|
271
|
+
// The change goes to a draft that exists: the one named, else the latest cycle. An issued cycle is frozen, so its change is refused.
|
|
272
|
+
const cycles = cyclesOf(w.root, id);
|
|
273
|
+
const named = rel ? cycles.find((c) => c.slot === slotOfRel(rel)) : cycles[0];
|
|
274
|
+
if (named && named.state === 'issued') return send(bad(409, `the ${named.slot} report is issued, so it is frozen. Change the template for the next cycle instead`));
|
|
275
|
+
if (!rel && named) rel = named.path;
|
|
180
276
|
const r = ask(ctx, w, id, change, rel || null);
|
|
181
277
|
return r.ok ? send({ status: 200, body: r }) : fail(r);
|
|
182
278
|
}
|
|
183
279
|
|
|
184
|
-
module.exports = { prefixes, handle, stateOf, askPrompt };
|
|
280
|
+
module.exports = { prefixes, handle, stateOf, askPrompt, draftWords };
|
|
@@ -276,7 +276,7 @@ async function tick(ctx, now = new Date()) {
|
|
|
276
276
|
function exportAnswer(ctx, res, w, url) {
|
|
277
277
|
const rel = text(url.searchParams.get('path'));
|
|
278
278
|
const fmt = text(url.searchParams.get('fmt'));
|
|
279
|
-
if (fmt === 'pdf') return bad(400, 'PDF is printed by the browser.
|
|
279
|
+
if (fmt === 'pdf') return bad(400, 'PDF is printed by the browser, so there is no PDF file to download here. Open the report, press Download › PDF, and choose Save as PDF in the print window: you get the same slides, one on each page.');
|
|
280
280
|
if (!Object.prototype.hasOwnProperty.call(RX.FORMATS, fmt)) return bad(400, `fmt must be one of ${Object.keys(RX.FORMATS).join(', ')}`);
|
|
281
281
|
if (!RC.isReportPath(rel)) return bad(400, 'path must be a report document: <state>/reports/<cadence>/<slot>.md');
|
|
282
282
|
const abs = path.resolve(w.root);
|
|
@@ -288,7 +288,9 @@ function exportAnswer(ctx, res, w, url) {
|
|
|
288
288
|
if (!real.startsWith(fs.realpathSync(abs) + path.sep)) return bad(404, 'outside workspace');
|
|
289
289
|
md = fs.readFileSync(real, 'utf8');
|
|
290
290
|
} catch { return bad(404, 'no such document'); }
|
|
291
|
-
const
|
|
291
|
+
const cyc = /(?:^|\/)reports\/([a-z][a-z0-9-]{0,39})\/\d{4}-\d{2}-\d{2}\.md$/.exec(rel);
|
|
292
|
+
const RPy = require('./report-pyramid.js');
|
|
293
|
+
const out = RX.exportOf(md, fmt, fmt === 'pptx' && cyc ? { look: RPy.lookOf(abs, cyc[1]), data: RPy.dataOf(full) } : {});
|
|
292
294
|
if (!out.ok) return bad(500, out.error);
|
|
293
295
|
return { status: 200, file: out };
|
|
294
296
|
}
|
|
@@ -42,6 +42,7 @@ const path = require('path');
|
|
|
42
42
|
const os = require('os');
|
|
43
43
|
const guard = require('./pm-guard.js');
|
|
44
44
|
const S = require('./report-structure.js');
|
|
45
|
+
const RT = require('./report-templates.js');
|
|
45
46
|
const CS = require('./charts-store.js');
|
|
46
47
|
const RS = require('./report-setups.js');
|
|
47
48
|
const RDraft = require('./report-draft.js');
|
|
@@ -775,7 +776,9 @@ async function handle(req, res, url, ctx) {
|
|
|
775
776
|
c.chart = r.chart.id; written.push(r.chart.id);
|
|
776
777
|
}
|
|
777
778
|
if (written.length) S.rewriteVersion(root, id, n, next);
|
|
779
|
+
const tw = RT.followBefore(root, id, setup);
|
|
778
780
|
S.append(root, id, { ev: 'accept', n, charts: written }, { actor: g.actor });
|
|
781
|
+
RT.followAfter(root, id, { structure: n }, tw); // the template follows the saved sections as T<n+1> (canvas TplA)
|
|
779
782
|
const b = S.writeBrief(root, id, setup);
|
|
780
783
|
if (!b.ok) return fail(b);
|
|
781
784
|
return send({ status: 200, body: { ok: true, structure: answerOf(root, id), brief: b.path, charts: written } });
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* POST /api/workflows/save { ws, wf, moveRemoved? } → { ok, workflow, moved } | 409 { ok:false, error, inUse }
|
|
7
7
|
* POST /api/workflows/copy { ws, id, name? } → { ok, workflow }
|
|
8
8
|
* POST /api/workflows/archive { ws, id, archived } → { ok } | 409 { inUse }
|
|
9
|
+
* POST /api/workflows/default { ws, id } → { ok, id } the star: the workflow a new initiative starts on (batch 24 item 3, DfA)
|
|
9
10
|
* POST /api/workflows/draft { ws, draft | null, note? } → { ok, draft } (the page relays the chat's draft; the agent writes through `pmos workflow draft`)
|
|
10
11
|
* GET /api/workflows/proposals → { ok, items } Claude's ✦: move the initiatives of a replaced workflow (batch 20 item 3, lib/wf-bridge.js)
|
|
11
12
|
* POST /api/workflows/bridge { ws, id, from, map } → { ok, workflow } the bridge saved with the new workflow (WmB, Settings)
|
|
@@ -68,7 +69,7 @@ async function handle(req, res, url, ctx) {
|
|
|
68
69
|
if (!read.ok) return send(bad(400, 'the body is not JSON'));
|
|
69
70
|
const body = read.body;
|
|
70
71
|
if (!isObj(body)) return send(bad(400, 'the body must be a JSON object'));
|
|
71
|
-
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot save, copy or
|
|
72
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot save, copy, archive or star a workflow. It drafts one with `pmos workflow draft`, and the PM presses Create'));
|
|
72
73
|
const g = guard.requirePagePress(req, body);
|
|
73
74
|
if (!g.ok) return send(bad(g.status, g.error));
|
|
74
75
|
const w = wsOf(ctx, typeof body.ws === 'string' ? body.ws : null);
|
|
@@ -101,6 +102,10 @@ async function handle(req, res, url, ctx) {
|
|
|
101
102
|
const r = WS.archive(dir, body.id, body.archived, g.actor, { inUse });
|
|
102
103
|
return send(r.ok ? { body: { ok: true } } : bad(r.status || 400, r.error, r.inUse ? { inUse: r.inUse } : null));
|
|
103
104
|
}
|
|
105
|
+
if (p === '/api/workflows/default') {
|
|
106
|
+
const r = WS.setDefault(dir, body.id, g.actor);
|
|
107
|
+
return send(r.ok ? { body: { ok: true, id: r.id } } : bad(r.status || 400, r.error));
|
|
108
|
+
}
|
|
104
109
|
if (p === '/api/workflows/bridge' || p === '/api/workflows/switch-all' || p === '/api/workflows/switch-all/undo') {
|
|
105
110
|
if (p !== '/api/workflows/switch-all/undo' && (typeof body.id !== 'string' || !body.id || typeof body.from !== 'string' || !body.from)) return send(bad(400, 'id (the new workflow) and from (the one it replaces) are required'));
|
|
106
111
|
const r = p === '/api/workflows/bridge' ? Bridge.saveBridge(ctx, w, body, g.actor) : p === '/api/workflows/switch-all' ? Bridge.switchAll(ctx, w, body, g) : Bridge.undoAll(ctx, w, body, g);
|
|
@@ -69,12 +69,14 @@ function proposals(ctx) {
|
|
|
69
69
|
const dir = home && ctx.stateDirFor ? ctx.stateDirFor(home) : null;
|
|
70
70
|
if (!dir) return [];
|
|
71
71
|
const lib = WS.library(dir);
|
|
72
|
+
// Most libraries replace nothing: then no workspace's initiatives are read at all (page-open-speed: New for you read every workspace's
|
|
73
|
+
// board frame on each Home open, ~100 ms on the thread every answer waits for, to find there was no ✦ to show).
|
|
74
|
+
const pairs = [...lib.byId.values()].map((wf) => [wf, replacesOf(lib, wf)]).filter(([, from]) => from);
|
|
75
|
+
if (!pairs.length) return [];
|
|
72
76
|
const all = [];
|
|
73
77
|
for (const w of workspaces(ctx)) { const st = standing(ctx, w); if (st) all.push(...st); }
|
|
74
78
|
const out = [];
|
|
75
|
-
for (const wf of
|
|
76
|
-
const from = replacesOf(lib, wf);
|
|
77
|
-
if (!from) continue;
|
|
79
|
+
for (const [wf, from] of pairs) {
|
|
78
80
|
const mine = all.filter((x) => movable(x, from.id));
|
|
79
81
|
if (!mine.length) continue;
|
|
80
82
|
const map = WF.mapOf(from, wf);
|
|
@@ -105,8 +105,8 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
105
105
|
|
|
106
106
|
if (verb === 'list') {
|
|
107
107
|
const all = WF.list(WS.library(stateDir));
|
|
108
|
-
if (json) { say(JSON.stringify({ ok: true, workflows: all.map((w) => ({ id: w.id, name: w.name, stages: w.stages.length, archived: !!w.archived })) })); return 0; }
|
|
109
|
-
for (const w of all) say(`${w.id} · ${w.name} · ${w.stages.length} stage${w.stages.length === 1 ? '' : 's'}${w.archived ? ' · archived' : ''}`);
|
|
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' : ''}`);
|
|
110
110
|
return 0;
|
|
111
111
|
}
|
|
112
112
|
if (verb === 'show') {
|
|
@@ -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.495.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.495.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');
|
|
@@ -71,7 +72,7 @@ const ALLOWED = /^\/api\/(?:boards\/(?:asks\/(?:add|accept|decline)|move|archive
|
|
|
71
72
|
|
|
72
73
|
browser = await chromium.launch({ executablePath: exe });
|
|
73
74
|
const open = async (url, { w = 1280, h = 900 } = {}) => {
|
|
74
|
-
const ctx = await browser.newContext({ viewport: { width: w, height: h } });
|
|
75
|
+
const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
|
|
75
76
|
await ctx.addInitScript(() => { try { localStorage.setItem('pmos.premium.openAs', 'popup'); } catch { /* none */ } });
|
|
76
77
|
const page = await ctx.newPage(), errors = [], posts = [];
|
|
77
78
|
page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
|
|
@@ -46,7 +46,7 @@ const ANSWER = { role: 'assistant', text: 'Signups from the lightbox rose to 1,2
|
|
|
46
46
|
const look = () => {
|
|
47
47
|
const vw = document.querySelector('#czViewer:not([hidden])'), pane = vw ? vw.querySelector('.vz[data-vw^="report:"]') : null;
|
|
48
48
|
return { shut: !pane, title: vw ? ((vw.querySelector('.cz-vw-title') || {}).textContent || '') : '', reader: !!(vw && vw.querySelector('.vz-rp0')), review: vw && vw.querySelector('.rf-rv') ? vw.querySelector('.rf-rv').textContent : null,
|
|
49
|
-
tpl: !!(vw && vw.querySelector('[data-cz-vw-p="template"]')), layer: false, chip: (document.querySelector('#hmLog [data-report-open]') || {}).textContent || null,
|
|
49
|
+
tpl: !!(vw && vw.querySelector('[data-cz-vw-p="template"]')) && !!(vw && vw.querySelector('[data-cz-vw-p="chat"]')), layer: false, chip: (document.querySelector('#hmLog [data-report-open]') || {}).textContent || null,
|
|
50
50
|
json: /pmos-report|"kind"/.test((document.querySelector('#hmLog') || {}).textContent || ''), user: [...document.querySelectorAll('#hmLog .hm-u .hm-bub')].map((u) => u.textContent.trim()),
|
|
51
51
|
tabs: pane ? [(vw.querySelector('.cz-vw-kind') || {}).textContent || ''] : [], pane: pane ? pane.textContent.replace(/\s+/g, ' ').trim() : null,
|
|
52
52
|
secs: pane ? (pane.querySelector('.vz-dkt') ? pane.querySelectorAll('.vz-dkt button').length - 1 : pane.querySelectorAll('.vz-secs li').length) : 0,
|
|
@@ -100,6 +100,10 @@ const look = () => {
|
|
|
100
100
|
ok('R4 Draft it records the report, pauses it (Once) and records T1', !!setup && setup.output === 'google-slides' && setup.audience === 'Growth leads' && L.some((l) => l.ev === 'pause' && l.id === setup.id) && T1 && T1.current === 1, JSON.stringify([L.map((l) => l.ev), T1]));
|
|
101
101
|
ok('R4 the report\'s current state opens: its first draft in the reader, Claude\'s review under it (RfH2), the template icon in the header', s3.reader && /I read it as your readers would/.test(s3.review || '') && s3.tpl, JSON.stringify(s3).slice(0, 600));
|
|
102
102
|
if (shots) await p.screenshot({ path: `${shots}/draft-1280-light.png` });
|
|
103
|
+
// the chat icon (Wawan, 2026-11-02): the report's own conversation opens in Home's chat, the report stays beside it
|
|
104
|
+
await p.click('#czViewer [data-cz-vw-p="chat"]');
|
|
105
|
+
const ch = await until(() => p.evaluate(() => { const i = document.getElementById('hmIn'); return i && i.placeholder === 'Change the report' ? { ph: i.placeholder, log: (document.getElementById('hmLog') || {}).textContent || '', reader: !!document.querySelector('#czViewer .vz-slide, #czViewer .vz-rp0') } : null; }), 8000);
|
|
106
|
+
ok('R4 the chat icon opens the report\'s own conversation beside it: Claude asks what to change, the report stays open', !!ch && /Tell me what to change in/.test(ch.log) && ch.reader, JSON.stringify(ch));
|
|
103
107
|
await p.click('#czViewer [data-cz-vw-p="template"]');
|
|
104
108
|
await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vz[data-panel="template"] [data-rt="fmt|google-doc"]')));
|
|
105
109
|
await p.click('#czViewer [data-rt="fmt|google-doc"]');
|
|
@@ -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');
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { spawn } = require('child_process');
|
|
17
17
|
const { findBrowser } = require('./lib/find-browser');
|
|
18
|
+
const { openMission } = require('./lib/open-mission');
|
|
18
19
|
const T = require('./test-run').configure({ name: 'program-panel' });
|
|
19
20
|
const BP = require('./lib/board-premium.js');
|
|
20
21
|
const F = require('./fixtures/board-premium-facts.js');
|
|
@@ -46,7 +47,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
46
47
|
browser = await chromium.launch({ executablePath: exe });
|
|
47
48
|
|
|
48
49
|
const open = async ({ w = 1440, h = 900, qs = '?ws=idpf&program=project-ascent', ctx = null, programs = F.programs() } = {}) => {
|
|
49
|
-
const c = ctx || await browser.newContext({ viewport: { width: w, height: h } });
|
|
50
|
+
const c = ctx || await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
|
|
50
51
|
const page = await c.newPage();
|
|
51
52
|
const errors = [], posts = [];
|
|
52
53
|
page.on('pageerror', (e) => errors.push(String((e && e.stack) || e)));
|
|
@@ -232,6 +232,7 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
|
|
|
232
232
|
.hm-u { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
|
233
233
|
.hm-bub { max-width: 80%; padding: 10px 16px; border-radius: 18px 18px 8px 18px; background: var(--cz-bubble); font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink); }
|
|
234
234
|
.hm-you { font-size: var(--fs-1); color: var(--cz-ink3); }
|
|
235
|
+
.hm-pend.sending .hm-bub { opacity: .72; } /* batch 24 item 2: the bubble shows at once, a touch lighter until PMOS takes it */
|
|
235
236
|
.hm-worked { padding-left: 38px; }
|
|
236
237
|
.hm-wk { display: flex; align-items: center; gap: 8px; max-width: 100%; min-height: 32px; padding: 0 4px; border: 0; border-radius: 8px; background: none; color: var(--cz-ink3); font-size: var(--fs-2); cursor: pointer; text-align: left; } /* a plain grey line (AgChatAsk) */
|
|
237
238
|
.hm-wk:hover { color: var(--cz-ink2); }
|
|
@@ -298,3 +298,15 @@ button.rp-cc { cursor: pointer; }
|
|
|
298
298
|
.rp-rfh .rp-bar { justify-content: flex-end; }
|
|
299
299
|
.rp-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: auto; padding-top: 4px; }
|
|
300
300
|
.rp-foot .rp-pri { width: 40px; height: 40px; margin: 0; }
|
|
301
|
+
|
|
302
|
+
/* a template's sections and look (canvas TplA, brief 3; public/pm/report-flow.js): chips in order, a small title slide and one line */
|
|
303
|
+
.rf-cps { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
304
|
+
.rf-cp { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 99px; border: 1px solid var(--cz-hair); background: var(--cz-card); color: var(--cz-ink2); font-size: var(--fs-2); }
|
|
305
|
+
.rf-cp[data-k="new"] { border-color: color-mix(in oklab, var(--cz-blue) 55%, transparent); background: var(--cz-bluebg); color: var(--cz-blueink); }
|
|
306
|
+
.rf-cp[data-k="gone"] { border-style: dashed; color: var(--cz-ink3); }
|
|
307
|
+
.rf-lk { display: flex; align-items: center; gap: 12px; }
|
|
308
|
+
.rf-mini { width: 96px; height: 54px; flex: none; border-radius: 7px; display: flex; align-items: flex-end; padding: 6px 7px; box-sizing: border-box; background: linear-gradient(135deg, var(--cz-c-2546b8), var(--cz-c-1b3a9e)); color: var(--cz-c-ffffff); font-size: var(--fs-1); line-height: 1.2; overflow: hidden; }
|
|
309
|
+
.rf-mini[data-k="own"] { background: var(--cz-well2); color: var(--cz-ink); border: 1px solid var(--cz-hair); }
|
|
310
|
+
.rf-lk .rf-fix small { color: var(--cz-ink3); font-size: var(--fs-2); }
|
|
311
|
+
.rf-tplx { padding: 10px 12px; border: 1px solid var(--cz-hair); border-radius: 14px; background: var(--cz-bluebg); }
|
|
312
|
+
.rf-hv em.rf-rest { color: var(--cz-ink2); }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* pm/fetch-share.js — one request for the same answer while it is on its way (page-open-speed).
|
|
2
|
+
*
|
|
3
|
+
* Imported FIRST by the page entries (home-app.js, premium-board.js, premium-insights.js). Parts of a page ask for the same GET on their
|
|
4
|
+
* own, so Home asked for /api/notes three times and /api/settings twice in its opening burst, and the Board asked for /api/board-premium,
|
|
5
|
+
* /api/board-publish, /api/sources and /api/settings twice each (bench 2026-11-02). Every one of them waits on the server's one thread.
|
|
6
|
+
*
|
|
7
|
+
* The rule: a GET to /api/ with the same address as one that is still on its way gets a copy of that one's answer instead of a second
|
|
8
|
+
* request. Nothing is kept once the answer is in, so a later read always asks the server again. Any other method (a press) first drops
|
|
9
|
+
* what is on its way, so a read made after a write never gets an answer that was asked for before it. Only plain same-origin GETs with no
|
|
10
|
+
* body, signal or cache option are shared; everything else goes to fetch as before. */
|
|
11
|
+
const KEY = '__pmosFetchShare';
|
|
12
|
+
if (typeof window !== 'undefined' && typeof window.fetch === 'function' && !window[KEY]) {
|
|
13
|
+
const real = window.fetch.bind(window);
|
|
14
|
+
const flying = new Map(); // url → Promise<Response>
|
|
15
|
+
window[KEY] = { flying };
|
|
16
|
+
window.fetch = function shared(input, init) {
|
|
17
|
+
let url = null, method = 'GET';
|
|
18
|
+
try {
|
|
19
|
+
url = typeof input === 'string' ? input : (typeof URL !== 'undefined' && input instanceof URL ? input.href : null);
|
|
20
|
+
method = String((init && init.method) || (input && typeof input === 'object' && input.method) || 'GET').toUpperCase();
|
|
21
|
+
} catch { url = null; }
|
|
22
|
+
if (method !== 'GET' && method !== 'HEAD') { flying.clear(); return real(input, init); }
|
|
23
|
+
if (!url || method !== 'GET') return real(input, init); // a Request object or a HEAD goes to fetch as before
|
|
24
|
+
if (init && (init.body != null || init.signal || init.cache || init.keepalive)) return real(input, init);
|
|
25
|
+
let abs;
|
|
26
|
+
try { abs = new URL(url, location.href); } catch { return real(input, init); }
|
|
27
|
+
if (abs.origin !== location.origin || !abs.pathname.startsWith('/api/')) return real(input, init);
|
|
28
|
+
const key = abs.href;
|
|
29
|
+
const hit = flying.get(key);
|
|
30
|
+
if (hit) return hit.then((r) => r.clone());
|
|
31
|
+
const p = real(input, init);
|
|
32
|
+
flying.set(key, p);
|
|
33
|
+
const done = () => { if (flying.get(key) === p) flying.delete(key); };
|
|
34
|
+
p.then(done, done);
|
|
35
|
+
return p.then((r) => r.clone());
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export {};
|