@vincemakes/kiso-tools-node 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +26 -7
- package/dist/index.js +151 -65
- package/dist/search-miss.js +2 -2
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -61,7 +61,8 @@ export declare function shellProgressPath(sessionId: string | undefined, command
|
|
|
61
61
|
export declare function escapeResult(reason: string): ToolResult;
|
|
62
62
|
/**
|
|
63
63
|
* Resolve an input path strictly inside the workspace (Area 5):
|
|
64
|
-
* - absolute
|
|
64
|
+
* - an absolute input is taken as the workspace-relative path it names when
|
|
65
|
+
* it lies inside the root, and refused otherwise;
|
|
65
66
|
* - `..` components cannot escape because the joined path is re-checked
|
|
66
67
|
* against the canonical root;
|
|
67
68
|
* - SYMLINKS cannot escape: the deepest existing ancestor is realpath'd
|
|
@@ -117,6 +118,12 @@ export interface WorkspaceToolsOptions {
|
|
|
117
118
|
* serves the session's task outputs. Absent: today's shell, its schema
|
|
118
119
|
* byte for byte. */
|
|
119
120
|
readonly tasks?: (sessionId: string | undefined) => ShellTasks | undefined;
|
|
121
|
+
/** O1 — the hidden observed revision. When present, edit_file and
|
|
122
|
+
* write_file take no expectedRevision (the model-facing schema drops
|
|
123
|
+
* it): the host returns the revision the model last OBSERVED for the
|
|
124
|
+
* file (undefined: never observed — the call is refused). Absent:
|
|
125
|
+
* the explicit expectedRevision protocol, its schema byte for byte. */
|
|
126
|
+
readonly observedRevision?: (sessionId: string | undefined, full: string, kind?: "edit" | "write") => string | undefined;
|
|
120
127
|
/**
|
|
121
128
|
* DC-54 — the bounds that keep a tool call finite. Every field is
|
|
122
129
|
* optional and defaults to the constant beside it; a host embedding
|
|
@@ -160,6 +167,11 @@ export interface WorkspaceToolsOptions {
|
|
|
160
167
|
* the default wait). A shorter `foregroundMs` is raised to it; 0
|
|
161
168
|
* keeps the model's value as asked (ADR-0058 Amendment 10). */
|
|
162
169
|
readonly minForegroundMs?: number;
|
|
170
|
+
/** shell, with tasks wired: the longest foreground wait (default 5
|
|
171
|
+
* min). A longer `foregroundMs` is lowered to it — the command is
|
|
172
|
+
* promoted to a task, never killed; it never goes below the floor;
|
|
173
|
+
* 0 keeps the model's value as asked (ADR-0058 Amendment 15). */
|
|
174
|
+
readonly maxForegroundMs?: number;
|
|
163
175
|
};
|
|
164
176
|
}
|
|
165
177
|
export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
|
|
@@ -187,16 +199,23 @@ export declare function writeFileTool(opts: WorkspaceToolsOptions): Tool<{
|
|
|
187
199
|
content: string;
|
|
188
200
|
expectedRevision?: string;
|
|
189
201
|
}>;
|
|
190
|
-
|
|
202
|
+
/** One hunk as a caller may send it: the current vocabulary, or the
|
|
203
|
+
* legacy `search`/`replace` a durable log or a host may still hold. */
|
|
204
|
+
type EditHunkInput = {
|
|
205
|
+
readonly oldText?: string;
|
|
206
|
+
readonly newText?: string;
|
|
207
|
+
readonly search?: string;
|
|
208
|
+
readonly replace?: string;
|
|
209
|
+
readonly expectedRevision?: string;
|
|
210
|
+
};
|
|
211
|
+
type EditFileInput = {
|
|
191
212
|
path: string;
|
|
213
|
+
edits?: readonly EditHunkInput[];
|
|
192
214
|
search?: string;
|
|
193
215
|
replace?: string;
|
|
194
|
-
edits?: readonly {
|
|
195
|
-
search: string;
|
|
196
|
-
replace: string;
|
|
197
|
-
}[];
|
|
198
216
|
expectedRevision?: string;
|
|
199
|
-
}
|
|
217
|
+
};
|
|
218
|
+
export declare function editFileTool(opts: WorkspaceToolsOptions): Tool<EditFileInput>;
|
|
200
219
|
export { SHELL_STRIP_EXACT, strippedShellEnv } from "./secret-env.js";
|
|
201
220
|
export { PROTECTED_REFUSAL, diskPath, isProtectedPath, protectedIdentity, protectedRefusalText, readUnlessProtected, type ProtectedIdentity } from "./protected.js";
|
|
202
221
|
/** The search corpus's credential rule, by name — exported so the CLI's
|
package/dist/index.js
CHANGED
|
@@ -82,6 +82,9 @@ const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
|
|
|
82
82
|
/** ADR-0058 §3: with tasks, how long the shell waits before a command
|
|
83
83
|
* continues as a task (the model's own value wins). */
|
|
84
84
|
const DEFAULT_FOREGROUND_MS = 60_000;
|
|
85
|
+
/** ADR-0058 Amendment 15: with tasks, the longest foreground wait. A longer
|
|
86
|
+
* `foregroundMs` still promotes here; the command is never killed. */
|
|
87
|
+
const DEFAULT_MAX_FOREGROUND_MS = 300_000;
|
|
85
88
|
/** A promoted task's output file rotates here, as a runner's does. */
|
|
86
89
|
const TASK_OUTPUT_CAP = 64 * 1024 * 1024;
|
|
87
90
|
/** A task's stop: SIGTERM, this long, then the confirmed sweep. */
|
|
@@ -164,7 +167,8 @@ function isWithin(root, target) {
|
|
|
164
167
|
}
|
|
165
168
|
/**
|
|
166
169
|
* Resolve an input path strictly inside the workspace (Area 5):
|
|
167
|
-
* - absolute
|
|
170
|
+
* - an absolute input is taken as the workspace-relative path it names when
|
|
171
|
+
* it lies inside the root, and refused otherwise;
|
|
168
172
|
* - `..` components cannot escape because the joined path is re-checked
|
|
169
173
|
* against the canonical root;
|
|
170
174
|
* - SYMLINKS cannot escape: the deepest existing ancestor is realpath'd
|
|
@@ -198,10 +202,18 @@ function resolveReadable(opts, input, sessionId) {
|
|
|
198
202
|
return { full: resolveWithinRoot(opts.workspaceRoot, input), root: opts.workspaceRoot };
|
|
199
203
|
}
|
|
200
204
|
export function resolveWithinRoot(root, input) {
|
|
205
|
+
const rootReal = realpathSync(root);
|
|
201
206
|
if (isAbsolute(input)) {
|
|
202
|
-
|
|
207
|
+
// an absolute path that names a place inside the workspace — as the
|
|
208
|
+
// root is written or as it resolves — is that workspace-relative
|
|
209
|
+
// path, and takes the same canonical checks below (symlinks, `..`)
|
|
210
|
+
for (const base of [root, rootReal]) {
|
|
211
|
+
const rel = relative(base, input);
|
|
212
|
+
if (!rel.startsWith("..") && !isAbsolute(rel))
|
|
213
|
+
return resolveWithinRoot(root, rel === "" ? "." : rel);
|
|
214
|
+
}
|
|
215
|
+
throw new PathEscapeError(`absolute path outside the workspace: ${input} — the workspace root is ${rootReal}; use a path inside it`);
|
|
203
216
|
}
|
|
204
|
-
const rootReal = realpathSync(root);
|
|
205
217
|
const candidate = resolve(root, input);
|
|
206
218
|
// Walk up to the deepest EXISTING ancestor (files to be created have a
|
|
207
219
|
// not-yet-existing tail), then canonicalize it.
|
|
@@ -369,7 +381,7 @@ export function readFileTool(opts) {
|
|
|
369
381
|
properties: {
|
|
370
382
|
path: { type: "string", description: "Workspace-relative path of the file to read" },
|
|
371
383
|
offset: { type: "number", description: "1-based first line to read (default: 1)" },
|
|
372
|
-
limit: { type: "number", description: "Maximum
|
|
384
|
+
limit: { type: "number", description: "Maximum lines; omit for the default bounded window" },
|
|
373
385
|
},
|
|
374
386
|
required: ["path"],
|
|
375
387
|
additionalProperties: false,
|
|
@@ -382,7 +394,7 @@ export function readFileTool(opts) {
|
|
|
382
394
|
// safety claim: the kernel enforces both, and had this line been
|
|
383
395
|
// omitted the tool would simply have been slower, never less correct.
|
|
384
396
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
385
|
-
promptSnippet: "read_file —
|
|
397
|
+
promptSnippet: "read_file — read a file or a line range",
|
|
386
398
|
promptGuidelines: ["read only the range you need — offset/limit beat whole-file reads"],
|
|
387
399
|
execute: async ({ path, offset, limit }, ctx) => {
|
|
388
400
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
@@ -544,7 +556,7 @@ export function listDirTool(opts) {
|
|
|
544
556
|
idempotent: true,
|
|
545
557
|
// EC-1 ②: read-only, free, local — see read_file above.
|
|
546
558
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
547
|
-
promptSnippet: "list_dir —
|
|
559
|
+
promptSnippet: "list_dir — list a directory",
|
|
548
560
|
promptGuidelines: ["narrow to a subdirectory when the listing caps at 200 entries"],
|
|
549
561
|
execute: async ({ path, glob }, ctx) => {
|
|
550
562
|
try {
|
|
@@ -761,7 +773,7 @@ export function searchTextTool(opts) {
|
|
|
761
773
|
// commit-required and exclusive, which is what closes the same-path
|
|
762
774
|
// write race without asking their authors to remember anything.
|
|
763
775
|
effects: { precommitSafe: true, concurrency: "shared" },
|
|
764
|
-
promptSnippet: "search_text — regex search
|
|
776
|
+
promptSnippet: "search_text — regex search in files",
|
|
765
777
|
promptGuidelines: ["narrow the pattern when the result caps — never re-run a broad search"],
|
|
766
778
|
execute: async ({ pattern, path, caseSensitive }, ctx) => {
|
|
767
779
|
let root;
|
|
@@ -854,27 +866,29 @@ export function searchTextTool(opts) {
|
|
|
854
866
|
export function writeFileTool(opts) {
|
|
855
867
|
return defineTool({
|
|
856
868
|
name: "write_file",
|
|
857
|
-
description:
|
|
869
|
+
description: opts.observedRevision !== undefined
|
|
870
|
+
? "Create or replace a whole workspace file. Replacing a file needs an earlier read of it."
|
|
871
|
+
: "Create or replace a whole workspace file. expectedRevision is the file's latest revision token, or \"absent\" to create a new file.",
|
|
858
872
|
parameters: {
|
|
859
873
|
type: "object",
|
|
860
874
|
properties: {
|
|
861
875
|
path: { type: "string", description: "Workspace-relative file to write" },
|
|
862
876
|
content: { type: "string", description: "Full new content" },
|
|
863
|
-
expectedRevision: {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
877
|
+
...(opts.observedRevision !== undefined ? {} : { expectedRevision: {
|
|
878
|
+
type: "string",
|
|
879
|
+
description: 'The latest revision token, or "absent" to create',
|
|
880
|
+
} }),
|
|
867
881
|
},
|
|
868
|
-
required: ["path", "content", "expectedRevision"],
|
|
882
|
+
required: opts.observedRevision !== undefined ? ["path", "content"] : ["path", "content", "expectedRevision"],
|
|
869
883
|
additionalProperties: false,
|
|
870
884
|
},
|
|
871
|
-
promptSnippet: "write_file —
|
|
872
|
-
promptGuidelines: ["
|
|
873
|
-
execute: async ({ path, content, expectedRevision: citedRevision }) => {
|
|
885
|
+
promptSnippet: "write_file — write a whole file",
|
|
886
|
+
...(opts.observedRevision !== undefined ? {} : { promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"] }),
|
|
887
|
+
execute: async ({ path, content, expectedRevision: citedRevision }, ctx) => {
|
|
874
888
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
875
889
|
// WR-1-F2: normalize every plausible copy of the token FIRST —
|
|
876
890
|
// tolerance in the reader, strictness in the comparison.
|
|
877
|
-
|
|
891
|
+
let expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
878
892
|
let full;
|
|
879
893
|
try {
|
|
880
894
|
full = resolveWithinRoot(opts.workspaceRoot, path);
|
|
@@ -887,6 +901,14 @@ export function writeFileTool(opts) {
|
|
|
887
901
|
const guard = protectedIdentity(opts.protectedFiles);
|
|
888
902
|
if (isProtectedPath(full, guard))
|
|
889
903
|
return precondition(protectedRefusalText("write_file", path));
|
|
904
|
+
// O1: bind the observed revision (never the disk's latest)
|
|
905
|
+
if (expectedRevision === undefined && opts.observedRevision !== undefined && existsSync(full)) {
|
|
906
|
+
expectedRevision = opts.observedRevision(ctx?.sessionId, full, "write");
|
|
907
|
+
// the trajectory audit: after ranged reads the model retried this
|
|
908
|
+
// in a loop — say that only a WHOLE read lets it replace the file
|
|
909
|
+
if (expectedRevision === undefined)
|
|
910
|
+
return precondition(`write_file: ${path} already exists — to replace it, first read the whole file (read_file with no offset or limit; a ranged read is not enough), or change part of it with edit_file`);
|
|
911
|
+
}
|
|
890
912
|
// WR-1 — the observed-revision stale-write guard. The decision
|
|
891
913
|
// lattice runs BEFORE any bytes move; every refusal is a
|
|
892
914
|
// precondition with one actionable sentence. What it proves:
|
|
@@ -928,7 +950,7 @@ export function writeFileTool(opts) {
|
|
|
928
950
|
return precondition(protectedRefusalText("write_file", path));
|
|
929
951
|
const current = contentRevision(held);
|
|
930
952
|
if (current !== expectedRevision) {
|
|
931
|
-
return precondition(`write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
|
|
953
|
+
return precondition(opts.observedRevision !== undefined ? `write_file: ${path} changed since you last observed it — read it again, then re-apply the change` : `write_file: ${path} changed since ${expectedRevision} — read it again and cite its [rev:…] line, then re-apply the change`);
|
|
932
954
|
}
|
|
933
955
|
}
|
|
934
956
|
const tmp = `${full}.kiso-tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
@@ -977,7 +999,14 @@ export function writeFileTool(opts) {
|
|
|
977
999
|
// WR-1: the result returns the NEW revision — the model just
|
|
978
1000
|
// authored these bytes; a legal new world witness for chained
|
|
979
1001
|
// edits without a wasted re-read.
|
|
980
|
-
|
|
1002
|
+
// O1: with no "absent" in the input, the "created" tag tells
|
|
1003
|
+
// the host's card a creation from a rewrite — a product tag,
|
|
1004
|
+
// never sent to the model.
|
|
1005
|
+
return {
|
|
1006
|
+
content: `wrote ${path} (${content.length} chars)\n[${contentRevision(Buffer.from(content, "utf8"))}]`,
|
|
1007
|
+
isError: false,
|
|
1008
|
+
...(opts.observedRevision !== undefined && !exists ? { tags: ["created"] } : {}),
|
|
1009
|
+
};
|
|
981
1010
|
}
|
|
982
1011
|
catch (err) {
|
|
983
1012
|
// round 8: a failed write never leaves a temp file with the FULL
|
|
@@ -1086,58 +1115,87 @@ function staleReport(text, hunks, single) {
|
|
|
1086
1115
|
}
|
|
1087
1116
|
return out.join("\n");
|
|
1088
1117
|
}
|
|
1118
|
+
/**
|
|
1119
|
+
* ADR-0061 — compatibility belongs in the executor; vocabulary belongs in
|
|
1120
|
+
* the schema. The model is shown one form (`edits` of `{oldText, newText}`):
|
|
1121
|
+
* `search`/`replace` was written SWAPPED in 80 of 86 failed T6 edits (the
|
|
1122
|
+
* 2026-10-08 replay), and old/new cannot be read backwards. Everything a
|
|
1123
|
+
* caller that skips schema validation may send — an approved call persisted
|
|
1124
|
+
* before the upgrade, a host, a test — is still taken here: legacy hunks,
|
|
1125
|
+
* the legacy top-level pair, a hunk-level `expectedRevision` (ignored, the
|
|
1126
|
+
* call's own governs). A hunk that MIXES the two vocabularies takes neither
|
|
1127
|
+
* half. The schema stays in the small portable subset by choice (no
|
|
1128
|
+
* `oneOf`), so the shape rules live here.
|
|
1129
|
+
*/
|
|
1130
|
+
function editHunksOf(input) {
|
|
1131
|
+
const pair = input.search !== undefined || input.replace !== undefined;
|
|
1132
|
+
if (pair && input.edits !== undefined)
|
|
1133
|
+
return "edit_file: pass edits — never edits and a top-level search/replace together";
|
|
1134
|
+
if (pair) {
|
|
1135
|
+
if (input.search === undefined || input.replace === undefined)
|
|
1136
|
+
return "edit_file: the legacy top-level form needs BOTH search and replace";
|
|
1137
|
+
return { hunks: [{ search: input.search, replace: input.replace }], single: true };
|
|
1138
|
+
}
|
|
1139
|
+
if (input.edits === undefined)
|
|
1140
|
+
return "edit_file: pass edits \u2014 1\u201332 {oldText, newText} replacements";
|
|
1141
|
+
if (input.edits.length === 0 || input.edits.length > 32)
|
|
1142
|
+
return `edit_file: edits must carry 1\u201332 replacements (got ${input.edits.length})`;
|
|
1143
|
+
const hunks = [];
|
|
1144
|
+
for (let i = 0; i < input.edits.length; i += 1) {
|
|
1145
|
+
const h = input.edits[i];
|
|
1146
|
+
const current = h.oldText !== undefined || h.newText !== undefined;
|
|
1147
|
+
const legacy = h.search !== undefined || h.replace !== undefined;
|
|
1148
|
+
if (current && legacy)
|
|
1149
|
+
return `edit_file: hunk ${i + 1} mixes oldText/newText with search/replace \u2014 send oldText and newText`;
|
|
1150
|
+
const oldText = current ? h.oldText : h.search;
|
|
1151
|
+
const newText = current ? h.newText : h.replace;
|
|
1152
|
+
if (typeof oldText !== "string" || typeof newText !== "string")
|
|
1153
|
+
return `edit_file: hunk ${i + 1} needs both oldText and newText`;
|
|
1154
|
+
hunks.push({ search: oldText, replace: newText });
|
|
1155
|
+
}
|
|
1156
|
+
return { hunks, single: false };
|
|
1157
|
+
}
|
|
1089
1158
|
export function editFileTool(opts) {
|
|
1090
1159
|
return defineTool({
|
|
1091
1160
|
name: "edit_file",
|
|
1092
|
-
description:
|
|
1161
|
+
description: opts.observedRevision !== undefined
|
|
1162
|
+
? "Edit a file you have read: exact replacements, applied in order, all or nothing. Batch changes to this file that are already known into one call."
|
|
1163
|
+
: "Edit a file at its latest revision (expectedRevision): exact replacements, applied in order, all or nothing. Batch changes to this file that are already known into one call. A refusal ends on its [rev:X].",
|
|
1093
1164
|
parameters: {
|
|
1094
1165
|
type: "object",
|
|
1095
1166
|
properties: {
|
|
1096
1167
|
path: { type: "string", description: "Workspace-relative file" },
|
|
1097
|
-
search: { type: "string", description: "Exact literal text to find (single-hunk form)" },
|
|
1098
|
-
replace: { type: "string", description: "Replacement text (single-hunk form)" },
|
|
1099
1168
|
edits: {
|
|
1100
1169
|
type: "array",
|
|
1101
|
-
description: "
|
|
1170
|
+
description: "1-32 replacements",
|
|
1102
1171
|
items: {
|
|
1103
1172
|
type: "object",
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
required: ["
|
|
1173
|
+
properties: {
|
|
1174
|
+
oldText: { type: "string", description: "Exact text to replace at this step; must match exactly once after the earlier edits in this call" },
|
|
1175
|
+
newText: { type: "string", description: "Replacement text for oldText" },
|
|
1176
|
+
},
|
|
1177
|
+
required: ["oldText", "newText"],
|
|
1109
1178
|
additionalProperties: false,
|
|
1110
1179
|
},
|
|
1111
1180
|
minItems: 1,
|
|
1112
1181
|
maxItems: 32,
|
|
1113
1182
|
},
|
|
1114
|
-
expectedRevision: { type: "string", description: "The file's latest revision token" },
|
|
1183
|
+
...(opts.observedRevision !== undefined ? {} : { expectedRevision: { type: "string", description: "The file's latest revision token" } }),
|
|
1115
1184
|
},
|
|
1116
|
-
required: ["path", "expectedRevision"],
|
|
1185
|
+
required: opts.observedRevision !== undefined ? ["path", "edits"] : ["path", "edits", "expectedRevision"],
|
|
1117
1186
|
additionalProperties: false,
|
|
1118
1187
|
},
|
|
1119
|
-
promptSnippet: "edit_file — replace
|
|
1120
|
-
promptGuidelines: ["
|
|
1121
|
-
execute: async (
|
|
1188
|
+
promptSnippet: "edit_file — replace oldText with newText",
|
|
1189
|
+
...(opts.observedRevision !== undefined ? {} : { promptGuidelines: ["cite the file's latest revision as expectedRevision; each successful mutation returns the next one; use \"absent\" only to create"] }),
|
|
1190
|
+
execute: async (input, ctx) => {
|
|
1191
|
+
const { path, expectedRevision: citedRevision } = input;
|
|
1122
1192
|
const maxReadBytes = opts.limits?.readMaxFileBytes ?? READ_MAX_FILE_BYTES;
|
|
1123
1193
|
// WR-1-F2: normalize the citation (see write_file).
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
return { content: "edit_file: pass EITHER search+replace OR edits — never both", isError: true, errorKind: "invalid_input" };
|
|
1130
|
-
}
|
|
1131
|
-
if (!single && edits === undefined) {
|
|
1132
|
-
return { content: "edit_file: pass search+replace, or edits (1\u201332 hunks)", isError: true, errorKind: "invalid_input" };
|
|
1133
|
-
}
|
|
1134
|
-
if (single && (search === undefined || replace === undefined)) {
|
|
1135
|
-
return { content: "edit_file: the single-hunk form needs BOTH search and replace", isError: true, errorKind: "invalid_input" };
|
|
1136
|
-
}
|
|
1137
|
-
if (edits !== undefined && (edits.length === 0 || edits.length > 32)) {
|
|
1138
|
-
return { content: `edit_file: edits must carry 1\u201332 hunks (got ${edits.length})`, isError: true, errorKind: "invalid_input" };
|
|
1139
|
-
}
|
|
1140
|
-
const hunks = edits ?? [{ search: search, replace: replace }];
|
|
1194
|
+
let expectedRevision = citedRevision === undefined ? undefined : normalizeRevision(citedRevision);
|
|
1195
|
+
const shape = editHunksOf(input);
|
|
1196
|
+
if (typeof shape === "string")
|
|
1197
|
+
return { content: shape, isError: true, errorKind: "invalid_input" };
|
|
1198
|
+
const { hunks, single } = shape;
|
|
1141
1199
|
let full;
|
|
1142
1200
|
try {
|
|
1143
1201
|
full = resolveWithinRoot(opts.workspaceRoot, path);
|
|
@@ -1150,6 +1208,12 @@ export function editFileTool(opts) {
|
|
|
1150
1208
|
const guard = protectedIdentity(opts.protectedFiles);
|
|
1151
1209
|
if (isProtectedPath(full, guard))
|
|
1152
1210
|
return precondition(protectedRefusalText("edit_file", path));
|
|
1211
|
+
// O1: bind the observed revision (never the disk's latest)
|
|
1212
|
+
if (expectedRevision === undefined && opts.observedRevision !== undefined) {
|
|
1213
|
+
expectedRevision = opts.observedRevision(ctx?.sessionId, full);
|
|
1214
|
+
if (expectedRevision === undefined)
|
|
1215
|
+
return precondition(`edit_file: ${path} — read it first; there is no observation of it in this session`);
|
|
1216
|
+
}
|
|
1153
1217
|
// WR-1: edits always target an existing file — the revision is
|
|
1154
1218
|
// not optional here, and the refusal teaches the protocol.
|
|
1155
1219
|
if (expectedRevision === undefined) {
|
|
@@ -1191,16 +1255,20 @@ export function editFileTool(opts) {
|
|
|
1191
1255
|
// ACI-3: the refusal carries the file as it is where each hunk
|
|
1192
1256
|
// should land, and its current revision last — the retry needs
|
|
1193
1257
|
// no read. The headline is unchanged (bench parsers read it).
|
|
1194
|
-
return precondition(
|
|
1258
|
+
return precondition(opts.observedRevision !== undefined
|
|
1259
|
+
? `edit_file: ${path} changed since you last observed it — the file as it is now is below; re-apply the change against it\n${staleReport(text, hunks, single)}\n[${current}]`
|
|
1260
|
+
: `edit_file: ${path} changed since ${expectedRevision} — the file as it is now is below; re-apply the change against it, citing the [rev:…] on the last line\n${staleReport(text, hunks, single)}\n[${current}]`);
|
|
1195
1261
|
}
|
|
1196
1262
|
// ACI-3 (superseding WR-1E2's one-snapshot rule): the hunks apply
|
|
1197
1263
|
// IN ORDER, each against the result of the ones before it — the
|
|
1198
|
-
// way a person writes a second change after the first
|
|
1199
|
-
// reference implementation
|
|
1264
|
+
// way a person writes a second change after the first. (The
|
|
1265
|
+
// reference implementation does NOT do this: it matches every edit
|
|
1266
|
+
// against the original and refuses overlaps.) 57 refusals on the
|
|
1200
1267
|
// owner's disk were "two hunks overlap": a second hunk written
|
|
1201
|
-
// against the first one's output.
|
|
1202
|
-
//
|
|
1203
|
-
//
|
|
1268
|
+
// against the first one's output. The 2026-10-08 replay found 0 of
|
|
1269
|
+
// 86 failures caused by this rule (ADR-0061). Still all or nothing:
|
|
1270
|
+
// nothing is written unless every hunk resolves, and each must
|
|
1271
|
+
// match exactly once in the text it meets (ACI-2).
|
|
1204
1272
|
// Windows P3: a uniformly CRLF file is matched on its LF form and
|
|
1205
1273
|
// written back CRLF — the model's search text uses LF, and an edit
|
|
1206
1274
|
// never changes the file's endings. A file mixing the two keeps
|
|
@@ -1211,7 +1279,12 @@ export function editFileTool(opts) {
|
|
|
1211
1279
|
for (let i = 0; i < hunks.length; i += 1) {
|
|
1212
1280
|
const h = { search: lf(hunks[i].search), replace: lf(hunks[i].replace) };
|
|
1213
1281
|
const { count, offsets } = occurrencesOf(edited, h.search);
|
|
1214
|
-
|
|
1282
|
+
// a batch's later hunk is matched against the text the earlier
|
|
1283
|
+
// ones made IN MEMORY; "after hunk 1 applied" read as written,
|
|
1284
|
+
// and the model repaired a hunk that never landed (the TB20
|
|
1285
|
+
// trajectory audit) — so a batch refusal says nothing changed
|
|
1286
|
+
const which = single ? "" : i === 0 ? " (hunk 1)" : ` (hunk ${i + 1}, matched after ${i === 1 ? "hunk 1" : `hunks 1–${i}`})`;
|
|
1287
|
+
const untouched = hunks.length > 1 ? ` — nothing was written, ${path} is unchanged` : "";
|
|
1215
1288
|
if (count === 0) {
|
|
1216
1289
|
// WR-1A ④: the WORLD lacks the pattern (the input is
|
|
1217
1290
|
// fine) and nothing ran — precondition; the note never
|
|
@@ -1222,7 +1295,13 @@ export function editFileTool(opts) {
|
|
|
1222
1295
|
const region = regionForSearch(edited, h.search);
|
|
1223
1296
|
const seen = i === 0 ? "the file now has" : `the text hunk ${i + 1} met`;
|
|
1224
1297
|
const block = region === null ? "" : `\n${seen}, lines ${region.from}–${region.to}:\n${region.body}`;
|
|
1225
|
-
|
|
1298
|
+
// ADR-0061: the replay's shape — oldText nowhere, newText exactly
|
|
1299
|
+
// once. Two readings fit it and the tool cannot tell them apart, so
|
|
1300
|
+
// it names both and applies nothing (ACI-2: never a guess).
|
|
1301
|
+
const swap = h.replace.length > 0 && occurrencesOf(edited, h.replace).count === 1
|
|
1302
|
+
? "\n newText is already in the file once and oldText is not: either this change is already applied, or oldText and newText are swapped"
|
|
1303
|
+
: "";
|
|
1304
|
+
return precondition(`edit_file: pattern not found in ${path}${which}${untouched}${detail ? `\n${detail}` : ""}${swap}${block}\n[${current}]`);
|
|
1226
1305
|
}
|
|
1227
1306
|
// ACI-2: more than one resolution is a QUESTION, not an edit.
|
|
1228
1307
|
// Taking the first one wrote the wrong place and reported
|
|
@@ -1232,7 +1311,7 @@ export function editFileTool(opts) {
|
|
|
1232
1311
|
if (count > 1) {
|
|
1233
1312
|
const lines = describeMatchLines(edited, offsets, count <= ACI2_OFFSETS_KEPT);
|
|
1234
1313
|
const where = which === "" ? lines : `${which.slice(2, -1)}, ${lines}`;
|
|
1235
|
-
return precondition(`edit_file: pattern matches ${count} places in ${path} (${where}) — include enough surrounding text to make it unique\n[${current}]`);
|
|
1314
|
+
return precondition(`edit_file: pattern matches ${count} places in ${path} (${where}) — include enough surrounding text to make it unique${untouched !== "" ? `; nothing was written, ${path} is unchanged` : ""}\n[${current}]`);
|
|
1236
1315
|
}
|
|
1237
1316
|
edited = edited.slice(0, offsets[0]) + h.replace + edited.slice(offsets[0] + h.search.length);
|
|
1238
1317
|
}
|
|
@@ -1348,7 +1427,10 @@ export function shellTool(opts) {
|
|
|
1348
1427
|
description: "Run independently as a task. Use for services/watchers or work whose exit result is not needed next. If you need the result, keep it foreground and raise foregroundMs. You are notified when it ends; do not sleep/poll.",
|
|
1349
1428
|
},
|
|
1350
1429
|
readyWhen: { type: "string", description: "For a continuing service, wait up to foregroundMs for this literal output before returning; the task keeps running afterward." },
|
|
1351
|
-
|
|
1430
|
+
// Plan B: timeoutMs (deprecated here since ADR-0058) leaves the
|
|
1431
|
+
// model-facing schema; the executor still reads it from a caller
|
|
1432
|
+
// that skips validation. The schema WITHOUT tasks keeps it — there
|
|
1433
|
+
// it is the shell's only timeout.
|
|
1352
1434
|
},
|
|
1353
1435
|
required: ["command"],
|
|
1354
1436
|
additionalProperties: false,
|
|
@@ -1362,6 +1444,7 @@ export function shellTool(opts) {
|
|
|
1362
1444
|
required: ["command"],
|
|
1363
1445
|
additionalProperties: false,
|
|
1364
1446
|
},
|
|
1447
|
+
// PR-1d row 3: this exact snippet is part of a MEASURED combination (weather 1/5 -> 5/5); Plan B leaves it as pinned
|
|
1365
1448
|
promptSnippet: "shell — any command the task needs (builds, tests, git, curl, system queries)",
|
|
1366
1449
|
promptGuidelines: ["commands run in the workspace root; on failure read the error and adjust — never repeat blindly"],
|
|
1367
1450
|
execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
|
|
@@ -1370,11 +1453,14 @@ export function shellTool(opts) {
|
|
|
1370
1453
|
// ADR-0058 Amendment 10: with tasks wired a wait shorter than the
|
|
1371
1454
|
// floor only moves the command to the background sooner, so the
|
|
1372
1455
|
// model may lengthen the wait, never shorten it. Without tasks the
|
|
1373
|
-
// wait is a kill timeout and is kept as asked.
|
|
1456
|
+
// wait is a kill timeout and is kept as asked. Amendment 15: a
|
|
1457
|
+
// wait past the ceiling only holds the turn — the command is
|
|
1458
|
+
// promoted there and the model is notified when it ends.
|
|
1374
1459
|
const asked = [foregroundMs, timeoutMs].find((v) => typeof v === "number" && Number.isFinite(v));
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1460
|
+
const floor = opts.limits?.minForegroundMs ?? DEFAULT_FOREGROUND_MS;
|
|
1461
|
+
const ceiling = opts.limits?.maxForegroundMs ?? DEFAULT_MAX_FOREGROUND_MS;
|
|
1462
|
+
const raised = Math.max(asked ?? DEFAULT_FOREGROUND_MS, floor);
|
|
1463
|
+
const timeout = tasks !== undefined ? (ceiling > 0 ? Math.min(raised, Math.max(ceiling, floor)) : raised) : (asked ?? DEFAULT_SHELL_TIMEOUT_MS);
|
|
1378
1464
|
// E group: a PRE-aborted signal never spawns the command.
|
|
1379
1465
|
if (ctx.signal.aborted) {
|
|
1380
1466
|
return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
|
|
@@ -1687,7 +1773,7 @@ export function taskStopTool(opts) {
|
|
|
1687
1773
|
required: ["id"],
|
|
1688
1774
|
additionalProperties: false,
|
|
1689
1775
|
},
|
|
1690
|
-
promptSnippet: "task_stop — stop a background task
|
|
1776
|
+
promptSnippet: "task_stop — stop a background task",
|
|
1691
1777
|
execute: async ({ id }, ctx) => {
|
|
1692
1778
|
const tasks = opts.tasks?.(ctx.sessionId);
|
|
1693
1779
|
const info = tasks?.get(id);
|
package/dist/search-miss.js
CHANGED
|
@@ -83,13 +83,13 @@ export function describeSearchMiss(text, search) {
|
|
|
83
83
|
if (endOfMatch >= text.length) {
|
|
84
84
|
return [
|
|
85
85
|
head,
|
|
86
|
-
` the file ENDS there — your
|
|
86
|
+
` the file ENDS there — your oldText continues for ${rest.length} more characters: "${fragment(rest)}"`,
|
|
87
87
|
].join("\n");
|
|
88
88
|
}
|
|
89
89
|
return [
|
|
90
90
|
head,
|
|
91
91
|
` the file then has: "${fragment(text.slice(endOfMatch))}"`,
|
|
92
|
-
` your
|
|
92
|
+
` your oldText wanted: "${fragment(rest)}"`,
|
|
93
93
|
].join("\n");
|
|
94
94
|
}
|
|
95
95
|
export function regionForSearch(text, search, context = 6, maxChars = 1500) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test": "vitest run"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@vincemakes/kiso-core": "0.
|
|
28
|
+
"@vincemakes/kiso-core": "0.50.0",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|