sortie-dogs 0.13.16 → 0.13.18

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/README.md CHANGED
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.13.16](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.16)**
23
- · [Release notes](docs/release-v0.13.16.md)
22
+ **Current release: [v0.13.18](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.18)**
23
+ · [Release notes](docs/release-v0.13.18.md)
24
24
 
25
25
  [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
26
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
@@ -57,7 +57,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
57
57
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
58
58
 
59
59
  ```sh
60
- npm install --save-dev sortie-dogs@0.13.16
60
+ npm install --save-dev sortie-dogs@0.13.18
61
61
  npx --no-install sortie-dogs codex init .
62
62
  ```
63
63
 
@@ -86,7 +86,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
86
86
  Use **OpenCode V2**. In the target project:
87
87
 
88
88
  ```sh
89
- npm install --save-dev sortie-dogs@0.13.16
89
+ npm install --save-dev sortie-dogs@0.13.18
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -188,7 +188,7 @@ known expense. Official local evaluation and leaderboard acceptance are separate
188
188
  **8/23 (34.8%)**, versus v0.12.25's 7/23; all seven prior resolutions retained, no empty patches or
189
189
  official evaluation errors. [Fixed conditions, stopped-run accounting and costs](docs/benchmarks/swebench-lite-history.md#v0131-dev23-2026-09-30).
190
190
 
191
- All scores belong to their fixed candidates, **not v0.13.16**. SWE-bench remains an optional
191
+ All scores belong to their fixed candidates, **not v0.13.18**. SWE-bench remains an optional
192
192
  measurement, not a release gate. Different budgets, models and conditions are not controlled comparisons.
193
193
 
194
194
  ## Configuration
@@ -211,7 +211,7 @@ Install the desired package version and rerun **your host's** initializer:
211
211
 
212
212
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
213
213
  update it. [Global/update instructions](docs/configuration.md#global-availability).
214
- The current Mission asset marker is `0.13.16-practical-continuity-v1`; the unchanged Codex skill marker is
214
+ The current Mission asset marker is `0.13.18-direct-settlement-v1`; the unchanged Codex skill marker is
215
215
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
216
216
 
217
217
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -227,7 +227,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
227
227
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
228
228
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
229
229
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
230
- - **Releases:** [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
230
+ - **Releases:** [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
231
231
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
232
232
 
233
233
  ## Community
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.13.16-practical-continuity-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.13.18-direct-settlement-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.13.16-practical-continuity-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.13.18-direct-settlement-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -111,6 +111,15 @@ export interface GoalTerminalReceipt {
111
111
  interface GoalEventBase {
112
112
  readonly at: string;
113
113
  }
114
+ /** Host verification of an existing direct finish receipt, not a new execution. */
115
+ export interface GoalDirectFinishRecovery {
116
+ readonly call_id: string;
117
+ readonly actor_session_id: string;
118
+ readonly finish_call_id: string;
119
+ readonly started_at: string;
120
+ readonly finished_at: string;
121
+ readonly validation_call_ids: readonly string[];
122
+ }
114
123
  export interface GoalValidationRetryAuthorization {
115
124
  readonly authority: "operator-repair-validation-retry";
116
125
  readonly operator_run_id: string;
@@ -218,6 +227,7 @@ export type GoalFlightEvent = (GoalEventBase & {
218
227
  readonly unit_id: string;
219
228
  readonly previous_receipt_id: string;
220
229
  readonly evidence: readonly GoalEvidence[];
230
+ readonly direct_finish?: GoalDirectFinishRecovery;
221
231
  }) | (GoalEventBase & {
222
232
  readonly kind: "validation.admission";
223
233
  readonly goal_id: string;
@@ -133,7 +133,7 @@ export function reduceGoalFlight(records) {
133
133
  let replanRevision = null;
134
134
  let legacyResetRevision = false;
135
135
  let previous = null;
136
- const processDefects = new Map();
136
+ const recoverableSettlements = new Map();
137
137
  const unitCosts = new Map();
138
138
  const validationAdmissions = new Map();
139
139
  const validationSettlements = new Map();
@@ -160,7 +160,7 @@ export function reduceGoalFlight(records) {
160
160
  session_ids: [event.origin_session_id] };
161
161
  replanRevision = null;
162
162
  legacyResetRevision = false;
163
- processDefects.clear();
163
+ recoverableSettlements.clear();
164
164
  validationAdmissions.clear();
165
165
  validationSettlements.clear();
166
166
  reopenedValidationEvidence.clear();
@@ -255,8 +255,8 @@ export function reduceGoalFlight(records) {
255
255
  unit_ids: addUnique(state.unit_ids, event.unit_id),
256
256
  evidence_refs: event.evidence.reduce((refs, evidence) => addUnique(refs, evidence.evidence_id), state.evidence_refs),
257
257
  satisfied_criteria: event.evidence.reduce((ids, evidence) => evidence.measurement.criterion_ids.reduce(addUnique, ids), state.satisfied_criteria) };
258
- if (event.result_class === "process-defect" && event.evidence.length === 0 && event.disposition !== "cancelled") {
259
- processDefects.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch });
258
+ if (event.evidence.length === 0 && event.disposition !== "succeeded") {
259
+ recoverableSettlements.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch, settlement: event });
260
260
  }
261
261
  }
262
262
  else if (event.kind === "unit.usage-reconciled") {
@@ -271,11 +271,24 @@ export function reduceGoalFlight(records) {
271
271
  : costs.reduce((sum, item) => sum + item.cost, 0) };
272
272
  }
273
273
  else if (event.kind === "unit.evidence-reconciled") {
274
- const old = processDefects.get(event.previous_receipt_id);
274
+ const old = recoverableSettlements.get(event.previous_receipt_id);
275
+ const direct = event.direct_finish, settled = old?.settlement;
276
+ const directFinish = direct !== undefined && text(direct.call_id) && direct.call_id.startsWith("direct-") &&
277
+ text(direct.actor_session_id) && text(direct.finish_call_id) && instant(direct.started_at) && instant(direct.finished_at) &&
278
+ Date.parse(direct.finished_at) >= Date.parse(direct.started_at) && Array.isArray(direct.validation_call_ids) && direct.validation_call_ids.length > 0 &&
279
+ direct.validation_call_ids.every(text) && settled?.native_session_id === direct.actor_session_id &&
280
+ settled.native_started_at !== undefined && Math.abs(Date.parse(settled.native_started_at) - Date.parse(direct.started_at)) <= 1000 &&
281
+ Math.abs(Date.parse(settled.at) - Date.parse(direct.finished_at)) <= 1000 &&
282
+ settled.reservation_id === goalFingerprint({ goal_id: event.goal_id, unit_id: event.unit_id, call_id: direct.call_id }) &&
283
+ settled.receipt_id === goalFingerprint({ call_id: direct.call_id,
284
+ output: "Host-observed direct unit finished; controller session remains active." }) &&
285
+ (settled.disposition === "cancelled" && settled.result_class === "interrupted" ||
286
+ settled.disposition === "failed" && ["process-defect", "acceptance"].includes(settled.result_class ?? ""));
287
+ const processDefect = direct === undefined && settled?.disposition !== "cancelled" && settled?.result_class === "process-defect";
275
288
  requireState(state.phase === "active" && state.receipt === null && old?.unitID === event.unit_id &&
276
- old.revision === state.revision && old.epoch === state.scope_epoch && state.outstanding_reservations.length === 0, "transition", "Evidence reconciliation requires a current settled process defect.");
289
+ old.revision === state.revision && old.epoch === state.scope_epoch && state.outstanding_reservations.length === 0 && (processDefect || directFinish), "transition", "Evidence reconciliation requires a current settled process defect or verified direct finish defect.");
277
290
  requireState(event.evidence.length > 0 && event.evidence.every(entry => validGoalEvidence(entry, state) && entry.execution.units.includes(event.unit_id)), "evidence", "Reconciled evidence must retain the current goal and unit identity.");
278
- processDefects.delete(event.previous_receipt_id);
291
+ recoverableSettlements.delete(event.previous_receipt_id);
279
292
  state = { ...state, evidence_refs: event.evidence.reduce((refs, evidence) => addUnique(refs, evidence.evidence_id), state.evidence_refs),
280
293
  satisfied_criteria: event.evidence.reduce((ids, evidence) => evidence.measurement.criterion_ids.reduce(addUnique, ids), state.satisfied_criteria) };
281
294
  }
@@ -76,6 +76,13 @@ export interface MissionAttempt {
76
76
  code: string;
77
77
  };
78
78
  resultClass?: string;
79
+ /** Retained erroneous finish projection; the original goal receipt remains append-only. */
80
+ registrationRecovery?: {
81
+ at: string;
82
+ previousStatus: MissionAttempt["status"];
83
+ previousResultClass?: string;
84
+ previousFailure?: MissionAttempt["failure"];
85
+ };
79
86
  selectedModel?: string;
80
87
  }
81
88
  export interface MissionTerminalRescue {
@@ -224,7 +224,11 @@ export class OperatorMissionRuntime {
224
224
  continuity: { notes: state.continuity?.notes, sources, progress: state.progress,
225
225
  attempts: (state.attempts ?? []).map(({ runID, unitID, status, failure, resultClass, childSessionID }) => ({ run_id: runID, unit_id: unitID, status, failure, result_class: resultClass, session_id: childSessionID })),
226
226
  execution: state.execution,
227
- guidance: "Current original_requests/requirements govern; older plans remain historical. Continue from known decisions, environment, completed work and failures. Read snapshot JSON entries by message/call IDs only for a relevant gap. Recheck mutable facts when needed or conflicting; do not repeat unchanged setup/investigation merely because ownership changed. Historical results are not fresh formal validation or new authority." } };
227
+ source_authors: state.sourceAuthors ?? [],
228
+ corrections: (state.corrections ?? []).map(({ initialPrompt: _prompt, inlineReview: _inline, ...correction }) => correction),
229
+ retained_review: state.review ? { run_id: state.review.runID, verdict: state.review.verdict,
230
+ mode: state.review.mode, author: state.review.child, result: state.review.result, self_recheck: state.review.selfRecheck } : null,
231
+ guidance: "Current original_requests/requirements govern; older plans remain historical. Continue from known decisions, environment, completed work, retained findings and failures. A new plan or author does not resolve findings or turn self-recheck into independent approval. Read snapshot JSON entries by message/call IDs only for a relevant gap. Recheck mutable facts when needed or conflicting; do not repeat unchanged setup/investigation/checks merely because ownership changed. Successful evidence remains with its actual author/call and input bindings; historical results are not new authority." } };
228
232
  }
229
233
  async load(file) {
230
234
  try {
@@ -804,14 +808,14 @@ export function missionPacket(mission, run) {
804
808
  ? "Operator: use acceptance_summary to compare the submitted candidate with the verbatim original requests and actual evidence, inspect concrete gaps only, then complete_mission if satisfied. Do not routinely search archives or reread all source. Historical PASS is not current PASS. Report remaining evidence gaps; they are not a review PASS."
805
809
  : run?.phase === "awaiting-acceptance" && mission.corrections?.some(item => item.runID === run.runID && item.status === "ready") && !reviewAccepted
806
810
  ? mission.corrections.find(item => item.runID === run.runID)?.selfRecheck?.unresolvedFindings.length
807
- ? "Known Major/Medium findings remain after self-recheck. Call repair_review for the SAME original correction owner; do not dispatch another Reviewer or accept unresolved Medium. Retain original requirements and cumulative spend."
811
+ ? "Known Major/Medium findings remain after self-recheck. Correct them with repair_review (reuse the author by default, or select an available Reviewer), or plan_units for Worker/direct execution. Retain findings, original requirements and cumulative spend; unresolved Medium is not acceptance."
808
812
  : mission.corrections.find(item => item.runID === run.runID)?.selfRecheck?.residualMajor
809
813
  ? "A concrete reachable Major risk remains after native author self-recheck. Call review_mission for a DIFFERENT Reviewer of the correction, retained findings and relevant impact; dispatch the exact returned Task if not already active. No fresh Worker or unchanged validation is required."
810
- : "Correction ready; call review_mission for the SAME native author to explicitly self-recheck the retained findings, relevant impact and original requirements. Only a concrete reachable Major risk remaining after self-recheck requires a DIFFERENT Reviewer. Known Major/Medium defects still require correction. Do not restart unchanged checks or investigation; CORRECTION_READY alone is not acceptance."
814
+ : "Correction ready; reuse the native author for explicit self-recheck or select an independent Reviewer with review_mission when useful. Remaining authorized implementation may proceed with plan_units without acceptance. Known Major/Medium defects still require correction. Do not restart unchanged checks or investigation; CORRECTION_READY alone is not acceptance."
811
815
  : mission.kind === "operation" && operationStatus === "running"
812
816
  ? "The declared operation is already running. Inspect its native shell/progress; do not start another Worker or run. Wait for a terminal result, or report the existing run as blocked if its completion cannot be observed."
813
817
  : run?.phase === "awaiting-decision" && mission.corrections?.some(item => item.runID === run.runID && item.status === "failed")
814
- ? "Correction validation or native execution failed. Call repair_review for a new admission in the SAME original Reviewer's native session; fix its own regression, rerun inherited checks and retain the commit/clean boundary and cumulative spend. Do not use ordinary retry, Rescue or a fresh Worker replan. Failure is never acceptance."
818
+ ? "Correction validation or native execution failed. Reuse its context with repair_review, select another available Reviewer, or plan_units for Worker/direct correction. Retain findings, affected checks, requested delivery and cumulative spend. Failure is never acceptance."
815
819
  : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
816
820
  ? "Coordinator: inspect units[].dispatch_denial before changing the plan. Correct only its diagnosed cause; do not repeat an unchanged refused Task or replan for a host-state mismatch. Report an unresolved runtime mismatch with the loaded runtime identity; preserve requirements and cumulative spend."
817
821
  : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && !unit.normalRemediationUsed)
@@ -822,8 +826,8 @@ export function missionPacket(mission, run) {
822
826
  : run?.phase === "awaiting-acceptance" && !operationComplete
823
827
  ? `Requested operation is ${operationStatus}. Continue the actual operation or report its blocker; auxiliary checks and review disposition cannot complete it.`
824
828
  : run?.phase === "awaiting-acceptance" ? (reviewAccepted
825
- ? "Coordinator: review permits submission. Retain advisory review notes; do not repeat passed validation or review for evidence formatting. Operator performs final acceptance against the original requirements."
826
- : "Coordinator: correct known Major/Medium findings, then obtain explicit native author self-recheck (different Reviewer only for residual concrete Major risk), or initial independent review, then submit_mission. Operator compares all requirements with source/evidence before complete_mission.")
829
+ ? "Coordinator: review permits submission, not completion of remaining implementation. Continue authorized remaining work with plan_units, or submit when the original requirements are met. Retain review notes; do not repeat passed validation or review for evidence formatting. Operator final acceptance remains separate."
830
+ : "Coordinator: correct known Major/Medium findings, then obtain native author self-recheck or useful independent review (required for concrete residual Major risk). Remaining authorized implementation can proceed without submission/acceptance. Operator compares all requirements with source/evidence before complete_mission.")
827
831
  : "Coordinator: continue the next useful unit within original requirements. Return only a completion candidate, user-only decision, or scope/budget extension." };
828
832
  }
829
833
  /** Models forward a short capability, never recopy the host's evidence hashes and source packet. */
@@ -131,6 +131,11 @@ interface UnitState {
131
131
  actor: string;
132
132
  startedAt: string;
133
133
  finishedAt?: string;
134
+ registrationRecovery?: {
135
+ at: string;
136
+ previousStatus: "failed" | "cancelled";
137
+ previousResultClass: string | null;
138
+ };
134
139
  checks: import("../plugin/runtime-bridge.js").ReviewerCorrectionCheck[];
135
140
  validationCwd?: Readonly<Record<string, string>>;
136
141
  validationRegisteredAt?: Readonly<Record<string, string>>;
@@ -280,6 +285,8 @@ export declare class OperatorRuntime {
280
285
  private file;
281
286
  /** Exact persisted source for read-only status projections. */
282
287
  statePath(root: string): string;
288
+ /** Stable detail source for a settled run replaced by Mission replanning. */
289
+ replanArchivePath(state: OperatorState): string;
283
290
  /** Read-only lineage projection. Never imports archived evidence into current acceptance. */
284
291
  acceptanceHistory(state: OperatorState, missionRunIDs: readonly string[]): Promise<{
285
292
  status: "available" | "unavailable";
@@ -428,7 +435,7 @@ export declare class OperatorRuntime {
428
435
  private repairValidationRetryBinding;
429
436
  canRetryRepairValidation(state: OperatorState): boolean;
430
437
  resumeRepairValidation(root: string, runID: string, authorize: (source: OperatorRepairValidationRetrySource) => Promise<void>): Promise<OperatorState>;
431
- resume(root: string, runID: string, recovered: ReadonlyMap<string, readonly GoalEvidence[]>, unstarted?: ReadonlySet<string>): Promise<OperatorState>;
438
+ resume(root: string, runID: string, recovered: ReadonlyMap<string, readonly GoalEvidence[]>, unstarted?: ReadonlySet<string>, recoveredDirect?: ReadonlySet<string>): Promise<OperatorState>;
432
439
  requireAcceptanceRemediation(root: string, runID: string): Promise<OperatorState>;
433
440
  draftStatus(root: string): Promise<unknown | undefined>;
434
441
  completionGoalFingerprint(state: OperatorState): Promise<string>;
@@ -276,6 +276,10 @@ export class OperatorRuntime {
276
276
  }
277
277
  /** Exact persisted source for read-only status projections. */
278
278
  statePath(root) { return this.file(root); }
279
+ /** Stable detail source for a settled run replaced by Mission replanning. */
280
+ replanArchivePath(state) {
281
+ return `${this.file(state.rootSessionID)}.${state.runID}.${state.sequence}.archive`;
282
+ }
279
283
  /** Read-only lineage projection. Never imports archived evidence into current acceptance. */
280
284
  async acceptanceHistory(state, missionRunIDs) {
281
285
  const runs = [];
@@ -878,14 +882,12 @@ export class OperatorRuntime {
878
882
  if (!previous || previous.runID !== mission.replaceRunID || ["cancelled", "completed"].includes(previous.phase)) {
879
883
  throw new Error("mission-replan-run-mismatch");
880
884
  }
881
- if (previous.units.some(unit => unit.reviewerCorrection) && !mission.reviewerCorrection) {
882
- throw new Error("mission-review-correction-owner-continuation-required");
883
- }
884
- if (previous.units.some(unit => unit.status === "running") || (previous.gitLifecycle !== null && !mission.reviewerCorrection))
885
+ // Completed correction/Git metadata is history, not a live writer lease.
886
+ // The plugin reconciles actual native executions before replacing this run.
887
+ if (previous.units.some(unit => unit.status === "running"))
885
888
  throw new Error("mission-replan-worker-still-active");
886
- const failedContinuation = previous.phase === "awaiting-decision" && previous.units.every(unit => unit.status === "failed" && unit.reviewerCorrection?.author === mission.reviewerCorrection?.author);
889
+ const failedContinuation = previous.phase === "awaiting-decision" && previous.units.every(unit => unit.status === "failed" && unit.reviewerCorrection);
887
890
  if (mission.reviewerCorrection && ((!failedContinuation && (previous.phase !== "awaiting-acceptance" || previous.units.some(unit => unit.status !== "succeeded"))) ||
888
- previous.units.some(unit => unit.reviewerCorrection && unit.reviewerCorrection.author !== mission.reviewerCorrection.author) ||
889
891
  plan.units.length !== 1 || plan.acceptance.length !== previous.acceptance.length ||
890
892
  previous.acceptance.some((text, i) => text !== plan.acceptance[i]) ||
891
893
  JSON.stringify(plan.acceptance_proof) !== JSON.stringify(previous.acceptanceProof) ||
@@ -1166,10 +1168,11 @@ export class OperatorRuntime {
1166
1168
  await handle.close();
1167
1169
  }
1168
1170
  }
1169
- if ((superseding || replanning) && previous) {
1171
+ if ((superseding || replanning || previous?.phase === "completed") && previous) {
1170
1172
  // A crash before the state switch may leave a checkpoint while the old run later advances.
1171
1173
  // Sequence-qualified replan snapshots keep that history without blocking a subsequent correction.
1172
- const archive = `${this.file(root)}.${previous.runID}${replanning ? `.${previous.sequence}` : ""}.archive`;
1174
+ const archive = replanning || previous.phase === "completed" ? this.replanArchivePath(previous)
1175
+ : `${this.file(root)}.${previous.runID}.archive`;
1173
1176
  try {
1174
1177
  const handle = await open(archive, "wx", 0o600);
1175
1178
  created.push(archive);
@@ -1323,8 +1326,6 @@ export class OperatorRuntime {
1323
1326
  return args.task_id === expected.task_id && args.subagent_type === expected.subagent_type &&
1324
1327
  args.description === expected.description && args.prompt === expected.prompt;
1325
1328
  }
1326
- if (unit.reviewerCorrection && (args.model !== undefined || args.variant !== undefined))
1327
- return false;
1328
1329
  if (unit.reviewerCorrection ? args.task_id !== unit.reviewerCorrection.author : args.task_id !== undefined && args.task_id !== "")
1329
1330
  return false;
1330
1331
  if (args.subagent_type !== unit.task.subagent_type || args.description !== unit.task.description)
@@ -2090,16 +2091,17 @@ export class OperatorRuntime {
2090
2091
  return state;
2091
2092
  });
2092
2093
  }
2093
- resume(root, runID, recovered, unstarted = new Set()) {
2094
+ resume(root, runID, recovered, unstarted = new Set(), recoveredDirect = new Set()) {
2094
2095
  return this.serial(root, async () => {
2095
2096
  const state = await this.required(root);
2096
2097
  if (state.contractRepair !== null)
2097
2098
  throw new Error("operator-resume-contract-repair-required");
2098
- if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(unit => unit.status === "running" || unit.status === "cancelled")) {
2099
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(unit => unit.status === "running" ||
2100
+ unit.status === "cancelled" && !recoveredDirect.has(unit.unit.id))) {
2099
2101
  throw new Error("operator-resume-not-ready");
2100
2102
  }
2101
2103
  for (const unit of state.units) {
2102
- if (unit.status !== "failed")
2104
+ if (unit.status !== "failed" && unit.status !== "cancelled")
2103
2105
  continue;
2104
2106
  if (unstarted.has(unit.unit.id)) {
2105
2107
  if (unit.resultClass !== "process-defect" || unit.childSessionID !== null || state.decision !== "dispatch-admission-rejected")
@@ -2110,10 +2112,18 @@ export class OperatorRuntime {
2110
2112
  continue;
2111
2113
  }
2112
2114
  const evidence = recovered.get(unit.unit.id);
2115
+ const direct = recoveredDirect.has(unit.unit.id) && unit.directExecution?.actor === unit.childSessionID &&
2116
+ !!unit.directExecution.finishedAt && !!unit.callID && !!evidence?.length;
2117
+ await this.verifyControls(unit);
2113
2118
  const ids = new Set(evidence?.flatMap(item => item.measurement.criterion_ids) ?? []);
2114
- if (unit.resultClass !== "process-defect" || !evidence?.length || !unit.unit.acceptance_indices.every(index => state.acceptanceProof[index].some(id => ids.has(id)))) {
2119
+ if (unit.resultClass !== "process-defect" && !direct || !evidence?.length || !unit.unit.acceptance_indices.every(index => state.acceptanceProof[index].some(id => ids.has(id)))) {
2115
2120
  throw new Error("operator-resume-proof-incomplete");
2116
2121
  }
2122
+ if (unit.status === "cancelled" && !direct)
2123
+ throw new Error("operator-resume-proof-incomplete");
2124
+ if (direct)
2125
+ unit.directExecution.registrationRecovery = { at: new Date().toISOString(),
2126
+ previousStatus: unit.status, previousResultClass: unit.resultClass };
2117
2127
  unit.status = "succeeded";
2118
2128
  unit.resultClass = "acceptance";
2119
2129
  unit.evidence = evidence;
@@ -925,6 +925,14 @@ export class RunFlightLedger {
925
925
  stored.reservation_id === event.reservation_id && stored.native_session_id === event.native_session_id &&
926
926
  stored.cost_usd === event.cost_usd))
927
927
  return reduceGoalFlight(records);
928
+ if (event.kind === "unit.evidence-reconciled" && event.direct_finish !== undefined) {
929
+ const existing = records.find(record => record.event.kind === "unit.evidence-reconciled" &&
930
+ record.event.previous_receipt_id === event.previous_receipt_id)?.event;
931
+ if (existing?.kind === "unit.evidence-reconciled" && existing.goal_id === event.goal_id && existing.unit_id === event.unit_id &&
932
+ goalFingerprint(existing.evidence) === goalFingerprint(event.evidence) &&
933
+ goalFingerprint(existing.direct_finish) === goalFingerprint(event.direct_finish))
934
+ return reduceGoalFlight(records);
935
+ }
928
936
  if (event.kind === "goal.accepted") {
929
937
  const prior = records.find(({ event: stored }) => stored.kind === "goal.accepted" &&
930
938
  stored.origin_user_message_id === event.origin_user_message_id);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { CONTRACT_TEXT_LIMITS } from "./contract-limits.js";
2
3
  export const VALIDATION_PROFILES = ["fast", "balanced", "assurance"];
3
4
  /** A real failed process can be rerun after setup repair without changing source or command identity. */
4
5
  export function retryableValidationFailure(outcome, exitCode) {
@@ -38,6 +39,7 @@ export function validationEvidenceState(events) {
38
39
  return { reusable: [...reusable], blocked: [...blocked] };
39
40
  }
40
41
  const id = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096;
42
+ const commandText = (value) => typeof value === "string" && value.length > 0 && value.length <= CONTRACT_TEXT_LIMITS.command;
41
43
  const scopes = new Set(["static", "targeted", "related", "canonical", "full-suite", "full"]);
42
44
  const normalizedScope = (scope) => scope === "full" ? "canonical" : scope;
43
45
  export function validationOwner(scope) {
@@ -94,7 +96,7 @@ export function decideValidationBudget(request, state) {
94
96
  const environment = value.environment;
95
97
  const marginal = value.marginal_value;
96
98
  const valid = id(value.run_id) && id(value.operation_id) && id(value.source_snapshot) && id(value.candidate) &&
97
- value.command.length > 0 && value.command.every(id) && scopes.has(value.scope) &&
99
+ value.command.length > 0 && value.command.every(commandText) && scopes.has(value.scope) &&
98
100
  environment !== null && typeof environment === "object" && id(environment.platform) && id(environment.arch) &&
99
101
  id(environment.runtime) && (environment.toolchain === undefined || id(environment.toolchain)) &&
100
102
  (value.owner === "worker" || value.owner === "coordinator") &&
@@ -70,6 +70,10 @@ export declare function normalizeCommand(command: string): string;
70
70
  export declare function canonicalDeclaredValidationSequence(command: string, declared: ReadonlySet<string>): string | undefined;
71
71
  /** Preserve ordered members (including repeats) without splitting quoted text or substring matching. */
72
72
  export declare function canonicalDeclaredValidationMembers(command: string, declared: ReadonlySet<string> | readonly string[], occurrence?: number): string[] | undefined;
73
+ /** Observation only, never shell authority. A successful native && chain can prove
74
+ * its exact declared tail after diagnostics/empty-directory cleanup. The caller
75
+ * must retain the real exit and the snapshot captured BEFORE the whole command. */
76
+ export declare function canonicalObservedValidationMembers(command: string, declared: ReadonlySet<string> | readonly string[], occurrence?: number): string[] | undefined;
73
77
  /** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
74
78
  export declare function isKnownReadOnlyTool(tool: string, args: unknown, additionalReadOnlyTools?: ReadonlySet<string>): boolean;
75
79
  /** Canonical scopes let sibling worker bindings reject equal or ancestor ownership. */
@@ -997,6 +997,40 @@ export function canonicalDeclaredValidationMembers(command, declared, occurrence
997
997
  };
998
998
  return sequence(0);
999
999
  }
1000
+ /** Observation only, never shell authority. A successful native && chain can prove
1001
+ * its exact declared tail after diagnostics/empty-directory cleanup. The caller
1002
+ * must retain the real exit and the snapshot captured BEFORE the whole command. */
1003
+ export function canonicalObservedValidationMembers(command, declared, occurrence = 0) {
1004
+ const exact = canonicalDeclaredValidationMembers(command, declared, occurrence);
1005
+ if (exact)
1006
+ return exact;
1007
+ const syntax = scanShellSyntax(command, "posix");
1008
+ // Do not infer a tail through comments, expansions, redirection, subshells or
1009
+ // shell-state changes. Unsupported observations leave existing authority alone.
1010
+ if (syntax.unsafeExpansion || syntax.activeBrace || /[\\#$()<>`]/u.test(syntax.masked))
1011
+ return undefined;
1012
+ const segments = shellSegments(command, "posix").map(segment => segment.trim());
1013
+ if (segments.length < 2 || segments.some(segment => !segment) ||
1014
+ normalizeCommand(command) !== segments.map(normalizeCommand).join(" && "))
1015
+ return undefined;
1016
+ const identities = [...new Set(declared)];
1017
+ for (let start = 1; start < segments.length; start++) {
1018
+ const prefix = segments[start - 1];
1019
+ // Never discard an ordered declared occurrence to find a later matching tail.
1020
+ if (declaredCommandMatch(prefix, identities) !== undefined)
1021
+ return undefined;
1022
+ if (prefix.includes("`"))
1023
+ return undefined;
1024
+ const extracted = shellPaths(prefix, false);
1025
+ const cleanup = /^rmdir(?:\s|$)/u.test(prefix);
1026
+ if (extracted.ambiguous || extracted.remoteMutation || extracted.gitMutation || extracted.applies && !cleanup)
1027
+ return undefined;
1028
+ const members = canonicalDeclaredValidationMembers(segments.slice(start).join(" && "), declared, occurrence);
1029
+ if (members)
1030
+ return members;
1031
+ }
1032
+ return undefined;
1033
+ }
1000
1034
  /** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
1001
1035
  export function isKnownReadOnlyTool(tool, args, additionalReadOnlyTools = new Set()) {
1002
1036
  const name = tool.toLowerCase();