start-command 0.42.0 → 0.43.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.
@@ -9,7 +9,8 @@
9
9
  * byte for byte, so comments, whitespace and unrelated content survive.
10
10
  *
11
11
  * - read: the exact UTF-8 text of a regular file, bounded by
12
- * START_FORMAL_AI_MAX_FILE_BYTES; it observes the file for later changes.
12
+ * START_FORMAL_AI_MAX_FILE_BYTES, as the numbered frame Formal AI decodes
13
+ * back to that text (#261); it observes the file for later changes.
13
14
  * - edit: replaces one literal occurrence of old_string (or every one with
14
15
  * replace_all) in a file read before, atomically, then reads it back.
15
16
  * - write: replaces an observed file, or creates one in an observed
@@ -19,6 +20,15 @@
19
20
  * symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
20
21
  * workspace-write and an earlier observation of the target, and a file that
21
22
  * changed since start read it is not overwritten.
23
+ *
24
+ * Bounded typed source edits (issue #261): a change to an existing file is
25
+ * bound to the complete bytes the read tool returned for it in this task
26
+ * (listing it or `cat` is not enough), applied to exactly the matched regions
27
+ * of those bytes, guarded against a change of the target until the new file
28
+ * takes its place, and verified by reading it back: every matched region now
29
+ * holds new_string and every other byte is unchanged and in place. The
30
+ * mutation receipt (preimage, postimage and byte offsets of the matches) is
31
+ * kept in the evidence and returned to Formal AI.
22
32
  */
23
33
 
24
34
  const crypto = require('crypto');
@@ -50,7 +60,7 @@ const FILE_TOOLS = Object.freeze({
50
60
  function: {
51
61
  name: 'edit',
52
62
  description:
53
- 'Replace the literal text old_string with new_string in one file read before; old_string must occur exactly once unless replace_all is true.',
63
+ 'Replace the literal text old_string with new_string in one file read completely with read in this task, unchanged since; old_string must occur exactly once unless replace_all is true. Nothing else in the file changes.',
54
64
  parameters: {
55
65
  type: 'object',
56
66
  properties: {
@@ -68,7 +78,7 @@ const FILE_TOOLS = Object.freeze({
68
78
  function: {
69
79
  name: 'write',
70
80
  description:
71
- 'Write the whole text of one file: a file read before, or a new file in a directory listed before.',
81
+ 'Write the whole text of one file: a file read completely with read in this task, unchanged since, or a new file in a directory listed before.',
72
82
  parameters: {
73
83
  type: 'object',
74
84
  properties: { path: PATH, content: { type: 'string' } },
@@ -190,38 +200,84 @@ function evaluateFileCall(tool, args, context) {
190
200
  const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
191
201
  // ignoreBOM keeps a byte order mark in the text, so an edit writes it back.
192
202
  const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
203
+ // The final path component is opened without following a symbolic link: the
204
+ // policy resolved the target, so a link there now is a swapped path (#261).
205
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
193
206
 
194
207
  class FileToolError extends Error {}
195
208
 
196
- /** Text of a regular workspace file, refusing what read could not return exactly. */
197
- function readText(file, maxFileBytes) {
198
- let stat;
209
+ /** lstat of `file`, or null when it does not exist. */
210
+ function lstatOrNull(file) {
199
211
  try {
200
- stat = fs.statSync(file);
212
+ return fs.lstatSync(file);
201
213
  } catch (error) {
214
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
215
+ return null;
216
+ }
202
217
  throw new FileToolError(
203
- error.code === 'ENOENT'
204
- ? 'no such file'
205
- : `cannot read the file (${error.code || error.message})`
218
+ `cannot inspect the file (${error.code || error.message})`
206
219
  );
207
220
  }
208
- if (stat.isDirectory()) {
209
- throw new FileToolError('is a directory, not a file');
210
- }
211
- if (!stat.isFile()) {
212
- throw new FileToolError('is not a regular file');
213
- }
214
- if (stat.size > maxFileBytes) {
221
+ }
222
+
223
+ /**
224
+ * The exact bytes of a regular workspace file, read through one descriptor
225
+ * opened without following a symbolic link.
226
+ */
227
+ function readBytes(file, maxFileBytes) {
228
+ const link = lstatOrNull(file);
229
+ if (link && link.isSymbolicLink()) {
215
230
  throw new FileToolError(
216
- `has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
231
+ 'is a symbolic link that appeared after start resolved the path; nothing changed'
217
232
  );
218
233
  }
219
- const bytes = fs.readFileSync(file);
220
- if (bytes.length > maxFileBytes) {
234
+ // Refused before opening, as the Rust port must on Windows (opening a
235
+ // directory as a file is denied there); the handle check covers a swap.
236
+ if (link && link.isDirectory()) {
237
+ throw new FileToolError('is a directory, not a file');
238
+ }
239
+ let handle;
240
+ try {
241
+ handle = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
242
+ } catch (error) {
221
243
  throw new FileToolError(
222
- `has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
244
+ error.code === 'ENOENT'
245
+ ? 'no such file'
246
+ : error.code === 'ELOOP'
247
+ ? 'is a symbolic link that appeared after start resolved the path; nothing changed'
248
+ : error.code === 'EISDIR'
249
+ ? 'is a directory, not a file'
250
+ : `cannot read the file (${error.code || error.message})`
223
251
  );
224
252
  }
253
+ try {
254
+ const stat = fs.fstatSync(handle);
255
+ if (stat.isDirectory()) {
256
+ throw new FileToolError('is a directory, not a file');
257
+ }
258
+ if (!stat.isFile()) {
259
+ throw new FileToolError('is not a regular file');
260
+ }
261
+ if (stat.size > maxFileBytes) {
262
+ throw new FileToolError(
263
+ `has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
264
+ );
265
+ }
266
+ const bytes = fs.readFileSync(handle);
267
+ if (bytes.length > maxFileBytes) {
268
+ throw new FileToolError(
269
+ `has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
270
+ );
271
+ }
272
+ return { bytes, mode: stat.mode };
273
+ } finally {
274
+ fs.closeSync(handle);
275
+ }
276
+ }
277
+
278
+ /** Text of a regular workspace file, refusing what read could not return exactly. */
279
+ function readText(file, maxFileBytes) {
280
+ const { bytes, mode } = readBytes(file, maxFileBytes);
225
281
  let text;
226
282
  try {
227
283
  text = decoder.decode(bytes);
@@ -231,11 +287,25 @@ function readText(file, maxFileBytes) {
231
287
  if (text === null || text.includes('\0')) {
232
288
  throw new FileToolError('is not UTF-8 text');
233
289
  }
234
- return { text, bytes, mode: stat.mode };
290
+ return { text, bytes, mode };
291
+ }
292
+
293
+ /** True when `text` has no lone UTF-16 surrogate (it encodes to UTF-8 exactly). */
294
+ function wellFormed(text) {
295
+ return typeof text.isWellFormed === 'function'
296
+ ? text.isWellFormed()
297
+ : !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(
298
+ text
299
+ );
235
300
  }
236
301
 
237
- /** Replace `file` by `content` through a temporary sibling and a rename. */
238
- function writeAtomically(file, content, mode) {
302
+ /**
303
+ * Replace `file` by `content` through a temporary sibling. Just before the
304
+ * temporary file takes the target's place, `guard()` checks that the target
305
+ * still holds the preimage start bound the change to; a new file is linked in
306
+ * place, so a file that appeared meanwhile is never overwritten.
307
+ */
308
+ function writeAtomically(file, content, mode, guard, create) {
239
309
  const temporary = path.join(
240
310
  path.dirname(file),
241
311
  `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.start-tmp`
@@ -251,12 +321,27 @@ function writeAtomically(file, content, mode) {
251
321
  } finally {
252
322
  fs.closeSync(handle);
253
323
  }
254
- fs.renameSync(temporary, file);
324
+ guard();
325
+ if (create) {
326
+ try {
327
+ fs.linkSync(temporary, file);
328
+ } catch (error) {
329
+ if (error.code === 'EEXIST') {
330
+ throw new FileToolError(
331
+ 'was created while start was writing it; nothing changed'
332
+ );
333
+ }
334
+ throw error;
335
+ }
336
+ } else {
337
+ fs.renameSync(temporary, file);
338
+ }
255
339
  } finally {
256
340
  fs.rmSync(temporary, { force: true });
257
341
  }
258
342
  }
259
343
 
344
+ /** Indices of the non-overlapping occurrences of `needle` in `text`. */
260
345
  function occurrences(text, needle) {
261
346
  const found = [];
262
347
  for (
@@ -269,19 +354,180 @@ function occurrences(text, needle) {
269
354
  return found;
270
355
  }
271
356
 
357
+ /** Byte-level splice of `preimage`: each match's old bytes become `newBytes`. */
358
+ function splice(preimage, matches, newBytes) {
359
+ const parts = [];
360
+ let cursor = 0;
361
+ for (const match of matches) {
362
+ parts.push(preimage.subarray(cursor, match.offset), newBytes);
363
+ cursor = match.offset + match.oldBytes;
364
+ }
365
+ parts.push(preimage.subarray(cursor));
366
+ return Buffer.concat(parts);
367
+ }
368
+
369
+ /**
370
+ * Check a readback against the mutation receipt, independently of how the
371
+ * new text was computed: every matched preimage region held exactly the old
372
+ * bytes, every other preimage byte is unchanged and in place, every replaced
373
+ * region holds exactly the new bytes, and nothing else was added.
374
+ * @param {Buffer} preimage - Bytes the change was bound to
375
+ * @param {Buffer} readback - Bytes read back after the change
376
+ * @param {object[]} matches - [{offset, oldBytes, newBytes}] in byte units
377
+ * @param {Buffer} oldBytes - UTF-8 bytes of old_string
378
+ * @param {Buffer} newBytes - UTF-8 bytes of new_string
379
+ * @returns {{verified: boolean, detail: string}}
380
+ */
381
+ function verifyMutation(preimage, readback, matches, oldBytes, newBytes) {
382
+ const bad = (detail) => ({ verified: false, detail });
383
+ if (!matches.length) {
384
+ return bad('the mutation receipt has no matched region');
385
+ }
386
+ const delta = matches.length * (newBytes.length - oldBytes.length);
387
+ if (readback.length !== preimage.length + delta) {
388
+ return bad(
389
+ `readback has ${readback.length} bytes, expected ${preimage.length + delta}`
390
+ );
391
+ }
392
+ let before = 0;
393
+ let after = 0;
394
+ for (const [index, match] of matches.entries()) {
395
+ if (
396
+ match.offset < before ||
397
+ match.oldBytes !== oldBytes.length ||
398
+ match.newBytes !== newBytes.length ||
399
+ match.offset + match.oldBytes > preimage.length
400
+ ) {
401
+ return bad(`match ${index + 1} of the mutation receipt is malformed`);
402
+ }
403
+ const keep = match.offset - before;
404
+ if (
405
+ !preimage
406
+ .subarray(before, match.offset)
407
+ .equals(readback.subarray(after, after + keep))
408
+ ) {
409
+ return bad(`bytes before match ${index + 1} changed`);
410
+ }
411
+ after += keep;
412
+ if (
413
+ !preimage
414
+ .subarray(match.offset, match.offset + match.oldBytes)
415
+ .equals(oldBytes)
416
+ ) {
417
+ return bad(`match ${index + 1} did not hold old_string in the preimage`);
418
+ }
419
+ if (!readback.subarray(after, after + newBytes.length).equals(newBytes)) {
420
+ return bad(
421
+ `match ${index + 1} does not hold new_string after the change`
422
+ );
423
+ }
424
+ before = match.offset + match.oldBytes;
425
+ after += newBytes.length;
426
+ }
427
+ if (!preimage.subarray(before).equals(readback.subarray(after))) {
428
+ return bad('bytes after the last match changed');
429
+ }
430
+ return {
431
+ verified: true,
432
+ detail: `${matches.length} replacement(s); every other byte unchanged`,
433
+ };
434
+ }
435
+
436
+ const digest = (bytes) => ({ sha256: sha256(bytes), bytes: bytes.length });
437
+
438
+ /**
439
+ * The complete text of a read as the numbered frame Formal AI binds as a
440
+ * whole-file Read (#261): one `NNNNN| line` per `\n`-separated line and a
441
+ * trailer with the line count. Formal AI decodes it back to exactly `text`
442
+ * (lines joined with `\n`, so a missing or present final newline, CR bytes
443
+ * and Unicode survive); its OpenAI-compatible request parser drops any other
444
+ * metadata on a tool message, so the frame is the only binding it accepts.
445
+ * @param {string} text - Complete file text
446
+ * @returns {string} Framed read result
447
+ */
448
+ function readFrame(text) {
449
+ const lines = text.split('\n');
450
+ const width = Math.max(5, String(lines.length).length);
451
+ const numbered = lines.map(
452
+ (line, index) => `${String(index + 1).padStart(width, '0')}| ${line}`
453
+ );
454
+ return `<file>\n${numbered.join('\n')}\n\n(End of file - total ${lines.length} lines)\n</file>`;
455
+ }
456
+
457
+ /**
458
+ * The exact text a readFrame() result frames, or null when it is not one.
459
+ * @param {string} frame - Read result
460
+ * @returns {string|null} Decoded text
461
+ */
462
+ function readFrameText(frame) {
463
+ const open = '<file>\n';
464
+ const marker = '\n\n(End of file - total ';
465
+ if (!frame.startsWith(open) || !frame.endsWith('</file>')) {
466
+ return null;
467
+ }
468
+ const at = frame.lastIndexOf(marker);
469
+ if (at < 0) {
470
+ return null;
471
+ }
472
+ const lines = frame.slice(open.length, at).split('\n');
473
+ const count = Number(frame.slice(at + marker.length).split(/\s+/u)[0]);
474
+ if (count !== lines.length) {
475
+ return null;
476
+ }
477
+ const out = [];
478
+ for (const [index, line] of lines.entries()) {
479
+ const split = line.indexOf('| ');
480
+ if (split < 0 || Number(line.slice(0, split)) !== index + 1) {
481
+ return null;
482
+ }
483
+ out.push(line.slice(split + 2));
484
+ }
485
+ return out.join('\n');
486
+ }
487
+
488
+ /**
489
+ * Whether a tool result of the conversation already contained `text`, as
490
+ * sent or as the file text of a read frame (whose lines carry numbers): an
491
+ * answer that only echoes file data back is not Formal AI's own (#261).
492
+ * @param {string} text - Trimmed answer
493
+ * @param {object[]} messages - Conversation so far
494
+ * @returns {boolean}
495
+ */
496
+ function echoedByTool(text, messages) {
497
+ return messages.some((m) => {
498
+ if (!m || m.role !== 'tool') {
499
+ return false;
500
+ }
501
+ const content = String(m.content ?? '');
502
+ return (
503
+ content.includes(text) || (readFrameText(content) ?? '').includes(text)
504
+ );
505
+ });
506
+ }
507
+
272
508
  /**
273
509
  * Run an approved file tool call.
510
+ *
511
+ * A change is bound to the complete bytes of the target that this task last
512
+ * read with the read tool (or wrote and verified): the edit is applied to
513
+ * those bytes only, the target is checked to still hold them just before the
514
+ * new file takes its place, and the readback is verified byte for byte
515
+ * against the mutation receipt (edit) or the whole intended text (write).
274
516
  * @param {string} tool - read, edit or write
275
517
  * @param {object} args - normalizeFileArgs() result
276
518
  * @param {object} decision - evaluateFileCall() result
277
- * @param {object} context - Loop context; `contents` maps paths to the sha256 start last saw
519
+ * @param {object} context - Loop context; `contents` maps paths to the sha256
520
+ * of the complete bytes start last read or verified; optional
521
+ * `beforeReplace(file)` and `afterReplace(file)` hooks run just before the
522
+ * guard and just before the readback (tests)
278
523
  * @param {object} bounds - {maxFileBytes}
279
- * @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?, replacements?, verification?}
524
+ * @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?,
525
+ * previousSha256?, replacements?, mutation?, verification?}
280
526
  */
281
527
  function runFileCall(tool, args, decision, context, bounds) {
282
528
  const file = path.join(fs.realpathSync(context.cwd), decision.target);
283
529
  const { target } = decision;
284
- const fail = (message) => ({
530
+ const fail = (message, extra = {}) => ({
285
531
  exitCode: 1,
286
532
  error: `${target}: ${message}`,
287
533
  result: JSON.stringify({
@@ -289,27 +535,49 @@ function runFileCall(tool, args, decision, context, bounds) {
289
535
  is_error: true,
290
536
  error: `${target}: ${message}`,
291
537
  }),
538
+ ...extra,
292
539
  });
293
- try {
294
- if (tool === 'read') {
540
+ if (tool === 'read') {
541
+ try {
295
542
  const { text, bytes } = readText(file, bounds.maxFileBytes);
296
543
  context.observed.add(target);
297
544
  context.contents.set(target, sha256(bytes));
298
545
  return {
299
546
  exitCode: 0,
300
- result: text,
547
+ result: readFrame(text),
301
548
  text,
302
549
  bytes: bytes.length,
303
550
  sha256: sha256(bytes),
304
551
  };
552
+ } catch (error) {
553
+ return fail(
554
+ error instanceof FileToolError
555
+ ? error.message
556
+ : `failed (${error.code || error.message})`
557
+ );
305
558
  }
306
- let current = null;
307
- if (tool === 'edit' || fs.existsSync(file)) {
308
- current = readText(file, bounds.maxFileBytes);
559
+ }
560
+ let current = null;
561
+ let mutation;
562
+ let expected;
563
+ try {
564
+ const existing = lstatOrNull(file);
565
+ if (existing && existing.isSymbolicLink()) {
566
+ return fail(
567
+ 'is a symbolic link start cannot resolve inside the working directory; nothing changed'
568
+ );
569
+ }
570
+ if (tool === 'edit' || existing) {
309
571
  const seen = context.contents.get(target);
310
- if (seen !== undefined && seen !== sha256(current.bytes)) {
572
+ if (seen === undefined) {
311
573
  return fail(
312
- 'changed since start read it; read it again before changing it'
574
+ 'was not read completely with the read tool in this task; read it before changing it; nothing changed'
575
+ );
576
+ }
577
+ current = readText(file, bounds.maxFileBytes);
578
+ if (seen !== sha256(current.bytes)) {
579
+ return fail(
580
+ 'changed since start read it; read it again before changing it; nothing changed'
313
581
  );
314
582
  }
315
583
  } else if (
@@ -317,14 +585,18 @@ function runFileCall(tool, args, decision, context, bounds) {
317
585
  ) {
318
586
  return fail('its directory does not exist');
319
587
  }
320
- let updated;
321
- let replacements = null;
588
+ let matches = null;
322
589
  if (tool === 'edit') {
323
590
  if (args.oldString === '') {
324
- return fail('old_string is empty');
591
+ return fail('old_string is empty; nothing changed');
325
592
  }
326
593
  if (args.oldString === args.newString) {
327
- return fail('old_string and new_string are the same');
594
+ return fail('old_string and new_string are the same; nothing changed');
595
+ }
596
+ if (!wellFormed(args.oldString) || !wellFormed(args.newString)) {
597
+ return fail(
598
+ 'old_string or new_string is not valid Unicode text; nothing changed'
599
+ );
328
600
  }
329
601
  const found = occurrences(current.text, args.oldString);
330
602
  if (!found.length) {
@@ -335,50 +607,191 @@ function runFileCall(tool, args, decision, context, bounds) {
335
607
  `old_string occurs ${found.length} times; give more surrounding text or set replace_all; nothing changed`
336
608
  );
337
609
  }
338
- replacements = args.replaceAll ? found.length : 1;
339
- updated = args.replaceAll
610
+ const oldBytes = Buffer.from(args.oldString, 'utf8');
611
+ const newBytes = Buffer.from(args.newString, 'utf8');
612
+ matches = (args.replaceAll ? found : found.slice(0, 1)).map((at) => ({
613
+ offset: Buffer.byteLength(current.text.slice(0, at), 'utf8'),
614
+ oldBytes: oldBytes.length,
615
+ newBytes: newBytes.length,
616
+ }));
617
+ expected = splice(current.bytes, matches, newBytes);
618
+ // The byte splice and the text replacement must agree exactly.
619
+ const text = args.replaceAll
340
620
  ? current.text.split(args.oldString).join(args.newString)
341
621
  : `${current.text.slice(0, found[0])}${args.newString}${current.text.slice(found[0] + args.oldString.length)}`;
622
+ if (!expected.equals(Buffer.from(text, 'utf8'))) {
623
+ return fail(
624
+ 'the replacement does not map to whole UTF-8 characters; nothing changed'
625
+ );
626
+ }
627
+ mutation = { oldBytes, newBytes };
342
628
  } else {
343
- updated = args.content;
629
+ if (!wellFormed(args.content)) {
630
+ return fail('content is not valid Unicode text; nothing changed');
631
+ }
632
+ expected = Buffer.from(args.content, 'utf8');
344
633
  }
345
- const expected = Buffer.from(updated, 'utf8');
346
634
  if (expected.length > bounds.maxFileBytes) {
347
635
  return fail(
348
- `the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}`
636
+ `the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}; nothing changed`
349
637
  );
350
638
  }
351
- writeAtomically(file, expected, current ? current.mode : undefined);
352
- const readback = fs.readFileSync(file);
353
- const verified = readback.equals(expected);
354
- context.observed.add(target);
355
- context.contents.set(target, sha256(readback));
639
+ mutation = {
640
+ tool,
641
+ target,
642
+ preimage: current ? digest(current.bytes) : null,
643
+ postimage: digest(expected),
644
+ ...(matches ? { matches } : {}),
645
+ ...(mutation ? { _old: mutation.oldBytes, _new: mutation.newBytes } : {}),
646
+ };
647
+ const guard = () => {
648
+ if (context.beforeReplace) {
649
+ context.beforeReplace(file);
650
+ }
651
+ const now = lstatOrNull(file);
652
+ const unchanged = current
653
+ ? now &&
654
+ !now.isSymbolicLink() &&
655
+ readBytes(file, bounds.maxFileBytes).bytes.equals(current.bytes)
656
+ : now === null;
657
+ if (!unchanged) {
658
+ throw new FileToolError(
659
+ current
660
+ ? 'changed while start was changing it; read it again; nothing changed'
661
+ : 'was created while start was writing it; nothing changed'
662
+ );
663
+ }
664
+ };
665
+ writeAtomically(
666
+ file,
667
+ expected,
668
+ current ? current.mode : undefined,
669
+ guard,
670
+ !current
671
+ );
672
+ } catch (error) {
673
+ const message =
674
+ error instanceof FileToolError
675
+ ? error.message
676
+ : `failed (${error.code || error.message})`;
677
+ if (!mutation) {
678
+ return fail(message);
679
+ }
680
+ // The change did not complete: say truthfully whether the target still
681
+ // holds its preimage, and forget a preimage that no longer holds.
682
+ const intact = stillHolds(file, current, bounds);
683
+ if (!intact) {
684
+ context.contents.delete(target);
685
+ }
686
+ return fail(
687
+ intact || /nothing changed$/.test(message)
688
+ ? `${message.replace(/; nothing changed$/, '')}; nothing changed`
689
+ : `${message}; the file may have changed; read it again`,
690
+ { mutation: publicMutation(mutation) }
691
+ );
692
+ }
693
+ context.observed.add(target);
694
+ const receipt = publicMutation(mutation);
695
+ let readback;
696
+ try {
697
+ if (context.afterReplace) {
698
+ context.afterReplace(file);
699
+ }
700
+ readback = readBytes(file, bounds.maxFileBytes).bytes;
701
+ } catch (error) {
702
+ context.contents.delete(target);
356
703
  return {
357
704
  exitCode: 0,
358
- result: '',
705
+ result: changeResult(receipt, false),
359
706
  bytes: expected.length,
360
- sha256: sha256(readback),
361
707
  previousSha256: current ? sha256(current.bytes) : null,
362
- replacements,
708
+ replacements: receipt.matches ? receipt.matches.length : null,
709
+ mutation: receipt,
363
710
  verification: {
364
- verified,
365
- detail: verified
366
- ? `readback confirmed ${tool} ${target} (${expected.length} bytes)`
367
- : `readback of ${target} differs from the written text`,
711
+ verified: false,
712
+ detail: `readback of ${target} failed (${error.message})`,
368
713
  },
369
714
  };
370
- } catch (error) {
371
- if (error instanceof FileToolError) {
372
- return fail(error.message);
715
+ }
716
+ const check =
717
+ tool === 'edit'
718
+ ? verifyMutation(
719
+ current.bytes,
720
+ readback,
721
+ receipt.matches,
722
+ mutation._old,
723
+ mutation._new
724
+ )
725
+ : { verified: true, detail: 'whole text' };
726
+ const verified = check.verified && readback.equals(expected);
727
+ receipt.readback = digest(readback);
728
+ if (verified) {
729
+ context.contents.set(target, sha256(readback));
730
+ } else {
731
+ context.contents.delete(target);
732
+ }
733
+ return {
734
+ exitCode: 0,
735
+ result: changeResult(receipt, verified),
736
+ bytes: expected.length,
737
+ sha256: sha256(readback),
738
+ previousSha256: current ? sha256(current.bytes) : null,
739
+ replacements: receipt.matches ? receipt.matches.length : null,
740
+ mutation: receipt,
741
+ verification: {
742
+ verified,
743
+ detail: verified
744
+ ? `readback confirmed ${tool} ${target} (${expected.length} bytes; ${check.detail})`
745
+ : `readback of ${target} differs from the intended text${check.verified ? '' : ` (${check.detail})`}`,
746
+ },
747
+ };
748
+ }
749
+
750
+ /** Whether `file` still holds the bound preimage (or is still absent). */
751
+ function stillHolds(file, current, bounds) {
752
+ try {
753
+ const now = lstatOrNull(file);
754
+ if (!current) {
755
+ return now === null;
373
756
  }
374
- return fail(`failed (${error.code || error.message})`);
757
+ return (
758
+ now !== null &&
759
+ !now.isSymbolicLink() &&
760
+ readBytes(file, bounds.maxFileBytes).bytes.equals(current.bytes)
761
+ );
762
+ } catch {
763
+ return false;
375
764
  }
376
765
  }
377
766
 
767
+ /** The mutation receipt without its internal byte buffers. */
768
+ function publicMutation(mutation) {
769
+ return Object.fromEntries(
770
+ Object.entries(mutation).filter(([key]) => key !== '_old' && key !== '_new')
771
+ );
772
+ }
773
+
774
+ /** The tool result Formal AI receives for a change: its receipt, as JSON. */
775
+ function changeResult(receipt, verified) {
776
+ return JSON.stringify({
777
+ ok: verified,
778
+ path: receipt.target,
779
+ tool: receipt.tool,
780
+ preimage: receipt.preimage,
781
+ postimage: receipt.postimage,
782
+ ...(receipt.matches ? { replacements: receipt.matches.length } : {}),
783
+ verified,
784
+ });
785
+ }
786
+
378
787
  module.exports = {
379
788
  FILE_TOOLS,
380
789
  describeFileCall,
790
+ echoedByTool,
381
791
  evaluateFileCall,
382
792
  normalizeFileArgs,
793
+ readFrame,
794
+ readFrameText,
383
795
  runFileCall,
796
+ verifyMutation,
384
797
  };