@counterfact-labs/cli 0.2.9 → 0.2.11

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 (134) hide show
  1. package/README.md +4 -5
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/analysis-results.js +10 -5
  4. package/dist/adapters/completion.js +75 -41
  5. package/dist/adapters/config-directory.js +5 -0
  6. package/dist/adapters/files.js +25 -10
  7. package/dist/adapters/legacy-reports.js +48 -0
  8. package/dist/adapters/output-paths.js +85 -5
  9. package/dist/adapters/relay-plan.js +7 -5
  10. package/dist/adapters/result-receipt.js +78 -0
  11. package/dist/adapters/results.js +5 -8
  12. package/dist/adapters/settings.js +10 -4
  13. package/dist/adapters/storage-identity.js +27 -0
  14. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -1
  15. package/dist/analysis-core/analysis/file-analysis-plan.js +1 -1
  16. package/dist/analysis-core/analysis/file-dependencies.js +1 -1
  17. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -1
  18. package/dist/analysis-core/analysis/folder-analysis-plan.js +1 -1
  19. package/dist/analysis-core/analysis/passes/full-file-passes.js +1 -1
  20. package/dist/analysis-core/analysis/project-architecture.js +1 -1
  21. package/dist/analysis-core/analysis/schemas.js +1 -1
  22. package/dist/analysis-core/chunks/{5B5UCWD3.js → 2IQN26TC.js} +1 -1
  23. package/dist/analysis-core/chunks/{MZOKZ3N6.js → 2J3PWUPG.js} +1 -1
  24. package/dist/analysis-core/chunks/33EUZF25.js +1 -0
  25. package/dist/analysis-core/chunks/{3GUKUEEJ.js → 5JG3ZRNJ.js} +1 -1
  26. package/dist/analysis-core/chunks/{FRXOQ2M6.js → 6E4K7JTI.js} +1 -1
  27. package/dist/analysis-core/chunks/{4RXRAB5S.js → 6JTHFDH3.js} +1 -1
  28. package/dist/analysis-core/chunks/{4UDQBDDE.js → 6W6B7VSP.js} +1 -1
  29. package/dist/analysis-core/chunks/{4PT5C4IT.js → AIS3FHQR.js} +7 -7
  30. package/dist/analysis-core/chunks/CNQZLSAP.js +1 -0
  31. package/dist/analysis-core/chunks/{YWL52FVW.js → EJ2IZUN2.js} +2 -2
  32. package/dist/analysis-core/chunks/{LOBIDUCA.js → GOBOE2SC.js} +1 -1
  33. package/dist/analysis-core/chunks/GYXDN5W4.js +1 -0
  34. package/dist/analysis-core/chunks/GZSUTDGX.js +1 -0
  35. package/dist/analysis-core/chunks/HB5BTGTI.js +6 -0
  36. package/dist/analysis-core/chunks/HH3HDT5K.js +73 -0
  37. package/dist/analysis-core/chunks/I4F56VMG.js +1 -0
  38. package/dist/analysis-core/chunks/{RQTNTIR4.js → I5F7S63K.js} +1 -1
  39. package/dist/analysis-core/chunks/ICJV6KIZ.js +11 -0
  40. package/dist/analysis-core/chunks/JAPVLU4Z.js +1 -0
  41. package/dist/analysis-core/chunks/{33IGHOYT.js → JCVB6IC4.js} +1 -1
  42. package/dist/analysis-core/chunks/{33HRXJ5Q.js → JLW44GIU.js} +1 -1
  43. package/dist/analysis-core/chunks/JS6HTVYX.js +1 -0
  44. package/dist/analysis-core/chunks/{JGK37AWN.js → JWQXG5RB.js} +1 -1
  45. package/dist/analysis-core/chunks/LOODGUMW.js +1 -0
  46. package/dist/analysis-core/chunks/{HVQJKPQ4.js → NWYNJHY4.js} +1 -1
  47. package/dist/analysis-core/chunks/{HHFDNY6V.js → OHDC3L6V.js} +1 -1
  48. package/dist/analysis-core/chunks/OXGKALKM.js +1 -0
  49. package/dist/analysis-core/chunks/{MJGB25S5.js → P5FCHJ7W.js} +1 -1
  50. package/dist/analysis-core/chunks/PISOGSJF.js +1 -0
  51. package/dist/analysis-core/chunks/{CTLHKFPH.js → QENYFKG3.js} +8 -8
  52. package/dist/analysis-core/chunks/{2YREYGOG.js → QY57UR6E.js} +1 -1
  53. package/dist/analysis-core/chunks/QYYN4YKB.js +35 -0
  54. package/dist/analysis-core/chunks/UYG422QR.js +0 -0
  55. package/dist/analysis-core/chunks/V7GZHSVR.js +1 -0
  56. package/dist/analysis-core/chunks/{JEWBPIQI.js → VBGQKYIT.js} +1 -1
  57. package/dist/analysis-core/chunks/{S27Y2TP5.js → VVSHUUWX.js} +1 -1
  58. package/dist/analysis-core/chunks/VX6LYNXV.js +1 -0
  59. package/dist/analysis-core/chunks/{JKWTHRUR.js → YDSB7UOR.js} +1 -1
  60. package/dist/analysis-core/chunks/ZLUI3EHB.js +1 -0
  61. package/dist/analysis-core/contracts/execution-request.js +1 -1
  62. package/dist/analysis-core/contracts/intake-finding.js +1 -1
  63. package/dist/analysis-core/contracts/model-errors.js +1 -0
  64. package/dist/analysis-core/contracts/relay-analysis.js +1 -1
  65. package/dist/analysis-core/contracts/requests.js +1 -1
  66. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  67. package/dist/analysis-core/conventions/file-admission.js +1 -1
  68. package/dist/analysis-core/errors.js +1 -1
  69. package/dist/analysis-core/import/calls/modules.js +1 -1
  70. package/dist/analysis-core/import/entry-selection.js +1 -1
  71. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  72. package/dist/analysis-core/index.js +1 -1
  73. package/dist/analysis-core/llm/cache-policy.js +1 -1
  74. package/dist/analysis-core/llm/cost.js +1 -1
  75. package/dist/analysis-core/llm/structured-output-error.js +1 -1
  76. package/dist/analysis-core/requests/answer-formats.js +1 -1
  77. package/dist/analysis-core/requests/frame-budget.js +1 -0
  78. package/dist/analysis-core/requests/local-plan.js +1 -1
  79. package/dist/analysis-core/requests/planned-limits.js +1 -1
  80. package/dist/analysis-core/requests/registry.js +1 -1
  81. package/dist/analysis-core/requests/template-version.js +1 -1
  82. package/dist/analysis-core/requests/tools.js +1 -1
  83. package/dist/analysis-core/settings.js +1 -1
  84. package/dist/analysis-core/stages/engine-entry.js +1 -1
  85. package/dist/analysis-core/wording.js +1 -0
  86. package/dist/cli/analysis-preview.js +22 -0
  87. package/dist/cli/analysis-selection.js +6 -8
  88. package/dist/cli/commands/analyze.js +29 -26
  89. package/dist/cli/commands/login.js +5 -0
  90. package/dist/cli/compatibility.js +39 -0
  91. package/dist/cli/completion-delivery.js +4 -3
  92. package/dist/cli/completion-recovery.js +25 -23
  93. package/dist/cli/credentials.js +3 -2
  94. package/dist/cli/finding-display.js +3 -2
  95. package/dist/cli/git.js +5 -2
  96. package/dist/cli/intake-payload.js +1 -2
  97. package/dist/cli/main.js +8 -2
  98. package/dist/cli/mcp.js +35 -10
  99. package/dist/cli/prior-completion.js +1 -1
  100. package/dist/cli/relay-check.js +88 -30
  101. package/dist/cli/relay-failure.js +19 -11
  102. package/dist/cli/relay-preparation.js +9 -6
  103. package/dist/cli/scope-choices.js +5 -4
  104. package/dist/cli/skipped-files.js +2 -1
  105. package/dist/cli/suggested-email.js +6 -1
  106. package/dist/cli/summary.js +2 -1
  107. package/dist/contract/agent-choice.js +2 -0
  108. package/dist/contract/cli-refusals.js +105 -9
  109. package/dist/contract/cli-version.js +1 -1
  110. package/dist/contract/console.js +2 -1
  111. package/dist/contract/email-sign-in-messages.js +2 -1
  112. package/dist/contract/scope-choice.js +4 -1
  113. package/dist/gatekeeper/client.js +11 -2
  114. package/dist/gatekeeper/connection-exchange.js +19 -4
  115. package/dist/gatekeeper/connection.js +15 -6
  116. package/dist/gatekeeper/console.js +10 -17
  117. package/dist/gatekeeper/model.js +13 -3
  118. package/package.json +1 -1
  119. package/dist/adapters/gitignore-output.js +0 -41
  120. package/dist/adapters/source-relay-plan.js +0 -7
  121. package/dist/analysis-core/chunks/2AASSZRO.js +0 -35
  122. package/dist/analysis-core/chunks/CQQ4TTXE.js +0 -1
  123. package/dist/analysis-core/chunks/F54DOWKN.js +0 -6
  124. package/dist/analysis-core/chunks/HCPO6QPU.js +0 -1
  125. package/dist/analysis-core/chunks/J4AXGE5W.js +0 -1
  126. package/dist/analysis-core/chunks/LFYTD7JF.js +0 -1
  127. package/dist/analysis-core/chunks/LMUXOKIM.js +0 -1
  128. package/dist/analysis-core/chunks/M5YWJY7P.js +0 -1
  129. package/dist/analysis-core/chunks/MGVIKA74.js +0 -1
  130. package/dist/analysis-core/chunks/NXHVBJL4.js +0 -1
  131. package/dist/analysis-core/chunks/OKBK3JL3.js +0 -1
  132. package/dist/analysis-core/chunks/SJESFYBA.js +0 -10
  133. package/dist/analysis-core/chunks/W24SFYZM.js +0 -67
  134. package/dist/analysis-core/chunks/YTHF2E2S.js +0 -1
package/dist/cli/git.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { realpath } from "node:fs/promises";
1
2
  import { basename, relative, resolve, sep } from "node:path";
2
3
  import { gitRepositoryRoot } from "../adapters/repository-root.js";
3
4
  import { runGit } from "../adapters/git.js";
@@ -10,7 +11,9 @@ export async function userEmail(folder, git = async (args) => (await runGit(fold
10
11
  }
11
12
  }
12
13
  export async function repositoryFacts(root, git = async (args) => (await runGit(root, args)).toString("utf8")) {
13
- const folderName = basename(resolve(root));
14
+ // Git reports the physical checkout root, so resolve aliases before deriving its selected folder.
15
+ const selected = await realpath(root).catch(() => resolve(root));
16
+ const folderName = basename(selected);
14
17
  const repoRoot = await gitRepositoryRoot(git);
15
18
  if (repoRoot === null)
16
19
  return { remoteUrl: null, relPath: "", folderName, branch: null, commit: null, dirty: null };
@@ -34,6 +37,6 @@ export async function repositoryFacts(root, git = async (args) => (await runGit(
34
37
  // Include new files even when Git hides them, and exclude sibling folders.
35
38
  read(["status", "--porcelain=v2", "--no-renames", "--ignore-submodules=all", "--untracked-files=normal", "--", "."]),
36
39
  ]);
37
- return { repoRoot, remoteUrl, relPath: relative(repoRoot, root).split(sep).join("/"), folderName,
40
+ return { repoRoot, remoteUrl, relPath: relative(repoRoot, selected).split(sep).join("/"), folderName,
38
41
  branch: branch === "HEAD" ? null : branch || null, commit: commit || null, dirty: status === null ? null : status.length > 0 };
39
42
  }
@@ -38,8 +38,7 @@ export function buildIntakePayload(identity, agent, report, options = {}) {
38
38
  delete legacy.activity;
39
39
  return legacy;
40
40
  });
41
- const architecture = options.consoleSupportsArchitecture === true && options.settings?.CF_LOCAL_ARCHITECTURE === "1"
42
- ? projectArchitecture(report.graph) : undefined;
41
+ const architecture = options.settings?.CF_LOCAL_ARCHITECTURE === "1" ? projectArchitecture(report.graph) : undefined;
43
42
  let result = localFindingsPayloadSchema.safeParse({ ...identity, agent, findings, ...metadata, ...(architecture ? { architecture } : {}) });
44
43
  // Optional map metadata must not crowd otherwise valid findings out of the total upload allowance.
45
44
  if (!result.success && architecture)
package/dist/cli/main.js CHANGED
@@ -14,6 +14,8 @@ import { folderPartsEnabled, skippedFilesEnabled } from "../analysis-core/settin
14
14
  import { loadAnalysisScope } from "./scope-choices.js";
15
15
  import { FILE_SCOPE_PUBLIC_USAGE } from "../contract/scope-choice.js";
16
16
  export async function run(args, options = {}) {
17
+ const previewEnabled = (options.startup?.transport ?? options.env ?? process.env).CF_FILES_PREVIEW !== "0";
18
+ const previewUsage = previewEnabled ? " counterfact-labs analyze [path] --preview\n" : "";
17
19
  const skippedUsage = skippedFilesEnabled(options.startup?.settings ?? options.env ?? process.env)
18
20
  ? " counterfact-labs analyze [path] [--show-skipped | --skipped-plan | --skipped]\n" : "";
19
21
  const usage = `Usage: counterfact-labs analyze [path] [--proposal <json-file>]\n${skippedUsage} counterfact-labs finding <id>\n counterfact-labs balance | login | logout | mcp\n`;
@@ -28,7 +30,7 @@ export async function run(args, options = {}) {
28
30
  fileMode = fileScopeEnabled(selectionSettings);
29
31
  const commandOptions = { ...options, emailSignIn, env: { ...(options.env ?? process.env), ...selectionSettings,
30
32
  ...(command === "analyze" || command === "mcp" ? {} : {
31
- CF_FILE_SCOPE: undefined, CF_SCOPE_CHOICES: undefined, CF_LOCAL_IMPORT_RESOLUTION: undefined, CF_MULTI_ENTRY: undefined,
33
+ CF_FILE_SCOPE: undefined, CF_SCOPE_CHOICES: undefined, CF_LOCAL_IMPORT_RESOLUTION: undefined, CF_MULTI_ENTRY: undefined, CF_PYTHON_SRC_IMPORTS: undefined,
32
34
  }) } };
33
35
  const choices = scopeChoicesEnabled(selectionSettings);
34
36
  const scopeUsage = choices ? FILE_SCOPE_PUBLIC_USAGE : usage;
@@ -37,6 +39,10 @@ export async function run(args, options = {}) {
37
39
  let commandUsage = choices && firstLineEnd > 0
38
40
  ? `${scopeUsage.slice(0, firstLineEnd)}${skippedUsage}${scopeUsage.slice(firstLineEnd)}`
39
41
  : scopeUsage;
42
+ if (previewUsage) {
43
+ const end = commandUsage.indexOf("\n") + 1;
44
+ commandUsage = commandUsage.slice(0, end) + previewUsage + commandUsage.slice(end);
45
+ }
40
46
  if (folderPartsEnabled(env))
41
47
  commandUsage = commandUsage.replace(choices ? "[--scope <json-file>]" : "[--proposal <json-file>]", choices ? "[--scope <json-file | json-list>]" : "[--proposal <json-file>] [--scope <json-list>]");
42
48
  if (["--help", "-h", "help"].includes(command) || !command) {
@@ -46,7 +52,7 @@ export async function run(args, options = {}) {
46
52
  if (command === "analyze") {
47
53
  let parsed;
48
54
  try {
49
- parsed = parseAnalyzeOptions(rest, choices, multiEntryEnabled(selectionSettings));
55
+ parsed = parseAnalyzeOptions(rest, choices, multiEntryEnabled(selectionSettings), previewEnabled);
50
56
  }
51
57
  catch {
52
58
  throw new Error(commandUsage.trimEnd());
package/dist/cli/mcp.js CHANGED
@@ -20,6 +20,7 @@ import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
20
20
  import { EMAIL_SIGN_IN_TERMS, normalizeSignInEmail, validSignInCode, startEmailSignIn, signInWithCode } from "./email-sign-in.js";
21
21
  import { resolveAgentFolder, resolveMcpAgentFolder } from "./agent-folder.js";
22
22
  import { suggestedEmail } from "./suggested-email.js";
23
+ import { checkSignInVersion } from "./compatibility.js";
23
24
  import { agentProposalInputSchema } from "../contract/agent-proposal.js";
24
25
  import { fixRequestTools, waitingFixes, waitingFixCount, claimFix, FIX_DISCOVERY_TIMEOUT_MS } from "./fix-requests.js";
25
26
  import { FINDING_PROJECT_MESSAGE, FindingReferences, numberedFindingInputSchema } from "./finding-lookup.js";
@@ -95,10 +96,12 @@ export async function serveMcp(input, output, options = {}) {
95
96
  const audienceChoice = (options.env ?? process.env).CF_FINDING_AUDIENCE;
96
97
  const cwd = options.cwd ?? process.cwd();
97
98
  const guarded = (options.env ?? process.env).CF_MCP_PATH_GUARD !== "0";
99
+ const retryAfterFailure = (options.env ?? process.env).CF_MCP_RETRY_AFTER_FAILURE !== "0";
98
100
  const failureReasons = (options.env ?? process.env).CF_MCP_FAILURE_REASONS !== "0";
99
101
  const launch = guarded ? resolveFolderPath(cwd).catch(() => null) : Promise.resolve(cwd);
100
102
  const lastAnalysis = new Map();
101
103
  const startupSettings = options.startup?.settings ?? options.env ?? process.env;
104
+ const scanPriority = startupSettings.CF_CODE_SCAN_PRIORITY;
102
105
  const skippedSettings = Object.freeze({ CF_SKIPPED_FILES: readSkippedFilesSetting(startupSettings.CF_SKIPPED_FILES) });
103
106
  const skippedEnabled = skippedFilesEnabled(skippedSettings);
104
107
  const partSettings = Object.freeze({ CF_FOLDER_PARTS: readFolderPartsSetting(startupSettings.CF_FOLDER_PARTS) });
@@ -108,10 +111,12 @@ export async function serveMcp(input, output, options = {}) {
108
111
  const analysisFindings = new Map();
109
112
  const { numbersEnabled, fixesEnabled, agentReadableResults } = findingSettings;
110
113
  const analysisSwitches = readSelectionSettings(startupSettings);
114
+ const signInSettings = Object.freeze({ CF_CLI_SIGN_IN_VERSION_CHECK: options.startup
115
+ ? options.startup.signInVersionCheck ? "1" : "0" : (options.env ?? process.env).CF_CLI_SIGN_IN_VERSION_CHECK });
111
116
  const choicesEnabled = scopeChoicesEnabled(analysisSwitches);
112
117
  const selectionEnabled = selectionModeEnabled(analysisSwitches);
113
118
  const multipleEnabled = multiEntryEnabled(analysisSwitches);
114
- const analysisEnv = selectionEnabled ? { ...(options.env ?? process.env), ...analysisSwitches } : undefined;
119
+ const analysisEnv = selectionEnabled ? { ...(options.env ?? process.env), ...analysisSwitches, ...signInSettings } : undefined;
115
120
  let analysisStartup = options.startup;
116
121
  const references = new FindingReferences();
117
122
  const emailEnv = analysisEnv && Object.hasOwn(analysisEnv, "CF_EMAIL_SIGN_IN") ? analysisEnv : options.env;
@@ -218,19 +223,27 @@ export async function serveMcp(input, output, options = {}) {
218
223
  if (selectionEnabled)
219
224
  analysisStartup ??= readCliSettings(analysisEnv, emailSignIn);
220
225
  const analysisSettings = analysisStartup?.settings ?? {};
221
- const ctx = commandContext({ ...options, ...(analysisStartup ? { startup: analysisStartup } : {}), env: { ...(analysisEnv ?? options.env ?? process.env), ...analysisSwitches, ...skippedSettings, ...partSettings, CF_FINDING_AUDIENCE: audienceChoice },
222
- onCheckFailure: failure => { reason = failure.reason; options.onCheckFailure?.(failure); },
226
+ const ctx = commandContext({ ...options, ...(analysisStartup ? { startup: analysisStartup } : {}), env: { ...(analysisEnv ?? options.env ?? process.env), ...analysisSwitches, ...skippedSettings, ...partSettings, ...signInSettings, CF_CODE_SCAN_PRIORITY: scanPriority, CF_FINDING_AUDIENCE: audienceChoice },
227
+ onCheckFailure: failure => {
228
+ reason = failure.reason;
229
+ if (guarded && retryAfterFailure && analysisFolder && failure.charged && !url) {
230
+ lastAnalysis.set(analysisFolder, { at: ctx.now(), charged: true });
231
+ }
232
+ options.onCheckFailure?.(failure);
233
+ },
223
234
  emailSignIn, findingSettings, signal, onCheckStart: () => {
224
235
  started = true;
225
- if (guarded && analysisFolder)
226
- lastAnalysis.set(analysisFolder, ctx.now());
236
+ if (guarded && !retryAfterFailure && analysisFolder)
237
+ lastAnalysis.set(analysisFolder, { at: ctx.now() });
227
238
  options.onCheckStart?.();
228
239
  }, onScopeChoice: choice => {
229
240
  scopeChoice = choice;
230
- if (guarded && analysisFolder)
241
+ if (guarded && !retryAfterFailure && analysisFolder)
231
242
  lastAnalysis.delete(analysisFolder);
232
243
  options.onScopeChoice?.(choice);
233
244
  }, onAgentChoice: choice => { agentChoice = choice; options.onAgentChoice?.(choice); }, onCheckComplete: result => {
245
+ if (guarded && analysisFolder && retryAfterFailure)
246
+ lastAnalysis.set(analysisFolder, { at: ctx.now(), url: result.url });
234
247
  findingIds = result.findingIds ?? [];
235
248
  topFindingIds = result.topFindingIds;
236
249
  if (skippedEnabled && result.skippedFiles)
@@ -266,6 +279,8 @@ export async function serveMcp(input, output, options = {}) {
266
279
  if (!chunk.startsWith("\r"))
267
280
  text += chunk;
268
281
  } });
282
+ if (name === "sign_in_status" || name === "sign_in" || name === "sign_in_with_code")
283
+ await checkSignInVersion(ctx);
269
284
  if (name === "waiting_fixes") {
270
285
  const waiting = await waitingFixes(ctx.api);
271
286
  fixRequests = waiting;
@@ -303,9 +318,11 @@ export async function serveMcp(input, output, options = {}) {
303
318
  const pending = pendingSignIn;
304
319
  const allowed = await analyzeToolAllowed(ctx.homeDir, cwd);
305
320
  if (args.wait === true && pending) {
321
+ let completed = false;
306
322
  if (pending.flow.retryEnabled) {
307
323
  try {
308
324
  await waitForSignIn(pending.flow, signal);
325
+ completed = true;
309
326
  }
310
327
  catch (error) {
311
328
  if (signal.aborted)
@@ -320,13 +337,19 @@ export async function serveMcp(input, output, options = {}) {
320
337
  if (signal.aborted)
321
338
  cancel();
322
339
  await pending.flow.done;
340
+ completed = true;
323
341
  }
324
342
  finally {
325
343
  signal.removeEventListener("abort", cancel);
326
344
  }
327
345
  }
346
+ if (completed)
347
+ await checkSignInVersion(ctx);
328
348
  }
329
349
  signIn = { ...await signInStatus(ctx), analyzeToolAllowed: allowed };
350
+ // The browser flow may finish and clear itself while the initial unsigned probe is in flight.
351
+ if (args.wait === true && !pending && signIn.signedIn)
352
+ await checkSignInVersion(ctx);
330
353
  if (fixesEnabled)
331
354
  waitingCount = signIn.signedIn ? await waitingFixCount(ctx.api) : 0;
332
355
  text = signIn.signedIn ? "Signed in." : "Not signed in.";
@@ -359,6 +382,7 @@ export async function serveMcp(input, output, options = {}) {
359
382
  }
360
383
  else {
361
384
  await signInWithCode(ctx, email, args.code);
385
+ await checkSignInVersion(ctx);
362
386
  signInFailure = undefined;
363
387
  signIn = { signedIn: true };
364
388
  text = "Signed in.";
@@ -421,8 +445,9 @@ export async function serveMcp(input, output, options = {}) {
421
445
  const key = selection ? includedEntries
422
446
  ? JSON.stringify([selection.launchRoot, [...includedEntries].sort()]) : JSON.stringify([selection.launchRoot, selection.path]) : folder;
423
447
  const last = lastAnalysis.get(key);
424
- if (name === ANALYZE_TOOL_NAME && guarded && last !== undefined && ctx.now() - last < 10 * 60 * 1000) {
425
- throw new CliRefusalError("repeat_too_soon");
448
+ if (name === ANALYZE_TOOL_NAME && guarded && last !== undefined && ctx.now() - last.at < 10 * 60 * 1000) {
449
+ throw new CliRefusalError("repeat_too_soon", retryAfterFailure
450
+ ? { retryAt: last.at + 10 * 60 * 1000, url: last.url, charged: last.charged } : undefined);
426
451
  }
427
452
  analysisFolder = key;
428
453
  try {
@@ -432,8 +457,8 @@ export async function serveMcp(input, output, options = {}) {
432
457
  finally {
433
458
  if (reason === "results_pending")
434
459
  lastAnalysis.delete(key);
435
- else if (guarded && started && !scopeChoice)
436
- lastAnalysis.set(key, ctx.now());
460
+ else if (!retryAfterFailure && guarded && started && !scopeChoice)
461
+ lastAnalysis.set(key, { at: ctx.now() });
437
462
  }
438
463
  }
439
464
  }
@@ -22,7 +22,7 @@ export async function priorCompletion(folder, options) {
22
22
  continue;
23
23
  }
24
24
  if (localMismatch && decision.action === "give-up") {
25
- await discardCompletion(folder, item.path, options.identity);
25
+ await discardCompletion(folder, item.path, options.identity, { keepCopy: false });
26
26
  if (decision.message)
27
27
  notices.add(decision.message);
28
28
  continue;
@@ -1,3 +1,4 @@
1
+ import { countForm } from "../analysis-core/wording.js";
1
2
  import { skippedFilesEnabled } from "../analysis-core/settings.js";
2
3
  import { analysisSkippedFiles, sizeSkippedFiles } from "../analysis-core/analysis/code-agents/skipped-files.js";
3
4
  import { loadAnalysisResults, loadPublicReport, saveAnalysisContext, settleAnalysisContext } from "../adapters/analysis-results.js";
@@ -7,17 +8,18 @@ import { randomUUID } from "node:crypto";
7
8
  import { disabledSetupPings } from "../setup-pings/client.js";
8
9
  import { fileScopeEnabled, scopeChoicesEnabled } from "../adapters/settings.js";
9
10
  import { saveCompletion, discardCompletion, lockCompletion } from "../adapters/completion.js";
11
+ import { CompletionBusyError } from "../adapters/completion-lock.js";
10
12
  import { saveLocalResults } from "../adapters/results.js";
11
- import { GITIGNORE_OUTPUT_NOTICE } from "../adapters/gitignore-output.js";
13
+ import { loadResultReceipt, saveResultReceipt, readResultReceipt, verifiedResultPaths } from "../adapters/result-receipt.js";
12
14
  import { priorCompletion } from "./prior-completion.js";
13
- import { OutputPathError, outputPaths } from "../adapters/output-paths.js";
15
+ import { outputPaths, reportOutputDirectory } from "../adapters/output-paths.js";
14
16
  import { validateClientRelease } from "../adapters/release.js";
15
17
  import { selectAnalysisExecution } from "../adapters/models.js";
16
18
  import { resolveFolderPath } from "../adapters/folder-path.js";
17
19
  import { AgentChoiceError } from "../contract/agent-choice.js";
18
20
  import { savedAnalysisUrl, savedArchitectureUrl } from "../contract/console-url.js";
19
21
  import { prepareRelayCheck, prepareFolderChoice } from "./relay-preparation.js";
20
- import { cliRefusals, refusalExitCode } from "../contract/cli-refusals.js";
22
+ import { cliRefusals, failureReason, refusalExitCode } from "../contract/cli-refusals.js";
21
23
  import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
22
24
  import { GatekeeperClient } from "../gatekeeper/client.js";
23
25
  import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
@@ -37,6 +39,7 @@ import { SCOPE_CHOICE_EXIT_CODE, progressLine } from "../contract/scope-choice.j
37
39
  import { ScopeChoiceError, scopeChoiceRefusal, selectionScopeChoices } from "../contract/selection-offer.js";
38
40
  import { fileScopeOf } from "../analysis-core/analysis/file-analysis-plan.js";
39
41
  const stateLine = (status) => `Reservation ${status.state}; settlement ${status.settlement}; lease ${new Date(status.expiresAt).toLocaleString("en-US", { timeZone: "America/Los_Angeles", timeZoneName: "short" })}.\n`;
42
+ const reservationExpiredError = () => Object.assign(new Error(), { reason: "reservation_expired" });
40
43
  export function runRelayCheck(folder, options) {
41
44
  return runRelayLifecycle(folder, options);
42
45
  }
@@ -47,10 +50,12 @@ export function runReservedRelayCheck(folder, options) {
47
50
  async function runRelayLifecycle(folder, options, existing) {
48
51
  const { config, stdout, stderr } = options;
49
52
  const retainUnsaved = config.CF_LOCAL_RESULTS_RETRY !== "0";
53
+ const keepLocalResults = config.CF_LOCAL_RESULTS_KEEP !== "0";
50
54
  let settings = options.settings;
51
55
  const summaryOptions = { audienceEnabled: false, notActiveEnabled: false, skippedFilesEnabled: skippedFilesEnabled(settings), skippedOffer: !options.addToAnalysisId, analyzedFolder: options.analyzedFolder ?? folder };
52
56
  let skippedFiles = [];
53
57
  let client, reservationId, projectId;
58
+ let consoleSupportsFailureReason = options.consoleSupportsFailureReason ?? false;
54
59
  let connection = existing?.connection;
55
60
  let completedPayload;
56
61
  let displayFindings;
@@ -67,6 +72,10 @@ async function runRelayLifecycle(folder, options, existing) {
67
72
  let releaseCompletion;
68
73
  let localErrorMessage;
69
74
  let completionStored = false, lostCompletionLock = false;
75
+ let localPaths;
76
+ let localReportReceipt;
77
+ let retainedPath;
78
+ let internalAbortCause;
70
79
  let uploadOutcome;
71
80
  let savedResult;
72
81
  const controller = new AbortController();
@@ -75,11 +84,17 @@ async function runRelayLifecycle(folder, options, existing) {
75
84
  const uploadSignal = retainUnsaved ? options.signal ?? new AbortController().signal : signal;
76
85
  const persist = async (payload, origin, uploaded = false, finished = false) => {
77
86
  if (lostCompletionLock)
78
- throw new OutputPathError();
87
+ throw new CompletionBusyError();
79
88
  const path = await saveCompletion(folder, payload, origin, { uploaded, finished, completedAt: completionTiming.completedAt,
80
89
  absoluteExpiresAt: completionTiming.absoluteExpiresAt, retainUnsaved, identity: completionIdentity });
81
90
  completionStored = true;
82
91
  completionRecordPath = path;
92
+ if (localReportReceipt) {
93
+ try {
94
+ await saveResultReceipt(path, payload.checkId, localReportReceipt);
95
+ }
96
+ catch { /* A receipt is optional; the pending payload already preserves the results. */ }
97
+ }
83
98
  return path;
84
99
  };
85
100
  const submit = (payload, alreadySaved = false) => {
@@ -144,14 +159,18 @@ async function runRelayLifecycle(folder, options, existing) {
144
159
  Object.assign(error, { localOperation: "read" });
145
160
  throw error;
146
161
  });
147
- outputDir ??= selection?.outputDir;
162
+ outputDir = await reportOutputDirectory(folder, outputDir ?? selection?.outputDir);
148
163
  if (selection?.kind === "file")
149
164
  completionIdentity = {
150
165
  consoleOrigin: new URL(config.CF_CONSOLE_URL).origin, selectionKey: selection.selectionKey,
151
166
  };
152
167
  signal.throwIfAborted();
153
168
  if (retainUnsaved)
154
- releaseCompletion = await lockCompletion(folder, () => { lostCompletionLock = true; controller.abort(); }, completionIdentity);
169
+ releaseCompletion = await lockCompletion(folder, () => {
170
+ lostCompletionLock = true;
171
+ internalAbortCause = new CompletionBusyError();
172
+ controller.abort();
173
+ }, completionIdentity);
155
174
  const recovery = await priorCompletion(folder, { retryEnabled: retainUnsaved,
156
175
  consoleOrigin: new URL(config.CF_CONSOLE_URL).origin, projectId: existing?.expectedProjectId, retryUpload: options.retryUpload,
157
176
  identity: completionIdentity });
@@ -173,6 +192,17 @@ async function runRelayLifecycle(folder, options, existing) {
173
192
  completedPayload = prior.payload;
174
193
  skippedFiles = prior.payload.coverage?.skippedFiles ?? [];
175
194
  completionOrigin = prior.consoleOrigin;
195
+ if (keepLocalResults) {
196
+ try {
197
+ const receipt = await loadResultReceipt(path, prior.payload.checkId);
198
+ if (receipt) {
199
+ localPaths = await verifiedResultPaths(folder, receipt);
200
+ if (localPaths)
201
+ localReportReceipt = receipt;
202
+ }
203
+ }
204
+ catch { /* Missing or changed reports cannot justify deleting the pending payload. */ }
205
+ }
176
206
  completionTiming = { completedAt: prior.completedAt, absoluteExpiresAt: prior.absoluteExpiresAt };
177
207
  uploaded = prior.uploaded ?? false;
178
208
  reservationId = prior.payload.reservationId;
@@ -187,12 +217,14 @@ async function runRelayLifecycle(folder, options, existing) {
187
217
  signal.throwIfAborted();
188
218
  if (options.retryUpload)
189
219
  throw new Error("The saved completion record is missing. Keep the local report and check the output directory.");
190
- // Negotiate once before paid work: older strict intake schemas cannot accept optional metadata.
191
- const capabilities = skippedFilesEnabled(settings) && options.skippedFilesSupported === undefined
192
- || settings.CF_LOCAL_ARCHITECTURE === "1" && options.consoleSupportsArchitecture === undefined
193
- ? await client.capabilities() : undefined;
194
- const consoleSupportsArchitecture = options.consoleSupportsArchitecture ?? capabilities?.architecture ?? false;
195
- if (skippedFilesEnabled(settings) && !(options.skippedFilesSupported ?? capabilities?.skippedFiles)) {
220
+ // Negotiate before paid work: older strict intake schemas cannot accept coverage or additions.
221
+ if (options.consoleSupportsFailureReason === undefined) {
222
+ try {
223
+ consoleSupportsFailureReason = await client.supportsFailureReason();
224
+ }
225
+ catch { /* Older consoles omit optional metadata. */ }
226
+ }
227
+ if (skippedFilesEnabled(settings) && !(options.skippedFilesSupported ?? await client.supportsSkippedFiles())) {
196
228
  if (options.addToAnalysisId || fileScopeOf(settings) !== undefined)
197
229
  throw new ConsoleError("unavailable", "start");
198
230
  settings = Object.freeze({ ...settings, CF_SKIPPED_FILES: "0" });
@@ -233,10 +265,17 @@ async function runRelayLifecycle(folder, options, existing) {
233
265
  if (renewal)
234
266
  return;
235
267
  renewal = activeClient.heartbeat(activeId).then(status => {
236
- if (status.state !== "reserved")
237
- throw new Error();
238
- lease = status;
239
- }).catch(() => { controller.abort(); }).finally(() => { renewal = undefined; });
268
+ if (status.state !== "reserved") {
269
+ internalAbortCause = reservationExpiredError();
270
+ controller.abort();
271
+ }
272
+ else
273
+ lease = status;
274
+ }).catch(error => {
275
+ const expired = error instanceof ConsoleError && [401, 403, 404, 409].includes(error.httpStatus ?? 0);
276
+ internalAbortCause = expired ? reservationExpiredError() : new Error();
277
+ controller.abort();
278
+ }).finally(() => { renewal = undefined; });
240
279
  }, 60000);
241
280
  heartbeat.unref();
242
281
  const execution = await selectAnalysisExecution(config, () => { throw new Error(); }, {
@@ -252,6 +291,8 @@ async function runRelayLifecycle(folder, options, existing) {
252
291
  const priorResults = options.addToAnalysisId ? await loadAnalysisResults(folder, outputDir) : undefined;
253
292
  const assessments = combineAssessments(priorResults?.context.assessments ?? [], report.findingCheck.qualityAudit?.assessments ?? []);
254
293
  completionTiming = observedCompletionTiming({ completedAt: Date.now() }, { kind: "found", status: reservation }, reservation);
294
+ // A final optional call can drain successfully after an internal abort.
295
+ controller.signal.throwIfAborted();
255
296
  // The 30-minute lease covers upload retries; renewal failure must not cancel completed results.
256
297
  if (retainUnsaved)
257
298
  clearInterval(heartbeat);
@@ -260,12 +301,18 @@ async function runRelayLifecycle(folder, options, existing) {
260
301
  let saveContext;
261
302
  const saveReports = async () => {
262
303
  const paths = await saveLocalResults(folder, report, skippedFiles, outputDir, { ...summaryOptions, settings,
263
- ...analysisSaveOptions(selection, options.outputDir), selection: membership, includedPaths,
304
+ ...analysisSaveOptions(selection), selection: membership, includedPaths,
264
305
  ...(options.addToAnalysisId ? { append: { readPaths: includedPaths, assessments } } : {}) });
306
+ localPaths = paths;
307
+ if (retainUnsaved && keepLocalResults && completedPayload && !savedResult) {
308
+ try {
309
+ localReportReceipt = (await readResultReceipt(folder, outputDir)).receipt;
310
+ await persist(completedPayload, completionOrigin);
311
+ }
312
+ catch { /* A missing receipt cannot undo reports already written in this invocation. */ }
313
+ }
265
314
  try {
266
315
  options.onLocalSaved?.(paths);
267
- if (paths.ignoreAdded)
268
- stderr(GITIGNORE_OUTPUT_NOTICE);
269
316
  }
270
317
  catch { /* Output failure must not change a server outcome. */ }
271
318
  await saveContext?.();
@@ -274,7 +321,7 @@ async function runRelayLifecycle(folder, options, existing) {
274
321
  // The retry off path saves the full report even when intake validation refuses it.
275
322
  let paths = retainUnsaved ? undefined : await saveReports();
276
323
  const { payload, resultCheck, findingIndexes, findingActivities } = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
277
- bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report, { settings, files: source.snapshot, consoleSupportsActivity: options.consoleSupportsActivity, consoleSupportsArchitecture,
324
+ bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report, { settings, files: source.snapshot, consoleSupportsActivity: options.consoleSupportsActivity,
278
325
  ...(!skippedFilesEnabled(settings) || !options.addToAnalysisId && !sizeSkippedFiles(skippedFiles).length ? {} : { coverage: { skippedFiles: sizeSkippedFiles(skippedFiles), readPaths: includedPaths },
279
326
  addToAnalysisId: options.addToAnalysisId, findingAssessments: report.findingCheck.qualityAudit?.assessments ?? [] }) });
280
327
  displayFindings = findingIndexes.map((index, acceptedIndex) => {
@@ -297,9 +344,9 @@ async function runRelayLifecycle(folder, options, existing) {
297
344
  if (resultCheck?.droppedFindings) {
298
345
  const count = resultCheck.droppedFindings;
299
346
  const reason = resultCheck.schemaDroppedFindings || resultCheck.capDroppedFindings
300
- ? `${count === 1 ? "it was" : "they were"} too long or not in the expected format` : `${count === 1 ? "its" : "their"} quotes did not match your files`;
347
+ ? `${countForm(count, "it was", "they were")} too long or not in the expected format` : `${countForm(count, "its", "their")} quotes did not match your files`;
301
348
  try {
302
- stderr(`${count} ${count === 1 ? "finding was" : "findings were"} not uploaded because ${reason}.\n`);
349
+ stderr(`${count} ${countForm(count, "finding was", "findings were")} not uploaded because ${reason}.\n`);
303
350
  }
304
351
  catch { /* Output failure must not change a server outcome. */ }
305
352
  }
@@ -335,12 +382,13 @@ async function runRelayLifecycle(folder, options, existing) {
335
382
  return 0;
336
383
  }
337
384
  catch (caught) {
338
- let error = caught;
385
+ // Reading passes can sanitize errors; this lifecycle owns internal abort causes.
386
+ let error = !completed && internalAbortCause && !options.signal?.aborted ? internalAbortCause : caught;
339
387
  let status;
340
388
  let statusLookup = { kind: "skipped" };
341
389
  if (client && reservationId) {
342
390
  try {
343
- status = await (completed || uploaded ? client.status(reservationId) : client.fail(reservationId));
391
+ status = await (completed || uploaded ? client.status(reservationId) : client.fail(reservationId, consoleSupportsFailureReason && !(error instanceof AgentChoiceError) ? failureReason(error) : undefined));
344
392
  statusLookup = { kind: "found", status };
345
393
  }
346
394
  catch (statusError) {
@@ -368,12 +416,18 @@ async function runRelayLifecycle(folder, options, existing) {
368
416
  }
369
417
  if (error instanceof ResultsUploadError)
370
418
  completionTiming = error.timing ?? completionTiming;
371
- const failure = await recoverRelayFailure({ error, retryEnabled: retainUnsaved, payload: completedPayload, completed,
372
- timing: completionTiming, statusLookup, uploadOutcome, savedResult,
373
- canWrite: !lostCompletionLock, completionStored, localErrorMessage, fileSelection: selection?.kind === "file", fileMode: fileScopeEnabled(settings) }, {
419
+ const failure = await recoverRelayFailure({ error, cancelled: options.signal?.aborted, retryEnabled: retainUnsaved, payload: completedPayload, completed,
420
+ timing: completionTiming, statusLookup, reservationId, uploadOutcome, savedResult,
421
+ canWrite: !lostCompletionLock, completionStored, reportStored: localPaths !== undefined,
422
+ keepLocalResults, localErrorMessage, fileSelection: selection?.kind === "file", fileMode: fileScopeEnabled(settings) }, {
374
423
  markUploaded: () => persist(completedPayload, completionOrigin, true),
375
424
  keepPending: timing => { completionTiming = timing; return persist(completedPayload, completionOrigin); },
376
- retire: () => retainUnsaved ? completionRecordPath ? discardCompletion(folder, completionRecordPath, completionIdentity) : Promise.resolve() : persist(completedPayload, completionOrigin, false, true),
425
+ retire: async () => {
426
+ if (!retainUnsaved)
427
+ return persist(completedPayload, completionOrigin, false, true);
428
+ if (completionRecordPath)
429
+ retainedPath = await discardCompletion(folder, completionRecordPath, completionIdentity, { keepCopy: keepLocalResults, reportStored: localPaths !== undefined });
430
+ },
377
431
  });
378
432
  if (failure.saved) {
379
433
  try {
@@ -400,10 +454,14 @@ async function runRelayLifecycle(folder, options, existing) {
400
454
  }
401
455
  catch { /* A failed local plan preserves the console's authoritative refusal. */ }
402
456
  }
457
+ const savedPaths = localPaths ? [localPaths.findingsPath, localPaths.summaryPath] : retainedPath ? [retainedPath]
458
+ : completionStored && !lostCompletionLock && completionRecordPath ? [completionRecordPath] : [];
459
+ const message = failure.kept && savedPaths.length
460
+ ? `${failure.message}\n${savedPaths.map(path => `Saved ${path}`).join("\n")}` : failure.message;
403
461
  try {
404
- options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failure.reason, message: scopeChoice?.message ?? failure.message,
462
+ options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failure.reason, message: scopeChoice?.message ?? message, ...(failure.charged ? { charged: true } : {}),
405
463
  ...(scopeChoice ? { agentChoice: scopeChoice.agentChoice } : error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
406
- stderr(`${scopeChoice?.message ?? failure.message}\n`);
464
+ stderr(`${scopeChoice?.message ?? message}\n`);
407
465
  if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
408
466
  stderr(stateLine(status));
409
467
  }
@@ -2,7 +2,7 @@ import { CompletionBusyError } from "../adapters/completion-lock.js";
2
2
  import { OutputPathError } from "../adapters/output-paths.js";
3
3
  import { AgentChoiceError } from "../contract/agent-choice.js";
4
4
  import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
5
- import { cliRefusals, COMMITTED_FILES_MESSAGE, failureReason, FINISH_FAILURE_MESSAGE, localFailure, RESULTS_TOO_LARGE_MESSAGE } from "../contract/cli-refusals.js";
5
+ import { analysisFailureDetails, CliRefusalError, cliRefusals, COMMITTED_FILES_MESSAGE, failureReason, FINISH_FAILURE_MESSAGE, START_FAILURE_MESSAGE, relayChargeStatus, RESULTS_TOO_LARGE_MESSAGE } from "../contract/cli-refusals.js";
6
6
  import { isSelectionRefusal, selectionRefusalMessage } from "../contract/scope-choice.js";
7
7
  import { ConsoleError } from "../gatekeeper/console.js";
8
8
  import { recoverCompletedResults } from "./completion-delivery.js";
@@ -15,18 +15,26 @@ export async function recoverRelayFailure(input, operations) {
15
15
  const recovery = input.payload && input.completed ? await recoverCompletedResults({
16
16
  retryEnabled, payload: input.payload, timing: input.timing, statusLookup: input.statusLookup,
17
17
  uploadOutcome: retryEnabled && input.savedResult ? { kind: "saved" } : carried ?? { kind: "cancelled" },
18
- canWrite: input.canWrite, completionStored: input.completionStored, localErrorMessage: input.localErrorMessage,
18
+ canWrite: input.canWrite, completionStored: input.completionStored, reportStored: input.reportStored,
19
+ keepLocalResults: input.keepLocalResults, localErrorMessage: input.localErrorMessage,
19
20
  }, operations) : undefined;
20
21
  if (recovery?.decision.action === "saved")
21
- return { saved: true, message: recovery.message, reason: "results_saved_report_failed" };
22
- const known = error instanceof AgentChoiceError || error instanceof ConsoleError || error instanceof OutputPathError
23
- || isSelectionRefusal(error, input.fileSelection ?? false)
24
- || error instanceof CompletionBusyError || error instanceof CompletionSettingsMismatchError || error instanceof Error
22
+ return { saved: true,
23
+ message: error instanceof CompletionBusyError ? error.message : recovery.message, reason: "results_saved_report_failed" };
24
+ const selectionRefusal = isSelectionRefusal(error, input.fileSelection ?? false);
25
+ const known = error instanceof CliRefusalError || error instanceof AgentChoiceError || error instanceof ConsoleError || error instanceof OutputPathError
26
+ || selectionRefusal || error instanceof CompletionBusyError || error instanceof CompletionSettingsMismatchError || error instanceof Error
25
27
  && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, INVALID_PROPOSAL_MESSAGE].includes(error.message)
26
28
  || error.message.startsWith("The saved completion"));
27
- const message = recovery?.message ?? input.localErrorMessage ?? localFailure(error)?.message ?? (known ? error.message : FINISH_FAILURE_MESSAGE);
28
- const reason = !retryEnabled ? failureReason(error)
29
- : recovery?.decision.action === "retry" && input.completionStored ? "results_pending"
30
- : recovery?.decision.action === "give-up" ? "results_discarded" : failureReason(error);
31
- return { saved: false, message: selectionRefusalMessage(message, input.fileMode ?? true), reason };
29
+ const status = input.statusLookup.kind === "found" ? input.statusLookup.status : undefined;
30
+ const charge = relayChargeStatus(status, !input.reservationId && input.statusLookup.kind === "skipped");
31
+ const failure = error instanceof ConsoleError ? undefined : analysisFailureDetails(error, charge, { cancelled: input.cancelled, noReservation: !input.reservationId });
32
+ const knownMessage = known ? error.message : undefined;
33
+ const failureMessage = failure?.reason === "request_failed" ? knownMessage ?? failure.message : failure?.message;
34
+ const message = recovery?.message ?? input.localErrorMessage ?? failureMessage ?? knownMessage ?? (!input.reservationId && !input.completed ? START_FAILURE_MESSAGE : FINISH_FAILURE_MESSAGE);
35
+ const originalReason = failure?.reason ?? failureReason(error);
36
+ const reason = recovery && "reason" in recovery.decision ? recovery.decision.reason ?? originalReason : originalReason;
37
+ return { saved: false, message: selectionRefusalMessage(message, input.fileMode ?? true), reason,
38
+ ...(recovery?.decision.action === "give-up" && recovery.decision.kept ? { kept: true } : {}),
39
+ ...(reason !== "results_pending" && (charge === "charged" || charge === "free_used") ? { charged: true } : {}) };
32
40
  }
@@ -7,7 +7,7 @@ import { resolveFolderPath } from "../adapters/folder-path.js";
7
7
  import { readLocalFolder } from "../adapters/files.js";
8
8
  import { snapshotProvenance, sameFileHashes } from "../adapters/snapshot.js";
9
9
  import { validateClientRelease } from "../adapters/release.js";
10
- import { prepareRelayPlan } from "../adapters/source-relay-plan.js";
10
+ import { prepareRelayPlan } from "../adapters/relay-plan.js";
11
11
  import { readAnalysisSource } from "../adapters/selection.js";
12
12
  import { AgentChoiceError } from "../contract/agent-choice.js";
13
13
  import { COMMITTED_FILES_MESSAGE } from "../contract/cli-refusals.js";
@@ -16,9 +16,12 @@ import { selectionStartIdentity } from "../contract/selection-metadata.js";
16
16
  import { repositoryFacts } from "./git.js";
17
17
  import { proposalWhollyRefused } from "../analysis-core/index.js";
18
18
  /** Absolute checkout paths stay local; repository facts own the start's commit. */
19
- export function buildRelayStartPayload(facts, files) {
19
+ export function buildRelayStartPayload(facts, files, coordinates = {}) {
20
20
  const { remoteUrl, relPath, folderName, branch, commit, dirty } = facts;
21
- return { execution: "local", remoteUrl, relPath, folderName, branch, commit, dirty, files };
21
+ // Older consoles placed repository-relative results under an empty selected folder.
22
+ const legacy = process.env.CF_CLI_RELPATH === "0";
23
+ const folder = legacy && coordinates.resultBase && coordinates.resultBase !== coordinates.readRoot ? "" : relPath;
24
+ return { execution: "local", remoteUrl, relPath: folder, folderName, branch, commit, dirty, files };
22
25
  }
23
26
  export function committedFilesOnly(settings) {
24
27
  return settings.CF_LOCAL_COMMITTED_ONLY === "1";
@@ -66,7 +69,7 @@ export async function prepareFolderChoice(folder, options) {
66
69
  }
67
70
  /** Recovery can reuse signature validation without reading the folder again. */
68
71
  export async function prepareRelayCheck(folder, options, validated = validateClientRelease(options.release, options.settings)) {
69
- const { bundle } = validated;
72
+ const { bundle, tools } = validated;
70
73
  options.signal?.throwIfAborted();
71
74
  // Discovery must agree with Git before publishing repository-relative captures.
72
75
  const selection = options.selection;
@@ -95,13 +98,13 @@ export async function prepareRelayCheck(folder, options, validated = validateCli
95
98
  }
96
99
  else if (options.expectedCommit !== undefined && provenance.commit !== options.expectedCommit)
97
100
  throw new Error(COMMITTED_FILES_MESSAGE);
98
- const { plan, files } = prepareRelayPlan({ bundle, settings: validated.settings, relayVersion: options.release.relayVersion, source });
101
+ const { plan, files } = prepareRelayPlan({ bundle, tools, settings: validated.settings, relayVersion: options.release.relayVersion, source });
99
102
  options.signal?.throwIfAborted();
100
103
  const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(factsFolder);
101
104
  options.signal?.throwIfAborted();
102
105
  const startMetadata = { analysisSize: source.analysisSize, startAttempt: randomUUID(),
103
106
  ...(selection?.kind === "file" && membership
104
107
  ? { selection: selectionStartIdentity(selection.selectionKey, membership.snapshot) } : {}) };
105
- return { source, provenance, plan, start: buildRelayStartPayload(source.sourceBase && selection?.kind !== "file" ? { ...facts, relPath: "" } : facts, files), startMetadata,
108
+ return { source, provenance, plan, start: buildRelayStartPayload(facts, files, { resultBase: selection?.kind !== "file" ? source.sourceBase : undefined, readRoot: folder }), startMetadata,
106
109
  ...(membership ? { membership } : {}) };
107
110
  }
@@ -1,6 +1,6 @@
1
1
  import { readdir, readFile, unlink } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { outputPaths, writeOutputFiles } from "../adapters/output-paths.js";
3
+ import { outputPaths, reportOutputDirectory, writeReportFiles } from "../adapters/output-paths.js";
4
4
  import { analysisScopeSchema, chooseScopeResultSchema, INCLUDED_FILES_CAPTION, OMITTED_IMPORTS_CAPTION, OMITTED_FILES_CAPTION, SCOPE_CHOICE_INSTRUCTION, INCOMPLETE_IMPORTS_MESSAGE, IMPORT_DISCOVERY_LIMIT_MESSAGE, INVALID_SCOPE_MESSAGE, SCOPE_RETRY_TEMPLATE } from "../contract/scope-choice.js";
5
5
  import { scopeChoiceRefusal } from "../contract/selection-offer.js";
6
6
  import { scopeChoicesEnabled } from "../adapters/settings.js";
@@ -45,13 +45,14 @@ export function scopeChoiceText(value, retryPaths = {}) {
45
45
  export async function renderScopeChoices(value, anchor) {
46
46
  const result = chooseScopeResultSchema.parse(value);
47
47
  // Validate the output directory and old entries before removing only generated scope files.
48
- const [probe] = await outputPaths(anchor.outputAnchor, ["scope-1.json"], anchor.outputDir, true);
48
+ const outputDir = await reportOutputDirectory(anchor.outputAnchor, anchor.outputDir);
49
+ const [probe] = await outputPaths(anchor.outputAnchor, ["scope-1.json"], outputDir, true);
49
50
  const old = (await readdir(dirname(probe))).filter(name => /^scope-.*\.json$/.test(name));
50
- await outputPaths(anchor.outputAnchor, old, anchor.outputDir);
51
+ await outputPaths(anchor.outputAnchor, old, outputDir);
51
52
  for (const name of old)
52
53
  await unlink(join(dirname(probe), name));
53
54
  const runnable = result.choices.flatMap((choice, index) => choice.fits === false ? [] : [{ choice, index }]);
54
55
  const files = Object.fromEntries(runnable.map(({ choice, index }) => [`scope-${index + 1}.json`, JSON.stringify(choice.scope, null, 2) + "\n"]));
55
- const paths = await writeOutputFiles(anchor.outputAnchor, files, anchor.outputDir);
56
+ const paths = await writeReportFiles(anchor.outputAnchor, files, outputDir);
56
57
  return scopeChoiceText(result, Object.fromEntries(runnable.map(({ index }, position) => [index, paths[position]])));
57
58
  }