auditor-lambda 0.2.8 → 0.2.9

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 (98) hide show
  1. package/README.md +6 -0
  2. package/audit-code-wrapper-lib.mjs +1 -1
  3. package/dist/adapters/eslint.js +9 -5
  4. package/dist/cli.d.ts +42 -1
  5. package/dist/cli.js +114 -64
  6. package/dist/extractors/bucketing.d.ts +4 -0
  7. package/dist/extractors/bucketing.js +6 -2
  8. package/dist/extractors/disposition.d.ts +4 -0
  9. package/dist/extractors/disposition.js +6 -2
  10. package/dist/extractors/fileInventory.js +24 -28
  11. package/dist/extractors/flows.d.ts +5 -0
  12. package/dist/extractors/flows.js +18 -38
  13. package/dist/extractors/pathPatterns.d.ts +10 -3
  14. package/dist/extractors/pathPatterns.js +109 -61
  15. package/dist/extractors/surfaces.d.ts +4 -0
  16. package/dist/extractors/surfaces.js +11 -11
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +2 -1
  19. package/dist/io/artifacts.d.ts +55 -40
  20. package/dist/io/artifacts.js +73 -110
  21. package/dist/io/json.js +52 -21
  22. package/dist/io/runArtifacts.d.ts +1 -1
  23. package/dist/io/runArtifacts.js +26 -3
  24. package/dist/orchestrator/advance.js +83 -62
  25. package/dist/orchestrator/flowCoverage.js +11 -5
  26. package/dist/orchestrator/flowPlanning.d.ts +7 -2
  27. package/dist/orchestrator/flowPlanning.js +46 -21
  28. package/dist/orchestrator/flowRequeue.js +28 -8
  29. package/dist/orchestrator/internalExecutors.js +12 -8
  30. package/dist/orchestrator/planning.js +25 -3
  31. package/dist/orchestrator/requeue.js +11 -1
  32. package/dist/orchestrator/taskBuilder.d.ts +4 -2
  33. package/dist/orchestrator/taskBuilder.js +153 -52
  34. package/dist/orchestrator/unitBuilder.d.ts +3 -1
  35. package/dist/orchestrator/unitBuilder.js +24 -16
  36. package/dist/prompts/renderWorkerPrompt.d.ts +1 -1
  37. package/dist/prompts/renderWorkerPrompt.js +16 -8
  38. package/dist/providers/claudeCodeProvider.d.ts +4 -1
  39. package/dist/providers/claudeCodeProvider.js +8 -5
  40. package/dist/providers/localSubprocessProvider.d.ts +4 -0
  41. package/dist/providers/localSubprocessProvider.js +7 -2
  42. package/dist/providers/spawnLoggedCommand.d.ts +9 -1
  43. package/dist/providers/spawnLoggedCommand.js +77 -29
  44. package/dist/reporting/synthesis.d.ts +2 -0
  45. package/dist/reporting/synthesis.js +12 -9
  46. package/dist/supervisor/operatorHandoff.js +48 -18
  47. package/dist/supervisor/runLedger.d.ts +1 -1
  48. package/dist/supervisor/runLedger.js +112 -5
  49. package/dist/supervisor/sessionConfig.js +10 -10
  50. package/dist/types/externalAnalyzer.d.ts +3 -0
  51. package/dist/types/flowCoverage.d.ts +5 -1
  52. package/dist/types/flowCoverage.js +5 -1
  53. package/dist/types/flows.d.ts +5 -1
  54. package/dist/types/flows.js +1 -1
  55. package/dist/types/runLedger.d.ts +5 -1
  56. package/dist/types/runLedger.js +6 -1
  57. package/dist/types/runtimeValidation.d.ts +12 -3
  58. package/dist/types/runtimeValidation.js +16 -1
  59. package/dist/types/sessionConfig.d.ts +15 -2
  60. package/dist/types/sessionConfig.js +15 -1
  61. package/dist/types/surfaces.d.ts +4 -1
  62. package/dist/types/surfaces.js +1 -1
  63. package/dist/types/workerSession.d.ts +9 -0
  64. package/dist/types/workerSession.js +5 -1
  65. package/dist/validation/artifacts.d.ts +1 -1
  66. package/dist/validation/artifacts.js +33 -20
  67. package/dist/validation/auditResults.d.ts +2 -2
  68. package/dist/validation/auditResults.js +7 -15
  69. package/dist/validation/basic.d.ts +9 -1
  70. package/dist/validation/basic.js +40 -3
  71. package/dist/validation/sessionConfig.d.ts +4 -2
  72. package/dist/validation/sessionConfig.js +62 -15
  73. package/docs/agent-integrations.md +29 -9
  74. package/docs/next-steps.md +21 -4
  75. package/docs/packaging.md +14 -0
  76. package/docs/product-direction.md +22 -0
  77. package/docs/production-launch-bar.md +2 -0
  78. package/docs/releasing.md +17 -0
  79. package/docs/remediation-baseline.md +75 -0
  80. package/docs/run-flow.md +23 -11
  81. package/docs/session-config.md +50 -5
  82. package/docs/supervisor.md +7 -0
  83. package/docs/workflow-refactor-brief.md +177 -0
  84. package/package.json +1 -1
  85. package/schemas/audit_result.schema.json +4 -1
  86. package/schemas/audit_task.schema.json +3 -1
  87. package/schemas/coverage_matrix.schema.json +3 -3
  88. package/schemas/critical_flows.schema.json +6 -2
  89. package/schemas/file_disposition.schema.json +2 -2
  90. package/schemas/finding.schema.json +9 -4
  91. package/schemas/flow_coverage.schema.json +2 -2
  92. package/schemas/repo_manifest.schema.json +4 -4
  93. package/schemas/risk_register.schema.json +2 -2
  94. package/schemas/runtime_validation_report.schema.json +2 -2
  95. package/schemas/runtime_validation_tasks.schema.json +8 -2
  96. package/schemas/surface_manifest.schema.json +6 -3
  97. package/schemas/unit_manifest.schema.json +3 -2
  98. package/skills/audit-code/SKILL.md +5 -0
@@ -1,8 +1,9 @@
1
- function shellQuote(arg) {
2
- return JSON.stringify(arg);
1
+ import { usesDeferredWorkerCommand, } from "../types/workerSession.js";
2
+ function renderArgv(task) {
3
+ return JSON.stringify(task.worker_command);
3
4
  }
4
5
  export function renderWorkerPrompt(task) {
5
- const command = task.worker_command.map(shellQuote).join(" ");
6
+ const commandArgv = renderArgv(task);
6
7
  if (task.preferred_executor === "agent" && task.audit_results_path) {
7
8
  const tasksPath = task.pending_audit_tasks_path ??
8
9
  `${task.artifacts_dir}/audit_tasks.json`;
@@ -29,14 +30,17 @@ export function renderWorkerPrompt(task) {
29
30
  " Example evidence entry: src/foo.ts:42 - variable overwritten before use",
30
31
  " Optional finding fields: impact, likelihood, reproduction, systemic, related_findings",
31
32
  " Low-priority tasks still require a real review. Use findings: [] only when you genuinely found nothing notable.",
33
+ task.timeout_ms
34
+ ? ` Time budget for this task: ${task.timeout_ms} ms.`
35
+ : " Keep the task bounded to the assigned files only.",
32
36
  `Reference schema: ${task.artifacts_dir}/dispatch/audit-result.schema.json`,
33
37
  `Write the AuditResult[] JSON array to: ${task.audit_results_path}`,
34
38
  ];
35
- if (task.skip_worker_command) {
36
- lines.push("", "Stop after writing the results file.");
39
+ if (usesDeferredWorkerCommand(task)) {
40
+ lines.push("", "This run is using deferred worker-command ingestion.", "Do not execute worker_command in this session.", "Stop after writing the results file.");
37
41
  }
38
42
  else {
39
- lines.push("", "Then run this command exactly:", command, "Stop after the command completes.");
43
+ lines.push("", "Then execute the worker_command array from task.json exactly as written.", "Preserve argv boundaries instead of reconstructing shell quoting.", `worker_command argv JSON: ${commandArgv}`, "Stop after the command completes.");
40
44
  }
41
45
  return lines.join("\n");
42
46
  }
@@ -46,10 +50,14 @@ export function renderWorkerPrompt(task) {
46
50
  `Repository root: ${task.repo_root}`,
47
51
  `Obligation: ${task.obligation_id ?? "unknown"}`,
48
52
  `Executor: ${task.preferred_executor}`,
49
- "Execute the following command exactly, without modification:",
50
- command,
53
+ "Execute the worker_command array from task.json exactly as written.",
54
+ "Preserve argv boundaries instead of reconstructing shell quoting.",
55
+ `worker_command argv JSON: ${commandArgv}`,
51
56
  "Do not continue the audit recursively.",
52
57
  "Do not choose another task.",
58
+ task.timeout_ms
59
+ ? `The worker command is budgeted for ${task.timeout_ms} ms.`
60
+ : "If the command hangs or fails, stop and let the supervisor handle it.",
53
61
  `The command must write the worker result JSON to: ${task.result_path}`,
54
62
  "After the command completes, stop.",
55
63
  ].join("\n");
@@ -1,8 +1,11 @@
1
1
  import type { FreshSessionProvider, LaunchFreshSessionInput } from "./types.js";
2
2
  import type { ClaudeCodeConfig } from "../types/sessionConfig.js";
3
+ import { spawnLoggedCommand } from "./spawnLoggedCommand.js";
4
+ export declare const ACTIVE_CLAUDE_CODE_SESSION_MESSAGE: string;
3
5
  export declare class ClaudeCodeProvider implements FreshSessionProvider {
4
6
  name: string;
5
7
  private readonly config;
6
- constructor(config?: ClaudeCodeConfig);
8
+ private readonly launchCommand;
9
+ constructor(config?: ClaudeCodeConfig, launchCommand?: typeof spawnLoggedCommand);
7
10
  launch(input: LaunchFreshSessionInput): Promise<import("./types.js").LaunchFreshSessionResult>;
8
11
  }
@@ -1,16 +1,19 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { spawnLoggedCommand } from "./spawnLoggedCommand.js";
3
+ export const ACTIVE_CLAUDE_CODE_SESSION_MESSAGE = "claude-code provider cannot be used inside an active Claude Code session. " +
4
+ 'Set provider to "local-subprocess" in .audit-artifacts/session-config.json, ' +
5
+ "then run /audit-code conversationally and follow the dispatch prompts manually.";
3
6
  export class ClaudeCodeProvider {
4
7
  name = "claude-code";
5
8
  config;
6
- constructor(config = {}) {
9
+ launchCommand;
10
+ constructor(config = {}, launchCommand = spawnLoggedCommand) {
7
11
  this.config = config;
12
+ this.launchCommand = launchCommand;
8
13
  }
9
14
  async launch(input) {
10
15
  if (process.env.CLAUDECODE) {
11
- throw new Error("claude-code provider cannot be used inside an active Claude Code session. " +
12
- "Set provider to \"local-subprocess\" in .audit-artifacts/session-config.json, " +
13
- "then run /audit-code conversationally and follow the dispatch prompts manually.");
16
+ throw new Error(ACTIVE_CLAUDE_CODE_SESSION_MESSAGE);
14
17
  }
15
18
  const prompt = await readFile(input.promptPath, "utf8");
16
19
  const command = this.config.command ?? "claude";
@@ -20,6 +23,6 @@ export class ClaudeCodeProvider {
20
23
  ...(this.config.extra_args ?? []),
21
24
  "--dangerously-skip-permissions",
22
25
  ];
23
- return await spawnLoggedCommand(command, args, input);
26
+ return await this.launchCommand(command, args, input);
24
27
  }
25
28
  }
@@ -1,5 +1,9 @@
1
1
  import type { FreshSessionProvider, LaunchFreshSessionInput } from "./types.js";
2
+ import { spawnLoggedCommand } from "./spawnLoggedCommand.js";
3
+ export declare const MISSING_WORKER_COMMAND_MESSAGE = "local-subprocess provider requires task.worker_command.";
2
4
  export declare class LocalSubprocessProvider implements FreshSessionProvider {
3
5
  name: string;
6
+ private readonly launchCommand;
7
+ constructor(launchCommand?: typeof spawnLoggedCommand);
4
8
  launch(input: LaunchFreshSessionInput): Promise<import("./types.js").LaunchFreshSessionResult>;
5
9
  }
@@ -1,13 +1,18 @@
1
1
  import { readJsonFile } from "../io/json.js";
2
2
  import { spawnLoggedCommand } from "./spawnLoggedCommand.js";
3
+ export const MISSING_WORKER_COMMAND_MESSAGE = "local-subprocess provider requires task.worker_command.";
3
4
  export class LocalSubprocessProvider {
4
5
  name = "local-subprocess";
6
+ launchCommand;
7
+ constructor(launchCommand = spawnLoggedCommand) {
8
+ this.launchCommand = launchCommand;
9
+ }
5
10
  async launch(input) {
6
11
  const task = await readJsonFile(input.taskPath);
7
12
  if (!task.worker_command.length) {
8
- throw new Error("local-subprocess provider requires task.worker_command.");
13
+ throw new Error(MISSING_WORKER_COMMAND_MESSAGE);
9
14
  }
10
15
  const [command, ...args] = task.worker_command;
11
- return await spawnLoggedCommand(command, args, input);
16
+ return await this.launchCommand(command, args, input);
12
17
  }
13
18
  }
@@ -1,2 +1,10 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { spawn } from "node:child_process";
1
3
  import type { LaunchFreshSessionInput, LaunchFreshSessionResult } from "./types.js";
2
- export declare function spawnLoggedCommand(command: string, args: string[], input: LaunchFreshSessionInput, env?: Record<string, string>): Promise<LaunchFreshSessionResult>;
4
+ interface SpawnLoggedCommandOptions {
5
+ createWriteStream?: typeof createWriteStream;
6
+ spawn?: typeof spawn;
7
+ killGraceMs?: number;
8
+ }
9
+ export declare function spawnLoggedCommand(command: string, args: string[], input: LaunchFreshSessionInput, env?: Record<string, string>, options?: SpawnLoggedCommandOptions): Promise<LaunchFreshSessionResult>;
10
+ export {};
@@ -1,5 +1,8 @@
1
1
  import { createWriteStream } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
+ const TERMINATION_SIGNAL = "SIGTERM";
4
+ const FORCE_KILL_SIGNAL = "SIGKILL";
5
+ const FORCE_KILL_GRACE_MS = 1_000;
3
6
  function tee(write, chunk) {
4
7
  write.write(chunk);
5
8
  }
@@ -7,22 +10,77 @@ function tee(write, chunk) {
7
10
  // does not consult PATH for executables without a shell. Callers should use
8
11
  // `platformCommand()` (scripts/smoke-packaged-audit-code.mjs) or similar to
9
12
  // supply the correct command form for the host OS.
10
- export async function spawnLoggedCommand(command, args, input, env) {
13
+ export async function spawnLoggedCommand(command, args, input, env, options = {}) {
14
+ const openWriteStream = options.createWriteStream ?? createWriteStream;
15
+ const spawnProcess = options.spawn ?? spawn;
16
+ const killGraceMs = options.killGraceMs ?? FORCE_KILL_GRACE_MS;
11
17
  return await new Promise((resolve, reject) => {
12
- const stdoutLog = createWriteStream(input.stdoutPath, { flags: "a" });
13
- const stderrLog = createWriteStream(input.stderrPath, { flags: "a" });
18
+ const stdoutLog = openWriteStream(input.stdoutPath, { flags: "a" });
19
+ const stderrLog = openWriteStream(input.stderrPath, { flags: "a" });
14
20
  const startedAt = Date.now();
15
21
  let timedOut = false;
16
- const child = spawn(command, args, {
17
- cwd: input.repoRoot,
18
- env: { ...process.env, ...env },
19
- stdio: ["ignore", "pipe", "pipe"],
20
- });
21
- const timer = setTimeout(() => {
22
+ let settled = false;
23
+ let child = null;
24
+ let timer;
25
+ let heartbeat;
26
+ let forceKillTimer;
27
+ const cleanup = () => {
28
+ if (timer) {
29
+ clearTimeout(timer);
30
+ }
31
+ if (heartbeat) {
32
+ clearInterval(heartbeat);
33
+ }
34
+ if (forceKillTimer) {
35
+ clearTimeout(forceKillTimer);
36
+ }
37
+ stdoutLog.end();
38
+ stderrLog.end();
39
+ };
40
+ const settle = (callback) => {
41
+ if (settled) {
42
+ return;
43
+ }
44
+ settled = true;
45
+ cleanup();
46
+ callback();
47
+ };
48
+ const fail = (error) => {
49
+ if (child && !child.killed) {
50
+ child.kill(FORCE_KILL_SIGNAL);
51
+ }
52
+ const normalized = error instanceof Error ? error : new Error(String(error));
53
+ settle(() => reject(normalized));
54
+ };
55
+ stdoutLog.on("error", fail);
56
+ stderrLog.on("error", fail);
57
+ let spawnedChild;
58
+ try {
59
+ spawnedChild = spawnProcess(command, args, {
60
+ cwd: input.repoRoot,
61
+ env: { ...process.env, ...env },
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+ child = spawnedChild;
65
+ }
66
+ catch (error) {
67
+ fail(error);
68
+ return;
69
+ }
70
+ if (!spawnedChild.stdout || !spawnedChild.stderr) {
71
+ fail(new Error(`Fresh session spawn for run ${input.runId} did not provide pipe-backed stdout/stderr streams.`));
72
+ return;
73
+ }
74
+ timer = setTimeout(() => {
22
75
  timedOut = true;
23
- child.kill("SIGTERM");
76
+ spawnedChild.kill(TERMINATION_SIGNAL);
77
+ forceKillTimer = setTimeout(() => {
78
+ if (!settled) {
79
+ spawnedChild.kill(FORCE_KILL_SIGNAL);
80
+ }
81
+ }, killGraceMs);
24
82
  }, input.timeoutMs);
25
- const heartbeat = setInterval(() => {
83
+ heartbeat = setInterval(() => {
26
84
  const elapsedMs = Date.now() - startedAt;
27
85
  const message = `[provider] run ${input.runId} still running after ${elapsedMs}ms\n`;
28
86
  tee(stderrLog, message);
@@ -30,40 +88,30 @@ export async function spawnLoggedCommand(command, args, input, env) {
30
88
  process.stderr.write(message);
31
89
  }
32
90
  }, 30_000);
33
- child.stdout.on("data", (chunk) => {
91
+ spawnedChild.stdout.on("data", (chunk) => {
34
92
  tee(stdoutLog, chunk);
35
93
  if (input.uiMode === "visible") {
36
94
  process.stdout.write(chunk);
37
95
  }
38
96
  });
39
- child.stderr.on("data", (chunk) => {
97
+ spawnedChild.stderr.on("data", (chunk) => {
40
98
  tee(stderrLog, chunk);
41
99
  if (input.uiMode === "visible") {
42
100
  process.stderr.write(chunk);
43
101
  }
44
102
  });
45
- child.on("error", (error) => {
46
- clearTimeout(timer);
47
- clearInterval(heartbeat);
48
- stdoutLog.end();
49
- stderrLog.end();
50
- reject(error);
51
- });
52
- child.on("exit", (code, signal) => {
53
- clearTimeout(timer);
54
- clearInterval(heartbeat);
55
- stdoutLog.end();
56
- stderrLog.end();
103
+ spawnedChild.on("error", fail);
104
+ spawnedChild.on("exit", (code, signal) => {
57
105
  if (timedOut) {
58
- reject(new Error(`Fresh session timed out after ${input.timeoutMs}ms for run ${input.runId}.`));
106
+ settle(() => reject(new Error(`Fresh session timed out after ${input.timeoutMs}ms for run ${input.runId}.`)));
59
107
  return;
60
108
  }
61
- resolve({
109
+ settle(() => resolve({
62
110
  accepted: true,
63
- processId: child.pid,
111
+ processId: spawnedChild.pid,
64
112
  exitCode: code,
65
113
  signal,
66
- });
114
+ }));
67
115
  });
68
116
  });
69
117
  }
@@ -1,4 +1,5 @@
1
1
  import type { AuditResult, CoverageMatrix, Finding, UnitManifest } from "../types.js";
2
+ import type { ExternalAnalyzerResults } from "../types/externalAnalyzer.js";
2
3
  import type { CriticalFlowManifest } from "../types/flows.js";
3
4
  import type { GraphBundle } from "../types/graph.js";
4
5
  import type { RuntimeValidationReport } from "../types/runtimeValidation.js";
@@ -23,5 +24,6 @@ export declare function buildAuditReportModel(params: {
23
24
  criticalFlows?: CriticalFlowManifest;
24
25
  coverageMatrix?: CoverageMatrix;
25
26
  runtimeValidationReport?: RuntimeValidationReport;
27
+ externalAnalyzerResults?: ExternalAnalyzerResults;
26
28
  }): AuditReportModel;
27
29
  export declare function renderAuditReportMarkdown(model: AuditReportModel): string;
@@ -1,18 +1,21 @@
1
1
  import { buildWorkBlocks } from "./workBlocks.js";
2
2
  import { mergeFindings } from "./mergeFindings.js";
3
- function severityBreakdown(findings) {
3
+ function countBy(items, selectKey) {
4
4
  const breakdown = {};
5
- for (const finding of findings) {
6
- breakdown[finding.severity] = (breakdown[finding.severity] ?? 0) + 1;
5
+ for (const item of items) {
6
+ const key = selectKey(item);
7
+ if (!key) {
8
+ continue;
9
+ }
10
+ breakdown[key] = (breakdown[key] ?? 0) + 1;
7
11
  }
8
12
  return breakdown;
9
13
  }
14
+ function severityBreakdown(findings) {
15
+ return countBy(findings, (finding) => finding.severity);
16
+ }
10
17
  function runtimeStatusBreakdown(report) {
11
- const breakdown = {};
12
- for (const result of report?.results ?? []) {
13
- breakdown[result.status] = (breakdown[result.status] ?? 0) + 1;
14
- }
15
- return breakdown;
18
+ return countBy(report?.results ?? [], (result) => result.status);
16
19
  }
17
20
  function coverageSummary(coverage) {
18
21
  const files = coverage?.files ?? [];
@@ -29,7 +32,7 @@ function formatSeverityList(summary) {
29
32
  return parts.length > 0 ? parts.join(", ") : "none";
30
33
  }
31
34
  export function buildAuditReportModel(params) {
32
- const findings = mergeFindings(params.results, params.runtimeValidationReport);
35
+ const findings = mergeFindings(params.results, params.runtimeValidationReport, params.externalAnalyzerResults);
33
36
  const workBlocks = buildWorkBlocks({
34
37
  findings,
35
38
  unitManifest: params.unitManifest,
@@ -3,22 +3,52 @@ import { join } from "node:path";
3
3
  import { writeJsonFile } from "../io/json.js";
4
4
  import { LOCAL_SUBPROCESS_PROVIDER_NAME } from "../providers/constants.js";
5
5
  export const CONFIG_ERROR_BLOCKER_PREFIX = "config-error:";
6
- function quoteShellPath(path) {
7
- return `"${path.replace(/"/g, '\\"')}"`;
6
+ const INCOMING_DIRNAME = "incoming";
7
+ const OPERATOR_HANDOFF_JSON_FILENAME = "operator-handoff.json";
8
+ const OPERATOR_HANDOFF_MARKDOWN_FILENAME = "operator-handoff.md";
9
+ const SESSION_CONFIG_FILENAME = "session-config.json";
10
+ const RUN_LEDGER_FILENAME = "run-ledger.json";
11
+ const AUDIT_TASKS_FILENAME = "audit_tasks.json";
12
+ const RUNTIME_VALIDATION_TASKS_FILENAME = "runtime_validation_tasks.json";
13
+ const BLOCKED_STATUS = "blocked";
14
+ const COMPLETE_STATUS = "complete";
15
+ const NOT_STARTED_STATUS = "not_started";
16
+ const NON_PENDING_OBLIGATION_STATES = new Set([
17
+ "present",
18
+ "satisfied",
19
+ ]);
20
+ const INTERACTIVE_PROVIDER_OPTIONS = [
21
+ "auto",
22
+ "claude-code",
23
+ "opencode",
24
+ "subprocess-template",
25
+ "vscode-task",
26
+ ];
27
+ function quoteShellPath(filePath) {
28
+ // The handoff renders a single shell argument, so the snippet only needs
29
+ // double-quote wrapping plus escaping embedded double quotes.
30
+ return `"${filePath.replace(/"/g, '\\"')}"`;
8
31
  }
9
32
  function buildPendingObligations(state) {
10
33
  return state.obligations
11
- .filter((item) => item.state !== "satisfied" && item.state !== "present")
34
+ .filter((item) => !NON_PENDING_OBLIGATION_STATES.has(item.state))
12
35
  .map((item) => item.id);
13
36
  }
37
+ function formatQuotedList(values) {
38
+ if (values.length === 1) {
39
+ return `"${values[0]}"`;
40
+ }
41
+ const head = values.slice(0, -1).map((value) => `"${value}"`).join(", ");
42
+ return `${head}, or "${values[values.length - 1]}"`;
43
+ }
14
44
  function buildSummary(status, providerName, fallbackSummary) {
15
- if (status === "complete") {
45
+ if (status === COMPLETE_STATUS) {
16
46
  return "No operator handoff is required. All known obligations are currently satisfied.";
17
47
  }
18
- if (status === "blocked") {
48
+ if (status === BLOCKED_STATUS) {
19
49
  return fallbackSummary;
20
50
  }
21
- if (status === "not_started") {
51
+ if (status === NOT_STARTED_STATUS) {
22
52
  return "The artifact bundle is not initialized yet. Run the wrapper from the repository root to create the initial audit artifacts.";
23
53
  }
24
54
  return providerName
@@ -26,10 +56,10 @@ function buildSummary(status, providerName, fallbackSummary) {
26
56
  : "Automatic work can continue. Re-run the same wrapper or inspect the listed artifacts if you need operator context.";
27
57
  }
28
58
  function buildSuggestedInputs(artifactsDir, status, isConfigError) {
29
- if (status !== "blocked" || isConfigError) {
59
+ if (status !== BLOCKED_STATUS || isConfigError) {
30
60
  return [];
31
61
  }
32
- const incomingDir = join(artifactsDir, "incoming");
62
+ const incomingDir = join(artifactsDir, INCOMING_DIRNAME);
33
63
  return [
34
64
  {
35
65
  flag: "--results",
@@ -49,20 +79,20 @@ function buildSuggestedInputs(artifactsDir, status, isConfigError) {
49
79
  ];
50
80
  }
51
81
  function buildSuggestedCommands(suggestedInputs, status) {
52
- if (status !== "blocked") {
82
+ if (status !== BLOCKED_STATUS) {
53
83
  return [];
54
84
  }
55
85
  return suggestedInputs.map((item) => `audit-code ${item.flag} ${quoteShellPath(item.suggested_path)}`);
56
86
  }
57
87
  function buildInteractiveProviderHint(status, providerName, sessionConfigPath, isConfigError) {
58
- if (status !== "blocked") {
88
+ if (status !== BLOCKED_STATUS) {
59
89
  return null;
60
90
  }
61
91
  if (isConfigError) {
62
92
  return `A project configuration issue is blocking the audit. Verify that --root points to the repository root containing a project file (package.json, go.mod, etc.), then run audit-code again.`;
63
93
  }
64
94
  const providerLabel = providerName ?? LOCAL_SUBPROCESS_PROVIDER_NAME;
65
- return `Current provider is ${providerLabel}. If you want the backend to continue through an interactive provider instead of importing results manually, set "provider" in ${sessionConfigPath} to "auto", "claude-code", "opencode", "subprocess-template", or "vscode-task", then run audit-code again from the repository root.`;
95
+ return `Current backend worker provider is ${providerLabel}. Remaining semantic review belongs to the active conversation agent by default. If you intentionally want the backend to continue through a compatibility bridge instead, configure ${sessionConfigPath} for ${formatQuotedList(INTERACTIVE_PROVIDER_OPTIONS)} and re-run audit-code with an explicit --provider value from the repository root.`;
66
96
  }
67
97
  function renderMarkdown(handoff) {
68
98
  const lines = [
@@ -115,18 +145,18 @@ function renderMarkdown(handoff) {
115
145
  }
116
146
  export function buildAuditCodeHandoff(params) {
117
147
  const isConfigError = params.isConfigError ?? false;
118
- const incomingDir = join(params.artifactsDir, "incoming");
148
+ const incomingDir = join(params.artifactsDir, INCOMING_DIRNAME);
119
149
  const artifactPaths = {
120
150
  incoming_dir: incomingDir,
121
- operator_handoff_json: join(params.artifactsDir, "operator-handoff.json"),
122
- operator_handoff_markdown: join(params.artifactsDir, "operator-handoff.md"),
123
- session_config: join(params.artifactsDir, "session-config.json"),
124
- run_ledger: join(params.artifactsDir, "run-ledger.json"),
151
+ operator_handoff_json: join(params.artifactsDir, OPERATOR_HANDOFF_JSON_FILENAME),
152
+ operator_handoff_markdown: join(params.artifactsDir, OPERATOR_HANDOFF_MARKDOWN_FILENAME),
153
+ session_config: join(params.artifactsDir, SESSION_CONFIG_FILENAME),
154
+ run_ledger: join(params.artifactsDir, RUN_LEDGER_FILENAME),
125
155
  audit_tasks: params.bundle.audit_tasks
126
- ? join(params.artifactsDir, "audit_tasks.json")
156
+ ? join(params.artifactsDir, AUDIT_TASKS_FILENAME)
127
157
  : null,
128
158
  runtime_validation_tasks: params.bundle.runtime_validation_tasks
129
- ? join(params.artifactsDir, "runtime_validation_tasks.json")
159
+ ? join(params.artifactsDir, RUNTIME_VALIDATION_TASKS_FILENAME)
130
160
  : null,
131
161
  };
132
162
  const suggestedInputs = buildSuggestedInputs(params.artifactsDir, params.state.status, isConfigError);
@@ -1,3 +1,3 @@
1
- import type { RunLedger, RunLedgerEntry } from "../types/runLedger.js";
1
+ import { type RunLedger, type RunLedgerEntry } from "../types/runLedger.js";
2
2
  export declare function loadRunLedger(artifactsDir: string): Promise<RunLedger>;
3
3
  export declare function appendRunLedgerEntry(artifactsDir: string, entry: RunLedgerEntry): Promise<void>;
@@ -1,20 +1,127 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { RUN_LEDGER_STATUSES, } from "../types/runLedger.js";
1
5
  import { isFileMissingError, readJsonFile, writeJsonFile } from "../io/json.js";
6
+ const RUN_LEDGER_FILENAME = "run-ledger.json";
7
+ const RUN_LEDGER_LOCK_FILENAME = "run-ledger.lock";
8
+ const LOCK_RETRY_DELAY_MS = 20;
9
+ const LOCK_RETRY_LIMIT = 100;
10
+ const VALID_RUN_LEDGER_STATUSES = new Set(RUN_LEDGER_STATUSES);
2
11
  function ledgerPath(artifactsDir) {
3
- return `${artifactsDir}/run-ledger.json`;
12
+ return join(artifactsDir, RUN_LEDGER_FILENAME);
13
+ }
14
+ function ledgerLockPath(artifactsDir) {
15
+ return join(artifactsDir, RUN_LEDGER_LOCK_FILENAME);
16
+ }
17
+ function buildTempLedgerPath(artifactsDir) {
18
+ return join(artifactsDir, `${RUN_LEDGER_FILENAME}.${process.pid}.${randomUUID()}.tmp`);
19
+ }
20
+ function isRecord(value) {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+ function isFileExistsError(error) {
24
+ return (typeof error === "object" &&
25
+ error !== null &&
26
+ "code" in error &&
27
+ error.code === "EEXIST");
28
+ }
29
+ function assertRunLedgerEntry(value, fieldPath) {
30
+ if (!isRecord(value)) {
31
+ throw new Error(`Invalid run ledger in ${fieldPath}: expected an object.`);
32
+ }
33
+ const requireString = (field) => {
34
+ const entry = value[field];
35
+ if (typeof entry !== "string" || entry.trim().length === 0) {
36
+ throw new Error(`Invalid run ledger in ${fieldPath}.${field}: expected a non-empty string.`);
37
+ }
38
+ return entry;
39
+ };
40
+ const requireNullableString = (field) => {
41
+ const entry = value[field];
42
+ if (entry === null) {
43
+ return null;
44
+ }
45
+ if (typeof entry !== "string" || entry.trim().length === 0) {
46
+ throw new Error(`Invalid run ledger in ${fieldPath}.${field}: expected a string or null.`);
47
+ }
48
+ return entry;
49
+ };
50
+ const status = value.status;
51
+ if (typeof status !== "string" ||
52
+ !VALID_RUN_LEDGER_STATUSES.has(status)) {
53
+ throw new Error(`Invalid run ledger in ${fieldPath}.status: expected one of ${Array.from(VALID_RUN_LEDGER_STATUSES).join(", ")}.`);
54
+ }
55
+ return {
56
+ run_id: requireString("run_id"),
57
+ provider: requireString("provider"),
58
+ obligation_id: requireNullableString("obligation_id"),
59
+ selected_executor: requireNullableString("selected_executor"),
60
+ status: status,
61
+ started_at: requireString("started_at"),
62
+ ended_at: requireString("ended_at"),
63
+ result_path: requireString("result_path"),
64
+ };
65
+ }
66
+ function parseRunLedger(value, path) {
67
+ if (!isRecord(value)) {
68
+ throw new Error(`Invalid run ledger in ${path}: expected an object.`);
69
+ }
70
+ if (!Array.isArray(value.runs)) {
71
+ throw new Error(`Invalid run ledger in ${path}: expected runs to be an array.`);
72
+ }
73
+ return {
74
+ runs: value.runs.map((entry, index) => assertRunLedgerEntry(entry, `${path}.runs[${index}]`)),
75
+ };
76
+ }
77
+ function sleep(ms) {
78
+ return new Promise((resolve) => setTimeout(resolve, ms));
79
+ }
80
+ async function acquireLedgerLock(artifactsDir) {
81
+ const lockPath = ledgerLockPath(artifactsDir);
82
+ await mkdir(artifactsDir, { recursive: true });
83
+ for (let attempt = 0; attempt < LOCK_RETRY_LIMIT; attempt += 1) {
84
+ try {
85
+ return await open(lockPath, "wx");
86
+ }
87
+ catch (error) {
88
+ if (!isFileExistsError(error)) {
89
+ throw error;
90
+ }
91
+ if (attempt === LOCK_RETRY_LIMIT - 1) {
92
+ throw new Error(`Timed out waiting to update ${ledgerPath(artifactsDir)} because ${lockPath} is locked.`);
93
+ }
94
+ await sleep(LOCK_RETRY_DELAY_MS);
95
+ }
96
+ }
97
+ throw new Error(`Failed to acquire lock for ${ledgerPath(artifactsDir)}.`);
4
98
  }
5
99
  export async function loadRunLedger(artifactsDir) {
100
+ const path = ledgerPath(artifactsDir);
6
101
  try {
7
- return await readJsonFile(ledgerPath(artifactsDir));
102
+ return parseRunLedger(await readJsonFile(path), path);
8
103
  }
9
104
  catch (error) {
10
105
  if (isFileMissingError(error)) {
106
+ // A missing run ledger just means no worker runs have been recorded yet.
11
107
  return { runs: [] };
12
108
  }
13
109
  throw error;
14
110
  }
15
111
  }
16
112
  export async function appendRunLedgerEntry(artifactsDir, entry) {
17
- const ledger = await loadRunLedger(artifactsDir);
18
- ledger.runs.push(entry);
19
- await writeJsonFile(ledgerPath(artifactsDir), ledger);
113
+ const lockHandle = await acquireLedgerLock(artifactsDir);
114
+ const path = ledgerPath(artifactsDir);
115
+ const tempPath = buildTempLedgerPath(artifactsDir);
116
+ try {
117
+ const ledger = await loadRunLedger(artifactsDir);
118
+ ledger.runs.push(entry);
119
+ await writeJsonFile(tempPath, ledger);
120
+ await rename(tempPath, path);
121
+ }
122
+ finally {
123
+ await lockHandle.close();
124
+ await rm(ledgerLockPath(artifactsDir), { force: true });
125
+ await rm(tempPath, { force: true }).catch(() => undefined);
126
+ }
20
127
  }
@@ -1,29 +1,29 @@
1
+ import { join } from "node:path";
1
2
  import { readOptionalJsonFile } from "../io/json.js";
3
+ import { formatValidationIssues, } from "../validation/basic.js";
2
4
  import { validateSessionConfig } from "../validation/sessionConfig.js";
3
5
  import { writeJsonFile } from "../io/json.js";
6
+ const SESSION_CONFIG_FILENAME = "session-config.json";
7
+ const DEFAULT_SESSION_CONFIG = { provider: "local-subprocess" };
4
8
  export function getSessionConfigPath(artifactsDir) {
5
- return `${artifactsDir}/session-config.json`;
9
+ return join(artifactsDir, SESSION_CONFIG_FILENAME);
6
10
  }
7
11
  export async function readSessionConfigFile(artifactsDir) {
8
12
  return await readOptionalJsonFile(getSessionConfigPath(artifactsDir));
9
13
  }
10
- function formatValidationIssues(configPath, issues) {
11
- const details = issues
12
- .map((issue) => `- ${issue.path}: ${issue.message}`)
13
- .join("\n");
14
- return `Invalid ${configPath}:\n${details}`;
14
+ function formatConfigValidationIssues(configPath, issues) {
15
+ return `Invalid ${configPath}:\n${formatValidationIssues(issues).replace(/^ /gm, "- ")}`;
15
16
  }
16
17
  export async function loadSessionConfig(artifactsDir) {
17
18
  const configPath = getSessionConfigPath(artifactsDir);
18
19
  const rawConfig = await readOptionalJsonFile(configPath);
19
20
  if (rawConfig === undefined) {
20
- const defaultConfig = { provider: "local-subprocess" };
21
- await writeJsonFile(configPath, defaultConfig);
22
- return defaultConfig;
21
+ await writeJsonFile(configPath, DEFAULT_SESSION_CONFIG);
22
+ return { ...DEFAULT_SESSION_CONFIG };
23
23
  }
24
24
  const issues = validateSessionConfig(rawConfig);
25
25
  if (issues.length > 0) {
26
- throw new Error(formatValidationIssues(configPath, issues));
26
+ throw new Error(formatConfigValidationIssues(configPath, issues));
27
27
  }
28
28
  return rawConfig;
29
29
  }