sortie-dogs 0.9.10 → 0.9.12

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
@@ -20,9 +20,42 @@ bounded implementation, canonical validation, and evidence-backed completion.
20
20
 
21
21
  Requirements: Node.js 22.6 or newer, npm, and OpenCode.
22
22
 
23
- Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
23
+ Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [テスト実行](docs/testing.md) · [CLI testing](docs/cli-testing.md)
24
24
 
25
- Release: [v0.9.10](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.10)
25
+ Release: [v0.9.12](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.12)
26
+
27
+ ## Provisional quality–cost position
28
+
29
+ **Reference values, not a successful benchmark claim.** Quality and end-to-end completion
30
+ problems remain. The latest completed qualification attempt ended at `IN_PROGRESS`, so its official
31
+ verifier was not run. Further benchmarks are frozen while completion defects are repaired.
32
+
33
+ The last complete measured **Bare OpenCode vs Sortie** pair below used one frozen task,
34
+ `datacurve/anko-typed-variable-bindings`, on 2026-09-10. It used Sortie **v0.9.5**, not the
35
+ current release. Both candidates failed the official verifier.
36
+
37
+ | Metric · one task, one trial per arm | Bare OpenCode | Sortie v0.9.5 |
38
+ | --- | ---: | ---: |
39
+ | Verified PASS | 0/1 | 0/1 |
40
+ | Task-check completion · F2P | 55.6% · 5/9 | 88.9% · 8/9 |
41
+ | Retained checks · P2P | 94/94 | 93/94 |
42
+ | Estimated API-equivalent total cost | $5.42 | $1.46 |
43
+ | Median agent wall · n=1 | 28.7 min | 10.0 min |
44
+ | Premium-model token share · Sol | 100% | 20.1% |
45
+
46
+ Later Sortie-only evidence is weaker: the v0.9.9 recovery candidate passed **5/9** task
47
+ checks with **0/1 Verified PASS**; `0.9.11-bench.2` did not reach a gradeable completion.
48
+ Those attempts are not pooled into the historical pair above.
49
+
50
+ ![Historical quality–cost reference: Bare at $5.42 and 55.6% task-check completion; Sortie v0.9.5 at $1.46 and 88.9%. Neither achieved Verified PASS.](docs/assets/quality-cost-reference.svg)
51
+
52
+ The goal is **higher OpenCode task success with selective use of premium models**.
53
+ These reference observations do not yet establish that success-rate claim: Sortie missed
54
+ one task check and regressed one retained check. Codex, Pi, and Oh My OpenCode belong to
55
+ separate methodologies and are not assigned comparable positions on this chart.
56
+
57
+ [Definitions, frozen inputs, current failure status, and limitations](docs/benchmark-reference.md)
58
+ · [Machine-readable reference values](docs/benchmarks/provisional-reference.json)
26
59
 
27
60
  ## Why Sortie-dogs?
28
61
 
@@ -41,6 +74,15 @@ models are reserved for implementation, escalation, and independent review.
41
74
  Writes stay scoped, and completion requires validation evidence. Every completed
42
75
  run can return a concise Speed / Cost / Proof debrief.
43
76
 
77
+ ### Use only as much harness as the task needs
78
+
79
+ Small changes can skip Scout and independent review when one worker and targeted
80
+ validation are sufficient. Larger work can be decomposed into multiple units;
81
+ units that are safely independent can use a Luna fabric DAG for bounded parallel
82
+ execution. Higher-risk candidates add independent review, while full-suite and
83
+ package verification are reserved for release work. Not every task pays the
84
+ cost of the heaviest workflow.
85
+
44
86
  ## Designed to coexist with OpenCode
45
87
 
46
88
  Sortie-dogs adds a workflow to your existing setup rather than replacing it.
@@ -266,6 +308,18 @@ dog-coordinator: completion evidence accepted
266
308
  progress; repeated batches remain bounded rather than becoming endless
267
309
  delegation.
268
310
 
311
+ ## Built to work on itself
312
+
313
+ Self-improvement keeps the same scoped manifests, worker ownership, validation,
314
+ and review gates as other work. A loaded plugin is not treated as hot-reloadable:
315
+ source changes are validated first, then packaged into an isolated `_testenv`
316
+ fixture and exercised through the real OpenCode CLI. Continuation and compaction
317
+ changes must demonstrate same-session recovery and terminal completion there.
318
+
319
+ `npm run test:full` is reserved for explicit release validation; ordinary
320
+ changes run targeted tests and `npm test`. The control plane coordinating a run
321
+ is not replaced while that run is in flight.
322
+
269
323
  ## A visual walkthrough
270
324
 
271
325
  ### Control complexity
@@ -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.86-codegen-proof-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-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.86-codegen-proof-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
@@ -978,11 +978,11 @@ async function stagedPaths(context) {
978
978
  return new Set(fields);
979
979
  }
980
980
  async function assertNoSubmodules(context, entries) {
981
- for (const entry of entries) {
982
- const index = await context.git(["ls-files", "--stage", "-z", "--", entry.path]);
983
- if (index.toString("utf8").startsWith("160000 ")) {
984
- throw new WorktreeCommitArtifactError("invalid-state", "Submodule changes are forbidden.");
985
- }
981
+ if (entries.length === 0)
982
+ return;
983
+ const index = await context.git(["ls-files", "--stage", "-z", "--", ...entries.map(({ path }) => path)]);
984
+ if (index.toString("utf8").split("\0").some((entry) => entry.startsWith("160000 "))) {
985
+ throw new WorktreeCommitArtifactError("invalid-state", "Submodule changes are forbidden.");
986
986
  }
987
987
  }
988
988
  function decodeGitOutput(source, message) {
@@ -1261,7 +1261,7 @@ export async function produceWorktreeCommitArtifact(request, control) {
1261
1261
  throw new WorktreeCommitArtifactError("validation-failed", "Validation was cancelled.");
1262
1262
  control?.enterProtectedPhase();
1263
1263
  await context.git(["add", "--", ...before.map(({ path }) => path)]);
1264
- await assertValidatedIndex(context, before, beforeFingerprint);
1264
+ // Keep one fresh index check after staging, immediately before the protected commit.
1265
1265
  await assertValidatedIndex(context, before, beforeFingerprint);
1266
1266
  await context.git([
1267
1267
  "-c", "user.name=Sortie Fabric",
@@ -2138,57 +2138,79 @@ export class ParallelDispatchCoordinator {
2138
2138
  };
2139
2139
  }
2140
2140
  async buildFabricCandidate(runID, base, tasks) {
2141
+ if (tasks.length === 0)
2142
+ return base;
2141
2143
  let head = base;
2142
- for (const task of tasks) {
2143
- const artifact = task.artifact;
2144
- const nonce = randomUUID();
2145
- const indexPath = join(this.stateRoot, `.fabric-index-${nonce}`);
2146
- const patchPath = join(this.stateRoot, `.fabric-patch-${nonce}`);
2147
- const messagePath = join(this.stateRoot, `.fabric-message-${nonce}`);
2148
- const indexEnvironment = this.cleanGitEnvironment({ GIT_INDEX_FILE: indexPath });
2149
- try {
2150
- await this.gitBuffer(["read-tree", head], indexEnvironment);
2151
- const patch = await this.gitBuffer([
2152
- "diff-tree", "-p", "--binary", "--full-index", "--no-ext-diff", "--no-renames",
2153
- artifact.base_sha, artifact.commit_sha, "--",
2154
- ]);
2155
- if (patch.byteLength > 8 * 1024 * 1024) {
2156
- throw new ParallelDispatchError("wave-integration-failed", "Fabric artifact patch exceeds the practical bound.");
2157
- }
2158
- await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
2159
- await this.gitBuffer(["apply", "--cached", "--3way", "--binary", patchPath], indexEnvironment);
2160
- const tree = (await this.gitBuffer(["write-tree"], indexEnvironment)).toString("utf8").trim();
2161
- const timestamp = (await this.gitBuffer(["show", "-s", "--format=%ct", artifact.commit_sha])).toString("utf8").trim();
2162
- if (!SHA.test(tree) || !/^\d{1,12}$/u.test(timestamp))
2144
+ const indexPath = join(this.stateRoot, `.fabric-index-${randomUUID()}`);
2145
+ const indexEnvironment = this.cleanGitEnvironment({ GIT_INDEX_FILE: indexPath });
2146
+ try {
2147
+ // Commit objects are immutable; collect their timestamps once for this build only.
2148
+ const commits = [...new Set(tasks.map((task) => task.artifact.commit_sha))];
2149
+ const metadata = (await this.gitBuffer([
2150
+ "show", "-s", "--no-walk=unsorted", "--format=%H%x00%ct", ...commits, "--",
2151
+ ])).toString("utf8").trimEnd();
2152
+ const timestamps = new Map();
2153
+ for (const line of metadata.split(/\r?\n/u)) {
2154
+ const fields = line.split("\0");
2155
+ if (fields.length !== 2 || !SHA.test(fields[0]) || !/^\d{1,12}$/u.test(fields[1]) || timestamps.has(fields[0])) {
2163
2156
  throw new Error("identity");
2164
- const message = `Sortie fabric integration: ${task.descriptor.task_id}\n\n` +
2165
- `Sortie-Run: ${runID}\nSortie-Task: ${task.descriptor.task_id}\n` +
2166
- `Sortie-Artifact: ${artifact.commit_sha}\nSortie-Base: ${artifact.base_sha}\n`;
2167
- await writeFile(messagePath, message, { flag: "wx", mode: 0o600 });
2168
- const commitEnvironment = this.cleanGitEnvironment({
2169
- GIT_AUTHOR_NAME: "Sortie Fabric",
2170
- GIT_AUTHOR_EMAIL: "sortie@example.invalid",
2171
- GIT_COMMITTER_NAME: "Sortie Fabric",
2172
- GIT_COMMITTER_EMAIL: "sortie@example.invalid",
2173
- GIT_AUTHOR_DATE: `${timestamp} +0000`,
2174
- GIT_COMMITTER_DATE: `${timestamp} +0000`,
2175
- });
2176
- const commit = (await this.gitBuffer(["commit-tree", tree, "-p", head, "-F", messagePath], commitEnvironment))
2177
- .toString("utf8").trim();
2178
- if (!SHA.test(commit))
2179
- throw new Error("commit");
2180
- head = commit;
2181
- }
2182
- catch (error) {
2183
- if (error instanceof ParallelDispatchError)
2184
- throw error;
2185
- throw new ParallelDispatchError("wave-integration-failed", "Fabric artifact could not be applied to the hidden candidate.");
2157
+ }
2158
+ timestamps.set(fields[0], fields[1]);
2186
2159
  }
2187
- finally {
2188
- await Promise.all([indexPath, `${indexPath}.lock`, patchPath, messagePath].map((path) => rm(path, { force: true }).catch(() => undefined)));
2160
+ if (timestamps.size !== commits.length || commits.some((commit) => !timestamps.has(commit)))
2161
+ throw new Error("identity");
2162
+ await this.gitBuffer(["read-tree", base], indexEnvironment);
2163
+ for (const task of tasks) {
2164
+ const artifact = task.artifact;
2165
+ const nonce = randomUUID();
2166
+ const patchPath = join(this.stateRoot, `.fabric-patch-${nonce}`);
2167
+ const messagePath = join(this.stateRoot, `.fabric-message-${nonce}`);
2168
+ try {
2169
+ const patch = await this.gitBuffer([
2170
+ "diff-tree", "-p", "--binary", "--full-index", "--no-ext-diff", "--no-renames",
2171
+ artifact.base_sha, artifact.commit_sha, "--",
2172
+ ]);
2173
+ if (patch.byteLength > 8 * 1024 * 1024) {
2174
+ throw new ParallelDispatchError("wave-integration-failed", "Fabric artifact patch exceeds the practical bound.");
2175
+ }
2176
+ await writeFile(patchPath, patch, { flag: "wx", mode: 0o600 });
2177
+ await this.gitBuffer(["apply", "--cached", "--3way", "--binary", patchPath], indexEnvironment);
2178
+ const tree = (await this.gitBuffer(["write-tree"], indexEnvironment)).toString("utf8").trim();
2179
+ const timestamp = timestamps.get(artifact.commit_sha);
2180
+ if (!SHA.test(tree) || timestamp === undefined)
2181
+ throw new Error("identity");
2182
+ const message = `Sortie fabric integration: ${task.descriptor.task_id}\n\n` +
2183
+ `Sortie-Run: ${runID}\nSortie-Task: ${task.descriptor.task_id}\n` +
2184
+ `Sortie-Artifact: ${artifact.commit_sha}\nSortie-Base: ${artifact.base_sha}\n`;
2185
+ await writeFile(messagePath, message, { flag: "wx", mode: 0o600 });
2186
+ const commitEnvironment = this.cleanGitEnvironment({
2187
+ GIT_AUTHOR_NAME: "Sortie Fabric",
2188
+ GIT_AUTHOR_EMAIL: "sortie@example.invalid",
2189
+ GIT_COMMITTER_NAME: "Sortie Fabric",
2190
+ GIT_COMMITTER_EMAIL: "sortie@example.invalid",
2191
+ GIT_AUTHOR_DATE: `${timestamp} +0000`,
2192
+ GIT_COMMITTER_DATE: `${timestamp} +0000`,
2193
+ });
2194
+ const commit = (await this.gitBuffer(["commit-tree", tree, "-p", head, "-F", messagePath], commitEnvironment))
2195
+ .toString("utf8").trim();
2196
+ if (!SHA.test(commit))
2197
+ throw new Error("commit");
2198
+ head = commit;
2199
+ }
2200
+ finally {
2201
+ await Promise.all([patchPath, messagePath].map((path) => rm(path, { force: true }).catch(() => undefined)));
2202
+ }
2189
2203
  }
2204
+ return head;
2205
+ }
2206
+ catch (error) {
2207
+ if (error instanceof ParallelDispatchError)
2208
+ throw error;
2209
+ throw new ParallelDispatchError("wave-integration-failed", "Fabric artifact could not be applied to the hidden candidate.");
2210
+ }
2211
+ finally {
2212
+ await Promise.all([indexPath, `${indexPath}.lock`].map((path) => rm(path, { force: true }).catch(() => undefined)));
2190
2213
  }
2191
- return head;
2192
2214
  }
2193
2215
  cleanGitEnvironment(extra = {}) {
2194
2216
  const environment = { ...process.env };
@@ -199,7 +199,7 @@ export interface ContinuationHooks {
199
199
  toolStarted(sessionID: string, tool: string): void;
200
200
  blocksTool(sessionID: string): boolean;
201
201
  sessionIdle(sessionID: string): Promise<void>;
202
- stopAutomaticRecovery(sessionID: string, abortSession?: boolean): Promise<void>;
202
+ stopAutomaticRecovery(sessionID: string, abortSession?: boolean, resumeOnRealUserTurn?: boolean): Promise<void>;
203
203
  recoverStalledTask(sessionID: string, callIDs: readonly string[]): Promise<"recovered" | "identity-rejected" | "capability-unavailable" | "request-rejected">;
204
204
  forgetSession(sessionID: string): void;
205
205
  }
@@ -225,6 +225,7 @@ export function createContinuationHooks(client, directory, policySource, timings
225
225
  const sessions = new Map();
226
226
  const warned = new Set();
227
227
  const stoppedSessions = new Set();
228
+ const realTurnResumableStops = new Set();
228
229
  function observeTransition(type, sessionID, state, reason) {
229
230
  try {
230
231
  transitionObserver?.({
@@ -811,8 +812,9 @@ export function createContinuationHooks(client, directory, policySource, timings
811
812
  }
812
813
  sessions.delete(sessionID);
813
814
  stoppedSessions.delete(sessionID);
815
+ realTurnResumableStops.delete(sessionID);
814
816
  }
815
- async function stopAutomaticRecovery(sessionID, abortSession = true) {
817
+ async function stopAutomaticRecovery(sessionID, abortSession = true, resumeOnRealUserTurn = false) {
816
818
  const state = sessions.get(sessionID);
817
819
  if (state !== undefined) {
818
820
  clearTimer(state.cooldownTimer);
@@ -830,8 +832,14 @@ export function createContinuationHooks(client, directory, policySource, timings
830
832
  }
831
833
  stoppedSessions.delete(sessionID);
832
834
  stoppedSessions.add(sessionID);
835
+ if (resumeOnRealUserTurn)
836
+ realTurnResumableStops.add(sessionID);
837
+ else
838
+ realTurnResumableStops.delete(sessionID);
833
839
  while (stoppedSessions.size > MAX_TRACKED_SESSIONS) {
834
- stoppedSessions.delete(stoppedSessions.values().next().value);
840
+ const oldest = stoppedSessions.values().next().value;
841
+ stoppedSessions.delete(oldest);
842
+ realTurnResumableStops.delete(oldest);
835
843
  }
836
844
  const abort = abortSession ? client?.session?.abort : undefined;
837
845
  if (abort !== undefined) {
@@ -842,6 +850,8 @@ export function createContinuationHooks(client, directory, policySource, timings
842
850
  }
843
851
  }
844
852
  async function recoverStalledTask(sessionID, callIDs) {
853
+ if (stoppedSessions.has(sessionID))
854
+ return "request-rejected";
845
855
  const active = policy();
846
856
  const resolution = resolveContinuation({
847
857
  identity: await readIdentity(sessionID),
@@ -863,6 +873,8 @@ export function createContinuationHooks(client, directory, policySource, timings
863
873
  // Reserve durable authority before mutating the host session. A configured authority that
864
874
  // cannot issue a ticket must not cause an abort/retry loop or an unproven synthetic send.
865
875
  const metadata = await ticketMetadata(sessionID, `watchdog:${callIDs.join(",")}`);
876
+ if (stoppedSessions.has(sessionID))
877
+ return "request-rejected";
866
878
  await abort.call(client.session, {
867
879
  path: { id: sessionID },
868
880
  query: { directory },
@@ -1164,8 +1176,11 @@ export function createContinuationHooks(client, directory, policySource, timings
1164
1176
  output.enabled = false;
1165
1177
  },
1166
1178
  observeModel(sessionID, model, synthetic = false) {
1167
- if (stoppedSessions.has(sessionID))
1168
- return;
1179
+ if (stoppedSessions.has(sessionID)) {
1180
+ if (synthetic || !realTurnResumableStops.delete(sessionID))
1181
+ return;
1182
+ stoppedSessions.delete(sessionID);
1183
+ }
1169
1184
  if (!nonEmpty(model.providerID) || !nonEmpty(model.modelID))
1170
1185
  return;
1171
1186
  const state = stateFor(sessionID);
@@ -91,6 +91,11 @@ export type HandoffDenialReason = "configuration-unavailable" | "path-invalid" |
91
91
  export type FreshSessionReason = "child-lineage" | "asset-contract-skew";
92
92
  export type FreshSessionAction = "open-fresh-root" | "install-assets-then-open-fresh-root" | "restart-host-after-install";
93
93
  export type FreshSessionResult = Readonly<{
94
+ status: "redispatch-queued";
95
+ reason: FreshSessionReason;
96
+ source_session_id: string;
97
+ retry_same_session: false;
98
+ }> | Readonly<{
94
99
  status: "redispatched";
95
100
  reason: FreshSessionReason;
96
101
  source_session_id: string;
@@ -483,15 +483,58 @@ function sameRelativePath(left, right) {
483
483
  function textPart(part) {
484
484
  return isRecord(part) && typeof part.text === "string" ? part.text : undefined;
485
485
  }
486
+ const FILE_PART_KEYS = new Set(["id", "sessionID", "messageID", "type", "mime", "filename", "url", "source"]);
487
+ function safeFilePart(part) {
488
+ if (Object.keys(part).some((key) => !FILE_PART_KEYS.has(key)) ||
489
+ typeof part.mime !== "string" || part.mime.length === 0 ||
490
+ typeof part.url !== "string" || part.url.length === 0 ||
491
+ (part.filename !== undefined && typeof part.filename !== "string") ||
492
+ (part.id !== undefined && typeof part.id !== "string") ||
493
+ (part.sessionID !== undefined && typeof part.sessionID !== "string") ||
494
+ (part.messageID !== undefined && typeof part.messageID !== "string") ||
495
+ (part.source !== undefined && !isRecord(part.source)))
496
+ return undefined;
497
+ return {
498
+ type: "file",
499
+ mime: part.mime,
500
+ ...(part.filename === undefined ? {} : { filename: part.filename }),
501
+ url: part.url,
502
+ };
503
+ }
504
+ /** OpenCode expands text attachments into synthetic explanatory text alongside the real file.
505
+ * Those derived text parts carry no user authority and are never copied into a fresh prompt.
506
+ * A ticket-bearing synthetic turn (or a turn without real user text and a safe file) stays synthetic.
507
+ */
508
+ function realAttachmentParts(parts) {
509
+ const hasUserText = parts.some((part) => isRecord(part) && part.type === "text" &&
510
+ part.synthetic !== true && typeof part.text === "string" && part.text.trim().length > 0);
511
+ const hasFile = parts.some((part) => isRecord(part) && part.type === "file" && part.synthetic !== true && safeFilePart(part) !== undefined);
512
+ if (!hasUserText || !hasFile || parts.some((part) => isRecord(part) && part.synthetic === true &&
513
+ (part.type !== "text" || typeof part.text !== "string" || part.metadata !== undefined)))
514
+ return parts;
515
+ return parts.filter((part) => !isRecord(part) || part.synthetic !== true);
516
+ }
517
+ function syntheticPrompt(parts) {
518
+ return realAttachmentParts(parts).some((part) => isRecord(part) && part.synthetic === true);
519
+ }
486
520
  function freshSessionPrompt(parts) {
487
521
  const prompt = [];
488
- for (const part of parts) {
489
- if (!isRecord(part) || part.type !== "text" || part.synthetic === true || typeof part.text !== "string") {
522
+ for (const part of realAttachmentParts(parts)) {
523
+ if (!isRecord(part) || part.synthetic === true)
490
524
  return undefined;
525
+ if (part.type === "text" && typeof part.text === "string")
526
+ prompt.push({ type: "text", text: part.text });
527
+ else if (part.type === "file") {
528
+ const attachment = safeFilePart(part);
529
+ if (attachment === undefined)
530
+ return undefined;
531
+ prompt.push(attachment);
491
532
  }
492
- prompt.push({ type: "text", text: part.text });
533
+ else
534
+ return undefined;
493
535
  }
494
- return prompt.length > 0 && prompt.some(({ text }) => text.trim().length > 0) ? prompt : undefined;
536
+ return prompt.length > 0 && prompt.some((part) => part.type === "text" && part.text.trim().length > 0)
537
+ ? prompt : undefined;
495
538
  }
496
539
  /**
497
540
  * One handoff entry per line. The coordinator asset emits inline digests as `key: value`, often
@@ -1282,10 +1325,6 @@ export const SortieDogsPlugin = async (input, options) => {
1282
1325
  const messages = input.client?.session?.messages;
1283
1326
  if (messages === undefined)
1284
1327
  return undefined;
1285
- const currentText = currentParts.filter(isRecord).filter((part) => part.type === "text" && part.synthetic !== true)
1286
- .map((part) => part.text).filter((text) => typeof text === "string");
1287
- if (currentText.length === 0)
1288
- return undefined;
1289
1328
  for (const delay of [0, 10, 50]) {
1290
1329
  if (delay > 0)
1291
1330
  await new Promise((resolve) => setTimeout(resolve, delay));
@@ -1303,11 +1342,10 @@ export const SortieDogsPlugin = async (input, options) => {
1303
1342
  continue;
1304
1343
  const info = isRecord(message.info) ? message.info : undefined;
1305
1344
  if ((info?.role ?? message.role) !== "user" || (info?.agent ?? message.agent) !== selectedAgent ||
1306
- !Array.isArray(message.parts) || message.parts.some((part) => isRecord(part) && part.synthetic === true))
1345
+ !Array.isArray(message.parts) || syntheticPrompt(message.parts))
1307
1346
  continue;
1308
- const persistedText = message.parts.filter(isRecord).filter((part) => part.type === "text")
1309
- .map((part) => part.text).filter((text) => typeof text === "string");
1310
- if (JSON.stringify(persistedText) !== JSON.stringify(currentText))
1347
+ const persistedParts = freshSessionPrompt(message.parts);
1348
+ if (persistedParts === undefined || JSON.stringify(persistedParts) !== JSON.stringify(currentParts))
1311
1349
  continue;
1312
1350
  const messageID = info?.id ?? message.id;
1313
1351
  if (typeof messageID === "string" && messageID.length > 0)
@@ -1503,16 +1541,24 @@ export const SortieDogsPlugin = async (input, options) => {
1503
1541
  }
1504
1542
  }
1505
1543
  async function acceptRealGoalTurn(sessionID, messageID, selectedAgent, parts) {
1544
+ // Root recovery can prove the persisted user message identity before its parts are available.
1545
+ // Preserve that existing empty projection while every observed non-empty shape stays strict.
1546
+ const safeParts = parts.length === 0
1547
+ ? []
1548
+ : freshSessionPrompt(parts);
1549
+ if (safeParts === undefined)
1550
+ throw new Error("SORTIE_GOAL_CONTROL_DENIED: unsafe-message-parts");
1506
1551
  await recoverCompletedGoalReservations(sessionID);
1507
1552
  const ledger = await goalLedger(sessionID);
1508
1553
  const state = (await ledger.readGoal()).state;
1509
1554
  if (state.latest_user_message_id === messageID)
1510
1555
  return;
1511
1556
  const at = new Date().toISOString();
1512
- const explicitContinuation = parts.map(textPart).filter((value) => value !== undefined).join("\n")
1557
+ const explicitContinuation = safeParts.filter((part) => part.type === "text")
1558
+ .map((part) => part.text).join("\n")
1513
1559
  .split(/\r?\n/u).some((line) => /^\s*(?:goal_acceptance_fingerprint|goal_budget_(?:units|time_ms|cost_usd))\s*:/iu.test(line));
1514
1560
  if (state.goal_id === null || state.phase === "terminal" || (state.phase === "stopped" && !explicitContinuation)) {
1515
- const acceptance = goalFingerprint({ message_id: messageID, parts: parts.map(textPart).filter((value) => value !== undefined) });
1561
+ const acceptance = goalFingerprint({ message_id: messageID, parts: safeParts.map((part) => part.type === "text" ? part.text : part) });
1516
1562
  await ledger.appendGoal({ kind: "goal.accepted", at,
1517
1563
  goal_id: goalFingerprint({ root: goalRoot(sessionID), origin_user_message_id: messageID }),
1518
1564
  revision: 1, scope_epoch: 1, acceptance_fingerprint: acceptance,
@@ -1544,7 +1590,7 @@ export const SortieDogsPlugin = async (input, options) => {
1544
1590
  const persistedInfo = isRecord(message.info) ? message.info : undefined;
1545
1591
  if ((persistedInfo?.role ?? message.role) !== "user" ||
1546
1592
  (persistedInfo?.agent ?? message.agent) !== COORDINATOR_AGENT || !Array.isArray(message.parts) ||
1547
- message.parts.some((part) => isRecord(part) && part.synthetic === true))
1593
+ syntheticPrompt(message.parts))
1548
1594
  return;
1549
1595
  await acceptRealGoalTurn(sessionID, info.id, COORDINATOR_AGENT, message.parts);
1550
1596
  goalDeclarationAuthority.set(sessionID, info.id);
@@ -1685,9 +1731,6 @@ export const SortieDogsPlugin = async (input, options) => {
1685
1731
  else if (receipt === undefined && proved) {
1686
1732
  receipt = await terminalGoal(sessionID, "completed", "succeeded").catch(() => undefined);
1687
1733
  }
1688
- else if (receipt === undefined && outcome === "DONE") {
1689
- receipt = await terminalGoal(sessionID, "completed", "succeeded").catch(() => undefined);
1690
- }
1691
1734
  else if (receipt === undefined && outcome === "INTERRUPTED") {
1692
1735
  receipt = await terminalGoal(sessionID, "stopped", "stopped").catch(() => undefined);
1693
1736
  }
@@ -2054,6 +2097,9 @@ export const SortieDogsPlugin = async (input, options) => {
2054
2097
  entry.execution.units.includes(reservation.unitID));
2055
2098
  const newEvidence = acceptedEvidence.filter((entry) => entry.measurement.criterion_ids.some((criterionID) => !state.satisfied_criteria.includes(criterionID)));
2056
2099
  const progress = newEvidence.length > 0;
2100
+ // Revalidation can succeed for an already-satisfied criterion after a candidate change.
2101
+ // New criterion coverage controls progress accounting, not the validation disposition.
2102
+ const validated = acceptedEvidence.length > 0;
2057
2103
  const metadata = isRecord(output.metadata) ? output.metadata : undefined;
2058
2104
  const interrupted = metadata?.status === "cancel" || metadata?.status === "cancelled" ||
2059
2105
  output.status === "cancel" || output.status === "cancelled";
@@ -2063,12 +2109,12 @@ export const SortieDogsPlugin = async (input, options) => {
2063
2109
  execution.endedAt !== undefined && Date.parse(execution.startedAt) >= reservation.started - 1000 &&
2064
2110
  execution.outcome === "fail");
2065
2111
  const processDefect = !failedAcceptanceExecution && (childSessionID === undefined || hostBindingDefect ||
2066
- goalValidationDefects.has(childSessionID));
2067
- const resultClass = progress ? "acceptance" : interrupted ? "interrupted" : processDefect ? "process-defect" : "acceptance";
2112
+ goalValidationDefects.has(childSessionID) || !validated);
2113
+ const resultClass = validated ? "acceptance" : interrupted ? "interrupted" : processDefect ? "process-defect" : "acceptance";
2068
2114
  await ledger.appendGoal({ kind: "unit.settled", at: new Date().toISOString(),
2069
2115
  reservation_id: reservation.reservationID, receipt_id: goalFingerprint({ call_id: callID, output: outputText.slice(0, 2048) }),
2070
2116
  goal_id: state.goal_id, unit_id: reservation.unitID,
2071
- disposition: progress ? "succeeded" : interrupted ? "cancelled" : "failed", result_class: resultClass,
2117
+ disposition: validated ? "succeeded" : interrupted ? "cancelled" : "failed", result_class: resultClass,
2072
2118
  progress_fingerprint: progress ? goalFingerprint(acceptedEvidence) : null,
2073
2119
  evidence: acceptedEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started), cost_usd: null });
2074
2120
  if (childSessionID !== undefined) {
@@ -2181,57 +2227,67 @@ export const SortieDogsPlugin = async (input, options) => {
2181
2227
  }
2182
2228
  const key = `${sourceSessionID}\u0000${reason}`;
2183
2229
  const existing = freshSessionRedispatches.get(key);
2230
+ const queued = { status: "redispatch-queued", reason,
2231
+ source_session_id: sourceSessionID, retry_same_session: false };
2184
2232
  if (existing !== undefined)
2185
- return await existing.operation;
2186
- const operation = (async () => {
2187
- let targetSessionID;
2188
- try {
2189
- const created = await create.call(input.client.session, {
2190
- query: { directory: input.worktree ?? input.directory },
2191
- body: {},
2192
- });
2193
- const payload = isRecord(created) && "data" in created ? created.data : created;
2194
- if (!isRecord(payload) || typeof payload.id !== "string" || payload.id.length === 0) {
2195
- return freshSessionFallback(reason, fallbackAction);
2196
- }
2197
- targetSessionID = payload.id;
2198
- const parentID = typeof payload.parentID === "string" ? payload.parentID
2199
- : typeof payload.parentId === "string" ? payload.parentId
2200
- : undefined;
2201
- if (parentID !== undefined) {
2202
- await deleteFreshSession(targetSessionID);
2203
- return freshSessionFallback(reason, fallbackAction);
2204
- }
2205
- goalRootSessions.set(targetSessionID, goalRoot(sourceSessionID));
2206
- const ticket = await issueGoalTicket(targetSessionID, `fresh-root:${reason}`);
2207
- if (!ticket.issued)
2208
- throw new Error("fresh coordinator ticket already outstanding");
2209
- const sent = await send.call(input.client.session, {
2210
- path: { id: targetSessionID },
2211
- query: { directory: input.worktree ?? input.directory },
2212
- body: { agent: COORDINATOR_AGENT, parts: prompt.map((part) => ({ ...part, synthetic: true,
2213
- metadata: ticket.metadata })) },
2214
- });
2215
- if (!promptAccepted(sent))
2216
- throw new Error("fresh coordinator prompt rejected");
2217
- appLogInfo("fresh-session.redispatched", sourceSessionID, {
2218
- reason,
2219
- targetSessionID: targetSessionID.slice(0, 128),
2220
- });
2221
- return {
2222
- status: "redispatched",
2223
- reason,
2224
- source_session_id: sourceSessionID,
2225
- target_session_id: targetSessionID,
2226
- retry_same_session: false,
2227
- };
2228
- }
2229
- catch {
2230
- if (targetSessionID !== undefined)
2231
- await deleteFreshSession(targetSessionID);
2232
- return freshSessionFallback(reason, fallbackAction);
2233
- }
2234
- })();
2233
+ return existing.settled ? await existing.operation : queued;
2234
+ // Both session.create and promptAsync enter the host request scheduler. Defer the entire
2235
+ // redispatch until the child chat hook has returned its typed control error.
2236
+ const operation = new Promise((accept) => {
2237
+ setTimeout(() => {
2238
+ void (async () => {
2239
+ let targetSessionID;
2240
+ try {
2241
+ const created = await create.call(input.client.session, {
2242
+ query: { directory: input.worktree ?? input.directory },
2243
+ body: {},
2244
+ });
2245
+ const payload = isRecord(created) && "data" in created ? created.data : created;
2246
+ if (!isRecord(payload) || typeof payload.id !== "string" || payload.id.length === 0) {
2247
+ return freshSessionFallback(reason, fallbackAction);
2248
+ }
2249
+ targetSessionID = payload.id;
2250
+ const parentID = typeof payload.parentID === "string" ? payload.parentID
2251
+ : typeof payload.parentId === "string" ? payload.parentId
2252
+ : undefined;
2253
+ if (parentID !== undefined) {
2254
+ await deleteFreshSession(targetSessionID);
2255
+ return freshSessionFallback(reason, fallbackAction);
2256
+ }
2257
+ goalRootSessions.set(targetSessionID, goalRoot(sourceSessionID));
2258
+ const ticket = await issueGoalTicket(targetSessionID, `fresh-root:${reason}`);
2259
+ if (!ticket.issued)
2260
+ throw new Error("fresh coordinator ticket already outstanding");
2261
+ const sendFresh = send;
2262
+ const sent = await sendFresh.call(input.client.session, {
2263
+ path: { id: targetSessionID },
2264
+ query: { directory: input.worktree ?? input.directory },
2265
+ body: { agent: COORDINATOR_AGENT, parts: prompt.map((part) => part.type === "text"
2266
+ ? { ...part, synthetic: true, metadata: ticket.metadata }
2267
+ : part) },
2268
+ });
2269
+ if (!promptAccepted(sent))
2270
+ throw new Error("fresh coordinator prompt rejected");
2271
+ appLogInfo("fresh-session.redispatched", sourceSessionID, {
2272
+ reason,
2273
+ targetSessionID: targetSessionID.slice(0, 128),
2274
+ });
2275
+ return {
2276
+ status: "redispatched",
2277
+ reason,
2278
+ source_session_id: sourceSessionID,
2279
+ target_session_id: targetSessionID,
2280
+ retry_same_session: false,
2281
+ };
2282
+ }
2283
+ catch {
2284
+ if (targetSessionID !== undefined)
2285
+ await deleteFreshSession(targetSessionID);
2286
+ return freshSessionFallback(reason, fallbackAction);
2287
+ }
2288
+ })().then(accept);
2289
+ }, 0);
2290
+ });
2235
2291
  const entry = { operation, settled: false };
2236
2292
  freshSessionRedispatches.set(key, entry);
2237
2293
  void operation.then((result) => {
@@ -2254,7 +2310,7 @@ export const SortieDogsPlugin = async (input, options) => {
2254
2310
  break;
2255
2311
  freshSessionRedispatches.delete(completed[0]);
2256
2312
  }
2257
- return await operation;
2313
+ return queued;
2258
2314
  }
2259
2315
  async function ensureLoaded() {
2260
2316
  if (loaded?.gate !== undefined)
@@ -3425,9 +3481,6 @@ export const SortieDogsPlugin = async (input, options) => {
3425
3481
  contract_fingerprint: structuralAdmission.contract_fingerprint, experience: experience.trace });
3426
3482
  }
3427
3483
  const coordinator = await getParallelCoordinator();
3428
- if (await coordinator.targetCheckedOut(`refs/heads/${structuralAdmission.contract.provenance.target_branch}`)) {
3429
- return JSON.stringify({ status: "sol-serial", reason: "target-checked-out", experience: experience.trace });
3430
- }
3431
3484
  const result = await coordinator.prepareFabric(contract, ownerRoot, executionPlanPath === undefined ? undefined : await readJson(resolve(executionPlanPath), INPUT_LIMITS.parallel));
3432
3485
  if (result.status === "sol-serial")
3433
3486
  return JSON.stringify({ ...result, experience: experience.trace });
@@ -5017,15 +5070,19 @@ export const SortieDogsPlugin = async (input, options) => {
5017
5070
  const agent = info?.agent ?? message.agent;
5018
5071
  if (typeof agent !== "string")
5019
5072
  return undefined;
5020
- if (message.parts !== undefined && !Array.isArray(message.parts))
5073
+ const parts = message.parts === undefined ? [] : message.parts;
5074
+ if (!Array.isArray(parts))
5021
5075
  return undefined;
5022
- const synthetic = Array.isArray(message.parts) && message.parts.some((part) => isRecord(part) && part.synthetic === true);
5076
+ const synthetic = syntheticPrompt(parts);
5023
5077
  if (synthetic)
5024
5078
  continue;
5079
+ const persistedParts = parts.length === 0 ? [] : freshSessionPrompt(parts);
5080
+ if (persistedParts === undefined)
5081
+ return undefined;
5025
5082
  const messageID = info?.id ?? message.id;
5026
5083
  if (typeof messageID !== "string" || messageID.length === 0)
5027
5084
  return undefined;
5028
- persistedTurn = { agent, synthetic: false, messageID };
5085
+ persistedTurn = { agent, synthetic: false, messageID, parts: persistedParts };
5029
5086
  break;
5030
5087
  }
5031
5088
  return { hasForeignUserTurn: persistedTurn?.agent !== undefined && persistedTurn.agent !== COORDINATOR_AGENT,
@@ -5121,7 +5178,7 @@ export const SortieDogsPlugin = async (input, options) => {
5121
5178
  return false;
5122
5179
  await rememberCoordinatorRoot(sessionID);
5123
5180
  if (persistedTurn !== undefined) {
5124
- await acceptRealGoalTurn(sessionID, persistedTurn.messageID, persistedTurn.agent, []);
5181
+ await acceptRealGoalTurn(sessionID, persistedTurn.messageID, persistedTurn.agent, persistedTurn.parts);
5125
5182
  goalDeclarationAuthority.set(sessionID, persistedTurn.messageID);
5126
5183
  }
5127
5184
  await pinAssetVersion(sessionID);
@@ -5560,16 +5617,23 @@ export const SortieDogsPlugin = async (input, options) => {
5560
5617
  .replaceAll(CONTINUATION_MARKER, "")
5561
5618
  .trimEnd();
5562
5619
  }
5620
+ const hostRunOutcome = terminalRunOutcome(textOutput.text);
5563
5621
  textOutput.text = await preserveActiveGoalContinuation(textInput.sessionID, textOutput.text, textInput.messageID);
5564
- const runOutcome = terminalRunOutcome(textOutput.text);
5622
+ // Preserve a host DONE claim for proof checks while allowing an ordinary local INTERRUPTED
5623
+ // response to remain presentation-only IN_PROGRESS continuation.
5624
+ const runOutcome = hostRunOutcome === "DONE" ? hostRunOutcome : terminalRunOutcome(textOutput.text);
5565
5625
  const terminal = runOutcome === undefined || !isCoordinatorSession(textInput.sessionID)
5566
5626
  ? undefined
5567
5627
  : await terminalGoalFromHostText(textInput.sessionID, textOutput.text);
5568
- if (runOutcome === "DONE" && terminal !== undefined && (terminal.delivery === "running" ||
5569
- (terminal.receipt === undefined && terminal.goal !== undefined && terminal.goal.goal_id !== null))) {
5570
- textOutput.text = replaceDoneTerminalStatus(textOutput.text, terminal.delivery === "running"
5571
- ? "status: IN_PROGRESS — durable delivery active; same sessionでjoinまたはstale reconcileが必要"
5572
- : "status: IN_PROGRESS\ngoal_control: accepted criteria remain unproved");
5628
+ if (runOutcome === "DONE" && terminal?.delivery === "running") {
5629
+ textOutput.text = replaceDoneTerminalStatus(textOutput.text, "status: IN_PROGRESS durable delivery active; same sessionでjoinまたはstale reconcileが必要");
5630
+ await continuation.stopAutomaticRecovery(textInput.sessionID, false, true);
5631
+ }
5632
+ else if (runOutcome === "DONE" && terminal?.receipt === undefined &&
5633
+ terminal?.goal !== undefined && terminal.goal.goal_id !== null) {
5634
+ textOutput.text = replaceDoneTerminalStatus(textOutput.text, "status: INTERRUPTED — accepted criteria remain unproved\n" +
5635
+ "TRUE_INTERRUPTION: internal: accepted criteria remain unproved");
5636
+ await continuation.stopAutomaticRecovery(textInput.sessionID, false, true);
5573
5637
  }
5574
5638
  if (runOutcome !== "DONE" && terminal?.delivery === "ready" && terminal.receipt?.status === "succeeded") {
5575
5639
  textOutput.text = replaceTerminalStatus(textOutput.text, "status: DONE");
@@ -5663,7 +5727,7 @@ export const SortieDogsPlugin = async (input, options) => {
5663
5727
  observedChildTerminals.delete(chatInput.sessionID);
5664
5728
  await serializeChatTransition(chatInput.sessionID, async () => {
5665
5729
  const parentID = chatParentID(chatInput);
5666
- const synthetic = output.parts.some((part) => isRecord(part) && part.synthetic === true);
5730
+ const synthetic = syntheticPrompt(output.parts);
5667
5731
  const selectedAgent = chatInput.agent ?? output.message.agent;
5668
5732
  if (parentID !== undefined)
5669
5733
  rememberParent(chatInput.sessionID, parentID);
@@ -5673,8 +5737,15 @@ export const SortieDogsPlugin = async (input, options) => {
5673
5737
  output.message.agent = chatInput.agent;
5674
5738
  }
5675
5739
  const requestedCoordinator = selectedAgent === COORDINATOR_AGENT;
5740
+ const projectedCoordinatorParts = requestedCoordinator && !synthetic && output.parts.length > 0
5741
+ ? freshSessionPrompt(output.parts)
5742
+ : undefined;
5743
+ if (requestedCoordinator && !synthetic && output.parts.length > 0 && projectedCoordinatorParts === undefined) {
5744
+ throw new Error("SORTIE_GOAL_CONTROL_DENIED: unsafe-message-parts");
5745
+ }
5676
5746
  const messageID = realMessageID(chatInput, output) ?? (synthetic ? undefined
5677
- : await persistedCurrentRealMessageID(chatInput.sessionID, selectedAgent, output.parts));
5747
+ : projectedCoordinatorParts === undefined ? undefined
5748
+ : await persistedCurrentRealMessageID(chatInput.sessionID, selectedAgent, projectedCoordinatorParts));
5678
5749
  if (requestedCoordinator && !coordinatorRoot && (parentID !== undefined || knownChildSessions.has(chatInput.sessionID)) &&
5679
5750
  !synthetic && messageID !== undefined) {
5680
5751
  await acceptRealGoalTurn(chatInput.sessionID, messageID, selectedAgent, output.parts);
@@ -5721,11 +5792,11 @@ export const SortieDogsPlugin = async (input, options) => {
5721
5792
  // Some native hosts persist the user message only after this hook returns. Defer to the
5722
5793
  // system-transform boundary, but retain no synthetic authority and accept only the exact
5723
5794
  // final persisted real-user parts through persistedCurrentRealMessageID.
5724
- pendingRealGoalTurns.set(chatInput.sessionID, { selectedAgent, parts: [...output.parts] });
5795
+ pendingRealGoalTurns.set(chatInput.sessionID, { selectedAgent, parts: projectedCoordinatorParts ?? [] });
5725
5796
  pruneParallelChildMap(pendingRealGoalTurns);
5726
5797
  schedulePendingRealGoalRecovery(chatInput.sessionID);
5727
5798
  }
5728
- const prompt = synthetic ? undefined : freshSessionPrompt(output.parts);
5799
+ const prompt = projectedCoordinatorParts;
5729
5800
  if (prompt !== undefined)
5730
5801
  coordinatorPrompts.set(chatInput.sessionID, prompt);
5731
5802
  // Synthetic coordinator turns reach here only after consumeGoalTicket accepted the
@@ -6676,14 +6747,19 @@ export const SortieDogsPlugin = async (input, options) => {
6676
6747
  return;
6677
6748
  // Deletion is terminal for watchdog recovery. Disarm synchronously before any generic event
6678
6749
  // processing can await, touch activity, or let a queued sweep recover the cancelled root.
6679
- if (event.type === "session.deleted")
6750
+ if (event.type === "session.deleted") {
6680
6751
  disarmDeletedCoordinatorTaskWatchdog(eventSessionID);
6752
+ await continuation.stopAutomaticRecovery(eventSessionID, false);
6753
+ }
6681
6754
  if (event.type === "message.updated" && info !== undefined) {
6682
6755
  rememberCoordinatorInterruption(eventSessionID, info);
6683
- await acceptPersistedRealGoalEvent(eventSessionID, info);
6756
+ if (pendingRealGoalTurns.has(eventSessionID))
6757
+ await recoverPendingRealGoalTurn(eventSessionID);
6758
+ else
6759
+ await acceptPersistedRealGoalEvent(eventSessionID, info);
6684
6760
  }
6685
6761
  if (pendingRealGoalTurns.has(eventSessionID) &&
6686
- (event.type === "message.updated" || event.type === "message.part.updated")) {
6762
+ event.type === "message.part.updated") {
6687
6763
  await recoverPendingRealGoalTurn(eventSessionID);
6688
6764
  }
6689
6765
  const eventPartTime = isRecord(eventPart?.time) ? eventPart.time : undefined;
@@ -112,7 +112,15 @@ type SortieGoalSnapshot = Pick<GoalFlightState, "acceptance_contract" | "consume
112
112
  export declare function createSortieResult(receipt: GoalTerminalReceipt, goal: SortieGoalSnapshot, metrics: RunMetrics | undefined, asOf?: string, records?: readonly GoalFlightEventRecord[]): SortieResult;
113
113
  export type RunTerminalOutcome = "DONE" | "INTERRUPTED" | "BLOCKED" | "NEED_DECISION";
114
114
  export declare function collectRunMetrics(client: RunMetricsClient | undefined, rootSessionID: string, directory?: string, now?: number, window?: RunMetricsWindow): Promise<RunMetrics | undefined>;
115
- export declare function formatSortieResult(result: SortieResult): string;
115
+ export interface SortieResultPresentation {
116
+ readonly implementation?: string;
117
+ readonly pending?: string;
118
+ readonly next?: string;
119
+ readonly commit?: string;
120
+ readonly statusSummary?: string;
121
+ readonly stopReason?: string;
122
+ }
123
+ export declare function formatSortieResult(result: SortieResult, presentation?: SortieResultPresentation): string;
116
124
  export declare function formatRunMetrics(metrics: RunMetrics): string;
117
125
  export declare function isDoneTerminalText(text: string): boolean;
118
126
  export declare function terminalRunOutcome(text: string): RunTerminalOutcome | undefined;
@@ -1,4 +1,4 @@
1
- import { buildDebrief, renderDebrief, observeDebriefSession } from "./sortie-debrief.js";
1
+ import { buildDebrief, renderDebrief, renderDebriefProof, observeDebriefSession } from "./sortie-debrief.js";
2
2
  import { goalFingerprint } from "../core/goal-bound.js";
3
3
  import { renderCareer } from "./sortie-career.js";
4
4
  const unavailable = (reason) => ({ availability: "unavailable", value: null, reason });
@@ -341,25 +341,61 @@ function duration(milliseconds) {
341
341
  function metricText(metric, render) {
342
342
  return metric.availability === "available" ? render(metric.value) : "計測不可";
343
343
  }
344
- export function formatSortieResult(result) {
344
+ const displayText = (value) => value?.replace(/[\r\n\t]+/gu, " ").trim() || "未取得";
345
+ const reportFence = (body) => {
346
+ const longest = Math.max(0, ...[...body.matchAll(/^~+/gmu)].map((match) => match[0].length));
347
+ const fence = "~".repeat(Math.max(3, longest + 1));
348
+ return `${fence}text\n${body}\n${fence}`;
349
+ };
350
+ export function formatSortieResult(result, presentation = {}) {
345
351
  const criteria = metricText(result.proof.criteria, (entries) => {
346
352
  const passing = entries.filter(({ status }) => status === "PASS").length;
347
353
  return `${passing}/${entries.length}`;
348
354
  });
349
- const achievement = result.mission.status === "COMPLETED" ? "完了"
355
+ const achievement = result.mission.status === "COMPLETED" ? "COMPLETED"
356
+ : result.mission.status === "INTERRUPTED" ? "INTERRUPTED"
357
+ : result.mission.status === "EXTERNAL_BLOCKER" ? "EXTERNAL_BLOCKER" : "USER_DECISION";
358
+ const summaryLabel = result.mission.status === "COMPLETED" ? "完了"
350
359
  : result.mission.status === "INTERRUPTED" ? "中断(未完了)"
351
- : result.mission.status === "EXTERNAL_BLOCKER" ? "外部要因で未完了"
352
- : "ユーザー判断待ち(未完了)";
360
+ : result.mission.status === "EXTERNAL_BLOCKER" ? "外部要因で未完了" : "ユーザー判断待ち(未完了)";
353
361
  const color = result.mission.status === "COMPLETED" ? "🟢" : result.mission.status === "EXTERNAL_BLOCKER" ? "🔴" : "🟡";
362
+ const proof = renderDebriefProof(result.debrief);
354
363
  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)}`)}(実課金換算なし)`,
364
+ "🐾 SORTIE DOGS — 帰還報告",
365
+ result.result_id[0],
366
+ "",
367
+ `${color} ${achievement} — ${displayText(presentation.statusSummary)}`,
368
+ "",
369
+ "⚔️ MISSION",
370
+ `経過 ⏱ ${metricText(result.speed.goal_wall_ms, duration)} ※待機含む`,
371
+ `最終達成条件 ◔ ${criteria}`,
372
+ `対象検証 ${proof.validation}`,
373
+ `SourceReview ${proof.review}`,
374
+ `Commit ${displayText(presentation.commit)}`,
375
+ "",
376
+ "🔧 実装",
377
+ displayText(presentation.implementation),
378
+ "",
379
+ "⏳ 未実施",
380
+ displayText(presentation.pending),
381
+ "",
382
+ "➡️ NEXT",
383
+ displayText(presentation.next),
384
+ "",
385
+ "🪙 COST / PACK",
386
+ `使用量 ${metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`)}`,
387
+ `host推定額 ${metricText(result.cost.cost_usd, (value) => `$${value.toFixed(4)}`)} ※実課金換算なし`,
388
+ "",
357
389
  ...renderDebrief(result.debrief),
358
- `**🛡 達成:** ${color} **${achievement}** · 達成条件 **${criteria}**`,
359
- "*最終応答生成前の計測*",
390
+ "",
360
391
  ...renderCareer(result.career),
361
- ].join("\n\n");
362
- return `<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告|${color} ${achievement}</strong></summary>\n\n${body}\n\n</details>`;
392
+ "",
393
+ "🛑 STOP REASON",
394
+ displayText(presentation.stopReason ?? result.mission.stop_reason),
395
+ "",
396
+ "※使用量は最終応答生成前の計測",
397
+ ].join("\n");
398
+ return `<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告|${color} ${summaryLabel}</strong></summary>\n\n${reportFence(body)}\n\n</details>`;
363
399
  }
364
400
  export function formatRunMetrics(metrics) {
365
401
  const elapsed = metrics.durationMilliseconds === undefined ? "duration unavailable" : `${duration(metrics.durationMilliseconds)} wall-clock`;
@@ -476,6 +512,7 @@ export function insertRunMetrics(text, metrics) {
476
512
  return lines.join(newline);
477
513
  }
478
514
  export function insertSortieResult(text, result) {
515
+ const presentation = extractSortiePresentation(text);
479
516
  const visible = sanitizeTerminalReport(text);
480
517
  const checkpoint = terminalCheckpoint(visible);
481
518
  if (checkpoint === undefined)
@@ -506,7 +543,7 @@ export function insertSortieResult(text, result) {
506
543
  cleaned.splice(checkpoint.index + 1, 1);
507
544
  let card;
508
545
  try {
509
- card = formatSortieResult(result);
546
+ card = formatSortieResult(result, presentation);
510
547
  }
511
548
  catch {
512
549
  card = "<details>\n<summary><strong>🐾 SORTIE DOGS — 帰還報告</strong></summary>\n\n**確認:** 表示集計を取得できません。任務結果は先頭の状態を参照。\n\n</details>";
@@ -514,6 +551,23 @@ export function insertSortieResult(text, result) {
514
551
  cleaned.splice(checkpoint.index + 1, 0, "", card, "");
515
552
  return cleaned.join(newline).trimEnd();
516
553
  }
554
+ function extractSortiePresentation(text) {
555
+ const first = topLevelLines(text).find(({ line }) => line.trim().length > 0)?.line ?? "";
556
+ const statusSummary = first.split(/\s+[—-]\s+/u).slice(1).join(" — ").trim() || undefined;
557
+ const section = (names) => {
558
+ const expression = new RegExp(`^[ \\t]*(?:#{1,6}[ \\t]*)?(?:\\*\\*)?(?:${names})(?:\\*\\*)?[ \\t]*:?[ \\t]*(?:\\*\\*)?[ \\t]*(.*)$`, "imu");
559
+ const match = expression.exec(text);
560
+ if (match === null)
561
+ return undefined;
562
+ if (match[1]?.trim())
563
+ return match[1].trim();
564
+ const tail = text.slice(match.index + match[0].length).split(/\r?\n/u);
565
+ return tail.find((line) => line.trim().length > 0 && !/^[ \\t]*(?:#{1,6}[ \\t]*)?(?:\\*\\*)?(?:変更点|実装|未実施|次|NEXT|Commit|コミット)/iu.test(line))?.trim();
566
+ };
567
+ const explicitStop = /^(?:TRUE_INTERRUPTION|TRUE_BLOCKER)[ \\t]*:[ \\t]*(.+)$/imu.exec(text)?.[1]?.trim();
568
+ return { statusSummary, implementation: section("変更点|実装"), pending: section("未実施"), next: section("次|NEXT"),
569
+ commit: section("Commit|コミット"), stopReason: explicitStop };
570
+ }
517
571
  export function createGoalReport(result, receipt) {
518
572
  const tokens = result.cost.total_tokens.availability === "available" && Number.isSafeInteger(result.cost.total_tokens.value)
519
573
  ? result.cost.total_tokens.value : null;
@@ -124,21 +124,25 @@ export async function collectCareer(directories, currentPath, current, read, max
124
124
  }
125
125
  export function renderCareer(career) {
126
126
  if (career === undefined)
127
- return ["**📜 PACK RECORD:** 保存履歴を取得できません"];
127
+ return ["📜 PACK RECORD", "保存履歴を取得できません"];
128
128
  const terminal = career.goals - career.active;
129
- const firstPass = career.firstPass.eligible === 0 ? "計測不可(対象0件)" : `${career.firstPass.count}/${career.firstPass.eligible}件`;
130
- const minutes = (metric) => metric.covered === 0 ? "計測不可" : `${(metric.sum / 60000).toFixed(1)}分(${metric.covered}/${terminal}任務)`;
131
- const models = [...career.models].sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
132
- const modelText = models.slice(0, 4).map((entry) => `${entry.model.replace(/[\\`*_{}\[\]()<>!|\r\n]/gu, "").slice(0, 120)} ${entry.tokens.toLocaleString("ja-JP")}`).join(" · ");
129
+ const minutes = (metric) => metric.covered === 0 ? "計測不可" : `${(metric.sum / 60000).toFixed(1)}分 ※${metric.covered}/${terminal}任務`;
133
130
  return [
134
- "**📜 PACK RECORD — 記録済み戦績**",
135
- `**戦績:** 完了 ${career.completed} · 中断 ${career.interrupted} · 外部待機 ${career.external} · 指示待ち ${career.decision} · 進行中 ${career.active}`,
136
- `**初回完遂:** ${firstPass} · 復帰 ${career.recoveries}件(計測記録 ${career.telemetryCovered}/${terminal}任務)`,
137
- `**累積使用量:** ${career.tokens.covered === 0 ? "計測不可" : `${career.tokens.sum.toLocaleString("ja-JP")} tokens`}(計測 ${career.tokens.covered}/${terminal}任務)`,
138
- `**累積モデル:** ${career.modelCovered === 0 ? "計測不可" : modelText || "出力なし"}${models.length > 4 ? " · ほか" : ""}(token計測 ${career.modelCovered}/${terminal}任務)`,
139
- `**累積時間:** worker ${minutes(career.workerTime)} · goal期間合計 ${minutes(career.goalWall)}(待機含む・同時刻重複あり)`,
140
- `**累積実行重複率:** ${career.overlap.ratio === null ? "計測不可" : `${career.overlap.ratio.toFixed(2)}×`}(総和の比・${career.overlap.covered}/${terminal}任務・速度倍率ではありません)`,
141
- `**保存範囲:** ${career.since?.slice(0, 10) ?? "開始日不明"}以降の現存履歴 · ${career.coverage.included}/${career.coverage.files}ファイル${career.coverage.unavailable || career.coverage.truncated ? " · 部分集計" : ""} · 生涯戦績ではありません`,
142
- ...(career.titles.length ? [`**🎖 隊の称号:** ${career.titles.join(" · ")}`] : []),
131
+ "📜 PACK RECORD",
132
+ `🏁 完了 ${career.completed}`,
133
+ `🟡 中断 ${career.interrupted}`,
134
+ `⏳ 外部待機 ${career.external}`,
135
+ `❓ 指示待ち ${career.decision}`,
136
+ `🔄 進行中 ${career.active}`,
137
+ `↩️ 復帰 ${career.recoveries}`,
138
+ "",
139
+ `🪙 累積使用量 ${career.tokens.covered === 0 ? "計測不可" : `${career.tokens.sum.toLocaleString("ja-JP")} tokens`} ※${career.tokens.covered}/${terminal}任務`,
140
+ `⏱ 累積worker ${minutes(career.workerTime)}`,
141
+ `🕰 累積goal ${minutes(career.goalWall)} ※待機・重複含む`,
142
+ `⚡ 累積重複率 ${career.overlap.ratio === null ? "計測不可" : `${career.overlap.ratio.toFixed(2)}×`} ※${career.overlap.covered}/${terminal}任務・速度倍率ではありません`,
143
+ "",
144
+ "📦 保存範囲",
145
+ `${career.since?.slice(0, 10) ?? "開始日不明"}以降 / ${career.coverage.included} of ${career.coverage.files} files${career.coverage.unavailable || career.coverage.truncated ? " ※部分集計" : ""}`,
146
+ "※生涯戦績ではありません",
143
147
  ];
144
148
  }
@@ -55,4 +55,8 @@ export interface Debrief {
55
55
  export declare function observeDebriefSession(id: string, root: boolean, messages: readonly Record<string, unknown>[], window?: Span): DebriefSession;
56
56
  export declare function buildDebrief(receipt: GoalTerminalReceipt, contract: GoalAcceptanceContract | null, observation: DebriefObservation | undefined, records?: readonly GoalFlightEventRecord[]): Debrief;
57
57
  export declare function renderDebrief(debrief: Debrief | undefined): string[];
58
+ export declare function renderDebriefProof(debrief: Debrief | undefined): {
59
+ validation: string;
60
+ review: string;
61
+ };
58
62
  export {};
@@ -254,6 +254,12 @@ export function buildDebrief(receipt, contract, observation, records) {
254
254
  wallMilliseconds: unionDuration(spans) } } : {}) };
255
255
  }
256
256
  const label = (text) => text.replace(/[\r\n\t]/gu, " ").replace(/[\\`*_{}\[\]()<>!|]/gu, "").slice(0, 120);
257
+ const gauge = (percent) => {
258
+ const eighths = Math.max(0, Math.min(80, Math.round(percent * 0.8)));
259
+ const whole = Math.floor(eighths / 8), remainder = eighths % 8;
260
+ const partial = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][remainder];
261
+ return `${"█".repeat(whole)}${partial}${" ".repeat(10 - whole - (remainder === 0 ? 0 : 1))}`;
262
+ };
257
263
  export function renderDebrief(debrief) {
258
264
  const pack = debrief?.pack == null ? null : [...debrief.pack].sort((a, b) => b.count - a.count || a.model.localeCompare(b.model));
259
265
  const packVisible = pack?.slice(0, 4) ?? [];
@@ -264,21 +270,24 @@ export function renderDebrief(debrief) {
264
270
  if (mix !== null && mix.length > 4)
265
271
  visible.push({ model: "その他", tokens: mix.slice(4).reduce((sum, entry) => sum + entry.tokens, 0),
266
272
  percent: mix.slice(4).reduce((sum, entry) => sum + entry.percent, 0) });
267
- const bars = (percent) => {
268
- const filled = Math.max(0, Math.min(10, Math.round(percent / 10)));
269
- return "█".repeat(filled) + "░".repeat(10 - filled);
270
- };
271
- const status = (value) => value === "PASS" ? "🟢 **PASS**" : value === "FAIL" ? "🔴 **FAIL**"
273
+ const status = (value) => value === "PASS" ? "🟢 PASS" : value === "FAIL" ? "🔴 FAIL"
272
274
  : value === "WAIVED" ? "免除" : "未記録";
275
+ const counts = new Map(packVisible.map((entry) => [entry.model, entry.count]));
273
276
  return [
274
- `**🐕 出撃隊:** ${pack === null ? "履歴未取得" : pack.length === 0 ? "出撃なし" : packVisible.map((entry) => `${label(entry.model)} **×${entry.count}**`).join(" · ")}`,
275
- `**モデル別token内訳:** ${mix === null ? "usage未取得" : ""}`,
276
- ...visible.map((entry) => `**↳** ${label(entry.model)} \`${bars(entry.percent)}\` ${entry.percent.toFixed(1)}%`),
277
- `**実行重複率:** ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
278
- ? `**${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×**(worker区間・速度倍率ではありません)`
277
+ ...(mix === null ? ["モデル内訳 usage未取得"] : visible.map((entry) => {
278
+ const count = counts.get(entry.model);
279
+ return `🐕 ${label(entry.model)} ${gauge(entry.percent)} ${entry.percent.toFixed(1)}% ${entry.tokens.toLocaleString("ja-JP")} tokens${count === undefined ? "" : ` ×${count}`}`;
280
+ })),
281
+ `⚡ 実行重複率 ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
282
+ ? `${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×`
279
283
  : pack?.length === 0 ? "対象なし(出撃なし)" : "稼働区間の記録不足"}`,
280
- `**確認:** 対象検証 ${status(debrief?.validation ?? "未確認")} · 直近Review ${status(debrief?.review ?? "未確認")}${debrief?.reviewSource === "reviewer" ? "(reviewer報告)" : ""}`,
281
- ...(debrief?.notes?.length ? [`**計測範囲:** ${debrief.notes.join(" · ")}`] : []),
282
- ...(debrief?.traits.length ? [`**🏅 今回の戦績:** ${debrief.traits.join(" · ")}`] : []),
284
+ " ※worker区間・速度倍率ではありません",
285
+ ...(debrief?.notes?.length ? [`計測範囲 ${debrief.notes.join(" · ")}`] : []),
283
286
  ];
284
287
  }
288
+ export function renderDebriefProof(debrief) {
289
+ const status = (value) => value === "PASS" ? "🟢 PASS" : value === "FAIL" ? "🔴 FAIL"
290
+ : value === "WAIVED" ? "免除" : "未記録";
291
+ return { validation: status(debrief?.validation ?? "未確認"),
292
+ review: `${status(debrief?.review ?? "未確認")}${debrief?.reviewSource === "reviewer" ? "(reviewer報告)" : ""}` };
293
+ }
@@ -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.86-codegen-proof-v1";
10
+ readonly version: "0.3.89-completion-proof-v1";
11
11
  readonly installPath: "agent/dog-coordinator.md";
12
12
  readonly content: `---
13
13
  description: Canonical MkII coordinator packaged by Sortie-dogs
@@ -1391,6 +1391,9 @@ criterion -> changed or inspected implementation path -> concrete exercising tes
1391
1391
  An aggregate validation command without that mapping is insufficient. Multi-form criteria must cover their
1392
1392
  materially distinct syntax, value-shape, scope, and error paths; any missing path remains UNPROVEN and requires
1393
1393
  continued implementation or validation. Include the complete trace in every high-risk SourceReview artifact.
1394
+ For accepted failure behavior, require result/error/state evidence and a valid case; for accepted composite
1395
+ or wrapped-value behavior, require public-entry-point evidence. Accept justified N/A dimensions and existing
1396
+ sufficient evidence without extra validation or review. Do not accept expectations copied from the candidate's output.
1394
1397
  Any generated input/output pair in the changed manifest is high risk and requires SourceReview. DONE requires
1395
1398
  generator command evidence, post-generation candidate identity, generated-output stability, and canonical
1396
1399
  validation after generation. Reject evidence produced only before regeneration.
@@ -1468,10 +1471,10 @@ TERMINAL_STATUS_SEMANTICS_FIXTURE
1468
1471
  END_TERMINAL_STATUS_SEMANTICS_FIXTURE
1469
1472
 
1470
1473
  RUNTIME_ASSET_VERSION_SYNC_FIXTURE
1471
- runtime_version: 0.3.86-codegen-proof-v1
1474
+ runtime_version: 0.3.89-completion-proof-v1
1472
1475
  shared_marker: src/asset-version.ts
1473
- packaged_expectation: test/plugin-loader.test.ts uses 0.3.86-codegen-proof-v1
1474
- initialize_expectation: test/initialize.test.ts uses 0.3.86-codegen-proof-v1
1476
+ packaged_expectation: test/plugin-loader.test.ts uses 0.3.89-completion-proof-v1
1477
+ initialize_expectation: test/initialize.test.ts uses 0.3.89-completion-proof-v1
1475
1478
  rule: runtime asset versions, shared marker, packaged expectation, and initialize expectation change together
1476
1479
  END_RUNTIME_ASSET_VERSION_SYNC_FIXTURE
1477
1480
 
@@ -1494,32 +1497,32 @@ END_INTERNAL_TERMINAL_PROOF_FIXTURE
1494
1497
  `;
1495
1498
  }, {
1496
1499
  readonly name: "dog-worker";
1497
- readonly version: "0.3.86-codegen-proof-v1";
1500
+ readonly version: "0.3.89-completion-proof-v1";
1498
1501
  readonly installPath: "agent/dog-worker.md";
1499
1502
  readonly content: string;
1500
1503
  }, {
1501
1504
  readonly name: "dog-luna-worker";
1502
- readonly version: "0.3.86-codegen-proof-v1";
1505
+ readonly version: "0.3.89-completion-proof-v1";
1503
1506
  readonly installPath: "agent/dog-luna-worker.md";
1504
1507
  readonly content: string;
1505
1508
  }, {
1506
1509
  readonly name: "dog-scout";
1507
- readonly version: "0.3.86-codegen-proof-v1";
1510
+ readonly version: "0.3.89-completion-proof-v1";
1508
1511
  readonly installPath: "agent/dog-scout.md";
1509
1512
  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";
1510
1513
  }, {
1511
1514
  readonly name: "dog-reviewer";
1512
- readonly version: "0.3.86-codegen-proof-v1";
1515
+ readonly version: "0.3.89-completion-proof-v1";
1513
1516
  readonly installPath: "agent/dog-reviewer.md";
1514
- 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.\nAlso require each acceptance item to map to a concrete exercising test/input/branch and result. A broad\nsuite PASS without criterion-level exercise evidence is insufficient. When one item contains materially\ndifferent syntax forms, value shapes, scopes, or error paths, reject PASS unless representative traces cover\neach path or the artifact proves they share one implementation path.\nWhen changed files include generator inputs or checked-in generated outputs, require the canonical generator\ncommand, a stable post-generation diff, and validation executed after generation. Reject PASS if helper logic\nexists only in a generated output, regeneration removes behavior, or validation predates the generated candidate.\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";
1517
+ 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.\nAlso require each acceptance item to map to a concrete exercising test/input/branch and result. A broad\nsuite PASS without criterion-level exercise evidence is insufficient. When one item contains materially\ndifferent syntax forms, value shapes, scopes, or error paths, reject PASS unless representative traces cover\neach path or the artifact proves they share one implementation path.\nCheck contract-derived expectations only for accepted behavior. For an applicable failure or composite-value\ncriterion, error-only or helper-only assertions can leave public result/state behavior unproved. Accept justified\nN/A dimensions; do not demand new behavior, new review rounds, or redundant checks outside accepted scope.\nWhen changed files include generator inputs or checked-in generated outputs, require the canonical generator\ncommand, a stable post-generation diff, and validation executed after generation. Reject PASS if helper logic\nexists only in a generated output, regeneration removes behavior, or validation predates the generated candidate.\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";
1515
1518
  }, {
1516
1519
  readonly name: "dog-advisor";
1517
- readonly version: "0.3.86-codegen-proof-v1";
1520
+ readonly version: "0.3.89-completion-proof-v1";
1518
1521
  readonly installPath: "agent/dog-advisor.md";
1519
1522
  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";
1520
1523
  }, {
1521
1524
  readonly name: "sortie";
1522
- readonly version: "0.3.86-codegen-proof-v1";
1525
+ readonly version: "0.3.89-completion-proof-v1";
1523
1526
  readonly installPath: "command/sortie.md";
1524
1527
  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";
1525
1528
  }];
@@ -1,5 +1,5 @@
1
1
  import { GOAL_DECLARATION_FORMAT } from "./core/goal-declaration-format.js";
2
- const ASSET_VERSION = "0.3.86-codegen-proof-v1";
2
+ const ASSET_VERSION = "0.3.89-completion-proof-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";
@@ -101,6 +101,8 @@ Validation budget exhaustion, host counters, local routing, and unavailable host
101
101
  defects, never TRUE_BLOCKER: external and never a reason to ask the user for an internal route. Return the
102
102
  typed defect to dog-coordinator for autonomous repair. A changed candidate may run the next declared
103
103
  validation; an unchanged duplicate remains forbidden.
104
+ Run the exact declared canonical validation string in its own tool call. Do not prepend or append
105
+ formatting, generation, or cleanup commands: host evidence must match the declared command boundary.
104
106
 
105
107
  Before returning canonical PASS, build a criterion-level trace for every accepted criterion. Each trace
106
108
  must name the criterion, the changed implementation path or inspected existing path, the concrete test
@@ -108,6 +110,11 @@ case/input form or static branch that exercises it, and PASS or UNPROVEN. A broa
108
110
  not prove every criterion. Split criteria that cover multiple syntax forms, value shapes, scopes, or error
109
111
  paths into representative paths. If any accepted edge remains UNPROVEN, add a manifest-authorized check or
110
112
  return the evidence gap; never report completion from aggregate validation alone.
113
+ Derive expected behavior from the accepted contract and existing public semantics, not the candidate.
114
+ Only when an accepted criterion covers failure behavior, check the public return/result, error, and
115
+ observable state together, paired with a valid case. For an accepted composite or wrapped-value feature,
116
+ exercise its public entry point as well as its helper. Mark inapplicable dimensions N/A with a short reason;
117
+ do not invent failure behavior, widen acceptance, or repeat a proved check to satisfy this guidance.
111
118
  Treat generated-source boundaries as high risk. If a manifest changes a generator input, grammar, schema,
112
119
  template, or a checked-in generated output, identify the repository's canonical generator and run it before
113
120
  the final validation. Prove the regenerated output is stable and that canonical validation ran against that
@@ -1613,6 +1620,9 @@ criterion -> changed or inspected implementation path -> concrete exercising tes
1613
1620
  An aggregate validation command without that mapping is insufficient. Multi-form criteria must cover their
1614
1621
  materially distinct syntax, value-shape, scope, and error paths; any missing path remains UNPROVEN and requires
1615
1622
  continued implementation or validation. Include the complete trace in every high-risk SourceReview artifact.
1623
+ For accepted failure behavior, require result/error/state evidence and a valid case; for accepted composite
1624
+ or wrapped-value behavior, require public-entry-point evidence. Accept justified N/A dimensions and existing
1625
+ sufficient evidence without extra validation or review. Do not accept expectations copied from the candidate's output.
1616
1626
  Any generated input/output pair in the changed manifest is high risk and requires SourceReview. DONE requires
1617
1627
  generator command evidence, post-generation candidate identity, generated-output stability, and canonical
1618
1628
  validation after generation. Reject evidence produced only before regeneration.
@@ -1821,6 +1831,9 @@ Also require each acceptance item to map to a concrete exercising test/input/bra
1821
1831
  suite PASS without criterion-level exercise evidence is insufficient. When one item contains materially
1822
1832
  different syntax forms, value shapes, scopes, or error paths, reject PASS unless representative traces cover
1823
1833
  each path or the artifact proves they share one implementation path.
1834
+ Check contract-derived expectations only for accepted behavior. For an applicable failure or composite-value
1835
+ criterion, error-only or helper-only assertions can leave public result/state behavior unproved. Accept justified
1836
+ N/A dimensions; do not demand new behavior, new review rounds, or redundant checks outside accepted scope.
1824
1837
  When changed files include generator inputs or checked-in generated outputs, require the canonical generator
1825
1838
  command, a stable post-generation diff, and validation executed after generation. Reject PASS if helper logic
1826
1839
  exists only in a generated output, regeneration removes behavior, or validation predates the generated candidate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",