create-pmos 0.188.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/lib/headless.js +19 -5
- 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/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/server.js +7 -4
- 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
package/workspace/lib/scan.js
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* scan.js — the Horizon scan's ledger and rules (
|
|
2
|
+
/* scan.js — the Horizon scan's ledger and rules (initiatives horizon-scan and horizon-scan-portfolio).
|
|
3
3
|
*
|
|
4
4
|
* The agent looks outward on a schedule. A scan is two headless runs: a web run that sees only the watch list the PM kept, and a
|
|
5
|
-
* repository run that sees the repository
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* repository run that sees the repository. Since horizon-scan-portfolio the scan covers the whole workspace (the portfolio): ONE watch
|
|
6
|
+
* list, whose items name the boards they relate to, ONE setting (cadence, repository on or off, report types), and an output that is
|
|
7
|
+
* a set of report documents (benchmark, competitive, insight, productivity) with proposals for what to do next. A proposal is a line
|
|
8
|
+
* the PM approves, changes or dismisses; nothing happens without that press. This file is the pure core: the shape of every line in
|
|
9
|
+
* <state folder>/scan.jsonl, the fold, the schedule, the limits and the duplicate test. It reads the clock only through the `now` its
|
|
10
|
+
* callers pass. Its I/O is the read/append pair, and writeReports(), which assembles a report document from the section lines.
|
|
11
|
+
*
|
|
12
|
+
* PORTFOLIO LEDGER (horizon-scan-portfolio, PRD sections 1 to 6). One ledger is one workspace. A watch line carries `boards` (the
|
|
13
|
+
* boards the item relates to; empty means the whole portfolio) and may still carry the old `board`. The per-board lists of the first
|
|
14
|
+
* Horizon scan fold into the one list: the same name on two boards is one item with both boards. A `cadence` line (the first scan's
|
|
15
|
+
* per-board press) folds into the workspace setting, the latest line of either kind winning.
|
|
16
|
+
* setting { cadence?, repo?, types? } PM only. Each key is applied alone; the latest wins
|
|
17
|
+
* scan-start adds { manual, by?, select?, repo, types, plan: { web, repo }, research? } `research` = { question, depth, sources, parent, proposal }
|
|
18
|
+
* run-start { scan, run, origin } a run began (so the open scan can show its steps)
|
|
19
|
+
* scan-stop { scan } PM only: the PM pressed Stop. The runs end, what was filed stays
|
|
20
|
+
* report { scan, id, type, title, path, origin: web|repo|both, parent? } path = the file, relative to the repository. A later
|
|
21
|
+
* line with the same id and `written: true` says the file exists (and its final origin and path)
|
|
22
|
+
* section { scan, report, heading, body, origin: web|repo, sources, run? } one part of a report; lib/scan.js writes the file from them
|
|
23
|
+
* proposal { scan, report, id, kind: create|research|attach, title, why, finding, evidence: [{ label, ref }], fields }
|
|
24
|
+
* proposal-change { id, fields } proposal-approve { id, result? } proposal-dismiss { id, why } PM only
|
|
25
|
+
* proposal-undo { id } PM only. Takes back a dismissal (the proposal is open again) or a change (the fields and title go back to what the
|
|
26
|
+
* agent filed). It is a new line, never a rewrite. An approved proposal cannot be undone: approve made an initiative or started runs.
|
|
27
|
+
* evidence { initiative, report, proposal } PM only (the approve press writes it): the report is evidence of that initiative
|
|
8
28
|
*
|
|
9
29
|
* LEDGER · KINDS · CADENCES · DEFAULT_CADENCE · EVENTS · LIMITS · SLOT
|
|
10
30
|
* ledgerFile(stateDir) → <state folder>/scan.jsonl
|
|
@@ -46,16 +66,26 @@ const LEDGER = 'scan.jsonl';
|
|
|
46
66
|
const KINDS = Object.freeze(['product', 'domain', 'approach']);
|
|
47
67
|
const CADENCES = Object.freeze(['daily', 'weekly', 'off']);
|
|
48
68
|
const DEFAULT_CADENCE = 'daily';
|
|
49
|
-
const EVENTS = Object.freeze(['watch-suggest', 'watch-keep', 'watch-edit', 'watch-remove', 'watch-add', 'cadence', 'scan-start', 'scan-end', 'finding', 'skip', 'pages', 'run-end'
|
|
50
|
-
|
|
69
|
+
const EVENTS = Object.freeze(['watch-suggest', 'watch-keep', 'watch-edit', 'watch-remove', 'watch-add', 'cadence', 'scan-start', 'scan-end', 'finding', 'skip', 'pages', 'run-end',
|
|
70
|
+
'setting', 'run-start', 'scan-stop', 'report', 'section', 'proposal', 'proposal-change', 'proposal-approve', 'proposal-dismiss', 'proposal-undo', 'evidence']);
|
|
71
|
+
const LIMITS = Object.freeze({ suggestions: 8, web: 5, repo: 3, sources: 6, items: 40, manual: 3, sections: 6, proposals: 5, body: 6000 });
|
|
72
|
+
const REPORT_TYPES = Object.freeze(['benchmark', 'competitive', 'insight', 'productivity']);
|
|
73
|
+
const WEB_TYPES = Object.freeze(['benchmark', 'competitive', 'insight']); // the productivity report comes from the repository run only
|
|
74
|
+
const REPORT_LABEL = Object.freeze({ benchmark: 'Benchmark report', competitive: 'Competitive analysis', insight: 'Insight report', productivity: 'Productivity report' });
|
|
75
|
+
const PROPOSAL_KINDS = Object.freeze(['create', 'research', 'attach']);
|
|
76
|
+
const DEPTHS = Object.freeze(['quick', 'standard', 'deep']);
|
|
77
|
+
const PORTFOLIO = 'portfolio';
|
|
51
78
|
const SLOT = '09:00';
|
|
52
79
|
const ORIGINS = Object.freeze(['web', 'repo']);
|
|
53
|
-
const MODES = Object.freeze(['scan', 'suggest']);
|
|
80
|
+
const MODES = Object.freeze(['scan', 'suggest', 'research']);
|
|
81
|
+
const REPORT_ORIGINS = Object.freeze(['web', 'repo', 'both']);
|
|
54
82
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
55
83
|
const WATCH_RE = /^w-[0-9a-f]{8}$/;
|
|
56
84
|
const SCAN_RE = /^scan-[0-9a-f]{10}$/;
|
|
57
85
|
const FINDING_RE = /^f-[0-9a-f]{10}$/;
|
|
58
86
|
const RUN_RE = /^srun-[0-9a-f]{10}$/;
|
|
87
|
+
const REPORT_RE = /^r-[0-9a-f]{10}$/;
|
|
88
|
+
const PROPOSAL_RE = /^p-[0-9a-f]{10}$/;
|
|
59
89
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
60
90
|
const oneLine = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
|
|
61
91
|
const pm = (by) => typeof by === 'string' && by.startsWith('pm:');
|
|
@@ -152,12 +182,13 @@ function sameWords(a, b) {
|
|
|
152
182
|
if (Math.min(wa.size, wb.size) < 3) return false;
|
|
153
183
|
return both / (wa.size + wb.size - both) >= 0.75;
|
|
154
184
|
}
|
|
155
|
-
/** The earlier finding on the same board that this one repeats (the same public page, or the same significant title words), or null.
|
|
185
|
+
/** The earlier finding on the same board that this one repeats (the same public page, or the same significant title words), or null.
|
|
186
|
+
* `board` null means the whole portfolio (every scan of the workspace). */
|
|
156
187
|
function duplicateOf(state, board, { title, sources } = {}) {
|
|
157
188
|
const urls = new Set((sources || []).filter((s) => s && isUrl(s.ref)).map((s) => normUrl(s.ref)));
|
|
158
189
|
for (const id of state.findingOrder) {
|
|
159
190
|
const f = state.findings.get(id);
|
|
160
|
-
if (!f || f.board !== board) continue;
|
|
191
|
+
if (!f || (board && f.board !== board)) continue;
|
|
161
192
|
if (urls.size && (f.sources || []).some((s) => isUrl(s.ref) && urls.has(normUrl(s.ref)))) return f;
|
|
162
193
|
if (sameWords(f.title, title)) return f;
|
|
163
194
|
}
|
|
@@ -166,67 +197,93 @@ function duplicateOf(state, board, { title, sources } = {}) {
|
|
|
166
197
|
|
|
167
198
|
// ── the fold ─────────────────────────────────────────────────────────────────────────────────────────
|
|
168
199
|
function boardState(s, id) {
|
|
169
|
-
if (!s.boards.has(id)) s.boards.set(id, { id,
|
|
200
|
+
if (!s.boards.has(id)) s.boards.set(id, { id, items: new Map(), order: [] });
|
|
170
201
|
return s.boards.get(id);
|
|
171
202
|
}
|
|
172
|
-
const emptyState = () => ({ boards: new Map(), scans: new Map(), scanOrder: [], findings: new Map(), findingOrder: []
|
|
203
|
+
const emptyState = () => ({ boards: new Map(), scans: new Map(), scanOrder: [], findings: new Map(), findingOrder: [],
|
|
204
|
+
itemById: new Map(), itemOrder: [], setting: { cadence: DEFAULT_CADENCE, repo: true, types: REPORT_TYPES.slice(), at: null },
|
|
205
|
+
reports: new Map(), reportOrder: [], proposals: new Map(), proposalOrder: [], evidence: [] });
|
|
206
|
+
const validBoard = (b) => typeof b === 'string' && ID_RE.test(b);
|
|
207
|
+
/** The boards a watch line names: its `boards` and its old `board`, valid ids only, once each. */
|
|
208
|
+
function boardsIn(l) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const b of [].concat(Array.isArray(l.boards) ? l.boards : [], l.board ? [l.board] : [])) if (validBoard(b) && !out.includes(b)) out.push(b);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
const typesOf = (v) => (Array.isArray(v) ? REPORT_TYPES.filter((t) => v.includes(t)) : null);
|
|
173
214
|
|
|
174
215
|
/** The current state of one ledger. A decision line from anything but the PM is ignored; the first line for an id wins. */
|
|
175
216
|
function fold(lines) {
|
|
176
217
|
const s = emptyState();
|
|
218
|
+
const addItem = (l, added) => {
|
|
219
|
+
if (!WATCH_RE.test(String(l.id)) || s.itemById.has(l.id) || !KINDS.includes(l.kind) || !oneLine(l.name, 80)) return;
|
|
220
|
+
const boards = boardsIn(l);
|
|
221
|
+
const it = { id: l.id, kind: l.kind, name: l.name, why: typeof l.why === 'string' ? l.why : '', ini: added ? null : (typeof l.ini === 'string' ? l.ini : null), added,
|
|
222
|
+
state: added ? 'kept' : 'suggested', prev: null, boards, scan: typeof l.scan === 'string' ? l.scan : null, at: l.at || null };
|
|
223
|
+
const b = boardState(s, boards[0] || PORTFOLIO);
|
|
224
|
+
b.items.set(it.id, it); b.order.push(it.id);
|
|
225
|
+
s.itemById.set(it.id, it); s.itemOrder.push(it.id);
|
|
226
|
+
};
|
|
177
227
|
for (const l of lines || []) {
|
|
178
|
-
if (!isObj(l)
|
|
179
|
-
const b = boardState(s, l.board);
|
|
228
|
+
if (!isObj(l)) continue;
|
|
180
229
|
switch (l.ev) {
|
|
181
|
-
case 'watch-suggest':
|
|
182
|
-
|
|
183
|
-
b.items.set(l.id, { id: l.id, kind: l.kind, name: l.name, why: typeof l.why === 'string' ? l.why : '', ini: typeof l.ini === 'string' ? l.ini : null, added: false, state: 'suggested', prev: null, at: l.at || null });
|
|
184
|
-
b.order.push(l.id);
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
case 'watch-add': {
|
|
188
|
-
if (!pm(l.by) || !WATCH_RE.test(String(l.id)) || b.items.has(l.id) || !KINDS.includes(l.kind) || !oneLine(l.name, 80)) break;
|
|
189
|
-
b.items.set(l.id, { id: l.id, kind: l.kind, name: l.name, why: typeof l.why === 'string' ? l.why : '', ini: null, added: true, state: 'kept', prev: null, at: l.at || null });
|
|
190
|
-
b.order.push(l.id);
|
|
191
|
-
break;
|
|
192
|
-
}
|
|
230
|
+
case 'watch-suggest': addItem(l, false); break;
|
|
231
|
+
case 'watch-add': if (pm(l.by)) addItem(l, true); break;
|
|
193
232
|
case 'watch-keep': {
|
|
194
|
-
const it =
|
|
233
|
+
const it = s.itemById.get(l.id);
|
|
195
234
|
if (!it || !pm(l.by)) break;
|
|
196
235
|
if (it.state === 'suggested') it.state = 'kept';
|
|
197
236
|
else if (it.state === 'removed' && l.restore === true) { it.state = it.prev || 'kept'; it.prev = null; }
|
|
198
237
|
break;
|
|
199
238
|
}
|
|
200
239
|
case 'watch-edit': {
|
|
201
|
-
const it =
|
|
240
|
+
const it = s.itemById.get(l.id);
|
|
202
241
|
if (!it || !pm(l.by) || it.state === 'removed') break;
|
|
203
242
|
if (oneLine(l.name, 80)) it.name = l.name.trim();
|
|
204
243
|
if (KINDS.includes(l.kind)) it.kind = l.kind;
|
|
205
244
|
if (typeof l.why === 'string') it.why = l.why.trim();
|
|
245
|
+
if (Array.isArray(l.boards)) it.boards = boardsIn({ boards: l.boards });
|
|
206
246
|
it.state = 'kept';
|
|
207
247
|
break;
|
|
208
248
|
}
|
|
209
249
|
case 'watch-remove': {
|
|
210
|
-
const it =
|
|
250
|
+
const it = s.itemById.get(l.id);
|
|
211
251
|
if (!it || !pm(l.by) || it.state === 'removed') break;
|
|
212
252
|
it.prev = it.state; it.state = 'removed';
|
|
213
253
|
break;
|
|
214
254
|
}
|
|
215
|
-
case 'cadence': if (pm(l.by) && CADENCES.includes(l.cadence)) {
|
|
255
|
+
case 'cadence': if (pm(l.by) && CADENCES.includes(l.cadence)) { s.setting.cadence = l.cadence; s.setting.at = l.at || null; } break;
|
|
256
|
+
case 'setting': {
|
|
257
|
+
if (!pm(l.by)) break;
|
|
258
|
+
if (CADENCES.includes(l.cadence)) s.setting.cadence = l.cadence;
|
|
259
|
+
if (typeof l.repo === 'boolean') s.setting.repo = l.repo;
|
|
260
|
+
const t = typesOf(l.types); if (t) s.setting.types = t;
|
|
261
|
+
s.setting.at = l.at || null;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
216
264
|
case 'scan-start': {
|
|
217
|
-
if (!SCAN_RE.test(String(l.scan)) || s.scans.has(l.scan) || !MODES.includes(l.mode)) break;
|
|
265
|
+
if (!validBoard(l.board) || !SCAN_RE.test(String(l.scan)) || s.scans.has(l.scan) || !MODES.includes(l.mode)) break;
|
|
266
|
+
const r = isObj(l.research) ? l.research : null;
|
|
267
|
+
const plan = isObj(l.plan) ? { web: l.plan.web === true, repo: l.plan.repo === true }
|
|
268
|
+
: { web: l.mode === 'scan' && Array.isArray(l.watch) && l.watch.length > 0, repo: true };
|
|
218
269
|
s.scans.set(l.scan, { scan: l.scan, board: l.board, mode: l.mode, start: l.at || null, end: null, ok: null, reason: null, watch: Array.isArray(l.watch) ? l.watch : [],
|
|
219
|
-
findings: [], skips: 0, pagesSaid: 0, pages: 0, found: 0, skipped: 0, runs: 0, runLines: []
|
|
270
|
+
findings: [], skips: 0, pagesSaid: 0, pages: 0, found: 0, skipped: 0, runs: 0, runLines: [],
|
|
271
|
+
manual: l.manual === true, by: typeof l.by === 'string' ? l.by : null, select: isObj(l.select) ? l.select : null, repo: l.repo !== false, types: typesOf(l.types) || [],
|
|
272
|
+
plan, research: r ? { question: String(r.question || ''), depth: DEPTHS.includes(r.depth) ? r.depth : 'standard', sources: Array.isArray(r.sources) ? r.sources : [],
|
|
273
|
+
parent: REPORT_RE.test(String(r.parent)) ? r.parent : null, proposal: PROPOSAL_RE.test(String(r.proposal)) ? r.proposal : null } : null,
|
|
274
|
+
reports: [], runStarts: [], stopped: false, stopAt: null });
|
|
220
275
|
s.scanOrder.push(l.scan);
|
|
221
276
|
break;
|
|
222
277
|
}
|
|
278
|
+
case 'run-start': { const sc = s.scans.get(l.scan); if (sc && RUN_RE.test(String(l.run))) sc.runStarts.push({ run: l.run, origin: l.origin, at: l.at || null }); break; }
|
|
279
|
+
case 'scan-stop': { const sc = s.scans.get(l.scan); if (sc && pm(l.by) && !sc.stopped) { sc.stopped = true; sc.stopAt = l.at || null; } break; }
|
|
223
280
|
case 'finding': {
|
|
224
281
|
const sc = s.scans.get(l.scan);
|
|
225
282
|
if (!sc || !FINDING_RE.test(String(l.id))) break;
|
|
226
283
|
const had = s.findings.get(l.id);
|
|
227
284
|
if (had) { if (typeof l.match === 'string' && ID_RE.test(l.match) && !l.title) had.match = l.match; break; }
|
|
228
285
|
if (!ORIGINS.includes(l.origin) || !oneLine(l.title, 160)) break;
|
|
229
|
-
const f = { id: l.id, board: l.board, scan: l.scan, origin: l.origin, title: l.title, why: typeof l.why === 'string' ? l.why : '', sources: Array.isArray(l.sources) ? l.sources : [],
|
|
286
|
+
const f = { id: l.id, board: validBoard(l.board) ? l.board : sc.board, scan: l.scan, origin: l.origin, title: l.title, why: typeof l.why === 'string' ? l.why : '', sources: Array.isArray(l.sources) ? l.sources : [],
|
|
230
287
|
match: typeof l.match === 'string' && ID_RE.test(l.match) ? l.match : null, by: l.by || null, run: RUN_RE.test(String(l.run)) ? l.run : null, at: l.at || null };
|
|
231
288
|
s.findings.set(l.id, f); s.findingOrder.push(l.id); sc.findings.push(l.id);
|
|
232
289
|
break;
|
|
@@ -242,6 +299,72 @@ function fold(lines) {
|
|
|
242
299
|
sc.skipped = Number.isFinite(l.skipped) ? l.skipped : sc.skips; sc.runs = Number.isFinite(l.runs) ? l.runs : sc.runLines.length;
|
|
243
300
|
break;
|
|
244
301
|
}
|
|
302
|
+
case 'report': {
|
|
303
|
+
const sc = s.scans.get(l.scan);
|
|
304
|
+
if (!sc || !REPORT_RE.test(String(l.id))) break;
|
|
305
|
+
const had = s.reports.get(l.id);
|
|
306
|
+
if (had) {
|
|
307
|
+
if (l.written === true) {
|
|
308
|
+
had.written = true; had.writtenAt = l.at || null;
|
|
309
|
+
if (REPORT_ORIGINS.includes(l.origin)) had.origin = l.origin;
|
|
310
|
+
if (typeof l.path === 'string' && l.path) had.path = l.path;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
if (!REPORT_TYPES.includes(l.type) || !oneLine(l.title, 200) || typeof l.path !== 'string' || !l.path) break;
|
|
315
|
+
s.reports.set(l.id, { id: l.id, scan: l.scan, board: sc.board, type: l.type, title: l.title, path: l.path, origin: REPORT_ORIGINS.includes(l.origin) ? l.origin : 'repo',
|
|
316
|
+
parent: REPORT_RE.test(String(l.parent)) ? l.parent : null, at: l.at || null, written: false, writtenAt: null, sections: [] });
|
|
317
|
+
s.reportOrder.push(l.id); sc.reports.push(l.id);
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
case 'section': {
|
|
321
|
+
const r = s.reports.get(l.report);
|
|
322
|
+
if (!r || r.scan !== l.scan || !ORIGINS.includes(l.origin) || !oneLine(l.heading, 120) || typeof l.body !== 'string') break;
|
|
323
|
+
r.sections.push({ heading: l.heading.trim(), body: l.body, origin: l.origin, sources: Array.isArray(l.sources) ? l.sources : [], run: RUN_RE.test(String(l.run)) ? l.run : null, by: l.by || null, at: l.at || null });
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
case 'proposal': {
|
|
327
|
+
const r = s.reports.get(l.report);
|
|
328
|
+
if (!r || r.scan !== l.scan || !PROPOSAL_RE.test(String(l.id)) || s.proposals.has(l.id) || !PROPOSAL_KINDS.includes(l.kind) || !oneLine(l.title, 160)) break;
|
|
329
|
+
s.proposals.set(l.id, { id: l.id, report: r.id, scan: r.scan, kind: l.kind, title: l.title.trim(), why: typeof l.why === 'string' ? l.why : '', finding: typeof l.finding === 'string' ? l.finding : '',
|
|
330
|
+
evidence: Array.isArray(l.evidence) ? l.evidence : [], fields: isObj(l.fields) ? { ...l.fields } : {}, state: 'open', changed: false, dismissWhy: null, result: null,
|
|
331
|
+
by: l.by || null, run: RUN_RE.test(String(l.run)) ? l.run : null, at: l.at || null, decidedAt: null, filed: null });
|
|
332
|
+
{ const q = s.proposals.get(l.id); q.filed = { title: q.title, fields: { ...q.fields } }; }
|
|
333
|
+
s.proposalOrder.push(l.id);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case 'proposal-change': {
|
|
337
|
+
const p = s.proposals.get(l.id);
|
|
338
|
+
if (!p || !pm(l.by) || p.state !== 'open' || !isObj(l.fields)) break;
|
|
339
|
+
const { title, ...rest } = l.fields;
|
|
340
|
+
if (oneLine(title, 160)) p.title = title.trim();
|
|
341
|
+
p.fields = { ...p.fields, ...rest }; p.changed = true;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case 'proposal-approve': {
|
|
345
|
+
const p = s.proposals.get(l.id);
|
|
346
|
+
if (!p || !pm(l.by) || p.state !== 'open') break;
|
|
347
|
+
p.state = 'approved'; p.result = isObj(l.result) ? { ...l.result } : {}; p.decidedAt = l.at || null;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case 'proposal-dismiss': {
|
|
351
|
+
const p = s.proposals.get(l.id);
|
|
352
|
+
if (!p || !pm(l.by) || p.state !== 'open' || !oneLine(l.why, 500)) break;
|
|
353
|
+
p.state = 'dismissed'; p.dismissWhy = l.why.trim(); p.decidedAt = l.at || null;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case 'proposal-undo': {
|
|
357
|
+
const p = s.proposals.get(l.id);
|
|
358
|
+
if (!p || !pm(l.by)) break;
|
|
359
|
+
if (p.state === 'dismissed') { p.state = 'open'; p.dismissWhy = null; p.decidedAt = null; }
|
|
360
|
+
else if (p.state === 'open' && p.changed) { p.title = p.filed.title; p.fields = { ...p.filed.fields }; p.changed = false; }
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
case 'evidence': {
|
|
364
|
+
if (!pm(l.by) || !ID_RE.test(String(l.initiative)) || !REPORT_RE.test(String(l.report))) break;
|
|
365
|
+
s.evidence.push({ initiative: l.initiative, report: l.report, proposal: PROPOSAL_RE.test(String(l.proposal)) ? l.proposal : null, note: typeof l.note === 'string' ? l.note : '', at: l.at || null });
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
245
368
|
default: break;
|
|
246
369
|
}
|
|
247
370
|
}
|
|
@@ -254,27 +377,47 @@ function fold(lines) {
|
|
|
254
377
|
return s;
|
|
255
378
|
}
|
|
256
379
|
|
|
257
|
-
|
|
258
|
-
/** The
|
|
259
|
-
|
|
260
|
-
|
|
380
|
+
// ── the portfolio watch list ─────────────────────────────────────────────────────────────────────────
|
|
381
|
+
/** The workspace setting: { cadence, repo, types }. */
|
|
382
|
+
const settingOf = (state) => ({ cadence: state.setting.cadence, repo: state.setting.repo, types: state.setting.types.slice() });
|
|
383
|
+
const cadenceOf = (state) => state.setting.cadence; // the one cadence of the workspace; the old per-board argument is ignored
|
|
384
|
+
/** The one watch list. The same name (any case) on two boards is ONE item: the first id leads, `ids` lists every id, `boards` is the union. `board` filters to the
|
|
385
|
+
* items that relate to that board; an item that names no board relates to the whole portfolio and is in every board's list. */
|
|
386
|
+
function portfolioView(state, board = null) {
|
|
387
|
+
const groups = new Map();
|
|
388
|
+
for (const id of state.itemOrder) {
|
|
389
|
+
const it = state.itemById.get(id);
|
|
390
|
+
if (!it || it.state === 'removed') continue;
|
|
391
|
+
const key = it.name.toLowerCase();
|
|
392
|
+
const g = groups.get(key);
|
|
393
|
+
if (!g) { groups.set(key, { id: it.id, kind: it.kind, name: it.name, why: it.why, ini: it.ini, added: it.added, state: it.state, boards: it.boards.slice(), ids: [it.id], at: it.at }); continue; }
|
|
394
|
+
g.ids.push(it.id);
|
|
395
|
+
for (const b of it.boards) if (!g.boards.includes(b)) g.boards.push(b);
|
|
396
|
+
if (it.state === 'kept') g.state = 'kept';
|
|
397
|
+
if (it.added) g.added = true;
|
|
398
|
+
if (!g.ini && it.ini) g.ini = it.ini;
|
|
399
|
+
}
|
|
400
|
+
const out = [...groups.values()];
|
|
401
|
+
return board ? out.filter((g) => !g.boards.length || g.boards.includes(board)) : out;
|
|
402
|
+
}
|
|
403
|
+
/** The live items of a board's list (or of the portfolio, board null): { suggested: [], kept: [] }. */
|
|
404
|
+
function watchOf(state, board = null) {
|
|
261
405
|
const out = { suggested: [], kept: [] };
|
|
262
|
-
|
|
263
|
-
for (const id of b.order) { const it = b.items.get(id); if (it.state === 'suggested') out.suggested.push(it); else if (it.state === 'kept') out.kept.push(it); }
|
|
406
|
+
for (const it of portfolioView(state, board)) (it.state === 'kept' ? out.kept : out.suggested).push(it);
|
|
264
407
|
return out;
|
|
265
408
|
}
|
|
266
409
|
/** The same items in the order they were filed, each with its state: [{ …item, state: 'suggested'|'kept' }]. */
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
const
|
|
272
|
-
const hasWatchList = (state, board) => { const b = state.boards.get(board); return !!(b && b.order.length); };
|
|
273
|
-
const scansOf = (state, board, mode) => state.scanOrder.map((id) => state.scans.get(id)).filter((x) => x.board === board && (!mode || x.mode === mode));
|
|
410
|
+
const itemsOf = (state, board = null) => portfolioView(state, board);
|
|
411
|
+
const keptItems = (state, board = null) => watchOf(state, board).kept;
|
|
412
|
+
/** Was any item ever filed (even one the PM removed)? Then the suggest run has done its part. */
|
|
413
|
+
const hasWatchList = (state, board = null) => state.itemOrder.some((id) => { const it = state.itemById.get(id); return !board || !it.boards.length || it.boards.includes(board); });
|
|
414
|
+
const scansOf = (state, board, mode) => state.scanOrder.map((id) => state.scans.get(id)).filter((x) => (!board || x.board === board) && (!mode || x.mode === mode));
|
|
274
415
|
const openScans = (state) => state.scanOrder.map((id) => state.scans.get(id)).filter((x) => !x.end);
|
|
275
|
-
/** The scans
|
|
416
|
+
/** The scans that started on a local day (YYYY-MM-DD). A scan that failed or was stopped still counts: one scan per day. */
|
|
276
417
|
const scanDay = (state, board, day) => scansOf(state, board, 'scan').filter((x) => x.start && D.localDay(Date.parse(x.start)) === day);
|
|
277
418
|
const lastScan = (state, board) => scansOf(state, board, 'scan').filter((x) => x.end).pop() || null;
|
|
419
|
+
/** The manual scans (Scan now) that started on a local day. Research runs are not counted. */
|
|
420
|
+
const manualToday = (state, day) => scansOf(state, null, 'scan').filter((x) => x.manual && x.start && D.localDay(Date.parse(x.start)) === day);
|
|
278
421
|
|
|
279
422
|
// ── the schedule ─────────────────────────────────────────────────────────────────────────────────────
|
|
280
423
|
const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
@@ -286,26 +429,37 @@ function isWorkingDay(ms, cadence) {
|
|
|
286
429
|
return false;
|
|
287
430
|
}
|
|
288
431
|
const slotMs = (ms) => { const d = new Date(ms); const [h, m] = SLOT.split(':').map(Number); d.setHours(h, m, 0, 0); return d.getTime(); };
|
|
289
|
-
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, 09:00' : cadence === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs
|
|
432
|
+
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, 09:00' : cadence === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs');
|
|
290
433
|
|
|
291
|
-
/**
|
|
292
|
-
*
|
|
434
|
+
/** The report types a scan can write, given the repository switch and the number of web items it will search. Web reports (benchmark, competitive, insight)
|
|
435
|
+
* need an item or the repository; the productivity report needs the repository. */
|
|
436
|
+
function effectiveTypes(types, { repo, items }) {
|
|
437
|
+
const want = REPORT_TYPES.filter((t) => (types || []).includes(t));
|
|
438
|
+
return want.filter((t) => (t === 'productivity' ? !!repo : !!repo || items > 0));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** May a scheduled scan start now? `open` is the day-check window and quiet hours as one boolean (lib/driver-run.js dayCheckOpen). `board` null = the portfolio.
|
|
442
|
+
* → { ok } | { ok: false, why }. A scan that missed 09:00 starts at the first tick inside the working hours of that day, never outside them.
|
|
443
|
+
* One scan per day across the portfolio, whoever started it: a manual scan today is today's scan. */
|
|
293
444
|
function dueNow(state, board, now, { open = true } = {}) {
|
|
294
|
-
const cadence = cadenceOf(state
|
|
445
|
+
const cadence = cadenceOf(state);
|
|
295
446
|
if (cadence === 'off') return { ok: false, why: 'off' };
|
|
296
|
-
|
|
447
|
+
const kept = keptItems(state, board);
|
|
448
|
+
if (!kept.length) return { ok: false, why: 'no-watch-list' };
|
|
297
449
|
if (!isWorkingDay(now, cadence)) return { ok: false, why: 'not-a-scan-day' };
|
|
298
450
|
if (now < slotMs(now)) return { ok: false, why: 'before-09' };
|
|
299
451
|
if (!open) return { ok: false, why: 'outside-working-hours' };
|
|
300
452
|
if (scanDay(state, board, D.localDay(now)).length) return { ok: false, why: 'done-today' };
|
|
301
|
-
if (openScans(state).some((x) => x.board === board)) return { ok: false, why: 'running' };
|
|
453
|
+
if (openScans(state).some((x) => !board || x.board === board)) return { ok: false, why: 'running' };
|
|
454
|
+
if (!effectiveTypes(state.setting.types, { repo: state.setting.repo, items: kept.length }).length) return { ok: false, why: 'no-reports' };
|
|
302
455
|
return { ok: true };
|
|
303
456
|
}
|
|
304
457
|
|
|
305
|
-
/** May the one suggest run
|
|
306
|
-
* and none today. Without a kept item nothing is scanned, so the suggestions are what the PM decides on first. */
|
|
458
|
+
/** May the one suggest run start now? A portfolio with no watch list at all, whose cadence is not off and whose repository switch is on, with no successful
|
|
459
|
+
* suggest run and none today. Without a kept item nothing is scanned, so the suggestions are what the PM decides on first. */
|
|
307
460
|
function suggestDue(state, board, now, { open = true, initiatives = 0 } = {}) {
|
|
308
|
-
if (cadenceOf(state
|
|
461
|
+
if (cadenceOf(state) === 'off') return { ok: false, why: 'off' };
|
|
462
|
+
if (!state.setting.repo) return { ok: false, why: 'repo-off' };
|
|
309
463
|
if (hasWatchList(state, board)) return { ok: false, why: 'has-watch-list' };
|
|
310
464
|
if (!initiatives) return { ok: false, why: 'no-initiatives' };
|
|
311
465
|
if (!open) return { ok: false, why: 'outside-working-hours' };
|
|
@@ -316,10 +470,10 @@ function suggestDue(state, board, now, { open = true, initiatives = 0 } = {}) {
|
|
|
316
470
|
return { ok: true };
|
|
317
471
|
}
|
|
318
472
|
|
|
319
|
-
/** When the next scan
|
|
473
|
+
/** When the next scan starts, in ms, or null (off, or nothing kept). `dayEnd` is the day-check end ('18:00'). A scan that is due
|
|
320
474
|
* now and has not started starts at the next tick, so the answer is `now`. */
|
|
321
475
|
function nextScan(state, board, now, { dayEnd = '18:00' } = {}) {
|
|
322
|
-
const cadence = cadenceOf(state
|
|
476
|
+
const cadence = cadenceOf(state);
|
|
323
477
|
if (cadence === 'off' || !keptItems(state, board).length) return null;
|
|
324
478
|
const doneToday = scanDay(state, board, D.localDay(now)).length > 0;
|
|
325
479
|
for (let k = 0; k < 8; k++) {
|
|
@@ -337,60 +491,450 @@ function nextScan(state, board, now, { dayEnd = '18:00' } = {}) {
|
|
|
337
491
|
return null;
|
|
338
492
|
}
|
|
339
493
|
|
|
494
|
+
/** May a manual scan start now? A manual scan may start outside the working hours but never inside quiet hours; at most LIMITS.manual a day; one scan at a time.
|
|
495
|
+
* `quiet` is whether quiet hours hold at `now`; `running` is a scan alive in the process that the ledger may not show yet.
|
|
496
|
+
* → { ok, used, left } | { ok: false, status, why, error } */
|
|
497
|
+
function manualCheck(state, now, { quiet = false, running = false } = {}) {
|
|
498
|
+
const used = manualToday(state, D.localDay(now)).length;
|
|
499
|
+
const left = Math.max(0, LIMITS.manual - used);
|
|
500
|
+
if (running || openScans(state).length) return { ok: false, status: 409, why: 'running', error: 'A scan is running. Wait for it to end, or stop it.', used, left };
|
|
501
|
+
if (quiet) return { ok: false, status: 409, why: 'quiet-hours', error: 'Quiet hours hold. A manual scan does not start inside them.', used, left };
|
|
502
|
+
if (used >= LIMITS.manual) return { ok: false, status: 429, why: 'manual-limit', error: `At most ${LIMITS.manual} manual scans a day. Today's ${LIMITS.manual} are used.`, used, left };
|
|
503
|
+
return { ok: true, used, left };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** What a scan will do, from the PM's picks (or the setting, for a scheduled scan). `input`: { items?: [watch item id], repo?: boolean, types?: [type] }.
|
|
507
|
+
* → { ok, items: [item], repo, types, plan: { web, repo } } | { ok: false, status, error } */
|
|
508
|
+
function selectScan(state, input) {
|
|
509
|
+
const x = isObj(input) ? input : {};
|
|
510
|
+
const kept = keptItems(state, null);
|
|
511
|
+
let items = kept;
|
|
512
|
+
if (x.items !== undefined) {
|
|
513
|
+
if (!Array.isArray(x.items) || x.items.length > LIMITS.items) return refuse(400, 'items is a list of watch list item ids');
|
|
514
|
+
items = [];
|
|
515
|
+
for (const id of x.items) {
|
|
516
|
+
if (typeof id !== 'string' || !WATCH_RE.test(id)) return refuse(400, 'each item is a watch list item id');
|
|
517
|
+
const g = portfolioView(state, null).find((it) => it.ids.includes(id));
|
|
518
|
+
if (!g) return refuse(404, `no watch list item ${id}`);
|
|
519
|
+
if (g.state !== 'kept') return refuse(400, `"${g.name}" is a suggestion. Keep it before it is scanned`);
|
|
520
|
+
if (!items.some((i) => i.id === g.id)) items.push(g);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (x.repo !== undefined && typeof x.repo !== 'boolean') return refuse(400, 'repo is true or false');
|
|
524
|
+
const repo = x.repo === undefined ? state.setting.repo : x.repo;
|
|
525
|
+
let types = state.setting.types;
|
|
526
|
+
if (x.types !== undefined) {
|
|
527
|
+
if (!Array.isArray(x.types) || x.types.some((t) => !REPORT_TYPES.includes(t))) return refuse(400, `types are a list of ${REPORT_TYPES.join(', ')}`);
|
|
528
|
+
types = REPORT_TYPES.filter((t) => x.types.includes(t));
|
|
529
|
+
}
|
|
530
|
+
const eff = effectiveTypes(types, { repo, items: items.length });
|
|
531
|
+
if (!eff.length) return refuse(400, 'Nothing to write. Pick a report type that the picks can fill: a web report needs an item or the repository, and the productivity report needs the repository');
|
|
532
|
+
const plan = { web: items.length > 0 && eff.some((t) => WEB_TYPES.includes(t)), repo: !!repo };
|
|
533
|
+
return { ok: true, items, repo: !!repo, types: eff, plan };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── steps of a scan, for the page ────────────────────────────────────────────────────────────────────
|
|
537
|
+
const STEP_LABEL = Object.freeze({ web: 'Searching the public web', repo: 'Reading the repository', reports: 'Writing the reports' });
|
|
538
|
+
/** The steps of a scan: web, repo and reports, each with a state (pending | running | done | failed | skipped). */
|
|
539
|
+
function stepsOf(sc) {
|
|
540
|
+
const one = (origin, planned) => {
|
|
541
|
+
if (!planned) return 'skipped';
|
|
542
|
+
const end = sc.runLines.find((r) => r.origin === origin);
|
|
543
|
+
if (end) return end.ok ? 'done' : 'failed';
|
|
544
|
+
if (sc.runStarts.some((r) => r.origin === origin)) return sc.end ? 'failed' : 'running';
|
|
545
|
+
return sc.end ? 'skipped' : 'pending';
|
|
546
|
+
};
|
|
547
|
+
const web = one('web', sc.plan.web);
|
|
548
|
+
const repo = one('repo', sc.mode === 'suggest' ? false : sc.plan.repo);
|
|
549
|
+
const settled = (st) => st === 'done' || st === 'failed' || st === 'skipped';
|
|
550
|
+
const reports = sc.mode === 'suggest' ? 'skipped' : sc.end ? (sc.reports.length ? 'done' : 'skipped') : (settled(web) && settled(repo) ? 'running' : 'pending');
|
|
551
|
+
return [{ id: 'web', label: STEP_LABEL.web, state: web }, { id: 'repo', label: STEP_LABEL.repo, state: repo }, { id: 'reports', label: STEP_LABEL.reports, state: reports }];
|
|
552
|
+
}
|
|
553
|
+
|
|
340
554
|
// ── the PM's presses ─────────────────────────────────────────────────────────────────────────────────
|
|
341
555
|
const isPm = (actor) => !!actor && pm(actor.by);
|
|
342
556
|
function setCadence(file, board, cadence, actor, { now } = {}) {
|
|
343
557
|
if (!isPm(actor)) return refuse(403, 'only the PM can change the scan cadence');
|
|
344
558
|
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
345
559
|
if (!CADENCES.includes(cadence)) return refuse(400, `cadence must be one of ${CADENCES.join(', ')}`);
|
|
346
|
-
if (typeof board !== 'string' || !ID_RE.test(board)) return refuse(400, 'board must be a board id');
|
|
347
|
-
append(file, { ev: 'cadence', board, cadence, by: actor.by }, { now });
|
|
348
|
-
return { ok: true, board, cadence };
|
|
560
|
+
if (board != null && (typeof board !== 'string' || !ID_RE.test(board))) return refuse(400, 'board must be a board id');
|
|
561
|
+
append(file, { ev: 'cadence', ...(board ? { board } : {}), cadence, by: actor.by }, { now });
|
|
562
|
+
return { ok: true, ...(board ? { board } : {}), cadence };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The workspace setting: any of cadence, repo, types. Each key sent is written and nothing else is.
|
|
566
|
+
* → { ok, setting } | { ok: false, status, error } */
|
|
567
|
+
function setSetting(file, input, actor, { now } = {}) {
|
|
568
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can change the scan setting');
|
|
569
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
570
|
+
const x = isObj(input) ? input : {};
|
|
571
|
+
const line = { ev: 'setting', by: actor.by };
|
|
572
|
+
if (x.cadence !== undefined) { if (!CADENCES.includes(x.cadence)) return refuse(400, `cadence must be one of ${CADENCES.join(', ')}`); line.cadence = x.cadence; }
|
|
573
|
+
if (x.repo !== undefined) { if (typeof x.repo !== 'boolean') return refuse(400, 'repo is true or false'); line.repo = x.repo; }
|
|
574
|
+
if (x.types !== undefined) {
|
|
575
|
+
if (!Array.isArray(x.types) || x.types.some((t) => !REPORT_TYPES.includes(t))) return refuse(400, `types are a list of ${REPORT_TYPES.join(', ')}`);
|
|
576
|
+
line.types = REPORT_TYPES.filter((t) => x.types.includes(t));
|
|
577
|
+
}
|
|
578
|
+
if (line.cadence === undefined && line.repo === undefined && line.types === undefined) return refuse(400, 'say what changes: cadence, repo or types');
|
|
579
|
+
append(file, line, { now });
|
|
580
|
+
return { ok: true, setting: settingOf(fold(readLines(file))) };
|
|
349
581
|
}
|
|
350
582
|
|
|
351
|
-
/** keep | edit | remove | add on
|
|
583
|
+
/** keep | edit | remove | add on the portfolio watch list. `board` is the board a press came from (the per-board routes) or null.
|
|
584
|
+
* `input`: { id } for keep, edit and remove; { id, name?, kind?, why?, boards? } for edit; { kind, name, why?, boards? } for add.
|
|
585
|
+
* A press on an item that was folded from several boards (the same name) writes one line for each id, so the item stays one item. */
|
|
352
586
|
function watchOp(file, op, board, input, actor, { now } = {}) {
|
|
353
587
|
if (!isPm(actor)) return refuse(403, 'only the PM can change the watch list');
|
|
354
588
|
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
355
|
-
if (typeof board !== 'string' || !ID_RE.test(board)) return refuse(400, 'board must be a board id');
|
|
589
|
+
if (board != null && (typeof board !== 'string' || !ID_RE.test(board))) return refuse(400, 'board must be a board id');
|
|
356
590
|
if (!['keep', 'edit', 'remove', 'add'].includes(op)) return refuse(400, 'op must be keep, edit, remove or add');
|
|
357
591
|
const state = fold(readLines(file));
|
|
358
|
-
const b = state.boards.get(board);
|
|
359
592
|
const x = isObj(input) ? input : {};
|
|
593
|
+
let boards = null;
|
|
594
|
+
if (x.boards !== undefined) {
|
|
595
|
+
if (!Array.isArray(x.boards) || x.boards.length > 20 || x.boards.some((b) => !validBoard(b))) return refuse(400, 'boards is a list of board ids');
|
|
596
|
+
boards = boardsIn({ boards: x.boards });
|
|
597
|
+
}
|
|
598
|
+
const live = portfolioView(state, null);
|
|
360
599
|
if (op === 'add') {
|
|
361
600
|
const c = checkWatchItem(x);
|
|
362
601
|
if (!c.ok) return refuse(400, c.error);
|
|
363
|
-
if (
|
|
364
|
-
if (
|
|
602
|
+
if (live.length >= LIMITS.items) return refuse(409, `a watch list holds at most ${LIMITS.items} items`);
|
|
603
|
+
if (live.some((i) => i.name.toLowerCase() === c.item.name.toLowerCase())) return refuse(409, 'the watch list has that item already');
|
|
365
604
|
const id = newId('w');
|
|
366
|
-
|
|
367
|
-
|
|
605
|
+
const on = boards || (board ? [board] : []);
|
|
606
|
+
append(file, { ev: 'watch-add', ...(board ? { board } : {}), boards: on, id, ...c.item, by: actor.by }, { now });
|
|
607
|
+
return { ok: true, id, item: { id, ...c.item, state: 'kept', added: true, boards: on } };
|
|
368
608
|
}
|
|
369
609
|
if (typeof x.id !== 'string' || !WATCH_RE.test(x.id)) return refuse(400, 'id must be a watch list item id');
|
|
370
|
-
const
|
|
371
|
-
|
|
610
|
+
const raw = state.itemById.get(x.id);
|
|
611
|
+
const group = live.find((g) => g.ids.includes(x.id));
|
|
612
|
+
const it = group || (raw && raw.state === 'removed' ? { ...raw, ids: [raw.id], state: 'removed' } : null);
|
|
613
|
+
const here = !!it && (!board || !it.boards || !it.boards.length || it.boards.includes(board) || (raw && raw.state === 'removed'));
|
|
614
|
+
if (!it || !here) return refuse(404, `no watch list item ${x.id.slice(0, 20)} on this ${board ? 'board' : 'list'}`);
|
|
615
|
+
const ids = it.ids;
|
|
372
616
|
if (op === 'keep') {
|
|
373
617
|
if (it.state === 'kept') return refuse(409, 'the item is kept already');
|
|
374
618
|
if (it.state === 'removed' && x.restore !== true) return refuse(409, 'the item was removed');
|
|
375
|
-
append(file, { ev: 'watch-keep', board
|
|
619
|
+
for (const id of ids) append(file, { ev: 'watch-keep', ...(board ? { board } : {}), id, ...(x.restore === true ? { restore: true } : {}), by: actor.by }, { now });
|
|
376
620
|
return { ok: true, id: it.id };
|
|
377
621
|
}
|
|
378
622
|
if (it.state === 'removed') return refuse(409, 'the item was removed');
|
|
379
|
-
if (op === 'remove') { append(file, { ev: 'watch-remove', board
|
|
623
|
+
if (op === 'remove') { for (const id of ids) append(file, { ev: 'watch-remove', ...(board ? { board } : {}), id, by: actor.by }, { now }); return { ok: true, id: it.id }; }
|
|
380
624
|
// edit
|
|
381
|
-
const line = { ev: 'watch-edit', board
|
|
625
|
+
const line = { ev: 'watch-edit', ...(board ? { board } : {}), by: actor.by };
|
|
382
626
|
if (x.name !== undefined) { if (!oneLine(x.name, 80)) return refuse(400, 'name is one line of 1 to 80 characters'); line.name = x.name.trim(); }
|
|
383
627
|
if (x.kind !== undefined) { if (!KINDS.includes(x.kind)) return refuse(400, `kind must be one of ${KINDS.join(', ')}`); line.kind = x.kind; }
|
|
384
628
|
if (x.why !== undefined) { if (x.why !== '' && !oneLine(x.why, 200)) return refuse(400, 'why is one line of at most 200 characters'); line.why = String(x.why).trim(); }
|
|
385
|
-
if (
|
|
386
|
-
if (line.name
|
|
387
|
-
|
|
629
|
+
if (boards) line.boards = boards;
|
|
630
|
+
if (line.name === undefined && line.kind === undefined && line.why === undefined && !line.boards) return refuse(400, 'say what changes: name, kind, why or boards');
|
|
631
|
+
if (line.name && live.some((i) => i.id !== it.id && i.name.toLowerCase() === line.name.toLowerCase())) return refuse(409, 'the watch list has that item already');
|
|
632
|
+
for (const id of ids) append(file, { ...line, id }, { now });
|
|
388
633
|
return { ok: true, id: it.id };
|
|
389
634
|
}
|
|
390
635
|
|
|
636
|
+
// ── reports: sections, proposals ─────────────────────────────────────────────────────────────────────
|
|
637
|
+
const URL_IN_TEXT = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
|
638
|
+
const urlsIn = (text) => (String(text || '').match(URL_IN_TEXT) || []).map((u) => u.replace(/[.,;:!?]+$/, ''));
|
|
639
|
+
/** The public pages the web run of a scan has said it read: the sources of its findings and of its web sections. A repository section may cite these, no others. */
|
|
640
|
+
function readUrls(state, scanId) {
|
|
641
|
+
const out = new Set();
|
|
642
|
+
const sc = state.scans.get(scanId);
|
|
643
|
+
if (!sc) return out;
|
|
644
|
+
for (const id of sc.findings) { const f = state.findings.get(id); if (f.origin === 'web') for (const x of f.sources) if (isUrl(x.ref)) out.add(normUrl(x.ref)); }
|
|
645
|
+
for (const rid of sc.reports) for (const sec of state.reports.get(rid).sections) if (sec.origin === 'web') for (const x of sec.sources) if (isUrl(x.ref)) out.add(normUrl(x.ref));
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
function sourcesIn(list, max = LIMITS.sources) {
|
|
649
|
+
if (!Array.isArray(list) || !list.length) return { error: 'at least one source is needed' };
|
|
650
|
+
if (list.length > max) return { error: `at most ${max} sources` };
|
|
651
|
+
const sources = [];
|
|
652
|
+
for (const s of list) {
|
|
653
|
+
if (!isObj(s) || !oneLine(s.label, 80) || !oneLine(s.ref, 500)) return { error: 'each source needs a label (up to 80 characters) and a ref (up to 500)' };
|
|
654
|
+
sources.push({ label: s.label.trim(), ref: s.ref.trim() });
|
|
655
|
+
}
|
|
656
|
+
return { sources };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** One section of a report as an agent files it. A web section: every source is a public page the run read, and every address in the text is one of the pages
|
|
660
|
+
* the run declared (here or earlier). A repository section: every source is a pointer (a path, a Jira key or a note), and an address in it must be a page the
|
|
661
|
+
* web run read. → { ok, section } | { ok: false, error } */
|
|
662
|
+
function checkSection(input, origin, { state, scanId } = {}) {
|
|
663
|
+
const x = isObj(input) ? input : {};
|
|
664
|
+
if (!ORIGINS.includes(origin)) return { ok: false, error: 'origin is web or repo' };
|
|
665
|
+
if (!oneLine(x.heading, 100) || /^#/.test(String(x.heading).trim())) return { ok: false, error: 'heading is one line of 1 to 100 characters, with no # in front' };
|
|
666
|
+
const body = typeof x.body === 'string' ? x.body.replace(/\r\n?/g, '\n').trim() : '';
|
|
667
|
+
if (!body) return { ok: false, error: 'the section has no text: give it on stdin or with --body' };
|
|
668
|
+
if (body.length > LIMITS.body) return { ok: false, error: `a section is at most ${LIMITS.body} characters` };
|
|
669
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(body)) return { ok: false, error: 'the section holds a control character' };
|
|
670
|
+
const src = sourcesIn(x.sources);
|
|
671
|
+
if (src.error) return { ok: false, error: origin === 'web' ? 'a web section needs at least one source: the public page you read (--source "label=https://…")' : 'a repository section needs at least one pointer (--source "label=path, Jira key or note")' };
|
|
672
|
+
const read = state && scanId ? readUrls(state, scanId) : new Set();
|
|
673
|
+
const own = new Set(src.sources.filter((s) => isUrl(s.ref)).map((s) => normUrl(s.ref)));
|
|
674
|
+
if (origin === 'web') {
|
|
675
|
+
if (src.sources.some((s) => !isUrl(s.ref))) return { ok: false, error: 'every source of a web section is a public page address (https://…) that you read' };
|
|
676
|
+
const allowed = new Set([...read, ...own]);
|
|
677
|
+
const stray = urlsIn(body).find((u) => !allowed.has(normUrl(u)));
|
|
678
|
+
if (stray) return { ok: false, error: `the text names ${stray.slice(0, 80)}, which is not a page you declared. Add it as a --source, or take it out` };
|
|
679
|
+
} else {
|
|
680
|
+
const badSrc = src.sources.find((s) => isUrl(s.ref) && !read.has(normUrl(s.ref)));
|
|
681
|
+
if (badSrc) return { ok: false, error: 'a repository section cites a path, a Jira key or a note. A public page only when the web run read it' };
|
|
682
|
+
const stray = urlsIn(body).find((u) => !read.has(normUrl(u)));
|
|
683
|
+
if (stray) return { ok: false, error: `the text names ${stray.slice(0, 80)}, which the web run did not read. Take it out` };
|
|
684
|
+
}
|
|
685
|
+
return { ok: true, section: { heading: x.heading.trim(), body, sources: src.sources, origin } };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const LANE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
689
|
+
/** The fields a proposal of each kind carries. `base` holds the fields it has now (a change merges onto them). → { ok, fields } | { ok: false, error } */
|
|
690
|
+
function checkFields(kind, input, base = {}) {
|
|
691
|
+
const x = { ...base, ...(isObj(input) ? input : {}) };
|
|
692
|
+
if (kind === 'create') {
|
|
693
|
+
if (!validBoard(x.board)) return { ok: false, error: 'board is the board id the initiative goes on' };
|
|
694
|
+
if (typeof x.lane !== 'string' || !LANE_RE.test(x.lane)) return { ok: false, error: 'lane is a lane name such as feature or chore' };
|
|
695
|
+
if (!oneLine(x.anchor, 120)) return { ok: false, error: 'anchor is a KR, or health:<id>' };
|
|
696
|
+
if (!oneLine(x.intent, 400)) return { ok: false, error: 'intent is one paragraph of at most 400 characters' };
|
|
697
|
+
return { ok: true, fields: { board: x.board, lane: x.lane, anchor: x.anchor.trim(), intent: x.intent.trim() } };
|
|
698
|
+
}
|
|
699
|
+
if (kind === 'research') {
|
|
700
|
+
if (!oneLine(x.question, 300)) return { ok: false, error: 'question is one line of at most 300 characters' };
|
|
701
|
+
const depth = x.depth === undefined || x.depth === '' ? 'standard' : x.depth;
|
|
702
|
+
if (!DEPTHS.includes(depth)) return { ok: false, error: `depth is one of ${DEPTHS.join(', ')}` };
|
|
703
|
+
return { ok: true, fields: { question: x.question.trim(), depth } };
|
|
704
|
+
}
|
|
705
|
+
if (kind === 'attach') {
|
|
706
|
+
if (typeof x.initiative !== 'string' || !ID_RE.test(x.initiative)) return { ok: false, error: 'initiative is the id of the initiative the finding is attached to' };
|
|
707
|
+
return { ok: true, fields: { initiative: x.initiative } };
|
|
708
|
+
}
|
|
709
|
+
return { ok: false, error: `kind is one of ${PROPOSAL_KINDS.join(', ')}` };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** One proposal as the repository run files it. → { ok, proposal } | { ok: false, error } */
|
|
713
|
+
function checkProposal(input) {
|
|
714
|
+
const x = isObj(input) ? input : {};
|
|
715
|
+
if (!PROPOSAL_KINDS.includes(x.kind)) return { ok: false, error: `kind is one of ${PROPOSAL_KINDS.join(', ')}` };
|
|
716
|
+
if (!oneLine(x.title, x.kind === 'create' ? 120 : 160)) return { ok: false, error: `title is one line of 1 to ${x.kind === 'create' ? 120 : 160} characters` };
|
|
717
|
+
if (!oneLine(x.why, 300)) return { ok: false, error: 'why is one line of 1 to 300 characters' };
|
|
718
|
+
if (!oneLine(x.finding, 160)) return { ok: false, error: 'finding names the finding or section it came from, in one line' };
|
|
719
|
+
const src = sourcesIn(x.evidence);
|
|
720
|
+
if (src.error) return { ok: false, error: `a proposal needs evidence: ${src.error} (--source "label=ref")` };
|
|
721
|
+
const f = checkFields(x.kind, x.fields);
|
|
722
|
+
if (!f.ok) return f;
|
|
723
|
+
return { ok: true, proposal: { kind: x.kind, title: x.title.trim(), why: x.why.trim(), finding: x.finding.trim(), evidence: src.sources, fields: f.fields } };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The earlier proposal that this title repeats: one the PM dismissed, or one still waiting. A dismissed title is not raised again. */
|
|
727
|
+
function duplicateProposal(state, title) {
|
|
728
|
+
for (const id of state.proposalOrder) {
|
|
729
|
+
const p = state.proposals.get(id);
|
|
730
|
+
if ((p.state === 'dismissed' || p.state === 'open') && sameWords(p.title, title)) return p;
|
|
731
|
+
}
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const proposalsOf = (state, reportId) => state.proposalOrder.map((id) => state.proposals.get(id)).filter((p) => !reportId || p.report === reportId);
|
|
735
|
+
const waitingProposals = (state) => state.proposalOrder.map((id) => state.proposals.get(id)).filter((p) => p.state === 'open');
|
|
736
|
+
|
|
737
|
+
/** The PM edits an open proposal's fields. → { ok, proposal } | { ok: false, status, error } */
|
|
738
|
+
function proposalChange(file, id, fields, actor, { now } = {}) {
|
|
739
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can change a proposal');
|
|
740
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
741
|
+
const state = fold(readLines(file));
|
|
742
|
+
const p = state.proposals.get(id);
|
|
743
|
+
if (!p) return refuse(404, `no proposal ${String(id).slice(0, 20)}`);
|
|
744
|
+
if (p.state !== 'open') return refuse(409, `the proposal is ${p.state} already`);
|
|
745
|
+
if (!isObj(fields)) return refuse(400, 'fields is an object');
|
|
746
|
+
const { title, ...rest } = fields;
|
|
747
|
+
const keys = p.kind === 'create' ? ['board', 'lane', 'anchor', 'intent'] : p.kind === 'research' ? ['question', 'depth'] : ['initiative'];
|
|
748
|
+
const extra = Object.keys(rest).find((k) => !keys.includes(k));
|
|
749
|
+
if (extra) return refuse(400, `a ${p.kind} proposal has no field ${extra.slice(0, 30)}`);
|
|
750
|
+
if (title !== undefined && !oneLine(title, p.kind === 'create' ? 120 : 160)) return refuse(400, 'title is one line');
|
|
751
|
+
const c = checkFields(p.kind, rest, p.fields);
|
|
752
|
+
if (!c.ok) return refuse(400, c.error);
|
|
753
|
+
append(file, { ev: 'proposal-change', id, fields: { ...c.fields, ...(title !== undefined ? { title: title.trim() } : {}) }, by: actor.by }, { now });
|
|
754
|
+
return { ok: true, proposal: fold(readLines(file)).proposals.get(id) };
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** The PM dismisses an open proposal, with a one-line why. The caller writes the correction line. → { ok, proposal } | { ok: false, status, error } */
|
|
758
|
+
function proposalDismiss(file, id, why, actor, { now } = {}) {
|
|
759
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can dismiss a proposal');
|
|
760
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
761
|
+
const p = fold(readLines(file)).proposals.get(id);
|
|
762
|
+
if (!p) return refuse(404, `no proposal ${String(id).slice(0, 20)}`);
|
|
763
|
+
if (p.state !== 'open') return refuse(409, `the proposal is ${p.state} already`);
|
|
764
|
+
if (!oneLine(why, 500)) return refuse(400, 'a dismissal needs a reason (why) of 1 to 500 characters');
|
|
765
|
+
append(file, { ev: 'proposal-dismiss', id, why: why.trim(), by: actor.by }, { now });
|
|
766
|
+
return { ok: true, proposal: fold(readLines(file)).proposals.get(id) };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** The PM takes back a dismissal (the proposal is open again) or a change (the fields go back to what the agent filed). An approved proposal cannot be
|
|
770
|
+
* undone. The line is appended; no earlier line changes. → { ok, proposal } | { ok: false, status, error } */
|
|
771
|
+
function proposalUndo(file, id, actor, { now } = {}) {
|
|
772
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can undo a proposal press');
|
|
773
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
774
|
+
const p = fold(readLines(file)).proposals.get(id);
|
|
775
|
+
if (!p) return refuse(404, `no proposal ${String(id).slice(0, 20)}`);
|
|
776
|
+
if (p.state === 'approved') return refuse(409, 'an approved proposal cannot be undone: it made an initiative or started a run');
|
|
777
|
+
if (p.state === 'open' && !p.changed) return refuse(409, 'the proposal has nothing to undo');
|
|
778
|
+
append(file, { ev: 'proposal-undo', id, by: actor.by }, { now });
|
|
779
|
+
return { ok: true, proposal: fold(readLines(file)).proposals.get(id) };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** The PM approves an open proposal; `result` says what it started or made. → { ok } | { ok: false, status, error } */
|
|
783
|
+
function proposalApprove(file, id, result, actor, { now } = {}) {
|
|
784
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can approve a proposal');
|
|
785
|
+
if (!file) return refuse(409, 'this repository has no state folder for the scan');
|
|
786
|
+
const p = fold(readLines(file)).proposals.get(id);
|
|
787
|
+
if (!p) return refuse(404, `no proposal ${String(id).slice(0, 20)}`);
|
|
788
|
+
if (p.state !== 'open') return refuse(409, `the proposal is ${p.state} already`);
|
|
789
|
+
append(file, { ev: 'proposal-approve', id, result: isObj(result) ? result : {}, by: actor.by }, { now });
|
|
790
|
+
return { ok: true };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** The PM's approve press links the report to an initiative as evidence. */
|
|
794
|
+
function addEvidence(file, { initiative, report, proposal, note }, actor, { now } = {}) {
|
|
795
|
+
if (!isPm(actor)) return refuse(403, 'only the PM can attach evidence');
|
|
796
|
+
append(file, { ev: 'evidence', initiative, report, proposal, ...(note ? { note: String(note).slice(0, 300) } : {}), by: actor.by }, { now });
|
|
797
|
+
return { ok: true };
|
|
798
|
+
}
|
|
799
|
+
const evidenceOf = (state, initiative) => state.evidence.filter((e) => e.initiative === initiative);
|
|
800
|
+
|
|
801
|
+
// ── starting a scan, and its report documents ────────────────────────────────────────────────────────
|
|
802
|
+
/** The file a report will have: <reports folder>/scan-<type>/<day>.md, and -2, -3 … for a later scan on the same day. `rel` is the folder, relative to the repository.
|
|
803
|
+
* The reports folder is the report-workspace store (lib/report-needs.js reportsDir): the Documents list reads it with no change to its code. */
|
|
804
|
+
function planPath(state, reports, type, day) {
|
|
805
|
+
const taken = new Set([...state.reports.values()].map((r) => r.path));
|
|
806
|
+
for (let n = 1; n < 1000; n++) {
|
|
807
|
+
const name = n === 1 ? `${day}.md` : `${day}-${n}.md`;
|
|
808
|
+
const rel = `${reports.rel}/scan-${type}/${name}`;
|
|
809
|
+
if (taken.has(rel)) continue;
|
|
810
|
+
if (fs.existsSync(path.join(reports.abs, `scan-${type}`, name))) continue;
|
|
811
|
+
return rel;
|
|
812
|
+
}
|
|
813
|
+
return `${reports.rel}/scan-${type}/${day}-${crypto.randomBytes(3).toString('hex')}.md`;
|
|
814
|
+
}
|
|
815
|
+
const longDay = (ms) => D.localDay(ms);
|
|
816
|
+
function researchTitle(question) { const q = String(question || '').replace(/\s+/g, ' ').trim(); return `Research: ${q.length > 90 ? q.slice(0, 89).trimEnd() + '…' : q}`; }
|
|
817
|
+
|
|
818
|
+
/** Write the scan-start line and one `report` line for each document the scan will write. `spec`:
|
|
819
|
+
* { board, mode, manual?, by?, select?, watch?, repo, types, plan: { web, repo }, research?: { question, depth, sources, parent, proposal } }
|
|
820
|
+
* `reports` is { abs, rel }: the report-workspace folder. A research scan writes ONE document, an insight report linked to its parent.
|
|
821
|
+
* → { scan, reports: [{ id, type, title, path, origin }] } */
|
|
822
|
+
function beginScan(file, spec, { now, reports } = {}) {
|
|
823
|
+
const state = fold(readLines(file));
|
|
824
|
+
const scan = newId('scan', 5);
|
|
825
|
+
const day = longDay(now == null ? Date.now() : now);
|
|
826
|
+
const planned = [];
|
|
827
|
+
if (spec.mode === 'scan' && reports) {
|
|
828
|
+
for (const type of spec.types || []) {
|
|
829
|
+
const origin = WEB_TYPES.includes(type) ? (spec.plan.web && spec.plan.repo ? 'both' : spec.plan.web ? 'web' : 'repo') : 'repo';
|
|
830
|
+
planned.push({ id: newId('r', 5), type, title: `${REPORT_LABEL[type]} · ${day}`, origin });
|
|
831
|
+
}
|
|
832
|
+
} else if (spec.mode === 'research' && reports) {
|
|
833
|
+
planned.push({ id: newId('r', 5), type: 'insight', title: researchTitle(spec.research && spec.research.question), origin: spec.plan.web && spec.plan.repo ? 'both' : spec.plan.web ? 'web' : 'repo',
|
|
834
|
+
parent: spec.research && spec.research.parent });
|
|
835
|
+
}
|
|
836
|
+
const line = { ev: 'scan-start', board: spec.board, scan, mode: spec.mode, watch: (spec.watch || []).map((i) => ({ id: i.id, kind: i.kind, name: i.name, why: i.why })),
|
|
837
|
+
manual: !!spec.manual, repo: !!spec.repo, types: spec.types || [], plan: spec.plan || { web: false, repo: true } };
|
|
838
|
+
if (spec.by) line.by = spec.by;
|
|
839
|
+
if (spec.select) line.select = spec.select;
|
|
840
|
+
if (spec.research) line.research = spec.research;
|
|
841
|
+
append(file, line, { now });
|
|
842
|
+
const taken = [];
|
|
843
|
+
for (const r of planned) {
|
|
844
|
+
const rel = planPath({ reports: new Map([...state.reports, ...taken.map((t) => [t.id, t])]) }, reports, r.type, day);
|
|
845
|
+
const out = { ...r, path: rel };
|
|
846
|
+
taken.push(out);
|
|
847
|
+
append(file, { ev: 'report', board: spec.board, scan, id: r.id, type: r.type, title: r.title, path: rel, origin: r.origin, ...(r.parent ? { parent: r.parent } : {}) }, { now });
|
|
848
|
+
}
|
|
849
|
+
return { scan, reports: taken.map(({ id, type, title, path: p, origin }) => ({ id, type, title, path: p, origin })) };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** The report-workspace folder of a repository: { abs, rel }, or null when this checkout has no lib/report-needs.js. It is lib/report-needs.js reportsDir(root) — `pmos/state/reports` in
|
|
853
|
+
* a kit repository (the state folder's own `reports`), `planning/reports` in PMOS-self — the folder the Documents list reads. A caller without it falls back to
|
|
854
|
+
* <state folder>/reports, which is the same folder in a kit repository. */
|
|
855
|
+
function reportsFolder(root) {
|
|
856
|
+
try {
|
|
857
|
+
const rel = require('./report-needs.js').reportsDir(root);
|
|
858
|
+
return rel ? { abs: path.join(path.resolve(String(root)), rel), rel } : null;
|
|
859
|
+
} catch { return null; }
|
|
860
|
+
}
|
|
861
|
+
/** Is `rel` a report file this scan wrote: <folder>/scan-<type>/<day>[-n].md, with no way out of the folder. */
|
|
862
|
+
const isReportPath = (rel) => typeof rel === 'string' && !rel.split('/').some((p) => p === '..' || p === '.' || p === '')
|
|
863
|
+
&& /^(?:[A-Za-z0-9._-]+\/)*scan-(?:benchmark|competitive|insight|productivity)\/\d{4}-\d{2}-\d{2}(?:-\d{1,3})?\.md$/.test(rel);
|
|
864
|
+
|
|
865
|
+
const yamlQ = (s) => '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ') + '"';
|
|
866
|
+
/** Keep an agent's text from making headings of its own at the levels the document uses (# and ##). */
|
|
867
|
+
const calm = (body) => String(body).split('\n').map((l) => (/^#{1,2}\s/.test(l) ? '###' + l.replace(/^#+/, '') : l)).join('\n');
|
|
868
|
+
const sourceLine = (x) => (isUrl(x.ref) ? `- [${x.label}](${x.ref})` : `- ${x.label}: ${x.ref}`);
|
|
869
|
+
const PROPOSAL_WORD = Object.freeze({ create: 'Create an initiative', research: 'Do more research', attach: 'Add as evidence' });
|
|
870
|
+
|
|
871
|
+
/** The markdown of one report: frontmatter (one key per line), the title, its sections with their sources, and a final "What next" section that lists the
|
|
872
|
+
* report's proposals. → { text, origin } */
|
|
873
|
+
function renderReport(state, rep, { at, stopped = false } = {}) {
|
|
874
|
+
const origins = new Set(rep.sections.map((x) => x.origin));
|
|
875
|
+
const origin = origins.size === 2 ? 'both' : origins.has('web') ? 'web' : origins.has('repo') ? 'repo' : rep.origin;
|
|
876
|
+
const stamp = new Date(at == null ? Date.now() : at).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
877
|
+
const fm = ['---', 'type: Reference', `title: ${yamlQ(rep.title)}`, `timestamp: ${stamp}`, 'source: scan:portfolio', 'made_by: agent', 'output: google-doc', `scan: ${rep.scan}`, `report: ${rep.id}`, `origin: ${origin}`];
|
|
878
|
+
if (rep.parent) fm.push(`parent: ${rep.parent}`);
|
|
879
|
+
fm.push('---', '');
|
|
880
|
+
const body = [`# ${rep.title}`, '', `_Made by the agent from the Horizon scan ${rep.scan}. Every claim names its source. The PM has not reviewed it._`, ''];
|
|
881
|
+
if (!rep.sections.length) body.push('## Findings', '', stopped ? 'The scan was stopped before it filed anything for this report.' : 'The scan filed nothing for this report.', '');
|
|
882
|
+
for (const sec of rep.sections) {
|
|
883
|
+
body.push(`## ${sec.heading}`, '', calm(sec.body), '');
|
|
884
|
+
if (sec.sources.length) body.push('Sources:', ...sec.sources.map(sourceLine), '');
|
|
885
|
+
}
|
|
886
|
+
const props = proposalsOf(state, rep.id);
|
|
887
|
+
body.push('## What next', '');
|
|
888
|
+
if (!props.length) body.push('The scan raised no proposals for this report.', '');
|
|
889
|
+
else {
|
|
890
|
+
for (const p of props) {
|
|
891
|
+
const f = p.fields || {};
|
|
892
|
+
const extra = p.kind === 'create' ? ` (board ${f.board}, lane ${f.lane})` : p.kind === 'attach' ? ` (${f.initiative})` : p.kind === 'research' ? ` (${f.depth})` : '';
|
|
893
|
+
body.push(`- **${PROPOSAL_WORD[p.kind]}:** ${p.title}${extra}. ${p.why} _From: ${p.finding}._`);
|
|
894
|
+
}
|
|
895
|
+
body.push('', 'The PM approves, changes or dismisses each proposal in the document sheet. Nothing happens until then.', '');
|
|
896
|
+
}
|
|
897
|
+
return { text: fm.join('\n') + body.join('\n'), origin };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Write the report documents of a scan that has not written them yet, and append a `report` line (written: true) for each. Safe to call twice.
|
|
901
|
+
* `reports` is { abs, rel }. → [{ id, path }] */
|
|
902
|
+
function writeReports(file, scanId, { now, reports } = {}) {
|
|
903
|
+
const out = [];
|
|
904
|
+
if (!reports) return out;
|
|
905
|
+
const state = fold(readLines(file));
|
|
906
|
+
const sc = state.scans.get(scanId);
|
|
907
|
+
if (!sc) return out;
|
|
908
|
+
const fileOf = (rel) => path.join(reports.abs, ...rel.split('/').slice(-2)); // scan-<type>/<name>.md inside the reports folder
|
|
909
|
+
for (const rid of sc.reports) {
|
|
910
|
+
const rep = state.reports.get(rid);
|
|
911
|
+
if (!rep || rep.written) continue;
|
|
912
|
+
// The path was planned when the scan began. A file that appeared since (a second server, the PM) is never overwritten: take the next free name.
|
|
913
|
+
let rel = rep.path;
|
|
914
|
+
let abs = fileOf(rel);
|
|
915
|
+
for (let n = 2; fs.existsSync(abs) && n < 1000; n++) {
|
|
916
|
+
const parts = rep.path.split('/');
|
|
917
|
+
parts[parts.length - 1] = parts[parts.length - 1].replace(/\.md$/, '') + `-${n}.md`;
|
|
918
|
+
rel = parts.join('/');
|
|
919
|
+
abs = fileOf(rel);
|
|
920
|
+
}
|
|
921
|
+
const r = renderReport(state, rep, { at: now, stopped: sc.stopped });
|
|
922
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
923
|
+
const tmp = abs + '.tmp-' + process.pid;
|
|
924
|
+
fs.writeFileSync(tmp, r.text);
|
|
925
|
+
fs.renameSync(tmp, abs);
|
|
926
|
+
append(file, { ev: 'report', board: sc.board, scan: sc.scan, id: rep.id, written: true, origin: r.origin, path: rel, sections: rep.sections.length }, { now });
|
|
927
|
+
out.push({ id: rep.id, path: rel });
|
|
928
|
+
}
|
|
929
|
+
return out;
|
|
930
|
+
}
|
|
931
|
+
|
|
391
932
|
module.exports = {
|
|
392
|
-
LEDGER, KINDS, CADENCES, DEFAULT_CADENCE, EVENTS, LIMITS, SLOT, ORIGINS, MODES, ID_RE, WATCH_RE, SCAN_RE, FINDING_RE, RUN_RE,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
933
|
+
LEDGER, KINDS, CADENCES, DEFAULT_CADENCE, EVENTS, LIMITS, SLOT, ORIGINS, MODES, ID_RE, WATCH_RE, SCAN_RE, FINDING_RE, RUN_RE, REPORT_RE, PROPOSAL_RE,
|
|
934
|
+
REPORT_TYPES, WEB_TYPES, REPORT_LABEL, PROPOSAL_KINDS, DEPTHS, REPORT_ORIGINS, PORTFOLIO,
|
|
935
|
+
ledgerFile, append, readLines, newId, fold, cadenceOf, settingOf, watchOf, itemsOf, portfolioView, keptItems, hasWatchList, scansOf, openScans, scanDay, lastScan, manualToday,
|
|
936
|
+
checkWatchItem, checkFinding, checkSection, checkProposal, checkFields, sourceOf, isUrl, urlsIn, words, normUrl, sameWords, duplicateOf, duplicateProposal, readUrls,
|
|
937
|
+
proposalsOf, waitingProposals, evidenceOf,
|
|
938
|
+
isWorkingDay, effectiveTypes, dueNow, suggestDue, nextScan, scheduleText, manualCheck, selectScan, stepsOf, setCadence, setSetting, watchOp, hhmm,
|
|
939
|
+
proposalChange, proposalDismiss, proposalUndo, proposalApprove, addEvidence, beginScan, planPath, renderReport, writeReports, reportsFolder, isReportPath,
|
|
396
940
|
};
|