sortie-dogs 0.9.7 → 0.9.8

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
@@ -1,13 +1,13 @@
1
1
  # Sortie-dogs
2
2
 
3
- **Give OpenCode a task; get a bounded, validated implementation loop instead of an open-ended agent run.**
3
+ **Add a bounded, cost-aware execution loop to OpenCode without taking OpenCode over.**
4
4
 
5
- Sortie-dogs is an execution harness for OpenCode, applying harness-engineering
6
- principles through mechanical write boundaries, validation, review, and bounded
7
- orchestration.
5
+ Sortie-dogs is an opt-in overlay, not a replacement. It stays passive until you
6
+ invoke `/sortie` or select `dog-coordinator`. Standard OpenCode agents and
7
+ unrelated sessions remain available and unchanged.
8
8
 
9
- > **Project status: Beta.** Stable in regular use. As a pre-1.0 release,
10
- > configuration and runtime assets may still change between releases.
9
+ > **Project status: Beta.** v0.9.x is under active stabilization. Runtime
10
+ > behavior, configuration, and runtime assets may still change before 1.0.
11
11
 
12
12
  [![npm](https://img.shields.io/npm/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
13
13
  [![license](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
@@ -15,47 +15,63 @@ orchestration.
15
15
 
16
16
  ![Sortie-dogs coordinating a bounded implementation workflow](https://raw.githubusercontent.com/zufall-upon/Sortie-dogs/main/docs/assets/sortie-workflow.gif)
17
17
 
18
- Sortie-dogs is an opt-in OpenCode orchestration plugin. It turns a task into a
19
- scoped plan, optional evidence gathering, one bounded implementation unit, canonical
20
- validation, and evidence-backed completion—while preserving standard OpenCode
21
- agents and settings.
18
+ Sortie-dogs turns selected work into a scoped plan, optional evidence gathering,
19
+ bounded implementation, canonical validation, and evidence-backed completion.
22
20
 
23
21
  Requirements: Node.js 22.6 or newer, npm, and OpenCode.
24
22
 
25
23
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
26
24
 
27
- Release: [v0.9.7](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.7)
28
-
29
- ## Quick start
30
-
31
- Install the public npm package in the project and generate the project-local
32
- OpenCode runtime files:
25
+ Release: [v0.9.8](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.8)
26
+
27
+ ## Why Sortie-dogs?
28
+
29
+ ### Invisible until invited
30
+
31
+ Use normal OpenCode normally. Sortie activates only for `/sortie` or
32
+ `dog-coordinator`; it does not disable or replace OpenCode's standard agents.
33
+
34
+ ### Spend strong models only where they matter
35
+
36
+ Lower-cost models handle bounded retrieval and parallel volume work. Stronger
37
+ models are reserved for implementation, escalation, and independent review.
38
+
39
+ ### Return with proof
40
+
41
+ Writes stay scoped, and completion requires validation evidence. Every completed
42
+ run can return a concise Speed / Cost / Proof debrief.
43
+
44
+ ## Designed to coexist with OpenCode
45
+
46
+ Sortie-dogs adds a workflow to your existing setup rather than replacing it.
47
+
48
+ - It does not disable OpenCode's native agents or replace standard roles such as
49
+ `build`, `plan`, `explore`, or `general`.
50
+ - Ordinary sessions are not automatically converted into Sortie workflows.
51
+ - Project-local initialization is the recommended setup and does not change
52
+ user settings.
53
+ - Global runtime availability and cross-project reflection require separate,
54
+ explicit opt-in. Reflection is disabled by default.
55
+ - Unknown or user-owned runtime files are preserved rather than overwritten.
56
+ - Manual removal targets only known Sortie-owned runtime assets.
57
+
58
+ Use OpenCode normally. Invoke the pack only when you want it.
59
+
60
+ ## Quick start
61
+
62
+ Install the public npm package in the project and generate the project-local
63
+ OpenCode runtime files:
33
64
 
34
65
  ```sh
35
66
  npm install --save-dev sortie-dogs
36
- npx sortie-dogs init .
37
- ```
38
-
39
- Alternatively, install the CLI globally and initialize OpenCode's global
40
- configuration:
41
-
42
- ```sh
43
- npm install --global sortie-dogs
44
- sortie-dogs init --global
45
- ```
46
-
47
- This installs the canonical runtime assets in OpenCode's global configuration,
48
- so `dog-coordinator` can be selected from other projects without project-local
49
- initialization. Global initialization and project-local initialization are
50
- separate: `sortie-dogs init .` still writes runtime files only into that
51
- project. Project-local configuration and the plugin bridge below remain
52
- available when a project needs its own settings or dependency.
53
-
54
- Installing the runtime assets does not load the plugin, and without the plugin
55
- every role runs on whichever model the caller happened to use. Add the package
56
- to the `plugin` array of the OpenCode configuration the agents run under —
57
- `~/.config/opencode/opencode.json` for the global assets, or the project's
58
- `.opencode/opencode.json`:
67
+ npx sortie-dogs init .
68
+ ```
69
+
70
+ This is the recommended setup. Runtime assets stay project-local.
71
+
72
+ Installing the runtime assets does not load the plugin, and without the plugin
73
+ every role runs on whichever model the caller happened to use. Add the package
74
+ to the `plugin` array in the project's `.opencode/opencode.json`:
59
75
 
60
76
  ```json
61
77
  {
@@ -218,26 +234,7 @@ global file for durable global settings.
218
234
  unit, with a maximum of three records per run; routine bugs and external
219
235
  failures are never journaled.
220
236
 
221
- ## Why Sortie-dogs
222
-
223
- - **Focused when invited, invisible otherwise.** Activate it with `/sortie` or
224
- select `dog-coordinator`; ordinary OpenCode sessions remain unchanged.
225
- - **Evidence gathering without arbitrary dispatch ceilings.** A bounded scout resolves
226
- each concrete manifest, validation, or ownership gap whenever it appears; unchanged
227
- requests are not repeated.
228
- - **Writes stay inside the assignment.** Exact source or operation manifests
229
- gate edits and handoffs.
230
- - **Autonomous implementation routing.** An accepted scope with at least two safe independent
231
- units defaults to Luna fabric without opt-in. An explicit serial/no-parallel request wins;
232
- other work uses sequential workers. The separate parallel contract remains explicit.
233
- - **Runtime overlap protection.** Active equal or ancestor write scopes are rejected
234
- before mutation, and full validation waits until every parallel unit joins.
235
- - **Evidence before completion.** Canonical validation, risk-based review, and
236
- terminal evidence gate coordinator-owned completion and commits.
237
- - **Long work can recover.** Restart recovery and bounded compaction continue
238
- from retained handoff context rather than silently starting over.
239
-
240
- ## Example run
237
+ ## Example run
241
238
 
242
239
  An illustrative low-risk run stays bounded and reports its gates:
243
240
 
@@ -400,7 +397,30 @@ coordinator. `dog-reviewer` independently checks high-risk candidates after
400
397
  canonical validation. Neither role implements, stages, commits, or acts as a
401
398
  user-facing worker.
402
399
 
403
- ## Updates and migration
400
+ ## Optional global availability
401
+
402
+ If you intentionally want the Sortie roles available across projects, install
403
+ the CLI and runtime assets globally:
404
+
405
+ ```sh
406
+ npm install --global sortie-dogs
407
+ sortie-dogs init --global
408
+ ```
409
+
410
+ This writes canonical Sortie runtime assets to OpenCode's global configuration;
411
+ it does not make project-local initialization global. Load the plugin from the
412
+ global `~/.config/opencode/opencode.json` when using these assets:
413
+
414
+ ```json
415
+ {
416
+ "plugin": ["sortie-dogs"]
417
+ }
418
+ ```
419
+
420
+ Project-local configuration and plugin loading remain available when a project
421
+ needs its own settings or dependency.
422
+
423
+ ## Updates and migration
404
424
 
405
425
  After replacing the dependency with a newer release asset, run:
406
426
 
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.81-mission-debrief-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.84-dispatch-recovery-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.81-mission-debrief-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.84-dispatch-recovery-v1";
@@ -243,7 +243,7 @@ export interface GoalFlightState {
243
243
  }
244
244
  export declare class GoalBoundError extends Error {
245
245
  readonly code: "invalid" | "transition" | "budget" | "ticket" | "evidence";
246
- constructor(code: "invalid" | "transition" | "budget" | "ticket" | "evidence", message: string);
246
+ constructor(code: GoalBoundError["code"], message: string);
247
247
  }
248
248
  export declare function goalFingerprint(value: unknown): string;
249
249
  export declare function selectGoalDelivery(input: {
@@ -2,3 +2,5 @@
2
2
  export declare const GOAL_DELIVERY_INTENTS: readonly ["design", "registration", "implementation", "repair", "controlled-change"];
3
3
  export declare const GOAL_DELIVERY_MODES: readonly ["planning-only", "mvp-first", "repair-first", "controlled-change"];
4
4
  export declare const GOAL_DECLARATION_FORMAT: string;
5
+ /** Normalize a shared declaration without guessing acceptance, coverage, delivery, or validation. */
6
+ export declare function expandGoalDeclaration(value: unknown): string;
@@ -1,12 +1,16 @@
1
+ import { goalFingerprint } from "./goal-bound.js";
1
2
  /** Shared planner guidance and admission enums; no inferred acceptance or dispatch authority. */
2
3
  export const GOAL_DELIVERY_INTENTS = ["design", "registration", "implementation", "repair", "controlled-change"];
3
4
  export const GOAL_DELIVERY_MODES = ["planning-only", "mvp-first", "repair-first", "controlled-change"];
4
- export const GOAL_DECLARATION_FORMAT = `Goal declaration belongs in the Task prompt, not in the handoff JSON or operation manifest.
5
+ export const GOAL_DECLARATION_FORMAT = `Declare a goal once, then reference it with goal_declaration_path in the Task prompt.
6
+ The referenced JSON may contain shared defaults and a criteria array; the host expands them privately.
7
+ An inline ext["sortie-dogs/goal-declaration"] in the registered handoff is also supported.
8
+ Existing accepted goals need no repeated declaration; budget-only revisions retain their criteria.
9
+ Legacy flat goal_* fields remain supported. No need to rewrite unrelated files or repeat common fields for each criterion.
5
10
  delivery_intent must be exactly one of: ${GOAL_DELIVERY_INTENTS.join(" | ")}. Never use prose or a boolean.
6
11
  delivery_mode is a separate optional enum: ${GOAL_DELIVERY_MODES.join(" | ")}.
7
12
  Use flat key: value lines. Each criterion begins with its own goal_criterion_id: line.
8
- Repeat the entire flat criterion block for multiple criteria. Do not use goal_criteria, goal_acceptance.criteria,
9
- YAML object/list wrappers, inline { goal_criterion_id: ... }, or JSON objects for these blocks.
13
+ For legacy flat input each criterion has a goal_criterion_id line. Prefer shared JSON defaults for multiple criteria.
10
14
  Replace every placeholder with the accepted task's actual value; preserve its acceptance and validation:
11
15
  goal_acceptance_fingerprint: <sha256: followed by exactly 64 lowercase hexadecimal characters>
12
16
  delivery_intent: implementation
@@ -31,3 +35,48 @@ goal_validation_command: <exact operation manifest validation command>
31
35
  The example's implementation/mvp-first selections apply only to an implementation without its usable path;
32
36
  select other enum values only from the accepted request. A declaration rejection launches no child.
33
37
  Repair the Task prompt's named fields, not unrelated files; retry only with the corrected declaration.`;
38
+ /** Normalize a shared declaration without guessing acceptance, coverage, delivery, or validation. */
39
+ export function expandGoalDeclaration(value) {
40
+ if (value === null || typeof value !== "object" || Array.isArray(value))
41
+ throw new Error("goal declaration must be an object");
42
+ const object = value;
43
+ if (!Array.isArray(object.criteria) || object.criteria.length === 0)
44
+ throw new Error("goal declaration requires criteria");
45
+ const defaults = object.defaults !== null && typeof object.defaults === "object" && !Array.isArray(object.defaults)
46
+ ? object.defaults : {};
47
+ const fields = ["criterion_id", "target", "entrypoint", "workload", "oracle_coverage", "build_boundary", "source",
48
+ "candidate", "source_binding", "candidate_binding", "fixture", "proof_scope", "expected_outcome", "validation_command"];
49
+ const scalar = (value) => {
50
+ if (Array.isArray(value))
51
+ return JSON.stringify(value);
52
+ if (typeof value === "string" && !/[\r\n]/u.test(value))
53
+ return value;
54
+ if (typeof value === "boolean" || typeof value === "number")
55
+ return String(value);
56
+ throw new Error("goal declaration field must be a single-line scalar or array");
57
+ };
58
+ const normalizedFields = (values) => Object.fromEntries(fields.flatMap(field => {
59
+ const found = values[`goal_${field}`] ?? values[field];
60
+ return found === undefined ? [] : [[`goal_${field}`, found]];
61
+ }));
62
+ const criteria = object.criteria.map(raw => {
63
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
64
+ throw new Error("goal criterion must be an object");
65
+ return { ...normalizedFields(defaults), ...normalizedFields(raw) };
66
+ });
67
+ const identity = { criteria, delivery_intent: object.delivery_intent, delivery_mode: object.delivery_mode,
68
+ usable_path_established: object.usable_path_established, controlled_change: object.controlled_change };
69
+ const lines = [`goal_acceptance_fingerprint: ${scalar(object.goal_acceptance_fingerprint ?? goalFingerprint(identity))}`];
70
+ for (const field of ["delivery_intent", "delivery_mode", "usable_path_established", "controlled_change",
71
+ "goal_budget_units", "goal_budget_time_ms", "goal_budget_cost_usd"]) {
72
+ if (object[field] !== undefined)
73
+ lines.push(`${field}: ${scalar(object[field])}`);
74
+ }
75
+ for (const criterion of criteria) {
76
+ criterion.goal_criterion_id ??= `criterion-${goalFingerprint(criterion).slice(7, 31)}`;
77
+ for (const field of fields)
78
+ if (criterion[`goal_${field}`] !== undefined)
79
+ lines.push(`goal_${field}: ${scalar(criterion[`goal_${field}`])}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
@@ -65,6 +65,8 @@ export interface OpenCodeHooks {
65
65
  /** Continuation observes the coordinator's completed final text to honour its fallback markers. */
66
66
  "experimental.text.complete"?: (input: {
67
67
  sessionID: string;
68
+ messageID?: string;
69
+ partID?: string;
68
70
  }, output: {
69
71
  text: string;
70
72
  }) => Promise<void>;
@@ -10,7 +10,7 @@ import { createHash, randomUUID } from "node:crypto";
10
10
  import { lstat, mkdir, open, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
13
- import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES } from "../core/goal-declaration-format.js";
13
+ import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES, expandGoalDeclaration } from "../core/goal-declaration-format.js";
14
14
  import { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, } from "../core/acceptance-continuity.js";
15
15
  import { resolveGlobalConfigRoot } from "../core/initialize.js";
16
16
  import { admitLunaFabric } from "../core/luna-fabric-contract.js";
@@ -1699,6 +1699,17 @@ export const SortieDogsPlugin = async (input, options) => {
1699
1699
  rootAcceptanceContinuity.delete(sessionID);
1700
1700
  return { outcome, goal, receipt, delivery, records: snapshot?.records };
1701
1701
  }
1702
+ const ROOT_INTERRUPTION = /(^|\n)TRUE_INTERRUPTION\s*:\s*(?:user|internal)\s*:/iu;
1703
+ async function preserveActiveGoalContinuation(sessionID, text, messageID) {
1704
+ if (!isCoordinatorSession(sessionID) || terminalRunOutcome(text) !== "INTERRUPTED" ||
1705
+ ROOT_INTERRUPTION.test(text) || hasCoordinatorInterruption(sessionID, messageID)) {
1706
+ return text;
1707
+ }
1708
+ const goal = await currentGoal(sessionID).catch(() => undefined);
1709
+ if (goal === undefined || goal.goal_id === null || goal.receipt !== null)
1710
+ return text;
1711
+ return replaceTerminalStatus(text, "status: IN_PROGRESS — local/process/step continuation remains active in the same session");
1712
+ }
1702
1713
  function goalDeclarationContract(prompt) {
1703
1714
  const lines = prompt.split(/\r?\n/u);
1704
1715
  const criterionStarts = lines.flatMap((line, index) => /^\s*goal_criterion_id\s*:/u.test(line) ? [index] : []);
@@ -1822,15 +1833,52 @@ export const SortieDogsPlugin = async (input, options) => {
1822
1833
  });
1823
1834
  return { declaration: { fingerprint, delivery, contract: contract.contract }, defects };
1824
1835
  }
1836
+ function resolveGoalDeclaration(state, prompt) {
1837
+ const keys = [...handoffEntries(prompt).keys()].filter(key => /^(?:goal_|delivery_intent$|delivery_mode$|usable_path_established$|controlled_change$)/u.test(key));
1838
+ const budgetOnly = keys.every(key => ["goal_budget_units", "goal_budget_time_ms", "goal_budget_cost_usd",
1839
+ "goal_acceptance_fingerprint"].includes(key));
1840
+ const fingerprint = handoffValue(handoffEntries(prompt), ["goal_acceptance_fingerprint"]);
1841
+ if (budgetOnly && state.acceptance_contract !== null && state.acceptance_fingerprint !== null && state.delivery !== null &&
1842
+ (fingerprint === undefined || fingerprint === state.acceptance_fingerprint)) {
1843
+ return { declaration: { fingerprint: state.acceptance_fingerprint, delivery: state.delivery,
1844
+ contract: state.acceptance_contract }, defects: [], inherited: true };
1845
+ }
1846
+ return { ...validateGoalDeclaration(prompt), inherited: false };
1847
+ }
1848
+ async function resolveGoalPrompt(prompt) {
1849
+ const entries = handoffEntries(prompt);
1850
+ if (entries.has("goal_criterion_id"))
1851
+ return prompt;
1852
+ const reference = handoffValue(entries, ["goal_declaration_path"]);
1853
+ let definition;
1854
+ if (reference !== undefined)
1855
+ definition = await readJson(resolve(input.directory, reference), INPUT_LIMITS.handoff);
1856
+ else {
1857
+ const handoffPath = handoffValue(entries, ["handoff_path", "handoffpath"]);
1858
+ if (handoffPath !== undefined) {
1859
+ const handoff = await readJson(resolve(input.directory, handoffPath), INPUT_LIMITS.handoff);
1860
+ if (isRecord(handoff) && isRecord(handoff.ext))
1861
+ definition = handoff.ext["sortie-dogs/goal-declaration"];
1862
+ }
1863
+ }
1864
+ if (definition === undefined)
1865
+ return prompt;
1866
+ const expanded = expandGoalDeclaration(definition).split("\n").filter(line => {
1867
+ const field = line.slice(0, line.indexOf(":"));
1868
+ return !entries.has(field);
1869
+ });
1870
+ return `${prompt}\n${expanded.join("\n")}`;
1871
+ }
1825
1872
  async function bindGoalDeclaration(sessionID, prompt) {
1873
+ prompt = await resolveGoalPrompt(prompt);
1826
1874
  const ledger = await goalLedger(sessionID);
1827
1875
  let state = (await ledger.readGoal()).state;
1828
1876
  if (state.goal_id === null || state.origin_user_message_id === null)
1829
1877
  return undefined;
1830
1878
  const entries = handoffEntries(prompt);
1831
1879
  const typedDeclarationPresent = prompt.split(/\r?\n/u).some((line) => /^\s*(?:goal_[a-z0-9_]+|delivery_intent|delivery_mode|usable_path_established|controlled_change)\s*:/iu.test(line));
1832
- // Accepted goals may retain their existing declaration on later units, but the first worker
1833
- // handoff and every handoff containing declaration fields must be complete.
1880
+ // A budget-only amendment retains the accepted goal contract, just like a later unit with
1881
+ // no declaration fields. Explicit acceptance changes still use the existing full declaration.
1834
1882
  if (!typedDeclarationPresent) {
1835
1883
  if (state.acceptance_contract === null && goalDeclarationAuthority.get(sessionID) === state.latest_user_message_id) {
1836
1884
  throw new HandoffDeniedError("contract-invalid", "<goal-declaration>", {
@@ -1841,7 +1889,7 @@ export const SortieDogsPlugin = async (input, options) => {
1841
1889
  goalDeclarationAuthority.delete(sessionID);
1842
1890
  return state;
1843
1891
  }
1844
- const validated = validateGoalDeclaration(prompt);
1892
+ const validated = resolveGoalDeclaration(state, prompt);
1845
1893
  if (validated.declaration === undefined) {
1846
1894
  throw new HandoffDeniedError("contract-invalid", "<goal-declaration>", { defects: validated.defects });
1847
1895
  }
@@ -1883,6 +1931,7 @@ export const SortieDogsPlugin = async (input, options) => {
1883
1931
  async function reserveGoalDispatch(sessionID, callID, prompt) {
1884
1932
  if (goalReservations.has(callID))
1885
1933
  return;
1934
+ await recoverCompletedGoalReservations(sessionID);
1886
1935
  const ledger = await goalLedger(sessionID);
1887
1936
  let state = await bindGoalDeclaration(sessionID, prompt) ?? (await ledger.readGoal()).state;
1888
1937
  if (state.goal_id === null)
@@ -2064,6 +2113,26 @@ export const SortieDogsPlugin = async (input, options) => {
2064
2113
  }), { issueTicket: issueGoalTicket });
2065
2114
  const completedCoordinatorMessages = new Set();
2066
2115
  const completedCoordinatorParts = new Set();
2116
+ const interruptedCoordinatorMessages = new Map();
2117
+ function rememberCoordinatorInterruption(sessionID, info) {
2118
+ const error = isRecord(info.error) ? info.error : undefined;
2119
+ if (info.role !== "assistant" || info.agent !== COORDINATOR_AGENT || typeof info.id !== "string" ||
2120
+ error?.name !== "MessageAbortedError")
2121
+ return;
2122
+ const messages = interruptedCoordinatorMessages.get(sessionID) ?? new Set();
2123
+ messages.add(info.id);
2124
+ interruptedCoordinatorMessages.delete(sessionID);
2125
+ interruptedCoordinatorMessages.set(sessionID, messages);
2126
+ while (messages.size > ACTIVE_SESSION_CACHE.maximum)
2127
+ messages.delete(messages.values().next().value);
2128
+ while (interruptedCoordinatorMessages.size > ACTIVE_SESSION_CACHE.maximum) {
2129
+ interruptedCoordinatorMessages.delete(interruptedCoordinatorMessages.keys().next().value);
2130
+ }
2131
+ }
2132
+ function hasCoordinatorInterruption(sessionID, messageID) {
2133
+ const messages = interruptedCoordinatorMessages.get(sessionID);
2134
+ return messages !== undefined && (messageID === undefined || messages.has(messageID));
2135
+ }
2067
2136
  function freshSessionFallback(reason, action) {
2068
2137
  return { status: "user-action-required", reason, action, retry_same_session: false };
2069
2138
  }
@@ -4734,6 +4803,9 @@ export const SortieDogsPlugin = async (input, options) => {
4734
4803
  return true;
4735
4804
  }
4736
4805
  function expireSession(sessionID) {
4806
+ for (const key of inspectionOperations.keys())
4807
+ if (key.startsWith(`${sessionID}\u0000`))
4808
+ inspectionOperations.delete(key);
4737
4809
  activeSessions.delete(sessionID);
4738
4810
  sessionOperationMetrics.delete(sessionID);
4739
4811
  abandonSessionLease(sessionID);
@@ -4755,6 +4827,9 @@ export const SortieDogsPlugin = async (input, options) => {
4755
4827
  clearSessionLinks(sessionID);
4756
4828
  }
4757
4829
  function evictSession(sessionID) {
4830
+ for (const key of inspectionOperations.keys())
4831
+ if (key.startsWith(`${sessionID}\u0000`))
4832
+ inspectionOperations.delete(key);
4758
4833
  goalValidationDefects.delete(sessionID);
4759
4834
  activeSessions.delete(sessionID);
4760
4835
  sessionOperationMetrics.delete(sessionID);
@@ -4771,6 +4846,7 @@ export const SortieDogsPlugin = async (input, options) => {
4771
4846
  assetVersionPins.delete(sessionID);
4772
4847
  coordinatorPrompts.delete(sessionID);
4773
4848
  explicitCoordinatorModels.delete(sessionID);
4849
+ interruptedCoordinatorMessages.delete(sessionID);
4774
4850
  bindingDenials.delete(sessionID);
4775
4851
  sessionTaskIDs.delete(sessionID);
4776
4852
  recoverableWorkerChildren.delete(sessionID);
@@ -4780,17 +4856,17 @@ export const SortieDogsPlugin = async (input, options) => {
4780
4856
  parallelArtifactOperations.delete(sessionID);
4781
4857
  clearSessionLinks(sessionID);
4782
4858
  }
4783
- async function inspectSuccessfulRead(input) {
4784
- if (input.tool.toLowerCase() !== "read" || input.sessionID === undefined)
4859
+ async function inspectSuccessfulRead(toolInput) {
4860
+ if (toolInput.tool.toLowerCase() !== "read" || toolInput.sessionID === undefined)
4785
4861
  return;
4786
- if (activeSessionStatus(input.sessionID) !== "active" || !isRecord(input.args))
4862
+ if (activeSessionStatus(toolInput.sessionID) !== "active" || !isRecord(toolInput.args))
4787
4863
  return;
4788
- const path = input.args.filePath;
4864
+ const path = toolInput.args.filePath;
4789
4865
  if (typeof path !== "string" || path.length === 0)
4790
4866
  return;
4791
- const absolutePath = resolve(path);
4792
- const key = `${input.sessionID}\u0000${absolutePath}`;
4793
- const operation = inspect(path, input.sessionID).then(() => undefined);
4867
+ const absolutePath = isAbsolute(path) ? resolve(path) : resolve(input.worktree ?? input.directory, path);
4868
+ const key = `${toolInput.sessionID}\u0000${absolutePath}`;
4869
+ const operation = inspectionOperations.get(key) ?? inspect(absolutePath, toolInput.sessionID).then(() => undefined);
4794
4870
  inspectionOperations.set(key, operation);
4795
4871
  try {
4796
4872
  await operation;
@@ -5219,10 +5295,33 @@ export const SortieDogsPlugin = async (input, options) => {
5219
5295
  description: "Report handoff and operation manifest contract defects before dispatch. Read-only: it never inspects, binds, or authorizes.",
5220
5296
  args: {
5221
5297
  handoff_path: defineTool.schema.string(),
5298
+ task_prompt: optionalString(),
5222
5299
  },
5223
- async execute(args) {
5300
+ async execute(args, context) {
5224
5301
  try {
5225
5302
  await inspect(args.handoff_path, undefined, { report: true });
5303
+ if (typeof args.task_prompt === "string") {
5304
+ const state = await currentGoal(context.sessionID);
5305
+ const effectivePrompt = await resolveGoalPrompt(args.task_prompt);
5306
+ const resolved = resolveGoalDeclaration(state, effectivePrompt);
5307
+ if (resolved.defects.length > 0)
5308
+ return JSON.stringify({ status: "defective",
5309
+ defects: resolved.defects.slice(0, CONTRACT_DEFECTS.limit),
5310
+ remedy: "Correct the Task declaration before dispatch. Budget-only changes may omit the already accepted goal fields." });
5311
+ const units = Number(handoffValue(handoffEntries(effectivePrompt), ["goal_budget_units"]));
5312
+ const proposed = Number.isSafeInteger(units) && units > 0 && units >= state.consumed_units
5313
+ ? units : state.budget?.max_units ?? null;
5314
+ const approvalAvailable = goalDeclarationAuthority.get(context.sessionID) === state.latest_user_message_id &&
5315
+ state.latest_user_message_id !== null;
5316
+ return JSON.stringify({ status: "ok", defects: [], goal: {
5317
+ goal_id: state.goal_id, revision: state.revision, inherited: resolved.inherited,
5318
+ max_units: state.budget?.max_units ?? null, proposed_max_units: proposed,
5319
+ consumed_units: state.consumed_units, reserved_units: state.outstanding_reservations.length,
5320
+ remaining_units: state.budget === null ? null : Math.max(0, state.budget.max_units - state.consumed_units - state.outstanding_reservations.length),
5321
+ proposed_remaining_units: proposed === null ? null : Math.max(0, proposed - state.consumed_units - state.outstanding_reservations.length),
5322
+ budget_revision_requires_approval: proposed !== state.budget?.max_units && !approvalAvailable,
5323
+ } });
5324
+ }
5226
5325
  return JSON.stringify({ status: "ok", defects: [] });
5227
5326
  }
5228
5327
  catch (error) {
@@ -5420,12 +5519,14 @@ export const SortieDogsPlugin = async (input, options) => {
5420
5519
  "experimental.text.complete": async (textInput, textOutput) => {
5421
5520
  if (pendingRealGoalTurns.has(textInput.sessionID))
5422
5521
  await recoverPendingRealGoalTurn(textInput.sessionID);
5522
+ const coordinatorReport = isCoordinatorSession(textInput.sessionID) || await recoverCoordinatorRoot(textInput.sessionID);
5423
5523
  if (fastLane.manualCompactionForbidden(textInput.sessionID)) {
5424
5524
  textOutput.text = textOutput.text
5425
5525
  .replaceAll(ROLLOVER_MARKER, "")
5426
5526
  .replaceAll(CONTINUATION_MARKER, "")
5427
5527
  .trimEnd();
5428
5528
  }
5529
+ textOutput.text = await preserveActiveGoalContinuation(textInput.sessionID, textOutput.text, textInput.messageID);
5429
5530
  const runOutcome = terminalRunOutcome(textOutput.text);
5430
5531
  const terminal = runOutcome === undefined || !isCoordinatorSession(textInput.sessionID)
5431
5532
  ? undefined
@@ -5442,7 +5543,7 @@ export const SortieDogsPlugin = async (input, options) => {
5442
5543
  if (runOutcome === "DONE" && terminal?.delivery === "failed") {
5443
5544
  textOutput.text = replaceTerminalStatus(textOutput.text, "status: INTERRUPTED — durable delivery failed");
5444
5545
  }
5445
- if (runOutcome !== undefined && isCoordinatorSession(textInput.sessionID)) {
5546
+ if (coordinatorReport && (runOutcome !== undefined || /<summary\b[^>]*>\s*Evidence\b/iu.test(textOutput.text))) {
5446
5547
  textOutput.text = sanitizeTerminalReport(textOutput.text);
5447
5548
  }
5448
5549
  if ((isCoordinatorSession(textInput.sessionID) || await recoverCoordinatorRoot(textInput.sessionID)) &&
@@ -5561,6 +5662,8 @@ export const SortieDogsPlugin = async (input, options) => {
5561
5662
  }
5562
5663
  const coordinatorOrigin = parentID === undefined && requestedCoordinator;
5563
5664
  if (coordinatorOrigin) {
5665
+ if (!synthetic)
5666
+ interruptedCoordinatorMessages.delete(chatInput.sessionID);
5564
5667
  if (synthetic) {
5565
5668
  if (messageID === undefined)
5566
5669
  throw new Error("SORTIE_GOAL_CONTROL_DENIED: receiving-message-id-required");
@@ -5987,6 +6090,18 @@ export const SortieDogsPlugin = async (input, options) => {
5987
6090
  }
5988
6091
  },
5989
6092
  "tool.execute.before": async (toolInput, output) => {
6093
+ // Publish the inspection at Read admission, before a concurrently scheduled bind can
6094
+ // observe an empty cache. Bind joins this same host validation rather than asking the
6095
+ // coordinator to launch another worker round merely for tool scheduling.
6096
+ if (toolInput.tool.toLowerCase() === "read" && activeSessionStatus(toolInput.sessionID) === "active" &&
6097
+ isRecord(output.args) && typeof output.args.filePath === "string") {
6098
+ const path = output.args.filePath;
6099
+ const absolutePath = isAbsolute(path) ? resolve(path) : resolve(input.worktree ?? input.directory, path);
6100
+ const key = `${toolInput.sessionID}\u0000${absolutePath}`;
6101
+ const operation = inspect(absolutePath, toolInput.sessionID).then(() => undefined);
6102
+ inspectionOperations.set(key, operation);
6103
+ void operation.catch(() => undefined); // The after hook/bind consumes the actual outcome.
6104
+ }
5990
6105
  if (childLifecycles.get(toolInput.sessionID)?.stopping && toolInput.tool !== "sortie_release_write_gate") {
5991
6106
  throw new Error("child-cancellation-in-progress");
5992
6107
  }
@@ -6161,12 +6276,12 @@ export const SortieDogsPlugin = async (input, options) => {
6161
6276
  const resumeDeltaPresent = resumeDeltas.length === 1 && hasResumeContractShape(contractPrompt);
6162
6277
  const contractRedefinitions = [
6163
6278
  ...taskValues(contractPrompt, [
6164
- "role", "project_root", "projectroot", "source_manifest", "sourcemanifest",
6279
+ "project_root", "projectroot", "source_manifest", "sourcemanifest",
6165
6280
  "acceptance", "validation", "validation_history", "validation_attempts", "scout",
6166
6281
  "known_facts", "known_paths", "relevant_constraints", "preserve",
6167
6282
  "parallel_group", "parallel_unit", "parallel_units",
6168
6283
  ]),
6169
- ...roleTokenValues(contractPrompt),
6284
+ ...taskValues(contractPrompt, ["role"]).filter(role => !TASK_ROLES.has(role)),
6170
6285
  ];
6171
6286
  if (modes.length !== 0 && !resume) {
6172
6287
  throw new HandoffDeniedError("contract-invalid", "<worker-dispatch>", {
@@ -6516,6 +6631,7 @@ export const SortieDogsPlugin = async (input, options) => {
6516
6631
  if (eventSessionID === undefined)
6517
6632
  return;
6518
6633
  if (event.type === "message.updated" && info !== undefined) {
6634
+ rememberCoordinatorInterruption(eventSessionID, info);
6519
6635
  await acceptPersistedRealGoalEvent(eventSessionID, info);
6520
6636
  }
6521
6637
  if (pendingRealGoalTurns.has(eventSessionID) &&
@@ -6549,8 +6665,9 @@ export const SortieDogsPlugin = async (input, options) => {
6549
6665
  while (completedCoordinatorMessages.size > ACTIVE_SESSION_CACHE.maximum) {
6550
6666
  completedCoordinatorMessages.delete(completedCoordinatorMessages.values().next().value);
6551
6667
  }
6552
- await terminalGoalFromHostText(eventSessionID, text);
6553
- await completeContinuationText(eventSessionID, text, false);
6668
+ const effectiveText = await preserveActiveGoalContinuation(eventSessionID, text, eventPart.messageID);
6669
+ await terminalGoalFromHostText(eventSessionID, effectiveText);
6670
+ await completeContinuationText(eventSessionID, effectiveText, false);
6554
6671
  return;
6555
6672
  }
6556
6673
  completedCoordinatorParts.delete(eventPart.id);
@@ -6583,8 +6700,9 @@ export const SortieDogsPlugin = async (input, options) => {
6583
6700
  if (text === undefined)
6584
6701
  completedCoordinatorMessages.delete(info.id);
6585
6702
  else {
6586
- await terminalGoalFromHostText(eventSessionID, text);
6587
- await completeContinuationText(eventSessionID, text);
6703
+ const effectiveText = await preserveActiveGoalContinuation(eventSessionID, text, info.id);
6704
+ await terminalGoalFromHostText(eventSessionID, effectiveText);
6705
+ await completeContinuationText(eventSessionID, effectiveText);
6588
6706
  }
6589
6707
  }
6590
6708
  catch {
@@ -334,6 +334,8 @@ function duration(milliseconds) {
334
334
  if (seconds < 60)
335
335
  return `${seconds}s`;
336
336
  const minutes = Math.floor(seconds / 60);
337
+ if (minutes >= 60)
338
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m ${String(seconds % 60).padStart(2, "0")}s`;
337
339
  return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
338
340
  }
339
341
  function metricText(metric, render) {
@@ -348,15 +350,16 @@ export function formatSortieResult(result) {
348
350
  : result.mission.status === "INTERRUPTED" ? "中断(未完了)"
349
351
  : result.mission.status === "EXTERNAL_BLOCKER" ? "外部要因で未完了"
350
352
  : "ユーザー判断待ち(未完了)";
351
- return [
352
- "**🐾 SORTIE DOGS — 帰還報告**",
353
- `**⚡ 時間:** ${metricText(result.speed.goal_wall_ms, duration)}`,
354
- `**🪙 使用量:** ${metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`)} · host推定額 ${metricText(result.cost.cost_usd, (value) => `$${value.toFixed(4)}`)}(実課金換算なし)`,
353
+ const color = result.mission.status === "COMPLETED" ? "🟢" : result.mission.status === "EXTERNAL_BLOCKER" ? "🔴" : "🟡";
354
+ const body = [
355
+ `**⚡ 任務経過(待機含む):** **${metricText(result.speed.goal_wall_ms, duration)}**`,
356
+ `**🪙 使用量:** **${metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`)}** · host推定額 ${metricText(result.cost.cost_usd, (value) => `$${value.toFixed(4)}`)}(実課金換算なし)`,
355
357
  ...renderDebrief(result.debrief),
356
- `**🛡 達成:** ${achievement} · 達成条件 ${criteria}`,
358
+ `**🛡 達成:** ${color} **${achievement}** · 達成条件 **${criteria}**`,
357
359
  "*最終応答生成前の計測*",
358
360
  ...renderCareer(result.career),
359
361
  ].join("\n\n");
362
+ return `<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告|${color} ${achievement}</strong></summary>\n\n${body}\n\n</details>`;
360
363
  }
361
364
  export function formatRunMetrics(metrics) {
362
365
  const elapsed = metrics.durationMilliseconds === undefined ? "duration unavailable" : `${duration(metrics.durationMilliseconds)} wall-clock`;
@@ -370,7 +373,16 @@ export function formatRunMetrics(metrics) {
370
373
  function topLevelLines(text) {
371
374
  const lines = [];
372
375
  let fence;
376
+ let disclosureDepth = 0;
373
377
  for (const [index, line] of text.split(/\r?\n/u).entries()) {
378
+ if (fence === undefined) {
379
+ const opens = (line.match(/<details\b[^>]*>/giu) ?? []).length;
380
+ const closes = (line.match(/<\/details>/giu) ?? []).length;
381
+ const inside = disclosureDepth > 0 || opens > 0;
382
+ disclosureDepth = Math.max(0, disclosureDepth + opens - closes);
383
+ if (inside)
384
+ continue;
385
+ }
374
386
  if (fence === undefined) {
375
387
  const opener = /^[ \t]*(`{3,}|~{3,})/u.exec(line)?.[1];
376
388
  if (opener === undefined) {
@@ -398,13 +410,11 @@ function terminalCheckpoint(text) {
398
410
  const explicit = normalized === "DONE" || normalized === "INTERRUPTED" || normalized === "BLOCKED" || normalized === "NEED_DECISION"
399
411
  ? normalized
400
412
  : undefined;
401
- const outcome = explicit ?? conclusionStatusAlias(line) ??
402
- (/^✅[ \t]+\*\*DONE\*\*/u.test(line) ? "DONE" :
403
- /^⚠️[ \t]+\*\*INTERRUPTED\*\*/u.test(line) ? "INTERRUPTED" :
404
- /^⛔[ \t]+\*\*BLOCKED\*\*/u.test(line) ? "BLOCKED" :
405
- /^❓[ \t]+\*\*NEED_DECISION\*\*/u.test(line) ? "NEED_DECISION" : undefined);
413
+ // The status carries meaning; a decorative icon must not suppress terminal rendering.
414
+ const decorated = /^(?:\p{Extended_Pictographic}\uFE0F?[ \t]+)?\*\*(DONE|INTERRUPTED|BLOCKED|NEED_DECISION)\*\*(?=[ \t]|$)/u.exec(line)?.[1];
415
+ const outcome = explicit ?? conclusionStatusAlias(line) ?? decorated;
406
416
  return outcome === "DONE" || outcome === "INTERRUPTED" || outcome === "BLOCKED" || outcome === "NEED_DECISION"
407
- ? { index, outcome }
417
+ ? { index, outcome: outcome }
408
418
  : undefined;
409
419
  })();
410
420
  return checkpoint;
@@ -437,12 +447,20 @@ export function replaceDoneTerminalStatus(text, replacement) {
437
447
  }
438
448
  export function sanitizeTerminalReport(text) {
439
449
  const newline = text.includes("\r\n") ? "\r\n" : "\n";
440
- const internal = /\b(?:evidence_refs?|manifest|raw|raw_status|reason_code|goal_control|TRUE_BLOCKER)\s*:|\bEvidence\b/iu;
441
- return text.replace(/^[ \t]*(`{3,}|~{3,})[^\r\n]*\r?\n([\s\S]*?)^[ \t]*\1[ \t]*$/gimu, (block, _fence, body) => internal.test(body) ? "" : block)
442
- .replace(/<details\b[^>]*>[\s\S]*?<\/details>/giu, "")
450
+ const preserved = [];
451
+ const keep = (block) => `\0SORTIE_PROSE_${preserved.push(block) - 1}\0`;
452
+ return text.replace(/<details\b[^>]*>[\s\S]*?<\/details>/giu, block => {
453
+ const summary = /<summary\b[^>]*>([\s\S]*?)<\/summary>/iu.exec(block)?.[1]?.replace(/<[^>]*>/gu, "").trim() ?? "";
454
+ return /^(?:Evidence\b|🐾\s*SORTIE DOGS\s*[—-]\s*帰還報告)/iu.test(summary) ? "" : keep(block);
455
+ })
456
+ .replace(/^[ \t]*(`{3,}|~{3,})[^\r\n]*\r?\n[\s\S]*?^[ \t]*\1[ \t]*$/gimu, (block, _fence, offset, source) => {
457
+ const preceding = source.slice(0, offset).trimEnd().split(/\r?\n\s*\r?\n/u).at(-1) ?? "";
458
+ return /^[ \t]*(?:#{1,6}[ \t]*)?\**Evidence\**[ \t]*:/imu.test(preceding) ? "" : keep(block);
459
+ })
443
460
  .split(/\r?\n/u)
444
461
  .filter((line) => !/^\s*(?:(?:#{1,6}\s*)?\**Evidence\**\s*:|(?:TRUE_BLOCKER|goal_control|evidence_refs?|reason_code|raw|raw_status|manifest)\s*:)/iu.test(line))
445
462
  .join(newline)
463
+ .replace(/\0SORTIE_PROSE_(\d+)\0/gu, (_token, index) => preserved[Number(index)])
446
464
  .trimEnd();
447
465
  }
448
466
  export function insertRunMetrics(text, metrics) {
@@ -465,8 +483,24 @@ export function insertSortieResult(text, result) {
465
483
  // A title in model text is not trusted evidence. Replace existing cards, including legacy cards.
466
484
  const newline = visible.includes("\r\n") ? "\r\n" : "\n";
467
485
  const lines = visible.split(/\r?\n/u);
468
- const cardLines = new Set(topLevelLines(visible).filter(({ index, line }) => index > checkpoint.index &&
469
- /^(?:\*\*(?:Sortie Result|🐾 SORTIE DOGS — 帰還報告|📜 PACK RECORD|↳\*\*|(?:Speed|Cost|達成|⚡ 時間|🪙 使用量|🐕 出撃隊|モデル別token内訳|実行重複率|🛡 達成|確認|🏅 今回の戦績|戦績|初回完遂|累積使用量|累積モデル|累積時間|累積実行重複率|保存範囲|🎖 隊の称号):)|\*最終応答生成前の計測\*)/u.test(line)).map(({ index }) => index));
486
+ const cardLines = new Set();
487
+ let legacyCard = false;
488
+ let previousIndex = checkpoint.index;
489
+ for (const { index, line } of topLevelLines(visible)) {
490
+ if (index <= checkpoint.index)
491
+ continue;
492
+ if (index > previousIndex + 1)
493
+ legacyCard = false;
494
+ previousIndex = index;
495
+ if (/^\*\*(?:Sortie Result|🐾 SORTIE DOGS — 帰還報告|📜 PACK RECORD)/u.test(line))
496
+ legacyCard = true;
497
+ if (legacyCard && (line.trim() === "" ||
498
+ /^(?:\*\*(?:Sortie Result|🐾 SORTIE DOGS — 帰還報告|📜 PACK RECORD|↳\*\*|(?:Speed|Cost|達成|⚡ 時間|🪙 使用量|🐕 出撃隊|モデル別token内訳|実行重複率|🛡 達成|確認|🏅 今回の戦績|戦績|初回完遂|累積使用量|累積モデル|累積時間|累積実行重複率|保存範囲|🎖 隊の称号):)|\*最終応答生成前の計測\*)/u.test(line))) {
499
+ cardLines.add(index);
500
+ }
501
+ else
502
+ legacyCard = false;
503
+ }
470
504
  const cleaned = lines.filter((_, index) => !cardLines.has(index));
471
505
  while (cleaned[checkpoint.index + 1] === "")
472
506
  cleaned.splice(checkpoint.index + 1, 1);
@@ -475,7 +509,7 @@ export function insertSortieResult(text, result) {
475
509
  card = formatSortieResult(result);
476
510
  }
477
511
  catch {
478
- card = "**🐾 SORTIE DOGS — 帰還報告**\n**確認:** 表示集計を取得できません。任務結果は先頭の状態を参照。";
512
+ card = "<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告</strong></summary>\n\n**確認:** 表示集計を取得できません。任務結果は先頭の状態を参照。\n\n</details>";
479
513
  }
480
514
  cleaned.splice(checkpoint.index + 1, 0, "", card, "");
481
515
  return cleaned.join(newline).trimEnd();
@@ -17,11 +17,13 @@ export interface DebriefSession {
17
17
  readonly reviews: Array<{
18
18
  at: number;
19
19
  status: "PASS" | "FAIL" | "WAIVED";
20
+ source?: "reviewer" | "controller";
20
21
  }>;
21
22
  readonly tasks: string[];
22
23
  complete: boolean;
23
24
  timingComplete: boolean;
24
25
  failed: boolean;
26
+ lastPossibleMutation?: number;
25
27
  }
26
28
  export interface DebriefObservation {
27
29
  readonly complete: boolean;
@@ -40,6 +42,8 @@ export interface Debrief {
40
42
  }[] | null;
41
43
  readonly validation: "PASS" | "FAIL" | "未確認";
42
44
  readonly review: "PASS" | "FAIL" | "WAIVED" | "未確認";
45
+ readonly reviewSource?: "reviewer" | "controller";
46
+ readonly notes?: readonly string[];
43
47
  readonly traits: readonly ("連携作戦" | "修正から復帰" | "一発完遂")[];
44
48
  readonly firstPassEligible?: boolean;
45
49
  readonly overlap?: {
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { DEDICATED_WORKER_ROLES, LUNA_FABRIC_WORKER_ROLE } from "./model-routing.js";
3
+ import { normalizeCommand } from "./gate.js";
3
4
  const object = (value) => value !== null && typeof value === "object" ? value : undefined;
4
5
  const timeValue = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
5
- const fingerprint = (value) => createHash("sha256").update(value).digest("hex");
6
+ const fingerprint = (value) => createHash("sha256").update(normalizeCommand(value)).digest("hex");
6
7
  const workerRoles = new Set([...DEDICATED_WORKER_ROLES, LUNA_FABRIC_WORKER_ROLE]);
7
8
  function unionDuration(spans) {
8
9
  let end = -1, total = 0;
@@ -34,14 +35,11 @@ export function observeDebriefSession(id, root, messages, window) {
34
35
  session.timingComplete = false;
35
36
  continue;
36
37
  }
37
- if (!within(span, window)) {
38
- if (window !== undefined && span.start < window.end && span.end > window.start) {
39
- session.complete = false;
40
- session.timingComplete = false;
41
- }
38
+ // Match the token collector's completed-message window. A message can start just before
39
+ // goal.accepted; clip its timing rather than invalidating every independent metric.
40
+ if (window !== undefined && (span.end < window.start || span.end > window.end))
42
41
  continue;
43
- }
44
- session.spans.push(span);
42
+ session.spans.push(window === undefined ? span : { start: Math.max(span.start, window.start), end: span.end });
45
43
  if (info.error !== undefined)
46
44
  session.failed = true;
47
45
  for (const raw of Array.isArray(message.parts) ? message.parts : []) {
@@ -89,8 +87,20 @@ export function observeDebriefSession(id, root, messages, window) {
89
87
  else
90
88
  session.complete = false;
91
89
  }
90
+ if (args?.subagent_type === "dog-reviewer" && typeof state.output === "string") {
91
+ const body = /<task_result>([\s\S]*?)<\/task_result>/u.exec(state.output)?.[1] ?? state.output;
92
+ const lines = body.replace(/^[ \t]*(`{3,}|~{3,})[^\r\n]*\r?\n[\s\S]*?^[ \t]*\1[ \t]*$/gmu, "")
93
+ .trim().split(/\r?\n/u);
94
+ const verdicts = [lines[0], lines.at(-1)].map(line => line?.trim().match(/^(?:\*\*)?(?:(?:verdict|結論|判定)\s*:\s*)?(PASS|FAIL|MUST_FIX)(?:\*\*)?$/iu)?.[1]?.toUpperCase());
95
+ const verdict = verdicts.some(value => value === "FAIL" || value === "MUST_FIX") || /^FINDING\b/iu.test(lines[0] ?? "")
96
+ ? "FAIL" : verdicts.includes("PASS") ? "PASS" : undefined;
97
+ if (verdict !== undefined)
98
+ session.reviews.push({ at: interval.end,
99
+ status: verdict === "PASS" ? "PASS" : "FAIL", source: "reviewer" });
100
+ }
92
101
  }
93
102
  if (["edit", "write", "apply_patch"].includes(tool)) {
103
+ session.lastPossibleMutation = Math.max(session.lastPossibleMutation ?? 0, interval.end);
94
104
  const metadata = object(state.metadata);
95
105
  const changed = typeof metadata?.diff === "string" && /^[+-](?![+-])/mu.test(metadata.diff) ||
96
106
  Array.isArray(metadata?.files) && metadata.files.some((file) => {
@@ -138,15 +148,14 @@ export function observeDebriefSession(id, root, messages, window) {
138
148
  return session;
139
149
  }
140
150
  export function buildDebrief(receipt, contract, observation, records) {
141
- const empty = { pack: null, mix: null, validation: "未確認", review: "未確認", traits: [] };
142
- if (observation === undefined)
143
- return empty;
151
+ observation ??= { complete: false, sessions: [], window: undefined };
144
152
  const sessions = observation.sessions;
145
153
  const complete = observation.complete && sessions.every((session) => session.complete);
146
154
  const timingComplete = observation.complete && sessions.every((session) => session.timingComplete);
147
- const children = sessions.filter((session) => !session.root && session.spans.length > 0);
155
+ const children = sessions.filter((session) => !session.root &&
156
+ (session.spans.length > 0 || Object.keys(session.models).length > 0));
148
157
  const counts = new Map(), totals = new Map();
149
- let usageComplete = timingComplete;
158
+ let usageComplete = observation.complete;
150
159
  for (const session of sessions) {
151
160
  for (const [model, tokens] of Object.entries(session.models)) {
152
161
  if (tokens === null || model === "未分類")
@@ -163,11 +172,25 @@ export function buildDebrief(receipt, contract, observation, records) {
163
172
  const total = [...totals.values()].reduce((sum, tokens) => sum + tokens, 0);
164
173
  const commands = new Set(contract?.criteria.flatMap((criterion) => criterion.validation_command === undefined ? [] : [fingerprint(criterion.validation_command)]));
165
174
  const checks = sessions.flatMap((session) => session.checks.filter((check) => commands.has(check.command))).sort((a, b) => a.end - b.end);
175
+ // Retain canonical proof even when an unrelated host tool lacks timing/metadata.
176
+ for (const { event } of records ?? []) {
177
+ if (event.kind !== "unit.settled" || event.goal_id !== receipt.goal_id)
178
+ continue;
179
+ for (const evidence of event.evidence ?? []) {
180
+ const command = fingerprint(evidence.execution.command.join(" "));
181
+ const start = Date.parse(evidence.execution.started_at), end = Date.parse(evidence.execution.ended_at);
182
+ if (receipt.evidence_refs.includes(evidence.evidence_id) && commands.has(command) &&
183
+ Number.isFinite(start) && Number.isFinite(end))
184
+ checks.push({ start, end, command,
185
+ passed: evidence.execution.exit_code === 0 && evidence.execution.outcome === "pass" });
186
+ }
187
+ }
188
+ checks.sort((a, b) => a.end - b.end);
166
189
  const latest = new Map();
167
190
  for (const check of checks)
168
191
  latest.set(check.command, check);
169
192
  const validation = [...latest.values()].some((check) => !check.passed) ? "FAIL"
170
- : complete && commands.size > 0 && latest.size === commands.size ? "PASS" : "未確認";
193
+ : commands.size > 0 && latest.size === commands.size ? "PASS" : "未確認";
171
194
  const reviews = sessions.flatMap((session) => session.reviews).sort((a, b) => a.at - b.at);
172
195
  const traits = [];
173
196
  const spans = children.flatMap((session) => session.spans.filter((span) => span.end > span.start)
@@ -218,9 +241,15 @@ export function buildDebrief(receipt, contract, observation, records) {
218
241
  (event.kind === "validation.admission" && event.decision === "DENY") || (event.kind === "validation.settled" && event.outcome !== "passed")) &&
219
242
  sessions.every((session) => !session.failed && new Set(session.checks.map((check) => check.command)).size === session.checks.length))
220
243
  traits.push("一発完遂");
221
- return { pack: timingComplete ? [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([model, count]) => ({ model, count })) : null,
244
+ const review = reviews.filter((entry) => !sessions.some((session) => (session.lastPossibleMutation ?? 0) > entry.at ||
245
+ session.mutations.some((edit) => edit.end > entry.at))).at(-1);
246
+ const packComplete = observation.complete && sessions.every(session => session.root || session.timingComplete || Object.keys(session.models).length > 0);
247
+ return { pack: packComplete ? [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([model, count]) => ({ model, count })) : null,
222
248
  mix: usageComplete && total > 0 ? [...totals].sort(([a], [b]) => a.localeCompare(b)).map(([model, tokens]) => ({ model, tokens, percent: tokens / total * 100 })) : null,
223
- validation, review: complete ? reviews.filter((review) => !sessions.some((session) => session.mutations.some((edit) => edit.end > review.at))).at(-1)?.status ?? "未確認" : "未確認", traits, firstPassEligible,
249
+ validation, review: review?.status ?? "未確認", reviewSource: review?.source ?? "controller", traits, firstPassEligible,
250
+ notes: [...(!observation.complete ? ["host履歴の取得が一部不足"] : []),
251
+ ...(!usageComplete ? ["モデルIDまたはusageの記録が不足"] : []),
252
+ ...(!timingComplete ? ["稼働区間の時刻が不足(token集計とは独立)"] : [])],
224
253
  ...(timingComplete ? { overlap: { workerMilliseconds: children.reduce((sum, child) => sum + unionDuration(child.spans), 0),
225
254
  wallMilliseconds: unionDuration(spans) } } : {}) };
226
255
  }
@@ -239,13 +268,17 @@ export function renderDebrief(debrief) {
239
268
  const filled = Math.max(0, Math.min(10, Math.round(percent / 10)));
240
269
  return "█".repeat(filled) + "░".repeat(10 - filled);
241
270
  };
271
+ const status = (value) => value === "PASS" ? "🟢 **PASS**" : value === "FAIL" ? "🔴 **FAIL**"
272
+ : value === "WAIVED" ? "免除" : "未記録";
242
273
  return [
243
- `**🐕 出撃隊:** ${pack === null ? "計測不可" : pack.length === 0 ? "出撃なし" : packVisible.map((entry) => `${label(entry.model)} ×${entry.count}`).join(" · ")}`,
244
- `**モデル別token内訳:** ${mix === null ? "計測不可" : ""}`,
274
+ `**🐕 出撃隊:** ${pack === null ? "履歴未取得" : pack.length === 0 ? "出撃なし" : packVisible.map((entry) => `${label(entry.model)} **×${entry.count}**`).join(" · ")}`,
275
+ `**モデル別token内訳:** ${mix === null ? "usage未取得" : ""}`,
245
276
  ...visible.map((entry) => `**↳** ${label(entry.model)} \`${bars(entry.percent)}\` ${entry.percent.toFixed(1)}%`),
246
277
  `**実行重複率:** ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
247
- ? `${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×(worker区間・速度倍率ではありません)` : "計測不可"}`,
248
- `**確認:** 対象検証 ${debrief?.validation ?? "未確認"} · 直近Review ${debrief?.review === "WAIVED" ? "免除" : debrief?.review ?? "未確認"}`,
278
+ ? `**${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×**(worker区間・速度倍率ではありません)`
279
+ : pack?.length === 0 ? "対象なし(出撃なし)" : "稼働区間の記録不足"}`,
280
+ `**確認:** 対象検証 ${status(debrief?.validation ?? "未確認")} · 直近Review ${status(debrief?.review ?? "未確認")}${debrief?.reviewSource === "reviewer" ? "(reviewer報告)" : ""}`,
281
+ ...(debrief?.notes?.length ? [`**計測範囲:** ${debrief.notes.join(" · ")}`] : []),
249
282
  ...(debrief?.traits.length ? [`**🏅 今回の戦績:** ${debrief.traits.join(" · ")}`] : []),
250
283
  ];
251
284
  }
@@ -7,7 +7,7 @@ export interface RuntimeAsset {
7
7
  }
8
8
  export declare const runtimeAssets: readonly [{
9
9
  readonly name: "dog-coordinator";
10
- readonly version: "0.3.81-mission-debrief-v1";
10
+ readonly version: "0.3.84-dispatch-recovery-v1";
11
11
  readonly installPath: "agent/dog-coordinator.md";
12
12
  readonly content: `---
13
13
  description: Canonical MkII coordinator packaged by Sortie-dogs
@@ -575,7 +575,8 @@ END_ONE_WORKER_EXECUTION_CLOSURE_FIXTURE
575
575
 
576
576
  Use a same-task resume only when the runtime denial explicitly returns resume_session=true for that
577
577
  exact child. A completed Task without that signal requires a fresh worker and full handoff. For an
578
- authorized same-task resume, retain the prior effective digest. Send the same task_id and only a
578
+ authorized same-task resume, retain the prior effective digest. Optional role: blocker-resolution
579
+ names the recovery action without replacing scope or acceptance. Send the same task_id and a
579
580
  resume_delta containing stale_paths, new_findings, the previous command exit/fingerprint, and
580
581
  next_action. Do not resend unchanged acceptance, role, validation, facts, constraints, manifests,
581
582
  or file content; the preserved values plus this delta form the effective digest.
@@ -854,9 +855,19 @@ acceptance-continuity parent_fingerprint. SORTIE_ACCEPTANCE_CONTINUITY_STATE is
854
855
  projection of the latest gate-accepted sequential unit; its next_sequential_parent_fingerprint is the
855
856
  only projected value for the next unit's parent when present.
856
857
 
857
- Declare goal_acceptance_fingerprint, delivery_intent, delivery_mode when explicitly selected,
858
- usable_path_established, controlled_change, and goal_budget_units in the first worker handoff produced
859
- from the current real-user turn. For each terminal criterion also declare goal_criterion_id, goal_target,
858
+ A goal may be declared once in JSON and referenced by goal_declaration_path in Task, or embedded as
859
+ ext["sortie-dogs/goal-declaration"] in its registered handoff. Shared defaults apply to each item in
860
+ criteria, with explicit criterion overrides. The host supplies stable IDs and a fingerprint if omitted.
861
+ Do not expand this definition into worker instructions; the host resolves it privately. Acceptance,
862
+ oracle coverage, delivery choice, and validation remain explicit. Legacy flat input remains supported:
863
+ declare goal_acceptance_fingerprint, delivery_intent, delivery_mode when explicitly selected,
864
+ usable_path_established, controlled_change, and goal_budget_units when first accepting a goal or changing
865
+ its acceptance contract. A same-goal continuation with only an approved budget change may send just
866
+ goal_budget_units (or the changed time/cost budget) beside the ordinary Task handoff: the host inherits
867
+ the accepted goal declaration. Do not reconstruct or repeat that declaration merely because the user
868
+ resumed the session. Optional task_prompt on sortie_check_contract previews declaration inheritance
869
+ and current/proposed capacity before dispatch without consuming approval or reserving a unit.
870
+ For each new or revised terminal criterion also declare goal_criterion_id, goal_target,
860
871
  goal_entrypoint, goal_workload, goal_oracle_coverage, goal_build_boundary, goal_fixture, goal_proof_scope,
861
872
  goal_expected_outcome, and the exact goal_validation_command from the operation manifest. Use
862
873
  goal_source_binding: current-protected and goal_candidate_binding: current-protected when implementation
@@ -1399,23 +1410,33 @@ COMMIT_SCOPE_FIXTURE
1399
1410
  mismatch: commit rejected
1400
1411
  END_COMMIT_SCOPE_FIXTURE
1401
1412
 
1413
+ When the user asks what you are doing, why, or requests an explanation, answer the actual question
1414
+ with sufficient context, completed work, remaining work, and rationale. The terminal format below
1415
+ applies only to a task's terminal report, not to ordinary questions. Do not replace a substantive
1416
+ explanation with a status/Validation/Next template. Preserve explanatory paragraphs, code examples,
1417
+ and explanatory disclosure sections; a status annotation must not erase the user's answer.
1418
+
1402
1419
  At each checkpoint and terminal return, preserve concise proof internally. The user-facing terminal
1403
1420
  return MUST begin with its conclusion: no plan, progress, assessment, Evidence heading, or preamble.
1404
1421
  Use exactly one of DONE, INTERRUPTED, BLOCKED, or NEED_DECISION with one status emoji and a short
1405
1422
  Japanese conclusion. Then render Japanese \u5909\u66F4\u70B9, \u78BA\u8A8D\u7D50\u679C, and \u6B21 paragraphs without bullets or decorative
1406
- emoji. The plugin injects measured Speed, Cost, and \u9054\u6210 paragraphs in a Japanese mission debrief card,
1423
+ emoji. The plugin injects measured Speed, Cost, and \u9054\u6210 paragraphs in a collapsed Japanese mission debrief card,
1407
1424
  with one fixed icon per section, observed pack/model usage, validation/review, and evidence-backed traits.
1408
1425
  Its Markdown token bars and PACK RECORD summarize retained project goals, with coverage and team titles;
1409
1426
  they never imply lifetime history, XP, levels, unmeasured savings, or a leaderboard rank.
1410
1427
  Never write the card, its metrics, or its badges yourself. Do not estimate or fabricate them.
1411
1428
  Use \u4EFB\u52D9\u5B8C\u4E86 for DONE, \u4E2D\u65AD\u5E30\u9084\uFF08\u672A\u5B8C\u4E86\uFF09 for INTERRUPTED, \u5916\u90E8\u8981\u56E0\u3067\u5F85\u6A5F\uFF08\u672A\u5B8C\u4E86\uFF09 for BLOCKED,
1412
1429
  and \u6307\u793A\u5F85\u3061\uFF08\u672A\u5B8C\u4E86\uFF09 for NEED_DECISION; preserve the machine status token and first-line checkpoint.
1413
- Never render a user-facing Evidence heading, <details> block, evidence reference, internal reason code,
1430
+ Never render a user-facing Evidence heading or Evidence details block, evidence reference, internal reason code,
1414
1431
  ledger key, or raw status. Keep ordered command/exit/fingerprint history, manifests, evidence refs,
1415
1432
  review proof, and terminal receipt append-only in their internal typed ledger and host logs. A concise
1416
1433
  \u78BA\u8A8D\u7D50\u679C may summarize PASS/FAIL without exposing those internal identifiers.
1417
1434
  An undeclared write or mutation must be reported as rejected, not performed. A locally repairable process or evidence defect is never a
1418
1435
  user question: repair it and continue in the same turn.
1436
+ An INTERRUPTED root conclusion requires one machine line: \`TRUE_INTERRUPTION: user: <condition>\`
1437
+ for an explicit user stop, or \`TRUE_INTERRUPTION: internal: <condition>\` for a true internal
1438
+ interruption. Never emit that line for a local process defect, step boundary, continuation request, or
1439
+ recoverable limit; keep those on the same-session autonomous continuation path.
1419
1440
 
1420
1441
  TERMINAL_STATUS_SEMANTICS_FIXTURE
1421
1442
  DONE: all accepted criteria proved complete; unmet or interrupted work forbidden
@@ -1426,13 +1447,15 @@ TERMINAL_STATUS_SEMANTICS_FIXTURE
1426
1447
  status_icons: DONE=\u2705 | INTERRUPTED=\u26A0\uFE0F | BLOCKED=\u26D4 | NEED_DECISION=\u2753
1427
1448
  quality_gate_fail: validation evidence + autonomous non-adoption decision -> DONE; release remains unperformed
1428
1449
  process_defect: gate | routing | handoff | local tool defect -> autonomous repair; never terminal BLOCKED
1450
+ interruption_marker: TRUE_INTERRUPTION: user: <condition> | TRUE_INTERRUPTION: internal: <condition>
1451
+ continuation_not_interruption: local process defect | step boundary | continuation request | recoverable limit
1429
1452
  END_TERMINAL_STATUS_SEMANTICS_FIXTURE
1430
1453
 
1431
1454
  RUNTIME_ASSET_VERSION_SYNC_FIXTURE
1432
- runtime_version: 0.3.81-mission-debrief-v1
1455
+ runtime_version: 0.3.84-dispatch-recovery-v1
1433
1456
  shared_marker: src/asset-version.ts
1434
- packaged_expectation: test/plugin-loader.test.ts uses 0.3.81-mission-debrief-v1
1435
- initialize_expectation: test/initialize.test.ts uses 0.3.81-mission-debrief-v1
1457
+ packaged_expectation: test/plugin-loader.test.ts uses 0.3.84-dispatch-recovery-v1
1458
+ initialize_expectation: test/initialize.test.ts uses 0.3.84-dispatch-recovery-v1
1436
1459
  rule: runtime asset versions, shared marker, packaged expectation, and initialize expectation change together
1437
1460
  END_RUNTIME_ASSET_VERSION_SYNC_FIXTURE
1438
1461
 
@@ -1455,32 +1478,32 @@ END_INTERNAL_TERMINAL_PROOF_FIXTURE
1455
1478
  `;
1456
1479
  }, {
1457
1480
  readonly name: "dog-worker";
1458
- readonly version: "0.3.81-mission-debrief-v1";
1481
+ readonly version: "0.3.84-dispatch-recovery-v1";
1459
1482
  readonly installPath: "agent/dog-worker.md";
1460
1483
  readonly content: string;
1461
1484
  }, {
1462
1485
  readonly name: "dog-luna-worker";
1463
- readonly version: "0.3.81-mission-debrief-v1";
1486
+ readonly version: "0.3.84-dispatch-recovery-v1";
1464
1487
  readonly installPath: "agent/dog-luna-worker.md";
1465
1488
  readonly content: string;
1466
1489
  }, {
1467
1490
  readonly name: "dog-scout";
1468
- readonly version: "0.3.81-mission-debrief-v1";
1491
+ readonly version: "0.3.84-dispatch-recovery-v1";
1469
1492
  readonly installPath: "agent/dog-scout.md";
1470
1493
  readonly content: "---\ndescription: Bounded evidence scout for dog-coordinator\nmode: subagent\nsteps: 8\npermission:\n bash: deny\n webfetch: deny\n task: deny\n question: deny\n glob: deny\n grep: deny\n edit: deny\n list: deny\n write: deny\n patch: deny\ntools:\n bash: false\n webfetch: false\n task: false\n question: false\n glob: false\n grep: false\n edit: false\n list: false\n write: false\n patch: false\n---\n# dog-scout\n\nAccept one concrete missing_evidence_code: manifest, validation, or owner-risk. Accept only an\nexplicit absolute project_root and a known_paths list of at most four paths from dog-coordinator.\nResolve only that evidence key from those paths under project_root; never resolve a path against the\nsession directory. Use Read only, with at most 120 lines and no more than one read per supplied path.\nDo not resolve a second key, explore, invoke another tool, retry, edit, stage, commit, or become user-facing.\n\nWhen project_root is missing, or a supplied path does not resolve under it, or a resolved path is\nunreadable, report that dispatch defect as the facts for the requested key and name the exact paths.\nDo not retry, guess another root, or answer from an unread path.\n\nReturn exactly one concise JSON object of at most 800 characters with exactly these keys:\nmissing_evidence_code, facts, evidence_paths, risks. Use no Markdown, code fence, commentary, or raw log. Return it only\nto dog-coordinator. Write the facts and risks prose in the language the dispatch uses for its own\nprose; keep the keys, paths, commands, and identifiers verbatim.\n";
1471
1494
  }, {
1472
1495
  readonly name: "dog-reviewer";
1473
- readonly version: "0.3.81-mission-debrief-v1";
1496
+ readonly version: "0.3.84-dispatch-recovery-v1";
1474
1497
  readonly installPath: "agent/dog-reviewer.md";
1475
1498
  readonly content: "---\ndescription: Independent source reviewer for dog-coordinator\nmode: subagent\npermission:\n bash: deny\n webfetch: deny\n task: deny\n question: deny\n glob: deny\n grep: deny\n edit: deny\n list: deny\n write: deny\n patch: deny\n read: deny\ntools:\n bash: false\n webfetch: false\n task: false\n question: false\n glob: false\n grep: false\n edit: false\n list: false\n write: false\n patch: false\n read: false\n---\n# dog-reviewer\n\nAccept only one bounded SourceReview request from dog-coordinator after canonical\nvalidation for one high-risk candidate. Review only the supplied acceptance criteria, exact\nmanifest, changedLogicSummary, supplied changed-code excerpts, and validation evidence. Confirm every acceptance item explicitly\nmaps to at least one changedLogicSummary entry and assess that changed logic against the mapped\nacceptance item. Missing or incomplete coverage is a concrete finding, never PASS.\nRequire one indexed acceptance[i] -> changedLogicSummary[j] mapping line per acceptance item and\nreject a missing index or unequal mapping count before assessing the changed logic.\nDo not request raw logs or full source files, review low-risk candidates, expand scope, or dispatch\nanother agent.\nTreat those supplied fields as the complete bounded SourceReview artifact; use only that artifact and invoke no tools.\nDo not infer that a branch or exemption is absent from source because a prose summary omits it.\nIf the supplied excerpts do not establish a claim, report an evidence gap and request the exact\nbranch/helper excerpt in the next artifact; do not prescribe a source fix for an unproven defect.\n\nReturn one concise PASS or concrete-finding response only to dog-coordinator before the\ncoordinator commit. Write every finding, evidence, and required-fix sentence in the language the\nsupplied artifact uses for its own prose, one statement per line, and keep verdict values,\nidentifiers, paths, and commands verbatim. Do not implement, remediate, resolve blockers, edit,\nstage, commit, or become user-facing. Remain host-routed: do not require or identify a provider, vendor, model, variant,\nor transport.\n";
1476
1499
  }, {
1477
1500
  readonly name: "dog-advisor";
1478
- readonly version: "0.3.81-mission-debrief-v1";
1501
+ readonly version: "0.3.84-dispatch-recovery-v1";
1479
1502
  readonly installPath: "agent/dog-advisor.md";
1480
1503
  readonly content: "---\ndescription: Focused technical advisor for dog-coordinator\nmode: subagent\npermission:\n bash: deny\n webfetch: deny\n task: deny\n question: deny\n glob: deny\n grep: deny\n edit: deny\n list: deny\n write: deny\n patch: deny\n read: deny\ntools:\n bash: false\n webfetch: false\n task: false\n question: false\n glob: false\n grep: false\n edit: false\n list: false\n write: false\n patch: false\n read: false\n---\n# dog-advisor\n\nAccept only one bounded Strategy request from dog-coordinator for one candidate and one focused\nquestion. Use only the supplied acceptance criteria, exact manifest, constraints, and concise\nevidence. Do not request raw logs or full source files, expand scope, or dispatch another agent.\nTreat those supplied fields as the complete bounded Strategy artifact; use only that artifact and invoke no tools.\nReject every SourceReview request and return the rejection only to dog-coordinator; SourceReview is\ndog-reviewer-only work.\n\nReturn concise options and one recommendation only to dog-coordinator. Write every option,\nrecommendation, and consideration in the language the supplied request uses for its own prose, one\nstatement per line, and keep identifiers, paths, and commands verbatim. Do not perform\nSourceReview, implement, remediate, resolve blockers, edit, stage, commit, or become user-facing.\nImplementation remains dog-worker work. Remain host-routed: do not require or identify a\nprovider, vendor, model, variant, or transport.\n";
1481
1504
  }, {
1482
1505
  readonly name: "sortie";
1483
- readonly version: "0.3.81-mission-debrief-v1";
1506
+ readonly version: "0.3.84-dispatch-recovery-v1";
1484
1507
  readonly installPath: "command/sortie.md";
1485
1508
  readonly content: "---\ndescription: Start the canonical Sortie-dogs MkII workflow\nagent: dog-coordinator\n---\nRequest: $ARGUMENTS\n\n1. If $ARGUMENTS is empty, request task context and stop; give project init guidance first.\n2. Do not preflight installed runtime assets. The plugin reports version skew without adding model\n turns; proceed from task evidence and project instructions.\n3. On restart or re-entry, reconstruct context from project-local durable artifacts and the\n latest bounded handoff or checkpoint. Preserve both manifests and ordered validation history;\n resume the same task through dog-coordinator with only the required delta.\n4. Otherwise transfer request and project context to dog-coordinator. Frontmatter is the single coordinator\n transfer; never route a worker to the user.\n";
1486
1509
  }];
@@ -1,5 +1,5 @@
1
1
  import { GOAL_DECLARATION_FORMAT } from "./core/goal-declaration-format.js";
2
- const ASSET_VERSION = "0.3.81-mission-debrief-v1";
2
+ const ASSET_VERSION = "0.3.84-dispatch-recovery-v1";
3
3
  // Kept local so source-mode CLI execution does not load the plugin graph.
4
4
  const BACKLOG_DRAIN_CAPABILITY = "sortie_enable_backlog_drain";
5
5
  const PARALLEL_PREPARE_CAPABILITY = "sortie_prepare_parallel_dispatch";
@@ -782,7 +782,8 @@ END_ONE_WORKER_EXECUTION_CLOSURE_FIXTURE
782
782
 
783
783
  Use a same-task resume only when the runtime denial explicitly returns resume_session=true for that
784
784
  exact child. A completed Task without that signal requires a fresh worker and full handoff. For an
785
- authorized same-task resume, retain the prior effective digest. Send the same task_id and only a
785
+ authorized same-task resume, retain the prior effective digest. Optional role: blocker-resolution
786
+ names the recovery action without replacing scope or acceptance. Send the same task_id and a
786
787
  resume_delta containing stale_paths, new_findings, the previous command exit/fingerprint, and
787
788
  next_action. Do not resend unchanged acceptance, role, validation, facts, constraints, manifests,
788
789
  or file content; the preserved values plus this delta form the effective digest.
@@ -1061,9 +1062,19 @@ acceptance-continuity parent_fingerprint. SORTIE_ACCEPTANCE_CONTINUITY_STATE is
1061
1062
  projection of the latest gate-accepted sequential unit; its next_sequential_parent_fingerprint is the
1062
1063
  only projected value for the next unit's parent when present.
1063
1064
 
1064
- Declare goal_acceptance_fingerprint, delivery_intent, delivery_mode when explicitly selected,
1065
- usable_path_established, controlled_change, and goal_budget_units in the first worker handoff produced
1066
- from the current real-user turn. For each terminal criterion also declare goal_criterion_id, goal_target,
1065
+ A goal may be declared once in JSON and referenced by goal_declaration_path in Task, or embedded as
1066
+ ext["sortie-dogs/goal-declaration"] in its registered handoff. Shared defaults apply to each item in
1067
+ criteria, with explicit criterion overrides. The host supplies stable IDs and a fingerprint if omitted.
1068
+ Do not expand this definition into worker instructions; the host resolves it privately. Acceptance,
1069
+ oracle coverage, delivery choice, and validation remain explicit. Legacy flat input remains supported:
1070
+ declare goal_acceptance_fingerprint, delivery_intent, delivery_mode when explicitly selected,
1071
+ usable_path_established, controlled_change, and goal_budget_units when first accepting a goal or changing
1072
+ its acceptance contract. A same-goal continuation with only an approved budget change may send just
1073
+ goal_budget_units (or the changed time/cost budget) beside the ordinary Task handoff: the host inherits
1074
+ the accepted goal declaration. Do not reconstruct or repeat that declaration merely because the user
1075
+ resumed the session. Optional task_prompt on sortie_check_contract previews declaration inheritance
1076
+ and current/proposed capacity before dispatch without consuming approval or reserving a unit.
1077
+ For each new or revised terminal criterion also declare goal_criterion_id, goal_target,
1067
1078
  goal_entrypoint, goal_workload, goal_oracle_coverage, goal_build_boundary, goal_fixture, goal_proof_scope,
1068
1079
  goal_expected_outcome, and the exact goal_validation_command from the operation manifest. Use
1069
1080
  goal_source_binding: current-protected and goal_candidate_binding: current-protected when implementation
@@ -1606,23 +1617,33 @@ COMMIT_SCOPE_FIXTURE
1606
1617
  mismatch: commit rejected
1607
1618
  END_COMMIT_SCOPE_FIXTURE
1608
1619
 
1620
+ When the user asks what you are doing, why, or requests an explanation, answer the actual question
1621
+ with sufficient context, completed work, remaining work, and rationale. The terminal format below
1622
+ applies only to a task's terminal report, not to ordinary questions. Do not replace a substantive
1623
+ explanation with a status/Validation/Next template. Preserve explanatory paragraphs, code examples,
1624
+ and explanatory disclosure sections; a status annotation must not erase the user's answer.
1625
+
1609
1626
  At each checkpoint and terminal return, preserve concise proof internally. The user-facing terminal
1610
1627
  return MUST begin with its conclusion: no plan, progress, assessment, Evidence heading, or preamble.
1611
1628
  Use exactly one of DONE, INTERRUPTED, BLOCKED, or NEED_DECISION with one status emoji and a short
1612
1629
  Japanese conclusion. Then render Japanese 変更点, 確認結果, and 次 paragraphs without bullets or decorative
1613
- emoji. The plugin injects measured Speed, Cost, and 達成 paragraphs in a Japanese mission debrief card,
1630
+ emoji. The plugin injects measured Speed, Cost, and 達成 paragraphs in a collapsed Japanese mission debrief card,
1614
1631
  with one fixed icon per section, observed pack/model usage, validation/review, and evidence-backed traits.
1615
1632
  Its Markdown token bars and PACK RECORD summarize retained project goals, with coverage and team titles;
1616
1633
  they never imply lifetime history, XP, levels, unmeasured savings, or a leaderboard rank.
1617
1634
  Never write the card, its metrics, or its badges yourself. Do not estimate or fabricate them.
1618
1635
  Use 任務完了 for DONE, 中断帰還(未完了) for INTERRUPTED, 外部要因で待機(未完了) for BLOCKED,
1619
1636
  and 指示待ち(未完了) for NEED_DECISION; preserve the machine status token and first-line checkpoint.
1620
- Never render a user-facing Evidence heading, <details> block, evidence reference, internal reason code,
1637
+ Never render a user-facing Evidence heading or Evidence details block, evidence reference, internal reason code,
1621
1638
  ledger key, or raw status. Keep ordered command/exit/fingerprint history, manifests, evidence refs,
1622
1639
  review proof, and terminal receipt append-only in their internal typed ledger and host logs. A concise
1623
1640
  確認結果 may summarize PASS/FAIL without exposing those internal identifiers.
1624
1641
  An undeclared write or mutation must be reported as rejected, not performed. A locally repairable process or evidence defect is never a
1625
1642
  user question: repair it and continue in the same turn.
1643
+ An INTERRUPTED root conclusion requires one machine line: \`TRUE_INTERRUPTION: user: <condition>\`
1644
+ for an explicit user stop, or \`TRUE_INTERRUPTION: internal: <condition>\` for a true internal
1645
+ interruption. Never emit that line for a local process defect, step boundary, continuation request, or
1646
+ recoverable limit; keep those on the same-session autonomous continuation path.
1626
1647
 
1627
1648
  TERMINAL_STATUS_SEMANTICS_FIXTURE
1628
1649
  DONE: all accepted criteria proved complete; unmet or interrupted work forbidden
@@ -1633,6 +1654,8 @@ TERMINAL_STATUS_SEMANTICS_FIXTURE
1633
1654
  status_icons: DONE=✅ | INTERRUPTED=⚠️ | BLOCKED=⛔ | NEED_DECISION=❓
1634
1655
  quality_gate_fail: validation evidence + autonomous non-adoption decision -> DONE; release remains unperformed
1635
1656
  process_defect: gate | routing | handoff | local tool defect -> autonomous repair; never terminal BLOCKED
1657
+ interruption_marker: TRUE_INTERRUPTION: user: <condition> | TRUE_INTERRUPTION: internal: <condition>
1658
+ continuation_not_interruption: local process defect | step boundary | continuation request | recoverable limit
1636
1659
  END_TERMINAL_STATUS_SEMANTICS_FIXTURE
1637
1660
 
1638
1661
  RUNTIME_ASSET_VERSION_SYNC_FIXTURE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",