@copperbox/why 1.0.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 (48) hide show
  1. package/README.md +168 -0
  2. package/dist/anchor.d.ts +112 -0
  3. package/dist/anchor.js +697 -0
  4. package/dist/anchors.d.ts +101 -0
  5. package/dist/anchors.js +223 -0
  6. package/dist/audit.d.ts +120 -0
  7. package/dist/audit.js +483 -0
  8. package/dist/blame.d.ts +91 -0
  9. package/dist/blame.js +294 -0
  10. package/dist/bundle.d.ts +78 -0
  11. package/dist/bundle.js +188 -0
  12. package/dist/capture.d.ts +76 -0
  13. package/dist/capture.js +462 -0
  14. package/dist/cli.d.ts +11 -0
  15. package/dist/cli.js +641 -0
  16. package/dist/dig-state.d.ts +46 -0
  17. package/dist/dig-state.js +146 -0
  18. package/dist/dig.d.ts +125 -0
  19. package/dist/dig.js +380 -0
  20. package/dist/discover.d.ts +11 -0
  21. package/dist/discover.js +47 -0
  22. package/dist/doctor.d.ts +135 -0
  23. package/dist/doctor.js +263 -0
  24. package/dist/evidence.d.ts +73 -0
  25. package/dist/evidence.js +425 -0
  26. package/dist/export.d.ts +67 -0
  27. package/dist/export.js +106 -0
  28. package/dist/find-symbol.d.ts +19 -0
  29. package/dist/find-symbol.js +267 -0
  30. package/dist/git.d.ts +17 -0
  31. package/dist/git.js +51 -0
  32. package/dist/init.d.ts +24 -0
  33. package/dist/init.js +100 -0
  34. package/dist/lint.d.ts +40 -0
  35. package/dist/lint.js +161 -0
  36. package/dist/serve-assets.d.ts +10 -0
  37. package/dist/serve-assets.js +55 -0
  38. package/dist/serve.d.ts +53 -0
  39. package/dist/serve.js +226 -0
  40. package/dist/trace-range.d.ts +22 -0
  41. package/dist/trace-range.js +216 -0
  42. package/package.json +44 -0
  43. package/ui/app.js +323 -0
  44. package/ui/graph.js +164 -0
  45. package/ui/highlight.js +202 -0
  46. package/ui/story-panel.d.ts +9 -0
  47. package/ui/story-panel.js +125 -0
  48. package/ui/style.css +229 -0
@@ -0,0 +1,462 @@
1
+ // Merge-time capture (DESIGN.md open problem #5): draft a concept from a PR
2
+ // while the discussion still holds the why, at confidence `recorded`. The CLI
3
+ // half is deterministic assembly only — gh/git evidence via the dig evidence
4
+ // module, anchors from the merge diff's hunks, rationale candidates quoted
5
+ // verbatim — and everything it emits is a *draft* under `.why/.drafts/`.
6
+ // Drafts are deliberately a dot-directory: okf-mcp serves every non-dot
7
+ // `.md` under the bundle root (verified against its walkMarkdownFiles), so a
8
+ // plain `drafts/` would leak unedited machine output into `why blame` and the
9
+ // served bundle. Promotion out of drafts is an editorial act, lint-gated by
10
+ // `why capture --promote`; the judgment step lives in skills/capture/SKILL.md.
11
+ import { existsSync } from "node:fs";
12
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { basename, dirname, join, resolve } from "node:path";
14
+ import { serializeDocument, splitFrontmatter, writeConcept } from "@copperbox/okf-mcp";
15
+ import { parseLineRange } from "./anchors.js";
16
+ import { CONCEPT_TYPES, isOneOf, isPlainMap, loadBundle } from "./bundle.js";
17
+ import { asComments, buildEvidencePack, runCommand, } from "./evidence.js";
18
+ import { lintBundle } from "./lint.js";
19
+ /** A capture step that must stop the command cleanly (exit 1), not crash. */
20
+ export class CaptureError extends Error {
21
+ }
22
+ /**
23
+ * Where drafts live, relative to the bundle root. Must stay a dot-directory:
24
+ * okf-mcp's bundle walk skips dot-dirs only, and drafts must never serve.
25
+ */
26
+ export const DRAFTS_DIRNAME = ".drafts";
27
+ /** Suffix of the evidence pack written beside each draft. */
28
+ export const EVIDENCE_SUFFIX = ".evidence.md";
29
+ /** Above this many hunks a file gets one whole-file anchor, said out loud. */
30
+ export const MAX_HUNK_ANCHORS_PER_FILE = 4;
31
+ /** Rationale candidates quoted into the draft; the rest point at the pack. */
32
+ export const MAX_RATIONALE_CANDIDATES = 12;
33
+ /**
34
+ * A discussion paragraph is a rationale candidate when it matches this —
35
+ * dig's comment-tell vocabulary widened with decision language.
36
+ */
37
+ export const RATIONALE_TELL_RE = /\b(because|instead|rather than|so that|decided?|decision|chose|choice|why|trade-?off|workaround|hack|constraint|blocked|reverted?)\b/i;
38
+ /**
39
+ * Derive anchor claims from a zero-context (`-U0`) patch: one anchor per
40
+ * hunk's new-side span, exact to the changed lines. A pure deletion anchors
41
+ * the single line the cut sits after; a file with no hunks (binary,
42
+ * rename-only) anchors whole-file; a deleted file anchors nothing; a file
43
+ * with more than MAX_HUNK_ANCHORS_PER_FILE hunks collapses to one whole-file
44
+ * anchor with a note — never a silent cap.
45
+ */
46
+ export function anchorsFromPatch(patch, asOf) {
47
+ const files = [];
48
+ let current;
49
+ for (const line of patch.split("\n")) {
50
+ const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
51
+ if (header !== null) {
52
+ current = { path: header[1], deleted: false, ranges: [] };
53
+ files.push(current);
54
+ continue;
55
+ }
56
+ if (current === undefined)
57
+ continue;
58
+ if (/^deleted file mode /.test(line))
59
+ current.deleted = true;
60
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
61
+ if (hunk !== null) {
62
+ const start = Number(hunk[1]);
63
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
64
+ if (count > 0) {
65
+ current.ranges.push({ start, end: start + count - 1 });
66
+ }
67
+ else {
68
+ // Pure deletion: new side has no lines; claim the line the cut sits after.
69
+ const at = Math.max(start, 1);
70
+ current.ranges.push({ start: at, end: at });
71
+ }
72
+ }
73
+ }
74
+ files.sort((a, b) => a.path.localeCompare(b.path));
75
+ const anchors = [];
76
+ const notes = [];
77
+ const touched = [];
78
+ for (const file of files) {
79
+ touched.push(file.path);
80
+ if (file.deleted) {
81
+ notes.push(`${file.path} was deleted by this change — nothing to anchor there`);
82
+ continue;
83
+ }
84
+ if (file.ranges.length === 0 || file.ranges.length > MAX_HUNK_ANCHORS_PER_FILE) {
85
+ if (file.ranges.length > MAX_HUNK_ANCHORS_PER_FILE) {
86
+ notes.push(`${file.path}: ${file.ranges.length} hunks collapsed into one whole-file anchor`);
87
+ }
88
+ anchors.push({ path: file.path, as_of: asOf, state: "live" });
89
+ continue;
90
+ }
91
+ for (const range of file.ranges) {
92
+ const lines = range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
93
+ // The anchor machinery must be able to read back every span we write.
94
+ if (parseLineRange(lines) === undefined) {
95
+ throw new CaptureError(`internal: derived an unparseable anchor span ${file.path}:${lines}`);
96
+ }
97
+ anchors.push({ path: file.path, lines, as_of: asOf, state: "live" });
98
+ }
99
+ }
100
+ return { anchors, files: touched, notes };
101
+ }
102
+ function rationaleParagraphs(text) {
103
+ return text
104
+ .replace(/\r\n/g, "\n")
105
+ .split(/\n{2,}/)
106
+ .map((p) => p.trim())
107
+ .filter((p) => p !== "" && RATIONALE_TELL_RE.test(p));
108
+ }
109
+ function commentCandidates(value, kind, n) {
110
+ const out = [];
111
+ for (const c of asComments(value)) {
112
+ const paragraphs = rationaleParagraphs(c.body ?? "");
113
+ if (paragraphs.length === 0)
114
+ continue;
115
+ out.push({ text: paragraphs.join("\n\n"), source: `PR #${n} ${kind} by ${attribution(c)}` });
116
+ }
117
+ return out;
118
+ }
119
+ function attribution(c) {
120
+ const login = c.author?.login ?? "unknown";
121
+ const date = (c.createdAt ?? c.submittedAt ?? "").slice(0, 10);
122
+ return date === "" ? `@${login}` : `@${login} (${date})`;
123
+ }
124
+ // --- Shared assembly -----------------------------------------------------------
125
+ function firstLine(text) {
126
+ return text.split("\n").find((l) => l.trim() !== "")?.trim() ?? "";
127
+ }
128
+ /** Short kebab-case slug from a title; empty when nothing survives. */
129
+ export function slugify(text) {
130
+ const slug = text
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, "-")
133
+ .replace(/^-+|-+$/g, "");
134
+ return slug.slice(0, 48).replace(/-+$/, "");
135
+ }
136
+ /** Origin URL normalized to https, or undefined when none is derivable. */
137
+ function remoteHttpsUrl(runner, repo) {
138
+ const r = runner("git", ["remote", "get-url", "origin"], repo);
139
+ if (r.status !== 0)
140
+ return undefined;
141
+ const url = r.stdout.trim();
142
+ const ssh = /^git@([^:]+):(.+)$/.exec(url) ?? /^ssh:\/\/git@([^/]+)\/(.+)$/.exec(url);
143
+ if (ssh !== null)
144
+ return `https://${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
145
+ if (/^https?:\/\//.test(url))
146
+ return url.replace(/\.git$/, "");
147
+ return undefined;
148
+ }
149
+ function blockquote(text) {
150
+ return text
151
+ .trimEnd()
152
+ .split("\n")
153
+ .map((line) => (line === "" ? ">" : `> ${line}`))
154
+ .join("\n");
155
+ }
156
+ function draftBody(opts) {
157
+ const lines = [`# ${opts.title}`, ""];
158
+ lines.push(`<!-- capture draft from ${opts.provenance}. Replace this comment with a`, `one-paragraph summary: what is true now because of this ${opts.kind}. -->`);
159
+ for (const note of opts.notes)
160
+ lines.push(`<!-- capture: ${note} -->`);
161
+ lines.push("", "# Why", "");
162
+ lines.push("<!-- Rationale candidates quoted verbatim by `why capture` — keep what states", "the why, rewrite it into narrative, and delete the rest. Never keep a claim", `the quotes below do not support (DESIGN.md §2). Full evidence pack:`, `${DRAFTS_DIRNAME}/${opts.evidenceName} (removed on promote) -->`);
163
+ const shown = opts.candidates.slice(0, MAX_RATIONALE_CANDIDATES);
164
+ for (const candidate of shown) {
165
+ lines.push("", blockquote(candidate.text), "", `— ${candidate.source}`);
166
+ }
167
+ const omitted = opts.candidates.length - shown.length;
168
+ if (omitted > 0) {
169
+ lines.push("", `[capture: ${omitted} more rationale candidate(s) omitted — see the evidence pack]`);
170
+ }
171
+ if (opts.candidates.length === 0) {
172
+ lines.push("", "(no rationale candidates found in the evidence — if the why cannot be stated", "from the pack, turn this draft into a `question` instead of promoting it)");
173
+ }
174
+ if (opts.citations.length > 0) {
175
+ lines.push("", "# Citations", "", ...opts.citations);
176
+ }
177
+ return `${lines.join("\n")}\n`;
178
+ }
179
+ /** Write the draft and its evidence pack; refuses to overwrite a draft. */
180
+ async function emitDraft(bundle, spec, repo, runner) {
181
+ const draftsDir = join(bundle.root, DRAFTS_DIRNAME);
182
+ const draftPath = join(draftsDir, `${spec.base}.md`);
183
+ if (existsSync(draftPath)) {
184
+ throw new CaptureError(`${draftPath} already exists — promote or remove it before re-capturing`);
185
+ }
186
+ await mkdir(draftsDir, { recursive: true });
187
+ const pack = await buildEvidencePack(spec.episode, { repo, runner });
188
+ const evidencePath = join(draftsDir, `${spec.base}${EVIDENCE_SUFFIX}`);
189
+ await writeFile(evidencePath, pack.markdown, "utf8");
190
+ await writeFile(draftPath, serializeDocument(spec.frontmatter, spec.body), "utf8");
191
+ return { draftPath, evidencePath };
192
+ }
193
+ function whyMap(opts) {
194
+ const why = { status: opts.status };
195
+ if (opts.happenedOn !== undefined)
196
+ why.happened_on = opts.happenedOn;
197
+ if (opts.candidateCount > 0) {
198
+ // Verbatim human-written rationale from the time of the change is the
199
+ // `recorded` bar; the promotion step confirms the kept quotes state it.
200
+ why.confidence = "recorded";
201
+ }
202
+ else {
203
+ opts.notes.push("no rationale candidates found — confidence left unset; consider a question instead of promoting");
204
+ }
205
+ if (opts.anchors.length > 0)
206
+ why.anchors = opts.anchors;
207
+ return why;
208
+ }
209
+ function asString(v) {
210
+ return typeof v === "string" && v !== "" ? v : undefined;
211
+ }
212
+ /** Link text must not break the `[text](url)` citation form. */
213
+ function linkText(text) {
214
+ return text.replace(/[[\]]/g, "");
215
+ }
216
+ // --- `why capture --pr <n>` ----------------------------------------------------
217
+ const PR_FIELDS = "state,title,body,author,url,mergedAt,closedAt,mergeCommit,headRefOid,comments,reviews,files";
218
+ export async function capturePr(bundle, n, options = {}) {
219
+ const runner = options.runner ?? runCommand;
220
+ const repo = dirname(bundle.root);
221
+ const r = runner("gh", ["pr", "view", String(n), "--json", PR_FIELDS], repo);
222
+ if (r.status === 127) {
223
+ throw new CaptureError("gh is not installed or not on PATH — `--pr` needs it; `--commit <sha>` is the gh-free fallback");
224
+ }
225
+ if (r.status !== 0) {
226
+ throw new CaptureError(`gh pr view ${n} failed: ${firstLine(r.stderr) || `exit ${r.status}`}`);
227
+ }
228
+ let data;
229
+ try {
230
+ const parsed = JSON.parse(r.stdout);
231
+ if (!isPlainMap(parsed))
232
+ throw new Error("not an object");
233
+ data = parsed;
234
+ }
235
+ catch {
236
+ throw new CaptureError(`gh pr view ${n} returned unparseable JSON`);
237
+ }
238
+ const state = asString(data.state);
239
+ if (state === "OPEN") {
240
+ throw new CaptureError(`PR #${n} is still open — capture records outcomes; run it after the merge or close`);
241
+ }
242
+ if (state !== "MERGED" && state !== "CLOSED") {
243
+ throw new CaptureError(`PR #${n} has unexpected state "${String(data.state)}"`);
244
+ }
245
+ const merged = state === "MERGED";
246
+ const type = merged ? "decision" : "attempt";
247
+ const title = asString(data.title) ?? `PR #${n}`;
248
+ const author = isPlainMap(data.author) ? asString(data.author.login) ?? "unknown" : "unknown";
249
+ const notes = [];
250
+ const whenRaw = merged ? asString(data.mergedAt) : asString(data.closedAt);
251
+ const happenedOn = whenRaw?.slice(0, 10);
252
+ if (happenedOn === undefined) {
253
+ notes.push(`gh reported no ${merged ? "merge" : "close"} date — happened_on left unset`);
254
+ }
255
+ // Anchors come from the merge commit's diff hunks; an attempt's diff never
256
+ // landed on the mainline, so anchoring it there would be silently wrong.
257
+ let anchors = [];
258
+ let touched = ghFilePaths(data.files);
259
+ const mergeSha = merged && isPlainMap(data.mergeCommit) ? asString(data.mergeCommit.oid) : undefined;
260
+ let mergeShaLocal = false;
261
+ if (merged) {
262
+ if (mergeSha === undefined) {
263
+ notes.push("gh reported no merge commit — no anchors derived");
264
+ }
265
+ else if (runner("git", ["rev-parse", "--verify", `${mergeSha}^{commit}`], repo).status !== 0) {
266
+ notes.push(`merge commit ${mergeSha.slice(0, 7)} is not in this clone (fetch first?) — no anchors derived`);
267
+ }
268
+ else {
269
+ mergeShaLocal = true;
270
+ const patch = runner("git", ["show", "-m", "--first-parent", "--format=", "--patch", "--no-color", "-U0", mergeSha], repo);
271
+ if (patch.status !== 0) {
272
+ throw new CaptureError(`git show ${mergeSha} failed: ${firstLine(patch.stderr)}`);
273
+ }
274
+ const derived = anchorsFromPatch(patch.stdout, mergeSha);
275
+ anchors = derived.anchors;
276
+ touched = derived.files;
277
+ notes.push(...derived.notes);
278
+ }
279
+ }
280
+ else {
281
+ notes.push("closed without merging — the diff never landed, so no anchors were derived (anchors are optional on an attempt)");
282
+ }
283
+ const candidates = [];
284
+ const prBody = asString(data.body)?.trim();
285
+ if (prBody !== undefined && prBody !== "") {
286
+ candidates.push({ text: prBody, source: `PR #${n} description by @${author}` });
287
+ }
288
+ candidates.push(...commentCandidates(data.comments, "comment", n));
289
+ candidates.push(...commentCandidates(data.reviews, "review", n));
290
+ const citations = [];
291
+ const url = asString(data.url);
292
+ if (url !== undefined) {
293
+ citations.push(`[1] [PR #${n}: ${linkText(title)}](${url})`);
294
+ }
295
+ else {
296
+ notes.push("gh reported no PR url — add a citation by hand before promoting");
297
+ }
298
+ const remote = remoteHttpsUrl(runner, repo);
299
+ if (merged && mergeSha !== undefined && remote !== undefined) {
300
+ citations.push(`[${citations.length + 1}] [merge commit ${mergeSha.slice(0, 7)}](${remote}/commit/${mergeSha})`);
301
+ }
302
+ const why = whyMap({
303
+ status: merged ? "active" : "abandoned",
304
+ candidateCount: candidates.length,
305
+ anchors,
306
+ notes,
307
+ happenedOn,
308
+ });
309
+ const frontmatter = {
310
+ type,
311
+ title,
312
+ description: `Draft captured from PR #${n} — replace with the one-line truth this ${type} created.`,
313
+ why,
314
+ };
315
+ const headRefOid = asString(data.headRefOid);
316
+ let commits = [];
317
+ if (mergeSha !== undefined && mergeShaLocal) {
318
+ commits = [{ sha: mergeSha }];
319
+ }
320
+ else if (headRefOid !== undefined) {
321
+ commits = [{ sha: headRefOid }];
322
+ }
323
+ const episode = { id: `pr-${n}`, commits, files: touched, prs: [n], issues: [] };
324
+ const slug = slugify(title);
325
+ const base = slug === "" ? `pr-${n}` : `pr-${n}-${slug}`;
326
+ const provenance = `PR #${n} (${merged ? "merged" : "closed unmerged"}${happenedOn === undefined ? "" : ` ${happenedOn}`})`;
327
+ const body = draftBody({
328
+ title,
329
+ kind: type,
330
+ provenance,
331
+ candidates,
332
+ citations,
333
+ notes,
334
+ evidenceName: `${base}${EVIDENCE_SUFFIX}`,
335
+ });
336
+ const { draftPath, evidencePath } = await emitDraft(bundle, { base, frontmatter, body, episode }, repo, runner);
337
+ return { draftPath, evidencePath, type, candidateCount: candidates.length, anchorCount: anchors.length, notes };
338
+ }
339
+ function ghFilePaths(value) {
340
+ if (!Array.isArray(value))
341
+ return [];
342
+ const paths = value
343
+ .filter(isPlainMap)
344
+ .map((f) => asString(f.path))
345
+ .filter((p) => p !== undefined);
346
+ return [...new Set(paths)].sort();
347
+ }
348
+ // --- `why capture --commit <sha>` ------------------------------------------------
349
+ export async function captureCommit(bundle, ref, options = {}) {
350
+ const runner = options.runner ?? runCommand;
351
+ const repo = dirname(bundle.root);
352
+ const verify = runner("git", ["rev-parse", "--verify", `${ref}^{commit}`], repo);
353
+ if (verify.status !== 0) {
354
+ throw new CaptureError(`"${ref}" does not name a commit in ${repo}`);
355
+ }
356
+ const sha = verify.stdout.trim();
357
+ const sha7 = sha.slice(0, 7);
358
+ const shown = runner("git", ["show", "-s", "--format=%as%n%B", sha], repo);
359
+ if (shown.status !== 0) {
360
+ throw new CaptureError(`git show ${sha} failed: ${firstLine(shown.stderr)}`);
361
+ }
362
+ const [date, subject, ...rest] = shown.stdout.split("\n");
363
+ const messageBody = rest.join("\n").trim();
364
+ const title = subject?.trim() || `commit ${sha7}`;
365
+ const happenedOn = asString(date?.trim());
366
+ const notes = [];
367
+ const patch = runner("git", ["show", "-m", "--first-parent", "--format=", "--patch", "--no-color", "-U0", sha], repo);
368
+ if (patch.status !== 0) {
369
+ throw new CaptureError(`git show ${sha} failed: ${firstLine(patch.stderr)}`);
370
+ }
371
+ const derived = anchorsFromPatch(patch.stdout, sha);
372
+ notes.push(...derived.notes);
373
+ // The commit message body is the only rationale a bare commit can record.
374
+ const candidates = messageBody === "" ? [] : [{ text: messageBody, source: `commit ${sha7} message` }];
375
+ const remote = remoteHttpsUrl(runner, repo);
376
+ const citations = remote === undefined ? [] : [`[1] [commit ${sha7}](${remote}/commit/${sha})`];
377
+ if (remote === undefined) {
378
+ notes.push("no git remote to build a commit link from — add a citation by hand before promoting");
379
+ }
380
+ const why = whyMap({
381
+ status: "active",
382
+ candidateCount: candidates.length,
383
+ anchors: derived.anchors,
384
+ notes,
385
+ happenedOn,
386
+ });
387
+ const frontmatter = {
388
+ type: "decision",
389
+ title,
390
+ description: `Draft captured from commit ${sha7} — replace with the one-line truth this decision created.`,
391
+ why,
392
+ };
393
+ const episode = { id: `commit-${sha7}`, commits: [{ sha }], files: derived.files, prs: [], issues: [] };
394
+ const slug = slugify(title);
395
+ const base = slug === "" ? `commit-${sha7}` : `commit-${sha7}-${slug}`;
396
+ const body = draftBody({
397
+ title,
398
+ kind: "decision",
399
+ provenance: `commit ${sha7}${happenedOn === undefined ? "" : ` (${happenedOn})`}`,
400
+ candidates,
401
+ citations,
402
+ notes,
403
+ evidenceName: `${base}${EVIDENCE_SUFFIX}`,
404
+ });
405
+ const { draftPath, evidencePath } = await emitDraft(bundle, { base, frontmatter, body, episode }, repo, runner);
406
+ return {
407
+ draftPath,
408
+ evidencePath,
409
+ type: "decision",
410
+ candidateCount: candidates.length,
411
+ anchorCount: derived.anchors.length,
412
+ notes,
413
+ };
414
+ }
415
+ /**
416
+ * Move one draft into its type directory, gated on lint: the concept is
417
+ * written through okf-mcp writeConcept (which stamps `timestamp` and
418
+ * normalizes citations), the reloaded bundle is linted, and any error-severity
419
+ * finding on the new file rolls the write back and keeps the draft. Findings
420
+ * elsewhere in the bundle never block a promotion.
421
+ */
422
+ export async function promoteDraft(bundle, ref, cwd) {
423
+ const draftsDir = resolve(join(bundle.root, DRAFTS_DIRNAME));
424
+ // A bare name always means a file in the drafts dir; a path resolves as one.
425
+ const inDrafts = join(draftsDir, ref);
426
+ const draftPath = basename(ref) === ref || existsSync(inDrafts) ? inDrafts : resolve(cwd, ref);
427
+ if (resolve(dirname(draftPath)) !== draftsDir) {
428
+ throw new CaptureError(`${ref} is not in ${draftsDir} — promotion only moves drafts out of the drafts directory`);
429
+ }
430
+ if (basename(draftPath).endsWith(EVIDENCE_SUFFIX)) {
431
+ throw new CaptureError(`${basename(draftPath)} is an evidence pack, not a draft`);
432
+ }
433
+ if (!existsSync(draftPath)) {
434
+ throw new CaptureError(`no draft at ${draftPath}`);
435
+ }
436
+ const split = splitFrontmatter(await readFile(draftPath, "utf8"));
437
+ if (split.data === null) {
438
+ throw new CaptureError(`${basename(draftPath)}: no parseable frontmatter — a draft must carry the concept's frontmatter`);
439
+ }
440
+ const type = split.data.type;
441
+ if (!isOneOf(CONCEPT_TYPES, type)) {
442
+ throw new CaptureError(`${basename(draftPath)}: type "${String(type)}" is not a concept type (${CONCEPT_TYPES.join(", ")})`);
443
+ }
444
+ const relPath = `${type}s/${basename(draftPath)}`;
445
+ try {
446
+ await writeConcept(bundle.root, relPath, split.data, split.body, {
447
+ failIfExists: true,
448
+ });
449
+ }
450
+ catch (e) {
451
+ throw new CaptureError(`cannot promote to ${relPath}: ${e instanceof Error ? e.message : String(e)}`);
452
+ }
453
+ const fresh = await loadBundle(bundle.root);
454
+ const findings = (await lintBundle(fresh)).filter((f) => f.file === relPath);
455
+ if (findings.some((f) => f.severity === "error")) {
456
+ await rm(join(bundle.root, relPath));
457
+ return { promoted: false, path: relPath, findings };
458
+ }
459
+ await rm(draftPath);
460
+ await rm(draftPath.replace(/\.md$/, EVIDENCE_SUFFIX), { force: true });
461
+ return { promoted: true, path: relPath, findings };
462
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ export declare const COMMANDS: readonly ["init", "lint", "blame", "anchor", "doctor", "dig", "audit", "capture", "export", "serve"];
3
+ export type Command = (typeof COMMANDS)[number];
4
+ /** Where a command's output goes; injectable so tests can capture it. */
5
+ export interface CliIo {
6
+ out: (line: string) => void;
7
+ err: (line: string) => void;
8
+ }
9
+ export declare function usage(): string;
10
+ /** Exit codes: 0 ok, 1 operational error (e.g. no bundle), 2 usage error. */
11
+ export declare function main(argv: string[], cwd?: string, io?: CliIo): Promise<number>;