@flyingrobots/graft 0.4.0 → 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 +47 -0
  3. package/CODE_OF_CONDUCT.md +65 -0
  4. package/README.md +153 -17
  5. package/bin/graft.js +4 -14
  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 +15 -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 +75 -11
  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 +65 -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 +696 -55
  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 +92 -38
  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 +7 -2
  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 +171 -56
  108. package/src/warp/observers.ts +1 -1
  109. package/src/warp/open.ts +4 -3
  110. package/src/warp/plumbing.d.ts +5 -1
  111. package/src/warp/writer-id.ts +30 -0
@@ -0,0 +1,102 @@
1
+ import { z } from "zod";
2
+
3
+ const severitySchema = z.enum([ "info", "low", "moderate", "high", "critical" ]);
4
+
5
+ const auditReportSchema = z.object({
6
+ metadata: z.object({
7
+ vulnerabilities: z.object({
8
+ info: z.number().int().nonnegative(),
9
+ low: z.number().int().nonnegative(),
10
+ moderate: z.number().int().nonnegative(),
11
+ high: z.number().int().nonnegative(),
12
+ critical: z.number().int().nonnegative(),
13
+ }),
14
+ }),
15
+ advisories: z.record(z.string(), z.object({
16
+ id: z.number().int().nonnegative(),
17
+ module_name: z.string(),
18
+ severity: severitySchema,
19
+ title: z.string(),
20
+ recommendation: z.string(),
21
+ github_advisory_id: z.string().nullable().optional(),
22
+ findings: z.array(z.object({
23
+ version: z.string(),
24
+ paths: z.array(z.string()),
25
+ })).optional(),
26
+ })),
27
+ });
28
+
29
+ export interface SecurityGateViolation {
30
+ readonly id: number;
31
+ readonly moduleName: string;
32
+ readonly severity: "high" | "critical";
33
+ readonly title: string;
34
+ readonly advisoryId: string | null;
35
+ readonly recommendation: string;
36
+ readonly paths: readonly string[];
37
+ readonly versions: readonly string[];
38
+ }
39
+
40
+ export interface SecurityGateResult {
41
+ readonly blocking: boolean;
42
+ readonly counts: {
43
+ readonly info: number;
44
+ readonly low: number;
45
+ readonly moderate: number;
46
+ readonly high: number;
47
+ readonly critical: number;
48
+ };
49
+ readonly blockingFindings: readonly SecurityGateViolation[];
50
+ }
51
+
52
+ export function evaluateSecurityGate(report: unknown): SecurityGateResult {
53
+ const parsed = auditReportSchema.parse(report);
54
+ const blockingFindings: SecurityGateViolation[] = [];
55
+
56
+ for (const advisory of Object.values(parsed.advisories)) {
57
+ if (advisory.severity !== "high" && advisory.severity !== "critical") {
58
+ continue;
59
+ }
60
+ blockingFindings.push({
61
+ id: advisory.id,
62
+ moduleName: advisory.module_name,
63
+ severity: advisory.severity,
64
+ title: advisory.title,
65
+ advisoryId: advisory.github_advisory_id ?? null,
66
+ recommendation: advisory.recommendation,
67
+ paths: advisory.findings?.flatMap((finding) => finding.paths) ?? [],
68
+ versions: advisory.findings?.map((finding) => finding.version) ?? [],
69
+ });
70
+ }
71
+
72
+ return {
73
+ blocking: blockingFindings.length > 0,
74
+ counts: parsed.metadata.vulnerabilities,
75
+ blockingFindings,
76
+ };
77
+ }
78
+
79
+ export function formatSecurityGateResult(result: SecurityGateResult): string {
80
+ const lines = [
81
+ `audit summary: critical=${String(result.counts.critical)} high=${String(result.counts.high)} moderate=${String(result.counts.moderate)} low=${String(result.counts.low)} info=${String(result.counts.info)}`,
82
+ ];
83
+
84
+ if (!result.blocking) {
85
+ lines.push("release security gate: pass");
86
+ return lines.join("\n");
87
+ }
88
+
89
+ lines.push("release security gate: fail");
90
+ for (const finding of result.blockingFindings) {
91
+ const advisoryId = finding.advisoryId ?? `npm:${String(finding.id)}`;
92
+ const paths = finding.paths.length > 0 ? finding.paths.join(", ") : "unknown path";
93
+ const versions = finding.versions.length > 0 ? finding.versions.join(", ") : "unknown version";
94
+ lines.push(
95
+ `- ${finding.severity.toUpperCase()} ${advisoryId} ${finding.moduleName}@${versions} via ${paths}`,
96
+ );
97
+ lines.push(` ${finding.title}`);
98
+ lines.push(` ${finding.recommendation}`);
99
+ }
100
+
101
+ return lines.join("\n");
102
+ }
@@ -5,6 +5,15 @@ const EDIT_BASH_TOOLS = new Set(["Edit", "Bash"]);
5
5
  const LATE_READ_BYTE_THRESHOLD = 20480;
6
6
  const LATE_READ_MESSAGE_THRESHOLD = 300;
7
7
 
8
+ export interface SessionTrackerSnapshot {
9
+ readonly totalMessages: number;
10
+ readonly toolCallsSinceUser: number;
11
+ readonly editBashTransitions: number;
12
+ readonly lastEditBashTool: string | null;
13
+ readonly budgetBytes: number | null;
14
+ readonly consumedBytes: number;
15
+ }
16
+
8
17
  export class SessionTracker {
9
18
  private totalMessages = 0;
10
19
  private toolCallsSinceUser = 0;
@@ -13,6 +22,17 @@ export class SessionTracker {
13
22
  private budgetBytes: number | null = null;
14
23
  private consumedBytes = 0;
15
24
 
25
+ static fromSnapshot(snapshot: SessionTrackerSnapshot): SessionTracker {
26
+ const tracker = new SessionTracker();
27
+ tracker.totalMessages = snapshot.totalMessages;
28
+ tracker.toolCallsSinceUser = snapshot.toolCallsSinceUser;
29
+ tracker.editBashTransitions = snapshot.editBashTransitions;
30
+ tracker.lastEditBashTool = snapshot.lastEditBashTool;
31
+ tracker.budgetBytes = snapshot.budgetBytes;
32
+ tracker.consumedBytes = snapshot.consumedBytes;
33
+ return tracker;
34
+ }
35
+
16
36
  getMessageCount(): number {
17
37
  return this.totalMessages;
18
38
  }
@@ -102,6 +122,17 @@ export class SessionTracker {
102
122
  };
103
123
  }
104
124
 
125
+ snapshot(): SessionTrackerSnapshot {
126
+ return {
127
+ totalMessages: this.totalMessages,
128
+ toolCallsSinceUser: this.toolCallsSinceUser,
129
+ editBashTransitions: this.editBashTransitions,
130
+ lastEditBashTool: this.lastEditBashTool,
131
+ budgetBytes: this.budgetBytes,
132
+ consumedBytes: this.consumedBytes,
133
+ };
134
+ }
135
+
105
136
  getSessionDepth(): SessionDepth {
106
137
  if (this.totalMessages < 100) {
107
138
  return "early";
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ import packageJson from "../package.json";
2
+
3
+ export const GRAFT_VERSION = packageJson.version;
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type WarpApp from "@git-stunts/git-warp";
10
- import { execFileSync } from "node:child_process";
10
+ import type { GitClient } from "../ports/git.js";
11
11
  import { extractOutline } from "../parser/outline.js";
12
12
  import { diffOutlines } from "../parser/diff.js";
13
13
  import { detectLang } from "../parser/lang.js";
@@ -17,6 +17,7 @@ import type { DiffEntry } from "../parser/diff.js";
17
17
 
18
18
  export interface IndexOptions {
19
19
  readonly cwd: string;
20
+ readonly git: GitClient;
20
21
  readonly from?: string;
21
22
  readonly to?: string;
22
23
  }
@@ -36,22 +37,46 @@ interface PatchOps {
36
37
  removeEdge(from: string, to: string, label: string): PatchOps;
37
38
  }
38
39
 
39
- function listCommits(cwd: string, from?: string, to?: string): string[] {
40
+ interface PreparedChange {
41
+ readonly status: string;
42
+ readonly filePath: string;
43
+ readonly fileId: string;
44
+ readonly lang: string | null;
45
+ readonly parentExists: boolean;
46
+ readonly oldOutline: readonly OutlineEntry[];
47
+ readonly newOutline?: readonly OutlineEntry[] | undefined;
48
+ readonly jumpLookup?: Map<string, { start: number; end: number }> | undefined;
49
+ readonly diff?: ReturnType<typeof diffOutlines> | undefined;
50
+ }
51
+
52
+ async function git(gitClient: GitClient, cwd: string, args: readonly string[]): Promise<string> {
53
+ const result = await gitClient.run({ cwd, args });
54
+ if (result.error !== undefined || result.status !== 0) {
55
+ throw result.error ?? new Error(result.stderr.trim() || `git exited with status ${String(result.status)}`);
56
+ }
57
+ return result.stdout;
58
+ }
59
+
60
+ async function listCommits(gitClient: GitClient, cwd: string, from?: string, to?: string): Promise<string[]> {
40
61
  const range = from !== undefined ? `${from}..${to ?? "HEAD"}` : to ?? "HEAD";
41
62
  const args = ["log", "--reverse", "--format=%H", range];
42
63
  try {
43
- return execFileSync("git", args, { cwd, encoding: "utf-8" })
64
+ return (await git(gitClient, cwd, args))
44
65
  .trim().split("\n").filter((l) => l.length > 0);
45
66
  } catch {
46
67
  return [];
47
68
  }
48
69
  }
49
70
 
50
- function getCommitChanges(sha: string, cwd: string): { status: string; path: string }[] {
71
+ async function getCommitChanges(
72
+ gitClient: GitClient,
73
+ sha: string,
74
+ cwd: string,
75
+ ): Promise<{ status: string; path: string }[]> {
51
76
  // --root handles the initial commit (no parent to diff against)
52
77
  const args = ["diff-tree", "--root", "--no-commit-id", "-r", "--name-status", sha];
53
78
  try {
54
- return execFileSync("git", args, { cwd, encoding: "utf-8" })
79
+ return (await git(gitClient, cwd, args))
55
80
  .trim().split("\n").filter((l) => l.length > 0).map((line) => {
56
81
  const parts = line.split("\t");
57
82
  return { status: parts[0] ?? "", path: parts[1] ?? "" };
@@ -61,9 +86,13 @@ function getCommitChanges(sha: string, cwd: string): { status: string; path: str
61
86
  }
62
87
  }
63
88
 
64
- function getCommitMeta(sha: string, cwd: string): { message: string; author: string; email: string; timestamp: string } {
89
+ async function getCommitMeta(
90
+ gitClient: GitClient,
91
+ sha: string,
92
+ cwd: string,
93
+ ): Promise<{ message: string; author: string; email: string; timestamp: string }> {
65
94
  try {
66
- const output = execFileSync("git", ["log", "-1", "--format=%s%n%aN%n%aE%n%aI", sha], { cwd, encoding: "utf-8" });
95
+ const output = await git(gitClient, cwd, ["log", "-1", "--format=%s%n%aN%n%aE%n%aI", sha]);
67
96
  const lines = output.trim().split("\n");
68
97
  return { message: lines[0] ?? "", author: lines[1] ?? "", email: lines[2] ?? "", timestamp: lines[3] ?? "" };
69
98
  } catch {
@@ -74,9 +103,9 @@ function getCommitMeta(sha: string, cwd: string): { message: string; author: str
74
103
  /**
75
104
  * Check if a commit has a parent (is not the root commit).
76
105
  */
77
- function hasParent(sha: string, cwd: string): boolean {
106
+ async function hasParent(gitClient: GitClient, sha: string, cwd: string): Promise<boolean> {
78
107
  try {
79
- execFileSync("git", ["rev-parse", "--verify", `${sha}~1`], { cwd, encoding: "utf-8" });
108
+ await git(gitClient, cwd, ["rev-parse", "--verify", `${sha}~1`]);
80
109
  return true;
81
110
  } catch {
82
111
  return false;
@@ -262,6 +291,104 @@ function applyChildDiffs(
262
291
  }
263
292
  }
264
293
 
294
+ async function prepareChange(
295
+ gitClient: GitClient,
296
+ cwd: string,
297
+ sha: string,
298
+ parentRef: string,
299
+ parentExists: boolean,
300
+ change: { status: string; path: string },
301
+ ): Promise<PreparedChange> {
302
+ const filePath = change.path;
303
+ const fileId = fileNodeId(filePath);
304
+ const lang = detectLang(filePath);
305
+
306
+ if (change.status === "D") {
307
+ let oldOutline: readonly OutlineEntry[] = [];
308
+ if (lang !== null && parentExists) {
309
+ const oldContent = await getFileAtRef(parentRef, filePath, { cwd, git: gitClient });
310
+ if (oldContent !== null) {
311
+ oldOutline = extractOutline(oldContent, lang).entries;
312
+ }
313
+ }
314
+ return {
315
+ status: change.status,
316
+ filePath,
317
+ fileId,
318
+ lang,
319
+ parentExists,
320
+ oldOutline,
321
+ };
322
+ }
323
+
324
+ if (lang === null) {
325
+ return {
326
+ status: change.status,
327
+ filePath,
328
+ fileId,
329
+ lang,
330
+ parentExists,
331
+ oldOutline: [],
332
+ };
333
+ }
334
+
335
+ const newContent = await getFileAtRef(sha, filePath, { cwd, git: gitClient });
336
+ if (newContent === null) {
337
+ return {
338
+ status: change.status,
339
+ filePath,
340
+ fileId,
341
+ lang,
342
+ parentExists,
343
+ oldOutline: [],
344
+ };
345
+ }
346
+
347
+ const newResult = extractOutline(newContent, lang);
348
+ const newOutline = newResult.entries;
349
+ const jumpLookup = buildJumpLookup(newResult.jumpTable ?? []);
350
+
351
+ if (change.status === "A" || !parentExists) {
352
+ return {
353
+ status: change.status,
354
+ filePath,
355
+ fileId,
356
+ lang,
357
+ parentExists,
358
+ oldOutline: [],
359
+ newOutline,
360
+ jumpLookup,
361
+ };
362
+ }
363
+
364
+ const oldContent = await getFileAtRef(parentRef, filePath, { cwd, git: gitClient });
365
+ if (oldContent === null) {
366
+ return {
367
+ status: change.status,
368
+ filePath,
369
+ fileId,
370
+ lang,
371
+ parentExists,
372
+ oldOutline: [],
373
+ newOutline,
374
+ jumpLookup,
375
+ };
376
+ }
377
+
378
+ const oldOutline = extractOutline(oldContent, lang).entries;
379
+ return {
380
+ status: change.status,
381
+ filePath,
382
+ fileId,
383
+ lang,
384
+ parentExists,
385
+ oldOutline,
386
+ newOutline,
387
+ jumpLookup,
388
+ diff: diffOutlines(oldOutline, newOutline),
389
+ };
390
+ }
391
+
265
392
  /**
266
393
  * Index a range of commits into the WARP graph.
267
394
  */
@@ -269,14 +396,14 @@ export async function indexCommits(
269
396
  warp: WarpApp,
270
397
  options: IndexOptions,
271
398
  ): Promise<IndexResult> {
272
- const { cwd } = options;
273
- const commits = listCommits(cwd, options.from, options.to);
399
+ const { cwd, git: gitClient } = options;
400
+ const commits = await listCommits(gitClient, cwd, options.from, options.to);
274
401
 
275
402
  let patchesWritten = 0;
276
403
  const commitTicks = new Map<string, number>();
277
404
 
278
405
  for (const sha of commits) {
279
- const changes = getCommitChanges(sha, cwd);
406
+ const changes = await getCommitChanges(gitClient, sha, cwd);
280
407
 
281
408
  // Only materialize when removals are possible (D or M status).
282
409
  // Materialization is expensive — O(n) replay of all prior patches.
@@ -286,9 +413,12 @@ export async function indexCommits(
286
413
  await warp.core().materialize();
287
414
  }
288
415
 
289
- const meta = getCommitMeta(sha, cwd);
290
- const parentExists = hasParent(sha, cwd);
416
+ const meta = await getCommitMeta(gitClient, sha, cwd);
417
+ const parentExists = await hasParent(gitClient, sha, cwd);
291
418
  const parentRef = `${sha}~1`;
419
+ const preparedChanges = await Promise.all(changes.map((change) =>
420
+ prepareChange(gitClient, cwd, sha, parentRef, parentExists, change)
421
+ ));
292
422
 
293
423
  await warp.patch((p) => {
294
424
  const patch = p as unknown as PatchOps;
@@ -301,62 +431,40 @@ export async function indexCommits(
301
431
  patch.setProperty(commitId, "email", meta.email);
302
432
  patch.setProperty(commitId, "timestamp", meta.timestamp);
303
433
 
304
- for (const change of changes) {
305
- const filePath = change.path;
306
- const fileId = fileNodeId(filePath);
307
- const lang = detectLang(filePath);
308
-
434
+ for (const change of preparedChanges) {
309
435
  if (change.status === "D") {
310
- if (lang !== null && parentExists) {
311
- const oldContent = getFileAtRef(parentRef, filePath, cwd);
312
- if (oldContent !== null) {
313
- const oldOutline = extractOutline(oldContent, lang).entries;
314
- removeSymbols(patch, filePath, oldOutline);
315
- }
436
+ if (change.lang !== null) {
437
+ removeSymbols(patch, change.filePath, change.oldOutline);
316
438
  }
317
- patch.removeNode(fileId);
439
+ patch.removeNode(change.fileId);
318
440
  continue;
319
441
  }
320
442
 
321
443
  // Added or modified — ensure file + directory nodes exist
322
- patch.addNode(fileId);
323
- patch.setProperty(fileId, "path", filePath);
324
- patch.setProperty(fileId, "lang", lang ?? "unknown");
325
- patch.addEdge(commitId, fileId, "touches");
326
- emitDirectoryChain(patch, filePath);
444
+ patch.addNode(change.fileId);
445
+ patch.setProperty(change.fileId, "path", change.filePath);
446
+ patch.setProperty(change.fileId, "lang", change.lang ?? "unknown");
447
+ patch.addEdge(commitId, change.fileId, "touches");
448
+ emitDirectoryChain(patch, change.filePath);
327
449
 
328
- if (lang === null) continue;
450
+ if (change.lang === null || change.newOutline === undefined || change.jumpLookup === undefined) continue;
329
451
 
330
- const newContent = getFileAtRef(sha, filePath, cwd);
331
- if (newContent === null) continue;
332
- const newResult = extractOutline(newContent, lang);
333
- const newOutline = newResult.entries;
334
- const jumpLookup = buildJumpLookup(newResult.jumpTable ?? []);
335
-
336
- if (change.status === "A" || !parentExists) {
452
+ if (change.status === "A" || !change.parentExists || change.diff === undefined) {
337
453
  // New file or root commit — emit all symbols
338
- emitSymbols(patch, filePath, newOutline, jumpLookup);
454
+ emitSymbols(patch, change.filePath, change.newOutline, change.jumpLookup);
339
455
  } else {
340
- // Modified file — structural diff
341
- const oldContent = getFileAtRef(parentRef, filePath, cwd);
342
- if (oldContent === null) {
343
- emitSymbols(patch, filePath, newOutline, jumpLookup);
344
- continue;
345
- }
346
-
347
- const oldOutline = extractOutline(oldContent, lang).entries;
348
- const diff = diffOutlines(oldOutline, newOutline);
456
+ const diff = change.diff;
349
457
 
350
458
  // Remove deleted symbols
351
459
  for (const removed of diff.removed) {
352
- const symId = symNodeId(filePath, removed.name);
460
+ const symId = symNodeId(change.filePath, removed.name);
353
461
  patch.addEdge(commitId, symId, "removes");
354
- removeDiffSymbols(patch, filePath, fileId, [removed]);
462
+ removeDiffSymbols(patch, change.filePath, change.fileId, [removed]);
355
463
  }
356
464
 
357
465
  // Add new symbols (preserve actual exported status)
358
466
  for (const added of diff.added) {
359
- const symId = symNodeId(filePath, added.name);
467
+ const symId = symNodeId(change.filePath, added.name);
360
468
  patch.addNode(symId);
361
469
  patch.setProperty(symId, "name", added.name);
362
470
  patch.setProperty(symId, "kind", added.kind);
@@ -366,18 +474,18 @@ export async function indexCommits(
366
474
  if (added.signature !== undefined) {
367
475
  patch.setProperty(symId, "signature", added.signature);
368
476
  }
369
- const jump = jumpLookup.get(added.name);
477
+ const jump = change.jumpLookup.get(added.name);
370
478
  if (jump !== undefined) {
371
479
  patch.setProperty(symId, "startLine", jump.start);
372
480
  patch.setProperty(symId, "endLine", jump.end);
373
481
  }
374
- patch.addEdge(fileId, symId, "contains");
482
+ patch.addEdge(change.fileId, symId, "contains");
375
483
  patch.addEdge(commitId, symId, "adds");
376
484
  }
377
485
 
378
486
  // Update changed symbols
379
487
  for (const changed of diff.changed) {
380
- const symId = symNodeId(filePath, changed.name);
488
+ const symId = symNodeId(change.filePath, changed.name);
381
489
  patch.setProperty(symId, "kind", changed.kind);
382
490
  if (changed.signature !== undefined) {
383
491
  patch.setProperty(symId, "signature", changed.signature);
@@ -386,7 +494,14 @@ export async function indexCommits(
386
494
  }
387
495
 
388
496
  // Apply nested child diffs (methods in classes)
389
- applyChildDiffs(patch, filePath, fileId, commitId, [...diff.changed], jumpLookup);
497
+ applyChildDiffs(
498
+ patch,
499
+ change.filePath,
500
+ change.fileId,
501
+ commitId,
502
+ [...diff.changed],
503
+ change.jumpLookup,
504
+ );
390
505
  }
391
506
  }
392
507
  });
@@ -37,7 +37,7 @@ export function fileSymbolsLens(filePath: string): Lens {
37
37
  export function allSymbolsLens(): Lens {
38
38
  return {
39
39
  match: "sym:*",
40
- expose: ["name", "kind", "signature", "exported"],
40
+ expose: ["name", "kind", "signature", "exported", "startLine", "endLine"],
41
41
  };
42
42
  }
43
43
 
package/src/warp/open.ts CHANGED
@@ -8,12 +8,13 @@
8
8
 
9
9
  import WarpApp, { GitGraphAdapter } from "@git-stunts/git-warp";
10
10
  import GitPlumbing from "@git-stunts/plumbing";
11
+ import { DEFAULT_WARP_WRITER_ID } from "./writer-id.js";
11
12
 
12
- const GRAPH_NAME = "graft-ast";
13
- const WRITER_ID = "graft";
13
+ export const GRAPH_NAME = "graft-ast";
14
14
 
15
15
  export interface OpenWarpOptions {
16
16
  readonly cwd: string;
17
+ readonly writerId?: string;
17
18
  }
18
19
 
19
20
  export async function openWarp(options: OpenWarpOptions): Promise<WarpApp> {
@@ -24,7 +25,7 @@ export async function openWarp(options: OpenWarpOptions): Promise<WarpApp> {
24
25
  return WarpApp.open({
25
26
  persistence,
26
27
  graphName: GRAPH_NAME,
27
- writerId: WRITER_ID,
28
+ writerId: options.writerId ?? DEFAULT_WARP_WRITER_ID,
28
29
  onDeleteWithData: "cascade",
29
30
  });
30
31
  }
@@ -6,6 +6,10 @@ declare module "@git-stunts/plumbing" {
6
6
  constructor(options: { runner: unknown; cwd?: string });
7
7
  static createDefault(options?: { cwd?: string; env?: string }): GitPlumbing;
8
8
  execute(options: { args: string[]; input?: string | Uint8Array }): Promise<string>;
9
- executeStream(options: { args: string[] }): Promise<AsyncIterable<Uint8Array> & { collect(opts?: { asString?: boolean }): Promise<Uint8Array | string> }>;
9
+ executeStream(options: { args: string[] }): Promise<{
10
+ finished: Promise<{ code: number; stderr: string; error?: Error }>;
11
+ collect(opts?: { maxBytes?: number; asString?: boolean; encoding?: string }): Promise<Uint8Array | string>;
12
+ [Symbol.asyncIterator](): AsyncIterator<Uint8Array>;
13
+ }>;
10
14
  }
11
15
  }
@@ -0,0 +1,30 @@
1
+ import * as crypto from "node:crypto";
2
+
3
+ export const DEFAULT_WARP_WRITER_ID = "graft";
4
+
5
+ function sanitizeSegment(input: string): string {
6
+ const normalized = input
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[^a-z0-9_-]+/g, "_")
10
+ .replace(/^_+|_+$/g, "");
11
+ return normalized.length > 0 ? normalized : "lane";
12
+ }
13
+
14
+ export function buildWarpWriterId(kind: string, scope?: string): string {
15
+ const lane = sanitizeSegment(kind);
16
+ if (scope === undefined || scope.trim().length === 0) {
17
+ return `graft_${lane}`;
18
+ }
19
+
20
+ const digest = crypto.createHash("sha256").update(scope).digest("hex").slice(0, 12);
21
+ return `graft_${lane}_${digest}`;
22
+ }
23
+
24
+ export function buildSessionWarpWriterId(sessionId: string): string {
25
+ return buildWarpWriterId("session", sessionId);
26
+ }
27
+
28
+ export function buildMonitorWarpWriterId(repoId: string): string {
29
+ return buildWarpWriterId("monitor", repoId);
30
+ }