create-pmos 0.59.2 → 0.60.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 (47) 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/scripts/delivery-index.js +85 -7
  6. package/payload/scripts/workflow-state.js +3 -3
  7. package/payload/state/discoveries.md +1 -1
  8. package/workspace/REMOTE.md +1 -1
  9. package/workspace/fixtures/initiatives-api/sheet.json +29 -7
  10. package/workspace/guide/index.md +3 -3
  11. package/workspace/guide/initiatives-screen.md +25 -1
  12. package/workspace/guide/layout.md +7 -9
  13. package/workspace/guide/proactive-engine.md +1 -0
  14. package/workspace/guide/today.md +23 -1
  15. package/workspace/lib/calendar-memory.js +257 -0
  16. package/workspace/lib/console-state.js +1 -1
  17. package/workspace/lib/doc-category.js +3 -1
  18. package/workspace/lib/engine/delivery-index.js +85 -7
  19. package/workspace/lib/engine/workflow-state.js +3 -3
  20. package/workspace/lib/initiative-files.js +59 -1
  21. package/workspace/lib/initiative-rows.js +14 -2
  22. package/workspace/lib/mobile-nav.js +1 -1
  23. package/workspace/lib/plan-tree.js +44 -5
  24. package/workspace/lib/proactive-cli.js +16 -1
  25. package/workspace/lib/routes-initiatives.js +165 -9
  26. package/workspace/package-lock.json +1 -1
  27. package/workspace/package.json +1 -1
  28. package/workspace/public/index.html +1 -1
  29. package/workspace/public/pm/components.css +70 -0
  30. package/workspace/public/pm/dest-initiatives.js +7 -1
  31. package/workspace/public/pm/dest-settings.js +1 -0
  32. package/workspace/public/pm/frame-copy.js +14 -2
  33. package/workspace/public/pm/initiative-sheet.js +42 -10
  34. package/workspace/public/pm/initiatives-api.js +15 -0
  35. package/workspace/public/pm/initiatives-copy.js +13 -0
  36. package/workspace/public/pm/initiatives-ui.js +14 -0
  37. package/workspace/public/pm/mobile.js +4 -5
  38. package/workspace/public/pm/registry.js +1 -1
  39. package/workspace/public/pm/tab-term.js +6 -50
  40. package/workspace/public/pm/today-agenda.js +305 -0
  41. package/workspace/public/pm/today-copy.js +54 -2
  42. package/workspace/public/pm/today-day.js +23 -3
  43. package/workspace/public/pm/today-goals.js +7 -1
  44. package/workspace/server.js +71 -63
  45. package/workspace/skills/morning-brief.skill +6 -1
  46. package/workspace/test-files.json +2 -0
  47. package/workspace/test-lists/scan-consistency.json +5 -0
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  export const COPY = {
12
- title: 'Today',
12
+ title: 'Agenda',
13
13
  /** "3 need you", "1 needs you". */
14
14
  count: (n) => `${n} ${n === 1 ? 'needs' : 'need'} you`,
15
15
  countIn: (n, repo) => `${n} ${n === 1 ? 'needs' : 'need'} you in ${repo || 'this repository'}`,
@@ -128,7 +128,7 @@ export const COPY = {
128
128
  unreached: 'The workspace did not answer.',
129
129
  refused: (why) => `Nothing changed. ${why}`,
130
130
  oldServer: 'This workspace server has no day run yet. Only your goals show.',
131
- panels: { summary: 'Agent results', calendar: 'Calendar', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', waiting: 'Also waiting for you', detail: 'Detail' },
131
+ panels: { summary: 'Agent results', calendar: 'Calendar', agenda: 'Agenda', jobs: 'Jobs', goals: 'Goals for today', mentions: 'Mentions since yesterday', waiting: 'Also waiting for you', detail: 'Detail' },
132
132
  summary: (c) => [c.review ? `${c.review} ${c.review === 1 ? 'draft waits' : 'drafts wait'} for you` : null, c.done ? `${c.done} done` : null,
133
133
  c.working ? `${c.working} working` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' · ') || 'No agent job yet.',
134
134
  summaryAll: (n) => `All ${n} agent ${n === 1 ? 'job has' : 'jobs have'} a result.`,
@@ -282,6 +282,58 @@ export const COPY = {
282
282
  siteFull: 'At most 10 websites.',
283
283
  remove: 'Remove',
284
284
  noSources: 'The sources could not be read.',
285
+ /** The Agenda month (G25 to G27): the month on top, the agenda of a day or a week under it, the meeting's memory. */
286
+ ag: {
287
+ title: 'Agenda',
288
+ loading: 'Reading the month…',
289
+ prev: 'Earlier month',
290
+ later: 'Later month',
291
+ thisMonth: 'This month',
292
+ weekNo: (n) => `W${n}`,
293
+ weekOf: (n) => `Week ${n}`,
294
+ weekHint: (n) => `Show the agenda of week ${n}`,
295
+ dayHint: (label, key) => `Show the agenda of ${label}${key ? '. Matters most: ' + key : ''}`,
296
+ lens: { overview: 'Overview', full: 'Full', key: 'Key', ready: 'Prep', free: 'Free' },
297
+ lensHelp: {
298
+ overview: 'Each day: how full it is, the meeting that matters and your prep.',
299
+ full: 'The bar is 09:00 to 18:00. Each block is a meeting. Dark blocks are important.',
300
+ key: 'The one meeting of the day that matters, when there is one.',
301
+ ready: 'Prep done out of prep needed. Red: too little free time before the meeting.',
302
+ free: 'Free blocks of 90 minutes or more.',
303
+ },
304
+ busy: (min) => (min >= 60 ? `${Math.floor(min / 60)} h${min % 60 ? ' ' + (min % 60) + ' min' : ''}` : `${min} min`),
305
+ freeBlocks: (n) => `${n} free ${n === 1 ? 'block' : 'blocks'}`,
306
+ noFree: 'No long free block',
307
+ readyBadge: (done, need) => `${done}/${need}`,
308
+ readyWord: { ok: 'prep done', warn: 'prep left', risk: 'prep at risk' },
309
+ noNotes: (n) => `${n} no notes`,
310
+ star: '★',
311
+ lit: (title, n) => `${title}: ${n} ${n === 1 ? 'day' : 'days'} marked on the month.`,
312
+ empty: 'Nothing is remembered yet. Press Prepare my day: the session reads your calendar 90 days back and 31 days ahead.',
313
+ emptyMonth: 'No meetings remembered for this month.',
314
+ unread: 'The month could not be read.',
315
+ tiles: { busy: 'In meetings', key: 'Matters most', ready: 'Prep' },
316
+ noKey: 'Nothing stands out',
317
+ noPrep: 'Nothing to prepare',
318
+ noMeetings: 'No meetings.',
319
+ routine: (n) => `+${n} repeating, nothing for you`,
320
+ hideRoutine: 'Hide the repeating ones',
321
+ kindWord: { one: 'One-to-one', onboard: 'Onboarding', group: 'Group meeting' },
322
+ why: { present: 'You present.', decide: 'You decide.', external: 'People from outside the company join.', exec: 'Leadership joins.', many: (n) => `${n} people join.`, routine: 'It repeats, and nothing in it is for you.' },
323
+ means: 'What it means',
324
+ before: 'Before this meeting',
325
+ prepLine: (done, need) => `${done} of ${need} prep steps done.`,
326
+ readyNote: { ok: 'Your prep is done.', warn: 'Some prep is left, and there is time for it.', risk: 'The free time before it is short for the prep left.' },
327
+ series: (past, coming) => `Held ${past} ${past === 1 ? 'time' : 'times'} so far · ${coming} to come.`,
328
+ once: 'It happens once.',
329
+ lastTime: (date) => `Last time · ${date}`,
330
+ noLast: 'No notes from the last time.',
331
+ afterNotes: 'Notes',
332
+ openLines: 'Still open',
333
+ noNotesYet: 'No notes for this meeting.',
334
+ people: (n) => `${n} people`,
335
+ today: 'Today',
336
+ },
285
337
  },
286
338
  /** The Acceptance Gate card (D12): the PM's own act on a run. */
287
339
  accept: {
@@ -28,6 +28,7 @@ import { pmGet, pmPost } from './http.js';
28
28
  import { FIXTURE } from './state.js';
29
29
  import { COPY } from './today-copy.js';
30
30
  import { goalRows, goalAdd, goalDetail, addGoal, goalsSig } from './today-goals.js';
31
+ import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAgenda } from './today-agenda.js';
31
32
 
32
33
  const D = COPY.day;
33
34
  const SLOW_MS = 30000;
@@ -73,6 +74,8 @@ async function load(ws) {
73
74
  const next = out && out.ok ? { data: out, error: null } : { data: prev ? prev.data : null, error: (out && out.error) || D.unreached };
74
75
  cache.set(ws, next);
75
76
  if (!prev || dataSig(prev.data) !== dataSig(next.data) || prev.error !== next.error) rerender();
77
+ // the month follows the brief and the clock: it is read again with the day
78
+ refreshAgenda();
76
79
  schedule();
77
80
  }
78
81
 
@@ -378,6 +381,13 @@ function eventChip(e) {
378
381
  function calendar(ctx) {
379
382
  const { m } = ctx;
380
383
  if (!m.events.length) return panel('calendar', null, note(m.run && m.stage === 'reading' ? D.readingJobs : D.noCalendar));
384
+ return panel('calendar', null, calendarRows(ctx));
385
+ }
386
+
387
+ /** Today's meetings from the brief, with a line at the current time: the agenda of today, and the calendar panel. */
388
+ function calendarRows(ctx) {
389
+ const { m } = ctx;
390
+ if (!m.events.length) return null;
381
391
  const rows = [];
382
392
  let nowShown = false;
383
393
  const nowRow = () => el('li', { class: 'td-day__nowrow', 'data-day': 'now', 'aria-hidden': 'true' }, D.now(hhmm(m.now)));
@@ -389,7 +399,14 @@ function calendar(ctx) {
389
399
  { 'data-past': e.ended ? 'true' : null, 'data-goal-tie': n ? 'true' : null }));
390
400
  }
391
401
  if (!nowShown) rows.push(nowRow());
392
- return panel('calendar', null, el('ul', { class: 'td-day__rows', 'data-day-list': 'calendar' }, ...rows));
402
+ return el('ul', { class: 'td-day__rows', 'data-day-list': 'calendar' }, ...rows);
403
+ }
404
+
405
+ /* The Agenda month (G25 to G27): today-agenda.js draws it with the helpers of this section. An older workspace server
406
+ * has no agenda route: then the day's calendar panel shows, as before. */
407
+ function agenda(ctx) {
408
+ const h = { panel, sel: ui.sel, todayRows: () => calendarRows(ctx), rowBtn: (type, id, parts, mods) => rowBtn(ctx, type, id, parts, mods) };
409
+ return agendaPanel(ctx, h) || calendar(ctx);
393
410
  }
394
411
 
395
412
  function jobRow(ctx, j) {
@@ -525,6 +542,7 @@ function eventDetail(ctx, e) {
525
542
  ini ? el('div', { class: 'td-day__ini', 'data-day': 'initiative' }, el('span', null, D.initiative + ': ', el('b', { class: 'td-day__mono' }, ini.id), ini.why ? el('span', { class: 'td-day__dim' }, ' (' + D.suggested(ini.why) + ')') : null),
526
543
  ctx.openIni ? button({ label: D.openInitiative, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'open-initiative' }, onClick: () => ctx.openIni(ini.id) }) : null) : null,
527
544
  tie,
545
+ memoryBlock(e.id),
528
546
  !e.ended ? el('div', { class: 'td-day__acts' }, e.prep ? pill(D.needPrep, 'warn') : steps.before && steps.before.length ? pill(D.nothingToPrep, 'ok') : null) : null,
529
547
  after,
530
548
  !e.ended ? [list(D.phases.before, (steps.before || []).map(stepItem)), list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), mailBlock] : null,
@@ -777,6 +795,7 @@ function detail(ctx) {
777
795
  if (s.type === 'channel') return channelDetail(ctx, s.id);
778
796
  if (s.type === 'step') return stepDetail(ctx, Math.max(0, Math.min(3, Number(s.id) || 0)));
779
797
  if (s.type === 'sources') return sourcesDetail(ctx);
798
+ if (s.type === 'meeting') return meetingDetail(ctx, s.id, { dhead });
780
799
  return null;
781
800
  };
782
801
  kids = find(sel);
@@ -798,6 +817,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
798
817
  // like Today's own feeds, a read that lands re-renders only while the section is on the page: a hidden Today must not
799
818
  // cause a whole-page render under whatever the PM is doing elsewhere
800
819
  if (setState) rerender = () => { if (document.querySelector('[data-today="day"]')) setState({}); };
820
+ onAgenda(() => rerender());
801
821
  ensure(ws);
802
822
  const c = cache.get(ws) || {};
803
823
  const m = model(c.data, { here: here || null, all: !!all });
@@ -828,12 +848,12 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
828
848
  c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
829
849
  m.d && m.d.legacy ? note(D.oldServer) : null,
830
850
  el('div', { class: 'td-day__cols' },
831
- el('div', { class: 'td-day__left' }, summaryPanel(ctx), goalsPanel(ctx), calendar(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
851
+ el('div', { class: 'td-day__left' }, summaryPanel(ctx), goalsPanel(ctx), agenda(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
832
852
  detail(ctx)));
833
853
  }
834
854
 
835
855
  export function daySig(ws) {
836
856
  const c = cache.get(ws) || {};
837
857
  return JSON.stringify([ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], c.error, runs.get(ws) || null, [...seen].length])
838
- + dataSig(c.data) + goalsSig();
858
+ + dataSig(c.data) + goalsSig() + agendaSig();
839
859
  }
@@ -21,6 +21,7 @@ import { COPY } from './today-copy.js';
21
21
  const G = COPY.goals;
22
22
  const D = COPY.day;
23
23
  let draft = '';
24
+ let lastAdded = null; // { key, at }: the text of the last goal sent, so a double commit adds it once
24
25
  const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
25
26
 
26
27
  async function press(ctx, body) {
@@ -69,10 +70,15 @@ export function goalAdd(ctx) {
69
70
  const f = field({ label: G.inputLabel, value: '', placeholder: G.inputHint, hideLabel: true, attrs: { 'data-goals': 'input', 'data-day-input': 'goal', maxlength: '140' },
70
71
  onCommit: async (v) => {
71
72
  if (!String(v).trim()) { draft = v; return; }
73
+ // a rebuild while the text is typed can commit it twice: once as the old field leaves the page, once on Enter.
74
+ // The second commit of the text just added clears the field and adds nothing.
75
+ const key = String(v).trim().toLowerCase();
76
+ if (lastAdded && lastAdded.key === key && Date.now() - lastAdded.at < 10000) { draft = ''; f.control.value = ''; ctx.rerender(); return; }
77
+ lastAdded = { key, at: Date.now() };
72
78
  // clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
73
79
  draft = '';
74
80
  if (await addGoal(ctx, v)) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; ctx.rerender(); }
75
- else { draft = v; ctx.rerender(); }
81
+ else { lastAdded = null; draft = v; ctx.rerender(); }
76
82
  } });
77
83
  // a rebuild keeps what is typed. The field is made empty and filled after, so Enter still commits the restored text.
78
84
  f.control.value = draft;
@@ -95,6 +95,7 @@ const judgmentLib = require('./lib/judgment');
95
95
  const proactiveLib = require('./lib/proactive');
96
96
  const learnLib = require('./lib/learn');
97
97
  const briefLib = require('./lib/brief');
98
+ const calMemLib = require('./lib/calendar-memory');
98
99
  const goalsLib = require('./lib/goals');
99
100
  const dayRunLib = require('./lib/day-run');
100
101
  const todayLib = require('./lib/today');
@@ -302,7 +303,8 @@ const TOKEN = BIND.token || null; // non-loopback peers must
302
303
  const onlineMode = require('./lib/online-mode');
303
304
  const accessJwt = require('./lib/access-jwt');
304
305
  const accessKeys = require('./lib/access-keys');
305
- const ONLINE = { settings: onlineMode.read(STATE), keys: null, keysTeam: null, fetchKeys: accessKeys.fetchCerts, find: onlineMode.findCloudflared };
306
+ const ONLINE = { settings: onlineMode.read(STATE), keys: null, keysTeam: null, fetchKeys: accessKeys.fetchCerts, find: onlineMode.findCloudflared,
307
+ lastRefusal: null }; // { reason, at } of the last refused sign-in: a fixed reason word only, never the assertion or a claim (D08)
306
308
  const tunnel = onlineMode.createTunnel({ stateDir: STATE, find: () => ONLINE.find() });
307
309
  /** The public hostname the guards admit right now: the environment's, else the Online switch's while it is on. */
308
310
  function publicHost() { return BIND.publicHost || (ONLINE.settings.on ? ONLINE.settings.host : null); }
@@ -331,21 +333,26 @@ function applyOnline() {
331
333
  /** The D89 decision for one request: { ok: true } or { ok: false, reason }. The assertion is never logged (D08). */
332
334
  async function accessVerdict(headers) {
333
335
  const s = ONLINE.settings; const store = ONLINE.keys;
334
- if (!store) return { ok: false, reason: 'no keys' };
336
+ if (!store) { const v = { ok: false, reason: 'no keys' }; noteVerdict(v); return v; }
335
337
  try { await store.ensure(); } catch { /* the store records its own error */ }
336
338
  const token = (headers || {})['cf-access-jwt-assertion'];
337
339
  const opts = { keyFor: store.keyFor, teamDomain: s.team, audience: s.audience, email: s.email, now: Math.floor(Date.now() / 1000) };
338
340
  let v = accessJwt.verify(token, opts);
339
341
  if (!v.ok && v.reason === 'unknown key' && v.kid && await store.refetchFor(v.kid)) v = accessJwt.verify(token, opts);
342
+ noteVerdict(v);
340
343
  return v;
341
344
  }
345
+ /** Keep the reason of the last refusal for Settings › Online, so the PM can see which check failed. A pass clears it. */
346
+ function noteVerdict(v) {
347
+ ONLINE.lastRefusal = v && v.ok ? null : { reason: String((v && v.reason) || 'unknown'), at: new Date().toISOString() };
348
+ }
342
349
  /** The Online record and state, as GET /api/online returns it. Nothing secret is in it: the fields are not credentials. */
343
350
  function onlineView(req) {
344
351
  const s = ONLINE.settings;
345
352
  return {
346
353
  ok: true, on: s.on, host: s.host, tunnel: s.tunnel, team: s.team, audience: s.audience, email: s.email,
347
354
  complete: onlineMode.complete(s), envHost: BIND.publicHost || null, cloudflared: !!ONLINE.find(),
348
- running: tunnel.status(), keys: ONLINE.keys ? ONLINE.keys.status() : null,
355
+ running: tunnel.status(), keys: ONLINE.keys ? ONLINE.keys.status() : null, lastRefusal: ONLINE.lastRefusal,
349
356
  canChange: onlineChangeAllowed(req),
350
357
  };
351
358
  }
@@ -453,13 +460,6 @@ function profileOverlay(profile) {
453
460
  }
454
461
 
455
462
  const SCROLLBACK = 200_000; // bytes of ring buffer per session
456
- const SCROLLBACK_SLACK = 32_768; // how far the ring may run over before a trim (appendBuf)
457
- // PTY output goes on the wire at most once per this window per session (queueOutput). node-pty
458
- // hands over whatever one read() returned, which for an agent CLI repainting its screen is many
459
- // small chunks — each one a JSON frame, a WebSocket message and a parse in the browser. The first
460
- // chunk after a quiet spell is sent at once, so a keystroke's echo is never delayed by it.
461
- const OUTPUT_COALESCE_MS = 4;
462
- const OUTPUT_COALESCE_MAX = 64 * 1024; // …and flushed at once past this, so a flood stays bounded
463
463
  // How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
464
464
  // 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
465
465
  // a 2s cadence puts the worst-case error in "quiet 31s" at one tick — under the rounding the UI
@@ -1456,7 +1456,13 @@ function parseHead(full) {
1456
1456
  }
1457
1457
  }
1458
1458
  const alsoIds = [...new Set(also.filter((x) => /^[A-Za-z0-9][\w.-]*$/.test(x)))];
1459
+ // scan-consistency R3 — the files a RECORD names in its frontmatter (`prd_path`, `explainer_path`, `design_path`, …).
1460
+ // Only the record role reads them (see docMeta); they are the strongest tie a document can have to an initiative.
1461
+ const fmBlock = /^---\r?\n([\s\S]*?)(?:\r?\n---|$)/.exec(head);
1462
+ const named = fmBlock ? [...fmBlock[1].matchAll(/^[a-z_]*_path[ \t]*:[ \t]*["']?([^"'\s][^"'\n]*?)["']?[ \t]*$/gm)]
1463
+ .map((m) => m[1].trim().replace(/^\.?\//, '')).filter((x) => x && !x.split('/').includes('..')) : [];
1459
1464
  return {
1465
+ named,
1460
1466
  initiative: id ? id[1] : (alsoIds[0] || null),
1461
1467
  also: alsoIds,
1462
1468
  recordId: rid ? rid[1] : null,
@@ -1522,6 +1528,7 @@ function docMeta(root, full) {
1522
1528
  // frontmatter wins; the filename convention is the fallback, never the override
1523
1529
  initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
1524
1530
  also: role === 'record' ? [] : (head.also || []),
1531
+ named: role === 'record' ? (head.named || []) : [],
1525
1532
  stem, title: head.title || null,
1526
1533
  kr: head.kr || null, lane: head.lane || null,
1527
1534
  dest: head.dest || null,
@@ -1575,6 +1582,19 @@ function initiativeIndex(root) {
1575
1582
  // a filename stem is promoted to a group only when it matches a declared id or when
1576
1583
  // several artifacts share it, so a lone research note never invents an initiative.
1577
1584
  const declared = new Set(metas.map((m) => m.initiative).filter(Boolean));
1585
+ // scan-consistency R3 — a document a record NAMES (`explainer_path: planning/explainers/x.md`) belongs to that record's
1586
+ // initiative, whatever its file name. Before this the join read only declarations and file names, so an explainer or
1587
+ // a design whose name differs from the id was attributed to nothing and the initiative showed its record alone (62 of
1588
+ // 283 here). When two records name one file, the first in path order keeps it; the other still lists it as shared.
1589
+ const namedBy = new Map();
1590
+ const alsoNamed = new Map();
1591
+ for (const m of metas.filter((x) => x.role === 'record' && x.initiative).sort((a, b) => a.path.localeCompare(b.path))) {
1592
+ for (const rel of m.named || []) {
1593
+ if (!namedBy.has(rel)) namedBy.set(rel, m.initiative);
1594
+ else if (namedBy.get(rel) !== m.initiative) { if (!alsoNamed.has(rel)) alsoNamed.set(rel, new Set()); alsoNamed.get(rel).add(m.initiative); }
1595
+ }
1596
+ }
1597
+ const relOf = (m) => String(m.path).split(path.sep).join('/');
1578
1598
  const byStem = new Map();
1579
1599
  for (const m of metas) if (!m.initiative) byStem.set(m.stem, (byStem.get(m.stem) || 0) + 1);
1580
1600
  const groups = new Map();
@@ -1603,6 +1623,7 @@ function initiativeIndex(root) {
1603
1623
  const prefixOwner = (stem) => capturing.find((id) => stem.length > id.length + 1 && stem.startsWith(id + '-')) || null;
1604
1624
  const keyFor = (m) => {
1605
1625
  if (m.initiative) return m.initiative;
1626
+ if (namedBy.has(relOf(m))) return namedBy.get(relOf(m));
1606
1627
  if (declared.has(m.stem)) return m.stem;
1607
1628
  // the convention join: `jira-sync-prd.md` + `jira-sync-eval-rubric.md` are one unit
1608
1629
  if ((byStem.get(m.stem) || 0) > 1) return m.stem;
@@ -1617,7 +1638,7 @@ function initiativeIndex(root) {
1617
1638
  g.artifacts.push(art);
1618
1639
  // overhaul-r2 C1 — listed under every OTHER initiative it names too (it does not lend them a
1619
1640
  // title, an anchor or a lane: those stay the record's and its primary owner's)
1620
- for (const other of m.also || []) {
1641
+ for (const other of [...(m.also || []), ...(alsoNamed.get(relOf(m)) || [])]) {
1621
1642
  if (other === k) continue;
1622
1643
  if (!groups.has(other)) groups.set(other, { id: other, title: null, kr: null, lane: null, artifacts: [] });
1623
1644
  groups.get(other).artifacts.push({ ...art, shared: true });
@@ -2843,11 +2864,7 @@ function trimBuf(buf, limit) {
2843
2864
 
2844
2865
  function appendBuf(s, data) {
2845
2866
  s.buf += data;
2846
- // Trimmed with SLACK, not on every byte past the limit. Once a busy session's ring is full, a trim
2847
- // per chunk flattens and scans the whole 200 KB string for each few bytes an agent CLI repaints —
2848
- // a spinner alone is dozens of chunks a second. Letting the ring run SCROLLBACK_SLACK over and then
2849
- // cutting it back to SCROLLBACK does the same work once per 32 KB of output.
2850
- if (s.buf.length > SCROLLBACK + SCROLLBACK_SLACK) s.buf = trimBuf(s.buf, SCROLLBACK);
2867
+ if (s.buf.length > SCROLLBACK) s.buf = trimBuf(s.buf, SCROLLBACK);
2851
2868
  s.bufDirty = true; // the periodic writer skips a session that is not dirty
2852
2869
  // Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
2853
2870
  // from reading the bytes — which is why it is the only one of the five that cannot misread.
@@ -2855,46 +2872,6 @@ function appendBuf(s, data) {
2855
2872
  s.parsed = null; // buffer moved; the cached parse is stale
2856
2873
  }
2857
2874
 
2858
- /**
2859
- * Put PTY output on the wire as at most ONE frame per OUTPUT_COALESCE_MS per session.
2860
- *
2861
- * Leading edge, then a window: a chunk that arrives when nothing has been sent recently goes out at
2862
- * once — a keystroke's echo is never held back — and opens a window in which further chunks are
2863
- * gathered and sent together when it closes. A burst (an agent CLI repainting its screen) therefore
2864
- * costs one frame per window instead of one per node-pty read, and a lone echo costs nothing.
2865
- *
2866
- * The bytes reach the ring (`appendBuf`) at the same moment they reach the clients, never before:
2867
- * a client that attaches in between gets `buf` without the pending bytes and then the flush, so it
2868
- * sees every byte exactly once and in order. Anything that writes to `buf` directly while output may
2869
- * be pending (the exit note) calls `flushOutput` first for the same reason.
2870
- */
2871
- function queueOutput(s, data) {
2872
- if (!data) return;
2873
- if (s._out) s._out += data; else s._out = data;
2874
- if (!s._outTimer) { sendOutput(s); s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS); return; }
2875
- if (s._out.length >= OUTPUT_COALESCE_MAX) sendOutput(s);
2876
- }
2877
- function closeOutputWindow(s) {
2878
- s._outTimer = null;
2879
- if (!s._out) return;
2880
- // Output kept arriving through the window: send it, and keep gathering for one more window.
2881
- sendOutput(s);
2882
- s._outTimer = setTimeout(() => closeOutputWindow(s), OUTPUT_COALESCE_MS);
2883
- }
2884
- function flushOutput(s) {
2885
- if (s._outTimer) { clearTimeout(s._outTimer); s._outTimer = null; }
2886
- sendOutput(s);
2887
- }
2888
- function sendOutput(s) {
2889
- const data = s._out;
2890
- if (!data) return;
2891
- s._out = '';
2892
- appendBuf(s, data);
2893
- if (!s.clients || !s.clients.size) return;
2894
- const frame = JSON.stringify({ t: 'data', id: s.id, data });
2895
- for (const c of s.clients) if (c.readyState === 1) c.send(frame);
2896
- }
2897
-
2898
2875
  // Platform-correct guidance when a spawn fails. On macOS `posix_spawnp failed` is almost always
2899
2876
  // node-pty's prebuilt `spawn-helper` missing its executable bit (prebuild extraction drops it) —
2900
2877
  // the binary is fine, just not +x. The server auto-restores it at startup and `npm run doctor`
@@ -3128,7 +3105,8 @@ function attachPty(s, { seed = false } = {}) {
3128
3105
  onOutput: (pane, text) => {
3129
3106
  if (paneId && pane !== paneId) return; // only this session's pane
3130
3107
  if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
3131
- queueOutput(s, text);
3108
+ appendBuf(s, text);
3109
+ for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: text }));
3132
3110
  },
3133
3111
  });
3134
3112
  p.onData((d) => parser.feed(d));
@@ -3136,7 +3114,6 @@ function attachPty(s, { seed = false } = {}) {
3136
3114
  // on the client PTY's own window size.
3137
3115
  client.resize(s.cols || 80, s.rows || 24);
3138
3116
  p.onExit(({ exitCode }) => {
3139
- flushOutput(s); // whatever the pane said last lands before a reconnect or the exit note
3140
3117
  s.pty = null;
3141
3118
  // Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
3142
3119
  // the tmux session is still alive, the process lives on — reconnect (bounded, to never spin).
@@ -3378,9 +3355,11 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3378
3355
  announceStateHealth(s);
3379
3356
  announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
3380
3357
  announceModel(s);
3381
- p.onData((d) => queueOutput(s, d));
3358
+ p.onData((d) => {
3359
+ appendBuf(s, d);
3360
+ for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
3361
+ });
3382
3362
  p.onExit(({ exitCode }) => {
3383
- flushOutput(s); // the last bytes the process wrote come before the exit note, in the ring and on the wire
3384
3363
  s.state = 'exited'; s.pty = null;
3385
3364
  const fast = Date.now() - startedMs < 800; // exited almost immediately → surface a hint
3386
3365
  s.note = fast
@@ -3879,6 +3858,15 @@ function dayTick() {
3879
3858
  if (JSON.stringify(run) !== before) writeDayRun(run);
3880
3859
  }
3881
3860
  // ── end of the today day run helpers ─────────────────────────────────────────────────────────────────────────────
3861
+ /* The calendar the Agenda remembers (G23): one file beside the briefs, written only by the brief route. */
3862
+ const CAL_MEMORY = () => path.join(STATE, 'calendar-memory.json');
3863
+ function readCalMemory() {
3864
+ try { return JSON.parse(fs.readFileSync(CAL_MEMORY(), 'utf8')); } catch { return null; }
3865
+ }
3866
+ function writeCalMemory(m) {
3867
+ const f = CAL_MEMORY(); const tmp = `${f}.${process.pid}.tmp`;
3868
+ fs.mkdirSync(STATE, { recursive: true }); fs.writeFileSync(tmp, JSON.stringify(m) + '\n'); fs.renameSync(tmp, f);
3869
+ }
3882
3870
 
3883
3871
  // ── Background prepare, slice D3 (initiative proactive-engine) ──────────────────────────────────────────────────
3884
3872
  // The PM approves ONE plan hash from Today. The 60-second tick later starts that session through the SAME path a next-step
@@ -7637,6 +7625,13 @@ async function handleRequest(req, res) {
7637
7625
  item_repos: goalsLib.itemRepos({ day: readGoalsDay(date), brief, placeRepos: st.placeRepos || {} }),
7638
7626
  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 : [] } });
7639
7627
  }
7628
+ /* The Agenda month (G25 to G27): read-only. The calendar memory and today's brief, joined by lib/calendar-memory.js. */
7629
+ if (p === '/api/proactive/agenda' && req.method === 'GET') {
7630
+ const now = Date.now();
7631
+ const month = url.searchParams.get('month') || undefined;
7632
+ if (month !== undefined && !/^\d{4}-\d{2}$/.test(month)) return json(res, { ok: false, error: 'month must be YYYY-MM' }, 400);
7633
+ return json(res, calMemLib.agendaMonth({ memory: readCalMemory(), brief: briefOfDay(briefLib.localDate(now)), month, now }));
7634
+ }
7640
7635
  if (p.startsWith('/api/proactive/day/') && req.method === 'POST') {
7641
7636
  const verb = p.slice('/api/proactive/day/'.length);
7642
7637
  if (!['deliver', 'sent', 'fail', 'decide', 'check'].includes(verb)) return json(res, { ok: false, error: 'unknown day-run action' }, 404);
@@ -7740,6 +7735,14 @@ async function handleRequest(req, res) {
7740
7735
  const bday = body && typeof body.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.date) ? body.date : briefLib.localDate(Date.now());
7741
7736
  const v = briefLib.validateBrief(body, { now: Date.now(), settings: proSettings(), goalIds: readGoalsDay(bday).goals.map((g) => g.id) });
7742
7737
  if (!v.ok) return json(res, { ok: false, error: v.error }, 400);
7738
+ // The calendar memory rides with the brief (G23): checked first, so a refused memory stores nothing at all.
7739
+ let mem = null;
7740
+ if (body.memory !== undefined) {
7741
+ if (v.brief.sources.calendar !== 'read') return json(res, { ok: false, error: 'a calendar memory was given but calendar is not reported as read' }, 400);
7742
+ const mv = calMemLib.validateMemory(body.memory, { now: Date.now() });
7743
+ if (!mv.ok) return json(res, { ok: false, error: mv.error }, 400);
7744
+ mem = mv.memory;
7745
+ }
7743
7746
  // `merge: true` (a daytime check, G22) merges into the stored brief of that day; with none stored it is stored as it is.
7744
7747
  let out = { ...v.brief, resolved: [] }; let merged = null;
7745
7748
  let stored = null; try { stored = JSON.parse(fs.readFileSync(path.join(dir, v.brief.date + '.json'), 'utf8')); } catch { /* none yet */ }
@@ -7752,10 +7755,15 @@ async function handleRequest(req, res) {
7752
7755
  }
7753
7756
  try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, out.date + '.json'), JSON.stringify(out) + '\n'); }
7754
7757
  catch (e) { warnWriteFail('briefs', e); return json(res, { ok: false, error: 'could not save — nothing changed' }, 500); }
7758
+ let remembered = null; let memoryError = null;
7759
+ if (mem) {
7760
+ try { const mm = calMemLib.mergeMemory(readCalMemory(), mem, { now: Date.now() }).memory; writeCalMemory(mm); remembered = { events: mem.events.length, from: mem.from, to: mem.to }; }
7761
+ catch (e) { warnWriteFail('calendar memory', e); memoryError = 'the brief was saved, but the calendar memory could not be saved'; }
7762
+ }
7755
7763
  // A brief for a day the PM pressed "Prepare my day": its agent priorities become tasks and start (G13), and a merge
7756
7764
  // closes the check that sent it (G22).
7757
7765
  try { dayAfterBrief(out.date, merged); } catch (e) { warnWriteFail('day run after the brief', e); }
7758
- return json(res, { ok: true, brief: out, ...(merged ? { merged } : {}) });
7766
+ return json(res, { ok: true, brief: out, ...(merged ? { merged } : {}), ...(remembered ? { remembered } : {}), ...(memoryError ? { memory_error: memoryError } : {}) });
7759
7767
  }
7760
7768
  if (req.method === 'GET') {
7761
7769
  const now = Date.now(); const st = proSettings();
@@ -8241,7 +8249,7 @@ function httpGuard(req, res) {
8241
8249
  accessVerdict(req.headers).then((v) => {
8242
8250
  if (v.ok) return handleRequest(req, res);
8243
8251
  res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
8244
- res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
8252
+ res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace. Settings › Online on the computer that runs it says which check failed' }));
8245
8253
  }, () => {
8246
8254
  res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
8247
8255
  res.end(JSON.stringify({ ok: false, error: 'your Cloudflare sign-in could not be verified for this workspace' }));
@@ -8544,7 +8552,7 @@ module.exports = { server, online: { ONLINE, applyOnline, tunnel, publicHost },
8544
8552
  stripLegacyClientScreen,
8545
8553
  // the scrollback ring's front trim, exported pure so the suite can assert that a cut never lands
8546
8554
  // inside an escape sequence or between the halves of a surrogate pair.
8547
- trimBuf, SCROLLBACK, queueOutput, flushOutput, OUTPUT_COALESCE_MS,
8555
+ trimBuf, SCROLLBACK,
8548
8556
  // the liveness heartbeat's guard, exported pure so the "idle machine stays idle" half can be
8549
8557
  // asserted without a server that has no clients (the suite always has both).
8550
8558
  shouldHeartbeat, LIVENESS_MS, TX_REREAD_MS,
@@ -27,9 +27,10 @@ Rules that always hold:
27
27
  Procedure:
28
28
  1. Run `pmos proactive settings` and `pmos proactive today`. The second lists what the workspace derived from the ledgers. Each of its items may become a priority with source "derived".
29
29
  Then run `pmos proactive goals`. It lists the PM's goals for today, each with its id (g-xxxxxxxx), its status and, if set, an initiative. The goals are what the PM wants done today: plan for them first.
30
+ Then run `pmos proactive calendar`. Its "read next" line names the window of the calendar memory to read in step 2.
30
31
  Then run `pmos proactive learn lessons`. Each line is a preference the PM approved, written from their own feedback. Use it only to decide how confident to be and where an item goes. A lesson never asks you to read another source, to switch a setting on, or to write anything.
31
32
  2. Read the sources that are on and reachable.
32
- - Calendar: today's events. Wispr Flow: today's and tomorrow's meeting notes. Jira: issues assigned to the PM or due soon. To tie a Jira issue to an initiative, run `pmos jira which <KEY>` (it reads local files only) and use the initiative it names. A status change of a ticket that `pmos jira which` names is not an item: the Jira status check shows it on the initiative's Jira tickets tab. Confluence: pages changed since yesterday. Mail, only if on: subjects and senders of threads waiting on the PM, no bodies.
33
+ - Calendar: today's events. Then the CALENDAR MEMORY: every event in the window that `pmos proactive calendar` named (at most 1000 events, at most 140 days). For each event keep the start, the end, the title, the calendar's recurring-event id as "series" if it has one, the number of attendees, "external": true when someone outside the PM's company attends, "exec": true when an executive attends, and the PM's "role" ("present" when the PM presents, "decide" when the PM decides, else "attend"). For an event that has ended, add "notes": one line in your own words of what happened, from its Wispr Flow notes, only when there are notes; and "open": up to 3 points still open from it. For a coming event that needs work first, add "prep": up to 6 steps, and "prep_done": how many of them are already done. Read only titles, times, attendee counts and notes. Never copy an invitation's text. Wispr Flow: today's and tomorrow's meeting notes. Jira: issues assigned to the PM or due soon. To tie a Jira issue to an initiative, run `pmos jira which <KEY>` (it reads local files only) and use the initiative it names. A status change of a ticket that `pmos jira which` names is not an item: the Jira status check shows it on the initiative's Jira tickets tab. Confluence: pages changed since yesterday. Mail, only if on: subjects and senders of threads waiting on the PM, no bodies.
33
34
  - GitHub, if on: pull requests that wait on the PM for review, pull requests of the PM that have a review or a failed check, and issues assigned to the PM. Keep the repository, the number, the title and the state.
34
35
  - Drive, if on: files shared with the PM or changed since yesterday that a meeting or a goal names. Keep the name, the owner and the link.
35
36
  - Figma, if on: files the PM owns or was asked to look at, with new comments since yesterday. Keep the file, the commenter and a short paraphrase of the comment.
@@ -72,8 +73,12 @@ Procedure:
72
73
  "link": "https://…", "where": "#pm-team", "who": "…", "about": "…", "ask": "…", "reply_needed": true, "how": "…", "kind": "you|approve|auto"}],
73
74
  "digests": [{"source": "slack|jira|confluence", "name": "#pm-team", "link": "https://…", "decisions": ["…"], "open": ["…"], "goals": ["…"]}],
74
75
  "places": [{"source": "slack|jira|confluence", "name": "#pm-team", "link": "https://…"}],
76
+ "memory": {"from": "YYYY-MM-DD", "to": "YYYY-MM-DD",
77
+ "events": [{"start": "<ISO>", "end": "<ISO>", "title": "…", "series": "<recurring id or omit>", "attendees": 2, "external": false, "exec": false, "role": "present|decide|attend",
78
+ "notes": "<ended only>", "open": ["…"], "prep": ["<coming only>"], "prep_done": 0}]},
75
79
  "judgment": {"used": false}}
76
80
  ```
81
+ "memory" is optional: give it only when calendar is "read". Its "from" and "to" are the window you read, and every event's start is inside it. The workspace replaces the days of that window in what it remembers.
77
82
  "places" holds at most 200 entries, each a name of at most 120 characters, only for a source that is "read". A digest has at most 5 decisions, 5 open questions and 5 goal lines, each at most 140 characters, and at most 30 digests. Write a digest only for a place with scope watch, and only for a source that is "read". Only report calendar events if calendar is "read". Only give an item a source that is "read" (or "derived"). Only use a goal id that `pmos proactive goals` listed today: the workspace refuses any other. Leave out a field you cannot fill: an empty string is worse than no field.
78
83
  5. Run `pmos proactive brief submit --file <that file>`. If it is refused, read the reason, fix the file and submit once more. Do not work around a refusal.
79
84
  6. Finish by saying in three lines how many goals have a step and which have none, how many priorities and worth-asking items you submitted, and which sources were not reached.
@@ -10,6 +10,7 @@
10
10
  "modules": [
11
11
  "proactive.test.js",
12
12
  "goals.test.js",
13
+ "agenda.test.js",
13
14
  "day-run.test.js",
14
15
  "focus.test.js",
15
16
  "handoff.test.js",
@@ -109,6 +110,7 @@
109
110
  "test.js": 48,
110
111
  "proactive.test.js": 4,
111
112
  "goals.test.js": 6,
113
+ "agenda.test.js": 5,
112
114
  "day-run.test.js": 6,
113
115
  "focus.test.js": 1,
114
116
  "handoff.test.js": 1,
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "scan-consistency: delivery attribution by change, stage frontier, Files sources and the breakdown read from the spec.",
3
+ "modules": ["scan-consistency.test.js"],
4
+ "seconds": { "scan-consistency.test.js": 10 }
5
+ }