septum 0.1.4 → 0.1.5

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 (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -105
  3. package/bin/septum.ts +4 -4
  4. package/package.json +63 -63
  5. package/src/cli/commands/check.ts +161 -161
  6. package/src/cli/commands/hook.ts +188 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +211 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +64 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +48 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +210 -210
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +38 -38
  17. package/src/cli/templates/skill.ts +69 -69
  18. package/src/core/boundary/evaluator.ts +232 -210
  19. package/src/core/config/loader.ts +97 -97
  20. package/src/core/config/schema.ts +39 -39
  21. package/src/core/database/client.ts +44 -44
  22. package/src/core/database/repositories/dependency.repository.ts +366 -349
  23. package/src/core/database/repositories/domain.repository.ts +218 -198
  24. package/src/core/database/repositories/file.repository.ts +116 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +443 -443
  27. package/src/core/database/repository.ts +264 -260
  28. package/src/core/database/schema.ts +153 -153
  29. package/src/core/discovery/topology-detector.ts +486 -486
  30. package/src/core/ingestion/hasher.ts +5 -5
  31. package/src/core/ingestion/pipeline.ts +584 -535
  32. package/src/core/parser/boundary-tracker.ts +288 -288
  33. package/src/core/parser/extractors/base.ts +62 -62
  34. package/src/core/parser/extractors/blade.ts +158 -158
  35. package/src/core/parser/extractors/frontend.ts +194 -194
  36. package/src/core/parser/extractors/go.ts +229 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1039 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +199 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +119 -119
  40. package/src/core/parser/extractors/php.ts +153 -153
  41. package/src/core/parser/extractors/python.ts +228 -228
  42. package/src/core/parser/extractors/registry.ts +70 -70
  43. package/src/core/parser/extractors/semantic-extractor.interface.ts +21 -21
  44. package/src/core/parser/extractors/typescript.ts +181 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/resolver/call-graph-tracer.ts +666 -668
  47. package/src/core/resolver/module-resolver.ts +408 -342
  48. package/src/core/resolver/path-resolver.ts +147 -147
  49. package/src/core/resolver/symbol-locator.ts +543 -543
  50. package/src/core/resolver/vertical-slice-tracer.ts +301 -285
  51. package/src/core/session/session-manager.ts +56 -56
  52. package/src/core/telemetry/telemetry.ts +86 -86
  53. package/src/index.ts +21 -21
  54. package/src/mcp/schemas.ts +578 -286
  55. package/src/mcp/server.ts +875 -716
  56. package/src/mcp/tools/check-boundary.ts +171 -152
  57. package/src/mcp/tools/get-domain-catalog.ts +145 -121
  58. package/src/mcp/tools/get-feature-context.ts +212 -116
  59. package/src/mcp/tools/get-symbol-hotspots.ts +60 -59
  60. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  61. package/src/mcp/tools/get-symbol.ts +65 -63
  62. package/src/mcp/tools/locate-symbol.ts +171 -190
  63. package/src/mcp/tools/register-domain.ts +68 -64
  64. package/src/mcp/tools/register-feature.ts +100 -0
  65. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  66. package/src/types/index.ts +335 -315
  67. package/src/version.ts +3 -3
@@ -1,190 +1,171 @@
1
- import { existsSync, readdirSync } from "node:fs";
2
- import { isAbsolute, join, basename } from "node:path";
3
- import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
4
- import type { SeptumRepository } from "../../core/database/repository.ts";
5
- import type { IngestionPipeline } from "../../core/ingestion/pipeline.ts";
6
- import {
7
- SymbolLocator,
8
- type SymbolLocationResult,
9
- } from "../../core/resolver/symbol-locator.ts";
10
-
11
- export interface LocateSymbolArgs {
12
- query: string;
13
- domain?: string;
14
- workspace_path?: string;
15
- }
16
-
17
- export async function handleLocateSymbol(
18
- repo: SeptumRepository,
19
- config: ValidatedSeptumConfig,
20
- args: LocateSymbolArgs,
21
- pipeline?: IngestionPipeline,
22
- workspaceRoot?: string
23
- ) {
24
- if (!args.query) {
25
- throw new Error(
26
- "Missing required argument: 'query' (e.g. 'OrderController::calculateTotal' or error log snippet)"
27
- );
28
- }
29
-
30
- const root = workspaceRoot || process.cwd();
31
-
32
- // 1. Guarded Lazy Ingestion on Empty Catalog / Cold Start
33
- if (repo.countFiles() === 0 && pipeline) {
34
- try {
35
- await pipeline.run(config);
36
- } catch {
37
- // Ingestion fallback
38
- }
39
-
40
- if (repo.countFiles() === 0) {
41
- const diagnostic = {
42
- status: "NOT_INDEXED",
43
- found: false,
44
- query: args.query,
45
- message: `Workspace '${root}' belum terindeks dalam Septum catalog.`,
46
- suggested_action: {
47
- tool: "septum_register_domain",
48
- params: { name: "core", root: "src", ingest_now: true },
49
- },
50
- agent_guidance:
51
- "Panggil tool 'septum_register_domain(name: \"core\", root: \"src\", ingest_now: true)' untuk mendaftarkan dan mengindeks codebase ini, atau jalankan 'septum init' di terminal workspace.",
52
- };
53
-
54
- return {
55
- content: [
56
- {
57
- type: "text",
58
- text: JSON.stringify(diagnostic, null, 2),
59
- },
60
- ],
61
- metadata: diagnostic,
62
- };
63
- }
64
- }
65
-
66
- const locator = new SymbolLocator(repo);
67
- let result: SymbolLocationResult = locator.locate(args.query, args.domain);
68
-
69
- // 2. JIT Delta Sync: Check if located file has changed on disk since last ingest
70
- if (result.found && result.file_path && pipeline) {
71
- const fullPath = isAbsolute(result.file_path)
72
- ? result.file_path
73
- : join(root, result.file_path);
74
- try {
75
- const reindexed = await pipeline.ingestFile(fullPath, config);
76
- if (reindexed) {
77
- result = locator.locate(args.query, args.domain);
78
- }
79
- } catch {
80
- // Silently fall back to existing result
81
- }
82
- }
83
-
84
- // 3. Fallback on Cache Miss: Search for candidate source files matching the query terms
85
- if (!result.found && pipeline) {
86
- try {
87
- const cleanTerm = args.query.replace(/[^a-zA-Z0-9_]/g, " ").trim().split(/\s+/)[0] || "";
88
- if (cleanTerm.length >= 3) {
89
- const candidateFiles = findCandidateFiles(root, cleanTerm);
90
- let anyReindexed = false;
91
- for (const candidate of candidateFiles) {
92
- const reindexed = await pipeline.ingestFile(candidate, config);
93
- if (reindexed) anyReindexed = true;
94
- }
95
- if (anyReindexed) {
96
- result = locator.locate(args.query, args.domain);
97
- }
98
- }
99
- } catch {
100
- // Ignore candidate scan errors
101
- }
102
- }
103
-
104
- let formattedText = "";
105
-
106
- if (result.found) {
107
- formattedText = `=== SYMBOL LOCATED ===\n`;
108
- formattedText += `Status: FOUND (Exact Match)\n`;
109
- formattedText += `File: ${result.file_path}\n`;
110
- if (result.exact_symbol) {
111
- formattedText += `Symbol: ${result.exact_symbol.name} [${result.exact_symbol.kind.toUpperCase()}]\n`;
112
- formattedText += `Signature: ${result.exact_symbol.signature}\n`;
113
- formattedText += `Lines: ${result.exact_symbol.line_start} - ${result.exact_symbol.line_end}\n`;
114
- }
115
- if (result.sibling_methods && result.sibling_methods.length > 0) {
116
- formattedText += `\nSibling Methods in ${result.container_name || "Container"} (${result.sibling_methods.length}):\n`;
117
- formattedText += ` ${result.sibling_methods.join(", ")}\n`;
118
- }
119
- } else {
120
- formattedText = `=== SYMBOL NOT FOUND / DIAGNOSTIC RESOLUTION ===\n`;
121
- formattedText += `Status: NOT FOUND\n`;
122
- if (result.file_path) {
123
- formattedText += `Target File: ${result.file_path}\n`;
124
- formattedText += `Target Container: ${result.container_name || "Unknown"}\n`;
125
- }
126
- formattedText += `Message: ${result.message}\n`;
127
-
128
- if (result.sibling_methods && result.sibling_methods.length > 0) {
129
- formattedText += `\nExisting Methods in ${result.container_name || "Container"} (${result.sibling_methods.length}):\n`;
130
- formattedText += ` ${result.sibling_methods.join(", ")}\n`;
131
- }
132
-
133
- if (result.suggestions.length > 0) {
134
- formattedText += `\nTop Suggestions (Ranked by Similarity):\n`;
135
- for (const sug of result.suggestions) {
136
- const percent = Math.round(sug.similarity_score * 100);
137
- formattedText += ` • ${sug.name} [${percent}% match] -> lines ${sug.line_start}-${sug.line_end} (${sug.signature})\n`;
138
- }
139
- }
140
- }
141
-
142
- return {
143
- content: [
144
- {
145
- type: "text",
146
- text: formattedText.trim(),
147
- },
148
- ],
149
- metadata: result,
150
- };
151
- }
152
-
153
- /**
154
- * Searches for potential candidate files whose names resemble parts of the query term.
155
- */
156
- function findCandidateFiles(root: string, term: string): string[] {
157
- const matched: string[] = [];
158
- const lowerTerm = term.toLowerCase().replace(/_/g, "");
159
- const searchDirs = ["src", "app", "lib"].map((d) => join(root, d)).filter((d) => existsSync(d));
160
-
161
- function scan(dir: string, depth: number = 0) {
162
- if (depth > 4) return;
163
- try {
164
- const entries = readdirSync(dir, { withFileTypes: true });
165
- for (const e of entries) {
166
- if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
167
- const full = join(dir, e.name);
168
- if (e.isDirectory()) {
169
- scan(full, depth + 1);
170
- } else if (e.isFile() && /\.(ts|tsx|js|jsx|php|py|go)$/i.test(e.name)) {
171
- const lowerName = e.name.toLowerCase().replace(/[-_.]/g, "");
172
- if (lowerName.includes(lowerTerm) || lowerTerm.includes(lowerName.replace(/\.[^.]+$/, ""))) {
173
- matched.push(full);
174
- if (matched.length >= 5) return;
175
- }
176
- }
177
- }
178
- } catch {
179
- // Ignore
180
- }
181
- }
182
-
183
- for (const sDir of searchDirs) {
184
- scan(sDir);
185
- if (matched.length >= 5) break;
186
- }
187
-
188
- return matched;
189
- }
190
-
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { isAbsolute, join, basename } from "node:path";
3
+ import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
4
+ import type { SeptumRepository } from "../../core/database/repository.ts";
5
+ import type { IngestionPipeline } from "../../core/ingestion/pipeline.ts";
6
+ import {
7
+ SymbolLocator,
8
+ type SymbolLocationResult,
9
+ } from "../../core/resolver/symbol-locator.ts";
10
+
11
+ export interface LocateSymbolArgs {
12
+ query?: string;
13
+ symbol?: string;
14
+ domain?: string;
15
+ workspace_path?: string;
16
+ }
17
+
18
+ export async function handleLocateSymbol(
19
+ repo: SeptumRepository,
20
+ config: ValidatedSeptumConfig,
21
+ args: LocateSymbolArgs,
22
+ pipeline?: IngestionPipeline,
23
+ workspaceRoot?: string
24
+ ) {
25
+ const query = (args.query || args.symbol || "").toString().trim();
26
+ if (!query) {
27
+ throw new Error(
28
+ "Missing required argument: 'query' (e.g. 'OrderController::calculateTotal' or error log snippet)"
29
+ );
30
+ }
31
+
32
+ const root = workspaceRoot || process.cwd();
33
+
34
+ // 1. Guarded Lazy Ingestion on Empty Catalog / Cold Start
35
+ if (repo.countFiles() === 0 && pipeline) {
36
+ try {
37
+ await pipeline.run(config);
38
+ } catch {
39
+ // Ingestion fallback
40
+ }
41
+
42
+ if (repo.countFiles() === 0) {
43
+ const diagnostic = {
44
+ status: "NOT_INDEXED",
45
+ found: false,
46
+ query,
47
+ message: `Workspace '${root}' belum terindeks dalam Septum catalog.`,
48
+ suggested_action: {
49
+ tool: "septum_register_domain",
50
+ params: { name: "core", root: "src", ingest_now: true },
51
+ },
52
+ agent_guidance:
53
+ "Panggil tool 'septum_register_domain(name: \"core\", root: \"src\", ingest_now: true)' untuk mendaftarkan dan mengindeks codebase ini, atau jalankan 'septum init' di terminal workspace.",
54
+ };
55
+
56
+ return {
57
+ content: [
58
+ {
59
+ type: "text",
60
+ text: JSON.stringify(diagnostic, null, 2),
61
+ },
62
+ ],
63
+ metadata: diagnostic,
64
+ };
65
+ }
66
+ }
67
+
68
+ const locator = new SymbolLocator(repo);
69
+ let result: SymbolLocationResult = locator.locate(query, args.domain);
70
+
71
+ // 2. JIT Delta Sync: Check if located file has changed on disk since last ingest
72
+ if (result.found && result.file_path && pipeline) {
73
+ const fullPath = isAbsolute(result.file_path)
74
+ ? result.file_path
75
+ : join(root, result.file_path);
76
+ try {
77
+ const reindexed = await pipeline.ingestFile(fullPath, config);
78
+ if (reindexed) {
79
+ result = locator.locate(query, args.domain);
80
+ }
81
+ } catch {
82
+ // Silently fall back to existing result
83
+ }
84
+ }
85
+
86
+ // 3. Fallback on Cache Miss: Search for candidate source files matching the query terms
87
+ if (!result.found && pipeline) {
88
+ try {
89
+ const cleanTerm = query.replace(/[^a-zA-Z0-9_]/g, " ").trim().split(/\s+/)[0] || "";
90
+ if (cleanTerm.length >= 3) {
91
+ const candidateFiles = findCandidateFiles(root, cleanTerm);
92
+ let anyReindexed = false;
93
+ for (const candidate of candidateFiles) {
94
+ const reindexed = await pipeline.ingestFile(candidate, config);
95
+ if (reindexed) anyReindexed = true;
96
+ }
97
+ if (anyReindexed) {
98
+ result = locator.locate(query, args.domain);
99
+ }
100
+ }
101
+ } catch {
102
+ // Ignore candidate scan errors
103
+ }
104
+ }
105
+
106
+ const summary = result.found
107
+ ? `[FOUND] ${result.exact_symbol?.name || query} in ${result.file_path} (lines ${result.exact_symbol?.line_start}-${result.exact_symbol?.line_end})`
108
+ : `[NOT FOUND] Symbol '${query}' not found. ${result.suggestions.length > 0 ? `Top suggestions: ${result.suggestions.map((s) => s.name).slice(0, 3).join(", ")}` : "No close suggestions."}`;
109
+
110
+ const payload = {
111
+ status: result.found ? "success" : "not_found",
112
+ found: result.found,
113
+ query,
114
+ file_path: result.file_path || null,
115
+ container_name: result.container_name || null,
116
+ exact_symbol: result.exact_symbol || null,
117
+ sibling_methods: result.sibling_methods || [],
118
+ suggestions: result.suggestions || [],
119
+ summary,
120
+ message: result.message || summary,
121
+ };
122
+
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text" as const,
127
+ text: JSON.stringify(payload, null, 2),
128
+ },
129
+ ],
130
+ metadata: payload,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Searches for potential candidate files whose names resemble parts of the query term.
136
+ */
137
+ function findCandidateFiles(root: string, term: string): string[] {
138
+ const matched: string[] = [];
139
+ const lowerTerm = term.toLowerCase().replace(/_/g, "");
140
+ const searchDirs = ["src", "app", "lib"].map((d) => join(root, d)).filter((d) => existsSync(d));
141
+
142
+ function scan(dir: string, depth: number = 0) {
143
+ if (depth > 4) return;
144
+ try {
145
+ const entries = readdirSync(dir, { withFileTypes: true });
146
+ for (const e of entries) {
147
+ if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
148
+ const full = join(dir, e.name);
149
+ if (e.isDirectory()) {
150
+ scan(full, depth + 1);
151
+ } else if (e.isFile() && /\.(ts|tsx|js|jsx|php|py|go)$/i.test(e.name)) {
152
+ const lowerName = e.name.toLowerCase().replace(/[-_.]/g, "");
153
+ if (lowerName.includes(lowerTerm) || lowerTerm.includes(lowerName.replace(/\.[^.]+$/, ""))) {
154
+ matched.push(full);
155
+ if (matched.length >= 5) return;
156
+ }
157
+ }
158
+ }
159
+ } catch {
160
+ // Ignore
161
+ }
162
+ }
163
+
164
+ for (const sDir of searchDirs) {
165
+ scan(sDir);
166
+ if (matched.length >= 5) break;
167
+ }
168
+
169
+ return matched;
170
+ }
171
+
@@ -1,64 +1,68 @@
1
- import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
2
- import type { SeptumRepository } from "../../core/database/repository.ts";
3
- import { IngestionPipeline, type IngestionMetrics } from "../../core/ingestion/pipeline.ts";
4
- import type { DomainConfig } from "../../types/index.ts";
5
-
6
- export interface RegisterDomainArgs {
7
- name: string;
8
- root: string;
9
- description?: string;
10
- allowed_dependencies?: string[];
11
- forbidden_dependencies?: string[];
12
- archetypes?: Record<string, string>;
13
- ingest_now?: boolean;
14
- }
15
-
16
- export async function handleRegisterDomain(
17
- repo: SeptumRepository,
18
- config: ValidatedSeptumConfig,
19
- args: RegisterDomainArgs
20
- ) {
21
- if (!args.name || !args.root) {
22
- throw new Error("Missing required arguments: 'name' and 'root' are required.");
23
- }
24
-
25
- const domainConfig = {
26
- root: args.root,
27
- description: args.description || `Domain: ${args.name}`,
28
- allowed_dependencies: args.allowed_dependencies || [],
29
- forbidden_dependencies: args.forbidden_dependencies || [],
30
- archetypes: args.archetypes || {},
31
- };
32
-
33
- const domainId = repo.domains.upsertDomain(args.name, domainConfig);
34
-
35
- config.domains[args.name] = domainConfig;
36
-
37
- let ingestionMetrics: IngestionMetrics | null = null;
38
- if (args.ingest_now) {
39
- const pipeline = new IngestionPipeline(repo);
40
- ingestionMetrics = await pipeline.run(config);
41
- }
42
-
43
- return {
44
- content: [
45
- {
46
- type: "text" as const,
47
- text: JSON.stringify(
48
- {
49
- status: "success",
50
- message: `Domain '${args.name}' successfully registered in SQLite SSOT.${
51
- args.ingest_now ? " Initial ingestion completed." : ""
52
- }`,
53
- domain_id: domainId,
54
- domain: args.name,
55
- config: domainConfig,
56
- ...(ingestionMetrics ? { ingestion_metrics: ingestionMetrics } : {}),
57
- },
58
- null,
59
- 2
60
- ),
61
- },
62
- ],
63
- };
64
- }
1
+ import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
2
+ import type { SeptumRepository } from "../../core/database/repository.ts";
3
+ import { IngestionPipeline, type IngestionMetrics } from "../../core/ingestion/pipeline.ts";
4
+ import type { DomainConfig } from "../../types/index.ts";
5
+
6
+ export interface RegisterDomainArgs {
7
+ domain?: string;
8
+ name?: string;
9
+ root: string;
10
+ description?: string;
11
+ allowed_dependencies?: string[];
12
+ forbidden_dependencies?: string[];
13
+ archetypes?: Record<string, string>;
14
+ ingest_now?: boolean;
15
+ }
16
+
17
+ export async function handleRegisterDomain(
18
+ repo: SeptumRepository,
19
+ config: ValidatedSeptumConfig,
20
+ args: RegisterDomainArgs,
21
+ workspaceRoot: string = process.cwd()
22
+ ) {
23
+ const domainName = (args.domain || args.name || "").trim();
24
+ if (!domainName || !args.root) {
25
+ throw new Error("Missing required arguments: 'domain' (or 'name') and 'root' are required.");
26
+ }
27
+
28
+ const domainConfig = {
29
+ root: args.root,
30
+ description: args.description || `Domain: ${domainName}`,
31
+ allowed_dependencies: args.allowed_dependencies || [],
32
+ forbidden_dependencies: args.forbidden_dependencies || [],
33
+ archetypes: args.archetypes || {},
34
+ };
35
+
36
+ const domainId = repo.domains.upsertDomain(domainName, domainConfig);
37
+
38
+ config.domains[domainName] = domainConfig;
39
+
40
+ let ingestionMetrics: IngestionMetrics | null = null;
41
+ if (args.ingest_now) {
42
+ const pipeline = new IngestionPipeline(repo, workspaceRoot);
43
+ ingestionMetrics = await pipeline.run(config);
44
+ }
45
+
46
+ return {
47
+ content: [
48
+ {
49
+ type: "text" as const,
50
+ text: JSON.stringify(
51
+ {
52
+ status: "success",
53
+ message: `Domain '${domainName}' successfully registered in SQLite SSOT.${
54
+ args.ingest_now ? " Initial ingestion completed." : ""
55
+ }`,
56
+ domain_id: domainId,
57
+ domain: domainName,
58
+ name: domainName,
59
+ config: domainConfig,
60
+ ...(ingestionMetrics ? { ingestion_metrics: ingestionMetrics } : {}),
61
+ },
62
+ null,
63
+ 2
64
+ ),
65
+ },
66
+ ],
67
+ };
68
+ }
@@ -0,0 +1,100 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
4
+ import type { SeptumRepository } from "../../core/database/repository.ts";
5
+ import { SessionManager } from "../../core/session/session-manager.ts";
6
+ import type { FeatureConfig, RegisterFeatureArgs } from "../../types/index.ts";
7
+
8
+ export async function handleRegisterFeature(
9
+ _repo: SeptumRepository,
10
+ config: ValidatedSeptumConfig,
11
+ args: RegisterFeatureArgs,
12
+ workspaceRoot: string = process.cwd()
13
+ ) {
14
+ const featureKey = (args.feature_key || (args as unknown as Record<string, unknown>).feature || "").toString().trim();
15
+ const domain = (args.domain || (args as unknown as Record<string, unknown>).domain_name || "").toString().trim();
16
+
17
+ if (!featureKey || !domain) {
18
+ throw new Error("Missing required arguments: 'feature_key' and 'domain' are required.");
19
+ }
20
+
21
+ const rawTouchpoints = args.allowed_touchpoints ?? [];
22
+ const allowedTouchpoints = Array.isArray(rawTouchpoints)
23
+ ? rawTouchpoints
24
+ : typeof rawTouchpoints === "string"
25
+ ? [rawTouchpoints]
26
+ : [];
27
+
28
+ const rawReuse = args.reuse_symbols;
29
+ const reuseSymbols: string[] = Array.isArray(rawReuse)
30
+ ? rawReuse
31
+ : typeof rawReuse === "string"
32
+ ? [rawReuse]
33
+ : [];
34
+
35
+ const featureConfig = {
36
+ domain,
37
+ description: args.description || `Feature: ${featureKey}`,
38
+ allowed_touchpoints: allowedTouchpoints,
39
+ reuse_symbols: reuseSymbols,
40
+ input_contract: args.input_contract || {},
41
+ output_contract: args.output_contract || {},
42
+ };
43
+
44
+ if (!config.features) {
45
+ config.features = {};
46
+ }
47
+ config.features[featureKey] = featureConfig;
48
+
49
+ // Record Active Feature Session Lease (.septum/session.json)
50
+ SessionManager.saveActiveSession(workspaceRoot, {
51
+ feature_key: featureKey,
52
+ domain,
53
+ touchpoints: featureConfig.allowed_touchpoints,
54
+ locked_at: new Date().toISOString(),
55
+ });
56
+
57
+ let persisted = false;
58
+ if (args.persist_to_config) {
59
+ try {
60
+ const configPath = path.resolve(workspaceRoot, "septum.config.json");
61
+ let diskConfig: Record<string, unknown> = {};
62
+ if (fs.existsSync(configPath)) {
63
+ diskConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
64
+ }
65
+ if (!diskConfig.features || typeof diskConfig.features !== "object") {
66
+ diskConfig.features = {};
67
+ }
68
+ (diskConfig.features as Record<string, unknown>)[featureKey] = featureConfig;
69
+ fs.writeFileSync(configPath, JSON.stringify(diskConfig, null, 2), "utf-8");
70
+ persisted = true;
71
+ } catch (err: unknown) {
72
+ const msg = err instanceof Error ? err.message : String(err);
73
+ console.warn(`[Septum Warning] Failed to persist feature to septum.config.json: ${msg}`);
74
+ }
75
+ }
76
+
77
+ return {
78
+ content: [
79
+ {
80
+ type: "text" as const,
81
+ text: JSON.stringify(
82
+ {
83
+ status: "success",
84
+ message: `Feature '${featureKey}' successfully registered and session lease established.${
85
+ persisted ? " Persisted to septum.config.json." : ""
86
+ }`,
87
+ feature_key: featureKey,
88
+ feature: featureKey,
89
+ domain,
90
+ touchpoints: featureConfig.allowed_touchpoints,
91
+ reuse_symbols: featureConfig.reuse_symbols,
92
+ persisted_to_disk: persisted,
93
+ },
94
+ null,
95
+ 2
96
+ ),
97
+ },
98
+ ],
99
+ };
100
+ }