create-pmos 0.210.0 → 0.212.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 (40) 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/state/discoveries.md +1 -1
  6. package/workspace/c3-light-load-test.js +110 -0
  7. package/workspace/c3-settings-test.js +53 -2
  8. package/workspace/guide/briefing.md +7 -1
  9. package/workspace/guide/data-freshness.md +35 -8
  10. package/workspace/guide/index.md +1 -1
  11. package/workspace/lib/driver-run.js +4 -3
  12. package/workspace/lib/freshness.js +2 -0
  13. package/workspace/lib/refresh-run.js +300 -0
  14. package/workspace/lib/refresh.js +464 -0
  15. package/workspace/lib/report-finder.js +22 -1
  16. package/workspace/lib/route-modules.js +1 -0
  17. package/workspace/lib/routes-calendar-views.js +10 -2
  18. package/workspace/lib/routes-pm-items.js +2 -0
  19. package/workspace/lib/routes-refresh.js +93 -0
  20. package/workspace/lib/settings-store.js +0 -2
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-brief-pulse.js +4 -4
  24. package/workspace/public/pm/c3-calpop.js +11 -3
  25. package/workspace/public/pm/c3-dock.js +9 -0
  26. package/workspace/public/pm/c3-driver.js +1 -1
  27. package/workspace/public/pm/c3-look.js +27 -0
  28. package/workspace/public/pm/c3-plan.js +3 -2
  29. package/workspace/public/pm/c3-quiet.css +6 -1
  30. package/workspace/public/pm/c3-report-store.js +2 -1
  31. package/workspace/public/pm/c3-scan-setup.js +3 -7
  32. package/workspace/public/pm/c3-scan-words.js +7 -0
  33. package/workspace/public/pm/c3-scan.js +6 -4
  34. package/workspace/public/pm/c3-settings.css +11 -0
  35. package/workspace/public/pm/c3-settings.js +236 -27
  36. package/workspace/public/pm/c3-shell.js +56 -100
  37. package/workspace/replaced-tests.json +11 -0
  38. package/workspace/server.js +106 -12
  39. package/workspace/test-lists/c3-desktop-light-load.json +6 -0
  40. package/workspace/test-lists/data-refresh-settings.json +5 -0
@@ -0,0 +1,464 @@
1
+ 'use strict';
2
+ /* refresh.js — what PMOS refreshes, when, and what each refresh did (initiative data-refresh-settings).
3
+ *
4
+ * THE SOURCES. One registry of the 13 places PMOS refreshes data (SOURCES). Each says how it runs (`skill`: a Claude skill this module
5
+ * schedules; `job`: a PMOS job with its own tick, which this module only gates; `change`: a watcher with no schedule), what it USES (its
6
+ * inputs, as read in the code: dependencies.md section 3) and the IMPACT line shown when it fails or is off.
7
+ *
8
+ * THE SETTINGS. <state>/refresh.json: { sources: { id: { on, s, ws } }, rules, migrated }. `s` is the schedule of a skill source:
9
+ * { kind: 'manual' } only when the PM presses Refresh
10
+ * { kind: 'every', every: '15'..'240', work } every N minutes from midnight; `work` keeps it inside the background working hours
11
+ * { kind: 'daily' | 'weekdays', times: ['HH:MM', …] } one to four times a day (weekdays: Monday to Friday)
12
+ * { kind: 'weekly', day: 'Mon'..'Sun', times: ['HH:MM'] }
13
+ * A job source keeps only `on` here; the day check's minutes live in the proactive settings (dayCheck.every), their one home.
14
+ *
15
+ * THE STATE. <state>/refresh-state.json: per source { last_slot, running, run, started_at, trigger, retry_at, retried, waiting_since, pending }.
16
+ * It is what the tick needs between minutes; the log is the record.
17
+ *
18
+ * THE LOG. <state>/refresh-log.jsonl, append-only: { at, source, ws, ev: 'start'|'end'|'skip', run, trigger, result?, took_ms?, got?, error?,
19
+ * inputs?, note?, copy? }. Every background run writes a start and an end; a slot that does not run writes a skip with the reason. A done run of
20
+ * a source whose data is overwritten (Jira, work assigned to you) keeps a copy of what it read under <state>/refresh-copies/<source>/<stamp>/.
21
+ * prune() removes lines and copies older than the keep rule, once a day.
22
+ *
23
+ * Everything that decides is pure (lastSlot, nextSlot, decide, rowsOf); the file helpers are at the bottom.
24
+ */
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
29
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
30
+ const SKILL_KINDS = Object.freeze(['manual', 'every', 'daily', 'weekdays', 'weekly']);
31
+ const EVERY = Object.freeze(['15', '30', '60', '120', '240']);
32
+ const WAIT_MS = 30 * 60 * 1000; // a refresh waits at most this long for its inputs
33
+ const LATE_MS = 15 * 60 * 1000; // a slot older than this when first seen was missed (PMOS was closed)
34
+ const MAX_TIMES = 4;
35
+
36
+ /* The sources. `fresh` names the lib/freshness.js row whose age and detail this source shares; `copy` names the files a done run keeps. */
37
+ const SOURCES = Object.freeze([
38
+ { id: 'calendar', label: 'Calendar', group: 'tools', ic: 'cal', run: 'skill', skill: 'calendar-sync', how: 'tool', fresh: 'calendar',
39
+ what: 'Events from your calendars, for Calendar, the phone day, meeting focus and meeting notes.', uses: [],
40
+ impact: 'Calendar, the phone day, meeting focus and meeting notes show the last import. The Briefing reads your calendar itself, so it is not affected.',
41
+ def: { kind: 'daily', times: ['06:30', '12:30'] } },
42
+ { id: 'jira', label: 'Jira tickets and sprints', group: 'tools', ic: 'org', run: 'skill', skill: 'jira-sync', how: 'tool', fresh: 'jira', copy: 'jira',
43
+ what: 'The status of every pushed ticket, the active sprints, and each sprint\'s goal and team summary.', uses: [],
44
+ impact: 'Tickets keep their last status. Sprint summaries stop. The initiative driver cannot see ticket moves or blocked tickets, core documents write the sprint review from old data, and report drafts say Jira was not checked.',
45
+ def: { kind: 'daily', times: ['06:30'] } },
46
+ { id: 'mywork', label: 'Work assigned to you', group: 'tools', ic: 'todo', run: 'skill', skill: 'my-work-read', how: 'tool', fresh: 'my-work', copy: 'mywork',
47
+ what: 'Jira tickets assigned to you, for the Work page and the Briefing.', uses: [],
48
+ impact: 'The Work page and the Briefing keep the last list of work assigned to you.',
49
+ def: { kind: 'manual' } },
50
+ { id: 'tracker', label: 'Tracker files', group: 'tools', ic: 'sheet', run: 'skill', skill: 'tracker-refresh', how: 'code', fresh: 'tracker',
51
+ what: 'Plan items brought up to date from the tracker files in the workspace.', uses: [],
52
+ impact: 'The bi-weekly report says the tracker was not refreshed.',
53
+ def: { kind: 'weekly', day: 'Fri', times: ['15:30'] } },
54
+ { id: 'brief', label: 'Morning briefing', group: 'pmos', ic: 'sun', run: 'skill', skill: 'morning-brief', how: 'bg',
55
+ what: 'Your Briefing for the day, ranked by what needs you first.', uses: ['mywork'],
56
+ impact: 'No new Briefing for the day, and the day check has nothing to update.',
57
+ def: { kind: 'daily', times: ['06:45'] } },
58
+ { id: 'daycheck', label: 'Day check', group: 'pmos', ic: 'clock', run: 'job', how: 'bg',
59
+ what: 'Looks at today\'s plan, moves what is done and flags what slipped. It also runs before and after meetings, on a day with a plan.', uses: ['brief'],
60
+ impact: 'Today\'s plan is not checked during the day.', def: { kind: 'every', every: '60', work: true }, every: ['30', '60'] },
61
+ { id: 'scan', label: 'Horizon scan', group: 'pmos', ic: 'search', run: 'job', how: 'bg', setIn: 'Initiatives, Horizon scan',
62
+ what: 'Reads the web and your repositories for changes that matter to your initiatives.', uses: ['repo'],
63
+ impact: 'Core documents cite the older scan.' },
64
+ { id: 'coredocs', label: 'Core documents', group: 'pmos', ic: 'doc', run: 'job', how: 'bg', fixed: 'Every 30 min in working hours, each document at most once a day',
65
+ what: 'Drafts the portfolio, board and initiative documents that are due, old or missing.', uses: ['repo', 'jira', 'scan'],
66
+ impact: 'The initiative driver no longer sees documents that are due or old.' },
67
+ { id: 'driver', label: 'Initiative driver', group: 'pmos', ic: 'bolt', run: 'job', how: 'bg', fixed: 'Every 30 min in working hours, and when an initiative moves',
68
+ what: 'Checks each driven initiative for its next step and unblocks what it can.', uses: ['repo', 'jira', 'coredocs'],
69
+ impact: 'Driven initiatives get no unblocks or next steps.' },
70
+ { id: 'finder', label: 'Report finder', group: 'pmos', ic: 'report', run: 'job', how: 'bg', setIn: 'Connectors, Report finder',
71
+ what: 'Looks at your calendar, Slack, mail subjects and Drive for reports you write by hand, and proposes them.', uses: [],
72
+ impact: 'No new report proposals.' },
73
+ { id: 'signals', label: 'Signals', group: 'pmos', ic: 'bell', run: 'job', how: 'code', fixed: 'Every 10 min, outside quiet hours',
74
+ what: 'Five checks for things that need you, such as a stalled initiative or a schedule that failed.', uses: ['repo'],
75
+ impact: 'No new signal cards.' },
76
+ { id: 'repo', label: 'Repository and initiatives', group: 'change', ic: 'repo', run: 'change', how: 'code', fresh: 'initiatives',
77
+ what: 'Initiative facts and boards, rebuilt when a file or commit in the workspace changes.', uses: [],
78
+ impact: 'Boards, the driver, signals, core documents and the scan use the last built board.' },
79
+ { id: 'sync', label: 'Git sync', group: 'change', ic: 'branch', run: 'change', how: 'code',
80
+ what: 'Pulls and pushes your records once they have been quiet for 5 minutes.', uses: [],
81
+ impact: 'Your records are not pulled or pushed, so other computers do not see your changes.' },
82
+ ]);
83
+ const BY_ID = new Map(SOURCES.map((s) => [s.id, s]));
84
+ const SKILL_OF = new Map(SOURCES.filter((s) => s.skill).map((s) => [s.skill, s.id]));
85
+ const usedBy = (id) => SOURCES.filter((s) => s.uses.includes(id)).map((s) => s.id);
86
+
87
+ const RULE_DEFAULTS = Object.freeze({ catchup: true, retry: '15', tell: 'brief', max: '3', keep: '90' });
88
+ const RULE_OPTS = Object.freeze({ retry: ['15', '60', 'no'], tell: ['brief', 'here'], max: ['1', '2', '3', '5'], keep: ['30', '90', '365', 'forever'] });
89
+
90
+ /* ── schedules ── */
91
+ function cleanTimes(t) {
92
+ const a = [...new Set((Array.isArray(t) ? t : []).map(String).filter((x) => HHMM.test(x)))].sort();
93
+ return a.slice(0, MAX_TIMES);
94
+ }
95
+ /** Check a schedule from the PM. → { ok, s } | { ok: false, error } */
96
+ function normalize(src, s) {
97
+ if (!src || src.run !== 'skill') {
98
+ if (src && src.id === 'daycheck' && s && s.kind === 'every' && src.every.includes(String(s.every))) return { ok: true, s: { kind: 'every', every: String(s.every), work: true } };
99
+ return { ok: false, error: src && src.id === 'daycheck' ? 'The day check runs every 30 or 60 minutes.' : 'This source has no schedule to set here.' };
100
+ }
101
+ if (!s || typeof s !== 'object' || !SKILL_KINDS.includes(s.kind)) return { ok: false, error: 'kind must be one of ' + SKILL_KINDS.join(', ') };
102
+ if (s.kind === 'manual') return { ok: true, s: { kind: 'manual' } };
103
+ if (s.kind === 'every') {
104
+ if (!EVERY.includes(String(s.every))) return { ok: false, error: 'every must be one of ' + EVERY.join(', ') + ' (minutes)' };
105
+ return { ok: true, s: { kind: 'every', every: String(s.every), work: !!s.work } };
106
+ }
107
+ const times = cleanTimes(s.times);
108
+ if (!times.length) return { ok: false, error: 'times must hold one to four HH:MM times (24-hour)' };
109
+ if (Array.isArray(s.times) && s.times.some((x) => !HHMM.test(String(x)))) return { ok: false, error: 'each time must be HH:MM (24-hour)' };
110
+ if (s.kind === 'weekly') {
111
+ if (!DAYS.includes(s.day)) return { ok: false, error: 'day must be one of Mon, Tue, Wed, Thu, Fri, Sat, Sun' };
112
+ return { ok: true, s: { kind: 'weekly', day: s.day, times: times.slice(0, 1) } };
113
+ }
114
+ return { ok: true, s: { kind: s.kind, times } };
115
+ }
116
+ const minutesOf = (hhmm) => { const [h, m] = String(hhmm).split(':').map(Number); return h * 60 + m; };
117
+ const at = (dayMs, hhmm) => { const d = new Date(dayMs); const m = minutesOf(hhmm); d.setHours(Math.floor(m / 60), m % 60, 0, 0); return d.getTime(); };
118
+ const dayStart = (ms) => { const d = new Date(ms); d.setHours(0, 0, 0, 0); return d.getTime(); };
119
+ const addDays = (ms, n) => { const d = new Date(ms); d.setDate(d.getDate() + n); return d.getTime(); };
120
+ const dayOk = (s, ms) => { const d = new Date(ms).getDay(); return s.kind === 'weekdays' ? d >= 1 && d <= 5 : s.kind === 'weekly' ? DAYS[d] === s.day : true; };
121
+
122
+ /** The slots of one day, in ms, ascending. `win` is { start, end } HH:MM, the background working hours. */
123
+ function slotsOfDay(s, dayMs, win) {
124
+ if (!s || s.kind === 'manual') return [];
125
+ if (s.kind === 'every') {
126
+ const step = Number(s.every) || 60; const out = [];
127
+ const lo = s.work && win ? minutesOf(win.start) : 0; const hi = s.work && win ? minutesOf(win.end) : 24 * 60;
128
+ for (let m = 0; m < 24 * 60; m += step) if (m >= lo && m < hi) out.push(dayStart(dayMs) + m * 60000);
129
+ // A clock change can shift a day; dayStart + minutes is near enough for minute slots.
130
+ return out;
131
+ }
132
+ if (!dayOk(s, dayMs)) return [];
133
+ return cleanTimes(s.times).map((t) => at(dayMs, t));
134
+ }
135
+ /** The most recent slot at or before `now`, in ms, or null (none in the last 8 days). */
136
+ function lastSlot(s, now, win) {
137
+ for (let k = 0; k < 8; k++) {
138
+ const day = addDays(dayStart(now), -k);
139
+ const hits = slotsOfDay(s, day, win).filter((x) => x <= now);
140
+ if (hits.length) return hits[hits.length - 1];
141
+ }
142
+ return null;
143
+ }
144
+ /** The next slot strictly after `now`, in ms, or null. */
145
+ function nextSlot(s, now, win) {
146
+ for (let k = 0; k < 9; k++) {
147
+ const day = addDays(dayStart(now), k);
148
+ const hit = slotsOfDay(s, day, win).find((x) => x > now);
149
+ if (hit != null) return hit;
150
+ }
151
+ return null;
152
+ }
153
+ const inWindow = (now, win) => { if (!win) return true; const d = new Date(now); const m = d.getHours() * 60 + d.getMinutes(); return m >= minutesOf(win.start) && m < minutesOf(win.end); };
154
+ function inQuiet(now, quiet) {
155
+ if (!quiet || !quiet.start || !quiet.end) return false;
156
+ const d = new Date(now); const m = d.getHours() * 60 + d.getMinutes();
157
+ const a = minutesOf(quiet.start); const b = minutesOf(quiet.end);
158
+ return a < b ? m >= a && m < b : m >= a || m < b;
159
+ }
160
+
161
+ /* ── settings ── */
162
+ /** The settings with every default filled in. A missing or bad entry falls back to the source's default, off. */
163
+ function withDefaults(raw) {
164
+ const r = raw && typeof raw === 'object' ? raw : {};
165
+ const sources = {};
166
+ for (const src of SOURCES) {
167
+ const x = (r.sources && r.sources[src.id]) || {};
168
+ const on = typeof x.on === 'boolean' ? x.on : src.run !== 'skill'; // a job or watcher is on unless the PM turned it off; a skill source starts off
169
+ const n = src.run === 'skill' && x.s ? normalize(src, x.s) : null;
170
+ sources[src.id] = { on, s: n && n.ok ? n.s : (src.def ? { ...src.def, ...(src.def.times ? { times: src.def.times.slice() } : {}) } : null), ws: typeof x.ws === 'string' ? x.ws : null };
171
+ }
172
+ const rules = { ...RULE_DEFAULTS };
173
+ const rr = r.rules || {};
174
+ if (typeof rr.catchup === 'boolean') rules.catchup = rr.catchup;
175
+ for (const k of Object.keys(RULE_OPTS)) if (RULE_OPTS[k].includes(String(rr[k]))) rules[k] = String(rr[k]);
176
+ return { sources, rules, migrated: !!r.migrated };
177
+ }
178
+ /** Check a rules patch. → { ok, patch } | { ok: false, error } */
179
+ function normalizeRules(p) {
180
+ const out = {};
181
+ if (!p || typeof p !== 'object') return { ok: false, error: 'rules must be an object' };
182
+ for (const [k, v] of Object.entries(p)) {
183
+ if (k === 'catchup') { if (typeof v !== 'boolean') return { ok: false, error: 'catchup must be true or false' }; out.catchup = v; continue; }
184
+ if (!RULE_OPTS[k]) return { ok: false, error: `unknown rule '${k}'` };
185
+ if (!RULE_OPTS[k].includes(String(v))) return { ok: false, error: `${k} must be one of ${RULE_OPTS[k].join(', ')}` };
186
+ out[k] = String(v);
187
+ }
188
+ return { ok: true, patch: out };
189
+ }
190
+
191
+ /** Adopt the schedules.json entries of the managed skills (R3). → { settings, schedules, adopted: [id] }. Pure: the caller writes both. */
192
+ function migrate(settings, schedules, { homeWs } = {}) {
193
+ const next = JSON.parse(JSON.stringify(settings));
194
+ const list = (schedules || []).map((x) => ({ ...x }));
195
+ const adopted = [];
196
+ if (next.migrated) return { settings: next, schedules: list, adopted };
197
+ for (const src of SOURCES.filter((x) => x.run === 'skill')) {
198
+ const mine = list.filter((x) => x.skill === src.skill && x.enabled !== false && !x.endedReason);
199
+ if (!mine.length) continue;
200
+ const ws = mine[0].ws || homeWs || null;
201
+ const same = mine.filter((x) => (x.ws || null) === ws);
202
+ let s = null;
203
+ if (same.every((x) => x.cadence === 'daily')) s = { kind: 'daily', times: cleanTimes(same.map((x) => x.time)) };
204
+ else if (same[0].cadence === 'weekly' || same[0].cadence === 'bi-weekly') s = { kind: 'weekly', day: same[0].day || 'Mon', times: cleanTimes([same[0].time]) };
205
+ else s = { kind: 'daily', times: cleanTimes(same.map((x) => x.time)) };
206
+ const n = normalize(src, s);
207
+ next.sources[src.id] = { on: true, s: n.ok ? n.s : src.def, ws };
208
+ for (const x of same) { x.enabled = false; x.endedReason = 'Moved to Settings › Data refresh'; x.nextRun = null; }
209
+ adopted.push(src.id);
210
+ }
211
+ next.migrated = true;
212
+ return { settings: next, schedules: list, adopted };
213
+ }
214
+
215
+ /* ── the decision ── */
216
+ /**
217
+ * What the tick should do for skill source `src` now. → { act: 'start'|'skip'|'wait'|'none', trigger?, slot?, why?, note? }
218
+ * st the source's state record (refresh-state.json)
219
+ * ctx { now, settings, rules, win, quiet, runningCount, inputState(id) → 'running'|'due'|'waiting'|'ok'|'off', first }
220
+ * `first` is true on the first tick after PMOS started: a slot older than LATE_MS then is a missed slot, which catch-up runs or skips.
221
+ */
222
+ function decide(src, st, ctx) {
223
+ const set = ctx.settings.sources[src.id];
224
+ const now = ctx.now; const rules = ctx.rules;
225
+ st = st || {};
226
+ if (st.running) return { act: 'none', why: 'running' };
227
+ if (!set || !set.on) {
228
+ if (st.pending) return gateInputs(src, st, ctx, { trigger: st.pending.trigger || 'pressed', pressed: true });
229
+ return { act: 'none', why: 'off' };
230
+ }
231
+ // A pressed refresh that waits for its inputs.
232
+ if (st.pending) return gateInputs(src, st, ctx, { trigger: st.pending.trigger || 'pressed', pressed: true });
233
+ // A retry of a failed run.
234
+ if (st.retry_at && now >= st.retry_at) return gateInputs(src, st, ctx, { trigger: 'retry', slot: st.last_slot || null });
235
+ const slot = lastSlot(set.s, now, ctx.win);
236
+ if (slot == null) return { act: 'none', why: 'no slot' };
237
+ if (st.last_slot != null && st.last_slot >= slot) return { act: 'none', why: 'done' };
238
+ // A slot from before the last start (a run pressed after it) is done too.
239
+ if (st.started_at && st.started_at >= slot && !st.waiting_since) return { act: 'skip', slot, why: 'already refreshed after this slot', quiet: true };
240
+ const late = now - slot > LATE_MS;
241
+ if (late && !st.waiting_since) {
242
+ if (!rules.catchup) return { act: 'skip', slot, why: 'PMOS was closed at ' + hm(slot) + ', and catch-up is off' };
243
+ if (inQuiet(now, ctx.quiet)) return { act: 'skip', slot, why: 'missed at ' + hm(slot) + ', and it is quiet hours now' };
244
+ return gateInputs(src, st, ctx, { trigger: 'catch-up', slot });
245
+ }
246
+ if (inQuiet(now, ctx.quiet) && !st.waiting_since) return { act: 'skip', slot, why: 'quiet hours' };
247
+ return gateInputs(src, st, ctx, { trigger: 'schedule', slot });
248
+ }
249
+ function gateInputs(src, st, ctx, { trigger, slot, pressed }) {
250
+ const busy = src.uses.filter((id) => ['running', 'due', 'waiting'].includes(ctx.inputState(id)));
251
+ if (busy.length) {
252
+ const since = st.waiting_since || ctx.now;
253
+ if (ctx.now - since < WAIT_MS) return { act: 'wait', trigger, slot, waiting: busy, since };
254
+ return startOrHold(ctx, { act: 'start', trigger, slot, pressed, note: 'Waited 30 min for ' + busy.map(labelOf).join(', ') + ', then ran without it' });
255
+ }
256
+ return startOrHold(ctx, { act: 'start', trigger, slot, pressed });
257
+ }
258
+ function startOrHold(ctx, d) {
259
+ if (!d.pressed && ctx.runningCount >= Number(ctx.rules.max || 3)) return { ...d, act: 'wait', why: 'the run limit', waiting: [] };
260
+ return d;
261
+ }
262
+ const labelOf = (id) => (BY_ID.get(id) || { label: id }).label;
263
+ const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
264
+
265
+ /** May job `id` run its tick now? → { ok } | { ok: false, why }. Off, or an input running or waiting (up to WAIT_MS since `waitingSince`). */
266
+ function jobGate(id, ctx) {
267
+ const set = ctx.settings.sources[id];
268
+ if (set && set.on === false) return { ok: false, why: 'off' };
269
+ const src = BY_ID.get(id);
270
+ const busy = (src ? src.uses : []).filter((x) => ['running', 'waiting'].includes(ctx.inputState(x)));
271
+ if (busy.length && (!ctx.waitingSince || ctx.now - ctx.waitingSince < WAIT_MS)) return { ok: false, why: 'waiting', waiting: busy };
272
+ return { ok: true };
273
+ }
274
+
275
+ /** The source a session belongs to, from its origin, or null. `skillOfSchedule(id)` maps a schedules.json id to its skill. */
276
+ function sourceOfOrigin(origin, { skillOfSchedule } = {}) {
277
+ const o = String(origin || '');
278
+ let m = /^refresh:([a-z]+)$/.exec(o); if (m && BY_ID.has(m[1])) return m[1];
279
+ if (o === 'my-work-read') return 'mywork';
280
+ if (o === 'day-check') return 'daycheck';
281
+ if (/^scan-(web|repo):/.test(o)) return 'scan';
282
+ if (/^core-doc:/.test(o)) return 'coredocs';
283
+ if (/^drive:/.test(o)) return 'driver';
284
+ if (/^report-find:/.test(o)) return 'finder';
285
+ m = /^schedule:(.+)$/.exec(o);
286
+ if (m && skillOfSchedule) { const sk = skillOfSchedule(m[1]); return sk && SKILL_OF.has(sk) ? SKILL_OF.get(sk) : null; }
287
+ return null;
288
+ }
289
+
290
+ /* ── rows for the page ── */
291
+ /**
292
+ * One row per source for GET /api/refresh. → [row]
293
+ * opts { now, settings, state, log (folded: lastRun(id), runs(id, n)), fresh (Map id → freshness row), win, quiet, jobSchedule(id) → text|null, ws }
294
+ */
295
+ function rowsOf(opts) {
296
+ const { now, settings, state, log, fresh, win } = opts;
297
+ const rows = SOURCES.map((src) => {
298
+ const set = settings.sources[src.id]; const st = state[src.id] || {};
299
+ const last = log.lastRun(src.id);
300
+ const fr = src.fresh && fresh ? fresh.get(src.fresh) : null;
301
+ const lastAt = (last && last.end_at) || (fr && fr.as_of) || null;
302
+ let stateName;
303
+ // A pressed refresh runs even when the source is off, so running and waiting come before off.
304
+ if (st.running || (last && !last.end_at && last.start_at && now - Date.parse(last.start_at) < 6 * 3600000)) stateName = 'running';
305
+ else if (st.waiting_since || st.pending) stateName = 'waiting';
306
+ else if (!set.on) stateName = 'off';
307
+ else if (last && last.result === 'failed') stateName = 'failed';
308
+ else if (fr && fr.state === 'failed') stateName = 'failed';
309
+ else if (fr && fr.state === 'stale') stateName = 'old';
310
+ else if (!lastAt) stateName = 'never';
311
+ else stateName = staleOf(src, set, lastAt, now, win) ? 'old' : 'recent';
312
+ const next = set.on && src.run === 'skill' ? nextSlot(set.s, now, win) : null;
313
+ return {
314
+ id: src.id, label: src.label, group: src.group, ic: src.ic, run: src.run, how: src.how, what: src.what, uses: src.uses.slice(), used_by: usedBy(src.id), impact: src.impact,
315
+ on: set.on, schedule: src.run === 'skill' || src.id === 'daycheck' ? set.s : null, default: src.def || null, ws: set.ws,
316
+ kinds: src.run === 'skill' ? SKILL_KINDS.slice() : src.id === 'daycheck' ? ['every'] : [], every: src.run === 'skill' ? EVERY.slice() : src.every ? src.every.slice() : [],
317
+ schedule_text: src.run === 'skill' || src.id === 'daycheck' ? textOf(set.s) : src.run === 'change' ? 'On every change' : (opts.jobSchedule && opts.jobSchedule(src.id)) || src.fixed || '',
318
+ set_in: src.setIn || null,
319
+ state: stateName,
320
+ last: last ? { at: lastAt, start_at: last.start_at, result: last.result || (last.end_at ? 'done' : null), took_ms: last.took_ms != null ? last.took_ms : null,
321
+ got: last.got || (fr && fr.detail) || null, error: last.error || null, trigger: last.trigger || null, note: last.note || null } : (lastAt ? { at: lastAt, got: fr && fr.detail || null } : null),
322
+ next_at: next ? new Date(next).toISOString() : null,
323
+ waiting_for: st.waiting_for || [],
324
+ runs: log.runs(src.id, 5),
325
+ };
326
+ });
327
+ // What each source was built on: an input that is failed, old or off now (R5, R9).
328
+ const byId = new Map(rows.map((r) => [r.id, r]));
329
+ for (const r of rows) {
330
+ r.inputs = r.uses.map((id) => { const x = byId.get(id); return { id, label: x.label, state: x.state, at: x.last ? x.last.at : null }; });
331
+ r.built_on = r.inputs.filter((x) => ['failed', 'old', 'off', 'never'].includes(x.state));
332
+ }
333
+ return rows;
334
+ }
335
+ /** Old: no refresh since the slot before the last one (a skill source), or older than a day (a job). */
336
+ function staleOf(src, set, lastAt, now, win) {
337
+ const t = Date.parse(lastAt); if (!Number.isFinite(t)) return false;
338
+ if (src.run === 'skill') {
339
+ const s = set.s; if (!s || s.kind === 'manual') return now - t > 26 * 3600000;
340
+ const l1 = lastSlot(s, now, win); if (l1 == null) return false;
341
+ const l0 = lastSlot(s, l1 - 1, win);
342
+ return l0 != null && t < l0;
343
+ }
344
+ if (src.run === 'change') return false;
345
+ return now - t > (src.id === 'finder' ? 8 * 86400000 : src.id === 'scan' ? 4 * 86400000 : 26 * 3600000);
346
+ }
347
+ const DAYN = { Mon: 'Monday', Tue: 'Tuesday', Wed: 'Wednesday', Thu: 'Thursday', Fri: 'Friday', Sat: 'Saturday', Sun: 'Sunday' };
348
+ const everyText = (v) => ({ 15: '15 min', 30: '30 min', 60: 'hour', 120: '2 hours', 240: '4 hours' })[v] || v + ' min';
349
+ /** The plain sentence for a schedule. */
350
+ function textOf(s) {
351
+ if (!s) return '';
352
+ if (s.kind === 'manual') return 'Only when you press Refresh';
353
+ if (s.kind === 'every') return 'Every ' + everyText(s.every) + (s.work ? ', working hours only' : '');
354
+ const t = (s.times || []).join(' and ');
355
+ if (s.kind === 'daily') return 'Daily at ' + t;
356
+ if (s.kind === 'weekdays') return 'Working days at ' + t;
357
+ if (s.kind === 'weekly') return 'Every ' + (DAYN[s.day] || s.day) + ' at ' + t;
358
+ return '';
359
+ }
360
+
361
+ /* ── files ── */
362
+ const files = (stateDir) => ({
363
+ settings: path.join(stateDir, 'refresh.json'),
364
+ state: path.join(stateDir, 'refresh-state.json'),
365
+ log: path.join(stateDir, 'refresh-log.jsonl'),
366
+ copies: path.join(stateDir, 'refresh-copies'),
367
+ });
368
+ function readJson(f, fallback) { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fallback; } }
369
+ function writeJson(f, v) {
370
+ fs.mkdirSync(path.dirname(f), { recursive: true });
371
+ const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(v, null, 1) + '\n'); fs.renameSync(tmp, f);
372
+ }
373
+ const readSettings = (dir) => withDefaults(readJson(files(dir).settings, null));
374
+ const writeSettings = (dir, s) => writeJson(files(dir).settings, { sources: s.sources, rules: s.rules, migrated: !!s.migrated });
375
+ const readState = (dir) => { const s = readJson(files(dir).state, {}); return s && typeof s === 'object' && !Array.isArray(s) ? s : {}; };
376
+ const writeState = (dir, s) => writeJson(files(dir).state, s);
377
+
378
+ let runSeq = 0;
379
+ const newRun = (source, now = Date.now()) => `${source}-${now.toString(36)}-${(runSeq++ % 1296).toString(36)}`;
380
+ const clip = (s, n) => (s == null ? null : String(s).replace(/\s+/g, ' ').trim().slice(0, n) || null);
381
+ /** Append one log line. Bad source ids are refused. → the line, or null */
382
+ function append(dir, line, now = Date.now()) {
383
+ if (!line || !BY_ID.has(line.source) || !['start', 'end', 'skip'].includes(line.ev)) return null;
384
+ const rec = { at: new Date(now).toISOString(), ...line };
385
+ for (const k of ['got', 'error', 'note']) if (rec[k] != null) rec[k] = clip(rec[k], k === 'error' ? 400 : 200);
386
+ try { fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(files(dir).log, JSON.stringify(rec) + '\n'); } catch { return null; }
387
+ return rec;
388
+ }
389
+ let cache = { f: null, mtime: 0, size: 0, lines: [] };
390
+ function readLog(dir) {
391
+ const f = files(dir).log;
392
+ let st; try { st = fs.statSync(f); } catch { return []; }
393
+ if (cache.f === f && cache.mtime === st.mtimeMs && cache.size === st.size) return cache.lines;
394
+ const lines = [];
395
+ for (const l of fs.readFileSync(f, 'utf8').split('\n')) { if (!l.trim()) continue; try { lines.push(JSON.parse(l)); } catch { /* a torn line is skipped */ } }
396
+ cache = { f, mtime: st.mtimeMs, size: st.size, lines };
397
+ return lines;
398
+ }
399
+ /** Fold the log into runs: { runs: [run], lastRun(id), runs(id, n) }. A run is { run, source, ws, trigger, start_at, end_at, result, took_ms, got, error, note, inputs, copy }. */
400
+ function foldLog(lines) {
401
+ const byRun = new Map(); const order = [];
402
+ for (const l of lines) {
403
+ if (l.ev === 'skip') { const r = { run: l.run || 'skip-' + l.at, source: l.source, ws: l.ws || null, trigger: l.trigger || 'schedule', start_at: l.at, end_at: l.at, result: 'skipped', note: l.note || null, error: null }; byRun.set(r.run, r); order.push(r.run); continue; }
404
+ let r = byRun.get(l.run);
405
+ if (!r) { r = { run: l.run, source: l.source, ws: l.ws || null, trigger: l.trigger || null, start_at: null, end_at: null, result: null }; byRun.set(l.run, r); order.push(l.run); }
406
+ if (l.ev === 'start') { r.start_at = l.at; r.trigger = l.trigger || r.trigger; r.inputs = l.inputs || null; r.note = l.note || r.note || null; r.session = l.session || null; }
407
+ if (l.ev === 'end') { r.end_at = l.at; r.result = l.result || 'done'; r.took_ms = l.took_ms != null ? l.took_ms : (r.start_at ? Date.parse(l.at) - Date.parse(r.start_at) : null); r.got = l.got || null; r.error = l.error || null; if (l.copy) r.copy = l.copy; if (l.note) r.note = l.note; }
408
+ }
409
+ const runs = order.map((id) => byRun.get(id));
410
+ const real = (r) => r.result !== 'skipped';
411
+ return {
412
+ all: runs,
413
+ lastRun: (id) => { for (let i = runs.length - 1; i >= 0; i--) if (runs[i].source === id && real(runs[i])) return runs[i]; return null; },
414
+ runs: (id, n) => runs.filter((r) => r.source === id).slice(-n).reverse(),
415
+ };
416
+ }
417
+ /** Keep a copy of what a done run read. `paths` are files or folders. → the copy folder relative to the state dir, or null */
418
+ function keepCopy(dir, source, paths, now = Date.now()) {
419
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
420
+ const dest = path.join(files(dir).copies, source, stamp);
421
+ let n = 0;
422
+ for (const p of paths || []) {
423
+ try {
424
+ const st = fs.statSync(p);
425
+ fs.mkdirSync(dest, { recursive: true });
426
+ if (st.isDirectory()) { for (const f of fs.readdirSync(p)) { const fp = path.join(p, f); if (fs.statSync(fp).isFile()) { fs.copyFileSync(fp, path.join(dest, f)); n++; } } }
427
+ else { fs.copyFileSync(p, path.join(dest, path.basename(p))); n++; }
428
+ } catch { /* a missing file is not copied */ }
429
+ }
430
+ return n ? path.relative(dir, dest).split(path.sep).join('/') : null;
431
+ }
432
+ /** Remove log lines and copies older than `keep` days ('forever' keeps all). → { lines, copies } removed */
433
+ function prune(dir, keep, now = Date.now()) {
434
+ const out = { lines: 0, copies: 0 };
435
+ if (!keep || keep === 'forever') return out;
436
+ const cut = now - Number(keep) * 86400000;
437
+ const f = files(dir).log;
438
+ try {
439
+ const all = fs.readFileSync(f, 'utf8').split('\n').filter((l) => l.trim());
440
+ const kept = all.filter((l) => { try { return Date.parse(JSON.parse(l).at) >= cut; } catch { return false; } });
441
+ if (kept.length !== all.length) { const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, kept.join('\n') + (kept.length ? '\n' : '')); fs.renameSync(tmp, f); out.lines = all.length - kept.length; }
442
+ } catch { /* no log yet */ }
443
+ try {
444
+ for (const src of fs.readdirSync(files(dir).copies)) {
445
+ const sd = path.join(files(dir).copies, src);
446
+ for (const c of fs.readdirSync(sd)) { try { if (fs.statSync(path.join(sd, c)).mtimeMs < cut) { fs.rmSync(path.join(sd, c), { recursive: true, force: true }); out.copies++; } } catch { /* gone */ } }
447
+ }
448
+ } catch { /* no copies yet */ }
449
+ return out;
450
+ }
451
+ /** The log as CSV, newest first. */
452
+ function toCsv(runs) {
453
+ const q = (v) => { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
454
+ const head = ['source', 'run', 'trigger', 'started', 'ended', 'result', 'took_seconds', 'got', 'error', 'note', 'inputs', 'copy'];
455
+ const rows = runs.slice().reverse().map((r) => [r.source, r.run, r.trigger, r.start_at, r.end_at, r.result, r.took_ms != null ? Math.round(r.took_ms / 1000) : '', r.got, r.error, r.note,
456
+ (r.inputs || []).map((i) => `${i.id}:${i.state}${i.at ? '@' + i.at : ''}`).join(' '), r.copy]);
457
+ return [head, ...rows].map((r) => r.map(q).join(',')).join('\n') + '\n';
458
+ }
459
+
460
+ module.exports = {
461
+ SOURCES, BY_ID, SKILL_OF, SKILL_KINDS, EVERY, WAIT_MS, LATE_MS, RULE_DEFAULTS, RULE_OPTS,
462
+ usedBy, normalize, normalizeRules, withDefaults, migrate, lastSlot, nextSlot, slotsOfDay, inWindow, inQuiet, decide, jobGate, sourceOfOrigin, rowsOf, textOf,
463
+ files, readSettings, writeSettings, readState, writeState, newRun, append, readLog, foldLog, keepCopy, prune, toCsv,
464
+ };
@@ -150,7 +150,28 @@ function create(deps) {
150
150
  return summary;
151
151
  }
152
152
 
153
- return { tick, active: () => [...active.keys()] };
153
+ /** Refresh now (data-refresh-settings R8): start the weekly run at once for the lead workspace, whatever the day. → { ok, error? } */
154
+ function runNow({ now = Date.now(), env } = {}) {
155
+ if (off(env || deps.env)) return { ok: false, error: 'The report finder is switched off on this computer (PMOS_REPORT_FIND=off).' };
156
+ if (deps.headlessOff()) return { ok: false, error: 'The report finder runs only as a background run, and background runs are off.' };
157
+ if (active.size) return { ok: false, error: 'The report finder is already running.' };
158
+ const home = deps.homeId ? deps.homeId() : null;
159
+ for (const w of deps.workspaces()) {
160
+ let lead = false;
161
+ try { lead = !!deps.isLead(w) && (!home || w.id === home); } catch { lead = false; }
162
+ if (!lead) continue;
163
+ let file = null;
164
+ try { file = RS.ledgerFile(w.root); } catch { file = null; }
165
+ if (!file) continue;
166
+ const state = RS.fold(RS.readLines(file));
167
+ if (!sourcesOn(state).length) return { ok: false, error: 'Turn on at least one place to look first, in Connectors, Report finder.' };
168
+ start(w, file, state, now);
169
+ return { ok: true };
170
+ }
171
+ return { ok: false, error: 'No repository holds the reports yet.' };
172
+ }
173
+
174
+ return { tick, runNow, active: () => [...active.keys()] };
154
175
  }
155
176
 
156
177
  module.exports = { create, off, weekStart, dueNow, nextRun, findPrompt, sourcesOn, SLOT };
@@ -74,6 +74,7 @@ const MODULES = Object.freeze([
74
74
  'routes-report-setups', // report-workspace: recurring reports, the Ask box, the finder, the report documents and their export (report-setups.jsonl)
75
75
  'routes-core-docs', // core-documents: the core documents of each level, Draft it (a core-doc run), Issue it and Drop (core-docs.jsonl)
76
76
  'routes-report-design', // report-design-studio: the design studio (describe, samples, turns, versions, This is it), cycle renders and decisions, speaker notes and pins
77
+ 'routes-refresh', // data-refresh-settings: Settings › Data refresh: what refreshes, when, the refresh log, Refresh now
77
78
  ]);
78
79
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
79
80
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -4,6 +4,7 @@
4
4
  * → { ok, view, range, cells, unit, gantt, strategy, events_source, today,
5
5
  * layers, layers_total, layers_note, free, context, context_total, (lib/calendar-layers.js)
6
6
  * link_boards } every board a meeting can be linked to: [{ ws, id, name }]
7
+ * &only=gantt → { ok, view, range, gantt, no_plan, strategy, today } the rows with a plan date or a Jira read, and how many have neither
7
8
  * POST /api/calendar-links → { ok, link } what a meeting is about (lib/calendar-links.js, below)
8
9
  * tidy, tidy_total, tidy_note } (lib/calendar-tidy.js: suggestions in words, no write)
9
10
  *
@@ -39,7 +40,7 @@ function handle(query, deps = {}) {
39
40
  const inis = facts.inis || [];
40
41
  const range = model.rangeFor(view, date);
41
42
  let events = []; let source = 'none';
42
- if (typeof deps.readEvents === 'function') { events = deps.readEvents(range) || []; source = 'calendar-memory'; }
43
+ if (typeof deps.readEvents === 'function' && !(query && query.only === 'gantt')) { events = deps.readEvents(range) || []; source = 'calendar-memory'; }
43
44
  const krs = typeof deps.readKrs === 'function' ? deps.readKrs() : [];
44
45
  const b = model.bucketEvents(events, view, date);
45
46
  const nowDay = deps.today || today();
@@ -50,6 +51,12 @@ function handle(query, deps = {}) {
50
51
  if (jira && typeof jira.get === 'function') for (const g of gantt) { const p = jira.get(g.id); if (p) g.jira = p; }
51
52
  }
52
53
  const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
54
+ // only=gantt: for the Briefing's Portfolio pulse (c3-plan.js readQuarter). No events, layers or tidy, and only the rows a reader can
55
+ // draw: a plan date or a Jira read. The rest are counted in no_plan (a large repository has hundreds of them).
56
+ if (query && query.only === 'gantt') {
57
+ const rows = gantt.filter((g) => g.status !== 'no plan' || g.jira);
58
+ return { status: 200, body: { ok: true, view, range, gantt: rows, no_plan: gantt.length - rows.length, strategy, today: nowDay } };
59
+ }
53
60
  const dated = typeof deps.readDated === 'function' ? (deps.readDated(range) || {}) : {
54
61
  unavailable: { plan_items: 'no reader is wired', releases: 'no reader is wired', reports: 'no reader is wired', own: 'no reader is wired' } };
55
62
  // the initiatives come from the facts this handler already holds, so every caller gets their promised dates
@@ -228,7 +235,8 @@ module.exports = {
228
235
  let facts;
229
236
  try { facts = await require('./facts-async.js').readFactsAsync(root); }
230
237
  catch (e) { ctx.json(res, { ok: false, error: 'the calendar could not read the repository: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
231
- const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
238
+ const only = url.searchParams.get('only') || undefined;
239
+ const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined, only }, {
232
240
  readFacts: () => facts,
233
241
  readEvents: () => require('./calendar-events.js').readAll(ctx.runtimeDir),
234
242
  readLinks: () => require('./calendar-links.js').read(ctx.runtimeDir),
@@ -7,6 +7,7 @@
7
7
  * review, review_days: 7 open items untouched 7 days, most stale first
8
8
  * focus: { suggestions, accepted, dismissed } at most 3 suggestions: { key, title, why, item_ids, job }; dismissed = the keys marked not today
9
9
  * counts: { inbox, today, upcoming, waiting, someday, review } }
10
+ * GET /api/pm-items?counts=1 → { ok, today, counts } the counts alone (the console's Work tab badge)
10
11
  * POST /api/pm-items/add { line } → { ok, item } one line: the parser (lib/pm-items.js) reads the parts
11
12
  * POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job,
12
13
  * defer, flagged, resolves, kind, person, evidence, outcome
@@ -343,6 +344,7 @@ module.exports = {
343
344
  if (p === '/api/pm-items' && req.method === 'GET') {
344
345
  const c = prepare(ctx);
345
346
  const v = view(c.itemLines, c.jobLines, c.now, c.stepLines, c.focusLines, proofSources(ctx, c.itemLines), await assignedAsync(ctx, c.now));
347
+ if (url.searchParams.get('counts') === '1') { ctx.json(res, { ok: v.ok, today: v.today, counts: v.counts }); return true; }
346
348
  v.resolves_titles = resolvesTitles(v.items, ctx, url);
347
349
  // an item the PM added: the agent can draft a first version of it (c3-work-agent amendment 11)
348
350
  const runs = assigned.recentRuns(ctx.readDayRun, c.now.getTime());