create-pmos 0.54.1 → 0.55.1

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",