create-pmos 0.168.0 → 0.170.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 (40) 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/c3-scan-fixture.js +78 -0
  9. package/workspace/c3-scan-test.js +170 -0
  10. package/workspace/guide/horizon-scan.md +73 -0
  11. package/workspace/guide/index.md +1 -0
  12. package/workspace/guide/initiative-driver.md +12 -0
  13. package/workspace/lib/engine/boards.js +8 -2
  14. package/workspace/lib/headless.js +21 -3
  15. package/workspace/lib/pmos-verbs.js +3 -0
  16. package/workspace/lib/route-modules.js +1 -0
  17. package/workspace/lib/routes-boards.js +30 -1
  18. package/workspace/lib/routes-driver.js +38 -6
  19. package/workspace/lib/routes-scan.js +130 -0
  20. package/workspace/lib/scan-cli.js +226 -0
  21. package/workspace/lib/scan-run.js +186 -0
  22. package/workspace/lib/scan.js +396 -0
  23. package/workspace/package-lock.json +2 -2
  24. package/workspace/package.json +1 -1
  25. package/workspace/public/pm/c3-brief-needs.js +19 -3
  26. package/workspace/public/pm/c3-driver-work.js +110 -0
  27. package/workspace/public/pm/c3-driver.css +18 -0
  28. package/workspace/public/pm/c3-driver.js +298 -0
  29. package/workspace/public/pm/c3-ib-triage.js +6 -2
  30. package/workspace/public/pm/c3-ii-tabs.js +3 -2
  31. package/workspace/public/pm/c3-ini-ui.js +4 -2
  32. package/workspace/public/pm/c3-initiatives.js +21 -0
  33. package/workspace/public/pm/c3-scan.css +25 -0
  34. package/workspace/public/pm/c3-scan.js +263 -0
  35. package/workspace/public/pm/c3-shell.js +3 -2
  36. package/workspace/public/pm/pm-work-view.js +12 -2
  37. package/workspace/server.js +40 -4
  38. package/workspace/skills/horizon-scan-repo.skill +56 -0
  39. package/workspace/skills/horizon-scan-web.skill +43 -0
  40. package/workspace/test-files.json +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.168.0",
3
+ "version": "0.170.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@b5055b4
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-12T17:05:32Z"}
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-12T17:05:32Z"}
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-12T17:05:32Z"}
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); });
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+ /* c3-scan-fixture.js — a real server, a scratch repository and a seeded scan ledger for the console3 Horizon scan suite (c3-scan-test.js).
3
+ * Not a suite: it asserts nothing. The server runs with PMOS_DRIVER=off, so no scan and no suggest run starts: every suggestion, scan and
4
+ * finding the page shows is a line this file wrote with lib/scan.js (the single writer) and the boards ledger.
5
+ *
6
+ * const F = await start({ name }) -> { BASE, ws, board, team, ledger, boards, post, get, seedScan, stop, S }
7
+ * at start: the board holds three suggestions from the agent (a product, a domain, an approach) and no scan has run
8
+ * seedScan() writes one finished scan of today: 2 web findings and 1 repository finding, each already an open Triage ask
9
+ */
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawn } = require('child_process');
13
+ const T = require('./test-run').configure({ name: 'c3-scan' });
14
+ const { makeRepos, codeRepo, write, git } = require('./plan-test-repos');
15
+ const S = require('./lib/scan.js');
16
+
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-scan' } = {}) {
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', 'scan.jsonl');
30
+ const boards = path.join(team, 'pmos', 'state', 'boards.jsonl');
31
+ const BASE = T.BASE;
32
+ const srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore',
33
+ env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: team, PMOS_NO_OPEN: '1', PMOS_DRIVER: 'off' }) });
34
+ await T.waitForServer(BASE);
35
+ const press = await T.pressHeaders(BASE);
36
+ const post = (p, body) => call(BASE, 'POST', p, body, press);
37
+ const get = (p) => call(BASE, 'GET', p);
38
+ const reg = await post('/api/workspaces', { root: team, focus: true });
39
+ const ws = reg.body.workspace.id;
40
+ const made = await post('/api/initiatives', { ws, title: 'Partner API onboarding', lane: 'feature', anchor: 'health:tech', id: 'scan-ui' });
41
+ if (made.status !== 200) throw new Error('could not create the initiative: ' + made.raw);
42
+ const now = Date.now();
43
+ const w = (line, at) => S.append(ledger, line, { now: at });
44
+ w({ ev: 'scan-start', board: ws, scan: 'scan-0000000001', mode: 'suggest', watch: [] }, now - 3600000 * 30);
45
+ w({ ev: 'watch-suggest', board: ws, id: 'w-00000001', kind: 'product', name: 'Rival Notes', why: 'A direct rival in partner onboarding', ini: 'scan-ui' }, now - 3600000 * 30);
46
+ w({ ev: 'watch-suggest', board: ws, id: 'w-00000002', kind: 'domain', name: 'Data privacy law', why: 'We store partner emails', ini: 'scan-ui' }, now - 3600000 * 30);
47
+ w({ ev: 'watch-suggest', board: ws, id: 'w-00000003', kind: 'approach', name: 'Eval-first delivery', why: 'How other teams write the test before the build', ini: 'scan-ui' }, now - 3600000 * 30);
48
+ w({ ev: 'scan-end', board: ws, scan: 'scan-0000000001', ok: true, pages: 0, found: 3, skipped: 0, runs: 1 }, now - 3600000 * 30 + 60000);
49
+
50
+ /** One finished scan of today, with its three findings as open Triage asks. */
51
+ function seedScan() {
52
+ const t0 = new Date(); t0.setHours(9, 0, 0, 0);
53
+ const t = Math.min(t0.getTime(), now - 120000);
54
+ const scan = 'scan-00000000a2';
55
+ w({ ev: 'watch-keep', board: ws, id: 'w-00000001', by: 'pm:wawan' }, t - 60000);
56
+ w({ ev: 'scan-start', board: ws, scan, mode: 'scan', watch: [{ id: 'w-00000001', kind: 'product', name: 'Rival Notes', why: '' }] }, t);
57
+ const F = [
58
+ { id: 'f-00000000a1', origin: 'web', title: 'Rival Notes ships PDF export for partners', why: 'A rival added PDF export for partner reports. Two of our partners asked for the same.', match: null,
59
+ sources: [{ label: 'Rival blog', ref: 'https://rivalnotes.example/blog/pdf-export' }, { label: 'Changelog', ref: 'https://rivalnotes.example/changelog' }] },
60
+ { id: 'f-00000000a2', origin: 'web', title: 'New consent rule for partner emails', why: 'A new privacy rule changes how consent for partner emails must be stored.', match: 'scan-ui',
61
+ sources: [{ label: 'Regulator notice', ref: 'https://regulator.example/privacy/consent-rule' }] },
62
+ { id: 'f-00000000a3', origin: 'repo', title: 'Retire the unused CSV importer', why: 'No file in the repository calls it and it has not changed in 9 months.', match: null,
63
+ sources: [{ label: 'File', ref: 'src/importer.js' }, { label: 'Jira', ref: 'IDPF-1204' }] },
64
+ ];
65
+ F.forEach((f, i) => {
66
+ w({ ev: 'finding', board: ws, scan, id: f.id, origin: f.origin, title: f.title, why: f.why, sources: f.sources, match: f.match, by: 'agent:sess-4.0a1b2c3d', run: 'srun-000000000' + (i < 2 ? 1 : 2) }, t + 1000 * (i + 1));
67
+ const line = { at: new Date(t + 5000 + i).toISOString(), by: 'agent:sess-4.0a1b2c3d', kind: 'ask', board: ws, ask: 'ask-' + f.id.slice(2), line: f.title, source: 'scan', draft: null,
68
+ found: { why: f.why, sources: f.sources, match: f.match, scan, run: 'srun-000000000' + (i < 2 ? 1 : 2) } };
69
+ fs.appendFileSync(boards, JSON.stringify(line) + '\n');
70
+ });
71
+ w({ ev: 'skip', board: ws, scan, origin: 'web', of: 'f-00000000a1' }, t + 8000);
72
+ w({ ev: 'scan-end', board: ws, scan, ok: true, pages: 12, found: 3, skipped: 1, runs: 2 }, t + 60000);
73
+ return { scan, findings: F.map((f) => f.id), asks: F.map((f) => 'ask-' + f.id.slice(2)) };
74
+ }
75
+ const stop = () => { try { srv.kill('SIGKILL'); } catch { /* gone */ } T.killTmux(); R.cleanup(); };
76
+ return { BASE, ws, board: ws, team, ledger, boards, post, get, seedScan, stop, T, S };
77
+ }
78
+ module.exports = { start };
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-scan-test.js — the Horizon scan on console3 (initiative horizon-scan; PRD section 6 and the mockup 40-scan).
4
+ * LIVE mode: a real server and a scratch repository whose scan ledger this suite seeds (c3-scan-fixture.js; PMOS_DRIVER=off, so no scan
5
+ * and no suggest run starts and every line the page shows is one the fixture wrote). A real Chromium drives /c3.html.
6
+ *
7
+ * settings Board settings has the Horizon scan block: How often (Daily, Weekly, Off), the schedule line, the banner, the watch list with
8
+ * Keep, Edit and Remove on each suggestion, Undo after Remove, and Add; every press posts to /api/scan/* with no writer field
9
+ * triage the asks a scan found read "Found by agent", with the reason, source chips (a public page is a link), the match chip and
10
+ * Accept, Ask for more and Decline
11
+ * briefing the Horizon scan card: the counts, one row per finding, "Nothing was sent", Open Triage and Scan settings both navigate
12
+ * phone at 400 wide none of the three has a horizontal scroll
13
+ * Set SCAN_SHOTS=<dir> to save screenshots of the three screens at 1280 and 400 wide. Every wait polls the page. No script error may be raised.
14
+ */
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { findBrowser } = require('./lib/find-browser');
18
+ const F = require('./c3-scan-fixture');
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ 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); } };
22
+ const results = [];
23
+ const ok = (n, cond, extra = '') => { results.push({ n, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${n}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 500) : ''}`); };
24
+ let fx = null; let browser = null;
25
+ const finish = async (code) => {
26
+ if (browser) await browser.close().catch(() => {});
27
+ if (fx) fx.stop();
28
+ const bad = results.filter((x) => !x.cond).length;
29
+ console.log(`\n${results.length - bad}/${results.length} c3-scan assertions passed`);
30
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
31
+ };
32
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
33
+
34
+ (async () => {
35
+ const exe = findBrowser();
36
+ if (!exe) { console.log('· no Chrome/Chromium on this machine — c3-scan suite skipped'); process.exit(0); }
37
+ let chromium;
38
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('· playwright-core not installed — c3-scan suite skipped'); process.exit(0); }
39
+ fx = await F.start({ name: 'c3-scan' });
40
+ const { BASE, ws, S, ledger } = fx;
41
+ const state = () => S.fold(S.readLines(ledger));
42
+ browser = await chromium.launch({ executablePath: exe });
43
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
44
+ const page = await ctx.newPage();
45
+ const errors = []; page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
46
+ const posts = []; page.on('request', (r) => { if (r.method() === 'POST' && r.url().includes('/api/scan/')) posts.push({ path: new URL(r.url()).pathname, body: JSON.parse(r.postData() || '{}') }); });
47
+ const st = (f, a) => page.evaluate(f, a);
48
+ const sel = (s) => until(() => page.$(s));
49
+ const shots = process.env.SCAN_SHOTS || '';
50
+ if (shots) fs.mkdirSync(shots, { recursive: true });
51
+ const shot = async (name) => { if (shots) await page.screenshot({ path: path.join(shots, name + '.png'), fullPage: false }); };
52
+ const noScroll = () => st(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
53
+ const openSettings = async () => {
54
+ await page.goto(BASE + `/c3.html?tab=init&init=board:${ws}:summary`, { waitUntil: 'load' });
55
+ await (await sel('[data-ini=board-settings]')).click();
56
+ await sel('[data-scan-set] [data-scan-item]');
57
+ };
58
+
59
+ /* ── settings ── */
60
+ await openSettings();
61
+ const items = () => st(() => [...document.querySelectorAll('[data-scan-item]')].map((n) => ({ id: n.dataset.scanItem, state: n.dataset.scanState, text: n.textContent })));
62
+ const blk = await st(() => document.querySelector('[data-scan-set]').textContent);
63
+ ok('T1 the block says HORIZON SCAN, How often, the schedule line and the watch list count', /HORIZON SCAN/.test(blk) && /HOW OFTEN/.test(blk) && /Every working day, 09:00/.test(blk) && /WATCH LIST \(3\)/.test(blk), blk.slice(0, 300));
64
+ ok('T2 Daily is pressed; Weekly and Off are not', await st(() => ['daily', 'weekly', 'off'].map((k) => document.querySelector(`[data-scan-freq=${k}]`).getAttribute('aria-pressed')).join() === 'true,false,false'));
65
+ ok('T3 the first-time banner says the agent suggests 3 things and to keep, edit or remove each', /The agent suggests 3 things to watch\. Keep, edit or remove each before the first scan\./.test(await st(() => (document.querySelector('[data-scan="banner"]') || {}).textContent || '')));
66
+ ok('T4 the last scan line says none yet and that nothing runs until an item is kept', /Last scan: none yet/.test(blk) && /No scan runs until you keep an item/.test(blk), blk);
67
+ const its = await items();
68
+ ok('T5 three suggestions, each with a kind tag, the initiative it came from, Keep, Edit and Remove', its.length === 3 && its.every((i) => i.state === 'suggested') && await st(() => [...document.querySelectorAll('[data-scan-item]')].every((n) => n.querySelector('[data-scan-act=keep]') && n.querySelector('[data-scan-act=edit]') && n.querySelector('[data-scan-act=remove]') && /from scan-ui/.test(n.textContent))), JSON.stringify(its));
69
+ await st(() => document.querySelector('[data-scan-set]').scrollIntoView({ block: 'start' }));
70
+ await shot('settings-1280');
71
+ ok('T6 the block uses the real buttons (no hand-made controls)', await st(() => [...document.querySelectorAll('[data-scan-set] button')].every((b) => /pmc-btn|(^| )btn( |$)/.test(b.className))));
72
+ await page.click('[data-scan-item="w-00000001"] [data-scan-act=keep]');
73
+ await until(() => state().boards.get(ws).items.get('w-00000001').state === 'kept');
74
+ await until(() => st(() => document.querySelector('[data-scan-item="w-00000001"]').dataset.scanState === 'kept'));
75
+ ok('T7 Keep posts op keep with the item and no writer field; the ledger holds the keep by the PM; the row reads Kept', posts.some((p) => p.path === '/api/scan/watch' && p.body.op === 'keep' && p.body.id === 'w-00000001' && !('by' in p.body))
76
+ && S.readLines(ledger).some((l) => l.ev === 'watch-keep' && l.id === 'w-00000001' && l.by === 'pm:wawan') && /Kept/.test((await items())[0].text));
77
+ await page.click('[data-scan-item="w-00000002"] [data-scan-act=edit]');
78
+ await sel('[data-scan-edit-in]');
79
+ await page.fill('[data-scan-edit-in]', 'Data privacy law (EU)');
80
+ await page.click('[data-scan-act=save]');
81
+ await until(() => state().boards.get(ws).items.get('w-00000002').name === 'Data privacy law (EU)');
82
+ ok('T8 Edit then Save posts op edit with the new name and the item becomes kept', posts.some((p) => p.body.op === 'edit' && p.body.name === 'Data privacy law (EU)') && state().boards.get(ws).items.get('w-00000002').state === 'kept');
83
+ await page.click('[data-scan-item="w-00000003"] [data-scan-act=edit]');
84
+ await sel('[data-scan-edit-in]');
85
+ await page.click('[data-scan-act=cancel]');
86
+ ok('T9 Cancel leaves the item as it was', state().boards.get(ws).items.get('w-00000003').name === 'Eval-first delivery' && state().boards.get(ws).items.get('w-00000003').state === 'suggested' && !(await page.$('[data-scan-edit-in]')));
87
+ await page.click('[data-scan-item="w-00000003"] [data-scan-act=remove]');
88
+ await until(() => !state().boards.get(ws).items.get('w-00000003') || state().boards.get(ws).items.get('w-00000003').state === 'removed');
89
+ await sel('[data-c3-toast-action]');
90
+ ok('T10 Remove posts op remove, the row leaves, and a toast offers Undo', posts.some((p) => p.body.op === 'remove' && p.body.id === 'w-00000003') && (await items()).length === 2 && /Removed Eval-first delivery/.test(await st(() => document.querySelector('[data-c3-toast]').textContent)));
91
+ await page.click('[data-c3-toast-action]');
92
+ await until(() => state().boards.get(ws).items.get('w-00000003').state === 'suggested');
93
+ await until(async () => (await items()).length === 3);
94
+ ok('T11 Undo posts keep with restore and the item is back as it was (suggested)', posts.some((p) => p.body.op === 'keep' && p.body.restore === true && p.body.id === 'w-00000003') && (await items()).length === 3);
95
+ await page.click('[data-scan-act=add]');
96
+ ok('T12 Add with nothing typed says so in plain words and posts nothing', /Write what to watch first/.test(await st(() => (document.querySelector('[data-scan="add-error"]') || {}).textContent || '')) && !posts.some((p) => p.body.op === 'add'));
97
+ await page.fill('[data-scan-add-in]', 'Partner SDK trends');
98
+ await page.selectOption('[data-scan-add-kind]', 'domain');
99
+ await page.click('[data-scan-act=add]');
100
+ await until(() => [...state().boards.get(ws).items.values()].some((i) => i.name === 'Partner SDK trends'));
101
+ await until(async () => (await items()).length === 4);
102
+ const added = [...state().boards.get(ws).items.values()].find((i) => i.name === 'Partner SDK trends');
103
+ ok('T13 Add posts op add with the name and kind; the item is kept and marked added by you', posts.some((p) => p.body.op === 'add' && p.body.kind === 'domain' && p.body.name === 'Partner SDK trends') && added.state === 'kept' && added.added === true && /added by you/.test((await items()).find((i) => i.id === added.id).text));
104
+ await page.click('[data-scan-freq=weekly]');
105
+ await until(() => S.cadenceOf(state(), ws) === 'weekly');
106
+ await until(() => st(() => document.querySelector('[data-scan-freq=weekly]').getAttribute('aria-pressed') === 'true'));
107
+ ok('T14 Weekly posts the cadence; the line says Every Monday, 09:00 and a toast says so', posts.some((p) => p.path === '/api/scan/cadence' && p.body.cadence === 'weekly' && p.body.board === ws) && /Every Monday, 09:00/.test(await st(() => document.querySelector('[data-scan="schedule"]').textContent)));
108
+ await page.click('[data-scan-freq=off]');
109
+ await until(() => S.cadenceOf(state(), ws) === 'off');
110
+ await until(() => st(() => /^Off\./.test(document.querySelector('[data-scan="schedule"]').textContent)));
111
+ ok('T15 Off says that no scan runs and shows no next scan', await st(() => /Off\. No scan runs on this board/.test(document.querySelector('[data-scan="schedule"]').textContent) && !document.querySelector('[data-scan="next"]')));
112
+ await page.click('[data-scan-freq=daily]');
113
+ await until(() => S.cadenceOf(state(), ws) === 'daily');
114
+ await until(() => st(() => document.querySelector('[data-scan-freq=daily]').getAttribute('aria-pressed') === 'true'));
115
+ ok('T16 Daily brings the schedule back, with a next scan', await st(() => /Every working day, 09:00/.test(document.querySelector('[data-scan="schedule"]').textContent) && !!document.querySelector('[data-scan="next"]')));
116
+ await page.setViewportSize({ width: 400, height: 900 });
117
+ await sleep(300);
118
+ await st(() => document.querySelector('[data-scan-set]').scrollIntoView({ block: 'start' }));
119
+ await shot('settings-400');
120
+ ok('T17 at 400 wide the settings have no horizontal scroll', await noScroll(), await st(() => document.documentElement.scrollWidth));
121
+ await page.setViewportSize({ width: 1280, height: 900 });
122
+
123
+ /* ── triage ── */
124
+ const seed = fx.seedScan();
125
+ await page.goto(BASE + `/c3.html?tab=init&init=board:${ws}:triage`, { waitUntil: 'load' });
126
+ await sel(`[data-ibt-ask="${seed.asks[0]}"]`);
127
+ const row = (i) => st((id) => { const r = document.querySelector(`[data-ibt-ask="${id}"]`); return { text: r.textContent, agent: !!r.querySelector('[data-scan="found-by-agent"]'), links: [...r.querySelectorAll('a[data-scan-src=web]')].map((a) => ({ href: a.href, target: a.target, rel: a.rel })),
128
+ noMatch: !!r.querySelector('[data-scan="no-match"]'), accept: !!r.querySelector('[data-ibt-accept]'), more: !!r.querySelector('[data-ibt-more]'), decline: !!r.querySelector('[data-ibt-decline]'), srcs: r.querySelectorAll('[data-scan-src]').length }; }, seed.asks[i]);
129
+ const r0 = await row(0); const r1 = await row(1); const r2 = await row(2);
130
+ ok('R1 each scan ask reads "Found by agent" with the reason, the Horizon scan chip and the source count', [r0, r1, r2].every((r) => r.agent && /Horizon scan/.test(r.text)) && /A rival added PDF export for partner reports/.test(r0.text) && /2 sources/.test(r0.text), r0.text.slice(0, 400));
131
+ ok('R2 a public page source is a link that opens in a new tab with noopener; a repository path and a Jira key are chips', r0.links.length === 2 && r0.links.every((l) => l.target === '_blank' && /noopener/.test(l.rel)) && /rivalnotes\.example\/blog\/pdf-export/.test(r0.links[0].href) && r2.links.length === 0 && r2.srcs === 2 && /src\/importer\.js/.test(r2.text) && /IDPF-1204/.test(r2.text), JSON.stringify([r0.links, r2.srcs]));
132
+ ok('R3 the match chip names the initiative; an ask with no match says "no initiative yet"', /matches scan-ui/.test(r1.text) && !r1.noMatch && r0.noMatch && r2.noMatch);
133
+ ok('R4 each has Accept, Ask for more and Decline with the shared attributes', [r0, r1, r2].every((r) => r.accept && r.more && r.decline) && /Accept into/.test(r0.text) && /Ask for more/.test(r0.text) && /Decline/.test(r0.text));
134
+ ok('R5 the agent is named by page name with no token', !/0a1b2c3d/.test(await st(() => document.body.innerHTML)));
135
+ await shot('triage-1280');
136
+ await page.setViewportSize({ width: 400, height: 900 });
137
+ await sleep(300);
138
+ await st((id) => document.querySelector(`[data-ibt-ask="${id}"]`).scrollIntoView({ block: 'start' }), seed.asks[0]);
139
+ await shot('triage-400');
140
+ ok('R6 at 400 wide Triage has no horizontal scroll', await noScroll(), await st(() => document.documentElement.scrollWidth));
141
+ await page.setViewportSize({ width: 1280, height: 900 });
142
+
143
+ /* ── briefing ── */
144
+ await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
145
+ await sel('[data-c3pg="scan"] [data-scan-find]');
146
+ const card = await st(() => document.querySelector('[data-c3pg="scan"]').textContent);
147
+ ok('B1 the Horizon scan card counts the day: 3 asks found, 1 matches an initiative, 2 new, the board scanned', /Horizon scan/.test(card) && /3 asks found/.test(card) && /1 matches an initiative/.test(card) && /2 new/.test(card) && /1 board scanned/.test(card), card.slice(0, 400));
148
+ ok('B2 one row per finding, in the order they were found', await st(() => [...document.querySelectorAll('[data-scan-find]')].map((n) => n.dataset.scanFind).join()) === seed.findings.join());
149
+ ok('B3 the foot says nothing was sent, 12 public pages were read and 1 finding was skipped', /Nothing was sent\. The agent read 12 public pages\. It skipped 1 finding from earlier scans\./.test(card), card);
150
+ await shot('briefing-1280');
151
+ await page.click('[data-scan-find] [data-scan-go]');
152
+ await until(() => page.$('[data-ibt-ask]'));
153
+ ok('B4 "Open in Triage" on a finding opens that board\'s Triage', !!(await page.$(`[data-ibt-ask="${seed.asks[0]}"]`)) && /board:.*triage/.test(decodeURIComponent(page.url())), page.url());
154
+ await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
155
+ await sel('[data-scan-settings]');
156
+ await page.click('[data-scan-settings]');
157
+ await until(() => page.$('[data-scan-set] [data-scan-item]'));
158
+ ok('B5 "Scan settings" opens the board with its settings open at the Horizon scan block', !!(await page.$('[data-scan-set] [data-scan-freq=daily]')));
159
+ ok('B6 the block now shows the last scan', /Last scan: today/.test(await st(() => document.querySelector('[data-scan="last"]').textContent)), await st(() => document.querySelector('[data-scan="last"]').textContent));
160
+ await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
161
+ await sel('[data-c3pg="scan"] [data-scan-find]');
162
+ await page.setViewportSize({ width: 400, height: 900 });
163
+ await sleep(300);
164
+ await st(() => document.querySelector('[data-c3pg="scan"]').scrollIntoView({ block: 'start' }));
165
+ await shot('briefing-400');
166
+ ok('B7 at 400 wide the Briefing has no horizontal scroll', await noScroll(), await st(() => document.documentElement.scrollWidth));
167
+
168
+ ok('Z1 the page raised no script error', errors.length === 0, errors.join(' | '));
169
+ await finish();
170
+ })().catch((e) => { console.error(e); finish(1); });
@@ -0,0 +1,73 @@
1
+ ---
2
+ type: Guide
3
+ title: "The Horizon scan: the agent looks outside your initiatives"
4
+ description: "What the Horizon scan does, how often it runs, the watch list you keep, where its findings land, what it can and cannot do, and the pmos scan commands."
5
+ timestamp: 2026-10-12
6
+ ---
7
+
8
+ # The Horizon scan: the agent looks outside your initiatives
9
+
10
+ The Initiative Driver works on initiatives that already exist. The Horizon scan looks the other way. It looks for opportunities you have not asked for yet. It reads the public web for similar products, changes in your domain and approaches other teams use. It reads your repository for what keeps coming up and no initiative covers.
11
+
12
+ Every finding is an ask in Triage. A scan never creates an initiative. You accept or decline each ask, as you do for any ask.
13
+
14
+ ## How often it runs
15
+
16
+ Each board has its own setting: **Daily**, **Weekly** or **Off**. Daily is the default.
17
+
18
+ - **Daily.** Every working day, Monday to Friday, at 09:00 local time.
19
+ - **Weekly.** Every Monday at 09:00.
20
+ - **Off.** No scan runs on that board.
21
+
22
+ If the workspace was not running at 09:00, the scan starts at the first check that day inside your working hours. It never starts outside your working hours or inside your quiet hours. A board gets at most one scan a day. Only one scan runs at a time across the workspace. `PMOS_DRIVER=off` or `PMOS_SCAN=off` starts no scan at all.
23
+
24
+ Open the board, press **Board settings**, and find **Horizon scan** below Agent. The block shows the last scan and the next one.
25
+
26
+ ## The watch list
27
+
28
+ The web part of a scan searches for what is on the watch list of the board, and for nothing else. An item has a kind and a name:
29
+
30
+ - **product.** A similar product or a reference product.
31
+ - **domain.** A topic that changes often.
32
+ - **approach.** A way of working that other teams use.
33
+
34
+ A board that has no watch list gets one **suggest run**. The agent reads the initiatives of the board and files up to 8 suggestions. Each one names the initiative it came from. Suggestions appear in Board settings. For each one, press **Keep**, **Edit** or **Remove**. You can also add your own item.
35
+
36
+ Only items you kept or added are scanned. A board with no kept item is not scanned.
37
+
38
+ ## What a scan does
39
+
40
+ A scan is two short background runs, in this order.
41
+
42
+ 1. **The web run.** It gets the kept watch list and nothing else. It cannot read your repository, your initiatives or your notes. It searches the public web and reads public pages. It files up to 5 findings. Each one has a title, a one-line reason and the public page the agent read.
43
+ 2. **The repository run.** It reads your repository, your notes and the Jira themes in them. It has no way to reach the web. It files up to 3 findings of its own: recurring friction, an ask repeated in notes or meetings, a Jira theme with no initiative, or a stalled bet. It also marks which findings, from both runs, match an initiative that already exists.
44
+
45
+ The agent skips a finding it already filed on this board. A finding repeats an earlier one when it has the same source page, or when its title has the same significant words. The scan counts the skips.
46
+
47
+ ## Where the findings land
48
+
49
+ - **Triage.** Each new finding is an ask on its board. The row says **Found by agent**. It shows the reason, the sources and either "matches" an initiative or "no initiative yet". Accept, Ask for more and Decline work as for any ask. A declined scan ask counts as a correction.
50
+ - **Briefing.** The **Horizon scan** card shows the scan of the day: when it ran, which boards, how many asks were found, how many match an initiative and how many are new. Each finding has **Open in Triage**. The card says how many public pages the agent read and how many findings it skipped.
51
+
52
+ ## What it can and cannot do
53
+
54
+ - It can search and read public web pages, and read your repository.
55
+ - It can file a finding. That is the only thing it writes.
56
+ - It cannot send anything: no mail, no message, no comment, no ticket.
57
+ - It cannot read a page behind a login.
58
+ - It cannot create an initiative, accept an ask or change a record. Only you can.
59
+ - The run that reads the web never sees your repository. The run that reads your repository never reaches the web.
60
+ - It cannot be started from the page. It runs on its schedule.
61
+
62
+ Findings are proposals. The agent can be wrong. Check the sources before you accept.
63
+
64
+ ## The commands
65
+
66
+ The agent uses `pmos scan file` to file one finding, `pmos scan match` to mark a finding that matches an initiative, `pmos scan suggest` to file a watch list suggestion and `pmos scan list` to read the findings of one scan. `pmos scan status` shows the cadence, the watch list and the last scan of each board. The other commands are yours, at an interactive terminal:
67
+
68
+ - `pmos scan cadence <daily|weekly|off> --board <id>` sets how often a board is scanned.
69
+ - `pmos scan watch keep <item> --board <id>` keeps a suggestion. `pmos scan watch remove <item> --board <id>` removes an item.
70
+ - `pmos scan watch edit <item> --board <id> --name "<new name>"` changes an item.
71
+ - `pmos scan watch add --board <id> --kind product --name "<what to watch>"` adds an item.
72
+
73
+ The ledger is `scan.jsonl` in the state folder of the repository (`pmos/state`, `state` or `.pmos`). Each action adds one line. Nothing is changed or removed.