create-pmos 0.116.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.
@@ -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
+ }
@@ -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) });
@@ -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
+ }