sortie-dogs 0.12.19 → 0.12.20

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,5 +3,5 @@
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.12.19-focused-behavior-review-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.20-mission-recovery-rescue-v1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
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.12.19-focused-behavior-review-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.20-mission-recovery-rescue-v1";
@@ -20,6 +20,55 @@ export interface MissionReviewScope {
20
20
  read: string[];
21
21
  write: string[];
22
22
  }
23
+ export interface MissionConsultation {
24
+ id: string;
25
+ role: "advisor" | "scout";
26
+ disposition: "skipped" | "dispatched";
27
+ reason: string;
28
+ at: string;
29
+ trigger?: string;
30
+ question?: string;
31
+ callID?: string;
32
+ childSessionID?: string;
33
+ observedModel?: string;
34
+ observedVariant?: string;
35
+ outcome?: "completed" | "failed" | "unknown";
36
+ result?: string;
37
+ }
38
+ export interface MissionAttempt {
39
+ attemptID: string;
40
+ runID: string;
41
+ unitID: string;
42
+ taskID: string;
43
+ predecessorAttemptID?: string | null;
44
+ /** Fingerprint of the settled scoped candidate against which a later Rescue is proposed. */
45
+ candidateID?: string;
46
+ kind: "implementation" | "normal_remediation" | "astra_rescue";
47
+ status: "pending" | "dispatched" | "succeeded" | "failed" | "cancelled" | "unconfirmed";
48
+ callID?: string;
49
+ childSessionID?: string;
50
+ nativeOutcome?: "completed" | "failed" | "unknown";
51
+ observedModel?: string;
52
+ observedVariant?: string;
53
+ failure?: {
54
+ category: "infrastructure" | "authorization" | "contract" | "cancellation" | "implementation" | "unknown";
55
+ code: string;
56
+ };
57
+ resultClass?: string;
58
+ selectedModel?: string;
59
+ }
60
+ export interface MissionTerminalRescue {
61
+ attemptID: string;
62
+ runID: string;
63
+ unitID: string;
64
+ selectedModel?: string;
65
+ selectedVariant?: string | null;
66
+ status: "prepared" | "dispatched" | "succeeded" | "failed" | "cancelled" | "unconfirmed" | "non_rescue";
67
+ reason?: string;
68
+ observedModel?: string;
69
+ observedVariant?: string;
70
+ outcome?: "succeeded" | "failed" | "cancelled" | "unknown";
71
+ }
23
72
  export interface MissionExecution {
24
73
  commands: string[];
25
74
  directory: string;
@@ -61,6 +110,12 @@ export interface OperatorMission {
61
110
  reviewBaseline?: string;
62
111
  /** Cumulative declared review inputs/outputs, including units that failed after writing source. */
63
112
  reviewScope?: MissionReviewScope;
113
+ /** Optional Advisor/Scout decisions and native consultation outcomes; never an admission gate. */
114
+ consultations?: MissionConsultation[];
115
+ /** Host-observed current-Mission Worker attempts; only an implementation failure followed by a failed remediation can qualify for Rescue. */
116
+ attempts?: MissionAttempt[];
117
+ /** One bounded Astra Rescue per eligible unit; validation, independent review and root acceptance remain separate. */
118
+ rescue?: MissionTerminalRescue;
64
119
  /** A cancelled run replaced by a later real user turn; never reuse its acceptance or evidence. */
65
120
  supersededRunID?: string;
66
121
  plans: number;
@@ -95,6 +150,7 @@ export interface OperatorMission {
95
150
  * Worker unit, so after this many evidence-only reviews the candidate may be submitted with the gaps listed.
96
151
  */
97
152
  export declare const MISSION_EVIDENCE_GAP_REVIEW_LIMIT = 2;
153
+ export declare const MISSION_CONSULTATION_LIMIT = 32;
98
154
  /** Review coverage survives a narrower replan; it is not a Worker write grant. */
99
155
  export declare function missionReviewScope(previous: MissionReviewScope | undefined, ...runs: OperatorState[]): MissionReviewScope;
100
156
  /** Classify an independent Reviewer's first line. Anything else is a finding. */
@@ -133,6 +189,20 @@ export declare class OperatorMissionRuntime {
133
189
  cancelledRunID?: string;
134
190
  }): Promise<OperatorMission>;
135
191
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
192
+ recordConsultationSkip(root: string, role: MissionConsultation["role"], reason: string): Promise<OperatorMission>;
193
+ recordConsultationDispatch(root: string, input: {
194
+ role: MissionConsultation["role"];
195
+ callID: string;
196
+ reason: string;
197
+ trigger?: string;
198
+ question?: string;
199
+ }): Promise<OperatorMission>;
200
+ settleConsultation(root: string, callID: string, outcome: MissionConsultation["outcome"], details?: {
201
+ childSessionID?: string;
202
+ observedModel?: string;
203
+ observedVariant?: string;
204
+ result?: string;
205
+ }): Promise<OperatorMission>;
136
206
  /** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
137
207
  carryForward(root: string, missionID: string, acceptance: readonly string[], supersededRunID?: string): Promise<OperatorMission>;
138
208
  task(state: OperatorMission): OperatorTask;
@@ -23,6 +23,7 @@ export function missionValidationCommand(command) {
23
23
  * Worker unit, so after this many evidence-only reviews the candidate may be submitted with the gaps listed.
24
24
  */
25
25
  export const MISSION_EVIDENCE_GAP_REVIEW_LIMIT = 2;
26
+ export const MISSION_CONSULTATION_LIMIT = 32;
26
27
  /** Review coverage survives a narrower replan; it is not a Worker write grant. */
27
28
  export function missionReviewScope(previous, ...runs) {
28
29
  return {
@@ -267,6 +268,41 @@ export class OperatorMissionRuntime {
267
268
  return state;
268
269
  });
269
270
  }
271
+ recordConsultationSkip(root, role, reason) {
272
+ if ((role !== "advisor" && role !== "scout") || typeof reason !== "string" || !reason.trim() ||
273
+ reason.length > 1000 || /[\r\n]/u.test(reason))
274
+ throw new Error("mission-consultation-input-invalid");
275
+ return this.update(root, state => {
276
+ state.consultations = [...(state.consultations ?? []), { id: randomUUID(), role, disposition: "skipped",
277
+ reason: reason.trim(), at: new Date().toISOString() }].slice(-MISSION_CONSULTATION_LIMIT);
278
+ });
279
+ }
280
+ recordConsultationDispatch(root, input) {
281
+ if ((input.role !== "advisor" && input.role !== "scout") || !input.callID || !input.reason.trim() ||
282
+ input.reason.length > 1000 || /[\r\n]/u.test(input.reason) ||
283
+ (input.trigger !== undefined && (input.trigger.length > 128 || /[\r\n]/u.test(input.trigger))) ||
284
+ (input.question !== undefined && (input.question.length > 1000 || /[\r\n]/u.test(input.question)))) {
285
+ throw new Error("mission-consultation-input-invalid");
286
+ }
287
+ return this.update(root, state => {
288
+ state.consultations = [...(state.consultations ?? []), { id: randomUUID(), role: input.role,
289
+ disposition: "dispatched", reason: input.reason.trim(), at: new Date().toISOString(), callID: input.callID,
290
+ ...(input.trigger === undefined ? {} : { trigger: input.trigger }),
291
+ ...(input.question === undefined ? {} : { question: input.question }) }].slice(-MISSION_CONSULTATION_LIMIT);
292
+ });
293
+ }
294
+ settleConsultation(root, callID, outcome, details = {}) {
295
+ if (!callID || !["completed", "failed", "unknown"].includes(String(outcome)))
296
+ throw new Error("mission-consultation-input-invalid");
297
+ return this.update(root, state => {
298
+ const item = [...(state.consultations ?? [])].reverse().find(entry => entry.callID === callID);
299
+ if (!item)
300
+ return;
301
+ if (details.result !== undefined && details.result.length > 4000)
302
+ details.result = details.result.slice(0, 4000);
303
+ Object.assign(item, { outcome, ...details });
304
+ });
305
+ }
270
306
  /** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
271
307
  carryForward(root, missionID, acceptance, supersededRunID) {
272
308
  return this.update(root, state => {
@@ -418,7 +454,8 @@ export function missionPacket(mission, run) {
418
454
  completed_units: predecessor.units.filter(unit => unit.status === "succeeded").length,
419
455
  note: "Historical results and spend are retained; they do not complete the current requirements." } } : {}),
420
456
  requirements: mission.requirements, original_request_refs: mission.requests.map(item => `user:${item.id}`),
421
- submission: mission.submission, progress: mission.progress,
457
+ submission: mission.submission, progress: mission.progress, consultations: mission.consultations ?? [],
458
+ attempts: mission.attempts ?? [], ...(mission.rescue ? { rescue: mission.rescue } : {}),
422
459
  operation: { kind: mission.kind ?? "implementation", status: missionExecutionStatus(mission),
423
460
  ...(mission.execution ? { ...mission.execution } : {}) },
424
461
  execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
@@ -438,13 +475,19 @@ export function missionPacket(mission, run) {
438
475
  child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
439
476
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
440
477
  scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
478
+ normal_remediation_used: unit.normalRemediationUsed ?? false,
479
+ ...(unit.failure ? { failure: { outcome: unit.failure.outcome, exit_code: unit.failure.exitCode } } : {}),
441
480
  ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
442
481
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
443
482
  : mission.phase === "submitted" && mission.submission?.status === "ready"
444
483
  ? "Operator: compare the submitted candidate with the original requirements and actual evidence, then complete_mission if satisfied. Report remaining evidence gaps; they are not a review PASS."
445
484
  : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
446
485
  ? "Coordinator: inspect units[].dispatch_denial before changing the plan. Correct only its diagnosed cause; do not repeat an unchanged refused Task or replan for a host-state mismatch. Report an unresolved runtime mismatch with the loaded runtime identity; preserve requirements and cumulative spend."
447
- : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
486
+ : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && !unit.normalRemediationUsed)
487
+ ? "Coordinator: one exact declared validation failed. Reconcile the returned Worker and cumulative budget, then call retry_mission_unit once for that unit before replanning. This preserves the same scope and acceptance."
488
+ : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && unit.normalRemediationUsed && !unit.terminalRescue)
489
+ ? "Coordinator: the one ordinary remediation failed the declared validation again. If its native terminal, writer release, exact candidate and cumulative budget permit, call rescue_mission_unit once; inspect and report a non_rescue reason, then continue ordinary correction within budget. Rescue does not bypass validation, review or root acceptance."
490
+ : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
448
491
  : run?.phase === "awaiting-acceptance" && !operationComplete
449
492
  ? `Requested operation is ${operationStatus}. Continue the actual operation or report its blocker; auxiliary checks and review disposition cannot complete it.`
450
493
  : run?.phase === "awaiting-acceptance" ? (currentReview && mission.review?.verdict === "evidence-gaps" && !reviewAccepted
@@ -106,15 +106,32 @@ export type OperatorProposal = {
106
106
  type OperatorPhase = "prepared" | "running" | "awaiting-decision" | "awaiting-acceptance" | "completed" | "cancelled";
107
107
  interface UnitState {
108
108
  readonly unit: OperatorUnit;
109
- readonly task: OperatorTask;
109
+ task: OperatorTask;
110
110
  readonly handoffPath: string;
111
111
  readonly manifestPath: string;
112
- hashes: readonly string[];
112
+ hashes: string[];
113
113
  status: "pending" | "running" | "succeeded" | "failed" | "cancelled";
114
114
  callID: string | null;
115
115
  childSessionID: string | null;
116
116
  evidence: readonly GoalEvidence[];
117
117
  resultClass: string | null;
118
+ failure?: SerialDispatchSettlement["failure"];
119
+ normalRemediationUsed?: boolean;
120
+ terminalRescue?: {
121
+ readonly attempt_id: string;
122
+ readonly selected_model: string;
123
+ readonly selected_variant: string | null;
124
+ readonly accepted_base: {
125
+ readonly candidate_id: string;
126
+ readonly contract_id: string;
127
+ readonly scope: readonly string[];
128
+ readonly acceptance: readonly string[];
129
+ readonly validation: readonly string[];
130
+ };
131
+ readonly prior_failure: SerialDispatchSettlement["failure"];
132
+ status: "prepared" | "dispatched" | "settled";
133
+ disposition?: "succeeded" | "failed" | "cancelled";
134
+ };
118
135
  dispatchDenial?: string;
119
136
  repairValidationAttempts: number;
120
137
  repairValidation: {
@@ -319,6 +336,21 @@ export declare class OperatorRuntime {
319
336
  private operatorRejectedOnce;
320
337
  settled(result: SerialDispatchSettlement): Promise<void>;
321
338
  private settledOnce;
339
+ /** One ordinary same-scope Mission retry before any optional model Rescue is considered. */
340
+ prepareMissionNormalRemediation(root: string, runID: string, unitID: string): Promise<OperatorState>;
341
+ /** Requeue a host-classified normal-remediation failure through the ordinary Mission Worker gates. */
342
+ prepareMissionTerminalRescue(root: string, runID: string, unitID: string, input: {
343
+ attempt_id: string;
344
+ selected_model: string;
345
+ selected_variant: string | null;
346
+ accepted_base: {
347
+ candidate_id: string;
348
+ contract_id: string;
349
+ scope: readonly string[];
350
+ acceptance: readonly string[];
351
+ validation: readonly string[];
352
+ };
353
+ }): Promise<OperatorState>;
322
354
  observeChild(root: string, callID: string, childID: string): Promise<void>;
323
355
  private observeChildOnce;
324
356
  operatorReturned(root: string): Promise<OperatorState>;
@@ -1237,6 +1237,8 @@ export class OperatorRuntime {
1237
1237
  state.dispatched++;
1238
1238
  unit.status = "running";
1239
1239
  unit.callID = callID;
1240
+ if (unit.terminalRescue)
1241
+ unit.terminalRescue.status = "dispatched";
1240
1242
  state.phase = "running";
1241
1243
  await this.save(state);
1242
1244
  return admitted;
@@ -1528,7 +1530,13 @@ export class OperatorRuntime {
1528
1530
  unit.resultClass = result.resultClass;
1529
1531
  if (result.failure !== undefined)
1530
1532
  unit.failure = result.failure;
1533
+ else
1534
+ delete unit.failure;
1531
1535
  unit.status = result.disposition;
1536
+ if (unit.terminalRescue) {
1537
+ unit.terminalRescue.status = "settled";
1538
+ unit.terminalRescue.disposition = result.disposition;
1539
+ }
1532
1540
  if (result.disposition !== "succeeded") {
1533
1541
  state.phase = "awaiting-decision";
1534
1542
  state.decision = result.resultClass === "acceptance" ? ACCEPTANCE_REMEDIATION_DECISION : result.resultClass;
@@ -1540,6 +1548,76 @@ export class OperatorRuntime {
1540
1548
  }
1541
1549
  await this.save(state);
1542
1550
  }
1551
+ /** One ordinary same-scope Mission retry before any optional model Rescue is considered. */
1552
+ prepareMissionNormalRemediation(root, runID, unitID) {
1553
+ return this.serial(root, async () => {
1554
+ const state = await this.required(root);
1555
+ const unit = state.units.find(candidate => candidate.unit.id === unitID);
1556
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(candidate => candidate.status === "running") ||
1557
+ !unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || !unit.failure ||
1558
+ unit.failure.outcome !== "fail" || unit.normalRemediationUsed || unit.terminalRescue ||
1559
+ state.units.some(candidate => candidate.terminalRescue))
1560
+ throw new Error("operator-mission-normal-remediation-unavailable");
1561
+ await this.verifyControls(unit);
1562
+ unit.normalRemediationUsed = true;
1563
+ unit.status = "pending";
1564
+ unit.callID = null;
1565
+ unit.childSessionID = null;
1566
+ unit.evidence = [];
1567
+ unit.resultClass = null;
1568
+ delete unit.failure;
1569
+ unit.repairValidation = null;
1570
+ state.phase = "prepared";
1571
+ state.decision = null;
1572
+ await this.save(state);
1573
+ return structuredClone(state);
1574
+ });
1575
+ }
1576
+ /** Requeue a host-classified normal-remediation failure through the ordinary Mission Worker gates. */
1577
+ prepareMissionTerminalRescue(root, runID, unitID, input) {
1578
+ return this.serial(root, async () => {
1579
+ const state = await this.required(root);
1580
+ const unit = state.units.find(candidate => candidate.unit.id === unitID);
1581
+ const base = input.accepted_base;
1582
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(candidate => candidate.status === "running") ||
1583
+ !unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || !unit.failure ||
1584
+ unit.failure.outcome !== "fail" || unit.normalRemediationUsed !== true || unit.terminalRescue ||
1585
+ state.units.some(candidate => candidate.terminalRescue) ||
1586
+ !identifier(input.attempt_id) || !text(input.selected_model) ||
1587
+ (input.selected_variant !== null && !text(input.selected_variant))) {
1588
+ throw new Error("operator-mission-terminal-rescue-unavailable");
1589
+ }
1590
+ if (!/^sha256:[0-9a-f]{64}$/u.test(base.candidate_id) ||
1591
+ !text(base.contract_id) || base.contract_id !== state.acceptanceFingerprint ||
1592
+ !strings(base.scope, true) || JSON.stringify(base.scope) !== JSON.stringify(unit.unit.write) ||
1593
+ !strings(base.acceptance, true) || base.acceptance.length !== state.acceptance.length ||
1594
+ base.acceptance.some((criterion, index) => criterion !== state.acceptance[index]) ||
1595
+ !strings(base.validation, true) || JSON.stringify(base.validation) !== JSON.stringify(unit.unit.validation)) {
1596
+ throw new Error("operator-mission-terminal-rescue-contract-mismatch");
1597
+ }
1598
+ const header = /^task_id: [^\r\n]+$/mu;
1599
+ if (!header.test(unit.task.prompt) || /^terminal_rescue_attempt:/mu.test(unit.task.prompt)) {
1600
+ throw new Error("operator-mission-terminal-rescue-task-invalid");
1601
+ }
1602
+ await this.verifyControls(unit);
1603
+ // The Rescue route is sealed in host-owned operator state and the returned Task reference.
1604
+ // Do not alter the registered handoff or expose the stable runtime's rescue capability.
1605
+ unit.task = { ...unit.task, prompt: unit.task.prompt.replace(header, line => `${line}\nterminal_rescue_attempt: ${input.attempt_id}`) };
1606
+ unit.terminalRescue = { attempt_id: input.attempt_id, selected_model: input.selected_model,
1607
+ selected_variant: input.selected_variant, accepted_base: structuredClone(base), prior_failure: structuredClone(unit.failure), status: "prepared" };
1608
+ unit.status = "pending";
1609
+ unit.callID = null;
1610
+ unit.childSessionID = null;
1611
+ unit.evidence = [];
1612
+ unit.resultClass = null;
1613
+ delete unit.failure;
1614
+ unit.repairValidation = null;
1615
+ state.phase = "prepared";
1616
+ state.decision = null;
1617
+ await this.save(state);
1618
+ return structuredClone(state);
1619
+ });
1620
+ }
1543
1621
  observeChild(root, callID, childID) {
1544
1622
  return this.serial(root, () => this.observeChildOnce(root, callID, childID));
1545
1623
  }
@@ -1910,6 +1988,10 @@ export class OperatorRuntime {
1910
1988
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
1911
1989
  scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
1912
1990
  acceptance_indices: unit.unit.acceptance_indices,
1991
+ normal_remediation_used: unit.normalRemediationUsed ?? false,
1992
+ ...(unit.terminalRescue ? { terminal_rescue: { attempt_id: unit.terminalRescue.attempt_id,
1993
+ selected_model: unit.terminalRescue.selected_model, selected_variant: unit.terminalRescue.selected_variant,
1994
+ status: unit.terminalRescue.status, ...(unit.terminalRescue.disposition ? { disposition: unit.terminalRescue.disposition } : {}) } } : {}),
1913
1995
  evidence: unit.evidence.map(item => ({ id: item.evidence_id, criteria: item.measurement.criterion_ids,
1914
1996
  candidate: item.identity.candidate, command: item.execution.command, outcome: item.execution.outcome, exit_code: item.execution.exit_code })),
1915
1997
  repair_validation: unit.repairValidation === null ? null : { repair_fingerprint: unit.repairValidation.repair_fingerprint,
@@ -6259,14 +6259,24 @@ export const SortieDogsPlugin = async (input, options) => {
6259
6259
  let terminalRescueTarget;
6260
6260
  if (selectedAgent === SERIAL_WORKER_AGENT) {
6261
6261
  const text = explicitTaskText(output);
6262
- const rescueRequested = text !== undefined && handoffValue(handoffEntries(text), ["terminal_rescue_attempt"]) !== undefined;
6262
+ const rescueAttemptID = text === undefined ? undefined : handoffValue(handoffEntries(text), ["terminal_rescue_attempt"]);
6263
+ const rescueRequested = rescueAttemptID !== undefined;
6264
+ const missionSelection = chatInput.missionRescueSelection;
6265
+ const missionRescueTarget = runtimeProfile.id === "v010" && rescueRequested && isRecord(missionSelection) &&
6266
+ missionSelection.attempt_id === rescueAttemptID && typeof missionSelection.model === "string" &&
6267
+ missionSelection.model.includes("/") &&
6268
+ (missionSelection.variant === null || typeof missionSelection.variant === "string")
6269
+ ? { model: missionSelection.model,
6270
+ ...(missionSelection.variant === null ? {} : { variant: missionSelection.variant }) }
6271
+ : undefined;
6263
6272
  if (text !== undefined && !rescueRequested)
6264
6273
  terminalRescueHandoffs.delete(chatInput.sessionID);
6265
6274
  const suppliedPath = !rescueRequested ? undefined : handoffValue(handoffEntries(text), ["handoff_path"]);
6266
6275
  const path = suppliedPath === undefined ? terminalRescueHandoffs.get(chatInput.sessionID) : unquoteValue(suppliedPath);
6267
6276
  if (path !== undefined) {
6268
- const identity = await inspect(path, undefined, { report: true, rescueSessionID: chatInput.sessionID });
6269
- terminalRescueTarget = identity?.terminalRescueTarget;
6277
+ const identity = await inspect(path, undefined, { report: true,
6278
+ ...(missionRescueTarget === undefined ? { rescueSessionID: chatInput.sessionID } : {}) });
6279
+ terminalRescueTarget = missionRescueTarget ?? identity?.terminalRescueTarget;
6270
6280
  if (rescueRequested && terminalRescueTarget === undefined)
6271
6281
  throw new Error("rescue-binding-unavailable");
6272
6282
  if (terminalRescueTarget !== undefined) {
@@ -6586,10 +6596,11 @@ export const SortieDogsPlugin = async (input, options) => {
6586
6596
  diagnosis.accepting = false;
6587
6597
  diagnosisCalls.delete(toolInput.callID ?? "");
6588
6598
  }
6589
- const returnedMissionWorker = completedChildSessionID !== undefined &&
6590
- await input.runtimeBridge?.allowsInvestigativeShell?.(completedChildSessionID);
6591
6599
  parallelCalls.delete(toolInput.callID ?? "");
6592
6600
  activeSessions.get(toolInput.sessionID ?? "")?.inFlightCalls.delete(toolInput.callID ?? "");
6601
+ // Snapshot Mission ownership before settling a failed validation changes its durable phase to awaiting-decision.
6602
+ const returnedMissionWorker = completedChildSessionID !== undefined &&
6603
+ await input.runtimeBridge?.allowsInvestigativeShell?.(completedChildSessionID);
6593
6604
  if (coordinatorTaskFinished && diagnosis === undefined) {
6594
6605
  if (toolInput.callID !== undefined) {
6595
6606
  await settleGoalDispatch(toolInput.callID, output).catch((error) => {
@@ -6603,16 +6614,16 @@ export const SortieDogsPlugin = async (input, options) => {
6603
6614
  if (repair?.callID === toolInput.callID)
6604
6615
  operatorContractRepairResumes.delete(toolInput.sessionID);
6605
6616
  }
6617
+ if (completedChildSessionID !== undefined && activeSessions.get(completedChildSessionID)?.parallel !== "valid" &&
6618
+ returnedMissionWorker) {
6619
+ // Mission failures also leave the run awaiting-decision before this return hook completes. Release the
6620
+ // serial writer before settlement/correction can observe terminal ownership, then evict as usual.
6621
+ await releaseWriteGate(completedChildSessionID);
6622
+ }
6606
6623
  if (completedChildSessionID !== undefined && !recoverableWorkerChildren.has(completedChildSessionID)) {
6607
6624
  evictSession(completedChildSessionID);
6608
6625
  void childLifecycles.get(completedChildSessionID)?.check();
6609
6626
  }
6610
- else if (completedChildSessionID !== undefined && activeSessions.get(completedChildSessionID)?.parallel !== "valid" &&
6611
- returnedMissionWorker) {
6612
- // Keep same-worker recovery identity, but a returned serial Task must not keep a live
6613
- // write reservation. Rebinding the retained manifest reacquires it before any later write.
6614
- await releaseWriteGate(completedChildSessionID);
6615
- }
6616
6627
  }
6617
6628
  },
6618
6629
  "tool.execute.before": async (toolInput, output) => {
@@ -7850,12 +7861,32 @@ export const SortieDogsPlugin = async (input, options) => {
7850
7861
  if (state.goal_id === null || state.budget === null)
7851
7862
  return null;
7852
7863
  const reserved = state.outstanding_reservations.length;
7853
- return { max_units: state.budget.max_units, consumed_units: state.consumed_units,
7864
+ const snapshot = { max_units: state.budget.max_units, consumed_units: state.consumed_units,
7854
7865
  reserved_units: reserved, remaining_units: Math.max(0, state.budget.max_units - state.consumed_units - reserved),
7866
+ settled_time_ms: state.consumed_time_ms, time_limit_ms: state.budget.time_ms,
7855
7867
  settled_cost_usd: state.consumed_cost_usd, cost_limit_usd: state.budget.cost_usd,
7856
7868
  cost_status: state.consumed_cost_usd === null ? "unknown-usage" : reserved > 0 ? "in-flight-not-final" : "settled",
7857
7869
  cost_source: "native-usage-price-table",
7858
7870
  cost_note: "Worker token-price estimates from complete native usage; missing requests remain unknown and are reconciled on later status. Excludes orchestration/review and external campaign spend. Zero settled cost does not mean free execution." };
7871
+ return snapshot;
7872
+ },
7873
+ ...{
7874
+ missionWorkerTerminal: async (childSessionID, writeScopes) => {
7875
+ const active = activeSessions.get(childSessionID), authorization = sessionAuthorizations.get(childSessionID);
7876
+ if ((active?.inFlightCalls.size ?? 0) !== 0 || (active !== undefined && active.released !== true) ||
7877
+ (authorization !== undefined && (!authorization.suspended || authorization.lease !== undefined)))
7878
+ return false;
7879
+ const scopeRoot = await durableScopeRoot(project?.root ?? input.directory);
7880
+ if (scopeRoot === undefined)
7881
+ return false;
7882
+ try {
7883
+ const scope = normalizeWorktreeScope({ read: [], write: [...writeScopes] });
7884
+ return !await new ScopeLeaseRegistry(scopeRoot).hasConflictingLease(scope);
7885
+ }
7886
+ catch {
7887
+ return false;
7888
+ }
7889
+ },
7859
7890
  },
7860
7891
  registerGoalDeclaration: async (root, prompt, missionRevision) => {
7861
7892
  if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
@@ -28,6 +28,10 @@ export function missionProgressReader(directory, root, callID) {
28
28
  return { description: `🐾 ${status}: ${title}`, sortie_progress: {
29
29
  mission_id: mission.id, phase: mission.phase, unit: running ? `${run.runID}/${running.unit.id}` : last?.unit ?? null,
30
30
  title, status, child_session_id: running?.childSessionID ?? null,
31
+ // Stable timestamps, not a fabricated heartbeat. The parent can tell when the
32
+ // run began and when a unit last settled without generating another model turn.
33
+ run_created_at: run?.runID === mission.runID ? run.createdAt : null,
34
+ last_transition_at: last?.at ?? null,
31
35
  completed_units: units.filter(unit => unit.status === "succeeded").length,
32
36
  failed_units: units.filter(unit => unit.status === "failed").length,
33
37
  historical_failed_attempts: mission.progress.filter(unit => unit.status === "failed").length,
@@ -18,7 +18,10 @@ import { publishMissionProgress } from "./mission-progress.js";
18
18
  import { completedMissionReviewPrompts, initialMissionReviewPrompt, missionReviewBaseline, missionReviewSource } from "./mission-review.js";
19
19
  import { missionLocations, missionLocationPacket } from "./mission-location.js";
20
20
  import { SOURCE_REVIEW_RISK_TAGS } from "../core/consultation.js";
21
- import { createHash } from "node:crypto";
21
+ import { createHash, randomUUID } from "node:crypto";
22
+ import { proposeTerminalRescue } from "../core/terminal-rescue-policy.js";
23
+ import { DEFAULT_TERMINAL_RESCUE_MODEL } from "./terminal-rescue-host.js";
24
+ import { readHostModels } from "./model-routing-hook.js";
22
25
  const SERIAL_CAPABILITIES = new Set([
23
26
  "sortie_bind_write_gate", "sortie_release_write_gate", "sortie_check_contract",
24
27
  "sortie_compact_and_continue", "sortie_enable_backlog_drain", "sortie_reflection",
@@ -84,6 +87,17 @@ export function previewModelCatalog(routes = PREVIEW_ROUTES, base = BUILT_IN_MOD
84
87
  }
85
88
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
86
89
  const payload = (value) => record(value) && "data" in value ? value.data : value;
90
+ function missionConsultationDetails(role, prompt) {
91
+ const marker = role === "advisor" ? /^strategy_trigger:\s*(\S+)\s*$/mu : /^missing_evidence_code:\s*(\S+)\s*$/mu;
92
+ const match = marker.exec(prompt);
93
+ const lines = prompt.split(/\r?\n/u);
94
+ const start = match === null ? 0 : lines.findIndex(line => marker.test(line)) + 1;
95
+ const question = lines.slice(Math.max(0, start)).map(line => line.trim()).find(line => line.length > 0 &&
96
+ !/^(?:project_root|known_paths|missing_evidence_code|strategy_trigger|role|task_id|acceptance|validation)\s*:/u.test(line));
97
+ const summary = (question ?? prompt.trim().split(/\r?\n/u).find(line => line.trim()) ?? "consultation requested").slice(0, 1000);
98
+ return { reason: summary, ...(question === undefined ? {} : { question: question.slice(0, 1000) }),
99
+ ...(match === null ? {} : { trigger: match[1].slice(0, 128) }) };
100
+ }
87
101
  /** Prove native V2 termination of the old dispatch, including earlier units and consultations. */
88
102
  export async function terminalCancelledMissionChildren(profile, root, previous, budget, host) {
89
103
  if (!budget || budget.reserved_units !== 0)
@@ -153,6 +167,7 @@ export function createProfiledPlugin(profile, assetVersion) {
153
167
  const selected = new Map();
154
168
  const operatorParents = new Map();
155
169
  const taskOwners = new Map();
170
+ const missionRescueSelections = new Map();
156
171
  const operatorTurnLifecycle = new Map();
157
172
  const historicalTurnMessages = new Map();
158
173
  const dispatchTransitions = new Map();
@@ -189,6 +204,19 @@ export function createProfiledPlugin(profile, assetVersion) {
189
204
  const result = payload(await session(typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages", { path: { id }, query: { directory: input.directory } }));
190
205
  return Array.isArray(result) ? result.filter(record) : [];
191
206
  }
207
+ async function observedSessionModel(id) {
208
+ const info = payload(await session("get", { path: { id }, query: { directory: input.directory } }).catch(() => undefined));
209
+ if (!record(info))
210
+ return { outcome: "unknown" };
211
+ const model = record(info.model) ? info.model : info;
212
+ const provider = typeof model.providerID === "string" ? model.providerID : info.providerID;
213
+ const modelID = typeof model.modelID === "string" ? model.modelID : typeof model.id === "string" ? model.id : info.modelID;
214
+ const variant = typeof model.variant === "string" ? model.variant : typeof info.variant === "string" ? info.variant : undefined;
215
+ const outcome = ["succeeded", "completed"].includes(String(info.outcome)) ? "completed"
216
+ : ["failed", "interrupted", "cancelled"].includes(String(info.outcome)) ? "failed" : "unknown";
217
+ return { ...(typeof provider === "string" && typeof modelID === "string" ? { model: `${provider}/${modelID}` } : {}),
218
+ ...(variant === undefined ? {} : { variant }), outcome };
219
+ }
192
220
  async function identity(id) {
193
221
  const info = payload(await session("get", { path: { id }, query: { directory: input.directory } }));
194
222
  const parent = record(info) && typeof info.parentID === "string" ? info.parentID : undefined;
@@ -466,8 +494,52 @@ export function createProfiledPlugin(profile, assetVersion) {
466
494
  const unit = run.units.find(unit => unit.callID === result.callID);
467
495
  if (!unit)
468
496
  return;
497
+ const recordedAttempt = [...(mission.attempts ?? [])].reverse().find(attempt => attempt.callID === result.callID);
498
+ const observed = result.childSessionID ? await observedSessionModel(result.childSessionID) : { outcome: "unknown" };
499
+ const currentCandidate = result.resultClass === "acceptance" && result.failure?.outcome === "fail"
500
+ ? await missionReviewSource(input.directory, run, [], mission.reviewBaseline, mission.reviewScope).then(value => value.fingerprint)
501
+ : undefined;
502
+ const attemptFailure = result.disposition === "cancelled"
503
+ ? { category: "cancellation", code: "native-task-cancelled" }
504
+ : result.resultClass === "acceptance" && result.failure?.outcome === "fail" && observed.outcome === "completed"
505
+ ? { category: "implementation",
506
+ code: `validation-failed:${result.failure.exitCode ?? "unknown"}` }
507
+ : result.resultClass === "process-defect"
508
+ ? { category: "contract", code: result.resultClass }
509
+ : { category: "unknown", code: result.resultClass };
469
510
  const progress = { unit: `${run.runID}/${unit.unit.id}`, title: unit.unit.title, status: unit.status, at: new Date().toISOString() };
470
- await missions.update(result.rootSessionID, state => { state.progress.push(progress); });
511
+ await missions.update(result.rootSessionID, state => {
512
+ state.progress.push(progress);
513
+ const attempt = recordedAttempt && state.attempts?.find(item => item.attemptID === recordedAttempt.attemptID);
514
+ if (attempt) {
515
+ attempt.status = result.disposition;
516
+ attempt.resultClass = result.resultClass;
517
+ attempt.nativeOutcome = observed.outcome;
518
+ if (result.childSessionID)
519
+ attempt.childSessionID = result.childSessionID;
520
+ if (observed.model)
521
+ attempt.observedModel = observed.model;
522
+ if (observed.variant)
523
+ attempt.observedVariant = observed.variant;
524
+ if (result.disposition !== "succeeded")
525
+ attempt.failure = attemptFailure;
526
+ else
527
+ delete attempt.failure;
528
+ if (currentCandidate)
529
+ attempt.candidateID = currentCandidate;
530
+ }
531
+ if (state.rescue && state.rescue.attemptID === unit.terminalRescue?.attempt_id) {
532
+ state.rescue.status = result.disposition === "succeeded" ? "succeeded"
533
+ : result.disposition === "cancelled" ? "cancelled" : "failed";
534
+ state.rescue.outcome = result.disposition;
535
+ if (observed.model)
536
+ state.rescue.observedModel = observed.model;
537
+ if (observed.variant)
538
+ state.rescue.observedVariant = observed.variant;
539
+ }
540
+ });
541
+ if (result.childSessionID)
542
+ missionRescueSelections.delete(result.childSessionID);
471
543
  await publishMissionProgress(result.rootSessionID, { description: `🐾 ${unit.status === "succeeded" ? "✅" : "🔧"} ${unit.unit.title}`,
472
544
  sortie_progress: progress });
473
545
  },
@@ -507,6 +579,12 @@ export function createProfiledPlugin(profile, assetVersion) {
507
579
  next_action: "Use a known Coordinator's native location to resume. Absence here does not prove absence in other locations." },
508
580
  } : {}) };
509
581
  }
582
+ function sourceReconciliationRequired(mission, previous) {
583
+ return previous?.phase === "cancelled" && !["completed", "cancelled"].includes(mission.phase) &&
584
+ mission.runID === null && mission.supersededRunID === undefined &&
585
+ (previous.sourceRefs[0] !== `user:${mission.requests[0]?.id}` ||
586
+ !previous.sourceRefs.every(ref => mission.requests.some(request => ref === `user:${request.id}`)));
587
+ }
510
588
  async function missionAuthority(id) {
511
589
  const root = await rootFor(id);
512
590
  if (!root)
@@ -516,11 +594,54 @@ export function createProfiledPlugin(profile, assetVersion) {
516
594
  throw new Error("mission-controller-required");
517
595
  return { root, mission };
518
596
  }
597
+ async function missionWorkerTerminalProof(root, mission, run, attempt) {
598
+ if (!attempt.callID || !attempt.childSessionID || !mission.coordinator ||
599
+ !control) {
600
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
601
+ }
602
+ if (attempt.runID !== run.runID || attempt.unitID !== run.units.find(unit => unit.unit.id === attempt.unitID)?.unit.id) {
603
+ return { status: "non_rescue", reason: "terminal_identity_conflict" };
604
+ }
605
+ try {
606
+ await control.assertActiveGoal(root, await operators.completionGoalFingerprint(run));
607
+ }
608
+ catch {
609
+ return { status: "non_rescue", reason: "goal_not_active" };
610
+ }
611
+ const budget = await control.currentBudget(root);
612
+ if (!budget || budget.reserved_units !== 0)
613
+ return { status: "non_rescue", reason: "goal_reservation_unsettled" };
614
+ const child = attempt.childSessionID;
615
+ const who = await identity(child);
616
+ if (who.role !== "dog-worker" || who.parent !== mission.coordinator) {
617
+ return { status: "non_rescue", reason: "terminal_identity_conflict" };
618
+ }
619
+ const info = payload(await session("get", { path: { id: child }, query: { directory: input.directory } }).catch(() => undefined));
620
+ const outcome = record(info) ? String(info.outcome) : "unknown";
621
+ if (!record(info) || info.id !== child || info.parentID !== mission.coordinator ||
622
+ !["succeeded", "completed"].includes(outcome)) {
623
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
624
+ }
625
+ const descendants = payload(await session("children", { path: { id: child }, query: { directory: input.directory } }).catch(() => undefined));
626
+ if (!Array.isArray(descendants) || descendants.length !== 0) {
627
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
628
+ }
629
+ const terminalObserver = control?.missionWorkerTerminal;
630
+ if (!terminalObserver) {
631
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
632
+ }
633
+ if (!await terminalObserver(child, run.units.find(unit => unit.unit.id === attempt.unitID).unit.write)) {
634
+ return { status: "non_rescue", reason: "writer_not_released" };
635
+ }
636
+ return { status: "ready", child };
637
+ }
519
638
  async function reconcileMissionDispatch(root) {
520
639
  const mission = await missions.read(root);
521
- if (!mission?.dispatchOpen || !mission.callID || ["cancelled", "completed"].includes(mission.phase) ||
522
- taskOwners.has(mission.callID))
640
+ if (!mission?.dispatchOpen || !mission.callID || ["cancelled", "completed"].includes(mission.phase))
523
641
  return mission;
642
+ // A native Task can be interrupted without an execute.after/event callback. Its in-memory
643
+ // owner then survives even though the host has already persisted a terminal tool part.
644
+ // The native record, not that cache, decides whether this exact dispatch can be resumed.
524
645
  const history = await messages(root);
525
646
  const finished = history.flatMap(message => Array.isArray(message.parts) ? message.parts : []).filter(part => record(part) && part.type === "tool" && part.tool === "task" && part.callID === mission.callID &&
526
647
  record(part.state) && ["completed", "error"].includes(String(part.state.status)) &&
@@ -535,11 +656,20 @@ export function createProfiledPlugin(profile, assetVersion) {
535
656
  if (who.parent !== root || who.role !== "dog-operator")
536
657
  return mission;
537
658
  }
538
- return missions.reconcileFinishedDispatch(root, mission.id, mission.callID);
659
+ const reconciled = await missions.reconcileFinishedDispatch(root, mission.id, mission.callID);
660
+ taskOwners.delete(mission.callID);
661
+ return reconciled;
539
662
  }
540
663
  function missionDispatchPacket(mission, run) {
541
664
  const packet = { ...missionPacket(mission, run), project_root: input.directory,
542
665
  coordinator_dispatch: mission.dispatchOpen ? "active" : ["completed", "cancelled"].includes(mission.phase) ? "terminal" : "resumable" };
666
+ if (sourceReconciliationRequired(mission, run))
667
+ return { ...packet, status: "mission-source-reconciliation-required",
668
+ next_action: mission.dispatchOpen ? "The Coordinator Task is still active. Do not redispatch; wait for its native completion and reconcile via operator_status."
669
+ : `The cancelled run's source_refs cannot prove this mission is the same goal. Do not dispatch or repeat plan_units. ` +
670
+ `If these saved requirements reflect the user's changed or narrowed scope, Operator: call ${profile.toolPrefix}start_mission ` +
671
+ `with intent=replace and the exact requirements array shown here. The host repairs this mission in place, retains spend, ` +
672
+ `and verifies old children before preparing a Worker. Otherwise obtain the user's scope decision.` };
543
673
  if (!mission.dispatchOpen && (["open", "running"].includes(mission.phase) ||
544
674
  (mission.phase === "submitted" && mission.submission?.status !== "ready"))) {
545
675
  return { ...packet, task: missions.task(mission),
@@ -560,8 +690,7 @@ export function createProfiledPlugin(profile, assetVersion) {
560
690
  }
561
691
  // A different original user request needs the explicit, host-proven supersession path.
562
692
  // Only the same source can retain a cancelled run's acceptance as a prefix.
563
- if (previous.sourceRefs[0] !== `user:${mission.requests[0]?.id}` ||
564
- !previous.sourceRefs.every(ref => mission.requests.some(request => ref === `user:${request.id}`))) {
693
+ if (sourceReconciliationRequired(mission, previous)) {
565
694
  throw new Error("mission-cancelled-source-unproven: the cancelled run's source_refs do not prove this is the same request. " +
566
695
  "If the user changed or narrowed the goal, start_mission with intent=replace and the complete current requirements; " +
567
696
  "otherwise preserve the earlier accepted criteria and establish the original source before continuing.");
@@ -838,6 +967,9 @@ export function createProfiledPlugin(profile, assetVersion) {
838
967
  const activeMission = await reconcileMissionDispatch(root);
839
968
  if (activeMission && !["cancelled", "completed"].includes(activeMission.phase) && activeMission.runID === null && context.sessionID !== root) {
840
969
  const budget = await control.currentBudget(root);
970
+ const prior = await operators.read(root);
971
+ if (sourceReconciliationRequired(activeMission, prior))
972
+ return JSON.stringify({ ...missionDispatchPacket(activeMission, prior), budget });
841
973
  return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget,
842
974
  next_action: "This mission has no unit plan yet. Call plan_units with the next useful work under the current requirements; prior run results are historical." });
843
975
  }
@@ -1339,9 +1471,9 @@ export function createProfiledPlugin(profile, assetVersion) {
1339
1471
  return JSON.stringify({ ...proposalIdentity(committed), execution: JSON.parse(preparedTask(prepared.state)) });
1340
1472
  });
1341
1473
  } };
1342
- const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`;
1474
+ const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`, skipMissionConsultation = `${profile.toolPrefix}skip_mission_consultation`;
1343
1475
  const stringList = { type: "array", items: { type: "string" } };
1344
- tools[startMission] = { description: "Operator: save the current user requirements. Use intent=replace when the user changes an existing request (version, parallelism, target): host cancels the previous run and archives its requirements/results while retaining spend. Supply the complete current requirements, retaining constraints the user has not changed. For separate work in a different location use intent=new. Dispatch the Coordinator immediately, or plan_units for a known single-unit procedure; item count and runtime alone do not require a Coordinator.",
1476
+ tools[startMission] = { description: "Operator: save the current user requirements. Use intent=replace when the user changes an existing request (version, parallelism, target): host cancels the previous run and archives its requirements/results while retaining spend. If status reports mission-source-reconciliation-required and the saved requirements reflect that changed scope, call intent=replace with those exact requirements: the host repairs this mission in place and keeps its Coordinator. Supply the complete current requirements, retaining constraints the user has not changed. For separate work in a different location use intent=new. Dispatch the Coordinator immediately, or plan_units for a known single-unit procedure; item count and runtime alone do not require a Coordinator.",
1345
1477
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 },
1346
1478
  kind: { type: "string", enum: ["implementation", "operation"], description: "Use operation for running an existing benchmark, command or procedure. The host records its actual execution separately from setup and checks.", "x-sortie-optional": true },
1347
1479
  intent: { type: "string", enum: ["", "continue", "new", "replace"], "x-sortie-optional": true } }, execute: async (args, context) => {
@@ -1350,6 +1482,27 @@ export function createProfiledPlugin(profile, assetVersion) {
1350
1482
  throw new Error("mission-intent-invalid");
1351
1483
  return serializeDispatchTransition(context.sessionID, async () => {
1352
1484
  const existing = await reconcileMissionDispatch(context.sessionID);
1485
+ const prior = await operators.read(context.sessionID);
1486
+ const requirements = args.requirements;
1487
+ if (args.intent === "replace" && existing && !existing.dispatchOpen && existing.runID === null &&
1488
+ existing.phase !== "cancelled" && existing.phase !== "completed" &&
1489
+ (existing.phase !== "submitted" || existing.submission?.status === "blocked") &&
1490
+ sourceReconciliationRequired(existing, prior) && Array.isArray(requirements) &&
1491
+ requirements.length === existing.requirements.length &&
1492
+ existing.requirements.every((item, index) => item.text === requirements[index])) {
1493
+ const repaired = await missions.update(context.sessionID, state => {
1494
+ if (state.id !== existing.id || state.runID !== null || state.dispatchOpen)
1495
+ throw new Error("mission-source-reconciliation-stale");
1496
+ state.supersededRunID = prior.runID;
1497
+ state.requirementsReplaced = true;
1498
+ if (state.phase === "submitted") {
1499
+ state.phase = "running";
1500
+ state.submission = null;
1501
+ }
1502
+ });
1503
+ return JSON.stringify({ ...locationObservation(context.sessionID), ...missionDispatchPacket(repaired, prior),
1504
+ budget: await control.currentBudget(context.sessionID) });
1505
+ }
1353
1506
  if (args.intent === "replace" && existing && !["completed", "cancelled"].includes(existing.phase)) {
1354
1507
  await stop(context.sessionID, "explicit-cancellation", false);
1355
1508
  }
@@ -1358,12 +1511,16 @@ export function createProfiledPlugin(profile, assetVersion) {
1358
1511
  if (relocated)
1359
1512
  return JSON.stringify({ ...relocated, budget: await control.currentBudget(context.sessionID) });
1360
1513
  }
1361
- const prior = await operators.read(context.sessionID);
1362
- let mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements, args.intent === "replace" || args.intent === "new", {
1514
+ const cancelled = await operators.read(context.sessionID);
1515
+ let mission = await missions.start(context.sessionID, requirements, args.intent === "replace" || args.intent === "new", {
1363
1516
  kind: args.kind === "operation" ? "operation" : "implementation",
1364
1517
  context: missionConversationContext(await messages(context.sessionID)),
1365
- ...(args.intent === "replace" && prior?.phase === "cancelled" ? { cancelledRunID: prior.runID } : {}),
1366
- }));
1518
+ ...(args.intent === "replace" && cancelled?.phase === "cancelled" ? { cancelledRunID: cancelled.runID } : {}),
1519
+ });
1520
+ if (sourceReconciliationRequired(mission, cancelled))
1521
+ return JSON.stringify({ ...locationObservation(context.sessionID),
1522
+ ...missionDispatchPacket(mission, cancelled), budget: await control.currentBudget(context.sessionID) });
1523
+ mission = await retainCancelledMissionAcceptance(context.sessionID, mission, cancelled);
1367
1524
  if (!mission.reviewBaseline) {
1368
1525
  const baseline = await missionReviewBaseline(input.directory);
1369
1526
  if (baseline)
@@ -1375,8 +1532,146 @@ export function createProfiledPlugin(profile, assetVersion) {
1375
1532
  next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." }) });
1376
1533
  });
1377
1534
  } };
1535
+ tools[skipMissionConsultation] = { description: "Coordinator: durably record why a concrete optional Advisor/Scout consultation is unnecessary. This is observation only: it adds no approval, consultation requirement, or dispatch gate.",
1536
+ args: { role: { type: "string", enum: ["advisor", "scout"] }, reason: stringSchema }, execute: async (args, context) => {
1537
+ const { root, mission } = await missionAuthority(context.sessionID);
1538
+ if (context.sessionID !== mission.coordinator || (await identity(context.sessionID)).role !== "dog-operator") {
1539
+ throw new Error("mission-consultation-coordinator-required");
1540
+ }
1541
+ const consultation = await missions.recordConsultationSkip(root, args.role, args.reason);
1542
+ return JSON.stringify({ status: "recorded", consultation: consultation.consultations?.at(-1) });
1543
+ } };
1544
+ const retryMissionUnit = `${profile.toolPrefix}retry_mission_unit`, rescueMissionUnit = `${profile.toolPrefix}rescue_mission_unit`;
1545
+ tools[retryMissionUnit] = { description: "Coordinator: after one host-classified implementation validation failure, dispatch exactly one same-scope ordinary Mission Worker remediation. Native child termination, released reservation/writer, and remaining cumulative unit budget are required; this does not change acceptance or scope.",
1546
+ args: { unit_id: stringSchema }, execute: async (args, context) => {
1547
+ const { root, mission } = await missionAuthority(context.sessionID);
1548
+ if (context.sessionID !== mission.coordinator || (await identity(context.sessionID)).role !== "dog-operator") {
1549
+ throw new Error("mission-remediation-coordinator-required");
1550
+ }
1551
+ const run = await operators.required(root), unitID = String(args.unit_id);
1552
+ const unit = run.units.find(item => item.unit.id === unitID);
1553
+ const attempt = [...(mission.attempts ?? [])].reverse().find(item => item.runID === run.runID && item.unitID === unitID);
1554
+ if (!unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || unit.failure?.outcome !== "fail" ||
1555
+ unit.normalRemediationUsed || !attempt || attempt.kind !== "implementation" || attempt.status !== "failed" ||
1556
+ attempt.failure?.category !== "implementation")
1557
+ throw new Error("operator-mission-normal-remediation-unavailable");
1558
+ const terminal = await missionWorkerTerminalProof(root, mission, run, attempt);
1559
+ if (terminal.status !== "ready") {
1560
+ const packet = missionPacket(mission, run);
1561
+ return JSON.stringify({ ...packet, run_status: packet.status, status: "non_rescue", reason: terminal.reason });
1562
+ }
1563
+ const budget = await control.currentBudget(root);
1564
+ if (!budget || budget.remaining_units < 1) {
1565
+ const packet = missionPacket(mission, run);
1566
+ return JSON.stringify({ ...packet, run_status: packet.status, status: "non_rescue", reason: "budget_exhausted", budget });
1567
+ }
1568
+ const prepared = await operators.prepareMissionNormalRemediation(root, run.runID, unitID);
1569
+ control.enableUnits(root, 1);
1570
+ const next = await operators.next(root, context.sessionID);
1571
+ return JSON.stringify({ status: "normal_remediation_prepared", run_id: prepared.runID,
1572
+ unit_id: unitID, task: record(next) ? next.task : undefined, budget: await control.currentBudget(root) });
1573
+ } };
1574
+ tools[rescueMissionUnit] = { description: "Coordinator: only after the same unit's one ordinary remediation failed the same declared validation, evaluate one optional Astra Rescue. The host requires the current Mission failure class, reconciled native terminal, exact current candidate/acceptance/scope/validation, discovered model availability, one-use and cumulative budget; the returned Worker must still pass ordinary validation, independent review, and root acceptance. This is not the legacy sortie_execute_terminal_rescue tool.",
1575
+ args: { unit_id: stringSchema }, execute: async (args, context) => {
1576
+ const { root, mission } = await missionAuthority(context.sessionID);
1577
+ if (context.sessionID !== mission.coordinator || (await identity(context.sessionID)).role !== "dog-operator") {
1578
+ throw new Error("mission-rescue-coordinator-required");
1579
+ }
1580
+ const run = await operators.required(root), unitID = String(args.unit_id);
1581
+ const unit = run.units.find(item => item.unit.id === unitID);
1582
+ const attempt = [...(mission.attempts ?? [])].reverse().find(item => item.runID === run.runID && item.unitID === unitID);
1583
+ const nonRescue = async (reason) => {
1584
+ await missions.update(root, state => {
1585
+ state.rescue = { attemptID: attempt?.attemptID ?? randomUUID(),
1586
+ runID: run.runID, unitID, status: "non_rescue", reason };
1587
+ });
1588
+ const packet = missionPacket(await missions.required(root), await operators.read(root));
1589
+ return JSON.stringify({ ...packet, run_status: packet.status, status: "non_rescue", reason });
1590
+ };
1591
+ if (!unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || unit.failure?.outcome !== "fail" ||
1592
+ unit.normalRemediationUsed !== true || unit.terminalRescue || !attempt || attempt.kind !== "normal_remediation" ||
1593
+ attempt.status !== "failed" || attempt.resultClass !== "acceptance" || attempt.failure?.category !== "implementation") {
1594
+ return nonRescue("normal_remediation_not_exhausted");
1595
+ }
1596
+ const terminal = await missionWorkerTerminalProof(root, mission, run, attempt);
1597
+ if (terminal.status !== "ready")
1598
+ return nonRescue(terminal.reason);
1599
+ const candidate = await missionReviewSource(input.directory, run, [], mission.reviewBaseline, mission.reviewScope);
1600
+ if (!attempt.candidateID || attempt.candidateID !== candidate.fingerprint)
1601
+ return nonRescue("accepted_candidate_changed");
1602
+ const budget = await control.currentBudget(root);
1603
+ if (!budget || budget.reserved_units !== 0)
1604
+ return nonRescue("budget_exhausted");
1605
+ const budgetDetails = budget;
1606
+ const hasTimeLimit = typeof budgetDetails.time_limit_ms === "number";
1607
+ const timeLimit = hasTimeLimit ? budgetDetails.time_limit_ms : Number.MAX_SAFE_INTEGER;
1608
+ const consumedTime = hasTimeLimit ? budgetDetails.settled_time_ms : 0;
1609
+ const hasCostLimit = typeof budgetDetails.cost_limit_usd === "number";
1610
+ const costLimit = hasCostLimit ? budgetDetails.cost_limit_usd : Number.MAX_VALUE;
1611
+ const consumedCost = hasCostLimit ? budgetDetails.settled_cost_usd : (budgetDetails.settled_cost_usd ?? 0);
1612
+ if (!Number.isSafeInteger(timeLimit) || timeLimit < 0 || !Number.isSafeInteger(consumedTime) || consumedTime < 0 ||
1613
+ !Number.isFinite(costLimit) || costLimit < 0 || typeof consumedCost !== "number" || !Number.isFinite(consumedCost) || consumedCost < 0) {
1614
+ return nonRescue("budget_exhausted");
1615
+ }
1616
+ if ((hasTimeLimit && consumedTime >= timeLimit) || (hasCostLimit && consumedCost >= costLimit)) {
1617
+ return nonRescue("budget_exhausted");
1618
+ }
1619
+ const nativeModels = await readHostModels(input.client);
1620
+ const target = nativeModels?.has(DEFAULT_TERMINAL_RESCUE_MODEL)
1621
+ ? { model: DEFAULT_TERMINAL_RESCUE_MODEL, variant: null } : null;
1622
+ const terminalIdentity = { run_id: run.runID, unit_id: unitID, attempt_id: attempt.attemptID,
1623
+ predecessor_attempt_id: attempt.predecessorAttemptID ?? null, candidate_id: candidate.fingerprint,
1624
+ route_id: `${profile.id}:mission:${unitID}`, child_id: terminal.child, call_id: attempt.callID };
1625
+ const priorFailure = attempt.failure;
1626
+ const policy = proposeTerminalRescue({ prior_attempt: { identity: terminalIdentity, role: "implementation", recovery_kind: "normal_remediation",
1627
+ disposition: "failed", failure: priorFailure ? { category: priorFailure.category, code: priorFailure.code } : null },
1628
+ terminal_reconciliation: { current: terminalIdentity, observation: { identity: terminalIdentity, disposition: "failed" }, evidence: {
1629
+ terminal: "satisfied", tools_quiescent: "satisfied", artifact_window_closed: "satisfied", writer_released: "satisfied",
1630
+ gate_released: "satisfied", lease_released: "satisfied", worktree_released: "satisfied"
1631
+ } },
1632
+ accepted_base: { candidate_id: candidate.fingerprint, contract_id: run.acceptanceFingerprint, scope: [...unit.unit.write],
1633
+ acceptance: [...run.acceptance], validation: [...unit.unit.validation] }, available_target: target,
1634
+ explicit_override: (attempt.selectedModel !== undefined && attempt.selectedModel !== PREVIEW_WORKER_ROUTE.model) ||
1635
+ (attempt.observedModel !== undefined && attempt.observedModel !== PREVIEW_WORKER_ROUTE.model) ||
1636
+ (attempt.observedVariant !== undefined && attempt.observedVariant !== PREVIEW_WORKER_ROUTE.variant),
1637
+ unit_rescue_count: (mission.attempts ?? []).filter(item => item.runID === run.runID && item.unitID === unitID && item.kind === "astra_rescue").length,
1638
+ active_run_rescue_count: (mission.attempts ?? []).filter(item => item.runID === run.runID && item.kind === "astra_rescue").length,
1639
+ budget_limits: { counters: { recovery_actions: budget.max_units, probe_iterations: 0, model_attempts: budget.max_units },
1640
+ resources: { time_ms: timeLimit, cost_usd: costLimit } },
1641
+ budget_consumed: { counters: { recovery_actions: budget.consumed_units + budget.reserved_units, probe_iterations: 0,
1642
+ model_attempts: budget.consumed_units + budget.reserved_units },
1643
+ resources: { time_ms: consumedTime, cost_usd: consumedCost } },
1644
+ budget_request: { counters: { recovery_actions: 1, probe_iterations: 0, model_attempts: 1 },
1645
+ // Current Mission charges native time/cost through its cumulative goal ledger at normal dispatch settlement;
1646
+ // this policy reservation consumes one existing unit, not an invented parallel resource allowance.
1647
+ resources: { time_ms: 0, cost_usd: 0 } } });
1648
+ if (policy.status !== "proposed")
1649
+ return nonRescue(policy.reason);
1650
+ const rescueAttemptID = randomUUID();
1651
+ const prepared = await operators.prepareMissionTerminalRescue(root, run.runID, unitID, {
1652
+ attempt_id: rescueAttemptID, selected_model: policy.proposal.target.model,
1653
+ selected_variant: policy.proposal.target.variant,
1654
+ accepted_base: { candidate_id: policy.proposal.accepted_base_candidate_id, contract_id: policy.proposal.contract_id,
1655
+ scope: policy.proposal.scope, acceptance: policy.proposal.acceptance, validation: policy.proposal.validation },
1656
+ });
1657
+ control.enableUnits(root, 1);
1658
+ await missions.update(root, state => {
1659
+ state.rescue = { attemptID: rescueAttemptID, runID: run.runID, unitID,
1660
+ selectedModel: policy.proposal.target.model, selectedVariant: policy.proposal.target.variant, status: "prepared" };
1661
+ });
1662
+ const next = await operators.next(root, context.sessionID);
1663
+ return JSON.stringify({ status: "prepared", rescue: { attempt_id: rescueAttemptID, selected_model: policy.proposal.target.model,
1664
+ selected_variant: policy.proposal.target.variant, accepted_base_candidate_id: policy.proposal.accepted_base_candidate_id,
1665
+ obligations: policy.proposal.obligations }, run_id: prepared.runID, unit_id: unitID,
1666
+ task: record(next) ? next.task : undefined, budget: await control.currentBudget(root) });
1667
+ } };
1378
1668
  async function declareMissionUnits(root, actor, mission, raw, reason, execution) {
1379
1669
  const previous = await operators.read(root);
1670
+ if (sourceReconciliationRequired(mission, previous))
1671
+ return JSON.stringify({ ...missionDispatchPacket(mission, previous),
1672
+ next_action: `Coordinator: do not repeat plan_units. Call ${submitMission} with status=blocked and report the saved ` +
1673
+ `requirements to Operator. Operator can relink this mission in place via ${startMission} intent=replace ` +
1674
+ `when they match the user's changed scope; no Worker can start before that decision.` });
1380
1675
  mission = await retainCancelledMissionAcceptance(root, mission, previous);
1381
1676
  let operation = mission.execution;
1382
1677
  // Models can serialize an optional operation field as an empty object for a normal edit.
@@ -1583,7 +1878,8 @@ export function createProfiledPlugin(profile, assetVersion) {
1583
1878
  await missions.update(context.sessionID, state => { state.phase = "completed"; });
1584
1879
  return result;
1585
1880
  } };
1586
- const ownTools = new Set([startMission, planUnits, expandUnit, reviewMission, submitMission, completeMission,
1881
+ const ownTools = new Set([startMission, planUnits, expandUnit, reviewMission, submitMission, completeMission, skipMissionConsultation,
1882
+ retryMissionUnit, rescueMissionUnit,
1587
1883
  prepare, repair, next, status, cancel, complete, resume, resolveContractRepair,
1588
1884
  beginProposal, submitProposal, approveProposal, reviseProposal, extendProposalBudget, reopenProposalScope,
1589
1885
  reconcileOrphan, reviseApprovedIntent]);
@@ -1798,17 +2094,36 @@ export function createProfiledPlugin(profile, assetVersion) {
1798
2094
  });
1799
2095
  }
1800
2096
  textParts[0].text = admitted.prompt;
2097
+ const current = await operators.required(root);
2098
+ const activeUnit = current.units.find(unit => unit.status === "running" && unit.childSessionID === chat.sessionID);
2099
+ if (activeUnit?.terminalRescue)
2100
+ missionRescueSelections.set(chat.sessionID, {
2101
+ attemptID: activeUnit.terminalRescue.attempt_id,
2102
+ model: activeUnit.terminalRescue.selected_model, variant: activeUnit.terminalRescue.selected_variant,
2103
+ });
1801
2104
  }
1802
2105
  }
1803
2106
  const mapped = translate(output, false);
1804
- await core["chat.message"]?.(translate(chat, false), mapped);
2107
+ const mappedChat = translate(chat, false);
2108
+ const rescueSelection = role === "dog-worker" ? missionRescueSelections.get(chat.sessionID) : undefined;
2109
+ if (rescueSelection)
2110
+ mappedChat.missionRescueSelection = { attempt_id: rescueSelection.attemptID,
2111
+ model: rescueSelection.model, variant: rescueSelection.variant };
2112
+ await core["chat.message"]?.(mappedChat, mapped);
1805
2113
  Object.assign(output, translate(mapped, true));
1806
2114
  if (role === "dog-worker")
1807
2115
  for (const part of output.parts) {
1808
2116
  if (record(part) && part.type === "text" && typeof part.text === "string")
1809
2117
  part.text = workerControlPrompt(part.text, true);
1810
2118
  }
1811
- if (role === "dog-worker" && explicitWorkerSelection) {
2119
+ if (rescueSelection) {
2120
+ const split = rescueSelection.model.indexOf("/");
2121
+ if (split <= 0)
2122
+ throw new Error("invalid-mission-terminal-rescue-model");
2123
+ output.message.model = { providerID: rescueSelection.model.slice(0, split), modelID: rescueSelection.model.slice(split + 1),
2124
+ ...(rescueSelection.variant === null ? {} : { variant: rescueSelection.variant }) };
2125
+ }
2126
+ else if (role === "dog-worker" && explicitWorkerSelection) {
1812
2127
  if (explicitWorkerSelection.model) {
1813
2128
  const split = explicitWorkerSelection.model.indexOf("/");
1814
2129
  if (split <= 0)
@@ -1907,6 +2222,11 @@ export function createProfiledPlugin(profile, assetVersion) {
1907
2222
  const mapped = translate(output, false);
1908
2223
  await core["tool.execute.before"]?.({ ...request, sessionID: root, agent: "dog-coordinator" }, mapped);
1909
2224
  Object.assign(output, translate(mapped, true));
2225
+ if ((consultRole === "dog-advisor" || consultRole === "dog-scout") && typeof args.prompt === "string") {
2226
+ const role = consultRole === "dog-advisor" ? "advisor" : "scout";
2227
+ await missions.recordConsultationDispatch(root, { role, callID: request.callID,
2228
+ ...missionConsultationDetails(role, args.prompt) });
2229
+ }
1910
2230
  taskOwners.set(request.callID, { root, actor: request.sessionID, operator: false, consultation: consultRole });
1911
2231
  return;
1912
2232
  }
@@ -1995,6 +2315,13 @@ export function createProfiledPlugin(profile, assetVersion) {
1995
2315
  throw new Error("operator-worker-task-required");
1996
2316
  let expandedWorker;
1997
2317
  if (delegated) {
2318
+ const pendingUnit = state.units.find(unit => unit.status !== "succeeded");
2319
+ if (pendingUnit?.terminalRescue) {
2320
+ const requiredModel = pendingUnit.terminalRescue.selected_model;
2321
+ if (args.model !== undefined && args.model !== requiredModel)
2322
+ throw new Error("operator-mission-terminal-rescue-model-mismatch");
2323
+ args.model = requiredModel;
2324
+ }
1998
2325
  if (!mission || mission.runID !== state.runID)
1999
2326
  await proposals.assertExecutionApproved(root, state.planHash);
2000
2327
  if (!mission || mission.runID !== state.runID)
@@ -2080,6 +2407,27 @@ export function createProfiledPlugin(profile, assetVersion) {
2080
2407
  }
2081
2408
  throw error;
2082
2409
  }
2410
+ if (delegated && mission?.runID === state.runID) {
2411
+ const admitted = await operators.required(root);
2412
+ const unit = admitted.units.find(candidate => candidate.callID === request.callID);
2413
+ const taskID = unit && /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1];
2414
+ if (unit && taskID) {
2415
+ const previousAttempt = [...(mission.attempts ?? [])].reverse().find(attempt => attempt.runID === admitted.runID && attempt.unitID === unit.unit.id);
2416
+ const kind = unit.terminalRescue ? "astra_rescue"
2417
+ : unit.normalRemediationUsed ? "normal_remediation" : "implementation";
2418
+ await missions.update(root, current => {
2419
+ current.attempts ??= [];
2420
+ if (current.attempts.some(attempt => attempt.callID === request.callID))
2421
+ return;
2422
+ current.attempts.push({ attemptID: unit.terminalRescue?.attempt_id ?? randomUUID(), runID: admitted.runID,
2423
+ unitID: unit.unit.id, taskID, ...(previousAttempt ? { predecessorAttemptID: previousAttempt.attemptID } : { predecessorAttemptID: null }),
2424
+ kind, status: "dispatched", callID: request.callID,
2425
+ ...(unit.terminalRescue ? { selectedModel: unit.terminalRescue.selected_model } :
2426
+ typeof args.model === "string" ? { selectedModel: args.model } : {}) });
2427
+ current.attempts = current.attempts.slice(-64);
2428
+ });
2429
+ }
2430
+ }
2083
2431
  },
2084
2432
  "tool.execute.after": async (request, output) => {
2085
2433
  const id = request.sessionID;
@@ -2124,6 +2472,16 @@ export function createProfiledPlugin(profile, assetVersion) {
2124
2472
  : `\n\nHOST: evidence-gap review ${count}/${MISSION_EVIDENCE_GAP_REVIEW_LIMIT}. Supply focused original-file excerpts through review_mission evidence and traces. Do not create an evidence-copying Worker or re-implement.`;
2125
2473
  }
2126
2474
  }
2475
+ else if (ownership.consultation === "dog-advisor" || ownership.consultation === "dog-scout") {
2476
+ const child = taskChildSessionID(output);
2477
+ const observed = child ? await observedSessionModel(child) : { outcome: "unknown" };
2478
+ await missions.settleConsultation(ownership.root, request.callID, observed.outcome, {
2479
+ ...(child === undefined ? {} : { childSessionID: child }),
2480
+ ...(observed.model === undefined ? {} : { observedModel: observed.model }),
2481
+ ...(observed.variant === undefined ? {} : { observedVariant: observed.variant }),
2482
+ ...(typeof raw === "string" ? { result: raw } : {}),
2483
+ });
2484
+ }
2127
2485
  taskOwners.delete(request.callID);
2128
2486
  return;
2129
2487
  }
package/dist/plugin/v2.js CHANGED
@@ -492,7 +492,7 @@ async function registerV2Hooks(context, hooks) {
492
492
  if (record(event.tools)) {
493
493
  const visible = {
494
494
  "dog-operator": ["start_mission", "plan_units", "operator_next", "operator_status", "expand_unit", "review_mission", "complete_mission", "cancel_operator", "reflection"],
495
- "dogs-coordinator": ["plan_units", "operator_next", "operator_status", "expand_unit", "review_mission", "submit_mission"],
495
+ "dogs-coordinator": ["plan_units", "operator_next", "operator_status", "expand_unit", "review_mission", "submit_mission", "skip_mission_consultation", "retry_mission_unit", "rescue_mission_unit"],
496
496
  "dog-worker-v010": ["bind_write_gate", "release_write_gate", "operator_status"],
497
497
  "dog-luna-worker-v010": ["bind_write_gate", "release_write_gate", "operator_status"],
498
498
  "dog-reviewer-v010": [], "dog-scout-v010": [], "dog-advisor-v010": [],
@@ -56,6 +56,13 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
56
56
  When the user replaces a version, target, parallelism or other requirement, call start_mission with
57
57
  intent: "replace" and the complete current requirements. The host cancels/archives the old run and
58
58
  retains spend/results; superseded instructions are history, not additional obligations.
59
+ If status/start_mission reports mission-source-reconciliation-required, do not dispatch its Task
60
+ or declare the user's work impossible. Compare the saved requirements with the user's current
61
+ scope. When they reflect an already requested narrowing/change, call start_mission with intent:
62
+ "replace" and the exact saved requirements. The host links the cancelled predecessor to the
63
+ SAME mission before any Worker starts, retains its Coordinator and cumulative spend, and checks
64
+ old children before preparing a Worker. This is not permission to discard unchanged acceptance:
65
+ if the scope is uncertain, ask the user which requirements remain instead of inferring a replacement.
59
66
  2. Dispatch the returned ${profileAgent(profile, "dog-operator")} task immediately. It owns investigation,
60
67
  unit boundaries, Worker/Scout/Advisor/independent Reviewer calls, write-scope extensions and corrections
61
68
  within the original request and cumulative budget. Do not investigate or approve each unit at the root.
@@ -135,7 +142,7 @@ permission:
135
142
  ${profileAgent(profile, "dog-advisor")}: allow
136
143
  tools:
137
144
  "sortie_*": false
138
- ${controls(profile, ["plan_units", "operator_next", "operator_status", "expand_unit", "review_mission", "submit_mission"])}
145
+ ${controls(profile, ["plan_units", "operator_next", "operator_status", "expand_unit", "review_mission", "submit_mission", "skip_mission_consultation", "retry_mission_unit", "rescue_mission_unit"])}
139
146
  ---
140
147
  # ${profileAgent(profile, "dog-operator")}
141
148
 
@@ -190,6 +197,10 @@ ${profileAgent(profile, "dog-worker")} task verbatim, in foreground. V2 maps sub
190
197
  task_id to sessionID. Do not insert model overrides unless the user explicitly selected them.
191
198
 
192
199
  After each Worker returns, use its actual report and host evidence. Continue pending units with operator_next.
200
+ If plan_units returns mission-source-reconciliation-required, do not retry the same plan. The
201
+ root-only Operator must reconcile the prior cancelled run. Submit status=blocked with the saved
202
+ requirements and exact host diagnostic, then return; do not declare a user-only decision when
203
+ the current request already narrowed the old scope.
193
204
  For a needed write-scope addition within the original request, call expand_unit with unit_id, paths and
194
205
  reason; the host returns a replacement contract without Operator approval. For a changed approach, formal
195
206
  check or reviewer finding, call plan_units with the corrected units and a short observed reason. Replanning
@@ -213,6 +224,22 @@ the header, correct and redispatch this same consultation once before proceeding
213
224
  do not investigate runtime policy or bounce the question to Operator. Use your existing evidence
214
225
  and ask a bounded question in the user's language; do not send generic exploratory delegations.
215
226
  If the user explicitly requested Advisor input before a decision, do not treat it as optional.
227
+ Actual Advisor/Scout dispatches are recorded in operator_status with their trigger/code, bounded question,
228
+ observed model and native outcome. When you considered a concrete decision or missing fact but existing
229
+ evidence makes consultation unnecessary, record the role and concise skip reason with
230
+ ${profile.toolPrefix}skip_mission_consultation. Record only meaningful considered skips, not a generic
231
+ "not needed" for each unit. This is observation only: it neither requires consultation nor adds approval.
232
+
233
+ After a Mission Worker returns a host-classified failed declared validation (not a Task launch error, contract
234
+ defect, cancellation, or unknown outcome), you may call ${profile.toolPrefix}retry_mission_unit once for that unit.
235
+ It reuses the exact scope, acceptance and validation under the ordinary cumulative budget. If that same normal
236
+ remediation then fails the same declared validation and native termination/writer release are confirmed, you may
237
+ call ${profile.toolPrefix}rescue_mission_unit once. The host records the Astra model actually selected, or a
238
+ specific non_rescue reason; do not expose or substitute the legacy sortie_execute_terminal_rescue capability.
239
+ Rescue is still a normal current-Mission Worker dispatch: its declared validation must pass, then the existing
240
+ independent review, final evidence check and root complete_mission acceptance remain mandatory. A Worker return
241
+ or rescue dispatch alone is never success. On non_rescue, continue the ordinary correction/replan within the same
242
+ requirements and remaining budget; never bypass a failure class or create another run to reset spend.
216
243
 
217
244
  After formal validation, call review_mission with risk_tags and one concise implementation/test trace per
218
245
  requirement. For changed failure behavior, connect the operation and concrete input to the contract-derived
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.19",
3
+ "version": "0.12.20",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",