create-pmos 0.187.0 → 0.189.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-report-setup-test.js +1 -1
  7. package/workspace/c3-scan-fixture.js +247 -5
  8. package/workspace/c3-scan-test.js +453 -106
  9. package/workspace/guide/horizon-scan.md +45 -36
  10. package/workspace/guide/my-work.md +1 -0
  11. package/workspace/lib/day-run.js +4 -3
  12. package/workspace/lib/headless.js +19 -5
  13. package/workspace/lib/my-work.js +2 -1
  14. package/workspace/lib/pmos-verbs.js +2 -2
  15. package/workspace/lib/route-modules.js +1 -1
  16. package/workspace/lib/routes-boards.js +29 -12
  17. package/workspace/lib/routes-scan.js +224 -44
  18. package/workspace/lib/scan-cli.js +143 -45
  19. package/workspace/lib/scan-run.js +242 -87
  20. package/workspace/lib/scan.js +623 -79
  21. package/workspace/lib/work-assigned.js +1 -1
  22. package/workspace/package-lock.json +2 -2
  23. package/workspace/package.json +1 -1
  24. package/workspace/public/pm/c3-doc-sheet.js +7 -1
  25. package/workspace/public/pm/c3-initiatives.js +5 -4
  26. package/workspace/public/pm/c3-scan-api.js +167 -0
  27. package/workspace/public/pm/c3-scan-next.js +248 -0
  28. package/workspace/public/pm/c3-scan-setup.js +317 -0
  29. package/workspace/public/pm/c3-scan-words.js +34 -0
  30. package/workspace/public/pm/c3-scan.css +58 -9
  31. package/workspace/public/pm/c3-scan.js +36 -208
  32. package/workspace/public/pm/c3-toast.js +1 -0
  33. package/workspace/public/pm/console3.css +3 -0
  34. package/workspace/public/pm/pm-work-view.js +11 -5
  35. package/workspace/public/pm/run-card.js +12 -3
  36. package/workspace/public/pm/sessions-panel.js +2 -2
  37. package/workspace/server.js +25 -5
  38. package/workspace/skills/horizon-scan-repo.skill +26 -10
  39. package/workspace/skills/horizon-scan-research.skill +44 -0
  40. package/workspace/skills/horizon-scan-web.skill +12 -5
@@ -1,29 +1,49 @@
1
1
  'use strict';
2
- /* routes-scan.js — the Horizon scan's routes (initiative horizon-scan, PRD section 6).
2
+ /* routes-scan.js — the Horizon scan's routes (initiatives horizon-scan and horizon-scan-portfolio, PRD section 7).
3
3
  *
4
- * GET /api/scan/board?ws&board → { ok, ws, board, ledger, cadence, schedule, items: [item], suggested: [item], kept: [item], suggesting, suggest_failed, running, last, next, switched_off }
5
- * GET /api/scan/today?ws → { ok, ws, ledger, scan: null | { day, at, boards: [{ id, name }], found, matches, fresh, pages, skipped, findings: [finding], failed: [{ board, reason }] } }
6
- * POST /api/scan/watch { ws, board, op: keep | edit | remove | add, id?, name?, kind?, why?, restore? } → { ok, id, item? }
7
- * POST /api/scan/cadence { ws, board, cadence: daily | weekly | off } → { ok, board, cadence }
4
+ * THE PORTFOLIO ROUTES (horizon-scan-portfolio). One scan for the whole workspace.
5
+ * GET /api/scan/portfolio?ws → { ok, ws, ledger, switched_off, setting, schedule, boards, items, suggested, kept, suggesting, suggest_failed,
6
+ * last, next, manual, open, reports, proposals_waiting, proposals }
7
+ * GET /api/scan/report?ws&id → { ok, ws, report, markdown, body, written, proposals }
8
+ * POST /api/scan/watch { ws, op: keep | edit | remove | add, id?, name?, kind?, why?, boards?, restore?, board? } → { ok, id, item? }
9
+ * POST /api/scan/setting { ws, cadence?, repo?, types? } → { ok, setting, schedule }
10
+ * POST /api/scan/now { ws, items?, repo?, types? } → { ok, scan, reports, steps, left }
11
+ * POST /api/scan/stop { ws } → { ok, scans }
12
+ * POST /api/scan/proposal { ws, id, action: approve | change | dismiss | undo, fields?, why? } → { ok, proposal, result }
8
13
  *
9
- * An item is { id, kind, name, why, ini, added, state: suggested | kept }; `ini` is the initiative a suggestion came from (null for one the PM added). A finding is
10
- * { id, ask, board, title, match, origin, state } where `state` is the Triage ask's state (open, accepted or declined). `next` is an ISO time or null.
11
- * `scan` of today is the day's scans across the boards of the workspace read as one: the time of the latest, the boards scanned, the counts and the findings.
14
+ * item { id, kind, name, why, ini, added, state: suggested | kept, boards: [board id] } `boards` empty = the whole portfolio
15
+ * setting { cadence: daily | weekly | off, repo: boolean, types: [benchmark | competitive | insight | productivity] }
16
+ * manual { limit: 3, used, left, ok, why?, error? } `ok` = a manual scan may start now (`why`: running | quiet-hours | manual-limit)
17
+ * open null | { scan, mode: scan | research, manual, start, stopping, steps: [{ id: web | repo | reports, label, state }], reports: [{ id, type, title }], research? }
18
+ * report { id, scan, type, label, title, path, origin: web | repo | both, parent, at, written, sections, proposals: { open, total } }
19
+ * proposal { id, report, report_title, kind: create | research | attach, title, why, finding, evidence: [{ label, ref }], fields, state: open | approved | dismissed,
20
+ * changed, dismissed_why, result, at, decided_at }
12
21
  *
13
- * WHO WRITES. Both POSTs pass pm-guard requirePagePress, and a request that carries an agent session header is refused first (403), as the driver routes
22
+ * THE FIRST SCAN'S ROUTES, kept for compatibility and mapped onto the portfolio list:
23
+ * GET /api/scan/board?ws&board → the portfolio's items that relate to that board, with the workspace cadence, the last scan and the next one
24
+ * GET /api/scan/today?ws → the day's scan for the Briefing
25
+ * POST /api/scan/watch { ws, board, op, … } a press from a board lands on the one list, with that board on the item
26
+ * POST /api/scan/cadence { ws, board, cadence } sets the workspace cadence
27
+ *
28
+ * WHO WRITES. Every POST passes pm-guard requirePagePress, and a request that carries an agent session header is refused first (403), as the driver routes
14
29
  * do. `by` of every line is the guard's actor; the page sends none. The agent writes through `pmos scan` (lib/scan-cli.js) and the scan run, never here.
15
- * State: <state folder>/scan.jsonl, one per repository (lib/scan.js). Nothing here sends anything outward.
30
+ * Approve acts only on a PM press: create goes through initiative-writer (lib/routes-boards.js createAndPlace) into the first stage of the board's workflow;
31
+ * research starts a research scan; attach writes an evidence line. State: <state folder>/scan.jsonl, one per repository (lib/scan.js). The report documents
32
+ * sit in the report-workspace store (lib/scan.js reportsFolder). Nothing here sends anything outward.
16
33
  */
34
+ const fs = require('fs');
35
+ const path = require('path');
17
36
  const guard = require('./pm-guard.js');
18
37
  const D = require('./driver.js');
19
38
  const S = require('./scan.js');
20
39
  const scanRun = require('./scan-run.js');
40
+ const proactive = require('./proactive.js');
21
41
 
22
42
  const text = (v) => (typeof v === 'string' ? v : '');
23
- const bad = (status, error) => ({ status, body: { ok: false, error } });
24
- const GETS = ['board', 'today'];
25
- const POSTS = ['watch', 'cadence'];
26
- const itemOut = (i) => ({ id: i.id, kind: i.kind, name: i.name, why: i.why, ini: i.ini, added: !!i.added, state: i.state });
43
+ const bad = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
44
+ const GETS = ['board', 'today', 'portfolio', 'report'];
45
+ const POSTS = ['watch', 'cadence', 'setting', 'now', 'stop', 'proposal'];
46
+ const itemOut = (i) => ({ id: i.id, kind: i.kind, name: i.name, why: i.why, ini: i.ini, added: !!i.added, state: i.state, boards: (i.boards || []).slice() });
27
47
 
28
48
  function wsOf(ctx, id) {
29
49
  const w = ctx.getWs(id || '');
@@ -35,21 +55,84 @@ function ledgerOf(ctx, w) {
35
55
  }
36
56
  const boardsOf = (ctx, w) => { try { return require('./routes-boards.js').frameFor(ctx, w); } catch { return null; } };
37
57
  const settingsOf = (ctx) => { try { return ctx.proSettings ? ctx.proSettings() : null; } catch { return null; } };
58
+ /** The scan's clock: PMOS_SCAN_NOW fixes it in the suites (server.js). */
59
+ const clockOf = (ctx) => { try { const c = ctx.scanClock ? ctx.scanClock() : null; return c && Number.isFinite(c.now) ? c.now : Date.now(); } catch { return Date.now(); } };
60
+ const quietNow = (ctx, now) => { const st = settingsOf(ctx); try { return !!proactive.inQuietHours(now, st && st.quiet); } catch { return false; } };
61
+
62
+ const reportOut = (state, r) => {
63
+ const props = S.proposalsOf(state, r.id);
64
+ return { id: r.id, scan: r.scan, type: r.type, label: S.REPORT_LABEL[r.type], title: r.title, path: r.path, origin: r.origin, parent: r.parent, at: r.writtenAt || r.at, written: r.written,
65
+ sections: r.sections.length, proposals: { open: props.filter((p) => p.state === 'open').length, total: props.length } };
66
+ };
67
+ const proposalOut = (state, p) => {
68
+ const r = state.reports.get(p.report);
69
+ return { id: p.id, report: p.report, report_title: r ? r.title : '', kind: p.kind, title: p.title, why: p.why, finding: p.finding, evidence: p.evidence, fields: p.fields, state: p.state,
70
+ changed: p.changed, dismissed_why: p.dismissWhy, result: p.result, at: p.at, decided_at: p.decidedAt };
71
+ };
72
+ const openOut = (state, sc) => ({ scan: sc.scan, mode: sc.mode, manual: sc.manual, start: sc.start, stopping: sc.stopped, steps: S.stepsOf(sc),
73
+ reports: sc.reports.map((id) => { const r = state.reports.get(id); return { id: r.id, type: r.type, title: r.title }; }),
74
+ ...(sc.research ? { research: { question: sc.research.question, depth: sc.research.depth, parent: sc.research.parent } } : {}) });
75
+ const lastOut = (last) => (last ? { scan: last.scan, at: last.start, end: last.end, ok: last.ok, reason: last.reason, found: last.found, skipped: last.skipped, pages: last.pages, manual: last.manual, stopped: last.stopped } : null);
76
+
77
+ function manualOut(state, ctx, now) {
78
+ const chk = S.manualCheck(state, now, { quiet: quietNow(ctx, now), running: !!(ctx.scanner && ctx.scanner.active().length) });
79
+ return { limit: S.LIMITS.manual, used: chk.used, left: chk.left, ok: !!chk.ok, ...(chk.ok ? {} : { why: chk.why, error: chk.error }) };
80
+ }
81
+
82
+ function portfolioAnswer(ctx, w, file, now) {
83
+ const state = S.fold(file ? S.readLines(file) : []);
84
+ const fr = boardsOf(ctx, w);
85
+ const dc = (settingsOf(ctx) || {}).dayCheck || {};
86
+ const wl = S.watchOf(state, null);
87
+ const last = S.lastScan(state, null);
88
+ const cadence = S.cadenceOf(state);
89
+ const next = file ? S.nextScan(state, null, now, { dayEnd: dc.end || '18:00' }) : null;
90
+ const sug = S.scansOf(state, null, 'suggest');
91
+ const lastSug = sug.filter((x) => x.end).pop() || null;
92
+ const open = S.openScans(state).find((x) => x.mode !== 'suggest') || null;
93
+ const reports = state.reportOrder.map((id) => state.reports.get(id)).reverse().slice(0, 40).map((r) => reportOut(state, r));
94
+ const waiting = S.waitingProposals(state);
95
+ return {
96
+ setting: S.settingOf(state), schedule: S.scheduleText(cadence), boards: ((fr && fr.boards) || []).map((b) => ({ id: b.id, name: b.name })),
97
+ items: S.itemsOf(state, null).map(itemOut), suggested: wl.suggested.map(itemOut), kept: wl.kept.map(itemOut),
98
+ suggesting: sug.some((x) => !x.end), suggest_failed: !!(lastSug && lastSug.ok === false && !wl.suggested.length && !wl.kept.length),
99
+ last: lastOut(last), next: next == null ? null : new Date(next).toISOString(), switched_off: !!scanRun.off(process.env),
100
+ manual: manualOut(state, ctx, now), open: open ? openOut(state, open) : null, reports,
101
+ proposals_waiting: waiting.length, proposals: waiting.map((p) => proposalOut(state, p)),
102
+ };
103
+ }
104
+
105
+ function reportAnswer(ctx, w, file, id, now) {
106
+ const state = S.fold(file ? S.readLines(file) : []);
107
+ const rep = state.reports.get(id);
108
+ if (!rep) return bad(404, `no report ${String(id).slice(0, 30)} in this workspace`);
109
+ let markdown = '';
110
+ if (rep.written && S.isReportPath(rep.path)) {
111
+ try {
112
+ const abs = path.resolve(w.root, rep.path);
113
+ if (abs.startsWith(path.resolve(w.root) + path.sep)) markdown = fs.readFileSync(abs, 'utf8');
114
+ } catch { markdown = ''; }
115
+ }
116
+ // A report still being written has no file yet: show what the runs have filed so far.
117
+ if (!markdown) { try { markdown = S.renderReport(state, rep, { at: now }).text; } catch { markdown = ''; } }
118
+ const body = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
119
+ return { status: 200, body: { ok: true, ws: w.id, report: reportOut(state, rep), markdown, body, written: rep.written, proposals: S.proposalsOf(state, rep.id).map((p) => proposalOut(state, p)) } };
120
+ }
38
121
 
122
+ /** The first scan's per-board read, mapped onto the portfolio list. */
39
123
  function boardAnswer(ctx, w, file, board, now) {
40
124
  const state = S.fold(file ? S.readLines(file) : []);
41
125
  const wl = S.watchOf(state, board);
42
- const last = S.lastScan(state, board);
43
- const cadence = S.cadenceOf(state, board);
126
+ const last = S.lastScan(state, null);
127
+ const cadence = S.cadenceOf(state);
44
128
  const dc = (settingsOf(ctx) || {}).dayCheck || {};
45
- const next = file ? S.nextScan(state, board, now, { dayEnd: dc.end || '18:00' }) : null;
46
- const sug = S.scansOf(state, board, 'suggest');
47
- const openSug = sug.find((x) => !x.end);
129
+ const next = file ? S.nextScan(state, null, now, { dayEnd: dc.end || '18:00' }) : null;
130
+ const sug = S.scansOf(state, null, 'suggest');
48
131
  const lastSug = sug.filter((x) => x.end).pop() || null;
49
132
  return {
50
133
  cadence, schedule: S.scheduleText(cadence), items: S.itemsOf(state, board).map(itemOut), suggested: wl.suggested.map(itemOut), kept: wl.kept.map(itemOut),
51
- suggesting: !!openSug, suggest_failed: !!(lastSug && lastSug.ok === false && !wl.suggested.length && !wl.kept.length),
52
- running: S.openScans(state).some((x) => x.board === board && x.mode === 'scan'),
134
+ suggesting: sug.some((x) => !x.end), suggest_failed: !!(lastSug && lastSug.ok === false && !wl.suggested.length && !wl.kept.length),
135
+ running: S.openScans(state).some((x) => x.mode === 'scan'),
53
136
  last: last ? { scan: last.scan, at: last.start, end: last.end, ok: last.ok, reason: last.reason, found: last.found, skipped: last.skipped, pages: last.pages } : null,
54
137
  next: next == null ? null : new Date(next).toISOString(), switched_off: !!scanRun.off(process.env),
55
138
  };
@@ -60,25 +143,21 @@ function todayAnswer(ctx, w, file, now) {
60
143
  const day = D.localDay(now);
61
144
  const done = state.scanOrder.map((id) => state.scans.get(id)).filter((x) => x.mode === 'scan' && x.end && x.start && D.localDay(Date.parse(x.start)) === day);
62
145
  if (!done.length) return null;
63
- const fr = boardsOf(ctx, w);
64
- const names = new Map(((fr && fr.boards) || []).map((b) => [b.id, b.name]));
65
- let asks = new Map();
66
- try { asks = fr ? fr.folded.asks : new Map(); } catch { asks = new Map(); }
67
- const boards = []; const findings = []; const failed = [];
146
+ const findings = []; const failed = []; const reports = [];
68
147
  let pages = 0; let skipped = 0; let latest = done[0];
69
148
  for (const sc of done) {
70
149
  if (Date.parse(sc.start) >= Date.parse(latest.start)) latest = sc;
71
- if (!boards.some((b) => b.id === sc.board)) boards.push({ id: sc.board, name: names.get(sc.board) || sc.board });
72
150
  pages += sc.pages || 0; skipped += sc.skipped || 0;
73
151
  if (sc.ok === false && sc.reason) failed.push({ board: sc.board, reason: sc.reason });
74
152
  for (const fid of sc.findings) {
75
153
  const f = state.findings.get(fid);
76
- const ask = asks.get('ask-' + fid.replace(/^f-/, ''));
77
- findings.push({ id: f.id, ask: ask ? ask.id : null, board: f.board, title: f.title, match: f.match, origin: f.origin, state: ask ? ask.state : null });
154
+ findings.push({ id: f.id, ask: null, board: f.board, title: f.title, match: f.match, origin: f.origin, state: null });
78
155
  }
156
+ for (const rid of sc.reports) reports.push(reportOut(state, state.reports.get(rid)));
79
157
  }
80
158
  const matches = findings.filter((f) => f.match).length;
81
- return { day, at: latest.start, boards, found: findings.length, matches, fresh: findings.length - matches, pages, skipped, findings, failed };
159
+ return { day, at: latest.start, boards: [{ id: latest.board, name: 'Portfolio' }], found: findings.length, matches, fresh: findings.length - matches, pages, skipped, findings, failed,
160
+ reports, proposals_waiting: S.waitingProposals(state).length };
82
161
  }
83
162
 
84
163
  async function handle(req, res, url, ctx) {
@@ -92,10 +171,14 @@ async function handle(req, res, url, ctx) {
92
171
  const w = wsOf(ctx, url.searchParams.get('ws'));
93
172
  if (!w) return send(bad(404, 'no such workspace'));
94
173
  const file = ledgerOf(ctx, w);
95
- // the same clock the scheduler uses: PMOS_SCAN_NOW (server.js scanClock) fixes it, so a suite's scan is "today" on any date
96
- const fixed = Date.parse(process.env.PMOS_SCAN_NOW || '');
97
- const now = Number.isFinite(fixed) ? fixed : Date.now();
174
+ const now = clockOf(ctx);
98
175
  if (sub === 'today') return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, scan: todayAnswer(ctx, w, file, now) } });
176
+ if (sub === 'portfolio') return send({ status: 200, body: { ok: true, ws: w.id, ledger: !!file, ...portfolioAnswer(ctx, w, file, now) } });
177
+ if (sub === 'report') {
178
+ const id = text(url.searchParams.get('id'));
179
+ if (!S.REPORT_RE.test(id)) return send(bad(400, 'id must be a report id'));
180
+ return send(reportAnswer(ctx, w, file, id, now));
181
+ }
99
182
  const board = text(url.searchParams.get('board')) || w.id;
100
183
  if (!S.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
101
184
  const fr = boardsOf(ctx, w);
@@ -107,26 +190,123 @@ async function handle(req, res, url, ctx) {
107
190
  const read = await ctx.readJson(req);
108
191
  if (!read.ok) return send(bad(400, 'the body is not JSON'));
109
192
  const body = read.body;
110
- if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot change the watch list or the scan cadence'));
193
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot change the watch list, the scan setting or a proposal, start a scan or stop one'));
111
194
  const g = guard.requirePagePress(req, body);
112
195
  if (!g.ok) return send(bad(g.status, g.error));
113
196
  const w = wsOf(ctx, text(body.ws));
114
197
  if (!w) return send(bad(404, 'no such workspace'));
115
198
  const file = ledgerOf(ctx, w);
116
199
  if (!file) return send(bad(409, 'this repository has no state folder (pmos/state, state or .pmos), so the scan has nowhere to keep its ledger'));
117
- const board = text(body.board);
118
- if (!board || !S.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
119
- const fr = boardsOf(ctx, w);
120
- if (fr && !fr.boards.some((b) => b.id === board)) return send(bad(404, `no board ${board.slice(0, 40)} in this workspace`));
200
+ const now = clockOf(ctx);
121
201
 
202
+ // An optional board (the first scan's presses name one). When it is named it must exist.
203
+ const boardGiven = body.board !== undefined && body.board !== null && body.board !== '';
204
+ let board = null;
205
+ if (boardGiven && (sub === 'watch' || sub === 'cadence')) {
206
+ board = text(body.board);
207
+ if (!S.ID_RE.test(board)) return send(bad(400, 'board must be a board id'));
208
+ const fr = boardsOf(ctx, w);
209
+ if (fr && !fr.boards.some((b) => b.id === board)) return send(bad(404, `no board ${board.slice(0, 40)} in this workspace`));
210
+ }
122
211
  if (sub === 'cadence') {
123
- const r = S.setCadence(file, board, text(body.cadence), g.actor);
212
+ const r = S.setCadence(file, board, text(body.cadence), g.actor, { now });
213
+ if (!r.ok) return send(bad(r.status, r.error));
214
+ return send({ status: 200, body: { ok: true, ...(board ? { board } : {}), cadence: r.cadence, schedule: S.scheduleText(r.cadence) } });
215
+ }
216
+ if (sub === 'watch') {
217
+ const r = S.watchOp(file, text(body.op), board, { id: body.id, name: body.name, kind: body.kind, why: body.why, restore: body.restore === true, ...(body.boards !== undefined ? { boards: body.boards } : {}) }, g.actor, { now });
124
218
  if (!r.ok) return send(bad(r.status, r.error));
125
- return send({ status: 200, body: { ok: true, board: r.board, cadence: r.cadence, schedule: S.scheduleText(r.cadence) } });
219
+ return send({ status: 200, body: { ok: true, id: r.id, ...(r.item ? { item: r.item } : {}) } });
220
+ }
221
+ if (sub === 'setting') {
222
+ const r = S.setSetting(file, { cadence: body.cadence, repo: body.repo, types: body.types }, g.actor, { now });
223
+ if (!r.ok) return send(bad(r.status, r.error));
224
+ return send({ status: 200, body: { ok: true, setting: r.setting, schedule: S.scheduleText(r.setting.cadence) } });
225
+ }
226
+
227
+ // The runs live in the server's scanner; a context without one cannot start or stop a scan.
228
+ if (sub === 'now' || sub === 'stop') {
229
+ if (!ctx.scanner) return send(bad(503, 'the scanner is not running in this server'));
230
+ if (sub === 'now') {
231
+ const r = ctx.scanner.startNow(w, { items: body.items, repo: body.repo, types: body.types }, { now, by: g.actor.by });
232
+ if (!r.ok) return send(bad(r.status || 409, r.error, { ...(r.why ? { why: r.why } : {}), ...(r.left !== undefined ? { left: r.left, used: r.used } : {}) }));
233
+ return send({ status: 200, body: { ok: true, scan: r.scan, reports: r.reports, steps: r.steps, left: r.left } });
234
+ }
235
+ const r = ctx.scanner.stop(w, { now, by: g.actor.by });
236
+ if (!r.ok) return send(bad(r.status || 409, r.error));
237
+ return send({ status: 200, body: { ok: true, scans: r.scans } });
238
+ }
239
+
240
+ return send(proposalPress(ctx, w, file, body, g, now));
241
+ }
242
+
243
+ /** POST /api/scan/proposal. Nothing happens without this press. */
244
+ function proposalPress(ctx, w, file, body, g, now) {
245
+ const id = text(body.id);
246
+ if (!S.PROPOSAL_RE.test(id)) return bad(400, 'id must be a proposal id');
247
+ const action = text(body.action);
248
+ if (!['approve', 'change', 'dismiss', 'undo'].includes(action)) return bad(400, 'action must be approve, change, dismiss or undo');
249
+ let state = S.fold(S.readLines(file));
250
+ const p0 = state.proposals.get(id);
251
+ if (!p0) return bad(404, `no proposal ${id}`);
252
+ // Undo takes back a dismissal or a change. Approve cannot be undone (it made an initiative or started runs), so the proposal is not open then.
253
+ if (action === 'undo') {
254
+ const r = S.proposalUndo(file, id, g.actor, { now });
255
+ if (!r.ok) return bad(r.status, r.error);
256
+ const st = S.fold(S.readLines(file));
257
+ return { status: 200, body: { ok: true, proposal: proposalOut(st, st.proposals.get(id)) } };
258
+ }
259
+ if (p0.state !== 'open') return bad(409, `the proposal is ${p0.state} already`);
260
+ const answer = (extra) => { const st = S.fold(S.readLines(file)); return { status: 200, body: { ok: true, proposal: proposalOut(st, st.proposals.get(id)), ...extra } }; };
261
+
262
+ if (action === 'dismiss') {
263
+ const r = S.proposalDismiss(file, id, body.why, g.actor, { now });
264
+ if (!r.ok) return bad(r.status, r.error);
265
+ // A dismissed proposal is a correction signal, as a declined scan ask is. A later scan does not raise the same title again (lib/scan.js duplicateProposal).
266
+ require('./routes-boards.js').scanDeclineSignal(ctx, { found: { run: p0.run, scan: p0.scan } });
267
+ return answer({});
268
+ }
269
+ if (body.fields !== undefined || action === 'change') {
270
+ const r = S.proposalChange(file, id, body.fields, g.actor, { now });
271
+ if (!r.ok) return bad(r.status, r.error);
272
+ if (action === 'change') return answer({});
273
+ state = S.fold(S.readLines(file));
274
+ }
275
+ const p = state.proposals.get(id);
276
+ const rep = state.reports.get(p.report);
277
+
278
+ if (p.kind === 'create') {
279
+ const boards = require('./routes-boards.js');
280
+ const f = p.fields;
281
+ const input = { title: p.title, lane: f.lane, anchor: f.anchor, intent: f.intent };
282
+ const made = boards.createAndPlace(ctx, w, f.board, input, g.actor, ({ file: bfile, board: b, initiative, stage }) => {
283
+ boards.append(bfile, g.actor, { kind: 'assign', board: b.id, id: initiative, to: b.id });
284
+ boards.append(bfile, g.actor, { kind: 'move', board: b.id, id: initiative, from: null, to: stage, why: 'proposed by the Horizon scan', warnings: [] });
285
+ });
286
+ if (!made.ok) return bad(made.status || 400, made.error, { field: made.field || null });
287
+ S.proposalApprove(file, id, { initiative: made.initiative.id, path: made.initiative.path, board: made.board, stage: made.stage }, g.actor, { now });
288
+ S.addEvidence(file, { initiative: made.initiative.id, report: p.report, proposal: id, note: p.finding }, g.actor, { now });
289
+ return answer({ result: { initiative: made.initiative, board: made.board, stage: made.stage } });
290
+ }
291
+ if (p.kind === 'attach') {
292
+ // The initiative must exist: evidence for an id that is not there is a dead link. While the rows are still being read, the PM presses again.
293
+ let known = null;
294
+ try {
295
+ const rows = require('./routes-boards.js').baseRows(w);
296
+ if (rows.pending) return bad(409, 'The initiatives are being read. Ask again in a few seconds.');
297
+ known = rows.ok ? rows.rows.some((x) => x.id === p.fields.initiative) : null;
298
+ } catch { known = null; }
299
+ if (known === false) return bad(404, `no initiative ${String(p.fields.initiative).slice(0, 60)} in this workspace`);
300
+ S.proposalApprove(file, id, { initiative: p.fields.initiative }, g.actor, { now });
301
+ S.addEvidence(file, { initiative: p.fields.initiative, report: p.report, proposal: id, note: p.finding }, g.actor, { now });
302
+ return answer({ result: { initiative: p.fields.initiative } });
126
303
  }
127
- const r = S.watchOp(file, text(body.op), board, { id: body.id, name: body.name, kind: body.kind, why: body.why, restore: body.restore === true }, g.actor);
128
- if (!r.ok) return send(bad(r.status, r.error));
129
- return send({ status: 200, body: { ok: true, id: r.id, ...(r.item ? { item: r.item } : {}) } });
304
+ // research: a web run that gets only the approved question and the public sources, then a repository run that files the follow-up's proposals.
305
+ if (!ctx.scanner) return bad(503, 'the scanner is not running in this server');
306
+ const r = ctx.scanner.startResearch(w, p, { now, by: g.actor.by });
307
+ if (!r.ok) return bad(r.status || 409, r.error);
308
+ S.proposalApprove(file, id, { scan: r.scan, report: r.reports[0].id, parent: rep ? rep.id : null }, g.actor, { now });
309
+ return answer({ result: { scan: r.scan, report: r.reports[0].id, parent: rep ? rep.id : null, steps: r.steps } });
130
310
  }
131
311
 
132
- module.exports = { prefixes: ['/api/scan'], handle, boardAnswer, todayAnswer };
312
+ module.exports = { prefixes: ['/api/scan'], handle, boardAnswer, todayAnswer, portfolioAnswer, reportAnswer };