create-pmos 0.193.0 → 0.195.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 (46) 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/core-documents.md +49 -0
  9. package/workspace/guide/index.md +2 -1
  10. package/workspace/guide/initiative-boards.md +14 -0
  11. package/workspace/lib/core-docs-needs.js +86 -0
  12. package/workspace/lib/core-docs.js +675 -0
  13. package/workspace/lib/coredoc-cli.js +90 -0
  14. package/workspace/lib/doc-category.js +1 -1
  15. package/workspace/lib/driver-cli.js +16 -2
  16. package/workspace/lib/driver-decide.js +8 -1
  17. package/workspace/lib/driver-facts.js +8 -2
  18. package/workspace/lib/driver-run.js +6 -0
  19. package/workspace/lib/driver.js +32 -4
  20. package/workspace/lib/headless.js +13 -3
  21. package/workspace/lib/needs-providers.js +1 -0
  22. package/workspace/lib/notes-final.js +36 -9
  23. package/workspace/lib/notes-live-guard.js +30 -1
  24. package/workspace/lib/notes-whisper.js +11 -1
  25. package/workspace/lib/pmos-verbs.js +3 -0
  26. package/workspace/lib/route-modules.js +1 -0
  27. package/workspace/lib/routes-core-docs.js +171 -0
  28. package/workspace/lib/routes-driver.js +1 -0
  29. package/workspace/lib/vendor/scripts/migrate-layout.js +1 -1
  30. package/workspace/lib/vendor/workspace/lib/doc-category.js +1 -1
  31. package/workspace/package-lock.json +2 -2
  32. package/workspace/package.json +1 -1
  33. package/workspace/public/pm/c3-brief-needs.js +3 -0
  34. package/workspace/public/pm/c3-core-docs.css +18 -0
  35. package/workspace/public/pm/c3-core-docs.js +234 -0
  36. package/workspace/public/pm/c3-demo-coredocs.js +211 -0
  37. package/workspace/public/pm/c3-demo.js +2 -1
  38. package/workspace/public/pm/c3-driver.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/server.js +15 -2
  43. package/workspace/skills/initiative-drive.skill +6 -2
  44. package/workspace/test-lists/c3-core-docs.json +9 -0
  45. package/workspace/test-lists/core-documents-backend.json +5 -0
  46. package/workspace/test-lists/notes-silence-loops.json +5 -0
@@ -71,6 +71,7 @@ const MODULES = Object.freeze([
71
71
  'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
72
72
  'routes-scan', // horizon-scan-portfolio: one scan for the workspace: the watch list and setting, Scan now and Stop, report documents and their proposals (approve, change, dismiss, undo), plus the first scan's per-board and Briefing reads (scan.jsonl)
73
73
  'routes-report-setups', // report-workspace: recurring reports, the Ask box, the finder, the report documents and their export (report-setups.jsonl)
74
+ 'routes-core-docs', // core-documents: the core documents of each level, Draft it (a core-doc run), Issue it and Drop (core-docs.jsonl)
74
75
  ]);
75
76
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
76
77
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ /* routes-core-docs.js — the core documents of each level (initiative core-documents, PRD sections 3 to 5).
3
+ *
4
+ * GET /api/core-docs?ws&level=portfolio|board|initiative&board&id
5
+ * → { ok, level, scope, question, reader, rhythm, build, wfTpl, stage, cat, summary: { draft, due, missing, stale }, docs: [entry + state] }
6
+ * 202 { ok: true, pending: true } while the initiatives are still being read (level=initiative only)
7
+ * POST /api/core-docs/draft { ws, scope, key } → { ok, run } starts a core-doc run; 409 when one runs for that entry
8
+ * POST /api/core-docs/issue { ws, scope, key, slot } → { ok } `state: issued` in the file and one `issue` line
9
+ * POST /api/core-docs/drop { ws, scope, key, slot } → { ok } one `drop` line
10
+ *
11
+ * WHO WRITES. Every POST refuses a request that carries an agent session header (403), as routes-scan.js does. Draft it passes pm-guard
12
+ * requirePagePress; Issue it and Drop pass requirePmPress (act `issue`): only the PM issues or drops a core document. A draft is never sent.
13
+ *
14
+ * THE RUN. A press on Draft it (and the schedule tick) starts ONE headless run, kind `core-doc` (lib/headless.js), origin
15
+ * `core-doc:<scope>:<key>`. Its tools are Read, Glob, Grep and `pmos coredoc write`; the server pins the entry it may write with
16
+ * PMOS_CORE_DOC_SCOPE and PMOS_CORE_DOC_KEY. At most RUN_CAP core-doc runs run at once.
17
+ *
18
+ * THE TICK (PRD section 4). tick(ctx, { now }) runs every 30 minutes inside the working hours (lib/driver-run.js dayCheckOpen: the day-check
19
+ * window and the quiet hours). For each repository (the lead tree) it drafts every `doc` entry of the portfolio and of each board whose cadence
20
+ * has passed (state `due`) and that has no open draft, at most once per entry per day; never an entry the PM writes (`by: you`). Initiative
21
+ * documents are the Initiative Driver's (lib/driver.js doc-due). ctx adds to the route ctx: { proSettings(), inQuiet(now, quiet), isLead(w) }.
22
+ */
23
+ const guard = require('./pm-guard.js');
24
+ const CD = require('./core-docs.js');
25
+ const headless = require('./headless.js');
26
+
27
+ const text = (v) => (typeof v === 'string' ? v : '');
28
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
29
+ const POSTS = ['draft', 'issue', 'drop'];
30
+ const RUN_CAP = 3;
31
+ const TICK_EVERY_MS = 30 * 60 * 1000;
32
+
33
+ const runs = new Map(); // `${ws}|${scope}|${key}` → session id, for the core-doc runs this server started
34
+ const tried = new Map(); // `${ws}|${scope}|${key}` → the local day the tick last started a run for it
35
+ let lastTick = null;
36
+
37
+ const runKey = (ws, scope, key) => `${ws}|${scope}|${key}`;
38
+ function wsOf(ctx, id) {
39
+ const w = ctx.getWs(id || null);
40
+ return w && (!id || w.id === id) ? w : null;
41
+ }
42
+ const isRunning = (ws) => (scope, key) => runs.has(runKey(ws, scope, key));
43
+
44
+ /** The entry of a scope in this workspace: the level's facts must find the board or the initiative. → { ok, entry, facts } | refusal */
45
+ function resolve(ctx, w, scope, key, now) {
46
+ const p = CD.parseScope(scope);
47
+ if (!p) return { ok: false, status: 400, error: 'scope must be portfolio, board:<id> or initiative:<id>' };
48
+ const entry = CD.entryOf(p.level, key);
49
+ if (!entry) return { ok: false, status: 400, error: `no core document ${String(key).slice(0, 40)} at the ${p.level} level` };
50
+ const g = CD.gather(ctx, w, { level: p.level, board: p.level === 'board' ? p.id : null, id: p.level === 'initiative' ? p.id : null, now });
51
+ if (g.pending) return { ok: false, status: 202, error: 'The initiatives are being read. Ask again in a few seconds.' };
52
+ if (!g.ok) return g;
53
+ return { ok: true, entry, facts: g.facts, level: p.level, id: p.id };
54
+ }
55
+
56
+ /** Start the run that drafts one entry. → { ok, run } | { ok: false, status, error } */
57
+ function startRun(ctx, w, scope, entry, { label, now } = {}) {
58
+ if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'this workspace cannot start sessions' };
59
+ const origin = `core-doc:${scope}:${entry.key}`;
60
+ if (headless.kindFor({ agent: 'claude', origin }) !== 'core-doc') return { ok: false, status: 409, error: 'Background runs are switched off here (PMOS_HEADLESS=off), so PMOS cannot draft this document.' };
61
+ const k = runKey(w.id, scope, entry.key);
62
+ if (runs.has(k)) return { ok: false, status: 409, error: `PMOS is drafting the ${entry.title.toLowerCase()} already` };
63
+ if (runs.size >= RUN_CAP) return { ok: false, status: 409, error: `${RUN_CAP} documents are being drafted already. Try again when one is done.` };
64
+ const prompt = CD.runPrompt(entry, scope, { today: CD.localDay(now == null ? Date.now() : now), label });
65
+ const s = ctx.spawnSession({ name: `Core document · ${entry.title}`, agent: 'claude', ws: w.id, origin, initialInput: prompt + '\r',
66
+ initiative: scope.startsWith('initiative:') ? scope.slice('initiative:'.length) : undefined,
67
+ extraEnv: { PMOS_CORE_DOC_SCOPE: scope, PMOS_CORE_DOC_KEY: entry.key },
68
+ onDone: () => { runs.delete(k); try { if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id); } catch { /* the next poll recounts */ } } });
69
+ if (!s || s.state === 'exited' || s.headless !== 'core-doc') {
70
+ if (s && s.headless !== 'core-doc' && s.state !== 'exited' && typeof ctx.stopSession === 'function') { try { ctx.stopSession(s.id); } catch { /* best effort */ } }
71
+ return { ok: false, status: 409, error: 'The draft could not start: ' + ((s && s.note) || 'no background run') };
72
+ }
73
+ runs.set(k, s.id);
74
+ return { ok: true, run: s.id };
75
+ }
76
+
77
+ const labelOf = (facts) => (facts.level === 'board' && facts.board ? facts.board.name : facts.level === 'initiative' && facts.row ? `${facts.row.id} ${facts.row.title || ''}`.trim() : null);
78
+
79
+ // ── the tick ─────────────────────────────────────────────────────────────────────────────────────────
80
+ /** Draft each due doc entry of the portfolio and the boards. → [{ ws, scope, key, ok, error? }] */
81
+ function tick(ctx, { now = Date.now(), force = false } = {}) {
82
+ if (!ctx || typeof ctx.getWs !== 'function') return [];
83
+ if (!force && lastTick != null && now - lastTick < TICK_EVERY_MS) return [];
84
+ const settings = (() => { try { return ctx.proSettings ? ctx.proSettings() : null; } catch { return null; } })();
85
+ const open = require('./driver-run.js').dayCheckOpen(settings || {}, now, ctx.inQuiet || (() => false));
86
+ if (!open) return [];
87
+ lastTick = now;
88
+ if (headless.switchedOff(process.env)) return [];
89
+ const day = CD.localDay(now);
90
+ const out = [];
91
+ const list = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [ctx.getWs(null)].filter(Boolean);
92
+ for (const w of list) {
93
+ if (!w || !w.root) continue;
94
+ if (typeof ctx.isLead === 'function' && !ctx.isLead(w)) continue;
95
+ const levels = [{ level: 'portfolio' }];
96
+ try { for (const b of require('./routes-boards.js').frameFor(ctx, w).boards) levels.push({ level: 'board', board: b.id }); } catch { /* the portfolio still drafts */ }
97
+ for (const lv of levels) {
98
+ if (runs.size >= RUN_CAP) return out;
99
+ let g;
100
+ try { g = CD.gather(ctx, w, { ...lv, now }); } catch { continue; }
101
+ if (!g.ok) continue;
102
+ for (const entry of g.facts.entries) {
103
+ if (runs.size >= RUN_CAP) return out;
104
+ if (!CD.draftable(entry)) continue;
105
+ const st = CD.stateOf(entry, g.facts);
106
+ if (st.st !== 'due' || st.draft) continue;
107
+ const k = runKey(w.id, g.facts.scope, entry.key);
108
+ if (runs.has(k) || tried.get(k) === day) continue;
109
+ const x = g.facts.store.get(`${g.facts.scope}|${entry.key}`);
110
+ if (x && x.drafts.some((d) => CD.localDay(Date.parse(d.at)) === day)) continue; // drafted today already (by a press, or before a restart)
111
+ tried.set(k, day);
112
+ const r = startRun(ctx, w, g.facts.scope, entry, { label: labelOf(g.facts), now });
113
+ out.push({ ws: w.id, scope: g.facts.scope, key: entry.key, ok: r.ok, ...(r.ok ? { run: r.run } : { error: r.error }) });
114
+ }
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // ── the handler ──────────────────────────────────────────────────────────────────────────────────────
121
+ async function handle(req, res, url, ctx) {
122
+ const p = url.pathname;
123
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
124
+ const sub = p.replace(/^\/api\/core-docs\/?/, '');
125
+
126
+ if (sub === '') {
127
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
128
+ const w = wsOf(ctx, url.searchParams.get('ws'));
129
+ if (!w) return send(bad(404, 'no such workspace'));
130
+ const level = text(url.searchParams.get('level')) || 'portfolio';
131
+ const g = CD.gather(ctx, w, { level, board: url.searchParams.get('board') || null, id: url.searchParams.get('id') || null, now: Date.now() });
132
+ if (g.pending) return send({ status: 202, body: { ok: true, pending: true } });
133
+ if (!g.ok) return send(bad(g.status || 400, g.error));
134
+ return send({ status: 200, body: CD.answer(g.facts, { running: isRunning(w.id) }) });
135
+ }
136
+ if (!POSTS.includes(sub)) return send(bad(404, 'no such core-docs route'));
137
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
138
+ const read = await ctx.readJson(req);
139
+ if (!read.ok) return send(bad(400, 'the body is not JSON'));
140
+ const body = read.body || {};
141
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot draft, issue or drop a core document. Only a press on the page does'));
142
+ const g = sub === 'draft' ? guard.requirePagePress(req, body) : guard.requirePmPress(req, body, { act: 'issue' });
143
+ if (!g.ok) return send(bad(g.status, g.error));
144
+ const w = wsOf(ctx, text(body.ws));
145
+ if (!w) return send(bad(404, 'no such workspace'));
146
+ const scope = text(body.scope);
147
+ const key = text(body.key);
148
+ const now = Date.now();
149
+ const refresh = () => { try { if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id); } catch { /* the next poll recounts */ } };
150
+
151
+ if (sub === 'draft') {
152
+ const r = resolve(ctx, w, scope, key, now);
153
+ if (!r.ok) return send(bad(r.status || 400, r.error));
154
+ if (r.entry.kind !== 'doc') return send(bad(400, `${r.entry.title} is read from its own source; there is nothing to draft here`));
155
+ if (r.entry.by === 'you') return send(bad(400, `You write the ${r.entry.title.toLowerCase()}. PMOS only checks it`));
156
+ if (r.level === 'board' && r.entry.build && !r.facts.board.build) return send(bad(400, `${r.entry.title} is only for a board with Build and deliver`));
157
+ if (r.level === 'initiative' && !r.facts.list.some((x) => x.entry.key === key)) return send(bad(400, `${r.entry.title} is not in this initiative's set`));
158
+ const s = startRun(ctx, w, scope, r.entry, { label: labelOf(r.facts), now });
159
+ if (!s.ok) return send(bad(s.status, s.error));
160
+ return send({ status: 200, body: { ok: true, run: s.run } });
161
+ }
162
+ const c = CD.checkEntry(scope, key);
163
+ if (!c.ok) return send(bad(c.status, c.error));
164
+ const fn = sub === 'issue' ? CD.issue : CD.drop;
165
+ const r = fn(w.root, { scope, key, slot: text(body.slot) }, g.actor, { now });
166
+ if (!r.ok) return send(bad(r.status, r.error));
167
+ refresh();
168
+ return send({ status: 200, body: { ok: true } });
169
+ }
170
+
171
+ module.exports = { prefixes: ['/api/core-docs'], handle, tick, startRun, RUN_CAP, TICK_EVERY_MS, _runs: runs, _reset: () => { runs.clear(); tried.clear(); lastTick = null; } };
@@ -135,6 +135,7 @@ async function handle(req, res, url, ctx) {
135
135
  stageFile: path.join(stateDir, stageTools.LEDGER),
136
136
  itemsDir: path.join(runtimeOf(ctx), 'pm-items'),
137
137
  correctionsFile: path.join(runtimeOf(ctx), 'corrections.jsonl'),
138
+ root: w.root, // core-documents: Keep of a doc proposal issues the draft (lib/core-docs.js)
138
139
  // The board move route's own function: its validation and its warnings apply, and `by` is the PM.
139
140
  move: async (pr) => {
140
141
  const routesBoards = require('./routes-boards.js');
@@ -67,7 +67,7 @@ function registry() {
67
67
  try {
68
68
  REGISTRY = new Set(fs.readFileSync(path.join(PMOS_ROOT, 'scripts', 'frontmatter_registry.txt'), 'utf8')
69
69
  .split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')));
70
- } catch { REGISTRY = new Set(['Concept', 'ADR', 'Template', 'Playbook', 'Reference', 'Initiative', 'PRD', 'Story', 'Rubric', 'OKR', 'Radar', 'Checklist', 'Calibration', 'Research', 'Design', 'Prototype', 'Handover', 'Decision', 'Principle', 'Debt', 'Discovery', 'Guide']); }
70
+ } catch { REGISTRY = new Set(['Concept', 'ADR', 'Template', 'Playbook', 'Reference', 'Initiative', 'PRD', 'Story', 'Rubric', 'OKR', 'Radar', 'Checklist', 'Calibration', 'Research', 'Design', 'Prototype', 'Handover', 'Review', 'Decision', 'Principle', 'Debt', 'Discovery', 'Guide']); }
71
71
  return REGISTRY;
72
72
  }
73
73
 
@@ -47,7 +47,7 @@ const TYPE_TO_CATEGORY = {
47
47
  Concept: 'knowledge', ADR: 'spec-artifact', Template: 'other', Playbook: 'knowledge', Reference: null,
48
48
  Initiative: 'spec-artifact', PRD: 'prd', Story: 'spec-artifact', Rubric: 'spec-artifact', OKR: 'spec-artifact',
49
49
  Radar: 'report', Checklist: 'other', Calibration: 'report', Research: null, Design: 'spec-artifact',
50
- Prototype: 'other', Handover: 'report',
50
+ Prototype: 'other', Handover: 'report', Review: 'report',
51
51
  Decision: 'spec-artifact', Principle: 'knowledge', Debt: 'report', Discovery: 'report', Guide: 'knowledge',
52
52
  };
53
53
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.193.0",
3
+ "version": "0.195.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.193.0",
9
+ "version": "0.195.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.193.0",
3
+ "version": "0.195.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",
@@ -21,6 +21,7 @@ import { cardView, TAG_STYLE } from './briefing-model.js';
21
21
  import { onBriefPage } from './c3-brief-data.js';
22
22
  import { button, ensureComponentsCss } from './primitives.js';
23
23
  import { loadDriverNeeds, needsRow as driverRow } from './c3-driver.js';
24
+ import { needsLoc } from './c3-core-docs.js';
24
25
 
25
26
  const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
26
27
  const post = (path, body) => pmPost(path, body).catch(() => ({ ok: false }));
@@ -175,6 +176,8 @@ export function needsSection(S) {
175
176
  /* the agent's rows: drafts waiting on the PM, a target the PM is blocking. A press opens the initiative (its Overview holds the strip) or Work. */
176
177
  let agentSig = '';
177
178
  const goAgent = (n) => {
179
+ const docsLoc = n.kind === 'core-doc-drafts' ? needsLoc(n) : null;
180
+ if (docsLoc) { try { const u = new URL(location.href); u.searchParams.set('init', docsLoc); history.replaceState(history.state, '', u); } catch { /* no history */ } if (hooks.go) hooks.go({ action: 'goinit' }); try { window.dispatchEvent(new CustomEvent('pmos:ini-go', { detail: { loc: docsLoc } })); } catch { /* the page reads the address when it mounts */ } return; }
178
181
  if (n.initiative_id) { try { const u = new URL(location.href); u.searchParams.set('init', 'ini:' + encodeURIComponent(n.ws) + ':' + encodeURIComponent(n.initiative_id) + ':overview'); history.replaceState(history.state, '', u); } catch { /* no history */ } }
179
182
  if (hooks.go) hooks.go({ action: n.initiative_id ? 'goinit' : 'gowork' });
180
183
  };
@@ -0,0 +1,18 @@
1
+ /* pm/c3-core-docs.css — the core documents of a level (c3-core-docs.js): the agent colour for a draft, the actions under a row, the
2
+ * panel above a draft in the document sheet. Scoped to .cd-host and .cd-panel; tokens only. */
3
+ .c3 .cd-host { display: contents; }
4
+ .c3 .cd-host .nrow > .acts { grid-column: 2; display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-start; align-items: flex-start; }
5
+ .c3 .cd-host .st.cd-ag, .c3 .cd-panel .st.cd-ag { color: var(--agent); border-color: color-mix(in oklab, var(--agent) 45%, transparent); background: color-mix(in oklab, var(--agent) 8%, transparent); }
6
+ .c3 .cd-host .chip2.cd-agc { color: var(--agent); }
7
+ .c3 .cd-panel { display: grid; gap: var(--sp-2); min-width: 0; }
8
+ .c3 .cd-panel .fold { border-color: color-mix(in oklab, var(--agent) 45%, transparent); }
9
+ .c3 .cd-oh { margin-top: var(--sp-3); }
10
+ /* The quiet pages (c3-quiet.css) hide teaching sentences and every chip after the second. The approved mockup of the Docs views keeps both:
11
+ * a row says what the document is for, who reads it, how often it changes and who writes it. */
12
+ .c3 .cd-host .ini-note, .c3 .cd-panel .ini-note { display: block !important; }
13
+ .c3 .cd-host .nrow .nb > .chips > :nth-child(n+3) { display: inline-flex !important; }
14
+ .c3 [data-ini="pf-docs"] .ini-lhm .dim { display: block !important; }
15
+ .c3 .cd-host .sect .sh2 .ini-meta { display: inline !important; }
16
+ /* the heading above the board's other documents: the look of a section heading, without a card around the search and the groups */
17
+ .c3 .cd-host .cd-oh { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: var(--sp-3) 0 var(--sp-2); border-bottom: 1px solid var(--border); }
18
+ .c3 .cd-host .cd-oh h2 { margin: 0; font: 500 var(--fs-2) var(--mono); color: var(--muted-foreground); text-transform: uppercase; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; }
@@ -0,0 +1,234 @@
1
+ /* pm/c3-core-docs.js — the core documents of a level (initiative core-documents, PRD section 6; the approved mockup 43-doc-levels.js).
2
+ * Each level answers one question for one reader: Portfolio, Board, Initiative. Its Docs view starts with the level's core set, from
3
+ * GET /api/core-docs?ws&level=portfolio|board|initiative&board&id { ok, level, scope, question, reader, rhythm, build, wfTpl, stage, cat,
4
+ * summary, docs: [entry + state] }
5
+ * POST /api/core-docs/draft { ws, scope, key } { ok, run } Draft it, and Redraft ("Ask PMOS to change it")
6
+ * POST /api/core-docs/issue { ws, scope, key, slot } { ok } Issue it
7
+ * The server holds the sets, the states and the drafts; this file draws them and holds nothing.
8
+ *
9
+ * coreDocs(env) -> { left, right, dispose } two hosts to put in the columns of cols(): the sections and the cards. env =
10
+ * { api, level, board?, id?, ws, today, toast, nav: { tab(tab), tool(tool), reports() }, boardRow?, iniDocs?: { docs, initiatives } }
11
+ * needsLoc(row) -> the `init=` address of the Docs view a Briefing row (kind core-doc-drafts) opens
12
+ * segBadge(n) the count badge of the Portfolio's Documents segment
13
+ * A press on a row opens `open`: { path } the document sheet, { tab } the board tab, { tool } the initiative's Overview tool, { reports } Reports.
14
+ * Review the draft opens the draft in the document sheet. The sheet holds the actions: a panel above the draft (docAbove, the sheet's own hook)
15
+ * with Issue it and Ask PMOS to change it, which asks for a new draft. Nothing is sent to anyone; the PM's press issues it. */
16
+ import { el } from './dom.js';
17
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, nrow, plural, day, ago, sw, chgl, mono, put, ensureIniCss } from './c3-ini-ui.js';
18
+
19
+ const BY = { you: 'You write it', proposes: 'PMOS proposes', drafts: 'PMOS drafts it', files: 'PMOS files it', watches: 'PMOS watches it', keeps: 'PMOS keeps it current', live: 'Live view', reviews: 'PMOS reviews, you accept' };
20
+ const AGENT_BY = ['drafts', 'proposes', 'files', 'watches', 'keeps', 'reviews'];
21
+ const CAT_IC = { Discover: 'search', Plan: 'cal', Do: 'bolt', Learn: 'up' };
22
+ const CAT_ORDER = ['Discover', 'Plan', 'Do', 'Learn'];
23
+ const POLL_MS = 4000;
24
+ const PENDING_MS = 1500;
25
+
26
+ function ensureCss() {
27
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-cd]')) return;
28
+ ensureIniCss();
29
+ const l = document.createElement('link');
30
+ l.rel = 'stylesheet'; l.href = new URL('./c3-core-docs.css', import.meta.url).href; l.setAttribute('data-c3-cd', '');
31
+ document.head.appendChild(l);
32
+ }
33
+
34
+ /** The count badge of the Portfolio's Documents segment (hidden at 0). */
35
+ export function segBadge(n) { return el('span', { class: 'bdg', 'data-cd': 'seg-badge', ...(n ? {} : { hidden: '' }) }, String(n || 0)); }
36
+
37
+ /** The address of the Docs view a Briefing row opens, as the `init=` value (c3-initiatives.js parseLoc). Null when the row names no scope. */
38
+ export function needsLoc(row) {
39
+ const r = row || {};
40
+ const raw = String(r.scope || (/(?:^|:)(portfolio|board:[^:|]+|ini(?:tiative)?:[^:|]+)$/.exec(String(r.run_id || '')) || [])[1] || '');
41
+ if (raw == 'portfolio') return 'docs';
42
+ let m = /^board:(.+)$/.exec(raw);
43
+ if (m) return 'board:' + encodeURIComponent(m[1]) + ':docs';
44
+ m = /^ini(?:tiative)?:(.+)$/.exec(raw);
45
+ const id = m ? m[1] : r.initiative_id;
46
+ if (id) return 'ini:' + encodeURIComponent(r.board || r.ws || '') + ':' + encodeURIComponent(id) + ':docs';
47
+ return null;
48
+ }
49
+
50
+ /* ── the sheet's draft panel ── */
51
+ const DRAFTS = new Map(); // path -> { ws, api, scope, key, slot, toast, ui, done }
52
+ const NODES = new WeakMap();
53
+ let hooked = null;
54
+ function hookSheet() {
55
+ if (!hooked) hooked = import('./c3-doc-sheet.js').then((m) => { m.docAbove.push(({ doc, key }) => { const e = DRAFTS.get(doc.path); if (!e) return null; if (!NODES.has(key)) NODES.set(key, draftPanel(e)); return NODES.get(key); }); return m; });
56
+ return hooked;
57
+ }
58
+ function draftPanel(e) {
59
+ const msg = el('div', { class: 'fold w', role: 'alert', hidden: '', 'data-cd': 'panel-error' });
60
+ const fail = (t) => { msg.textContent = t; msg.hidden = false; };
61
+ let busy = false;
62
+ const act = (path, body, done) => async () => {
63
+ if (busy) return; busy = true; msg.hidden = true;
64
+ const r = await e.api.post(path, { scope: e.scope, key: e.key, ...body }).catch(() => null);
65
+ busy = false;
66
+ if (!r || !r.ok) { fail((r && r.error) || 'no answer'); return; }
67
+ done(); if (e.ui && e.ui.close) e.ui.close();
68
+ if (e.done) e.done();
69
+ };
70
+ const issue = act('/api/core-docs/issue', { slot: e.slot }, () => { DRAFTS.delete(e.path); e.toast('Issued. Nothing was sent to anyone.'); });
71
+ const redraft = act('/api/core-docs/draft', {}, () => { e.toast('PMOS is drafting it again. The new draft replaces this one when it is ready.'); });
72
+ return el('div', { class: 'cd-panel', 'data-cd': 'draft-panel' },
73
+ el('div', { class: 'fold' }, icon('spark', 'sm'), el('span', null, ' PMOS drafted this from its sources. Nothing is sent. Read it, change what you like, then issue it.')),
74
+ el('div', { class: 'chips2' }, pbtn('primary', 'Issue it', 'check', { 'data-cd-issue': e.key, onclick: issue }), pbtn('outline', 'Ask PMOS to change it', 'spark', { 'data-cd-redraft': e.key, onclick: redraft })),
75
+ msg);
76
+ }
77
+
78
+ /* ── rows ── */
79
+ const summaryLine = (docs) => {
80
+ const n = (s) => docs.filter((d) => d.st === s).length;
81
+ return [n('draft') ? plural(n('draft'), 'draft') + ' to review' : '', n('due') ? n('due') + ' due' : '', n('missing') ? n('missing') + ' not started' : ''].filter(Boolean).join(', ') || 'Everything is up to date';
82
+ };
83
+ const isAgent = (d) => d.running || d.st === 'draft';
84
+
85
+ function stChip(d) {
86
+ if (d.running) return el('span', { class: 'st cd-ag', 'data-cd-state': 'running' }, icon('spark', 'sm'), 'Agent drafting');
87
+ const a = { 'data-cd-state': d.st };
88
+ switch (d.st) {
89
+ case 'ok': return st('ok', 'check', 'Up to date', a);
90
+ case 'due': return st('warn', 'clock', d.due ? 'Due ' + day(d.due) : 'Due now', a);
91
+ case 'draft': return el('span', { class: 'st cd-ag', ...a }, icon('spark', 'sm'), 'Draft ready' + (d.due ? ', due ' + day(d.due) : ''));
92
+ case 'missing': return st('bad', 'alert', 'Not started', a);
93
+ case 'stale': return st('warn', 'clock', 'Out of date', a);
94
+ default: return el('span', { class: 'chip2 dim', ...a }, 'Comes later');
95
+ }
96
+ }
97
+ const byChip = (k) => (AGENT_BY.includes(k) ? el('span', { class: 'chip2 cd-agc' }, icon('spark', 'sm'), BY[k]) : chip(icon(k === 'live' ? 'eye' : 'user', 'sm'), BY[k] || k));
98
+
99
+ function pathNameOf(env, d) { return d.title + (env.scopeLabel ? ' · ' + env.scopeLabel : ''); }
100
+
101
+ async function openSheetFor(env, d, path, draft) {
102
+ try {
103
+ const m = await hookSheet();
104
+ const ui = m.openDocSheet({ ws: env.ws, doc: { path, name: pathNameOf(env, d), ty: draft ? 'Draft' : 'Document', v: '', st: draft ? 'draft' : d.st, ini: env.level === 'initiative' ? env.id : '', iniName: '' },
105
+ library: () => env.api.get('/api/docs/library', env.board ? { board: env.board } : {}) });
106
+ if (draft) { const e = DRAFTS.get(path); if (e) e.ui = ui; }
107
+ return ui;
108
+ } catch { env.toast('The document sheet could not open. The file is ' + path + '.'); return null; }
109
+ }
110
+
111
+ function press(env, d, redraw) {
112
+ const o = d.open || null;
113
+ if (!o) return null;
114
+ if (o.path) return () => openSheetFor(env, d, o.path, false);
115
+ if (o.tab) return () => env.nav.tab(o.tab);
116
+ if (o.tool) return () => env.nav.tool(o.tool);
117
+ if (o.reports) return () => env.nav.reports();
118
+ return null;
119
+ }
120
+
121
+ function review(env, d, redraw) {
122
+ const p = d.draft && d.draft.path;
123
+ if (!p) return () => env.toast('The draft has no file yet. Try again in a moment.');
124
+ return () => {
125
+ DRAFTS.set(p, { path: p, ws: env.ws, api: env.api, scope: env.scope, key: d.key, slot: d.draft.slot, toast: env.toast, ui: null, done: redraw });
126
+ openSheetFor(env, d, p, true);
127
+ };
128
+ }
129
+
130
+ async function draftIt(env, d, redraw) {
131
+ const r = await env.api.post('/api/core-docs/draft', { scope: env.scope, key: d.key }).catch(() => null);
132
+ if (!r || !r.ok) { env.toast('The draft did not start: ' + ((r && r.error) || 'no answer') + '.'); return; }
133
+ env.toast('PMOS is drafting the ' + d.title.toLowerCase() + '. It lands here as a draft for you to review.');
134
+ redraw();
135
+ }
136
+
137
+ function coreRow(env, d, redraw) {
138
+ const open = press(env, d, redraw);
139
+ const meta = [d.when && !['later', 'missing'].includes(d.st) ? chip(icon('clock', 'sm'), (d.st === 'draft' ? 'drafted ' : 'updated ') + ago(d.when, env.today)) : null,
140
+ d.flag ? st('warn', 'alert', d.flag) : null, d.staleWhy && d.st === 'stale' ? chip(d.staleWhy) : null];
141
+ let action = null;
142
+ if (!d.running && d.st === 'draft') action = pbtn('primary', 'Review the draft', 'spark', { 'data-cd-review': d.key, onclick: review(env, d, redraw) });
143
+ else if (!d.running && d.kind === 'doc' && d.by !== 'you' && (d.st === 'missing' || d.st === 'stale')) action = pbtn('outline', 'Draft it', 'spark', { 'data-cd-draft': d.key, onclick: () => draftIt(env, d, redraw) });
144
+ return nrow({ ic: d.ic || 'doc', hot: isAgent(d) || d.st === 'missing', title: [d.title, d.isNew ? [' ', tag('new')] : null], onOpen: open || undefined, openLabel: open ? 'Open ' + d.title : undefined,
145
+ titleAttrs: { 'data-cd-open': d.key }, sub: d['for'] || '',
146
+ chips: [stChip(d), d.cadence ? chip(icon('cal', 'sm'), d.cadence) : null, d.reader ? chip(icon('user', 'sm'), d.reader) : null, byChip(d.by), ...meta],
147
+ right: [action], attrs: { 'data-cd-row': d.key, 'data-cd-st': d.running ? 'running' : d.st } });
148
+ }
149
+
150
+ /* ── the sections ── */
151
+ function coreSection(env, data, redraw) {
152
+ return sect({ ic: 'doc', title: 'Core documents', count: data.docs.length, attrs: { 'data-cd': 'core' }, right: mono(summaryLine(data.docs)) },
153
+ list(data.docs.map((d) => coreRow(env, d, redraw)), { 'data-cd': 'core-list' }));
154
+ }
155
+
156
+ function iniSections(env, data, redraw) {
157
+ const cur = data.cat || 'Plan';
158
+ return CAT_ORDER.map((c) => {
159
+ const ds = data.docs.filter((d) => d.cat === c);
160
+ if (!ds.length) return null;
161
+ const ic = CAT_IC[c];
162
+ const a = (extra) => ({ 'data-cd-cat': c, ...extra });
163
+ if (ds.every((d) => d.st === 'later')) return sect({ ic, title: c, count: ds.length, attrs: a({ 'data-cd-fold': 'later' }), right: chip('Comes later') },
164
+ el('div', { class: 'chips2' }, ...ds.map((d) => el('span', { class: 'chip2 dim' }, icon(d.ic || 'doc', 'sm'), d.title))));
165
+ if (c !== cur && ds.every((d) => d.st === 'ok' && !d.running)) return sect({ ic, title: c, count: ds.length, attrs: a({ 'data-cd-fold': 'done' }), right: st('ok', 'check', 'All ' + ds.length + ' up to date') },
166
+ el('div', { class: 'chips2' }, ...ds.map((d) => { const o = press(env, d, redraw); return o ? lk({ onclick: o, 'data-cd-chip': d.key }, icon(d.ic || 'doc', 'sm'), d.title) : el('span', { class: 'chip2' }, icon(d.ic || 'doc', 'sm'), d.title); })));
167
+ return sect({ ic, title: c + (c === cur ? ' · now' : ''), count: ds.length, attrs: a({ 'data-cd-fold': 'open' }), right: chip(sw(c), ds.filter((d) => d.st === 'ok').length + ' of ' + ds.length + ' done') },
168
+ list(ds.map((d) => coreRow(env, d, redraw))));
169
+ }).filter(Boolean);
170
+ }
171
+
172
+ /* ── the cards of the right column ── */
173
+ const levelCard = (data, extra) => card({ ic: 'info', title: 'What this level is for', attrs: { 'data-cd': 'level' } },
174
+ el('p', { class: 'ini-p' }, el('b', null, data.question || '')),
175
+ note('Read by ' + (data.reader || 'the team') + '. Changes ' + (data.rhythm || 'as it changes') + '.' + (data.level == 'portfolio' ? ' Boards and initiatives link up to it through their key result, so nothing here repeats what a board says.' : '')),
176
+ ...extra);
177
+
178
+ function draftsCard(env, data, redraw) {
179
+ const ds = data.docs.filter((d) => d.st === 'draft' && !d.running);
180
+ return card({ ic: 'spark', title: 'Drafts waiting for you', attrs: { 'data-cd': 'drafts' } },
181
+ ds.length ? chgl(ds.map((d) => el('li', null, icon('spark', 'sm'), el('span', null, lk({ onclick: review(env, d, redraw), 'data-cd-wait': d.key }, d.title), d.due ? ' due ' + day(d.due) : ''), tag('Briefing'))))
182
+ : note('No draft is waiting.'),
183
+ note('Drafts also show as one Briefing card. Nothing is sent until you issue it.'));
184
+ }
185
+ const connectCard = () => card({ ic: 'init', title: 'How the levels connect', attrs: { 'data-cd': 'connect' } }, chgl([
186
+ el('li', null, icon('alert', 'sm'), el('span', null, 'Risks are written once, on the initiative. Board risks and cross-board risks collect them.')),
187
+ el('li', null, icon('up', 'sm'), el('span', null, 'Each initiative\'s outcome review feeds the board retro and the quarterly review.')),
188
+ el('li', null, icon('report', 'sm'), el('span', null, 'Status reports at every level come from the same dashboard, so they agree.'))]));
189
+
190
+ function rightCards(env, data, redraw) {
191
+ if (data.level == 'portfolio') return [levelCard(data, []), draftsCard(env, data, redraw), connectCard()];
192
+ if (data.level === 'board') {
193
+ const b = env.boardRow || {};
194
+ const ini = env.iniDocs;
195
+ return [levelCard(data, [note(data.build ? 'Build and deliver is on, so the set adds the sprint review and release notes' + (b.jira ? ' from Jira ' + b.jira : '') + '.' : 'Build and deliver is off, so there is no sprint review or release notes. Progress comes from PMOS tasks.')]),
196
+ card({ ic: 'doc', title: 'Initiative documents', attrs: { 'data-cd': 'ini-docs' } },
197
+ note('Each initiative keeps its own PRD, rubric, risks and outcome review on its Docs tab.' + (ini ? ' ' + plural(ini.docs, 'document') + ' across ' + plural(ini.initiatives, 'initiative') + '.' : '')))];
198
+ }
199
+ return [levelCard(data, [note('The set grows with the workflow: each stage adds the documents it needs, and later stages wait.'), data.wfTpl === 'research' ? note('Research only: nothing gets built, so there is no PRD, rubric, design or launch checklist.') : null]),
200
+ card({ ic: 'alert', title: 'Order rules', attrs: { 'data-cd': 'order' } }, note('A missing PRD or rubric before Build shows as a warning on the initiative, never a block. The CI gate still needs a PRD and a rubric before a pull request merges.'))];
201
+ }
202
+
203
+ /** The two hosts of a level's core documents. Reads the set, draws it, and reads it again after a press and while an agent drafts. */
204
+ export function coreDocs(env) {
205
+ ensureCss();
206
+ const left = el('div', { class: 'cd-host', 'data-cd-host': 'left' }, note('Reading the core documents'));
207
+ const right = el('div', { class: 'cd-host', 'data-cd-host': 'right' });
208
+ let dead = false; let timer = null; let seq = 0; let last = null;
209
+ const get = () => env.api.get('/api/core-docs', { level: env.level, board: env.level == 'portfolio' ? '' : env.board, id: env.level === 'initiative' ? env.id : '' });
210
+ async function load() {
211
+ const token = ++seq;
212
+ const r = await get().catch(() => null);
213
+ if (dead || token !== seq) return;
214
+ clearTimeout(timer);
215
+ if (r && r.pending) { // the initiative rows are still loading: say so and ask again
216
+ put(left, note('Reading the core documents')); put(right);
217
+ timer = setTimeout(() => { if (!dead && left.isConnected) load(); }, PENDING_MS); return;
218
+ }
219
+ if (!r || !r.ok || !Array.isArray(r.docs)) {
220
+ put(left, el('div', { class: 'fold w', role: 'alert', 'data-cd': 'error' }, icon('alert', 'sm'), 'The core documents could not be read: ' + ((r && r.error) || 'no answer') + '.'));
221
+ put(right); return;
222
+ }
223
+ const redraw = () => { load(); };
224
+ last = { r, redraw };
225
+ put(left, ...(env.level === 'initiative' ? iniSections({ ...env, scope: r.scope }, r, redraw) : [coreSection({ ...env, scope: r.scope }, r, redraw)]));
226
+ put(right, ...rightCards({ ...env, scope: r.scope }, r, redraw));
227
+ if (env.onData) env.onData(r);
228
+ if (r.docs.some((d) => d.running)) timer = setTimeout(() => { if (!dead && left.isConnected) load(); }, POLL_MS);
229
+ }
230
+ load();
231
+ return { left, right, dispose() { dead = true; clearTimeout(timer); }, reload: load,
232
+ /** The counts of the board's other documents, for the Initiative documents card. */
233
+ setIniDocs(v) { env.iniDocs = v; if (last) put(right, ...rightCards({ ...env, scope: last.r.scope }, last.r, last.redraw)); } };
234
+ }