jev-agent-tools 0.1.4 → 0.3.0

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 (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
@@ -23,7 +23,7 @@ export async function collectAskFiles(
23
23
  paths: readonly string[],
24
24
  signal?: AbortSignal,
25
25
  exec?: GitExec,
26
- ): Promise<Result<{ files: AskFile[]; skipped: string[] }>> {
26
+ ): Promise<Result<{ files: AskFile[]; skipped: string[]; secret: string[] }>> {
27
27
  if (!paths.length)
28
28
  return {
29
29
  ok: false,
@@ -42,6 +42,7 @@ export async function collectAskFiles(
42
42
  };
43
43
  }
44
44
  const skipped = new Set<string>();
45
+ const secret = new Set<string>();
45
46
  const candidates = new Set<string>();
46
47
  try {
47
48
  let inventory: Set<string> | undefined;
@@ -63,7 +64,9 @@ export async function collectAskFiles(
63
64
  inventory,
64
65
  );
65
66
  if (!admission.ok) {
66
- skipped.add(`${path} (${admission.error})`);
67
+ const entry = `${path} (${admission.error})`;
68
+ skipped.add(entry);
69
+ if (admission.cause === "secret_pattern") secret.add(entry);
67
70
  return;
68
71
  }
69
72
  if (path.split("/").some((part) => excludedDirectories.has(part)))
@@ -229,7 +232,12 @@ export async function collectAskFiles(
229
232
  };
230
233
  }
231
234
  }
232
- return { ok: true, files, skipped: [...skipped].sort() };
235
+ return {
236
+ ok: true,
237
+ files,
238
+ skipped: [...skipped].sort(),
239
+ secret: [...secret].sort(),
240
+ };
233
241
  } catch (error) {
234
242
  return { ok: false, error: `Cannot expand paths: ${String(error)}` };
235
243
  }
@@ -1,14 +1,15 @@
1
- import { relative, resolve } from "node:path";
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
2
  import { STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.ts";
3
3
  import type { GitExec } from "../core/git.ts";
4
4
  import type { ImportSource } from "../core/imports.ts";
5
5
  import type { Result } from "../result.ts";
6
- import { collectFiles } from "./files.ts";
6
+ import { checkFileAdmission, collectFiles } from "./files.ts";
7
7
  import { verifyGitUtf8 } from "./utf8.ts";
8
8
 
9
9
  export interface AskRepository {
10
10
  root: string;
11
11
  known: ReadonlySet<string>;
12
+ beforeKnown: ReadonlySet<string>;
12
13
  baseSha?: string;
13
14
  read(path: string): Promise<Result<ImportSource>>;
14
15
  readBefore(path: string): Promise<Result<{ text: string | null }>>;
@@ -69,18 +70,20 @@ export async function collectAskRepository(
69
70
  for (const path of ignored.stdout.split("\0")) known.delete(path);
70
71
  const baseSha = revision?.stdout.trim();
71
72
  const basePaths = baseSha
72
- ? await exec(
73
- "git",
74
- ["ls-tree", "-r", "--name-only", "-z", baseSha],
75
- gitOptions,
76
- )
73
+ ? await exec("git", ["ls-tree", "-r", "-z", baseSha], gitOptions)
77
74
  : undefined;
78
75
  if (basePaths && (basePaths.code !== 0 || basePaths.killed))
79
76
  return {
80
77
  ok: false,
81
78
  error: basePaths.stderr || "Cannot list base paths.",
82
79
  };
83
- const beforeKnown = new Set(basePaths?.stdout.split("\0").filter(Boolean));
80
+ const beforeEntries = new Map(
81
+ (basePaths?.stdout.split("\0").filter(Boolean) ?? []).map((entry) => [
82
+ entry.slice(entry.indexOf("\t") + 1),
83
+ entry,
84
+ ]),
85
+ );
86
+ const beforeKnown = new Set(beforeEntries.keys());
84
87
  const reads = new Map<string, Promise<Result<ImportSource>>>();
85
88
  const beforeReads = new Map<
86
89
  string,
@@ -90,6 +93,7 @@ export async function collectAskRepository(
90
93
  ok: true,
91
94
  root,
92
95
  known,
96
+ beforeKnown,
93
97
  baseSha,
94
98
  read(path) {
95
99
  let pending = reads.get(path);
@@ -117,9 +121,53 @@ export async function collectAskRepository(
117
121
  let pending = beforeReads.get(path);
118
122
  if (!pending) {
119
123
  pending = (async () => {
120
- if (!baseSha || !beforeKnown.has(path))
121
- return { ok: true as const, text: null };
122
124
  try {
125
+ if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
126
+ return {
127
+ ok: false as const,
128
+ cause: "forbidden_path" as const,
129
+ error: `Path must remain inside the repository: ${path}.`,
130
+ };
131
+ const admission = await checkFileAdmission(root, path);
132
+ if (!admission.ok) return admission;
133
+ const ignoredPath = await exec(
134
+ "git",
135
+ ["check-ignore", "--no-index", "--", path],
136
+ gitOptions,
137
+ );
138
+ if (ignoredPath.killed || ![0, 1].includes(ignoredPath.code))
139
+ return {
140
+ ok: false as const,
141
+ cause: "git_failure" as const,
142
+ error: `${path}: historical admission unavailable`,
143
+ };
144
+ if (ignoredPath.code === 0)
145
+ return {
146
+ ok: false as const,
147
+ cause: "ignored_path" as const,
148
+ error: `${path}: gitignored: not sent to Jev`,
149
+ };
150
+ if (!baseSha)
151
+ return {
152
+ ok: false as const,
153
+ error: `${path}: comparison base unavailable`,
154
+ };
155
+ if (!beforeKnown.has(path))
156
+ return known.has(path)
157
+ ? { ok: true as const, text: null }
158
+ : {
159
+ ok: false as const,
160
+ error: `${path}: not in current or comparison base inventory`,
161
+ };
162
+ const entry = beforeEntries.get(path) ?? "";
163
+ if (!/^(?:100644|100755) blob /.test(entry))
164
+ return {
165
+ ok: false as const,
166
+ cause: entry.startsWith("120000 ")
167
+ ? ("symlink" as const)
168
+ : ("file_unavailable" as const),
169
+ error: `${path}: not a regular file at comparison base: not sent to Jev`,
170
+ };
123
171
  const object = await exec(
124
172
  "git",
125
173
  [
@@ -172,10 +220,20 @@ export async function collectAskRepository(
172
220
  ok: false as const,
173
221
  error: `before evidence unavailable for ${path}`,
174
222
  };
223
+ if (loaded.stdout.includes("\0"))
224
+ return {
225
+ ok: false as const,
226
+ cause: "binary_or_non_utf8" as const,
227
+ error: `${path}: binary content: not sent to Jev`,
228
+ };
175
229
  const decoded = verifyGitUtf8(loaded.stdout, id, size);
176
230
  return decoded.ok
177
231
  ? decoded
178
- : { ok: false as const, error: `${path}: ${decoded.error}` };
232
+ : {
233
+ ok: false as const,
234
+ cause: "binary_or_non_utf8" as const,
235
+ error: `${path}: ${decoded.error}`,
236
+ };
179
237
  } catch (error) {
180
238
  return { ok: false as const, error: String(error) };
181
239
  }
@@ -0,0 +1,18 @@
1
+ import { realpath } from "node:fs";
2
+ import { promisify } from "node:util";
3
+
4
+ const nativeRealpath = promisify(realpath.native);
5
+
6
+ /**
7
+ * Canonical form of an existing path: resolves symlinks and, on Windows,
8
+ * expands 8.3 short names (C:\Users\NAME~1) so the path relates correctly to
9
+ * `git rev-parse --show-toplevel`, which always reports the long form. Falls
10
+ * back to the input when the path cannot be resolved.
11
+ */
12
+ export async function canonicalPath(path: string): Promise<string> {
13
+ try {
14
+ return await nativeRealpath(path);
15
+ } catch {
16
+ return path;
17
+ }
18
+ }
@@ -21,6 +21,7 @@ import {
21
21
  import type { GitExec } from "../core/git.ts";
22
22
  import type { Result } from "../result.ts";
23
23
  import { outputLines } from "./output-lines.ts";
24
+ import { resolveShell } from "./shell.ts";
24
25
 
25
26
  export interface CommandOutput {
26
27
  command: string;
@@ -31,12 +32,47 @@ export interface CommandOutput {
31
32
  compressed: boolean;
32
33
  truncated: boolean;
33
34
  }
35
+ const TRUNCATION_MARK = `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`;
36
+ /** Shortest key prefix worth redacting when a line cut leaves only its start. */
37
+ const SECRET_PREFIX_MIN = 4;
38
+ /**
39
+ * Replace every occurrence of `secret` in `text`, counting replacements. A
40
+ * line cut at OUTPUT_LINE_MAX_CHARS can end inside the key; that trailing key
41
+ * prefix is redacted too.
42
+ */
43
+ export function redactSecret(
44
+ text: string,
45
+ secret: string | undefined,
46
+ ): { text: string; count: number } {
47
+ if (!secret) return { text, count: 0 };
48
+ const parts = text.split(secret);
49
+ let result = parts.join("[redacted]");
50
+ let count = parts.length - 1;
51
+ if (result.endsWith(TRUNCATION_MARK)) {
52
+ const kept = result.slice(0, -TRUNCATION_MARK.length);
53
+ const min = Math.min(SECRET_PREFIX_MIN, secret.length);
54
+ for (
55
+ let length = Math.min(secret.length - 1, kept.length);
56
+ length >= min;
57
+ length--
58
+ ) {
59
+ if (kept.endsWith(secret.slice(0, length))) {
60
+ result = `${kept.slice(0, -length)}[redacted]${TRUNCATION_MARK}`;
61
+ count++;
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ return { text: result, count };
67
+ }
34
68
  export async function captureCommand(
35
69
  exec: GitExec,
36
70
  cwd: string,
37
71
  command: string,
38
72
  timeoutS = ASK_TIMEOUT_S,
39
73
  signal?: AbortSignal,
74
+ /** Configured Jev API key, replaced in the captured command and output. */
75
+ secret?: string,
40
76
  ): Promise<
41
77
  Result<{
42
78
  output: CommandOutput;
@@ -47,9 +83,19 @@ export async function captureCommand(
47
83
  selectedPassages: boolean;
48
84
  assertion: boolean;
49
85
  targets: FailureTarget[];
50
- }>
86
+ /** Occurrences of `secret` replaced with `[redacted]`. */
87
+ redactions: number;
88
+ }> & {
89
+ commandExecution: "not_started" | "unknown" | "finished";
90
+ commandExitCode?: number | null;
91
+ commandTimedOut?: boolean;
92
+ }
51
93
  > {
52
94
  const directory = await mkdtemp(join(tmpdir(), "jev-output-"));
95
+ let commandExecution: "not_started" | "unknown" | "finished" = "not_started";
96
+ let completion:
97
+ | { commandExitCode: number | null; commandTimedOut: boolean }
98
+ | undefined;
53
99
  try {
54
100
  await chmod(directory, 0o700);
55
101
  const stdoutPath = join(directory, "stdout");
@@ -62,39 +108,40 @@ export async function captureCommand(
62
108
  killed: boolean;
63
109
  };
64
110
  try {
111
+ const shell = resolveShell();
112
+ // Fail closed: never spawn a bare name that PATH could resolve to WSL.
113
+ if (!shell.ok)
114
+ return {
115
+ ok: false,
116
+ cause: "file_unavailable",
117
+ commandExecution: "not_started",
118
+ error: shell.error,
119
+ };
120
+ commandExecution = "unknown";
65
121
  executed = await exec(
66
- "env",
122
+ shell.executable,
67
123
  [
68
- "CI=1",
69
- "bash",
124
+ ...shell.prefix,
70
125
  "-c",
71
- `exec >"$1" 2>"$2"; set --; (\n${command}\n)\nexit $?`,
126
+ `${shell.scriptPrefix}exec >"$1" 2>"$2"; set --; (\n${command}\n)\nexit $?`,
72
127
  "jev",
73
128
  stdoutPath,
74
129
  stderrPath,
75
130
  ],
76
131
  { cwd, timeout: timeoutS * 1000, signal },
77
132
  );
133
+ commandExecution = "finished";
134
+ completion = {
135
+ commandExitCode: executed.killed ? null : executed.code,
136
+ commandTimedOut: executed.killed,
137
+ };
78
138
  } catch (error) {
79
139
  signal?.throwIfAborted();
80
140
  return {
81
- ok: true,
82
- output: {
83
- command,
84
- exit_code: null,
85
- timed_out: false,
86
- stdout: "",
87
- stderr: `Command executable unavailable: ${String(error)}`,
88
- compressed: true,
89
- truncated: false,
90
- },
91
- originalBytes: 0,
92
- compressedChars: 0,
93
- lineOmittedChars: 0,
94
- shapeLimitExceeded: false,
95
- selectedPassages: false,
96
- assertion: false,
97
- targets: [],
141
+ ok: false,
142
+ cause: "file_unavailable",
143
+ commandExecution,
144
+ error: `Command executable unavailable: ${String(error)}`,
98
145
  };
99
146
  }
100
147
  signal?.throwIfAborted();
@@ -116,6 +163,9 @@ export async function captureCommand(
116
163
  if (sizes.some((size) => size > OUTPUT_FILE_MAX_BYTES))
117
164
  return {
118
165
  ok: false,
166
+ cause: "evidence_too_large",
167
+ commandExecution: "finished",
168
+ ...completion,
119
169
  error: `output exceeded ${OUTPUT_FILE_MAX_BYTES} bytes per stream; narrow command`,
120
170
  };
121
171
  const targets: FailureTarget[] = [];
@@ -125,19 +175,23 @@ export async function captureCommand(
125
175
  let lineOmittedChars = 0;
126
176
  let shapeLimitExceeded = false;
127
177
  const texts: string[] = [];
178
+ let redactions = 0;
179
+ const redacted = (text: string): string => {
180
+ const result = redactSecret(text, secret);
181
+ redactions += result.count;
182
+ return result.text;
183
+ };
128
184
  for (const [index, path] of [stdoutPath, stderrPath].entries()) {
129
185
  if (!sizes[index]) {
130
- texts.push(index === 0 ? executed.stdout : executed.stderr);
186
+ texts.push(redacted(index === 0 ? executed.stdout : executed.stderr));
131
187
  continue;
132
188
  }
133
189
  const frequencies = new Map<string, number>();
134
190
  let shapeLimit = false;
135
191
  for await (const raw of outputLines(path, signal)) {
136
192
  signal?.throwIfAborted();
137
- const line = cleanOutput(raw);
138
- linesTruncated ||= raw.endsWith(
139
- `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
140
- );
193
+ const line = redactSecret(cleanOutput(raw), secret).text;
194
+ linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
141
195
  const shape = lineShape(line);
142
196
  if (
143
197
  !frequencies.has(shape) &&
@@ -184,17 +238,17 @@ export async function captureCommand(
184
238
  lineOmittedChars += chars;
185
239
  })) {
186
240
  signal?.throwIfAborted();
187
- linesTruncated ||= raw.endsWith(
188
- `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
189
- );
190
- compressor.line(cleanOutput(raw));
241
+ linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
242
+ // Redact before compression, failure windows and state assembly.
243
+ const line = redacted(cleanOutput(raw));
244
+ compressor.line(line);
191
245
  if (!failureSeen) {
192
- failureWindow.push(cleanOutput(raw));
246
+ failureWindow.push(line);
193
247
  if (failureWindow.length > OUTPUT_FAILURE_WINDOW_LINES + 1)
194
248
  failureWindow.shift();
195
249
  if (isAnchor(raw)) failureSeen = true;
196
250
  } else if (afterFailure++ < OUTPUT_FAILURE_WINDOW_LINES) {
197
- failureWindow.push(cleanOutput(raw));
251
+ failureWindow.push(line);
198
252
  if (!secondSeen && afterFailure > 1 && isFailingTestsHeader(raw)) {
199
253
  secondSeen = true;
200
254
  afterSecond = 0;
@@ -203,10 +257,10 @@ export async function captureCommand(
203
257
  if (isFailingTestsHeader(raw)) {
204
258
  secondSeen = true;
205
259
  afterSecond = 0;
206
- failureWindow.push(cleanOutput(raw));
260
+ failureWindow.push(line);
207
261
  }
208
262
  } else if (afterSecond++ < OUTPUT_FAILURE_WINDOW_LINES) {
209
- failureWindow.push(cleanOutput(raw));
263
+ failureWindow.push(line);
210
264
  }
211
265
  }
212
266
  compressor.finish();
@@ -217,8 +271,10 @@ export async function captureCommand(
217
271
  }
218
272
  return {
219
273
  ok: true,
274
+ commandExecution: "finished",
275
+ ...completion,
220
276
  output: {
221
- command,
277
+ command: redacted(command),
222
278
  exit_code: executed.killed ? null : executed.code,
223
279
  timed_out: executed.killed,
224
280
  stdout: texts[0] ?? "",
@@ -233,6 +289,16 @@ export async function captureCommand(
233
289
  selectedPassages: false,
234
290
  assertion: signature === "assertion",
235
291
  targets,
292
+ redactions,
293
+ };
294
+ } catch (error) {
295
+ signal?.throwIfAborted();
296
+ return {
297
+ ok: false,
298
+ cause: "file_unavailable",
299
+ commandExecution,
300
+ ...completion,
301
+ error: `Command capture unavailable: ${String(error)}`,
236
302
  };
237
303
  } finally {
238
304
  await rm(directory, { recursive: true, force: true });
@@ -5,6 +5,7 @@ import {
5
5
  } from "../core/docs.ts";
6
6
  import type { GitExec } from "../core/git.ts";
7
7
  import type { Result } from "../result.ts";
8
+ import type { Cause } from "../result-types.ts";
8
9
  import {
9
10
  checkFileAdmission,
10
11
  openRepoFile,
@@ -20,7 +21,7 @@ export interface DocsInventory {
20
21
  cwd: string;
21
22
  files: DocsSource[];
22
23
  tracked: ReadonlySet<string>;
23
- limits: { path: string; reason: string }[];
24
+ limits: { path: string; reason: string; cause?: Cause }[];
24
25
  read: (path: string) => Promise<DocsSource | undefined>;
25
26
  }
26
27
  /** Inventory paths eagerly, but load only Markdown/configuration and subsequently reached sources. */
@@ -37,6 +38,7 @@ export async function collectDocsInventory(
37
38
  if (root.code || root.killed)
38
39
  return {
39
40
  ok: false,
41
+ cause: signal?.aborted ? "cancelled" : "git_failure",
40
42
  error: root.stderr.trim() || "Repository root not found.",
41
43
  };
42
44
  cwd = root.stdout.trim();
@@ -48,6 +50,7 @@ export async function collectDocsInventory(
48
50
  if (list.code || list.killed)
49
51
  return {
50
52
  ok: false,
53
+ cause: signal?.aborted ? "cancelled" : "git_failure",
51
54
  error: list.stderr.trim() || "Unable to inventory tracked files.",
52
55
  };
53
56
  const tracked = new Set(list.stdout.split("\0").filter(Boolean));
@@ -70,7 +73,11 @@ export async function collectDocsInventory(
70
73
  inventory: tracked,
71
74
  });
72
75
  if (!opened.ok) {
73
- limits.push({ path, reason: opened.error });
76
+ limits.push({
77
+ path,
78
+ reason: opened.error,
79
+ ...(opened.cause ? { cause: opened.cause } : {}),
80
+ });
74
81
  return undefined;
75
82
  }
76
83
  const handle = opened.handle;
@@ -168,18 +175,22 @@ export function docsDeclarationSearch(
168
175
  const path = candidates[index++];
169
176
  if (path === undefined) return;
170
177
  const location = await resolveInsideRepo(inventory.cwd, path);
171
- const admitted =
172
- location.ok &&
173
- (
174
- await checkFileAdmission(
178
+ const admission = location.ok
179
+ ? await checkFileAdmission(
175
180
  inventory.cwd,
176
181
  location.rel,
177
182
  exec,
178
183
  signal,
179
184
  inventory.tracked,
180
185
  )
181
- ).ok;
182
- if (admitted) paths.push(path);
186
+ : location;
187
+ if (admission.ok) paths.push(path);
188
+ else if (admission.cause === "secret_pattern")
189
+ inventory.limits.push({
190
+ path,
191
+ reason: admission.error,
192
+ cause: admission.cause,
193
+ });
183
194
  }
184
195
  }),
185
196
  );
@@ -188,12 +199,20 @@ export function docsDeclarationSearch(
188
199
  const paths = await admittedPaths;
189
200
  if (!paths.length) return new Map();
190
201
  const patterns = ["-e", docsDeclarationPattern(names)];
191
- const result = await exec("rg", ["--json", ...patterns, "--", ...paths], {
192
- cwd: inventory.cwd,
193
- timeout: TIMEOUT_MS,
194
- signal,
195
- });
196
- if (result.killed || (result.code !== 0 && result.code !== 1)) {
202
+ let result:
203
+ | { stdout: string; stderr: string; code: number; killed: boolean }
204
+ | undefined;
205
+ try {
206
+ result = await exec("rg", ["--json", ...patterns, "--", ...paths], {
207
+ cwd: inventory.cwd,
208
+ timeout: TIMEOUT_MS,
209
+ signal,
210
+ });
211
+ } catch {
212
+ // A missing or unstartable rg is an unavailable search, not a failure.
213
+ signal?.throwIfAborted();
214
+ }
215
+ if (!result || result.killed || (result.code !== 0 && result.code !== 1)) {
197
216
  inventory.limits.push({
198
217
  path: "documentation",
199
218
  reason: "declaration search unavailable",