create-pmos 0.193.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 (42) 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/pmos-verbs.js +3 -0
  23. package/workspace/lib/route-modules.js +1 -0
  24. package/workspace/lib/routes-core-docs.js +171 -0
  25. package/workspace/lib/routes-driver.js +1 -0
  26. package/workspace/lib/vendor/scripts/migrate-layout.js +1 -1
  27. package/workspace/lib/vendor/workspace/lib/doc-category.js +1 -1
  28. package/workspace/package-lock.json +2 -2
  29. package/workspace/package.json +1 -1
  30. package/workspace/public/pm/c3-brief-needs.js +3 -0
  31. package/workspace/public/pm/c3-core-docs.css +18 -0
  32. package/workspace/public/pm/c3-core-docs.js +234 -0
  33. package/workspace/public/pm/c3-demo-coredocs.js +211 -0
  34. package/workspace/public/pm/c3-demo.js +2 -1
  35. package/workspace/public/pm/c3-driver.js +6 -4
  36. package/workspace/public/pm/c3-ib-docs.js +24 -14
  37. package/workspace/public/pm/c3-ii-tabs.js +15 -8
  38. package/workspace/public/pm/c3-initiatives.js +29 -2
  39. package/workspace/server.js +15 -2
  40. package/workspace/skills/initiative-drive.skill +6 -2
  41. package/workspace/test-lists/c3-core-docs.json +9 -0
  42. package/workspace/test-lists/core-documents-backend.json +5 -0
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+ /* coredoc-cli.js — `pmos coredoc …`: the core documents of each level from a terminal (initiative core-documents, PRD sections 3 and 4).
3
+ *
4
+ * pmos coredoc write --scope <portfolio|board:<id>|initiative:<id>> --key <key> --file <path|-> [--json] [--root <dir>]
5
+ * writes ONE draft of a core document under <state>/core-docs/<scope>/<key>/<slot>.md and one `draft` line. Agent-callable: it is the
6
+ * only write of a core-doc run (lib/headless.js kind core-doc) and the driver's way to draft a due document. It writes a draft and
7
+ * nothing else: only the PM issues or drops it, on the page. Nothing is sent.
8
+ *
9
+ * REFUSALS (exit 1): an unknown scope form, a key that is not a document of that level, an entry PMOS does not keep as a document (a derived,
10
+ * live or report entry), an entry the PM writes (`by: you`) from an agent session, and a scope or key other than the one the server pinned for
11
+ * this run (PMOS_CORE_DOC_SCOPE, PMOS_CORE_DOC_KEY). Who wrote the draft is the call path (lib/actor.js), never an argument.
12
+ * Exit codes: 0 done, 1 refused, 2 usage.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { execFileSync } = require('child_process');
17
+ const actorLib = require('./actor.js');
18
+ const CD = require('./core-docs.js');
19
+
20
+ const COMMANDS = { coredoc: ['write'] };
21
+ const USAGE = [
22
+ 'usage: pmos coredoc write — the core documents of a portfolio, a board or an initiative',
23
+ ' pmos coredoc write --scope <portfolio|board:<id>|initiative:<id>> --key <key> --file <path|->',
24
+ ' Write ONE draft of a core document (the markdown from the file, or standard input with -). It waits for the PM, who issues it.',
25
+ ' Keys: ' + CD.LEVELS.map((l) => `${l}: ${CD.SETS[l].filter((x) => x.kind === 'doc').map((x) => x.key).join(', ')}`).join(' · '),
26
+ ' every command: [--json] [--root <dir>]. Nothing is sent.',
27
+ ];
28
+ const BOOL = new Set(['json', 'help']);
29
+ const VALUE = new Set(['scope', 'key', 'file', 'root']);
30
+
31
+ function parse(argv) {
32
+ const pos = []; const flags = {};
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a === '-h') { flags.help = true; continue; }
36
+ if (!a.startsWith('--')) { pos.push(a); continue; }
37
+ const eq = a.indexOf('=');
38
+ const key = eq > 0 ? a.slice(2, eq) : a.slice(2);
39
+ if (BOOL.has(key)) { flags[key] = true; continue; }
40
+ let val;
41
+ if (eq > 0) val = a.slice(eq + 1);
42
+ else { const next = argv[i + 1]; if (next === undefined || (next.startsWith('--') && next.length > 2)) val = ''; else { val = next; i++; } }
43
+ flags[key] = val;
44
+ }
45
+ return { pos, flags };
46
+ }
47
+ function repoRoot(cwd) {
48
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
49
+ catch { return cwd; }
50
+ }
51
+ function readStdin(stdin) {
52
+ if (typeof stdin === 'string') return stdin;
53
+ try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
54
+ }
55
+
56
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, now, stdin } = {}) {
57
+ const args = argv[0] === 'coredoc' ? argv.slice(1) : argv.slice();
58
+ const verb = args[0];
59
+ const { flags } = parse(args.slice(1));
60
+ const json = !!flags.json;
61
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h' || flags.help) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
62
+ if (!COMMANDS.coredoc.includes(verb)) { say(`unknown coredoc command "${verb}" — one of ${COMMANDS.coredoc.join(', ')}`); return 2; }
63
+ if ('by' in flags || 'via' in flags) { say(`attribution is set from the call path; --${'by' in flags ? 'by' : 'via'} is not accepted`); return 2; }
64
+ for (const k of Object.keys(flags)) {
65
+ if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
66
+ if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
67
+ }
68
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
69
+ if (!flags.scope || !flags.key || !flags.file) { say('usage: pmos coredoc write --scope <portfolio|board:<id>|initiative:<id>> --key <key> --file <path|->'); return 2; }
70
+ const e = env === undefined ? process.env : env;
71
+ const c = CD.checkEntry(flags.scope, flags.key);
72
+ if (!c.ok) return fail(c.error);
73
+ const pinScope = typeof e.PMOS_CORE_DOC_SCOPE === 'string' && e.PMOS_CORE_DOC_SCOPE ? e.PMOS_CORE_DOC_SCOPE : null;
74
+ const pinKey = typeof e.PMOS_CORE_DOC_KEY === 'string' && e.PMOS_CORE_DOC_KEY ? e.PMOS_CORE_DOC_KEY : null;
75
+ if ((pinScope && pinScope !== flags.scope) || (pinKey && pinKey !== flags.key)) return fail(`this run drafts ${pinKey || flags.key} for ${pinScope || flags.scope} only`);
76
+ const actor = actorLib.fromCli({ isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env: e });
77
+ if (c.entry.by === 'you' && actorLib.isAgent(actor)) return fail(`the PM writes the ${c.entry.title.toLowerCase()}; an agent only checks it`);
78
+ let markdown;
79
+ if (flags.file === '-') markdown = readStdin(stdin);
80
+ else { try { markdown = fs.readFileSync(path.resolve(cwd, flags.file), 'utf8'); } catch (err) { return fail(`cannot read ${flags.file}: ${String(err.code || err.message)}`, 2); } }
81
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
82
+ let r;
83
+ try { r = CD.writeDraft(root, { scope: flags.scope, key: flags.key, markdown, by: actor.by, now }); } catch (err) { return fail(`the draft was not written: ${String((err && err.message) || err).slice(0, 160)}`); }
84
+ if (!r.ok) return fail(r.error);
85
+ if (json) say(JSON.stringify({ ok: true, scope: flags.scope, key: flags.key, slot: r.slot, path: r.path }));
86
+ else say(`✓ drafted ${r.path}. It waits for the PM, who issues it. Nothing was sent.`);
87
+ return 0;
88
+ }
89
+
90
+ module.exports = { run, COMMANDS, USAGE };
@@ -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
 
@@ -31,7 +31,7 @@ const COMMANDS = { driver: ['propose', 'status', 'keep', 'return', 'drop', 'paus
31
31
  const USAGE = [
32
32
  'usage: pmos driver propose|status|keep|return|drop|pause|resume|mode — the Initiative Driver',
33
33
  ' pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>" [--source label=ref …]',
34
- ' what: problem, evidence, sizing, assumption, stakeholders, move, note (initiative) · next-step, follow-up, note (Work task)',
34
+ ' what: problem, evidence, sizing, assumption, stakeholders, move, note, doc (initiative) · next-step, follow-up, note (Work task)',
35
35
  ' pmos driver status <initiative|work> <id>',
36
36
  ' pmos driver keep <proposal> PM only',
37
37
  ' pmos driver return <proposal> --note "<what to change>" PM only',
@@ -132,6 +132,20 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
132
132
  const actor = actorLib.fromCli(who);
133
133
  const state = D.fold(D.readLines(file));
134
134
  const same = JSON.stringify(c.proposal.data);
135
+ // core-documents: a stage move waits until the current stage's documents are kept; a doc proposal names a draft that is there.
136
+ if (c.proposal.what === 'move' || c.proposal.what === 'doc') {
137
+ const CD = require('./core-docs.js');
138
+ const store = CD.storeOf(root);
139
+ const scope = `initiative:${c.proposal.target.id}`;
140
+ if (c.proposal.what === 'move') {
141
+ const latestIssue = (key) => { const x = store.get(`${scope}|${key}`); return x && x.issued ? Date.parse(x.issued.at) : null; };
142
+ const held = D.moveHeld(state, c.proposal.target, { now, issuedAt: latestIssue });
143
+ if (held) return fail(`a stage move waits until the current stage's documents are kept (${(held.docs || []).join(', ') || held.text}). Draft them with pmos coredoc write and file a doc proposal first`);
144
+ } else {
145
+ const x = store.get(`${scope}|${c.proposal.data.key}`);
146
+ if (!x || !x.draft || x.draft.slot !== c.proposal.data.slot) return fail(`no open draft ${c.proposal.data.slot} of ${c.proposal.data.key} for initiative ${c.proposal.target.id}: write it first with pmos coredoc write --scope ${scope} --key ${c.proposal.data.key}`);
147
+ }
148
+ }
135
149
  const dup = [...state.proposals.values()].find((p) => D.targetKey(p.target) === D.targetKey(c.proposal.target) && p.what === c.proposal.what && JSON.stringify(p.data) === same);
136
150
  if (dup) return done({ filed: false, duplicate: dup.id, state: dup.state }, `${dup.id} holds this already (${dup.state}); nothing new was filed`);
137
151
  const token = actorLib.isAgent(actor) && actor.by !== 'agent:unknown' ? actor.by.slice('agent:'.length) : null;
@@ -175,7 +189,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
175
189
  if (!file) return noState();
176
190
  const p = D.fold(D.readLines(file)).proposals.get(pid);
177
191
  if (p && verb === 'keep' && p.what === 'move') return fail('keep a move on the workspace page: the board move route checks the stage and its warnings');
178
- const ctx = { stageFile: path.join(stateDir, stageTools.LEDGER), itemsDir: path.join(stateRoot.defaultRoot(), 'pm-items'),
192
+ const ctx = { stageFile: path.join(stateDir, stageTools.LEDGER), itemsDir: path.join(stateRoot.defaultRoot(), 'pm-items'), root,
179
193
  correctionsFile: path.join(stateRoot.defaultRoot(), 'corrections.jsonl') };
180
194
  const r = await decideLib.decide(verb, { file, proposal: pid, note: flags.note, actor, ctx, now });
181
195
  if (!r.ok) return fail(r.error);
@@ -4,7 +4,7 @@
4
4
  * a page press cannot differ.
5
5
  *
6
6
  * decide(verb, { file, proposal, note, actor, ctx, now }) → Promise of { ok, proposal, wrote } | { ok: false, status, error }
7
- * verb: keep | return | drop. ctx: { stageFile, itemsDir, move(proposal) → { status, body }, correctionsFile? }
7
+ * verb: keep | return | drop. ctx: { stageFile, itemsDir, move(proposal) → { status, body }, correctionsFile?, root? }
8
8
  * setPause(file, verb, target, actor, { now }) verb: pause | resume
9
9
  * setMode(file, board, mode, actor, { now }) mode: off | shadow | approve; board '*' is every board
10
10
  *
@@ -15,6 +15,7 @@
15
15
  * problem → lib/stage-tools.js appendTool, tool `problem`, `by` the PM
16
16
  * move → the board move route's own function (lib/routes-boards.js move): its validation and warnings apply
17
17
  * next-step → lib/pm-items.js appendEvent: a step (lib/item-steps.js) and, when the proposal names a date, an `edited` due
18
+ * doc → lib/core-docs.js issue: the draft the run wrote is issued (one `issue` line, `state: issued` in its file), as the PM
18
19
  * Every other kind (evidence, sizing, assumption, stakeholders, follow-up, note) is kept as a decision only: the PM's choice to
19
20
  * rely on it. A follow-up is never sent: the PM sends it from their own session. The driver never moves a stage by itself (D3).
20
21
  * RETURN needs a note and DROP may carry one; each logs one correction (the north star counts it) and a return wakes a run.
@@ -46,6 +47,12 @@ async function writeRecord(p, actor, ctx, now) {
46
47
  if (!r || !r.body || r.body.ok !== true) return refuse((r && r.status) || 500, (r && r.body && r.body.error) || 'the move was refused');
47
48
  return { ok: true, wrote: 'move', warnings: r.body.warnings || [] };
48
49
  }
50
+ if (p.what === 'doc') {
51
+ if (!ctx.root) return refuse(409, 'this repository has no folder for the core documents');
52
+ const r = require('./core-docs.js').issue(ctx.root, { scope: `initiative:${p.target.id}`, key: p.data && p.data.key, slot: p.data && p.data.slot }, actor, { now });
53
+ if (!r.ok) return refuse(r.status || 409, r.error);
54
+ return { ok: true, wrote: 'doc' };
55
+ }
49
56
  if (p.what === 'next-step') {
50
57
  if (!ctx.itemsDir) return refuse(409, 'no folder for the Work items');
51
58
  const paths = itemsPaths(ctx.itemsDir);
@@ -13,6 +13,7 @@
13
13
  * LIMITS, NAMED. The Jira readings keep no status-change time, so `last_ticket_move` is the latest resolved date of the
14
14
  * initiative's tickets (null when none: the Do rule then stays quiet instead of guessing). `has_evidence` counts kept driver
15
15
  * evidence only. `has_done_criteria` is a PRD that names done-criteria, acceptance criteria or a rubric, or a rubric file.
16
+ * `docs_due` and `docs_stale` (core-documents) are the current stage's core documents that are due or stale (lib/core-docs.js driverLists).
16
17
  */
17
18
  const fs = require('fs');
18
19
  const path = require('path');
@@ -43,7 +44,7 @@ function outcomeChecked(stateDir, id) {
43
44
  } catch { return false; }
44
45
  }
45
46
 
46
- function initiativeFacts(w, ctx, { driverState, stateDir }) {
47
+ function initiativeFacts(w, ctx, { driverState, stateDir, now = Date.now() }) {
47
48
  const boards = require('./routes-boards.js');
48
49
  const got = boards.baseRows(w);
49
50
  if (got.pending) return { pending: true };
@@ -57,6 +58,10 @@ function initiativeFacts(w, ctx, { driverState, stateDir }) {
57
58
  const keptEvidence = new Set();
58
59
  for (const p of driverState.proposals.values()) if (p.state === 'kept' && p.what === 'evidence' && p.target.kind === 'initiative') keptEvidence.add(p.target.id);
59
60
  const out = [];
61
+ // core-documents: each active initiative's core set with its state, read once for the sweep (lib/core-docs.js). A set that cannot be
62
+ // read gives empty lists: the doc rules then stay quiet instead of guessing.
63
+ let docSets = new Map();
64
+ try { docSets = require('./core-docs.js').initiativeDocs(ctx, w, { now, fr, rows: got.rows.filter((b) => !(b.status === 'done' || b.lifecycle || b.dormant)) }); } catch { docSets = new Map(); }
60
65
  for (const base of got.rows) {
61
66
  if (base.status === 'done' || base.lifecycle || base.dormant) continue;
62
67
  const boardId = boards.boardIdOf(base, fr.boards, fr.folded, w);
@@ -78,6 +83,7 @@ function initiativeFacts(w, ctx, { driverState, stateDir }) {
78
83
  check_by: placed && placed.ok && placed.byInit[base.id] ? placed.byInit[base.id].check_by || null : null,
79
84
  outcome_checked: outcomeChecked(stateDir, base.id),
80
85
  kill_tripped: !!(rec.kill && rec.kill.data && (rec.kill.data.items || []).some((i) => i.status === 'tripped')),
86
+ ...(() => { const set = docSets.get(base.id); if (!set) return { docs_due: [], docs_stale: [] }; const l = require('./core-docs.js').driverLists(set); return { docs_due: l.due, docs_stale: l.stale }; })(),
81
87
  });
82
88
  }
83
89
  return { ok: true, list: out };
@@ -100,7 +106,7 @@ function gather(w, ctx, { now = Date.now(), includeWork = false, runtimeDir, dri
100
106
  const stateDir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
101
107
  if (!stateDir) return { ok: false, error: 'no state folder' };
102
108
  const facts = { day: D.localDay(now), now, initiatives: [], work: [] };
103
- const ini = initiativeFacts(w, ctx, { driverState: driverState || D.fold([]), stateDir });
109
+ const ini = initiativeFacts(w, ctx, { driverState: driverState || D.fold([]), stateDir, now });
104
110
  if (ini.pending) return { pending: true };
105
111
  if (ini.ok === false) return ini;
106
112
  facts.initiatives = ini.list;
@@ -64,6 +64,12 @@ function create(deps) {
64
64
  if (facts1 && target.kind === 'initiative') {
65
65
  stage = facts1.cat;
66
66
  lines.push(`Stage category: ${stage || 'unknown'}. Kept problem statement: ${facts1.has_problem ? 'yes' : 'no'}. Kept evidence: ${facts1.has_evidence ? 'yes' : 'no'}. PRD: ${facts1.has_prd ? 'yes' : 'no'}. Anchor: ${facts1.has_anchor ? 'yes' : 'no'}.`);
67
+ // core-documents: the current stage's documents. A move waits for them (pmos driver propose refuses it).
68
+ const due = facts1.docs_due || []; const stale = facts1.docs_stale || [];
69
+ if (due.length || stale.length) {
70
+ lines.push(`Core documents of this stage: due ${due.join(', ') || 'none'}; stale ${stale.join(', ') || 'none'}. Draft each one with \`pmos coredoc write --scope initiative:${target.id} --key <key> --file -\` (the document on standard input), then file \`--what doc\` with data {"key": "<key>", "slot": "<the slot it printed>"}. The PM's Keep issues it.`);
71
+ if (due.length) lines.push('Do not propose a stage move while a document of this stage is due: `pmos driver propose` refuses it until the PM keeps the documents.');
72
+ }
67
73
  }
68
74
  if (facts1 && target.kind === 'work') {
69
75
  lines.push(`Placement: ${facts1.placement}. Due: ${facts1.due || 'none'}. Waiting on: ${facts1.waiting_on || 'nobody'}${facts1.waiting_since ? ` since ${String(facts1.waiting_since).slice(0, 10)}` : ''}. Steps: ${facts1.steps}.`);
@@ -24,6 +24,7 @@
24
24
  *
25
25
  * THE LINES. Every line is { v: 1, ev, …, at }. Who wrote a decision is the call path (lib/actor.js), never a field:
26
26
  * proposal { id, target: { kind, ws, id }, what, data, why, sources: [{ label, ref }], run, by } by an agent session
27
+ * what `doc` (core-documents): data { key, slot } names a core document draft the run wrote; Keep issues it
27
28
  * keep|return|drop { proposal, note?, by } PM only: the fold ignores one whose `by` is not pm:*
28
29
  * correction { proposal, run, kind: 'return'|'drop', by } one per return or drop; the north star counts it
29
30
  * pause|resume { target, by } · mode { board, mode, by } PM only, as above. `board` '*' is every board.
@@ -48,7 +49,7 @@ const crypto = require('crypto');
48
49
  const LEDGER = 'driver.jsonl';
49
50
  const KINDS = Object.freeze(['initiative', 'work']);
50
51
  const WHATS = Object.freeze({
51
- initiative: Object.freeze(['problem', 'evidence', 'sizing', 'assumption', 'stakeholders', 'move', 'note']),
52
+ initiative: Object.freeze(['problem', 'evidence', 'sizing', 'assumption', 'stakeholders', 'move', 'note', 'doc']),
52
53
  work: Object.freeze(['next-step', 'follow-up', 'note']),
53
54
  });
54
55
  // A claim without a pointer is not a proposal (PRD section 2: "Nothing without a pointer").
@@ -69,6 +70,7 @@ const WS_RE = /^[A-Za-z0-9._-]{1,80}$/;
69
70
  const PROPOSAL_RE = /^p-[0-9a-f]{10}$/;
70
71
  const RUN_RE = /^run-[0-9a-f]{10}$/;
71
72
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
73
+ const SLOT_RE = /^\d{4}-\d{2}-\d{2}(-[2-9]|-[1-9]\d)?$/; // a core document's slot (lib/core-docs.js)
72
74
  const MAX_DATA = 8000;
73
75
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
74
76
  const oneLine = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
@@ -163,6 +165,13 @@ function checkProposal(input) {
163
165
  } else if (i.what === 'move') {
164
166
  if (!oneLine(data.to, 120)) return { ok: false, error: 'a move needs data.to: the name of the stage' };
165
167
  data = { to: data.to.trim(), ...(oneLine(data.board, 120) ? { board: data.board.trim() } : {}) };
168
+ } else if (i.what === 'doc') {
169
+ // core-documents: a draft the run wrote with `pmos coredoc write`. Keep issues it (lib/driver-decide.js).
170
+ const CD = require('./core-docs.js');
171
+ const e = CD.entryOf('initiative', data.key);
172
+ if (!e || !CD.draftable(e)) return { ok: false, error: `a doc proposal needs data.key: one of ${CD.SETS.initiative.filter(CD.draftable).map((x) => x.key).join(', ')}` };
173
+ if (typeof data.slot !== 'string' || !SLOT_RE.test(data.slot)) return { ok: false, error: 'a doc proposal needs data.slot: the slot `pmos coredoc write` printed (YYYY-MM-DD, or YYYY-MM-DD-2)' };
174
+ data = { key: data.key, slot: data.slot };
166
175
  } else if (i.what === 'next-step') {
167
176
  if (!oneLine(data.text, 500)) return { ok: false, error: 'a next step needs data.text: one line of up to 500 characters' };
168
177
  if (data.due != null && !(typeof data.due === 'string' && DATE_RE.test(data.due))) return { ok: false, error: 'data.due is YYYY-MM-DD' };
@@ -282,7 +291,9 @@ function plan(state, { now, running = 0, activeTargets = [] } = {}) {
282
291
  * facts.initiatives[] { ws, board, id, title, cat: Discover|Plan|Do|Learn, active, last_activity (ms|null),
283
292
  * has_problem, has_evidence, has_prd, has_done_criteria, has_anchor,
284
293
  * has_tickets, last_ticket_move (ms|null), blocked_tickets: [key], check_by ('YYYY-MM-DD'|null),
285
- * outcome_checked, kill_tripped }
294
+ * outcome_checked, kill_tripped, docs_due: [key], docs_stale: [key] }
295
+ * docs_due / docs_stale (core-documents): the current stage's core documents (lib/core-docs.js driverLists) that are
296
+ * due or not started, and that are stale. They raise doc-due and doc-stale; a run drafts them and files a `doc` proposal.
286
297
  * facts.work[] { ws, id, title, done, placement: inbox|today|upcoming|waiting|someday, due, waiting_on, waiting_since (ISO|null),
287
298
  * touched_at (ISO|null), untouched (bool: the Review queue holds it), steps (count) }
288
299
  * A blocker is { key, target, board, title, code, owner: 'pm'|'agent', cat, text }. */
@@ -316,6 +327,9 @@ function detect(facts, state, { now, stalledDays } = {}) {
316
327
  if (i.check_by && i.check_by < day && !i.outcome_checked) b('check-by-passed', 'agent', 'Learn', `The check-by date ${i.check_by} passed with no outcome check`);
317
328
  if (i.kill_tripped) b('kill-tripped', 'agent', 'Learn', 'A kill condition tripped');
318
329
  }
330
+ // core-documents: a current-stage document that is due or stale. While doc-due is raised, a stage move is not filed (lib/driver-cli.js).
331
+ if (Array.isArray(i.docs_due) && i.docs_due.length) { b('doc-due', 'agent', i.cat || null, `Documents due at this stage: ${i.docs_due.join(', ')}`); out[out.length - 1].docs = i.docs_due.slice(); }
332
+ if (Array.isArray(i.docs_stale) && i.docs_stale.length) b('doc-stale', 'agent', i.cat || null, `Stale since the stage move: ${i.docs_stale.join(', ')}`);
319
333
  if (i.last_activity != null && t - i.last_activity >= stall) b('stalled', 'agent', null, `Nothing changed for ${Math.floor((t - i.last_activity) / DAY)} days`);
320
334
  }
321
335
  for (const w of facts.work || []) {
@@ -346,7 +360,7 @@ function sweepLines(facts, state, { now, stalledDays } = {}) {
346
360
  const lines = [];
347
361
  for (const b of blockers) {
348
362
  if (state.blockerDays.has(`${b.key}|${day}`)) continue;
349
- lines.push({ ev: 'blocker', key: b.key, day, target: b.target, board: b.board, code: b.code, owner: b.owner, text: b.text });
363
+ lines.push({ ev: 'blocker', key: b.key, day, target: b.target, board: b.board, code: b.code, owner: b.owner, text: b.text, ...(b.docs ? { docs: b.docs } : {}) });
350
364
  if (b.owner === 'agent' && !pmBlocked.has(targetKey(b.target))) {
351
365
  const key = `stuck:${b.key}:${day}`;
352
366
  if (!state.wakeKeys.has(key)) lines.push({ ev: 'wake', key, target: b.target, board: b.board, reason: 'stuck', blocker: b.code });
@@ -387,6 +401,20 @@ function answeredWakes(state, { boardOf = () => null } = {}) {
387
401
  return lines;
388
402
  }
389
403
 
404
+ /** core-documents: is a stage move held? The doc-due blocker of today's sweep for the target while one of the documents it names
405
+ * has not been issued since. `issuedAt(key)` is the ms of the latest `issue` of that initiative document (lib/core-docs.js), or null. */
406
+ function moveHeld(state, target, { now, issuedAt = () => null } = {}) {
407
+ const day = localDay(now == null ? Date.now() : now);
408
+ const key = targetKey(target);
409
+ const b = state.blockers.filter((x) => x.code === 'doc-due' && x.day === day && x.target && targetKey(x.target) === key).pop();
410
+ if (!b) return null;
411
+ const since = Date.parse(b.at);
412
+ const docs = Array.isArray(b.docs) && b.docs.length ? b.docs : null;
413
+ if (!docs) return b;
414
+ const open = docs.filter((k) => { const t = issuedAt(k); return !(t != null && Number.isFinite(since) && t > since); });
415
+ return open.length ? { ...b, docs: open } : null;
416
+ }
417
+
390
418
  /** The run that was alive for an agent session token (its session id part), or null. */
391
419
  function runOfSession(state, token) {
392
420
  if (typeof token !== 'string') return null;
@@ -396,5 +424,5 @@ function runOfSession(state, token) {
396
424
  module.exports = {
397
425
  LEDGER, KINDS, WHATS, NEEDS_SOURCE, MODES, DEFAULT_MODE, WAKES, EVENTS, BUDGET, DAY, PM_WAIT_MS, SWEEP_EVERY_MS, DEFAULT_STALLED_DAYS, ID_RE, WS_RE, PROPOSAL_RE, RUN_RE,
398
426
  stateDirOf, ledgerFile, append, readLines, newId, checkTarget, checkProposal, fold, modeOf, isPaused, openProposals, runsOnDay, admit, plan,
399
- detect, sweepLines, wakesFromLedgers, answeredWakes, runOfSession, localDay, targetKey, wsIdOf,
427
+ detect, sweepLines, wakesFromLedgers, answeredWakes, runOfSession, moveHeld, localDay, targetKey, wsIdOf,
400
428
  };
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * streamLines(line) → { log: [lines], result: object|null } one stream-json event, for the live log
@@ -40,6 +40,10 @@
40
40
  * no Write, no Edit, no web fetch and no tool that sends or posts, and its only write is `pmos report propose`. A connector tool is named in neither
41
41
  * list: a connector read runs only where the PM's own Claude Code settings already allow it, and a connector write is denied and reported. A proposal
42
42
  * is data; only the PM's press on the Reports page sets a report up. Neither kind ever runs in a pane.
43
+ *
44
+ * The `core-doc` kind drafts ONE core document (initiative core-documents): origin `core-doc:<scope>:<key>`. It reads and searches the repository,
45
+ * and its only write is `pmos coredoc write`, which writes a draft and nothing else (the server pins the scope and key in the run's environment).
46
+ * No Write, no Edit, no web, nothing that sends. The draft waits for the PM, who issues it. It never runs in a pane.
43
47
  */
44
48
 
45
49
  // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
@@ -68,14 +72,17 @@ const WEB_KINDS = Object.freeze(['scan-web', 'scan-research-web']); // kinds t
68
72
  // report-workspace: the Ask box run (origin `report-setup:<ask>`) and the weekly finder run (origin `report-find:<run>`).
69
73
  const REPORT_SETUP_ORIGINS = Object.freeze(['report-setup:']);
70
74
  const REPORT_FIND_ORIGINS = Object.freeze(['report-find:']);
75
+ // core-documents: one run drafts one core document of a portfolio, a board or an initiative (origin `core-doc:<scope>:<key>`).
76
+ const CORE_DOC_ORIGINS = Object.freeze(['core-doc:']);
71
77
 
72
78
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
73
79
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
74
80
  const TOOLS = Object.freeze({
75
81
  notes: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(mv *)']),
76
82
  ask: Object.freeze(['Read', 'Glob', 'Grep']),
83
+ // core-documents: the driver drafts a current-stage document that is due with `pmos coredoc write` (a draft only; Keep issues it).
77
84
  drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
78
- 'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
85
+ 'Bash(pmos coredoc write *)', 'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
79
86
  // The web run: WebSearch, WebFetch and the one verb that files a finding. No Read, no Glob, no Grep, no repository path in its prompt or its add-dirs.
80
87
  // `pmos scan section` writes a section of a report document; the web run gives each web claim a URL it read.
81
88
  'scan-web': Object.freeze(['WebSearch', 'WebFetch', 'Bash(pmos scan file *)', 'Bash(pmos scan section *)']),
@@ -92,6 +99,8 @@ const TOOLS = Object.freeze({
92
99
  // The finder run: read and search, and the one verb that files a finding. Calendar, Slack, mail subjects and Drive are read through the PM's own
93
100
  // connectors where the PM's settings allow it. No web fetch, no git, no Write or Edit, nothing that sends or posts.
94
101
  'report-find': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos report propose *)']),
102
+ // The core-doc run: read and search, and the one verb that writes a draft. No Write, no Edit, no web, nothing that sends.
103
+ 'core-doc': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos coredoc write *)']),
95
104
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
96
105
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
97
106
  });
@@ -114,6 +123,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
114
123
  if (SCAN_RESEARCH_REPO_ORIGINS.some((p) => o.startsWith(p))) return 'scan-research-repo';
115
124
  if (REPORT_SETUP_ORIGINS.some((p) => o.startsWith(p))) return 'report-setup';
116
125
  if (REPORT_FIND_ORIGINS.some((p) => o.startsWith(p))) return 'report-find';
126
+ if (CORE_DOC_ORIGINS.some((p) => o.startsWith(p))) return 'core-doc';
117
127
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
118
128
  return null;
119
129
  }
@@ -232,4 +242,4 @@ function logLines(r, exitCode) {
232
242
  return out;
233
243
  }
234
244
 
235
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
245
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
@@ -34,6 +34,7 @@ const PROVIDERS = Object.freeze([
34
34
  'work-check-needs', // Run 4 (4D): a person's item in Review, a batched Release (kind 4)
35
35
  'report-needs', // Run 5 (5C): a report to issue (kind 1), cycle rows (kind 7), a schedule left no output (kind 8)
36
36
  'driver-needs', // initiative-driver: drafts the agent filed (kind 7), a target the PM is blocking (kind 7)
37
+ 'core-docs-needs', // core-documents: one row per scope with core document drafts waiting for the PM (kind 1)
37
38
  ]);
38
39
  /** Kinds that already mean something in the needs queue. A provider may not reuse one. */
39
40
  const RESERVED_KINDS = Object.freeze(['question', 'accept', 'outcome', 'keep', 'evidence', 'plan-flag', 'provider-error']);
@@ -62,6 +62,9 @@ const VERBS = Object.freeze({
62
62
  // Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table; horizon-scan-portfolio added section, propose, setting and --boards.
63
63
  scan: Object.freeze({ module: 'scan-cli.js', run: null, guide: 'horizon-scan.md',
64
64
  help: 'pmos scan file|section|propose|list [--sections]|match|suggest [--boards]|status|setting|cadence|watch [--boards] … — the Horizon scan: the portfolio watch list, the findings, the report sections and the proposals the PM approves, changes or dismisses (pmos scan help)' }),
65
+ // Not a pm-os-absorb verb (run: null): initiative core-documents added it through the same table.
66
+ coredoc: Object.freeze({ module: 'coredoc-cli.js', run: null, guide: 'core-documents.md',
67
+ help: 'pmos coredoc write --scope <scope> --key <key> --file <path|-> — write one draft of a core document; the PM issues it on the page (pmos coredoc help)' }),
65
68
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
66
69
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
67
70
  });
@@ -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