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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-report-setup-test.js +1 -1
- package/workspace/c3-scan-fixture.js +247 -5
- package/workspace/c3-scan-test.js +453 -106
- package/workspace/guide/horizon-scan.md +45 -36
- package/workspace/guide/my-work.md +1 -0
- package/workspace/lib/day-run.js +4 -3
- package/workspace/lib/headless.js +19 -5
- package/workspace/lib/my-work.js +2 -1
- package/workspace/lib/pmos-verbs.js +2 -2
- package/workspace/lib/route-modules.js +1 -1
- package/workspace/lib/routes-boards.js +29 -12
- package/workspace/lib/routes-scan.js +224 -44
- package/workspace/lib/scan-cli.js +143 -45
- package/workspace/lib/scan-run.js +242 -87
- package/workspace/lib/scan.js +623 -79
- package/workspace/lib/work-assigned.js +1 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-doc-sheet.js +7 -1
- package/workspace/public/pm/c3-initiatives.js +5 -4
- package/workspace/public/pm/c3-scan-api.js +167 -0
- package/workspace/public/pm/c3-scan-next.js +248 -0
- package/workspace/public/pm/c3-scan-setup.js +317 -0
- package/workspace/public/pm/c3-scan-words.js +34 -0
- package/workspace/public/pm/c3-scan.css +58 -9
- package/workspace/public/pm/c3-scan.js +36 -208
- package/workspace/public/pm/c3-toast.js +1 -0
- package/workspace/public/pm/console3.css +3 -0
- package/workspace/public/pm/pm-work-view.js +11 -5
- package/workspace/public/pm/run-card.js +12 -3
- package/workspace/public/pm/sessions-panel.js +2 -2
- package/workspace/server.js +25 -5
- package/workspace/skills/horizon-scan-repo.skill +26 -10
- package/workspace/skills/horizon-scan-research.skill +44 -0
- 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 (
|
|
2
|
+
/* routes-scan.js — the Horizon scan's routes (initiatives horizon-scan and horizon-scan-portfolio, PRD section 7).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* GET /api/scan/
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
43
|
-
const cadence = S.cadenceOf(state
|
|
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,
|
|
46
|
-
const sug = S.scansOf(state,
|
|
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:
|
|
52
|
-
running: S.openScans(state).some((x) => x.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
128
|
-
if (!
|
|
129
|
-
|
|
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 };
|