@northtek/overstory 0.1.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 (71) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +115 -0
  3. package/dist/build/aggregate.d.ts +9 -0
  4. package/dist/build/aggregate.js +74 -0
  5. package/dist/build/builder.d.ts +52 -0
  6. package/dist/build/builder.js +223 -0
  7. package/dist/build/inmemory.d.ts +16 -0
  8. package/dist/build/inmemory.js +79 -0
  9. package/dist/build/pool.d.ts +3 -0
  10. package/dist/build/pool.js +16 -0
  11. package/dist/build/reflexion.d.ts +15 -0
  12. package/dist/build/reflexion.js +116 -0
  13. package/dist/build/summarize.d.ts +25 -0
  14. package/dist/build/summarize.js +146 -0
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +323 -0
  17. package/dist/core/bm25.d.ts +19 -0
  18. package/dist/core/bm25.js +48 -0
  19. package/dist/core/chunk.d.ts +5 -0
  20. package/dist/core/chunk.js +91 -0
  21. package/dist/core/corpus.d.ts +21 -0
  22. package/dist/core/corpus.js +112 -0
  23. package/dist/core/gate.d.ts +25 -0
  24. package/dist/core/gate.js +155 -0
  25. package/dist/core/hash.d.ts +7 -0
  26. package/dist/core/hash.js +15 -0
  27. package/dist/core/store.d.ts +74 -0
  28. package/dist/core/store.js +66 -0
  29. package/dist/core/types.d.ts +96 -0
  30. package/dist/core/types.js +1 -0
  31. package/dist/fix/prompts.d.ts +4 -0
  32. package/dist/fix/prompts.js +124 -0
  33. package/dist/fix/scan.d.ts +18 -0
  34. package/dist/fix/scan.js +197 -0
  35. package/dist/index.d.ts +28 -0
  36. package/dist/index.js +22 -0
  37. package/dist/llm/anthropic.d.ts +11 -0
  38. package/dist/llm/anthropic.js +46 -0
  39. package/dist/llm/mock.d.ts +5 -0
  40. package/dist/llm/mock.js +20 -0
  41. package/dist/llm/ollama.d.ts +11 -0
  42. package/dist/llm/ollama.js +49 -0
  43. package/dist/llm/provider.d.ts +16 -0
  44. package/dist/llm/provider.js +7 -0
  45. package/dist/llm/resolve.d.ts +14 -0
  46. package/dist/llm/resolve.js +24 -0
  47. package/dist/mcp/server.d.ts +7 -0
  48. package/dist/mcp/server.js +136 -0
  49. package/dist/query/ask.d.ts +53 -0
  50. package/dist/query/ask.js +127 -0
  51. package/dist/query/notarize.d.ts +40 -0
  52. package/dist/query/notarize.js +31 -0
  53. package/dist/registry/github.d.ts +28 -0
  54. package/dist/registry/github.js +113 -0
  55. package/dist/registry/publishClient.d.ts +25 -0
  56. package/dist/registry/publishClient.js +39 -0
  57. package/dist/registry/registry.d.ts +35 -0
  58. package/dist/registry/registry.js +57 -0
  59. package/dist/registry/repoTree.d.ts +9 -0
  60. package/dist/registry/repoTree.js +32 -0
  61. package/dist/serve/app.d.ts +4 -0
  62. package/dist/serve/app.js +809 -0
  63. package/dist/serve/server.d.ts +9 -0
  64. package/dist/serve/server.js +180 -0
  65. package/dist/serve/threads.d.ts +31 -0
  66. package/dist/serve/threads.js +69 -0
  67. package/dist/site/data.d.ts +58 -0
  68. package/dist/site/data.js +88 -0
  69. package/dist/site/generate.d.ts +21 -0
  70. package/dist/site/generate.js +827 -0
  71. package/package.json +64 -0
@@ -0,0 +1,155 @@
1
+ import { sha256 } from './hash.js';
2
+ /** Builder-side helper: cite lines start..end (1-based, inclusive) of a file as it
3
+ * currently exists in the corpus. Throws if the file is absent — you cannot honestly
4
+ * cite what you cannot see. */
5
+ export const makeSpan = (file, startLine, endLine, corpus) => {
6
+ const entry = corpus.files.get(file);
7
+ if (!entry)
8
+ throw new Error(`makeSpan: ${file} is not in the corpus`);
9
+ const last = Math.min(endLine, entry.lines.length);
10
+ const first = Math.max(1, Math.min(startLine, last));
11
+ const text = entry.lines.slice(first - 1, last).join('\n');
12
+ return { file, startLine: first, endLine: last, text, contentHash: sha256(text) };
13
+ };
14
+ /** Locate exact normalized text in a file at line granularity. Returns 1-based lines. */
15
+ const findText = (lines, needle) => {
16
+ if (needle.length === 0)
17
+ return null;
18
+ const hay = lines.join('\n');
19
+ let from = 0;
20
+ for (;;) {
21
+ const idx = hay.indexOf(needle, from);
22
+ if (idx === -1)
23
+ return null;
24
+ const startsOnBoundary = idx === 0 || hay[idx - 1] === '\n';
25
+ const end = idx + needle.length;
26
+ const endsOnBoundary = end === hay.length || hay[end] === '\n';
27
+ if (startsOnBoundary && endsOnBoundary) {
28
+ const startLine = hay.slice(0, idx).split('\n').length;
29
+ const endLine = startLine + needle.split('\n').length - 1;
30
+ return { startLine, endLine };
31
+ }
32
+ from = idx + 1;
33
+ }
34
+ };
35
+ /** Mechanical span verification against the live corpus. Assumes an internally
36
+ * consistent receipt (text/hash agreement is enforced upstream in verifyClaim).
37
+ * Self-heals when the cited text merely moved (edits above the span). */
38
+ export const verifySpan = (span, corpus) => {
39
+ const file = corpus.files.get(span.file);
40
+ if (!file)
41
+ return { verdict: 'OUT_OF_CORPUS' };
42
+ const atRecorded = file.lines.slice(span.startLine - 1, span.endLine).join('\n');
43
+ if (sha256(atRecorded) === span.contentHash)
44
+ return { verdict: 'VERIFIED' };
45
+ // Healing anchors to the first byte-identical occurrence. When the text appears more
46
+ // than once, any occurrence is equally valid EVIDENCE (the receipt is the text itself,
47
+ // not its position), so first-match is deliberate, not an oversight.
48
+ const moved = findText(file.lines, span.text);
49
+ if (moved)
50
+ return { verdict: 'VERIFIED', healed: moved };
51
+ return { verdict: 'STALE' };
52
+ };
53
+ const SEVERITY = {
54
+ VERIFIED: 0,
55
+ STALE: 1,
56
+ OUT_OF_CORPUS: 2,
57
+ UNGROUNDED: 3,
58
+ };
59
+ const worse = (a, b) => (SEVERITY[a] >= SEVERITY[b] ? a : b);
60
+ /** Fail-closed claim verification. A claim is VERIFIED only when every citation
61
+ * verifies. Any internally inconsistent receipt (text/hash mismatch — a forgery or
62
+ * corruption) voids the claim's grounding entirely: UNGROUNDED. Interior claims
63
+ * (citing child claims) verify transitively; staleness propagates upward.
64
+ *
65
+ * `path` holds only the claims on the CURRENT recursion path (backtracked on return):
66
+ * true cycles fail closed, while duplicate citations of the same evidence — a common
67
+ * LLM habit — and diamond-shaped grounding verify correctly. Duplicate refs within one
68
+ * claim are verified once (evidence is idempotent). */
69
+ export const verifyClaim = (claim, tree, corpus, path = new Set()) => {
70
+ const spans = [];
71
+ if (claim.citations.length === 0)
72
+ return { claimId: claim.id, verdict: 'UNGROUNDED', spans };
73
+ let verdict = 'VERIFIED';
74
+ const refsVerified = new Set();
75
+ for (const citation of claim.citations) {
76
+ if (citation.kind === 'span') {
77
+ const { span } = citation;
78
+ if (span.text.length === 0 || sha256(span.text) !== span.contentHash) {
79
+ return { claimId: claim.id, verdict: 'UNGROUNDED', spans };
80
+ }
81
+ const result = verifySpan(span, corpus);
82
+ spans.push(result);
83
+ verdict = worse(verdict, result.verdict);
84
+ }
85
+ else {
86
+ const { nodeId, claimId } = citation.ref;
87
+ const key = `${nodeId}:${claimId}`;
88
+ if (refsVerified.has(key))
89
+ continue;
90
+ refsVerified.add(key);
91
+ const node = Object.hasOwn(tree.nodes, nodeId) ? tree.nodes[nodeId] : undefined;
92
+ const child = node?.claims.find((c) => c.id === claimId);
93
+ if (!child || path.has(key)) {
94
+ verdict = worse(verdict, 'OUT_OF_CORPUS');
95
+ continue;
96
+ }
97
+ path.add(key);
98
+ const childResult = verifyClaim(child, tree, corpus, path);
99
+ path.delete(key);
100
+ verdict = worse(verdict, childResult.verdict);
101
+ }
102
+ }
103
+ return { claimId: claim.id, verdict, spans };
104
+ };
105
+ /** Verify every claim in the tree. Freshness is the VERIFIED fraction (1.0 for an
106
+ * empty tree — nothing claimed, nothing wrong). */
107
+ export const verifyTree = (tree, corpus) => {
108
+ const verdicts = new Map();
109
+ const details = new Map();
110
+ const byNode = new Map();
111
+ let verified = 0;
112
+ let total = 0;
113
+ for (const node of Object.values(tree.nodes)) {
114
+ const counts = { verified: 0, total: 0 };
115
+ for (const claim of node.claims) {
116
+ const result = verifyClaim(claim, tree, corpus);
117
+ verdicts.set(claim.id, result.verdict);
118
+ details.set(claim.id, result);
119
+ counts.total += 1;
120
+ total += 1;
121
+ if (result.verdict === 'VERIFIED') {
122
+ counts.verified += 1;
123
+ verified += 1;
124
+ }
125
+ }
126
+ byNode.set(node.id, counts);
127
+ }
128
+ return { verdicts, details, byNode, freshness: total === 0 ? 1 : verified / total };
129
+ };
130
+ /** Write verdicts + healed span positions back into a (copied) tree. Healing updates only
131
+ * line numbers — text and hash are untouched, so the receipt itself never changes. */
132
+ export const applyVerification = (tree, verification) => {
133
+ const nodes = {};
134
+ for (const [id, node] of Object.entries(tree.nodes)) {
135
+ nodes[id] = {
136
+ ...node,
137
+ claims: node.claims.map((claim) => {
138
+ const result = verification.details.get(claim.id);
139
+ if (!result)
140
+ return claim;
141
+ let spanIdx = 0;
142
+ const citations = claim.citations.map((citation) => {
143
+ if (citation.kind !== 'span')
144
+ return citation;
145
+ const sv = result.spans[spanIdx++];
146
+ if (!sv?.healed)
147
+ return citation;
148
+ return { ...citation, span: { ...citation.span, startLine: sv.healed.startLine, endLine: sv.healed.endLine } };
149
+ });
150
+ return { ...claim, citations, verdict: result.verdict };
151
+ }),
152
+ };
153
+ }
154
+ return { ...tree, nodes };
155
+ };
@@ -0,0 +1,7 @@
1
+ /** Split on LF or CRLF; a single trailing empty line (from a final newline) is dropped. */
2
+ export declare const splitLines: (text: string) => string[];
3
+ /** Canonical text form: LF endings, per-line trailing whitespace stripped, indentation kept.
4
+ * All hashing runs over this form so CRLF checkouts and trailing-space edits never
5
+ * invalidate a receipt. */
6
+ export declare const normalizeText: (text: string) => string;
7
+ export declare const sha256: (text: string) => string;
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Split on LF or CRLF; a single trailing empty line (from a final newline) is dropped. */
3
+ export const splitLines = (text) => {
4
+ const lines = text.split(/\r?\n/);
5
+ if (lines.length > 1 && lines[lines.length - 1] === '')
6
+ lines.pop();
7
+ return lines;
8
+ };
9
+ /** Canonical text form: LF endings, per-line trailing whitespace stripped, indentation kept.
10
+ * All hashing runs over this form so CRLF checkouts and trailing-space edits never
11
+ * invalidate a receipt. */
12
+ export const normalizeText = (text) => splitLines(text)
13
+ .map((line) => line.replace(/[ \t]+$/u, ''))
14
+ .join('\n');
15
+ export const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import type { Tree } from './types.js';
3
+ export declare const treeSchema: z.ZodObject<{
4
+ version: z.ZodLiteral<1>;
5
+ name: z.ZodString;
6
+ root: z.ZodString;
7
+ nodes: z.ZodRecord<z.ZodString, z.ZodObject<{
8
+ id: z.ZodString;
9
+ kind: z.ZodEnum<{
10
+ dir: "dir";
11
+ leaf: "leaf";
12
+ root: "root";
13
+ }>;
14
+ path: z.ZodString;
15
+ title: z.ZodString;
16
+ summary: z.ZodString;
17
+ claims: z.ZodArray<z.ZodObject<{
18
+ id: z.ZodString;
19
+ text: z.ZodString;
20
+ citations: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
21
+ kind: z.ZodLiteral<"span">;
22
+ span: z.ZodObject<{
23
+ file: z.ZodString;
24
+ startLine: z.ZodNumber;
25
+ endLine: z.ZodNumber;
26
+ contentHash: z.ZodString;
27
+ text: z.ZodString;
28
+ }, z.core.$strip>;
29
+ }, z.core.$strip>, z.ZodObject<{
30
+ kind: z.ZodLiteral<"claim">;
31
+ ref: z.ZodObject<{
32
+ nodeId: z.ZodString;
33
+ claimId: z.ZodString;
34
+ }, z.core.$strip>;
35
+ }, z.core.$strip>]>>;
36
+ verdict: z.ZodOptional<z.ZodEnum<{
37
+ OUT_OF_CORPUS: "OUT_OF_CORPUS";
38
+ STALE: "STALE";
39
+ UNGROUNDED: "UNGROUNDED";
40
+ VERIFIED: "VERIFIED";
41
+ }>>;
42
+ faithfulness: z.ZodOptional<z.ZodEnum<{
43
+ supported: "supported";
44
+ unchecked: "unchecked";
45
+ unsupported: "unsupported";
46
+ }>>;
47
+ }, z.core.$strip>>;
48
+ childIds: z.ZodArray<z.ZodString>;
49
+ sourceHash: z.ZodOptional<z.ZodString>;
50
+ builtWith: z.ZodEnum<{
51
+ extractive: "extractive";
52
+ llm: "llm";
53
+ }>;
54
+ provider: z.ZodOptional<z.ZodString>;
55
+ builtAt: z.ZodString;
56
+ }, z.core.$strip>>;
57
+ corpusFiles: z.ZodRecord<z.ZodString, z.ZodObject<{
58
+ hash: z.ZodString;
59
+ lines: z.ZodNumber;
60
+ }, z.core.$strip>>;
61
+ corpusOptions: z.ZodOptional<z.ZodObject<{
62
+ include: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ maxFiles: z.ZodOptional<z.ZodNumber>;
64
+ }, z.core.$strip>>;
65
+ builtAt: z.ZodString;
66
+ generator: z.ZodString;
67
+ }, z.core.$strip>;
68
+ export declare const treePath: (root: string) => string;
69
+ export declare const partialPath: (root: string) => string;
70
+ /** Atomic save: write temp then rename, so a killed build never corrupts the tree. */
71
+ export declare const saveTree: (path: string, tree: Tree) => Promise<void>;
72
+ /** Load + schema-validate. Returns null when missing or invalid (caller decides whether
73
+ * that means "build needed" — a corrupt tree is never trusted). */
74
+ export declare const loadTree: (path: string) => Promise<Tree | null>;
@@ -0,0 +1,66 @@
1
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { z } from 'zod';
4
+ const spanSchema = z.object({
5
+ file: z.string(),
6
+ startLine: z.number().int().min(1),
7
+ endLine: z.number().int().min(1),
8
+ contentHash: z.string(),
9
+ text: z.string(),
10
+ });
11
+ const citationSchema = z.union([
12
+ z.object({ kind: z.literal('span'), span: spanSchema }),
13
+ z.object({ kind: z.literal('claim'), ref: z.object({ nodeId: z.string(), claimId: z.string() }) }),
14
+ ]);
15
+ const claimSchema = z.object({
16
+ id: z.string(),
17
+ text: z.string(),
18
+ citations: z.array(citationSchema),
19
+ verdict: z.enum(['VERIFIED', 'STALE', 'OUT_OF_CORPUS', 'UNGROUNDED']).optional(),
20
+ faithfulness: z.enum(['supported', 'unsupported', 'unchecked']).optional(),
21
+ });
22
+ const nodeSchema = z.object({
23
+ id: z.string(),
24
+ kind: z.enum(['leaf', 'dir', 'root']),
25
+ path: z.string(),
26
+ title: z.string(),
27
+ summary: z.string(),
28
+ claims: z.array(claimSchema),
29
+ childIds: z.array(z.string()),
30
+ sourceHash: z.string().optional(),
31
+ builtWith: z.enum(['llm', 'extractive']),
32
+ provider: z.string().optional(),
33
+ builtAt: z.string(),
34
+ });
35
+ export const treeSchema = z.object({
36
+ version: z.literal(1),
37
+ name: z.string(),
38
+ root: z.string(),
39
+ nodes: z.record(z.string(), nodeSchema),
40
+ corpusFiles: z.record(z.string(), z.object({ hash: z.string(), lines: z.number().int() })),
41
+ corpusOptions: z
42
+ .object({ include: z.array(z.string()).optional(), maxFiles: z.number().int().optional() })
43
+ .optional(),
44
+ builtAt: z.string(),
45
+ generator: z.string(),
46
+ });
47
+ export const treePath = (root) => join(root, '.overstory', 'tree.json');
48
+ export const partialPath = (root) => join(root, '.overstory', 'tree.partial.json');
49
+ /** Atomic save: write temp then rename, so a killed build never corrupts the tree. */
50
+ export const saveTree = async (path, tree) => {
51
+ await mkdir(dirname(path), { recursive: true });
52
+ const tmp = `${path}.tmp`;
53
+ await writeFile(tmp, JSON.stringify(tree), 'utf8');
54
+ await rename(tmp, path);
55
+ };
56
+ /** Load + schema-validate. Returns null when missing or invalid (caller decides whether
57
+ * that means "build needed" — a corrupt tree is never trusted). */
58
+ export const loadTree = async (path) => {
59
+ try {
60
+ const raw = await readFile(path, 'utf8');
61
+ return treeSchema.parse(JSON.parse(raw));
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ };
@@ -0,0 +1,96 @@
1
+ export type Verdict = 'VERIFIED' | 'STALE' | 'OUT_OF_CORPUS' | 'UNGROUNDED';
2
+ /** A contiguous source span. Stores its normalized text so receipts stay
3
+ * renderable without the repo present and citations can self-heal when code moves. */
4
+ export interface SpanRef {
5
+ file: string;
6
+ startLine: number;
7
+ endLine: number;
8
+ contentHash: string;
9
+ text: string;
10
+ }
11
+ export interface NodeClaimRef {
12
+ nodeId: string;
13
+ claimId: string;
14
+ }
15
+ export type Citation = {
16
+ kind: 'span';
17
+ span: SpanRef;
18
+ } | {
19
+ kind: 'claim';
20
+ ref: NodeClaimRef;
21
+ };
22
+ /** Semantic disclosure tier, set at build time. `supported`: the Reflexion critique
23
+ * confirmed the cited lines support the claim text. `unsupported`: the critique flagged a
24
+ * mismatch. `unchecked`: no critique ran (budget-capped or provider failure) — honest
25
+ * default, never upgraded silently. Extractive claims are supported-by-construction. */
26
+ export type Faithfulness = 'supported' | 'unsupported' | 'unchecked';
27
+ export interface Claim {
28
+ id: string;
29
+ text: string;
30
+ citations: Citation[];
31
+ verdict?: Verdict;
32
+ faithfulness?: Faithfulness;
33
+ }
34
+ export interface TreeNode {
35
+ id: string;
36
+ kind: 'leaf' | 'dir' | 'root';
37
+ path: string;
38
+ title: string;
39
+ summary: string;
40
+ claims: Claim[];
41
+ childIds: string[];
42
+ sourceHash?: string;
43
+ builtWith: 'llm' | 'extractive';
44
+ provider?: string;
45
+ builtAt: string;
46
+ }
47
+ export interface CorpusFileMeta {
48
+ hash: string;
49
+ lines: number;
50
+ }
51
+ export interface Tree {
52
+ version: 1;
53
+ name: string;
54
+ root: string;
55
+ nodes: Record<string, TreeNode>;
56
+ corpusFiles: Record<string, CorpusFileMeta>;
57
+ /** Corpus options the tree was built with. Verification MUST reuse these — verifying
58
+ * against a differently-scoped corpus produces wrong verdicts, not conservative ones. */
59
+ corpusOptions?: {
60
+ include?: string[];
61
+ maxFiles?: number;
62
+ };
63
+ builtAt: string;
64
+ generator: string;
65
+ }
66
+ /** Live view of the corpus used by the gate at verification time. */
67
+ export interface CorpusSnapshot {
68
+ root: string;
69
+ files: Map<string, {
70
+ hash: string;
71
+ lines: string[];
72
+ }>;
73
+ }
74
+ export interface SpanVerification {
75
+ verdict: Extract<Verdict, 'VERIFIED' | 'STALE' | 'OUT_OF_CORPUS'>;
76
+ /** Present when the span text was found at a new location (code moved). */
77
+ healed?: {
78
+ startLine: number;
79
+ endLine: number;
80
+ };
81
+ }
82
+ export interface ClaimVerification {
83
+ claimId: string;
84
+ verdict: Verdict;
85
+ spans: SpanVerification[];
86
+ }
87
+ export interface TreeVerification {
88
+ verdicts: Map<string, Verdict>;
89
+ details: Map<string, ClaimVerification>;
90
+ byNode: Map<string, {
91
+ verified: number;
92
+ total: number;
93
+ }>;
94
+ /** Fraction of claims VERIFIED across the whole tree, 0..1. */
95
+ freshness: number;
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { Finding } from './scan.js';
2
+ /** Render one finding as a complete, paste-ready prompt for a coding agent. */
3
+ export declare const findingToPrompt: (f: Finding, index: number) => string;
4
+ export declare const findingsToMarkdown: (findings: Finding[], repoName: string) => string;
@@ -0,0 +1,124 @@
1
+ /** Per-kind goal/steps material. The composer wraps these in the spec shape: one bounded
2
+ * goal, receipts as context, explicit constraints, machine-checkable acceptance, steps. */
3
+ const KIND_SPEC = {
4
+ 'todo-comment': {
5
+ goal: (f) => `Resolve the ${f.title.split(' in ')[0]} comment: ${f.detail}. Implement what the comment promises (or, if it is genuinely obsolete, remove it with a one-line justification in the commit message).`,
6
+ steps: [
7
+ 'Read the receipt lines and the surrounding function to understand what the comment defers.',
8
+ 'Implement the smallest complete version of the deferred work.',
9
+ 'Delete the comment — done work does not need a tombstone.',
10
+ 'Add or extend one test that fails before your change and passes after.',
11
+ ],
12
+ },
13
+ 'stale-doc-claim': {
14
+ goal: (f) => `Make the documentation true again: ${f.detail} Update the doc to match what the code does NOW (never change code to match old docs without evidence the code regressed).`,
15
+ steps: [
16
+ 'Open the cited doc lines (receipts below) and the code they describe.',
17
+ 'Determine which side is right: did the code intentionally move on, or did it regress?',
18
+ 'Rewrite the stale passage to describe current behavior, precisely and briefly.',
19
+ ],
20
+ extraAcceptance: ['Rebuild the tree and confirm the claim flips back to VERIFIED: `npx @northtek/overstory build && npx @northtek/overstory verify` exits 0.'],
21
+ },
22
+ 'oversized-file': {
23
+ goal: (f) => `${f.title}. Split it along its natural seams into focused modules without changing any behavior.`,
24
+ steps: [
25
+ 'List the file\'s top-level declarations and group them by cohesion (what changes together).',
26
+ 'Extract one cohesive group at a time into a new module; re-export from the original file to keep the public surface identical.',
27
+ 'Move imports; run the type checker after each extraction, not just at the end.',
28
+ ],
29
+ extraAcceptance: ['No public API change: existing imports of this module compile untouched.'],
30
+ },
31
+ 'empty-catch': {
32
+ goal: (f) => `${f.title}: ${f.detail} Decide what this failure MEANS and handle it explicitly.`,
33
+ steps: [
34
+ 'Determine what can actually throw inside the try block (read the receipt).',
35
+ 'Choose deliberately: propagate, retry, degrade with a logged warning, or prove it cannot throw and remove the try.',
36
+ 'Never leave `catch {}` — even a comment explaining why ignoring is CORRECT beats silence.',
37
+ ],
38
+ },
39
+ 'debug-leftover': {
40
+ goal: (f) => `${f.title}. Remove it or promote it to intentional, leveled logging.`,
41
+ steps: ['If it aided debugging once, delete it.', 'If operators genuinely need it, route it through the project\'s logging convention with a level.'],
42
+ },
43
+ 'ts-escape-hatch': {
44
+ goal: (f) => `${f.title}: ${f.detail} Restore type safety at this point.`,
45
+ steps: [
46
+ 'Read what the compiler was complaining about before the escape hatch.',
47
+ 'Fix the underlying type (narrowing, generics, or a corrected signature) instead of silencing it.',
48
+ 'If the silencing is truly required (third-party gap), leave @ts-expect-error WITH a one-line reason.',
49
+ ],
50
+ },
51
+ 'untested-module': {
52
+ goal: (f) => `${f.title}. Write focused tests for its exported behavior.`,
53
+ steps: [
54
+ 'For each receipt (an exported declaration), write the smallest test proving its contract: expected output for a representative input, plus one edge case.',
55
+ 'Test through the public surface only — no reaching into internals.',
56
+ 'Include one negative-control test: an input that must fail, failing the right way.',
57
+ ],
58
+ extraAcceptance: ['New tests fail if the module\'s behavior is broken (verify by temporarily inverting one assertion).'],
59
+ },
60
+ 'undocumented-dir': {
61
+ goal: (f) => `${f.title}. Write a short README for this directory: what it is, how its files relate, where a reader starts.`,
62
+ steps: [
63
+ 'Start from what the knowledge tree already says (in the finding detail).',
64
+ 'Write 10-20 lines: purpose, the 2-3 load-bearing files, one usage example if applicable.',
65
+ 'Precise beats complete — link to code rather than restating it.',
66
+ ],
67
+ },
68
+ 'unsupported-claim': {
69
+ goal: (f) => `${f.detail} Make the code say what it means: rename, restructure, or comment the cited lines so a reader (and the next summarizer) cannot misread them.`,
70
+ steps: [
71
+ 'Read the cited lines and the claim; identify exactly what misleads.',
72
+ 'Prefer renames and small restructures over comments; comment only what code cannot express.',
73
+ ],
74
+ extraAcceptance: ['Rebuild and confirm the claim comes back `supported`: `npx @northtek/overstory build` then check the node in the explorer.'],
75
+ },
76
+ };
77
+ const BASE_CONSTRAINTS = [
78
+ 'Smallest possible diff — change nothing beyond the stated goal.',
79
+ 'No new dependencies.',
80
+ 'Match the file\'s existing style and conventions exactly.',
81
+ 'If you discover the premise is wrong (the finding misread the code), STOP and say so instead of forcing a change.',
82
+ ];
83
+ const BASE_ACCEPTANCE = [
84
+ 'The project\'s existing tests and type checks pass, with the command output shown.',
85
+ 'Every claim in the change is verifiable from the diff — no "should work" without the proving command.',
86
+ ];
87
+ /** Render one finding as a complete, paste-ready prompt for a coding agent. */
88
+ export const findingToPrompt = (f, index) => {
89
+ const spec = KIND_SPEC[f.kind];
90
+ const receipts = f.receipts.length
91
+ ? f.receipts
92
+ .map((r) => `\`${r.file}:${r.startLine}-${r.endLine}\`\n\`\`\`\n${r.text}\n\`\`\``)
93
+ .join('\n')
94
+ : '_(structural finding — receipts are the directory listing itself)_';
95
+ const acceptance = [...(spec.extraAcceptance ?? []), ...BASE_ACCEPTANCE];
96
+ return `## Fix ${index}: ${f.title}
97
+
98
+ **Goal** — ${spec.goal(f)}
99
+
100
+ **Context (receipts — the exact lines this finding is grounded in):**
101
+ ${receipts}
102
+
103
+ **Constraints:**
104
+ ${BASE_CONSTRAINTS.map((c) => `- ${c}`).join('\n')}
105
+
106
+ **Acceptance criteria (machine-checkable):**
107
+ ${acceptance.map((a) => `- ${a}`).join('\n')}
108
+
109
+ **Steps:**
110
+ ${spec.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}
111
+ `;
112
+ };
113
+ export const findingsToMarkdown = (findings, repoName) => {
114
+ const bySeverity = [1, 2, 3].map((s) => findings.filter((f) => f.severity === s).length);
115
+ return `# Fix prompts for ${repoName}
116
+
117
+ Generated by OVERSTORY from the verified knowledge tree — every prompt is grounded in
118
+ receipts (exact lines), scoped to one bounded change, and closes with machine-checkable
119
+ acceptance criteria. Paste one prompt per agent session; smallest diff wins.
120
+
121
+ ${findings.length} findings (${bySeverity[0]} fix-first · ${bySeverity[1]} soon · ${bySeverity[2]} when-convenient)
122
+
123
+ ${findings.map((f, i) => findingToPrompt(f, i + 1)).join('\n---\n\n')}`;
124
+ };
@@ -0,0 +1,18 @@
1
+ import type { CorpusSnapshot, Tree, TreeVerification } from '../core/types.js';
2
+ export type FindingKind = 'todo-comment' | 'stale-doc-claim' | 'oversized-file' | 'empty-catch' | 'debug-leftover' | 'ts-escape-hatch' | 'untested-module' | 'undocumented-dir' | 'unsupported-claim';
3
+ export interface FindingReceipt {
4
+ file: string;
5
+ startLine: number;
6
+ endLine: number;
7
+ text: string;
8
+ }
9
+ export interface Finding {
10
+ kind: FindingKind;
11
+ severity: 1 | 2 | 3;
12
+ title: string;
13
+ detail: string;
14
+ receipts: FindingReceipt[];
15
+ }
16
+ /** Deterministic issue scanner — no LLM anywhere. Every finding carries receipts (the
17
+ * exact lines), so the generated prompts are grounded, not vibes. Caps keep output usable. */
18
+ export declare const scanFindings: (corpus: CorpusSnapshot, tree: Tree, verification: TreeVerification) => Finding[];