@counterfact-labs/cli 0.2.6 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +20 -0
  2. package/bundle/client-release.json +1 -1
  3. package/dist/adapters/analysis-results.js +87 -0
  4. package/dist/adapters/combined-results.js +27 -0
  5. package/dist/adapters/completion-lock.js +95 -0
  6. package/dist/adapters/completion.js +132 -17
  7. package/dist/adapters/entry-files.js +129 -0
  8. package/dist/adapters/entry-packing.js +161 -0
  9. package/dist/adapters/entry-selection.js +57 -0
  10. package/dist/adapters/files.js +111 -5
  11. package/dist/adapters/git.js +10 -1
  12. package/dist/adapters/relay-plan.js +5 -7
  13. package/dist/adapters/release.js +4 -1
  14. package/dist/adapters/results.js +18 -3
  15. package/dist/adapters/selection.js +114 -0
  16. package/dist/adapters/settings.js +62 -4
  17. package/dist/adapters/source-relay-plan.js +7 -0
  18. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -1
  19. package/dist/analysis-core/analysis/file-analysis-plan.js +1 -0
  20. package/dist/analysis-core/analysis/file-dependencies.js +1 -0
  21. package/dist/analysis-core/analysis/finding-activity-switch.js +1 -0
  22. package/dist/analysis-core/analysis/finding-identity.js +1 -0
  23. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  24. package/dist/analysis-core/analysis/folder-analysis-plan.js +1 -0
  25. package/dist/analysis-core/analysis/passes/full-file-passes.js +1 -0
  26. package/dist/analysis-core/analysis/project-architecture.js +1 -0
  27. package/dist/analysis-core/analysis/schemas.js +1 -1
  28. package/dist/analysis-core/chunks/2AASSZRO.js +35 -0
  29. package/dist/analysis-core/chunks/2YREYGOG.js +1 -0
  30. package/dist/analysis-core/chunks/33HRXJ5Q.js +1 -0
  31. package/dist/analysis-core/chunks/33IGHOYT.js +1 -0
  32. package/dist/analysis-core/chunks/4PT5C4IT.js +7 -0
  33. package/dist/analysis-core/chunks/4RXRAB5S.js +1 -0
  34. package/dist/analysis-core/chunks/4UDQBDDE.js +1 -0
  35. package/dist/analysis-core/chunks/{OPMA32A6.js → 5B5UCWD3.js} +1 -1
  36. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  37. package/dist/analysis-core/chunks/CQQ4TTXE.js +1 -0
  38. package/dist/analysis-core/chunks/CTLHKFPH.js +19 -0
  39. package/dist/analysis-core/chunks/F54DOWKN.js +6 -0
  40. package/dist/analysis-core/chunks/FRXOQ2M6.js +3 -0
  41. package/dist/analysis-core/chunks/HCPO6QPU.js +1 -0
  42. package/dist/analysis-core/chunks/HHFDNY6V.js +14 -0
  43. package/dist/analysis-core/chunks/{3JWEFCTJ.js → HVQJKPQ4.js} +1 -1
  44. package/dist/analysis-core/chunks/INVTU73D.js +1 -0
  45. package/dist/analysis-core/chunks/JEWBPIQI.js +1 -0
  46. package/dist/analysis-core/chunks/JGK37AWN.js +1 -0
  47. package/dist/analysis-core/chunks/LFYTD7JF.js +1 -0
  48. package/dist/analysis-core/chunks/LMUXOKIM.js +1 -0
  49. package/dist/analysis-core/chunks/LOBIDUCA.js +1 -0
  50. package/dist/analysis-core/chunks/M5YWJY7P.js +1 -0
  51. package/dist/analysis-core/chunks/MGVIKA74.js +1 -0
  52. package/dist/analysis-core/chunks/MKYLK6DB.js +1 -0
  53. package/dist/analysis-core/chunks/MZOKZ3N6.js +1 -0
  54. package/dist/analysis-core/chunks/NXHVBJL4.js +1 -0
  55. package/dist/analysis-core/chunks/OKBK3JL3.js +1 -0
  56. package/dist/analysis-core/chunks/PBHHWFR6.js +1 -0
  57. package/dist/analysis-core/chunks/Q5WR6B2H.js +2 -0
  58. package/dist/analysis-core/chunks/QVXARKP7.js +1 -0
  59. package/dist/analysis-core/chunks/RQTNTIR4.js +1 -0
  60. package/dist/analysis-core/chunks/RUVMFAST.js +1 -0
  61. package/dist/analysis-core/chunks/S27VIQMZ.js +1 -0
  62. package/dist/analysis-core/chunks/{YDBO7P4X.js → S27Y2TP5.js} +1 -1
  63. package/dist/analysis-core/chunks/SJESFYBA.js +10 -0
  64. package/dist/analysis-core/chunks/SP6XFHNH.js +1 -0
  65. package/dist/analysis-core/chunks/VDUFPTDD.js +1 -0
  66. package/dist/analysis-core/chunks/W24SFYZM.js +67 -0
  67. package/dist/analysis-core/chunks/YTHF2E2S.js +1 -0
  68. package/dist/analysis-core/chunks/YWL52FVW.js +2 -0
  69. package/dist/analysis-core/contracts/analysis-selection.js +1 -0
  70. package/dist/analysis-core/contracts/analysis-size.js +1 -0
  71. package/dist/analysis-core/contracts/execution-request.js +1 -0
  72. package/dist/analysis-core/contracts/intake-finding.js +1 -0
  73. package/dist/analysis-core/contracts/model-call-identity.js +1 -0
  74. package/dist/analysis-core/contracts/module-resolution.js +1 -0
  75. package/dist/analysis-core/contracts/relay-analysis.js +1 -1
  76. package/dist/analysis-core/contracts/requests.js +1 -1
  77. package/dist/analysis-core/contracts/selection-paths.js +1 -0
  78. package/dist/analysis-core/conventions/analysis-fit.js +1 -1
  79. package/dist/analysis-core/conventions/file-admission.js +1 -1
  80. package/dist/analysis-core/conventions/read-limits.js +1 -0
  81. package/dist/analysis-core/import/calls/modules.js +1 -0
  82. package/dist/analysis-core/import/entry-selection.js +1 -0
  83. package/dist/analysis-core/import/outside-loader-sources.js +1 -1
  84. package/dist/analysis-core/index.js +3 -3
  85. package/dist/analysis-core/requests/answer-formats.js +1 -1
  86. package/dist/analysis-core/requests/local-plan.js +1 -1
  87. package/dist/analysis-core/requests/planned-limits.js +1 -0
  88. package/dist/analysis-core/requests/registry.js +1 -1
  89. package/dist/analysis-core/requests/template-version.js +1 -1
  90. package/dist/analysis-core/requests/tools.js +1 -1
  91. package/dist/analysis-core/settings.js +1 -1
  92. package/dist/analysis-core/stages/engine-entry.js +1 -1
  93. package/dist/bundle/seal/engine.js +1 -1
  94. package/dist/cli/analysis-selection.js +133 -0
  95. package/dist/cli/api.js +1 -1
  96. package/dist/cli/commands/analyze.js +82 -14
  97. package/dist/cli/commands/skipped.js +65 -0
  98. package/dist/cli/completion-delivery.js +63 -0
  99. package/dist/cli/completion-recovery.js +68 -14
  100. package/dist/cli/context.js +4 -2
  101. package/dist/cli/finding-display.js +102 -7
  102. package/dist/cli/finding-settings.js +3 -1
  103. package/dist/cli/intake-payload.js +26 -2
  104. package/dist/cli/main.js +37 -11
  105. package/dist/cli/mcp-failure.js +6 -5
  106. package/dist/cli/mcp.js +191 -26
  107. package/dist/cli/prior-completion.js +35 -0
  108. package/dist/cli/relay-check.js +260 -110
  109. package/dist/cli/relay-failure.js +32 -0
  110. package/dist/cli/relay-preparation.js +72 -21
  111. package/dist/cli/result-check.js +31 -12
  112. package/dist/cli/results-upload.js +131 -0
  113. package/dist/cli/saved-results.js +10 -0
  114. package/dist/cli/scope-choices.js +57 -0
  115. package/dist/cli/sign-in.js +2 -2
  116. package/dist/cli/skipped-files.js +22 -0
  117. package/dist/cli/summary.js +15 -1
  118. package/dist/contract/agent-choice.js +25 -2
  119. package/dist/contract/cli-api-messages.js +3 -0
  120. package/dist/contract/cli-refusals.js +39 -5
  121. package/dist/contract/cli-version.js +1 -1
  122. package/dist/contract/console-url.js +3 -0
  123. package/dist/contract/console.js +24 -40
  124. package/dist/contract/engine.js +1 -1
  125. package/dist/contract/messages.js +4 -3
  126. package/dist/contract/project-architecture.js +1 -0
  127. package/dist/contract/scope-choice.js +98 -0
  128. package/dist/contract/selection-metadata.js +9 -0
  129. package/dist/contract/selection-offer.js +40 -0
  130. package/dist/gatekeeper/cli-api.js +70 -4
  131. package/dist/gatekeeper/console.js +55 -28
  132. package/dist/gatekeeper/model.js +5 -23
  133. package/dist/gatekeeper/trust/policy.js +2 -1
  134. package/package.json +5 -3
  135. package/dist/analysis-core/analysis/source-notes/types.js +0 -1
  136. package/dist/analysis-core/chunks/2PSNOSJ2.js +0 -1
  137. package/dist/analysis-core/chunks/377MLK4M.js +0 -21
  138. package/dist/analysis-core/chunks/5DGPLXYB.js +0 -1
  139. package/dist/analysis-core/chunks/6B2HILSY.js +0 -1
  140. package/dist/analysis-core/chunks/6QY5BV5B.js +0 -14
  141. package/dist/analysis-core/chunks/6U2IR4XB.js +0 -1
  142. package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
  143. package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
  144. package/dist/analysis-core/chunks/FSLWD2PE.js +0 -1
  145. package/dist/analysis-core/chunks/K2GNDX3W.js +0 -1
  146. package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
  147. package/dist/analysis-core/chunks/KWECQJ6X.js +0 -1
  148. package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
  149. package/dist/analysis-core/chunks/PECX3OXB.js +0 -1
  150. package/dist/analysis-core/chunks/PYKAERZZ.js +0 -1
  151. package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
  152. package/dist/analysis-core/chunks/SKVEBOGA.js +0 -1
  153. package/dist/analysis-core/chunks/STI4JE7L.js +0 -1
  154. package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
  155. package/dist/analysis-core/chunks/X7WZAA7S.js +0 -1
  156. package/dist/analysis-core/chunks/ZTOVBAG7.js +0 -1
  157. package/dist/analysis-core/data/finding-types.js +0 -1
@@ -1,16 +1,23 @@
1
+ import { skippedFilesEnabled } from "../analysis-core/settings.js";
2
+ import { analysisSkippedFiles, sizeSkippedFiles } from "../analysis-core/analysis/code-agents/skipped-files.js";
3
+ import { loadAnalysisResults, loadPublicReport, saveAnalysisContext, settleAnalysisContext } from "../adapters/analysis-results.js";
4
+ import { fingerprintOf } from "../analysis-core/analysis/finding-identity.js";
5
+ import { combineAssessments } from "../adapters/combined-results.js";
1
6
  import { randomUUID } from "node:crypto";
2
7
  import { disabledSetupPings } from "../setup-pings/client.js";
3
- import { saveCompletion, loadCompletion } from "../adapters/completion.js";
8
+ import { fileScopeEnabled, scopeChoicesEnabled } from "../adapters/settings.js";
9
+ import { saveCompletion, discardCompletion, lockCompletion } from "../adapters/completion.js";
4
10
  import { saveLocalResults } from "../adapters/results.js";
5
11
  import { GITIGNORE_OUTPUT_NOTICE } from "../adapters/gitignore-output.js";
12
+ import { priorCompletion } from "./prior-completion.js";
6
13
  import { OutputPathError, outputPaths } from "../adapters/output-paths.js";
7
14
  import { validateClientRelease } from "../adapters/release.js";
8
15
  import { selectAnalysisExecution } from "../adapters/models.js";
9
16
  import { resolveFolderPath } from "../adapters/folder-path.js";
10
17
  import { AgentChoiceError } from "../contract/agent-choice.js";
11
- import { savedAnalysisUrl } from "../contract/console-url.js";
12
- import { prepareRelayCheck } from "./relay-preparation.js";
13
- import { cliRefusals, COMMITTED_FILES_MESSAGE, failureReason, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
18
+ import { savedAnalysisUrl, savedArchitectureUrl } from "../contract/console-url.js";
19
+ import { prepareRelayCheck, prepareFolderChoice } from "./relay-preparation.js";
20
+ import { cliRefusals, refusalExitCode } from "../contract/cli-refusals.js";
14
21
  import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
15
22
  import { GatekeeperClient } from "../gatekeeper/client.js";
16
23
  import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
@@ -19,51 +26,17 @@ import { runAnalysisToEnd } from "../core/analysis/run-to-end.js";
19
26
  import { findingPresentation } from "../analysis-core/findings/presentation.js";
20
27
  import { formatSummary } from "./summary.js";
21
28
  import { buildIntakePayload } from "./intake-payload.js";
22
- import { completionRecoveryDecision, COMPLETION_SETTINGS_MISMATCH_MESSAGE, matchesCheck } from "./completion-recovery.js";
29
+ import { matchesCheck, observedCompletionTiming } from "./completion-recovery.js";
23
30
  import { savedFindings } from "./finding-display.js";
24
- import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
25
31
  import { readFindingSettings } from "./finding-settings.js";
32
+ import { deliverCompletedResults } from "./completion-delivery.js";
33
+ import { requireIntakeReservation, ResultsUploadError, submitResults, uploadFailure } from "./results-upload.js";
34
+ import { recoverRelayFailure } from "./relay-failure.js";
35
+ import { analysisSaveOptions, resolveRunSelection } from "./analysis-selection.js";
36
+ import { SCOPE_CHOICE_EXIT_CODE, progressLine } from "../contract/scope-choice.js";
37
+ import { ScopeChoiceError, scopeChoiceRefusal, selectionScopeChoices } from "../contract/selection-offer.js";
38
+ import { fileScopeOf } from "../analysis-core/analysis/file-analysis-plan.js";
26
39
  const stateLine = (status) => `Reservation ${status.state}; settlement ${status.settlement}; lease ${new Date(status.expiresAt).toLocaleString("en-US", { timeZone: "America/Los_Angeles", timeZoneName: "short" })}.\n`;
27
- function requireIntakeReservation(status) {
28
- if (status.state === "reserved")
29
- return;
30
- if (status.state === "closing")
31
- throw new ConsoleError("reservation_closing");
32
- if (status.state === "closed")
33
- throw new ConsoleError("reservation_closed");
34
- throw new ConsoleError("reservation_expired", "intake");
35
- }
36
- /** Retry an exact completed payload; a lost intake response must never purchase another analysis. */
37
- async function submit(client, payload, signal) {
38
- signal.throwIfAborted();
39
- const status = await client.status(payload.reservationId);
40
- const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId, analysisId: value.analysisId, findingIds: value.findingIds,
41
- ...(value.findings ? { findings: value.findings } : {}), settlement: value.settlement });
42
- if (!matchesCheck(status, payload))
43
- throw new ConsoleError("check_conflict");
44
- if (status.uploadState === "saved")
45
- return committed(status);
46
- requireIntakeReservation(status);
47
- signal.throwIfAborted();
48
- try {
49
- return await client.intake(payload);
50
- }
51
- catch (error) {
52
- // Keep the refusal for display; giving up the saved payload requires confirmed recovery status.
53
- const after = await client.status(payload.reservationId).catch(() => { throw error; });
54
- if (!matchesCheck(after, payload))
55
- throw new ConsoleError("check_conflict");
56
- if (after.uploadState === "saved")
57
- return committed(after);
58
- // Exact idempotent retry only; a second uncertainty leaves the completion record for restart.
59
- if (error instanceof ConsoleError && error.code === "unavailable") {
60
- requireIntakeReservation(after);
61
- signal.throwIfAborted();
62
- return client.intake(payload);
63
- }
64
- throw error;
65
- }
66
- }
67
40
  export function runRelayCheck(folder, options) {
68
41
  return runRelayLifecycle(folder, options);
69
42
  }
@@ -73,23 +46,73 @@ export function runReservedRelayCheck(folder, options) {
73
46
  }
74
47
  async function runRelayLifecycle(folder, options, existing) {
75
48
  const { config, stdout, stderr } = options;
49
+ const retainUnsaved = config.CF_LOCAL_RESULTS_RETRY !== "0";
76
50
  let settings = options.settings;
77
- const summaryOptions = { audienceEnabled: false };
51
+ const summaryOptions = { audienceEnabled: false, notActiveEnabled: false, skippedFilesEnabled: skippedFilesEnabled(settings), skippedOffer: !options.addToAnalysisId, analyzedFolder: options.analyzedFolder ?? folder };
52
+ let skippedFiles = [];
78
53
  let client, reservationId, projectId;
79
54
  let connection = existing?.connection;
80
- let identity;
81
55
  let completedPayload;
82
56
  let displayFindings;
83
- let completionOrigin;
57
+ let completionOrigin, completionRecordPath;
58
+ let completionTiming = {};
59
+ let combinedFindings;
60
+ let topFindingIds;
61
+ let refusedCalls;
84
62
  let completed = false, uploaded = false, heartbeat;
63
+ let selection, membership;
64
+ let selectedPaths, completionIdentity;
65
+ let outputDir = options.outputDir;
85
66
  let renewal;
67
+ let releaseCompletion;
68
+ let localErrorMessage;
69
+ let completionStored = false, lostCompletionLock = false;
70
+ let uploadOutcome;
71
+ let savedResult;
86
72
  const controller = new AbortController();
87
73
  const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
74
+ // A completed upload must survive the analysis lease closing during local report writes.
75
+ const uploadSignal = retainUnsaved ? options.signal ?? new AbortController().signal : signal;
76
+ const persist = async (payload, origin, uploaded = false, finished = false) => {
77
+ if (lostCompletionLock)
78
+ throw new OutputPathError();
79
+ const path = await saveCompletion(folder, payload, origin, { uploaded, finished, completedAt: completionTiming.completedAt,
80
+ absoluteExpiresAt: completionTiming.absoluteExpiresAt, retainUnsaved, identity: completionIdentity });
81
+ completionStored = true;
82
+ completionRecordPath = path;
83
+ return path;
84
+ };
85
+ const submit = (payload, alreadySaved = false) => {
86
+ if (retainUnsaved) {
87
+ try {
88
+ options.onSaving?.();
89
+ stderr("Saving the results…\n");
90
+ }
91
+ catch { /* Progress output must not prevent saving completed work. */ }
92
+ }
93
+ return submitResults(client, payload, uploadSignal, { retry: retainUnsaved, alreadySaved, timing: completionTiming }).then(result => { savedResult = result; return result; });
94
+ };
88
95
  const showSaved = (result) => {
89
96
  const url = savedAnalysisUrl(result.projectId, config.CF_CONSOLE_URL);
90
- const findings = savedFindings({ findingIds: result.findingIds ?? [], findings: result.findings }, displayFindings ?? completedPayload?.findings ?? []);
91
- options.onSaved?.({ ...result, findings, url, audienceEnabled: summaryOptions.audienceEnabled });
92
- stdout(`Saved analysis ${result.analysisId}: ${url}\n`);
97
+ const architectureUrl = completedPayload?.architecture ? savedArchitectureUrl(result.projectId, config.CF_CONSOLE_URL) : undefined;
98
+ const findings = combinedFindings ?? savedFindings({ findingIds: result.findingIds ?? [], findings: result.findings }, displayFindings ?? completedPayload?.findings ?? []);
99
+ options.onSaved?.({ ...result, ...(combinedFindings ? { findingIds: combinedFindings.map(f => f.id), topFindingIds } : {}), ...(!skippedFilesEnabled(settings) ? {} : { skippedFiles }), findings, url, ...(architectureUrl ? { architectureUrl } : {}), ...summaryOptions });
100
+ stdout(result.analysisId ? `Saved analysis ${result.analysisId}: ${url}\n` : `Full results: ${url}\n`);
101
+ if (architectureUrl)
102
+ stdout(`Architecture: ${architectureUrl}\n`);
103
+ };
104
+ const settleSaved = async (result) => {
105
+ const payload = completedPayload;
106
+ if (!result.analysisId || !skippedFilesEnabled(settings) || !payload?.coverage || !payload.addToAnalysisId && !sizeSkippedFiles(payload.coverage.skippedFiles).length)
107
+ return;
108
+ const context = await settleAnalysisContext(folder, { ...result, analysisId: result.analysisId, findingIds: result.findingIds ?? [] }, payload.checkId, payload.addToAnalysisId, outputDir);
109
+ const { report } = await loadAnalysisResults(folder, outputDir);
110
+ skippedFiles = report.refusedFiles;
111
+ if (payload.addToAnalysisId) {
112
+ topFindingIds = context.topFindingIds;
113
+ const references = new Map(context.findings?.map(f => [f.fingerprint, f]));
114
+ combinedFindings = report.findings.flatMap(finding => { const saved = references.get(fingerprintOf(finding)); return saved ? [saved] : []; });
115
+ }
93
116
  };
94
117
  try {
95
118
  if (!config.CF_CONSOLE_URL || !existing && (!config.CF_RELAY_URL || !config.CF_CONSOLE_TOKEN)) {
@@ -101,61 +124,103 @@ async function runRelayLifecycle(folder, options, existing) {
101
124
  throw new ConsoleError("check_conflict");
102
125
  reservationId = existing.reservation.reservationId;
103
126
  projectId = existing.reservation.projectId;
104
- identity = existing.reservation;
105
127
  client = existing.client;
106
128
  }
107
129
  else {
108
130
  relayOrigin(config.CF_RELAY_URL);
109
131
  client = new ConsoleClient({ url: config.CF_CONSOLE_URL, token: config.CF_CONSOLE_TOKEN });
110
132
  }
111
- // Signature verification precedes all folder reads, status calls and spending.
133
+ // Signature verification precedes all file content reads, status calls and spending.
112
134
  const validated = validateClientRelease(options.release, settings);
113
135
  const { bundle } = validated;
114
136
  settings = validated.settings;
115
- summaryOptions.audienceEnabled = readFindingSettings(settings).audienceEnabled;
116
- folder = await resolveFolderPath(folder);
137
+ const findingSettings = readFindingSettings(settings);
138
+ summaryOptions.audienceEnabled = findingSettings.audienceEnabled;
139
+ summaryOptions.notActiveEnabled = findingSettings.notActiveEnabled;
140
+ selection = await resolveRunSelection(folder, options.cwd ?? process.cwd(), settings, options);
141
+ // Verify the selected folder before output and recovery operations can fail for their own reasons.
142
+ folder = await resolveFolderPath(selection?.outputAnchor ?? folder).catch(error => {
143
+ if (error instanceof Error)
144
+ Object.assign(error, { localOperation: "read" });
145
+ throw error;
146
+ });
147
+ outputDir ??= selection?.outputDir;
148
+ if (selection?.kind === "file")
149
+ completionIdentity = {
150
+ consoleOrigin: new URL(config.CF_CONSOLE_URL).origin, selectionKey: selection.selectionKey,
151
+ };
117
152
  signal.throwIfAborted();
118
- const prior = await loadCompletion(folder);
119
- if (prior && (!prior.uploaded && !prior.finished || options.retryUpload)) {
153
+ if (retainUnsaved)
154
+ releaseCompletion = await lockCompletion(folder, () => { lostCompletionLock = true; controller.abort(); }, completionIdentity);
155
+ const recovery = await priorCompletion(folder, { retryEnabled: retainUnsaved,
156
+ consoleOrigin: new URL(config.CF_CONSOLE_URL).origin, projectId: existing?.expectedProjectId, retryUpload: options.retryUpload,
157
+ identity: completionIdentity });
158
+ for (const notice of recovery.notices) {
159
+ try {
160
+ options.onNotice?.(notice);
161
+ stderr(`${notice}\n`);
162
+ }
163
+ catch { /* Notices must not prevent saving the current setup's results. */ }
164
+ }
165
+ if (recovery.prior) {
166
+ const { record: prior, path } = recovery.prior;
120
167
  // Recovering another check would leave the caller's paid reservation unexecuted.
121
168
  if (existing && (prior.payload.reservationId !== reservationId || !matchesCheck(prior.payload, existing.reservation))) {
122
169
  throw new ConsoleError("check_conflict");
123
170
  }
171
+ completionStored = true;
172
+ completionRecordPath = path;
124
173
  completedPayload = prior.payload;
174
+ skippedFiles = prior.payload.coverage?.skippedFiles ?? [];
125
175
  completionOrigin = prior.consoleOrigin;
176
+ completionTiming = { completedAt: prior.completedAt, absoluteExpiresAt: prior.absoluteExpiresAt };
126
177
  uploaded = prior.uploaded ?? false;
127
- const recovery = completionRecoveryDecision({ statusSucceeded: false,
128
- localMismatch: prior.consoleOrigin !== new URL(config.CF_CONSOLE_URL).origin });
129
- if (recovery.message)
130
- throw new Error(recovery.message);
131
178
  reservationId = prior.payload.reservationId;
132
- identity = prior.payload;
133
179
  completed = true;
134
- const saved = await submit(client, prior.payload, signal);
180
+ const saved = await submit(prior.payload, retainUnsaved && uploaded);
135
181
  uploaded = true;
136
- await saveCompletion(folder, prior.payload, prior.consoleOrigin, true);
182
+ await persist(prior.payload, prior.consoleOrigin, true);
183
+ await settleSaved(saved);
137
184
  showSaved(saved);
138
185
  return 0;
139
186
  }
140
187
  signal.throwIfAborted();
141
188
  if (options.retryUpload)
142
189
  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)) {
196
+ if (options.addToAnalysisId || fileScopeOf(settings) !== undefined)
197
+ throw new ConsoleError("unavailable", "start");
198
+ settings = Object.freeze({ ...settings, CF_SKIPPED_FILES: "0" });
199
+ summaryOptions.skippedFilesEnabled = false;
200
+ }
143
201
  try {
144
- await outputPaths(folder, ["findings.json", "summary.md"], options.outputDir);
202
+ await outputPaths(folder, ["findings.json", "summary.md", ...(selection?.kind === "file" ? ["selection.json"] : [])], outputDir);
145
203
  }
146
204
  catch (error) {
147
205
  if (error.code !== "ENOENT")
148
206
  throw error;
149
207
  }
150
- const { source, provenance, plan, start, startMetadata } = await prepareRelayCheck(folder, { ...options, signal, expectedCommit: existing?.expectedCommit, expectedFiles: existing?.expectedFiles }, validated);
208
+ const prepared = await prepareRelayCheck(selection?.path ?? folder, { ...options, settings, selection, signal, expectedCommit: existing?.expectedCommit, expectedFiles: existing?.expectedFiles }, { ...validated, settings });
209
+ const { source, provenance, plan, start, startMetadata } = prepared;
210
+ membership = prepared.membership;
211
+ const includedPaths = Object.keys(source.files);
212
+ selectedPaths = includedPaths;
213
+ refusedCalls = plan.calls;
214
+ skippedFiles = source.skippedFiles;
151
215
  let reservation = existing?.reservation;
152
216
  if (!reservation) {
153
- const started = await client.start(start, startMetadata);
217
+ const started = await client.start(start, { ...startMetadata, ...(options.addToAnalysisId ? { addToAnalysisId: options.addToAnalysisId } : {}) });
218
+ if (options.expectedProjectId && started.projectId !== options.expectedProjectId)
219
+ throw new ConsoleError("check_conflict");
154
220
  projectId = started.projectId;
155
221
  signal.throwIfAborted();
156
222
  const created = await client.create({ ...started, checkId: randomUUID(), plan });
157
223
  reservationId = created.reservationId;
158
- identity = created;
159
224
  signal.throwIfAborted();
160
225
  // Attestation contains no source and precedes reserve, so a bad relay never starts paid work.
161
226
  connection = await (options.connect ?? (url => connectRelay(url, { trust: productionTrust })))(config.CF_RELAY_URL);
@@ -180,23 +245,55 @@ async function runRelayLifecycle(folder, options, existing) {
180
245
  reservationId: activeId, reservationToken: reservation.reservationToken, instructions: { bundle, verificationKey: options.release.verificationKey }, signal,
181
246
  });
182
247
  options.onStart?.();
183
- stderr("Reading the folder and running analysis.\n");
248
+ stderr(progressLine(selection));
184
249
  (options.setupPings ?? disabledSetupPings).ping("first_analysis_started");
185
- const report = await runAnalysisToEnd(source, execution.model, settings, { execution });
186
- signal.throwIfAborted();
187
- const paths = await saveLocalResults(folder, report, source.skippedFiles, options.outputDir, { ...summaryOptions, settings });
188
- try {
189
- options.onLocalSaved?.(paths);
190
- if (paths.ignoreAdded)
191
- stderr(GITIGNORE_OUTPUT_NOTICE);
192
- }
193
- catch { /* Output failure must not change a server outcome. */ }
194
- const { payload, resultCheck, findingIndexes } = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
195
- bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report, { settings, files: source.snapshot });
196
- displayFindings = findingIndexes.map(index => {
250
+ const report = await runAnalysisToEnd(source, execution.model, settings, { execution, ...(options.addToAnalysisId ? { findingsOnly: true } : {}) });
251
+ skippedFiles = !skippedFilesEnabled(settings) ? source.skippedFiles : analysisSkippedFiles(source.skippedFiles, report.skippedFiles);
252
+ const priorResults = options.addToAnalysisId ? await loadAnalysisResults(folder, outputDir) : undefined;
253
+ const assessments = combineAssessments(priorResults?.context.assessments ?? [], report.findingCheck.qualityAudit?.assessments ?? []);
254
+ completionTiming = observedCompletionTiming({ completedAt: Date.now() }, { kind: "found", status: reservation }, reservation);
255
+ // The 30-minute lease covers upload retries; renewal failure must not cancel completed results.
256
+ if (retainUnsaved)
257
+ clearInterval(heartbeat);
258
+ else
259
+ signal.throwIfAborted();
260
+ let saveContext;
261
+ const saveReports = async () => {
262
+ const paths = await saveLocalResults(folder, report, skippedFiles, outputDir, { ...summaryOptions, settings,
263
+ ...analysisSaveOptions(selection, options.outputDir), selection: membership, includedPaths,
264
+ ...(options.addToAnalysisId ? { append: { readPaths: includedPaths, assessments } } : {}) });
265
+ try {
266
+ options.onLocalSaved?.(paths);
267
+ if (paths.ignoreAdded)
268
+ stderr(GITIGNORE_OUTPUT_NOTICE);
269
+ }
270
+ catch { /* Output failure must not change a server outcome. */ }
271
+ await saveContext?.();
272
+ return paths;
273
+ };
274
+ // The retry off path saves the full report even when intake validation refuses it.
275
+ let paths = retainUnsaved ? undefined : await saveReports();
276
+ 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,
278
+ ...(!skippedFilesEnabled(settings) || !options.addToAnalysisId && !sizeSkippedFiles(skippedFiles).length ? {} : { coverage: { skippedFiles: sizeSkippedFiles(skippedFiles), readPaths: includedPaths },
279
+ addToAnalysisId: options.addToAnalysisId, findingAssessments: report.findingCheck.qualityAudit?.assessments ?? [] }) });
280
+ displayFindings = findingIndexes.map((index, acceptedIndex) => {
197
281
  const finding = report.findings[index];
198
- return { title: findingPresentation(finding).displayTitle, audience: finding.audience };
282
+ const activity = findingActivities[acceptedIndex];
283
+ return { title: findingPresentation(finding).displayTitle, audience: finding.audience,
284
+ severity: finding.severity, site: finding.site, fixes: finding.fixes,
285
+ ...(activity === undefined ? {} : { activity }) };
199
286
  });
287
+ if (skippedFilesEnabled(settings) && (options.addToAnalysisId || sizeSkippedFiles(skippedFiles).length)) {
288
+ saveContext = async () => {
289
+ await saveAnalysisContext(folder, { projectId: projectId, analysisId: options.addToAnalysisId ?? reservation.checkId,
290
+ settings: priorResults?.context.settings ?? settings, assessments,
291
+ ...(priorResults?.context.findings ? { findings: priorResults.context.findings } : {}),
292
+ pending: { checkId: reservation.checkId, fingerprints: findingIndexes.map(index => fingerprintOf(report.findings[index])), titles: [...displayFindings] } }, outputDir);
293
+ };
294
+ if (paths)
295
+ await saveContext();
296
+ }
200
297
  if (resultCheck?.droppedFindings) {
201
298
  const count = resultCheck.droppedFindings;
202
299
  const reason = resultCheck.schemaDroppedFindings || resultCheck.capDroppedFindings
@@ -207,54 +304,106 @@ async function runRelayLifecycle(folder, options, existing) {
207
304
  catch { /* Output failure must not change a server outcome. */ }
208
305
  }
209
306
  completionOrigin = new URL(config.CF_CONSOLE_URL).origin;
210
- await saveCompletion(folder, payload, completionOrigin);
211
- completedPayload = payload;
212
- completed = true;
213
- const saved = await submit(client, payload, signal);
214
- uploaded = true;
215
- await saveCompletion(folder, payload, completionOrigin, true);
216
- stdout(formatSummary(report, summaryOptions));
307
+ let saved;
308
+ if (retainUnsaved) {
309
+ completedPayload = payload;
310
+ completed = true;
311
+ const delivery = await deliverCompletedResults({ persist: uploaded => persist(payload, completionOrigin, uploaded),
312
+ submit: () => submit(payload), saveReports });
313
+ uploaded = delivery.uploaded;
314
+ saved = delivery.saved;
315
+ paths = delivery.paths;
316
+ localErrorMessage = delivery.localErrorMessage;
317
+ uploadOutcome = delivery.outcome;
318
+ completionTiming = delivery.timing ?? completionTiming;
319
+ if (delivery.error)
320
+ throw delivery.error;
321
+ }
322
+ else {
323
+ await persist(payload, completionOrigin);
324
+ completedPayload = payload;
325
+ completed = true;
326
+ paths ??= await saveReports();
327
+ saved = await submit(payload);
328
+ uploaded = true;
329
+ await persist(payload, completionOrigin, true);
330
+ }
331
+ await settleSaved(saved);
332
+ stdout(formatSummary(options.addToAnalysisId ? await loadPublicReport(folder, outputDir) : { ...report, refusedFiles: skippedFiles }, summaryOptions));
217
333
  stdout(`\nSaved ${paths.findingsPath}\nSaved ${paths.summaryPath}\n`);
218
334
  showSaved(saved);
219
335
  return 0;
220
336
  }
221
- catch (error) {
337
+ catch (caught) {
338
+ let error = caught;
222
339
  let status;
340
+ let statusLookup = { kind: "skipped" };
223
341
  if (client && reservationId) {
224
342
  try {
225
343
  status = await (completed || uploaded ? client.status(reservationId) : client.fail(reservationId));
344
+ statusLookup = { kind: "found", status };
226
345
  }
227
- catch {
228
- // An uncertain server outcome cannot prove a refund.
346
+ catch (statusError) {
347
+ statusLookup = { ...uploadFailure(statusError), kind: "failed" };
229
348
  }
230
349
  }
231
- if (status?.uploadState === "saved" && identity && matchesCheck(status, identity)) {
232
- if (completedPayload) {
233
- try {
234
- await saveCompletion(folder, completedPayload, completionOrigin, true);
350
+ const sizeRefusal = error instanceof ConsoleError && ["plan_over_limit", "folder_too_large"].includes(error.code);
351
+ const closed = !reservationId || status && (status.state === "closed" || status.state === "expired");
352
+ if (scopeChoicesEnabled(settings) && !completedPayload && closed) {
353
+ const choice = error instanceof ScopeChoiceError ? error.choice
354
+ : sizeRefusal && membership ? selectionScopeChoices(membership, { currentPaths: selectedPaths,
355
+ oversizedPaths: membership.kind === "file" && selectedPaths?.length === 1 && selectedPaths[0] === membership.entry
356
+ ? [membership.entry] : undefined }) : undefined;
357
+ if (choice) {
358
+ const refusal = scopeChoiceRefusal(choice);
359
+ if (refusal)
360
+ error = new Error(refusal);
361
+ else {
362
+ options.onScopeChoice?.(choice);
363
+ if (!options.onScopeChoice)
364
+ stderr(`${choice.text}\n`);
365
+ return SCOPE_CHOICE_EXIT_CODE;
235
366
  }
236
- catch { /* Committed server state still survives a local write failure. */ }
237
367
  }
368
+ }
369
+ if (error instanceof ResultsUploadError)
370
+ 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) }, {
374
+ markUploaded: () => persist(completedPayload, completionOrigin, true),
375
+ keepPending: timing => { completionTiming = timing; return persist(completedPayload, completionOrigin); },
376
+ retire: () => retainUnsaved ? completionRecordPath ? discardCompletion(folder, completionRecordPath, completionIdentity) : Promise.resolve() : persist(completedPayload, completionOrigin, false, true),
377
+ });
378
+ if (failure.saved) {
238
379
  try {
239
- showSaved({ projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds, findings: status.findings });
380
+ const saved = savedResult ?? { projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds, findings: status.findings };
381
+ await settleSaved(saved);
382
+ showSaved(saved);
240
383
  }
241
384
  catch { /* Output failure must not change a server outcome. */ }
242
- return 0;
385
+ if (!failure.message)
386
+ return 0;
387
+ try {
388
+ options.onError?.({ reason: failure.reason, message: failure.message });
389
+ stderr(`${failure.message}\n`);
390
+ }
391
+ catch { /* Reporting failure cannot undo saved results. */ }
392
+ return 1;
243
393
  }
244
- const recovery = completionRecoveryDecision({ statusSucceeded: status !== undefined, status, completedPayload });
245
- if (completedPayload && recovery.finished) {
394
+ let scopeChoice;
395
+ if (!reservationId && !existing && error instanceof ConsoleError && (error.code === "folder_too_large" || error.code === "plan_over_limit")) {
246
396
  try {
247
- await saveCompletion(folder, completedPayload, completionOrigin, false, true);
397
+ const choice = await prepareFolderChoice(folder, { ...options, settings, signal, limit: error.code, refusedCalls });
398
+ if (choice)
399
+ scopeChoice = new AgentChoiceError(choice);
248
400
  }
249
- catch { /* Keep the pending record if the durable write cannot be confirmed. */ }
401
+ catch { /* A failed local plan preserves the console's authoritative refusal. */ }
250
402
  }
251
- const known = error instanceof AgentChoiceError || error instanceof ConsoleError || error instanceof OutputPathError || error instanceof Error && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, INVALID_PROPOSAL_MESSAGE].includes(error.message)
252
- || error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
253
- const message = known ? error.message : FINISH_FAILURE_MESSAGE;
254
403
  try {
255
- options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failureReason(error), message,
256
- ...(error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
257
- stderr(`${message}\n`);
404
+ options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failure.reason, message: scopeChoice?.message ?? failure.message,
405
+ ...(scopeChoice ? { agentChoice: scopeChoice.agentChoice } : error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
406
+ stderr(`${scopeChoice?.message ?? failure.message}\n`);
258
407
  if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
259
408
  stderr(stateLine(status));
260
409
  }
@@ -264,6 +413,7 @@ async function runRelayLifecycle(folder, options, existing) {
264
413
  finally {
265
414
  clearInterval(heartbeat);
266
415
  await renewal;
416
+ await releaseCompletion?.().catch(() => { });
267
417
  try {
268
418
  connection?.close();
269
419
  }
@@ -0,0 +1,32 @@
1
+ import { CompletionBusyError } from "../adapters/completion-lock.js";
2
+ import { OutputPathError } from "../adapters/output-paths.js";
3
+ import { AgentChoiceError } from "../contract/agent-choice.js";
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";
6
+ import { isSelectionRefusal, selectionRefusalMessage } from "../contract/scope-choice.js";
7
+ import { ConsoleError } from "../gatekeeper/console.js";
8
+ import { recoverCompletedResults } from "./completion-delivery.js";
9
+ import { CompletionSettingsMismatchError } from "./prior-completion.js";
10
+ import { ResultsUploadError } from "./results-upload.js";
11
+ /** Cleanup reports the recovery owner's decision without exposing private diagnostics. */
12
+ export async function recoverRelayFailure(input, operations) {
13
+ const { error, retryEnabled } = input;
14
+ const carried = retryEnabled ? input.uploadOutcome ?? (error instanceof ResultsUploadError ? error.outcome : undefined) : undefined;
15
+ const recovery = input.payload && input.completed ? await recoverCompletedResults({
16
+ retryEnabled, payload: input.payload, timing: input.timing, statusLookup: input.statusLookup,
17
+ uploadOutcome: retryEnabled && input.savedResult ? { kind: "saved" } : carried ?? { kind: "cancelled" },
18
+ canWrite: input.canWrite, completionStored: input.completionStored, localErrorMessage: input.localErrorMessage,
19
+ }, operations) : undefined;
20
+ 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
25
+ && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, INVALID_PROPOSAL_MESSAGE].includes(error.message)
26
+ || 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 };
32
+ }