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
@@ -74,6 +74,28 @@ const pathOf = (v) => (typeof v === 'string' && /^[\w./-]+\.md$/.test(v.trim())
74
74
 
75
75
  /** The default io: the files of `root`, its git history (one `git log` per HEAD), the Jira draft and reading, the OKR anchors. */
76
76
  const gitCache = new Map();
77
+ /** The size and time of HEAD and its reflog in the git directory that holds `root` (a linked worktree's own one too), or null when they
78
+ * cannot be read: then the caller asks git, as before. */
79
+ function headStamp(root) {
80
+ let dir = path.resolve(root);
81
+ for (let i = 0; i < 40; i++) {
82
+ const dot = path.join(dir, '.git');
83
+ let gitDir = null;
84
+ try {
85
+ const st = fs.statSync(dot);
86
+ if (st.isDirectory()) gitDir = dot;
87
+ else { const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dot, 'utf8')); if (m) gitDir = path.resolve(dir, m[1].trim()); }
88
+ } catch { /* not here: look one level up */ }
89
+ if (gitDir) {
90
+ try {
91
+ const a = fs.statSync(path.join(gitDir, 'HEAD')), b = fs.statSync(path.join(gitDir, 'logs', 'HEAD'));
92
+ return gitDir + '|' + a.size + ':' + a.mtimeMs + '|' + b.size + ':' + b.mtimeMs;
93
+ } catch { return null; }
94
+ }
95
+ const up = path.dirname(dir); if (up === dir) return null; dir = up;
96
+ }
97
+ return null;
98
+ }
77
99
  function defaultIo(root) {
78
100
  const read = (rel) => { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } };
79
101
  return {
@@ -81,16 +103,21 @@ function defaultIo(root) {
81
103
  list: (dir) => { try { return fs.readdirSync(path.join(root, dir)); } catch { return []; } },
82
104
  records: () => { try { return fs.readdirSync(path.join(root, 'planning', 'initiatives')).filter((f) => f.endsWith('.md') && f !== 'README.md' && f !== 'index.md').map((f) => 'planning/initiatives/' + f); } catch { return []; } },
83
105
  firstSeen: () => {
106
+ // page-open-speed: HEAD's reflog moves on every commit, checkout and reset, so while it is unchanged the cached map stands and
107
+ // no `git` runs at all (the trail asked `git rev-parse` on every Board open, ~30 ms on the thread every answer waits for).
108
+ const hit = gitCache.get(root);
109
+ const stamp = headStamp(root);
110
+ if (hit && stamp && hit.stamp === stamp) return hit.map;
84
111
  let head = '';
85
112
  try { head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return new Map(); }
86
- const hit = gitCache.get(root); if (hit && hit.head === head) return hit.map;
113
+ if (hit && hit.head === head) { hit.stamp = stamp; return hit.map; }
87
114
  const map = new Map();
88
115
  try {
89
116
  const out = execFileSync('git', ['-C', root, 'log', '--diff-filter=A', '--name-only', '--format=@@%at', '--', 'planning'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
90
117
  let at = 0;
91
118
  for (const line of out.split('\n')) { if (line.startsWith('@@')) at = Number(line.slice(2)) * 1000; else if (line.trim()) map.set(line.trim(), at); } // newest first: the last write is the first appearance
92
119
  } catch { /* not a git checkout: no dates */ }
93
- gitCache.set(root, { head, map });
120
+ gitCache.set(root, { head, map, stamp });
94
121
  return map;
95
122
  },
96
123
  jira: (id) => {
@@ -258,4 +285,4 @@ function trail({ root, ids, rows = [], moves = [], now = Date.now(), io } = {})
258
285
  return { ok: true, at: new Date(now).toISOString(), usual, usualBy, themes: out, items };
259
286
  }
260
287
 
261
- module.exports = { trail, frontmatter, partsOf, ladderDates, median, STAGES, MIN_SAMPLES, TONES };
288
+ module.exports = { trail, defaultIo, frontmatter, partsOf, ladderDates, median, STAGES, MIN_SAMPLES, TONES };
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * CATS · ENDS · OUTPUT_KINDS · WHEN_MODES · DECIDE · ICONS · PRODUCT_FEATURE_ID · PRODUCT_FEATURE
6
6
  * check(wf, { existing }) → { ok, workflow } | { ok:false, status, error } validate and normalise one workflow
7
- * fold(lines) → { byId: Map, draft } the library (Product feature first)
8
- * list(folded, counts?) → [workflow + { count }] Product feature first, archived last
7
+ * fold(lines) → { byId: Map, draft, defaultId } the library (Product feature first)
8
+ * list(folded, counts?) → [workflow + { count, default? }] Product feature first, archived last
9
+ * defaultOf(folded) → the workflow a new initiative starts on (Product feature when none is set or it is archived)
9
10
  * resolve(folded, id) → the workflow (Product feature when the id is unknown or missing)
10
11
  * stageByRef(wf, ref) → the stage a key or a name names, or null
11
12
  * switchTarget(from, to, stageKey) → the key an initiative lands on when it switches workflow (the bridge's stage, see mapOf)
@@ -27,6 +28,8 @@
27
28
  * wf-save { wf } create or edit (Product feature is never saved: it is built in)
28
29
  * wf-archive { id, archived }
29
30
  * wf-draft { draft | null, note? } the one open draft the chat wrote (null clears it)
31
+ * wf-default { id } the workflow a new initiative starts on, for this workspace (batch 24 item 3, canvas DfA). The last
32
+ * line wins. Product feature can be the default. An unknown or archived id reads as Product feature.
30
33
  */
31
34
 
32
35
  const { STAGES: LIFE, LABELS, OUTPUTS, MILESTONES } = require('./nine-stages');
@@ -134,7 +137,7 @@ function check(wf) {
134
137
  /** Fold workflows.jsonl (objects or JSON strings), oldest first. */
135
138
  function fold(lines) {
136
139
  const byId = new Map([[PRODUCT_FEATURE_ID, PRODUCT_FEATURE]]);
137
- let draft = null;
140
+ let draft = null, defaultId = null;
138
141
  for (const raw of lines || []) {
139
142
  let e = raw;
140
143
  if (typeof raw === 'string') { try { e = JSON.parse(raw); } catch { continue; } }
@@ -153,14 +156,24 @@ function fold(lines) {
153
156
  byId.set(e.id, w);
154
157
  } else if (e.kind === 'wf-draft') {
155
158
  draft = isObj(e.draft) ? { draft: e.draft, note: typeof e.note === 'string' ? e.note : '', at: e.at || null } : null;
159
+ } else if (e.kind === 'wf-default' && typeof e.id === 'string' && e.id) {
160
+ defaultId = e.id;
156
161
  }
157
162
  }
158
- return { byId, draft };
163
+ return { byId, draft, defaultId };
164
+ }
165
+
166
+ /** The workflow a new initiative starts on: the last wf-default line's workflow, or Product feature when none is set, the id is not in the
167
+ * library, or that workflow is archived. */
168
+ function defaultOf(folded) {
169
+ const w = folded && folded.defaultId ? folded.byId.get(folded.defaultId) : null;
170
+ return w && !w.archived ? w : PRODUCT_FEATURE;
159
171
  }
160
172
 
161
173
  /** The library as a list: Product feature first, then by name, archived last. counts: { [id]: n } initiatives on each. */
162
174
  function list(folded, counts = {}) {
163
- const all = [...folded.byId.values()].map((w) => ({ ...w, count: counts[w.id] || 0 }));
175
+ const def = defaultOf(folded).id;
176
+ const all = [...folded.byId.values()].map((w) => ({ ...w, count: counts[w.id] || 0, ...(w.id === def ? { default: true } : {}) }));
164
177
  const rank = (w) => (w.id === PRODUCT_FEATURE_ID ? 0 : w.archived ? 2 : 1);
165
178
  return all.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
166
179
  }
@@ -269,5 +282,5 @@ const milestonesOf = (wf) => (wf || PRODUCT_FEATURE).stages.filter((s) => s.mile
269
282
 
270
283
  module.exports = {
271
284
  CATS, CAT_LABEL, ENDS, OUTPUT_KINDS, WHEN_MODES, DECIDE, ICONS, PRODUCT_FEATURE_ID, PRODUCT_FEATURE,
272
- slug, check, fold, list, resolve, stageByRef, switchTarget, proposeBridge, mapOf, removedInUse, stagesOf, copyOf, milestonesOf,
285
+ slug, check, fold, list, defaultOf, resolve, stageByRef, switchTarget, proposeBridge, mapOf, removedInUse, stagesOf, copyOf, milestonesOf,
273
286
  };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ /* git-shape.js — where a folder sits in git, read from the disk with no `git` run (page-open-speed). */
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /** A cheap stamp of where `root` sits in git, read from the disk with no `git` run: the nearest `.git` entry above it, whether it is a
7
+ * directory (a main tree) or a file (a linked worktree, with the file's time: it names the tree's git directory).
8
+ * A directory's time is left out: git touches it on every commit, and a main tree stays the main tree. It changes when the folder becomes, or stops being, a repository
9
+ * or a worktree, so an answer about the repository's SHAPE (which tree is the main one) can be kept until it changes instead of asking
10
+ * `git` again on a clock (page-open-speed). 'none' when no `.git` is found up to the file-system root. */
11
+ function shapeStamp(root) {
12
+ if (!root) return 'none';
13
+ let dir = path.resolve(root);
14
+ for (let i = 0; i < 64; i++) {
15
+ try { const st = fs.statSync(path.join(dir, '.git')); return dir + '|' + (st.isDirectory() ? 'd' : 'f|' + st.mtimeMs); } catch { /* look one level up */ }
16
+ const up = path.dirname(dir); if (up === dir) break; dir = up;
17
+ }
18
+ return 'none';
19
+ }
20
+
21
+ module.exports = { shapeStamp };
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * deps: { workspaces() → [w], isLead(w), stateDirFor(w), runtimeDir(), rowOf(w, id) → { title, intent, stage, stages } | null,
13
13
  * exits(w) → [{ ini, board, stage }] (stages about to end), shapes(w) → [{ ini, board, n, end }] (open tasks, latest due),
14
+ * looks(w) → [{ ini, board, n, end }] (running initiatives with open tasks, for the monthly look),
14
15
  * goalInis(w, goalId) → [{ id, endAt, status }] (the initiatives anchored to that goal), checks(w, day) → [{ ini, board, tasks }] (late or at-risk work), turned(dir, sinceIso) → [forecast `turned` line] (lib/forecast.js),
15
16
  * api { available(env), run({ prompt, root, env, budget, signal }) → Promise<{ ok, code, costUsd, note?, stopped? }> } (lib/grill-api.js; optional),
16
17
  * setting(key) (lib/settings-store.js get), open(now) → bool, headlessOff() → bool, env, promptFor(skillId), spawn(spec), stop(session), warn }
@@ -27,7 +28,7 @@ const WHY = {
27
28
  exit: 'The initiative is about to leave its stage. Grill it before it does: what would make the next stage fail?',
28
29
  forecast: 'Its forecast just turned. Grill it: why, and what would bring it back?',
29
30
  move: 'Its scope or dates moved a lot. Grill it: is the plan still the right plan?',
30
- monthly: 'The monthly look at running work that feels off (it has late work). Grill it.',
31
+ monthly: 'The monthly look at running work (late work first, then the initiatives grilled longest ago). Grill it.',
31
32
  ask: 'The PM asked for it in the initiative\'s chat. Grill it now: what would make it fail, and what is the next step?',
32
33
  };
33
34
 
@@ -50,6 +51,16 @@ function pickFor(inis) {
50
51
  return run.length ? run[0].id : null;
51
52
  }
52
53
 
54
+ /** PURE. The monthly look's order. late: [{ ini, board, tasks }] (late or at-risk work), open: [{ ini, board, n }] (running initiatives with
55
+ * open tasks), lastOf(ini) → ms of its last grill (0: never). → [{ ini, board, last, detail }]: late work first, then never grilled, then the
56
+ * one grilled longest ago; within each, the most open work first. */
57
+ function monthlyOrder(late, open, lastOf) {
58
+ const out = new Map();
59
+ for (const c of late || []) if (c && c.ini && !out.has(c.ini)) out.set(c.ini, { ini: c.ini, board: c.board, last: lastOf(c.ini), late: true, n: (c.tasks || []).length, detail: (c.tasks || []).length + ' late or at-risk tasks' });
60
+ for (const c of open || []) if (c && c.ini && !out.has(c.ini) && Number(c.n) > 0) out.set(c.ini, { ini: c.ini, board: c.board, last: lastOf(c.ini), late: false, n: Number(c.n), detail: c.n + ' open tasks' + (c.end ? ', the latest due ' + c.end : '') });
61
+ return [...out.values()].sort((a, b) => (b.late - a.late) || (a.last - b.last) || (b.n - a.n));
62
+ }
63
+
53
64
  function create(deps) {
54
65
  const active = new Map(); // run → { session }
55
66
  const warn = deps.warn || (() => {});
@@ -147,10 +158,14 @@ function create(deps) {
147
158
  const what = G.moved(prev, cur);
148
159
  if (what) out.push({ ws: w.id, board: s.board, ini: s.ini, why: 'move', key: `move:${w.id}:${s.ini}:${cur.n}:${cur.end}`, detail: what });
149
160
  }
150
- for (const c of tryList(() => deps.checks && deps.checks(w, localDay(now)))) {
151
- const last = Date.parse(F.lastAt.get(w.id + '|' + c.ini) || '') || 0;
152
- if (now - last < G.MONTHLY_DAYS * 864e5) continue;
153
- out.push({ ws: w.id, board: c.board, ini: c.ini, why: 'monthly', key: `monthly:${w.id}:${c.ini}:${G.monthOf(now)}`, detail: (c.tasks || []).length + ' late or at-risk tasks' });
161
+ // the monthly look: every running initiative with open work, at most once in 30 days each (Wawan 2026-11-02: "why only one?").
162
+ // Late work first, then the ones never grilled, then the ones grilled longest ago; the cap decides how far down the list a month goes.
163
+ const late = tryList(() => deps.checks && deps.checks(w, localDay(now))), seen = new Set();
164
+ const looks = monthlyOrder(late, tryList(() => deps.looks && deps.looks(w)), (ini) => Date.parse(F.lastAt.get(w.id + '|' + ini) || '') || 0);
165
+ for (const c of looks) {
166
+ if (seen.has(c.ini)) continue; seen.add(c.ini);
167
+ if (now - c.last < G.MONTHLY_DAYS * 864e5) continue;
168
+ out.push({ ws: w.id, board: c.board, ini: c.ini, why: 'monthly', key: `monthly:${w.id}:${c.ini}:${G.monthOf(now)}`, detail: c.detail });
154
169
  }
155
170
  return out;
156
171
  }
@@ -182,4 +197,4 @@ function create(deps) {
182
197
  return { tick, start, stop, dueOf, active: () => [...active.keys()] };
183
198
  }
184
199
 
185
- module.exports = { create, promptOf, pickFor, WHY, MAX };
200
+ module.exports = { create, promptOf, pickFor, monthlyOrder, WHY, MAX };
@@ -15,7 +15,7 @@
15
15
  * exit the initiative is about to leave its stage (most of the stage's tasks are closed), once per stage
16
16
  * forecast its forecast, or the forecast of a goal it moves, turned at-risk or will-miss (lib/forecast.js `turned` lines), once per turn
17
17
  * move its scope or dates moved a lot since the last look (tasks ±30% and 3 or more, or the end ±14 days)
18
- * monthly a running initiative with late work, at most once in 30 days
18
+ * monthly every running initiative with open work, at most once in 30 days each: late work first, then the ones grilled longest ago
19
19
  * `exits` runs only the first. Every run counts against the monthly credit cap (grill.cap, US dollars): before a run the cap must hold
20
20
  * the run's whole budget, which goes to the run as --max-budget-usd, so the month never goes beyond the cap. At the cap Claude pauses and
21
21
  * says so once per month (a `paused` line).
@@ -9,6 +9,10 @@
9
9
  * ticket that moved after your decision, a spec used, a KR that moved after your initiative delivered), with the source to open it. Never
10
10
  * logins, ticks, time, messages sent, or Claude's own work.
11
11
  *
12
+ * COUNTED BY DEFAULT (batch 23 item 4, canvas IcA). An item counts unless the PM drops it (✕, "Not right, drop it"). The check of week W is open
13
+ * during the week after W; when that week ends, every row of W nobody dropped is real. A closed week is judged for the gate only when the
14
+ * check card was shown to the PM (a `looked` line), so the gate never opens on weeks nobody saw. An explicit ✓ (older ledgers) still counts.
15
+ *
12
16
  * NO NUMBERS LEAVE THIS FILE. The views carry words: grow is rest · bud · leaf · full; the gate is measuring · open · short. The gate's rate
13
17
  * is written to the ledger (the brief's "record the rate") and never shown.
14
18
  *
@@ -18,6 +22,7 @@
18
22
  * end { run, ok, error? } the run ended
19
23
  * verdict { id, real: true | false } the PM's ✓ or ✕ in the weekly check
20
24
  * undo { id } a row back to unchecked
25
+ * looked { week } the weekly check card was shown to the PM for that week
21
26
  * seen { ids } read on a Phase 2 surface; ends its celebration
22
27
  * gate { state, real, judged, weeks } the gate changed: measuring → open (and stays), or → short
23
28
  *
@@ -34,7 +39,7 @@ const path = require('path');
34
39
  const crypto = require('crypto');
35
40
 
36
41
  const LEDGER = 'impact.jsonl';
37
- const LINES = new Set(['run', 'item', 'end', 'verdict', 'undo', 'seen', 'gate']);
42
+ const LINES = new Set(['run', 'item', 'end', 'verdict', 'undo', 'seen', 'gate', 'looked']);
38
43
  const KINDS = ['thanks', 'unblock', 'used', 'kr'];
39
44
  const SOURCE_TYPES = ['slack', 'mail', 'jira', 'doc', 'meeting', 'kr', 'board', 'initiative'];
40
45
  const GROW = ['rest', 'bud', 'leaf', 'full'];
@@ -115,7 +120,7 @@ function clean(input) {
115
120
 
116
121
  /* ── the fold ── */
117
122
  function fold(lines) {
118
- const items = new Map(), runs = [];
123
+ const items = new Map(), runs = [], looked = new Set();
119
124
  let gate = { state: 'measuring' };
120
125
  const runOf = (id) => runs.find((r) => r.run === id);
121
126
  for (const l of lines || []) {
@@ -129,10 +134,11 @@ function fold(lines) {
129
134
  items.set(c.item.id, { ...c.item, real: null, read: false, filed: str(l.at), run: str(l.run) });
130
135
  } else if (l.kind === 'verdict' && str(l.id)) { const it = items.get(l.id); if (it && typeof l.real === 'boolean') { it.real = l.real; it.checkedAt = str(l.at); } }
131
136
  else if (l.kind === 'undo' && str(l.id)) { const it = items.get(l.id); if (it) { it.real = null; it.read = false; } }
132
- else if (l.kind === 'seen' && Array.isArray(l.ids)) { for (const id of l.ids) { const it = items.get(String(id)); if (it && it.real === true) it.read = true; } }
137
+ else if (l.kind === 'seen' && Array.isArray(l.ids)) { for (const id of l.ids) { const it = items.get(String(id)); if (it && it.real !== false) it.read = true; } }
133
138
  else if (l.kind === 'gate' && ['measuring', 'open', 'short'].includes(l.state)) { if (gate.state !== 'open') gate = { state: l.state, at: str(l.at) }; }
139
+ else if (l.kind === 'looked' && DAY_RE.test(str(l.week))) looked.add(l.week);
134
140
  }
135
- return { items, runs, gate };
141
+ return { items, runs, gate, looked };
136
142
  }
137
143
 
138
144
  const weekIs = (it, start) => weekOf(dayOf(Date.parse(it.at)), start);
@@ -144,14 +150,21 @@ function itemsByWeek(F, start) {
144
150
  return m;
145
151
  }
146
152
 
147
- /** PURE. The gate before Phase 2: a checked week is a finished week whose every item has ✓ or ✕. Across the checked weeks, at least 9 of 10
148
- * judged items real, over at least two weeks and ten items: open (and it stays open once written). Ten or more judged and under 9 of 10: short. */
153
+ /** The check of week `w` is closed once the week after it has ended: rows nobody dropped are real from then on. */
154
+ const closed = (w, thisWeek) => w < addDays(thisWeek, -7);
155
+ /** Counts in your impact: not dropped. A row still in its open check counts too (the card says so: "counted them in your impact"). */
156
+ const counts = (x) => x.real !== false;
157
+
158
+ /** PURE. The gate before Phase 2: a judged week is a finished week whose every item has ✓ or ✕, or a closed week whose check card the PM saw
159
+ * (rows nobody dropped are real). Across the judged weeks, at least 9 of 10 items real, over at least two weeks and ten items: open (and it
160
+ * stays open once written). Ten or more judged and under 9 of 10: short. */
149
161
  function gateOf(F, thisWeek, start = 'mon') {
150
- const W = itemsByWeek(F, start);
162
+ const W = itemsByWeek(F, start), saw = F.looked || new Set();
151
163
  let real = 0, judged = 0, weeks = 0;
152
164
  for (const [w, list] of W) {
153
- if (w >= thisWeek || !list.length || list.some((x) => x.real === null)) continue;
154
- weeks++; judged += list.length; real += list.filter((x) => x.real === true).length;
165
+ if (w >= thisWeek || !list.length) continue;
166
+ if (list.some((x) => x.real === null) && !(closed(w, thisWeek) && saw.has(w))) continue;
167
+ weeks++; judged += list.length; real += list.filter(counts).length;
155
168
  }
156
169
  if (F.gate && F.gate.state === 'open') return { state: 'open', real, judged, weeks };
157
170
  const state = weeks < GATE_WEEKS || judged < GATE_ITEMS ? 'measuring' : real * 10 >= judged * 9 ? 'open' : 'short';
@@ -163,7 +176,7 @@ const growOf = (n) => GROW[n <= 0 ? 0 : n === 1 ? 1 : n <= 3 ? 2 : 3];
163
176
  function weekView(F, o) {
164
177
  const thisWeek = weekOf(o.today, o.start), week = o.week && DAY_RE.test(o.week) ? weekOf(o.week, o.start) : thisWeek;
165
178
  const live = gateOf(F, thisWeek, o.start).state === 'open';
166
- const real = live ? (itemsByWeek(F, o.start).get(week) || []).filter((x) => x.real === true).sort(byNewest) : [];
179
+ const real = live ? (itemsByWeek(F, o.start).get(week) || []).filter(counts).sort(byNewest) : [];
167
180
  const cel = real.find((x) => !x.read);
168
181
  return { live, first: F.items.size === 0, week, from: week, to: addDays(week, 6), label: labelOf(week, thisWeek), grow: growOf(real.length),
169
182
  celebrate: cel ? cel.id : null, quiet: real.length === 0, items: real.map(shown) };
@@ -176,19 +189,19 @@ function weeksView(F, o) {
176
189
  const W = itemsByWeek(F, o.start), all = [...W.keys()].filter((w) => w <= thisWeek).sort();
177
190
  const weeks = [];
178
191
  if (all.length) for (let w = thisWeek; w >= all[0]; w = addDays(w, -7)) {
179
- const real = (W.get(w) || []).filter((x) => x.real === true).sort(byNewest);
192
+ const real = (W.get(w) || []).filter(counts).sort(byNewest);
180
193
  weeks.push({ week: w, from: w, to: addDays(w, 6), label: labelOf(w, thisWeek), quiet: real.length === 0, items: real.map(shown) });
181
194
  }
182
195
  return { live, weeks };
183
196
  }
184
- /** PURE. GET /api/impact/check: the oldest finished week with a row not checked yet, all its rows (checked ones keep their mark). */
197
+ /** PURE. GET /api/impact/check: last week's rows while its check is open (the whole of this week); dropped rows keep their mark for Undo.
198
+ * looked: the card was shown already this week, so the row under Mentions goes; the card stays Home's chat intro until the week ends. */
185
199
  function checkView(F, o) {
186
200
  const thisWeek = weekOf(o.today, o.start), W = itemsByWeek(F, o.start);
187
201
  const gate = gateOf(F, thisWeek, o.start).state;
188
- const open = [...W.keys()].filter((w) => w < thisWeek && W.get(w).some((x) => x.real === null)).sort();
189
- if (!open.length) return { due: false, week: null, label: '', items: [], gate };
190
- const week = open[0];
191
- return { due: true, week, label: labelOf(week, thisWeek), items: W.get(week).sort(byNewest).map(shown), gate };
202
+ const week = addDays(thisWeek, -7), list = W.get(week) || [];
203
+ if (!list.length) return { due: false, week: null, label: '', items: [], gate, looked: false };
204
+ return { due: true, week, label: labelOf(week, thisWeek), items: list.sort(byNewest).map(shown), gate, looked: (F.looked || new Set()).has(week) };
192
205
  }
193
206
 
194
207
  module.exports = { LEDGER, KINDS, SOURCE_TYPES, GROW, GATE_ITEMS, GATE_WEEKS, COUNT_RE, dirOf, ledgerFile, readLines, read, append, clean, fold,
@@ -35,6 +35,18 @@ const triage = require('./triage.js');
35
35
  const lifecycle = require('./lifecycle-stage.js');
36
36
  const { parseArgs } = require('./schedule-cli');
37
37
 
38
+ /** Batch 24 item 3: the new initiative starts on this repository's default workflow (routes-boards startOnDefault), as a page create does. */
39
+ function startOnDefault(root, r, actor) {
40
+ try {
41
+ const D = require('./driver.js');
42
+ const dir = D.stateDirOf(root);
43
+ if (!dir) return;
44
+ const w = { id: D.wsIdOf(root), name: path.basename(root), root };
45
+ const ctx = { getWs: () => w, workspaceList: () => [w], stateDirFor: () => dir, selfRoot: path.resolve(__dirname, '..', '..') };
46
+ require('./routes-boards.js').startOnDefault(ctx, w, r.id, actor, { recordPath: r.path });
47
+ } catch { /* the initiative is made; it runs Product feature */ }
48
+ }
49
+
38
50
  const COMMANDS = { initiative: ['new', 'top', 'up', 'down', 'bottom', 'after', 'check-by', 'adopt', 'drop', 'list', 'export'] };
39
51
  /** The horizon verbs D105 retired: they answer with what to use instead. */
40
52
  const RETIRED = Object.freeze(['now', 'next', 'later']);
@@ -121,8 +133,10 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
121
133
  }
122
134
  if (flags.title) input.title = flags.title;
123
135
  for (const k of ['lane', 'anchor', 'intent', 'id']) if (flags[k]) input[k] = flags[k];
124
- const r = writer.createInitiative(root, input, { actor: actorLib.fromCli(who), now });
136
+ const actor = actorLib.fromCli(who);
137
+ const r = writer.createInitiative(root, input, { actor, now });
125
138
  if (!r.ok) return fail(r.error);
139
+ startOnDefault(root, r, actor);
126
140
  return done({ id: r.id, path: r.path }, `✓ ${r.id} added at the end of the Roadmap (${r.path}). Commit the record to keep it.`);
127
141
  }
128
142
 
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ /* open-mission: a browser-test helper. The initiative mission (the telemetry card, the way to finish, About) starts hidden in its
3
+ * banner (batch 24 item 1). Suites that read the open card wrap their context with this, as a PM who unfolded it: any initiative
4
+ * with no fold of its own in the browser reads as open. A suite that checks the banner default does not use it. */
5
+ async function openMission(ctx) {
6
+ await ctx.addInitScript(() => {
7
+ try {
8
+ const g = Storage.prototype.getItem;
9
+ Storage.prototype.getItem = function (k) { const v = g.call(this, k); return v == null && /^pmos\.iv\.mc\./.test(String(k)) ? '{"mc":true}' : v; };
10
+ } catch { /* no storage: the default holds */ }
11
+ });
12
+ return ctx;
13
+ }
14
+ module.exports = { openMission };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+ /* page-warm.js — the first page after a start opens as fast as a later one (page-open-speed).
3
+ *
4
+ * warm({ host, port, paths, env, get }) → Promise<{ ran, ok } | { skipped }>
5
+ *
6
+ * Nothing is read until a page asks, so the first Home after PMOS starts made the server read every record cold: about 3 s against
7
+ * about 1.5 s for a later open (bench on the PM's data, 2026-11-02). A few seconds after the start, the server asks itself for the
8
+ * heaviest answers Home and the Board read, ONE AT A TIME over loopback, exactly as a page asks for them, so their read caches are
9
+ * filled before the PM opens a page. Only GET routes that write no "seen" or "read" mark are on the list: a warm-up is not the PM
10
+ * looking. PMOS_WARM=off stops it; a state folder under the system temp folder (a suite's) does not warm unless PMOS_WARM=on. */
11
+ const PATHS = [
12
+ '/api/board-premium',
13
+ '/api/my-work-premium',
14
+ '/api/news-for-you',
15
+ '/api/briefing-premium/products',
16
+ '/api/programs',
17
+ '/api/sources',
18
+ '/api/notes',
19
+ '/api/live',
20
+ '/api/insights-premium',
21
+ ];
22
+
23
+ function httpGet(host, port, p) {
24
+ return new Promise((resolve) => {
25
+ const req = require('http').get({ host, port, path: p, headers: { accept: 'application/json' }, timeout: 30000 }, (res) => {
26
+ res.resume(); res.on('end', () => resolve(res.statusCode)); res.on('error', () => resolve(0));
27
+ });
28
+ req.on('timeout', () => { req.destroy(); resolve(0); });
29
+ req.on('error', () => resolve(0));
30
+ });
31
+ }
32
+
33
+ async function warm({ host, port, paths = PATHS, env = process.env, get = httpGet } = {}) {
34
+ if (String(env.PMOS_WARM || '').toLowerCase() === 'off') return { skipped: 'off' };
35
+ if (!host || !port) return { skipped: 'no loopback address' };
36
+ let ok = 0;
37
+ for (const p of paths) { const code = await get(host, port, p); if (code >= 200 && code < 300) ok++; }
38
+ return { ran: paths.length, ok };
39
+ }
40
+
41
+ /** The loopback address among the server's binds, or null when it listens on none (a tailnet-only bind is not asked). */
42
+ function loopbackOf(binds) {
43
+ const b = (binds || []).find((a) => a === '127.0.0.1' || a === 'localhost' || a === '::1' || a === '0.0.0.0' || a === '::');
44
+ if (!b) return null;
45
+ return b === '::1' || b === '::' ? '::1' : '127.0.0.1';
46
+ }
47
+
48
+ /** The server's wiring: when `start`, one warm-up DELAY_MS after the boot (the server is listening by then; a request that finds it not
49
+ * yet up just fails and the page reads as before). The timer is unref'd, so it never holds a process open. */
50
+ const DELAY_MS = 5000;
51
+ /** Whether a start warms: PMOS_WARM=on or off decides; unset, a state folder under the system temp folder (every suite's, test-run.js)
52
+ * does not warm, so no suite's server reads, or writes a forecast line, while the suite does. The PM's own state folder warms. */
53
+ function shouldWarm(env, stateDir) {
54
+ const v = String(env.PMOS_WARM || '').toLowerCase();
55
+ if (v === 'off') return false;
56
+ if (v === 'on') return true;
57
+ if (!stateDir) return true;
58
+ const os = require('os'), path = require('path');
59
+ const rel = path.relative(path.resolve(os.tmpdir()), path.resolve(stateDir));
60
+ return !(rel && !rel.startsWith('..') && !path.isAbsolute(rel));
61
+ }
62
+ function create({ binds, port, stateDir = null, start = true, env = process.env, delayMs = DELAY_MS } = {}) {
63
+ const run = () => warm({ host: loopbackOf(binds), port: typeof port === 'function' ? port() : port, env }).catch(() => ({ failed: true }));
64
+ if (start && shouldWarm(env, stateDir)) { const t = setTimeout(run, delayMs); if (t.unref) t.unref(); }
65
+ return { run };
66
+ }
67
+
68
+ module.exports = { create, warm, shouldWarm, loopbackOf, PATHS };
@@ -28,6 +28,8 @@
28
28
  * version { n, format, turn, answer, from } a job (or an adopted cycle) landed D<n>
29
29
  * accept { n, rules } "This is it": D<n> is the design; brief.md is written
30
30
  * revert { n } the next turn builds on D<n>
31
+ * unsave {} Go back to a template version with the standard look: no design is saved
32
+ * (lib/routes-report-flow.js restore; brief.md is removed, cycles keep their look)
31
33
  * error { text, mode, slot?, sha? } a job failed, timed out or wrote nothing usable
32
34
  * cycle-decision { path, slot, diff, choice } keep | adopt | match on a cycle that differs
33
35
  *
@@ -48,7 +50,7 @@ const zlib = require('zlib');
48
50
  const RD = require('./report-draft.js');
49
51
  const issues = require('./issues.js');
50
52
 
51
- const EVENTS = Object.freeze(['describe', 'sample', 'unsample', 'fetched', 'took', 'turn', 'version', 'accept', 'revert', 'error', 'cycle-decision']);
53
+ const EVENTS = Object.freeze(['describe', 'sample', 'unsample', 'fetched', 'took', 'turn', 'version', 'accept', 'revert', 'unsave', 'error', 'cycle-decision']);
52
54
  const FORMATS = Object.freeze(['deck', 'doc']);
53
55
  const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'pictures', 'link', 'pmos-doc']);
54
56
  const MAX_PICTURES = 12;
@@ -149,6 +151,9 @@ function fold(events) {
149
151
  } else if (l.ev === 'revert') {
150
152
  if (st.versions.has(l.n)) st.current = l.n;
151
153
  st.log.push({ at: l.at, who: 'you', kind: 'revert', text: `Back to D${l.n}. The next turn builds on it.`, version: l.n });
154
+ } else if (l.ev === 'unsave') {
155
+ st.saved = null; st.rules = null;
156
+ st.log.push({ at: l.at, who: whoOf(l), kind: 'unsave', text: 'Back to the standard look (a template went back).' });
152
157
  } else if (l.ev === 'error') {
153
158
  st.errors.push({ at: l.at, text: str(l.text), mode: l.mode || null, slot: l.slot || null, sha: l.sha || null });
154
159
  st.log.push({ at: l.at, who: 'pmos', kind: 'error', text: str(l.text) });