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.
Files changed (59) hide show
  1. package/README.md +36 -55
  2. package/package.json +3 -2
  3. package/src/cli/commands/check.ts +2 -0
  4. package/src/cli/commands/hook.ts +59 -12
  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 +5 -67
  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/staged-auditor.ts +5 -2
  15. package/src/core/database/client.ts +8 -0
  16. package/src/core/database/repositories/dependency.repository.ts +77 -13
  17. package/src/core/database/repositories/domain.repository.ts +86 -33
  18. package/src/core/database/repositories/symbol.repository.ts +128 -117
  19. package/src/core/database/repository.ts +17 -5
  20. package/src/core/database/schema.ts +1 -1
  21. package/src/core/discovery/topology-detector.ts +71 -19
  22. package/src/core/ingestion/pipeline.ts +228 -94
  23. package/src/core/parser/extractors/laravel-semantic.ts +9 -8
  24. package/src/core/parser/extractors/mcp-cli-semantic.ts +56 -16
  25. package/src/core/parser/extractors/nestjs-semantic.ts +3 -2
  26. package/src/core/parser/schema/providers/laravel-migration-provider.ts +4 -3
  27. package/src/core/parser/schema/providers/prisma-schema-provider.ts +2 -1
  28. package/src/core/resolver/call-graph-tracer.ts +4 -3
  29. package/src/core/resolver/path-resolver.ts +57 -4
  30. package/src/core/resources/ignore.ts +35 -0
  31. package/src/core/resources/limits.ts +14 -0
  32. package/src/core/resources/source-files.ts +62 -0
  33. package/src/core/resources/work-queue.ts +74 -0
  34. package/src/core/session/session-manager.ts +19 -6
  35. package/src/core/telemetry/telemetry.ts +12 -1
  36. package/src/core/topology/environment-detector.ts +21 -11
  37. package/src/mcp/schemas.ts +381 -8
  38. package/src/mcp/server.ts +321 -535
  39. package/src/mcp/tools/boundary.ts +17 -0
  40. package/src/mcp/tools/check-boundary.ts +83 -11
  41. package/src/mcp/tools/domain.ts +40 -0
  42. package/src/mcp/tools/feature.ts +67 -0
  43. package/src/mcp/tools/get-domain-catalog.ts +9 -6
  44. package/src/mcp/tools/get-environment-topology.ts +2 -2
  45. package/src/mcp/tools/get-feature-context.ts +9 -12
  46. package/src/mcp/tools/get-symbol-impact.ts +5 -2
  47. package/src/mcp/tools/get-symbol.ts +2 -1
  48. package/src/mcp/tools/locate-symbol.ts +3 -3
  49. package/src/mcp/tools/register-domain.ts +25 -9
  50. package/src/mcp/tools/register-feature.ts +34 -17
  51. package/src/mcp/tools/slice.ts +15 -0
  52. package/src/mcp/tools/symbol.ts +82 -0
  53. package/src/mcp/workspace-manager.ts +159 -0
  54. package/src/mcp/workspace-watcher.ts +202 -0
  55. package/src/types/index.ts +1 -0
  56. package/src/cli/commands/ingest.ts +0 -29
  57. package/src/cli/commands/locate.ts +0 -61
  58. package/src/cli/commands/query.ts +0 -104
  59. package/src/cli/commands/slice.ts +0 -50
@@ -1,3 +1,4 @@
1
+ import { readOptionalSourceFile } from "../../resources/source-files.ts";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import type { SemanticSliceExtractor, VerticalSliceCandidate } from "./semantic-extractor.interface.ts";
@@ -14,7 +15,7 @@ export class NestJsSemanticExtractor implements SemanticSliceExtractor {
14
15
  const pkgPath = path.join(projectRoot, "package.json");
15
16
  if (fs.existsSync(pkgPath)) {
16
17
  try {
17
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
18
+ const pkg = JSON.parse(readOptionalSourceFile(pkgPath, "utf-8"));
18
19
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
19
20
  if (deps["@nestjs/core"]) return true;
20
21
  } catch {}
@@ -32,7 +33,7 @@ export class NestJsSemanticExtractor implements SemanticSliceExtractor {
32
33
 
33
34
  for (const file of controllerFiles) {
34
35
  const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
35
- const content = fs.readFileSync(file, "utf-8");
36
+ const content = readOptionalSourceFile(file, "utf-8");
36
37
 
37
38
  // Extract @Controller('prefix')
38
39
  const controllerMatch = /@Controller\s*\(\s*['"]?([^'")]*)['"]?\s*\)[\s\S]*?export\s+class\s+([A-Za-z0-9_]+)/.exec(
@@ -1,3 +1,4 @@
1
+ import { readOptionalSourceFile } from "../../../resources/source-files.ts";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import type {
@@ -35,7 +36,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
35
36
  for (const migFile of migrationFiles) {
36
37
  const fullPath = path.join(migrationsDir, migFile);
37
38
  try {
38
- const content = fs.readFileSync(fullPath, "utf-8");
39
+ const content = readOptionalSourceFile(fullPath, "utf-8");
39
40
  this.extractColumnsFromMigration(content, tableName, fieldsMap);
40
41
  } catch {
41
42
  // Skip unreadable files
@@ -66,7 +67,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
66
67
  : path.join(projectRoot, entityFilePath);
67
68
  if (fs.existsSync(fullEntityPath)) {
68
69
  try {
69
- const content = fs.readFileSync(fullEntityPath, "utf-8");
70
+ const content = readOptionalSourceFile(fullEntityPath, "utf-8");
70
71
  const tableMatch = /protected\s+\$table\s*=\s*['"]([^'"]+)['"]/.exec(content);
71
72
  if (tableMatch) {
72
73
  return tableMatch[1];
@@ -120,7 +121,7 @@ export class LaravelMigrationSchemaProvider implements StackSchemaProvider {
120
121
  if (matched.length === 0) {
121
122
  for (const f of files) {
122
123
  try {
123
- const content = fs.readFileSync(path.join(migrationsDir, f), "utf-8");
124
+ const content = readOptionalSourceFile(path.join(migrationsDir, f), "utf-8");
124
125
  if (
125
126
  content.includes(`Schema::create('${tableName}'`) ||
126
127
  content.includes(`Schema::create("${tableName}"`) ||
@@ -1,3 +1,4 @@
1
+ import { readOptionalSourceFile } from "../../../resources/source-files.ts";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import type {
@@ -38,7 +39,7 @@ export class PrismaSchemaProvider implements StackSchemaProvider {
38
39
  if (!schemaPath) return null;
39
40
 
40
41
  try {
41
- const content = fs.readFileSync(schemaPath, "utf-8");
42
+ const content = readOptionalSourceFile(schemaPath, "utf-8");
42
43
  const modelRegex = new RegExp(`model\\s+${entitySymbol}\\s*\\{([^}]+)\\}`, "m");
43
44
  const match = modelRegex.exec(content);
44
45
  if (!match) return null;
@@ -1,3 +1,4 @@
1
+ import { readOptionalSourceFile } from "../resources/source-files.ts";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { dirname, join, normalize, relative, resolve } from "node:path";
3
4
  import type {
@@ -86,7 +87,7 @@ export class CallGraphTracer {
86
87
  if (!existsSync(fullPath)) continue;
87
88
 
88
89
  try {
89
- const content = readFileSync(fullPath, "utf-8");
90
+ const content = readOptionalSourceFile(fullPath, "utf-8");
90
91
  const lines = content.split("\n");
91
92
 
92
93
  for (let i = 0; i < lines.length; i++) {
@@ -414,7 +415,7 @@ export class CallGraphTracer {
414
415
  fileSymbols[0];
415
416
 
416
417
  const symbolDisplay = mainSymbol
417
- ? `${mainSymbol.name}${mainSymbol.kind === "class" ? "::execute" : ""}`
418
+ ? mainSymbol.name
418
419
  : resolvedFile.path.split("/").pop()?.replace(/\.[^.]+$/, "") || "Module";
419
420
 
420
421
  // Prevent adding duplicate stages if already present, or add as refinement
@@ -428,7 +429,7 @@ export class CallGraphTracer {
428
429
  symbol: symbolDisplay,
429
430
  file: resolvedFile.path,
430
431
  line: mainSymbol?.line_start || 1,
431
- description: `${stage.toUpperCase()}: ${symbolDisplay}`,
432
+ description: `${stage.toUpperCase()} (dependency): ${symbolDisplay}`,
432
433
  });
433
434
  }
434
435
 
@@ -1,5 +1,5 @@
1
- import { existsSync, statSync } from "node:fs";
2
- import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
1
+ import { existsSync, statSync, realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
3
3
 
4
4
  export const PROJECT_ROOT_MARKERS = [
5
5
  "package.json",
@@ -26,6 +26,7 @@ export function canonicalizePath(targetPath: string): string {
26
26
  return targetPath;
27
27
  }
28
28
  let abs = normalize(resolve(targetPath));
29
+ try { if (existsSync(abs)) abs = realpathSync(abs); } catch { /* Keep lexical paths for missing or inaccessible files. */ }
29
30
  if (process.platform === "win32") {
30
31
  abs = abs.replace(/^[a-zA-Z]:/, (m) => m.toUpperCase());
31
32
  }
@@ -104,7 +105,7 @@ export function findProjectRoot(fromPath: string): string {
104
105
  // Filesystem traversal error fallback
105
106
  }
106
107
 
107
- return canonicalizePath(dirname(resolve(fromPath)));
108
+ return canonicalizePath(existsSync(fromPath) && statSync(fromPath).isDirectory() ? fromPath : dirname(resolve(fromPath)));
108
109
  }
109
110
 
110
111
  /**
@@ -117,7 +118,8 @@ export function findProjectRoot(fromPath: string): string {
117
118
  export function resolveWorkspaceRoot(pathHint?: string): string {
118
119
  if (pathHint && typeof pathHint === "string" && pathHint.trim()) {
119
120
  const trimmed = pathHint.trim().replace(/^['"]|['"]$/g, "");
120
- const candidate = findProjectRoot(trimmed);
121
+ const absHint = resolve(trimmed);
122
+ const candidate = existsSync(absHint) && statSync(absHint).isDirectory() ? canonicalizePath(absHint) : findProjectRoot(trimmed);
121
123
  if (candidate && existsSync(candidate) && !isAppHostDirectory(candidate)) {
122
124
  setLastKnownProjectRoot(candidate);
123
125
  return candidate;
@@ -145,3 +147,54 @@ export function resolveWorkspaceRoot(pathHint?: string): string {
145
147
  // Fallback if inside app host directory: keep looking in lastKnown or return cwd
146
148
  return canonicalizePath(cwd);
147
149
  }
150
+
151
+ /**
152
+ * Verifies that candidatePath is strictly contained within workspaceRoot.
153
+ * Resolves symlinks (realpath) if the path exists, and checks ancestor realpath if not yet created.
154
+ */
155
+ export function isPathContained(candidatePath: string, workspaceRoot: string): boolean {
156
+ if (!candidatePath || !workspaceRoot) return false;
157
+ try {
158
+ const canonicalWorkspace = canonicalizePath(workspaceRoot);
159
+ const resolvedTarget = isAbsolute(candidatePath) ? normalize(candidatePath) : resolve(canonicalWorkspace, candidatePath);
160
+
161
+ // Lexical containment
162
+ const relLexical = relative(canonicalWorkspace, resolvedTarget).replace(/\\/g, "/");
163
+ if (relLexical === ".." || relLexical.startsWith("../") || isAbsolute(relLexical)) {
164
+ return false;
165
+ }
166
+
167
+ // Realpath / symlink containment
168
+ if (existsSync(resolvedTarget)) {
169
+ const realTarget = realpathSync(resolvedTarget);
170
+ const relReal = relative(canonicalWorkspace, realTarget).replace(/\\/g, "/");
171
+ if (relReal === ".." || relReal.startsWith("../") || isAbsolute(relReal)) {
172
+ return false;
173
+ }
174
+ } else {
175
+ let cur = dirname(resolvedTarget);
176
+ while (!existsSync(cur)) {
177
+ const parent = dirname(cur);
178
+ if (parent === cur) break;
179
+ cur = parent;
180
+ }
181
+ if (existsSync(cur)) {
182
+ const realCur = realpathSync(cur);
183
+ const relCur = relative(canonicalWorkspace, realCur).replace(/\\/g, "/");
184
+ if (relCur === ".." || relCur.startsWith("../") || isAbsolute(relCur)) {
185
+ return false;
186
+ }
187
+ }
188
+ }
189
+
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ export function assertPathContained(candidatePath: string, workspaceRoot: string, contextDescription = "Path"): void {
197
+ if (!isPathContained(candidatePath, workspaceRoot)) {
198
+ throw new Error(`${contextDescription} '${candidatePath}' escapes workspace boundary '${workspaceRoot}'.`);
199
+ }
200
+ }
@@ -0,0 +1,35 @@
1
+ export function shouldIgnoreSourcePath(filePath: string, ignorePatterns: string[]): boolean {
2
+ if (filePath.replace(/\\/g, "/").split("/").some(segment => ["node_modules", "vendor", ".git", ".septum", ".strata", ".codegraph", "dist", "build", ".output", ".nuxt", ".next", ".cache", "coverage", ".turbo"].includes(segment))) return true;
3
+ const normPath = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
4
+ for (const pattern of ignorePatterns) {
5
+ const normPattern = pattern.replace(/\\/g, "/").replace(/^\.\//, "").trim();
6
+ if (!normPattern) continue;
7
+
8
+ if (normPattern === "*" || normPattern === "**") {
9
+ return true;
10
+ }
11
+
12
+ if (normPath === normPattern) return true;
13
+
14
+ if (normPattern.endsWith("/**")) {
15
+ const prefix = normPattern.slice(0, -3);
16
+ if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
17
+ } else if (normPattern.endsWith("/*")) {
18
+ const prefix = normPattern.slice(0, -2);
19
+ if (normPath === prefix || normPath.startsWith(prefix + "/")) return true;
20
+ } else if (normPath.startsWith(normPattern + "/")) {
21
+ return true;
22
+ }
23
+
24
+ if (normPattern.startsWith("*.") && normPath.endsWith(normPattern.slice(1))) {
25
+ return true;
26
+ }
27
+
28
+ const segments = normPath.split("/");
29
+ if (segments.includes(normPattern)) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
@@ -0,0 +1,14 @@
1
+ export const RESOURCE_LIMITS = Object.freeze({
2
+ workspaces: 4,
3
+ workspaceIdleMs: 10 * 60_000,
4
+ watches: 2048,
5
+ pendingPaths: 1000,
6
+ debounceMs: 300,
7
+ pollMs: 5000,
8
+ verifyMs: 60_000,
9
+ sourceBytes: 2 * 1024 * 1024,
10
+ batchFiles: 50,
11
+ batchBytes: 1024 * 1024,
12
+ maxQueuePending: 50,
13
+ shutdownMs: 5000,
14
+ });
@@ -0,0 +1,62 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
3
+ import { relative } from 'node:path';
4
+ import { computeContentHash } from "../ingestion/hasher.ts";
5
+ import { RESOURCE_LIMITS } from './limits.ts';
6
+
7
+ export interface SkippedSourceFile { path: string; reason: 'file_too_large' }
8
+ export class SourceFileLimitError extends Error {
9
+ constructor(public readonly filePath: string) { super(`Source file exceeds ${RESOURCE_LIMITS.sourceBytes} bytes: ${filePath}`); }
10
+ }
11
+ export interface SourceReadFingerprint { path: string; hash: string; size: number; mtime: number }
12
+ const reads = new AsyncLocalStorage<{skipped: Map<string, SkippedSourceFile>; inputs: Map<string, SourceReadFingerprint>}>();
13
+
14
+ // Read at most the admission limit, even if the file grows after stat.
15
+ export function readSourceFile(filePath: string, _encoding?: string): string {
16
+ const fd = openSync(filePath, 'r');
17
+ try {
18
+ const size = fstatSync(fd).size;
19
+ if (size > RESOURCE_LIMITS.sourceBytes) throw new SourceFileLimitError(filePath);
20
+ let buffer = Buffer.allocUnsafe(Math.min(Math.max(size + 1, 4096), RESOURCE_LIMITS.sourceBytes + 1));
21
+ let count = 0;
22
+ while (true) {
23
+ const read = readSync(fd, buffer, count, buffer.length - count, null);
24
+ if (!read) return buffer.toString('utf8', 0, count);
25
+ count += read;
26
+ if (count > RESOURCE_LIMITS.sourceBytes) throw new SourceFileLimitError(filePath);
27
+ if (count === buffer.length) {
28
+ const expanded = Buffer.allocUnsafe(Math.min(buffer.length * 2, RESOURCE_LIMITS.sourceBytes + 1));
29
+ buffer.copy(expanded, 0, 0, count);
30
+ buffer = expanded;
31
+ }
32
+ }
33
+ } finally { closeSync(fd); }
34
+ }
35
+
36
+ export function readOptionalSourceFile(filePath: string, encoding?: string): string {
37
+ try {
38
+ const content = readSourceFile(filePath, encoding);
39
+ const collector = reads.getStore();
40
+ if (collector) {
41
+ const stat = statSync(filePath);
42
+ collector.inputs.set(filePath, {path:filePath,hash:computeContentHash(content),size:stat.size,mtime:stat.mtimeMs});
43
+ }
44
+ return content;
45
+ }
46
+ catch (error) {
47
+ if (!(error instanceof SourceFileLimitError)) throw error;
48
+ const collector = reads.getStore();
49
+ if (!collector) throw error;
50
+ collector.skipped.set(filePath, { path: filePath, reason: 'file_too_large' });
51
+ const stat = statSync(filePath);
52
+ collector.inputs.set(filePath, {path:filePath,hash:'file_too_large',size:stat.size,mtime:stat.mtimeMs});
53
+ return '';
54
+ }
55
+ }
56
+
57
+ export async function collectSourceSkips<T>(root: string, read: () => Promise<T>): Promise<{ value: T; skipped: SkippedSourceFile[]; inputs: SourceReadFingerprint[] }> {
58
+ const collector = {skipped: new Map<string, SkippedSourceFile>(), inputs: new Map<string, SourceReadFingerprint>()};
59
+ const value = await reads.run(collector, read);
60
+ const rel = (file: {path:string}) => relative(root, file.path).replace(/\\/g, '/');
61
+ return {value, skipped:[...collector.skipped.values()].map(file => ({...file,path:rel(file)})), inputs:[...collector.inputs.values()].map(file => ({...file,path:rel(file)}))};
62
+ }
@@ -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,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import type { SeptumRepository } from "../database/repository.ts";
4
5
  import type { ActiveFeatureSession } from "../../types/index.ts";
5
6
  import { SeptumDatabase } from "../database/client.ts";
6
7
 
@@ -11,7 +12,8 @@ export class SessionManager {
11
12
  return process.env.SEPTUM_SESSION_ID || `process:${process.pid}`;
12
13
  }
13
14
 
14
- private static mutate<T>(workspaceRoot: string, fn: () => T): T {
15
+ private static mutate<T>(workspaceRoot: string, fn: () => T, repo?: SeptumRepository): T {
16
+ if (repo) return repo.runInTransaction(fn, true);
15
17
  const db = new SeptumDatabase(join(workspaceRoot, ".septum/septum.db"));
16
18
  try {
17
19
  // Serialize ownership checks with writes across CLI and MCP processes.
@@ -24,7 +26,8 @@ export class SessionManager {
24
26
  public static saveActiveSession(
25
27
  workspaceRoot: string,
26
28
  session: ActiveFeatureSession,
27
- ownerId: string = SessionManager.getOwnerId()
29
+ ownerId: string = SessionManager.getOwnerId(),
30
+ persistence?: { repo: SeptumRepository; save: () => void }
28
31
  ): void {
29
32
  SessionManager.mutate(workspaceRoot, () => {
30
33
  const current = SessionManager.getActiveSession(workspaceRoot);
@@ -37,18 +40,28 @@ export class SessionManager {
37
40
  const temporaryPath = `${sessionPath}.${randomUUID()}.tmp`;
38
41
  try {
39
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();
40
45
  renameSync(temporaryPath, sessionPath);
41
46
  } finally {
42
47
  rmSync(temporaryPath, { force: true });
43
48
  }
44
- });
49
+ }, persistence?.repo);
45
50
  }
46
51
 
47
52
  public static getActiveSession(workspaceRoot: string): ActiveFeatureSession | null {
48
53
  const sessionPath = join(workspaceRoot, SessionManager.SESSION_FILE);
49
54
  if (!existsSync(sessionPath)) return null;
50
- const parsed = JSON.parse(readFileSync(sessionPath, "utf-8")) as ActiveFeatureSession;
51
- if (parsed && typeof parsed.feature_key === "string" && typeof parsed.domain === "string" && Array.isArray(parsed.touchpoints)) return parsed;
55
+ let parsed: any;
56
+ try {
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})'.`);
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;
52
65
  throw new Error(`Invalid active feature session in '${sessionPath}'. Release it explicitly with 'septum feature clear --force'.`);
53
66
  }
54
67
 
@@ -65,7 +78,7 @@ export class SessionManager {
65
78
  const current = SessionManager.getActiveSession(workspaceRoot);
66
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.");
67
80
  }
68
- rmSync(sessionPath);
81
+ rmSync(sessionPath, { force: true });
69
82
  return true;
70
83
  });
71
84
  }
@@ -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
  }