create-pmos 0.132.0 → 0.133.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 (51) 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/bin/pmos.js +18 -2
  7. package/workspace/guide/index.md +1 -0
  8. package/workspace/guide/welcome.md +38 -0
  9. package/workspace/lib/import-overlay.js +32 -0
  10. package/workspace/lib/route-modules.js +1 -0
  11. package/workspace/lib/routes-onboarding.js +215 -0
  12. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  13. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  14. package/workspace/lib/vendor/scripts/intake.js +78 -0
  15. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  16. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  17. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  18. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  19. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  20. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  21. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  22. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  23. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  24. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  25. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  26. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  27. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  28. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  29. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  30. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  31. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  32. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  33. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  34. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  35. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  36. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  37. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  38. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  39. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  40. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  41. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  42. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  43. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  44. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  45. package/workspace/package-lock.json +2 -2
  46. package/workspace/package.json +1 -1
  47. package/workspace/public/pm/components.css +280 -0
  48. package/workspace/public/pm/welcome.js +283 -0
  49. package/workspace/public/welcome.html +20 -0
  50. package/workspace/server.js +2 -2
  51. package/workspace/test-lists/onboarding-firstrun.json +15 -0
@@ -0,0 +1,859 @@
1
+ 'use strict';
2
+ /* scan.js: the READ-ONLY, deterministic first scan of an existing product folder (onboarding-scan-and-map, slice 1).
3
+ *
4
+ * const { scan } = require('./onboarding/scan.js');
5
+ * scan(root, { domain, today }) -> { classes, totals, candidates, capped, ... }
6
+ *
7
+ * What it does: it runs the layout classifier (scripts/migrate-layout.js plan, in-place strategy), reads the prose
8
+ * documents the classifier found, and turns what the files SAY into candidates. A candidate is a PROPOSAL, never a change:
9
+ * { id, kind, title, proposed, evidence[{file,line,quote}], confidence, source, label, supply, needs_pm, detail }.
10
+ *
11
+ * What it never does: write into the folder it scans, call a model, guess a person's name from free text, read a file over
12
+ * the size limit, or read a binary. It runs `git log` (a read) for last-commit dates.
13
+ * Every extractor below is a pure function over file text, so it can be tested alone.
14
+ * Text from the folder is untrusted data (scripts/trust/import-guard.js): evidence quotes are cut at 160 characters, a
15
+ * quote that looks like an instruction is flagged in `detail.guard`, and no text from a file is ever treated as a command.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const cp = require('child_process');
21
+ const migrate = require('../migrate-layout.js');
22
+ const guard = require('../trust/import-guard.js');
23
+ const briefLib = require('./brief.js');
24
+
25
+ const KINDS = ['INITIATIVE', 'OBJECTIVE', 'KR', 'DEADLINE', 'DECISION', 'PERSON', 'RISK', 'OPEN_QUESTION', 'STALE', 'DUPLICATE', 'BROKEN_LINK', 'MISSING_ANCHOR', 'DOCUMENT'];
26
+ const DEFAULT_CAPS = { INITIATIVE: 80, OBJECTIVE: 40, KR: 80, DEADLINE: 120, DECISION: 80, PERSON: 60, RISK: 80, OPEN_QUESTION: 80, STALE: 80, DUPLICATE: 60, BROKEN_LINK: 80, MISSING_ANCHOR: 60, DOCUMENT: 300 };
27
+ const CONF_RANK = { high: 0, medium: 1, low: 2 };
28
+ const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
29
+ const DEFAULT_MAX_TEXT = 1024 * 1024; // a document over 1 MB is not read
30
+ const STALE_DAYS = 90;
31
+ const DAY = 86400000;
32
+ const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
33
+ const MON_RE = '(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
34
+
35
+ // ── small helpers ───────────────────────────────────────────────────────────────────────────────────────────────
36
+ const posix = (p) => p.split(path.sep).join('/');
37
+ const quote = (s) => String(s).trim().replace(/\s+/g, ' ').slice(0, 160);
38
+ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
39
+ const iso = (y, m, d) => `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
40
+ const validYmd = (y, m, d) => { const t = new Date(Date.UTC(y, m - 1, d)); return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d; };
41
+ const lastDay = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();
42
+ const daysBetween = (isoDate, today) => Math.round((Date.parse(isoDate + 'T00:00:00Z') - Date.parse(today + 'T00:00:00Z')) / DAY);
43
+ const isReserved = (rel) => /^(index|log)\.md$/i.test(path.posix.basename(rel));
44
+ const mdFile = (rel) => /\.(md|markdown)$/i.test(rel);
45
+ const stem = (rel) => path.posix.basename(rel).replace(/\.[^.]+$/, '');
46
+
47
+ /** Lines of `text` that are inside a fenced code block (1-based set). Code is not a deadline or a risk. */
48
+ function fencedLines(lines) {
49
+ const set = new Set(); let open = null;
50
+ lines.forEach((l, i) => {
51
+ const m = /^\s*(```+|~~~+)/.exec(l);
52
+ if (m) { if (open === null) { open = m[1][0]; } else if (m[1][0] === open) { open = null; } set.add(i + 1); return; }
53
+ if (open !== null) set.add(i + 1);
54
+ });
55
+ return set;
56
+ }
57
+
58
+ // ── frontmatter with line numbers (parseFrontmatter in migrate-layout has no lines and ignores lists) ───────────────
59
+ function fmInfo(text) {
60
+ const lines = String(text).split('\n');
61
+ if (!lines.length || lines[0].trim() !== '---') return { fm: null, end: 0, lines, keyLine: {}, lists: {} };
62
+ const fm = {}; const keyLine = {}; const lists = {}; let lastKey = null; let end = 0;
63
+ for (let i = 1; i < lines.length; i++) {
64
+ const l = lines[i];
65
+ if (l.trim() === '---') { end = i + 1; break; }
66
+ const m = /^([A-Za-z0-9_-]+):\s?(.*)$/.exec(l);
67
+ if (m) {
68
+ lastKey = m[1];
69
+ let v = m[2].trim();
70
+ if (/^(".*"|'.*')$/.test(v)) v = v.slice(1, -1);
71
+ if (!(lastKey in fm)) { fm[lastKey] = v; keyLine[lastKey] = i + 1; }
72
+ const inl = /^\[(.*)\]$/.exec(v);
73
+ if (inl) lists[lastKey] = inl[1].split(',').map((x) => ({ value: x.trim().replace(/^["']|["']$/g, ''), line: i + 1 })).filter((x) => x.value);
74
+ } else {
75
+ const li = /^\s+-\s+(.+?)\s*$/.exec(l);
76
+ if (li && lastKey) { (lists[lastKey] = lists[lastKey] || []).push({ value: li[1].replace(/^["']|["']$/g, ''), line: i + 1 }); }
77
+ }
78
+ }
79
+ if (!end) return { fm: null, end: 0, lines, keyLine: {}, lists: {} }; // unterminated
80
+ return { fm, end, keyLine, lists, lines };
81
+ }
82
+
83
+ const EMPTYISH = new Set(['', 'null', 'none', 'tbd', 'n/a', 'na', '~', '-', 'unknown', '<id>', 'todo']);
84
+ const emptyish = (v) => v === undefined || v === null || EMPTYISH.has(String(v).trim().toLowerCase());
85
+
86
+ // ── extractor 1: initiative records ─────────────────────────────────────────────────────────────────────────────
87
+ const INITIATIVE_RECORD_RE = /(^|\/)(state|planning)\/initiatives\/[^/]+\.md$/;
88
+ const isInitiativeRecordPath = (rel) => INITIATIVE_RECORD_RE.test(rel) && !/\/archive\//.test(rel) && !/^(README|index)\.md$/i.test(path.posix.basename(rel));
89
+ const isArchivedRecordPath = (rel) => /(^|\/)(state|planning)\/initiatives\/archive\/[^/]+\.md$/.test(rel) && !/^(README|index)\.md$/i.test(path.posix.basename(rel));
90
+
91
+ /** An initiative record: frontmatter with an id (or a file name). Returns null when the file has no frontmatter. */
92
+ function extractInitiativeRecord(text, rel) {
93
+ const info = fmInfo(text);
94
+ if (!info.fm) return null;
95
+ const fm = info.fm;
96
+ const id = !emptyish(fm.id) ? fm.id : stem(rel);
97
+ const laneRaw = fm.lane || fm.type || '';
98
+ const status = fm.status || '';
99
+ const ev = [];
100
+ const add = (key) => { if (info.keyLine[key]) ev.push({ line: info.keyLine[key], quote: quote(info.lines[info.keyLine[key] - 1]) }); };
101
+ add('id'); add('title'); add('parent_kr'); add('status');
102
+ if (!ev.length) ev.push({ line: 1, quote: quote(info.lines[0]) });
103
+ return {
104
+ id, title: fm.title || id, lane: LANES.includes(laneRaw) ? laneRaw : null, lane_raw: laneRaw || null,
105
+ parent_kr: emptyish(fm.parent_kr) ? null : fm.parent_kr, status, owner: emptyish(fm.owner) ? null : fm.owner,
106
+ jira_epic: emptyish(fm.jira_epic) ? null : fm.jira_epic, evidence: ev, anchored: !emptyish(fm.parent_kr),
107
+ anchor_line: info.keyLine.parent_kr || null, id_line: info.keyLine.id || 1,
108
+ };
109
+ }
110
+
111
+ /** A document that declares `initiative_id` (frontmatter or the kit's `- initiative_id:` prose line in the first 30 lines). */
112
+ function extractDeclaredInitiativeId(text) {
113
+ const lines = String(text).split('\n');
114
+ for (let i = 0; i < Math.min(lines.length, 40); i++) {
115
+ const m = /^[-*\s]*initiative_id:\s*(.+?)\s*$/i.exec(lines[i]);
116
+ if (m) { const v = m[1].replace(/\s+#.*$/, '').replace(/\s*\*?\(.*$/, '').replace(/^["'`]+|["'`*]+$/g, '').trim(); if (!emptyish(v) && /^[A-Za-z0-9][A-Za-z0-9._\/-]{1,60}$/.test(v)) return { id: v, line: i + 1, quote: quote(lines[i]) }; }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /** Jira epic keys: `jira_epic:` in frontmatter, or a line that says "epic" next to a KEY-123 (used for files under jira/). */
122
+ function extractJiraEpics(text) {
123
+ const lines = String(text).split('\n'); const out = []; const fenced = fencedLines(lines);
124
+ const info = fmInfo(text);
125
+ lines.forEach((l, i) => {
126
+ if (fenced.has(i + 1)) return;
127
+ const inFm = info.end && i + 1 <= info.end;
128
+ if (inFm) { const m = /^jira_epic:\s*["']?([A-Z][A-Z0-9]{1,9}-\d+)/.exec(l); if (m) out.push({ key: m[1], line: i + 1, quote: quote(l), via: 'frontmatter' }); return; }
129
+ if (!/\bepic\b/i.test(l)) return;
130
+ const m = /\b([A-Z][A-Z0-9]{1,9}-\d+)\b/.exec(l);
131
+ if (m) out.push({ key: m[1], line: i + 1, quote: quote(l), via: 'text' });
132
+ });
133
+ return out;
134
+ }
135
+
136
+ // ── extractor 2: objectives and key results ─────────────────────────────────────────────────────────────────────
137
+ const OBJ_HEAD_ID = /^#{1,6}\s*\**\s*(O-?(?:[A-Z]|\d{1,2}))\b\**\s*[—:–.-]+\s*(.+?)\s*$/; // O-A, O1: an uppercase letter or digits, so "Open" is not an objective
138
+ const OBJ_HEAD = /^#{1,6}\s*\**\s*(Objective\s*[A-Za-z0-9]{0,3})\b\**\s*[—:–.-]+\s*(.+?)\s*$/i;
139
+ const OBJ_LINE = /^[\s>*+-]*\**\s*(Objective\s*[A-Za-z0-9]{0,3})\b\**\s*[:—–-]\s*(.+?)\s*$/i;
140
+ const KR_LINE = /^[\s>*+-]*(?:\[[ xX]\]\s*)?\**\s*((?:O-?[A-Za-z0-9]{1,3}\.)?KR[-\s]?[A-Za-z]?-?\d+(?:\.\d+)*|Key Results?\s*\d+)\b\**\s*[—:–.\-]*\s*(.*)$/i;
141
+ const NUM = '(-?\\d[\\d,]*\\.?\\d*)';
142
+ const numOf = (s) => { const v = parseFloat(String(s).replace(/,/g, '')); return Number.isFinite(v) ? v : null; };
143
+
144
+ /** Parse baseline, target and a by-date out of a KR paragraph. Only what is written; nothing is inferred. */
145
+ function parseMetric(par) {
146
+ const s = String(par).replace(/\*+/g, '').replace(/`/g, '');
147
+ const out = { baseline: null, target: null, unit: null, by: null, by_iso: null };
148
+ let m = new RegExp('\\bfrom\\s+[^0-9]{0,30}?' + NUM + '\\s*(%|[a-z]{1,4}\\b)?[^.]{0,60}?\\bto\\s+(?:[≥≤<>~]=?\\s*)?' + NUM + '\\s*(%|[a-z]{1,4}\\b)?', 'i').exec(s);
149
+ if (m) { out.baseline = numOf(m[1]); out.target = numOf(m[3]); out.unit = m[4] === '%' || m[2] === '%' ? '%' : null; }
150
+ else {
151
+ const b = new RegExp('\\bbaseline[^0-9]{0,25}' + NUM, 'i').exec(s);
152
+ const t = new RegExp('\\btarget[^0-9]{0,25}' + NUM, 'i').exec(s) || new RegExp('(?:≥|>=|≤|<=)\\s*' + NUM + '\\s*(%)?', 'i').exec(s);
153
+ if (b) out.baseline = numOf(b[1]);
154
+ if (t) { out.target = numOf(t[1]); if (/%/.test(s.slice(t.index, t.index + t[0].length + 2))) out.unit = '%'; }
155
+ }
156
+ const by = new RegExp('\\bby\\s+(?:end of\\s+)?(' + MON_RE + '\\.?\\s+\\d{4}|\\d{4}-\\d{2}-\\d{2}|Q[1-4]\\s*\\d{4})', 'i').exec(s);
157
+ if (by) {
158
+ out.by = by[1];
159
+ const d = parseDates(by[0], '2000-01-01', { requireTrigger: false })[0];
160
+ if (d && !d.assumed_year) out.by_iso = d.iso;
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function extractOkr(text) {
166
+ const lines = String(text).split('\n'); const fenced = fencedLines(lines);
167
+ const objectives = []; const krs = [];
168
+ lines.forEach((l, i) => {
169
+ if (fenced.has(i + 1)) return;
170
+ let m = OBJ_HEAD_ID.exec(l) || OBJ_HEAD.exec(l) || OBJ_LINE.exec(l);
171
+ if (m && !KR_LINE.test(l)) { objectives.push({ id: m[1].replace(/\s+/g, '').replace(/^objective/i, 'O'), title: quote(m[2].replace(/\*+/g, '').replace(/\.$/, '')), line: i + 1, quote: quote(l) }); return; }
172
+ m = KR_LINE.exec(l);
173
+ if (!m) return;
174
+ const par = [l]; for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) { if (!lines[j].trim() || KR_LINE.test(lines[j]) || /^#{1,6}\s/.test(lines[j]) || OBJ_LINE.test(lines[j])) break; par.push(lines[j]); }
175
+ const rest = m[2] || '';
176
+ let title = rest.split('**')[0] || rest; title = title.replace(/\*+/g, '').replace(/[.\s]+$/, '').trim();
177
+ krs.push({ id: m[1].replace(/\s+/g, '').replace(/^Key Results?/i, 'KR'), title: quote(title) || m[1], line: i + 1, quote: quote(l), metric: parseMetric(par.join(' ')) });
178
+ });
179
+ return { objectives, krs };
180
+ }
181
+
182
+ // ── extractor 3: deadlines ──────────────────────────────────────────────────────────────────────────────────────
183
+ const STRONG = /\b(due|deadline|go[- ]?live|ETA|cut-?over)\b/i;
184
+ const WEAK = /\b(launch(?:es|ing)?|release[sd]?|ship(?:s|ping)?|GA|target(?:ed)?|milestone|freeze|expir\w*|renewal|deliver(?:y|ed|s)?|kick-?off|by)\b/i;
185
+ const PAST_TENSE = /\b(released|launched|shipped|delivered|went live|completed|closed|done)\b/i;
186
+
187
+ // Fiscal quarters. fyStart is the month number the fiscal year starts in. A fiscal year is named by the year it ENDS in
188
+ // (FY2026 starting October runs Oct 2025 to Sep 2026): that is a convention, so every date it makes is labelled an assumption.
189
+ function quarterEndFromFy(n, fyStart, fyStartYear) {
190
+ const startIdx = (fyStart - 1) + 3 * (n - 1); // months since January of fyStartYear
191
+ const endIdx = startIdx + 2; const y = fyStartYear + Math.floor(endIdx / 12); const mo = (endIdx % 12) + 1;
192
+ return { iso: iso(y, mo, lastDay(y, mo)) };
193
+ }
194
+ function fiscalQuarterOf(todayIso, fyStart) {
195
+ const y = +todayIso.slice(0, 4); const mo = +todayIso.slice(5, 7);
196
+ const fyStartYear = mo >= fyStart ? y : y - 1; const off = ((mo - fyStart) % 12 + 12) % 12;
197
+ return { n: Math.floor(off / 3) + 1, fyStartYear };
198
+ }
199
+ function fiscalQuarterEnd(n, fyStart, year, todayIso) {
200
+ if (year) return quarterEndFromFy(n, fyStart, fyStart === 1 ? year : year - 1);
201
+ return quarterEndFromFy(n, fyStart, fiscalQuarterOf(todayIso, fyStart).fyStartYear);
202
+ }
203
+ /** The month the fiscal year starts in, from 1..12, a month name ("Oct") or null. */
204
+ function monthNumber(v) {
205
+ if (v === null || v === undefined || v === '') return null;
206
+ if (Number.isInteger(+v) && +v >= 1 && +v <= 12) return +v;
207
+ const k = String(v).slice(0, 3).toLowerCase(); return MONTHS[k] || null;
208
+ }
209
+
210
+ /** Dates in one line: ISO, "30 Oct 2026", "Oct 30, 2026", "30 Oct", "Oct 2026" (month end), "Q4 2026" / "Q4" (quarter end). */
211
+ function dateTokens(line, todayYear, ctx) {
212
+ const out = []; let s = line;
213
+ const take = (re, fn) => { s = s.replace(re, (...a) => { const r = fn(a); const idx = a[a.length - (typeof a[a.length - 1] === 'object' ? 3 : 2)]; if (r) out.push(Object.assign({ index: idx, raw: a[0] }, r)); return ' '.repeat(a[0].length); }); };
214
+ take(/\b(\d{4})-(\d{2})-(\d{2})\b/g, (a) => (validYmd(+a[1], +a[2], +a[3]) ? { iso: iso(+a[1], +a[2], +a[3]), precision: 'day', assumed_year: false } : null));
215
+ take(new RegExp('\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+' + MON_RE + '\\.?,?\\s+(\\d{4})\\b', 'gi'), (a) => { const mo = MONTHS[a[2].slice(0, 3).toLowerCase()]; return validYmd(+a[3], mo, +a[1]) ? { iso: iso(+a[3], mo, +a[1]), precision: 'day', assumed_year: false } : null; });
216
+ take(new RegExp('\\b' + MON_RE + '\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s+(\\d{4})\\b', 'gi'), (a) => { const mo = MONTHS[a[1].slice(0, 3).toLowerCase()]; return validYmd(+a[3], mo, +a[2]) ? { iso: iso(+a[3], mo, +a[2]), precision: 'day', assumed_year: false } : null; });
217
+ take(new RegExp('\\b' + MON_RE + '\\.?\\s+(\\d{4})\\b', 'gi'), (a) => { const mo = MONTHS[a[1].slice(0, 3).toLowerCase()]; return { iso: iso(+a[2], mo, lastDay(+a[2], mo)), precision: 'month', assumed_year: false }; });
218
+ if (ctx && ctx.fyStart) { // fiscal quarters, and the user's own quarter names; every such date is an assumption
219
+ const names = ctx.quarterNames && ctx.quarterNames.length === 4 ? ctx.quarterNames.map((x) => String(x).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : null;
220
+ const qre = names ? '(?:' + names.map((n, i) => '(?<q' + (i + 1) + '>' + n + ')').join('|') + '|Q(?<qn>[1-4]))' : 'Q(?<qn>[1-4])';
221
+ take(new RegExp('\\b' + qre + "\\s*['’]?\\s*(\\d{4})?\\b", 'g'), (a) => {
222
+ const g = a[a.length - 1]; const grp = g || {}; let n = grp.qn ? +grp.qn : 0;
223
+ if (!n) for (let i = 1; i <= 4; i++) if (grp['q' + i]) n = i;
224
+ if (!n) return null;
225
+ const yr = a.slice(1, a.length - 3).filter((x) => /^\d{4}$/.test(x || ''))[0];
226
+ return Object.assign({ precision: 'quarter', assumed_year: true, fiscal: true }, fiscalQuarterEnd(n, ctx.fyStart, yr ? +yr : null, ctx.todayIso));
227
+ });
228
+ if (ctx.todayIso) {
229
+ take(/\b(next|this)\s+quarter\b/gi, (a) => { const cur = fiscalQuarterOf(ctx.todayIso, ctx.fyStart); const n = /^next/i.test(a[1]) ? cur.n + 1 : cur.n; const nn = ((n - 1) % 4) + 1; const fy = cur.fyStartYear + Math.floor((n - 1) / 4); return Object.assign({ precision: 'quarter', assumed_year: true, fiscal: true }, quarterEndFromFy(nn, ctx.fyStart, fy)); });
230
+ }
231
+ }
232
+ take(/\bQ([1-4])\s*['’]?\s*(\d{4})\b/g, (a) => { const mo = +a[1] * 3; return { iso: iso(+a[2], mo, lastDay(+a[2], mo)), precision: 'quarter', assumed_year: false }; });
233
+ take(/\bQ([1-4])\b/g, (a) => { const mo = +a[1] * 3; return { iso: iso(todayYear, mo, lastDay(todayYear, mo)), precision: 'quarter', assumed_year: true }; });
234
+ take(new RegExp('\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+' + MON_RE + '\\b(?!\\.?,?\\s+\\d{4})', 'gi'), (a) => { const mo = MONTHS[a[2].slice(0, 3).toLowerCase()]; return validYmd(todayYear, mo, +a[1]) ? { iso: iso(todayYear, mo, +a[1]), precision: 'day', assumed_year: true } : null; });
235
+ return out;
236
+ }
237
+
238
+ /** Deadline hits in a document. A date counts only next to a trigger word on its line (due, deadline, launch, release,
239
+ * go-live, by, ETA ...). `today` only fixes the year of a date that gives none. Returns [{line, quote, iso, precision,
240
+ * assumed_year, trigger, strength}]. */
241
+ function parseDates(line, today, { requireTrigger = true, ctx = null } = {}) {
242
+ const yr = +String(today).slice(0, 4);
243
+ const toks = dateTokens(line, yr, ctx);
244
+ if (!toks.length) return [];
245
+ if (!requireTrigger) return toks.map((t) => ({ iso: t.iso, precision: t.precision, assumed_year: t.assumed_year, fiscal: !!t.fiscal, trigger: null, strength: 'strong' }));
246
+ // a date counts only when a trigger word is close to IT: up to 60 characters before it or 25 after it, on the same line
247
+ const trigs = []; const re = new RegExp(STRONG.source + '|' + WEAK.source, 'gi'); let m;
248
+ while ((m = re.exec(line))) trigs.push({ word: m[0], index: m.index, end: m.index + m[0].length, strong: STRONG.test(m[0]) });
249
+ const out = [];
250
+ for (const t of toks) {
251
+ const tEnd = t.index + t.raw.length; let best = null;
252
+ for (const g of trigs) {
253
+ const before = t.index - g.end; const after = g.index - tEnd;
254
+ if (/^by$/i.test(g.word)) { if (!(before >= 0 && before <= 16 && /\bby\s+(?:the\s+)?(?:end of\s+)?$/i.test(line.slice(g.index, t.index)))) continue; }
255
+ let d = before >= 0 && before <= 60 ? before : after >= 0 && after <= 25 ? after : null;
256
+ if (d === null) continue;
257
+ if (!best || d < best.d || (d === best.d && g.strong)) best = { g, d };
258
+ }
259
+ if (best) out.push({ iso: t.iso, precision: t.precision, assumed_year: t.assumed_year, fiscal: !!t.fiscal, trigger: best.g.word, strength: best.g.strong ? 'strong' : 'weak' });
260
+ }
261
+ return out;
262
+ }
263
+
264
+ const FM_DEADLINE_KEYS = /^(due|due_date|deadline|launch|launch_date|go_live|golive|eta|release|release_date|target|target_release|target_date|target_window|next_milestone)$/i;
265
+
266
+ function extractDeadlines(text, rel, today, ctx) {
267
+ const info = fmInfo(text); const lines = info.lines; const fenced = fencedLines(lines); const out = []; const seen = new Set();
268
+ lines.forEach((l, i) => {
269
+ const n = i + 1;
270
+ if (fenced.has(n) || /^\s*\|?\s*:?-{2,}/.test(l) || /^\s*<!--/.test(l) || /placeholder/i.test(l) || /\b(dropped|removed|voided?|cancell?ed|superseded|no longer|was dropped|no launch date)\b/i.test(l)) return; // a date that was dropped is not a deadline
271
+ let hits;
272
+ if (info.end && n <= info.end) {
273
+ const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(l);
274
+ if (!m || !FM_DEADLINE_KEYS.test(m[1])) return;
275
+ hits = parseDates(l, today, { requireTrigger: false, ctx }).map((h) => Object.assign(h, { trigger: m[1], strength: /^(due|due_date|deadline|eta|go_live|golive)$/i.test(m[1]) ? 'strong' : 'weak' }));
276
+ } else {
277
+ if (PAST_TENSE.test(l) && !STRONG.test(l)) { // "released 2026-05-01" is a fact about the past, not a deadline
278
+ hits = parseDates(l, today, { ctx }).filter((h) => daysBetween(h.iso, today) >= 0);
279
+ } else hits = parseDates(l, today, { ctx });
280
+ }
281
+ for (const h of hits) {
282
+ const k = h.iso + '|' + n; if (seen.has(k)) continue; seen.add(k);
283
+ out.push(Object.assign(h, { line: n, quote: quote(l) }));
284
+ }
285
+ });
286
+ return out;
287
+ }
288
+
289
+ /** past | in_30 | in_90 | later, against a fixed today. The keys keep their historical names: in_30 is the near window (30 days, or
290
+ * the brief's horizonDays) and in_90 is beyond it up to the far window (90 days, or 3 times horizonDays). */
291
+ function classifyDeadline(isoDate, today, win) {
292
+ const near = (win && win.near) || 30; const far = (win && win.far) || 90;
293
+ const d = daysBetween(isoDate, today);
294
+ return { days: d, bucket: d < 0 ? 'past' : d <= near ? 'in_30' : d <= far ? 'in_90' : 'later' };
295
+ }
296
+
297
+ // ── extractor 3b: initiatives in the user's own words (brief.vocabulary.initiative) ─────────────────────────────────
298
+ const escRe = (w) => String(w).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
299
+ /** A heading, a frontmatter key or a line start that uses one of the user's initiative words ("epic", "bet") and names the thing. */
300
+ function extractVocabInitiatives(text, words) {
301
+ const ws = (words || []).map((w) => String(w).trim()).filter(Boolean); if (!ws.length) return [];
302
+ const alt = ws.map(escRe).join('|'); const info = fmInfo(text); const lines = info.lines; const fenced = fencedLines(lines); const out = [];
303
+ const headRe = new RegExp('^#{1,6}\\s*\\**\\s*(' + alt + ')\\b\\**\\s*[A-Za-z0-9.-]{0,12}\\s*[—:–-]+\\s*(.+?)\\s*$', 'i');
304
+ const lineRe = new RegExp('^[\\s>*+-]*\\**(' + alt + ')\\**\\s*[:—–]\\s*(.+?)\\s*$', 'i');
305
+ lines.forEach((l, i) => {
306
+ const n = i + 1; if (fenced.has(n)) return;
307
+ if (info.end && n <= info.end) { const m = /^([A-Za-z0-9_-]+):\s*(.+)$/.exec(l); if (m && ws.some((w) => w.toLowerCase() === m[1].toLowerCase()) && !emptyish(m[2])) out.push({ word: m[1], title: m[2].replace(/^["']|["']$/g, ''), line: n, quote: quote(l), via: 'frontmatter' }); return; }
308
+ const m = headRe.exec(l) || lineRe.exec(l);
309
+ if (m) out.push({ word: m[1], title: m[2].replace(/\*+/g, '').replace(/[.\s]+$/, ''), line: n, quote: quote(l), via: /^#/.test(l) ? 'heading' : 'line' });
310
+ });
311
+ return out;
312
+ }
313
+
314
+ /** The user's own goal words ("North Star", "goal") read like Objective/KR lines. Returns [{word,title,line,quote,metric}]. */
315
+ function extractGoalWords(text, words) {
316
+ const ws = (words || []).map((w) => String(w).trim()).filter((w) => w && !/^(okrs?|krs?|objectives?|key results?)$/i.test(w)); if (!ws.length) return [];
317
+ const alt = ws.map(escRe).join('|'); const lines = String(text).split('\n'); const fenced = fencedLines(lines); const out = [];
318
+ const re = new RegExp('^(?:#{1,6}\\s*|[\\s>*+-]*\\**\\s*)(' + alt + ')\\b\\**\\s*[A-Za-z0-9.-]{0,8}\\**\\s*[—:–-]\\s*(.+?)\\s*$', 'i');
319
+ lines.forEach((l, i) => {
320
+ if (fenced.has(i + 1)) return; const m = re.exec(l); if (!m) return;
321
+ const par = [l]; for (let j = i + 1; j < Math.min(lines.length, i + 4); j++) { if (!lines[j].trim() || /^#{1,6}\s/.test(lines[j])) break; par.push(lines[j]); }
322
+ out.push({ word: m[1], title: quote(m[2].replace(/\*+/g, '').replace(/[.\s]+$/, '')), line: i + 1, quote: quote(l), metric: parseMetric(par.join(' ')) });
323
+ });
324
+ return out;
325
+ }
326
+
327
+ /** Known initiatives (brief.knownInitiatives): which names or aliases a path, a heading or a frontmatter value contains. */
328
+ function matchKnown(known, text, rel) {
329
+ const out = []; const info = fmInfo(text); const lines = info.lines;
330
+ const res = known.map((k) => ({ k, re: new RegExp('(?<![\\p{L}\\p{N}])(?:' + [k.name, ...(k.aliases || [])].filter(Boolean).map(escRe).join('|') + ')(?![\\p{L}\\p{N}])', 'iu') }));
331
+ for (const { k, re } of res) {
332
+ const base = rel.replace(/[-_]+/g, ' ');
333
+ let m = re.exec(base);
334
+ if (m) out.push({ name: k.name, line: 0, quote: 'file name: ' + rel, matched: m[0], basis: 'file-name' });
335
+ lines.forEach((l, i) => {
336
+ const n = i + 1; const inFm = info.end && n <= info.end;
337
+ if (!inFm && !/^#{1,6}\s/.test(l)) return;
338
+ m = re.exec(l); if (m) out.push({ name: k.name, line: n, quote: quote(l), matched: m[0], basis: inFm ? 'frontmatter' : 'heading' });
339
+ });
340
+ }
341
+ return out;
342
+ }
343
+
344
+ // ── extractor 4: decisions ──────────────────────────────────────────────────────────────────────────────────────
345
+ const DECISION_HEAD = /^(#{1,6})\s*\**\s*(Decisions?|ADR[-\s]?\d*|Decision log|D\d{1,3})\b\**\s*[—:–.-]*\s*(.*)$/i;
346
+ /** Decisions in one document: the document itself (type ADR/Decision, an adr/decisions path or name) and its Decision headings. */
347
+ function extractDecisions(text, rel) {
348
+ const info = fmInfo(text); const lines = info.lines; const fenced = fencedLines(lines); const out = [];
349
+ const ty = info.fm && info.fm.type;
350
+ if (ty && /^(ADR|Decision)$/i.test(ty)) out.push({ level: 'doc', basis: 'type', line: info.keyLine.type, quote: quote(lines[info.keyLine.type - 1]), confidence: 'high' });
351
+ else if (/(^|\/)(adr|adrs|decisions?)(\/|\.md$)/i.test(rel) || /^(adr|decision)[-_0-9]/i.test(path.posix.basename(rel))) {
352
+ const h1 = lines.findIndex((l) => /^#\s+/.test(l));
353
+ out.push({ level: 'doc', basis: 'name', line: h1 >= 0 ? h1 + 1 : 1, quote: quote(lines[h1 >= 0 ? h1 : 0] || ''), confidence: 'medium' });
354
+ }
355
+ lines.forEach((l, i) => {
356
+ if (fenced.has(i + 1)) return;
357
+ const m = DECISION_HEAD.exec(l); if (!m) return;
358
+ const singular = !/^Decisions$/i.test(m[2]) && (m[3] || '').trim().length > 3;
359
+ out.push({ level: 'heading', basis: 'heading', line: i + 1, quote: quote(l), confidence: singular ? 'medium' : 'low', title: (m[3] || m[2]).trim() });
360
+ });
361
+ return out;
362
+ }
363
+
364
+ // ── extractor 5: people, from structured places only ────────────────────────────────────────────────────────────
365
+ const PERSON_KEYS = /^(owner|author|authors|attendees|participants|assignee|reviewer|approver|stakeholders|requester|lead)$/i;
366
+ const NOT_NAMES = new Set(['tbd', 'n/a', 'na', 'none', 'null', 'unknown', 'team', 'me', 'all', 'pm', 'everyone', 'tba', 'agent', 'claude', 'user', 'owner', 'name', 'role', 'you']);
367
+ /** A person's name or handle from a structured value, or null. Never called on free text. */
368
+ function cleanName(v) {
369
+ let s = String(v).trim().replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\*+/g, '').replace(/^@/, '').replace(/^human:/i, '').replace(/\s*[(<].*$/, '').trim();
370
+ if (!s || s.length > 40 || NOT_NAMES.has(s.toLowerCase())) return null;
371
+ if (!/^[\p{L}][\p{L}\p{M}.'’ -]{1,39}$/u.test(s)) return null;
372
+ return s;
373
+ }
374
+ function splitTableRow(l) { return l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim()); }
375
+ function extractPeople(text) {
376
+ const info = fmInfo(text); const lines = info.lines; const out = [];
377
+ if (info.fm) {
378
+ for (const [k, v] of Object.entries(info.fm)) {
379
+ if (!PERSON_KEYS.test(k)) continue;
380
+ const items = info.lists[k] ? info.lists[k] : (v ? v.split(/[,;]/).map((x) => ({ value: x.trim(), line: info.keyLine[k] })) : []);
381
+ for (const it of items) { const name = cleanName(it.value); if (name) out.push({ name, role: k.toLowerCase(), line: it.line, quote: quote(lines[it.line - 1]), via: 'frontmatter', confidence: 'high' }); }
382
+ }
383
+ }
384
+ const fenced = fencedLines(lines);
385
+ for (let i = 0; i < lines.length - 2; i++) {
386
+ if (fenced.has(i + 1) || !/^\s*\|/.test(lines[i]) || !/^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) continue;
387
+ const head = splitTableRow(lines[i]).map((c) => c.toLowerCase().replace(/\*+/g, ''));
388
+ const roleCol = head.findIndex((c) => /^(role|responsibility)$/.test(c));
389
+ const nameCol = head.findIndex((c) => /^(name|person|who|owner|stakeholder|member)$/.test(c));
390
+ if (roleCol < 0 || nameCol < 0) continue;
391
+ for (let j = i + 2; j < lines.length && /^\s*\|/.test(lines[j]); j++) {
392
+ const cells = splitTableRow(lines[j]); const name = cleanName(cells[nameCol] || '');
393
+ if (name) out.push({ name, role: quote(cells[roleCol] || '').replace(/\*+/g, '') || null, line: j + 1, quote: quote(lines[j]), via: 'roles-table', confidence: 'medium' });
394
+ }
395
+ }
396
+ return out;
397
+ }
398
+
399
+ // ── extractor 6: risks, open questions, blocked ─────────────────────────────────────────────────────────────────
400
+ const RISK_HEAD = /^(#{1,6})\s*(.*?)\b(risks?|open questions?|blocked|blockers?)\b/i;
401
+ /** Lines under a Risk, Open questions or Blocked heading (bullets and table rows), plus lines that start with "Risk:" or "Blocked:". */
402
+ function extractRisks(text) {
403
+ const info = fmInfo(text); const lines = info.lines; const fenced = fencedLines(lines); const out = [];
404
+ let sec = null; // { level, kind, heading }
405
+ const bodyStart = info.end || 0;
406
+ for (let i = bodyStart; i < lines.length; i++) {
407
+ const l = lines[i]; const n = i + 1;
408
+ if (fenced.has(n)) continue;
409
+ const h = /^(#{1,6})\s+(.*)$/.exec(l);
410
+ if (h) {
411
+ const m = RISK_HEAD.exec(l);
412
+ if (m) sec = { level: h[1].length, kind: /open question/i.test(m[3]) ? 'open-question' : 'risk', heading: quote(l), inTable: false, seenSep: false };
413
+ else if (sec && h[1].length <= sec.level) sec = null;
414
+ continue;
415
+ }
416
+ if (sec) {
417
+ let item = null;
418
+ const b = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s*)?(.+)$/.exec(l);
419
+ if (b) item = b[1];
420
+ else if (/^\s*\|/.test(l)) {
421
+ if (/^\s*\|?\s*:?-{2,}/.test(l)) { sec.seenSep = true; continue; }
422
+ if (sec.seenSep) item = splitTableRow(l).filter(Boolean).join(' | ');
423
+ }
424
+ if (item && item.replace(/[\s*_`|]/g, '').length > 5) { out.push({ kind: sec.kind, line: n, quote: quote(l), heading: sec.heading, basis: 'heading', confidence: 'medium' }); continue; }
425
+ }
426
+ const inl = /^[\s>*+-]*\**(Risk|Open question|Blocked(?: by| on)?)\**\s*[:—–]\s*\S/i.exec(l);
427
+ if (inl) out.push({ kind: /^open/i.test(inl[1]) ? 'open-question' : 'risk', line: n, quote: quote(l), heading: null, basis: 'line-prefix', confidence: 'low', blocked: /^blocked/i.test(inl[1]) });
428
+ }
429
+ return out;
430
+ }
431
+
432
+ // ── references and titles used by cross-document checks ─────────────────────────────────────────────────────────
433
+ function docTitle(text) {
434
+ const info = fmInfo(text);
435
+ if (info.fm && !emptyish(info.fm.title)) return { title: info.fm.title, line: info.keyLine.title };
436
+ const i = info.lines.findIndex((l, k) => k >= (info.end || 0) && /^#\s+\S/.test(l));
437
+ return i >= 0 ? { title: info.lines[i].replace(/^#\s+/, '').trim(), line: i + 1 } : null;
438
+ }
439
+
440
+ /** Paths an initiative record points at, by a frontmatter value or a markdown link. Resolved against `fileSet`. */
441
+ function recordRefs(text, rel, fileSet) {
442
+ const found = new Set(); const info = fmInfo(text);
443
+ const resolve = (v) => {
444
+ const c = String(v).trim().replace(/^["'`]|["'`]$/g, '').replace(/#.*$/, ''); if (!c || /^[a-z][a-z0-9+.-]*:/i.test(c)) return null;
445
+ const dir = path.posix.dirname(rel); const kit = rel.startsWith('pmos/') ? 'pmos/' : '';
446
+ for (const x of [c.replace(/^\//, ''), path.posix.join(dir, c), kit + c.replace(/^\//, '')]) { const nx = path.posix.normalize(x); if (fileSet.has(nx)) return nx; }
447
+ return null;
448
+ };
449
+ if (info.fm) for (const v of Object.values(info.fm)) { const r = resolve(v); if (r && r !== rel) found.add(r); }
450
+ for (const l of migrate.extractLinks(text)) { if (l.kind !== 'md') continue; let p = l.raw.split('#')[0]; try { p = decodeURI(p); } catch { /* keep */ } const r = resolve(p); if (r && r !== rel) found.add(r); }
451
+ return [...found].sort();
452
+ }
453
+
454
+ // ── candidate construction ──────────────────────────────────────────────────────────────────────────────────────
455
+ function mk(c) {
456
+ const evidence = (c.evidence || []).map((e) => ({ file: e.file, line: e.line, quote: quote(e.quote || '') }));
457
+ const target = (c.proposed && (c.proposed.where || c.proposed.becomes)) || '';
458
+ const id = 'p_' + sha1([c.kind, target, c.key || '', JSON.stringify(evidence.map((e) => [e.file, e.line, e.quote]))].join('|')).slice(0, 12);
459
+ const g = []; for (const e of evidence) for (const f of guard.scan(e.quote)) g.push(f.k);
460
+ return {
461
+ id, kind: c.kind, title: quote(c.title).slice(0, 120), proposed: c.proposed, evidence, confidence: c.confidence, source: c.source,
462
+ label: c.label, supply: c.supply, needs_pm: !!c.needs_pm, detail: c.detail || {},
463
+ untrusted: true, guard: [...new Set(g)],
464
+ };
465
+ }
466
+ const ev1 = (file, h) => ({ file, line: h.line, quote: h.quote });
467
+
468
+ // ── git facts: last commit date per file (read-only) ────────────────────────────────────────────────────────────
469
+ function gitDates(root) {
470
+ const dates = new Map();
471
+ if (!fs.existsSync(path.join(root, '.git'))) return { dates, available: false };
472
+ try {
473
+ const out = cp.execFileSync('git', ['-C', root, '--no-optional-locks', 'log', '--no-renames', '--format=%x01%cs', '--name-only'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
474
+ let date = null;
475
+ for (const line of out.split('\n')) { if (line.startsWith('\x01')) { date = line.slice(1); continue; } if (line && !dates.has(line)) dates.set(line, date); }
476
+ return { dates, available: true };
477
+ } catch { return { dates, available: false }; }
478
+ }
479
+
480
+ const CLOSED = /^(closed|done|shipped|accepted|archived|cancelled|canceled|killed|complete|completed|abandoned)\b/i;
481
+ const isOpenStatus = (s) => !CLOSED.test(String(s || '').trim());
482
+
483
+ // ── the onboarding brief as hard limits ─────────────────────────────────────────────────────────────────────────────
484
+ const LOCAL_KINDS = briefLib.LOCAL_KINDS || ['folder', 'repo'];
485
+ /** Turn a brief's sources, exclude and doNotRead into a skip function the walker calls BEFORE it opens anything.
486
+ * A file or folder that matches doNotRead, or an exclude of every source that covers it, or that no local source covers, is never
487
+ * opened (not read, not hashed). Sources of other kinds (jira, confluence, drive ...) are not scannable here and are listed. */
488
+ function briefRules(brief, root) {
489
+ const doNot = (brief && brief.doNotRead) || [];
490
+ const all = ((brief && brief.sources) || []);
491
+ const local = [];
492
+ const unscannable = [];
493
+ for (const s of all) {
494
+ if (LOCAL_KINDS.includes(s.kind || 'folder')) {
495
+ let ref = String(s.ref || '').trim();
496
+ if (path.isAbsolute(ref)) { const r = posix(path.relative(root, ref)); ref = r.startsWith('..') ? null : r; }
497
+ ref = ref === null ? null : ref.replace(/^\.?\//, '').replace(/\/+$/, '');
498
+ if (ref === null) { unscannable.push({ kind: s.kind, ref: s.ref, why: 'a local path outside the scanned folder' }); continue; }
499
+ local.push({ ref: ref === '.' ? '' : ref, include: s.include || [], exclude: s.exclude || [], sensitivity: s.sensitivity || null });
500
+ } else unscannable.push({ kind: s.kind, ref: s.ref, why: 'not a local folder: its content arrives through an agent session with the user\'s own connection, not through this scan' });
501
+ }
502
+ const under = (rel, ref) => !ref || rel === ref || rel.startsWith(ref + '/');
503
+ const above = (rel, ref) => !ref || ref.startsWith(rel + '/');
504
+ const incl = (rel, s) => !s.include.length || briefLib.matchesAny(s.include, rel) || (s.ref && briefLib.matchesAny(s.include, rel.slice(s.ref.length + 1)));
505
+ const sourceFor = (rel) => local.find((s) => under(rel, s.ref) && incl(rel, s) && !briefLib.matchesAny(s.exclude, rel)) || null;
506
+ const skip = (rel, isDir) => {
507
+ const g = doNot.find((x) => briefLib.matchGlob(x, rel)); if (g) return 'doNotRead: ' + g;
508
+ if (!local.length) return null;
509
+ if (isDir) {
510
+ const cover = local.filter((s) => under(rel, s.ref) || above(rel, s.ref)); if (!cover.length) return 'outside every source in the brief';
511
+ if (cover.every((s) => under(rel, s.ref) && briefLib.matchesAny(s.exclude, rel))) return 'excluded by the brief';
512
+ return null;
513
+ }
514
+ if (sourceFor(rel)) return null;
515
+ const cover = local.filter((s) => under(rel, s.ref));
516
+ if (!cover.length) return 'outside every source in the brief';
517
+ if (cover.some((s) => briefLib.matchesAny(s.exclude, rel))) { const x = cover.flatMap((s) => s.exclude).find((e) => briefLib.matchGlob(e, rel)); return 'excluded by the brief: ' + x; }
518
+ return 'not matched by any include in the brief';
519
+ };
520
+ return { skip, sourceFor, unscannable, local };
521
+ }
522
+
523
+ // ── the scan ────────────────────────────────────────────────────────────────────────────────────────────────────
524
+ function scan(rootArg, opts = {}) {
525
+ const root = path.resolve(rootArg);
526
+ const today = opts.today || new Date().toISOString().slice(0, 10);
527
+ const maxText = opts.maxTextBytes || DEFAULT_MAX_TEXT;
528
+ const caps = Object.assign({}, DEFAULT_CAPS, opts.caps || {});
529
+ const brief = opts.brief || null;
530
+ const rules = briefRules(brief, root);
531
+ const read = opts.reader || ((f) => fs.readFileSync(f, 'utf8'));
532
+ const vocab = (brief && brief.vocabulary) || {};
533
+ const fyStart = monthNumber(vocab.fiscalYearStartMonth) || monthNumber(vocab.quarterStart);
534
+ const ctx = brief ? { fyStart, quarterNames: vocab.quarterNames, todayIso: today } : null;
535
+ const near = brief && brief.horizonDays > 0 ? Math.round(brief.horizonDays) : 30; const win = { near, far: brief && brief.horizonDays > 0 ? near * 3 : 90 };
536
+ const m = migrate.plan(root, { domain: opts.domain, strategy: 'in-place', skip: brief ? rules.skip : null });
537
+ const entries = m.entries;
538
+ const byFrom = new Map(entries.map((e) => [e.from, e]));
539
+ const fileSet = new Set(entries.map((e) => e.from));
540
+ const git = gitDates(root);
541
+ let layout = null; try { const p = require('../signals/layout-paths.js').pathsFor(root); layout = { kit: p.kit, recordsDir: path.relative(root, p.recordsDir) || '.' }; } catch { layout = { kit: false, recordsDir: 'planning/records' }; }
542
+ const recordsDir = layout.recordsDir;
543
+
544
+ // the prose documents: markdown, not kit, readable, within the size limit
545
+ const docs = []; const unread = [];
546
+ for (const e of entries) {
547
+ if (e.class === 'kit' || !mdFile(e.from) || e.action === 'upgrade') continue;
548
+ if (e.size > maxText) { unread.push({ file: e.from, reason: `over the text size limit (${e.size} > ${maxText} bytes)` }); continue; }
549
+ let text; try { text = read(path.join(root, e.from)); } catch { continue; }
550
+ if (text.includes('\0')) { unread.push({ file: e.from, reason: 'not readable as text' }); continue; }
551
+ docs.push({ rel: e.from, text, entry: e });
552
+ }
553
+ const nonReserved = docs.filter((d) => !isReserved(d.rel));
554
+ // generated output (the kit's reports and live notes) restates other records; reading claims out of it would count them twice
555
+ const skipDoc = (d) => d.entry.class === 'kit' || /(^|\/)state\/(reports|live-notes)\//.test(d.rel);
556
+ const cands = [];
557
+ const totals = { files: entries.length, documents: 0, documents_with_frontmatter: 0, attachments: 0 };
558
+
559
+ // — initiative records, missing anchors —
560
+ const records = [];
561
+ for (const d of docs) {
562
+ const archived = isArchivedRecordPath(d.rel);
563
+ if (!isInitiativeRecordPath(d.rel) && !archived) continue;
564
+ const r = extractInitiativeRecord(d.text, d.rel);
565
+ if (!r) continue;
566
+ records.push(Object.assign(r, { rel: d.rel, archived, open: !archived && isOpenStatus(r.status) }));
567
+ }
568
+ const live = records.filter((r) => !r.archived);
569
+ const idSet = new Set(records.map((r) => r.id));
570
+ const known = ((brief && brief.knownInitiatives) || []).filter((k) => k && k.name);
571
+ const knownOf = (...strs) => { for (const k of known) { const re = new RegExp('(?<![\\p{L}\\p{N}])(?:' + [k.name, ...(k.aliases || [])].filter(Boolean).map(escRe).join('|') + ')(?![\\p{L}\\p{N}])', 'iu'); if (strs.some((x) => x && re.test(String(x).replace(/[-_]+/g, ' ')))) return k.name; } return null; };
572
+ const prior = (title) => { try { const I = require('../intake.js'); const a = I.priorRejections(root, title); const b = I.priorRejections(path.join(root, 'pmos'), title); return [...a, ...b]; } catch { return []; } };
573
+ for (const r of records) {
574
+ const lane = r.lane;
575
+ cands.push(mk({
576
+ kind: 'INITIATIVE', key: r.id, title: `${r.title}${r.archived ? ' (archived)' : ''}`, evidence: r.evidence.map((e) => ev1(r.rel, e)),
577
+ confidence: r.archived ? 'medium' : 'high', source: 'mechanical', label: 'cited', supply: 'mechanical-transform', needs_pm: !lane || r.archived,
578
+ proposed: { becomes: 'initiative', where: r.rel + ' (in place)', summary: `Register ${r.id} as an initiative${lane ? ' in lane ' + lane : ' (lane not stated; the PM names it)'}; its record stays where it is, frontmatter is added only.`, fields: { id: r.id, lane, parent_kr: r.parent_kr, status: r.status || null } },
579
+ detail: { id: r.id, record: r.rel, lane, lane_raw: r.lane_raw, parent_kr: r.parent_kr, anchored: r.anchored, status: r.status || null, owner: r.owner, jira_epic: r.jira_epic, archived: r.archived, open: r.open, known_name: knownOf(r.id, r.title) },
580
+ }));
581
+ if (!r.anchored && !r.archived) {
582
+ const line = r.id_line;
583
+ cands.push(mk({
584
+ kind: 'MISSING_ANCHOR', key: r.id, title: `${r.id} has no parent_kr`, evidence: [{ file: r.rel, line: r.anchor_line || line, quote: r.anchor_line ? r.evidence.find((e) => e.line === r.anchor_line).quote : (fmInfo(d0(docs, r.rel)).lines[line - 1] || '') }],
585
+ confidence: 'high', source: 'mechanical', label: 'fact', supply: 'pm-only', needs_pm: true,
586
+ proposed: { becomes: 'anchor', where: r.rel + ' (parent_kr)', summary: `Name a KR or a health budget (health:<id>) for ${r.id}. PMOS rejects unanchored work; the PM chooses, nothing is guessed.` },
587
+ detail: { id: r.id, status: r.status || null, open: r.open },
588
+ }));
589
+ }
590
+ }
591
+ totals.initiatives = live.length;
592
+ totals.initiatives_anchored = live.filter((r) => r.anchored).length;
593
+ totals.initiatives_archived = records.length - live.length;
594
+ totals.missing_anchor = live.filter((r) => !r.anchored).length;
595
+
596
+ // — initiative clusters with no record: declared initiative_id, Jira epics, document folders, briefs —
597
+ const clusters = new Map(); // key -> {title, basis, evidence[], confidence}
598
+ const addCluster = (key, c, e) => { if (!clusters.has(key)) clusters.set(key, Object.assign({ evidence: [] }, c)); const k = clusters.get(key); if (k.evidence.length < 5) k.evidence.push(e); };
599
+ const epicOfRecord = new Set(records.map((r) => r.jira_epic).filter(Boolean));
600
+ for (const d of nonReserved) {
601
+ if (records.some((r) => r.rel === d.rel)) continue;
602
+ const di = extractDeclaredInitiativeId(d.text);
603
+ if (di && !idSet.has(di.id)) addCluster('id:' + di.id, { title: di.id, basis: 'declared initiative_id that matches no record', confidence: 'medium', via: 'initiative_id' }, { file: d.rel, line: di.line, quote: di.quote });
604
+ if (/(^|\/)jira\//.test(d.rel)) for (const ep of extractJiraEpics(d.text)) {
605
+ if (epicOfRecord.has(ep.key)) continue;
606
+ addCluster('epic:' + ep.key, { title: `Jira epic ${ep.key}`, basis: 'a Jira epic key in a jira/ file', confidence: ep.via === 'frontmatter' ? 'medium' : 'low', via: 'jira' }, { file: d.rel, line: ep.line, quote: ep.quote });
607
+ }
608
+ }
609
+ if ((vocab.initiative || []).length) for (const d of nonReserved) {
610
+ if (d.entry.class === 'kit' || records.some((r) => r.rel === d.rel)) continue;
611
+ for (const h of extractVocabInitiatives(d.text, vocab.initiative)) addCluster('vocab:' + h.title.toLowerCase(), { title: h.title, basis: `a ${h.via} that uses your word "${h.word}"`, confidence: 'medium', via: 'vocabulary:' + h.word }, { file: d.rel, line: h.line, quote: h.quote });
612
+ }
613
+ // folders: three or more prose documents under one folder, not covered by a record or a known system folder
614
+ const SYSTEM_DIR = /^(pmos|briefs|reports|prototype|tools|node_modules|state|planning|okf|me|inbox)(\/|$)/i;
615
+ const byDir = new Map();
616
+ for (const d of nonReserved) { if (d.entry.class === 'kit') continue; const dir = path.posix.dirname(d.rel); if (dir === '.' || SYSTEM_DIR.test(dir) || /\/(archive|drafts|sources)(\/|$)/.test(dir)) continue; if (!byDir.has(dir)) byDir.set(dir, []); byDir.get(dir).push(d); }
617
+ const claimedByRecord = new Set(); for (const r of records) for (const p of recordRefs(d0(docs, r.rel), r.rel, fileSet)) claimedByRecord.add(path.posix.dirname(p));
618
+ for (const [dir, list] of [...byDir.entries()].sort()) {
619
+ if (list.length < 3 || claimedByRecord.has(dir)) continue;
620
+ const first = list[0]; const h = docTitle(first.text);
621
+ addCluster('dir:' + dir, { title: path.posix.basename(dir), basis: `${list.length} documents share the folder ${dir}/ and no initiative record names it`, confidence: 'low', via: 'folder' }, { file: first.rel, line: h ? h.line || 1 : 1, quote: h ? h.title : first.rel });
622
+ }
623
+ // briefs: a document named *brief* that is not a dated daily brief
624
+ for (const d of nonReserved) {
625
+ const b = stem(d.rel);
626
+ if (!/brief/i.test(b) || /^\d{4}-\d{2}-\d{2}$/.test(b) || /^brief-config$/i.test(b) || records.some((r) => r.rel === d.rel) || d.entry.class === 'kit' || /(^|\/)(briefs|pmos\/planning\/briefs)\/(drafts|sources)\//.test(d.rel)) continue;
627
+ const h = docTitle(d.text);
628
+ addCluster('brief:' + d.rel, { title: h ? h.title : b, basis: 'a brief that no initiative record names', confidence: 'low', via: 'brief' }, { file: d.rel, line: h && h.line ? h.line : 1, quote: h ? h.title : b });
629
+ }
630
+ const clusterCands = [];
631
+ for (const [key, c] of clusters) {
632
+ const I = (() => { try { return require('../intake.js').parseIntakeLine(c.title); } catch { return { lane_guess: null, confidence: 0.2 }; } })();
633
+ const pr = prior(c.title);
634
+ clusterCands.push(mk({
635
+ kind: 'INITIATIVE', key, title: c.title, evidence: c.evidence, confidence: c.confidence, source: 'heuristic', label: 'assumption', supply: 'agent-drafted', needs_pm: true,
636
+ proposed: { becomes: 'initiative', where: 'new initiative record (in place: the documents stay)', summary: `Possible initiative "${c.title}": ${c.basis}. An agent grouping later decides whether it is one; the PM decides now.`, fields: { id: null, lane: I.lane_guess, parent_kr: null } },
637
+ detail: { basis: c.basis, via: c.via, lane_guess: I.lane_guess, lane_confidence: I.confidence, prior_rejections: pr, record: null },
638
+ }));
639
+ }
640
+ cands.push(...clusterCands);
641
+ // known initiatives from the brief: matched against file names, headings and frontmatter first
642
+ const knownHits = new Map(known.map((k) => [k.name, []]));
643
+ if (known.length) for (const d of nonReserved) { if (d.entry.class === 'kit') continue; for (const h of matchKnown(known, d.text, d.rel)) { const a = knownHits.get(h.name); if (a.length < 5) a.push({ file: d.rel, line: h.line, quote: h.quote, basis: h.basis, matched: h.matched }); } }
644
+ for (const k of known) {
645
+ const hits = knownHits.get(k.name); const rec = live.find((r) => knownOf(r.id, r.title) === k.name);
646
+ const strong = hits.some((h) => h.basis === 'frontmatter') || !!rec;
647
+ cands.push(mk({ kind: 'INITIATIVE', key: 'known:' + k.name, title: k.name, evidence: hits.length ? hits : [{ file: '(brief)', line: 0, quote: 'named in the onboarding brief: ' + k.name }],
648
+ confidence: strong ? 'high' : hits.length ? 'medium' : 'low', source: 'mechanical', label: hits.length ? 'cited' : 'reported', supply: 'mechanical-transform', needs_pm: !rec,
649
+ proposed: { becomes: 'initiative', where: rec ? rec.rel + ' (in place)' : 'new initiative record (in place: the documents stay)', summary: hits.length ? `You named "${k.name}"; ${hits.length} place(s) in the folder use that name or an alias. The documents that name it would be linked to it.` : `You named "${k.name}" but no file name, heading or frontmatter value uses it or an alias.`, fields: { id: rec ? rec.id : null, lane: rec ? rec.lane : null, parent_kr: rec ? rec.parent_kr : null } },
650
+ detail: { known: true, name: k.name, aliases: k.aliases || [], matched_places: hits.length, record: rec ? rec.rel : null, matched_text: [...new Set(hits.map((h) => h.matched).filter(Boolean))] } }));
651
+ }
652
+ totals.known_initiatives = known.length;
653
+ totals.initiative_clusters = clusterCands.length;
654
+
655
+ // — objectives and key results —
656
+ const goalWords = vocab.goal || [];
657
+ const okrLike = (d) => /okr|objective|key[-_ ]result/i.test(d.rel) || (goalWords.length && extractGoalWords(d.text, goalWords).length > 0) || (fmInfo(d.text).fm || {}).type === 'OKR' || /^#{1,6}\s*\**\s*O-?(?:[A-Z]|\d{1,2})\b\s*[—:–.-]/m.test(d.text) || /^#{1,6}\s*\**\s*Objective\b/im.test(d.text);
658
+ const seenObj = new Set(); const seenKr = new Set(); let krTotal = 0; let objTotal = 0;
659
+ for (const d of [...nonReserved].sort((a, b) => Number(okrLike(b)) - Number(okrLike(a)) || (a.rel < b.rel ? -1 : 1))) {
660
+ if (skipDoc(d) || /\/(templates?)\//.test(d.rel)) continue;
661
+ const like = okrLike(d); const { objectives, krs } = extractOkr(d.text);
662
+ if (goalWords.length) for (const g of extractGoalWords(d.text, goalWords)) {
663
+ const mt = g.metric; const has = mt.baseline !== null || mt.target !== null;
664
+ if (has) krs.push({ id: g.word.replace(/\s+/g, '') + ':' + g.title.slice(0, 24), title: g.title, line: g.line, quote: g.quote, metric: mt, viaWord: g.word });
665
+ else objectives.push({ id: g.word.replace(/\s+/g, '') + ':' + g.title.slice(0, 24), title: g.title, line: g.line, quote: g.quote, viaWord: g.word });
666
+ }
667
+ if (!like && !krs.length) continue;
668
+ if (like) for (const o of objectives) {
669
+ if (seenObj.has(o.id)) continue; seenObj.add(o.id); objTotal++;
670
+ cands.push(mk({ kind: 'OBJECTIVE', key: o.id, title: `${o.id}: ${o.title}`, evidence: [ev1(d.rel, o)], confidence: 'high', source: 'mechanical', label: 'cited', supply: 'mechanical-transform', needs_pm: false,
671
+ proposed: { becomes: 'objective', where: d.rel + ' (in place; it is the OKR document)', summary: `Objective ${o.id} from ${d.rel}.` }, detail: { id: o.id } }));
672
+ }
673
+ for (const k of krs) {
674
+ if (seenKr.has(k.id)) continue;
675
+ if (!like && !k.viaWord && !/^KR-?[A-Za-z]?-?\d/i.test(k.id)) continue;
676
+ seenKr.add(k.id); krTotal++;
677
+ const mt = k.metric; const both = mt.baseline !== null && mt.target !== null;
678
+ cands.push(mk({ kind: 'KR', key: k.id, title: `${k.id}: ${k.title}`, evidence: [ev1(d.rel, k)], confidence: !like ? 'low' : both ? 'high' : (mt.baseline !== null || mt.target !== null) ? 'medium' : 'low',
679
+ source: 'mechanical', label: 'cited', supply: both ? 'mechanical-transform' : 'agent-drafted', needs_pm: !both,
680
+ proposed: { becomes: 'kr-metrics record', where: recordsDir + '/kr-metrics.jsonl', summary: both ? `KR ${k.id}: baseline ${mt.baseline}${mt.unit || ''} to target ${mt.target}${mt.unit || ''}${mt.by ? ' by ' + mt.by : ''}.` : `KR ${k.id}: the metric is not fully written (${mt.baseline === null ? 'no baseline' : 'baseline ' + mt.baseline}, ${mt.target === null ? 'no target' : 'target ' + mt.target}); the PM or an agent drafts the rest.`, fields: { metric: k.title, baseline: mt.baseline, target: mt.target, by: mt.by_iso } },
681
+ detail: Object.assign({ id: k.id, in_okr_doc: like }, mt) }));
682
+ }
683
+ }
684
+ totals.objectives = objTotal; totals.krs = krTotal;
685
+
686
+ // — deadlines —
687
+ const dl = []; const ownerByDoc = new Map();
688
+ const recByRel = new Map(records.map((r) => [r.rel, r]));
689
+ for (const d of nonReserved) {
690
+ if (skipDoc(d)) continue;
691
+ const info = fmInfo(d.text); const docOwner = (info.fm && (!emptyish(info.fm.owner) ? info.fm.owner : !emptyish(info.fm.author) ? info.fm.author : null)) || null;
692
+ ownerByDoc.set(d.rel, docOwner);
693
+ const archived = /\/archive\//.test(d.rel);
694
+ for (const h of extractDeadlines(d.text, d.rel, today, ctx)) {
695
+ const c = classifyDeadline(h.iso, today, win);
696
+ const day = h.precision === 'day';
697
+ let confidence = day && !h.assumed_year ? (h.strength === 'strong' ? 'high' : 'medium') : 'low';
698
+ if (c.bucket === 'past' && c.days < -30 && confidence !== 'high') confidence = 'low';
699
+ if (archived) confidence = 'low';
700
+ const rec = recByRel.get(d.rel);
701
+ dl.push({ d, h, c, confidence, owner: docOwner, rec, label: h.assumed_year || !day ? 'assumption' : 'cited' });
702
+ }
703
+ }
704
+ const dTotals = { total: dl.length, past: 0, in_30: 0, in_90: 0, later: 0, in_next_90: 0 };
705
+ for (const x of dl) { dTotals[x.c.bucket]++; if (x.c.bucket === 'in_30' || x.c.bucket === 'in_90') dTotals.in_next_90++; }
706
+ dTotals.windows = win; totals.deadlines = dTotals;
707
+ for (const x of dl) {
708
+ cands.push(mk({ kind: 'DEADLINE', key: x.h.iso, title: `${x.h.iso} (${x.h.trigger}) ${x.d.rel}`, evidence: [ev1(x.d.rel, x.h)], confidence: x.confidence, source: 'mechanical', label: x.label, supply: 'mechanical-transform', needs_pm: x.confidence !== 'high' || !x.owner,
709
+ proposed: { becomes: 'dated milestone on a plan item', where: (x.rec ? x.rec.rel : x.d.rel) + ' (due date)', summary: `${x.h.precision === 'day' ? 'Due' : x.h.precision === 'month' ? 'By the end of the month' : 'By the end of the quarter'} ${x.h.iso}${x.h.assumed_year ? ' (' + (x.h.fiscal ? 'fiscal quarter by the brief: a convention' : 'year not written: ' + today.slice(0, 4) + ' assumed') + ')' : ''}; ${x.c.bucket === 'past' ? Math.abs(x.c.days) + ' days ago' : 'in ' + x.c.days + ' days'}.` },
710
+ detail: { iso: x.h.iso, precision: x.h.precision, assumed_year: x.h.assumed_year, fiscal: !!x.h.fiscal, trigger: x.h.trigger, bucket: x.c.bucket, days: x.c.days, owner: x.owner, initiative: x.rec ? x.rec.id : null } }));
711
+ }
712
+
713
+ // — decisions —
714
+ const decDocs = new Set(); let decTotal = 0;
715
+ for (const d of nonReserved) {
716
+ if (skipDoc(d)) continue;
717
+ const hits = extractDecisions(d.text, d.rel); if (!hits.length) continue;
718
+ const title = (docTitle(d.text) || { title: stem(d.rel) }).title;
719
+ // follow-up: another document names this file, or this document itself names an initiative or a ticket
720
+ const bn = path.posix.basename(d.rel);
721
+ const followups = nonReserved.filter((o) => o.rel !== d.rel && o.text.includes(bn)).length + (extractDeclaredInitiativeId(d.text) || /\b[A-Z][A-Z0-9]{1,9}-\d+\b/.test(d.text) ? 1 : 0);
722
+ for (const h of hits) {
723
+ decTotal++; decDocs.add(d.rel);
724
+ cands.push(mk({ kind: 'DECISION', key: h.level + ':' + (h.title || ''), title: h.level === 'doc' ? title : `${title}: ${h.title || 'Decisions'}`, evidence: [ev1(d.rel, h)], confidence: h.confidence, source: h.basis === 'type' ? 'mechanical' : 'heuristic', label: 'cited', supply: 'agent-drafted', needs_pm: true,
725
+ proposed: { becomes: 'decision record (case, chose, why)', where: recordsDir + '/decisions.jsonl', summary: h.level === 'doc' ? `The document ${d.rel} is a decision (${h.basis === 'type' ? 'its type says so' : 'its name or folder says so'}). An agent drafts case, chose and why from it; the PM confirms.` : `A Decision heading in ${d.rel}: an agent reads what follows it and drafts the record.` },
726
+ detail: { file: d.rel, level: h.level, basis: h.basis, followups } }));
727
+ }
728
+ }
729
+ totals.decisions = decTotal;
730
+
731
+ // — people (structured places only) —
732
+ const people = new Map();
733
+ for (const bp of ((brief && brief.people) || [])) { const name = cleanName(bp.name); if (!name) continue; people.set(name.toLowerCase(), { name, roles: new Set(bp.role ? [bp.role] : ['named in the brief']), evidence: [{ file: '(brief)', line: 0, quote: 'brief: ' + name + (bp.role ? ', ' + bp.role : '') }], confidence: 'high' }); }
734
+ for (const d of nonReserved) {
735
+ if (skipDoc(d)) continue;
736
+ for (const p of extractPeople(d.text)) {
737
+ const k = p.name.toLowerCase(); if (!people.has(k)) people.set(k, { name: p.name, roles: new Set(), evidence: [], confidence: p.confidence });
738
+ const x = people.get(k); x.roles.add(p.role); if (p.confidence === 'high') x.confidence = 'high';
739
+ if (x.evidence.length < 3) x.evidence.push({ file: d.rel, line: p.line, quote: p.quote });
740
+ }
741
+ }
742
+ totals.people = people.size;
743
+ for (const [, x] of [...people.entries()].sort()) {
744
+ cands.push(mk({ kind: 'PERSON', key: x.name.toLowerCase(), title: x.name, evidence: x.evidence, confidence: x.confidence, source: 'mechanical', label: 'cited', supply: 'mechanical-transform', needs_pm: true,
745
+ proposed: { becomes: 'person (stakeholder)', where: 'people list (me/people or the domain stakeholders)', summary: `${x.name}, named in a structured field (${[...x.roles].join(', ')}). The PM links the name to an identity; a name is never guessed from prose.` }, detail: { roles: [...x.roles] } }));
746
+ }
747
+
748
+ // — risks and open questions —
749
+ const rk = []; for (const d of nonReserved) { if (skipDoc(d) || /\/templates?\//.test(d.rel)) continue; for (const h of extractRisks(d.text)) rk.push({ d, h }); }
750
+ totals.risks = rk.filter((x) => x.h.kind === 'risk').length; totals.open_questions = rk.filter((x) => x.h.kind === 'open-question').length;
751
+ const perDoc = new Map();
752
+ for (const x of rk) {
753
+ const n = (perDoc.get(x.d.rel) || 0) + 1; perDoc.set(x.d.rel, n); if (n > 8) continue;
754
+ const r = x.h.kind === 'risk';
755
+ cands.push(mk({ kind: r ? 'RISK' : 'OPEN_QUESTION', key: x.h.line + '', title: x.h.quote.replace(/^[\s>*+\-\d.)|]+/, '').replace(/\*+/g, ''), evidence: [ev1(x.d.rel, x.h)], confidence: x.h.confidence, source: 'heuristic', label: 'reported', supply: 'agent-drafted', needs_pm: true,
756
+ proposed: { becomes: r ? 'risk note (issue) on the initiative that owns this document' : 'open question on the initiative that owns this document', where: recordsDir + '/../issues.jsonl', summary: `${r ? (x.h.blocked ? 'A blocker' : 'A risk') : 'An open question'} reported in ${x.d.rel}${x.h.heading ? ' under "' + x.h.heading.replace(/^#+\s*/, '') + '"' : ''}.` },
757
+ detail: { heading: x.h.heading, basis: x.h.basis, blocked: !!x.h.blocked, file: x.d.rel } }));
758
+ }
759
+
760
+ // — stale documents —
761
+ const refsByRecord = new Map(); // doc path -> open initiative ids that cite it
762
+ for (const r of live) { if (!r.open) continue; for (const p of recordRefs(d0(docs, r.rel), r.rel, fileSet)) { if (!refsByRecord.has(p)) refsByRecord.set(p, []); refsByRecord.get(p).push(r.id); } }
763
+ const stale = [];
764
+ if (git.available) for (const d of nonReserved) {
765
+ if (skipDoc(d) || /\/archive\//.test(d.rel) || recByRel.has(d.rel)) continue;
766
+ const last = git.dates.get(d.rel); if (!last) continue;
767
+ const age = -daysBetween(last, today); if (age > STALE_DAYS) stale.push({ d, last, age });
768
+ }
769
+ totals.stale = stale.length; totals.git_history = git.available;
770
+ for (const x of stale) {
771
+ cands.push(mk({ kind: 'STALE', key: x.last, title: `${x.d.rel} untouched for ${x.age} days`, evidence: [{ file: x.d.rel, line: 0, quote: `git last commit ${x.last} (${x.age} days before ${today})` }], confidence: 'high', source: 'mechanical', label: 'fact', supply: 'derived', needs_pm: false,
772
+ proposed: { becomes: 'stale flag (frontmatter timestamp stays; no change to the file)', where: x.d.rel + ' (flag only)', summary: `Last commit ${x.last}, ${x.age} days ago. The PM keeps, refreshes or retires it.` },
773
+ detail: { last_commit: x.last, age_days: x.age, cited_by: refsByRecord.get(x.d.rel) || [] } }));
774
+ }
775
+
776
+ // — duplicates: identical content, or the same title in two places —
777
+ const byHash = new Map();
778
+ for (const e of entries) { if (e.class === 'kit' || !e.sha256 || e.size < 20 || /(^|\/)\.gitkeep$/.test(e.from) || isReserved(e.from)) continue; if (!byHash.has(e.sha256)) byHash.set(e.sha256, []); byHash.get(e.sha256).push(e.from); }
779
+ const dupGroups = [];
780
+ for (const [h, files] of [...byHash.entries()].sort()) if (files.length > 1) dupGroups.push({ basis: 'identical content (sha256 ' + h.slice(0, 12) + ')', files: files.sort(), confidence: 'high' });
781
+ const inHash = new Set(dupGroups.flatMap((g) => g.files));
782
+ const GENERIC = new Set(['readme', 'index', 'overview', 'changelog', 'notes', 'untitled', 'template', 'summary', 'introduction']);
783
+ const byTitle = new Map();
784
+ for (const d of nonReserved) { if (d.entry.class === 'kit' || inHash.has(d.rel)) continue; const t = docTitle(d.text); if (!t) continue; const k = t.title.toLowerCase().replace(/\s+/g, ' ').trim(); if (k.length < 8 || GENERIC.has(k)) continue; if (!byTitle.has(k)) byTitle.set(k, []); byTitle.get(k).push({ rel: d.rel, line: t.line || 1, title: t.title }); }
785
+ for (const [k, list] of [...byTitle.entries()].sort()) if (list.length > 1) dupGroups.push({ basis: 'the same title in two places', files: list.map((x) => x.rel).sort(), confidence: 'medium', titled: list });
786
+ totals.duplicates = dupGroups.length;
787
+ for (const g of dupGroups) {
788
+ const evid = g.files.slice(0, 4).map((f) => { const d = docs.find((x) => x.rel === f); const t = d ? docTitle(d.text) : null; return { file: f, line: t && t.line ? t.line : 1, quote: t ? t.title : f }; });
789
+ cands.push(mk({ kind: 'DUPLICATE', key: g.files.join(','), title: `${g.files.length} copies: ${g.files[0]}`, evidence: evid, confidence: g.confidence, source: 'mechanical', label: 'fact', supply: 'derived', needs_pm: true,
790
+ proposed: { becomes: 'duplicate flag (no file is deleted or merged)', where: g.files.join(', '), summary: `${g.basis}: ${g.files.join(', ')}. The PM names the canonical one.` }, detail: { basis: g.basis, files: g.files } }));
791
+ }
792
+
793
+ // — broken links (the classifier already found them) —
794
+ const brk = (m.already_broken_links || []).filter((b) => { const e = byFrom.get(b.file); return e && e.class !== 'kit'; });
795
+ totals.broken_links = brk.length;
796
+ const seenBrk = new Set();
797
+ for (const b of brk) {
798
+ const k = b.file + '|' + b.href; if (seenBrk.has(k)) continue; seenBrk.add(k);
799
+ const d = docs.find((x) => x.rel === b.file); const lines = d ? d.text.split('\n') : []; let li = lines.findIndex((l) => l.includes(b.href)); li = li < 0 ? 0 : li;
800
+ cands.push(mk({ kind: 'BROKEN_LINK', key: b.href, title: `${b.file} links to ${b.href}`, evidence: [{ file: b.file, line: li + 1, quote: lines[li] || b.href }], confidence: 'high', source: 'mechanical', label: 'fact', supply: 'derived', needs_pm: false,
801
+ proposed: { becomes: 'link repair list', where: b.file + ' (no edit in this slice)', summary: `The ${b.kind} link ${b.href} points at no file in this folder.` }, detail: { href: b.href, link_kind: b.kind } }));
802
+ }
803
+
804
+ // — documents and the frontmatter each would gain —
805
+ let docsCount = 0; let docsWithFm = 0;
806
+ for (const d of nonReserved) {
807
+ const e = d.entry; if (e.class === 'kit') continue;
808
+ docsCount++;
809
+ const add = e.frontmatter_add || {}; const gains = Object.keys(add);
810
+ if (!gains.length) { docsWithFm++; continue; }
811
+ const t = docTitle(d.text);
812
+ cands.push(mk({ kind: 'DOCUMENT', key: d.rel, title: (t && t.title) || stem(d.rel), evidence: [{ file: d.rel, line: 1, quote: (t && t.title) || stem(d.rel) }], confidence: (e.notes || []).length ? 'medium' : 'high', source: 'mechanical', label: 'fact', supply: 'mechanical-transform', needs_pm: false,
813
+ proposed: { becomes: 'frontmatter added (the body is never edited)', where: d.rel + ' (in place)', summary: `Gains ${gains.join(', ')}.`, frontmatter_add: add },
814
+ detail: { category: e.category || null, owner_suggestion: e.suggested_owner || [], notes: e.notes || [] } }));
815
+ }
816
+ for (const e of entries) if (e.action === 'sidecar') {
817
+ totals.attachments++;
818
+ cands.push(mk({ kind: 'DOCUMENT', key: e.from, title: path.posix.basename(e.from), evidence: [{ file: e.from, line: 0, quote: `${path.extname(e.from)} file, ${e.size} bytes (not read; a sidecar note would carry the frontmatter)` }], confidence: 'high', source: 'mechanical', label: 'fact', supply: 'mechanical-transform', needs_pm: false,
819
+ proposed: { becomes: 'sidecar note', where: e.sidecar_to, summary: 'The file stays where it is; a sidecar .md beside it carries type, title and timestamp.', frontmatter_add: e.sidecar_frontmatter || {} }, detail: { attachment: true } }));
820
+ }
821
+ totals.documents = docsCount; totals.documents_with_frontmatter = docsWithFm;
822
+
823
+ // — sort, cap, report what the cap hid —
824
+ const byKind = {}; for (const k of KINDS) byKind[k] = [];
825
+ for (const c of cands) byKind[c.kind].push(c);
826
+ const candidates = []; const capped = {};
827
+ for (const k of KINDS) {
828
+ const list = byKind[k].sort((a, b) => CONF_RANK[a.confidence] - CONF_RANK[b.confidence] || (a.evidence[0].file < b.evidence[0].file ? -1 : a.evidence[0].file > b.evidence[0].file ? 1 : a.evidence[0].line - b.evidence[0].line) || (a.id < b.id ? -1 : 1));
829
+ capped[k] = { total: list.length, shown: Math.min(list.length, caps[k]) };
830
+ candidates.push(...list.slice(0, caps[k]));
831
+ }
832
+ if (brief) for (const c of candidates) {
833
+ const f = c.evidence[0] && c.evidence[0].file; const src = f && f !== '(brief)' ? rules.sourceFor(f) : null;
834
+ if (src && src.sensitivity) { c.detail.sensitivity = src.sensitivity; if (src.sensitivity === 'personal') for (const e of c.evidence) e.quote = '[personal source: quote withheld; read ' + e.file + (e.line ? ':' + e.line : '') + ']'; }
835
+ }
836
+ const skippedByRule = m.skipped_by_rule || [];
837
+ const briefApplied = brief ? {
838
+ skipped_files: skippedByRule.reduce((n, x) => n + x.files, 0),
839
+ skipped_by_reason: skippedByRule.reduce((a, x) => { const k = x.reason.replace(/:.*$/, '') + (/^doNotRead/.test(x.reason) ? ': ' + x.reason.slice(11) : ''); a[k] = (a[k] || 0) + x.files; return a; }, {}),
840
+ skipped: skippedByRule.slice(0, 100), unscannable_sources: rules.unscannable,
841
+ honoured: ['sources[folder,repo].ref/include/exclude/sensitivity', 'doNotRead', 'vocabulary.initiative', 'vocabulary.goal', 'vocabulary.fiscalYearStartMonth (and quarterStart)', 'vocabulary.quarterNames', 'knownInitiatives', 'horizonDays', 'people', 'goals (echoed to first-look)'],
842
+ goals: brief.goals || [], vocabulary: { initiative: vocab.initiative || [], goal: vocab.goal || [], fiscalYearStartMonth: fyStart, quarterNames: vocab.quarterNames || [] },
843
+ } : null;
844
+ const scanId = sha1(root + '|' + (opts.at || new Date().toISOString())).slice(0, 12);
845
+ return {
846
+ schema: 'pmos-onboarding-scan/1', scan_id: scanId, root, tenant_name: path.basename(root), domain: m.domain, today, scanned_at: opts.at || new Date().toISOString(),
847
+ read_only: true, layout,
848
+ classes: { by_class: m.summary.by_class, by_action: m.summary.by_action, mode: m.mode, strategy: m.strategy, kit_dir: m.summary.kit_dir, files: m.summary.files, skipped: m.skipped.length },
849
+ totals, capped, candidates, unread, git_history: git.available, windows: win, brief: briefApplied,
850
+ };
851
+ }
852
+ // the text of a doc by path (records were already read into `docs`)
853
+ function d0(docs, rel) { const d = docs.find((x) => x.rel === rel); return d ? d.text : ''; }
854
+
855
+ module.exports = {
856
+ scan, KINDS, DEFAULT_CAPS, quote, fmInfo, classifyDeadline, parseDates, parseMetric, cleanName, isOpenStatus,
857
+ briefRules, extractVocabInitiatives, extractGoalWords, matchKnown, monthNumber, fiscalQuarterEnd,
858
+ extractInitiativeRecord, extractDeclaredInitiativeId, extractJiraEpics, extractOkr, extractDeadlines, extractDecisions, extractPeople, extractRisks, docTitle, recordRefs,
859
+ };