create-pmos 0.166.0 → 0.168.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 (52) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/extension/README.md +33 -0
  7. package/workspace/extension/address.js +20 -0
  8. package/workspace/extension/content.js +76 -0
  9. package/workspace/extension/icons/icon-128.png +0 -0
  10. package/workspace/extension/icons/icon-16.png +0 -0
  11. package/workspace/extension/icons/icon-32.png +0 -0
  12. package/workspace/extension/icons/icon-48.png +0 -0
  13. package/workspace/extension/manifest.json +15 -0
  14. package/workspace/extension/options.css +33 -0
  15. package/workspace/extension/options.html +26 -0
  16. package/workspace/extension/options.js +24 -0
  17. package/workspace/extension/panel.css +21 -0
  18. package/workspace/extension/panel.html +14 -0
  19. package/workspace/extension/panel.js +67 -0
  20. package/workspace/guide/console-calendar.md +14 -0
  21. package/workspace/guide/index.md +1 -0
  22. package/workspace/guide/initiative-driver.md +79 -0
  23. package/workspace/guide/validate-with-typesafe.md +1 -1
  24. package/workspace/lib/driver-cli.js +207 -0
  25. package/workspace/lib/driver-decide.js +118 -0
  26. package/workspace/lib/driver-facts.js +113 -0
  27. package/workspace/lib/driver-needs.js +107 -0
  28. package/workspace/lib/driver-run.js +187 -0
  29. package/workspace/lib/driver.js +408 -0
  30. package/workspace/lib/extension-cli.js +39 -0
  31. package/workspace/lib/headless.js +13 -2
  32. package/workspace/lib/meeting-focus.js +48 -1
  33. package/workspace/lib/needs-providers.js +1 -0
  34. package/workspace/lib/pmos-verbs.js +6 -0
  35. package/workspace/lib/route-modules.js +1 -0
  36. package/workspace/lib/routes-boards.js +11 -1
  37. package/workspace/lib/routes-driver.js +135 -0
  38. package/workspace/lib/routes-meeting-focus.js +17 -1
  39. package/workspace/lib/typesafe-settings.js +15 -1
  40. package/workspace/package-lock.json +2 -2
  41. package/workspace/package.json +1 -1
  42. package/workspace/public/pm/c3-demo-focus.js +12 -2
  43. package/workspace/public/pm/c3-demo.js +1 -1
  44. package/workspace/public/pm/c3-focus.css +31 -0
  45. package/workspace/public/pm/c3-focus.js +122 -21
  46. package/workspace/public/pm/c3-settings.js +12 -2
  47. package/workspace/public/pm/c3-shell.js +1 -1
  48. package/workspace/server.js +32 -1
  49. package/workspace/skills/initiative-drive.skill +50 -0
  50. package/workspace/skills/work-unblock.skill +42 -0
  51. package/workspace/test-files.json +4 -0
  52. package/workspace/test-lists/c3-meeting-focus.json +13 -4
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+ /* driver-cli.js — `pmos driver …`: the Initiative Driver from a terminal (initiative initiative-driver, PRD sections 4 and 5).
3
+ *
4
+ * pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>"
5
+ * [--source label=ref …] [--ws <id>] an agent session files a proposal (anyone may; the PM seldom does)
6
+ * pmos driver status <initiative|work> <id> proposals, pause, mode and the day's runs (read)
7
+ * pmos driver keep <proposal> PM only: type the proposal id to confirm
8
+ * pmos driver return <proposal> --note "<what to change>" PM only: send back; the note wakes a run
9
+ * pmos driver drop <proposal> [--note "<why>"] PM only
10
+ * pmos driver pause|resume <initiative|work> <id> PM only
11
+ * pmos driver mode <off|shadow|approve> [--board <id>] PM only; with no --board it sets every board
12
+ * every command: [--json] [--root <dir>]
13
+ *
14
+ * Who wrote a line is the call path (lib/actor.js), never an argument. `keep` goes through lib/pm-guard.js requirePmCli, which
15
+ * refuses an agent session without asking; `return`, `drop`, `pause`, `resume` and `mode` refuse any actor that is not the PM
16
+ * at an interactive terminal. The agent-facing verbs are `propose` and `status` only. Keep writes through the existing writers
17
+ * (lib/driver-decide.js); a kept move is pressed on the workspace page, where the board move route runs.
18
+ * Exit codes: 0 done, 1 refused, 2 usage.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { execFileSync } = require('child_process');
23
+ const guard = require('./pm-guard.js');
24
+ const actorLib = require('./actor.js');
25
+ const D = require('./driver.js');
26
+ const decideLib = require('./driver-decide.js');
27
+ const stageTools = require('./stage-tools.js');
28
+ const stateRoot = require('./state-root.js');
29
+
30
+ const COMMANDS = { driver: ['propose', 'status', 'keep', 'return', 'drop', 'pause', 'resume', 'mode'] };
31
+ const USAGE = [
32
+ 'usage: pmos driver propose|status|keep|return|drop|pause|resume|mode — the Initiative Driver',
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)',
35
+ ' pmos driver status <initiative|work> <id>',
36
+ ' pmos driver keep <proposal> PM only',
37
+ ' pmos driver return <proposal> --note "<what to change>" PM only',
38
+ ' pmos driver drop <proposal> [--note "<why>"] PM only',
39
+ ' pmos driver pause|resume <initiative|work> <id> PM only',
40
+ ' pmos driver mode <off|shadow|approve> [--board <id>] PM only',
41
+ ' every command: [--json] [--root <dir>]',
42
+ 'Every claim in a proposal carries a pointer (--source label=ref). The driver sends nothing and edits no record: Keep is the PM\'s.',
43
+ ];
44
+ const BOOL = new Set(['json', 'help']);
45
+ const VALUE = new Set(['what', 'file', 'data', 'why', 'source', 'ws', 'note', 'board', 'root']);
46
+
47
+ function parse(argv) {
48
+ const pos = []; const flags = {}; const sources = [];
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const a = argv[i];
51
+ if (a === '-h') { flags.help = true; continue; }
52
+ if (!a.startsWith('--')) { pos.push(a); continue; }
53
+ const eq = a.indexOf('=');
54
+ const key = eq > 0 ? a.slice(2, eq) : a.slice(2);
55
+ if (BOOL.has(key)) { flags[key] = true; continue; }
56
+ let val;
57
+ if (eq > 0) val = a.slice(eq + 1);
58
+ else { const next = argv[i + 1]; if (next === undefined || (next.startsWith('--') && next.length > 2)) val = ''; else { val = next; i++; } }
59
+ if (key === 'source') sources.push(val); else flags[key] = val;
60
+ }
61
+ return { pos, flags, sources };
62
+ }
63
+ function repoRoot(cwd) {
64
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
65
+ catch { return cwd; }
66
+ }
67
+
68
+ /** The data of a proposal from --file (JSON by extension or by content, else the text) or --data. */
69
+ function dataFrom(flags, cwd) {
70
+ let raw;
71
+ if (flags.file) {
72
+ try { raw = fs.readFileSync(path.resolve(cwd, flags.file), 'utf8'); } catch (e) { return { error: `cannot read ${flags.file}: ${String(e.code || e.message)}` }; }
73
+ } else raw = flags.data;
74
+ if (typeof raw !== 'string' || !raw.trim()) return { error: 'give the proposal body with --file <json|md> or --data "<text>"' };
75
+ const t = raw.trim();
76
+ if (t.startsWith('{') || (flags.file && /\.json$/i.test(flags.file))) {
77
+ try { const j = JSON.parse(t); if (j && typeof j === 'object' && !Array.isArray(j)) return { data: j }; } catch { if (/\.json$/i.test(flags.file || '')) return { error: `${flags.file} is not valid JSON` }; }
78
+ }
79
+ return { data: { text: t } };
80
+ }
81
+ function sourcesFrom(list) {
82
+ const out = [];
83
+ for (const s of list) {
84
+ const eq = s.indexOf('=');
85
+ if (eq < 1 || eq === s.length - 1) return { error: `--source needs label=ref, not "${s.slice(0, 60)}"` };
86
+ out.push({ label: s.slice(0, eq).trim(), ref: s.slice(eq + 1).trim() });
87
+ }
88
+ return { sources: out };
89
+ }
90
+
91
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, now, ask } = {}) {
92
+ const args = argv[0] === 'driver' ? argv.slice(1) : argv.slice();
93
+ const verb = args[0];
94
+ const { pos, flags, sources } = parse(args.slice(1));
95
+ const json = !!flags.json;
96
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h' || flags.help) { for (const l of USAGE) say(l); return verb ? 0 : 1; }
97
+ if (!COMMANDS.driver.includes(verb)) { say(`unknown driver command "${verb}" — one of ${COMMANDS.driver.join(', ')}`); return 2; }
98
+ 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; }
99
+ for (const k of Object.keys(flags)) {
100
+ if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
101
+ if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
102
+ }
103
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
104
+ const who = { isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env: env === undefined ? process.env : env };
105
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
106
+ const done = (obj, line) => { if (json) say(JSON.stringify({ ok: true, ...obj })); else say(line); return 0; };
107
+ const stateDir = D.stateDirOf(root);
108
+ const file = D.ledgerFile(stateDir);
109
+ const noState = () => fail('this repository has no PMOS state folder (pmos/state, state or .pmos), so the driver has nowhere to keep its ledger');
110
+ const ws = flags.ws || D.wsIdOf(root);
111
+ const target = () => ({ kind: pos[0], ws, id: pos[1] });
112
+ const needTarget = () => {
113
+ if (!pos[0] || !pos[1]) { say(`usage: pmos driver ${verb} <initiative|work> <id>`); return false; }
114
+ return true;
115
+ };
116
+ const pmOnly = (act) => {
117
+ const a = actorLib.fromCli(who);
118
+ if (!actorLib.isPm(a)) return { ok: false, error: `only the PM can ${act}, at an interactive terminal${actorLib.isAgent(a) && a.by !== 'agent:unknown' ? '; an agent session cannot' : ''}` };
119
+ return { ok: true, actor: a };
120
+ };
121
+
122
+ if (verb === 'propose') {
123
+ if (!needTarget()) return 2;
124
+ if (!file) return noState();
125
+ if (!flags.what) { say('usage: pmos driver propose <initiative|work> <id> --what <what> (--file <json|md> | --data "<text>") --why "<one line>"'); return 2; }
126
+ const d = dataFrom(flags, cwd);
127
+ if (d.error) return fail(d.error, 2);
128
+ const s = sourcesFrom(sources);
129
+ if (s.error) return fail(s.error, 2);
130
+ const c = D.checkProposal({ ...target(), what: flags.what, data: d.data, why: flags.why, sources: s.sources });
131
+ if (!c.ok) return fail(c.error);
132
+ const actor = actorLib.fromCli(who);
133
+ const state = D.fold(D.readLines(file));
134
+ const same = JSON.stringify(c.proposal.data);
135
+ 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
+ if (dup) return done({ filed: false, duplicate: dup.id, state: dup.state }, `${dup.id} holds this already (${dup.state}); nothing new was filed`);
137
+ const token = actorLib.isAgent(actor) && actor.by !== 'agent:unknown' ? actor.by.slice('agent:'.length) : null;
138
+ const id = D.newId('p');
139
+ const line = D.append(file, { ev: 'proposal', id, ...c.proposal, run: D.runOfSession(state, token), by: actor.by }, { now });
140
+ return done({ filed: true, id, proposal: { ...line, by: actorLib.pageName(line.by) } }, `✓ filed ${id}: ${c.proposal.what} for ${c.proposal.target.kind} ${c.proposal.target.id}. The PM keeps, sends back or drops it.`);
141
+ }
142
+
143
+ if (verb === 'status') {
144
+ if (!needTarget()) return 2;
145
+ if (!file) return noState();
146
+ const t = D.checkTarget(target());
147
+ if (!t.ok) return fail(t.error);
148
+ const state = D.fold(D.readLines(file));
149
+ const mine = [...state.proposals.values()].filter((p) => D.targetKey(p.target) === D.targetKey(t.target));
150
+ const day = D.localDay(now == null ? Date.now() : now);
151
+ const used = D.runsOnDay(state, t.target, day);
152
+ const limit = t.target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay;
153
+ const out = { target: t.target, paused: D.isPaused(state, t.target), mode: D.modeOf(state, t.target.kind === 'work' ? 'work' : null), runs_today: used, runs_limit: limit,
154
+ proposals: mine.map((p) => ({ id: p.id, what: p.what, state: p.state, why: p.why, note: p.note, at: p.at })) };
155
+ if (json) { say(JSON.stringify({ ok: true, ...out })); return 0; }
156
+ say(`${t.target.kind} ${t.target.id} · mode ${out.mode}${out.paused ? ' · paused' : ''} · ${used} of ${limit} runs today`);
157
+ for (const p of out.proposals) say(` ${p.id} · ${p.what} · ${p.state}${p.note ? ` · "${p.note}"` : ''} — ${p.why}`);
158
+ if (!out.proposals.length) say(' no proposals');
159
+ return 0;
160
+ }
161
+
162
+ // ── the PM's verbs ────────────────────────────────────────────────────────────────────────────────
163
+ if (verb === 'keep' || verb === 'return' || verb === 'drop') {
164
+ const pid = pos[0];
165
+ if (!pid) { say(`usage: pmos driver ${verb} <proposal>${verb === 'return' ? ' --note "<what to change>"' : ''}`); return 2; }
166
+ let actor;
167
+ if (verb === 'keep') {
168
+ const g = await guard.requirePmCli({ act: 'keep', expect: pid, isTTY: who.isTTY, env: who.env, ask: ask || guard.askAtTerminal });
169
+ if (!g.ok) return fail(g.error);
170
+ actor = g.actor;
171
+ } else {
172
+ const g = pmOnly(verb === 'return' ? 'send a proposal back' : 'drop a proposal');
173
+ if (!g.ok) return fail(g.error);
174
+ actor = g.actor;
175
+ }
176
+ if (!file) return noState();
177
+ const p = D.fold(D.readLines(file)).proposals.get(pid);
178
+ 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');
179
+ const ctx = { stageFile: path.join(stateDir, stageTools.LEDGER), itemsDir: path.join(stateRoot.defaultRoot(), 'pm-items'),
180
+ correctionsFile: path.join(stateRoot.defaultRoot(), 'corrections.jsonl') };
181
+ const r = await decideLib.decide(verb, { file, proposal: pid, note: flags.note, actor, ctx, now });
182
+ if (!r.ok) return fail(r.error);
183
+ return done({ proposal: pid, state: r.proposal.state, wrote: r.wrote }, `✓ ${pid} ${r.proposal.state}${r.wrote ? ` (written to the ${r.wrote} record as you)` : ''}`);
184
+ }
185
+
186
+ if (verb === 'pause' || verb === 'resume') {
187
+ if (!needTarget()) return 2;
188
+ const g = pmOnly(`${verb} the driver`);
189
+ if (!g.ok) return fail(g.error);
190
+ if (!file) return noState();
191
+ const r = decideLib.setPause(file, verb, target(), g.actor, { now });
192
+ if (!r.ok) return fail(r.error);
193
+ return done({ target: r.target, paused: r.paused }, `✓ the driver is ${r.paused ? 'paused' : 'resumed'} for ${r.target.kind} ${r.target.id}`);
194
+ }
195
+
196
+ // mode
197
+ const mode = pos[0];
198
+ if (!mode) { say('usage: pmos driver mode <off|shadow|approve> [--board <id>]'); return 2; }
199
+ const g = pmOnly('set the driver mode');
200
+ if (!g.ok) return fail(g.error);
201
+ if (!file) return noState();
202
+ const r = decideLib.setMode(file, flags.board || '*', mode, g.actor, { now });
203
+ if (!r.ok) return fail(r.error);
204
+ return done({ board: r.board, mode: r.mode }, `✓ driver mode ${r.mode} for ${r.board === '*' ? 'every board' : 'board ' + r.board}`);
205
+ }
206
+
207
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+ /* driver-decide.js — what the PM does with a driver proposal, and with the driver itself (initiative initiative-driver,
3
+ * PRD sections 4 and 5). The CLI (lib/driver-cli.js) and the routes (lib/routes-driver.js) both call it, so a terminal and
4
+ * a page press cannot differ.
5
+ *
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? }
8
+ * setPause(file, verb, target, actor, { now }) verb: pause | resume
9
+ * setMode(file, board, mode, actor, { now }) mode: off | shadow | approve; board '*' is every board
10
+ *
11
+ * The caller has already proved the actor is the PM (pm-guard requirePagePress on a route, lib/actor.js on the CLI). These
12
+ * functions still refuse a non-PM actor: a run cannot keep its own proposals.
13
+ *
14
+ * KEEP WRITES THE PM'S RECORD THROUGH THE EXISTING WRITERS, so the record says the PM made it (PRD section 4):
15
+ * problem → lib/stage-tools.js appendTool, tool `problem`, `by` the PM
16
+ * move → the board move route's own function (lib/routes-boards.js move): its validation and warnings apply
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
+ * Every other kind (evidence, sizing, assumption, stakeholders, follow-up, note) is kept as a decision only: the PM's choice to
19
+ * 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
+ * RETURN needs a note and DROP may carry one; each logs one correction (the north star counts it) and a return wakes a run.
21
+ */
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const actorLib = require('./actor.js');
25
+ const D = require('./driver.js');
26
+ const stageTools = require('./stage-tools.js');
27
+ const pmItems = require('./pm-items.js');
28
+
29
+ const refuse = (status, error) => ({ ok: false, status, error });
30
+ const isPm = (a) => !!a && actorLib.isPm(a);
31
+
32
+ function itemsPaths(dir) { return { items: path.join(dir, 'items.jsonl'), steps: path.join(dir, 'steps.jsonl') }; }
33
+
34
+ /** The record write of a Keep. → { ok, wrote } | { ok: false, status, error }. */
35
+ async function writeRecord(p, actor, ctx, now) {
36
+ if (p.what === 'problem') {
37
+ if (!ctx.stageFile) return refuse(409, 'this repository has no state folder to keep the problem statement in');
38
+ const c = stageTools.checkData('problem', { text: p.data && p.data.text });
39
+ if (!c.ok) return refuse(400, c.error);
40
+ stageTools.appendTool(ctx.stageFile, { initiative: p.target.id, tool: 'problem', data: c.data }, actor, { now });
41
+ return { ok: true, wrote: 'problem' };
42
+ }
43
+ if (p.what === 'move') {
44
+ if (typeof ctx.move !== 'function') return refuse(409, 'a move is kept from the workspace page');
45
+ const r = await ctx.move(p);
46
+ 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
+ return { ok: true, wrote: 'move', warnings: r.body.warnings || [] };
48
+ }
49
+ if (p.what === 'next-step') {
50
+ if (!ctx.itemsDir) return refuse(409, 'no folder for the Work items');
51
+ const paths = itemsPaths(ctx.itemsDir);
52
+ const lines = pmItems.readLines(paths.items);
53
+ const item = pmItems.fold(lines).find((i) => i.id === p.target.id);
54
+ if (!item) return refuse(404, `no Work task ${p.target.id}`);
55
+ const stepsLib = require('./item-steps.js');
56
+ const id = `step-${require('crypto').randomBytes(6).toString('hex')}`;
57
+ const r = stepsLib.addStep(p.target.id, p.data.text, 'you', id);
58
+ if (!r.ok) return refuse(400, r.error);
59
+ pmItems.appendEvent(paths.steps, r.event, new Date(now));
60
+ if (p.data.due) pmItems.appendEvent(paths.items, { event: 'edited', id: p.target.id, fields: { due: p.data.due } }, new Date(now));
61
+ return { ok: true, wrote: 'next-step' };
62
+ }
63
+ return { ok: true, wrote: null };
64
+ }
65
+
66
+ async function decide(verb, { file, proposal, note, actor, ctx = {}, now } = {}) {
67
+ if (!['keep', 'return', 'drop'].includes(verb)) return refuse(500, `driver: ${JSON.stringify(verb)} is not a decision`);
68
+ if (!isPm(actor)) return refuse(403, 'only the PM can keep, send back or drop a driver proposal');
69
+ if (!file) return refuse(409, 'this repository has no state folder for the driver');
70
+ const t = now == null ? Date.now() : now;
71
+ const state = D.fold(D.readLines(file));
72
+ const p = state.proposals.get(proposal);
73
+ if (!p) return refuse(404, `no driver proposal ${String(proposal).slice(0, 40)}`);
74
+ if (p.state !== 'open') return refuse(409, `the proposal is ${p.state} already`);
75
+ const text = typeof note === 'string' ? note.trim() : '';
76
+ if (text.length > 500 || /[\r\n]/.test(text)) return refuse(400, 'the note is one line of at most 500 characters');
77
+ if (verb === 'return' && !text) return refuse(400, 'say what to change: sending a proposal back needs a note');
78
+ let wrote = null; let warnings = [];
79
+ if (verb === 'keep') {
80
+ const w = await writeRecord(p, actor, ctx, t);
81
+ if (!w.ok) return w;
82
+ wrote = w.wrote; warnings = w.warnings || [];
83
+ }
84
+ D.append(file, { ev: verb, proposal: p.id, ...(text ? { note: text } : {}), by: actor.by }, { now: t });
85
+ if (verb !== 'keep') {
86
+ D.append(file, { ev: 'correction', proposal: p.id, run: p.run, kind: verb, by: actor.by }, { now: t });
87
+ if (ctx.correctionsFile) {
88
+ try {
89
+ const reason = `driver:${verb}`;
90
+ fs.mkdirSync(path.dirname(ctx.correctionsFile), { recursive: true });
91
+ fs.appendFileSync(ctx.correctionsFile, JSON.stringify({ type: 'correction', kind: 'reject', reason, run: p.run, counted: true, at: new Date(t).toISOString(),
92
+ record: { kind: 'correction', run: p.run, reason, by: 'pm' } }) + '\n');
93
+ } catch { /* the driver ledger already holds the correction */ }
94
+ }
95
+ }
96
+ return { ok: true, proposal: D.fold(D.readLines(file)).proposals.get(p.id), wrote, warnings };
97
+ }
98
+
99
+ function setPause(file, verb, target, actor, { now } = {}) {
100
+ if (!['pause', 'resume'].includes(verb)) return refuse(500, `driver: ${JSON.stringify(verb)} is not pause or resume`);
101
+ if (!isPm(actor)) return refuse(403, 'only the PM can pause or resume the driver');
102
+ if (!file) return refuse(409, 'this repository has no state folder for the driver');
103
+ const c = D.checkTarget(target);
104
+ if (!c.ok) return refuse(400, c.error);
105
+ D.append(file, { ev: verb, target: c.target, by: actor.by }, { now });
106
+ return { ok: true, paused: verb === 'pause', target: c.target };
107
+ }
108
+
109
+ function setMode(file, board, mode, actor, { now } = {}) {
110
+ if (!isPm(actor)) return refuse(403, 'only the PM can set the driver mode');
111
+ if (!file) return refuse(409, 'this repository has no state folder for the driver');
112
+ if (!D.MODES.includes(mode)) return refuse(400, `mode must be one of ${D.MODES.join(', ')}`);
113
+ if (typeof board !== 'string' || !/^[A-Za-z0-9*][A-Za-z0-9._*-]{0,79}$/.test(board)) return refuse(400, 'board must be a board id (or * for every board)');
114
+ D.append(file, { ev: 'mode', board, mode, by: actor.by }, { now });
115
+ return { ok: true, board, mode };
116
+ }
117
+
118
+ module.exports = { decide, setPause, setMode, writeRecord };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+ /* driver-facts.js — what the unblock sweep reads (initiative initiative-driver, PRD section 3).
3
+ *
4
+ * gather(w, ctx, { now, includeWork, runtimeDir, driverState }) → { ok: true, facts } | { pending: true } | { ok: false, error }
5
+ *
6
+ * `facts` is the shape lib/driver.js detect() documents. This file only READS: the cached workflow rows and the boards ledger
7
+ * (lib/routes-boards.js), the stage-tool records (lib/stage-tools.js), the cached Jira readings (lib/jira-status.js), the
8
+ * placements ledger and the Work items. It starts no derivation of its own: while the rows are still being read it answers
9
+ * pending and the sweep tries again at the next tick. `ctx` is what route modules get (stateDirFor, selfRoot, getWs …).
10
+ *
11
+ * WHAT IS ACTIVE. An initiative that is not done, not stopped by a lifecycle line and not dormant. A dormant initiative (Later,
12
+ * untouched for dormant_days) is folded away on the Roadmap and starts no driver run.
13
+ * LIMITS, NAMED. The Jira readings keep no status-change time, so `last_ticket_move` is the latest resolved date of the
14
+ * initiative's tickets (null when none: the Do rule then stays quiet instead of guessing). `has_evidence` counts kept driver
15
+ * evidence only. `has_done_criteria` is a PRD that names done-criteria, acceptance criteria or a rubric, or a rubric file.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const D = require('./driver.js');
20
+ const stageTools = require('./stage-tools.js');
21
+ const pmItems = require('./pm-items.js');
22
+ const itemSteps = require('./item-steps.js');
23
+
24
+ const ms = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
25
+
26
+ function fmOf(root, rel) {
27
+ if (!rel) return {};
28
+ let text = '';
29
+ try { text = fs.readFileSync(path.isAbsolute(rel) ? rel : path.join(root, rel), 'utf8'); } catch { return {}; }
30
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
31
+ const out = {};
32
+ if (m) for (const line of m[1].split(/\r?\n/)) { const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*?)\s*$/.exec(line); if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
33
+ return out;
34
+ }
35
+ function doneCriteriaOf(root, fm) {
36
+ if (fm.rubric_path && fs.existsSync(path.join(root, fm.rubric_path))) return true;
37
+ if (!fm.prd_path) return false;
38
+ try { return /done[- ]criteria|acceptance criteria|rubric/i.test(fs.readFileSync(path.join(root, fm.prd_path), 'utf8')); } catch { return false; }
39
+ }
40
+ function outcomeChecked(stateDir, id) {
41
+ try {
42
+ return fs.readFileSync(path.join(stateDir, 'outcome_checks.jsonl'), 'utf8').split('\n').some((l) => { try { const c = JSON.parse(l); return c && c.initiative_id === id; } catch { return false; } });
43
+ } catch { return false; }
44
+ }
45
+
46
+ function initiativeFacts(w, ctx, { driverState, stateDir }) {
47
+ const boards = require('./routes-boards.js');
48
+ const got = boards.baseRows(w);
49
+ if (got.pending) return { pending: true };
50
+ if (!got.ok) return { ok: false, error: got.error };
51
+ const fr = boards.frameFor(ctx, w);
52
+ const tools = stageTools.readLedger(path.join(stateDir, stageTools.LEDGER));
53
+ let placed = null;
54
+ try { placed = require('./placements.js').readPlacements(w.root); } catch { placed = null; }
55
+ let jiraStatus = null;
56
+ try { jiraStatus = require('./jira-status.js'); } catch { jiraStatus = null; }
57
+ const keptEvidence = new Set();
58
+ for (const p of driverState.proposals.values()) if (p.state === 'kept' && p.what === 'evidence' && p.target.kind === 'initiative') keptEvidence.add(p.target.id);
59
+ const out = [];
60
+ for (const base of got.rows) {
61
+ if (base.status === 'done' || base.lifecycle || base.dormant) continue;
62
+ const boardId = boards.boardIdOf(base, fr.boards, fr.folded, w);
63
+ const board = fr.boards.find((b) => b.id === boardId) || fr.boards[0];
64
+ const row = boards.boardRow(board, base, fr.folded);
65
+ const fm = fmOf(w.root, base.record);
66
+ const rec = stageTools.recordsOf(tools, base.id);
67
+ let snap = null;
68
+ try { snap = jiraStatus ? jiraStatus.readSnapshot(w.root, base.id) : null; } catch { snap = null; }
69
+ const tickets = snap ? [...snap.tickets.values()] : [];
70
+ const resolved = tickets.map((t) => ms(t.resolved)).filter((x) => x != null);
71
+ out.push({
72
+ ws: w.id, board: board.id, id: base.id, title: base.title || base.id, cat: row.cat, active: true,
73
+ last_activity: ms(base.updated),
74
+ has_problem: !!rec.problem, has_evidence: keptEvidence.has(base.id),
75
+ has_prd: !!base.hasSpec, has_done_criteria: doneCriteriaOf(w.root, fm), has_anchor: !!(base.anchor && String(base.anchor).trim()),
76
+ has_tickets: tickets.length > 0, last_ticket_move: resolved.length ? Math.max(...resolved) : null,
77
+ blocked_tickets: tickets.filter((t) => t.flagged).map((t) => t.key),
78
+ check_by: placed && placed.ok && placed.byInit[base.id] ? placed.byInit[base.id].check_by || null : null,
79
+ outcome_checked: outcomeChecked(stateDir, base.id),
80
+ kill_tripped: !!(rec.kill && rec.kill.data && (rec.kill.data.items || []).some((i) => i.status === 'tripped')),
81
+ });
82
+ }
83
+ return { ok: true, list: out };
84
+ }
85
+
86
+ function workFacts(w, { runtimeDir, now }) {
87
+ const dir = path.join(runtimeDir, 'pm-items');
88
+ const items = pmItems.fold(pmItems.readLines(path.join(dir, 'items.jsonl')));
89
+ const steps = itemSteps.foldSteps(pmItems.readLines(path.join(dir, 'steps.jsonl')));
90
+ const today = new Date(Date.UTC(new Date(now).getFullYear(), new Date(now).getMonth(), new Date(now).getDate()));
91
+ const stale = new Set(pmItems.reviewQueue(items, new Date(now)).map((i) => i.id));
92
+ return items.filter((i) => !i.done).map((i) => ({
93
+ ws: w.id, id: i.id, title: i.title || i.id, done: false, placement: pmItems.workPlacement(i, today), due: i.due || null,
94
+ waiting_on: i.waiting_on || null, waiting_since: i.waiting_since || null, touched_at: i.touched_at || null,
95
+ untouched: stale.has(i.id), steps: (steps.byItem[i.id] || []).length,
96
+ }));
97
+ }
98
+
99
+ function gather(w, ctx, { now = Date.now(), includeWork = false, runtimeDir, driverState } = {}) {
100
+ const stateDir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
101
+ if (!stateDir) return { ok: false, error: 'no state folder' };
102
+ const facts = { day: D.localDay(now), now, initiatives: [], work: [] };
103
+ const ini = initiativeFacts(w, ctx, { driverState: driverState || D.fold([]), stateDir });
104
+ if (ini.pending) return { pending: true };
105
+ if (ini.ok === false) return ini;
106
+ facts.initiatives = ini.list;
107
+ if (includeWork) {
108
+ try { facts.work = workFacts(w, { runtimeDir, now }); } catch (e) { return { ok: false, error: `the Work items could not be read: ${String((e && e.message) || e).slice(0, 120)}` }; }
109
+ }
110
+ return { ok: true, facts };
111
+ }
112
+
113
+ module.exports = { gather, initiativeFacts, workFacts };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ /* driver-needs.js — the needs provider of the Initiative Driver (initiative initiative-driver, PRD sections 3 to 5).
3
+ * lib/needs-providers.js folds these rows into server.js deriveNeeds, and from there into /api/needs and Today.
4
+ *
5
+ * kinds: driver-drafts, driver-blocked
6
+ * rowsFor(w, { lead }) → rows · sig(w) → string · titlesOf(w) · rowsOf(state, { titleOf, now, ledgerRel })
7
+ *
8
+ * driver-drafts one row per initiative or Work task with open proposals: "Agent has 3 drafts on <title>".
9
+ * driver-blocked one row for a target the PM is blocking: its drafts have waited more than a day, and today's sweep raised
10
+ * the finding again. The sweep starts no run for it; the row is the reminder.
11
+ *
12
+ * SHADOW MODE raises no row (PRD section 5): the board mode, taken from the board of the target's latest run, decides. The
13
+ * rows are repository-wide, so a tree that is not the lead returns []. A row never carries a session token.
14
+ */
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+ const D = require('./driver.js');
19
+
20
+ const NAME = 'driver-needs';
21
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
22
+ const KIND_WORD = { initiative: 'the initiative', work: 'the Work task' };
23
+
24
+ /** Titles for the targets of a workspace: initiative titles from the cached workflow rows, Work titles from the Work items. */
25
+ function titlesOf(w) {
26
+ const out = new Map();
27
+ try {
28
+ const r = require('./initiative-rows.js').peek(w.root);
29
+ if (r && r.ok) for (const x of r.rows) out.set(`initiative:${x.id}`, x.title || x.id);
30
+ } catch { /* the id stands in */ }
31
+ try {
32
+ const pmItems = require('./pm-items.js');
33
+ const f = path.join(require('./state-root.js').defaultRoot(), 'pm-items', 'items.jsonl');
34
+ for (const i of pmItems.fold(pmItems.readLines(f))) out.set(`work:${i.id}`, i.title || i.id);
35
+ } catch { /* the id stands in */ }
36
+ return out;
37
+ }
38
+
39
+ function rowsOf(state, { titleOf = () => null, now = Date.now(), ledgerRel = 'pmos/state/driver.jsonl' } = {}) {
40
+ const rows = [];
41
+ const open = new Map();
42
+ for (const p of D.openProposals(state)) {
43
+ const k = D.targetKey(p.target);
44
+ if (!open.has(k)) open.set(k, { target: p.target, list: [] });
45
+ open.get(k).list.push(p);
46
+ }
47
+ const shadow = (target) => {
48
+ let board = target.kind === 'work' ? 'work' : null;
49
+ if (target.kind !== 'work') {
50
+ const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === D.targetKey(target) && r.board);
51
+ if (runs.length) board = runs[runs.length - 1].board;
52
+ }
53
+ return D.modeOf(state, board) === 'shadow';
54
+ };
55
+ for (const [k, g] of open) {
56
+ if (shadow(g.target)) continue;
57
+ const title = titleOf(g.target) || g.target.id;
58
+ const whats = [...new Set(g.list.map((p) => p.what))].join(', ');
59
+ rows.push({
60
+ run_id: `${NAME}:drafts:${k}`, kind: 'driver-drafts', initiative_id: g.target.kind === 'initiative' ? g.target.id : null,
61
+ question: `Agent has ${plural(g.list.length, 'draft', 'drafts')} on ${title}`, doc: null,
62
+ today: { kind: 7, source: NAME, cause: `The agent filed ${plural(g.list.length, 'draft', 'drafts')} for ${KIND_WORD[g.target.kind]}: ${whats}`, evidence: ledgerRel, act: { label: 'Review', cli: null } },
63
+ });
64
+ }
65
+ const today = D.localDay(now);
66
+ const seen = new Set();
67
+ for (const b of state.blockers) {
68
+ if (b.owner !== 'pm' || b.day !== today || !b.target) continue;
69
+ const k = D.targetKey(b.target);
70
+ if (seen.has(k) || !open.has(k) || shadow(b.target)) continue;
71
+ const g = open.get(k);
72
+ const oldest = g.list.map((p) => Date.parse(p.at)).filter(Number.isFinite).sort((a, c) => a - c)[0];
73
+ if (!(now - oldest >= D.PM_WAIT_MS)) continue;
74
+ seen.add(k);
75
+ const days = Math.max(1, Math.floor((now - oldest) / D.DAY));
76
+ const title = titleOf(g.target) || g.target.id;
77
+ rows.push({
78
+ run_id: `${NAME}:blocked:${k}`, kind: 'driver-blocked', initiative_id: g.target.kind === 'initiative' ? g.target.id : null,
79
+ question: `${title} is waiting on you: the drafts are ${plural(days, 'day', 'days')} old`, doc: null,
80
+ today: { kind: 7, source: NAME, cause: 'The agent starts no new run on this until you keep, send back or drop its drafts', evidence: ledgerRel, act: { label: 'Review', cli: null } },
81
+ });
82
+ }
83
+ return rows;
84
+ }
85
+
86
+ function rowsFor(w, { lead } = {}) {
87
+ if (!lead || !w || !w.root) return [];
88
+ const dir = D.stateDirOf(w.root);
89
+ if (!dir) return [];
90
+ const file = D.ledgerFile(dir);
91
+ const state = D.fold(D.readLines(file));
92
+ if (!state.proposals.size) return [];
93
+ const titles = titlesOf(w);
94
+ let rel = 'driver.jsonl';
95
+ try { rel = path.relative(w.root, file).split(path.sep).join('/'); } catch { /* the plain name stands */ }
96
+ return rowsOf(state, { titleOf: (t) => titles.get(`${t.kind}:${t.id}`) || null, now: Date.now(), ledgerRel: rel });
97
+ }
98
+
99
+ function sig(w) {
100
+ if (!w || !w.root) return 'none';
101
+ const dir = D.stateDirOf(w.root);
102
+ let st = 'none';
103
+ try { const s = fs.statSync(D.ledgerFile(dir)); st = `${s.size}:${s.mtimeMs}:${s.ino}`; } catch { /* no ledger yet */ }
104
+ return crypto.createHash('sha1').update(`${st}|${D.localDay(Date.now())}`).digest('hex');
105
+ }
106
+
107
+ module.exports = { kinds: ['driver-drafts', 'driver-blocked'], rowsFor, sig, titlesOf, rowsOf };