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.
- package/README.md +36 -55
- package/package.json +3 -2
- package/src/cli/commands/check.ts +2 -0
- package/src/cli/commands/hook.ts +59 -12
- package/src/cli/commands/init.ts +440 -121
- package/src/cli/commands/sync.ts +27 -17
- package/src/cli/commands/topology.ts +89 -47
- package/src/cli/index.ts +5 -67
- package/src/cli/scaffold/harness-config.ts +697 -0
- package/src/cli/templates/hooks.ts +6 -6
- package/src/cli/templates/rules.ts +8 -8
- package/src/cli/templates/skill.ts +11 -18
- package/src/cli/ui/prompts.ts +122 -0
- package/src/core/boundary/staged-auditor.ts +5 -2
- package/src/core/database/client.ts +8 -0
- package/src/core/database/repositories/dependency.repository.ts +77 -13
- package/src/core/database/repositories/domain.repository.ts +86 -33
- package/src/core/database/repositories/symbol.repository.ts +128 -117
- package/src/core/database/repository.ts +17 -5
- package/src/core/database/schema.ts +1 -1
- package/src/core/discovery/topology-detector.ts +71 -19
- package/src/core/ingestion/pipeline.ts +228 -94
- package/src/core/parser/extractors/laravel-semantic.ts +9 -8
- package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
- package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
- package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
- package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
- package/src/core/resolver/call-graph-tracer.ts +4 -3
- package/src/core/resolver/path-resolver.ts +57 -4
- package/src/core/resources/ignore.ts +35 -0
- package/src/core/resources/limits.ts +14 -0
- package/src/core/resources/source-files.ts +62 -0
- package/src/core/resources/work-queue.ts +74 -0
- package/src/core/session/session-manager.ts +19 -6
- package/src/core/telemetry/telemetry.ts +12 -1
- package/src/core/topology/environment-detector.ts +21 -11
- package/src/mcp/schemas.ts +381 -8
- package/src/mcp/server.ts +321 -535
- package/src/mcp/tools/boundary.ts +17 -0
- package/src/mcp/tools/check-boundary.ts +83 -11
- package/src/mcp/tools/domain.ts +40 -0
- package/src/mcp/tools/feature.ts +67 -0
- package/src/mcp/tools/get-domain-catalog.ts +9 -6
- package/src/mcp/tools/get-environment-topology.ts +2 -2
- package/src/mcp/tools/get-feature-context.ts +9 -12
- package/src/mcp/tools/get-symbol-impact.ts +5 -2
- package/src/mcp/tools/get-symbol.ts +2 -1
- package/src/mcp/tools/locate-symbol.ts +3 -3
- package/src/mcp/tools/register-domain.ts +25 -9
- package/src/mcp/tools/register-feature.ts +34 -17
- package/src/mcp/tools/slice.ts +15 -0
- package/src/mcp/tools/symbol.ts +82 -0
- package/src/mcp/workspace-manager.ts +159 -0
- package/src/mcp/workspace-watcher.ts +202 -0
- package/src/types/index.ts +1 -0
- package/src/cli/commands/ingest.ts +0 -29
- package/src/cli/commands/locate.ts +0 -61
- package/src/cli/commands/query.ts +0 -104
- 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
|
+
|