sortie-dogs 0.16.0 → 0.16.2

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.
@@ -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.16.0-purpose-aligned-v2";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.16.2-direct-validation-registration-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
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.16.0-purpose-aligned-v2";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.16.2-direct-validation-registration-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.14.6-codex-skill-v1";
@@ -273,6 +273,14 @@ export type GoalFlightEvent = (GoalEventBase & {
273
273
  readonly goal_id: string;
274
274
  readonly revision: number;
275
275
  readonly reason: "no-progress";
276
+ }) | (GoalEventBase & {
277
+ readonly kind: "goal.validation-registered";
278
+ readonly goal_id: string;
279
+ readonly reservation_id: string;
280
+ readonly unit_id: string;
281
+ readonly criterion_id: string;
282
+ readonly command: string;
283
+ readonly registered_at: string;
276
284
  }) | (GoalEventBase & {
277
285
  readonly kind: "goal.terminal";
278
286
  readonly goal_id: string;
@@ -309,6 +317,12 @@ export interface GoalFlightState {
309
317
  readonly delivery: GoalDeliveryMode | null;
310
318
  readonly budget: GoalBudget | null;
311
319
  readonly acceptance_contract: GoalAcceptanceContract | null;
320
+ /** Native registration metadata only; the acceptance contract and its fingerprint stay frozen. */
321
+ readonly validation_registrations?: Readonly<Record<string, {
322
+ readonly unit_id: string;
323
+ readonly command: string;
324
+ readonly registered_at: string;
325
+ }>>;
312
326
  readonly consumed_units: number;
313
327
  readonly consumed_time_ms: number | null;
314
328
  readonly consumed_cost_usd: number | null;
@@ -355,6 +369,7 @@ export declare function selectGoalDelivery(input: {
355
369
  readonly irreversible_or_major_scope: boolean;
356
370
  readonly explicit_mode?: GoalDeliveryMode;
357
371
  }): GoalDeliveryMode;
358
- export declare function validGoalEvidence(value: GoalEvidence, state: Pick<GoalFlightState, "goal_id" | "revision" | "scope_epoch" | "acceptance_fingerprint" | "acceptance_contract">): boolean;
372
+ export declare const goalValidationCommand: (state: Pick<GoalFlightState, "validation_registrations">, criterion: GoalAcceptanceCriterion) => string | undefined;
373
+ export declare function validGoalEvidence(value: GoalEvidence, state: Pick<GoalFlightState, "goal_id" | "revision" | "scope_epoch" | "acceptance_fingerprint" | "acceptance_contract" | "validation_registrations">): boolean;
359
374
  export declare function reduceGoalFlight(records: readonly GoalFlightEventRecord[]): GoalFlightState;
360
375
  export {};
@@ -51,6 +51,7 @@ function validAcceptanceContract(value) {
51
51
  function sameStrings(left, right) {
52
52
  return left.length === right.length && left.every((value, index) => value === right[index]);
53
53
  }
54
+ export const goalValidationCommand = (state, criterion) => state.validation_registrations?.[criterion.criterion_id]?.command ?? criterion.validation_command;
54
55
  export function validGoalEvidence(value, state) {
55
56
  if (typeof value !== "object" || value === null || typeof value.measurement !== "object" || value.measurement === null ||
56
57
  typeof value.identity !== "object" || value.identity === null || typeof value.execution !== "object" || value.execution === null ||
@@ -100,8 +101,11 @@ export function validGoalEvidence(value, state) {
100
101
  (criterion.candidate_binding === "current-protected"
101
102
  ? protectedBindingValid && HASH.test(value.identity.candidate) && (dockerWorkspace === undefined || dockerWorkspace.candidate === value.identity.candidate)
102
103
  : criterion.candidate === value.identity.candidate) &&
103
- (criterion.validation_command === undefined ||
104
- (value.execution.command.length === 1 && value.execution.command[0] === criterion.validation_command)) &&
104
+ (goalValidationCommand(state, criterion) === undefined ||
105
+ (value.execution.command.length === 1 && value.execution.command[0] === goalValidationCommand(state, criterion))) &&
106
+ (!state.validation_registrations?.[criterion.criterion_id] ||
107
+ value.execution.units.includes(state.validation_registrations[criterion.criterion_id].unit_id) &&
108
+ Date.parse(value.execution.started_at) >= Date.parse(state.validation_registrations[criterion.criterion_id].registered_at)) &&
105
109
  criterion.fixture === value.identity.fixture &&
106
110
  criterion.proof_scope === value.proof_scope && criterion.expected_outcome === value.execution.outcome);
107
111
  return text(value.evidence_id) && value.goal_id === state.goal_id && value.goal_revision === state.revision &&
@@ -193,7 +197,7 @@ export function reduceGoalFlight(records) {
193
197
  selected_agent: event.selected_agent, delivery: event.delivery, budget: event.budget,
194
198
  validation_budget: { ...state.validation_budget,
195
199
  limit: state.validation_budget.limit === null ? null : Math.max(state.validation_budget.limit, state.validation_budget.consumed, event.budget.max_units) },
196
- acceptance_contract: event.acceptance_contract,
200
+ acceptance_contract: event.acceptance_contract, validation_registrations: undefined,
197
201
  session_ids: addUnique(state.session_ids, event.session_id), phase: "active", stop_reason: null,
198
202
  receipt: null, tickets: [], ...(event.reset_no_progress === true
199
203
  ? { no_progress_results: 0, replan_required: false, replan_used: false }
@@ -204,6 +208,14 @@ export function reduceGoalFlight(records) {
204
208
  if (validationAdmissionLimit !== null)
205
209
  validationAdmissionLimit = Math.max(event.budget.max_units, state.validation_budget.consumed);
206
210
  }
211
+ else if (event.kind === "goal.validation-registered") {
212
+ requireState(state.phase === "active" && state.outstanding_reservations.some(item => item.reservation_id === event.reservation_id && item.unit_id === event.unit_id) &&
213
+ state.acceptance_contract?.criteria.some(item => item.criterion_id === event.criterion_id) === true &&
214
+ commandText(event.command) && instant(event.registered_at) && Date.parse(event.registered_at) <= Date.parse(event.at), "transition", "Validation registration requires the same active reserved unit and a current criterion.");
215
+ state = { ...state, validation_registrations: { ...state.validation_registrations,
216
+ [event.criterion_id]: { unit_id: event.unit_id, command: event.command, registered_at: event.registered_at } },
217
+ satisfied_criteria: state.satisfied_criteria.filter(id => id !== event.criterion_id) };
218
+ }
207
219
  else if (event.kind === "ticket.issued") {
208
220
  requireState(state.phase === "active" && event.revision === state.revision && event.scope_epoch === state.scope_epoch &&
209
221
  event.origin_user_message_id === state.latest_user_message_id && event.sequence === state.tickets.length + 1 &&
@@ -15,4 +15,4 @@ export interface ObservedGoalExecution {
15
15
  }
16
16
  /** One host execution can exercise several independently declared criteria.
17
17
  * Preserve each measurement identity instead of dropping all heterogeneous ones. */
18
- export declare function evidenceFromObservedExecution(execution: ObservedGoalExecution, state: Pick<GoalFlightState, "goal_id" | "revision" | "scope_epoch" | "acceptance_fingerprint" | "acceptance_contract">, unitID: string): GoalEvidence[];
18
+ export declare function evidenceFromObservedExecution(execution: ObservedGoalExecution, state: Pick<GoalFlightState, "goal_id" | "revision" | "scope_epoch" | "acceptance_fingerprint" | "acceptance_contract" | "validation_registrations">, unitID: string): GoalEvidence[];
@@ -1,4 +1,4 @@
1
- import { goalFingerprint } from "./goal-bound.js";
1
+ import { goalFingerprint, goalValidationCommand } from "./goal-bound.js";
2
2
  /** One host execution can exercise several independently declared criteria.
3
3
  * Preserve each measurement identity instead of dropping all heterogeneous ones. */
4
4
  export function evidenceFromObservedExecution(execution, state, unitID) {
@@ -6,7 +6,7 @@ export function evidenceFromObservedExecution(execution, state, unitID) {
6
6
  return [];
7
7
  // Canonical requested-full proof is coordinator-owned and must never be manufactured from a
8
8
  // child shell result. The caller derives owner from the observed host session lineage.
9
- const matching = state.acceptance_contract?.criteria.filter(criterion => criterion.validation_command === execution.command[0] && criterion.expected_outcome === "pass" &&
9
+ const matching = state.acceptance_contract?.criteria.filter(criterion => goalValidationCommand(state, criterion) === execution.command[0] && criterion.expected_outcome === "pass" &&
10
10
  (criterion.proof_scope !== "requested-full" || execution.owner === "coordinator")) ?? [];
11
11
  const groups = new Map();
12
12
  for (const criterion of matching) {
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, open, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
3
3
  import { join, resolve, win32 } from "node:path";
4
4
  import { normalizeExecutionScope } from "./path.js";
5
- import { parseOperatorPlan } from "./operator-runtime.js";
5
+ import { parseOperatorPlan, unitValidation } from "./operator-runtime.js";
6
6
  import { profileAgent } from "./runtime-profile.js";
7
7
  import { normalizeCommand, shellCommandBase, shellCommandWords } from "../plugin/gate.js";
8
8
  import { missionValidationCommand } from "./mission-validation-command.js";
@@ -859,7 +859,7 @@ export async function missionAcceptanceSummary(mission, run, operators, observe)
859
859
  // Settled proof already records these commands. Do not fetch the same native
860
860
  // history again merely to render acceptance; freshness is checked separately.
861
861
  const proofs = unit.evidence ?? [];
862
- if (unit.status === "succeeded" && unit.unit.validation.every(command => proofs.some(proof => proof.execution.exit_code === 0 && proof.execution.outcome === "pass" &&
862
+ if (unit.status === "succeeded" && unitValidation(unit).every(command => proofs.some(proof => proof.execution.exit_code === 0 && proof.execution.outcome === "pass" &&
863
863
  proof.execution.command.includes(command)))) {
864
864
  return { ...provenance, status: "recorded-formal-evidence",
865
865
  evidence_ids: proofs.map(proof => proof.evidence_id),
@@ -868,7 +868,7 @@ export async function missionAcceptanceSummary(mission, run, operators, observe)
868
868
  try {
869
869
  if (!observe || !unit.childSessionID)
870
870
  throw new Error("native-worker-history-unavailable");
871
- return { ...provenance, status: "available", observations: await observe(unit.unit.validation, unit.childSessionID, unit.directExecution ? Date.parse(unit.directExecution.startedAt) :
871
+ return { ...provenance, status: "available", observations: await observe(unitValidation(unit), unit.childSessionID, unit.directExecution ? Date.parse(unit.directExecution.startedAt) :
872
872
  unit.reviewerCorrection ? Date.parse(unit.reviewerCorrection.admittedAt ?? item.state.createdAt) : undefined) };
873
873
  }
874
874
  catch (error) {
@@ -942,7 +942,7 @@ export function missionPacket(mission, run, includeWorkQuantity = true) {
942
942
  units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
943
943
  child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
944
944
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
945
- scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
945
+ scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unitValidation(unit),
946
946
  normal_remediation_used: unit.normalRemediationUsed ?? false,
947
947
  ...(unit.failure ? { failure: { outcome: unit.failure.outcome, exit_code: unit.failure.exitCode } } : {}),
948
948
  ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
@@ -127,6 +127,13 @@ interface UnitState {
127
127
  validationRegisteredAt?: Readonly<Record<string, string>>;
128
128
  };
129
129
  evidence: readonly GoalEvidence[];
130
+ /** Effective check registration, separate from the immutable admitted contract. */
131
+ validationRegistration?: {
132
+ validation: readonly string[];
133
+ cwd: Readonly<Record<string, string>>;
134
+ registeredAt: Readonly<Record<string, string>>;
135
+ checks: import("../plugin/runtime-bridge.js").ReviewerCorrectionCheck[];
136
+ };
130
137
  /** Implementation in the existing controller session, without a native child Task. */
131
138
  directExecution?: {
132
139
  actor: string;
@@ -138,6 +145,7 @@ interface UnitState {
138
145
  previousResultClass: string | null;
139
146
  };
140
147
  checks: import("../plugin/runtime-bridge.js").ReviewerCorrectionCheck[];
148
+ validation?: readonly string[];
141
149
  validationCwd?: Readonly<Record<string, string>>;
142
150
  validationRegisteredAt?: Readonly<Record<string, string>>;
143
151
  };
@@ -283,6 +291,10 @@ export interface OperatorProgress {
283
291
  export declare function cancelledMissionRetainsAcceptance(state: OperatorState): boolean;
284
292
  export declare function operatorGitPathAuthorized(path: string, scopes: readonly string[], platform?: NodeJS.Platform): boolean;
285
293
  export declare function parseOperatorPlan(value: unknown, scopeFormat?: "repository" | "execution"): OperatorPlan;
294
+ export declare const unitValidation: (unit: OperatorState["units"][number]) => readonly string[];
295
+ export declare const unitValidationCwd: (unit: OperatorState["units"][number]) => Readonly<Record<string, string>> | undefined;
296
+ /** Strip only the owning unit's registration fields when comparing its frozen plan. */
297
+ export declare function directValidationRegistrationPlanHash(state: OperatorState, plan: OperatorPlan, unitID: string): string;
286
298
  /** Root-owned durable queue. It never executes code, changes acceptance, or accepts a candidate. */
287
299
  export declare class OperatorRuntime {
288
300
  readonly profile: RuntimeProfile;
@@ -260,6 +260,23 @@ export function parseOperatorPlan(value, scopeFormat = "repository") {
260
260
  expandGoalDeclaration(value.goal_declaration);
261
261
  return { ...value, acceptance: normalizeAcceptanceCriteria(value.acceptance) };
262
262
  }
263
+ export const unitValidation = (unit) => unit.validationRegistration?.validation ?? unit.unit.validation;
264
+ export const unitValidationCwd = (unit) => unit.validationRegistration?.cwd ?? unit.directExecution?.validationCwd ?? unit.unit.validation_cwd;
265
+ /** Strip only the owning unit's registration fields when comparing its frozen plan. */
266
+ export function directValidationRegistrationPlanHash(state, plan, unitID) {
267
+ const original = state.units.find(item => item.unit.id === unitID)?.unit;
268
+ const proposed = plan.units.find(item => item.id === unitID);
269
+ const criteria = plan.goal_declaration.criteria;
270
+ const goal = Array.isArray(criteria) ? { ...plan.goal_declaration, criteria: criteria.map(criterion => record(criterion) && criterion.criterion_id === unitID && criterion.validation_command === proposed?.validation.at(-1)
271
+ ? { ...criterion, validation_command: original?.validation.at(-1) } : criterion) } : plan.goal_declaration;
272
+ return hash(JSON.stringify({ ...plan, goal_declaration: goal, units: plan.units.map((next, index) => {
273
+ const prior = state.units[index]?.unit;
274
+ if (!prior || prior.id !== unitID || next.id !== unitID)
275
+ return next;
276
+ const { validation_cwd: _cwd, ...rest } = next;
277
+ return { ...rest, validation: prior.validation, ...(prior.validation_cwd ? { validation_cwd: prior.validation_cwd } : {}) };
278
+ }) }));
279
+ }
263
280
  /** Root-owned durable queue. It never executes code, changes acceptance, or accepts a candidate. */
264
281
  export class OperatorRuntime {
265
282
  profile;
@@ -766,26 +783,22 @@ export class OperatorRuntime {
766
783
  unit.directExecution.actor !== actor || unit.directExecution.finishedAt || unit.reviewerCorrection ||
767
784
  plan.units.length !== state.units.length)
768
785
  throw new Error("mission-validation-registration-owner-mismatch");
769
- const original = { ...plan, units: plan.units.map((next, i) => {
770
- const prior = state.units[i].unit;
771
- if (prior.validation_cwd)
772
- return { ...next, validation_cwd: prior.validation_cwd };
773
- const { validation_cwd: _cwd, ...rest } = next;
774
- return rest;
775
- }) };
776
- if (hash(JSON.stringify(original)) !== state.planHash || plan.units.some((next, i) => next.id !== unit.unit.id && JSON.stringify(next.validation_cwd) !== JSON.stringify(state.units[i].unit.validation_cwd))) {
786
+ if (directValidationRegistrationPlanHash(state, plan, unit.unit.id) !== state.planHash) {
777
787
  throw new Error("mission-validation-registration-contract-mismatch");
778
788
  }
779
- const cwd = plan.units.find(next => next.id === unit.unit.id).validation_cwd ?? {};
780
- const previousCwd = unit.directExecution.validationCwd ?? unit.unit.validation_cwd ?? {};
781
- if (JSON.stringify(cwd) !== JSON.stringify(previousCwd)) {
789
+ const next = plan.units.find(next => next.id === unit.unit.id);
790
+ const validation = next.validation.map(normalizeCommand), cwd = next.validation_cwd ?? {};
791
+ const previousValidation = unitValidation(unit), previousCwd = unitValidationCwd(unit) ?? {};
792
+ if (JSON.stringify(validation) !== JSON.stringify(previousValidation) || JSON.stringify(cwd) !== JSON.stringify(previousCwd)) {
782
793
  // Keep old checks as history, never attach a new recipe to their old successes.
783
794
  const registeredAt = { ...unit.directExecution.validationRegisteredAt }, now = new Date().toISOString();
784
- for (const command of unit.unit.validation.map(normalizeCommand)) {
785
- if (resolve(this.projectRoot, cwd[command] ?? ".") !== resolve(this.projectRoot, previousCwd[command] ?? ".")) {
795
+ for (const command of validation) {
796
+ if (previousValidation.filter(item => item === command).length !== validation.filter(item => item === command).length ||
797
+ resolve(this.projectRoot, cwd[command] ?? ".") !== resolve(this.projectRoot, previousCwd[command] ?? ".")) {
786
798
  registeredAt[command] = now;
787
799
  }
788
800
  }
801
+ unit.validationRegistration = { validation, cwd, registeredAt, checks: unit.validationRegistration?.checks ?? [] };
789
802
  unit.directExecution.validationCwd = cwd;
790
803
  unit.directExecution.validationRegisteredAt = registeredAt;
791
804
  await this.save(state);
@@ -837,9 +850,9 @@ export class OperatorRuntime {
837
850
  return this.serial(root, async () => {
838
851
  const state = await this.required(root);
839
852
  const unit = state.units.find(item => /^task_id: (.+)$/mu.exec(item.task.prompt)?.[1] === taskID);
840
- const execution = unit?.reviewerCorrection ?? unit?.directExecution;
853
+ const execution = unit?.reviewerCorrection ?? unit?.directExecution ?? unit?.validationRegistration;
841
854
  if (!unit || !execution || unit.callID !== check.dispatchCallID || unit.childSessionID !== check.childSessionID ||
842
- (unit.reviewerCorrection?.author ?? unit.directExecution?.actor) !== check.childSessionID)
855
+ (unit.reviewerCorrection?.author ?? unit.directExecution?.actor ?? unit.childSessionID) !== check.childSessionID)
843
856
  throw new Error("mission-review-correction-check-owner-mismatch");
844
857
  const checks = execution.checks ??= [];
845
858
  if (checks.some(item => item.callID === check.callID))
@@ -906,8 +919,8 @@ export class OperatorRuntime {
906
919
  plan.units.length !== 1 || plan.acceptance.length !== previous.acceptance.length ||
907
920
  previous.acceptance.some((text, i) => text !== plan.acceptance[i]) ||
908
921
  JSON.stringify(plan.acceptance_proof) !== JSON.stringify(previous.acceptanceProof) ||
909
- JSON.stringify(plan.units[0].validation) !== JSON.stringify(previous.units.flatMap(item => item.unit.validation)) ||
910
- JSON.stringify(plan.units[0].validation_cwd ?? {}) !== JSON.stringify(Object.assign({}, ...previous.units.map(item => item.directExecution?.validationCwd ?? item.unit.validation_cwd ?? {}))) ||
922
+ JSON.stringify(plan.units[0].validation) !== JSON.stringify(previous.units.flatMap(unitValidation)) ||
923
+ JSON.stringify(plan.units[0].validation_cwd ?? {}) !== JSON.stringify(Object.assign({}, ...previous.units.map(item => unitValidationCwd(item) ?? {}))) ||
911
924
  JSON.stringify(plan.source_refs) !== JSON.stringify(previous.sourceRefs) ||
912
925
  plan.units.some(unit => unit.write.some(path => !this.pathAuthorized(path, mission.reviewerCorrection.writeUnion))))) {
913
926
  throw new Error("mission-review-correction-contract-mismatch");
@@ -1467,14 +1480,15 @@ export class OperatorRuntime {
1467
1480
  throw new Error("mission-direct-handoff-unavailable");
1468
1481
  }
1469
1482
  await this.verifyControls(unit);
1470
- const segment = { ...structuredClone(unit.directExecution), callID: unit.callID, handedOffAt: new Date().toISOString() };
1483
+ const segment = { ...structuredClone(unit.directExecution), validation: unitValidation(unit),
1484
+ callID: unit.callID, handedOffAt: new Date().toISOString() };
1471
1485
  // Keep the original controls immutable. Publish continuation context and the new
1472
1486
  // Task reference together in the atomic run save, so a crash has no half-updated pins.
1473
1487
  const continuation = { actor, unit_id: unit.unit.id, at: segment.handedOffAt,
1474
- instruction: "Continue existing changes and remaining action. The authoritative handoff retains the exact original requirements. Reuse current source-bound checks; inspect missing facts only. Handoff itself is not validation.",
1488
+ instruction: "Continue existing changes and remaining action. The authoritative handoff retains the exact original requirements. This continuation's validation/validation_cwd is the effective registration, superseding only the frozen check recipe. Reuse current source-bound checks; inspect missing facts only. Handoff itself is not validation.",
1475
1489
  continuity: context.continuity,
1476
1490
  checks: segment.checks.map(({ callID, command, directory, exitCode, startedAt, endedAt }) => ({ callID, command, directory, exitCode, startedAt, endedAt })),
1477
- validation_cwd: segment.validationCwd ?? unit.unit.validation_cwd, details_ref: this.statePath(root) };
1491
+ validation: segment.validation, validation_cwd: unitValidationCwd(unit), details_ref: this.statePath(root) };
1478
1492
  unit.task = { ...unit.task, prompt: `${unit.task.prompt.replace(/\ndirect_handoff: .+$/mu, "")}\ndirect_handoff: ${JSON.stringify(continuation)}` };
1479
1493
  (unit.directHandoffs ??= []).push(segment);
1480
1494
  delete unit.directExecution;
@@ -1610,7 +1624,7 @@ export class OperatorRuntime {
1610
1624
  }
1611
1625
  async captureContractRepair(state, unit, error, command) {
1612
1626
  const normalized = normalizeCommand(command);
1613
- const commandIndex = unit.unit.validation.map(normalizeCommand).indexOf(normalized);
1627
+ const commandIndex = unitValidation(unit).map(normalizeCommand).indexOf(normalized);
1614
1628
  if (commandIndex < 0)
1615
1629
  throw new Error("operator-contract-repair-validation-command-missing");
1616
1630
  const paths = [...new Set(error.diagnostics.flatMap(item => item.repair_paths ?? []))].sort();
@@ -1630,7 +1644,7 @@ export class OperatorRuntime {
1630
1644
  return { code: "operator-git-change-outside-write-union", unit_id: unit.unit.id,
1631
1645
  diagnostics: structuredClone(error.diagnostics), diagnostics_truncated: error.diagnostics_truncated,
1632
1646
  repair_generation: 0, mode, repair_fingerprint: repairFingerprint, files,
1633
- remaining_validation: unit.unit.validation.slice(commandIndex).map(normalizeCommand) };
1647
+ remaining_validation: unitValidation(unit).slice(commandIndex).map(normalizeCommand) };
1634
1648
  }
1635
1649
  recordContractRepair(root, actor, error, command) {
1636
1650
  return this.serial(root, async () => {
@@ -2368,7 +2382,7 @@ export class OperatorRuntime {
2368
2382
  id: unit.unit.id, status: unit.status, child_session_id: unit.childSessionID, result_class: unit.resultClass,
2369
2383
  task_ref: unit.status === "pending" ? (unit.repairValidation === null ? this.workerTask(state, unit).prompt : this.repairWorkerTask(state, unit).prompt) : null,
2370
2384
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
2371
- scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
2385
+ scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unitValidation(unit),
2372
2386
  acceptance_indices: unit.unit.acceptance_indices,
2373
2387
  normal_remediation_used: unit.normalRemediationUsed ?? false,
2374
2388
  ...(unit.terminalRescue ? { terminal_rescue: { attempt_id: unit.terminalRescue.attempt_id,
@@ -2398,7 +2412,7 @@ export class OperatorRuntime {
2398
2412
  unit_id: unit.unit.id, task_id: /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1],
2399
2413
  project_root: this.projectRoot, handoff_path: this.controlReference(unit.handoffPath), operation_manifest: this.controlReference(unit.manifestPath),
2400
2414
  objective: unit.unit.objective, read: unit.unit.read, write: unit.unit.write,
2401
- validation: unit.unit.validation, acceptance: state.acceptance,
2415
+ validation: unitValidation(unit), acceptance: state.acceptance,
2402
2416
  ...(unit.repairValidation === null ? {} : { validation_only: true }),
2403
2417
  })}\nThis is the durable assignment for this same Worker, including after compaction or reload. ` +
2404
2418
  "A missing path in a summary does not mean the handoff is missing. Read the retained handoff/manifest using these project-relative paths without prepending project_root, and inspect the actual diff and outputs before claiming no edits or replaying work. " +
@@ -41,7 +41,7 @@ import { CancellableChildLifecycle, DEFAULT_CHILD_DEADLINE_MS } from "../core/ch
41
41
  import { FailureSwarmRuntime } from "../core/failure-swarm-runtime.js";
42
42
  import { EvidenceCapsuleStore } from "../core/evidence-capsule.js";
43
43
  import { RunFlightLedger, diagnosisContractHash } from "../core/run-flight-ledger.js";
44
- import { GOAL_BOUND_METADATA_KEY, goalFingerprint, selectGoalDelivery, validGoalEvidence } from "../core/goal-bound.js";
44
+ import { GOAL_BOUND_METADATA_KEY, goalFingerprint, goalValidationCommand, selectGoalDelivery, validGoalEvidence } from "../core/goal-bound.js";
45
45
  import { LUNA_FABRIC_MAX_ACTIVE } from "../core/luna-fabric-scheduler.js";
46
46
  import { terminalRescueModel } from "./terminal-rescue-binding.js";
47
47
  import { TerminalRescueRuntime } from "../core/terminal-rescue-runtime.js";
@@ -1381,7 +1381,8 @@ export const SortieDogsPlugin = async (input, options) => {
1381
1381
  const rawCommand = args !== undefined && typeof args.command === "string" ? normalizeCommand(args.command) : undefined;
1382
1382
  if (rawCommand === undefined || rawCommand.length === 0)
1383
1383
  return;
1384
- const correcting = !!direct || await input.runtimeBridge?.ownsReviewerCorrection?.(toolInput.sessionID) === true;
1384
+ const correcting = !!direct || await input.runtimeBridge?.ownsReviewerCorrection?.(toolInput.sessionID) === true ||
1385
+ await input.runtimeBridge?.ownsValidationRegistration?.(toolInput.sessionID) === true;
1385
1386
  const directory = dockerRequest?.cwd ?? resolve(input.directory, typeof args?.workdir === "string" ? args.workdir : ".");
1386
1387
  // A check is proof only in its registered cwd. Snapshot the same admitted source
1387
1388
  // recipe at actual native start; never retrofit it to older external-cwd history.
@@ -1413,7 +1414,7 @@ export const SortieDogsPlugin = async (input, options) => {
1413
1414
  let reusedEvidence;
1414
1415
  const unitID = authorization.taskID;
1415
1416
  const repairResume = operatorContractRepairResumes.get(root);
1416
- const criteria = goal.acceptance_contract?.criteria.filter((criterion) => (correction ? criterion.validation_command !== undefined && correction.commands.includes(criterion.validation_command) : criterion.validation_command === rawCommand) &&
1417
+ const criteria = goal.acceptance_contract?.criteria.filter((criterion) => (correction ? goalValidationCommand(goal, criterion) !== undefined && correction.commands.includes(goalValidationCommand(goal, criterion)) : goalValidationCommand(goal, criterion) === rawCommand) &&
1417
1418
  criterion.expected_outcome === "pass") ?? [];
1418
1419
  const denyValidation = (reason) => {
1419
1420
  goalValidationDefects.add(toolInput.sessionID);
@@ -1438,7 +1439,7 @@ export const SortieDogsPlugin = async (input, options) => {
1438
1439
  if (goal.goal_id !== null && criteria.length > 0) {
1439
1440
  const requestedFull = criteria.some((criterion) => criterion.proof_scope === "requested-full");
1440
1441
  const coordinatorOwned = requestedFull && unitID !== undefined &&
1441
- await input.runtimeBridge?.ownsCanonicalValidation?.(root, unitID, toolInput.sessionID, correction ? criteria[0].validation_command : rawCommand) === true;
1442
+ await input.runtimeBridge?.ownsCanonicalValidation?.(root, unitID, toolInput.sessionID, correction ? goalValidationCommand(goal, criteria[0]) : rawCommand) === true;
1442
1443
  if (requestedFull && !coordinatorOwned)
1443
1444
  throw denyValidation("owner-mismatch: coordinator-routing-unavailable");
1444
1445
  const profile = loaded?.validationProfile ?? DEFAULT_PLUGIN_OPTIONS.validationProfile;
@@ -2485,7 +2486,8 @@ export const SortieDogsPlugin = async (input, options) => {
2485
2486
  Date.parse(execution.startedAt) >= Date.parse(check.startedAt));
2486
2487
  return laterFailure ? [] : observedHostGoalEvidence({ ...check, owner: "coordinator", outcome: "pass",
2487
2488
  immutableRef: goalFingerprint({ direct_check: check }),
2488
- correction: { taskID: reservation.unitID, dispatchCallID: check.dispatchCallID, commands: check.command } }, state, reservation.unitID);
2489
+ correction: { taskID: reservation.unitID, dispatchCallID: check.dispatchCallID,
2490
+ commands: check.matchedOccurrences.map(item => check.command[item.member]) } }, state, reservation.unitID);
2489
2491
  });
2490
2492
  const hostEvidence = [...new Map((directEvidence ?? [...observedEvidence, ...inherited]).map(entry => [entry.evidence_id, entry])).values()];
2491
2493
  // A worker may return after a user scope epoch changed. Settle its spend/reservation, but only
@@ -8426,6 +8428,21 @@ export const SortieDogsPlugin = async (input, options) => {
8426
8428
  sessionTaskIDs.set(child, taskID);
8427
8429
  activateSession(child);
8428
8430
  },
8431
+ registerDirectValidation: async (root, actor, criterionID, command, registeredAt) => {
8432
+ const direct = await input.runtimeBridge?.directMissionExecution?.(actor);
8433
+ if (!direct || direct.root !== root || !await input.runtimeBridge?.ownsCanonicalValidation?.(root, direct.taskID, actor, command)) {
8434
+ throw new Error("mission-validation-registration-owner-mismatch");
8435
+ }
8436
+ const ledger = await goalLedger(root), state = (await ledger.readGoal()).state;
8437
+ const reservation = state.outstanding_reservations.find(item => item.unit_id === direct.taskID && item.session_id === root);
8438
+ if (!reservation || state.phase !== "active" || !state.goal_id)
8439
+ throw new Error("mission-validation-registration-reservation-missing");
8440
+ const prior = state.validation_registrations?.[criterionID];
8441
+ if (prior?.unit_id === direct.taskID && prior.command === command && prior.registered_at === registeredAt)
8442
+ return;
8443
+ await ledger.appendGoal({ kind: "goal.validation-registered", at: new Date().toISOString(), goal_id: state.goal_id,
8444
+ reservation_id: reservation.reservation_id, unit_id: direct.taskID, criterion_id: criterionID, command, registered_at: registeredAt });
8445
+ },
8429
8446
  activateDirectUnit: (root, actor, prompt, handoffPath) => serializeChatTransition(actor, async () => {
8430
8447
  const direct = await input.runtimeBridge?.directMissionExecution?.(actor);
8431
8448
  if (!direct || direct.root !== root)
@@ -1,7 +1,7 @@
1
- import type { OperatorState } from "../core/operator-runtime.js";
1
+ import { type OperatorState } from "../core/operator-runtime.js";
2
2
  import { type MissionEvidenceExcerpt, type MissionReviewScope, type OperatorMission } from "../core/operator-mission.js";
3
3
  import { type RuntimeProfile } from "../core/runtime-profile.js";
4
- import type { ReviewerCorrectionCheck } from "./runtime-bridge.js";
4
+ import type { DirectHandoffValidationCheck, ReviewerCorrectionCheck } from "./runtime-bridge.js";
5
5
  export declare const validationDirectoryMatches: (commands: readonly string[], actual: string | undefined, directory?: string, directories?: Readonly<Record<string, string>>) => boolean;
6
6
  /** Reviewer-facing evidence preserves execution/coverage; the host retains the full snapshot recipe. */
7
7
  export declare function missionReviewValidation(run: OperatorState, statePath: string): {
@@ -125,7 +125,7 @@ export declare function reviewerCorrectionValidationFresh(validation: readonly s
125
125
  nextOccurrence?: number;
126
126
  }>;
127
127
  /** Preserve a current ordered prefix while the next author executes the remaining recipe. */
128
- export declare function directHandoffValidationChecks(...args: Parameters<typeof reviewerCorrectionValidationFresh>): Promise<readonly ReviewerCorrectionCheck[]>;
128
+ export declare function directHandoffValidationChecks(...args: Parameters<typeof reviewerCorrectionValidationFresh>): Promise<readonly DirectHandoffValidationCheck[]>;
129
129
  /** Summary-only native reader: unavailable API/error is not a successfully observed empty history. */
130
130
  export declare function observedMissionValidationSummary(validation: readonly string[], childSessionID: string, read?: () => Promise<unknown>, notBefore?: number, directory?: string, directories?: Readonly<Record<string, string>>): Promise<Record<string, unknown>>;
131
131
  export declare function missionReviewBaseline(directory: string): Promise<string | undefined>;
@@ -5,6 +5,7 @@ import { lstat, readlink, readdir } from "node:fs/promises";
5
5
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { createInterface } from "node:readline";
8
+ import { unitValidation, unitValidationCwd } from "../core/operator-runtime.js";
8
9
  import { TOOL_ENVIRONMENT } from "../runtime-mission-assets.js";
9
10
  import { MISSION_REVIEW_REFERENCE, missionReviewScope } from "../core/operator-mission.js";
10
11
  import { canonicalAgent } from "../core/runtime-profile.js";
@@ -39,9 +40,9 @@ export const validationDirectoryMatches = (commands, actual, directory, director
39
40
  export function missionReviewValidation(run, statePath) {
40
41
  return run.units.map(unit => ({
41
42
  unit_id: unit.unit.id,
42
- command: unit.unit.validation,
43
- ...(unit.directExecution?.validationCwd ?? unit.unit.validation_cwd ? {
44
- validation_cwd: unit.directExecution?.validationCwd ?? unit.unit.validation_cwd,
43
+ command: unitValidation(unit),
44
+ ...(unitValidationCwd(unit) ? {
45
+ validation_cwd: unitValidationCwd(unit),
45
46
  } : {}),
46
47
  evidence: unit.evidence.map(({ protected_binding, ...evidence }) => ({
47
48
  ...evidence,
@@ -381,7 +382,11 @@ export async function directHandoffValidationChecks(...args) {
381
382
  for (let count = validation.length; count > 0; count--) {
382
383
  const result = await reviewerCorrectionValidationFresh(validation.slice(0, count), child, history, started, checks, root, directories, registeredAt);
383
384
  if (result.ready)
384
- return checks.filter(check => result.matched?.some(item => item.callID === check.callID));
385
+ return checks.flatMap(check => {
386
+ const matchedOccurrences = result.matched?.filter(item => item.callID === check.callID)
387
+ .map(({ member, occurrence }) => ({ member, occurrence })) ?? [];
388
+ return matchedOccurrences.length ? [{ ...check, matchedOccurrences }] : [];
389
+ });
385
390
  }
386
391
  return [];
387
392
  }
@@ -683,12 +688,66 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
683
688
  local.push(scoped || ".");
684
689
  }
685
690
  const toolInput = scope.read.some(path => normalizeManifestScope(path).path.startsWith(TOOL_ENVIRONMENT));
686
- const gitScopes = [...local, ...(toolInput ? [] : [`:(exclude)${TOOL_ENVIRONMENT}`])];
691
+ // A Location may inherit an ancestor repository while its actual outputs live in a
692
+ // nested repository/worktree. Resolve that boundary before asking the outer Git:
693
+ // ls-files can return the whole ignored repository (or fail internally) for a narrow output.
694
+ const rootLocal = [], nested = new Map();
695
+ for (const path of local) {
696
+ let current = resolve(directory), repository;
697
+ for (const part of path.split("/")) {
698
+ current = resolve(current, part);
699
+ const info = await lstat(current).catch((error) => {
700
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR")
701
+ throw error;
702
+ return undefined;
703
+ });
704
+ if (!info?.isDirectory())
705
+ break; // Missing files and symlink targets are not traversed.
706
+ if (current === resolve(directory))
707
+ continue;
708
+ const metadata = await lstat(join(current, ".git")).catch((error) => {
709
+ if (error.code !== "ENOENT")
710
+ throw error;
711
+ return undefined;
712
+ });
713
+ if (metadata)
714
+ repository = current; // Includes .git files and broken worktrees: Git must diagnose them.
715
+ }
716
+ if (repository)
717
+ nested.set(repository, [...(nested.get(repository) ?? []), relative(repository, resolve(directory, path)).replaceAll("\\", "/") || "."]);
718
+ else
719
+ rootLocal.push(path);
720
+ }
721
+ const gitScopes = [...rootLocal, ...(toolInput ? [] : [`:(exclude)${TOOL_ENVIRONMENT}`])];
687
722
  const git = async (args) => (await exec("git", args, { cwd: directory, maxBuffer: 8 * 1024 * 1024 })).stdout;
688
723
  let names = "", ignored = "", repository = true, scopedDiff = "";
689
724
  const untracked = new Set(), currentFiles = new Set();
725
+ const collected = new Set();
726
+ const collectRepository = async (absolute, path, pathspec) => {
727
+ const scopedGit = async (args) => (await exec("git", args, { cwd: absolute, maxBuffer: 8 * 1024 * 1024 })).stdout;
728
+ const tracked = await scopedGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspec]);
729
+ const prefix = (name) => join(path, name).replaceAll("\\", "/");
730
+ for (const name of tracked.split("\0").filter(Boolean)) {
731
+ collected.add(prefix(name));
732
+ currentFiles.add(prefix(name));
733
+ }
734
+ for (const name of (await scopedGit(["ls-files", "-z", "--others", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean))
735
+ untracked.add(prefix(name));
736
+ for (const name of (await scopedGit(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean)) {
737
+ collected.add(prefix(name));
738
+ untracked.add(prefix(name));
739
+ }
740
+ // The Mission baseline belongs to its root, not these different repositories.
741
+ // Without a baseline here, committed outputs still appear as current content.
742
+ const delta = await currentGitDiff(scopedGit, pathspec);
743
+ if (delta)
744
+ scopedDiff += `\n--- repository output: ${path} ---\n${delta}`;
745
+ };
746
+ for (const [absolute, paths] of nested) {
747
+ await collectRepository(absolute, relative(directory, absolute).replaceAll("\\", "/"), paths);
748
+ }
690
749
  try {
691
- if (local.length) {
750
+ if (rootLocal.length) {
692
751
  names = await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...gitScopes]);
693
752
  for (const path of (await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...gitScopes])).split("\0").filter(Boolean))
694
753
  untracked.add(path);
@@ -701,7 +760,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
701
760
  repository = false;
702
761
  // Mission storage can be outside Git while its declared outputs are repositories,
703
762
  // worktrees or plain files. Inventory those outputs in place, not the storage root.
704
- const collected = new Set(), walked = new Set();
763
+ const walked = new Set();
705
764
  const collect = async (path) => {
706
765
  if (walked.has(path) || excluded(path))
707
766
  return;
@@ -719,11 +778,9 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
719
778
  untracked.add(path);
720
779
  return;
721
780
  }
722
- const scopedGit = async (args) => (await exec("git", args, { cwd: absolute, maxBuffer: 8 * 1024 * 1024 })).stdout;
723
- const pathspec = ["."]; // Shared tooling is rooted at the Mission, not each output repository.
724
- let tracked;
781
+ // Shared tooling is rooted at the Mission, not each output repository.
725
782
  try {
726
- tracked = await scopedGit(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspec]);
783
+ await collectRepository(absolute, path, ["."]);
727
784
  }
728
785
  catch (error) {
729
786
  if (!notRepository(error))
@@ -733,27 +790,11 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
733
790
  }
734
791
  return;
735
792
  }
736
- const prefix = (name) => join(path, name).replaceAll("\\", "/");
737
- for (const name of tracked.split("\0").filter(Boolean)) {
738
- collected.add(prefix(name));
739
- currentFiles.add(prefix(name));
740
- }
741
- for (const name of (await scopedGit(["ls-files", "-z", "--others", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean))
742
- untracked.add(prefix(name));
743
- for (const name of (await scopedGit(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...pathspec])).split("\0").filter(Boolean)) {
744
- collected.add(prefix(name));
745
- untracked.add(prefix(name));
746
- }
747
- // The mission's baseline belongs to its root, not to these different repositories.
748
- // With no baseline here, committed outputs still appear as current file content.
749
- const delta = await currentGitDiff(scopedGit, pathspec);
750
- if (delta)
751
- scopedDiff += `\n--- repository output: ${path} ---\n${delta}`;
752
793
  };
753
- for (const path of local)
794
+ for (const path of rootLocal)
754
795
  await collect(path);
755
- names = [...collected].sort().join("\0");
756
796
  }
797
+ names = [...new Set([...names.split("\0").filter(Boolean), ...collected])].sort().join("\0");
757
798
  // Go's ignored in-project caches may be writable during validation but are not candidate
758
799
  // output. Keep every other ignored declared output visible and fingerprinted, including
759
800
  // directories of generated artifacts; focused references can still pin a cache file.
@@ -763,7 +804,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
763
804
  const unreadable = [];
764
805
  const outsideLinks = [];
765
806
  let diff = scopedDiff;
766
- if (repository && local.length && displayBaseline) {
807
+ if (repository && rootLocal.length && displayBaseline) {
767
808
  // HEAD-only diffs are empty once the author commits. Display the original mission delta
768
809
  // or the focused correction delta without changing the full fingerprint basis.
769
810
  const changed = (await git(["diff", "--name-only", "-z", "--no-ext-diff", displayBaseline, "--", ...gitScopes]))
@@ -774,7 +815,7 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
774
815
  const available = Math.max(0, 24_000 - Buffer.byteLength(selected + heading) -
775
816
  headers.reduce((size, title) => size + Buffer.byteLength(title), 0) - 500);
776
817
  const allowance = shown.length ? Math.min(3_000, Math.floor(available / shown.length)) : 0;
777
- diff = heading;
818
+ diff += heading;
778
819
  for (const [index, path] of shown.entries()) {
779
820
  const patch = await git(["diff", "--no-ext-diff", "--no-textconv", displayBaseline, "--", path]);
780
821
  const bytes = Buffer.from(patch);
@@ -788,8 +829,8 @@ export async function missionReviewSource(directory, run, evidence = [], baselin
788
829
  }
789
830
  omitted.push(...changed.slice(shown.length));
790
831
  }
791
- else if (repository && local.length) {
792
- diff = await currentGitDiff(git, gitScopes);
832
+ else if (repository && rootLocal.length) {
833
+ diff += await currentGitDiff(git, gitScopes);
793
834
  }
794
835
  const unchanged = diff.length === 0;
795
836
  let excerpt = selected + diff;
@@ -25,6 +25,7 @@ export declare class NativeBackgroundLifecycle {
25
25
  private readonly recoverResult?;
26
26
  private readonly operations;
27
27
  private readonly restored;
28
+ private readonly reconciliationErrors;
28
29
  constructor(context: OpenCodeV2Context, settle: (dispatch: NativeBackgroundDispatch, text: string) => Promise<string | void>, owner?: ((callID: string, restore?: ObjectValue) => ObjectValue | undefined) | undefined, recoverResult?: ((parent: string, callID: string, child: string, promptID?: string) => Promise<string | undefined>) | undefined);
29
30
  private serial;
30
31
  private read;
@@ -33,6 +34,7 @@ export declare class NativeBackgroundLifecycle {
33
34
  bind(parent: string, child: string, agent: string, prompt: string, promptID?: string): Promise<void>;
34
35
  launched(parent: string, callID: string, child: string | undefined, running: boolean): Promise<boolean>;
35
36
  awaiting(sessionID: string): Promise<boolean>;
37
+ reconciliationNotice(parent: string): string | undefined;
36
38
  reconcile(parent: string): Promise<void>;
37
39
  /** Add provenance beside its existing native notification, without editing history or waking the parent again. */
38
40
  project(parent: string, messages: readonly unknown[]): Promise<unknown[]>;
@@ -9,6 +9,7 @@ export class NativeBackgroundLifecycle {
9
9
  recoverResult;
10
10
  operations = new Map();
11
11
  restored = new Set();
12
+ reconciliationErrors = new Map();
12
13
  constructor(context, settle, owner, recoverResult) {
13
14
  this.context = context;
14
15
  this.settle = settle;
@@ -85,8 +86,22 @@ export class NativeBackgroundLifecycle {
85
86
  return object(info) && typeof info.parentID === "string" &&
86
87
  (await this.read(info.parentID)).some(item => !item.settled && item.child === sessionID);
87
88
  }
89
+ reconciliationNotice(parent) { return this.reconciliationErrors.get(parent); }
88
90
  async reconcile(parent) {
89
- await this.serial(parent, async () => this.reconcileInside(parent, await this.read(parent)));
91
+ try {
92
+ await this.serial(parent, async () => this.reconcileInside(parent, await this.read(parent)));
93
+ this.reconciliationErrors.delete(parent);
94
+ }
95
+ catch (error) {
96
+ // This is recovery of existing native work, not admission or acceptance. A failed
97
+ // inspection must retain its terminal/reservation for idempotent retry without
98
+ // making the next user prompt, model request or compaction fail before it starts.
99
+ const reason = error instanceof Error ? error.message : String(error);
100
+ console.warn("[sortie-dogs-v010] Background reconciliation unavailable; saved work retained", reason);
101
+ this.reconciliationErrors.set(parent, `SORTIE_BACKGROUND_RECONCILIATION_UNAVAILABLE\n${JSON.stringify(reason.slice(0, 2_000))}\n` +
102
+ "Native work/results and cumulative spend are retained; formal validation and acceptance are not proven by this recovery failure. " +
103
+ "Diagnose the failed inspection and reconcile this same invocation after repair. Do not relaunch or repeat an already completed external operation just to repair accounting.");
104
+ }
90
105
  }
91
106
  /** Add provenance beside its existing native notification, without editing history or waking the parent again. */
92
107
  async project(parent, messages) {
@@ -1,6 +1,6 @@
1
1
  import { V010_RUNTIME_ASSET_VERSION } from "../asset-version.js";
2
2
  import { MISSION_BEHAVIOR_REVIEW, MISSION_GIT_SCOPE, REVIEWER_VALIDATION_WORKFLOW } from "../runtime-mission-assets.js";
3
- import { cancelledMissionRetainsAcceptance, operatorGitPathAuthorized, OperatorContractError, OperatorRuntime } from "../core/operator-runtime.js";
3
+ import { cancelledMissionRetainsAcceptance, directValidationRegistrationPlanHash, unitValidation, unitValidationCwd, operatorGitPathAuthorized, OperatorContractError, OperatorRuntime } from "../core/operator-runtime.js";
4
4
  import { DEFAULT_OPERATOR_PROPOSAL_BUDGET, OPERATOR_APPROVAL_CONTRACT, OPERATOR_PROPOSAL_BUDGET_CAPS, OPERATOR_PROPOSAL_REVISION_CONTRACT, OperatorProposalBudgetError, OperatorProposalRuntime } from "../core/operator-proposal.js";
5
5
  import { CANONICAL_AGENT_ROLES, canonicalAgent, profileAgent, profileTool, V010_RUNTIME_PROFILE } from "../core/runtime-profile.js";
6
6
  import { SortieDogsPlugin as canonicalPlugin, realAttachmentParts } from "./index.js";
@@ -256,7 +256,7 @@ export function createProfiledPlugin(profile, assetVersion) {
256
256
  const method = typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages";
257
257
  return observedMissionValidationSummary(validation, child, typeof nativeSession?.[method] === "function"
258
258
  ? () => session(method, { path: { id: child }, query: { directory: input.directory,
259
- ...(notBefore === undefined ? {} : { notBefore: new Date(notBefore).toISOString() }) } }) : undefined, notBefore, notBefore === undefined ? undefined : input.directory, direct?.unit.directExecution?.validationCwd ?? direct?.unit.unit.validation_cwd);
259
+ ...(notBefore === undefined ? {} : { notBefore: new Date(notBefore).toISOString() }) } }) : undefined, notBefore, notBefore === undefined ? undefined : input.directory, direct ? unitValidationCwd(direct.unit) : undefined);
260
260
  }
261
261
  async function reviewMessages(id, notBefore, requireAvailable = false) {
262
262
  const method = typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages";
@@ -498,6 +498,34 @@ export function createProfiledPlugin(profile, assetVersion) {
498
498
  return undefined;
499
499
  return unit ? { root, mission, run, unit } : undefined;
500
500
  }
501
+ async function registeredValidation(id) {
502
+ const root = await rootFor(id);
503
+ if (!root)
504
+ return undefined;
505
+ const mission = await missions.read(root), run = await operators.read(root);
506
+ if (!mission || !run || mission.runID !== run.runID || run.phase !== "running" ||
507
+ ["completed", "cancelled"].includes(mission.phase))
508
+ return undefined;
509
+ const unit = run.units.find(item => item.status === "running" && item.childSessionID === id && item.validationRegistration);
510
+ return unit ? { root, mission, run, unit } : undefined;
511
+ }
512
+ async function remainingRegisteredValidation(root, unit) {
513
+ const validation = unitValidation(unit);
514
+ if (unit.directExecution || !unit.validationRegistration || !unit.callID)
515
+ return validation;
516
+ const taskID = /^task_id: (.+)$/mu.exec(unit.task.prompt)?.[1];
517
+ const inherited = taskID ? await runtimeBridge.missionHandoffChecks(root, taskID, unit.callID) : [];
518
+ let prefix = inherited.reduce((count, check) => count + check.matchedOccurrences.length, 0);
519
+ // A new author's actual retry supersedes that inherited occurrence, including
520
+ // a later failure. Never let an earlier handed-off PASS hide this native result.
521
+ for (const check of unit.validationRegistration.checks)
522
+ for (const command of check.command) {
523
+ const occurrence = validation.indexOf(command);
524
+ if (occurrence >= 0)
525
+ prefix = Math.min(prefix, occurrence);
526
+ }
527
+ return validation.slice(prefix);
528
+ }
501
529
  /**
502
530
  * Resolve the owning root of a proposal investigation child for prompt assembly only.
503
531
  *
@@ -644,14 +672,14 @@ export function createProfiledPlugin(profile, assetVersion) {
644
672
  const unit = state.units.find(candidate => /^task_id: (.+)$/m.exec(candidate.task.prompt)?.[1] === taskID);
645
673
  const active = unit?.status === "running" || (unit?.status === "failed" && unit.repairValidation !== null);
646
674
  return active && unit.childSessionID === child &&
647
- unit.unit.validation.some(candidate => normalizeCommand(candidate) === command);
675
+ unitValidation(unit).some(candidate => normalizeCommand(candidate) === command);
648
676
  },
649
677
  beforeValidationSnapshot: (root, child, command) => operators.beforePostCommitValidation(root, child, command),
650
678
  requiresValidationExecution: async (root, taskID, child, commands) => {
651
679
  const run = await operators.read(root);
652
680
  const unit = run?.units.find(item => item.status === "running" && item.childSessionID === child &&
653
681
  /^task_id: (.+)$/mu.exec(item.task.prompt)?.[1] === taskID);
654
- return !!unit && commands.some(command => unit.unit.validation.filter(item => normalizeCommand(item) === command).length > 1);
682
+ return !!unit && commands.some(command => unitValidation(unit).filter(item => normalizeCommand(item) === command).length > 1);
655
683
  },
656
684
  ownsMissionDispatch: async (root, callID, taskID) => {
657
685
  const mission = await missions.read(root), state = await operators.read(root);
@@ -661,6 +689,7 @@ export function createProfiledPlugin(profile, assetVersion) {
661
689
  /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1] === taskID);
662
690
  },
663
691
  ownsReviewerCorrection: async (child) => !!await reviewerCorrection(child),
692
+ ownsValidationRegistration: async (child) => !!await registeredValidation(child),
664
693
  directMissionExecution: async (actor) => {
665
694
  const direct = await directExecution(actor);
666
695
  const taskID = direct && /^task_id: (.+)$/mu.exec(direct.unit.task.prompt)?.[1];
@@ -683,21 +712,24 @@ export function createProfiledPlugin(profile, assetVersion) {
683
712
  return [];
684
713
  try {
685
714
  const history = await reviewMessages(prior.actor, prior.startedAt, true);
686
- return await directHandoffValidationChecks(unit.unit.validation, prior.actor, history, Date.parse(prior.startedAt), prior.checks, input.directory, prior.validationCwd ?? unit.unit.validation_cwd, prior.validationRegisteredAt);
715
+ return await directHandoffValidationChecks(unitValidation(unit), prior.actor, history, Date.parse(prior.startedAt), prior.checks, input.directory, prior.validationCwd ?? unit.unit.validation_cwd, prior.validationRegisteredAt);
687
716
  }
688
717
  catch {
689
718
  return [];
690
719
  }
691
720
  },
692
721
  reviewerCorrectionValidationMembers: async (child, command, directory) => {
693
- const correction = await reviewerCorrection(child) ?? await directExecution(child);
722
+ const correction = await reviewerCorrection(child) ?? await directExecution(child) ?? await registeredValidation(child);
694
723
  if (!correction)
695
724
  return undefined;
696
725
  const unit = correction.unit;
697
- const directories = unit.directExecution?.validationCwd ?? unit.unit.validation_cwd;
726
+ const directories = unitValidationCwd(unit);
698
727
  const admittedAt = unit.reviewerCorrection?.admittedAt ?? unit.directExecution?.startedAt ?? correction.run.createdAt;
699
- const progress = reviewerCorrectionValidation(unit.unit.validation, child, await reviewMessages(child, admittedAt), Date.parse(admittedAt), unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? [], input.directory, directories, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt);
700
- const members = canonicalObservedValidationMembers(command, unit.unit.validation, progress.nextOccurrence ?? 0);
728
+ const validation = await remainingRegisteredValidation(correction.root, unit);
729
+ const progress = reviewerCorrectionValidation(validation, child, await reviewMessages(child, admittedAt), Date.parse(admittedAt), unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? unit.validationRegistration?.checks ?? [], input.directory, directories, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt ?? unit.validationRegistration?.registeredAt);
730
+ // Progress is relative to the remaining suffix; native matching uses the
731
+ // full declaration so a retry of an inherited occurrence is captured too.
732
+ const members = canonicalObservedValidationMembers(command, unitValidation(unit), unitValidation(unit).length - validation.length + (progress.nextOccurrence ?? 0));
701
733
  return members && validationDirectoryMatches(members, directory, input.directory, directories) ? members : undefined;
702
734
  },
703
735
  recordReviewerCorrectionCheck: (root, taskID, check) => operators.recordReviewerCorrectionCheck(root, taskID, check),
@@ -706,18 +738,21 @@ export function createProfiledPlugin(profile, assetVersion) {
706
738
  // Recovery also compares failed units after their native Task has ended.
707
739
  const unit = run?.units.find(item => item.childSessionID === child &&
708
740
  /^task_id: (.+)$/mu.exec(item.task.prompt)?.[1] === taskID);
709
- return !unit || validationDirectoryMatches(commands, directory, input.directory, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd);
741
+ return !unit || validationDirectoryMatches(commands, directory, input.directory, unitValidationCwd(unit));
710
742
  },
711
743
  reviewerCorrectionValidation: async (root, callID, child, startedAt) => {
712
744
  const run = await operators.read(root);
713
745
  const unit = run?.units.find(item => item.callID === callID && item.childSessionID === child &&
714
- (item.reviewerCorrection?.author === child || item.directExecution?.actor === child));
746
+ (item.reviewerCorrection?.author === child || item.directExecution?.actor === child || item.validationRegistration));
715
747
  if (!unit)
716
748
  return undefined;
717
749
  // Compaction removes earlier tools from session.context, not from native history.
718
750
  // Reuse the admission-window reader; saved checks still require their live input binding.
719
751
  try {
720
- return await reviewerCorrectionValidationFresh(unit.unit.validation, child, await reviewMessages(child, new Date(startedAt).toISOString()), startedAt, unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? [], input.directory, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd, unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt);
752
+ const validation = await remainingRegisteredValidation(root, unit);
753
+ if (!validation.length && unitValidation(unit).length)
754
+ return { ready: true, matched: [] };
755
+ return await reviewerCorrectionValidationFresh(validation, child, await reviewMessages(child, new Date(startedAt).toISOString()), startedAt, unit.reviewerCorrection?.checks ?? unit.directExecution?.checks ?? unit.validationRegistration?.checks ?? [], input.directory, unitValidationCwd(unit), unit.reviewerCorrection?.validationRegisteredAt ?? unit.directExecution?.validationRegisteredAt ?? unit.validationRegistration?.registeredAt);
721
756
  }
722
757
  catch {
723
758
  return { ready: false, reason: "mission-review-correction-validation-history-unavailable" };
@@ -1920,7 +1955,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1920
1955
  const gapSummary = reviewGaps?.replace(/^EVIDENCE_GAPS\s*/u, "").replace(/\s+/gu, " ").slice(0, 500);
1921
1956
  const text = withMissionOperationOutcome(`✅ **DONE** \`${state.runID}\` — declared checks passed; Operator accepted the result.`, mission) + `\n\n` +
1922
1957
  `**${t("変更点", "Changes")}:** ${state.units.map(unit => unit.unit.title).join("; ")}\n\n` +
1923
- `**${t("確認結果", "Validation")}:** ${t("宣言検証合格", "Declared checks passed")} — ${[...new Set(state.units.flatMap(unit => unit.unit.validation))].join("; ")}` +
1958
+ `**${t("確認結果", "Validation")}:** ${t("宣言検証合格", "Declared checks passed")} — ${[...new Set(state.units.flatMap(unitValidation))].join("; ")}` +
1924
1959
  (mission?.review ? `\n${t("レビュー", "Review")}: ${mission.review.verdict === "self-rechecked" ? t("修正著者の自己再確認(独立PASSではない)", "Author self-rechecked (not independent PASS)") : mission.review.verdict === "evidence-gaps" ? t("証拠不足を残して受入れ(レビューPASSではない)", "Accepted with evidence gaps (not Review PASS)") : mission.review.verdict}.` : "") +
1925
1960
  (mission?.deliveryObservation?.head ? `\nCommit: ${mission.deliveryObservation.head} (${mission.deliveryObservation.branch ?? "detached"}; clean=${mission.deliveryObservation.clean})` : "") +
1926
1961
  (reviewGaps ? `\n\n**${t("レビュー補足", "Review note")}:** ${gapSummary}\n\n**${t("次", "Next")}:** ${t("なし(補足だけを理由に再レビュー不要)", "None (no repeat review solely for the advisory)")}`
@@ -2008,8 +2043,8 @@ export function createProfiledPlugin(profile, assetVersion) {
2008
2043
  recovered.set(unit.unit.id, await control.recoverUnitEvidence(context.sessionID, { unitID: taskID,
2009
2044
  childSessionID: direct.actor, manifestPath: unit.manifestPath, manifestHash: unit.hashes[1], goalFingerprint,
2010
2045
  direct: { callID: unit.callID, startedAt: direct.startedAt, finishedAt: direct.finishedAt,
2011
- checks: unit.reviewerCorrection?.checks ?? direct.checks, validation: unit.unit.validation,
2012
- directories: direct.validationCwd ?? unit.unit.validation_cwd,
2046
+ checks: unit.reviewerCorrection?.checks ?? direct.checks, validation: unitValidation(unit),
2047
+ directories: unitValidationCwd(unit),
2013
2048
  registeredAt: unit.reviewerCorrection?.validationRegisteredAt ?? direct.validationRegisteredAt } }));
2014
2049
  recoveredDirect.add(unit.unit.id);
2015
2050
  continue;
@@ -2284,9 +2319,18 @@ export function createProfiledPlugin(profile, assetVersion) {
2284
2319
  } };
2285
2320
  const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, repairReview = `${profile.toolPrefix}repair_review`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`, skipMissionConsultation = `${profile.toolPrefix}skip_mission_consultation`, extendMissionBudget = `${profile.toolPrefix}extend_mission_budget`;
2286
2321
  const startDirectUnit = `${profile.toolPrefix}start_direct_unit`, finishDirectUnit = `${profile.toolPrefix}finish_direct_unit`;
2322
+ async function syncDirectValidation(direct) {
2323
+ if (!direct.unit.validationRegistration)
2324
+ return;
2325
+ const last = unitValidation(direct.unit).at(-1);
2326
+ await control.registerDirectValidation(direct.root, direct.unit.directExecution.actor, direct.unit.unit.id, last, direct.unit.directExecution.validationRegisteredAt?.[last] ?? direct.unit.directExecution.startedAt);
2327
+ }
2287
2328
  async function activateDirect(actor) {
2288
2329
  const direct = await directExecution(actor);
2289
2330
  if (direct) {
2331
+ // Recover a crash between durable registration and its goal-ledger projection
2332
+ // before admitting another native check or selecting finish evidence.
2333
+ await syncDirectValidation(direct);
2290
2334
  await control.activateDirectUnit(direct.root, actor, direct.unit.task.prompt, direct.unit.handoffPath);
2291
2335
  }
2292
2336
  return direct;
@@ -2308,7 +2352,7 @@ export function createProfiledPlugin(profile, assetVersion) {
2308
2352
  throw new Error("mission-direct-binding-unavailable: retain the admitted unit and restore its Mission binding");
2309
2353
  await activateDirect(actor);
2310
2354
  return JSON.stringify({ status: "direct-unit-running", run_id: direct.run.runID, unit_id: direct.unit.unit.id,
2311
- executor_session_id: actor, validation: direct.unit.unit.validation,
2355
+ executor_session_id: actor, validation: unitValidation(direct.unit),
2312
2356
  next_action: `Implement/correct and run the declared checks here, in this same session. Call ${finishDirectUnit} after the requested delivery boundary. No Worker or handoff read is needed. Retain the original requirements; independent Review and Operator acceptance still follow.` });
2313
2357
  }
2314
2358
  async function assertMissionRunBound(root) {
@@ -2317,10 +2361,45 @@ export function createProfiledPlugin(profile, assetVersion) {
2317
2361
  throw new Error("mission-run-registration-incomplete: retry plan_units with the same plan to finish goal registration and Mission binding; no unit admitted");
2318
2362
  await operators.verifyContinuityControls(run);
2319
2363
  }
2364
+ async function assertDirectPlanningQuiescent(actor, planningCallID) {
2365
+ await control.assertDirectUnitQuiescent(actor);
2366
+ // Cold hook state is not proof. Only the exact current planner may be pending;
2367
+ // a completed background launch or another Task remains an active/unproven writer.
2368
+ const history = payload(await session("messages", { path: { id: actor }, query: { directory: input.directory } }));
2369
+ if (!Array.isArray(history))
2370
+ throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
2371
+ const calls = new Map();
2372
+ for (const message of history) {
2373
+ if (!record(message) || !record(message.info) || message.info.sessionID !== actor || !Array.isArray(message.parts)) {
2374
+ throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
2375
+ }
2376
+ for (const part of message.parts) {
2377
+ if (!record(part) || typeof part.type !== "string")
2378
+ throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
2379
+ if (part.type !== "tool")
2380
+ continue;
2381
+ if (part.callID === planningCallID && planningCallID && part.tool === planUnits)
2382
+ continue;
2383
+ if (typeof part.callID !== "string")
2384
+ throw new Error("mission-replan-terminal-unreconciled:tool_identity_unavailable");
2385
+ const prior = calls.get(part.callID);
2386
+ if (prior && prior.tool !== part.tool)
2387
+ throw new Error("mission-replan-terminal-unreconciled:tool_identity_conflict");
2388
+ calls.set(part.callID, part);
2389
+ }
2390
+ }
2391
+ for (const part of calls.values()) {
2392
+ if (!record(part.state) || !["completed", "error"].includes(String(part.state.status)) ||
2393
+ record(part.state.metadata) && ["running", "pending", "unknown"].includes(String(part.state.metadata.status))) {
2394
+ throw new Error("mission-replan-terminal-unreconciled:tool_dispatch_active_or_unproven");
2395
+ }
2396
+ }
2397
+ }
2320
2398
  async function handoffDirect(root, actor, callID, notes) {
2321
2399
  const direct = await directExecution(actor);
2322
2400
  if (direct) {
2323
2401
  await control.assertDirectUnitQuiescent(actor);
2402
+ await syncDirectValidation(direct);
2324
2403
  const history = await reviewMessages(actor, direct.unit.directExecution.startedAt, true);
2325
2404
  const calls = new Map();
2326
2405
  for (const message of history)
@@ -2390,7 +2469,7 @@ export function createProfiledPlugin(profile, assetVersion) {
2390
2469
  }
2391
2470
  if (direct.unit.reviewerCorrection)
2392
2471
  return JSON.stringify({ status: "correction-validated",
2393
- execution: "same-native-task", validation: direct.unit.unit.validation,
2472
+ execution: "same-native-task", validation: unitValidation(direct.unit),
2394
2473
  next_action: 'Compare the original requirements, retained findings and affected public behavior in this same context, then finish THIS native Task with SELF_RECHECKED and self_recheck: {"candidate":"current-validated","unresolved_findings":[],"residual_major":null}. No handoff reread, second Task or Operator round trip. This is author self-recheck; only the actual successful native terminal can bind it, never this tool result.' });
2395
2474
  const run = await operators.required(root);
2396
2475
  // Full protected path arrays can make V2 truncate this one-line JSON to zero lines,
@@ -2975,25 +3054,23 @@ export function createProfiledPlugin(profile, assetVersion) {
2975
3054
  const same = previous?.planHash === createHash("sha256").update(JSON.stringify(plan)).digest("hex");
2976
3055
  const replanning = previous && !["completed", "cancelled"].includes(previous.phase) && !same;
2977
3056
  const activeDirect = previous?.units.find(unit => unit.status === "running" && unit.directExecution?.actor === actor);
2978
- const registrationChanged = activeDirect && JSON.stringify(activeDirect.directExecution.validationCwd ?? activeDirect.unit.validation_cwd ?? {}) !==
2979
- JSON.stringify(plan.units.find(unit => unit.id === activeDirect.unit.id)?.validation_cwd ?? {});
2980
- const registrationPlanHash = previous && activeDirect && createHash("sha256").update(JSON.stringify({ ...plan,
2981
- units: plan.units.map((unit, index) => {
2982
- const prior = previous.units[index]?.unit;
2983
- if (prior?.validation_cwd)
2984
- return { ...unit, validation_cwd: prior.validation_cwd };
2985
- const { validation_cwd: _cwd, ...rest } = unit;
2986
- return rest;
2987
- }) })).digest("hex");
3057
+ const nextRegistration = plan.units.find(unit => unit.id === activeDirect?.unit.id);
3058
+ const registrationChanged = activeDirect && (JSON.stringify(unitValidationCwd(activeDirect) ?? {}) !==
3059
+ JSON.stringify(nextRegistration?.validation_cwd ?? {}) ||
3060
+ JSON.stringify(unitValidation(activeDirect)) !== JSON.stringify(nextRegistration?.validation));
3061
+ const registrationPlanHash = previous && activeDirect && directValidationRegistrationPlanHash(previous, plan, activeDirect.unit.id);
2988
3062
  if (previous && activeDirect && (registrationChanged || replanning && registrationPlanHash === previous.planHash)) {
2989
3063
  if (!reason?.trim())
2990
3064
  throw new Error("mission-replan-reason-required: name the validation registration correction");
2991
3065
  if (execution !== undefined && !emptyImplementationExecution)
2992
3066
  throw new Error("mission-validation-registration-operation-change-forbidden");
3067
+ await assertDirectPlanningQuiescent(actor, planningCallID);
2993
3068
  const corrected = await operators.correctDirectValidationRegistration(root, actor, previous.runID, plan);
2994
3069
  const unit = corrected.units.find(unit => unit.status === "running" && unit.directExecution?.actor === actor);
3070
+ const last = unitValidation(unit).at(-1);
3071
+ await control.registerDirectValidation(root, actor, unit.unit.id, last, unit.directExecution.validationRegisteredAt?.[last] ?? unit.directExecution.startedAt);
2995
3072
  return JSON.stringify({ status: "direct-unit-registration-corrected", run_id: corrected.runID, unit_id: unit.unit.id,
2996
- executor_session_id: actor, validation: unit.unit.validation, validation_cwd: unit.directExecution.validationCwd,
3073
+ executor_session_id: actor, validation: unitValidation(unit), validation_cwd: unitValidationCwd(unit),
2997
3074
  registered_at: unit.directExecution.validationRegisteredAt,
2998
3075
  next_action: `Run the affected declared checks in their registered cwd here, then ${finishDirectUnit}. Old observations remain historical; no new Task, terminal, reservation or benchmark attempt was created.` });
2999
3076
  }
@@ -3019,38 +3096,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3019
3096
  throw new Error(`mission-replan-terminal-unreconciled:${"reason" in terminal ? terminal.reason : "unknown"}`);
3020
3097
  }
3021
3098
  if (ownDirect) {
3022
- // Cold hook state alone cannot prove quiescence. Require actual native tool records;
3023
- // a completed background launch still owns a running process. Only this exact planner
3024
- // call may be pending while it ends its own admission.
3025
- const history = payload(await session("messages", { path: { id: actor }, query: { directory: input.directory } }));
3026
- if (!Array.isArray(history))
3027
- throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
3028
- const calls = new Map();
3029
- for (const message of history) {
3030
- if (!record(message) || !record(message.info) || message.info.sessionID !== actor || !Array.isArray(message.parts)) {
3031
- throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
3032
- }
3033
- for (const part of message.parts) {
3034
- if (!record(part) || typeof part.type !== "string")
3035
- throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
3036
- if (part.type !== "tool")
3037
- continue;
3038
- if (part.callID === planningCallID && planningCallID && part.tool === planUnits)
3039
- continue;
3040
- if (typeof part.callID !== "string")
3041
- throw new Error("mission-replan-terminal-unreconciled:tool_identity_unavailable");
3042
- const prior = calls.get(part.callID);
3043
- if (prior && prior.tool !== part.tool)
3044
- throw new Error("mission-replan-terminal-unreconciled:tool_identity_conflict");
3045
- calls.set(part.callID, part);
3046
- }
3047
- }
3048
- for (const part of calls.values()) {
3049
- if (!record(part.state) || !["completed", "error"].includes(String(part.state.status)) ||
3050
- (record(part.state.metadata) && ["running", "pending", "unknown"].includes(String(part.state.metadata.status)))) {
3051
- throw new Error("mission-replan-terminal-unreconciled:tool_dispatch_active_or_unproven");
3052
- }
3053
- }
3099
+ await assertDirectPlanningQuiescent(actor, planningCallID);
3054
3100
  await activateDirect(actor);
3055
3101
  await control.finishDirectUnit(root, actor, []);
3056
3102
  previous = await operators.required(root);
@@ -3151,7 +3197,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3151
3197
  handoff_context: handoffContextSchema,
3152
3198
  reason: { type: "string", "x-sortie-optional": true },
3153
3199
  purpose_decision: purposeDecisionSchema,
3154
- executor: { type: "string", enum: ["worker", "self"], description: "Use self to implement/correct and formally validate here. Use worker without units to yield the current direct unit, carrying remaining action in handoff_context; the same unit, edits, checks and budget continue. With units, worker/default retains configured Worker routing.", "x-sortie-optional": true } }, execute: async (args, context) => {
3200
+ executor: { type: "string", enum: ["worker", "self"], description: "Use self to implement/correct and formally validate here. Use executor=worker with units=\"\" in V2 (omit units in legacy; never units=[]) to yield the current direct unit, carrying remaining action in handoff_context; the same unit, edits, checks and budget continue. With units, worker/default retains configured Worker routing.", "x-sortie-optional": true } }, execute: async (args, context) => {
3155
3201
  const { root, mission } = await missionAuthority(context.sessionID);
3156
3202
  if (args.executor !== undefined && !["self", "worker"].includes(args.executor))
3157
3203
  throw new Error("mission-executor-invalid");
@@ -3360,8 +3406,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3360
3406
  objective: `${validationRecovery ? "The prior correction completed, but its inherited validation is now stale. Preserve completed implementation, reproduction, original findings and historical checks; refresh only affected checks in this selected author context. If the check recipe needs repair, use repair_review here as its actual author. Do not redo unchanged implementation/discovery or request a routine repeat Review. " : ""}Correct all retained concrete Major/Medium findings in this selected native conversation; preserve every original requirement, prior author and evidence binding. Exact findings are at correction_context.findings in the handoff; retained_findings_ref is optional lineage, not an instruction to reread/print the entire Mission or previous review prompt. Relevant search and focused diagnostics may widen normally; do not rediscover unchanged work. ${REVIEWER_VALIDATION_WORKFLOW} Retain the requested commit/clean boundary. THEN explicitly self-recheck the original requirements, ALL retained findings, correction and relevant impact in this Task. Correct known defects and revalidate affected checks before finishing. First line SELF_RECHECKED. Next line self_recheck: {"candidate":"current-validated","unresolved_findings":[],"residual_major":null}. The HOST binds current-validated to actual current source and fresh successful native checks after this exact prompt; never copy a pre-edit hash. Explain the actual comparison below. List unresolved concrete Major/Medium defects in unresolved_findings (no acceptance). Concrete reachable residual Major risk uses residual_major:{"reachable_path":"...","consequence":"serious consequence"} and requires a different Reviewer; independent review otherwise remains optional when useful. Tags/hashes/Medium/prose gaps alone never mandate it. This is author self-recheck, NOT independent PASS. Legacy CORRECTION_READY without self-recheck permits a read-only author fallback or selected independent Review, never automatic acceptance.`,
3361
3407
  read: [...new Set([...(mission.reviewScope?.read ?? []), ...run.units.flatMap(unit => unit.unit.read)])],
3362
3408
  write: [...new Set([...(mission.reviewScope?.write ?? []), ...run.units.flatMap(unit => unit.unit.write)])],
3363
- validation: run.units.flatMap(unit => unit.unit.validation),
3364
- validation_cwd: Object.assign({}, ...run.units.map(unit => unit.directExecution?.validationCwd ?? unit.unit.validation_cwd ?? {})) }], input.directory);
3409
+ validation: run.units.flatMap(unitValidation),
3410
+ validation_cwd: Object.assign({}, ...run.units.map(unit => unitValidationCwd(unit) ?? {})) }], input.directory);
3365
3411
  // Inherit fixed declaration budgets and Git authority, never infer fresh capacity.
3366
3412
  const goalPath = /^goal_declaration_path: (.+)$/mu.exec(run.units[0].task.prompt)?.[1];
3367
3413
  if (!goalPath)
@@ -3369,7 +3415,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3369
3415
  const declaration = JSON.parse(await readFile(goalPath, "utf8"));
3370
3416
  const retainedPlan = { ...plan, acceptance: run.acceptance, acceptance_proof: run.acceptanceProof,
3371
3417
  source_refs: run.sourceRefs, goal_declaration: declaration,
3372
- units: [{ ...plan.units[0], validation: run.units.flatMap(unit => unit.unit.validation) }] };
3418
+ units: [{ ...plan.units[0], validation: run.units.flatMap(unitValidation) }] };
3373
3419
  const correctedPlan = run.gitLifecycle ? { ...retainedPlan, git_lifecycle: {
3374
3420
  branch_create: { branch: run.gitLifecycle.branch, start_ref: run.gitLifecycle.startRef },
3375
3421
  commit: { message: run.gitLifecycle.commitMessage }, post_commit_validation: run.gitLifecycle.postCommitValidation,
@@ -3520,9 +3566,9 @@ export function createProfiledPlugin(profile, assetVersion) {
3520
3566
  }).catch(() => []) : [];
3521
3567
  const initialPrompt = initialMissionReviewPrompt(mission, completed);
3522
3568
  const phase = initialPrompt ? "verification" : "initial";
3523
- const observedValidation = risk.length === 0 && !correction ? [] : await Promise.all(run.units.filter(unit => unit.unit.validation.length > 0).map(async (unit) => ({
3569
+ const observedValidation = risk.length === 0 && !correction ? [] : await Promise.all(run.units.filter(unit => unitValidation(unit).length > 0).map(async (unit) => ({
3524
3570
  unit_id: unit.unit.id,
3525
- ...observedMissionValidation(unit.unit.validation, unit.childSessionID, unit.childSessionID ? await messages(unit.childSessionID).catch(() => []) : [], unit.reviewerCorrection ? Date.parse(unit.reviewerCorrection.admittedAt ?? run.createdAt) : undefined, unit.reviewerCorrection || unit.directExecution ? input.directory : undefined, unit.directExecution?.validationCwd ?? unit.unit.validation_cwd),
3571
+ ...observedMissionValidation(unitValidation(unit), unit.childSessionID, unit.childSessionID ? await messages(unit.childSessionID).catch(() => []) : [], unit.reviewerCorrection ? Date.parse(unit.reviewerCorrection.admittedAt ?? run.createdAt) : undefined, unit.reviewerCorrection || unit.directExecution ? input.directory : undefined, unitValidationCwd(unit)),
3526
3572
  })));
3527
3573
  const task = risk.length === 0 && !correction && !selfRecheck && !independentRequested ? null : { subagent_type: profileAgent(profile, "dog-reviewer"),
3528
3574
  ...(reviewerID ? { task_id: reviewerID } : {}),
@@ -47,6 +47,13 @@ export interface ReviewerCorrectionCheck {
47
47
  /** A command that actually generated/formatted declared outputs retains its stable input digest. */
48
48
  readonly generatedInputs?: string;
49
49
  }
50
+ /** Live projection of reusable occurrences; never rewrites the saved native check. */
51
+ export interface DirectHandoffValidationCheck extends ReviewerCorrectionCheck {
52
+ readonly matchedOccurrences: readonly {
53
+ readonly member: number;
54
+ readonly occurrence: number;
55
+ }[];
56
+ }
50
57
  /** Exact durable Mission Task claim, not authority supplied by a tool argument or Read. */
51
58
  export interface MissionImplementationAdmission {
52
59
  readonly runID: string;
@@ -82,6 +89,8 @@ export interface RuntimeBridge {
82
89
  /** Only an exact admitted correction Task is an implementation dispatch continuing the original Reviewer child. */
83
90
  ownsReviewerCorrectionDispatch?(rootSessionID: string, callID: string, taskID: string): Promise<boolean>;
84
91
  ownsReviewerCorrection?(childSessionID: string): Promise<boolean>;
92
+ /** Native check capture only; does not grant Reviewer correction or artifact authority. */
93
+ ownsValidationRegistration?(childSessionID: string): Promise<boolean>;
85
94
  /** Existing controller executing a declared unit itself; never a fictitious native Task. */
86
95
  directMissionExecution?(sessionID: string): Promise<{
87
96
  root: string;
@@ -99,7 +108,7 @@ export interface RuntimeBridge {
99
108
  endedAt: string;
100
109
  }[];
101
110
  } | undefined>;
102
- missionHandoffChecks?(rootSessionID: string, taskID: string, callID: string): Promise<readonly ReviewerCorrectionCheck[]>;
111
+ missionHandoffChecks?(rootSessionID: string, taskID: string, callID: string): Promise<readonly DirectHandoffValidationCheck[]>;
103
112
  reviewerCorrectionValidationMembers?(childSessionID: string, command: string, directory?: string): Promise<string[] | undefined>;
104
113
  missionValidationDirectoryMatches?(rootSessionID: string, taskID: string, childSessionID: string, commands: readonly string[], directory: string): Promise<boolean>;
105
114
  recordReviewerCorrectionCheck?(rootSessionID: string, taskID: string, check: ReviewerCorrectionCheck): Promise<void>;
@@ -156,6 +165,7 @@ export interface RuntimeBridge {
156
165
  currentTaskIDs: readonly string[];
157
166
  }): Promise<void>;
158
167
  registerGoalDeclaration(rootSessionID: string, prompt: string, missionRevision?: boolean): Promise<void>;
168
+ registerDirectValidation(rootSessionID: string, actor: string, criterionID: string, command: string, registeredAt: string): Promise<void>;
159
169
  relinkRegisteredGoal(rootSessionID: string, request: {
160
170
  prompt: string;
161
171
  expectedFingerprint: string;
package/dist/plugin/v2.js CHANGED
@@ -847,6 +847,9 @@ async function registerV2Hooks(context, hooks, background, liveState) {
847
847
  event.messages = await background.project(String(event.sessionID ?? ""), event.messages);
848
848
  const output = { system: [] };
849
849
  await hooks["experimental.chat.system.transform"]({ sessionID: String(event.sessionID ?? "") }, output);
850
+ const reconciliation = background.reconciliationNotice(String(event.sessionID ?? ""));
851
+ if (reconciliation)
852
+ (output.system ??= []).push(reconciliation);
850
853
  await reconcileCorrectionPermissions(String(event.sessionID));
851
854
  if (record(event.tools)) {
852
855
  const correcting = correctionAgent(event.agent) && !!await hooks.reviewerCorrectionScope?.(String(event.sessionID));
@@ -913,6 +916,9 @@ async function registerV2Hooks(context, hooks, background, liveState) {
913
916
  }
914
917
  const output = { context: [] };
915
918
  await hooks["experimental.session.compacting"]({ sessionID: String(event.sessionID ?? "") }, output);
919
+ const reconciliation = background.reconciliationNotice(String(event.sessionID ?? ""));
920
+ if (reconciliation)
921
+ (output.context ??= []).push(reconciliation);
916
922
  if (Array.isArray(event.system))
917
923
  event.system.push(...output.context.map(text => ({ type: "text", text })));
918
924
  });
@@ -195,7 +195,8 @@ ${MISSION_DEPENDENCY_WORKFLOW}
195
195
  an ordinary affirmative reply or a request to self-recover is enough. No hidden command or ID is
196
196
  needed from the user, and ordinary autonomous recovery does not require a new approval.
197
197
  Inspect operator_status to reconcile only native-proven ended work. If this session owns a quiescent
198
- direct admission that cannot bind, plan_units with executor: "worker" and no units yields that same
198
+ direct admission that cannot bind, plan_units with executor: "worker" and units: "" in V2
199
+ (omit units on the legacy API; never units: []) yields that same
199
200
  unit and reservation without acquiring the contested writer. Explain the result and continue the
200
201
  returned Task when its write scope is available. A genuinely live or unproven other owner is not
201
202
  released: wait or separate work, and do not dispatch repeated conflicting attempts. Do not invent
@@ -545,14 +546,16 @@ Use the Operator's preferred_executor as a starting recommendation. Start useful
545
546
  do not investigate merely to confirm the recommendation. Adapt through actual work, honoring explicit user choices.
546
547
  Use plan_units with executor: "self" for direct work, or start_direct_unit for an already prepared unit.
547
548
  If a direct admission cannot bind because of manifest-overlap, operator_status and plan_units with
548
- executor: "worker" and no units remain management operations: yield only this quiescent owner, retain
549
+ executor: "worker" and units: "" in V2 (omit units on the legacy API; never units: []) remain
550
+ management operations: yield only this quiescent owner, retain
549
551
  its checks and reservation, and wait for a real conflicting writer rather than evicting it. Continue
550
552
  the same Mission and actual remaining work; do not create another Mission to clear the conflict.
551
553
  The host binds your native check results to the candidate; finish_direct_unit closes that work without
552
554
  a Worker handoff. Independent Review and Operator final acceptance still follow. This is not limited to
553
555
  micro-edits. Delegate to the configured Worker when that reduces work/cost or provides useful separation.
554
556
  There is no proposal/approval/contract-repair round trip in this route.
555
- To hand unfinished direct work to a Worker, call plan_units with executor: "worker", omit units, and put
557
+ To hand unfinished direct work to a Worker, call plan_units with executor: "worker", units: "" in V2
558
+ (omit units on the legacy API; never units: []), and put
556
559
  the remaining action/useful observations in handoff_context. The host transfers this same unit after current
557
560
  native commands settle, retaining changes, checks and budget. Do not finish it as successful or replan just
558
561
  to change its author. Dispatch the returned Task once; Operator recommendation does not prevent this choice.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Adaptive execution harness for OpenCode and Codex: goal retention, validation, review, and evidence-backed handoff",
5
5
  "keywords": [
6
6
  "opencode",