create-pmos 0.168.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.168.0",
3
+ "version": "0.169.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@21b6322
1
+ kernel-source: pmos@d87e438
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T14:26:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T14:51:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T14:26:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T14:51:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T14:26:40Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T14:51:11Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ /* c3-driver-fixture.js — a real server, a scratch repository and a seeded driver ledger for the console3 Initiative Driver suite
3
+ * (c3-driver-test.js). Not a suite: it asserts nothing. The server runs with PMOS_DRIVER=off, so no headless run starts: every
4
+ * proposal, run and blocker the page shows is a line this file wrote with lib/driver.js, the single writer.
5
+ *
6
+ * const F = await start({ name }) -> { BASE, ws, board, team, ledger, ids, item, itemDue, post, get, stop }
7
+ * ids: { problem, evidence, sizing, move, follow, step } proposal ids; item: the Work task with a follow-up; itemDue: the one with a due date
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { spawn } = require('child_process');
12
+ const T = require('./test-run').configure({ name: 'c3-driver' });
13
+ const { makeRepos, codeRepo, write, git } = require('./plan-test-repos');
14
+ const D = require('./lib/driver.js');
15
+
16
+ const HOUR = 3600000;
17
+ async function call(base, method, p, body, headers) {
18
+ const r = await fetch(base + p, { method, headers: headers || {}, body: body === undefined ? undefined : JSON.stringify(body) });
19
+ const raw = await r.text(); let j = null; try { j = JSON.parse(raw); } catch { /* not JSON */ }
20
+ return { status: r.status, body: j, raw };
21
+ }
22
+
23
+ async function start({ name = 'c3-driver' } = {}) {
24
+ const R = makeRepos(name);
25
+ const team = codeRepo(path.join(R.base, 'team'));
26
+ fs.mkdirSync(path.join(team, 'pmos', 'state'), { recursive: true });
27
+ write(team, 'pmos/state/.keep', '');
28
+ git(team, ['add', '-A']); git(team, ['commit', '-q', '-m', 'state']);
29
+ const ledger = path.join(team, 'pmos', 'state', 'driver.jsonl');
30
+ const BASE = T.BASE;
31
+ const srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore',
32
+ env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: team, PMOS_NO_OPEN: '1', PMOS_DRIVER: 'off' }) });
33
+ await T.waitForServer(BASE);
34
+ const press = await T.pressHeaders(BASE);
35
+ const post = (p, body) => call(BASE, 'POST', p, body, press);
36
+ const get = (p) => call(BASE, 'GET', p);
37
+ const reg = await post('/api/workspaces', { root: team, focus: true });
38
+ const ws = reg.body.workspace.id;
39
+ const made = await post('/api/initiatives', { ws, title: 'Partner API onboarding', lane: 'feature', anchor: 'health:tech', id: 'drv-ui' });
40
+ if (made.status !== 200) throw new Error('could not create the initiative: ' + made.raw);
41
+ const a = (await post('/api/pm-items/add', { line: 'Get the sandbox access date from Dana waiting on Dana Hartono' })).body.item;
42
+ const b = (await post('/api/pm-items/add', { line: 'Send the partner onboarding list to Lina' })).body.item;
43
+ const tI = { kind: 'initiative', ws, id: 'drv-ui' };
44
+ const now = Date.now();
45
+ const w = (line, at) => D.append(ledger, line, { now: at });
46
+ const prop = (id, target, what, data, why, sources, at, run) => { w({ ev: 'proposal', id, target, what, data, why, sources: sources || [], run, by: 'agent:sess-4.0a1b2c3d' }, at); return id; };
47
+ const ids = { problem: 'p-aaaaaaaa01', evidence: 'p-aaaaaaaa02', sizing: 'p-aaaaaaaa03', move: 'p-aaaaaaaa04', follow: 'p-aaaaaaaa05', step: 'p-aaaaaaaa06' };
48
+ w({ ev: 'epoch' }, now - 3 * 24 * HOUR);
49
+ w({ ev: 'run-start', run: 'run-aaaaaaaa01', target: tI, board: ws, wakes: [], session: 'sess-4.0a1b2c3d' }, now - 5 * HOUR);
50
+ prop(ids.problem, tI, 'problem', { text: 'New partners wait 9 days on average from signed contract to first live API call. Support handles 23 onboarding tickets a month.' }, 'first draft from the Jira and Slack threads', [{ label: 'Jira', ref: 'IDPF-1204' }, { label: 'Slack', ref: '#partner-support, 6 Oct' }], now - 4 * HOUR, 'run-aaaaaaaa01');
51
+ prop(ids.evidence, tI, 'evidence', { text: 'Six sources back the problem statement. Four name sandbox access as the main delay.', items: [{ text: 'Partner X waited 9 days for sandbox access', ref: 'IDPF-1204' }, { text: 'Two partners paused go-live this quarter', ref: 'https://example.invalid/partner-council/1-oct' }] }, 'evidence for the problem', [{ label: 'Meeting note', ref: 'IDPF DSU, 8 Oct' }], now - 4 * HOUR + 600000, 'run-aaaaaaaa01');
52
+ prop(ids.sizing, tI, 'sizing', { text: '14 partners are in the pipeline this half. Cutting the wait from 9 days to 3 moves about 5 go-lives into Q4.' }, 'low confidence: two data points', [{ label: 'Slack', ref: '#sales-asks, 7 Oct' }], now - 3 * HOUR, 'run-aaaaaaaa01');
53
+ prop(ids.move, tI, 'move', { to: 'Plan' }, 'the problem, evidence and sizing are in', [], now - 50 * HOUR, 'run-aaaaaaaa01');
54
+ w({ ev: 'run-end', run: 'run-aaaaaaaa01', ok: true, agent_run: null }, now - 3 * HOUR + 60000);
55
+ w({ ev: 'blocker', key: `initiative:${ws}:drv-ui:pm-open`, day: D.localDay(now), target: tI, board: ws, code: 'pm-open', owner: 'pm', text: "The agent's drafts have waited on you since " + new Date(now - 50 * HOUR).toISOString().slice(0, 10) }, now - HOUR);
56
+ prop(ids.follow, { kind: 'work', ws, id: a.id }, 'follow-up', { text: 'Hi Dana,\n\nQuick follow-up on the sandbox access for Nusa Pay. Can you tell me which day your engineers can use the sandbox?\n\nThanks,\nWawan', to: 'Dana' }, 'waiting 4 days, no reply', [{ label: 'Slack', ref: '#partner-support' }], now - 2 * HOUR, null);
57
+ prop(ids.step, { kind: 'work', ws, id: b.id }, 'next-step', { text: 'Send the list to Lina', due: '2026-10-15' }, 'overdue with no next step', [], now - HOUR, null);
58
+ const stop = () => { try { srv.kill('SIGKILL'); } catch { /* gone */ } T.killTmux(); R.cleanup(); };
59
+ return { BASE, ws, board: ws, team, ledger, ids, item: a, itemDue: b, post, get, stop, T, D, tI };
60
+ }
61
+ module.exports = { start };
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-driver-test.js — the Initiative Driver on console3 (initiative initiative-driver; PRD section 5 and done-criterion 9).
4
+ * LIVE mode: a real server and a scratch repository whose driver.jsonl this suite seeds (c3-driver-fixture.js; PMOS_DRIVER=off, so no
5
+ * run starts and every line the page shows is one the fixture wrote). A real Chromium drives /c3.html.
6
+ *
7
+ * strip Initiative · Overview shows the Agent strip: the count, Pause, "1 of 3 runs used today", the status, the last action, the
8
+ * blocker line, one row per open proposal; a row opens to its text and sources (a URL source is a link)
9
+ * runs the runs chip opens a sheet that lists today's run
10
+ * decide Keep on a problem proposal posts /api/driver/keep and the row turns "Kept by you"; Send back asks for a note and posts it;
11
+ * Drop posts and the row leaves
12
+ * pause Pause posts /api/driver/pause and the strip says Paused; Resume brings it back
13
+ * work a Work task with a follow-up shows the agent line; Review opens the sheet with the draft and "the agent sent nothing";
14
+ * Keep records the keep; a task with a next-step shows Keep and Drop in the line
15
+ * setting Board settings has the Agent setting; pressing Shadow posts /api/driver/mode and the ledger holds it
16
+ * briefing Needs you shows the agent's two kinds of row and a press opens the initiative
17
+ * phone at 400 wide the strip has no horizontal scroll
18
+ * Every wait polls the page. The page must raise no script error.
19
+ */
20
+ const path = require('path');
21
+ const { findBrowser } = require('./lib/find-browser');
22
+ const F = require('./c3-driver-fixture');
23
+
24
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ const until = async (fn, ms = 20000, step = 120) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
26
+ const results = [];
27
+ const ok = (n, cond, extra = '') => { results.push({ n, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${n}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 400) : ''}`); };
28
+ let fx = null; let browser = null;
29
+ const finish = async (code) => {
30
+ if (browser) await browser.close().catch(() => {});
31
+ if (fx) fx.stop();
32
+ const bad = results.filter((x) => !x.cond).length;
33
+ console.log(`\n${results.length - bad}/${results.length} c3-driver assertions passed`);
34
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
35
+ };
36
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
37
+
38
+ (async () => {
39
+ const exe = findBrowser();
40
+ if (!exe) { console.log('· no Chrome/Chromium on this machine — c3-driver suite skipped'); process.exit(0); }
41
+ let chromium;
42
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('· playwright-core not installed — c3-driver suite skipped'); process.exit(0); }
43
+ fx = await F.start({ name: 'c3-driver' });
44
+ const { BASE, ws, ids, D, ledger } = fx;
45
+ const state = () => D.fold(D.readLines(ledger));
46
+ browser = await chromium.launch({ executablePath: exe });
47
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
48
+ const page = await ctx.newPage();
49
+ const errors = []; page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
50
+ const posts = []; page.on('request', (r) => { if (r.method() === 'POST' && r.url().includes('/api/driver/')) posts.push({ path: new URL(r.url()).pathname, body: JSON.parse(r.postData() || '{}') }); });
51
+ const st = (f, a) => page.evaluate(f, a);
52
+ const sel = (s) => until(() => page.$(s));
53
+ const INI = `/c3.html?tab=init&init=ini:${ws}:drv-ui:overview`;
54
+
55
+ /* ── strip ── */
56
+ await page.goto(BASE + INI, { waitUntil: 'load' });
57
+ await sel('[data-drv-row]');
58
+ const rows = await st(() => [...document.querySelectorAll('[data-drv-row]')].map((r) => r.dataset.drvRow));
59
+ ok('S1 the Agent strip draws one row per open proposal, in the order the agent filed them', rows.join() === [ids.problem, ids.evidence, ids.sizing, ids.move].join(), rows.join());
60
+ const head = await st(() => document.querySelector('[data-drv="strip"] .sh2').textContent);
61
+ ok('S2 the header says AGENT 4, has Pause and "1 of 3 runs used today"', /Agent\s*4/i.test(head) && /Pause/.test(head) && /1 of 3 runs used today/.test(head), head);
62
+ const body = await st(() => document.querySelector('[data-drv="strip"]').textContent);
63
+ ok('S3 the status line says the agent is idle and waiting on you, with the last action', /Agent is idle · waiting on you/.test(body) && /The oldest proposal is 2 days old/.test(body) && /Last: filed 4 proposals/.test(body), body.slice(0, 300));
64
+ ok('S4 the blocker line names who stopped the agent', /Stopped by: you\./.test(await st(() => document.querySelector('[data-drv="blocker"]').textContent)));
65
+ ok('S5 the old proposal says how long it waited', /waiting 2 days/.test(await st(() => document.querySelector(`[data-drv-row="${'p-aaaaaaaa04'}"]`).textContent)));
66
+ await page.click(`[data-drv-title="${ids.evidence}"]`);
67
+ await until(() => st((id) => document.querySelector(`[data-drv-row="${id}"] .xp.open`), ids.evidence));
68
+ const ev = await st((id) => { const r = document.querySelector(`[data-drv-row="${id}"]`); return { text: r.textContent, link: (r.querySelector('a[href^="https://example.invalid"]') || {}).href || '', n: r.querySelectorAll('[data-drv-src]').length }; }, ids.evidence);
69
+ ok('S6 a row opens to its text and its sources (label and ref); a URL ref is a link', /Six sources back the problem statement/.test(ev.text) && /IDPF DSU, 8 Oct/.test(ev.text) && ev.n === 3 && /partner-council/.test(ev.link), JSON.stringify(ev));
70
+ await page.click('[data-drv="runs"]');
71
+ await sel('[data-drv="runs-sheet"]');
72
+ const runs = await st(() => document.querySelector('[data-shell="sheet"]').textContent);
73
+ ok('S7 the runs chip opens a sheet with today\'s run', /Agent runs today/.test(runs) && /1 of 3 runs used today/.test(runs) && /Filed 4 proposals/.test(runs), runs.slice(0, 300));
74
+ await page.keyboard.press('Escape');
75
+
76
+ /* ── decide ── */
77
+ await page.click(`[data-drv-row="${ids.problem}"] [data-drv-act=keep]`);
78
+ await until(() => posts.some((p) => p.path === '/api/driver/keep'));
79
+ ok('D1 Keep posts /api/driver/keep with the proposal id and no writer field', posts.some((p) => p.path === '/api/driver/keep' && p.body.proposal === ids.problem && !('by' in p.body)), JSON.stringify(posts));
80
+ await until(() => st((id) => { const r = document.querySelector(`[data-drv-row="${id}"]`); return r && r.dataset.drvState === 'kept'; }, ids.problem));
81
+ ok('D2 the row turns "Kept by you" and loses its buttons; the ledger holds the keep by the PM', await st((id) => { const r = document.querySelector(`[data-drv-row="${id}"]`); return /Kept by you/.test(r.textContent) && !r.querySelector('[data-drv-act]'); }, ids.problem)
82
+ && state().proposals.get(ids.problem).state === 'kept' && state().proposals.get(ids.problem).decided_by === 'pm:wawan');
83
+ await until(() => st(() => /Kept: Problem statement|Kept/.test(document.querySelector('[data-c3-toast]') ? document.querySelector('[data-c3-toast]').textContent : '')));
84
+ await page.click(`[data-drv-row="${ids.sizing}"] [data-drv-act=back]`);
85
+ await sel('[data-drv="send-back"]');
86
+ await page.click('[data-drv="send-back"]');
87
+ ok('D3 Send back with an empty note is refused in plain words and posts nothing', !!(await until(() => page.$('[data-drv="note-error"]:not([hidden])'))) && !posts.some((p) => p.path === '/api/driver/return'));
88
+ await page.fill('[data-drv="note"]', 'Use active partners, not signed ones');
89
+ await page.click('[data-drv="send-back"]');
90
+ await until(() => posts.some((p) => p.path === '/api/driver/return'));
91
+ ok('D4 Send back posts the note; the row says it was sent back; the ledger holds it', posts.some((p) => p.path === '/api/driver/return' && p.body.note === 'Use active partners, not signed ones')
92
+ && !!(await until(() => st((id) => { const r = document.querySelector(`[data-drv-row="${id}"]`); return r && r.dataset.drvState === 'back'; }, ids.sizing))) && state().proposals.get(ids.sizing).state === 'returned');
93
+ await page.click(`[data-drv-row="${ids.evidence}"] [data-drv-act=drop]`);
94
+ await until(() => st((id) => !document.querySelector(`[data-drv-row="${id}"]`), ids.evidence));
95
+ ok('D5 Drop posts /api/driver/drop and the row leaves', posts.some((p) => p.path === '/api/driver/drop' && p.body.proposal === ids.evidence) && state().proposals.get(ids.evidence).state === 'dropped');
96
+
97
+ /* ── pause ── */
98
+ await page.click('[data-drv-act=pause]');
99
+ await until(() => st(() => /Agent is paused/.test(document.querySelector('[data-drv="status"]').textContent)));
100
+ ok('P1 Pause posts /api/driver/pause for the initiative and the strip says Paused with a Resume button', posts.some((p) => p.path === '/api/driver/pause' && p.body.target_kind === 'initiative' && p.body.id === 'drv-ui')
101
+ && !!(await page.$('[data-drv-act=resume]')) && D.isPaused(state(), fx.tI));
102
+ await page.click('[data-drv-act=resume]');
103
+ await until(() => st(() => !!document.querySelector('[data-drv-act=pause]')));
104
+ ok('P2 Resume lifts it', !D.isPaused(state(), fx.tI));
105
+
106
+ /* ── setting ── */
107
+ await page.goto(BASE + `/c3.html?tab=init&init=board:${ws}:summary`, { waitUntil: 'load' });
108
+ await (await sel('[data-ini=board-settings]')).click();
109
+ await sel('[data-drv="setting"] [data-drv-mode]');
110
+ ok('T1 Board settings shows the Agent setting at Approve, with the one-line explanations', await st(() => { const s = document.querySelector('[data-drv="setting"]'); return s.querySelector('[data-drv-mode=approve]').getAttribute('aria-pressed') === 'true' && /The agent drafts and asks you/.test(s.textContent) && /The agent does nothing on this board/.test(s.textContent); }));
111
+ await page.click('[data-drv-mode=shadow]');
112
+ await until(() => posts.some((p) => p.path === '/api/driver/mode'));
113
+ await until(() => st(() => document.querySelector('[data-drv-mode=shadow]').getAttribute('aria-pressed') === 'true'));
114
+ ok('T2 pressing Shadow posts /api/driver/mode for the board and the ledger holds it', posts.some((p) => p.path === '/api/driver/mode' && p.body.mode === 'shadow' && p.body.board === ws) && D.modeOf(state(), ws) === 'shadow');
115
+ await page.goto(BASE + INI, { waitUntil: 'load' });
116
+ await sel('[data-drv="shadow"]');
117
+ ok('T3 in Shadow the strip says so and offers no Keep', !(await page.$('[data-drv-act=keep]')) && /Shadow mode/.test(await st(() => document.querySelector('[data-drv="shadow"]').textContent)));
118
+ await page.goto(BASE + `/c3.html?tab=init&init=board:${ws}:summary`, { waitUntil: 'load' });
119
+ await (await sel('[data-ini=board-settings]')).click();
120
+ await sel('[data-drv-mode=approve]');
121
+ await page.click('[data-drv-mode=approve]');
122
+ await until(() => D.modeOf(state(), ws) === 'approve');
123
+ ok('T4 pressing Approve puts it back', D.modeOf(state(), ws) === 'approve');
124
+
125
+ /* ── work ── */
126
+ await page.goto(BASE + '/c3.html?tab=work', { waitUntil: 'load' });
127
+ await sel('[data-pm-lenses]');
128
+ await page.click('[data-pm-lenses] [data-lens=waiting]');
129
+ const line = await sel(`[data-drv-line="${fx.item.id}"]`);
130
+ ok('W1 a Waiting task with a follow-up shows "Agent drafted a follow-up to Dana. Nothing is sent." and Review', /Agent drafted a follow-up to Dana\. Nothing is sent\./.test(await line.textContent()) && !!(await page.$('[data-drv-wnote-act=review]')));
131
+ await page.click('[data-drv-wnote-act=review]');
132
+ await sel('[data-drv="work-sheet"]');
133
+ const sheet = await st(() => document.querySelector('[data-drv="work-sheet"]').textContent);
134
+ ok('W2 Review opens a sheet with the draft and the line that the agent sent nothing', /Quick follow-up on the sandbox access/.test(sheet) && /The agent sent nothing and cannot send/.test(sheet) && !!(await page.$('[data-drv=sheet-keep]')) && !!(await page.$('[data-drv=sheet-back]')) && !!(await page.$('[data-drv=sheet-drop]')));
135
+ await ctx.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: BASE }).catch(() => {});
136
+ await page.click('[data-drv=sheet-keep]');
137
+ await until(() => state().proposals.get(ids.follow).state === 'kept');
138
+ ok('W3 Keep records the keep (by the PM) and the sheet closes', state().proposals.get(ids.follow).decided_by === 'pm:wawan' && !!(await until(async () => !(await page.$('[data-drv="work-sheet"]')))));
139
+ const clip = await page.evaluate(() => navigator.clipboard.readText().catch(() => null));
140
+ ok('W4 the draft text was copied to the clipboard (or the browser refused, and the text was selected)', (clip && /Quick follow-up on the sandbox access/.test(clip)) || clip === null, String(clip));
141
+ await page.click('[data-pm-lenses] [data-lens=inbox]');
142
+ await sel(`[data-drv-line="${fx.itemDue.id}"]`);
143
+ ok('W5 a task with a next-step shows the suggestion with Keep and Drop in the line', /Agent suggests a next step: Send the list to Lina, due Thu 15 Oct/.test(await st((id) => document.querySelector(`[data-drv-line="${id}"]`).textContent, fx.itemDue.id)) && !!(await page.$('[data-drv-wnote-act=keep]')) && !!(await page.$('[data-drv-wnote-act=drop]')));
144
+ await page.click('[data-drv-wnote-act=drop]');
145
+ await until(() => state().proposals.get(ids.step).state === 'dropped');
146
+ ok('W6 Drop in the line drops the proposal and the line goes', !!(await until(async () => !(await page.$(`[data-drv-line="${fx.itemDue.id}"]`)))));
147
+
148
+ /* ── briefing ── */
149
+ await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
150
+ await sel('[data-drv-need]');
151
+ const needs = await st(() => [...document.querySelectorAll('[data-drv-need]')].map((r) => r.dataset.drvNeed + ':' + r.textContent.replace(/\s+/g, ' ')));
152
+ ok('B1 Needs you shows the agent\'s rows: the drafts left on the initiative, and the initiative waiting on you', needs.some((t) => /^driver-drafts:Agent has 1 draft on Partner API onboarding/.test(t)) && needs.some((t) => /^driver-blocked:.*Partner API onboarding is waiting on you/.test(t)), needs.join(' | '));
153
+ await page.click('[data-drv-need="driver-drafts"] .tt');
154
+ await until(() => page.$('[data-drv="strip"]'));
155
+ ok('B2 a press on the row opens the initiative with its Agent strip', !!(await page.$('[data-drv="strip"]')));
156
+
157
+ /* ── phone ── */
158
+ await page.setViewportSize({ width: 400, height: 900 });
159
+ await page.goto(BASE + INI, { waitUntil: 'load' });
160
+ await sel('[data-drv-row]');
161
+ ok('M1 at 400 wide the page has no horizontal scroll', await st(() => document.documentElement.scrollWidth <= window.innerWidth + 1), await st(() => document.documentElement.scrollWidth));
162
+ ok('N1 no page error was thrown', errors.length === 0, errors.join(' | '));
163
+ await finish();
164
+ })().catch((e) => { console.error(e); finish(1); });
@@ -50,6 +50,14 @@ The agent reads, and it files proposals with `pmos driver propose`. It has no Wr
50
50
 
51
51
  In Discover, the agent writes the problem statement, the evidence, the opportunity sizing, the riskiest assumption and the stakeholders. When you have kept the first three, it proposes the move to Plan. In the other stages, it names the blocker and proposes what would clear it.
52
52
 
53
+ ## Where you see the agent
54
+
55
+ - **The Agent strip.** Open an initiative and stay on Overview. The Agent section is at the top. It shows the state of the agent (working, idle, waiting on you, paused or off), the last thing it did, and what stops it ("Stopped by: ..."). Each open proposal is one row with Keep, Send back and Drop. Press a row to read its text and its sources. A source that is a link opens in a new tab. Pause and Resume are in the header. The text "1 of 3 runs used today" opens the list of today's runs.
56
+ - **Keep, Send back and Drop on a row.** A kept row stays in the strip, marked "Kept by you", until you leave the page. Send back asks for a one-line note. A kept stage move changes the stage on the page at once. These three acts cannot be undone: each adds one line to the ledger.
57
+ - **One line in Work.** A Work task with an open proposal shows one line under its title, for example "Agent drafted a follow-up to Dana. Nothing is sent." Press Review to read the draft. Keep copies the text so you can send it yourself, and records your decision. If the browser does not allow copying, the text is selected and you press Ctrl+C. A suggested next step shows Keep and Drop in the line. Shadow and Off show no line.
58
+ - **Needs you.** In Briefing, the agent has two kinds of row: "Agent has 3 drafts on ..." and "... is waiting on you". Press a row to open the initiative.
59
+ - **The Agent setting.** Open the board, press Board settings, and find Agent. Choose Off, Shadow or Approve. The setting is saved when you press it.
60
+
53
61
  ## Keep, send back, drop
54
62
 
55
63
  Open proposals appear in Needs you as one row for each initiative or task: "Agent has 3 drafts on the initiative".
@@ -1,13 +1,16 @@
1
1
  'use strict';
2
2
  /* routes-driver.js — the Initiative Driver's routes (initiative initiative-driver, PRD sections 4 and 5).
3
3
  *
4
- * GET /api/driver/strip?ws&target_kind&id → { ok, ws, target, mode, board, paused, budget, running, last, blocker, proposals }
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
5
  * POST /api/driver/keep { ws, proposal } → { ok, proposal, wrote, warnings }
6
6
  * POST /api/driver/return { ws, proposal, note } → { ok, proposal } the note is required; it wakes a run
7
7
  * POST /api/driver/drop { ws, proposal, note? } → { ok, proposal }
8
8
  * POST /api/driver/pause { ws, target_kind, id } → { ok, paused: true, target }
9
9
  * POST /api/driver/resume { ws, target_kind, id } → { ok, paused: false, target }
10
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
11
14
  *
12
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
13
16
  * `proposals` are the open ones; `by` is the page name (`agent:sess-<n>`), never the session token. `running` is a run that has
@@ -28,6 +31,8 @@ const stageTools = require('./stage-tools.js');
28
31
  const text = (v) => (typeof v === 'string' ? v : '');
29
32
  const bad = (status, error) => ({ status, body: { ok: false, error } });
30
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 });
31
36
 
32
37
  function wsOf(ctx, id) {
33
38
  const w = ctx.getWs(id || '');
@@ -46,22 +51,23 @@ function boardOfTarget(state, target) {
46
51
  return runs.length ? runs[runs.length - 1].board : null;
47
52
  }
48
53
 
49
- function stripOf(state, target, { now = Date.now() } = {}) {
54
+ function stripOf(state, target, { now = Date.now(), board: asked = null } = {}) {
50
55
  const key = D.targetKey(target);
51
56
  const runs = [...state.runs.values()].filter((r) => D.targetKey(r.target) === key);
52
57
  const running = runs.filter((r) => !r.end).pop() || null;
53
58
  const ended = runs.filter((r) => r.end).pop() || null;
54
- const board = boardOfTarget(state, target);
59
+ const board = boardOfTarget(state, target) || asked || null;
55
60
  const day = D.localDay(now);
56
61
  const blocker = state.blockers.filter((b) => b.target && D.targetKey(b.target) === key && b.day === day).pop() || null;
57
62
  const open = D.openProposals(state, target);
58
63
  return {
59
64
  target, board, mode: D.modeOf(state, board), paused: D.isPaused(state, target),
60
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 })),
61
67
  running: running ? { run: running.run, since: running.start } : null,
62
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,
63
69
  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 })),
70
+ proposals: open.map(proposalOut),
65
71
  };
66
72
  }
67
73
 
@@ -69,7 +75,33 @@ async function handle(req, res, url, ctx) {
69
75
  const p = url.pathname;
70
76
  const send = (r) => { ctx.json(res, r.body, r.status); return true; };
71
77
  const sub = p.replace(/^\/api\/driver\/?/, '');
72
- if (sub !== 'strip' && !POSTS.includes(sub)) return send(bad(404, 'no such driver route'));
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
+ }
73
105
 
74
106
  if (sub === 'strip') {
75
107
  if (req.method !== 'GET') return send(bad(405, 'GET only'));
@@ -79,7 +111,7 @@ async function handle(req, res, url, ctx) {
79
111
  if (!t.ok) return send(bad(400, t.error));
80
112
  const file = ledgerOf(ctx, w);
81
113
  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) } });
114
+ return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...stripOf(state, t.target, { board: text(url.searchParams.get('board')) || null }) } });
83
115
  }
84
116
 
85
117
  if (req.method !== 'POST') return send(bad(405, 'POST only'));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.168.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.168.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.168.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;
@@ -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%}
@@ -0,0 +1,298 @@
1
+ /* pm/c3-driver.js — the Initiative Driver on console3 (initiative initiative-driver, PRD section 5; mockup mockup-c3-driver, 39-driver.js).
2
+ * The agent works on every initiative and Work task on its own and files each result as a PROPOSAL. Nothing it writes changes a record
3
+ * or leaves PMOS: the PM keeps, sends back with a note, or drops. This file draws the three places the PM sees it:
4
+ *
5
+ * driverStrip(ctx, row) the Agent strip on an initiative's Overview: status, last action, blocker, one row per open proposal
6
+ * agentSetting(state, { onPick }) the Agent setting (Off, Shadow, Approve) in the Board settings sheet; loadAgentMode(api, board) reads it
7
+ * workNote(o) · openDraftSheet(o) the one agent line under a Work task and the sheet behind its Review (c3-driver-work.js draws them)
8
+ *
9
+ * Reads: GET /api/driver/strip, /api/driver/mode, /api/driver/open. Presses: POST /api/driver/keep, return, drop, pause, resume, mode,
10
+ * through http.js (the nonce and the page Origin). The page sends no `by`: the server takes the PM from the call path. After each press
11
+ * the strip is read again. Keep, send back and drop have no Undo: each adds a line to the ledger, and nothing is removed.
12
+ * A proposal that was just decided stays in its strip, marked, until the page is left. Plain words for every empty and error state.
13
+ */
14
+ import { el } from './dom.js';
15
+ import { icon, pbtn, lk, chip, tag, st, note, lbl, chgl, list, nrow, sect, put, btn, plural } from './c3-ini-ui.js';
16
+ import { toast } from './c3-toast.js';
17
+ import { pmGet } from './http.js';
18
+
19
+ /** Link c3-driver.css once, from this module's own folder (the way c3-initiatives.js links its stylesheet). */
20
+ export function ensureDriverCss() {
21
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-drv]')) return;
22
+ const l = document.createElement('link');
23
+ l.rel = 'stylesheet'; l.href = new URL('./c3-driver.css', import.meta.url).href; l.setAttribute('data-c3-drv', '');
24
+ document.head.appendChild(l);
25
+ }
26
+
27
+ /* ── words ── */
28
+ export const WHAT = {
29
+ problem: ['Problem statement', 'doc'], evidence: ['Evidence', 'doc'], sizing: ['Opportunity sizing', 'target'], assumption: ['Riskiest assumption and spike brief', 'bolt'],
30
+ stakeholders: ['Stakeholders', 'user'], move: ['Move', 'next'], note: ['Note', 'notes'], 'next-step': ['Next step', 'check'], 'follow-up': ['Follow-up', 'mail'],
31
+ };
32
+ export const MODES = [
33
+ ['off', 'Off', 'The agent does nothing on this board.'],
34
+ ['shadow', 'Shadow', 'The agent drafts but raises nothing. You see its drafts only on the initiative page.'],
35
+ ['approve', 'Approve', 'The agent drafts and asks you. Each draft waits for Keep, Send back or Drop.'],
36
+ ];
37
+ const errText = (r, what) => what + ': ' + ((r && r.error) || 'the workspace did not answer') + '.';
38
+ const isUrl = (s) => /^https?:\/\/\S+$/i.test(String(s || ''));
39
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
40
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
41
+ const p2 = (n) => String(n).padStart(2, '0');
42
+ const clock = (d) => p2(d.getHours()) + ':' + p2(d.getMinutes());
43
+ /** '11:05' for today, 'Fri 9 Oct, 16:05' for any other day. */
44
+ export function whenLabel(iso, now = new Date()) {
45
+ const d = new Date(iso);
46
+ if (!Number.isFinite(d.getTime())) return '';
47
+ return d.toDateString() === now.toDateString() ? clock(d) : WD[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()] + ', ' + clock(d);
48
+ }
49
+ /** Whole days a time is old. */
50
+ export const daysOld = (iso, now = Date.now()) => { const t = Date.parse(iso); return Number.isFinite(t) ? Math.floor((now - t) / 864e5) : 0; };
51
+ /** 'Thu 15 Oct' for a YYYY-MM-DD. */
52
+ export function dueLabel(ymd) {
53
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(ymd || ''));
54
+ if (!m) return '';
55
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
56
+ return WD[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()];
57
+ }
58
+ const textOf = (p) => (p && p.data && typeof p.data.text === 'string' ? p.data.text : '');
59
+ export const titleOf = (p) => (p.what === 'move' && p.data && p.data.to ? 'Move to ' + p.data.to : (WHAT[p.what] || [p.what])[0]);
60
+
61
+ /** The text a proposal shows when it opens: the draft, then the agent's one-line reason. */
62
+ export function bodyOf(p) {
63
+ const out = [];
64
+ const t = textOf(p);
65
+ if (p.what === 'move') out.push('Move this initiative to ' + ((p.data && p.data.to) || 'the next stage') + '.');
66
+ else if (t) out.push(t);
67
+ else if (p.data && Array.isArray(p.data.items)) out.push(plural(p.data.items.length, 'item') + ' with a pointer each.');
68
+ else if (p.data && typeof p.data === 'object' && Object.keys(p.data).length) out.push(Object.entries(p.data).map(([k, v]) => k + ': ' + (typeof v === 'string' ? v : JSON.stringify(v))).join('\n'));
69
+ if (p.what === 'next-step' && p.data && p.data.due) out.push('Due: ' + dueLabel(p.data.due) + '.');
70
+ if (p.why) out.push('Why: ' + p.why);
71
+ if (p.what === 'move') out.push('Keep moves the stage. The agent only proposes.');
72
+ return out.join('\n');
73
+ }
74
+ /** The sources of a proposal: its own list, then the items of an evidence draft that carry a pointer. */
75
+ export function sourcesOf(p) {
76
+ const out = (p.sources || []).map((s) => ({ label: s.label, ref: s.ref, text: '' }));
77
+ if (p.data && Array.isArray(p.data.items)) for (const i of p.data.items) if (i && i.ref) out.push({ label: 'Evidence', ref: String(i.ref), text: String(i.text || '') });
78
+ return out;
79
+ }
80
+ const srcIcon = (label) => (/jira/i.test(label) ? 'jira' : /slack|mail|email|thread/i.test(label) ? 'mail' : 'doc');
81
+ function srcRow(s) {
82
+ const ref = isUrl(s.ref) ? el('a', { class: 'mono drv-ref', href: s.ref, target: '_blank', rel: 'noopener noreferrer' }, s.ref) : el('span', { class: 'mono drv-ref' }, s.ref);
83
+ return el('li', { 'data-drv-src': '' }, icon(srcIcon(s.label), 'sm'), el('span', { class: 'drv-src' }, el('b', null, s.label), ref, s.text ? el('span', null, s.text) : null));
84
+ }
85
+
86
+ /* ── what the PM decided this visit: kept and sent-back rows stay in the strip, marked ── */
87
+ const DECIDED = new Map(); // target key -> Map(proposal id -> { st: 'kept' | 'back', note, p })
88
+ const decidedOf = (key) => { if (!DECIDED.has(key)) DECIDED.set(key, new Map()); return DECIDED.get(key); };
89
+
90
+ /** One press on the driver. Returns the server's answer; an error is a toast and null. */
91
+ async function press(ctx, route, body, what) {
92
+ const r = await ctx.api.post('/api/driver/' + route, body);
93
+ if (!r || !r.ok) { toast({ text: errText(r, what + ' did not work'), tone: 'error', timeout: 6000 }); return null; }
94
+ return r;
95
+ }
96
+
97
+ /** The sheet that asks for a one-line note and sends a proposal back with it. */
98
+ export function openSendBack(ctx, p, onSent) {
99
+ const input = el('input', { type: 'text', 'data-drv': 'note', placeholder: 'Tell the agent what is wrong or missing', 'aria-label': 'Note to the agent', maxlength: '500', autocomplete: 'off' });
100
+ const err = el('div', { class: 'fold w', role: 'alert', hidden: true, 'data-drv': 'note-error' });
101
+ let busy = false;
102
+ const send = async () => {
103
+ const n = input.value.trim();
104
+ if (!n) { err.hidden = false; put(err, icon('alert', 'sm'), 'Write one line for the agent. A note starts its next run.'); input.focus(); return; }
105
+ if (busy) return; busy = true;
106
+ const r = await press(ctx, 'return', { proposal: p.id, note: n }, 'Send back');
107
+ busy = false;
108
+ if (!r) return;
109
+ sh.close();
110
+ toast('Sent back with your note. The agent redrafts it.');
111
+ onSent(n);
112
+ };
113
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); send(); } });
114
+ const sh = ctx.openSheet({ title: 'Send back: ' + titleOf(p), icon: 'reply', body: el('div', { class: 'drv-sheet' },
115
+ el('p', { class: 'drv-pre' }, bodyOf(p)), el('div', { class: 'form' }, input), err,
116
+ el('div', { class: 'iacts chips2' }, pbtn('primary', 'Send back with note', 'reply', { 'data-drv': 'send-back', onclick: send }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() }))) });
117
+ setTimeout(() => input.focus(), 30);
118
+ return sh;
119
+ }
120
+
121
+ /* ── the Agent strip ── */
122
+ /** Plain words for the state of the agent on this initiative, from GET /api/driver/strip. */
123
+ export function statusOf(d, now = Date.now()) {
124
+ const open = d.proposals || [];
125
+ const used = (d.budget && d.budget.used) || 0; const limit = (d.budget && d.budget.limit) || 3;
126
+ const oldest = open.map((p) => daysOld(p.at, now)).reduce((a, b) => Math.max(a, b), 0);
127
+ let last = 'No run yet.';
128
+ if (d.last) last = d.last.ok ? 'Last: ' + (d.last.proposals ? 'filed ' + plural(d.last.proposals, 'proposal') : 'looked and found nothing new') + ' · ' + whenLabel(d.last.at, new Date(now))
129
+ : 'Last run did not finish' + (d.last.reason ? ': ' + d.last.reason : '') + ' · ' + whenLabel(d.last.at, new Date(now));
130
+ if (d.paused) return { ic: 'pause', b: 'Agent is paused', sub: 'It files nothing until you resume.', last };
131
+ if (d.running) return { ic: 'spark', b: 'Agent is working · started ' + whenLabel(d.running.since, new Date(now)), sub: 'It works inside the limits you set and files each result as a proposal.', last };
132
+ if (open.length) return { ic: 'wait', b: 'Agent is idle · waiting on you', sub: oldest >= 1 ? 'The oldest proposal is ' + plural(oldest, 'day') + ' old.' : plural(open.length, 'proposal') + ' wait for your decision.', last };
133
+ if (used >= limit) return { ic: 'clock', b: 'Agent is idle · used its runs for today', sub: 'It runs up to ' + limit + ' times a day on each initiative. It starts again tomorrow.', last };
134
+ return { ic: 'spark', b: 'Agent is idle', sub: 'It starts again when something changes: a stage move, a new initiative, or a note from you.', last };
135
+ }
136
+ /** The line that says what stops the agent, or ''. A blocker the PM owns names the PM. */
137
+ export function blockerLine(d) {
138
+ if (!d.blocker || !d.blocker.text) return '';
139
+ if (d.blocker.owner === 'pm') return 'Stopped by: you. ' + d.blocker.text + '. The agent starts no run until you decide.';
140
+ return 'Stopped by: ' + String(d.blocker.text).replace(/\.$/, '') + '.';
141
+ }
142
+
143
+ /**
144
+ * The Agent section of an initiative's Overview. Draws at once with "Reading the agent" and fills itself from the server.
145
+ * ctx: api, row (the initiative), board, refresh, openSheet, openBoardSettings.
146
+ */
147
+ export function driverStrip(ctx, row) {
148
+ ensureDriverCss();
149
+ const wrap = el('div', { class: 'drv', 'data-drv': 'strip' });
150
+ const key = (ctx.ws || '') + ':initiative:' + row.id;
151
+ const S = { data: null, err: '', open: new Set(), busy: false };
152
+
153
+ async function load() {
154
+ const r = await ctx.api.get('/api/driver/strip', { target_kind: 'initiative', id: row.id, board: ctx.board && ctx.board.id });
155
+ if (!r || !r.ok) { S.data = null; S.err = errText(r, 'The agent strip could not be read'); } else { S.data = r; S.err = ''; }
156
+ draw();
157
+ }
158
+ const reread = load;
159
+
160
+ async function decide(route, p, extra) {
161
+ const r = await press(ctx, route, { proposal: p.id, ...(extra || {}) }, route === 'keep' ? 'Keep' : 'Drop');
162
+ return r;
163
+ }
164
+ async function keep(p) {
165
+ const r = await decide('keep', p);
166
+ if (!r) return;
167
+ decidedOf(key).set(p.id, { st: 'kept', p });
168
+ const w = (r.warnings || []).filter(Boolean);
169
+ toast(p.what === 'move' ? 'Kept. The stage moved to ' + ((p.data && p.data.to) || 'the next stage') + '.' + (w.length ? ' ' + w.join(' ') : '') : 'Kept: ' + titleOf(p));
170
+ if (['move', 'problem', 'next-step'].includes(r.wrote)) ctx.refresh(); // the stage chips and the record cards read the new record
171
+ else await reread();
172
+ }
173
+ async function drop(p) {
174
+ const r = await decide('drop', p);
175
+ if (!r) return;
176
+ S.open.delete(p.id);
177
+ toast('Dropped: ' + titleOf(p));
178
+ await reread();
179
+ }
180
+ const sendBack = (p) => openSendBack(ctx, p, (n) => { decidedOf(key).set(p.id, { st: 'back', note: n, p }); reread(); });
181
+ async function pause(on) {
182
+ const r = await press(ctx, on ? 'pause' : 'resume', { target_kind: 'initiative', id: row.id }, on ? 'Pause' : 'Resume');
183
+ if (!r) return;
184
+ toast(on ? 'Agent paused. It files nothing until you resume.' : 'Agent resumed.');
185
+ await reread();
186
+ }
187
+ function openRuns() {
188
+ const d = S.data; const runs = (d && d.today) || [];
189
+ const used = d && d.budget ? d.budget.used : runs.length; const limit = d && d.budget ? d.budget.limit : 3;
190
+ ctx.openSheet({ title: 'Agent runs today', icon: 'clock', body: el('div', { class: 'drv-sheet', 'data-drv': 'runs-sheet' },
191
+ note(row.title + ' · ' + used + ' of ' + limit + ' runs used today'),
192
+ runs.length ? chgl(runs.map((r) => el('li', { 'data-drv-run': r.run }, icon('clock', 'sm'),
193
+ el('span', null, el('b', null, whenLabel(r.start)), ' ', r.end ? (r.ok ? 'Run ended' : 'Run did not finish') : 'Running now'),
194
+ tag(r.end ? (r.ok ? (r.proposals ? 'Filed ' + plural(r.proposals, 'proposal') : 'Nothing new') : (r.reason || 'Failed')) : 'Running now'))))
195
+ : el('div', { class: 'fold' }, icon('info', 'sm'), 'The agent has not run on this initiative today.'),
196
+ el('div', { class: 'fold' }, icon('info', 'sm'), 'The agent runs up to ' + limit + ' times a day on each initiative. It stops at the limit and starts again tomorrow. A run that fails still counts.'),
197
+ el('div', { class: 'iacts chips2' }, pbtn('outline', 'Board settings', 'settings', { onclick: () => { closeAnd(() => ctx.openBoardSettings()); } }))) });
198
+ }
199
+ const closeAnd = (fn) => { import('./c3-sheet.js').then((m) => { m.closeSheet(); fn(); }); };
200
+
201
+ function propRow(p, state) {
202
+ const kept = state && state.st === 'kept'; const back = state && state.st === 'back';
203
+ const old = !state && daysOld(p.at) >= 1;
204
+ const srcs = sourcesOf(p);
205
+ const chips = [
206
+ tag(p.what === 'move' ? 'stage move' : 'draft'),
207
+ p.at ? chip(icon('clock', 'sm'), whenLabel(p.at)) : null,
208
+ srcs.length ? chip(icon('out', 'sm'), plural(srcs.length, 'source')) : null,
209
+ old ? el('span', { class: 'tag warn' }, 'waiting ' + plural(daysOld(p.at), 'day')) : null,
210
+ kept ? st('ok', 'check', 'Kept by you') : null,
211
+ back ? tag('Sent back · agent is redrafting') : null,
212
+ ];
213
+ const xp = el('div', { class: 'ip' },
214
+ el('p', { class: 'drv-pre' }, bodyOf(p)),
215
+ back && state.note ? el('div', { class: 'fold' }, icon('reply', 'sm'), ' Your note: ' + state.note) : null,
216
+ srcs.length ? [el('span', { class: 'dim ini-note' }, 'Sources'), chgl(srcs.map(srcRow))] : el('span', { class: 'dim ini-note' }, 'No sources. ' + (p.what === 'move' ? 'This is a stage proposal.' : 'The agent named none.')));
217
+ const acts = state ? [] : S.data.mode === 'shadow' ? [el('span', { class: 'tag', title: 'Shadow mode: the agent drafts and raises nothing.' }, 'Shadow draft')] : [
218
+ pbtn('outline', 'Keep', 'check', { 'data-drv-act': 'keep', onclick: () => keep(p) }),
219
+ pbtn('ghost', 'Send back', null, { 'data-drv-act': 'back', onclick: () => sendBack(p) }),
220
+ pbtn('ghost', 'Drop', null, { 'data-drv-act': 'drop', onclick: () => drop(p) })];
221
+ const r = nrow({ ic: (WHAT[p.what] || ['', 'doc'])[1], hot: !state, title: titleOf(p), titleAttrs: { 'data-drv-title': p.id }, chips, xp, right: acts, attrs: { 'data-drv-row': p.id, 'data-drv-state': state ? state.st : 'open' }, key: 'drv:' + p.id, open: S.open.has(p.id), onToggle: (on) => { if (on) S.open.add(p.id); else S.open.delete(p.id); } });
222
+ return r;
223
+ }
224
+
225
+ function draw() {
226
+ const d = S.data;
227
+ if (S.err || !d) { put(wrap, sect({ ic: 'spark', title: 'Agent', attrs: { 'aria-label': 'Agent' } },
228
+ S.err ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'error' }, icon('alert', 'sm'), S.err, ' ', pbtn('outline', 'Try again', null, { onclick: load })) : note('Reading the agent'))); return; }
229
+ if (!d.ledger) { put(wrap, sect({ ic: 'spark', title: 'Agent', attrs: { 'aria-label': 'Agent' } }, el('div', { class: 'fold', 'data-drv': 'no-ledger' }, icon('info', 'sm'), 'This repository has no state folder (pmos/state, state or .pmos), so the agent has nowhere to keep its drafts.'))); return; }
230
+ const used = d.budget.used + ' of ' + d.budget.limit + ' runs used today';
231
+ const right = el('div', { class: 'chips2' },
232
+ d.mode === 'approve' ? (d.paused ? pbtn('outline', 'Resume', 'next', { 'data-drv-act': 'resume', onclick: () => pause(false) }) : pbtn('outline', 'Pause', 'pause', { 'data-drv-act': 'pause', onclick: () => pause(true) })) : null,
233
+ lk({ 'data-drv': 'runs', onclick: openRuns }, icon('clock', 'sm'), used));
234
+ let body;
235
+ if (d.mode === 'off') {
236
+ body = [el('div', { class: 'lh', 'data-drv': 'off' }, el('span', { class: 'ki' }, icon('pause')),
237
+ el('div', { class: 'ini-lhm' }, el('b', null, 'Agent is off for ' + ((ctx.board && ctx.board.name) || 'this board')), el('div', { class: 'dim' }, 'Nothing is drafted on this board. Turn it on in the board settings.')),
238
+ el('div', { class: 'chips2' }, pbtn('outline', 'Board settings', 'settings', { onclick: () => ctx.openBoardSettings() })))];
239
+ } else {
240
+ const s = statusOf(d); const blk = blockerLine(d);
241
+ const decided = decidedOf(key);
242
+ const ids = new Set((d.proposals || []).map((p) => p.id));
243
+ const rows = (d.proposals || []).map((p) => propRow(p, decided.get(p.id) || null));
244
+ for (const [id, x] of decided) if (!ids.has(id) && x.st !== 'dropped') rows.push(propRow(x.p, x));
245
+ const oldest = [...(d.proposals || [])].sort((a, b) => Date.parse(a.at) - Date.parse(b.at))[0];
246
+ body = [
247
+ el('div', { class: 'lh', 'data-drv': 'status' }, el('span', { class: 'ki' }, icon(s.ic)), el('div', { class: 'ini-lhm' }, el('b', null, s.b), el('div', { class: 'dim' }, s.sub), el('div', { class: 'dim', 'data-drv': 'last' }, s.last))),
248
+ d.mode === 'shadow' ? el('div', { class: 'fold', 'data-drv': 'shadow' }, icon('info', 'sm'), ' Shadow mode. The agent drafts but raises nothing: no Needs you rows, no notes in Work. Switch to Approve in the board settings to act on these.') : null,
249
+ blk ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'blocker' }, icon('alert', 'sm'), el('span', null, blk, d.blocker.owner === 'pm' && oldest && d.mode === 'approve' ? [' ', pbtn('outline', 'Review ' + titleOf(oldest), null, { 'data-drv-act': 'focus', onclick: () => { S.open.add(oldest.id); draw(); const t = wrap.querySelector('[data-drv-row="' + oldest.id + '"]'); if (t && t.scrollIntoView) t.scrollIntoView({ block: 'center' }); } })] : null)) : null,
250
+ rows.length ? list(rows) : el('div', { class: 'fold', 'data-drv': 'empty' }, icon('check', 'sm'), 'No open proposals. The agent files the next one when it has something.')];
251
+ }
252
+ put(wrap, sect({ ic: 'spark', title: 'Agent', count: d.mode === 'off' ? 0 : (d.proposals || []).length, right, attrs: { 'aria-label': 'Agent' } }, ...body));
253
+ }
254
+
255
+ draw();
256
+ load();
257
+ return wrap;
258
+ }
259
+
260
+ /* ── the Agent setting in Board settings ── */
261
+ /** Read a board's mode. { ok, mode } or { ok: false, error }. */
262
+ export async function loadAgentMode(api, board) {
263
+ const r = await api.get('/api/driver/mode', { board });
264
+ return r && r.ok ? { ok: true, mode: r.mode } : { ok: false, error: errText(r, 'The agent setting could not be read') };
265
+ }
266
+ /** state: { mode, err, busy }. onPick(mode) saves at once (POST /api/driver/mode); the caller redraws. */
267
+ export function agentSetting(state, { onPick } = {}) {
268
+ ensureDriverCss();
269
+ const cur = MODES.find((m) => m[0] === state.mode);
270
+ return el('div', { class: 'drv-set ini-field', 'data-drv': 'setting' }, lbl('AGENT'),
271
+ state.mode == null && !state.err ? note('Reading the agent setting') : null,
272
+ state.err ? el('div', { class: 'fold w', role: 'alert', 'data-drv': 'setting-error' }, icon('alert', 'sm'), state.err) : null,
273
+ state.mode != null ? [
274
+ el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Agent mode' }, ...MODES.map((m) => btn({ 'aria-pressed': String(state.mode === m[0]), 'data-drv-mode': m[0], disabled: !!state.busy, onclick: () => onPick(m[0]) }, m[1]))),
275
+ el('div', { class: 'dim ini-note', 'data-drv': 'mode-line' }, el('b', null, cur[1] + '.'), ' ' + cur[2]),
276
+ chgl(MODES.filter((m) => m !== cur).map((m) => el('li', null, icon('next', 'sm'), el('span', null, el('b', null, m[1] + ':'), ' ' + m[2]), el('span')))),
277
+ el('div', { class: 'dim ini-note' }, 'The agent files every result as a proposal. It cannot send, merge or change a record by itself. A mode is saved when you press it.')] : null);
278
+ }
279
+
280
+ /* ── Needs you: the agent's rows (Briefing) ── */
281
+ /** The driver rows of GET /api/needs: "Agent has 3 drafts on <title>" and "<title> is waiting on you". [] when the read fails. */
282
+ export async function loadDriverNeeds() {
283
+ const r = await pmGet('/api/needs');
284
+ return r && Array.isArray(r.needs) ? r.needs.filter((n) => n && (n.kind === 'driver-drafts' || n.kind === 'driver-blocked')) : [];
285
+ }
286
+ /** One li.nrow for a driver row (the mockup's two rows). `go(row)` opens the initiative or the Work page. */
287
+ export function needsRow(n, go) {
288
+ ensureDriverCss();
289
+ const blocked = n.kind === 'driver-blocked';
290
+ const work = !n.initiative_id;
291
+ const chips = blocked ? [el('span', { class: 'tag warn' }, 'waiting on you'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)]
292
+ : [tag('agent'), chip(icon(work ? 'check' : 'init', 'sm'), work ? 'Work' : n.initiative_id)];
293
+ chips[0].className = blocked ? 'tag warn' : 'tag acc';
294
+ chips.push(el('span', { class: 'dim' }, blocked ? 'The agent will not go on until you decide' : 'Keep, send back or drop each one'));
295
+ const li = nrow({ ic: blocked ? 'next' : 'spark', hot: true, title: n.question, onOpen: () => go(n), openLabel: n.question, chips,
296
+ attrs: { 'data-needs-row': n.run_id, 'data-kind': n.kind, 'data-drv-need': n.kind, 'data-ref-row': n.initiative_id || n.run_id } });
297
+ return li;
298
+ }
@@ -1,7 +1,7 @@
1
1
  /* pm/c3-ii-tabs.js — the six tabs of an initiative page (mockup v5, 34-initiatives.js iniOverview, iniBreakdown, iniDocs, iniTickets,
2
2
  * iniTimeline, iniHistory). registerInitiativeTab(id, mount(host, ctx)); ctx.row, ctx.history (newest first), ctx.tools, ctx.toolsByCat
3
3
  * and ctx.tool come from the page (c3-initiatives.js).
4
- * Overview Left: a Summary, then one card per tool of the current stage: the stage records (c3-ini-records.js: problem, RICE, launch
4
+ * Overview On top, the Agent strip (c3-driver.js: GET /api/driver/strip; not in demo mode). Left: a Summary, then one card per tool of the current stage: the stage records (c3-ini-records.js: problem, RICE, launch
5
5
  * checklist, burndown, kill conditions, learnings) and the server's stage tools (ideas, PRD, breakdown, tickets, releases,
6
6
  * measurements, …) drawn from what PMOS holds. ctx.tool (a tool pressed in the strip or picked in More tools) is drawn
7
7
  * first and scrolled to. Right: Details (portfolio, board, workflow with Edit, stage, owner, anchor, horizon, lane), Progress,
@@ -19,6 +19,7 @@ import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems
19
19
  import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
20
20
  import { loadDocs, renderDocs } from './c3-ib-docs.js';
21
21
  import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
22
+ import { driverStrip } from './c3-driver.js';
22
23
 
23
24
  const errLine = (text) => el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), text);
24
25
  const toneOf = (s) => (s === 'late' ? 'bad' : s === 'at risk' ? 'warn' : 'ok');
@@ -138,7 +139,7 @@ tab('overview', async (ctx, isDead, host) => {
138
139
  rels.length ? card({ ic: 'out', title: 'Releases', sub: 'live in the Calendar' }, chgl(rels.map((x) => el('li', null, icon('out', 'sm'), el('span', null, (x.name || x.id) + ' · ' + day(String(x.release_at || x.deploy_at || '').slice(0, 10))), tag('Calendar'))))) : null,
139
140
  ];
140
141
  if (picked) requestAnimationFrame(() => { const n = host.querySelector('[data-ini-tool-card="' + CSS.escape(picked) + '"]'); if (n && !isDead()) { if (n.scrollIntoView) n.scrollIntoView({ block: 'nearest' }); n.classList.add('ini-flash'); setTimeout(() => n.classList.remove('ini-flash'), 1400); } });
141
- return cols(left, right);
142
+ return [ctx.demo ? null : driverStrip(ctx, r), cols(left, right)];
142
143
  });
143
144
 
144
145
  /* ── Breakdown ── */
@@ -7,7 +7,7 @@
7
7
  * Rules the builders keep (the conformance gate, R28): every control is a <button> from primitives.js button() (btn() below adds the
8
8
  * console3 class it is drawn as, and `ini-b`, the reset that takes the primitive's own height and nowrap off); a colour, a width or a
9
9
  * position is a class or a `--in-…` custom property set with geo(), never an inline style; icons come from the console3 sprite
10
- * (c3ic) or, for the three it lacks (gear, grip, the Jira mark), from svgIcon(). Nothing here reads or writes the server.
10
+ * (c3ic) or, for the four it lacks (gear, grip, pause, the Jira mark), from svgIcon(). Nothing here reads or writes the server.
11
11
  *
12
12
  * btn(attrs, ...kids) a button; attrs.class is the console3 class (pill, chip2 lk, ib sm, tt, ...), attrs.onclick its press
13
13
  * pbtn(variant, label, icon, attrs) a primitive button drawn as the primitive (primary, outline, ghost), small
@@ -41,12 +41,14 @@ export function svgIcon(paths, cls) {
41
41
  }
42
42
  export const GEAR = ['M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'];
43
43
  export const GRIP = ['M9 5h.01M9 12h.01M9 19h.01M15 5h.01M15 12h.01M15 19h.01'];
44
+ export const PAUSE = ['M8 5v14M16 5v14'];
44
45
  export const JIRA = ['M12 3l9 9-9 9-9-9z', 'M12 8l4 4-4 4-4-4z'];
45
46
  /** An icon by name: the sprite's, or one of the three drawn here. */
46
47
  export function icon(name, cls) {
47
48
  if (name === 'settings') return svgIcon(GEAR, cls);
48
49
  if (name === 'grip') return svgIcon(GRIP, cls);
49
50
  if (name === 'jira') return svgIcon(JIRA, cls);
51
+ if (name === 'pause') return svgIcon(PAUSE, cls);
50
52
  return c3ic(name, cls);
51
53
  }
52
54
 
@@ -170,7 +172,7 @@ export function nrow(o) {
170
172
  el('span', { class: 'ki' + (o.hot ? ' hot' : '') + (o.tone ? ' ini-k-' + o.tone : '') }, icon(o.ic || 'doc')),
171
173
  el('div', { class: 'nb' }, t, o.sub ? note(o.sub) : null, o.chips && o.chips.length ? el('div', { class: 'chips' }, ...o.chips.filter(Boolean)) : null, o.after || null, xp),
172
174
  o.right && o.right.filter(Boolean).length ? el('div', { class: 'acts' }, ...o.right.filter(Boolean)) : el('span'));
173
- if (xp) attachExpand(li, xp, { key: o.key });
175
+ if (xp) attachExpand(li, xp, { key: o.key, initial: !!o.open, onToggle: o.onToggle });
174
176
  return li;
175
177
  }
176
178
  export function crumbs(parts, right) {
@@ -48,6 +48,7 @@ import {
48
48
  import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
49
49
  import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
50
50
  import { RECORD_META, recordToolsFor } from './c3-ini-records.js';
51
+ import { agentSetting, loadAgentMode, MODES as AGENT_MODES } from './c3-driver.js';
51
52
 
52
53
  export { btn, geo };
53
54
  export { TEMPLATES, CATS };
@@ -386,6 +387,18 @@ export function mountInitiatives(pane, opts) {
386
387
  const sh = openSheet({ title: 'Board settings · ' + b.name, icon: 'cols', body: host, wide: true });
387
388
  let usage = null;
388
389
  loadItems(makeCtx(b, {})).then((r) => { usage = r; render(); });
390
+ // The Agent setting (c3-driver.js): read once, and saved at once when a mode is pressed (POST /api/driver/mode). Not in demo mode.
391
+ const A = { mode: null, err: '', busy: false };
392
+ if (!opts.demo) loadAgentMode(apiFor(b.ws), b.id).then((r) => { if (r.ok) A.mode = r.mode; else A.err = r.error; render(); });
393
+ async function pickAgent(mode) {
394
+ if (A.busy || mode === A.mode) return;
395
+ A.busy = true; A.err = ''; render();
396
+ const r = await apiFor(b.ws).post('/api/driver/mode', { board: b.id, mode });
397
+ A.busy = false;
398
+ if (!r || !r.ok) A.err = 'The agent setting was not saved: ' + ((r && r.error) || 'no answer') + '.';
399
+ else { A.mode = r.mode; toast(b.name + ': Agent ' + (AGENT_MODES.find((m) => m[0] === r.mode) || [0, r.mode])[1] + '.'); }
400
+ render('[data-drv-mode="' + A.mode + '"]');
401
+ }
389
402
  function render(focusSel) {
390
403
  const allowed = (k) => B.build || !templateBuilds(k);
391
404
  for (const k of [...B.offer]) if (!allowed(k)) B.offer.delete(k);
@@ -409,6 +422,7 @@ export function mountInitiatives(pane, opts) {
409
422
  lbl('DEFAULT FOR NEW INITIATIVES AND ACCEPTED ASKS'),
410
423
  tplSeg([...B.offer], B.def, (k) => { B.def = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'),
411
424
  tplCase(B.def),
425
+ opts.demo ? null : agentSetting(A, { onPick: pickAgent }),
412
426
  el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, 'Initiatives that do not run their own workflow run the board\'s: ' + workflowLabel(b) + '.'), lk({ 'data-ini': 'edit-board-workflow', onclick: () => openBoardWorkflow(b) }, 'Edit the board\'s workflow')),
413
427
  B.err ? errLine(B.err) : null,
414
428
  el('div', { class: 'chips2' }, pbtn('primary', B.busy ? 'Saving' : 'Save board settings', null, { 'data-ini': 'settings-save', disabled: B.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
@@ -524,7 +524,7 @@ function draftsTab(pane) {
524
524
  });
525
525
  }
526
526
 
527
- const BUILD = { brief: briefTab, cal: (p) => mountCalendarViews(p, { view: 'week', page: true, scope: sc('cal'), deck: planNode('cal', { bare: true }) }), work: (p) => { mountPmWork(p, { go: (t) => showTab(t, true) }); }, init: (p) => mountInitiatives(p, { ws: S.ws, workspaces: S.workspaces, demo: DEMO, showTab }), rep: repTab };
527
+ const BUILD = { brief: briefTab, cal: (p) => mountCalendarViews(p, { view: 'week', page: true, scope: sc('cal'), deck: planNode('cal', { bare: true }) }), work: (p) => { mountPmWork(p, { go: (t) => showTab(t, true), ws: S.ws }); }, init: (p) => mountInitiatives(p, { ws: S.ws, workspaces: S.workspaces, demo: DEMO, showTab }), rep: repTab };
528
528
 
529
529
  function showTab(id, push) {
530
530
  if (!BUILD[id] && id !== 'settings') id = 'brief';
@@ -315,8 +315,15 @@ const later = (inst, fn, ms) => { const t = setTimeout(() => { inst.timers.delet
315
315
 
316
316
  /* ── reads and presses ───────────────────────────────────────────────────────────────────────────────────────────── */
317
317
 
318
+ /* The Initiative Driver's open Work proposals (c3-driver-work.js), read with the list. Null when there are none to draw: demo mode, an old server, no answer. */
319
+ async function driverRead(inst) {
320
+ if (DEMO() || inst.ctx.FIXTURE) return null;
321
+ try { const m = await import('./c3-driver-work.js'); const o = await m.loadDriverOpen(inst.ctx.ws); return o ? { m, ws: o.ws, mode: o.mode, targets: o.targets || {} } : null; } catch { return null; }
322
+ }
323
+
318
324
  async function load(inst) {
319
- const r = await pmGet(API);
325
+ const [r, drv] = await Promise.all([pmGet(API), driverRead(inst)]);
326
+ inst.drv = drv;
320
327
  inst.base = r && r.ok ? r : null;
321
328
  inst.data = inst.base && DEMO() ? applyOv(inst.base, inst.ov) : inst.base;
322
329
  if (!inst.data) say({ text: (r && r.error) || COPY.readError, tone: 'error' });
@@ -851,9 +858,12 @@ function itemRow(inst, item, lens, o = {}) {
851
858
  if (lens === 'inbox') right = button({ label: COPY.decide, variant: 'ghost', size: 'sm', icon: 'next', attrs: { 'data-pm-decide': item.id }, onClick: (e) => { e.stopPropagation(); if (row.__exp) row.__exp.toggle(); } });
852
859
  else if (lens === 'waiting') right = c3ib(COPY.nudge, 'out', { 'data-pm-nudge': item.id, onClick: (e) => { e.stopPropagation(); say({ text: COPY.nudged(item.waiting_on || 'them'), timeout: 4000 }); } }, 'sm');
853
860
  else if (lens !== 'someday') right = c3ib(COPY.doneBtn, 'check', { 'data-pm-done': item.id, onClick: (e) => { e.stopPropagation(); doneAct(inst, item, row, 'Done.'); } }, 'sm');
861
+ /* the agent line (Initiative Driver): one line under the title when the agent filed a proposal for this task */
862
+ const dn = inst.drv && !item.done ? inst.drv.m.workNote({ item, props: inst.drv.targets[item.id] || [], ws: inst.drv.ws, mode: inst.drv.mode, done: () => load(inst) }) : null;
863
+ const agentLine = dn ? el('div', { class: 'dim drv-small drv-sub', 'data-drv-line': item.id }, dn) : null;
854
864
  const row = el('li', { class: 'nrow wrow', 'data-row': '', 'data-id': item.id, 'data-w': '1', 'data-pm-item': item.id, 'data-lens': lens },
855
865
  el('span', { class: 'ki' + (hot ? ' hot' : ''), title: item.kind === 'offline' ? 'Happens outside PMOS' : item.priority || '' }, c3ic(icon)),
856
- el('div', { class: 'nb' }, title, mock && item.left ? fuseBar(item.left) : null, chips.filter(Boolean).length ? el('div', { class: 'chips' }, chips.filter(Boolean)) : null),
866
+ el('div', { class: 'nb' }, title, agentLine, mock && item.left ? fuseBar(item.left) : null, chips.filter(Boolean).length ? el('div', { class: 'chips' }, chips.filter(Boolean)) : null),
857
867
  right ? el('div', { class: 'acts' }, right) : el('span'),
858
868
  el('span', { class: 'sg', title: agent ? 'Drag right: the agent acts. Drag left: reply.' : 'Drag right: ' + spec.right.label + '. Drag left: ' + spec.left.label + '.' },
859
869
  el('span', { class: 'g l' + (recSide === 'l' ? ' y' : '') }, c3ic(spec.left.icon)), el('span', { class: 'g r' + (recSide === 'r' ? ' y' : '') }, c3ic(spec.right.icon))));
@@ -139,6 +139,7 @@
139
139
  "pm-portfolio-inline-test.js",
140
140
  "pm-brief-page-test.js",
141
141
  "c3-settings-test.js",
142
+ "c3-driver-test.js",
142
143
  "c3-sessions-panel-test.js",
143
144
  "c3-updates-test.js"
144
145
  ],
@@ -190,6 +191,7 @@
190
191
  "guide.test.js": 1,
191
192
  "driver.test.js": 3,
192
193
  "driver-server.test.js": 25,
194
+ "c3-driver-test.js": 40,
193
195
  "next-step-actions.test.js": 5,
194
196
  "kg.test.js": 1,
195
197
  "doc-category.test.js": 1,