create-pmos 0.129.0 → 0.131.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 (48) 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 +8 -3
  7. package/workspace/c3-work-test.js +200 -0
  8. package/workspace/fixtures/one-home-baseline.json +0 -91
  9. package/workspace/guide/briefing.md +60 -0
  10. package/workspace/guide/calendar-popup.md +46 -0
  11. package/workspace/guide/console-calendar.md +65 -0
  12. package/workspace/guide/index.md +4 -0
  13. package/workspace/guide/layout.md +1 -0
  14. package/workspace/guide/my-work.md +76 -1
  15. package/workspace/lib/calendar-layers.js +372 -0
  16. package/workspace/lib/pm-items.js +71 -6
  17. package/workspace/lib/report-needs.js +1 -1
  18. package/workspace/lib/route-modules.js +1 -0
  19. package/workspace/lib/routes-brief-page.js +187 -0
  20. package/workspace/lib/routes-calendar-views.js +112 -3
  21. package/workspace/lib/routes-pm-items.js +123 -13
  22. package/workspace/lib/work-focus.js +106 -0
  23. package/workspace/package-lock.json +2 -2
  24. package/workspace/package.json +1 -1
  25. package/workspace/public/pm/c3-brief-data.js +11 -0
  26. package/workspace/public/pm/c3-brief-needs.js +145 -0
  27. package/workspace/public/pm/c3-brief-pulse.js +70 -0
  28. package/workspace/public/pm/c3-brief-today.js +44 -0
  29. package/workspace/public/pm/c3-calendar.js +392 -0
  30. package/workspace/public/pm/c3-calpop.js +103 -0
  31. package/workspace/public/pm/c3-demo-brief-page.js +54 -0
  32. package/workspace/public/pm/c3-demo-cal4.js +114 -0
  33. package/workspace/public/pm/c3-demo-data.js +79 -77
  34. package/workspace/public/pm/c3-demo-misc2.js +4 -16
  35. package/workspace/public/pm/c3-demo-work-ix.js +19 -67
  36. package/workspace/public/pm/c3-demo.js +11 -4
  37. package/workspace/public/pm/c3-shell.js +27 -149
  38. package/workspace/public/pm/calendar-views.js +11 -3
  39. package/workspace/public/pm/components.css +1 -0
  40. package/workspace/public/pm/console3.css +198 -0
  41. package/workspace/public/pm/pm-work-view.js +364 -172
  42. package/workspace/public/pm/work-place.js +58 -0
  43. package/workspace/test-files.json +7 -1
  44. package/workspace/test-lists/calendar-layers.json +5 -0
  45. package/workspace/test-lists/calendar-time-page.json +5 -0
  46. package/workspace/test-lists/calpop.json +4 -0
  47. package/workspace/test-lists/console3-work-page.json +9 -0
  48. package/workspace/public/pm/c3-demo-work2.js +0 -13
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+ /* routes-brief-page.js — GET /api/brief-page[?ws=<id>&now=ISO] (console3 Briefing as the brief, PRD briefing-brief R2, R3, R8).
3
+ *
4
+ * → { ok, date, today: [meeting], needs: [row], decisions_error? }
5
+ *
6
+ * READ ONLY, and built only from what PMOS already holds. Nothing is invented.
7
+ * today one row per meeting of the local day, in time order, from the cached calendar events (routes-calendar-import.js readEvents,
8
+ * the same source as /api/calendar-views): { id, title, start, end, minutes, people, initiative, prepared, before, state }.
9
+ * prepared: [{ kind: 'prep'|'pm-note'|'note'|'last-note', text, id? }] what PMOS holds for the meeting (the agent's prep note,
10
+ * the PM's notes on the event, the note linked to it, the last note of an earlier occurrence). before: [{ id, title, due,
11
+ * done }] the PM's own open items that name the meeting (item.meeting_id or item.event_id equals the event id, or the title
12
+ * contains the meeting's title). state: 'past' | 'now' | 'next' | 'later'.
13
+ * needs decisions only (with ?decisions=1; the page takes them from GET /api/briefing instead) the PM can make (the signal engine's cards that carry options or are Critical), the PM's own items that are
14
+ * overdue or due today, and outside-PMOS items (kind 'offline') that await confirmation. Overdue rows come first, the most
15
+ * late first, then the items due today, then the offline ones, then the decisions.
16
+ * An item row: { key, kind: 'overdue'|'due-today'|'offline', id, headline, due, days_late, priority, person, offline, evidence }.
17
+ * evidence is { text, event_id } when the calendar holds a meeting with the person, else null: the page asks the PM to
18
+ * confirm it and never marks the item done itself.
19
+ * A decision row: { key, kind: 'decision', headline, tag, card }.
20
+ * Item fields read defensively (a missing field is absent): kind, defer, person, evidence, workPlacement(item, today) when lib/pm-items.js
21
+ * has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes. */
22
+ const path = require('path');
23
+ const pmItems = require('./pm-items.js');
24
+
25
+ const pad = (n) => String(n).padStart(2, '0');
26
+ const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
27
+ const DAY = 86400000;
28
+ const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
29
+ const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
30
+ const hhmm = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
31
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
32
+
33
+ function daysBetween(fromIso, toIso) {
34
+ const a = Date.parse(fromIso + 'T00:00:00Z'); const b = Date.parse(toIso + 'T00:00:00Z');
35
+ return Math.round((b - a) / DAY);
36
+ }
37
+
38
+ function deferred(item, now, todayIso) {
39
+ const d = item.defer;
40
+ if (!d) return false;
41
+ const s = String(d);
42
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s > todayIso;
43
+ const t = Date.parse(s);
44
+ return Number.isFinite(t) && t > now.getTime();
45
+ }
46
+
47
+ /** The meetings of the local day. `notes` is an optional { findByEvent(ev), lastBefore(ev) } so the route can read the notes store. */
48
+ function todayAgenda(events, items, now, notes = {}) {
49
+ const todayIso = localDate(now);
50
+ const open = items.filter((i) => !i.done);
51
+ const rows = (events || []).filter((e) => e && e.at && !Number.isNaN(Date.parse(e.at)) && localDate(new Date(e.at)) === todayIso)
52
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || String(a.id).localeCompare(String(b.id)))
53
+ .map((e) => {
54
+ const s = new Date(e.at); const endMs = e.end && Date.parse(e.end) > Date.parse(e.at) ? Date.parse(e.end) : Date.parse(e.at);
55
+ const prepared = [];
56
+ if (e.agent_prep_notes && String(e.agent_prep_notes).trim()) prepared.push({ kind: 'prep', text: clip(e.agent_prep_notes, 200) });
57
+ const pm = Array.isArray(e.pm_notes) ? e.pm_notes : [];
58
+ if (pm.length) prepared.push({ kind: 'pm-note', text: clip(pm[pm.length - 1].text, 200) });
59
+ const linked = notes.findByEvent ? notes.findByEvent(e) : null;
60
+ if (linked) prepared.push({ kind: 'note', text: 'Notes started: ' + clip(linked.title, 80), id: linked.id });
61
+ const last = notes.lastBefore ? notes.lastBefore(e) : null;
62
+ if (last) prepared.push({ kind: 'last-note', text: 'Last notes: ' + clip(last.title, 80) + ' (' + String(last.updated_at || last.created_at || '').slice(0, 10) + ')', id: last.id });
63
+ const t = norm(e.title);
64
+ const before = open.filter((i) => (i.meeting_id && i.meeting_id === e.id) || (i.event_id && i.event_id === e.id) || (t.length >= 6 && norm(i.title).includes(t)))
65
+ .map((i) => ({ id: i.id, title: i.title, due: i.due || null, done: !!i.done }));
66
+ return { id: e.id, title: e.title, start: s.toISOString(), end: new Date(endMs).toISOString(), startLocal: hhmm(s), minutes: Math.max(0, Math.round((endMs - s.getTime()) / 60000)),
67
+ people: Array.isArray(e.attendees) ? e.attendees.slice(0, 12) : [], initiative: e.initiative || null, prepared, before, _s: s.getTime(), _e: endMs };
68
+ });
69
+ let nextDone = false;
70
+ for (const r of rows) {
71
+ const nowMs = now.getTime();
72
+ if (r._e <= nowMs && r._e > r._s) r.state = 'past';
73
+ else if (r._s <= nowMs && r._e > nowMs) r.state = 'now';
74
+ else if (r._e === r._s && r._s < nowMs) r.state = 'past';
75
+ else if (!nextDone) { r.state = 'next'; nextDone = true; } else r.state = 'later';
76
+ }
77
+ rows.forEach((r) => { delete r._s; delete r._e; });
78
+ return rows;
79
+ }
80
+
81
+ /** A calendar meeting that names the person, from today on: "a meeting with Ana is booked Tue 10:00". */
82
+ function evidenceFor(item, events, now) {
83
+ if (item.evidence) {
84
+ const t = Array.isArray(item.evidence) ? item.evidence[0] : item.evidence;
85
+ const text = typeof t === 'string' ? t : t && t.text;
86
+ if (text) return { text: clip(text, 160), event_id: (t && t.event_id) || null };
87
+ }
88
+ const names = [];
89
+ if (item.person) names.push(norm(item.person));
90
+ const title = norm(item.title);
91
+ const hits = (events || []).filter((e) => e && e.at && Date.parse(e.at) >= now.getTime() - 3600000 && Date.parse(e.at) <= now.getTime() + 21 * DAY)
92
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
93
+ for (const e of hits) {
94
+ const who = (Array.isArray(e.attendees) ? e.attendees : []).map(norm).filter(Boolean);
95
+ const hit = who.find((w) => names.some((n) => n && (w.includes(n) || n.includes(w))) || (w.split(' ')[0].length >= 3 && new RegExp('\\b' + w.split(' ')[0].replace(/[^a-z0-9]/g, '') + '\\b').test(title)));
96
+ if (hit) {
97
+ const d = new Date(e.at);
98
+ const who1 = hit.split(' ')[0]; const nice = who1.charAt(0).toUpperCase() + who1.slice(1);
99
+ return { text: 'a meeting with ' + nice + ' is booked ' + WD[d.getDay()] + ' ' + hhmm(d), event_id: e.id };
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function needsOf(items, events, cards, now, deps = {}) {
106
+ const todayIso = localDate(now);
107
+ const todayDate = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
108
+ const work = typeof pmItems.workPlacement === 'function' ? pmItems.workPlacement : null;
109
+ const late = []; const dueToday = []; const offline = [];
110
+ for (const i of items) {
111
+ if (!i || i.done || deferred(i, now, todayIso)) continue;
112
+ const base = { id: i.id, headline: i.title, due: i.due || null, priority: i.priority || null, person: i.person || null };
113
+ if (i.kind === 'offline') {
114
+ const late0 = i.due && i.due < todayIso ? daysBetween(i.due, todayIso) : 0;
115
+ offline.push({ key: 'item:' + i.id, kind: 'offline', ...base, days_late: late0, offline: true, evidence: evidenceFor(i, events, now) });
116
+ continue;
117
+ }
118
+ if (!i.due || i.due > todayIso) continue;
119
+ if (work) { if (work(i, todayDate) !== 'today') continue; } else if (i.waiting_on || i.parked || i.list_override === 'later') continue;
120
+ if (i.due < todayIso) late.push({ key: 'item:' + i.id, kind: 'overdue', ...base, days_late: daysBetween(i.due, todayIso), offline: false, evidence: null });
121
+ else dueToday.push({ key: 'item:' + i.id, kind: 'due-today', ...base, days_late: 0, offline: false, evidence: null });
122
+ }
123
+ late.sort((a, b) => b.days_late - a.days_late || String(a.headline).localeCompare(String(b.headline)));
124
+ offline.sort((a, b) => b.days_late - a.days_late || String(a.headline).localeCompare(String(b.headline)));
125
+ const byTitle = (a, b) => String(a.headline).localeCompare(String(b.headline));
126
+ dueToday.sort(byTitle);
127
+ const decisions = (cards || []).filter((c) => c && (c.move || c.tag === 'Critical')).map((c) => ({ key: 'card:' + c.key, kind: 'decision', headline: c.headline, tag: c.tag, card: c }));
128
+ return [...late, ...dueToday, ...offline, ...decisions];
129
+ }
130
+
131
+ /** The pure builder. deps: { events, items (folded), cards, notes } so a test needs no repository. */
132
+ function build(query, deps = {}) {
133
+ const q = query || {};
134
+ let now = q.now ? new Date(q.now) : (deps.now ? new Date(deps.now) : new Date());
135
+ if (Number.isNaN(now.getTime())) now = new Date();
136
+ const events = deps.events || []; const items = deps.items || [];
137
+ const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps) };
138
+ if (deps.decisionsError) body.decisions_error = deps.decisionsError;
139
+ return { status: 200, body };
140
+ }
141
+
142
+ function notesAdapter(runtimeDir) {
143
+ let notes; try { notes = require('./notes.js'); } catch { return {}; }
144
+ const rows = () => { try { const l = notes.list(runtimeDir); return l && l.ok ? l.notes.filter((n) => !n.private) : []; } catch { return []; } };
145
+ return {
146
+ findByEvent: (e) => { try { const n = notes.findByEvent(runtimeDir, { event_id: e.id, start: e.at, title: e.title }); return n && !n.private ? { id: n.id, title: n.title || 'Untitled note' } : null; } catch { return null; } },
147
+ lastBefore: (e) => {
148
+ const t = norm(e.title);
149
+ const hits = rows().filter((n) => n.meeting && n.meeting.kind === 'event' && norm(n.meeting.title) === t && Date.parse(n.meeting.start) < Date.parse(e.at))
150
+ .sort((a, b) => String(b.meeting.start).localeCompare(String(a.meeting.start)));
151
+ return hits[0] || null;
152
+ },
153
+ };
154
+ }
155
+
156
+ module.exports = {
157
+ prefixes: ['/api/brief-page'],
158
+ build, todayAgenda, needsOf, evidenceFor, deferred,
159
+ handle: async (req, res, url, ctx) => {
160
+ if (url.pathname !== '/api/brief-page') return false;
161
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
162
+ const q = Object.fromEntries(url.searchParams.entries());
163
+ let now = q.now ? new Date(q.now) : new Date();
164
+ if (Number.isNaN(now.getTime())) now = new Date();
165
+ try {
166
+ const dir = ctx.runtimeDir;
167
+ const events = dir ? require('./routes-calendar-import.js').readEvents(dir) : [];
168
+ const items = dir ? pmItems.fold(pmItems.readLines(path.join(dir, 'pm-items', 'items.jsonl'))) : [];
169
+ // The decisions come from the signal engine, whose repository read is slow. The page already holds them (GET /api/briefing feeds its deck), so the
170
+ // read is made only when the caller asks (?decisions=1); the items and the agenda never wait for it.
171
+ let cards = []; let decisionsError = null;
172
+ if (q.decisions === '1') try {
173
+ const w = ctx.getWs && ctx.getWs(q.ws || null);
174
+ const root = (w && w.root) || path.resolve(__dirname, '..', '..');
175
+ const iso = localDate(now);
176
+ const facts = await require('./facts-async.js').readFactsAsync(root, { today: iso });
177
+ const sig = path.resolve(__dirname, '..', '..', 'scripts', 'signals');
178
+ cards = require(path.join(sig, 'index.js')).present(facts, { lens: 'Unblock', zoom: 'week', today: iso }).cards || [];
179
+ } catch (e) { decisionsError = 'the decisions could not be read: ' + (e && e.message ? e.message : String(e)); }
180
+ const out = build({ now: now.toISOString() }, { events, items, cards, decisionsError, notes: dir ? notesAdapter(dir) : {} });
181
+ ctx.json(res, out.body, out.status);
182
+ } catch (e) {
183
+ ctx.json(res, { ok: false, error: 'the brief page could not be built: ' + (e && e.message ? e.message : String(e)) }, 500);
184
+ }
185
+ return true;
186
+ },
187
+ };
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
  /* routes-calendar-views.js — GET /api/calendar-views?view=day|week|month|quarter|year&date=YYYY-MM-DD&ws=<id> (console3 wave C2).
3
3
  *
4
- * → { ok, view, range, cells, unit, gantt, strategy, events_source }
4
+ * → { ok, view, range, cells, unit, gantt, strategy, events_source, today,
5
+ * layers, layers_total, layers_note, free, context, context_total } (the last six: lib/calendar-layers.js)
5
6
  *
6
7
  * READ ONLY. Initiatives come from the signal engine's repo reader (scripts/signals/facts.js readRepoFacts), required inside
7
8
  * the handler so a failure answers 500 { ok:false } and never reaches the server. The date and the plan come from the records:
@@ -13,10 +14,12 @@
13
14
  * gets none and says so. */
14
15
  const path = require('path');
15
16
  const model = require('../../scripts/calendar-model.js');
17
+ const layersLib = require('./calendar-layers.js');
16
18
 
17
19
  const today = () => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
18
20
 
19
- /** The pure handler. deps: { readFacts(root), readEvents?(range), readKrs?(), today } so tests need no repo. */
21
+ /** The pure handler. deps: { readFacts(), readEvents?(range), readKrs?(), today, nowMs?, readDated?(range), readItems?(),
22
+ * readContextSources?() } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
20
23
  function handle(query, deps = {}) {
21
24
  const view = (query && query.view) || 'month';
22
25
  const date = (query && query.date) || (deps.today || today());
@@ -33,13 +36,118 @@ function handle(query, deps = {}) {
33
36
  const nowDay = deps.today || today();
34
37
  const gantt = model.ganttRows(inis, view, date, { today: nowDay });
35
38
  const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
36
- return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay } };
39
+ const dated = typeof deps.readDated === 'function' ? (deps.readDated(range) || {}) : {
40
+ unavailable: { plan_items: 'no reader is wired', releases: 'no reader is wired', reports: 'no reader is wired', own: 'no reader is wired' } };
41
+ // the initiatives come from the facts this handler already holds, so every caller gets their promised dates
42
+ if (!dated.initiatives && !(dated.unavailable && dated.unavailable.initiatives)) dated.initiatives = inis;
43
+ const items = typeof deps.readItems === 'function' ? (deps.readItems() || {}) : {};
44
+ const src = typeof deps.readContextSources === 'function' ? (deps.readContextSources() || {}) : {};
45
+ const nowMs = Number.isFinite(deps.nowMs) ? deps.nowMs : (deps.today ? undefined : Date.now());
46
+ const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems });
47
+ return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay, ...extra } };
37
48
  } catch (e) {
38
49
  return { status: 500, body: { ok: false, error: String(e && e.message || e) } };
39
50
  }
40
51
  }
41
52
 
53
+ /* ── the readers behind the layers, free time and context (each source on its own: one that cannot be read is named in
54
+ * layers_note and gives nothing; it never takes the answer down) ────────────────────────────────────────────────── */
55
+ const reason = (e) => String((e && e.message) || e).slice(0, 160);
56
+
57
+ function readers(root, runtimeDir) {
58
+ let plan = null;
59
+ const planFold = () => { if (!plan) plan = require('./plan-items.js').readFold(root); return plan; };
60
+ const planRows = () => Object.values(planFold().items).filter((p) => !(p.proposed && !p.kept));
61
+ let ownLines = null;
62
+ const ownRows = () => {
63
+ if (!ownLines) ownLines = require('./pm-items.js').readLines(path.join(runtimeDir, 'pm-items', 'items.jsonl'));
64
+ return require('./pm-items.js').fold(ownLines);
65
+ };
66
+ const planItem = (p) => ({ id: p.id, initiative: p.initiative, title: p.title, due: p.due, milestone: !!p.milestone, state: p.state });
67
+ return {
68
+ readDated(range) {
69
+ const unavailable = {};
70
+ const out = { unavailable };
71
+ try { out.planItems = planRows().map(planItem); } catch (e) { unavailable.plan_items = 'the plan ledger could not be read: ' + reason(e); }
72
+ try {
73
+ const R = require('../../scripts/releases.js');
74
+ const f = path.join(runtimeDir, 'releases', 'releases.jsonl');
75
+ let lines = []; try { lines = require('fs').readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { lines = []; }
76
+ out.releases = R.foldReleases(lines);
77
+ } catch (e) { unavailable.releases = 'the releases file could not be read: ' + reason(e); }
78
+ try {
79
+ const RN = require('./report-needs.js');
80
+ out.reports = RN.reportsOf(root).reports.map((r) => ({ cadence: r.cadence, slot: r.slot, issued: r.issued ? { at: r.issued.at } : null }));
81
+ out.reportSchedules = reportSchedules(RN, range);
82
+ } catch (e) { unavailable.reports = 'the report folders could not be read: ' + reason(e); }
83
+ try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done })); } catch (e) { unavailable.own = 'your items could not be read: ' + reason(e); }
84
+ return out;
85
+ },
86
+ readItems() {
87
+ const out = { own: [], planItems: [] };
88
+ try { out.planItems = planRows().map(planItem); } catch { /* the plan ledger is named in layers_note */ }
89
+ try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
90
+ return out;
91
+ },
92
+ readContextSources() {
93
+ const src = { notes: [], planItems: [], ownItems: [] };
94
+ try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt })); } catch { /* no plan ledger: no moved items */ }
95
+ try { src.ownItems = ownRows().map((o) => ({ id: o.id, title: o.title, initiative: o.initiative || null, created_at: o.created_at || null, done_at: o.done_at || null })); } catch { /* no own items */ }
96
+ try { src.notes = linkedNotes(runtimeDir); } catch { /* no notes: no decisions or actions */ }
97
+ return src;
98
+ },
99
+ };
100
+ }
101
+
102
+ /** The dates a report schedule fires on, from the range start, using the same slot math the scheduler runs on. */
103
+ function reportSchedules(RN, range) {
104
+ const logic = require('./schedule-logic.js');
105
+ const out = [];
106
+ const t0 = new Date();
107
+ const today = `${t0.getFullYear()}-${String(t0.getMonth() + 1).padStart(2, '0')}-${String(t0.getDate()).padStart(2, '0')}`;
108
+ const first = range && range.start > today ? range.start : today;
109
+ const from = new Date(Number(first.slice(0, 4)), Number(first.slice(5, 7)) - 1, Number(first.slice(8, 10)));
110
+ for (const s of RN.readSchedules()) {
111
+ if (!s || !s.enabled) continue;
112
+ const cadence = RN.cadenceOfSchedule(s);
113
+ if (!cadence) continue;
114
+ const dates = [];
115
+ let at = from;
116
+ for (let i = 0; i < 62; i++) {
117
+ const next = logic.nextRun(s, at);
118
+ if (!next) break;
119
+ const d = new Date(next);
120
+ const ds = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
121
+ if (range && ds > range.end) break;
122
+ dates.push(ds);
123
+ at = new Date(d.getTime() + 1000);
124
+ }
125
+ out.push({ id: String(s.id || s.name || cadence), name: s.name, cadence, dates: [...new Set(dates)] });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** Notes linked to a calendar event, read straight from the notes folder (no import, no write). */
131
+ function linkedNotes(runtimeDir) {
132
+ const fs = require('fs');
133
+ const dir = path.join(runtimeDir, 'notes');
134
+ const out = [];
135
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
136
+ let n; try { n = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
137
+ if (!n || !n.id || !n.meeting || n.meeting.kind !== 'event') continue;
138
+ const fin = n.final_notes && typeof n.final_notes === 'object' ? n.final_notes : null;
139
+ const sec = n.summary && n.summary.sections ? n.summary.sections : null;
140
+ const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
141
+ out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null,
142
+ meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title },
143
+ decisions: pick(fin && fin.decisions, sec && sec.decisions).map((x) => ({ text: x && x.text })),
144
+ actions: pick(fin && fin.actions, sec && sec.actions).map((x) => ({ text: x && x.text, owner: x && x.owner || null, due: x && x.due || null })) });
145
+ }
146
+ return out;
147
+ }
148
+
42
149
  module.exports = {
150
+ readers,
43
151
  prefixes: ['/api/calendar-views'],
44
152
  handlePure: handle,
45
153
  handle: async (req, res, url, ctx) => {
@@ -54,6 +162,7 @@ module.exports = {
54
162
  const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
55
163
  readFacts: () => facts,
56
164
  readEvents: () => require('./routes-calendar-import.js').readEvents(ctx.runtimeDir),
165
+ ...readers(root, ctx.runtimeDir),
57
166
  });
58
167
  ctx.json(res, out.body, out.status);
59
168
  return true;
@@ -1,13 +1,29 @@
1
1
  'use strict';
2
- /* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice).
2
+ /* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice; the
3
+ * console3 Work page: lib/pm-items.js workPlacement, lib/work-focus.js).
3
4
  *
4
- * GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items }
5
+ * GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items,
6
+ * work: { inbox, today, upcoming, waiting, someday } open items, placed once here
7
+ * review, review_days: 7 open items untouched 7 days, most stale first
8
+ * focus: { suggestions, accepted } at most 3 suggestions: { key, title, why, item_ids, job }
9
+ * counts: { inbox, today, upcoming, waiting, someday, review } }
5
10
  * POST /api/pm-items/add { line } → { ok, item } one line: the parser (lib/pm-items.js) reads the parts
6
- * POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job
11
+ * POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job,
12
+ * defer, flagged, resolves, kind, person, evidence, outcome
7
13
  * POST /api/pm-items/remove { id } → { ok, undo_ms } deleted (created here) or dropped (imported)
8
14
  * POST /api/pm-items/undo { id } → { ok, item } | 409 restores the last removal inside the 8 s window
9
- * POST /api/pm-items/done { id } → { ok } folds to item.done + done_at; /reopen undoes it
15
+ * POST /api/pm-items/done { id, outcome? } → { ok } folds to item.done + done_at (+ outcome, one line); /reopen undoes it
10
16
  * POST /api/pm-items/park { id } → { ok } parked: true, placed in Later; /unpark clears it
17
+ * POST /api/pm-items/flag { id, flagged } → { ok } flagged: boolean; a flagged item is in Today
18
+ * POST /api/pm-items/defer { id, until|null } → { ok } defer: not before that date (null clears); clears parked
19
+ * POST /api/pm-items/someday { id } → { ok } parked: true, placed in Someday
20
+ * POST /api/pm-items/waiting { id, on|null } → { ok } waiting_on and waiting_since (null clears); clears parked
21
+ * POST /api/pm-items/notyet { id, when } → { ok } when: later-today | tomorrow | next-week; adds 1 to not_yet_count;
22
+ * tomorrow and next-week set defer (next Monday), later-today sets nudge_at (now + 3 h)
23
+ * POST /api/pm-items/keep { id } → { ok } the Review pass kept it: event 'kept', touched_at moves
24
+ * POST /api/pm-items/focus/accept { key, title, why, item_ids, job? } → { ok } today's Focus (focus.jsonl)
25
+ * POST /api/pm-items/focus/dismiss { key } → { ok } not suggested again today
26
+ * POST /api/pm-items/focus/clear { key } → { ok } forget the accept or the dismissal
11
27
  * POST /api/pm-items/move { id, list } → { ok } list_override, one of the five lists
12
28
  * POST /api/pm-items/steps/add { id, text, who? } → { ok, step } outward steps are forced to agent-drafts
13
29
  * POST /api/pm-items/steps/who { step_id, who } → { ok, who }
@@ -16,7 +32,7 @@
16
32
  * POST /api/pm-jobs/edit { id, fields }→ { ok, job } fields: when, want, so
17
33
  * POST /api/pm-jobs/delete { id, reassign_to? } → { ok, reassigned } items move to reassign_to, or to none
18
34
  *
19
- * STATE. Two append-only files under <runtime>/pm-items/: items.jsonl and jobs.jsonl. Every write is one appended line
35
+ * STATE. Append-only files under <runtime>/pm-items/: items.jsonl, jobs.jsonl, steps.jsonl and focus.jsonl. Every write is one appended line
20
36
  * through pm-items.appendEvent, the single writer, and the GET folds the whole file again. Nothing is read from Jira.
21
37
  *
22
38
  * WHO WRITES. Every POST passes pm-guard requirePagePress: the page only, with the Origin and nonce checks. An agent
@@ -28,9 +44,12 @@ const crypto = require('crypto');
28
44
  const guard = require('./pm-guard.js');
29
45
  const pmItems = require('./pm-items.js');
30
46
  const jobs = require('./jobs-to-be-done.js');
47
+ const workFocus = require('./work-focus.js');
31
48
  const steps = require('./item-steps.js');
32
49
 
33
- const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job'];
50
+ const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job', 'defer', 'flagged', 'resolves', 'kind', 'person', 'evidence', 'outcome'];
51
+ const STRING_OR_NULL = ['resolves', 'person', 'evidence', 'outcome'];
52
+ const NOT_YET = ['later-today', 'tomorrow', 'next-week'];
34
53
  const JOB_FIELDS = ['when', 'want', 'so'];
35
54
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
36
55
 
@@ -38,17 +57,40 @@ const dirOf = (ctx) => path.join(ctx.runtimeDir, 'pm-items');
38
57
  const itemsFile = (ctx) => path.join(dirOf(ctx), 'items.jsonl');
39
58
  const stepsFile = (ctx) => path.join(dirOf(ctx), 'steps.jsonl');
40
59
  const jobsFile = (ctx) => path.join(dirOf(ctx), 'jobs.jsonl');
60
+ const focusFile = (ctx) => path.join(dirOf(ctx), 'focus.jsonl');
41
61
  const newId = (prefix) => `${prefix}-${crypto.randomBytes(6).toString('hex')}`;
42
62
 
43
63
  // The current view, folded from the files. `now` decides placement, so tests can pin the day.
44
- function view(itemLines, jobLines, now, stepLines = []) {
64
+ // The titles of the team tasks (plan items) that own items advance, so the page can say "Advances: <title>". Best effort: no workspace, no map.
65
+ function resolvesTitles(items, ctx, url) {
66
+ const ids = [...new Set((items || []).map((i) => i.resolves).filter(Boolean))];
67
+ if (!ids.length || typeof ctx.getWs !== 'function') return {};
68
+ try {
69
+ const w = ctx.getWs(String(url.searchParams.get('ws') || ''));
70
+ if (!w || !w.root) return {};
71
+ const fold = require('./plan-items.js').readFold(w.root);
72
+ const out = {};
73
+ for (const id of ids) if (fold.items[id] && fold.items[id].title) out[id] = fold.items[id].title;
74
+ return out;
75
+ } catch { return {}; }
76
+ }
77
+
78
+ function view(itemLines, jobLines, now, stepLines = [], focusLines = []) {
45
79
  const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
46
80
  const byItem = steps.foldSteps(stepLines).byItem;
47
81
  const items = pmItems.fold(itemLines).map((i) => ({ ...i, steps: byItem[i.id] || [] }));
48
82
  const currentJobs = jobs.foldJobs(jobLines).map((j) => ({ ...j, ...jobs.progress(j.id, items) }));
49
83
  const lists = Object.fromEntries(pmItems.LISTS.map((l) => [l, []]));
50
84
  for (const it of items) lists[pmItems.placement(it, today)].push(it);
51
- return { ok: true, today: today.toISOString().slice(0, 10), lists, jobs: currentJobs, items };
85
+ const todayIso = today.toISOString().slice(0, 10);
86
+ const work = Object.fromEntries(pmItems.WORK_LISTS.map((l) => [l, []]));
87
+ for (const it of items) if (!it.done) work[pmItems.workPlacement(it, today)].push(it);
88
+ for (const l of pmItems.WORK_LISTS) work[l] = pmItems.sortWorkList(l, work[l], today);
89
+ const review = pmItems.reviewQueue(items, now);
90
+ const state = workFocus.foldFocus(focusLines, todayIso);
91
+ const focus = { suggestions: workFocus.focusSuggestions(work.today, currentJobs, state, todayIso), accepted: state.accepted };
92
+ const counts = { ...Object.fromEntries(pmItems.WORK_LISTS.map((l) => [l, work[l].length])), review: review.length };
93
+ return { ok: true, today: todayIso, lists, jobs: currentJobs, items, work, review, review_days: pmItems.REVIEW_DAYS, focus, counts };
52
94
  }
53
95
 
54
96
  // Each operation returns { status, body, events } and writes nothing itself; the caller appends `events`.
@@ -59,6 +101,12 @@ function checkFields(fields, allowed) {
59
101
  }
60
102
  if ('due' in fields && fields.due !== null && !DATE_RE.test(String(fields.due))) return 'due must be YYYY-MM-DD or null';
61
103
  if ('priority' in fields && fields.priority !== null && !/^P[1-3]$/.test(String(fields.priority))) return 'priority must be P1, P2, P3 or null';
104
+ if ('defer' in fields && fields.defer !== null && !DATE_RE.test(String(fields.defer))) return 'defer must be YYYY-MM-DD or null';
105
+ if ('flagged' in fields && typeof fields.flagged !== 'boolean') return 'flagged must be true or false';
106
+ if ('kind' in fields && fields.kind !== null && fields.kind !== 'offline') return 'kind must be offline or null';
107
+ for (const k of STRING_OR_NULL) {
108
+ if (k in fields && fields[k] !== null && typeof fields[k] !== 'string') return `${k} must be a string or null`;
109
+ }
62
110
  return null;
63
111
  }
64
112
 
@@ -98,7 +146,11 @@ function flagItem(body, itemLines, events) {
98
146
  if (!item) return noItem;
99
147
  return { status: 200, body: { ok: true }, events: typeof events === 'function' ? events(item) : events };
100
148
  }
101
- const doneItem = (b, l) => flagItem(b, l, [{ event: 'done', id: b.id }]);
149
+ function doneItem(b, l) {
150
+ if (b.outcome !== undefined && b.outcome !== null && typeof b.outcome !== 'string') return { status: 400, body: { ok: false, error: 'outcome must be a string' } };
151
+ const outcome = typeof b.outcome === 'string' && b.outcome.trim() ? { outcome: b.outcome.trim() } : {};
152
+ return flagItem(b, l, [{ event: 'done', id: b.id, ...outcome }]);
153
+ }
102
154
  const reopenItem = (b, l) => flagItem(b, l, [{ event: 'reopened', id: b.id }]);
103
155
  const parkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
104
156
  const unparkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: false } }]);
@@ -107,6 +159,53 @@ function moveItem(b, l) {
107
159
  return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { list_override: b.list, parked: false } }]);
108
160
  }
109
161
 
162
+ // Work page routes. A legacy list_override would beat the new placement, so the routes that place an item clear it.
163
+ const bad = (error) => ({ status: 400, body: { ok: false, error } });
164
+ function flagRoute(b, l) {
165
+ if (typeof b.flagged !== 'boolean') return bad('flagged must be true or false');
166
+ return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { flagged: b.flagged } }]);
167
+ }
168
+ function deferItem(b, l) {
169
+ if (b.until !== null && !DATE_RE.test(String(b.until))) return bad('until must be YYYY-MM-DD or null');
170
+ return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { defer: b.until, parked: false, list_override: null } }]);
171
+ }
172
+ const somedayItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
173
+ function waitingItem(b, l, now) {
174
+ const on = typeof b.on === 'string' ? b.on.trim() : b.on;
175
+ if (on !== null && (typeof on !== 'string' || !on)) return bad('on must be a name or null');
176
+ return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { waiting_on: on, waiting_since: on ? now.toISOString() : null, parked: false, list_override: null } }]);
177
+ }
178
+ // "Not yet": tomorrow and next Monday are dates (defer); later today only counts and nudges 3 hours on.
179
+ // Dates follow the server's UTC day, as the placement does.
180
+ function notYetItem(b, l, now) {
181
+ if (!NOT_YET.includes(b.when)) return bad('when must be one of ' + NOT_YET.join(', '));
182
+ return flagItem(b, l, (item) => {
183
+ const fields = { not_yet_count: (item.not_yet_count || 0) + 1 };
184
+ const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
185
+ const plus = (n) => new Date(day.getTime() + n * 86400000).toISOString().slice(0, 10);
186
+ if (b.when === 'later-today') fields.nudge_at = new Date(now.getTime() + 3 * 3600000).toISOString();
187
+ else {
188
+ fields.defer = b.when === 'tomorrow' ? plus(1) : plus(((1 - day.getUTCDay() + 7) % 7) || 7);
189
+ fields.nudge_at = null;
190
+ fields.list_override = null;
191
+ }
192
+ return [{ event: 'edited', id: b.id, fields }];
193
+ });
194
+ }
195
+ const keepItem = (b, l) => flagItem(b, l, [{ event: 'kept', id: b.id }]);
196
+
197
+ // Focus events go to focus.jsonl. The day comes from the server date, never from the page.
198
+ function focusEvent(kind, b, now) {
199
+ if (typeof b.key !== 'string' || !b.key) return bad('key is required');
200
+ const day = now.toISOString().slice(0, 10);
201
+ if (kind !== 'accepted') return { status: 200, body: { ok: true }, events: [{ event: kind, key: b.key, day }] };
202
+ if (typeof b.title !== 'string' || !b.title.trim()) return bad('title is required');
203
+ if (typeof b.why !== 'string') return bad('why must be a string');
204
+ if (!Array.isArray(b.item_ids) || b.item_ids.some((x) => typeof x !== 'string')) return bad('item_ids must be a list of item ids');
205
+ if (b.job !== undefined && b.job !== null && typeof b.job !== 'string') return bad('job must be a string or null');
206
+ return { status: 200, body: { ok: true }, events: [{ event: 'accepted', key: b.key, title: b.title.trim(), why: b.why, item_ids: b.item_ids, job: b.job || null, day }] };
207
+ }
208
+
110
209
  function addStepRoute(b, itemLines) {
111
210
  if (!findItem(itemLines, b.id)) return noItem;
112
211
  const id = newId('step');
@@ -172,6 +271,15 @@ const ROUTES = {
172
271
  'POST /api/pm-items/park': (b, ctx) => ({ ...parkItem(b, ctx.itemLines), file: 'items' }),
173
272
  'POST /api/pm-items/unpark': (b, ctx) => ({ ...unparkItem(b, ctx.itemLines), file: 'items' }),
174
273
  'POST /api/pm-items/move': (b, ctx) => ({ ...moveItem(b, ctx.itemLines), file: 'items' }),
274
+ 'POST /api/pm-items/flag': (b, ctx) => ({ ...flagRoute(b, ctx.itemLines), file: 'items' }),
275
+ 'POST /api/pm-items/defer': (b, ctx) => ({ ...deferItem(b, ctx.itemLines), file: 'items' }),
276
+ 'POST /api/pm-items/someday': (b, ctx) => ({ ...somedayItem(b, ctx.itemLines), file: 'items' }),
277
+ 'POST /api/pm-items/waiting': (b, ctx) => ({ ...waitingItem(b, ctx.itemLines, ctx.now || new Date()), file: 'items' }),
278
+ 'POST /api/pm-items/notyet': (b, ctx) => ({ ...notYetItem(b, ctx.itemLines, ctx.now || new Date()), file: 'items' }),
279
+ 'POST /api/pm-items/keep': (b, ctx) => ({ ...keepItem(b, ctx.itemLines), file: 'items' }),
280
+ 'POST /api/pm-items/focus/accept': (b, ctx) => ({ ...focusEvent('accepted', b, ctx.now || new Date()), file: 'focus' }),
281
+ 'POST /api/pm-items/focus/dismiss': (b, ctx) => ({ ...focusEvent('dismissed', b, ctx.now || new Date()), file: 'focus' }),
282
+ 'POST /api/pm-items/focus/clear': (b, ctx) => ({ ...focusEvent('cleared', b, ctx.now || new Date()), file: 'focus' }),
175
283
  'POST /api/pm-items/steps/add': (b, ctx) => ({ ...addStepRoute(b, ctx.itemLines), file: 'steps' }),
176
284
  'POST /api/pm-items/steps/who': (b, ctx) => ({ ...stepWho(b, ctx.stepLines), file: 'steps' }),
177
285
  'POST /api/pm-items/steps/plan': (b, ctx) => ({ ...planSteps(b, ctx.itemLines), file: 'steps' }),
@@ -181,17 +289,19 @@ const ROUTES = {
181
289
  };
182
290
 
183
291
  function prepare(ctx, now = new Date()) {
184
- return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), now };
292
+ return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), focusLines: pmItems.readLines(focusFile(ctx)), now };
185
293
  }
186
294
 
187
295
  module.exports = {
188
296
  prefixes: ['/api/pm-items', '/api/pm-jobs'],
189
- view, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
297
+ view, resolvesTitles, flagRoute, deferItem, somedayItem, waitingItem, notYetItem, keepItem, focusEvent, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
190
298
  handle: async (req, res, url, ctx) => {
191
299
  const p = url.pathname;
192
300
  if (p === '/api/pm-items' && req.method === 'GET') {
193
301
  const c = prepare(ctx);
194
- ctx.json(res, view(c.itemLines, c.jobLines, c.now, c.stepLines));
302
+ const v = view(c.itemLines, c.jobLines, c.now, c.stepLines, c.focusLines);
303
+ v.resolves_titles = resolvesTitles(v.items, ctx, url);
304
+ ctx.json(res, v);
195
305
  return true;
196
306
  }
197
307
  const key = `${req.method} ${p}`;
@@ -207,7 +317,7 @@ module.exports = {
207
317
  for (const ev of r.events) {
208
318
  const which = ev._file || r.file;
209
319
  const { _file, ...line } = ev;
210
- pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : itemsFile(ctx), line, c.now);
320
+ pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : which === 'focus' ? focusFile(ctx) : itemsFile(ctx), line, c.now);
211
321
  }
212
322
  }
213
323
  ctx.json(res, r.body, r.status);