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.
Files changed (82) hide show
  1. package/package.json +99 -27
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-adddoc-test.js +2 -1
  7. package/workspace/briefing-premium-board-test.js +5 -4
  8. package/workspace/briefing-premium-initiative-test.js +4 -2
  9. package/workspace/changelog.json +98 -0
  10. package/workspace/guide/briefing-premium.md +4 -1
  11. package/workspace/guide/claude-proposes.md +2 -2
  12. package/workspace/guide/data-freshness.md +3 -1
  13. package/workspace/guide/initiatives-screen.md +3 -1
  14. package/workspace/guide/report-slides.md +14 -1
  15. package/workspace/guide/reports.md +2 -0
  16. package/workspace/guide/workflows.md +15 -3
  17. package/workspace/guide/your-impact.md +6 -7
  18. package/workspace/ini-workflow-test.js +2 -1
  19. package/workspace/lib/board-trail.js +30 -3
  20. package/workspace/lib/engine/workflows.js +19 -6
  21. package/workspace/lib/git-shape.js +21 -0
  22. package/workspace/lib/grill-runs.js +21 -6
  23. package/workspace/lib/grill.js +1 -1
  24. package/workspace/lib/impact.js +29 -16
  25. package/workspace/lib/initiative-cli.js +15 -1
  26. package/workspace/lib/open-mission.js +14 -0
  27. package/workspace/lib/page-warm.js +68 -0
  28. package/workspace/lib/report-design.js +6 -1
  29. package/workspace/lib/report-export.js +150 -9
  30. package/workspace/lib/report-pyramid.js +535 -0
  31. package/workspace/lib/report-structure.js +5 -1
  32. package/workspace/lib/report-templates.js +111 -20
  33. package/workspace/lib/routes-boards.js +36 -3
  34. package/workspace/lib/routes-brief-page.js +21 -3
  35. package/workspace/lib/routes-grill.js +10 -4
  36. package/workspace/lib/routes-impact.js +12 -4
  37. package/workspace/lib/routes-initiatives.js +1 -0
  38. package/workspace/lib/routes-programs.js +6 -4
  39. package/workspace/lib/routes-report-design.js +15 -0
  40. package/workspace/lib/routes-report-flow.js +115 -19
  41. package/workspace/lib/routes-report-setups.js +4 -2
  42. package/workspace/lib/routes-report-structure.js +3 -0
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/wf-bridge.js +5 -3
  45. package/workspace/lib/workflow-cli.js +2 -2
  46. package/workspace/lib/workflows-store.js +15 -1
  47. package/workspace/metric-drafts-ui-test.js +2 -1
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/premium-board-actions-test.js +2 -1
  51. package/workspace/premium-chat-report-tab-test.js +5 -1
  52. package/workspace/premium-vw-backlog-test.js +3 -2
  53. package/workspace/premium-vw-item-test.js +3 -2
  54. package/workspace/premium-vw-jira-test.js +3 -2
  55. package/workspace/program-panel-test.js +2 -1
  56. package/workspace/public/pm/cozy-home.css +1 -0
  57. package/workspace/public/pm/cozy-vw-rp.css +12 -0
  58. package/workspace/public/pm/fetch-share.js +38 -0
  59. package/workspace/public/pm/home-app.js +24 -4
  60. package/workspace/public/pm/home-chat.js +53 -11
  61. package/workspace/public/pm/home-impact.css +23 -13
  62. package/workspace/public/pm/home-impact.js +49 -32
  63. package/workspace/public/pm/home-ini.css +2 -0
  64. package/workspace/public/pm/ini-view.css +55 -0
  65. package/workspace/public/pm/ini-view.js +7 -4
  66. package/workspace/public/pm/premium-board.js +1 -0
  67. package/workspace/public/pm/premium-insights.js +1 -0
  68. package/workspace/public/pm/premium-report-read.js +25 -6
  69. package/workspace/public/pm/premium-report-tab.js +69 -14
  70. package/workspace/public/pm/premium-shell.js +57 -6
  71. package/workspace/public/pm/premium.css +1 -0
  72. package/workspace/public/pm/report-flow.js +93 -10
  73. package/workspace/public/pm/wf-page.css +8 -0
  74. package/workspace/public/pm/wf-page.js +33 -5
  75. package/workspace/server.js +24 -3
  76. package/workspace/skills/report-template.skill +10 -0
  77. package/workspace/split-merge-ui-test.js +3 -2
  78. package/workspace/stage-card-test.js +2 -1
  79. package/workspace/task-press-test.js +2 -1
  80. package/workspace/test-lists/page-open-speed.json +5 -0
  81. package/workspace/test-lists/report-pyramid.json +9 -0
  82. package/workspace/tk-tasks-test.js +2 -1
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * ledgerOf(root, id) <state>/reports/<id>/template.jsonl
5
5
  * readLines(root, id) · fold(lines) → { current, versions: [{ n, settings, at, why, current }], chat, log }
6
- * settingsOf(setup) → { output, audience, schedule } the three settings a template is
7
- * sameSettings(a, b) · diffWords(a, b) "Format: Slides → Document"
8
- * ensureFirst(root, id, setup, { chat, now }) → T1 from the setup, once. `chat` is the conversation key the report was proposed in
6
+ * settingsOf(setup, refs?) → { output, audience, schedule, structure?, design? } what a template is
7
+ * liveRefs(root, id) → { structure: S<n> | null, design: D<n> | null } the saved sections and look now
8
+ * namesOf(root, id) → { sections(n) → [title] | null, look(n) → words } for the words below
9
+ * sameSettings(a, b) · diffWords(a, b, names) "Format: Slides → Document", "Sections: + Customers", "Look: Standard PMOS look → D3 · warmer"
10
+ * sectionsDiff(was, now) → "+ Customers, − Risk watch" | "new order" | ''
11
+ * ensureFirst(root, id, setup, { chat, refs, now }) → T1 from the setup, once. `chat` is the conversation key the report was proposed in
9
12
  * addVersion(root, id, settings, { why, now }) → { ok, n } the PM's ✓ on a changed template: T<n+1> becomes the current one
10
13
  * restore(root, id, n, { now }) → { ok, n } Go back to T<n>: that version is the current one again. Nothing is deleted
11
14
  * cyclesOf(folded, issued) → Map n → issued cycles while that version was the current one
@@ -15,7 +18,10 @@
15
18
  * THE LINES. { v: 1, ev, …, at }:
16
19
  * version { n, settings, why, chat? } T<n>; the newest version line is the current template until a restore line
17
20
  * restore { n } Go back to T<n>
18
- * A template is its settings: the format (output), who reads it (audience) and when it goes out (schedule). A change to any of them is a new
21
+ * A template is its settings: the format (output), who reads it (audience), when it goes out (schedule), its sections (structure: the saved
22
+ * structure version S<n> of lib/report-structure.js, or null = the standard sections) and its look (design: the saved design version D<n> of
23
+ * lib/report-design.js, or null = the standard look) (canvas TplA, brief 3). A T line written before TplA has no structure or design key: it is
24
+ * read as "not recorded", never as a change, and Go back to it leaves the sections and look as they are. A change to any of them is a new
19
25
  * version (the agreed note: "Changing any setting makes a different template"); the drafts and issued cycles stay with the version they were made
20
26
  * under. The setup line in report-setups.jsonl keeps the live values: the route applies a version there with report-setups change and reschedule.
21
27
  *
@@ -50,13 +56,33 @@ function append(root, id, line, now) {
50
56
  return rec;
51
57
  }
52
58
 
53
- /** The three settings of a template, as report-setups keeps them. */
54
- function settingsOf(s) {
59
+ /** A template's settings: the three report-setups keeps, and the saved sections and look when `refs` names them. */
60
+ function settingsOf(s, refs) {
55
61
  const x = isObj(s) ? s : {};
56
62
  const sc = isObj(x.schedule) ? x.schedule : null;
57
63
  const paused = x.status === 'paused';
58
- return { output: String(x.output || ''), audience: String(x.audience || ''), schedule: !sc || paused || sc.cadence === 'off' ? 'off' : { cadence: sc.cadence, ...(sc.day ? { day: sc.day } : {}), ...(sc.dayOfMonth ? { dayOfMonth: sc.dayOfMonth } : {}), time: sc.time || '09:00' } };
64
+ const out = { output: String(x.output || ''), audience: String(x.audience || ''), schedule: !sc || paused || sc.cadence === 'off' ? 'off' : { cadence: sc.cadence, ...(sc.day ? { day: sc.day } : {}), ...(sc.dayOfMonth ? { dayOfMonth: sc.dayOfMonth } : {}), time: sc.time || '09:00' } };
65
+ if (isObj(refs)) { if (refs.structure !== undefined) out.structure = refN(refs.structure); if (refs.design !== undefined) out.design = refN(refs.design); }
66
+ return out;
67
+ }
68
+ /** A saved version number, or null (the standard sections or look). */
69
+ const refN = (v) => (Number.isInteger(v) && v >= 1 ? v : null);
70
+ /** The sections and look saved now: the structure's and the design's accept lines. */
71
+ function liveRefs(root, id) {
72
+ let structure = null, design = null;
73
+ try { const st = require('./report-structure.js').stateOf(root, id); structure = st.saved ? refN(st.saved.n) : null; } catch { structure = null; }
74
+ try { const st = require('./report-design.js').stateOf(root, id); design = st.saved ? refN(st.saved.version) : null; } catch { design = null; }
75
+ return { structure, design };
76
+ }
77
+ /** The words of a version's sections and look, read from their stores. */
78
+ function namesOf(root, id) {
79
+ const S = require('./report-structure.js'), D = require('./report-design.js');
80
+ return {
81
+ sections: (n) => { if (n == null) return null; try { const p = S.readVersion(root, id, n); return p && Array.isArray(p.outline) ? p.outline.map((o) => String(o.title || o.id)) : null; } catch { return null; } },
82
+ look: (n) => { if (n == null) return STANDARD_LOOK; let m = null; try { m = D.versionMeta(root, id, n); } catch { m = null; } const t = m && String(m.turn || '').replace(/\s+/g, ' ').trim(); return 'D' + n + (t ? ' · ' + (t.length > 40 ? t.slice(0, 39) + '…' : t) : ''); },
83
+ };
59
84
  }
85
+ const STANDARD_LOOK = 'Standard PMOS look';
60
86
  function cleanSettings(s) {
61
87
  if (!isObj(s)) return null;
62
88
  const out = {};
@@ -64,10 +90,22 @@ function cleanSettings(s) {
64
90
  if (typeof s.audience === 'string' && s.audience.trim()) out.audience = s.audience.replace(/\s+/g, ' ').trim().slice(0, 120);
65
91
  if (s.schedule === 'off' || (isObj(s.schedule) && s.schedule.cadence === 'off')) out.schedule = 'off';
66
92
  else if (isObj(s.schedule) && typeof s.schedule.cadence === 'string') out.schedule = { cadence: s.schedule.cadence, ...(s.schedule.day ? { day: String(s.schedule.day) } : {}), ...(s.schedule.dayOfMonth ? { dayOfMonth: +s.schedule.dayOfMonth } : {}), time: /^\d{1,2}:\d{2}$/.test(String(s.schedule.time || '')) ? String(s.schedule.time) : '09:00' };
93
+ for (const k of ['structure', 'design']) if (s[k] === null || (Number.isInteger(s[k]) && s[k] >= 1)) out[k] = s[k];
67
94
  return Object.keys(out).length ? out : null;
68
95
  }
69
96
  const key = (sc) => JSON.stringify(sc === 'off' || !sc ? 'off' : { c: sc.cadence, d: sc.day || null, m: sc.dayOfMonth || null, t: sc.time || '09:00' });
70
- function sameSettings(a, b) { return !!a && !!b && a.output === b.output && a.audience === b.audience && key(a.schedule) === key(b.schedule); }
97
+ /** A sections or look ref differs only when both sides recorded one: an old T line (no key) is "not recorded", never a change. */
98
+ const refDiffers = (a, b, k) => a[k] !== undefined && b[k] !== undefined && a[k] !== b[k];
99
+ function sameSettings(a, b) { return !!a && !!b && a.output === b.output && a.audience === b.audience && key(a.schedule) === key(b.schedule) && !refDiffers(a, b, 'structure') && !refDiffers(a, b, 'design'); }
100
+ /** What changed between two section lists, in words: "+ Customers, − Risk watch", or "new order" when only the order moved. */
101
+ function sectionsDiff(was, now) {
102
+ const A = Array.isArray(was) ? was : [], B = Array.isArray(now) ? now : [];
103
+ const low = (L) => L.map((t) => String(t).toLowerCase());
104
+ const a = low(A), b = low(B);
105
+ const out = B.filter((t, i) => !a.includes(b[i])).map((t) => '+ ' + t).concat(A.filter((t, i) => !b.includes(a[i])).map((t) => '− ' + t));
106
+ if (out.length) return out.join(', ');
107
+ return a.join('\n') !== b.join('\n') ? 'new order' : '';
108
+ }
71
109
 
72
110
  const FORMAT = { 'google-slides': 'Slides', 'google-doc': 'Document', pdf: 'PDF', 'google-sheets': 'Sheets', confluence: 'Confluence page' };
73
111
  const DAYS = { Mon: 'Monday', Tue: 'Tuesday', Wed: 'Wednesday', Thu: 'Thursday', Fri: 'Friday', Sat: 'Saturday', Sun: 'Sunday' };
@@ -78,19 +116,31 @@ function goesWords(sc) {
78
116
  if (sc.cadence === 'monthly') return 'every month on day ' + (sc.dayOfMonth || 1) + ' ' + t;
79
117
  return (sc.cadence === 'bi-weekly' ? 'every two weeks, ' : 'every week, ') + (DAYS[sc.day] || 'Monday') + ' ' + t;
80
118
  }
81
- /** What changed between two templates, in words: ["Format: Slides → Document", …]. */
82
- function diffWords(a, b) {
119
+ const lookOf = (names, n) => (n == null ? STANDARD_LOOK : names && typeof names.look === 'function' ? names.look(n) : 'D' + n);
120
+ const titlesOf = (names, n) => (n == null ? null : names && typeof names.sections === 'function' ? names.sections(n) : null);
121
+ /** What changed between two templates, in words: ["Format: Slides → Document", "Sections: + Customers", "Look: D2 → D3 · warmer"]. `names`
122
+ * (namesOf) reads the section titles and look words; without it a sections change is said by its version ("Sections: S2 → S3"). */
123
+ function diffWords(a, b, names) {
83
124
  const out = [];
84
125
  if (!a || !b) return out;
85
126
  if (a.output !== b.output) out.push('Format: ' + (FORMAT[a.output] || a.output || 'none') + ' → ' + (FORMAT[b.output] || b.output || 'none'));
86
127
  if (a.audience !== b.audience) out.push('Readers: ' + (a.audience || 'none') + ' → ' + (b.audience || 'none'));
87
128
  if (key(a.schedule) !== key(b.schedule)) out.push('Goes out: ' + goesWords(a.schedule) + ' → ' + goesWords(b.schedule));
129
+ if (refDiffers(a, b, 'structure')) {
130
+ const A = titlesOf(names, a.structure), B = titlesOf(names, b.structure);
131
+ const d = A && B ? sectionsDiff(A, B) : '';
132
+ out.push('Sections: ' + (d || (a.structure == null ? 'standard' : 'S' + a.structure) + ' → ' + (b.structure == null ? 'standard' : 'S' + b.structure)));
133
+ }
134
+ if (refDiffers(a, b, 'design')) out.push('Look: ' + lookOf(names, a.design) + ' → ' + lookOf(names, b.design));
88
135
  return out;
89
136
  }
90
- /** One line naming a template's settings: "Format: Slides · readers: The team · goes out once". */
91
- function settingsWords(s) {
137
+ /** One line naming a template's settings: "Format: Slides · readers: The team · goes out once · 6 sections · Standard PMOS look". */
138
+ function settingsWords(s, names) {
92
139
  if (!s) return '';
93
- return 'Format: ' + (FORMAT[s.output] || s.output || 'none') + ' · readers: ' + (s.audience || 'none') + ' · goes out ' + goesWords(s.schedule);
140
+ const T = s.structure !== undefined ? titlesOf(names, s.structure) : null;
141
+ const sec = s.structure === undefined ? '' : s.structure == null ? ' · standard sections' : ' · ' + (T ? T.length + (T.length === 1 ? ' section' : ' sections') : 'sections S' + s.structure);
142
+ const look = s.design === undefined ? '' : ' · ' + lookOf(names, s.design) + (s.design == null ? '' : ' look');
143
+ return 'Format: ' + (FORMAT[s.output] || s.output || 'none') + ' · readers: ' + (s.audience || 'none') + ' · goes out ' + goesWords(s.schedule) + sec + look;
94
144
  }
95
145
 
96
146
  function fold(lines) {
@@ -107,26 +157,58 @@ function fold(lines) {
107
157
  }
108
158
 
109
159
  /** T1, from the setup as it is now. Once: a report with a version already keeps it. `chat` names the conversation it came from. */
110
- function ensureFirst(root, id, setup, { chat, now } = {}) {
160
+ function ensureFirst(root, id, setup, { chat, refs, why, now } = {}) {
111
161
  if (!ID_RE.test(String(id || ''))) return refuse(400, 'id must be a report id');
112
162
  const f = fold(readLines(root, id));
113
163
  if (f.current != null) return { ok: true, n: f.current, existing: true };
114
- const line = { ev: 'version', n: 1, settings: settingsOf(setup), why: 'First shape, from your chat.' };
164
+ const line = { ev: 'version', n: 1, settings: settingsOf(setup, refs || liveRefs(root, id)), why: typeof why === 'string' && why ? why.slice(0, 200) : 'First shape, from your chat.' };
115
165
  if (typeof chat === 'string' && CHAT_RE.test(chat)) line.chat = chat;
116
166
  append(root, id, line, now);
117
167
  return { ok: true, n: 1 };
118
168
  }
119
169
  /** The PM's ✓ on a changed template: T<n+1> with these settings is the current one. Unchanged settings write nothing. */
120
- function addVersion(root, id, settings, { why, now } = {}) {
170
+ function addVersion(root, id, settings, { why, names, fill, now } = {}) {
121
171
  if (!ID_RE.test(String(id || ''))) return refuse(400, 'id must be a report id');
122
172
  const f = fold(readLines(root, id));
123
173
  const cur = f.versions.find((v) => v.current);
124
- const next = { ...(cur ? cur.settings : {}), ...(cleanSettings(settings) || {}) };
125
- if (cur && sameSettings(cur.settings, next)) return { ok: true, n: cur.n, unchanged: true };
174
+ // `fill`: the sections and look saved before this change, for a current T line that did not record them (written before TplA)
175
+ const was = cur ? { ...(isObj(fill) ? fill : {}), ...cur.settings } : null;
176
+ const next = { ...(was || {}), ...(cleanSettings(settings) || {}) };
177
+ if (cur && sameSettings(was, next)) return { ok: true, n: cur.n, unchanged: true };
126
178
  const n = f.versions.reduce((m, v) => Math.max(m, v.n), 0) + 1;
127
- append(root, id, { ev: 'version', n, settings: next, why: typeof why === 'string' && why.trim() ? why.trim().slice(0, 200) : diffWords(cur && cur.settings, next).join('; ') }, now);
179
+ append(root, id, { ev: 'version', n, settings: next, why: typeof why === 'string' && why.trim() ? why.trim().slice(0, 200) : diffWords(was, next, names).join('; ').slice(0, 200) }, now);
128
180
  return { ok: true, n, settings: next };
129
181
  }
182
+ /** The saved version (accept n, unsave null) of one store's events at a time; undefined when the store has no event at or before it. */
183
+ function savedAt(events, t) {
184
+ let out;
185
+ for (const e of Array.isArray(events) ? events : []) {
186
+ const at = Date.parse((e && e.at) || '');
187
+ if (!Number.isFinite(at) || at > t) continue;
188
+ if (e.ev === 'accept') out = refN(e.n); else if (e.ev === 'unsave') out = null;
189
+ }
190
+ return out;
191
+ }
192
+ /** Backfill on read (live check of 0.494.0, brief 4 item 10): a T line written before TplA has no sections or look. Each such version takes the
193
+ * S<n> and D<n> that were saved at its time (no saved one then = the standard ones); the current version takes the ones saved now. Nothing is
194
+ * written. `filled` names the keys that were backfilled. Pure apart from reading the two stores' events. */
195
+ function backfill(root, id, folded) {
196
+ if (!folded || !Array.isArray(folded.versions) || !folded.versions.some((v) => v.settings.structure === undefined || v.settings.design === undefined)) return folded;
197
+ let sEv = [], dEv = [];
198
+ try { sEv = require('./report-structure.js').readEvents(root, id); } catch { sEv = []; }
199
+ try { dEv = require('./report-design.js').readEvents(root, id); } catch { dEv = []; }
200
+ const live = liveRefs(root, id);
201
+ const versions = folded.versions.map((v) => {
202
+ const fill = {}; const t = Date.parse(v.at || '');
203
+ for (const [k, ev] of [['structure', sEv], ['design', dEv]]) {
204
+ if (v.settings[k] !== undefined) continue;
205
+ const then = Number.isFinite(t) ? savedAt(ev, t) : undefined;
206
+ fill[k] = v.current || !Number.isFinite(t) ? live[k] : then === undefined ? null : then;
207
+ }
208
+ return Object.keys(fill).length ? { ...v, settings: { ...v.settings, ...fill }, filled: Object.keys(fill) } : v;
209
+ });
210
+ return { ...folded, versions };
211
+ }
130
212
  /** Go back to T<n>: that version is the current one again. Nothing is deleted. */
131
213
  function restore(root, id, n, { now } = {}) {
132
214
  const f = fold(readLines(root, id));
@@ -169,4 +251,13 @@ function review(markdown, { slot = '', now = Date.now(), listed = [] } = {}) {
169
251
  return { fix, passed };
170
252
  }
171
253
 
172
- module.exports = { ID_RE, CHAT_RE, FORMAT, ledgerOf, readLines, fold, settingsOf, cleanSettings, sameSettings, diffWords, settingsWords, goesWords, ensureFirst, addVersion, restore, cyclesOf, chatOf, review };
254
+ /**
255
+ * A sections or look change saved in its own studio (Structure "Use this", Design "Use it") is the PM's ✓ too: the template follows it as
256
+ * T<n+1>. Call `followBefore(root, id, setup)` BEFORE the studio's accept line (T1 then records the sections and look as they were; it answers
257
+ * them), and `followAfter(root, id, { structure | design }, was)` after it, with that answer.
258
+ */
259
+ function followBefore(root, id, setup) { try { const was = liveRefs(root, id); ensureFirst(root, id, setup, { refs: was }); return was; } catch { return null; } }
260
+ function followAfter(root, id, patch, was) { try { return addVersion(root, id, patch, { names: namesOf(root, id), fill: was }); } catch { return null; } }
261
+
262
+ module.exports = { ID_RE, CHAT_RE, FORMAT, STANDARD_LOOK, ledgerOf, readLines, fold, settingsOf, cleanSettings, sameSettings, sectionsDiff, diffWords, settingsWords, goesWords,
263
+ liveRefs, namesOf, refN, ensureFirst, backfill, savedAt, addVersion, restore, cyclesOf, chatOf, review, followBefore, followAfter };
@@ -207,10 +207,43 @@ function createAndPlace(ctx, w, boardId, input, actor, tie) {
207
207
  const stage = (ownWf || board.workflow)[0].name;
208
208
  tie({ file, board, initiative: r.id, stage });
209
209
  if (ownWf) append(file, actor, { kind: 'ini-workflow', board: board.id, id: r.id, template: tpl, stages: ownWf, moved: null });
210
+ const wfStart = startOnDefault(ctx, w, r.id, actor, { boardId: board.id });
210
211
  rowsLib.invalidate(w.root);
211
212
  rowCache.delete(w.root);
212
213
  if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
213
- return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id };
214
+ return { ok: true, initiative: { id: r.id, path: r.path }, stage, board: board.id, ...(wfStart ? { wf: wfStart } : {}) };
215
+ }
216
+
217
+ /** Batch 24 item 3 (canvas DfA): a new initiative starts on the default workflow (Settings › Workflows, the star). It writes one ini-wf
218
+ * line on the initiative's board, the line a workflow switch writes, so every surface reads the workflow from it; with no `moved` the
219
+ * initiative stands in the first stage (Triage) and no move shows in its history. Product feature (the default when none is set, or when
220
+ * the default is archived) writes nothing. The workspace's own default first; a workspace that never set one reads the home workspace's
221
+ * (Settings opens on home), and that workflow is adopted into this workspace's library first, as a switch does. PMOS still reads the BRD
222
+ * and proposes a switch. Never throws: a new initiative is made even when this line cannot be written.
223
+ * boardId: the board the initiative is on; else its domain board (products/<domain>/) or the workspace board. → the workflow id | null */
224
+ function startOnDefault(ctx, w, iniId, actor, { boardId = null, recordPath = null } = {}) {
225
+ try {
226
+ const file = ledgerFile(ctx, w);
227
+ const dir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
228
+ if (!file || !dir || isSelf(ctx, w)) return null;
229
+ let lib = WS.library(dir), from = null;
230
+ if (!lib.defaultId) {
231
+ const home = ctx.getWs ? ctx.getWs(null) : null;
232
+ const hd = home && home.id !== w.id && ctx.stateDirFor ? ctx.stateDirFor(home) : null;
233
+ const hl = hd ? WS.library(hd) : null;
234
+ if (hl && hl.defaultId) { lib = hl; from = home; }
235
+ }
236
+ const def = WF.defaultOf(lib);
237
+ if (def.id === WF.PRODUCT_FEATURE_ID) return null;
238
+ const fr = frameFor(ctx, w);
239
+ const dm = recordPath ? /^products\/([^/]+)\//.exec(recordPath) : null;
240
+ const bid = boardId || (dm && fr.boards.some((b) => b.id === dm[1] && b.kind === 'domain') ? dm[1] : w.id);
241
+ const board = fr.boards.find((b) => b.id === bid);
242
+ if (!board || board.self) return null;
243
+ if (from && !WS.library(dir).byId.has(def.id)) { const a = WS.adopt(dir, def, actor, from.id); if (!a.ok) return null; }
244
+ append(file, actor, { kind: 'ini-wf', board: board.id, id: iniId, wf: def.id, why: 'the default workflow for new initiatives', start: true });
245
+ return def.id;
246
+ } catch { return null; }
214
247
  }
215
248
 
216
249
  // ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
@@ -225,7 +258,7 @@ function domainsOf(root) {
225
258
 
226
259
  function boardsOf(ctx, w, folded) {
227
260
  const self = isSelf(ctx, w);
228
- const base = [{ id: w.id, name: w.name || path.basename(w.root), domain: null, kind: 'default' }];
261
+ const base = [{ id: w.id, name: w.label || w.name || path.basename(w.root), domain: null, kind: 'default' }];
229
262
  for (const d of domainsOf(w.root)) if (d !== w.id) base.push({ id: d, name: d, domain: d, kind: 'domain' });
230
263
  const known = new Set(base.map((b) => b.id));
231
264
  for (const [id, b] of folded.boards) if (!known.has(id) && b.created) { base.push({ id, name: b.name || id, domain: b.domain || null, kind: 'created' }); known.add(id); }
@@ -1784,4 +1817,4 @@ function meetingBoards(ctx) {
1784
1817
  return { list, detail };
1785
1818
  }
1786
1819
 
1787
- module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
1820
+ module.exports = { prefixes: ['/api/boards'], handle, tasksOfIni, postFrame, needRows, itemsOf, readLedger, foldOf, epicsOf, rowsOfBoard, invalidateRows: (w) => { rowsLib.invalidate(w.root); rowCache.delete(w.root); }, move, switchWorkflow, taskState, taskAdd, taskEdit, writeAs, taskDelete, taskRestore, initiativeWorkflow, seedInitiative, stageTasks, boardIndex, appendScanAsk, scanDeclineSignal, createAndPlace, startOnDefault, ledgerFile, append, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, taskSpanOf, workflowRowOf, foldOf, portfolio, SELF_REFUSAL, NO_STATE };
@@ -28,6 +28,7 @@
28
28
  * has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes; a mention's
29
29
  * read mark goes through POST /api/mentions/read (routes-mentions.js). */
30
30
  const path = require('path');
31
+ const fs = require('fs');
31
32
  const pmItems = require('./pm-items.js');
32
33
  const { repoScript } = require('./repo-script.js');
33
34
 
@@ -233,12 +234,29 @@ function notesAdapter(runtimeDir) {
233
234
  };
234
235
  }
235
236
 
236
- /** The PM's first name for the greeting: the first word of `git config user.name` where PMOS runs. null when git holds none (the page then greets without a name). */
237
+ /** The PM's first name for the greeting: the first word of `git config user.name` where PMOS runs. null when git holds none (the page then greets without a name).
238
+ * Kept until one of git's config files changes (page-open-speed): Home asked `git` twice per open, about 35 ms each, on the thread every
239
+ * other answer waits for. The key is the size and time of the files `git config` reads, so an edited name shows on the next open. */
240
+ const nameCache = new Map(); // cwd -> { key, name }
241
+ function gitConfigKey(cwd) {
242
+ const os = require('os');
243
+ const home = os.homedir();
244
+ const files = [path.join(home, '.gitconfig'), path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'git', 'config')];
245
+ if (cwd) files.push(path.join(cwd, '.git', 'config')); // a caller with no folder (routes-mentions answer in a test) reads the global config only
246
+ if (process.env.GIT_CONFIG_GLOBAL) files.push(process.env.GIT_CONFIG_GLOBAL);
247
+ return files.map((f) => { try { const st = fs.statSync(f); return st.size + ':' + st.mtimeMs; } catch { return '-'; } }).join('|');
248
+ }
237
249
  function firstName(cwd) {
250
+ const key = gitConfigKey(cwd);
251
+ const hit = nameCache.get(cwd);
252
+ if (hit && hit.key === key) return hit.name;
253
+ let name = null;
238
254
  try {
239
255
  const n = String(require('child_process').execFileSync('git', ['config', 'user.name'], { cwd, encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] })).trim().split(/\s+/)[0];
240
- return n && n.length <= 40 ? n : null;
241
- } catch { return null; }
256
+ name = n && n.length <= 40 ? n : null;
257
+ } catch { name = null; }
258
+ nameCache.set(cwd, { key, name });
259
+ return name;
242
260
  }
243
261
 
244
262
  module.exports = {
@@ -7,7 +7,8 @@
7
7
  * spent, left this month, in US dollars (lib/grill.js spentIn: an open run counts its whole budget)
8
8
  * paused the cap held a run back this month
9
9
  * runs for each initiative asked for: its last ended run { ini, why, at, cost, n, turn? }; n = its open grill findings
10
- * news the newest ended run of this workspace in the last 7 days that left open findings (Home's one line); null is silence
10
+ * news the newest ended run of this workspace in the last 7 days that left open findings (Home's one line); null is silence.
11
+ * news.more: how many other initiatives were grilled this week with open findings; news.total: all their open findings
11
12
  * run.turn: { scope, id, to } when the run started on a forecast turn (lib/forecast.js `turned` line, its key)
12
13
  *
13
14
  * Read-only: no run starts from a page (Claude grills by itself, lib/grill-runs.js), and the settings are written by /api/settings. */
@@ -44,13 +45,18 @@ function model(ctx, w, inis, now = Date.now()) {
44
45
  const shape = (r) => ({ ini: r.ini, why: r.why, at: r.endAt || r.at, cost: r.cost, n: P ? openFindings(P, r.ini) : 0, ...(r.why === 'forecast' ? { turn: turnOf(r, turned) } : {}) });
45
46
  const runs = {};
46
47
  for (const id of inis) { const r = ended.filter((x) => x.ini === id).pop(); if (r) runs[id] = shape(r); }
47
- let news = null;
48
- for (let i = ended.length - 1; i >= 0 && !news; i--) {
48
+ // news: the newest; more and total: the other initiatives grilled this week that left open findings, so Home says how many, not just one
49
+ let news = null; const others = new Set(); let total = 0;
50
+ for (let i = ended.length - 1; i >= 0; i--) {
49
51
  const r = ended[i];
50
52
  if (now - (Date.parse(r.endAt || r.at) || 0) > WEEK) break;
53
+ if ((news && r.ini === news.ini) || others.has(r.ini)) continue;
51
54
  const s = shape(r);
52
- if (s.n > 0) news = s;
55
+ if (!(s.n > 0)) continue;
56
+ if (!news) news = s; else others.add(r.ini);
57
+ total += s.n;
53
58
  }
59
+ if (news) Object.assign(news, { more: others.size, total });
54
60
  return { ok: true, mode: G.modeOf(get, ctx.env || process.env), envOff: G.off(ctx.env || process.env), cap: Number.isFinite(cap) ? cap : null,
55
61
  spent, left: Number.isFinite(cap) ? Math.max(0, Math.round((cap - spent) * 100) / 100) : null, month, paused: F.paused.has(month), runs, news };
56
62
  }
@@ -5,10 +5,11 @@
5
5
  *
6
6
  * GET /api/impact/week?week=YYYY-MM-DD → { ok, on, live, first, week, from, to, label, grow, celebrate, quiet, items } Home's Adler, the Viewer
7
7
  * GET /api/impact/weeks → { ok, on, live, weeks: [{ week, from, to, label, quiet, items }] } the log, newest first
8
- * GET /api/impact/check → { ok, on, due, week, label, items, gate } the weekly check in Home's chat
8
+ * GET /api/impact/check → { ok, on, due, week, label, items, gate, looked } the weekly check in Home's chat
9
9
  * POST /api/impact/verdict { id, real } → { ok } ✓ (true) or ✕ (false) one row
10
10
  * POST /api/impact/verdict-all { week } → { ok } ✓✓ every row of that week not checked yet
11
- * POST /api/impact/undo { id } → { ok } the row back to not checked
11
+ * POST /api/impact/undo { id } → { ok } the row back to not checked (a dropped row counts again)
12
+ * POST /api/impact/looked { week } → { ok } the check card was shown for last week (a closed week then counts for the gate)
12
13
  * POST /api/impact/seen { ids } → { ok } read on a Phase 2 surface; ends its one celebration
13
14
  * `ws` is accepted on every route and ignored: the ledger is per person, across products.
14
15
  *
@@ -50,7 +51,7 @@ async function handle(req, res, url, ctx) {
50
51
  : what === 'weeks' ? IM.weeksView(F, optsOf(ctx)) : IM.checkView(F, optsOf(ctx));
51
52
  return send({ status: 200, body: { ok: true, on: true, ...body } });
52
53
  }
53
- if (!['verdict', 'verdict-all', 'undo', 'seen'].includes(what)) return send(bad(404, 'no such impact route'));
54
+ if (!['verdict', 'verdict-all', 'undo', 'seen', 'looked'].includes(what)) return send(bad(404, 'no such impact route'));
54
55
  if (req.method !== 'POST') return send(bad(405, 'POST only'));
55
56
  if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only you check your impact, from the page. Claude\'s run files items with pmos impact.'));
56
57
  const rd = await ctx.readJson(req);
@@ -63,10 +64,17 @@ async function handle(req, res, url, ctx) {
63
64
  const F = IM.read(dir), at = new Date(nowMs(ctx)).toISOString(), by = { by: 'pm' };
64
65
 
65
66
  if (what === 'seen') {
66
- const ids = (Array.isArray(body.ids) ? body.ids : []).map(String).filter((id) => { const it = F.items.get(id); return it && it.real === true && !it.read; }).slice(0, 100);
67
+ const ids = (Array.isArray(body.ids) ? body.ids : []).map(String).filter((id) => { const it = F.items.get(id); return it && it.real !== false && !it.read; }).slice(0, 100);
67
68
  if (ids.length) IM.append(dir, by, { kind: 'seen', ids }, at);
68
69
  return send({ status: 200, body: { ok: true } });
69
70
  }
71
+ if (what === 'looked') {
72
+ const week = text(body.week), o = optsOf(ctx);
73
+ if (week !== IM.addDays(IM.weekOf(o.today, o.start), -7)) return send(bad(400, 'week is the first day of last week, YYYY-MM-DD: the week the check is open for'));
74
+ if (!F.looked.has(week)) IM.append(dir, by, { kind: 'looked', week }, at);
75
+ noteGate(ctx, dir);
76
+ return send({ status: 200, body: { ok: true } });
77
+ }
70
78
  if (what === 'verdict-all') {
71
79
  const week = text(body.week), o = optsOf(ctx);
72
80
  if (!/^\d{4}-\d{2}-\d{2}$/.test(week)) return send(bad(400, 'week is the first day of the week, YYYY-MM-DD'));
@@ -666,6 +666,7 @@ async function handle(req, res, url, ctx) {
666
666
  const { ws: _ws, ...input } = body;
667
667
  const r = writer.createInitiative(w.root, input, { actor: g.actor });
668
668
  if (!r.ok) { ctx.json(res, { ok: false, status: r.status, error: r.error, field: r.field }, r.status); return true; }
669
+ require('./routes-boards.js').startOnDefault(ctx, w, r.id, g.actor, { recordPath: r.path }); // batch 24 item 3: the default workflow
669
670
  rowsLib.invalidate(w.root);
670
671
  if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
671
672
  ctx.json(res, { ok: true, id: r.id, path: r.path, written: r.written, sig: sigOf(w.root) });
@@ -33,6 +33,7 @@ const guard = require('./pm-guard.js');
33
33
  const pmItems = require('./pm-items.js');
34
34
  const P = require('./programs.js');
35
35
  const MS = require('./milestone-suggest.js');
36
+ const gitShape = require('./git-shape.js');
36
37
  const gitInfo = require('./git-info.js');
37
38
  const deletedIds = require('./ini-deleted.js');
38
39
 
@@ -54,17 +55,18 @@ const read = (f) => { try { return f ? pmItems.readLines(f).filter(Boolean) : []
54
55
  * such, a charter saved from the worktree puts the program under the worktree product while its members sit under the main one, and a
55
56
  * member line that both hold shows twice. So the ledgers of a repo's trees are read as ONE product, the main tree's. Null when the
56
57
  * workspace is not a linked worktree, or its main tree is not registered (nothing to fold into). Memoised: all() runs on every poll. */
57
- const MAIN_TTL_MS = 15000;
58
- const mainCache = new Map(); // root -> { at, main: root of the repo's main tree | null }
58
+ // Kept while the folder's git shape stamp is unchanged (page-open-speed): a 15 s clock re-ran `git` on every Board open after a break.
59
+ const mainCache = new Map(); // root -> { stamp, main: root of the repo's main tree | null }
59
60
  function mainTreeRoot(w) {
60
61
  const hit = mainCache.get(w.root);
61
- if (hit && Date.now() - hit.at < MAIN_TTL_MS) return hit.main;
62
+ const stamp = gitShape.shapeStamp(w.root);
63
+ if (hit && hit.stamp === stamp) return hit.main;
62
64
  let main = null;
63
65
  try {
64
66
  const top = gitInfo.repoRoot(w.root), trees = top ? gitInfo.worktrees(w.root) : [];
65
67
  if (top && trees.length && trees[0].dir && path.resolve(trees[0].dir) !== path.resolve(top)) main = path.join(path.resolve(trees[0].dir), path.relative(path.resolve(top), path.resolve(w.root)));
66
68
  } catch { main = null; }
67
- mainCache.set(w.root, { at: Date.now(), main });
69
+ mainCache.set(w.root, { stamp, main });
68
70
  return main;
69
71
  }
70
72
  function foldedInto(list, w) {
@@ -14,6 +14,7 @@
14
14
  * POST /api/report-design/revert { ws, id, n } → { ok, design }
15
15
  * GET /api/report-design/cycle?ws&path → { ok, designed, design_version, format, outline, differences, decisions, matches, rendering, previous }
16
16
  * GET /api/report-design/cycle-html?ws&path → text/html, the sanitized cycle render
17
+ * GET /api/report-design/pyramid?ws&path → text/html, the finished slides of a cadence draft (lib/report-pyramid.js; 404 for another document)
17
18
  * POST /api/report-design/cycle-decision { ws, path, diff, choice: keep | adopt | match } → { ok, cycle }
18
19
  * GET /api/report-notes?ws&path → { ok, speaker, suggestions, pins }
19
20
  * POST /api/report-notes/speaker { ws, path, slide, text } | { ws, path, slide, keep: true }
@@ -43,9 +44,11 @@ const path = require('path');
43
44
  const crypto = require('crypto');
44
45
  const guard = require('./pm-guard.js');
45
46
  const D = require('./report-design.js');
47
+ const RT = require('./report-templates.js');
46
48
  const RS = require('./report-setups.js');
47
49
  const RC = require('./report-checks.js');
48
50
  const RDraft = require('./report-draft.js');
51
+ const RP = require('./report-pyramid.js');
49
52
  const headless = require('./headless.js');
50
53
 
51
54
  const text = (v) => (typeof v === 'string' ? v : '');
@@ -475,6 +478,14 @@ async function handle(req, res, url, ctx) {
475
478
  const setup = e ? setupOf(w.root, e.id) : null;
476
479
  return send({ status: 200, body: { ok: true, id: setup ? e.id : null, name: setup ? setup.name : null, at: setup ? e.at : null } });
477
480
  }
481
+ if (sub === 'pyramid') {
482
+ // The finished slides of a cadence draft (canvas RsA): the Pyramid deck in the report's saved look, else the standard PMOS look.
483
+ const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
484
+ let md; try { md = fs.readFileSync(d.c.md, 'utf8'); } catch { return send(bad(404, 'no such report document')); }
485
+ const deck = RP.deckOf(md, RP.lookOf(w.root, d.c.cadence), { data: RP.dataOf(d.c.md) });
486
+ if (!deck) return send(bad(404, 'this document is not a cadence draft, so it has no Pyramid deck'));
487
+ return sendHtml(res, D.sanitize(RP.deckHtml(deck)));
488
+ }
478
489
  if (sub === 'cycle' || sub === 'cycle-html') {
479
490
  const d = reportDoc(w, text(url.searchParams.get('path'))); if (d.error) return send(d.error);
480
491
  if (sub === 'cycle') return send({ status: 200, body: cycleAnswer(w, d.c) });
@@ -532,7 +543,9 @@ async function handle(req, res, url, ctx) {
532
543
  rules: D.rulesFor(root, c.cadence, st.saved.version), from: 'adopt', actor: g.actor });
533
544
  if (!v.ok) return fail(v);
534
545
  newVersion = v.n;
546
+ const tw = RT.followBefore(root, c.cadence, setupOf(root, c.cadence));
535
547
  D.append(root, c.cadence, { ev: 'accept', n: v.n, rules: D.rulesFor(root, c.cadence, v.n) }, { actor: g.actor });
548
+ RT.followAfter(root, c.cadence, { design: v.n }, tw); // the template follows the saved look as T<n+1> (canvas TplA)
536
549
  D.writeBrief(root, c.cadence, setupOf(root, c.cadence));
537
550
  }
538
551
  const r = D.decideCycle(root, c.rel, { diff: diff.id, choice, newVersion });
@@ -597,7 +610,9 @@ async function handle(req, res, url, ctx) {
597
610
  const n = body.n;
598
611
  if (!Number.isInteger(n) || !D.stateOf(root, id).versions.has(n)) return send(bad(404, 'no such version'));
599
612
  if (sub === 'revert') { D.append(root, id, { ev: 'revert', n }, { actor: g.actor }); return answer(); }
613
+ const tw = RT.followBefore(root, id, setup);
600
614
  D.append(root, id, { ev: 'accept', n, rules: D.rulesFor(root, id, n) }, { actor: g.actor });
615
+ RT.followAfter(root, id, { design: n }, tw); // the template follows the saved look as T<n+1> (canvas TplA)
601
616
  const b = D.writeBrief(root, id, setup);
602
617
  if (!b.ok) return fail(b);
603
618
  // the current cycle is redrawn in the saved design at once (Wawan, 2026-10-13); older cycles, and a cycle already sent, keep their look