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
@@ -0,0 +1,74 @@
1
+ import { RESOURCE_LIMITS } from "./limits.ts";
2
+
3
+ /** One mutation/parse lane per server; failures do not poison later work. */
4
+ export class SerialWorkQueue {
5
+ private tail: Promise<void> = Promise.resolve();
6
+ private pending = 0;
7
+ private readonly maxPending: number;
8
+
9
+ constructor(maxPending = RESOURCE_LIMITS.maxQueuePending) {
10
+ this.maxPending = maxPending;
11
+ }
12
+
13
+ get pendingCount(): number {
14
+ return this.pending;
15
+ }
16
+
17
+ run<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
18
+ if (signal?.aborted) {
19
+ return Promise.reject(signal.reason ?? new Error("Request was cancelled before queueing"));
20
+ }
21
+
22
+ if (this.pending >= this.maxPending) {
23
+ const busyError = new Error(`Server is busy: request admission limit (${this.maxPending}) exceeded. Retry after current tasks finish.`);
24
+ (busyError as any).code = "SERVER_BUSY";
25
+ return Promise.reject(busyError);
26
+ }
27
+
28
+ this.pending++;
29
+
30
+ return new Promise<T>((resolve, reject) => {
31
+ let cancelled = false;
32
+
33
+ const abortHandler = () => {
34
+ cancelled = true;
35
+ reject(signal?.reason ?? new Error("Request was cancelled while queued"));
36
+ };
37
+
38
+ if (signal) {
39
+ signal.addEventListener("abort", abortHandler, { once: true });
40
+ }
41
+
42
+ const execute = async () => {
43
+ if (signal) {
44
+ signal.removeEventListener("abort", abortHandler);
45
+ }
46
+ if (cancelled || signal?.aborted) {
47
+ if (!cancelled) {
48
+ reject(signal?.reason ?? new Error("Request was cancelled before execution"));
49
+ }
50
+ return;
51
+ }
52
+
53
+ try {
54
+ const res = await work();
55
+ resolve(res);
56
+ } catch (err) {
57
+ reject(err);
58
+ }
59
+ };
60
+
61
+ const next = this.tail.then(execute, execute);
62
+ this.tail = next.finally(() => {
63
+ this.pending--;
64
+ if (this.pending === 0) {
65
+ this.tail = Promise.resolve();
66
+ }
67
+ });
68
+ });
69
+ }
70
+
71
+ drain(): Promise<void> {
72
+ return this.tail;
73
+ }
74
+ }
@@ -1,56 +1,85 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import type { SeptumRepository } from "../database/repository.ts";
3
5
  import type { ActiveFeatureSession } from "../../types/index.ts";
6
+ import { SeptumDatabase } from "../database/client.ts";
4
7
 
5
8
  export class SessionManager {
6
9
  private static readonly SESSION_FILE = ".septum/session.json";
7
10
 
8
- /**
9
- * Save the active feature session to .septum/session.json
10
- */
11
- public static saveActiveSession(workspaceRoot: string, session: ActiveFeatureSession): void {
12
- const septumDir = join(workspaceRoot, ".septum");
13
- if (!existsSync(septumDir)) {
14
- mkdirSync(septumDir, { recursive: true });
11
+ public static getOwnerId(): string {
12
+ return process.env.SEPTUM_SESSION_ID || `process:${process.pid}`;
13
+ }
14
+
15
+ private static mutate<T>(workspaceRoot: string, fn: () => T, repo?: SeptumRepository): T {
16
+ if (repo) return repo.runInTransaction(fn, true);
17
+ const db = new SeptumDatabase(join(workspaceRoot, ".septum/septum.db"));
18
+ try {
19
+ // Serialize ownership checks with writes across CLI and MCP processes.
20
+ return db.raw.transaction(fn).immediate();
21
+ } finally {
22
+ db.close();
15
23
  }
16
- const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
17
- writeFileSync(sessionPath, JSON.stringify(session, null, 2), "utf-8");
18
24
  }
19
25
 
20
- /**
21
- * Get the active feature session, or null if no session exists or file is invalid
22
- */
26
+ public static saveActiveSession(
27
+ workspaceRoot: string,
28
+ session: ActiveFeatureSession,
29
+ ownerId: string = SessionManager.getOwnerId(),
30
+ persistence?: { repo: SeptumRepository; save: () => void }
31
+ ): void {
32
+ SessionManager.mutate(workspaceRoot, () => {
33
+ const current = SessionManager.getActiveSession(workspaceRoot);
34
+ if (current && current.owner_id !== ownerId) {
35
+ throw new Error(`Workspace already has an active feature session owned by '${current.owner_id || "legacy-session"}'. Release it with its owner, or explicitly run 'septum feature clear --force'.`);
36
+ }
37
+ const septumDir = join(workspaceRoot, ".septum");
38
+ mkdirSync(septumDir, { recursive: true });
39
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
40
+ const temporaryPath = `${sessionPath}.${randomUUID()}.tmp`;
41
+ try {
42
+ writeFileSync(temporaryPath, JSON.stringify({ ...session, owner_id: ownerId }, null, 2), "utf-8");
43
+ // Metadata and ownership use one write lock; a failed rename rolls back SQLite.
44
+ persistence?.save();
45
+ renameSync(temporaryPath, sessionPath);
46
+ } finally {
47
+ rmSync(temporaryPath, { force: true });
48
+ }
49
+ }, persistence?.repo);
50
+ }
51
+
23
52
  public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
24
53
  const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
25
- if (!existsSync(sessionPath)) {
26
- return null;
27
- }
28
-
54
+ if (!existsSync(sessionPath)) return null;
55
+ let parsed: any;
29
56
  try {
30
- const raw = readFileSync(sessionPath, "utf-8");
31
- const parsed = JSON.parse(raw) as ActiveFeatureSession;
32
- if (parsed && typeof parsed.feature_key === "string" && Array.isArray(parsed.touchpoints)) {
33
- return parsed;
34
- }
35
- return null;
36
- } catch {
37
- return null;
57
+ parsed = JSON.parse(readFileSync(sessionPath, "utf-8")) as ActiveFeatureSession;
58
+ } catch (e: any) {
59
+ throw new Error(`Corrupted active feature session JSON in '${sessionPath}': ${e.message}. Release it explicitly with 'septum feature clear --force' or 'septum_feature({action: "release", force: true})'.`);
38
60
  }
61
+ if (parsed && typeof parsed.feature_key === "string" && parsed.feature_key.trim() &&
62
+ typeof parsed.domain === "string" && parsed.domain.trim() &&
63
+ Array.isArray(parsed.touchpoints) && parsed.touchpoints.every((tp: unknown) => typeof tp === "string") &&
64
+ (parsed.owner_id === undefined || typeof parsed.owner_id === "string")) return parsed;
65
+ throw new Error(`Invalid active feature session in '${sessionPath}'. Release it explicitly with 'septum feature clear --force'.`);
39
66
  }
40
67
 
41
- /**
42
- * Clear the active feature session
43
- */
44
- public static clearActiveSession(workspaceRoot: string): boolean {
45
- const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
46
- if (existsSync(sessionPath)) {
47
- try {
48
- rmSync(sessionPath, { force: true });
49
- return true;
50
- } catch {
51
- return false;
68
+ public static clearActiveSession(
69
+ workspaceRoot: string,
70
+ ownerId: string = SessionManager.getOwnerId(),
71
+ force: boolean = false
72
+ ): boolean {
73
+ if (!existsSync(join(workspaceRoot, SessionManager.SESSION_FILE))) return false;
74
+ return SessionManager.mutate(workspaceRoot, () => {
75
+ const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
76
+ if (!existsSync(sessionPath)) return false;
77
+ if (!force) {
78
+ const current = SessionManager.getActiveSession(workspaceRoot);
79
+ if (current?.owner_id !== ownerId) throw new Error("Only the active session owner can release it. Use 'septum feature clear --force' for explicit recovery.");
52
80
  }
53
- }
54
- return false;
81
+ rmSync(sessionPath, { force: true });
82
+ return true;
83
+ });
55
84
  }
56
85
  }
@@ -56,13 +56,24 @@ export class SeptumTelemetry {
56
56
  console.error(`[Septum Telemetry SLOW_WARN] ${jsonLine}`);
57
57
  }
58
58
 
59
+ this.appendLog(jsonLine, workspaceRoot);
60
+ }
61
+
62
+ public static recordMemorySample(metrics: Record<string, number>, workspaceRoot: string = process.cwd()): void {
63
+ const jsonLine = JSON.stringify({timestamp: new Date().toISOString(), event: "memory_sample", metrics});
64
+ console.error(`[Septum Memory] ${jsonLine}`);
65
+ this.appendLog(jsonLine, workspaceRoot);
66
+ }
67
+
68
+ private static appendLog(jsonLine: string, workspaceRoot: string): void {
59
69
  // Sink 2: Append-only persistent file recorder in .septum/septum.log
60
70
  try {
61
71
  const logPath = this.getLogPath(workspaceRoot);
62
72
  const logDir = dirname(logPath);
63
73
 
74
+ // SV-03: Do not auto-scaffold .septum directory for telemetry if workspace has not been initialized
64
75
  if (!existsSync(logDir)) {
65
- mkdirSync(logDir, { recursive: true });
76
+ return;
66
77
  }
67
78
 
68
79
  // Check for rotation if file exceeds 2MB
@@ -18,8 +18,8 @@ export class EnvironmentDetector {
18
18
  ): EnvironmentTopology {
19
19
  // 1. Fetch any previously verified layers and nodes from SQLite SSOT
20
20
  const savedLayers: Partial<Record<EnvironmentLayer, EnvironmentLayerInfo>> =
21
- repo && !forceRefresh ? repo.environment.getAllLayers() : {};
22
- const savedNodes = repo && !forceRefresh ? repo.environment.getAllNodes() : [];
21
+ repo ? repo.environment.getAllLayers() : {};
22
+ const savedNodes = repo ? repo.environment.getAllNodes() : [];
23
23
  const savedNodeMap = new Map(savedNodes.map((n) => [n.id, n]));
24
24
 
25
25
  // 2. Perform root file-based heuristic inspection for each of the 6 layers
@@ -31,19 +31,28 @@ export class EnvironmentDetector {
31
31
  const detectedTelemetry = this.inspectTelemetryLayer(workspaceRoot);
32
32
 
33
33
  // 3. Merge layers: Database verified layers take precedence
34
+ const resolveLayer = (layerKey: EnvironmentLayer, detected: EnvironmentLayerInfo): EnvironmentLayerInfo => {
35
+ const saved = savedLayers[layerKey];
36
+ if (saved && saved.status === "VERIFIED") {
37
+ return saved;
38
+ }
39
+ return detected;
40
+ };
41
+
34
42
  const layers: Record<EnvironmentLayer, EnvironmentLayerInfo> = {
35
- edge: savedLayers.edge?.status === "VERIFIED" ? savedLayers.edge : detectedEdge,
36
- gateway: savedLayers.gateway?.status === "VERIFIED" ? savedLayers.gateway : detectedGateway,
37
- host: savedLayers.host?.status === "VERIFIED" ? savedLayers.host : detectedHost,
38
- runtime: savedLayers.runtime?.status === "VERIFIED" ? savedLayers.runtime : detectedRuntime,
39
- storage: savedLayers.storage?.status === "VERIFIED" ? savedLayers.storage : detectedStorage,
40
- telemetry: savedLayers.telemetry?.status === "VERIFIED" ? savedLayers.telemetry : detectedTelemetry,
43
+ edge: resolveLayer("edge", detectedEdge),
44
+ gateway: resolveLayer("gateway", detectedGateway),
45
+ host: resolveLayer("host", detectedHost),
46
+ runtime: resolveLayer("runtime", detectedRuntime),
47
+ storage: resolveLayer("storage", detectedStorage),
48
+ telemetry: resolveLayer("telemetry", detectedTelemetry),
41
49
  };
42
50
 
43
- // 4. Save layers to SQLite if not already stored
51
+ // 4. Save layers to SQLite if not already stored (refresh only non-VERIFIED layers)
44
52
  if (repo) {
45
53
  for (const layerKey of Object.keys(layers) as EnvironmentLayer[]) {
46
- if (!savedLayers[layerKey] || forceRefresh) {
54
+ const saved = savedLayers[layerKey];
55
+ if (!saved || (forceRefresh && saved.status !== "VERIFIED")) {
47
56
  repo.environment.saveLayer(layers[layerKey]);
48
57
  }
49
58
  }
@@ -231,7 +240,8 @@ export class EnvironmentDetector {
231
240
  // 7. Save detected nodes to SQLite SSOT
232
241
  if (repo) {
233
242
  for (const node of finalNodes) {
234
- if (!savedNodeMap.has(node.id) || forceRefresh) {
243
+ const saved = savedNodeMap.get(node.id);
244
+ if (!saved || (forceRefresh && saved.status !== "VERIFIED")) {
235
245
  repo.environment.saveNode(node);
236
246
  }
237
247
  }