sentinelayer-cli 0.46.5 → 0.48.0

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 (133) hide show
  1. package/README.md +47 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +32 -1
  26. package/src/auth/session-store.js +37 -0
  27. package/src/board/evidence-resolvers.js +3 -4
  28. package/src/cli.js +23 -3
  29. package/src/commands/ai/identity-lifecycle.js +30 -26
  30. package/src/commands/ai/provision-governance.js +32 -24
  31. package/src/commands/ai/shared.js +6 -4
  32. package/src/commands/audit.js +14 -12
  33. package/src/commands/board.js +2 -2
  34. package/src/commands/chat.js +8 -6
  35. package/src/commands/judge.js +125 -0
  36. package/src/commands/legacy-args.js +1 -0
  37. package/src/commands/mcp.js +45 -52
  38. package/src/commands/omargate.js +2 -1
  39. package/src/commands/plugin.js +11 -3
  40. package/src/commands/review.js +9 -4
  41. package/src/commands/scan.js +7 -7
  42. package/src/commands/session.js +516 -30
  43. package/src/commands/skills.js +45 -0
  44. package/src/commands/watch.js +9 -7
  45. package/src/config/output-filesystem.js +156 -0
  46. package/src/config/schema.js +4 -2
  47. package/src/config/service.js +40 -4
  48. package/src/cost/history.js +15 -9
  49. package/src/daemon/artifact-lineage.js +15 -12
  50. package/src/daemon/assignment-ledger.js +31 -25
  51. package/src/daemon/ast-drift.js +7 -3
  52. package/src/daemon/budget-governor.js +26 -20
  53. package/src/daemon/error-worker.js +28 -22
  54. package/src/daemon/fix-cycle.js +5 -4
  55. package/src/daemon/hybrid-mapper.js +19 -16
  56. package/src/daemon/ingest-refresh.js +4 -0
  57. package/src/daemon/jira-lifecycle.js +21 -17
  58. package/src/daemon/operator-control.js +18 -15
  59. package/src/daemon/reliability-lane.js +18 -15
  60. package/src/daemon/scope-engine.js +12 -8
  61. package/src/daemon/watchdog.js +15 -12
  62. package/src/engram/build-state.js +24 -3
  63. package/src/engram/capability-index.js +367 -0
  64. package/src/engram/compile-graph.js +1063 -0
  65. package/src/engram/contracts/action-receipt.js +82 -0
  66. package/src/engram/contracts/capability-index.js +259 -0
  67. package/src/engram/contracts/capability.js +377 -0
  68. package/src/engram/contracts/content-hash.js +8 -0
  69. package/src/engram/contracts/evidence.js +258 -0
  70. package/src/engram/contracts/graph.js +594 -0
  71. package/src/engram/contracts/identity.js +8 -0
  72. package/src/engram/contracts/index.js +149 -0
  73. package/src/engram/contracts/media-asset.js +85 -0
  74. package/src/engram/contracts/money.js +249 -0
  75. package/src/engram/contracts/source-snapshot.js +57 -0
  76. package/src/engram/contracts/task-context-pack.js +114 -0
  77. package/src/engram/contracts/temporal-segment.js +72 -0
  78. package/src/engram/contracts/versions.js +96 -0
  79. package/src/engram/extract-html.js +692 -0
  80. package/src/engram/graph-store.js +877 -0
  81. package/src/engram/index.js +98 -14
  82. package/src/engram/ingest-site.js +132 -0
  83. package/src/engram/live-state.js +1180 -0
  84. package/src/engram/namespace.js +34 -1
  85. package/src/engram/retrieve-site.js +753 -0
  86. package/src/engram/safe-reader.js +597 -0
  87. package/src/engram/source-intake.js +251 -0
  88. package/src/engram/store.js +227 -9
  89. package/src/ingest/engine.js +21 -28
  90. package/src/judge/verdict.js +305 -0
  91. package/src/legacy-cli.js +96 -19
  92. package/src/mcp/bridge-inputs.js +25 -4
  93. package/src/mcp/cli-command-tools.js +70 -0
  94. package/src/mcp/registry.js +17 -13
  95. package/src/mcp/session-stdio-server.js +297 -19
  96. package/src/memory/blackboard.js +5 -4
  97. package/src/net/gh-api-path.js +35 -9
  98. package/src/net/test-egress-guard.js +16 -4
  99. package/src/plugin/manifest.js +12 -8
  100. package/src/review/ai-review.js +110 -10
  101. package/src/review/investor-dd-devtestbot.js +17 -6
  102. package/src/review/investor-dd-orchestrator.js +21 -13
  103. package/src/review/local-review.js +26 -16
  104. package/src/review/omargate-cache.js +5 -5
  105. package/src/review/omargate-orchestrator.js +89 -17
  106. package/src/review/replay.js +9 -4
  107. package/src/review/report.js +22 -13
  108. package/src/review/typesafe-severity.js +201 -0
  109. package/src/session/admission-auth.js +34 -7
  110. package/src/session/admission.js +110 -15
  111. package/src/session/attachment-summary.js +77 -0
  112. package/src/session/edit-summary.js +19 -0
  113. package/src/session/invitations.js +65 -1
  114. package/src/session/mentions.js +17 -9
  115. package/src/session/message-edits.js +5 -4
  116. package/src/session/preview.js +10 -2
  117. package/src/session/recap.js +3 -2
  118. package/src/session/room-files.js +519 -0
  119. package/src/session/route-classes.js +22 -8
  120. package/src/session/setup-guides.js +4 -0
  121. package/src/session/standup.js +192 -0
  122. package/src/session/sync.js +229 -10
  123. package/src/session/tickets.js +41 -0
  124. package/src/session/transcript.js +2 -1
  125. package/src/session/wake/resolve-target.js +7 -3
  126. package/src/skills/listener-runtime.cjs +92 -0
  127. package/src/skills/service.js +207 -0
  128. package/src/swarm/factory.js +5 -5
  129. package/src/swarm/pentest.js +9 -9
  130. package/src/swarm/report.js +4 -4
  131. package/src/swarm/runtime.js +15 -12
  132. package/src/swarm/scenario-dsl.js +7 -5
  133. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,251 @@
1
+ /**
2
+ * EW04 source-byte intake. The caller owns the source registry, origin allowlist,
3
+ * authorization decision and byte reader. In particular, `read` must be a
4
+ * trusted connector (for example, a repository blob reader); this module does
5
+ * not turn a URL from page content into a network request.
6
+ *
7
+ * Capture is deliberately before extraction: the returned bytes and digest are
8
+ * the evidence an extractor must cite. A schema-valid snapshot alone does not
9
+ * make extracted assertions authoritative.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { isIP } from "node:net";
13
+ import { sourceSnapshotSchema } from "./contracts/source-snapshot.js";
14
+
15
+ export const MAX_SOURCE_BYTES = 1024 * 1024;
16
+ const DEFAULT_SOURCE_BYTES = 256 * 1024;
17
+ const TEXT_TYPES = new Set(["text/plain", "text/html", "application/json"]);
18
+ // Public guides may name credential variables. Reject credential-shaped values
19
+ // without treating a variable name such as AIDENID_API_KEY as a leaked key.
20
+ const CREDENTIAL_VALUES = [
21
+ /(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,})/,
22
+ /(?:AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]+PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35})/,
23
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/,
24
+ /authorization\s*:\s*bearer\s+[A-Za-z0-9._~+/-]{24,}/i,
25
+ // The key may be quoted, as in JSON ("api_key": "...") or YAML ('token': ...).
26
+ /(?:api[_-]?key|secret|token|password)["']?\s*[:=]\s*["']?[A-Za-z0-9._~+/-]{24,}/i,
27
+ ];
28
+
29
+ export class SourceIntakeError extends Error {
30
+ constructor(code) {
31
+ super(code);
32
+ this.name = "SourceIntakeError";
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ const OWN_ERRORS = new WeakSet();
38
+ const CODES = new Set([
39
+ "invalid_source_limit", "source_access_denied", "source_not_approved",
40
+ "source_rejected", "source_too_large", "source_unavailable",
41
+ ]);
42
+
43
+ function reject(code) {
44
+ // Frozen: a caller that kept an error from an earlier call cannot rewrite its message or code and replay it.
45
+ const error = Object.freeze(new SourceIntakeError(code));
46
+ OWN_ERRORS.add(error);
47
+ throw error;
48
+ }
49
+
50
+ function approvedUrl(raw, allowedOrigins) {
51
+ if (typeof raw !== "string" || !(allowedOrigins instanceof Set)) reject("source_not_approved");
52
+ let url;
53
+ try {
54
+ url = new URL(raw);
55
+ } catch {
56
+ reject("source_not_approved");
57
+ }
58
+ if (
59
+ url.href !== raw || url.protocol !== "https:" || url.username || url.password ||
60
+ url.search || url.hash || isIP(url.hostname.replace(/^\[|\]$/g, "")) || !allowedOrigins.has(url.origin)
61
+ ) reject("source_not_approved");
62
+ return url.href;
63
+ }
64
+
65
+ function unchangedApprovedSource(current, approved, allowedOrigins) {
66
+ try {
67
+ return current?.tenantScope === approved.tenantScope &&
68
+ current.visibility === approved.visibility &&
69
+ current.version === approved.version &&
70
+ current.sourceType === approved.sourceType &&
71
+ current.mediaType === approved.mediaType &&
72
+ approvedUrl(current.url, allowedOrigins) === approved.url;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ // The TypedArray intrinsic getters: a reader-supplied view can shadow .byteLength/.byteOffset/.buffer with
79
+ // own properties, so the cap and the copy read the real values through these, never through the object.
80
+ const TYPED_ARRAY = Object.getPrototypeOf(Uint8Array.prototype);
81
+ const intrinsic = (name) => Object.getOwnPropertyDescriptor(TYPED_ARRAY, name).get;
82
+ const VIEW_LENGTH = intrinsic("byteLength");
83
+ const VIEW_OFFSET = intrinsic("byteOffset");
84
+ const VIEW_BUFFER = intrinsic("buffer");
85
+ // A stream may only yield this many chunks in total, so endless empty chunks cannot hold a capture open.
86
+ export const MAX_SOURCE_CHUNKS = 4096;
87
+
88
+ function copyView(view) {
89
+ const length = Reflect.apply(VIEW_LENGTH, view, []);
90
+ return { length, copy: () => Buffer.from(new Uint8Array(Reflect.apply(VIEW_BUFFER, view, []), Reflect.apply(VIEW_OFFSET, view, []), length)) };
91
+ }
92
+
93
+ async function boundedBytes(body, limit) {
94
+ if (body instanceof Uint8Array) {
95
+ const view = copyView(body);
96
+ if (view.length > limit) reject("source_too_large");
97
+ return view.copy();
98
+ }
99
+ if (!body || typeof body[Symbol.asyncIterator] !== "function") reject("source_rejected");
100
+ const chunks = [];
101
+ let length = 0;
102
+ let count = 0;
103
+ for await (const chunk of body) {
104
+ if (!(chunk instanceof Uint8Array)) reject("source_rejected");
105
+ count += 1;
106
+ if (count > MAX_SOURCE_CHUNKS) reject("source_too_large");
107
+ const view = copyView(chunk);
108
+ if (view.length === 0) continue; // never retain empty chunks
109
+ length += view.length;
110
+ if (length > limit) reject("source_too_large");
111
+ chunks.push(view.copy());
112
+ }
113
+ return Buffer.concat(chunks, length);
114
+ }
115
+
116
+ /**
117
+ * Capture a registered source into an EW02 SourceSnapshot and bounded bytes.
118
+ * `authorize` must consult the real tenant policy and is called again before
119
+ * returning captured bytes; `sources` and
120
+ * `allowedOrigins` must be maintained by the site owner, not by page content.
121
+ * `read` receives the approved URL and immutable source version, and must not
122
+ * follow redirects. It must report the actual version of the returned bytes,
123
+ * not echo the requested version; only the trusted connector can make that
124
+ * assertion. Its result must also report the final URL, status and media type.
125
+ */
126
+ export async function captureSourceBytes({
127
+ sourceId,
128
+ tenantScope,
129
+ sources,
130
+ allowedOrigins,
131
+ authorize,
132
+ read,
133
+ capturedAt = () => new Date().toISOString(),
134
+ maxBytes = DEFAULT_SOURCE_BYTES,
135
+ } = {}) {
136
+ if (
137
+ typeof sourceId !== "string" || !sourceId ||
138
+ typeof tenantScope !== "string" || !tenantScope ||
139
+ typeof authorize !== "function"
140
+ ) reject("source_access_denied");
141
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_SOURCE_BYTES) {
142
+ reject("invalid_source_limit");
143
+ }
144
+
145
+ // No source lookup or reader invocation until the tenant policy grants access.
146
+ let permitted = false;
147
+ try {
148
+ permitted = await authorize({ tenantScope, sourceId });
149
+ } catch {
150
+ reject("source_access_denied");
151
+ }
152
+ if (permitted !== true) reject("source_access_denied");
153
+
154
+ let approved;
155
+ try {
156
+ const source = sources instanceof Map ? sources.get(sourceId) : undefined;
157
+ approved = source && Object.freeze({
158
+ tenantScope: source.tenantScope,
159
+ visibility: source.visibility,
160
+ url: source.url,
161
+ version: source.version,
162
+ sourceType: source.sourceType,
163
+ mediaType: source.mediaType,
164
+ });
165
+ } catch {
166
+ reject("source_not_approved");
167
+ }
168
+ if (!approved || approved.tenantScope !== tenantScope || approved.visibility !== "public") {
169
+ reject("source_not_approved");
170
+ }
171
+ const url = approvedUrl(approved.url, allowedOrigins);
172
+ if (typeof approved.version !== "string" || !approved.version ||
173
+ (approved.sourceType !== "api" && approved.sourceType !== "ingest" && approved.sourceType !== "import") ||
174
+ !TEXT_TYPES.has(approved.mediaType) ||
175
+ typeof read !== "function") reject("source_not_approved");
176
+
177
+ let response;
178
+ try {
179
+ response = await read({ sourceId, tenantScope, url, version: approved.version, maxBytes });
180
+ } catch {
181
+ reject("source_unavailable");
182
+ }
183
+ let declaredLength;
184
+ try {
185
+ declaredLength = response?.contentLength;
186
+ if (
187
+ response?.status !== 200 || response.url !== url || response.version !== approved.version ||
188
+ typeof response.mediaType !== "string" ||
189
+ response.mediaType.split(";", 1)[0].trim().toLowerCase() !== approved.mediaType.toLowerCase() ||
190
+ (declaredLength !== undefined &&
191
+ (!Number.isSafeInteger(declaredLength) || declaredLength < 0 || declaredLength > maxBytes))
192
+ ) reject("source_rejected");
193
+ } catch {
194
+ reject("source_rejected");
195
+ }
196
+
197
+ let bytes;
198
+ try {
199
+ bytes = await boundedBytes(response.body, maxBytes);
200
+ } catch (error) {
201
+ // Never rethrow the caught object: a body stream can throw anything, including one of our own earlier errors.
202
+ // Re-raise only a fresh error carrying an allow-listed code.
203
+ if (OWN_ERRORS.has(error) && CODES.has(error.code)) reject(error.code);
204
+ reject("source_unavailable");
205
+ }
206
+ if (declaredLength !== undefined && declaredLength !== bytes.length) {
207
+ reject("source_rejected");
208
+ }
209
+ let text;
210
+ try {
211
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
212
+ } catch {
213
+ reject("source_rejected");
214
+ }
215
+ // This first slice accepts public text only. A private/authenticated connector
216
+ // needs its own grant and content policy; a token in public source is denied.
217
+ if (CREDENTIAL_VALUES.some((pattern) => pattern.test(text))) reject("source_rejected");
218
+
219
+ let snapshot;
220
+ try {
221
+ snapshot = sourceSnapshotSchema.parse({
222
+ sourceId,
223
+ sourceType: approved.sourceType,
224
+ version: approved.version,
225
+ capturedAt: capturedAt(),
226
+ tenantScope,
227
+ contentHash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
228
+ metadata: { origin: new URL(url).origin, mediaType: approved.mediaType, byteLength: bytes.length },
229
+ });
230
+ } catch {
231
+ reject("source_rejected");
232
+ }
233
+
234
+ // A read may outlive owner policy or source-version changes. Never return
235
+ // bytes with a snapshot from a different approved record or revoked grant.
236
+ let stillPermitted = false;
237
+ try {
238
+ stillPermitted = await authorize({ tenantScope, sourceId });
239
+ } catch {
240
+ reject("source_access_denied");
241
+ }
242
+ if (stillPermitted !== true) reject("source_access_denied");
243
+ let current;
244
+ try {
245
+ current = sources.get(sourceId);
246
+ } catch {
247
+ reject("source_not_approved");
248
+ }
249
+ if (!unchangedApprovedSource(current, approved, allowedOrigins)) reject("source_not_approved");
250
+ return { snapshot, bytes };
251
+ }
@@ -13,22 +13,218 @@
13
13
  *
14
14
  * Detachability: imports ONLY the §1 engine core (observations + text) and
15
15
  * node builtins. No SentinelLayer session/mcp/auth imports.
16
+ *
17
+ * --- On-disk layout (since 2a885ea8) ---
18
+ *
19
+ * Each namespace lives under `.sentinelayer/engram/ns-<sha256hex(raw)>/`.
20
+ * The sha256-hex directory name makes every namespace's storage independent:
21
+ * distinct namespaces never share a directory, even on case-insensitive
22
+ * filesystems. A `namespace.json` sidecar is written on first write and
23
+ * verified on every subsequent access as defence-in-depth (the stored item
24
+ * records carry no namespace field, so ownership of a legacy lossy directory
25
+ * can never be proven from contents alone).
26
+ *
27
+ * Before 2a885ea8, namespaceDir mapped a namespace to a lossy sanitized name
28
+ * (`raw.replace(/[^A-Za-z0-9._-]/g, "_")`), which let distinct namespaces
29
+ * collide. The `legacyLossyDirs` option controls what happens when the hashed
30
+ * directory is absent but the old lossy directory exists:
31
+ * - "refuse" (default): throw a typed error, never read or write.
32
+ * - "first-claim": atomically rename the legacy dir to the hashed dir, write
33
+ * namespace.json with a legacyFrom marker, and proceed. Two concurrent
34
+ * claims yield exactly one owner (rename is the arbiter).
35
+ *
36
+ * A `namespace.json` sidecar is written on first write and verified on every
37
+ * access. A sidecar whose `raw` names a different namespace causes a
38
+ * `NamespaceOwnershipError`.
16
39
  */
17
40
 
18
41
  import fsp from "node:fs/promises";
42
+ import crypto from "node:crypto";
19
43
  import path from "node:path";
20
44
 
21
45
  import { buildObservationsFromItems } from "./observations.js";
22
46
  import { contentHash, normalizeString } from "../session/recall/text.js";
23
47
 
48
+ function sha256hex(str) {
49
+ return crypto.createHash("sha256").update(str, "utf-8").digest("hex");
50
+ }
51
+
52
+ const ENGRAM_ROOT = ".sentinelayer";
53
+ const ENGRAM_DIR = "engram";
54
+
55
+ export class LegacyStorageError extends Error {
56
+ constructor(message) {
57
+ super(message);
58
+ this.name = "LegacyStorageError";
59
+ }
60
+ }
61
+
62
+ export class NamespaceOwnershipError extends Error {
63
+ constructor(message) {
64
+ super(message);
65
+ this.name = "NamespaceOwnershipError";
66
+ }
67
+ }
68
+
69
+ /**
70
+ * The old (pre-2a885ea8) lossy directory name for a namespace.
71
+ * Exported for legacy migration logic.
72
+ */
73
+ export function legacyNamespaceDirName(namespace) {
74
+ return namespace.raw.replace(/[^A-Za-z0-9._-]/g, "_");
75
+ }
76
+
24
77
  /**
25
- * Where a namespace's files live. EXPORTED so siblings (e.g. build-state.js) resolve
26
- * the same directory rather than re-deriving the convention -- a second copy of this
27
- * sanitization would drift and silently point at a different namespace.
78
+ * Where a namespace's files live. Each namespace gets its own sha256-hex
79
+ * directory so distinct namespaces never collide, even on case-insensitive
80
+ * filesystems. A `namespace.json` sidecar is written on first write and
81
+ * verified on every subsequent access.
82
+ *
83
+ * EXPORTED so siblings (e.g. build-state.js) resolve the same directory rather
84
+ * than re-deriving the convention -- a second copy would drift and silently
85
+ * point at a different namespace.
28
86
  */
29
87
  export function namespaceDir(root, namespace) {
30
- const safe = namespace.raw.replace(/[^A-Za-z0-9._-]/g, "_");
31
- return path.join(root, ".sentinelayer", "engram", safe);
88
+ return path.join(root, ENGRAM_ROOT, ENGRAM_DIR, `ns-${sha256hex(namespace.raw)}`);
89
+ }
90
+
91
+ /** The old lossy directory path (pre-2a885ea8). */
92
+ function legacyDir(root, namespace) {
93
+ return path.join(root, ENGRAM_ROOT, ENGRAM_DIR, legacyNamespaceDirName(namespace));
94
+ }
95
+
96
+ const SIDECAR = "namespace.json";
97
+
98
+ /**
99
+ * Verify the directory's namespace.json sidecar matches the expected namespace.
100
+ * NEVER creates the directory or sidecar. Throws NamespaceOwnershipError if a
101
+ * different namespace already owns the directory.
102
+ */
103
+ export async function verifyNamespace(dir, namespace) {
104
+ const sidecar = path.join(dir, SIDECAR);
105
+ let raw;
106
+ try {
107
+ raw = await fsp.readFile(sidecar, "utf-8");
108
+ } catch (e) {
109
+ if (e && e.code === "ENOENT") {
110
+ throw new NamespaceOwnershipError(
111
+ `namespace dir has no sidecar: directory ${dir} is not a valid engram namespace`,
112
+ );
113
+ }
114
+ throw new Error(`corrupt namespace sidecar in ${dir}: ${e.message}`);
115
+ }
116
+ let existing;
117
+ try {
118
+ existing = JSON.parse(raw);
119
+ } catch (e) {
120
+ throw new Error(`corrupt namespace sidecar in ${dir}: ${e.message}`);
121
+ }
122
+ if (existing && typeof existing === "object" && existing !== null) {
123
+ if (existing.raw !== namespace.raw) {
124
+ throw new NamespaceOwnershipError(
125
+ `namespace dir collision: directory ${dir} already owned by "${existing.raw}" (tried to access as "${namespace.raw}")`,
126
+ );
127
+ }
128
+ return;
129
+ }
130
+ throw new Error(`corrupt namespace sidecar in ${dir}: expected object, got ${typeof existing}`);
131
+ }
132
+
133
+ /**
134
+ * Ensure the directory's namespace.json sidecar matches the expected namespace,
135
+ * writing it on first write with flag "wx" so a concurrent create is detected.
136
+ * Throws NamespaceOwnershipError if a different namespace already owns the
137
+ * directory.
138
+ */
139
+ export async function ensureNamespace(dir, namespace, legacyFrom) {
140
+ await fsp.mkdir(dir, { recursive: true });
141
+ const sidecar = path.join(dir, SIDECAR);
142
+ try {
143
+ await fsp.writeFile(
144
+ sidecar,
145
+ JSON.stringify({ raw: namespace.raw, ...(legacyFrom ? { legacyFrom } : {}) }, null, 2) + "\n",
146
+ { flag: "wx" },
147
+ );
148
+ return;
149
+ } catch (e) {
150
+ if (e && e.code === "EEXIST") {
151
+ // Sidecar already exists — verify it matches.
152
+ return verifyNamespace(dir, namespace);
153
+ }
154
+ throw e;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Resolve the namespace directory, handling legacy migration if configured.
160
+ * Returns the directory path to use.
161
+ */
162
+ export async function resolveNamespaceDir(root, namespace, legacyLossyDirs) {
163
+ const dir = namespaceDir(root, namespace);
164
+ try {
165
+ await fsp.access(dir);
166
+ return dir;
167
+ } catch {
168
+ // Hashed dir doesn't exist — check for legacy
169
+ }
170
+
171
+ const oldDir = legacyDir(root, namespace);
172
+ let legacyExists = false;
173
+ try {
174
+ await fsp.access(oldDir);
175
+ legacyExists = true;
176
+ } catch {
177
+ // No legacy dir either
178
+ }
179
+
180
+ if (!legacyExists) {
181
+ return dir;
182
+ }
183
+
184
+ if (legacyLossyDirs === "first-claim") {
185
+ // Atomically claim the legacy directory via rename
186
+ try {
187
+ await fsp.rename(oldDir, dir);
188
+ // Write sidecar with legacy marker
189
+ await ensureNamespace(dir, namespace, legacyNamespaceDirName(namespace));
190
+ // Notify once per claim
191
+ console.error(
192
+ `engram: claimed legacy storage for "${namespace.raw}" from lossy dir "${legacyNamespaceDirName(namespace)}"`,
193
+ );
194
+ return dir;
195
+ } catch (e) {
196
+ if (e && e.code === "ENOENT") {
197
+ // Lost the race — another caller claimed it or it was deleted.
198
+ // Try the hashed dir now (the winner may have renamed to it).
199
+ try {
200
+ await fsp.access(dir);
201
+ return dir;
202
+ } catch {
203
+ // Neither exists — start fresh
204
+ return dir;
205
+ }
206
+ }
207
+ // NS6: on EEXIST/ENOTEMPTY/EPERM, the hashed dir appeared concurrently.
208
+ // Re-check: if its sidecar is ours, use it; otherwise rethrow.
209
+ if (e && (e.code === "EEXIST" || e.code === "ENOTEMPTY" || e.code === "EPERM")) {
210
+ try {
211
+ await verifyNamespace(dir, namespace);
212
+ // Sidecar is ours — use the hashed dir, leave legacy untouched
213
+ return dir;
214
+ } catch {
215
+ // Sidecar names another namespace — rethrow original
216
+ }
217
+ }
218
+ throw e;
219
+ }
220
+ }
221
+
222
+ // "refuse" — the default
223
+ // Note: the error message intentionally omits directory paths (public repo).
224
+ throw new LegacyStorageError(
225
+ `ambiguous legacy storage for namespace "${namespace.raw}". The old lossy directory still exists. ` +
226
+ `Run with legacyLossyDirs: "first-claim" to migrate, or delete the legacy dir manually.`,
227
+ );
32
228
  }
33
229
 
34
230
  /** Stable, content-addressed item id (idempotent write — §3 dedup for free). */
@@ -66,17 +262,20 @@ async function readItemsFile(file) {
66
262
  * @param {string} options.storeRoot Root dir for the append-log store.
67
263
  * @param {Record<string, (id:string)=>Promise<object[]>>} [options.adapters]
68
264
  * Map of namespace-kind -> async source adapter returning observations.
265
+ * @param {"refuse"|"first-claim"} [options.legacyLossyDirs="refuse"]
266
+ * Policy for pre-2a885ea8 lossy directories.
69
267
  * @returns {{
70
268
  * appendItems: (namespace:object, items:object[]) => Promise<{written:number, deduped:number, namespace:string}>,
71
269
  * readObservations: (namespace:object) => Promise<object[]>,
72
270
  * }}
73
271
  */
74
- export function createStore({ storeRoot, adapters = {} } = {}) {
272
+ export function createStore({ storeRoot, adapters = {}, legacyLossyDirs = "refuse" } = {}) {
75
273
  const root = path.resolve(String(storeRoot || "."));
76
274
 
77
275
  return {
78
276
  async appendItems(namespace, items = []) {
79
- const dir = namespaceDir(root, namespace);
277
+ const dir = await resolveNamespaceDir(root, namespace, legacyLossyDirs);
278
+ await ensureNamespace(dir, namespace);
80
279
  const file = path.join(dir, "items.ndjson");
81
280
  const existing = await readItemsFile(file);
82
281
  const seen = new Set(existing.map((it) => itemId(it)));
@@ -105,8 +304,27 @@ export function createStore({ storeRoot, adapters = {} } = {}) {
105
304
  const adapterObservations = adapter
106
305
  ? await Promise.resolve(adapter(namespace.id)).catch(() => [])
107
306
  : [];
108
- const file = path.join(namespaceDir(root, namespace), "items.ndjson");
109
- const items = await readItemsFile(file);
307
+ const dir = await resolveNamespaceDir(root, namespace, legacyLossyDirs);
308
+ // NS1: reads never create. If the hashed dir is absent, return empty.
309
+ let dirExists = false;
310
+ try {
311
+ await fsp.access(dir);
312
+ dirExists = true;
313
+ } catch {
314
+ // dir absent — no data
315
+ }
316
+
317
+ let items = [];
318
+ if (dirExists) {
319
+ // A first-claim rename on read is allowed (the explicit migration policy
320
+ // in resolveNamespaceDir handles it). If the dir exists after
321
+ // resolution, verify its sidecar without creating anything.
322
+ await verifyNamespace(dir, namespace);
323
+ const file = path.join(dir, "items.ndjson");
324
+ items = await readItemsFile(file);
325
+ }
326
+ // If dir is absent and no adapter observations, items stays []
327
+ // and the union below produces only adapter observations (or []).
110
328
  const itemObservations = buildObservationsFromItems(items, { namespace: namespace.raw }).observations;
111
329
 
112
330
  // Union, dedup by id (first wins — adapter source is authoritative for its ids).
@@ -1,3 +1,4 @@
1
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
1
2
  import { execFile } from "node:child_process";
2
3
  import { createHash } from "node:crypto";
3
4
  import fs from "node:fs";
@@ -8,7 +9,7 @@ import { promisify } from "node:util";
8
9
 
9
10
  import ignore from "ignore";
10
11
 
11
- import { resolveOutputRoot } from "../config/service.js";
12
+ import { resolveOutputContext } from "../config/service.js";
12
13
 
13
14
  const DEFAULT_IGNORED_DIRS = new Set([
14
15
  ".git",
@@ -259,19 +260,6 @@ async function readExistingIngest(outputPath) {
259
260
  }
260
261
  }
261
262
 
262
- async function resolveIngestOutputPath({ rootPath, outputFile = "", outputDir = "" }) {
263
- const resolvedRoot = path.resolve(rootPath || process.cwd());
264
- const explicitOutputFile = String(outputFile || "").trim();
265
- if (explicitOutputFile) {
266
- return path.resolve(resolvedRoot, explicitOutputFile);
267
- }
268
- const outputRoot = await resolveOutputRoot({
269
- cwd: resolvedRoot,
270
- outputDirOverride: outputDir,
271
- });
272
- return path.join(outputRoot, "CODEBASE_INGEST.json");
273
- }
274
-
275
263
  async function readGitLastCommitAt(rootPath) {
276
264
  const resolvedRoot = path.resolve(rootPath || process.cwd());
277
265
  try {
@@ -793,36 +781,36 @@ export function formatIngestSummary(ingest) {
793
781
  return lines.join("\n");
794
782
  }
795
783
 
796
- export async function writeCodebaseIngest({ ingest, rootPath, outputFile = "", outputDir = "" } = {}) {
784
+ async function resolveIngestOutputContext({ rootPath, outputFile = "", outputDir = "" }) {
797
785
  const resolvedRoot = path.resolve(rootPath || process.cwd());
798
786
  const resolvedOutputFile = String(outputFile || "").trim();
799
- const outputPath = resolvedOutputFile
800
- ? path.resolve(resolvedRoot, resolvedOutputFile)
801
- : path.join(
802
- await resolveOutputRoot({
803
- cwd: resolvedRoot,
804
- outputDirOverride: outputDir,
805
- }),
806
- "CODEBASE_INGEST.json"
807
- );
808
-
809
- await fsp.mkdir(path.dirname(outputPath), { recursive: true });
810
- await fsp.writeFile(outputPath, `${JSON.stringify(ingest, null, 2)}\n`, "utf-8");
787
+ const output = await resolveOutputContext({ cwd: resolvedRoot, outputDirOverride: resolvedOutputFile || outputDir });
788
+ return { outputPath: resolvedOutputFile ? output.outputRoot : path.join(output.outputRoot, "CODEBASE_INGEST.json"), outputFs: output.outputFs };
789
+ }
790
+
791
+ export async function writeCodebaseIngest({ ingest, rootPath, outputFile = "", outputDir = "", outputFs } = {}) {
792
+ requireOutputFileSystem(outputFs);
793
+ const { outputPath } = await resolveIngestOutputContext({ rootPath, outputFile, outputDir });
794
+
795
+ await outputFs.mkdir(path.dirname(outputPath), { recursive: true });
796
+ await outputFs.writeFile(outputPath, `${JSON.stringify(ingest, null, 2)}\n`, "utf-8");
811
797
  return outputPath;
812
798
  }
813
799
 
814
800
  export async function resolveCodebaseIngest({
801
+ outputFs: inheritedOutputFs,
815
802
  rootPath = process.cwd(),
816
803
  outputFile = "",
817
804
  outputDir = "",
818
805
  refresh = false,
819
806
  } = {}) {
820
807
  const resolvedRoot = path.resolve(rootPath || process.cwd());
821
- const outputPath = await resolveIngestOutputPath({
808
+ const { outputPath, outputFs: resolvedOutputFs } = await resolveIngestOutputContext({
822
809
  rootPath: resolvedRoot,
823
810
  outputFile,
824
811
  outputDir,
825
812
  });
813
+ const outputFs = inheritedOutputFs === undefined ? resolvedOutputFs : requireOutputFileSystem(inheritedOutputFs);
826
814
  const existingIngest = await readExistingIngest(outputPath);
827
815
  const fingerprint = await computeCodebaseContentFingerprint({
828
816
  rootPath: resolvedRoot,
@@ -859,6 +847,7 @@ export async function resolveCodebaseIngest({
859
847
  ingest.cache.filesCount = fingerprint.filesCount;
860
848
  ingest.cache.latestFileMtimeMs = fingerprint.latestFileMtimeMs;
861
849
  await writeCodebaseIngest({
850
+ outputFs,
862
851
  ingest,
863
852
  rootPath: resolvedRoot,
864
853
  outputFile,
@@ -900,12 +889,16 @@ export async function resolveCodebaseIngest({
900
889
  }
901
890
 
902
891
  export async function generateCodebaseIngest({
892
+ outputFs: inheritedOutputFs,
903
893
  rootPath = process.cwd(),
904
894
  outputFile = "",
905
895
  outputDir = "",
906
896
  } = {}) {
897
+ const { outputFs: resolvedOutputFs } = await resolveIngestOutputContext({ rootPath, outputFile, outputDir });
898
+ const outputFs = inheritedOutputFs === undefined ? resolvedOutputFs : requireOutputFileSystem(inheritedOutputFs);
907
899
  const ingest = await collectCodebaseIngest({ rootPath });
908
900
  const outputPath = await writeCodebaseIngest({
901
+ outputFs,
909
902
  ingest,
910
903
  rootPath,
911
904
  outputFile,