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
@@ -175,6 +175,42 @@ function owedFor(group, steps, def) {
175
175
  return null;
176
176
  }
177
177
 
178
+ /**
179
+ * The index of the furthest stage with evidence (scan-consistency R2): the last stage that holds a step the ladder
180
+ * reads `done`. -1 when no stage has one.
181
+ *
182
+ * WHY THE STEPPER NEEDS IT. The current stage used to be the FIRST stage with an open step. A step skipped long ago — a
183
+ * `priors` citation nobody wrote, a contract never logged before the build — then pinned the initiative at Spec or
184
+ * Contract for ever, while the build had merged and the PM had accepted it: 90 built initiatives read Spec on this
185
+ * repository, and 51 accepted ones read Intake, Spec, Contract or Build. Work moves forward past a gap; the gap stays a
186
+ * fact. So the current stage is the first stage with an open step AT OR AFTER the frontier, and an earlier stage that
187
+ * still has an open step reads `skipped` (its owed row is kept), never `done`.
188
+ */
189
+ function frontierOf(stages, steps) {
190
+ const done = new Set((steps || []).filter((s) => s && s.status === 'done').map((s) => s.key));
191
+ // Accepted work is finished whatever its leftover steps (initiative-display.js rule 2): the frontier is past the end,
192
+ // so no stage is current and every stage with an open step reads `skipped`. A document lane is finished once it is
193
+ // approved and no handoff is still open.
194
+ const by = new Map((steps || []).filter(Boolean).map((s) => [s.key, s.status]));
195
+ if (by.get('accept') === 'done' || (by.get('approved') === 'done' && !OPEN(by.get('handoff') || 'na'))) return stages.length;
196
+ let at = -1;
197
+ stages.forEach((s, i) => { if (s.steps.some((k) => done.has(k))) at = i; });
198
+ return at;
199
+ }
200
+
201
+ /**
202
+ * The stage an initiative is in now, from its ladder steps, by the same rule the Breakdown and the sheet's stepper use:
203
+ * { id, label } of the first stage at or after the frontier with an open step, or null when no stage has one.
204
+ */
205
+ function currentStage(root, initiative, steps, def) {
206
+ const stages = stagesFor(root, initiative);
207
+ const front = frontierOf(stages, steps);
208
+ for (let i = Math.max(front, 0); i < stages.length; i++) {
209
+ if (owedFor(stages[i], steps, def) !== null) return { id: stages[i].id, label: stages[i].label };
210
+ }
211
+ return null;
212
+ }
213
+
178
214
  /**
179
215
  * The progress label (R6), shared by the layout, the fold's progressOf and the CLI. Proposed and withdrawn items never
180
216
  * count; when nothing is kept yet but an agent proposed items, it says so instead of a bare "no breakdown".
@@ -309,7 +345,8 @@ function layout({ root, initiative, fold, ladder, now }) {
309
345
  const counted = (list) => list.filter((it) => !it.proposed);
310
346
 
311
347
  let firstOpenSeen = false;
312
- const stageRows = stages.map((s) => {
348
+ const frontier = steps ? frontierOf(stages, steps) : -1;
349
+ const stageRows = stages.map((s, i) => {
313
350
  const inStage = visibleItems.filter((it) => it.stage === s.id).sort(treeOrder);
314
351
  const real = counted(inStage);
315
352
  const total = real.length;
@@ -324,10 +361,12 @@ function layout({ root, initiative, fold, ladder, now }) {
324
361
  // `state` and `owed` are not derived, so they are left out: the client keeps them from its last full
325
362
  // response. A ladder that was asked for but failed gives `current:false`, `state:null`, `owed:null`.
326
363
  if (steps) {
327
- const current = owed !== null && !firstOpenSeen;
364
+ // A stage behind the frontier with a step still open was passed over, not finished (see frontierOf).
365
+ const skipped = owed !== null && i < frontier;
366
+ const current = owed !== null && !skipped && !firstOpenSeen;
328
367
  row.current = current;
329
- row.state = current ? 'current' : (firstOpenSeen ? 'later' : 'done');
330
- if (owed !== null) firstOpenSeen = true;
368
+ row.state = current ? 'current' : skipped ? 'skipped' : (firstOpenSeen ? 'later' : 'done');
369
+ if (owed !== null && !skipped) firstOpenSeen = true;
331
370
  } else if (ladderIncluded) {
332
371
  row.current = false;
333
372
  row.state = null;
@@ -376,5 +415,5 @@ function layout({ root, initiative, fold, ladder, now }) {
376
415
 
377
416
  module.exports = {
378
417
  STAGE_GROUPS, STEP_NOUN, OWED_ACTOR, OPEN, DONE_WINDOW_MS, INIT_RE, NOT_MERGED_LABEL, withFactory,
379
- mainTree, recordPath, profileAndLane, viewGroup, stagesFor, stageIdsFor, resolveStage, owedFor, nounFor, byLabel, progressLabel, layout, historyText,
418
+ mainTree, recordPath, profileAndLane, viewGroup, stagesFor, stageIdsFor, resolveStage, owedFor, frontierOf, currentStage, nounFor, byLabel, progressLabel, layout, historyText,
380
419
  };
@@ -15,6 +15,7 @@
15
15
  * [--website URL] [--no-website URL]
16
16
  * pmos proactive brief submit --file PATH [--merge]
17
17
  * pmos proactive brief schedule [--repo PATH]
18
+ * pmos proactive calendar what the calendar memory holds, and the window the next read covers
18
19
  * pmos proactive goals [list] | add TEXT [--initiative ID] | done N | drop N | reopen N | remove N | plan [--repo PATH]
19
20
  * pmos proactive day [--repo PATH] the day run: its stage, its tasks and the daytime check
20
21
  * pmos proactive day show TASK one task with its item, draft and revision notes (what day-task reads)
@@ -35,7 +36,7 @@ const path = require('path');
35
36
  const { execFileSync } = require('child_process');
36
37
  const { parseArgs } = require('./schedule-cli');
37
38
 
38
- const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'goals', 'prepare', 'learn', 'day'] };
39
+ const COMMANDS = { proactive: ['today', 'scan', 'settings', 'feedback', 'brief', 'goals', 'prepare', 'learn', 'day', 'calendar'] };
39
40
  const BRIEF_SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence', 'github', 'drive', 'figma', 'website'];
40
41
  const onOff = (v, what) => (v === 'on' || v === 'true' ? true : v === 'off' || v === 'false' ? false : { err: `${what} takes on or off` });
41
42
 
@@ -355,6 +356,18 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
355
356
  if (t.cards_error) say('! Today could not be read: ' + t.cards_error);
356
357
  return 0;
357
358
  }
359
+ if (verb === 'calendar') {
360
+ // Read-only: what the Agenda remembers, and which window the morning brief reads next (G23).
361
+ const r = await call('GET', '/api/proactive/agenda');
362
+ if (!r || r.ok === false) return fail((r && r.error) || 'the server did not answer');
363
+ const day = (n) => { const d = new Date(Date.now() + n * 86400000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
364
+ const m = r.memory;
365
+ const stale = !m || !m.updated_at || Date.now() - Date.parse(m.updated_at) > 7 * 86400000 || !m.from || m.from > day(-80);
366
+ if (m && m.events) say(`calendar memory: ${m.events} events from ${m.from} to ${m.to}, updated ${String(m.updated_at || '').slice(0, 16).replace('T', ' ')}`);
367
+ else say('calendar memory: empty');
368
+ say(stale ? `read next: the wide window, from ${day(-90)} to ${day(31)}` : `read next: from ${day(0)} to ${day(31)} (the earlier days are remembered)`);
369
+ return 0;
370
+ }
358
371
  if (verb === 'brief') {
359
372
  const sub = pos[0] || 'show';
360
373
  if (sub === 'submit') {
@@ -366,6 +379,8 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
366
379
  if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the server did not answer'}. Nothing was stored.`);
367
380
  if (merge) say(`merged into the brief for ${r.brief.date} — ${r.merged ? r.merged.found : 0} new, ${r.merged ? r.merged.resolved : 0} resolved; it now holds ${r.brief.priorities.length} priorities, ${r.brief.worth_asking.length} worth asking`);
368
381
  else say(`stored the brief for ${r.brief.date} — ${r.brief.priorities.length} priorities, ${r.brief.worth_asking.length} worth asking`);
382
+ if (r.memory_error) say('! ' + r.memory_error);
383
+ if (r.remembered) say(`remembered ${r.remembered.events} calendar events from ${r.remembered.from} to ${r.remembered.to}`);
369
384
  return 0;
370
385
  }
371
386
  if (sub === 'schedule') {
@@ -7,6 +7,8 @@
7
7
  * GET /api/initiatives/<id>/export?ws= → { ok, id, markdown } (lib/breakdown-export.js; nothing is written)
8
8
  * POST /api/initiatives { ws, title, lane, anchor, intent?, id?, from?, specimen_test?, surface_check? }
9
9
  * → { ok, id, path, written, sig } New initiative (requirePagePress)
10
+ * POST /api/initiatives/rescan { ws } → { ok, started, running } read the repository again now (requirePagePress);
11
+ * nothing is written: the cached rows go stale and one derivation starts
10
12
  * POST /api/placements { ws, events: [placed | created | adopted | dropped] }
11
13
  * → { ok, written, sig } fixtures/initiatives-api/post-ok.json
12
14
  * POST /api/measurements { ws, events: [measured] } → { ok, written, sig }
@@ -42,10 +44,14 @@ const rowsLib = require('./initiative-rows.js');
42
44
  const writer = require('./initiative-writer.js');
43
45
  const breakdownExport = require('./breakdown-export.js');
44
46
  const consoleState = require('./console-state');
47
+ const initiativeFiles = require('./initiative-files');
48
+ const jiraTickets = require('./jira-tickets.js');
45
49
 
46
50
  const HORIZON_RANK = { now: 0, next: 1, later: 2 };
47
51
  const FILE_KEYS = [['brief_path', 'brief', 'Brief'], ['prd_path', 'prd', 'PRD'], ['rubric_path', 'rubric', 'Rubric'], ['doc_path', 'doc', 'Document']];
48
52
  const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
53
+ const GIT_GROUP_LABEL = { code: 'Code', tests: 'Test', documents: 'Document', other: 'File' };
54
+ const GIT_FILES_MAX = 80;
49
55
 
50
56
  /** The signature of everything a read answers from, taken before the read. */
51
57
  function sigOf(root) {
@@ -96,9 +102,14 @@ function frameOf(w, rows, { now, sessions = [] } = {}) {
96
102
  return { root, rules, placed, fold, meas, join, anchors, accepted, byId, now, sessions };
97
103
  }
98
104
 
99
- /** The stage of a workflow row as the R4 stage group it falls in: { id, label }. */
105
+ /** The stage of a workflow row as the R4 stage group it falls in: { id, label }. The stage the work is IN is read from the
106
+ * ladder steps by plan-tree.js currentStage, the rule the sheet's stepper draws (scan-consistency R2), so a list row and
107
+ * its sheet never name two different stages. Accepted work, and a row without steps, keep the engine's stage. */
100
108
  function stageOf(root, rec) {
101
109
  const raw = rec.stage || null;
110
+ if (raw !== 'accepted' && Array.isArray(rec.steps)) {
111
+ try { const cur = planTree.currentStage(root, rec.id, rec.steps, null); if (cur && cur.label) return cur; } catch { /* fall through */ }
112
+ }
102
113
  if (!raw) return { id: null, label: 'Unknown' };
103
114
  try {
104
115
  const st = planTree.stagesFor(root, rec.id).find((s) => s.id === raw || s.steps.includes(raw));
@@ -139,10 +150,14 @@ function rowOf(F, rec) {
139
150
  const p = F.placed.byInit[id];
140
151
  const a = F.anchors.resolve(rec.anchor);
141
152
  const ctx = displayCtx(F, rec, id);
142
- const d = display.displayOf({ ...rec, last_activity: rec.last_activity }, ctx);
153
+ // Without the steps: display's owed step is the FIRST open one, which names a step skipped long ago; who the sheet
154
+ // waits on comes from the layout's current stage (sheetOf), so the row's word and the stepper agree.
155
+ const d = display.displayOf({ ...rec, steps: undefined, last_activity: rec.last_activity }, ctx);
143
156
  const measurement = measurements.latest(F.meas, a.token || '') || (rec.anchor ? measurements.latest(F.meas, String(rec.anchor).trim()) : null);
144
157
  const h = health.healthOf({ id, row: rec, fold: ctx.fold, placement: ctx.placement, measurement, factory: factoryJoin.factoryOf(F.join, id), rules: F.rules, now: F.now });
145
- const pr = planItems.progressOf(F.fold, id);
158
+ let pr = planItems.progressOf(F.fold, id);
159
+ // No planned item: the breakdown the repository's own spec lists, labelled with its source (derivedBreakdown).
160
+ if (!pr.total && !pr.proposed) { const d = derivedBreakdown(F.root, rec, { progress: pr }); if (d) pr = d.progress; }
146
161
  return {
147
162
  id, title: rec.title || id, lane: rec.lane, anchor: a.anchor, anchor_kind: a.kind,
148
163
  horizon: p.horizon, rank: p.rank, confirmed: p.confirmed, dormant: p.dormant, check_by: p.check_by,
@@ -240,13 +255,13 @@ function listOf(w, F) {
240
255
  };
241
256
  }
242
257
 
243
- function sheetOf(w, F, rec) {
258
+ function sheetOf(w, F, rec, opts = {}) {
244
259
  const root = F.root;
245
260
  const row = rowOf(F, rec);
246
261
  let lad = null;
247
262
  try { lad = consoleState.ladder({ root, id: rec.id }); } catch (e) { lad = { ok: false, error: String((e && e.message) || e) }; }
248
263
  const lay = planTree.layout({ root, initiative: rec.id, fold: F.fold, ladder: lad, now: F.now });
249
- const stepper = lay.stages.map((s) => ({ id: s.id, label: s.label, state: s.state === 'current' ? 'current' : s.state === 'done' ? 'done' : 'todo' }));
264
+ const stepper = lay.stages.map((s) => ({ id: s.id, label: s.label, state: s.state === 'current' ? 'current' : s.state === 'done' ? 'done' : s.state === 'skipped' ? 'skipped' : 'todo', owed: s.state === 'skipped' && s.owed ? s.owed.noun : null }));
250
265
  let waiting = null;
251
266
  if (row.display.waitingOn) {
252
267
  waiting = { on: row.display.waitingOn, act: row.display.act, action: row.display.waitingOn === 'pm' ? { label: 'Open gate', kind: 'gate', target: null } : { label: 'Hand to agent', kind: 'handoff', target: rec.id } };
@@ -263,12 +278,144 @@ function sheetOf(w, F, rec) {
263
278
  const file = planTree.recordPath(root, rec.id);
264
279
  if (loaded.ok && file) fm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {};
265
280
  } catch { fm = {}; }
266
- const files = FILE_KEYS.filter(([k]) => typeof fm[k] === 'string' && fm[k].trim()).map(([k, kind, label]) => ({ kind, label, path: String(fm[k]).trim() }));
281
+ const files = filesOf(root, rec, fm, opts);
282
+ const derived = derivedBreakdown(root, rec, lay);
267
283
  const verb = { now: 'Moved to Now', next: 'Moved to Next', later: 'Moved to Later' };
268
284
  const history = F.placed.moves.filter((m) => m.init === rec.id)
269
285
  .map((m) => ({ at: m.at, by: actorLib.pageName(m.by), text: m.from === null && m.to === 'later' ? 'Added to Later' : verb[m.to] }))
270
286
  .reverse();
271
- return { initiative: row, stepper, waiting, files, history, cli: { now: `pmos initiative now ${rec.id}`, export: `pmos initiative export ${rec.id}` } };
287
+ return { initiative: row, stepper, waiting, files, derived, history, cli: { now: `pmos initiative now ${rec.id}`, export: `pmos initiative export ${rec.id}` } };
288
+ }
289
+
290
+ /**
291
+ * Every file that belongs to one initiative (scan-consistency R3), in three sources, each row saying which:
292
+ * record the record itself and every `*_path` its frontmatter names (prd, rubric, brief, explainer, design, …)
293
+ * knowledge-graph the documents the library's initiative index joins to it (server.js initiativeIndex: a declared
294
+ * initiative_id, the record's own paths, the file-name convention, the declared-id prefix)
295
+ * git the files the commits for it changed (lib/initiative-files.js: its branch, and trunk commits naming it)
296
+ * A path is listed once, under its first source. A git read that fails is a `{ kind: 'note' }` row, never an empty list.
297
+ */
298
+ function filesOf(root, rec, fm, opts) {
299
+ const out = [];
300
+ const seen = new Set();
301
+ const exists = (rel) => { try { return fs.statSync(path.join(root, rel)).isFile(); } catch { return false; } };
302
+ // A path the record names is listed even when it is absent, marked `missing` (a dangling pointer is a fact to see);
303
+ // the other two sources list only files that exist.
304
+ const push = (row) => {
305
+ const p = String(row.path || '').trim().replace(/^\.?\//, '');
306
+ if (!p || seen.has(p) || p.split('/').includes('..')) return;
307
+ const there = exists(p);
308
+ if (!there && row.source !== 'record') return;
309
+ seen.add(p);
310
+ out.push(there ? { ...row, path: p } : { ...row, path: p, missing: true });
311
+ };
312
+ for (const [k, kind, label] of FILE_KEYS) if (typeof fm[k] === 'string' && fm[k].trim()) push({ kind, label, path: String(fm[k]).trim(), source: 'record' });
313
+ for (const [k, v] of Object.entries(fm || {})) {
314
+ if (!/_path$/.test(k) || typeof v !== 'string' || !v.trim() || FILE_KEYS.some(([fk]) => fk === k)) continue;
315
+ const kind = k.replace(/_path$/, '');
316
+ push({ kind, label: cap(kind.replace(/_/g, ' ')), path: v.trim(), source: 'record' });
317
+ }
318
+ if (rec.record) push({ kind: 'record', label: 'Record', path: String(rec.record), source: 'record' });
319
+ let groups = [];
320
+ try { groups = opts && opts.initiativeIndex ? (opts.initiativeIndex(root).initiatives || []) : []; } catch { groups = []; }
321
+ const g = groups.find((x) => x && x.id === rec.id);
322
+ for (const a of (g && g.artifacts) || []) {
323
+ const role = String(a.role || 'doc');
324
+ push({ kind: role.replace(/\s+/g, '-').toLowerCase(), label: role === 'doc' ? 'Document' : cap(role), path: String(a.path || '').split(path.sep).join('/'), source: 'knowledge-graph', shared: !!a.shared });
325
+ }
326
+ let git = null;
327
+ try { git = initiativeFiles.filesFor(root, rec.id, { exclude: seen }); } catch (e) { git = { ok: false, reason: String((e && e.message) || e).slice(0, 120) }; }
328
+ if (git && git.ok) {
329
+ const items = git.items.filter((x) => x.where === 'head');
330
+ for (const x of items.slice(0, GIT_FILES_MAX)) push({ kind: x.group, label: GIT_GROUP_LABEL[x.group] || 'File', path: x.path, source: 'git', commits: x.commits });
331
+ if (items.length > GIT_FILES_MAX) out.push({ kind: 'note', label: `${items.length - GIT_FILES_MAX} more files changed for it are not listed`, path: '', source: 'git' });
332
+ } else if (git) {
333
+ out.push({ kind: 'note', label: 'The files git changed for it could not be read', path: '', source: 'git', reason: git.reason || null });
334
+ }
335
+ return out;
336
+ }
337
+
338
+ /**
339
+ * The work breakdown read from the repository when nobody has planned one (scan-consistency R4).
340
+ *
341
+ * The Breakdown is the plan-items ledger, and on this repository not one initiative has a ledger line, so every
342
+ * Breakdown read empty although the PRDs list the work. When the ledger holds no item for the initiative, this reads the
343
+ * items of the spec the record names (lib/jira-tickets.js items: the user stories and story files) and, when it has none,
344
+ * the top-level entries of its Requirements section. Each item's state is the initiative's, from the ladder: `done` once
345
+ * the PM accepted it, `review` once its build is evidenced, `todo` before that. That is a stated inference about the
346
+ * whole initiative, not a claim about each item, so every answer carries its basis. Nothing is written: Plan and Keep
347
+ * stay the way a breakdown enters the ledger.
348
+ */
349
+ const REQ_SECTION = /^(?:functional\s+)?requirements\b|^deliverables\b|^work\s+items?\b/i;
350
+ // an item's name: up to its first sentence end, dash or colon, so a requirement written as a paragraph reads as a title
351
+ const firstSentence = (t) => String(t || '').split(/(?<=[a-z0-9)`'"])[.:]\s|\s[—–]\s/i)[0].replace(/[.:;]$/, '').trim();
352
+ const NOT_AN_ITEM = /^(?:in|out|in scope|out of scope|non[- ]goals?|notes?|open questions?|risks?|constraints?)$/i;
353
+ function requirementItems(text) {
354
+ const lines = String(text || '').replace(/^---\n[\s\S]*?\n---\n?/, '').split('\n');
355
+ const out = [];
356
+ for (let i = 0; i < lines.length; i++) {
357
+ const m = /^(#{2,4})\s+(?:\d+[.)]?\s+)?(.+?)\s*$/.exec(lines[i]);
358
+ if (!m || !REQ_SECTION.test(m[2].replace(/[*_`]/g, '').trim())) continue;
359
+ const level = m[1].length;
360
+ const body = [];
361
+ for (let j = i + 1; j < lines.length; j++) {
362
+ const h = /^(#{1,6})\s/.exec(lines[j]);
363
+ if (h && h[1].length <= level) break;
364
+ body.push(lines[j]);
365
+ }
366
+ // The section's own top-level entries, before its first subheading; else its subheadings, minus the ones that
367
+ // name a part of the section rather than a piece of work (Non-goals, In, Out).
368
+ const sub = '#'.repeat(level + 1) + ' ';
369
+ const firstSub = body.findIndex((l) => /^#{2,6}\s/.test(l));
370
+ for (const l of firstSub < 0 ? body : body.slice(0, firstSub)) {
371
+ const b = /^(?:[-*]|\d+[.)])\s+(.+)$/.exec(l) || /^(\*\*[A-Z]{1,3}-?\d{1,3}[a-z]?\b.+)$/.exec(l); // a list entry, or a paragraph led by **R-1 …**
372
+ if (b) out.push(b[1]);
373
+ }
374
+ if (!out.length) for (const l of body) if (l.startsWith(sub) && !NOT_AN_ITEM.test(l.slice(sub.length).replace(/[*_`]/g, '').trim())) out.push(l.slice(sub.length));
375
+ if (out.length) break;
376
+ }
377
+ return out.map((t) => {
378
+ const clean = t.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*`_]/g, '').trim();
379
+ const idm = /^([A-Z]{1,3}-?\d{1,3}[a-z]?)\b\s*[·:.\-—–)]?\s*(.*)$/.exec(clean);
380
+ const title = firstSentence((idm ? idm[2] : clean).replace(/^[—–-]\s*/, ''));
381
+ return { ref: idm ? idm[1] : null, title: (title || clean).slice(0, 140) };
382
+ }).filter((x) => x.title);
383
+ }
384
+ function derivedBreakdown(root, rec, lay) {
385
+ const p = lay && lay.progress ? lay.progress : null;
386
+ if (p && (p.total || p.proposed)) return null;
387
+ let rfm = {};
388
+ try {
389
+ const loaded = consoleState.loadEngine();
390
+ const file = planTree.recordPath(root, rec.id);
391
+ if (loaded.ok && file) rfm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {};
392
+ } catch { rfm = {}; }
393
+ let items = [];
394
+ let source = null;
395
+ try {
396
+ const st = jiraTickets.items(root, rec.id);
397
+ if (st && st.ok && st.items.length) { items = st.items.map((x) => ({ ref: x.id, title: firstSentence(x.title) })); source = st.sources.join(', '); }
398
+ } catch { items = []; }
399
+ if (!items.length) {
400
+ for (const k of ['prd_path', 'doc_path', 'brief_path']) {
401
+ const rel = typeof rfm[k] === 'string' ? rfm[k].trim() : '';
402
+ if (!rel) continue;
403
+ let text = null;
404
+ try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { text = null; }
405
+ if (text == null) continue;
406
+ items = requirementItems(text);
407
+ if (items.length) { source = rel; break; }
408
+ }
409
+ }
410
+ if (!items.length) return null;
411
+ const steps = Array.isArray(rec.steps) ? rec.steps : [];
412
+ const done = (k) => steps.some((x) => x.key === k && x.status === 'done');
413
+ const state = rec.accepted || done('accept') || done('approved') ? 'done' : done('build') || done('draft') ? 'review' : 'todo';
414
+ const basis = state === 'done' ? 'the PM accepted this initiative'
415
+ : state === 'review' ? 'its build is on the trunk and acceptance is open' : 'no build is evidenced on the trunk yet';
416
+ const list = items.slice(0, 60).map((x, i) => ({ id: x.ref || `item-${i + 1}`, ref: x.ref || null, title: x.title, state }));
417
+ const n = list.length;
418
+ return { source, basis, state, items: list, progress: { done: state === 'done' ? n : 0, total: n, proposed: 0, label: `${state === 'done' ? n : 0} of ${n} · from ${source.split(',')[0].split('/').pop()}` } };
272
419
  }
273
420
 
274
421
  // ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
@@ -356,7 +503,7 @@ async function handle(req, res, url, ctx) {
356
503
  if (id) {
357
504
  const rec = F.byId.get(id);
358
505
  if (!rec || !F.placed.byInit[id]) { ctx.json(res, { ok: false, error: `no work-lane initiative ${id} in this repository` }, 404); return true; }
359
- ctx.json(res, { ...head, sig, now: new Date(now).toISOString(), ...sheetOf(w, F, rec) });
506
+ ctx.json(res, { ...head, sig, now: new Date(now).toISOString(), ...sheetOf(w, F, rec, { initiativeIndex: ctx.initiativeIndex }) });
360
507
  return true;
361
508
  }
362
509
  const body = view === 'list' ? listOf(w, F) : roadmapOf(w, F);
@@ -375,6 +522,15 @@ async function handle(req, res, url, ctx) {
375
522
  const w = ctx.getWs(wsParam);
376
523
  if (!w || (wsParam && w.id !== wsParam)) { ctx.json(res, { ok: false, status: 404, error: 'no such workspace' }, 404); return true; }
377
524
 
525
+ if (p === '/api/initiatives/rescan') {
526
+ const g = guard.requirePagePress(req, body);
527
+ if (!g.ok) return answer(ctx, res, g), true;
528
+ initiativeFiles.forget(w.root);
529
+ const r = rowsLib.rescan(w.root);
530
+ if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id);
531
+ ctx.json(res, { ok: true, ws: w.id, started: r.started, running: r.running });
532
+ return true;
533
+ }
378
534
  if (p === '/api/initiatives') {
379
535
  const g = guard.requirePagePress(req, body);
380
536
  if (!g.ok) return answer(ctx, res, g), true;
@@ -401,4 +557,4 @@ async function handle(req, res, url, ctx) {
401
557
  return true;
402
558
  }
403
559
 
404
- module.exports = { prefixes: ['/api/initiatives', '/api/placements', '/api/measurements'], handle, sigOf, rowOf, frameOf, roadmapOf, listOf, sheetOf, unrecordedOf };
560
+ module.exports = { prefixes: ['/api/initiatives', '/api/placements', '/api/measurements'], handle, sigOf, rowOf, frameOf, roadmapOf, listOf, sheetOf, unrecordedOf, filesOf, requirementItems, derivedBreakdown };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.59.2",
3
+ "version": "0.60.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.59.2",
3
+ "version": "0.60.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -129,7 +129,7 @@
129
129
  <button type="button" data-seq="right" aria-label="Right arrow"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="arrow-right"><use href="#lucide-arrow-right"></use></svg></button>
130
130
  </div>
131
131
  <nav class="mobnav" id="mobNav" aria-label="Destinations">
132
- <button type="button" class="mobnav-b" data-dest="today" aria-current="page"><span class="l">Today</span><span class="mobnav-n" id="mobTodayCount" hidden></span></button>
132
+ <button type="button" class="mobnav-b" data-dest="today" aria-current="page"><span class="l">Agenda</span><span class="mobnav-n" id="mobTodayCount" hidden></span></button>
133
133
  <button type="button" class="mobnav-b" data-dest="initiatives" aria-current="false"><span class="l">Initiatives</span></button>
134
134
  <button type="button" class="mobnav-b" data-dest="board" aria-current="false"><span class="l">Board</span></button>
135
135
  <button type="button" class="mobnav-b" data-dest="reports" aria-current="false"><span class="l">Reports</span></button>
@@ -1761,6 +1761,29 @@
1761
1761
  .in-stepper__step[data-state="done"] {
1762
1762
  color: var(--foreground);
1763
1763
  }
1764
+ .in-stepper__step[data-state="skipped"] {
1765
+ color: var(--warning);
1766
+ }
1767
+ .in-files,
1768
+ .in-derived {
1769
+ display: flex;
1770
+ flex-direction: column;
1771
+ gap: 8px;
1772
+ }
1773
+ .in-files {
1774
+ gap: 16px;
1775
+ }
1776
+ .in-derived {
1777
+ margin-bottom: 16px;
1778
+ }
1779
+ .in-files__title,
1780
+ .in-derived__title {
1781
+ margin: 0;
1782
+ font-size: 12px;
1783
+ line-height: 16px;
1784
+ font-weight: 600;
1785
+ color: var(--muted-foreground);
1786
+ }
1764
1787
  .in-waiting {
1765
1788
  display: flex;
1766
1789
  align-items: center;
@@ -2651,6 +2674,53 @@
2651
2674
  .td-day__sites { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
2652
2675
  .td-day__sites li { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
2653
2676
  .td-day__sites li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2677
+ /* the Agenda month (today-agenda.js, G25 to G27): weeks of 8 columns, the week number and seven day cells */
2678
+ .td-ag__mhead { display: flex; align-items: center; gap: 6px; }
2679
+ .td-ag__mlabel { min-width: 120px; font-size: 14px; text-align: center; color: var(--foreground); }
2680
+ .td-ag__lens { flex-wrap: wrap; max-width: 100%; }
2681
+ .td-ag__grid { display: grid; gap: 2px; }
2682
+ .td-ag__wk { display: grid; grid-template-columns: 36px repeat(7, minmax(0, 1fr)); gap: 2px; }
2683
+ .td-ag__wk--head { font: 11px var(--mono); color: var(--muted-foreground); text-transform: uppercase; }
2684
+ .td-ag__wd { padding: 0 4px; }
2685
+ .td-day .td-ag__wno { height: auto; min-height: 64px; padding: 0; font: 11px var(--mono); letter-spacing: 0; color: var(--muted-foreground); }
2686
+ .td-day .td-ag__wno[aria-pressed="true"] { background: var(--accent); color: var(--foreground); border-color: var(--ring); }
2687
+ .td-day .td-ag__cell {
2688
+ display: grid; align-items: start; justify-content: stretch; height: auto; min-height: 64px; padding: 4px;
2689
+ border: 1px solid var(--border); background: var(--background); text-align: left; text-transform: none; letter-spacing: 0;
2690
+ font-weight: 400; white-space: normal;
2691
+ }
2692
+ .td-day .td-ag__cell[data-out="true"] { opacity: 0.45; }
2693
+ .td-day .td-ag__cell[data-weekend="true"] { background: var(--muted); }
2694
+ .td-day .td-ag__cell[data-today="true"] { border: 2px solid var(--foreground); }
2695
+ .td-day .td-ag__cell[aria-pressed="true"] { background: var(--accent); border-color: var(--ring); }
2696
+ .td-day .td-ag__cell[data-lit="true"] { box-shadow: inset 0 0 0 2px var(--warning); }
2697
+ .td-ag__in { display: grid; gap: 3px; min-width: 0; }
2698
+ .td-ag__num { font: 12px var(--mono); color: var(--foreground); font-variant-numeric: tabular-nums; }
2699
+ .td-ag__cell[data-past="true"] .td-ag__num { color: var(--muted-foreground); }
2700
+ .td-ag__strip { position: relative; height: 6px; background: var(--muted); }
2701
+ .td-ag__blk { position: absolute; top: 0; bottom: 0; left: var(--td-ag-l, 0); width: var(--td-ag-w, 0); background: var(--muted-foreground); }
2702
+ .td-ag__blk--imp { background: var(--foreground); }
2703
+ .td-ag__blk--free { background: var(--ring); }
2704
+ .td-ag__key, .td-ag__meta, .td-ag__nonotes { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: 14px; }
2705
+ .td-ag__key { font-weight: 600; color: var(--foreground); }
2706
+ .td-ag__meta { color: var(--muted-foreground); }
2707
+ .td-ag__nonotes { color: var(--warning); }
2708
+ .td-ag__ready { justify-self: start; padding: 0 4px; font: 11px/16px var(--mono); border: 1px solid var(--border); color: var(--foreground); }
2709
+ .td-ag__ready[data-ag-ready="warn"] { border-color: var(--warning); color: var(--warning); }
2710
+ .td-ag__ready[data-ag-ready="risk"] { border-color: var(--destructive); background: var(--destructive); color: var(--background); }
2711
+ .td-ag__agenda { display: grid; gap: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
2712
+ .td-ag__ah { margin: 0; font-size: 14px; color: var(--foreground); }
2713
+ .td-ag__tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
2714
+ .td-ag__tile { display: grid; gap: 2px; min-width: 0; padding: 6px 8px; background: var(--muted); }
2715
+ .td-ag__tile b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; color: var(--foreground); }
2716
+ .td-ag__tile[data-ag-ready="risk"] b { color: var(--destructive); }
2717
+ .td-ag__tile[data-ag-ready="warn"] b { color: var(--warning); }
2718
+ .td-ag__tk { font: 11px var(--mono); text-transform: uppercase; color: var(--muted-foreground); }
2719
+ .td-ag__wday { display: grid; gap: 2px; }
2720
+ .td-ag__wday h4 { display: flex; align-items: center; gap: 6px; margin: 4px 0 0; font-size: 12px; color: var(--foreground); }
2721
+ .td-ag__last summary { cursor: pointer; font-size: 12px; color: var(--muted-foreground); }
2722
+ .td-ag__last summary:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
2723
+ @media (pointer: coarse) { .td-ag__last summary { min-height: 44px; } }
2654
2724
  /* the goal detail keeps the steps the goals section showed (G6): Today cards and the agent steps that can be prepared */
2655
2725
  .td-goal__steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
2656
2726
  .td-goal__step { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
@@ -18,6 +18,7 @@ import { wsOf } from './data.js';
18
18
  import { setInitiativeOpener, registerCommand, initiativesViews, initiativesViewCtx, openDestination } from './registry.js';
19
19
  import { COPY } from './initiatives-copy.js';
20
20
  import { getRoadmap, grantScopes } from './initiatives-api.js';
21
+ import { rescanRepo } from './initiatives-ui.js';
21
22
  import { mountRoadmap } from './roadmap.js';
22
23
  import { mountList } from './initiative-list.js';
23
24
  import { openSheet } from './initiative-sheet.js';
@@ -32,6 +33,10 @@ registerCommand({
32
33
  id: 'initiatives-new', label: COPY.newInitiative, hint: 'Add one to Later', cli: 'pmos initiative new "<title>" --anchor <kr>',
33
34
  run: () => { openDestination('initiatives'); try { window.dispatchEvent(new CustomEvent('pmos:initiatives-new')); } catch { /* no window */ } },
34
35
  });
36
+ registerCommand({
37
+ id: 'initiatives-rescan', label: COPY.rescan, hint: COPY.rescanHint,
38
+ run: () => { openDestination('initiatives'); try { window.dispatchEvent(new CustomEvent('pmos:initiatives-rescan')); } catch { /* no window */ } },
39
+ });
35
40
  registerCommand({ id: 'initiatives-list', label: 'Initiatives as a list', hint: 'By objective', cli: 'pmos initiative list', run: () => { try { window.localStorage.setItem('pmos.initiatives.view', 'list'); } catch { /* no storage */ } openDestination('initiatives'); try { window.dispatchEvent(new CustomEvent('pmos:initiatives-view', { detail: 'list' })); } catch { /* no window */ } } });
36
41
 
37
42
  const BUILT_IN = ['roadmap', 'list'];
@@ -57,10 +62,11 @@ export function mountDestination(host, ctx) {
57
62
  host.replaceChildren(r.root);
58
63
  const reloadView = () => { if (r.handle && r.handle.reload) r.handle.reload(); refreshRows(r, ws); };
59
64
  r.onChanged = reloadView;
60
- // The three listeners are kept on r so disposeDestination can take them off: a closed tab must not keep its host alive.
65
+ // The listeners are kept on r so disposeDestination can take them off: a closed tab must not keep its host alive.
61
66
  r.listeners = [
62
67
  ['pmos:initiatives-changed', () => { if (roots.get(host) === r) r.onChanged(); }],
63
68
  ['pmos:initiatives-new', () => { if (roots.get(host) === r) r.openNew(); }],
69
+ ['pmos:initiatives-rescan', () => { if (roots.get(host) === r && ws) rescanRepo(ws); }],
64
70
  ['pmos:initiatives-view', (e) => { if (roots.get(host) === r && e.detail) { r.view = e.detail; r.draw(r.ctx); } }],
65
71
  ];
66
72
  for (const [name, fn] of r.listeners) window.addEventListener(name, fn);
@@ -204,6 +204,7 @@ function onlineSection() {
204
204
  lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-tunnel' },
205
205
  r.running ? P.onlineRunning : r.lastExit ? P.onlineExited(r.lastExit.code) : P.onlineStarting));
206
206
  if (d.keys && !d.keys.keys) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-keys' }, d.keys.lastError ? P.onlineKeysError(d.keys.lastError) : P.onlineKeysWait));
207
+ if (d.lastRefusal) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-refused', role: 'status' }, P.onlineRefused(d.lastRefusal.reason, d.lastRefusal.at)));
207
208
  }
208
209
  if (!d.canChange) return section('online', P.online, [...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)]);
209
210
  const f = (key, label, placeholder) => field({
@@ -22,7 +22,7 @@ export const COPY = {
22
22
  },
23
23
  nav: {
24
24
  label: 'Destinations',
25
- today: 'Today',
25
+ today: 'Agenda',
26
26
  initiatives: 'Initiatives',
27
27
  board: 'Board',
28
28
  reports: 'Reports',
@@ -115,7 +115,7 @@ export const COPY = {
115
115
  },
116
116
  page: {
117
117
  title: 'Settings',
118
- intro: 'Settings for this workspace. Your work is in Today, Initiatives, Board and Reports.',
118
+ intro: 'Settings for this workspace. Your work is in Agenda, Initiatives, Board and Reports.',
119
119
  repos: 'Repositories',
120
120
  reposEmpty: 'No repository is registered. Add one to start.',
121
121
  add: 'Add repository',
@@ -170,6 +170,18 @@ export const COPY = {
170
170
  onlineEnv: (host) => `Online at https://${host}/, set by PMOS_PUBLIC_HOST when the workspace started. It signs in with the token. Restart the workspace without PMOS_PUBLIC_HOST to use this switch.`,
171
171
  onlineRemote: 'Only the computer that runs the workspace can change this.',
172
172
  onlineUnread: 'The Online settings did not load.',
173
+ onlineRefused: (reason, at) => `The last sign-in at the public address was refused${at ? ` (${at.slice(11, 16)} UTC)` : ''}: ${({
174
+ malformed: 'Cloudflare sent no sign-in, or one PMOS cannot read. Check that the Access application covers the public address.',
175
+ algorithm: 'the sign-in was not signed the way Cloudflare Access signs.',
176
+ 'unknown key': 'it was signed with a key this team does not publish. Check the team domain.',
177
+ 'no keys': 'Cloudflare\'s sign-in keys are not loaded. Check the team domain.',
178
+ signature: 'its signature did not match. Check the team domain.',
179
+ issuer: 'it came from another Cloudflare team. Check the team domain.',
180
+ audience: 'it was for another Access application. Check the audience (AUD) tag: copy it from the application for the public address.',
181
+ expired: 'it had expired, or this computer\'s clock is wrong. Sign in again.',
182
+ 'not yet valid': 'it is not valid yet, so this computer\'s clock is probably wrong.',
183
+ email: 'you signed in with another email. The email field must be the address you sign in to Cloudflare with.',
184
+ })[reason] || reason}`,
173
185
  updates: 'Updates',
174
186
  updatesCheck: 'Check for updates',
175
187
  updatesChecking: 'Checking for updates…',