sortie-dogs 0.16.1 → 0.16.3

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.1-review-git-boundary-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.16.3-strategy-admission-recovery-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.1-review-git-boundary-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.16.3-strategy-admission-recovery-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;
@@ -332,7 +344,10 @@ export declare class OperatorRuntime {
332
344
  prepareMission(root: string, raw: unknown, dispatcher?: {
333
345
  sessionID: string;
334
346
  callID: string;
335
- }, supersededRunID?: string, terminalChildren?: readonly string[], replaceRequirements?: boolean, context?: Record<string, unknown>): Promise<OperatorState>;
347
+ }, supersededRunID?: string, terminalChildren?: readonly string[], replaceRequirements?: boolean, context?: Record<string, unknown>, terminalTaskCalls?: {
348
+ runID: string;
349
+ callIDs: readonly string[];
350
+ }): Promise<OperatorState>;
336
351
  /** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
337
352
  replanMission(root: string, runID: string, raw: unknown, dispatcher?: {
338
353
  sessionID: string;
@@ -368,6 +383,8 @@ export declare class OperatorRuntime {
368
383
  }>;
369
384
  private matchesWorkerTask;
370
385
  matchesRecordedWorkerTask(state: OperatorState, unitID: string, args: unknown): boolean;
386
+ /** Historical comparison only: cancellation revoked the admission generation. */
387
+ matchesCancelledWorkerTask(state: OperatorState, unitID: string, args: unknown): boolean;
371
388
  admitOperator(root: string, callID: string, args: unknown): Promise<OperatorTask>;
372
389
  private admitOperatorOnce;
373
390
  bindOperator(root: string, sessionID: string, prompt: string): Promise<void>;
@@ -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;
@@ -749,8 +766,8 @@ export class OperatorRuntime {
749
766
  }
750
767
  }
751
768
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
752
- prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = [], replaceRequirements = false, context) {
753
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren, replaceRequirements, context }));
769
+ prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = [], replaceRequirements = false, context, terminalTaskCalls) {
770
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren, replaceRequirements, context, terminalTaskCalls }));
754
771
  }
755
772
  /** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
756
773
  replanMission(root, runID, raw, dispatcher, context) {
@@ -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");
@@ -933,6 +946,17 @@ export class OperatorRuntime {
933
946
  if (mission?.supersededRunID !== undefined && !superseding)
934
947
  throw new Error("mission-superseded-run-mismatch");
935
948
  const terminalChildren = mission?.terminalChildren ?? [];
949
+ const unclaimedCalls = previous?.units.flatMap(unit => !unit.directExecution && unit.status === "cancelled" &&
950
+ unit.childSessionID === null && unit.callID !== null ? [unit.callID] : []) ?? [];
951
+ // A cancelled admission can precede its child's first prompt. Only the owning
952
+ // profile's exact native Task proof, not null or cancellation alone, closes it.
953
+ if (mission && previous?.phase === "cancelled" && unclaimedCalls.length &&
954
+ (mission?.terminalTaskCalls?.runID !== previous.runID ||
955
+ new Set(mission.terminalTaskCalls.callIDs).size !== unclaimedCalls.length ||
956
+ mission.terminalTaskCalls.callIDs.length !== unclaimedCalls.length ||
957
+ unclaimedCalls.some(call => !mission.terminalTaskCalls.callIDs.includes(call)))) {
958
+ throw new Error("mission-cancelled-run-task-not-terminal");
959
+ }
936
960
  const retainAcceptance = previous !== undefined && cancelledMissionRetainsAcceptance(previous);
937
961
  const predecessorChildren = previous?.units.flatMap(unit => !unit.directExecution && unit.childSessionID !== null
938
962
  ? [unit.childSessionID] : []) ?? [];
@@ -944,7 +968,7 @@ export class OperatorRuntime {
944
968
  unit.repairValidation !== null || (unit.status === "pending" && (unit.callID !== null || unit.childSessionID !== null ||
945
969
  unit.evidence.length > 0 || unit.resultClass !== null)) ||
946
970
  (unit.status === "succeeded" && (unit.childSessionID === null || unit.callID === null || unit.evidence.length === 0 || unit.resultClass !== "acceptance")) ||
947
- (unit.status === "cancelled" && (unit.childSessionID === null || unit.callID === null))))) {
971
+ (unit.status === "cancelled" && unit.callID === null)))) {
948
972
  throw new Error("mission-superseded-run-has-work: reconcile prior workers and evidence before changing acceptance");
949
973
  }
950
974
  if (plan.git_lifecycle !== undefined) {
@@ -1184,9 +1208,11 @@ export class OperatorRuntime {
1184
1208
  await handle.close();
1185
1209
  }
1186
1210
  }
1187
- if ((superseding || replanning || previous?.phase === "completed") && previous) {
1211
+ if ((superseding || replanning || previous?.phase === "completed" ||
1212
+ previous?.phase === "cancelled" && unclaimedCalls.length > 0 && mission?.terminalTaskCalls !== undefined) && previous) {
1188
1213
  // A crash before the state switch may leave a checkpoint while the old run later advances.
1189
1214
  // Sequence-qualified replan snapshots keep that history without blocking a subsequent correction.
1215
+ // Same-request recovery also retains the original unclaimed cancellation, not just its costs.
1190
1216
  const archive = replanning || previous.phase === "completed" ? this.replanArchivePath(previous)
1191
1217
  : `${this.file(root)}.${previous.runID}.archive`;
1192
1218
  try {
@@ -1357,6 +1383,28 @@ export class OperatorRuntime {
1357
1383
  : args;
1358
1384
  return unit !== undefined && this.matchesWorkerTask(state, unit, normalized);
1359
1385
  }
1386
+ /** Historical comparison only: cancellation revoked the admission generation. */
1387
+ matchesCancelledWorkerTask(state, unitID, args) {
1388
+ const unit = state.units.find(item => item.unit.id === unitID);
1389
+ if (state.phase !== "cancelled" || !unit || unit.status !== "cancelled" || unit.childSessionID !== null ||
1390
+ !record(args) || typeof args.prompt !== "string")
1391
+ return false;
1392
+ if (args.prompt === unit.task.prompt)
1393
+ return this.matchesRecordedWorkerTask(state, unitID, args);
1394
+ if (!args.prompt.startsWith(`${OPERATOR_TASK_REFERENCE} `))
1395
+ return false;
1396
+ let reference;
1397
+ try {
1398
+ reference = JSON.parse(args.prompt.slice(OPERATOR_TASK_REFERENCE.length + 1));
1399
+ }
1400
+ catch {
1401
+ return false;
1402
+ }
1403
+ if (!record(reference) || !Number.isSafeInteger(reference.g) || reference.g < 1 ||
1404
+ reference.g >= state.generation)
1405
+ return false;
1406
+ return this.matchesRecordedWorkerTask({ ...state, generation: reference.g }, unitID, args);
1407
+ }
1360
1408
  admitOperator(root, callID, args) {
1361
1409
  return this.serial(root, () => this.admitOperatorOnce(root, callID, args));
1362
1410
  }
@@ -1467,14 +1515,15 @@ export class OperatorRuntime {
1467
1515
  throw new Error("mission-direct-handoff-unavailable");
1468
1516
  }
1469
1517
  await this.verifyControls(unit);
1470
- const segment = { ...structuredClone(unit.directExecution), callID: unit.callID, handedOffAt: new Date().toISOString() };
1518
+ const segment = { ...structuredClone(unit.directExecution), validation: unitValidation(unit),
1519
+ callID: unit.callID, handedOffAt: new Date().toISOString() };
1471
1520
  // Keep the original controls immutable. Publish continuation context and the new
1472
1521
  // Task reference together in the atomic run save, so a crash has no half-updated pins.
1473
1522
  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.",
1523
+ 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
1524
  continuity: context.continuity,
1476
1525
  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) };
1526
+ validation: segment.validation, validation_cwd: unitValidationCwd(unit), details_ref: this.statePath(root) };
1478
1527
  unit.task = { ...unit.task, prompt: `${unit.task.prompt.replace(/\ndirect_handoff: .+$/mu, "")}\ndirect_handoff: ${JSON.stringify(continuation)}` };
1479
1528
  (unit.directHandoffs ??= []).push(segment);
1480
1529
  delete unit.directExecution;
@@ -1610,7 +1659,7 @@ export class OperatorRuntime {
1610
1659
  }
1611
1660
  async captureContractRepair(state, unit, error, command) {
1612
1661
  const normalized = normalizeCommand(command);
1613
- const commandIndex = unit.unit.validation.map(normalizeCommand).indexOf(normalized);
1662
+ const commandIndex = unitValidation(unit).map(normalizeCommand).indexOf(normalized);
1614
1663
  if (commandIndex < 0)
1615
1664
  throw new Error("operator-contract-repair-validation-command-missing");
1616
1665
  const paths = [...new Set(error.diagnostics.flatMap(item => item.repair_paths ?? []))].sort();
@@ -1630,7 +1679,7 @@ export class OperatorRuntime {
1630
1679
  return { code: "operator-git-change-outside-write-union", unit_id: unit.unit.id,
1631
1680
  diagnostics: structuredClone(error.diagnostics), diagnostics_truncated: error.diagnostics_truncated,
1632
1681
  repair_generation: 0, mode, repair_fingerprint: repairFingerprint, files,
1633
- remaining_validation: unit.unit.validation.slice(commandIndex).map(normalizeCommand) };
1682
+ remaining_validation: unitValidation(unit).slice(commandIndex).map(normalizeCommand) };
1634
1683
  }
1635
1684
  recordContractRepair(root, actor, error, command) {
1636
1685
  return this.serial(root, async () => {
@@ -2368,7 +2417,7 @@ export class OperatorRuntime {
2368
2417
  id: unit.unit.id, status: unit.status, child_session_id: unit.childSessionID, result_class: unit.resultClass,
2369
2418
  task_ref: unit.status === "pending" ? (unit.repairValidation === null ? this.workerTask(state, unit).prompt : this.repairWorkerTask(state, unit).prompt) : null,
2370
2419
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
2371
- scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
2420
+ scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unitValidation(unit),
2372
2421
  acceptance_indices: unit.unit.acceptance_indices,
2373
2422
  normal_remediation_used: unit.normalRemediationUsed ?? false,
2374
2423
  ...(unit.terminalRescue ? { terminal_rescue: { attempt_id: unit.terminalRescue.attempt_id,
@@ -2398,7 +2447,7 @@ export class OperatorRuntime {
2398
2447
  unit_id: unit.unit.id, task_id: /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1],
2399
2448
  project_root: this.projectRoot, handoff_path: this.controlReference(unit.handoffPath), operation_manifest: this.controlReference(unit.manifestPath),
2400
2449
  objective: unit.unit.objective, read: unit.unit.read, write: unit.unit.write,
2401
- validation: unit.unit.validation, acceptance: state.acceptance,
2450
+ validation: unitValidation(unit), acceptance: state.acceptance,
2402
2451
  ...(unit.repairValidation === null ? {} : { validation_only: true }),
2403
2452
  })}\nThis is the durable assignment for this same Worker, including after compaction or reload. ` +
2404
2453
  "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
  }
@@ -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" };
@@ -1216,6 +1251,9 @@ export function createProfiledPlugin(profile, assetVersion) {
1216
1251
  return packet;
1217
1252
  }
1218
1253
  function missionDispatchPacket(mission, run) {
1254
+ const strategyRecovery = " If a method stalled or proposed avoidable extra work, Operator: compare the original goal, actual result and concrete unmet work before choosing to continue, change or stop that method. " +
1255
+ `For a concrete adjustment, use ${profile.toolPrefix}plan_units with proposed units, reason and handoff_context, then dispatch its returned same-Coordinator continuation. ` +
1256
+ "Preserve required checks through lawful registration/replanning and reuse current evidence. Otherwise continue normally; no extra approval or logging-only step.";
1219
1257
  const packet = { ...missionPacket(mission, run), project_root: input.directory,
1220
1258
  coordinator_dispatch: mission.dispatchOpen ? "active" : ["completed", "cancelled"].includes(mission.phase) ? "terminal" : "resumable" };
1221
1259
  if (mission.phase === "submitted" && mission.submission?.status === "blocked" &&
@@ -1249,7 +1287,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1249
1287
  if (mission.runID === run?.runID && run?.units.some(unit => unit.status === "running")) {
1250
1288
  if (mission.coordinator && !mission.dispatchOpen && !["completed", "cancelled"].includes(mission.phase))
1251
1289
  return { ...packet, task: missions.task(mission),
1252
- next_action: "Resume this existing Coordinator Task with its task_id to continue the retained unit. Do not dispatch a replacement or wait for background work that has already ended." };
1290
+ next_action: "Resume this existing Coordinator Task with its task_id to continue the retained unit. Do not dispatch a replacement or wait for background work that has already ended." + strategyRecovery };
1253
1291
  return { ...packet, next_action: "The current unit is active. Continue its owning session; do not dispatch a replacement Worker or Coordinator. Native background completion will return to the parent." };
1254
1292
  }
1255
1293
  if (mission.coordinator === null && mission.runID === run?.runID && !mission.dispatchOpen &&
@@ -1295,7 +1333,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1295
1333
  next_action: (run?.units.some(unit => unit.dispatchDenial) ? `${packet.next_action}\n` : "") +
1296
1334
  (mission.coordinator === null
1297
1335
  ? "Fast-lane needs correction or coordination: dispatch this exact Coordinator Task with the existing Worker changes, checks and concrete remaining work. Keep the same mission, requirements and cumulative budget; do not replace an active Worker."
1298
- : "The previous Coordinator Task is finished. Dispatch this exact Task to continue the same mission and Coordinator session; keep the original requirements and cumulative budget.") };
1336
+ : "The previous Coordinator Task is finished. Dispatch this exact Task to continue the same mission and Coordinator session; keep the original requirements and cumulative budget." + strategyRecovery) };
1299
1337
  }
1300
1338
  return packet;
1301
1339
  }
@@ -1524,6 +1562,58 @@ export function createProfiledPlugin(profile, assetVersion) {
1524
1562
  await operators.recordStoppedChild(root, run.runID, child);
1525
1563
  }
1526
1564
  }
1565
+ async function cancelledUnclaimedTaskCalls(root, run) {
1566
+ const units = run.units.filter(unit => !unit.directExecution && unit.status === "cancelled" &&
1567
+ unit.childSessionID === null && unit.callID !== null);
1568
+ if (!units.length)
1569
+ return undefined;
1570
+ const owner = run.operatorSessionID ?? root;
1571
+ const deny = () => new Error("mission-cancelled-run-task-not-terminal");
1572
+ // The bounded chat context may omit the old Task. Use the existing native
1573
+ // history reader, and never substitute a model's stop report for a tool terminal.
1574
+ const history = await reviewMessages(owner, undefined, true);
1575
+ for (const unit of units) {
1576
+ const parts = history.flatMap(message => record(message.info) && message.info.role === "assistant" &&
1577
+ message.info.sessionID === owner && Array.isArray(message.parts) ? message.parts : []).filter(part => record(part) && part.type === "tool" && part.tool === "task" && part.callID === unit.callID);
1578
+ const part = parts.length === 1 && record(parts[0]) ? parts[0] : undefined;
1579
+ const state = part && record(part.state) ? part.state : undefined;
1580
+ if (!part || part.sessionID !== undefined && part.sessionID !== owner || !state ||
1581
+ !["completed", "error"].includes(String(state.status)) ||
1582
+ record(state.metadata) && ["running", "pending", "unknown"].includes(String(state.metadata.status)) ||
1583
+ !record(state.time) || typeof state.time.end !== "number" || !Number.isFinite(state.time.end) ||
1584
+ !operators.matchesCancelledWorkerTask(run, unit.unit.id, state.input) || taskChildSessionID(state) !== undefined)
1585
+ throw deny();
1586
+ }
1587
+ // A parent Task terminal does not prove that an unclaimed child never started.
1588
+ // Reuse the owning service's active snapshot and child lineage before allowing
1589
+ // this formerly unrepresentable cancellation into prepareMission.
1590
+ const active = payload(await session("active", {}));
1591
+ if (!record(active) || !Object.values(active).every(value => record(value) &&
1592
+ ["running", "retry"].includes(String(value.type))) || owner !== root && Object.hasOwn(active, owner))
1593
+ throw deny();
1594
+ const children = payload(await session("children", { path: { id: owner }, query: { directory: input.directory } }));
1595
+ if (!Array.isArray(children) || children.some(child => !record(child) || typeof child.id !== "string") ||
1596
+ new Set(children.map(child => child.id)).size !== children.length)
1597
+ throw deny();
1598
+ for (const child of children) {
1599
+ const info = payload(await session("get", { path: { id: child.id }, query: { directory: input.directory } }));
1600
+ if (!record(info) || info.id !== child.id || info.parentID !== owner ||
1601
+ typeof info.agent !== "string" || !info.agent.trim())
1602
+ throw deny();
1603
+ const role = canonicalAgent(profile, info.agent);
1604
+ // Root Fast-lane Tasks must not claim unrelated profile or native agents.
1605
+ if (owner === root && role !== "dog-worker" && role !== "dog-luna-worker")
1606
+ continue;
1607
+ if (!role)
1608
+ throw deny();
1609
+ const descendants = payload(await session("children", { path: { id: child.id }, query: { directory: input.directory } }));
1610
+ if (Object.hasOwn(active, child.id) || !Array.isArray(descendants) || descendants.length ||
1611
+ !["succeeded", "failed", "interrupted"].includes(String(info.outcome)) &&
1612
+ !(info.outcome === undefined && run.stoppedChildren?.includes(child.id)))
1613
+ throw deny();
1614
+ }
1615
+ return { runID: run.runID, callIDs: units.map(unit => unit.callID) };
1616
+ }
1527
1617
  async function stop(root, reason, retireRoot = true) {
1528
1618
  const directActor = (await operators.read(root))?.units.find(unit => unit.status === "running" && unit.directExecution)?.directExecution?.actor;
1529
1619
  // Stopping owned execution does not write its artifacts. Its existing reservation
@@ -1920,7 +2010,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1920
2010
  const gapSummary = reviewGaps?.replace(/^EVIDENCE_GAPS\s*/u, "").replace(/\s+/gu, " ").slice(0, 500);
1921
2011
  const text = withMissionOperationOutcome(`✅ **DONE** \`${state.runID}\` — declared checks passed; Operator accepted the result.`, mission) + `\n\n` +
1922
2012
  `**${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("; ")}` +
2013
+ `**${t("確認結果", "Validation")}:** ${t("宣言検証合格", "Declared checks passed")} — ${[...new Set(state.units.flatMap(unitValidation))].join("; ")}` +
1924
2014
  (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
2015
  (mission?.deliveryObservation?.head ? `\nCommit: ${mission.deliveryObservation.head} (${mission.deliveryObservation.branch ?? "detached"}; clean=${mission.deliveryObservation.clean})` : "") +
1926
2016
  (reviewGaps ? `\n\n**${t("レビュー補足", "Review note")}:** ${gapSummary}\n\n**${t("次", "Next")}:** ${t("なし(補足だけを理由に再レビュー不要)", "None (no repeat review solely for the advisory)")}`
@@ -2008,8 +2098,8 @@ export function createProfiledPlugin(profile, assetVersion) {
2008
2098
  recovered.set(unit.unit.id, await control.recoverUnitEvidence(context.sessionID, { unitID: taskID,
2009
2099
  childSessionID: direct.actor, manifestPath: unit.manifestPath, manifestHash: unit.hashes[1], goalFingerprint,
2010
2100
  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,
2101
+ checks: unit.reviewerCorrection?.checks ?? direct.checks, validation: unitValidation(unit),
2102
+ directories: unitValidationCwd(unit),
2013
2103
  registeredAt: unit.reviewerCorrection?.validationRegisteredAt ?? direct.validationRegisteredAt } }));
2014
2104
  recoveredDirect.add(unit.unit.id);
2015
2105
  continue;
@@ -2284,9 +2374,18 @@ export function createProfiledPlugin(profile, assetVersion) {
2284
2374
  } };
2285
2375
  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
2376
  const startDirectUnit = `${profile.toolPrefix}start_direct_unit`, finishDirectUnit = `${profile.toolPrefix}finish_direct_unit`;
2377
+ async function syncDirectValidation(direct) {
2378
+ if (!direct.unit.validationRegistration)
2379
+ return;
2380
+ const last = unitValidation(direct.unit).at(-1);
2381
+ await control.registerDirectValidation(direct.root, direct.unit.directExecution.actor, direct.unit.unit.id, last, direct.unit.directExecution.validationRegisteredAt?.[last] ?? direct.unit.directExecution.startedAt);
2382
+ }
2287
2383
  async function activateDirect(actor) {
2288
2384
  const direct = await directExecution(actor);
2289
2385
  if (direct) {
2386
+ // Recover a crash between durable registration and its goal-ledger projection
2387
+ // before admitting another native check or selecting finish evidence.
2388
+ await syncDirectValidation(direct);
2290
2389
  await control.activateDirectUnit(direct.root, actor, direct.unit.task.prompt, direct.unit.handoffPath);
2291
2390
  }
2292
2391
  return direct;
@@ -2308,7 +2407,7 @@ export function createProfiledPlugin(profile, assetVersion) {
2308
2407
  throw new Error("mission-direct-binding-unavailable: retain the admitted unit and restore its Mission binding");
2309
2408
  await activateDirect(actor);
2310
2409
  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,
2410
+ executor_session_id: actor, validation: unitValidation(direct.unit),
2312
2411
  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
2412
  }
2314
2413
  async function assertMissionRunBound(root) {
@@ -2317,10 +2416,45 @@ export function createProfiledPlugin(profile, assetVersion) {
2317
2416
  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
2417
  await operators.verifyContinuityControls(run);
2319
2418
  }
2419
+ async function assertDirectPlanningQuiescent(actor, planningCallID) {
2420
+ await control.assertDirectUnitQuiescent(actor);
2421
+ // Cold hook state is not proof. Only the exact current planner may be pending;
2422
+ // a completed background launch or another Task remains an active/unproven writer.
2423
+ const history = payload(await session("messages", { path: { id: actor }, query: { directory: input.directory } }));
2424
+ if (!Array.isArray(history))
2425
+ throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
2426
+ const calls = new Map();
2427
+ for (const message of history) {
2428
+ if (!record(message) || !record(message.info) || message.info.sessionID !== actor || !Array.isArray(message.parts)) {
2429
+ throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
2430
+ }
2431
+ for (const part of message.parts) {
2432
+ if (!record(part) || typeof part.type !== "string")
2433
+ throw new Error("mission-replan-terminal-unreconciled:tool_terminal_records_unavailable");
2434
+ if (part.type !== "tool")
2435
+ continue;
2436
+ if (part.callID === planningCallID && planningCallID && part.tool === planUnits)
2437
+ continue;
2438
+ if (typeof part.callID !== "string")
2439
+ throw new Error("mission-replan-terminal-unreconciled:tool_identity_unavailable");
2440
+ const prior = calls.get(part.callID);
2441
+ if (prior && prior.tool !== part.tool)
2442
+ throw new Error("mission-replan-terminal-unreconciled:tool_identity_conflict");
2443
+ calls.set(part.callID, part);
2444
+ }
2445
+ }
2446
+ for (const part of calls.values()) {
2447
+ if (!record(part.state) || !["completed", "error"].includes(String(part.state.status)) ||
2448
+ record(part.state.metadata) && ["running", "pending", "unknown"].includes(String(part.state.metadata.status))) {
2449
+ throw new Error("mission-replan-terminal-unreconciled:tool_dispatch_active_or_unproven");
2450
+ }
2451
+ }
2452
+ }
2320
2453
  async function handoffDirect(root, actor, callID, notes) {
2321
2454
  const direct = await directExecution(actor);
2322
2455
  if (direct) {
2323
2456
  await control.assertDirectUnitQuiescent(actor);
2457
+ await syncDirectValidation(direct);
2324
2458
  const history = await reviewMessages(actor, direct.unit.directExecution.startedAt, true);
2325
2459
  const calls = new Map();
2326
2460
  for (const message of history)
@@ -2390,7 +2524,7 @@ export function createProfiledPlugin(profile, assetVersion) {
2390
2524
  }
2391
2525
  if (direct.unit.reviewerCorrection)
2392
2526
  return JSON.stringify({ status: "correction-validated",
2393
- execution: "same-native-task", validation: direct.unit.unit.validation,
2527
+ execution: "same-native-task", validation: unitValidation(direct.unit),
2394
2528
  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
2529
  const run = await operators.required(root);
2396
2530
  // Full protected path arrays can make V2 truncate this one-line JSON to zero lines,
@@ -2952,8 +3086,24 @@ export function createProfiledPlugin(profile, assetVersion) {
2952
3086
  // packet can already contain one even while its Reviewer is still pending.
2953
3087
  const { task: _dispatchTask, ...packet } = missionDispatchPacket(mission, previous);
2954
3088
  const reviewPending = mission.runID === previous?.runID && mission.review?.verdict === "pending";
2955
- const active = mission.dispatchOpen || reviewPending || mission.runID === previous?.runID &&
2956
- previous?.units.some(unit => unit.status === "running");
3089
+ const running = mission.runID === previous?.runID ? previous.units.filter(unit => unit.status === "running") : [];
3090
+ let retainedDirectOwnerEnded = false;
3091
+ if (!mission.dispatchOpen && !reviewPending && mission.coordinator && running.length &&
3092
+ running.every(unit => unit.directExecution?.actor === mission.coordinator && !unit.reviewerCorrection)) {
3093
+ // This only forwards a proposal to the SAME owner. Native session termination
3094
+ // does not settle its retained unit or authorize a new writer/contract.
3095
+ const native = payload(await session("get", { path: { id: mission.coordinator } }).catch(() => undefined));
3096
+ if (record(native) && native.id === mission.coordinator && native.parentID === root &&
3097
+ canonicalAgent(profile, native.agent) === "dog-operator" &&
3098
+ ["succeeded", "failed", "interrupted"].includes(String(native.outcome))) {
3099
+ try {
3100
+ await assertDirectPlanningQuiescent(mission.coordinator);
3101
+ retainedDirectOwnerEnded = true;
3102
+ }
3103
+ catch { /* A live/unproven native tool still owns this work; do not offer another Task. */ }
3104
+ }
3105
+ }
3106
+ const active = mission.dispatchOpen || reviewPending || running.length > 0 && !retainedDirectOwnerEnded;
2957
3107
  const continuation = !active ? missions.task(mission) : undefined;
2958
3108
  // Existing Coordinator resumes accept a concrete prompt. Do not use the opaque
2959
3109
  // initial reference here: claim() would expand it from unchanged Mission state
@@ -2975,25 +3125,23 @@ export function createProfiledPlugin(profile, assetVersion) {
2975
3125
  const same = previous?.planHash === createHash("sha256").update(JSON.stringify(plan)).digest("hex");
2976
3126
  const replanning = previous && !["completed", "cancelled"].includes(previous.phase) && !same;
2977
3127
  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");
3128
+ const nextRegistration = plan.units.find(unit => unit.id === activeDirect?.unit.id);
3129
+ const registrationChanged = activeDirect && (JSON.stringify(unitValidationCwd(activeDirect) ?? {}) !==
3130
+ JSON.stringify(nextRegistration?.validation_cwd ?? {}) ||
3131
+ JSON.stringify(unitValidation(activeDirect)) !== JSON.stringify(nextRegistration?.validation));
3132
+ const registrationPlanHash = previous && activeDirect && directValidationRegistrationPlanHash(previous, plan, activeDirect.unit.id);
2988
3133
  if (previous && activeDirect && (registrationChanged || replanning && registrationPlanHash === previous.planHash)) {
2989
3134
  if (!reason?.trim())
2990
3135
  throw new Error("mission-replan-reason-required: name the validation registration correction");
2991
3136
  if (execution !== undefined && !emptyImplementationExecution)
2992
3137
  throw new Error("mission-validation-registration-operation-change-forbidden");
3138
+ await assertDirectPlanningQuiescent(actor, planningCallID);
2993
3139
  const corrected = await operators.correctDirectValidationRegistration(root, actor, previous.runID, plan);
2994
3140
  const unit = corrected.units.find(unit => unit.status === "running" && unit.directExecution?.actor === actor);
3141
+ const last = unitValidation(unit).at(-1);
3142
+ await control.registerDirectValidation(root, actor, unit.unit.id, last, unit.directExecution.validationRegisteredAt?.[last] ?? unit.directExecution.startedAt);
2995
3143
  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,
3144
+ executor_session_id: actor, validation: unitValidation(unit), validation_cwd: unitValidationCwd(unit),
2997
3145
  registered_at: unit.directExecution.validationRegisteredAt,
2998
3146
  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
3147
  }
@@ -3019,38 +3167,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3019
3167
  throw new Error(`mission-replan-terminal-unreconciled:${"reason" in terminal ? terminal.reason : "unknown"}`);
3020
3168
  }
3021
3169
  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
- }
3170
+ await assertDirectPlanningQuiescent(actor, planningCallID);
3054
3171
  await activateDirect(actor);
3055
3172
  await control.finishDirectUnit(root, actor, []);
3056
3173
  previous = await operators.required(root);
@@ -3062,7 +3179,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3062
3179
  const cancelledPredecessor = previous?.phase === "cancelled" &&
3063
3180
  (mission.supersededRunID === previous.runID ||
3064
3181
  (mission.supersededRunID === undefined && ["explicit-cancellation", "agent-changed"].includes(previous.decision ?? "") &&
3065
- previous.units.some(unit => (previous.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null)));
3182
+ previous.units.some(unit => (previous?.decision === "agent-changed" || unit.status === "cancelled") &&
3183
+ (unit.childSessionID !== null || !unit.directExecution && unit.callID !== null))));
3066
3184
  let budget = await control.currentBudget(root);
3067
3185
  if (cancelledPredecessor && budget?.reserved_units) {
3068
3186
  await control.reconcileAbortedOperatorOrphan(root);
@@ -3073,6 +3191,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3073
3191
  // Reconcile it even when reservation recovery has nothing to do. Never resume it,
3074
3192
  // interrupt live work here, or manufacture its native terminal/validation records.
3075
3193
  await stopCancelledChildren(root, previous, true);
3194
+ previous = await operators.required(root);
3076
3195
  }
3077
3196
  if (budget && budget.remaining_units < plan.units.length && !same)
3078
3197
  throw new Error(`mission-budget-exhausted: plan needs ${plan.units.length} units; ${budget.remaining_units} remain. ` +
@@ -3110,8 +3229,9 @@ export function createProfiledPlugin(profile, assetVersion) {
3110
3229
  ...(previous ? { retained_validation: missionReviewValidation(previous, replanning || previous.phase === "completed"
3111
3230
  ? operators.replanArchivePath(previous) : cancelledPredecessor ? `${operators.statePath(root)}.${previous.runID}.archive` : operators.statePath(root)),
3112
3231
  prior_run_id: previous.runID } : {}) };
3232
+ const terminalTaskCalls = cancelledPredecessor && previous ? await cancelledUnclaimedTaskCalls(root, previous) : undefined;
3113
3233
  const state = replanning && previous ? await operators.replanMission(root, previous.runID, plan, dispatcher, context)
3114
- : await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren, mission.requirementsReplaced, context);
3234
+ : await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren, mission.requirementsReplaced, context, terminalTaskCalls);
3115
3235
  // prepareMission retains an identical prepared run after either registration stage
3116
3236
  // fails. Verify its pinned controls, then idempotently register the same declaration;
3117
3237
  // only the durable Mission binding below makes it eligible for admission.
@@ -3151,7 +3271,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3151
3271
  handoff_context: handoffContextSchema,
3152
3272
  reason: { type: "string", "x-sortie-optional": true },
3153
3273
  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) => {
3274
+ 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
3275
  const { root, mission } = await missionAuthority(context.sessionID);
3156
3276
  if (args.executor !== undefined && !["self", "worker"].includes(args.executor))
3157
3277
  throw new Error("mission-executor-invalid");
@@ -3360,8 +3480,8 @@ export function createProfiledPlugin(profile, assetVersion) {
3360
3480
  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
3481
  read: [...new Set([...(mission.reviewScope?.read ?? []), ...run.units.flatMap(unit => unit.unit.read)])],
3362
3482
  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);
3483
+ validation: run.units.flatMap(unitValidation),
3484
+ validation_cwd: Object.assign({}, ...run.units.map(unit => unitValidationCwd(unit) ?? {})) }], input.directory);
3365
3485
  // Inherit fixed declaration budgets and Git authority, never infer fresh capacity.
3366
3486
  const goalPath = /^goal_declaration_path: (.+)$/mu.exec(run.units[0].task.prompt)?.[1];
3367
3487
  if (!goalPath)
@@ -3369,7 +3489,7 @@ export function createProfiledPlugin(profile, assetVersion) {
3369
3489
  const declaration = JSON.parse(await readFile(goalPath, "utf8"));
3370
3490
  const retainedPlan = { ...plan, acceptance: run.acceptance, acceptance_proof: run.acceptanceProof,
3371
3491
  source_refs: run.sourceRefs, goal_declaration: declaration,
3372
- units: [{ ...plan.units[0], validation: run.units.flatMap(unit => unit.unit.validation) }] };
3492
+ units: [{ ...plan.units[0], validation: run.units.flatMap(unitValidation) }] };
3373
3493
  const correctedPlan = run.gitLifecycle ? { ...retainedPlan, git_lifecycle: {
3374
3494
  branch_create: { branch: run.gitLifecycle.branch, start_ref: run.gitLifecycle.startRef },
3375
3495
  commit: { message: run.gitLifecycle.commitMessage }, post_commit_validation: run.gitLifecycle.postCommitValidation,
@@ -3520,9 +3640,9 @@ export function createProfiledPlugin(profile, assetVersion) {
3520
3640
  }).catch(() => []) : [];
3521
3641
  const initialPrompt = initialMissionReviewPrompt(mission, completed);
3522
3642
  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) => ({
3643
+ const observedValidation = risk.length === 0 && !correction ? [] : await Promise.all(run.units.filter(unit => unitValidation(unit).length > 0).map(async (unit) => ({
3524
3644
  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),
3645
+ ...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
3646
  })));
3527
3647
  const task = risk.length === 0 && !correction && !selfRecheck && !independentRequested ? null : { subagent_type: profileAgent(profile, "dog-reviewer"),
3528
3648
  ...(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;
@@ -163,6 +163,18 @@ ${controls(profile, ["start_mission", "plan_units", "start_direct_unit", "finish
163
163
  You are Operator, the user-facing strategic authority. Preserve every current requirement, prohibition,
164
164
  quality threshold and explicit model/budget choice. Follow AGENTS.md and use the user's language.
165
165
 
166
+ Own strategy, not just the relay of a stopped author's reply. At a method stall or proposed extra step,
167
+ compare the user's goal, actual result and concrete unmet work. Continue planned useful work without
168
+ intervention; suppress unnecessary expansion, and choose only the recovery needed for a real obstacle.
169
+ Separate a required outcome/check from an AI-selected method. Do not promote your own checker, evidence
170
+ format or procedure into an immutable user requirement. An AI-selected method already registered as
171
+ formal validation still needs lawful recipe correction or replanning; never silently omit it or invent PASS.
172
+ When a change of method is needed, give the existing author the concrete alternative, rationale and
173
+ retained obligations, not merely "resume" or the same failing instruction. Use plan_units with proposed
174
+ units, reason and handoff_context to obtain its concrete Coordinator continuation when available.
175
+ Do not restart a release, cancel live work or take over implementation just to exercise strategic authority.
176
+ Ordinary implementation decisions remain with the author; no per-Task approval or decision-record ritual.
177
+
166
178
  ${MISSION_DEPENDENCY_WORKFLOW}
167
179
 
168
180
  1. For an implementation or operation request, give at most three short lines, then call ${profile.toolPrefix}start_mission
@@ -195,7 +207,8 @@ ${MISSION_DEPENDENCY_WORKFLOW}
195
207
  an ordinary affirmative reply or a request to self-recover is enough. No hidden command or ID is
196
208
  needed from the user, and ordinary autonomous recovery does not require a new approval.
197
209
  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
210
+ direct admission that cannot bind, plan_units with executor: "worker" and units: "" in V2
211
+ (omit units on the legacy API; never units: []) yields that same
199
212
  unit and reservation without acquiring the contested writer. Explain the result and continue the
200
213
  returned Task when its write scope is available. A genuinely live or unproven other owner is not
201
214
  released: wait or separate work, and do not dispatch repeated conflicting attempts. Do not invent
@@ -545,14 +558,16 @@ Use the Operator's preferred_executor as a starting recommendation. Start useful
545
558
  do not investigate merely to confirm the recommendation. Adapt through actual work, honoring explicit user choices.
546
559
  Use plan_units with executor: "self" for direct work, or start_direct_unit for an already prepared unit.
547
560
  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
561
+ executor: "worker" and units: "" in V2 (omit units on the legacy API; never units: []) remain
562
+ management operations: yield only this quiescent owner, retain
549
563
  its checks and reservation, and wait for a real conflicting writer rather than evicting it. Continue
550
564
  the same Mission and actual remaining work; do not create another Mission to clear the conflict.
551
565
  The host binds your native check results to the candidate; finish_direct_unit closes that work without
552
566
  a Worker handoff. Independent Review and Operator final acceptance still follow. This is not limited to
553
567
  micro-edits. Delegate to the configured Worker when that reduces work/cost or provides useful separation.
554
568
  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
569
+ To hand unfinished direct work to a Worker, call plan_units with executor: "worker", units: "" in V2
570
+ (omit units on the legacy API; never units: []), and put
556
571
  the remaining action/useful observations in handoff_context. The host transfers this same unit after current
557
572
  native commands settle, retaining changes, checks and budget. Do not finish it as successful or replan just
558
573
  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.1",
3
+ "version": "0.16.3",
4
4
  "description": "Adaptive execution harness for OpenCode and Codex: goal retention, validation, review, and evidence-backed handoff",
5
5
  "keywords": [
6
6
  "opencode",