@sdd-method/author-cli 0.9.0 → 0.11.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,670 @@
1
+ // `migrate intents` (M-ADR 0227 §5.2): turn an adopter's Ideas and Epic Briefs into intents, one per request id.
2
+ //
3
+ // An Idea and its Epic Brief are one request at two stages, so they make one intent. The intent takes the
4
+ // originator's words from the Idea where it has them and the Brief's otherwise (a request with no Idea takes
5
+ // its outcome from the Brief's Scope); the Brief's other elaboration (evidence, handoff, exit criteria) is not
6
+ // copied, because the artefacts it produced hold it. The method shipped no Brief template, so Brief headings
7
+ // are matched by name, and the report lists every heading the converter did not recognise. Its
8
+ // "Where it went" is rebuilt from evidence, each line saying which source supports it:
9
+ // cited a path the Idea or Brief names that is a file the SDD owns in its working tree (a repository-
10
+ // relative path, or a blob URL whose repository is one of the SDDs);
11
+ // mentioned a tracked file in an SDD that names the request id, unless the file names many request ids
12
+ // (an index lists requests; it is not where one went);
13
+ // titled a tracked file whose title names the request id (an application's own Epic Brief): it is
14
+ // about that request whatever else it cites, so it counts even when it names many ids, and it
15
+ // weighs more than any other evidence in choosing the intent's home;
16
+ // commit a commit in an SDD whose message names the id, unless its subject moves, renames, archives or
17
+ // marks the Ideas and Briefs themselves. When it names at most a few ids (a plan
18
+ // bundle delivers several), the files it changed that the SDD still owns are evidence too
19
+ // (committed, naming the other ids it shares with), unless it touched so many that it was a sweep;
20
+ // plan a plan file (from --plans) that names the id. A plan says what was planned, not what shipped,
21
+ // so an id whose only evidence is a plan is flagged for a check that the work landed. A plan
22
+ // naming at most a few ids also contributes the paths it cites that an SDD owns now (planned).
23
+ // With no evidence, an intent goes to the platform among the SDDs (the first one named when none is a
24
+ // platform), and is flagged. A path that moved since it was cited or committed is followed through the SDD's git renames to where it is
25
+ // now. A reviewer's decisions are given back as --home ID=<sdd-id> and --status ID=open|closed.
26
+ // Planning state (status, priority, relations) goes to a tool-neutral work-item import file, never into the
27
+ // intent (M-ADR 0227 §4.3, M-ADR 0035). A dry run writes the report, the import file, the citation table and
28
+ // the intents, all under --out; --write writes the intents into the SDDs instead, never over an existing file.
29
+ import { execFile } from "node:child_process";
30
+ import { existsSync, readFileSync, statSync } from "node:fs";
31
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
32
+ import { basename, dirname, join, relative, resolve } from "node:path";
33
+ import { promisify } from "node:util";
34
+ import { parseDocument } from "yaml";
35
+ import { normaliseHeading, parseMarkdown } from "./format.js";
36
+ const run = promisify(execFile);
37
+ const ID_TOKEN = /\b[A-Z][A-Z0-9]*-\d+\b/g;
38
+ const CLOSED = new Set(["completed", "complete", "done", "delivered", "shipped", "closed"]);
39
+ const DECLINED = new Set(["dropped", "rejected", "declined", "obsolete", "withdrawn"]);
40
+ const IDEA_MAP_IDS = /\b[A-Z][A-Z0-9]*-\d+\b/g;
41
+ const NOT_OWN = /^(docs\/method\/|catalogues|\.sdd\/|openspec\/schemas\/|content\/(method|product)\/intents\/)/;
42
+ /** A commit changing more owned files than this was a sweep (a rename, a reformat), not a delivery. */
43
+ const SWEEP = 25;
44
+ /** A commit or plan naming more request ids than this is not evidence for any one of them. */
45
+ const SHARED = 3;
46
+ /** How much a file titled with the id weighs in choosing a home, against one for any other evidence line. */
47
+ const TITLED_WEIGHT = 100;
48
+ const HOUSEKEEPING = /\b(move[sd]?|moving|rename[sd]?|renaming|archive[sd]?|mark(s|ed)?|retire[sd]?|close[sd]?|complete[sd]?)\b.*\b(idea|ideas|idea-map|epic|epics|brief|briefs)\b|\b(idea|ideas|idea-map|epic|epics|brief|briefs)\b.*\b(moved|renamed|archived|marked|retired|closed|completed)\b/i;
49
+ async function walk(dir, accept) {
50
+ const out = [];
51
+ const go = async (d) => {
52
+ let entries;
53
+ try {
54
+ entries = await readdir(d, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ for (const e of entries) {
60
+ if (e.name === ".git" || e.name === "node_modules")
61
+ continue;
62
+ const p = join(d, e.name);
63
+ if (e.isDirectory())
64
+ await go(p);
65
+ else if (e.isFile() && accept(p))
66
+ out.push(p);
67
+ }
68
+ };
69
+ await go(dir);
70
+ return out.sort();
71
+ }
72
+ async function gitTop(dir) {
73
+ try {
74
+ return (await run("git", ["-C", dir, "rev-parse", "--show-toplevel"])).stdout.trim();
75
+ }
76
+ catch {
77
+ return dir;
78
+ }
79
+ }
80
+ async function gitHead(dir) {
81
+ try {
82
+ return (await run("git", ["-C", dir, "rev-parse", "--short", "HEAD"])).stdout.trim();
83
+ }
84
+ catch {
85
+ return "uncommitted";
86
+ }
87
+ }
88
+ async function loadSdd(dir) {
89
+ let id = basename(dir);
90
+ try {
91
+ const m = parseDocument(await readFile(join(dir, ".sdd/sdd-manifest.yaml"), "utf8")).toJS();
92
+ if (typeof m?.sdd_id === "string" && m.sdd_id)
93
+ id = m.sdd_id;
94
+ }
95
+ catch {
96
+ // no manifest: the directory name
97
+ }
98
+ const repoNames = new Set([basename(dir)]);
99
+ try {
100
+ const url = (await run("git", ["-C", dir, "remote", "get-url", "origin"])).stdout.trim();
101
+ repoNames.add(basename(url).replace(/\.git$/, ""));
102
+ }
103
+ catch {
104
+ // no remote
105
+ }
106
+ const renames = new Map();
107
+ try {
108
+ const { stdout } = await run("git", ["-C", dir, "log", "--format=", "--name-status", "-M", "--diff-filter=R"], { maxBuffer: 256 * 1024 * 1024 });
109
+ for (const line of stdout.split("\n")) {
110
+ const [st, from, to] = line.split("\t");
111
+ if (st?.startsWith("R") && from && to && !renames.has(from))
112
+ renames.set(from, to);
113
+ }
114
+ }
115
+ catch {
116
+ // not a git repository
117
+ }
118
+ let kind = "";
119
+ try {
120
+ kind = (await readFile(join(dir, ".sdd-repo-kind"), "utf8")).trim();
121
+ }
122
+ catch {
123
+ // no marker
124
+ }
125
+ return { dir, id, repoNames, managed: await managedPaths(dir), renames, kind };
126
+ }
127
+ function readSource(path, text) {
128
+ const doc = parseMarkdown(text);
129
+ // The title decides first, then the id field, then the folder: a Brief often repeats its Idea's id field.
130
+ const t = doc.title.toLowerCase();
131
+ const kind = t.startsWith("epic brief")
132
+ ? "brief"
133
+ : t.startsWith("idea")
134
+ ? "idea"
135
+ : doc.fields.has("Epic ID")
136
+ ? "brief"
137
+ : /(^|\/)epics\//.test(path)
138
+ ? "brief"
139
+ : doc.fields.has("Idea ID") || /(^|\/)ideas\//.test(path)
140
+ ? "idea"
141
+ : null;
142
+ if (!kind)
143
+ return null;
144
+ const id = doc.fields.get("Idea ID") ?? doc.fields.get("Epic ID") ?? /^([A-Z][A-Z0-9]*-\d+)/.exec(basename(path))?.[1];
145
+ if (!id)
146
+ return null;
147
+ return { kind, path, id: id.replace(/`/g, "").trim(), doc };
148
+ }
149
+ function titleOf(f) {
150
+ return f.doc.title
151
+ .replace(/^(idea|epic brief)\s*:\s*/i, "")
152
+ .replace(new RegExp(`^${f.id}\\s*[—–:-]\\s*`), "")
153
+ .trim();
154
+ }
155
+ function section(f, ...names) {
156
+ if (!f)
157
+ return "";
158
+ for (const n of names) {
159
+ for (const [k, v] of f.doc.sections)
160
+ if ((k === n || k.startsWith(`${n} `)) && v)
161
+ return v;
162
+ }
163
+ return "";
164
+ }
165
+ /** Every section whose normalised heading passes the test, in order. */
166
+ function matching(f, test) {
167
+ if (!f)
168
+ return [];
169
+ return [...f.doc.sections.entries()].filter(([h, v]) => test(h) && v).map(([, v]) => v);
170
+ }
171
+ const isGuardrails = (h) => h.startsWith("guardrails");
172
+ function idsIn(s) {
173
+ return s ? [...new Set(s.match(IDEA_MAP_IDS) ?? [])] : [];
174
+ }
175
+ /** Required | Optional | None, keeping the source's own words after it. */
176
+ function adrRequirement(raw) {
177
+ const v = raw.trim();
178
+ if (!v)
179
+ return "None (not recorded in the source).";
180
+ const w = v.split(/[\s—–(-]/)[0].toLowerCase();
181
+ const norm = w === "required" || w === "likely" ? "Required" : w === "optional" ? "Optional" : w === "none" || w === "unlikely" ? "None" : null;
182
+ return norm ? `${norm}. Source: ${v}` : `${v} (not one of Required, Optional or None: review)`;
183
+ }
184
+ const KNOWN_BRIEF = [
185
+ "problem statement",
186
+ "decision to resolve",
187
+ "scope",
188
+ "resolved and open questions",
189
+ "resolved vs open",
190
+ "resolved vs open questions",
191
+ "dependencies and constraints",
192
+ "dependencies",
193
+ "evidence and draft artifacts",
194
+ "planning handoff targets",
195
+ "planning handoff",
196
+ "exit criteria for planning readiness",
197
+ "exit criteria",
198
+ ];
199
+ const KNOWN_IDEA = [
200
+ "problem or opportunity",
201
+ "idea summary",
202
+ "why explore this",
203
+ "open questions",
204
+ "early impact notes",
205
+ "explore next",
206
+ "related ideas",
207
+ "exit criteria to become an epic brief",
208
+ ];
209
+ function readIdeaMap(text) {
210
+ const rows = new Map();
211
+ let cols = null;
212
+ for (const line of text.split("\n")) {
213
+ if (!line.trim().startsWith("|")) {
214
+ cols = null;
215
+ continue;
216
+ }
217
+ const cells = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
218
+ if (cells.every((c) => /^:?-+:?$/.test(c)))
219
+ continue;
220
+ if (!cols) {
221
+ cols = cells.map((c) => c.toLowerCase());
222
+ continue;
223
+ }
224
+ const at = (name) => {
225
+ const i = cols.indexOf(name);
226
+ return i >= 0 ? cells[i] : undefined;
227
+ };
228
+ const id = at("id");
229
+ if (!id || !/^[A-Z][A-Z0-9]*-\d+$/.test(id))
230
+ continue;
231
+ rows.set(id, { status: at("status"), priority: at("priority"), depends: idsIn(at("depends on")) });
232
+ }
233
+ return rows;
234
+ }
235
+ const PATHISH = /`([^`\s][^`]*\/[^`]*)`/g;
236
+ const LINK = /\]\(([^)\s]+)\)/g;
237
+ const BLOB = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/(?:blob|tree)\/[^/]+\/(.+)$/;
238
+ /** A file the SDD owns: not a directory, not a method path, not a synced copy, not generated or migrated output. */
239
+ function owned(s, p) {
240
+ if (s.managed.has(p) || NOT_OWN.test(p))
241
+ return false;
242
+ try {
243
+ return statSync(join(s.dir, p)).isFile();
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
249
+ /** Where a path is now: itself if the SDD owns it, else followed through the SDD's renames; null if neither. */
250
+ function current(s, p) {
251
+ const seen = new Set();
252
+ let q = p;
253
+ while (!owned(s, q)) {
254
+ const next = s.renames.get(q);
255
+ if (!next || seen.has(q))
256
+ return null;
257
+ seen.add(q);
258
+ q = next;
259
+ }
260
+ return q;
261
+ }
262
+ function citedPaths(text, sdds) {
263
+ const out = [];
264
+ const consider = (raw) => {
265
+ let t = raw.trim();
266
+ try {
267
+ t = decodeURIComponent(t);
268
+ }
269
+ catch {
270
+ // a literal % that is not an escape: keep the text as written
271
+ }
272
+ t = t.replace(/[#?].*$/, "").replace(/:\d+(-\d+)?$/, "");
273
+ const blob = BLOB.exec(t);
274
+ if (blob) {
275
+ const s = sdds.find((x) => x.repoNames.has(blob[1]));
276
+ const now = s ? current(s, blob[2]) : null;
277
+ if (s && now)
278
+ out.push({ sdd: s.id, path: now });
279
+ return;
280
+ }
281
+ if (/^[a-z]+:/i.test(t) || t.startsWith("/") || t.startsWith(".."))
282
+ return;
283
+ for (const s of sdds) {
284
+ const now = current(s, t);
285
+ if (now)
286
+ out.push({ sdd: s.id, path: now });
287
+ }
288
+ };
289
+ for (const m of text.matchAll(PATHISH))
290
+ consider(m[1]);
291
+ for (const m of text.matchAll(LINK))
292
+ consider(m[1]);
293
+ return out;
294
+ }
295
+ /** Paths every bundle in the SDD's lock manages: synced copies (method files, context-pack ADRs), not its own content. */
296
+ async function managedPaths(dir) {
297
+ try {
298
+ const text = await readFile(join(dir, "method-baseline.lock"), "utf8");
299
+ return new Set([...text.matchAll(/^\s+-\s+[0-9a-f]{64}\s{2}(.+?)\s*$/gm)].map((m) => m[1]));
300
+ }
301
+ catch {
302
+ return new Set();
303
+ }
304
+ }
305
+ async function trackedText(dir, managed) {
306
+ try {
307
+ const { stdout } = await run("git", ["-C", dir, "ls-files", "-z", "--", "*.md", "*.yaml", "*.yml", "*.feature"], {
308
+ maxBuffer: 64 * 1024 * 1024,
309
+ });
310
+ return stdout
311
+ .split("\0")
312
+ .filter((p) => p && !managed.has(p) && !NOT_OWN.test(p));
313
+ }
314
+ catch {
315
+ return [];
316
+ }
317
+ }
318
+ export async function migrateIntents(opts) {
319
+ const sdds = await Promise.all(opts.sdds.map((d) => loadSdd(resolve(d))));
320
+ if (sdds.length === 0)
321
+ throw new Error("name at least one SDD (--sdd)");
322
+ const defaultHome = sdds.find((s) => s.kind === "platform-product") ?? sdds[0];
323
+ // 1. Sources, grouped by request id.
324
+ const byId = new Map();
325
+ const unrecognised = [];
326
+ const sourceRepos = new Map();
327
+ for (const dir of opts.sources) {
328
+ const top = await gitTop(resolve(dir));
329
+ sourceRepos.set(resolve(dir), { top, head: await gitHead(top) });
330
+ for (const p of await walk(resolve(dir), (x) => x.endsWith(".md") && basename(x).toLowerCase() !== "readme.md")) {
331
+ if (opts.ideaMap && resolve(p) === resolve(opts.ideaMap))
332
+ continue;
333
+ const f = readSource(p, await readFile(p, "utf8"));
334
+ if (!f) {
335
+ unrecognised.push(p);
336
+ continue;
337
+ }
338
+ const e = byId.get(f.id) ?? {};
339
+ if (e[f.kind])
340
+ unrecognised.push(`${p} (a second ${f.kind} for ${f.id}; the first is kept)`);
341
+ else
342
+ e[f.kind] = f;
343
+ byId.set(f.id, e);
344
+ }
345
+ }
346
+ const ids = new Set(byId.keys());
347
+ const map = opts.ideaMap ? readIdeaMap(await readFile(opts.ideaMap, "utf8")) : new Map();
348
+ // 2. Evidence.
349
+ const ev = new Map([...ids].map((i) => [i, { cited: [], mentioned: [], titled: [], committed: [], planned: [], commits: [], plans: [] }]));
350
+ const indexFiles = [];
351
+ for (const [id, e] of byId) {
352
+ const text = [e.idea, e.brief].filter(Boolean).map((f) => readFileSyncSafe(f.path)).join("\n");
353
+ const seen = new Set();
354
+ for (const c of citedPaths(text, sdds)) {
355
+ const k = `${c.sdd}\0${c.path}`;
356
+ if (seen.has(k))
357
+ continue;
358
+ seen.add(k);
359
+ ev.get(id).cited.push(c);
360
+ }
361
+ }
362
+ for (const s of sdds) {
363
+ for (const rel of await trackedText(s.dir, s.managed)) {
364
+ let text;
365
+ try {
366
+ text = await readFile(join(s.dir, rel), "utf8");
367
+ }
368
+ catch {
369
+ continue;
370
+ }
371
+ const hits = [...new Set((text.match(ID_TOKEN) ?? []).filter((t) => ids.has(t)))];
372
+ if (hits.length === 0)
373
+ continue;
374
+ // A file titled with a request id is about that request, however many others it cites.
375
+ const title = /^#\s+(.+)$/m.exec(text)?.[1] ?? "";
376
+ const titled = (title.match(ID_TOKEN) ?? []).filter((t) => ids.has(t));
377
+ for (const id of titled)
378
+ ev.get(id).titled.push({ sdd: s.id, path: rel });
379
+ if (hits.length > opts.indexThreshold) {
380
+ indexFiles.push(`${s.id}: ${rel} (${hits.length} ids)`);
381
+ continue;
382
+ }
383
+ for (const id of hits) {
384
+ if (titled.includes(id))
385
+ continue;
386
+ const e = ev.get(id);
387
+ if (!e.cited.some((c) => c.sdd === s.id && c.path === rel))
388
+ e.mentioned.push({ sdd: s.id, path: rel });
389
+ }
390
+ }
391
+ try {
392
+ const { stdout } = await run("git", ["-C", s.dir, "log", "--name-only", "--format=%x1e%h%x09%s%x09%b%x1f"], { maxBuffer: 256 * 1024 * 1024 });
393
+ for (const rec of stdout.split("\x1e")) {
394
+ const [head, names = ""] = rec.split("\x1f");
395
+ const [sha, subject = "", ...rest] = (head ?? "").trim().split("\t");
396
+ if (!sha)
397
+ continue;
398
+ // A commit that only kept the Ideas and Briefs themselves (moved, renamed, marked done) is not where one went.
399
+ if (HOUSEKEEPING.test(subject))
400
+ continue;
401
+ const named = [...new Set(`${subject} ${rest.join(" ")}`.match(ID_TOKEN) ?? [])].filter((i) => ids.has(i));
402
+ for (const id of named)
403
+ ev.get(id).commits.push({ sdd: s.id, sha, subject });
404
+ // Its files are evidence when it names at most a few requests, and was not a sweep.
405
+ if (named.length === 0 || named.length > SHARED)
406
+ continue;
407
+ const files = [...new Set(names.split("\n").map((n) => n.trim()).filter(Boolean).map((n) => current(s, n)).filter((n) => n !== null))];
408
+ if (files.length === 0 || files.length > SWEEP)
409
+ continue;
410
+ for (const id of named) {
411
+ const e = ev.get(id);
412
+ for (const f of files) {
413
+ if (!e.committed.some((c) => c.sdd === s.id && c.path === f))
414
+ e.committed.push({ sdd: s.id, path: f, sha, with: named.filter((n) => n !== id) });
415
+ }
416
+ }
417
+ }
418
+ }
419
+ catch {
420
+ // not a git repository
421
+ }
422
+ }
423
+ for (const dir of opts.plans) {
424
+ const top = await gitTop(resolve(dir));
425
+ for (const p of await walk(resolve(dir), (x) => x.endsWith(".md"))) {
426
+ const text = readFileSyncSafe(p);
427
+ const named = [...new Set((text.match(ID_TOKEN) ?? []).filter((t) => ids.has(t)))];
428
+ const where = relative(top, p);
429
+ const paths = named.length > 0 && named.length <= SHARED ? citedPaths(text, sdds) : [];
430
+ for (const id of named) {
431
+ const e = ev.get(id);
432
+ e.plans.push({ repo: basename(top), path: where });
433
+ for (const c of paths) {
434
+ if (!e.planned.some((x) => x.sdd === c.sdd && x.path === c.path))
435
+ e.planned.push({ ...c, plan: where, with: named.filter((n) => n !== id) });
436
+ }
437
+ }
438
+ }
439
+ }
440
+ // 3. Intents, work items and citations.
441
+ const flags = new Map();
442
+ const notCarried = new Map();
443
+ const flag = (id, why) => flags.set(id, [...(flags.get(id) ?? []), why]);
444
+ const items = [];
445
+ const citations = ["id\tkind\tsource_repository\tsource_path\ttarget_sdd\ttarget_path"];
446
+ const written = [];
447
+ const skippedExisting = [];
448
+ const intents = [];
449
+ for (const id of [...ids].sort(compareIds)) {
450
+ const { idea, brief } = byId.get(id);
451
+ const e = ev.get(id);
452
+ const primary = (idea ?? brief);
453
+ const fields = (k) => idea?.doc.fields.get(k) ?? brief?.doc.fields.get(k) ?? "";
454
+ for (const f of [idea, brief]) {
455
+ if (!f)
456
+ continue;
457
+ const known = f.kind === "idea" ? KNOWN_IDEA : KNOWN_BRIEF;
458
+ const extra = f.doc.headings
459
+ .map(normaliseHeading)
460
+ .filter((h) => !known.some((k) => h === k || h.startsWith(`${k} `)) && !h.includes("decision questions") && h !== "impact" && !isGuardrails(h));
461
+ for (const h of extra)
462
+ notCarried.set(`${f.kind}: ${h}`, (notCarried.get(`${f.kind}: ${h}`) ?? 0) + 1);
463
+ }
464
+ // Mentioned and committed paths already cited are listed once, as cited.
465
+ const has = (l, x) => l.some((c) => c.sdd === x.sdd && c.path === x.path);
466
+ e.titled = e.titled.filter((x) => !has(e.cited, x));
467
+ e.mentioned = e.mentioned.filter((x) => !has(e.cited, x) && !has(e.titled, x));
468
+ e.committed = e.committed.filter((x) => !has(e.cited, x) && !has(e.titled, x) && !has(e.mentioned, x));
469
+ e.planned = e.planned.filter((x) => !has(e.cited, x) && !has(e.titled, x) && !has(e.mentioned, x) && !has(e.committed, x));
470
+ // Home: the SDD most of the evidence is in, unless a reviewer set it.
471
+ const count = new Map();
472
+ for (const x of [...e.cited, ...e.mentioned, ...e.committed, ...e.planned])
473
+ count.set(x.sdd, (count.get(x.sdd) ?? 0) + 1);
474
+ // A file titled with the id outweighs the rest: an application's own Epic Brief places its request there.
475
+ for (const x of e.titled)
476
+ count.set(x.sdd, (count.get(x.sdd) ?? 0) + TITLED_WEIGHT);
477
+ const ranked = [...count.entries()].sort((a, b) => b[1] - a[1]);
478
+ let home = defaultHome;
479
+ const set = opts.homes?.get(id);
480
+ if (set) {
481
+ const s = sdds.find((x) => x.id === set);
482
+ if (s)
483
+ home = s;
484
+ else
485
+ flag(id, `--home names ${set}, which is not one of the SDDs`);
486
+ }
487
+ else if (ranked.length === 0) {
488
+ flag(id, e.plans.length || e.commits.length ? (e.commits.length ? "no artefact found; commits only" : "plan only: check that the work landed") : "no evidence found");
489
+ }
490
+ else {
491
+ if (ranked.length > 1 && ranked[0][1] === ranked[1][1])
492
+ flag(id, `home ambiguous: ${ranked.map(([s, n]) => `${s} ${n}`).join(", ")}`);
493
+ home = sdds.find((s) => s.id === ranked[0][0]);
494
+ if (e.cited.length + e.titled.length + e.mentioned.length + e.committed.length === 0)
495
+ flag(id, "planned paths only: check that the work landed");
496
+ }
497
+ const classification = (fields("Classification") || "").toLowerCase();
498
+ const isMethod = classification.startsWith("method") || /(^|\/)method\//.test(relative(sourceRoot(primary.path, sourceRepos), primary.path));
499
+ const dir = isMethod ? "content/method/intents" : "content/product/intents";
500
+ const path = `${dir}/${slug(primary.path, id)}`;
501
+ const where = [
502
+ ...e.cited.map((c) => `- \`${c.path}\` (${c.sdd}; cited)`),
503
+ ...e.titled.map((c) => `- \`${c.path}\` (${c.sdd}; titled)`),
504
+ ...e.mentioned.map((c) => `- \`${c.path}\` (${c.sdd}; mentioned)`),
505
+ ...e.committed.map((c) => `- \`${c.path}\` (${c.sdd}; committed in ${c.sha}${c.with.length ? ` with ${c.with.join(", ")}` : ""})`),
506
+ ...e.planned.map((c) => `- \`${c.path}\` (${c.sdd}; planned in ${c.plan}${c.with.length ? ` with ${c.with.join(", ")}` : ""})`),
507
+ ...e.commits.slice(0, 8).map((c) => `- commit \`${c.sha}\` in ${c.sdd}: ${c.subject}`),
508
+ ...e.plans.map((p) => `- plan \`${p.path}\` in ${p.repo}`),
509
+ ];
510
+ if (e.commits.length > 8)
511
+ where.push(`- ${e.commits.length - 8} more commit(s) name ${id}`);
512
+ const srcs = [idea, brief].filter(Boolean).map((f) => {
513
+ const r = sourceRepos.get(sourceDirOf(f.path, sourceRepos));
514
+ return { f: f, repo: basename(r.top), rel: relative(r.top, f.path), head: r.head };
515
+ });
516
+ const originator = `${fields("Owner") || "not recorded"}. Migrated from ${srcs.map((s) => `the ${s.f.kind === "idea" ? "Idea" : "Epic Brief"} \`${s.rel}\``).join(" and ")} in ${srcs[0].repo} at ${srcs[0].head}.`;
517
+ const outcome = [section(idea, "idea summary"), section(idea, "why explore this")].filter(Boolean).join("\n\n") || section(brief, "scope");
518
+ const open = [section(idea, "open questions"), ...matching(idea, (h) => h.includes("decision questions"))].filter(Boolean).join("\n\n") ||
519
+ section(brief, "resolved and open questions", "resolved vs open questions", "resolved vs open");
520
+ const affected = [section(idea, "early impact notes"), ...matching(idea, (h) => h === "impact")].filter(Boolean).join("\n\n");
521
+ const constraints = [section(brief, "dependencies and constraints", "dependencies"), ...matching(idea, isGuardrails), ...matching(brief, isGuardrails)]
522
+ .filter(Boolean)
523
+ .join("\n\n");
524
+ const decision = fields("Decision to Resolve in Exploration") || section(brief, "decision to resolve");
525
+ const body = [
526
+ `# Intent: ${titleOf(primary)}`,
527
+ "",
528
+ `**Work item:** ${id}`,
529
+ `**Captured:** ${fields("Created") || "not recorded"}`,
530
+ `**Originator:** ${originator}`,
531
+ "",
532
+ "## Problem",
533
+ "",
534
+ section(idea, "problem or opportunity") || section(brief, "problem statement") || "Not recorded in the source.",
535
+ "",
536
+ "## Proposed outcome",
537
+ "",
538
+ outcome || "Not recorded in the source.",
539
+ "",
540
+ "## Affected users and systems",
541
+ "",
542
+ affected || "Not recorded in the source.",
543
+ "",
544
+ "## Constraints",
545
+ "",
546
+ constraints || "Not recorded in the source.",
547
+ "",
548
+ "## Open questions",
549
+ "",
550
+ open || "None recorded.",
551
+ "",
552
+ "## Decision to resolve in exploration",
553
+ "",
554
+ decision || "None.",
555
+ "",
556
+ "## ADR requirement",
557
+ "",
558
+ adrRequirement(fields("ADR Requirement")),
559
+ "",
560
+ "## Where it went",
561
+ "",
562
+ ...(where.length ? where : ["- Nothing found: see the migration report."]),
563
+ "",
564
+ ].join("\n");
565
+ intents.push({ sdd: home, path, body });
566
+ for (const s of srcs)
567
+ citations.push([id, s.f.kind, s.repo, s.rel, home.id, path].join("\t"));
568
+ const row = map.get(id);
569
+ const statusRaw = row?.status || brief?.doc.fields.get("Status") || idea?.doc.fields.get("Status") || "";
570
+ const st = statusRaw.toLowerCase().replace(/[`*]/g, "").trim();
571
+ const reviewed = opts.statuses?.get(id);
572
+ const closed = reviewed ? reviewed === "closed" : CLOSED.has(st) || DECLINED.has(st);
573
+ items.push({
574
+ alias: id,
575
+ title: titleOf(primary),
576
+ status: closed ? "closed" : "open",
577
+ ...(closed ? { closed_reason: !reviewed && DECLINED.has(st) ? "declined" : "completed" } : {}),
578
+ ...(row?.priority ? { priority: row.priority } : {}),
579
+ intent: { sdd: home.id, path },
580
+ ...(idsIn(fields("Parent Idea"))[0] ? { parent: idsIn(fields("Parent Idea"))[0] } : {}),
581
+ children: idsIn(fields("Child Ideas")),
582
+ depends_on: [...new Set([...(row?.depends ?? []), ...idsIn(fields("Blocked by"))])].filter((d) => d !== id),
583
+ labels: (fields("Theme") || "").split("|").map((s) => s.trim()).filter(Boolean),
584
+ ...(statusRaw ? { source_status: statusRaw } : {}),
585
+ });
586
+ }
587
+ // 4. Write.
588
+ await mkdir(opts.out, { recursive: true });
589
+ await writeFile(join(opts.out, "work-items.json"), `${JSON.stringify(items, null, 2)}\n`);
590
+ await writeFile(join(opts.out, "citations.tsv"), `${citations.join("\n")}\n`);
591
+ if (opts.write) {
592
+ for (const i of intents) {
593
+ const full = join(i.sdd.dir, i.path);
594
+ if (existsSync(full)) {
595
+ skippedExisting.push(`${i.sdd.id}: ${i.path}`);
596
+ continue;
597
+ }
598
+ await mkdir(dirname(full), { recursive: true });
599
+ await writeFile(full, i.body);
600
+ written.push(`${i.sdd.id}: ${i.path}`);
601
+ }
602
+ }
603
+ else {
604
+ for (const i of intents) {
605
+ const full = join(opts.out, "intents", i.sdd.id, i.path);
606
+ await mkdir(dirname(full), { recursive: true });
607
+ await writeFile(full, i.body);
608
+ }
609
+ }
610
+ const homes = new Map();
611
+ for (const i of intents)
612
+ homes.set(i.sdd.id, (homes.get(i.sdd.id) ?? 0) + 1);
613
+ const pairs = [...byId.values()];
614
+ const supported = (k) => [...ev.values()].filter((e) => e[k].length > 0).length;
615
+ const report = [
616
+ "# Intent migration report",
617
+ "",
618
+ `Mode: ${opts.write ? "write" : "dry run (intents under intents/ here, not in the SDDs)"}.`,
619
+ `Request ids: ${ids.size} (${pairs.filter((p) => p.idea && p.brief).length} with an Idea and an Epic Brief, ${pairs.filter((p) => p.idea && !p.brief).length} with an Idea only, ${pairs.filter((p) => !p.idea && p.brief).length} with a Brief only).`,
620
+ `Work items: ${items.filter((i) => i.status === "closed").length} closed, ${items.filter((i) => i.status === "open").length} open.`,
621
+ `Homes: ${[...homes.entries()].map(([s, n]) => `${s} ${n}`).join(", ")}.`,
622
+ `Evidence, ids supported by each source: cited ${supported("cited")}, titled ${supported("titled")}, mentioned ${supported("mentioned")}, committed ${supported("committed")}, planned ${supported("planned")}, commit ${supported("commits")}, plan ${supported("plans")}.`,
623
+ "",
624
+ `## Flagged (${flags.size})`,
625
+ "",
626
+ ...([...flags.entries()].sort((a, b) => compareIds(a[0], b[0])).map(([id, w]) => `- ${id}: ${w.join("; ")}`)),
627
+ "",
628
+ `## Source headings not recognised (${notCarried.size} distinct)`,
629
+ "",
630
+ "Not carried into intents. Review the list for a heading that should map to an intent section.",
631
+ "",
632
+ ...[...notCarried.entries()].sort((a, b) => b[1] - a[1]).map(([h, n]) => `- ${n} × ${h}`),
633
+ "",
634
+ `## Index files, not counted as evidence (${indexFiles.length}; threshold ${opts.indexThreshold} ids)`,
635
+ "",
636
+ ...indexFiles.map((f) => `- ${f}`),
637
+ "",
638
+ `## Not read as an Idea or Epic Brief (${unrecognised.length})`,
639
+ "",
640
+ ...unrecognised.map((f) => `- ${f}`),
641
+ "",
642
+ ...(opts.write ? [`## Written (${written.length})`, "", ...written.map((w) => `- ${w}`), "", `## Not written: a file exists (${skippedExisting.length})`, "", ...skippedExisting.map((w) => `- ${w}`), ""] : []),
643
+ ].join("\n");
644
+ await writeFile(join(opts.out, "report.md"), report);
645
+ return { ids: ids.size, written, skippedExisting, flags, unrecognised };
646
+ }
647
+ function readFileSyncSafe(p) {
648
+ try {
649
+ return readFileSync(p, "utf8");
650
+ }
651
+ catch {
652
+ return "";
653
+ }
654
+ }
655
+ function compareIds(a, b) {
656
+ const na = Number(/\d+$/.exec(a)?.[0] ?? 0);
657
+ const nb = Number(/\d+$/.exec(b)?.[0] ?? 0);
658
+ return a.replace(/\d+$/, "").localeCompare(b.replace(/\d+$/, "")) || na - nb;
659
+ }
660
+ function slug(path, id) {
661
+ const b = basename(path, ".md").toLowerCase();
662
+ return b.startsWith(id.toLowerCase()) ? `${b}.md` : `${id.toLowerCase()}-${b}.md`;
663
+ }
664
+ function sourceDirOf(p, repos) {
665
+ return [...repos.keys()].filter((d) => p.startsWith(`${d}/`)).sort((a, b) => b.length - a.length)[0] ?? dirname(p);
666
+ }
667
+ function sourceRoot(p, repos) {
668
+ return repos.get(sourceDirOf(p, repos))?.top ?? dirname(p);
669
+ }
670
+ //# sourceMappingURL=migrate.js.map