@counterfact-labs/cli 0.1.3 → 0.2.4

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 (186) hide show
  1. package/README.md +176 -42
  2. package/bundle/client-release.json +7 -0
  3. package/bundle/sealed.json +908 -0
  4. package/dist/adapters/anthropic.js +151 -0
  5. package/dist/adapters/completion.js +112 -0
  6. package/dist/adapters/durable-write.js +34 -0
  7. package/dist/adapters/files.js +144 -0
  8. package/dist/adapters/folder-path.js +6 -0
  9. package/dist/adapters/git-ignore.js +49 -0
  10. package/dist/adapters/git.js +44 -0
  11. package/dist/adapters/ignored-files.js +36 -0
  12. package/dist/adapters/instructions.js +35 -0
  13. package/dist/adapters/models.js +51 -0
  14. package/dist/adapters/output-paths.js +92 -0
  15. package/dist/adapters/prompt-cache.js +28 -0
  16. package/dist/adapters/public-instructions.js +36 -0
  17. package/dist/adapters/quality.js +6 -0
  18. package/dist/adapters/relay-plan.js +15 -0
  19. package/dist/adapters/release.js +62 -0
  20. package/dist/adapters/report.js +29 -0
  21. package/dist/adapters/repository-files.js +199 -0
  22. package/dist/adapters/repository-root.js +120 -0
  23. package/dist/adapters/results.js +9 -0
  24. package/dist/adapters/settings.js +26 -0
  25. package/dist/adapters/snapshot.js +68 -0
  26. package/dist/analysis-core/analysis/analysis-flags.js +1 -0
  27. package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
  28. package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
  29. package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
  30. package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
  31. package/dist/analysis-core/analysis/headlines/input.js +1 -0
  32. package/dist/analysis-core/analysis/headlines/types.js +1 -0
  33. package/dist/analysis-core/analysis/schemas.js +1 -0
  34. package/dist/analysis-core/analysis/source-notes/types.js +1 -0
  35. package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
  36. package/dist/analysis-core/chunks/3227KXHF.js +1 -0
  37. package/dist/analysis-core/chunks/326F4XNG.js +1 -0
  38. package/dist/analysis-core/chunks/3CKW5JIF.js +1 -0
  39. package/dist/analysis-core/chunks/3GUKUEEJ.js +1 -0
  40. package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
  41. package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
  42. package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
  43. package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
  44. package/dist/analysis-core/chunks/724WZMYW.js +2 -0
  45. package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
  46. package/dist/analysis-core/chunks/7OYIBWX3.js +1 -0
  47. package/dist/analysis-core/chunks/D6AWHLRI.js +1 -0
  48. package/dist/analysis-core/chunks/DRGDIIFA.js +1 -0
  49. package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
  50. package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
  51. package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
  52. package/dist/analysis-core/chunks/FTODBFQW.js +1 -0
  53. package/dist/analysis-core/chunks/HPIAEQHE.js +37 -0
  54. package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
  55. package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
  56. package/dist/analysis-core/chunks/IVDDV6CU.js +14 -0
  57. package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
  58. package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
  59. package/dist/analysis-core/chunks/JKWTHRUR.js +1 -0
  60. package/dist/analysis-core/chunks/K5OTWBTT.js +22 -0
  61. package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
  62. package/dist/analysis-core/chunks/MJGB25S5.js +1 -0
  63. package/dist/analysis-core/chunks/MXXNED6N.js +7 -0
  64. package/dist/analysis-core/chunks/NCWEOJVJ.js +6 -0
  65. package/dist/analysis-core/chunks/O652FQQH.js +1 -0
  66. package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
  67. package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
  68. package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
  69. package/dist/analysis-core/chunks/QMIXCT62.js +1 -0
  70. package/dist/analysis-core/chunks/QV2EOS7U.js +1 -0
  71. package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
  72. package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
  73. package/dist/analysis-core/chunks/T2SAP7OV.js +60 -0
  74. package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
  75. package/dist/analysis-core/chunks/T66VDFXF.js +1 -0
  76. package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
  77. package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
  78. package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
  79. package/dist/analysis-core/chunks/V7BFUTM7.js +1 -0
  80. package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
  81. package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
  82. package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
  83. package/dist/analysis-core/contracts/requests.js +1 -0
  84. package/dist/analysis-core/conventions/analysis-fit.js +1 -0
  85. package/dist/analysis-core/conventions/file-admission.js +1 -0
  86. package/dist/analysis-core/data/finding-types.js +1 -0
  87. package/dist/analysis-core/errors.js +1 -0
  88. package/dist/analysis-core/findings/presentation.js +1 -0
  89. package/dist/analysis-core/github/repo-key.js +1 -0
  90. package/dist/analysis-core/import/outside-loader-sources.js +1 -0
  91. package/dist/analysis-core/index.js +6 -0
  92. package/dist/analysis-core/llm/cache-policy.js +1 -0
  93. package/dist/analysis-core/llm/cost.js +1 -0
  94. package/dist/analysis-core/llm/execution.js +1 -0
  95. package/dist/analysis-core/llm/output-limit.js +1 -0
  96. package/dist/analysis-core/llm/slots.js +1 -0
  97. package/dist/analysis-core/llm/stable-json.js +1 -0
  98. package/dist/analysis-core/llm/structured-output-error.js +1 -0
  99. package/dist/analysis-core/map-limit.js +1 -0
  100. package/dist/analysis-core/requests/answer-formats.js +1 -0
  101. package/dist/analysis-core/requests/check-diagnostics.js +1 -0
  102. package/dist/analysis-core/requests/headline-request.js +1 -0
  103. package/dist/analysis-core/requests/instruction-text.js +1 -0
  104. package/dist/analysis-core/requests/local-plan.js +1 -0
  105. package/dist/analysis-core/requests/registry.js +1 -0
  106. package/dist/analysis-core/requests/schema-descriptions.js +1 -0
  107. package/dist/analysis-core/requests/shape-finding-input.js +1 -0
  108. package/dist/analysis-core/requests/template-version.js +1 -0
  109. package/dist/analysis-core/requests/tools.js +1 -0
  110. package/dist/analysis-core/settings.js +1 -0
  111. package/dist/bundle/seal/deserialise.js +75 -0
  112. package/dist/bundle/seal/encryption.js +25 -0
  113. package/dist/bundle/seal/metadata.js +69 -0
  114. package/dist/bundle/seal/open.js +38 -0
  115. package/dist/bundle/seal/planned-calls.js +20 -0
  116. package/dist/bundle/seal/signature.js +51 -0
  117. package/dist/cli/agent-folder.js +17 -0
  118. package/dist/cli/api-types.js +2 -0
  119. package/dist/cli/api.js +1 -0
  120. package/dist/{claude-code-permission.js → cli/claude-code-permission.js} +4 -3
  121. package/dist/cli/commands/analyze.js +75 -0
  122. package/dist/cli/commands/login.js +51 -0
  123. package/dist/cli/completion-recovery.js +18 -0
  124. package/dist/{context.js → cli/context.js} +8 -14
  125. package/dist/cli/development-relay.js +34 -0
  126. package/dist/cli/email-sign-in.js +56 -0
  127. package/dist/{git.js → cli/git.js} +12 -8
  128. package/dist/cli/intake-payload.js +18 -0
  129. package/dist/{main.js → cli/main.js} +23 -12
  130. package/dist/{mcp.js → cli/mcp.js} +109 -40
  131. package/dist/cli/output.js +10 -0
  132. package/dist/cli/relay-check.js +239 -0
  133. package/dist/cli/relay-preparation.js +33 -0
  134. package/dist/cli/sign-in-input.js +97 -0
  135. package/dist/{sign-in.js → cli/sign-in.js} +2 -1
  136. package/dist/cli/suggested-email.js +10 -0
  137. package/dist/cli/summary.js +30 -0
  138. package/dist/contract/cli-api-messages.js +5 -0
  139. package/dist/contract/cli-refusals.js +24 -0
  140. package/dist/contract/cli-version.js +1 -0
  141. package/dist/contract/console-url.js +8 -0
  142. package/dist/contract/console.js +131 -0
  143. package/dist/contract/email-sign-in-messages.js +18 -0
  144. package/dist/contract/messages.js +252 -0
  145. package/dist/contract/release-key.js +2 -0
  146. package/dist/contract/runtime-environment.js +37 -0
  147. package/dist/core/analysis/finding-quality/config.js +4 -0
  148. package/dist/core/analysis/run-to-end.js +55 -0
  149. package/dist/core/analysis/structural-run.js +16 -0
  150. package/dist/gatekeeper/attestation.js +67 -0
  151. package/dist/gatekeeper/certificate-algorithms.js +18 -0
  152. package/dist/gatekeeper/certificate-path.js +99 -0
  153. package/dist/gatekeeper/cli-api.js +174 -0
  154. package/dist/gatekeeper/client.js +76 -0
  155. package/dist/gatekeeper/connection-exchange.js +61 -0
  156. package/dist/gatekeeper/connection.js +140 -0
  157. package/dist/gatekeeper/console.js +175 -0
  158. package/dist/gatekeeper/loopback.js +5 -0
  159. package/dist/gatekeeper/model.js +70 -0
  160. package/dist/gatekeeper/production-attestation.js +149 -0
  161. package/dist/gatekeeper/trust/hardware.js +21 -0
  162. package/dist/gatekeeper/trust/policy.js +42 -0
  163. package/dist/gatekeeper/trust/production.json +25 -0
  164. package/dist/setup-pings/client.js +31 -0
  165. package/dist/setup-pings/contract.js +2 -0
  166. package/dist/setup-pings/policy.js +3 -0
  167. package/dist/setup-pings/state.js +261 -0
  168. package/dist/setup-pings/worker.js +95 -0
  169. package/dist/transport/base64url.js +9 -0
  170. package/dist/transport/http-response.js +89 -0
  171. package/dist/transport/tls-exporter.js +5 -0
  172. package/package.json +57 -11
  173. package/LICENSE +0 -1
  174. package/dist/api-types.js +0 -2
  175. package/dist/api.js +0 -97
  176. package/dist/commands/check.js +0 -104
  177. package/dist/commands/login.js +0 -9
  178. package/dist/files.js +0 -99
  179. package/dist/gitignore.js +0 -70
  180. package/dist/output.js +0 -23
  181. package/dist/secret-files.js +0 -5
  182. /package/dist/{browser.js → cli/browser.js} +0 -0
  183. /package/dist/{commands → cli/commands}/balance.js +0 -0
  184. /package/dist/{commands → cli/commands}/finding.js +0 -0
  185. /package/dist/{commands → cli/commands}/logout.js +0 -0
  186. /package/dist/{credentials.js → cli/credentials.js} +0 -0
@@ -0,0 +1,151 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
3
+ import { JobError } from "../analysis-core/errors.js";
4
+ import { estimateTokensFromBytes } from "../analysis-core/llm/cost.js";
5
+ import { anthropicStructuredLimit, supportsReasoningControls } from "../analysis-core/llm/output-limit.js";
6
+ import { requestCachesInput, structuredPromptOf } from "./prompt-cache.js";
7
+ import { StructuredOutputError } from "../analysis-core/llm/structured-output-error.js";
8
+ export function anthropicLlm(settings, opts = {}) {
9
+ const client = opts.client ?? new Anthropic({ ...(opts.apiKey === undefined ? {} : { apiKey: opts.apiKey }) });
10
+ return { structured: call => structuredCall(settings, client, call, opts.onUsage, opts.sourceRetries) };
11
+ }
12
+ /** The recorder shares one client for counting and generation, with transport retries disabled. */
13
+ export function anthropicCaptureBackend(client = new Anthropic({ maxRetries: 0 }), onProviderError) {
14
+ return { countInputTokens: (call) => anthropicInputTokens({}, client, call),
15
+ createModel: (onUsage) => ({
16
+ structured: call => structuredCall({}, client, call, onUsage, false, onProviderError),
17
+ }) };
18
+ }
19
+ function canceled() {
20
+ return new JobError("canceled", "The model call was canceled.");
21
+ }
22
+ export class ProviderCallError extends JobError {
23
+ payment;
24
+ constructor(code, message, retryable, payment) {
25
+ super(code, message, { retryable });
26
+ this.payment = payment;
27
+ }
28
+ }
29
+ /** Provider messages can quote private inputs, so retain only safe classifications. */
30
+ function providerFailure(error, signal) {
31
+ if (signal?.aborted || error instanceof Anthropic.APIUserAbortError)
32
+ return canceled();
33
+ if (error instanceof Anthropic.RateLimitError) {
34
+ return new ProviderCallError("rate_limited", "The model provider is busy. Wait a minute and try again.", true, "unpaid");
35
+ }
36
+ if (error instanceof Anthropic.APIConnectionError) {
37
+ return new ProviderCallError("llm", "The model could not be reached. Try again.", true, "unknown");
38
+ }
39
+ const directive = error instanceof Anthropic.APIError ? error.headers?.get("x-should-retry") : undefined;
40
+ const status = error instanceof Anthropic.APIError ? error.status : undefined;
41
+ const retryable = directive === "true" || directive !== "false" && (status === 408 || status === 409 || status !== undefined && status >= 500);
42
+ const payment = status !== undefined && [400, 401, 403, 404, 413, 422].includes(status) ? "unpaid" : "unknown";
43
+ return new ProviderCallError("llm", "The model call failed. Try again.", retryable, payment);
44
+ }
45
+ function reportUsage(onUsage, call, usage, requestStartedAt, estimated = false) {
46
+ if (!onUsage || !usage)
47
+ return;
48
+ try {
49
+ onUsage({
50
+ route: call.route, model: call.model,
51
+ inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0,
52
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0, cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
53
+ ...(usage.cache_creation ? {
54
+ cacheWrite5mTokens: usage.cache_creation.ephemeral_5m_input_tokens,
55
+ cacheWrite1hTokens: usage.cache_creation.ephemeral_1h_input_tokens,
56
+ } : {}),
57
+ requestStartedAt, ...(estimated ? { estimated: true } : {}),
58
+ });
59
+ }
60
+ catch {
61
+ // Losing a usage count must not discard a completed answer. Never log sink errors.
62
+ }
63
+ }
64
+ function providerRequest(settings, call) {
65
+ const format = zodOutputFormat(call.schema);
66
+ const streamed = supportsReasoningControls(call.model);
67
+ const params = {
68
+ model: call.model, max_tokens: anthropicStructuredLimit(call),
69
+ ...(streamed ? { thinking: { type: call.thinking ?? "adaptive" } } : {}),
70
+ ...(call.system ? { system: call.system } : {}),
71
+ messages: [{ role: "user", content: structuredPromptOf(settings, call) }],
72
+ output_config: {
73
+ // SDK stream parsing can throw before returning usage for incomplete JSON.
74
+ format: { type: format.type, schema: format.schema },
75
+ ...(call.effort && streamed && call.thinking !== "disabled" ? { effort: call.effort } : {}),
76
+ },
77
+ };
78
+ return { params, format, streamed };
79
+ }
80
+ /** Include the provider-transformed schema and cache blocks in reservation byte admission. */
81
+ export function anthropicInputBytes(settings, call) {
82
+ const { params } = providerRequest(settings, call);
83
+ return Buffer.byteLength(JSON.stringify({ system: params.system, messages: params.messages,
84
+ thinking: params.thinking, output_config: params.output_config }));
85
+ }
86
+ /** Capture admission counts the same SDK-transformed schema and prompt that generation sends. */
87
+ export async function anthropicInputTokens(settings, client, call) {
88
+ const { params } = providerRequest(settings, call);
89
+ const usage = await client.messages.countTokens({ model: params.model, system: params.system, messages: params.messages,
90
+ thinking: params.thinking, output_config: params.output_config });
91
+ if (!Number.isSafeInteger(usage.input_tokens) || usage.input_tokens < 0)
92
+ throw new JobError("llm", "The model input could not be counted.");
93
+ return usage.input_tokens;
94
+ }
95
+ async function structuredCall(settings, client, call, onUsage, sourceRetries = false, onProviderError) {
96
+ const { params, format, streamed } = providerRequest(settings, call);
97
+ if (call.signal?.aborted)
98
+ throw canceled();
99
+ const requestStartedAt = new Date();
100
+ let usageReported = false;
101
+ const estimateOnAbort = () => {
102
+ if (usageReported)
103
+ return;
104
+ usageReported = true;
105
+ const inputTokens = estimateTokensFromBytes(Buffer.byteLength(JSON.stringify({ system: params.system,
106
+ messages: params.messages, output_config: { format: params.output_config.format } })));
107
+ const cached = requestCachesInput(settings, call);
108
+ reportUsage(onUsage, call, { input_tokens: cached ? 0 : inputTokens, output_tokens: params.max_tokens,
109
+ cache_creation_input_tokens: cached ? inputTokens : 0 }, requestStartedAt, true);
110
+ };
111
+ // Deadlines may settle before the SDK rejects; report cancellation synchronously.
112
+ call.signal?.addEventListener("abort", estimateOnAbort, { once: true });
113
+ let message;
114
+ try {
115
+ // Relay retries need fresh authorization. Only the source-checkout factory preserves its earlier SDK policy.
116
+ const options = { ...(!sourceRetries ? { maxRetries: 0 } : {}), ...(call.signal ? { signal: call.signal } : {}) };
117
+ message = streamed
118
+ ? await client.messages.stream(params, options).finalMessage()
119
+ : await client.messages.create(params, options);
120
+ if (!usageReported) {
121
+ usageReported = true;
122
+ reportUsage(onUsage, call, message.usage, requestStartedAt);
123
+ }
124
+ }
125
+ catch (error) {
126
+ // Only explicit capture tools retain provider diagnostics for fictional inputs.
127
+ try {
128
+ onProviderError?.(error);
129
+ }
130
+ catch { /* A diagnostic sink cannot change settlement. */ }
131
+ throw providerFailure(error, call.signal);
132
+ }
133
+ finally {
134
+ call.signal?.removeEventListener("abort", estimateOnAbort);
135
+ }
136
+ if (message.stop_reason === "refusal")
137
+ throw new JobError("llm", "The model declined this request. Try again.");
138
+ const text = message.content.find(block => block.type === "text");
139
+ if (text) {
140
+ try {
141
+ return format.parse(text.text);
142
+ }
143
+ catch (error) {
144
+ if (!(error instanceof Anthropic.AnthropicError))
145
+ throw error;
146
+ }
147
+ }
148
+ throw new StructuredOutputError(message.stop_reason === "max_tokens"
149
+ ? "The model ran out of room for this step's answer. Try again."
150
+ : "The model returned nothing usable for this step. Try again.");
151
+ }
@@ -0,0 +1,112 @@
1
+ import { constants } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { z } from "zod";
7
+ import { LOCAL_FINDINGS_BODY_LIMIT, localFindingsPayloadSchema } from "../contract/console.js";
8
+ import { OutputPathError, outputPaths, writeOutputFiles } from "./output-paths.js";
9
+ import { standaloneGitFailure } from "./repository-root.js";
10
+ import { runGit } from "./git.js";
11
+ import { resolveFolderPath } from "./folder-path.js";
12
+ import { matchesSnapshotProvenance } from "./snapshot.js";
13
+ const record = z.strictObject({ version: z.literal(1), consoleOrigin: z.url().optional(), uploaded: z.boolean().optional(),
14
+ finished: z.boolean().optional(), payload: localFindingsPayloadSchema });
15
+ const maxBytes = LOCAL_FINDINGS_BODY_LIMIT + 1024;
16
+ /** Recovery belongs to this checkout and origin, and survives edits and new commits. */
17
+ export async function completionPath(folder) {
18
+ const root = await resolveFolderPath(folder);
19
+ let repository = null;
20
+ let common;
21
+ try {
22
+ common = (await runGit(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).toString("utf8").trim();
23
+ }
24
+ // Standalone folders need no Git identity; a usable checkout must never silently lose its identity.
25
+ catch (error) {
26
+ if (!await standaloneGitFailure(root, error))
27
+ throw error;
28
+ }
29
+ if (common !== undefined) {
30
+ let origin = "";
31
+ try {
32
+ origin = (await runGit(root, ["config", "--get", "remote.origin.url"])).toString("utf8").trim();
33
+ }
34
+ catch (error) {
35
+ if (error.code !== 1)
36
+ throw error;
37
+ }
38
+ repository = [await realpath(common), origin];
39
+ }
40
+ const key = createHash("sha256").update(JSON.stringify([root, repository])).digest("hex");
41
+ const home = await realpath(os.homedir());
42
+ return { home, path: join(home, ".config/counterfact-labs/completions", `${key}.json`) };
43
+ }
44
+ export async function saveCompletion(folder, payload, consoleOrigin, uploaded = false, finished = false) {
45
+ const value = record.parse({ version: 1, payload, consoleOrigin, uploaded, finished });
46
+ const bytes = JSON.stringify(value);
47
+ if (Buffer.byteLength(bytes) > maxBytes)
48
+ throw new Error("Invalid completion record");
49
+ const { home, path } = await completionPath(folder);
50
+ await writeOutputFiles(home, { [basename(path)]: bytes }, dirname(path));
51
+ return path;
52
+ }
53
+ const INVALID_COMPLETION_MESSAGE = "The saved completion record is invalid. Keep the local report and choose a new output directory.";
54
+ async function readCompletion(path) {
55
+ let file;
56
+ try {
57
+ file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
58
+ const info = await file.stat();
59
+ if (!info.isFile() || info.size > maxBytes)
60
+ throw new Error();
61
+ const bytes = Buffer.alloc(maxBytes + 1);
62
+ const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
63
+ if (bytesRead > maxBytes)
64
+ throw new Error();
65
+ return record.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, bytesRead))));
66
+ }
67
+ finally {
68
+ await file?.close();
69
+ }
70
+ }
71
+ export async function loadCompletion(folder) {
72
+ try {
73
+ const { home, path } = await completionPath(folder);
74
+ await outputPaths(home, [basename(path)], dirname(path));
75
+ return await readCompletion(path);
76
+ }
77
+ catch (error) {
78
+ if (error instanceof OutputPathError)
79
+ throw error;
80
+ if (error.code !== "ENOENT") {
81
+ throw new Error(INVALID_COMPLETION_MESSAGE);
82
+ }
83
+ }
84
+ // Repository-owned records cannot supply recovery data, even with matching provenance.
85
+ let legacy;
86
+ try {
87
+ await lstat(join(folder, ".counterfact/completion.json"));
88
+ try {
89
+ await runGit(folder, ["ls-files", "--error-unmatch", "--with-tree=HEAD", "--", ".counterfact/completion.json"]);
90
+ return null;
91
+ }
92
+ catch (error) {
93
+ // Without Git there is no provenance to verify, and uncertain legacy data must not block analysis.
94
+ if (await standaloneGitFailure(folder, error))
95
+ return null;
96
+ if (error.code !== 1)
97
+ throw error;
98
+ }
99
+ const [path] = await outputPaths(folder, ["completion.json"]);
100
+ legacy = await readCompletion(path);
101
+ }
102
+ catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return null;
105
+ throw new Error(INVALID_COMPLETION_MESSAGE);
106
+ }
107
+ if (!await matchesSnapshotProvenance(folder, legacy.payload.agent))
108
+ throw new Error(INVALID_COMPLETION_MESSAGE);
109
+ // Attempt imported recovery once automatically; uncertain legacy data must not block later analyses.
110
+ await saveCompletion(folder, legacy.payload, legacy.consoleOrigin, legacy.uploaded, true);
111
+ return legacy;
112
+ }
@@ -0,0 +1,34 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open, rename, rm } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ export async function durableWrite(path, bytes) {
6
+ const temporary = `${path}.${randomUUID()}.tmp`;
7
+ const permission = process.permission !== undefined;
8
+ // The permission model disables fsync, so sync data on each write and skip the unavailable directory sync.
9
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | (permission ? constants.O_DSYNC : 0);
10
+ try {
11
+ const file = await open(temporary, flags, 0o600);
12
+ try {
13
+ await file.writeFile(bytes);
14
+ if (!permission)
15
+ await file.sync();
16
+ }
17
+ finally {
18
+ await file.close();
19
+ }
20
+ await rename(temporary, path);
21
+ if (!permission) {
22
+ const parent = await open(dirname(path), "r");
23
+ try {
24
+ await parent.sync();
25
+ }
26
+ finally {
27
+ await parent.close();
28
+ }
29
+ }
30
+ }
31
+ finally {
32
+ await rm(temporary, { force: true });
33
+ }
34
+ }
@@ -0,0 +1,144 @@
1
+ import { opendir, realpath, stat, readFile } from "node:fs/promises";
2
+ import { dirname, join, posix } from "node:path";
3
+ import { admitFile, fileRefusalWarning, localSymlinkGuardEnabled, respectGitignore, readRepoFile, resolveLocalExclusions } from "../analysis-core/conventions/file-admission.js";
4
+ import { assessAnalysisSize, measureAnalysisSize } from "../analysis-core/conventions/analysis-fit.js";
5
+ import { localRepoKeyFor } from "../analysis-core/github/repo-key.js";
6
+ import { fetchLocalSources } from "../analysis-core/index.js";
7
+ import { repoPathFrom } from "../analysis-core/import/outside-loader-sources.js";
8
+ import { mergeSkippedFiles, mergeUnlistedFileWarnings } from "../analysis-core/analysis/code-agents/skipped-files.js";
9
+ import { createGitIgnore } from "./git-ignore.js";
10
+ import { createIgnoredFileCollector } from "./ignored-files.js";
11
+ import { resolveFolderPath } from "./folder-path.js";
12
+ import { createRepositoryFiles } from "./repository-files.js";
13
+ export const FOLDER_ENTRY_LIMIT = 10_000;
14
+ export const FOLDER_ENTRY_LIMIT_MESSAGE = "The folder has too many entries to analyze. Choose a smaller folder.";
15
+ export async function readLocalFolder(settings, folder, admission = {}, options = {}) {
16
+ const root = await resolveFolderPath(folder);
17
+ if (!(await stat(root)).isDirectory())
18
+ throw new Error("Select a folder to analyze.");
19
+ const entries = [];
20
+ const warnings = [];
21
+ const refused = [];
22
+ const gitSkipped = [];
23
+ const ignoredPaths = createGitIgnore(settings, root);
24
+ const ignoredFiles = createIgnoredFileCollector(FOLDER_ENTRY_LIMIT);
25
+ const guarded = localSymlinkGuardEnabled(settings) || !!admission.exclusions?.length || respectGitignore(settings);
26
+ const excludedTargets = await resolveLocalExclusions(admission, async (path) => {
27
+ try {
28
+ return await realpath(join(root, path));
29
+ }
30
+ catch (error) {
31
+ if (["ENOENT", "ELOOP"].includes(error.code ?? ""))
32
+ return null;
33
+ throw error;
34
+ }
35
+ });
36
+ const targets = new Map();
37
+ const snapshot = {};
38
+ let visited = 0;
39
+ const targetOf = async (path, purpose, directory = false) => {
40
+ const target = await realpath(join(root, path));
41
+ const result = admitFile(settings, { path, directory }, { purpose, route: "local-folder", ...admission, localTarget: { root, path: target, excludedTargets } });
42
+ if (!result.admitted) {
43
+ refused.push({ path, reason: result.reason });
44
+ if (result.reason === "secret" || result.reason === "out-of-scope")
45
+ warnings.push({ path, reason: result.reason });
46
+ return null;
47
+ }
48
+ const gitIgnored = await ignoredPaths([target]);
49
+ const parent = gitIgnored === undefined ? undefined : await realpath(dirname(join(root, path)));
50
+ const allowed = admitFile(settings, { path, directory }, { purpose, route: "local-folder", ...admission,
51
+ gitIgnored, localTarget: { root, path: target, parent, excludedTargets } });
52
+ if (!allowed.admitted) {
53
+ if (directory)
54
+ gitSkipped.push(...await ignoredFiles(join(root, path), path));
55
+ else
56
+ gitSkipped.push({ path, reason: allowed.reason });
57
+ return null;
58
+ }
59
+ return target;
60
+ };
61
+ async function walk(dir, prefix, ancestors) {
62
+ const target = await realpath(dir);
63
+ if (ancestors.has(target))
64
+ return;
65
+ const parents = new Set([...ancestors, target]);
66
+ // Stream and count even excluded entries, so a huge directory cannot exhaust memory before the cap.
67
+ const items = [];
68
+ for await (const entry of await opendir(target)) {
69
+ if (++visited > FOLDER_ENTRY_LIMIT)
70
+ throw new Error(FOLDER_ENTRY_LIMIT_MESSAGE);
71
+ items.push(entry);
72
+ }
73
+ const names = items.map(item => item.name);
74
+ const facts = await ignoredPaths(names.map(name => join(target, name)));
75
+ const gitIgnored = facts && new Set(names.filter(name => facts.has(join(target, name))).map(name => prefix ? `${prefix}/${name}` : name));
76
+ for (const item of items.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
77
+ const name = item.name;
78
+ const path = prefix ? `${prefix}/${name}` : name;
79
+ // A link's filename-only refusal waits until its target type is known.
80
+ const listed = { path, directory: item.isDirectory() || item.isSymbolicLink() };
81
+ if (!admitFile(settings, listed, { purpose: "listing", route: "local-folder", ...admission }).admitted)
82
+ continue;
83
+ const allowed = admitFile(settings, listed, { purpose: "listing", route: "local-folder", ...admission, gitIgnored });
84
+ if (!allowed.admitted) {
85
+ if (item.isDirectory())
86
+ gitSkipped.push(...await ignoredFiles(join(dir, name), path));
87
+ else if (item.isFile() || item.isSymbolicLink())
88
+ gitSkipped.push({ path, reason: allowed.reason });
89
+ continue;
90
+ }
91
+ let file;
92
+ let info;
93
+ try {
94
+ info = await stat(join(dir, name));
95
+ const directory = info.isDirectory();
96
+ if (!admitFile(settings, { path, directory }, { purpose: "listing", route: "local-folder", ...admission }).admitted)
97
+ continue;
98
+ file = guarded ? await targetOf(path, "listing", directory) : join(root, path);
99
+ }
100
+ catch (error) {
101
+ if (["ENOENT", "ELOOP"].includes(error.code ?? ""))
102
+ continue;
103
+ throw error;
104
+ }
105
+ if (!file)
106
+ continue;
107
+ if (info.isDirectory())
108
+ await walk(file, path, parents);
109
+ else if (info.isFile()) {
110
+ entries.push({ path, size: info.size, ref: path });
111
+ targets.set(path, file);
112
+ }
113
+ }
114
+ }
115
+ const selectedRoot = admitFile(settings, { path: ".", directory: true }, { purpose: "override", route: "local-folder",
116
+ gitIgnored: await ignoredPaths(["."]), localTarget: { root, path: root } });
117
+ if (selectedRoot.admitted)
118
+ await walk(root, "", new Set());
119
+ else
120
+ gitSkipped.push(...await ignoredFiles(root, ""));
121
+ const analysisSize = measureAnalysisSize(settings, entries, admission);
122
+ // The service makes the final decision; local prediction never changes retained files.
123
+ const analysisFit = settings.CF_REFUSE_TOO_LARGE === "0" ? undefined : assessAnalysisSize(analysisSize);
124
+ const listing = new Set(entries.map(entry => entry.path));
125
+ const repoKey = localRepoKeyFor(root);
126
+ const repository = createRepositoryFiles(settings, root, admission, options);
127
+ const discovered = await fetchLocalSources(settings, { repoKey, entries, admission, repository, read: async (entry) => {
128
+ const target = guarded ? await targetOf(entry.path, "override") : targets.get(entry.path);
129
+ return target ? readRepoFile(settings, entry, async () => {
130
+ if (!options.captureSnapshot)
131
+ return readFile(target, "utf8");
132
+ const bytes = snapshot[entry.path] ?? await readFile(target);
133
+ snapshot[entry.path] = bytes;
134
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
135
+ }, { ...admission, listing, route: "local-folder", localTarget: { root, path: target, excludedTargets } }) : null;
136
+ } });
137
+ const expanded = discovered.root !== undefined;
138
+ const ignored = mergeSkippedFiles(expanded ? gitSkipped.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : gitSkipped, repository.skippedFiles.map(file => ({ ...file, path: expanded ? file.path : posix.relative(repository.selectedRoot ?? "", file.path) })));
139
+ const captured = { ...Object.fromEntries(Object.entries(snapshot).map(([path, bytes]) => [repoPathFrom(path, discovered.root), bytes])), ...repository.snapshot };
140
+ return { repoKey, files: discovered.files, analysisSize, ...(analysisFit ? { analysisFit } : {}), ...(expanded ? { root: discovered.root, sourceBase: repository.sourceBase } : {}),
141
+ ...(options.captureSnapshot ? { snapshot: Object.fromEntries(Object.keys(discovered.files).map(path => [path, captured[path]])) } : {}), fragment: discovered.fragment, selection: discovered.selection,
142
+ skippedFiles: mergeSkippedFiles(discovered.skippedFiles, ignored, expanded ? refused.map(file => ({ ...file, path: repoPathFrom(file.path, discovered.root) })) : refused),
143
+ warnings: [...new Set(mergeUnlistedFileWarnings([...warnings.map(file => fileRefusalWarning(repoPathFrom(file.path, discovered.root), file.reason)), ...discovered.warnings], ignored.length))] };
144
+ }
@@ -0,0 +1,6 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ /** Folder consumers share the same canonical coordinate for reads and output. */
4
+ export async function resolveFolderPath(folder) {
5
+ return realpath(resolve(folder));
6
+ }
@@ -0,0 +1,49 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { mapLimit } from "../analysis-core/map-limit.js";
3
+ import { respectGitignore } from "../analysis-core/conventions/file-admission.js";
4
+ import { runGit } from "./git.js";
5
+ import { gitRepositoryRoot, searchBaseMatches, searchBoundary } from "./repository-root.js";
6
+ /** Query path facts only; admission owns whether an ignored path can be read. */
7
+ export function createGitIgnore(settings, folder) {
8
+ const cache = new Map();
9
+ let root;
10
+ return async (paths) => {
11
+ if (!respectGitignore(settings))
12
+ return undefined;
13
+ if (!paths.length)
14
+ return new Set();
15
+ const base = await (root ??= (async () => {
16
+ const [boundary, reported] = await Promise.all([searchBoundary(folder),
17
+ gitRepositoryRoot(async (args) => (await runGit(folder, args)).toString("utf8"))]);
18
+ return boundary && await searchBaseMatches(boundary.base, reported) ? boundary.base : null;
19
+ })());
20
+ if (!base)
21
+ return undefined;
22
+ const candidates = paths.filter(path => {
23
+ const rel = relative(base, resolve(folder, path));
24
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
25
+ });
26
+ const pending = [...new Set(candidates.map(path => resolve(folder, path)))].filter(path => !cache.has(path));
27
+ // Bound each process's input and output without losing unusual filenames to line quoting.
28
+ const batches = Array.from({ length: Math.ceil(pending.length / 100) }, (_, index) => pending.slice(index * 100, (index + 1) * 100));
29
+ await mapLimit(batches, 4, async (batch) => {
30
+ const input = Buffer.from(batch.map(path => resolve(folder, path)).join("\0") + "\0");
31
+ let output;
32
+ try {
33
+ output = await runGit(folder, ["check-ignore", "-z", "--stdin"], { userConfig: true, input, maxBuffer: input.length + 1024 });
34
+ }
35
+ catch (error) {
36
+ if (error.code === 1) {
37
+ for (const path of batch)
38
+ cache.set(path, false);
39
+ return;
40
+ }
41
+ throw error;
42
+ }
43
+ const matches = new Set(output.toString("utf8").split("\0"));
44
+ for (const path of batch)
45
+ cache.set(path, matches.has(path));
46
+ });
47
+ return new Set(candidates.filter(path => cache.get(resolve(folder, path))));
48
+ };
49
+ }
@@ -0,0 +1,44 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ /** All repository subprocesses use the same local-only, non-executable Git policy. */
4
+ export async function runGit(folder, args, options = {}) {
5
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
6
+ const execute = async (command, overrides = [], maxBuffer = options.maxBuffer ?? 16_384, input) => {
7
+ const running = promisify(execFile)("git", ["--no-pager", "--no-optional-locks",
8
+ "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "core.sshCommand=",
9
+ "-c", "protocol.ext.allow=never", "-c", "diff.external=", "-c", "core.attributesFile=/dev/null",
10
+ ...overrides, "-C", folder, ...command], {
11
+ encoding: "buffer", timeout: options.timeout ?? 3000, maxBuffer,
12
+ env: { ...env, GIT_ATTR_NOSYSTEM: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1",
13
+ GIT_CONFIG_GLOBAL: options.userConfig ? process.env.GIT_CONFIG_GLOBAL : "/dev/null", GIT_PAGER: "cat" },
14
+ });
15
+ if (input)
16
+ running.child.stdin?.end(input);
17
+ return (await running).stdout;
18
+ };
19
+ // Reading configuration runs no filters. Disable every filter and diff driver before any command touches working files.
20
+ let configured;
21
+ try {
22
+ configured = await execute(["config", "--null", "--name-only", "--get-regexp", "^(filter|diff)\\."], [], 1024 * 1024);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== 1)
26
+ throw error;
27
+ configured = Buffer.alloc(0);
28
+ }
29
+ const filters = new Set(), diffs = new Set();
30
+ for (const key of configured.toString("utf8").split("\0")) {
31
+ const match = /^(filter|diff)\.([\s\S]+)\.[^.]+$/i.exec(key);
32
+ if (match)
33
+ (match[1].toLowerCase() === "filter" ? filters : diffs).add(match[2]);
34
+ }
35
+ const overrides = [
36
+ ...[...filters].flatMap(name => ["clean=", "smudge=", "process=", "required=false"].flatMap(value => ["-c", `filter.${name}.${value}`])),
37
+ ...[...diffs].flatMap(name => ["command=", "textconv="].flatMap(value => ["-c", `diff.${name}.${value}`])),
38
+ ];
39
+ // These commands can consult named diff drivers even when diff.external is empty.
40
+ const diffCommand = ["diff", "diff-files", "diff-index", "diff-tree", "show", "log"].includes(args[0]);
41
+ const boundary = args.indexOf("--"), endOptions = boundary === -1 ? args.length : boundary;
42
+ const command = diffCommand ? [...args.slice(0, endOptions), "--no-ext-diff", "--no-textconv", ...args.slice(endOptions)] : args;
43
+ return execute(command, overrides, options.maxBuffer, options.input);
44
+ }
@@ -0,0 +1,36 @@
1
+ import { lstat, opendir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /** Count metadata only, sharing a walk-sized budget across all ignored trees. */
4
+ export function createIgnoredFileCollector(limit) {
5
+ let remaining = limit;
6
+ return async (folder, prefix) => {
7
+ const files = [];
8
+ const pending = [{ folder, prefix }];
9
+ while (pending.length && remaining > 0) {
10
+ const directory = pending.pop();
11
+ let handle;
12
+ try {
13
+ if ((await lstat(directory.folder)).isSymbolicLink()) {
14
+ remaining--;
15
+ files.push({ path: directory.prefix, reason: "excluded" });
16
+ continue;
17
+ }
18
+ handle = await opendir(directory.folder);
19
+ }
20
+ catch {
21
+ continue;
22
+ }
23
+ for await (const entry of handle) {
24
+ if (remaining-- <= 0)
25
+ return files;
26
+ const path = directory.prefix ? `${directory.prefix}/${entry.name}` : entry.name;
27
+ if (entry.isDirectory())
28
+ pending.push({ folder: join(directory.folder, entry.name), prefix: path });
29
+ // Git stores a link as a file; following it could leave the ignored tree or loop.
30
+ else if (entry.isFile() || entry.isSymbolicLink())
31
+ files.push({ path, reason: "excluded" });
32
+ }
33
+ }
34
+ return files;
35
+ };
36
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { resolve, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createInstructionText } from "../analysis-core/requests/instruction-text.js";
5
+ import { createRequestTools } from "../analysis-core/requests/tools.js";
6
+ import { createTemplateVersion } from "../analysis-core/requests/template-version.js";
7
+ import { sealedBundle } from "../contract/messages.js";
8
+ import { verifyRelease } from "../bundle/seal/signature.js";
9
+ export { loadPublicRequestTools } from "./public-instructions.js";
10
+ export { openBundle as loadEncryptedInstructions } from "../bundle/seal/open.js";
11
+ export function loadPublicInstructionVersions(value, verificationKey) {
12
+ const bundle = sealedBundle.parse(value);
13
+ verifyRelease(bundle, verificationKey);
14
+ return createTemplateVersion(bundle.metadata);
15
+ }
16
+ export function loadReadableInstructions(folder) {
17
+ const root = resolve(folder ?? fileURLToPath(new URL("../packages/core/instructions/", import.meta.url)));
18
+ const templatesPath = join(root, "templates");
19
+ if (!existsSync(templatesPath))
20
+ throw new Error("This package needs the sealed relay for analysis. Use a source checkout for local analysis.");
21
+ const templates = Object.freeze(Object.fromEntries(readdirSync(templatesPath).filter(name => name.endsWith(".md")).map(name => [
22
+ name.slice(0, -3), readFileSync(join(templatesPath, name), "utf8"),
23
+ ])));
24
+ const descriptions = Object.freeze(JSON.parse(readFileSync(join(root, "descriptions.json"), "utf8")));
25
+ return createInstructionText({ templates, descriptions });
26
+ }
27
+ let readable;
28
+ let tools;
29
+ // Loading is lazy so a relay can open encrypted instructions without readable assets.
30
+ export function readableInstructions() {
31
+ return readable ??= loadReadableInstructions();
32
+ }
33
+ export function readableRequestTools() {
34
+ return tools ??= createRequestTools(readableInstructions());
35
+ }