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
|
@@ -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
|
-
|
|
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 }
|
|
8
|
-
* list(folded, counts?) → [workflow + { count }]
|
|
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
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 };
|
package/workspace/lib/grill.js
CHANGED
|
@@ -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
|
|
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).
|
package/workspace/lib/impact.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
148
|
-
|
|
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
|
|
154
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
189
|
-
if (!
|
|
190
|
-
|
|
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
|
|
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) });
|