switchboard-cli 0.1.0-alpha.1

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 (79) hide show
  1. package/README.md +122 -0
  2. package/bin/switchboard.mjs +49 -0
  3. package/calibration/engine/baseline.ts +93 -0
  4. package/calibration/engine/diagnosis.ts +191 -0
  5. package/calibration/engine/diff.ts +118 -0
  6. package/calibration/engine/escalation.ts +49 -0
  7. package/calibration/engine/ledger.ts +141 -0
  8. package/calibration/engine/trends.ts +141 -0
  9. package/calibration/external/rubric.yaml +32 -0
  10. package/calibration/external/scorer.ts +479 -0
  11. package/calibration/external/verdict-writer.ts +29 -0
  12. package/calibration/internal/harness.ts +697 -0
  13. package/calibration/internal/return-simulator.ts +270 -0
  14. package/calibration/internal/trace-collector.ts +78 -0
  15. package/calibration/internal/verdict-writer.ts +149 -0
  16. package/calibration/ledger/baselines/baseline-2026-04-09.yaml +23 -0
  17. package/calibration/ledger/history.yaml +18 -0
  18. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/diffs/adv-0bdc944b61d5.yaml +9 -0
  19. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/diffs/blind-16cdf0db1b43.yaml +9 -0
  20. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/diffs/blind-a6b2c8be67cc.yaml +9 -0
  21. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/manifest.yaml +8 -0
  22. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/seeds/adv-0bdc944b61d5.yaml +7 -0
  23. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/seeds/blind-16cdf0db1b43.yaml +8 -0
  24. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/seeds/blind-a6b2c8be67cc.yaml +8 -0
  25. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/summary.yaml +10 -0
  26. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/traces/adv-0bdc944b61d5.yaml +141 -0
  27. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/traces/blind-16cdf0db1b43.yaml +147 -0
  28. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/traces/blind-a6b2c8be67cc.yaml +147 -0
  29. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/verdicts-a/adv-0bdc944b61d5.yaml +24 -0
  30. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/verdicts-a/blind-16cdf0db1b43.yaml +24 -0
  31. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/verdicts-a/blind-a6b2c8be67cc.yaml +25 -0
  32. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/verdicts-b/adv-0bdc944b61d5.yaml +31 -0
  33. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/verdicts-b/blind-16cdf0db1b43.yaml +32 -0
  34. package/calibration/ledger/runs/2026-04-09T09-45-01-838Z/verdicts-b/blind-a6b2c8be67cc.yaml +32 -0
  35. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/diffs/adv-a0c9e2bfb0d6.yaml +9 -0
  36. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/diffs/blind-3e892f3a89ee.yaml +9 -0
  37. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/diffs/blind-958b2f9e6816.yaml +9 -0
  38. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/manifest.yaml +8 -0
  39. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/seeds/adv-a0c9e2bfb0d6.yaml +7 -0
  40. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/seeds/blind-3e892f3a89ee.yaml +8 -0
  41. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/seeds/blind-958b2f9e6816.yaml +8 -0
  42. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/summary.yaml +10 -0
  43. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/traces/adv-a0c9e2bfb0d6.yaml +141 -0
  44. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/traces/blind-3e892f3a89ee.yaml +147 -0
  45. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/traces/blind-958b2f9e6816.yaml +147 -0
  46. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/verdicts-a/adv-a0c9e2bfb0d6.yaml +24 -0
  47. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/verdicts-a/blind-3e892f3a89ee.yaml +23 -0
  48. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/verdicts-a/blind-958b2f9e6816.yaml +25 -0
  49. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/verdicts-b/adv-a0c9e2bfb0d6.yaml +31 -0
  50. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/verdicts-b/blind-3e892f3a89ee.yaml +32 -0
  51. package/calibration/ledger/runs/2026-04-09T10-02-57-143Z/verdicts-b/blind-958b2f9e6816.yaml +32 -0
  52. package/calibration/seeds/adversarial-generator.ts +159 -0
  53. package/calibration/seeds/blind-generator.ts +169 -0
  54. package/calibration/seeds/replay-loader.ts +117 -0
  55. package/calibration/skill/calibrate.ts +292 -0
  56. package/calibration/skill/cli-flags.ts +49 -0
  57. package/calibration/skill/report.ts +80 -0
  58. package/calibration/skill/review.ts +118 -0
  59. package/calibration/types.ts +292 -0
  60. package/package.json +46 -0
  61. package/src/commands/audit-codex.ts +266 -0
  62. package/src/commands/calibrate.ts +70 -0
  63. package/src/commands/compile.ts +117 -0
  64. package/src/commands/evaluate.ts +103 -0
  65. package/src/commands/ingest.ts +250 -0
  66. package/src/commands/init.ts +133 -0
  67. package/src/commands/packet.ts +408 -0
  68. package/src/commands/receipt.ts +305 -0
  69. package/src/commands/run-claude.ts +355 -0
  70. package/src/index.ts +43 -0
  71. package/src/lib/draft-return.ts +278 -0
  72. package/src/lib/drift-guard.ts +105 -0
  73. package/src/lib/errors.ts +61 -0
  74. package/src/lib/output.ts +43 -0
  75. package/src/lib/paths.ts +125 -0
  76. package/src/lib/proof.ts +262 -0
  77. package/src/lib/transport.ts +276 -0
  78. package/src/lib/yaml-io.ts +62 -0
  79. package/src/store/filesystem-store.ts +326 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Portable proof capture — CLI-native proof persistence under .switchboard/proof/.
3
+ *
4
+ * Reuses @switchboard/core proof types (ProofArtifact, ProofManifest,
5
+ * buildProofManifest) but stores under .switchboard/proof/<dispatchId>/
6
+ * instead of .runs/ so the canonical root stays self-contained.
7
+ *
8
+ * The portable proof path captures what it can:
9
+ * - dispatch metadata (captured — from the governed harness)
10
+ * - structured return (derived — parsed from operator-provided YAML)
11
+ * - dispatch prompt (captured — the governed prompt)
12
+ *
13
+ * It does NOT fake an execution transcript — that requires real SDK
14
+ * transport. The evaluation lane is honest about this gap.
15
+ */
16
+
17
+ import { createHash, randomBytes } from "crypto";
18
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "fs";
19
+ import { join } from "path";
20
+ import YAML from "yaml";
21
+ import {
22
+ buildProofManifest,
23
+ type ProofArtifact,
24
+ type ProofManifest,
25
+ proofArtifactSchema,
26
+ proofManifestSchema,
27
+ } from "@switchboard/core";
28
+ import { proofDir, proofManifestPath } from "./paths";
29
+ import { readYaml } from "./yaml-io";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Content hashing
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function hashContent(content: string): string {
36
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Proof folder management
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export function ensureProofDir(repoRoot: string, dispatchId: string): string {
44
+ const dir = proofDir(repoRoot, dispatchId);
45
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
46
+ return dir;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Capture individual proof artifacts
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function captureArtifact(
54
+ dir: string,
55
+ kind: ProofArtifact["kind"],
56
+ provenance: ProofArtifact["provenance"],
57
+ filename: string,
58
+ content: string,
59
+ description: string,
60
+ ): ProofArtifact {
61
+ const filePath = join(dir, filename);
62
+ writeFileSync(filePath, content, "utf-8");
63
+
64
+ return proofArtifactSchema.parse({
65
+ artifact_id: `prf-${randomBytes(6).toString("hex")}`,
66
+ kind,
67
+ provenance,
68
+ filename,
69
+ captured_at: new Date().toISOString(),
70
+ size_bytes: Buffer.byteLength(content, "utf-8"),
71
+ content_hash: hashContent(content),
72
+ description,
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Capture dispatch metadata as a proof artifact.
78
+ */
79
+ export function captureDispatchProof(
80
+ repoRoot: string,
81
+ dispatchId: string,
82
+ meta: {
83
+ surface: string;
84
+ prompt: string;
85
+ packetHash: string;
86
+ promptHash: string;
87
+ specMarkdown: string;
88
+ },
89
+ ): ProofArtifact {
90
+ const dir = ensureProofDir(repoRoot, dispatchId);
91
+
92
+ const content = YAML.stringify({
93
+ dispatch_id: dispatchId,
94
+ surface: meta.surface,
95
+ packet_hash: meta.packetHash,
96
+ prompt_hash: meta.promptHash,
97
+ prompt_length: meta.prompt.length,
98
+ spec_length: meta.specMarkdown.length,
99
+ captured_at: new Date().toISOString(),
100
+ });
101
+
102
+ return captureArtifact(
103
+ dir,
104
+ "dispatch-metadata",
105
+ "captured",
106
+ "dispatch-metadata.yaml",
107
+ content,
108
+ `Dispatch ${dispatchId} to ${meta.surface}`,
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Capture the governed prompt as a proof artifact.
114
+ */
115
+ export function capturePromptProof(
116
+ repoRoot: string,
117
+ dispatchId: string,
118
+ prompt: string,
119
+ ): ProofArtifact {
120
+ const dir = ensureProofDir(repoRoot, dispatchId);
121
+
122
+ return captureArtifact(
123
+ dir,
124
+ "dispatch-metadata",
125
+ "captured",
126
+ "governed-prompt.md",
127
+ prompt,
128
+ `Governed dispatch prompt (${prompt.length} chars)`,
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Capture the structured return as a proof artifact.
134
+ */
135
+ export function captureReturnProof(
136
+ repoRoot: string,
137
+ dispatchId: string,
138
+ returnYaml: string,
139
+ ): ProofArtifact {
140
+ const dir = ensureProofDir(repoRoot, dispatchId);
141
+
142
+ return captureArtifact(
143
+ dir,
144
+ "return-structured",
145
+ "derived",
146
+ "structured-return.yaml",
147
+ returnYaml,
148
+ "Parsed structured return from execution output",
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Capture an execution transcript as a proof artifact.
154
+ * This is a real "captured" artifact — it came from actual CLI transport,
155
+ * not from operator-provided YAML.
156
+ */
157
+ export function captureTranscriptProof(
158
+ repoRoot: string,
159
+ dispatchId: string,
160
+ transcript: string,
161
+ surface: string,
162
+ durationMs: number,
163
+ ): ProofArtifact {
164
+ const dir = ensureProofDir(repoRoot, dispatchId);
165
+
166
+ const header = [
167
+ `# Execution Transcript`,
168
+ `# Surface: ${surface}`,
169
+ `# Dispatch: ${dispatchId}`,
170
+ `# Duration: ${durationMs}ms`,
171
+ `# Captured: ${new Date().toISOString()}`,
172
+ `---\n`,
173
+ ].join("\n");
174
+
175
+ return captureArtifact(
176
+ dir,
177
+ "execution-transcript",
178
+ "captured",
179
+ "execution-transcript.md",
180
+ header + transcript,
181
+ `Captured execution transcript from ${surface} (${durationMs}ms)`,
182
+ );
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Manifest persistence
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Build and write a proof manifest for a dispatch.
191
+ * Collects any artifacts already in the proof folder + newly provided ones.
192
+ */
193
+ export function buildAndWriteProofManifest(
194
+ repoRoot: string,
195
+ dispatchId: string,
196
+ additionalArtifacts: ProofArtifact[] = [],
197
+ ): ProofManifest {
198
+ const dir = proofDir(repoRoot, dispatchId);
199
+ const existingArtifacts: ProofArtifact[] = [];
200
+
201
+ // Scan for already-captured artifacts
202
+ if (existsSync(dir)) {
203
+ const files = readdirSync(dir).filter(f => f !== "manifest.yaml");
204
+ for (const file of files) {
205
+ const filePath = join(dir, file);
206
+ const s = statSync(filePath);
207
+ if (s.isFile()) {
208
+ const content = readFileSync(filePath, "utf-8");
209
+
210
+ // Classify kind from filename
211
+ let kind: ProofArtifact["kind"] = "custom";
212
+ if (file.includes("dispatch-metadata")) kind = "dispatch-metadata";
213
+ else if (file.includes("return")) kind = "return-structured";
214
+ else if (file.includes("prompt")) kind = "dispatch-metadata";
215
+ else if (file.includes("transcript")) kind = "execution-transcript";
216
+ else if (file.includes("trace")) kind = "execution-trace";
217
+
218
+ existingArtifacts.push(proofArtifactSchema.parse({
219
+ artifact_id: `prf-${randomBytes(6).toString("hex")}`,
220
+ kind,
221
+ provenance: file.includes("return") ? "derived" : "captured",
222
+ filename: file,
223
+ captured_at: new Date(s.mtimeMs).toISOString(),
224
+ size_bytes: s.size,
225
+ content_hash: hashContent(content),
226
+ description: `Proof artifact: ${file}`,
227
+ }));
228
+ }
229
+ }
230
+ }
231
+
232
+ // Deduplicate by filename (prefer provided over scanned)
233
+ const seenFilenames = new Set(additionalArtifacts.map(a => a.filename));
234
+ const allArtifacts = [
235
+ ...additionalArtifacts,
236
+ ...existingArtifacts.filter(a => !seenFilenames.has(a.filename)),
237
+ ];
238
+
239
+ const manifest = buildProofManifest({
240
+ dispatchId,
241
+ runFolder: dir,
242
+ artifacts: allArtifacts,
243
+ });
244
+
245
+ // Write manifest
246
+ ensureProofDir(repoRoot, dispatchId);
247
+ const manifestFile = proofManifestPath(repoRoot, dispatchId);
248
+ writeFileSync(manifestFile, YAML.stringify(manifest), "utf-8");
249
+
250
+ return manifest;
251
+ }
252
+
253
+ /**
254
+ * Load a proof manifest for a dispatch, if one exists.
255
+ */
256
+ export function loadPortableProofManifest(
257
+ repoRoot: string,
258
+ dispatchId: string,
259
+ ): ProofManifest | null {
260
+ const manifestFile = proofManifestPath(repoRoot, dispatchId);
261
+ return readYaml<ProofManifest>(manifestFile);
262
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Transport detection — discovers whether Claude Code or Codex CLI
3
+ * is available locally, with honest status reporting.
4
+ *
5
+ * Returns actionable readiness signals so commands can:
6
+ * 1. Attempt automatic transport when ready
7
+ * 2. Fall back to manual with clear guidance when not
8
+ * 3. Never fake or approximate transport availability
9
+ */
10
+
11
+ import { execFileSync, type SpawnSyncReturns } from "child_process";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type TransportStatus =
18
+ | "ready" // CLI found + invocable (auth validated at execution time)
19
+ | "no-cli" // CLI binary not found on PATH
20
+ | "no-auth" // CLI found but auth provably missing
21
+ | "cli-error" // CLI found but probe command failed unexpectedly
22
+ | "manual-requested"; // Operator explicitly chose manual dispatch
23
+
24
+ export type TransportCapability =
25
+ | "review-only" // Transcript capture only — CLI cannot modify files (claude -p default)
26
+ | "write-capable" // CLI can modify repo files (requires --allowedTools or equivalent)
27
+ | "unknown"; // Cannot determine capability
28
+
29
+ export interface TransportReadiness {
30
+ status: TransportStatus;
31
+ cli_path: string | null;
32
+ detail: string;
33
+ capability: TransportCapability;
34
+ capability_detail: string;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function whichSync(cmd: string): string | null {
42
+ try {
43
+ return execFileSync("which", [cmd], {
44
+ encoding: "utf-8",
45
+ timeout: 5000,
46
+ stdio: ["pipe", "pipe", "pipe"],
47
+ }).trim();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Claude Code detection
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Detect whether the `claude` CLI is available and authenticated.
59
+ *
60
+ * Checks:
61
+ * 1. `claude` binary on PATH (via `which`)
62
+ * 2. Basic invocability (`claude --version`)
63
+ *
64
+ * Does NOT run a real prompt — that happens in the transport itself.
65
+ */
66
+ export function detectClaudeTransport(): TransportReadiness {
67
+ const cliPath = whichSync("claude");
68
+
69
+ if (!cliPath) {
70
+ return {
71
+ status: "no-cli",
72
+ cli_path: null,
73
+ detail: "Claude Code CLI not found on PATH. Install: npm install -g @anthropic-ai/claude-code",
74
+ capability: "unknown",
75
+ capability_detail: "CLI not found.",
76
+ };
77
+ }
78
+
79
+ // Probe: can we invoke it?
80
+ try {
81
+ execFileSync(cliPath, ["--version"], {
82
+ encoding: "utf-8",
83
+ timeout: 10000,
84
+ stdio: ["pipe", "pipe", "pipe"],
85
+ });
86
+ } catch (e: any) {
87
+ return {
88
+ status: "cli-error",
89
+ cli_path: cliPath,
90
+ detail: `Claude CLI found at ${cliPath} but --version failed: ${e.message?.slice(0, 120) ?? "unknown error"}`,
91
+ capability: "unknown",
92
+ capability_detail: "CLI probe failed.",
93
+ };
94
+ }
95
+
96
+ // Capability: claude -p (print mode) is review-only by default.
97
+ // Write capability requires --allowedTools or similar flags.
98
+ return {
99
+ status: "ready",
100
+ cli_path: cliPath,
101
+ detail: `Claude Code CLI ready at ${cliPath} (auth validated at execution time)`,
102
+ capability: "review-only",
103
+ capability_detail: "Print mode (-p) captures transcript only. Claude cannot modify files without --allowedTools flag. To enable writes: claude -p --allowedTools Edit,Write",
104
+ };
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Codex CLI detection
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Detect whether the `codex` CLI is available and authenticated.
113
+ *
114
+ * Checks:
115
+ * 1. `codex` binary on PATH
116
+ * 2. Basic invocability (`codex --version`)
117
+ *
118
+ * Auth is validated at execution time, not detection time.
119
+ * Codex supports both OPENAI_API_KEY and local login — we do not
120
+ * hard-require either, since a `--version` probe cannot distinguish them.
121
+ */
122
+ export function detectCodexTransport(): TransportReadiness {
123
+ const cliPath = whichSync("codex");
124
+
125
+ if (!cliPath) {
126
+ return {
127
+ status: "no-cli",
128
+ cli_path: null,
129
+ detail: "Codex CLI not found on PATH. Install: npm install -g @openai/codex",
130
+ capability: "unknown",
131
+ capability_detail: "CLI not found.",
132
+ };
133
+ }
134
+
135
+ // Probe: can we invoke it?
136
+ try {
137
+ execFileSync(cliPath, ["--version"], {
138
+ encoding: "utf-8",
139
+ timeout: 10000,
140
+ stdio: ["pipe", "pipe", "pipe"],
141
+ });
142
+ } catch (e: any) {
143
+ return {
144
+ status: "cli-error",
145
+ cli_path: cliPath,
146
+ detail: `Codex CLI found at ${cliPath} but --version failed: ${e.message?.slice(0, 120) ?? "unknown error"}`,
147
+ capability: "unknown",
148
+ capability_detail: "CLI probe failed.",
149
+ };
150
+ }
151
+
152
+ // Codex review is always read-only — that's the correct audit posture
153
+ return {
154
+ status: "ready",
155
+ cli_path: cliPath,
156
+ detail: `Codex CLI ready at ${cliPath} (auth validated at execution time)`,
157
+ capability: "review-only",
158
+ capability_detail: "Codex review mode is read-only by design (audit lane).",
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Transport execution — Claude Code
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export interface TransportResult {
167
+ success: boolean;
168
+ transcript: string;
169
+ exit_code: number;
170
+ error?: string;
171
+ duration_ms: number;
172
+ }
173
+
174
+ /**
175
+ * Execute a governed prompt via Claude Code CLI in print mode.
176
+ *
177
+ * Uses `claude -p` (print mode) which reads the prompt, produces output,
178
+ * and exits. The full stdout is captured as the execution transcript.
179
+ */
180
+ export function executeClaudeTransport(
181
+ cliPath: string,
182
+ prompt: string,
183
+ workingDirectory: string,
184
+ timeoutMs: number = 600000, // 10 minutes default
185
+ ): TransportResult {
186
+ const start = Date.now();
187
+
188
+ try {
189
+ const result = execFileSync(cliPath, ["-p", "--output-format", "text"], {
190
+ input: prompt,
191
+ cwd: workingDirectory,
192
+ encoding: "utf-8",
193
+ timeout: timeoutMs,
194
+ maxBuffer: 10 * 1024 * 1024, // 10MB
195
+ stdio: ["pipe", "pipe", "pipe"],
196
+ });
197
+
198
+ return {
199
+ success: true,
200
+ transcript: result,
201
+ exit_code: 0,
202
+ duration_ms: Date.now() - start,
203
+ };
204
+ } catch (e: any) {
205
+ const exitCode = e.status ?? 1;
206
+ const stdout = e.stdout ?? "";
207
+ const stderr = e.stderr ?? "";
208
+
209
+ return {
210
+ success: false,
211
+ transcript: stdout || stderr || e.message || "Unknown transport error",
212
+ exit_code: exitCode,
213
+ error: stderr || e.message,
214
+ duration_ms: Date.now() - start,
215
+ };
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Transport execution — Codex CLI
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Execute an audit review via Codex CLI `review` subcommand.
225
+ *
226
+ * `codex review` is Codex's built-in read-only code review surface.
227
+ * It inspects the working tree and produces review output without
228
+ * making changes — exactly what an audit lane needs.
229
+ *
230
+ * Constraint: `--uncommitted` and `[PROMPT]` are mutually exclusive
231
+ * in the Codex CLI. We use `--uncommitted` for the working tree scope
232
+ * and `--title` to pass dispatch context.
233
+ */
234
+ export function executeCodexTransport(
235
+ cliPath: string,
236
+ auditTitle: string,
237
+ workingDirectory: string,
238
+ timeoutMs: number = 600000,
239
+ ): TransportResult {
240
+ const start = Date.now();
241
+
242
+ // --title passes dispatch context; --uncommitted scopes to working tree
243
+ const titleSnippet = auditTitle.slice(0, 200);
244
+ try {
245
+ const result = execFileSync(
246
+ cliPath,
247
+ ["review", "--uncommitted", "--title", titleSnippet],
248
+ {
249
+ cwd: workingDirectory,
250
+ encoding: "utf-8",
251
+ timeout: timeoutMs,
252
+ maxBuffer: 10 * 1024 * 1024,
253
+ stdio: ["pipe", "pipe", "pipe"],
254
+ },
255
+ );
256
+
257
+ return {
258
+ success: true,
259
+ transcript: result,
260
+ exit_code: 0,
261
+ duration_ms: Date.now() - start,
262
+ };
263
+ } catch (e: any) {
264
+ const exitCode = e.status ?? 1;
265
+ const stdout = e.stdout ?? "";
266
+ const stderr = e.stderr ?? "";
267
+
268
+ return {
269
+ success: false,
270
+ transcript: stdout || stderr || e.message || "Unknown transport error",
271
+ exit_code: exitCode,
272
+ error: stderr || e.message,
273
+ duration_ms: Date.now() - start,
274
+ };
275
+ }
276
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * YAML read/write helpers for Switchboard CLI.
3
+ *
4
+ * Thin wrappers around the `yaml` package that handle
5
+ * missing files, directory creation, and consistent formatting.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
+ import { dirname } from "path";
10
+ import YAML from "yaml";
11
+
12
+ /**
13
+ * Read and parse a YAML file. Returns null if the file doesn't exist.
14
+ * Throws on parse errors (malformed YAML is a real problem, not a missing-file case).
15
+ */
16
+ export function readYaml<T = unknown>(filePath: string): T | null {
17
+ if (!existsSync(filePath)) return null;
18
+ const raw = readFileSync(filePath, "utf-8");
19
+ return YAML.parse(raw) as T;
20
+ }
21
+
22
+ /**
23
+ * Read a YAML file or throw if it doesn't exist.
24
+ */
25
+ export function requireYaml<T = unknown>(filePath: string, label: string): T {
26
+ const result = readYaml<T>(filePath);
27
+ if (result === null || result === undefined) {
28
+ throw new Error(`Required file not found: ${filePath} (${label})`);
29
+ }
30
+ return result;
31
+ }
32
+
33
+ /**
34
+ * Write a value as YAML. Creates parent directories if needed.
35
+ */
36
+ export function writeYaml(filePath: string, data: unknown): void {
37
+ const dir = dirname(filePath);
38
+ if (!existsSync(dir)) {
39
+ mkdirSync(dir, { recursive: true });
40
+ }
41
+ const content = YAML.stringify(data, { lineWidth: 120 });
42
+ writeFileSync(filePath, content, "utf-8");
43
+ }
44
+
45
+ /**
46
+ * Read a markdown file. Returns null if missing.
47
+ */
48
+ export function readMarkdown(filePath: string): string | null {
49
+ if (!existsSync(filePath)) return null;
50
+ return readFileSync(filePath, "utf-8");
51
+ }
52
+
53
+ /**
54
+ * Write a markdown file. Creates parent directories if needed.
55
+ */
56
+ export function writeMarkdown(filePath: string, content: string): void {
57
+ const dir = dirname(filePath);
58
+ if (!existsSync(dir)) {
59
+ mkdirSync(dir, { recursive: true });
60
+ }
61
+ writeFileSync(filePath, content, "utf-8");
62
+ }