@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
package/src/cli/init.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { CanonicalJsonCodec } from "../adapters/canonical-json.js";
5
+ import { attachCliSchemaMeta, validateCliOutput } from "../contracts/output-schemas.js";
6
+ import {
7
+ buildTargetGitHookScript,
8
+ isRecognizedTargetGitHook,
9
+ resolveGitHooksPath,
10
+ TARGET_GIT_TRANSITION_HOOKS,
11
+ } from "../git/target-git-hook-bootstrap.js";
12
+
13
+ const codec = new CanonicalJsonCodec();
3
14
 
4
15
  const GRAFTIGNORE_TEMPLATE = `# Graft ignore patterns — files matching these are refused by safe_read.
5
16
  # Syntax: same as .gitignore (glob matching via picomatch).
@@ -10,6 +21,8 @@ const GRAFTIGNORE_TEMPLATE = `# Graft ignore patterns — files matching these a
10
21
  # data/**/*.json
11
22
  `;
12
23
 
24
+ const READ_GUIDANCE_MARKER = "## File reads";
25
+
13
26
  const AGENT_SNIPPET = `## File reads
14
27
 
15
28
  This project uses [graft](https://github.com/flyingrobots/graft) as
@@ -28,85 +41,823 @@ session metrics. Native reads bypass all of that.
28
41
 
29
42
  const GITIGNORE_ENTRY = "\n# Graft runtime data\n.graft/\n";
30
43
 
31
- const HOOKS_CONFIG = `
32
- Add to .claude/settings.json for Claude Code hook integration:
33
-
34
- {
35
- "hooks": {
36
- "PreToolUse": [
37
- {
38
- "matcher": "Read",
39
- "hooks": [
40
- {
41
- "type": "command",
42
- "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
43
- }
44
- ]
44
+ interface Writer {
45
+ write(chunk: string): unknown;
46
+ }
47
+
48
+ export interface RunInitOptions {
49
+ cwd?: string | undefined;
50
+ args?: readonly string[] | undefined;
51
+ stdout?: Writer | undefined;
52
+ stderr?: Writer | undefined;
53
+ }
54
+
55
+ type JsonPrimitive = string | number | boolean | null;
56
+ type JsonArrayValue = JsonValue[];
57
+ type JsonValue = JsonPrimitive | JsonArrayValue | JsonObjectValue;
58
+
59
+ interface JsonObjectValue {
60
+ [key: string]: JsonValue | undefined;
61
+ }
62
+
63
+ type InitActionKind = "exists" | "create" | "append";
64
+
65
+ class InitAction {
66
+ constructor(
67
+ readonly action: InitActionKind,
68
+ readonly label: string,
69
+ readonly detail?: string,
70
+ ) {}
71
+
72
+ static create(label: string, detail?: string): InitAction {
73
+ return new InitAction("create", label, detail);
74
+ }
75
+
76
+ static append(label: string, detail?: string): InitAction {
77
+ return new InitAction("append", label, detail);
78
+ }
79
+
80
+ static exists(label: string, detail?: string): InitAction {
81
+ return new InitAction("exists", label, detail);
82
+ }
83
+ }
84
+
85
+ class InitFailure {
86
+ readonly ok = false;
87
+
88
+ constructor(
89
+ readonly cwd: string,
90
+ readonly error: string,
91
+ ) {}
92
+
93
+ toJSON(): JsonObjectValue {
94
+ return {
95
+ ok: this.ok,
96
+ cwd: this.cwd,
97
+ error: this.error,
98
+ };
99
+ }
100
+ }
101
+
102
+ function consumeFlag(args: string[], flag: string): boolean {
103
+ const index = args.indexOf(flag);
104
+ if (index === -1) {
105
+ return false;
106
+ }
107
+ args.splice(index, 1);
108
+ return true;
109
+ }
110
+
111
+ class ParsedInitArgs {
112
+ constructor(
113
+ readonly json: boolean,
114
+ readonly writeClaudeMcp: boolean,
115
+ readonly writeClaudeHooks: boolean,
116
+ readonly writeTargetGitHooks: boolean,
117
+ readonly writeCodexMcp: boolean,
118
+ readonly writeCursorMcp: boolean,
119
+ readonly writeWindsurfMcp: boolean,
120
+ readonly writeContinueMcp: boolean,
121
+ readonly writeClineMcp: boolean,
122
+ ) {}
123
+
124
+ static parse(rawArgs: readonly string[]): ParsedInitArgs {
125
+ const args = [...rawArgs];
126
+ const parsed = new ParsedInitArgs(
127
+ consumeFlag(args, "--json"),
128
+ consumeFlag(args, "--write-claude-mcp"),
129
+ consumeFlag(args, "--write-claude-hooks"),
130
+ consumeFlag(args, "--write-target-git-hooks"),
131
+ consumeFlag(args, "--write-codex-mcp"),
132
+ consumeFlag(args, "--write-cursor-mcp"),
133
+ consumeFlag(args, "--write-windsurf-mcp"),
134
+ consumeFlag(args, "--write-continue-mcp"),
135
+ consumeFlag(args, "--write-cline-mcp"),
136
+ );
137
+
138
+ if (args.length > 0) {
139
+ throw new Error(`Unknown init arguments: ${args.join(" ")}`);
140
+ }
141
+
142
+ return parsed;
143
+ }
144
+
145
+ get writesAnyMcpConfig(): boolean {
146
+ return this.writeClaudeMcp
147
+ || this.writeCodexMcp
148
+ || this.writeCursorMcp
149
+ || this.writeWindsurfMcp
150
+ || this.writeContinueMcp
151
+ || this.writeClineMcp;
152
+ }
153
+ }
154
+
155
+ class GraftMcpServer {
156
+ readonly name = "graft";
157
+ readonly command = "npx";
158
+ readonly args = ["-y", "@flyingrobots/graft", "serve"] as const;
159
+ readonly codexStartupTimeoutSec = 120;
160
+
161
+ toJsonServerEntry(): JsonObjectValue {
162
+ return {
163
+ command: this.command,
164
+ args: [...this.args],
165
+ };
166
+ }
167
+
168
+ toJsonMcpConfig(): JsonObjectValue {
169
+ return {
170
+ mcpServers: {
171
+ [this.name]: this.toJsonServerEntry(),
172
+ },
173
+ };
174
+ }
175
+
176
+ toContinueServerEntry(): JsonObjectValue {
177
+ return {
178
+ name: this.name,
179
+ command: this.command,
180
+ args: [...this.args],
181
+ };
182
+ }
183
+
184
+ toContinueConfig(): JsonObjectValue {
185
+ return {
186
+ mcpServers: [this.toContinueServerEntry()],
187
+ };
188
+ }
189
+
190
+ toCodexTomlBlock(): string {
191
+ return [
192
+ "[mcp_servers.graft]",
193
+ "command = \"npx\"",
194
+ "args = [\"-y\", \"@flyingrobots/graft\", \"serve\"]",
195
+ `startup_timeout_sec = ${String(this.codexStartupTimeoutSec)}`,
196
+ "",
197
+ ].join("\n");
198
+ }
199
+ }
200
+
201
+ function ensureCodexStartupTimeout(existing: string): { content: string; changed: boolean } {
202
+ const marker = "[mcp_servers.graft]";
203
+ const timeoutLine = `startup_timeout_sec = ${String(GRAFT_MCP_SERVER.codexStartupTimeoutSec)}`;
204
+ const lines = existing.split("\n");
205
+ const blockStart = lines.findIndex((line) => line.trim() === marker);
206
+ if (blockStart === -1) {
207
+ return { content: existing, changed: false };
208
+ }
209
+
210
+ let blockEnd = lines.length;
211
+ for (let index = blockStart + 1; index < lines.length; index++) {
212
+ const line = lines[index];
213
+ if (line !== undefined && line.trim().startsWith("[") && line.trim().endsWith("]")) {
214
+ blockEnd = index;
215
+ break;
216
+ }
217
+ }
218
+
219
+ const hasTimeout = lines
220
+ .slice(blockStart + 1, blockEnd)
221
+ .some((line) => line.trim().startsWith("startup_timeout_sec"));
222
+ if (hasTimeout) {
223
+ return { content: existing, changed: false };
224
+ }
225
+
226
+ lines.splice(blockEnd, 0, timeoutLine);
227
+ return {
228
+ content: lines.join("\n"),
229
+ changed: true,
230
+ };
231
+ }
232
+
233
+ class GraftHookCommand {
234
+ constructor(readonly command: string) {}
235
+
236
+ toJsonValue(): JsonObjectValue {
237
+ return {
238
+ type: "command",
239
+ command: this.command,
240
+ };
241
+ }
242
+ }
243
+
244
+ class GraftHookMatcher {
245
+ constructor(
246
+ readonly matcher: "Read",
247
+ readonly hooks: readonly GraftHookCommand[],
248
+ ) {}
249
+
250
+ toJsonValue(): JsonObjectValue {
251
+ return {
252
+ matcher: this.matcher,
253
+ hooks: this.hooks.map((hook) => hook.toJsonValue()),
254
+ };
255
+ }
256
+ }
257
+
258
+ class GraftHooksConfig {
259
+ constructor(
260
+ readonly preToolUse: GraftHookMatcher,
261
+ readonly postToolUse: GraftHookMatcher,
262
+ ) {}
263
+
264
+ toJsonValue(): JsonObjectValue {
265
+ return {
266
+ hooks: {
267
+ PreToolUse: [this.preToolUse.toJsonValue()],
268
+ PostToolUse: [this.postToolUse.toJsonValue()],
269
+ },
270
+ };
271
+ }
272
+ }
273
+
274
+ const GRAFT_MCP_SERVER = new GraftMcpServer();
275
+ const GRAFT_HOOKS_CONFIG = new GraftHooksConfig(
276
+ new GraftHookMatcher("Read", [
277
+ new GraftHookCommand("node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"),
278
+ ]),
279
+ new GraftHookMatcher("Read", [
280
+ new GraftHookCommand("node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"),
281
+ ]),
282
+ );
283
+
284
+ class InitResult {
285
+ readonly ok = true;
286
+
287
+ constructor(
288
+ readonly cwd: string,
289
+ readonly actions: readonly InitAction[],
290
+ readonly hooksConfig: GraftHooksConfig,
291
+ readonly suggestedMcpServer: GraftMcpServer,
292
+ ) {}
293
+
294
+ toJSON(): JsonObjectValue {
295
+ return {
296
+ ok: this.ok,
297
+ cwd: this.cwd,
298
+ actions: this.actions.map((action) => ({
299
+ action: action.action,
300
+ label: action.label,
301
+ ...(action.detail !== undefined ? { detail: action.detail } : {}),
302
+ })),
303
+ hooksConfig: this.hooksConfig.toJsonValue(),
304
+ suggestedMcpServer: this.suggestedMcpServer.toJsonMcpConfig(),
305
+ };
306
+ }
307
+ }
308
+
309
+ function isJsonObjectValue(value: unknown): value is JsonObjectValue {
310
+ return typeof value === "object" && value !== null && !Array.isArray(value);
311
+ }
312
+
313
+ function cloneJsonValue<T>(value: T): T {
314
+ return JSON.parse(JSON.stringify(value)) as T;
315
+ }
316
+
317
+ function writeLine(writer: Writer, line = ""): void {
318
+ writer.write(`${line}\n`);
319
+ }
320
+
321
+ function formatJsonField(pointer: readonly string[]): string {
322
+ return pointer.length === 0 ? "" : ` field ${pointer.join(".")}`;
323
+ }
324
+
325
+ class JsonArrayNode {
326
+ constructor(
327
+ private readonly value: JsonArrayValue,
328
+ private readonly label: string,
329
+ private readonly pointer: readonly string[] = [],
330
+ ) {}
331
+
332
+ static fromUnknown(value: unknown, label: string, pointer: readonly string[] = []): JsonArrayNode {
333
+ if (!Array.isArray(value)) {
334
+ throw new Error(`${label}${formatJsonField(pointer)} must be an array`);
335
+ }
336
+ return new JsonArrayNode(value as JsonArrayValue, label, pointer);
337
+ }
338
+
339
+ objectItems(): JsonObjectNode[] {
340
+ return this.value.map((candidate, index) => {
341
+ if (!isJsonObjectValue(candidate)) {
342
+ throw new Error(`${this.label}${formatJsonField(this.pointer)} must contain only object entries`);
45
343
  }
46
- ],
47
- "PostToolUse": [
48
- {
49
- "matcher": "Read",
50
- "hooks": [
51
- {
52
- "type": "command",
53
- "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
54
- }
55
- ]
344
+ return new JsonObjectNode(candidate, this.label, [...this.pointer, String(index)]);
345
+ });
346
+ }
347
+
348
+ push(value: JsonValue): void {
349
+ this.value.push(value);
350
+ }
351
+ }
352
+
353
+ class JsonObjectNode {
354
+ constructor(
355
+ private readonly value: JsonObjectValue,
356
+ private readonly label: string,
357
+ private readonly pointer: readonly string[] = [],
358
+ ) {}
359
+
360
+ static fromUnknown(value: unknown, label: string, pointer: readonly string[] = []): JsonObjectNode {
361
+ if (!isJsonObjectValue(value)) {
362
+ throw new Error(`${label}${formatJsonField(pointer)} must be a JSON object`);
363
+ }
364
+ return new JsonObjectNode(value, label, pointer);
365
+ }
366
+
367
+ has(key: string): boolean {
368
+ return Object.prototype.hasOwnProperty.call(this.value, key);
369
+ }
370
+
371
+ set(key: string, value: JsonValue): void {
372
+ this.value[key] = value;
373
+ }
374
+
375
+ ensureObject(key: string): JsonObjectNode {
376
+ const current = this.value[key];
377
+ if (current === undefined) {
378
+ const created: JsonObjectValue = {};
379
+ this.value[key] = created;
380
+ return new JsonObjectNode(created, this.label, [...this.pointer, key]);
381
+ }
382
+ return JsonObjectNode.fromUnknown(current, this.label, [...this.pointer, key]);
383
+ }
384
+
385
+ ensureArray(key: string): JsonArrayNode {
386
+ const current = this.value[key];
387
+ if (current === undefined) {
388
+ const created: JsonArrayValue = [];
389
+ this.value[key] = created;
390
+ return new JsonArrayNode(created, this.label, [...this.pointer, key]);
391
+ }
392
+ return JsonArrayNode.fromUnknown(current, this.label, [...this.pointer, key]);
393
+ }
394
+
395
+ requireArray(key: string): JsonArrayNode {
396
+ const current = this.value[key];
397
+ if (current === undefined) {
398
+ throw new Error(`${this.label}${formatJsonField([...this.pointer, key])} must be an array`);
399
+ }
400
+ return JsonArrayNode.fromUnknown(current, this.label, [...this.pointer, key]);
401
+ }
402
+
403
+ stringValue(key: string): string | undefined {
404
+ const current = this.value[key];
405
+ if (current === undefined) {
406
+ return undefined;
407
+ }
408
+ if (typeof current !== "string") {
409
+ throw new Error(`${this.label}${formatJsonField([...this.pointer, key])} must be a string`);
410
+ }
411
+ return current;
412
+ }
413
+
414
+ toJsonValue(): JsonObjectValue {
415
+ return this.value;
416
+ }
417
+ }
418
+
419
+ class JsonObjectDocument {
420
+ constructor(
421
+ readonly label: string,
422
+ readonly filePath: string,
423
+ private readonly rootNode: JsonObjectNode,
424
+ ) {}
425
+
426
+ static create(filePath: string, label: string, root: JsonObjectValue): JsonObjectDocument {
427
+ return new JsonObjectDocument(
428
+ label,
429
+ filePath,
430
+ new JsonObjectNode(cloneJsonValue(root), label),
431
+ );
432
+ }
433
+
434
+ static open(filePath: string, label: string): JsonObjectDocument {
435
+ try {
436
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
437
+ return new JsonObjectDocument(label, filePath, JsonObjectNode.fromUnknown(parsed, label));
438
+ } catch (err: unknown) {
439
+ const message = err instanceof Error ? err.message : String(err);
440
+ throw new Error(`Unable to parse ${label}: ${message}`, { cause: err });
441
+ }
442
+ }
443
+
444
+ root(): JsonObjectNode {
445
+ return this.rootNode;
446
+ }
447
+
448
+ write(): void {
449
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
450
+ fs.writeFileSync(this.filePath, `${JSON.stringify(this.rootNode.toJsonValue(), null, 2)}\n`);
451
+ }
452
+ }
453
+
454
+ class JsonMcpConfigDocument {
455
+ constructor(
456
+ private readonly document: JsonObjectDocument,
457
+ private readonly server: GraftMcpServer,
458
+ ) {}
459
+
460
+ static create(filePath: string, label: string, server: GraftMcpServer): JsonMcpConfigDocument {
461
+ return new JsonMcpConfigDocument(
462
+ JsonObjectDocument.create(filePath, label, server.toJsonMcpConfig()),
463
+ server,
464
+ );
465
+ }
466
+
467
+ static open(filePath: string, label: string, server: GraftMcpServer): JsonMcpConfigDocument {
468
+ return new JsonMcpConfigDocument(JsonObjectDocument.open(filePath, label), server);
469
+ }
470
+
471
+ write(): void {
472
+ this.document.write();
473
+ }
474
+
475
+ ensureGraftServer(): InitAction {
476
+ const mcpServers = this.document.root().ensureObject("mcpServers");
477
+ if (mcpServers.has(this.server.name)) {
478
+ return InitAction.exists(this.document.label, "already has graft mcp server");
479
+ }
480
+ mcpServers.set(this.server.name, this.server.toJsonServerEntry());
481
+ this.document.write();
482
+ return InitAction.append(this.document.label, "merged graft mcp server");
483
+ }
484
+ }
485
+
486
+ class ContinueMcpConfigDocument {
487
+ constructor(
488
+ private readonly document: JsonObjectDocument,
489
+ private readonly server: GraftMcpServer,
490
+ ) {}
491
+
492
+ static create(filePath: string, label: string, server: GraftMcpServer): ContinueMcpConfigDocument {
493
+ return new ContinueMcpConfigDocument(
494
+ JsonObjectDocument.create(filePath, label, server.toContinueConfig()),
495
+ server,
496
+ );
497
+ }
498
+
499
+ static open(filePath: string, label: string, server: GraftMcpServer): ContinueMcpConfigDocument {
500
+ return new ContinueMcpConfigDocument(JsonObjectDocument.open(filePath, label), server);
501
+ }
502
+
503
+ write(): void {
504
+ this.document.write();
505
+ }
506
+
507
+ ensureGraftServer(): InitAction {
508
+ const servers = this.document.root().ensureArray("mcpServers");
509
+ const hasGraft = servers.objectItems().some((candidate) => candidate.stringValue("name") === this.server.name);
510
+ if (hasGraft) {
511
+ return InitAction.exists(this.document.label, "already has graft mcp server");
512
+ }
513
+ servers.push(this.server.toContinueServerEntry());
514
+ this.document.write();
515
+ return InitAction.append(this.document.label, "merged graft mcp server");
516
+ }
517
+ }
518
+
519
+ class ClaudeHooksDocument {
520
+ constructor(
521
+ private readonly document: JsonObjectDocument,
522
+ private readonly hooksConfig: GraftHooksConfig,
523
+ ) {}
524
+
525
+ static create(filePath: string, label: string, hooksConfig: GraftHooksConfig): ClaudeHooksDocument {
526
+ return new ClaudeHooksDocument(
527
+ JsonObjectDocument.create(filePath, label, hooksConfig.toJsonValue()),
528
+ hooksConfig,
529
+ );
530
+ }
531
+
532
+ static open(filePath: string, label: string, hooksConfig: GraftHooksConfig): ClaudeHooksDocument {
533
+ return new ClaudeHooksDocument(JsonObjectDocument.open(filePath, label), hooksConfig);
534
+ }
535
+
536
+ write(): void {
537
+ this.document.write();
538
+ }
539
+
540
+ ensureGraftHooks(): InitAction {
541
+ const hooksRoot = this.document.root().ensureObject("hooks");
542
+ const changed = [
543
+ this.mergeHookPhase(hooksRoot.ensureArray("PreToolUse"), this.hooksConfig.preToolUse),
544
+ this.mergeHookPhase(hooksRoot.ensureArray("PostToolUse"), this.hooksConfig.postToolUse),
545
+ ].some(Boolean);
546
+
547
+ if (!changed) {
548
+ return InitAction.exists(this.document.label, "already has graft hooks");
549
+ }
550
+ this.document.write();
551
+ return InitAction.append(this.document.label, "merged graft hooks");
552
+ }
553
+
554
+ private mergeHookPhase(phases: JsonArrayNode, entry: GraftHookMatcher): boolean {
555
+ const existing = phases.objectItems().find((candidate) => candidate.stringValue("matcher") === entry.matcher);
556
+ if (existing === undefined) {
557
+ phases.push(entry.toJsonValue());
558
+ return true;
559
+ }
560
+
561
+ const hooks = existing.requireArray("hooks");
562
+ let changed = false;
563
+ for (const graftHook of entry.hooks) {
564
+ const alreadyPresent = hooks.objectItems().some((candidate) =>
565
+ candidate.stringValue("type") === "command"
566
+ && candidate.stringValue("command") === graftHook.command,
567
+ );
568
+ if (alreadyPresent) {
569
+ continue;
56
570
  }
57
- ]
571
+ hooks.push(graftHook.toJsonValue());
572
+ changed = true;
573
+ }
574
+ return changed;
58
575
  }
59
576
  }
60
- `;
61
577
 
62
- function writeIfMissing(filePath: string, content: string, label: string): void {
578
+ function writeIfMissing(filePath: string, content: string, label: string): InitAction {
63
579
  if (fs.existsSync(filePath)) {
64
- console.log(` exists ${label}`);
65
- } else {
66
- fs.writeFileSync(filePath, content);
67
- console.log(` create ${label}`);
580
+ return InitAction.exists(label);
68
581
  }
582
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
583
+ fs.writeFileSync(filePath, content);
584
+ return InitAction.create(label);
69
585
  }
70
586
 
71
- function appendIfMissing(filePath: string, marker: string, content: string, label: string): void {
587
+ function appendIfMissing(filePath: string, marker: string, content: string, label: string): InitAction {
72
588
  if (fs.existsSync(filePath)) {
73
589
  const existing = fs.readFileSync(filePath, "utf-8");
74
590
  if (existing.includes(marker)) {
75
- console.log(` exists ${label} (already has graft entry)`);
76
- return;
591
+ return InitAction.exists(label, "already has graft entry");
77
592
  }
78
593
  fs.appendFileSync(filePath, content);
79
- console.log(` append ${label}`);
80
- } else {
81
- fs.writeFileSync(filePath, content.trimStart());
82
- console.log(` create ${label}`);
594
+ return InitAction.append(label);
595
+ }
596
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
597
+ fs.writeFileSync(filePath, content.trimStart());
598
+ return InitAction.create(label);
599
+ }
600
+
601
+ function readGitValue(cwd: string, args: readonly string[]): string | null {
602
+ try {
603
+ const value = execFileSync("git", [...args], {
604
+ cwd,
605
+ encoding: "utf-8",
606
+ stdio: ["ignore", "pipe", "ignore"],
607
+ }).trim();
608
+ return value.length > 0 ? value : null;
609
+ } catch {
610
+ return null;
83
611
  }
84
612
  }
85
613
 
86
- export function runInit(): void {
87
- const cwd = process.cwd();
88
- console.log(`\nInitializing graft in ${cwd}\n`);
614
+ function relativeLabel(cwd: string, filePath: string): string {
615
+ const relative = path.relative(cwd, filePath);
616
+ if (relative.length === 0 || relative.startsWith("..")) {
617
+ return filePath;
618
+ }
619
+ return relative;
620
+ }
89
621
 
90
- // 1. .graftignore
91
- writeIfMissing(path.join(cwd, ".graftignore"), GRAFTIGNORE_TEMPLATE, ".graftignore");
622
+ function resolveTargetGitHooksDirectory(cwd: string): {
623
+ configuredCoreHooksPath: string | null;
624
+ resolvedHooksPath: string;
625
+ } {
626
+ const worktreeRoot = readGitValue(cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
627
+ const gitCommonDir = readGitValue(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
628
+ if (worktreeRoot === null || gitCommonDir === null) {
629
+ throw new Error("--write-target-git-hooks requires a git worktree");
630
+ }
92
631
 
93
- // 2. .gitignore append .graft/
94
- appendIfMissing(path.join(cwd, ".gitignore"), ".graft/", GITIGNORE_ENTRY, ".gitignore");
632
+ const configuredCoreHooksPath = readGitValue(cwd, ["config", "--get", "core.hooksPath"]);
633
+ return {
634
+ configuredCoreHooksPath,
635
+ resolvedHooksPath: resolveGitHooksPath(
636
+ worktreeRoot,
637
+ gitCommonDir,
638
+ configuredCoreHooksPath,
639
+ ),
640
+ };
641
+ }
95
642
 
96
- // 3. CLAUDE.md append agent instructions snippet
97
- appendIfMissing(path.join(cwd, "CLAUDE.md"), "safe_read", "\n" + AGENT_SNIPPET, "CLAUDE.md");
643
+ function ensureTargetGitHooks(cwd: string): InitAction[] {
644
+ const { resolvedHooksPath } = resolveTargetGitHooksDirectory(cwd);
645
+ const actions: InitAction[] = [];
646
+ fs.mkdirSync(resolvedHooksPath, { recursive: true });
98
647
 
99
- // 4. Print hooks config for manual setup
100
- console.log(HOOKS_CONFIG);
648
+ for (const hookName of TARGET_GIT_TRANSITION_HOOKS) {
649
+ const hookPath = path.join(resolvedHooksPath, hookName);
650
+ const label = relativeLabel(cwd, hookPath);
651
+ const nextContent = buildTargetGitHookScript(hookName);
652
+ if (!fs.existsSync(hookPath)) {
653
+ fs.writeFileSync(hookPath, nextContent);
654
+ fs.chmodSync(hookPath, 0o755);
655
+ actions.push(InitAction.create(label, "wrote graft target git hook"));
656
+ continue;
657
+ }
101
658
 
102
- console.log("Done. Add graft to your MCP config:\n");
103
- console.log(` {
104
- "mcpServers": {
105
- "graft": {
106
- "command": "npx",
107
- "args": ["-y", "@flyingrobots/graft"]
108
- }
659
+ const existing = fs.readFileSync(hookPath, "utf-8");
660
+ if (existing === nextContent) {
661
+ actions.push(InitAction.exists(label, "already has graft target git hook"));
662
+ continue;
663
+ }
664
+ if (isRecognizedTargetGitHook(existing, hookName)) {
665
+ fs.writeFileSync(hookPath, nextContent);
666
+ fs.chmodSync(hookPath, 0o755);
667
+ actions.push(InitAction.append(label, "updated graft target git hook"));
668
+ continue;
669
+ }
670
+ actions.push(InitAction.exists(label, "external hook preserved"));
671
+ }
672
+
673
+ return actions;
674
+ }
675
+
676
+ function ensureJsonMcpConfig(filePath: string, label: string): InitAction {
677
+ if (!fs.existsSync(filePath)) {
678
+ const created = JsonMcpConfigDocument.create(filePath, label, GRAFT_MCP_SERVER);
679
+ created.write();
680
+ return InitAction.create(label, "wrote graft mcp server");
681
+ }
682
+ return JsonMcpConfigDocument.open(filePath, label, GRAFT_MCP_SERVER).ensureGraftServer();
683
+ }
684
+
685
+ function mergeClaudeMcpConfig(cwd: string): InitAction {
686
+ const label = ".mcp.json";
687
+ const filePath = path.join(cwd, label);
688
+ return ensureJsonMcpConfig(filePath, label);
689
+ }
690
+
691
+ function mergeCursorMcpConfig(cwd: string): InitAction {
692
+ const label = ".cursor/mcp.json";
693
+ const filePath = path.join(cwd, ".cursor", "mcp.json");
694
+ return ensureJsonMcpConfig(filePath, label);
695
+ }
696
+
697
+ function mergeWindsurfMcpConfig(cwd: string): InitAction {
698
+ const label = ".codeium/windsurf/mcp_config.json";
699
+ const filePath = path.join(cwd, ".codeium", "windsurf", "mcp_config.json");
700
+ return ensureJsonMcpConfig(filePath, label);
701
+ }
702
+
703
+ function mergeClineMcpConfig(cwd: string): InitAction {
704
+ const label = ".vscode/cline_mcp_settings.json";
705
+ const filePath = path.join(cwd, ".vscode", "cline_mcp_settings.json");
706
+ return ensureJsonMcpConfig(filePath, label);
707
+ }
708
+
709
+ function mergeClaudeHooksConfig(cwd: string): InitAction {
710
+ const label = ".claude/settings.json";
711
+ const filePath = path.join(cwd, ".claude", "settings.json");
712
+ if (!fs.existsSync(filePath)) {
713
+ const created = ClaudeHooksDocument.create(filePath, label, GRAFT_HOOKS_CONFIG);
714
+ created.write();
715
+ return InitAction.create(label, "wrote graft hooks");
716
+ }
717
+ return ClaudeHooksDocument.open(filePath, label, GRAFT_HOOKS_CONFIG).ensureGraftHooks();
718
+ }
719
+
720
+ function mergeContinueMcpConfig(cwd: string): InitAction {
721
+ const label = ".continue/config.json";
722
+ const filePath = path.join(cwd, ".continue", "config.json");
723
+ if (!fs.existsSync(filePath)) {
724
+ const created = ContinueMcpConfigDocument.create(filePath, label, GRAFT_MCP_SERVER);
725
+ created.write();
726
+ return InitAction.create(label, "wrote graft mcp server");
727
+ }
728
+ return ContinueMcpConfigDocument.open(filePath, label, GRAFT_MCP_SERVER).ensureGraftServer();
729
+ }
730
+
731
+ function mergeCodexMcpConfig(cwd: string): InitAction {
732
+ const label = ".codex/config.toml";
733
+ const filePath = path.join(cwd, ".codex", "config.toml");
734
+ const marker = "[mcp_servers.graft]";
735
+ if (!fs.existsSync(filePath)) {
736
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
737
+ fs.writeFileSync(filePath, GRAFT_MCP_SERVER.toCodexTomlBlock());
738
+ return InitAction.create(label, "wrote graft mcp server");
739
+ }
740
+
741
+ const existing = fs.readFileSync(filePath, "utf-8");
742
+ if (existing.includes(marker)) {
743
+ const ensured = ensureCodexStartupTimeout(existing);
744
+ if (ensured.changed) {
745
+ fs.writeFileSync(filePath, ensured.content);
746
+ return InitAction.append(label, "added graft startup timeout");
747
+ }
748
+ return InitAction.exists(label, "already has graft mcp server");
749
+ }
750
+
751
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
752
+ fs.writeFileSync(filePath, `${existing}${separator}${GRAFT_MCP_SERVER.toCodexTomlBlock()}`);
753
+ return InitAction.append(label, "appended graft mcp server");
754
+ }
755
+
756
+ function initProject(cwd: string, args: ParsedInitArgs): InitResult {
757
+ const actions: InitAction[] = [
758
+ writeIfMissing(path.join(cwd, ".graftignore"), GRAFTIGNORE_TEMPLATE, ".graftignore"),
759
+ appendIfMissing(path.join(cwd, ".gitignore"), ".graft/", GITIGNORE_ENTRY, ".gitignore"),
760
+ appendIfMissing(path.join(cwd, "CLAUDE.md"), READ_GUIDANCE_MARKER, `\n${AGENT_SNIPPET}`, "CLAUDE.md"),
761
+ ];
762
+
763
+ if (args.writeClaudeMcp) {
764
+ actions.push(mergeClaudeMcpConfig(cwd));
765
+ }
766
+ if (args.writeClaudeHooks) {
767
+ actions.push(mergeClaudeHooksConfig(cwd));
768
+ }
769
+ if (args.writeTargetGitHooks) {
770
+ actions.push(...ensureTargetGitHooks(cwd));
771
+ }
772
+ if (args.writeCodexMcp) {
773
+ actions.push(mergeCodexMcpConfig(cwd));
774
+ actions.push(
775
+ appendIfMissing(
776
+ path.join(cwd, "AGENTS.md"),
777
+ READ_GUIDANCE_MARKER,
778
+ `\n${AGENT_SNIPPET}`,
779
+ "AGENTS.md",
780
+ ),
781
+ );
782
+ }
783
+ if (args.writeCursorMcp) {
784
+ actions.push(mergeCursorMcpConfig(cwd));
785
+ }
786
+ if (args.writeWindsurfMcp) {
787
+ actions.push(mergeWindsurfMcpConfig(cwd));
788
+ }
789
+ if (args.writeContinueMcp) {
790
+ actions.push(mergeContinueMcpConfig(cwd));
791
+ }
792
+ if (args.writeClineMcp) {
793
+ actions.push(mergeClineMcpConfig(cwd));
794
+ }
795
+
796
+ return new InitResult(cwd, actions, GRAFT_HOOKS_CONFIG, GRAFT_MCP_SERVER);
797
+ }
798
+
799
+ function indentJson(value: unknown, spaces = 2): string {
800
+ const json = JSON.stringify(value, null, spaces);
801
+ return json.split("\n").map((line) => ` ${line}`).join("\n");
802
+ }
803
+
804
+ function renderInitText(result: InitResult, args: ParsedInitArgs, writer: Writer): void {
805
+ writeLine(writer);
806
+ writeLine(writer, `Initializing graft in ${result.cwd}`);
807
+ writeLine(writer);
808
+ for (const action of result.actions) {
809
+ const detail = action.detail !== undefined ? ` (${action.detail})` : "";
810
+ writeLine(writer, ` ${action.action.padEnd(6)} ${action.label}${detail}`);
811
+ }
812
+ writeLine(writer);
813
+
814
+ if (!args.writeClaudeHooks) {
815
+ writeLine(writer, "Add to .claude/settings.json for Claude Code hook integration:");
816
+ writeLine(writer);
817
+ writeLine(writer, JSON.stringify(GRAFT_HOOKS_CONFIG.toJsonValue(), null, 2));
818
+ writeLine(writer);
819
+ }
820
+
821
+ if (!args.writesAnyMcpConfig) {
822
+ writeLine(writer, "Done. Add graft to your MCP config:");
823
+ writeLine(writer);
824
+ writeLine(writer, indentJson(GRAFT_MCP_SERVER.toJsonMcpConfig()));
825
+ writeLine(writer);
826
+ writeLine(writer, "Use explicit --write-*-mcp flags or --write-claude-hooks");
827
+ writeLine(writer, "or --write-target-git-hooks for one-step bootstrap into project-local config files.");
828
+ }
829
+
830
+ writeLine(writer);
831
+ }
832
+
833
+ function emitInitJson(result: JsonObjectValue | InitFailure, writer: Writer): void {
834
+ const payload = result instanceof InitFailure
835
+ ? result.toJSON()
836
+ : result;
837
+ writer.write(`${codec.encode(validateCliOutput("init", attachCliSchemaMeta("init", payload)))}\n`);
838
+ }
839
+
840
+ export function runInit(options: RunInitOptions = {}): void {
841
+ const cwd = options.cwd ?? process.cwd();
842
+ const args = options.args ?? process.argv.slice(3);
843
+ const stdout = options.stdout ?? process.stdout;
844
+ const stderr = options.stderr ?? process.stderr;
845
+
846
+ try {
847
+ const parsed = ParsedInitArgs.parse(args);
848
+ const result = initProject(cwd, parsed);
849
+ if (parsed.json) {
850
+ emitInitJson(result.toJSON(), stdout);
851
+ return;
852
+ }
853
+ renderInitText(result, parsed, stdout);
854
+ } catch (err: unknown) {
855
+ const message = err instanceof Error ? err.message : String(err);
856
+ process.exitCode = 1;
857
+ if ((options.args ?? process.argv.slice(3)).includes("--json")) {
858
+ emitInitJson(new InitFailure(cwd, message), stdout);
859
+ return;
109
860
  }
861
+ writeLine(stderr, `Error: ${message}`);
110
862
  }
111
- `);
112
863
  }