create-pmos 0.73.0 → 0.75.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.
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { el } from './dom.js';
28
- import { button, iconButton, badge, toast, row, tabs, field } from './primitives.js';
28
+ import { button, iconButton, badge, toast, row, tabs, field, menu, meter } from './primitives.js';
29
29
  import { pmGet, pmPost } from './http.js';
30
30
  import { FIXTURE } from './state.js';
31
31
  import { COPY } from './today-copy.js';
@@ -35,6 +35,7 @@ import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAg
35
35
  import { notesBlock, onNotes } from './notes-links.js';
36
36
 
37
37
  const D = COPY.day;
38
+ const A = COPY.act;
38
39
  const SLOW_MS = 30000;
39
40
  const FAST_MS = 4000;
40
41
  const MAX_REVISIONS = 3;
@@ -58,6 +59,8 @@ const ui = {
58
59
  open: (() => { const v = store.get(OPEN_KEY); return v === 'true' ? true : v === 'false' ? false : null; })(),
59
60
  sel: null, mtab: 'all', openSrc: null, showIgn: {}, showIgnDigests: false,
60
61
  revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
62
+ // today-act-prepare: the folded groups the PM opened, the item whose hold form is open, and its draft
63
+ groupsOpen: new Set(), holding: null, holdDraft: { who: '', when: 'h2' }, compose: new Map(), verdict: new Map(),
61
64
  };
62
65
  let rerender = () => {};
63
66
  let notesSub = null; // the redraw when the meeting notes list is read (pm/notes-links.js)
@@ -119,7 +122,8 @@ const isUrl = (s) => typeof s === 'string' && s.length <= 500 && /^https?:\/\/\S
119
122
  const srcName = (k) => D.srcName[k] || (k === 'derived' ? 'PMOS' : String(k || ''));
120
123
 
121
124
  function jobOf(item, task, worth) {
122
- const kind = (item && item.kind) || (task && task.kind) || (item && item.owner === 'pm' ? 'you' : 'approve');
125
+ // a job the PM gave to the agent (Have the agent draft it) has the task's kind: approve
126
+ const kind = (task && task.kind) || (item && item.kind) || (item && item.owner === 'pm' ? 'you' : 'approve');
123
127
  const status = task ? task.status : kind === 'you' ? 'you' : 'notStarted';
124
128
  const group = worth ? 'worth' : ['you', 'review', 'failed'].includes(status) ? 'needs' : ['done', 'sent'].includes(status) ? 'done' : 'working';
125
129
  return {
@@ -160,10 +164,20 @@ function model(d, view) {
160
164
  for (const x of (brief && brief.worth_asking) || []) add(x, true);
161
165
  for (const t of tasks) if (!used.has(t.id)) jobs.push(jobOf(null, t, false)); // an item a check took off: its task stays
162
166
  const now = d && d.now ? (typeof d.now === 'number' ? d.now : Date.parse(d.now)) : Date.now();
167
+ const marks = (d && d.marks) || {};
163
168
  const events = ((brief && brief.calendar) || []).map((e, i) => {
164
169
  const s = Date.parse(e.start);
165
170
  const f = e.end ? Date.parse(e.end) : s + 30 * 60000;
166
- return { ...e, id: e.id || 'ev-' + i, s, f, time: hhmm(e.start), ended: Number.isFinite(f) && f <= now };
171
+ const mark = marks[e.id] || null;
172
+ const checked = new Set((mark && mark.steps) || []);
173
+ const before = ((e.steps && e.steps.before) || []).map((st) => ({ ...st, done: !!st.done || checked.has(String(st.t || '')) }));
174
+ const ended = Number.isFinite(f) && f <= now;
175
+ // a meeting needs prep when the brief gave a prep line or Before steps (the explainer's contradiction: Before steps
176
+ // with no prep line read "Nothing to prepare" while the Agenda counted them as prep left)
177
+ const needsPrep = !ended && (!!e.prep || before.length > 0);
178
+ const st = mark && mark.state;
179
+ const group = !needsPrep ? null : st === 'done' || st === 'dismissed' ? 'done' : st === 'snoozed' ? 'later' : 'needs';
180
+ return { ...e, id: e.id || 'ev-' + i, s, f, time: hhmm(e.start), ended, mark, before, needsPrep, group };
167
181
  }).sort((a, b) => a.s - b.s);
168
182
  const settings = (d && d.settings) || null;
169
183
  const scopes = (settings && settings.scopes) || {};
@@ -176,6 +190,13 @@ function model(d, view) {
176
190
  const repos = (d && Array.isArray(d.repos)) ? d.repos : [];
177
191
  const repoName = (id) => { const r = repos.find((x) => x.id === id); return r ? r.name : id; };
178
192
  for (const j of jobs) j.repo = itemRepos[j.id] || null;
193
+ // the PM's marks (today-act-prepare, PRD M1 to M6): a job the PM owns leaves Needs you when it is done, snoozed, held
194
+ // or not the PM's; a snooze or hold whose time has passed comes back as open (the server reads it so)
195
+ for (const j of jobs) {
196
+ j.mark = marks[j.id] || null;
197
+ const st = j.mark && j.mark.state;
198
+ if (j.status === 'you' && st) j.group = { done: 'done', dismissed: 'done', snoozed: 'later', waiting: 'waiting' }[st] || j.group;
199
+ }
179
200
  for (const g of digests) g.repo = placeRepo(g.source, g.name);
180
201
  const v = view || {};
181
202
  const narrow = !v.all && !!v.here;
@@ -193,8 +214,9 @@ function model(d, view) {
193
214
  readFailed: (d && d.read_failed) || null,
194
215
  free: plan && plan.time ? plan.time.free_minutes : null,
195
216
  maxGoals: (d && d.max_goals) || 7,
196
- needs: shownJobs.filter((j) => j.group === 'needs').length,
197
- done: shownJobs.filter((j) => j.group === 'done').length,
217
+ preps: events.filter((e) => e.needsPrep),
218
+ needs: shownJobs.filter((j) => j.group === 'needs').length + events.filter((e) => e.group === 'needs').length,
219
+ done: shownJobs.filter((j) => j.group === 'done').length + events.filter((e) => e.group === 'done').length,
198
220
  };
199
221
  }
200
222
 
@@ -226,7 +248,7 @@ function statusPill(j) {
226
248
  const map = {
227
249
  notStarted: [s.notStarted, ''], waiting: [s.waiting, ''], working: [s.working, 'info'], sending: [s.sending, 'info'],
228
250
  review: [j.kind === 'auto' ? s.reviewAuto : s.review, 'purple'], sent: [s.sent, 'ok'], done: [s.done, 'ok'],
229
- failed: [s.failed, 'bad'], you: [s.you, 'warn'],
251
+ failed: [s.failed, 'bad'],
230
252
  };
231
253
  const [t, tone] = map[j.status] || [j.status, ''];
232
254
  return pill(t, tone, { 'data-day-status': j.status });
@@ -306,6 +328,48 @@ async function decide(ws, task, action, note) {
306
328
  load(ws);
307
329
  }
308
330
 
331
+ /* The PM's marks (today-act-prepare, PRD M1 to M6). Every press but a step check says what it did, with Undo: the
332
+ * answer carries the mark before the press, and Undo puts it back. */
333
+ async function markPress(ws, id, op, said) {
334
+ const key = 'mark:' + id;
335
+ if (ui.busy.has(key)) return;
336
+ ui.busy.add(key); rerender();
337
+ const r = await pmPost('/api/proactive/day/mark', { id, ...op });
338
+ ui.busy.delete(key);
339
+ if (r && r.ok) {
340
+ if (op.action !== 'step') ui.holding = null;
341
+ if (said) toast({ text: said, actionLabel: A.undo, onAction: () => undoMark(ws, id, r.prev) });
342
+ } else toast({ text: (op.action === 'step' ? A.stepFailed : D.refused)((r && r.error) || D.unreached), tone: 'destructive' });
343
+ rerender();
344
+ load(ws);
345
+ }
346
+ async function undoMark(ws, id, prev) {
347
+ const r = await pmPost('/api/proactive/day/mark', { id, action: 'restore', mark: prev === undefined ? null : prev });
348
+ toast(r && r.ok ? { text: A.undone } : { text: D.refused((r && r.error) || D.unreached), tone: 'destructive' });
349
+ load(ws);
350
+ }
351
+ /** "Have the agent draft it" (PRD C2): one briefed task for this job; the job then shows the draft to approve. */
352
+ async function sendPress(ws, j, body, to) {
353
+ const key = 'send:' + j.id;
354
+ if (ui.busy.has(key)) return;
355
+ ui.busy.add(key); rerender();
356
+ const r = await pmPost('/api/proactive/day/send', { item: j.id, ws, ...body });
357
+ ui.busy.delete(key);
358
+ if (r && r.ok) { ui.compose.delete(j.id); ui.verdict.delete(j.id); toast({ text: A.sendStarted(to) }); } else toast({ text: D.refused((r && r.error) || D.unreached), tone: 'destructive' });
359
+ rerender();
360
+ load(ws);
361
+ }
362
+ async function draftPress(ws, j) {
363
+ const key = 'draft:' + j.id;
364
+ if (ui.busy.has(key)) return;
365
+ ui.busy.add(key); rerender();
366
+ const r = await pmPost('/api/proactive/day/draft', { item: j.id, ws });
367
+ ui.busy.delete(key);
368
+ toast(r && r.ok ? { text: A.draftStarted } : { text: D.refused((r && r.error) || D.unreached), tone: 'destructive' });
369
+ rerender();
370
+ load(ws);
371
+ }
372
+
309
373
  async function setSettings(ws, patch) {
310
374
  const r = await pmPost('/api/proactive/settings', patch);
311
375
  if (!r || r.ok === false) { toast({ text: D.refused((r && r.error) || D.unreached), tone: 'destructive' }); return false; }
@@ -421,7 +485,11 @@ function jobRow(ctx, j) {
421
485
  const n = j.goal ? ctx.m.goalNo.get(j.goal) : null;
422
486
  const sub = j.status === 'failed' && j.task && j.task.error ? j.task.error
423
487
  : [j.item && j.item.where, srcName(j.source)].filter(Boolean).join(' · ');
424
- return rowBtn(ctx, 'job', j.id, { lead: n ? D.pill.goal(n) : '·', title: j.title, meta: sub, trail: el('span', { class: 'td-day__chips' }, repoTag(ctx.m, j.repo), statusPill(j)),
488
+ const mine = j.status === 'you';
489
+ const due = mine && j.item && j.item.due ? Date.parse(j.item.due) : NaN;
490
+ const meta = mine && j.mark && j.mark.state === 'waiting' ? `${A.hold.replace('…', '')} ${j.mark.on}` : sub;
491
+ return rowBtn(ctx, 'job', j.id, { lead: mine ? verbChip(verbOf(j.item)) : n ? D.pill.goal(n) : '·', title: j.title, meta,
492
+ trail: el('span', { class: 'td-day__chips' }, repoTag(ctx.m, j.repo), mine ? dueChip(ctx.m, j.mark, due, false) : statusPill(j)),
425
493
  dot: j.isNew && !seen.has(j.id) ? el('span', { class: 'td-day__newdot', 'data-day': 'new', role: 'img', 'aria-label': D.newItem }) : null });
426
494
  }
427
495
 
@@ -438,18 +506,38 @@ function owedItems(owed) {
438
506
  });
439
507
  }
440
508
 
509
+ /** A meeting to prepare for, as a row of the jobs (PRD D4): it selects the meeting. */
510
+ function prepRow(ctx, e) {
511
+ const total = e.before.length; const d = e.before.filter((x) => x.done).length;
512
+ return rowBtn(ctx, 'event', e.id, { lead: verbChip('prepare'), title: e.title, meta: [total ? A.readiness(d, total) : null, e.with || null].filter(Boolean).join(' · '),
513
+ trail: dueChip(ctx.m, e.mark, e.s, true) }, { 'data-day-prep': e.id });
514
+ }
515
+
441
516
  function owedRow(ctx, o) {
442
- return rowBtn(ctx, 'owed', o.id, { lead: '·', title: o.title, meta: o.meta, trail: pill(D.status2.you, 'warn') });
517
+ return rowBtn(ctx, 'owed', o.id, { lead: '·', title: o.title, meta: o.meta, trail: pill(A.chip.today, 'warn', { 'data-day-status': 'you' }) });
443
518
  }
444
519
 
520
+ // Needs you holds only what the PM can do now (PRD D1): snoozed, held and done items fold into groups of their own.
521
+ const JOB_GROUPS = ['needs', 'later', 'waiting', 'working', 'done', 'worth'];
522
+ const FOLDED = new Set(['later', 'waiting', 'done']);
445
523
  function jobsPanel(ctx) {
446
524
  const { m } = ctx;
447
525
  const owed = ctx.owed;
448
- if (!m.jobs.length && !owed.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
449
- 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));
450
- 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 },
451
- el('div', { class: 'td-day__gh' }, D.groups[g], pill(String(l.length + (g === 'needs' ? owed.length : 0)), '')),
452
- 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)) : [])))));
526
+ if (!m.jobs.length && !owed.length && !m.preps.length) return panel('jobs', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noJobs, { 'data-day': 'no-jobs' }));
527
+ const groups = JOB_GROUPS.map((g) => [g, m.jobs.filter((j) => j.group === g), m.preps.filter((e) => e.group === g)])
528
+ .filter(([g, l, p]) => l.length || p.length || (g === 'needs' && owed.length));
529
+ return panel('jobs', D.jobsCount(m.needs + owed.length, m.done), ...groups.map(([g, l, p]) => {
530
+ const count = l.length + p.length + (g === 'needs' ? owed.length : 0);
531
+ const label = A.groups[g] && g !== 'done' ? A.groups[g] : g === 'done' ? A.groups.done : D.groups[g];
532
+ const foldable = FOLDED.has(g);
533
+ const shut = foldable && !ui.groupsOpen.has(g);
534
+ const head = foldable
535
+ ? button({ label: `${label} · ${count}`, variant: 'ghost', size: 'sm', iconEnd: shut ? 'chevron-right' : 'chevron-down', attrs: { class: 'td-day__fold-head', 'aria-expanded': shut ? 'false' : 'true', 'data-day-fold': g },
536
+ onClick: () => { if (shut) ui.groupsOpen.add(g); else ui.groupsOpen.delete(g); rerender(); } })
537
+ : el('div', { class: 'td-day__gh' }, label, pill(String(count), ''));
538
+ return el('div', { class: 'td-day__group', 'data-day-group': g }, head,
539
+ shut ? null : el('ul', { class: 'td-day__rows', 'data-day-list': 'jobs-' + g }, ...p.map((e) => prepRow(ctx, e)), ...l.map((j) => jobRow(ctx, j)), ...(g === 'needs' ? owed.map((o) => owedRow(ctx, o)) : [])));
540
+ }));
453
541
  }
454
542
 
455
543
  /* The coordinator's view of the run: what every agent job came back with, in one place. The server runs the jobs as one
@@ -489,7 +577,8 @@ function goalsPanel(ctx) {
489
577
 
490
578
  function mentionStatus(j) {
491
579
  if (j.group === 'done') return pill(D.status2.handled, 'ok');
492
- if (j.status === 'you') return j.item && j.item.reply_needed ? pill(D.status2.replyNeeded, 'warn') : pill(D.status2.you, 'warn');
580
+ if (j.status === 'you' && j.mark && j.mark.state) return dueChip({ now: Date.now() }, j.mark, NaN, false);
581
+ if (j.status === 'you') return j.item && j.item.reply_needed ? pill(D.status2.replyNeeded, 'warn', { 'data-day-status': 'you' }) : verbChip(verbOf(j.item));
493
582
  return statusPill(j);
494
583
  }
495
584
 
@@ -523,6 +612,243 @@ function mentionsPanel(ctx) {
523
612
  return panel('mentions', m.mentions.length ? D.mentionsCount(m.mentions.length, need) : null, mtabs, rows, dig);
524
613
  }
525
614
 
615
+ /* ── act and prepare (initiative today-act-prepare) ─────────────────────────────────────────────────────────────── */
616
+
617
+ const MIN_MS = 60000;
618
+ const clockAt = (ms, hh, mm) => { const d = new Date(ms); return new Date(d.getFullYear(), d.getMonth(), d.getDate(), hh, mm).getTime(); };
619
+ const sameDay = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
620
+ const verbOf = (it) => (it && A.verb[it.verb] ? it.verb : it && it.reply_needed ? 'reply' : 'do');
621
+ function verbChip(verb) {
622
+ return el('span', { class: 'td-day__verb', 'data-verb': verb, 'data-day': 'verb' }, A.verb[verb] || A.verb.do);
623
+ }
624
+ /** The chip at the end of a row the PM acts on: where its mark left it, else its due time, coloured by time left. */
625
+ function dueChip(m, mark, due, prep) {
626
+ const st = mark && mark.state;
627
+ const at = (v) => hhmm(v);
628
+ if (st === 'snoozed') return pill(A.back(at(mark.until)), '', { 'data-day-status': 'snoozed' });
629
+ if (st === 'waiting') return pill(A.check(at(mark.until)), '', { 'data-day-status': 'waiting' });
630
+ if (st === 'done') return pill(prep ? A.chip.ready : A.chip.done, 'ok', { 'data-day-status': 'marked-done' });
631
+ if (st === 'dismissed') return pill(prep ? A.chip.skipped : A.chip.dismissed, '', { 'data-day-status': 'dismissed' });
632
+ const status = prep ? 'prepare' : 'you';
633
+ if (!Number.isFinite(due)) return pill(A.chip.today, 'warn', { 'data-day-status': status });
634
+ const left = Math.round((due - m.now) / MIN_MS);
635
+ return pill(prep ? A.at(at(due)) : A.by(at(due)), left < 60 ? 'bad' : left < 180 ? 'warn' : '', { 'data-day-status': status, title: A.left(left) });
636
+ }
637
+ /** The times a snooze can take, later today: [label, ms]. */
638
+ function snoozeTimes(m, { due, start }) {
639
+ const out = [];
640
+ const add = (label, t) => { if (Number.isFinite(t) && t > m.now + 5 * MIN_MS && sameDay(t, m.now) && !out.some((x) => x[1] === t)) out.push([label, t]); };
641
+ if (Number.isFinite(start)) { add(A.snoozeTo.m30, start - 30 * MIN_MS); add(A.snoozeTo.m15, start - 15 * MIN_MS); return out; }
642
+ add(A.snoozeTo.hour, m.now + 60 * MIN_MS);
643
+ if (Number.isFinite(due)) add(A.snoozeTo.due, due - 30 * MIN_MS);
644
+ add(A.snoozeTo.later, clockAt(m.now, 16, 0));
645
+ add(A.snoozeTo.end, clockAt(m.now, 17, 30));
646
+ return out;
647
+ }
648
+ /** The banner of a marked item: what the mark is, and the press that undoes it. */
649
+ function markBanner(ctx, id, mark, prep) {
650
+ if (!mark) return null;
651
+ if (mark.back) return note(A.state.back, { 'data-day': 'mark-back' });
652
+ const st = mark.state;
653
+ if (!st) return null;
654
+ const text = st === 'snoozed' ? A.state.snoozed(hhmm(mark.until)) : st === 'waiting' ? A.state.waiting(mark.on, hhmm(mark.until))
655
+ : st === 'done' ? (prep ? A.state.ready : A.state.done)(hhmm(mark.at)) : A.state.dismissed(A.why[mark.why] || mark.why);
656
+ return el('div', { class: 'td-day__mark', 'data-day': 'mark', 'data-day-mark-state': st }, el('span', null, text),
657
+ button({ label: st === 'done' ? A.reopen : A.bringBack, size: 'sm', attrs: { 'data-day-mark': 'reopen' }, onClick: () => markPress(ctx.ws, id, { action: 'reopen' }, A.did.reopen) }));
658
+ }
659
+ /** The steps the PM checks off. A step the brief already marked done stays done. A step that reads a source shows what
660
+ * the source says under it (PRD X2), and a note step holds the PM's own note (PRD F3), so no other file needs opening. */
661
+ function stepChecks(ctx, id, steps, { refs = [], mark = null } = {}) {
662
+ if (!steps.length) return null;
663
+ return el('ul', { class: 'td-day__steps2', 'data-day': 'steps' }, ...steps.map((st) => {
664
+ const f = field({ kind: 'check', label: st.t, value: !!st.done, attrs: { 'data-day-step': st.t, disabled: st.fixed ? true : null },
665
+ onCommit: (v) => markPress(ctx.ws, id, { action: 'step', step: st.t, done: !!v }) });
666
+ const r = Number.isInteger(st.ref) ? refs[st.ref] : null;
667
+ return el('li', { class: 'td-day__step2', 'data-done': st.done ? 'true' : 'false' },
668
+ el('div', { class: 'td-day__steprow' }, f, st.who ? el('span', { class: 'td-day__who' }, D.who[st.who] || st.who) : null),
669
+ r ? refBlock(r, { open: !st.done, step: true }) : null,
670
+ st.note ? noteBox(ctx, id, mark) : null);
671
+ }));
672
+ }
673
+ /** One source in a few lines, in the card: what it says, and a small link to the source for when the PM wants more. */
674
+ function refBlock(r, { open, step } = {}) {
675
+ return el('details', { class: 'td-day__ref', 'data-day': 'ref', open: open ? true : null },
676
+ el('summary', null, el('span', null, (step ? A.refRead : '') + r.label), r.source ? el('span', { class: 'td-day__src' }, srcName(r.source)) : null),
677
+ el('ul', { class: 'td-day__reflines' }, ...(r.lines || []).map((l) => el('li', null, l))),
678
+ isUrl(r.link) ? extLink(A.refOpen, r.link, { 'data-day-link': 'ref' }) : null);
679
+ }
680
+ /** The sources no step names, under one heading. */
681
+ function refsBlock(refs, steps) {
682
+ const named = new Set(steps.map((st) => st.ref).filter(Number.isInteger));
683
+ const rest = refs.filter((_, i) => !named.has(i));
684
+ return rest.length ? el('div', { class: 'td-day__phase', 'data-day': 'refs' }, el('h4', null, A.refsTitle), ...rest.map((r) => refBlock(r, { open: false }))) : null;
685
+ }
686
+ /** The conversation, as the brief read it: at most 4 messages, the last one first in weight. */
687
+ function threadBlock(thread) {
688
+ if (!Array.isArray(thread) || !thread.length) return null;
689
+ return el('div', { class: 'td-day__phase td-day__thread', 'data-day': 'thread' }, el('h4', null, A.threadTitle),
690
+ ...thread.map((x, i) => el('div', { class: 'td-day__msg' + (i === thread.length - 1 ? ' td-day__msg--last' : '') },
691
+ el('div', { class: 'td-day__src' }, [x.who, x.at].filter(Boolean).join(' · ')), el('p', null, x.text))));
692
+ }
693
+ /** The PM's own note on a meeting, saved with its marks when the box loses focus. Nothing is sent. */
694
+ function noteBox(ctx, id, mark) {
695
+ const f = field({ label: A.noteTitle, kind: 'textarea', value: (mark && mark.note) || '', placeholder: A.noteHint, attrs: { maxlength: '1000', 'data-day-input': 'note' },
696
+ onCommit: (v) => markPress(ctx.ws, id, { action: 'note', text: v }, A.noteSaved) });
697
+ return el('div', { class: 'td-day__notebox', 'data-day': 'note' }, f, el('span', { class: 'td-day__dim' }, A.noteStays));
698
+ }
699
+ /** Where the PM's own answer goes, and the box to write it in (PRD F1, F2). The press is the approval: the agent posts
700
+ * exactly this text, as the PM, to the place the item names. */
701
+ function composer(ctx, j, verb) {
702
+ const it = j.item || {};
703
+ const sd = it.send || {};
704
+ const to = sd.to || it.where || srcName(j.source);
705
+ const review = !!sd.review;
706
+ const busy = ui.busy.has('send:' + j.id);
707
+ const f = field({ label: A.compose[verb] || A.compose.reply, kind: 'textarea', value: ui.compose.get(j.id) || '', placeholder: A.composeHint,
708
+ attrs: { maxlength: '4000', rows: '4', 'data-day-input': 'compose' } });
709
+ const ta = f.control;
710
+ ta.addEventListener('input', () => { ui.compose.set(j.id, ta.value); });
711
+ const chips = (sd.suggest || []).length ? el('div', { class: 'td-day__chipsrow', 'data-day': 'suggest' }, el('span', { class: 'td-day__dim' }, A.startFrom),
712
+ ...sd.suggest.map((t, i) => button({ label: t.length > 60 ? t.slice(0, 57) + '…' : t, size: 'sm', variant: 'ghost', attrs: { 'data-day-suggest': String(i), title: t },
713
+ onClick: () => { ta.value = t; ui.compose.set(j.id, t); ta.focus(); } }))) : null;
714
+ const verdict = ui.verdict.get(j.id) || null;
715
+ const toggle = review ? tabs({ variant: 'segmented', role: 'radiogroup', ariaLabel: A.verdictLabel, value: verdict, attrs: { 'data-day': 'verdict' },
716
+ items: [{ id: 'approve', label: A.verdict.approve }, { id: 'changes', label: A.verdict.changes }], onChange: (k) => { ui.verdict.set(j.id, k); rerender(); } }) : null;
717
+ const go = () => {
718
+ const text = (ui.compose.get(j.id) || ta.value || '').trim();
719
+ if (!text) { toast({ text: A.sendNeed, tone: 'destructive' }); return; }
720
+ if (review && !verdict) { toast({ text: A.verdictNeed, tone: 'destructive' }); return; }
721
+ sendPress(ctx.ws, j, { text, verdict: review ? verdict : undefined }, to);
722
+ };
723
+ return el('div', { class: 'td-day__compose', 'data-day': 'compose' }, f, chips, toggle,
724
+ el('div', { class: 'td-day__acts' }, button({ label: review ? A.postReview(to) : A.sendTo(to), variant: 'primary', size: 'sm', iconEnd: 'arrow-right', disabled: busy, attrs: { 'data-day-act': 'send' }, onClick: go })),
725
+ el('span', { class: 'td-day__dim' }, A.sendNote));
726
+ }
727
+ /** The four presses of a job (Mark done, Snooze, Waiting on…, Not mine) or the three of a meeting (Mark ready, Remind
728
+ * me, Skip prep). `strong` makes the first one the primary button: every step is checked. */
729
+ function marksBar(ctx, { id, title, kind, due, start, strong }) {
730
+ const { m, ws } = ctx;
731
+ const prep = kind === 'event';
732
+ const busy = ui.busy.has('mark:' + id);
733
+ const st = (m.d && m.d.marks && m.d.marks[id] && m.d.marks[id].state) || null;
734
+ const times = snoozeTimes(m, { due, start });
735
+ const kids = [];
736
+ if (st !== 'done') kids.push(button({ label: prep ? A.ready : A.done, variant: strong ? 'primary' : 'outline', size: 'sm', icon: 'check', disabled: busy, attrs: { 'data-day-mark': 'done' },
737
+ onClick: () => markPress(ws, id, { action: 'done' }, (prep ? A.did.ready : A.did.done)(title)) }));
738
+ if (times.length) {
739
+ const b = button({ label: prep ? A.remind : A.snooze, size: 'sm', iconEnd: 'chevron-down', disabled: busy, attrs: { 'data-day-mark': 'snooze', 'aria-haspopup': 'menu' },
740
+ onClick: () => menu({ anchor: b, ariaLabel: prep ? A.remind : A.snooze, items: times.map(([l, t]) => ({ label: `${l} · ${hhmm(t)}`, attrs: { 'data-day-snooze': hhmm(t) },
741
+ onSelect: () => markPress(ws, id, { action: 'snooze', until: new Date(t).toISOString() }, A.did.snooze(hhmm(t))) })) }) });
742
+ kids.push(b);
743
+ }
744
+ if (!prep) kids.push(button({ label: A.hold, size: 'sm', disabled: busy, attrs: { 'data-day-mark': 'hold', 'aria-expanded': ui.holding === id ? 'true' : 'false' },
745
+ onClick: () => { ui.holding = ui.holding === id ? null : id; ui.holdDraft = { who: '', when: 'h2' }; rerender(); } }));
746
+ const reasons = prep ? ['nothing', 'elsewhere'] : ['no-action', 'someone-else', 'done-elsewhere'];
747
+ if (st !== 'dismissed') {
748
+ const nb = button({ label: prep ? A.skip : A.notMine, size: 'sm', variant: 'ghost', iconEnd: 'chevron-down', disabled: busy, attrs: { 'data-day-mark': 'dismiss', 'aria-haspopup': 'menu' },
749
+ onClick: () => menu({ anchor: nb, ariaLabel: prep ? A.skip : A.notMine, items: reasons.map((w) => ({ label: A.why[w], attrs: { 'data-day-why': w },
750
+ onSelect: () => markPress(ws, id, { action: 'dismiss', why: w }, A.did.dismiss(A.why[w])) })) }) });
751
+ kids.push(el('span', { class: 'td-day__grow', 'aria-hidden': 'true' }), nb);
752
+ }
753
+ return [el('div', { class: 'td-day__marks', 'data-day': 'marks', role: 'group', 'aria-label': title }, ...kids), ui.holding === id && !prep ? holdForm(ctx, id) : null];
754
+ }
755
+ function holdForm(ctx, id) {
756
+ const { m } = ctx;
757
+ const h2 = m.now + 120 * MIN_MS; const end = clockAt(m.now, 17, 30);
758
+ const opts = [['h2', A.holdWhenOpts.h2, h2], ['end', A.holdWhenOpts.end, end]].filter(([, , t]) => t > m.now && sameDay(t, m.now));
759
+ if (!opts.some((o) => o[0] === ui.holdDraft.when) && opts.length) ui.holdDraft.when = opts[0][0];
760
+ const who = field({ label: A.holdWho, value: ui.holdDraft.who, placeholder: A.holdHint, attrs: { maxlength: '80', 'data-day-input': 'hold-who' } });
761
+ who.control.addEventListener('input', () => { ui.holdDraft.who = who.control.value; });
762
+ const go = () => {
763
+ const v = (ui.holdDraft.who || '').trim();
764
+ const o = opts.find((x) => x[0] === ui.holdDraft.when);
765
+ if (!v || v.length > 80) { toast({ text: A.holdNeed, tone: 'destructive' }); return; }
766
+ if (!o) return;
767
+ markPress(ctx.ws, id, { action: 'hold', on: v, until: new Date(o[2]).toISOString() }, A.did.hold(v, hhmm(o[2])));
768
+ };
769
+ return el('div', { class: 'td-day__revise', 'data-day': 'hold' }, who,
770
+ opts.length ? el('div', { class: 'td-day__ch' }, el('span', null, A.holdWhen), tabs({ variant: 'segmented', role: 'radiogroup', ariaLabel: A.holdWhen, value: ui.holdDraft.when,
771
+ attrs: { 'data-day': 'hold-when' }, items: opts.map(([k, l, t]) => ({ id: k, label: `${l} (${hhmm(t)})` })), onChange: (k) => { ui.holdDraft.when = k; rerender(); } })) : null,
772
+ el('div', { class: 'td-day__acts' }, button({ label: A.holdGo, variant: 'primary', size: 'sm', disabled: !opts.length, attrs: { 'data-day-act': 'hold-go' }, onClick: go }),
773
+ button({ label: D.cancel, size: 'sm', variant: 'ghost', onClick: () => { ui.holding = null; rerender(); } })));
774
+ }
775
+ const open0 = (mark) => !mark || !mark.state;
776
+ /** A link drawn as the primary button of a card. */
777
+ function primaryLink(label, href, attrs) {
778
+ return extLink(label, href, { class: 'td-day__a td-day__a--primary', ...(attrs || {}) });
779
+ }
780
+
781
+ /** The card of a job only the PM can do (PRD A1, C1, C2, M1, D3): the verb and one sentence, From and Why now, the
782
+ * steps, the button that fits, the marks, and the rest of the context folded. */
783
+ function actCard(ctx, j) {
784
+ const { m } = ctx;
785
+ const it = j.item || {};
786
+ const mark = j.mark || null;
787
+ const verb = verbOf(it);
788
+ const todo = it.todo || it.how || it.ask || null;
789
+ const checked = new Set((mark && mark.steps) || []);
790
+ const steps = (Array.isArray(it.steps) ? it.steps : []).map((x) => (typeof x === 'string' ? { t: x } : x)).map((x) => ({ ...x, done: checked.has(x.t) }));
791
+ const refs = Array.isArray(it.refs) ? it.refs : [];
792
+ const canSend = open0(mark) && it.owner === 'pm' && isUrl(j.link) && (!!it.send || ['reply', 'review', 'decide', 'sign-off'].includes(verb));
793
+ const due = it.due ? Date.parse(it.due) : NaN;
794
+ const n = j.goal ? m.goalNo.get(j.goal) : null;
795
+ const open = !mark || !mark.state;
796
+ const allDone = steps.length > 0 && steps.every((x) => x.done);
797
+ const reply = typeof it.reply_needed === 'boolean' ? (it.reply_needed ? D.yes : D.no) : null;
798
+ const more = dl([[D.about, it.about || null], [D.ask, it.ask || null], [D.reply, reply], [D.how, todo === it.how ? null : it.how || null]]);
799
+ return el('article', { class: 'td-day__job td-day__job--act', 'data-day-job': j.id, 'data-day-act-card': verb },
800
+ el('div', { class: 'td-day__jobtop' }, el('span', { class: 'td-day__chips' }, verbChip(verb), el('span', { class: 'td-day__src' }, [srcName(j.source), n ? D.pill.goal(n) : null].filter(Boolean).join(' · '))),
801
+ dueChip(m, mark, due, false)),
802
+ el('h3', { class: 'td-day__acth' }, j.title),
803
+ todo ? el('p', { class: 'td-day__todo', 'data-day': 'todo' }, todo) : null,
804
+ markBanner(ctx, j.id, mark, false),
805
+ dl([[D.where, A.from(it.who, it.where)], [D.why, it.why_now || null]]),
806
+ threadBlock(it.thread),
807
+ steps.length ? el('div', { class: 'td-day__phase' }, el('h4', null, A.howTitle(steps.filter((x) => x.done).length, steps.length)), stepChecks(ctx, j.id, steps, { refs })) : null,
808
+ refsBlock(refs, steps),
809
+ canSend ? composer(ctx, j, verb) : null,
810
+ open ? el('div', { class: 'td-day__acts', 'data-day': 'cta' },
811
+ (canSend ? extLink : primaryLink)(A.open[j.source] || A.openLink, j.link, { 'data-day-link': 'conversation' }),
812
+ j.item && j.item.owner === 'pm' ? button({ label: A.draft, size: 'sm', disabled: ui.busy.has('draft:' + j.id), attrs: { 'data-day-act': 'draft' }, onClick: () => draftPress(ctx.ws, j) }) : null) : null,
813
+ marksBar(ctx, { id: j.id, title: j.title, kind: 'item', due, strong: allDone }),
814
+ more ? el('details', { class: 'td-day__fold', 'data-day': 'more' }, el('summary', null, A.more), more, note(A.readOnly)) : null);
815
+ }
816
+
817
+ /** Free minutes from now to the meeting's start, less the other meetings in between. */
818
+ function freeBefore(m, e) {
819
+ if (!Number.isFinite(e.s) || e.s <= m.now) return 0;
820
+ let busy = 0;
821
+ for (const o of m.events) {
822
+ if (o.id === e.id) continue;
823
+ const a = Math.max(o.s, m.now); const b = Math.min(o.f, e.s);
824
+ if (b > a) busy += b - a;
825
+ }
826
+ return Math.max(0, Math.round((e.s - m.now - busy) / MIN_MS));
827
+ }
828
+
829
+ /** A meeting's prep (PRD A1, C1, M1, M6, D2): the verb and the prep line, how ready the PM is, the Before steps to
830
+ * check off, the button that fits, and the marks. */
831
+ function prepBlock(ctx, e) {
832
+ const { m } = ctx;
833
+ const steps = e.before.map((st) => ({ t: String(st.t || ''), who: st.who, done: st.done, ref: st.ref, note: !!st.note, fixed: !!(e.steps && e.steps.before && e.steps.before.find((x) => x.t === st.t && x.done)) }));
834
+ const refs = Array.isArray(e.refs) ? e.refs : [];
835
+ const d = steps.filter((x) => x.done).length; const total = steps.length;
836
+ const left = total - d;
837
+ const free = freeBefore(m, e);
838
+ const fit = !total ? null : !left ? A.fit.ok : free >= left * 15 ? A.fit.time(left, free) : A.fit.short(left, free);
839
+ const open = !e.mark || !e.mark.state;
840
+ const link = Array.isArray(e.links) ? e.links.find((l) => isUrl(l.url)) : null;
841
+ return el('div', { class: 'td-day__prep', 'data-day': 'prep' },
842
+ el('div', { class: 'td-day__jobtop' }, verbChip('prepare'), dueChip(m, e.mark, e.s, true)),
843
+ e.prep ? el('p', { class: 'td-day__todo', 'data-day': 'todo' }, e.prep) : null,
844
+ markBanner(ctx, e.id, e.mark, true),
845
+ total ? el('div', { class: 'td-day__ready', 'data-day': 'readiness' }, meter({ done: d, total, label: A.readiness(d, total) }), fit ? el('span', { class: 'td-day__dim' }, fit) : null) : null,
846
+ total ? el('div', { class: 'td-day__phase' }, el('h4', null, A.beforeTitle(e.time)), stepChecks(ctx, e.id, steps, { refs, mark: e.mark })) : null,
847
+ refsBlock(refs, steps),
848
+ open && link && left ? el('div', { class: 'td-day__acts', 'data-day': 'cta' }, primaryLink((link.label || link.url) + ' ↗', link.url, { 'data-day-link': 'prep' })) : null,
849
+ marksBar(ctx, { id: e.id, title: e.title, kind: 'event', start: e.s, strong: total > 0 && !left }));
850
+ }
851
+
526
852
  /* ── the right column ─────────────────────────────────────────────────────────────────────────────────────────── */
527
853
 
528
854
  function dhead(kind, title) {
@@ -563,27 +889,31 @@ function eventDetail(ctx, e) {
563
889
  el('p', { class: 'td-day__dim td-day__mono' }, `${e.time}${e.end ? '–' + hhmm(e.end) : ''}${e.with ? ' · ' + e.with : ''}${e.ended ? ' · ' + D.ended : ''}`),
564
890
  e.with ? peopleLinks(ctx.ws, e.with, { 'data-day': 'people' }) : null,
565
891
  e.purpose ? el('p', null, el('b', null, D.purpose + ': '), e.purpose) : null,
566
- e.prep ? el('p', null, el('b', null, D.prep + ': '), e.prep) : null,
892
+ e.needsPrep ? prepBlock(ctx, e) : null,
567
893
  ini ? el('div', { class: 'td-day__ini', 'data-day': 'initiative' }, el('span', null, D.initiative + ': ', el('b', { class: 'td-day__mono' }, ini.id), ini.why ? el('span', { class: 'td-day__dim' }, ' (' + D.suggested(ini.why) + ')') : null),
568
894
  ctx.openIni ? button({ label: D.openInitiative, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'open-initiative' }, onClick: () => ctx.openIni(ini.id) }) : null) : null,
569
895
  tie,
570
896
  memoryBlock(e.id),
571
- !e.ended ? el('div', { class: 'td-day__acts' }, e.prep ? pill(D.needPrep, 'warn') : steps.before && steps.before.length ? pill(D.nothingToPrep, 'ok') : null) : null,
572
897
  after,
573
898
  notesBlock(e, { ws: ctx.ws, initiative: ini }),
574
- !e.ended ? [list(D.phases.before, (steps.before || []).map(stepItem)), list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
899
+ // with prep, the Before steps are the checklist above; During, After and the mail fold away (PRD D2)
900
+ !e.ended && e.needsPrep && ((steps.during || []).length || (steps.after || []).length || mailBlock)
901
+ ? el('details', { class: 'td-day__fold', 'data-day': 'more' }, el('summary', null, A.moreMeeting((steps.during || []).length, (steps.after || []).length)),
902
+ list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock) : null,
903
+ !e.ended && !e.needsPrep ? [list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
575
904
  Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
576
905
  ];
577
906
  }
578
907
 
579
908
  function jobCard(ctx, j) {
909
+ if (j.status === 'you') return actCard(ctx, j);
580
910
  const { m, ws } = ctx;
581
911
  const t = j.task;
582
912
  const it = j.item || {};
583
913
  const n = j.goal ? m.goalNo.get(j.goal) : null;
584
914
  const busy = t && ui.busy.has(t.id);
585
915
  const revs = t && Array.isArray(t.revisions) ? t.revisions.length : 0;
586
- const statusNote = { notStarted: D.notTask, waiting: D.waitingNote, working: D.workingNote, sending: D.sendingNote, you: D.youNote }[j.status]
916
+ const statusNote = { notStarted: D.notTask, waiting: D.waitingNote, working: D.workingNote, sending: D.sendingNote }[j.status]
587
917
  || (j.status === 'done' && j.kind === 'auto' ? D.autoNote : null);
588
918
  const draft = t && t.draft ? el('div', { class: 'td-day__draftbox', 'data-day': 'draft' }, el('h4', null, D.draft),
589
919
  t.draft.summary ? el('p', null, t.draft.summary) : null,
@@ -902,6 +1232,6 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
902
1232
 
903
1233
  export function daySig(ws) {
904
1234
  const c = cache.get(ws) || {};
905
- return JSON.stringify([forced, ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], c.error, runs.get(ws) || null, [...seen].length])
1235
+ return JSON.stringify([forced, ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], [...ui.groupsOpen], ui.holding, ui.holdDraft.when, [...ui.verdict], c.error, runs.get(ws) || null, [...seen].length])
906
1236
  + dataSig(c.data) + goalsSig() + agendaSig();
907
1237
  }
@@ -98,6 +98,7 @@ const briefLib = require('./lib/brief');
98
98
  const calMemLib = require('./lib/calendar-memory');
99
99
  const goalsLib = require('./lib/goals');
100
100
  const dayRunLib = require('./lib/day-run');
101
+ const dayMarksLib = require('./lib/day-marks');
101
102
  const todayLib = require('./lib/today');
102
103
  const prepareQueue = require('./lib/prepare-queue');
103
104
  const harness = require('./lib/harness-parse');
@@ -3842,7 +3843,7 @@ function startDayTask(run, t, now) {
3842
3843
  }
3843
3844
  /** My work's Run agent (initiative my-work, PRD R5): add the briefed task to today's run and start what may start. A day
3844
3845
  * with no run gets one, marked opened by My work, in the repository the PM pressed from. Returns { ok, run } or why not. */
3845
- function myWorkBrief(spec, wsId) {
3846
+ function myWorkBrief(spec, wsId, add) {
3846
3847
  const now = Date.now(); const date = briefLib.localDate(now);
3847
3848
  let run = readDayRun(date);
3848
3849
  if (!run) {
@@ -3850,7 +3851,8 @@ function myWorkBrief(spec, wsId) {
3850
3851
  if (!w) return { ok: false, status: 400, error: 'no workspace to run the agent in' };
3851
3852
  run = { ...dayRunLib.newRun(date, now), ws: w.id, opened_by: 'my-work' };
3852
3853
  }
3853
- const a = dayRunLib.addBriefed(run, spec);
3854
+ // `add` is how the task joins the run: briefed by default; Send from a Your day card adds the PM's own text (addSend)
3855
+ const a = add ? add(run, spec, now) : dayRunLib.addBriefed(run, spec);
3854
3856
  if (!a.ok) return { ok: false, status: 409, error: a.error };
3855
3857
  const next = startDayTasks(a.run, now);
3856
3858
  if (!writeDayRun(next)) return { ok: false, status: 500, error: 'could not save — nothing changed' };
@@ -3940,6 +3942,30 @@ function writeCalMemory(m) {
3940
3942
  fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(tmp, JSON.stringify(m) + '\n'); fs.renameSync(tmp, f);
3941
3943
  }
3942
3944
 
3945
+ /* The PM's marks on today's items and meetings (initiative today-act-prepare, PRD M5): done or ready, snoozed, waiting on
3946
+ * someone, not mine, and the steps checked. goals/<date>.marks.json, written only by POST /api/proactive/day/mark. Kept outside the
3947
+ * proactive, prepare and day-run blocks, whose boundary tests list the only files they write. */
3948
+ const dayMarksFile = (date) => path.join(GOALS_DIR(), date + '.marks.json');
3949
+ function readDayMarks(date) {
3950
+ let raw = null;
3951
+ try { raw = JSON.parse(fs.readFileSync(dayMarksFile(date), 'utf8')); } catch { /* none yet */ }
3952
+ return dayMarksLib.readMarks(raw, date);
3953
+ }
3954
+ function writeDayMarks(rec) {
3955
+ try {
3956
+ fs.mkdirSync(GOALS_DIR(), { recursive: true });
3957
+ const f = dayMarksFile(rec.date); const tmp = `${f}.${process.pid}.tmp`;
3958
+ fs.writeFileSync(tmp, JSON.stringify(rec) + '\n'); fs.renameSync(tmp, f); return true;
3959
+ } catch (e) { warnWriteFail('day marks', e); return false; }
3960
+ }
3961
+ /** The ids a mark may name: today's brief items ('item') and calendar events ('event'). */
3962
+ function dayMarkIds(brief) {
3963
+ const known = new Map();
3964
+ if (!brief) return known;
3965
+ for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) known.set(x.id, 'item');
3966
+ for (const e of brief.calendar || []) known.set(e.id, 'event');
3967
+ return known;
3968
+ }
3943
3969
  // ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
3944
3970
  // The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
3945
3971
  // `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
@@ -7774,7 +7800,7 @@ async function handleRequest(req, res) {
7774
7800
  : !bs ? { session: null, why: 'the morning-brief session did not start.' }
7775
7801
  : (!!bs.ended_at || !bsess || bsess.state === 'exited') ? { session: bs.id, why: (bsess && bsess.note) || 'the session ended without submitting a brief.' }
7776
7802
  : briefSessionDone(run, date, now) === 'idle' ? { session: bs.id, idle: true, why: 'the session has printed nothing for two minutes and has not submitted a brief.' } : null;
7777
- return json(res, { ok: true, ws: w ? w.id : null, today: date, now: new Date(now).toISOString(), run, stage: dayRunLib.stage(run, brief), brief, plan, cards_error: cardsError, read_failed: readFailed,
7803
+ return json(res, { ok: true, ws: w ? w.id : null, today: date, now: new Date(now).toISOString(), run, stage: dayRunLib.stage(run, brief), brief, plan, marks: dayMarksLib.effective(readDayMarks(date), now), cards_error: cardsError, read_failed: readFailed,
7778
7804
  settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
7779
7805
  // one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
7780
7806
  repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
@@ -7797,6 +7823,58 @@ async function handleRequest(req, res) {
7797
7823
  if (!writeCalMarks(r.marks)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
7798
7824
  return json(res, { ok: true });
7799
7825
  }
7826
+ /* The PM's mark on an item or a meeting of today's brief (initiative today-act-prepare, PRD M1 to M6). It needs no day
7827
+ * run: a mark is about the brief, and only this press writes goals/<date>.marks.json. The answer carries `prev`, the
7828
+ * mark before the press, so the page's Undo can put it back with { action: 'restore', mark: prev }. */
7829
+ if (p === '/api/proactive/day/mark' && req.method === 'POST') {
7830
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7831
+ const now = Date.now(); const date = briefLib.localDate(now);
7832
+ const stored = briefOfDay(date);
7833
+ const brief = stored && stored.date === date ? stored : null;
7834
+ if (!brief) return json(res, { ok: false, error: 'no brief today — press Prepare my day first' }, 409);
7835
+ const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief) });
7836
+ if (!r.ok) return json(res, { ok: false, error: r.error }, /no item or meeting with that id/.test(r.error) ? 404 : 400);
7837
+ if (!writeDayMarks(r.rec)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
7838
+ return json(res, { ok: true, mark: r.mark, prev: r.prev, marks: dayMarksLib.effective(r.rec, now) });
7839
+ }
7840
+ /* "Have the agent draft it" on a job the PM owns (PRD C2): one briefed task, kind approve, tied to the item, through the
7841
+ * My work path. The day-task session drafts and sends nothing; the PM approves or revises the draft as for any job. */
7842
+ if (p === '/api/proactive/day/draft' && req.method === 'POST') {
7843
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7844
+ const now = Date.now(); const date = briefLib.localDate(now);
7845
+ const stored = briefOfDay(date);
7846
+ const brief = stored && stored.date === date ? stored : null;
7847
+ const x = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])].find((y) => y.id === String(body.item || '')) : null;
7848
+ if (!x) return json(res, { ok: false, error: 'no item with that id in today\'s brief' }, 404);
7849
+ if (x.owner !== 'pm') return json(res, { ok: false, error: 'the agent already does this job: approve or revise its draft' }, 409);
7850
+ const verb = { reply: 'reply', review: 'review notes', decide: 'answer', 'sign-off': 'sign-off comment', read: 'summary', do: 'draft' }[x.verb] || (x.reply_needed ? 'reply' : 'draft');
7851
+ const spec = { id: 't-' + x.id, item_id: x.id, title: x.title, source: x.source, link: x.link || null,
7852
+ instruction: `Draft the PM's ${verb} for this item: ${x.todo || x.how || x.ask || x.title}. Draft only. Send nothing: the PM approves it first.`,
7853
+ context: { from: x.who || null, where: x.where || null, about: x.about || null, ask: x.ask || null, how: x.how || null, why_now: x.why_now || null } };
7854
+ const r = myWorkBrief(spec, body.ws);
7855
+ if (!r.ok) return json(res, { ok: false, error: r.error }, r.status || 400);
7856
+ return json(res, { ok: true, task: r.task });
7857
+ }
7858
+ /* Send from the card (PRD F1 to F3): the PM wrote the reply or the review in the card and pressed Send. The press is the
7859
+ * approval, so the task goes straight to sending with the PM's text, and one send session posts it as written to the
7860
+ * place the item names. Nothing is sent without this press (D65 propose-only). */
7861
+ if (p === '/api/proactive/day/send' && req.method === 'POST') {
7862
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7863
+ const now = Date.now(); const date = briefLib.localDate(now);
7864
+ const stored = briefOfDay(date);
7865
+ const brief = stored && stored.date === date ? stored : null;
7866
+ const x = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])].find((y) => y.id === String(body.item || '')) : null;
7867
+ if (!x) return json(res, { ok: false, error: 'no item with that id in today\'s brief' }, 404);
7868
+ if (x.owner !== 'pm') return json(res, { ok: false, error: 'the agent already does this job: approve or revise its draft' }, 409);
7869
+ if (!x.link) return json(res, { ok: false, error: 'this item has no link, so there is no place to send to — open it and answer there' }, 409);
7870
+ const review = !!(x.send && x.send.review);
7871
+ if (review && !['approve', 'changes'].includes(body.verdict)) return json(res, { ok: false, error: 'a review needs a verdict: approve or changes' }, 400);
7872
+ const spec = { id: 't-' + x.id, item_id: x.id, title: x.title, source: x.source, link: x.link, to: (x.send && x.send.to) || x.where || null,
7873
+ text: body.text, verdict: review ? body.verdict : null };
7874
+ const r = myWorkBrief(spec, body.ws, dayRunLib.addSend);
7875
+ if (!r.ok) return json(res, { ok: false, error: r.error }, r.status || 400);
7876
+ return json(res, { ok: true, task: r.task });
7877
+ }
7800
7878
  if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
7801
7879
  const verb = p.slice('/api/proactive/day/'.length);
7802
7880
  if (!['deliver', 'sent', 'fail', 'decide', 'check'].includes(verb)) return json(res, { ok: false, error: 'unknown day-run action' }, 404);