create-pmos 0.59.2 → 0.60.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 (47) hide show
  1. package/package.json +1 -1
  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/scripts/delivery-index.js +85 -7
  6. package/payload/scripts/workflow-state.js +3 -3
  7. package/payload/state/discoveries.md +1 -1
  8. package/workspace/REMOTE.md +1 -1
  9. package/workspace/fixtures/initiatives-api/sheet.json +29 -7
  10. package/workspace/guide/index.md +3 -3
  11. package/workspace/guide/initiatives-screen.md +25 -1
  12. package/workspace/guide/layout.md +7 -9
  13. package/workspace/guide/proactive-engine.md +1 -0
  14. package/workspace/guide/today.md +23 -1
  15. package/workspace/lib/calendar-memory.js +257 -0
  16. package/workspace/lib/console-state.js +1 -1
  17. package/workspace/lib/doc-category.js +3 -1
  18. package/workspace/lib/engine/delivery-index.js +85 -7
  19. package/workspace/lib/engine/workflow-state.js +3 -3
  20. package/workspace/lib/initiative-files.js +59 -1
  21. package/workspace/lib/initiative-rows.js +14 -2
  22. package/workspace/lib/mobile-nav.js +1 -1
  23. package/workspace/lib/plan-tree.js +44 -5
  24. package/workspace/lib/proactive-cli.js +16 -1
  25. package/workspace/lib/routes-initiatives.js +165 -9
  26. package/workspace/package-lock.json +1 -1
  27. package/workspace/package.json +1 -1
  28. package/workspace/public/index.html +1 -1
  29. package/workspace/public/pm/components.css +70 -0
  30. package/workspace/public/pm/dest-initiatives.js +7 -1
  31. package/workspace/public/pm/dest-settings.js +1 -0
  32. package/workspace/public/pm/frame-copy.js +14 -2
  33. package/workspace/public/pm/initiative-sheet.js +42 -10
  34. package/workspace/public/pm/initiatives-api.js +15 -0
  35. package/workspace/public/pm/initiatives-copy.js +13 -0
  36. package/workspace/public/pm/initiatives-ui.js +14 -0
  37. package/workspace/public/pm/mobile.js +4 -5
  38. package/workspace/public/pm/registry.js +1 -1
  39. package/workspace/public/pm/tab-term.js +6 -50
  40. package/workspace/public/pm/today-agenda.js +305 -0
  41. package/workspace/public/pm/today-copy.js +54 -2
  42. package/workspace/public/pm/today-day.js +23 -3
  43. package/workspace/public/pm/today-goals.js +7 -1
  44. package/workspace/server.js +71 -63
  45. package/workspace/skills/morning-brief.skill +6 -1
  46. package/workspace/test-files.json +2 -0
  47. package/workspace/test-lists/scan-consistency.json +5 -0
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+ /* lib/calendar-memory.js — the calendar the Agenda remembers (initiative today-goals, PRD G23 to G25).
3
+ *
4
+ * validateMemory(raw, { now }) → { ok, memory } | { ok:false, error }
5
+ * mergeMemory(stored, memory, { now }) → { ok, memory } the stored file with the submitted window replaced
6
+ * seriesKey(e) · normTitle(title) → the key that joins the occurrences of one meeting
7
+ * kindOf(e) · isImportant(e) → 'one' | 'onboard' | 'group' · true | false
8
+ * agendaMonth({ memory, brief, month, now }) → the month the Agenda draws: weeks, days with their three answers, series
9
+ *
10
+ * The morning-brief session reads the calendar wide (90 days back, 31 ahead) and submits it with the brief. The workspace
11
+ * checks it here and keeps it in one file. Every answer the month gives is derived here, with no model and no I/O:
12
+ * how full minutes in meetings between 09:00 and 18:00 (overlaps merged), and the free blocks of 90 minutes or more;
13
+ * key meeting the one meeting of the day that matters, named only when it is important or its prep is at risk;
14
+ * readiness prep done out of prep needed, for the coming occurrence of each meeting; at risk when the free time left
15
+ * before the meeting is shorter than 30 minutes for each step left.
16
+ * Today's events come from the brief when there is one, so a meeting keeps its purpose, prep and steps.
17
+ */
18
+
19
+ const crypto = require('crypto');
20
+
21
+ const MAX_EVENTS = 1000;
22
+ const MAX_SPAN_DAYS = 140;
23
+ const KEEP_BACK_DAYS = 100;
24
+ const KEEP_AHEAD_DAYS = 45;
25
+ const ROLES = ['present', 'decide', 'attend'];
26
+ const DAY_START = 9 * 60;
27
+ const DAY_END = 18 * 60;
28
+ const FREE_BLOCK = 90; // a free block worth naming, in minutes
29
+ const PER_STEP = 30; // minutes a prep step needs
30
+ const PER_DAY = 60; // minutes of a whole working day before the meeting that count toward its prep
31
+ const ROUTINE_COUNT = 4; // a group series seen this often, with nothing for the PM, is routine
32
+ const DAY_MS = 86400000;
33
+
34
+ const sha12 = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
35
+ const pad = (n) => String(n).padStart(2, '0');
36
+ const localDate = (t) => { const d = new Date(t); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
37
+ const minuteOfDay = (t) => { const d = new Date(t); return d.getHours() * 60 + d.getMinutes(); };
38
+ const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
39
+ const dayStartMs = (date) => { const [y, m, d] = date.split('-').map(Number); return new Date(y, m - 1, d).getTime(); };
40
+ const addDays = (date, n) => { const [y, m, d] = date.split('-').map(Number); return localDate(new Date(y, m - 1, d + n).getTime()); };
41
+ const weekday = (date) => { const [y, m, d] = date.split('-').map(Number); return new Date(y, m - 1, d).getDay(); }; // 0 Sunday
42
+ const tooLong = (s, n) => typeof s === 'string' && s.length > n;
43
+ const str = (v, n) => typeof v === 'string' && v.trim() && v.length <= n;
44
+
45
+ /** The title without numbers and counting words, so "Onboarding: Priya, week 2" and "… week 3" are one meeting. */
46
+ function normTitle(title) {
47
+ return String(title || '').toLowerCase()
48
+ .replace(/\b(week|wk|day|session|part|round|sprint|no|#)\s*\d+\b/g, ' ')
49
+ .replace(/\d+/g, ' ')
50
+ .replace(/[^a-zÀ-ɏ]+/g, ' ')
51
+ .replace(/\s+/g, ' ').trim();
52
+ }
53
+ function seriesKey(e) { return e && e.series ? 's:' + e.series : 't:' + normTitle(e && e.title); }
54
+
55
+ function kindOf(e) {
56
+ const t = String((e && e.title) || '');
57
+ if (/onboard/i.test(t)) return 'onboard';
58
+ if ((e && e.attendees === 2) || /\b1\s*[:/-]\s*1\b|one[- ]on[- ]one/i.test(t)) return 'one';
59
+ return 'group';
60
+ }
61
+ function isImportant(e) {
62
+ return !!e && (e.role === 'present' || e.role === 'decide' || e.external === true || e.exec === true || (typeof e.attendees === 'number' && e.attendees >= 8));
63
+ }
64
+
65
+ /* ── the check ─────────────────────────────────────────────────────────────────────────────────────────────────── */
66
+
67
+ function validateMemory(raw, { now = Date.now() } = {}) {
68
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'memory is an object: {"from", "to", "events"}' };
69
+ if (!isDate(raw.from) || !isDate(raw.to)) return { ok: false, error: 'memory needs "from" and "to" as YYYY-MM-DD' };
70
+ if (raw.from > raw.to) return { ok: false, error: 'memory "from" is after "to"' };
71
+ const span = Math.round((dayStartMs(raw.to) - dayStartMs(raw.from)) / DAY_MS) + 1;
72
+ if (span > MAX_SPAN_DAYS) return { ok: false, error: `memory covers ${span} days — at most ${MAX_SPAN_DAYS}` };
73
+ if (!Array.isArray(raw.events)) return { ok: false, error: 'memory "events" must be a list' };
74
+ if (raw.events.length > MAX_EVENTS) return { ok: false, error: `memory has ${raw.events.length} events — at most ${MAX_EVENTS}` };
75
+ const events = [];
76
+ const ids = new Set();
77
+ for (let i = 0; i < raw.events.length; i++) {
78
+ const e = raw.events[i];
79
+ const at = `memory event ${i + 1}`;
80
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, error: `${at} is not an object` };
81
+ if (!str(e.title, 120)) return { ok: false, error: `${at} needs a title of at most 120 characters` };
82
+ const s = Date.parse(e.start);
83
+ if (typeof e.start !== 'string' || Number.isNaN(s)) return { ok: false, error: `${at}: start must be a date and time` };
84
+ const f = e.end === undefined ? s + 30 * 60000 : Date.parse(e.end);
85
+ if (e.end !== undefined && (typeof e.end !== 'string' || Number.isNaN(f))) return { ok: false, error: `${at}: end must be a date and time` };
86
+ if (f < s) return { ok: false, error: `${at}: end is before start` };
87
+ const day = localDate(s);
88
+ if (day < raw.from || day > raw.to) return { ok: false, error: `${at}: ${day} is outside the window ${raw.from} to ${raw.to}` };
89
+ if (e.id !== undefined) return { ok: false, error: `${at}: id is derived by the workspace — do not give one` };
90
+ if (e.series !== undefined && !str(e.series, 200)) return { ok: false, error: `${at}: series is a text of at most 200 characters` };
91
+ if (e.attendees !== undefined && !(Number.isInteger(e.attendees) && e.attendees >= 0 && e.attendees <= 1000)) return { ok: false, error: `${at}: attendees is a whole number from 0 to 1000` };
92
+ for (const b of ['external', 'exec']) if (e[b] !== undefined && typeof e[b] !== 'boolean') return { ok: false, error: `${at}: ${b} is true or false` };
93
+ if (e.role !== undefined && !ROLES.includes(e.role)) return { ok: false, error: `${at}: role is one of ${ROLES.join(', ')}` };
94
+ const past = f <= now;
95
+ if (e.notes !== undefined) {
96
+ if (!str(e.notes, 240)) return { ok: false, error: `${at}: notes is one line of at most 240 characters` };
97
+ if (!past) return { ok: false, error: `${at}: notes are for a meeting that has ended` };
98
+ }
99
+ if (e.open !== undefined && (!Array.isArray(e.open) || e.open.length > 3 || e.open.some((x) => !str(x, 140)))) return { ok: false, error: `${at}: open is a list of at most 3 lines of at most 140 characters` };
100
+ if (e.prep !== undefined) {
101
+ if (!Array.isArray(e.prep) || e.prep.length > 6 || e.prep.some((x) => !str(x, 140))) return { ok: false, error: `${at}: prep is a list of at most 6 steps of at most 140 characters` };
102
+ if (past) return { ok: false, error: `${at}: prep is for a meeting that has not ended` };
103
+ }
104
+ if (e.prep_done !== undefined && !(Number.isInteger(e.prep_done) && e.prep_done >= 0 && e.prep_done <= (e.prep || []).length)) return { ok: false, error: `${at}: prep_done is a whole number from 0 to the number of prep steps` };
105
+ const title = e.title.trim();
106
+ const id = sha12(`${new Date(s).toISOString()}|${title}`);
107
+ if (ids.has(id)) return { ok: false, error: `${at}: two events have the same start and title` };
108
+ ids.add(id);
109
+ const out = { id, start: new Date(s).toISOString(), end: new Date(f).toISOString(), title };
110
+ for (const k of ['series', 'attendees', 'external', 'exec', 'role', 'notes', 'open', 'prep', 'prep_done']) if (e[k] !== undefined) out[k] = e[k];
111
+ events.push(out);
112
+ }
113
+ events.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
114
+ return { ok: true, memory: { from: raw.from, to: raw.to, events } };
115
+ }
116
+
117
+ /** The stored memory with the days of the submitted window replaced, trimmed to 100 days back and 45 ahead. */
118
+ function mergeMemory(stored, memory, { now = Date.now() } = {}) {
119
+ const old = stored && Array.isArray(stored.events) ? stored.events : [];
120
+ const keepFrom = localDate(now - KEEP_BACK_DAYS * DAY_MS);
121
+ const keepTo = localDate(now + KEEP_AHEAD_DAYS * DAY_MS);
122
+ const inWindow = (e) => { const d = localDate(Date.parse(e.start)); return d >= memory.from && d <= memory.to; };
123
+ const kept = old.filter((e) => !inWindow(e));
124
+ const events = [...kept, ...memory.events]
125
+ .filter((e) => { const d = localDate(Date.parse(e.start)); return d >= keepFrom && d <= keepTo; })
126
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
127
+ const from = [stored && stored.from, memory.from].filter(Boolean).sort()[0];
128
+ const to = [stored && stored.to, memory.to].filter(Boolean).sort().pop();
129
+ return { ok: true, memory: { from: from < keepFrom ? keepFrom : from, to: to > keepTo ? keepTo : to, updated_at: new Date(now).toISOString(), events } };
130
+ }
131
+
132
+ /* ── the month ─────────────────────────────────────────────────────────────────────────────────────────────────── */
133
+
134
+ /** Minutes in meetings inside the working day, overlaps merged, and the free blocks of FREE_BLOCK minutes or more. */
135
+ function dayLoad(evs) {
136
+ const iv = evs.map((e) => [Math.max(DAY_START, e.from), Math.min(DAY_END, e.to)]).filter(([a, b]) => b > a).sort((a, b) => a[0] - b[0]);
137
+ const merged = [];
138
+ for (const [a, b] of iv) { const last = merged[merged.length - 1]; if (last && a <= last[1]) last[1] = Math.max(last[1], b); else merged.push([a, b]); }
139
+ const busy = merged.reduce((n, [a, b]) => n + b - a, 0);
140
+ const gaps = [];
141
+ let c = DAY_START;
142
+ for (const [a, b] of merged) { if (a - c >= FREE_BLOCK) gaps.push([c, a]); c = Math.max(c, b); }
143
+ if (DAY_END - c >= FREE_BLOCK) gaps.push([c, DAY_END]);
144
+ return { busy, merged, gaps };
145
+ }
146
+ /** Free minutes of a day's working hours between `from` and `until` (minutes of the day). */
147
+ function freeBetween(merged, from, until) {
148
+ const a = Math.max(DAY_START, from); const b = Math.min(DAY_END, until);
149
+ if (b <= a) return 0;
150
+ let busy = 0;
151
+ for (const [s, e] of merged) busy += Math.max(0, Math.min(e, b) - Math.max(s, a));
152
+ return b - a - busy;
153
+ }
154
+
155
+ /** A brief event as an agenda event: its steps before are its prep. */
156
+ function fromBrief(e) {
157
+ const before = (e.steps && Array.isArray(e.steps.before)) ? e.steps.before : [];
158
+ const prep = before.length ? before.map((x) => String(x.t || '')) : e.prep ? [String(e.prep)] : [];
159
+ const done = before.filter((x) => x && x.done).length;
160
+ return { id: e.id, src: 'brief', start: e.start, end: e.end || new Date(Date.parse(e.start) + 30 * 60000).toISOString(), title: e.title,
161
+ ...(prep.length ? { prep, prep_done: done } : {}), with: e.with || null, goal: e.goal || null };
162
+ }
163
+
164
+ /**
165
+ * The month the Agenda draws. `month` is YYYY-MM (default: this month). Weeks run Monday to Sunday. Each day of the month
166
+ * carries its events and the three answers; `series` describes every series that has an event in the month.
167
+ */
168
+ function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
169
+ const today = localDate(now);
170
+ const ym = typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : today.slice(0, 7);
171
+ const all = ((memory && memory.events) || []).map((e) => ({ ...e, src: 'memory' }));
172
+ // today's events come from the brief of today when there is one; memory events of today are then left out
173
+ const b = brief && brief.date === today && Array.isArray(brief.calendar) ? brief.calendar.map(fromBrief) : null;
174
+ const events = (b ? [...all.filter((e) => localDate(Date.parse(e.start)) !== today), ...b] : all)
175
+ .map((e) => { const s = Date.parse(e.start); const f = Date.parse(e.end); return { ...e, s, f, date: localDate(s), from: minuteOfDay(s), to: f - s >= DAY_MS ? DAY_END : (localDate(f) === localDate(s) ? minuteOfDay(f) : DAY_END), key: seriesKey(e), kind: kindOf(e), imp: isImportant(e) }; })
176
+ .sort((x, y) => x.s - y.s);
177
+
178
+ // series over everything remembered, so "held 12 of 13" and "the last time" see beyond the month
179
+ const series = new Map();
180
+ for (const e of events) {
181
+ let x = series.get(e.key);
182
+ if (!x) { x = { key: e.key, title: e.title, kind: e.kind, count: 0, past: 0, coming: 0, dates: [], last: null, coming_id: null }; series.set(e.key, x); }
183
+ x.count++; x.dates.push(e.date); x.title = e.title;
184
+ if (e.f <= now) { x.past++; x.last = e; } else { x.coming++; if (!x.coming_id) x.coming_id = e.id; }
185
+ }
186
+ const routine = (e) => !e.imp && e.kind === 'group' && (series.get(e.key).count >= ROUTINE_COUNT) && !(e.prep && e.prep.length && series.get(e.key).coming_id === e.id);
187
+
188
+ // the days of the calendar grid: from the Monday on or before the 1st to the Sunday on or after the last day
189
+ const first = ym + '-01';
190
+ const [yy, mm] = ym.split('-').map(Number);
191
+ const last = localDate(new Date(yy, mm, 0).getTime());
192
+ let start = first; while (weekday(start) !== 1) start = addDays(start, -1);
193
+ let end = last; while (weekday(end) !== 0) end = addDays(end, 1);
194
+ const byDay = new Map();
195
+ for (const e of events) { if (e.date < start || e.date > end) continue; if (!byDay.has(e.date)) byDay.set(e.date, []); byDay.get(e.date).push(e); }
196
+
197
+ // working-day free time for readiness: today's from now on, later days' whole hours (capped at PER_DAY each)
198
+ const nowMin = minuteOfDay(now);
199
+ const loadOf = new Map();
200
+ const load = (d) => { if (!loadOf.has(d)) loadOf.set(d, dayLoad(events.filter((e) => e.date === d))); return loadOf.get(d); };
201
+ const freeBefore = (e) => {
202
+ let free = freeBetween(load(e.date).merged, e.date === today ? nowMin : DAY_START, e.from);
203
+ for (let d = today; d < e.date; d = addDays(d, 1)) {
204
+ const wd = weekday(d); if (wd === 0 || wd === 6) continue;
205
+ free += Math.min(PER_DAY, freeBetween(load(d).merged, d === today ? nowMin : DAY_START, DAY_END));
206
+ }
207
+ return free;
208
+ };
209
+
210
+ const days = {};
211
+ const weeks = [];
212
+ let week = [];
213
+ for (let d = start; d <= end; d = addDays(d, 1)) {
214
+ const evs = byDay.get(d) || [];
215
+ const past = d < today;
216
+ const { busy, merged, gaps } = load(d);
217
+ let need = 0; let done = 0; let level = null;
218
+ const out = evs.map((e) => {
219
+ const ended = e.f <= now;
220
+ // prep counts on the coming occurrence of a series only: later repeats get their own prep later
221
+ const coming = !ended && series.get(e.key).coming_id === e.id;
222
+ const p = coming && Array.isArray(e.prep) ? e.prep.length : 0;
223
+ const pd = p ? Math.min(e.prep_done || 0, p) : 0;
224
+ let lv = null;
225
+ if (p) { need += p; done += pd; lv = pd >= p ? 'ok' : freeBefore(e) < (p - pd) * PER_STEP ? 'risk' : 'warn'; }
226
+ if (lv === 'risk' || (lv === 'warn' && level !== 'risk') || (lv === 'ok' && !level)) level = lv;
227
+ const x = series.get(e.key);
228
+ return { id: e.id, src: e.src, title: e.title, start: e.start, end: e.end, time: `${pad(Math.floor(e.from / 60))}:${pad(e.from % 60)}`, minutes: Math.round((e.f - e.s) / 60000),
229
+ kind: e.kind, imp: e.imp, key: e.key, repeats: x.count > 1, routine: routine(e), ended, prep: p ? e.prep : null, prep_done: p ? pd : null, ready: lv,
230
+ notes: e.notes || null, open: e.open || null, role: e.role || null, external: e.external === true, attendees: typeof e.attendees === 'number' ? e.attendees : null, with: e.with || null };
231
+ });
232
+ if (need && done >= need) level = 'ok';
233
+ const score = (e) => (e.imp ? 100 : 0) + (e.ready === 'risk' ? 50 : 0) + (e.prep ? 20 : 0) + (e.repeats ? 0 : 10) + e.minutes / 30 - (e.routine ? 200 : 0);
234
+ const best = out.filter((e) => !e.routine).sort((a, b) => score(b) - score(a))[0] || null;
235
+ const key = best && (best.imp || best.ready === 'risk') ? best.id : null;
236
+ const noNotes = past ? out.filter((e) => e.ended && !e.notes && (e.imp || e.kind === 'one')).length : 0;
237
+ days[d] = { date: d, in_month: d.slice(0, 7) === ym, past, today: d === today, weekend: [0, 6].includes(weekday(d)), events: out,
238
+ busy, gaps, blocks: merged, key, need, done, ready: need ? level : null, no_notes: noNotes };
239
+ week.push(d);
240
+ if (weekday(d) === 0) { weeks.push(week); week = []; }
241
+ }
242
+
243
+ const ser = {};
244
+ const seen = new Set(Object.values(days).flatMap((d) => d.events.map((e) => e.key)));
245
+ for (const k of seen) {
246
+ const x = series.get(k);
247
+ ser[k] = { key: k, title: x.title, kind: x.kind, count: x.count, past: x.past, coming: x.coming, dates: [...new Set(x.dates)],
248
+ last: x.last ? { date: x.last.date, notes: x.last.notes || null, open: x.last.open || null } : null };
249
+ }
250
+ return { ok: true, month: ym, today, now: new Date(now).toISOString(), day_start: '09:00', day_end: '18:00', weeks, days, series: ser,
251
+ memory: memory ? { from: memory.from || null, to: memory.to || null, updated_at: memory.updated_at || null, events: (memory.events || []).length } : null };
252
+ }
253
+
254
+ module.exports = {
255
+ validateMemory, mergeMemory, agendaMonth, seriesKey, normTitle, kindOf, isImportant, dayLoad,
256
+ MAX_EVENTS, MAX_SPAN_DAYS, KEEP_BACK_DAYS, KEEP_AHEAD_DAYS, FREE_BLOCK, PER_STEP,
257
+ };
@@ -190,7 +190,7 @@ function workflow({ root }) {
190
190
  const changeRange = engine.commitRange(root);
191
191
  // ONE delivery index for the board, built once and shared, exactly as the CLI board does.
192
192
  let deliveryIndex = null;
193
- try { deliveryIndex = require(path.join(path.dirname(loaded.from), 'delivery-index.js')).deliveryIndex(root, ids); }
193
+ try { deliveryIndex = require(path.join(path.dirname(loaded.from), 'delivery-index.js')).deliveryIndex(root, ids, { recordsDir: engine.layout(root).initiatives }); }
194
194
  catch { deliveryIndex = null; } // absent → each ladder builds its own; the engine handles it
195
195
  const bi = gitInfo.branchInfo(root);
196
196
  const scope = {
@@ -68,7 +68,9 @@ ALIAS.set('designticket', ['ticket', 'design']);
68
68
  ALIAS.set('engineeringticket', ['ticket', 'engineering']);
69
69
  ALIAS.set('engticket', ['ticket', 'engineering']);
70
70
 
71
- const ROLE_CATEGORY = { prd_path: 'prd', rubric_path: 'spec-artifact' };
71
+ // What a record's path key says about the file it names. scan-consistency R3 adds the keys records use for their other
72
+ // artifacts, so an explainer or a design a record names is categorized by that tie, not left to its file name.
73
+ const ROLE_CATEGORY = { prd_path: 'prd', rubric_path: 'spec-artifact', brief_path: 'spec-artifact', design_path: 'spec-artifact', explainer_path: 'knowledge' };
72
74
 
73
75
  const RESERVED = /(^|\/)(index|log)\.md$/i;
74
76
  const TEMPLATE = /(^|\/)templates?\//i;
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * const D = require('./delivery-index');
7
7
  * const idx = D.deliveryIndex(root, ids); // { ok, reason?, byBranch:Set, byMention:Set }
8
- * D.attribution(idx, id) // 'branch' | 'mention' | null | 'unknown'
8
+ * D.attribution(idx, id) // 'branch' | 'mention' | 'change' | null | 'unknown'
9
9
  * D.isDeliveryPath('workspace/server.js') // true — a build path, not planning prose
10
10
  *
11
11
  * WHY THIS EXISTS. This derivation shipped inside `workspace/server.js` as a module-private
@@ -101,13 +101,13 @@ const deliveryCache = new Map(); // root -> { head, index }
101
101
  * any commit subject into a delivery claim
102
102
  * @returns {{ok:boolean, reason?:string, byBranch:Set<string>, byMention:Set<string>}}
103
103
  */
104
- function deliveryIndex(root, ids) {
104
+ function deliveryIndex(root, ids, opts) {
105
105
  const t = trunkRef(root);
106
106
  if (!t) {
107
107
  return {
108
108
  ok: false,
109
109
  reason: 'no trunk branch (main/master) is reachable from this repository — delivery cannot be derived',
110
- byBranch: new Set(), byMention: new Set(),
110
+ byBranch: new Set(), byMention: new Set(), byChange: new Set(),
111
111
  };
112
112
  }
113
113
  const hit = deliveryCache.get(root);
@@ -119,7 +119,7 @@ function deliveryIndex(root, ids) {
119
119
  return {
120
120
  ok: false,
121
121
  reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`,
122
- byBranch: new Set(), byMention: new Set(),
122
+ byBranch: new Set(), byMention: new Set(), byChange: new Set(),
123
123
  };
124
124
  }
125
125
  const byBranch = new Set();
@@ -137,11 +137,87 @@ function deliveryIndex(root, ids) {
137
137
  // the safe direction and the one the surface already errs in.
138
138
  for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
139
139
  }
140
- const index = { ok: true, byBranch, byMention };
140
+ // 3. THE CHANGE THAT CARRIED THE SPEC (scan-consistency R1). See changeSignal below.
141
+ const byChange = changeSignal(root, t.head, known, opts);
142
+ const index = { ok: true, byBranch, byMention, byChange };
141
143
  deliveryCache.set(root, { head: t.head, index });
142
144
  return index;
143
145
  }
144
146
 
147
+ /*
148
+ * THE THIRD SIGNAL — a change on the trunk that touched this initiative's OWN documents and build
149
+ * paths together (scan-consistency R1).
150
+ *
151
+ * Signals 1 and 2 both need the id spelled in a commit message. On this repository most pull
152
+ * requests ship on `claude/<slug>-<suffix>` branches and their commits name the area (`feat(workspace):
153
+ * …`), not the id, so 102 of 283 initiatives read `build pending` although their PRD, record and code
154
+ * merged in one pull request. The tie is concrete and git holds it: the pull request that changed
155
+ * `planning/initiatives/<id>.md` (or the PRD the record names) ALSO changed build files.
156
+ *
157
+ * A UNIT is one first-parent commit of the trunk: a merge commit (its diff against the first parent
158
+ * is the whole pull request), a squash merge, or a direct commit. Two guards keep it from becoming a
159
+ * similarity score:
160
+ * · the unit must change at least one build path (isDeliveryPath) — a docs-only pull request
161
+ * delivers nothing;
162
+ * · the unit may touch the documents of at most MAX_CO_INITIATIVES initiatives. A sweep that edits
163
+ * forty records while changing a script is evidence for none of them.
164
+ * A merged branch whose name, less its random suffix, IS a known id (`claude/jira-wbs-kpamaz`) counts
165
+ * too, when its pull request changed build paths.
166
+ */
167
+ const MAX_CO_INITIATIVES = 2;
168
+ const RECORD_DIRS = ['planning/initiatives', 'pmos/state/initiatives', 'state/initiatives'];
169
+ /** { [repo-relative path]: Set<id> } — each record and every `*_path` its frontmatter names. */
170
+ function documentOwners(root, ids, recordsDir) {
171
+ const fs = require('fs');
172
+ const path = require('path');
173
+ const dirs = recordsDir ? [recordsDir] : RECORD_DIRS.map((d) => path.join(root, d));
174
+ const owners = new Map();
175
+ const add = (rel, id) => {
176
+ const k = String(rel || '').trim().replace(/^["']|["']$/g, '').replace(/^\.?\//, '');
177
+ if (!k || /\s/.test(k) || k.includes('..')) return;
178
+ if (!owners.has(k)) owners.set(k, new Set());
179
+ owners.get(k).add(id);
180
+ };
181
+ for (const id of ids) {
182
+ for (const dir of dirs) {
183
+ const file = path.join(dir, id + '.md');
184
+ let text;
185
+ try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
186
+ add(path.relative(root, file).split(path.sep).join('/'), id);
187
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
188
+ if (fm) for (const m of fm[1].matchAll(/^[a-z_]*_path[ \t]*:[ \t]*(\S.*?)[ \t]*$/gm)) add(m[1], id);
189
+ break;
190
+ }
191
+ }
192
+ return owners;
193
+ }
194
+ function changeSignal(root, head, known, opts) {
195
+ const out = new Set();
196
+ if (!known.length) return out;
197
+ const owners = documentOwners(root, known, opts && opts.recordsDir);
198
+ const ids = new Set(known);
199
+ const r = gitCap(root, ['log', '--first-parent', '--diff-merges=first-parent', '--format=%x1e%s%x1f', '--name-only', head]);
200
+ if (!r.ok) return out;
201
+ for (const chunk of r.out.split('\x1e')) {
202
+ if (!chunk.trim()) continue;
203
+ const cut = chunk.indexOf('\x1f');
204
+ const subject = cut < 0 ? chunk : chunk.slice(0, cut);
205
+ const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
206
+ if (!files.some(isDeliveryPath)) continue;
207
+ const touched = new Set();
208
+ for (const f of files) for (const id of owners.get(f) || []) touched.add(id);
209
+ if (touched.size && touched.size <= MAX_CO_INITIATIVES) for (const id of touched) out.add(id);
210
+ const b = /^Merge (?:pull request #\d+ from|branch ')\s*(?:[^/\s]+\/)?(?:claude|feature|feat|fix|chore)\/([A-Za-z0-9][\w.-]*?)'?(?:\s|$)/.exec(subject.trim());
211
+ if (b) {
212
+ const slug = b[1];
213
+ const bare = slug.replace(/-[a-z0-9]{4,8}$/, '');
214
+ if (ids.has(slug)) out.add(slug);
215
+ else if (bare !== slug && ids.has(bare)) out.add(bare);
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
145
221
  /*
146
222
  * The branches that actually MERGED — a strictly narrower signal than `byBranch`, and the two are
147
223
  * kept apart on purpose.
@@ -251,12 +327,13 @@ function declaredDelivery(root, declared) {
251
327
  * caller that collapses the two reports "nothing was delivered" for a repository whose history it
252
328
  * failed to read, which is a claim the missing evidence cannot support.
253
329
  *
254
- * @returns {'branch'|'mention'|'unknown'|null}
330
+ * @returns {'branch'|'mention'|'change'|'unknown'|null}
255
331
  */
256
332
  function attribution(index, id) {
257
333
  if (!index || !index.ok) return 'unknown';
258
334
  if (index.byBranch.has(id)) return 'branch';
259
335
  if (index.byMention.has(id)) return 'mention';
336
+ if (index.byChange && index.byChange.has(id)) return 'change';
260
337
  return null;
261
338
  }
262
339
 
@@ -264,6 +341,7 @@ function attribution(index, id) {
264
341
  function attributionEvidence(signal, id, extra) {
265
342
  if (signal === 'branch') return `an initiative/${id} branch merged to the trunk`;
266
343
  if (signal === 'mention') return 'a commit on the trunk names this initiative and changes build files';
344
+ if (signal === 'change') return 'a change merged to the trunk carried this initiative\'s own documents and build files together';
267
345
  if (signal === 'declared') return `delivered_by ${String(extra || '').slice(0, 8)} — verified on the trunk, and it changes build paths`;
268
346
  if (signal === 'unknown') return 'the trunk history could not be read — delivery cannot be derived here';
269
347
  return 'no commit on the trunk is attributable to this initiative';
@@ -274,5 +352,5 @@ function _resetCache() { deliveryCache.clear(); }
274
352
 
275
353
  module.exports = {
276
354
  deliveryIndex, attribution, attributionEvidence, declaredDelivery, mergedBranches,
277
- isDeliveryPath, trunkRef, _resetCache,
355
+ isDeliveryPath, trunkRef, documentOwners, _resetCache,
278
356
  };
@@ -438,7 +438,7 @@ function buildCommits(root, range, lane, opts) {
438
438
  if (declared && declared.declared && !declared.ok) {
439
439
  return { unknown: false, failedDeclaration: declared, first: null, count: 0, sample: null, signal: null, attributed: true };
440
440
  }
441
- const index = (opts && opts.deliveryIndex) || delivery.deliveryIndex(root, listInitiatives(root));
441
+ const index = (opts && opts.deliveryIndex) || delivery.deliveryIndex(root, listInitiatives(root), { recordsDir: layout(root).initiatives });
442
442
  signal = delivery.attribution(index, id);
443
443
  if (declared && declared.ok) {
444
444
  // A verified declaration attributes the work regardless of what the conventions found. It is
@@ -478,7 +478,7 @@ function buildCommits(root, range, lane, opts) {
478
478
  count++;
479
479
  if (first === null) { first = i; sample = hit; }
480
480
  }
481
- if (whole && id && signal === 'branch' && first === null) {
481
+ if (whole && id && (signal === 'branch' || signal === 'change') && first === null) {
482
482
  // The branch merged and every commit it carried was written on the branch, so no trunk commit
483
483
  // subject names the id. The merge itself is the evidence, and saying "0 commits" over a merged
484
484
  // branch would be the under-claim in a new place.
@@ -1657,7 +1657,7 @@ function main(argv) {
1657
1657
  // reason the commit range is: `deliveryIndex` memoises per trunk head, so a per-initiative build
1658
1658
  // would be cheap but the intent — one read, shared — is what keeps the rows consistent with each
1659
1659
  // other even if the trunk moved mid-run.
1660
- const deliveryIdx = delivery.deliveryIndex(root, ids);
1660
+ const deliveryIdx = delivery.deliveryIndex(root, ids, { recordsDir: layout(root).initiatives });
1661
1661
  const rows = ids.map((i) => computeWorkflow(i, { root, range, changeRange, deliveryIndex: deliveryIdx }));
1662
1662
  if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
1663
1663
  if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
@@ -16,6 +16,31 @@ const PK = require('./preview-kind');
16
16
  const { previewKind } = PK;
17
17
 
18
18
  const MAX_FILES = 200;
19
+ // scan-consistency R3 — the shared delivery reader (scripts/delivery-index.js; lib/engine/ in an installed workspace),
20
+ // for the record-owned document map the trunk-unit source below joins on.
21
+ const DELIVERY_PATHS = [path.join(__dirname, '..', '..', 'scripts', 'delivery-index.js'), path.join(__dirname, 'engine', 'delivery-index.js')];
22
+ let delivery;
23
+ function deliveryLib() {
24
+ if (delivery === undefined) {
25
+ delivery = null;
26
+ for (const p of DELIVERY_PATHS) { if (fs.existsSync(p)) { try { delivery = require(p); } catch { delivery = null; } break; } }
27
+ }
28
+ return delivery;
29
+ }
30
+ const unitCache = new Map(); // root → { head, units }
31
+ /** The trunk's first-parent units (a merged pull request, a squash merge or a direct commit) as parseLog records. */
32
+ function trunkUnits(root, trunk) {
33
+ const headR = git(root, ['rev-parse', trunk]);
34
+ if (!headR.ok) return null;
35
+ const head = headR.out.trim();
36
+ const hit = unitCache.get(root);
37
+ if (hit && hit.head === head) return hit.units;
38
+ const r = git(root, ['log', '--first-parent', '--diff-merges=first-parent', '-n', '3000', '--name-status', '--format=%x1e%H%x1f%s', head]);
39
+ if (!r.ok) return null;
40
+ const units = parseLog(r.out, () => true);
41
+ unitCache.set(root, { head, units });
42
+ return units;
43
+ }
19
44
  const cache = new Map(); // root|id → {head, at, result}
20
45
  const TTL_MS = 30000;
21
46
 
@@ -63,6 +88,17 @@ function parseLog(out, keep) {
63
88
  * @param {{exclude?:Set<string>}} opts repo-relative paths the document index already lists
64
89
  * @returns {{ok:true, items:Array, total:number, truncated:boolean, via:string[]}|{ok:false, reason:string}}
65
90
  */
91
+ /** The ids of the repository's initiative records (planning/initiatives/ or pmos/state/initiatives/). */
92
+ function listRecordIds(root) {
93
+ for (const d of ['planning/initiatives', 'pmos/state/initiatives', 'state/initiatives']) {
94
+ try {
95
+ const names = fs.readdirSync(path.join(root, d)).filter((f) => f.endsWith('.md') && f !== 'README.md' && f !== 'index.md');
96
+ if (names.length) return names.map((f) => f.slice(0, -3));
97
+ } catch { /* next */ }
98
+ }
99
+ return [];
100
+ }
101
+
66
102
  function filesFor(root, id, opts) {
67
103
  const exclude = (opts && opts.exclude) || new Set();
68
104
  if (!/^[A-Za-z0-9][\w.-]*$/.test(String(id || ''))) return { ok: false, reason: 'not an initiative id' };
@@ -86,6 +122,25 @@ function filesFor(root, id, opts) {
86
122
  const own = git(root, ['log', trunk + '..' + branch, '--no-merges', '--name-status', '--format=%x1e%H%x1f%s']);
87
123
  if (own.ok) { const rows = parseLog(own.out, () => true); if (rows.length) { via.push('its branch ' + branch); commits.push(...rows); } }
88
124
  }
125
+ // 3. the trunk's own units that carried this initiative's documents (scan-consistency R3). Most pull requests here
126
+ // ship on `claude/*` branches whose commits never name the id, and a merged branch is usually deleted, so 1 and 2
127
+ // find nothing for delivered work. A unit that changed the record or a file the record names, and the documents of
128
+ // at most one other initiative, is this initiative's work: the same rule delivery-index.js attributes delivery by.
129
+ const D = deliveryLib();
130
+ if (trunk && D && typeof D.documentOwners === 'function') {
131
+ let ids = [];
132
+ try { ids = listRecordIds(root); } catch { ids = []; }
133
+ const owners = D.documentOwners(root, ids.length ? ids : [id]);
134
+ const units = trunkUnits(root, trunk) || [];
135
+ const known = new Set(commits.map((c) => c.sha));
136
+ const mine = units.filter((u) => {
137
+ if (known.has(u.sha)) return false;
138
+ const touched = new Set();
139
+ for (const f of u.files) for (const o of owners.get(f) || []) touched.add(o);
140
+ return touched.has(id) && touched.size <= 2;
141
+ });
142
+ if (mine.length) { via.push('changes that carried its documents'); commits.push(...mine); }
143
+ }
89
144
  // What still exists: the checked-out tree plus the initiative's own branch tip, so a file that lives only on an
90
145
  // unmerged branch is listed, and a file deleted since is not. `ls-tree` once per ref, never one spawn per file.
91
146
  const present = new Set(); const onHead = new Set();
@@ -139,4 +194,7 @@ function readAtRef(root, ref, rel) {
139
194
  return { ok: true, kind: known ? kind : 'text', text };
140
195
  }
141
196
 
142
- module.exports = { filesFor, groupOf, namesId, readAtRef };
197
+ /** Forget the trunk changes read for `root` (all roots without one): the next read walks the history again. */
198
+ function forget(root) { if (root) unitCache.delete(root); else unitCache.clear(); }
199
+
200
+ module.exports = { forget, filesFor, groupOf, namesId, readAtRef };
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * A ROW is one line of `node scripts/workflow-state.js --json` (every record, uncapped), narrowed to what the planning
18
18
  * layer reads: { ok, id, title, lane, group, anchor, stage, accepted, stopped, last_activity (ISO), profile, record,
19
- * next }. `stopped` is the lifecycle fold ({ state, reason, at }) or null.
19
+ * next, steps: [{ key, status }] }. `stopped` is the lifecycle fold ({ state, reason, at }) or null.
20
20
  *
21
21
  * WHY A CHILD PROCESS. The engine resolves each artifact's first appearance by running git: about 260 ms per record,
22
22
  * so a repository of 100 records takes half a minute (lib/board-cache.js measured it). The board cache is capped at
@@ -71,6 +71,8 @@ function narrow(root, raw) {
71
71
  stage: r.stage || null, accepted: !!r.accepted,
72
72
  stopped: r.stopped ? { state: r.stopped.state, reason: r.stopped.reason || '', at: r.stopped.at || null } : null,
73
73
  last_activity: iso(r.last_activity), profile: r.profile || null, record: r.record || null, next: r.next || null,
74
+ // key and status of each ladder step, so a row's stage is read by the same rule as the sheet's stepper
75
+ steps: Array.isArray(r.steps) ? r.steps.filter(Boolean).map((x) => ({ key: x.key, status: x.status })) : null,
74
76
  } : { ok: false, id: r && r.id, error: (r && r.error) || 'the row could not be derived' }));
75
77
  }
76
78
 
@@ -157,6 +159,16 @@ function cachedSig(root) {
157
159
  const hit = cache.get(root);
158
160
  return hit ? `${hit.sig}@${hit.at}${hit.error ? '!' : ''}` : 'none';
159
161
  }
162
+ /** Read `root` again now, whatever the cache holds (POST /api/initiatives/rescan, the PM's Re-scan press). The last
163
+ * answer is kept but marked stale, so the page keeps its rows and a poll sees the new derivation land. Starts one
164
+ * background derivation; answers { started: false, running: true } when one is already running. */
165
+ function rescan(root) {
166
+ if (inflight.get(root)) return { started: false, running: true };
167
+ const hit = cache.get(root);
168
+ if (hit) hit.at = 0;
169
+ refresh(root, recordsSig(root));
170
+ return { started: true, running: true };
171
+ }
160
172
  /** Test and maintenance seam. */
161
173
  function invalidate(root) { if (root) { cache.delete(root); inflight.delete(root); } else { cache.clear(); inflight.clear(); } }
162
174
 
@@ -207,4 +219,4 @@ function anchorsOf(root) {
207
219
  return { resolve, period, error, ok: !!(index && index.ok) };
208
220
  }
209
221
 
210
- module.exports = { deriveSync, rowsFor, peek, setBackground, isDeriving, deriveNow, answerSig, cachedSig, recordsSig, invalidate, anchorsOf, workGroupOf, periodLabel, narrow };
222
+ module.exports = { deriveSync, rowsFor, rescan, peek, setBackground, isDeriving, deriveNow, answerSig, cachedSig, recordsSig, invalidate, anchorsOf, workGroupOf, periodLabel, narrow };
@@ -74,7 +74,7 @@ const DETAIL_TITLE = {
74
74
  const MOBILE_DESTS = ['today', 'initiatives', 'board', 'reports', 'agent'];
75
75
  // What each tab and Settings is called. The same words as the desktop bar (public/pm/frame-copy.js COPY.nav), which
76
76
  // MOBNAV-P7 holds equal.
77
- const DEST_TITLE = { today: 'Today', initiatives: 'Initiatives', board: 'Board', reports: 'Reports', agent: 'Agent', settings: 'Settings' };
77
+ const DEST_TITLE = { today: 'Agenda', initiatives: 'Initiatives', board: 'Board', reports: 'Reports', agent: 'Agent', settings: 'Settings' };
78
78
  // Every old MODES key has a tab. The suite ITERATES this map, so a mode without a phone path fails.
79
79
  const MOBILE_DEST = { product: 'initiatives', scheduled: 'reports', agent: 'agent', memory: 'initiatives',
80
80
  workflow: 'initiatives', skills: 'initiatives', knowledge: 'initiatives' };