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.
- package/package.json +1 -1
- 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/scripts/delivery-index.js +85 -7
- package/payload/scripts/workflow-state.js +3 -3
- package/payload/state/discoveries.md +1 -1
- package/workspace/REMOTE.md +1 -1
- package/workspace/fixtures/initiatives-api/sheet.json +29 -7
- package/workspace/guide/index.md +3 -3
- package/workspace/guide/initiatives-screen.md +25 -1
- package/workspace/guide/layout.md +7 -9
- package/workspace/guide/proactive-engine.md +1 -0
- package/workspace/guide/today.md +23 -1
- package/workspace/lib/calendar-memory.js +257 -0
- package/workspace/lib/console-state.js +1 -1
- package/workspace/lib/doc-category.js +3 -1
- package/workspace/lib/engine/delivery-index.js +85 -7
- package/workspace/lib/engine/workflow-state.js +3 -3
- package/workspace/lib/initiative-files.js +59 -1
- package/workspace/lib/initiative-rows.js +14 -2
- package/workspace/lib/mobile-nav.js +1 -1
- package/workspace/lib/plan-tree.js +44 -5
- package/workspace/lib/proactive-cli.js +16 -1
- package/workspace/lib/routes-initiatives.js +165 -9
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +1 -1
- package/workspace/public/pm/components.css +70 -0
- package/workspace/public/pm/dest-initiatives.js +7 -1
- package/workspace/public/pm/dest-settings.js +1 -0
- package/workspace/public/pm/frame-copy.js +14 -2
- package/workspace/public/pm/initiative-sheet.js +42 -10
- package/workspace/public/pm/initiatives-api.js +15 -0
- package/workspace/public/pm/initiatives-copy.js +13 -0
- package/workspace/public/pm/initiatives-ui.js +14 -0
- package/workspace/public/pm/mobile.js +4 -5
- package/workspace/public/pm/registry.js +1 -1
- package/workspace/public/pm/tab-term.js +6 -50
- package/workspace/public/pm/today-agenda.js +305 -0
- package/workspace/public/pm/today-copy.js +54 -2
- package/workspace/public/pm/today-day.js +23 -3
- package/workspace/public/pm/today-goals.js +7 -1
- package/workspace/server.js +71 -63
- package/workspace/skills/morning-brief.skill +6 -1
- package/workspace/test-files.json +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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' };
|