@yagni-app/code-staging 1.1.2-staging.1369.1 → 1.1.2-staging.1372.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.
@@ -72,6 +72,19 @@ export declare const TICKET_IMAGE_RULE: string;
72
72
  * the standalone word "pi", must not open with a `- ` bullet line, no emojis.
73
73
  */
74
74
  export declare const GITHUB_OPERATIONS: string;
75
+ /**
76
+ * File-tool steering: prefer the first-class write/edit tools over bash file
77
+ * writes, one write call per file instead of a heredoc batch, and never create
78
+ * documentation files unless asked. Mirrors Claude Code's "Using your tools"
79
+ * preference block plus its write-tool description guidance. The observed
80
+ * bash — the model traded tool-call count against the known-safe path — so
81
+ * the multi-file line steers that incentive back to one write call per file.
82
+ *
83
+ * Content constraints (parity with {@link GITHUB_OPERATIONS}): must not
84
+ * contain the standalone word "pi", must not open with a `- ` bullet line,
85
+ * no emojis, no ticket ids.
86
+ */
87
+ export declare const FILE_TOOL_STEERING: string;
75
88
  /**
76
89
  * The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
77
90
  * carries this exact sentence in every system prompt so its whole reminder
@@ -147,6 +160,16 @@ export interface BrandSystemPromptOptions {
147
160
  * attribution talk at all.
148
161
  */
149
162
  attributionSection?: string | null;
163
+ /**
164
+ * Inject the file-tool steering section (write over bash writes, edit for
165
+ * modifications). Set ONLY where the file-tool guards it promises are
166
+ * actually registered — the condensed-tools registration in index.ts. An
167
+ * opt-in flag rather than a section string because the mismatch it guards
168
+ * against (a prompt saying "this tool will fail without a read" on a
169
+ * surface with no guard — eval mode, desktop, classic rows) is an
170
+ * all-or-nothing per-surface call, not user policy.
171
+ */
172
+ fileToolSteering?: boolean;
150
173
  }
151
174
  /**
152
175
  * Rebrand pi's assembled system prompt as YAGNI Code's, and optionally inject a
@@ -155,6 +155,35 @@ export const GITHUB_OPERATIONS = "## GitHub Operations\n" +
155
155
  "multi-paragraph.";
156
156
  /** Stable header that starts the GitHub operations section (idempotency anchor). */
157
157
  const GITHUB_OPERATIONS_HEADER = "## GitHub Operations";
158
+ /**
159
+ * File-tool steering: prefer the first-class write/edit tools over bash file
160
+ * writes, one write call per file instead of a heredoc batch, and never create
161
+ * documentation files unless asked. Mirrors Claude Code's "Using your tools"
162
+ * preference block plus its write-tool description guidance. The observed
163
+ * bash — the model traded tool-call count against the known-safe path — so
164
+ * the multi-file line steers that incentive back to one write call per file.
165
+ *
166
+ * Content constraints (parity with {@link GITHUB_OPERATIONS}): must not
167
+ * contain the standalone word "pi", must not open with a `- ` bullet line,
168
+ * no emojis, no ticket ids.
169
+ */
170
+ export const FILE_TOOL_STEERING = "## File tools over bash file writes\n" +
171
+ "\n" +
172
+ "Prefer the first-class file tools over bash for every file change:\n" +
173
+ "\n" +
174
+ "* Use `write` to create files, never `cat >` with a heredoc or `echo >`\n" +
175
+ " redirection. When several files must be written, issue one `write` call per\n" +
176
+ " file rather than batching them into a single bash heredoc.\n" +
177
+ "* Use `edit` to modify existing files — `write` is for new files and complete\n" +
178
+ " rewrites, not for changes to files you have read.\n" +
179
+ "* NEVER create documentation files (*.md) or README files unless the user\n" +
180
+ " explicitly asks for one.\n" +
181
+ "\n" +
182
+ "Reserve bash for system commands and terminal operations that require shell\n" +
183
+ "execution. Using the file tools lets the user review your work and keeps the\n" +
184
+ "permission path silent; a bash write escapes both.";
185
+ /** Stable header that starts the file-tool steering section (idempotency anchor). */
186
+ const FILE_TOOL_STEERING_HEADER = "## File tools over bash file writes";
158
187
  /**
159
188
  * The injected-reminder framing (YAG-574, Change A prerequisite). Claude Code
160
189
  * carries this exact sentence in every system prompt so its whole reminder
@@ -345,6 +374,13 @@ export function brandSystemPrompt(original, opts = {}) {
345
374
  if (!s.includes(GITHUB_OPERATIONS_HEADER)) {
346
375
  s = `${s}\n\n${GITHUB_OPERATIONS}`;
347
376
  }
377
+ // 5b2b. File-tool steering — same standing-directive placement and
378
+ // idempotency shape as GitHub operations (guarded by its stable header),
379
+ // but gated on the caller confirming the guards it promises are live
380
+ // (see fileToolSteering's doc).
381
+ if (opts.fileToolSteering && !s.includes(FILE_TOOL_STEERING_HEADER)) {
382
+ s = `${s}\n\n${FILE_TOOL_STEERING}`;
383
+ }
348
384
  // 5b3. Commit / PR attribution — a standing directive next to the GitHub
349
385
  // operations it qualifies (same placement, same idempotency shape, keyed on
350
386
  // the section's stable first line). Settings-driven: the section is absent
@@ -28,6 +28,7 @@ import * as os from "node:os";
28
28
  import * as path from "node:path";
29
29
  import { createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, getAgentDir, getLanguageFromPath, highlightCode, renderDiff, SettingsManager, } from "@earendil-works/pi-coding-agent";
30
30
  import { Container, Text } from "@earendil-works/pi-tui";
31
+ import { ReadStateTracker, READ_FIRST_ERROR, readIsPartial, readTextOf, STALE_ERROR } from "./fileGuards.js";
31
32
  import { isQuiet, kindForTool, ToolRunTracker } from "./toolRuns.js";
32
33
  /** Lines of write content shown collapsed (mirrors Claude Code's preview). */
33
34
  export const WRITE_PREVIEW_LINES = 8;
@@ -55,6 +56,19 @@ const TITLE_BY_NAME = {
55
56
  find: "Find",
56
57
  ls: "List",
57
58
  };
59
+ /** Claude Code's write-tool description guidance, adapted to our arg names. */
60
+ const WRITE_DESCRIPTION = "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. " +
61
+ "Automatically creates parent directories.\n" +
62
+ "Usage:\n" +
63
+ "- If this is an existing file, you MUST use the read tool first to read the file's contents. " +
64
+ "This tool will fail if you did not read the file first.\n" +
65
+ "- Prefer the edit tool for modifying existing files — it only sends the diff. Only use this tool " +
66
+ "to create new files or for complete rewrites.\n" +
67
+ "- NEVER create documentation files (*.md) or README files unless explicitly requested by the user.";
68
+ /** Claude Code's edit-tool pre-read instruction, adapted. */
69
+ const EDIT_DESCRIPTION_APPENDIX = "\nUsage:\n" +
70
+ "- You must use the read tool at least once in the conversation before editing. This tool will " +
71
+ "error if you attempt an edit without reading the file.";
58
72
  /** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
59
73
  function clip(text, max) {
60
74
  const collapsed = text.replace(/\s+/g, " ").trim();
@@ -257,6 +271,10 @@ function textOf(result) {
257
271
  function hasImages(result) {
258
272
  return (result?.content ?? []).some((c) => c.type === "image");
259
273
  }
274
+ /** A failed tool result carries no fresh state to record. */
275
+ function isErrorResult(result) {
276
+ return result?.isError === true;
277
+ }
260
278
  function empty() {
261
279
  return new Container();
262
280
  }
@@ -318,6 +336,11 @@ export function registerCondensedTools(pi, deps = {}) {
318
336
  return defs;
319
337
  };
320
338
  definitionsByCwd.set(registrationCwd, buildDefinitions(registrationCwd));
339
+ // Read-first + staleness guards (Claude Code's FileWriteTool/FileEditTool
340
+ // parity): one session-scoped tracker records what `read` last returned, and
341
+ // write/edit refuse to overwrite an existing unread or changed file. The
342
+ // scratchpad is exempt so permission-free scratchpad writes stay silent.
343
+ const readState = new ReadStateTracker(deps.scratchpadDir);
321
344
  for (const name of BUILTIN_NAMES) {
322
345
  const base = definitionsFor(registrationCwd)[name];
323
346
  const kind = kindForTool(name);
@@ -333,10 +356,48 @@ export function registerCondensedTools(pi, deps = {}) {
333
356
  };
334
357
  pi.registerTool({
335
358
  ...base,
359
+ ...(name === "write" ? { description: WRITE_DESCRIPTION } : {}),
360
+ ...(name === "edit" ? { description: `${base.description}${EDIT_DESCRIPTION_APPENDIX}` } : {}),
336
361
  renderShell: "self",
337
- execute(toolCallId, params, signal, onUpdate, ctx) {
362
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
338
363
  const cwd = ctx?.cwd ?? registrationCwd;
339
- return definitionsFor(cwd)[name].execute(toolCallId, params, signal, onUpdate, ctx);
364
+ const args = params;
365
+ const target = typeof args?.path === "string" ? args.path : undefined;
366
+ // write/edit: refuse unread or stale overwrites, then refresh the
367
+ // read state so consecutive writes/edits keep working. A guard fs
368
+ // error that is not one of the two refusals fails OPEN (delegate);
369
+ // pi's own execute produces the error the model would have seen.
370
+ if (target && (name === "write" || name === "edit")) {
371
+ try {
372
+ await readState.assertFreshRead(target, cwd);
373
+ }
374
+ catch (error) {
375
+ if (error instanceof Error && (error.message === READ_FIRST_ERROR || error.message === STALE_ERROR)) {
376
+ throw error;
377
+ }
378
+ // Not a refusal — delegate and let pi's execute handle it.
379
+ }
380
+ }
381
+ const result = await definitionsFor(cwd)[name].execute(toolCallId, params, signal, onUpdate, ctx);
382
+ // read: record what the model saw. write/edit: the just-written
383
+ // state is the fresh truth. AWAITED, not fire-and-forget — a follow-up
384
+ // write/edit in the same burst must not race a pending refresh and
385
+ // get refused as stale (Claude Code updates readFileState before the
386
+ // tool call returns; the captures are one stat and at most one small
387
+ // read, and each fails soft internally). A FAILED read records
388
+ // nothing — its content is error text, not the file — and must not
389
+ // clobber a prior good record of the same path.
390
+ if (name === "read" && target && !isErrorResult(result)) {
391
+ const partial = readIsPartial({ path: args.path, offset: args.offset, limit: args.limit }, result.details ?? undefined);
392
+ await readState.recordRead(target, cwd, readTextOf(result), partial);
393
+ }
394
+ else if (name === "write" && target && typeof args.content === "string" && !isErrorResult(result)) {
395
+ await readState.recordWrite(target, cwd, args.content);
396
+ }
397
+ else if (name === "edit" && target && !isErrorResult(result)) {
398
+ await readState.refreshFromDisk(target, cwd);
399
+ }
400
+ return result;
340
401
  },
341
402
  renderCall(args, theme, context) {
342
403
  const slice = context;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Read-first + staleness guards for the `write` and `edit` file tools —
3
+ * Claude Code's FileWriteTool/FileEditTool safety level, adapted to pi's
4
+ * tool shapes.
5
+ *
6
+ * A session-scoped `ReadStateTracker` records what the `read` tool last
7
+ * returned per canonical path (fs.realpath where it exists, path.resolve
8
+ * otherwise). `write`/`edit` refuse to overwrite an existing file the
9
+ * model has not read in this session, and refuse again when the file
10
+ * changed on disk since that read (mtime, with a content-compare fallback
11
+ * so a spurious timestamp bump — cloud sync, antivirus, `utimes` noise —
12
+ * does not produce a false refusal on a full read). New-file creates and
13
+ * partial reads follow Claude Code exactly: a create needs no prior read;
14
+ * an offset/limit or truncated read does NOT satisfy the read-first
15
+ * requirement (`isPartialView`).
16
+ *
17
+ * Fail-open contract: any guard-internal filesystem error that is not one
18
+ * of the two refusals (EACCES, EMFILE, a racing delete, ...) delegates
19
+ * straight to the underlying pi execute, which produces its own error.
20
+ * The guard must never invent a refusal pi wouldn't.
21
+ */
22
+ /** Claude Code's exact refusal, errorCode 2. */
23
+ export declare const READ_FIRST_ERROR = "File has not been read yet. Read it first before writing to it.";
24
+ /** Claude Code's exact refusal, errorCode 3. */
25
+ export declare const STALE_ERROR = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
26
+ /** A read result's details slice, as pi's read tool emits it. */
27
+ interface ReadDetails {
28
+ truncation?: {
29
+ truncated?: boolean;
30
+ };
31
+ }
32
+ export interface ReadArgs {
33
+ path?: unknown;
34
+ offset?: unknown;
35
+ limit?: unknown;
36
+ }
37
+ /** Whether the read call viewed only a slice of the file. */
38
+ export declare function readIsPartial(args: ReadArgs, details: ReadDetails | undefined): boolean;
39
+ /** The text content of a read tool result, empty when it carries none. */
40
+ export declare function readTextOf(result: {
41
+ content?: Array<{
42
+ type: string;
43
+ text?: string;
44
+ }>;
45
+ } | undefined): string;
46
+ /**
47
+ * Session read state. One instance per registration (created by
48
+ * registerCondensedTools), so driver, `/go` stage children, and subagents
49
+ * each track their own session's reads — matching Claude Code's
50
+ * per-conversation readFileState.
51
+ */
52
+ export declare class ReadStateTracker {
53
+ /** Session scratchpad dir; paths under it are exempt from the guards. */
54
+ private readonly scratchpadDir;
55
+ /** Stat/read seams for tests. */
56
+ private readonly io;
57
+ private reads;
58
+ constructor(
59
+ /** Session scratchpad dir; paths under it are exempt from the guards. */
60
+ scratchpadDir: string | undefined,
61
+ /** Stat/read seams for tests. */
62
+ io?: {
63
+ stat?: (p: string) => Promise<{
64
+ mtimeMs: number;
65
+ }>;
66
+ readFile?: (p: string) => Promise<string>;
67
+ });
68
+ /** Record a successful read of `rawPath` (resolved against `cwd`). */
69
+ recordRead(rawPath: string, cwd: string, text: string, partial: boolean): Promise<void>;
70
+ /** Record a successful write (the written content is the fresh truth). */
71
+ recordWrite(rawPath: string, cwd: string, content: string): Promise<void>;
72
+ /** Refresh a record from disk after a successful edit (content unknown to us). */
73
+ refreshFromDisk(rawPath: string, cwd: string): Promise<void>;
74
+ private capture;
75
+ /**
76
+ * Throw one of the two refusals when the write/edit must not proceed.
77
+ * Resolves (returns) when the overwrite may continue; rethrows any fs
78
+ * error it did not classify, so the wrapper can fail open.
79
+ */
80
+ assertFreshRead(rawPath: string, cwd: string): Promise<void>;
81
+ private isInside;
82
+ }
83
+ export {};
84
+ //# sourceMappingURL=fileGuards.d.ts.map
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Read-first + staleness guards for the `write` and `edit` file tools —
3
+ * Claude Code's FileWriteTool/FileEditTool safety level, adapted to pi's
4
+ * tool shapes.
5
+ *
6
+ * A session-scoped `ReadStateTracker` records what the `read` tool last
7
+ * returned per canonical path (fs.realpath where it exists, path.resolve
8
+ * otherwise). `write`/`edit` refuse to overwrite an existing file the
9
+ * model has not read in this session, and refuse again when the file
10
+ * changed on disk since that read (mtime, with a content-compare fallback
11
+ * so a spurious timestamp bump — cloud sync, antivirus, `utimes` noise —
12
+ * does not produce a false refusal on a full read). New-file creates and
13
+ * partial reads follow Claude Code exactly: a create needs no prior read;
14
+ * an offset/limit or truncated read does NOT satisfy the read-first
15
+ * requirement (`isPartialView`).
16
+ *
17
+ * Fail-open contract: any guard-internal filesystem error that is not one
18
+ * of the two refusals (EACCES, EMFILE, a racing delete, ...) delegates
19
+ * straight to the underlying pi execute, which produces its own error.
20
+ * The guard must never invent a refusal pi wouldn't.
21
+ */
22
+ import * as fs from "node:fs/promises";
23
+ import * as path from "node:path";
24
+ import { logEvent } from "./errorSink.js";
25
+ /** Claude Code's exact refusal, errorCode 2. */
26
+ export const READ_FIRST_ERROR = "File has not been read yet. Read it first before writing to it.";
27
+ /** Claude Code's exact refusal, errorCode 3. */
28
+ export const STALE_ERROR = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
29
+ /** Whether the read call viewed only a slice of the file. */
30
+ export function readIsPartial(args, details) {
31
+ if (args.offset !== undefined || args.limit !== undefined)
32
+ return true;
33
+ return details?.truncation?.truncated === true;
34
+ }
35
+ /** The text content of a read tool result, empty when it carries none. */
36
+ export function readTextOf(result) {
37
+ return (result?.content ?? [])
38
+ .filter((c) => c.type === "text" && typeof c.text === "string")
39
+ .map((c) => c.text)
40
+ .join("\n");
41
+ }
42
+ /** Canonical key: fs.realpath where the entry exists (symlinks resolved,
43
+ * so two spellings of one file share a record and a symlink cannot smuggle
44
+ * the scratchpad exemption to an outside file). When the entry does not
45
+ * exist: a DANGLING symlink keys as its eventual target (a link inside a
46
+ * privileged dir whose target is outside must not carry the exemption to
47
+ * the file a write would create), and a genuinely missing tail keys as the
48
+ * canonicalized parent plus the basename (a not-yet-created target). */
49
+ async function canonicalKey(absolute, depth = 0) {
50
+ if (depth > 32) {
51
+ // A symlink chain too deep to resolve (a loop, or just a legitimately
52
+ // deep chain): the key falls back to the raw path (treated as an
53
+ // ordinary unread target — fail-safe) and the trip reaches the trail
54
+ // with the depth, so a confusing refusal on such a path is diagnosable —
55
+ // same intent as the sibling catch trails. "depth" not "loop": the cap
56
+ // fires for deep non-looping chains too.
57
+ logEvent({
58
+ source: "fileGuards",
59
+ level: "info",
60
+ event: "canonical_key_depth_guard",
61
+ fields: { depth },
62
+ });
63
+ return absolute;
64
+ }
65
+ try {
66
+ return await fs.realpath(absolute);
67
+ }
68
+ catch {
69
+ const stat = await fs.lstat(absolute).catch(() => null);
70
+ if (stat?.isSymbolicLink()) {
71
+ const target = await fs.readlink(absolute);
72
+ return canonicalKey(path.resolve(path.dirname(absolute), target), depth + 1);
73
+ }
74
+ const parent = path.dirname(absolute);
75
+ if (parent === absolute)
76
+ return absolute; // filesystem root
77
+ return path.join(await canonicalKey(parent, depth), path.basename(absolute));
78
+ }
79
+ }
80
+ /** Default disk read, typed as string (fs.readFile's overload returns a
81
+ * buffer union the tracker's seam does not). */
82
+ async function readTextFile(p) {
83
+ return (await fs.readFile(p)).toString("utf-8");
84
+ }
85
+ /**
86
+ * Session read state. One instance per registration (created by
87
+ * registerCondensedTools), so driver, `/go` stage children, and subagents
88
+ * each track their own session's reads — matching Claude Code's
89
+ * per-conversation readFileState.
90
+ */
91
+ export class ReadStateTracker {
92
+ scratchpadDir;
93
+ io;
94
+ reads = new Map();
95
+ constructor(
96
+ /** Session scratchpad dir; paths under it are exempt from the guards. */
97
+ scratchpadDir,
98
+ /** Stat/read seams for tests. */
99
+ io = {}) {
100
+ this.scratchpadDir = scratchpadDir;
101
+ this.io = io;
102
+ }
103
+ /** Record a successful read of `rawPath` (resolved against `cwd`). */
104
+ async recordRead(rawPath, cwd, text, partial) {
105
+ return this.capture(rawPath, cwd, { content: text, partial });
106
+ }
107
+ /** Record a successful write (the written content is the fresh truth). */
108
+ async recordWrite(rawPath, cwd, content) {
109
+ return this.capture(rawPath, cwd, { content, partial: false });
110
+ }
111
+ /** Refresh a record from disk after a successful edit (content unknown to us). */
112
+ async refreshFromDisk(rawPath, cwd) {
113
+ return this.capture(rawPath, cwd, {});
114
+ }
115
+ async capture(rawPath, cwd, known) {
116
+ const absolute = path.resolve(cwd, rawPath);
117
+ try {
118
+ const stat = await (this.io.stat ?? fs.stat)(absolute);
119
+ const content = known.content ?? await (this.io.readFile ?? readTextFile)(absolute);
120
+ // Floor at RECORD time as well as compare time (Claude Code's shape):
121
+ // a full-precision recorded mtime (ns fraction) would always exceed the
122
+ // floored current one and the staleness check would never fire.
123
+ const key = await canonicalKey(absolute);
124
+ this.reads.set(key, {
125
+ content,
126
+ mtimeMs: Math.floor(stat.mtimeMs),
127
+ partial: known.partial ?? false,
128
+ });
129
+ }
130
+ catch (error) {
131
+ // The file vanished between the tool result and the capture, or stat
132
+ // failed — the record stays absent and the NEXT write treats the file
133
+ // as unread. Fail-safe, never fail-loud — but never silent either:
134
+ // info (not debug — the durable trail filters debug out of a default
135
+ // session) so a later "unread" refusal after a successful read is
136
+ // diagnosable without a repro.
137
+ logEvent({
138
+ source: "fileGuards",
139
+ level: "info",
140
+ event: "record_capture_failed",
141
+ fields: { code: error?.code ?? "unknown" },
142
+ });
143
+ }
144
+ }
145
+ /**
146
+ * Throw one of the two refusals when the write/edit must not proceed.
147
+ * Resolves (returns) when the overwrite may continue; rethrows any fs
148
+ * error it did not classify, so the wrapper can fail open.
149
+ */
150
+ async assertFreshRead(rawPath, cwd) {
151
+ const absolute = path.resolve(cwd, rawPath);
152
+ const key = await canonicalKey(absolute);
153
+ if (this.scratchpadDir) {
154
+ const scratchKey = await canonicalKey(path.resolve(this.scratchpadDir));
155
+ if (this.isInside(key, scratchKey)) {
156
+ return; // permission-free scratchpad writes stay silent (never refuse)
157
+ }
158
+ }
159
+ let mtimeMs;
160
+ try {
161
+ mtimeMs = (await (this.io.stat ?? fs.stat)(key)).mtimeMs;
162
+ }
163
+ catch (error) {
164
+ const code = error?.code;
165
+ if (code === "ENOENT")
166
+ return; // a new-file create — no prior read needed
167
+ throw error; // unknown fs failure — the wrapper fails open
168
+ }
169
+ const record = this.reads.get(key);
170
+ if (!record || record.partial) {
171
+ throw new Error(READ_FIRST_ERROR);
172
+ }
173
+ if (Math.floor(mtimeMs) > record.mtimeMs) {
174
+ // Timestamp moved, but on some platforms (cloud sync, antivirus) it
175
+ // moves without content changes. For a full read, compare content
176
+ // before refusing — Claude Code's exact fallback. The record holds
177
+ // the read tool's exact output and pi returns file bytes verbatim,
178
+ // so identical content compares byte-equal.
179
+ let onDisk;
180
+ try {
181
+ onDisk = await (this.io.readFile ?? readTextFile)(key);
182
+ }
183
+ catch (error) {
184
+ // Unreadable now, but the read succeeded then — stale — but the real
185
+ // cause (EACCES/EIO on the re-read, not an edit) rides the trail so
186
+ // the refusal is not misdiagnosed as a content change.
187
+ logEvent({
188
+ source: "fileGuards",
189
+ level: "info",
190
+ event: "staleness_reread_failed",
191
+ fields: { code: error?.code ?? "unknown" },
192
+ });
193
+ throw new Error(STALE_ERROR);
194
+ }
195
+ if (onDisk !== record.content) {
196
+ throw new Error(STALE_ERROR);
197
+ }
198
+ // Content-identical: refresh the record so the next write's mtime
199
+ // comparison uses the bumped timestamp instead of refusing again.
200
+ this.reads.set(key, { ...record, mtimeMs: Math.floor(mtimeMs) });
201
+ }
202
+ }
203
+ isInside(absolute, base) {
204
+ const slashed = (p) => p.replace(/\\/g, "/");
205
+ const a = slashed(absolute);
206
+ const b = slashed(base).replace(/\/+$/, "");
207
+ return a === b || a.startsWith(`${b}/`);
208
+ }
209
+ }
210
+ //# sourceMappingURL=fileGuards.js.map
@@ -36,8 +36,10 @@ import { registerGoCommand } from "./pipeline/goCommand.js";
36
36
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
37
37
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
38
38
  import { registerSandbox } from "./sandbox/session.js";
39
- import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
39
+ import { isSandboxDenialOutput, sandboxAutoAllowDecision, sandboxDenialSignature, shouldUseSandbox } from "./sandbox/bash.js";
40
+ import { sandboxFailureKey } from "./permission/approvedPrefixes.js";
40
41
  import { createEscapeTally } from "./sandbox/escapeTally.js";
42
+ import { createGrantCensus } from "./permission/grantCensus.js";
41
43
  import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
42
44
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
43
45
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
@@ -723,6 +725,37 @@ export async function registerYagni(pi, deps = {}) {
723
725
  // the startup load is the trust boundary; live reload was reviewed and
724
726
  // rejected as a same-session self-authorization path, PR #1698).
725
727
  const sessionGrants = evalMode ? [] : loadGrants();
728
+ // Prior sandboxed failures (the auto-sandbox rung's session state): a
729
+ // bash tool_result whose output carries a sandbox-denial signature marks
730
+ // the command — its later escape attempts go to the consent dialog (the
731
+ // sandbox genuinely cannot run it). Content stays local: only the trimmed
732
+ // command string is held in memory, never logged.
733
+ const sandboxedFailures = new Map();
734
+ const SANDBOXED_FAILURES_MAX = 50;
735
+ const recordSandboxedFailure = (command) => {
736
+ // The CANONICAL key (sandboxFailureKey): a retry differing by quote
737
+ // rendering or safe decoration must hit the same entry — raw-string
738
+ // keys would miss exactly the variable shapes the grants layer
739
+ // canonicalizes for.
740
+ const key = sandboxFailureKey(command);
741
+ if (!key)
742
+ return;
743
+ sandboxedFailures.delete(key);
744
+ sandboxedFailures.set(key, true);
745
+ if (sandboxedFailures.size > SANDBOXED_FAILURES_MAX) {
746
+ const oldest = sandboxedFailures.keys().next().value;
747
+ if (oldest !== undefined)
748
+ sandboxedFailures.delete(oldest);
749
+ }
750
+ };
751
+ const hasSandboxedFailure = (command) => sandboxedFailures.has(sandboxFailureKey(command));
752
+ // A mode transition is a trust-posture change — the auto-sandbox rung's
753
+ // prior-failure evidence clears with the gate's other session caches
754
+ // (the gate's own fallback map clears via modeHolder.onSet; this map is
755
+ // the production one, so it must clear here too).
756
+ modeHolder.onSet(() => {
757
+ sandboxedFailures.clear();
758
+ });
726
759
  // settings-based permission rules, loaded once at startup (same
727
760
  // trust-boundary posture as grants: no mid-session reload). User config
728
761
  // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
@@ -758,6 +791,11 @@ export async function registerYagni(pi, deps = {}) {
758
791
  // shared by the gate (record), the /sandbox panel (breakdown), and the
759
792
  // session-end summary line.
760
793
  const escapeTally = createEscapeTally();
794
+ // The session grant census (telemetry-only): every grant the session
795
+ // loaded plus every match, for the /sandbox panel's never-matched
796
+ // surfacing. Nothing is pruned, nothing is written back to rules.json.
797
+ const grantCensus = createGrantCensus();
798
+ grantCensus.seed(sessionGrants);
761
799
  const sandboxHandle = evalMode
762
800
  ? null
763
801
  : registerSandbox(pi, {
@@ -766,6 +804,9 @@ export async function registerYagni(pi, deps = {}) {
766
804
  hasUI: (ctx) => ctx.hasUI,
767
805
  onShellResolutionRetry: (outcome) => telemetry.sandboxShellResolutionRetry(outcome),
768
806
  escapeBreakdown: () => escapeTally.breakdown(),
807
+ // The grant census snapshot for the /sandbox panel's grants
808
+ // section (session-scoped match counts + never-matched labels).
809
+ grantCensus: () => grantCensus.entries(),
769
810
  // Anchors project-protected paths (.yagni-code + its config.json in
770
811
  // denyWrite) and project-sourced permission rules to the repo the
771
812
  // session runs in — same root the gate uses for its rule anchoring.
@@ -785,6 +826,18 @@ export async function registerYagni(pi, deps = {}) {
785
826
  sandboxHandle.manager.initialized &&
786
827
  params.dangerouslyDisableSandbox === true &&
787
828
  sandboxHandle.settings().allowUnsandboxedCommands !== false,
829
+ // The auto-sandbox rung's would-run-wrapped proof: after the gate
830
+ // strips the flag, this command must actually be wrapped by
831
+ // shouldUseSandbox — an excludedCommands entry (or a manager reset)
832
+ // would otherwise run it UNSANDBOXED with zero consent. The gate
833
+ // additionally requires its own allow classification before it
834
+ // consults this predicate.
835
+ sandboxEscapeAutoSandbox: (command) => shouldUseSandbox({ command, dangerouslyDisableSandbox: false }, sandboxHandle.manager, sandboxHandle.settings()),
836
+ recordSandboxedFailure,
837
+ hasSandboxedFailure,
838
+ // Grant census feed: every grant match (prompt band + escape flow)
839
+ // counts toward the never-matched surfacing in /sandbox.
840
+ onGrantMatch: (grant) => grantCensus.record(grant),
788
841
  // Escape tally: interception-time, content-free on the always-on
789
842
  // info tier (the command prefix is command content — it rides the
790
843
  // debug tier only, same contract as logGateOutcomeTrail; the OTel
@@ -1248,8 +1301,18 @@ export async function registerYagni(pi, deps = {}) {
1248
1301
  // eval mode keeps pi's stock registration (measured behavior stays
1249
1302
  // byte-identical) and the desktop surface keeps the structured pipeline.
1250
1303
  // YAGNI_CLASSIC_TOOL_ROWS=1 is the debugging escape hatch back to pi's
1251
- // boxed renderers.
1252
- if (!evalMode && !isDesktopSurface() && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
1304
+ // boxed renderers. The file-tool guards and the guarded write/edit
1305
+ // descriptions ride this SAME registration, so the steering prompt section
1306
+ // (which promises those guards) is injected only when this is active —
1307
+ // eval mode stays byte-identical and desktop never gets a promise the
1308
+ // tools there don't keep.
1309
+ const condensedToolsWanted = !evalMode && !isDesktopSurface() && env.YAGNI_CLASSIC_TOOL_ROWS !== "1";
1310
+ // The steering flag tracks the registration OUTCOME, not the condition:
1311
+ // the catch below keeps activation alive when registration throws, and a
1312
+ // throw means the guards never went live — the prompt section that
1313
+ // promises them must not ship either.
1314
+ let condensedToolsRegistered = false;
1315
+ if (condensedToolsWanted) {
1253
1316
  try {
1254
1317
  registerCondensedTools(pi, {
1255
1318
  ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
@@ -1258,6 +1321,7 @@ export async function registerYagni(pi, deps = {}) {
1258
1321
  // the sandbox is off.
1259
1322
  ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
1260
1323
  });
1324
+ condensedToolsRegistered = true;
1261
1325
  }
1262
1326
  catch {
1263
1327
  // Rendering must never break activation; pi's built-ins remain.
@@ -1307,6 +1371,7 @@ export async function registerYagni(pi, deps = {}) {
1307
1371
  rulesSection,
1308
1372
  scratchpadSection: scratchpadSectionText,
1309
1373
  attributionSection: attributionPromptSection(attribution()),
1374
+ fileToolSteering: condensedToolsRegistered,
1310
1375
  }),
1311
1376
  });
1312
1377
  // Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
@@ -1658,12 +1723,39 @@ export async function registerYagni(pi, deps = {}) {
1658
1723
  pi.on("tool_result", (event) => {
1659
1724
  if (event.toolName !== "bash")
1660
1725
  return;
1661
- const command = typeof event.input?.command === "string"
1662
- ? event.input.command
1663
- : "";
1726
+ const input = (event.input ?? {});
1727
+ const command = typeof input.command === "string" ? input.command : "";
1664
1728
  if (GIT_MUTATING_PATTERN.test(command)) {
1665
1729
  footerInvalidateHandle.invalidateGit();
1666
1730
  }
1731
+ // The auto-sandbox rung's prior-failure evidence: a SANDBOXED bash
1732
+ // result carrying a sandbox-denial signature marks this command — its
1733
+ // later escape attempts re-arm the consent dialog instead of
1734
+ // auto-sandboxing into the same denial forever. Only sandboxed runs
1735
+ // count (an escaped run's failure text proves nothing about the
1736
+ // sandbox); only the trimmed command is kept, never logged.
1737
+ if (sandboxHandle &&
1738
+ !input.dangerouslyDisableSandbox &&
1739
+ event.isError &&
1740
+ command) {
1741
+ const text = (event.content ?? [])
1742
+ .map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : ""))
1743
+ .join("\n");
1744
+ if (isSandboxDenialOutput(text)) {
1745
+ recordSandboxedFailure(command);
1746
+ // The seed must be observable (debug tier only — no command content,
1747
+ // the closed signature class rides the field): a false-positive seed
1748
+ // session-long disables auto-sandbox for a command, and without this
1749
+ // line the only symptom would be unexplained dialogs.
1750
+ const signature = sandboxDenialSignature(text);
1751
+ logEvent({
1752
+ source: "sandbox",
1753
+ level: "debug",
1754
+ event: "sandbox_failure_seeded",
1755
+ fields: { signature },
1756
+ });
1757
+ }
1758
+ }
1667
1759
  });
1668
1760
  // Seed the unified error trail from tool-exec failures. A tool's SUCCESS
1669
1761
  // is content (it lives in the transcript); its FAILURE is an error and belongs