sortie-dogs 0.12.6 → 0.12.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.
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.12.3-v2-zod-schema-compaction-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.8-mission-lineage-v1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.12.3-v2-zod-schema-compaction-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.8-mission-lineage-v1";
package/dist/cli/main.js CHANGED
@@ -258,6 +258,14 @@ export async function run(argv) {
258
258
  if (initialized.preservedLegacyPaths.length > 0) {
259
259
  process.stdout.write(`Preserved legacy runtime files: ${initialized.preservedLegacyPaths.join(", ")}.\n`);
260
260
  }
261
+ const override = profile === "v010"
262
+ ? await initializer.coordinatorModelOverride(global ? target : resolve(initArgs[0] ?? process.cwd()), global).catch(() => undefined)
263
+ : undefined;
264
+ if (override) {
265
+ process.stderr.write(`Warning: ${override.path} routes dogs-coordinator to ${override.model}. ` +
266
+ `Sortie-dogs expects ${initializer.V010_COORDINATOR_MODEL}; a gpt-6-luna-fast Coordinator was measured to repeat plans and reviews until timeout. ` +
267
+ "Remove the override unless it is intentional.\n");
268
+ }
261
269
  return 0;
262
270
  }
263
271
  catch (error) {
@@ -11,6 +11,15 @@ export declare class ProjectInitializationError extends Error {
11
11
  readonly code: ProjectInitializationErrorCode;
12
12
  constructor(code: ProjectInitializationErrorCode, message: string, options?: ErrorOptions);
13
13
  }
14
+ export declare const V010_COORDINATOR_MODEL = "openai/gpt-6-sol#xhigh";
15
+ /**
16
+ * Report a user config that routes the v0.10 Coordinator away from its packaged model. Measured Luna
17
+ * Coordinators repeated plans and review dispatches until timeout, so init surfaces it without editing it.
18
+ */
19
+ export declare function coordinatorModelOverride(root: string, global: boolean): Promise<{
20
+ path: string;
21
+ model: string;
22
+ } | undefined>;
14
23
  /** Resolves the OpenCode global configuration directory without platform-specific paths. */
15
24
  export declare function resolveGlobalConfigRoot(env?: NodeJS.ProcessEnv, home?: string): Promise<string>;
16
25
  export declare function initializeProject(projectRoot?: string, profile?: RuntimeProfileId): Promise<InitializeProjectResult>;
@@ -536,6 +536,34 @@ async function initializeRoot(requestedRoot, layout, installAssets = runtimeAsse
536
536
  preservedLegacyPaths,
537
537
  };
538
538
  }
539
+ export const V010_COORDINATOR_MODEL = "openai/gpt-6-sol#xhigh";
540
+ /**
541
+ * Report a user config that routes the v0.10 Coordinator away from its packaged model. Measured Luna
542
+ * Coordinators repeated plans and review dispatches until timeout, so init surfaces it without editing it.
543
+ */
544
+ export async function coordinatorModelOverride(root, global) {
545
+ const prefix = global ? "" : `${OPEN_CODE_DIRECTORY}/`;
546
+ for (const relativePath of [`${prefix}opencode.jsonc`, `${prefix}opencode.json`]) {
547
+ let content;
548
+ try {
549
+ content = await readFile(resolve(root, relativePath), "utf8");
550
+ }
551
+ catch (error) {
552
+ if (error.code === "ENOENT")
553
+ continue;
554
+ throw error;
555
+ }
556
+ const config = parse(content, [], { allowTrailingComma: true });
557
+ for (const key of ["agents", "agent"]) {
558
+ const agents = config?.[key];
559
+ const coordinator = agents !== null && typeof agents === "object" ? agents["dogs-coordinator"] : undefined;
560
+ const model = coordinator !== null && typeof coordinator === "object" ? coordinator.model : undefined;
561
+ if (typeof model === "string" && model !== V010_COORDINATOR_MODEL)
562
+ return { path: resolve(root, relativePath), model };
563
+ }
564
+ }
565
+ return undefined;
566
+ }
539
567
  /** Resolves the OpenCode global configuration directory without platform-specific paths. */
540
568
  export async function resolveGlobalConfigRoot(env = process.env, home = homedir()) {
541
569
  if (env.OPENCODE_CONFIG_DIR)
@@ -40,11 +40,22 @@ export interface OperatorMission {
40
40
  risk: string[];
41
41
  source: string;
42
42
  task: OperatorTask | null;
43
- verdict: "pending" | "PASS" | "findings" | "skipped-low-risk";
43
+ verdict: "pending" | "PASS" | "findings" | "evidence-gaps" | "skipped-low-risk";
44
44
  result?: string;
45
45
  child?: string;
46
+ /** Reviews on this mission that found only missing evidence; bounded by MISSION_EVIDENCE_GAP_REVIEW_LIMIT. */
47
+ evidenceGapReviews?: number;
46
48
  };
47
49
  }
50
+ /**
51
+ * Evidence-only findings do not establish a defect. Each extra round costs a full Reviewer pass and often a
52
+ * Worker unit, so after this many evidence-only reviews the candidate may be submitted with the gaps listed.
53
+ */
54
+ export declare const MISSION_EVIDENCE_GAP_REVIEW_LIMIT = 2;
55
+ /** Classify an independent Reviewer's first line. Anything else is a finding. */
56
+ export declare function missionReviewVerdict(text: string): "PASS" | "evidence-gaps" | "findings";
57
+ /** Whether the recorded review permits submission and acceptance of the current candidate. */
58
+ export declare function missionReviewAccepted(review: NonNullable<OperatorMission["review"]>): boolean;
48
59
  /** Durable user intent and dispatch ownership. Execution/evidence still belong to the v0.10 engine. */
49
60
  export declare class OperatorMissionRuntime {
50
61
  readonly projectRoot: string;
@@ -64,6 +75,8 @@ export declare class OperatorMissionRuntime {
64
75
  capture(root: string, request: MissionRequest): Promise<void>;
65
76
  start(root: string, requirements: unknown): Promise<OperatorMission>;
66
77
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
78
+ /** Keep a cancelled same-turn run's host-accepted criteria ahead of new mission text. */
79
+ carryForward(root: string, missionID: string, acceptance: readonly string[]): Promise<OperatorMission>;
67
80
  task(state: OperatorMission): OperatorTask;
68
81
  admit(root: string, callID: string, args: Record<string, unknown>): Promise<OperatorMission>;
69
82
  /** Reopen only the exact dispatch whose native Task has a terminal record after a server restart. */
@@ -18,6 +18,20 @@ export function missionValidationCommand(command) {
18
18
  const expression = `exec(${JSON.stringify(heredoc[4])})`;
19
19
  return `${heredoc[1]} -c '${expression.replaceAll("'", "'\\''")}'`;
20
20
  }
21
+ /**
22
+ * Evidence-only findings do not establish a defect. Each extra round costs a full Reviewer pass and often a
23
+ * Worker unit, so after this many evidence-only reviews the candidate may be submitted with the gaps listed.
24
+ */
25
+ export const MISSION_EVIDENCE_GAP_REVIEW_LIMIT = 2;
26
+ /** Classify an independent Reviewer's first line. Anything else is a finding. */
27
+ export function missionReviewVerdict(text) {
28
+ return /^\s*PASS(?:\s|$)/u.test(text) ? "PASS" : /^\s*EVIDENCE_GAPS(?:\s|$)/u.test(text) ? "evidence-gaps" : "findings";
29
+ }
30
+ /** Whether the recorded review permits submission and acceptance of the current candidate. */
31
+ export function missionReviewAccepted(review) {
32
+ return review.verdict === "PASS" || review.verdict === "skipped-low-risk" ||
33
+ (review.verdict === "evidence-gaps" && (review.evidenceGapReviews ?? 0) >= MISSION_EVIDENCE_GAP_REVIEW_LIMIT);
34
+ }
21
35
  /** Durable user intent and dispatch ownership. Execution/evidence still belong to the v0.10 engine. */
22
36
  export class OperatorMissionRuntime {
23
37
  projectRoot;
@@ -44,7 +58,7 @@ export class OperatorMissionRuntime {
44
58
  async loadMission(root) {
45
59
  const state = await this.load(this.file(root));
46
60
  if (!state || state.supersededRunID !== undefined || state.runID !== null ||
47
- !["open", "running", "submitted"].includes(state.phase) || state.requests.length === 0)
61
+ !["open", "running", "submitted", "cancelled"].includes(state.phase) || state.requests.length === 0)
48
62
  return state;
49
63
  const directory = join(this.projectRoot, this.profile.stateDirectory, "missions");
50
64
  let entries;
@@ -61,13 +75,18 @@ export class OperatorMissionRuntime {
61
75
  const matches = [];
62
76
  for (const name of candidates) {
63
77
  const previous = await this.load(join(directory, name));
64
- if (previous?.root === root && previous.phase === "cancelled" && previous.runID !== null &&
65
- previous.requests[0]?.id !== state.requests[0].id &&
78
+ if (previous?.root === root && previous.phase === "cancelled" &&
79
+ (previous.runID !== null || previous.supersededRunID !== undefined) &&
80
+ (previous.runID === null || previous.requests[0]?.id !== state.requests[0].id) &&
66
81
  previous.requests.some(request => request.id === state.requests[0].id))
67
82
  matches.push(previous);
68
83
  }
69
- if (matches.length === 1)
70
- state.supersededRunID = matches[0].runID;
84
+ if (matches.length === 1) {
85
+ const predecessor = matches[0].runID ?? matches[0].supersededRunID;
86
+ if (predecessor && (matches[0].runID !== null || await this.archivedRun(root, predecessor))) {
87
+ state.supersededRunID = predecessor;
88
+ }
89
+ }
71
90
  return state;
72
91
  }
73
92
  /** Find exactly one archived cancelled mission that owned this run; ambiguity never grants recovery. */
@@ -164,8 +183,10 @@ export class OperatorMissionRuntime {
164
183
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
165
184
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
166
185
  coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
167
- ...(previous?.phase === "cancelled" && previous.runID !== null && request.id !== previous.requests[0]?.id
168
- ? { supersededRunID: previous.runID } : {}) };
186
+ ...(previous?.phase === "cancelled" &&
187
+ (previous.runID === null || request.id !== previous.requests[0]?.id) &&
188
+ (previous.runID ?? previous.supersededRunID)
189
+ ? { supersededRunID: (previous.runID ?? previous.supersededRunID) } : {}) };
169
190
  await this.save(this.file(root), state);
170
191
  return state;
171
192
  });
@@ -180,6 +201,23 @@ export class OperatorMissionRuntime {
180
201
  return state;
181
202
  });
182
203
  }
204
+ /** Keep a cancelled same-turn run's host-accepted criteria ahead of new mission text. */
205
+ carryForward(root, missionID, acceptance) {
206
+ return this.update(root, state => {
207
+ if (state.id !== missionID || state.supersededRunID !== undefined || state.runID !== null ||
208
+ !["open", "running"].includes(state.phase) || (state.dispatchOpen && state.coordinator === null) ||
209
+ acceptance.length === 0 || acceptance.some(text => typeof text !== "string" || !text.trim())) {
210
+ throw new Error("mission-acceptance-carry-forward-unavailable");
211
+ }
212
+ const existing = state.requirements.map(item => item.text);
213
+ if (acceptance.every((text, index) => existing[index] === text))
214
+ return;
215
+ const additions = existing.filter(text => !acceptance.includes(text));
216
+ if (acceptance.length + additions.length > 64)
217
+ throw new Error("mission-requirements-limit: carried acceptance and additions exceed 64");
218
+ state.requirements = [...acceptance, ...additions].map((text, index) => ({ id: `R${index + 1}`, text }));
219
+ });
220
+ }
183
221
  task(state) {
184
222
  return { subagent_type: profileAgent(this.profile, "dog-operator"), description: state.requirements[0].text.slice(0, 100),
185
223
  prompt: `${MISSION_REFERENCE}${JSON.stringify({ r: state.root, m: state.id, h: digest(JSON.stringify(state.requirements)) })}`,
@@ -294,12 +332,15 @@ export function missionPacket(mission, run) {
294
332
  submission: mission.submission, progress: mission.progress,
295
333
  review: mission.review ? { risk_tags: mission.review.risk, verdict: mission.review.verdict,
296
334
  source_fingerprint: mission.review.source, reviewer_session_id: mission.review.child ?? null,
297
- result: mission.review.result ?? null } : null,
335
+ result: mission.review.result ?? null, evidence_gap_reviews: mission.review.evidenceGapReviews ?? 0,
336
+ evidence_gap_review_limit: MISSION_EVIDENCE_GAP_REVIEW_LIMIT, accepted: missionReviewAccepted(mission.review) } : null,
298
337
  ...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
299
338
  units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
300
339
  child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence })) } : {}),
301
340
  next_action: run?.phase === "awaiting-decision" ? "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative."
302
- : run?.phase === "awaiting-acceptance" ? "Coordinator: obtain required independent review, then submit_mission. Operator: compare all requirements with source/evidence before complete_mission."
341
+ : run?.phase === "awaiting-acceptance" ? (mission.review?.verdict === "evidence-gaps" && !missionReviewAccepted(mission.review)
342
+ ? "Coordinator: the Reviewer found only missing evidence. Supply it through review_mission traces (or at most one evidence-only unit) and re-review once; do not re-implement. At the evidence-gap limit, submit_mission ready with the gaps listed."
343
+ : "Coordinator: obtain required independent review, then submit_mission. Operator: compare all requirements with source/evidence before complete_mission.")
303
344
  : "Coordinator: continue the next useful unit within original requirements. Return only a completion candidate, user-only decision, or scope/budget extension." };
304
345
  }
305
346
  /** Models forward a short capability, never recopy the host's evidence hashes and source packet. */
@@ -109,7 +109,7 @@ interface UnitState {
109
109
  readonly task: OperatorTask;
110
110
  readonly handoffPath: string;
111
111
  readonly manifestPath: string;
112
- readonly hashes: readonly string[];
112
+ hashes: readonly string[];
113
113
  status: "pending" | "running" | "succeeded" | "failed" | "cancelled";
114
114
  callID: string | null;
115
115
  childSessionID: string | null;
@@ -245,7 +245,7 @@ export declare class OperatorRuntime {
245
245
  prepareMission(root: string, raw: unknown, dispatcher?: {
246
246
  sessionID: string;
247
247
  callID: string;
248
- }, supersededRunID?: string): Promise<OperatorState>;
248
+ }, supersededRunID?: string, terminalChildren?: readonly string[]): Promise<OperatorState>;
249
249
  retireMissionRun(root: string): Promise<void>;
250
250
  private prepareOnce;
251
251
  operatorTask(state: OperatorState): OperatorTask;
@@ -332,6 +332,8 @@ export declare class OperatorRuntime {
332
332
  packet(state: OperatorState): unknown;
333
333
  continuationCheckpoint(root: string): Promise<string | undefined>;
334
334
  private verifyControls;
335
+ /** Follow only the host's authorized parent-link repair; keep the durable control pin in sync. */
336
+ acknowledgeHostHandoffRepair(root: string, taskID: string, path: string, original: string, repaired: string): Promise<void>;
335
337
  private gitPaths;
336
338
  private pathAuthorized;
337
339
  /**
@@ -678,8 +678,8 @@ export class OperatorRuntime {
678
678
  }
679
679
  }
680
680
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
681
- prepareMission(root, raw, dispatcher, supersededRunID) {
682
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID }));
681
+ prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = []) {
682
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren }));
683
683
  }
684
684
  retireMissionRun(root) {
685
685
  return this.serial(root, async () => {
@@ -727,9 +727,15 @@ export class OperatorRuntime {
727
727
  previous.phase === "cancelled";
728
728
  if (mission?.supersededRunID !== undefined && !superseding)
729
729
  throw new Error("mission-superseded-run-mismatch");
730
+ const terminalChildren = mission?.terminalChildren ?? [];
731
+ const cancelledChildren = previous?.units.flatMap(unit => unit.status === "cancelled" && unit.childSessionID !== null
732
+ ? [unit.childSessionID] : []) ?? [];
730
733
  if (superseding && previous && (previous.decision !== "explicit-cancellation" || previous.gitLifecycle !== null ||
731
734
  previous.repairResidualPaths.length > 0 || previous.priorAcceptedUnits.length > 0 ||
732
- previous.units.some(unit => unit.childSessionID !== null || unit.status === "succeeded"))) {
735
+ terminalChildren.length !== cancelledChildren.length || new Set(terminalChildren).size !== terminalChildren.length ||
736
+ cancelledChildren.some(id => !terminalChildren.includes(id)) || previous.units.some(unit => !["pending", "cancelled"].includes(unit.status) || unit.evidence.length > 0 || unit.resultClass !== null ||
737
+ unit.repairValidation !== null || (unit.status === "pending" && (unit.callID !== null || unit.childSessionID !== null)) ||
738
+ (unit.status === "cancelled" && (unit.childSessionID === null || unit.callID === null))))) {
733
739
  throw new Error("mission-superseded-run-has-work: reconcile prior workers and evidence before changing acceptance");
734
740
  }
735
741
  if (plan.git_lifecycle !== undefined) {
@@ -743,6 +749,14 @@ export class OperatorRuntime {
743
749
  // Cancellation stops execution, not the accepted user order. A replacement
744
750
  // plan must carry the original ordered criteria; only a completed root clears them.
745
751
  const parent = !superseding && previous?.phase === "cancelled" ? previous : undefined;
752
+ if (parent?.decision === "explicit-cancellation") {
753
+ const cancelled = parent.units.flatMap(unit => unit.status === "cancelled" && unit.childSessionID !== null
754
+ ? [unit.childSessionID] : []);
755
+ if (cancelled.length > 0 && (new Set(terminalChildren).size !== terminalChildren.length ||
756
+ cancelled.some(id => !terminalChildren.includes(id)))) {
757
+ throw new Error("mission-cancelled-run-worker-not-terminal");
758
+ }
759
+ }
746
760
  if (parent?.decision === "operator-contract-repair-unavailable-after-cancel") {
747
761
  // The diagnosed transients, not the cancellation itself, block a replacement. Once they are
748
762
  // gone the same accepted order may proceed; while they remain the refusal names them exactly.
@@ -1893,6 +1907,30 @@ export class OperatorRuntime {
1893
1907
  if (contents.some((value, index) => hash(value) !== unit.hashes[index]))
1894
1908
  throw new Error("operator-contract-changed");
1895
1909
  }
1910
+ /** Follow only the host's authorized parent-link repair; keep the durable control pin in sync. */
1911
+ acknowledgeHostHandoffRepair(root, taskID, path, original, repaired) {
1912
+ return this.serial(root, async () => {
1913
+ const state = await this.read(root);
1914
+ const unit = state?.units.find(candidate => candidate.status === "running" && candidate.handoffPath === path &&
1915
+ /^task_id: (.+)$/m.exec(candidate.task.prompt)?.[1] === taskID);
1916
+ if (!unit)
1917
+ return; // The same host repair also serves non-Operator handoffs.
1918
+ if (hash(original) !== unit.hashes[0] || await readFile(path, "utf8") !== repaired) {
1919
+ throw new Error("operator-host-handoff-repair-unpinned");
1920
+ }
1921
+ const before = JSON.parse(original), after = JSON.parse(repaired);
1922
+ const oldLink = inspectAcceptanceContinuity(before).ledger;
1923
+ const newLink = inspectAcceptanceContinuity(after).ledger;
1924
+ if (!oldLink || !newLink || oldLink.task_id !== taskID || oldLink.parent_fingerprint !== "none" ||
1925
+ newLink.task_id !== taskID || newLink.fingerprint !== oldLink.fingerprint ||
1926
+ newLink.parent_fingerprint === "none" ||
1927
+ JSON.stringify({ ...before, ext: { ...before.ext, [ACCEPTANCE_CONTINUITY_EXTENSION]: { ...oldLink, parent_fingerprint: newLink.parent_fingerprint } } }, null, 2) + "\n" !== repaired) {
1928
+ throw new Error("operator-host-handoff-repair-invalid");
1929
+ }
1930
+ unit.hashes = [hash(repaired), ...unit.hashes.slice(1)];
1931
+ await this.save(state);
1932
+ });
1933
+ }
1896
1934
  gitPaths(output) {
1897
1935
  return output.split("\0").filter(Boolean).map(path => normalizeRelativePath(path.replaceAll("\\", "/")));
1898
1936
  }
@@ -6953,7 +6953,8 @@ export const SortieDogsPlugin = async (input, options) => {
6953
6953
  // Persist it so the worker's Read and later recovery see the same contract.
6954
6954
  if (reservedParallelDescriptor === undefined && ledger.parent_fingerprint === "none" &&
6955
6955
  (exactCarryForward || strictAppend)) {
6956
- const handoff = JSON.parse(await readFile(handoffPaths[0], "utf8"));
6956
+ const source = await readFile(handoffPaths[0], "utf8");
6957
+ const handoff = JSON.parse(source);
6957
6958
  const validated = validateHandoffSchema(handoff);
6958
6959
  const current = validated.ok ? inspectAcceptanceContinuity(validated.value).ledger : undefined;
6959
6960
  if (current === undefined || JSON.stringify(current) !== JSON.stringify(ledger)) {
@@ -6963,7 +6964,15 @@ export const SortieDogsPlugin = async (input, options) => {
6963
6964
  }
6964
6965
  ledger = { ...ledger, parent_fingerprint: previous.fingerprint };
6965
6966
  handoff.ext[ACCEPTANCE_CONTINUITY_EXTENSION] = ledger;
6966
- await writeFile(handoffPaths[0], `${JSON.stringify(handoff, null, 2)}\n`);
6967
+ const repaired = `${JSON.stringify(handoff, null, 2)}\n`;
6968
+ await writeFile(handoffPaths[0], repaired);
6969
+ try {
6970
+ await input.runtimeBridge?.onHostHandoffRepaired?.(toolInput.sessionID, ledger.task_id, handoffPaths[0], source, repaired);
6971
+ }
6972
+ catch (error) {
6973
+ await writeFile(handoffPaths[0], source);
6974
+ throw error;
6975
+ }
6967
6976
  await inspect(handoffPaths[0], undefined, { report: true });
6968
6977
  }
6969
6978
  if (ledger.parent_fingerprint !== previous.fingerprint || (!exactCarryForward && !strictAppend)) {
@@ -3,10 +3,12 @@ import { execFile } from "node:child_process";
3
3
  import { lstat, readFile, readlink } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { TOOL_ENVIRONMENT } from "../runtime-mission-assets.js";
6
7
  const exec = promisify(execFile);
7
8
  /** Pin all scoped tracked/untracked source bytes, including deletions; display a bounded excerpt only. */
8
9
  export async function missionReviewSource(directory, run) {
9
- const scopes = [...new Set(run.units.flatMap(unit => unit.unit.write))];
10
+ // The shared dependency environment is local tooling, never reviewed or pinned source.
11
+ const scopes = [...new Set(run.units.flatMap(unit => unit.unit.write)), `:(exclude)${TOOL_ENVIRONMENT}`];
10
12
  const git = async (args) => (await exec("git", args, { cwd: directory, maxBuffer: 8 * 1024 * 1024 })).stdout;
11
13
  const names = await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...scopes]);
12
14
  const untracked = new Set((await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...scopes])).split("\0").filter(Boolean));
@@ -1,3 +1,4 @@
1
+ import { type OperatorState } from "../core/operator-runtime.js";
1
2
  import { type RuntimeProfile } from "../core/runtime-profile.js";
2
3
  import { type OpenCodePlugin } from "./index.js";
3
4
  import { type CatalogModel } from "./model-routing.js";
@@ -20,6 +21,13 @@ export declare function previewModelCatalog(routes?: readonly {
20
21
  readonly model: string;
21
22
  readonly variant: string;
22
23
  }[], base?: readonly CatalogModel[]): readonly CatalogModel[];
24
+ /** Native V2 termination and lineage are required before a cancelled Worker loses its old acceptance. */
25
+ export declare function terminalCancelledMissionChildren(profile: RuntimeProfile, root: string, previous: OperatorState, budget: {
26
+ reserved_units: number;
27
+ } | null, host: {
28
+ get(id: string): Promise<unknown>;
29
+ children(id: string): Promise<unknown>;
30
+ }): Promise<string[]>;
23
31
  /** One transport namespace around the existing MkII engine; no second write gate or acceptance engine. */
24
32
  export declare function createProfiledPlugin(profile: RuntimeProfile, assetVersion: string): OpenCodePlugin;
25
33
  export declare const SortieDogsV010Plugin: OpenCodePlugin;
@@ -12,7 +12,7 @@ import { decoratePreviewHeadings, returnReportPanel } from "./receipt-presentati
12
12
  import { sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
13
13
  import { extractWritePaths, normalizeCommand } from "./gate.js";
14
14
  import { normalizeRelativePath } from "../core/path.js";
15
- import { OperatorMissionRuntime, missionPacket, missionPlan, missionReviewTask, missionReviewTraces } from "../core/operator-mission.js";
15
+ import { MISSION_EVIDENCE_GAP_REVIEW_LIMIT, OperatorMissionRuntime, missionPacket, missionPlan, missionReviewAccepted, missionReviewTask, missionReviewTraces, missionReviewVerdict } from "../core/operator-mission.js";
16
16
  import { publishMissionProgress } from "./mission-progress.js";
17
17
  import { missionReviewSource } from "./mission-review.js";
18
18
  import { SOURCE_REVIEW_RISK_TAGS } from "../core/consultation.js";
@@ -82,6 +82,34 @@ export function previewModelCatalog(routes = PREVIEW_ROUTES, base = BUILT_IN_MOD
82
82
  }
83
83
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
84
84
  const payload = (value) => record(value) && "data" in value ? value.data : value;
85
+ /** Native V2 termination and lineage are required before a cancelled Worker loses its old acceptance. */
86
+ export async function terminalCancelledMissionChildren(profile, root, previous, budget, host) {
87
+ if (!budget || budget.reserved_units !== 0)
88
+ throw new Error("mission-superseded-run-reservations-pending");
89
+ const oldWorkers = previous.units.flatMap(unit => unit.childSessionID === null ? [] : [unit.childSessionID]);
90
+ if (oldWorkers.length === 0)
91
+ return [];
92
+ const coordinatorID = previous.operatorSessionID;
93
+ const coordinator = coordinatorID && await host.get(coordinatorID);
94
+ if (coordinatorID === null || !record(coordinator) || coordinator.parentID !== root ||
95
+ canonicalAgent(profile, coordinator.agent) !== "dog-operator" || coordinator.outcome !== "interrupted") {
96
+ throw new Error("mission-superseded-coordinator-not-terminal");
97
+ }
98
+ const children = await host.children(coordinatorID);
99
+ if (!Array.isArray(children) || children.length !== oldWorkers.length ||
100
+ children.some(child => !record(child) || !oldWorkers.includes(child.id))) {
101
+ throw new Error("mission-superseded-worker-lineage-unproven");
102
+ }
103
+ for (const id of oldWorkers) {
104
+ const worker = await host.get(id), descendants = await host.children(id);
105
+ if (!record(worker) || worker.parentID !== coordinatorID ||
106
+ !["dog-worker", "dog-luna-worker"].includes(canonicalAgent(profile, worker.agent) ?? "") ||
107
+ worker.outcome !== "interrupted" || !Array.isArray(descendants) || descendants.length !== 0) {
108
+ throw new Error("mission-superseded-worker-not-terminal");
109
+ }
110
+ }
111
+ return oldWorkers;
112
+ }
85
113
  const RUNTIME_PROFILE_SESSION_INACTIVE = "runtime-profile-session-inactive: non-profile agents retain native read, edit, patch, shell, and task tools; continue directly without Sortie profile tools";
86
114
  const fallbackOptionalSchema = (schema) => record(schema) && schema.type === "string" && typeof schema.optional !== "function"
87
115
  ? { ...schema, "x-sortie-optional": true }
@@ -284,6 +312,7 @@ export function createProfiledPlugin(profile, assetVersion) {
284
312
  });
285
313
  const runtimeBridge = {
286
314
  profile, assetVersion,
315
+ onHostHandoffRepaired: (root, taskID, path, original, repaired) => operators.acknowledgeHostHandoffRepair(root, taskID, path, original, repaired),
287
316
  continuationCheckpoint: async (root) => {
288
317
  const mission = await missions.read(root);
289
318
  if (mission && !["completed", "cancelled"].includes(mission.phase))
@@ -405,6 +434,19 @@ export function createProfiledPlugin(profile, assetVersion) {
405
434
  }
406
435
  return packet;
407
436
  }
437
+ async function retainCancelledMissionAcceptance(root, mission, prior) {
438
+ const previous = prior ?? await operators.read(root);
439
+ if (previous?.phase !== "cancelled" || mission.supersededRunID !== undefined || mission.runID !== null)
440
+ return mission;
441
+ // A different original user request needs the explicit, host-proven supersession path.
442
+ // Only the same source can retain a cancelled run's acceptance as a prefix.
443
+ if (previous.sourceRefs[0] !== `user:${mission.requests[0]?.id}` ||
444
+ !previous.sourceRefs.every(ref => mission.requests.some(request => ref === `user:${request.id}`))) {
445
+ throw new Error("mission-cancelled-source-unproven");
446
+ }
447
+ return previous.acceptance.every((text, index) => mission.requirements[index]?.text === text)
448
+ ? mission : missions.carryForward(root, mission.id, previous.acceptance);
449
+ }
408
450
  async function restorePriorAcceptance(root, state) {
409
451
  const succeeded = [...state.units].reverse().find(unit => unit.status === "succeeded");
410
452
  const current = succeeded === undefined ? undefined : {
@@ -1114,21 +1156,24 @@ export function createProfiledPlugin(profile, assetVersion) {
1114
1156
  } };
1115
1157
  const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`;
1116
1158
  const stringList = { type: "array", items: { type: "string" } };
1117
- tools[startMission] = { description: "Operator: save a few one-line requirements/negative constraints. The original user message, IDs and ownership are captured automatically. Dispatch the returned Coordinator task immediately, or use plan_units for a simple single-unit Fast-lane task.",
1159
+ tools[startMission] = { description: "Operator: save a few one-line requirements/negative constraints. The original user message, IDs and ownership are captured automatically. A cancelled same-turn run's accepted criteria are restored from host state in their exact order before dispatch. Dispatch the returned Coordinator task immediately, or use plan_units for a simple single-unit Fast-lane task.",
1118
1160
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 } }, execute: async (args, context) => {
1119
1161
  await requireRoot(context.sessionID);
1120
- await reconcileMissionDispatch(context.sessionID);
1121
- const mission = await missions.start(context.sessionID, args.requirements);
1122
- return JSON.stringify(mission.dispatchOpen
1123
- ? missionDispatchPacket(mission, await operators.read(context.sessionID))
1124
- : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
1125
- next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." });
1162
+ return serializeDispatchTransition(context.sessionID, async () => {
1163
+ await reconcileMissionDispatch(context.sessionID);
1164
+ const mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements));
1165
+ return JSON.stringify(mission.dispatchOpen
1166
+ ? missionDispatchPacket(mission, await operators.read(context.sessionID))
1167
+ : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
1168
+ next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." });
1169
+ });
1126
1170
  } };
1127
1171
  async function declareMissionUnits(root, actor, mission, raw, reason) {
1172
+ const previous = await operators.read(root);
1173
+ mission = await retainCancelledMissionAcceptance(root, mission, previous);
1128
1174
  const plan = missionPlan(mission, raw);
1129
1175
  if (actor === root && (mission.coordinator !== null || plan.units.length !== 1))
1130
1176
  throw new Error("mission-coordinator-required: dispatch the returned Coordinator task");
1131
- const previous = await operators.read(root);
1132
1177
  const same = previous?.planHash === createHash("sha256").update(JSON.stringify(plan)).digest("hex");
1133
1178
  if (previous && !["completed", "cancelled"].includes(previous.phase) && !same) {
1134
1179
  if (!reason?.trim())
@@ -1138,8 +1183,19 @@ export function createProfiledPlugin(profile, assetVersion) {
1138
1183
  const budget = await control.currentBudget(root);
1139
1184
  if (budget && budget.remaining_units < plan.units.length && !same)
1140
1185
  throw new Error("mission-budget-exhausted: report the required cumulative extension to Operator");
1186
+ // Cancellation marks the durable units before interrupting their native sessions. A cancelled
1187
+ // status alone therefore cannot prove the old Worker stopped or its reservation settled.
1188
+ const cancelledPredecessor = previous?.phase === "cancelled" &&
1189
+ (mission.supersededRunID === previous.runID ||
1190
+ (mission.supersededRunID === undefined && previous.decision === "explicit-cancellation" &&
1191
+ previous.units.some(unit => unit.status === "cancelled" && unit.childSessionID !== null)));
1192
+ const terminalChildren = cancelledPredecessor
1193
+ ? await terminalCancelledMissionChildren(profile, root, previous, budget, {
1194
+ get: async (id) => payload(await session("get", { path: { id }, query: { directory: input.directory } })),
1195
+ children: async (id) => payload(await session("children", { path: { id }, query: { directory: input.directory } })),
1196
+ }) : [];
1141
1197
  const state = await operators.prepareMission(root, plan, actor === root ? undefined
1142
- : { sessionID: actor, callID: mission.callID }, mission.supersededRunID);
1198
+ : { sessionID: actor, callID: mission.callID }, mission.supersededRunID, terminalChildren);
1143
1199
  await restorePriorAcceptance(root, state);
1144
1200
  await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
1145
1201
  control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
@@ -1193,7 +1249,8 @@ export function createProfiledPlugin(profile, assetVersion) {
1193
1249
  const task = risk.length === 0 ? null : { subagent_type: profileAgent(profile, "dog-reviewer"),
1194
1250
  description: `🔎 ${run.units[0].unit.title}`, prompt: [
1195
1251
  `candidate_id: ${mission.id}`, `review_phase: ${phase}`, "canonical_validation_exit: 0", `risk_tags: [${risk.join(", ")}]`,
1196
- "Review this candidate independently. Use the language of the requirements/traces. Invoke no tools. First line: exactly PASS or FINDINGS.",
1252
+ "Review this candidate independently. Use the language of the requirements/traces. Invoke no tools. First line: exactly PASS, FINDINGS or EVIDENCE_GAPS.",
1253
+ "Use EVIDENCE_GAPS only when no finding establishes a source/test defect and every finding is proof the supplied artifact cannot settle, including process history later established on the base. Any concrete defect uses FINDINGS.",
1197
1254
  `acceptance: ${JSON.stringify(run.acceptance)}`, `changedLogicSummary: ${JSON.stringify(traces)}`,
1198
1255
  ...run.acceptance.map((_, i) => `acceptance[${i}] -> changedLogicSummary[${i}]`),
1199
1256
  `manifest: ${JSON.stringify(run.units.map(unit => unit.unit))}`, `sourceFingerprint: ${source.fingerprint}`,
@@ -1202,13 +1259,14 @@ export function createProfiledPlugin(profile, assetVersion) {
1202
1259
  ].join("\n") };
1203
1260
  const reviewed = await missions.update(root, item => {
1204
1261
  item.review = { runID: run.runID, risk: risk, source: source.fingerprint,
1205
- task, verdict: task ? "pending" : "skipped-low-risk", ...(mission.review?.child ? { child: mission.review.child } : {}) };
1262
+ task, verdict: task ? "pending" : "skipped-low-risk", ...(mission.review?.child ? { child: mission.review.child } : {}),
1263
+ ...(mission.review?.evidenceGapReviews ? { evidenceGapReviews: mission.review.evidenceGapReviews } : {}) };
1206
1264
  });
1207
1265
  return JSON.stringify(task ? { status: "review-required", task: missionReviewTask(reviewed) } : { status: "skipped-low-risk" });
1208
1266
  } };
1209
1267
  async function assertMissionReview(root, mission) {
1210
1268
  const run = await operators.required(root), review = mission.review;
1211
- if (!review || review.runID !== run.runID || !["PASS", "skipped-low-risk"].includes(review.verdict) ||
1269
+ if (!review || review.runID !== run.runID || !missionReviewAccepted(review) ||
1212
1270
  review.source !== (await missionReviewSource(input.directory, run)).fingerprint)
1213
1271
  throw new Error("mission-review-required-or-stale");
1214
1272
  }
@@ -1736,13 +1794,23 @@ export function createProfiledPlugin(profile, assetVersion) {
1736
1794
  const history = child ? await messages(child) : [];
1737
1795
  const last = [...history].reverse().find(message => (record(message.info) ? message.info.role : message.role) === "assistant");
1738
1796
  const text = Array.isArray(last?.parts) ? last.parts.filter(record).filter(part => part.type === "text").map(part => part.text).join("\n") : String(raw);
1739
- await missions.update(ownership.root, mission => {
1797
+ const independent = who?.role === "dog-reviewer" && who.parent === ownership.actor;
1798
+ const reviewed = await missions.update(ownership.root, mission => {
1740
1799
  if (mission.review) {
1741
1800
  mission.review.result = text.slice(0, 16_000);
1742
- mission.review.verdict = /^\s*PASS(?:\s|$)/u.test(text) && who?.role === "dog-reviewer" && who.parent === ownership.actor ? "PASS" : "findings";
1801
+ const verdict = missionReviewVerdict(text);
1802
+ mission.review.verdict = independent ? verdict : "findings";
1803
+ if (mission.review.verdict === "evidence-gaps")
1804
+ mission.review.evidenceGapReviews = (mission.review.evidenceGapReviews ?? 0) + 1;
1743
1805
  mission.review.child = child;
1744
1806
  }
1745
1807
  });
1808
+ if (reviewed.review?.verdict === "evidence-gaps" && typeof output.output === "string") {
1809
+ const count = reviewed.review.evidenceGapReviews ?? 0;
1810
+ output.output += missionReviewAccepted(reviewed.review)
1811
+ ? `\n\nHOST: evidence-gap review ${count}/${MISSION_EVIDENCE_GAP_REVIEW_LIMIT} reached the limit. No defect was found; submit_mission ready is permitted. List the remaining evidence gaps in the summary.`
1812
+ : `\n\nHOST: evidence-gap review ${count}/${MISSION_EVIDENCE_GAP_REVIEW_LIMIT}. No defect was found; supply the requested evidence through review_mission traces (or at most one evidence-only unit) and re-review. Do not re-implement.`;
1813
+ }
1746
1814
  }
1747
1815
  taskOwners.delete(request.callID);
1748
1816
  return;
@@ -1847,8 +1915,20 @@ export function createProfiledPlugin(profile, assetVersion) {
1847
1915
  },
1848
1916
  "experimental.chat.system.transform": async (request, output) => {
1849
1917
  const root = await rootFor(request.sessionID) ?? await proposalPromptRoot(request.sessionID);
1850
- if (!root)
1918
+ if (!root) {
1919
+ const who = await identity(request.sessionID);
1920
+ if (who.parent === undefined && who.role === undefined &&
1921
+ (await missions.read(request.sessionID))?.phase === "cancelled") {
1922
+ // OpenCode retains the same conversation when the user switches agents. A prior
1923
+ // INTERRUPTED report describes only its Sortie run; it cannot revoke Build's
1924
+ // native tools or turn a new independent request into a cancelled mission.
1925
+ (output.system ??= []).push("SORTIE_PROFILE_INACTIVE: This is a non-Sortie agent. " +
1926
+ "Any earlier INTERRUPTED mission or cancelled operator run in this session is historical state of the Sortie profile, " +
1927
+ "not a restriction on this agent's current user request. Use the current agent's native tools for independent work. " +
1928
+ "Do not revive, overwrite or claim completion of that historical Sortie run.");
1929
+ }
1851
1930
  return;
1931
+ }
1852
1932
  await core["experimental.chat.system.transform"]?.(request, output);
1853
1933
  (output.system ??= []).push(`SORTIE_RUNTIME_PROFILE ${profile.id}; marker ${assetVersion}. ` +
1854
1934
  `Shared MkII protocol role names are logical: ${protocolMap}. Use only ${profile.toolPrefix} tools for this profile. ` +
@@ -27,6 +27,7 @@ export interface RuntimeBridge {
27
27
  continuationCheckpoint?(rootSessionID: string): Promise<string | undefined>;
28
28
  requiresExplicitAcceptance?(rootSessionID: string): Promise<boolean>;
29
29
  ownsCanonicalValidation?(rootSessionID: string, unitID: string, childSessionID: string, command: string): Promise<boolean>;
30
+ onHostHandoffRepaired?(rootSessionID: string, taskID: string, handoffPath: string, original: string, repaired: string): Promise<void>;
30
31
  allowsInvestigativeShell?(sessionID: string): Promise<boolean>;
31
32
  onSerialSettlement?(settlement: SerialDispatchSettlement): Promise<void>;
32
33
  onRootTerminal?(rootSessionID: string, receipt: GoalTerminalReceipt): Promise<void>;
package/dist/plugin/v2.js CHANGED
@@ -63,13 +63,14 @@ function legacyMessage(value, session, agent) {
63
63
  if (!record(value) || typeof value.id !== "string" || typeof value.type !== "string")
64
64
  return undefined;
65
65
  const id = value.id;
66
+ const originalAgent = record(value.metadata) ? string(value.metadata.agent) ?? agent : agent;
66
67
  if (value.type === "user") {
67
- return { info: { id, sessionID: session, role: "user", agent, time: value.time },
68
+ return { info: { id, sessionID: session, role: "user", agent: originalAgent, time: value.time },
68
69
  parts: [{ id: `${id}-text`, sessionID: session, messageID: id, type: "text", text: String(value.text ?? "") }] };
69
70
  }
70
71
  if (value.type !== "assistant") {
71
72
  if (value.type === "synthetic")
72
- return { info: { id, sessionID: session, role: "user", agent, time: value.time },
73
+ return { info: { id, sessionID: session, role: "user", agent: originalAgent, time: value.time },
73
74
  parts: [{ id: `${id}-text`, sessionID: session, messageID: id, type: "text", text: String(value.text ?? ""), synthetic: true }] };
74
75
  return undefined;
75
76
  }
@@ -432,13 +432,20 @@ routes separately from mutation routes; an enumeration justified only by the req
432
432
  covers mutation routes while leaving creation routes unlisted, is incomplete. Require the value representations
433
433
  that reach the rule to be listed too, and require each trace to exercise the form with the new rule active; a
434
434
  trace whose case leaves the new rule inactive evidences only the pre-existing behavior. A missing enumeration, an
435
- excluded form without a reason, or an enumerated form without a trace is a concrete finding, never PASS.
436
- Report it as an evidence gap when the supplied excerpts cannot settle the form.
435
+ excluded form without a reason, or an enumerated form without a trace is never PASS. When the supplied
436
+ excerpts cannot settle the form and no defect is established, it is an evidence gap, not a FINDINGS defect.
437
437
 
438
438
  Reject a matrix that lists independent syntax or dispatch dimensions but traces them only in isolation. Require
439
439
  the material combinations that can select different branches, including cardinality with optional-clause
440
440
  presence, scope, and value representation where applicable. For a multi-target route, require evidence for every
441
441
  target; proving only the first target is a concrete asymmetry finding.
442
+
443
+ ## Mission review verdict
444
+
445
+ Start with exactly one of PASS, FINDINGS or EVIDENCE_GAPS. Use FINDINGS when any finding establishes a
446
+ source/test defect or an observed behavior contradicting an acceptance item. Use EVIDENCE_GAPS when every finding
447
+ is proof the artifact cannot settle: a missing excerpt, trace, enumeration, or process history later established
448
+ on the base. List all gaps you see in one response rather than one per round; the host bounds evidence rounds.
442
449
  `;
443
450
  export const runtimeAssets = Object.freeze([
444
451
  ...canonicalAssets.map((asset) => {
@@ -1,4 +1,6 @@
1
1
  import { type RuntimeProfile } from "./core/runtime-profile.ts";
2
+ /** Repository-local dependency environment shared by all units; excluded from reviewed and captured source. */
3
+ export declare const TOOL_ENVIRONMENT = ".sortie-env";
2
4
  export declare function missionOperatorContent(profile: RuntimeProfile, version: string): string;
3
5
  export declare function missionCoordinatorContent(profile: RuntimeProfile, version: string): string;
4
6
  export declare function missionWorkerContent(profile: RuntimeProfile): string;
@@ -1,6 +1,8 @@
1
1
  import { profileAgent } from "./core/runtime-profile.js";
2
2
  import { STRATEGY_TRIGGERS, SOURCE_REVIEW_RISK_TAGS } from "./core/consultation.js";
3
3
  import { SCOUT_EVIDENCE_CODES } from "./core/scout-contract.js";
4
+ /** Repository-local dependency environment shared by all units; excluded from reviewed and captured source. */
5
+ export const TOOL_ENVIRONMENT = ".sortie-env";
4
6
  function controls(profile, names) {
5
7
  return names.map(name => ` ${profile.toolPrefix}${name}: true`).join("\n");
6
8
  }
@@ -36,8 +38,9 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
36
38
  unit boundaries, Worker/Scout/Advisor/independent Reviewer calls, write-scope extensions and corrections
37
39
  within the original request and cumulative budget. Do not investigate or approve each unit at the root.
38
40
  3. Compare the returned completion candidate against the original request, real source and observed evidence.
39
- If incomplete, resume the SAME Coordinator with concrete feedback. If complete and required review passed,
40
- call ${profile.toolPrefix}complete_mission. Only its succeeded receipt authorizes DONE.
41
+ If incomplete, resume the SAME Coordinator with concrete feedback. If complete and required review passed
42
+ (or the host accepted it at the evidence-gap limit, with the gaps reported), call
43
+ ${profile.toolPrefix}complete_mission. Only its succeeded receipt authorizes DONE.
41
44
 
42
45
  Fast-lane exception: simple, low-risk, single-unit work with known files and validation may use
43
46
  ${profile.toolPrefix}plan_units directly after start_mission, then dispatch its exact Worker task.
@@ -91,7 +94,10 @@ Investigate only enough to start the first useful Worker. Prefer a targeted read
91
94
  inventory or speculative full design. Call ${profile.toolPrefix}plan_units with concise units:
92
95
  title, objective, read/write file or directory scopes, validation commands, optionally requirement_ids.
93
96
  Keep all original requirements covered; omitted requirement_ids means all. Last validation command proves
94
- that unit. Host generates IDs, proof mapping, handoff, manifest and Task references. Dispatch the returned
97
+ that unit. If your quick check shows repository-declared dependencies or the test runner are missing, keep
98
+ setup inside the first unit: declare its checks through the repository-local tool environment ${TOOL_ENVIRONMENT}/
99
+ (for Python, ${TOOL_ENVIRONMENT}/bin/python -m pytest ...) and let that Worker create it. Never plan a separate setup
100
+ unit or put ${TOOL_ENVIRONMENT}/ in a write scope. Host generates IDs, proof mapping, handoff, manifest and Task references. Dispatch the returned
95
101
  ${profileAgent(profile, "dog-worker")} task verbatim, in foreground. V2 maps subagent_type to agent and
96
102
  task_id to sessionID. Do not insert model overrides unless the user explicitly selected them.
97
103
 
@@ -114,8 +120,10 @@ After formal validation, call review_mission with risk_tags and one concise impl
114
120
  requirement. Recognized tags: ${SOURCE_REVIEW_RISK_TAGS.join(", ")}.
115
121
  High-risk changes require the generated independent ${profileAgent(profile, "dog-reviewer")} task.
116
122
  Low risk uses [] and the host records the skip. The host supplies source excerpts, manifest, requirement
117
- mapping and validation evidence; do not handwrite that envelope. Fix concrete findings yourself through
118
- Worker and rerun affected validation/review. A missing excerpt calls for evidence, not an invented source bug.
123
+ mapping and validation evidence; do not handwrite that envelope. Fix concrete FINDINGS defects yourself
124
+ through Worker and rerun affected validation/review. EVIDENCE_GAPS means missing proof, not a defect: answer
125
+ it with sharper traces in the next review_mission, or at most one evidence-only unit, never a re-implementation.
126
+ The host caps evidence-only reviews; at its limit, submit ready and list the remaining gaps.
119
127
  Preserve candidate lineage and independence; your own opinion or Worker PASS is not independent review.
120
128
 
121
129
  Call submit_mission only for: ready (complete candidate with evidence/review), needs-decision (only the user
@@ -140,7 +148,8 @@ The binding remains valid throughout this Task until return or a control/source
140
148
 
141
149
  Read/search and read-only investigation commands are unrestricted. Use targeted reproduction/diagnosis
142
150
  without registering every exploratory command. All writes, generated/transient files and cleanup stay
143
- inside the unit's file/directory scopes. Do not write outside them through scripts or tools. If scope must
151
+ inside the unit's file/directory scopes, except the tool environment below. Do not write outside them through
152
+ scripts or tools. If scope must
144
153
  expand or a formal check must change, return the exact paths/command and reason to Coordinator; it can
145
154
  approve an in-request extension immediately. Do not ask the user or delegate to another agent.
146
155
 
@@ -150,6 +159,12 @@ and separate shell calls; the host records actual command, source and exit. Diag
150
159
  formal acceptance evidence. Do not repeat a failed command without a concrete source/setup correction or
151
160
  repeat passed checks on unchanged source. Add meaningful tests only when needed by the change/request.
152
161
 
162
+ Missing repository-declared dependencies or test runner are setup, not a result. Make one bounded,
163
+ repository-documented setup attempt in the repository-local tool environment ${TOOL_ENVIRONMENT}/ (for Python:
164
+ python -m venv ${TOOL_ENVIRONMENT}, then install the declared dependencies with its pip), then run the checks.
165
+ Reuse an existing ${TOOL_ENVIRONMENT}/ and never delete it; it is local tooling, not a change, and needs no write
166
+ scope. Return to Coordinator for setup only when it is externally blocked or a formal command must change.
167
+
153
168
  Return changed paths and concise criterion-level implementation/test/input evidence with the real check
154
169
  results. Respect negative constraints and API success/error compatibility. A broad suite PASS does not
155
170
  alone prove every requirement; leave unproven items explicit. Never claim other units or the whole mission
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",