create-pmos 0.167.0 → 0.169.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 (59) 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/c3-driver-fixture.js +61 -0
  7. package/workspace/c3-driver-test.js +164 -0
  8. package/workspace/extension/README.md +33 -0
  9. package/workspace/extension/address.js +20 -0
  10. package/workspace/extension/content.js +76 -0
  11. package/workspace/extension/icons/icon-128.png +0 -0
  12. package/workspace/extension/icons/icon-16.png +0 -0
  13. package/workspace/extension/icons/icon-32.png +0 -0
  14. package/workspace/extension/icons/icon-48.png +0 -0
  15. package/workspace/extension/manifest.json +15 -0
  16. package/workspace/extension/options.css +33 -0
  17. package/workspace/extension/options.html +26 -0
  18. package/workspace/extension/options.js +24 -0
  19. package/workspace/extension/panel.css +21 -0
  20. package/workspace/extension/panel.html +14 -0
  21. package/workspace/extension/panel.js +67 -0
  22. package/workspace/guide/console-calendar.md +14 -0
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-driver.md +87 -0
  25. package/workspace/lib/driver-cli.js +207 -0
  26. package/workspace/lib/driver-decide.js +118 -0
  27. package/workspace/lib/driver-facts.js +113 -0
  28. package/workspace/lib/driver-needs.js +107 -0
  29. package/workspace/lib/driver-run.js +187 -0
  30. package/workspace/lib/driver.js +408 -0
  31. package/workspace/lib/extension-cli.js +39 -0
  32. package/workspace/lib/headless.js +13 -2
  33. package/workspace/lib/meeting-focus.js +48 -1
  34. package/workspace/lib/needs-providers.js +1 -0
  35. package/workspace/lib/pmos-verbs.js +6 -0
  36. package/workspace/lib/route-modules.js +1 -0
  37. package/workspace/lib/routes-boards.js +11 -1
  38. package/workspace/lib/routes-driver.js +167 -0
  39. package/workspace/lib/routes-meeting-focus.js +17 -1
  40. package/workspace/package-lock.json +2 -2
  41. package/workspace/package.json +1 -1
  42. package/workspace/public/pm/c3-brief-needs.js +19 -3
  43. package/workspace/public/pm/c3-demo-focus.js +12 -2
  44. package/workspace/public/pm/c3-demo.js +1 -1
  45. package/workspace/public/pm/c3-driver-work.js +110 -0
  46. package/workspace/public/pm/c3-driver.css +18 -0
  47. package/workspace/public/pm/c3-driver.js +298 -0
  48. package/workspace/public/pm/c3-focus.css +31 -0
  49. package/workspace/public/pm/c3-focus.js +122 -21
  50. package/workspace/public/pm/c3-ii-tabs.js +3 -2
  51. package/workspace/public/pm/c3-ini-ui.js +4 -2
  52. package/workspace/public/pm/c3-initiatives.js +14 -0
  53. package/workspace/public/pm/c3-shell.js +2 -2
  54. package/workspace/public/pm/pm-work-view.js +12 -2
  55. package/workspace/server.js +21 -1
  56. package/workspace/skills/initiative-drive.skill +50 -0
  57. package/workspace/skills/work-unblock.skill +42 -0
  58. package/workspace/test-files.json +6 -0
  59. package/workspace/test-lists/c3-meeting-focus.json +13 -4
@@ -0,0 +1,408 @@
1
+ 'use strict';
2
+ /* driver.js — the Initiative Driver's ledger and rules (initiative initiative-driver, PRD sections 1 to 5).
3
+ *
4
+ * The driver is a short headless run for one initiative or one Work task. It reads the record, files what it found as
5
+ * PROPOSALS, and ends. The PM keeps, sends back or drops each proposal. This file is the pure core: the shape of every
6
+ * line in <state folder>/driver.jsonl, the fold, the budget, the modes, the wake queue and the unblock sweep. It reads the
7
+ * clock only through the `now` its callers pass. The only I/O is the thin read/append pair at the bottom.
8
+ *
9
+ * LEDGER · KINDS · WHATS · MODES · DEFAULT_MODE · WAKES · BUDGET
10
+ * stateDirOf(root, { create }) → the repository's state folder (pmos/state, state or .pmos), else null
11
+ * ledgerFile(stateDir) → <state folder>/driver.jsonl
12
+ * append(file, line, { now }) → the line written. THE SINGLE WRITER of driver.jsonl.
13
+ * readLines(file) → every line that parses, in file order
14
+ * checkProposal(input) → { ok, proposal } | { ok: false, error } the shape of one proposal
15
+ * fold(lines) → the current state (proposals, decisions, runs, wakes, pauses, modes, blockers)
16
+ * modeOf(state, board) · isPaused(state, target) · openProposals(state, target?)
17
+ * admit(state, { target, board }, { now, running }) → { ok } | { ok: false, refuse: reason } | { ok: false, wait: reason }
18
+ * plan(state, { now, running }) → { start: [{ target, board, wakes[] }], refused: [{ target, wakes[], reason }] }
19
+ * detect(facts, state, { now, stalledDays }) → the blockers, each { key, target, board, title, code, owner, cat, text }
20
+ * sweepLines(facts, state, { now, stalledDays }) → { blockers, lines } the lines to append (once per blocker per day)
21
+ * wakesFromLedgers({ created, moved }, state, { ws, epoch }) → wake lines for creations and moves not yet woken
22
+ * answeredWakes(state) → wake lines for proposals the PM sent back with a note
23
+ * localDay(ms) · targetKey(target) · wsIdOf(root)
24
+ *
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
+ * proposal { id, target: { kind, ws, id }, what, data, why, sources: [{ label, ref }], run, by } by an agent session
27
+ * keep|return|drop { proposal, note?, by } PM only: the fold ignores one whose `by` is not pm:*
28
+ * correction { proposal, run, kind: 'return'|'drop', by } one per return or drop; the north star counts it
29
+ * pause|resume { target, by } · mode { board, mode, by } PM only, as above. `board` '*' is every board.
30
+ * wake { key, target, board, reason: created|moved|answered|stuck, blocker? } queued: a run should start
31
+ * run-start { run, target, board, wakes: [key], session } one per run; counts against the day's budget
32
+ * run-end { run, ok, reason?, agent_run } agent_run is the run record the correction rate reads
33
+ * refused { target, wakes: [key], reason } a wake the rules turned away, recorded
34
+ * blocker { key, day, target, board, code, owner: 'pm'|'agent', text } the sweep's finding, once per key per day
35
+ * epoch { } the first scan of this ledger: older events do not wake
36
+ *
37
+ * BUDGET (PM decision 4): 3 runs per initiative per local day, 1 per Work task per day, 3 at once, no daily total. A run
38
+ * that fails or times out still counts. A wake the budget turns away is recorded as `refused`; a wake that only waits for
39
+ * a free slot stays queued. Several wakes of one target are answered by ONE run.
40
+ *
41
+ * THE FACTORY NEVER WRITES HERE (D65): the CLI and the routes pass the actor from the call path. This ledger is not an
42
+ * initiative record, an acceptance, an anchor or a stage move; Keep writes those through their own writers.
43
+ */
44
+ const fs = require('fs');
45
+ const path = require('path');
46
+ const crypto = require('crypto');
47
+
48
+ const LEDGER = 'driver.jsonl';
49
+ const KINDS = Object.freeze(['initiative', 'work']);
50
+ const WHATS = Object.freeze({
51
+ initiative: Object.freeze(['problem', 'evidence', 'sizing', 'assumption', 'stakeholders', 'move', 'note']),
52
+ work: Object.freeze(['next-step', 'follow-up', 'note']),
53
+ });
54
+ // A claim without a pointer is not a proposal (PRD section 2: "Nothing without a pointer").
55
+ const NEEDS_SOURCE = Object.freeze(['evidence', 'sizing', 'assumption', 'stakeholders']);
56
+ const MODES = Object.freeze(['off', 'shadow', 'approve']);
57
+ const DEFAULT_MODE = 'approve';
58
+ const WAKES = Object.freeze(['created', 'moved', 'answered', 'stuck']);
59
+ const EVENTS = Object.freeze(['proposal', 'keep', 'return', 'drop', 'correction', 'pause', 'resume', 'mode', 'wake', 'run-start', 'run-end', 'refused', 'blocker', 'epoch']);
60
+ const BUDGET = Object.freeze({ initiativePerDay: 3, workPerDay: 1, concurrent: 3 });
61
+ const DAY = 86400000;
62
+ const PM_WAIT_MS = DAY; // an open proposal older than this makes the PM the blocker
63
+ const SWEEP_EVERY_MS = 30 * 60 * 1000; // the sweep's cadence inside the day-check window
64
+ const DEFAULT_STALLED_DAYS = 5;
65
+ const WORK_WAIT_DAYS = 3;
66
+ const WORK_UNTOUCHED_DAYS = 7; // lib/pm-items.js REVIEW_DAYS
67
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
68
+ const WS_RE = /^[A-Za-z0-9._-]{1,80}$/;
69
+ const PROPOSAL_RE = /^p-[0-9a-f]{10}$/;
70
+ const RUN_RE = /^run-[0-9a-f]{10}$/;
71
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
72
+ const MAX_DATA = 8000;
73
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
74
+ const oneLine = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
75
+ const pm = (by) => typeof by === 'string' && by.startsWith('pm:');
76
+
77
+ const targetKey = (t) => `${t.kind}:${t.ws}:${t.id}`;
78
+ const wsIdOf = (root) => crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 10);
79
+ /** The local calendar day of a time in ms, YYYY-MM-DD. The budget counts per local day. */
80
+ function localDay(ms) {
81
+ const d = new Date(ms);
82
+ const p = (n) => String(n).padStart(2, '0');
83
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
84
+ }
85
+
86
+ // ── where the ledger lives ───────────────────────────────────────────────────────────────────────────
87
+ const isDir = (d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } };
88
+ /** The repository's state folder, by the rule server.js stateDirFor uses; the checkout this file ships in falls back to
89
+ * workspace/state, as the server does for PMOS-self. Null when there is none (and `create` is off). */
90
+ function stateDirOf(root, { create = false } = {}) {
91
+ if (!root) return null;
92
+ for (const rel of [['pmos', 'state'], ['state'], ['.pmos']]) {
93
+ const d = path.join(root, ...rel);
94
+ if (isDir(d)) return d;
95
+ }
96
+ if (path.resolve(root) === path.resolve(__dirname, '..', '..') && isDir(path.join(__dirname, '..', 'state'))) return path.join(__dirname, '..', 'state');
97
+ if (create) {
98
+ const d = path.join(root, 'pmos', 'state');
99
+ try { fs.mkdirSync(d, { recursive: true }); return d; } catch { return null; }
100
+ }
101
+ return null;
102
+ }
103
+ const ledgerFile = (stateDir) => (stateDir ? path.join(stateDir, LEDGER) : null);
104
+
105
+ // ── the one writer, and the read ─────────────────────────────────────────────────────────────────────
106
+ /** THE SINGLE WRITER of driver.jsonl. `v` and `at` are set here; a caller never chooses them. */
107
+ function append(file, line, { now } = {}) {
108
+ if (!file) throw new Error('driver: no ledger file');
109
+ if (!isObj(line) || !EVENTS.includes(line.ev)) throw new Error(`driver: unknown event ${JSON.stringify(line && line.ev)}`);
110
+ const out = { ...line, v: 1, at: new Date(now == null ? Date.now() : now).toISOString() };
111
+ fs.mkdirSync(path.dirname(file), { recursive: true });
112
+ fs.appendFileSync(file, JSON.stringify(out) + '\n');
113
+ return out;
114
+ }
115
+ function readLines(file) {
116
+ let text = '';
117
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
118
+ const out = [];
119
+ for (const raw of text.split('\n')) {
120
+ if (!raw.trim()) continue;
121
+ try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev)) out.push(l); } catch { /* a torn line is skipped */ }
122
+ }
123
+ return out;
124
+ }
125
+ const newId = (prefix) => `${prefix}-${crypto.randomBytes(5).toString('hex')}`;
126
+
127
+ // ── a proposal ───────────────────────────────────────────────────────────────────────────────────────
128
+ function checkTarget(t) {
129
+ if (!isObj(t)) return { ok: false, error: 'target must be { kind, ws, id }' };
130
+ if (!KINDS.includes(t.kind)) return { ok: false, error: `target kind must be one of ${KINDS.join(', ')}` };
131
+ if (typeof t.ws !== 'string' || !WS_RE.test(t.ws)) return { ok: false, error: 'target ws must be a workspace id' };
132
+ if (typeof t.id !== 'string' || !ID_RE.test(t.id)) return { ok: false, error: 'target id must be an initiative or Work task id (letters, digits, . _ -)' };
133
+ return { ok: true, target: { kind: t.kind, ws: t.ws, id: t.id } };
134
+ }
135
+
136
+ /** The shape of one proposal as an agent files it. Returns the line fields the writer adds `id`, `run`, `by` to. */
137
+ function checkProposal(input) {
138
+ const i = isObj(input) ? input : {};
139
+ const t = checkTarget({ kind: i.kind, ws: i.ws, id: i.id });
140
+ if (!t.ok) return t;
141
+ if (!WHATS[t.target.kind].includes(i.what)) return { ok: false, error: `what must be one of ${WHATS[t.target.kind].join(', ')} for ${t.target.kind === 'work' ? 'a Work task' : 'an initiative'}` };
142
+ if (!oneLine(i.why, 500)) return { ok: false, error: 'why is one line of 1 to 500 characters' };
143
+ let data = i.data;
144
+ if (typeof data === 'string') data = { text: data };
145
+ if (!isObj(data)) return { ok: false, error: 'data must be an object or text' };
146
+ if (JSON.stringify(data).length > MAX_DATA) return { ok: false, error: `data is too long (at most ${MAX_DATA} characters)` };
147
+ const sources = [];
148
+ if (i.sources !== undefined && i.sources !== null) {
149
+ if (!Array.isArray(i.sources) || i.sources.length > 20) return { ok: false, error: 'sources is a list of at most 20 { label, ref }' };
150
+ for (const s of i.sources) {
151
+ if (!isObj(s) || !oneLine(s.label, 80) || !oneLine(s.ref, 500)) return { ok: false, error: 'each source needs a label (up to 80 characters) and a ref (a Jira key, link or path, up to 500)' };
152
+ sources.push({ label: s.label.trim(), ref: s.ref.trim() });
153
+ }
154
+ }
155
+ if (NEEDS_SOURCE.includes(i.what) && !sources.length) return { ok: false, error: `${/^[aeiou]/.test(i.what) ? 'an' : 'a'} ${i.what} proposal needs at least one source with a pointer (--source label=ref); nothing without a pointer` };
156
+ if (i.what === 'evidence' && Array.isArray(data.items) && data.items.some((x) => !isObj(x) || !oneLine(x.text, 500) || !oneLine(x.ref, 500))) {
157
+ return { ok: false, error: 'every evidence item needs text and a ref (the pointer to where you read it)' };
158
+ }
159
+ if (i.what === 'problem') {
160
+ const c = require('./stage-tools.js').checkData('problem', { text: data.text });
161
+ if (!c.ok) return { ok: false, error: c.error };
162
+ data = { text: c.data.text };
163
+ } else if (i.what === 'move') {
164
+ if (!oneLine(data.to, 120)) return { ok: false, error: 'a move needs data.to: the name of the stage' };
165
+ data = { to: data.to.trim(), ...(oneLine(data.board, 120) ? { board: data.board.trim() } : {}) };
166
+ } else if (i.what === 'next-step') {
167
+ if (!oneLine(data.text, 500)) return { ok: false, error: 'a next step needs data.text: one line of up to 500 characters' };
168
+ if (data.due != null && !(typeof data.due === 'string' && DATE_RE.test(data.due))) return { ok: false, error: 'data.due is YYYY-MM-DD' };
169
+ data = { text: data.text.trim(), ...(data.due ? { due: data.due } : {}) };
170
+ }
171
+ return { ok: true, proposal: { target: t.target, what: i.what, data, why: i.why.trim(), sources } };
172
+ }
173
+
174
+ // ── the fold ─────────────────────────────────────────────────────────────────────────────────────────
175
+ function emptyState() {
176
+ return { epoch: null, proposals: new Map(), order: [], paused: new Map(), modes: new Map(), runs: new Map(), bySession: new Map(),
177
+ wakes: [], wakeKeys: new Set(), blockers: [], blockerDays: new Set(), refusals: [], corrections: [] };
178
+ }
179
+
180
+ /** The current state of one ledger. Order matters only inside a kind; a decision line from anything but the PM is ignored. */
181
+ function fold(lines) {
182
+ const s = emptyState();
183
+ const wakeBy = new Map();
184
+ for (const l of lines || []) {
185
+ if (!isObj(l)) continue;
186
+ switch (l.ev) {
187
+ case 'epoch': if (!s.epoch) s.epoch = l.at || null; break;
188
+ case 'proposal': {
189
+ if (!PROPOSAL_RE.test(String(l.id)) || s.proposals.has(l.id) || !checkTarget(l.target).ok) break;
190
+ s.proposals.set(l.id, { id: l.id, target: { kind: l.target.kind, ws: l.target.ws, id: l.target.id }, what: l.what, data: l.data, why: l.why,
191
+ sources: Array.isArray(l.sources) ? l.sources : [], run: l.run || null, by: l.by || null, at: l.at || null, state: 'open', note: null, decided_at: null, decided_by: null });
192
+ s.order.push(l.id);
193
+ break;
194
+ }
195
+ case 'keep': case 'return': case 'drop': {
196
+ const p = s.proposals.get(l.proposal);
197
+ if (!p || p.state !== 'open' || !pm(l.by)) break;
198
+ p.state = l.ev === 'keep' ? 'kept' : l.ev === 'return' ? 'returned' : 'dropped';
199
+ p.note = typeof l.note === 'string' && l.note ? l.note : null; p.decided_at = l.at || null; p.decided_by = l.by;
200
+ break;
201
+ }
202
+ case 'correction': s.corrections.push({ proposal: l.proposal || null, run: l.run || null, kind: l.kind, at: l.at || null }); break;
203
+ case 'pause': case 'resume': if (pm(l.by) && checkTarget(l.target).ok) s.paused.set(targetKey(l.target), l.ev === 'pause'); break;
204
+ case 'mode': if (pm(l.by) && MODES.includes(l.mode) && typeof l.board === 'string' && l.board) s.modes.set(l.board, l.mode); break;
205
+ case 'wake': {
206
+ if (typeof l.key !== 'string' || s.wakeKeys.has(l.key) || !checkTarget(l.target).ok) break;
207
+ s.wakeKeys.add(l.key);
208
+ const w = { key: l.key, target: l.target, board: l.board || null, reason: l.reason, blocker: l.blocker || null, at: l.at || null, state: 'queued' };
209
+ s.wakes.push(w); wakeBy.set(l.key, w);
210
+ break;
211
+ }
212
+ case 'run-start': {
213
+ if (typeof l.run !== 'string' || s.runs.has(l.run) || !checkTarget(l.target).ok) break;
214
+ const r = { run: l.run, target: l.target, board: l.board || null, wakes: Array.isArray(l.wakes) ? l.wakes : [], session: l.session || null, start: l.at || null, end: null, ok: null, reason: null, agent_run: null };
215
+ s.runs.set(l.run, r);
216
+ if (r.session) s.bySession.set(String(r.session).replace(/\.[0-9a-f]{8}$/, ''), l.run);
217
+ for (const k of r.wakes) { const w = wakeBy.get(k); if (w) w.state = 'started'; }
218
+ break;
219
+ }
220
+ case 'run-end': {
221
+ const r = s.runs.get(l.run);
222
+ if (!r || r.end) break;
223
+ r.end = l.at || null; r.ok = l.ok === true; r.reason = typeof l.reason === 'string' ? l.reason : null; r.agent_run = isObj(l.agent_run) ? l.agent_run : null;
224
+ break;
225
+ }
226
+ case 'refused': {
227
+ s.refusals.push({ target: l.target, wakes: l.wakes || [], reason: l.reason, at: l.at || null });
228
+ for (const k of l.wakes || []) { const w = wakeBy.get(k); if (w) w.state = 'refused'; }
229
+ break;
230
+ }
231
+ case 'blocker': if (typeof l.key === 'string' && typeof l.day === 'string') { s.blockers.push(l); s.blockerDays.add(`${l.key}|${l.day}`); } break;
232
+ default: break;
233
+ }
234
+ }
235
+ return s;
236
+ }
237
+
238
+ const modeOf = (state, board) => (board && state.modes.get(board)) || state.modes.get('*') || DEFAULT_MODE;
239
+ const isPaused = (state, target) => state.paused.get(targetKey(target)) === true;
240
+ function openProposals(state, target) {
241
+ const want = target ? targetKey(target) : null;
242
+ return state.order.map((id) => state.proposals.get(id)).filter((p) => p.state === 'open' && (!want || targetKey(p.target) === want));
243
+ }
244
+ const runsOnDay = (state, target, day) => [...state.runs.values()].filter((r) => targetKey(r.target) === targetKey(target) && r.start && localDay(Date.parse(r.start)) === day).length;
245
+ const budgetFor = (target) => (target.kind === 'work' ? BUDGET.workPerDay : BUDGET.initiativePerDay);
246
+
247
+ /** May a run start for this target now? `running` is the number of driver runs alive. A closed rule refuses (and the
248
+ * caller records it); a full house only waits. */
249
+ function admit(state, { target, board }, { now, running = 0 } = {}) {
250
+ if (modeOf(state, board) === 'off') return { ok: false, refuse: 'mode-off' };
251
+ if (isPaused(state, target)) return { ok: false, refuse: 'paused' };
252
+ if (runsOnDay(state, target, localDay(now)) >= budgetFor(target)) return { ok: false, refuse: 'budget' };
253
+ if (running >= BUDGET.concurrent) return { ok: false, wait: 'concurrency' };
254
+ return { ok: true };
255
+ }
256
+
257
+ /** What to do with the queued wakes now. Wakes of one target are answered by one run. `running` counts the runs alive;
258
+ * `activeTargets` (target keys) holds the targets that have one, so a target never has two at once. */
259
+ function plan(state, { now, running = 0, activeTargets = [] } = {}) {
260
+ const groups = new Map();
261
+ for (const w of state.wakes) {
262
+ if (w.state !== 'queued') continue;
263
+ const k = targetKey(w.target);
264
+ if (!groups.has(k)) groups.set(k, { target: w.target, board: w.board, wakes: [] });
265
+ groups.get(k).wakes.push(w.key);
266
+ }
267
+ const start = []; const refused = [];
268
+ const active = new Set(activeTargets);
269
+ // Plan on a scratch copy of the run counts so four wakes of one target never plan four runs.
270
+ const planned = new Map();
271
+ let live = running;
272
+ for (const g of groups.values()) {
273
+ if (active.has(targetKey(g.target))) continue;
274
+ const verdict = admit(state, g, { now, running: live });
275
+ if (verdict.refuse) { refused.push({ target: g.target, wakes: g.wakes, reason: verdict.refuse }); continue; }
276
+ if (verdict.wait) continue;
277
+ const day = localDay(now);
278
+ const used = runsOnDay(state, g.target, day) + (planned.get(targetKey(g.target)) || 0);
279
+ if (used >= budgetFor(g.target)) { refused.push({ target: g.target, wakes: g.wakes, reason: 'budget' }); continue; }
280
+ planned.set(targetKey(g.target), (planned.get(targetKey(g.target)) || 0) + 1);
281
+ live++;
282
+ start.push(g);
283
+ }
284
+ return { start, refused };
285
+ }
286
+
287
+ // ── the unblock sweep ────────────────────────────────────────────────────────────────────────────────
288
+ /* FACTS (the server gathers them; this file never reads a repository):
289
+ * facts.day 'YYYY-MM-DD', local facts.now ms
290
+ * facts.initiatives[] { ws, board, id, title, cat: Discover|Plan|Do|Learn, active, last_activity (ms|null),
291
+ * has_problem, has_evidence, has_prd, has_done_criteria, has_anchor,
292
+ * has_tickets, last_ticket_move (ms|null), blocked_tickets: [key], check_by ('YYYY-MM-DD'|null),
293
+ * outcome_checked, kill_tripped }
294
+ * facts.work[] { ws, id, title, done, placement: inbox|today|upcoming|waiting|someday, due, waiting_on, waiting_since (ISO|null),
295
+ * touched_at (ISO|null), untouched (bool: the Review queue holds it), steps (count) }
296
+ * A blocker is { key, target, board, title, code, owner: 'pm'|'agent', cat, text }. */
297
+ const iniKey = (i, code) => `initiative:${i.ws}:${i.id}:${code}`;
298
+ const workKey = (w, code) => `work:${w.ws}:${w.id}:${code}`;
299
+
300
+ function detect(facts, state, { now, stalledDays } = {}) {
301
+ const t = now == null ? facts.now : now;
302
+ const day = facts.day || localDay(t);
303
+ const stall = (stalledDays || DEFAULT_STALLED_DAYS) * DAY;
304
+ const out = [];
305
+ const add = (target, board, title, code, owner, cat, text, key) => out.push({ key, target, board, title, code, owner, cat: cat || null, text });
306
+ for (const i of facts.initiatives || []) {
307
+ if (!i || !i.active) continue;
308
+ const target = { kind: 'initiative', ws: i.ws, id: i.id };
309
+ const title = i.title || i.id;
310
+ const b = (code, owner, cat, text) => add(target, i.board || null, title, code, owner, cat, text, iniKey(i, code));
311
+ const oldest = openProposals(state, target).filter((p) => p.at && t - Date.parse(p.at) >= PM_WAIT_MS).sort((a, c) => Date.parse(a.at) - Date.parse(c.at))[0];
312
+ if (oldest) b('pm-open', 'pm', null, `The agent's drafts have waited on you since ${oldest.at.slice(0, 10)}`);
313
+ if (i.cat === 'Discover') {
314
+ if (!i.has_problem) b('no-problem', 'agent', 'Discover', 'No kept problem statement');
315
+ if (!i.has_evidence) b('no-evidence', 'agent', 'Discover', 'No evidence is kept');
316
+ } else if (i.cat === 'Plan') {
317
+ if (!i.has_prd) b('no-prd', 'agent', 'Plan', 'No PRD');
318
+ if (!i.has_done_criteria) b('no-done-criteria', 'agent', 'Plan', 'No done-criteria');
319
+ if (!i.has_anchor) b('no-anchor', 'agent', 'Plan', 'No anchor (a KR or a health budget)');
320
+ } else if (i.cat === 'Do') {
321
+ if (i.has_tickets && i.last_ticket_move != null && t - i.last_ticket_move >= stall) b('no-ticket-movement', 'agent', 'Do', `No Jira ticket moved for ${Math.floor((t - i.last_ticket_move) / DAY)} days`);
322
+ if (Array.isArray(i.blocked_tickets) && i.blocked_tickets.length) b('ticket-blocked', 'agent', 'Do', `Blocked ticket: ${i.blocked_tickets.join(', ')}`);
323
+ } else if (i.cat === 'Learn') {
324
+ 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`);
325
+ if (i.kill_tripped) b('kill-tripped', 'agent', 'Learn', 'A kill condition tripped');
326
+ }
327
+ 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`);
328
+ }
329
+ for (const w of facts.work || []) {
330
+ if (!w || w.done) continue;
331
+ const target = { kind: 'work', ws: w.ws, id: w.id };
332
+ const title = w.title || w.id;
333
+ const b = (code, owner, text) => add(target, 'work', title, code, owner, null, text, workKey(w, code));
334
+ const oldest = openProposals(state, target).filter((p) => p.at && t - Date.parse(p.at) >= PM_WAIT_MS).sort((a, c) => Date.parse(a.at) - Date.parse(c.at))[0];
335
+ if (oldest) b('pm-open', 'pm', `The agent's drafts have waited on you since ${oldest.at.slice(0, 10)}`);
336
+ if (w.due && w.due < day) b('work-overdue', 'agent', `Overdue since ${w.due}`);
337
+ if (w.waiting_on && w.waiting_since && Number.isFinite(Date.parse(w.waiting_since)) && t - Date.parse(w.waiting_since) >= WORK_WAIT_DAYS * DAY) {
338
+ b('work-waiting', 'agent', `Waiting on ${w.waiting_on} for ${Math.floor((t - Date.parse(w.waiting_since)) / DAY)} days`);
339
+ }
340
+ if (w.untouched) b('work-untouched', 'agent', `Untouched for ${WORK_UNTOUCHED_DAYS} days or more`);
341
+ if (['today', 'upcoming', 'waiting'].includes(w.placement) && !(w.steps > 0)) b('work-no-next-step', 'agent', 'No next step');
342
+ }
343
+ return out;
344
+ }
345
+
346
+ /** The lines the sweep appends now: one `blocker` per blocker per day, and for an agent blocker one `wake`. A target the PM
347
+ * is blocking (an open proposal older than a day) starts no run, so its other blockers wake nothing; the pm-open blocker is
348
+ * written once a day, which is what raises its Needs you row again. */
349
+ function sweepLines(facts, state, { now, stalledDays } = {}) {
350
+ const t = now == null ? facts.now : now;
351
+ const day = facts.day || localDay(t);
352
+ const blockers = detect(facts, state, { now: t, stalledDays });
353
+ const pmBlocked = new Set(blockers.filter((b) => b.owner === 'pm').map((b) => targetKey(b.target)));
354
+ const lines = [];
355
+ for (const b of blockers) {
356
+ if (state.blockerDays.has(`${b.key}|${day}`)) continue;
357
+ lines.push({ ev: 'blocker', key: b.key, day, target: b.target, board: b.board, code: b.code, owner: b.owner, text: b.text });
358
+ if (b.owner === 'agent' && !pmBlocked.has(targetKey(b.target))) {
359
+ const key = `stuck:${b.key}:${day}`;
360
+ if (!state.wakeKeys.has(key)) lines.push({ ev: 'wake', key, target: b.target, board: b.board, reason: 'stuck', blocker: b.code });
361
+ }
362
+ }
363
+ return { blockers, lines };
364
+ }
365
+
366
+ // ── the wakes ────────────────────────────────────────────────────────────────────────────────────────
367
+ /** `created`: placements lines { ev: 'created', initiative, at }. `moved`: boards ledger lines { kind: 'move', id, to, at, board }.
368
+ * Only events after the ledger's epoch wake a run: the first scan of a ledger marks what already happened as history. */
369
+ function wakesFromLedgers({ created = [], moved = [] } = {}, state, { ws, epoch, boardOf = () => null } = {}) {
370
+ const lines = [];
371
+ const after = (at) => Number.isFinite(Date.parse(at)) && Number.isFinite(Date.parse(epoch)) && Date.parse(at) >= Date.parse(epoch);
372
+ for (const c of created) {
373
+ if (!c || c.ev !== 'created' || typeof c.initiative !== 'string' || !ID_RE.test(c.initiative) || !after(c.at)) continue;
374
+ const key = `created:${ws}:${c.initiative}`;
375
+ if (state.wakeKeys.has(key)) continue;
376
+ lines.push({ ev: 'wake', key, target: { kind: 'initiative', ws, id: c.initiative }, board: boardOf(c.initiative), reason: 'created' });
377
+ }
378
+ for (const m of moved) {
379
+ if (!m || m.kind !== 'move' || typeof m.id !== 'string' || !ID_RE.test(m.id) || !after(m.at)) continue;
380
+ const key = `moved:${ws}:${m.id}:${m.at}`;
381
+ if (state.wakeKeys.has(key)) continue;
382
+ lines.push({ ev: 'wake', key, target: { kind: 'initiative', ws, id: m.id }, board: m.board || boardOf(m.id), reason: 'moved' });
383
+ }
384
+ return lines;
385
+ }
386
+ /** A proposal the PM sent back with a note wakes a run for its target (once per proposal). */
387
+ function answeredWakes(state, { boardOf = () => null } = {}) {
388
+ const lines = [];
389
+ for (const p of state.proposals.values()) {
390
+ if (p.state !== 'returned') continue;
391
+ const key = `answered:${p.id}`;
392
+ if (state.wakeKeys.has(key)) continue;
393
+ lines.push({ ev: 'wake', key, target: p.target, board: p.target.kind === 'work' ? 'work' : boardOf(p.target.id), reason: 'answered' });
394
+ }
395
+ return lines;
396
+ }
397
+
398
+ /** The run that was alive for an agent session token (its session id part), or null. */
399
+ function runOfSession(state, token) {
400
+ if (typeof token !== 'string') return null;
401
+ return state.bySession.get(token.replace(/\.[0-9a-f]{8}$/, '')) || null;
402
+ }
403
+
404
+ module.exports = {
405
+ 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,
406
+ stateDirOf, ledgerFile, append, readLines, newId, checkTarget, checkProposal, fold, modeOf, isPaused, openProposals, runsOnDay, admit, plan,
407
+ detect, sweepLines, wakesFromLedgers, answeredWakes, runOfSession, localDay, targetKey, wsIdOf,
408
+ };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+ /* extension-cli.js — `pmos extension`: where the Chrome extension for Google Meet is, and how to load it (initiative c3-meet-extension).
3
+ *
4
+ * pmos extension print the absolute path of workspace/extension and the steps to load it in Chrome
5
+ *
6
+ * The extension is a folder of plain files that ships in the workspace package. Chrome loads it unpacked, so nothing is installed or written here.
7
+ * It reads nothing and starts nothing; it does not need a running server. */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const COMMANDS = { extension: [] };
12
+ const FOLDER = path.resolve(__dirname, '..', 'extension');
13
+ const USAGE = ['usage: pmos extension — the Chrome extension that shows the meeting companion over a Google Meet call'];
14
+
15
+ /** The lines `pmos extension` prints for a folder. Pure. */
16
+ function lines(folder, exists = true) {
17
+ const out = [];
18
+ if (!exists) { out.push(`✗ the extension folder is not here: ${folder}`, ' Reinstall the workspace with `pmos upgrade`.'); return out; }
19
+ out.push('PMOS for Google Meet (Chrome)', '', ` folder ${folder}`, '',
20
+ 'Load it in three steps:',
21
+ ' 1. In Chrome, open chrome://extensions',
22
+ ' 2. Turn on Developer mode (top right)',
23
+ ' 3. Press Load unpacked, then pick the folder above',
24
+ '',
25
+ 'PMOS is looked for at http://localhost:7433. If pmos printed another port, open the extension\'s Options and set that address.',
26
+ 'Then open a Google Meet call: the panel shows at the bottom right.');
27
+ return out;
28
+ }
29
+
30
+ function run(argv, { say = console.log } = {}) {
31
+ const rest = (argv || []).slice(1);
32
+ if (rest.includes('--help') || rest.includes('-h') || rest[0] === 'help') { for (const l of USAGE) say(l); return 0; }
33
+ if (rest.length) { say(`✗ pmos extension takes no arguments (got "${rest[0]}")`); for (const l of USAGE) say(l); return 2; }
34
+ const exists = fs.existsSync(path.join(FOLDER, 'manifest.json'));
35
+ for (const l of lines(FOLDER, exists)) say(l);
36
+ return exists ? 0 : 1;
37
+ }
38
+
39
+ module.exports = { run, lines, COMMANDS, USAGE, FOLDER };
@@ -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' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 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
  * logLines(result, exitCode) → the lines the read-only log pane shows at the end
@@ -14,6 +14,12 @@
14
14
  * It cannot ask for permission, so each kind gets a FIXED tool list here; a connector tool runs only where the
15
15
  * PM's own Claude Code settings already allow it, and anything else is denied and reported. No bypass flag is
16
16
  * ever passed. `PMOS_HEADLESS=off` sends every job back to a pane.
17
+ *
18
+ * The `drive` kind is the Initiative Driver (initiative initiative-driver): origin `drive:<kind>:<id>`. It reads, and it files
19
+ * proposals with `pmos driver propose`. It has no Write, no Edit, no `git push`, no web fetch and no connector write tool: no
20
+ * connector tool is named in its list, so a connector read runs only where the PM's own Claude Code settings already allow it,
21
+ * and a connector write is denied and reported like any other tool outside the list. The PM-only `pmos driver` verbs refuse an
22
+ * agent session by themselves. The driver never runs in a pane (server.js refuses to start it when PMOS_HEADLESS=off).
17
23
  */
18
24
 
19
25
  // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
@@ -25,12 +31,16 @@ const HEADLESS_SKILLS = Object.freeze([
25
31
  const HEADLESS_ORIGINS = Object.freeze(['notes-bullets:', 'notes-summary:']);
26
32
  // page-starts-agent: Ask the agent on a document. The run reads and answers with the revised text; it writes nothing.
27
33
  const ASK_ORIGINS = Object.freeze(['doc-ask:', 'focus-companion:']); // focus-companion: the Meeting focus page's quiet companion and its private questions (read-only, nothing sent)
34
+ // initiative-driver: one short run for one initiative or one Work task. Its origin is `drive:<kind>:<id>`.
35
+ const DRIVE_ORIGINS = Object.freeze(['drive:']);
28
36
 
29
37
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
30
38
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
31
39
  const TOOLS = Object.freeze({
32
40
  notes: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(mv *)']),
33
41
  ask: Object.freeze(['Read', 'Glob', 'Grep']),
42
+ drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
43
+ 'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
34
44
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
35
45
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
36
46
  });
@@ -46,6 +56,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
46
56
  const o = String(origin || '');
47
57
  if (HEADLESS_ORIGINS.some((p) => o.startsWith(p))) return 'notes';
48
58
  if (ASK_ORIGINS.some((p) => o.startsWith(p))) return 'ask';
59
+ if (DRIVE_ORIGINS.some((p) => o.startsWith(p))) return 'drive';
49
60
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
50
61
  return null;
51
62
  }
@@ -96,4 +107,4 @@ function logLines(r, exitCode) {
96
107
  return out;
97
108
  }
98
109
 
99
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
110
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
@@ -9,6 +9,7 @@
9
9
  * foldState(lines, { topics, startMs, nowMs }) → the page's state the append-only focus/<id>.jsonl folded
10
10
  * outLine(parsed, at) · moveLine(...) → the lines the state file takes
11
11
  * shouldRun(now, state, note, opts) → { run, reason } the rules of when the companion may run
12
+ * pickNow(events, { now, meet, title }) → { ok, event, date, by } | { ok:false, status:404, error, choices } which meeting a Google Meet call is (the Chrome extension, c3-meet-extension)
12
13
  *
13
14
  * Nothing here reads a file or starts a process; lib/routes-meeting-focus.js does both.
14
15
  *
@@ -289,4 +290,50 @@ function shouldRun(now, state, note, opts = {}) {
289
290
  return { run: true, reason: 'new lines' };
290
291
  }
291
292
 
292
- module.exports = { CATS, REF_TYPES, MIN_GAP_MS, TAIL_MINUTES, TAIL_MAX, topicsFrom, tailOf, capturedFrom, companionPrompt, parseCompanion, foldState, outLine, moveLine, shouldRun, norm, str, off };
293
+ /* ── which meeting is this Google Meet call (c3-meet-extension) ───────────────────────────────────────────────────── */
294
+
295
+ const MEET_CODE = /\b[a-z]{3}-[a-z]{4}-[a-z]{3}\b/i;
296
+ const NOW_SLACK_MS = 10 * 60000; // a call may start 10 minutes early and run 10 minutes over
297
+ const DEFAULT_LEN_MS = 60 * 60000;
298
+ const CHOICES_MAX = 6;
299
+ const localDayOf = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
300
+ const startOf = (e) => Date.parse(e && (e.at || e.start));
301
+ const endOf = (e) => { const s = startOf(e); const t = Date.parse(e && e.end); return Number.isFinite(t) && t > s ? t : s + DEFAULT_LEN_MS; };
302
+ /** Every text of an event that may carry the Meet address: its links, and the fields a calendar reader may fill. */
303
+ const meetTextsOf = (e) => [].concat((Array.isArray(e.links) ? e.links : []).map((l) => (l && typeof l === 'object' ? l.url : l)), e.hangoutLink, e.hangout_link, e.meet_url, e.conference, e.location)
304
+ .filter((x) => typeof x === 'string').map((x) => x.toLowerCase());
305
+ /** The tab title of a Meet call without its "Meet - " lead, in a form two titles can be compared in. A bare Meet code is no title. */
306
+ function meetTitle(title) {
307
+ const raw = String(title == null ? '' : title).replace(/^\s*meet\s*[-\u2013\u2014]\s*/i, '').trim();
308
+ return /^[a-z]{3}-[a-z]{4}-[a-z]{3}$/i.test(raw) ? '' : norm(raw);
309
+ }
310
+ const titleFits = (a, b) => !!a && !!b && (a === b || (Math.min(a.length, b.length) >= 4 && (a.includes(b) || b.includes(a))));
311
+
312
+ /**
313
+ * Which meeting on the calendar is the Google Meet call the browser shows. In order: the meeting whose links carry the Meet code, the meeting whose
314
+ * title is the tab's title, the meeting under way now (from 10 minutes before the start to 10 minutes after the end; of several, the one that
315
+ * started last). Only today's meetings, and any meeting under way now, are looked at. Several that fit one rule: the one under way, else the nearest start.
316
+ * When none fits: { ok:false, status:404, error, choices } with today's meetings nearest now (at most 6, in time order). Pure.
317
+ */
318
+ function pickNow(events, { now = Date.now(), meet = '', title = '' } = {}) {
319
+ const day = localDayOf(now);
320
+ const pool = (events || []).filter((e) => e && e.id && Number.isFinite(startOf(e)) && (localDayOf(startOf(e)) === day || (startOf(e) <= now && now <= endOf(e))));
321
+ const near = (e) => (now < startOf(e) ? startOf(e) - now : now > endOf(e) ? now - endOf(e) : 0);
322
+ const best = (list) => list.slice().sort((a, b) => near(a) - near(b) || startOf(b) - startOf(a))[0] || null;
323
+ const hit = (e, by) => ({ ok: true, by, date: String(e.at || e.start || '').slice(0, 10), event: { id: e.id, title: e.title || '', at: e.at || e.start, end: e.end || null } });
324
+ const code = (String(meet || '').match(MEET_CODE) || [''])[0].toLowerCase();
325
+ if (code) { const e = best(pool.filter((x) => meetTextsOf(x).some((t) => t.includes(code)))); if (e) return hit(e, 'meet'); }
326
+ const want = meetTitle(title);
327
+ if (want) {
328
+ const exact = pool.filter((x) => norm(x.title) === want);
329
+ const e = best(exact.length ? exact : pool.filter((x) => titleFits(norm(x.title), want)));
330
+ if (e) return hit(e, 'title');
331
+ }
332
+ const under = pool.filter((e) => startOf(e) - NOW_SLACK_MS <= now && now <= endOf(e) + NOW_SLACK_MS);
333
+ if (under.length) return hit(under.slice().sort((a, b) => startOf(b) - startOf(a))[0], 'time');
334
+ const choices = pool.slice().sort((a, b) => near(a) - near(b) || startOf(a) - startOf(b)).slice(0, CHOICES_MAX).sort((a, b) => startOf(a) - startOf(b))
335
+ .map((e) => ({ id: e.id, title: e.title || '', at: e.at || e.start, end: e.end || null }));
336
+ return { ok: false, status: 404, error: 'No meeting on the calendar fits this call.', choices };
337
+ }
338
+
339
+ module.exports = { CATS, REF_TYPES, MIN_GAP_MS, TAIL_MINUTES, TAIL_MAX, topicsFrom, tailOf, capturedFrom, companionPrompt, parseCompanion, foldState, outLine, moveLine, shouldRun, pickNow, meetTitle, norm, str, off };
@@ -33,6 +33,7 @@ const PROVIDERS = Object.freeze([
33
33
  'initiative-needs', // Run 2 (2C): day one, agent proposals, passed check-by (kind 7); a record with no anchor (kind 8)
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
+ 'driver-needs', // initiative-driver: drafts the agent filed (kind 7), a target the PM is blocking (kind 7)
36
37
  ]);
37
38
  /** Kinds that already mean something in the needs queue. A provider may not reuse one. */
38
39
  const RESERVED_KINDS = Object.freeze(['question', 'accept', 'outcome', 'keep', 'evidence', 'plan-flag', 'provider-error']);
@@ -53,6 +53,12 @@ const VERBS = Object.freeze({
53
53
  // Not a pm-os-absorb verb (run: null): tracker-read-freshness added it through the same table.
54
54
  tracker: Object.freeze({ module: 'tracker-cli.js', run: null, guide: 'data-freshness.md',
55
55
  help: 'pmos tracker done|status — record when the tracker-refresh skill finished a read of this repository (pmos tracker help)' }),
56
+ // Not a pm-os-absorb verb (run: null): c3-meet-extension added it through the same table.
57
+ extension: Object.freeze({ module: 'extension-cli.js', run: null, guide: 'console-calendar.md',
58
+ help: 'pmos extension — the folder of the Chrome extension for Google Meet, and the three steps to load it' }),
59
+ // Not a pm-os-absorb verb (run: null): initiative initiative-driver added it through the same table.
60
+ driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
61
+ help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
56
62
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
57
63
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
58
64
  });
@@ -68,6 +68,7 @@ const MODULES = Object.freeze([
68
68
  'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
69
69
  'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
70
70
  'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
71
+ 'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
71
72
  ]);
72
73
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
73
74
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -776,6 +776,16 @@ async function handle(req, res, url, ctx) {
776
776
  return true;
777
777
  }
778
778
 
779
+ /** The PM's move of one initiative, by the same function POST /api/boards/move runs (initiative initiative-driver: Keep on a
780
+ * driver proposal). `g` is the guard's answer ({ ok, actor }); nothing is chosen by the caller. Answers { status, body } with the
781
+ * body the route would send ({ ok, row, warnings } or { ok: false, error }); the route's validation and warnings apply. */
782
+ function move(ctx, body, g) {
783
+ let out = { status: 500, body: { ok: false, error: 'the move was not answered' } };
784
+ const cap = { ...ctx, json: (_res, obj, code) => { out = { status: code || 200, body: obj }; } };
785
+ WRITES['/api/boards/move'](cap, null, body, g);
786
+ return out;
787
+ }
788
+
779
789
  /** The board of each initiative of a workspace, for other routes (Reports): { boards: [{ id, title }], of(id) → board id }.
780
790
  * Rows still being read fall back to the ledger's assignment, else the default board. */
781
791
  function boardIndex(ctx, w) {
@@ -836,4 +846,4 @@ function meetingBoards(ctx) {
836
846
  return { list, detail };
837
847
  }
838
848
 
839
- module.exports = { prefixes: ['/api/boards'], handle, boardIndex, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
849
+ module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };