create-pmos 0.211.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.
@@ -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$/;
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ /* routes-refresh.js — Settings › Data refresh (initiative data-refresh-settings, PRD R7, R8).
3
+ *
4
+ * GET /api/refresh?ws=<id> → { ok, ws, rules, quiet, window, rows: [row] } lib/refresh.js rowsOf
5
+ * POST /api/refresh/source { id, on?, schedule? } → { ok, source } on or off, and a skill source's schedule
6
+ * POST /api/refresh/rules { catchup?, retry?, tell?, max?, keep? } → { ok, rules }
7
+ * POST /api/refresh/run { id, ws?, with_inputs? } → { ok, source, started?|waiting?, inputs } Refresh now
8
+ * POST /api/refresh/run-all { ws? } → { ok, started: [id], failed: [{ id, error }] } every source that is on
9
+ * GET /api/refresh/log?source=&result=&limit= → { ok, runs: [run] } newest first
10
+ * GET /api/refresh/log.csv → text/csv, every run kept
11
+ *
12
+ * Every POST passes pm-guard requirePagePress: only the PM's press on the page changes what refreshes or starts a refresh.
13
+ * A wrong value is refused with the reason, and nothing changes. `ctx.refreshRun` is lib/refresh-run.js, bound by server.js.
14
+ */
15
+ const guard = require('./pm-guard.js');
16
+ const R = require('./refresh.js');
17
+
18
+ const RESULTS = ['done', 'failed', 'skipped', 'interrupted', 'running'];
19
+
20
+ async function handle(req, res, url, ctx) {
21
+ const p = url.pathname;
22
+ const run = ctx.refreshRun;
23
+ if (!run) { ctx.json(res, { ok: false, error: 'the refresh engine is not running' }, 503); return true; }
24
+
25
+ if (p === '/api/refresh' || p === '/api/refresh/log' || p === '/api/refresh/log.csv') {
26
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
27
+ if (p === '/api/refresh') {
28
+ const id = url.searchParams.get('ws') || '';
29
+ const w = ctx.getWs(id);
30
+ if (id && (!w || w.id !== id)) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
31
+ ctx.json(res, { ok: true, ws: w ? w.id : null, ...run.rows(w ? w.id : null) });
32
+ return true;
33
+ }
34
+ let runs = run.log().all.slice().reverse().map((r) => ({ ...r, result: r.result || (r.end_at ? 'done' : 'running') }));
35
+ const source = url.searchParams.get('source'); const result = url.searchParams.get('result');
36
+ if (source && !R.BY_ID.has(source)) { ctx.json(res, { ok: false, error: 'source must be one of ' + R.SOURCES.map((x) => x.id).join(', ') }, 400); return true; }
37
+ if (result && !RESULTS.includes(result)) { ctx.json(res, { ok: false, error: 'result must be one of ' + RESULTS.join(', ') }, 400); return true; }
38
+ if (source) runs = runs.filter((r) => r.source === source);
39
+ if (result) runs = runs.filter((r) => r.result === result);
40
+ if (p === '/api/refresh/log.csv') {
41
+ res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="pmos-refresh-log.csv"' });
42
+ res.end(R.toCsv(runs.slice().reverse()));
43
+ return true;
44
+ }
45
+ const limit = Math.max(1, Math.min(1000, Number(url.searchParams.get('limit')) || 200));
46
+ ctx.json(res, { ok: true, total: runs.length, runs: runs.slice(0, limit) });
47
+ return true;
48
+ }
49
+
50
+ if (!['/api/refresh/source', '/api/refresh/rules', '/api/refresh/run', '/api/refresh/run-all'].includes(p)) return false;
51
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
52
+ const got = await ctx.readJson(req);
53
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'the body is not JSON' }, 400); return true; }
54
+ const body = got.body || {};
55
+ const g = guard.requirePagePress(req, body);
56
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
57
+ let ws = null;
58
+ if (body.ws != null) {
59
+ const w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
60
+ if (!w || w.id !== body.ws) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
61
+ ws = w.id;
62
+ }
63
+
64
+ if (p === '/api/refresh/source') {
65
+ const r = run.setSource(String(body.id || ''), { on: body.on, schedule: body.schedule });
66
+ ctx.json(res, r, r.ok ? 200 : 400);
67
+ return true;
68
+ }
69
+ if (p === '/api/refresh/rules') {
70
+ const patch = { ...body }; delete patch.ws; delete patch.nonce; delete patch.press;
71
+ for (const k of Object.keys(patch)) if (k.startsWith('_')) delete patch[k];
72
+ const r = run.setRules(patch);
73
+ ctx.json(res, r, r.ok ? 200 : 400);
74
+ return true;
75
+ }
76
+ if (p === '/api/refresh/run') {
77
+ const r = run.runNow(String(body.id || ''), { ws, withInputs: body.with_inputs === true });
78
+ ctx.json(res, r, r.ok ? 200 : r.status || 400);
79
+ return true;
80
+ }
81
+ // run-all: every source that is on, inputs before the sources that use them (the order of the registry already is).
82
+ const set = run.settings();
83
+ const startedIds = []; const failed = [];
84
+ for (const src of R.SOURCES) {
85
+ if (!set.sources[src.id].on || src.id === 'sync') continue;
86
+ const r = run.runNow(src.id, { ws });
87
+ if (r.ok) startedIds.push(src.id); else failed.push({ id: src.id, error: r.error });
88
+ }
89
+ ctx.json(res, { ok: true, started: startedIds, failed });
90
+ return true;
91
+ }
92
+
93
+ module.exports = { prefixes: ['/api/refresh'], handle };
@@ -37,9 +37,7 @@ const DEFS = Object.freeze({
37
37
  'board.rules': one('boards', 'ws', ['warn', 'off'], 'warn'),
38
38
  'board.templates': { section: 'boards', scope: 'computer', type: 'templates', def: TEMPLATES },
39
39
  'conn.cal.window': one('connectors', 'computer', ['7-14', '14-30', '30-60'], '14-30'),
40
- 'conn.cal.refresh': one('connectors', 'computer', ['open', 'hour', 'manual'], 'open'),
41
40
  'conn.cal.private': one('connectors', 'computer', ['busy', 'hide', 'title'], 'busy'),
42
- 'conn.jira.refresh': one('connectors', 'ws', ['open', '15', 'manual'], '15'),
43
41
  'conn.wispr.wait': one('connectors', 'computer', ['2', '5', '10'], '5'),
44
42
  'conn.sender': one('connectors', 'ws', ['none', 'slack', 'gmail'], 'none'),
45
43
  'rec.sources': one('recording', 'computer', ['both', 'mic', 'meeting'], 'both'),
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.211.0",
3
+ "version": "0.212.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.211.0",
9
+ "version": "0.212.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.211.0",
3
+ "version": "0.212.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",