create-pmos 0.65.1 → 0.65.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.65.1",
3
+ "version": "0.65.2",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@5f54d4e
1
+ kernel-source: pmos@cc06cf0
2
2
  assembled: 2026-10-07
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-07T00:49:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T01:20:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-07T00:49:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T01:20:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-07T00:49:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T01:20:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -30,7 +30,7 @@ An agent job can stop with its session still open, for example when the agent as
30
30
 
31
31
  Each job session is named "Job ·" and the first words of the job, so the session list stays short.
32
32
 
33
- The Agenda month is at the top of the left column. **Agent results** comes under it, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under Agent results. The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
33
+ The Agenda month is at the top of the left column. **Agent results** comes under it, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under Agent results. The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are jobs too. Each one is a row of **Needs you** in the jobs. Press it to see its card, with its own buttons (for example **Draft now**), in the detail column. When the day is folded, they show as cards under it.
34
34
 
35
35
  ### The Agenda month
36
36
 
@@ -50,7 +50,9 @@ Each day of the month answers three questions:
50
50
 
51
51
  The switch above the month shows one answer at a time: **Overview** (all three), **Full** (the time in meetings), **Key** (the meeting that matters), **Prep** (how ready you are) or **Free** (the free blocks of 90 minutes or more).
52
52
 
53
- The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
53
+ The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters.
54
+
55
+ To mark a meeting yourself, open it. Press **Mark as key** to make this time a key meeting, or **Key every time** for every time it happens. On a meeting that is key already, press **Not key** or **Never key**. Your mark is used before what the calendar says. Press **Remove my mark** to go back to what the calendar says. The marks are kept in `calendar-marks.json` in the state folder. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
54
56
 
55
57
  The month comes from the calendar memory. The morning-brief session reads your calendar 90 days back and 31 days ahead the first time, and from today to 31 days ahead after that, and sends it with the brief. The workspace keeps 100 days back and 45 days ahead, in `calendar-memory.json` in the state folder. Nothing on the Agenda writes to your calendar. To see what the memory holds, run `pmos proactive calendar`.
56
58
 
@@ -66,7 +68,13 @@ To tie an item to a repository:
66
68
  1. For a goal, open it and pick a **Repository**. A goal you add while Today shows one repository belongs to that repository.
67
69
  2. For a Slack channel, Jira project or Confluence space, open **Sources**, open the service, and pick a repository next to the place.
68
70
 
69
- A job takes the repository of its goal. A job with no goal takes the repository of the place it came from. A job with neither belongs to no repository.
71
+ A job takes the repository of its goal. A job with no goal takes the repository of the initiative it names, then of the place it came from. A goal tied to an initiative takes that initiative's repository when you pick none. A job with none of these belongs to no repository.
72
+
73
+ When **Repository** hides a goal of another repository, the goals say how many it hides. Press **Show all repositories** to see them.
74
+
75
+ A job's agent session runs in the folder of the job's repository, so it can read that repository's files. A job with no repository runs in the repository where you pressed **Prepare my day**.
76
+
77
+ Your goals stay until you mark them done or drop them. A new day starts with the goals that were still open on the last day you had goals.
70
78
 
71
79
  Each job has one of three kinds.
72
80
 
@@ -5,7 +5,8 @@
5
5
  * mergeMemory(stored, memory, { now }) → { ok, memory } the stored file with the submitted window replaced
6
6
  * seriesKey(e) · normTitle(title) → the key that joins the occurrences of one meeting
7
7
  * kindOf(e) · isImportant(e) → 'one' | 'onboard' | 'group' · true | false
8
- * agendaMonth({ memory, brief, month, now }) → the month the Agenda draws: weeks, days with their three answers, series
8
+ * agendaMonth({ memory, brief, month, marks, now }) → the month the Agenda draws: weeks, days with their three answers, series
9
+ * readMarks(raw) · setMark(marks, { title, start, scope, on }) · markOf(marks, e) → the PM's key-meeting marks
9
10
  *
10
11
  * The morning-brief session reads the calendar wide (90 days back, 31 ahead) and submits it with the brief. The workspace
11
12
  * checks it here and keeps it in one file. Every answer the month gives is derived here, with no model and no I/O:
@@ -62,6 +63,51 @@ function isImportant(e) {
62
63
  return !!e && (e.role === 'present' || e.role === 'decide' || e.external === true || e.exec === true || (typeof e.attendees === 'number' && e.attendees >= 8));
63
64
  }
64
65
 
66
+ /* ── the PM's own marks ───────────────────────────────────────────────────────────────────────────────────────────
67
+ * The PM can mark a meeting as key, or as not key, over what isImportant derives: this one time, or every time it
68
+ * happens. A mark is keyed by the title (normTitle), and a time mark also by the start minute, so it holds whether the
69
+ * meeting comes from the calendar memory or from today's brief. */
70
+ const MAX_MARKS = 500;
71
+ const seriesMark = (title) => 't:' + normTitle(title);
72
+ const timeMark = (title, start) => { const t = Date.parse(start); return Number.isFinite(t) ? seriesMark(title) + '@' + new Date(t).toISOString().slice(0, 16) : null; };
73
+ const emptyMarks = () => ({ one: {}, series: {} });
74
+
75
+ /** The marks file, checked: anything else reads as no marks. */
76
+ function readMarks(raw) {
77
+ const out = emptyMarks();
78
+ if (!raw || typeof raw !== 'object') return out;
79
+ for (const k of ['one', 'series']) {
80
+ const m = raw[k] && typeof raw[k] === 'object' && !Array.isArray(raw[k]) ? raw[k] : {};
81
+ for (const [key, v] of Object.entries(m).slice(0, MAX_MARKS)) if (typeof v === 'boolean' && key.length <= 300 && key.startsWith('t:')) out[k][key] = v;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** One mark set or cleared. `scope` 'one' (this time) or 'series' (every time); `on` true (key), false (not key) or null
87
+ * (clear, back to what the calendar says). A mark for every time also clears this time's mark. */
88
+ function setMark(marks, { title, start, scope, on } = {}) {
89
+ if (!str(title, 300)) return { ok: false, error: 'a mark needs the meeting title' };
90
+ if (!['one', 'series'].includes(scope)) return { ok: false, error: "scope must be 'one' or 'series'" };
91
+ if (!(on === true || on === false || on === null)) return { ok: false, error: 'on must be true, false or null' };
92
+ const key = scope === 'one' ? timeMark(title, start) : seriesMark(title);
93
+ if (!key || key === 't:') return { ok: false, error: 'a mark needs a title with letters and, for one time, its start' };
94
+ const next = readMarks(marks);
95
+ if (on === null) delete next[scope][key]; else next[scope][key] = on;
96
+ if (scope === 'series') { const t = timeMark(title, start); if (t) delete next.one[t]; }
97
+ if (Object.keys(next[scope]).length > MAX_MARKS) return { ok: false, error: 'too many marks — clear some first' };
98
+ return { ok: true, marks: next };
99
+ }
100
+
101
+ /** The PM's mark of a meeting: { on, scope } or null when there is none. A time mark wins over an every-time mark. */
102
+ function markOf(marks, e) {
103
+ if (!marks || !e) return null;
104
+ const t = timeMark(e.title, e.start);
105
+ if (t && marks.one && typeof marks.one[t] === 'boolean') return { on: marks.one[t], scope: 'one' };
106
+ const s = seriesMark(e.title);
107
+ if (marks.series && typeof marks.series[s] === 'boolean') return { on: marks.series[s], scope: 'series' };
108
+ return null;
109
+ }
110
+
65
111
  /* ── the check ─────────────────────────────────────────────────────────────────────────────────────────────────── */
66
112
 
67
113
  function validateMemory(raw, { now = Date.now() } = {}) {
@@ -166,14 +212,16 @@ function fromBrief(e) {
166
212
  * The month the Agenda draws. `month` is YYYY-MM (default: this month). Weeks run Monday to Sunday. Each day of the month
167
213
  * carries its events and the three answers; `series` describes every series that has an event in the month.
168
214
  */
169
- function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
215
+ function agendaMonth({ memory, brief, month, marks = null, now = Date.now() } = {}) {
170
216
  const today = localDate(now);
171
217
  const ym = typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : today.slice(0, 7);
172
218
  const all = ((memory && memory.events) || []).map((e) => ({ ...e, src: 'memory' }));
173
219
  // today's events come from the brief of today when there is one; memory events of today are then left out
174
220
  const b = brief && brief.date === today && Array.isArray(brief.calendar) ? brief.calendar.map(fromBrief) : null;
221
+ // key: the PM's mark when there is one, else what the calendar says (isImportant)
222
+ const impOf = (e) => { const auto = isImportant(e); const mk = markOf(marks, e); return { imp: mk ? mk.on : auto, auto, mark: mk ? mk.scope : null }; };
175
223
  const events = (b ? [...all.filter((e) => localDate(Date.parse(e.start)) !== today), ...b] : all)
176
- .map((e) => { const s = Date.parse(e.start); const f = Date.parse(e.end); return { ...e, s, f, date: localDate(s), from: minuteOfDay(s), to: f - s >= DAY_MS ? DAY_END : (localDate(f) === localDate(s) ? minuteOfDay(f) : DAY_END), key: seriesKey(e), kind: kindOf(e), imp: isImportant(e) }; })
224
+ .map((e) => { const s = Date.parse(e.start); const f = Date.parse(e.end); return { ...e, s, f, date: localDate(s), from: minuteOfDay(s), to: f - s >= DAY_MS ? DAY_END : (localDate(f) === localDate(s) ? minuteOfDay(f) : DAY_END), key: seriesKey(e), kind: kindOf(e), ...impOf(e) }; })
177
225
  .sort((x, y) => x.s - y.s);
178
226
 
179
227
  // series over everything remembered, so "held 12 of 13" and "the last time" see beyond the month
@@ -227,7 +275,7 @@ function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
227
275
  if (lv === 'risk' || (lv === 'warn' && level !== 'risk') || (lv === 'ok' && !level)) level = lv;
228
276
  const x = series.get(e.key);
229
277
  return { id: e.id, src: e.src, title: e.title, start: e.start, end: e.end, time: `${pad(Math.floor(e.from / 60))}:${pad(e.from % 60)}`, minutes: Math.round((e.f - e.s) / 60000),
230
- kind: e.kind, imp: e.imp, key: e.key, repeats: x.count > 1, routine: routine(e), ended, prep: p ? e.prep : null, prep_done: p ? pd : null, ready: lv,
278
+ kind: e.kind, imp: e.imp, auto: e.auto, mark: e.mark, key: e.key, repeats: x.count > 1, routine: routine(e), ended, prep: p ? e.prep : null, prep_done: p ? pd : null, ready: lv,
231
279
  notes: e.notes || null, open: e.open || null, role: e.role || null, external: e.external === true, attendees: typeof e.attendees === 'number' ? e.attendees : null, with: e.with || null };
232
280
  });
233
281
  if (need && done >= need) level = 'ok';
@@ -253,6 +301,6 @@ function agendaMonth({ memory, brief, month, now = Date.now() } = {}) {
253
301
  }
254
302
 
255
303
  module.exports = {
256
- validateMemory, mergeMemory, agendaMonth, seriesKey, normTitle, kindOf, isImportant, dayLoad,
304
+ validateMemory, mergeMemory, agendaMonth, seriesKey, normTitle, kindOf, isImportant, dayLoad, readMarks, setMark, markOf,
257
305
  MAX_EVENTS, MAX_SPAN_DAYS, KEEP_BACK_DAYS, KEEP_AHEAD_DAYS, FREE_BLOCK, PER_STEP,
258
306
  };
@@ -11,12 +11,15 @@
11
11
  * | { action: 'tag', id, repo } (repo: a workspace id, or null for none)
12
12
  * readDay(raw, date) → a checked day record, or an empty day when `raw` is not one
13
13
  * planDay({ day, brief, cards, now, endOfDay }) → the plan Today and `pmos proactive goals` show
14
- * itemRepos({ day, brief, placeRepos }) → { itemId: repo } for the brief items that have a repository
14
+ * itemRepos({ day, brief, placeRepos, iniRepos }) → { itemId: repo } for the brief items that have a repository
15
+ * goalRepos({ day, iniRepos }) → { goalId: repo } for the untagged goals whose initiative names one
16
+ * carryOver(prev, date) → a new day holding the goals still open on `prev`
15
17
  *
16
18
  * ONE DAY, REPOSITORY TAGS. The day is the PM's, not a repository's: every repository shows the same goals, brief and
17
19
  * jobs. A goal can carry a repository (`repo`, a workspace id), and so can a channel, project or space (the settings'
18
20
  * `placeRepos`). An item takes the repository of the open goal it is under; failing that, of the place it came from
19
- * (its `where`). An item with neither is personal and shows under every repository.
21
+ * (its `where`). An initiative held by one repository tags the items that name it, and the goals tied to it. An item
22
+ * with none of these is personal and shows under every repository.
20
23
  *
21
24
  * THE JOIN IS CHECKABLE. A brief item or calendar event belongs to a goal when it names the goal's id (the morning-brief
22
25
  * session sets it). Failing that, an item or Today card belongs to the first open goal tied to the same initiative. There
@@ -56,6 +59,13 @@ function readDay(raw, date) {
56
59
  return { date, goals };
57
60
  }
58
61
 
62
+ /** A new day starts with the goals still open on the last day that had any: a goal is the PM's until it is done or
63
+ * dropped, not until midnight. `prev` is that day's record (readDay); a day with its own file never takes these. */
64
+ function carryOver(prev, date) {
65
+ if (!prev || !Array.isArray(prev.goals) || !prev.date || prev.date >= date) return emptyDay(date);
66
+ return { date, goals: prev.goals.filter((g) => g.status === 'open').slice(0, MAX_GOALS).map((g) => ({ ...g, closed_at: null })) };
67
+ }
68
+
59
69
  /** One edit of the day's goals. A refused edit returns the reason and the caller writes nothing. */
60
70
  function edit(day, op, { now = Date.now() } = {}) {
61
71
  if (!day || !DATE_RE.test(String(day.date)) || !Array.isArray(day.goals)) return { ok: false, error: 'no day record to edit' };
@@ -205,8 +215,9 @@ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = E
205
215
  const placeKey = (s) => { const t = String(s || '').trim().toLowerCase().replace(/^#/, ''); const k = /^([a-z][a-z0-9]*)-\d+$/.exec(t); return k ? k[1] : t; };
206
216
  const placeNameKeys = (n) => { const t = String(n || '').trim().toLowerCase().replace(/^#/, ''); return [t, t.split(/[\s·:|]+/)[0]].filter(Boolean); };
207
217
 
208
- /** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of its place. */
209
- function itemRepos({ day, brief, placeRepos = {} } = {}) {
218
+ /** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of the initiative
219
+ * it names (`iniRepos`: { initiativeId: repo }, only the ids one repository holds), else of its place. */
220
+ function itemRepos({ day, brief, placeRepos = {}, iniRepos = {} } = {}) {
210
221
  const out = {};
211
222
  if (!brief || !day || brief.date !== day.date) return out;
212
223
  const open = (day.goals || []).filter((g) => g.status === 'open');
@@ -214,7 +225,8 @@ function itemRepos({ day, brief, placeRepos = {} } = {}) {
214
225
  const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
215
226
  for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) {
216
227
  const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
217
- let r = g ? repoOrNull(g.repo) : null;
228
+ let r = g ? repoOrNull(g.repo) || (g.initiative_id ? repoOrNull(iniRepos[g.initiative_id]) : null) : null;
229
+ if (!r && x.initiative_id) r = repoOrNull(iniRepos[x.initiative_id]);
218
230
  if (!r && x.where) {
219
231
  const want = placeKey(x.where);
220
232
  for (const [name, repo] of Object.entries(placeRepos[x.source] || {})) if (placeNameKeys(name).includes(want) && repoOrNull(repo)) { r = repo; break; }
@@ -224,4 +236,11 @@ function itemRepos({ day, brief, placeRepos = {} } = {}) {
224
236
  return out;
225
237
  }
226
238
 
227
- module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, edit, freeTime, planDay, itemRepos };
239
+ /** { goalId: repo } for the goals with no repository of their own whose initiative one repository holds. */
240
+ function goalRepos({ day, iniRepos = {} } = {}) {
241
+ const out = {};
242
+ for (const g of (day && day.goals) || []) if (!g.repo && g.initiative_id && repoOrNull(iniRepos[g.initiative_id])) out[g.id] = iniRepos[g.initiative_id];
243
+ return out;
244
+ }
245
+
246
+ module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, carryOver, edit, freeTime, planDay, itemRepos, goalRepos };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.65.1",
3
+ "version": "0.65.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.65.1",
3
+ "version": "0.65.2",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -143,14 +143,14 @@ function build(ctx0) {
143
143
  const dayWs = dayRepo ? wsOf(dayRepo) : null;
144
144
  const prep = onPhone && !(window.pmDeck && window.pmDeck.phoneView('today') === 'day') ? prepCard(dayWs) : null;
145
145
  if (prep) { prep.setAttribute('role', 'listitem'); const none = list.querySelector('[data-today="empty"]'); if (none) none.setAttribute('data-today-id', 'empty'); list.prepend(prep); } // the empty note stays, as the deck's last card
146
- // the open day takes the list into its left column ("Also waiting for you"), so Today shows one layout; a folded day
147
- // leaves it below, as before
148
- const owed = { list, more, count: n };
146
+ // the open day takes the cards into its jobs (rows of Needs you; a pressed row shows its card), so Today shows one layout; a folded day
147
+ // leaves it below, as before. A phone does not draw the day, so its deck keeps every card
148
+ const owed = onPhone ? null : { list, more, count: n };
149
149
  // one day for every repository: the day is the same everywhere; the Show switch narrows it to the repository Today
150
150
  // was opened from (`here`) or shows all of it
151
151
  const here = sc.repo ? wsOf(sc.repo) : null;
152
152
  const day = daySection({ ws: dayWs, setState: ctx0.setState || setState, openIni: dayRepo ? (id) => openInitiative(dayRepo, id) : null, owed, here, all: sc.all });
153
- const inDay = !!(day && day.contains(list));
153
+ const inDay = !!(day && owed && owed.taken);
154
154
  return { children: inDay ? [head, day, health] : [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner, onPhone, prep ? prep.outerHTML : null]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
155
155
  }
156
156
 
@@ -10,13 +10,14 @@
10
10
  * One read feeds it: GET /api/proactive/agenda?month=YYYY-MM. The server derives every answer (how full, the key
11
11
  * meeting, readiness, no notes); this file only draws them. A day cell is a button: it picks that day. A week number
12
12
  * picks the week. The lens switch says which answer the cells show; the agenda under the month always shows them all.
13
- * Nothing here writes: the calendar memory changes only through the morning-brief session's brief.
13
+ * The calendar memory changes only through the morning-brief session's brief. The one write here is the PM's key-meeting
14
+ * mark (POST /api/proactive/agenda/mark): this time or every time, key or not key, over what the calendar says.
14
15
  */
15
16
 
16
17
  import { peopleLinks } from './people-link.js';
17
18
  import { el } from './dom.js';
18
- import { button, iconButton, badge, tabs } from './primitives.js';
19
- import { pmGet } from './http.js';
19
+ import { button, iconButton, badge, tabs, toast } from './primitives.js';
20
+ import { pmGet, pmPost } from './http.js';
20
21
  import { COPY } from './today-copy.js';
21
22
 
22
23
  const A = COPY.day.ag;
@@ -74,10 +75,12 @@ function current() {
74
75
  return c;
75
76
  }
76
77
 
78
+ // a short hash of the days, so a change that keeps the length (a key mark set or cleared) still redraws
79
+ const hash = (t) => { let h = 0; for (let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) | 0; return h; };
77
80
  export function agendaSig() {
78
81
  const c = ag.month ? cache.get(ag.month) : cache.get('now');
79
82
  return JSON.stringify([ag, c ? [c.error, c.data && c.data.now ? String(c.data.now).slice(0, 16) : null, c.data && Object.keys(c.data.days || {}).length, c.data && c.data.memory] : null])
80
- + (c && c.data ? JSON.stringify(c.data.days).length : 0);
83
+ + (c && c.data ? hash(JSON.stringify(c.data.days)) : 0);
81
84
  }
82
85
 
83
86
  /* ── finding a meeting across the months read ─────────────────────────────────────────────────────────────────── */
@@ -270,6 +273,26 @@ function whyLines(e) {
270
273
  return out;
271
274
  }
272
275
 
276
+ async function setMark(e, scope, on) {
277
+ const r = await pmPost('/api/proactive/agenda/mark', { title: e.title, start: e.start, scope, on });
278
+ if (!r || r.ok === false) { toast({ text: A.key.failed((r && r.error) || 'the workspace did not answer'), tone: 'destructive' }); return; }
279
+ for (const k of [...cache.keys()]) loadMonth(k === 'now' ? undefined : k);
280
+ }
281
+
282
+ /** The key-meeting line of a meeting: what the PM marked, and the presses that change it. */
283
+ function keyBlock(e) {
284
+ const K = A.key;
285
+ const b = (label, scope, on, id) => button({ label, variant: 'outline', size: 'sm', attrs: { 'data-ag-mark': id }, onClick: () => setMark(e, scope, on) });
286
+ const presses = e.mark ? [b(K.clear, e.mark, null, 'clear')]
287
+ : e.imp ? [b(K.unmark, 'one', false, 'not-one'), e.repeats ? b(K.unmarkAll, 'series', false, 'not-series') : null]
288
+ : [b(K.mark, 'one', true, 'one'), e.repeats ? b(K.markAll, 'series', true, 'series') : null];
289
+ const said = e.mark ? K[(e.imp ? 'marked' : 'not') + e.mark] : null;
290
+ return el('div', { class: 'td-day__phase', 'data-ag': 'key', 'data-ag-key': e.imp ? 'true' : 'false' },
291
+ e.imp ? el('h4', null, A.star + ' ' + K.title) : null,
292
+ said ? el('p', { class: 'td-day__note' }, said) : null,
293
+ el('div', { class: 'td-day__acts' }, ...presses));
294
+ }
295
+
273
296
  /** The series of a meeting: how often it met, what is to come, and the last time, folded. Null when unknown. */
274
297
  export function memoryBlock(id) {
275
298
  const f = findEvent(id);
@@ -280,7 +303,8 @@ export function memoryBlock(id) {
280
303
  return el('div', { class: 'td-day__notes', 'data-ag': 'memory' },
281
304
  el('h4', null, A.means),
282
305
  el('p', null, A.kindWord[f.e.kind] + '. ' + (s.count > 1 ? A.series(s.past, s.coming) : A.once)),
283
- ...whyLines(f.e).map((t) => el('p', null, t)),
306
+ ...(f.e.mark ? [] : whyLines(f.e)).map((t) => el('p', null, t)),
307
+ keyBlock(f.e),
284
308
  last ? el('details', { class: 'td-ag__last', 'data-ag': 'last' }, el('summary', null, A.lastTime(dayLabel(last.date))),
285
309
  last.notes ? el('p', null, last.notes) : el('p', { class: 'td-day__note' }, A.noLast),
286
310
  last.open && last.open.length ? el('ul', { class: 'td-day__chk' }, ...last.open.map((x) => el('li', null, x))) : null) : null);
@@ -136,16 +136,17 @@ export const COPY = {
136
136
  unreached: 'The workspace did not answer.',
137
137
  refused: (why) => `Nothing changed. ${why}`,
138
138
  oldServer: 'This workspace server has no day run yet. Only your goals show.',
139
- panels: { summary: 'Agent results', calendar: 'Calendar', agenda: 'Agenda', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', waiting: 'Also waiting for you', detail: 'Detail' },
139
+ panels: { summary: 'Agent results', calendar: 'Calendar', agenda: 'Agenda', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', detail: 'Detail' },
140
140
  summary: (c) => [c.review ? `${c.review} ${c.review === 1 ? 'draft waits' : 'drafts wait'} for you` : null, c.done ? `${c.done} done` : null,
141
141
  c.working ? `${c.working} working` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' · ') || 'No agent job yet.',
142
142
  summaryAll: (n) => `All ${n} agent ${n === 1 ? 'job has' : 'jobs have'} a result.`,
143
- waitingHint: 'Outcome checks, reports and sessions from your repositories, outside today\'s jobs.',
144
143
  // one day for every repository: a goal or a place can carry a repository; the Show switch narrows the day to it
145
144
  repo: { label: 'Repository', none: 'No repository',
146
145
  goalHint: 'Its jobs show under this repository. With no repository, the goal is yours and shows under every repository.',
147
146
  placeHint: 'Repository: its mentions and jobs show under that repository. With none, they show under every repository.',
148
- only: (name) => `Showing ${name} and your items with no repository. The calendar always shows.` },
147
+ only: (name) => `Showing ${name} and your items with no repository. The calendar always shows.`,
148
+ hiddenGoals: (n) => `${n} ${n === 1 ? 'goal belongs' : 'goals belong'} to another repository.`,
149
+ showAll: 'Show all repositories' },
149
150
  now: (at) => `now ${at}`,
150
151
  noCalendar: 'No events read for today.',
151
152
  noJobs: 'No jobs yet. Press Prepare my day. Each job shows here; select one to see what it needs.',
@@ -329,6 +330,13 @@ export const COPY = {
329
330
  kindWord: { one: 'One-to-one', onboard: 'Onboarding', group: 'Group meeting' },
330
331
  why: { present: 'You present.', decide: 'You decide.', external: 'People from outside the company join.', exec: 'Leadership joins.', many: (n) => `${n} people join.`, routine: 'It repeats, and nothing in it is for you.' },
331
332
  means: 'What it means',
333
+ key: {
334
+ title: 'Key meeting',
335
+ markedone: 'You marked this time as key.', markedseries: 'You marked it as key every time.',
336
+ notone: 'You marked this time as not key.', notseries: 'You marked it as not key every time.',
337
+ mark: 'Mark as key', markAll: 'Key every time', unmark: 'Not key', unmarkAll: 'Never key', clear: 'Remove my mark',
338
+ failed: (why) => `The mark was not saved: ${why}`,
339
+ },
332
340
  before: 'Before this meeting',
333
341
  prepLine: (done, need) => `${done} of ${need} prep steps done.`,
334
342
  readyNote: { ok: 'Your prep is done.', warn: 'Some prep is left, and there is time for it.', risk: 'The free time before it is short for the prep left.' },
@@ -142,7 +142,9 @@ function model(d, view) {
142
142
  const today = d && d.today;
143
143
  const brief = d && d.brief && (!today || d.brief.date === today) ? d.brief : null;
144
144
  const plan = (d && d.plan) || null;
145
- const goals = plan ? plan.goals || [] : [];
145
+ // a goal with no repository of its own takes the one its initiative is in (goal_repos, from the server)
146
+ const goalRepos = (d && d.goal_repos) || {};
147
+ const goals = (plan ? plan.goals || [] : []).map((g) => (g.repo || !goalRepos[g.id] ? g : { ...g, repo: goalRepos[g.id] }));
146
148
  const goalNo = new Map(goals.map((g, i) => [g.id, i + 1]));
147
149
  const run = (d && d.run) || null;
148
150
  const tasks = run && Array.isArray(run.tasks) ? run.tasks : [];
@@ -178,7 +180,7 @@ function model(d, view) {
178
180
  const shownIds = new Set(shownJobs.map((j) => j.task && j.task.id).filter(Boolean));
179
181
  const shownTasks = narrow ? tasks.filter((t) => shownIds.has(t.id)) : tasks;
180
182
  return {
181
- d, brief, plan, goals: goals.filter((g) => inView(g.repo)), allGoals: goals, goalNo, run, tasks: shownTasks, jobs: shownJobs, events, now, settings, scopes, level,
183
+ d, brief, plan, goals: goals.filter((g) => inView(g.repo)), allGoals: goals, hiddenGoals: goals.filter((g) => !inView(g.repo)).length, goalNo, run, tasks: shownTasks, jobs: shownJobs, events, now, settings, scopes, level,
182
184
  digests: digests.filter((g) => inView(g.repo)), places, placeRepo, repos, repoName, here: v.here || null, narrow,
183
185
  stage: run ? (d.stage || deriveStage(run, brief)) : null,
184
186
  mentions: shownJobs.filter((j) => j.item && j.item.mention),
@@ -419,13 +421,31 @@ function jobRow(ctx, j) {
419
421
  dot: j.isNew && !seen.has(j.id) ? el('span', { class: 'td-day__newdot', 'data-day': 'new', role: 'img', 'aria-label': D.newItem }) : null });
420
422
  }
421
423
 
424
+ /* What the repositories owe the PM outside today's jobs (outcome checks, report drafts, sessions) are jobs too: each one is a
425
+ * row of Needs you, and pressing it shows its card, with the card's own buttons, in the detail column. The Agenda has no
426
+ * separate section for them. */
427
+ function owedItems(owed) {
428
+ if (!owed || !owed.list) return [];
429
+ return [...owed.list.children].filter((n) => n.getAttribute && n.getAttribute('data-today-id')).map((n) => {
430
+ const cause = n.querySelector('[data-today-cause]');
431
+ const ev = n.querySelector('.td-card__evidence');
432
+ const text = ((cause || n).textContent || '').trim().replace(/\s+/g, ' ');
433
+ return { id: n.getAttribute('data-today-id'), node: n, title: text, meta: ev ? ev.textContent.trim().replace(/\s+/g, ' ') : '' };
434
+ });
435
+ }
436
+
437
+ function owedRow(ctx, o) {
438
+ return rowBtn(ctx, 'owed', o.id, { lead: '·', title: o.title, meta: o.meta, trail: pill(D.status2.you, 'warn') });
439
+ }
440
+
422
441
  function jobsPanel(ctx) {
423
442
  const { m } = ctx;
424
- if (!m.jobs.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
425
- const groups = ['needs', 'working', 'done', 'worth'].map((g) => [g, m.jobs.filter((j) => j.group === g)]).filter(([, l]) => l.length);
426
- return panel('jobs', D.jobsCount(m.needs, m.done), ...groups.map(([g, l]) => el('div', { class: 'td-day__group', 'data-day-group': g },
427
- el('div', { class: 'td-day__gh' }, D.groups[g], pill(String(l.length), '')),
428
- el('ul', { class: 'td-day__rows' }, ...l.map((j) => jobRow(ctx, j))))));
443
+ const owed = ctx.owed;
444
+ if (!m.jobs.length && !owed.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
445
+ const groups = ['needs', 'working', 'done', 'worth'].map((g) => [g, m.jobs.filter((j) => j.group === g)]).filter(([g, l]) => l.length || (g === 'needs' && owed.length));
446
+ return panel('jobs', D.jobsCount(m.needs + owed.length, m.done), ...groups.map(([g, l]) => el('div', { class: 'td-day__group', 'data-day-group': g },
447
+ el('div', { class: 'td-day__gh' }, D.groups[g], pill(String(l.length + (g === 'needs' ? owed.length : 0)), '')),
448
+ el('ul', { class: 'td-day__rows', 'data-day-list': 'jobs-' + g }, ...l.map((j) => jobRow(ctx, j)), ...(g === 'needs' ? owed.map((o) => owedRow(ctx, o)) : [])))));
429
449
  }
430
450
 
431
451
  /* The coordinator's view of the run: what every agent job came back with, in one place. The server runs the jobs as one
@@ -455,14 +475,12 @@ function summaryPanel(ctx) {
455
475
 
456
476
  /* What waits for the PM outside today's jobs (the Today cards: outcome checks, reports, sessions). Today hands its list
457
477
  * in, so the cards keep their own controls and the open day shows one layout. */
458
- function waitingPanel(owed) {
459
- if (!owed || !owed.list) return null;
460
- return panel('waiting', owed.count ? String(owed.count) : null, note(D.waitingHint), owed.list, owed.more || null);
461
- }
462
-
463
478
  function goalsPanel(ctx) {
464
479
  const { m } = ctx;
465
- return panel('goals', D.goalsCount(m.allGoals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), goalAdd(ctx));
480
+ // a goal of another repository is not lost: the panel says how many the Show switch hides, with the way to them
481
+ const hidden = m.hiddenGoals ? el('p', { class: 'td-day__note', 'data-day': 'goals-hidden' }, D.repo.hiddenGoals(m.hiddenGoals) + ' ',
482
+ button({ label: D.repo.showAll, variant: 'ghost', size: 'sm', attrs: { 'data-day': 'show-all' }, onClick: () => ctx.showAll() })) : null;
483
+ return panel('goals', D.goalsCount(m.allGoals.filter((g) => g.status === 'open').length, m.maxGoals), goalRows(ctx), hidden, goalAdd(ctx));
466
484
  }
467
485
 
468
486
  function mentionStatus(j) {
@@ -778,9 +796,10 @@ function sourcesDetail(ctx) {
778
796
  return [dhead(D.kind.settings, D.sources), note(D.sourcesIntro), box, el('div', { class: 'td-day__slist' }, ...rows)];
779
797
  }
780
798
 
781
- function defaultSel(m) {
799
+ function defaultSel(m, owed) {
782
800
  const j = m.jobs.find((x) => x.group === 'needs');
783
801
  if (j) return { type: 'job', id: j.id };
802
+ if (owed && owed.length) return { type: 'owed', id: owed[0].id };
784
803
  const e = m.events.find((x) => !x.ended);
785
804
  if (e) return { type: 'event', id: e.id };
786
805
  return { type: 'step', id: String(Math.max(0, Math.min(3, stepIndex(m.stage)))) };
@@ -799,10 +818,11 @@ function detail(ctx) {
799
818
  if (s.type === 'step') return stepDetail(ctx, Math.max(0, Math.min(3, Number(s.id) || 0)));
800
819
  if (s.type === 'sources') return sourcesDetail(ctx);
801
820
  if (s.type === 'meeting') return meetingDetail(ctx, s.id, { dhead });
821
+ if (s.type === 'owed') { const o = ctx.owed.find((x) => x.id === s.id); if (o) o.node.hidden = false; return o ? [dhead(D.groups.needs), el('div', { class: 'td-day__owed', 'data-day': 'owed' }, o.node)] : null; }
802
822
  return null;
803
823
  };
804
824
  kids = find(sel);
805
- if (!kids) { sel = defaultSel(m); kids = find(sel); }
825
+ if (!kids) { sel = defaultSel(m, ctx.owed); kids = find(sel); }
806
826
  return el('aside', { class: 'td-day__detail', 'aria-label': D.panels.detail, 'data-day': 'detail', 'data-day-detail': sel.type, 'data-day-detail-id': sel.id }, kids);
807
827
  }
808
828
 
@@ -847,9 +867,13 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
847
867
  const sel = ui.sel;
848
868
  const ctx = {
849
869
  ws, m, sel, openIni, reload: () => load(ws), rerender: () => rerender(), select, repoPicker,
850
- cur: (type, id) => { const s = sel || defaultSel(m); return s.type === type && s.id === String(id) ? 'true' : 'false'; },
870
+ showAll: () => { if (setState) setState({ todayScope: 'all' }); },
871
+ cur: (type, id) => { const s = sel || defaultSel(m, ctx.owed); return s.type === type && s.id === String(id) ? 'true' : 'false'; },
872
+ owed: owedItems(owed),
851
873
  };
852
- ctx.sel = sel || defaultSel(m);
874
+ ctx.sel = sel || defaultSel(m, ctx.owed);
875
+ // the open day takes what the repositories owe into its jobs: dest-today draws no second list under it
876
+ if (owed) owed.taken = true;
853
877
  if (ui.scroll) {
854
878
  ui.scroll = false;
855
879
  // on a narrow screen the detail is under the list: bring it into view once it is on the page
@@ -866,7 +890,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
866
890
  c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
867
891
  m.d && m.d.legacy ? note(D.oldServer) : null,
868
892
  el('div', { class: 'td-day__cols' },
869
- el('div', { class: 'td-day__left' }, agenda(ctx), summaryPanel(ctx), goalsPanel(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
893
+ el('div', { class: 'td-day__left' }, agenda(ctx), summaryPanel(ctx), goalsPanel(ctx), jobsPanel(ctx), mentionsPanel(ctx)),
870
894
  detail(ctx)));
871
895
  }
872
896
 
@@ -3772,8 +3772,8 @@ function stallQuietTasks(run, now) {
3772
3772
  return run;
3773
3773
  }
3774
3774
  // The skill's own body is the prompt, as fireSchedule hands it over; the line after it names this run's task and mode.
3775
- function daySkillPrompt(run, skillId) {
3776
- const w = getWs(run.ws);
3775
+ function daySkillPrompt(run, skillId, wsId) {
3776
+ const w = getWs(wsId || run.ws);
3777
3777
  const task = taskById(skillId, w ? w.root : null);
3778
3778
  if (!task || task.kind !== 'agent' || !task.prompt) return { error: `the ${skillId} skill is not installed for this repository` };
3779
3779
  return { w, task, prompt: skillStore.retargetFiling(task.prompt, filingContract(w)) };
@@ -3785,9 +3785,22 @@ function jobSessionName(title) {
3785
3785
  const cut = t.slice(0, 28); const sp = cut.lastIndexOf(' ');
3786
3786
  return 'Job · ' + (sp > 12 ? cut.slice(0, sp) : cut).replace(/[\s,.;:·—-]+$/, '') + '…';
3787
3787
  }
3788
+ /** The repository a job belongs to (its item's tag: its goal's, its initiative's or its place's repository), when that
3789
+ * repository is registered and has the day-task skill; else null and the job runs where Prepare my day was pressed. */
3790
+ function taskWsId(run, t) {
3791
+ try {
3792
+ const brief = briefOfDay(run.date);
3793
+ if (!brief || !t.item_id) return null;
3794
+ const st = proSettings();
3795
+ const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos() })[t.item_id];
3796
+ const w = r && r !== run.ws ? getWs(r) : null;
3797
+ return w && !daySkillPrompt(run, 'day-task', w.id).error ? w.id : null;
3798
+ } catch { return null; }
3799
+ }
3788
3800
  /** Start one task session; a start that fails marks the task failed with the reason. Returns the new run. */
3789
3801
  function startDayTask(run, t, now) {
3790
- const sk = daySkillPrompt(run, 'day-task');
3802
+ // a job runs in its own repository's folder, so it reads that repository's files and records (one day, many repositories)
3803
+ const sk = daySkillPrompt(run, 'day-task', taskWsId(run, t));
3791
3804
  let s = null;
3792
3805
  const line = `This run: /day-task ${t.id} ${t.mode} — read the task first with: pmos proactive day show ${t.id}`;
3793
3806
  // The session that did the task's last step is used again while it is open: it has the skill and the task in its
@@ -3862,6 +3875,15 @@ function dayTick() {
3862
3875
  // ── end of the today day run helpers ─────────────────────────────────────────────────────────────────────────────
3863
3876
  /* The calendar the Agenda remembers (G23): one file beside the briefs, written only by the brief route. */
3864
3877
  const CAL_MEMORY = () => path.join(STATE, 'calendar-memory.json');
3878
+ // the PM's key-meeting marks (lib/calendar-memory.js setMark): a small file beside the calendar memory, written only by
3879
+ // POST /api/proactive/agenda/mark
3880
+ const CAL_MARKS = () => path.join(path.dirname(CAL_MEMORY()), 'calendar-marks.json');
3881
+ function readCalMarks() {
3882
+ try { return calMemLib.readMarks(JSON.parse(fs.readFileSync(CAL_MARKS(), 'utf8'))); } catch { return calMemLib.readMarks(null); }
3883
+ }
3884
+ function writeCalMarks(m) {
3885
+ try { const f = CAL_MARKS(); fs.mkdirSync(path.dirname(f), { recursive: true }); const tmp = `${f}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify(m) + '\n'); fs.renameSync(tmp, f); return true; } catch { return false; }
3886
+ }
3865
3887
  function readCalMemory() {
3866
3888
  try { return JSON.parse(fs.readFileSync(CAL_MEMORY(), 'utf8')); } catch { return null; }
3867
3889
  }
@@ -3935,7 +3957,30 @@ const goalsCardsMemo = new Map(); // ws -> { at, cards, error }: GET /api/proa
3935
3957
  function readGoalsDay(date) {
3936
3958
  let raw = null;
3937
3959
  try { raw = JSON.parse(fs.readFileSync(path.join(GOALS_DIR(), date + '.json'), 'utf8')); } catch { /* none yet */ }
3938
- return goalsLib.readDay(raw, date);
3960
+ if (raw) return goalsLib.readDay(raw, date);
3961
+ // a day with no file of its own yet starts with the goals still open on the last day that had a file (lib/goals.js
3962
+ // carryOver); the first edit of the day writes them into the day's own file
3963
+ let prev = null;
3964
+ try {
3965
+ const last = fs.readdirSync(GOALS_DIR()).map((f) => /^(\d{4}-\d{2}-\d{2})\.json$/.exec(f)).filter((m) => m && m[1] < date).map((m) => m[1]).sort().pop();
3966
+ if (last) prev = goalsLib.readDay(JSON.parse(fs.readFileSync(path.join(GOALS_DIR(), last + '.json'), 'utf8')), last);
3967
+ } catch { /* no earlier day */ }
3968
+ return goalsLib.carryOver(prev, date);
3969
+ }
3970
+ /** { initiativeId: workspace id } for the initiatives exactly one registered repository holds, read at most every 30 s.
3971
+ * An item or goal naming one of them belongs to that repository (lib/goals.js itemRepos, goalRepos). */
3972
+ let iniReposMemo = null;
3973
+ function iniRepos(now = Date.now()) {
3974
+ if (iniReposMemo && now - iniReposMemo.at < 30000) return iniReposMemo.map;
3975
+ const map = {}; const twice = new Set();
3976
+ try {
3977
+ for (const r of portfolio().rows) {
3978
+ if (twice.has(r.id)) continue;
3979
+ if (map[r.id] && map[r.id] !== r.ws) { delete map[r.id]; twice.add(r.id); } else map[r.id] = r.ws;
3980
+ }
3981
+ } catch { /* no tags this read */ }
3982
+ iniReposMemo = { at: now, map };
3983
+ return map;
3939
3984
  }
3940
3985
  function latestBrief() {
3941
3986
  try {
@@ -7676,7 +7721,8 @@ async function handleRequest(req, res) {
7676
7721
  settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
7677
7722
  // one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
7678
7723
  repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
7679
- item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {} }),
7724
+ item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now) }),
7725
+ goal_repos: goalsLib.goalRepos({ day: readGoalsDay(date), iniRepos: iniRepos(now) }),
7680
7726
  check: { every: st.dayCheck.every, last: run ? run.checks.last : null, next: due.next, running: !!(run && run.checks.session_id), log: run ? run.checks.log : [] } });
7681
7727
  }
7682
7728
  /* The Agenda month (G25 to G27): read-only. The calendar memory and today's brief, joined by lib/calendar-memory.js. */
@@ -7684,7 +7730,15 @@ async function handleRequest(req, res) {
7684
7730
  const now = Date.now();
7685
7731
  const month = url.searchParams.get('month') || undefined;
7686
7732
  if (month !== undefined && !/^\d{4}-\d{2}$/.test(month)) return json(res, { ok: false, error: 'month must be YYYY-MM' }, 400);
7687
- return json(res, calMemLib.agendaMonth({ memory: readCalMemory(), brief: briefOfDay(briefLib.localDate(now)), month, now }));
7733
+ return json(res, calMemLib.agendaMonth({ memory: readCalMemory(), brief: briefOfDay(briefLib.localDate(now)), month, marks: readCalMarks(), now }));
7734
+ }
7735
+ /* A key-meeting mark: the PM's press in a meeting's detail. { title, start, scope: 'one'|'series', on: true|false|null }. */
7736
+ if (p === '/api/proactive/agenda/mark' && req.method === 'POST') {
7737
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7738
+ const r = calMemLib.setMark(readCalMarks(), { title: body.title, start: body.start, scope: body.scope, on: body.on === undefined ? null : body.on });
7739
+ if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
7740
+ if (!writeCalMarks(r.marks)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
7741
+ return json(res, { ok: true });
7688
7742
  }
7689
7743
  if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
7690
7744
  const verb = p.slice('/api/proactive/day/'.length);