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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +2 -0
- package/workspace/guide/today.md +21 -12
- package/workspace/lib/audio-resample.js +76 -0
- package/workspace/lib/brief.js +2 -2
- package/workspace/lib/day-marks.js +29 -9
- package/workspace/lib/goals.js +68 -10
- package/workspace/lib/notes-audio.js +62 -20
- package/workspace/lib/notes-echo.js +32 -14
- package/workspace/lib/notes-hires.js +94 -0
- package/workspace/lib/notes-polish.js +2 -3
- package/workspace/lib/routes-notes.js +9 -1
- package/workspace/notes-test-signals.js +65 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/audio-capture.js +20 -6
- package/workspace/public/pm/components.css +39 -11
- package/workspace/public/pm/today-agenda.js +27 -53
- package/workspace/public/pm/today-copy.js +34 -15
- package/workspace/public/pm/today-day.js +98 -76
- package/workspace/public/pm/today-goals.js +159 -15
- package/workspace/public/pm/today-jobs.js +84 -0
- package/workspace/public/pm/today-mentions.js +50 -0
- package/workspace/public/pm/view-notes.js +15 -4
- package/workspace/server.js +10 -8
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
- package/workspace/test-lists/today-goals-jobs.json +5 -0
- 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
|
-
|
|
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
|
|
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) :
|
|
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
|
|
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:
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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({});
|
package/workspace/server.js
CHANGED
|
@@ -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')
|
|
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
|
|
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, 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
|
+
}
|