@yagni-app/code-staging 1.1.2-staging.1368.1 → 1.1.2-staging.1370.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
@@ -1248,8 +1248,18 @@ export async function registerYagni(pi, deps = {}) {
1248
1248
  // eval mode keeps pi's stock registration (measured behavior stays
1249
1249
  // byte-identical) and the desktop surface keeps the structured pipeline.
1250
1250
  // 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") {
1251
+ // boxed renderers. The file-tool guards and the guarded write/edit
1252
+ // descriptions ride this SAME registration, so the steering prompt section
1253
+ // (which promises those guards) is injected only when this is active —
1254
+ // eval mode stays byte-identical and desktop never gets a promise the
1255
+ // tools there don't keep.
1256
+ const condensedToolsWanted = !evalMode && !isDesktopSurface() && env.YAGNI_CLASSIC_TOOL_ROWS !== "1";
1257
+ // The steering flag tracks the registration OUTCOME, not the condition:
1258
+ // the catch below keeps activation alive when registration throws, and a
1259
+ // throw means the guards never went live — the prompt section that
1260
+ // promises them must not ship either.
1261
+ let condensedToolsRegistered = false;
1262
+ if (condensedToolsWanted) {
1253
1263
  try {
1254
1264
  registerCondensedTools(pi, {
1255
1265
  ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
@@ -1258,6 +1268,7 @@ export async function registerYagni(pi, deps = {}) {
1258
1268
  // the sandbox is off.
1259
1269
  ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
1260
1270
  });
1271
+ condensedToolsRegistered = true;
1261
1272
  }
1262
1273
  catch {
1263
1274
  // Rendering must never break activation; pi's built-ins remain.
@@ -1307,6 +1318,7 @@ export async function registerYagni(pi, deps = {}) {
1307
1318
  rulesSection,
1308
1319
  scratchpadSection: scratchpadSectionText,
1309
1320
  attributionSection: attributionPromptSection(attribution()),
1321
+ fileToolSteering: condensedToolsRegistered,
1310
1322
  }),
1311
1323
  });
1312
1324
  // Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
@@ -127,13 +127,64 @@ export declare function shouldUseSandboxForUserCommand(command: string, manager:
127
127
  * advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
128
128
  * hint (no annotation context, and the sink is not wired yet).
129
129
  */
130
- export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string): {
130
+ export declare function annotateCommandOutput(manager: YagniSandboxManager, command: string, output: string, fsScope?: {
131
+ cwd: string;
132
+ scope: FsDenialScope;
133
+ }): {
131
134
  text: string;
132
135
  net: {
133
136
  cls: NetworkDenialClass;
134
137
  hint: string;
135
138
  } | null;
139
+ fs: {
140
+ cls: FsDenialClass;
141
+ hint: string;
142
+ } | null;
136
143
  };
144
+ /** The filesystem denial classes this classifier knows (closed set — rides
145
+ * the sink line as a low-cardinality field, the same contract as
146
+ * NetworkDenialClass). write-scope-protected is a distinct class (not a
147
+ * sub-case) because its REMEDY differs: protected paths can never be
148
+ * admitted with an allow rule, so the hint must not point at the knob. */
149
+ export type FsDenialClass = "write-scope" | "write-scope-protected" | "heredoc-cwd";
150
+ /** The writable/denied root lists the fs classifier needs, passed in by the
151
+ * caller so the classifier stays pure (the established pattern — see
152
+ * networkDenialHint). allowWrite: the sandbox's writable roots (session cwd,
153
+ * the temp dirs, config allowWrite). denyWrite: the protected paths that are
154
+ * denied even when a wider allow would cover them (config files, state dirs). */
155
+ export interface FsDenialScope {
156
+ allowWrite: readonly string[];
157
+ denyWrite: readonly string[];
158
+ }
159
+ /**
160
+ * Classify a sandbox filesystem denial from command + output. Pure;
161
+ * advisory-only (the fs twin of networkDenialHint — never widens anything,
162
+ * the remedy is model-side or a user config edit, never a grant). Matches
163
+ * the signatures observed live and in the session review:
164
+ * - heredoc-cwd: `cannot create temp file for here document` /
165
+ * `/dev/fd/NN: Operation not permitted` — bash 3.2
166
+ * writes heredoc temp files relative to the CURRENT
167
+ * directory, so a cd outside the writable roots makes
168
+ * every heredoc fail even when the target is writable
169
+ * (reproduced live; the e2b /dev/fd/62 case is the
170
+ * process-substitution sibling of the same mechanism)
171
+ * - write-scope: an EPERM whose line names a write TARGET path
172
+ * outside the writable roots — the literal `/tmp`
173
+ * writes observed in the sessions (`cat > /tmp/x`,
174
+ * `> /tmp/after.txt`, python PermissionError on a
175
+ * non-root path)
176
+ * - write-scope-protected: same shape but the target is a PROTECTED path
177
+ * (denyWrite) — the config.json / state-dir denies.
178
+ * Protected paths can never be admitted with allow
179
+ * rules, so the hint copy differs.
180
+ * Returns null when the output carries no fs-denial signature — a network
181
+ * denial must NOT get an fs hint (the pinned negative in networkDenialHint
182
+ * is symmetric here).
183
+ */
184
+ export declare function fsDenialHint(command: string, output: string, cwd: string, scope: FsDenialScope): {
185
+ cls: FsDenialClass;
186
+ hint: string;
187
+ } | null;
137
188
  /** cwd must exist before spawn (guard borrowed from pi's local ops). */
138
189
  export declare function assertSpawnableCwd(cwd: string): void;
139
190
  /**
@@ -21,6 +21,7 @@
21
21
  * lessons (process-group kill, stdio release).
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
24
25
  import { logEvent } from "../errorSink.js";
25
26
  import { scrubSecrets } from "../pipeline/scrubSecrets.js";
26
27
  function stripLeadingSafeEnvVars(command) {
@@ -347,18 +348,128 @@ export function shouldUseSandboxForUserCommand(command, manager, settings) {
347
348
  * advisory only, never a grant. Uninitialized manager ⇒ passthrough, no
348
349
  * hint (no annotation context, and the sink is not wired yet).
349
350
  */
350
- export function annotateCommandOutput(manager, command, output) {
351
+ export function annotateCommandOutput(manager, command, output, fsScope) {
351
352
  if (!manager.initialized)
352
- return { text: output, net: null };
353
+ return { text: output, net: null, fs: null };
353
354
  const net = networkDenialHint(output);
354
355
  if (!output.includes("Operation not permitted") && !net)
355
- return { text: output, net: null };
356
+ return { text: output, net: null, fs: null };
356
357
  const annotated = output.includes("Operation not permitted")
357
358
  ? manager.annotateStderrWithSandboxFailures(command, output)
358
359
  : output;
359
- if (!net)
360
- return { text: annotated, net: null };
361
- return { text: `${annotated}\n\n[sandbox] ${net.hint}`, net };
360
+ // The fs classification: same regex surface, same composition position,
361
+ // but the NETWORK hint wins when both classify (network denials have a
362
+ // user-flippable knob; fs remedies are model-side). When net wins, fs
363
+ // returns null — the callers log BOTH returns unconditionally, so a
364
+ // non-null loser would emit a second census line for one denial.
365
+ const fs = net
366
+ ? null
367
+ : fsScope
368
+ ? fsDenialHint(command, output, fsScope.cwd, fsScope.scope)
369
+ : null;
370
+ const hint = net ?? fs;
371
+ if (!hint)
372
+ return { text: annotated, net: null, fs: null };
373
+ return { text: `${annotated}\n\n[sandbox] ${hint.hint}`, net, fs };
374
+ }
375
+ /** Is `p` at or under one of the roots? Prefix match on path-segment
376
+ * boundaries (`/var/folders/T` must not admit `/var/folders/T-evil`). */
377
+ function underRoot(p, roots) {
378
+ return roots.some((r) => {
379
+ if (p === r)
380
+ return true;
381
+ return p.startsWith(r.endsWith("/") ? r : r + "/");
382
+ });
383
+ }
384
+ /** Does the command carry a heredoc marker or a process substitution, plus a
385
+ * cd to a directory outside the writable roots? The discriminator for the
386
+ * heredoc-cwd drift shape: the temp-file write follows the shell's CURRENT
387
+ * directory, so a heredoc from inside the roots never has this failure —
388
+ * only the cd-drifted form does. */
389
+ function hasHeredocCwdDrift(command, cwd, scope) {
390
+ if (!/<<|<\(/.test(command))
391
+ return false;
392
+ // find every cd target and require at least one OUTSIDE the writable roots
393
+ const cdRe = /(^|[\n;&|(\s])cd\s+([^\n;&|)\s]+)/g;
394
+ let m;
395
+ while ((m = cdRe.exec(command)) !== null) {
396
+ const target = m[2].replace(/^("|')+|("|')+$/g, "");
397
+ // Resolve the same way a shell would for the classification question:
398
+ // absolute as-is, ~ against HOME (the common drift target), relative
399
+ // against the session cwd (a relative cd stays inside the roots only if
400
+ // the resolved path is under them).
401
+ let resolved = null;
402
+ if (target.startsWith("/"))
403
+ resolved = target;
404
+ else if (target.startsWith("~"))
405
+ resolved = target.replace(/^~/, process.env.HOME ?? "~");
406
+ else if (target !== "-" && !target.startsWith("$"))
407
+ resolved = join(cwd, target);
408
+ if (resolved && !underRoot(resolved, scope.allowWrite))
409
+ return true;
410
+ }
411
+ return false;
412
+ }
413
+ /**
414
+ * Classify a sandbox filesystem denial from command + output. Pure;
415
+ * advisory-only (the fs twin of networkDenialHint — never widens anything,
416
+ * the remedy is model-side or a user config edit, never a grant). Matches
417
+ * the signatures observed live and in the session review:
418
+ * - heredoc-cwd: `cannot create temp file for here document` /
419
+ * `/dev/fd/NN: Operation not permitted` — bash 3.2
420
+ * writes heredoc temp files relative to the CURRENT
421
+ * directory, so a cd outside the writable roots makes
422
+ * every heredoc fail even when the target is writable
423
+ * (reproduced live; the e2b /dev/fd/62 case is the
424
+ * process-substitution sibling of the same mechanism)
425
+ * - write-scope: an EPERM whose line names a write TARGET path
426
+ * outside the writable roots — the literal `/tmp`
427
+ * writes observed in the sessions (`cat > /tmp/x`,
428
+ * `> /tmp/after.txt`, python PermissionError on a
429
+ * non-root path)
430
+ * - write-scope-protected: same shape but the target is a PROTECTED path
431
+ * (denyWrite) — the config.json / state-dir denies.
432
+ * Protected paths can never be admitted with allow
433
+ * rules, so the hint copy differs.
434
+ * Returns null when the output carries no fs-denial signature — a network
435
+ * denial must NOT get an fs hint (the pinned negative in networkDenialHint
436
+ * is symmetric here).
437
+ */
438
+ export function fsDenialHint(command, output, cwd, scope) {
439
+ // heredoc-cwd first (most specific): the temp-file EPERM signatures, gated
440
+ // on the command actually carrying the drift shape.
441
+ if (/(cannot create temp file for here document|\/dev\/fd\/\d+: Operation not permitted)/.test(output) &&
442
+ hasHeredocCwdDrift(command, cwd, scope)) {
443
+ return {
444
+ cls: "heredoc-cwd",
445
+ hint: "This looks like a heredoc temp-file denial — bash writes heredoc temp files relative to the current directory, and a cd outside the writable roots makes every heredoc fail even when the heredoc target is writable. " +
446
+ "Run the heredoc from the working directory (no cd first), or use printf or a file in the working directory instead. Retrying with dangerouslyDisableSandbox is NOT needed.",
447
+ };
448
+ }
449
+ // write-target EPERM: the bash line shape (reusing extractBlockedWritePath's
450
+ // regex family) plus the python PermissionError shape.
451
+ const blocked = extractBlockedWritePath(output) ??
452
+ (output.match(/PermissionError: \[Errno \d+\] Operation not permitted: '([^']+)'/)?.[1] ?? null);
453
+ if (!blocked)
454
+ return null;
455
+ // A target inside the writable roots is NOT a write-scope denial — the
456
+ // scratchpad lives under the temp dir and its writes never hit the fence
457
+ // (the observed non-bug; pinned by test).
458
+ if (underRoot(blocked, scope.allowWrite))
459
+ return null;
460
+ if (underRoot(blocked, scope.denyWrite)) {
461
+ return {
462
+ cls: "write-scope-protected",
463
+ hint: "This looks like a sandbox write to a protected path — this path is denied even with allow rules (it holds the agent's own configuration). " +
464
+ "Do not write here; ask the user or use a different location. Retrying with dangerouslyDisableSandbox will still ask for permission and is the wrong tool for this — the protection is deliberate.",
465
+ };
466
+ }
467
+ return {
468
+ cls: "write-scope",
469
+ hint: "This looks like a sandbox write-scope denial — the target is outside the writable roots (the working directory and $TMPDIR). " +
470
+ "Use $TMPDIR or the project directory for temporary files. To write here anyway, add the path to sandbox.filesystem.allowWrite in your settings (the /sandbox panel's Config tab shows the current scope). " +
471
+ "Retrying with dangerouslyDisableSandbox is usually NOT needed — move the target inside the scope instead.",
472
+ };
362
473
  }
363
474
  /** cwd must exist before spawn (guard borrowed from pi's local ops). */
364
475
  export function assertSpawnableCwd(cwd) {
@@ -28,7 +28,10 @@ import type { PermissionRule } from "../permissionRules/loadConfig.js";
28
28
  * here would be overwritten and the sandbox would silently never reach
29
29
  * model-driven tool calls.
30
30
  */
31
- export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void): (def: ToolDefinition) => ToolDefinition;
31
+ export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void, fsScope?: () => {
32
+ allowWrite: readonly string[];
33
+ denyWrite: readonly string[];
34
+ } | null): (def: ToolDefinition) => ToolDefinition;
32
35
  export interface SandboxSessionHandle {
33
36
  manager: YagniSandboxManager;
34
37
  settings: () => SandboxSettings;
@@ -23,7 +23,7 @@ import { isDebug } from "../diagnostics.js";
23
23
  import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
24
24
  import { loadSandboxSettings } from "./config.js";
25
25
  import { resolveWorktreeGitAccess } from "./worktreeGit.js";
26
- import { annotateCommandOutput, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
26
+ import { annotateCommandOutput, fsDenialHint, makeSandboxSpawnHook, networkDenialHint, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
27
27
  import { YagniSandboxManager } from "./manager.js";
28
28
  import { SandboxPanel, buildPanelState, engineeringPresetBlock } from "./panel.js";
29
29
  import { effectiveRules } from "../permissionRules/loadConfig.js";
@@ -39,7 +39,7 @@ import { effectiveRules } from "../permissionRules/loadConfig.js";
39
39
  * here would be overwritten and the sandbox would silently never reach
40
40
  * model-driven tool calls.
41
41
  */
42
- export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry) {
42
+ export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry, fsScope) {
43
43
  return (def) => {
44
44
  if (!settings().enabled)
45
45
  return def;
@@ -50,6 +50,12 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
50
50
  shellPath: userShellPath,
51
51
  spawnHook,
52
52
  });
53
+ // The fs-denial classification scope (advisory hint only): resolved
54
+ // lazily per annotation — a denial is rare, the merge is not free.
55
+ const fsScopeForAnnotate = () => {
56
+ const scope = fsScope?.();
57
+ return scope ? { cwd, scope } : undefined;
58
+ };
53
59
  const schema = Type.Object({
54
60
  command: Type.String({ description: "The bash command to execute" }),
55
61
  timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
@@ -138,9 +144,11 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
138
144
  // The thrown-message gate matches the result-text gate: a
139
145
  // network-signature denial in EITHER surface gets the
140
146
  // annotation + hint (node throws lowercase "operation not
141
- // permitted", which the bare substring misses).
142
- const annotated = annotateCommandOutput(manager, input.command, err.message);
147
+ // permitted", which the bare substring misses). The fs hint
148
+ // composes under the same gate (one diagnosis, network wins).
149
+ const annotated = annotateCommandOutput(manager, input.command, err.message, fsScopeForAnnotate());
143
150
  logNetworkDenialHint(annotated.net);
151
+ logFsDenialHint(annotated.fs, "thrown");
144
152
  throw new Error(annotated.text);
145
153
  }
146
154
  throw err;
@@ -151,8 +159,9 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
151
159
  if (result?.content) {
152
160
  const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
153
161
  if (text.includes("Operation not permitted") || networkDenialHint(text)) {
154
- const annotated = annotateCommandOutput(manager, input.command, text);
162
+ const annotated = annotateCommandOutput(manager, input.command, text, fsScopeForAnnotate());
155
163
  logNetworkDenialHint(annotated.net);
164
+ logFsDenialHint(annotated.fs, "result");
156
165
  result = { ...result, content: [{ type: "text", text: annotated.text }] };
157
166
  }
158
167
  }
@@ -179,6 +188,33 @@ export function registerSandbox(pi, opts) {
179
188
  }).settings;
180
189
  let currentSettings = load();
181
190
  let rules = [];
191
+ // The fs-denial classification scope for the advisory hint (the roots
192
+ // the runtime merge resolves: cwd + tmpdir + config allowWrite, denied:
193
+ // the protected paths). Lazy + memo-invalidated per call — a denial is
194
+ // rare and buildRuntimeMerge is not free; re-reading per annotation also
195
+ // picks up rules/settings changes without a cache to invalidate.
196
+ const fsScope = () => {
197
+ if (!manager.initialized)
198
+ return null;
199
+ try {
200
+ const merge = manager.buildRuntimeMerge(rules);
201
+ return { allowWrite: merge.filesystem.allowWrite, denyWrite: merge.filesystem.denyWrite };
202
+ }
203
+ catch (err) {
204
+ // Never the thrown message (it can carry command content) — error
205
+ // class only, same posture as the gate's classify-error line. Without
206
+ // this trace a persistently-throwing merge would silently strip fs
207
+ // hints from all three surfaces and the only symptom would be "hints
208
+ // disappeared" from the census the measurement depends on.
209
+ logEvent({
210
+ source: "sandbox",
211
+ level: "warn",
212
+ event: "fs_scope_error",
213
+ fields: { error: err instanceof Error ? err.constructor.name : typeof err },
214
+ });
215
+ return null; // fail-soft: no scope, no hint (the network hint still rides)
216
+ }
217
+ };
182
218
  // Optional-chained: some harnesses/mocks don't implement flag APIs; the
183
219
  // flag simply reads as unset there. Real pi registers it normally.
184
220
  pi.registerFlag?.("no-sandbox", {
@@ -326,7 +362,7 @@ export function registerSandbox(pi, opts) {
326
362
  logShellResolutionRetry(outcome);
327
363
  opts.onShellResolutionRetry?.(outcome);
328
364
  };
329
- const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry);
365
+ const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry, fsScope);
330
366
  const registerOwnBash = () => {
331
367
  if (!currentSettings.enabled)
332
368
  return;
@@ -409,11 +445,22 @@ export function registerSandbox(pi, opts) {
409
445
  throw new Error(`timeout:${timeout}`);
410
446
  // Post-run hint: same advisory as the tool path, appended after
411
447
  // the child's own output so the user sees the fix pointer inline.
448
+ // One diagnosis per denial: the network hint wins when both
449
+ // classify (network has a user knob; fs remedies are model-side).
450
+ // A null fsScope() (uninitialized manager, merge error) skips the
451
+ // fs classification entirely — an empty-roots fallback would
452
+ // misclassify in-roots and protected targets as write-scope.
412
453
  const net = networkDenialHint(tail);
454
+ const scope = net ? null : fsScope();
455
+ const fs = scope ? fsDenialHint(event.command, tail, opts.cwd, scope) : null;
413
456
  if (net) {
414
457
  logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
415
458
  onData(Buffer.from(`\n[sandbox] ${net.hint}`));
416
459
  }
460
+ else if (fs) {
461
+ logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface: "user-bash" } });
462
+ onData(Buffer.from(`\n[sandbox] ${fs.hint}`));
463
+ }
417
464
  return { exitCode };
418
465
  }
419
466
  finally {
@@ -825,6 +872,15 @@ function logNetworkDenialHint(net) {
825
872
  return;
826
873
  logEvent({ source: "sandbox", level: "info", event: "network_denial_hint", fields: { class: net.cls } });
827
874
  }
875
+ /** Sink line for the fs-denial hint (same low-cardinality contract as the
876
+ * network one — the before/after escape census reads these lines). The
877
+ * surface field distinguishes the tool-call thrown-error / result-text /
878
+ * !-command call sites in the log stream. */
879
+ function logFsDenialHint(fs, surface) {
880
+ if (!fs)
881
+ return;
882
+ logEvent({ source: "sandbox", level: "info", event: "fs_denial_hint", fields: { class: fs.cls, surface } });
883
+ }
828
884
  /** Sink line for a failed domain-grant persist — the thrown message is
829
885
  * mutateConfigJson's own path/reason text (diagnosable, no file content),
830
886
  * same posture as sandbox_persist_failed / rule_save_failed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.2-staging.1368.1",
3
+ "version": "1.1.2-staging.1370.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "a5f338426410c8549a0b85f6e0ac39fd3fdd3e78"
61
+ "yagniSourceSha": "2f7255058068c24ee9b070aec43e23b4fc32fb39"
62
62
  }