septum 0.1.7 → 0.1.8

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 (59) hide show
  1. package/README.md +36 -55
  2. package/package.json +3 -2
  3. package/src/cli/commands/check.ts +2 -0
  4. package/src/cli/commands/hook.ts +59 -12
  5. package/src/cli/commands/init.ts +440 -121
  6. package/src/cli/commands/sync.ts +27 -17
  7. package/src/cli/commands/topology.ts +89 -47
  8. package/src/cli/index.ts +5 -67
  9. package/src/cli/scaffold/harness-config.ts +697 -0
  10. package/src/cli/templates/hooks.ts +6 -6
  11. package/src/cli/templates/rules.ts +8 -8
  12. package/src/cli/templates/skill.ts +11 -18
  13. package/src/cli/ui/prompts.ts +122 -0
  14. package/src/core/boundary/staged-auditor.ts +5 -2
  15. package/src/core/database/client.ts +8 -0
  16. package/src/core/database/repositories/dependency.repository.ts +77 -13
  17. package/src/core/database/repositories/domain.repository.ts +86 -33
  18. package/src/core/database/repositories/symbol.repository.ts +128 -117
  19. package/src/core/database/repository.ts +17 -5
  20. package/src/core/database/schema.ts +1 -1
  21. package/src/core/discovery/topology-detector.ts +71 -19
  22. package/src/core/ingestion/pipeline.ts +228 -94
  23. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  24. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  25. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  28. package/src/core/resolver/call-graph-tracer.ts +4 -3
  29. package/src/core/resolver/path-resolver.ts +57 -4
  30. package/src/core/resources/ignore.ts +35 -0
  31. package/src/core/resources/limits.ts +14 -0
  32. package/src/core/resources/source-files.ts +62 -0
  33. package/src/core/resources/work-queue.ts +74 -0
  34. package/src/core/session/session-manager.ts +19 -6
  35. package/src/core/telemetry/telemetry.ts +12 -1
  36. package/src/core/topology/environment-detector.ts +21 -11
  37. package/src/mcp/schemas.ts +381 -8
  38. package/src/mcp/server.ts +321 -535
  39. package/src/mcp/tools/boundary.ts +17 -0
  40. package/src/mcp/tools/check-boundary.ts +83 -11
  41. package/src/mcp/tools/domain.ts +40 -0
  42. package/src/mcp/tools/feature.ts +67 -0
  43. package/src/mcp/tools/get-domain-catalog.ts +9 -6
  44. package/src/mcp/tools/get-environment-topology.ts +2 -2
  45. package/src/mcp/tools/get-feature-context.ts +9 -12
  46. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  47. package/src/mcp/tools/get-symbol.ts +2 -1
  48. package/src/mcp/tools/locate-symbol.ts +3 -3
  49. package/src/mcp/tools/register-domain.ts +25 -9
  50. package/src/mcp/tools/register-feature.ts +34 -17
  51. package/src/mcp/tools/slice.ts +15 -0
  52. package/src/mcp/tools/symbol.ts +82 -0
  53. package/src/mcp/workspace-manager.ts +159 -0
  54. package/src/mcp/workspace-watcher.ts +202 -0
  55. package/src/types/index.ts +1 -0
  56. package/src/cli/commands/ingest.ts +0 -29
  57. package/src/cli/commands/locate.ts +0 -61
  58. package/src/cli/commands/query.ts +0 -104
  59. package/src/cli/commands/slice.ts +0 -50
@@ -0,0 +1,697 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import {
5
+ SEPTUM_HOOKS_CONFIG,
6
+ SEPTUM_POST_WRITE_SCRIPT,
7
+ SEPTUM_PRE_WRITE_SCRIPT,
8
+ } from "../templates/hooks.ts";
9
+ import { SEPTUM_RULES_TEMPLATE } from "../templates/rules.ts";
10
+ import { SEPTUM_MAP_SKILL_TEMPLATE } from "../templates/skill.ts";
11
+
12
+ export interface HarnessOption {
13
+ id: string;
14
+ name: string;
15
+ targetFile: string;
16
+ description: string;
17
+ selectedByDefault?: boolean;
18
+ }
19
+
20
+ export const SUPPORTED_HARNESSES: HarnessOption[] = [
21
+ {
22
+ id: "claude",
23
+ name: "Claude Code",
24
+ targetFile: ".mcp.json & .claude/settings.json",
25
+ description: "Standard project-local MCP & lifecycle hook guard for Claude Code",
26
+ selectedByDefault: true,
27
+ },
28
+ {
29
+ id: "antigravity",
30
+ name: "Antigravity",
31
+ targetFile: ".gemini/ (skills, rules, hooks, mcp)",
32
+ description: "Local skills, rules, hooks, and MCP config for Antigravity AI agents",
33
+ selectedByDefault: true,
34
+ },
35
+ {
36
+ id: "opencode",
37
+ name: "Opencode",
38
+ targetFile: "opencode.json",
39
+ description: "Project configuration for Opencode AI harness",
40
+ selectedByDefault: false,
41
+ },
42
+ {
43
+ id: "cursor",
44
+ name: "Cursor",
45
+ targetFile: ".cursor/mcp.json & .cursor/hooks.json",
46
+ description: "Local MCP & lifecycle hook guard for Cursor IDE",
47
+ selectedByDefault: false,
48
+ },
49
+ {
50
+ id: "codex",
51
+ name: "Codex",
52
+ targetFile: ".codex/config.toml & .codex/hooks.json",
53
+ description: "Project workspace config & hook guard for OpenAI Codex CLI",
54
+ selectedByDefault: false,
55
+ },
56
+ {
57
+ id: "goose",
58
+ name: "Goose",
59
+ targetFile: ".goose/config.yaml",
60
+ description: "Extension config template for Block Goose",
61
+ selectedByDefault: false,
62
+ },
63
+ {
64
+ id: "pi",
65
+ name: "Pi / Oh My Pi",
66
+ targetFile: ".pi/mcp.json",
67
+ description: "Local MCP configuration for Pi agent workflows",
68
+ selectedByDefault: false,
69
+ },
70
+ ];
71
+
72
+ export type ConfigReadResult<T = Record<string, any>> =
73
+ | { status: "missing" }
74
+ | { status: "valid"; data: T }
75
+ | { status: "malformed"; error: string; raw: string };
76
+
77
+ export function readJsonConfigFile<T = Record<string, any>>(filePath: string): ConfigReadResult<T> {
78
+ if (!fs.existsSync(filePath)) return { status: "missing" };
79
+ const raw = fs.readFileSync(filePath, "utf-8");
80
+ try {
81
+ const parsed = JSON.parse(raw);
82
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
83
+ return {
84
+ status: "malformed",
85
+ error: `Expected JSON object root, found ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed}`,
86
+ raw,
87
+ };
88
+ }
89
+ return { status: "valid", data: parsed as T };
90
+ } catch (err: unknown) {
91
+ const message = err instanceof Error ? err.message : String(err);
92
+ return {
93
+ status: "malformed",
94
+ error: `Malformed JSON syntax: ${message}`,
95
+ raw,
96
+ };
97
+ }
98
+ }
99
+
100
+ export function readYamlConfigFile<T = Record<string, any>>(filePath: string): ConfigReadResult<T> {
101
+ if (!fs.existsSync(filePath)) return { status: "missing" };
102
+ const raw = fs.readFileSync(filePath, "utf-8");
103
+ try {
104
+ const parsed = parseYaml(raw);
105
+ if (parsed === null || parsed === undefined) {
106
+ return { status: "valid", data: {} as T };
107
+ }
108
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
109
+ return {
110
+ status: "malformed",
111
+ error: `Expected YAML mapping root, found ${Array.isArray(parsed) ? "array" : typeof parsed}`,
112
+ raw,
113
+ };
114
+ }
115
+ return { status: "valid", data: parsed as T };
116
+ } catch (err: unknown) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ return {
119
+ status: "malformed",
120
+ error: `Malformed YAML syntax: ${message}`,
121
+ raw,
122
+ };
123
+ }
124
+ }
125
+
126
+ function writeJsonFile(filePath: string, data: unknown): void {
127
+ const dir = path.dirname(filePath);
128
+ if (!fs.existsSync(dir)) {
129
+ fs.mkdirSync(dir, { recursive: true });
130
+ }
131
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
132
+ }
133
+
134
+ export function configureHarness(
135
+ harnessId: string,
136
+ cwd: string = process.cwd(),
137
+ force: boolean = false,
138
+ outErrors?: string[]
139
+ ): string | null {
140
+ switch (harnessId) {
141
+ case "claude": {
142
+ const configured: string[] = [];
143
+
144
+ // 1. MCP config in .mcp.json
145
+ const configPath = path.join(cwd, ".mcp.json");
146
+ const mcpResult = readJsonConfigFile(configPath);
147
+ if (mcpResult.status === "malformed") {
148
+ console.error(` ✕ Error: Malformed JSON in '.mcp.json': ${mcpResult.error}. Preserving existing file without modification.`);
149
+ if (outErrors) outErrors.push(`Malformed JSON in '.mcp.json': ${mcpResult.error}`);
150
+ } else {
151
+ const existing = mcpResult.status === "valid" ? mcpResult.data : {};
152
+ const servers = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
153
+ ? existing.mcpServers
154
+ : {};
155
+ if (!servers.septum || force) {
156
+ servers.septum = {
157
+ command: "septum",
158
+ args: ["serve"],
159
+ };
160
+ existing.mcpServers = servers;
161
+ writeJsonFile(configPath, existing);
162
+ }
163
+ configured.push(".mcp.json");
164
+ }
165
+
166
+ // 2. Lifecycle hooks in .claude/settings.json
167
+ ensureSharedHookScripts(cwd, force);
168
+ const claudeSettingsPath = path.join(cwd, ".claude", "settings.json");
169
+ const claudeResult = readJsonConfigFile(claudeSettingsPath);
170
+ if (claudeResult.status === "malformed") {
171
+ console.error(` ✕ Error: Malformed JSON in '.claude/settings.json': ${claudeResult.error}. Preserving existing file without modification.`);
172
+ if (outErrors) outErrors.push(`Malformed JSON in '.claude/settings.json': ${claudeResult.error}`);
173
+ } else {
174
+ const claudeSettings = claudeResult.status === "valid" ? claudeResult.data : {};
175
+ const claudeHooks = (typeof claudeSettings.hooks === "object" && claudeSettings.hooks !== null && !Array.isArray(claudeSettings.hooks))
176
+ ? claudeSettings.hooks
177
+ : {};
178
+ const preToolHooks = Array.isArray(claudeHooks.PreToolUse) ? claudeHooks.PreToolUse : [];
179
+
180
+ const hasSeptumHook = preToolHooks.some((h: any) => {
181
+ if (Array.isArray(h?.hooks)) {
182
+ return h.hooks.some((hk: any) => typeof hk?.command === "string" && hk.command.includes("septum-pre-write"));
183
+ }
184
+ return typeof h?.command === "string" && h.command.includes("septum-pre-write");
185
+ });
186
+ const canonicalHook = {
187
+ matcher: "Write|Edit|NotebookEdit",
188
+ hooks: [
189
+ {
190
+ type: "command",
191
+ command:
192
+ "sh -c 'ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; if [ -f \"$ROOT/.gemini/hooks/septum-pre-write.sh\" ]; then exec \"$ROOT/.gemini/hooks/septum-pre-write.sh\" --exit-code-2; elif [ -f \"$ROOT/.agents/hooks/septum-pre-write.sh\" ]; then exec \"$ROOT/.agents/hooks/septum-pre-write.sh\" --exit-code-2; else exit 0; fi'",
193
+ },
194
+ ],
195
+ };
196
+ if (!hasSeptumHook) {
197
+ preToolHooks.push(canonicalHook);
198
+ claudeHooks.PreToolUse = preToolHooks;
199
+ claudeSettings.hooks = claudeHooks;
200
+ writeJsonFile(claudeSettingsPath, claudeSettings);
201
+ console.log(" ✓ Configured Claude Code lifecycle boundary guard: '.claude/settings.json'.");
202
+ } else if (force) {
203
+ const cleanedHooks: any[] = [];
204
+ for (const h of preToolHooks) {
205
+ if (Array.isArray(h?.hooks)) {
206
+ const userHooks = h.hooks.filter((hk: any) =>
207
+ !(typeof hk?.command === "string" && hk.command.includes("septum-pre-write"))
208
+ );
209
+ if (userHooks.length > 0) {
210
+ cleanedHooks.push({ ...h, hooks: userHooks });
211
+ }
212
+ } else if (typeof h?.command === "string" && h.command.includes("septum-pre-write")) {
213
+ // Dedicated single command hook, omit
214
+ } else if (!JSON.stringify(h).includes("septum-pre-write")) {
215
+ cleanedHooks.push(h);
216
+ }
217
+ }
218
+ cleanedHooks.push(canonicalHook);
219
+ claudeHooks.PreToolUse = cleanedHooks;
220
+ claudeSettings.hooks = claudeHooks;
221
+ writeJsonFile(claudeSettingsPath, claudeSettings);
222
+ console.log(" ✓ Force-regenerated Claude Code lifecycle boundary guard: '.claude/settings.json' (--force).");
223
+ }
224
+ configured.push(".claude/settings.json");
225
+ }
226
+
227
+ return configured.length > 0 ? configured.join(" & ") : null;
228
+ }
229
+
230
+ case "cursor": {
231
+ const configured: string[] = [];
232
+
233
+ // 1. MCP config in .cursor/mcp.json
234
+ const configPath = path.join(cwd, ".cursor", "mcp.json");
235
+ const mcpResult = readJsonConfigFile(configPath);
236
+ if (mcpResult.status === "malformed") {
237
+ console.error(` ✕ Error: Malformed JSON in '.cursor/mcp.json': ${mcpResult.error}. Preserving existing file without modification.`);
238
+ if (outErrors) outErrors.push(`Malformed JSON in '.cursor/mcp.json': ${mcpResult.error}`);
239
+ } else {
240
+ const existing = mcpResult.status === "valid" ? mcpResult.data : {};
241
+ const servers = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
242
+ ? existing.mcpServers
243
+ : {};
244
+ if (!servers.septum || force) {
245
+ servers.septum = {
246
+ command: "septum",
247
+ args: ["serve"],
248
+ };
249
+ existing.mcpServers = servers;
250
+ writeJsonFile(configPath, existing);
251
+ }
252
+ configured.push(".cursor/mcp.json");
253
+ }
254
+
255
+ // 2. Lifecycle hooks in .cursor/hooks.json
256
+ ensureSharedHookScripts(cwd, force);
257
+ const cursorHooksPath = path.join(cwd, ".cursor", "hooks.json");
258
+ const hooksResult = readJsonConfigFile(cursorHooksPath);
259
+ if (hooksResult.status === "malformed") {
260
+ console.error(` ✕ Error: Malformed JSON in '.cursor/hooks.json': ${hooksResult.error}. Preserving existing file without modification.`);
261
+ if (outErrors) outErrors.push(`Malformed JSON in '.cursor/hooks.json': ${hooksResult.error}`);
262
+ } else {
263
+ const cursorHooksConfig = hooksResult.status === "valid" ? hooksResult.data : { version: 1, hooks: {} };
264
+ cursorHooksConfig.version = cursorHooksConfig.version || 1;
265
+ cursorHooksConfig.hooks = (typeof cursorHooksConfig.hooks === "object" && cursorHooksConfig.hooks !== null && !Array.isArray(cursorHooksConfig.hooks))
266
+ ? cursorHooksConfig.hooks
267
+ : {};
268
+ const preToolUse = Array.isArray(cursorHooksConfig.hooks.preToolUse) ? cursorHooksConfig.hooks.preToolUse : [];
269
+
270
+ const hasSeptumHook = preToolUse.some((h: any) => {
271
+ if (Array.isArray(h?.hooks)) {
272
+ return h.hooks.some((hk: any) => typeof hk?.command === "string" && hk.command.includes("septum-pre-write"));
273
+ }
274
+ return typeof h?.command === "string" && h.command.includes("septum-pre-write");
275
+ });
276
+ const canonicalHook = {
277
+ matcher: "Write|Edit",
278
+ command:
279
+ "sh -c 'ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; if [ -f \"$ROOT/.gemini/hooks/septum-pre-write.sh\" ]; then exec \"$ROOT/.gemini/hooks/septum-pre-write.sh\" --exit-code-2; elif [ -f \"$ROOT/.agents/hooks/septum-pre-write.sh\" ]; then exec \"$ROOT/.agents/hooks/septum-pre-write.sh\" --exit-code-2; else exit 0; fi'",
280
+ failClosed: true,
281
+ };
282
+ if (!hasSeptumHook) {
283
+ preToolUse.push(canonicalHook);
284
+ cursorHooksConfig.hooks.preToolUse = preToolUse;
285
+ writeJsonFile(cursorHooksPath, cursorHooksConfig);
286
+ console.log(" ✓ Configured Cursor IDE lifecycle boundary guard: '.cursor/hooks.json'.");
287
+ } else if (force) {
288
+ const cleanedHooks: any[] = [];
289
+ for (const h of preToolUse) {
290
+ if (Array.isArray(h?.hooks)) {
291
+ const remaining = h.hooks.filter((hk: any) =>
292
+ !(typeof hk?.command === "string" && hk.command.includes("septum-pre-write"))
293
+ );
294
+ if (remaining.length > 0) cleanedHooks.push({ ...h, hooks: remaining });
295
+ } else if (typeof h?.command === "string" && h.command.includes("septum-pre-write")) {
296
+ // Dedicated single command hook, omit
297
+ } else if (!JSON.stringify(h).includes("septum-pre-write")) {
298
+ cleanedHooks.push(h);
299
+ }
300
+ }
301
+ cleanedHooks.push(canonicalHook);
302
+ cursorHooksConfig.hooks.preToolUse = cleanedHooks;
303
+ writeJsonFile(cursorHooksPath, cursorHooksConfig);
304
+ console.log(" ✓ Force-regenerated Cursor IDE lifecycle boundary guard: '.cursor/hooks.json' (--force).");
305
+ }
306
+ configured.push(".cursor/hooks.json");
307
+ }
308
+
309
+ return configured.length > 0 ? configured.join(" & ") : null;
310
+ }
311
+
312
+ case "opencode": {
313
+ const configPath = path.join(cwd, "opencode.json");
314
+ const result = readJsonConfigFile(configPath);
315
+ if (result.status === "malformed") {
316
+ console.error(` ✕ Error: Malformed JSON in 'opencode.json': ${result.error}. Preserving existing file without modification.`);
317
+ if (outErrors) outErrors.push(`Malformed JSON in 'opencode.json': ${result.error}`);
318
+ return null;
319
+ }
320
+ const existing = result.status === "valid" ? result.data : {};
321
+ const mcp = (typeof existing.mcp === "object" && existing.mcp !== null && !Array.isArray(existing.mcp))
322
+ ? existing.mcp
323
+ : {};
324
+ if (!mcp.septum || force) {
325
+ mcp.septum = {
326
+ type: "local",
327
+ command: ["septum", "serve"],
328
+ };
329
+ existing.mcp = mcp;
330
+ writeJsonFile(configPath, existing);
331
+ }
332
+ return "opencode.json";
333
+ }
334
+
335
+ case "codex": {
336
+ const configured: string[] = [];
337
+
338
+ // 1. MCP config in .codex/config.toml
339
+ const codexDir = path.join(cwd, ".codex");
340
+ if (!fs.existsSync(codexDir)) {
341
+ fs.mkdirSync(codexDir, { recursive: true });
342
+ }
343
+ const configTomlPath = path.join(codexDir, "config.toml");
344
+ appendTomlMcpServer(configTomlPath, "septum", "septum", ["serve"], force);
345
+ configured.push(".codex/config.toml");
346
+
347
+ // 2. Lifecycle hooks in .codex/hooks.json
348
+ ensureSharedHookScripts(cwd, force);
349
+ const codexHooksPath = path.join(codexDir, "hooks.json");
350
+ const hooksResult = readJsonConfigFile(codexHooksPath);
351
+ if (hooksResult.status === "malformed") {
352
+ console.error(` ✕ Error: Malformed JSON in '.codex/hooks.json': ${hooksResult.error}. Preserving existing file without modification.`);
353
+ if (outErrors) outErrors.push(`Malformed JSON in '.codex/hooks.json': ${hooksResult.error}`);
354
+ } else {
355
+ const codexHooksConfig = hooksResult.status === "valid" ? hooksResult.data : { hooks: {} };
356
+ codexHooksConfig.hooks = (typeof codexHooksConfig.hooks === "object" && codexHooksConfig.hooks !== null && !Array.isArray(codexHooksConfig.hooks))
357
+ ? codexHooksConfig.hooks
358
+ : {};
359
+ const preToolUse = Array.isArray(codexHooksConfig.hooks.PreToolUse) ? codexHooksConfig.hooks.PreToolUse : [];
360
+
361
+ const hasSeptumHook = preToolUse.some((h: any) => {
362
+ if (Array.isArray(h?.hooks)) {
363
+ return h.hooks.some((hk: any) => typeof hk?.command === "string" && hk.command.includes("septum-pre-write"));
364
+ }
365
+ return typeof h?.command === "string" && h.command.includes("septum-pre-write");
366
+ });
367
+ const canonicalHook = {
368
+ matcher: "Write|Edit|Bash",
369
+ hooks: [
370
+ {
371
+ type: "command",
372
+ command:
373
+ "sh -c 'ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; if [ -f \"$ROOT/.gemini/hooks/septum-pre-write.sh\" ]; then exec \"$ROOT/.gemini/hooks/septum-pre-write.sh\" --exit-code-2; elif [ -f \"$ROOT/.agents/hooks/septum-pre-write.sh\" ]; then exec \"$ROOT/.agents/hooks/septum-pre-write.sh\" --exit-code-2; else exit 0; fi'",
374
+ },
375
+ ],
376
+ };
377
+ if (!hasSeptumHook) {
378
+ preToolUse.push(canonicalHook);
379
+ codexHooksConfig.hooks.PreToolUse = preToolUse;
380
+ writeJsonFile(codexHooksPath, codexHooksConfig);
381
+ console.log(" ✓ Configured Codex CLI lifecycle boundary guard: '.codex/hooks.json'.");
382
+ } else if (force) {
383
+ const cleanedHooks: any[] = [];
384
+ for (const h of preToolUse) {
385
+ if (Array.isArray(h?.hooks)) {
386
+ const remaining = h.hooks.filter((hk: any) =>
387
+ !(typeof hk?.command === "string" && hk.command.includes("septum-pre-write"))
388
+ );
389
+ if (remaining.length > 0) cleanedHooks.push({ ...h, hooks: remaining });
390
+ } else if (typeof h?.command === "string" && h.command.includes("septum-pre-write")) {
391
+ // Dedicated single command hook, omit
392
+ } else if (!JSON.stringify(h).includes("septum-pre-write")) {
393
+ cleanedHooks.push(h);
394
+ }
395
+ }
396
+ cleanedHooks.push(canonicalHook);
397
+ codexHooksConfig.hooks.PreToolUse = cleanedHooks;
398
+ writeJsonFile(codexHooksPath, codexHooksConfig);
399
+ console.log(" ✓ Force-regenerated Codex CLI lifecycle boundary guard: '.codex/hooks.json' (--force).");
400
+ }
401
+ configured.push(".codex/hooks.json");
402
+ }
403
+
404
+ return configured.length > 0 ? configured.join(" & ") : null;
405
+ }
406
+
407
+ case "pi": {
408
+ const configPath = path.join(cwd, ".pi", "mcp.json");
409
+ const result = readJsonConfigFile(configPath);
410
+ if (result.status === "malformed") {
411
+ console.error(` ✕ Error: Malformed JSON in '.pi/mcp.json': ${result.error}. Preserving existing file without modification.`);
412
+ if (outErrors) outErrors.push(`Malformed JSON in '.pi/mcp.json': ${result.error}`);
413
+ return null;
414
+ }
415
+ const existing = result.status === "valid" ? result.data : {};
416
+ const servers = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
417
+ ? existing.mcpServers
418
+ : {};
419
+ if (!servers.septum || force) {
420
+ servers.septum = {
421
+ command: "septum",
422
+ args: ["serve"],
423
+ };
424
+ existing.mcpServers = servers;
425
+ writeJsonFile(configPath, existing);
426
+ }
427
+ return ".pi/mcp.json";
428
+ }
429
+
430
+ case "goose": {
431
+ const configPath = path.join(cwd, ".goose", "config.yaml");
432
+ const yamlResult = readYamlConfigFile(configPath);
433
+ if (yamlResult.status === "malformed") {
434
+ console.error(` ✕ Error: Malformed YAML in '.goose/config.yaml': ${yamlResult.error}. Preserving existing file without modification.`);
435
+ if (outErrors) outErrors.push(`Malformed YAML in '.goose/config.yaml': ${yamlResult.error}`);
436
+ return null;
437
+ }
438
+ const dir = path.dirname(configPath);
439
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
440
+
441
+ const parsed: Record<string, any> = yamlResult.status === "valid" ? yamlResult.data : {};
442
+ parsed.extensions = (typeof parsed.extensions === "object" && parsed.extensions !== null && !Array.isArray(parsed.extensions))
443
+ ? parsed.extensions
444
+ : {};
445
+ if (!parsed.extensions.septum || force) {
446
+ parsed.extensions.septum = {
447
+ name: "septum",
448
+ cmd: "septum",
449
+ args: ["serve"],
450
+ type: "stdio",
451
+ };
452
+ fs.writeFileSync(configPath, stringifyYaml(parsed), "utf-8");
453
+ }
454
+ console.log(
455
+ " \x1b[33mℹ Notice for Block Goose:\x1b[0m Generated project template at '.goose/config.yaml'.\n" +
456
+ " Goose CLI reads configuration from '~/.config/goose/config.yaml' (or %APPDATA%\\Block\\goose\\config\\config.yaml).\n" +
457
+ " Please run 'goose configure' or merge the extension block into your user config."
458
+ );
459
+ return ".goose/config.yaml";
460
+ }
461
+
462
+ case "antigravity": {
463
+ // Antigravity artifacts placed strictly in .gemini/
464
+ scaffoldAgentCustomizations(cwd, force, outErrors);
465
+
466
+ const geminiDir = path.join(cwd, ".gemini");
467
+ if (!fs.existsSync(geminiDir)) {
468
+ fs.mkdirSync(geminiDir, { recursive: true });
469
+ }
470
+ const mcpConfigPath = path.join(geminiDir, "mcp_config.json");
471
+ const result = readJsonConfigFile(mcpConfigPath);
472
+ if (result.status === "malformed") {
473
+ console.error(` ✕ Error: Malformed JSON in '.gemini/mcp_config.json': ${result.error}. Preserving existing file without modification.`);
474
+ if (outErrors) outErrors.push(`Malformed JSON in '.gemini/mcp_config.json': ${result.error}`);
475
+ } else {
476
+ const existing = result.status === "valid" ? result.data : {};
477
+ const servers = (typeof existing.mcpServers === "object" && existing.mcpServers !== null && !Array.isArray(existing.mcpServers))
478
+ ? existing.mcpServers
479
+ : {};
480
+ if (!servers.septum || force) {
481
+ servers.septum = {
482
+ command: "septum",
483
+ args: ["serve"],
484
+ };
485
+ existing.mcpServers = servers;
486
+ writeJsonFile(mcpConfigPath, existing);
487
+ }
488
+ }
489
+ return ".gemini/ (skills, rules, hooks, mcp_config.json)";
490
+ }
491
+
492
+ default:
493
+ return null;
494
+ }
495
+ }
496
+
497
+ export function appendTomlMcpServer(
498
+ tomlPath: string,
499
+ serverName: string,
500
+ command: string,
501
+ args: string[],
502
+ force: boolean = false
503
+ ): void {
504
+ let existingContent = "";
505
+ if (fs.existsSync(tomlPath)) {
506
+ existingContent = fs.readFileSync(tomlPath, "utf-8");
507
+ }
508
+
509
+ const tableHeader = `[mcp_servers.${serverName}]`;
510
+ const formattedArgs = JSON.stringify(args);
511
+ const block = `\n${tableHeader}\ncommand = "${command}"\nargs = ${formattedArgs}\n`;
512
+
513
+ const mcpServersKeyPattern = `(?:"mcp_servers"|'mcp_servers'|mcp_servers)`;
514
+ const serverKeyPattern = `(?:"${serverName}"|'${serverName}'|${serverName})`;
515
+ const activeTableHeaderRegex = new RegExp(
516
+ `^[ \\t]*\\[[ \\t]*${mcpServersKeyPattern}[ \\t]*\\.[ \\t]*${serverKeyPattern}[ \\t]*\\][ \\t]*(?:#.*)?$`,
517
+ "m"
518
+ );
519
+ const hasActiveTable = activeTableHeaderRegex.test(existingContent);
520
+
521
+ if (hasActiveTable) {
522
+ if (force) {
523
+ const sectionRegex = new RegExp(
524
+ `(?:^|\\n)[ \\t]*\\[[ \\t]*${mcpServersKeyPattern}[ \\t]*\\.[ \\t]*${serverKeyPattern}[ \\t]*\\][ \\t]*(?:#.*)?\\r?\\n[\\s\\S]*?(?=\\r?\\n[ \\t]*\\[|$)`
525
+ );
526
+ if (sectionRegex.test(existingContent)) {
527
+ const updated = existingContent.replace(
528
+ sectionRegex,
529
+ `\n${tableHeader}\ncommand = "${command}"\nargs = ${formattedArgs}\n`
530
+ );
531
+ fs.writeFileSync(tomlPath, updated.trimStart(), "utf-8");
532
+ return;
533
+ }
534
+ }
535
+ return;
536
+ }
537
+
538
+ fs.writeFileSync(tomlPath, (existingContent.trim() ? existingContent.trim() + "\n" : "") + block, "utf-8");
539
+ }
540
+
541
+ export function ensureSharedHookScripts(cwd: string, force: boolean = false): { preWrite: string; postWrite: string } {
542
+ // Use .gemini/hooks/ as primary home for hook scripts
543
+ const hooksDir = path.join(cwd, ".gemini", "hooks");
544
+ if (!fs.existsSync(hooksDir)) {
545
+ fs.mkdirSync(hooksDir, { recursive: true });
546
+ }
547
+
548
+ const preWriteHook = path.join(hooksDir, "septum-pre-write.sh");
549
+ if (!fs.existsSync(preWriteHook) || force) {
550
+ fs.writeFileSync(preWriteHook, SEPTUM_PRE_WRITE_SCRIPT, "utf-8");
551
+ }
552
+ try {
553
+ const stat = fs.statSync(preWriteHook);
554
+ if ((stat.mode & 0o111) === 0 || force) {
555
+ fs.chmodSync(preWriteHook, 0o755);
556
+ }
557
+ } catch (err: unknown) {
558
+ const message = err instanceof Error ? err.message : String(err);
559
+ console.warn(` Notice: Could not set executable permission on '${preWriteHook}': ${message}`);
560
+ }
561
+
562
+ const postWriteHook = path.join(hooksDir, "septum-post-write.sh");
563
+ if (!fs.existsSync(postWriteHook) || force) {
564
+ fs.writeFileSync(postWriteHook, SEPTUM_POST_WRITE_SCRIPT, "utf-8");
565
+ }
566
+ try {
567
+ const stat = fs.statSync(postWriteHook);
568
+ if ((stat.mode & 0o111) === 0 || force) {
569
+ fs.chmodSync(postWriteHook, 0o755);
570
+ }
571
+ } catch (err: unknown) {
572
+ const message = err instanceof Error ? err.message : String(err);
573
+ console.warn(` Notice: Could not set executable permission on '${postWriteHook}': ${message}`);
574
+ }
575
+
576
+ return { preWrite: preWriteHook, postWrite: postWriteHook };
577
+ }
578
+
579
+ export function scaffoldAgentCustomizations(cwd: string, force: boolean = false, outErrors?: string[]): void {
580
+ const geminiDir = path.join(cwd, ".gemini");
581
+
582
+ // 1. Skill: .gemini/skills/septum-map/SKILL.md
583
+ const skillDir = path.join(geminiDir, "skills", "septum-map");
584
+ if (!fs.existsSync(skillDir)) {
585
+ fs.mkdirSync(skillDir, { recursive: true });
586
+ }
587
+ const skillPath = path.join(skillDir, "SKILL.md");
588
+ if (!fs.existsSync(skillPath)) {
589
+ fs.writeFileSync(skillPath, SEPTUM_MAP_SKILL_TEMPLATE, "utf-8");
590
+ console.log(" ✓ Deployed AI Agent skill: '.gemini/skills/septum-map/SKILL.md'.");
591
+ } else {
592
+ const existing = fs.readFileSync(skillPath, "utf-8");
593
+ if (existing === SEPTUM_MAP_SKILL_TEMPLATE) {
594
+ // Unchanged
595
+ } else if (force) {
596
+ fs.writeFileSync(skillPath, SEPTUM_MAP_SKILL_TEMPLATE, "utf-8");
597
+ console.log(" ✓ Updated AI Agent skill: '.gemini/skills/septum-map/SKILL.md' (--force).");
598
+ } else {
599
+ console.log(" ✓ Preserved existing customized AI Agent skill: '.gemini/skills/septum-map/SKILL.md'.");
600
+ }
601
+ }
602
+
603
+ // 2. Rule: .gemini/rules/septum-boundary.md
604
+ const rulesDir = path.join(geminiDir, "rules");
605
+ if (!fs.existsSync(rulesDir)) {
606
+ fs.mkdirSync(rulesDir, { recursive: true });
607
+ }
608
+ const rulePath = path.join(rulesDir, "septum-boundary.md");
609
+ if (!fs.existsSync(rulePath)) {
610
+ fs.writeFileSync(rulePath, SEPTUM_RULES_TEMPLATE, "utf-8");
611
+ console.log(" ✓ Deployed AI Agent boundary rules: '.gemini/rules/septum-boundary.md'.");
612
+ } else {
613
+ const existing = fs.readFileSync(rulePath, "utf-8");
614
+ if (existing === SEPTUM_RULES_TEMPLATE) {
615
+ // Unchanged
616
+ } else if (force) {
617
+ fs.writeFileSync(rulePath, SEPTUM_RULES_TEMPLATE, "utf-8");
618
+ console.log(" ✓ Updated AI Agent boundary rules: '.gemini/rules/septum-boundary.md' (--force).");
619
+ } else {
620
+ console.log(" ✓ Preserved existing customized AI Agent boundary rules: '.gemini/rules/septum-boundary.md'.");
621
+ }
622
+ }
623
+
624
+ // 3. Shared Hook Scripts: .gemini/hooks/septum-pre-write.sh & septum-post-write.sh
625
+ ensureSharedHookScripts(cwd, force);
626
+ console.log(" ✓ Deployed lifecycle hook scripts in '.gemini/hooks/'.");
627
+
628
+ // 4. Hooks Config: .gemini/hooks.json
629
+ const hooksJsonPath = path.join(geminiDir, "hooks.json");
630
+ const hooksResult = readJsonConfigFile(hooksJsonPath);
631
+ if (hooksResult.status === "malformed") {
632
+ console.error(` ✕ Error: Malformed JSON in '.gemini/hooks.json': ${hooksResult.error}. Preserving existing file without modification.`);
633
+ if (outErrors) outErrors.push(`Malformed JSON in '.gemini/hooks.json': ${hooksResult.error}`);
634
+ return;
635
+ }
636
+
637
+ const existingHooks = hooksResult.status === "valid" ? hooksResult.data : {};
638
+ const mergedHooks: Record<string, any> = { ...existingHooks };
639
+
640
+ for (const [namespace, events] of Object.entries(SEPTUM_HOOKS_CONFIG)) {
641
+ if (!mergedHooks[namespace] || typeof mergedHooks[namespace] !== "object") {
642
+ mergedHooks[namespace] = { ...events };
643
+ continue;
644
+ }
645
+
646
+ const targetNs = { ...mergedHooks[namespace] };
647
+ const hasAnyHookArray = Array.isArray(targetNs.PreToolUse) || Array.isArray(targetNs.PostToolUse);
648
+
649
+ if (!force && !hasAnyHookArray && targetNs.user_override) {
650
+ // Explicit user override without hook arrays: preserve as-is
651
+ continue;
652
+ }
653
+
654
+ for (const [eventName, templateEntries] of Object.entries(events as Record<string, any[]>)) {
655
+ const existingEntries = Array.isArray(targetNs[eventName]) ? [...targetNs[eventName]] : [];
656
+ const isSeptumCommand = (cmd: unknown) =>
657
+ typeof cmd === "string" && (cmd.includes("septum-pre-write") || cmd.includes("septum-post-write"));
658
+
659
+ if (!force) {
660
+ // Normal mode: add template entry if not already present, preserving all existing user entries
661
+ const hasManagedHook = existingEntries.some((entry: any) => {
662
+ if (Array.isArray(entry?.hooks)) {
663
+ return entry.hooks.some((h: any) => isSeptumCommand(h?.command));
664
+ }
665
+ return isSeptumCommand(entry?.command);
666
+ });
667
+ if (!hasManagedHook) {
668
+ existingEntries.push(...templateEntries);
669
+ }
670
+ } else {
671
+ // Force mode: strip only Septum hook commands, preserve user commands/entries, then add canonical
672
+ const cleanedEntries: any[] = [];
673
+ for (const entry of existingEntries) {
674
+ if (Array.isArray(entry?.hooks)) {
675
+ const userHooks = entry.hooks.filter((h: any) => !isSeptumCommand(h?.command));
676
+ if (userHooks.length > 0) {
677
+ cleanedEntries.push({ ...entry, hooks: userHooks });
678
+ }
679
+ } else if (isSeptumCommand(entry?.command)) {
680
+ // Dedicated entry, omit
681
+ } else if (!JSON.stringify(entry).includes("septum-pre-write") && !JSON.stringify(entry).includes("septum-post-write")) {
682
+ cleanedEntries.push(entry);
683
+ }
684
+ }
685
+ cleanedEntries.push(...templateEntries);
686
+ targetNs[eventName] = cleanedEntries;
687
+ continue;
688
+ }
689
+ targetNs[eventName] = existingEntries;
690
+ }
691
+ mergedHooks[namespace] = targetNs;
692
+ }
693
+
694
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(mergedHooks, null, 2) + "\n", "utf-8");
695
+ console.log(" ✓ Configured AI Agent lifecycle hooks: '.gemini/hooks.json'.");
696
+ }
697
+