create-pmos 0.54.0 → 0.55.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.
@@ -1,206 +1,141 @@
1
- /* pm/today-goals.js — the goals for today at the top of Today (initiative today-goals, PRD G5 and G6).
1
+ /* pm/today-goals.js — the goals for today inside the day section (initiative today-goals, PRD G5, G6 and G16).
2
2
  *
3
- * goalsSection({ ws, setState }) → the section node, or null in fixture mode
4
- * goalsSig(ws) → a string that changes whenever what the section shows changes
3
+ * goalRows(ctx) → the left column's goal rows: number, text, jobs done, a small bar
4
+ * goalAdd(ctx) → the add input under them ("Add a goal and press Enter")
5
+ * goalDetail(ctx, goalId) → the right column's detail of one goal: its meetings, its jobs, the steps found, and the
6
+ * presses done, drop, reopen and remove; on an agent step that names an initiative, Prepare
7
+ * then Queue it (the shipped background prepare: one approval is one prompt hash)
8
+ * addGoal(ctx, text, ini) → POST the add; true when the goal is on the list
9
+ * goalsSig() → a string that changes whenever what this module draws changes on its own
5
10
  *
6
- * The server joins the goals to what the morning-brief session read and to Today's cards (lib/goals.js planDay, GET /api/proactive/goals).
7
- * This module draws that answer and sends the PM's presses: add, done, drop, reopen, remove, "Prepare my day" (runs the
8
- * morning-brief schedule) and, on an agent step that names an initiative, Prepare then Queue it (the shipped background
9
- * prepare: one approval is one prompt hash).
10
- *
11
- * FIRST PAINT. Folded, the section is one control (its toggle) and a list of the goals as text, so Today keeps inside its
12
- * control budget (first-paint-today.test.js). The input and the buttons are drawn only when the PM opens it.
11
+ * `ctx` is the day section's (today-day.js): { ws, m (the day model), select(type, id), reload(), rerender(), cur(type, id) }.
12
+ * The server joins the goals to the brief and Today by goal id or initiative (lib/goals.js planDay). Nothing here guesses.
13
13
  */
14
14
 
15
15
  import { el } from './dom.js';
16
- import { button, field, badge, toast } from './primitives.js';
17
- import { pmGet, pmPost } from './http.js';
18
- import { FIXTURE } from './state.js';
16
+ import { button, field, badge, toast, row, meter } from './primitives.js';
17
+ import { pmPost } from './http.js';
19
18
  import { COPY } from './today-copy.js';
20
19
 
21
20
  const G = COPY.goals;
22
- const POLL_MS = 30000;
23
- const cache = new Map(); // ws -> { data, error, at }
24
- let open = false;
21
+ const D = COPY.day;
25
22
  let draft = '';
26
23
  const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
27
- let rerender = () => {};
28
- let timer = null;
29
-
30
- async function load(ws) {
31
- const out = await pmGet('/api/proactive/goals' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
32
- const prev = cache.get(ws);
33
- const next = out && out.ok ? { data: out, error: null, at: Date.now() } : { data: prev ? prev.data : null, error: (out && out.error) || G.unreached, at: Date.now() };
34
- cache.set(ws, next);
35
- if (!prev || JSON.stringify(prev.data) !== JSON.stringify(next.data) || prev.error !== next.error) rerender();
36
- }
37
24
 
38
- function ensure(ws) {
39
- if (!cache.has(ws)) { cache.set(ws, { data: null, error: null, at: 0 }); load(ws); }
40
- if (!timer && typeof window !== 'undefined') {
41
- timer = setInterval(() => { for (const w of cache.keys()) load(w); }, POLL_MS);
42
- }
43
- }
44
-
45
- async function press(ws, body) {
25
+ async function press(ctx, body) {
46
26
  const r = await pmPost('/api/proactive/goals', body);
47
27
  if (!r || r.ok === false) { toast({ text: G.refused((r && r.error) || G.unreached), tone: 'destructive' }); return false; }
48
- await load(ws);
28
+ await ctx.reload();
49
29
  return true;
50
30
  }
51
31
 
52
- // "Prepare my day", per repository: { state: 'busy' | 'started' | 'failed', at, session, error }. The session runs in the
53
- // background, so the section says what happened and offers to open it; a toast alone is gone before the PM looks.
54
- const runs = new Map();
32
+ /** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. */
33
+ export function addGoal(ctx, text, ini) {
34
+ const body = { action: 'add', text: String(text || '').trim().slice(0, 140) };
35
+ if (ini) body.initiative_id = ini;
36
+ return press(ctx, body);
37
+ }
55
38
 
56
- async function prepareDay(ws) {
57
- const cur = runs.get(ws);
58
- if (cur && cur.state === 'busy') return;
59
- runs.set(ws, { state: 'busy' });
60
- rerender();
61
- const r = await pmPost('/api/proactive/goals/plan', { ws });
62
- const sid = r && r.result && r.result.sessionId;
63
- if (r && r.ok) {
64
- const session = sid ? { id: sid, name: 'morning-brief' } : null;
65
- runs.set(ws, { state: 'started', at: Date.now(), session });
66
- toast({ text: G.started });
67
- if (session) openSession(session);
68
- } else {
69
- const error = (r && r.error) || G.unreached;
70
- runs.set(ws, { state: 'failed', at: Date.now(), error });
71
- toast({ text: G.runFailed(error), tone: 'destructive' });
72
- }
73
- rerender();
74
- load(ws);
39
+ /** The jobs of a goal: the day model's jobs that name it. */
40
+ const jobsOf = (m, id) => m.jobs.filter((j) => j.goal === id && !j.worth);
41
+
42
+ export function goalRows(ctx) {
43
+ const { m } = ctx;
44
+ if (!m.goals.length) return null;
45
+ return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g, i) => {
46
+ const js = jobsOf(m, g.id);
47
+ const done = js.filter((j) => j.group === 'done').length;
48
+ const need = js.filter((j) => j.group === 'needs').length;
49
+ const closed = g.status !== 'open';
50
+ const sub = closed ? (G.coverage[g.coverage] || g.status) : js.length ? D.goalJobs(done, js.length) + (need ? ' · ' + D.goalNeed(need) : '') : (G.coverage[g.coverage] || D.goalNone);
51
+ const cur = ctx.cur('goal', g.id);
52
+ return el('li', null, row({
53
+ title: g.text, meta: [sub], done: closed, onOpen: () => ctx.select('goal', g.id),
54
+ lead: [el('span', { class: 'td-day__lead' }, String(i + 1))],
55
+ trail: [meter({ done: js.length ? done : g.status === 'done' ? 1 : 0, total: js.length || 1, label: '' })],
56
+ attrs: { class: 'td-day__r', 'data-day-sel': 'goal:' + g.id, 'data-goal': g.id, 'data-goal-coverage': g.coverage, 'data-cur': cur },
57
+ openAttrs: { 'aria-current': cur },
58
+ }));
59
+ }));
75
60
  }
76
61
 
77
- async function openSession(session) {
78
- try { const m = await import('./handoff-box.js'); await m.openSessionTab(null, session); } catch (e) { console.error('[pm] could not open the session:', e); }
62
+ export function goalAdd(ctx) {
63
+ const { m } = ctx;
64
+ const max = m.maxGoals;
65
+ if (m.goals.length >= max) return el('p', { class: 'td-day__note' }, G.full(max));
66
+ const f = field({ label: G.inputLabel, value: '', placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', 'data-day-input': 'goal', maxlength: '140' },
67
+ onCommit: async (v) => {
68
+ if (!String(v).trim()) { draft = v; return; }
69
+ // clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
70
+ draft = '';
71
+ if (await addGoal(ctx, v)) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; ctx.rerender(); }
72
+ else { draft = v; ctx.rerender(); }
73
+ } });
74
+ // a rebuild keeps what is typed. The field is made empty and filled after, so Enter still commits the restored text.
75
+ f.control.value = draft;
76
+ f.control.addEventListener('input', () => { draft = f.control.value; });
77
+ return el('div', { class: 'td-day__goaladd' }, f);
79
78
  }
80
79
 
81
- async function prepareStep(ws, st) {
82
- const r = await pmPost('/api/proactive/prepare/plan', { ws, initiative_id: st.initiative_id });
80
+ async function prepareStep(ctx, st) {
81
+ const r = await pmPost('/api/proactive/prepare/plan', { ws: ctx.ws, initiative_id: st.initiative_id });
83
82
  if (!r || r.ok === false) { toast({ text: G.refused((r && r.error) || G.unreached), tone: 'destructive' }); return; }
84
83
  if (r.refused) { toast({ text: G.refused(r.refused.why) }); return; }
85
- prepared.set(st.ref, { key: r.item.key, branch: r.plan && r.plan.branch, item_id: r.item.id, prompt_sha: r.prompt_sha, initiative_id: st.initiative_id, ws: r.ws || ws });
86
- rerender();
84
+ prepared.set(st.ref, { key: r.item.key, branch: r.plan && r.plan.branch, item_id: r.item.id, prompt_sha: r.prompt_sha, initiative_id: st.initiative_id, ws: r.ws || ctx.ws });
85
+ ctx.rerender();
87
86
  }
88
87
 
89
- async function queueStep(ref) {
88
+ async function queueStep(ctx, ref) {
90
89
  const p = prepared.get(ref);
91
90
  if (!p) return;
92
91
  const r = await pmPost('/api/proactive/prepare/queue', { confirm: true, ws: p.ws, initiative_id: p.initiative_id, item_id: p.item_id, prompt_sha: p.prompt_sha });
93
92
  prepared.delete(ref);
94
93
  toast({ text: r && r.ok ? G.queued(p.key) : G.refused((r && r.error) || G.unreached), tone: r && r.ok ? undefined : 'destructive' });
95
- rerender();
94
+ ctx.rerender();
96
95
  }
97
96
 
98
- const hhmm = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? String(iso) : `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
99
-
100
- function stepNode(ws, st, opened) {
101
- const who = st.owner === 'pm' ? G.you : G.agent;
97
+ function stepNode(ctx, st) {
102
98
  const meta = [st.source, st.mention ? G.mention : null, st.effort].filter(Boolean).join(' · ');
103
99
  const p = prepared.get(st.ref);
104
- const acts = !opened || !st.prepare ? null
100
+ const acts = !st.prepare ? null
105
101
  : p ? el('span', { class: 'td-goal__prep' }, el('span', { class: 'td-goal__prep-text' }, G.prepareShows(p.key, p.branch)),
106
- button({ label: G.queue, size: 'sm', attrs: { 'data-goal-queue': st.ref }, onClick: () => queueStep(st.ref) }))
107
- : button({ label: G.prepare, size: 'sm', variant: 'ghost', attrs: { 'data-goal-prepare': st.ref }, onClick: () => prepareStep(ws, st) });
102
+ button({ label: G.queue, size: 'sm', attrs: { 'data-goal-queue': st.ref }, onClick: () => queueStep(ctx, st.ref) }))
103
+ : button({ label: G.prepare, size: 'sm', variant: 'ghost', attrs: { 'data-goal-prepare': st.ref }, onClick: () => prepareStep(ctx, st) });
108
104
  return el('li', { class: 'td-goal__step', 'data-goal-step': st.ref },
109
- badge({ text: who, tone: st.owner === 'pm' ? 'outline' : 'muted' }),
105
+ badge({ text: st.owner === 'pm' ? G.you : G.agent, tone: st.owner === 'pm' ? 'outline' : 'muted' }),
110
106
  el('span', { class: 'td-goal__step-title' }, st.title),
111
107
  meta ? el('span', { class: 'td-goal__meta' }, meta) : null,
112
108
  acts);
113
109
  }
114
110
 
115
- function goalNode(ws, g, opened) {
111
+ /** The detail of one goal. `row(type, id, label, lead, trail)` draws a selectable row (today-day.js). */
112
+ export function goalDetail(ctx, id, row) {
113
+ const { m } = ctx;
114
+ const i = m.goals.findIndex((g) => g.id === id);
115
+ if (i < 0) return null;
116
+ const g = m.goals[i];
116
117
  const closed = g.status !== 'open';
117
- const ctrls = opened ? el('span', { class: 'td-goal__ctrls' },
118
- ...(closed
119
- ? [button({ label: G.reopen, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ws, { action: 'reopen', id: g.id }) })]
120
- : [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ws, { action: 'done', id: g.id }) }),
121
- button({ label: G.drop, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'drop' }, onClick: () => press(ws, { action: 'drop', id: g.id }) })]),
122
- button({ label: G.remove, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'remove' }, onClick: () => press(ws, { action: 'remove', id: g.id }) })) : null;
123
- const ctx = (g.context || []).map((e) => el('li', { class: 'td-goal__ctx' }, `${hhmm(e.start)} ${e.title}${e.with ? ' · ' + e.with : ''}`));
124
- const body = opened && !closed ? el('div', { class: 'td-goal__body' },
125
- ctx.length ? el('ul', { class: 'td-goal__ctx-list' }, ...ctx) : null,
126
- g.steps.length ? el('ul', { class: 'td-goal__steps' }, ...g.steps.map((st) => stepNode(ws, st, opened))) : el('p', { class: 'td-goal__none' }, G.noStep)) : null;
127
- return el('li', { class: 'td-goal' + (closed ? ' td-goal--closed' : ''), 'data-goal': g.id, 'data-goal-coverage': g.coverage },
128
- el('div', { class: 'td-goal__head' },
129
- el('span', { class: 'td-goal__text' }, g.text),
130
- badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'muted' }),
131
- g.initiative_id ? el('span', { class: 'td-goal__meta' }, g.initiative_id) : null,
132
- ctrls),
133
- body);
134
- }
135
-
136
- function stepList(ws, title, steps, attr) {
137
- if (!steps.length) return null;
138
- return el('div', { class: 'td-goals__also', 'data-goals': attr },
139
- el('p', { class: 'td-goals__sub' }, title),
140
- el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ws, st, true))));
141
- }
142
-
143
- /** The section. `ws` is the repository whose Today cards the goals are joined to. */
144
- export function goalsSection({ ws, setState } = {}) {
145
- if (FIXTURE) return null;
146
- // like Today's own feeds, a read that lands re-renders only while the section is on the page: a hidden Today must not
147
- // cause a whole-page render under whatever the PM is doing elsewhere
148
- if (setState) rerender = () => { if (document.querySelector('[data-today="goals"]')) setState({}); };
149
- ensure(ws);
150
- const c = cache.get(ws) || {};
151
- const d = c.data;
152
- const plan = d && d.plan;
153
- const goals = plan ? plan.goals : [];
154
- const openN = goals.filter((g) => g.status === 'open').length;
155
- const toggle = button({ label: G.toggle(openN, goals.length), variant: 'ghost', iconEnd: open ? 'chevron-down' : 'chevron-right',
156
- attrs: { 'data-goals': 'toggle', 'aria-expanded': open ? 'true' : 'false' }, onClick: () => { open = !open; rerender(); } });
157
- const list = goals.length ? el('ol', { class: 'td-goals__list', 'data-goals': 'list' }, ...goals.map((g) => goalNode(ws, g, open))) : null;
158
- const kids = [el('div', { class: 'td-goals__head' }, toggle), list];
159
- if (c.error) kids.push(el('p', { class: 'td-goals__note', role: 'status' }, G.refused(c.error)));
160
- if (open) {
161
- if (!goals.length) kids.push(el('p', { class: 'td-goals__note' }, G.intro));
162
- const max = (d && d.max_goals) || 7;
163
- if (goals.length < max) {
164
- const f = field({ label: G.inputLabel, value: draft, placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', maxlength: '140' },
165
- onCommit: async (v) => {
166
- if (!String(v).trim()) { draft = v; return; }
167
- // clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
168
- draft = '';
169
- if (await press(ws, { action: 'add', text: v })) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; rerender(); }
170
- else { draft = v; rerender(); }
171
- } });
172
- f.control.addEventListener('input', () => { draft = f.control.value; }); // a rebuild keeps what is typed
173
- kids.push(f);
174
- } else kids.push(el('p', { class: 'td-goals__note' }, G.full(max)));
175
- const run = runs.get(ws);
176
- const busy = !!run && run.state === 'busy';
177
- kids.push(el('div', { class: 'td-goals__bar' },
178
- button({ label: busy ? G.starting : G.prepareDay, attrs: { 'data-goals': 'prepare-day', title: G.prepareDayHint, 'aria-busy': busy ? 'true' : 'false' },
179
- disabled: !openN || busy, onClick: () => prepareDay(ws) }),
180
- run && run.state === 'started' && run.session
181
- ? button({ label: G.openSession, variant: 'ghost', attrs: { 'data-goals': 'open-session' }, onClick: () => openSession(run.session) }) : null));
182
- if (run && run.state !== 'busy') {
183
- kids.push(el('p', { class: 'td-goals__run' + (run.state === 'failed' ? ' td-goals__run--failed' : ''), 'data-goals': 'run', 'data-run': run.state,
184
- role: run.state === 'failed' ? 'alert' : 'status' }, run.state === 'failed' ? G.runFailed(run.error) : G.runStarted(hhmm(run.at))));
185
- }
186
- if (plan) {
187
- const lines = [];
188
- if (!plan.brief) lines.push(G.noBrief);
189
- else if (!plan.brief.fresh) lines.push(G.stale(plan.brief.date));
190
- if (plan.time) {
191
- lines.push(G.free(plan.time.free_minutes, plan.time.until));
192
- if (plan.time.next) lines.push(G.comingUp(plan.time.next.title, hhmm(plan.time.next.start)));
193
- }
194
- if (plan.not_read && plan.not_read.length) lines.push(G.notRead(plan.not_read.map((x) => x.source).join(', ')));
195
- if (lines.length) kids.push(el('p', { class: 'td-goals__note', 'data-goals': 'context' }, lines.join(' ')));
196
- kids.push(stepList(ws, G.also, plan.also || [], 'also'));
197
- kids.push(stepList(ws, G.worthAsking, plan.worth_asking || [], 'worth-asking'));
198
- }
199
- }
200
- return el('section', { class: 'td-goals', 'data-today': 'goals', 'aria-label': G.title }, ...kids.filter(Boolean));
118
+ const js = jobsOf(m, g.id);
119
+ const meets = m.events.filter((e) => e.goal === g.id);
120
+ const ctrls = closed
121
+ ? [button({ label: G.reopen, size: 'sm', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ctx, { action: 'reopen', id: g.id }) })]
122
+ : [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ctx, { action: 'done', id: g.id }) }),
123
+ button({ label: G.drop, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'drop' }, onClick: () => press(ctx, { action: 'drop', id: g.id }) })];
124
+ ctrls.push(button({ label: G.remove, size: 'sm', variant: 'ghost', attrs: { 'data-goal-act': 'remove' },
125
+ onClick: async () => { if (await press(ctx, { action: 'remove', id: g.id })) ctx.select(null, null); } }));
126
+ const steps = (g.steps || []).filter((st) => st.from !== 'brief' || st.prepare); // brief items show as jobs; Today cards and preparable steps here
127
+ return [
128
+ el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind' }, D.kind.goal(i + 1)), el('h3', null, g.text)),
129
+ el('div', { class: 'td-day__acts' }, badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'outline' }),
130
+ g.initiative_id ? el('span', { class: 'td-day__mono' }, g.initiative_id) : null),
131
+ js.length ? el('p', null, D.goalJobs(js.filter((j) => j.group === 'done').length, js.length)) : el('p', { class: 'td-day__note' }, G.noStep),
132
+ meets.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.meetings), el('ul', { class: 'td-day__rows' }, ...meets.map((e) => row('event', e.id, e.title, e.time)))) : null,
133
+ js.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.jobs), el('ul', { class: 'td-day__rows' }, ...js.map((j) => row('job', j.id, j.title, '', j)))) : null,
134
+ !closed && steps.length ? el('div', { class: 'td-day__rel', 'data-goals': 'steps' }, el('h4', null, D.stepsTitle), el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ctx, st)))) : null,
135
+ el('div', { class: 'td-day__acts' }, ...ctrls),
136
+ ];
201
137
  }
202
138
 
203
- export function goalsSig(ws) {
204
- const c = cache.get(ws) || {};
205
- return JSON.stringify([open, draft === '', c.data && c.data.plan, c.error, [...prepared.keys()], runs.get(ws) || null]);
139
+ export function goalsSig() {
140
+ return JSON.stringify([draft === '', [...prepared.keys()]]);
206
141
  }
@@ -40,6 +40,16 @@
40
40
  "I6 an unknown brief source is refused with the allowed names"
41
41
  ]
42
42
  },
43
+ {
44
+ "id": "STG-MAX30",
45
+ "file": "workspace/proactive.test.js",
46
+ "run": "today-goals",
47
+ "ref": "planning/prd/today-goals-prd.md G9 (a brief holds at most 30 items)",
48
+ "reason": "G8 asserted that 13 items are refused with 'at most 12'. today-goals G9 raises the limit to 30, so it now sends 31 items and expects 'at most 30'; the event limit and the field limits are asserted unchanged.",
49
+ "names": [
50
+ "G8 too many items, too many events and an over-long field are refused"
51
+ ]
52
+ },
43
53
  {
44
54
  "id": "S6-D86",
45
55
  "file": "workspace/d86-seams.test.js",
@@ -95,6 +95,7 @@ const proactiveLib = require('./lib/proactive');
95
95
  const learnLib = require('./lib/learn');
96
96
  const briefLib = require('./lib/brief');
97
97
  const goalsLib = require('./lib/goals');
98
+ const dayRunLib = require('./lib/day-run');
98
99
  const todayLib = require('./lib/today');
99
100
  const prepareQueue = require('./lib/prepare-queue');
100
101
  const harness = require('./lib/harness-parse');
@@ -2325,7 +2326,7 @@ function checkDueSchedules() {
2325
2326
  // PMOS_TICK_MS exists for the suites (same footing as PMOS_LIVENESS_MS): proactive-prepare.test.js
2326
2327
  // used to wait on two real minutes of this clock. Unset, the interval is the 60 seconds it always was.
2327
2328
  const TICK_MS = Math.max(200, Number(process.env.PMOS_TICK_MS) || 60000);
2328
- try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } autoSync.tick().catch(() => {}); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2329
+ try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } try { dayTick(); } catch (e) { warnWriteFail('day run tick', e); } autoSync.tick().catch(() => {}); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2329
2330
 
2330
2331
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2331
2332
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -3572,6 +3573,103 @@ function prepareHandoff(body) {
3572
3573
  };
3573
3574
  }
3574
3575
 
3576
+ // ── Today day run (G13, G14, G21, G22; initiative today-goals) ───────────────────────────────────────────────────
3577
+ // The PM's press of "Prepare my day" records a run in goals/<date>.run.json beside the goals file. These helpers are
3578
+ // the only code that writes that file and the only code that starts its sessions: one per task through the `day-task`
3579
+ // skill (mode do, revise or send) and one per check through the `day-check` skill, both through spawnSession as
3580
+ // fireSchedule starts a scheduled agent skill. Every rule (which task, how many at once, when a check is due) is in
3581
+ // lib/day-run.js; this only reads, starts and writes. Nothing here runs for a day the PM did not press.
3582
+ const dayRunFile = (date) => path.join(GOALS_DIR(), date + '.run.json');
3583
+ function readDayRun(date) {
3584
+ let raw = null;
3585
+ try { raw = JSON.parse(fs.readFileSync(dayRunFile(date), 'utf8')); } catch { /* no run that day */ }
3586
+ const run = dayRunLib.readRun(raw, date);
3587
+ if (run && raw && typeof raw.ws === 'string') run.ws = raw.ws; // the repository the PM pressed in; the sessions run there
3588
+ return run;
3589
+ }
3590
+ function writeDayRun(run) {
3591
+ try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(dayRunFile(run.date), JSON.stringify(run) + '\n'); return true; }
3592
+ catch (e) { warnWriteFail('day run (goals/<date>.run.json)', e); return false; }
3593
+ }
3594
+ function briefOfDay(date) {
3595
+ try { return JSON.parse(fs.readFileSync(path.join(STATE, 'briefs', date + '.json'), 'utf8')); } catch { return null; }
3596
+ }
3597
+ const daySessionAlive = (id) => { const s = id && sessions.get(id); return !!s && s.state !== 'exited'; };
3598
+ // The skill's own body is the prompt, as fireSchedule hands it over; the line after it names this run's task and mode.
3599
+ function daySkillPrompt(run, skillId) {
3600
+ const w = getWs(run.ws);
3601
+ const task = taskById(skillId, w ? w.root : null);
3602
+ if (!task || task.kind !== 'agent' || !task.prompt) return { error: `the ${skillId} skill is not installed for this repository` };
3603
+ return { w, task, prompt: skillStore.retargetFiling(task.prompt, filingContract(w)) };
3604
+ }
3605
+ /** Start one task session; a start that fails marks the task failed with the reason. Returns the new run. */
3606
+ function startDayTask(run, t, now) {
3607
+ const sk = daySkillPrompt(run, 'day-task');
3608
+ let s = null;
3609
+ if (!sk.error) {
3610
+ const prompt = `${sk.prompt}\n\nThis run: /day-task ${t.id} ${t.mode} — read the task first with: pmos proactive day show ${t.id}`;
3611
+ s = spawnSession({ name: `${t.title} · day task`, agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-task', initialInput: prompt + '\r',
3612
+ modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
3613
+ }
3614
+ const live = s && s.state !== 'exited';
3615
+ const st = dayRunLib.started(run, t.id, live ? s.id : null, now);
3616
+ if (!st.ok) return run;
3617
+ if (live) return st.run;
3618
+ const f = dayRunLib.fail(st.run, t.id, String(sk.error || (s && s.note) || 'the session could not start').slice(0, 240), now);
3619
+ return f.ok ? f.run : st.run;
3620
+ }
3621
+ /** Start what lib/day-run.js says may start now (at most 5 sessions at once). */
3622
+ function startDayTasks(run, now) {
3623
+ for (const t of dayRunLib.toStart(run)) run = startDayTask(run, t, now);
3624
+ return run;
3625
+ }
3626
+ /** Start one check session. @returns {{ok:true, run, session}|{ok:false, error}} */
3627
+ function startDayCheck(run, reason, now) {
3628
+ if (run.checks.session_id && daySessionAlive(run.checks.session_id)) return { ok: false, error: 'a check is already running' };
3629
+ if (run.checks.session_id) run = dayRunLib.sessionEnded(run, run.checks.session_id, now).run;
3630
+ const sk = daySkillPrompt(run, 'day-check');
3631
+ if (sk.error) return { ok: false, error: sk.error };
3632
+ const prompt = `${sk.prompt}\n\nThis run: /day-check ${reason} — the last check was at ${run.checks.last || 'none yet'}; read what changed since ${run.checks.last || run.started_at}.`;
3633
+ const s = spawnSession({ name: 'Daytime check', agent: sk.task.agent || 'claude', ws: sk.w ? sk.w.id : run.ws, origin: 'day-check', initialInput: prompt + '\r',
3634
+ modelAsk: { scheduleTier: null, skillTier: sk.task.tier || null, skill: sk.task.skill } });
3635
+ if (s.state === 'exited') return { ok: false, error: s.note || 'the check session could not start' };
3636
+ const c = dayRunLib.checkStarted(run, s.id, reason, now);
3637
+ return c.ok ? { ok: true, run: c.run, session: s.id } : c;
3638
+ }
3639
+ /** A brief for a day with a run arrived: its agent priorities become tasks and start. */
3640
+ function dayAfterBrief(date, merged) {
3641
+ const now = Date.now();
3642
+ let run = readDayRun(date);
3643
+ if (!run) return null;
3644
+ const brief = briefOfDay(date);
3645
+ if (merged && run.checks.session_id) { const c = dayRunLib.checkDone(run, merged, now); if (c.ok) run = c.run; }
3646
+ const a = dayRunLib.addTasks(run, brief);
3647
+ if (a.ok) run = a.run;
3648
+ run = startDayTasks(run, now);
3649
+ writeDayRun(run);
3650
+ return run;
3651
+ }
3652
+ /** The tick: mark ended sessions, start what may start, and start a check when one is due. */
3653
+ function dayTick() {
3654
+ const now = Date.now();
3655
+ const date = briefLib.localDate(now);
3656
+ let run = readDayRun(date);
3657
+ if (!run) return;
3658
+ const before = JSON.stringify(run);
3659
+ const ids = new Set([...run.tasks.filter((t) => (t.status === 'working' || t.status === 'sending') && t.session_id).map((t) => t.session_id),
3660
+ run.brief_session && !run.brief_session.ended_at ? run.brief_session.id : null, run.checks.session_id].filter(Boolean));
3661
+ for (const id of ids) if (!daySessionAlive(id)) run = dayRunLib.sessionEnded(run, id, now).run;
3662
+ const brief = briefOfDay(date);
3663
+ const a = dayRunLib.addTasks(run, brief);
3664
+ if (a.ok) run = a.run;
3665
+ run = startDayTasks(run, now);
3666
+ const due = dayRunLib.checkDue(run, { settings: proSettings(), now, events: brief ? brief.calendar || [] : [] });
3667
+ if (due.due) { const c = startDayCheck(run, due.reason, now); if (c.ok) run = c.run; }
3668
+ run.checks.next = due.next;
3669
+ if (JSON.stringify(run) !== before) writeDayRun(run);
3670
+ }
3671
+ // ── end of the today day run helpers ─────────────────────────────────────────────────────────────────────────────
3672
+
3575
3673
  // ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
3576
3674
  // The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
3577
3675
  // `go` uses (startWork with origin next-step and the prompt that was shown), only while background prepare is on, outside quiet
@@ -6998,13 +7096,21 @@ async function handleRequest(req, res) {
6998
7096
  return json(res, { ok: true, day: r.day, goal: r.goal });
6999
7097
  }
7000
7098
  if (p === '/api/proactive/goals/plan' && req.method === 'POST') {
7001
- // A PM press: make sure the morning brief is scheduled for this repository, then run it now. The session reads the
7002
- // goals with `pmos proactive goals` and submits a brief whose items name them.
7099
+ // A PM press ("Prepare my day"): record the day run (G13), make sure the morning brief is scheduled for this
7100
+ // repository, then run it now. The session reads the goals with `pmos proactive goals` and submits a brief whose
7101
+ // items name them. No goal is needed: the brief plans the day either way. A second press while the brief session
7102
+ // still runs starts nothing and answers that session.
7003
7103
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7004
7104
  const w = getWs(body.ws);
7005
7105
  if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
7006
- const open = readGoalsDay(briefLib.localDate(Date.now())).goals.filter((g) => g.status === 'open');
7007
- if (!open.length) return json(res, { ok: false, error: 'add a goal for today first: the plan is made for your goals' }, 409);
7106
+ const pnow = Date.now(); const pdate = briefLib.localDate(pnow);
7107
+ const open = readGoalsDay(pdate).goals.filter((g) => g.status === 'open');
7108
+ let run = readDayRun(pdate) || dayRunLib.newRun(pdate, pnow);
7109
+ run.ws = w.id;
7110
+ if (run.brief_session && !run.brief_session.ended_at) {
7111
+ if (daySessionAlive(run.brief_session.id)) { writeDayRun(run); return json(res, { ok: true, running: true, session: run.brief_session.id, run, goals: open.length }); }
7112
+ run = dayRunLib.sessionEnded(run, run.brief_session.id, pnow).run;
7113
+ }
7008
7114
  let sched = readSchedules().find((x) => x.ws === w.id && x.skill === 'morning-brief');
7009
7115
  let seeded = false;
7010
7116
  if (!sched) {
@@ -7016,9 +7122,69 @@ async function handleRequest(req, res) {
7016
7122
  const list = readSchedules(); list.unshift(sched); writeSchedules(list); seeded = true;
7017
7123
  }
7018
7124
  const result = fireSchedule(sched);
7019
- return json(res, { ok: !!result.ok, seeded, schedule: { id: sched.id, time: sched.time }, result, goals: open.length,
7125
+ if (result.ok && result.sessionId) run.brief_session = { id: result.sessionId, started_at: new Date(pnow).toISOString(), ended_at: null };
7126
+ writeDayRun(run);
7127
+ return json(res, { ok: !!result.ok, started: !!result.ok, session: result.sessionId || null, run, seeded, schedule: { id: sched.id, time: sched.time }, result, goals: open.length,
7020
7128
  ...(result.ok ? {} : { error: result.reason || 'the morning-brief session did not start' }) }, result.ok ? 200 : 500);
7021
7129
  }
7130
+ /* ── today day run (G13, G14, G21, G22): the day view, the task sessions' reports and the PM's decisions. These
7131
+ * routes write only goals/<date>.run.json (writeDayRun) and start sessions only through the day-run helpers, for
7132
+ * the run of a day the PM pressed. Deliver, sent and fail are called by the sessions through `pmos proactive day`;
7133
+ * decide and check are the PM's presses. ── */
7134
+ if (p === '/api/proactive/day' && req.method === 'GET') {
7135
+ const now = Date.now();
7136
+ const date = briefLib.localDate(now);
7137
+ const w = getWs(url.searchParams.get('ws'));
7138
+ const st = proSettings();
7139
+ const run = readDayRun(date);
7140
+ const stored = briefOfDay(date);
7141
+ const brief = stored && stored.date === date ? stored : null;
7142
+ let cards = []; let cardsError = null;
7143
+ if (w) {
7144
+ const memo = goalsCardsMemo.get(w.id);
7145
+ if (memo && now - memo.at < 30000) ({ cards, error: cardsError } = memo);
7146
+ else {
7147
+ let sess = []; try { sess = sessionList(); } catch { /* no session cards */ }
7148
+ const t = todayLib.todayFor(w, { stateDir: stateDirFor(w), sessions: sess, registered: (root) => { const x = workspaces.get(wsId(root)); return !!x && path.resolve(x.root) === path.resolve(root); } });
7149
+ if (t.ok) cards = t.cards; else cardsError = t.error;
7150
+ goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
7151
+ }
7152
+ }
7153
+ const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now });
7154
+ const due = dayRunLib.checkDue(run, { settings: st, now, events: brief ? brief.calendar || [] : [] });
7155
+ const items = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
7156
+ const sources = proactiveLib.BRIEF_SOURCES.map((k) => ({ k, on: st.briefSources[k] === true, state: brief ? brief.sources[k] || null : null, got: items.filter((x) => x.source === k).length }));
7157
+ 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,
7158
+ settings: { briefSources: st.briefSources, scopes: st.scopes, dayCheck: st.dayCheck, websites: st.websites }, sources,
7159
+ 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 : [] } });
7160
+ }
7161
+ if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
7162
+ const verb = p.slice('/api/proactive/day/'.length);
7163
+ if (!['deliver', 'sent', 'fail', 'decide', 'check'].includes(verb)) return json(res, { ok: false, error: 'unknown day-run action' }, 404);
7164
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7165
+ const now = Date.now();
7166
+ let run = readDayRun(briefLib.localDate(now));
7167
+ if (!run) return json(res, { ok: false, error: 'no day run today — press Prepare my day first' }, 409);
7168
+ if (verb === 'check') {
7169
+ // "Check now": one check at once, whatever the hour. The PM's press; reason 'manual'.
7170
+ if (body.ws && !run.ws) run.ws = getWs(body.ws) ? getWs(body.ws).id : run.ws;
7171
+ const c = startDayCheck(run, 'manual', now);
7172
+ if (!c.ok) return json(res, { ok: false, error: c.error }, 409);
7173
+ writeDayRun(c.run);
7174
+ return json(res, { ok: true, session: c.session, run: c.run });
7175
+ }
7176
+ const id = String(body.task || '');
7177
+ if (!dayRunLib.findTask(run, id)) return json(res, { ok: false, error: `no task ${id || '(none given)'} in today's run` }, 404);
7178
+ const r = verb === 'deliver' ? dayRunLib.deliver(run, id, { summary: body.summary, body: body.body, link: body.link }, now)
7179
+ : verb === 'sent' ? dayRunLib.sent(run, id, { link: body.link }, now)
7180
+ : verb === 'fail' ? dayRunLib.fail(run, id, body.reason, now)
7181
+ : dayRunLib.decide(run, id, { action: body.action, note: body.note }, now);
7182
+ if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
7183
+ // A delivered, sent or failed task frees a place, and a decision may make a task ready: start what may start now.
7184
+ run = startDayTasks(r.run, now);
7185
+ if (!writeDayRun(run)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
7186
+ return json(res, { ok: true, run, task: dayRunLib.findTask(run, id) });
7187
+ }
7022
7188
  /* ── background prepare (D3): plan is read-only; queue records an approval; the runner starts it later ── */
7023
7189
  if (p === '/api/proactive/prepare/plan' && req.method === 'POST') {
7024
7190
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
@@ -7095,9 +7261,20 @@ async function handleRequest(req, res) {
7095
7261
  const bday = body && typeof body.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.date) ? body.date : briefLib.localDate(Date.now());
7096
7262
  const v = briefLib.validateBrief(body, { now: Date.now(), settings: proSettings(), goalIds: readGoalsDay(bday).goals.map((g) => g.id) });
7097
7263
  if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
7098
- try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, v.brief.date + '.json'), JSON.stringify(v.brief) + '\n'); }
7264
+ // `merge: true` (a daytime check, G22) merges into the stored brief of that day; with none stored it is stored as it is.
7265
+ let out = { ...v.brief, resolved: [] }; let merged = null;
7266
+ if (body.merge === true) {
7267
+ let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
7268
+ const m = briefLib.mergeBrief(stored, v.brief, { now: Date.now() });
7269
+ if (!m.ok) return json(res, { ok: false, error: m.error }, 400);
7270
+ out = m.brief; merged = { found: m.added, resolved: m.removed };
7271
+ }
7272
+ try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, out.date + '.json'), JSON.stringify(out) + '\n'); }
7099
7273
  catch (e) { warnWriteFail('briefs', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
7100
- return json(res, { ok: true, brief: v.brief });
7274
+ // A brief for a day the PM pressed "Prepare my day": its agent priorities become tasks and start (G13), and a merge
7275
+ // closes the check that sent it (G22).
7276
+ try { dayAfterBrief(out.date, merged); } catch (e) { warnWriteFail('day run after the brief', e); }
7277
+ return json(res, { ok: true, brief: out, ...(merged ? { merged } : {}) });
7101
7278
  }
7102
7279
  if (req.method === 'GET') {
7103
7280
  const now = Date.now(); const st = proSettings();
@@ -7113,7 +7290,11 @@ async function handleRequest(req, res) {
7113
7290
  if (req.method === 'POST') {
7114
7291
  let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
7115
7292
  const cur = proSettings();
7116
- const n = proactiveLib.normalizeSettings({ ...cur, ...body, triggers: { ...cur.triggers, ...(body.triggers || {}) }, briefSources: { ...cur.briefSources, ...(body.briefSources || {}) } });
7293
+ // A patch: triggers, sources, the check and each service's levels merge over what is stored (a level of null clears it).
7294
+ const bs = body.scopes && typeof body.scopes === 'object' && !Array.isArray(body.scopes) ? body.scopes : null;
7295
+ const scopes = bs ? { ...cur.scopes, ...Object.fromEntries(Object.entries(bs).map(([k, v]) => [k, v && typeof v === 'object' && !Array.isArray(v) ? { ...(cur.scopes[k] || {}), ...v } : v])) } : cur.scopes;
7296
+ const dayCheck = body.dayCheck && typeof body.dayCheck === 'object' && !Array.isArray(body.dayCheck) ? { ...cur.dayCheck, ...body.dayCheck } : (body.dayCheck === undefined ? cur.dayCheck : body.dayCheck);
7297
+ const n = proactiveLib.normalizeSettings({ ...cur, ...body, triggers: { ...cur.triggers, ...(body.triggers || {}) }, briefSources: { ...cur.briefSources, ...(body.briefSources || {}) }, scopes, dayCheck });
7117
7298
  if (!n.ok) return json(res, { ok: false, error: n.error }, 400);
7118
7299
  try { fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(PRO_SETTINGS(), JSON.stringify(n.settings) + '\n'); }
7119
7300
  catch (e) { warnWriteFail('proactive-settings.json', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }