@claudexor/cli 1.0.1 → 2.1.3

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 (221) hide show
  1. package/dist/acp-surface-runner.d.ts +17 -0
  2. package/dist/acp-surface-runner.d.ts.map +1 -0
  3. package/dist/acp-surface-runner.js +187 -0
  4. package/dist/acp-surface-runner.js.map +1 -0
  5. package/dist/args.d.ts.map +1 -1
  6. package/dist/args.js.map +1 -1
  7. package/dist/bridge-serve.d.ts +3 -0
  8. package/dist/bridge-serve.d.ts.map +1 -0
  9. package/dist/bridge-serve.js +39 -0
  10. package/dist/bridge-serve.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +12 -3
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/claude-oauth-usage.d.ts +33 -0
  15. package/dist/claude-oauth-usage.d.ts.map +1 -0
  16. package/dist/claude-oauth-usage.js +190 -0
  17. package/dist/claude-oauth-usage.js.map +1 -0
  18. package/dist/claude-statusline.d.ts +25 -0
  19. package/dist/claude-statusline.d.ts.map +1 -0
  20. package/dist/claude-statusline.js +259 -0
  21. package/dist/claude-statusline.js.map +1 -0
  22. package/dist/claudexord.js +346 -543
  23. package/dist/claudexord.js.map +1 -1
  24. package/dist/cli-io.d.ts +8 -9
  25. package/dist/cli-io.d.ts.map +1 -1
  26. package/dist/cli-io.js +11 -7
  27. package/dist/cli-io.js.map +1 -1
  28. package/dist/cli.js +475 -516
  29. package/dist/cli.js.map +1 -1
  30. package/dist/codex-quota-source.d.ts +11 -0
  31. package/dist/codex-quota-source.d.ts.map +1 -0
  32. package/dist/codex-quota-source.js +181 -0
  33. package/dist/codex-quota-source.js.map +1 -0
  34. package/dist/command-flags.d.ts +18 -0
  35. package/dist/command-flags.d.ts.map +1 -0
  36. package/dist/command-flags.js +124 -0
  37. package/dist/command-flags.js.map +1 -0
  38. package/dist/command-registry.d.ts +2 -40
  39. package/dist/command-registry.d.ts.map +1 -1
  40. package/dist/command-registry.js +74 -164
  41. package/dist/command-registry.js.map +1 -1
  42. package/dist/control-services.d.ts +2490 -0
  43. package/dist/control-services.d.ts.map +1 -0
  44. package/dist/control-services.js +453 -0
  45. package/dist/control-services.js.map +1 -0
  46. package/dist/credential-commands.d.ts +9 -0
  47. package/dist/credential-commands.d.ts.map +1 -0
  48. package/dist/credential-commands.js +224 -0
  49. package/dist/credential-commands.js.map +1 -0
  50. package/dist/daemon-lifecycle.d.ts +7 -3
  51. package/dist/daemon-lifecycle.d.ts.map +1 -1
  52. package/dist/daemon-lifecycle.js +40 -12
  53. package/dist/daemon-lifecycle.js.map +1 -1
  54. package/dist/daemon-run.d.ts +2 -0
  55. package/dist/daemon-run.d.ts.map +1 -1
  56. package/dist/daemon-run.js +62 -14
  57. package/dist/daemon-run.js.map +1 -1
  58. package/dist/daemon-runtime-shutdown.d.ts +58 -0
  59. package/dist/daemon-runtime-shutdown.d.ts.map +1 -0
  60. package/dist/daemon-runtime-shutdown.js +142 -0
  61. package/dist/daemon-runtime-shutdown.js.map +1 -0
  62. package/dist/decision.d.ts.map +1 -1
  63. package/dist/decision.js +12 -3
  64. package/dist/decision.js.map +1 -1
  65. package/dist/live.d.ts +29 -0
  66. package/dist/live.d.ts.map +1 -1
  67. package/dist/live.js +117 -6
  68. package/dist/live.js.map +1 -1
  69. package/dist/local-attachment.d.ts +14 -0
  70. package/dist/local-attachment.d.ts.map +1 -0
  71. package/dist/local-attachment.js +67 -0
  72. package/dist/local-attachment.js.map +1 -0
  73. package/dist/mcp-runner.d.ts +4 -15
  74. package/dist/mcp-runner.d.ts.map +1 -1
  75. package/dist/mcp-runner.js +276 -145
  76. package/dist/mcp-runner.js.map +1 -1
  77. package/dist/native-login.d.ts +21 -0
  78. package/dist/native-login.d.ts.map +1 -0
  79. package/dist/native-login.js +110 -0
  80. package/dist/native-login.js.map +1 -0
  81. package/dist/ops-commands.d.ts +8 -6
  82. package/dist/ops-commands.d.ts.map +1 -1
  83. package/dist/ops-commands.js +250 -168
  84. package/dist/ops-commands.js.map +1 -1
  85. package/dist/orchestrate-options.d.ts.map +1 -1
  86. package/dist/orchestrate-options.js +1 -1
  87. package/dist/orchestrate-options.js.map +1 -1
  88. package/dist/orphan-reaper.d.ts +4 -5
  89. package/dist/orphan-reaper.d.ts.map +1 -1
  90. package/dist/orphan-reaper.js +29 -56
  91. package/dist/orphan-reaper.js.map +1 -1
  92. package/dist/orphan-sweeper.d.ts +1 -2
  93. package/dist/orphan-sweeper.d.ts.map +1 -1
  94. package/dist/orphan-sweeper.js +29 -48
  95. package/dist/orphan-sweeper.js.map +1 -1
  96. package/dist/plugins.d.ts.map +1 -1
  97. package/dist/plugins.js +196 -64
  98. package/dist/plugins.js.map +1 -1
  99. package/dist/primary-output.d.ts.map +1 -1
  100. package/dist/primary-output.js.map +1 -1
  101. package/dist/profile-compatibility.d.ts +6 -0
  102. package/dist/profile-compatibility.d.ts.map +1 -0
  103. package/dist/profile-compatibility.js +21 -0
  104. package/dist/profile-compatibility.js.map +1 -0
  105. package/dist/profile-registration.d.ts +15 -0
  106. package/dist/profile-registration.d.ts.map +1 -0
  107. package/dist/profile-registration.js +68 -0
  108. package/dist/profile-registration.js.map +1 -0
  109. package/dist/project-command.d.ts +3 -0
  110. package/dist/project-command.d.ts.map +1 -0
  111. package/dist/project-command.js +87 -0
  112. package/dist/project-command.js.map +1 -0
  113. package/dist/quota-command.d.ts +3 -0
  114. package/dist/quota-command.d.ts.map +1 -0
  115. package/dist/quota-command.js +59 -0
  116. package/dist/quota-command.js.map +1 -0
  117. package/dist/registry.d.ts +1 -1
  118. package/dist/registry.d.ts.map +1 -1
  119. package/dist/registry.js +16 -4
  120. package/dist/registry.js.map +1 -1
  121. package/dist/release.d.ts.map +1 -1
  122. package/dist/release.js +15 -3
  123. package/dist/release.js.map +1 -1
  124. package/dist/repl.d.ts +3 -8
  125. package/dist/repl.d.ts.map +1 -1
  126. package/dist/repl.js +23 -122
  127. package/dist/repl.js.map +1 -1
  128. package/dist/request-preflight.d.ts +13 -0
  129. package/dist/request-preflight.d.ts.map +1 -0
  130. package/dist/request-preflight.js +81 -0
  131. package/dist/request-preflight.js.map +1 -0
  132. package/dist/retention-service.d.ts +24 -0
  133. package/dist/retention-service.d.ts.map +1 -0
  134. package/dist/retention-service.js +100 -0
  135. package/dist/retention-service.js.map +1 -0
  136. package/dist/retry-command-specs.d.ts +3 -0
  137. package/dist/retry-command-specs.d.ts.map +1 -0
  138. package/dist/retry-command-specs.js +21 -0
  139. package/dist/retry-command-specs.js.map +1 -0
  140. package/dist/retry-command.d.ts +4 -0
  141. package/dist/retry-command.d.ts.map +1 -0
  142. package/dist/retry-command.js +57 -0
  143. package/dist/retry-command.js.map +1 -0
  144. package/dist/review-command.d.ts.map +1 -1
  145. package/dist/review-command.js +47 -12
  146. package/dist/review-command.js.map +1 -1
  147. package/dist/run-options.d.ts +2 -2
  148. package/dist/run-options.d.ts.map +1 -1
  149. package/dist/run-options.js +27 -5
  150. package/dist/run-options.js.map +1 -1
  151. package/dist/settings-command.d.ts.map +1 -1
  152. package/dist/settings-command.js +143 -188
  153. package/dist/settings-command.js.map +1 -1
  154. package/dist/settings-service.d.ts +50 -0
  155. package/dist/settings-service.d.ts.map +1 -1
  156. package/dist/settings-service.js +67 -1
  157. package/dist/settings-service.js.map +1 -1
  158. package/dist/setup-job-reducer.d.ts +9 -0
  159. package/dist/setup-job-reducer.d.ts.map +1 -0
  160. package/dist/setup-job-reducer.js +290 -0
  161. package/dist/setup-job-reducer.js.map +1 -0
  162. package/dist/setup-job-store.d.ts +250 -0
  163. package/dist/setup-job-store.d.ts.map +1 -0
  164. package/dist/setup-job-store.js +359 -0
  165. package/dist/setup-job-store.js.map +1 -0
  166. package/dist/setup-job-support.d.ts +27 -0
  167. package/dist/setup-job-support.d.ts.map +1 -0
  168. package/dist/setup-job-support.js +141 -0
  169. package/dist/setup-job-support.js.map +1 -0
  170. package/dist/setup-jobs.d.ts +437 -22
  171. package/dist/setup-jobs.d.ts.map +1 -1
  172. package/dist/setup-jobs.js +813 -423
  173. package/dist/setup-jobs.js.map +1 -1
  174. package/dist/setup-lifecycle-binding.d.ts +40 -0
  175. package/dist/setup-lifecycle-binding.d.ts.map +1 -0
  176. package/dist/setup-lifecycle-binding.js +159 -0
  177. package/dist/setup-lifecycle-binding.js.map +1 -0
  178. package/dist/setup-login-protocol.d.ts +13 -0
  179. package/dist/setup-login-protocol.d.ts.map +1 -0
  180. package/dist/setup-login-protocol.js +179 -0
  181. package/dist/setup-login-protocol.js.map +1 -0
  182. package/dist/setup-login-runner.d.ts +20 -0
  183. package/dist/setup-login-runner.d.ts.map +1 -0
  184. package/dist/setup-login-runner.js +225 -0
  185. package/dist/setup-login-runner.js.map +1 -0
  186. package/dist/setup-supervisor.d.ts +53 -0
  187. package/dist/setup-supervisor.d.ts.map +1 -0
  188. package/dist/setup-supervisor.js +177 -0
  189. package/dist/setup-supervisor.js.map +1 -0
  190. package/dist/spec-thread-scope.d.ts +3 -0
  191. package/dist/spec-thread-scope.d.ts.map +1 -0
  192. package/dist/spec-thread-scope.js +9 -0
  193. package/dist/spec-thread-scope.js.map +1 -0
  194. package/dist/spec.d.ts +9 -20
  195. package/dist/spec.d.ts.map +1 -1
  196. package/dist/spec.js +49 -21
  197. package/dist/spec.js.map +1 -1
  198. package/dist/thread-delivery.d.ts +18 -0
  199. package/dist/thread-delivery.d.ts.map +1 -0
  200. package/dist/thread-delivery.js +67 -0
  201. package/dist/thread-delivery.js.map +1 -0
  202. package/dist/thread-select.d.ts +9 -0
  203. package/dist/thread-select.d.ts.map +1 -0
  204. package/dist/thread-select.js +12 -0
  205. package/dist/thread-select.js.map +1 -0
  206. package/dist/trust-command.d.ts.map +1 -1
  207. package/dist/trust-command.js +69 -61
  208. package/dist/trust-command.js.map +1 -1
  209. package/dist/trust-services.d.ts +5 -6
  210. package/dist/trust-services.d.ts.map +1 -1
  211. package/dist/trust-services.js +42 -13
  212. package/dist/trust-services.js.map +1 -1
  213. package/package.json +24 -22
  214. package/dist/attachment-resolver.d.ts +0 -11
  215. package/dist/attachment-resolver.d.ts.map +0 -1
  216. package/dist/attachment-resolver.js +0 -61
  217. package/dist/attachment-resolver.js.map +0 -1
  218. package/dist/settings-display.d.ts +0 -3
  219. package/dist/settings-display.d.ts.map +0 -1
  220. package/dist/settings-display.js +0 -33
  221. package/dist/settings-display.js.map +0 -1
package/dist/cli.js CHANGED
@@ -1,45 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  import process from "node:process";
3
- import { existsSync, lstatSync, readdirSync } from "node:fs";
4
- import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
- import { Orchestrator } from "@claudexor/orchestrator";
3
+ import { createReadStream, existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import { Readable, Transform } from "node:stream";
6
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
7
  import { ArtifactStore } from "@claudexor/artifact-store";
7
- import { DELIVER_MODES, checkPatch, deliver, validateApplyGate, } from "@claudexor/delivery";
8
- import { CLAUDEXOR_VERSION, containsSecretLikeToken, ensureDir, newId, noProjectRepoRoot, readTextSafe, userConfigDir, writeJson, } from "@claudexor/util";
8
+ import { CLAUDEXOR_VERSION, containsSecretLikeToken, noProjectRepoRoot, readTextSafe, userConfigDir, } from "@claudexor/util";
9
9
  import { checkName } from "./release.js";
10
- import { defaultClaudexorTools, serveClaudexorMcp } from "@claudexor/mcp-server";
11
- import { AcpServer } from "@claudexor/acp-server";
12
- import { initProjectConfig, loadConfig } from "@claudexor/config";
13
- import { atRiskNodeAdvisory, validateModel } from "@claudexor/core";
14
- import { DecisionRecord, EffortHint, ExternalContextPolicy, ModeKind as ModeKindSchema, Portfolio, RunTelemetry, TaskContract, WorkProduct, } from "@claudexor/schema";
10
+ import { serveAcpBridge, serveMcpBridge } from "./bridge-serve.js";
11
+ import { initProjectConfig } from "@claudexor/config";
12
+ import { DecisionRecord, ControlSpecSession, EffortHint, ExternalContextPolicy, ModeKind as ModeKindSchema, RoutingGoal, ControlThreadListResponse, RunTelemetry, StructuredOutputConformance, TaskContract, } from "@claudexor/schema";
15
13
  import { flagBool, flagStr, flagStringList, flagValues, parseArgs, requiredStringFlagError, } from "./args.js";
16
- import { authSourceAvailability, checksSummary, print, printJson, printUsageError, statusGlyph } from "./cli-io.js";
17
- import { KNOWN_FLAGS, VALUE_FLAGS, commandFlagScopeError, helpJson, renderHelp } from "./command-registry.js";
14
+ import { print, printJson, printJsonLine, printUsageError, statusGlyph } from "./cli-io.js";
15
+ import { pickResumableThread } from "./thread-select.js";
16
+ import { KNOWN_FLAGS, VALUE_FLAGS, commandFlagScopeError, helpJson, renderHelp, } from "./command-registry.js";
18
17
  import { buildAgentCapabilityCatalog } from "./capabilities.js";
19
- import { authCommand, daemonCommand, modelsCommand, secretsCommand } from "./ops-commands.js";
18
+ import { dispatchOpsCommand } from "./ops-commands.js";
20
19
  import { reviewCommand } from "./review-command.js";
21
- import { followRun, formatRunEventLine, promptQuestionsOnTty } from "./live.js";
20
+ import { controlApiFetch, followRun } from "./live.js";
21
+ import { retryCommand, runAgainCommand } from "./retry-command.js";
22
22
  import { assertCliRunParamsHaveNoInlineSecrets } from "./run-secret-scan.js";
23
- import { connectDaemonIfRunning, daemonOutcomeSummary, ensureDaemon, enqueueAndAwait, exitCodeForState, fetchApplyEligibility, } from "./daemon-run.js";
23
+ import { openLocalAttachment, resolveLocalAttachment, } from "./local-attachment.js";
24
+ import { connectDaemonIfRunning, daemonOutcomeSummary, ensureDaemon, enqueueAndAwait, exitCodeForState, fetchApplyEligibility, runStatusForCli, } from "./daemon-run.js";
24
25
  import { resolveDecisionBody } from "./decision.js";
25
26
  import { primaryOutputForCli } from "./primary-output.js";
26
27
  import { PLUGIN_TARGETS, PLUGIN_VERBS, formatPluginResult, pluginCommandErrorResult, runPluginCommand, } from "./plugins.js";
27
- import { buildGateway, buildRegistry, harnessModels } from "./registry.js";
28
- import { mcpSurfaceRunner, orchestratorRunner } from "./mcp-runner.js";
28
+ import { buildRegistry } from "./registry.js";
29
29
  import { settingsCommand } from "./settings-command.js";
30
+ import { quotaCommand } from "./quota-command.js";
30
31
  import { trustCommand } from "./trust-command.js";
31
- import { extractQuestionsFromPlan, freezeSpecFromGrounding, loadFrozenSpec, loadPreviousSpec, persistSpec, readAnswers, resolveRunTestCommands, } from "./spec.js";
32
+ import { projectCommand } from "./project-command.js";
33
+ import { loadFrozenSpec, readAnswers, resolveRunTestCommands } from "./spec.js";
32
34
  import { parseAutonomy } from "./orchestrate-options.js";
33
35
  import { runRepl } from "./repl.js";
34
36
  import { parseProtectedPathApprovalFlags, parseTestCommandFlags, parseReviewerEffortFlags, parseReviewerModelFlags, parseReviewerPanelFlags, } from "./run-options.js";
35
- // Single version SSOT: the generated CLAUDEXOR_VERSION constant (from the root
36
- // package.json) so the banner / --version can never ship stale or drift.
37
37
  const CLI_VERSION = CLAUDEXOR_VERSION;
38
- // The help text is a rendered view of the command registry (the ONE owner of
39
- // the CLI surface — command-registry.ts). Never hand-edit a help literal.
40
38
  const HELP = renderHelp(CLI_VERSION);
41
39
  const MODES = new Set(["ask", "plan", "audit", "agent", "orchestrate"]);
42
- /** Canonical mode ids are single words; trim and validate against the schema. */
43
40
  function normalizeMode(s) {
44
41
  const trimmed = s.trim();
45
42
  const parsed = ModeKindSchema.safeParse(trimmed);
@@ -71,7 +68,7 @@ function floatFlag(args, key) {
71
68
  throw new Error(`invalid --${key} '${v}' (expected a non-negative number)`);
72
69
  return n;
73
70
  }
74
- /** Deterministic gate commands from `--test "<cmd>"`; repeat flag or separate with `;;`. */
71
+ /** Deterministic typed-argv gates from repeated `--test '["program","arg"]'`. */
75
72
  function testCommands(args) {
76
73
  return parseTestCommandFlags(flagValues(args, "test"));
77
74
  }
@@ -79,6 +76,27 @@ function testCommands(args) {
79
76
  function protectedPathApprovals(args) {
80
77
  return parseProtectedPathApprovalFlags(flagValues(args, "allow-protected-path"));
81
78
  }
79
+ /**
80
+ * Per-run system instructions from `--instructions "<text>"` or
81
+ * `--instructions-file <path>` (mutually exclusive; the file form avoids
82
+ * ARG_MAX and keeps long instructions out of the process argv / `ps`).
83
+ */
84
+ function resolveInstructions(args) {
85
+ const inline = flagStr(args, "instructions");
86
+ const file = flagStr(args, "instructions-file");
87
+ if (inline !== undefined && file !== undefined) {
88
+ throw new Error("pass either --instructions or --instructions-file, not both");
89
+ }
90
+ if (file !== undefined) {
91
+ try {
92
+ return readFileSync(file, "utf8");
93
+ }
94
+ catch (err) {
95
+ throw new Error(`could not read --instructions-file ${file}: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+ }
98
+ return inline;
99
+ }
82
100
  const ACCESS_PROFILES = new Set([
83
101
  "readonly",
84
102
  "workspace_write",
@@ -134,45 +152,58 @@ function attachmentPaths(args) {
134
152
  }
135
153
  return values;
136
154
  }
137
- function imageMimeFor(path) {
138
- switch (extname(path).toLowerCase()) {
139
- case ".png":
140
- return "image/png";
141
- case ".jpg":
142
- case ".jpeg":
143
- return "image/jpeg";
144
- case ".gif":
145
- return "image/gif";
146
- case ".webp":
147
- return "image/webp";
148
- default:
149
- return null;
150
- }
151
- }
152
155
  function attachmentInputs(args) {
153
- const out = attachmentPaths(args).map(({ path, forceImage }) => {
154
- const resolved = resolve(path);
155
- if (!existsSync(resolved) || !lstatSync(resolved).isFile())
156
- throw new Error(`attachment must be an existing file: ${path}`);
157
- const imageMime = imageMimeFor(resolved);
158
- const kind = forceImage || imageMime ? "image" : "file";
159
- return {
160
- kind,
161
- mime: imageMime ?? "application/octet-stream",
162
- name: basename(resolved),
163
- path: resolved,
164
- };
165
- });
156
+ const out = attachmentPaths(args).map(({ path, forceImage }) => resolveLocalAttachment(path, forceImage));
166
157
  return out.length > 0 ? out : undefined;
167
158
  }
168
- function attachmentsFromInputs(inputs) {
169
- return inputs?.map((a) => ({
170
- id: newId("att"),
171
- kind: a.kind,
172
- mime: a.mime,
173
- name: a.name,
174
- path: a.path,
159
+ async function uploadLocalAttachment(addr, attachment) {
160
+ const created = (await controlJson(addr, "/uploads", {
161
+ method: "POST",
162
+ headers: { "content-type": "application/json" },
163
+ body: JSON.stringify({
164
+ kind: attachment.kind,
165
+ mime: attachment.mime,
166
+ name: attachment.name,
167
+ sizeBytes: attachment.sizeBytes,
168
+ }),
175
169
  }));
170
+ try {
171
+ const source = createReadStream(attachment.path, {
172
+ fd: openLocalAttachment(attachment),
173
+ autoClose: true,
174
+ });
175
+ const hash = createHash("sha256");
176
+ const hashingStream = new Transform({
177
+ transform(chunk, _encoding, callback) {
178
+ hash.update(chunk);
179
+ callback(null, chunk);
180
+ },
181
+ });
182
+ source.pipe(hashingStream);
183
+ const response = await controlApiFetch(addr, `/uploads/${encodeURIComponent(created.uploadId)}/bytes`, {
184
+ method: "PUT",
185
+ headers: { "content-type": "application/octet-stream" },
186
+ body: Readable.toWeb(hashingStream),
187
+ duplex: "half",
188
+ });
189
+ if (!response.ok) {
190
+ const detail = (await response.json().catch(() => ({})));
191
+ throw new Error(String(detail["message"] ?? detail["error"] ?? `HTTP ${response.status}`));
192
+ }
193
+ const expectedSha256 = `sha256:${hash.digest("hex")}`;
194
+ const resource = (await controlJson(addr, `/uploads/${encodeURIComponent(created.uploadId)}/finalize`, {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({ expectedSha256 }),
198
+ }));
199
+ return { resourceId: resource.resourceId };
200
+ }
201
+ catch (error) {
202
+ await controlApiFetch(addr, `/uploads/${encodeURIComponent(created.uploadId)}`, {
203
+ method: "DELETE",
204
+ }).catch(() => undefined);
205
+ throw error;
206
+ }
176
207
  }
177
208
  /** Per-family reviewer model map from `--reviewer-model "openai=gpt-4o-mini,anthropic=claude-haiku"`. Fails loudly on malformed input. */
178
209
  function reviewerModels(args) {
@@ -187,7 +218,28 @@ function reviewerPanel(args) {
187
218
  return parseReviewerPanelFlags(flagValues(args, "reviewer-panel"));
188
219
  }
189
220
  async function orchestrate(args, mode, json, forced = {}) {
190
- const rawPrompt = args._.slice(1).join(" ").trim();
221
+ let rawPrompt = args._.slice(1).join(" ").trim();
222
+ // Headless prompt sources (W13): `-` reads the prompt from stdin; a file
223
+ // beats retyping. Exactly ONE source — ambiguity is a usage error, never a
224
+ // silent pick.
225
+ const promptFile = flagStr(args, "prompt-file");
226
+ if (promptFile !== undefined) {
227
+ if (rawPrompt && rawPrompt !== "-") {
228
+ return printUsageError(json, "claudexor: pass either an inline prompt or --prompt-file, not both");
229
+ }
230
+ try {
231
+ rawPrompt = readFileSync(promptFile, "utf8").trim();
232
+ }
233
+ catch (err) {
234
+ return printUsageError(json, `claudexor: --prompt-file: cannot read ${promptFile}: ${err instanceof Error ? err.message : String(err)}`);
235
+ }
236
+ }
237
+ else if (rawPrompt === "-") {
238
+ rawPrompt = readFileSync(0, "utf8").trim();
239
+ if (!rawPrompt) {
240
+ return printUsageError(json, "claudexor: stdin prompt (`-`) was empty");
241
+ }
242
+ }
191
243
  const specPath = flagStr(args, "spec");
192
244
  let loadedSpec = null;
193
245
  try {
@@ -229,9 +281,14 @@ async function orchestrate(args, mode, json, forced = {}) {
229
281
  return printUsageError(json, "claudexor: missing prompt");
230
282
  }
231
283
  const portfolioRaw = flagStr(args, "portfolio");
232
- const portfolio = portfolioRaw !== undefined ? Portfolio.safeParse(portfolioRaw) : null;
233
- if (portfolioRaw !== undefined && !portfolio?.success) {
234
- return printUsageError(json, `claudexor: unknown --portfolio '${portfolioRaw}'`);
284
+ if (portfolioRaw !== undefined) {
285
+ return printUsageError(json, "claudexor: --portfolio was removed in v2; use --routing-goal auto|quality|economy");
286
+ }
287
+ const credentialProfileId = flagStr(args, "profile");
288
+ const routingGoalRaw = flagStr(args, "routing-goal");
289
+ const routingGoal = routingGoalRaw !== undefined ? RoutingGoal.safeParse(routingGoalRaw) : null;
290
+ if (routingGoalRaw !== undefined && !routingGoal?.success) {
291
+ return printUsageError(json, `claudexor: unknown --routing-goal '${routingGoalRaw}'`);
235
292
  }
236
293
  let reviewerEffortOverrides;
237
294
  let resolvedReviewerModels;
@@ -239,7 +296,7 @@ async function orchestrate(args, mode, json, forced = {}) {
239
296
  let resolvedWebPolicy = undefined;
240
297
  let resolvedAccess = undefined;
241
298
  let resolvedEffort;
242
- let maxUsd;
299
+ let paidBudget;
243
300
  let maxToolCalls;
244
301
  let nFlag;
245
302
  let attemptsFlag;
@@ -248,9 +305,13 @@ async function orchestrate(args, mode, json, forced = {}) {
248
305
  let resolvedHarnesses;
249
306
  let resolvedPrimaryHarness;
250
307
  let resolvedModel;
251
- let attachments;
252
308
  let attachmentRequest;
253
309
  let resolvedProtectedPathApprovals;
310
+ let resolvedInstructions;
311
+ let resolvedMaxSeconds;
312
+ let resolvedMaxTurns;
313
+ let resolvedDenyPaths;
314
+ let resolvedOutputSchema;
254
315
  try {
255
316
  reviewerEffortOverrides = reviewerEfforts(args);
256
317
  resolvedReviewerModels = reviewerModels(args);
@@ -261,15 +322,41 @@ async function orchestrate(args, mode, json, forced = {}) {
261
322
  resolvedHarnesses = harnessList(args);
262
323
  resolvedPrimaryHarness = flagStr(args, "primary-harness");
263
324
  resolvedModel = flagStr(args, "model");
264
- maxUsd = floatFlag(args, "max-usd");
325
+ const maxUsd = floatFlag(args, "max-usd");
326
+ paidBudget = maxUsd === undefined ? undefined : { kind: "finite", maxUsd };
265
327
  maxToolCalls = intFlag(args, "max-tool-calls");
266
328
  nFlag = intFlag(args, "n");
267
329
  attemptsFlag = intFlag(args, "attempts");
268
330
  autonomy = parseAutonomy(flagStr(args, "autonomy"));
269
331
  resolvedSynthesis = synthesisMode(args);
270
332
  attachmentRequest = attachmentInputs(args);
271
- attachments = attachmentsFromInputs(attachmentRequest);
272
333
  resolvedProtectedPathApprovals = protectedPathApprovals(args);
334
+ resolvedInstructions = resolveInstructions(args);
335
+ resolvedMaxSeconds = intFlag(args, "max-seconds");
336
+ resolvedMaxTurns = intFlag(args, "max-turns");
337
+ const denyPathFlags = flagStringList(args, "deny-path");
338
+ resolvedDenyPaths = denyPathFlags.length > 0 ? denyPathFlags : undefined;
339
+ const outputSchemaPath = flagStr(args, "output-schema");
340
+ if (outputSchemaPath !== undefined) {
341
+ let raw;
342
+ try {
343
+ raw = readFileSync(outputSchemaPath, "utf8");
344
+ }
345
+ catch (err) {
346
+ throw new Error(`--output-schema: cannot read ${outputSchemaPath}: ${err instanceof Error ? err.message : String(err)}`);
347
+ }
348
+ let parsedSchema;
349
+ try {
350
+ parsedSchema = JSON.parse(raw);
351
+ }
352
+ catch {
353
+ throw new Error(`--output-schema: ${outputSchemaPath} is not valid JSON`);
354
+ }
355
+ if (!parsedSchema || typeof parsedSchema !== "object" || Array.isArray(parsedSchema)) {
356
+ throw new Error(`--output-schema: ${outputSchemaPath} must contain a JSON Schema object`);
357
+ }
358
+ resolvedOutputSchema = parsedSchema;
359
+ }
273
360
  }
274
361
  catch (err) {
275
362
  return printUsageError(json, `claudexor: ${err instanceof Error ? err.message : String(err)}`);
@@ -280,6 +367,7 @@ async function orchestrate(args, mode, json, forced = {}) {
280
367
  tests = resolveRunTestCommands(cliTests, spec);
281
368
  assertCliRunParamsHaveNoInlineSecrets({
282
369
  prompt,
370
+ instructions: resolvedInstructions,
283
371
  attachments: attachmentRequest,
284
372
  mode,
285
373
  harnesses: resolvedHarnesses,
@@ -291,7 +379,7 @@ async function orchestrate(args, mode, json, forced = {}) {
291
379
  reviewerEfforts: reviewerEffortOverrides,
292
380
  tests,
293
381
  protectedPathApprovals: resolvedProtectedPathApprovals,
294
- maxUsd,
382
+ paidBudget,
295
383
  access: resolvedAccess,
296
384
  web: resolvedWebPolicy,
297
385
  externalContextPolicy: resolvedWebPolicy,
@@ -305,137 +393,60 @@ async function orchestrate(args, mode, json, forced = {}) {
305
393
  catch (err) {
306
394
  return printUsageError(json, `claudexor: ${err instanceof Error ? err.message : String(err)}`);
307
395
  }
308
- // `--autonomy` only governs the orchestrate executor; on any other
309
- // mode it would silently do nothing, so reject it loudly (a misplaced flag is
310
- // an error, not a no-op).
311
396
  if (autonomy !== undefined && mode !== "orchestrate") {
312
397
  return printUsageError(json, `claudexor: --autonomy only applies to 'orchestrate' (got mode '${mode}')`);
313
398
  }
314
- // The mutating run paths are DAEMON-TRACKED: they enqueue via the daemon
315
- // (auto-started if needed) so the control-api can see/unblock them and a
316
- // blocked run is unblockable through `claudexor decision`. This covers `agent`
317
- // and an `orchestrate` run that actually ACTS (auto_safe/auto_full execute the
318
- // plan against the tree). Read-only routes (ask/plan/audit and orchestrate in
319
- // the default `suggest` autonomy, where the plan IS the work product) have
320
- // nothing to apply or unblock, so they stay in-process.
321
- const orchestrateExecutes = mode === "orchestrate" && autonomy !== undefined && autonomy !== "suggest";
322
- // --max-tool-calls caps the orchestrate EXECUTOR's plan steps; on any other
323
- // mode it would be a silent no-op knob (INV-023) — refuse loudly.
324
399
  if (maxToolCalls !== undefined && mode !== "orchestrate") {
325
400
  return printUsageError(json, "claudexor: --max-tool-calls only applies to orchestrate runs");
326
401
  }
327
- if (mode === "agent" || orchestrateExecutes) {
328
- return daemonAgentRun(args, json, {
329
- mode,
330
- autonomy,
331
- prompt,
332
- tests,
333
- portfolio: portfolio?.success ? portfolio.data : undefined,
334
- maxUsd,
335
- maxToolCalls,
336
- reviewerPanel: resolvedReviewerPanel,
337
- reviewerModels: resolvedReviewerModels,
338
- reviewerEfforts: reviewerEffortOverrides,
339
- protectedPathApprovals: resolvedProtectedPathApprovals,
340
- resolvedWebPolicy,
341
- resolvedAccess,
342
- resolvedEffort,
343
- resolvedSynthesis,
344
- nFlag,
345
- attemptsFlag,
346
- specId: spec?.id,
347
- specHash: loadedSpec?.specHash,
348
- specPath: loadedSpec?.specPath,
349
- attachmentRequest,
350
- forced,
351
- });
352
- }
353
- const orch = new Orchestrator({
354
- registry: buildRegistry(),
355
- portfolio: portfolio?.success ? portfolio.data : undefined,
356
- maxUsd: maxUsd ?? null,
402
+ return daemonRun(args, json, {
403
+ mode,
404
+ autonomy,
405
+ prompt: prompt || "audit this repository",
406
+ instructions: resolvedInstructions,
407
+ maxSeconds: resolvedMaxSeconds,
408
+ maxTurns: resolvedMaxTurns,
409
+ denyPaths: resolvedDenyPaths,
410
+ outputSchema: resolvedOutputSchema,
411
+ tests,
412
+ paidBudget,
413
+ routingGoal: routingGoal?.success ? routingGoal.data : undefined,
414
+ credentialProfileId,
415
+ maxToolCalls,
357
416
  reviewerPanel: resolvedReviewerPanel,
358
417
  reviewerModels: resolvedReviewerModels,
359
418
  reviewerEfforts: reviewerEffortOverrides,
419
+ protectedPathApprovals: resolvedProtectedPathApprovals,
420
+ resolvedWebPolicy,
421
+ resolvedAccess,
422
+ resolvedEffort,
423
+ resolvedSynthesis,
424
+ resolvedHarnesses,
425
+ resolvedPrimaryHarness,
426
+ resolvedModel,
427
+ nFlag,
428
+ attemptsFlag,
429
+ specId: spec?.id,
430
+ specHash: loadedSpec?.specHash,
431
+ specPath: loadedSpec?.specPath,
432
+ attachmentRequest,
433
+ forced,
360
434
  });
361
- // Ctrl-C on a direct (in-process) run cancels GRACEFULLY: abort the run
362
- // signal so harness children die via the process-group kill plumbing, gates
363
- // are skipped, and the run ends with a typed cancelled terminal instead
364
- // of the node process dying and orphaning children + a terminal-less
365
- // events.jsonl. A second Ctrl-C force-exits.
366
- const cancelController = new AbortController();
367
- let sigints = 0;
368
- const onSigint = () => {
369
- sigints += 1;
370
- if (sigints >= 2)
371
- process.exit(130);
372
- process.stderr.write("\ncancelling run (Ctrl-C again to force-quit)...\n");
373
- cancelController.abort();
374
- };
375
- process.on("SIGINT", onSigint);
376
- process.on("SIGTERM", onSigint);
435
+ }
436
+ /**
437
+ * All five product modes enter through the managed daemon and control API.
438
+ * `--json` prints one stable `{ runId, runDir, status }` machine envelope.
439
+ */
440
+ async function daemonRun(args, json, p) {
441
+ const inPlace = flagBool(args, "in-place");
442
+ const jsonStream = flagBool(args, "json-stream");
443
+ if (json && jsonStream) {
444
+ return printUsageError(json, "claudexor: --json prints exactly one object and --json-stream prints NDJSON; pass one, not both");
445
+ }
446
+ let client;
447
+ let addr;
377
448
  try {
378
- const res = await orch.run({
379
- repoRoot: process.cwd(),
380
- prompt: prompt || "audit this repository",
381
- attachments,
382
- mode,
383
- signal: cancelController.signal,
384
- // Only `suggest` (or unset) reaches the in-process path; auto_safe/auto_full
385
- // are routed to the daemon above. Forward it so an explicit --autonomy
386
- // suggest is honoured rather than silently dropped.
387
- ...(autonomy ? { autonomy } : {}),
388
- harnesses: resolvedHarnesses,
389
- primaryHarness: resolvedPrimaryHarness,
390
- portfolio: portfolio?.success ? portfolio.data : undefined,
391
- n: forced.race === true ? (nFlag ?? 2) : nFlag,
392
- attempts: attemptsFlag ?? null,
393
- untilClean: flagBool(args, "until-clean"),
394
- swarm: forced.swarm === true || flagBool(args, "swarm"),
395
- create: forced.create === true || flagBool(args, "create"),
396
- tests,
397
- protectedPathApprovals: resolvedProtectedPathApprovals,
398
- maxUsd: maxUsd ?? null,
399
- maxToolCalls: maxToolCalls ?? null,
400
- access: resolvedAccess,
401
- web: resolvedWebPolicy,
402
- externalContextPolicy: resolvedWebPolicy,
403
- model: resolvedModel,
404
- effort: resolvedEffort,
405
- synthesis: resolvedSynthesis,
406
- specId: spec?.id,
407
- specHash: loadedSpec?.specHash,
408
- specPath: loadedSpec?.specPath,
409
- inPlace: flagBool(args, "in-place"),
410
- // Live progress + interactive answers on a TTY; --json stays a pure
411
- // machine surface (no printer, no prompts — questions decline benignly).
412
- ...(json
413
- ? {}
414
- : {
415
- onEvent: (ev) => {
416
- const line = formatRunEventLine(ev);
417
- if (line)
418
- print(line);
419
- },
420
- onInteraction: (ctx) => promptQuestionsOnTty(ctx.request.interaction_id, ctx.request.questions, ctx.timeoutAt),
421
- }),
422
- });
423
- if (json) {
424
- // One machine surface: on a non-success terminal, ADD a top-level `error`
425
- // (mirroring the daemon path's `error`) so a JSON consumer reads the reason
426
- // the same way regardless of which run path produced it. Add-only: the full
427
- // result (runId/runDir/status/mode/winner/summary/candidates) is preserved.
428
- printJson(res.status === "success" ? res : { ...res, error: res.summary });
429
- }
430
- else {
431
- print(`run ${res.runId} [${res.status}] mode=${res.mode} winner=${res.winner ?? "none"}`);
432
- print(` artifacts: ${res.runDir}`);
433
- for (const c of res.candidates)
434
- print(` - ${c.attemptId} ${c.harnessId} [${c.status}]`);
435
- print("");
436
- print(res.summary);
437
- }
438
- return res.status === "success" ? 0 : 1;
449
+ ({ client, addr } = await ensureDaemon());
439
450
  }
440
451
  catch (err) {
441
452
  if (json)
@@ -448,38 +459,81 @@ async function orchestrate(args, mode, json, forced = {}) {
448
459
  process.stderr.write(`claudexor: ${err instanceof Error ? err.message : String(err)}\n`);
449
460
  return 1;
450
461
  }
451
- finally {
452
- process.removeListener("SIGINT", onSigint);
453
- process.removeListener("SIGTERM", onSigint);
462
+ // Thread continuation (W13): --thread <id> targets a thread explicitly;
463
+ // --resume picks the most recently updated one. POST /runs already funnels a
464
+ // threadId through the SINGLE createThreadTurn point server-side — this is
465
+ // parameter plumbing, never a second turn-creation path.
466
+ let threadId = flagStr(args, "thread");
467
+ if (threadId === undefined && flagBool(args, "resume")) {
468
+ try {
469
+ const res = await controlApiFetch(addr, "/threads", {
470
+ headers: { Authorization: `Bearer ${addr.token}` },
471
+ });
472
+ if (!res.ok)
473
+ throw new Error(`GET /threads failed: ${res.status}`);
474
+ // Parse through the typed DTO — the field is camelCase `updatedAt`, and a
475
+ // hand-rolled `updated_at` read silently sorts undefined and throws.
476
+ const list = ControlThreadListResponse.parse(await res.json());
477
+ const newest = pickResumableThread(list.threads);
478
+ if (!newest) {
479
+ // Stay valid on the active output surface: NDJSON stream keeps compact
480
+ // lines; --json prints its one object; text goes to stderr.
481
+ const message = "claudexor: --resume found no threads to continue";
482
+ if (jsonStream)
483
+ printJsonLine({ ok: false, exitCode: 1, error: message });
484
+ else if (json)
485
+ printJson({ ok: false, exitCode: 1, error: message });
486
+ else
487
+ process.stderr.write(`${message}\n`);
488
+ return 1;
489
+ }
490
+ threadId = newest.id;
491
+ }
492
+ catch (err) {
493
+ const message = `claudexor: --resume could not list threads: ${err instanceof Error ? err.message : String(err)}`;
494
+ if (jsonStream)
495
+ printJsonLine({ ok: false, exitCode: 1, error: message });
496
+ else if (json)
497
+ printJson({ ok: false, exitCode: 1, error: message });
498
+ else
499
+ process.stderr.write(`${message}\n`);
500
+ return 1;
501
+ }
502
+ }
503
+ let attachmentRefs;
504
+ try {
505
+ attachmentRefs = p.attachmentRequest
506
+ ? await Promise.all(p.attachmentRequest.map((attachment) => uploadLocalAttachment(addr, attachment)))
507
+ : undefined;
508
+ }
509
+ catch (err) {
510
+ if (json)
511
+ printJson({
512
+ ok: false,
513
+ exitCode: 1,
514
+ error: `claudexor: attachment upload failed: ${err instanceof Error ? err.message : String(err)}`,
515
+ });
516
+ else
517
+ process.stderr.write(`claudexor: attachment upload failed: ${err instanceof Error ? err.message : String(err)}\n`);
518
+ return 1;
454
519
  }
455
- }
456
- /**
457
- * DAEMON-TRACKED mutating run (the unified `agent`/`best-of`/`create` path, and an
458
- * `orchestrate` run with auto_safe/auto_full autonomy). Auto-starts
459
- * the daemon if needed, enqueues via the control API (so the run is registered
460
- * and the control-api can see/unblock it), streams its events to the TTY (text
461
- * mode) and resolves the runDir from the daemon (the run lives under the daemon
462
- * dir, not project-local) so apply/decision/inspect can find it. `--json` prints
463
- * exactly one object `{ runId, runDir, status }` — the SWE/TB benchmark runner
464
- * parses this to read <runDir>/final/patch.diff.
465
- */
466
- async function daemonAgentRun(args, json, p) {
467
- // The run is always project-scoped (the cwd is its repo); execution is
468
- // live in-place only under the explicit --in-place convergence path.
469
- const inPlace = flagBool(args, "in-place");
470
520
  const body = {
471
521
  prompt: p.prompt,
472
- ...(p.attachmentRequest ? { attachments: p.attachmentRequest } : {}),
522
+ ...(p.instructions ? { instructions: p.instructions } : {}),
523
+ ...(p.maxSeconds !== undefined ? { maxSeconds: p.maxSeconds } : {}),
524
+ ...(p.maxTurns !== undefined ? { maxTurns: p.maxTurns } : {}),
525
+ ...(p.denyPaths?.length ? { denyPaths: p.denyPaths } : {}),
526
+ ...(p.outputSchema !== undefined ? { outputSchema: p.outputSchema } : {}),
527
+ ...(attachmentRefs ? { attachments: attachmentRefs } : {}),
473
528
  mode: p.mode,
474
- // Autonomy only governs the orchestrate executor; send it only for that mode.
529
+ ...(threadId ? { threadId } : {}),
475
530
  ...(p.mode === "orchestrate" && p.autonomy ? { autonomy: p.autonomy } : {}),
476
531
  scope: { kind: "project", root: process.cwd() },
477
532
  execution: { isolation: inPlace ? "live" : "envelope" },
478
- ...(harnessList(args) ? { harnesses: harnessList(args) } : {}),
479
- ...(flagStr(args, "primary-harness")
480
- ? { primaryHarness: flagStr(args, "primary-harness") }
481
- : {}),
482
- ...(p.portfolio ? { portfolio: p.portfolio } : {}),
533
+ ...(p.resolvedHarnesses ? { harnesses: p.resolvedHarnesses } : {}),
534
+ ...(p.resolvedPrimaryHarness ? { primaryHarness: p.resolvedPrimaryHarness } : {}),
535
+ ...(p.routingGoal ? { routingGoal: p.routingGoal } : {}),
536
+ ...(p.credentialProfileId ? { credentialProfileId: p.credentialProfileId } : {}),
483
537
  ...(p.forced.race === true ? { n: p.nFlag ?? 2 } : p.nFlag !== undefined ? { n: p.nFlag } : {}),
484
538
  ...(p.attemptsFlag !== undefined ? { attempts: p.attemptsFlag } : {}),
485
539
  ...(flagBool(args, "until-clean") ? { untilClean: true } : {}),
@@ -488,11 +542,13 @@ async function daemonAgentRun(args, json, p) {
488
542
  ...(p.resolvedSynthesis ? { synthesis: p.resolvedSynthesis } : {}),
489
543
  ...(p.tests ? { tests: p.tests } : {}),
490
544
  ...(p.protectedPathApprovals ? { protectedPathApprovals: p.protectedPathApprovals } : {}),
491
- ...(p.maxUsd !== undefined ? { maxUsd: p.maxUsd } : {}),
492
- ...(p.mode === "orchestrate" && p.maxToolCalls !== undefined ? { maxToolCalls: p.maxToolCalls } : {}),
545
+ ...(p.paidBudget !== undefined ? { paidBudget: p.paidBudget } : {}),
546
+ ...(p.mode === "orchestrate" && p.maxToolCalls !== undefined
547
+ ? { maxToolCalls: p.maxToolCalls }
548
+ : {}),
493
549
  ...(p.resolvedAccess ? { access: p.resolvedAccess } : {}),
494
550
  ...(p.resolvedWebPolicy ? { web: p.resolvedWebPolicy } : {}),
495
- ...(flagStr(args, "model") ? { model: flagStr(args, "model") } : {}),
551
+ ...(p.resolvedModel ? { model: p.resolvedModel } : {}),
496
552
  ...(p.resolvedEffort ? { effort: p.resolvedEffort } : {}),
497
553
  ...(p.reviewerPanel ? { reviewerPanel: p.reviewerPanel } : {}),
498
554
  ...(p.reviewerModels ? { reviewerModels: p.reviewerModels } : {}),
@@ -502,15 +558,63 @@ async function daemonAgentRun(args, json, p) {
502
558
  ...(p.specPath ? { specPath: p.specPath } : {}),
503
559
  };
504
560
  try {
505
- const { client, addr } = await ensureDaemon();
561
+ if (jsonStream) {
562
+ // NDJSON machine surface (W13): an EARLY runId frame first, every run
563
+ // event as its own line (the shared follow pipeline in json mode), and
564
+ // the same terminal object --json prints as the LAST line. --json keeps
565
+ // its exactly-one-object contract untouched.
566
+ const started = await enqueueAndAwait(client, addr, body, { waitForTerminal: false });
567
+ if (!started.runId) {
568
+ // Even the early-failure path stays valid NDJSON (compact, one line).
569
+ printJsonLine({
570
+ frame: "run.terminal",
571
+ runId: "",
572
+ runDir: started.runDir,
573
+ status: runStatusForCli(started.status),
574
+ jobId: started.jobId,
575
+ mode: p.mode,
576
+ ...(started.error ? { error: started.error } : {}),
577
+ });
578
+ return exitCodeForState(started.status);
579
+ }
580
+ printJsonLine({
581
+ frame: "run.started",
582
+ runId: started.runId,
583
+ runDir: started.runDir,
584
+ jobId: started.jobId,
585
+ mode: p.mode,
586
+ });
587
+ // Per-event lines: followRun(json=true) already writes one COMPACT object
588
+ // per event via print(JSON.stringify(ev)).
589
+ await followRun(started.runId, true);
590
+ const final = started.jobId ? await client.status(started.jobId) : null;
591
+ const status = final?.state ?? started.status;
592
+ const out = {
593
+ runId: started.runId,
594
+ runDir: final?.runDir ?? started.runDir,
595
+ status: runStatusForCli(status),
596
+ jobId: started.jobId,
597
+ error: final?.error ?? started.error,
598
+ };
599
+ const reason = daemonOutcomeSummary({ ...started, status, error: out.error });
600
+ const applyEligibility = await fetchApplyEligibility(addr, started.runId);
601
+ printJsonLine({
602
+ frame: "run.terminal",
603
+ runId: out.runId,
604
+ runDir: out.runDir,
605
+ status: out.status,
606
+ jobId: out.jobId,
607
+ mode: p.mode,
608
+ ...(out.error ? { error: out.error } : {}),
609
+ ...(reason ? { summary: reason } : {}),
610
+ ...(applyEligibility ? { applyEligibility } : {}),
611
+ });
612
+ return exitCodeForState(status);
613
+ }
506
614
  if (json) {
507
- // Pure machine surface: enqueue, wait for the terminal outcome, print
508
- // exactly one JSON object. No event printer, no TTY prompts.
615
+ // Pure machine surface: await the terminal outcome and print one JSON object.
509
616
  const out = await enqueueAndAwait(client, addr, body, { waitForTerminal: true });
510
- // Keep the documented bench-parser keys {runId,runDir,status}; ADD `mode`,
511
- // `error` (only for real errors), and `summary` (a machine-readable reason
512
- // for EVERY non-success terminal incl. `blocked`, which carries no error)
513
- // so this path matches the in-process one (P2: one machine surface).
617
+ // Preserve bench keys while adding mode and honest non-success detail.
514
618
  const reason = daemonOutcomeSummary(out);
515
619
  // ADD-ONLY key (bench contract keeps {runId,runDir,status}): the derived
516
620
  // apply-gate verdict, so machine callers act on truth instead of
@@ -519,7 +623,7 @@ async function daemonAgentRun(args, json, p) {
519
623
  printJson({
520
624
  runId: out.runId,
521
625
  runDir: out.runDir,
522
- status: out.status,
626
+ status: runStatusForCli(out.status),
523
627
  jobId: out.jobId,
524
628
  mode: p.mode,
525
629
  ...(out.error ? { error: out.error } : {}),
@@ -539,19 +643,15 @@ async function daemonAgentRun(args, json, p) {
539
643
  await followRun(started.runId, false);
540
644
  const final = started.jobId ? await client.status(started.jobId) : null;
541
645
  const status = final?.state ?? started.status;
646
+ const publicStatus = runStatusForCli(status);
542
647
  print("");
543
- print(`run ${started.runId} [${status}]`);
648
+ print(`run ${started.runId} [${publicStatus}]`);
544
649
  print(` artifacts: ${final?.runDir ?? started.runDir}`);
545
650
  if (status === "blocked") {
546
651
  print(` blocked (needs human): unblock with \`claudexor decision ${started.runId} --accept-risk\` or rerun with \`claudexor decision ${started.runId} --rerun --feedback "..."\``);
547
652
  }
548
653
  else if (exitCodeForState(status) === 0) {
549
- // Honest post-run hint: "apply with" is printed ONLY when the apply
550
- // gate verifiably accepts the patch. A null verdict means no patch
551
- // artifact (answer/no-op runs) or an unreachable detail endpoint —
552
- // either way, promising `claudexor apply` would be a lie, so print the
553
- // inspect handle instead. Ineligible runs get the honest unblock hint
554
- // (ungated/review_not_run used to get a doomed apply command here).
654
+ // Offer apply only after a positive gate; otherwise print inspect/unblock guidance.
555
655
  const eligibility = await fetchApplyEligibility(addr, started.runId);
556
656
  if (eligibility?.eligible) {
557
657
  print(` apply with: claudexor apply ${started.runId}`);
@@ -595,7 +695,7 @@ async function decisionCommand(args, json) {
595
695
  const { action, body } = resolved;
596
696
  try {
597
697
  const { addr } = await ensureDaemon();
598
- const res = await fetch(`${addr.baseUrl}/runs/${encodeURIComponent(runId)}/decision`, {
698
+ const res = await controlApiFetch(addr, `/runs/${encodeURIComponent(runId)}/decision`, {
599
699
  method: "POST",
600
700
  headers: { Authorization: `Bearer ${addr.token}`, "content-type": "application/json" },
601
701
  body: JSON.stringify(body),
@@ -639,9 +739,9 @@ async function decisionCommand(args, json) {
639
739
  * Resolve the ArtifactStore that owns a given run, regardless of the cwd the
640
740
  * CLI is invoked from. Order:
641
741
  * 1. the project store rooted at the current cwd (the common case);
642
- * 2. the user-level store (~/.claudexor/runs) used by no-project Ask runs;
742
+ * 2. the v2 user store (~/.claudexor/v2/runs) used by no-project Ask runs;
643
743
  * 3. a daemon-tracked run that started in ANOTHER project — agent/race/create
644
- * runs live under `<thatProjectRoot>/.claudexor/runs/<runId>`, so we ask
744
+ * runs live under that project's external runtime namespace, so we ask
645
745
  * the daemon for the run's absolute runDir (GET /runs/:id ->
646
746
  * summary.runDir) and rebuild a store whose runPaths(runId).root matches.
647
747
  * Returns null when no store can be located (the run does not exist anywhere
@@ -672,15 +772,15 @@ async function resolveRunStore(runId) {
672
772
  if (!conn)
673
773
  return null;
674
774
  const { addr } = conn;
675
- const resp = await fetch(`${addr.baseUrl}/runs/${encodeURIComponent(runId)}`, {
775
+ const resp = await controlApiFetch(addr, `/runs/${encodeURIComponent(runId)}`, {
676
776
  headers: { Authorization: `Bearer ${addr.token}` },
677
777
  });
678
778
  if (resp.ok) {
679
779
  const detail = (await resp.json());
680
780
  const runDir = detail.summary?.runDir;
681
781
  if (runDir && existsSync(runDir)) {
682
- // runDir = <repoRoot>/.claudexor/runs/<runId>; reconstruct a store whose
683
- // runPaths(runId).root === runDir: runId -> runs -> .claudexor.
782
+ // Reconstruct a store from the daemon-authoritative absolute runDir:
783
+ // runId -> runs -> owned runtime root.
684
784
  const claudexorDir = resolve(runDir, "..", "..");
685
785
  const ds = new ArtifactStore(dirname(claudexorDir), { claudexorDir });
686
786
  if (existsSync(ds.runPaths(runId).root))
@@ -740,79 +840,66 @@ async function specCommand(args, json) {
740
840
  return printUsageError(json, "claudexor spec: --harness/--n/--web/--effort/--max-usd/--reviewer-panel/--reviewer-model/--reviewer-effort control the grounding plan run and only apply when generating questions; drop them when re-running with --answers");
741
841
  }
742
842
  try {
743
- const answers = answersPath ? readAnswers(answersPath) : null;
744
- let planRunId = answers?.planRunId ?? "";
745
- let planDir = answers?.planDir ?? "";
746
- let planText = planDir ? (readTextSafe(join(planDir, "final", "plan.md")) ?? "") : "";
747
- if (!planText) {
748
- if (answersPath) {
749
- throw new Error("answers file does not contain a usable planDir/final/plan.md; re-run without --answers to generate a fresh questions file");
750
- }
751
- const orch = new Orchestrator({
752
- registry: buildRegistry(),
753
- reviewerPanel: groundingReviewerPanel,
754
- reviewerModels: groundingReviewerModels,
755
- reviewerEfforts: groundingReviewerEfforts,
756
- });
757
- const plan = await orch.run({
758
- repoRoot: process.cwd(),
759
- prompt,
760
- mode: "plan",
761
- harnesses: groundingHarnesses,
762
- n: groundingN,
763
- access: "readonly",
764
- web: groundingWeb,
765
- effort: groundingEffort,
766
- maxUsd: groundingMaxUsd ?? null,
767
- });
768
- planRunId = plan.runId;
769
- planDir = plan.runDir;
770
- planText = readTextSafe(join(plan.runDir, "final", "plan.md")) ?? plan.summary;
771
- }
772
- const questions = extractQuestionsFromPlan(planText);
843
+ const { addr } = await ensureDaemon();
844
+ await controlJson(addr, "/projects", {
845
+ method: "POST",
846
+ headers: { "content-type": "application/json" },
847
+ body: JSON.stringify({ root: process.cwd() }),
848
+ });
773
849
  if (!answersPath) {
774
- const draftDir = join(process.cwd(), ".claudexor", "specs", "drafts", planRunId);
775
- ensureDir(draftDir);
776
- const questionsPath = join(draftDir, "questions.json");
777
- writeJson(questionsPath, { prompt, planRunId, planDir, questions, answers: [] });
778
- const result = {
779
- status: "questions",
780
- planRunId,
781
- planDir,
782
- questionsPath,
783
- questions,
784
- };
850
+ const session = ControlSpecSession.parse(await controlJson(addr, "/spec/sessions", {
851
+ method: "POST",
852
+ headers: { "content-type": "application/json" },
853
+ body: JSON.stringify({
854
+ prompt,
855
+ scope: { kind: "project", root: process.cwd(), context: "auto" },
856
+ harnesses: groundingHarnesses,
857
+ n: groundingN,
858
+ effort: groundingEffort,
859
+ paidBudget: groundingMaxUsd === undefined
860
+ ? undefined
861
+ : { kind: "finite", maxUsd: groundingMaxUsd },
862
+ web: groundingWeb,
863
+ reviewerPanel: groundingReviewerPanel,
864
+ reviewerModels: groundingReviewerModels,
865
+ reviewerEfforts: groundingReviewerEfforts,
866
+ }),
867
+ }));
785
868
  if (json)
786
- printJson(result);
869
+ printJson(session);
787
870
  else {
788
- print(`plan grounding run: ${planRunId}`);
789
- print(`questions: ${questionsPath}`);
790
- print(`answer with: claudexor spec ${JSON.stringify(prompt)} --answers ${questionsPath}`);
871
+ print(`durable spec session: ${session.sessionId}`);
872
+ print(`grounding run: ${session.planRunId ?? "pending"}`);
873
+ for (const question of session.questions)
874
+ print(`- [${question.id}] ${question.prompt}`);
875
+ print("save `claudexor spec ... --json` output, fill its answers array, then pass it with --answers");
791
876
  }
792
- return 0;
877
+ return session.state === "failed" || session.state === "interrupted_unknown" ? 1 : 0;
878
+ }
879
+ const answers = readAnswers(answersPath);
880
+ if (!answers.sessionId) {
881
+ throw new Error("answers file must contain sessionId from the durable spec session");
793
882
  }
794
- const spec = await freezeSpecFromGrounding(prompt, planText, answers ?? readAnswers(answersPath));
795
- const persisted = persistSpec(process.cwd(), spec, planText, loadPreviousSpec(flagStr(args, "previous")));
796
- const specJsonPath = join(persisted.specDir, "spec.json");
797
- const runHint = `claudexor best-of --spec ${JSON.stringify(specJsonPath)}`;
798
- const result = {
799
- status: "frozen",
800
- planRunId,
801
- planDir,
802
- specId: spec.id,
803
- specDir: persisted.specDir,
804
- specHash: persisted.specHash,
805
- runHint,
806
- questions,
807
- changes: persisted.changes,
808
- };
883
+ const encodedId = encodeURIComponent(answers.sessionId);
884
+ await controlJson(addr, `/spec/sessions/${encodedId}/answers`, {
885
+ method: "POST",
886
+ headers: { "content-type": "application/json" },
887
+ body: JSON.stringify({
888
+ answers: answers.answers,
889
+ priorDecisions: answers.priorDecisions,
890
+ }),
891
+ });
892
+ const session = ControlSpecSession.parse(await controlJson(addr, `/spec/sessions/${encodedId}/freeze`, { method: "POST" }));
893
+ if (session.state !== "frozen" || !session.specPath || !session.specId || !session.specHash) {
894
+ throw new Error(`spec session ended as ${session.state}`);
895
+ }
896
+ const runHint = `claudexor best-of --spec ${JSON.stringify(session.specPath)}`;
809
897
  if (json)
810
- printJson(result);
898
+ printJson({ ...session, status: "frozen", runHint });
811
899
  else {
812
- print(`frozen SpecPack: ${spec.id} v${spec.version}`);
813
- print(` dir: ${persisted.specDir}`);
814
- print(` hash: ${persisted.specHash}`);
815
- print(` native projection: ${join(persisted.specDir, "PLANS.md")}`);
900
+ print(`frozen SpecPack: ${session.specId}`);
901
+ print(` path: ${session.specPath}`);
902
+ print(` hash: ${session.specHash}`);
816
903
  print(`run: ${runHint}`);
817
904
  }
818
905
  return 0;
@@ -831,6 +918,15 @@ async function specCommand(args, json) {
831
918
  return 1;
832
919
  }
833
920
  }
921
+ async function controlJson(addr, path, init = {}) {
922
+ const response = await controlApiFetch(addr, path, init);
923
+ const text = await response.text();
924
+ const body = text ? JSON.parse(text) : {};
925
+ if (response.ok)
926
+ return body;
927
+ const detail = body && typeof body === "object" ? body : {};
928
+ throw new Error(String(detail["message"] ?? detail["error"] ?? `HTTP ${response.status}`));
929
+ }
834
930
  function printPreflightError(args, json, error) {
835
931
  if (json && (args._[0] ?? "help") === "plugin") {
836
932
  printJson(pluginCommandErrorResult(args._[1], args._[2], flagBool(args, "dry-run"), 2, error));
@@ -892,6 +988,9 @@ async function main() {
892
988
  return runRepl(process.cwd());
893
989
  }
894
990
  const cwd = process.cwd();
991
+ const opsCommand = dispatchOpsCommand(cmd, args, json);
992
+ if (opsCommand)
993
+ return opsCommand;
895
994
  switch (cmd) {
896
995
  case "init": {
897
996
  const res = initProjectConfig(cwd);
@@ -901,77 +1000,8 @@ async function main() {
901
1000
  print(res.created ? `Created ${res.configPath}` : `Config already exists: ${res.configPath}`);
902
1001
  return 0;
903
1002
  }
904
- case "doctor": {
905
- const cfg = loadConfig(cwd);
906
- const only = flagStr(args, "harness");
907
- const onlyList = only
908
- ? only
909
- .split(",")
910
- .map((s) => s.trim())
911
- .filter(Boolean)
912
- : undefined;
913
- const gateway = buildGateway({ includeFakes: flagBool(args, "all") });
914
- // Scope discovery to the requested harness(es) (P14): a single-harness
915
- // query no longer probes every adapter (incl. paid smokes) just to filter.
916
- const statuses = await gateway.statusAll({ cwd }, onlyList);
917
- // An explicit --harness typo must FAIL LOUDLY, not silently succeed over an
918
- // empty list (the scoped probe returns nothing for an unknown id).
919
- if (onlyList) {
920
- const got = new Set(statuses.map((s) => s.id));
921
- const unknown = onlyList.filter((id) => !got.has(id));
922
- if (unknown.length)
923
- return printUsageError(json, `claudexor: unknown harness(es): ${unknown.join(", ")} (run \`claudexor harness list --all\`)`);
924
- }
925
- const filtered = statuses;
926
- // a configured default model that the harness does not recognize must
927
- // not be silently masked by a smoke that ran a DIFFERENT model. Validate
928
- // each harness's configured default against its model truth source (live
929
- // inventory or manifest hints via the shared harnessModels SSOT) and
930
- // surface a violation honestly as INVALID (strict model truth — no "unverified").
931
- const configuredChecks = new Map();
932
- await Promise.all(filtered.map(async (s) => {
933
- const configured = cfg.global.harnesses[s.id]?.default_model ?? null;
934
- if (!configured)
935
- return;
936
- const truth = await harnessModels(s.id, process.cwd(), true);
937
- const check = validateModel(configured, truth.models.map((m) => m.id), truth.source === "api" ? "api" : "manifest");
938
- configuredChecks.set(s.id, { configured, check });
939
- }));
940
- const modelNote = (s) => {
941
- const entry = configuredChecks.get(s.id);
942
- if (!entry || entry.check.status === "ok")
943
- return null;
944
- return ` model: INVALID — ${entry.check.message}`;
945
- };
946
- if (json) {
947
- printJson({
948
- harnesses: filtered.map((s) => ({
949
- ...s,
950
- configured_model: configuredChecks.get(s.id)?.configured ?? null,
951
- configured_model_check: configuredChecks.get(s.id)?.check ?? null,
952
- })),
953
- node_advisory: atRiskNodeAdvisory(),
954
- });
955
- return 0;
956
- }
957
- for (const s of filtered) {
958
- const ver = s.manifest?.version ? ` ${s.manifest.version}` : "";
959
- print(`${statusGlyph(s.status)} ${s.id}${ver}`);
960
- if (s.enabledIntents.length)
961
- print(` intents: ${s.enabledIntents.join(", ")}`);
962
- print(` auth sources: ${authSourceAvailability(s)}`);
963
- print(` checks: ${checksSummary(s)}`);
964
- if (s.reasons.length)
965
- print(` reasons: ${s.reasons.join(", ")}`);
966
- const mn = modelNote(s);
967
- if (mn)
968
- print(mn);
969
- }
970
- const advisory = atRiskNodeAdvisory();
971
- if (advisory)
972
- print(`advisory: ${advisory}`);
973
- return 0;
974
- }
1003
+ case "project":
1004
+ return projectCommand(args, json);
975
1005
  case "agent": {
976
1006
  const specStrategyError = "claudexor: --spec requires a gated strategy; use 'claudexor best-of --spec <file>' or 'claudexor agent --attempts N --spec <file>'";
977
1007
  // ONE gate for both spellings: `agent --spec` and `agent --mode agent --spec`
@@ -1032,42 +1062,20 @@ async function main() {
1032
1062
  case "audit":
1033
1063
  case "map":
1034
1064
  return orchestrate(args, "audit", json);
1035
- case "daemon":
1036
- return daemonCommand(args, json);
1037
1065
  case "settings":
1038
1066
  return settingsCommand(args, json);
1067
+ case "quota":
1068
+ return quotaCommand(args, json);
1039
1069
  case "trust":
1040
1070
  return trustCommand(args, json);
1041
- case "auth":
1042
- return authCommand(args, json);
1043
- case "models":
1044
- return modelsCommand(args, json);
1045
- case "secrets":
1046
- return secretsCommand(args, json);
1047
1071
  case "mcp": {
1048
- if (args._[1] === "serve") {
1049
- // SDK-owned protocol core; mutating verbs are daemon-tracked, so a
1050
- // run started from an MCP host is visible/unblockable like a CLI run.
1051
- serveClaudexorMcp({
1052
- version: CLAUDEXOR_VERSION,
1053
- tools: defaultClaudexorTools(mcpSurfaceRunner()),
1054
- transport: { read: process.stdin, write: process.stdout },
1055
- });
1056
- // Serve until stdin closes (the SDK handle owns the transport).
1057
- await new Promise((resolve) => process.stdin.once("close", resolve));
1058
- return 0;
1059
- }
1072
+ if (args._[1] === "serve")
1073
+ return serveMcpBridge();
1060
1074
  return printUsageError(json, "usage: claudexor mcp serve");
1061
1075
  }
1062
1076
  case "acp": {
1063
- if (args._[1] === "serve") {
1064
- await new AcpServer({
1065
- version: CLAUDEXOR_VERSION,
1066
- runner: orchestratorRunner(),
1067
- transport: { read: process.stdin, write: process.stdout },
1068
- }).serve();
1069
- return 0;
1070
- }
1077
+ if (args._[1] === "serve")
1078
+ return serveAcpBridge();
1071
1079
  return printUsageError(json, "usage: claudexor acp serve");
1072
1080
  }
1073
1081
  case "follow": {
@@ -1077,6 +1085,10 @@ async function main() {
1077
1085
  }
1078
1086
  return followRun(runId, json);
1079
1087
  }
1088
+ case "retry":
1089
+ return retryCommand(args, json);
1090
+ case "run-again":
1091
+ return runAgainCommand(args, json);
1080
1092
  case "review":
1081
1093
  return reviewCommand(args, json);
1082
1094
  case "inspect": {
@@ -1159,17 +1171,42 @@ async function main() {
1159
1171
  }
1160
1172
  if (telemetry) {
1161
1173
  print(`web: policy=${telemetry.external_context_policy} effective=${telemetry.effective_web_mode} required=${telemetry.web_required} evidence=${telemetry.web.status}`);
1174
+ for (const requirement of telemetry.request_requirements.filter((item) => item.requested)) {
1175
+ print(`${requirement.capability}: harness=${requirement.harness_id} requested=true effective=${requirement.effective} reason=${requirement.reason}`);
1176
+ }
1162
1177
  }
1163
1178
  else if (contract.success) {
1164
1179
  print(`web: policy=${contract.data.external_context.policy} required=${contract.data.external_context.web_required} evidence=unavailable (no telemetry.yaml)`);
1165
1180
  }
1166
1181
  print(`output: ${outputReadyState}${primary ? ` ${primary.path}` : ""}`);
1182
+ {
1183
+ // Structured-output contract receipt (only present when the run was
1184
+ // started with --output-schema); projected, never re-validated here.
1185
+ const conformance = StructuredOutputConformance.safeParse(store.readYaml(join(paths.finalDir, "structured_output.yaml")));
1186
+ if (conformance.success) {
1187
+ print(`structured output: ${conformance.data.status}${conformance.data.output_path ? ` ${conformance.data.output_path}` : ""}${conformance.data.reason ? ` (${conformance.data.reason})` : ""}`);
1188
+ }
1189
+ }
1167
1190
  if (parsedDecision.success) {
1168
1191
  const vb = parsedDecision.data.verification_basis;
1169
1192
  print(`decision: ${parsedDecision.data.status} outcome=${parsedDecision.data.outcome} apply=${parsedDecision.data.apply_recommendation}${vb !== "none" ? ` verified_by=${vb}` : ""}`);
1170
1193
  const budget = parsedDecision.data.budget_summary;
1171
1194
  print(`budget: spend=${budget.spend_usd ?? "unknown"}${budget.estimated ? " estimated" : ""}`);
1172
1195
  }
1196
+ if (telemetry) {
1197
+ const u = telemetry.usage_totals;
1198
+ if (u.input_tokens !== null || u.output_tokens !== null || u.cached_input_tokens !== null) {
1199
+ print(`tokens: in=${u.input_tokens ?? "n/a"} out=${u.output_tokens ?? "n/a"} cached=${u.cached_input_tokens ?? "n/a"}`);
1200
+ }
1201
+ // Route receipt (INV-061 disclosure), projected verbatim from telemetry.
1202
+ const route = telemetry.auth_route;
1203
+ if (route) {
1204
+ print(`auth route: requested=${route.requested} effective=${route.effective ?? "undisclosed"} source=${route.source ?? "undisclosed"} reason=${route.reason}${route.harness_id ? ` (${route.harness_id}/${route.attempt_id ?? "?"})` : ""}`);
1205
+ if (route.model_mismatch) {
1206
+ print(`model mismatch: requested=${route.model_mismatch.requested} observed=${route.model_mismatch.observed}`);
1207
+ }
1208
+ }
1209
+ }
1173
1210
  if (telemetry && (telemetry.web.attempted || telemetry.web.required)) {
1174
1211
  print(`web evidence: status=${telemetry.web.status} tool=${telemetry.web.tool ?? "none"} target=${telemetry.web.target ?? "none"}${telemetry.web.error_summary ? ` error=${telemetry.web.error_summary}` : ""}`);
1175
1212
  }
@@ -1203,127 +1240,43 @@ async function main() {
1203
1240
  if (!runId) {
1204
1241
  return printUsageError(json, "usage: claudexor apply <run_id> [--mode apply|commit|branch|pr] [--dry-run]");
1205
1242
  }
1206
- // Resolve the owning store from any cwd (project / user Ask / daemon-tracked
1207
- // run in another project) before reading the patch artifact.
1208
- const resolved = await resolveRunStore(runId);
1209
- if (!resolved) {
1210
- if (json)
1211
- printJson({ runId, error: `no such run ${runId}` });
1212
- else
1213
- print(`no such run ${runId}`);
1214
- return 1;
1215
- }
1216
- const store = resolved.store;
1217
- const paths = store.runPaths(runId);
1218
- const patch = readTextSafe(join(paths.finalDir, "patch.diff"));
1219
- if (!patch || patch.trim().length === 0) {
1220
- if (json)
1221
- printJson({ runId, error: `no patch found for run ${runId}` });
1222
- else
1223
- print(`no patch found for run ${runId}`);
1224
- return 1;
1225
- }
1226
- if (containsSecretLikeToken(patch)) {
1227
- if (json)
1228
- printJson({ runId, error: "patch contains secret-like token; refusing apply" });
1229
- else
1230
- print("patch contains secret-like token; refusing apply");
1231
- return 1;
1232
- }
1233
- // Apply policy has ONE owner (delivery.validateApplyGate) shared with the
1234
- // Control API; the CLI only adapts artifact reads into it. No duplicated
1235
- // pre-checks here: a NEEDS_HUMAN run unblocked through the typed decision
1236
- // endpoint (arbitration/operator_decision.yaml, hash-bound) must apply
1237
- // identically from BOTH surfaces.
1238
- const applyDecision = DecisionRecord.safeParse(store.readYaml(join(paths.arbitrationDir, "decision.yaml")));
1239
- const workProduct = WorkProduct.safeParse(store.readYaml(join(paths.finalDir, "work_product.yaml")));
1240
- const contract = TaskContract.safeParse(store.readYaml(join(paths.contextDir, "task.yaml")));
1241
- const operatorDecisionRaw = store.readYaml(join(paths.arbitrationDir, "operator_decision.yaml"));
1242
- const operatorDecision = operatorDecisionRaw && typeof operatorDecisionRaw["action"] === "string"
1243
- ? {
1244
- action: operatorDecisionRaw["action"],
1245
- patch_sha256: typeof operatorDecisionRaw["patch_sha256"] === "string"
1246
- ? operatorDecisionRaw["patch_sha256"]
1247
- : undefined,
1248
- }
1249
- : null;
1250
- // The default apply target is the run's ORIGINAL project (from its contract),
1251
- // not the current working directory — so a daemon-tracked run resolved via
1252
- // the registry applies correctly from any cwd (namespace unification).
1253
- // Fall back to cwd only for a legacy run with no readable contract.
1254
- const applyRoot = contract.success ? contract.data.repo.root : process.cwd();
1255
- // Artifact-only path: the live daemon job state is unavailable, but the
1256
- // orchestrator records the terminal run status in work_product.meta.status.
1257
- // Feed it (mapped to daemon-state vocab) into the shared gate so the CLI
1258
- // enforces the SAME terminal-state bar the Control API does — e.g. a
1259
- // convergence run that persists decision.status=success but terminal
1260
- // not_converged (stale diff after a required review) is refused identically.
1261
- const recordedStatus = workProduct.success
1262
- ? workProduct.data.meta?.["status"]
1263
- : undefined;
1264
- const recordedState = recordedStatus
1265
- ? recordedStatus === "success"
1266
- ? "succeeded"
1267
- : recordedStatus
1268
- : null;
1269
- const gateError = validateApplyGate({
1270
- state: recordedState,
1271
- decision: applyDecision.success ? applyDecision.data : null,
1272
- workProduct: workProduct.success ? workProduct.data : null,
1273
- patch,
1274
- originalRepoRoot: contract.success ? contract.data.repo.root : null,
1275
- targetRepoRoot: applyRoot,
1276
- operatorDecision,
1277
- });
1278
- if (gateError) {
1279
- if (json)
1280
- printJson({ runId, error: gateError });
1281
- else
1282
- print(gateError);
1283
- return 1;
1284
- }
1285
- if (flagBool(args, "dry-run")) {
1286
- const r = await checkPatch(applyRoot, patch);
1287
- if (json)
1288
- printJson({
1289
- runId,
1290
- dryRun: true,
1291
- applies: r.ok,
1292
- ...(r.ok ? {} : { error: r.stderr.trim() }),
1293
- });
1294
- else
1295
- print(r.ok ? "patch applies cleanly" : `patch does not apply: ${r.stderr.trim()}`);
1296
- return r.ok ? 0 : 1;
1297
- }
1298
1243
  const rawMode = flagStr(args, "mode") ?? "apply";
1299
- if (!DELIVER_MODES.has(rawMode)) {
1244
+ if (!["apply", "commit", "branch", "pr"].includes(rawMode)) {
1300
1245
  if (json)
1301
1246
  printJson({ runId, error: `unsupported apply mode: ${rawMode}` });
1302
1247
  else
1303
1248
  print(`unsupported apply mode: ${rawMode}`);
1304
1249
  return 2;
1305
1250
  }
1306
- // `apply` mutates the tree; `artifact_only` produces no mutation and the
1307
- // patch artifact was already emitted by the run — reject it here (it stays
1308
- // valid on the control-api for clients that want a dry materialization).
1309
- if (rawMode === "artifact_only") {
1310
- const msg = "apply --mode artifact_only is a no-op (the patch artifact already exists at <runDir>/final/patch.diff); use apply|branch|commit|pr to mutate, or read the artifact directly";
1311
- if (json)
1312
- printJson({ runId, error: msg });
1313
- else
1314
- print(msg);
1315
- return 2;
1316
- }
1317
- const mode = rawMode;
1318
- const res = await deliver(applyRoot, patch, { mode, message: `claudexor: apply ${runId}` });
1251
+ const { addr } = await ensureDaemon();
1252
+ const dryRun = flagBool(args, "dry-run");
1253
+ const response = await controlApiFetch(addr, `/runs/${encodeURIComponent(runId)}/apply${dryRun ? "/check" : ""}`, {
1254
+ method: "POST",
1255
+ headers: { "content-type": "application/json" },
1256
+ body: JSON.stringify(dryRun
1257
+ ? { target: { kind: "original_project" } }
1258
+ : {
1259
+ target: { kind: "original_project" },
1260
+ mode: rawMode,
1261
+ message: `claudexor: apply ${runId}`,
1262
+ }),
1263
+ });
1264
+ const text = await response.text();
1265
+ const result = text ? JSON.parse(text) : {};
1319
1266
  if (json)
1320
- printJson(res);
1267
+ printJson({ runId, ...(dryRun ? { dryRun: true } : {}), ...result });
1268
+ else if (!response.ok)
1269
+ print(String(result["message"] ?? result["error"] ?? text));
1270
+ else if (dryRun)
1271
+ print(result["ok"] === true ? "patch applies cleanly" : "patch does not apply");
1321
1272
  else
1322
- print(`${res.mode}: applied=${res.applied}` +
1323
- (res.commit ? ` commit=${res.commit.slice(0, 8)}` : "") +
1324
- (res.branch ? ` branch=${res.branch}` : "") +
1325
- (res.detail ? ` (${res.detail})` : ""));
1326
- return res.applied ? 0 : 1;
1273
+ print(`${String(result["mode"] ?? rawMode)}: applied=${String(result["applied"] ?? false)}` +
1274
+ (typeof result["commit"] === "string"
1275
+ ? ` commit=${result["commit"].slice(0, 8)}`
1276
+ : "") +
1277
+ (typeof result["branch"] === "string" ? ` branch=${result["branch"]}` : "") +
1278
+ (typeof result["detail"] === "string" ? ` (${result["detail"]})` : ""));
1279
+ return response.ok && (dryRun ? result["ok"] === true : result["applied"] === true) ? 0 : 1;
1327
1280
  }
1328
1281
  case "decision":
1329
1282
  return decisionCommand(args, json);
@@ -1421,7 +1374,9 @@ async function main() {
1421
1374
  print(`modes: ${catalog.modes.join(", ")}`);
1422
1375
  print(`available harnesses: ${catalog.availableHarnesses.join(", ") || "(none)"}`);
1423
1376
  for (const h of catalog.harnesses) {
1424
- const model = h.configuredModel ? ` model=${h.configuredModel}${h.configuredModelValid === false ? " (REJECTED)" : ""}` : "";
1377
+ const model = h.configuredModel
1378
+ ? ` model=${h.configuredModel}${h.configuredModelValid === false ? " (REJECTED)" : ""}`
1379
+ : "";
1425
1380
  print(` ${statusGlyph(h.status)} ${h.id}: ${h.status}; intents=${h.enabledIntents.join(",") || "-"}; models=${h.models.count} (${h.models.source})${model}`);
1426
1381
  }
1427
1382
  print(`mcp tools: ${catalog.mcpTools.join(", ")}`);
@@ -1443,7 +1398,11 @@ async function main() {
1443
1398
  // exit 0 — scripts must not mistake a typo'd verb for success. --json
1444
1399
  // callers get the machine envelope on stdout (stdout purity contract).
1445
1400
  if (json) {
1446
- printJson({ ok: false, exitCode: 2, error: `claudexor: unknown command '${cmd}' (see \`claudexor help --json\`)` });
1401
+ printJson({
1402
+ ok: false,
1403
+ exitCode: 2,
1404
+ error: `claudexor: unknown command '${cmd}' (see \`claudexor help --json\`)`,
1405
+ });
1447
1406
  return 2;
1448
1407
  }
1449
1408
  process.stderr.write(`claudexor: unknown command '${cmd}'\n\n${HELP}\n`);