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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/okf/core/concepts/frontmatter-format.md +3 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-core-docs-test.js +152 -0
- package/workspace/guide/console-calendar.md +1 -1
- package/workspace/guide/core-documents.md +49 -0
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/initiative-boards.md +14 -0
- package/workspace/lib/core-docs-needs.js +86 -0
- package/workspace/lib/core-docs.js +675 -0
- package/workspace/lib/coredoc-cli.js +90 -0
- package/workspace/lib/doc-category.js +1 -1
- package/workspace/lib/driver-cli.js +16 -2
- package/workspace/lib/driver-decide.js +8 -1
- package/workspace/lib/driver-facts.js +8 -2
- package/workspace/lib/driver-run.js +6 -0
- package/workspace/lib/driver.js +32 -4
- package/workspace/lib/headless.js +13 -3
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-core-docs.js +171 -0
- package/workspace/lib/routes-driver.js +1 -0
- package/workspace/lib/routes-meeting-focus.js +23 -5
- package/workspace/lib/vendor/scripts/migrate-layout.js +1 -1
- package/workspace/lib/vendor/workspace/lib/doc-category.js +1 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-brief-needs.js +3 -0
- package/workspace/public/pm/c3-core-docs.css +18 -0
- package/workspace/public/pm/c3-core-docs.js +234 -0
- package/workspace/public/pm/c3-demo-coredocs.js +211 -0
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-driver.js +6 -4
- package/workspace/public/pm/c3-focus.js +6 -4
- package/workspace/public/pm/c3-ib-docs.js +24 -14
- package/workspace/public/pm/c3-ii-tabs.js +15 -8
- package/workspace/public/pm/c3-initiatives.js +29 -2
- package/workspace/public/pm/c3-shell.js +1 -1
- package/workspace/public/pm/shell-recording.js +2 -3
- package/workspace/server.js +15 -2
- package/workspace/skills/initiative-drive.skill +6 -2
- package/workspace/test-lists/c3-core-docs.json +9 -0
- 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}.`);
|
package/workspace/lib/driver.js
CHANGED
|
@@ -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');
|