@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,47 @@
1
+ // Bundle discovery: find the `.why/` directory a command should operate on.
2
+ import { statSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ export const BUNDLE_DIRNAME = ".why";
5
+ /** A command needed a bundle and none could be found. Rendered, not a crash. */
6
+ export class BundleNotFoundError extends Error {
7
+ }
8
+ function isDirectory(path) {
9
+ try {
10
+ return statSync(path).isDirectory();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /** Walk up from startDir to the filesystem root; nearest `.why/` wins. */
17
+ export function findBundleRoot(startDir) {
18
+ let dir = resolve(startDir);
19
+ for (;;) {
20
+ const candidate = join(dir, BUNDLE_DIRNAME);
21
+ if (isDirectory(candidate))
22
+ return candidate;
23
+ const parent = dirname(dir);
24
+ if (parent === dir)
25
+ return undefined;
26
+ dir = parent;
27
+ }
28
+ }
29
+ /**
30
+ * The bundle root a command should use: an explicit `--bundle` path (resolved
31
+ * against cwd, must exist) or the nearest `.why/` above cwd.
32
+ */
33
+ export function resolveBundleRoot(cwd, override) {
34
+ if (override !== undefined) {
35
+ const root = resolve(cwd, override);
36
+ if (!isDirectory(root)) {
37
+ throw new BundleNotFoundError(`why: --bundle points at ${root}, which is not a directory`);
38
+ }
39
+ return root;
40
+ }
41
+ const found = findBundleRoot(cwd);
42
+ if (found === undefined) {
43
+ throw new BundleNotFoundError(`why: no ${BUNDLE_DIRNAME}/ bundle found searching up from ${resolve(cwd)} — ` +
44
+ "run `why init` to create one, or point at a bundle with --bundle <path>");
45
+ }
46
+ return found;
47
+ }
@@ -0,0 +1,135 @@
1
+ import type { WhyBundle } from "./bundle.js";
2
+ import { type Finding } from "./lint.js";
3
+ /** Red exits 1 (the archive cannot be trusted); yellow is maintenance debt. */
4
+ export type DoctorSeverity = "red" | "yellow";
5
+ export interface AnchorItem {
6
+ concept: string;
7
+ path: string;
8
+ lines?: string;
9
+ symbol?: string;
10
+ as_of?: string;
11
+ }
12
+ /**
13
+ * Why a live anchor's as_of is a real concern: it names a commit unrelated to
14
+ * HEAD (history diverged, or a squash merge discarded the branch it was
15
+ * stamped on), or one this repository cannot resolve at all. A clean ancestor
16
+ * behind HEAD is stable provenance — the `why anchor` claim (DESIGN.md §2/§4:
17
+ * "the commit at which path+lines were valid") has simply survived unchanged
18
+ * since then — so it is not flagged.
19
+ *
20
+ * These are the anchors `why anchor` reports as `unverified as_of`: the code
21
+ * is present, the provenance is unreadable. That is maintenance debt (yellow),
22
+ * not a broken archive (red) — the anchor is not asserting anything false. The
23
+ * two reasons differ only by whether this clone happens to still have the
24
+ * commit, which is why neither is trusted to read history through (DESIGN.md
25
+ * §4 "`as_of` must be an ancestor") and why both read as the same finding.
26
+ */
27
+ export type StaleReason = "not-ancestor" | "unresolved";
28
+ export interface StaleAsOfItem extends AnchorItem {
29
+ as_of: string;
30
+ reason: StaleReason;
31
+ }
32
+ export interface ReviewByItem {
33
+ concept: string;
34
+ review_by: string;
35
+ }
36
+ export interface ConstraintItem {
37
+ concept: string;
38
+ expired_on?: string;
39
+ }
40
+ export interface QuestionItem {
41
+ concept: string;
42
+ happened_on?: string;
43
+ }
44
+ export interface DoctorSection<Item> {
45
+ title: string;
46
+ severity: DoctorSeverity;
47
+ count: number;
48
+ items: Item[];
49
+ /** Present only when the check could not run (as_of freshness needs a git repo). */
50
+ skipped?: string;
51
+ }
52
+ /**
53
+ * Dig freshness (DESIGN.md §6): how far HEAD has moved past the high-water
54
+ * mark in `.dig-state.json`. Informational — it never counts as a finding.
55
+ */
56
+ export type DigStateHealth = {
57
+ status: "ok";
58
+ branch: string;
59
+ lastProcessed: string;
60
+ commitsSince: number;
61
+ } | {
62
+ status: "none";
63
+ branch?: string;
64
+ } | {
65
+ status: "invalid";
66
+ detail: string;
67
+ } | {
68
+ status: "unavailable";
69
+ detail: string;
70
+ };
71
+ /** The `--json` shape. Keys and section order are a stable, tested surface. */
72
+ export interface DoctorReport {
73
+ root: string;
74
+ /** Short HEAD sha the as_of checks ran against; null when there is no repo. */
75
+ head: string | null;
76
+ digState: DigStateHealth;
77
+ concepts: number;
78
+ /** No red findings — the exit-0 condition. */
79
+ healthy: boolean;
80
+ red: number;
81
+ yellow: number;
82
+ sections: {
83
+ lostAnchors: DoctorSection<AnchorItem>;
84
+ staleAsOf: DoctorSection<StaleAsOfItem>;
85
+ reviewByPastDue: DoctorSection<ReviewByItem>;
86
+ unknownConstraints: DoctorSection<ConstraintItem>;
87
+ expiredConstraints: DoctorSection<ConstraintItem>;
88
+ openQuestions: DoctorSection<QuestionItem>;
89
+ lintErrors: DoctorSection<Finding>;
90
+ };
91
+ }
92
+ export interface DoctorOptions {
93
+ /** "Today" for the review-by check; injectable so tests are deterministic. */
94
+ now?: Date;
95
+ }
96
+ /** Build the full health report. Pure read — nothing on disk changes. */
97
+ export declare function buildDoctorReport(bundle: WhyBundle, options?: DoctorOptions): Promise<DoctorReport>;
98
+ /** One display line per finding — shared by the CLI renderer and the serve
99
+ * summary (schemas/doctor.schema.json), so the two surfaces cannot drift. */
100
+ declare const ITEM_TEXT: {
101
+ readonly lostAnchors: (i: AnchorItem) => string;
102
+ readonly staleAsOf: (i: StaleAsOfItem) => string;
103
+ readonly reviewByPastDue: (i: ReviewByItem) => string;
104
+ readonly unknownConstraints: (i: ConstraintItem) => string;
105
+ readonly expiredConstraints: (i: ConstraintItem) => string;
106
+ readonly openQuestions: (i: QuestionItem) => string;
107
+ readonly lintErrors: (i: Finding) => string;
108
+ };
109
+ export type DoctorSectionKey = keyof typeof ITEM_TEXT;
110
+ /** Major version of the serve summary payload — policy in docs/ui-contract.md. */
111
+ export declare const DOCTOR_SUMMARY_SCHEMA_VERSION = 1;
112
+ export interface DoctorSummarySection {
113
+ key: DoctorSectionKey;
114
+ title: string;
115
+ severity: DoctorSeverity;
116
+ count: number;
117
+ /** Pre-rendered display lines — the same text the CLI prints per finding. */
118
+ items: string[];
119
+ skipped?: string;
120
+ }
121
+ /** The `GET /api/doctor` payload (schemas/doctor.schema.json). */
122
+ export interface DoctorSummary {
123
+ schemaVersion: typeof DOCTOR_SUMMARY_SCHEMA_VERSION;
124
+ healthy: boolean;
125
+ head: string | null;
126
+ concepts: number;
127
+ red: number;
128
+ yellow: number;
129
+ /** All seven sections, in the CLI's render order. */
130
+ sections: DoctorSummarySection[];
131
+ }
132
+ /** Flatten a report into the UI-contract summary `why serve` emits. */
133
+ export declare function buildDoctorSummary(report: DoctorReport): DoctorSummary;
134
+ export declare function renderDoctorReport(report: DoctorReport): string[];
135
+ export {};
package/dist/doctor.js ADDED
@@ -0,0 +1,263 @@
1
+ // `why doctor` (DESIGN.md §4, §8): the maintenance dashboard — one read-only
2
+ // command that says whether the archive can be trusted right now. It reports
3
+ // what the recorded state already implies: lost anchors and lint errors are
4
+ // red (the archive is asserting things it cannot back), everything else is
5
+ // maintenance debt (yellow). Doctor diagnoses only — `why anchor` and
6
+ // `why audit` treat.
7
+ import { dirname } from "node:path";
8
+ import { AnchorError, GitView } from "./anchor.js";
9
+ import { anchorSpan } from "./blame.js";
10
+ import { DigStateError, readDigState } from "./dig-state.js";
11
+ import { git as runGit } from "./git.js";
12
+ import { lintBundle } from "./lint.js";
13
+ function anchorItem(concept, anchor) {
14
+ const item = { concept, path: anchor.path };
15
+ if (anchor.symbol !== undefined)
16
+ item.symbol = anchor.symbol;
17
+ if (anchor.lines !== undefined)
18
+ item.lines = anchor.lines;
19
+ if (anchor.as_of !== undefined)
20
+ item.as_of = anchor.as_of;
21
+ return item;
22
+ }
23
+ /**
24
+ * A state file doctor cannot read reports `invalid` (with the read error) but
25
+ * never throws — the dashboard must still render. With no enclosing repo the
26
+ * freshness of an existing mark is unknowable and says so.
27
+ */
28
+ async function digStateHealth(bundleRoot, git, gitUnavailable) {
29
+ let state;
30
+ try {
31
+ state = await readDigState(bundleRoot);
32
+ }
33
+ catch (e) {
34
+ if (!(e instanceof DigStateError))
35
+ throw e;
36
+ return { status: "invalid", detail: e.message };
37
+ }
38
+ if (git === undefined) {
39
+ if (state === undefined)
40
+ return { status: "none" };
41
+ return { status: "unavailable", detail: gitUnavailable };
42
+ }
43
+ const branch = runGit(git.root, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.trim();
44
+ const mark = state?.branches[branch];
45
+ if (mark === undefined)
46
+ return { status: "none", branch };
47
+ const count = runGit(git.root, ["rev-list", "--count", `${mark.lastProcessed}..HEAD`]);
48
+ if (count.status !== 0) {
49
+ return {
50
+ status: "invalid",
51
+ detail: `last processed commit ${mark.lastProcessed} on branch ${branch} does not resolve in this repository`,
52
+ };
53
+ }
54
+ return {
55
+ status: "ok",
56
+ branch,
57
+ lastProcessed: mark.lastProcessed,
58
+ commitsSince: Number(count.stdout.trim()),
59
+ };
60
+ }
61
+ function section(title, severity, items, skipped) {
62
+ const built = { title, severity, count: items.length, items };
63
+ if (skipped !== undefined)
64
+ built.skipped = skipped;
65
+ return built;
66
+ }
67
+ /** Build the full health report. Pure read — nothing on disk changes. */
68
+ export async function buildDoctorReport(bundle, options = {}) {
69
+ const now = options.now ?? new Date();
70
+ const concepts = [...bundle.concepts.values()];
71
+ // No enclosing git repo (or no commits) leaves as_of freshness unknowable;
72
+ // the section then says so out loud instead of silently reporting zero.
73
+ let git;
74
+ let gitUnavailable;
75
+ try {
76
+ git = await GitView.open(dirname(bundle.root));
77
+ }
78
+ catch (e) {
79
+ if (!(e instanceof AnchorError))
80
+ throw e;
81
+ gitUnavailable = e.message;
82
+ }
83
+ const digState = await digStateHealth(bundle.root, git, gitUnavailable);
84
+ const lost = [];
85
+ const stale = [];
86
+ for (const concept of concepts) {
87
+ for (const anchor of concept.why.anchors) {
88
+ if (anchor.state === "lost") {
89
+ lost.push(anchorItem(concept.id, anchor));
90
+ continue;
91
+ }
92
+ if (anchor.as_of === undefined || git === undefined)
93
+ continue;
94
+ const sha = await git.commitSha(anchor.as_of);
95
+ if (sha === git.headFull)
96
+ continue;
97
+ let reason;
98
+ if (sha === undefined)
99
+ reason = "unresolved";
100
+ else if (await git.isAncestor(sha))
101
+ continue; // clean ancestor of HEAD — stable provenance, not drift
102
+ else
103
+ reason = "not-ancestor";
104
+ stale.push({ ...anchorItem(concept.id, anchor), as_of: anchor.as_of, reason });
105
+ }
106
+ }
107
+ const reviewByPastDue = [];
108
+ const unknown = [];
109
+ const expired = [];
110
+ for (const concept of concepts) {
111
+ if (concept.frontmatter.type !== "constraint")
112
+ continue;
113
+ const why = concept.why;
114
+ if (why.status === "unknown")
115
+ unknown.push({ concept: concept.id });
116
+ if (why.status === "expired") {
117
+ const item = { concept: concept.id };
118
+ if (why.expired_on !== undefined)
119
+ item.expired_on = why.expired_on;
120
+ expired.push(item);
121
+ }
122
+ const reviewBy = why.verify?.method === "review-by" ? why.verify.review_by : undefined;
123
+ // An unparseable date cannot be shown to lie in the future, so it counts
124
+ // as due — this check may not silently pass a claim it cannot evaluate.
125
+ if (reviewBy !== undefined && !(Date.parse(reviewBy) > now.getTime())) {
126
+ reviewByPastDue.push({ concept: concept.id, review_by: reviewBy });
127
+ }
128
+ }
129
+ // Age-sorted: the longest-open gaps first; undated questions sink last.
130
+ const openQuestions = concepts
131
+ .filter((c) => c.frontmatter.type === "question" && c.why.status === "open")
132
+ .map((c) => {
133
+ const item = { concept: c.id };
134
+ if (c.why.happened_on !== undefined)
135
+ item.happened_on = c.why.happened_on;
136
+ return item;
137
+ })
138
+ .sort((a, b) => (a.happened_on ?? "\uffff").localeCompare(b.happened_on ?? "\uffff"));
139
+ const lintErrors = (await lintBundle(bundle)).filter((f) => f.severity === "error");
140
+ const sections = {
141
+ lostAnchors: section("lost anchors", "red", lost),
142
+ staleAsOf: section("stale as_of", "yellow", stale, gitUnavailable),
143
+ reviewByPastDue: section("review-by past due", "yellow", reviewByPastDue),
144
+ unknownConstraints: section("constraints with status unknown", "yellow", unknown),
145
+ expiredConstraints: section("expired constraints", "yellow", expired),
146
+ openQuestions: section("open questions", "yellow", openQuestions),
147
+ lintErrors: section("lint errors", "red", lintErrors),
148
+ };
149
+ let red = 0;
150
+ let yellow = 0;
151
+ for (const s of Object.values(sections)) {
152
+ if (s.severity === "red")
153
+ red += s.count;
154
+ else
155
+ yellow += s.count;
156
+ }
157
+ return {
158
+ root: bundle.root,
159
+ head: git?.headShort ?? null,
160
+ digState,
161
+ concepts: concepts.length,
162
+ healthy: red === 0,
163
+ red,
164
+ yellow,
165
+ sections,
166
+ };
167
+ }
168
+ // --- Rendering -------------------------------------------------------------
169
+ const STALE_NOTES = {
170
+ "not-ancestor": "is not an ancestor of HEAD",
171
+ unresolved: "does not resolve in this repository",
172
+ };
173
+ /** One display line per finding — shared by the CLI renderer and the serve
174
+ * summary (schemas/doctor.schema.json), so the two surfaces cannot drift. */
175
+ const ITEM_TEXT = {
176
+ lostAnchors: (i) => `${i.concept} ${anchorSpan(i)} (last known${i.as_of === undefined ? "" : `, as_of ${i.as_of}`})`,
177
+ staleAsOf: (i) => `${i.concept} ${anchorSpan(i)} — as_of ${i.as_of} ${STALE_NOTES[i.reason]}`,
178
+ reviewByPastDue: (i) => `${i.concept} review_by ${i.review_by}`,
179
+ unknownConstraints: (i) => i.concept,
180
+ expiredConstraints: (i) => `${i.concept} expired_on ${i.expired_on ?? "(unrecorded — see why lint W402)"}`,
181
+ openQuestions: (i) => `${i.concept} open since ${i.happened_on ?? "(undated)"}`,
182
+ lintErrors: (i) => `${i.file} ${i.rule} ${i.message}`,
183
+ };
184
+ /** Major version of the serve summary payload — policy in docs/ui-contract.md. */
185
+ export const DOCTOR_SUMMARY_SCHEMA_VERSION = 1;
186
+ function summarizeSection(key, sec) {
187
+ const items = sec.items.map((item) => ITEM_TEXT[key](item));
188
+ const summary = {
189
+ key,
190
+ title: sec.title,
191
+ severity: sec.severity,
192
+ count: sec.count,
193
+ items,
194
+ };
195
+ if (sec.skipped !== undefined)
196
+ summary.skipped = sec.skipped;
197
+ return summary;
198
+ }
199
+ /** Flatten a report into the UI-contract summary `why serve` emits. */
200
+ export function buildDoctorSummary(report) {
201
+ const s = report.sections;
202
+ return {
203
+ schemaVersion: DOCTOR_SUMMARY_SCHEMA_VERSION,
204
+ healthy: report.healthy,
205
+ head: report.head,
206
+ concepts: report.concepts,
207
+ red: report.red,
208
+ yellow: report.yellow,
209
+ sections: [
210
+ summarizeSection("lostAnchors", s.lostAnchors),
211
+ summarizeSection("staleAsOf", s.staleAsOf),
212
+ summarizeSection("reviewByPastDue", s.reviewByPastDue),
213
+ summarizeSection("unknownConstraints", s.unknownConstraints),
214
+ summarizeSection("expiredConstraints", s.expiredConstraints),
215
+ summarizeSection("openQuestions", s.openQuestions),
216
+ summarizeSection("lintErrors", s.lintErrors),
217
+ ],
218
+ };
219
+ }
220
+ const TITLE_WIDTH = 31; // the longest section title
221
+ function pushSection(out, sec, detail) {
222
+ const label = sec.count === 0 ? "ok" : sec.severity;
223
+ out.push(` ${label.padEnd(7)} ${sec.title.padEnd(TITLE_WIDTH)} ${sec.count}`);
224
+ if (sec.skipped !== undefined)
225
+ out.push(` (not checked: ${sec.skipped})`);
226
+ for (const item of sec.items)
227
+ out.push(` ${detail(item)}`);
228
+ }
229
+ function digStateLine(d) {
230
+ if (d.status === "ok") {
231
+ return `dig state: ${d.commitsSince} commit${d.commitsSince === 1 ? "" : "s"} since last dig on ${d.branch}`;
232
+ }
233
+ if (d.status === "none") {
234
+ return `dig state: none — ${d.branch === undefined ? "never dug" : `branch ${d.branch} has never been dug`}`;
235
+ }
236
+ if (d.status === "invalid")
237
+ return `dig state: invalid — ${d.detail}`;
238
+ return `dig state: not checked — ${d.detail}`;
239
+ }
240
+ export function renderDoctorReport(report) {
241
+ const lines = [];
242
+ const head = report.head === null ? "" : `, HEAD ${report.head}`;
243
+ lines.push(`why doctor: ${report.concepts} concept${report.concepts === 1 ? "" : "s"} at ${report.root}${head}`);
244
+ lines.push(digStateLine(report.digState));
245
+ lines.push("");
246
+ const s = report.sections;
247
+ pushSection(lines, s.lostAnchors, ITEM_TEXT.lostAnchors);
248
+ pushSection(lines, s.staleAsOf, ITEM_TEXT.staleAsOf);
249
+ pushSection(lines, s.reviewByPastDue, ITEM_TEXT.reviewByPastDue);
250
+ pushSection(lines, s.unknownConstraints, ITEM_TEXT.unknownConstraints);
251
+ pushSection(lines, s.expiredConstraints, ITEM_TEXT.expiredConstraints);
252
+ pushSection(lines, s.openQuestions, ITEM_TEXT.openQuestions);
253
+ pushSection(lines, s.lintErrors, ITEM_TEXT.lintErrors);
254
+ lines.push("");
255
+ const total = report.red + report.yellow;
256
+ if (total === 0) {
257
+ lines.push("healthy — no findings");
258
+ }
259
+ else {
260
+ lines.push(`${total} finding${total === 1 ? "" : "s"} (${report.red} red, ${report.yellow} yellow) — ${report.healthy ? "healthy" : "unhealthy"}`);
261
+ }
262
+ return lines;
263
+ }
@@ -0,0 +1,73 @@
1
+ export declare class EvidenceError extends Error {
2
+ }
3
+ /** Default total pack budget (~50k tokens): bounded context for one agent run. */
4
+ export declare const DEFAULT_MAX_CHARS = 200000;
5
+ /** Default cap on any single file's diff (or local evidence file) inside a pack. */
6
+ export declare const DEFAULT_PER_FILE_CHARS = 8000;
7
+ export interface RunResult {
8
+ status: number;
9
+ stdout: string;
10
+ stderr: string;
11
+ }
12
+ /** Every external command (git, gh) goes through this so tests never hit the network. */
13
+ export type CommandRunner = (cmd: string, args: string[], cwd: string) => RunResult;
14
+ export declare const runCommand: CommandRunner;
15
+ export interface EpisodeCommit {
16
+ sha: string;
17
+ /** Fallback only — the pack prefers the full message from `git show`. */
18
+ message?: string;
19
+ }
20
+ export interface Episode {
21
+ id: string;
22
+ commits: EpisodeCommit[];
23
+ /** Touched paths, used for --evidence-dir matching and the pack header. */
24
+ files: string[];
25
+ prs: number[];
26
+ issues: number[];
27
+ }
28
+ /**
29
+ * Parse `why dig --episodes` output tolerantly: a bare array or an
30
+ * `{episodes: [...]}` wrapper; commits as objects (`sha` required) or bare
31
+ * sha strings; files as strings or `{path}` objects with churn fields;
32
+ * PR/issue references as numbers or digit strings. Anything that cannot be
33
+ * an episode is an EvidenceError, never a silently-empty pack.
34
+ */
35
+ export declare function readEpisodes(raw: string, source: string): Episode[];
36
+ export interface EvidencePackOptions {
37
+ /** Repository the episode's commits live in. */
38
+ repo: string;
39
+ runner?: CommandRunner;
40
+ /** Total pack budget in characters. */
41
+ maxChars?: number;
42
+ /** Per-file cap for diffs and local evidence files. */
43
+ perFileChars?: number;
44
+ /** Local exported context (postmortems, chat exports) to merge in. */
45
+ evidenceDir?: string;
46
+ }
47
+ export interface EvidencePack {
48
+ episodeId: string;
49
+ markdown: string;
50
+ /** Human notes of what was clipped where (also embedded as markers). */
51
+ clipped: string[];
52
+ /** `[unavailable: …]` reasons emitted into the pack. */
53
+ unavailable: string[];
54
+ }
55
+ export declare function buildEvidencePack(episode: Episode, options: EvidencePackOptions): Promise<EvidencePack>;
56
+ export interface GhComment {
57
+ author?: {
58
+ login?: string;
59
+ };
60
+ body?: string;
61
+ createdAt?: string;
62
+ submittedAt?: string;
63
+ state?: string;
64
+ }
65
+ /** The comment/review shape `gh` returns, tolerantly filtered. */
66
+ export declare function asComments(value: unknown): GhComment[];
67
+ /**
68
+ * A file matches an episode when its *name* mentions one of the episode's
69
+ * PR/issue numbers (digit-bounded, so `notes-30.md` never matches #3) or a
70
+ * touched path's basename — with extension, or the bare stem when it is at
71
+ * least 3 characters (so a stem like `db` can't match everything).
72
+ */
73
+ export declare function matchEvidenceFile(name: string, episode: Episode): string[];