sortie-dogs 0.9.3 → 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.3](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.3)
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,
@@ -4,6 +4,15 @@ export declare class FastLaneDeniedError extends Error {
4
4
  readonly code: FastLaneDenialCode;
5
5
  constructor(code: FastLaneDenialCode);
6
6
  }
7
+ export interface WorkerAccounting {
8
+ readonly totalWorkerDispatches: number;
9
+ readonly workerDispatches: number;
10
+ readonly workerInFlight: boolean;
11
+ readonly workerResumeUsed: boolean;
12
+ readonly workerResumeSessionID?: string;
13
+ readonly workerResumeTaskID?: string;
14
+ readonly workerTaskID?: string;
15
+ }
7
16
  export interface FastLaneToolOptions {
8
17
  readonly readonlyDiagnosisAuthorized?: boolean;
9
18
  readonly consultationFallbackAuthorized?: boolean;
@@ -25,5 +34,8 @@ export declare class FastLaneController {
25
34
  continuationQueued(sessionID: string): void;
26
35
  authorizeRecoverableWorkerResume(sessionID: string, taskID: string, childSessionID: string): boolean;
27
36
  workerCompleted(sessionID: string): void;
37
+ /** Snapshot worker accounting so a later denial in the same dispatch cannot leak the slot. */
38
+ snapshotWorkerAccounting(sessionID: string): WorkerAccounting | undefined;
39
+ restoreWorkerAccounting(sessionID: string, snapshot: WorkerAccounting | undefined): void;
28
40
  beforeTool(sessionID: string, tool: string, args: unknown, options?: FastLaneToolOptions): string | undefined;
29
41
  }
@@ -199,6 +199,32 @@ export class FastLaneController {
199
199
  if (state !== undefined)
200
200
  state.workerInFlight = false;
201
201
  }
202
+ /** Snapshot worker accounting so a later denial in the same dispatch cannot leak the slot. */
203
+ snapshotWorkerAccounting(sessionID) {
204
+ const state = this.sessions.get(sessionID);
205
+ if (state === undefined)
206
+ return undefined;
207
+ return { totalWorkerDispatches: state.totalWorkerDispatches, workerDispatches: state.workerDispatches,
208
+ workerInFlight: state.workerInFlight, workerResumeUsed: state.workerResumeUsed,
209
+ workerResumeSessionID: state.workerResumeSessionID, workerResumeTaskID: state.workerResumeTaskID,
210
+ workerTaskID: state.workerTaskID };
211
+ }
212
+ restoreWorkerAccounting(sessionID, snapshot) {
213
+ const state = this.sessions.get(sessionID);
214
+ if (state === undefined || snapshot === undefined)
215
+ return;
216
+ state.totalWorkerDispatches = snapshot.totalWorkerDispatches;
217
+ state.workerDispatches = snapshot.workerDispatches;
218
+ state.workerInFlight = snapshot.workerInFlight;
219
+ state.workerResumeUsed = snapshot.workerResumeUsed;
220
+ for (const [key, value] of Object.entries({ workerResumeSessionID: snapshot.workerResumeSessionID,
221
+ workerResumeTaskID: snapshot.workerResumeTaskID, workerTaskID: snapshot.workerTaskID })) {
222
+ if (value === undefined)
223
+ delete state[key];
224
+ else
225
+ state[key] = value;
226
+ }
227
+ }
202
228
  beforeTool(sessionID, tool, args, options = {}) {
203
229
  const state = this.sessions.get(sessionID);
204
230
  if (state === undefined) {
@@ -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;
@@ -6231,45 +6284,54 @@ export const SortieDogsPlugin = async (input, options) => {
6231
6284
  // therefore be repaired by a corrected Task call in this same coordinator turn.
6232
6285
  await bindGoalDeclaration(toolInput.sessionID, typeof output.args.prompt === "string" ? output.args.prompt : "");
6233
6286
  }
6287
+ // Accounting is provisional until this dispatch is fully admitted. A later denial such as a
6288
+ // budget stop must release the serial slot, or no worker could be dispatched or resumed again.
6289
+ const workerAccounting = fastLane.snapshotWorkerAccounting(toolInput.sessionID);
6234
6290
  const resumedWorkerSessionID = fastLane.beforeTool(toolInput.sessionID, toolInput.tool, output.args, {
6235
6291
  readonlyDiagnosisAuthorized: readonlyDiagnosis,
6236
6292
  consultationFallbackAuthorized,
6237
6293
  parallelWorkerAlreadyBound,
6238
6294
  parallelWorkerAuthorized,
6239
6295
  });
6240
- if (toolInput.tool === "task" && taskRole !== undefined && IMPLEMENTATION_AGENTS.has(taskRole) &&
6241
- isRecord(output.args)) {
6242
- await reserveGoalDispatch(toolInput.sessionID, toolInput.callID, typeof output.args.prompt === "string" ? output.args.prompt : "");
6243
- }
6244
- if (validatedRootAcceptance !== undefined && reservedParallelDescriptor === undefined) {
6245
- rootAcceptanceContinuity.delete(toolInput.sessionID);
6246
- rootAcceptanceContinuity.set(toolInput.sessionID, validatedRootAcceptance);
6247
- while (rootAcceptanceContinuity.size > ACTIVE_SESSION_CACHE.maximum) {
6248
- rootAcceptanceContinuity.delete(rootAcceptanceContinuity.keys().next().value);
6296
+ try {
6297
+ if (toolInput.tool === "task" && taskRole !== undefined && IMPLEMENTATION_AGENTS.has(taskRole) &&
6298
+ isRecord(output.args)) {
6299
+ await reserveGoalDispatch(toolInput.sessionID, toolInput.callID, typeof output.args.prompt === "string" ? output.args.prompt : "");
6249
6300
  }
6250
- }
6251
- if (resumedWorkerSessionID !== undefined) {
6252
- const recoverableParallel = parallelRecoverableChildren.get(resumedWorkerSessionID);
6253
- if (recoverableParallel !== undefined) {
6254
- parallelCalls.set(toolInput.callID, recoverableParallel);
6255
- parallelRecoverableChildren.delete(resumedWorkerSessionID);
6301
+ if (validatedRootAcceptance !== undefined && reservedParallelDescriptor === undefined) {
6302
+ rootAcceptanceContinuity.delete(toolInput.sessionID);
6303
+ rootAcceptanceContinuity.set(toolInput.sessionID, validatedRootAcceptance);
6304
+ while (rootAcceptanceContinuity.size > ACTIVE_SESSION_CACHE.maximum) {
6305
+ rootAcceptanceContinuity.delete(rootAcceptanceContinuity.keys().next().value);
6306
+ }
6256
6307
  }
6257
- recoverableWorkerChildren.delete(resumedWorkerSessionID);
6258
- }
6259
- if (toolInput.tool === "task" && taskRole !== undefined && IMPLEMENTATION_AGENTS.has(taskRole)) {
6260
- if (readonlyDiagnosis && isRecord(output.args))
6261
- await claimDiagnosisTask(toolInput.sessionID, toolInput.callID, output.args, true);
6262
- bootstrapRequired = false;
6263
- bootstrapCompleted = true;
6264
- bootstrapIdleWarnings.delete(toolInput.sessionID);
6265
- beginCoordinatorTask(toolInput.sessionID, toolInput.callID);
6266
- if (reservedParallelDescriptor !== undefined) {
6267
- parallelCalls.set(toolInput.callID, {
6268
- ownerRoot: toolInput.sessionID,
6269
- descriptor: reservedParallelDescriptor,
6270
- completionCallID: toolInput.callID,
6271
- });
6308
+ if (resumedWorkerSessionID !== undefined) {
6309
+ const recoverableParallel = parallelRecoverableChildren.get(resumedWorkerSessionID);
6310
+ if (recoverableParallel !== undefined) {
6311
+ parallelCalls.set(toolInput.callID, recoverableParallel);
6312
+ parallelRecoverableChildren.delete(resumedWorkerSessionID);
6313
+ }
6314
+ recoverableWorkerChildren.delete(resumedWorkerSessionID);
6272
6315
  }
6316
+ if (toolInput.tool === "task" && taskRole !== undefined && IMPLEMENTATION_AGENTS.has(taskRole)) {
6317
+ if (readonlyDiagnosis && isRecord(output.args))
6318
+ await claimDiagnosisTask(toolInput.sessionID, toolInput.callID, output.args, true);
6319
+ bootstrapRequired = false;
6320
+ bootstrapCompleted = true;
6321
+ bootstrapIdleWarnings.delete(toolInput.sessionID);
6322
+ beginCoordinatorTask(toolInput.sessionID, toolInput.callID);
6323
+ if (reservedParallelDescriptor !== undefined) {
6324
+ parallelCalls.set(toolInput.callID, {
6325
+ ownerRoot: toolInput.sessionID,
6326
+ descriptor: reservedParallelDescriptor,
6327
+ completionCallID: toolInput.callID,
6328
+ });
6329
+ }
6330
+ }
6331
+ }
6332
+ catch (error) {
6333
+ fastLane.restoreWorkerAccounting(toolInput.sessionID, workerAccounting);
6334
+ throw error;
6273
6335
  }
6274
6336
  return;
6275
6337
  }
@@ -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 {};