@cruxy/cli 0.28.0 → 0.28.2

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.
@@ -51,9 +51,9 @@ async function driveLoop(args, renderer, routed) {
51
51
  platform: process.platform,
52
52
  date: new Date().toISOString().slice(0, 10),
53
53
  model: `${config.model.provider}/${config.model.model}`,
54
- tools: registry
55
- .list()
56
- .map((tool) => ({ name: tool.name, description: tool.description })),
54
+ // Names only: each tool's description/parameters reach the model as its wire
55
+ // schema, so the prompt's Tools section lists just the roster (see prompts.ts).
56
+ tools: registry.list().map((tool) => ({ name: tool.name })),
57
57
  git: args.git ?? null,
58
58
  projectInstructions: args.projectInstructions ?? null,
59
59
  recalledMemory: args.recalledMemory ?? null,
@@ -8,7 +8,6 @@
8
8
  */
9
9
  export interface ToolSummary {
10
10
  name: string;
11
- description: string;
12
11
  }
13
12
  export interface PromptContext {
14
13
  /** Absolute working directory the agent is rooted in. */
@@ -74,8 +74,13 @@ function renderTools(tools) {
74
74
  if (tools.length === 0) {
75
75
  return "## Tools\nNo tools are available this session; respond in text only.";
76
76
  }
77
- const list = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
78
- return `## Tools\nYou have these tools available. Use them — don't ask the user to run things you can run yourself:\n${list}`;
77
+ // Names only. Each tool's description and parameter schema already reach the
78
+ // model as its wire spec (ToolRegistry.specs the provider's tools field), so
79
+ // repeating the descriptions here just duplicated them on every first turn (and
80
+ // every turn on uncached providers). The roster and the "use them" nudge below
81
+ // are the parts the wire schema does NOT carry.
82
+ const names = tools.map((t) => t.name).join(", ");
83
+ return `## Tools\nYou have these tools available (each one's parameters and description are in its schema). Use them — don't ask the user to run things you can run yourself:\n${names}`;
79
84
  }
80
85
  /** Assemble the full system prompt for a session. */
81
86
  export function buildSystemPrompt(ctx) {
@@ -99,6 +99,12 @@ export class Session {
99
99
  * history is unaffected.
100
100
  */
101
101
  async send(userPrompt, renderer) {
102
+ // Per-turn tool lifecycle (C.13): re-arm any per-episode tool state (e.g. the
103
+ // run_tests consecutive-failure breaker) at the top of the turn, so a fresh
104
+ // user instruction starts clean. The breaker still latches across the many
105
+ // model iterations WITHIN this turn — it just never leaks into the next one.
106
+ for (const tool of this.args.registry.list())
107
+ tool.onTurnStart?.();
102
108
  this.messages.push({ role: "user", content: userPrompt });
103
109
  // Usage telemetry (C.22): one collector per run. `onReq` is threaded into
104
110
  // every real model request this turn drives — the main loop, compaction, and
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { loadConfig, resolveApiKey } from "../../config/index.js";
4
- import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
4
+ import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
5
5
  import { createRenderer } from "../../render/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { summarizeRuns, renderSummary, } from "../../usage/index.js";
@@ -217,8 +217,9 @@ export function runCommand() {
217
217
  // Provider/network/auth failures propagate to the top-level boundary,
218
218
  // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
219
219
  // the matching code — a one-shot run must fail non-zero on error.
220
+ let result;
220
221
  try {
221
- const result = await session.send(prompt, renderer);
222
+ result = await session.send(prompt, renderer);
222
223
  logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
223
224
  `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
224
225
  }
@@ -235,12 +236,26 @@ export function runCommand() {
235
236
  await resetMcpServices();
236
237
  }
237
238
  // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
238
- // cost (only when priced). Printed after the live region is torn down.
239
- // Reaches here only on success a thrown run propagates past it and
240
- // never affects the run's outcome.
239
+ // cost (only when priced). Printed after the live region is torn down, for
240
+ // BOTH a completed run and one that gave up below the tokens burned are
241
+ // useful either way. A thrown run (provider error) propagates past it.
241
242
  if (config.usage.enabled && session.lastRun) {
242
243
  printRunUsage(session.lastRun, config);
243
244
  }
245
+ // Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
246
+ // iteration cap or a token budget (or was cancelled) MUST exit non-zero —
247
+ // otherwise CI reads a gave-up run as success. The partial history already
248
+ // streamed to stdout stands; the boundary prints the coded reason to stderr
249
+ // and exits with CRUXY_E_AGENT_INCOMPLETE's code. `result` is always set
250
+ // here — a thrown send would have propagated past this point.
251
+ if (result && result.stop !== "completed") {
252
+ throw agentIncomplete({
253
+ stop: result.stop,
254
+ iterations: result.iterations,
255
+ reason: result.stopReason,
256
+ maxIterations: config.agent.maxIterations,
257
+ });
258
+ }
244
259
  });
245
260
  }
246
261
  /** Render the just-finished run's usage as a single themed line (C.22). */
@@ -266,20 +266,20 @@ export declare const SubagentConfigSchema: z.ZodObject<{
266
266
  /** Optional wall-clock cap; unset means no time limit. */
267
267
  timeoutMs: z.ZodOptional<z.ZodNumber>;
268
268
  }, "strict", z.ZodTypeAny, {
269
- maxTokens: number;
270
269
  maxIterations: number;
270
+ maxTokens: number;
271
271
  timeoutMs?: number | undefined;
272
272
  }, {
273
273
  timeoutMs?: number | undefined;
274
- maxTokens?: number | undefined;
275
274
  maxIterations?: number | undefined;
275
+ maxTokens?: number | undefined;
276
276
  }>>;
277
277
  }, "strict", z.ZodTypeAny, {
278
278
  maxDepth: number;
279
279
  maxConcurrency: number;
280
280
  defaultBudget: {
281
- maxTokens: number;
282
281
  maxIterations: number;
282
+ maxTokens: number;
283
283
  timeoutMs?: number | undefined;
284
284
  };
285
285
  }, {
@@ -287,8 +287,8 @@ export declare const SubagentConfigSchema: z.ZodObject<{
287
287
  maxConcurrency?: number | undefined;
288
288
  defaultBudget?: {
289
289
  timeoutMs?: number | undefined;
290
- maxTokens?: number | undefined;
291
290
  maxIterations?: number | undefined;
291
+ maxTokens?: number | undefined;
292
292
  } | undefined;
293
293
  }>;
294
294
  /**
@@ -1217,20 +1217,20 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1217
1217
  /** Optional wall-clock cap; unset means no time limit. */
1218
1218
  timeoutMs: z.ZodOptional<z.ZodNumber>;
1219
1219
  }, "strict", z.ZodTypeAny, {
1220
- maxTokens: number;
1221
1220
  maxIterations: number;
1221
+ maxTokens: number;
1222
1222
  timeoutMs?: number | undefined;
1223
1223
  }, {
1224
1224
  timeoutMs?: number | undefined;
1225
- maxTokens?: number | undefined;
1226
1225
  maxIterations?: number | undefined;
1226
+ maxTokens?: number | undefined;
1227
1227
  }>>;
1228
1228
  }, "strict", z.ZodTypeAny, {
1229
1229
  maxDepth: number;
1230
1230
  maxConcurrency: number;
1231
1231
  defaultBudget: {
1232
- maxTokens: number;
1233
1232
  maxIterations: number;
1233
+ maxTokens: number;
1234
1234
  timeoutMs?: number | undefined;
1235
1235
  };
1236
1236
  }, {
@@ -1238,8 +1238,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1238
1238
  maxConcurrency?: number | undefined;
1239
1239
  defaultBudget?: {
1240
1240
  timeoutMs?: number | undefined;
1241
- maxTokens?: number | undefined;
1242
1241
  maxIterations?: number | undefined;
1242
+ maxTokens?: number | undefined;
1243
1243
  } | undefined;
1244
1244
  }>>;
1245
1245
  jobs: z.ZodDefault<z.ZodObject<{
@@ -1720,8 +1720,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1720
1720
  maxDepth: number;
1721
1721
  maxConcurrency: number;
1722
1722
  defaultBudget: {
1723
- maxTokens: number;
1724
1723
  maxIterations: number;
1724
+ maxTokens: number;
1725
1725
  timeoutMs?: number | undefined;
1726
1726
  };
1727
1727
  };
@@ -1873,8 +1873,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1873
1873
  maxConcurrency?: number | undefined;
1874
1874
  defaultBudget?: {
1875
1875
  timeoutMs?: number | undefined;
1876
- maxTokens?: number | undefined;
1877
1876
  maxIterations?: number | undefined;
1877
+ maxTokens?: number | undefined;
1878
1878
  } | undefined;
1879
1879
  } | undefined;
1880
1880
  model?: {
@@ -362,6 +362,22 @@ export declare function webFetchFailed(url: string, underlying?: unknown): Cruxy
362
362
  */
363
363
  export declare function webBlockedHost(url: string, reason: string): CruxyError;
364
364
  export declare function internal(underlying?: unknown): CruxyError;
365
+ /**
366
+ * A one-shot `cruxy run` ended without completing: the agent loop hit a hard stop
367
+ * — the iteration cap (`max_iterations`) or a token budget (`budget`) — or was
368
+ * cancelled (`aborted`). We fail loud with a non-zero exit so CI never reads a
369
+ * gave-up run as success; the partial work already streamed to stdout is preserved
370
+ * and the specific reason is named. `stop` is taken as a plain string so this
371
+ * (low-level) module needn't depend on the agent's result type.
372
+ */
373
+ export declare function agentIncomplete(info: {
374
+ stop: string;
375
+ iterations: number;
376
+ /** The loop's stopReason (the concrete budget message), when `stop === "budget"`. */
377
+ reason?: string;
378
+ /** The turn ceiling in force, surfaced for `max_iterations`. */
379
+ maxIterations?: number;
380
+ }): CruxyError;
365
381
  /**
366
382
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
367
383
  * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
@@ -1193,6 +1193,53 @@ export function internal(underlying) {
1193
1193
  underlying,
1194
1194
  });
1195
1195
  }
1196
+ // ── one-shot run outcome (exit 20) ───────────────────────────────────────────
1197
+ /**
1198
+ * A one-shot `cruxy run` ended without completing: the agent loop hit a hard stop
1199
+ * — the iteration cap (`max_iterations`) or a token budget (`budget`) — or was
1200
+ * cancelled (`aborted`). We fail loud with a non-zero exit so CI never reads a
1201
+ * gave-up run as success; the partial work already streamed to stdout is preserved
1202
+ * and the specific reason is named. `stop` is taken as a plain string so this
1203
+ * (low-level) module needn't depend on the agent's result type.
1204
+ */
1205
+ export function agentIncomplete(info) {
1206
+ const { stop, iterations, reason, maxIterations } = info;
1207
+ const turns = `${iterations} turn${iterations === 1 ? "" : "s"}`;
1208
+ const meta = { stop, iterations };
1209
+ if (stop === "budget") {
1210
+ return new CruxyError({
1211
+ code: ErrorCode.AgentIncomplete,
1212
+ title: "cruxy stopped: token budget reached before the task finished",
1213
+ cause: `${reason ?? "the token budget was exhausted"} (after ${turns}). The work so far is shown above.`,
1214
+ nextSteps: [
1215
+ "review the partial output above, then re-run with a narrower prompt",
1216
+ "raise or unset `agent.maxTokensPerTurn` if the task legitimately needs more",
1217
+ ],
1218
+ meta,
1219
+ });
1220
+ }
1221
+ if (stop === "aborted") {
1222
+ return new CruxyError({
1223
+ code: ErrorCode.AgentIncomplete,
1224
+ title: "cruxy stopped: the run was cancelled before the task finished",
1225
+ cause: `cancelled after ${turns}. The work so far is shown above.`,
1226
+ nextSteps: ["re-run to continue in a fresh session"],
1227
+ meta,
1228
+ });
1229
+ }
1230
+ // max_iterations (and any other non-completed stop, defensively).
1231
+ const cap = maxIterations !== undefined ? ` (${maxIterations})` : "";
1232
+ return new CruxyError({
1233
+ code: ErrorCode.AgentIncomplete,
1234
+ title: "cruxy stopped: iteration cap reached before the task finished",
1235
+ cause: `reached the turn limit${cap} after ${turns} without completing. The work so far is shown above.`,
1236
+ nextSteps: [
1237
+ "review the partial output above, then re-run with a narrower prompt",
1238
+ "raise `agent.maxIterations` if the task legitimately needs more turns",
1239
+ ],
1240
+ meta,
1241
+ });
1242
+ }
1196
1243
  /**
1197
1244
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
1198
1245
  * {@link CruxyError}, or `null` if it isn't one. Order matters: specific
@@ -201,6 +201,11 @@ export declare const ErrorCode: {
201
201
  * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
202
202
  * rather than pretending there are simply no jobs. */
203
203
  readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
204
+ /** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
205
+ * a hard stop (the iteration cap or a token budget) or was cancelled, rather
206
+ * than finishing on its own. Fail loud with a non-zero exit so CI never reads a
207
+ * gave-up run as success — the partial work already streamed to stdout stands. */
208
+ readonly AgentIncomplete: "CRUXY_E_AGENT_INCOMPLETE";
204
209
  };
205
210
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
206
211
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -222,6 +222,12 @@ export const ErrorCode = {
222
222
  * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
223
223
  * rather than pretending there are simply no jobs. */
224
224
  JobsDisabled: "CRUXY_E_JOBS_DISABLED",
225
+ // one-shot run outcome (exit 20)
226
+ /** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
227
+ * a hard stop (the iteration cap or a token budget) or was cancelled, rather
228
+ * than finishing on its own. Fail loud with a non-zero exit so CI never reads a
229
+ * gave-up run as success — the partial work already streamed to stdout stands. */
230
+ AgentIncomplete: "CRUXY_E_AGENT_INCOMPLETE",
225
231
  };
226
232
  /**
227
233
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -343,6 +349,10 @@ const EXIT_CODES = {
343
349
  [ErrorCode.JobLimit]: 2,
344
350
  [ErrorCode.JobNotFound]: 19,
345
351
  [ErrorCode.JobsDisabled]: 19,
352
+ // One-shot run outcome. A run that hit the iteration cap / token budget (or was
353
+ // cancelled) without completing gets its own greppable exit code, so CI can tell
354
+ // "the agent gave up" apart from a provider/auth/config failure.
355
+ [ErrorCode.AgentIncomplete]: 20,
346
356
  };
347
357
  /** The process exit code for an error code (defaults to 1 for safety). */
348
358
  export function exitCodeFor(code) {
@@ -19,14 +19,14 @@ declare const parameters: z.ZodObject<{
19
19
  }, "strip", z.ZodTypeAny, {
20
20
  task: string;
21
21
  root?: string | undefined;
22
- maxTokens?: number | undefined;
23
22
  maxIterations?: number | undefined;
23
+ maxTokens?: number | undefined;
24
24
  tools?: [string, ...string[]] | undefined;
25
25
  }, {
26
26
  task: string;
27
27
  root?: string | undefined;
28
- maxTokens?: number | undefined;
29
28
  maxIterations?: number | undefined;
29
+ maxTokens?: number | undefined;
30
30
  tools?: [string, ...string[]] | undefined;
31
31
  }>;
32
32
  /** Build the `run_in_background` tool bound to the session's job manager. */
@@ -15,13 +15,13 @@ declare const parameters: z.ZodObject<{
15
15
  maxTokens: z.ZodOptional<z.ZodNumber>;
16
16
  }, "strip", z.ZodTypeAny, {
17
17
  task: string;
18
- maxTokens?: number | undefined;
19
18
  maxIterations?: number | undefined;
19
+ maxTokens?: number | undefined;
20
20
  tools?: [string, ...string[]] | undefined;
21
21
  }, {
22
22
  task: string;
23
- maxTokens?: number | undefined;
24
23
  maxIterations?: number | undefined;
24
+ maxTokens?: number | undefined;
25
25
  tools?: [string, ...string[]] | undefined;
26
26
  }>;
27
27
  /** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
@@ -36,42 +36,42 @@ declare const batchParameters: z.ZodObject<{
36
36
  }, "strip", z.ZodTypeAny, {
37
37
  task: string;
38
38
  root?: string | undefined;
39
- maxTokens?: number | undefined;
40
39
  maxIterations?: number | undefined;
40
+ maxTokens?: number | undefined;
41
41
  tools?: [string, ...string[]] | undefined;
42
42
  }, {
43
43
  task: string;
44
44
  root?: string | undefined;
45
- maxTokens?: number | undefined;
46
45
  maxIterations?: number | undefined;
46
+ maxTokens?: number | undefined;
47
47
  tools?: [string, ...string[]] | undefined;
48
48
  }>, "atleastone">;
49
49
  }, "strip", z.ZodTypeAny, {
50
50
  tasks: [{
51
51
  task: string;
52
52
  root?: string | undefined;
53
- maxTokens?: number | undefined;
54
53
  maxIterations?: number | undefined;
54
+ maxTokens?: number | undefined;
55
55
  tools?: [string, ...string[]] | undefined;
56
56
  }, ...{
57
57
  task: string;
58
58
  root?: string | undefined;
59
- maxTokens?: number | undefined;
60
59
  maxIterations?: number | undefined;
60
+ maxTokens?: number | undefined;
61
61
  tools?: [string, ...string[]] | undefined;
62
62
  }[]];
63
63
  }, {
64
64
  tasks: [{
65
65
  task: string;
66
66
  root?: string | undefined;
67
- maxTokens?: number | undefined;
68
67
  maxIterations?: number | undefined;
68
+ maxTokens?: number | undefined;
69
69
  tools?: [string, ...string[]] | undefined;
70
70
  }, ...{
71
71
  task: string;
72
72
  root?: string | undefined;
73
- maxTokens?: number | undefined;
74
73
  maxIterations?: number | undefined;
74
+ maxTokens?: number | undefined;
75
75
  tools?: [string, ...string[]] | undefined;
76
76
  }[]];
77
77
  }>;
@@ -10,19 +10,37 @@ import type { TestCommand, TestRunner } from "./types.js";
10
10
  */
11
11
  /**
12
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.
13
+ * count reaches the cap, `trip` LATCHES: every further attempt is refused with
14
+ * the coded CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes) for the rest
15
+ * of the episode. Because a refused attempt never runs, {@link record} is never
16
+ * reached and the counter stays pinned at the cap the breaker cannot re-arm
17
+ * itself mid-episode. (This replaces a bug where `trip` zeroed the counter as it
18
+ * tripped, so the very next call ran again and the "cap" was a speed bump, not a
19
+ * stop: the model could burn the whole turn re-running a failing suite.)
20
+ *
21
+ * The latch is cleared by {@link reset}, called at the top of each user turn
22
+ * (see `Session.send`) — so a fresh instruction starts with the full budget,
23
+ * but the model cannot bypass the cap by simply calling the tool again within
24
+ * one episode. A green run mid-streak still re-arms the budget via {@link record}.
17
25
  * Per-turn work stays bounded regardless via `agent.maxIterations`.
18
26
  */
19
27
  export declare class TestIterationBudget {
20
28
  private failedRuns;
21
29
  /** Runs already spent in the current failing streak. */
22
30
  get spent(): number;
23
- /** True when the next run must be refused; resets the streak as it trips. */
31
+ /**
32
+ * True when the next run must be refused. Latches at the cap: once the streak
33
+ * reaches `maxIterations` this keeps returning true (the refused call never
34
+ * executes, so {@link record} never runs and the counter stays pinned) until
35
+ * {@link reset} clears it at the next user turn.
36
+ */
24
37
  trip(maxIterations: number): boolean;
25
38
  record(passed: boolean): void;
39
+ /**
40
+ * Clear the failing streak, re-arming the breaker. Called at the start of each
41
+ * user turn so a new instruction starts fresh; within one turn the latch holds.
42
+ */
43
+ reset(): void;
26
44
  }
27
45
  declare const parameters: z.ZodObject<{
28
46
  command: z.ZodOptional<z.ZodString>;
@@ -12,10 +12,18 @@ import { SandboxTestRunner } from "./sandbox-runner.js";
12
12
  */
13
13
  /**
14
14
  * Counts consecutive FAILING test executions; a green run resets it. When the
15
- * count reaches the cap, the next attempt is refused with the coded
16
- * CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes), and the counter
17
- * resets — so the episode ends loudly, but a deliberate later attempt (a new
18
- * user instruction) starts fresh rather than finding a permanently dead tool.
15
+ * count reaches the cap, `trip` LATCHES: every further attempt is refused with
16
+ * the coded CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes) for the rest
17
+ * of the episode. Because a refused attempt never runs, {@link record} is never
18
+ * reached and the counter stays pinned at the cap the breaker cannot re-arm
19
+ * itself mid-episode. (This replaces a bug where `trip` zeroed the counter as it
20
+ * tripped, so the very next call ran again and the "cap" was a speed bump, not a
21
+ * stop: the model could burn the whole turn re-running a failing suite.)
22
+ *
23
+ * The latch is cleared by {@link reset}, called at the top of each user turn
24
+ * (see `Session.send`) — so a fresh instruction starts with the full budget,
25
+ * but the model cannot bypass the cap by simply calling the tool again within
26
+ * one episode. A green run mid-streak still re-arms the budget via {@link record}.
19
27
  * Per-turn work stays bounded regardless via `agent.maxIterations`.
20
28
  */
21
29
  export class TestIterationBudget {
@@ -24,16 +32,25 @@ export class TestIterationBudget {
24
32
  get spent() {
25
33
  return this.failedRuns;
26
34
  }
27
- /** True when the next run must be refused; resets the streak as it trips. */
35
+ /**
36
+ * True when the next run must be refused. Latches at the cap: once the streak
37
+ * reaches `maxIterations` this keeps returning true (the refused call never
38
+ * executes, so {@link record} never runs and the counter stays pinned) until
39
+ * {@link reset} clears it at the next user turn.
40
+ */
28
41
  trip(maxIterations) {
29
- if (this.failedRuns < maxIterations)
30
- return false;
31
- this.failedRuns = 0;
32
- return true;
42
+ return this.failedRuns >= maxIterations;
33
43
  }
34
44
  record(passed) {
35
45
  this.failedRuns = passed ? 0 : this.failedRuns + 1;
36
46
  }
47
+ /**
48
+ * Clear the failing streak, re-arming the breaker. Called at the start of each
49
+ * user turn so a new instruction starts fresh; within one turn the latch holds.
50
+ */
51
+ reset() {
52
+ this.failedRuns = 0;
53
+ }
37
54
  }
38
55
  const parameters = z.object({
39
56
  command: z
@@ -69,6 +86,12 @@ export function makeRunTestsTool(deps = {}) {
69
86
  "Use it to verify changes: run, read the failures, fix, re-run. The edit→re-run loop is " +
70
87
  "capped — when the iteration limit trips, stop, summarize the remaining failures, and ask the user.",
71
88
  parameters,
89
+ // Per-turn lifecycle (C.13): a new user instruction re-arms the failing-run
90
+ // breaker. Within one turn the latch holds (the model can't bypass the cap by
91
+ // re-calling); across turns a deliberate retry starts fresh.
92
+ onTurnStart() {
93
+ budget.reset();
94
+ },
72
95
  async execute(input, ctx) {
73
96
  // Resolve the command first: detection failure needs no approval and
74
97
  // must be a coded, actionable error — never an invented command.
@@ -3,7 +3,22 @@ import { z } from "zod";
3
3
  import { contextWorkspace, isEscapingPattern, labelPath, resolveReadRoots, } from "./paths.js";
4
4
  /** Cap on returned paths — beyond this we truncate with a notice. */
5
5
  const MAX_RESULTS = 200;
6
- const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"];
6
+ // Dependencies, VCS internals, and conventional compiled-output dirs. The last
7
+ // group keeps gitignored build artifacts (e.g. dist/mcp.js and its .d.ts) from
8
+ // crowding out the real source file a pattern like `**/*mcp*` is looking for.
9
+ // These are glob patterns, not gitignore rules: tinyglobby's `ignore` matches
10
+ // globs, so honoring an actual .gitignore would mean reusing the indexer's
11
+ // gitignore matcher per root — out of proportion for these ad-hoc tools, and the
12
+ // indexed search path already honors .gitignore.
13
+ const DEFAULT_IGNORE = [
14
+ "**/node_modules/**",
15
+ "**/.git/**",
16
+ "**/dist/**",
17
+ "**/build/**",
18
+ "**/coverage/**",
19
+ "**/.next/**",
20
+ "**/out/**",
21
+ ];
7
22
  /**
8
23
  * Find files by glob pattern within the workspace. Read-only — no approval.
9
24
  * The pattern is constrained to a root (no absolute or `..` patterns) so glob
@@ -17,7 +32,7 @@ const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"];
17
32
  */
18
33
  export const globTool = {
19
34
  name: "glob",
20
- description: "Find files by glob pattern (e.g. 'src/**/*.ts') within the project, ignoring node_modules and .git. Returns paths relative to the project root.",
35
+ description: "Find files by glob pattern (e.g. 'src/**/*.ts') within the project, ignoring node_modules, .git, and common build-output dirs (dist, build, coverage, .next, out). Returns paths relative to the project root.",
21
36
  parameters: z.object({
22
37
  pattern: z
23
38
  .string()
@@ -28,7 +28,8 @@ declare const parameters: z.ZodObject<{
28
28
  * run_command (which is platform-dependent and routes through the approval gate).
29
29
  *
30
30
  * Files are enumerated with the same glob mechanism as the `glob` tool (so
31
- * node_modules and .git are always ignored), binary files are skipped, and the
31
+ * node_modules, .git, and build-output dirs are always ignored), binary files
32
+ * are skipped, and the
32
33
  * search is bounded by the same root boundary as every file tool.
33
34
  *
34
35
  * Multi-repo (C.26, Funnel B): with more than one declared root and no root-
@@ -10,7 +10,18 @@ const DEFAULT_MAX_RESULTS = 100;
10
10
  const MAX_LINE_LENGTH = 200;
11
11
  /** Bytes sniffed for a NUL to decide a file is binary and skip it. */
12
12
  const BINARY_SNIFF_BYTES = 8 * 1024;
13
- const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"];
13
+ // Dependencies, VCS internals, and conventional compiled-output dirs — kept in
14
+ // sync with glob's DEFAULT_IGNORE so both file tools skip build artifacts (see
15
+ // glob.ts for why these are static globs rather than a parsed .gitignore).
16
+ const DEFAULT_IGNORE = [
17
+ "**/node_modules/**",
18
+ "**/.git/**",
19
+ "**/dist/**",
20
+ "**/build/**",
21
+ "**/coverage/**",
22
+ "**/.next/**",
23
+ "**/out/**",
24
+ ];
14
25
  const parameters = z.object({
15
26
  pattern: z
16
27
  .string()
@@ -49,7 +60,8 @@ function firstSegmentIsRoot(ws, p) {
49
60
  * run_command (which is platform-dependent and routes through the approval gate).
50
61
  *
51
62
  * Files are enumerated with the same glob mechanism as the `glob` tool (so
52
- * node_modules and .git are always ignored), binary files are skipped, and the
63
+ * node_modules, .git, and build-output dirs are always ignored), binary files
64
+ * are skipped, and the
53
65
  * search is bounded by the same root boundary as every file tool.
54
66
  *
55
67
  * Multi-repo (C.26, Funnel B): with more than one declared root and no root-
@@ -61,7 +73,7 @@ function firstSegmentIsRoot(ws, p) {
61
73
  */
62
74
  export const grepFilesTool = {
63
75
  name: "grep_files",
64
- description: "Search file CONTENTS for a regular expression within the project (vs `glob`, which matches file NAMES). Returns matches as 'path:line: text', ignoring node_modules and .git. Read-only and requires no approval — prefer it over shelling out to grep/rg/find via run_command.",
76
+ description: "Search file CONTENTS for a regular expression within the project (vs `glob`, which matches file NAMES). Returns matches as 'path:line: text', ignoring node_modules, .git, and common build-output dirs (dist, build, coverage, .next, out). Read-only and requires no approval — prefer it over shelling out to grep/rg/find via run_command.",
65
77
  parameters,
66
78
  async execute(input, ctx) {
67
79
  // Compile the regex up front; an invalid pattern is a clean failure, not a throw.
@@ -241,6 +241,16 @@ export interface Tool<Schema extends ZodTypeAny = ZodTypeAny> {
241
241
  * leave it unset and are advertised from their zod schema as before.
242
242
  */
243
243
  rawInputSchema?: Record<string, unknown>;
244
+ /**
245
+ * Optional per-turn lifecycle hook (C.13). {@link Session.send} calls it on
246
+ * every tool in the session registry at the start of each user turn, before the
247
+ * model runs. A tool that carries per-episode state — e.g. `run_tests`' consecutive-
248
+ * failure breaker — resets it here so a fresh instruction starts clean, while that
249
+ * state still latches across the many model iterations *within* one turn. Tools
250
+ * with no per-turn state omit it (a subagent/one-shot run is a single episode, so
251
+ * a missing hook simply means the state lives for that whole run).
252
+ */
253
+ onTurnStart?(): void;
244
254
  /** Run the tool against validated `input` and the ambient `ctx`. */
245
255
  execute(input: z.infer<Schema>, ctx: ToolContext): Promise<ToolResult>;
246
256
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {