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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "My work: every Jira ticket and WBS item assigned to you, ranked, briefed to an agent, approved in Agenda"
4
- timestamp: 2026-10-07
4
+ timestamp: 2026-10-11
5
5
  ---
6
6
 
7
7
  # My work
@@ -68,3 +68,78 @@ Agenda shows one card, **N items need your brief**, when items wait in To brief.
68
68
  | `pmos my-work submit --file <read.json>` | The my-work-read skill submits its read. |
69
69
 
70
70
  None of these briefs an agent or takes an item. Do that in My work.
71
+
72
+ ## The Work page: your own items in six views
73
+
74
+ The Work page holds the items you add for yourself. These are your own to-dos, such as "send Dana the new date". The server decides where each item shows, so one item is never in two views.
75
+
76
+ The six views follow the OmniFocus style. The **Work** tab badge counts Inbox plus Today.
77
+
78
+ | View | What it holds |
79
+ |---|---|
80
+ | Inbox | Items with no date, not yet decided. Give each one a date, a defer date or a job, mark it waiting, move it to Someday, or drop it. |
81
+ | Today | Items due today or overdue, and every flagged item. Overdue items come first, then flagged items, then the rest by due date. |
82
+ | Upcoming | Items that arrive in the future. A later due date or a defer date puts an item here. The next 7 days are grouped by day, then **Later**. |
83
+ | Waiting | Items where you wait for someone. The item that has waited longest comes first. |
84
+ | Someday | Items you parked. They do not appear anywhere else. |
85
+ | Review | Items in Inbox, Someday or Waiting that nobody touched for 7 days. The most stale item comes first. |
86
+
87
+ An item goes to the first view that fits these rules, in this order. Parked is Someday. Waiting on someone is Waiting. A defer date after today is Upcoming. Flagged is Today. Due today or earlier is Today. A later due date is Upcoming. An undated item whose defer date has arrived is Today. No date is Inbox.
88
+
89
+ ### Defer and flag
90
+
91
+ A defer date means "not before this day". The item stays in Upcoming and shows **Not before** with the date. On that day it moves to Today by itself. A due date is when the work must be done. A defer date is when you start.
92
+
93
+ Flag an item to put it in Today now, whatever its date. Remove the flag to send it back.
94
+
95
+ ### Focus
96
+
97
+ Focus sits at the top of Today. PMOS suggests up to three jobs for the day and gives the reason for each, for example "2 items overdue, 1 flagged".
98
+
99
+ PMOS groups the Today items by their job. An item with no job is its own group. It ranks a group by how late its items are, and by whether they are due today, flagged, P1 or work outside PMOS. It uses rules only and calls no model.
100
+
101
+ For each suggestion, you accept it, change it or reject it. An accepted job stays at the top of Today for the day. A rejected job does not come back that day. Focus starts empty each new day.
102
+
103
+ ### Work outside PMOS
104
+
105
+ Some items are not done in PMOS, such as "talk to Ana" or "set up a meeting with Raj". Mark these as offline work and name the person. An offline item offers three actions:
106
+
107
+ - **Done.** Add a one-line outcome if you want to keep it.
108
+ - **Not yet.** Choose later today, tomorrow or next week. Tomorrow and next week set a defer date, and the item returns to Today on that day. Next week means the next Monday. Later today reminds you in 3 hours.
109
+ - **Drop.**
110
+
111
+ An offline item can carry evidence that it is done, for example a meeting that took place. The row then says **Looks done?** and shows a **Confirm** button. PMOS never marks an item done by itself. You press Confirm.
112
+
113
+ ### Review
114
+
115
+ Review is a weekly pass. It shows one item at a time. Press **Keep** to leave the item as it is and clear it from the queue for 7 days. Press **Change** to edit it. Press **Drop** to remove it. Any change to an item also clears it from the queue.
116
+
117
+ ### Link to a team task
118
+
119
+ A Work item can link to the plan item it advances. The link shows on the item with the title of the plan item. Closing the plan item is a separate step, in the initiative.
120
+
121
+ ### Where decisions go
122
+
123
+ A decision or an approval only you can make is not a Work item. It shows in Briefing, under **Needs you**.
124
+
125
+ ## Using the Work page
126
+
127
+ The Work page in the console has six views: **Inbox**, **Today**, **Upcoming**, **Waiting**, **Someday** and **Review**. Each view button shows how many items it holds.
128
+
129
+ Each item is in one view only. The rule, in order: an item in Someday stays in Someday. An item that waits on a person is in Waiting. An item with a Not before date after today is in Upcoming. A flagged item is in Today. An item due today or earlier is in Today. An item due later is in Upcoming. An item with no date is in Inbox.
130
+
131
+ Decisions are not on the Work page. They have their own place in the console.
132
+
133
+ **Today** starts with a Focus band. It suggests up to 3 items. Select **Accept** to pin one, **Not today** to remove the suggestion, or **Unpin** to remove a pinned item. You can edit the title of a pinned item.
134
+
135
+ **Upcoming** shows one block for each of the next 7 days, then **Later**.
136
+
137
+ **Review** shows the items in Inbox, Waiting and Someday that nobody changed for 7 days or more, one at a time, the oldest first. For each item, select **Keep**, **Change** or **Drop**. When no items remain, the view shows **Review done**.
138
+
139
+ Open an item to use these actions: **Flag**, **Not before** (a date), **Someday** or **Bring back**, **Waiting** (a name) or **Stop waiting**, **Done** and **Drop**.
140
+
141
+ An item marked **outside PMOS** is work you do yourself, for example a talk with a person. It shows the person's name. Write the result in one line and select **Done**, or select **Not yet** and then **Later today**, **Tomorrow** or **Next week**. The item shows how many times you moved it. When PMOS finds evidence that the work is done, the item shows **Looks done?** and the evidence. Select **Confirm** to close it.
142
+
143
+ Each action shows **Undo** for a short time.
144
+
145
+ When you type a new line, the chip below the box tells you the view where the line will go.
@@ -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 };
@@ -6,6 +6,10 @@
6
6
  * and the fold of the whole file is the current list. Stage is never stored; a list is derived from the due
7
7
  * date, priority and waiting-on fields by placement() below.
8
8
  *
9
+ * The Work page (console3) reads a second placement, workPlacement(), into five views: inbox, today, upcoming,
10
+ * waiting, someday. placement() and its five lists stay for the old routes. A sixth view, Review, is a queue
11
+ * (reviewQueue) of items nobody has touched for a week. fold() stamps `touched_at` from the event's `at`.
12
+ *
9
13
  * SINGLE WRITER: appendEvent() is the only function that writes the file. Callers pass the event; they never
10
14
  * choose `at` or `by` themselves (attribution comes from the call path, as in plan-items.js).
11
15
  */
@@ -14,6 +18,11 @@ const path = require('path');
14
18
 
15
19
  const UNDO_MS = 8000;
16
20
  const LISTS = ['waiting', 'today', 'week', 'triage', 'later'];
21
+ const WORK_LISTS = ['inbox', 'today', 'upcoming', 'waiting', 'someday'];
22
+ const REVIEW_DAYS = 7;
23
+ const REVIEW_FROM = ['inbox', 'someday', 'waiting'];
24
+ // A legacy list_override (set by /move) maps onto the Work views.
25
+ const LEGACY_TO_WORK = { today: 'today', triage: 'inbox', waiting: 'waiting', week: 'upcoming', later: 'someday' };
17
26
  const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
18
27
 
19
28
  const DUE_WORD = /\b(?:due|by)\s+(today|tomorrow|sun(?:day)?|mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:r(?:s(?:day)?)?)?|fri(?:day)?|sat(?:urday)?)\b/i;
@@ -66,15 +75,71 @@ function placement(item, today) {
66
75
  return 'later';
67
76
  }
68
77
 
78
+ // Place an item in one of the five Work views (console3 PRD R2). The one place this is decided; the client never
79
+ // copies an item between views. `today` is a Date. Rules, in order: a legacy override, parked is Someday, waiting
80
+ // on someone is Waiting, a defer date after today is Upcoming, flagged is Today, due today or earlier is Today,
81
+ // a later due date is Upcoming, no date is Inbox. One addition to R2, for R6 ("the item returns to Today on that
82
+ // date"): an undated item whose defer date has arrived is Today, not Inbox.
83
+ function workPlacement(item, today) {
84
+ if (item.list_override && LEGACY_TO_WORK[item.list_override]) return LEGACY_TO_WORK[item.list_override];
85
+ if (item.parked) return 'someday';
86
+ if (item.waiting_on) return 'waiting';
87
+ const t = iso(today);
88
+ if (item.defer && item.defer > t) return 'upcoming';
89
+ if (item.flagged) return 'today';
90
+ if (item.due && item.due <= t) return 'today';
91
+ if (item.due) return 'upcoming';
92
+ if (item.defer) return 'today';
93
+ return 'inbox';
94
+ }
95
+
96
+ const PRIORITY_RANK = { P1: 1, P2: 2, P3: 3 };
97
+ const prio = (i) => PRIORITY_RANK[i.priority] || 9;
98
+ const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
99
+ // The day an Upcoming item arrives: its defer date while that is still ahead, else its due date.
100
+ function arrivesOn(item, todayIso) { return item.defer && item.defer > todayIso ? item.defer : (item.due || ''); }
101
+
102
+ // Sort one Work view. today: overdue first (oldest first), then flagged, then by due date. upcoming: by arrival
103
+ // day. inbox and someday: newest first. waiting: the one waited on longest first.
104
+ function sortWorkList(name, items, today) {
105
+ const t = iso(today);
106
+ const byNew = (a, b) => cmpStr(b.created_at || '', a.created_at || '');
107
+ const out = [...items];
108
+ if (name === 'today') {
109
+ const group = (i) => (i.due && i.due < t ? 0 : i.flagged ? 1 : 2);
110
+ out.sort((a, b) => group(a) - group(b) || cmpStr(a.due || '9999', b.due || '9999') || prio(a) - prio(b) || byNew(a, b));
111
+ } else if (name === 'upcoming') {
112
+ out.sort((a, b) => cmpStr(arrivesOn(a, t) || '9999', arrivesOn(b, t) || '9999') || prio(a) - prio(b) || byNew(a, b));
113
+ } else if (name === 'waiting') {
114
+ out.sort((a, b) => cmpStr(a.waiting_since || a.created_at || '', b.waiting_since || b.created_at || ''));
115
+ } else out.sort(byNew);
116
+ return out;
117
+ }
118
+
119
+ // The review queue: open items in Inbox, Someday or Waiting that nobody touched for `days` days, most stale first.
120
+ // `now` is a Date. An item with no readable touched_at is left out, never guessed.
121
+ function reviewQueue(items, now, days = REVIEW_DAYS) {
122
+ const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
123
+ const cutoff = now.getTime() - days * 86400000;
124
+ const stamp = (i) => Date.parse(i.touched_at || i.created_at || '');
125
+ return items
126
+ .filter((i) => !i.done && REVIEW_FROM.includes(workPlacement(i, today)) && Number.isFinite(stamp(i)) && stamp(i) <= cutoff)
127
+ .sort((a, b) => stamp(a) - stamp(b));
128
+ }
129
+
69
130
  // Fold the event lines into the current items, keyed by id. A dropped or deleted item is removed.
70
131
  function fold(lines) {
71
132
  const items = new Map();
72
133
  for (const ev of lines) {
73
134
  if (!ev || !ev.id) continue;
74
- if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at });
75
- else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields });
76
- else if (ev.event === 'done' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at });
77
- else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null });
135
+ if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at, touched_at: ev.at });
136
+ else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields, touched_at: ev.at });
137
+ else if (ev.event === 'done' && items.has(ev.id)) {
138
+ const outcome = typeof ev.outcome === 'string' && ev.outcome.trim() ? { outcome: ev.outcome.trim() } : {};
139
+ items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at, touched_at: ev.at, ...outcome });
140
+ }
141
+ else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null, touched_at: ev.at });
142
+ else if (ev.event === 'kept' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), touched_at: ev.at });
78
143
  else if (ev.event === 'dropped' || ev.event === 'deleted') items.delete(ev.id);
79
144
  }
80
145
  return [...items.values()];
@@ -92,7 +157,7 @@ function removalEvent(item, now) {
92
157
  function undoEvent(lines, id, now = new Date()) {
93
158
  const removal = [...lines].reverse().find((ev) => ev && ev.id === id && (ev.event === 'deleted' || ev.event === 'dropped'));
94
159
  if (!removal || !removal.snapshot || !canUndo(removal.at, now)) return null;
95
- const { id: _id, source, created_at, ...fields } = removal.snapshot;
160
+ const { id: _id, source, created_at, touched_at, ...fields } = removal.snapshot;
96
161
  return { event: 'created', id, fields, source: source || 'local', restored: true };
97
162
  }
98
163
 
@@ -112,4 +177,4 @@ function readLines(file) {
112
177
  return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } });
113
178
  }
114
179
 
115
- module.exports = { UNDO_MS, LISTS, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
180
+ module.exports = { UNDO_MS, LISTS, WORK_LISTS, REVIEW_DAYS, workPlacement, sortWorkList, reviewQueue, arrivesOn, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
@@ -216,4 +216,4 @@ function sig(w) {
216
216
  return [reportsOf(w.root).sig, schedulesStamp(), kill, hour].join(':');
217
217
  }
218
218
 
219
- module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
219
+ module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, readSchedules, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
@@ -48,6 +48,7 @@ const MODULES = Object.freeze([
48
48
  'routes-forecast', // console3 B5: forecast, critical path, time machine and capacity (read-only)
49
49
  'routes-calendar-views', // console3 C2: Day/Week/Month/Quarter/Year cells, Gantt and the strategy lens (read-only)
50
50
  'routes-briefing', // console3 C1: the day-part Briefing cards from the signal engine (read-only)
51
+ 'routes-brief-page', // console3 Briefing as the brief: Today agenda and Needs you (read-only; writes go through routes-pm-items)
51
52
  'routes-report-facts', // console3 D: GET /api/report-families/facts (read-only, real data only)
52
53
  'routes-records', // console3 E1a: ideas, hypotheses, decisions, KR metrics, bets (GET list; page-press add/edit/reading)
53
54
  'routes-doc-sheet', // console3 E1c: document sheet: revisions, edit with why, propose and accept by hash