@yagni-app/code-staging 1.1.2-staging.1369.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
|
-
|
|
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
|
package/dist/extension/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.2-staging.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "2f7255058068c24ee9b070aec43e23b4fc32fb39"
|
|
62
62
|
}
|