@flyingrobots/graft 0.3.5 → 0.5.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 (111) hide show
  1. package/ARCHITECTURE.md +386 -0
  2. package/CHANGELOG.md +69 -0
  3. package/CODE_OF_CONDUCT.md +65 -0
  4. package/README.md +153 -17
  5. package/bin/graft.js +4 -11
  6. package/docs/ADVANCED_GUIDE.md +49 -0
  7. package/docs/CLI.md +43 -0
  8. package/docs/GUIDE.md +321 -32
  9. package/docs/MCP.md +44 -0
  10. package/package.json +17 -4
  11. package/src/adapters/node-fs.ts +4 -0
  12. package/src/adapters/node-git.ts +47 -0
  13. package/src/adapters/node-process-runner.ts +27 -0
  14. package/src/cli/index-cmd.ts +86 -0
  15. package/src/cli/init.ts +808 -57
  16. package/src/cli/main.ts +437 -0
  17. package/src/contracts/capabilities.ts +341 -0
  18. package/src/contracts/causal-ontology.ts +622 -0
  19. package/src/contracts/causal-surface-next-action.ts +18 -0
  20. package/src/contracts/output-schemas.ts +1169 -0
  21. package/src/git/diff.ts +25 -21
  22. package/src/git/target-git-hook-bootstrap.ts +56 -0
  23. package/src/hooks/posttooluse-read.ts +21 -74
  24. package/src/hooks/pretooluse-read.ts +20 -56
  25. package/src/hooks/read-governor.ts +95 -0
  26. package/src/hooks/read-messages.ts +53 -0
  27. package/src/mcp/burden.ts +123 -0
  28. package/src/mcp/cache.ts +51 -0
  29. package/src/mcp/cached-file.ts +10 -8
  30. package/src/mcp/context.ts +67 -2
  31. package/src/mcp/daemon-control-plane.ts +554 -0
  32. package/src/mcp/daemon-job-scheduler.ts +279 -0
  33. package/src/mcp/daemon-repos.ts +216 -0
  34. package/src/mcp/daemon-server.ts +396 -0
  35. package/src/mcp/daemon-worker-pool.ts +310 -0
  36. package/src/mcp/daemon-worker-process.ts +52 -0
  37. package/src/mcp/metrics.ts +108 -1
  38. package/src/mcp/monitor-tick-job.ts +99 -0
  39. package/src/mcp/persisted-local-history.ts +1246 -0
  40. package/src/mcp/persistent-monitor-runtime.ts +549 -0
  41. package/src/mcp/policy.ts +84 -0
  42. package/src/mcp/receipt.ts +82 -12
  43. package/src/mcp/repo-concurrency.ts +318 -0
  44. package/src/mcp/repo-state.ts +777 -0
  45. package/src/mcp/repo-tool-job.ts +302 -0
  46. package/src/mcp/run-capture-config.ts +33 -0
  47. package/src/mcp/runtime-causal-context.ts +72 -0
  48. package/src/mcp/runtime-observability.ts +219 -0
  49. package/src/mcp/runtime-staged-target.ts +161 -0
  50. package/src/mcp/runtime-workspace-overlay.ts +255 -0
  51. package/src/mcp/semantic-transition-guidance.ts +60 -0
  52. package/src/mcp/semantic-transition-summary.ts +130 -0
  53. package/src/mcp/server.ts +704 -45
  54. package/src/mcp/stdio-server.ts +12 -0
  55. package/src/mcp/stdio.ts +2 -5
  56. package/src/mcp/tools/activity-view.ts +325 -0
  57. package/src/mcp/tools/causal-attach.ts +67 -0
  58. package/src/mcp/tools/causal-status.ts +58 -0
  59. package/src/mcp/tools/changed-since.ts +13 -11
  60. package/src/mcp/tools/code-find.ts +164 -0
  61. package/src/mcp/tools/code-refs.ts +466 -0
  62. package/src/mcp/tools/code-show.ts +252 -0
  63. package/src/mcp/tools/daemon-monitors.ts +14 -0
  64. package/src/mcp/tools/daemon-repos.ts +22 -0
  65. package/src/mcp/tools/daemon-sessions.ts +14 -0
  66. package/src/mcp/tools/daemon-status.ts +12 -0
  67. package/src/mcp/tools/doctor.ts +45 -2
  68. package/src/mcp/tools/explain.ts +4 -0
  69. package/src/mcp/tools/file-outline.ts +7 -3
  70. package/src/mcp/tools/git-files.ts +73 -0
  71. package/src/mcp/tools/graft-diff.ts +12 -4
  72. package/src/mcp/tools/map.ts +136 -0
  73. package/src/mcp/tools/monitor-pause.ts +18 -0
  74. package/src/mcp/tools/monitor-resume.ts +18 -0
  75. package/src/mcp/tools/monitor-start.ts +20 -0
  76. package/src/mcp/tools/monitor-stop.ts +18 -0
  77. package/src/mcp/tools/precision-match.ts +51 -0
  78. package/src/mcp/tools/precision-query.ts +127 -0
  79. package/src/mcp/tools/precision.ts +312 -0
  80. package/src/mcp/tools/run-capture.ts +126 -44
  81. package/src/mcp/tools/safe-read.ts +14 -12
  82. package/src/mcp/tools/since.ts +49 -0
  83. package/src/mcp/tools/state.ts +11 -3
  84. package/src/mcp/tools/stats.ts +5 -1
  85. package/src/mcp/tools/workspace-authorizations.ts +14 -0
  86. package/src/mcp/tools/workspace-authorize.ts +20 -0
  87. package/src/mcp/tools/workspace-bind.ts +25 -0
  88. package/src/mcp/tools/workspace-rebind.ts +25 -0
  89. package/src/mcp/tools/workspace-revoke.ts +18 -0
  90. package/src/mcp/tools/workspace-status.ts +12 -0
  91. package/src/mcp/warp-pool.ts +36 -0
  92. package/src/mcp/workspace-router.ts +984 -0
  93. package/src/operations/file-outline.ts +12 -2
  94. package/src/operations/graft-diff.ts +56 -10
  95. package/src/operations/safe-read.ts +27 -4
  96. package/src/operations/state.ts +6 -9
  97. package/src/parser/lang.ts +19 -3
  98. package/src/parser/outline.ts +191 -2
  99. package/src/parser/types.ts +9 -1
  100. package/src/policy/types.ts +4 -3
  101. package/src/ports/filesystem.ts +1 -0
  102. package/src/ports/git.ts +16 -0
  103. package/src/ports/process-runner.ts +22 -0
  104. package/src/release/security-gate.ts +102 -0
  105. package/src/session/tracker.ts +31 -0
  106. package/src/version.ts +3 -0
  107. package/src/warp/indexer.ts +513 -0
  108. package/src/warp/observers.ts +105 -0
  109. package/src/warp/open.ts +31 -0
  110. package/src/warp/plumbing.d.ts +15 -0
  111. package/src/warp/writer-id.ts +30 -0
@@ -1,4 +1,4 @@
1
- import { extractOutline } from "../parser/outline.js";
1
+ import { extractOutlineForFile } from "../parser/outline.js";
2
2
  import type { OutlineEntry, JumpEntry } from "../parser/types.js";
3
3
  import type { FileSystem } from "../ports/filesystem.js";
4
4
 
@@ -8,6 +8,7 @@ export interface FileOutlineResult {
8
8
  outline: OutlineEntry[];
9
9
  jumpTable: JumpEntry[];
10
10
  partial?: boolean | undefined;
11
+ reason?: "UNSUPPORTED_LANGUAGE" | undefined;
11
12
  error?: string | undefined;
12
13
  }
13
14
 
@@ -27,7 +28,16 @@ export async function fileOutline(
27
28
  };
28
29
  }
29
30
 
30
- const result = extractOutline(content);
31
+ const result = extractOutlineForFile(filePath, content);
32
+ if (result === null) {
33
+ return {
34
+ path: filePath,
35
+ outline: [],
36
+ jumpTable: [],
37
+ reason: "UNSUPPORTED_LANGUAGE",
38
+ error: "Unsupported file type: no parser-backed outline available",
39
+ };
40
+ }
31
41
 
32
42
  return {
33
43
  path: filePath,
@@ -1,5 +1,5 @@
1
- import * as path from "node:path";
2
1
  import type { FileSystem } from "../ports/filesystem.js";
2
+ import type { GitClient } from "../ports/git.js";
3
3
  import { getChangedFiles, getFileAtRef } from "../git/diff.js";
4
4
  import { detectLang } from "../parser/lang.js";
5
5
  import { extractOutline } from "../parser/outline.js";
@@ -12,6 +12,14 @@ export interface FileDiff {
12
12
  diff: OutlineDiff;
13
13
  }
14
14
 
15
+ export interface GraftDiffRefusal {
16
+ path: string;
17
+ reason: string;
18
+ reasonDetail: string;
19
+ next: readonly string[];
20
+ actual: { lines: number; bytes: number };
21
+ }
22
+
15
23
  function buildSummary(filePath: string, status: string, diff: OutlineDiff): string {
16
24
  const parts: string[] = [];
17
25
  if (diff.added.length > 0) parts.push(`+${String(diff.added.length)} added`);
@@ -27,31 +35,57 @@ export interface GraftDiffResult {
27
35
  base: string;
28
36
  head: string;
29
37
  files: FileDiff[];
38
+ refused?: GraftDiffRefusal[] | undefined;
30
39
  }
31
40
 
32
41
  export interface GraftDiffOptions {
33
42
  cwd: string;
34
43
  fs: FileSystem;
44
+ git: GitClient;
45
+ resolveWorkingTreePath: (filePath: string) => string;
35
46
  base?: string | undefined;
36
47
  head?: string | undefined;
37
48
  path?: string | undefined;
49
+ refusalCheck?: ((filePath: string, actual: { lines: number; bytes: number }) => GraftDiffRefusal | null) | undefined;
38
50
  }
39
51
 
40
52
  function emptyDiff(): OutlineDiff {
41
53
  return new OutlineDiff({ added: [], removed: [], changed: [], unchangedCount: 0 });
42
54
  }
43
55
 
56
+ function countLines(content: string): number {
57
+ return content.split("\n").length;
58
+ }
59
+
60
+ function measureActual(
61
+ baseContent: string | null,
62
+ headContent: string | null,
63
+ ): { lines: number; bytes: number } {
64
+ const byteLengths = [baseContent, headContent]
65
+ .filter((content): content is string => content !== null)
66
+ .map((content) => Buffer.byteLength(content));
67
+ const lineCounts = [baseContent, headContent]
68
+ .filter((content): content is string => content !== null)
69
+ .map((content) => countLines(content));
70
+
71
+ return {
72
+ bytes: byteLengths.length > 0 ? Math.max(...byteLengths) : 0,
73
+ lines: lineCounts.length > 0 ? Math.max(...lineCounts) : 0,
74
+ };
75
+ }
76
+
44
77
 
45
78
  /**
46
79
  * Compute structural diffs between two git refs (or working tree).
47
80
  */
48
- export function graftDiff(opts: GraftDiffOptions): GraftDiffResult {
81
+ export async function graftDiff(opts: GraftDiffOptions): Promise<GraftDiffResult> {
49
82
  const base = opts.base ?? "HEAD";
50
83
  const headLabel = opts.head ?? "working tree";
51
84
  const cwd = opts.cwd;
52
85
 
53
- let changedFiles = getChangedFiles({
86
+ let changedFiles = await getChangedFiles({
54
87
  cwd,
88
+ git: opts.git,
55
89
  base,
56
90
  head: opts.head,
57
91
  });
@@ -62,21 +96,19 @@ export function graftDiff(opts: GraftDiffOptions): GraftDiffResult {
62
96
  }
63
97
 
64
98
  const files: FileDiff[] = [];
99
+ const refused: GraftDiffRefusal[] = [];
65
100
 
66
101
  for (const filePath of changedFiles) {
67
- const lang = detectLang(filePath);
68
-
69
102
  // Get content at base (null = file absent at ref)
70
- const baseContent = getFileAtRef(base, filePath, cwd);
103
+ const baseContent = await getFileAtRef(base, filePath, { cwd, git: opts.git });
71
104
 
72
105
  // Get content at head (null = file absent at ref/worktree)
73
106
  let headContent: string | null;
74
107
  if (opts.head !== undefined) {
75
- headContent = getFileAtRef(opts.head, filePath, cwd);
108
+ headContent = await getFileAtRef(opts.head, filePath, { cwd, git: opts.git });
76
109
  } else {
77
- const fullPath = path.join(cwd, filePath);
78
110
  try {
79
- headContent = opts.fs.readFileSync(fullPath, "utf-8");
111
+ headContent = await opts.fs.readFile(opts.resolveWorkingTreePath(filePath), "utf-8");
80
112
  } catch {
81
113
  headContent = null;
82
114
  }
@@ -95,6 +127,15 @@ export function graftDiff(opts: GraftDiffOptions): GraftDiffResult {
95
127
  status = "modified";
96
128
  }
97
129
 
130
+ const actual = measureActual(baseContent, headContent);
131
+ const refusal = opts.refusalCheck?.(filePath, actual) ?? null;
132
+ if (refusal !== null) {
133
+ refused.push(refusal);
134
+ continue;
135
+ }
136
+
137
+ const lang = detectLang(filePath);
138
+
98
139
  // Compute structural diff (only for supported languages)
99
140
  if (lang === null) {
100
141
  const empty = emptyDiff();
@@ -113,5 +154,10 @@ export function graftDiff(opts: GraftDiffOptions): GraftDiffResult {
113
154
  files.push({ path: filePath, status, summary: buildSummary(filePath, status, diff), diff });
114
155
  }
115
156
 
116
- return { base, head: headLabel, files };
157
+ return {
158
+ base,
159
+ head: headLabel,
160
+ files,
161
+ ...(refused.length > 0 ? { refused } : {}),
162
+ };
117
163
  }
@@ -1,7 +1,7 @@
1
1
  import { evaluatePolicy } from "../policy/evaluate.js";
2
2
  import { ContentResult, RefusedResult } from "../policy/types.js";
3
3
  import type { SessionDepth } from "../policy/types.js";
4
- import { extractOutline } from "../parser/outline.js";
4
+ import { extractOutlineForFile } from "../parser/outline.js";
5
5
  import type { OutlineEntry, JumpEntry } from "../parser/types.js";
6
6
  import type { FileSystem } from "../ports/filesystem.js";
7
7
  import type { JsonCodec } from "../ports/codec.js";
@@ -26,6 +26,8 @@ export interface SafeReadOptions {
26
26
  codec: JsonCodec;
27
27
  content?: string | undefined;
28
28
  intent?: string | undefined;
29
+ policyPath?: string | undefined;
30
+ graftignorePatterns?: string[] | undefined;
29
31
  sessionDepth?: SessionDepth | undefined;
30
32
  budgetRemaining?: number | undefined;
31
33
  }
@@ -58,8 +60,12 @@ export async function safeRead(
58
60
  const lines = content.split("\n").length;
59
61
 
60
62
  const policy = evaluatePolicy(
61
- { path: filePath, lines, bytes },
62
- { sessionDepth: options.sessionDepth, budgetRemaining: options.budgetRemaining },
63
+ { path: options.policyPath ?? filePath, lines, bytes },
64
+ {
65
+ graftignorePatterns: options.graftignorePatterns,
66
+ sessionDepth: options.sessionDepth,
67
+ budgetRemaining: options.budgetRemaining,
68
+ },
63
69
  );
64
70
 
65
71
  const base: SafeReadResult = {
@@ -83,7 +89,24 @@ export async function safeRead(
83
89
  }
84
90
 
85
91
  // projection === "outline"
86
- const outlineResult = extractOutline(content);
92
+ const outlineResult = extractOutlineForFile(filePath, content);
93
+ if (outlineResult === null) {
94
+ const emptyOutlineJson = options.codec.encode({ entries: [], jumpTable: [] });
95
+ const estimatedBytesAvoided = bytes - Buffer.byteLength(emptyOutlineJson, "utf-8");
96
+
97
+ return {
98
+ ...base,
99
+ reason: "UNSUPPORTED_LANGUAGE",
100
+ outline: [],
101
+ jumpTable: [],
102
+ estimatedBytesAvoided: estimatedBytesAvoided > 0 ? estimatedBytesAvoided : 0,
103
+ next: [
104
+ "No parser-backed outline is available for this file type.",
105
+ "Use read_range for targeted reads if you know the section you need.",
106
+ ],
107
+ };
108
+ }
109
+
87
110
  const outlineJson = options.codec.encode(outlineResult);
88
111
  const estimatedBytesAvoided = bytes - Buffer.byteLength(outlineJson, "utf-8");
89
112
 
@@ -1,31 +1,28 @@
1
- import * as path from "node:path";
2
1
  import type { FileSystem } from "../ports/filesystem.js";
3
2
 
4
3
  const MAX_STATE_BYTES = 8192;
5
- const STATE_FILENAME = "state.md";
4
+ export const STATE_FILENAME = "state.md";
6
5
 
7
6
  export async function stateSave(
8
7
  content: string,
9
- opts: { graftDir: string; fs: FileSystem },
8
+ opts: { stateDir: string; statePath: string; fs: FileSystem },
10
9
  ): Promise<{ ok: boolean; reason?: string | undefined }> {
11
10
  const bytes = Buffer.byteLength(content, "utf-8");
12
11
  if (bytes > MAX_STATE_BYTES) {
13
12
  return { ok: false, reason: `State exceeds 8 KB limit (${String(bytes)} bytes)` };
14
13
  }
15
14
 
16
- const filePath = path.join(opts.graftDir, STATE_FILENAME);
17
- await opts.fs.mkdir(opts.graftDir, { recursive: true });
18
- await opts.fs.writeFile(filePath, content, "utf-8");
15
+ await opts.fs.mkdir(opts.stateDir, { recursive: true });
16
+ await opts.fs.writeFile(opts.statePath, content, "utf-8");
19
17
 
20
18
  return { ok: true };
21
19
  }
22
20
 
23
21
  export async function stateLoad(
24
- opts: { graftDir: string; fs: FileSystem },
22
+ opts: { statePath: string; fs: FileSystem },
25
23
  ): Promise<{ content: string | null }> {
26
- const filePath = path.join(opts.graftDir, STATE_FILENAME);
27
24
  try {
28
- const content = await opts.fs.readFile(filePath, "utf-8");
25
+ const content = await opts.fs.readFile(opts.statePath, "utf-8");
29
26
  return { content };
30
27
  } catch {
31
28
  return { content: null };
@@ -1,12 +1,28 @@
1
1
  import * as path from "node:path";
2
2
 
3
+ export type SupportedLang = "ts" | "js";
4
+ export type SupportedStructuredFormat = SupportedLang | "md";
5
+
3
6
  /**
4
7
  * Detect the tree-sitter language from a file extension.
5
8
  * Returns null for unsupported file types.
6
9
  */
7
- export function detectLang(filePath: string): "ts" | "js" | null {
10
+ export function detectLang(filePath: string): SupportedLang | null {
11
+ const ext = path.extname(filePath).toLowerCase();
12
+ if (ext === ".ts" || ext === ".tsx" || ext === ".mts" || ext === ".cts") return "ts";
13
+ if (ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") return "js";
14
+ return null;
15
+ }
16
+
17
+ /**
18
+ * Detect the supported structured format for bounded read surfaces.
19
+ * Returns null for file types that still have no explicit extractor.
20
+ */
21
+ export function detectStructuredFormat(filePath: string): SupportedStructuredFormat | null {
22
+ const lang = detectLang(filePath);
23
+ if (lang !== null) return lang;
24
+
8
25
  const ext = path.extname(filePath).toLowerCase();
9
- if (ext === ".ts" || ext === ".tsx") return "ts";
10
- if (ext === ".js" || ext === ".jsx") return "js";
26
+ if (ext === ".md") return "md";
11
27
  return null;
12
28
  }
@@ -1,5 +1,7 @@
1
1
  import Parser from "web-tree-sitter";
2
2
  import { createRequire } from "node:module";
3
+ import { detectStructuredFormat } from "./lang.js";
4
+ import type { SupportedStructuredFormat } from "./lang.js";
3
5
  import { OutlineEntry, JumpEntry } from "./types.js";
4
6
  import type { OutlineResult } from "./types.js";
5
7
 
@@ -53,6 +55,17 @@ interface TSNode {
53
55
  childForFieldName(name: string): TSNode | null;
54
56
  }
55
57
 
58
+ interface MarkdownHeading {
59
+ level: number;
60
+ name: string;
61
+ start: number;
62
+ end: number;
63
+ }
64
+
65
+ interface MarkdownHeadingNode extends MarkdownHeading {
66
+ children: MarkdownHeadingNode[];
67
+ }
68
+
56
69
  // ---------------------------------------------------------------------------
57
70
  // Node kind mapping
58
71
  // ---------------------------------------------------------------------------
@@ -210,6 +223,166 @@ function buildJumpEntry(
210
223
  });
211
224
  }
212
225
 
226
+ function buildMarkdownJumpEntry(heading: MarkdownHeading): JumpEntry {
227
+ return new JumpEntry({
228
+ symbol: heading.name,
229
+ kind: "heading",
230
+ start: heading.start,
231
+ end: heading.end,
232
+ });
233
+ }
234
+
235
+ function isFenceLine(line: string): { marker: "`" | "~"; length: number } | null {
236
+ const match = /^\s*([`~])\1{2,}.*$/.exec(line);
237
+ if (match === null) return null;
238
+
239
+ const marker = match[1];
240
+ if (marker !== "`" && marker !== "~") return null;
241
+ const markerRun = /^([`~]+)/.exec(match[0].trimStart());
242
+ if (markerRun === null) return null;
243
+ const run = markerRun[1];
244
+ if (run === undefined) return null;
245
+ return { marker, length: run.length };
246
+ }
247
+
248
+ function isFenceClose(line: string, marker: "`" | "~", length: number): boolean {
249
+ const pattern = marker === "`"
250
+ ? new RegExp(`^\\s*\`{${String(length)},}\\s*$`)
251
+ : new RegExp(`^\\s*~{${String(length)},}\\s*$`);
252
+ return pattern.test(line);
253
+ }
254
+
255
+ function parseAtxHeading(line: string, lineNumber: number): MarkdownHeading | null {
256
+ const match = /^\s{0,3}(#{1,6})[ \t]+(.+?)\s*#*\s*$/.exec(line);
257
+ if (match === null) return null;
258
+
259
+ const hashes = match[1];
260
+ const rawName = match[2]?.trim() ?? "";
261
+ if (hashes === undefined || rawName.length === 0) return null;
262
+
263
+ return {
264
+ level: hashes.length,
265
+ name: rawName,
266
+ start: lineNumber,
267
+ end: lineNumber,
268
+ };
269
+ }
270
+
271
+ function parseSetextHeading(
272
+ currentLine: string,
273
+ nextLine: string | undefined,
274
+ lineNumber: number,
275
+ ): MarkdownHeading | null {
276
+ if (nextLine === undefined) return null;
277
+ if (currentLine.trim().length === 0) return null;
278
+ if (parseAtxHeading(currentLine, lineNumber) !== null) return null;
279
+
280
+ if (/^\s{0,3}=+\s*$/.test(nextLine)) {
281
+ return { level: 1, name: currentLine.trim(), start: lineNumber, end: lineNumber + 1 };
282
+ }
283
+ if (/^\s{0,3}-+\s*$/.test(nextLine)) {
284
+ return { level: 2, name: currentLine.trim(), start: lineNumber, end: lineNumber + 1 };
285
+ }
286
+
287
+ return null;
288
+ }
289
+
290
+ function finalizeMarkdownRanges(headings: MarkdownHeading[], totalLines: number): void {
291
+ for (let i = 0; i < headings.length; i++) {
292
+ const current = headings[i];
293
+ if (current === undefined) continue;
294
+ let end = totalLines;
295
+ for (let j = i + 1; j < headings.length; j++) {
296
+ const next = headings[j];
297
+ if (next === undefined) continue;
298
+ if (next.level <= current.level) {
299
+ end = next.start - 1;
300
+ break;
301
+ }
302
+ }
303
+ current.end = end;
304
+ }
305
+ }
306
+
307
+ function buildMarkdownHierarchy(headings: readonly MarkdownHeading[]): MarkdownHeadingNode[] {
308
+ const roots: MarkdownHeadingNode[] = [];
309
+ const stack: MarkdownHeadingNode[] = [];
310
+
311
+ for (const heading of headings) {
312
+ const node: MarkdownHeadingNode = { ...heading, children: [] };
313
+ while (stack.length > 0) {
314
+ const last = stack[stack.length - 1];
315
+ if (last === undefined || last.level < node.level) break;
316
+ stack.pop();
317
+ }
318
+ const parent = stack[stack.length - 1];
319
+ if (parent === undefined) roots.push(node);
320
+ else parent.children.push(node);
321
+ stack.push(node);
322
+ }
323
+
324
+ return roots;
325
+ }
326
+
327
+ function toOutlineEntry(node: MarkdownHeadingNode): OutlineEntry {
328
+ return new OutlineEntry({
329
+ kind: "heading",
330
+ name: node.name,
331
+ exported: false,
332
+ ...(node.children.length > 0
333
+ ? { children: node.children.map((child) => toOutlineEntry(child)) }
334
+ : {}),
335
+ });
336
+ }
337
+
338
+ function extractMarkdownOutline(source: string): OutlineResult {
339
+ const lines = source.split("\n");
340
+ const headings: MarkdownHeading[] = [];
341
+ let inFence = false;
342
+ let activeFence: { marker: "`" | "~"; length: number } | null = null;
343
+
344
+ for (let i = 0; i < lines.length; i++) {
345
+ const line = lines[i];
346
+ if (line === undefined) continue;
347
+ const lineNumber = i + 1;
348
+ const fence = isFenceLine(line);
349
+
350
+ if (inFence) {
351
+ if (activeFence !== null && isFenceClose(line, activeFence.marker, activeFence.length)) {
352
+ inFence = false;
353
+ activeFence = null;
354
+ }
355
+ continue;
356
+ }
357
+
358
+ if (fence !== null) {
359
+ inFence = true;
360
+ activeFence = fence;
361
+ continue;
362
+ }
363
+
364
+ const atx = parseAtxHeading(line, lineNumber);
365
+ if (atx !== null) {
366
+ headings.push(atx);
367
+ continue;
368
+ }
369
+
370
+ const setext = parseSetextHeading(line, lines[i + 1], lineNumber);
371
+ if (setext !== null) {
372
+ headings.push(setext);
373
+ i++;
374
+ }
375
+ }
376
+
377
+ finalizeMarkdownRanges(headings, lines.length);
378
+ const hierarchy = buildMarkdownHierarchy(headings);
379
+
380
+ return {
381
+ entries: hierarchy.map((node) => toOutlineEntry(node)),
382
+ jumpTable: headings.map((heading) => buildMarkdownJumpEntry(heading)),
383
+ };
384
+ }
385
+
213
386
  // ---------------------------------------------------------------------------
214
387
  // Main extraction
215
388
  // ---------------------------------------------------------------------------
@@ -218,13 +391,17 @@ function buildJumpEntry(
218
391
  * Extract a structural outline from source code.
219
392
  *
220
393
  * @param source - The source code text.
221
- * @param lang - Language identifier: `"ts"` or `"js"`. Defaults to `"ts"`.
394
+ * @param lang - Structured format identifier. Defaults to `"ts"`.
222
395
  * @returns An {@link OutlineResult} with entries, jump table, and partial flag.
223
396
  */
224
397
  export function extractOutline(
225
398
  source: string,
226
- lang: "ts" | "js" = "ts",
399
+ lang: SupportedStructuredFormat = "ts",
227
400
  ): OutlineResult {
401
+ if (lang === "md") {
402
+ return extractMarkdownOutline(source);
403
+ }
404
+
228
405
  const parser = new Parser();
229
406
  parser.setLanguage(lang === "ts" ? tsLang : jsLang);
230
407
 
@@ -325,3 +502,15 @@ export function extractOutline(
325
502
 
326
503
  return result;
327
504
  }
505
+
506
+ export function extractOutlineForFile(
507
+ filePath: string,
508
+ source: string,
509
+ ): OutlineResult | null {
510
+ const lang = detectStructuredFormat(filePath);
511
+ if (lang === null) {
512
+ return null;
513
+ }
514
+
515
+ return extractOutline(source, lang);
516
+ }
@@ -1,5 +1,13 @@
1
1
  /** The kind of a top-level or member declaration. */
2
- export type EntryKind = "function" | "class" | "method" | "interface" | "type" | "enum" | "export";
2
+ export type EntryKind =
3
+ | "function"
4
+ | "class"
5
+ | "method"
6
+ | "interface"
7
+ | "type"
8
+ | "enum"
9
+ | "export"
10
+ | "heading";
3
11
 
4
12
  /** A single entry in a file outline. */
5
13
  export class OutlineEntry {
@@ -8,7 +8,8 @@ export type ReasonCode =
8
8
  | "BUILD_OUTPUT"
9
9
  | "SECRET"
10
10
  | "GRAFTIGNORE"
11
- | "BUDGET_CAP";
11
+ | "BUDGET_CAP"
12
+ | "UNSUPPORTED_LANGUAGE";
12
13
 
13
14
  export type SessionDepth = "early" | "mid" | "late" | "unknown";
14
15
 
@@ -48,12 +49,12 @@ export class ContentResult implements PolicyResultBase {
48
49
 
49
50
  export class OutlineResult implements PolicyResultBase {
50
51
  readonly projection = "outline" as const;
51
- readonly reason: "OUTLINE" | "SESSION_CAP" | "BUDGET_CAP";
52
+ readonly reason: "OUTLINE" | "SESSION_CAP" | "BUDGET_CAP" | "UNSUPPORTED_LANGUAGE";
52
53
  readonly thresholds: { readonly lines: number; readonly bytes: number };
53
54
  readonly actual: { readonly lines: number; readonly bytes: number };
54
55
  readonly sessionDepth?: SessionDepth | undefined;
55
56
 
56
- constructor(opts: { reason: "OUTLINE" | "SESSION_CAP" | "BUDGET_CAP"; thresholds: { lines: number; bytes: number }; actual: { lines: number; bytes: number }; sessionDepth?: SessionDepth | undefined }) {
57
+ constructor(opts: { reason: "OUTLINE" | "SESSION_CAP" | "BUDGET_CAP" | "UNSUPPORTED_LANGUAGE"; thresholds: { lines: number; bytes: number }; actual: { lines: number; bytes: number }; sessionDepth?: SessionDepth | undefined }) {
57
58
  this.reason = opts.reason;
58
59
  this.thresholds = Object.freeze({ ...opts.thresholds });
59
60
  this.actual = Object.freeze({ ...opts.actual });
@@ -9,6 +9,7 @@
9
9
  export interface FileSystem {
10
10
  readFile(path: string, encoding: "utf-8"): Promise<string>;
11
11
  readFile(path: string): Promise<Buffer>;
12
+ readdir(path: string): Promise<string[]>;
12
13
  writeFile(path: string, data: string, encoding: "utf-8"): Promise<void>;
13
14
  appendFile(path: string, data: string, encoding: "utf-8"): Promise<void>;
14
15
  mkdir(path: string, options: { recursive: true }): Promise<void>;
@@ -0,0 +1,16 @@
1
+ // ---------------------------------------------------------------------------
2
+ // GitClient port — hexagonal boundary for git command execution
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import type { ProcessRunResult } from "./process-runner.js";
6
+
7
+ export interface GitRunRequest {
8
+ readonly args: readonly string[];
9
+ readonly cwd: string;
10
+ readonly timeoutMs?: number;
11
+ readonly maxBufferBytes?: number;
12
+ }
13
+
14
+ export interface GitClient {
15
+ run(request: GitRunRequest): Promise<ProcessRunResult>;
16
+ }
@@ -0,0 +1,22 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ProcessRunner port — hexagonal boundary for command execution
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface ProcessRunRequest {
6
+ readonly command: string;
7
+ readonly args: readonly string[];
8
+ readonly cwd: string;
9
+ readonly timeoutMs?: number;
10
+ readonly maxBufferBytes?: number;
11
+ }
12
+
13
+ export interface ProcessRunResult {
14
+ readonly status: number | null;
15
+ readonly stdout: string;
16
+ readonly stderr: string;
17
+ readonly error?: Error;
18
+ }
19
+
20
+ export interface ProcessRunner {
21
+ run(request: ProcessRunRequest): ProcessRunResult;
22
+ }