@cruxy/cli 0.10.0 → 0.12.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 (70) hide show
  1. package/dist/approval/classify.js +21 -0
  2. package/dist/approval/policy.js +6 -0
  3. package/dist/approval/prompt.js +21 -17
  4. package/dist/approval/types.d.ts +5 -0
  5. package/dist/cli/commands/checkpoint.js +6 -4
  6. package/dist/cli/commands/config.js +10 -7
  7. package/dist/cli/commands/index.js +16 -15
  8. package/dist/cli/commands/init.js +5 -3
  9. package/dist/cli/commands/login.js +5 -3
  10. package/dist/cli/commands/pr.js +8 -7
  11. package/dist/cli/commands/rollback.js +7 -6
  12. package/dist/cli/commands/run.js +7 -6
  13. package/dist/cli/commands/skills.js +12 -10
  14. package/dist/cli/commands/test.d.ts +9 -0
  15. package/dist/cli/commands/test.js +47 -0
  16. package/dist/cli/program.js +9 -6
  17. package/dist/cli/repl.js +11 -9
  18. package/dist/cli/session-factory.js +6 -2
  19. package/dist/components/frame.js +3 -1
  20. package/dist/components/fuzzy.d.ts +4 -4
  21. package/dist/components/fuzzy.js +14 -13
  22. package/dist/components/select.js +8 -7
  23. package/dist/config/schema.d.ts +47 -0
  24. package/dist/config/schema.js +20 -0
  25. package/dist/errors/constructors.d.ts +5 -0
  26. package/dist/errors/constructors.js +16 -0
  27. package/dist/errors/format.js +8 -8
  28. package/dist/errors/types.d.ts +3 -0
  29. package/dist/errors/types.js +8 -0
  30. package/dist/onboarding/flow.js +6 -6
  31. package/dist/onboarding/steps.js +11 -11
  32. package/dist/plan/approve.js +6 -6
  33. package/dist/plan/render.js +26 -18
  34. package/dist/render/capabilities.js +4 -0
  35. package/dist/render/diff.d.ts +6 -7
  36. package/dist/render/diff.js +33 -22
  37. package/dist/render/highlight.d.ts +3 -3
  38. package/dist/render/highlight.js +15 -15
  39. package/dist/render/index.d.ts +1 -1
  40. package/dist/render/plain-renderer.d.ts +2 -1
  41. package/dist/render/plain-renderer.js +7 -6
  42. package/dist/render/state.d.ts +7 -2
  43. package/dist/render/state.js +16 -10
  44. package/dist/render/tty-renderer.d.ts +2 -1
  45. package/dist/render/tty-renderer.js +20 -17
  46. package/dist/render/types.d.ts +7 -0
  47. package/dist/subagent/orchestrator.js +21 -6
  48. package/dist/testing/detect.d.ts +3 -0
  49. package/dist/testing/detect.js +44 -0
  50. package/dist/testing/index.d.ts +5 -0
  51. package/dist/testing/index.js +5 -0
  52. package/dist/testing/parse.d.ts +33 -0
  53. package/dist/testing/parse.js +137 -0
  54. package/dist/testing/run-tests-tool.d.ts +42 -0
  55. package/dist/testing/run-tests-tool.js +128 -0
  56. package/dist/testing/runner.d.ts +26 -0
  57. package/dist/testing/runner.js +124 -0
  58. package/dist/testing/types.d.ts +61 -0
  59. package/dist/testing/types.js +7 -0
  60. package/dist/theme/index.d.ts +2 -0
  61. package/dist/theme/index.js +2 -0
  62. package/dist/theme/resolve.d.ts +32 -0
  63. package/dist/theme/resolve.js +73 -0
  64. package/dist/theme/tokens.d.ts +104 -0
  65. package/dist/theme/tokens.js +52 -0
  66. package/dist/tools/registry.js +3 -0
  67. package/dist/tools/types.d.ts +2 -2
  68. package/dist/utils/logger.d.ts +2 -0
  69. package/dist/utils/logger.js +7 -4
  70. package/package.json +1 -1
@@ -1,13 +1,10 @@
1
- import pc from "picocolors";
1
+ import { resolveTheme } from "../theme/index.js";
2
2
  import { createStreamPrinter } from "../cli/stream-print.js";
3
3
  import { renderActionPreview } from "./diff.js";
4
4
  import { createStreamHighlighter, } from "./highlight.js";
5
5
  import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
6
6
  /** Erase the current line and return the cursor to column 0. */
7
7
  const CLEAR_LINE = "\r\x1b[2K";
8
- /** Spinner frames (braille); a static glyph when animation is disabled. */
9
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
- const STATIC_FRAME = "◐";
11
8
  const SPINNER_INTERVAL_MS = 100;
12
9
  /**
13
10
  * The interactive renderer: committed content is append-only; the one transient
@@ -37,7 +34,7 @@ const SPINNER_INTERVAL_MS = 100;
37
34
  export class TtyRenderer {
38
35
  caps;
39
36
  out;
40
- colors;
37
+ theme;
41
38
  print;
42
39
  highlighter;
43
40
  wroteInSegment = false;
@@ -59,8 +56,8 @@ export class TtyRenderer {
59
56
  constructor(caps, out) {
60
57
  this.caps = caps;
61
58
  this.out = out;
62
- this.colors = pc.createColors(caps.color);
63
- this.highlighter = createStreamHighlighter(this.colors);
59
+ this.theme = resolveTheme(caps);
60
+ this.highlighter = createStreamHighlighter(this.theme);
64
61
  this.print = this.newPrinter();
65
62
  }
66
63
  newPrinter() {
@@ -115,7 +112,7 @@ export class TtyRenderer {
115
112
  const elapsed = this.phase !== null && this.caps.spinner
116
113
  ? Date.now() - this.phaseStartedAt
117
114
  : undefined;
118
- return composeStatusLine(this.progressState, this.phase, elapsed);
115
+ return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
119
116
  }
120
117
  /** Redraw the live line from current state, or hide it when there is none. */
121
118
  refresh() {
@@ -138,16 +135,19 @@ export class TtyRenderer {
138
135
  }
139
136
  drawLine(text) {
140
137
  this.lineVisible = true;
138
+ const frames = this.theme.glyph.spinnerFrames;
141
139
  const glyph = this.caps.spinner
142
- ? FRAMES[this.frame % FRAMES.length]
143
- : STATIC_FRAME;
140
+ ? frames[this.frame % frames.length]
141
+ : this.theme.glyph.spinnerStatic;
144
142
  // Reserve glyph + space; truncate so the live line can never soft-wrap.
145
143
  const room = Math.max(1, this.caps.width - 2);
146
- const line = text.length > room ? text.slice(0, Math.max(0, room - 1)) + "…" : text;
147
- this.out.write(`${CLEAR_LINE}${this.colors.cyan(glyph)} ${this.colors.dim(line)}`);
144
+ const line = text.length > room
145
+ ? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
146
+ : text;
147
+ this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
148
148
  }
149
149
  beginTurn() {
150
- this.highlighter = createStreamHighlighter(this.colors);
150
+ this.highlighter = createStreamHighlighter(this.theme);
151
151
  this.print = this.newPrinter();
152
152
  this.wroteInSegment = false;
153
153
  }
@@ -169,13 +169,15 @@ export class TtyRenderer {
169
169
  if (this.closed)
170
170
  return;
171
171
  const room = Math.max(1, this.caps.width);
172
- const line = text.length > room ? text.slice(0, room - 1) + "…" : text;
173
- this.commit(this.colors.dim(line) + "\n");
172
+ const line = text.length > room
173
+ ? text.slice(0, room - 1) + this.theme.glyph.ellipsis
174
+ : text;
175
+ this.commit(this.theme.muted(line) + "\n");
174
176
  }
175
177
  preview(preview) {
176
178
  if (this.closed)
177
179
  return;
178
- const block = renderActionPreview(preview, this.colors);
180
+ const block = renderActionPreview(preview, this.theme);
179
181
  if (block)
180
182
  this.commit(block + "\n");
181
183
  }
@@ -237,7 +239,8 @@ export class TtyRenderer {
237
239
  // honest even with CRUXY_NO_SPINNER; shown only once it means something.
238
240
  const elapsed = started === null ? 0 : Date.now() - started.at;
239
241
  const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
240
- this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
242
+ const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
243
+ this.note(`${mark} ${event.label}${suffix}`);
241
244
  }
242
245
  promptResolved() {
243
246
  if (this.closed)
@@ -1,4 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
+ import type { Theme } from "../theme/index.js";
2
3
  /**
3
4
  * The streaming render seam (U.2): the agent loop talks to a
4
5
  * {@link StreamRenderer}, never to raw stdout. Two implementations exist —
@@ -21,6 +22,9 @@ export interface RenderCapabilities {
21
22
  cursor: boolean;
22
23
  /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
24
  spinner: boolean;
25
+ /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
26
+ * independent of `color`. Drives the theme's glyph table, not its stylers. */
27
+ unicode: boolean;
24
28
  /** Terminal columns; 80 when unknown (non-TTY). */
25
29
  width: number;
26
30
  }
@@ -97,6 +101,9 @@ export type ToolLifecycleEvent = {
97
101
  */
98
102
  export interface StreamRenderer {
99
103
  readonly caps: RenderCapabilities;
104
+ /** The one resolved design system (U.1) — glyphs/roles for chrome a
105
+ * surface emits through this renderer (e.g. the subagent trail notes). */
106
+ readonly theme: Theme;
100
107
  /** Start a user turn: reset leading-newline trim and code-fence state. */
101
108
  beginTurn(): void;
102
109
  /**
@@ -67,7 +67,9 @@ export class SubagentOrchestrator {
67
67
  },
68
68
  };
69
69
  const label = taskLabel(spec.task);
70
- deps.renderer?.note(`⏵ subagent: ${label}`);
70
+ if (deps.renderer) {
71
+ deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
72
+ }
71
73
  deps.renderer?.setPhase({ kind: "subagent", label });
72
74
  // The isolation seam: a brand-new history seeded with ONLY the task. The
73
75
  // parent's messages are never in scope here, and this array dies with the
@@ -96,7 +98,9 @@ export class SubagentOrchestrator {
96
98
  deps.renderer?.setPhase(null);
97
99
  throw err;
98
100
  }
99
- deps.renderer?.note(`✗ subagent failed: ${label}`);
101
+ if (deps.renderer) {
102
+ deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
103
+ }
100
104
  deps.renderer?.setPhase(null);
101
105
  return {
102
106
  status: "failed",
@@ -123,7 +127,9 @@ export class SubagentOrchestrator {
123
127
  usage: run.usage,
124
128
  };
125
129
  if (run.stop === "completed") {
126
- this.deps.renderer?.note(`✓ subagent done: ${label}`);
130
+ const r = this.deps.renderer;
131
+ if (r)
132
+ r.note(`${r.theme.glyph.success} subagent done: ${label}`);
127
133
  return { status: "done", ...base };
128
134
  }
129
135
  // Both cap paths are the same outcome for the parent: a truncated, partial
@@ -132,7 +138,9 @@ export class SubagentOrchestrator {
132
138
  const reason = run.stop === "budget"
133
139
  ? (run.stopReason ?? "budget cap reached")
134
140
  : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
135
- this.deps.renderer?.note(`✗ subagent stopped (budget): ${label}`);
141
+ const r = this.deps.renderer;
142
+ if (r)
143
+ r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
136
144
  return {
137
145
  status: "budget-exceeded",
138
146
  ...base,
@@ -195,12 +203,16 @@ function lastAssistantText(messages) {
195
203
  */
196
204
  class SubagentRenderer {
197
205
  caps;
206
+ theme;
198
207
  inner;
199
208
  label;
209
+ prefix;
200
210
  constructor(inner, label) {
201
211
  this.inner = inner;
202
212
  this.label = label;
203
213
  this.caps = inner.caps;
214
+ this.theme = inner.theme;
215
+ this.prefix = `subagent ${inner.theme.glyph.sep} `;
204
216
  }
205
217
  /** Turn framing belongs to the parent's turn — the child's is dropped. */
206
218
  beginTurn() { }
@@ -209,7 +221,7 @@ class SubagentRenderer {
209
221
  write() { }
210
222
  endSegment() { }
211
223
  note(text) {
212
- this.inner.note(`subagent · ${text}`);
224
+ this.inner.note(`${this.prefix}${text}`);
213
225
  }
214
226
  preview(preview) {
215
227
  this.inner.preview(preview);
@@ -231,7 +243,10 @@ class SubagentRenderer {
231
243
  /** The plan executor owns the progress register (C.31) — never the child. */
232
244
  progress() { }
233
245
  toolLifecycle(event) {
234
- this.inner.toolLifecycle({ ...event, label: `subagent · ${event.label}` });
246
+ this.inner.toolLifecycle({
247
+ ...event,
248
+ label: `${this.prefix}${event.label}`,
249
+ });
235
250
  }
236
251
  promptResolved() {
237
252
  this.inner.promptResolved();
@@ -0,0 +1,3 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { TestCommand } from "./types.js";
3
+ export declare function detectTestCommand(cwd: string, config: CruxyConfig): TestCommand | null;
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Resolve the project's test command (C.13). Order: explicit `test.command`
5
+ * config, then package.json `scripts.test`. Returns `null` when neither
6
+ * yields one — cruxy NEVER invents a test command; the caller surfaces the
7
+ * coded not-configured error instead.
8
+ */
9
+ /** npm's scaffold placeholder is an error message, not a test suite. */
10
+ const NPM_PLACEHOLDER = /no test specified/i;
11
+ export function detectTestCommand(cwd, config) {
12
+ if (config.test.command) {
13
+ return { command: config.test.command, source: "config" };
14
+ }
15
+ const script = readTestScript(cwd);
16
+ if (script === null)
17
+ return null;
18
+ return { command: `${packageManager(cwd)} test`, source: "package-json" };
19
+ }
20
+ /** The package.json `scripts.test` value, or null if absent/placeholder/unreadable. */
21
+ function readTestScript(cwd) {
22
+ try {
23
+ const raw = readFileSync(path.join(cwd, "package.json"), "utf8");
24
+ const pkg = JSON.parse(raw);
25
+ const script = pkg.scripts?.test;
26
+ if (typeof script !== "string" || script.trim() === "")
27
+ return null;
28
+ if (NPM_PLACEHOLDER.test(script))
29
+ return null;
30
+ return script;
31
+ }
32
+ catch {
33
+ // No package.json / unparseable → not detected (never a crash).
34
+ return null;
35
+ }
36
+ }
37
+ /** Pick the package manager by lockfile; npm when nothing identifies one. */
38
+ function packageManager(cwd) {
39
+ if (existsSync(path.join(cwd, "pnpm-lock.yaml")))
40
+ return "pnpm";
41
+ if (existsSync(path.join(cwd, "yarn.lock")))
42
+ return "yarn";
43
+ return "npm";
44
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./detect.js";
3
+ export * from "./runner.js";
4
+ export * from "./parse.js";
5
+ export * from "./run-tests-tool.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./detect.js";
3
+ export * from "./runner.js";
4
+ export * from "./parse.js";
5
+ export * from "./run-tests-tool.js";
@@ -0,0 +1,33 @@
1
+ import type { FailureParser, TestFailure } from "./types.js";
2
+ /**
3
+ * Best-effort failure extraction (C.13). Two conservative parsers ship —
4
+ * vitest-style and jest-style — behind the pluggable {@link FailureParser}
5
+ * seam. The contract: extract only what a pattern positively recognizes;
6
+ * when nothing matches, return NOTHING (the caller falls back to the raw
7
+ * tail). Parsers never decide pass/fail and never invent counts — `message`
8
+ * fields are verbatim runner output, not summaries we authored.
9
+ */
10
+ /**
11
+ * Vitest: per-test failure lines (`FAIL src/x.test.ts > suite > name`, also
12
+ * `×`/`✗` markers) and the `Tests 2 failed | 570 passed (572)` summary.
13
+ * Plain file-level `FAIL <file>` lines are deliberately left to the jest
14
+ * parser, which owns the file+bullet association.
15
+ */
16
+ export declare const parseVitest: FailureParser;
17
+ /**
18
+ * Jest: `FAIL <file>` headers with `● <name>` bullets underneath (the bullet's
19
+ * following indented lines are its message, verbatim), and the
20
+ * `Tests: …, N total` summary. A FAIL header with no bullets (e.g. a suite
21
+ * that failed to load) becomes one file-level failure.
22
+ */
23
+ export declare const parseJest: FailureParser;
24
+ /** Parser order: most-specific first. The pluggable seam for new frameworks. */
25
+ export declare const defaultParsers: readonly FailureParser[];
26
+ /**
27
+ * Run the parser chain; the first parser that recognizes anything wins.
28
+ * Nothing recognized → empty failures, no total — the raw tail is the result.
29
+ */
30
+ export declare function parseFailures(output: string): {
31
+ failures: TestFailure[];
32
+ total?: number;
33
+ };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Best-effort failure extraction (C.13). Two conservative parsers ship —
3
+ * vitest-style and jest-style — behind the pluggable {@link FailureParser}
4
+ * seam. The contract: extract only what a pattern positively recognizes;
5
+ * when nothing matches, return NOTHING (the caller falls back to the raw
6
+ * tail). Parsers never decide pass/fail and never invent counts — `message`
7
+ * fields are verbatim runner output, not summaries we authored.
8
+ */
9
+ /**
10
+ * Vitest: per-test failure lines (`FAIL src/x.test.ts > suite > name`, also
11
+ * `×`/`✗` markers) and the `Tests 2 failed | 570 passed (572)` summary.
12
+ * Plain file-level `FAIL <file>` lines are deliberately left to the jest
13
+ * parser, which owns the file+bullet association.
14
+ */
15
+ export const parseVitest = (output) => {
16
+ const failures = [];
17
+ for (const line of output.split("\n")) {
18
+ const match = /^\s*(?:FAIL|✗|×)\s+(\S+)\s+>\s+(.+?)\s*$/.exec(line);
19
+ if (match) {
20
+ failures.push({
21
+ name: match[2],
22
+ message: line.trim(),
23
+ file: match[1],
24
+ });
25
+ }
26
+ }
27
+ attachLines(failures, output);
28
+ const total = /^\s*Tests\s+.*\((\d+)\)\s*$/m.exec(output);
29
+ return {
30
+ failures,
31
+ ...(total ? { total: Number(total[1]) } : {}),
32
+ };
33
+ };
34
+ /**
35
+ * Jest: `FAIL <file>` headers with `● <name>` bullets underneath (the bullet's
36
+ * following indented lines are its message, verbatim), and the
37
+ * `Tests: …, N total` summary. A FAIL header with no bullets (e.g. a suite
38
+ * that failed to load) becomes one file-level failure.
39
+ */
40
+ export const parseJest = (output) => {
41
+ const failures = [];
42
+ const lines = output.split("\n");
43
+ let currentFile;
44
+ const filesWithBullets = new Set();
45
+ const bareFiles = [];
46
+ for (let i = 0; i < lines.length; i++) {
47
+ const fail = /^\s*FAIL\s+(\S+)\s*$/.exec(lines[i]);
48
+ if (fail) {
49
+ currentFile = fail[1];
50
+ bareFiles.push(fail[1]);
51
+ continue;
52
+ }
53
+ const bullet = /^\s*●\s+(.+?)\s*$/.exec(lines[i]);
54
+ if (bullet) {
55
+ failures.push({
56
+ name: bullet[1],
57
+ message: bulletMessage(lines, i),
58
+ ...(currentFile ? { file: currentFile } : {}),
59
+ });
60
+ if (currentFile)
61
+ filesWithBullets.add(currentFile);
62
+ }
63
+ }
64
+ // A failed suite with no per-test bullets is still one honest failure.
65
+ for (const file of bareFiles) {
66
+ if (!filesWithBullets.has(file)) {
67
+ failures.push({ name: file, message: `FAIL ${file}`, file });
68
+ }
69
+ }
70
+ attachLines(failures, output);
71
+ const total = /^Tests:.*?(\d+)\s+total\s*$/m.exec(output);
72
+ return {
73
+ failures,
74
+ ...(total ? { total: Number(total[1]) } : {}),
75
+ };
76
+ };
77
+ /**
78
+ * Up to three non-blank lines under a jest bullet — its verbatim message.
79
+ * Jest separates the bullet from its detail with a blank line, so leading
80
+ * blanks are skipped; collection stops at the next blank, the next bullet,
81
+ * or the cap.
82
+ */
83
+ function bulletMessage(lines, bulletIndex) {
84
+ const body = [];
85
+ for (let j = bulletIndex + 1; j < lines.length && body.length < 3; j++) {
86
+ const text = lines[j].trim();
87
+ if (text === "") {
88
+ if (body.length === 0)
89
+ continue; // the separator blank under the bullet
90
+ break;
91
+ }
92
+ if (/^\s*●\s+/.test(lines[j]))
93
+ break;
94
+ body.push(text);
95
+ }
96
+ return body.length > 0 ? body.join("\n") : lines[bulletIndex].trim();
97
+ }
98
+ /**
99
+ * Attach `line` to failures whose file appears in a `file:line:col` stack
100
+ * reference anywhere in the output (vitest `❯ file:39:5`, jest
101
+ * `at … (file:12:15)`). First reference per file wins; no reference → no line.
102
+ */
103
+ function attachLines(failures, output) {
104
+ if (failures.length === 0)
105
+ return;
106
+ const firstLineFor = new Map();
107
+ for (const match of output.matchAll(/([^\s():]+):(\d+):\d+/g)) {
108
+ if (!firstLineFor.has(match[1])) {
109
+ firstLineFor.set(match[1], Number(match[2]));
110
+ }
111
+ }
112
+ for (const failure of failures) {
113
+ if (failure.file === undefined || failure.line !== undefined)
114
+ continue;
115
+ const line = firstLineFor.get(failure.file);
116
+ if (line !== undefined)
117
+ failure.line = line;
118
+ }
119
+ }
120
+ /** Parser order: most-specific first. The pluggable seam for new frameworks. */
121
+ export const defaultParsers = [
122
+ parseVitest,
123
+ parseJest,
124
+ ];
125
+ /**
126
+ * Run the parser chain; the first parser that recognizes anything wins.
127
+ * Nothing recognized → empty failures, no total — the raw tail is the result.
128
+ */
129
+ export function parseFailures(output) {
130
+ for (const parser of defaultParsers) {
131
+ const result = parser(output);
132
+ if (result.failures.length > 0 || result.total !== undefined) {
133
+ return result;
134
+ }
135
+ }
136
+ return { failures: [] };
137
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import type { Tool, ToolContext } from "../tools/types.js";
3
+ import type { TestCommand, TestRunner } from "./types.js";
4
+ /**
5
+ * The `run_tests` tool (C.13): execute the project's test suite and return a
6
+ * structured result the model can iterate on (edit → re-run → repeat). The
7
+ * iteration loop itself is the ordinary agent loop; this module contributes
8
+ * the two guarantees that make it trustworthy — honest green (exit-code-only)
9
+ * and a hard cap on consecutive failing runs.
10
+ */
11
+ /**
12
+ * Counts consecutive FAILING test executions; a green run resets it. When the
13
+ * count reaches the cap, the next attempt is refused with the coded
14
+ * CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
15
+ * resets — so the episode ends loudly, but a deliberate later attempt (a new
16
+ * user instruction) starts fresh rather than finding a permanently dead tool.
17
+ * Per-turn work stays bounded regardless via `agent.maxIterations`.
18
+ */
19
+ export declare class TestIterationBudget {
20
+ private failedRuns;
21
+ /** Runs already spent in the current failing streak. */
22
+ get spent(): number;
23
+ /** True when the next run must be refused; resets the streak as it trips. */
24
+ trip(maxIterations: number): boolean;
25
+ record(passed: boolean): void;
26
+ }
27
+ declare const parameters: z.ZodObject<{
28
+ command: z.ZodOptional<z.ZodString>;
29
+ }, "strip", z.ZodTypeAny, {
30
+ command?: string | undefined;
31
+ }, {
32
+ command?: string | undefined;
33
+ }>;
34
+ export interface RunTestsToolDeps {
35
+ /** Execution seam (tests inject a fake; default spawns the real command). */
36
+ runner?: TestRunner;
37
+ /** Detection seam (defaults to config + package.json detection). */
38
+ detect?: (ctx: ToolContext) => TestCommand | null;
39
+ }
40
+ /** Build the `run_tests` tool. One instance = one session's iteration budget. */
41
+ export declare function makeRunTestsTool(deps?: RunTestsToolDeps): Tool<typeof parameters>;
42
+ export {};
@@ -0,0 +1,128 @@
1
+ import { z } from "zod";
2
+ import { ErrorCode } from "../errors/index.js";
3
+ import { detectTestCommand } from "./detect.js";
4
+ import { CommandTestRunner } from "./runner.js";
5
+ /**
6
+ * The `run_tests` tool (C.13): execute the project's test suite and return a
7
+ * structured result the model can iterate on (edit → re-run → repeat). The
8
+ * iteration loop itself is the ordinary agent loop; this module contributes
9
+ * the two guarantees that make it trustworthy — honest green (exit-code-only)
10
+ * and a hard cap on consecutive failing runs.
11
+ */
12
+ /**
13
+ * Counts consecutive FAILING test executions; a green run resets it. When the
14
+ * count reaches the cap, the next attempt is refused with the coded
15
+ * CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
16
+ * resets — so the episode ends loudly, but a deliberate later attempt (a new
17
+ * user instruction) starts fresh rather than finding a permanently dead tool.
18
+ * Per-turn work stays bounded regardless via `agent.maxIterations`.
19
+ */
20
+ export class TestIterationBudget {
21
+ failedRuns = 0;
22
+ /** Runs already spent in the current failing streak. */
23
+ get spent() {
24
+ return this.failedRuns;
25
+ }
26
+ /** True when the next run must be refused; resets the streak as it trips. */
27
+ trip(maxIterations) {
28
+ if (this.failedRuns < maxIterations)
29
+ return false;
30
+ this.failedRuns = 0;
31
+ return true;
32
+ }
33
+ record(passed) {
34
+ this.failedRuns = passed ? 0 : this.failedRuns + 1;
35
+ }
36
+ }
37
+ const parameters = z.object({
38
+ command: z
39
+ .string()
40
+ .min(1)
41
+ .optional()
42
+ .describe("Override the detected test command (still requires approval). " +
43
+ "Omit to use the project's configured/detected command."),
44
+ });
45
+ /** The wire shape fed back to the model — structured, bounded, honest. */
46
+ function renderResult(result, command, iteration) {
47
+ return JSON.stringify({
48
+ passed: result.passed,
49
+ exitCode: result.exitCode,
50
+ durationMs: result.durationMs,
51
+ command: command.command,
52
+ ...(result.total !== undefined ? { total: result.total } : {}),
53
+ failures: result.failures,
54
+ iteration,
55
+ output: result.output,
56
+ outputTruncated: result.outputTruncated,
57
+ });
58
+ }
59
+ /** Build the `run_tests` tool. One instance = one session's iteration budget. */
60
+ export function makeRunTestsTool(deps = {}) {
61
+ const runner = deps.runner ?? new CommandTestRunner();
62
+ const detect = deps.detect ??
63
+ ((ctx) => detectTestCommand(ctx.cwd, ctx.config));
64
+ const budget = new TestIterationBudget();
65
+ return {
66
+ name: "run_tests",
67
+ description: "Run the project's test suite and get a structured result: passed (from the exit code), " +
68
+ "extracted failures with file/line where recognizable, and the output tail. " +
69
+ "Use it to verify changes: run, read the failures, fix, re-run. The edit→re-run loop is " +
70
+ "capped — when the iteration limit trips, stop, summarize the remaining failures, and ask the user.",
71
+ parameters,
72
+ async execute(input, ctx) {
73
+ // Resolve the command first: detection failure needs no approval and
74
+ // must be a coded, actionable error — never an invented command.
75
+ let resolved;
76
+ if (input.command !== undefined) {
77
+ resolved = { command: input.command, source: "override" };
78
+ }
79
+ else {
80
+ const detected = detect(ctx);
81
+ if (detected === null)
82
+ return { ok: false, error: NOT_FOUND };
83
+ resolved = detected;
84
+ }
85
+ // Iteration cap BEFORE the gate and the run: a refused attempt executes
86
+ // nothing and costs nothing.
87
+ const max = ctx.config.test.maxIterations;
88
+ if (budget.trip(max)) {
89
+ return {
90
+ ok: false,
91
+ error: `${ErrorCode.TestIterationLimit}: tests are still failing after ${max} run${max === 1 ? "" : "s"}. ` +
92
+ "Stop iterating. Summarize the remaining failures and what you tried, then ask the user how to proceed.",
93
+ };
94
+ }
95
+ // U.3 gate (destructive tier, exact-command grant scope). A thrown
96
+ // CRUXY_E_APPROVAL_REQUIRED (non-interactive) propagates — do not catch.
97
+ const decision = await ctx.requestApproval({
98
+ kind: "test",
99
+ command: resolved.command,
100
+ });
101
+ if (!decision.allow) {
102
+ return {
103
+ ok: false,
104
+ error: decision.feedback ?? "test run denied by the user",
105
+ };
106
+ }
107
+ const result = await runner.run(resolved.command, {
108
+ cwd: ctx.cwd,
109
+ timeoutMs: ctx.config.shell.timeoutMs,
110
+ captureBytes: ctx.config.test.captureBytes,
111
+ });
112
+ budget.record(result.passed);
113
+ const payload = renderResult(result, resolved, {
114
+ run: result.passed ? 0 : budget.spent,
115
+ max,
116
+ });
117
+ // Failing tests are an is_error result so the U.4 trail note reads ✗ —
118
+ // the payload is identical either way; the model reasons over both.
119
+ return result.passed
120
+ ? { ok: true, output: payload }
121
+ : { ok: false, error: payload };
122
+ },
123
+ };
124
+ }
125
+ /** The coded not-configured message, actionable for model and user alike. */
126
+ const NOT_FOUND = `${ErrorCode.TestCommandNotFound}: no test command found — package.json has no usable ` +
127
+ "`scripts.test` and `test.command` is not configured. Ask the user to set `test.command` " +
128
+ '(e.g. `cruxy config set test.command "pnpm test"`); do not guess a command.';
@@ -0,0 +1,26 @@
1
+ import type { TestRunner, TestRunOptions, TestRunResult } from "./types.js";
2
+ /**
3
+ * The shipped {@link TestRunner}: spawn the command via the system shell (the
4
+ * same detached-group + kill-tree discipline as `run_command`), capture a
5
+ * TAIL-biased, byte-capped transcript (failures live at the end of test
6
+ * output), and derive `passed` from the exit code — the only source of truth.
7
+ * A timeout, a signal kill, or a spawn error is a *failed result*, never a
8
+ * thrown exception and never a fabricated success.
9
+ */
10
+ export declare class CommandTestRunner implements TestRunner {
11
+ run(command: string, opts: TestRunOptions): Promise<TestRunResult>;
12
+ }
13
+ /**
14
+ * A rolling, tail-biased capture: whole chunks are dropped from the FRONT
15
+ * once the byte cap is exceeded, so the end of the output — where test
16
+ * runners print their failure summaries — always survives.
17
+ */
18
+ export declare class TailCapture {
19
+ private readonly cap;
20
+ private chunks;
21
+ private bytes;
22
+ truncated: boolean;
23
+ constructor(cap: number);
24
+ push(buf: Buffer): void;
25
+ text(): string;
26
+ }