create-pmos 0.169.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.169.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@d87e438
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:51:11Z"}
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:51:11Z"}
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:51:11Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T17:05:32Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -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.
@@ -62,6 +62,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
62
62
  - [Initiatives: the Roadmap, the List and the initiative sheet](initiatives-screen.md): how to read and order the initiatives of a repository, open the sheet, make a new initiative, and review the cycle.
63
63
  - [Initiatives and the Roadmap: Now, Next and Later](initiatives-roadmap.md): what day one shows, how an agent proposes a move, and `pmos initiative` and `pmos measure` from a terminal.
64
64
  - [The Initiative Driver: drafts the agent files for you](initiative-driver.md): when the driver runs, the unblock sweep, the limits, Keep, Send back and Drop, the board modes, and `pmos driver`.
65
+ - [The Horizon scan: the agent looks outside your initiatives](horizon-scan.md): how often it runs, the watch list you keep, what a scan does, where findings land, what it cannot do, and `pmos scan`.
65
66
  - [PM-only acts: what only you can do](pm-only-acts.md): which acts are yours, how the workspace checks that a press comes from your page or your terminal, and what to do when one is refused.
66
67
  - [PM-only lines: how the workspace checks your acts](pm-only-lines.md): how each act is recorded, what the Today card "A PM-only line is not attested" means, and the grants for a bulk lifecycle change and a Release.
67
68
  - [Breakdown › Timeline](timeline.md): an initiative's items on a time axis, split by stage, with milestones, today, and the at-risk flag when an item starts before its predecessor ends.
@@ -85,3 +85,7 @@ The agent uses `pmos driver propose` to file a proposal and `pmos driver status`
85
85
  - `pmos driver mode <off|shadow|approve> [--board <id>]` sets the board mode. Without `--board` it sets every board.
86
86
 
87
87
  The ledger is `driver.jsonl` in the state folder of the repository (`pmos/state`, `state` or `.pmos`). Each action adds one line. Nothing is changed or removed.
88
+
89
+ ## Related
90
+
91
+ The [Horizon scan](horizon-scan.md) is the part of the agent that looks outside the initiatives: it files new opportunities as asks in Triage.
@@ -27,7 +27,7 @@
27
27
  * workflow { stages: [{ name, cat, hint }], renamed: { old: new }, moved: [{ id, from, to }], sprints? }
28
28
  * move { id, from, to, why, warnings }
29
29
  * assign { id, to } (`board` is the board it left, or null)
30
- * ask { ask, line, source, draft }
30
+ * ask { ask, line, source, draft, found? } found: what a Horizon scan filed with the ask { why, sources: [{ label, ref }], match, scan } (source 'scan')
31
31
  * ask-accept { ask, initiative, stage }
32
32
  * ask-decline { ask, why }
33
33
  * ask-more { ask, note } the asker is prompted for more detail; the ask stays open
@@ -146,6 +146,12 @@ const TOOLS = Object.freeze({
146
146
 
147
147
  const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'done']);
148
148
  const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
149
+ /** What a Horizon scan filed with an ask (initiative horizon-scan), cut to its shape; null for any other ask. */
150
+ function foundOf(f) {
151
+ if (!f || typeof f !== 'object' || Array.isArray(f)) return null;
152
+ const sources = Array.isArray(f.sources) ? f.sources.filter((x) => x && typeof x.ref === 'string').slice(0, 8).map((x) => ({ label: String(x.label || ''), ref: x.ref })) : [];
153
+ return { why: typeof f.why === 'string' ? f.why : '', sources, match: typeof f.match === 'string' && f.match ? f.match : null, scan: typeof f.scan === 'string' ? f.scan : null, run: typeof f.run === 'string' ? f.run : null };
154
+ }
149
155
  const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update', 'ini-epics']);
150
156
  const TASK_STATES = Object.freeze(['To do', 'In progress', 'In review', 'Done']);
151
157
 
@@ -206,7 +212,7 @@ function fold(events) {
206
212
  } else if (e.kind === 'assign' && typeof e.id === 'string' && typeof e.to === 'string') {
207
213
  assigns.set(e.id, e.to);
208
214
  } else if (e.kind === 'ask' && b && typeof e.ask === 'string' && !asks.has(e.ask)) {
209
- asks.set(e.ask, { id: e.ask, board: b, line: String(e.line || ''), source: e.source || null, draft: e.draft || null, at: e.at || null, by: e.by || null, state: 'open', initiative: null, stage: null, why: null, closedAt: null });
215
+ asks.set(e.ask, { id: e.ask, board: b, line: String(e.line || ''), source: e.source || null, draft: e.draft || null, ...(foundOf(e.found) ? { found: foundOf(e.found) } : {}), at: e.at || null, by: e.by || null, state: 'open', initiative: null, stage: null, why: null, closedAt: null });
210
216
  } else if (e.kind === 'ask-accept' && typeof e.ask === 'string' && asks.has(e.ask)) {
211
217
  const a = asks.get(e.ask);
212
218
  if (a.state !== 'open') continue;
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs }) → the argv after `claude`
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * logLines(result, exitCode) → the lines the read-only log pane shows at the end
@@ -20,6 +20,11 @@
20
20
  * connector tool is named in its list, so a connector read runs only where the PM's own Claude Code settings already allow it,
21
21
  * and a connector write is denied and reported like any other tool outside the list. The PM-only `pmos driver` verbs refuse an
22
22
  * agent session by themselves. The driver never runs in a pane (server.js refuses to start it when PMOS_HEADLESS=off).
23
+ *
24
+ * The `scan-web` and `scan-repo` kinds are the Horizon scan (initiative horizon-scan). `scan-web` has WebSearch, WebFetch and
25
+ * `pmos scan file`, and nothing else: it reads untrusted pages, so it holds no private data (no Read, Glob or Grep, no repository
26
+ * path in its prompt, no --add-dir). `scan-repo` reads the repository and the findings the web run filed, so it has no web tool and
27
+ * nothing that sends. Neither kind ever runs in a pane.
23
28
  */
24
29
 
25
30
  // The skills whose every start runs headless: they read, file a result for PMOS, and send nothing out.
@@ -33,6 +38,11 @@ const HEADLESS_ORIGINS = Object.freeze(['notes-bullets:', 'notes-summary:']);
33
38
  const ASK_ORIGINS = Object.freeze(['doc-ask:', 'focus-companion:']); // focus-companion: the Meeting focus page's quiet companion and its private questions (read-only, nothing sent)
34
39
  // initiative-driver: one short run for one initiative or one Work task. Its origin is `drive:<kind>:<id>`.
35
40
  const DRIVE_ORIGINS = Object.freeze(['drive:']);
41
+ // horizon-scan: a scan is two runs. The web run (origin `scan-web:<board>:<scan>`) reads the public web for the kept watch list and sees nothing of
42
+ // the repository. The repository run (origin `scan-repo:<board>:<scan>`, and `scan-suggest:<board>` for the one that suggests a watch list) reads
43
+ // the repository and fetches nothing. Neither has both untrusted input and a way out (PRD: trust boundary).
44
+ const SCAN_WEB_ORIGINS = Object.freeze(['scan-web:']);
45
+ const SCAN_REPO_ORIGINS = Object.freeze(['scan-repo:', 'scan-suggest:']);
36
46
 
37
47
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
38
48
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
@@ -41,6 +51,11 @@ const TOOLS = Object.freeze({
41
51
  ask: Object.freeze(['Read', 'Glob', 'Grep']),
42
52
  drive: Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos driver *)', 'Bash(pmos initiative list *)', 'Bash(pmos initiative export *)',
43
53
  'Bash(git log *)', 'Bash(ls *)', 'Bash(date *)']),
54
+ // The web run: WebSearch, WebFetch and the one verb that files a finding. No Read, no Glob, no Grep, no repository path in its prompt or its add-dirs.
55
+ 'scan-web': Object.freeze(['WebSearch', 'WebFetch', 'Bash(pmos scan file *)']),
56
+ // The repository run: read and search the repository, `pmos scan` (its PM-only verbs refuse an agent session), the initiative list and git log.
57
+ // No WebSearch, no WebFetch, no curl, no git push: it reads the web run's findings, which are untrusted text, so it has nothing that sends or fetches.
58
+ 'scan-repo': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos scan *)', 'Bash(pmos initiative list *)', 'Bash(git log *)']),
44
59
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
45
60
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
46
61
  });
@@ -57,6 +72,8 @@ function kindFor({ agent, origin, skill, env } = {}) {
57
72
  if (HEADLESS_ORIGINS.some((p) => o.startsWith(p))) return 'notes';
58
73
  if (ASK_ORIGINS.some((p) => o.startsWith(p))) return 'ask';
59
74
  if (DRIVE_ORIGINS.some((p) => o.startsWith(p))) return 'drive';
75
+ if (SCAN_WEB_ORIGINS.some((p) => o.startsWith(p))) return 'scan-web';
76
+ if (SCAN_REPO_ORIGINS.some((p) => o.startsWith(p))) return 'scan-repo';
60
77
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
61
78
  return null;
62
79
  }
@@ -65,7 +82,8 @@ function argsFor(kind, { modelArgs = [], addDirs = [] } = {}) {
65
82
  const tools = TOOLS[kind];
66
83
  if (!tools) throw new Error('unknown headless kind: ' + kind);
67
84
  const argv = ['-p', '--output-format', 'json', '--no-session-persistence', ...modelArgs, '--allowedTools', tools.join(',')];
68
- for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
85
+ // The web run gets no directory at all, whatever the caller passes: it must not be able to read the repository.
86
+ if (kind !== 'scan-web') for (const d of addDirs) if (d) argv.push('--add-dir', String(d));
69
87
  return argv;
70
88
  }
71
89
 
@@ -107,4 +125,4 @@ function logLines(r, exitCode) {
107
125
  return out;
108
126
  }
109
127
 
110
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
128
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, logLines, switchedOff };
@@ -59,6 +59,9 @@ const VERBS = Object.freeze({
59
59
  // Not a pm-os-absorb verb (run: null): initiative initiative-driver added it through the same table.
60
60
  driver: Object.freeze({ module: 'driver-cli.js', run: null, guide: 'initiative-driver.md',
61
61
  help: 'pmos driver propose|status|keep|return|drop|pause|resume|mode … — the Initiative Driver: drafts for you to keep, send back or drop (pmos driver help)' }),
62
+ // Not a pm-os-absorb verb (run: null): initiative horizon-scan added it through the same table.
63
+ scan: Object.freeze({ module: 'scan-cli.js', run: null, guide: 'horizon-scan.md',
64
+ help: 'pmos scan file|list|match|suggest|status|cadence|watch … — the Horizon scan: what the agent found outside the initiatives, and the watch list (pmos scan help)' }),
62
65
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
63
66
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
64
67
  });
@@ -69,6 +69,7 @@ const MODULES = Object.freeze([
69
69
  'routes-meeting-focus', // console3 Meeting focus: the meeting, its note's talk, the companion's notes (GET); topic corrections and Ask PMOS quietly (page press)
70
70
  'routes-stage-tools', // stage-tools-records: the records the PM edits per stage, what PMOS holds per stage, the sprint burndown
71
71
  'routes-driver', // initiative-driver: the Agent strip's data, Keep, Send back, Drop, Pause and the board mode (driver.jsonl)
72
+ 'routes-scan', // horizon-scan: a board's watch list, cadence and last scan, the day's scan for the Briefing, the PM's watch and cadence presses (scan.jsonl)
72
73
  ]);
73
74
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
74
75
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -94,6 +94,33 @@ function append(file, actor, ev) {
94
94
  return line;
95
95
  }
96
96
 
97
+ /** A Horizon scan ask that the PM declines counts as a correction (initiative horizon-scan, PRD section 5): one line in corrections.jsonl, as the driver writes. */
98
+ function scanDeclineSignal(ctx, a) {
99
+ if (!ctx.runtimeDir) return;
100
+ try {
101
+ const f = path.join(ctx.runtimeDir, 'corrections.jsonl');
102
+ const reason = 'scan:decline';
103
+ fs.mkdirSync(path.dirname(f), { recursive: true });
104
+ fs.appendFileSync(f, JSON.stringify({ type: 'correction', kind: 'reject', reason, run: (a.found && (a.found.run || a.found.scan)) || null, counted: true, at: new Date().toISOString(),
105
+ record: { kind: 'correction', run: (a.found && (a.found.run || a.found.scan)) || null, reason, by: 'pm' } }) + '\n');
106
+ } catch { /* the declined ask in the boards ledger is still the record */ }
107
+ }
108
+
109
+ /** The Triage ask of one Horizon scan finding (initiative horizon-scan, PRD section 4). The same ledger line as POST /api/boards/asks/add, with
110
+ * source 'scan', `found` and the agent as `by`. The ask id comes from the finding id, so writing it twice makes one ask. A scan never creates an
111
+ * initiative: the PM accepts or declines. → { ok, ask, existing } | { ok: false, error } */
112
+ function appendScanAsk(ctx, w, boardId, f, by) {
113
+ const file = ledgerFile(ctx, w);
114
+ if (!file) return { ok: false, error: NO_STATE };
115
+ const id = 'ask-' + String(f.id).replace(/^f-/, '');
116
+ if (B.fold(readLedger(ctx, w)).asks.has(id)) return { ok: true, existing: true, id };
117
+ let draft = null;
118
+ try { draft = intake.intakeDraft({ line: f.title, root: w.root }); } catch { draft = null; }
119
+ append(file, { by }, { kind: 'ask', board: boardId, ask: id, line: f.title, source: 'scan', draft,
120
+ found: { why: f.why, sources: f.sources, match: f.match || null, scan: f.scan, run: f.run || null } });
121
+ return { ok: true, existing: false, id };
122
+ }
123
+
97
124
  // ── boards of a workspace ─────────────────────────────────────────────────────────────────────────
98
125
  const isSelf = (ctx, w) => !!(ctx.selfRoot && w && path.resolve(w.root) === path.resolve(ctx.selfRoot));
99
126
 
@@ -750,6 +777,8 @@ const WRITES = {
750
777
  if (!ONE_LINE(body.why, 500)) return fail(ctx, res, 400, 'a decline needs a reason (why) of 1 to 500 characters');
751
778
  if (!p.file) return fail(ctx, res, 409, NO_STATE);
752
779
  append(p.file, g.actor, { kind: 'ask-decline', board: p.board.id, ask: a.id, why: body.why.trim() });
780
+ // initiative horizon-scan: a declined scan finding is a correction signal, as a dropped driver proposal is.
781
+ if (a.source === 'scan') scanDeclineSignal(ctx, a);
753
782
  ctx.json(res, { ok: true });
754
783
  },
755
784
  };
@@ -846,4 +875,4 @@ function meetingBoards(ctx) {
846
875
  return { list, detail };
847
876
  }
848
877
 
849
- module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };
878
+ module.exports = { prefixes: ['/api/boards'], handle, move, boardIndex, appendScanAsk, frameFor, boardIdOf, meetingBoards, boardsOf, baseRows, boardRow, portfolio, SELF_REFUSAL, NO_STATE };