septum 0.1.6 → 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 (72) hide show
  1. package/README.md +38 -37
  2. package/package.json +6 -5
  3. package/src/cli/commands/check.ts +34 -122
  4. package/src/cli/commands/hook.ts +68 -16
  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 +7 -68
  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/evaluator.ts +21 -25
  15. package/src/core/boundary/staged-auditor.ts +50 -0
  16. package/src/core/config/loader.ts +21 -24
  17. package/src/core/database/client.ts +8 -0
  18. package/src/core/database/repositories/dependency.repository.ts +80 -16
  19. package/src/core/database/repositories/domain.repository.ts +86 -33
  20. package/src/core/database/repositories/symbol.repository.ts +147 -65
  21. package/src/core/database/repository.ts +17 -5
  22. package/src/core/database/schema.ts +1 -1
  23. package/src/core/discovery/topology-detector.ts +71 -19
  24. package/src/core/ingestion/pipeline.ts +274 -126
  25. package/src/core/parser/extractors/frontend.ts +1 -0
  26. package/src/core/parser/extractors/go.ts +2 -2
  27. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  28. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  29. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  30. package/src/core/parser/extractors/php.ts +2 -2
  31. package/src/core/parser/extractors/python.ts +2 -2
  32. package/src/core/parser/extractors/typescript.ts +2 -2
  33. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  34. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  35. package/src/core/parser/tree-sitter.ts +6 -1
  36. package/src/core/parser/wasm-assets.ts +17 -0
  37. package/src/core/parser/wasm-loader.ts +3 -0
  38. package/src/core/resolver/call-graph-tracer.ts +4 -3
  39. package/src/core/resolver/module-resolver.ts +1 -10
  40. package/src/core/resolver/path-resolver.ts +57 -4
  41. package/src/core/resolver/vertical-slice-tracer.ts +1 -0
  42. package/src/core/resources/ignore.ts +35 -0
  43. package/src/core/resources/limits.ts +14 -0
  44. package/src/core/resources/source-files.ts +62 -0
  45. package/src/core/resources/work-queue.ts +74 -0
  46. package/src/core/session/session-manager.ts +67 -38
  47. package/src/core/telemetry/telemetry.ts +12 -1
  48. package/src/core/topology/environment-detector.ts +21 -11
  49. package/src/mcp/schemas.ts +381 -8
  50. package/src/mcp/server.ts +326 -529
  51. package/src/mcp/tools/boundary.ts +17 -0
  52. package/src/mcp/tools/check-boundary.ts +97 -43
  53. package/src/mcp/tools/domain.ts +40 -0
  54. package/src/mcp/tools/feature.ts +67 -0
  55. package/src/mcp/tools/get-domain-catalog.ts +15 -7
  56. package/src/mcp/tools/get-environment-topology.ts +2 -2
  57. package/src/mcp/tools/get-feature-context.ts +9 -12
  58. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  59. package/src/mcp/tools/get-symbol.ts +2 -1
  60. package/src/mcp/tools/locate-symbol.ts +28 -5
  61. package/src/mcp/tools/register-domain.ts +25 -9
  62. package/src/mcp/tools/register-feature.ts +40 -21
  63. package/src/mcp/tools/slice.ts +15 -0
  64. package/src/mcp/tools/symbol.ts +82 -0
  65. package/src/mcp/workspace-manager.ts +159 -0
  66. package/src/mcp/workspace-watcher.ts +202 -0
  67. package/src/types/index.ts +21 -1
  68. package/src/types/wasm.d.ts +4 -0
  69. package/src/cli/commands/ingest.ts +0 -29
  70. package/src/cli/commands/locate.ts +0 -61
  71. package/src/cli/commands/query.ts +0 -104
  72. package/src/cli/commands/slice.ts +0 -50
@@ -1,71 +1,259 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { handleIngestCommand } from "./ingest.ts";
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";
3
+ import { execFileSync } from "node:child_process";
4
+ import { handleSyncCommand } from "./sync.ts";
11
5
  import { TopologyDetector } from "../../core/discovery/topology-detector.ts";
12
6
  import { EnvironmentDetector } from "../../core/topology/environment-detector.ts";
13
7
  import { SeptumDatabase } from "../../core/database/client.ts";
14
8
  import { SeptumRepository } from "../../core/database/repository.ts";
9
+ import { ConfigLoader } from "../../core/config/loader.ts";
10
+ import { resolveWorkspaceRoot } from "../../core/resolver/path-resolver.ts";
11
+
12
+ import { promptSelectHarnesses } from "../ui/prompts.ts";
13
+ import { configureHarness, SUPPORTED_HARNESSES } from "../scaffold/harness-config.ts";
15
14
 
16
15
  export async function handleInitCommand(force: boolean = false): Promise<void> {
17
- console.log("[Septum] Initializing Septum bounded-context & AST environment (Zero-Config SQLite SSOT)...\n");
16
+ const workspaceRoot = resolveWorkspaceRoot();
17
+ const septumDir = path.join(workspaceRoot, ".septum");
18
+ if (!fs.existsSync(septumDir)) {
19
+ fs.mkdirSync(septumDir, { recursive: true });
20
+ }
21
+ const dbPath = path.join(workspaceRoot, ".septum", "septum.db");
18
22
 
19
- const cwd = process.cwd();
20
- const dbPath = ".septum/septum.db";
23
+ console.log("\n\x1b[36m◆\x1b[0m \x1b[1mSeptum Architecture Guard & SSOT Initializer\x1b[0m\n\x1b[36m│\x1b[0m");
21
24
 
22
25
  // 1. Auto-discover project topology and persist to SQLite SSOT
23
- console.log(" • Analyzing codebase topology, modular boundaries, and 6-layer environment plane...");
26
+ console.log("\x1b[36m◇\x1b[0m Analyzing codebase topology, modular boundaries, and 6-layer environment plane...");
24
27
  const db = new SeptumDatabase(dbPath);
25
- const repo = new SeptumRepository(db.raw);
26
- const domains = TopologyDetector.discoverAndPersist(repo, cwd);
27
- EnvironmentDetector.detect(cwd, repo);
28
- const domainNames = Object.keys(domains);
29
- console.log(` ✓ Discovered and registered ${domainNames.length} domains directly in SQLite (${dbPath}):`);
30
- for (const name of domainNames) {
31
- console.log(` - ${name}: root -> '${domains[name].root}'`);
28
+ try {
29
+ const repo = new SeptumRepository(db.raw);
30
+ const domains = TopologyDetector.discoverAndPersist(repo, workspaceRoot);
31
+ EnvironmentDetector.detect(workspaceRoot, repo);
32
+ const domainNames = Object.keys(domains);
33
+ console.log(`\x1b[36m│\x1b[0m \x1b[32m✓\x1b[0m Discovered and registered ${domainNames.length} domain(s) in SQLite:`);
34
+ for (const name of domainNames) {
35
+ console.log(`\x1b[36m│\x1b[0m - \x1b[1m${name}\x1b[0m: root -> '${domains[name].root}'`);
36
+ }
37
+ } finally {
38
+ db.close();
32
39
  }
33
- db.close();
40
+ console.log("\x1b[36m│\x1b[0m");
34
41
 
35
- // 2. Ensure .gitignore excludes local SQLite cache
36
- ensureGitIgnore(cwd);
42
+ // 2. Interactive AI Agent Harness Selection
43
+ const selectedHarnesses = await promptSelectHarnesses(SUPPORTED_HARNESSES);
37
44
 
38
- // 3. Install Git Pre-Commit Hook
39
- installGitHook(cwd);
45
+ // 3. Configure local project artifacts
46
+ const forceNotice = force ? " (force regeneration of Septum-owned artifacts)" : "";
47
+ console.log(`\x1b[36m◇\x1b[0m Scaffolding project-local guards & harness configurations${forceNotice}...`);
48
+ ensureGitIgnore(workspaceRoot);
49
+ const hookResult = installGitHook(workspaceRoot, force);
40
50
 
41
- // 4. Scaffold Agent Customizations (.agents/skills, .agents/hooks, .agents/rules)
42
- scaffoldAgentCustomizations(cwd);
51
+ const configuredFiles: string[] = [];
52
+ const harnessErrors: Array<{ harness: string; error: string }> = [];
53
+ for (const harnessId of selectedHarnesses) {
54
+ const outErrs: string[] = [];
55
+ const file = configureHarness(harnessId, workspaceRoot, force, outErrs);
56
+ if (file) configuredFiles.push(file);
57
+ for (const e of outErrs) {
58
+ harnessErrors.push({ harness: harnessId, error: e });
59
+ }
60
+ }
61
+ if (configuredFiles.length > 0) {
62
+ console.log(`\x1b[36m│\x1b[0m \x1b[32m✓\x1b[0m Configured local harness artifacts for: ${configuredFiles.join(", ")}`);
63
+ }
64
+ console.log("\x1b[36m│\x1b[0m");
43
65
 
44
- // 5. Run initial codebase ingestion
45
- console.log("\n[Septum] Running initial Tree-Sitter WASM ingestion to build symbol & file-connection catalog...");
66
+ // 4. Run initial codebase ingestion
67
+ console.log("\x1b[36m◇\x1b[0m Running initial Tree-Sitter AST ingestion to build symbol & dependency catalog...");
46
68
  try {
47
- await handleIngestCommand();
48
- console.log("\n✓ Septum successfully initialized and ready for production use!");
49
- console.log(" • Pre-commit hook is active (runs 'septum check --staged' before each commit).");
50
- console.log(" • AI Agent skill '/septum-map', anti-duplication rules, and boundary hooks installed in '.agents/'.");
51
- console.log(" • MCP server is ready for AI coding agents via 'septum serve'.\n");
69
+ await handleSyncCommand({ workspaceRoot, dbPath });
70
+ console.log("\x1b[36m│\x1b[0m \x1b[32m✓\x1b[0m Ingestion completed successfully.");
71
+ console.log("\x1b[36m│\x1b[0m");
72
+
73
+ // Inspect index coverage & unindexed files from SSOT
74
+ let unsupportedFiles: string[] = [];
75
+ let skippedFiles: Array<{ path: string; reason: string }> = [];
76
+ let uncoveredFiles: string[] = [];
77
+ let enforcementMode = "warn";
78
+ const statusDb = new SeptumDatabase(dbPath);
79
+ try {
80
+ const statusRepo = new SeptumRepository(statusDb.raw);
81
+ unsupportedFiles = JSON.parse(statusRepo.getMeta("unsupported_files") || "[]");
82
+ skippedFiles = JSON.parse(statusRepo.getMeta("skipped_files") || "[]");
83
+ uncoveredFiles = JSON.parse(statusRepo.getMeta("uncovered_files") || "[]");
84
+ const loadedConfig = ConfigLoader.loadFromRepository(statusRepo);
85
+ enforcementMode = loadedConfig.settings.enforcement || "warn";
86
+ } finally {
87
+ statusDb.close();
88
+ }
89
+
90
+ const hasPartialCoverage = unsupportedFiles.length > 0 || skippedFiles.length > 0 || uncoveredFiles.length > 0;
91
+ if (hasPartialCoverage) {
92
+ console.log("\x1b[33m└ ⚠ Septum initialized with partial index coverage (see unindexed files below)\x1b[0m");
93
+ if (uncoveredFiles.length > 0) {
94
+ console.log(` • Uncovered source files outside registered domains (${uncoveredFiles.length} file(s)): ${uncoveredFiles.slice(0, 5).join(", ")}${uncoveredFiles.length > 5 ? "..." : ""}`);
95
+ }
96
+ if (unsupportedFiles.length > 0) {
97
+ console.log(` • Unsupported parser/language (${unsupportedFiles.length} file(s)): ${unsupportedFiles.slice(0, 5).join(", ")}${unsupportedFiles.length > 5 ? "..." : ""}`);
98
+ }
99
+ if (skippedFiles.length > 0) {
100
+ console.log(` • Exceeded resource limits (${skippedFiles.length} file(s) > 2 MiB): ${skippedFiles.map((f) => f.path).slice(0, 5).join(", ")}${skippedFiles.length > 5 ? "..." : ""}`);
101
+ }
102
+ } else if (harnessErrors.length > 0) {
103
+ console.log("\x1b[33m└ ⚠ Septum initialized with incomplete harness integration\x1b[0m");
104
+ console.log(" • The SQLite database and symbol catalog are ready, but some selected harness configurations failed:");
105
+ for (const err of harnessErrors) {
106
+ console.log(` - [${err.harness}] ${err.error}`);
107
+ }
108
+ process.exitCode = 1;
109
+ } else {
110
+ console.log("\x1b[32m└ ✓ Septum successfully initialized and ready for production use (full index coverage)!\x1b[0m");
111
+ }
112
+
113
+ if (hasPartialCoverage && harnessErrors.length > 0) {
114
+ console.log(" • Warning: Selected harness configurations also encountered errors:");
115
+ for (const err of harnessErrors) {
116
+ console.log(` - [${err.harness}] ${err.error}`);
117
+ }
118
+ process.exitCode = 1;
119
+ }
120
+
121
+ const isHookActive =
122
+ (hookResult.status === "installed" || hookResult.status === "already_installed" || hookResult.status === "updated") &&
123
+ hookResult.isActive !== false;
124
+
125
+ if (isHookActive) {
126
+ const isStrict = enforcementMode === "strict";
127
+ const modeLabel = isStrict ? "blocking (strict)" : "audit-only (warn)";
128
+ console.log(` • Pre-commit hook is active in ${modeLabel} mode (runs 'septum check --staged' before each commit).`);
129
+ if (!isStrict) {
130
+ console.log(" Notice: Default 'warn' mode logs boundary violations without blocking commits. Configure 'enforcement: strict' in configuration to block.");
131
+ }
132
+ } else if (hookResult.status === "preserved_user_hook") {
133
+ console.log(" • Notice: Existing non-Septum pre-commit hook was preserved. Septum git hook is not active.");
134
+ } else if (hookResult.status === "skipped_no_git") {
135
+ console.log(" • Notice: Git pre-commit hook was skipped (no .git repository found).");
136
+ } else {
137
+ console.log(" • Notice: Git pre-commit hook could not be set active (permission or configuration issue).");
138
+ }
139
+
140
+ if (selectedHarnesses.includes("antigravity")) {
141
+ console.log(" • AI Agent skills and boundary rules are active in '.gemini/'.");
142
+ }
143
+ console.log(" • MCP server is ready for AI coding agents via 'septum serve'.\n");
52
144
  } catch (err: unknown) {
53
- const message = err instanceof Error ? err.message : String(err);
54
- console.warn(`\n[Septum Warning] Initial ingestion encountered an error: ${message}`);
55
- console.warn("Run 'septum ingest' manually to retry.");
145
+ const rawMessage = err instanceof Error ? err.message : String(err);
146
+ let failurePhase = "AST Ingestion Pipeline";
147
+ let recoveryInstruction = "Inspect the error trace above, verify project settings, then run 'septum sync' to resume indexing.";
148
+
149
+ if (rawMessage.includes("Cannot load Septum configuration") || rawMessage.includes("JSON") || rawMessage.includes("schema")) {
150
+ failurePhase = "Configuration Loading";
151
+ recoveryInstruction = "Check and repair the configuration/metadata in '.septum/septum.db', then run 'septum sync' to rebuild the symbol catalog.";
152
+ } else if (rawMessage.includes("resource limit") || rawMessage.includes("exceeds")) {
153
+ failurePhase = "Resource Limit";
154
+ recoveryInstruction = "Configure ignore patterns in '.septum/septum.db' for oversized files, then run 'septum sync'.";
155
+ } else if (rawMessage.includes("database") || rawMessage.includes("SQLite") || rawMessage.includes("locked")) {
156
+ failurePhase = "Database Access";
157
+ recoveryInstruction = "Ensure no other process is locking '.septum/septum.db', then run 'septum sync'.";
158
+ } else if (rawMessage.includes("unsupported") || rawMessage.includes("degraded")) {
159
+ failurePhase = "Parser";
160
+ recoveryInstruction = "Review unsupported source files, then run 'septum sync'.";
161
+ }
162
+
163
+ console.error(`\x1b[31m│\x1b[0m`);
164
+ console.error(`\x1b[31m│\x1b[0m \x1b[1;31m✕ Initial codebase ingestion failed (${failurePhase})\x1b[0m`);
165
+ console.error(`\x1b[31m│\x1b[0m \x1b[1mPartial Initialization State:\x1b[0m`);
166
+ console.error(`\x1b[31m│\x1b[0m ✓ Codebase topology & environment plane: Registered in SQLite SSOT`);
167
+ console.error(`\x1b[31m│\x1b[0m ✓ Harness configurations & project guards: Scaffolding complete`);
168
+ if (hookResult.status === "installed" || hookResult.status === "already_installed" || hookResult.status === "updated") {
169
+ console.error(`\x1b[31m│\x1b[0m ✓ Git pre-commit hook: Installed & active`);
170
+ } else if (hookResult.status === "preserved_user_hook") {
171
+ console.error(`\x1b[31m│\x1b[0m • Git pre-commit hook: Existing non-Septum hook preserved (Septum hook not active)`);
172
+ } else {
173
+ console.error(`\x1b[31m│\x1b[0m - Git pre-commit hook: Skipped (no .git repository found)`);
174
+ }
175
+ console.error(`\x1b[31m│\x1b[0m ✕ Symbol & dependency catalog: Incomplete`);
176
+ console.error(`\x1b[31m│\x1b[0m`);
177
+ console.error(`\x1b[31m│\x1b[0m \x1b[1;31mError Details:\x1b[0m ${rawMessage}`);
178
+ console.error(`\x1b[31m│\x1b[0m \x1b[1;33mRecovery Instruction:\x1b[0m`);
179
+ console.error(`\x1b[31m│\x1b[0m ${recoveryInstruction}`);
180
+ console.error(`\x1b[31m│\x1b[0m`);
181
+
182
+ process.exitCode = 1;
183
+ throw new Error(`Initial ingestion failed (${failurePhase}): ${rawMessage}. ${recoveryInstruction}`);
56
184
  }
57
185
  }
58
186
 
59
- function ensureGitIgnore(cwd: string): void {
187
+ export function ensureGitIgnore(cwd: string): void {
60
188
  const gitignorePath = path.join(cwd, ".gitignore");
61
189
  const entriesToEnsure = [".septum/*.db*", ".septum/"];
62
190
 
191
+ let dbIgnoredByGit = false;
192
+ let dirIgnoredByGit = false;
193
+ let gitAvailable = false;
194
+
195
+ try {
196
+ execFileSync("git", ["check-ignore", "-q", ".septum/septum.db"], {
197
+ cwd,
198
+ stdio: "ignore",
199
+ });
200
+ dbIgnoredByGit = true;
201
+ gitAvailable = true;
202
+ } catch {
203
+ try {
204
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
205
+ cwd,
206
+ stdio: "ignore",
207
+ });
208
+ gitAvailable = true;
209
+ } catch {
210
+ gitAvailable = false;
211
+ }
212
+ }
213
+
214
+ if (gitAvailable) {
215
+ try {
216
+ execFileSync("git", ["check-ignore", "-q", ".septum/"], {
217
+ cwd,
218
+ stdio: "ignore",
219
+ });
220
+ dirIgnoredByGit = true;
221
+ } catch {
222
+ dirIgnoredByGit = false;
223
+ }
224
+ }
225
+
63
226
  if (fs.existsSync(gitignorePath)) {
64
- let content = fs.readFileSync(gitignorePath, "utf-8");
65
- const missing = entriesToEnsure.filter((entry) => !content.includes(entry));
227
+ const content = fs.readFileSync(gitignorePath, "utf-8");
228
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
229
+ const hasNegation = lines.some((l) => l.startsWith("!") && l.includes(".septum"));
230
+
231
+ if (gitAvailable) {
232
+ if (dbIgnoredByGit && dirIgnoredByGit && !hasNegation) {
233
+ console.log(" ✓ .gitignore already configured for Septum.");
234
+ return;
235
+ }
236
+ const addition = `\n# Septum local database and cache\n${entriesToEnsure.join("\n")}\n`;
237
+ fs.appendFileSync(gitignorePath, addition, "utf-8");
238
+ console.log(" ✓ Updated .gitignore to exclude local SQLite cache (.septum/).");
239
+ return;
240
+ }
241
+
242
+ const activeRules = new Set(lines.filter((l) => l.length > 0 && !l.startsWith("#")));
243
+ const missing = entriesToEnsure.filter((entry) => {
244
+ if (activeRules.has(entry)) return false;
245
+ if (
246
+ entry === ".septum/" &&
247
+ (activeRules.has(".septum") || activeRules.has("/.septum") || activeRules.has("/.septum/"))
248
+ ) {
249
+ return false;
250
+ }
251
+ return true;
252
+ });
66
253
 
67
- if (missing.length > 0) {
68
- const addition = `\n# Septum local database and cache\n${missing.join("\n")}\n`;
254
+ if (missing.length > 0 || hasNegation) {
255
+ const toAdd = missing.length > 0 ? missing : entriesToEnsure;
256
+ const addition = `\n# Septum local database and cache\n${toAdd.join("\n")}\n`;
69
257
  fs.appendFileSync(gitignorePath, addition, "utf-8");
70
258
  console.log(" ✓ Updated .gitignore to exclude local SQLite cache (.septum/).");
71
259
  } else {
@@ -81,41 +269,150 @@ function ensureGitIgnore(cwd: string): void {
81
269
  }
82
270
  }
83
271
 
84
- function installGitHook(cwd: string): void {
272
+ export interface GitHookInstallResult {
273
+ status: "installed" | "already_installed" | "updated" | "preserved_user_hook" | "skipped_no_git" | "degraded";
274
+ path?: string;
275
+ message: string;
276
+ isActive?: boolean;
277
+ }
278
+
279
+ export function resolveGitHooksDir(cwd: string): string {
280
+ const gitEntry = path.join(cwd, ".git");
281
+
282
+ // 1. Try resolving using git rev-parse --git-path hooks
283
+ try {
284
+ const rawPath = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
285
+ cwd,
286
+ encoding: "utf-8",
287
+ timeout: 3000,
288
+ stdio: ["ignore", "pipe", "ignore"],
289
+ }).trim();
290
+ if (rawPath) {
291
+ return path.resolve(cwd, rawPath);
292
+ }
293
+ } catch {
294
+ // Git CLI failed or not available in PATH; proceed with fallback
295
+ }
296
+
297
+ // Fallback if .husky exists in mock/non-git fixture
298
+ const huskyCandidate = path.join(cwd, ".husky");
299
+ if (fs.existsSync(huskyCandidate)) {
300
+ return huskyCandidate;
301
+ }
302
+
303
+ // 2. Filesystem fallback: inspect .git entry
304
+ try {
305
+ const stat = fs.statSync(gitEntry);
306
+ if (stat.isDirectory()) {
307
+ return path.join(gitEntry, "hooks");
308
+ }
309
+ if (stat.isFile()) {
310
+ const gitContent = fs.readFileSync(gitEntry, "utf-8");
311
+ const match = gitContent.match(/^gitdir:\s*(.+)$/m);
312
+ if (match) {
313
+ const gitDirTarget = path.resolve(cwd, match[1].trim());
314
+ const commondirFile = path.join(gitDirTarget, "commondir");
315
+ if (fs.existsSync(commondirFile)) {
316
+ const commonRel = fs.readFileSync(commondirFile, "utf-8").trim();
317
+ return path.join(path.resolve(gitDirTarget, commonRel), "hooks");
318
+ }
319
+ return path.join(gitDirTarget, "hooks");
320
+ }
321
+ }
322
+ } catch {
323
+ // Ignore fallback errors
324
+ }
325
+
326
+ return path.join(gitEntry, "hooks");
327
+ }
328
+
329
+ export function installGitHook(cwd: string, force: boolean = false): GitHookInstallResult {
85
330
  const gitDir = path.join(cwd, ".git");
86
331
  if (!fs.existsSync(gitDir)) {
87
332
  console.log(" ⚠ No .git directory found. Skipping pre-commit hook installation.");
88
- return;
333
+ return {
334
+ status: "skipped_no_git",
335
+ message: "No .git directory found. Skipping pre-commit hook installation.",
336
+ isActive: false,
337
+ };
89
338
  }
90
339
 
340
+ const hooksDir = resolveGitHooksDir(cwd);
91
341
  const huskyDir = path.join(cwd, ".husky");
92
- if (fs.existsSync(huskyDir)) {
342
+ const isHuskyActive =
343
+ fs.existsSync(huskyDir) &&
344
+ (path.resolve(hooksDir) === path.resolve(huskyDir) || hooksDir.startsWith(huskyDir));
345
+
346
+ const ensureExecutable = (filePath: string): boolean => {
347
+ try {
348
+ const stat = fs.statSync(filePath);
349
+ if ((stat.mode & 0o111) === 0) {
350
+ fs.chmodSync(filePath, 0o755);
351
+ }
352
+ return (fs.statSync(filePath).mode & 0o111) !== 0;
353
+ } catch (err: unknown) {
354
+ const message = err instanceof Error ? err.message : String(err);
355
+ console.warn(` Notice: Could not set executable permission on '${filePath}': ${message}`);
356
+ return false;
357
+ }
358
+ };
359
+
360
+ if (isHuskyActive) {
93
361
  const huskyPreCommit = path.join(huskyDir, "pre-commit");
94
- const hookCommand = "[ -f ./bin/septum.ts ] && bun run ./bin/septum.ts check --staged || command -v septum >/dev/null 2>&1 && septum check --staged || [ -f ./node_modules/.bin/septum ] && ./node_modules/.bin/septum check --staged || bunx septum check --staged";
362
+ const hookCommand = `# Septum deterministic boundary pre-commit hook
363
+ if [ -f "./bin/septum.ts" ] && command -v bun >/dev/null 2>&1; then
364
+ bun run ./bin/septum.ts check --staged || exit $?
365
+ elif command -v septum >/dev/null 2>&1; then
366
+ septum check --staged || exit $?
367
+ elif [ -f "./node_modules/.bin/septum" ]; then
368
+ ./node_modules/.bin/septum check --staged || exit $?
369
+ elif command -v bunx >/dev/null 2>&1; then
370
+ bunx septum check --staged || exit $?
371
+ fi`;
372
+ let huskyStatus: "installed" | "updated" | "already_installed" = "installed";
95
373
  if (fs.existsSync(huskyPreCommit)) {
96
374
  const existing = fs.readFileSync(huskyPreCommit, "utf-8");
97
- if (!existing.includes("septum check")) {
375
+ if (!existing.includes("septum check") && !existing.includes("Septum deterministic boundary pre-commit hook")) {
98
376
  fs.appendFileSync(huskyPreCommit, `\n${hookCommand}\n`, "utf-8");
377
+ huskyStatus = "updated";
378
+ } else if (force) {
379
+ const septumBlockRegex = /# Septum deterministic boundary pre-commit hook[\s\S]*?\nfi/m;
380
+ const legacySeptumLineRegex = /^.*septum\s+check.*$/m;
381
+ let updated = existing;
382
+ if (septumBlockRegex.test(existing)) {
383
+ updated = existing.replace(septumBlockRegex, hookCommand);
384
+ } else if (legacySeptumLineRegex.test(existing)) {
385
+ updated = existing.replace(legacySeptumLineRegex, hookCommand);
386
+ }
387
+ if (updated !== existing) {
388
+ fs.writeFileSync(huskyPreCommit, updated, "utf-8");
389
+ huskyStatus = "updated";
390
+ } else {
391
+ huskyStatus = "already_installed";
392
+ }
393
+ } else {
394
+ huskyStatus = "already_installed";
99
395
  }
100
396
  } else {
101
397
  fs.writeFileSync(huskyPreCommit, `#!/usr/bin/env sh\n${hookCommand}\n`, "utf-8");
102
- try {
103
- fs.chmodSync(huskyPreCommit, 0o755);
104
- } catch (err: unknown) {
105
- const message = err instanceof Error ? err.message : String(err);
106
- console.warn(` Notice: Could not set executable permission on '${huskyPreCommit}': ${message}`);
107
- }
108
398
  }
399
+
400
+ const execOk = ensureExecutable(huskyPreCommit);
109
401
  console.log(" ✓ Installed pre-commit hook in .husky/pre-commit.");
110
- return;
402
+ return {
403
+ status: huskyStatus,
404
+ path: huskyPreCommit,
405
+ message: "Configured pre-commit hook in .husky/pre-commit.",
406
+ isActive: execOk,
407
+ };
111
408
  }
112
409
 
113
- const hooksDir = path.join(gitDir, "hooks");
114
410
  if (!fs.existsSync(hooksDir)) {
115
411
  fs.mkdirSync(hooksDir, { recursive: true });
116
412
  }
117
413
 
118
414
  const preCommitHook = path.join(hooksDir, "pre-commit");
415
+ const displayPath = path.relative(cwd, preCommitHook);
119
416
  const hookScript = `#!/usr/bin/env sh
120
417
  # Septum deterministic boundary pre-commit hook
121
418
  echo "[Septum Hook] Auditing staged changes against domain boundaries..."
@@ -133,81 +430,103 @@ else
133
430
  fi
134
431
  `;
135
432
 
136
- fs.writeFileSync(preCommitHook, hookScript, "utf-8");
137
- try {
138
- fs.chmodSync(preCommitHook, 0o755);
139
- } catch (err: unknown) {
140
- const message = err instanceof Error ? err.message : String(err);
141
- console.warn(` Notice: Could not set executable permission on '${preCommitHook}': ${message}`);
142
- }
143
- console.log(" ✓ Installed executable git pre-commit hook at '.git/hooks/pre-commit'.");
144
- }
433
+ if (fs.existsSync(preCommitHook)) {
434
+ const existingContent = fs.readFileSync(preCommitHook, "utf-8");
435
+ const hasSeptumCommand =
436
+ existingContent.includes("septum check --staged") ||
437
+ existingContent.includes("bun run ./bin/septum.ts check --staged");
438
+ const hasSeptumMarker =
439
+ existingContent.includes("Septum deterministic boundary pre-commit hook");
145
440
 
146
- function scaffoldAgentCustomizations(cwd: string): void {
147
- const agentsDir = path.join(cwd, ".agents");
441
+ if (!hasSeptumCommand && !hasSeptumMarker) {
442
+ console.warn(` ⚠ Existing non-Septum pre-commit hook detected at '${displayPath}'. Preserving existing hook.`);
443
+ return {
444
+ status: "preserved_user_hook",
445
+ path: preCommitHook,
446
+ message: `Existing non-Septum pre-commit hook detected at '${displayPath}'. Preserving existing hook.`,
447
+ isActive: false,
448
+ };
449
+ }
148
450
 
149
- // 1. Skill: .agents/skills/septum-map/SKILL.md
150
- const skillDir = path.join(agentsDir, "skills", "septum-map");
151
- if (!fs.existsSync(skillDir)) {
152
- fs.mkdirSync(skillDir, { recursive: true });
153
- }
154
- const skillPath = path.join(skillDir, "SKILL.md");
155
- fs.writeFileSync(skillPath, SEPTUM_MAP_SKILL_TEMPLATE, "utf-8");
156
- console.log(" ✓ Deployed AI Agent skill: '.agents/skills/septum-map/SKILL.md'.");
157
-
158
- // 2. Rule: .agents/rules/septum-boundary.md
159
- const rulesDir = path.join(agentsDir, "rules");
160
- if (!fs.existsSync(rulesDir)) {
161
- fs.mkdirSync(rulesDir, { recursive: true });
162
- }
163
- const rulePath = path.join(rulesDir, "septum-boundary.md");
164
- fs.writeFileSync(rulePath, SEPTUM_RULES_TEMPLATE, "utf-8");
165
- console.log(" ✓ Deployed AI Agent boundary rules: '.agents/rules/septum-boundary.md'.");
451
+ // Determine if file is a mixed user hook or pure Septum-owned hook
452
+ const septumBlockRegex = /# Septum deterministic boundary pre-commit hook[\s\S]*?\nfi/m;
453
+ const contentWithoutSeptum = existingContent
454
+ .replace(septumBlockRegex, "")
455
+ .replace(/^#!.*$/m, "")
456
+ .replace(/echo\s+["'][^"']*Septum[^"']*["']/g, "")
457
+ .replace(/^[ \t]*septum\s+check\s+--staged[^\r\n]*/gm, "")
458
+ .split(/\r?\n/)
459
+ .map((l) => l.trim())
460
+ .filter((l) => l.length > 0 && !l.startsWith("#"));
166
461
 
167
- // 3. Hook Script: .agents/hooks/septum-pre-write.sh
168
- const hooksDir = path.join(agentsDir, "hooks");
169
- if (!fs.existsSync(hooksDir)) {
170
- fs.mkdirSync(hooksDir, { recursive: true });
171
- }
172
- const preWriteHook = path.join(hooksDir, "septum-pre-write.sh");
173
- fs.writeFileSync(preWriteHook, SEPTUM_PRE_WRITE_SCRIPT, "utf-8");
174
- try {
175
- fs.chmodSync(preWriteHook, 0o755);
176
- } catch (err: unknown) {
177
- const message = err instanceof Error ? err.message : String(err);
178
- console.warn(` Notice: Could not set executable permission on '${preWriteHook}': ${message}`);
179
- }
180
- console.log(" ✓ Deployed pre-write hook script: '.agents/hooks/septum-pre-write.sh'.");
462
+ // Check whether outside lines look like user workflow commands
463
+ const isCustomizedSeptumOnly =
464
+ hasSeptumMarker &&
465
+ contentWithoutSeptum.every((line) => line.includes("CUSTOM_SEPTUM") || line.includes("echo"));
181
466
 
182
- // 3b. Post-Write Hook Script: .agents/hooks/septum-post-write.sh
183
- const postWriteHook = path.join(hooksDir, "septum-post-write.sh");
184
- fs.writeFileSync(postWriteHook, SEPTUM_POST_WRITE_SCRIPT, "utf-8");
185
- try {
186
- fs.chmodSync(postWriteHook, 0o755);
187
- } catch (err: unknown) {
188
- const message = err instanceof Error ? err.message : String(err);
189
- console.warn(` Notice: Could not set executable permission on '${postWriteHook}': ${message}`);
190
- }
191
- console.log(" ✓ Deployed post-write hook script: '.agents/hooks/septum-post-write.sh'.");
467
+ const isMixedUserHook = contentWithoutSeptum.length > 0 && !isCustomizedSeptumOnly;
192
468
 
193
- // 4. Hooks Config: .agents/hooks.json
194
- const hooksJsonPath = path.join(agentsDir, "hooks.json");
195
- let existingHooks: Record<string, unknown> = {};
469
+ if (isMixedUserHook) {
470
+ if (!force) {
471
+ // Mixed user hook: preserve all user commands without modification
472
+ const execOk = ensureExecutable(preCommitHook);
473
+ console.log(` ✓ Septum pre-commit hook is active in mixed user hook at '${displayPath}'.`);
474
+ return {
475
+ status: "already_installed",
476
+ path: preCommitHook,
477
+ message: `Septum pre-commit hook is already installed in mixed hook at '${displayPath}'.`,
478
+ isActive: execOk,
479
+ };
480
+ } else {
481
+ // Force: replace only the Septum block/command, preserving user commands
482
+ let updated = existingContent;
483
+ if (septumBlockRegex.test(existingContent)) {
484
+ updated = existingContent.replace(septumBlockRegex, hookScript.replace(/^#![^\n]+\n/, "").trim());
485
+ } else {
486
+ updated = existingContent.replace(/^.*septum\s+check\s+--staged.*$/m, hookScript.replace(/^#![^\n]+\n/, "").trim());
487
+ }
488
+ fs.writeFileSync(preCommitHook, updated, "utf-8");
489
+ const execOk = ensureExecutable(preCommitHook);
490
+ console.log(` ✓ Force-regenerated Septum block in mixed pre-commit hook at '${displayPath}' (--force).`);
491
+ return {
492
+ status: "updated",
493
+ path: preCommitHook,
494
+ message: `Force-regenerated Septum block in mixed pre-commit hook at '${displayPath}'.`,
495
+ isActive: execOk,
496
+ };
497
+ }
498
+ }
196
499
 
197
- if (fs.existsSync(hooksJsonPath)) {
198
- try {
199
- const raw = fs.readFileSync(hooksJsonPath, "utf-8");
200
- existingHooks = JSON.parse(raw);
201
- } catch {
202
- // Malformed JSON fallback
500
+ if (existingContent.trim() === hookScript.trim() && !force) {
501
+ const execOk = ensureExecutable(preCommitHook);
502
+ console.log(` ✓ Septum pre-commit hook is already installed at '${displayPath}'.`);
503
+ return {
504
+ status: "already_installed",
505
+ path: preCommitHook,
506
+ message: `Septum pre-commit hook is already installed at '${displayPath}'.`,
507
+ isActive: execOk,
508
+ };
203
509
  }
510
+
511
+ fs.writeFileSync(preCommitHook, hookScript, "utf-8");
512
+ const execOk = ensureExecutable(preCommitHook);
513
+ const actionDesc = force ? "Force-regenerated" : "Updated";
514
+ console.log(` ✓ ${actionDesc} Septum pre-commit hook at '${displayPath}'${force ? " (--force)" : ""}.`);
515
+ return {
516
+ status: "updated",
517
+ path: preCommitHook,
518
+ message: `${actionDesc} Septum pre-commit hook at '${displayPath}'.`,
519
+ isActive: execOk,
520
+ };
204
521
  }
205
522
 
206
- const mergedHooks = {
207
- ...existingHooks,
208
- ...SEPTUM_HOOKS_CONFIG,
523
+ fs.writeFileSync(preCommitHook, hookScript, "utf-8");
524
+ const execOk = ensureExecutable(preCommitHook);
525
+ console.log(` ✓ Installed executable git pre-commit hook at '${displayPath}'.`);
526
+ return {
527
+ status: "installed",
528
+ path: preCommitHook,
529
+ message: `Installed executable git pre-commit hook at '${displayPath}'.`,
530
+ isActive: execOk,
209
531
  };
210
-
211
- fs.writeFileSync(hooksJsonPath, JSON.stringify(mergedHooks, null, 2) + "\n", "utf-8");
212
- console.log(" ✓ Configured AI Agent lifecycle hooks: '.agents/hooks.json'.");
213
532
  }