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,45 @@
1
+ import { installSkills, listSkills } from "../skills/service.js";
2
+
3
+ export function registerSkillsCommand(program) {
4
+ const skills = program.command("skills").description("Install and inspect bundled Senti agent skills");
5
+ for (const action of ["install", "list"]) {
6
+ const command = skills.command(action === "install" ? "install [names...]" : action)
7
+ .description(action === "install"
8
+ ? "Install bundled skills (all, or only the named ones) without replacing local edits"
9
+ : "List bundled and installed skill versions and SHA-256 hashes")
10
+ .option("--dir <path>", "Skills directory (default: ~/.claude/skills)")
11
+ .option("--json", "Emit machine-readable output");
12
+ if (action === "install") command.option("--force", "Replace differing local files");
13
+ command.action(async (...args) => {
14
+ const invoked = args.at(-1);
15
+ const options = args.at(-2);
16
+ const names = action === "install" ? args[0] : undefined;
17
+ const json = Boolean(invoked.optsWithGlobals().json);
18
+ try {
19
+ const result = await (action === "install" ? installSkills({ ...options, names }) : listSkills(options));
20
+ if (json) {
21
+ console.log(JSON.stringify({ command: `skills ${action}`, ...result }, null, 2));
22
+ return;
23
+ }
24
+ console.log(`Skills directory: ${result.directory}`);
25
+ for (const skill of result.skills) {
26
+ if (action === "install") {
27
+ console.log(`${skill.name} ${skill.version}: ${skill.written} written, ${skill.unchanged} unchanged (sha256 ${skill.hash})`);
28
+ } else {
29
+ console.log(`${skill.name}: ${skill.status}`);
30
+ console.log(` bundled: ${skill.bundled.version} sha256 ${skill.bundled.hash}`);
31
+ console.log(skill.installed ? ` installed: ${skill.installed.version || "unknown"} sha256 ${skill.installed.hash}` : " installed: none");
32
+ if (skill.error) console.log(` error: ${skill.error}`);
33
+ }
34
+ }
35
+ } catch (error) {
36
+ if (json) console.log(JSON.stringify({ command: `skills ${action}`, error: {
37
+ code: error.code || "SKILLS_ERROR", message: error.message,
38
+ ...(error.files ? { files: error.files } : {}),
39
+ } }, null, 2));
40
+ else console.error(error.message);
41
+ process.exitCode = 1;
42
+ }
43
+ });
44
+ }
45
+ }
@@ -11,7 +11,8 @@ import {
11
11
  listRuntimeRunEvents,
12
12
  resolveActiveAuthSession,
13
13
  } from "../auth/service.js";
14
- import { resolveOutputRoot } from "../config/service.js";
14
+ import { resolveOutputRoot, resolveOutputContext } from "../config/service.js";
15
+ import { bindOutputFileSystem, requireOutputFileSystem } from "../config/output-filesystem.js";
15
16
  import { authLoginHint } from "../ui/command-hints.js";
16
17
  import { isAuthenticated } from "../auth/credential-destinations.js";
17
18
 
@@ -254,11 +255,12 @@ async function resolveWatchArtifacts({
254
255
  if (!enabled) {
255
256
  return null;
256
257
  }
257
- const outputRoot = await resolveOutputRoot({
258
+ const { outputRoot, outputFs } = await resolveOutputContext({
258
259
  cwd: targetPath,
259
260
  outputDirOverride: outputDir,
260
261
  env: process.env,
261
262
  });
263
+ requireOutputFileSystem(outputFs);
262
264
  const watchStamp = stableTimestampForFile();
263
265
  const watchDir = path.join(
264
266
  outputRoot,
@@ -266,12 +268,12 @@ async function resolveWatchArtifacts({
266
268
  "runtime-watch",
267
269
  sanitizePathSegment(runId)
268
270
  );
269
- await fsp.mkdir(watchDir, { recursive: true });
270
- return {
271
+ await outputFs.mkdir(watchDir, { recursive: true });
272
+ return bindOutputFileSystem({
271
273
  watchDir,
272
274
  eventsPath: path.join(watchDir, `events-${watchStamp}.ndjson`),
273
275
  summaryPath: path.join(watchDir, `summary-${watchStamp}.json`),
274
- };
276
+ }, outputFs);
275
277
  }
276
278
 
277
279
  export function registerWatchCommand(program) {
@@ -411,7 +413,7 @@ export function registerWatchCommand(program) {
411
413
  eventCount += 1;
412
414
 
413
415
  if (artifactPaths?.eventsPath) {
414
- await fsp.appendFile(artifactPaths.eventsPath, `${JSON.stringify(event)}\n`, "utf-8");
416
+ await artifactPaths.outputFs.appendFile(artifactPaths.eventsPath, `${JSON.stringify(event)}\n`, "utf-8");
415
417
  }
416
418
 
417
419
  if (emitJson) {
@@ -478,7 +480,7 @@ export function registerWatchCommand(program) {
478
480
  };
479
481
 
480
482
  if (artifactPaths?.summaryPath) {
481
- await fsp.writeFile(artifactPaths.summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
483
+ await artifactPaths.outputFs.writeFile(artifactPaths.summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
482
484
  }
483
485
 
484
486
  if (emitJson) {
@@ -0,0 +1,156 @@
1
+ import fsp from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const outputCapabilities = new WeakSet();
7
+
8
+ /** Writers accept only a capability created for this operation's output policy. */
9
+ export function requireOutputFileSystem(outputFs) {
10
+ if (!outputCapabilities.has(outputFs)) {
11
+ throw new TypeError("An output filesystem capability is required for artifact writes.");
12
+ }
13
+ return outputFs;
14
+ }
15
+
16
+ function isInside(root, candidate) {
17
+ const relative = path.relative(root, candidate);
18
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
19
+ }
20
+
21
+ /** Validate the complete destination, including an existing final-file link. */
22
+ export async function assertWorkspaceOutputPath(destination, workspaceRoot) {
23
+ try {
24
+ const value = destination instanceof URL ? fileURLToPath(destination) : destination;
25
+ if (typeof value !== "string") throw new Error("Unverifiable artifact destination");
26
+ const root = path.resolve(workspaceRoot);
27
+ const candidate = path.resolve(value);
28
+ if (!isInside(root, candidate)) throw new Error("Artifact outside workspace");
29
+ const realRoot = await fsp.realpath(root);
30
+ let ancestor = candidate;
31
+ for (;;) {
32
+ let resolved;
33
+ try {
34
+ resolved = await fsp.realpath(ancestor);
35
+ } catch (error) {
36
+ if (error.code !== "ENOENT") throw error;
37
+ const entry = await fsp.lstat(ancestor).catch((statError) => {
38
+ if (statError.code !== "ENOENT") throw statError;
39
+ return null;
40
+ });
41
+ if (entry) {
42
+ if (entry.isSymbolicLink()) throw new Error("Unresolved artifact link");
43
+ // A concurrent artifact writer may have created this ordinary entry
44
+ // after realpath saw ENOENT. Resolve it once, then apply the same real
45
+ // workspace check below; never treat its mere existence as authority.
46
+ resolved = await fsp.realpath(ancestor);
47
+ } else {
48
+ const parent = path.dirname(ancestor);
49
+ if (parent === ancestor) throw error;
50
+ ancestor = parent;
51
+ continue;
52
+ }
53
+ }
54
+ if (!isInside(realRoot, path.resolve(resolved, path.relative(ancestor, candidate)))) {
55
+ throw new Error("Artifact link leaves workspace");
56
+ }
57
+ return candidate;
58
+ }
59
+ } catch {
60
+ throw new Error("Workspace artifact destination must resolve inside the workspace.");
61
+ }
62
+ }
63
+
64
+ /** Per-operation capability; never stored in a process-global path registry. */
65
+ export function createOutputFileSystem({ workspaceRoot = null } = {}) {
66
+ workspaceRoot = workspaceRoot ? path.resolve(workspaceRoot) : null;
67
+ const assertPath = async (destination) => {
68
+ if (workspaceRoot) return assertWorkspaceOutputPath(destination, workspaceRoot);
69
+ return destination;
70
+ };
71
+ const assertFilePath = async (destination) => {
72
+ const checked = await assertPath(destination);
73
+ if (workspaceRoot) {
74
+ const entry = await fsp.lstat(checked).catch((error) => {
75
+ if (error.code !== "ENOENT") throw new Error("Workspace artifact destination must resolve inside the workspace.");
76
+ return null;
77
+ });
78
+ if (entry?.isSymbolicLink() || (entry?.isFile() && entry.nlink > 1)) {
79
+ throw new Error("Workspace artifact destination must resolve inside the workspace without linked files.");
80
+ }
81
+ }
82
+ return checked;
83
+ };
84
+ // Expose only read operations and the mutations guarded below. Adding a new
85
+ // writer must not silently inherit an unchecked fs/promises method.
86
+ const io = { assertPath, assertFilePath };
87
+ for (const method of ["access", "readFile", "readdir", "readlink", "realpath", "stat", "lstat", "statfs", "opendir"]) {
88
+ io[method] = fsp[method];
89
+ }
90
+ for (const method of ["mkdir", "rm", "unlink", "truncate"]) {
91
+ io[method] = async (destination, ...args) => {
92
+ const checked = await (method === "mkdir" ? assertPath(destination) : assertFilePath(destination));
93
+ return fsp[method](checked, ...args);
94
+ };
95
+ }
96
+ for (const method of ["rename", "copyFile"]) {
97
+ io[method] = async (source, destination, ...args) => {
98
+ const checkedSource = method === "rename" ? await assertFilePath(source) : source;
99
+ const checkedDestination = await assertFilePath(destination);
100
+ return fsp[method](checkedSource, checkedDestination, ...args);
101
+ };
102
+ }
103
+ io.open = async (destination, flags, ...args) => {
104
+ const writeFlags = constants.O_WRONLY | constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_TRUNC;
105
+ if (typeof flags === "number" ? Boolean(flags & writeFlags) : /[wa+]/.test(flags || "r")) {
106
+ const checked = await assertFilePath(destination);
107
+ return fsp.open(checked, workspaceRoot ? numericOpenFlags(flags) | (constants.O_NOFOLLOW || 0) : flags, ...args);
108
+ }
109
+ return fsp.open(destination, flags, ...args);
110
+ };
111
+ for (const method of ["writeFile", "appendFile"]) {
112
+ io[method] = async (destination, data, options) => {
113
+ if (!workspaceRoot) return fsp[method](destination, data, options);
114
+ if (options !== undefined && options !== null && typeof options !== "string" && typeof options !== "object") {
115
+ throw new TypeError("Artifact file options must be an encoding string or an object.");
116
+ }
117
+ const normalizedOptions = typeof options === "string" ? { encoding: options } : options || {};
118
+ const flag = normalizedOptions.flag || (method === "appendFile" ? "a" : "w");
119
+ const checked = await assertFilePath(destination);
120
+ return fsp[method](checked, data, {
121
+ ...normalizedOptions,
122
+ flag: numericOpenFlags(flag) | (constants.O_NOFOLLOW || 0),
123
+ });
124
+ };
125
+ }
126
+ outputCapabilities.add(io);
127
+ return Object.freeze(io);
128
+ }
129
+
130
+ function numericOpenFlags(flags) {
131
+ if (typeof flags === "number") return flags;
132
+ const { O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND, O_EXCL, O_SYNC } = constants;
133
+ const modes = {
134
+ r: O_RDONLY, rs: O_RDONLY | O_SYNC, sr: O_RDONLY | O_SYNC,
135
+ "r+": O_RDWR, "rs+": O_RDWR | O_SYNC, "sr+": O_RDWR | O_SYNC,
136
+ w: O_TRUNC | O_CREAT | O_WRONLY, wx: O_TRUNC | O_CREAT | O_WRONLY | O_EXCL,
137
+ xw: O_TRUNC | O_CREAT | O_WRONLY | O_EXCL,
138
+ "w+": O_TRUNC | O_CREAT | O_RDWR, "wx+": O_TRUNC | O_CREAT | O_RDWR | O_EXCL,
139
+ "xw+": O_TRUNC | O_CREAT | O_RDWR | O_EXCL,
140
+ a: O_APPEND | O_CREAT | O_WRONLY, ax: O_APPEND | O_CREAT | O_WRONLY | O_EXCL,
141
+ xa: O_APPEND | O_CREAT | O_WRONLY | O_EXCL,
142
+ "a+": O_APPEND | O_CREAT | O_RDWR, "ax+": O_APPEND | O_CREAT | O_RDWR | O_EXCL,
143
+ "xa+": O_APPEND | O_CREAT | O_RDWR | O_EXCL,
144
+ as: O_APPEND | O_CREAT | O_WRONLY | O_SYNC, sa: O_APPEND | O_CREAT | O_WRONLY | O_SYNC,
145
+ "as+": O_APPEND | O_CREAT | O_RDWR | O_SYNC, "sa+": O_APPEND | O_CREAT | O_RDWR | O_SYNC,
146
+ };
147
+ if (!Object.hasOwn(modes, flags)) throw new TypeError("Unsupported artifact file open flag.");
148
+ return modes[flags];
149
+ }
150
+
151
+ // Storage objects are often returned as JSON. Keep the executable capability
152
+ // out of that public data shape while allowing explicit downstream propagation.
153
+ export function bindOutputFileSystem(storage, outputFs) {
154
+ Object.defineProperty(storage, "outputFs", { value: requireOutputFileSystem(outputFs), enumerable: false });
155
+ return storage;
156
+ }
@@ -8,8 +8,10 @@ function emptyToUndefined(value) {
8
8
  return normalized ? normalized : undefined;
9
9
  }
10
10
 
11
- const optionalTrimmedString = z.preprocess(emptyToUndefined, z.string().min(1)).optional();
12
- const optionalUrl = z.preprocess(emptyToUndefined, z.string().url()).optional();
11
+ // optional() sits inside preprocess: zod >=4.2 validates the preprocessed value, so a blank value
12
+ // mapped to undefined must be accepted by the inner schema, not only by an outer optional().
13
+ const optionalTrimmedString = z.preprocess(emptyToUndefined, z.string().min(1).optional());
14
+ const optionalUrl = z.preprocess(emptyToUndefined, z.string().url().optional());
13
15
  const optionalStringArray = z
14
16
  .array(z.preprocess(emptyToUndefined, z.string().min(1)))
15
17
  .optional();
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { getConfigPaths } from "./paths.js";
5
5
  import { ensureConfigFile, readConfigFile, writeConfigFile } from "./io.js";
6
6
  import { CONFIG_KEYS, configSchema } from "./schema.js";
7
+ import { assertWorkspaceOutputPath, createOutputFileSystem } from "./output-filesystem.js";
7
8
 
8
9
  const ENV_MAPPING = Object.freeze({
9
10
  SENTINELAYER_API_URL: "apiUrl",
@@ -200,7 +201,38 @@ export function listConfigKeys() {
200
201
  return [...CONFIG_KEYS];
201
202
  }
202
203
 
203
- export async function resolveOutputRoot({
204
+ function isInsideRoot(root, candidate) {
205
+ const relative = path.relative(root, candidate);
206
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
207
+ }
208
+
209
+ // Every workspace-local output is confined, regardless of where its setting came
210
+ // from. Only an explicit absolute trusted-layer path outside the workspace opts out.
211
+ async function resolveOutputSelection(value, cwd, { project = false } = {}) {
212
+ try {
213
+ const root = path.resolve(cwd);
214
+ const absolute = path.isAbsolute(value);
215
+ const foreignAbsolute = path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value);
216
+ if ((project && (absolute || foreignAbsolute)) || (!absolute && foreignAbsolute)) throw new Error();
217
+ const candidate = path.resolve(root, value);
218
+ if (absolute && !isInsideRoot(root, candidate)) {
219
+ return { outputRoot: candidate, outputFs: createOutputFileSystem() };
220
+ }
221
+ // Backslash escapes remain rejected on POSIX as well, so config is portable.
222
+ if (!isInsideRoot(root, candidate) || !isInsideRoot(root, path.resolve(root, value.replace(/\\/g, "/")))) {
223
+ throw new Error();
224
+ }
225
+ return {
226
+ outputRoot: await assertWorkspaceOutputPath(candidate, root),
227
+ outputFs: createOutputFileSystem({ workspaceRoot: root }),
228
+ };
229
+ } catch {
230
+ // Do not echo private configuration values or filesystem errors containing their paths.
231
+ throw new Error("Workspace outputDir must resolve to a relative path inside the workspace.");
232
+ }
233
+ }
234
+
235
+ export async function resolveOutputContext({
204
236
  cwd = process.cwd(),
205
237
  outputDirOverride = "",
206
238
  env = process.env,
@@ -208,14 +240,18 @@ export async function resolveOutputRoot({
208
240
  } = {}) {
209
241
  const overrideValue = String(outputDirOverride || "").trim();
210
242
  if (overrideValue) {
211
- return path.resolve(cwd, overrideValue);
243
+ return resolveOutputSelection(overrideValue, cwd);
212
244
  }
213
245
 
214
246
  const config = await loadConfig({ cwd, env, homeDir });
215
247
  const configuredOutputDir = String(config.resolved.outputDir || "").trim();
216
248
  if (configuredOutputDir) {
217
- return path.resolve(cwd, configuredOutputDir);
249
+ return resolveOutputSelection(configuredOutputDir, cwd, { project: findConfigSource(config, "outputDir") === "project" });
218
250
  }
219
251
 
220
- return path.resolve(cwd, ".sentinelayer");
252
+ return resolveOutputSelection(".sentinelayer", cwd);
253
+ }
254
+
255
+ export async function resolveOutputRoot(options = {}) {
256
+ return (await resolveOutputContext(options)).outputRoot;
221
257
  }
@@ -1,8 +1,9 @@
1
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
1
2
  import fsp from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { randomUUID } from "node:crypto";
4
5
 
5
- import { resolveOutputRoot } from "../config/service.js";
6
+ import { resolveOutputContext } from "../config/service.js";
6
7
  import { rollupUsage } from "./tracker.js";
7
8
 
8
9
  const HISTORY_VERSION = 1;
@@ -60,19 +61,23 @@ function normalizeEntry(entry) {
60
61
  };
61
62
  }
62
63
 
63
- export async function resolveCostHistoryPath({
64
+ async function resolveCostHistoryOutput({
64
65
  targetPath = ".",
65
66
  outputDirOverride = "",
66
67
  env,
67
68
  homeDir,
68
69
  } = {}) {
69
- const outputRoot = await resolveOutputRoot({
70
+ const { outputRoot, outputFs } = await resolveOutputContext({
70
71
  cwd: path.resolve(targetPath),
71
72
  outputDirOverride,
72
73
  env,
73
74
  homeDir,
74
75
  });
75
- return path.join(outputRoot, HISTORY_FILE_NAME);
76
+ return { filePath: path.join(outputRoot, HISTORY_FILE_NAME), outputFs };
77
+ }
78
+
79
+ export async function resolveCostHistoryPath(options = {}) {
80
+ return (await resolveCostHistoryOutput(options)).filePath;
76
81
  }
77
82
 
78
83
  export async function loadCostHistory(options = {}) {
@@ -106,25 +111,26 @@ async function readCostHistoryFile(filePath) {
106
111
  }
107
112
  }
108
113
 
109
- export async function saveCostHistory({ filePath, history }) {
114
+ export async function saveCostHistory({ filePath, history, outputFs }) {
115
+ requireOutputFileSystem(outputFs);
110
116
  const payload = {
111
117
  version: HISTORY_VERSION,
112
118
  entries: Array.isArray(history?.entries) ? history.entries : [],
113
119
  };
114
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
115
- await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
120
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
121
+ await outputFs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
116
122
  }
117
123
 
118
124
  export async function appendCostEntry(options = {}, entry = {}) {
119
125
  const normalizedEntry = normalizeEntry(entry);
120
- const filePath = await resolveCostHistoryPath(options);
126
+ const { filePath, outputFs } = await resolveCostHistoryOutput(options);
121
127
  return withCostHistoryWriteQueue(filePath, async () => {
122
128
  const history = await readCostHistoryFile(filePath);
123
129
  const nextHistory = {
124
130
  version: HISTORY_VERSION,
125
131
  entries: [...history.entries, normalizedEntry],
126
132
  };
127
- await saveCostHistory({ filePath, history: nextHistory });
133
+ await saveCostHistory({ filePath, history: nextHistory, outputFs });
128
134
  return {
129
135
  filePath,
130
136
  entry: normalizedEntry,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
+ import { bindOutputFileSystem, requireOutputFileSystem } from "../config/output-filesystem.js";
5
6
  import { listAssignments, resolveAssignmentLedgerStorage } from "./assignment-ledger.js";
6
7
  import { getBudgetHealthColor, resolveOperatorControlStorage } from "./operator-control.js";
7
8
  import { listBudgetStates, resolveBudgetGovernorStorage } from "./budget-governor.js";
@@ -215,7 +216,7 @@ export async function writeCloseoutArtifact({
215
216
  });
216
217
  const dateKey = normalizeDateKey(date, normalizedNow);
217
218
  const artifactDir = resolveWorkItemArtifactDir(storage, normalizedWorkItemId, dateKey);
218
- await fsp.mkdir(artifactDir, { recursive: true });
219
+ await storage.outputFs.mkdir(artifactDir, { recursive: true });
219
220
  const closeoutPath = path.join(artifactDir, "closeout.json");
220
221
 
221
222
  const entries = await fsp.readdir(artifactDir, { withFileTypes: true }).catch((error) => {
@@ -273,7 +274,7 @@ export async function writeCloseoutArtifact({
273
274
  ),
274
275
  anchorSha256,
275
276
  };
276
- await writeJsonFile(closeoutPath, payload);
277
+ await writeJsonFile(closeoutPath, payload, storage.outputFs);
277
278
  return {
278
279
  closeoutPath,
279
280
  payload,
@@ -390,14 +391,16 @@ export async function verifyArtifactChain({
390
391
  };
391
392
  }
392
393
 
393
- async function writeJsonFile(filePath, payload = {}) {
394
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
395
- await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
394
+ async function writeJsonFile(filePath, payload = {}, outputFs) {
395
+ requireOutputFileSystem(outputFs);
396
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
397
+ await outputFs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
396
398
  }
397
399
 
398
- async function appendJsonLine(filePath, payload = {}) {
399
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
400
- await fsp.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
400
+ async function appendJsonLine(filePath, payload = {}, outputFs) {
401
+ requireOutputFileSystem(outputFs);
402
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
403
+ await outputFs.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
401
404
  }
402
405
 
403
406
  async function readJsonFile(filePath, fallbackValue) {
@@ -518,12 +521,12 @@ export async function resolveArtifactLineageStorage({
518
521
  homeDir,
519
522
  });
520
523
  const lineageDir = path.join(daemonStorage.baseDir, "lineage");
521
- return {
524
+ return bindOutputFileSystem({
522
525
  ...daemonStorage,
523
526
  lineageDir,
524
527
  lineageIndexPath: path.join(lineageDir, "lineage-index.json"),
525
528
  lineageEventsPath: path.join(lineageDir, "lineage-events.ndjson"),
526
- };
529
+ }, daemonStorage.outputFs);
527
530
  }
528
531
 
529
532
  export async function buildArtifactLineageIndex({
@@ -799,7 +802,7 @@ export async function buildArtifactLineageIndex({
799
802
  };
800
803
 
801
804
  await Promise.all([
802
- writeJsonFile(storage.lineageIndexPath, index),
805
+ writeJsonFile(storage.lineageIndexPath, index, storage.outputFs),
803
806
  appendJsonLine(storage.lineageEventsPath, {
804
807
  timestamp: normalizedNow,
805
808
  eventType: "lineage_build",
@@ -809,7 +812,7 @@ export async function buildArtifactLineageIndex({
809
812
  jiraLinkedCount,
810
813
  budgetGuardedCount,
811
814
  operatorCoveredCount,
812
- }),
815
+ }, storage.outputFs),
813
816
  ]);
814
817
 
815
818
  return {
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
+ import { bindOutputFileSystem, requireOutputFileSystem } from "../config/output-filesystem.js";
5
6
  import { WORK_ITEM_STATUSES, resolveErrorDaemonStorage } from "./error-worker.js";
6
7
 
7
8
  const LEDGER_SCHEMA_VERSION = "1.0.0";
@@ -170,14 +171,16 @@ async function loadJsonFile(filePath, defaultFactory) {
170
171
  }
171
172
  }
172
173
 
173
- async function writeJsonFile(filePath, payload = {}) {
174
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
175
- await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
174
+ async function writeJsonFile(filePath, payload = {}, outputFs) {
175
+ requireOutputFileSystem(outputFs);
176
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
177
+ await outputFs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
176
178
  }
177
179
 
178
- async function appendEvent(filePath, payload = {}) {
179
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
180
- await fsp.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
180
+ async function appendEvent(filePath, payload = {}, outputFs) {
181
+ requireOutputFileSystem(outputFs);
182
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
183
+ await outputFs.appendFile(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
181
184
  }
182
185
 
183
186
  async function loadLedger(ledgerPath, nowIso = new Date().toISOString()) {
@@ -193,7 +196,8 @@ async function loadLedger(ledgerPath, nowIso = new Date().toISOString()) {
193
196
  };
194
197
  }
195
198
 
196
- async function writeLedger(ledgerPath, ledger = {}, nowIso = new Date().toISOString()) {
199
+ async function writeLedger(ledgerPath, ledger = {}, nowIso = new Date().toISOString(), outputFs) {
200
+ requireOutputFileSystem(outputFs);
197
201
  const normalized = {
198
202
  schemaVersion: LEDGER_SCHEMA_VERSION,
199
203
  generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
@@ -203,7 +207,7 @@ async function writeLedger(ledgerPath, ledger = {}, nowIso = new Date().toISOStr
203
207
  .filter((record) => normalizeString(record.workItemId))
204
208
  : [],
205
209
  };
206
- await writeJsonFile(ledgerPath, normalized);
210
+ await writeJsonFile(ledgerPath, normalized, outputFs);
207
211
  return normalized;
208
212
  }
209
213
 
@@ -218,7 +222,8 @@ async function loadQueue(queuePath, nowIso = new Date().toISOString()) {
218
222
  };
219
223
  }
220
224
 
221
- async function writeQueue(queuePath, queue = {}, nowIso = new Date().toISOString()) {
225
+ async function writeQueue(queuePath, queue = {}, nowIso = new Date().toISOString(), outputFs) {
226
+ requireOutputFileSystem(outputFs);
222
227
  const normalized = {
223
228
  schemaVersion: QUEUE_SCHEMA_VERSION,
224
229
  generatedAt: normalizeIsoTimestamp(nowIso, nowIso),
@@ -226,16 +231,17 @@ async function writeQueue(queuePath, queue = {}, nowIso = new Date().toISOString
226
231
  ? queue.items.map((item) => normalizeQueueItem(item, nowIso)).filter((item) => item.workItemId)
227
232
  : [],
228
233
  };
229
- await writeJsonFile(queuePath, normalized);
234
+ await writeJsonFile(queuePath, normalized, outputFs);
230
235
  return normalized;
231
236
  }
232
237
 
233
238
  function getAssignmentStoragePaths(baseStorage = {}) {
234
- return {
239
+ requireOutputFileSystem(baseStorage.outputFs);
240
+ return bindOutputFileSystem({
235
241
  ...baseStorage,
236
242
  ledgerPath: path.join(baseStorage.baseDir, "assignment-ledger.json"),
237
243
  eventsPath: path.join(baseStorage.baseDir, "assignment-events.ndjson"),
238
- };
244
+ }, baseStorage.outputFs);
239
245
  }
240
246
 
241
247
  async function loadLedgerAndQueue(storage, nowIso) {
@@ -436,8 +442,8 @@ export async function claimAssignment({
436
442
  });
437
443
 
438
444
  const [savedLedger, savedQueue] = await Promise.all([
439
- writeLedger(storage.ledgerPath, ledger, normalizedNow),
440
- writeQueue(storage.queuePath, queue, normalizedNow),
445
+ writeLedger(storage.ledgerPath, ledger, normalizedNow, storage.outputFs),
446
+ writeQueue(storage.queuePath, queue, normalizedNow, storage.outputFs),
441
447
  ]);
442
448
 
443
449
  await appendEvent(storage.eventsPath, {
@@ -451,7 +457,7 @@ export async function claimAssignment({
451
457
  stage: normalizeString(stage) || "triage",
452
458
  runId: normalizeString(runId) || null,
453
459
  jiraIssueKey: normalizeString(jiraIssueKey) || null,
454
- });
460
+ }, storage.outputFs);
455
461
 
456
462
  return {
457
463
  ...storage,
@@ -537,8 +543,8 @@ export async function heartbeatAssignment({
537
543
  });
538
544
 
539
545
  const [savedLedger, savedQueue] = await Promise.all([
540
- writeLedger(storage.ledgerPath, ledger, normalizedNow),
541
- writeQueue(storage.queuePath, queue, normalizedNow),
546
+ writeLedger(storage.ledgerPath, ledger, normalizedNow, storage.outputFs),
547
+ writeQueue(storage.queuePath, queue, normalizedNow, storage.outputFs),
542
548
  ]);
543
549
 
544
550
  await appendEvent(storage.eventsPath, {
@@ -549,7 +555,7 @@ export async function heartbeatAssignment({
549
555
  agentIdentity: normalizedAgentIdentity,
550
556
  stage: next.stage,
551
557
  leaseExpiresAt,
552
- });
558
+ }, storage.outputFs);
553
559
 
554
560
  return {
555
561
  ...storage,
@@ -638,8 +644,8 @@ export async function releaseAssignment({
638
644
  });
639
645
 
640
646
  const [savedLedger, savedQueue] = await Promise.all([
641
- writeLedger(storage.ledgerPath, ledger, normalizedNow),
642
- writeQueue(storage.queuePath, queue, normalizedNow),
647
+ writeLedger(storage.ledgerPath, ledger, normalizedNow, storage.outputFs),
648
+ writeQueue(storage.queuePath, queue, normalizedNow, storage.outputFs),
643
649
  ]);
644
650
 
645
651
  await appendEvent(storage.eventsPath, {
@@ -650,7 +656,7 @@ export async function releaseAssignment({
650
656
  agentIdentity: next.assignedAgentIdentity,
651
657
  status: normalizedStatus,
652
658
  reason: next.releaseReason,
653
- });
659
+ }, storage.outputFs);
654
660
 
655
661
  return {
656
662
  ...storage,
@@ -746,8 +752,8 @@ export async function reassignAssignment({
746
752
  });
747
753
 
748
754
  const [savedLedger, savedQueue] = await Promise.all([
749
- writeLedger(storage.ledgerPath, ledger, normalizedNow),
750
- writeQueue(storage.queuePath, queue, normalizedNow),
755
+ writeLedger(storage.ledgerPath, ledger, normalizedNow, storage.outputFs),
756
+ writeQueue(storage.queuePath, queue, normalizedNow, storage.outputFs),
751
757
  ]);
752
758
 
753
759
  await appendEvent(storage.eventsPath, {
@@ -760,7 +766,7 @@ export async function reassignAssignment({
760
766
  leaseTtlSeconds: normalizedTtl,
761
767
  leaseExpiresAt,
762
768
  stage: next.stage,
763
- });
769
+ }, storage.outputFs);
764
770
 
765
771
  return {
766
772
  ...storage,
@@ -1073,7 +1079,7 @@ export async function ensureWorkItemQueued({
1073
1079
  queue.items.push(normalizedItem);
1074
1080
  }
1075
1081
 
1076
- const savedQueue = await writeQueue(storage.queuePath, queue, normalizedNow);
1082
+ const savedQueue = await writeQueue(storage.queuePath, queue, normalizedNow, storage.outputFs);
1077
1083
  const queueItem = savedQueue.items.find((item) => item.workItemId === normalizedWorkItemId) || normalizedItem;
1078
1084
  return {
1079
1085
  ...storage,