start-command 0.41.0 → 0.42.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Structured `read`, `edit` and `write` tools of the experimental
3
+ * `--use-formal-ai` mode (issue #258).
4
+ *
5
+ * The `bash` tool runs only programs without shell syntax, and its write
6
+ * programs (cp, mkdir, mv, rm, rmdir, touch) cannot change the text of a
7
+ * file. These three tools are the guarded replacement primitive instead of an
8
+ * interpreter: start itself reads and writes the one named workspace file,
9
+ * byte for byte, so comments, whitespace and unrelated content survive.
10
+ *
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.
13
+ * - edit: replaces one literal occurrence of old_string (or every one with
14
+ * replace_all) in a file read before, atomically, then reads it back.
15
+ * - write: replaces an observed file, or creates one in an observed
16
+ * directory, atomically, then reads it back.
17
+ *
18
+ * Paths follow the bash tool's workspace policy (no absolute, escaping or
19
+ * symlinked-out paths), edit and write need START_FORMAL_AI_EFFECTS=
20
+ * workspace-write and an earlier observation of the target, and a file that
21
+ * changed since start read it is not overwritten.
22
+ */
23
+
24
+ const crypto = require('crypto');
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const policy = require('./formal-ai-policy');
28
+
29
+ const { TextDecoder } = globalThis;
30
+
31
+ const PATH = {
32
+ type: 'string',
33
+ description: 'File path inside the working directory',
34
+ };
35
+ const FILE_TOOLS = Object.freeze({
36
+ read: Object.freeze({
37
+ type: 'function',
38
+ function: {
39
+ name: 'read',
40
+ description: 'Read the exact text of one file in the working directory.',
41
+ parameters: {
42
+ type: 'object',
43
+ properties: { path: PATH },
44
+ required: ['path'],
45
+ },
46
+ },
47
+ }),
48
+ edit: Object.freeze({
49
+ type: 'function',
50
+ function: {
51
+ name: 'edit',
52
+ 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.',
54
+ parameters: {
55
+ type: 'object',
56
+ properties: {
57
+ path: PATH,
58
+ old_string: { type: 'string' },
59
+ new_string: { type: 'string' },
60
+ replace_all: { type: 'boolean' },
61
+ },
62
+ required: ['path', 'old_string', 'new_string'],
63
+ },
64
+ },
65
+ }),
66
+ write: Object.freeze({
67
+ type: 'function',
68
+ function: {
69
+ name: 'write',
70
+ description:
71
+ 'Write the whole text of one file: a file read before, or a new file in a directory listed before.',
72
+ parameters: {
73
+ type: 'object',
74
+ properties: { path: PATH, content: { type: 'string' } },
75
+ required: ['path', 'content'],
76
+ },
77
+ },
78
+ }),
79
+ });
80
+
81
+ /** Argument names Formal AI and other agents use for the same field. */
82
+ const ALIASES = Object.freeze({
83
+ path: ['path', 'filePath', 'file_path'],
84
+ oldString: ['old_string', 'oldString', 'old_str', 'old'],
85
+ newString: ['new_string', 'newString', 'new_str', 'new'],
86
+ replaceAll: ['replace_all', 'replaceAll'],
87
+ content: ['content'],
88
+ });
89
+ const FIELDS = Object.freeze({
90
+ read: ['path'],
91
+ edit: ['path', 'oldString', 'newString', 'replaceAll'],
92
+ write: ['path', 'content'],
93
+ });
94
+ const REQUIRED = Object.freeze({
95
+ read: ['path'],
96
+ edit: ['path', 'oldString', 'newString'],
97
+ write: ['path', 'content'],
98
+ });
99
+
100
+ /**
101
+ * Normalize the JSON arguments of a file tool call.
102
+ * @param {string} tool - read, edit or write
103
+ * @param {object} args - Parsed arguments
104
+ * @returns {{args?: object, problem?: string}}
105
+ */
106
+ function normalizeFileArgs(tool, args) {
107
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
108
+ return { problem: `${tool} arguments are not a JSON object` };
109
+ }
110
+ const result = {};
111
+ for (const field of FIELDS[tool]) {
112
+ const type = field === 'replaceAll' ? 'boolean' : 'string';
113
+ const values = ALIASES[field]
114
+ .filter((key) => Object.prototype.hasOwnProperty.call(args, key))
115
+ .map((key) => args[key]);
116
+ if (values.some((value) => typeof value !== type)) {
117
+ return {
118
+ problem: `${tool} argument ${ALIASES[field][0]} must be a ${type}`,
119
+ };
120
+ }
121
+ if (values.some((value) => value !== values[0])) {
122
+ return {
123
+ problem: `${tool} received conflicting ${ALIASES[field][0]} values`,
124
+ };
125
+ }
126
+ if (values.length) {
127
+ result[field] = values[0];
128
+ }
129
+ }
130
+ const missing = REQUIRED[tool].filter((field) => !(field in result));
131
+ if (missing.length) {
132
+ return {
133
+ problem: `${tool} needs ${missing.map((f) => ALIASES[f][0]).join(', ')}`,
134
+ };
135
+ }
136
+ if (!result.path.trim() || result.path.includes('\0')) {
137
+ return { problem: `${tool} needs a non-empty path` };
138
+ }
139
+ return { args: { replaceAll: false, ...result } };
140
+ }
141
+
142
+ /** One-line rendering of a file call for notes and evidence. */
143
+ function describeFileCall(tool, args) {
144
+ return `${tool} ${args.path}`;
145
+ }
146
+
147
+ /**
148
+ * Decide whether a file tool call may run (the effect policy of #255).
149
+ * @param {string} tool - read, edit or write
150
+ * @param {object} args - normalizeFileArgs() result
151
+ * @param {object} context - {cwd, effects, observed, observedDirs}
152
+ * @returns {{allowed: boolean, reason?: string, kind?: string, target?: string}}
153
+ */
154
+ function evaluateFileCall(tool, args, context) {
155
+ const refuse = (reason) => ({ allowed: false, reason });
156
+ const kind = tool === 'read' ? 'read' : 'write';
157
+ if (kind === 'write' && context.effects !== 'workspace-write') {
158
+ return refuse(
159
+ `"${tool}" changes files; the effect mode is read-only (set START_FORMAL_AI_EFFECTS=workspace-write to allow workspace changes)`
160
+ );
161
+ }
162
+ if (args.path.startsWith('~')) {
163
+ return refuse(`"${args.path}" is outside the working directory`);
164
+ }
165
+ const target = policy.workspacePath(context.cwd, args.path);
166
+ if (target === null) {
167
+ return refuse(`"${args.path}" is outside the working directory`);
168
+ }
169
+ if (target === '.') {
170
+ return refuse(`"${args.path}" is the working directory, not a file`);
171
+ }
172
+ if (kind === 'write') {
173
+ const exists = fs.existsSync(
174
+ path.join(fs.realpathSync(context.cwd), target)
175
+ );
176
+ const observed =
177
+ context.observed.has(target) ||
178
+ (tool === 'write' &&
179
+ !exists &&
180
+ context.observedDirs.has(path.dirname(target)));
181
+ if (!observed) {
182
+ return refuse(
183
+ `target "${target}" was not ${tool === 'edit' ? 'read' : 'observed'} before the change`
184
+ );
185
+ }
186
+ }
187
+ return { allowed: true, kind, target };
188
+ }
189
+
190
+ const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
191
+ // ignoreBOM keeps a byte order mark in the text, so an edit writes it back.
192
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
193
+
194
+ class FileToolError extends Error {}
195
+
196
+ /** Text of a regular workspace file, refusing what read could not return exactly. */
197
+ function readText(file, maxFileBytes) {
198
+ let stat;
199
+ try {
200
+ stat = fs.statSync(file);
201
+ } catch (error) {
202
+ throw new FileToolError(
203
+ error.code === 'ENOENT'
204
+ ? 'no such file'
205
+ : `cannot read the file (${error.code || error.message})`
206
+ );
207
+ }
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) {
215
+ throw new FileToolError(
216
+ `has ${stat.size} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
217
+ );
218
+ }
219
+ const bytes = fs.readFileSync(file);
220
+ if (bytes.length > maxFileBytes) {
221
+ throw new FileToolError(
222
+ `has ${bytes.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${maxFileBytes}`
223
+ );
224
+ }
225
+ let text;
226
+ try {
227
+ text = decoder.decode(bytes);
228
+ } catch {
229
+ text = null;
230
+ }
231
+ if (text === null || text.includes('\0')) {
232
+ throw new FileToolError('is not UTF-8 text');
233
+ }
234
+ return { text, bytes, mode: stat.mode };
235
+ }
236
+
237
+ /** Replace `file` by `content` through a temporary sibling and a rename. */
238
+ function writeAtomically(file, content, mode) {
239
+ const temporary = path.join(
240
+ path.dirname(file),
241
+ `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.start-tmp`
242
+ );
243
+ try {
244
+ fs.writeFileSync(temporary, content, { flag: 'wx', mode: mode ?? 0o666 });
245
+ if (mode !== undefined) {
246
+ fs.chmodSync(temporary, mode & 0o7777);
247
+ }
248
+ const handle = fs.openSync(temporary, 'r+');
249
+ try {
250
+ fs.fsyncSync(handle);
251
+ } finally {
252
+ fs.closeSync(handle);
253
+ }
254
+ fs.renameSync(temporary, file);
255
+ } finally {
256
+ fs.rmSync(temporary, { force: true });
257
+ }
258
+ }
259
+
260
+ function occurrences(text, needle) {
261
+ const found = [];
262
+ for (
263
+ let at = text.indexOf(needle);
264
+ at >= 0;
265
+ at = text.indexOf(needle, at + needle.length)
266
+ ) {
267
+ found.push(at);
268
+ }
269
+ return found;
270
+ }
271
+
272
+ /**
273
+ * Run an approved file tool call.
274
+ * @param {string} tool - read, edit or write
275
+ * @param {object} args - normalizeFileArgs() result
276
+ * @param {object} decision - evaluateFileCall() result
277
+ * @param {object} context - Loop context; `contents` maps paths to the sha256 start last saw
278
+ * @param {object} bounds - {maxFileBytes}
279
+ * @returns {object} Receipt {exitCode, result, error?, bytes?, sha256?, replacements?, verification?}
280
+ */
281
+ function runFileCall(tool, args, decision, context, bounds) {
282
+ const file = path.join(fs.realpathSync(context.cwd), decision.target);
283
+ const { target } = decision;
284
+ const fail = (message) => ({
285
+ exitCode: 1,
286
+ error: `${target}: ${message}`,
287
+ result: JSON.stringify({
288
+ ok: false,
289
+ is_error: true,
290
+ error: `${target}: ${message}`,
291
+ }),
292
+ });
293
+ try {
294
+ if (tool === 'read') {
295
+ const { text, bytes } = readText(file, bounds.maxFileBytes);
296
+ context.observed.add(target);
297
+ context.contents.set(target, sha256(bytes));
298
+ return {
299
+ exitCode: 0,
300
+ result: text,
301
+ text,
302
+ bytes: bytes.length,
303
+ sha256: sha256(bytes),
304
+ };
305
+ }
306
+ let current = null;
307
+ if (tool === 'edit' || fs.existsSync(file)) {
308
+ current = readText(file, bounds.maxFileBytes);
309
+ const seen = context.contents.get(target);
310
+ if (seen !== undefined && seen !== sha256(current.bytes)) {
311
+ return fail(
312
+ 'changed since start read it; read it again before changing it'
313
+ );
314
+ }
315
+ } else if (
316
+ !fs.statSync(path.dirname(file), { throwIfNoEntry: false })?.isDirectory()
317
+ ) {
318
+ return fail('its directory does not exist');
319
+ }
320
+ let updated;
321
+ let replacements = null;
322
+ if (tool === 'edit') {
323
+ if (args.oldString === '') {
324
+ return fail('old_string is empty');
325
+ }
326
+ if (args.oldString === args.newString) {
327
+ return fail('old_string and new_string are the same');
328
+ }
329
+ const found = occurrences(current.text, args.oldString);
330
+ if (!found.length) {
331
+ return fail('old_string was not found; nothing changed');
332
+ }
333
+ if (found.length > 1 && !args.replaceAll) {
334
+ return fail(
335
+ `old_string occurs ${found.length} times; give more surrounding text or set replace_all; nothing changed`
336
+ );
337
+ }
338
+ replacements = args.replaceAll ? found.length : 1;
339
+ updated = args.replaceAll
340
+ ? current.text.split(args.oldString).join(args.newString)
341
+ : `${current.text.slice(0, found[0])}${args.newString}${current.text.slice(found[0] + args.oldString.length)}`;
342
+ } else {
343
+ updated = args.content;
344
+ }
345
+ const expected = Buffer.from(updated, 'utf8');
346
+ if (expected.length > bounds.maxFileBytes) {
347
+ return fail(
348
+ `the new text has ${expected.length} bytes; START_FORMAL_AI_MAX_FILE_BYTES is ${bounds.maxFileBytes}`
349
+ );
350
+ }
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));
356
+ return {
357
+ exitCode: 0,
358
+ result: '',
359
+ bytes: expected.length,
360
+ sha256: sha256(readback),
361
+ previousSha256: current ? sha256(current.bytes) : null,
362
+ replacements,
363
+ verification: {
364
+ verified,
365
+ detail: verified
366
+ ? `readback confirmed ${tool} ${target} (${expected.length} bytes)`
367
+ : `readback of ${target} differs from the written text`,
368
+ },
369
+ };
370
+ } catch (error) {
371
+ if (error instanceof FileToolError) {
372
+ return fail(error.message);
373
+ }
374
+ return fail(`failed (${error.code || error.message})`);
375
+ }
376
+ }
377
+
378
+ module.exports = {
379
+ FILE_TOOLS,
380
+ describeFileCall,
381
+ evaluateFileCall,
382
+ normalizeFileArgs,
383
+ runFileCall,
384
+ };
@@ -15,9 +15,9 @@ const path = require('path');
15
15
  /** Exit statuses of the mode; a finished task keeps its last tool's status. */
16
16
  const EXIT = Object.freeze({
17
17
  USAGE: 64, // request text or option combination rejected
18
- UNSUPPORTED: 65, // Formal AI reported that it did not understand the task
18
+ UNSUPPORTED: 65, // Formal AI did not understand the task or reported a capability gap
19
19
  UNAVAILABLE: 69, // no qualified Formal AI runtime could be resolved
20
- PROTOCOL: 70, // malformed response, unknown tool or unverified effect
20
+ PROTOCOL: 70, // malformed or 5xx response, unknown tool or unverified effect
21
21
  REFUSED: 77, // a tool call was refused by the effect policy
22
22
  CONFIG: 78, // invalid START_FORMAL_AI_* bound
23
23
  BUDGET: 124, // step, tool, time or retry budget exhausted
@@ -39,6 +39,11 @@ const BOUND_DEFAULTS = Object.freeze({
39
39
  value: 64 * 1024,
40
40
  max: 16 * 1024 * 1024,
41
41
  },
42
+ maxFileBytes: {
43
+ env: 'START_FORMAL_AI_MAX_FILE_BYTES',
44
+ value: 1024 * 1024,
45
+ max: 16 * 1024 * 1024,
46
+ },
42
47
  maxRequestChars: { env: 'START_FORMAL_AI_MAX_REQUEST_CHARS', value: 4096 },
43
48
  maxMemoryMb: { env: 'START_FORMAL_AI_MAX_MEMORY_MB', value: 1024 },
44
49
  maxFingerprintEntries: {
@@ -265,6 +270,8 @@ function operands(tokens) {
265
270
 
266
271
  /**
267
272
  * Normalized workspace-relative path of an operand, or null outside it.
273
+ * It uses `/` on every platform, like the fingerprint, so observations,
274
+ * messages and evidence are the same on Windows.
268
275
  * @param {string} cwd - Working directory
269
276
  * @param {string} operand - Operand text
270
277
  * @returns {string|null}
@@ -275,7 +282,7 @@ function workspacePath(cwd, operand) {
275
282
  if (!isInside(root, resolved)) {
276
283
  return null;
277
284
  }
278
- return path.relative(root, resolved) || '.';
285
+ return path.relative(root, resolved).split(path.sep).join('/') || '.';
279
286
  }
280
287
 
281
288
  /**
@@ -7,10 +7,14 @@
7
7
  * and keeps evidence of what actually happened. The run ends when Formal AI
8
8
  * gives its final answer, says it did not understand, or a bound, refusal,
9
9
  * protocol error or cancellation stops it, and the exit status says which.
10
+ * A final answer that is a known capability gap (issue #258) is reported as
11
+ * incomplete with its obligations, never as an answer.
10
12
  */
11
13
 
12
14
  const policy = require('./formal-ai-policy');
13
15
  const { formatToolResult, runTool } = require('./formal-ai-tool');
16
+ const files = require('./formal-ai-files');
17
+ const { recognizeCapabilityGap } = require('./formal-ai-client');
14
18
 
15
19
  const { EXIT } = policy;
16
20
  const PREVIEW_CHARS = 2000;
@@ -68,13 +72,15 @@ async function runFormalAiTask(options) {
68
72
  effects: bounds.effects,
69
73
  observed: new Set(),
70
74
  observedDirs: new Set(),
75
+ contents: new Map(),
71
76
  };
72
- const finish = (status, exitCode, message, remaining = []) => {
77
+ const finish = (status, exitCode, message, remaining = [], extra = {}) => {
73
78
  evidence.outcome = {
74
79
  status,
75
80
  exitCode,
76
81
  message,
77
82
  remainingObligations: remaining,
83
+ ...extra,
78
84
  };
79
85
  return evidence;
80
86
  };
@@ -119,9 +125,15 @@ async function runFormalAiTask(options) {
119
125
  : error.exitCode === EXIT.UNAVAILABLE
120
126
  ? 'unavailable'
121
127
  : 'protocol-error';
122
- return finish(status, error.exitCode || EXIT.PROTOCOL, error.message, [
123
- 'Formal AI did not complete the task',
124
- ]);
128
+ // The cause and attempt count of a runtime failure stay in the
129
+ // evidence (issue #257); they are sanitized and bounded by the client.
130
+ return finish(
131
+ status,
132
+ error.exitCode || EXIT.PROTOCOL,
133
+ error.message,
134
+ ['Formal AI did not complete the task'],
135
+ error.details ? { diagnostics: { ...error.details } } : {}
136
+ );
125
137
  }
126
138
  evidence.steps.push({
127
139
  kind: 'response',
@@ -135,6 +147,16 @@ async function runFormalAiTask(options) {
135
147
  if (response.content) {
136
148
  note(response.content);
137
149
  }
150
+ const gap = recognizeCapabilityGap(response.content, messages);
151
+ if (gap) {
152
+ return finish(
153
+ 'capability-gap',
154
+ EXIT.UNSUPPORTED,
155
+ describeGap(gap),
156
+ gapObligations(gap, bounds.effects),
157
+ { gap }
158
+ );
159
+ }
138
160
  if (response.handler === 'unknown' && toolCalls === 0) {
139
161
  return finish(
140
162
  'unsupported',
@@ -167,7 +189,10 @@ async function runFormalAiTask(options) {
167
189
  type: 'function',
168
190
  function: {
169
191
  name: call.name,
170
- arguments: JSON.stringify({ command: call.command }),
192
+ arguments:
193
+ call.tool === 'bash'
194
+ ? JSON.stringify({ command: call.command })
195
+ : call.arguments,
171
196
  },
172
197
  })),
173
198
  });
@@ -215,7 +240,139 @@ async function runFormalAiTask(options) {
215
240
  );
216
241
  }
217
242
 
218
- async function runCall({
243
+ /** Capability gap, in words. */
244
+ function describeGap(gap) {
245
+ const missing = gap.missingCapabilities.length
246
+ ? `; missing ${gap.missingCapabilities.join(', ')}`
247
+ : '';
248
+ return `Formal AI reported a capability gap for ${gap.target} (${gap.reason}${missing}); nothing was verified as done`;
249
+ }
250
+
251
+ function gapObligations(gap, effects) {
252
+ const obligations = [
253
+ `the requested change to ${gap.target} was not made or not verified`,
254
+ ];
255
+ const changes = ['edit', 'write', 'multi_edit'];
256
+ if (
257
+ effects !== 'workspace-write' &&
258
+ gap.missingCapabilities.some((c) => changes.includes(c))
259
+ ) {
260
+ obligations.push(
261
+ 'set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools'
262
+ );
263
+ }
264
+ return obligations;
265
+ }
266
+
267
+ function runCall(options) {
268
+ return options.call.tool === 'bash'
269
+ ? runBashCall(options)
270
+ : runFileToolCall(options);
271
+ }
272
+
273
+ /** Record a call's working-directory diff in its step and the run. */
274
+ function recordEffects(step, diff, evidence) {
275
+ step.effects = diff;
276
+ for (const key of ['added', 'removed', 'changed']) {
277
+ evidence.effects[key].push(...diff[key]);
278
+ }
279
+ evidence.effects.complete = evidence.effects.complete && diff.complete;
280
+ }
281
+
282
+ /** A structured read, edit or write call (issue #258). */
283
+ function runFileToolCall({ call, context, cwd, bounds, io, note, evidence }) {
284
+ const decision = files.evaluateFileCall(call.tool, call.args, context);
285
+ const step = {
286
+ kind: 'tool',
287
+ tool: call.tool,
288
+ command: call.command,
289
+ path: call.args.path,
290
+ };
291
+ evidence.steps.push(step);
292
+ if (!decision.allowed) {
293
+ step.decision = 'refused';
294
+ step.reason = decision.reason;
295
+ note(`refused \`${call.command}\`: ${decision.reason}`);
296
+ return {
297
+ stop: [
298
+ 'refused',
299
+ EXIT.REFUSED,
300
+ `refused \`${call.command}\`: ${decision.reason}`,
301
+ ['the refused call did not run; nothing after it ran either'],
302
+ ],
303
+ };
304
+ }
305
+ Object.assign(step, {
306
+ decision: 'allowed',
307
+ effect: decision.kind,
308
+ target: decision.target,
309
+ });
310
+ note(call.command);
311
+ const started = Date.now();
312
+ const before = policy.fingerprint(cwd, bounds.maxFingerprintEntries);
313
+ const receipt = files.runFileCall(
314
+ call.tool,
315
+ call.args,
316
+ decision,
317
+ context,
318
+ bounds
319
+ );
320
+ const diff = policy.diffFingerprints(
321
+ before,
322
+ policy.fingerprint(cwd, bounds.maxFingerprintEntries)
323
+ );
324
+ Object.assign(step, {
325
+ exitCode: receipt.exitCode,
326
+ durationMs: Date.now() - started,
327
+ ...(receipt.error ? { error: receipt.error } : {}),
328
+ ...(receipt.bytes !== undefined ? { bytes: receipt.bytes } : {}),
329
+ ...(receipt.sha256 ? { sha256: receipt.sha256 } : {}),
330
+ ...(receipt.previousSha256
331
+ ? { previousSha256: receipt.previousSha256 }
332
+ : {}),
333
+ ...(receipt.replacements ? { replacements: receipt.replacements } : {}),
334
+ });
335
+ recordEffects(step, diff, evidence);
336
+ if (receipt.error) {
337
+ note(`${call.tool} failed: ${receipt.error}`);
338
+ } else if (call.tool === 'read') {
339
+ mirrorRead(receipt.text, bounds, io, note);
340
+ if (policy.hasEffects(diff)) {
341
+ step.unexpectedEffects = true;
342
+ }
343
+ } else {
344
+ step.verification = receipt.verification;
345
+ if (!receipt.verification.verified) {
346
+ return {
347
+ stop: [
348
+ 'unverified',
349
+ EXIT.PROTOCOL,
350
+ `\`${call.command}\` wrote the file but ${receipt.verification.detail}`,
351
+ ['the change could not be confirmed by readback'],
352
+ ],
353
+ };
354
+ }
355
+ note(receipt.verification.detail);
356
+ }
357
+ return { exitCode: receipt.exitCode, result: receipt.result };
358
+ }
359
+
360
+ /** Show what a read returned, like the stdout of `cat`, within the output cap. */
361
+ function mirrorRead(text, bounds, io, note) {
362
+ if (!io.stdout || !text) {
363
+ return;
364
+ }
365
+ const bytes = Buffer.from(text, 'utf8');
366
+ const shown = bytes.subarray(0, bounds.maxOutputBytes).toString('utf8');
367
+ io.stdout.write(shown.endsWith('\n') ? shown : `${shown}\n`);
368
+ if (bytes.length > bounds.maxOutputBytes) {
369
+ note(
370
+ `output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES=${bounds.maxOutputBytes}`
371
+ );
372
+ }
373
+ }
374
+
375
+ async function runBashCall({
219
376
  call,
220
377
  context,
221
378
  cwd,
@@ -275,12 +432,8 @@ async function runCall({
275
432
  spawnError: receipt.spawnError,
276
433
  stdout: preview(receipt.stdout),
277
434
  stderr: preview(receipt.stderr),
278
- effects: diff,
279
435
  });
280
- for (const key of ['added', 'removed', 'changed']) {
281
- evidence.effects[key].push(...diff[key]);
282
- }
283
- evidence.effects.complete = evidence.effects.complete && diff.complete;
436
+ recordEffects(step, diff, evidence);
284
437
  if (receipt.truncated) {
285
438
  note(
286
439
  `output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES=${bounds.maxOutputBytes}`
package/src/lib/usage.js CHANGED
@@ -105,7 +105,9 @@ Examples:
105
105
  $ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
106
106
  $ -i docker -d --on-kill-resume 3 --on-kill-resume-delay 30-90 -- cargo test
107
107
  $ --use-command-stream echo "Hello" # Use command-stream library
108
- $ --use-formal-ai -- print list of files in current directory`);
108
+ $ --use-formal-ai -- print list of files in current directory
109
+ $ --use-formal-ai -- show contents of README.md
110
+ START_FORMAL_AI_EFFECTS=workspace-write $ --use-formal-ai -- "In a.js, replace «x» with «y»."`);
109
111
  console.log('');
110
112
  console.log('Piping with $:');
111
113
  console.log(' echo "hi" | $ agent # Preferred - pipe TO $ command');