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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- 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/extension/README.md +33 -0
- package/workspace/extension/address.js +20 -0
- package/workspace/extension/content.js +76 -0
- package/workspace/extension/icons/icon-128.png +0 -0
- package/workspace/extension/icons/icon-16.png +0 -0
- package/workspace/extension/icons/icon-32.png +0 -0
- package/workspace/extension/icons/icon-48.png +0 -0
- package/workspace/extension/manifest.json +15 -0
- package/workspace/extension/options.css +33 -0
- package/workspace/extension/options.html +26 -0
- package/workspace/extension/options.js +24 -0
- package/workspace/extension/panel.css +21 -0
- package/workspace/extension/panel.html +14 -0
- package/workspace/extension/panel.js +67 -0
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-driver.md +79 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/driver-cli.js +207 -0
- package/workspace/lib/driver-decide.js +118 -0
- package/workspace/lib/driver-facts.js +113 -0
- package/workspace/lib/driver-needs.js +107 -0
- package/workspace/lib/driver-run.js +187 -0
- package/workspace/lib/driver.js +408 -0
- package/workspace/lib/extension-cli.js +39 -0
- package/workspace/lib/headless.js +13 -2
- package/workspace/lib/meeting-focus.js +48 -1
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/pmos-verbs.js +6 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +11 -1
- package/workspace/lib/routes-driver.js +135 -0
- package/workspace/lib/routes-meeting-focus.js +17 -1
- package/workspace/lib/typesafe-settings.js +15 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-focus.js +12 -2
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-focus.css +31 -0
- package/workspace/public/pm/c3-focus.js +122 -21
- package/workspace/public/pm/c3-settings.js +12 -2
- package/workspace/public/pm/c3-shell.js +1 -1
- package/workspace/server.js +32 -1
- package/workspace/skills/initiative-drive.skill +50 -0
- package/workspace/skills/work-unblock.skill +42 -0
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/c3-meeting-focus.json +13 -4
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* driver-run.js — the Initiative Driver's part of the server tick (initiative initiative-driver, PRD sections 1 to 3).
|
|
3
|
+
*
|
|
4
|
+
* create(deps) → { tick(opts), active() }
|
|
5
|
+
*
|
|
6
|
+
* server.js builds one driver at start and calls tick() from the 60-second tick. One tick, per repository (the lead tree of each):
|
|
7
|
+
* 1. writes the ledger's `epoch` on the first scan, so only events AFTER it wake a run (an old initiative is history);
|
|
8
|
+
* 2. closes runs that were alive when the server stopped (`run-end`, reason "interrupted"): they still count against the day;
|
|
9
|
+
* 3. finds the wakes: a `created` placement line (the page, Triage accept and `pmos initiative new` all write one), a board
|
|
10
|
+
* `move` line, a proposal the PM sent back with a note. Each key wakes once (the ledger's `wake` line makes it idempotent);
|
|
11
|
+
* 4. every 30 minutes inside the day-check window, and outside quiet hours, runs the unblock sweep (no model, lib/driver.js);
|
|
12
|
+
* 5. starts the queued runs the rules allow (mode, pause, budget, 3 at once) through spawnSession with origin `drive:<kind>:<id>`
|
|
13
|
+
* and the skill prompt, which lib/headless.js turns into a `claude -p` run with a fixed tool list.
|
|
14
|
+
* `PMOS_DRIVER=off` does none of it. A run ends in a `run-end` line with its reason when it fails or times out.
|
|
15
|
+
*
|
|
16
|
+
* deps: { gather?(w, ctx, opts) → facts answer (tests), workspaces() → [w], isLead(w), stateDirFor(w), ctx (for lib/routes-boards.js), runtimeDir, settings() → proactive settings,
|
|
17
|
+
* inQuiet(now, quiet) → bool, spawn(spec) → session row, promptFor(kind) → skill body | null, boardOf(w, id) → board id | null,
|
|
18
|
+
* homeId() → the workspace id that holds the Work tasks, headlessOff() → bool, env, warn(what, err) }
|
|
19
|
+
* `spawn(spec)` gets { name, ws, origin, initialInput, initiative, onDone }; it returns the session row ({ id, state, headless }).
|
|
20
|
+
*/
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const D = require('./driver.js');
|
|
24
|
+
const facts = require('./driver-facts.js');
|
|
25
|
+
const proactive = require('./proactive.js');
|
|
26
|
+
|
|
27
|
+
const off = (env) => /^(off|0|false|no)$/i.test(String((env || process.env).PMOS_DRIVER || '').trim());
|
|
28
|
+
const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
29
|
+
const dayCheckOpen = (settings, now, inQuiet) => {
|
|
30
|
+
const dc = (settings && settings.dayCheck) || proactive.DEFAULTS.dayCheck;
|
|
31
|
+
const t = hhmm(now);
|
|
32
|
+
return settings && settings.enabled === false ? false : (t >= dc.start && t < dc.end && !inQuiet(now, settings && settings.quiet));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The lines of a plain JSONL file that parse; a missing file is empty. */
|
|
36
|
+
function readJsonl(f) {
|
|
37
|
+
let text = '';
|
|
38
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { return []; }
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { const l = JSON.parse(raw); if (l && typeof l === 'object') out.push(l); } catch { /* torn line */ } }
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function create(deps) {
|
|
45
|
+
const active = new Map(); // run id → { target, ledger, session }
|
|
46
|
+
const lastSweep = new Map(); // ledger file → ms
|
|
47
|
+
const warn = deps.warn || (() => {});
|
|
48
|
+
const gather = deps.gather || facts.gather; // tests pass their own facts
|
|
49
|
+
|
|
50
|
+
function readState(file) { return D.fold(D.readLines(file)); }
|
|
51
|
+
|
|
52
|
+
function describe(w, target, wakeLines, state, factsNow) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
const reasons = [...new Set(wakeLines.map((x) => x.reason))];
|
|
55
|
+
let title = target.id; let stage = null; let facts1 = null;
|
|
56
|
+
if (factsNow) {
|
|
57
|
+
facts1 = target.kind === 'work' ? factsNow.work.find((x) => x.id === target.id) : factsNow.initiatives.find((x) => x.id === target.id);
|
|
58
|
+
if (facts1 && facts1.title) title = facts1.title;
|
|
59
|
+
}
|
|
60
|
+
lines.push(target.kind === 'work'
|
|
61
|
+
? `Target: Work task ${target.id} "${title}" (workspace id ${target.ws}). Skill: work-unblock.`
|
|
62
|
+
: `Target: initiative ${target.id} "${title}" in repository ${w.name || path.basename(w.root)} (workspace id ${target.ws}). Skill: initiative-drive.`);
|
|
63
|
+
lines.push(`Why this run started: ${reasons.join(', ')}.`);
|
|
64
|
+
if (facts1 && target.kind === 'initiative') {
|
|
65
|
+
stage = facts1.cat;
|
|
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
|
+
}
|
|
68
|
+
if (facts1 && target.kind === 'work') {
|
|
69
|
+
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}.`);
|
|
70
|
+
}
|
|
71
|
+
const blockers = wakeLines.filter((x) => x.blocker).map((x) => x.blocker);
|
|
72
|
+
if (blockers.length) lines.push(`Blockers the sweep found: ${[...new Set(blockers)].join(', ')}.`);
|
|
73
|
+
const retNotes = wakeLines.filter((x) => x.reason === 'answered').map((x) => state.proposals.get(String(x.key).replace(/^answered:/, ''))).filter(Boolean);
|
|
74
|
+
for (const p of retNotes) lines.push(`The PM sent back ${p.id} (${p.what}) with this note: "${p.note}". Revise it and file a new proposal.`);
|
|
75
|
+
const mine = [...state.proposals.values()].filter((p) => D.targetKey(p.target) === D.targetKey(target));
|
|
76
|
+
if (mine.length) lines.push(`Proposals so far: ${mine.map((p) => `${p.id} ${p.what} (${p.state})`).join('; ')}. Do not file one again.`);
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function startRun(w, file, group, state, factsNow, now) {
|
|
81
|
+
const kind = group.target.kind;
|
|
82
|
+
const skill = deps.promptFor(kind === 'work' ? 'work-unblock' : 'initiative-drive');
|
|
83
|
+
const runId = D.newId('run');
|
|
84
|
+
const wakeLines = state.wakes.filter((x) => group.wakes.includes(x.key));
|
|
85
|
+
const board = group.board || null;
|
|
86
|
+
if (!skill) {
|
|
87
|
+
D.append(file, { ev: 'run-start', run: runId, target: group.target, board, wakes: group.wakes, session: null }, { now });
|
|
88
|
+
D.append(file, { ev: 'run-end', run: runId, ok: false, reason: `the ${kind === 'work' ? 'work-unblock' : 'initiative-drive'} skill is not installed`, agent_run: agentRun(runId, group.target, false, now) }, { now });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const prompt = `${skill}\n\nThis run:\n${describe(w, group.target, wakeLines, state, factsNow).map((l) => '- ' + l).join('\n')}\n\nFile every result with \`pmos driver propose\`. Send nothing, edit nothing.`;
|
|
92
|
+
let session = null;
|
|
93
|
+
try {
|
|
94
|
+
session = deps.spawn({
|
|
95
|
+
name: `Driver · ${kind} ${group.target.id}`, ws: w.id, origin: `drive:${kind}:${group.target.id}`, initialInput: prompt + '\r',
|
|
96
|
+
initiative: kind === 'initiative' ? group.target.id : undefined,
|
|
97
|
+
onDone: (r) => endRun(file, runId, group.target, r),
|
|
98
|
+
});
|
|
99
|
+
} catch (e) { warn('driver run', e); }
|
|
100
|
+
D.append(file, { ev: 'run-start', run: runId, target: group.target, board, wakes: group.wakes, session: session ? session.id : null }, { now });
|
|
101
|
+
if (!session || session.state === 'exited' || session.headless !== 'drive') {
|
|
102
|
+
// A start that did not become a headless run (no `claude` on the PATH, a refused model choice, a pane because headless
|
|
103
|
+
// is off) is a failed run. It counts against the day, as every failed run does. Never leave a driver in a pane.
|
|
104
|
+
if (session && session.headless !== 'drive' && session.state !== 'exited' && deps.stop) { try { deps.stop(session); } catch { /* best effort */ } }
|
|
105
|
+
D.append(file, { ev: 'run-end', run: runId, ok: false, reason: (session && session.note) || 'the run could not start as a background run', agent_run: agentRun(runId, group.target, false, now) }, { now });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
active.set(runId, { target: group.target, ledger: file, session: session.id });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const agentRun = (runId, target, ok, now) => ({ run_id: runId, initiative_id: target.kind === 'initiative' ? target.id : null, kind: 'driver', target_kind: target.kind,
|
|
112
|
+
ok, human_corrections: 0, builder_model: 'not recorded in the repo', created_at: new Date(now == null ? Date.now() : now).toISOString() });
|
|
113
|
+
|
|
114
|
+
function endRun(file, runId, target, r) {
|
|
115
|
+
active.delete(runId);
|
|
116
|
+
const res = (r && r.result) || {};
|
|
117
|
+
const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
|
|
118
|
+
const reason = ok ? null : (r && r.note) || 'the run failed';
|
|
119
|
+
try {
|
|
120
|
+
D.append(file, { ev: 'run-end', run: runId, ok, ...(reason ? { reason } : {}), ...(res.denied && res.denied.length ? { denied: res.denied } : {}),
|
|
121
|
+
agent_run: agentRun(runId, target, ok) });
|
|
122
|
+
} catch (e) { warn('driver run end', e); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tick({ now = Date.now(), env } = {}) {
|
|
126
|
+
if (off(env || deps.env)) return { off: true };
|
|
127
|
+
const summary = { epoch: 0, woke: 0, swept: 0, started: 0, refused: 0 };
|
|
128
|
+
const leads = [];
|
|
129
|
+
for (const w of deps.workspaces()) {
|
|
130
|
+
let dir = null;
|
|
131
|
+
try { dir = deps.isLead(w) ? deps.stateDirFor(w) : null; } catch { dir = null; }
|
|
132
|
+
if (dir) leads.push({ w, file: D.ledgerFile(dir), dir });
|
|
133
|
+
}
|
|
134
|
+
const settings = deps.settings();
|
|
135
|
+
const home = deps.homeId();
|
|
136
|
+
for (const { w, file, dir } of leads) {
|
|
137
|
+
try {
|
|
138
|
+
let state = readState(file);
|
|
139
|
+
if (!state.epoch) { D.append(file, { ev: 'epoch' }, { now }); state = readState(file); summary.epoch++; }
|
|
140
|
+
// Runs that were alive when the server stopped are over; they keep their place in the day's count.
|
|
141
|
+
for (const r of state.runs.values()) {
|
|
142
|
+
if (!r.end && !active.has(r.run)) D.append(file, { ev: 'run-end', run: r.run, ok: false, reason: 'interrupted: the workspace restarted', agent_run: agentRun(r.run, r.target, false, now) }, { now });
|
|
143
|
+
}
|
|
144
|
+
const boardOf = (id) => { try { return deps.boardOf(w, id); } catch { return null; } };
|
|
145
|
+
let created = [];
|
|
146
|
+
try { created = require('./plan-ledgers.js').readLines(w.root, 'placements').lines.map((x) => x.rec).filter((r) => r && r.ev === 'created'); } catch { created = []; }
|
|
147
|
+
const moved = readJsonl(path.join(dir, 'boards.jsonl')).filter((e) => e.kind === 'move');
|
|
148
|
+
const newWakes = [...D.wakesFromLedgers({ created, moved }, state, { ws: w.id, epoch: state.epoch, boardOf }), ...D.answeredWakes(state, { boardOf })];
|
|
149
|
+
for (const l of newWakes) { D.append(file, l, { now }); summary.woke++; }
|
|
150
|
+
state = readState(file);
|
|
151
|
+
// The unblock sweep: deterministic, every 30 minutes inside the day-check window.
|
|
152
|
+
const due = !lastSweep.has(file) || now - lastSweep.get(file) >= D.SWEEP_EVERY_MS;
|
|
153
|
+
let factsNow = null;
|
|
154
|
+
if (due && dayCheckOpen(settings, now, deps.inQuiet)) {
|
|
155
|
+
const g = gather(w, deps.ctx, { now, includeWork: w.id === home, runtimeDir: deps.runtimeDir, driverState: state });
|
|
156
|
+
if (g.ok) {
|
|
157
|
+
factsNow = g.facts;
|
|
158
|
+
lastSweep.set(file, now);
|
|
159
|
+
const sw = D.sweepLines(g.facts, state, { now, stalledDays: settings.stalledDays });
|
|
160
|
+
for (const l of sw.lines) { D.append(file, l, { now }); if (l.ev === 'wake') summary.swept++; }
|
|
161
|
+
state = readState(file);
|
|
162
|
+
} else if (!g.pending) warn('driver sweep', new Error(g.error));
|
|
163
|
+
}
|
|
164
|
+
// Start what the rules allow.
|
|
165
|
+
if (deps.headlessOff()) continue; // a driver never runs in a pane
|
|
166
|
+
const running = [...active.values()];
|
|
167
|
+
const p = D.plan(state, { now, running: active.size, activeTargets: running.map((r) => D.targetKey(r.target)) });
|
|
168
|
+
for (const r of p.refused) { D.append(file, { ev: 'refused', target: r.target, wakes: r.wakes, reason: r.reason }, { now }); summary.refused++; }
|
|
169
|
+
if (p.start.length && !factsNow) {
|
|
170
|
+
const g = gather(w, deps.ctx, { now, includeWork: w.id === home, runtimeDir: deps.runtimeDir, driverState: state });
|
|
171
|
+
factsNow = g.ok ? g.facts : null;
|
|
172
|
+
}
|
|
173
|
+
let live = active.size;
|
|
174
|
+
for (const group of p.start) {
|
|
175
|
+
if (live >= D.BUDGET.concurrent) break;
|
|
176
|
+
startRun(w, file, group, state, factsNow, now);
|
|
177
|
+
live = active.size; summary.started++;
|
|
178
|
+
}
|
|
179
|
+
} catch (e) { warn('driver tick', e); }
|
|
180
|
+
}
|
|
181
|
+
return summary;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { tick, active: () => [...active.keys()] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { create, off, dayCheckOpen };
|
|
@@ -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 };
|