create-pmos 0.192.0 → 0.194.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/okf/core/concepts/frontmatter-format.md +3 -2
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/c3-core-docs-test.js +152 -0
  8. package/workspace/guide/console-calendar.md +1 -1
  9. package/workspace/guide/core-documents.md +49 -0
  10. package/workspace/guide/index.md +2 -1
  11. package/workspace/guide/initiative-boards.md +14 -0
  12. package/workspace/lib/core-docs-needs.js +86 -0
  13. package/workspace/lib/core-docs.js +675 -0
  14. package/workspace/lib/coredoc-cli.js +90 -0
  15. package/workspace/lib/doc-category.js +1 -1
  16. package/workspace/lib/driver-cli.js +16 -2
  17. package/workspace/lib/driver-decide.js +8 -1
  18. package/workspace/lib/driver-facts.js +8 -2
  19. package/workspace/lib/driver-run.js +6 -0
  20. package/workspace/lib/driver.js +32 -4
  21. package/workspace/lib/headless.js +13 -3
  22. package/workspace/lib/needs-providers.js +1 -0
  23. package/workspace/lib/pmos-verbs.js +3 -0
  24. package/workspace/lib/route-modules.js +1 -0
  25. package/workspace/lib/routes-core-docs.js +171 -0
  26. package/workspace/lib/routes-driver.js +1 -0
  27. package/workspace/lib/routes-meeting-focus.js +23 -5
  28. package/workspace/lib/vendor/scripts/migrate-layout.js +1 -1
  29. package/workspace/lib/vendor/workspace/lib/doc-category.js +1 -1
  30. package/workspace/package-lock.json +2 -2
  31. package/workspace/package.json +1 -1
  32. package/workspace/public/pm/c3-brief-needs.js +3 -0
  33. package/workspace/public/pm/c3-core-docs.css +18 -0
  34. package/workspace/public/pm/c3-core-docs.js +234 -0
  35. package/workspace/public/pm/c3-demo-coredocs.js +211 -0
  36. package/workspace/public/pm/c3-demo.js +2 -1
  37. package/workspace/public/pm/c3-driver.js +6 -4
  38. package/workspace/public/pm/c3-focus.js +6 -4
  39. package/workspace/public/pm/c3-ib-docs.js +24 -14
  40. package/workspace/public/pm/c3-ii-tabs.js +15 -8
  41. package/workspace/public/pm/c3-initiatives.js +29 -2
  42. package/workspace/public/pm/c3-shell.js +1 -1
  43. package/workspace/public/pm/shell-recording.js +2 -3
  44. package/workspace/server.js +15 -2
  45. package/workspace/skills/initiative-drive.skill +6 -2
  46. package/workspace/test-lists/c3-core-docs.json +9 -0
  47. package/workspace/test-lists/core-documents-backend.json +5 -0
@@ -276,20 +276,22 @@ export function agentSetting(state, { onPick } = {}) {
276
276
  }
277
277
 
278
278
  /* ── Needs you: the agent's rows (Briefing) ── */
279
- /** The driver rows of GET /api/needs: "Agent has 3 drafts on <title>" and "<title> is waiting on you". [] when the read fails. */
279
+ /** The agent's rows of GET /api/needs: "Agent has 3 drafts on <title>", "<title> is waiting on you", and "2 portfolio drafts to review" (core
280
+ * documents, kind core-doc-drafts: it opens that scope's Docs view). [] when the read fails. */
280
281
  export async function loadDriverNeeds() {
281
282
  const r = await pmGet('/api/needs');
282
- return r && Array.isArray(r.needs) ? r.needs.filter((n) => n && (n.kind === 'driver-drafts' || n.kind === 'driver-blocked')) : [];
283
+ return r && Array.isArray(r.needs) ? r.needs.filter((n) => n && (n.kind === 'driver-drafts' || n.kind === 'driver-blocked' || n.kind === 'core-doc-drafts')) : [];
283
284
  }
284
285
  /** One li.nrow for a driver row (the mockup's two rows). `go(row)` opens the initiative or the Work page. */
285
286
  export function needsRow(n, go) {
286
287
  ensureDriverCss();
287
288
  const blocked = n.kind === 'driver-blocked';
289
+ const docs = n.kind === 'core-doc-drafts';
288
290
  const work = !n.initiative_id;
289
- const chips = blocked ? [el('span', { class: 'tag warn' }, 'waiting on you'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)]
291
+ const chips = docs ? [tag('agent'), chip(icon('doc', 'sm'), 'Core documents'), el('span', { class: 'dim' }, 'Review each draft, then issue it')] : blocked ? [el('span', { class: 'tag warn' }, 'waiting on you'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)]
290
292
  : [tag('agent'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)];
291
293
  chips[0].className = blocked ? 'tag warn' : 'tag acc';
292
- chips.push(el('span', { class: 'dim' }, blocked ? 'The agent will not go on until you decide' : 'Keep, send back or drop each one'));
294
+ if (!docs) chips.push(el('span', { class: 'dim' }, blocked ? 'The agent will not go on until you decide' : 'Keep, send back or drop each one'));
293
295
  const li = nrow({ ic: blocked ? 'next' : 'spark', hot: true, title: n.question, onOpen: () => go(n), openLabel: n.question, chips,
294
296
  attrs: { 'data-needs-row': n.run_id, 'data-kind': n.kind, 'data-drv-need': n.kind, 'data-ref-row': n.initiative_id || n.run_id } });
295
297
  return li;
@@ -32,6 +32,8 @@ import { toast } from './c3-toast.js';
32
32
  const POLL_MS = 3000;
33
33
  const LAST_MINUTES = 5; // the last minutes of the meeting: the captured items show under the stage
34
34
  const pad = (n) => String(n).padStart(2, '0');
35
+ /** The local day of a start: a note keeps its meeting's start in UTC, and its first ten characters can be the day before. */
36
+ const localDayOf = (t) => { const ms = Date.parse(t); if (!Number.isFinite(ms)) return ''; const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
35
37
  const hhmm = (ms) => { const d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); };
36
38
  export const baseId = (id) => String(id || '').split('@')[0];
37
39
  /** 3:07, or 1:03:07 from an hour on; a minus sign before a negative time. Pure. */
@@ -84,7 +86,7 @@ export function closeFocus() {
84
86
  const grid = host && host.querySelector('.c3grid'); if (grid) grid.inert = false;
85
87
  const comp = host && host.querySelector('[data-c3ph]'); if (comp) comp.inert = false;
86
88
  if (f.phone) { const st = f.phone && document.querySelector('[data-c3ph-focus-bar]'); if (st) st.focus({ preventScroll: true }); return; }
87
- try { const u = new URL(location.href); if (u.searchParams.has('focus')) { u.searchParams.delete('focus'); u.searchParams.delete('fdate'); history.replaceState(history.state, '', u); } } catch { /* no history */ }
89
+ try { const u = new URL(location.href); if (u.searchParams.has('focus')) { u.searchParams.delete('focus'); u.searchParams.delete('fdate'); u.searchParams.delete('fat'); u.searchParams.delete('ftitle'); history.replaceState(history.state, '', u); } } catch { /* no history */ }
88
90
  if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
89
91
  }
90
92
 
@@ -95,7 +97,7 @@ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle }
95
97
  if (phone) pop = false; // the phone has the whole page, in one column
96
98
  const resolve = !!pop && want === 'now'; // the extension names no meeting: which one this call is comes first
97
99
  const id = resolve ? '' : want;
98
- const day = String(date || String(ev.at || ev.start || '').slice(0, 10) || '');
100
+ const day = String(date || localDayOf(ev.at || ev.start) || '');
99
101
  ensureCss();
100
102
  const host = document.getElementById('c3-root') || document.body;
101
103
  const root = el('div', { class: 'c3focus' + (pop ? ' fc-pop' : '') + (phone ? ' fc-phone' : ''), role: 'region', 'aria-label': pop ? 'PMOS meeting helper' : 'Meeting focus', 'data-c3focus': '', ...(pop ? { 'data-fc-pop': '' } : {}), ...(phone ? { 'data-fc-phone': '' } : {}) });
@@ -142,7 +144,7 @@ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle }
142
144
  host.append(root);
143
145
  const grid = host.querySelector && host.querySelector('.c3grid'); if (grid) grid.inert = true;
144
146
  const comp = host.querySelector && host.querySelector('[data-c3ph]'); if (comp) comp.inert = true; // the phone companion under the page
145
- if (!pop && !phone) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
147
+ if (!pop && !phone) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); if (!id && ev.at) { u.searchParams.set('fat', ev.at); if (ev.title) u.searchParams.set('ftitle', ev.title); } history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
146
148
  draw();
147
149
  F.timers.clock = setInterval(tick, 1000);
148
150
  if (resolve) resolveNow(); else load();
@@ -185,7 +187,7 @@ async function load() {
185
187
  if (f.id) p.set('event', f.id);
186
188
  else if (f.noteId) p.set('note', f.noteId); // a recording with no meeting
187
189
  if (f.date && !f.noteId) p.set('date', f.date);
188
- if (!f.id && !f.noteId && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); }
190
+ if (!f.noteId && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); } // the start and title still find the meeting when the id or the day does not
189
191
  const j = await pmGet('/api/meeting-focus?' + p.toString());
190
192
  if (F !== f) return;
191
193
  if (j && j.ok) {
@@ -1,6 +1,8 @@
1
1
  /* pm/c3-ib-docs.js — the Docs tab of a board (mockup v5, 34-initiatives.js tabDocs / docList / docRow), and the doc helpers the
2
2
  * initiative's own Docs tab (c3-ii-tabs.js) shares.
3
3
  * registerBoardTab('docs', (host, ctx) => ...)
4
+ * CORE First, the board's core documents (c3-core-docs.js, GET /api/core-docs level=board): the set, its state, Draft it, Review the draft.
5
+ * Then "Other documents", the list below.
4
6
  * DOCS The documents of the board's initiatives from the Documents data: GET /api/docs/library (files { path, title, category, date,
5
7
  * initiative }, categories). A file belongs to the board when its `initiative` is one of the board's initiatives (from
6
8
  * loadItems, c3-ini-work.js). Graph nodes (files with `node`) are left out. A search box filters by title, path, type and
@@ -10,8 +12,9 @@
10
12
  * Nothing is invented: a board with no linked documents says so. */
11
13
  import { el } from './dom.js';
12
14
  import { registerBoardTab } from './c3-initiatives.js';
13
- import { icon, pbtn, lk, chip, tag, st, note, sect, list, empty, nrow, plural, day, ago, parseDay, put } from './c3-ini-ui.js';
15
+ import { icon, pbtn, lk, chip, tag, st, note, sect, list, empty, nrow, plural, day, ago, parseDay, put, cols } from './c3-ini-ui.js';
14
16
  import { loadItems } from './c3-ini-work.js';
17
+ import { coreDocs } from './c3-core-docs.js';
15
18
 
16
19
  const STALE_DAYS = 30;
17
20
  /** The documents of the library that belong to the ids: { ok, error, docs: [{ path, name, cat, ty, date, ini, st }] }. */
@@ -36,8 +39,9 @@ async function openDoc(ctx, d, titles) {
36
39
  } catch { ctx.toast('The document sheet could not open. The file is ' + d.path + '.'); }
37
40
  }
38
41
 
39
- /** The search box and the list of docs. `grouped` false draws one flat list (an initiative's own page). */
40
- export function renderDocs(host, ctx, docs, order, titles, grouped) {
42
+ /** The search box and the list of docs. `grouped` false draws one flat list (an initiative's own page). `title` names the list ("Other documents"
43
+ * beside the core documents; "Documents" by default). */
44
+ export function renderDocs(host, ctx, docs, order, titles, grouped, title) {
41
45
  const results = el('div', { 'data-inb': 'docs-list' });
42
46
  const input = el('input', { type: 'text', placeholder: 'Search documents', 'aria-label': 'Search documents', 'data-inb': 'docs-search', autocomplete: 'off' });
43
47
  const count = el('span', { class: 'dim mono ini-meta', 'data-inb': 'docs-count', 'aria-live': 'polite' });
@@ -51,32 +55,38 @@ export function renderDocs(host, ctx, docs, order, titles, grouped) {
51
55
  const shown = docs.filter((d) => !q || (d.name + ' ' + d.path + ' ' + d.ty + ' ' + d.ini + ' ' + (titles.get(d.ini) || '')).toLowerCase().includes(q));
52
56
  count.textContent = q ? plural(shown.length, 'document') + ' match' + (shown.length === 1 ? 'es' : '') : plural(docs.length, 'document');
53
57
  if (!shown.length) { put(results, empty(q ? 'No document matches "' + input.value.trim() + '".' : 'No documents.', { 'data-inb': 'docs-none' })); return; }
54
- if (!grouped) { put(results, sect({ ic: 'doc', title: 'Documents', count: shown.length }, list(shown.map(row)))); return; }
58
+ if (!grouped) { put(results, sect({ ic: 'doc', title: title || 'Documents', count: shown.length }, list(shown.map(row)))); return; }
55
59
  put(results, ...order.filter((id) => shown.some((d) => d.ini === id)).map((id) => {
56
60
  const g = shown.filter((d) => d.ini === id);
57
61
  return sect({ ic: 'init', title: id + ' ' + (titles.get(id) || ''), count: g.length, attrs: { 'data-inb-docgrp': id }, right: lk({ onclick: () => ctx.openInitiative(id, 'docs'), 'aria-label': 'Open ' + id }, 'Open initiative') }, list(g.map(row)));
58
62
  }));
59
63
  };
60
64
  input.addEventListener('input', draw);
61
- put(host, el('div', { class: 'calbar' }, el('div', { class: 'form ini-grow' }, input), count), results);
65
+ put(host, grouped && title ? el('div', { class: 'sh2 cd-oh', 'data-inb': 'docs-title' }, el('h2', null, icon('doc', 'sm'), title, ' ', el('span', { class: 'bdg g' }, String(docs.length)))) : null,
66
+ el('div', { class: 'calbar' }, el('div', { class: 'form ini-grow' }, input), count), results);
62
67
  draw();
63
68
  }
64
69
 
65
70
  registerBoardTab('docs', async (host, ctx) => {
66
71
  let dead = false;
67
- put(host, note('Reading the documents'));
72
+ const other = el('div', { class: 'cd-host', 'data-cd': 'other' }, note('Reading the documents'));
73
+ const cd = coreDocs({ api: ctx.api, level: 'board', board: ctx.board.id, ws: ctx.ws, today: ctx.today, toast: ctx.toast, scopeLabel: ctx.board.name, boardRow: ctx.board,
74
+ nav: { tab: (t) => ctx.openBoard(ctx.board.id, t), tool: () => {}, reports: () => ctx.showTab('rep') } });
75
+ put(host, cols([cd.left, other], cd.right));
76
+ const stop = () => { dead = true; cd.dispose(); };
68
77
  const items = await loadItems(ctx);
69
- if (dead) return () => {};
70
- if (!items.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The initiatives of this board could not be read: ' + items.error + '.')); return () => {}; }
78
+ if (dead) return stop;
79
+ if (!items.ok) { put(other, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The initiatives of this board could not be read: ' + items.error + '.')); return stop; }
71
80
  const titles = new Map(items.initiatives.map((r) => [r.id, r.title]));
72
81
  const lib = await loadDocs(ctx, new Set(titles.keys()));
73
- if (dead) return () => {};
74
- if (!lib.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The documents could not be read: ' + lib.error + '.')); return () => {}; }
82
+ if (dead) return stop;
83
+ if (!lib.ok) { put(other, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The documents could not be read: ' + lib.error + '.')); return stop; }
84
+ cd.setIniDocs({ docs: lib.docs.length, initiatives: new Set(lib.docs.map((d) => d.ini)).size });
75
85
  if (!lib.docs.length) {
76
- put(host, empty(items.initiatives.length ? 'No documents are linked to the initiatives of ' + ctx.board.name + ' yet.' : ctx.board.name + ' has no initiatives, so there are no documents.', { 'data-inb': 'docs-empty' }));
77
- return () => { dead = true; };
86
+ put(other, empty(items.initiatives.length ? 'No other documents are linked to the initiatives of ' + ctx.board.name + ' yet.' : ctx.board.name + ' has no initiatives, so there are no other documents.', { 'data-inb': 'docs-empty' }));
87
+ return stop;
78
88
  }
79
89
  const order = [...new Set(lib.docs.map((d) => d.ini))];
80
- renderDocs(host, ctx, lib.docs, order, titles, true);
81
- return () => { dead = true; };
90
+ renderDocs(other, ctx, lib.docs, order, titles, true, 'Other documents');
91
+ return stop;
82
92
  });
@@ -7,7 +7,9 @@
7
7
  * first and scrolled to. Right: Details (portfolio, board, workflow with Edit, stage, owner, anchor, horizon, lane), Progress,
8
8
  * and links to the board's Backlog, Board and Timeline filtered to this initiative; then its releases.
9
9
  * Breakdown its epics and items, Jira and PMOS mixed (loadItems), and "Add a PMOS task" (POST /api/boards/tasks/add, epic optional).
10
- * Docs the documents linked to it (GET /api/docs/library), c3-ib-docs.js renderDocs.
10
+ * Docs Left: its core documents by category (Discover, Plan, Do, Learn; c3-core-docs.js, GET /api/core-docs level=initiative): the current one
11
+ * lists its rows, a finished or not-yet-reached one folds to one line. Right: what this level is for, the order rules, then "Other documents"
12
+ * (the documents linked to it, GET /api/docs/library, c3-ib-docs.js renderDocs).
11
13
  * Tickets its Jira items: the counts by status and the rows. Read-only: status and dates are Jira's. (Build and deliver only.)
12
14
  * Timeline epic > item on the HTML timeline, its own bar on top; PMOS bars drag to reschedule, Jira bars are locked.
13
15
  * History ctx.history: moves, workflow changes, assignments and the accept, with who, when, why and the order warnings.
@@ -18,6 +20,7 @@ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs
18
20
  import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
21
  import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
20
22
  import { loadDocs, renderDocs } from './c3-ib-docs.js';
23
+ import { coreDocs } from './c3-core-docs.js';
21
24
  import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
22
25
  import { driverStrip } from './c3-driver.js';
23
26
 
@@ -185,14 +188,18 @@ tab('breakdown', async (ctx, isDead, host) => {
185
188
  });
186
189
 
187
190
  /* ── Docs ── */
188
- tab('docs', async (ctx, isDead) => {
191
+ registerInitiativeTab('docs', async (host, ctx) => {
192
+ let dead = false;
193
+ const other = el('div', { class: 'cd-host', 'data-cd': 'other' });
194
+ const cd = coreDocs({ api: ctx.api, level: 'initiative', board: ctx.board.id, id: ctx.row.id, ws: ctx.ws, today: ctx.today, toast: ctx.toast, scopeLabel: ctx.row.id,
195
+ nav: { tab: (t) => ctx.openBoard(ctx.board.id, t), tool: (t) => ctx.go({ view: 'ini', board: ctx.board.id, id: ctx.row.id, tab: 'overview', tool: t }), reports: () => ctx.showTab('rep') } });
196
+ put(host, cols(cd.left, [cd.right, other]));
189
197
  const lib = await loadDocs(ctx, new Set([ctx.row.id]));
190
- if (isDead()) return null;
191
- if (!lib.ok) return errLine('The documents could not be read: ' + lib.error + '.');
192
- if (!lib.docs.length) return empty('No documents are linked to ' + ctx.row.id + ' yet.', { 'data-inb': 'docs-empty' });
193
- const box = el('div');
194
- renderDocs(box, ctx, lib.docs, [ctx.row.id], new Map([[ctx.row.id, ctx.row.title]]), false);
195
- return box;
198
+ if (dead) return () => {};
199
+ if (!lib.ok) put(other, errLine('The documents could not be read: ' + lib.error + '.'));
200
+ else if (!lib.docs.length) put(other, empty('No other documents are linked to ' + ctx.row.id + ' yet.', { 'data-inb': 'docs-empty' }));
201
+ else { const box = el('div'); renderDocs(box, ctx, lib.docs, [ctx.row.id], new Map([[ctx.row.id, ctx.row.title]]), false, 'Other documents'); put(other, box); }
202
+ return () => { dead = true; cd.dispose(); };
196
203
  });
197
204
 
198
205
  /* ── Jira items ── */
@@ -21,6 +21,7 @@
21
21
  *
22
22
  * URL STATE `init=` with history.replaceState, read back on load:
23
23
  * (none) Portfolio
24
+ * docs Portfolio, Documents view (the core documents of the Portfolio)
24
25
  * board:<board>:<tab>[:<filter>] a board tab; <filter> is a query string (cat=Do&view=risk), each part URI-encoded
25
26
  * ini:<board>:<id>:<tab>[:<tool>] an initiative tab; <tool> is the tool the Overview shows first
26
27
  *
@@ -51,6 +52,7 @@ import { RECORD_META, recordToolsFor } from './c3-ini-records.js';
51
52
  import { agentSetting, loadAgentMode, MODES as AGENT_MODES } from './c3-driver.js';
52
53
  import { scanPortfolioSection, scanPointer } from './c3-scan-setup.js';
53
54
  import { lifeBanner, pageMenu, ensureLifeCss } from './c3-ini-life.js';
55
+ import { coreDocs, segBadge } from './c3-core-docs.js';
54
56
 
55
57
  export { btn, geo };
56
58
  export { TEMPLATES, CATS };
@@ -88,12 +90,14 @@ export function parseLoc(s) {
88
90
  const p = String(s || '').split(':').map(dec);
89
91
  if (p[0] === 'board' && p[1]) return { view: 'board', board: p[1], tab: BOARD_IDS.includes(p[2]) ? p[2] : 'summary', filter: p[3] ? Object.fromEntries(new URLSearchParams(p[3])) : {} };
90
92
  if (p[0] === 'ini' && p[1] && p[2]) return { view: 'ini', board: p[1], id: p[2], tab: INI_IDS.includes(p[3]) ? p[3] : 'overview', tool: p[4] || '' };
93
+ if (p[0] === 'docs') return { view: 'portfolio', tab: 'docs' };
91
94
  return { view: 'portfolio' };
92
95
  }
93
96
  /** The inverse of parseLoc; '' for the Portfolio. */
94
97
  export function formatLoc(loc) {
95
98
  const e = encodeURIComponent;
96
- if (!loc || loc.view === 'portfolio' || !loc.board) return '';
99
+ if (loc && loc.view === 'portfolio') return loc.tab === 'docs' ? 'docs' : '';
100
+ if (!loc || !loc.board) return '';
97
101
  if (loc.view === 'board') {
98
102
  const f = Object.entries(loc.filter || {}).filter(([, v]) => v !== '' && v != null);
99
103
  return ['board', e(loc.board), e(loc.tab || 'summary')].join(':') + (f.length ? ':' + e(new URLSearchParams(f).toString()) : '');
@@ -154,6 +158,7 @@ export function mountInitiatives(pane, opts) {
154
158
  }
155
159
  function go(loc) {
156
160
  const l = { ...(loc || { view: 'portfolio' }) };
161
+ if (l.view === 'portfolio') l.tab = l.tab === 'docs' ? 'docs' : 'overview';
157
162
  if (l.view === 'board') { l.tab = BOARD_IDS.includes(l.tab) ? l.tab : 'summary'; l.filter = l.filter || {}; }
158
163
  if (l.view === 'ini') { l.tab = INI_IDS.includes(l.tab) ? l.tab : 'overview'; l.tool = l.tool || ''; }
159
164
  P.loc = l; writeUrl(); draw();
@@ -161,7 +166,7 @@ export function mountInitiatives(pane, opts) {
161
166
  const openBoard = (id, tab, filter) => go({ view: 'board', board: id, tab: tab || 'summary', filter: filter || {} });
162
167
  const refresh = () => { P.boards = null; P.ini = null; dropItems(); draw(); };
163
168
  // The Briefing's Horizon scan card opens a board's Triage or settings (c3-scan.js goBoard). A page that is not built yet reads the address instead.
164
- if (typeof window !== 'undefined') { if (window.__iniGo) window.removeEventListener('pmos:ini-go', window.__iniGo); window.__iniGo = (e) => { const d = (e && e.detail) || {}; if (d.board && root.isConnected) openBoard(d.board, d.tab || 'summary'); }; window.addEventListener('pmos:ini-go', window.__iniGo); }
169
+ if (typeof window !== 'undefined') { if (window.__iniGo) window.removeEventListener('pmos:ini-go', window.__iniGo); window.__iniGo = (e) => { const d = (e && e.detail) || {}; if (d.loc != null && root.isConnected) go(parseLoc(d.loc)); else if (d.board && root.isConnected) openBoard(d.board, d.tab || 'summary'); }; window.addEventListener('pmos:ini-go', window.__iniGo); }
165
170
  const redraw = () => draw();
166
171
 
167
172
  async function loadBoards() {
@@ -232,7 +237,26 @@ export function mountInitiatives(pane, opts) {
232
237
  }
233
238
 
234
239
  /* ════════ PORTFOLIO ════════ */
240
+ /** Overview | Documents, under the breadcrumb of the Portfolio. */
241
+ function pfTabs(cur, badge) {
242
+ return segTabs([['overview', 'Overview', 'cols'], ['docs', 'Documents', 'doc', badge]], cur, (k) => go(k === 'docs' ? { view: 'portfolio', tab: 'docs' } : { view: 'portfolio' }), 'Portfolio views', { 'data-ini': 'pf-tabs' });
243
+ }
244
+ const setBadge = (badge, r) => { const n = r && r.summary ? (Number(r.summary.draft) || 0) + (Number(r.summary.due) || 0) : 0; badge.textContent = String(n); badge.hidden = !n; };
245
+ /** The Portfolio's Documents view: its core set, the question it answers and the drafts waiting (c3-core-docs.js). */
246
+ function drawPortfolioDocs(boards, token) {
247
+ const asks = boards.reduce((n, b) => n + (Number(b.asks) || 0), 0);
248
+ const total = boards.reduce((n, b) => n + ((b.counts || {}).total || 0), 0);
249
+ const badge = segBadge(0);
250
+ const lhHost = el('div', { class: 'cd-host' });
251
+ const cd = coreDocs({ api: pageApi, level: 'portfolio', ws: opts.ws || (boards[0] && boards[0].ws) || '', today: P.today, toast, nav: { reports: () => (opts.showTab || (() => {}))('rep'), tab: () => {}, tool: () => {} },
252
+ onData: (r) => { setBadge(badge, r); put(lhHost, lh('doc', 'Portfolio documents', PORTFOLIO + ': the documents leadership reads to judge the bets. ' + (r.docs.filter((d) => d.st === 'draft').length ? plural(r.docs.filter((d) => d.st === 'draft').length, 'draft') + ' to review, ' : '') + r.docs.filter((d) => d.st === 'due').length + ' due.')); } });
253
+ P.dispose = () => cd.dispose();
254
+ put(root, pathCrumbs(null, null, el('div', { class: 'chips2' }, mono(plural(boards.length, 'board') + ' · ' + plural(total, 'initiative') + ' · ' + plural(asks, 'new ask')), pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
255
+ pfTabs('docs', badge),
256
+ el('div', { class: 'calbody', 'data-ini': 'pf-docs' }, lhHost, cols(cd.left, cd.right)));
257
+ }
235
258
  async function drawPortfolio(boards, token) {
259
+ if (P.loc.tab === 'docs') return drawPortfolioDocs(boards, token);
236
260
  const asks = boards.reduce((n, b) => n + (Number(b.asks) || 0), 0);
237
261
  const total = boards.reduce((n, b) => n + ((b.counts && b.counts.total) || 0), 0);
238
262
  const bad = boards.filter((b) => b.health === 'bad'); const warn = boards.filter((b) => b.health === 'warn');
@@ -251,9 +275,12 @@ export function mountInitiatives(pane, opts) {
251
275
  askBoards.length ? chgl(askBoards.map((b) => el('li', null, icon('inbox', 'sm'), lk({ onclick: () => openBoard(b.id, 'triage'), 'data-ini-asks': b.id }, b.name), el('span', { class: 'tag' }, plural(Number(b.asks), 'new ask')))))
252
276
  : note('No new asks on any board.')),
253
277
  ];
278
+ const badge = segBadge(0);
279
+ pageApi.get('/api/core-docs', { level: 'portfolio' }).then((r) => { if (token === P.seq) setBadge(badge, r); }, () => {});
254
280
  put(root,
255
281
  pathCrumbs(null, null, el('div', { class: 'chips2' }, mono(plural(boards.length, 'board') + ' · ' + plural(total, 'initiative') + ' · ' + plural(asks, 'new ask')),
256
282
  pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
283
+ pfTabs('overview', badge),
257
284
  el('div', { class: 'calbody', 'data-ini': 'portfolio' },
258
285
  lh('cols', sentenceOf(bad, warn), PORTFOLIO + ': boards hold initiatives, initiatives hold epics, epics hold stories, tasks and bugs. Stages roll up into Discover, Plan, Do and Learn so boards compare.'),
259
286
  // The Horizon scan runs for the whole portfolio (c3-scan-setup.js): its section is here, with Scan now always visible. Not in demo mode.
@@ -623,7 +623,7 @@ async function boot() {
623
623
  let start = q.get('tab');
624
624
  const saved = DEMO ? null : await pmGet('/api/settings' + (S.ws ? '?ws=' + encodeURIComponent(S.ws) : '')).catch(() => null); // demo mode's workspace is not registered
625
625
  if (saved && saved.ok) { applyLook(root, saved.settings); if (!start) { const s = (saved.settings || []).find((x) => x.key === 'look.start'); if (s && BUILD[s.value]) start = s.value; } }
626
- if (q.get('focus') || q.get('fdate')) import('./c3-focus.js').then((m) => m.openFocus({ event: { id: q.get('focus') || '' }, date: q.get('fdate') || '', pop: q.get('pop') === '1', meet: q.get('meet') || '', mtitle: q.get('mtitle') || '' })).catch(() => { /* the focus page is not available */ }); // a reload keeps the Meeting focus page open; ?pop=1 is its compact form for the Chrome extension (focus=now asks which meeting the Meet call is)
626
+ if (q.get('focus') || q.get('fdate')) import('./c3-focus.js').then((m) => m.openFocus({ event: { id: q.get('focus') || '', ...(q.get('fat') ? { at: q.get('fat'), title: q.get('ftitle') || '' } : {}) }, date: q.get('fdate') || '', pop: q.get('pop') === '1', meet: q.get('meet') || '', mtitle: q.get('mtitle') || '' })).catch(() => { /* the focus page is not available */ }); // a reload keeps the Meeting focus page open; ?pop=1 is its compact form for the Chrome extension (focus=now asks which meeting the Meet call is)
627
627
  if (!S.userNav) showTab(start || 'brief', false); // a press during the read above (the gear, a tab) wins over the start page
628
628
  new ResizeObserver(moveInd).observe(S.tabNav);
629
629
  workCount(); briefCount(); askCount(); S.dock.start();
@@ -173,9 +173,8 @@ function meetingOfNote(id) {
173
173
  return m && m.kind === 'event' && m.start ? m : null;
174
174
  }
175
175
  /** Meeting focus (initiative c3-meeting-focus): the page for the meeting being recorded. The event id is empty for a meeting without a stored id; the page then finds it by start and title. */
176
- function openFocusFor(m) {
177
- const day = String(m.start || '').slice(0, 10);
178
- import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.event_id || '', at: m.start, end: m.end || null, title: m.title }, date: day }))
176
+ function openFocusFor(m) { // no date: the page takes the local day of the start (the note keeps it in UTC)
177
+ import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.event_id || '', at: m.start, end: m.end || null, title: m.title } }))
179
178
  .catch(() => toast({ text: 'The focus page could not load.', tone: 'error' }));
180
179
  }
181
180
 
@@ -2598,7 +2598,19 @@ const reportFinderTick = () => {
2598
2598
  const t = process.env.PMOS_REPORT_FIND_NOW ? Date.parse(process.env.PMOS_REPORT_FIND_NOW) : NaN;
2599
2599
  try { reportFinder.tick(Number.isFinite(t) ? { now: t } : {}); } catch (e) { warnWriteFail('report finder tick', e); }
2600
2600
  };
2601
- try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } try { dayTick(); } catch (e) { warnWriteFail('day run tick', e); } autoSync.tick().catch(() => {}); dashboardScheduleTick(); driverTick(); scanTick(); reportFinderTick(); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2601
+ // core-documents: every 30 minutes inside the working hours, ONE headless run per due core document of the portfolio and of each board
2602
+ // (at most 3 at once, once per document per day, never one the PM writes). The run writes a draft with `pmos coredoc write`; nothing is sent.
2603
+ // PMOS_CORE_DOCS_NOW (a local date-time) fixes its clock for the suites.
2604
+ const coreDocsTick = () => {
2605
+ let m; try { m = require('./lib/routes-core-docs.js'); } catch { return; }
2606
+ const t = process.env.PMOS_CORE_DOCS_NOW ? Date.parse(process.env.PMOS_CORE_DOCS_NOW) : NaN;
2607
+ try {
2608
+ m.tick({ getWs, stateDirFor, workspaceList: () => [...workspaces.values()], runtimeDir: STATE, selfRoot: EMBEDDED_SELF ? SELF_ROOT : null, initiativeIndex,
2609
+ spawnSession, stopSession: (id) => { const x = sessions.get(String(id || '')); if (x && x.child) stopHeadless(x); }, invalidateNeedsAgg,
2610
+ proSettings, inQuiet: (now, quiet) => proactiveLib.inQuietHours(now, quiet), isLead: (w) => isPlanNeedsTree(w) }, Number.isFinite(t) ? { now: t } : {});
2611
+ } catch (e) { warnWriteFail('core documents tick', e); }
2612
+ };
2613
+ try { const _tick = setInterval(() => { checkDueSchedules(); proactiveTick(); try { prepareTick({ manual: false }); } catch { /* the next tick tries again */ } try { dayTick(); } catch (e) { warnWriteFail('day run tick', e); } autoSync.tick().catch(() => {}); dashboardScheduleTick(); driverTick(); scanTick(); reportFinderTick(); coreDocsTick(); }, TICK_MS); if (_tick.unref) _tick.unref(); } catch {}
2602
2614
 
2603
2615
  // ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
2604
2616
  /** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
@@ -3637,7 +3649,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
3637
3649
  Object.assign(penv, itemEnv);
3638
3650
  // horizon-scan: the scan runs set PMOS_SCAN_FILE, PMOS_SCAN_ORIGIN and PMOS_SCAN_RUN for `pmos scan file`; the report-setup and report-find runs
3639
3651
  // (report-workspace) set PMOS_REPORT_FILE and PMOS_REPORT_RUN for `pmos report propose`; nothing else is added.
3640
- if (extraEnv && typeof extraEnv === 'object') for (const k of ['PMOS_SCAN_FILE', 'PMOS_SCAN_ORIGIN', 'PMOS_SCAN_RUN', 'PMOS_REPORT_FILE', 'PMOS_REPORT_RUN']) if (typeof extraEnv[k] === 'string') penv[k] = extraEnv[k];
3652
+ // core-documents: a core-doc run sets PMOS_CORE_DOC_SCOPE and PMOS_CORE_DOC_KEY, the one entry `pmos coredoc write` may write.
3653
+ if (extraEnv && typeof extraEnv === 'object') for (const k of ['PMOS_SCAN_FILE', 'PMOS_SCAN_ORIGIN', 'PMOS_SCAN_RUN', 'PMOS_REPORT_FILE', 'PMOS_REPORT_RUN', 'PMOS_CORE_DOC_SCOPE', 'PMOS_CORE_DOC_KEY']) if (typeof extraEnv[k] === 'string') penv[k] = extraEnv[k];
3641
3654
  const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
3642
3655
  profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
3643
3656
  // Hand to agent: the breakdown item this session was started for, or null. Set only by /api/plan-items/handoff/go.
@@ -15,7 +15,7 @@ output:
15
15
  The prompt that starts this run names the target, the reason the run started, the stage category and what the PM already kept. Work on that target only.
16
16
 
17
17
  Rules that always hold:
18
- 1. THE ONLY WRITE is `pmos driver propose`. You have no Write and no Edit tool. Do not change a repository file, do not commit, do not push, and do not move the initiative to another stage. Keeping a proposal is the PM's act, not yours.
18
+ 1. THE ONLY WRITES are `pmos driver propose` and `pmos coredoc write` (a draft of a core document of this initiative, nothing else). You have no Write and no Edit tool. Do not change a repository file, do not commit, do not push, and do not move the initiative to another stage. Keeping a proposal is the PM's act, not yours.
19
19
  2. SEND NOTHING. Never send, reply, forward, post, comment, react, create, transition or label anything in mail, Slack, Jira, Confluence, GitHub, Drive, Figma or any other source. Read a connector only where it is already allowed. If a tool is refused, say so in your answer and go on without it.
20
20
  3. EVERY CLAIM CARRIES A POINTER. A pointer is a Jira key, a Slack link, a meeting note path, a requirement note path or a document path. Give it with `--source label=ref`. A claim you cannot point at is not a claim: leave it out. A proposal of the kind evidence, sizing, assumption or stakeholders is refused without a pointer.
21
21
  4. NOTHING INVENTED. Write only what you read. Say "not known" and name who could tell you. A run that finds nothing new is a correct run: file nothing.
@@ -26,7 +26,7 @@ Rules that always hold:
26
26
  Procedure:
27
27
  1. Orient. Run `pmos driver status initiative <id>`. Read the initiative record (`planning/initiatives/<id>.md`, or `pmos/state/initiatives/<id>.md`) and every file it names: PRD, brief, requirement notes. Run `pmos initiative list` to see the other open initiatives.
28
28
  2. Check first for a prior rejection and a duplicate. Search `planning/out-of-scope/` for the idea (Grep on its key words). Compare the title and the intent with the other open initiatives. If you find a match, file a `note` proposal first that names the file or the initiative, quotes what it says in one line, and says why this one is the same or different. Then stop unless the match is weak.
29
- 3. Find the stage category in the prompt. For Plan, Do and Learn, go to step 5. For Discover, do step 4.
29
+ 3. Find the stage category in the prompt. When the prompt names core documents of this stage that are due or stale, do step 8 first. For Plan, Do and Learn, go to step 5. For Discover, do step 4.
30
30
  4. Discover, in this order. Skip any step already kept.
31
31
  a. Problem statement. One paragraph: who has the problem, what it is, what it costs them. File `--what problem` with `--data "<the paragraph>"`. Give at least one source when you have one.
32
32
  b. Evidence. Every source that supports or contradicts the problem: a Jira key, a Slack thread, a meeting note, a requirement note, a document. For each one, a pointer and one line on what it says. File `--what evidence` with a JSON file: `{"items": [{"text": "<one line>", "ref": "<pointer>", "stance": "supports|contradicts"}]}` and one `--source label=ref` for each pointer. Look for contradicting evidence as hard as for supporting evidence.
@@ -43,6 +43,10 @@ Procedure:
43
43
  `pmos driver propose initiative <id> --what evidence --file /tmp/evidence.json --why "<one line: why the PM needs this>" --source "Jira DISC-12=https://…" --source "Meeting note=notes/2026-10-08.md"`
44
44
  Write the JSON or text file in the scratch folder of this session. If `pmos driver propose` refuses, read the reason, fix the proposal and file it once more. Do not work around a refusal.
45
45
  7. Finish with the output contract. Do not repeat the proposals in full.
46
+ 8. Core documents of this stage (due or stale, named in the prompt). For each one: read its sources (the record, the files it names, the Jira snapshot, the notes that name the initiative), write the document once with
47
+ `pmos coredoc write --scope initiative:<id> --key <key> --file - <<'PMOS_DOC'` … `PMOS_DOC` (the document on standard input; every fact with its source in brackets; "not known" where no source says), then file it:
48
+ `pmos driver propose initiative <id> --what doc --data '{"key":"<key>","slot":"<the slot it printed>"}' --why "<one line>"`.
49
+ The PM's Keep issues the draft. While a document of this stage is due, do not propose a stage move: `pmos driver propose` refuses it.
46
50
 
47
51
  Output contract — produce EXACTLY these sections in your final message:
48
52
  - Filed (the proposal ids and their kinds, or "nothing new")
@@ -0,0 +1,9 @@
1
+ {
2
+ "_comment": "c3-core-docs: the core documents of each level (Portfolio Documents, a board's Docs tab, an initiative's Docs tab), Draft it, Review the draft, Issue it, the Briefing row and 400 px, in demo mode (browser).",
3
+ "panels": [
4
+ "c3-core-docs-test.js"
5
+ ],
6
+ "seconds": {
7
+ "c3-core-docs-test.js": 40
8
+ }
9
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "core-documents backend: the sets, state, store, CLI, run, tick and needs rows in process, and the core-docs routes on a real server with a stub claude.",
3
+ "modules": ["core-docs.test.js", "core-docs-server.test.js"],
4
+ "seconds": { "core-docs.test.js": 4, "core-docs-server.test.js": 12 }
5
+ }