septum 0.1.4 → 0.1.6

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 (70) 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 +216 -188
  7. package/src/cli/commands/ingest.ts +29 -29
  8. package/src/cli/commands/init.ts +213 -211
  9. package/src/cli/commands/locate.ts +61 -61
  10. package/src/cli/commands/query.ts +104 -64
  11. package/src/cli/commands/serve.ts +5 -5
  12. package/src/cli/commands/slice.ts +50 -48
  13. package/src/cli/commands/sync.ts +31 -31
  14. package/src/cli/index.ts +220 -210
  15. package/src/cli/templates/hooks.ts +75 -75
  16. package/src/cli/templates/rules.ts +50 -38
  17. package/src/cli/templates/skill.ts +65 -69
  18. package/src/core/boundary/evaluator.ts +429 -210
  19. package/src/core/config/loader.ts +117 -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 +552 -349
  23. package/src/core/database/repositories/domain.repository.ts +275 -198
  24. package/src/core/database/repositories/file.repository.ts +117 -106
  25. package/src/core/database/repositories/meta.repository.ts +33 -33
  26. package/src/core/database/repositories/symbol.repository.ts +482 -443
  27. package/src/core/database/repository.ts +281 -260
  28. package/src/core/database/schema.ts +154 -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 +606 -535
  32. package/src/core/parser/boundary-tracker.ts +433 -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 +147 -194
  36. package/src/core/parser/extractors/go.ts +408 -180
  37. package/src/core/parser/extractors/laravel-semantic.ts +1045 -901
  38. package/src/core/parser/extractors/mcp-cli-semantic.ts +328 -199
  39. package/src/core/parser/extractors/nestjs-semantic.ts +134 -119
  40. package/src/core/parser/extractors/php.ts +415 -153
  41. package/src/core/parser/extractors/python.ts +460 -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 +668 -181
  45. package/src/core/parser/tree-sitter.ts +41 -41
  46. package/src/core/parser/wasm-loader.ts +271 -0
  47. package/src/core/resolver/call-graph-tracer.ts +698 -668
  48. package/src/core/resolver/module-resolver.ts +479 -342
  49. package/src/core/resolver/path-resolver.ts +147 -147
  50. package/src/core/resolver/symbol-locator.ts +543 -543
  51. package/src/core/resolver/vertical-slice-tracer.ts +323 -285
  52. package/src/core/session/session-manager.ts +56 -56
  53. package/src/core/telemetry/telemetry.ts +86 -86
  54. package/src/core/topology/environment-detector.ts +109 -6
  55. package/src/index.ts +21 -21
  56. package/src/mcp/schemas.ts +695 -286
  57. package/src/mcp/server.ts +927 -716
  58. package/src/mcp/tools/check-boundary.ts +307 -152
  59. package/src/mcp/tools/get-domain-catalog.ts +155 -121
  60. package/src/mcp/tools/get-environment-topology.ts +3 -6
  61. package/src/mcp/tools/get-feature-context.ts +227 -116
  62. package/src/mcp/tools/get-symbol-hotspots.ts +54 -59
  63. package/src/mcp/tools/get-symbol-impact.ts +28 -26
  64. package/src/mcp/tools/get-symbol.ts +65 -63
  65. package/src/mcp/tools/locate-symbol.ts +169 -190
  66. package/src/mcp/tools/register-domain.ts +59 -64
  67. package/src/mcp/tools/register-feature.ts +91 -0
  68. package/src/mcp/tools/trace-vertical-slice.ts +74 -48
  69. package/src/types/index.ts +366 -315
  70. package/src/version.ts +3 -3
@@ -1,190 +1,169 @@
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 } 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
+ import type { LocateSymbolArgs } from "../schemas.ts";
11
+
12
+ export type { LocateSymbolArgs } from "../schemas.ts";
13
+
14
+ export async function handleLocateSymbol(
15
+ repo: SeptumRepository,
16
+ config: ValidatedSeptumConfig,
17
+ args: Partial<LocateSymbolArgs>,
18
+ pipeline?: IngestionPipeline,
19
+ workspaceRoot?: string
20
+ ) {
21
+ const query = (args.query || args.symbol || "").toString().trim();
22
+ if (!query) {
23
+ throw new Error(
24
+ "Missing required argument: 'query' (e.g. 'OrderController::calculateTotal' or error log snippet)"
25
+ );
26
+ }
27
+
28
+ const root = workspaceRoot || args.workspace_path || process.cwd();
29
+ const limit = args.limit ?? 10;
30
+
31
+ // 1. Guarded Lazy Ingestion on Empty Catalog / Cold Start
32
+ if (repo.countFiles() === 0 && pipeline) {
33
+ try {
34
+ await pipeline.run(config);
35
+ } catch {
36
+ // Ingestion fallback
37
+ }
38
+
39
+ if (repo.countFiles() === 0) {
40
+ const diagnostic = {
41
+ status: "NOT_INDEXED",
42
+ found: false,
43
+ query,
44
+ message: `Workspace '${root}' belum terindeks dalam Septum catalog.`,
45
+ suggested_action: {
46
+ tool: "septum_register_domain",
47
+ params: { name: "core", root: "src", ingest_now: true },
48
+ },
49
+ agent_guidance:
50
+ "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.",
51
+ };
52
+
53
+ return {
54
+ content: [
55
+ {
56
+ type: "text",
57
+ text: JSON.stringify(diagnostic, null, 2),
58
+ },
59
+ ],
60
+ metadata: diagnostic,
61
+ };
62
+ }
63
+ }
64
+
65
+ const locator = new SymbolLocator(repo);
66
+ let result: SymbolLocationResult = (locator.locate as any)(query, args.domain, limit);
67
+
68
+ // 2. JIT Delta Sync: Check if located file has changed on disk since last ingest
69
+ if (result.found && result.file_path && pipeline) {
70
+ const fullPath = isAbsolute(result.file_path)
71
+ ? result.file_path
72
+ : join(root, result.file_path);
73
+ try {
74
+ const reindexed = await pipeline.ingestFile(fullPath, config);
75
+ if (reindexed) {
76
+ result = (locator.locate as any)(query, args.domain, limit);
77
+ }
78
+ } catch {
79
+ // Silently fall back to existing result
80
+ }
81
+ }
82
+
83
+ // 3. Fallback on Cache Miss: Search for candidate source files matching the query terms
84
+ if (!result.found && pipeline) {
85
+ try {
86
+ const cleanTerm = query.replace(/[^a-zA-Z0-9_]/g, " ").trim().split(/\s+/)[0] || "";
87
+ if (cleanTerm.length >= 3) {
88
+ const candidateFiles = findCandidateFiles(root, cleanTerm);
89
+ let anyReindexed = false;
90
+ for (const candidate of candidateFiles) {
91
+ const reindexed = await pipeline.ingestFile(candidate, config);
92
+ if (reindexed) anyReindexed = true;
93
+ }
94
+ if (anyReindexed) {
95
+ result = (locator.locate as any)(query, args.domain, limit);
96
+ }
97
+ }
98
+ } catch {
99
+ // Ignore candidate scan errors
100
+ }
101
+ }
102
+
103
+ const suggestions = (result.suggestions || []).slice(0, limit);
104
+ const summary = result.found
105
+ ? `[FOUND] ${result.exact_symbol?.name || query} in ${result.file_path} (lines ${result.exact_symbol?.line_start}-${result.exact_symbol?.line_end})`
106
+ : `[NOT FOUND] Symbol '${query}' not found. ${suggestions.length > 0 ? `Top suggestions: ${suggestions.map((s) => s.name).slice(0, 3).join(", ")}` : "No close suggestions."}`;
107
+
108
+ const payload = {
109
+ status: result.found ? "success" : "not_found",
110
+ found: result.found,
111
+ query,
112
+ file_path: result.file_path || null,
113
+ container_name: result.container_name || null,
114
+ exact_symbol: result.exact_symbol || null,
115
+ sibling_methods: result.sibling_methods || [],
116
+ suggestions,
117
+ summary,
118
+ message: result.message || summary,
119
+ };
120
+
121
+ return {
122
+ content: [
123
+ {
124
+ type: "text" as const,
125
+ text: JSON.stringify(payload, null, 2),
126
+ },
127
+ ],
128
+ metadata: payload,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Searches for potential candidate files whose names resemble parts of the query term.
134
+ */
135
+ function findCandidateFiles(root: string, term: string): string[] {
136
+ const matched: string[] = [];
137
+ const lowerTerm = term.toLowerCase().replace(/_/g, "");
138
+ const searchDirs = ["src", "app", "lib"].map((d) => join(root, d)).filter((d) => existsSync(d));
139
+
140
+ function scan(dir: string, depth: number = 0) {
141
+ if (depth > 4) return;
142
+ try {
143
+ const entries = readdirSync(dir, { withFileTypes: true });
144
+ for (const e of entries) {
145
+ if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
146
+ const full = join(dir, e.name);
147
+ if (e.isDirectory()) {
148
+ scan(full, depth + 1);
149
+ } else if (e.isFile() && /\.(ts|tsx|js|jsx|php|py|go)$/i.test(e.name)) {
150
+ const lowerName = e.name.toLowerCase().replace(/[-_.]/g, "");
151
+ if (lowerName.includes(lowerTerm) || lowerTerm.includes(lowerName.replace(/\.[^.]+$/, ""))) {
152
+ matched.push(full);
153
+ if (matched.length >= 5) return;
154
+ }
155
+ }
156
+ }
157
+ } catch {
158
+ // Ignore
159
+ }
160
+ }
161
+
162
+ for (const sDir of searchDirs) {
163
+ scan(sDir);
164
+ if (matched.length >= 5) break;
165
+ }
166
+
167
+ return matched;
168
+ }
169
+
@@ -1,64 +1,59 @@
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 { RegisterDomainArgs } from "../schemas.ts";
5
+
6
+ export type { RegisterDomainArgs } from "../schemas.ts";
7
+
8
+ export async function handleRegisterDomain(
9
+ repo: SeptumRepository,
10
+ config: ValidatedSeptumConfig,
11
+ args: Partial<RegisterDomainArgs>,
12
+ workspaceRoot: string = process.cwd()
13
+ ) {
14
+ const domainName = (args.domain || args.name || "").trim();
15
+ if (!domainName || !args.root) {
16
+ throw new Error("Missing required arguments: 'domain' (or 'name') and 'root' are required.");
17
+ }
18
+
19
+ const domainConfig = {
20
+ root: args.root,
21
+ description: args.description || `Domain: ${domainName}`,
22
+ allowed_dependencies: args.allowed_dependencies || [],
23
+ forbidden_dependencies: args.forbidden_dependencies || [],
24
+ archetypes: args.archetypes || {},
25
+ };
26
+
27
+ const domainId = repo.domains.upsertDomain(domainName, domainConfig);
28
+
29
+ config.domains[domainName] = domainConfig;
30
+
31
+ let ingestionMetrics: IngestionMetrics | null = null;
32
+ if (args.ingest_now) {
33
+ const pipeline = new IngestionPipeline(repo, workspaceRoot);
34
+ ingestionMetrics = await pipeline.run(config);
35
+ }
36
+
37
+ return {
38
+ content: [
39
+ {
40
+ type: "text" as const,
41
+ text: JSON.stringify(
42
+ {
43
+ status: "success",
44
+ message: `Domain '${domainName}' successfully registered in SQLite SSOT.${
45
+ args.ingest_now ? " Initial ingestion completed." : ""
46
+ }`,
47
+ domain_id: domainId,
48
+ domain: domainName,
49
+ name: domainName,
50
+ config: domainConfig,
51
+ ...(ingestionMetrics ? { ingestion_metrics: ingestionMetrics } : {}),
52
+ },
53
+ null,
54
+ 2
55
+ ),
56
+ },
57
+ ],
58
+ };
59
+ }
@@ -0,0 +1,91 @@
1
+ import type { ValidatedSeptumConfig } from "../../core/config/schema.ts";
2
+ import type { SeptumRepository } from "../../core/database/repository.ts";
3
+ import { SessionManager } from "../../core/session/session-manager.ts";
4
+ import type { RegisterFeatureArgs } from "../schemas.ts";
5
+
6
+ export type { RegisterFeatureArgs } from "../schemas.ts";
7
+
8
+ export async function handleRegisterFeature(
9
+ repo: SeptumRepository,
10
+ config: ValidatedSeptumConfig,
11
+ args: Partial<RegisterFeatureArgs>,
12
+ workspaceRoot: string = process.cwd()
13
+ ) {
14
+ const featureKey = (args.feature_key || args.feature || "").toString().trim();
15
+ const domain = (args.domain || args.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
+ if (typeof (repo as any)?.upsertFeature === "function") {
50
+ (repo as any).upsertFeature(featureKey, featureConfig);
51
+ } else if (typeof repo?.setMeta === "function") {
52
+ try {
53
+ const existingRaw = repo.getMeta("features_json");
54
+ const featuresMap = existingRaw ? JSON.parse(existingRaw) : {};
55
+ featuresMap[featureKey] = featureConfig;
56
+ repo.setMeta("features_json", JSON.stringify(featuresMap));
57
+ } catch {
58
+ // Ignore meta persistence errors on mock repositories
59
+ }
60
+ }
61
+
62
+ // Record Active Feature Session Lease (.septum/session.json)
63
+ SessionManager.saveActiveSession(workspaceRoot, {
64
+ feature_key: featureKey,
65
+ domain,
66
+ touchpoints: featureConfig.allowed_touchpoints,
67
+ locked_at: new Date().toISOString(),
68
+ });
69
+
70
+ return {
71
+ content: [
72
+ {
73
+ type: "text" as const,
74
+ text: JSON.stringify(
75
+ {
76
+ status: "success",
77
+ message: `Feature '${featureKey}' successfully registered in SQLite SSOT and session lease established.`,
78
+ feature_key: featureKey,
79
+ feature: featureKey,
80
+ domain,
81
+ touchpoints: featureConfig.allowed_touchpoints,
82
+ reuse_symbols: featureConfig.reuse_symbols,
83
+ },
84
+ null,
85
+ 2
86
+ ),
87
+ },
88
+ ],
89
+ };
90
+ }
91
+