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.
- 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/c3-driver-fixture.js +61 -0
- package/workspace/c3-driver-test.js +164 -0
- 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 +87 -0
- 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 +167 -0
- package/workspace/lib/routes-meeting-focus.js +17 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-brief-needs.js +19 -3
- package/workspace/public/pm/c3-demo-focus.js +12 -2
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-driver-work.js +110 -0
- package/workspace/public/pm/c3-driver.css +18 -0
- package/workspace/public/pm/c3-driver.js +298 -0
- package/workspace/public/pm/c3-focus.css +31 -0
- package/workspace/public/pm/c3-focus.js +122 -21
- package/workspace/public/pm/c3-ii-tabs.js +3 -2
- package/workspace/public/pm/c3-ini-ui.js +4 -2
- package/workspace/public/pm/c3-initiatives.js +14 -0
- package/workspace/public/pm/c3-shell.js +2 -2
- package/workspace/public/pm/pm-work-view.js +12 -2
- package/workspace/server.js +21 -1
- package/workspace/skills/initiative-drive.skill +50 -0
- package/workspace/skills/work-unblock.skill +42 -0
- package/workspace/test-files.json +6 -0
- 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
|
-
|
|
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 };
|