@cruxy/cli 1.11.2 → 1.11.3

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 (37) hide show
  1. package/dist/agent/instruction-loss.js +204 -0
  2. package/dist/agent/prompts.js +25 -4
  3. package/dist/agent/session.js +165 -33
  4. package/dist/agent/status.js +18 -0
  5. package/dist/cli/commands/pr.js +14 -0
  6. package/dist/cli/commands/run.js +35 -0
  7. package/dist/cli/session-commands.js +3 -1
  8. package/dist/cli/session-factory.js +54 -6
  9. package/dist/config/schema.js +9 -0
  10. package/dist/mcp/bounds.js +8 -1
  11. package/dist/plan/execute.js +4 -1
  12. package/dist/plan/service.js +42 -5
  13. package/dist/plan/step-message.js +49 -0
  14. package/dist/render/context-view.js +44 -1
  15. package/dist/render/status-view.js +13 -0
  16. package/dist/session/index.js +6 -3
  17. package/dist/session/log.js +97 -2
  18. package/dist/session/recorded-runs.js +56 -0
  19. package/dist/session/replay.js +75 -1
  20. package/dist/session/resume.js +88 -0
  21. package/dist/session/types.js +158 -0
  22. package/dist/testing/run-tests-tool.js +3 -1
  23. package/dist/tools/create-pull-request.js +8 -1
  24. package/dist/tools/file/apply-patch.js +6 -2
  25. package/dist/tools/file/edit-file.js +6 -2
  26. package/dist/tools/file/snapshot.js +9 -4
  27. package/dist/tools/file/write-file.js +7 -2
  28. package/dist/tools/registry.js +39 -8
  29. package/dist/tools/schema-depth.js +79 -6
  30. package/dist/tools/shell/exec.js +7 -0
  31. package/dist/tools/shell/run-command.js +45 -21
  32. package/dist/vcs/generate.js +48 -6
  33. package/dist/verification/index.js +15 -0
  34. package/dist/verification/ledger.js +99 -0
  35. package/dist/verification/types.js +26 -0
  36. package/dist/verification/view.js +87 -0
  37. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { modeFromFlags } from "../agent/mode.js";
3
3
  import { redactMessages } from "./redact.js";
4
- import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
4
+ import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, emptyCompactionTally, } from "./types.js";
5
5
  /**
6
6
  * Replay: fold an append-only event log back into the state a session needs to
7
7
  * resume (P2).
@@ -109,6 +109,10 @@ export function foldEvents(events, counts = {}) {
109
109
  let planMode = false;
110
110
  let mode = null;
111
111
  let redactions = 0;
112
+ let lastVerification;
113
+ let externalChanges = 0;
114
+ let plan;
115
+ const compactions = emptyCompactionTally();
112
116
  const resumes = [];
113
117
  const usage = { input_tokens: 0, output_tokens: 0 };
114
118
  for (const event of events) {
@@ -123,6 +127,22 @@ export function foldEvents(events, counts = {}) {
123
127
  ...asMessages(event.summary),
124
128
  ...messages.slice(event.replaced),
125
129
  ];
130
+ // And tallied (P3): the count is the events; the sums cover only the
131
+ // events that carry a cost, so a pre-P3 compaction counts as one that
132
+ // happened and not as one that freed nothing.
133
+ compactions.count++;
134
+ if (event.estimatedBefore !== undefined &&
135
+ event.estimatedAfter !== undefined) {
136
+ compactions.measured++;
137
+ compactions.freedTokens += Math.max(0, event.estimatedBefore - event.estimatedAfter);
138
+ compactions.summaryInputTokens += event.summaryInputTokens ?? 0;
139
+ compactions.summaryOutputTokens += event.summaryOutputTokens ?? 0;
140
+ }
141
+ break;
142
+ case "instruction-loss":
143
+ // Counted, not folded: the synopsis is already in the history as the
144
+ // model sees it. This is the fact that it may be missing something.
145
+ compactions.instructionLosses++;
126
146
  break;
127
147
  case "clear":
128
148
  messages = [];
@@ -156,6 +176,56 @@ export function foldEvents(events, counts = {}) {
156
176
  // could be added without touching `meta` or the message array.
157
177
  resumes.push({ at: event.at, cwd: event.cwd });
158
178
  break;
179
+ case "verification":
180
+ // Last-writer-wins, and never folded into the messages: a run is a
181
+ // fact ABOUT the turn, not a turn in it. The tool_result the model saw
182
+ // is already in the `append` events; this is the structured claim.
183
+ lastVerification = {
184
+ at: event.at,
185
+ tool: event.tool,
186
+ command: event.command,
187
+ ...(event.source !== undefined ? { source: event.source } : {}),
188
+ passed: event.passed,
189
+ exitCode: event.exitCode,
190
+ durationMs: event.durationMs,
191
+ ...(event.total !== undefined ? { total: event.total } : {}),
192
+ failureCount: event.failureCount,
193
+ failureNames: event.failureNames,
194
+ outputTruncated: event.outputTruncated,
195
+ substrate: event.substrate,
196
+ };
197
+ break;
198
+ case "external-change":
199
+ // Counted, not folded: the refusal wrote nothing, so there is nothing
200
+ // in the history to adjust — only a fact for the resume notice.
201
+ externalChanges++;
202
+ break;
203
+ case "plan-approved":
204
+ // A new approval replaces the last: every step starts `pending`, as
205
+ // the executor had it, and the `plan-step` events that follow move
206
+ // them. Never folded into the messages — the plan's text is already
207
+ // there as the model's own `submit_plan` call.
208
+ plan = {
209
+ approvedAt: event.at,
210
+ decision: event.decision,
211
+ steps: event.steps.map((s) => ({
212
+ id: s.id,
213
+ title: s.title,
214
+ kind: s.kind,
215
+ status: "pending",
216
+ })),
217
+ };
218
+ break;
219
+ case "plan-step": {
220
+ // A transition for a step the last approval did not name is skipped,
221
+ // not an error: a torn `plan-approved` line, or a newer build's
222
+ // vocabulary. Position matters — a step left `running` is one whose
223
+ // outcome the log never received.
224
+ const step = plan?.steps.find((s) => s.id === event.stepId);
225
+ if (step)
226
+ step.status = event.status;
227
+ break;
228
+ }
159
229
  case "meta":
160
230
  break;
161
231
  }
@@ -172,6 +242,10 @@ export function foldEvents(events, counts = {}) {
172
242
  unknownEvents: counts.unknownEvents ?? 0,
173
243
  resumes,
174
244
  redactions,
245
+ ...(lastVerification ? { lastVerification } : {}),
246
+ externalChanges,
247
+ compactions,
248
+ ...(plan ? { plan } : {}),
175
249
  };
176
250
  }
177
251
  /** Read and parse a session file into its events, counting unusable lines. */
@@ -1,3 +1,4 @@
1
+ import { formatTokens } from "../render/state.js";
1
2
  import { selectList } from "../components/index.js";
2
3
  import { usageError } from "../errors/index.js";
3
4
  import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
@@ -127,8 +128,95 @@ export function loadResume(session, cwd) {
127
128
  warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
128
129
  `secrets are masked in the restored history, but the original text is still in ${session.file}`);
129
130
  }
131
+ if (state.externalChanges > 0) {
132
+ // A refused write is a fact about the user's TREE, not about cruxy: while
133
+ // they were being asked to approve a change, something else wrote to the
134
+ // file. It reached the model as an error at the time; on resume it is
135
+ // said once more, because "was my tree written to by something else"
136
+ // is a question people ask after the fact.
137
+ warnings.push(`${state.externalChanges} write${state.externalChanges === 1 ? " was" : "s were"} refused in this session ` +
138
+ `because the file changed on disk while awaiting approval — nothing was written by cruxy; ` +
139
+ `the session log names each path`);
140
+ }
141
+ if (state.compactions.count > 0) {
142
+ // What compaction has cost this conversation (P3 context quality), said
143
+ // once on resume because it is the moment a user decides whether to
144
+ // continue in a history that has been summarised N times. The freed and
145
+ // spent figures cover only the compactions that recorded them; the clause
146
+ // saying so is not optional when they differ.
147
+ warnings.push(describeCompactions(state.compactions));
148
+ }
149
+ if (state.compactions.instructionLosses > 0) {
150
+ // The heuristic's finding, restated where it can still be acted on: the
151
+ // sentences are in the session log; the remedy is to say them again.
152
+ const n = state.compactions.instructionLosses;
153
+ warnings.push(`${n} compaction${n === 1 ? "" : "s"} in this session may have dropped an instruction you gave — ` +
154
+ `the session log names the sentences; restate anything that still applies`);
155
+ }
156
+ if (state.lastVerification) {
157
+ // Not a warning in spirit, but this is the one channel a resume has, and
158
+ // the sentence is the same one `/status` shows: what last ran and how it
159
+ // exited, dated. A resumed session's "the tests passed" is a claim about
160
+ // the tree as it was THEN; the age is what keeps it honest.
161
+ const v = state.lastVerification;
162
+ const exit = v.exitCode === null ? "no exit code" : `exit ${v.exitCode}`;
163
+ warnings.push(`last verification ${relativeAge(v.at)}: ${v.tool} \`${v.command}\` → ${exit}`);
164
+ }
165
+ if (state.plan) {
166
+ // The last approved plan and where it got to (plan-durability). Same
167
+ // channel, same reasoning as the verification line: a fact the user
168
+ // decides on before continuing. The sentence ends by saying the approval
169
+ // is not carried over, because the one thing a reader of "approved" might
170
+ // reasonably expect is that the consent came back with the session.
171
+ warnings.push(describePlan(state.plan));
172
+ }
130
173
  return { session, state, warnings };
131
174
  }
175
+ /**
176
+ * One sentence for the last approved plan (plan-durability): when it was
177
+ * approved and how, how many steps finished, which failed, and — the fact a
178
+ * resume exists to surface — whether a step was still running when the
179
+ * session ended. Says in so many words that the approval is not carried over:
180
+ * `[g]`'s grants lived in the allowlist of the process that asked, and this
181
+ * process has not asked.
182
+ */
183
+ export function describePlan(p, now = Date.now()) {
184
+ const n = p.steps.length;
185
+ const count = (status) => p.steps.filter((s) => s.status === status).length;
186
+ const done = count("done");
187
+ const failed = count("failed");
188
+ const pending = count("pending");
189
+ const running = p.steps.find((s) => s.status === "running");
190
+ const how = p.decision === "approve-grant"
191
+ ? "with safe steps auto-allowed"
192
+ : "step by step";
193
+ const parts = [`${done}/${n} step${n === 1 ? "" : "s"} done`];
194
+ if (failed > 0)
195
+ parts.push(`${failed} failed`);
196
+ if (running) {
197
+ parts.push(`step ${running.id} (${running.title}) was still running when the session ended`);
198
+ }
199
+ if (pending > 0)
200
+ parts.push(`${pending} never started`);
201
+ return (`last plan approved ${relativeAge(p.approvedAt, now)} ${how}: ${parts.join(", ")} — ` +
202
+ `that approval is not carried into this session; every action asks again`);
203
+ }
204
+ /**
205
+ * One sentence for what compaction has cost a session (P3 context quality).
206
+ * Shared by the resume notice and nothing else; `/context` and the one-shot
207
+ * summary render the same tally as lines (`render/context-view.ts`).
208
+ */
209
+ export function describeCompactions(c) {
210
+ const times = `compacted ${c.count === 1 ? "once" : `${c.count} times`} in this session`;
211
+ if (c.measured === 0) {
212
+ return `${times} — cost not recorded (written before it was measured)`;
213
+ }
214
+ const scope = c.measured < c.count
215
+ ? ` (${c.count - c.measured} of them recorded no cost)`
216
+ : "";
217
+ return (`${times} — freed ~${formatTokens(c.freedTokens)} tokens; ` +
218
+ `the summaries cost ~${formatTokens(c.summaryInputTokens)} in / ~${formatTokens(c.summaryOutputTokens)} out${scope}`);
219
+ }
132
220
  /**
133
221
  * VALIDATE `--resume <id>` — which session does this name? — without loading it.
134
222
  *
@@ -154,6 +154,43 @@ export const CompactionEventSchema = z
154
154
  replaced: z.number().int().nonnegative(),
155
155
  /** What replaced them (the synthetic pair). */
156
156
  summary: z.array(MessageSchema),
157
+ /**
158
+ * What the compaction COST (P3 context quality) — the four facts in hand
159
+ * at the write site and, until P3, thrown away there. All optional: a log
160
+ * written before P3 carries none, and absence means "not measured", never
161
+ * zero. Freed tokens is `estimatedBefore - estimatedAfter` and is NOT
162
+ * stored — one derivation, in the fold, rather than a fifth field that
163
+ * could disagree with the other two.
164
+ *
165
+ * The estimates are `estimateTokens` over the history — the same chars/4
166
+ * heuristic the trigger uses — WITHOUT the fixed reserve, which is a
167
+ * setting and would cancel out of the difference anyway.
168
+ */
169
+ estimatedBefore: z.number().int().nonnegative().optional(),
170
+ estimatedAfter: z.number().int().nonnegative().optional(),
171
+ /**
172
+ * The summarize call's own usage, as the provider reported it. Absent
173
+ * when the provider reported nothing — the same honesty rule as the usage
174
+ * record: unknown is not zero.
175
+ */
176
+ summaryInputTokens: z.number().int().nonnegative().optional(),
177
+ summaryOutputTokens: z.number().int().nonnegative().optional(),
178
+ })
179
+ .passthrough();
180
+ /**
181
+ * A compaction after which the user's instructions could not all be found in
182
+ * the synopsis (P3 context quality). Written by the heuristic in
183
+ * `agent/instruction-loss.ts`, which can be wrong both ways and says so; the
184
+ * event is what makes a suspected loss auditable after the fact instead of a
185
+ * warning that scrolled away. `sentences` are the user's own words, bounded
186
+ * and truncated — enough to recognise what may need restating.
187
+ */
188
+ export const InstructionLossEventSchema = z
189
+ .object({
190
+ kind: z.literal("instruction-loss"),
191
+ at: z.string(),
192
+ runId: z.string().optional(),
193
+ sentences: z.array(z.string()),
157
194
  })
158
195
  .passthrough();
159
196
  /** `/clear`: history dropped, session kept. Replay resets to an empty array. */
@@ -283,6 +320,111 @@ export const ResumedEventSchema = z
283
320
  cliVersion: z.string().optional(),
284
321
  })
285
322
  .passthrough();
323
+ /**
324
+ * One run that ACTUALLY EXECUTED (P2 verification): `run_tests`, or a
325
+ * `run_command` the model chose. An index over what happened, keyed to the
326
+ * turn it happened in — NOT a copy of the run's output. The `append` event
327
+ * already holds the tool_result the model saw, as the string built for the
328
+ * model; this is the structured claim beside it, written from the same
329
+ * object the tool built rather than re-parsed from that string (the same
330
+ * reasoning as the `run_tests` renderer side channel, P3).
331
+ *
332
+ * `passed` is the exit code and nothing else. `tool` and `source` are plain
333
+ * strings so a newer build's tool or provenance parses here instead of
334
+ * poisoning the line. A refused attempt, a denied approval or a spawn failure
335
+ * ran nothing, so no event is written for it — writing one would be a
336
+ * fabrication.
337
+ *
338
+ * Evidence, not enforcement: nothing reads this to decide whether a turn may
339
+ * end. See `verification/types.ts` for why that decision stands.
340
+ */
341
+ export const VerificationEventSchema = z
342
+ .object({
343
+ kind: z.literal("verification"),
344
+ at: z.string(),
345
+ runId: z.string().optional(),
346
+ tool: z.string(),
347
+ command: z.string(),
348
+ source: z.string().optional(),
349
+ passed: z.boolean(),
350
+ exitCode: z.number().int().nullable(),
351
+ durationMs: z.number().nonnegative(),
352
+ total: z.number().int().nonnegative().optional(),
353
+ failureCount: z.number().int().nonnegative().default(0),
354
+ failureNames: z.array(z.string()).default([]),
355
+ outputTruncated: z.boolean().default(false),
356
+ substrate: z.string(),
357
+ })
358
+ .passthrough();
359
+ /**
360
+ * A write refused because its target moved during the approval wait (P1).
361
+ *
362
+ * The refusal itself is an error string handed to the model; before this
363
+ * event the user learned of it only by opening a rollback preview later.
364
+ * Something else wrote to a file in their tree while they were being asked to
365
+ * approve a change to it, and that is recorded here as a fact about the
366
+ * session — separate from `verification` so the fold never mistakes one for
367
+ * the other. No content is recorded: the path and what moved are the whole
368
+ * fact.
369
+ */
370
+ export const ExternalChangeEventSchema = z
371
+ .object({
372
+ kind: z.literal("external-change"),
373
+ at: z.string(),
374
+ runId: z.string().optional(),
375
+ path: z.string(),
376
+ what: z.string(),
377
+ })
378
+ .passthrough();
379
+ /**
380
+ * The user approved a plan (plan-durability).
381
+ *
382
+ * Recorded as a FACT — the decision kind and the steps as approved, dated —
383
+ * because it is stronger than the mode toggle, which was already journaled,
384
+ * and it used to evaporate with the process: the plan lived in a local holder
385
+ * inside one `runPlanSession` call and nowhere else. The plan's text was
386
+ * already on disk as the model's own `submit_plan` tool_use; this is the
387
+ * typed record beside it, the one a reader can fold without parsing a tool
388
+ * call.
389
+ *
390
+ * IT IS NEVER RE-ARMED ON RESUME. `decision: "approve-grant"` says the user
391
+ * chose `[g]`, which wrote grants into the session allowlist — RAM, in the
392
+ * process that asked. A resumed session reading this event and restoring
393
+ * those grants would pre-authorize writes the user consented to in a
394
+ * different process, against a tree that has since changed. The fold hands
395
+ * back a description; nothing on the consent side reads it.
396
+ *
397
+ * `decision` and `kind` are plain strings so a log written by a newer build
398
+ * parses here rather than poisoning the line.
399
+ */
400
+ export const PlanApprovedEventSchema = z
401
+ .object({
402
+ kind: z.literal("plan-approved"),
403
+ at: z.string(),
404
+ runId: z.string().optional(),
405
+ /** `approve` or `approve-grant`, as the user chose. */
406
+ decision: z.string(),
407
+ steps: z.array(z
408
+ .object({ id: z.string(), title: z.string(), kind: z.string() })
409
+ .passthrough()),
410
+ })
411
+ .passthrough();
412
+ /**
413
+ * One step of the last approved plan changed status (plan-durability):
414
+ * `running` when its turn began, `done` or `failed` when it ended. A step
415
+ * whose last event is `running` is the signature of an interrupted plan —
416
+ * the process died before the step could report either outcome — and that
417
+ * is exactly the fact the resume notice needs.
418
+ */
419
+ export const PlanStepEventSchema = z
420
+ .object({
421
+ kind: z.literal("plan-step"),
422
+ at: z.string(),
423
+ runId: z.string().optional(),
424
+ stepId: z.string(),
425
+ status: z.string(),
426
+ })
427
+ .passthrough();
286
428
  /** Every event, discriminated on `kind`. */
287
429
  export const SessionEventSchema = z.discriminatedUnion("kind", [
288
430
  SessionMetaSchema,
@@ -294,6 +436,11 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
294
436
  UsageEventSchema,
295
437
  RedactEventSchema,
296
438
  ResumedEventSchema,
439
+ VerificationEventSchema,
440
+ ExternalChangeEventSchema,
441
+ InstructionLossEventSchema,
442
+ PlanApprovedEventSchema,
443
+ PlanStepEventSchema,
297
444
  ]);
298
445
  /**
299
446
  * Every `kind` this build understands, derived from the union itself so the two
@@ -304,3 +451,14 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
304
451
  * they used to be counted as one.
305
452
  */
306
453
  export const KNOWN_EVENT_KINDS = new Set(SessionEventSchema.options.map((option) => option.shape.kind.value));
454
+ /** A tally with nothing in it — a fresh session, or a log with no compaction. */
455
+ export function emptyCompactionTally() {
456
+ return {
457
+ count: 0,
458
+ measured: 0,
459
+ freedTokens: 0,
460
+ summaryInputTokens: 0,
461
+ summaryOutputTokens: 0,
462
+ instructionLosses: 0,
463
+ };
464
+ }
@@ -147,7 +147,9 @@ export function makeRunTestsTool(deps = {}) {
147
147
  // The structured result goes to the renderer here, from the same object
148
148
  // the payload below is built from — never from re-reading that payload.
149
149
  try {
150
- deps.onResult?.(result, resolved);
150
+ deps.onResult?.(result, resolved, {
151
+ substrate: ctx.sandbox ? "sandbox" : "host",
152
+ });
151
153
  }
152
154
  catch {
153
155
  // A renderer problem is not a test-run problem.
@@ -25,7 +25,8 @@ const parameters = z.object({
25
25
  body: z
26
26
  .string()
27
27
  .optional()
28
- .describe("PR body in markdown (what changed · why · verification). Omit to auto-generate."),
28
+ .describe("PR body in markdown (what changed · why). Omit to auto-generate. " +
29
+ "Do not write a Verification section: it is added from the record of what actually ran this session, and one you write is replaced by it."),
29
30
  base: z
30
31
  .string()
31
32
  .optional()
@@ -58,6 +59,12 @@ export const createPullRequestTool = {
58
59
  ...i,
59
60
  scopes: guidance.scopes,
60
61
  skillBody: guidance.skillBody,
62
+ // The PR body's Verification section is the record's (P2): the
63
+ // runs this session actually executed, with their exit codes —
64
+ // or no such section at all. The model's own body keeps its
65
+ // prose; a Verification section it wrote is replaced by this.
66
+ // Never a manufactured "ran typecheck + lint + test".
67
+ verification: { runs: ctx.verification?.sessionRuns() ?? [] },
61
68
  }),
62
69
  });
63
70
  const outcome = await service.openPullRequest({
@@ -172,8 +172,12 @@ export const applyPatchTool = {
172
172
  const drifted = [];
173
173
  for (const p of planned) {
174
174
  const moved = await changedSince(p.abs, p.approved, p.rel);
175
- if (moved)
176
- drifted.push(moved);
175
+ if (moved) {
176
+ drifted.push(moved.message);
177
+ // One record per path that moved (P2 verification): the user learns
178
+ // which files something else wrote to, not just that the patch failed.
179
+ ctx.verification?.record({ kind: "external-change", ...moved });
180
+ }
177
181
  }
178
182
  if (drifted.length > 0)
179
183
  return { ok: false, error: drifted.join("\n") };
@@ -76,8 +76,12 @@ export const editFileTool = {
76
76
  // changed the file during the wait makes `updated` a splice of stale
77
77
  // content — refuse rather than overwrite what is there now (P1).
78
78
  const moved = await changedSince(abs, approvedState, input.path);
79
- if (moved)
80
- return { ok: false, error: moved };
79
+ if (moved) {
80
+ // Told to the model as the error, and recorded for the user (P2
81
+ // verification) — see write-file.ts.
82
+ ctx.verification?.record({ kind: "external-change", ...moved });
83
+ return { ok: false, error: moved.message };
84
+ }
81
85
  try {
82
86
  await fs.writeFile(abs, updated, "utf8");
83
87
  return { ok: true, output: `edited ${input.path}` };
@@ -28,8 +28,9 @@ export async function snapshotFile(abs) {
28
28
  /**
29
29
  * Re-read `abs` and compare it with the state the approval was granted
30
30
  * against. Returns `null` when the file is exactly as it was, otherwise the
31
- * refusal to hand back as the tool's error one sentence on what moved, and
32
- * the same next step every time: read it again and retry.
31
+ * refusal its `message` is the tool's error (one sentence on what moved,
32
+ * and the same next step every time: read it again and retry), and its
33
+ * `path`/`what` are what the tool records so the user learns of it too.
33
34
  *
34
35
  * Call this AFTER approval and IMMEDIATELY before the write, with nothing
35
36
  * awaited in between — the point is to make the gap as small as the platform
@@ -58,6 +59,10 @@ export async function changedSince(abs, approved, rel) {
58
59
  }
59
60
  /** The one refusal shape: what moved, that nothing was written, what to do. */
60
61
  function refusal(rel, what) {
61
- return (`${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
62
- `covers this write; nothing was written — read the file again and retry`);
62
+ return {
63
+ path: rel,
64
+ what,
65
+ message: `${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
66
+ `covers this write; nothing was written — read the file again and retry`,
67
+ };
63
68
  }
@@ -65,8 +65,13 @@ export const writeFileTool = {
65
65
  // appeared during the wait; an overwrite approved against one version must
66
66
  // not land on another (P1).
67
67
  const moved = await changedSince(abs, approvedState, input.path);
68
- if (moved)
69
- return { ok: false, error: moved };
68
+ if (moved) {
69
+ // Told to the model as the error, and recorded for the user (P2
70
+ // verification): a file in their tree was written to by something else
71
+ // while they were being asked to approve a change to it.
72
+ ctx.verification?.record({ kind: "external-change", ...moved });
73
+ return { ok: false, error: moved.message };
74
+ }
70
75
  try {
71
76
  await fs.mkdir(path.dirname(abs), { recursive: true });
72
77
  await fs.writeFile(abs, input.content, "utf8");
@@ -2,7 +2,7 @@ import { zodToJsonSchema } from "zod-to-json-schema";
2
2
  import { listFilesTool } from "./list-files.js";
3
3
  import { gitStatusTool } from "./git-status.js";
4
4
  import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
5
- import { runCommandTool } from "./shell/index.js";
5
+ import { makeRunCommandTool, runCommandTool, } from "./shell/index.js";
6
6
  import { makeRunTestsTool, } from "../testing/run-tests-tool.js";
7
7
  import { searchCodebaseTool } from "./search-codebase.js";
8
8
  import { listSkillsTool } from "./list-skills.js";
@@ -54,20 +54,51 @@ function toInputSchema(schema) {
54
54
  delete json.$ref;
55
55
  return json;
56
56
  }
57
- /** Build the default registry with every built-in tool registered. */
57
+ /**
58
+ * Build the default registry with every always-on built-in registered.
59
+ *
60
+ * `tools` is the config block of the same name (P4). Its two keys —
61
+ * `fileEdit` and `shell` — were declared in the C.0 scaffold and, until P4,
62
+ * consumed by NOTHING: a user could set `tools.shell: false`, `cruxy config
63
+ * list` would show it, and `run_command` would register regardless. Deleting
64
+ * them was the other option and is ruled out by `initConfig`, which writes the
65
+ * full defaults into every generated config: the schema is `.strict()`, so a
66
+ * removed key would stop every `cruxy init`-generated config from loading on
67
+ * upgrade — a compat break for everyone, not the #296 kind that bites only
68
+ * whoever wrote the key. So the keys now mean what their names say:
69
+ *
70
+ * - `fileEdit: false` withholds the three tools that write files through the
71
+ * U.3 gate — `write_file`, `edit_file`, `apply_patch`. Reads stay.
72
+ * - `shell: false` withholds the two that execute commands — `run_command`
73
+ * and `run_tests` (the test runner is a shell command with a parser on it,
74
+ * and "no shell" that still ran `pnpm test` would be a setting that lies).
75
+ * `create_pull_request` runs git through its own approval and is not a
76
+ * shell for the model; it stays.
77
+ *
78
+ * Both default to `true`, so a config that never mentions them — every config
79
+ * today — gets the same registry it always did. Omitted → both on.
80
+ */
58
81
  export function buildDefaultRegistry(opts = {}) {
82
+ const fileEdit = opts.tools?.fileEdit ?? true;
83
+ const shell = opts.tools?.shell ?? true;
59
84
  const registry = new ToolRegistry();
60
85
  registry.register(listFilesTool);
61
86
  registry.register(readFileTool);
62
- registry.register(writeFileTool);
63
- registry.register(editFileTool);
64
- registry.register(applyPatchTool);
87
+ if (fileEdit) {
88
+ registry.register(writeFileTool);
89
+ registry.register(editFileTool);
90
+ registry.register(applyPatchTool);
91
+ }
65
92
  registry.register(globTool);
66
93
  registry.register(grepFilesTool);
67
94
  registry.register(gitStatusTool);
68
- registry.register(runCommandTool);
69
- // A fresh tool per registry — its iteration budget (C.13) is session-scoped.
70
- registry.register(makeRunTestsTool(opts.onTestResult ? { onResult: opts.onTestResult } : {}));
95
+ if (shell) {
96
+ registry.register(opts.onCommandResult
97
+ ? makeRunCommandTool({ onResult: opts.onCommandResult })
98
+ : runCommandTool);
99
+ // A fresh tool per registry — its iteration budget (C.13) is session-scoped.
100
+ registry.register(makeRunTestsTool(opts.onTestResult ? { onResult: opts.onTestResult } : {}));
101
+ }
71
102
  registry.register(searchCodebaseTool);
72
103
  registry.register(listSkillsTool);
73
104
  registry.register(loadSkillTool);
@@ -1,10 +1,17 @@
1
1
  /**
2
- * The provider's tool-schema depth bound, and the counter that measures against
3
- * it. ONE home for both, because there are two consumers that must agree: the
4
- * CI gate on our own built-ins (`schema-depth.test.ts`) and the runtime bound on
5
- * third-party MCP schemas (`../mcp/bounds.ts`). A counter that disagreed with
6
- * the bound, or two copies of either drifting apart, would mean a schema that
7
- * passes here and dies on the wire.
2
+ * The gateway's three tool-schema bounds — depth, bytes, nodes — and the
3
+ * counters that measure against them. ONE home for all of it, because there
4
+ * are two consumers that must agree: the CI gate on our own built-ins
5
+ * (`schema-depth.test.ts`) and the runtime bound on third-party MCP schemas
6
+ * (`../mcp/bounds.ts`). A counter that disagreed with its bound, or two copies
7
+ * of either drifting apart, would mean a schema that passes here and dies on
8
+ * the wire.
9
+ *
10
+ * Until P4 only depth was mirrored. The gateway has always applied all three
11
+ * to every tool's `parameters` (cruxy-ai/api `internal/httpx/chat_tools.go`,
12
+ * `validateToolParameters`), from the same constants its structured-output
13
+ * validator uses, and a rejection names which one (`bound`: `bytes` | `depth`
14
+ * | `nodes`). Two of the three were caught by nothing here.
8
15
  */
9
16
  /**
10
17
  * Nesting depth of the JSON Schema we put on the wire, counted the way the
@@ -83,3 +90,69 @@ export function schemaDepth(node) {
83
90
  * `depth >= MAX_SCHEMA_DEPTH`.
84
91
  */
85
92
  export const MAX_SCHEMA_DEPTH = 8;
93
+ /**
94
+ * The gateway's byte bound on ONE tool's `parameters`, as serialized on the
95
+ * wire. Mirrors `maxSchemaBytes` (128 KiB) in cruxy-ai/api
96
+ * `internal/httpx/structured.go`, applied per tool by `validateToolParameters`
97
+ * as `len(raw) > maxSchemaBytes` — so, unlike {@link MAX_SCHEMA_DEPTH}, this
98
+ * bound is INCLUSIVE: a schema is safe AT the limit and dies one byte past it.
99
+ *
100
+ * NOT OURS TO CHOOSE, same as depth: raising it here moves the failure from CI
101
+ * to every user's terminal, and a rejection carrying `bound: "bytes"` with a
102
+ * `limit` that disagrees with this number means THIS number is stale. If a
103
+ * schema cannot fit, the schema changes. Every built-in is two orders of
104
+ * magnitude under it today (the largest, `spawn_subagents`, is ~1.2 KB); the
105
+ * bound is here so that stays a fact CI checks rather than one someone
106
+ * remembers.
107
+ *
108
+ * The MCP path is covered twice over: `mcp.maxSchemaBytes` has a config
109
+ * ceiling of 64 KiB (`config/schema.ts`, #237), half this limit, and a test
110
+ * pins that the ceiling stays under the mirror — so no MCP schema that loads
111
+ * from config can reach the gateway's byte bound at all.
112
+ */
113
+ export const MAX_SCHEMA_BYTES = 128 * 1024;
114
+ /**
115
+ * The gateway's node bound on ONE tool's `parameters`: the number of
116
+ * containers (objects and arrays) in the schema. Mirrors `maxSchemaNodes`
117
+ * (400) in cruxy-ai/api `internal/httpx/structured.go`, applied per tool by
118
+ * `boundJSON` as `nodes > maxSchemaNodes` — INCLUSIVE, like bytes: safe at
119
+ * 400, dead at 401. {@link schemaNodes} counts exactly what `boundJSON`
120
+ * counts. Same provenance rule: a sanity ceiling that mirrors the gateway,
121
+ * never raised to make a schema fit. Flatten instead.
122
+ *
123
+ * Small and wide is the shape this catches that neither of the others does:
124
+ * a flat object with 400 string properties is 3 levels deep and a few KB, and
125
+ * the gateway refuses it.
126
+ */
127
+ export const MAX_SCHEMA_NODES = 400;
128
+ /**
129
+ * Container count of a JSON tree, counted the way the gateway's `boundJSON`
130
+ * counts it: every object and every array is one node, scalars are not. So
131
+ * `{}` is 1, `{a: {}}` is 2, `{a: [{}, {}]}` is 4. Iterative for the same
132
+ * reason {@link schemaDepth} is — the MCP path can hand this a hostile input.
133
+ */
134
+ export function schemaNodes(node) {
135
+ const isContainer = (n) => Array.isArray(n) || (typeof n === "object" && n !== null);
136
+ if (!isContainer(node))
137
+ return 0;
138
+ let count = 0;
139
+ const pending = [node];
140
+ while (pending.length > 0) {
141
+ const current = pending.pop();
142
+ count++;
143
+ const children = Array.isArray(current)
144
+ ? current
145
+ : Object.values(current);
146
+ for (const child of children)
147
+ if (isContainer(child))
148
+ pending.push(child);
149
+ }
150
+ return count;
151
+ }
152
+ /**
153
+ * Bytes of a schema as it goes on the wire: compact `JSON.stringify`, UTF-8 —
154
+ * which is what the SDK serializes and the gateway measures with `len(raw)`.
155
+ */
156
+ export function schemaBytes(node) {
157
+ return Buffer.byteLength(JSON.stringify(node) ?? "", "utf8");
158
+ }