create-pmos 0.130.0 → 0.132.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 (51) 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-one-home-test.js +4 -2
  7. package/workspace/c3-work-test.js +200 -0
  8. package/workspace/fixtures/one-home-baseline.json +0 -91
  9. package/workspace/guide/console-calendar.md +65 -0
  10. package/workspace/guide/data-freshness.md +40 -0
  11. package/workspace/guide/index.md +2 -0
  12. package/workspace/guide/my-work.md +76 -1
  13. package/workspace/lib/calendar-cli.js +84 -0
  14. package/workspace/lib/calendar-layers.js +372 -0
  15. package/workspace/lib/facts-async.js +144 -35
  16. package/workspace/lib/freshness.js +167 -0
  17. package/workspace/lib/offloop.js +94 -0
  18. package/workspace/lib/pm-items.js +71 -6
  19. package/workspace/lib/pmos-verbs.js +3 -0
  20. package/workspace/lib/report-needs.js +1 -1
  21. package/workspace/lib/route-modules.js +1 -0
  22. package/workspace/lib/routes-board.js +3 -1
  23. package/workspace/lib/routes-calendar-views.js +112 -3
  24. package/workspace/lib/routes-my-work.js +27 -9
  25. package/workspace/lib/routes-pm-items.js +123 -13
  26. package/workspace/lib/routes-sources.js +64 -0
  27. package/workspace/lib/work-focus.js +106 -0
  28. package/workspace/package-lock.json +2 -2
  29. package/workspace/package.json +1 -1
  30. package/workspace/public/pm/c3-calendar.js +392 -0
  31. package/workspace/public/pm/c3-demo-cal4.js +114 -0
  32. package/workspace/public/pm/c3-demo-data.js +79 -77
  33. package/workspace/public/pm/c3-demo-misc2.js +4 -16
  34. package/workspace/public/pm/c3-demo-work-ix.js +19 -67
  35. package/workspace/public/pm/c3-demo.js +10 -3
  36. package/workspace/public/pm/c3-shell.js +5 -2
  37. package/workspace/public/pm/calendar-views.js +10 -3
  38. package/workspace/public/pm/components.css +34 -0
  39. package/workspace/public/pm/console3.css +155 -0
  40. package/workspace/public/pm/dest-settings.js +10 -1
  41. package/workspace/public/pm/freshness-line.js +123 -0
  42. package/workspace/public/pm/pm-work-view.js +364 -172
  43. package/workspace/public/pm/work-place.js +58 -0
  44. package/workspace/server.js +39 -2
  45. package/workspace/skills/calendar-sync.skill +30 -0
  46. package/workspace/test-files.json +2 -0
  47. package/workspace/test-lists/calendar-layers.json +5 -0
  48. package/workspace/test-lists/calendar-time-page.json +5 -0
  49. package/workspace/test-lists/console3-work-page.json +9 -0
  50. package/workspace/test-lists/workspace-data-freshness.json +6 -0
  51. package/workspace/public/pm/c3-demo-work2.js +0 -13
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+ /* calendar-cli.js — `pmos calendar …`: the calendar events an agent session read, saved for Briefing and Calendar
3
+ * (workspace-data-freshness R8).
4
+ * pmos calendar import --from <file|-> --sensitivity public|internal|confidential|private [--dry-run] [--json]
5
+ * pmos calendar status [--json]
6
+ * This command never talks to a calendar. The calendar-sync skill reads it in an agent session, with the PM's own
7
+ * connection, and hands the events to `import`. The import goes through scripts/calendar-import.js unchanged: hidden text
8
+ * is stripped, every line is marked, the raw text is hashed, and a missing sensitivity is refused. The events are kept in
9
+ * the per-user runtime folder, never in a repository.
10
+ */
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const stateRoot = require('./state-root');
14
+ const CI = require('../../scripts/calendar-import.js');
15
+ const L = require('../../scripts/trust/ledger.js');
16
+
17
+ const COMMANDS = { calendar: ['import', 'status'] };
18
+ const USAGE = [
19
+ 'usage: pmos calendar import|status — the calendar events an agent session read for Briefing and Calendar',
20
+ ' pmos calendar import --from <file|-> --sensitivity <label> [--dry-run] save the events (the calendar-sync skill runs this)',
21
+ ' pmos calendar status how many events are saved, and when they were read',
22
+ ' every command: [--json]',
23
+ 'This command never calls a calendar. Your agent reads it with your own connection.',
24
+ ];
25
+ const BOOL = new Set(['json', 'dry-run', 'help']);
26
+ const VALUE = new Set(['from', 'sensitivity']);
27
+
28
+ function parseArgs(argv) {
29
+ const out = { _: [] };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const t = argv[i];
32
+ if (!t.startsWith('--')) { out._.push(t); continue; }
33
+ const eq = t.indexOf('=');
34
+ const key = eq > 0 ? t.slice(2, eq) : t.slice(2);
35
+ if (BOOL.has(key)) out[key] = true;
36
+ else if (eq > 0) out[key] = t.slice(eq + 1);
37
+ else if (VALUE.has(key) && argv[i + 1] !== undefined) out[key] = argv[++i];
38
+ else out[key] = '';
39
+ }
40
+ return out;
41
+ }
42
+
43
+ const ledgerOf = (dir) => path.join(dir, 'calendar-import', 'events.jsonl');
44
+
45
+ function readInput(from, stdin) {
46
+ if (from === '-') return stdin();
47
+ return fs.readFileSync(path.resolve(from), 'utf8');
48
+ }
49
+
50
+ async function run(argv, { say = console.log, env = process.env, stateDir, stdin = () => fs.readFileSync(0, 'utf8'), now = () => new Date(), isTTY } = {}) {
51
+ const a = parseArgs(argv.slice(1));
52
+ const sub = a._ && a._[0];
53
+ const out = (o, line) => { if (a.json) say(JSON.stringify(o)); else say(line); };
54
+ if (!sub || a.help || sub === 'help') { USAGE.forEach((l) => say(l)); return sub || a.help ? 0 : 2; }
55
+ if (!COMMANDS.calendar.includes(sub)) { say(`✗ pmos calendar ${sub} is not a command`); USAGE.forEach((l) => say(l)); return 2; }
56
+ const dir = stateDir || stateRoot.defaultRoot({ env });
57
+ const file = ledgerOf(dir);
58
+
59
+ if (sub === 'status') {
60
+ const events = CI.foldEvents(CI.readLines(file));
61
+ const last = events.reduce((m, e) => (e.fetched_at && e.fetched_at > m ? e.fetched_at : m), '');
62
+ out({ ok: true, events: events.length, read_at: last || null }, events.length ? `${events.length} events saved, last read ${last}` : 'no calendar events are saved yet');
63
+ return 0;
64
+ }
65
+
66
+ if (!a.from) { say('✗ --from <file|-> is required'); return 2; }
67
+ let raw;
68
+ try { raw = JSON.parse(readInput(a.from, stdin)); } catch (e) { say(`✗ the events file is not JSON: ${String(e.message).slice(0, 160)}`); return 1; }
69
+ const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.events) ? raw.events : null;
70
+ if (!list) { say('✗ the file must hold a list of events, or { "events": [ … ] }'); return 1; }
71
+ const at = now().toISOString();
72
+ let evs;
73
+ try { evs = CI.importEvents(list, { fetchedAt: at, sensitivity: a.sensitivity }); }
74
+ catch (e) { say(`✗ ${e.message}`); return 1; }
75
+ if (a['dry-run']) { out({ ok: true, dryRun: true, count: evs.length }, `would save ${evs.length} events (dry run, nothing written)`); return 0; }
76
+ const actor = L.actorFrom({ env, isTTY: isTTY == null ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY });
77
+ try { CI.appendImport(file, evs, actor, at); }
78
+ catch (e) { say(`✗ the events could not be saved: ${String(e.message).slice(0, 160)}`); return 1; }
79
+ const flagged = evs.filter((e) => e.findings && e.findings.length).length;
80
+ out({ ok: true, count: evs.length, flagged, read_at: at }, `✓ saved ${evs.length} calendar events${flagged ? ` (${flagged} carried hidden or suspicious text, marked)` : ''}`);
81
+ return 0;
82
+ }
83
+
84
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,372 @@
1
+ 'use strict';
2
+ /* calendar-layers.js — the pure part of the console3 Calendar page's server answer (initiative calendar-time-page, R2 to R5, R8).
3
+ *
4
+ * layersFrom(dated, range, today) → { layers, layers_total, layers_note } dated items inside the range
5
+ * freeFrom(events, range, today, opts) → { min_minutes, day_start, day_end, days, count }
6
+ * contextFrom(events, range, src, opts) → { context, context_total } keyed by event id
7
+ * build({ range, today, nowMs, events, dated, src, view }) all three, for routes-calendar-views.js
8
+ *
9
+ * No I/O and no clock: `today` comes in as 'YYYY-MM-DD', `nowMs` as a number. Nothing is invented. A source with no reader
10
+ * is named in layers_note with the reason, and gives no item. Meetings are not layers; they stay in `cells`.
11
+ *
12
+ * INPUT SHAPES (plain data; the route reads them from the repository and the per-user store).
13
+ * dated = { initiatives:[{id,title,promised,done}], planItems:[{id,initiative,title,due,milestone,state}],
14
+ * releases:[{id,type,fix_version,deploy_at,release_at}], reports:[{cadence,slot,issued}],
15
+ * reportSchedules:[{id,name,cadence,dates:['YYYY-MM-DD']}], own:[{id,title,due,done}], unavailable:{ source: why } }
16
+ * src = { notes:[{id,title,private,meeting:{event_id,start,title},initiative,decisions:[{text}],actions:[{text,owner,due}]}],
17
+ * planItems:[{id,initiative,title,createdAt,doneAt}], ownItems:[{id,title,initiative,created_at,done_at}] }
18
+ * event = the imported event ({ id, at, end, title, initiative, pm_notes, agent_prep_notes, prep, prep_done, steps }).
19
+ *
20
+ * TONE. bad: past its date and not done. warn: due today up to 2 days ahead and not done. ok: done. Otherwise null.
21
+ * FREE TIME. Working days only (Monday to Friday), from DAY_START to DAY_END, a block of min_minutes or more (calendar-memory's
22
+ * dayLoad). Days before today are left out: free time that has passed cannot be used. An event with a date and no time (all day)
23
+ * does not block; an event with no end lasts 30 minutes. Quarter and Year answer days:[] and the count only. */
24
+ const memory = require('./calendar-memory.js');
25
+
26
+ const DAY_MS = 86400000;
27
+ const LAYER_CAP = 400;
28
+ const CONTEXT_CAP = 60;
29
+ const MIN_BLOCK = memory.FREE_BLOCK; // 90
30
+ const PER_STEP = memory.PER_STEP; // 30 minutes of free time for each prep step left
31
+ const PER_DAY = 60; // minutes of a whole working day before the meeting that count toward its prep
32
+ const DAY_START = 9 * 60;
33
+ const DAY_END = 18 * 60;
34
+ const WARN_DAYS = 2;
35
+ const SUGGEST_DAYS = 7;
36
+ const WINDOW_MS = 48 * 3600000;
37
+ const PER_KIND = { decision: 6, action: 6, moved: 8, prepared: 8, todo: 8 };
38
+ const LIGHT_VIEWS = ['quarter', 'year'];
39
+
40
+ const pad = (n) => String(n).padStart(2, '0');
41
+ const isDay = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
42
+ const day10 = (s) => String(s).slice(0, 10);
43
+ const hasTime = (s) => typeof s === 'string' && /T\d{2}:\d{2}/.test(s);
44
+ const ymdMs = (s) => Date.parse(day10(s) + 'T00:00:00Z');
45
+ const addDays = (s, n) => new Date(ymdMs(s) + n * DAY_MS).toISOString().slice(0, 10);
46
+ const diffDays = (a, b) => Math.round((ymdMs(a) - ymdMs(b)) / DAY_MS);
47
+ const dow = (s) => new Date(ymdMs(s)).getUTCDay(); // 0 Sunday
48
+ const isWorkday = (s) => dow(s) >= 1 && dow(s) <= 5;
49
+ const hhmm = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
50
+ const localMs = (s) => { const [y, m, d] = day10(s).split('-').map(Number); return new Date(y, m - 1, d).getTime(); };
51
+ const localDay = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
52
+ const minuteOfDay = (ms) => { const d = new Date(ms); return d.getHours() * 60 + d.getMinutes(); };
53
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
54
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
55
+ const inRange = (d, range) => d >= range.start && d <= range.end;
56
+ const overlaps = (a, b, range) => a <= range.end && b >= range.start;
57
+
58
+ /* ── layers ────────────────────────────────────────────────────────────────────────────────────────────────────── */
59
+
60
+ function toneOf(date, done, today) {
61
+ if (done) return 'ok';
62
+ if (date < today) return 'bad';
63
+ if (diffDays(date, today) <= WARN_DAYS) return 'warn';
64
+ return null;
65
+ }
66
+
67
+ function layersFrom(dated, range, today) {
68
+ const d = dated || {};
69
+ const out = [];
70
+ const note = {};
71
+ const dateless = {}; // source -> items that carry a date at all (in range or not)
72
+ const inside = {}; // source -> items inside the range
73
+ const put = (source, x) => {
74
+ dateless[source] = (dateless[source] || 0) + 1;
75
+ const end = x.end && x.end > x.date ? x.end : null;
76
+ if (!overlaps(x.date, end || x.date, range)) return;
77
+ inside[source] = (inside[source] || 0) + 1;
78
+ const { end: _e, ...rest } = x;
79
+ out.push(end ? { ...rest, end } : rest);
80
+ };
81
+ const unavailable = d.unavailable && typeof d.unavailable === 'object' ? d.unavailable : {};
82
+ const why = (source, fallback) => { if (unavailable[source]) note[source] = String(unavailable[source]); else if (fallback) note[source] = fallback; };
83
+
84
+ // initiatives: the promised date
85
+ if (unavailable.initiatives) why('initiatives');
86
+ else {
87
+ let n = 0;
88
+ for (const i of d.initiatives || []) {
89
+ if (!i || !i.id || !isDay(i.promised)) continue;
90
+ n++;
91
+ const date = day10(i.promised);
92
+ put('initiatives', { id: `ini:${i.id}:promised`, date, kind: 'milestone', layer: 'milestones', title: str(i.title) || i.id, tone: toneOf(date, !!i.done, today), ref: { type: 'initiative', id: i.id }, initiative: i.id });
93
+ }
94
+ if (!n) why('initiatives', 'no initiative has a promised date');
95
+ }
96
+ // plan items: a milestone or a due date
97
+ if (unavailable.plan_items) why('plan_items');
98
+ else {
99
+ let n = 0;
100
+ for (const p of d.planItems || []) {
101
+ if (!p || !p.id || !isDay(p.due)) continue;
102
+ n++;
103
+ const date = day10(p.due);
104
+ put('plan_items', { id: `plan:${p.id}:${p.milestone ? 'milestone' : 'due'}`, date, kind: p.milestone ? 'milestone' : 'due', layer: 'milestones', title: str(p.title) || p.id,
105
+ tone: toneOf(date, p.state === 'done', today), ref: { type: 'plan-item', id: p.id }, ...(p.initiative ? { initiative: p.initiative } : {}) });
106
+ }
107
+ if (!n) why('plan_items', 'no plan item has a due date');
108
+ }
109
+ // releases
110
+ if (unavailable.releases) why('releases');
111
+ else {
112
+ let n = 0;
113
+ for (const r of d.releases || []) {
114
+ if (!r || !r.id) continue;
115
+ const dep = isDay(r.deploy_at) ? day10(r.deploy_at) : null; const rel = isDay(r.release_at) ? day10(r.release_at) : null;
116
+ const date = dep || rel; if (!date) continue;
117
+ n++;
118
+ const last = rel || dep;
119
+ put('releases', { id: `rel:${r.id}`, date, ...(last > date ? { end: last } : {}), kind: 'release', layer: 'releases', title: str(r.fix_version) || `Release ${str(r.type) || r.id}`,
120
+ tone: null, ref: { type: 'release', id: r.id } });
121
+ }
122
+ if (!n) why('releases', 'no release has a deploy or release date');
123
+ }
124
+ // reports: the issue date of a report file, and the next runs of a report schedule
125
+ if (unavailable.reports) why('reports');
126
+ else {
127
+ let n = 0;
128
+ for (const r of d.reports || []) {
129
+ if (!r || !r.cadence || !isDay(r.slot)) continue;
130
+ const date = r.issued && isDay(r.issued.at) ? day10(r.issued.at) : day10(r.slot);
131
+ n++;
132
+ put('reports', { id: `report:${r.cadence}:${day10(r.slot)}`, date, kind: 'report', layer: 'reports', title: `${r.cadence} report${r.issued ? '' : ' (not issued)'}`,
133
+ tone: r.issued ? 'ok' : null, ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
134
+ }
135
+ for (const s of d.reportSchedules || []) {
136
+ if (!s || !s.id) continue;
137
+ for (const dt of s.dates || []) {
138
+ if (!isDay(dt)) continue;
139
+ n++;
140
+ put('reports', { id: `report-sched:${s.id}:${day10(dt)}`, date: day10(dt), kind: 'report', layer: 'reports', title: `${str(s.name) || s.cadence || s.id} (scheduled)`, tone: null,
141
+ ref: { type: 'report', id: s.cadence || s.id } });
142
+ }
143
+ }
144
+ if (!n) why('reports', 'no report file and no report schedule gives an issue date');
145
+ }
146
+ // the PM's own items with a due date: markers only
147
+ if (unavailable.own) why('own');
148
+ else {
149
+ let n = 0;
150
+ for (const o of d.own || []) {
151
+ if (!o || !o.id || !isDay(o.due)) continue;
152
+ n++;
153
+ const date = day10(o.due);
154
+ put('own', { id: `own:${o.id}`, date, kind: 'own', layer: 'mine', title: str(o.title) || o.id, tone: toneOf(date, !!o.done, today), ref: { type: 'item', id: o.id } });
155
+ }
156
+ if (!n) why('own', 'none of your own items has a due date');
157
+ }
158
+ out.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.title < b.title ? -1 : a.title > b.title ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
159
+ const layers = out.slice(0, LAYER_CAP);
160
+ // a source that read fine but has nothing inside this range says so too, so an empty layer is never a silent one
161
+ for (const source of ['initiatives', 'plan_items', 'releases', 'reports', 'own']) {
162
+ if (note[source]) continue;
163
+ if (dateless[source] && !inside[source]) note[source] = 'nothing dated falls inside this range';
164
+ }
165
+ return { layers, layers_total: out.length, layers_note: note };
166
+ }
167
+
168
+ /* ── free time ─────────────────────────────────────────────────────────────────────────────────────────────────── */
169
+
170
+ /** An event as a span in epoch ms, or null for an all-day event or one with no usable start. */
171
+ function spanOf(e) {
172
+ if (!e || !hasTime(e.at || e.start)) return null;
173
+ const s = Date.parse(e.at || e.start);
174
+ if (!Number.isFinite(s)) return null;
175
+ let f = e.end ? Date.parse(e.end) : s + 30 * 60000;
176
+ if (!Number.isFinite(f) || f < s) f = s + 30 * 60000;
177
+ return { s, f };
178
+ }
179
+
180
+ /** Meetings that touch `date`, as minutes of that day. */
181
+ function minutesOn(spans, date) {
182
+ const a = localMs(date); const b = a + DAY_MS;
183
+ const out = [];
184
+ for (const sp of spans) {
185
+ if (sp.f <= a || sp.s >= b) continue;
186
+ out.push({ from: sp.s <= a ? 0 : minuteOfDay(sp.s), to: sp.f >= b ? 1440 : minuteOfDay(sp.f) });
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /** Free minutes inside working hours between two minutes of a day, given the merged busy intervals. */
192
+ function freeBetween(merged, from, until) {
193
+ const a = Math.max(DAY_START, from); const b = Math.min(DAY_END, until);
194
+ if (b <= a) return 0;
195
+ let busy = 0;
196
+ for (const [s, e] of merged) busy += Math.max(0, Math.min(e, b) - Math.max(s, a));
197
+ return b - a - busy;
198
+ }
199
+
200
+ /** The prep steps of an event: { steps:[text], done:n }. Reads prep / prep_done, else steps.before. */
201
+ function prepOf(e) {
202
+ if (!e) return { steps: [], done: 0 };
203
+ if (Array.isArray(e.prep) && e.prep.length) {
204
+ const steps = e.prep.map((x) => str(typeof x === 'string' ? x : x && (x.t || x.text))).filter(Boolean);
205
+ const done = Math.min(steps.length, Math.max(0, Number(e.prep_done) || 0));
206
+ return { steps, done, doneFlags: steps.map((_, i) => i < done) };
207
+ }
208
+ const before = e.steps && Array.isArray(e.steps.before) ? e.steps.before : [];
209
+ const steps = before.map((x) => str(x && (x.t || x.text))).filter(Boolean);
210
+ const flags = before.filter((x) => str(x && (x.t || x.text))).map((x) => !!x.done);
211
+ return { steps, done: flags.filter(Boolean).length, doneFlags: flags };
212
+ }
213
+
214
+ function nextWorkday(date) { let d = addDays(date, 1); while (!isWorkday(d)) d = addDays(d, 1); return d; }
215
+
216
+ function freeFrom(events, range, today, opts = {}) {
217
+ const view = range.view;
218
+ const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : localMs(today);
219
+ const spans = (events || []).map((e) => ({ e, ...(spanOf(e) || {}) })).filter((x) => x.s !== undefined);
220
+ const memo = new Map();
221
+ const loadOf = (date) => { if (!memo.has(date)) memo.set(date, memory.dayLoad(minutesOn(spans, date))); return memo.get(date); };
222
+
223
+ // prep at risk: a coming event with steps left and less free time before it than PER_STEP for each step
224
+ const risky = [];
225
+ for (const x of spans) {
226
+ const p = prepOf(x.e);
227
+ const left = p.steps.length - p.done;
228
+ if (!left || x.f <= nowMs) continue;
229
+ const date = localDay(x.s);
230
+ const startMin = minuteOfDay(x.s);
231
+ let free = freeBetween(loadOf(date).merged, date === today ? minuteOfDay(nowMs) : DAY_START, startMin);
232
+ for (let dd = today; dd < date; dd = addDays(dd, 1)) {
233
+ if (!isWorkday(dd)) continue;
234
+ free += Math.min(PER_DAY, freeBetween(loadOf(dd).merged, dd === today ? minuteOfDay(nowMs) : DAY_START, DAY_END));
235
+ }
236
+ if (free < left * PER_STEP) risky.push({ id: x.e.id, title: str(x.e.title) || 'a meeting', date, s: x.s, left });
237
+ }
238
+ risky.sort((a, b) => a.s - b.s);
239
+
240
+ // items due soon, most pressing first: overdue and nearest due date, then the higher priority, then the title
241
+ const prio = (p) => (/^P[1-3]$/.test(String(p || '')) ? Number(String(p)[1]) : 9);
242
+ const due = [];
243
+ for (const o of opts.own || []) if (o && o.id && !o.done && isDay(o.due)) due.push({ id: o.id, type: 'item', title: str(o.title) || o.id, due: day10(o.due), p: prio(o.priority) });
244
+ for (const p of opts.planItems || []) if (p && p.id && p.state !== 'done' && isDay(p.due)) due.push({ id: p.id, type: 'plan-item', title: str(p.title) || p.id, due: day10(p.due), p: 5 });
245
+ due.sort((a, b) => (a.due < b.due ? -1 : a.due > b.due ? 1 : a.p - b.p || (a.title < b.title ? -1 : a.title > b.title ? 1 : a.id < b.id ? -1 : 1)));
246
+
247
+ const usedEvent = new Set(); const usedItem = new Set();
248
+ const suggest = (date, block) => {
249
+ const next = nextWorkday(date);
250
+ const hit = risky.find((r) => !usedEvent.has(r.id) && (r.date === next || (r.date === date && r.s >= localMs(date) + block.from * 60000)));
251
+ if (hit) {
252
+ usedEvent.add(hit.id);
253
+ return { text: `Prepare for "${hit.title}": ${hit.left} ${hit.left === 1 ? 'step is' : 'steps are'} left and little free time remains before it.`, ref: { type: 'event', id: hit.id } };
254
+ }
255
+ const top = due.find((x) => !usedItem.has(x.id) && diffDays(x.due, date) <= SUGGEST_DAYS);
256
+ if (top) {
257
+ usedItem.add(top.id);
258
+ return { text: top.due < date ? `Work on "${top.title}". It was due ${top.due}.` : `Work on "${top.title}". It is due ${top.due}.`, ref: { type: top.type, id: top.id } };
259
+ }
260
+ return null;
261
+ };
262
+
263
+ const days = []; let count = 0;
264
+ const light = LIGHT_VIEWS.includes(view);
265
+ for (let dd = range.start < today ? today : range.start; dd <= range.end; dd = addDays(dd, 1)) {
266
+ if (!isWorkday(dd)) continue;
267
+ const blocks = loadOf(dd).gaps.map(([a, b]) => ({ from: hhmm(a), to: hhmm(b), minutes: b - a, _a: a, _b: b }));
268
+ count += blocks.length;
269
+ if (light) continue;
270
+ const out = blocks.map((b) => ({ from: b.from, to: b.to, minutes: b.minutes, suggestion: suggest(dd, { from: b._a, to: b._b }) }));
271
+ if (out.length) days.push({ date: dd, blocks: out });
272
+ }
273
+ return { min_minutes: MIN_BLOCK, day_start: hhmm(DAY_START), day_end: hhmm(DAY_END), days, count };
274
+ }
275
+
276
+ /* ── context ───────────────────────────────────────────────────────────────────────────────────────────────────── */
277
+
278
+ function noteMatches(n, e) {
279
+ const m = n && n.meeting;
280
+ if (!m) return false;
281
+ if (m.event_id && e.id && String(m.event_id) === String(e.id)) return true;
282
+ const a = Date.parse(m.start); const b = Date.parse(e.at || e.start);
283
+ return Number.isFinite(a) && Number.isFinite(b) && Math.floor(a / 60000) === Math.floor(b / 60000) && norm(m.title) === norm(e.title);
284
+ }
285
+
286
+ function referencesEvent(text, e) {
287
+ const t = norm(text);
288
+ if (!t) return false;
289
+ if (e.id && t.includes(norm(e.id)) && norm(e.id).length >= 6) return true;
290
+ const title = norm(e.title);
291
+ return title.length >= 4 && t.includes(title);
292
+ }
293
+
294
+ function movedBy(e, eventEnd, src) {
295
+ const lo = eventEnd; const hi = eventEnd + WINDOW_MS;
296
+ const within = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) && t >= lo && t <= hi; };
297
+ const out = [];
298
+ const same = (ini, text) => (ini && e.initiative && ini === e.initiative) || referencesEvent(text, e);
299
+ for (const p of src.planItems || []) {
300
+ if (!p || !p.id || !same(p.initiative, p.title)) continue;
301
+ if (within(p.createdAt)) out.push({ kind: 'moved', text: `Created plan item: ${str(p.title) || p.id}`, ref: { type: 'plan-item', id: p.id }, at: p.createdAt });
302
+ if (within(p.doneAt)) out.push({ kind: 'moved', text: `Closed plan item: ${str(p.title) || p.id}`, ref: { type: 'plan-item', id: p.id }, at: p.doneAt });
303
+ }
304
+ for (const o of src.ownItems || []) {
305
+ if (!o || !o.id || !same(o.initiative, o.title)) continue;
306
+ if (within(o.created_at)) out.push({ kind: 'moved', text: `Created your item: ${str(o.title) || o.id}`, ref: { type: 'item', id: o.id }, at: o.created_at });
307
+ if (within(o.done_at)) out.push({ kind: 'moved', text: `Closed your item: ${str(o.title) || o.id}`, ref: { type: 'item', id: o.id }, at: o.done_at });
308
+ }
309
+ out.sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || (a.text < b.text ? -1 : 1));
310
+ return out;
311
+ }
312
+
313
+ function capKinds(items) {
314
+ const n = {};
315
+ return items.filter((x) => { n[x.kind] = (n[x.kind] || 0) + 1; return n[x.kind] <= (PER_KIND[x.kind] || 8); });
316
+ }
317
+
318
+ function contextFrom(events, range, src, opts = {}) {
319
+ const s = src || {};
320
+ const today = opts.today;
321
+ const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : localMs(today);
322
+ const inside = [];
323
+ for (const e of events || []) {
324
+ const at = e && (e.at || e.start);
325
+ if (!e || !e.id || !isDay(at)) continue;
326
+ const a = day10(at); const b = e.end && isDay(e.end) ? day10(e.end) : a;
327
+ if (overlaps(a, b < a ? a : b, range)) inside.push(e);
328
+ }
329
+ const mid = (e) => (spanOf(e) ? spanOf(e).s : localMs(e.at || e.start));
330
+ inside.sort((x, y) => Math.abs(mid(x) - nowMs) - Math.abs(mid(y) - nowMs) || (String(x.id) < String(y.id) ? -1 : 1));
331
+ const context = {};
332
+ for (const e of inside.slice(0, CONTEXT_CAP)) {
333
+ const sp = spanOf(e);
334
+ const endMs = sp ? sp.f : localMs(e.end && isDay(e.end) ? e.end : e.at) + DAY_MS; // an all-day event ends at the end of its day
335
+ const past = endMs <= nowMs;
336
+ const notes = (s.notes || []).filter((n) => noteMatches(n, e));
337
+ const items = [];
338
+ if (past) {
339
+ for (const n of notes) {
340
+ if (n.private) continue; // a private note shows its title and nothing of its content
341
+ for (const d of n.decisions || []) if (str(d && d.text)) items.push({ kind: 'decision', text: str(d.text), ref: { type: 'note', id: n.id } });
342
+ for (const a of n.actions || []) {
343
+ if (!str(a && a.text)) continue;
344
+ const tail = [a.owner && `owner: ${a.owner}`, a.due && `due: ${a.due}`].filter(Boolean).join(', ');
345
+ items.push({ kind: 'action', text: str(a.text) + (tail ? ` (${tail})` : ''), ref: { type: 'note', id: n.id } });
346
+ }
347
+ }
348
+ const moved = movedBy({ ...e, initiative: e.initiative || (notes.find((n) => n.initiative) || {}).initiative || null }, endMs, s);
349
+ items.push(...moved);
350
+ } else {
351
+ const p = prepOf(e);
352
+ p.steps.forEach((t, i) => items.push({ kind: p.doneFlags && p.doneFlags[i] ? 'prepared' : 'todo', text: t, ref: { type: 'event', id: e.id } }));
353
+ if (str(e.agent_prep_notes)) items.push({ kind: 'prepared', text: 'Prep notes from your agent session are saved.', ref: { type: 'event', id: e.id } });
354
+ for (const m of e.pm_notes || []) if (str(m && m.text)) items.push({ kind: 'prepared', text: `Your note: ${str(m.text)}`, ref: { type: 'event', id: e.id }, ...(m.at ? { at: m.at } : {}) });
355
+ for (const n of notes) items.push({ kind: 'prepared', text: `A meeting note is linked: ${str(n.title) || n.id}`, ref: { type: 'note', id: n.id } });
356
+ }
357
+ const kept = capKinds(items);
358
+ context[e.id] = { phase: past ? 'past' : 'upcoming', held: kept.length > 0, items: kept };
359
+ }
360
+ return { context, context_total: inside.length };
361
+ }
362
+
363
+ /* ── all three ─────────────────────────────────────────────────────────────────────────────────────────────────── */
364
+
365
+ function build({ range, today, nowMs, events, dated, src, own, planItemsDue }) {
366
+ const l = layersFrom(dated, range, today);
367
+ const free = freeFrom(events, range, today, { nowMs, own: own || (dated && dated.own) || [], planItems: planItemsDue || (dated && dated.planItems) || [] });
368
+ const c = contextFrom(events, range, src, { today, nowMs });
369
+ return { layers: l.layers, layers_total: l.layers_total, layers_note: l.layers_note, free, context: c.context, context_total: c.context_total };
370
+ }
371
+
372
+ module.exports = { layersFrom, freeFrom, contextFrom, build, toneOf, prepOf, spanOf, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
@@ -1,51 +1,160 @@
1
1
  'use strict';
2
- /* facts-async.js — the repo's initiative facts (scripts/signals/facts.js readRepoFacts) WITHOUT blocking the server.
2
+ /* facts-async.js — the repo's initiative facts (scripts/signals/facts.js readRepoFacts) WITHOUT blocking the server, and
3
+ * read again only when the repository changed (workspace-data-freshness R2, R3).
3
4
  *
4
- * readRepoFacts derives every initiative's stage from git, once per initiative: minutes for ~300 records, and synchronous.
5
- * Called inside the server process it stops every other request (the page sat "just loading"). This runs it in a child
6
- * process (scripts/signals/facts-cli.js), keeps one result per root and day for five minutes, and shares one read in
7
- * flight between callers, so a burst of requests starts one child, not one each. Once a result is older than five minutes it
8
- * is still returned at once while a new read runs in the background (stale while revalidate), so only the first read waits.
5
+ * readRepoFacts derives every initiative's stage from git and is synchronous, so it runs in a child process
6
+ * (scripts/signals/facts-cli.js). One read in flight is shared between callers.
7
+ *
8
+ * ONE ENTRY PER REPOSITORY. The entry is not keyed by day: a caller that passes `today` gets the same facts normalized for
9
+ * that day (normalizeFacts is pure and cheap), so Briefing and Calendar share one read instead of running two.
10
+ *
11
+ * REFRESHED ON A CHANGE, NOT ON A CLOCK. The entry carries the repository's signature (lib/repo-watch.js: HEAD, the
12
+ * porcelain status, and the size and time of each dirty file) and the engine version (a hash of the scripts that derive
13
+ * the facts). A read whose signature and engine match is served from the entry. A changed signature returns the entry at
14
+ * once and starts one rebuild (stale while revalidate); only a repository never read before waits. An entry older than
15
+ * MAX_AGE_MS is rebuilt in the background even when the signature holds, which catches an input the signature cannot see
16
+ * (an ignored file). A folder that is not a git checkout has no signature and keeps the old rule: a TTL of TTL_MS.
17
+ *
18
+ * KEPT ON DISK. configure({ stateDir }) names the runtime state folder; each entry is then written to
19
+ * <stateDir>/facts-cache/<hash of root>.json and read back after a restart. Never into the repository. Unconfigured (a
20
+ * suite), nothing is written.
9
21
  */
10
22
  const { execFile } = require('child_process');
23
+ const crypto = require('crypto');
24
+ const fs = require('fs');
11
25
  const path = require('path');
26
+ const repoWatch = require('./repo-watch.js');
12
27
 
13
28
  const CLI = path.resolve(__dirname, '..', '..', 'scripts', 'signals', 'facts-cli.js');
14
- const TTL_MS = 5 * 60 * 1000;
15
- const memo = new Map(); // root|today -> { at, facts }
16
- const inflight = new Map(); // root|today -> Promise
29
+ const TTL_MS = 5 * 60 * 1000; // only for a folder with no signature
30
+ const MAX_AGE_MS = 24 * 3600 * 1000; // an entry this old is rebuilt even when the signature holds
31
+ const SIG_REUSE_MS = 2000; // a burst of requests asks git once
32
+ const memo = new Map(); // root -> { at, sig, engine, facts, ms }
33
+ const inflight = new Map(); // root -> Promise
34
+ const sigMemo = new Map(); // root -> { at, sig }
35
+ const lastError = new Map(); // root -> { at, error }
36
+ let stateDir = null;
37
+ let engineMemo = null;
38
+
39
+ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
40
+
41
+ /** The version of the code that derives the facts: a change to it makes every entry stale. */
42
+ function engineVersion() {
43
+ if (engineMemo) return engineMemo;
44
+ const sig = path.resolve(__dirname, '..', '..', 'scripts');
45
+ const parts = [CLI, path.join(sig, 'signals', 'facts.js'), path.join(sig, 'workflow-state.js')].map((f) => {
46
+ try { return sha1(fs.readFileSync(f)); } catch { return '-'; }
47
+ });
48
+ engineMemo = sha1(parts.join('|')).slice(0, 12);
49
+ return engineMemo;
50
+ }
51
+
52
+ function configure(o = {}) { stateDir = o.stateDir || null; }
53
+ const fileOf = (root) => (stateDir ? path.join(stateDir, 'facts-cache', sha1(root).slice(0, 16) + '.json') : null);
54
+
55
+ function loadDisk(root) {
56
+ const f = fileOf(root);
57
+ if (!f) return null;
58
+ try {
59
+ const e = JSON.parse(fs.readFileSync(f, 'utf8'));
60
+ return e && e.root === root && e.facts && typeof e.at === 'number' ? e : null;
61
+ } catch { return null; }
62
+ }
63
+ function saveDisk(root, entry) {
64
+ const f = fileOf(root);
65
+ if (!f) return;
66
+ try {
67
+ fs.mkdirSync(path.dirname(f), { recursive: true });
68
+ const tmp = `${f}.${process.pid}.tmp`;
69
+ fs.writeFileSync(tmp, JSON.stringify({ root, ...entry }));
70
+ fs.renameSync(tmp, f);
71
+ } catch { /* a cache that cannot be written is only a slower next start */ }
72
+ }
73
+
74
+ async function currentSig(root, opts, clock) {
75
+ if (opts.signature) return opts.signature(root);
76
+ const hit = sigMemo.get(root);
77
+ if (hit && clock() - hit.at < SIG_REUSE_MS) return hit.sig;
78
+ let sig = null;
79
+ try {
80
+ // a folder with no HEAD is not a checkout: no signature, and the TTL rule decides
81
+ const head = await new Promise((resolve) => execFile('git', ['rev-parse', 'HEAD'], { cwd: root, timeout: 15000 }, (e, out) => resolve(e ? null : out)));
82
+ if (head != null) sig = await repoWatch.signature(root, async () => ({ files: [] }));
83
+ } catch { sig = null; }
84
+ sigMemo.set(root, { at: clock(), sig });
85
+ return sig;
86
+ }
87
+
88
+ function normalize(facts, today) {
89
+ try { return require('../../scripts/signals/facts.js').normalizeFacts(facts, today || undefined); } catch { return facts; }
90
+ }
17
91
 
18
92
  function readFactsAsync(root, opts = {}) {
19
- const today = opts.today || '';
20
93
  const resolved = path.resolve(root || process.cwd());
21
- const key = resolved + '|' + today;
22
94
  const clock = opts.clock || Date.now;
23
- const hit = memo.get(key);
24
- const fresh = hit && clock() - hit.at < (opts.ttlMs == null ? TTL_MS : opts.ttlMs);
25
- if (fresh) return Promise.resolve(hit.facts);
26
-
27
- const start = () => {
28
- if (inflight.has(key)) return inflight.get(key);
29
- const run = opts.run || ((r, t) => new Promise((resolve, reject) => {
30
- execFile(process.execPath, [CLI, r, t], { maxBuffer: 512 * 1024 * 1024, timeout: 15 * 60 * 1000 }, (err, out) => {
31
- if (err) return reject(err);
32
- try { resolve(JSON.parse(out)); } catch (e) { reject(e); }
33
- });
34
- }));
35
- const p = Promise.resolve(run(resolved, today)).then((facts) => {
36
- memo.set(key, { at: clock(), facts });
37
- return facts;
38
- }).finally(() => { inflight.delete(key); });
39
- inflight.set(key, p);
40
- return p;
95
+ const today = opts.today || '';
96
+ const out = (facts) => (opts.run ? facts : normalize(facts, today));
97
+ return (async () => {
98
+ let hit = memo.get(resolved);
99
+ if (!hit) { const d = loadDisk(resolved); if (d) { hit = d; memo.set(resolved, d); } }
100
+ const sig = await currentSig(resolved, opts, clock);
101
+ const engine = opts.run ? 'test' : engineVersion();
102
+
103
+ const start = () => {
104
+ if (inflight.has(resolved)) return inflight.get(resolved);
105
+ const run = opts.run || ((r) => new Promise((resolve, reject) => {
106
+ execFile(process.execPath, [CLI, r], { maxBuffer: 512 * 1024 * 1024, timeout: 15 * 60 * 1000 }, (err, stdout) => {
107
+ if (err) return reject(err);
108
+ try { resolve(JSON.parse(stdout)); } catch (e) { reject(e); }
109
+ });
110
+ }));
111
+ const t0 = Date.now();
112
+ const p = Promise.resolve(run(resolved)).then((facts) => {
113
+ const entry = { at: clock(), sig, engine, facts, ms: Date.now() - t0 };
114
+ memo.set(resolved, entry);
115
+ lastError.delete(resolved);
116
+ saveDisk(resolved, entry);
117
+ return facts;
118
+ }, (e) => { lastError.set(resolved, { at: clock(), error: String((e && e.message) || e).slice(0, 200) }); throw e; })
119
+ .finally(() => { inflight.delete(resolved); });
120
+ inflight.set(resolved, p);
121
+ return p;
122
+ };
123
+
124
+ if (hit) {
125
+ const age = clock() - hit.at;
126
+ const fresh = sig != null
127
+ ? hit.sig === sig && hit.engine === engine && age < MAX_AGE_MS
128
+ : age < (opts.ttlMs == null ? TTL_MS : opts.ttlMs);
129
+ if (fresh) return out(hit.facts);
130
+ if (opts.stale !== false) { start().catch(() => {}); return out(hit.facts); }
131
+ }
132
+ return out(await start());
133
+ })();
134
+ }
135
+
136
+ /** How old the facts of `root` are, for the freshness surface (R6). Reads nothing but memory and the disk entry. */
137
+ function status(root) {
138
+ const resolved = path.resolve(root);
139
+ const hit = memo.get(resolved) || loadDisk(resolved);
140
+ const err = lastError.get(resolved);
141
+ return {
142
+ as_of: hit ? new Date(hit.at).toISOString() : null,
143
+ building: inflight.has(resolved),
144
+ took_ms: hit && hit.ms != null ? hit.ms : null,
145
+ error: err ? err.error : null,
41
146
  };
147
+ }
42
148
 
43
- // Stale while revalidate: an expired result is still the best answer for now. Return it at once and refresh in the
44
- // background, so only the very first read of a repository ever waits (opts.stale === false turns this off).
45
- if (hit && opts.stale !== false) { start().catch(() => {}); return Promise.resolve(hit.facts); }
46
- return start();
149
+ /** Start a rebuild now (a change the watcher saw, or the PM's Refresh). The signature is asked again first. */
150
+ function refresh(root, opts = {}) {
151
+ const resolved = path.resolve(root);
152
+ sigMemo.delete(resolved);
153
+ const hit = memo.get(resolved);
154
+ if (hit && opts.force) memo.set(resolved, { ...hit, sig: '(forced)' });
155
+ return readFactsAsync(resolved, opts).catch(() => null);
47
156
  }
48
157
 
49
- function resetFactsCache() { memo.clear(); inflight.clear(); }
158
+ function resetFactsCache() { memo.clear(); inflight.clear(); sigMemo.clear(); lastError.clear(); }
50
159
 
51
- module.exports = { readFactsAsync, resetFactsCache, TTL_MS };
160
+ module.exports = { readFactsAsync, resetFactsCache, refresh, status, configure, engineVersion, TTL_MS, MAX_AGE_MS };