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
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
 
4
4
  import { ZodError, z } from "zod";
5
5
 
6
- import { resolveOutputRoot } from "../config/service.js";
6
+ import { resolveOutputContext } from "../config/service.js";
7
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
7
8
 
8
9
  export const PLUGIN_MANIFEST_SCHEMA_VERSION = "1.0.0";
9
10
  export const PLUGIN_MANIFEST_KIND = "sentinelayer.cli.plugin";
@@ -284,11 +285,13 @@ export function stringifyJson(value) {
284
285
  return `${JSON.stringify(value, null, 2)}\n`;
285
286
  }
286
287
 
287
- export async function writeJsonFile(filePath, value, { force = false } = {}) {
288
+ export async function writeJsonFile(filePath, value, { force = false, outputFs } = {}) {
289
+ requireOutputFileSystem(outputFs);
288
290
  const resolvedPath = path.resolve(filePath);
291
+ await outputFs.assertPath(resolvedPath);
289
292
  if (!force) {
290
293
  try {
291
- await fsp.access(resolvedPath);
294
+ await outputFs.access(resolvedPath);
292
295
  throw new Error(`File already exists: ${resolvedPath}. Use --force to overwrite.`);
293
296
  } catch (error) {
294
297
  if (error && typeof error === "object" && error.code === "ENOENT") {
@@ -301,8 +304,8 @@ export async function writeJsonFile(filePath, value, { force = false } = {}) {
301
304
  }
302
305
  }
303
306
 
304
- await fsp.mkdir(path.dirname(resolvedPath), { recursive: true });
305
- await fsp.writeFile(resolvedPath, stringifyJson(value), "utf-8");
307
+ await outputFs.mkdir(path.dirname(resolvedPath), { recursive: true });
308
+ await outputFs.writeFile(resolvedPath, stringifyJson(value), "utf-8");
306
309
  return resolvedPath;
307
310
  }
308
311
 
@@ -315,8 +318,8 @@ export async function readJsonFile(filePath) {
315
318
  };
316
319
  }
317
320
 
318
- export async function resolvePluginsDirectoryPath({ cwd, outputDir, env } = {}) {
319
- const outputRoot = await resolveOutputRoot({
321
+ export async function resolvePluginsDirectoryPath({ cwd, outputDir, env, outputContext } = {}) {
322
+ const { outputRoot } = outputContext || await resolveOutputContext({
320
323
  cwd,
321
324
  outputDirOverride: outputDir,
322
325
  env,
@@ -324,11 +327,12 @@ export async function resolvePluginsDirectoryPath({ cwd, outputDir, env } = {})
324
327
  return path.join(outputRoot, "plugins");
325
328
  }
326
329
 
327
- export async function resolveDefaultPluginManifestPath({ cwd, outputDir, env, pluginId } = {}) {
330
+ export async function resolveDefaultPluginManifestPath({ cwd, outputDir, env, pluginId, outputContext } = {}) {
328
331
  const pluginsRoot = await resolvePluginsDirectoryPath({
329
332
  cwd,
330
333
  outputDir,
331
334
  env,
335
+ outputContext,
332
336
  });
333
337
  const normalizedId = normalizePluginId(pluginId);
334
338
  return path.join(pluginsRoot, normalizedId, "plugin.json");
@@ -10,11 +10,12 @@ import {
10
10
  import { computeProviderCost } from "../billing/price-book.js";
11
11
  import { buildBillingRunId, buildCallIdempotencyKey, sanitizeBillingMetadata } from "../billing/ledger-entry.js";
12
12
  import { recordSessionUsage } from "../billing/session-usage.js";
13
- import { loadConfig, userConfigValues } from "../config/service.js";
13
+ import { loadConfig, resolveOutputContext, userConfigValues } from "../config/service.js";
14
14
  import { evaluateBudget } from "../cost/budget.js";
15
- import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
15
+ import { appendCostEntry, summarizeCostHistory, loadCostHistory } from "../cost/history.js";
16
16
  import { estimateTokens } from "../cost/tokenizer.js";
17
- import { appendRunEvent, deriveStopClassFromBudget } from "../telemetry/ledger.js";
17
+ import { evaluateSeverityClassification, normalizeSeverityClassifier, withSeverityEvaluationQueue, severityResourceTotals, parseSeverityEvaluationMaxCost, TYPESAFE_SEVERITY_MODEL, SEVERITY_DICTIONARY_VERSION } from "./typesafe-severity.js";
18
+ import { appendRunEvent, loadRunEvents, deriveStopClassFromBudget } from "../telemetry/ledger.js";
18
19
 
19
20
  const AI_SEVERITIES = new Set(["P0", "P1", "P2", "P3"]);
20
21
  const DEFAULT_AI_MAX_FINDINGS = 20;
@@ -199,13 +200,51 @@ function summarizeFindings(findings = []) {
199
200
  };
200
201
  }
201
202
 
203
+ // Normalization remains permissive for legacy reports. Completion requires a
204
+ // structured review, so error envelopes and invented defaults cannot prove it.
205
+ function validateReviewResponse(text) {
206
+ const raw = String(text || "").trim();
207
+ const fenced = raw.match(/^```(?:json)?\s*([\s\S]*?)```\s*$/i);
208
+ let parsed;
209
+ try {
210
+ parsed = JSON.parse(fenced ? fenced[1] : raw);
211
+ } catch {
212
+ return { valid: false, reason: "unstructured_response" };
213
+ }
214
+ if (Array.isArray(parsed)) parsed = { findings: parsed };
215
+ if (!parsed || typeof parsed !== "object") return { valid: false, reason: "invalid_review_envelope" };
216
+ const hasError = (value) => value !== undefined && value !== null && value !== false && value !== "" &&
217
+ !(Array.isArray(value) && value.length === 0);
218
+ const invalidStatus = Object.hasOwn(parsed, "status") &&
219
+ (typeof parsed.status !== "string" || !["ok", "success", "complete", "completed", "passed"].includes(parsed.status.trim().toLowerCase()));
220
+ if (hasError(parsed.error) || hasError(parsed.errors) || hasError(parsed.refusal) || invalidStatus ||
221
+ parsed.ok === false || parsed.success === false || parsed.complete === false) {
222
+ return { valid: false, reason: "error_response" };
223
+ }
224
+ if (!Array.isArray(parsed.findings)) return { valid: false, reason: "missing_or_invalid_findings" };
225
+ const malformed = parsed.findings.some((finding) => {
226
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) return true;
227
+ const message = finding.title || finding.message;
228
+ const line = typeof finding.line === "string" && /^\d+$/.test(finding.line) ? Number(finding.line) : finding.line;
229
+ return typeof message !== "string" || !message.trim() ||
230
+ typeof finding.file !== "string" || !finding.file.trim() ||
231
+ typeof finding.severity !== "string" || !AI_SEVERITIES.has(finding.severity.trim().toUpperCase()) ||
232
+ !Number.isSafeInteger(line) || line < 1 ||
233
+ (finding.confidence !== undefined && finding.confidence !== null &&
234
+ (typeof finding.confidence !== "number" || !Number.isFinite(finding.confidence) || finding.confidence < 0 || finding.confidence > 1));
235
+ });
236
+ return { valid: !malformed, reason: malformed ? "malformed_findings" : "structured_review" };
237
+ }
238
+
202
239
  export function parseAiReviewResponse({ text, maxFindings = DEFAULT_AI_MAX_FINDINGS } = {}) {
203
240
  const parsed = extractJsonPayload(text);
241
+ const reviewValidity = validateReviewResponse(text);
204
242
  const normalizedMaxFindings = Math.max(1, Math.floor(Number(maxFindings || DEFAULT_AI_MAX_FINDINGS)));
205
243
 
206
244
  if (!parsed) {
207
245
  return {
208
246
  parser: "fallback_text",
247
+ reviewValidity,
209
248
  summary:
210
249
  sanitizeExcerpt(text) || "AI response could not be parsed as JSON; no structured findings extracted.",
211
250
  findings: [],
@@ -229,6 +268,7 @@ export function parseAiReviewResponse({ text, maxFindings = DEFAULT_AI_MAX_FINDI
229
268
 
230
269
  return {
231
270
  parser: "json",
271
+ reviewValidity,
232
272
  summary: summary || "Structured AI response parsed successfully.",
233
273
  findings,
234
274
  };
@@ -355,12 +395,14 @@ function composeAiReviewMarkdown({
355
395
  runId,
356
396
  mode,
357
397
  parser,
398
+ reviewValidity,
358
399
  summary,
359
400
  provider,
360
401
  model,
361
402
  dryRun,
362
403
  findings = [],
363
404
  usage,
405
+ severityClassification = null,
364
406
  combinedSummary,
365
407
  } = {}) {
366
408
  const findingLines =
@@ -374,7 +416,8 @@ function composeAiReviewMarkdown({
374
416
  (finding.userImpact ? ` user_impact: ${finding.userImpact}\n` : "") +
375
417
  (finding.trafficLight ? ` traffic_light: ${finding.trafficLight}\n` : "") +
376
418
  ` suggested_fix: ${finding.suggestedFix}` +
377
- (finding.confidence === null ? "" : `\n confidence: ${finding.confidence.toFixed(2)}`)
419
+ (finding.confidence === null ? "" : `\n confidence: ${finding.confidence.toFixed(2)}`) +
420
+ (finding.severityClassification ? `\n severity_evaluation: ${finding.severityClassification.status} / ${finding.severityClassification.reason}; candidate=${finding.severityClassification.severity || "unverified"}; category=${finding.severityClassification.category || "unverified"}; authority=evaluation_only` : "")
378
421
  )
379
422
  .join("\n")
380
423
  : "- none";
@@ -389,12 +432,14 @@ function composeAiReviewMarkdown({
389
432
  `Model: ${model}`,
390
433
  `Dry run: ${dryRun ? "yes" : "no"}`,
391
434
  `Parser: ${parser}`,
435
+ `Structured review: ${reviewValidity?.valid ? "valid" : "incomplete"} (${reviewValidity?.reason || "unknown"})`,
392
436
  "",
437
+ ...(severityClassification ? [`TypeSafe classification evaluation: ${severityClassification.status} / ${severityClassification.reason}; schema validation only, original gate severities unchanged.`, `Classifier usage: ${severityClassification.usage ? JSON.stringify(severityClassification.usage) : "unknown/not submitted (not zero cost)"}; attempted=${severityClassification.attempted}`, ""] : []),
393
438
  "Summary:",
394
439
  `- ${summary || "No summary provided."}`,
395
440
  `- Combined findings: P0=${combinedSummary.P0} P1=${combinedSummary.P1} P2=${combinedSummary.P2} P3=${combinedSummary.P3}`,
396
441
  `- Blocking: ${combinedSummary.blocking ? "yes" : "no"}`,
397
- `- Usage: input_tokens=${usage.inputTokens} output_tokens=${usage.outputTokens} cost_usd=${usage.costUsd.toFixed(6)} duration_ms=${usage.durationMs}`,
442
+ `- ${severityClassification ? "Generation usage" : "Usage"}: input_tokens=${usage.inputTokens} output_tokens=${usage.outputTokens} cost_usd=${usage.costUsd.toFixed(6)} duration_ms=${usage.durationMs}`,
398
443
  "",
399
444
  "AI Findings:",
400
445
  findingLines,
@@ -486,6 +531,7 @@ export async function runAiReviewLayer({
486
531
  warningThresholdPercent = 80,
487
532
  systemPrompt = "",
488
533
  dryRun = false,
534
+ severityClassifier = "off",
489
535
  requireUsageLedger = false,
490
536
  usageRecorder = recordSessionUsage,
491
537
  sourceCommand = "review",
@@ -496,8 +542,11 @@ export async function runAiReviewLayer({
496
542
  usageFailureLabel = "Review AI",
497
543
  env = process.env,
498
544
  } = {}) {
545
+ normalizeSeverityClassifier(severityClassifier);
546
+ if (severityClassifier === "typesafe") maxCostUsd = parseSeverityEvaluationMaxCost(maxCostUsd, 1);
499
547
  const normalizedTargetPath = path.resolve(String(targetPath || "."));
500
548
  const normalizedRunDirectory = path.resolve(String(runDirectory || "."));
549
+ const { outputFs } = await resolveOutputContext({ cwd: normalizedTargetPath, outputDirOverride: outputDir, env });
501
550
  const normalizedMode = normalizeString(mode) || "full";
502
551
  const normalizedMaxFindings = Math.max(
503
552
  1,
@@ -624,7 +673,7 @@ export async function runAiReviewLayer({
624
673
  noProgressStreak: 0,
625
674
  };
626
675
 
627
- const budget = evaluateBudget({
676
+ let budget = evaluateBudget({
628
677
  sessionSummary,
629
678
  maxCostUsd: parseNonNegativeNumber(maxCostUsd, "maxCostUsd"),
630
679
  maxOutputTokens: parseNonNegativeNumber(maxOutputTokens, "maxOutputTokens"),
@@ -718,6 +767,51 @@ export async function runAiReviewLayer({
718
767
  }
719
768
  }
720
769
 
770
+ let severityClassification = null;
771
+ if (severityClassifier === "typesafe") {
772
+ const historyOptions = { targetPath: normalizedTargetPath, outputDirOverride: outputDir };
773
+ severityClassification = await withSeverityEvaluationQueue(`${appendedCost.filePath}:${normalizedSessionId}`, async () => {
774
+ let reservation = false; const attemptId = crypto.randomUUID();
775
+ const event = (phase, metadata) => appendRunEvent(historyOptions, {
776
+ sessionId: normalizedSessionId, runId: normalizedRunId,
777
+ eventType: phase === "reserved" ? "tool_call" : "run_step",
778
+ metadata: { layer: "severity_classification_evaluation", version: 1, attemptId, phase, costKnown: false, outputTokensKnown: false, ...metadata },
779
+ });
780
+ let classifierBudget = budget;
781
+ try {
782
+ const [{ events }, current] = await Promise.all([loadRunEvents(historyOptions), loadCostHistory(historyOptions)]);
783
+ const baseline = summarizeCostHistory(current.history).sessions.find((r) => r.sessionId === normalizedSessionId) || sessionSummary;
784
+ const extra = severityResourceTotals(events, normalizedSessionId);
785
+ classifierBudget = evaluateBudget({ ...budget.limits, sessionSummary: { ...baseline, toolCalls: baseline.toolCalls + extra.toolCalls, durationMs: baseline.durationMs + extra.durationMs } });
786
+ if (extra.unknownRuntime && budget.limits.maxRuntimeMs > 0) return { version: 1, mode: "typesafe", authority: "evaluation_only", verification: "response_schema_only", status: "unverified", reason: "prior_runtime_unknown", requestedModel: TYPESAFE_SEVERITY_MODEL, dictionaryVersion: SEVERITY_DICTIONARY_VERSION, attempted: false, durationMs: 0, usage: null, findings: parsed.findings.map(() => ({ status: "unverified", reason: "prior_runtime_unknown", severity: null, category: null })) };
787
+ } catch {
788
+ return { version: 1, mode: "typesafe", authority: "evaluation_only", status: "unverified", reason: "accounting_unavailable", requestedModel: TYPESAFE_SEVERITY_MODEL, dictionaryVersion: SEVERITY_DICTIONARY_VERSION, attempted: false, durationMs: 0, usage: null, findings: parsed.findings.map(() => ({ status: "unverified", reason: "accounting_unavailable", severity: null, category: null })) };
789
+ }
790
+ let evaluated = await evaluateSeverityClassification({
791
+ mode: severityClassifier, findings: parsed.findings, env, dryRun,
792
+ requireUsageLedger: normalizedRequireUsageLedger, budget: classifierBudget,
793
+ beforeRequest: async () => { await event("reserved", { reservedRequests: 1 }); reservation = true; },
794
+ });
795
+ if (reservation) {
796
+ try { await event("completed", { actualRequests: evaluated.attempted ? 1 : 0, durationMs: evaluated.durationMs, status: evaluated.status, reason: evaluated.reason, costKnown: false, outputTokensKnown: Boolean(evaluated.usage), reportedUsage: evaluated.usage }); }
797
+ catch { evaluated = { ...evaluated, status: "unverified", reason: "accounting_unavailable" }; }
798
+ }
799
+ const resourcesAfter = evaluateBudget({ ...classifierBudget.limits, sessionSummary: {
800
+ costUsd: classifierBudget.usage.costUsd + (evaluated.usage?.listPriceEstimateUsd || 0),
801
+ outputTokens: classifierBudget.usage.outputTokens + (evaluated.usage?.outputTokens || 0),
802
+ durationMs: classifierBudget.usage.runtimeMs + (reservation ? evaluated.durationMs : 0),
803
+ toolCalls: classifierBudget.usage.toolCalls + (reservation ? 1 : 0),
804
+ noProgressStreak: classifierBudget.usage.noProgressStreak,
805
+ } });
806
+ if (evaluated.attempted && !evaluated.usage) resourcesAfter.classificationUsageUnknown = true;
807
+ return { ...evaluated, resourceAccounting: { reservedRequests: reservation ? 1 : 0, actualRequests: evaluated.attempted ? 1 : 0, durationMs: evaluated.durationMs, budgetAfter: resourcesAfter } };
808
+ });
809
+ if (severityClassification.resourceAccounting?.budgetAfter) budget = severityClassification.resourceAccounting.budgetAfter;
810
+ aiFindings.forEach((finding, index) => {
811
+ finding.severityClassification = { ...severityClassification.findings[index], authority: "evaluation_only", dictionaryVersion: severityClassification.dictionaryVersion, model: severityClassification.requestedModel };
812
+ });
813
+ }
814
+
721
815
  let stopTelemetry = null;
722
816
  if (budget.blocking) {
723
817
  stopTelemetry = await appendRunEvent(
@@ -755,7 +849,7 @@ export async function runAiReviewLayer({
755
849
  );
756
850
  }
757
851
 
758
- await fsp.mkdir(normalizedRunDirectory, { recursive: true });
852
+ await outputFs.mkdir(normalizedRunDirectory, { recursive: true });
759
853
  const promptPath = path.join(normalizedRunDirectory, "REVIEW_AI_PROMPT.txt");
760
854
  const reportMarkdownPath = path.join(normalizedRunDirectory, "REVIEW_AI.md");
761
855
  const reportJsonPath = path.join(normalizedRunDirectory, "REVIEW_AI.json");
@@ -773,6 +867,7 @@ export async function runAiReviewLayer({
773
867
  runId: normalizedRunId,
774
868
  mode: normalizedMode,
775
869
  parser: parsed.parser,
870
+ reviewValidity: parsed.reviewValidity,
776
871
  summary: parsed.summary,
777
872
  provider: resolvedProvider,
778
873
  model: resolvedModel,
@@ -786,6 +881,7 @@ export async function runAiReviewLayer({
786
881
  aiSummary,
787
882
  combinedSummary,
788
883
  findings: aiFindings,
884
+ ...(severityClassification ? { severityClassification } : {}),
789
885
  };
790
886
 
791
887
  const reportMarkdown = composeAiReviewMarkdown({
@@ -793,22 +889,26 @@ export async function runAiReviewLayer({
793
889
  runId: normalizedRunId,
794
890
  mode: normalizedMode,
795
891
  parser: parsed.parser,
892
+ reviewValidity: parsed.reviewValidity,
796
893
  summary: parsed.summary,
797
894
  provider: resolvedProvider,
798
895
  model: resolvedModel,
799
896
  dryRun: Boolean(dryRun),
800
897
  findings: aiFindings,
801
898
  usage,
899
+ severityClassification,
802
900
  combinedSummary,
803
901
  });
804
902
 
805
- await fsp.writeFile(promptPath, `${prompt}\n`, "utf-8");
806
- await fsp.writeFile(reportMarkdownPath, `${reportMarkdown.trim()}\n`, "utf-8");
807
- await fsp.writeFile(reportJsonPath, `${JSON.stringify(reportPayload, null, 2)}\n`, "utf-8");
903
+ await outputFs.writeFile(promptPath, `${prompt}\n`, "utf-8");
904
+ await outputFs.writeFile(reportMarkdownPath, `${reportMarkdown.trim()}\n`, "utf-8");
905
+ await outputFs.writeFile(reportJsonPath, `${JSON.stringify(reportPayload, null, 2)}\n`, "utf-8");
808
906
 
809
907
  return {
810
908
  parser: parsed.parser,
909
+ reviewValidity: parsed.reviewValidity,
811
910
  summary: parsed.summary,
911
+ ...(severityClassification ? { severityClassification } : {}),
812
912
  findings: aiFindings,
813
913
  aiSummary,
814
914
  combinedSummary,
@@ -7,10 +7,10 @@
7
7
  * writes an explicit evidence-gap artifact bundle.
8
8
  */
9
9
 
10
- import fsp from "node:fs/promises";
11
10
  import path from "node:path";
12
11
  import crypto from "node:crypto";
13
12
  import { spawnSync } from "node:child_process";
13
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
14
14
 
15
15
  import { recordProvisionedIdentity } from "../ai/identity-store.js";
16
16
  import { runDevTestBotSession } from "../agents/devtestbot/tool.js";
@@ -27,9 +27,10 @@ export const DEVTESTBOT_PHASE_DEFAULT_SCOPE = "smoke";
27
27
  export const DEVTESTBOT_PHASE_DEFAULT_SWARMS = 1;
28
28
  export const DEVTESTBOT_PHASE_DEFAULT_PER_SWARM_BUDGET_USD = 0.25;
29
29
 
30
- async function writeJson(filePath, obj) {
31
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
32
- await fsp.writeFile(filePath, JSON.stringify(obj, null, 2), "utf-8");
30
+ async function writeJsonWithFs(outputFs, filePath, obj) {
31
+ requireOutputFileSystem(outputFs);
32
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
33
+ await outputFs.writeFile(filePath, JSON.stringify(obj, null, 2), "utf-8");
33
34
  }
34
35
 
35
36
  function normalizeString(value) {
@@ -347,14 +348,17 @@ function makeSyntheticIdentityResponse({ runId, index }) {
347
348
  }
348
349
 
349
350
  export async function provisionDevTestBotIdentities({
351
+ outputFs,
350
352
  outputRoot,
351
353
  runId,
352
354
  count,
353
355
  provisionIdentity = null,
354
356
  onEvent = () => {},
355
357
  } = {}) {
358
+ requireOutputFileSystem(outputFs);
356
359
  if (!outputRoot) throw new TypeError("provisionDevTestBotIdentities requires outputRoot");
357
360
  if (!runId) throw new TypeError("provisionDevTestBotIdentities requires runId");
361
+ await outputFs.assertPath(outputRoot);
358
362
  const total = clampInt(count, { min: 0, max: DEVTESTBOT_PHASE_MAX_CONCURRENT, fallback: 0 });
359
363
  const identities = [];
360
364
 
@@ -378,6 +382,7 @@ export async function provisionDevTestBotIdentities({
378
382
  response = makeSyntheticIdentityResponse({ runId, index });
379
383
  }
380
384
  const recorded = await recordProvisionedIdentity({
385
+ outputFs,
381
386
  outputRoot,
382
387
  response,
383
388
  context: {
@@ -459,6 +464,7 @@ function safeErrorMessage(error) {
459
464
  }
460
465
 
461
466
  export async function runDevTestBotPhase({
467
+ outputFs,
462
468
  runId,
463
469
  rootPath,
464
470
  outputRoot,
@@ -470,11 +476,15 @@ export async function runDevTestBotPhase({
470
476
  options = {},
471
477
  onEvent = () => {},
472
478
  } = {}) {
479
+ requireOutputFileSystem(outputFs);
480
+ const writeJson = (...args) => writeJsonWithFs(outputFs, ...args);
473
481
  if (!runId) throw new TypeError("runDevTestBotPhase requires runId");
474
482
  if (!rootPath) throw new TypeError("runDevTestBotPhase requires rootPath");
475
483
  if (!outputRoot) throw new TypeError("runDevTestBotPhase requires outputRoot");
476
484
  if (!runRoot) throw new TypeError("runDevTestBotPhase requires runRoot");
477
485
  if (!artifactDir) throw new TypeError("runDevTestBotPhase requires artifactDir");
486
+ const phaseRoot = path.join(runRoot, "devtestbot");
487
+ await Promise.all([outputRoot, phaseRoot, artifactDir].map((destination) => outputFs.assertPath(destination)));
478
488
 
479
489
  const normalized = normalizePhaseOptions(options);
480
490
  const phaseStartedAt = Date.now();
@@ -485,8 +495,7 @@ export async function runDevTestBotPhase({
485
495
  budget,
486
496
  options: normalized,
487
497
  });
488
- const phaseRoot = path.join(runRoot, "devtestbot");
489
- await fsp.mkdir(phaseRoot, { recursive: true });
498
+ await outputFs.mkdir(phaseRoot, { recursive: true });
490
499
 
491
500
  onEvent({
492
501
  type: "phase_start",
@@ -551,6 +560,7 @@ export async function runDevTestBotPhase({
551
560
  }
552
561
 
553
562
  const identities = await provisionDevTestBotIdentities({
563
+ outputFs,
554
564
  outputRoot,
555
565
  runId,
556
566
  count: plan.identityCount,
@@ -612,6 +622,7 @@ export async function runDevTestBotPhase({
612
622
  execute: plan.execute,
613
623
  recordVideo: plan.recordVideo,
614
624
  }, {
625
+ outputFs,
615
626
  targetPath: rootPath,
616
627
  outputRoot,
617
628
  runId,
@@ -22,6 +22,8 @@
22
22
  import fsp from "node:fs/promises";
23
23
  import path from "node:path";
24
24
  import crypto from "node:crypto";
25
+ import { resolveOutputContext } from "../config/service.js";
26
+ import { requireOutputFileSystem } from "../config/output-filesystem.js";
25
27
 
26
28
  import { routeFilesToPersonas, summarizeRouting } from "./investor-dd-file-router.js";
27
29
  import { runAllPersonas } from "./investor-dd-persona-runner.js";
@@ -160,9 +162,10 @@ async function collectFileMetrics(rootPath, files) {
160
162
  return metrics;
161
163
  }
162
164
 
163
- async function writeJson(filePath, obj) {
164
- await fsp.mkdir(path.dirname(filePath), { recursive: true });
165
- await fsp.writeFile(filePath, JSON.stringify(obj, null, 2), "utf-8");
165
+ async function writeJsonWithFs(outputFs, filePath, obj) {
166
+ requireOutputFileSystem(outputFs);
167
+ await outputFs.mkdir(path.dirname(filePath), { recursive: true });
168
+ await outputFs.writeFile(filePath, JSON.stringify(obj, null, 2), "utf-8");
166
169
  }
167
170
 
168
171
  function sha256(text) {
@@ -341,17 +344,20 @@ export async function runInvestorDd({
341
344
 
342
345
  const runId = `investor-dd-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
343
346
  const resolvedBudget = resolveInvestorDdBudget(budgetOptions);
344
- const artifactBase = outputDir
345
- ? path.resolve(outputDir, runId, INVESTOR_DD_ARTIFACT_SUBDIR)
346
- : path.resolve(rootPath, ".sentinelayer", "runs", runId, INVESTOR_DD_ARTIFACT_SUBDIR);
347
+ const explicitOutputDir = String(outputDir || "").trim();
348
+ const { outputRoot, outputFs } = await resolveOutputContext({
349
+ cwd: rootPath,
350
+ outputDirOverride: explicitOutputDir || ".sentinelayer",
351
+ });
352
+ const writeJson = (...args) => writeJsonWithFs(outputFs, ...args);
353
+ const artifactBase = explicitOutputDir
354
+ ? path.join(outputRoot, runId, INVESTOR_DD_ARTIFACT_SUBDIR)
355
+ : path.join(outputRoot, "runs", runId, INVESTOR_DD_ARTIFACT_SUBDIR);
347
356
  const runRoot = path.dirname(artifactBase);
348
- const outputRoot = outputDir
349
- ? path.resolve(outputDir)
350
- : path.resolve(rootPath, ".sentinelayer");
351
- await fsp.mkdir(artifactBase, { recursive: true });
357
+ await outputFs.mkdir(artifactBase, { recursive: true });
352
358
 
353
359
  const streamPath = path.join(artifactBase, "stream.ndjson");
354
- const streamHandle = await fsp.open(streamPath, "w");
360
+ const streamHandle = await outputFs.open(streamPath, "w");
355
361
  let streamClosePromise = null;
356
362
 
357
363
  const closeStream = () => {
@@ -410,6 +416,7 @@ export async function runInvestorDd({
410
416
  maxRuntimeMs: resolvedBudget.maxRuntimeMinutes * 60_000,
411
417
  });
412
418
  const runResult = await runAllPersonas({
419
+ outputFs,
413
420
  routing,
414
421
  rootPath,
415
422
  budget: budgetState,
@@ -434,6 +441,7 @@ export async function runInvestorDd({
434
441
  });
435
442
 
436
443
  devTestBotPhase = await runDevTestBotPhase({
444
+ outputFs,
437
445
  runId,
438
446
  rootPath,
439
447
  outputRoot,
@@ -539,7 +547,7 @@ export async function runInvestorDd({
539
547
 
540
548
  const markdown = buildSummaryMarkdown({ runId, summary, routing, byPersona });
541
549
  const reportPath = path.join(artifactBase, "report.md");
542
- await fsp.writeFile(reportPath, markdown, "utf-8");
550
+ await outputFs.writeFile(reportPath, markdown, "utf-8");
543
551
 
544
552
  const htmlReport = renderInvestorDdHtml({
545
553
  runId,
@@ -549,7 +557,7 @@ export async function runInvestorDd({
549
557
  findings,
550
558
  compliance: compliance ? compliance.packs : null,
551
559
  });
552
- await fsp.writeFile(path.join(artifactBase, "report.html"), htmlReport, "utf-8");
560
+ await outputFs.writeFile(path.join(artifactBase, "report.html"), htmlReport, "utf-8");
553
561
 
554
562
  emit({
555
563
  type: "investor_dd_complete",
@@ -4,7 +4,8 @@ import fsp from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
6
  import { buildScrubbedEnv } from "../agents/shared-tools/shell.js";
7
- import { resolveOutputRoot } from "../config/service.js";
7
+ import { resolveOutputContext } from "../config/service.js";
8
+ import { bindOutputFileSystem, requireOutputFileSystem } from "../config/output-filesystem.js";
8
9
  import { resolveCodebaseIngest } from "../ingest/engine.js";
9
10
  import { runSpecBindingChecks } from "./spec-binding.js";
10
11
 
@@ -852,9 +853,10 @@ export function staticCheckEnv(env = process.env) {
852
853
  return { ...buildScrubbedEnv(env), CI: "1", FORCE_COLOR: "0" };
853
854
  }
854
855
 
855
- async function executeStaticCheck({ check, targetPath, runDir } = {}) {
856
+ async function executeStaticCheck({ check, targetPath, runDir, outputFs } = {}) {
857
+ requireOutputFileSystem(outputFs);
856
858
  const checksDir = path.join(runDir, "checks");
857
- await fsp.mkdir(checksDir, { recursive: true });
859
+ await outputFs.mkdir(checksDir, { recursive: true });
858
860
 
859
861
  const startedAt = Date.now();
860
862
  const result = spawnSync(check.command, check.args, {
@@ -869,8 +871,8 @@ async function executeStaticCheck({ check, targetPath, runDir } = {}) {
869
871
  const stderr = String(result.stderr || "");
870
872
  const stdoutPath = path.join(checksDir, `${check.id}.stdout.log`);
871
873
  const stderrPath = path.join(checksDir, `${check.id}.stderr.log`);
872
- await fsp.writeFile(stdoutPath, stdout, "utf-8");
873
- await fsp.writeFile(stderrPath, stderr, "utf-8");
874
+ await outputFs.writeFile(stdoutPath, stdout, "utf-8");
875
+ await outputFs.writeFile(stderrPath, stderr, "utf-8");
874
876
 
875
877
  if (result.error && result.error.code === "ENOENT") {
876
878
  return {
@@ -933,7 +935,8 @@ async function executeStaticCheck({ check, targetPath, runDir } = {}) {
933
935
 
934
936
  export const __localReviewForTests = Object.freeze({ executeStaticCheck });
935
937
 
936
- async function runStaticAnalysisLayer({ targetPath, ingest, runDir, maxFindings = MAX_FINDINGS } = {}) {
938
+ async function runStaticAnalysisLayer({ targetPath, ingest, runDir, outputFs, maxFindings = MAX_FINDINGS } = {}) {
939
+ requireOutputFileSystem(outputFs);
937
940
  const checks = buildStaticChecks(ingest);
938
941
  const results = [];
939
942
  const findings = [];
@@ -943,6 +946,7 @@ async function runStaticAnalysisLayer({ targetPath, ingest, runDir, maxFindings
943
946
  check,
944
947
  targetPath,
945
948
  runDir,
949
+ outputFs,
946
950
  });
947
951
  results.push(result);
948
952
 
@@ -1145,12 +1149,13 @@ ${formatDeterministicFindingsMarkdown(result.findings)}
1145
1149
  `;
1146
1150
  }
1147
1151
 
1148
- async function writeDeterministicReviewArtifacts({ result, runDir } = {}) {
1152
+ async function writeDeterministicReviewArtifacts({ result, runDir, outputFs } = {}) {
1153
+ requireOutputFileSystem(outputFs);
1149
1154
  const markdownPath = path.join(runDir, "REVIEW_DETERMINISTIC.md");
1150
1155
  const jsonPath = path.join(runDir, "REVIEW_DETERMINISTIC.json");
1151
1156
 
1152
- await fsp.writeFile(markdownPath, `${buildDeterministicReviewMarkdown(result).trim()}\n`, "utf-8");
1153
- await fsp.writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
1157
+ await outputFs.writeFile(markdownPath, `${buildDeterministicReviewMarkdown(result).trim()}\n`, "utf-8");
1158
+ await outputFs.writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
1154
1159
 
1155
1160
  return {
1156
1161
  markdownPath,
@@ -1159,6 +1164,7 @@ async function writeDeterministicReviewArtifacts({ result, runDir } = {}) {
1159
1164
  }
1160
1165
 
1161
1166
  export async function runDeterministicReviewPipeline({
1167
+ outputFs: inheritedOutputFs,
1162
1168
  targetPath,
1163
1169
  mode = "full",
1164
1170
  outputDir = "",
@@ -1170,17 +1176,19 @@ export async function runDeterministicReviewPipeline({
1170
1176
  allowedModes: ["full", "diff", "staged"],
1171
1177
  });
1172
1178
 
1173
- const outputRoot = await resolveOutputRoot({
1179
+ const { outputRoot, outputFs: resolvedOutputFs } = await resolveOutputContext({
1174
1180
  cwd: normalizedTargetPath,
1175
1181
  outputDirOverride: outputDir,
1176
1182
  });
1183
+ const outputFs = inheritedOutputFs === undefined ? resolvedOutputFs : requireOutputFileSystem(inheritedOutputFs);
1177
1184
  const runId = `review-${formatTimestampForFile()}-${randomUUID().slice(0, 8)}`;
1178
1185
  const runDir = path.join(outputRoot, "reviews", runId);
1179
- await fsp.mkdir(runDir, { recursive: true });
1186
+ await outputFs.mkdir(runDir, { recursive: true });
1180
1187
 
1181
1188
  const ingestResolution = await resolveCodebaseIngest({
1182
1189
  rootPath: normalizedTargetPath,
1183
1190
  outputDir,
1191
+ outputFs,
1184
1192
  refresh: Boolean(refreshIngest),
1185
1193
  });
1186
1194
  const ingest = ingestResolution.ingest;
@@ -1221,6 +1229,7 @@ export async function runDeterministicReviewPipeline({
1221
1229
  targetPath: normalizedTargetPath,
1222
1230
  ingest,
1223
1231
  runDir,
1232
+ outputFs,
1224
1233
  maxFindings: remainingBudget,
1225
1234
  });
1226
1235
 
@@ -1302,14 +1311,15 @@ export async function runDeterministicReviewPipeline({
1302
1311
  const artifacts = await writeDeterministicReviewArtifacts({
1303
1312
  result,
1304
1313
  runDir,
1314
+ outputFs,
1305
1315
  });
1306
1316
 
1307
1317
  return {
1308
1318
  ...result,
1309
- artifacts: {
1319
+ artifacts: bindOutputFileSystem({
1310
1320
  runDirectory: runDir,
1311
1321
  ...artifacts,
1312
- },
1322
+ }, outputFs),
1313
1323
  };
1314
1324
  }
1315
1325
 
@@ -1363,13 +1373,13 @@ export async function writeReviewReport({
1363
1373
  outputDir = "",
1364
1374
  reportMarkdown,
1365
1375
  } = {}) {
1366
- const outputRoot = await resolveOutputRoot({
1376
+ const { outputRoot, outputFs } = await resolveOutputContext({
1367
1377
  cwd: targetPath,
1368
1378
  outputDirOverride: outputDir,
1369
1379
  });
1370
1380
  const reportDir = path.join(outputRoot, "reports");
1371
- await fsp.mkdir(reportDir, { recursive: true });
1381
+ await outputFs.mkdir(reportDir, { recursive: true });
1372
1382
  const reportPath = path.join(reportDir, `review-scan-${mode}-${formatTimestampForFile()}.md`);
1373
- await fsp.writeFile(reportPath, `${String(reportMarkdown || "").trim()}\n`, "utf-8");
1383
+ await outputFs.writeFile(reportPath, `${String(reportMarkdown || "").trim()}\n`, "utf-8");
1374
1384
  return reportPath;
1375
1385
  }