jev-agent-tools 0.2.0 → 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 (117) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +22 -14
  4. package/SECURITY.md +17 -1
  5. package/dist/adapters/ask-files.js +11 -2
  6. package/dist/adapters/ask-proof.js +63 -7
  7. package/dist/adapters/command.js +82 -29
  8. package/dist/adapters/docs.js +30 -10
  9. package/dist/adapters/evidence-context.js +119 -0
  10. package/dist/adapters/files.js +141 -16
  11. package/dist/adapters/find.js +34 -6
  12. package/dist/adapters/git-base.js +7 -1
  13. package/dist/adapters/git.js +51 -7
  14. package/dist/adapters/locate-file.js +47 -9
  15. package/dist/adapters/private-storage.js +14 -6
  16. package/dist/adapters/risk-callers.js +3 -0
  17. package/dist/adapters/shell.js +23 -7
  18. package/dist/adapters/test-inventory.js +10 -2
  19. package/dist/configuration.js +17 -7
  20. package/dist/constants.js +26 -5
  21. package/dist/core/ask-references.js +193 -109
  22. package/dist/core/asks.js +78 -7
  23. package/dist/core/locate.js +8 -8
  24. package/dist/core/output.js +17 -0
  25. package/dist/core/result-report.js +302 -0
  26. package/dist/core/secret-path.js +34 -0
  27. package/dist/core/state.js +8 -1
  28. package/dist/core/units.js +1 -1
  29. package/dist/jev/client.js +34 -12
  30. package/dist/mcp/protocol.js +50 -27
  31. package/dist/mcp/tools.js +20 -7
  32. package/dist/render.js +72 -0
  33. package/dist/report-schema.js +1356 -0
  34. package/dist/result-types.js +1 -0
  35. package/dist/texts/ask-files.js +3 -1
  36. package/dist/texts/ask.js +3 -1
  37. package/dist/texts/check-diff.js +7 -4
  38. package/dist/texts/find.js +7 -2
  39. package/dist/texts/guide.js +3 -16
  40. package/dist/texts/instructions.js +72 -0
  41. package/dist/texts/locate.js +7 -2
  42. package/dist/texts/select-tests.js +3 -1
  43. package/dist/tools/ask-files.js +248 -15
  44. package/dist/tools/ask.js +523 -62
  45. package/dist/tools/check-diff.js +222 -30
  46. package/dist/tools/docs-check.js +122 -13
  47. package/dist/tools/find.js +320 -27
  48. package/dist/tools/locate.js +317 -18
  49. package/dist/tools/review-report.js +230 -0
  50. package/dist/tools/select-tests.js +273 -19
  51. package/dist/tools/spec-check.js +119 -22
  52. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +3 -3
  53. package/docs/agent-instructions.md +59 -30
  54. package/docs/design.md +13 -1
  55. package/docs/mcp.md +8 -6
  56. package/docs/tools/jev_ask.md +8 -5
  57. package/docs/tools/jev_ask_files.md +2 -1
  58. package/docs/tools/jev_check_diff.md +4 -1
  59. package/docs/tools/jev_find_files.md +2 -1
  60. package/docs/tools/jev_locate_in_file.md +5 -0
  61. package/docs/tools/jev_select_tests.md +4 -1
  62. package/package.json +1 -1
  63. package/rules/jev-ask.md +22 -1
  64. package/server.json +2 -2
  65. package/src/adapters/ask-files.ts +11 -3
  66. package/src/adapters/ask-proof.ts +69 -11
  67. package/src/adapters/command.ts +96 -33
  68. package/src/adapters/docs.ts +33 -14
  69. package/src/adapters/evidence-context.ts +169 -0
  70. package/src/adapters/files.ts +146 -16
  71. package/src/adapters/find.ts +37 -7
  72. package/src/adapters/git-base.ts +7 -1
  73. package/src/adapters/git.ts +61 -8
  74. package/src/adapters/locate-file.ts +51 -9
  75. package/src/adapters/private-storage.ts +17 -5
  76. package/src/adapters/risk-callers.ts +3 -0
  77. package/src/adapters/shell.ts +23 -7
  78. package/src/adapters/test-inventory.ts +12 -4
  79. package/src/configuration.ts +16 -2
  80. package/src/constants.ts +26 -5
  81. package/src/core/ask-references.ts +262 -146
  82. package/src/core/asks.ts +79 -7
  83. package/src/core/import-boundaries.ts +8 -3
  84. package/src/core/locate.ts +8 -5
  85. package/src/core/output.ts +34 -0
  86. package/src/core/result-report.ts +410 -0
  87. package/src/core/secret-path.ts +37 -0
  88. package/src/core/state.ts +8 -1
  89. package/src/core/units.ts +3 -2
  90. package/src/index.ts +3 -0
  91. package/src/jev/client.ts +54 -16
  92. package/src/jev/types.ts +18 -3
  93. package/src/mcp/protocol.ts +91 -41
  94. package/src/mcp/tools.ts +26 -13
  95. package/src/render.ts +109 -0
  96. package/src/report-schema.ts +1380 -0
  97. package/src/result-types.ts +234 -0
  98. package/src/result.ts +4 -1
  99. package/src/runtime.ts +6 -0
  100. package/src/texts/ask-files.ts +4 -1
  101. package/src/texts/ask.ts +8 -1
  102. package/src/texts/check-diff.ts +7 -4
  103. package/src/texts/find.ts +8 -2
  104. package/src/texts/guide.ts +8 -16
  105. package/src/texts/instructions.ts +98 -0
  106. package/src/texts/locate.ts +8 -2
  107. package/src/texts/run-end.ts +2 -2
  108. package/src/texts/select-tests.ts +4 -1
  109. package/src/tools/ask-files.ts +309 -14
  110. package/src/tools/ask.ts +700 -77
  111. package/src/tools/check-diff.ts +331 -28
  112. package/src/tools/docs-check.ts +241 -39
  113. package/src/tools/find.ts +386 -29
  114. package/src/tools/locate.ts +384 -19
  115. package/src/tools/review-report.ts +308 -0
  116. package/src/tools/select-tests.ts +479 -21
  117. package/src/tools/spec-check.ts +193 -19
@@ -18,6 +18,7 @@ Code splits the file into declarations or sections and constructs a choice over
18
18
 
19
19
  | Field | Type / default | Meaning |
20
20
  |---|---|---|
21
+ | `root` | Optional nonempty string | Exact initial Git root or registered worktree of the same repository; see [evidence-root admission](../../README.md#evidence-root). |
21
22
  | `path` | Required nonempty string | One repository-relative file, at least 19,000 bytes. |
22
23
  | `goal` | Required nonempty string | Sentence describing the behavior you need to find. |
23
24
 
@@ -38,10 +39,14 @@ Illustrative call with a fictional repository path, not a recorded execution:
38
39
 
39
40
  A result names `path:start-end`, a label, probability and the next read. Above 0.7 an unmarked range is a verdict: read it. From 0.4 through 0.7, unsure lists the two best ranges by probability: read both, not an arbitrary next section. Below 0.4 the tool narrows to three leading candidates plus none and judges again, retaining the original unsure band. Option-order sensitivity can also leave the result unsure. `none` means no supplied section fits: search elsewhere or clarify the goal. See [shared result reading](../../README.md#read-the-results).
40
41
 
42
+ The versioned report preserves actual primary/control values and their fresh/cache source. Missing required control answers produce unjudged work rather than a synthesized range or probability. Typed diagnostics retain parsing/window omissions and native next actions, including searching elsewhere for a judged `none`.
43
+
41
44
  ## Limits and failure behavior
42
45
 
43
46
  Small files are refused with direct-read guidance. Whole-file admission is bounded by 320,000 characters and 1,280,000 bytes; larger files take the streamed-window path, not silent whole-file truncation. Window serialization, fallback section sizes, parsing support and choice cardinality are separately bounded. Unreadable or invalid evidence, missing configuration and exhausted session budgets are reported rather than producing a range verdict. Missing optional Python grammar is named with installation guidance. A selected window does not prove every relevant declaration was inspected.
44
47
 
48
+ The serialized judgment budget includes per-call evidence provenance. Block planning, excerpt allocation and full-text refinement reserve that metadata capacity before selecting evidence; provenance never silently pushes an otherwise planned request past admission. Selected blocks still require at least two sections and obey the choice limit.
49
+
45
50
  ## Host differences
46
51
 
47
52
  omp `read` already outlines code files: use this tool when that outline does not tell you which range serves your goal. Both hosts use the same judgment protocol; omp marks the tool read-only.
@@ -20,6 +20,7 @@ Residual exported-unit checks concern changed units not exercised by any discove
20
20
 
21
21
  | Field | Type / default | Meaning |
22
22
  |---|---|---|
23
+ | `root` | Optional nonempty string | Exact initial Git root or registered worktree of the same repository; see [evidence-root admission](../../README.md#evidence-root). |
23
24
  | `base` | Optional nonempty Git ref, default `HEAD` | Compare the current tree with this revision. |
24
25
  | `paths` | Optional array of nonempty strings, default discovered inventory | Candidate test files or globs, not changed source paths. |
25
26
  | `witnesses` | Optional `"off"`, `"auto"` (default), `"on"` | Controls residual coverage checks; test pointers have no witnesses. |
@@ -27,6 +28,8 @@ Residual exported-unit checks concern changed units not exercised by any discove
27
28
 
28
29
  Unknown fields and unresolved refs are rejected with guidance. Repository-relative evidence paths are confined to the repository; internal URLs are not file inputs. Narrowing candidate paths also narrows the inventory to which coverage observations apply.
29
30
 
31
+ Each requested criterion reports its matches and any excluded or unmatched inventory. Partial matches keep the matched selection and identify the unmatched criteria; zero matches are not proof of no affected tests. Changed files outside the supported dependency graph retain conservative widening, with the triggering paths named. Runner commands remain static plans and use the admitted root.
32
+
30
33
  ## Example
31
34
 
32
35
  Illustrative call with fictional repository paths, not a recorded execution:
@@ -44,7 +47,7 @@ Illustrative call with fictional repository paths, not a recorded execution:
44
47
 
45
48
  Inspect selected scenarios and the returned runner commands, then execute them yourself. A scenario is selected when `1 - p(none) >= 0.5`; missing answers are selected too. If at least 80% of a file's scenarios are selected, names are uncertain or counts unknown, the command runs the whole file instead of a fragile name filter. Commands remain separate when runner context differs.
46
49
 
47
- `fallback: all` means conservative selection, not that Jev confirmed every scenario is affected. `changed, run by no discovered test` is limited to discovery evidence; do not call it repository-wide uncovered behavior. Read unsure or unsupported-runner limits and follow the named manual check. See [shared result reading](../../README.md#read-the-results).
50
+ Static selection identifies its deterministic reason separately from fresh/cache Jev judgments. Conservative fallback preserves tests when judgments are unavailable; it is not confirmation that every scenario is affected. Residual absence is a derived reservation linked to retained coverage items or the considered inventory, not an extra requested judgment or fabricated probability. It does not establish repository-wide uncovered behavior. Read material limits and follow the named native action. See [shared result reading](../../README.md#read-the-results).
48
51
 
49
52
  ## Limits and failure behavior
50
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jev-agent-tools",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "mcpName": "io.github.NomenAK/jev-agent-tools",
package/rules/jev-ask.md CHANGED
@@ -1,4 +1,25 @@
1
1
  ---
2
2
  alwaysApply: true
3
3
  ---
4
- jev_ask: before you conclude that a failure is a bug in the code, a wrong test or the environment, or that a plan matches the docs, pass the files to jev_ask and weigh its answer against your own reading
4
+ <!-- Generated policy 2026-10-03.1 from src/texts/instructions.ts; run node scripts/generate-instructions.ts after editing the source. -->
5
+ Use Jev for a bounded semantic judgment when its answer could change an open decision or focus the next inspection, and the relevant evidence is available. Use decisive reading, search or authorized execution directly when it settles the question. Exact lookup, counting and runtime causality belong to native tools. A Jev call is not a prerequisite for a conclusion, a review or task completion; no explanation is needed for choosing native tools.
6
+
7
+ Before a chosen call, identify the open decision and supply only the evidence needed for a bounded question; a complete prior analysis is not required. Ask about one positive, self-contained observable fact per claim, with its sources, and show both sides of a comparison.
8
+ - Failure: relevant output, the failing test and the implementation it exercises; output alone is a lead, not a bug-versus-wrong-test diagnosis. Include configuration or runtime evidence when the hypothesis depends on it. A static judgment does not establish runtime causality.
9
+ - Change: current evidence and the earlier reference via base. Use the checkout/root corresponding to the work and admitted by the tool; another clone is not the same context.
10
+ - Plan/documentation: precise observable commitments and the passages that constrain them. Local confirmation does not establish that omitted obligations were searched.
11
+ - Selection/review: compatible inventory, references and configuration. Suggested commands are not executed commands; existing tests need not cover a new scenario. Read the diff natively for its contents, not as proof of safety or global coverage.
12
+
13
+ After an unavailable, refused or out-of-scope result, continue natively within the existing permissions; do not widen sharing or confinement to obtain a judgment. For uncertainty or missing evidence, inspect or obtain the decisive piece, or leave the conclusion open. Revisit Jev only when new evidence, a material context change or a new useful question makes the judgment useful; rewording unchanged evidence is not a reason to retry. There is no retry quota for genuinely changed evidence, and no certification is needed once decisive evidence settles the question. A reached cap or an unjudged result is not evidence of safety or zero affected tests.
14
+
15
+ jev_check_diff: Review a stable diff for semantic risks, stale documentation or specification drift when that review can inform an open decision. Read the diff natively when you need its contents. Choose risk, docs or spec for the question at hand; neither risk followed by docs nor a Jev review before done is required. Findings are leads within the inspected scope, not proof of global safety or completeness.
16
+
17
+ Report current conclusions first, then decisive evidence, origin and scope, then material reservations. Established means supported by relevant decisive evidence; a reported check remains explicitly reported, not observed execution. Distinguish native reading/execution, Jev's static judgment and testimony. A call count or global status is not proof.
18
+ If native evidence settles the same scope and context after a Jev uncertainty, attribute the current conclusion to that evidence; old unsure or abstain results need not be recited as current reservations. A later Jev confirmation is attributed to Jev and retains its static limits. Independent reservations survive either resolution.
19
+ Where an uncertainty actually returned by Jev remains material, explicitly say “Jev did not confirm X”, identify the missing evidence or guarantee, its known or indeterminate impact and the evidence to obtain. Preserve that reservation in the main text, summary and recommendation. Name native reservations and unjudged work as such, not as Jev uncertainty. Unresolved contradiction, stale evidence or a different checkout/base/scenario remains a reservation; the latest favorable answer does not win by default.
20
+ Keep material limits in the main text, not only behind a link. Do not generalize a checked scenario into a universal guarantee. Separate relevant unestablished hypotheses from observations; omit irrelevant hypotheses. Explain unavailable historical causes only when material to action or requested, without inventing causality.
21
+ Use existing history references when useful or requested; no exhaustive historical relay, persistent register or History block is required. A requested audit details available steps separately from the current state and does not reopen settled conclusions. Preserve existing traces; identify unavailable traces when they limit evidence or audit, without reconstructing evidence, executions or call counts. pi/OMP may reference an existing trace; MCP references must be client-accessible or explicitly unavailable.
22
+
23
+ pi and OMP retain an automatic, opt-out documentation check at task completion (JEV_TOOLS_AUTO_DOCS=0 disables it). It is a host feature, not an agent requirement to call Jev. Inspect any flagged sentence against the change and update it or explain with evidence why it remains correct. The check is limited and does not certify documentation completeness or demonstrated usefulness. A flagged sentence does not require a second call or recursive reviews.
24
+
25
+ Evidence passed to jev_* tools leaves the machine for the configured endpoint. Review its data handling; share only authorized evidence, never secrets or credentials. Host/client approvals still apply. jev_ask commands run with normal shell permissions and no sandbox; prefer read-only commands. Tool choice does not relax quality, proof, approval or confidentiality obligations.
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.NomenAK/jev-agent-tools",
4
4
  "title": "Jev agent tools",
5
5
  "description": "Evidence-oriented code questions, diff review and test selection via a Jev endpoint.",
6
- "version": "0.2.0",
6
+ "version": "0.3.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/NomenAK/jev-tools",
9
9
  "source": "github"
@@ -14,7 +14,7 @@
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "jev-agent-tools",
17
- "version": "0.2.0",
17
+ "version": "0.3.0",
18
18
  "runtimeHint": "npx",
19
19
  "transport": {
20
20
  "type": "stdio"
@@ -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
  }
@@ -32,12 +32,47 @@ export interface CommandOutput {
32
32
  compressed: boolean;
33
33
  truncated: boolean;
34
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
+ }
35
68
  export async function captureCommand(
36
69
  exec: GitExec,
37
70
  cwd: string,
38
71
  command: string,
39
72
  timeoutS = ASK_TIMEOUT_S,
40
73
  signal?: AbortSignal,
74
+ /** Configured Jev API key, replaced in the captured command and output. */
75
+ secret?: string,
41
76
  ): Promise<
42
77
  Result<{
43
78
  output: CommandOutput;
@@ -48,9 +83,19 @@ export async function captureCommand(
48
83
  selectedPassages: boolean;
49
84
  assertion: boolean;
50
85
  targets: FailureTarget[];
51
- }>
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
+ }
52
93
  > {
53
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;
54
99
  try {
55
100
  await chmod(directory, 0o700);
56
101
  const stdoutPath = join(directory, "stdout");
@@ -65,7 +110,14 @@ export async function captureCommand(
65
110
  try {
66
111
  const shell = resolveShell();
67
112
  // Fail closed: never spawn a bare name that PATH could resolve to WSL.
68
- if (!shell.ok) throw new Error(shell.error);
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";
69
121
  executed = await exec(
70
122
  shell.executable,
71
123
  [
@@ -78,26 +130,18 @@ export async function captureCommand(
78
130
  ],
79
131
  { cwd, timeout: timeoutS * 1000, signal },
80
132
  );
133
+ commandExecution = "finished";
134
+ completion = {
135
+ commandExitCode: executed.killed ? null : executed.code,
136
+ commandTimedOut: executed.killed,
137
+ };
81
138
  } catch (error) {
82
139
  signal?.throwIfAborted();
83
140
  return {
84
- ok: true,
85
- output: {
86
- command,
87
- exit_code: null,
88
- timed_out: false,
89
- stdout: "",
90
- stderr: `Command executable unavailable: ${String(error)}`,
91
- compressed: true,
92
- truncated: false,
93
- },
94
- originalBytes: 0,
95
- compressedChars: 0,
96
- lineOmittedChars: 0,
97
- shapeLimitExceeded: false,
98
- selectedPassages: false,
99
- assertion: false,
100
- targets: [],
141
+ ok: false,
142
+ cause: "file_unavailable",
143
+ commandExecution,
144
+ error: `Command executable unavailable: ${String(error)}`,
101
145
  };
102
146
  }
103
147
  signal?.throwIfAborted();
@@ -119,6 +163,9 @@ export async function captureCommand(
119
163
  if (sizes.some((size) => size > OUTPUT_FILE_MAX_BYTES))
120
164
  return {
121
165
  ok: false,
166
+ cause: "evidence_too_large",
167
+ commandExecution: "finished",
168
+ ...completion,
122
169
  error: `output exceeded ${OUTPUT_FILE_MAX_BYTES} bytes per stream; narrow command`,
123
170
  };
124
171
  const targets: FailureTarget[] = [];
@@ -128,19 +175,23 @@ export async function captureCommand(
128
175
  let lineOmittedChars = 0;
129
176
  let shapeLimitExceeded = false;
130
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
+ };
131
184
  for (const [index, path] of [stdoutPath, stderrPath].entries()) {
132
185
  if (!sizes[index]) {
133
- texts.push(index === 0 ? executed.stdout : executed.stderr);
186
+ texts.push(redacted(index === 0 ? executed.stdout : executed.stderr));
134
187
  continue;
135
188
  }
136
189
  const frequencies = new Map<string, number>();
137
190
  let shapeLimit = false;
138
191
  for await (const raw of outputLines(path, signal)) {
139
192
  signal?.throwIfAborted();
140
- const line = cleanOutput(raw);
141
- linesTruncated ||= raw.endsWith(
142
- `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
143
- );
193
+ const line = redactSecret(cleanOutput(raw), secret).text;
194
+ linesTruncated ||= raw.endsWith(TRUNCATION_MARK);
144
195
  const shape = lineShape(line);
145
196
  if (
146
197
  !frequencies.has(shape) &&
@@ -187,17 +238,17 @@ export async function captureCommand(
187
238
  lineOmittedChars += chars;
188
239
  })) {
189
240
  signal?.throwIfAborted();
190
- linesTruncated ||= raw.endsWith(
191
- `…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`,
192
- );
193
- 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);
194
245
  if (!failureSeen) {
195
- failureWindow.push(cleanOutput(raw));
246
+ failureWindow.push(line);
196
247
  if (failureWindow.length > OUTPUT_FAILURE_WINDOW_LINES + 1)
197
248
  failureWindow.shift();
198
249
  if (isAnchor(raw)) failureSeen = true;
199
250
  } else if (afterFailure++ < OUTPUT_FAILURE_WINDOW_LINES) {
200
- failureWindow.push(cleanOutput(raw));
251
+ failureWindow.push(line);
201
252
  if (!secondSeen && afterFailure > 1 && isFailingTestsHeader(raw)) {
202
253
  secondSeen = true;
203
254
  afterSecond = 0;
@@ -206,10 +257,10 @@ export async function captureCommand(
206
257
  if (isFailingTestsHeader(raw)) {
207
258
  secondSeen = true;
208
259
  afterSecond = 0;
209
- failureWindow.push(cleanOutput(raw));
260
+ failureWindow.push(line);
210
261
  }
211
262
  } else if (afterSecond++ < OUTPUT_FAILURE_WINDOW_LINES) {
212
- failureWindow.push(cleanOutput(raw));
263
+ failureWindow.push(line);
213
264
  }
214
265
  }
215
266
  compressor.finish();
@@ -220,8 +271,10 @@ export async function captureCommand(
220
271
  }
221
272
  return {
222
273
  ok: true,
274
+ commandExecution: "finished",
275
+ ...completion,
223
276
  output: {
224
- command,
277
+ command: redacted(command),
225
278
  exit_code: executed.killed ? null : executed.code,
226
279
  timed_out: executed.killed,
227
280
  stdout: texts[0] ?? "",
@@ -236,6 +289,16 @@ export async function captureCommand(
236
289
  selectedPassages: false,
237
290
  assertion: signature === "assertion",
238
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)}`,
239
302
  };
240
303
  } finally {
241
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",