create-pmos 0.167.0 → 0.169.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-driver-fixture.js +61 -0
  7. package/workspace/c3-driver-test.js +164 -0
  8. package/workspace/extension/README.md +33 -0
  9. package/workspace/extension/address.js +20 -0
  10. package/workspace/extension/content.js +76 -0
  11. package/workspace/extension/icons/icon-128.png +0 -0
  12. package/workspace/extension/icons/icon-16.png +0 -0
  13. package/workspace/extension/icons/icon-32.png +0 -0
  14. package/workspace/extension/icons/icon-48.png +0 -0
  15. package/workspace/extension/manifest.json +15 -0
  16. package/workspace/extension/options.css +33 -0
  17. package/workspace/extension/options.html +26 -0
  18. package/workspace/extension/options.js +24 -0
  19. package/workspace/extension/panel.css +21 -0
  20. package/workspace/extension/panel.html +14 -0
  21. package/workspace/extension/panel.js +67 -0
  22. package/workspace/guide/console-calendar.md +14 -0
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-driver.md +87 -0
  25. package/workspace/lib/driver-cli.js +207 -0
  26. package/workspace/lib/driver-decide.js +118 -0
  27. package/workspace/lib/driver-facts.js +113 -0
  28. package/workspace/lib/driver-needs.js +107 -0
  29. package/workspace/lib/driver-run.js +187 -0
  30. package/workspace/lib/driver.js +408 -0
  31. package/workspace/lib/extension-cli.js +39 -0
  32. package/workspace/lib/headless.js +13 -2
  33. package/workspace/lib/meeting-focus.js +48 -1
  34. package/workspace/lib/needs-providers.js +1 -0
  35. package/workspace/lib/pmos-verbs.js +6 -0
  36. package/workspace/lib/route-modules.js +1 -0
  37. package/workspace/lib/routes-boards.js +11 -1
  38. package/workspace/lib/routes-driver.js +167 -0
  39. package/workspace/lib/routes-meeting-focus.js +17 -1
  40. package/workspace/package-lock.json +2 -2
  41. package/workspace/package.json +1 -1
  42. package/workspace/public/pm/c3-brief-needs.js +19 -3
  43. package/workspace/public/pm/c3-demo-focus.js +12 -2
  44. package/workspace/public/pm/c3-demo.js +1 -1
  45. package/workspace/public/pm/c3-driver-work.js +110 -0
  46. package/workspace/public/pm/c3-driver.css +18 -0
  47. package/workspace/public/pm/c3-driver.js +298 -0
  48. package/workspace/public/pm/c3-focus.css +31 -0
  49. package/workspace/public/pm/c3-focus.js +122 -21
  50. package/workspace/public/pm/c3-ii-tabs.js +3 -2
  51. package/workspace/public/pm/c3-ini-ui.js +4 -2
  52. package/workspace/public/pm/c3-initiatives.js +14 -0
  53. package/workspace/public/pm/c3-shell.js +2 -2
  54. package/workspace/public/pm/pm-work-view.js +12 -2
  55. package/workspace/server.js +21 -1
  56. package/workspace/skills/initiative-drive.skill +50 -0
  57. package/workspace/skills/work-unblock.skill +42 -0
  58. package/workspace/test-files.json +6 -0
  59. package/workspace/test-lists/c3-meeting-focus.json +13 -4
@@ -0,0 +1,167 @@
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[&board] → { ok, ws, target, mode, board, paused, budget, today: [runs of today], 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
+ * GET /api/driver/mode?ws&board → { ok, ws, board, mode } the mode a board runs in (Approve when none is set)
12
+ * GET /api/driver/open?ws&target_kind → { ok, ws, kind, mode, targets: { <id>: [proposal] } } every open proposal of
13
+ * one kind, by target: the Work list draws one agent line per task from this
14
+ *
15
+ * 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
16
+ * `proposals` are the open ones; `by` is the page name (`agent:sess-<n>`), never the session token. `running` is a run that has
17
+ * started and not ended; `last` is the most recent run that ended; `blocker` is the sweep's latest finding of today.
18
+ *
19
+ * WHO WRITES. Every POST passes pm-guard requirePagePress, and a request that carries an agent session header is refused
20
+ * first. `by` of every line is the guard's actor. Keep writes through the existing writers (lib/driver-decide.js): a problem
21
+ * into the stage tools, a move through the board move route (lib/routes-boards.js move), a Work next step into the Work items.
22
+ * State: <state folder>/driver.jsonl, one per repository (lib/driver.js). Nothing here sends anything outward.
23
+ */
24
+ const path = require('path');
25
+ const guard = require('./pm-guard.js');
26
+ const actorLib = require('./actor.js');
27
+ const D = require('./driver.js');
28
+ const decideLib = require('./driver-decide.js');
29
+ const stageTools = require('./stage-tools.js');
30
+
31
+ const text = (v) => (typeof v === 'string' ? v : '');
32
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
33
+ const POSTS = ['keep', 'return', 'drop', 'pause', 'resume', 'mode'];
34
+ const GETS = ['strip', 'mode', 'open'];
35
+ const proposalOut = (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 });
36
+
37
+ function wsOf(ctx, id) {
38
+ const w = ctx.getWs(id || '');
39
+ return w && (!id || w.id === id) ? w : null;
40
+ }
41
+ function ledgerOf(ctx, w) {
42
+ const d = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
43
+ return D.ledgerFile(d);
44
+ }
45
+ const runtimeOf = (ctx) => ctx.runtimeDir || require('./state-root.js').defaultRoot();
46
+
47
+ /** The board a target's mode is read from: the board of its latest run, else Work's own, else every board. */
48
+ function boardOfTarget(state, target) {
49
+ if (target.kind === 'work') return 'work';
50
+ const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === D.targetKey(target) && r.board);
51
+ return runs.length ? runs[runs.length - 1].board : null;
52
+ }
53
+
54
+ function stripOf(state, target, { now = Date.now(), board: asked = null } = {}) {
55
+ const key = D.targetKey(target);
56
+ const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === key);
57
+ const running = runs.filter((r) => !r.end).pop() || null;
58
+ const ended = runs.filter((r) => r.end).pop() || null;
59
+ const board = boardOfTarget(state, target) || asked || null;
60
+ const day = D.localDay(now);
61
+ const blocker = state.blockers.filter((b) => b.target && D.targetKey(b.target) === key && b.day === day).pop() || null;
62
+ const open = D.openProposals(state, target);
63
+ return {
64
+ target, board, mode: D.modeOf(state, board), paused: D.isPaused(state, target),
65
+ budget: { used: D.runsOnDay(state, target, day), limit: target.kind === 'work' ? D.BUDGET.workPerDay : D.BUDGET.initiativePerDay, concurrent: D.BUDGET.concurrent },
66
+ today: runs.filter((r) => r.start && D.localDay(Date.parse(r.start)) === day).map((r) => ({ run: r.run, start: r.start, end: r.end, ok: r.ok, reason: r.reason, proposals: [...state.proposals.values()].filter((p) => p.run === r.run).length })),
67
+ running: running ? { run: running.run, since: running.start } : null,
68
+ 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,
69
+ blocker: blocker ? { code: blocker.code, text: blocker.text, owner: blocker.owner, day: blocker.day } : null,
70
+ proposals: open.map(proposalOut),
71
+ };
72
+ }
73
+
74
+ async function handle(req, res, url, ctx) {
75
+ const p = url.pathname;
76
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
77
+ const sub = p.replace(/^\/api\/driver\/?/, '');
78
+ if (!GETS.includes(sub) && !POSTS.includes(sub)) return send(bad(404, 'no such driver route'));
79
+
80
+ if (sub === 'mode' && req.method === 'GET') {
81
+ const w = wsOf(ctx, url.searchParams.get('ws'));
82
+ if (!w) return send(bad(404, 'no such workspace'));
83
+ const board = text(url.searchParams.get('board'));
84
+ if (!board || !D.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
85
+ const file = ledgerOf(ctx, w);
86
+ const state = D.fold(file ? D.readLines(file) : []);
87
+ return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, board, mode: D.modeOf(state, board) } });
88
+ }
89
+
90
+ if (sub === 'open') {
91
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
92
+ const w = wsOf(ctx, url.searchParams.get('ws'));
93
+ if (!w) return send(bad(404, 'no such workspace'));
94
+ const kind = text(url.searchParams.get('target_kind'));
95
+ if (!D.KINDS.includes(kind)) return send(bad(400, `target_kind must be one of ${D.KINDS.join(', ')}`));
96
+ const file = ledgerOf(ctx, w);
97
+ const state = D.fold(file ? D.readLines(file) : []);
98
+ const targets = {};
99
+ for (const pr of D.openProposals(state)) {
100
+ if (pr.target.kind !== kind || pr.target.ws !== w.id) continue;
101
+ (targets[pr.target.id] = targets[pr.target.id] || []).push(proposalOut(pr));
102
+ }
103
+ return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, kind, mode: D.modeOf(state, kind === 'work' ? 'work' : null), targets } });
104
+ }
105
+
106
+ if (sub === 'strip') {
107
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
108
+ const w = wsOf(ctx, url.searchParams.get('ws'));
109
+ if (!w) return send(bad(404, 'no such workspace'));
110
+ const t = D.checkTarget({ kind: text(url.searchParams.get('target_kind')), ws: w.id, id: text(url.searchParams.get('id')) });
111
+ if (!t.ok) return send(bad(400, t.error));
112
+ const file = ledgerOf(ctx, w);
113
+ const state = D.fold(file ? D.readLines(file) : []);
114
+ return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...stripOf(state, t.target, { board: text(url.searchParams.get('board')) || null }) } });
115
+ }
116
+
117
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
118
+ const read = await ctx.readJson(req);
119
+ if (!read.ok) return send(bad(400, 'the body is not JSON'));
120
+ const body = read.body;
121
+ 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'));
122
+ const g = guard.requirePagePress(req, body);
123
+ if (!g.ok) return send(bad(g.status, g.error));
124
+ const w = wsOf(ctx, text(body.ws));
125
+ if (!w) return send(bad(404, 'no such workspace'));
126
+ const file = ledgerOf(ctx, w);
127
+ 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'));
128
+ const refresh = () => { try { if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(w.id); } catch { /* the next poll recounts */ } };
129
+
130
+ if (sub === 'keep' || sub === 'return' || sub === 'drop') {
131
+ const proposal = text(body.proposal);
132
+ if (!D.PROPOSAL_RE.test(proposal)) return send(bad(400, 'proposal must be a driver proposal id'));
133
+ const stateDir = ctx.stateDirFor(w);
134
+ const dctx = {
135
+ stageFile: path.join(stateDir, stageTools.LEDGER),
136
+ itemsDir: path.join(runtimeOf(ctx), 'pm-items'),
137
+ correctionsFile: path.join(runtimeOf(ctx), 'corrections.jsonl'),
138
+ // The board move route's own function: its validation and its warnings apply, and `by` is the PM.
139
+ move: async (pr) => {
140
+ const routesBoards = require('./routes-boards.js');
141
+ let board = pr.data && pr.data.board;
142
+ if (!board) { try { board = routesBoards.boardIndex(ctx, w).of(pr.target.id); } catch { board = null; } }
143
+ 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);
144
+ },
145
+ };
146
+ const r = await decideLib.decide(sub, { file, proposal, note: body.note == null ? undefined : text(body.note), actor: g.actor, ctx: dctx });
147
+ if (!r.ok) return send(bad(r.status, r.error));
148
+ refresh();
149
+ return send({ status: 200, body: { ok: true, proposal: { ...r.proposal, by: actorLib.pageName(r.proposal.by) }, wrote: r.wrote, warnings: r.warnings } });
150
+ }
151
+
152
+ if (sub === 'pause' || sub === 'resume') {
153
+ const t = D.checkTarget({ kind: text(body.target_kind), ws: w.id, id: text(body.id) });
154
+ if (!t.ok) return send(bad(400, t.error));
155
+ const r = decideLib.setPause(file, sub, t.target, g.actor);
156
+ if (!r.ok) return send(bad(r.status, r.error));
157
+ refresh();
158
+ return send({ status: 200, body: { ok: true, paused: r.paused, target: r.target } });
159
+ }
160
+
161
+ const r = decideLib.setMode(file, body.board == null || body.board === '' ? '*' : text(body.board), text(body.mode), g.actor);
162
+ if (!r.ok) return send(bad(r.status, r.error));
163
+ refresh();
164
+ return send({ status: 200, body: { ok: true, board: r.board, mode: r.mode } });
165
+ }
166
+
167
+ 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.169.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.169.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.169.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",
@@ -15,11 +15,12 @@ import { el } from './dom.js';
15
15
  import { c3ic } from './pm-work-view.js';
16
16
  import { pmPost } from './http.js';
17
17
  import { makeXp, attachExpand } from './c3-expand.js';
18
- import { needRow, belowLine, BX, toast } from './c3-brief.js';
18
+ import { needRow, belowLine, BX, toast, hooks } from './c3-brief.js';
19
19
  import { onBriefing, loadBriefing } from './shell-ticker.js';
20
20
  import { cardView, TAG_STYLE } from './briefing-model.js';
21
21
  import { onBriefPage } from './c3-brief-data.js';
22
22
  import { button, ensureComponentsCss } from './primitives.js';
23
+ import { loadDriverNeeds, needsRow as driverRow } from './c3-driver.js';
23
24
 
24
25
  const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
25
26
  const post = (path, body) => pmPost(path, body).catch(() => ({ ok: false }));
@@ -104,11 +105,12 @@ export function needsSection(S) {
104
105
  const sumState = { dec: 0, own: 0, late: 0, quick: 0 };
105
106
  const own = el('ul', { class: 'list', role: 'list', 'data-needs-own': '' });
106
107
  const ownNote = el('div');
108
+ const agent = el('ul', { class: 'list', role: 'list', 'data-needs-agent': '' }); // the Initiative Driver's rows (c3-driver.js)
107
109
  const list = el('ul', { class: 'list', role: 'list' });
108
110
  const extra = el('div');
109
111
  const alertHost = el('div');
110
112
  const box = el('section', { class: 'card sect', 'data-c3pg': 'needs', 'aria-label': 'Needs you' },
111
- el('div', { class: 'sh2' }, el('h2', null, c3ic('inbox', 'sm'), 'Needs you ', count)), alertHost, ownNote, own, list, extra);
113
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('inbox', 'sm'), 'Needs you ', count)), alertHost, ownNote, own, agent, list, extra);
112
114
  let sig = ''; let last = null; let ownSig = ''; let page = null;
113
115
  const nothing = () => el('li', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing needs you right now.');
114
116
  /* the header line: "3 decisions · 4 of yours · 2 overdue", then how many take under 2 minutes */
@@ -118,6 +120,8 @@ export function needsSection(S) {
118
120
  if (!quick.isConnected) box.querySelector('.sh2').append(quick);
119
121
  quick.replaceChildren(document.createTextNode(dec + (dec === 1 ? ' decision' : ' decisions') + ' · ' + ownRows.length + ' of yours'));
120
122
  if (late) quick.append(' · ', el('span', { class: 'tag bad' }, late + ' overdue'));
123
+ const ag = agent.children.length;
124
+ if (ag) quick.append(' · ' + ag + ' from the agent');
121
125
  if (sumState.quick) quick.append(' · ', c3ic('bolt', 'sm'), sumState.quick + ' under 2 min');
122
126
  };
123
127
  const recount = () => {
@@ -168,9 +172,21 @@ export function needsSection(S) {
168
172
  extra.replaceChildren();
169
173
  recount();
170
174
  };
175
+ /* the agent's rows: drafts waiting on the PM, a target the PM is blocking. A press opens the initiative (its Overview holds the strip) or Work. */
176
+ let agentSig = '';
177
+ const goAgent = (n) => {
178
+ if (n.initiative_id) { try { const u = new URL(location.href); u.searchParams.set('init', 'ini:' + encodeURIComponent(n.ws) + ':' + encodeURIComponent(n.initiative_id) + ':overview'); history.replaceState(history.state, '', u); } catch { /* no history */ } }
179
+ if (hooks.go) hooks.go({ action: n.initiative_id ? 'goinit' : 'gowork' });
180
+ };
181
+ const fillAgent = () => loadDriverNeeds().then((rows) => {
182
+ const g = JSON.stringify(rows.map((r) => [r.run_id, r.question]));
183
+ if (g === agentSig) return; agentSig = g;
184
+ agent.replaceChildren(...rows.map((r) => driverRow(r, goAgent)));
185
+ recount();
186
+ }, () => {});
171
187
  S.refreshNeeds = () => { sig = ''; if (last) fill(last); };
172
188
  S.needsFill = fill;
173
- onBriefing((b) => { S.brief = b; fill(b); });
189
+ onBriefing((b) => { S.brief = b; fill(b); fillAgent(); });
174
190
  onBriefPage(fillOwn);
175
191
  loadBriefing();
176
192
  return box;
@@ -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();
@@ -0,0 +1,110 @@
1
+ /* pm/c3-driver-work.js — the Initiative Driver on a Work task (PRD section 5; mockup 39-driver.js workNote and openDraft).
2
+ * One agent line under the title of a task that has an open driver proposal, and the sheet behind its Review.
3
+ *
4
+ * loadDriverOpen(ws) GET /api/driver/open?target_kind=work → { ok, ws, mode, targets: { <task id>: [proposal] } }, or null
5
+ * workNote(o) the line: o = { item, props, ws, mode, done } → a node, or null (Shadow and Off draw no line, PRD section 5)
6
+ * openDraftSheet(o) the sheet: the draft text, the line that the agent sent nothing, Keep (copies the text), Send back, Drop
7
+ *
8
+ * A kept follow-up is copied to the clipboard and recorded. When the browser refuses the clipboard, the text is selected and the
9
+ * sheet stays open so Ctrl+C works. Presses: POST /api/driver/keep, return, drop (the server takes the PM from the call path).
10
+ * `done()` runs after a press so the Work list reads again. Nothing here sends anything to a person.
11
+ */
12
+ import { el } from './dom.js';
13
+ import { SEL } from './primitives.js';
14
+ import { pmGet, pmPost } from './http.js';
15
+ import { c3ic } from './pm-work-view.js';
16
+ import { openSheet } from './c3-sheet.js';
17
+ import { toast } from './c3-toast.js';
18
+ import { pbtn, chip, tag, put } from './c3-ini-ui.js';
19
+ import { whenLabel, dueLabel, titleOf, ensureDriverCss } from './c3-driver.js';
20
+
21
+ export async function loadDriverOpen(ws) {
22
+ const r = await pmGet('/api/driver/open?target_kind=work' + (ws ? '&ws=' + encodeURIComponent(ws) : ''));
23
+ return r && r.ok ? r : null;
24
+ }
25
+
26
+ const press = async (ws, route, body, what) => {
27
+ const r = await pmPost('/api/driver/' + route, { ws, ...body });
28
+ if (!r || !r.ok) { toast({ text: what + ' did not work: ' + ((r && r.error) || 'the workspace did not answer') + '.', tone: 'error', timeout: 6000 }); return null; }
29
+ return r;
30
+ };
31
+ const short = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
32
+ const lead = (props) => props.find((p) => p.what === 'follow-up') || props[0];
33
+ const who = (item, p) => (p.data && typeof p.data.to === 'string' && p.data.to) || item.waiting_on || '';
34
+
35
+ /** The words of the line, by what the agent filed. Pure. */
36
+ export function lineOf(item, props) {
37
+ const p = lead(props || []);
38
+ if (!p) return null;
39
+ const more = props.length > 1 ? ' (+' + (props.length - 1) + ' more)' : '';
40
+ const t = p.data && typeof p.data.text === 'string' ? p.data.text : '';
41
+ if (p.what === 'follow-up') return { p, text: 'Agent drafted a follow-up' + (who(item, p) ? ' to ' + who(item, p) : '') + '. Nothing is sent.' + more, act: 'review' };
42
+ if (p.what === 'next-step') return { p, text: 'Agent suggests ' + (p.data && p.data.due && !t ? 'a new due date: ' + dueLabel(p.data.due) : 'a next step: ' + short(t, 70) + (p.data && p.data.due ? ', due ' + dueLabel(p.data.due) : '')) + '.' + more, act: 'keepdrop' };
43
+ return { p, text: 'Agent left a note: ' + short(t, 70) + more, act: 'review' };
44
+ }
45
+
46
+ export function workNote({ item, props, ws, mode, done }) {
47
+ if (mode !== 'approve') return null;
48
+ ensureDriverCss();
49
+ const l = lineOf(item, props);
50
+ if (!l) return null;
51
+ const lk = (label, attrs, fn) => pbtn('ghost', label, null, { ...attrs, class: 'lk', onclick: (e) => { e.stopPropagation(); fn(); } });
52
+ const decide = async (route) => {
53
+ const r = await press(ws, route, { proposal: l.p.id }, route === 'keep' ? 'Keep' : 'Drop');
54
+ if (!r) return;
55
+ toast(route === 'keep' ? 'Kept: ' + titleOf(l.p) + '.' : 'Dropped the suggestion.');
56
+ done();
57
+ };
58
+ return el('span', { class: 'drv-note', 'data-drv-wnote': item.id, 'data-drv-what': l.p.what }, c3ic('spark', 'sm'), l.text, ' ',
59
+ l.act === 'review' ? lk('Review', { 'data-drv-wnote-act': 'review' }, () => openDraftSheet({ item, props, ws, done }))
60
+ : [lk('Keep', { 'data-drv-wnote-act': 'keep' }, () => decide('keep')), lk('Drop', { 'data-drv-wnote-act': 'drop' }, () => decide('drop'))]);
61
+ }
62
+
63
+ /** Copy `text`. The clipboard first; when the browser refuses, select the node's text so Ctrl+C works. Returns 'copied' or 'selected'. */
64
+ export async function copyText(text, node) {
65
+ try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return 'copied'; } } catch { /* fall back to the selection */ }
66
+ try { const r = document.createRange(); r.selectNodeContents(node); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r); } catch { /* the PM selects by hand */ }
67
+ return 'selected';
68
+ }
69
+
70
+ export function openDraftSheet({ item, props, ws, done }) {
71
+ ensureDriverCss();
72
+ const p = lead(props);
73
+ const text = (p.data && typeof p.data.text === 'string' && p.data.text) || '';
74
+ const isDraft = p.what === 'follow-up';
75
+ const to = who(item, p);
76
+ const draft = el('p', { class: 'sit drv-pre', 'data-drv': 'draft' }, text || 'The agent filed no text.');
77
+ const backBox = el('div', { class: 'form', hidden: true, 'data-drv': 'back-box' }, el('input', { type: 'text', placeholder: 'Tell the agent what to change', 'aria-label': 'Note to the agent', 'data-drv': 'note', maxlength: '500', autocomplete: 'off' }));
78
+ const status = el('div', { class: 'fold', 'data-drv': 'sheet-note' }, c3ic('info', 'sm'), isDraft ? 'The agent sent nothing and cannot send. Keep copies this text so you can send it yourself.' : 'The agent sent nothing. Keep records that you rely on this note.');
79
+ const sayStatus = (t, warn) => { status.className = 'fold' + (warn ? ' w' : ''); put(status, c3ic(warn ? 'alert' : 'info', 'sm'), t); };
80
+ let sh = null; let backOpen = false;
81
+ const keepBtn = pbtn('primary', isDraft ? 'Keep (copy to send yourself)' : 'Keep', 'check', { 'data-drv': 'sheet-keep', onclick: async () => {
82
+ const r = await press(ws, 'keep', { proposal: p.id }, 'Keep');
83
+ if (!r) return;
84
+ const how = isDraft && text ? await copyText(text, draft) : 'none';
85
+ done();
86
+ if (how === 'selected') { sayStatus('Kept. The browser did not allow copying, so the text is selected: press Ctrl+C. Nothing was sent.', true); keepBtn.disabled = true; return; }
87
+ sh.close();
88
+ toast(how === 'copied' ? 'Draft copied. Nothing was sent: send it yourself.' : 'Kept.');
89
+ } });
90
+ const backBtn = pbtn('outline', 'Send back', 'reply', { 'data-drv': 'sheet-back', onclick: async () => {
91
+ const inp = backBox.querySelector('input');
92
+ if (!backOpen) { backOpen = true; backBox.hidden = false; inp.focus(); backBtn.querySelector(SEL.btnLabel).textContent = 'Send back with note'; return; }
93
+ const n = inp.value.trim();
94
+ if (!n) { sayStatus('Write one line for the agent. A note starts its next run.', true); inp.focus(); return; }
95
+ const r = await press(ws, 'return', { proposal: p.id, note: n }, 'Send back');
96
+ if (!r) return;
97
+ sh.close(); toast('Sent back with your note. The agent redrafts it.'); done();
98
+ } });
99
+ const dropBtn = pbtn('ghost', 'Drop', null, { 'data-drv': 'sheet-drop', onclick: async () => {
100
+ const r = await press(ws, 'drop', { proposal: p.id }, 'Drop');
101
+ if (!r) return;
102
+ sh.close(); toast('Dropped the draft.'); done();
103
+ } });
104
+ sh = openSheet({ title: isDraft ? 'Draft follow-up' + (to ? ' to ' + to : '') : titleOf(p), icon: 'mail', body: el('div', { class: 'drv-sheet', 'data-drv': 'work-sheet' },
105
+ el('div', { class: 'chips' }, tag(isDraft ? 'draft' : p.what), to ? chip(c3ic('user', 'sm'), to) : null, p.at ? chip(c3ic('clock', 'sm'), whenLabel(p.at)) : null, chip(c3ic('check', 'sm'), short(item.title, 48))),
106
+ el('div', { class: 'case' }, draft),
107
+ p.why ? el('div', { class: 'dim drv-small' }, 'Why: ' + p.why) : null, status, backBox,
108
+ el('div', { class: 'iacts chips2' }, keepBtn, backBtn, dropBtn)) });
109
+ return sh;
110
+ }
@@ -0,0 +1,18 @@
1
+ /* pm/c3-driver.css — the few rules the Initiative Driver needs beyond console3.css (c3-driver.js, c3-driver-work.js). The mockup's
2
+ * 39-driver.css under the `drv-` prefix: the Agent strip on an initiative's Overview, the Agent board setting, the agent line on a Work
3
+ * row and the sheets. Held to the R28 rules: tokens only, no inline style, corners 0, 6px or 8px, font sizes from the --fs tokens. */
4
+ .c3 .drv .nrow .acts{flex-wrap:wrap;justify-content:flex-end}
5
+ .c3 .drv-src{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
6
+ .c3 .drv-src .drv-ref{font-size:var(--fs-1);color:var(--muted-foreground);overflow-wrap:anywhere}
7
+ .c3 .drv-src a.drv-ref{color:var(--acc)}
8
+ .c3 .drv-pre{margin:0;white-space:pre-line;overflow-wrap:anywhere}
9
+ .c3 .drv-small{font-size:var(--fs-2);line-height:1.45}
10
+ .c3 .drv-sub{margin-top:2px}
11
+ .c3 .drv-set{display:grid;gap:var(--sp-2)}
12
+ .c3 .drv-set .seg{max-width:100%;overflow-x:auto}
13
+ .c3 .drv-note{display:block}
14
+ .c3 .drv-note .ic{vertical-align:-2px;margin-right:6px}
15
+ .c3 .drv-note .lk{margin-left:4px}
16
+ .c3 .drv-sheet{display:grid;gap:var(--sp-3)}
17
+ .c3 .drv-sheet .sit{white-space:pre-line;overflow-wrap:anywhere}
18
+ .c3 .drv-sheet input[type=text]{width:100%}