create-pmos 0.169.0 → 0.170.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ 'use strict';
2
+ /* scan.js — the Horizon scan's ledger and rules (initiative horizon-scan, PRD sections 1 to 5).
3
+ *
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.
8
+ *
9
+ * LEDGER · KINDS · CADENCES · DEFAULT_CADENCE · EVENTS · LIMITS · SLOT
10
+ * ledgerFile(stateDir) → <state folder>/scan.jsonl
11
+ * append(file, line, { now }) → the line written. THE SINGLE WRITER of scan.jsonl.
12
+ * readLines(file) → every line that parses, in file order
13
+ * fold(lines) → the current state (boards with cadence and watch list, scans, findings)
14
+ * watchOf(state, board) · keptItems(state, board) · cadenceOf(state, board)
15
+ * checkWatchItem(input) · checkFinding(input) · sourceOf(ref)
16
+ * words(title) · normUrl(url) · duplicateOf(state, board, { title, sources }) the duplicate test (PRD section 3)
17
+ * isWorkingDay(ms, cadence) · scanDay(state, board, day) · dueNow(...) · suggestDue(...) · nextScan(...)
18
+ * setCadence(file, board, cadence, actor) · watchOp(file, op, board, input, actor) the PM's presses (routes and CLI)
19
+ * scheduleText(cadence) → the plain sentence the settings block shows
20
+ *
21
+ * THE LINES. Every line is { v: 1, ev, …, at }. Who wrote a decision is the call path (lib/actor.js), never a field:
22
+ * watch-suggest { board, id, kind, name, why, ini } the suggest run files up to 8; `ini` names the initiative it came from
23
+ * watch-keep { board, id, restore? } PM only. `restore` brings a removed item back as it was (the Undo)
24
+ * watch-edit { board, id, name?, kind?, why? } PM only; an edited item is kept
25
+ * watch-remove { board, id } PM only
26
+ * watch-add { board, id, kind, name, why? } PM only; an added item is kept
27
+ * cadence { board, cadence } PM only: daily | weekly | off
28
+ * scan-start { board, scan, mode: 'scan'|'suggest', watch: [{ id, kind, name, why }] }
29
+ * scan-end { board, scan, ok, reason?, pages, found, skipped, runs }
30
+ * finding { board, scan, id, origin: 'web'|'repo', title, why, sources: [{ label, ref }], match, by, run? } `run` is the scan run that filed it
31
+ * A later line with the same `id` and a `match` sets the match (the repository run marks the web findings).
32
+ * Three lines the PRD does not list, each needed by one rule it does:
33
+ * skip { board, scan, origin, of } a finding the duplicate test refused: "the skip count is recorded on the scan"
34
+ * pages { board, scan, pages } the number of public pages the web run says it read
35
+ * run-end { board, scan, run, origin: 'web'|'repo'|'suggest', ok, reason?, agent_run } "each scan run writes an agent-runs line"
36
+ *
37
+ * THE FACTORY NEVER WRITES HERE (D65): this ledger is not an initiative, an acceptance or an anchor. A finding is a proposal: the
38
+ * Triage ask it becomes (lib/routes-boards.js appendScanAsk) turns into an initiative only when the PM accepts it.
39
+ */
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const crypto = require('crypto');
43
+ const D = require('./driver.js');
44
+
45
+ const LEDGER = 'scan.jsonl';
46
+ const KINDS = Object.freeze(['product', 'domain', 'approach']);
47
+ const CADENCES = Object.freeze(['daily', 'weekly', 'off']);
48
+ 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 });
51
+ const SLOT = '09:00';
52
+ const ORIGINS = Object.freeze(['web', 'repo']);
53
+ const MODES = Object.freeze(['scan', 'suggest']);
54
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
55
+ const WATCH_RE = /^w-[0-9a-f]{8}$/;
56
+ const SCAN_RE = /^scan-[0-9a-f]{10}$/;
57
+ const FINDING_RE = /^f-[0-9a-f]{10}$/;
58
+ const RUN_RE = /^srun-[0-9a-f]{10}$/;
59
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
60
+ const oneLine = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
61
+ const pm = (by) => typeof by === 'string' && by.startsWith('pm:');
62
+ const newId = (prefix, bytes = 4) => `${prefix}-${crypto.randomBytes(bytes).toString('hex')}`;
63
+ const refuse = (status, error) => ({ ok: false, status, error });
64
+
65
+ // ── the one writer, and the read ─────────────────────────────────────────────────────────────────────
66
+ const ledgerFile = (stateDir) => (stateDir ? path.join(stateDir, LEDGER) : null);
67
+ /** THE SINGLE WRITER of scan.jsonl. `v` and `at` are set here; a caller never chooses them. */
68
+ function append(file, line, { now } = {}) {
69
+ if (!file) throw new Error('scan: no ledger file');
70
+ if (!isObj(line) || !EVENTS.includes(line.ev)) throw new Error(`scan: unknown event ${JSON.stringify(line && line.ev)}`);
71
+ const out = { ...line, v: 1, at: new Date(now == null ? Date.now() : now).toISOString() };
72
+ fs.mkdirSync(path.dirname(file), { recursive: true });
73
+ fs.appendFileSync(file, JSON.stringify(out) + '\n');
74
+ return out;
75
+ }
76
+ function readLines(file) {
77
+ let text = '';
78
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
79
+ const out = [];
80
+ for (const raw of text.split('\n')) {
81
+ if (!raw.trim()) continue;
82
+ try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev)) out.push(l); } catch { /* a torn line is skipped */ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // ── shapes ───────────────────────────────────────────────────────────────────────────────────────────
88
+ const isUrl = (s) => /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(String(s || ''));
89
+ const JIRA_RE = /^[A-Z][A-Z0-9_]+-\d+$/;
90
+ /** The kind of a source pointer: a public page, a Jira key, or a path or note. */
91
+ const sourceOf = (ref) => (isUrl(ref) ? 'web' : JIRA_RE.test(String(ref || '').trim()) ? 'jira' : 'note');
92
+
93
+ function checkWatchItem(i) {
94
+ const x = isObj(i) ? i : {};
95
+ if (!KINDS.includes(x.kind)) return { ok: false, error: `kind must be one of ${KINDS.join(', ')}` };
96
+ if (!oneLine(x.name, 80)) return { ok: false, error: 'name is one line of 1 to 80 characters' };
97
+ if (x.why != null && x.why !== '' && !oneLine(x.why, 200)) return { ok: false, error: 'why is one line of at most 200 characters' };
98
+ return { ok: true, item: { kind: x.kind, name: x.name.trim(), why: typeof x.why === 'string' ? x.why.trim() : '' } };
99
+ }
100
+
101
+ /** The shape of one finding as an agent files it. A web finding needs a public page it read; a repository finding needs a pointer. */
102
+ function checkFinding(i, origin) {
103
+ const x = isObj(i) ? i : {};
104
+ if (!ORIGINS.includes(origin)) return { ok: false, error: 'origin is web or repo' };
105
+ if (!oneLine(x.title, 160)) return { ok: false, error: 'title is one line of 1 to 160 characters' };
106
+ if (!oneLine(x.why, 300)) return { ok: false, error: 'why is one line of 1 to 300 characters' };
107
+ const list = Array.isArray(x.sources) ? x.sources : [];
108
+ if (!list.length) return { ok: false, error: origin === 'web' ? 'a web finding needs at least one source: the public page you read' : 'a repository finding needs at least one pointer (a path, a Jira key or a note)' };
109
+ if (list.length > LIMITS.sources) return { ok: false, error: `at most ${LIMITS.sources} sources` };
110
+ const sources = [];
111
+ for (const s of list) {
112
+ if (!isObj(s) || !oneLine(s.label, 80) || !oneLine(s.ref, 500)) return { ok: false, error: 'each source needs a label (up to 80 characters) and a ref (up to 500)' };
113
+ sources.push({ label: s.label.trim(), ref: s.ref.trim() });
114
+ }
115
+ if (origin === 'web' && !sources.some((s) => isUrl(s.ref))) return { ok: false, error: 'a web finding needs at least one source that is a public page address (https://…)' };
116
+ if (x.match != null && x.match !== '' && !(typeof x.match === 'string' && ID_RE.test(x.match))) return { ok: false, error: 'match is an initiative id' };
117
+ return { ok: true, finding: { origin, title: x.title.trim(), why: x.why.trim(), sources, match: x.match ? x.match : null } };
118
+ }
119
+
120
+ // ── the duplicate test ───────────────────────────────────────────────────────────────────────────────
121
+ const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'can', 'do', 'for', 'from', 'get', 'has', 'have', 'how', 'in', 'into', 'is', 'it', 'its', 'of', 'on', 'or', 'our', 'so', 'that', 'the',
122
+ 'their', 'them', 'they', 'this', 'to', 'up', 'us', 'we', 'what', 'when', 'with', 'you', 'your', 'before', 'after', 'than', 'then', 'new', 'make', 'give', 'show', 'let', 'use', 'using']);
123
+ /** The significant words of a title: lower case, no stop words, a light plural stem. */
124
+ function words(title) {
125
+ const out = new Set();
126
+ for (const raw of String(title || '').toLowerCase().split(/[^a-z0-9]+/)) {
127
+ if (raw.length < 3 && !/\d/.test(raw)) continue;
128
+ if (STOP.has(raw)) continue;
129
+ let w = raw;
130
+ if (w.length > 4 && w.endsWith('ies')) w = w.slice(0, -3) + 'y';
131
+ else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss')) w = w.slice(0, -1);
132
+ if (!STOP.has(w)) out.add(w);
133
+ }
134
+ return out;
135
+ }
136
+ /** The same page by another spelling: no scheme, no www, no fragment, no tracking query, no trailing slash. */
137
+ function normUrl(u) {
138
+ try {
139
+ const x = new URL(String(u).trim());
140
+ const q = [...x.searchParams.entries()].filter(([k]) => !/^(utm_|fbclid$|gclid$|ref$)/i.test(k)).sort(([a], [b]) => a.localeCompare(b));
141
+ const query = q.length ? '?' + q.map(([k, v]) => `${k}=${v}`).join('&') : '';
142
+ return (x.hostname.toLowerCase().replace(/^www\./, '') + x.pathname.replace(/\/+$/, '') + query).toLowerCase();
143
+ } catch { return String(u || '').trim().toLowerCase(); }
144
+ }
145
+ /** Two titles with the same significant words: equal sets, or (from three words up) four in five shared. */
146
+ function sameWords(a, b) {
147
+ const wa = words(a); const wb = words(b);
148
+ if (!wa.size || !wb.size) return false;
149
+ let both = 0;
150
+ for (const w of wa) if (wb.has(w)) both++;
151
+ if (both === wa.size && both === wb.size) return true;
152
+ if (Math.min(wa.size, wb.size) < 3) return false;
153
+ return both / (wa.size + wb.size - both) >= 0.75;
154
+ }
155
+ /** The earlier finding on the same board that this one repeats (the same public page, or the same significant title words), or null. */
156
+ function duplicateOf(state, board, { title, sources } = {}) {
157
+ const urls = new Set((sources || []).filter((s) => s && isUrl(s.ref)).map((s) => normUrl(s.ref)));
158
+ for (const id of state.findingOrder) {
159
+ const f = state.findings.get(id);
160
+ if (!f || f.board !== board) continue;
161
+ if (urls.size && (f.sources || []).some((s) => isUrl(s.ref) && urls.has(normUrl(s.ref)))) return f;
162
+ if (sameWords(f.title, title)) return f;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // ── the fold ─────────────────────────────────────────────────────────────────────────────────────────
168
+ function boardState(s, id) {
169
+ if (!s.boards.has(id)) s.boards.set(id, { id, cadence: DEFAULT_CADENCE, cadenceAt: null, items: new Map(), order: [] });
170
+ return s.boards.get(id);
171
+ }
172
+ const emptyState = () => ({ boards: new Map(), scans: new Map(), scanOrder: [], findings: new Map(), findingOrder: [] });
173
+
174
+ /** The current state of one ledger. A decision line from anything but the PM is ignored; the first line for an id wins. */
175
+ function fold(lines) {
176
+ const s = emptyState();
177
+ 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);
180
+ 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
+ }
193
+ case 'watch-keep': {
194
+ const it = b.items.get(l.id);
195
+ if (!it || !pm(l.by)) break;
196
+ if (it.state === 'suggested') it.state = 'kept';
197
+ else if (it.state === 'removed' && l.restore === true) { it.state = it.prev || 'kept'; it.prev = null; }
198
+ break;
199
+ }
200
+ case 'watch-edit': {
201
+ const it = b.items.get(l.id);
202
+ if (!it || !pm(l.by) || it.state === 'removed') break;
203
+ if (oneLine(l.name, 80)) it.name = l.name.trim();
204
+ if (KINDS.includes(l.kind)) it.kind = l.kind;
205
+ if (typeof l.why === 'string') it.why = l.why.trim();
206
+ it.state = 'kept';
207
+ break;
208
+ }
209
+ case 'watch-remove': {
210
+ const it = b.items.get(l.id);
211
+ if (!it || !pm(l.by) || it.state === 'removed') break;
212
+ it.prev = it.state; it.state = 'removed';
213
+ break;
214
+ }
215
+ case 'cadence': if (pm(l.by) && CADENCES.includes(l.cadence)) { b.cadence = l.cadence; b.cadenceAt = l.at || null; } break;
216
+ case 'scan-start': {
217
+ if (!SCAN_RE.test(String(l.scan)) || s.scans.has(l.scan) || !MODES.includes(l.mode)) break;
218
+ 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: [] });
220
+ s.scanOrder.push(l.scan);
221
+ break;
222
+ }
223
+ case 'finding': {
224
+ const sc = s.scans.get(l.scan);
225
+ if (!sc || !FINDING_RE.test(String(l.id))) break;
226
+ const had = s.findings.get(l.id);
227
+ if (had) { if (typeof l.match === 'string' && ID_RE.test(l.match) && !l.title) had.match = l.match; break; }
228
+ 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 : [],
230
+ 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
+ s.findings.set(l.id, f); s.findingOrder.push(l.id); sc.findings.push(l.id);
232
+ break;
233
+ }
234
+ case 'skip': { const sc = s.scans.get(l.scan); if (sc) sc.skips++; break; }
235
+ case 'pages': { const sc = s.scans.get(l.scan); if (sc && Number.isFinite(l.pages) && l.pages >= 0) sc.pagesSaid = Math.max(sc.pagesSaid, Math.floor(l.pages)); break; }
236
+ case 'run-end': { const sc = s.scans.get(l.scan); if (sc && RUN_RE.test(String(l.run))) sc.runLines.push({ run: l.run, origin: l.origin, ok: l.ok === true, reason: typeof l.reason === 'string' ? l.reason : null, agent_run: isObj(l.agent_run) ? l.agent_run : null, at: l.at || null }); break; }
237
+ case 'scan-end': {
238
+ const sc = s.scans.get(l.scan);
239
+ if (!sc || sc.end) break;
240
+ sc.end = l.at || null; sc.ok = l.ok === true; sc.reason = typeof l.reason === 'string' ? l.reason : null;
241
+ sc.pages = Number.isFinite(l.pages) ? l.pages : sc.pagesSaid; sc.found = Number.isFinite(l.found) ? l.found : sc.findings.length;
242
+ sc.skipped = Number.isFinite(l.skipped) ? l.skipped : sc.skips; sc.runs = Number.isFinite(l.runs) ? l.runs : sc.runLines.length;
243
+ break;
244
+ }
245
+ default: break;
246
+ }
247
+ }
248
+ // The page count of a scan still open, and of one whose end line has none: what the agent said, else the public pages its findings cite.
249
+ for (const sc of s.scans.values()) {
250
+ const cited = new Set();
251
+ for (const id of sc.findings) for (const x of s.findings.get(id).sources) if (isUrl(x.ref)) cited.add(normUrl(x.ref));
252
+ if (!sc.end) sc.pages = Math.max(sc.pagesSaid, cited.size);
253
+ }
254
+ return s;
255
+ }
256
+
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);
261
+ 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); }
264
+ return out;
265
+ }
266
+ /** 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));
274
+ 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. */
276
+ const scanDay = (state, board, day) => scansOf(state, board, 'scan').filter((x) => x.start && D.localDay(Date.parse(x.start)) === day);
277
+ const lastScan = (state, board) => scansOf(state, board, 'scan').filter((x) => x.end).pop() || null;
278
+
279
+ // ── the schedule ─────────────────────────────────────────────────────────────────────────────────────
280
+ const hhmm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
281
+ /** Daily: Monday to Friday. Weekly: Monday. Off: no day. */
282
+ function isWorkingDay(ms, cadence) {
283
+ const d = new Date(ms).getDay();
284
+ if (cadence === 'daily') return d >= 1 && d <= 5;
285
+ if (cadence === 'weekly') return d === 1;
286
+ return false;
287
+ }
288
+ 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');
290
+
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. */
293
+ function dueNow(state, board, now, { open = true } = {}) {
294
+ const cadence = cadenceOf(state, board);
295
+ if (cadence === 'off') return { ok: false, why: 'off' };
296
+ if (!keptItems(state, board).length) return { ok: false, why: 'no-watch-list' };
297
+ if (!isWorkingDay(now, cadence)) return { ok: false, why: 'not-a-scan-day' };
298
+ if (now < slotMs(now)) return { ok: false, why: 'before-09' };
299
+ if (!open) return { ok: false, why: 'outside-working-hours' };
300
+ 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' };
302
+ return { ok: true };
303
+ }
304
+
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. */
307
+ function suggestDue(state, board, now, { open = true, initiatives = 0 } = {}) {
308
+ if (cadenceOf(state, board) === 'off') return { ok: false, why: 'off' };
309
+ if (hasWatchList(state, board)) return { ok: false, why: 'has-watch-list' };
310
+ if (!initiatives) return { ok: false, why: 'no-initiatives' };
311
+ if (!open) return { ok: false, why: 'outside-working-hours' };
312
+ const runs = scansOf(state, board, 'suggest');
313
+ if (runs.some((x) => !x.end)) return { ok: false, why: 'running' };
314
+ if (runs.some((x) => x.ok === true)) return { ok: false, why: 'asked-already' };
315
+ if (runs.some((x) => x.start && D.localDay(Date.parse(x.start)) === D.localDay(now))) return { ok: false, why: 'tried-today' };
316
+ return { ok: true };
317
+ }
318
+
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
320
+ * now and has not started starts at the next tick, so the answer is `now`. */
321
+ function nextScan(state, board, now, { dayEnd = '18:00' } = {}) {
322
+ const cadence = cadenceOf(state, board);
323
+ if (cadence === 'off' || !keptItems(state, board).length) return null;
324
+ const doneToday = scanDay(state, board, D.localDay(now)).length > 0;
325
+ for (let k = 0; k < 8; k++) {
326
+ const d = new Date(now); d.setDate(d.getDate() + k);
327
+ const day = d.getTime();
328
+ if (!isWorkingDay(day, cadence)) continue;
329
+ if (k === 0) {
330
+ if (doneToday) continue;
331
+ if (now < slotMs(day)) return slotMs(day);
332
+ if (hhmm(now) < dayEnd) return now;
333
+ continue;
334
+ }
335
+ return slotMs(day);
336
+ }
337
+ return null;
338
+ }
339
+
340
+ // ── the PM's presses ─────────────────────────────────────────────────────────────────────────────────
341
+ const isPm = (actor) => !!actor && pm(actor.by);
342
+ function setCadence(file, board, cadence, actor, { now } = {}) {
343
+ if (!isPm(actor)) return refuse(403, 'only the PM can change the scan cadence');
344
+ if (!file) return refuse(409, 'this repository has no state folder for the scan');
345
+ 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 };
349
+ }
350
+
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. */
352
+ function watchOp(file, op, board, input, actor, { now } = {}) {
353
+ if (!isPm(actor)) return refuse(403, 'only the PM can change the watch list');
354
+ 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');
356
+ if (!['keep', 'edit', 'remove', 'add'].includes(op)) return refuse(400, 'op must be keep, edit, remove or add');
357
+ const state = fold(readLines(file));
358
+ const b = state.boards.get(board);
359
+ const x = isObj(input) ? input : {};
360
+ if (op === 'add') {
361
+ const c = checkWatchItem(x);
362
+ 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');
365
+ 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 } };
368
+ }
369
+ 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`);
372
+ if (op === 'keep') {
373
+ if (it.state === 'kept') return refuse(409, 'the item is kept already');
374
+ 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 });
376
+ return { ok: true, id: it.id };
377
+ }
378
+ 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 }; }
380
+ // edit
381
+ const line = { ev: 'watch-edit', board, id: it.id, by: actor.by };
382
+ 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
+ if (x.kind !== undefined) { if (!KINDS.includes(x.kind)) return refuse(400, `kind must be one of ${KINDS.join(', ')}`); line.kind = x.kind; }
384
+ 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 });
388
+ return { ok: true, id: it.id };
389
+ }
390
+
391
+ 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,
396
+ };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.169.0",
3
+ "version": "0.170.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.169.0",
9
+ "version": "0.170.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.169.0",
3
+ "version": "0.170.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@ import { el } from './dom.js';
15
15
  import { registerBoardTab } from './c3-initiatives.js';
16
16
  import { icon, pbtn, lk, chip, tag, st, note, noteA, lbl, sect, card, list, empty, nrow, cols, chgl, ago, put } from './c3-ini-ui.js';
17
17
  import { TEMPLATES } from './c3-ini-work.js';
18
+ import { isScanAsk, scanAskParts } from './c3-scan.js';
18
19
 
19
20
  const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
20
21
  const ANCHOR_WHY = 'An initiative needs an anchor: a key result or a health budget. PMOS refuses to create one without it.';
@@ -52,16 +53,19 @@ registerBoardTab('triage', async (host, ctx) => {
52
53
  }
53
54
  function row(a, state) {
54
55
  const open = state === 'open';
56
+ // initiative horizon-scan: an ask the scan found shows its reason, "Found by agent", its sources and its match, with the usual three buttons.
57
+ const scan = isScanAsk(a) ? scanAskParts(a, { today: ctx.today, first: first.name, onAccept: open ? openAccept : null, onMore: openMore, onDecline: openDecline }) : null;
55
58
  const ini = a.initiative && (a.initiative.id || a.initiative);
56
59
  const sharp = a.draft && typeof a.draft.confidence === 'number' ? (a.draft.confidence >= 0.7 ? 'sharp' : a.draft.confidence < 0.5 ? 'needs detail' : 'readable') : 'no draft';
57
60
  return nrow({ ic: state === 'declined' ? 'x' : state === 'accepted' ? 'check' : 'bolt', hot: open, title: a.line, key: 'ask:' + a.id,
58
- chips: [a.source ? chip(a.source) : null, a.by ? chip(icon('user', 'sm'), a.by) : null, a.at ? chip(ago(a.at, ctx.today)) : null,
61
+ sub: scan ? scan.sub : null, after: scan ? scan.after : null,
62
+ chips: [...(scan && open ? scan.chips : [a.source ? chip(a.source === 'scan' ? 'Horizon scan' : a.source) : null, a.by ? chip(icon('user', 'sm'), a.by) : null, a.at ? chip(ago(a.at, ctx.today)) : null]),
59
63
  open && a.needMore ? st('warn', 'alert', 'asked for more', { 'data-ibt': 'need-more', title: a.moreNote || '' }) : null,
60
64
  state === 'accepted' ? st('ok', 'check', 'Accepted') : null, state === 'accepted' && ini ? lk({ 'data-ibt-open': ini, onclick: () => ctx.openInitiative(ini) }, ini) : null,
61
65
  state === 'declined' ? tag('declined') : null],
62
66
  right: open ? [tag(sharp)] : [],
63
67
  xp: open ? [...draftNodes(a), a.needMore && a.moreNote ? note('You asked: ' + a.moreNote) : null,
64
- el('div', { class: 'chips2' }, pbtn('primary', 'Accept', 'check', { 'data-ibt-accept': a.id, onclick: () => openAccept(a) }),
68
+ scan ? null : el('div', { class: 'chips2' }, pbtn('primary', 'Accept', 'check', { 'data-ibt-accept': a.id, onclick: () => openAccept(a) }),
65
69
  pbtn('outline', 'Ask for more', 'reply', { 'data-ibt-more': a.id, onclick: () => openMore(a) }),
66
70
  pbtn('ghost', 'Decline', 'x', { 'data-ibt-decline': a.id, onclick: () => openDecline(a) }))].filter(Boolean)
67
71
  : state === 'declined' && a.why ? [note('Declined: ' + a.why)] : null,
@@ -49,6 +49,7 @@ import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf
49
49
  import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
50
50
  import { RECORD_META, recordToolsFor } from './c3-ini-records.js';
51
51
  import { agentSetting, loadAgentMode, MODES as AGENT_MODES } from './c3-driver.js';
52
+ import { scanSetting, takeSettingsFor } from './c3-scan.js';
52
53
 
53
54
  export { btn, geo };
54
55
  export { TEMPLATES, CATS };
@@ -160,6 +161,8 @@ export function mountInitiatives(pane, opts) {
160
161
  }
161
162
  const openBoard = (id, tab, filter) => go({ view: 'board', board: id, tab: tab || 'summary', filter: filter || {} });
162
163
  const refresh = () => { P.boards = null; P.ini = null; dropItems(); draw(); };
164
+ // The Briefing's Horizon scan card opens a board's Triage or settings (c3-scan.js goBoard). A page that is not built yet reads the address instead.
165
+ if (typeof window !== 'undefined') { if (window.__iniGo) window.removeEventListener('pmos:ini-go', window.__iniGo); window.__iniGo = (e) => { const d = (e && e.detail) || {}; if (d.board && root.isConnected) openBoard(d.board, d.tab || 'summary'); }; window.addEventListener('pmos:ini-go', window.__iniGo); }
163
166
  const redraw = () => draw();
164
167
 
165
168
  async function loadBoards() {
@@ -326,6 +329,7 @@ export function mountInitiatives(pane, opts) {
326
329
  segTabs(tabs, tab, (k) => openBoard(b.id, k), 'Board views', { 'data-ini': 'board-tabs' }),
327
330
  host));
328
331
  mountTab(host, tab === 'summary' ? mountSummary : null, (BOARD_TAB_LIST.find((t) => t[0] === tab) || [])[1], makeCtx(b, { tab, filter: P.loc.filter || {} }), token);
332
+ if (!opts.demo && takeSettingsFor(b.id)) openBoardSettings(b); // "Scan settings" on the Briefing's Horizon scan card
329
333
  }
330
334
 
331
335
  /* Summary: initiatives at a glance, where they stand, what needs attention */
@@ -389,6 +393,8 @@ export function mountInitiatives(pane, opts) {
389
393
  loadItems(makeCtx(b, {})).then((r) => { usage = r; render(); });
390
394
  // The Agent setting (c3-driver.js): read once, and saved at once when a mode is pressed (POST /api/driver/mode). Not in demo mode.
391
395
  const A = { mode: null, err: '', busy: false };
396
+ // The Horizon scan block (c3-scan.js): one node that reads and presses by itself, so a redraw of this form keeps it. Not in demo mode.
397
+ const scanNode = opts.demo ? null : scanSetting({ api: apiFor(b.ws), board: b.id, boardName: b.name });
392
398
  if (!opts.demo) loadAgentMode(apiFor(b.ws), b.id).then((r) => { if (r.ok) A.mode = r.mode; else A.err = r.error; render(); });
393
399
  async function pickAgent(mode) {
394
400
  if (A.busy || mode === A.mode) return;
@@ -423,6 +429,7 @@ export function mountInitiatives(pane, opts) {
423
429
  tplSeg([...B.offer], B.def, (k) => { B.def = k; render('[data-ini-tpl="' + k + '"]'); }, 'Default workflow'),
424
430
  tplCase(B.def),
425
431
  opts.demo ? null : agentSetting(A, { onPick: pickAgent }),
432
+ scanNode,
426
433
  el('div', { class: 'chips2' }, el('span', { class: 'dim ini-note' }, 'Initiatives that do not run their own workflow run the board\'s: ' + workflowLabel(b) + '.'), lk({ 'data-ini': 'edit-board-workflow', onclick: () => openBoardWorkflow(b) }, 'Edit the board\'s workflow')),
427
434
  B.err ? errLine(B.err) : null,
428
435
  el('div', { class: 'chips2' }, pbtn('primary', B.busy ? 'Saving' : 'Save board settings', null, { 'data-ini': 'settings-save', disabled: B.busy, onclick: save }), pbtn('ghost', 'Cancel', null, { onclick: () => sh.close() })));
@@ -0,0 +1,25 @@
1
+ /* pm/c3-scan.css — the few rules the Horizon scan needs beyond console3.css (c3-scan.js). The mockup's 40-scan.css under the `sc-` prefix: the
2
+ * Board settings block, the scan asks in Triage and the Briefing card. Held to the R28 rules: tokens only, no inline style, corners 0, 6px or 8px,
3
+ * font sizes from the --fs tokens. */
4
+ .c3 .sc-set { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); padding-top: var(--sp-3); border-top: 1px solid var(--border); }
5
+ .c3 .sc-set > * { min-width: 0; overflow-wrap: anywhere; }
6
+ .c3 .sc-set .seg { max-width: 100%; overflow-x: auto; }
7
+ .c3 .sc-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
8
+ .c3 .sc-it { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 8px; align-items: start; padding: var(--sp-2) 0; border-top: 1px solid var(--border); }
9
+ .c3 .sc-it > .sc-main { min-width: 0; display: grid; gap: 4px; }
10
+ .c3 .sc-it .sc-name { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; overflow-wrap: anywhere; }
11
+ .c3 .sc-it .sc-why { font-size: var(--fs-2); }
12
+ .c3 .sc-it .sc-acts { display: flex; gap: 6px; flex-wrap: wrap; }
13
+ .c3 .sc-it .form { margin: 0; }
14
+ .c3 .sc-add { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
15
+ .c3 .sc-add input[type="text"] { flex: 1 1 160px; min-width: 0; }
16
+ .c3 .sc-meta { display: grid; gap: 4px; font-size: var(--fs-2); }
17
+ .c3 .sc-foot { font-size: var(--fs-2); }
18
+ .c3 .nrow .sc-src { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; align-items: center; }
19
+ .c3 .nrow .sc-src .chip2 { max-width: 100%; }
20
+ .c3 .sc-link { text-decoration: none; }
21
+ .c3 .sc-card { display: grid; gap: var(--sp-2); }
22
+ .c3 .sc-card ul.chgl li { flex-wrap: wrap; }
23
+
24
+ .c3 .sc-card .sc-find { display: flex; flex-direction: column; min-width: 0; }
25
+ .c3 .sc-card .sc-find .sc-sub { display: block; }