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,6 +9,11 @@
9
9
  * protocol error or cancellation stops it, and the exit status says which.
10
10
  * A final answer that is a known capability gap (issue #258) is reported as
11
11
  * incomplete with its obligations, never as an answer.
12
+ *
13
+ * A failed change stays unresolved until the same typed tool changes the same
14
+ * target and its readback is verified (issue #261): a later read, a copy over
15
+ * the file or Formal AI's final answer does not resolve it, and the run then
16
+ * ends `incomplete` with the original failure and its exit status.
12
17
  */
13
18
 
14
19
  const policy = require('./formal-ai-policy');
@@ -74,12 +79,21 @@ async function runFormalAiTask(options) {
74
79
  observedDirs: new Set(),
75
80
  contents: new Map(),
76
81
  };
82
+ // Failed calls whose obligation no later verified call met (#261).
83
+ const unresolved = [];
77
84
  const finish = (status, exitCode, message, remaining = [], extra = {}) => {
85
+ const open = unresolved.map(failureObligation);
78
86
  evidence.outcome = {
79
87
  status,
80
88
  exitCode,
81
89
  message,
82
- remainingObligations: remaining,
90
+ remainingObligations: [
91
+ ...remaining,
92
+ ...open.filter((item) => !remaining.includes(item)),
93
+ ],
94
+ ...(unresolved.length
95
+ ? { failures: unresolved.map((f) => ({ ...f })) }
96
+ : {}),
83
97
  ...extra,
84
98
  };
85
99
  return evidence;
@@ -152,11 +166,29 @@ async function runFormalAiTask(options) {
152
166
  return finish(
153
167
  'capability-gap',
154
168
  EXIT.UNSUPPORTED,
155
- describeGap(gap),
169
+ describeGap(gap, evidence.effects),
156
170
  gapObligations(gap, bounds.effects),
157
171
  { gap }
158
172
  );
159
173
  }
174
+ if (unresolved.length) {
175
+ const [first] = unresolved;
176
+ return finish(
177
+ 'incomplete',
178
+ first.exitCode || 1,
179
+ `\`${first.command}\` failed (${first.error}) and no later call resolved it; ${describeEffects(evidence.effects)}`
180
+ );
181
+ }
182
+ const record = unrecognizedGapRecord(response.content, messages);
183
+ if (record) {
184
+ return finish(
185
+ 'incomplete',
186
+ EXIT.UNSUPPORTED,
187
+ `Formal AI reported ${record.schema}${record.reason ? ` (${record.reason})` : ''}${record.target ? ` for ${record.target}` : ''}${record.clause ? ` for the clause ${JSON.stringify(record.clause)}` : ''}; nothing was verified as done by Formal AI; ${describeEffects(evidence.effects)}`,
188
+ ['Formal AI did not confirm the requested change'],
189
+ { gapRecord: record }
190
+ );
191
+ }
160
192
  if (response.handler === 'unknown' && toolCalls === 0) {
161
193
  return finish(
162
194
  'unsupported',
@@ -218,6 +250,7 @@ async function runFormalAiTask(options) {
218
250
  note,
219
251
  evidence,
220
252
  });
253
+ trackFailures(unresolved, evidence.steps[evidence.steps.length - 1]);
221
254
  if (signal && signal.aborted) {
222
255
  return cancelled();
223
256
  }
@@ -240,12 +273,138 @@ async function runFormalAiTask(options) {
240
273
  );
241
274
  }
242
275
 
243
- /** Capability gap, in words. */
244
- function describeGap(gap) {
276
+ /**
277
+ * Keep the failed calls of a run until a later call meets their obligation
278
+ * (issue #261). A failed edit or write is resolved only by a verified edit or
279
+ * write of the same target (a write also resolves a failed read of the file
280
+ * it created); a failed read by a read of the same target; a failed write
281
+ * command only by the same command succeeding. A copy or move over the target
282
+ * never counts as the edit.
283
+ */
284
+ function trackFailures(unresolved, step) {
285
+ if (!step || step.kind !== 'tool' || step.decision !== 'allowed') {
286
+ return;
287
+ }
288
+ const typed = step.tool !== undefined;
289
+ const changed = typed && step.tool !== 'read';
290
+ if (step.exitCode !== 0 && (typed || step.effect === 'write')) {
291
+ unresolved.push({
292
+ command: step.command,
293
+ ...(typed ? { tool: step.tool, target: step.target } : {}),
294
+ exitCode: step.exitCode ?? 1,
295
+ error:
296
+ step.error ||
297
+ (step.signal
298
+ ? `stopped by ${step.signal}`
299
+ : `exited with ${step.exitCode}`),
300
+ });
301
+ return;
302
+ }
303
+ if (step.exitCode !== 0) {
304
+ return;
305
+ }
306
+ const verified = changed && step.verification && step.verification.verified;
307
+ for (let index = unresolved.length - 1; index >= 0; index--) {
308
+ const failure = unresolved[index];
309
+ const resolved = typed
310
+ ? failure.target === step.target &&
311
+ (failure.tool === 'read'
312
+ ? step.tool === 'read' || verified
313
+ : verified && (failure.tool === step.tool || step.tool === 'edit'))
314
+ : failure.tool === undefined && failure.command === step.command;
315
+ if (resolved) {
316
+ unresolved.splice(index, 1);
317
+ }
318
+ }
319
+ }
320
+
321
+ function failureObligation(failure) {
322
+ return `\`${failure.command}\` failed: ${failure.error}`;
323
+ }
324
+
325
+ /**
326
+ * A final answer that is exactly one Links Notation record whose record_type
327
+ * names a gap, as Formal AI's task obligations answer (`obligation_gap`, e.g.
328
+ * reason `no_artifact_in_clause`): an id line, then ` key "value"` lines.
329
+ * Returned in the JSON gap shape (schema = record_type, never complete), so
330
+ * a gap Formal AI states in its own notation is never taken as success, even
331
+ * when start verified every change it made (#261).
332
+ * @param {string} text - Trimmed final answer
333
+ * @returns {object|null} {schema, reason?, clause?, complete: false}
334
+ */
335
+ function linoGapRecord(text) {
336
+ const [id, ...lines] = text.split('\n');
337
+ if (!/^[A-Za-z0-9_]+$/.test(id) || !lines.length) {
338
+ return null;
339
+ }
340
+ const fields = {};
341
+ for (const line of lines) {
342
+ const match = /^ {2}([a-z_]+) (?:"([^"]*)"|'([^']*)'|(\S+))$/.exec(line);
343
+ if (!match || Object.hasOwn(fields, match[1])) {
344
+ return null;
345
+ }
346
+ fields[match[1]] = match[2] ?? match[3] ?? match[4];
347
+ }
348
+ if (typeof fields.record_type !== 'string') {
349
+ return null;
350
+ }
351
+ return {
352
+ schema: fields.record_type,
353
+ ...(fields.reason ? { reason: fields.reason } : {}),
354
+ ...(fields.clause ? { clause: fields.clause } : {}),
355
+ complete: false,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * A final answer that is a JSON record of a gap start does not know: it is
361
+ * never taken as success, though its schema grants no recognized gap.
362
+ */
363
+ function unrecognizedGapRecord(content, messages) {
364
+ let value;
365
+ try {
366
+ value = JSON.parse(String(content || '').trim());
367
+ } catch {
368
+ value = linoGapRecord(String(content || '').trim());
369
+ }
370
+ if (
371
+ !value ||
372
+ typeof value !== 'object' ||
373
+ Array.isArray(value) ||
374
+ typeof value.schema !== 'string' ||
375
+ !/gap/i.test(value.schema) ||
376
+ !(
377
+ value.verified === false ||
378
+ value.complete === false ||
379
+ value.completed === false
380
+ )
381
+ ) {
382
+ return null;
383
+ }
384
+ if (files.echoedByTool(String(content).trim(), messages)) {
385
+ return null;
386
+ }
387
+ return {
388
+ schema: value.schema,
389
+ ...(typeof value.target === 'string' ? { target: value.target } : {}),
390
+ ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
391
+ ...(typeof value.clause === 'string' ? { clause: value.clause } : {}),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Capability gap, in words. When tools changed the working directory, the
397
+ * message keeps what start observed: Formal AI's gap still means the task is
398
+ * not confirmed, whatever start verified on its own (#261).
399
+ */
400
+ function describeGap(gap, effects) {
245
401
  const missing = gap.missingCapabilities.length
246
402
  ? `; missing ${gap.missingCapabilities.join(', ')}`
247
403
  : '';
248
- return `Formal AI reported a capability gap for ${gap.target} (${gap.reason}${missing}); nothing was verified as done`;
404
+ const head = `Formal AI reported a capability gap for ${gap.target} (${gap.reason}${missing})`;
405
+ return effects && policy.hasEffects(effects)
406
+ ? `${head}; Formal AI did not confirm the task as done; start observed: ${describeEffects(effects)}`
407
+ : `${head}; nothing was verified as done`;
249
408
  }
250
409
 
251
410
  function gapObligations(gap, effects) {
@@ -331,6 +490,7 @@ function runFileToolCall({ call, context, cwd, bounds, io, note, evidence }) {
331
490
  ? { previousSha256: receipt.previousSha256 }
332
491
  : {}),
333
492
  ...(receipt.replacements ? { replacements: receipt.replacements } : {}),
493
+ ...(receipt.mutation ? { mutation: receipt.mutation } : {}),
334
494
  });
335
495
  recordEffects(step, diff, evidence);
336
496
  if (receipt.error) {
@@ -347,14 +507,17 @@ function runFileToolCall({ call, context, cwd, bounds, io, note, evidence }) {
347
507
  stop: [
348
508
  'unverified',
349
509
  EXIT.PROTOCOL,
350
- `\`${call.command}\` wrote the file but ${receipt.verification.detail}`,
510
+ `\`${call.command}\` ran but ${receipt.verification.detail}`,
351
511
  ['the change could not be confirmed by readback'],
352
512
  ],
353
513
  };
354
514
  }
355
515
  note(receipt.verification.detail);
356
516
  }
357
- return { exitCode: receipt.exitCode, result: receipt.result };
517
+ return {
518
+ exitCode: receipt.exitCode,
519
+ result: receipt.result,
520
+ };
358
521
  }
359
522
 
360
523
  /** Show what a read returned, like the stdout of `cat`, within the output cap. */
@@ -12,6 +12,10 @@
12
12
  * `Authorization: <scheme> <value>` header, so a logged one-line JSON prompt
13
13
  * keeps the rest of its line (issue #249, sensitive-data-sanitizer#45); the
14
14
  * pre-pass that did this here is gone (issue #253).
15
+ *
16
+ * The package depends on ^0.14.0, the current release flagged by the
17
+ * dependency freshness check; the Rust installer and its version check
18
+ * require the same release (rust/src/lib/log_sanitizer.rs).
15
19
  */
16
20
 
17
21
  const fs = require('fs');
@@ -1,6 +1,6 @@
1
1
  {
2
- "source": "captured from Formal AI 0.355.29 (982a7ffcb05d039292712ec9fe33bdbd645ed569) js agent mode by experiments/issue-258/capture-receipts.cjs",
3
- "capturedAt": "2026-11-02",
2
+ "source": "captured from Formal AI 0.355.32 (08ca27d1ee0560fb56450ad798f3a79c684404ed) js agent mode by experiments/issue-258/capture-receipts.cjs",
3
+ "capturedAt": "2026-11-04",
4
4
  "fixture": {
5
5
  "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
6
6
  },
@@ -136,10 +136,21 @@
136
136
  }
137
137
  ],
138
138
  "outcome": {
139
- "status": "completed",
139
+ "status": "incomplete",
140
140
  "exitCode": 1,
141
- "message": "finished; the last tool call exited with 1; no file changed",
142
- "remainingObligations": ["the last tool call failed"]
141
+ "message": "`edit snippet.mjs` failed (snippet.mjs: old_string was not found; nothing changed) and no later call resolved it; no file changed",
142
+ "remainingObligations": [
143
+ "`edit snippet.mjs` failed: snippet.mjs: old_string was not found; nothing changed"
144
+ ],
145
+ "failures": [
146
+ {
147
+ "command": "edit snippet.mjs",
148
+ "tool": "edit",
149
+ "target": "snippet.mjs",
150
+ "exitCode": 1,
151
+ "error": "snippet.mjs: old_string was not found; nothing changed"
152
+ }
153
+ ]
143
154
  },
144
155
  "after": {
145
156
  "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
@@ -322,10 +333,17 @@
322
333
  }
323
334
  ],
324
335
  "outcome": {
325
- "status": "completed",
326
- "exitCode": 0,
327
- "message": "finished after 3 tool call(s); changed snippet.mjs",
328
- "remainingObligations": []
336
+ "status": "incomplete",
337
+ "exitCode": 65,
338
+ "message": "Formal AI reported obligation_gap (no_artifact_in_clause) for the clause \"Preserve all other content.\"; nothing was verified as done by Formal AI; changed snippet.mjs",
339
+ "remainingObligations": [
340
+ "Formal AI did not confirm the requested change"
341
+ ],
342
+ "gapRecord": {
343
+ "schema": "obligation_gap",
344
+ "reason": "no_artifact_in_clause",
345
+ "clause": "Preserve all other content."
346
+ }
329
347
  },
330
348
  "after": {
331
349
  "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"after\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"