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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-setup-test.js +1 -1
  7. package/workspace/c3-scan-fixture.js +247 -5
  8. package/workspace/c3-scan-test.js +453 -106
  9. package/workspace/guide/horizon-scan.md +45 -36
  10. package/workspace/lib/headless.js +19 -5
  11. package/workspace/lib/pmos-verbs.js +2 -2
  12. package/workspace/lib/route-modules.js +1 -1
  13. package/workspace/lib/routes-boards.js +29 -12
  14. package/workspace/lib/routes-scan.js +224 -44
  15. package/workspace/lib/scan-cli.js +143 -45
  16. package/workspace/lib/scan-run.js +242 -87
  17. package/workspace/lib/scan.js +623 -79
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/c3-doc-sheet.js +7 -1
  21. package/workspace/public/pm/c3-initiatives.js +5 -4
  22. package/workspace/public/pm/c3-scan-api.js +167 -0
  23. package/workspace/public/pm/c3-scan-next.js +248 -0
  24. package/workspace/public/pm/c3-scan-setup.js +317 -0
  25. package/workspace/public/pm/c3-scan-words.js +34 -0
  26. package/workspace/public/pm/c3-scan.css +58 -9
  27. package/workspace/public/pm/c3-scan.js +36 -208
  28. package/workspace/public/pm/c3-toast.js +1 -0
  29. package/workspace/server.js +7 -4
  30. package/workspace/skills/horizon-scan-repo.skill +26 -10
  31. package/workspace/skills/horizon-scan-research.skill +44 -0
  32. package/workspace/skills/horizon-scan-web.skill +12 -5
@@ -1,10 +1,30 @@
1
1
  'use strict';
2
- /* scan.js — the Horizon scan's ledger and rules (initiative horizon-scan, PRD sections 1 to 5).
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 and files up to 3 findings of its own. Each finding becomes a Triage ask that the PM
6
- * accepts or declines. This file is the pure core: the shape of every line in <state folder>/scan.jsonl, the fold, the schedule and
7
- * the duplicate test. It reads the clock only through the `now` its callers pass. The only I/O is the read/append pair at the bottom.
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
- const LIMITS = Object.freeze({ suggestions: 8, web: 5, repo: 3, sources: 6, items: 40 });
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, cadence: DEFAULT_CADENCE, cadenceAt: null, items: new Map(), order: [] });
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) || typeof l.board !== 'string' || !ID_RE.test(l.board)) continue;
179
- const b = boardState(s, l.board);
228
+ if (!isObj(l)) continue;
180
229
  switch (l.ev) {
181
- case 'watch-suggest': {
182
- if (!WATCH_RE.test(String(l.id)) || b.items.has(l.id) || !KINDS.includes(l.kind) || !oneLine(l.name, 80)) break;
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 = b.items.get(l.id);
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 = b.items.get(l.id);
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 = b.items.get(l.id);
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)) { b.cadence = l.cadence; b.cadenceAt = l.at || null; } break;
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
- const cadenceOf = (state, board) => (state.boards.get(board) ? state.boards.get(board).cadence : DEFAULT_CADENCE);
258
- /** The items of a board's watch list that are not removed: { suggested: [], kept: [] }. */
259
- function watchOf(state, board) {
260
- const b = state.boards.get(board);
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
- if (!b) return out;
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
- function itemsOf(state, board) {
268
- const b = state.boards.get(board);
269
- return b ? b.order.map((id) => b.items.get(id)).filter((it) => it.state !== 'removed') : [];
270
- }
271
- const keptItems = (state, board) => watchOf(state, board).kept;
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 of a board that started on a local day (YYYY-MM-DD). A scan that failed still counts: one scan per board per day. */
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 on this board');
432
+ const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, 09:00' : cadence === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs');
290
433
 
291
- /** May a scan of this board start now? `open` is the day-check window and quiet hours as one boolean (lib/driver-run.js dayCheckOpen).
292
- * → { 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. */
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, board);
445
+ const cadence = cadenceOf(state);
295
446
  if (cadence === 'off') return { ok: false, why: 'off' };
296
- if (!keptItems(state, board).length) return { ok: false, why: 'no-watch-list' };
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 of this board start now? A board with no watch list at all, whose cadence is not off, with no successful 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, board) === 'off') return { ok: false, why: 'off' };
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 of a board starts, in ms, or null (off, or nothing kept). `dayEnd` is the day-check end ('18:00'). A scan that is due
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, board);
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 a board's watch list. `input`: { id } for keep, edit and remove; { id, name?, kind?, why? } for edit; { kind, name, why? } for add. */
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 (b && [...b.items.values()].filter((i) => i.state !== 'removed').length >= LIMITS.items) return refuse(409, `a watch list holds at most ${LIMITS.items} items`);
364
- if (b && [...b.items.values()].some((i) => i.state !== 'removed' && i.name.toLowerCase() === c.item.name.toLowerCase())) return refuse(409, 'the watch list has that item already');
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
- append(file, { ev: 'watch-add', board, id, ...c.item, by: actor.by }, { now });
367
- return { ok: true, id, item: { id, ...c.item, state: 'kept', added: true } };
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 it = b && b.items.get(x.id);
371
- if (!it) return refuse(404, `no watch list item ${x.id.slice(0, 20)} on this board`);
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, id: it.id, ...(x.restore === true ? { restore: true } : {}), by: actor.by }, { now });
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, id: it.id, by: actor.by }, { now }); return { ok: true, id: it.id }; }
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, id: it.id, by: actor.by };
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 (line.name === undefined && line.kind === undefined && line.why === undefined) return refuse(400, 'say what changes: name, kind or why');
386
- if (line.name && b && [...b.items.values()].some((i) => i.id !== it.id && i.state !== 'removed' && i.name.toLowerCase() === line.name.toLowerCase())) return refuse(409, 'the watch list has that item already');
387
- append(file, line, { now });
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
- ledgerFile, append, readLines, newId, fold, cadenceOf, watchOf, itemsOf, keptItems, hasWatchList, scansOf, openScans, scanDay, lastScan,
394
- checkWatchItem, checkFinding, sourceOf, isUrl, words, normUrl, sameWords, duplicateOf,
395
- isWorkingDay, dueNow, suggestDue, nextScan, scheduleText, setCadence, watchOp, hhmm,
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
  };