sortie-dogs 0.13.21 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,13 +19,13 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.13.21](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.21)**
23
- · [Release notes](docs/release-v0.13.21.md)
22
+ **Current release: [v0.14.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.14.0)**
23
+ · [Release notes](docs/release-v0.14.0.md) · [Execution vision](docs/v0.14.0-execution-vision.md)
24
24
 
25
25
  [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
26
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
27
27
 
28
- > **Beta:** the v0.13.x package is still stabilizing before 1.0. Both host integrations are
28
+ > **Beta:** the package is still stabilizing before 1.0. Both host integrations are
29
29
  > implemented; Codex Missions have completed implementation, validation, correction and acceptance.
30
30
  > [Host-specific execution boundaries](docs/codex.md#native-permissions-and-approval) remain explicit.
31
31
 
@@ -35,9 +35,9 @@ evidence-backed result, not more agents for their own sake.
35
35
 
36
36
  - **Autonomy:** retain the original request across delegation, correction, compaction and restart.
37
37
  Finish in-request work through the existing host authority instead of routine approval round trips.
38
- - **Efficiency:** small work stays small. Use a direct Worker for a known single unit; add
39
- Coordinator, Scout or Advisor only when discovery or task shape calls for them. Reuse valid
40
- unchanged evidence instead of repeating successful work.
38
+ - **Efficiency:** Operator recommends an initial author from the instruction and known context.
39
+ Coordinator can implement substantial work directly or delegate remaining work as execution makes
40
+ that useful. Continue the same unit with its edits, valid evidence and cumulative budget.
41
41
  - **Visibility:** show real commands, exits, timing, model routes, review disposition and final
42
42
  acceptance. Unknown execution or cost stays unknown, not an invented success or zero.
43
43
  - **Quality:** meaningful formal checks and risk-based review establish completion, not model prose.
@@ -57,7 +57,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
57
57
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
58
58
 
59
59
  ```sh
60
- npm install --save-dev sortie-dogs@0.13.21
60
+ npm install --save-dev sortie-dogs@0.14.0
61
61
  npx --no-install sortie-dogs codex init .
62
62
  ```
63
63
 
@@ -86,7 +86,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
86
86
  Use **OpenCode V2**. In the target project:
87
87
 
88
88
  ```sh
89
- npm install --save-dev sortie-dogs@0.13.21
89
+ npm install --save-dev sortie-dogs@0.14.0
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -108,14 +108,16 @@ v0.10 is installed. [OpenCode setup and configuration](docs/configuration.md) ·
108
108
 
109
109
  ```text
110
110
  Known unit: User → Operator → Worker → Validation → Review / Correction → Acceptance
111
- Discovery: User → Operator → Coordinator → Worker → Validation → Review / Correction → Acceptance
111
+ Direct: User → Operator → Coordinator → Validation → Review / Correction → Acceptance
112
+ Handoff: Coordinator → Worker (same unit, retained edits/context/checks/budget)
112
113
  ```
113
114
 
114
- 1. **Operator** saves original requirements and owns user decisions and final acceptance.
115
+ 1. **Operator** saves original requirements, recommends an initial executor from already-known
116
+ context, and owns user decisions and final acceptance. No routing-only investigation is required.
115
117
  2. **Worker** investigates, implements, runs meaningful checks and performs requested Git delivery
116
118
  in the same useful unit. A known unit/check can go directly to Worker without a planning handoff.
117
- 3. **Coordinator**, when needed, investigates or decomposes work, reconciles in-request scope and
118
- dispatches units. It can also implement and validate directly.
119
+ 3. **Coordinator** starts useful work, implements and validates directly when efficient, and
120
+ delegates remaining work when useful. The Operator recommendation is advisory, not an execution lock.
119
121
  4. **Review** is initially independent for high-risk changes; low-risk skips are explicit.
120
122
  Findings can be corrected and validated by the same Reviewer, followed by recorded author
121
123
  self-recheck. That is not a second independent PASS.
@@ -193,7 +195,7 @@ known expense. Official local evaluation and leaderboard acceptance are separate
193
195
  **8/23 (34.8%)**, versus v0.12.25's 7/23; all seven prior resolutions retained, no empty patches or
194
196
  official evaluation errors. [Fixed conditions, stopped-run accounting and costs](docs/benchmarks/swebench-lite-history.md#v0131-dev23-2026-09-30).
195
197
 
196
- All scores belong to their fixed candidates, **not v0.13.21**. SWE-bench remains an optional
198
+ All scores belong to their fixed candidates, **not v0.14.0**. SWE-bench remains an optional
197
199
  measurement, not a release gate. Different budgets, models and conditions are not controlled comparisons.
198
200
 
199
201
  ## Configuration
@@ -216,7 +218,7 @@ Install the desired package version and rerun **your host's** initializer:
216
218
 
217
219
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
218
220
  update it. [Global/update instructions](docs/configuration.md#global-availability).
219
- The current Mission asset marker is `0.13.21-operation-recovery-v1`; the unchanged Codex skill marker is
221
+ The current Mission asset marker is `0.14.0-autonomous-routing-v1`; the unchanged Codex skill marker is
220
222
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
221
223
 
222
224
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -232,7 +234,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
232
234
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
233
235
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
234
236
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
235
- - **Releases:** [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
237
+ - **Releases:** [v0.14.0](docs/release-v0.14.0.md) · [v0.13.22](docs/release-v0.13.22.md) · [v0.13.21](docs/release-v0.13.21.md) · [v0.13.20](docs/release-v0.13.20.md) · [v0.13.19](docs/release-v0.13.19.md) · [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
236
238
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
237
239
 
238
240
  ## Community
@@ -3,6 +3,6 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.13.21-operation-recovery-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.14.0-autonomous-routing-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.13.21-operation-recovery-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.14.0-autonomous-routing-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -47,7 +47,7 @@ export async function runCodexMissionCommand(argv) {
47
47
  const result = await adapter.run(prompt);
48
48
  process.stdout.write(`${JSON.stringify({ status: result.accepted ? "succeeded" : "incomplete", root_session_id: result.rootSessionID,
49
49
  thread_id: result.turn.threadID, turn_id: result.turn.turnID, native_status: result.turn.status,
50
- response: result.turn.finalResponse, sessions: result.sessions, cost: null })}\n`);
50
+ response: result.turn.finalResponse, lifecycle: result.lifecycle, sessions: result.sessions, cost: null })}\n`);
51
51
  return signalExit ?? (result.accepted ? 0 : 1);
52
52
  }
53
53
  catch (error) {
@@ -66,6 +66,11 @@ export async function runCodexMissionCommand(argv) {
66
66
  }
67
67
  /** Bounded user-facing progress; do not dump control packets or hidden reasoning. */
68
68
  export function codexMissionProgress(event) {
69
+ if (event.method === "turn/completed")
70
+ return { thread_id: event.threadId, phase: "native-turn-terminal", turn_id: event.params.turn?.id,
71
+ native_status: event.params.turn?.status };
72
+ if (event.method === "sortie/continuation")
73
+ return { thread_id: event.threadId, turn_id: event.params.turnId, phase: event.params.phase, calls: event.params.calls };
69
74
  if (event.method === "sortie/modelRoute")
70
75
  return { thread_id: event.threadId, phase: "model-route", agent: event.params.agent, model: event.params.model,
71
76
  effort: event.params.effort, service_tier: event.params.serviceTier };
@@ -46,6 +46,14 @@ export interface CodexTurnResult {
46
46
  readonly finalResponse?: string;
47
47
  readonly items: readonly JsonObject[];
48
48
  readonly usage?: JsonObject;
49
+ /** Host tools still running at native turn completion, drained without resending them. */
50
+ readonly deferredToolResults?: readonly CodexDeferredToolResult[];
51
+ }
52
+ export interface CodexDeferredToolResult {
53
+ readonly callID: string;
54
+ readonly tool: string;
55
+ readonly success: boolean;
56
+ readonly output: string;
49
57
  }
50
58
  export interface CodexAuthenticationState {
51
59
  readonly type: string | null;
@@ -188,7 +188,7 @@ export class CodexAppServerHost {
188
188
  if (!turnID)
189
189
  throw new CodexHostError("protocol-invalid-response", "turn/start returned no turn id.");
190
190
  return new Promise((resolve, reject) => {
191
- this.active = { threadID, turnID, items: [], observer: Promise.resolve(), onEvent: options.onEvent, resolve, reject };
191
+ this.active = { threadID, turnID, items: [], observer: Promise.resolve(), tools: new Set(), onEvent: options.onEvent, resolve, reject };
192
192
  const backlog = this.notificationBacklog.splice(0);
193
193
  void backlog.reduce((prior, event) => prior.then(() => this.notification(event.method, event.params)), Promise.resolve())
194
194
  .catch(error => { this.active?.reject(error); this.active = undefined; });
@@ -289,20 +289,34 @@ export class CodexAppServerHost {
289
289
  const params = object(message.params) ? message.params : {};
290
290
  if (method === "item/tool/call") {
291
291
  const active = this.active;
292
- let result;
292
+ if (!active || active.completing || params.threadId !== active.threadID || params.turnId !== active.turnID ||
293
+ (params.namespace != null) || typeof params.callId !== "string" || typeof params.tool !== "string" || !this.options.dynamicTool) {
294
+ this.transport.send({ id: message.id, result: { contentItems: [{ type: "inputText",
295
+ text: "Dynamic tool call does not belong to an active Sortie turn." }], success: false } });
296
+ return;
297
+ }
298
+ const call = { threadId: active.threadID, turnId: active.turnID, callId: params.callId, tool: params.tool, arguments: params.arguments };
299
+ const pending = Promise.resolve().then(async () => {
300
+ let output, success;
301
+ try {
302
+ output = await this.options.dynamicTool(call);
303
+ success = true;
304
+ }
305
+ catch (error) {
306
+ output = error instanceof Error ? error.message : "Dynamic tool failed.";
307
+ success = false;
308
+ }
309
+ if (!this.closed && !this.fault)
310
+ this.transport.send({ id: message.id, result: { contentItems: [{ type: "inputText", text: output }], success } });
311
+ return { callID: call.callId, tool: call.tool, success, output };
312
+ });
313
+ active.tools.add(pending);
293
314
  try {
294
- if (!active || active.completing || params.threadId !== active.threadID || params.turnId !== active.turnID ||
295
- (params.namespace != null) || typeof params.callId !== "string" || typeof params.tool !== "string" || !this.options.dynamicTool)
296
- throw new Error("Dynamic tool call does not belong to an active Sortie turn.");
297
- const output = await this.options.dynamicTool({ threadId: active.threadID, turnId: active.turnID,
298
- callId: params.callId, tool: params.tool, arguments: params.arguments });
299
- result = { contentItems: [{ type: "inputText", text: output }], success: true };
315
+ await pending;
300
316
  }
301
- catch (error) {
302
- result = { contentItems: [{ type: "inputText", text: error instanceof Error ? error.message : "Dynamic tool failed." }], success: false };
317
+ finally {
318
+ active.tools.delete(pending);
303
319
  }
304
- if (!this.closed)
305
- this.transport.send({ id: message.id, result });
306
320
  return;
307
321
  }
308
322
  if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
@@ -374,14 +388,23 @@ export class CodexAppServerHost {
374
388
  // requests such as interrupt(), whose response must be read by this pump.
375
389
  // Keep the completing turn active until observers settle so close() can
376
390
  // reject it and a later turn cannot receive an interrupt meant for it.
377
- void active.observer.then(() => {
391
+ const deferred = status === "completed" ? [...active.tools] : [];
392
+ // Native response completion is not host execution completion. Keep pumping
393
+ // command responses and child Tasks; explicit close/transport loss can still
394
+ // reject this turn. Failed/interrupted native turns never auto-continue.
395
+ void Promise.all([active.observer, Promise.all(deferred)]).then(([, tools]) => {
378
396
  if (this.active !== active)
379
397
  return;
380
398
  this.active = undefined;
381
399
  if (active.observerError)
382
400
  active.reject(new CodexHostError("request-failed", "Codex turn event observer failed.", active.observerError));
383
401
  else
384
- active.resolve(result);
402
+ active.resolve({ ...result, ...(tools.length ? { deferredToolResults: tools } : {}) });
403
+ }).catch(error => {
404
+ if (this.active !== active)
405
+ return;
406
+ this.active = undefined;
407
+ active.reject(error);
385
408
  });
386
409
  }
387
410
  }
@@ -67,6 +67,12 @@ export declare class CodexMissionSession {
67
67
  rootSessionID: string;
68
68
  turn: CodexTurnResult;
69
69
  accepted: boolean;
70
+ lifecycle: {
71
+ root_turn_terminal: boolean;
72
+ owned_tasks_settled: boolean;
73
+ mission_phase: string | null;
74
+ mission_accepted: boolean;
75
+ };
70
76
  sessions: readonly JsonObject[];
71
77
  }>;
72
78
  private stopHosts;
@@ -85,6 +91,7 @@ export declare class CodexMissionSession {
85
91
  private restoreSession;
86
92
  private tools;
87
93
  private prompt;
94
+ private promptTurn;
88
95
  private execute;
89
96
  }
90
97
  export {};
@@ -72,8 +72,11 @@ export class CodexMissionSession {
72
72
  const previous = await new OperatorMissionRuntime(this.directory, profile).read(session.id);
73
73
  const turn = await this.prompt(session, prompt);
74
74
  const mission = await new OperatorMissionRuntime(this.directory, profile).read(session.id);
75
- return { rootSessionID: session.id, turn, accepted: turn.status === "completed" && mission?.phase === "completed" &&
76
- (previous?.phase !== "completed" || previous.id !== mission.id), sessions: [...this.sessions.values()].map(item => ({ thread_id: item.id, agent: item.agent, model: item.model, usage: item.usage ?? null, usage_scope: "native-thread-cumulative", cost: null })) };
75
+ const ownedTasksSettled = [...this.sessions.values()].every(item => !["running", "unknown"].includes(item.outcome));
76
+ const accepted = turn.status === "completed" && ownedTasksSettled && mission?.phase === "completed" &&
77
+ (previous?.phase !== "completed" || previous.id !== mission.id);
78
+ return { rootSessionID: session.id, turn, accepted, lifecycle: { root_turn_terminal: ["completed", "failed", "interrupted", "cancelled"].includes(turn.status),
79
+ owned_tasks_settled: ownedTasksSettled, mission_phase: mission?.phase ?? null, mission_accepted: accepted }, sessions: [...this.sessions.values()].map(item => ({ thread_id: item.id, agent: item.agent, model: item.model, usage: item.usage ?? null, usage_scope: "native-thread-cumulative", cost: null })) };
77
80
  }
78
81
  async stopHosts() {
79
82
  this.closed = true;
@@ -459,11 +462,33 @@ export class CodexMissionSession {
459
462
  return tools;
460
463
  }
461
464
  async prompt(session, prompt, onDispatch) {
465
+ let result = await this.promptTurn(session, prompt, onDispatch);
466
+ while (result.status === "completed" && result.deferredToolResults?.length) {
467
+ if (this.closed)
468
+ throw new Error("Codex Mission adapter is closed.");
469
+ const mission = await new OperatorMissionRuntime(this.directory, profile).read(this.rootOf(session));
470
+ if (!mission || ["completed", "cancelled"].includes(mission.phase))
471
+ break;
472
+ await this.claimOwner(this.rootOf(session));
473
+ await this.options.onEvent?.({ method: "sortie/continuation", threadId: session.id,
474
+ params: { turnId: result.turnID, phase: "owned-tools-settled", calls: result.deferredToolResults.map(item => ({ callId: item.callID, tool: item.tool, success: item.success })) } });
475
+ result = await this.promptTurn(session, "The previous native response ended while existing host tools were still running. Those exact calls have now returned:\n" +
476
+ JSON.stringify(result.deferredToolResults) +
477
+ "\nContinue this same Mission from these results through the remaining validation, delivery, review and root acceptance. " +
478
+ "Do not resend the original request or redispatch the completed Tasks. Preserve failures, ownership and cumulative budget; use existing Mission status for the next action.", undefined, true);
479
+ }
480
+ return result;
481
+ }
482
+ async promptTurn(session, prompt, onDispatch, continuation = false) {
462
483
  const startedAt = Date.now();
463
484
  const messageID = randomUUID();
464
485
  const message = { id: messageID, agent: session.agent, model: { ...session.model } };
465
- const parts = [{ type: "text", text: prompt }];
466
- await this.hooks["chat.message"]?.({ sessionID: session.id, messageID, agent: session.agent }, { message, parts });
486
+ const parts = [{ type: "text", text: prompt, ...(continuation ? { synthetic: true } : {}) }];
487
+ // Only this host's drained results can continue an already-admitted execution.
488
+ // Reclaiming that Task with the result prompt rejects Workers' exact handoff;
489
+ // treating it as a real root request also grants spurious user-turn authority.
490
+ if (!continuation)
491
+ await this.hooks["chat.message"]?.({ sessionID: session.id, messageID, agent: session.agent }, { message, parts });
467
492
  // Shared Task routing may select a model; explicit adapter overrides still govern every native turn.
468
493
  if (message.model.providerID !== "openai")
469
494
  throw new Error("Codex Mission cannot use a non-OpenAI model route.");
@@ -204,6 +204,12 @@ export type GoalFlightEvent = (GoalEventBase & {
204
204
  readonly cost_usd: number | null;
205
205
  readonly native_session_id?: string;
206
206
  readonly native_started_at?: string;
207
+ /** Multiple actual authors of one handed-off unit; missing usage stays unknown. */
208
+ readonly native_usage_segments?: readonly {
209
+ readonly sessionID: string;
210
+ readonly startedAt: string;
211
+ readonly endedAt?: string;
212
+ }[];
207
213
  /** Host-native validation identity retained in this same goal ledger; never model-authored evidence. */
208
214
  readonly native_validation_observations?: readonly {
209
215
  readonly thread_id: string;
@@ -136,10 +136,12 @@ function classifyVersionTransition(installedValue, currentValue, v010Restore = f
136
136
  const adjacentPreOneLine = installed.major === 0 && current.major === 0 &&
137
137
  current.minor === installed.minor + 1;
138
138
  // v0.11 used a different runtime. The v010 asset migration restores the 0.10
139
- // preview into the 0.12/0.13 Mission line without selecting a v0.11 execution path.
139
+ // preview into the 0.12–0.14 Mission line without selecting a v0.11 execution path.
140
140
  const restoredPreview = v010Restore && installed.major === 0 && installed.minor === 10 &&
141
- current.major === 0 && (current.minor === 12 || current.minor === 13);
142
- return sameLine || adjacentPreOneLine || restoredPreview ? "compatible-update" : "incompatible";
141
+ current.major === 0 && [12, 13, 14].includes(current.minor);
142
+ const retainedMission = v010Restore && installed.major === 0 && installed.minor === 12 &&
143
+ current.major === 0 && current.minor === 14;
144
+ return sameLine || adjacentPreOneLine || restoredPreview || retainedMission ? "compatible-update" : "incompatible";
143
145
  }
144
146
  async function metadata(path) {
145
147
  try {
@@ -62,7 +62,7 @@ export interface MissionAttempt {
62
62
  /** Fingerprint of the settled scoped candidate against which a later Rescue is proposed. */
63
63
  candidateID?: string;
64
64
  kind: "implementation" | "normal_remediation" | "astra_rescue" | "reviewer_correction" | "direct_execution";
65
- status: "pending" | "dispatched" | "succeeded" | "failed" | "cancelled" | "unconfirmed";
65
+ status: "pending" | "dispatched" | "succeeded" | "failed" | "cancelled" | "unconfirmed" | "handed-off";
66
66
  callID?: string;
67
67
  childSessionID?: string;
68
68
  dispatchFingerprint?: string;
@@ -217,6 +217,10 @@ export interface OperatorMission {
217
217
  context?: MissionContext[];
218
218
  /** Shared public context/working notes; observations, never extra requirements or acceptance. */
219
219
  continuity?: MissionContinuity;
220
+ /** Operator recommendation from already-known context; not a requirement or execution lock. */
221
+ preferredExecutor?: "coordinator" | "worker";
222
+ /** Known unit facts passed to the Coordinator without pre-starting a Worker. */
223
+ suggestedUnit?: Record<string, unknown>;
220
224
  /** Explicit continue deliveries, keyed by the original real turn. */
221
225
  steering?: {
222
226
  requestID: string;
@@ -328,6 +328,7 @@ export class OperatorMissionRuntime {
328
328
  }
329
329
  }));
330
330
  return { original_requests: state.requests, requirements: state.requirements, prohibited_write: state.prohibitedWrite ?? [],
331
+ ...(state.preferredExecutor ? { preferred_executor: state.preferredExecutor } : {}),
331
332
  launch_conditions: state.launchConditions ?? [],
332
333
  conversation: sources.some(source => source.session_id === state.root && source.entries > 0 && !("snapshot_available" in source)) ? [] : state.context ?? [],
333
334
  continuity: { notes: state.continuity?.notes, sources, progress: state.progress,
@@ -709,6 +710,9 @@ export class OperatorMissionRuntime {
709
710
  return [`mission_id: ${state.id}`, `project_root: ${this.projectRoot}`, "Use the user's language below for all replies and Task titles.",
710
711
  "Own investigation, implementation, formal validation, corrections and Worker/Scout/Advisor/independent Reviewer dispatch.",
711
712
  "Use plan_units with executor=self for direct work in this session, or delegate promptly when useful. finish_direct_unit records native checks without a Worker handoff. No proposal/approval phase; root alone accepts completion.",
713
+ "Start useful work from the supplied context. Reconsider the recommendation through normal execution, not a separate routing investigation. You may implement substantial work here or delegate remaining work without asking Operator again; explicit user choices still govern.",
714
+ ...(state.preferredExecutor ? [`Operator recommendation (advisory): ${state.preferredExecutor}. This is not an immutable requirement or a model override.`] : []),
715
+ ...(state.suggestedUnit ? [`Known unit facts (reuse or adapt during actual work): ${JSON.stringify(state.suggestedUnit)}`] : []),
712
716
  "Escalate only a completion candidate, a user-only decision, or an extension of original requirements/budget. Unit progress is published without stopping you.",
713
717
  "Requirements:", ...state.requirements.map(item => `${item.id}: ${item.text}`),
714
718
  `Confirmed launch conditions (fixed limits, not consumption or remaining budget): ${JSON.stringify(state.launchConditions ?? [])}`,
@@ -932,6 +936,7 @@ export function missionPacket(mission, run, includeWorkQuantity = true) {
932
936
  normal_remediation_used: unit.normalRemediationUsed ?? false,
933
937
  ...(unit.failure ? { failure: { outcome: unit.failure.outcome, exit_code: unit.failure.exitCode } } : {}),
934
938
  ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
939
+ ...(mission.preferredExecutor ? { preferred_executor: mission.preferredExecutor } : {}),
935
940
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
936
941
  : mission.phase === "submitted" && mission.submission?.status === "ready"
937
942
  ? "Operator: use acceptance_summary to compare the submitted candidate with the verbatim original requests and actual evidence, inspect concrete gaps only, then complete_mission if satisfied. Do not routinely search archives or reread all source. Historical PASS is not current PASS. Report remaining evidence gaps; they are not a review PASS."
@@ -141,6 +141,12 @@ interface UnitState {
141
141
  validationCwd?: Readonly<Record<string, string>>;
142
142
  validationRegisteredAt?: Readonly<Record<string, string>>;
143
143
  };
144
+ /** Actual direct author segments retained when the same unit is handed to a Worker. */
145
+ directHandoffs?: (NonNullable<UnitState["directExecution"]> & {
146
+ callID: string;
147
+ handedOffAt: string;
148
+ settledCallID?: string;
149
+ })[];
144
150
  resultClass: string | null;
145
151
  failure?: SerialDispatchSettlement["failure"];
146
152
  normalRemediationUsed?: boolean;
@@ -372,6 +378,8 @@ export declare class OperatorRuntime {
372
378
  private nextOnce;
373
379
  admitWorker(root: string, actor: string, callID: string, args: unknown): Promise<OperatorTask>;
374
380
  admitDirect(root: string, actor: string, callID: string): Promise<OperatorState>;
381
+ /** Yield an unfinished ordinary direct unit; keep its contract, reservation and source history. */
382
+ handoffDirect(root: string, actor: string, context: Record<string, unknown>): Promise<OperatorState>;
375
383
  /** Continue an admitted independent Review in-place; no second native Task or prompt. */
376
384
  admitReviewerDirect(root: string, author: string, callID: string, promptID: string): Promise<OperatorState>;
377
385
  private admitWorkerOnce;
@@ -1454,6 +1454,37 @@ export class OperatorRuntime {
1454
1454
  return state;
1455
1455
  });
1456
1456
  }
1457
+ /** Yield an unfinished ordinary direct unit; keep its contract, reservation and source history. */
1458
+ handoffDirect(root, actor, context) {
1459
+ return this.serial(root, async () => {
1460
+ const state = await this.required(root);
1461
+ const unit = state.units.find(item => item.status !== "succeeded");
1462
+ if (actor !== (state.operatorSessionID ?? root) || state.phase !== "running" || !unit ||
1463
+ unit.status !== "running" || unit.directExecution?.actor !== actor || !unit.callID ||
1464
+ unit.directExecution.finishedAt || unit.reviewerCorrection || unit.repairValidation || unit.terminalRescue) {
1465
+ throw new Error("mission-direct-handoff-unavailable");
1466
+ }
1467
+ await this.verifyControls(unit);
1468
+ const segment = { ...structuredClone(unit.directExecution), callID: unit.callID, handedOffAt: new Date().toISOString() };
1469
+ // Keep the original controls immutable. Publish continuation context and the new
1470
+ // Task reference together in the atomic run save, so a crash has no half-updated pins.
1471
+ const continuation = { actor, unit_id: unit.unit.id, at: segment.handedOffAt,
1472
+ 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.",
1473
+ continuity: context.continuity,
1474
+ checks: segment.checks.map(({ callID, command, directory, exitCode, startedAt, endedAt }) => ({ callID, command, directory, exitCode, startedAt, endedAt })),
1475
+ validation_cwd: segment.validationCwd ?? unit.unit.validation_cwd, details_ref: this.statePath(root) };
1476
+ unit.task = { ...unit.task, prompt: `${unit.task.prompt.replace(/\ndirect_handoff: .+$/mu, "")}\ndirect_handoff: ${JSON.stringify(continuation)}` };
1477
+ (unit.directHandoffs ??= []).push(segment);
1478
+ delete unit.directExecution;
1479
+ unit.status = "pending";
1480
+ unit.childSessionID = null;
1481
+ state.units[state.units.indexOf(unit)] = { ...unit,
1482
+ unit: segment.validationCwd ? { ...unit.unit, validation_cwd: segment.validationCwd } : unit.unit };
1483
+ // Keep the original call as the reservation anchor until Worker admission replaces it.
1484
+ await this.save(state);
1485
+ return state;
1486
+ });
1487
+ }
1457
1488
  /** Continue an admitted independent Review in-place; no second native Task or prompt. */
1458
1489
  admitReviewerDirect(root, author, callID, promptID) {
1459
1490
  return this.serial(root, async () => {
@@ -1848,6 +1879,8 @@ export class OperatorRuntime {
1848
1879
  else
1849
1880
  delete unit.failure;
1850
1881
  unit.status = result.disposition;
1882
+ for (const segment of unit.directHandoffs ?? [])
1883
+ segment.settledCallID ??= result.callID;
1851
1884
  if (unit.directExecution)
1852
1885
  unit.directExecution.finishedAt = new Date().toISOString();
1853
1886
  if (unit.terminalRescue) {
@@ -1553,17 +1553,26 @@ export const SortieDogsPlugin = async (input, options) => {
1553
1553
  });
1554
1554
  }
1555
1555
  }
1556
- async function nativeUnitCost(child, startedAt) {
1556
+ async function nativeUnitCost(child, startedAt, endedAt) {
1557
1557
  if (!child || !input.client?.session?.messages)
1558
1558
  return null;
1559
1559
  try {
1560
1560
  const response = await input.client.session.messages({ path: { id: child }, query: { directory: input.directory } });
1561
- return isRecord(response) && Array.isArray(response.data) ? settledUnitUsage(response.data, startedAt) : null;
1561
+ return isRecord(response) && Array.isArray(response.data) ? settledUnitUsage(response.data, startedAt, endedAt) : null;
1562
1562
  }
1563
1563
  catch {
1564
1564
  return null;
1565
1565
  }
1566
1566
  }
1567
+ async function unitUsageSegments(root, taskID, callID, child) {
1568
+ const origin = await input.runtimeBridge?.missionDispatchOrigin?.(root, taskID, callID);
1569
+ return origin ? [...origin.segments, ...(child ? [{ sessionID: child,
1570
+ startedAt: origin.segments.at(-1).endedAt }] : [])] : undefined;
1571
+ }
1572
+ async function priceUsageSegments(segments) {
1573
+ const costs = await Promise.all(segments.map(segment => nativeUnitCost(segment.sessionID, segment.startedAt, segment.endedAt)));
1574
+ return costs.length && costs.every((cost) => cost !== null) ? costs.reduce((sum, cost) => sum + cost, 0) : null;
1575
+ }
1567
1576
  async function reconcileUnitUsage(root) {
1568
1577
  const ledger = await goalLedger(root), snapshot = await ledger.readGoal();
1569
1578
  const reconciled = new Set(snapshot.records.flatMap(({ event }) => event.kind === "unit.usage-reconciled" ? [event.reservation_id] : []));
@@ -1571,7 +1580,8 @@ export const SortieDogsPlugin = async (input, options) => {
1571
1580
  if (event.kind !== "unit.settled" || event.goal_id !== snapshot.state.goal_id || event.cost_usd !== null ||
1572
1581
  !event.native_session_id || reconciled.has(event.reservation_id))
1573
1582
  continue;
1574
- const cost = await nativeUnitCost(event.native_session_id, event.native_started_at);
1583
+ const cost = event.native_usage_segments ? await priceUsageSegments(event.native_usage_segments)
1584
+ : await nativeUnitCost(event.native_session_id, event.native_started_at);
1575
1585
  if (cost === null)
1576
1586
  continue;
1577
1587
  await ledger.appendGoal({ kind: "unit.usage-reconciled", at: new Date().toISOString(), goal_id: event.goal_id,
@@ -1594,8 +1604,9 @@ export const SortieDogsPlugin = async (input, options) => {
1594
1604
  const pending = state.outstanding_reservations;
1595
1605
  for (const reservation of pending) {
1596
1606
  const dispatch = await input.runtimeBridge?.recoverMissionDispatch?.(root, reservation.unit_id);
1607
+ const origin = dispatch && await input.runtimeBridge?.missionDispatchOrigin?.(root, reservation.unit_id, dispatch.callID);
1597
1608
  if (!dispatch || !state.goal_id || goalFingerprint({ goal_id: state.goal_id, unit_id: reservation.unit_id,
1598
- call_id: dispatch.callID }) !== reservation.reservation_id)
1609
+ call_id: origin?.callID ?? dispatch.callID }) !== reservation.reservation_id)
1599
1610
  continue;
1600
1611
  if (!dispatch.cancelled && dispatch.nativeOutcome === "completed" && goalReservations.get(dispatch.callID)?.reservationID === reservation.reservation_id) {
1601
1612
  // Reuse host-observed command exits/bindings when still present. A terminal outcome alone
@@ -1616,13 +1627,16 @@ export const SortieDogsPlugin = async (input, options) => {
1616
1627
  }
1617
1628
  const startedAt = (await ledger.readGoal()).records.find(({ event }) => event.kind === "dispatch.reserved" &&
1618
1629
  event.reservation_id === reservation.reservation_id)?.event.at;
1630
+ const usageSegments = await unitUsageSegments(root, reservation.unit_id, dispatch.callID, dispatch.childSessionID);
1619
1631
  await ledger.appendGoal({ kind: "unit.settled", at: new Date().toISOString(),
1620
1632
  reservation_id: reservation.reservation_id,
1621
1633
  receipt_id: goalFingerprint({ recovered_host_task: dispatch.callID, reservation: reservation.reservation_id }),
1622
1634
  goal_id: state.goal_id, unit_id: reservation.unit_id,
1623
1635
  disposition: dispatch.cancelled ? "cancelled" : "failed", result_class: dispatch.cancelled ? "interrupted" : "process-defect",
1624
1636
  progress_fingerprint: null, evidence: [], elapsed_ms: null,
1625
- cost_usd: await nativeUnitCost(dispatch.childSessionID, startedAt),
1637
+ cost_usd: usageSegments ? await priceUsageSegments(usageSegments) : await nativeUnitCost(dispatch.childSessionID, startedAt),
1638
+ ...(usageSegments ? { native_usage_segments: usageSegments,
1639
+ native_session_id: dispatch.childSessionID ?? usageSegments.at(-1)?.sessionID } : {}),
1626
1640
  ...(dispatch.childSessionID ? { native_session_id: dispatch.childSessionID, native_started_at: startedAt } : {}) });
1627
1641
  // A missed after hook closes execution, but cannot turn unobserved validation into acceptance.
1628
1642
  await input.runtimeBridge?.onSerialSettlement?.({ rootSessionID: root, callID: dispatch.callID,
@@ -2317,13 +2331,28 @@ export const SortieDogsPlugin = async (input, options) => {
2317
2331
  if (state.phase === "terminal" || state.receipt !== null) {
2318
2332
  throw new Error("SORTIE_GOAL_CONTROL_DENIED: terminal");
2319
2333
  }
2334
+ const inheritedTaskID = handoffValue(handoffEntries(prompt), ["task_id"]);
2335
+ const origin = inheritedTaskID && await input.runtimeBridge?.missionDispatchOrigin?.(goalRoot(sessionID), inheritedTaskID, callID);
2336
+ const retainHandoffReservation = () => {
2337
+ if (!origin)
2338
+ return;
2339
+ const reservationID = goalFingerprint({ goal_id: state.goal_id, unit_id: inheritedTaskID, call_id: origin.callID });
2340
+ const retained = state.outstanding_reservations.find(item => item.reservation_id === reservationID && item.unit_id === inheritedTaskID);
2341
+ if (!retained)
2342
+ throw new Error("mission-handoff-reservation-unavailable");
2343
+ for (const [previousCall, reservation] of goalReservations) {
2344
+ if (reservation.reservationID === reservationID)
2345
+ goalReservations.delete(previousCall);
2346
+ }
2347
+ goalReservations.set(callID, { root: goalRoot(sessionID), reservationID, unitID: inheritedTaskID, started: Date.parse(origin.startedAt) });
2348
+ };
2320
2349
  if (state.replan_required && !state.replan_used) {
2321
2350
  state = await ledger.appendGoal({ kind: "goal.replanned", at: new Date().toISOString(),
2322
2351
  goal_id: state.goal_id, revision: state.revision, reason: "no-progress" });
2323
2352
  }
2324
2353
  // No-progress is diagnostic, not another implicit execution budget. Useful
2325
2354
  // repair can continue within the same explicit unit/time/cost limits below.
2326
- if (state.budget !== null && state.consumed_units + state.outstanding_reservations.length >= state.budget.max_units) {
2355
+ if (!origin && state.budget !== null && state.consumed_units + state.outstanding_reservations.length >= state.budget.max_units) {
2327
2356
  await terminalGoal(sessionID, "stop_budget", "stopped");
2328
2357
  throw budgetDenied("units");
2329
2358
  }
@@ -2337,6 +2366,10 @@ export const SortieDogsPlugin = async (input, options) => {
2337
2366
  await terminalGoal(sessionID, "stop_budget", "stopped");
2338
2367
  throw budgetDenied("cost_usd");
2339
2368
  }
2369
+ if (origin) {
2370
+ retainHandoffReservation();
2371
+ return;
2372
+ }
2340
2373
  if (state.goal_id === null)
2341
2374
  return;
2342
2375
  const unitID = handoffValue(handoffEntries(prompt), ["task_id"]) ?? callID;
@@ -2400,7 +2433,16 @@ export const SortieDogsPlugin = async (input, options) => {
2400
2433
  ...execution, immutableRef: execution.immutableRef, endedAt: execution.endedAt, fresh: execution.fresh,
2401
2434
  exitCode: execution.exitCode, outcome: execution.outcome
2402
2435
  }, state, reservation.unitID));
2403
- const hostEvidence = [...new Map((directEvidence ?? observedEvidence).map(entry => [entry.evidence_id, entry])).values()];
2436
+ const handedChecks = await input.runtimeBridge?.missionHandoffChecks?.(reservation.root, reservation.unitID, callID) ?? [];
2437
+ const inherited = handedChecks.flatMap(check => {
2438
+ const laterFailure = [...hostGoalExecutions.values()].some(execution => execution.root === reservation.root &&
2439
+ execution.sessionID === childSessionID && execution.endedAt && execution.outcome !== "pass" && execution.outcome !== "skip" &&
2440
+ Date.parse(execution.startedAt) >= Date.parse(check.startedAt));
2441
+ return laterFailure ? [] : observedHostGoalEvidence({ ...check, owner: "coordinator", outcome: "pass",
2442
+ immutableRef: goalFingerprint({ direct_check: check }),
2443
+ correction: { taskID: reservation.unitID, dispatchCallID: check.dispatchCallID, commands: check.command } }, state, reservation.unitID);
2444
+ });
2445
+ const hostEvidence = [...new Map((directEvidence ?? [...observedEvidence, ...inherited]).map(entry => [entry.evidence_id, entry])).values()];
2404
2446
  // A worker may return after a user scope epoch changed. Settle its spend/reservation, but only
2405
2447
  // adopt observations still bound to the current accepted source/candidate/criterion contract.
2406
2448
  const acceptedEvidence = hostEvidence.filter((entry) => validGoalEvidence(entry, state) &&
@@ -2432,13 +2474,16 @@ export const SortieDogsPlugin = async (input, options) => {
2432
2474
  const processDefect = !correctionChecks?.failure && failedAcceptanceExecution === undefined && (childSessionID === undefined || hostBindingDefect ||
2433
2475
  goalValidationDefects.has(childSessionID) || !validated);
2434
2476
  const resultClass = validated ? "acceptance" : interrupted ? "interrupted" : processDefect ? "process-defect" : "acceptance";
2477
+ const usageSegments = await unitUsageSegments(reservation.root, reservation.unitID, callID, childSessionID);
2435
2478
  await ledger.appendGoal({ kind: "unit.settled", at: new Date().toISOString(),
2436
2479
  reservation_id: reservation.reservationID, receipt_id: goalFingerprint({ call_id: callID, output: outputText.slice(0, 2048) }),
2437
2480
  goal_id: state.goal_id, unit_id: reservation.unitID,
2438
2481
  disposition: validated ? "succeeded" : interrupted ? "cancelled" : "failed", result_class: resultClass,
2439
2482
  progress_fingerprint: progress && validated ? goalFingerprint(settlementEvidence) : null,
2440
2483
  evidence: settlementEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started),
2441
- cost_usd: await nativeUnitCost(childSessionID, new Date(reservation.started).toISOString()),
2484
+ cost_usd: usageSegments ? await priceUsageSegments(usageSegments) : await nativeUnitCost(childSessionID, new Date(reservation.started).toISOString()),
2485
+ ...(usageSegments ? { native_usage_segments: usageSegments,
2486
+ native_session_id: childSessionID ?? usageSegments.at(-1)?.sessionID } : {}),
2442
2487
  ...(childSessionID ? { native_session_id: childSessionID, native_started_at: new Date(reservation.started).toISOString() } : {}) });
2443
2488
  const result = {
2444
2489
  rootSessionID: reservation.root, callID, unitID: reservation.unitID,
@@ -2484,9 +2529,10 @@ export const SortieDogsPlugin = async (input, options) => {
2484
2529
  }
2485
2530
  if (request.direct) {
2486
2531
  const direct = request.direct;
2532
+ const origin = await input.runtimeBridge?.missionDispatchOrigin?.(root, request.unitID, direct.callID);
2487
2533
  const defect = [...snapshot.records].reverse().map(record => record.event).find(event => event.kind === "unit.settled" &&
2488
2534
  event.unit_id === request.unitID && event.evidence.length === 0 && event.native_session_id === request.childSessionID &&
2489
- event.reservation_id === goalFingerprint({ goal_id: goal.goal_id, unit_id: request.unitID, call_id: direct.callID }) &&
2535
+ event.reservation_id === goalFingerprint({ goal_id: goal.goal_id, unit_id: request.unitID, call_id: origin?.callID ?? direct.callID }) &&
2490
2536
  event.receipt_id === goalFingerprint({ call_id: direct.callID,
2491
2537
  output: "Host-observed direct unit finished; controller session remains active." }));
2492
2538
  if (defect?.kind !== "unit.settled" || Math.abs(Date.parse(defect.at) - Date.parse(direct.finishedAt)) > 1000) {
@@ -8287,7 +8333,7 @@ export const SortieDogsPlugin = async (input, options) => {
8287
8333
  cost_status: state.consumed_cost_usd === null ? "unknown-usage" : reserved > 0 ? "in-flight-not-final" : "settled",
8288
8334
  cost_source: "native-usage-price-table",
8289
8335
  cost_scope: "worker-only", campaign_remaining_usd: null,
8290
- cost_note: "Implementation token-price estimates from complete native usage; scoped Reviewer corrections use this same Worker-unit ledger. Missing requests remain unknown and are reconciled on later status. Excludes orchestration/read-only review and external campaign spend. Zero settled cost does not mean free execution." };
8336
+ cost_note: "Implementation token-price estimates from complete native usage; direct-author segments and scoped Reviewer corrections use this same Worker-unit ledger. Missing requests remain unknown and are reconciled on later status. Excludes orchestration/read-only review and external campaign spend. Zero settled cost does not mean free execution." };
8291
8337
  return snapshot;
8292
8338
  },
8293
8339
  extendMissionUnitBudget: (root, maxUnits) => serializeChatTransition(root, async () => {
@@ -8353,12 +8399,17 @@ export const SortieDogsPlugin = async (input, options) => {
8353
8399
  activateSession(actor);
8354
8400
  if (!goalReservations.has(direct.callID)) {
8355
8401
  const snapshot = await (await goalLedger(root)).readGoal();
8356
- const reservationID = goalFingerprint({ goal_id: snapshot.state.goal_id, unit_id: direct.taskID, call_id: direct.callID });
8357
- if (snapshot.state.outstanding_reservations.some(item => item.reservation_id === reservationID)) {
8358
- goalReservations.set(direct.callID, { root, reservationID, unitID: direct.taskID, started: Date.parse(direct.startedAt) });
8359
- }
8360
- else
8402
+ const origin = await input.runtimeBridge?.missionDispatchOrigin?.(root, direct.taskID, direct.callID);
8403
+ if (origin)
8361
8404
  await reserveGoalDispatch(root, direct.callID, prompt);
8405
+ else {
8406
+ const reservationID = goalFingerprint({ goal_id: snapshot.state.goal_id, unit_id: direct.taskID, call_id: direct.callID });
8407
+ if (snapshot.state.outstanding_reservations.some(item => item.reservation_id === reservationID)) {
8408
+ goalReservations.set(direct.callID, { root, reservationID, unitID: direct.taskID, started: Date.parse(direct.startedAt) });
8409
+ }
8410
+ else
8411
+ await reserveGoalDispatch(root, direct.callID, prompt);
8412
+ }
8362
8413
  }
8363
8414
  const binding = await input.runtimeBridge?.missionReadBinding?.(root, actor, handoffPath);
8364
8415
  if (!binding)
@@ -8395,6 +8446,10 @@ export const SortieDogsPlugin = async (input, options) => {
8395
8446
  await releaseWriteGate(actor);
8396
8447
  releaseSessionEnforcement(actor);
8397
8448
  },
8449
+ assertDirectUnitQuiescent: async (actor) => {
8450
+ if (activeSessions.get(actor)?.inFlightCalls.size)
8451
+ throw new Error("mission-direct-tools-running");
8452
+ },
8398
8453
  failDirectUnit: async (root, actor) => {
8399
8454
  const direct = await input.runtimeBridge?.directMissionExecution?.(actor);
8400
8455
  if (!direct || direct.root !== root)
@@ -8404,7 +8459,8 @@ export const SortieDogsPlugin = async (input, options) => {
8404
8459
  // Cold terminal recovery can happen before the actor's next tool/context hook.
8405
8460
  if (!goalReservations.has(direct.callID)) {
8406
8461
  const snapshot = await (await goalLedger(root)).readGoal();
8407
- const reservationID = goalFingerprint({ goal_id: snapshot.state.goal_id, unit_id: direct.taskID, call_id: direct.callID });
8462
+ const origin = await input.runtimeBridge?.missionDispatchOrigin?.(root, direct.taskID, direct.callID);
8463
+ const reservationID = goalFingerprint({ goal_id: snapshot.state.goal_id, unit_id: direct.taskID, call_id: origin?.callID ?? direct.callID });
8408
8464
  if (snapshot.state.outstanding_reservations.some(item => item.reservation_id === reservationID) ||
8409
8465
  snapshot.records.some(({ event }) => event.kind === "unit.settled" && event.reservation_id === reservationID)) {
8410
8466
  goalReservations.set(direct.callID, { root, reservationID, unitID: direct.taskID, started: Date.parse(direct.startedAt) });
@@ -8449,7 +8505,8 @@ export const SortieDogsPlugin = async (input, options) => {
8449
8505
  missionWorkerTerminal: async (root, terminal, writeScopes) => {
8450
8506
  const childSessionID = terminal.childSessionID;
8451
8507
  const snapshot = await (await goalLedger(root)).readGoal();
8452
- const reservationID = goalFingerprint({ goal_id: snapshot.state.goal_id, unit_id: terminal.taskID, call_id: terminal.callID });
8508
+ const origin = await input.runtimeBridge?.missionDispatchOrigin?.(root, terminal.taskID, terminal.callID);
8509
+ const reservationID = goalFingerprint({ goal_id: snapshot.state.goal_id, unit_id: terminal.taskID, call_id: origin?.callID ?? terminal.callID });
8453
8510
  if (snapshot.state.outstanding_reservations.some(item => item.reservation_id === reservationID) ||
8454
8511
  !snapshot.records.some(({ event }) => event.kind === "unit.settled" && event.goal_id === snapshot.state.goal_id &&
8455
8512
  event.unit_id === terminal.taskID && event.reservation_id === reservationID)) {
@@ -115,6 +115,8 @@ export declare function reviewerCorrectionValidationFresh(validation: readonly s
115
115
  }[];
116
116
  nextOccurrence?: number;
117
117
  }>;
118
+ /** Preserve a current ordered prefix while the next author executes the remaining recipe. */
119
+ export declare function directHandoffValidationChecks(...args: Parameters<typeof reviewerCorrectionValidationFresh>): Promise<readonly ReviewerCorrectionCheck[]>;
118
120
  /** Summary-only native reader: unavailable API/error is not a successfully observed empty history. */
119
121
  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>>;
120
122
  export declare function missionReviewBaseline(directory: string): Promise<string | undefined>;
@@ -268,6 +268,16 @@ export async function reviewerCorrectionValidationFresh(validation, child, histo
268
268
  }
269
269
  return checked;
270
270
  }
271
+ /** Preserve a current ordered prefix while the next author executes the remaining recipe. */
272
+ export async function directHandoffValidationChecks(...args) {
273
+ const [validation, child, history, started, checks, root, directories, registeredAt] = args;
274
+ for (let count = validation.length; count > 0; count--) {
275
+ const result = await reviewerCorrectionValidationFresh(validation.slice(0, count), child, history, started, checks, root, directories, registeredAt);
276
+ if (result.ready)
277
+ return checks.filter(check => result.matched?.some(item => item.callID === check.callID));
278
+ }
279
+ return [];
280
+ }
271
281
  /** Summary-only native reader: unavailable API/error is not a successfully observed empty history. */
272
282
  export async function observedMissionValidationSummary(validation, childSessionID, read, notBefore, directory, directories) {
273
283
  if (!read)
@@ -18,7 +18,7 @@ import { publishMissionProgress } from "./mission-progress.js";
18
18
  import { formatWorkQuantity, planWorkQuantity } from "../core/work-quantity.js";
19
19
  import { cachedWorkProgress, forgetWorkSession, observeActorWork, workRootForActor } from "../core/work-quantity-observations.js";
20
20
  import { serializeOperatorStatus } from "./operator-status-output.js";
21
- import { completedMissionReviewPrompts, initialMissionReviewPrompt, missionDeliveryObservation, missionReviewBaseline, missionReviewSource, observedMissionValidation, observedMissionValidationSummary, missionReviewValidation, reviewerCorrectionValidation, reviewerCorrectionValidationFresh, validationDirectoryMatches } from "./mission-review.js";
21
+ import { completedMissionReviewPrompts, initialMissionReviewPrompt, missionDeliveryObservation, missionReviewBaseline, missionReviewSource, observedMissionValidation, observedMissionValidationSummary, missionReviewValidation, reviewerCorrectionValidation, reviewerCorrectionValidationFresh, directHandoffValidationChecks, validationDirectoryMatches } from "./mission-review.js";
22
22
  import { missionLocations, missionLocationPacket } from "./mission-location.js";
23
23
  import { prepareValidationScratch } from "./validation-scratch.js";
24
24
  import { SOURCE_REVIEW_RISK_TAGS } from "../core/consultation.js";
@@ -610,6 +610,28 @@ export function createProfiledPlugin(profile, assetVersion) {
610
610
  return direct && taskID && direct.unit.callID ? { root: direct.root, taskID, callID: direct.unit.callID,
611
611
  startedAt: direct.unit.directExecution.startedAt } : undefined;
612
612
  },
613
+ missionDispatchOrigin: async (root, taskID, callID) => {
614
+ const state = await operators.read(root);
615
+ const unit = state?.units.find(item => item.callID === callID && /^task_id: (.+)$/mu.exec(item.task.prompt)?.[1] === taskID);
616
+ const segments = unit?.directHandoffs?.filter(item => !item.settledCallID || item.settledCallID === callID);
617
+ const first = segments?.[0];
618
+ return first ? { callID: first.callID, startedAt: first.startedAt,
619
+ segments: segments.map(item => ({ sessionID: item.actor, startedAt: item.startedAt, endedAt: item.handedOffAt })) } : undefined;
620
+ },
621
+ missionHandoffChecks: async (root, taskID, callID) => {
622
+ const run = await operators.read(root);
623
+ const unit = run?.units.find(item => item.callID === callID && /^task_id: (.+)$/mu.exec(item.task.prompt)?.[1] === taskID);
624
+ const prior = unit?.directHandoffs?.filter(item => !item.settledCallID || item.settledCallID === callID).at(-1);
625
+ if (!unit || !prior)
626
+ return [];
627
+ try {
628
+ const history = await reviewMessages(prior.actor, prior.startedAt, true);
629
+ 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);
630
+ }
631
+ catch {
632
+ return [];
633
+ }
634
+ },
613
635
  reviewerCorrectionValidationMembers: async (child, command, directory) => {
614
636
  const correction = await reviewerCorrection(child) ?? await directExecution(child);
615
637
  if (!correction)
@@ -654,6 +676,9 @@ export function createProfiledPlugin(profile, assetVersion) {
654
676
  const unit = run?.units.find(item => /^task_id: (.+)$/m.exec(item.task.prompt)?.[1] === taskID);
655
677
  if (!run || !unit?.callID)
656
678
  return undefined;
679
+ if (run.phase === "cancelled" && unit.directHandoffs?.length && !unit.childSessionID) {
680
+ return { callID: unit.callID, cancelled: true };
681
+ }
657
682
  // Direct work has no child Task terminal. Its explicit host-observed finish owns
658
683
  // settlement; never mistake a prior parent response for this unit's completion.
659
684
  if (unit.directExecution && run.phase !== "cancelled")
@@ -1150,7 +1175,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1150
1175
  `and verifies old children before preparing a Worker. Otherwise obtain the user's scope decision.` };
1151
1176
  if (mission.coordinator === null && mission.runID === null && !mission.dispatchOpen && mission.phase === "open") {
1152
1177
  return { ...packet, task: missions.task(mission),
1153
- next_action: "Fast-lane: plan one useful Worker unit with the meaningful formal check known from user, project or task context and estimated scope, then dispatch its Task immediately. Investigation/edit/check/requested commit belong inside Worker before independent Review. Objective: target 2000 characters; original requests are supplied separately. Use Coordinator for an unknown check or real unit decomposition." };
1178
+ next_action: initialMissionAction(mission) };
1154
1179
  }
1155
1180
  if (mission.coordinator === null && mission.runID === run?.runID && !mission.dispatchOpen &&
1156
1181
  run?.phase === "awaiting-acceptance" && mission.kind === "operation" && !missionExecutionComplete(mission)) {
@@ -2199,6 +2224,44 @@ export function createProfiledPlugin(profile, assetVersion) {
2199
2224
  executor_session_id: actor, validation: direct.unit.unit.validation,
2200
2225
  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.` });
2201
2226
  }
2227
+ async function handoffDirect(root, actor, callID, notes) {
2228
+ const direct = await directExecution(actor);
2229
+ if (direct) {
2230
+ await control.assertDirectUnitQuiescent(actor);
2231
+ const history = await reviewMessages(actor, direct.unit.directExecution.startedAt, true);
2232
+ const calls = new Map();
2233
+ for (const message of history)
2234
+ if (record(message.info) && message.info.sessionID === actor && Array.isArray(message.parts)) {
2235
+ for (const part of message.parts)
2236
+ if (record(part) && part.type === "tool" && typeof part.callID === "string" && part.callID !== callID)
2237
+ calls.set(part.callID, part);
2238
+ }
2239
+ if ([...calls.values()].some(part => !record(part.state) || !["completed", "error"].includes(String(part.state.status)) ||
2240
+ record(part.state.metadata) && ["running", "pending", "unknown"].includes(String(part.state.metadata.status)))) {
2241
+ return JSON.stringify({ status: "direct-handoff-awaits-tools", next_action: "Finish the existing native command before handing off this unit; retain its result and continue here or delegate afterwards." });
2242
+ }
2243
+ await activateDirect(actor);
2244
+ const mission = await missions.retainContext(root, actor, history, notes);
2245
+ await operators.handoffDirect(root, actor, await missions.handoffContext(mission));
2246
+ }
2247
+ const run = await operators.required(root);
2248
+ const unit = run.units.find(item => item.status !== "succeeded");
2249
+ const last = unit?.directHandoffs?.at(-1);
2250
+ if (actor !== (run.operatorSessionID ?? root) || !last || last.actor !== actor)
2251
+ throw new Error("mission-direct-handoff-unavailable");
2252
+ // Idempotently finish projection/release after a restart between the durable handoff and response.
2253
+ await missions.update(root, mission => {
2254
+ const previous = mission.attempts?.find(item => item.callID === last.callID);
2255
+ if (previous?.status === "dispatched")
2256
+ previous.status = "handed-off";
2257
+ });
2258
+ await control.releaseDirectUnit(actor);
2259
+ const next = await operators.next(root, actor);
2260
+ return JSON.stringify({ ...next,
2261
+ handoff: { from: actor, unit_id: unit.unit.id, retained_checks: last.checks.length },
2262
+ next_action: unit.status === "pending" ? "Dispatch the returned Worker once to continue this same unit. Existing edits, context, checks and reservation are retained. The Worker owns remaining implementation and validation; handoff is not completion."
2263
+ : "The handed-off unit is already admitted. Continue its existing owner; do not dispatch another Worker." });
2264
+ }
2202
2265
  tools[startDirectUnit] = { description: "Execute the next already-planned ordinary unit in this Coordinator/Operator session, including a correction planned with executor=self. Native edits and declared checks become formal evidence without a Worker, handoff read or approval. Never replace an active writer; completed correction history does not restrict this choice.",
2203
2266
  args: {}, execute: async (_args, context) => {
2204
2267
  const { root } = await missionAuthority(context.sessionID);
@@ -2269,12 +2332,19 @@ export function createProfiledPlugin(profile, assetVersion) {
2269
2332
  description: "For an operation: the actual run/grade commands, not a preflight or NO_START check. Host observes their native shell completion separately from successful exits; no handwritten proof file is needed. For run-once/result collection, validate the collected result without rerunning the operation.", "x-sortie-optional": true };
2270
2333
  const handoffContextSchema = { type: "string", "x-sortie-optional": true,
2271
2334
  description: "Optional current working notes: intent/rationale, known environment and paths, completed setup/checks with results, failed approaches and remaining work. Reuse known facts with source references; no required form or new approval. Public history and Mission observations are also supplied automatically. Omit when already sufficient; do not recopy the original request." };
2272
- tools[startMission] = { description: `Operator: save current requirements; the host retains the original request verbatim. With a meaningful formal check known from user, project or task context and one useful unit, include unit NOW (and execution for an operation) and dispatch its Worker directly; plan_units remains available after start. A literal command in the original request is not required. Worker owns investigation/edit/check/requested commit before independent Review; no routine preparation or commit-only handoff. ${MISSION_GIT_SCOPE} Unknown checks or real unit decomposition use Coordinator. intent=replace follows an actual user requirement change, retaining unchanged constraints and cumulative spend; for mission-source-reconciliation-required use the saved requirements when they reflect that change. intent=new is separate work in another location.`,
2335
+ function initialMissionAction(mission) {
2336
+ return mission.preferredExecutor === "coordinator"
2337
+ ? "Dispatch this Coordinator Task now with the supplied recommendation/context. Coordinator owns substantial implementation and can use plan_units executor=self or delegate remaining work when useful. Start useful work, not a route-selection investigation."
2338
+ : "Use the recommendation and already-known context: when a meaningful formal check and useful unit are already known, plan_units can dispatch its Worker directly. Otherwise dispatch this Coordinator Task now; it may implement here or delegate during actual work. Do not investigate merely to decide the route or discover a check for Operator planning.";
2339
+ }
2340
+ tools[startMission] = { description: `Operator: save current requirements and recommend preferred_executor from the original instruction and already-known context, without a routing investigation. The host retains the original request verbatim. Clear bounded work normally suits Worker; coupled design, ambiguous requirements or cross-system integration normally suit Coordinator direct execution. Follow explicit user choices. Coordinator may revise this advisory recommendation during useful work, not a mandatory reassessment. With known unit facts and meaningful formal checks, include unit now (and execution for an operation): worker/default can dispatch its Worker directly; coordinator retains these facts for its own execution choice. Unknown checks are resolved during actual work, not an Operator preflight. Investigation/edit/check/requested commit stay with the chosen author before applicable Review. ${MISSION_GIT_SCOPE} intent=replace follows an actual user requirement change, retaining unchanged constraints and cumulative spend; for mission-source-reconciliation-required use the saved requirements when they reflect that change. intent=new is separate work in another location.`,
2273
2341
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 },
2274
2342
  work_quantities: { type: ["array", "object", "string", "number", "boolean", "null"], items: {}, additionalProperties: true, "x-sortie-optional": true,
2275
2343
  minLength: 0, maxLength: 65535, minItems: 0, maxItems: 4096,
2276
2344
  description: "Optional display-only workload array: stable id, kind, relative_weight, bindings.paths/commands, checkpoints. Reuse this ordinary plan; never add a planning/report turn for progress. Invalid shapes/estimates fall back without changing execution." },
2277
- unit: { ...missionUnitSchema, description: "When the meaningful check and single-unit scope are already known, include this unit now. Returns its configured Worker directly, combining start_mission and plan_units without another model round trip.", "x-sortie-optional": true },
2345
+ preferred_executor: { type: "string", enum: ["coordinator", "worker"], "x-sortie-optional": true,
2346
+ description: "Initial recommendation from the instruction and already-known context. Coordinator may adapt through actual work without approval or a separate investigation. coordinator returns Coordinator even with supplied unit facts; worker uses the direct Worker route when those facts are ready. Not a model override or immutable requirement." },
2347
+ unit: { ...missionUnitSchema, description: "Include already-known unit facts without investigating to fill them. worker/default returns its configured Worker directly; coordinator passes these facts to Coordinator without starting a Worker.", "x-sortie-optional": true },
2278
2348
  execution: missionExecutionSchema,
2279
2349
  handoff_context: handoffContextSchema,
2280
2350
  confirmed_conditions: conditionsSchema,
@@ -2282,6 +2352,8 @@ export function createProfiledPlugin(profile, assetVersion) {
2282
2352
  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 },
2283
2353
  intent: { type: "string", enum: ["", "continue", "new", "replace"], "x-sortie-optional": true } }, execute: async (args, context) => {
2284
2354
  await requireRoot(context.sessionID);
2355
+ if (args.preferred_executor !== undefined && !["coordinator", "worker"].includes(args.preferred_executor))
2356
+ throw new Error("mission-preferred-executor-invalid");
2285
2357
  if (args.intent !== undefined && !["", "continue", "new", "replace"].includes(args.intent))
2286
2358
  throw new Error("mission-intent-invalid");
2287
2359
  return serializeDispatchTransition(context.sessionID, async () => {
@@ -2329,6 +2401,14 @@ export function createProfiledPlugin(profile, assetVersion) {
2329
2401
  ...(args.intent === "replace" && cancelled?.phase === "cancelled" ? { cancelledRunID: cancelled.runID } : {}),
2330
2402
  });
2331
2403
  mission = await missions.retainContext(context.sessionID, context.sessionID, history, args.handoff_context);
2404
+ if (args.preferred_executor !== undefined)
2405
+ mission = await missions.update(context.sessionID, state => {
2406
+ state.preferredExecutor = args.preferred_executor;
2407
+ });
2408
+ const suggestedUnit = args.unit;
2409
+ if (mission.preferredExecutor === "coordinator" && record(suggestedUnit) && mission.runID === null && !mission.dispatchOpen) {
2410
+ mission = await missions.update(context.sessionID, state => { state.suggestedUnit = structuredClone(suggestedUnit); });
2411
+ }
2332
2412
  const prohibited = args.prohibited_write;
2333
2413
  if (prohibited !== undefined) {
2334
2414
  if (!Array.isArray(prohibited) || !prohibited.every(path => typeof path === "string"))
@@ -2367,7 +2447,7 @@ export function createProfiledPlugin(profile, assetVersion) {
2367
2447
  if (baseline)
2368
2448
  mission = await missions.update(context.sessionID, state => { state.reviewBaseline = baseline; });
2369
2449
  }
2370
- if (args.unit !== undefined && mission.runID === null && !mission.dispatchOpen &&
2450
+ if (mission.preferredExecutor !== "coordinator" && args.unit !== undefined && mission.runID === null && !mission.dispatchOpen &&
2371
2451
  (mission.kind !== "operation" || args.execution !== undefined || mission.execution)) {
2372
2452
  const planned = JSON.parse(await declareMissionUnits(context.sessionID, context.sessionID, mission, [args.unit], undefined, args.execution, context.callID, args.handoff_context, args.work_quantities));
2373
2453
  return JSON.stringify({ ...locationObservation(context.sessionID), mission_id: mission.id, requirements: mission.requirements, ...planned });
@@ -2380,7 +2460,8 @@ export function createProfiledPlugin(profile, assetVersion) {
2380
2460
  return JSON.stringify({ ...locationObservation(context.sessionID), ...(mission.dispatchOpen
2381
2461
  ? missionDispatchPacket(mission, await operators.read(context.sessionID))
2382
2462
  : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
2383
- next_action: "Default to plan_units with a meaningful formal check known from user, project or task context and estimated read/write scope; dispatch its returned Worker now. Worker owns investigation/edit/check/requested commit before independent Review. Objective target 2000 characters; original request is separate and verbatim. Use Coordinator for an unknown check or real unit decomposition, not routine preparation." }) });
2463
+ ...(mission.preferredExecutor ? { preferred_executor: mission.preferredExecutor } : {}),
2464
+ next_action: initialMissionAction(mission) }) });
2384
2465
  });
2385
2466
  } };
2386
2467
  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.",
@@ -2539,6 +2620,8 @@ export function createProfiledPlugin(profile, assetVersion) {
2539
2620
  const recoveryErrors = [];
2540
2621
  const addOwner = (actor, window) => owners.set(actor, [...(owners.get(actor) ?? []), window]);
2541
2622
  for (const unit of run.units) {
2623
+ for (const prior of unit.directHandoffs ?? [])
2624
+ addOwner(prior.actor, { notBefore: prior.startedAt, notAfter: prior.handedOffAt });
2542
2625
  if (unit.directExecution?.actor) {
2543
2626
  addOwner(unit.directExecution.actor, { notBefore: unit.directExecution.startedAt,
2544
2627
  ...(unit.directExecution.finishedAt ? { notAfter: unit.directExecution.finishedAt } : {}) });
@@ -2936,17 +3019,20 @@ export function createProfiledPlugin(profile, assetVersion) {
2936
3019
  : next);
2937
3020
  }
2938
3021
  tools[planUnits] = { description: `Coordinator or single-unit Fast-lane Operator: declare useful work with estimated read/write scope and meaningful formal checks. executor=self starts implementation/correction and formal validation HERE without a Worker or handoff read; otherwise dispatch the returned configured Worker promptly. Reuse useful author context by default; legitimate Worker/direct handoff remains available after terminal work, including correction. Retain findings, actual authorship and passed input-bound checks; do not rediscover or revalidate unchanged work merely for a new actor. ${MISSION_GIT_SCOPE} Native scope reconciliation/expand_unit retain host permissions and explicit path prohibitions. Objective is the target or corrective delta: aim for 2000 characters; the full original request/public reproduction is supplied separately, not copied here. Oversized unit instructions are retained verbatim in handoff Mission context, not rejected for another planning round. Keep every requirement covered. Final validation proves the unit; empty or dummy checks do not qualify. Diagnostics need no registration. write: [] is read-only; dir/** and native absolute paths support actual outputs. reason replans settled work, or ends your own quiescent direct unit without acceptance to correct its declaration, in the same requirements/budget; same-scope failed validation uses retry_mission_unit. Completion history is not a correction-owner lock or an acceptance prerequisite.`,
2939
- args: { units: { type: "array", minItems: 1, maxItems: 32, items: missionUnitSchema },
3022
+ args: { units: { type: "array", minItems: 1, maxItems: 32, items: missionUnitSchema, "x-sortie-optional": true,
3023
+ description: "Useful work units. Omit only with executor=worker to hand off your currently running direct unit, retaining its contract and existing work." },
2940
3024
  work_quantities: tools[startMission].args.work_quantities,
2941
3025
  execution: missionExecutionSchema,
2942
3026
  handoff_context: handoffContextSchema,
2943
3027
  reason: { type: "string", "x-sortie-optional": true },
2944
- executor: { type: "string", enum: ["worker", "self"], description: "Use self to implement/correct and formally validate here without a Worker handoff. Default worker retains configured Worker routing.", "x-sortie-optional": true } }, execute: async (args, context) => {
3028
+ 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) => {
2945
3029
  const { root, mission } = await missionAuthority(context.sessionID);
2946
3030
  if (args.executor !== undefined && !["self", "worker"].includes(args.executor))
2947
3031
  throw new Error("mission-executor-invalid");
2948
3032
  try {
2949
3033
  return await serializeDispatchTransition(root, async () => {
3034
+ if (args.executor === "worker" && args.units === undefined)
3035
+ return handoffDirect(root, context.sessionID, context.callID, args.handoff_context);
2950
3036
  const planned = await declareMissionUnits(root, context.sessionID, mission, args.units, args.reason, args.execution, context.callID, args.handoff_context, args.work_quantities);
2951
3037
  return args.executor === "self" && record(JSON.parse(planned).task) ? startDirect(root, context.sessionID) : planned;
2952
3038
  });
@@ -89,6 +89,17 @@ export interface RuntimeBridge {
89
89
  callID: string;
90
90
  startedAt: string;
91
91
  } | undefined>;
92
+ /** The same unit's original reservation and completed direct-author usage segments. */
93
+ missionDispatchOrigin?(rootSessionID: string, taskID: string, callID: string): Promise<{
94
+ callID: string;
95
+ startedAt: string;
96
+ segments: readonly {
97
+ sessionID: string;
98
+ startedAt: string;
99
+ endedAt: string;
100
+ }[];
101
+ } | undefined>;
102
+ missionHandoffChecks?(rootSessionID: string, taskID: string, callID: string): Promise<readonly ReviewerCorrectionCheck[]>;
92
103
  reviewerCorrectionValidationMembers?(childSessionID: string, command: string, directory?: string): Promise<string[] | undefined>;
93
104
  missionValidationDirectoryMatches?(rootSessionID: string, taskID: string, childSessionID: string, commands: readonly string[], directory: string): Promise<boolean>;
94
105
  recordReviewerCorrectionCheck?(rootSessionID: string, taskID: string, check: ReviewerCorrectionCheck): Promise<void>;
@@ -225,6 +236,7 @@ export interface RuntimeBridge {
225
236
  /** Settle an unfinished direct unit against its actual actor, never a fabricated child Task. */
226
237
  failDirectUnit(rootSessionID: string, actorSessionID: string): Promise<void>;
227
238
  releaseDirectUnit(actorSessionID: string): Promise<void>;
239
+ assertDirectUnitQuiescent(actorSessionID: string): Promise<void>;
228
240
  expandMissionWriteGate(childSessionID: string, paths: readonly string[]): Promise<void>;
229
241
  completeRoot(rootSessionID: string, acceptanceFingerprint: string): Promise<{
230
242
  status: "succeeded" | "awaiting-evidence";
@@ -33,11 +33,13 @@ export function toolSchema(args) {
33
33
  const schema = plainSchema(value);
34
34
  if (optionalArgument(value)) {
35
35
  schema.description = `${typeof schema.description === "string" ? `${schema.description} ` : ""}Pass an empty string to omit this argument.`;
36
- // Object-typed optional arguments otherwise force a model to manufacture blank
37
- // required provenance fields. Admit the same omission sentinel we already decode.
38
- if (schema.type === "object")
36
+ // Structured optional arguments need the same omission sentinel we decode;
37
+ // otherwise V2 requires a fabricated object/array even when the tool says to omit it.
38
+ if (schema.type === "object" || schema.type === "array")
39
39
  return [name, { anyOf: [schema, { type: "string", enum: [""], minLength: 0, maxLength: 0 }],
40
40
  description: schema.description }];
41
+ if (schema.type === "string" && Array.isArray(schema.enum) && !schema.enum.includes(""))
42
+ schema.enum = [...schema.enum, ""];
41
43
  }
42
44
  return [name, schema];
43
45
  })),
@@ -1,2 +1,2 @@
1
1
  /** Price complete native requests; absent/interrupted usage is unknown, never a fabricated zero. */
2
- export declare function settledUnitUsage(messages: readonly unknown[], startedAt?: string): number | null;
2
+ export declare function settledUnitUsage(messages: readonly unknown[], startedAt?: string, endedAt?: string): number | null;
@@ -1,7 +1,7 @@
1
1
  import { estimateModelUsageCost } from "./model-cost.js";
2
2
  const record = (value) => !!value && typeof value === "object" && !Array.isArray(value);
3
3
  /** Price complete native requests; absent/interrupted usage is unknown, never a fabricated zero. */
4
- export function settledUnitUsage(messages, startedAt) {
4
+ export function settledUnitUsage(messages, startedAt, endedAt) {
5
5
  let cost = 0, requests = 0;
6
6
  const seen = new Set();
7
7
  for (const message of messages) {
@@ -12,10 +12,12 @@ export function settledUnitUsage(messages, startedAt) {
12
12
  continue;
13
13
  seen.add(info.id);
14
14
  const time = record(info.time) ? info.time : undefined;
15
- if (startedAt) {
15
+ if (startedAt || endedAt) {
16
16
  if (typeof time?.created !== "number")
17
17
  return null;
18
- if (time.created < Date.parse(startedAt))
18
+ if (startedAt && time.created < Date.parse(startedAt))
19
+ continue;
20
+ if (endedAt && time.created >= Date.parse(endedAt))
19
21
  continue;
20
22
  }
21
23
  if (typeof time?.completed !== "number")
@@ -161,20 +161,25 @@ ${MISSION_DEPENDENCY_WORKFLOW}
161
161
  ${profile.toolPrefix}cancel_operator with reason: "plain" to stop its owned children, then
162
162
  ${profile.toolPrefix}start_mission with intent: "replace" and the saved requirements. The cancelled
163
163
  Mission is archived; cumulative spend is retained. Dispatch only the returned Coordinator Task.
164
- 2. If a meaningful formal check is known from the user, project or task context and the work fits one unit,
165
- include unit in start_mission to receive its Worker immediately in the same call. For a known operation,
166
- include execution commands/directory alongside unit. If already started,
167
- call plan_units and dispatch its Worker. Source investigation, shell/environment checks,
168
- fix design and output inventory belong inside that Worker, not a routine Operator preflight.
164
+ 2. Recommend preferred_executor in start_mission from the original instruction and already-known context.
165
+ Respect explicit user choices. Clear bounded implementation or known procedures normally suit worker;
166
+ coupled design, ambiguous requirements and cross-system integration normally suit coordinator.
167
+ Do not read/search/run diagnostics merely to classify difficulty or fill an Operator planning form.
168
+ A short reason in handoff_context is enough. This recommendation is advisory, not an immutable requirement.
169
+ Coordinator starts useful work immediately and may implement substantially itself or delegate remaining work
170
+ as execution makes that useful, without a separate reassessment or your approval.
171
+ If meaningful formal checks and unit facts are already known, include unit (and execution for a known operation).
172
+ worker/default receives its configured Worker directly; coordinator receives these facts without starting a Worker.
173
+ Unknown checks are discovered during useful author work, not a route-selection preflight.
174
+ Source investigation, shell/environment checks, fix design and output inventory belong with that author.
169
175
  Use estimated read/write paths; native scope reconciliation and expand_unit cover actual outputs.
170
176
  Write objective as the target or corrective delta: aim for 2000 characters.
171
177
  The Worker reads the full original request natively from its handoff once, including exact public reproduction
172
178
  inputs, paths and failures; do not copy that request into objective. Preserve it, not a summary.
173
- Do not list speculative write paths or unrelated test suites as a precaution. Risk, file count,
174
- duration and independent review alone do not require Coordinator. If the check is unknown, real unit
175
- decomposition is needed, or a material user decision prevents work, use the returned
176
- ${profileAgent(profile, "dog-operator")} Task for targeted discovery/coordination within the same budget.
177
- Investigation, edits, formal checks and any requested commit stay in that Worker before independent Review;
179
+ Do not list speculative write paths or unrelated test suites as a precaution. File count, instruction length,
180
+ duration and availability of a check do not alone determine difficulty. Dispatch the returned
181
+ ${profileAgent(profile, "dog-operator")} Task when Coordinator is selected or unit facts are not yet known.
182
+ Investigation, edits, formal checks and any requested commit stay with the chosen author before independent Review;
178
183
  do not invent a review-before-commit gate or a commit-only handoff. Preserve explicit user ordering.
179
184
  ${MISSION_GIT_SCOPE}
180
185
  3. After a direct Worker succeeds, use its recorded result and inspect only missing source or evidence
@@ -470,11 +475,17 @@ ${controls(profile, ["plan_units", "start_direct_unit", "finish_direct_unit", "o
470
475
  You are Coordinator. Own most of the practical work and dispatch within the saved original request.
471
476
  You retain practical implementation authority: investigate, edit, correct, run formal checks and deliver
472
477
  in this SAME session when your existing context makes that efficient. You are not a dispatch-only role.
478
+ Use the Operator's preferred_executor as a starting recommendation. Start useful work with available context;
479
+ do not investigate merely to confirm the recommendation. Adapt through actual work, honoring explicit user choices.
473
480
  Use plan_units with executor: "self" for direct work, or start_direct_unit for an already prepared unit.
474
481
  The host binds your native check results to the candidate; finish_direct_unit closes that work without
475
482
  a Worker handoff. Independent Review and Operator final acceptance still follow. This is not limited to
476
483
  micro-edits. Delegate to the configured Worker when that reduces work/cost or provides useful separation.
477
484
  There is no proposal/approval/contract-repair round trip in this route.
485
+ To hand unfinished direct work to a Worker, call plan_units with executor: "worker", omit units, and put
486
+ the remaining action/useful observations in handoff_context. The host transfers this same unit after current
487
+ native commands settle, retaining changes, checks and budget. Do not finish it as successful or replan just
488
+ to change its author. Dispatch the returned Task once; Operator recommendation does not prevent this choice.
478
489
  Use project-relative paths for repository read/search/shell; the native working directory is project_root.
479
490
  Do not reconstruct or prepend the absolute workspace path. Preserve explicitly requested external paths.
480
491
 
@@ -495,9 +506,10 @@ For a named release in the one-attempt case-study fixture, pass its --release-re
495
506
  the v0127 matched profile intentionally pins 0.12.7, not the newest release. If these identities differ,
496
507
  select the matching runner/profile before starting rather than changing the pinned comparison or spending an arm.
497
508
 
498
- When delegating, start the first useful Worker without redundant source/shell preparation; let it
499
- investigate, check the environment and design the fix. When you already know the necessary implementation
500
- or correction, execute it directly instead of re-explaining it to another Worker. Call ${profile.toolPrefix}plan_units with concise units:
509
+ Continue directly while your context makes that efficient. Delegate when the remaining work is clearer or
510
+ separation reduces effort; no routing-only investigation or mandatory delegation checkpoint. The configured Worker
511
+ can investigate, design, implement and check its assigned work; do not reduce its capability to mechanical edits.
512
+ When delegating, start useful work without redundant source/shell preparation. Call ${profile.toolPrefix}plan_units with concise units:
501
513
  title, objective, read/write file or directory scopes, validation commands, and related requirement_ids
502
514
  when splitting multiple requirements across multiple units; a single unit inherits all requirements when
503
515
  requirement_ids is omitted. For operation missions, include execution with the actual
@@ -659,8 +671,7 @@ Use bounded documented setup in ${TOOL_ENVIRONMENT}/; reuse, never delete.
659
671
  User missing-only: installed/incompatible is not missing.
660
672
  Per-call modes: check retained creation/use for captured vs current settings.
661
673
  Tests: existing suites/subtests or distinctive regression entry names.
662
- ${WORKER_VALIDATION_WORKFLOW}
663
- No fake success.
674
+ ${WORKER_VALIDATION_WORKFLOW}No fake success.
664
675
  Parent: independent Review after return, not before execution; no review-before-commit gate.
665
676
  Do not spawn nested subagents, amend, push/publish. Return changes, command/exit/elapsed,
666
677
  rerun reasons, unresolved/untested behavior; no proof doc.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.13.21",
3
+ "version": "0.14.0",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",