create-pmos 0.167.0 → 0.168.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/extension/README.md +33 -0
  7. package/workspace/extension/address.js +20 -0
  8. package/workspace/extension/content.js +76 -0
  9. package/workspace/extension/icons/icon-128.png +0 -0
  10. package/workspace/extension/icons/icon-16.png +0 -0
  11. package/workspace/extension/icons/icon-32.png +0 -0
  12. package/workspace/extension/icons/icon-48.png +0 -0
  13. package/workspace/extension/manifest.json +15 -0
  14. package/workspace/extension/options.css +33 -0
  15. package/workspace/extension/options.html +26 -0
  16. package/workspace/extension/options.js +24 -0
  17. package/workspace/extension/panel.css +21 -0
  18. package/workspace/extension/panel.html +14 -0
  19. package/workspace/extension/panel.js +67 -0
  20. package/workspace/guide/console-calendar.md +14 -0
  21. package/workspace/guide/index.md +1 -0
  22. package/workspace/guide/initiative-driver.md +79 -0
  23. package/workspace/lib/driver-cli.js +207 -0
  24. package/workspace/lib/driver-decide.js +118 -0
  25. package/workspace/lib/driver-facts.js +113 -0
  26. package/workspace/lib/driver-needs.js +107 -0
  27. package/workspace/lib/driver-run.js +187 -0
  28. package/workspace/lib/driver.js +408 -0
  29. package/workspace/lib/extension-cli.js +39 -0
  30. package/workspace/lib/headless.js +13 -2
  31. package/workspace/lib/meeting-focus.js +48 -1
  32. package/workspace/lib/needs-providers.js +1 -0
  33. package/workspace/lib/pmos-verbs.js +6 -0
  34. package/workspace/lib/route-modules.js +1 -0
  35. package/workspace/lib/routes-boards.js +11 -1
  36. package/workspace/lib/routes-driver.js +135 -0
  37. package/workspace/lib/routes-meeting-focus.js +17 -1
  38. package/workspace/package-lock.json +2 -2
  39. package/workspace/package.json +1 -1
  40. package/workspace/public/pm/c3-demo-focus.js +12 -2
  41. package/workspace/public/pm/c3-demo.js +1 -1
  42. package/workspace/public/pm/c3-focus.css +31 -0
  43. package/workspace/public/pm/c3-focus.js +122 -21
  44. package/workspace/public/pm/c3-shell.js +1 -1
  45. package/workspace/server.js +21 -1
  46. package/workspace/skills/initiative-drive.skill +50 -0
  47. package/workspace/skills/work-unblock.skill +42 -0
  48. package/workspace/test-files.json +4 -0
  49. package/workspace/test-lists/c3-meeting-focus.json +13 -4
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * logLines(result, exitCode) → the lines the read-only log pane shows at the end
@@ -14,6 +14,12 @@
14
14
  * It cannot ask for permission, so each kind gets a FIXED tool list here; a connector tool runs only where the
15
15
  * PM's own Claude Code settings already allow it, and anything else is denied and reported. No bypass flag is
16
16
  * ever passed. `PMOS_HEADLESS=off` sends every job back to a pane.
17
+ *
18
+ * The `drive` kind is the Initiative Driver (initiative initiative-driver): origin `drive:<kind>:<id>`. It reads, and it files
19
+ * proposals with `pmos driver propose`. It has no Write, no Edit, no `git push`, no web fetch and no connector write tool: no
20
+ * connector tool is named in its list, so a connector read runs only where the PM's own Claude Code settings already allow it,
21
+ * and a connector write is denied and reported like any other tool outside the list. The PM-only `pmos driver` verbs refuse an
22
+ * agent session by themselves. The driver never runs in a pane (server.js refuses to start it when PMOS_HEADLESS=off).
17
23
  */
18
24
 
19
25
  // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
@@ -25,12 +31,16 @@ const HEADLESS_SKILLS = Object.freeze([
25
31
  const HEADLESS_ORIGINS = Object.freeze(['notes-bullets:', 'notes-summary:']);
26
32
  // page-starts-agent: Ask the agent on a document. The run reads and answers with the revised text; it writes nothing.
27
33
  const ASK_ORIGINS = Object.freeze(['doc-ask:', 'focus-companion:']); // focus-companion: the Meeting focus page's quiet companion and its private questions (read-only, nothing sent)
34
+ // initiative-driver: one short run for one initiative or one Work task. Its origin is `drive:<kind>:<id>`.
35
+ const DRIVE_ORIGINS = Object.freeze(['drive:']);
28
36
 
29
37
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
30
38
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
31
39
  const TOOLS = Object.freeze({
32
40
  notes: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(mv *)']),
33
41
  ask: Object.freeze(['Read', 'Glob', 'Grep']),
42
+ drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
43
+ 'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
34
44
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
35
45
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
36
46
  });
@@ -46,6 +56,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
46
56
  const o = String(origin || '');
47
57
  if (HEADLESS_ORIGINS.some((p) => o.startsWith(p))) return 'notes';
48
58
  if (ASK_ORIGINS.some((p) => o.startsWith(p))) return 'ask';
59
+ if (DRIVE_ORIGINS.some((p) => o.startsWith(p))) return 'drive';
49
60
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
50
61
  return null;
51
62
  }
@@ -96,4 +107,4 @@ function logLines(r, exitCode) {
96
107
  return out;
97
108
  }
98
109
 
99
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
110
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
@@ -9,6 +9,7 @@
9
9
  * foldState(lines, { topics, startMs, nowMs }) → the page's state the append-only focus/<id>.jsonl folded
10
10
  * outLine(parsed, at) · moveLine(...) → the lines the state file takes
11
11
  * shouldRun(now, state, note, opts) → { run, reason } the rules of when the companion may run
12
+ * pickNow(events, { now, meet, title }) → { ok, event, date, by } | { ok:false, status:404, error, choices } which meeting a Google Meet call is (the Chrome extension, c3-meet-extension)
12
13
  *
13
14
  * Nothing here reads a file or starts a process; lib/routes-meeting-focus.js does both.
14
15
  *
@@ -289,4 +290,50 @@ function shouldRun(now, state, note, opts = {}) {
289
290
  return { run: true, reason: 'new lines' };
290
291
  }
291
292
 
292
- module.exports = { CATS, REF_TYPES, MIN_GAP_MS, TAIL_MINUTES, TAIL_MAX, topicsFrom, tailOf, capturedFrom, companionPrompt, parseCompanion, foldState, outLine, moveLine, shouldRun, norm, str, off };
293
+ /* ── which meeting is this Google Meet call (c3-meet-extension) ───────────────────────────────────────────────────── */
294
+
295
+ const MEET_CODE = /\b[a-z]{3}-[a-z]{4}-[a-z]{3}\b/i;
296
+ const NOW_SLACK_MS = 10 * 60000; // a call may start 10 minutes early and run 10 minutes over
297
+ const DEFAULT_LEN_MS = 60 * 60000;
298
+ const CHOICES_MAX = 6;
299
+ const localDayOf = (ms) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
300
+ const startOf = (e) => Date.parse(e && (e.at || e.start));
301
+ const endOf = (e) => { const s = startOf(e); const t = Date.parse(e && e.end); return Number.isFinite(t) && t > s ? t : s + DEFAULT_LEN_MS; };
302
+ /** Every text of an event that may carry the Meet address: its links, and the fields a calendar reader may fill. */
303
+ const meetTextsOf = (e) => [].concat((Array.isArray(e.links) ? e.links : []).map((l) => (l && typeof l === 'object' ? l.url : l)), e.hangoutLink, e.hangout_link, e.meet_url, e.conference, e.location)
304
+ .filter((x) => typeof x === 'string').map((x) => x.toLowerCase());
305
+ /** The tab title of a Meet call without its "Meet - " lead, in a form two titles can be compared in. A bare Meet code is no title. */
306
+ function meetTitle(title) {
307
+ const raw = String(title == null ? '' : title).replace(/^\s*meet\s*[-\u2013\u2014]\s*/i, '').trim();
308
+ return /^[a-z]{3}-[a-z]{4}-[a-z]{3}$/i.test(raw) ? '' : norm(raw);
309
+ }
310
+ const titleFits = (a, b) => !!a && !!b && (a === b || (Math.min(a.length, b.length) >= 4 && (a.includes(b) || b.includes(a))));
311
+
312
+ /**
313
+ * Which meeting on the calendar is the Google Meet call the browser shows. In order: the meeting whose links carry the Meet code, the meeting whose
314
+ * title is the tab's title, the meeting under way now (from 10 minutes before the start to 10 minutes after the end; of several, the one that
315
+ * started last). Only today's meetings, and any meeting under way now, are looked at. Several that fit one rule: the one under way, else the nearest start.
316
+ * When none fits: { ok:false, status:404, error, choices } with today's meetings nearest now (at most 6, in time order). Pure.
317
+ */
318
+ function pickNow(events, { now = Date.now(), meet = '', title = '' } = {}) {
319
+ const day = localDayOf(now);
320
+ const pool = (events || []).filter((e) => e && e.id && Number.isFinite(startOf(e)) && (localDayOf(startOf(e)) === day || (startOf(e) <= now && now <= endOf(e))));
321
+ const near = (e) => (now < startOf(e) ? startOf(e) - now : now > endOf(e) ? now - endOf(e) : 0);
322
+ const best = (list) => list.slice().sort((a, b) => near(a) - near(b) || startOf(b) - startOf(a))[0] || null;
323
+ const hit = (e, by) => ({ ok: true, by, date: String(e.at || e.start || '').slice(0, 10), event: { id: e.id, title: e.title || '', at: e.at || e.start, end: e.end || null } });
324
+ const code = (String(meet || '').match(MEET_CODE) || [''])[0].toLowerCase();
325
+ if (code) { const e = best(pool.filter((x) => meetTextsOf(x).some((t) => t.includes(code)))); if (e) return hit(e, 'meet'); }
326
+ const want = meetTitle(title);
327
+ if (want) {
328
+ const exact = pool.filter((x) => norm(x.title) === want);
329
+ const e = best(exact.length ? exact : pool.filter((x) => titleFits(norm(x.title), want)));
330
+ if (e) return hit(e, 'title');
331
+ }
332
+ const under = pool.filter((e) => startOf(e) - NOW_SLACK_MS <= now && now <= endOf(e) + NOW_SLACK_MS);
333
+ if (under.length) return hit(under.slice().sort((a, b) => startOf(b) - startOf(a))[0], 'time');
334
+ const choices = pool.slice().sort((a, b) => near(a) - near(b) || startOf(a) - startOf(b)).slice(0, CHOICES_MAX).sort((a, b) => startOf(a) - startOf(b))
335
+ .map((e) => ({ id: e.id, title: e.title || '', at: e.at || e.start, end: e.end || null }));
336
+ return { ok: false, status: 404, error: 'No meeting on the calendar fits this call.', choices };
337
+ }
338
+
339
+ module.exports = { CATS, REF_TYPES, MIN_GAP_MS, TAIL_MINUTES, TAIL_MAX, topicsFrom, tailOf, capturedFrom, companionPrompt, parseCompanion, foldState, outLine, moveLine, shouldRun, pickNow, meetTitle, norm, str, off };
@@ -33,6 +33,7 @@ const PROVIDERS = Object.freeze([
33
33
  'initiative-needs', // Run 2 (2C): day one, agent proposals, passed check-by (kind 7); a record with no anchor (kind 8)
34
34
  'work-check-needs', // Run 4 (4D): a person's item in Review, a batched Release (kind 4)
35
35
  'report-needs', // Run 5 (5C): a report to issue (kind 1), cycle rows (kind 7), a schedule left no output (kind 8)
36
+ 'driver-needs', // initiative-driver: drafts the agent filed (kind 7), a target the PM is blocking (kind 7)
36
37
  ]);
37
38
  /** Kinds that already mean something in the needs queue. A provider may not reuse one. */
38
39
  const RESERVED_KINDS = Object.freeze(['question', 'accept', 'outcome', 'keep', 'evidence', 'plan-flag', 'provider-error']);
@@ -53,6 +53,12 @@ const VERBS = Object.freeze({
53
53
  // Not a pm-os-absorb verb (run: null): tracker-read-freshness added it through the same table.
54
54
  tracker: Object.freeze({ module: 'tracker-cli.js', run: null, guide: 'data-freshness.md',
55
55
  help: 'pmos tracker done|status — record when the tracker-refresh skill finished a read of this repository (pmos tracker help)' }),
56
+ // Not a pm-os-absorb verb (run: null): c3-meet-extension added it through the same table.
57
+ extension: Object.freeze({ module: 'extension-cli.js', run: null, guide: 'console-calendar.md',
58
+ help: 'pmos extension — the folder of the Chrome extension for Google Meet, and the three steps to load it' }),
59
+ // Not a pm-os-absorb verb (run: null): initiative initiative-driver added it through the same table.
60
+ driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
61
+ help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
56
62
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
57
63
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
58
64
  });
@@ -68,6 +68,7 @@ const MODULES = Object.freeze([
68
68
  'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
69
69
  'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
70
70
  'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
71
+ 'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
71
72
  ]);
72
73
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
73
74
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -776,6 +776,16 @@ async function handle(req, res, url, ctx) {
776
776
  return true;
777
777
  }
778
778
 
779
+ /** The PM's move of one initiative, by the same function POST /api/boards/move runs (initiative initiative-driver: Keep on a
780
+ * driver proposal). `g` is the guard's answer ({ ok, actor }); nothing is chosen by the caller. Answers { status, body } with the
781
+ * body the route would send ({ ok, row, warnings } or { ok: false, error }); the route's validation and warnings apply. */
782
+ function move(ctx, body, g) {
783
+ let out = { status: 500, body: { ok: false, error: 'the move was not answered' } };
784
+ const cap = { ...ctx, json: (_res, obj, code) => { out = { status: code || 200, body: obj }; } };
785
+ WRITES['/api/boards/move'](cap, null, body, g);
786
+ return out;
787
+ }
788
+
779
789
  /** The board of each initiative of a workspace, for other routes (Reports): { boards: [{ id, title }], of(id) → board id }.
780
790
  * Rows still being read fall back to the ledger's assignment, else the default board. */
781
791
  function boardIndex(ctx, w) {
@@ -836,4 +846,4 @@ function meetingBoards(ctx) {
836
846
  return { list, detail };
837
847
  }
838
848
 
839
- module.exports = { prefixes: ['/api/boards'], handle, boardIndex, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
849
+ module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+ /* routes-driver.js — the Initiative Driver's routes (initiative initiative-driver, PRD sections 4 and 5).
3
+ *
4
+ * GET /api/driver/strip?ws&target_kind&id → { ok, ws, target, mode, board, paused, budget, running, last, blocker, proposals }
5
+ * POST /api/driver/keep { ws, proposal } → { ok, proposal, wrote, warnings }
6
+ * POST /api/driver/return { ws, proposal, note } → { ok, proposal } the note is required; it wakes a run
7
+ * POST /api/driver/drop { ws, proposal, note? } → { ok, proposal }
8
+ * POST /api/driver/pause { ws, target_kind, id } → { ok, paused: true, target }
9
+ * POST /api/driver/resume { ws, target_kind, id } → { ok, paused: false, target }
10
+ * POST /api/driver/mode { ws, board?, mode } → { ok, board, mode } board omitted: every board
11
+ *
12
+ * The strip is the data of the Agent strip on an initiative's Overview and of the one-line version on a Work task. Its
13
+ * `proposals` are the open ones; `by` is the page name (`agent:sess-<n>`), never the session token. `running` is a run that has
14
+ * started and not ended; `last` is the most recent run that ended; `blocker` is the sweep's latest finding of today.
15
+ *
16
+ * WHO WRITES. Every POST passes pm-guard requirePagePress, and a request that carries an agent session header is refused
17
+ * first. `by` of every line is the guard's actor. Keep writes through the existing writers (lib/driver-decide.js): a problem
18
+ * into the stage tools, a move through the board move route (lib/routes-boards.js move), a Work next step into the Work items.
19
+ * State: <state folder>/driver.jsonl, one per repository (lib/driver.js). Nothing here sends anything outward.
20
+ */
21
+ const path = require('path');
22
+ const guard = require('./pm-guard.js');
23
+ const actorLib = require('./actor.js');
24
+ const D = require('./driver.js');
25
+ const decideLib = require('./driver-decide.js');
26
+ const stageTools = require('./stage-tools.js');
27
+
28
+ const text = (v) => (typeof v === 'string' ? v : '');
29
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
30
+ const POSTS = ['keep', 'return', 'drop', 'pause', 'resume', 'mode'];
31
+
32
+ function wsOf(ctx, id) {
33
+ const w = ctx.getWs(id || '');
34
+ return w && (!id || w.id === id) ? w : null;
35
+ }
36
+ function ledgerOf(ctx, w) {
37
+ const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
38
+ return D.ledgerFile(d);
39
+ }
40
+ const runtimeOf = (ctx) => ctx.runtimeDir || require('./state-root.js').defaultRoot();
41
+
42
+ /** The board a target's mode is read from: the board of its latest run, else Work's own, else every board. */
43
+ function boardOfTarget(state, target) {
44
+ if (target.kind === 'work') return 'work';
45
+ const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === D.targetKey(target) && r.board);
46
+ return runs.length ? runs[runs.length - 1].board : null;
47
+ }
48
+
49
+ function stripOf(state, target, { now = Date.now() } = {}) {
50
+ const key = D.targetKey(target);
51
+ const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === key);
52
+ const running = runs.filter((r) => !r.end).pop() || null;
53
+ const ended = runs.filter((r) => r.end).pop() || null;
54
+ const board = boardOfTarget(state, target);
55
+ const day = D.localDay(now);
56
+ const blocker = state.blockers.filter((b) => b.target && D.targetKey(b.target) === key && b.day === day).pop() || null;
57
+ const open = D.openProposals(state, target);
58
+ return {
59
+ target, board, mode: D.modeOf(state, board), paused: D.isPaused(state, target),
60
+ budget: { used: D.runsOnDay(state, target, day), limit: target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay, concurrent: D.BUDGET.concurrent },
61
+ running: running ? { run: running.run, since: running.start } : null,
62
+ last: ended ? { run: ended.run, at: ended.end, ok: ended.ok, reason: ended.reason, proposals: [...state.proposals.values()].filter((p) => p.run === ended.run).length } : null,
63
+ blocker: blocker ? { code: blocker.code, text: blocker.text, owner: blocker.owner, day: blocker.day } : null,
64
+ proposals: open.map((p) => ({ id: p.id, what: p.what, data: p.data, why: p.why, sources: p.sources, run: p.run, by: actorLib.pageName(p.by), at: p.at })),
65
+ };
66
+ }
67
+
68
+ async function handle(req, res, url, ctx) {
69
+ const p = url.pathname;
70
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
71
+ const sub = p.replace(/^\/api\/driver\/?/, '');
72
+ if (sub !== 'strip' && !POSTS.includes(sub)) return send(bad(404, 'no such driver route'));
73
+
74
+ if (sub === 'strip') {
75
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
76
+ const w = wsOf(ctx, url.searchParams.get('ws'));
77
+ if (!w) return send(bad(404, 'no such workspace'));
78
+ const t = D.checkTarget({ kind: text(url.searchParams.get('target_kind')), ws: w.id, id: text(url.searchParams.get('id')) });
79
+ if (!t.ok) return send(bad(400, t.error));
80
+ const file = ledgerOf(ctx, w);
81
+ const state = D.fold(file ? D.readLines(file) : []);
82
+ return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...stripOf(state, t.target) } });
83
+ }
84
+
85
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
86
+ const read = await ctx.readJson(req);
87
+ if (!read.ok) return send(bad(400, 'the body is not JSON'));
88
+ const body = read.body;
89
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot keep, send back, drop, pause or change the mode of the driver'));
90
+ const g = guard.requirePagePress(req, body);
91
+ if (!g.ok) return send(bad(g.status, g.error));
92
+ const w = wsOf(ctx, text(body.ws));
93
+ if (!w) return send(bad(404, 'no such workspace'));
94
+ const file = ledgerOf(ctx, w);
95
+ if (!file) return send(bad(409, 'this repository has no state folder (pmos/state, state or .pmos), so the driver has nowhere to keep its ledger'));
96
+ const refresh = () => { try { if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id); } catch { /* the next poll recounts */ } };
97
+
98
+ if (sub === 'keep' || sub === 'return' || sub === 'drop') {
99
+ const proposal = text(body.proposal);
100
+ if (!D.PROPOSAL_RE.test(proposal)) return send(bad(400, 'proposal must be a driver proposal id'));
101
+ const stateDir = ctx.stateDirFor(w);
102
+ const dctx = {
103
+ stageFile: path.join(stateDir, stageTools.LEDGER),
104
+ itemsDir: path.join(runtimeOf(ctx), 'pm-items'),
105
+ correctionsFile: path.join(runtimeOf(ctx), 'corrections.jsonl'),
106
+ // The board move route's own function: its validation and its warnings apply, and `by` is the PM.
107
+ move: async (pr) => {
108
+ const routesBoards = require('./routes-boards.js');
109
+ let board = pr.data && pr.data.board;
110
+ if (!board) { try { board = routesBoards.boardIndex(ctx, w).of(pr.target.id); } catch { board = null; } }
111
+ return routesBoards.move(ctx, { ws: w.id, board: board || w.id, id: pr.target.id, to: pr.data.to, why: (pr.why || '').slice(0, 500) }, g);
112
+ },
113
+ };
114
+ const r = await decideLib.decide(sub, { file, proposal, note: body.note == null ? undefined : text(body.note), actor: g.actor, ctx: dctx });
115
+ if (!r.ok) return send(bad(r.status, r.error));
116
+ refresh();
117
+ return send({ status: 200, body: { ok: true, proposal: { ...r.proposal, by: actorLib.pageName(r.proposal.by) }, wrote: r.wrote, warnings: r.warnings } });
118
+ }
119
+
120
+ if (sub === 'pause' || sub === 'resume') {
121
+ const t = D.checkTarget({ kind: text(body.target_kind), ws: w.id, id: text(body.id) });
122
+ if (!t.ok) return send(bad(400, t.error));
123
+ const r = decideLib.setPause(file, sub, t.target, g.actor);
124
+ if (!r.ok) return send(bad(r.status, r.error));
125
+ refresh();
126
+ return send({ status: 200, body: { ok: true, paused: r.paused, target: r.target } });
127
+ }
128
+
129
+ const r = decideLib.setMode(file, body.board == null || body.board === '' ? '*' : text(body.board), text(body.mode), g.actor);
130
+ if (!r.ok) return send(bad(r.status, r.error));
131
+ refresh();
132
+ return send({ status: 200, body: { ok: true, board: r.board, mode: r.mode } });
133
+ }
134
+
135
+ module.exports = { prefixes: ['/api/driver'], handle, stripOf, boardOfTarget };
@@ -7,6 +7,10 @@
7
7
  * minutes of that note's transcript with who said them, the decisions, actions and questions so far, and the companion's state.
8
8
  * An unknown meeting is a 404. The read also starts the companion when lib/meeting-focus.js shouldRun says it may: the note
9
9
  * records, new lines arrived, 40 s passed, no run is under way, PMOS_FOCUS_AGENT is not off. The run is fire and forget.
10
+ * GET /api/meeting-focus/now?meet=<abc-defg-hij>&title=<Meet tab title> (c3-meet-extension)
11
+ * → { ok, event: { id, title, at, end }, date, by: 'meet'|'title'|'time' }, or a 404 { ok:false, error, choices:[{ id, title, at, end }] }
12
+ * Which meeting a Google Meet call is: the one whose links carry the Meet code, else the one titled like the tab, else the one under way now
13
+ * (lib/meeting-focus.js pickNow). It reads the same calendar as the read above and writes nothing.
10
14
  * POST /api/meeting-focus/topic { event | note, date?, action: 'set'|'add'|'park', topic } page press; the PM's correction, appended to the state
11
15
  * POST /api/meeting-focus/ask { event, note?, date?, question } page press; one headless read-only run with the PM's question
12
16
  * GET /api/meeting-focus/ask?id= → { ok, id, state: working | answered | failed, error? } the answer itself comes back as a note in GET /api/meeting-focus
@@ -175,6 +179,17 @@ async function read(q, ctx) {
175
179
  } };
176
180
  }
177
181
 
182
+ /* ── GET /api/meeting-focus/now ───────────────────────────────────────────────────────────────────────────────────── */
183
+
184
+ function nowRoute(q, ctx) {
185
+ const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
186
+ let events = [];
187
+ try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: nowMs }); } catch { events = []; }
188
+ const r = F.pickNow(events, { now: nowMs, meet: F.str(q && q.meet, 40), title: F.str(q && q.title, 300) });
189
+ if (!r.ok) return fail(404, r.error, { choices: r.choices });
190
+ return { status: 200, body: { ok: true, event: r.event, date: r.date, by: r.by } };
191
+ }
192
+
178
193
  /* ── POST /api/meeting-focus/topic ────────────────────────────────────────────────────────────────────────────────── */
179
194
 
180
195
  async function topic(req, body, ctx) {
@@ -272,13 +287,14 @@ const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true;
272
287
 
273
288
  module.exports = {
274
289
  prefixes: ['/api/meeting-focus'],
275
- read, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf,
290
+ read, now: nowRoute, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf,
276
291
  handle: async (req, res, url, ctx) => {
277
292
  const p = url.pathname;
278
293
  const wsId = url.searchParams.get('ws');
279
294
  const w = ctx.getWs ? ctx.getWs(wsId) : null;
280
295
  const base = { runtimeDir: ctx.runtimeDir, spawnSession: ctx.spawnSession, w, getWs: ctx.getWs, json: ctx.json };
281
296
  if (req.method === 'GET' && p === '/api/meeting-focus') return respond(ctx, res, await read(Object.fromEntries(url.searchParams), base));
297
+ if (req.method === 'GET' && p === '/api/meeting-focus/now') return respond(ctx, res, nowRoute(Object.fromEntries(url.searchParams), base));
282
298
  if (req.method === 'GET' && p === '/api/meeting-focus/ask') return respond(ctx, res, askState(Object.fromEntries(url.searchParams), base));
283
299
  const fn = req.method === 'POST' && { '/api/meeting-focus/topic': topic, '/api/meeting-focus/ask': ask }[p];
284
300
  if (!fn) return false;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.167.0",
3
+ "version": "0.168.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.167.0",
9
+ "version": "0.168.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.167.0",
3
+ "version": "0.168.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -2,7 +2,8 @@
2
2
  * c3-demo.js passes GET /api/meeting-focus, GET /api/meeting-focus/ask and the two POSTs (topic, ask) here. The content is the approved
3
3
  * mockup's (version 7): the Partner API rate-limit review at 18:24 in, with its six topics (one added from the talk), the gist and its four
4
4
  * context chips, the room's last lines, three PMOS notes and two it is holding, the captured items and a Google Slides deck.
5
- * Whatever meeting the page asks for, the demo answers with this one. A correction (set, add, park) and a question change the answer
5
+ * Whatever meeting the page asks for, the demo answers with this one. GET /api/meeting-focus/now (the compact page of the Chrome extension) names it too;
6
+ * the Meet code NONE_CODE answers "no meeting fits" with three of the day's meetings as choices, so that path can be walked. A correction (set, add, park) and a question change the answer
6
7
  * for the rest of the page's life; nothing is saved and nothing reaches the server. Real mode never loads this file. */
7
8
 
8
9
  const DAY = '2026-10-08';
@@ -91,8 +92,17 @@ function view() {
91
92
  };
92
93
  }
93
94
 
94
- /** GET /api/meeting-focus and GET /api/meeting-focus/ask?id= */
95
+ export const NONE_CODE = 'zzz-zzzz-zzz';
96
+ const CHOICES = [['e1', 'Partner standup', '09:30', '10:00'], ['e2', EVENT.title, '11:00', '12:00'], ['e3', 'Onboarding v2 scope check', '14:00', '14:45']]
97
+ .map(([id, title, from, to]) => ({ id: id + '@' + DAY, title, at: DAY + 'T' + from + ':00', end: DAY + 'T' + to + ':00' }));
98
+
99
+ /** GET /api/meeting-focus, GET /api/meeting-focus/ask?id= and GET /api/meeting-focus/now?meet=&title= */
95
100
  export function focusGet(path, url) {
101
+ if (path === '/api/meeting-focus/now') {
102
+ const q = new URL(url, location.origin).searchParams;
103
+ if (q.get('meet') === NONE_CODE) return { ok: false, status: 404, error: 'No meeting on the calendar fits this call.', choices: CHOICES };
104
+ return { ok: true, event: { id: EVENT.id, title: EVENT.title, at: EVENT.at, end: EVENT.end }, date: DAY, by: q.get('meet') ? 'meet' : 'time' };
105
+ }
96
106
  if (path === '/api/meeting-focus/ask') {
97
107
  const q = new URL(url, location.origin).searchParams;
98
108
  const a = S.asks.find((x) => x.id === q.get('id'));
@@ -52,7 +52,7 @@ function getRoute(path, url) {
52
52
  switch (path) {
53
53
  case '/api/briefing': return D.briefing(url);
54
54
  case '/api/calendar-views': return withCalendarPage(D.calendarViews(url));
55
- case '/api/meeting-focus': case '/api/meeting-focus/ask': return focusGet(path, url);
55
+ case '/api/meeting-focus': case '/api/meeting-focus/ask': case '/api/meeting-focus/now': return focusGet(path, url);
56
56
  case '/api/pm-items': return D.pmItems();
57
57
  case '/api/runs': return D.runs();
58
58
  case '/api/releases': return D.releases();
@@ -167,3 +167,34 @@
167
167
  .c3focus .fc-cx > span { display: grid; gap: 1px; overflow: visible; text-align: left; }
168
168
  .c3focus .fc-cx > span > b { font-weight: 600; }
169
169
  .c3.fc-open { container-type: normal; }
170
+
171
+ /* compact mode (?pop=1, initiative c3-meet-extension): the panel the Chrome extension frames over a Google Meet call. A head, What is being said,
172
+ * the conversation and a foot. Folded to a pill, only the head shows. The look is the mockup's "Over Google Meet" panel. Tokens only. */
173
+ .c3focus.fc-pop { overflow: hidden; }
174
+ .c3focus.fc-pop .fc-page { display: flex; flex-direction: column; gap: 8px; height: 100%; max-width: none; margin: 0; padding: 10px; }
175
+ .c3focus.fc-pop .fc-ph { display: flex; align-items: center; gap: 8px; min-height: 24px; font-size: var(--fs-2); flex: 0 0 auto; }
176
+ .c3focus.fc-pop .fc-ph b { font-size: var(--fs-1); color: var(--acc); }
177
+ .c3focus.fc-pop .fc-ph-topic { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
178
+ .c3focus.fc-pop .fc-ph .mono.dim { font-variant-numeric: tabular-nums; }
179
+ .c3focus.fc-pop .fc-popbody { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-height: 0; overflow: auto; }
180
+ .c3focus.fc-pop .fc-ph .ib { flex: 0 0 auto; width: 28px; height: 28px; min-width: 0; min-height: 0; padding: 0; }
181
+ .c3focus.fc-pop .fc-bandwrap { flex: 0 0 auto; }
182
+ .c3focus.fc-pop .fc-gist { padding: 8px; border-radius: 8px; }
183
+ .c3focus.fc-pop .fc-gist p { font-size: var(--fs-2); }
184
+ .c3focus.fc-pop .fc-gl { display: block; margin: 0 0 2px; }
185
+ .c3focus.fc-pop .fc-cx { padding: 2px 8px; }
186
+ .c3focus.fc-pop .fc-cx > span { display: block; }
187
+ .c3focus.fc-pop .fc-cx > span > span { display: none; }
188
+ .c3focus.fc-pop .fc-cv { display: flex; flex-direction: column; flex: 1 0 auto; min-height: 200px; max-height: none; border: 0; background: none; border-radius: 0; }
189
+ .c3focus.fc-pop .fc-tk { padding: 4px 2px 6px; }
190
+ .c3focus.fc-pop .fc-s2 { flex: 1 1 auto; padding: 4px 2px; }
191
+ .c3focus.fc-pop .fc-in { padding: 6px 2px 0; }
192
+ .c3focus.fc-pop .fc-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex: 0 0 auto; position: sticky; bottom: 0; padding-top: 4px; background: var(--background); font-size: var(--fs-1); }
193
+ .c3focus.fc-pop .fc-popmsg { display: grid; gap: 6px; margin: 0; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); font-size: var(--fs-2); }
194
+ .c3focus.fc-pop .fc-popmsg p { margin: 0; line-height: 1.5; }
195
+ .c3focus.fc-pop .fc-choices { display: grid; gap: 6px; }
196
+ .c3focus.fc-pop .fc-choices button { justify-content: flex-start; text-align: left; white-space: normal; height: auto; }
197
+ .c3focus.fc-pop .fc-choices .mono { margin-right: 4px; }
198
+ .c3focus.fc-pop:not([data-fc-state="ok"]) .fc-cv, .c3focus.fc-pop:not([data-fc-state="ok"]) .fc-foot { display: none; }
199
+ .c3focus.fc-pop.fc-pill .fc-popbody { display: none; }
200
+ .c3focus.fc-pop.fc-pill .fc-page { padding: 6px 10px; justify-content: center; }