@cruxy/cli 1.11.1 → 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 (48) 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/checkpoint/service.js +44 -3
  6. package/dist/cli/commands/pr.js +14 -0
  7. package/dist/cli/commands/run.js +35 -0
  8. package/dist/cli/commands/sessions.js +8 -0
  9. package/dist/cli/session-commands.js +3 -1
  10. package/dist/cli/session-factory.js +54 -6
  11. package/dist/config/schema.js +9 -0
  12. package/dist/errors/constructors.js +15 -6
  13. package/dist/errors/types.js +7 -0
  14. package/dist/indexing/embedder.js +34 -11
  15. package/dist/indexing/model-cache.js +399 -0
  16. package/dist/mcp/bounds.js +8 -1
  17. package/dist/plan/execute.js +4 -1
  18. package/dist/plan/service.js +42 -5
  19. package/dist/plan/step-message.js +49 -0
  20. package/dist/render/context-view.js +44 -1
  21. package/dist/render/status-view.js +13 -0
  22. package/dist/session/index.js +7 -3
  23. package/dist/session/log.js +163 -2
  24. package/dist/session/owner.js +123 -0
  25. package/dist/session/prune.js +11 -0
  26. package/dist/session/recorded-runs.js +56 -0
  27. package/dist/session/replay.js +75 -1
  28. package/dist/session/resume.js +110 -3
  29. package/dist/session/types.js +158 -0
  30. package/dist/subagent/orchestrator.js +2 -2
  31. package/dist/subagent/registry-scope.js +28 -5
  32. package/dist/testing/run-tests-tool.js +3 -1
  33. package/dist/tools/create-pull-request.js +8 -1
  34. package/dist/tools/file/apply-patch.js +53 -23
  35. package/dist/tools/file/edit-file.js +19 -1
  36. package/dist/tools/file/snapshot.js +68 -0
  37. package/dist/tools/file/write-file.js +31 -5
  38. package/dist/tools/registry.js +39 -8
  39. package/dist/tools/schema-depth.js +79 -6
  40. package/dist/tools/shell/exec.js +7 -0
  41. package/dist/tools/shell/run-command.js +45 -21
  42. package/dist/utils/process-owner.js +107 -0
  43. package/dist/vcs/generate.js +48 -6
  44. package/dist/verification/index.js +15 -0
  45. package/dist/verification/ledger.js +99 -0
  46. package/dist/verification/types.js +26 -0
  47. package/dist/verification/view.js +87 -0
  48. package/package.json +3 -2
@@ -1,7 +1,9 @@
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";
4
5
  import { replaySession } from "./replay.js";
6
+ import { describeHolder, ownerFile, sessionHeldBy } from "./owner.js";
5
7
  /** How many sessions the bare-`--resume` picker offers. */
6
8
  export const PICKER_LIMIT = 10;
7
9
  /** Short, stable id form — enough to identify a session, short enough to type. */
@@ -75,6 +77,18 @@ export function priorDirectoriesWarning(state, cwd) {
75
77
  * worth failing loudly on, unlike an individual torn line.
76
78
  */
77
79
  export function loadResume(session, cwd) {
80
+ // OWNERSHIP (P1). Asked BEFORE the replay, and answered by the OS rather
81
+ // than by a file's presence: a stamp whose process is gone is not a holder.
82
+ // Two processes appending to one log produce a transcript that replays
83
+ // cleanly and means nothing, so this is a refusal, not a warning.
84
+ const holder = sessionHeldBy(session.file);
85
+ if (holder) {
86
+ throw usageError(`session ${shortId(session.sessionId)} is open in another cruxy (${describeHolder(holder)})`, [
87
+ "continue it there, or quit that cruxy and resume here",
88
+ "start a new session with `cruxy`",
89
+ `if that process is not a cruxy, remove ${ownerFile(session.file)}`,
90
+ ]);
91
+ }
78
92
  let state;
79
93
  try {
80
94
  state = replaySession(session.file);
@@ -114,8 +128,95 @@ export function loadResume(session, cwd) {
114
128
  warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
115
129
  `secrets are masked in the restored history, but the original text is still in ${session.file}`);
116
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
+ }
117
173
  return { session, state, warnings };
118
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
+ }
119
220
  /**
120
221
  * VALIDATE `--resume <id>` — which session does this name? — without loading it.
121
222
  *
@@ -189,9 +290,15 @@ export async function resumePicker(cwd, opts = {}) {
189
290
  ];
190
291
  const picked = await selectList(rows, {
191
292
  title: "resume a session",
192
- toLabel: (row) => row.kind === "new"
193
- ? "+ new session"
194
- : describeSession(row.session, now),
293
+ toLabel: (row) => {
294
+ if (row.kind === "new")
295
+ return "+ new session";
296
+ const label = describeSession(row.session, now);
297
+ // A row another live cruxy holds is offered — the user may want to
298
+ // see it — but says so, so picking it is not a surprise refusal.
299
+ const holder = sessionHeldBy(row.session.file);
300
+ return holder ? `${label} · open in another cruxy` : label;
301
+ },
195
302
  // Non-interactive with no id named: starting fresh is the safe default,
196
303
  // never an arbitrary session picked on the user's behalf.
197
304
  defaultValue: { kind: "new" },
@@ -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
+ }
@@ -7,7 +7,7 @@ import { UNRESOLVED_TIER, } from "../budget/index.js";
7
7
  import { resolveTaskModel } from "../routing/index.js";
8
8
  import { Workspace } from "../workspace/index.js";
9
9
  import { Budget, resolveBudget } from "../agent/budget.js";
10
- import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
10
+ import { isWriteTool, scopeRegistry } from "./registry-scope.js";
11
11
  import { Semaphore } from "./semaphore.js";
12
12
  import { makeSpawnSubagentTool } from "./spawn-tool.js";
13
13
  /** Longest task excerpt shown in render chrome — display, not record. */
@@ -556,7 +556,7 @@ function taskLabel(task) {
556
556
  /** A child that holds any mutating tool — the disjoint-scope check's unit. A
557
557
  * spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
558
558
  function isWriter(spec) {
559
- return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
559
+ return (spec.tools ?? []).some(isWriteTool);
560
560
  }
561
561
  /**
562
562
  * The tokens a child is KNOWN to have spent, or `undefined` when no request
@@ -17,8 +17,20 @@ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
17
17
  /**
18
18
  * Mutating tools (C.33): a child holding ANY of these is a "writer" for the
19
19
  * disjoint-scope check. Two writers in one parallel batch must target distinct
20
- * roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
21
- * side-effecting tool set (file writes, shell/test, VCS).
20
+ * roots, or the batch is refused pre-dispatch.
21
+ *
22
+ * This is the set of REGISTERED tool names that gate on `ctx.requestApproval`
23
+ * and act on a workspace root: file writes, shell/test, the PR tool, and the
24
+ * background-job dispatcher (a job is a whole agent run that may be granted
25
+ * any of the others). `registry-scope.test.ts` pins every name here to a tool
26
+ * that actually exists — until P1 this set named three tools that never did
27
+ * (`git_commit`, `git_branch`, `open_pr`) and omitted `create_pull_request`,
28
+ * so a child holding the PR tool was never counted as a writer and two of them
29
+ * could be dispatched against one root.
30
+ *
31
+ * Deliberately NOT here: `remember` (writes the memory store, not a root —
32
+ * root-disjointness says nothing about it) and the spawn tools (stripped from
33
+ * every child scope). MCP tools are covered by prefix in {@link isWriteTool}.
22
34
  */
23
35
  export const SUBAGENT_WRITE_TOOLS = new Set([
24
36
  "write_file",
@@ -26,10 +38,21 @@ export const SUBAGENT_WRITE_TOOLS = new Set([
26
38
  "apply_patch",
27
39
  "run_command",
28
40
  "run_tests",
29
- "git_commit",
30
- "git_branch",
31
- "open_pr",
41
+ "create_pull_request",
42
+ "run_in_background",
32
43
  ]);
44
+ /** The wire-name prefix every MCP-proxied tool carries (see `mcp/adapter.ts`). */
45
+ const MCP_TOOL_PREFIX = "mcp__";
46
+ /**
47
+ * Whether granting `name` to a child makes it a writer. The named set above,
48
+ * plus every MCP tool: the adapter gates each call on approval precisely
49
+ * because a server's tool can mutate anything, and the classifier never lets
50
+ * the server declare itself read-only — so the disjointness check must not
51
+ * either.
52
+ */
53
+ export function isWriteTool(name) {
54
+ return SUBAGENT_WRITE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX);
55
+ }
33
56
  /**
34
57
  * The default child toolset: read-only investigation plus skills. Mirrors the
35
58
  * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
@@ -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({
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath, toPosix } from "./paths.js";
5
5
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
+ import { changedSince, snapshotFile, snapshotOf, } from "./snapshot.js";
6
7
  /** How many leading lines of a created file the approval preview shows. */
7
8
  const PREVIEW_LINES = 20;
8
9
  /**
@@ -162,6 +163,24 @@ export const applyPatchTool = {
162
163
  if (!decision.allow) {
163
164
  return { ok: false, error: decision.feedback ?? "patch denied" };
164
165
  }
166
+ // The approval covered THESE files in THE states they were read in. Any
167
+ // path that moved during the wait — rewritten, deleted, or created by
168
+ // something else — voids the whole patch: nothing is applied, so the model
169
+ // re-reads and resubmits rather than landing a half-stale patch (P1).
170
+ // Every drifted path is named, not just the first: a retry that knows one
171
+ // of two moved re-reads one file and trips over the other.
172
+ const drifted = [];
173
+ for (const p of planned) {
174
+ const moved = await changedSince(p.abs, p.approved, p.rel);
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
+ }
181
+ }
182
+ if (drifted.length > 0)
183
+ return { ok: false, error: drifted.join("\n") };
165
184
  // Validation passed and the user approved; apply everything. A mid-apply I/O
166
185
  // failure is rare but reported with what already landed.
167
186
  const applied = [];
@@ -195,28 +214,44 @@ async function openTrack(i, op, abs, ctx) {
195
214
  // messages), consistent with every other path tool — see {@link toPosix}.
196
215
  const rel = toPosix(path.relative(ctx.cwd, abs));
197
216
  const base = { abs, rel, firstOp: i, hunks: [] };
198
- if (op.type === "create") {
199
- if (await exists(abs)) {
200
- return { ok: false, error: opError(i, op, "file already exists") };
217
+ if (op.type === "create" || op.type === "delete") {
218
+ let approved;
219
+ try {
220
+ approved = await snapshotFile(abs);
201
221
  }
202
- const content = op.content ?? "";
203
- return {
204
- ok: true,
205
- track: { ...base, kind: "create", content, eol: detectEol(content) },
206
- };
207
- }
208
- if (op.type === "delete") {
209
- if (!(await exists(abs))) {
222
+ catch (err) {
223
+ return { ok: false, error: opError(i, op, err.message) };
224
+ }
225
+ if (op.type === "create") {
226
+ if (approved.kind === "present") {
227
+ return { ok: false, error: opError(i, op, "file already exists") };
228
+ }
229
+ const content = op.content ?? "";
230
+ return {
231
+ ok: true,
232
+ track: {
233
+ ...base,
234
+ kind: "create",
235
+ content,
236
+ eol: detectEol(content),
237
+ approved,
238
+ },
239
+ };
240
+ }
241
+ if (approved.kind === "absent") {
210
242
  return { ok: false, error: opError(i, op, "file not found") };
211
243
  }
212
244
  return {
213
245
  ok: true,
214
- track: { ...base, kind: "delete", content: "", eol: "\n" },
246
+ track: { ...base, kind: "delete", content: "", eol: "\n", approved },
215
247
  };
216
248
  }
217
249
  let content;
250
+ let approved;
218
251
  try {
219
- content = await fs.readFile(abs, "utf8");
252
+ const bytes = await fs.readFile(abs);
253
+ approved = snapshotOf(bytes);
254
+ content = bytes.toString("utf8");
220
255
  }
221
256
  catch (err) {
222
257
  if (err.code === "ENOENT") {
@@ -229,6 +264,7 @@ async function openTrack(i, op, abs, ctx) {
229
264
  kind: "update",
230
265
  content,
231
266
  eol: detectEol(content),
267
+ approved,
232
268
  };
233
269
  const failure = applyHunk(i, op, track);
234
270
  return failure ? { ok: false, error: failure } : { ok: true, track };
@@ -261,12 +297,12 @@ function applyHunk(i, op, track) {
261
297
  }
262
298
  /** Collapse a finished track into the single write it represents. */
263
299
  function toPlanned(track) {
264
- const { kind, abs, rel, content, hunks } = track;
300
+ const { kind, abs, rel, content, hunks, approved } = track;
265
301
  if (kind === "delete")
266
- return { op: "delete", abs, rel };
302
+ return { op: "delete", abs, rel, approved };
267
303
  if (kind === "create")
268
- return { op: "create", abs, rel, content };
269
- return { op: "update", abs, rel, content, hunks };
304
+ return { op: "create", abs, rel, content, approved };
305
+ return { op: "update", abs, rel, content, hunks, approved };
270
306
  }
271
307
  /** Shape a planned op into its approval-preview form. */
272
308
  function toPreview(p) {
@@ -287,9 +323,3 @@ function toPreview(p) {
287
323
  function opError(i, op, reason) {
288
324
  return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
289
325
  }
290
- async function exists(abs) {
291
- return fs
292
- .access(abs)
293
- .then(() => true)
294
- .catch(() => false);
295
- }
@@ -2,10 +2,15 @@ import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { resolveToolPath } from "./paths.js";
4
4
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
5
+ import { changedSince, snapshotOf } from "./snapshot.js";
5
6
  /**
6
7
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
8
  * The uniqueness requirement is checked before approval so the model can fix an
8
9
  * ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
10
+ *
11
+ * The bytes read before approval are the state the approval is granted
12
+ * against; the write is refused if the file is no longer in that state when
13
+ * the write is about to happen (P1 — see `snapshot.ts`).
9
14
  */
10
15
  export const editFileTool = {
11
16
  name: "edit_file",
@@ -29,8 +34,11 @@ export const editFileTool = {
29
34
  return { ok: false, error: err.message };
30
35
  }
31
36
  let content;
37
+ let approvedState;
32
38
  try {
33
- content = await fs.readFile(abs, "utf8");
39
+ const bytes = await fs.readFile(abs);
40
+ approvedState = snapshotOf(bytes);
41
+ content = bytes.toString("utf8");
34
42
  }
35
43
  catch (err) {
36
44
  if (err.code === "ENOENT") {
@@ -64,6 +72,16 @@ export const editFileTool = {
64
72
  const updated = content.slice(0, match.start) +
65
73
  applyEol(input.new_str, detectEol(content)) +
66
74
  content.slice(match.end);
75
+ // The approval covered a diff against the bytes read above. Anything that
76
+ // changed the file during the wait makes `updated` a splice of stale
77
+ // content — refuse rather than overwrite what is there now (P1).
78
+ const moved = await changedSince(abs, approvedState, input.path);
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
+ }
67
85
  try {
68
86
  await fs.writeFile(abs, updated, "utf8");
69
87
  return { ok: true, output: `edited ${input.path}` };
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { ErrorCode } from "../../errors/index.js";
4
+ /** Snapshot the bytes a tool has ALREADY read (no second read). */
5
+ export function snapshotOf(bytes) {
6
+ return {
7
+ kind: "present",
8
+ digest: createHash("sha256").update(bytes).digest("hex"),
9
+ };
10
+ }
11
+ /**
12
+ * Read `abs` and snapshot it. A missing path is a legitimate state (`absent`)
13
+ * — it is what `write_file` and a patch `create` are approved against. Every
14
+ * other failure (a directory at the path, EACCES) propagates: the caller
15
+ * cannot build a truthful preview of a file it cannot read.
16
+ */
17
+ export async function snapshotFile(abs) {
18
+ try {
19
+ return snapshotOf(await fs.readFile(abs));
20
+ }
21
+ catch (err) {
22
+ if (err.code === "ENOENT") {
23
+ return { kind: "absent" };
24
+ }
25
+ throw err;
26
+ }
27
+ }
28
+ /**
29
+ * Re-read `abs` and compare it with the state the approval was granted
30
+ * against. Returns `null` when the file is exactly as it was, otherwise the
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.
34
+ *
35
+ * Call this AFTER approval and IMMEDIATELY before the write, with nothing
36
+ * awaited in between — the point is to make the gap as small as the platform
37
+ * allows, not to check early and then wait.
38
+ */
39
+ export async function changedSince(abs, approved, rel) {
40
+ let now;
41
+ try {
42
+ now = await snapshotFile(abs);
43
+ }
44
+ catch (err) {
45
+ return refusal(rel, `could not be re-read before the approved write (${err.message})`);
46
+ }
47
+ if (approved.kind === "absent") {
48
+ return now.kind === "absent"
49
+ ? null
50
+ : refusal(rel, "was created by something else after it was approved as a new file");
51
+ }
52
+ if (now.kind === "absent") {
53
+ return refusal(rel, "was deleted after it was read");
54
+ }
55
+ if (now.digest !== approved.digest) {
56
+ return refusal(rel, "changed on disk after it was read");
57
+ }
58
+ return null;
59
+ }
60
+ /** The one refusal shape: what moved, that nothing was written, what to do. */
61
+ function refusal(rel, what) {
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
+ };
68
+ }