sortie-dogs 0.9.11 → 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
@@ -22,7 +22,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
22
22
 
23
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.11](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.11)
25
+ Release: [v0.9.12](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.12)
26
26
 
27
27
  ## Provisional quality–cost position
28
28
 
@@ -74,6 +74,15 @@ models are reserved for implementation, escalation, and independent review.
74
74
  Writes stay scoped, and completion requires validation evidence. Every completed
75
75
  run can return a concise Speed / Cost / Proof debrief.
76
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
+
77
86
  ## Designed to coexist with OpenCode
78
87
 
79
88
  Sortie-dogs adds a workflow to your existing setup rather than replacing it.
@@ -299,6 +308,18 @@ dog-coordinator: completion evidence accepted
299
308
  progress; repeated batches remain bounded rather than becoming endless
300
309
  delegation.
301
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
+
302
323
  ## A visual walkthrough
303
324
 
304
325
  ### Control complexity
@@ -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
  }
@@ -2184,57 +2227,67 @@ export const SortieDogsPlugin = async (input, options) => {
2184
2227
  }
2185
2228
  const key = `${sourceSessionID}\u0000${reason}`;
2186
2229
  const existing = freshSessionRedispatches.get(key);
2230
+ const queued = { status: "redispatch-queued", reason,
2231
+ source_session_id: sourceSessionID, retry_same_session: false };
2187
2232
  if (existing !== undefined)
2188
- return await existing.operation;
2189
- const operation = (async () => {
2190
- let targetSessionID;
2191
- try {
2192
- const created = await create.call(input.client.session, {
2193
- query: { directory: input.worktree ?? input.directory },
2194
- body: {},
2195
- });
2196
- const payload = isRecord(created) && "data" in created ? created.data : created;
2197
- if (!isRecord(payload) || typeof payload.id !== "string" || payload.id.length === 0) {
2198
- return freshSessionFallback(reason, fallbackAction);
2199
- }
2200
- targetSessionID = payload.id;
2201
- const parentID = typeof payload.parentID === "string" ? payload.parentID
2202
- : typeof payload.parentId === "string" ? payload.parentId
2203
- : undefined;
2204
- if (parentID !== undefined) {
2205
- await deleteFreshSession(targetSessionID);
2206
- return freshSessionFallback(reason, fallbackAction);
2207
- }
2208
- goalRootSessions.set(targetSessionID, goalRoot(sourceSessionID));
2209
- const ticket = await issueGoalTicket(targetSessionID, `fresh-root:${reason}`);
2210
- if (!ticket.issued)
2211
- throw new Error("fresh coordinator ticket already outstanding");
2212
- const sent = await send.call(input.client.session, {
2213
- path: { id: targetSessionID },
2214
- query: { directory: input.worktree ?? input.directory },
2215
- body: { agent: COORDINATOR_AGENT, parts: prompt.map((part) => ({ ...part, synthetic: true,
2216
- metadata: ticket.metadata })) },
2217
- });
2218
- if (!promptAccepted(sent))
2219
- throw new Error("fresh coordinator prompt rejected");
2220
- appLogInfo("fresh-session.redispatched", sourceSessionID, {
2221
- reason,
2222
- targetSessionID: targetSessionID.slice(0, 128),
2223
- });
2224
- return {
2225
- status: "redispatched",
2226
- reason,
2227
- source_session_id: sourceSessionID,
2228
- target_session_id: targetSessionID,
2229
- retry_same_session: false,
2230
- };
2231
- }
2232
- catch {
2233
- if (targetSessionID !== undefined)
2234
- await deleteFreshSession(targetSessionID);
2235
- return freshSessionFallback(reason, fallbackAction);
2236
- }
2237
- })();
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
+ });
2238
2291
  const entry = { operation, settled: false };
2239
2292
  freshSessionRedispatches.set(key, entry);
2240
2293
  void operation.then((result) => {
@@ -2257,7 +2310,7 @@ export const SortieDogsPlugin = async (input, options) => {
2257
2310
  break;
2258
2311
  freshSessionRedispatches.delete(completed[0]);
2259
2312
  }
2260
- return await operation;
2313
+ return queued;
2261
2314
  }
2262
2315
  async function ensureLoaded() {
2263
2316
  if (loaded?.gate !== undefined)
@@ -3428,9 +3481,6 @@ export const SortieDogsPlugin = async (input, options) => {
3428
3481
  contract_fingerprint: structuralAdmission.contract_fingerprint, experience: experience.trace });
3429
3482
  }
3430
3483
  const coordinator = await getParallelCoordinator();
3431
- if (await coordinator.targetCheckedOut(`refs/heads/${structuralAdmission.contract.provenance.target_branch}`)) {
3432
- return JSON.stringify({ status: "sol-serial", reason: "target-checked-out", experience: experience.trace });
3433
- }
3434
3484
  const result = await coordinator.prepareFabric(contract, ownerRoot, executionPlanPath === undefined ? undefined : await readJson(resolve(executionPlanPath), INPUT_LIMITS.parallel));
3435
3485
  if (result.status === "sol-serial")
3436
3486
  return JSON.stringify({ ...result, experience: experience.trace });
@@ -5020,15 +5070,19 @@ export const SortieDogsPlugin = async (input, options) => {
5020
5070
  const agent = info?.agent ?? message.agent;
5021
5071
  if (typeof agent !== "string")
5022
5072
  return undefined;
5023
- if (message.parts !== undefined && !Array.isArray(message.parts))
5073
+ const parts = message.parts === undefined ? [] : message.parts;
5074
+ if (!Array.isArray(parts))
5024
5075
  return undefined;
5025
- const synthetic = Array.isArray(message.parts) && message.parts.some((part) => isRecord(part) && part.synthetic === true);
5076
+ const synthetic = syntheticPrompt(parts);
5026
5077
  if (synthetic)
5027
5078
  continue;
5079
+ const persistedParts = parts.length === 0 ? [] : freshSessionPrompt(parts);
5080
+ if (persistedParts === undefined)
5081
+ return undefined;
5028
5082
  const messageID = info?.id ?? message.id;
5029
5083
  if (typeof messageID !== "string" || messageID.length === 0)
5030
5084
  return undefined;
5031
- persistedTurn = { agent, synthetic: false, messageID };
5085
+ persistedTurn = { agent, synthetic: false, messageID, parts: persistedParts };
5032
5086
  break;
5033
5087
  }
5034
5088
  return { hasForeignUserTurn: persistedTurn?.agent !== undefined && persistedTurn.agent !== COORDINATOR_AGENT,
@@ -5124,7 +5178,7 @@ export const SortieDogsPlugin = async (input, options) => {
5124
5178
  return false;
5125
5179
  await rememberCoordinatorRoot(sessionID);
5126
5180
  if (persistedTurn !== undefined) {
5127
- await acceptRealGoalTurn(sessionID, persistedTurn.messageID, persistedTurn.agent, []);
5181
+ await acceptRealGoalTurn(sessionID, persistedTurn.messageID, persistedTurn.agent, persistedTurn.parts);
5128
5182
  goalDeclarationAuthority.set(sessionID, persistedTurn.messageID);
5129
5183
  }
5130
5184
  await pinAssetVersion(sessionID);
@@ -5563,16 +5617,23 @@ export const SortieDogsPlugin = async (input, options) => {
5563
5617
  .replaceAll(CONTINUATION_MARKER, "")
5564
5618
  .trimEnd();
5565
5619
  }
5620
+ const hostRunOutcome = terminalRunOutcome(textOutput.text);
5566
5621
  textOutput.text = await preserveActiveGoalContinuation(textInput.sessionID, textOutput.text, textInput.messageID);
5567
- 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);
5568
5625
  const terminal = runOutcome === undefined || !isCoordinatorSession(textInput.sessionID)
5569
5626
  ? undefined
5570
5627
  : await terminalGoalFromHostText(textInput.sessionID, textOutput.text);
5571
- if (runOutcome === "DONE" && terminal !== undefined && (terminal.delivery === "running" ||
5572
- (terminal.receipt === undefined && terminal.goal !== undefined && terminal.goal.goal_id !== null))) {
5573
- textOutput.text = replaceDoneTerminalStatus(textOutput.text, terminal.delivery === "running"
5574
- ? "status: IN_PROGRESS — durable delivery active; same sessionでjoinまたはstale reconcileが必要"
5575
- : "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);
5576
5637
  }
5577
5638
  if (runOutcome !== "DONE" && terminal?.delivery === "ready" && terminal.receipt?.status === "succeeded") {
5578
5639
  textOutput.text = replaceTerminalStatus(textOutput.text, "status: DONE");
@@ -5666,7 +5727,7 @@ export const SortieDogsPlugin = async (input, options) => {
5666
5727
  observedChildTerminals.delete(chatInput.sessionID);
5667
5728
  await serializeChatTransition(chatInput.sessionID, async () => {
5668
5729
  const parentID = chatParentID(chatInput);
5669
- const synthetic = output.parts.some((part) => isRecord(part) && part.synthetic === true);
5730
+ const synthetic = syntheticPrompt(output.parts);
5670
5731
  const selectedAgent = chatInput.agent ?? output.message.agent;
5671
5732
  if (parentID !== undefined)
5672
5733
  rememberParent(chatInput.sessionID, parentID);
@@ -5676,8 +5737,15 @@ export const SortieDogsPlugin = async (input, options) => {
5676
5737
  output.message.agent = chatInput.agent;
5677
5738
  }
5678
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
+ }
5679
5746
  const messageID = realMessageID(chatInput, output) ?? (synthetic ? undefined
5680
- : await persistedCurrentRealMessageID(chatInput.sessionID, selectedAgent, output.parts));
5747
+ : projectedCoordinatorParts === undefined ? undefined
5748
+ : await persistedCurrentRealMessageID(chatInput.sessionID, selectedAgent, projectedCoordinatorParts));
5681
5749
  if (requestedCoordinator && !coordinatorRoot && (parentID !== undefined || knownChildSessions.has(chatInput.sessionID)) &&
5682
5750
  !synthetic && messageID !== undefined) {
5683
5751
  await acceptRealGoalTurn(chatInput.sessionID, messageID, selectedAgent, output.parts);
@@ -5724,11 +5792,11 @@ export const SortieDogsPlugin = async (input, options) => {
5724
5792
  // Some native hosts persist the user message only after this hook returns. Defer to the
5725
5793
  // system-transform boundary, but retain no synthetic authority and accept only the exact
5726
5794
  // final persisted real-user parts through persistedCurrentRealMessageID.
5727
- pendingRealGoalTurns.set(chatInput.sessionID, { selectedAgent, parts: [...output.parts] });
5795
+ pendingRealGoalTurns.set(chatInput.sessionID, { selectedAgent, parts: projectedCoordinatorParts ?? [] });
5728
5796
  pruneParallelChildMap(pendingRealGoalTurns);
5729
5797
  schedulePendingRealGoalRecovery(chatInput.sessionID);
5730
5798
  }
5731
- const prompt = synthetic ? undefined : freshSessionPrompt(output.parts);
5799
+ const prompt = projectedCoordinatorParts;
5732
5800
  if (prompt !== undefined)
5733
5801
  coordinatorPrompts.set(chatInput.sessionID, prompt);
5734
5802
  // Synthetic coordinator turns reach here only after consumeGoalTicket accepted the
@@ -6679,14 +6747,19 @@ export const SortieDogsPlugin = async (input, options) => {
6679
6747
  return;
6680
6748
  // Deletion is terminal for watchdog recovery. Disarm synchronously before any generic event
6681
6749
  // processing can await, touch activity, or let a queued sweep recover the cancelled root.
6682
- if (event.type === "session.deleted")
6750
+ if (event.type === "session.deleted") {
6683
6751
  disarmDeletedCoordinatorTaskWatchdog(eventSessionID);
6752
+ await continuation.stopAutomaticRecovery(eventSessionID, false);
6753
+ }
6684
6754
  if (event.type === "message.updated" && info !== undefined) {
6685
6755
  rememberCoordinatorInterruption(eventSessionID, info);
6686
- await acceptPersistedRealGoalEvent(eventSessionID, info);
6756
+ if (pendingRealGoalTurns.has(eventSessionID))
6757
+ await recoverPendingRealGoalTurn(eventSessionID);
6758
+ else
6759
+ await acceptPersistedRealGoalEvent(eventSessionID, info);
6687
6760
  }
6688
6761
  if (pendingRealGoalTurns.has(eventSessionID) &&
6689
- (event.type === "message.updated" || event.type === "message.part.updated")) {
6762
+ event.type === "message.part.updated") {
6690
6763
  await recoverPendingRealGoalTurn(eventSessionID);
6691
6764
  }
6692
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",