sortie-dogs 0.9.4 → 0.9.5

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
@@ -24,7 +24,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
24
24
 
25
25
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
26
26
 
27
- Release: [v0.9.4](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.4)
27
+ Release: [v0.9.5](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.5)
28
28
 
29
29
  ## Quick start
30
30
 
@@ -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.80-review-evidence-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.81-mission-debrief-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.80-review-evidence-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.81-mission-debrief-v1";
@@ -1,4 +1,5 @@
1
1
  import type { ValidationOutcome } from "./validation-budget.js";
2
+ import type { GoalReport } from "./goal-report.js";
2
3
  export declare const GOAL_BOUND_SCHEMA_VERSION: "0.1";
3
4
  export declare const GOAL_BOUND_METADATA_KEY: "sortie-dogs.goal-bound/v1";
4
5
  export type GoalDeliveryMode = "planning-only" | "mvp-first" | "repair-first" | "controlled-change";
@@ -177,6 +178,10 @@ export type GoalFlightEvent = (GoalEventBase & {
177
178
  readonly kind: "goal.terminal";
178
179
  readonly goal_id: string;
179
180
  readonly receipt: GoalTerminalReceipt;
181
+ }) | (GoalEventBase & {
182
+ readonly kind: "goal.reported";
183
+ readonly goal_id: string;
184
+ readonly report: GoalReport;
180
185
  });
181
186
  export interface GoalFlightEventRecord {
182
187
  readonly sequence: number;
@@ -111,6 +111,10 @@ export function reduceGoalFlight(records) {
111
111
  requireState(record.sequence === index + 1 && record.previous_hash === previous && HASH.test(record.event_hash), "invalid", "Goal ledger chain is malformed.");
112
112
  previous = record.event_hash;
113
113
  const event = record.event;
114
+ // Optional presentation records never participate in execution replay. Writers validate them;
115
+ // older/newer telemetry definitions must not prevent an existing goal from resuming.
116
+ if (event.kind === "goal.reported")
117
+ continue;
114
118
  requireState(instant(event.at), "invalid", "Goal event timestamp is invalid.");
115
119
  if (event.kind === "goal.accepted") {
116
120
  requireState(state.goal_id === null || state.phase === "terminal", "transition", "An active goal already owns this root.");
@@ -134,13 +138,16 @@ export function reduceGoalFlight(records) {
134
138
  else if (event.kind === "goal.revised") {
135
139
  requireState(state.phase !== "terminal" && event.revision === state.revision + 1 && event.scope_epoch === state.scope_epoch + 1 &&
136
140
  HASH.test(event.acceptance_fingerprint) && event.budget.max_units >= state.consumed_units &&
141
+ event.budget.max_units >= state.validation_budget.consumed &&
137
142
  validAcceptanceContract(event.acceptance_contract), "transition", "Scope revision is stale or resets consumed budget.");
138
143
  state = { ...state, revision: event.revision, scope_epoch: event.scope_epoch,
139
144
  acceptance_fingerprint: event.acceptance_fingerprint, latest_user_message_id: event.origin_user_message_id,
140
145
  selected_agent: event.selected_agent, delivery: event.delivery, budget: event.budget,
146
+ validation_budget: { ...state.validation_budget,
147
+ limit: state.validation_budget.limit === null ? null : event.budget.max_units },
141
148
  acceptance_contract: event.acceptance_contract,
142
149
  session_ids: addUnique(state.session_ids, event.session_id), phase: "active", stop_reason: null,
143
- tickets: [] };
150
+ receipt: null, tickets: [] };
144
151
  }
145
152
  else if (event.kind === "ticket.issued") {
146
153
  requireState(state.phase === "active" && event.revision === state.revision && event.scope_epoch === state.scope_epoch &&
@@ -0,0 +1,18 @@
1
+ /** Optional, bounded telemetry retained with the existing terminal goal ledger, never an authority input. */
2
+ export interface GoalReport {
3
+ readonly definition: "pre-terminal-host-tokens/v1";
4
+ readonly terminal_key: string;
5
+ readonly tokens: number | null;
6
+ readonly models: readonly {
7
+ readonly model: string;
8
+ readonly tokens: number;
9
+ }[] | null;
10
+ readonly first_pass_eligible: boolean;
11
+ readonly traits: readonly ("pack-tactics" | "recovery" | "clean-sweep")[];
12
+ readonly overlap?: {
13
+ readonly definition: "worker-span-union/v1";
14
+ readonly worker_ms: number;
15
+ readonly wall_ms: number;
16
+ };
17
+ }
18
+ export declare function validGoalReport(value: unknown): value is GoalReport;
@@ -0,0 +1,21 @@
1
+ export function validGoalReport(value) {
2
+ if (value === null || typeof value !== "object")
3
+ return false;
4
+ const item = value;
5
+ const amount = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
6
+ const duration = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
7
+ const overlap = item.overlap;
8
+ return Object.keys(item).every((key) => ["definition", "terminal_key", "tokens", "models", "first_pass_eligible", "traits", "overlap"].includes(key)) &&
9
+ item.definition === "pre-terminal-host-tokens/v1" && /^sha256:[a-f0-9]{64}$/u.test(item.terminal_key) &&
10
+ (item.tokens === null || amount(item.tokens)) && typeof item.first_pass_eligible === "boolean" &&
11
+ Array.isArray(item.traits) && item.traits.length <= 3 && new Set(item.traits).size === item.traits.length &&
12
+ item.traits.every((trait) => ["pack-tactics", "recovery", "clean-sweep"].includes(trait)) &&
13
+ (overlap === undefined || overlap !== null && typeof overlap === "object" &&
14
+ Object.keys(overlap).every((key) => ["definition", "worker_ms", "wall_ms"].includes(key)) && overlap.definition === "worker-span-union/v1" &&
15
+ duration(overlap.worker_ms) && duration(overlap.wall_ms) && overlap.worker_ms >= overlap.wall_ms) &&
16
+ (item.models === null || Array.isArray(item.models) && item.models.length <= 128 &&
17
+ new Set(item.models.map((entry) => entry?.model)).size === item.models.length &&
18
+ item.models.every((entry) => entry !== null && typeof entry === "object" && Object.keys(entry).every((key) => key === "model" || key === "tokens") &&
19
+ typeof entry.model === "string" && entry.model.length > 0 && entry.model.length <= 512 && amount(entry.tokens)) &&
20
+ item.tokens !== null && item.models.reduce((sum, entry) => sum + entry.tokens, 0) === item.tokens);
21
+ }
@@ -366,6 +366,11 @@ export declare class RunFlightLedger {
366
366
  static open(filePath: string, evidence: RunFlightEvidenceAccess): Promise<RunFlightLedger>;
367
367
  /** Root goal checkpoints share this owner and lock/write discipline without inventing a Git run. */
368
368
  static openGoal(filePath: string): Promise<RunFlightLedger>;
369
+ /** Read-only Career inventory: validates one existing goal ledger without opening another store. */
370
+ static readGoalFile(filePath: string): Promise<{
371
+ readonly records: readonly GoalFlightEventRecord[];
372
+ readonly state: GoalFlightState;
373
+ }>;
369
374
  read(): Promise<{
370
375
  readonly records: readonly RunFlightEventRecord[];
371
376
  readonly state: RunFlightState;
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { LUNA_FABRIC_MAX_ACTIVE } from "./luna-fabric-scheduler.js";
5
5
  import { normalizeRelativePath } from "./path.js";
6
6
  import { CHILD_TERMINAL_EVIDENCE_FIELDS, isChildTerminalIdentity, reconcileChildTerminal, sameChildTerminalIdentity } from "./child-terminal-reconciliation.js";
7
- import { GOAL_BOUND_SCHEMA_VERSION, GoalBoundError, reduceGoalFlight } from "./goal-bound.js";
7
+ import { GOAL_BOUND_SCHEMA_VERSION, GoalBoundError, reduceGoalFlight, goalFingerprint } from "./goal-bound.js";
8
8
  export const RUN_FLIGHT_LEDGER_SCHEMA_VERSION = "0.1";
9
9
  export const MAX_RUN_FLIGHT_EVENTS = 2048;
10
10
  export const MAX_RUN_FLIGHT_LEDGER_BYTES = 1024 * 1024;
@@ -657,6 +657,10 @@ export class RunFlightLedger {
657
657
  await ledger.readGoal();
658
658
  return ledger;
659
659
  }
660
+ /** Read-only Career inventory: validates one existing goal ledger without opening another store. */
661
+ static async readGoalFile(filePath) {
662
+ return new RunFlightLedger(filePath, undefined, true).readGoal();
663
+ }
660
664
  async read() {
661
665
  if (this.#goalMode)
662
666
  throw new RunFlightLedgerError("invalid", "Goal ledger requires readGoal().");
@@ -820,6 +824,18 @@ export class RunFlightLedger {
820
824
  throw new RunFlightLedgerError("conflict", "Ledger lock remained busy.");
821
825
  try {
822
826
  const records = await this.#readGoalRecords();
827
+ if (event.kind === "goal.reported" && records.some(({ event: stored }) => stored.kind === "goal.reported" &&
828
+ stored.goal_id === event.goal_id && stored.report?.terminal_key === event.report.terminal_key)) {
829
+ return reduceGoalFlight(records);
830
+ }
831
+ if (event.kind === "goal.reported") {
832
+ const { validGoalReport } = await import("./goal-report.js");
833
+ const state = reduceGoalFlight(records);
834
+ if (state.receipt === null || event.goal_id !== state.goal_id || !validGoalReport(event.report) ||
835
+ event.report.terminal_key !== goalFingerprint(state.receipt)) {
836
+ throw new RunFlightLedgerError("invalid", "Report is not bound to the current terminal receipt.");
837
+ }
838
+ }
823
839
  const sequence = records.length + 1;
824
840
  const previousHash = records.at(-1)?.event_hash ?? null;
825
841
  const record = { sequence, previous_hash: previousHash,
@@ -45,7 +45,7 @@ import { TerminalRescueRuntime } from "../core/terminal-rescue-runtime.js";
45
45
  import { OpenCodeTerminalRescueHost } from "./terminal-rescue-host.js";
46
46
  import { AdaptiveRemediationRuntime, AdaptiveRunFlightLineage } from "../core/adaptive-remediation-runtime.js";
47
47
  import { DEFAULT_ADAPTIVE_REMEDIATION_MODEL, GitAdaptiveRemediationHost, OpenCodeAdaptiveRemediationProvider } from "./adaptive-remediation-host.js";
48
- import { collectRunMetrics, createSortieResult, insertRunMetrics, insertSortieResult, replaceDoneTerminalStatus, replaceTerminalStatus, sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
48
+ import { collectRunMetrics, createSortieResult, createGoalReport, insertRunMetrics, insertSortieResult, replaceDoneTerminalStatus, replaceTerminalStatus, sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
49
49
  const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
50
50
  const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
51
51
  const ACTIVE_SESSION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
@@ -1127,15 +1127,18 @@ export const SortieDogsPlugin = async (input, options) => {
1127
1127
  const parallelAcceptanceContinuity = new Map();
1128
1128
  const goalRootSessions = new Map();
1129
1129
  const goalLedgers = new Map();
1130
+ const goalLedgerFiles = new Map();
1131
+ const goalLedgerDirectories = new Set();
1130
1132
  const goalReservations = new Map();
1131
1133
  const hostGoalExecutions = new Map();
1134
+ const goalValidationDefects = new Set();
1132
1135
  const goalDeclarationAuthority = new Map();
1133
1136
  const pendingRealGoalTurns = new Map();
1134
1137
  const pendingGoalRecoveries = new Map();
1135
1138
  const scheduledGoalRecoveries = new Map();
1136
1139
  const globalConfig = await readOptionalGlobalConfig();
1137
1140
  const sessionOperationMetrics = new Map();
1138
- function appLogInfo(message, sessionID, extra) {
1141
+ function appLogInfo(message, sessionID, extra, level = "info") {
1139
1142
  const app = input.client?.app;
1140
1143
  const log = app?.log;
1141
1144
  if (log === undefined)
@@ -1144,7 +1147,7 @@ export const SortieDogsPlugin = async (input, options) => {
1144
1147
  const result = log.call(app, {
1145
1148
  body: {
1146
1149
  service: "sortie-dogs",
1147
- level: "info",
1150
+ level,
1148
1151
  message,
1149
1152
  extra: { sessionID: sessionID.slice(0, 128), ...extra },
1150
1153
  },
@@ -1242,15 +1245,22 @@ export const SortieDogsPlugin = async (input, options) => {
1242
1245
  const key = createHash("sha256").update(root).digest("hex");
1243
1246
  const projectRoot = resolve(input.worktree ?? input.directory);
1244
1247
  const legacyPath = join(projectRoot, ".sortie-dogs", "run-flight", `${key}.json`);
1248
+ goalLedgerDirectories.add(dirname(legacyPath));
1245
1249
  const opened = (async () => {
1250
+ const legacyExists = await stat(legacyPath).then((value) => value.isFile()).catch(() => false);
1251
+ const leaseRoot = legacyExists ? await durableScopeRoot(projectRoot).catch(() => undefined) : await durableScopeRoot(projectRoot);
1252
+ if (leaseRoot !== undefined)
1253
+ goalLedgerDirectories.add(join(dirname(leaseRoot), "run-flight"));
1246
1254
  // Existing roots remain on their original owner so an in-flight pre-upgrade goal is not forked.
1247
- if (await stat(legacyPath).then((value) => value.isFile()).catch(() => false)) {
1255
+ if (legacyExists) {
1256
+ goalLedgerFiles.set(root, legacyPath);
1248
1257
  return await RunFlightLedger.openGoal(legacyPath);
1249
1258
  }
1250
- const leaseRoot = await durableScopeRoot(projectRoot);
1251
1259
  const filePath = leaseRoot === undefined
1252
1260
  ? legacyPath
1253
1261
  : join(dirname(leaseRoot), "run-flight", `${key}.json`);
1262
+ goalLedgerFiles.set(root, filePath);
1263
+ goalLedgerDirectories.add(dirname(filePath));
1254
1264
  return await RunFlightLedger.openGoal(filePath);
1255
1265
  })();
1256
1266
  goalLedgers.set(root, opened);
@@ -1362,8 +1372,12 @@ export const SortieDogsPlugin = async (input, options) => {
1362
1372
  const unitID = authorization.taskID;
1363
1373
  const unitBound = unitID !== undefined && goal.outstanding_reservations.some((reservation) => reservation.unit_id === unitID && reservation.session_id === identity.parentID);
1364
1374
  const criteria = goal.acceptance_contract?.criteria.filter((criterion) => criterion.validation_command === rawCommand && criterion.expected_outcome === "pass") ?? [];
1375
+ const denyValidation = (reason) => {
1376
+ goalValidationDefects.add(toolInput.sessionID);
1377
+ return new Error(`SORTIE_VALIDATION_BUDGET_DENIED: ${reason}`);
1378
+ };
1365
1379
  if (!unitBound)
1366
- throw new Error("SORTIE_VALIDATION_BUDGET_DENIED: requirement-unbound");
1380
+ throw denyValidation("requirement-unbound");
1367
1381
  // Exact generation and formatting checks may support acceptance without proving a criterion.
1368
1382
  if (criteria.length === 0)
1369
1383
  return;
@@ -1377,10 +1391,10 @@ export const SortieDogsPlugin = async (input, options) => {
1377
1391
  reservation = await ledger.reserveValidation(request, goal.budget?.max_units ?? 1);
1378
1392
  }
1379
1393
  catch (error) {
1380
- throw new Error(`SORTIE_VALIDATION_BUDGET_DENIED: authority-unavailable:${error instanceof Error ? error.name : "unknown"}`);
1394
+ throw denyValidation(`authority-unavailable:${error instanceof Error ? error.name : "unknown"}`);
1381
1395
  }
1382
1396
  if (reservation.decision !== "ALLOW" || reservation.reservation_id === null) {
1383
- throw new Error(`SORTIE_VALIDATION_BUDGET_DENIED: ${reservation.reason}`);
1397
+ throw denyValidation(reservation.reason);
1384
1398
  }
1385
1399
  validation = { ledger, request, reservation: reservation.reservation_id };
1386
1400
  }
@@ -1615,11 +1629,12 @@ export const SortieDogsPlugin = async (input, options) => {
1615
1629
  : "external_dependency";
1616
1630
  receipt = await terminalGoal(sessionID, reason, "stopped").catch(() => undefined);
1617
1631
  }
1618
- if (receipt !== undefined)
1619
- goal = await currentGoal(sessionID).catch(() => goal);
1632
+ const snapshot = receipt === undefined ? undefined : await goalLedger(sessionID).then((ledger) => ledger.readGoal()).catch(() => undefined);
1633
+ if (snapshot !== undefined)
1634
+ goal = snapshot.state;
1620
1635
  if (receipt !== undefined)
1621
1636
  rootAcceptanceContinuity.delete(sessionID);
1622
- return { outcome, goal, receipt, delivery };
1637
+ return { outcome, goal, receipt, delivery, records: snapshot?.records };
1623
1638
  }
1624
1639
  function goalDeclarationContract(prompt) {
1625
1640
  const lines = prompt.split(/\r?\n/u);
@@ -1768,27 +1783,29 @@ export const SortieDogsPlugin = async (input, options) => {
1768
1783
  throw new HandoffDeniedError("contract-invalid", "<goal-declaration>", { defects: validated.defects });
1769
1784
  }
1770
1785
  const declaration = validated.declaration;
1786
+ const units = Number(handoffValue(entries, ["goal_budget_units"]));
1787
+ const maxUnits = Number.isSafeInteger(units) && units >= state.consumed_units && units > 0
1788
+ ? units : state.budget?.max_units ?? 32;
1789
+ const declaredTime = Number(handoffValue(entries, ["goal_budget_time_ms"]));
1790
+ const declaredCost = Number(handoffValue(entries, ["goal_budget_cost_usd"]));
1791
+ const timeBudget = Number.isFinite(declaredTime) && declaredTime > 0 ? declaredTime : state.budget?.time_ms ?? null;
1792
+ const costBudget = Number.isFinite(declaredCost) && declaredCost > 0 ? declaredCost : state.budget?.cost_usd ?? null;
1793
+ const budgetChanged = maxUnits !== state.budget?.max_units || timeBudget !== state.budget?.time_ms ||
1794
+ costBudget !== state.budget?.cost_usd;
1771
1795
  const authority = goalDeclarationAuthority.get(sessionID);
1772
1796
  if (authority === undefined || authority !== state.latest_user_message_id) {
1773
- if (declaration.fingerprint !== state.acceptance_fingerprint) {
1797
+ if (declaration.fingerprint !== state.acceptance_fingerprint || budgetChanged) {
1774
1798
  throw new HandoffDeniedError("contract-invalid", "<goal-declaration>", { defects: [
1775
1799
  contractDefect("contract", "/goal_acceptance_fingerprint", "goal_revision_unauthorized"),
1776
1800
  ] });
1777
1801
  }
1778
1802
  return state;
1779
1803
  }
1780
- if (declaration.fingerprint === state.acceptance_fingerprint &&
1804
+ if (!budgetChanged && declaration.fingerprint === state.acceptance_fingerprint &&
1781
1805
  goalFingerprint(declaration.contract) === goalFingerprint(state.acceptance_contract)) {
1782
1806
  goalDeclarationAuthority.delete(sessionID);
1783
1807
  return state;
1784
1808
  }
1785
- const units = Number(handoffValue(entries, ["goal_budget_units"]));
1786
- const maxUnits = Number.isSafeInteger(units) && units >= state.consumed_units && units > 0
1787
- ? units : state.budget?.max_units ?? 32;
1788
- const declaredTime = Number(handoffValue(entries, ["goal_budget_time_ms"]));
1789
- const declaredCost = Number(handoffValue(entries, ["goal_budget_cost_usd"]));
1790
- const timeBudget = Number.isFinite(declaredTime) && declaredTime > 0 ? declaredTime : state.budget?.time_ms ?? null;
1791
- const costBudget = Number.isFinite(declaredCost) && declaredCost > 0 ? declaredCost : state.budget?.cost_usd ?? null;
1792
1809
  state = await ledger.appendGoal({ kind: "goal.revised", at: new Date().toISOString(), goal_id: state.goal_id,
1793
1810
  revision: state.revision + 1, scope_epoch: state.scope_epoch + 1,
1794
1811
  acceptance_fingerprint: declaration.fingerprint, origin_user_message_id: state.latest_user_message_id,
@@ -1904,7 +1921,11 @@ export const SortieDogsPlugin = async (input, options) => {
1904
1921
  output.status === "cancel" || output.status === "cancelled";
1905
1922
  const hostBindingDefect = childSessionID !== undefined && [...(bindingDenials.get(reservation.root)?.values() ?? [])]
1906
1923
  .some((candidateDenials) => [...candidateDenials.values()].includes(childSessionID));
1907
- const processDefect = childSessionID === undefined || hostBindingDefect;
1924
+ const failedAcceptanceExecution = [...hostGoalExecutions.values()].some((execution) => execution.root === reservation.root && execution.sessionID === childSessionID &&
1925
+ execution.endedAt !== undefined && Date.parse(execution.startedAt) >= reservation.started - 1000 &&
1926
+ execution.outcome === "fail");
1927
+ const processDefect = !failedAcceptanceExecution && (childSessionID === undefined || hostBindingDefect ||
1928
+ goalValidationDefects.has(childSessionID));
1908
1929
  const resultClass = progress ? "acceptance" : interrupted ? "interrupted" : processDefect ? "process-defect" : "acceptance";
1909
1930
  await ledger.appendGoal({ kind: "unit.settled", at: new Date().toISOString(),
1910
1931
  reservation_id: reservation.reservationID, receipt_id: goalFingerprint({ call_id: callID, output: outputText.slice(0, 2048) }),
@@ -1913,6 +1934,7 @@ export const SortieDogsPlugin = async (input, options) => {
1913
1934
  progress_fingerprint: progress ? goalFingerprint(acceptedEvidence) : null,
1914
1935
  evidence: acceptedEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started), cost_usd: null });
1915
1936
  if (childSessionID !== undefined) {
1937
+ goalValidationDefects.delete(childSessionID);
1916
1938
  for (const [executionCallID, execution] of hostGoalExecutions) {
1917
1939
  if (execution.root === reservation.root && execution.sessionID === childSessionID)
1918
1940
  hostGoalExecutions.delete(executionCallID);
@@ -4118,7 +4140,7 @@ export const SortieDogsPlugin = async (input, options) => {
4118
4140
  const remedies = {
4119
4141
  "session-inactive": {
4120
4142
  recoverable: true,
4121
- remedy: "Freshly redispatch this worker with prompt text containing role, project_root, source_manifest or operation_manifest, and acceptance or validation fields; a bare resume or file read cannot activate the session.",
4143
+ remedy: "Freshly redispatch through dog-coordinator's admitted dog-worker Task, with prompt text containing role, project_root, source_manifest or operation_manifest, and acceptance or validation fields. A standalone build/fixer Task is not a registered Sortie worker; copying these fields, a bare resume, or a file read cannot activate it. Preserve explicit agent selection and resolve any existing goal stop before using the Sortie workflow; do not repeat the same standalone redispatch or reset its ledger to bypass a stop.",
4122
4144
  },
4123
4145
  "session-expired": {
4124
4146
  recoverable: true,
@@ -4659,6 +4681,7 @@ export const SortieDogsPlugin = async (input, options) => {
4659
4681
  clearSessionLinks(sessionID);
4660
4682
  }
4661
4683
  function evictSession(sessionID) {
4684
+ goalValidationDefects.delete(sessionID);
4662
4685
  activeSessions.delete(sessionID);
4663
4686
  sessionOperationMetrics.delete(sessionID);
4664
4687
  rootAcceptanceContinuity.delete(sessionID);
@@ -5354,13 +5377,33 @@ export const SortieDogsPlugin = async (input, options) => {
5354
5377
  startedAt: terminal.receipt.started_at,
5355
5378
  endedAt: terminal.receipt.ended_at,
5356
5379
  }).catch(() => undefined));
5357
- const sortieResult = terminal?.receipt === undefined || terminal.goal === undefined
5380
+ let sortieResult = terminal?.receipt === undefined || terminal.goal === undefined
5358
5381
  ? undefined
5359
- : createSortieResult(terminal.receipt, terminal.goal, metrics, new Date().toISOString());
5382
+ : createSortieResult(terminal.receipt, terminal.goal, metrics, new Date().toISOString(), terminal.records);
5383
+ if (sortieResult !== undefined && terminal?.receipt !== undefined && terminal.records !== undefined) {
5384
+ try {
5385
+ const ledger = await goalLedger(textInput.sessionID);
5386
+ const report = createGoalReport(sortieResult, terminal.receipt);
5387
+ let records = terminal.records;
5388
+ if (!records.some(({ event }) => event.kind === "goal.reported" && event.report?.terminal_key === report.terminal_key)) {
5389
+ await ledger.appendGoal({ kind: "goal.reported", at: new Date().toISOString(), goal_id: terminal.receipt.goal_id, report });
5390
+ records = (await ledger.readGoal()).records;
5391
+ }
5392
+ const currentPath = goalLedgerFiles.get(goalRoot(textInput.sessionID));
5393
+ if (currentPath !== undefined) {
5394
+ const { collectCareer } = await import("./sortie-career.js");
5395
+ sortieResult = { ...sortieResult, career: await collectCareer([...goalLedgerDirectories], currentPath, records, (path) => RunFlightLedger.readGoalFile(path)) };
5396
+ }
5397
+ }
5398
+ catch {
5399
+ appLogInfo("run-metrics.career-unavailable", textInput.sessionID, { outcome: runOutcome }, "warn");
5400
+ }
5401
+ }
5360
5402
  if (sortieResult !== undefined)
5361
5403
  textOutput.text = insertSortieResult(textOutput.text, sortieResult);
5362
5404
  else if (metrics !== undefined && runOutcome === "DONE")
5363
5405
  textOutput.text = insertRunMetrics(textOutput.text, metrics);
5406
+ const { debrief: _debriefObservation, ...metricSummary } = metrics ?? {};
5364
5407
  appLogInfo("run-metrics.snapshot", textInput.sessionID, {
5365
5408
  available: metrics !== undefined,
5366
5409
  outcome: runOutcome,
@@ -5371,7 +5414,7 @@ export const SortieDogsPlugin = async (input, options) => {
5371
5414
  resultProof: sortieResult.proof.overall,
5372
5415
  accountingPhase: sortieResult.accounting_phase,
5373
5416
  }),
5374
- ...(metrics ?? {}),
5417
+ ...metricSummary,
5375
5418
  ...operationMetricsSnapshot(textInput.sessionID),
5376
5419
  });
5377
5420
  }
@@ -5705,6 +5748,16 @@ export const SortieDogsPlugin = async (input, options) => {
5705
5748
  */
5706
5749
  "tool.execute.after": async (toolInput, output) => {
5707
5750
  await recordHostGoalEnd(toolInput, output);
5751
+ // A completed host question is a new user-interaction boundary, just like chat input.
5752
+ // It authorizes one subsequent typed declaration; it does not itself grant budget or clear a stop.
5753
+ if (toolInput.tool === "question" && toolInput.sessionID !== undefined &&
5754
+ isCoordinatorSession(toolInput.sessionID) && typeof output.output === "string" &&
5755
+ output.output.trim().length > 0 && output.status !== "error" && output.status !== "cancelled") {
5756
+ const goal = await currentGoal(toolInput.sessionID);
5757
+ if (goal.phase !== "terminal" && goal.latest_user_message_id !== null) {
5758
+ goalDeclarationAuthority.set(toolInput.sessionID, goal.latest_user_message_id);
5759
+ }
5760
+ }
5708
5761
  diagnosisChildren.get(toolInput.sessionID ?? "")?.tools.delete(toolInput.callID ?? "");
5709
5762
  const diagnosisCandidate = toolInput.tool === "task" ? diagnosisCalls.get(toolInput.callID ?? "") : undefined;
5710
5763
  const diagnosis = diagnosisCandidate?.context.ownerRoot === toolInput.sessionID ? diagnosisCandidate : undefined;
@@ -1,4 +1,7 @@
1
- import type { GoalFlightState, GoalTerminalReceipt } from "../core/goal-bound.js";
1
+ import type { GoalFlightState, GoalTerminalReceipt, GoalFlightEventRecord } from "../core/goal-bound.js";
2
+ import { type Debrief, type DebriefObservation } from "./sortie-debrief.ts";
3
+ import type { GoalReport } from "../core/goal-report.ts";
4
+ import { type SortieCareer } from "./sortie-career.ts";
2
5
  export interface RunMetricsClient {
3
6
  readonly session?: {
4
7
  readonly get?: (request: {
@@ -44,6 +47,8 @@ export interface RunMetrics {
44
47
  readonly sessions: number | undefined;
45
48
  readonly cacheRatio: number | undefined;
46
49
  readonly roles: Readonly<Record<string, RunRoleMetrics>> | undefined;
50
+ /** Optional for compatibility with older host snapshots. Collected in the existing history pass. */
51
+ readonly debrief?: DebriefObservation;
47
52
  }
48
53
  export interface RunRoleMetrics {
49
54
  readonly tokens: number;
@@ -72,6 +77,8 @@ export interface SortieResult {
72
77
  readonly result_id: readonly [goalID: string, terminalRevision: number];
73
78
  readonly accounting_phase: "pre-terminal";
74
79
  readonly as_of: string;
80
+ readonly debrief?: Debrief;
81
+ readonly career?: SortieCareer;
75
82
  readonly mission: {
76
83
  readonly status: "COMPLETED" | "INTERRUPTED" | "EXTERNAL_BLOCKER" | "USER_DECISION";
77
84
  readonly stop_reason: GoalTerminalReceipt["stop_reason"];
@@ -102,7 +109,7 @@ export interface SortieResult {
102
109
  }
103
110
  type SortieGoalSnapshot = Pick<GoalFlightState, "acceptance_contract" | "consumed_time_ms" | "satisfied_criteria">;
104
111
  /** Builds a pure terminal snapshot from the durable goal receipt and already-observed host metrics. */
105
- export declare function createSortieResult(receipt: GoalTerminalReceipt, goal: SortieGoalSnapshot, metrics: RunMetrics | undefined, asOf?: string): SortieResult;
112
+ export declare function createSortieResult(receipt: GoalTerminalReceipt, goal: SortieGoalSnapshot, metrics: RunMetrics | undefined, asOf?: string, records?: readonly GoalFlightEventRecord[]): SortieResult;
106
113
  export type RunTerminalOutcome = "DONE" | "INTERRUPTED" | "BLOCKED" | "NEED_DECISION";
107
114
  export declare function collectRunMetrics(client: RunMetricsClient | undefined, rootSessionID: string, directory?: string, now?: number, window?: RunMetricsWindow): Promise<RunMetrics | undefined>;
108
115
  export declare function formatSortieResult(result: SortieResult): string;
@@ -114,4 +121,5 @@ export declare function replaceDoneTerminalStatus(text: string, replacement: str
114
121
  export declare function sanitizeTerminalReport(text: string): string;
115
122
  export declare function insertRunMetrics(text: string, metrics: RunMetrics): string;
116
123
  export declare function insertSortieResult(text: string, result: SortieResult): string;
124
+ export declare function createGoalReport(result: SortieResult, receipt: GoalTerminalReceipt): GoalReport;
117
125
  export {};
@@ -1,3 +1,6 @@
1
+ import { buildDebrief, renderDebrief, observeDebriefSession } from "./sortie-debrief.js";
2
+ import { goalFingerprint } from "../core/goal-bound.js";
3
+ import { renderCareer } from "./sortie-career.js";
1
4
  const unavailable = (reason) => ({ availability: "unavailable", value: null, reason });
2
5
  const available = (value, provenance) => ({ availability: "available", value, provenance });
3
6
  function elapsedBetween(start, end) {
@@ -6,7 +9,7 @@ function elapsedBetween(start, end) {
6
9
  return Number.isFinite(started) && Number.isFinite(ended) && ended >= started ? ended - started : undefined;
7
10
  }
8
11
  /** Builds a pure terminal snapshot from the durable goal receipt and already-observed host metrics. */
9
- export function createSortieResult(receipt, goal, metrics, asOf = receipt.ended_at) {
12
+ export function createSortieResult(receipt, goal, metrics, asOf = receipt.ended_at, records) {
10
13
  const goalWall = elapsedBetween(receipt.started_at, receipt.ended_at);
11
14
  const firstVerifiable = receipt.milestone_at === null
12
15
  ? undefined
@@ -25,11 +28,13 @@ export function createSortieResult(receipt, goal, metrics, asOf = receipt.ended_
25
28
  : receipt.stop_reason === "external_dependency" || receipt.stop_reason === "persistence_unavailable"
26
29
  ? "EXTERNAL_BLOCKER"
27
30
  : "INTERRUPTED";
31
+ const debrief = buildDebrief(receipt, goal.acceptance_contract, metrics?.debrief, records);
28
32
  return {
29
33
  schema_version: "0.1",
30
34
  result_id: [receipt.goal_id, receipt.terminal_revision],
31
35
  accounting_phase: "pre-terminal",
32
36
  as_of: asOf,
37
+ debrief,
33
38
  mission: {
34
39
  status: missionStatus,
35
40
  stop_reason: receipt.stop_reason,
@@ -39,7 +44,9 @@ export function createSortieResult(receipt, goal, metrics, asOf = receipt.ended_
39
44
  worker_execution_ms: goal.consumed_time_ms === null
40
45
  ? unavailable("goal-usage-unavailable")
41
46
  : available(goal.consumed_time_ms, "goal-ledger"),
42
- execution_compression: unavailable("worker-overlap-unavailable"),
47
+ execution_compression: debrief.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
48
+ ? available(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds, "host-reported")
49
+ : unavailable("worker-overlap-unavailable"),
43
50
  first_verifiable_ms: firstVerifiable === undefined
44
51
  ? unavailable("milestone-unavailable")
45
52
  : available(firstVerifiable, "goal-receipt"),
@@ -194,11 +201,14 @@ export async function collectRunMetrics(client, rootSessionID, directory, now =
194
201
  let cost = 0;
195
202
  let costAvailable = true;
196
203
  const roleMetrics = new Map();
204
+ const observedSessions = [];
197
205
  for (const id of ids) {
198
206
  try {
199
207
  const messages = assistantMessages(await session.messages.call(session, { path: { id }, query: { directory } }));
200
208
  if (messages === undefined)
201
209
  return undefined;
210
+ const observed = observeDebriefSession(id, id === rootSessionID, messages, window === undefined ? undefined : { start: windowStart, end: windowEnd });
211
+ observedSessions.push(observed);
202
212
  for (const message of messages) {
203
213
  const info = record(message.info) ?? message;
204
214
  const time = record(info.time) ?? record(message.time);
@@ -240,6 +250,12 @@ export async function collectRunMetrics(client, rootSessionID, directory, now =
240
250
  roleMetrics.set(agent, role);
241
251
  for (const usage of fresh) {
242
252
  const tokens = messageTokens(usage.value);
253
+ const modelInfo = record(usage.value.info) ?? usage.value;
254
+ const provider = modelInfo.providerID ?? info.providerID;
255
+ const model = modelInfo.modelID ?? info.modelID;
256
+ const modelKey = typeof provider === "string" && typeof model === "string" ? `${provider}/${model}` : "未分類";
257
+ observed.models[modelKey] = tokens === undefined || observed.models[modelKey] === null ? null
258
+ : (observed.models[modelKey] ?? 0) + tokens.total;
243
259
  if (tokens !== undefined) {
244
260
  totalTokens += tokens.total;
245
261
  inputTokens += tokens.input;
@@ -309,6 +325,8 @@ export async function collectRunMetrics(client, rootSessionID, directory, now =
309
325
  cacheRatio: role.tokens > 0 ? role.cacheReadTokens / role.tokens : undefined,
310
326
  }]))
311
327
  : undefined,
328
+ debrief: { complete: hierarchyComplete && messagesComplete, sessions: observedSessions,
329
+ window: window === undefined ? undefined : { start: windowStart, end: windowEnd } },
312
330
  };
313
331
  }
314
332
  function duration(milliseconds) {
@@ -331,11 +349,14 @@ export function formatSortieResult(result) {
331
349
  : result.mission.status === "EXTERNAL_BLOCKER" ? "外部要因で未完了"
332
350
  : "ユーザー判断待ち(未完了)";
333
351
  return [
334
- "**Sortie Result**",
335
- `**Speed:** 全体 ${metricText(result.speed.goal_wall_ms, duration)} · worker ${metricText(result.speed.worker_execution_ms, duration)}`,
336
- `**Cost:** ${metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")}トークン`)} · ${metricText(result.cost.cost_usd, (value) => `$${value.toFixed(4)}`)}`,
337
- `**達成:** ${achievement} · acceptance ${criteria}`,
338
- ].join("\n");
352
+ "**🐾 SORTIE DOGS — 帰還報告**",
353
+ `**⚡ 時間:** ${metricText(result.speed.goal_wall_ms, duration)}`,
354
+ `**🪙 使用量:** ${metricText(result.cost.total_tokens, (value) => `${value.toLocaleString("ja-JP")} tokens`)} · host推定額 ${metricText(result.cost.cost_usd, (value) => `$${value.toFixed(4)}`)}(実課金換算なし)`,
355
+ ...renderDebrief(result.debrief),
356
+ `**🛡 達成:** ${achievement} · 達成条件 ${criteria}`,
357
+ "*最終応答生成前の計測*",
358
+ ...renderCareer(result.career),
359
+ ].join("\n\n");
339
360
  }
340
361
  export function formatRunMetrics(metrics) {
341
362
  const elapsed = metrics.durationMilliseconds === undefined ? "duration unavailable" : `${duration(metrics.durationMilliseconds)} wall-clock`;
@@ -441,10 +462,39 @@ export function insertSortieResult(text, result) {
441
462
  const checkpoint = terminalCheckpoint(visible);
442
463
  if (checkpoint === undefined)
443
464
  return visible;
444
- if (topLevelLines(visible).some(({ index, line }) => index > checkpoint.index && /^\*\*Sortie Result\*\*/u.test(line)))
445
- return visible;
465
+ // A title in model text is not trusted evidence. Replace existing cards, including legacy cards.
446
466
  const newline = visible.includes("\r\n") ? "\r\n" : "\n";
447
467
  const lines = visible.split(/\r?\n/u);
448
- lines.splice(checkpoint.index + 1, 0, "", formatSortieResult(result));
449
- return lines.join(newline);
468
+ const cardLines = new Set(topLevelLines(visible).filter(({ index, line }) => index > checkpoint.index &&
469
+ /^(?:\*\*(?:Sortie Result|🐾 SORTIE DOGS — 帰還報告|📜 PACK RECORD|↳\*\*|(?:Speed|Cost|達成|⚡ 時間|🪙 使用量|🐕 出撃隊|モデル別token内訳|実行重複率|🛡 達成|確認|🏅 今回の戦績|戦績|初回完遂|累積使用量|累積モデル|累積時間|累積実行重複率|保存範囲|🎖 隊の称号):)|\*最終応答生成前の計測\*)/u.test(line)).map(({ index }) => index));
470
+ const cleaned = lines.filter((_, index) => !cardLines.has(index));
471
+ while (cleaned[checkpoint.index + 1] === "")
472
+ cleaned.splice(checkpoint.index + 1, 1);
473
+ let card;
474
+ try {
475
+ card = formatSortieResult(result);
476
+ }
477
+ catch {
478
+ card = "**🐾 SORTIE DOGS — 帰還報告**\n**確認:** 表示集計を取得できません。任務結果は先頭の状態を参照。";
479
+ }
480
+ cleaned.splice(checkpoint.index + 1, 0, "", card, "");
481
+ return cleaned.join(newline).trimEnd();
482
+ }
483
+ export function createGoalReport(result, receipt) {
484
+ const tokens = result.cost.total_tokens.availability === "available" && Number.isSafeInteger(result.cost.total_tokens.value)
485
+ ? result.cost.total_tokens.value : null;
486
+ const mix = result.debrief?.mix;
487
+ const traits = [];
488
+ if (result.debrief?.traits.includes("連携作戦"))
489
+ traits.push("pack-tactics");
490
+ if (result.debrief?.traits.includes("修正から復帰"))
491
+ traits.push("recovery");
492
+ if (result.debrief?.traits.includes("一発完遂"))
493
+ traits.push("clean-sweep");
494
+ return { definition: "pre-terminal-host-tokens/v1", terminal_key: goalFingerprint(receipt), tokens,
495
+ models: mix != null && mix.length <= 128 && mix.every((entry) => entry.model.length <= 512) &&
496
+ mix.reduce((sum, entry) => sum + entry.tokens, 0) === tokens ? mix.map(({ model, tokens }) => ({ model, tokens })) : null,
497
+ first_pass_eligible: result.debrief?.firstPassEligible === true, traits,
498
+ ...(result.debrief?.overlap === undefined ? {} : { overlap: { definition: "worker-span-union/v1",
499
+ worker_ms: result.debrief.overlap.workerMilliseconds, wall_ms: result.debrief.overlap.wallMilliseconds } }) };
450
500
  }
@@ -0,0 +1,55 @@
1
+ import { type GoalFlightEventRecord } from "../core/goal-bound.ts";
2
+ export interface CareerCoverage {
3
+ readonly files: number;
4
+ readonly included: number;
5
+ readonly unavailable: number;
6
+ readonly truncated: boolean;
7
+ }
8
+ export interface SortieCareer {
9
+ readonly scope: "retained-project-goals";
10
+ readonly since: string | null;
11
+ readonly coverage: CareerCoverage;
12
+ readonly goals: number;
13
+ readonly completed: number;
14
+ readonly interrupted: number;
15
+ readonly external: number;
16
+ readonly decision: number;
17
+ readonly active: number;
18
+ readonly telemetryCovered: number;
19
+ readonly firstPass: {
20
+ readonly count: number;
21
+ readonly eligible: number;
22
+ };
23
+ readonly recoveries: number;
24
+ readonly tokens: {
25
+ readonly sum: number;
26
+ readonly covered: number;
27
+ };
28
+ readonly models: readonly {
29
+ readonly model: string;
30
+ readonly tokens: number;
31
+ }[];
32
+ readonly modelCovered: number;
33
+ readonly workerTime: {
34
+ readonly sum: number;
35
+ readonly covered: number;
36
+ };
37
+ readonly goalWall: {
38
+ readonly sum: number;
39
+ readonly covered: number;
40
+ };
41
+ readonly overlap: {
42
+ readonly worker: number;
43
+ readonly wall: number;
44
+ readonly covered: number;
45
+ readonly ratio: number | null;
46
+ };
47
+ readonly titles: readonly string[];
48
+ }
49
+ /** Rebuild from retained source records; no cache survives deletion, retention, or reset. */
50
+ export declare function summarizeCareer(histories: readonly (readonly GoalFlightEventRecord[])[], coverage: CareerCoverage): SortieCareer;
51
+ /** One bounded, read-only scan at terminal time. Current records are reused, not fetched again. */
52
+ export declare function collectCareer(directories: readonly string[], currentPath: string, current: readonly GoalFlightEventRecord[], read: (path: string) => Promise<{
53
+ readonly records: readonly GoalFlightEventRecord[];
54
+ }>, maxFiles?: number): Promise<SortieCareer>;
55
+ export declare function renderCareer(career: SortieCareer | undefined): string[];
@@ -0,0 +1,144 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { goalFingerprint } from "../core/goal-bound.js";
4
+ import { validGoalReport } from "../core/goal-report.js";
5
+ /** Rebuild from retained source records; no cache survives deletion, retention, or reset. */
6
+ export function summarizeCareer(histories, coverage) {
7
+ const unique = new Map();
8
+ for (const history of histories)
9
+ for (const record of history)
10
+ unique.set(record.event_hash, record);
11
+ const events = [...unique.values()].sort((a, b) => Date.parse(a.event.at) - Date.parse(b.event.at) || a.sequence - b.sequence);
12
+ const goals = new Map();
13
+ for (const { event } of events) {
14
+ if (!goals.has(event.goal_id) && event.kind !== "goal.accepted")
15
+ continue;
16
+ const goal = goals.get(event.goal_id) ?? { since: null, receipt: null, reports: new Map(), units: new Map() };
17
+ goals.set(event.goal_id, goal);
18
+ if (event.kind === "goal.accepted")
19
+ goal.since ??= event.at;
20
+ if (event.kind === "goal.terminal")
21
+ goal.receipt = event.receipt;
22
+ if (event.kind === "goal.user-continued" || event.kind === "goal.revised")
23
+ goal.receipt = null;
24
+ if (event.kind === "unit.settled")
25
+ goal.units.set(event.reservation_id, event.elapsed_ms);
26
+ if (event.kind === "goal.reported" && validGoalReport(event.report))
27
+ goal.reports.set(event.report.terminal_key, event.report);
28
+ }
29
+ let completed = 0, interrupted = 0, external = 0, decision = 0, active = 0, telemetryCovered = 0, recoveries = 0;
30
+ const firstPass = { count: 0, eligible: 0 }, tokens = { sum: 0, covered: 0 }, workerTime = { sum: 0, covered: 0 }, goalWall = { sum: 0, covered: 0 };
31
+ const models = new Map();
32
+ const overlap = { worker: 0, wall: 0, covered: 0, ratio: null };
33
+ let modelCovered = 0, packTactics = false;
34
+ for (const goal of goals.values()) {
35
+ const receipt = goal.receipt;
36
+ if (receipt === null) {
37
+ active++;
38
+ continue;
39
+ }
40
+ if (receipt.status === "succeeded")
41
+ completed++;
42
+ else if (receipt.stop_reason === "external_dependency" || receipt.stop_reason === "persistence_unavailable")
43
+ external++;
44
+ else if (receipt.stop_reason === "awaiting_user")
45
+ decision++;
46
+ else
47
+ interrupted++;
48
+ const elapsed = Date.parse(receipt.ended_at) - Date.parse(receipt.started_at);
49
+ if (Number.isFinite(elapsed) && elapsed >= 0) {
50
+ goalWall.sum += elapsed;
51
+ goalWall.covered++;
52
+ }
53
+ if ([...goal.units.values()].every((value) => value !== null && Number.isFinite(value) && value >= 0)) {
54
+ workerTime.sum += [...goal.units.values()].reduce((sum, value) => sum + (value ?? 0), 0);
55
+ workerTime.covered++;
56
+ }
57
+ const report = goal.reports.get(goalFingerprint(receipt));
58
+ if (report === undefined)
59
+ continue;
60
+ telemetryCovered++;
61
+ if (report.overlap !== undefined) {
62
+ overlap.worker += report.overlap.worker_ms;
63
+ overlap.wall += report.overlap.wall_ms;
64
+ overlap.covered++;
65
+ }
66
+ if (report.first_pass_eligible && receipt.status === "succeeded") {
67
+ firstPass.eligible++;
68
+ if (report.traits.includes("clean-sweep"))
69
+ firstPass.count++;
70
+ }
71
+ if (report.traits.includes("recovery") && receipt.status === "succeeded")
72
+ recoveries++;
73
+ if (report.traits.includes("pack-tactics"))
74
+ packTactics = true;
75
+ if (report.tokens !== null) {
76
+ tokens.sum += report.tokens;
77
+ tokens.covered++;
78
+ }
79
+ if (report.models !== null) {
80
+ modelCovered++;
81
+ for (const entry of report.models)
82
+ models.set(entry.model, (models.get(entry.model) ?? 0) + entry.tokens);
83
+ }
84
+ }
85
+ const dates = [...goals.values()].flatMap((goal) => goal.since === null ? [] : [goal.since]).sort((a, b) => Date.parse(a) - Date.parse(b));
86
+ if (overlap.wall > 0)
87
+ overlap.ratio = overlap.worker / overlap.wall;
88
+ return { scope: "retained-project-goals", since: dates[0] ?? null, coverage, goals: goals.size,
89
+ completed, interrupted, external, decision, active, telemetryCovered, firstPass, recoveries, tokens,
90
+ models: [...models].sort(([a], [b]) => a.localeCompare(b)).map(([model, tokens]) => ({ model, tokens })), modelCovered,
91
+ workerTime, goalWall, overlap, titles: [
92
+ ...(completed > 0 ? ["任務完遂の隊"] : []), ...(packTactics ? ["連携の隊"] : []),
93
+ ...(recoveries > 0 ? ["復帰の隊"] : []), ...(firstPass.count > 0 ? ["一発完遂の隊"] : []),
94
+ ] };
95
+ }
96
+ /** One bounded, read-only scan at terminal time. Current records are reused, not fetched again. */
97
+ export async function collectCareer(directories, currentPath, current, read, maxFiles = 64) {
98
+ const paths = new Set();
99
+ let unavailable = 0;
100
+ for (const directory of new Set(directories)) {
101
+ try {
102
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
103
+ if (entry.isFile() && /^[a-f0-9]{64}\.json$/u.test(entry.name))
104
+ paths.add(resolve(join(directory, entry.name)));
105
+ }
106
+ }
107
+ catch (error) {
108
+ if (error.code !== "ENOENT")
109
+ unavailable++;
110
+ }
111
+ }
112
+ paths.delete(resolve(currentPath));
113
+ const selected = [...paths].sort().slice(0, Math.max(0, maxFiles - 1));
114
+ const histories = [current];
115
+ for (const path of selected) {
116
+ try {
117
+ histories.push((await read(path)).records);
118
+ }
119
+ catch {
120
+ unavailable++;
121
+ }
122
+ }
123
+ return summarizeCareer(histories, { files: paths.size + 1, included: histories.length, unavailable, truncated: selected.length < paths.size });
124
+ }
125
+ export function renderCareer(career) {
126
+ if (career === undefined)
127
+ return ["**📜 PACK RECORD:** 保存履歴を取得できません"];
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(" · ");
133
+ 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(" · ")}`] : []),
143
+ ];
144
+ }
@@ -0,0 +1,54 @@
1
+ import type { GoalAcceptanceContract, GoalTerminalReceipt, GoalFlightEventRecord } from "../core/goal-bound.js";
2
+ type Span = {
3
+ start: number;
4
+ end: number;
5
+ };
6
+ type Check = Span & {
7
+ command: string;
8
+ passed: boolean;
9
+ };
10
+ export interface DebriefSession {
11
+ readonly id: string;
12
+ readonly root: boolean;
13
+ readonly spans: Span[];
14
+ readonly checks: Check[];
15
+ readonly mutations: Span[];
16
+ readonly models: Record<string, number | null>;
17
+ readonly reviews: Array<{
18
+ at: number;
19
+ status: "PASS" | "FAIL" | "WAIVED";
20
+ }>;
21
+ readonly tasks: string[];
22
+ complete: boolean;
23
+ timingComplete: boolean;
24
+ failed: boolean;
25
+ }
26
+ export interface DebriefObservation {
27
+ readonly complete: boolean;
28
+ readonly sessions: readonly DebriefSession[];
29
+ readonly window: Span | undefined;
30
+ }
31
+ export interface Debrief {
32
+ readonly pack: readonly {
33
+ readonly model: string;
34
+ readonly count: number;
35
+ }[] | null;
36
+ readonly mix: readonly {
37
+ readonly model: string;
38
+ readonly tokens: number;
39
+ readonly percent: number;
40
+ }[] | null;
41
+ readonly validation: "PASS" | "FAIL" | "未確認";
42
+ readonly review: "PASS" | "FAIL" | "WAIVED" | "未確認";
43
+ readonly traits: readonly ("連携作戦" | "修正から復帰" | "一発完遂")[];
44
+ readonly firstPassEligible?: boolean;
45
+ readonly overlap?: {
46
+ readonly workerMilliseconds: number;
47
+ readonly wallMilliseconds: number;
48
+ };
49
+ }
50
+ /** Extract only bounded typed metadata during the existing host history traversal. No conversation text survives. */
51
+ export declare function observeDebriefSession(id: string, root: boolean, messages: readonly Record<string, unknown>[], window?: Span): DebriefSession;
52
+ export declare function buildDebrief(receipt: GoalTerminalReceipt, contract: GoalAcceptanceContract | null, observation: DebriefObservation | undefined, records?: readonly GoalFlightEventRecord[]): Debrief;
53
+ export declare function renderDebrief(debrief: Debrief | undefined): string[];
54
+ export {};
@@ -0,0 +1,251 @@
1
+ import { createHash } from "node:crypto";
2
+ import { DEDICATED_WORKER_ROLES, LUNA_FABRIC_WORKER_ROLE } from "./model-routing.js";
3
+ const object = (value) => value !== null && typeof value === "object" ? value : undefined;
4
+ const timeValue = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
5
+ const fingerprint = (value) => createHash("sha256").update(value).digest("hex");
6
+ const workerRoles = new Set([...DEDICATED_WORKER_ROLES, LUNA_FABRIC_WORKER_ROLE]);
7
+ function unionDuration(spans) {
8
+ let end = -1, total = 0;
9
+ for (const span of [...spans].sort((a, b) => a.start - b.start)) {
10
+ total += Math.max(0, span.end - Math.max(span.start, end));
11
+ end = Math.max(end, span.end);
12
+ }
13
+ return total;
14
+ }
15
+ const within = (span, window) => span.start <= span.end && (window === undefined || span.start >= window.start && span.end <= window.end);
16
+ const spanOf = (value) => {
17
+ const time = object(value);
18
+ const start = timeValue(time?.start ?? time?.created), end = timeValue(time?.end ?? time?.completed);
19
+ return start === undefined || end === undefined || end < start ? undefined : { start, end };
20
+ };
21
+ /** Extract only bounded typed metadata during the existing host history traversal. No conversation text survives. */
22
+ export function observeDebriefSession(id, root, messages, window) {
23
+ const session = { id, root, spans: [], checks: [], mutations: [], models: {}, reviews: [], tasks: [], complete: true, timingComplete: true, failed: false };
24
+ const calls = new Set();
25
+ for (const message of messages) {
26
+ const info = object(message.info) ?? message;
27
+ const time = info.time ?? message.time;
28
+ // The root's in-flight terminal answer is intentionally outside pre-terminal accounting.
29
+ if (root && object(time) !== undefined && timeValue(object(time)?.completed) === undefined)
30
+ continue;
31
+ const span = spanOf(time);
32
+ if (span === undefined) {
33
+ session.complete = false;
34
+ session.timingComplete = false;
35
+ continue;
36
+ }
37
+ if (!within(span, window)) {
38
+ if (window !== undefined && span.start < window.end && span.end > window.start) {
39
+ session.complete = false;
40
+ session.timingComplete = false;
41
+ }
42
+ continue;
43
+ }
44
+ session.spans.push(span);
45
+ if (info.error !== undefined)
46
+ session.failed = true;
47
+ for (const raw of Array.isArray(message.parts) ? message.parts : []) {
48
+ const part = object(raw);
49
+ if (part?.type !== "tool")
50
+ continue;
51
+ const call = typeof part.callID === "string" ? part.callID : typeof part.id === "string" ? part.id : undefined;
52
+ if (call === undefined) {
53
+ session.complete = false;
54
+ continue;
55
+ }
56
+ if (calls.has(call))
57
+ continue;
58
+ calls.add(call);
59
+ const state = object(part.state);
60
+ const interval = spanOf(state?.time);
61
+ if (state?.status !== "completed" || interval === undefined) {
62
+ session.failed = true;
63
+ session.complete = false;
64
+ continue;
65
+ }
66
+ if (!within(interval, window)) {
67
+ session.complete = false;
68
+ continue;
69
+ }
70
+ const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
71
+ const args = object(state.input);
72
+ if (tool.startsWith("sortie_") && typeof state.output === "string" && state.output.length <= 32768) {
73
+ try {
74
+ if (object(JSON.parse(state.output))?.status === "denied")
75
+ session.failed = true;
76
+ }
77
+ catch { /* Non-JSON output cannot prove a clean controller operation. */
78
+ session.complete = false;
79
+ }
80
+ }
81
+ if (tool === "task") {
82
+ const metadata = object(state.metadata);
83
+ const child = metadata?.sessionId ?? metadata?.sessionID;
84
+ if (typeof args?.subagent_type !== "string")
85
+ session.complete = false;
86
+ else if (workerRoles.has(args.subagent_type)) {
87
+ if (typeof child === "string")
88
+ session.tasks.push(child);
89
+ else
90
+ session.complete = false;
91
+ }
92
+ }
93
+ if (["edit", "write", "apply_patch"].includes(tool)) {
94
+ const metadata = object(state.metadata);
95
+ const changed = typeof metadata?.diff === "string" && /^[+-](?![+-])/mu.test(metadata.diff) ||
96
+ Array.isArray(metadata?.files) && metadata.files.some((file) => {
97
+ const entry = object(file);
98
+ return typeof entry?.diff === "string" && /^[+-](?![+-])/mu.test(entry.diff);
99
+ });
100
+ if (changed)
101
+ session.mutations.push(interval);
102
+ else
103
+ session.complete = false;
104
+ }
105
+ if (tool === "bash" && typeof args?.command === "string") {
106
+ const metadata = object(state.metadata);
107
+ const exit = metadata?.exit ?? metadata?.exitCode;
108
+ if (typeof exit === "number" && Number.isInteger(exit)) {
109
+ session.checks.push({ ...interval, command: fingerprint(args.command), passed: exit === 0 });
110
+ if (exit !== 0)
111
+ session.failed = true;
112
+ }
113
+ else
114
+ session.complete = false;
115
+ }
116
+ // Only accepted controller outputs prove a review. Reviewer prose and tool input cannot do so.
117
+ if (tool === "sortie_accept_luna_fabric_candidate" && typeof state.output === "string" && state.output.length <= 32768) {
118
+ try {
119
+ const output = object(JSON.parse(state.output));
120
+ const fabric = object(output?.fabric);
121
+ const review = object(fabric?.review);
122
+ if (output?.status === "accepted" && review?.status === "pass")
123
+ session.reviews.push({ at: interval.end, status: "PASS" });
124
+ else if (output?.status === "accepted" && review?.status === "skip")
125
+ session.reviews.push({ at: interval.end, status: "WAIVED" });
126
+ else if (output?.status === "rejected" && review?.status === "fail")
127
+ session.reviews.push({ at: interval.end, status: "FAIL" });
128
+ }
129
+ catch { /* Unknown evidence stays unknown. */ }
130
+ }
131
+ }
132
+ if (session.spans.length > 4096 || calls.size > 4096) {
133
+ session.complete = false;
134
+ session.timingComplete = false;
135
+ break;
136
+ }
137
+ }
138
+ return session;
139
+ }
140
+ export function buildDebrief(receipt, contract, observation, records) {
141
+ const empty = { pack: null, mix: null, validation: "未確認", review: "未確認", traits: [] };
142
+ if (observation === undefined)
143
+ return empty;
144
+ const sessions = observation.sessions;
145
+ const complete = observation.complete && sessions.every((session) => session.complete);
146
+ const timingComplete = observation.complete && sessions.every((session) => session.timingComplete);
147
+ const children = sessions.filter((session) => !session.root && session.spans.length > 0);
148
+ const counts = new Map(), totals = new Map();
149
+ let usageComplete = timingComplete;
150
+ for (const session of sessions) {
151
+ for (const [model, tokens] of Object.entries(session.models)) {
152
+ if (tokens === null || model === "未分類")
153
+ usageComplete = false;
154
+ else
155
+ totals.set(model, (totals.get(model) ?? 0) + tokens);
156
+ }
157
+ }
158
+ for (const session of children) {
159
+ const models = Object.keys(session.models);
160
+ const label = models.length === 0 ? "未分類" : models.length === 1 ? models[0] : "混成";
161
+ counts.set(label, (counts.get(label) ?? 0) + 1);
162
+ }
163
+ const total = [...totals.values()].reduce((sum, tokens) => sum + tokens, 0);
164
+ const commands = new Set(contract?.criteria.flatMap((criterion) => criterion.validation_command === undefined ? [] : [fingerprint(criterion.validation_command)]));
165
+ const checks = sessions.flatMap((session) => session.checks.filter((check) => commands.has(check.command))).sort((a, b) => a.end - b.end);
166
+ const latest = new Map();
167
+ for (const check of checks)
168
+ latest.set(check.command, check);
169
+ const validation = [...latest.values()].some((check) => !check.passed) ? "FAIL"
170
+ : complete && commands.size > 0 && latest.size === commands.size ? "PASS" : "未確認";
171
+ const reviews = sessions.flatMap((session) => session.reviews).sort((a, b) => a.at - b.at);
172
+ const traits = [];
173
+ const spans = children.flatMap((session) => session.spans.filter((span) => span.end > span.start)
174
+ .map((span) => ({ ...span, session: session.id }))).sort((a, b) => a.start - b.start);
175
+ let furthest;
176
+ for (const span of spans) {
177
+ if (furthest !== undefined && span.session !== furthest.session && span.start < furthest.end) {
178
+ traits.push("連携作戦");
179
+ break;
180
+ }
181
+ if (furthest === undefined || span.end > furthest.end)
182
+ furthest = span;
183
+ }
184
+ // Same child and exact declared validator: never combine a sibling's failure with another candidate's success.
185
+ const recovered = children.some((session) => {
186
+ const failures = new Map();
187
+ const mutations = [...session.mutations].sort((a, b) => a.end - b.end);
188
+ let index = 0, lastEditStart = -1;
189
+ for (const check of [...session.checks].sort((a, b) => a.start - b.start)) {
190
+ while (index < mutations.length && mutations[index].end <= check.start) {
191
+ lastEditStart = Math.max(lastEditStart, mutations[index++].start);
192
+ }
193
+ if (!commands.has(check.command))
194
+ continue;
195
+ if (!check.passed)
196
+ failures.set(check.command, check.end);
197
+ else if (failures.has(check.command) && lastEditStart >= failures.get(check.command))
198
+ return true;
199
+ }
200
+ return false;
201
+ });
202
+ if (complete && receipt.status === "succeeded" && validation === "PASS" && recovered)
203
+ traits.push("修正から復帰");
204
+ // Only the complete, terminal-matched goal ledger can prove absence of retries; old/missing ledgers cannot.
205
+ const events = records?.filter(({ event }) => event.goal_id === receipt.goal_id && event.kind !== "goal.reported").map(({ event }) => event);
206
+ const reserved = events?.filter((event) => event.kind === "dispatch.reserved") ?? [];
207
+ const settled = events?.filter((event) => event.kind === "unit.settled") ?? [];
208
+ const tasks = sessions.flatMap((session) => session.tasks);
209
+ const terminal = events?.at(-1);
210
+ const fullLedger = events?.[0]?.kind === "goal.accepted" && terminal?.kind === "goal.terminal" &&
211
+ terminal.receipt.terminal_revision === receipt.terminal_revision && terminal.receipt.status === receipt.status;
212
+ const firstPassEligible = complete && fullLedger && receipt.status === "succeeded" && commands.size > 0 &&
213
+ validation === "PASS" && reserved.length > 0 && reserved.length === settled.length && tasks.length === reserved.length;
214
+ if (firstPassEligible &&
215
+ reserved.length === settled.length && tasks.length === reserved.length && new Set(tasks).size === tasks.length &&
216
+ settled.every((event) => event.disposition === "succeeded" && event.result_class === "acceptance") &&
217
+ !events?.some((event) => event.kind === "goal.replanned" || event.kind === "goal.user-continued" || event.kind === "goal.revised" ||
218
+ (event.kind === "validation.admission" && event.decision === "DENY") || (event.kind === "validation.settled" && event.outcome !== "passed")) &&
219
+ sessions.every((session) => !session.failed && new Set(session.checks.map((check) => check.command)).size === session.checks.length))
220
+ traits.push("一発完遂");
221
+ return { pack: timingComplete ? [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([model, count]) => ({ model, count })) : null,
222
+ mix: usageComplete && total > 0 ? [...totals].sort(([a], [b]) => a.localeCompare(b)).map(([model, tokens]) => ({ model, tokens, percent: tokens / total * 100 })) : null,
223
+ validation, review: complete ? reviews.filter((review) => !sessions.some((session) => session.mutations.some((edit) => edit.end > review.at))).at(-1)?.status ?? "未確認" : "未確認", traits, firstPassEligible,
224
+ ...(timingComplete ? { overlap: { workerMilliseconds: children.reduce((sum, child) => sum + unionDuration(child.spans), 0),
225
+ wallMilliseconds: unionDuration(spans) } } : {}) };
226
+ }
227
+ const label = (text) => text.replace(/[\r\n\t]/gu, " ").replace(/[\\`*_{}\[\]()<>!|]/gu, "").slice(0, 120);
228
+ export function renderDebrief(debrief) {
229
+ const pack = debrief?.pack == null ? null : [...debrief.pack].sort((a, b) => b.count - a.count || a.model.localeCompare(b.model));
230
+ const packVisible = pack?.slice(0, 4) ?? [];
231
+ if (pack !== null && pack.length > 4)
232
+ packVisible.push({ model: "その他", count: pack.slice(4).reduce((sum, entry) => sum + entry.count, 0) });
233
+ const mix = debrief?.mix == null ? null : [...debrief.mix].sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model));
234
+ const visible = mix?.slice(0, 4) ?? [];
235
+ if (mix !== null && mix.length > 4)
236
+ visible.push({ model: "その他", tokens: mix.slice(4).reduce((sum, entry) => sum + entry.tokens, 0),
237
+ percent: mix.slice(4).reduce((sum, entry) => sum + entry.percent, 0) });
238
+ const bars = (percent) => {
239
+ const filled = Math.max(0, Math.min(10, Math.round(percent / 10)));
240
+ return "█".repeat(filled) + "░".repeat(10 - filled);
241
+ };
242
+ return [
243
+ `**🐕 出撃隊:** ${pack === null ? "計測不可" : pack.length === 0 ? "出撃なし" : packVisible.map((entry) => `${label(entry.model)} ×${entry.count}`).join(" · ")}`,
244
+ `**モデル別token内訳:** ${mix === null ? "計測不可" : ""}`,
245
+ ...visible.map((entry) => `**↳** ${label(entry.model)} \`${bars(entry.percent)}\` ${entry.percent.toFixed(1)}%`),
246
+ `**実行重複率:** ${debrief?.overlap !== undefined && debrief.overlap.wallMilliseconds > 0
247
+ ? `${(debrief.overlap.workerMilliseconds / debrief.overlap.wallMilliseconds).toFixed(2)}×(worker区間・速度倍率ではありません)` : "計測不可"}`,
248
+ `**確認:** 対象検証 ${debrief?.validation ?? "未確認"} · 直近Review ${debrief?.review === "WAIVED" ? "免除" : debrief?.review ?? "未確認"}`,
249
+ ...(debrief?.traits.length ? [`**🏅 今回の戦績:** ${debrief.traits.join(" · ")}`] : []),
250
+ ];
251
+ }
@@ -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.80-review-evidence-v1";
10
+ readonly version: "0.3.81-mission-debrief-v1";
11
11
  readonly installPath: "agent/dog-coordinator.md";
12
12
  readonly content: `---
13
13
  description: Canonical MkII coordinator packaged by Sortie-dogs
@@ -1402,8 +1402,14 @@ END_COMMIT_SCOPE_FIXTURE
1402
1402
  At each checkpoint and terminal return, preserve concise proof internally. The user-facing terminal
1403
1403
  return MUST begin with its conclusion: no plan, progress, assessment, Evidence heading, or preamble.
1404
1404
  Use exactly one of DONE, INTERRUPTED, BLOCKED, or NEED_DECISION with one status emoji and a short
1405
- Japanese conclusion. Then render Japanese \u5909\u66F4\u70B9, \u78BA\u8A8D\u7D50\u679C, and \u6B21 paragraphs without bullets or extra
1406
- emoji. The plugin injects measured Speed, Cost, and \u9054\u6210 paragraphs. Do not estimate or fabricate them.
1405
+ Japanese conclusion. Then render Japanese \u5909\u66F4\u70B9, \u78BA\u8A8D\u7D50\u679C, and \u6B21 paragraphs without bullets or decorative
1406
+ emoji. The plugin injects measured Speed, Cost, and \u9054\u6210 paragraphs in a Japanese mission debrief card,
1407
+ with one fixed icon per section, observed pack/model usage, validation/review, and evidence-backed traits.
1408
+ Its Markdown token bars and PACK RECORD summarize retained project goals, with coverage and team titles;
1409
+ they never imply lifetime history, XP, levels, unmeasured savings, or a leaderboard rank.
1410
+ Never write the card, its metrics, or its badges yourself. Do not estimate or fabricate them.
1411
+ Use \u4EFB\u52D9\u5B8C\u4E86 for DONE, \u4E2D\u65AD\u5E30\u9084\uFF08\u672A\u5B8C\u4E86\uFF09 for INTERRUPTED, \u5916\u90E8\u8981\u56E0\u3067\u5F85\u6A5F\uFF08\u672A\u5B8C\u4E86\uFF09 for BLOCKED,
1412
+ and \u6307\u793A\u5F85\u3061\uFF08\u672A\u5B8C\u4E86\uFF09 for NEED_DECISION; preserve the machine status token and first-line checkpoint.
1407
1413
  Never render a user-facing Evidence heading, <details> block, evidence reference, internal reason code,
1408
1414
  ledger key, or raw status. Keep ordered command/exit/fingerprint history, manifests, evidence refs,
1409
1415
  review proof, and terminal receipt append-only in their internal typed ledger and host logs. A concise
@@ -1423,10 +1429,10 @@ TERMINAL_STATUS_SEMANTICS_FIXTURE
1423
1429
  END_TERMINAL_STATUS_SEMANTICS_FIXTURE
1424
1430
 
1425
1431
  RUNTIME_ASSET_VERSION_SYNC_FIXTURE
1426
- runtime_version: 0.3.80-review-evidence-v1
1432
+ runtime_version: 0.3.81-mission-debrief-v1
1427
1433
  shared_marker: src/asset-version.ts
1428
- packaged_expectation: test/plugin-loader.test.ts uses 0.3.80-review-evidence-v1
1429
- initialize_expectation: test/initialize.test.ts uses 0.3.80-review-evidence-v1
1434
+ packaged_expectation: test/plugin-loader.test.ts uses 0.3.81-mission-debrief-v1
1435
+ initialize_expectation: test/initialize.test.ts uses 0.3.81-mission-debrief-v1
1430
1436
  rule: runtime asset versions, shared marker, packaged expectation, and initialize expectation change together
1431
1437
  END_RUNTIME_ASSET_VERSION_SYNC_FIXTURE
1432
1438
 
@@ -1449,32 +1455,32 @@ END_INTERNAL_TERMINAL_PROOF_FIXTURE
1449
1455
  `;
1450
1456
  }, {
1451
1457
  readonly name: "dog-worker";
1452
- readonly version: "0.3.80-review-evidence-v1";
1458
+ readonly version: "0.3.81-mission-debrief-v1";
1453
1459
  readonly installPath: "agent/dog-worker.md";
1454
1460
  readonly content: string;
1455
1461
  }, {
1456
1462
  readonly name: "dog-luna-worker";
1457
- readonly version: "0.3.80-review-evidence-v1";
1463
+ readonly version: "0.3.81-mission-debrief-v1";
1458
1464
  readonly installPath: "agent/dog-luna-worker.md";
1459
1465
  readonly content: string;
1460
1466
  }, {
1461
1467
  readonly name: "dog-scout";
1462
- readonly version: "0.3.80-review-evidence-v1";
1468
+ readonly version: "0.3.81-mission-debrief-v1";
1463
1469
  readonly installPath: "agent/dog-scout.md";
1464
1470
  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";
1465
1471
  }, {
1466
1472
  readonly name: "dog-reviewer";
1467
- readonly version: "0.3.80-review-evidence-v1";
1473
+ readonly version: "0.3.81-mission-debrief-v1";
1468
1474
  readonly installPath: "agent/dog-reviewer.md";
1469
1475
  readonly content: "---\ndescription: Independent source reviewer for dog-coordinator\nmode: subagent\npermission:\n bash: deny\n webfetch: deny\n task: deny\n question: deny\n glob: deny\n grep: deny\n edit: deny\n list: deny\n write: deny\n patch: deny\n read: deny\ntools:\n bash: false\n webfetch: false\n task: false\n question: false\n glob: false\n grep: false\n edit: false\n list: false\n write: false\n patch: false\n read: false\n---\n# dog-reviewer\n\nAccept only one bounded SourceReview request from dog-coordinator after canonical\nvalidation for one high-risk candidate. Review only the supplied acceptance criteria, exact\nmanifest, changedLogicSummary, supplied changed-code excerpts, and validation evidence. Confirm every acceptance item explicitly\nmaps to at least one changedLogicSummary entry and assess that changed logic against the mapped\nacceptance item. Missing or incomplete coverage is a concrete finding, never PASS.\nRequire one indexed acceptance[i] -> changedLogicSummary[j] mapping line per acceptance item and\nreject a missing index or unequal mapping count before assessing the changed logic.\nDo not request raw logs or full source files, review low-risk candidates, expand scope, or dispatch\nanother agent.\nTreat those supplied fields as the complete bounded SourceReview artifact; use only that artifact and invoke no tools.\nDo not infer that a branch or exemption is absent from source because a prose summary omits it.\nIf the supplied excerpts do not establish a claim, report an evidence gap and request the exact\nbranch/helper excerpt in the next artifact; do not prescribe a source fix for an unproven defect.\n\nReturn one concise PASS or concrete-finding response only to dog-coordinator before the\ncoordinator commit. Write every finding, evidence, and required-fix sentence in the language the\nsupplied artifact uses for its own prose, one statement per line, and keep verdict values,\nidentifiers, paths, and commands verbatim. Do not implement, remediate, resolve blockers, edit,\nstage, commit, or become user-facing. Remain host-routed: do not require or identify a provider, vendor, model, variant,\nor transport.\n";
1470
1476
  }, {
1471
1477
  readonly name: "dog-advisor";
1472
- readonly version: "0.3.80-review-evidence-v1";
1478
+ readonly version: "0.3.81-mission-debrief-v1";
1473
1479
  readonly installPath: "agent/dog-advisor.md";
1474
1480
  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";
1475
1481
  }, {
1476
1482
  readonly name: "sortie";
1477
- readonly version: "0.3.80-review-evidence-v1";
1483
+ readonly version: "0.3.81-mission-debrief-v1";
1478
1484
  readonly installPath: "command/sortie.md";
1479
1485
  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";
1480
1486
  }];
@@ -1,5 +1,5 @@
1
1
  import { GOAL_DECLARATION_FORMAT } from "./core/goal-declaration-format.js";
2
- const ASSET_VERSION = "0.3.80-review-evidence-v1";
2
+ const ASSET_VERSION = "0.3.81-mission-debrief-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";
@@ -1609,8 +1609,14 @@ END_COMMIT_SCOPE_FIXTURE
1609
1609
  At each checkpoint and terminal return, preserve concise proof internally. The user-facing terminal
1610
1610
  return MUST begin with its conclusion: no plan, progress, assessment, Evidence heading, or preamble.
1611
1611
  Use exactly one of DONE, INTERRUPTED, BLOCKED, or NEED_DECISION with one status emoji and a short
1612
- Japanese conclusion. Then render Japanese 変更点, 確認結果, and 次 paragraphs without bullets or extra
1613
- emoji. The plugin injects measured Speed, Cost, and 達成 paragraphs. Do not estimate or fabricate them.
1612
+ Japanese conclusion. Then render Japanese 変更点, 確認結果, and 次 paragraphs without bullets or decorative
1613
+ emoji. The plugin injects measured Speed, Cost, and 達成 paragraphs in a Japanese mission debrief card,
1614
+ with one fixed icon per section, observed pack/model usage, validation/review, and evidence-backed traits.
1615
+ Its Markdown token bars and PACK RECORD summarize retained project goals, with coverage and team titles;
1616
+ they never imply lifetime history, XP, levels, unmeasured savings, or a leaderboard rank.
1617
+ Never write the card, its metrics, or its badges yourself. Do not estimate or fabricate them.
1618
+ Use 任務完了 for DONE, 中断帰還(未完了) for INTERRUPTED, 外部要因で待機(未完了) for BLOCKED,
1619
+ and 指示待ち(未完了) for NEED_DECISION; preserve the machine status token and first-line checkpoint.
1614
1620
  Never render a user-facing Evidence heading, <details> block, evidence reference, internal reason code,
1615
1621
  ledger key, or raw status. Keep ordered command/exit/fingerprint history, manifests, evidence refs,
1616
1622
  review proof, and terminal receipt append-only in their internal typed ledger and host logs. A concise
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",