create-pmos 0.115.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +2 -0
  7. package/workspace/guide/today.md +21 -12
  8. package/workspace/lib/audio-resample.js +76 -0
  9. package/workspace/lib/brief.js +2 -2
  10. package/workspace/lib/day-marks.js +29 -9
  11. package/workspace/lib/goals.js +68 -10
  12. package/workspace/lib/notes-audio.js +62 -20
  13. package/workspace/lib/notes-echo.js +32 -14
  14. package/workspace/lib/notes-hires.js +94 -0
  15. package/workspace/lib/notes-polish.js +2 -3
  16. package/workspace/lib/routes-notes.js +9 -1
  17. package/workspace/notes-test-signals.js +65 -0
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/audio-capture.js +20 -6
  21. package/workspace/public/pm/components.css +39 -11
  22. package/workspace/public/pm/today-agenda.js +27 -53
  23. package/workspace/public/pm/today-copy.js +34 -15
  24. package/workspace/public/pm/today-day.js +98 -76
  25. package/workspace/public/pm/today-goals.js +159 -15
  26. package/workspace/public/pm/today-jobs.js +84 -0
  27. package/workspace/public/pm/today-mentions.js +50 -0
  28. package/workspace/public/pm/view-notes.js +15 -4
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-lists/meeting-notes.json +5 -3
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -7,6 +7,9 @@
7
7
  * then Queue it (the shipped background prepare: one approval is one prompt hash)
8
8
  * addGoal(ctx, text, ini) → POST the add; true when the goal is on the list. While Today shows one repository, the
9
9
  * goal belongs to it (repo); the goal's detail can change that.
10
+ * goalJobsBlock(ctx, g, n) → a goal's done when and jobs, with the add and link presses: the goal's detail and a meeting's
11
+ * detail draw the same block (initiative today-goals-jobs, PRD G3, A4)
12
+ * jobsWithoutGoal(ctx) → the end of the Goals section: every job of the day with no goal, each with the link control (G5)
10
13
  * goalsSig() → a string that changes whenever what this module draws changes on its own
11
14
  *
12
15
  * `ctx` is the day section's (today-day.js): { ws, m (the day model), select(type, id), reload(), rerender(), cur(type, id) }.
@@ -17,10 +20,16 @@ import { el } from './dom.js';
17
20
  import { button, field, badge, toast, row, meter } from './primitives.js';
18
21
  import { pmPost } from './http.js';
19
22
  import { COPY } from './today-copy.js';
23
+ import { statusPill, whoActs, mark as whoMark, tagged, originOf, goalPicker, linkField } from './today-jobs.js';
20
24
 
21
25
  const G = COPY.goals;
22
26
  const D = COPY.day;
27
+ const A = COPY.act;
28
+ const MAX_JOBS = 12; // the jobs the PM types on one goal (lib/goals.js)
23
29
  let draft = '';
30
+ const ctxDraft = { done_when: '', anchor: '', due: '' }; // the "Add context" fields, kept while the section is redrawn
31
+ let ctxOpen = false;
32
+ const jobDrafts = new Map(); // goal id -> the text typed in its "Add a job" field
24
33
  let lastAdded = null; // { key, at }: the text of the last goal sent, so a double commit adds it once
25
34
  const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
26
35
 
@@ -31,10 +40,12 @@ async function press(ctx, body) {
31
40
  return true;
32
41
  }
33
42
 
34
- /** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. */
35
- export function addGoal(ctx, text, ini) {
43
+ /** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. `extra` holds the
44
+ * optional context: done_when, anchor, due and event_id (the meeting it was made from). */
45
+ export function addGoal(ctx, text, ini, extra) {
36
46
  const body = { action: 'add', text: String(text || '').trim().slice(0, 140) };
37
47
  if (ini) body.initiative_id = ini;
48
+ for (const k of ['done_when', 'anchor', 'due', 'event_id']) if (extra && typeof extra[k] === 'string' && extra[k].trim()) body[k] = extra[k].trim();
38
49
  // one day for every repository: a goal added while Today shows one repository belongs to it; otherwise to none
39
50
  if (ctx.m && ctx.m.narrow && ctx.m.here) body.repo = ctx.m.here;
40
51
  return press(ctx, body);
@@ -42,21 +53,28 @@ export function addGoal(ctx, text, ini) {
42
53
 
43
54
  /** The jobs of a goal: the day model's jobs that name it. */
44
55
  const jobsOf = (m, id) => m.jobs.filter((j) => j.goal === id && !j.worth);
56
+ const typedOf = (g) => (Array.isArray(g.jobs) ? g.jobs : []);
57
+ /** "N of M jobs done": the jobs made from the brief and the ones the PM typed. Information only: it never closes a goal (G2). */
58
+ function tally(m, g) {
59
+ const js = jobsOf(m, g.id);
60
+ const typed = typedOf(g);
61
+ return { js, typed, total: js.length + typed.length, done: js.filter((j) => j.group === 'done').length + typed.filter((x) => x.done).length, need: js.filter((j) => j.group === 'needs').length };
62
+ }
45
63
 
46
64
  export function goalRows(ctx) {
47
65
  const { m } = ctx;
48
66
  if (!m.goals.length) return null;
49
67
  return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g) => {
50
- const js = jobsOf(m, g.id);
51
- const done = js.filter((j) => j.group === 'done').length;
52
- const need = js.filter((j) => j.group === 'needs').length;
68
+ const t = tally(m, g);
53
69
  const closed = g.status !== 'open';
54
- 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);
70
+ const sub = closed ? (G.coverage[g.coverage] || g.status) : t.total ? D.goalJobs(t.done, t.total) + (t.need ? ' · ' + D.goalNeed(t.need) : '') : (G.coverage[g.coverage] || D.goalNone);
55
71
  const cur = ctx.cur('goal', g.id);
72
+ // the optional context (G1): when it is due, what it serves, and the prompt to say what done looks like
73
+ const meta = [sub, g.due ? G.dueMeta(g.due) : null, g.anchor ? G.anchorMeta(g.anchor) : null, !closed && !g.done_when ? G.addDoneWhen : null];
56
74
  return el('li', null, row({
57
- title: g.text, meta: [sub], done: closed, onOpen: () => ctx.select('goal', g.id),
75
+ title: g.text, meta, done: closed, onOpen: () => ctx.select('goal', g.id),
58
76
  lead: [el('span', { class: 'td-day__lead' }, String(m.goalNo.get(g.id)))],
59
- trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done: js.length ? done : g.status === 'done' ? 1 : 0, total: js.length || 1, label: '' })],
77
+ trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done: t.total ? t.done : g.status === 'done' ? 1 : 0, total: t.total || 1, label: '' })],
60
78
  attrs: { class: 'td-day__r', 'data-day-sel': 'goal:' + g.id, 'data-goal': g.id, 'data-goal-coverage': g.coverage, 'data-cur': cur },
61
79
  openAttrs: { 'aria-current': cur },
62
80
  }));
@@ -77,13 +95,32 @@ export function goalAdd(ctx) {
77
95
  lastAdded = { key, at: Date.now() };
78
96
  // clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
79
97
  draft = '';
80
- if (await addGoal(ctx, v)) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; ctx.rerender(); }
81
- else { lastAdded = null; draft = v; ctx.rerender(); }
98
+ const extra = { ...ctxDraft };
99
+ clearContext();
100
+ if (await addGoal(ctx, v, null, extra)) { if (f.control.value.trim() === String(v).trim()) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; } ctx.rerender(); }
101
+ else { lastAdded = null; draft = v; Object.assign(ctxDraft, extra); ctxOpen = true; ctx.rerender(); }
82
102
  } });
83
103
  // a rebuild keeps what is typed. The field is made empty and filled after, so Enter still commits the restored text.
84
104
  f.control.value = draft;
85
105
  f.control.addEventListener('input', () => { draft = f.control.value; });
86
- return el('div', { class: 'td-day__goaladd' }, f);
106
+ return el('div', { class: 'td-day__goaladd' }, f, contextFold());
107
+ }
108
+
109
+ function clearContext() { ctxDraft.done_when = ''; ctxDraft.anchor = ''; ctxDraft.due = ''; ctxOpen = false; }
110
+
111
+ /** "Add context" (G6): done when, anchor and due. The jobs carry who does the work and what comes after, so no field asks. */
112
+ function contextFold() {
113
+ const one = (key, label, hint, max) => {
114
+ const f = field({ label, value: '', placeholder: hint, attrs: { maxlength: String(max), 'data-goals': 'ctx-' + key, 'data-goal-ctx-new': key }, onCommit: (v) => { ctxDraft[key] = v; } });
115
+ f.control.value = ctxDraft[key];
116
+ f.control.addEventListener('input', () => { ctxDraft[key] = f.control.value; });
117
+ return f;
118
+ };
119
+ const d = el('details', { class: 'td-day__fold td-goal__ctxfold', open: ctxOpen ? true : null, 'data-goals': 'context' },
120
+ el('summary', null, G.addContext),
121
+ el('div', { class: 'td-goal__ctxfields' }, one('done_when', G.doneWhen, G.doneWhenHint, 200), one('anchor', G.anchor, G.anchorHint, 80), one('due', G.due, G.dueHint, 40)));
122
+ d.addEventListener('toggle', () => { ctxOpen = d.open; });
123
+ return d;
87
124
  }
88
125
 
89
126
  async function prepareStep(ctx, st) {
@@ -117,6 +154,113 @@ function stepNode(ctx, st) {
117
154
  acts);
118
155
  }
119
156
 
157
+ const srcOf = (k) => D.srcName[k] || String(k || '');
158
+
159
+ /** A job made from the brief, as a line of a goal (G3): a check for the PM's own job that has no task yet, the title, who
160
+ * acts, where it came from (when not from the goal), and its status. The press on the title opens the job. */
161
+ function jobLine(ctx, j) {
162
+ const who = whoActs(j);
163
+ const origin = originOf(j);
164
+ const cur = ctx.cur('job', j.id);
165
+ const checkable = j.status === 'you' && !j.task;
166
+ const check = checkable ? field({ kind: 'check', label: '', value: j.group === 'done', attrs: { 'aria-label': `${A.done}: ${j.title}`, 'data-goal-job-check': j.id },
167
+ onCommit: (v) => ctx.mark(j.id, { action: v ? 'done' : 'reopen' }, v ? A.did.done(j.title) : A.did.reopen) }) : null;
168
+ return el('li', null, row({
169
+ title: j.title, meta: [who.text, origin !== 'goal' ? D.origin[origin] : null, srcOf(j.source)], done: j.group === 'done',
170
+ lead: [check, whoMark(who.who)].filter(Boolean), trail: [statusPill(j)], onOpen: () => ctx.select('job', j.id),
171
+ attrs: { class: 'td-day__r', 'data-day-sel': 'job:' + j.id, 'data-goal-job': j.id, 'data-who': who.who, 'data-cur': cur }, openAttrs: { 'aria-current': cur },
172
+ }));
173
+ }
174
+
175
+ /** A job the PM typed on the goal (G3): the PM's own, with no agent and no session. Check, uncheck and remove are the PM's presses. */
176
+ function typedLine(ctx, g, j) {
177
+ const who = whoActs({ typed: true, done: j.done });
178
+ const open = g.status === 'open';
179
+ const check = field({ kind: 'check', label: '', value: !!j.done, attrs: { 'aria-label': `${A.done}: ${j.text}`, 'data-goal-typed-check': j.id, disabled: open ? null : true },
180
+ onCommit: (v) => press(ctx, { action: v ? 'job-done' : 'job-reopen', id: g.id, job: j.id }) });
181
+ return el('li', null, row({
182
+ title: j.text, meta: [who.text, D.origin.you], done: !!j.done, lead: [check, whoMark('pm')],
183
+ trail: open ? [button({ label: tagged(G.removeJob, 'you'), size: 'sm', variant: 'ghost', attrs: { 'data-goal-typed-remove': j.id, 'aria-label': `${G.removeJob}: ${j.text}` },
184
+ onClick: () => press(ctx, { action: 'job-remove', id: g.id, job: j.id }) })] : [],
185
+ attrs: { class: 'td-day__r', 'data-goal-typed': j.id, 'data-who': 'pm' },
186
+ }));
187
+ }
188
+
189
+ /** The "Add a job" field (G3, L2): typed text becomes a job the PM owns. Enter adds it. */
190
+ function jobAdd(ctx, g) {
191
+ if (typedOf(g).length >= MAX_JOBS) return el('p', { class: 'td-day__note' }, G.jobFull(MAX_JOBS));
192
+ const f = field({ label: G.addJob, value: '', placeholder: G.addJobHint, hideLabel: true, attrs: { 'data-goals': 'job-input', 'data-goal-job-input': g.id, maxlength: '140' },
193
+ onCommit: async (v) => {
194
+ const text = String(v).trim();
195
+ if (!text) { jobDrafts.set(g.id, ''); return; }
196
+ const key = g.id + '|' + text.toLowerCase();
197
+ if (lastAdded && lastAdded.key === key && Date.now() - lastAdded.at < 10000) { jobDrafts.set(g.id, ''); f.control.value = ''; ctx.rerender(); return; }
198
+ lastAdded = { key, at: Date.now() };
199
+ jobDrafts.set(g.id, '');
200
+ if (await press(ctx, { action: 'job-add', id: g.id, text })) { if (f.control.value.trim() === text) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; } ctx.rerender(); }
201
+ else { lastAdded = null; jobDrafts.set(g.id, text); ctx.rerender(); }
202
+ } });
203
+ f.control.value = jobDrafts.get(g.id) || '';
204
+ f.control.addEventListener('input', () => { jobDrafts.set(g.id, f.control.value); });
205
+ return f;
206
+ }
207
+
208
+ /** Link a job of the day that has no goal to this goal (G3, G4): the picker offers only jobs with no goal. */
209
+ function jobLink(ctx, g) {
210
+ const free = ctx.m.jobs.filter((j) => !j.goal);
211
+ if (!free.length) return null;
212
+ return linkField(free, (j) => ctx.mark(j.linkId, { action: 'link', goal: g.id }, D.linked(g.text)), { label: G.linkJob, prompt: G.linkJobPick, attrs: { 'data-goal-link': g.id } });
213
+ }
214
+
215
+ /** The jobs of a goal and what finished looks like. `n` is the goal's number. The goal's detail and a meeting's detail draw it. */
216
+ export function goalJobsBlock(ctx, g, n) {
217
+ const t = tally(ctx.m, g);
218
+ const open = g.status === 'open';
219
+ return el('div', { class: 'td-day__rel td-goal__jobs', 'data-goals': 'jobs', 'data-goal-jobs': g.id },
220
+ el('h4', null, n ? G.forGoal(n, g.text) : G.jobsTitle),
221
+ g.done_when ? el('p', { class: 'td-goal__donewhen', 'data-goal-donewhen': g.id }, el('b', null, G.doneWhen + ': '), g.done_when) : null,
222
+ t.total ? el('p', { class: 'td-day__note', 'data-goal-tally': g.id }, D.goalJobs(t.done, t.total)) : null,
223
+ t.total ? el('ul', { class: 'td-day__rows', 'data-goal-job-list': g.id }, ...t.js.map((j) => jobLine(ctx, j)), ...t.typed.map((j) => typedLine(ctx, g, j)))
224
+ : el('p', { class: 'td-day__note' }, G.noJobs),
225
+ open ? jobAdd(ctx, g) : null,
226
+ open ? jobLink(ctx, g) : null);
227
+ }
228
+
229
+ /** The context of a goal (G1): done when, anchor and due. The PM edits it while the goal is open; an empty value clears it. */
230
+ function contextFields(ctx, g) {
231
+ if (g.status !== 'open') {
232
+ const bits = [[G.doneWhen, g.done_when], [G.anchor, g.anchor], [G.due, g.due]].filter(([, v]) => v);
233
+ return bits.length ? el('dl', { class: 'td-day__ctx' }, ...bits.flatMap(([k, v]) => [el('dt', null, k), el('dd', null, v)])) : null;
234
+ }
235
+ const one = (key, label, hint, max) => {
236
+ const f = field({ label, value: g[key] || '', placeholder: hint, attrs: { maxlength: String(max), 'data-goals': 'ctx-' + key, 'data-goal-ctx': key },
237
+ onCommit: (v) => press(ctx, { action: 'context', id: g.id, [key]: String(v).trim() }) });
238
+ return f;
239
+ };
240
+ return el('div', { class: 'td-goal__ctxfields', 'data-goals': 'context-fields' }, one('done_when', G.doneWhen, G.doneWhenHint, 200), one('anchor', G.anchor, G.anchorHint, 80), one('due', G.due, G.dueHint, 40));
241
+ }
242
+
243
+ /** The end of the Goals section (G5): every job of the day with no goal, each with a picker of today's open goals. */
244
+ export function jobsWithoutGoal(ctx) {
245
+ const { m } = ctx;
246
+ const free = m.jobs.filter((j) => !j.goal);
247
+ if (!m.jobs.length) return null;
248
+ const hasGoals = m.allGoals.some((g) => g.status === 'open');
249
+ return el('div', { class: 'td-day__rel td-goal__nogoal', 'data-goals': 'no-goal' },
250
+ el('h4', null, G.noGoalTitle, free.length ? el('span', null, String(free.length)) : null),
251
+ free.length ? el('ul', { class: 'td-day__rows', 'data-day-list': 'no-goal' }, ...free.map((j) => {
252
+ const who = whoActs(j);
253
+ const pick = hasGoals ? goalPicker(m, null, (gid) => {
254
+ const g = gid ? m.allGoals.find((x) => x.id === gid) : null;
255
+ if (g) ctx.mark(j.linkId, { action: 'link', goal: g.id }, D.linked(g.text));
256
+ }, { label: G.linkLabel(j.title), none: G.linkTo, attrs: { 'data-nogoal-link': j.id } }) : null;
257
+ return el('li', null, row({
258
+ title: j.title, meta: [who.text, srcOf(j.source)], lead: [whoMark(who.who)], trail: pick ? [el('span', { class: 'td-goal__pick' }, pick)] : [], onOpen: () => ctx.select('job', j.id),
259
+ attrs: { class: 'td-day__r', 'data-nogoal-job': j.id, 'data-who': who.who },
260
+ }));
261
+ })) : el('p', { class: 'td-day__note' }, G.noGoalNone));
262
+ }
263
+
120
264
  /** The detail of one goal. `row(type, id, label, lead, trail)` draws a selectable row (today-day.js). */
121
265
  export function goalDetail(ctx, id, row) {
122
266
  const { m } = ctx;
@@ -124,8 +268,7 @@ export function goalDetail(ctx, id, row) {
124
268
  if (i < 0) return null;
125
269
  const g = m.allGoals[i];
126
270
  const closed = g.status !== 'open';
127
- const js = jobsOf(m, g.id);
128
- const meets = m.events.filter((e) => e.goal === g.id);
271
+ const meets = m.events.filter((e) => e.gid === g.id);
129
272
  const ctrls = closed
130
273
  ? [button({ label: G.reopen, size: 'sm', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ctx, { action: 'reopen', id: g.id }) })]
131
274
  : [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ctx, { action: 'done', id: g.id }) }),
@@ -137,12 +280,13 @@ export function goalDetail(ctx, id, row) {
137
280
  el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind' }, D.kind.goal(i + 1)), el('h3', null, g.text)),
138
281
  el('div', { class: 'td-day__acts' }, badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'outline' }),
139
282
  g.initiative_id ? el('span', { class: 'td-day__mono' }, g.initiative_id) : null),
283
+ contextFields(ctx, g),
284
+ closed ? null : el('p', { class: 'td-day__note', 'data-goals': 'close-note' }, G.closeNote),
140
285
  ctx.repoPicker && m.repos.length ? el('div', { class: 'td-day__ch', 'data-goals': 'repo' }, el('span', null, D.repo.label),
141
286
  ctx.repoPicker(m, g.repo, (r) => press(ctx, { action: 'tag', id: g.id, repo: r }), { 'data-goal-repo': g.id })) : null,
142
287
  ctx.repoPicker && m.repos.length ? el('p', { class: 'td-day__note' }, D.repo.goalHint) : null,
143
- js.length ? el('p', null, D.goalJobs(js.filter((j) => j.group === 'done').length, js.length)) : el('p', { class: 'td-day__note' }, G.noStep),
144
288
  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,
145
- 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,
289
+ goalJobsBlock(ctx, g, null),
146
290
  !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,
147
291
  el('div', { class: 'td-day__acts' }, ...ctrls),
148
292
  ];
@@ -0,0 +1,84 @@
1
+ /* pm/today-jobs.js — what a job says about itself on Today (initiative today-goals-jobs, PRD J1 to J4).
2
+ *
3
+ * statusPill(j) → the status badge: To do, Needs you, Running, Done or Blocked
4
+ * whoActs(j) → { who: 'pm'|'agent', text }: who acts on the job's next press
5
+ * mark(who) → the square beside it: open for the PM, filled for the agent
6
+ * tag(who) → the small YOU or AGENT tag a press carries
7
+ * originOf(j) → 'goal'|'found'|'mention'|'you': where the job came from, derived from the data
8
+ * goalPicker(m, cur, ...) → a select: No goal, or one of today's open goals
9
+ * linkField(...) → a select of the jobs of the day that have no goal
10
+ *
11
+ * Pure drawing: no request is sent here. The presses are the day section's (today-day.js) and the goals module's.
12
+ */
13
+
14
+ import { el } from './dom.js';
15
+ import { badge, field } from './primitives.js';
16
+ import { COPY } from './today-copy.js';
17
+
18
+ const D = COPY.day;
19
+ const TONE = { '': 'muted', ok: 'outline', info: 'outline', warn: 'warning', purple: 'warning', bad: 'destructive' };
20
+
21
+ /** The status of a job as a badge. The raw status stays in data-day-status for the suites. */
22
+ export function statusPill(j) {
23
+ const s = D.status2;
24
+ const map = {
25
+ notStarted: [s.notStarted, ''], waiting: [s.waiting, ''], working: [s.working, 'info'], sending: [s.sending, 'info'],
26
+ review: [j.kind === 'auto' ? s.reviewAuto : s.review, 'purple'], sent: [s.sent, 'ok'], done: [s.done, 'ok'],
27
+ failed: [s.failed, 'bad'], you: [s.you, 'warn'],
28
+ };
29
+ const [t, tone] = map[j.status] || [j.status, ''];
30
+ return badge({ text: t, tone: TONE[tone] || 'muted', attrs: { 'data-day-status': j.status } });
31
+ }
32
+
33
+ /** Who acts on the next press. A draft to approve, a failed job and the PM's own job wait on the PM. */
34
+ export function whoActs(j) {
35
+ const W = D.who2;
36
+ if (j.typed) return j.done ? { who: 'pm', text: W.youDone } : { who: 'pm', text: W.you };
37
+ if (j.status === 'you') return j.group === 'done' ? { who: 'pm', text: W.youDone } : { who: 'pm', text: W.you };
38
+ if (j.status === 'working' || j.status === 'sending') return { who: 'agent', text: W.working };
39
+ if (j.status === 'done' || j.status === 'sent') return { who: 'agent', text: W.finished };
40
+ if (j.status === 'notStarted' || j.status === 'waiting') return { who: 'agent', text: W.queued };
41
+ return { who: 'pm', text: W.you }; // review and failed
42
+ }
43
+
44
+ /** The square: filled for the agent, open for the PM. It is a picture; the words beside it say the same. */
45
+ export function mark(who) {
46
+ return el('span', { class: 'td-day__sq', 'data-who': who, 'aria-hidden': 'true' });
47
+ }
48
+
49
+ /** The small tag on a press: YOU when the PM does it, AGENT when the press starts the agent. */
50
+ export function tag(who) {
51
+ return el('span', { class: 'td-day__tag', 'data-who': who, 'data-day': 'tag' }, who === 'agent' ? D.tag.agent : D.tag.you);
52
+ }
53
+
54
+ /** A button label with its tag: [text, tag]. */
55
+ export const tagged = (text, who) => [text, ' ', tag(who)];
56
+
57
+ const MEETING_SOURCES = ['calendar', 'wispr'];
58
+ /** Where a job came from, from the data and never from a field the PM edits. */
59
+ export function originOf(j) {
60
+ if (j.typed) return 'you';
61
+ if (j.item && j.item.mention) return 'mention';
62
+ if (j.item && (MEETING_SOURCES.includes(j.item.source) || j.item.verb === 'prepare')) return 'found';
63
+ if (!j.item) return 'you'; // a briefed task with no item: the PM gave it to the agent by hand
64
+ return j.goal ? 'goal' : 'found';
65
+ }
66
+
67
+ /** A select of today's open goals with No goal first. `onPick(goalId|null)`. */
68
+ export function goalPicker(m, cur, onPick, { label, none = D.goalCtl.none, attrs } = {}) {
69
+ const open = m.allGoals.filter((g) => g.status === 'open');
70
+ const f = field({ label: label || D.goalCtl.label, kind: 'select', value: cur || '', hideLabel: true,
71
+ options: [{ value: '', label: none }, ...open.map((g) => ({ value: g.id, label: `${m.goalNo.get(g.id)} · ${g.text}` })),
72
+ // a goal that is closed stays shown while the job is under it, so the picker never hides what is stored
73
+ ...(cur && !open.some((g) => g.id === cur) ? [{ value: cur, label: (m.allGoals.find((g) => g.id === cur) || { text: cur }).text }] : [])],
74
+ attrs, onCommit: (v) => onPick(v || null) });
75
+ return f;
76
+ }
77
+
78
+ /** A select of jobs with no goal: the first option is the prompt. `onPick(job)`. */
79
+ export function linkField(jobs, onPick, { label, prompt, attrs } = {}) {
80
+ const f = field({ label, kind: 'select', value: '', hideLabel: true, attrs,
81
+ options: [{ value: '', label: prompt }, ...jobs.map((j) => ({ value: j.linkId, label: j.title }))],
82
+ onCommit: (v) => { const j = jobs.find((x) => x.linkId === v); if (j) onPick(j); } });
83
+ return f;
84
+ }
@@ -0,0 +1,50 @@
1
+ /* pm/today-mentions.js — a mention in the look of its source (initiative today-goals-jobs, PRD M1 to M3).
2
+ *
3
+ * hasCard(source) → true for Slack, Confluence and Figma: the sources that draw as a card
4
+ * mentionCard(j) → the card of a mention job: the source line, the author, the time, the text, and, only when the
5
+ * brief item carries them, the quoted passage (Confluence) or the frame (Figma)
6
+ *
7
+ * Pure drawing from the brief item's own fields (who, about, ask, where, quote, thread). A field the item lacks is left
8
+ * out, never made up. No press lives here: the card sits inside the row's open button or in the detail, and the card
9
+ * links to the conversation. Nothing here posts to, resolves in or reacts in a service.
10
+ */
11
+
12
+ import { el } from './dom.js';
13
+ import { icon } from './icons.js';
14
+ import { COPY } from './today-copy.js';
15
+
16
+ const D = COPY.day;
17
+ const ICON = { slack: 'message-square', confluence: 'file-text', figma: 'diamond' };
18
+
19
+ export const hasCard = (source) => Object.prototype.hasOwnProperty.call(ICON, source);
20
+
21
+ const initials = (who) => String(who || '').replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('');
22
+
23
+ /** The time of the author's last message in the thread the item carries; none when it carries none. */
24
+ function timeOf(it) {
25
+ const mine = (Array.isArray(it.thread) ? it.thread : []).filter((m) => m && m.at && (!it.who || m.who === it.who));
26
+ return mine.length ? mine[mine.length - 1].at : null;
27
+ }
28
+
29
+ /** The card of one mention. `name` is the display name of the source (Slack, Confluence, Figma). */
30
+ export function mentionCard(j, name) {
31
+ const it = j.item || {};
32
+ const svc = j.source;
33
+ const ini = initials(it.who);
34
+ const words = [it.about, it.ask].filter(Boolean);
35
+ const when = timeOf(it);
36
+ const quote = svc === 'confluence' && typeof it.quote === 'string' && it.quote.trim() ? it.quote.trim() : null;
37
+ const frame = svc === 'figma'
38
+ ? el('span', { class: 'td-day__mc-frame', 'data-mc': 'frame', 'aria-hidden': 'true' }, icon('circle-dot')) : null;
39
+ const avatar = svc !== 'figma' && ini ? el('span', { class: 'td-day__av', 'data-mc': 'avatar', 'aria-hidden': 'true' }, ini) : null;
40
+ return el('span', { class: 'td-day__mc', 'data-mention-card': svc },
41
+ el('span', { class: 'td-day__mc-head', 'data-mc': 'source' }, icon(ICON[svc]), D.mc.via(name, it.where)),
42
+ quote ? el('span', { class: 'td-day__mc-quote', 'data-mc': 'quote' }, quote) : null,
43
+ el('span', { class: 'td-day__mc-main' },
44
+ frame || avatar,
45
+ el('span', { class: 'td-day__mc-col' },
46
+ it.who || when ? el('span', { class: 'td-day__mc-line' },
47
+ it.who ? el('strong', { class: 'td-day__mc-name', 'data-mc': 'who' }, it.who) : null,
48
+ when ? el('span', { class: 'td-day__mc-time', 'data-mc': 'time' }, when) : null) : null,
49
+ ...words.map((w) => el('span', { class: 'td-day__mc-text', 'data-mc': 'text' }, w)))));
50
+ }
@@ -52,6 +52,7 @@ const POLL_MS = 1500; // while listening, and until the last line is lab
52
52
  const AGENT_POLL_MS = 3000;
53
53
  const CHUNK_SAMPLES = 16000; // one second at 16 kHz
54
54
  const RATE = 16000;
55
+ const HI_RATE = 24000; // the replay is kept at this rate when the device has it (D92 as amended 2026-10-08)
55
56
  const SEND_TRIES = 3; // a chunk that the server did not take is sent again this many times in all …
56
57
  const SEND_BACKOFF_MS = 400; // … after 400 ms, then 1200 ms
57
58
  const SECTIONS = [['decision', 'Decisions'], ['action', 'Next steps'], ['question', 'Open questions']];
@@ -588,7 +589,7 @@ async function openSource(kind) {
588
589
  return new MediaStream(tracks);
589
590
  }
590
591
  async function startAudio(ctx, n, want) {
591
- const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, lost: 0, ended: 0 };
592
+ const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, rs24: {}, buf24: {}, lost: 0, ended: 0 };
592
593
  const opened = [];
593
594
  // the meeting first, straight from the press: the browser lets a page share a screen only right after a click
594
595
  for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
@@ -604,17 +605,22 @@ async function startAudio(ctx, n, want) {
604
605
  // the clock counts the meeting time, as the line times do: Listen again carries on from where it stopped
605
606
  a.started = Date.parse(r.clock_from || r.recorded_from) || a.started;
606
607
  let ac;
607
- try { ac = new AudioContext({ sampleRate: RATE }); } catch { ac = new AudioContext(); }
608
+ // the device's own rate: the 16 kHz audio for transcription and the 24 kHz audio for the replay are both made from the same blocks (D92 as amended)
609
+ try { ac = new AudioContext(); } catch { ac = new AudioContext({ sampleRate: RATE }); }
608
610
  a.ctx = ac;
609
611
  const sink = ac.createGain(); sink.gain.value = 0; sink.connect(ac.destination);
610
612
  for (const [kind, stream] of opened) {
611
613
  a.streams.push(stream); a.sources.push(kind); a.buf[kind] = []; a.queue[kind] = Promise.resolve();
612
614
  const src = ac.createMediaStreamSource(stream);
613
615
  const rs = a.rs[kind] = makeResampler(ac.sampleRate);
616
+ // a device slower than 24 kHz has nothing above 12 kHz to keep, so it sends 16 kHz audio only and the replay stays 16 kHz
617
+ const rs24 = ac.sampleRate >= HI_RATE ? (a.rs24[kind] = makeResampler(ac.sampleRate, HI_RATE)) : null;
618
+ a.buf24[kind] = [];
614
619
  await openCapture(ac, src, sink, (block) => {
615
620
  if (audio !== a) return;
616
621
  const s = rs.push(block);
617
622
  const b = a.buf[kind]; for (let i = 0; i < s.length; i++) b.push(s[i]);
623
+ if (rs24) { const h = rs24.push(block); const b24 = a.buf24[kind]; for (let i = 0; i < h.length; i++) b24.push(h[i]); }
618
624
  if (b.length >= CHUNK_SAMPLES) sendChunk(ctx, a, kind, false);
619
625
  });
620
626
  for (const t of stream.getTracks()) t.addEventListener('ended', () => { if (audio !== a) return; a.ended++; if (a.ended >= a.streams.length) stopAudio(ctx); });
@@ -627,11 +633,13 @@ async function startAudio(ctx, n, want) {
627
633
  function sendChunk(ctx, a, kind, final) {
628
634
  const b = a.buf[kind] || []; a.buf[kind] = [];
629
635
  const pcm = b64(Int16Array.from(b));
636
+ const b24 = a.buf24[kind] || []; a.buf24[kind] = [];
637
+ const pcm24 = b24.length ? b64(Int16Array.from(b24)) : undefined;
630
638
  const seconds = b.length / RATE;
631
639
  a.queue[kind] = a.queue[kind].then(async () => {
632
640
  let r = null;
633
641
  for (let tries = 0; tries < SEND_TRIES; tries++) {
634
- r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, final });
642
+ r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, pcm24, final });
635
643
  if (r && r.ok) return;
636
644
  if (r && (r.status === 409 || r.status === 404)) break; // the server refused this note's audio: sending again changes nothing
637
645
  if (tries + 1 < SEND_TRIES) await new Promise((res) => setTimeout(res, SEND_BACKOFF_MS * 3 ** tries));
@@ -656,7 +664,10 @@ async function stopAudio(ctx, { discard = false } = {}) {
656
664
  try { if (a.ctx) await a.ctx.close(); } catch { /* closed */ }
657
665
  if (discard) { ctx.setState({}); return; }
658
666
  // the rest of what was said, then the summary once the last line is transcribed
659
- for (const k of a.sources) { const tail = a.rs[k] ? a.rs[k].flush() : []; const b = a.buf[k]; for (let i = 0; i < tail.length; i++) b.push(tail[i]); }
667
+ for (const k of a.sources) {
668
+ const tail = a.rs[k] ? a.rs[k].flush() : []; const b = a.buf[k]; for (let i = 0; i < tail.length; i++) b.push(tail[i]);
669
+ const tail24 = a.rs24[k] ? a.rs24[k].flush() : []; const b24 = a.buf24[k]; for (let i = 0; i < tail24.length; i++) b24.push(tail24[i]);
670
+ }
660
671
  await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
661
672
  await pmPost('/api/notes/pause', { id: a.id });
662
673
  ctx.setState({});
@@ -3889,7 +3889,7 @@ function taskWsId(run, t) {
3889
3889
  const brief = briefOfDay(run.date);
3890
3890
  if (!brief || !t.item_id) return null;
3891
3891
  const st = proSettings();
3892
- const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos() })[t.item_id];
3892
+ const r = goalsLib.itemRepos({ day: readGoalsDay(run.date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(), links: dayMarksLib.links(readDayMarks(run.date)) })[t.item_id];
3893
3893
  const w = r && r !== run.ws ? getWs(r) : null;
3894
3894
  return w && !daySkillPrompt(run, 'day-task', w.id).error ? w.id : null;
3895
3895
  } catch { return null; }
@@ -4035,9 +4035,10 @@ function writeDayMarks(rec) {
4035
4035
  fs.writeFileSync(tmp, JSON.stringify(rec) + '\n'); fs.renameSync(tmp, f); return true;
4036
4036
  } catch (e) { warnWriteFail('day marks', e); return false; }
4037
4037
  }
4038
- /** The ids a mark may name: today's brief items ('item') and calendar events ('event'). */
4039
- function dayMarkIds(brief) {
4038
+ /** The ids a mark may name: today's brief items ('item'), calendar events ('event') and the run's tasks ('task', for a link). */
4039
+ function dayMarkIds(brief, run) {
4040
4040
  const known = new Map();
4041
+ for (const t of (run && run.tasks) || []) if (t && /^t-[0-9a-f]{12}$/.test(String(t.id))) known.set(t.id, 'task');
4041
4042
  if (!brief) return known;
4042
4043
  for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) known.set(x.id, 'item');
4043
4044
  for (const e of brief.calendar || []) known.set(e.id, 'event');
@@ -7850,7 +7851,7 @@ async function handleRequest(req, res) {
7850
7851
  goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
7851
7852
  }
7852
7853
  }
7853
- const plan = goalsLib.planDay({ day, brief, cards, now });
7854
+ const plan = goalsLib.planDay({ day, brief, cards, now, links: dayMarksLib.links(readDayMarks(date)) });
7854
7855
  const sched = readSchedules().find((x) => x.skill === 'morning-brief' && (!w || x.ws === w.id)) || null;
7855
7856
  return json(res, { ok: true, ws: w ? w.id : null, today: date, plan, cards_error: cardsError, max_goals: goalsLib.MAX_GOALS,
7856
7857
  schedule: sched ? { id: sched.id, time: sched.time, lastRun: sched.lastRun, lastStatus: sched.lastStatus } : null,
@@ -7861,7 +7862,7 @@ async function handleRequest(req, res) {
7861
7862
  const now = Date.now();
7862
7863
  const date = briefLib.localDate(now);
7863
7864
  const r = goalsLib.edit(readGoalsDay(date), body, { now });
7864
- if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id/.test(r.error) ? 404 : 400);
7865
+ if (!r.ok) return json(res, { ok: false, error: r.error }, /no goal with that id|no job with that id/.test(r.error) ? 404 : 400);
7865
7866
  try { fs.mkdirSync(GOALS_DIR(), { recursive: true }); fs.writeFileSync(path.join(GOALS_DIR(), date + '.json'), JSON.stringify(r.day) + '\n'); }
7866
7867
  catch (e) { warnWriteFail('goals', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
7867
7868
  return json(res, { ok: true, day: r.day, goal: r.goal });
@@ -7943,7 +7944,7 @@ async function handleRequest(req, res) {
7943
7944
  goalsCardsMemo.set(w.id, { at: now, cards, error: cardsError });
7944
7945
  }
7945
7946
  }
7946
- const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now });
7947
+ const plan = goalsLib.planDay({ day: readGoalsDay(date), brief, cards, now, links: dayMarksLib.links(readDayMarks(date)) });
7947
7948
  const due = dayRunLib.checkDue(run, { settings: st, now, events: brief ? brief.calendar || [] : [] });
7948
7949
  const items = brief ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
7949
7950
  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 }));
@@ -7959,7 +7960,7 @@ async function handleRequest(req, res) {
7959
7960
  settings: { briefSources: st.briefSources, scopes: st.scopes, placeRepos: st.placeRepos, dayCheck: st.dayCheck, websites: st.websites }, sources,
7960
7961
  // one day for every repository: the repositories it can be narrowed to, and the repository of each tagged item
7961
7962
  repos: [...workspaces.values()].map((x) => ({ id: x.id, name: x.name || path.basename(x.root) })),
7962
- item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now) }),
7963
+ item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {}, iniRepos: iniRepos(now), links: dayMarksLib.links(readDayMarks(date)) }),
7963
7964
  goal_repos: goalsLib.goalRepos({ day: readGoalsDay(date), iniRepos: iniRepos(now) }),
7964
7965
  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 : [] } });
7965
7966
  }
@@ -7987,7 +7988,8 @@ async function handleRequest(req, res) {
7987
7988
  const stored = briefOfDay(date);
7988
7989
  const brief = stored && stored.date === date ? stored : null;
7989
7990
  if (!brief) return json(res, { ok: false, error: 'no brief today — press Prepare my day first' }, 409);
7990
- const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief) });
7991
+ const openGoals = new Set(readGoalsDay(date).goals.filter((g) => g.status === 'open').map((g) => g.id));
7992
+ const r = dayMarksLib.edit(readDayMarks(date), body, { now, known: dayMarkIds(brief, readDayRun(date)), goals: openGoals });
7991
7993
  if (!r.ok) return json(res, { ok: false, error: r.error }, /no item or meeting with that id/.test(r.error) ? 404 : 400);
7992
7994
  if (!writeDayMarks(r.rec)) return json(res, { ok: false, error: 'could not save — nothing changed' }, 500);
7993
7995
  return json(res, { ok: true, mark: r.mark, prev: r.prev, marks: dayMarksLib.effective(r.rec, now) });
@@ -1,12 +1,13 @@
1
1
  {
2
- "_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2) and the optional cleaned copy of the replay (notes-polish.test.js, Run 3), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
2
+ "_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2) and the optional cleaned copy of the replay (notes-polish.test.js, Run 3) and the 24 kHz replay (notes-hires.test.js, Run 4), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
3
3
  "modules": [
4
4
  "notes.test.js",
5
5
  "notes-listen.test.js",
6
6
  "notes-final.test.js",
7
7
  "notes-qwen.test.js",
8
8
  "notes-echo.test.js",
9
- "notes-polish.test.js"
9
+ "notes-polish.test.js",
10
+ "notes-hires.test.js"
10
11
  ],
11
12
  "panels": [
12
13
  "pm-notes-test.js",
@@ -20,6 +21,7 @@
20
21
  "notes-qwen.test.js": 4,
21
22
  "pm-note-dock-test.js": 20,
22
23
  "notes-echo.test.js": 25,
23
- "notes-polish.test.js": 3
24
+ "notes-polish.test.js": 3,
25
+ "notes-hires.test.js": 10
24
26
  }
25
27
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative today-goals-jobs, the client half: goals own jobs, who acts on each job, the link control and the meeting's goal block, in a browser against a real server.",
3
+ "panels": ["pm-today-goals-jobs-test.js"],
4
+ "seconds": { "pm-today-goals-jobs-test.js": 40 }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative today-goals-jobs: the pure core of goals that own jobs.",
3
+ "modules": ["today-goals-jobs.test.js"],
4
+ "seconds": { "today-goals-jobs.test.js": 2 }
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "Initiative today-goals-jobs, slice C: a Slack, Confluence or Figma mention draws as the card of its service, from the brief item's own fields, with no way to post to, resolve in or react in the service.",
3
+ "panels": ["pm-today-mentions-cards-test.js"],
4
+ "seconds": { "pm-today-mentions-cards-test.js": 25 }
5
+ }