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
package/src/cli/commands/init.ts
CHANGED
|
@@ -1,71 +1,259 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
40
|
+
console.log("\x1b[36m│\x1b[0m");
|
|
34
41
|
|
|
35
|
-
// 2.
|
|
36
|
-
|
|
42
|
+
// 2. Interactive AI Agent Harness Selection
|
|
43
|
+
const selectedHarnesses = await promptSelectHarnesses(SUPPORTED_HARNESSES);
|
|
37
44
|
|
|
38
|
-
// 3.
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
//
|
|
45
|
-
console.log("\
|
|
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
|
|
48
|
-
console.log("\
|
|
49
|
-
console.log("
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
}
|