opencode-workflow-guard 1.13.6 → 1.14.1

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.
package/src/lib/verify.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, type Hash } from "node:crypto";
3
3
  import { lstatSync, readFileSync, readlinkSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { dynamicShellSyntaxIn, normalize } from "./shell.ts";
@@ -141,6 +141,35 @@ function warnFingerprintSkip(root: string, file: string, detail: string): void {
141
141
  } catch {}
142
142
  }
143
143
 
144
+ /**
145
+ * Content hash over tracked repository content only: the index listing
146
+ * (staged/tracked blob hashes) plus the worktree-vs-index diff (unstaged
147
+ * edits). Review evidence binds to this fingerprint, not to the full one:
148
+ * untracked scratch files are not part of the reviewed diff, so creating,
149
+ * modifying, or deleting them must never invalidate a recorded approval.
150
+ */
151
+ function trackedContentHash(root: string): Hash | undefined {
152
+ const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
153
+ cwd: root,
154
+ encoding: "utf8",
155
+ timeout: 5_000,
156
+ });
157
+ const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
158
+ cwd: root,
159
+ encoding: "utf8",
160
+ timeout: 5_000,
161
+ });
162
+ if (staged.status !== 0 || unstaged.status !== 0) return undefined;
163
+ return createHash("sha256").update(staged.stdout).update(unstaged.stdout);
164
+ }
165
+
166
+ export function getTrackedWorktreeFingerprint(root: string): string | undefined {
167
+ try {
168
+ return trackedContentHash(root)?.digest("hex");
169
+ } catch {}
170
+ return undefined;
171
+ }
172
+
144
173
  export function getGitWorktreeFingerprint(root: string): string | undefined {
145
174
  try {
146
175
  // Index listing captures the exact staged/tracked content (blob hashes);
@@ -148,23 +177,14 @@ export function getGitWorktreeFingerprint(root: string): string | undefined {
148
177
  // contents cover files git does not yet track. Together these bind the
149
178
  // fingerprint to file contents regardless of whether HEAD exists, so
150
179
  // freshly initialized (commitless) repositories are covered too.
151
- const staged = spawnSync("git", ["ls-files", "--stage", "-z"], {
152
- cwd: root,
153
- encoding: "utf8",
154
- timeout: 5_000,
155
- });
156
- const unstaged = spawnSync("git", ["diff", "--no-ext-diff", "--"], {
157
- cwd: root,
158
- encoding: "utf8",
159
- timeout: 5_000,
160
- });
180
+ const hash = trackedContentHash(root);
181
+ if (!hash) return undefined;
161
182
  const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
162
183
  cwd: root,
163
184
  encoding: "utf8",
164
185
  timeout: 5_000,
165
186
  });
166
- if (staged.status !== 0 || unstaged.status !== 0 || untracked.status !== 0) return undefined;
167
- const hash = createHash("sha256").update(staged.stdout).update(unstaged.stdout);
187
+ if (untracked.status !== 0) return undefined;
168
188
  for (const file of untracked.stdout.split("\0").filter(Boolean).sort()) {
169
189
  // The path itself is always part of the fingerprint, so a skipped
170
190
  // entry still binds its presence even when its contents do not.
@@ -1,5 +1,5 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
1
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
2
+ import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, join, resolve, sep } from "node:path";
5
5
  import { getWorkspaceRoot } from "./state.ts";
@@ -63,6 +63,89 @@ function localBranchExists(branch: string, root: string): boolean {
63
63
  return res.status === 0;
64
64
  }
65
65
 
66
+ /**
67
+ * Worktree timeout: WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS env wins, then the
68
+ * 15s default. Large checkouts can exceed 15s; without this the killed
69
+ * `git worktree add` left the branch behind with only its stderr progress
70
+ * text as the reported reason.
71
+ */
72
+ export function resolveWorktreeTimeoutMs(): number {
73
+ const envValue = Number(process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS);
74
+ return Number.isFinite(envValue) && envValue > 0 ? envValue : 15_000;
75
+ }
76
+
77
+ /** git exit status is the failure signal; stderr is only context (progress lines land there too). */
78
+ function describeGitFailure(operation: string, res: SpawnSyncReturns<string>): string {
79
+ const status = res.status === null
80
+ ? `null (killed${res.signal ? ` via ${res.signal}` : ""}${res.error ? `: ${res.error.message}` : ""})`
81
+ : String(res.status);
82
+ const stderrTail = (res.stderr ?? "").trim().split("\n").filter(Boolean).slice(-3).join("; ");
83
+ return `git ${operation} failed (exit status ${status})${stderrTail ? `: ${stderrTail}` : ""}`;
84
+ }
85
+
86
+ /**
87
+ * Rolls back the partial state of a failed `git worktree add`. git creates
88
+ * the branch ref before the worktree checkout, so a failure after that point
89
+ * (target path collision, killed checkout, hook error) leaves a dangling
90
+ * branch and possibly stale worktree admin entries behind. Deletion of the
91
+ * branch only happens when this call created it.
92
+ */
93
+ function rollbackFailedWorktreeAdd(options: { branch: string; branchCreatedByUs: boolean; targetPath: string; existedBefore: boolean; root: string }): string {
94
+ const { branch, branchCreatedByUs, targetPath, existedBefore, root } = options;
95
+ const notes: string[] = [];
96
+ try {
97
+ const registered = (registeredWorktreePaths(root) ?? []).map((path) => resolve(path));
98
+ const targetRegistered = registered.includes(resolve(targetPath));
99
+ if (targetRegistered && !existedBefore && existsSync(targetPath)) {
100
+ // Only a worktree this call registered may be force-removed. A
101
+ // pre-existing registered worktree at the same sanitized path
102
+ // (duplicate name, concurrent subagents, feat/x vs feat:x) is the
103
+ // reason the add failed - it must never be destroyed by rollback.
104
+ spawnSync("git", ["worktree", "remove", "--force", targetPath], {
105
+ cwd: root,
106
+ env: getCleanGitEnv(),
107
+ encoding: "utf8",
108
+ timeout: 10_000,
109
+ });
110
+ }
111
+ if (!existedBefore) {
112
+ // Clear stale worktree admin entries left by an interrupted add for
113
+ // this new path. Scoped to the new-path case so unrelated
114
+ // worktrees on temporarily unavailable paths are never pruned.
115
+ spawnSync("git", ["worktree", "prune"], {
116
+ cwd: root,
117
+ env: getCleanGitEnv(),
118
+ encoding: "utf8",
119
+ timeout: 10_000,
120
+ });
121
+ }
122
+ if (!existedBefore && existsSync(targetPath)) {
123
+ // git may have partially populated the directory before failing.
124
+ // It did not exist before this call, so removing it is safe; a
125
+ // pre-existing path (e.g. the reason the add failed) is left alone.
126
+ try { rmSync(targetPath, { recursive: true, force: true }); } catch {}
127
+ }
128
+ if (branchCreatedByUs) {
129
+ // After the deregistration above: a branch cannot be deleted while
130
+ // still checked out in a worktree.
131
+ const deleteRes = spawnSync("git", ["branch", "-D", branch], {
132
+ cwd: root,
133
+ env: getCleanGitEnv(),
134
+ encoding: "utf8",
135
+ timeout: 5_000,
136
+ });
137
+ if (deleteRes.status !== 0) {
138
+ notes.push(`the created branch '${branch}' could not be rolled back and was left in place; delete it manually if it is not needed`);
139
+ }
140
+ }
141
+ } catch {
142
+ if (branchCreatedByUs) {
143
+ notes.push(`the created branch '${branch}' may remain; delete it manually if it is not needed`);
144
+ }
145
+ }
146
+ return notes.join("; ");
147
+ }
148
+
66
149
  export function createGitWorktree(
67
150
  branch: string,
68
151
  baseBranch = "HEAD",
@@ -77,12 +160,13 @@ export function createGitWorktree(
77
160
 
78
161
  const storageBase = getWorktreeStorageDir(root);
79
162
  const targetPath = join(storageBase, branch.replace(/[/\\:]/g, "-"));
163
+ const existedBefore = existsSync(targetPath);
80
164
 
81
165
  try {
82
166
  mkdirSync(storageBase, { recursive: true });
83
167
 
84
- const exists = localBranchExists(branch, root);
85
- const gitArgs = exists
168
+ const branchAlreadyExisted = localBranchExists(branch, root);
169
+ const gitArgs = branchAlreadyExisted
86
170
  ? ["worktree", "add", targetPath, branch]
87
171
  : ["worktree", "add", "-b", branch, targetPath, baseBranch];
88
172
 
@@ -90,10 +174,12 @@ export function createGitWorktree(
90
174
  cwd: root,
91
175
  env: getCleanGitEnv(),
92
176
  encoding: "utf8",
93
- timeout: 15_000,
177
+ timeout: resolveWorktreeTimeoutMs(),
94
178
  });
95
179
  if (addRes.status !== 0) {
96
- return { success: false, error: addRes.stderr.trim() || `git worktree add failed` };
180
+ const rollbackNote = rollbackFailedWorktreeAdd({ branch, branchCreatedByUs: !branchAlreadyExisted, targetPath, existedBefore, root });
181
+ const error = describeGitFailure("worktree add", addRes) + (rollbackNote ? `; ${rollbackNote}` : "");
182
+ return { success: false, error };
97
183
  }
98
184
 
99
185
  // Symlink the parent's node_modules so tooling works without a fresh install.
@@ -131,16 +217,44 @@ export function cleanupGitWorktree(
131
217
  root = getWorkspaceRoot(),
132
218
  ): { success: boolean; error?: string } {
133
219
  try {
220
+ const resolved = resolve(worktreePath);
221
+ const storageBase = resolve(getWorktreeStorageDir(root));
134
222
  if (!existsSync(worktreePath)) {
135
- return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
223
+ // A registered worktree whose directory is gone (manual removal, a
224
+ // crashed add, external cleanup) still holds a stale admin entry that
225
+ // keeps it in `git worktree list` as prunable, and must still be
226
+ // cleanable. Anything outside storage or unregistered is refused.
227
+ // The primary working tree is never at risk: prune only removes
228
+ // admin dirs under .git/worktrees/<id>, which the main tree lacks.
229
+ if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
230
+ return {
231
+ success: false,
232
+ error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
233
+ };
234
+ }
235
+ const registeredMissing = registeredWorktreePaths(root);
236
+ if (!registeredMissing) {
237
+ return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
238
+ }
239
+ if (!registeredMissing.map((path) => resolve(path)).includes(resolved)) {
240
+ return { success: false, error: `Worktree path '${worktreePath}' does not exist.` };
241
+ }
242
+ const prune = spawnSync("git", ["worktree", "prune"], {
243
+ cwd: root,
244
+ env: getCleanGitEnv(),
245
+ encoding: "utf8",
246
+ timeout: 10_000,
247
+ });
248
+ if (prune.status !== 0) {
249
+ return { success: false, error: describeGitFailure("worktree prune", prune) };
250
+ }
251
+ return { success: true };
136
252
  }
137
253
 
138
254
  // Ownership validation: the path must live under the configured worktree
139
255
  // storage directory AND be a registered worktree of this repository. This
140
256
  // refuses arbitrary directories, other repos' worktrees, and the primary
141
257
  // working tree - cleanup must never be an unrestricted delete primitive.
142
- const resolved = resolve(worktreePath);
143
- const storageBase = resolve(getWorktreeStorageDir(root));
144
258
  if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
145
259
  return {
146
260
  success: false,
@@ -84,23 +84,43 @@ const INTERACTIVE_COMMAND_PATTERNS: Array<{ regex: RegExp; name: string; advice:
84
84
 
85
85
  const MONITOR_ADVICE = "Use ps aux, uptime, or batch flags (e.g. top -b -n 1) instead of interactive monitors.";
86
86
  const PAGER_ADVICE = "Use cat, head, or grep with non-interactive pipes instead of interactive pagers.";
87
- // Process monitoring tokens are matched on whitespace/segment boundaries so that
88
- // an unrelated word such as `desktop`, a hyphenated filename like `top-level-dir`,
89
- // or a quoted argument like `echo "top"` is not mistaken for the `top` command.
90
- const ALWAYS_INTERACTIVE_MONITOR_RE = /(?:^|[;&|\s])(?:htop|btop|atop|glances)(?:$|[;&|\s])/i;
91
- const TOP_COMMAND_RE = /(?:^|[;&|\s])top(?:$|[;&|\s])/i;
87
+ // Monitor tokens are only interactive when they are the command of a
88
+ // segment: a `top` in an argument (a secret name, a grep pattern, `echo
89
+ // top`) is data, not the monitor. Wrappers are stripped first, so `sudo top`
90
+ // and `timeout 5 top` are still caught, and `eval top` / `sh -c top` recurse
91
+ // like the pager check. `top` in batch mode stays allowed. Indirection that
92
+ // this does not model (`watch top`, `xargs top`, `man top`, shell loop bodies
93
+ // such as `while true; do top; done`) is a known, documented limitation.
94
+ const INTERACTIVE_MONITORS = new Set(["top", "htop", "btop", "atop", "glances"]);
92
95
  const TOP_BATCH_FLAG_RE = /(?:^|\s)(?:--batch|-[A-Za-z]*b[A-Za-z]*)(?:\s|$)/i;
93
96
 
94
- function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
95
- for (const segment of command.split(/[;&|\n]+/)) {
96
- if (ALWAYS_INTERACTIVE_MONITOR_RE.test(segment)) {
97
- return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
97
+ function containsMonitorCommand(command: string, depth = 0): boolean {
98
+ if (depth >= 16) return false;
99
+ for (const segment of splitShellSegments(command)) {
100
+ const words = unwrapShellWords(segment);
101
+ while (words[0] === "--") words.shift();
102
+ const executable = basename(words[0] ?? "").toLowerCase();
103
+ const candidate = executable === "busybox" ? basename(words[1] ?? "").toLowerCase() : executable;
104
+ if (INTERACTIVE_MONITORS.has(candidate)) {
105
+ // Batch mode is the monitor's own flag, not a wrapper's (e.g. `sudo -b
106
+ // top` backgrounds sudo; it does not put `top` in batch mode).
107
+ const monitorIndex = executable === candidate ? 0 : 1;
108
+ if (candidate === "top" && TOP_BATCH_FLAG_RE.test(words.slice(monitorIndex + 1).join(" "))) continue;
109
+ return true;
98
110
  }
99
- if (TOP_COMMAND_RE.test(segment) && !TOP_BATCH_FLAG_RE.test(segment)) {
100
- return { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE };
111
+ if (executable === "eval" && words.length > 1 && containsMonitorCommand(words.slice(1).join(" "), depth + 1)) return true;
112
+ if (/^(?:ba|z|da|k)?sh$/i.test(executable)) {
113
+ const commandFlag = words.findIndex((word, index) => index > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
114
+ if (commandFlag >= 0 && words[commandFlag + 1] && containsMonitorCommand(words[commandFlag + 1]!, depth + 1)) return true;
101
115
  }
102
116
  }
103
- return { isInteractive: false };
117
+ return false;
118
+ }
119
+
120
+ function checkProcessMonitorCommand(command: string): { isInteractive: boolean; name?: string; advice?: string } {
121
+ return containsMonitorCommand(command)
122
+ ? { isInteractive: true, name: "interactive process monitor", advice: MONITOR_ADVICE }
123
+ : { isInteractive: false };
104
124
  }
105
125
 
106
126
  function containsPagerCommand(command: string, depth = 0): boolean {
@@ -51,6 +51,23 @@ export function recordSuccessfulRead(observation: ReadObservation, sessionID: st
51
51
  reads.set(sessionID, sessionReads);
52
52
  }
53
53
 
54
+ /**
55
+ * Records the session's observation of a file it just mutated. A successful
56
+ * edit/write means the session authored the resulting bytes, so a follow-up
57
+ * edit to that file must not demand a redundant re-read. The pre-mutation
58
+ * digest is compared so a no-op or failed call (content unchanged) does not
59
+ * seed an observation, and an external change after the mutation still fails
60
+ * the next comparison.
61
+ */
62
+ export function recordMutationObservation(path: string, sessionID: string, beforeDigest?: string): void {
63
+ const current = fingerprint(path);
64
+ if (!current) return;
65
+ if (beforeDigest !== undefined && current.digest === beforeDigest) return;
66
+ const sessionReads = reads.get(sessionID) ?? new Map<string, ReadFingerprint>();
67
+ sessionReads.set(canonicalPath(path), current);
68
+ reads.set(sessionID, sessionReads);
69
+ }
70
+
54
71
  export function staleWriteReason(path: string, sessionID: string): string | undefined {
55
72
  const current = fingerprint(path);
56
73
  const canonical = canonicalPath(path);
@@ -1,7 +1,8 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { basename, relative, resolve } from "node:path";
3
- import { getWorkspaceRoot } from "../lib/state.ts";
3
+ import { getLiveControlPlaneRoots, getWorkspaceRoot } from "../lib/state.ts";
4
4
  import { decodeShellEscapes, prepareRedirectResidue } from "../lib/shell.ts";
5
+ import { pathWithinAnyRoot, realpathWithinAnyRoot } from "../lib/live-control-plane.ts";
5
6
 
6
7
  export const PROTECTED_PATH_REASON =
7
8
  "Blocked: modifying Open" +
@@ -56,19 +57,37 @@ export function isCollaborationInvocation(segment: string): boolean {
56
57
  return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
57
58
  }
58
59
 
59
- export function isSettingsTamper(command: string): boolean {
60
+ export function isSettingsTamper(command: string, liveControlPlaneRoots?: readonly string[]): boolean {
61
+ // When the host declares its live control-plane roots, path-shaped tamper is
62
+ // decided by the mutation TARGET's runtime location (`isProtectedPath`), not
63
+ // by matching any operand's raw text. That stops a copy/move SOURCE under a
64
+ // config path from being flagged while the destination is a sanctioned draft
65
+ // (finding F3). The opencode CLI verb patterns still apply. Callers that scan
66
+ // written content (edit payloads) pass no roots and keep the legacy
67
+ // fail-closed path matching.
68
+ const factMode = liveControlPlaneRoots !== undefined && liveControlPlaneRoots.length > 0;
60
69
  return command.split(/[\n|;&]+/).some((s) => {
61
70
  if (isCollaborationInvocation(s)) return false;
62
- const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
63
71
  const flattened = normalizeGlobPathEvasion(normalizeShellEvasion(s));
64
- return PATH_PATTERNS.some((re) => re.test(residue)) || VERB_PATTERNS.some((re) => re.test(flattened));
72
+ if (VERB_PATTERNS.some((re) => re.test(flattened))) return true;
73
+ if (factMode) return false;
74
+ const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
75
+ return PATH_PATTERNS.some((re) => re.test(residue));
65
76
  });
66
77
  }
67
78
 
68
- export function isProtectedPath(targetPath: string): boolean {
79
+ export function isProtectedPath(
80
+ targetPath: string,
81
+ liveControlPlaneRoots: readonly string[] | undefined = getLiveControlPlaneRoots(),
82
+ ): boolean {
69
83
  if (!targetPath) return false;
70
84
  const root = getWorkspaceRoot();
71
85
  const resolved = resolve(root, targetPath);
86
+ if (liveControlPlaneRoots && liveControlPlaneRoots.length > 0) {
87
+ // Fact mode: only the LIVE control plane is protected; versioned drafts
88
+ // (project .opencode/, dotfiles .config/opencode, worktrees) are not.
89
+ return pathWithinAnyRoot(resolved, liveControlPlaneRoots) || realpathWithinAnyRoot(resolved, liveControlPlaneRoots);
90
+ }
72
91
  const matches = (path: string): boolean => {
73
92
  const base = basename(path);
74
93
  const lower = path.toLowerCase();
@@ -16,7 +16,7 @@ import { resolve } from "node:path";
16
16
  import { join } from "node:path";
17
17
  import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
18
18
  import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
19
- import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
19
+ import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
20
20
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
21
21
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
22
22
  import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -66,6 +66,7 @@ import {
66
66
  sessionMutationTimestamps,
67
67
  sessionVerifyResults,
68
68
  getProjectConfig,
69
+ getLiveControlPlaneRoots,
69
70
  getOperationProfile,
70
71
  isRecoveryCheckpointsEnabled,
71
72
  isReviewRequired,
@@ -88,6 +89,7 @@ import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, str
88
89
  export {
89
90
  setWorkspaceRoot,
90
91
  getWorkspaceRoot,
92
+ getLiveControlPlaneRoots,
91
93
  setSdkClient,
92
94
  getLastMutationTimestamp,
93
95
  getMutationCount,
@@ -196,6 +198,7 @@ import {
196
198
  getCurrentGitCommitHash,
197
199
  getGitStatusSummary,
198
200
  getGitWorktreeFingerprint,
201
+ getTrackedWorktreeFingerprint,
199
202
  } from "./lib/verify.ts";
200
203
 
201
204
  export {
@@ -206,6 +209,7 @@ export {
206
209
  getCurrentGitCommitHash,
207
210
  getGitStatusSummary,
208
211
  getGitWorktreeFingerprint,
212
+ getTrackedWorktreeFingerprint,
209
213
  };
210
214
 
211
215
  // ── Secondary review rubric ──────────────────────────────────────────────────
@@ -219,6 +223,7 @@ import {
219
223
  isValidBranchName,
220
224
  createGitWorktree,
221
225
  cleanupGitWorktree,
226
+ resolveWorktreeTimeoutMs,
222
227
  } from "./lib/worktree.ts";
223
228
 
224
229
  export {
@@ -227,6 +232,7 @@ export {
227
232
  isValidBranchName,
228
233
  createGitWorktree,
229
234
  cleanupGitWorktree,
235
+ resolveWorktreeTimeoutMs,
230
236
  };
231
237
 
232
238
  // ── Policy modules ───────────────────────────────────────────────────────────
@@ -512,6 +518,12 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
512
518
  const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
513
519
  if (!pending) return;
514
520
  await runWithRuntimeState(pending.root, ctx.client, async () => {
521
+ // A successful mutation means this session authored the resulting
522
+ // bytes, so seed its observation: a follow-up edit to a just
523
+ // created/modified file must not demand a redundant re-read. A
524
+ // no-op/failed call (content unchanged) seeds nothing, and an
525
+ // external change after the mutation still fails the comparison.
526
+ for (const before of pending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
515
527
  const reports = await Promise.all(pending.snapshots.map((before) => runPostEditValidators(pending.root, before)));
516
528
  const report = reports.filter((value): value is string => Boolean(value)).join("\n\n");
517
529
  if (report) output.output = `${output.output}\n\n${report}`;