sortie-dogs 0.12.13 → 0.12.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.12.13-mission-cold-recovery-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.15-review-lineage-model-gauges-v1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.12.13-mission-cold-recovery-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.15-review-lineage-model-gauges-v1";
@@ -46,6 +46,8 @@ export interface OperatorMission {
46
46
  verdict: "pending" | "PASS" | "findings" | "evidence-gaps" | "skipped-low-risk";
47
47
  result?: string;
48
48
  child?: string;
49
+ /** Completed, independent initial review for this mission, not merely an inherited child ID. */
50
+ initialPrompt?: string;
49
51
  /** Reviews on this mission that found only missing evidence; bounded by MISSION_EVIDENCE_GAP_REVIEW_LIMIT. */
50
52
  evidenceGapReviews?: number;
51
53
  };
@@ -358,6 +358,8 @@ export function missionPacket(mission, run) {
358
358
  ...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
359
359
  units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
360
360
  child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
361
+ handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
362
+ scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
361
363
  ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
362
364
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
363
365
  : mission.phase === "submitted" && mission.submission?.status === "ready"
@@ -340,6 +340,8 @@ export declare class OperatorRuntime {
340
340
  completionGoalFingerprint(state: OperatorState): Promise<string>;
341
341
  verifyContinuityControls(state: OperatorState): Promise<void>;
342
342
  packet(state: OperatorState): unknown;
343
+ /** Reconstruct the admitted child's instructions without trusting a lossy conversation summary. */
344
+ workerContext(root: string, child: string): Promise<string | undefined>;
343
345
  continuationCheckpoint(root: string): Promise<string | undefined>;
344
346
  private verifyControls;
345
347
  /** Follow only the host's authorized parent-link repair; keep the durable control pin in sync. */
@@ -1919,6 +1919,26 @@ export class OperatorRuntime {
1919
1919
  throw new Error("operator-packet-too-large-no-evidence-truncated");
1920
1920
  return packet;
1921
1921
  }
1922
+ /** Reconstruct the admitted child's instructions without trusting a lossy conversation summary. */
1923
+ async workerContext(root, child) {
1924
+ const state = await this.read(root);
1925
+ if (!state || state.phase !== "running")
1926
+ return undefined;
1927
+ const unit = state.units.find(item => item.childSessionID === child && item.status === "running");
1928
+ if (!unit)
1929
+ return undefined;
1930
+ // Keep this prefix stable across reads/edits: changing counters belong in status/tool results.
1931
+ return `SORTIE_WORKER_CONTEXT\n${JSON.stringify({ root_session_id: root, run_id: state.runID,
1932
+ unit_id: unit.unit.id, task_id: /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1],
1933
+ project_root: this.projectRoot, handoff_path: unit.handoffPath, operation_manifest: unit.manifestPath,
1934
+ objective: unit.unit.objective, read: unit.unit.read, write: unit.unit.write,
1935
+ validation: unit.unit.validation, acceptance: state.acceptance,
1936
+ ...(unit.repairValidation === null ? {} : { validation_only: true }),
1937
+ })}\nThis is the durable assignment for this same Worker, including after compaction or reload. ` +
1938
+ "A missing path in a summary does not mean the handoff is missing. Read the retained handoff/manifest and inspect the actual diff and outputs before claiming no edits or replaying work. " +
1939
+ `Use ${this.profile.toolPrefix}operator_status for observed validation and remaining work; do not repeat successful checks for an unchanged candidate. ` +
1940
+ "This context is an assignment, not proof of execution or acceptance.";
1941
+ }
1922
1942
  async continuationCheckpoint(root) {
1923
1943
  const state = await this.read(root);
1924
1944
  const cancelledRemediation = state?.phase === "cancelled" &&
@@ -1,5 +1,6 @@
1
- /** Display-only native Task progress. Never prompts a model or changes execution ownership. */
2
1
  type Sink = (value: Record<string, unknown>) => Promise<void>;
3
2
  export declare function bindMissionProgress(root: string, sink: Sink): () => void;
4
3
  export declare function publishMissionProgress(root: string, value: Record<string, unknown>): Promise<void>;
4
+ /** Read durable transitions even when settlement ran in a different/reloaded plugin instance. */
5
+ export declare function missionProgressReader(directory: string, root: string, callID: string): () => Promise<Record<string, unknown> | undefined>;
5
6
  export {};
@@ -1,3 +1,7 @@
1
+ /** Display-only native Task progress. Never prompts a model or changes execution ownership. */
2
+ import { OperatorMissionRuntime } from "../core/operator-mission.js";
3
+ import { OperatorRuntime } from "../core/operator-runtime.js";
4
+ import { V010_RUNTIME_PROFILE } from "../core/runtime-profile.js";
1
5
  const sinks = new Map();
2
6
  export function bindMissionProgress(root, sink) {
3
7
  sinks.set(root, sink);
@@ -7,3 +11,27 @@ export function bindMissionProgress(root, sink) {
7
11
  export async function publishMissionProgress(root, value) {
8
12
  await sinks.get(root)?.(value).catch(() => undefined);
9
13
  }
14
+ /** Read durable transitions even when settlement ran in a different/reloaded plugin instance. */
15
+ export function missionProgressReader(directory, root, callID) {
16
+ const missions = new OperatorMissionRuntime(directory, V010_RUNTIME_PROFILE);
17
+ return async () => {
18
+ const mission = await missions.read(root);
19
+ if (!mission || mission.callID !== callID)
20
+ return undefined;
21
+ // OperatorRuntime caches its own writes; a display observer must read a fresh disk snapshot.
22
+ const run = await new OperatorRuntime(directory, V010_RUNTIME_PROFILE).read(root);
23
+ const units = run?.runID === mission.runID ? run.units : [];
24
+ const running = units.find(unit => unit.status === "running");
25
+ const last = mission.progress.at(-1);
26
+ const status = running ? "running" : last?.status ?? mission.phase;
27
+ const title = running?.unit.title ?? last?.title ?? mission.requirements[0]?.text ?? "Coordinator";
28
+ return { description: `🐾 ${status}: ${title}`, sortie_progress: {
29
+ mission_id: mission.id, phase: mission.phase, unit: running ? `${run.runID}/${running.unit.id}` : last?.unit ?? null,
30
+ title, status, child_session_id: running?.childSessionID ?? null,
31
+ completed_units: units.filter(unit => unit.status === "succeeded").length,
32
+ failed_units: units.filter(unit => unit.status === "failed").length,
33
+ historical_failed_attempts: mission.progress.filter(unit => unit.status === "failed").length,
34
+ accepted: mission.phase === "completed",
35
+ } };
36
+ };
37
+ }
@@ -1,6 +1,8 @@
1
1
  import type { OperatorState } from "../core/operator-runtime.js";
2
2
  import { type OperatorMission } from "../core/operator-mission.js";
3
3
  import { type RuntimeProfile } from "../core/runtime-profile.js";
4
+ /** A child ID alone cannot establish the initial phase after a restart or failed dispatch. */
5
+ export declare function initialMissionReviewPrompt(mission: OperatorMission, prompts: readonly string[]): string | undefined;
4
6
  /** Recover from native completion records, never from the pending review's inherited child ID. */
5
7
  export declare function completedMissionReviewPrompts(mission: OperatorMission | undefined, profile: RuntimeProfile, root: string, requestedPrompt: string, host: {
6
8
  get(id: string): Promise<unknown>;
@@ -12,11 +12,21 @@ import { normalizeManifestScope } from "../core/path.js";
12
12
  import { declaredArtifacts } from "./declared-artifacts.js";
13
13
  const exec = promisify(execFile);
14
14
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
15
+ /** A child ID alone cannot establish the initial phase after a restart or failed dispatch. */
16
+ export function initialMissionReviewPrompt(mission, prompts) {
17
+ return [mission.review?.initialPrompt, ...prompts].find(prompt => typeof prompt === "string" &&
18
+ prompt.startsWith(`candidate_id: ${mission.id}\n`) && /^review_phase: initial$/mu.test(prompt) &&
19
+ /^canonical_validation_exit: 0$/mu.test(prompt));
20
+ }
15
21
  /** Recover from native completion records, never from the pending review's inherited child ID. */
16
22
  export async function completedMissionReviewPrompts(mission, profile, root, requestedPrompt, host) {
17
23
  if (!mission || mission.root !== root || !mission.coordinator || ["completed", "cancelled"].includes(mission.phase) ||
18
24
  mission.review?.task?.prompt !== requestedPrompt)
19
25
  return [];
26
+ // The completion hook has already bound this prompt to a real independent Reviewer child.
27
+ // Avoid a second V2 history read when its page/list API is temporarily unavailable.
28
+ if (initialMissionReviewPrompt(mission, []))
29
+ return [mission.review.initialPrompt];
20
30
  const coordinator = await host.get(mission.coordinator);
21
31
  if (!record(coordinator) || coordinator.parentID !== root || canonicalAgent(profile, coordinator.agent) !== "dog-operator")
22
32
  return [];
@@ -14,7 +14,7 @@ import { normalizeCommand } from "./gate.js";
14
14
  import { normalizeRelativePath } from "../core/path.js";
15
15
  import { MISSION_EVIDENCE_GAP_REVIEW_LIMIT, OperatorMissionRuntime, missionPacket, missionPlan, missionReviewAccepted, missionReviewTask, missionReviewTraces, missionReviewVerdict } from "../core/operator-mission.js";
16
16
  import { publishMissionProgress } from "./mission-progress.js";
17
- import { completedMissionReviewPrompts, missionReviewSource } from "./mission-review.js";
17
+ import { completedMissionReviewPrompts, initialMissionReviewPrompt, missionReviewSource } from "./mission-review.js";
18
18
  import { missionLocations, missionLocationPacket } from "./mission-location.js";
19
19
  import { SOURCE_REVIEW_RISK_TAGS } from "../core/consultation.js";
20
20
  import { createHash } from "node:crypto";
@@ -184,6 +184,10 @@ export function createProfiledPlugin(profile, assetVersion) {
184
184
  const result = payload(await session("messages", { path: { id }, query: { directory: input.directory } }));
185
185
  return Array.isArray(result) ? result.filter(record) : [];
186
186
  }
187
+ async function reviewMessages(id) {
188
+ const result = payload(await session(typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages", { path: { id }, query: { directory: input.directory } }));
189
+ return Array.isArray(result) ? result.filter(record) : [];
190
+ }
187
191
  async function identity(id) {
188
192
  const info = payload(await session("get", { path: { id }, query: { directory: input.directory } }));
189
193
  const parent = record(info) && typeof info.parentID === "string" ? info.parentID : undefined;
@@ -334,7 +338,7 @@ export function createProfiledPlugin(profile, assetVersion) {
334
338
  },
335
339
  completedReviewPrompts: async (root, prompt) => completedMissionReviewPrompts(await missions.read(root), profile, root, prompt, {
336
340
  get: async (id) => payload(await session("get", { path: { id }, query: { directory: input.directory } })),
337
- messages,
341
+ messages: reviewMessages,
338
342
  }),
339
343
  requiresExplicitAcceptance: async (root) => {
340
344
  const mission = await missions.read(root);
@@ -1418,7 +1422,17 @@ export function createProfiledPlugin(profile, assetVersion) {
1418
1422
  next_action: missionReviewAccepted(mission.review) ? "Review is already recorded for this unchanged candidate. Submit ready with any remaining gaps; do not repeat review."
1419
1423
  : "Review is already recorded for this unchanged candidate. Address its findings or supply new evidence through traces; do not repeat the same review." });
1420
1424
  }
1421
- const phase = mission.review?.child ? "verification" : "initial";
1425
+ // An old child can belong to a previous run, while the fast-lane is reset on a new
1426
+ // turn. Generate verification only when a completed initial review is recoverable;
1427
+ // otherwise generate an admissible initial review instead of trapping the Coordinator.
1428
+ const prior = mission.review?.task?.prompt;
1429
+ const completed = risk.length > 0 && prior && mission.review?.child && !mission.review.initialPrompt
1430
+ ? await completedMissionReviewPrompts(mission, profile, root, prior, {
1431
+ get: async (id) => payload(await session("get", { path: { id }, query: { directory: input.directory } })),
1432
+ messages: reviewMessages,
1433
+ }).catch(() => []) : [];
1434
+ const initialPrompt = initialMissionReviewPrompt(mission, completed);
1435
+ const phase = initialPrompt ? "verification" : "initial";
1422
1436
  const task = risk.length === 0 ? null : { subagent_type: profileAgent(profile, "dog-reviewer"),
1423
1437
  description: `🔎 ${run.units[0].unit.title}`, prompt: [
1424
1438
  `candidate_id: ${mission.id}`, `review_phase: ${phase}`, "canonical_validation_exit: 0", `risk_tags: [${risk.join(", ")}]`,
@@ -1434,6 +1448,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1434
1448
  const reviewed = await missions.update(root, item => {
1435
1449
  item.review = { runID: run.runID, risk: risk, source: source.fingerprint, requestFingerprint,
1436
1450
  task, verdict: task ? "pending" : "skipped-low-risk", ...(mission.review?.child ? { child: mission.review.child } : {}),
1451
+ ...(initialPrompt ? { initialPrompt } : {}),
1437
1452
  ...(mission.review?.evidenceGapReviews ? { evidenceGapReviews: mission.review.evidenceGapReviews } : {}) };
1438
1453
  });
1439
1454
  return JSON.stringify(task ? { status: "review-required", task: missionReviewTask(reviewed) } : { status: "skipped-low-risk" });
@@ -1982,6 +1997,9 @@ export function createProfiledPlugin(profile, assetVersion) {
1982
1997
  if (mission.review.verdict === "evidence-gaps")
1983
1998
  mission.review.evidenceGapReviews = (mission.review.evidenceGapReviews ?? 0) + 1;
1984
1999
  mission.review.child = child;
2000
+ if (independent && last && child && mission.review.task?.prompt.startsWith(`candidate_id: ${mission.id}\nreview_phase: initial\n`)) {
2001
+ mission.review.initialPrompt = mission.review.task.prompt;
2002
+ }
1985
2003
  }
1986
2004
  });
1987
2005
  if (reviewed.review?.verdict === "evidence-gaps" && typeof output.output === "string") {
@@ -2112,6 +2130,11 @@ export function createProfiledPlugin(profile, assetVersion) {
2112
2130
  (output.system ??= []).push(`SORTIE_RUNTIME_PROFILE ${profile.id}; marker ${assetVersion}. ` +
2113
2131
  `Shared MkII protocol role names are logical: ${protocolMap}. Use only ${profile.toolPrefix} tools for this profile. ` +
2114
2132
  "Never rewrite user acceptance or evidence to rename protocol roles. Final acceptance belongs only to the root coordinator.");
2133
+ if ((await identity(request.sessionID)).role === "dog-worker") {
2134
+ const context = await operators.workerContext(root, request.sessionID);
2135
+ if (context)
2136
+ (output.system ??= []).push(context);
2137
+ }
2115
2138
  const proposal = await proposals.read(root);
2116
2139
  if (proposal?.phase !== "approved" && proposal?.proposal_session_id === request.sessionID) {
2117
2140
  // Only immutable identity and the frozen budget caps belong here. Consumed counters move with
@@ -2166,6 +2189,12 @@ export function createProfiledPlugin(profile, assetVersion) {
2166
2189
  const root = await rootFor(request.sessionID);
2167
2190
  if (!root)
2168
2191
  return;
2192
+ if ((await identity(request.sessionID)).role === "dog-worker") {
2193
+ const context = await operators.workerContext(root, request.sessionID);
2194
+ if (context)
2195
+ (output.context ??= []).push(context +
2196
+ " Preserve the actual edits, tool outcomes and next action in the summary; do not replace them with an empty-work claim.");
2197
+ }
2169
2198
  if ((await identity(request.sessionID)).role === "dog-operator") {
2170
2199
  const mission = await missions.read(root);
2171
2200
  if (mission?.coordinator === request.sessionID) {
@@ -206,8 +206,38 @@ export async function collectRunMetrics(client, rootSessionID, directory, now =
206
206
  for (const id of ids) {
207
207
  try {
208
208
  const messages = assistantMessages(await session.messages.call(session, { path: { id }, query: { directory } }));
209
- if (messages === undefined)
210
- return undefined;
209
+ if (messages === undefined) {
210
+ messagesComplete = false;
211
+ continue;
212
+ }
213
+ if (!hierarchyComplete && session.get !== undefined) {
214
+ // Some V2 plugin facades cannot list children. Recover recorded native dispatches
215
+ // during this same history pass, without rerunning work or discarding parent usage.
216
+ const recorded = new Set();
217
+ for (const message of messages)
218
+ for (const raw of Array.isArray(message.parts) ? message.parts : []) {
219
+ const part = record(raw), state = record(part?.state), metadata = record(state?.metadata);
220
+ if (part?.type !== "tool" || part.tool !== "task")
221
+ continue;
222
+ const input = record(state?.input);
223
+ for (const child of [metadata?.sessionId, metadata?.sessionID, input?.task_id]) {
224
+ if (typeof child === "string" && child && !visited.has(child))
225
+ recorded.add(child);
226
+ }
227
+ }
228
+ for (const child of recorded) {
229
+ if (ids.length >= MAX_SESSIONS)
230
+ break;
231
+ try {
232
+ const info = record(unwrap(await session.get.call(session, { path: { id: child }, query: { directory } })));
233
+ if (info?.parentID !== id)
234
+ continue;
235
+ visited.add(child);
236
+ ids.push(child);
237
+ }
238
+ catch { /* Keep this partial display; unobserved usage is never zero-filled. */ }
239
+ }
240
+ }
211
241
  const observed = observeDebriefSession(id, id === rootSessionID, messages, window === undefined ? undefined : { start: windowStart, end: windowEnd });
212
242
  observedSessions.push(observed);
213
243
  for (const message of messages) {
@@ -257,9 +287,9 @@ export async function collectRunMetrics(client, rootSessionID, directory, now =
257
287
  const modelKey = typeof provider === "string" && typeof model === "string" ? `${provider}/${model}` : "未分類";
258
288
  observed.models[modelKey] = tokens === undefined || observed.models[modelKey] === null ? null
259
289
  : (observed.models[modelKey] ?? 0) + tokens.total;
290
+ const modelUsage = observed.modelUsage[modelKey] ??= { tokens: 0, uncachedInputTokens: 0, cacheReadTokens: 0,
291
+ cacheWriteTokens: 0, cost: 0, costAvailable: true, estimatedCost: 0, pricedRequests: 0, unpricedRequests: 0 };
260
292
  if (tokens !== undefined) {
261
- const modelUsage = observed.modelUsage[modelKey] ?? { tokens: 0, uncachedInputTokens: 0, cacheReadTokens: 0,
262
- cacheWriteTokens: 0, cost: 0, costAvailable: true, estimatedCost: 0, pricedRequests: 0, unpricedRequests: 0 };
263
293
  modelUsage.tokens += tokens.total;
264
294
  modelUsage.uncachedInputTokens += tokens.input;
265
295
  modelUsage.cacheReadTokens += tokens.cacheRead;
@@ -289,29 +319,31 @@ export async function collectRunMetrics(client, rootSessionID, directory, now =
289
319
  role.cacheReadTokens += tokens.cacheRead;
290
320
  role.cacheWriteTokens += tokens.cacheWrite;
291
321
  }
292
- else
322
+ else {
293
323
  tokensAvailable = false;
324
+ modelUsage.unpricedRequests = (modelUsage.unpricedRequests ?? 0) + 1;
325
+ }
294
326
  const usageInfo = record(usage.value.info) ?? usage.value;
295
327
  const reportedCost = number(usageInfo.cost) ?? number(usage.value.cost);
296
328
  if (reportedCost === undefined) {
297
329
  costAvailable = false;
298
330
  role.costAvailable = false;
299
- if (tokens !== undefined)
300
- observed.modelUsage[modelKey].costAvailable = false;
331
+ modelUsage.costAvailable = false;
301
332
  }
302
333
  else {
303
334
  cost += reportedCost;
304
335
  role.cost += reportedCost;
305
- if (tokens !== undefined)
306
- observed.modelUsage[modelKey].cost += reportedCost;
336
+ modelUsage.cost += reportedCost;
307
337
  }
308
338
  }
309
339
  }
310
340
  }
311
341
  catch {
312
- return undefined;
342
+ messagesComplete = false;
313
343
  }
314
344
  }
345
+ if (observedSessions.length === 0)
346
+ return undefined;
315
347
  let created;
316
348
  if (session.get !== undefined) {
317
349
  try {
@@ -388,6 +420,10 @@ export function formatSortieResult(result, presentation = {}) {
388
420
  const estimatedCost = estimate === undefined ? "計測不可"
389
421
  : estimate.pricedRequests === 0 ? "未換算"
390
422
  : `$${estimate.usd.toFixed(4)}${estimate.unpricedRequests > 0 ? "(一部未換算)" : ""}`;
423
+ const measuredTokens = result.debrief?.mixCoverage?.observedTokens;
424
+ const tokenText = result.cost.total_tokens.availability === "unavailable" && measuredTokens !== undefined && measuredTokens > 0
425
+ ? `${measuredTokens.toLocaleString("ja-JP")} tokens(観測分)`
426
+ : metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`);
391
427
  const body = [
392
428
  "🐾 SORTIE DOGS — 帰還報告",
393
429
  result.result_id[0],
@@ -411,7 +447,7 @@ export function formatSortieResult(result, presentation = {}) {
411
447
  displayText(presentation.next),
412
448
  "",
413
449
  "🪙 COST / PACK",
414
- `使用量 ${metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`)}`,
450
+ `使用量 ${tokenText}`,
415
451
  `予測費用 ${estimatedCost} ※予測概算`,
416
452
  "",
417
453
  ...renderDebrief(result.debrief),
@@ -58,6 +58,11 @@ export interface Debrief {
58
58
  };
59
59
  readonly hostCostZero?: boolean;
60
60
  }[] | null;
61
+ /** Shares use observed tokens; incomplete history must not erase known model usage. */
62
+ readonly mixCoverage?: {
63
+ readonly complete: boolean;
64
+ readonly observedTokens: number;
65
+ };
61
66
  readonly estimatedCost?: {
62
67
  readonly usd: number;
63
68
  readonly pricedRequests: number;
@@ -157,18 +157,22 @@ export function buildDebrief(receipt, contract, observation, records) {
157
157
  (session.spans.length > 0 || Object.keys(session.models).length > 0));
158
158
  const counts = new Map(), totals = new Map();
159
159
  const modelUsage = new Map();
160
- let usageComplete = observation.complete;
160
+ let usageComplete = true;
161
161
  for (const session of sessions) {
162
162
  for (const [model, tokens] of Object.entries(session.models)) {
163
163
  if (tokens === null || model === "未分類")
164
164
  usageComplete = false;
165
- else {
166
- totals.set(model, (totals.get(model) ?? 0) + tokens);
167
- const observed = session.modelUsage?.[model];
165
+ const observed = session.modelUsage?.[model];
166
+ // A missing request leaves models[model] null. The independent accumulator still
167
+ // contains real usage from this model's other requests, including older snapshots.
168
+ const measured = tokens ?? observed?.tokens;
169
+ if (measured !== undefined) {
170
+ if (measured > 0)
171
+ totals.set(model, (totals.get(model) ?? 0) + measured);
168
172
  const aggregate = modelUsage.get(model) ?? { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
169
173
  cost: 0, costAvailable: true, estimatedCost: 0, pricedRequests: 0, unpricedRequests: 0,
170
174
  pricingComplete: true, complete: true };
171
- if (observed === undefined || observed.tokens !== tokens)
175
+ if (observed === undefined || observed.tokens !== measured)
172
176
  aggregate.complete = false;
173
177
  else {
174
178
  aggregate.uncachedInputTokens += observed.uncachedInputTokens;
@@ -284,13 +288,14 @@ export function buildDebrief(receipt, contract, observation, records) {
284
288
  session.mutations.some((edit) => edit.end > entry.at))).at(-1);
285
289
  const packComplete = observation.complete && sessions.every(session => session.root || session.timingComplete || Object.keys(session.models).length > 0);
286
290
  return { pack: packComplete ? [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([model, count]) => ({ model, count })) : null,
287
- mix: usageComplete && total > 0 ? [...totals].sort(([a], [b]) => a.localeCompare(b)).map(([model, tokens]) => {
291
+ mix: total > 0 ? [...totals].sort(([a], [b]) => a.localeCompare(b)).map(([model, tokens]) => {
288
292
  const usage = modelUsage.get(model);
289
293
  return { model, tokens, percent: tokens / total * 100,
290
294
  ...(usage?.complete ? { inputCache: { uncachedInputTokens: usage.uncachedInputTokens,
291
295
  cacheReadTokens: usage.cacheReadTokens, cacheWriteTokens: usage.cacheWriteTokens } } : {}),
292
296
  ...(usage?.complete && usage.costAvailable && tokens > 0 && usage.cost === 0 ? { hostCostZero: true } : {}) };
293
297
  }) : null,
298
+ mixCoverage: { complete: observation.complete && usageComplete, observedTokens: total },
294
299
  ...(pricing.complete && pricing.pricedRequests + pricing.unpricedRequests > 0 ? { estimatedCost: {
295
300
  usd: pricing.usd, pricedRequests: pricing.pricedRequests, unpricedRequests: pricing.unpricedRequests
296
301
  } } : {}),
@@ -337,13 +342,15 @@ export function renderDebrief(debrief) {
337
342
  : value === "WAIVED" ? "免除" : "未記録";
338
343
  const counts = new Map(packVisible.map((entry) => [entry.model, entry.count]));
339
344
  return [
340
- ...(mix === null ? ["モデル内訳 usage未取得"] : visible.map((entry) => {
345
+ `モデル内訳 ${mix === null ? "usage未取得" : debrief?.mixCoverage?.complete === false
346
+ ? "観測済みtoken比率(一部未取得)" : "token比率"}`,
347
+ ...visible.map((entry) => {
341
348
  const count = counts.get(entry.model);
342
349
  const input = entry.inputCache === undefined ? 0 : entry.inputCache.uncachedInputTokens +
343
350
  entry.inputCache.cacheReadTokens + entry.inputCache.cacheWriteTokens;
344
351
  const cache = entry.inputCache === undefined || input === 0 ? "未取得" : `${(entry.inputCache.cacheReadTokens / input * 100).toFixed(0)}%`;
345
352
  return `🐕 ${label(entry.model)} ${gauge(entry.percent)} ${entry.percent.toFixed(1)}% ${entry.tokens.toLocaleString("ja-JP")} tokens${count === undefined ? "" : ` ×${count}`} ↺${cache}${entry.hostCostZero ? "†" : ""}`;
346
- })),
353
+ }),
347
354
  ...(visible.some((entry) => entry.hostCostZero) ? [" †host費用0計上あり(無料・全体価格の評価ではありません)"] : []),
348
355
  `⚡ 実行重複率 ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
349
356
  ? `${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×`
@@ -1,3 +1,4 @@
1
- import type { SessionListInput } from "@opencode/client";
2
- /** V2's plugin context can omit session.list even though the public client exposes it. */
1
+ import type { SessionListInput, MessageListInput } from "@opencode/client";
3
2
  export declare function owningServiceSessionList(): (input: SessionListInput) => Promise<unknown>;
3
+ /** Read-only paginated history for reviews older than V2's bounded session.context window. */
4
+ export declare function owningServiceMessageList(): (input: MessageListInput) => Promise<unknown>;
@@ -1,23 +1,33 @@
1
1
  /** V2's plugin context can omit session.list even though the public client exposes it. */
2
- export function owningServiceSessionList() {
2
+ function owningServiceClient() {
3
3
  let pending;
4
- async function connect() {
5
- const [{ Service }, { OpenCode }] = await Promise.all([import("@opencode/client/service"), import("@opencode/client")]);
6
- // Discovery is read-only: never start, stop, or replace the user's server. A private server
7
- // must not silently read the shared server's database, even if both contain the same IDs.
8
- const endpoint = await Service.discover();
9
- if (!endpoint)
10
- throw new Error("v2-history-owning-service-unavailable");
11
- const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) });
12
- const info = await client.server.info({ signal: AbortSignal.timeout(10_000) });
13
- if (info.pid !== process.pid)
14
- throw new Error("v2-history-owning-service-unavailable");
15
- return client;
16
- }
17
- return async (input) => {
18
- const current = pending ??= connect();
19
- const client = await current.catch(error => { if (pending === current)
4
+ return async () => {
5
+ const current = pending ??= (async () => {
6
+ const [{ Service }, { OpenCode }] = await Promise.all([import("@opencode/client/service"), import("@opencode/client")]);
7
+ // Discovery is read-only: never start, stop, or replace the user's server. A private server
8
+ // must not silently read the shared server's database, even if both contain the same IDs.
9
+ const endpoint = await Service.discover();
10
+ if (!endpoint)
11
+ throw new Error("v2-history-owning-service-unavailable");
12
+ const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) });
13
+ const info = await client.server.info({ signal: AbortSignal.timeout(10_000) });
14
+ if (info.pid !== process.pid)
15
+ throw new Error("v2-history-owning-service-unavailable");
16
+ return client;
17
+ })();
18
+ return current.catch(error => { if (pending === current)
20
19
  pending = undefined; throw error; });
20
+ };
21
+ }
22
+ export function owningServiceSessionList() {
23
+ const connect = owningServiceClient();
24
+ return async (input) => {
25
+ const client = await connect();
21
26
  return client.session.list(input, { signal: AbortSignal.timeout(10_000) });
22
27
  };
23
28
  }
29
+ /** Read-only paginated history for reviews older than V2's bounded session.context window. */
30
+ export function owningServiceMessageList() {
31
+ const connect = owningServiceClient();
32
+ return async (input) => (await connect()).message.list(input, { signal: AbortSignal.timeout(10_000) });
33
+ }
package/dist/plugin/v2.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { SortieDogsV010Plugin } from "./profiled.js";
2
- import { bindMissionProgress } from "./mission-progress.js";
3
- import { owningServiceSessionList } from "./v2-session-history.js";
2
+ import { bindMissionProgress, missionProgressReader } from "./mission-progress.js";
3
+ import { owningServiceMessageList, owningServiceSessionList } from "./v2-session-history.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { V010_RUNTIME_ASSET_VERSION } from "../asset-version.js";
@@ -100,10 +100,11 @@ function legacyMessage(value, session, agent) {
100
100
  return converted === undefined ? [] : [converted];
101
101
  }) };
102
102
  }
103
- async function legacyMessages(context, id) {
103
+ async function legacyMessages(context, id, paginated) {
104
+ const list = paginated ?? (context.message ? (input) => context.message.list(input) : undefined);
104
105
  const [info, history] = await Promise.all([
105
106
  context.session.get({ sessionID: id }).catch(() => undefined),
106
- context.message ? nativePages(cursor => context.message.list({ sessionID: id, limit: 100, ...(cursor ? { cursor } : { order: "asc" }) }))
107
+ list ? nativePages(cursor => list({ sessionID: id, limit: 100, ...(cursor ? { cursor } : { order: "asc" }) }))
107
108
  : context.session.context({ sessionID: id }),
108
109
  ]);
109
110
  const agent = record(info) ? string(info.agent) : undefined;
@@ -133,6 +134,7 @@ async function nativePages(fetch) {
133
134
  function legacyClient(context) {
134
135
  const directory = context.location.directory;
135
136
  const list = owningServiceSessionList();
137
+ const reviewList = owningServiceMessageList();
136
138
  const session = {
137
139
  get: async (request) => {
138
140
  const id = sessionID(request);
@@ -142,6 +144,10 @@ function legacyClient(context) {
142
144
  const id = sessionID(request);
143
145
  return { data: id === undefined ? [] : await legacyMessages(context, id) };
144
146
  },
147
+ reviewMessages: async (request) => {
148
+ const id = sessionID(request);
149
+ return { data: id === undefined ? [] : await legacyMessages(context, id, context.message ? input => context.message.list(input) : reviewList) };
150
+ },
145
151
  message: async (request) => {
146
152
  const id = sessionID(request), wanted = messageID(request);
147
153
  return { data: id === undefined || wanted === undefined ? undefined
@@ -332,18 +338,50 @@ async function registerV2Hooks(context, hooks) {
332
338
  return execute(value, execution);
333
339
  const report = execution.progress;
334
340
  let metadata = {};
335
- const unbind = bindMissionProgress(String(execution.sessionID), async (progress) => {
336
- metadata = { ...metadata, ...progress };
337
- await report(metadata);
338
- });
341
+ let pending = Promise.resolve();
342
+ const update = (update) => {
343
+ metadata = { ...metadata, ...update };
344
+ const snapshot = { ...metadata };
345
+ pending = pending.catch(() => undefined).then(() => report(snapshot));
346
+ return pending;
347
+ };
348
+ const unbind = bindMissionProgress(String(execution.sessionID), update);
349
+ const readProgress = missionProgressReader(context.location.directory, String(execution.sessionID), String(execution.id));
350
+ let previous = "", reading;
351
+ const refresh = () => {
352
+ if (reading)
353
+ return reading;
354
+ reading = (async () => {
355
+ try {
356
+ const progress = await readProgress(), key = JSON.stringify(progress);
357
+ if (progress && key !== previous) {
358
+ await update(progress);
359
+ previous = key;
360
+ }
361
+ }
362
+ catch (error) {
363
+ console.warn("[sortie-dogs-v010] mission progress unavailable", error instanceof Error ? error.message : "unknown");
364
+ }
365
+ })().finally(() => { reading = undefined; });
366
+ return reading;
367
+ };
368
+ // The native executor is a stable snapshot. Poll only local durable state, not models or
369
+ // session status APIs; this also recovers progress after another plugin instance settles work.
370
+ const timer = setInterval(() => { void refresh(); }, 1000);
371
+ timer.unref();
339
372
  try {
340
- return await execute(value, { ...execution, progress: async (update) => {
341
- metadata = { ...metadata, ...update };
342
- await report(metadata);
343
- } });
373
+ await refresh();
374
+ const result = await execute(value, { ...execution, progress: update });
375
+ await refresh();
376
+ await pending.catch(() => undefined);
377
+ // Native completion replaces metadata, so retain our display fields alongside its own.
378
+ return { ...result, metadata: { ...metadata, ...(record(result.metadata) ? result.metadata : {}) } };
344
379
  }
345
380
  finally {
381
+ clearInterval(timer);
346
382
  unbind();
383
+ await reading;
384
+ await pending.catch(() => undefined);
347
385
  }
348
386
  };
349
387
  });
@@ -117,6 +117,11 @@ You are Coordinator. Own most of the practical work and dispatch within the save
117
117
  Read/search and confirmation shell commands are available; source edits belong to Worker. Do not edit
118
118
  through shell. There is no proposal/approval/contract-repair round trip in this route.
119
119
 
120
+ For release/benchmark work, resolve the package from its release receipt and hash, not a matching version
121
+ or filename. Record the runner's own revision separately. Before repairing infrastructure on an old branch,
122
+ check current main for an existing fix; preserve local edits and use a current-main worktree when needed.
123
+ Report setup/route failures as such, with observed inference count, instead of calling runner exits a score.
124
+
120
125
  Investigate only enough to start the first useful Worker. Prefer a targeted read/reproduction over a broad
121
126
  inventory or speculative full design. Call ${profile.toolPrefix}plan_units with concise units:
122
127
  title, objective, read/write file or directory scopes, validation commands, optionally requirement_ids.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",