sortie-dogs 0.9.4 → 0.9.6

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.6](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.9.6)
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";
@@ -112,6 +113,7 @@ export type GoalFlightEvent = (GoalEventBase & {
112
113
  readonly delivery: GoalDeliveryMode;
113
114
  readonly budget: GoalBudget;
114
115
  readonly acceptance_contract: GoalAcceptanceContract | null;
116
+ readonly reset_no_progress?: boolean;
115
117
  }) | (GoalEventBase & {
116
118
  readonly kind: "ticket.issued";
117
119
  readonly ticket_id: string;
@@ -177,6 +179,10 @@ export type GoalFlightEvent = (GoalEventBase & {
177
179
  readonly kind: "goal.terminal";
178
180
  readonly goal_id: string;
179
181
  readonly receipt: GoalTerminalReceipt;
182
+ }) | (GoalEventBase & {
183
+ readonly kind: "goal.reported";
184
+ readonly goal_id: string;
185
+ readonly report: GoalReport;
180
186
  });
181
187
  export interface GoalFlightEventRecord {
182
188
  readonly sequence: number;
@@ -106,11 +106,17 @@ function addUnique(values, value) {
106
106
  }
107
107
  export function reduceGoalFlight(records) {
108
108
  let state = initial();
109
+ let replanRevision = null;
110
+ let legacyResetRevision = false;
109
111
  let previous = null;
110
112
  for (const [index, record] of records.entries()) {
111
113
  requireState(record.sequence === index + 1 && record.previous_hash === previous && HASH.test(record.event_hash), "invalid", "Goal ledger chain is malformed.");
112
114
  previous = record.event_hash;
113
115
  const event = record.event;
116
+ // Optional presentation records never participate in execution replay. Writers validate them;
117
+ // older/newer telemetry definitions must not prevent an existing goal from resuming.
118
+ if (event.kind === "goal.reported")
119
+ continue;
114
120
  requireState(instant(event.at), "invalid", "Goal event timestamp is invalid.");
115
121
  if (event.kind === "goal.accepted") {
116
122
  requireState(state.goal_id === null || state.phase === "terminal", "transition", "An active goal already owns this root.");
@@ -122,6 +128,8 @@ export function reduceGoalFlight(records) {
122
128
  latest_user_message_id: event.origin_user_message_id, selected_agent: event.selected_agent,
123
129
  delivery: event.delivery, budget: event.budget, acceptance_contract: event.acceptance_contract, phase: "active",
124
130
  session_ids: [event.origin_session_id] };
131
+ replanRevision = null;
132
+ legacyResetRevision = false;
125
133
  continue;
126
134
  }
127
135
  requireState(state.goal_id !== null && event.goal_id === state.goal_id, "transition", "Goal identity mismatch.");
@@ -134,13 +142,22 @@ export function reduceGoalFlight(records) {
134
142
  else if (event.kind === "goal.revised") {
135
143
  requireState(state.phase !== "terminal" && event.revision === state.revision + 1 && event.scope_epoch === state.scope_epoch + 1 &&
136
144
  HASH.test(event.acceptance_fingerprint) && event.budget.max_units >= state.consumed_units &&
137
- validAcceptanceContract(event.acceptance_contract), "transition", "Scope revision is stale or resets consumed budget.");
145
+ event.budget.max_units >= state.validation_budget.consumed &&
146
+ validAcceptanceContract(event.acceptance_contract) &&
147
+ (event.reset_no_progress === undefined || typeof event.reset_no_progress === "boolean"), "transition", "Scope revision is stale or resets consumed budget.");
138
148
  state = { ...state, revision: event.revision, scope_epoch: event.scope_epoch,
139
149
  acceptance_fingerprint: event.acceptance_fingerprint, latest_user_message_id: event.origin_user_message_id,
140
150
  selected_agent: event.selected_agent, delivery: event.delivery, budget: event.budget,
151
+ validation_budget: { ...state.validation_budget,
152
+ limit: state.validation_budget.limit === null ? null : event.budget.max_units },
141
153
  acceptance_contract: event.acceptance_contract,
142
154
  session_ids: addUnique(state.session_ids, event.session_id), phase: "active", stop_reason: null,
143
- tickets: [] };
155
+ receipt: null, tickets: [], ...(event.reset_no_progress === true
156
+ ? { no_progress_results: 0, replan_required: false, replan_used: false }
157
+ : {}) };
158
+ if (event.reset_no_progress === true)
159
+ replanRevision = null;
160
+ legacyResetRevision = event.reset_no_progress === undefined;
144
161
  }
145
162
  else if (event.kind === "ticket.issued") {
146
163
  requireState(state.phase === "active" && event.revision === state.revision && event.scope_epoch === state.scope_epoch &&
@@ -158,6 +175,13 @@ export function reduceGoalFlight(records) {
158
175
  session_ids: addUnique(state.session_ids, event.session_id) };
159
176
  }
160
177
  else if (event.kind === "dispatch.reserved") {
178
+ // A short-lived pre-marker runtime reset no-progress on revision but persisted no flag.
179
+ // A later accepted dispatch proves that reset; older revisions followed by an explicit
180
+ // replan retain their original semantics.
181
+ if (legacyResetRevision && state.replan_required && state.replan_used && replanRevision !== null && state.revision > replanRevision) {
182
+ state = { ...state, no_progress_results: 0, replan_required: false, replan_used: false };
183
+ replanRevision = null;
184
+ }
161
185
  requireState(state.phase === "active" && !state.replan_required, "transition", "Dispatch requires terminal handling or one bounded replan.");
162
186
  requireState(state.budget !== null && state.consumed_units + state.outstanding_reservations.length < state.budget.max_units, "budget", "Goal unit budget exhausted.");
163
187
  requireState(state.budget.time_ms === null || (state.consumed_time_ms !== null && state.consumed_time_ms < state.budget.time_ms), "budget", "Goal time budget is exhausted or usage is unknown.");
@@ -215,6 +239,7 @@ export function reduceGoalFlight(records) {
215
239
  else if (event.kind === "goal.replanned") {
216
240
  requireState(state.phase === "active" && state.replan_required && !state.replan_used && event.revision === state.revision, "transition", "Bounded replan is unavailable.");
217
241
  state = { ...state, replan_used: true, replan_required: false, no_progress_results: 0 };
242
+ replanRevision = event.revision;
218
243
  }
219
244
  else if (event.kind === "goal.terminal") {
220
245
  const allAccepted = state.acceptance_contract !== null && state.acceptance_contract.criteria.every(({ criterion_id }) => state.satisfied_criteria.includes(criterion_id));
@@ -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,
@@ -472,13 +472,16 @@ export class ScopeLeaseRegistry {
472
472
  await rmdir(this.lockPath).catch(() => undefined);
473
473
  return;
474
474
  }
475
- if (names.length !== 1)
475
+ const owners = names.filter((name) => OWNER_FILE.test(name));
476
+ if (owners.length !== 1 || names.length > MAX_TEMP_CLEANUP + 1 ||
477
+ names.some((name) => name !== owners[0] && !TEMP_FILE.test(name)))
476
478
  return;
477
- const match = OWNER_FILE.exec(names[0]);
479
+ const ownerName = owners[0];
480
+ const match = OWNER_FILE.exec(ownerName);
478
481
  if (match === null || !UUID.test(match[1]))
479
482
  return;
480
483
  const owner = match[1];
481
- const ownerPath = join(this.lockPath, names[0]);
484
+ const ownerPath = join(this.lockPath, ownerName);
482
485
  const before = await this.mutexSnapshot(ownerPath);
483
486
  if (before === undefined || Date.now() - before.mtimeMs <= this.options.mutexStaleMs || before.content !== owner)
484
487
  return;
@@ -490,7 +493,7 @@ export class ScopeLeaseRegistry {
490
493
  catch {
491
494
  return;
492
495
  }
493
- const movedOwnerPath = join(quarantine, names[0]);
496
+ const movedOwnerPath = join(quarantine, ownerName);
494
497
  const after = await this.mutexSnapshot(movedOwnerPath);
495
498
  if (after === undefined || !this.sameMutexSnapshot(before, after)) {
496
499
  // The whole-directory rename already fenced this owner. Keep its quarantine until a later
@@ -69,5 +69,5 @@ export declare function canonicalManifestWriteScopes(project: ProjectPaths, mani
69
69
  export declare function canonicalManifestReadScopes(project: ProjectPaths, manifest: OperationManifest): Promise<readonly string[]>;
70
70
  export declare function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean;
71
71
  export declare function createProjectPaths(rootCandidate: string): Promise<ProjectPaths>;
72
- export declare function createWriteGate(project: ProjectPaths, value: unknown): Promise<WriteGate>;
72
+ export declare function createWriteGate(project: ProjectPaths, value: unknown, toolDirectory?: string): Promise<WriteGate>;
73
73
  export {};
@@ -964,7 +964,7 @@ export async function createProjectPaths(rootCandidate) {
964
964
  },
965
965
  };
966
966
  }
967
- export async function createWriteGate(project, value) {
967
+ export async function createWriteGate(project, value, toolDirectory = project.root) {
968
968
  const validated = validateOperationManifestSchema(value);
969
969
  if (!validated.ok)
970
970
  throw new WriteDeniedError("manifest-unavailable", "<unknown>");
@@ -1189,8 +1189,20 @@ export async function createWriteGate(project, value) {
1189
1189
  }
1190
1190
  throw new WriteDeniedError("path-required", "<missing-path>");
1191
1191
  }
1192
- for (const path of extracted.paths)
1193
- await checkPath(path);
1192
+ // OpenCode resolves native file-tool paths against its instance directory, not the
1193
+ // manifest root. Check the same destination without rewriting the tool arguments.
1194
+ const nativeFileTool = /^(?:write|edit)(?:$|[_-])/iu.test(_input.tool) || /patch/iu.test(_input.tool);
1195
+ for (const path of extracted.paths) {
1196
+ if (nativeFileTool) {
1197
+ try {
1198
+ normalizeManifestPath(path);
1199
+ }
1200
+ catch (error) {
1201
+ throw new WriteDeniedError("project-boundary", path, { cause: error });
1202
+ }
1203
+ }
1204
+ await checkPath(nativeFileTool ? resolve(toolDirectory, path) : path);
1205
+ }
1194
1206
  if (extracted.gitCommit)
1195
1207
  await checkCachedSet();
1196
1208
  },
@@ -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,
@@ -1796,7 +1813,7 @@ export const SortieDogsPlugin = async (input, options) => {
1796
1813
  delivery: declaration.delivery, budget: { max_units: maxUnits,
1797
1814
  time_ms: timeBudget, cost_usd: costBudget,
1798
1815
  source: Number.isSafeInteger(units) ? "accepted-plan" : state.budget?.source ?? "policy-default" },
1799
- acceptance_contract: declaration.contract });
1816
+ acceptance_contract: declaration.contract, reset_no_progress: true });
1800
1817
  goalDeclarationAuthority.delete(sessionID);
1801
1818
  return state;
1802
1819
  }
@@ -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);
@@ -2108,7 +2130,7 @@ export const SortieDogsPlugin = async (input, options) => {
2108
2130
  throw new WriteDeniedError("manifest-unavailable", "<unknown>");
2109
2131
  loaded.manifest = validation.value;
2110
2132
  loaded.manifestFingerprint = inspectionFingerprint(validation.value, undefined);
2111
- loaded.gate = await createWriteGate(project, validation.value);
2133
+ loaded.gate = await createWriteGate(project, validation.value, input.directory);
2112
2134
  bootstrapRequired = false;
2113
2135
  bootstrapCompleted = true;
2114
2136
  loadFailure = undefined;
@@ -4035,7 +4057,7 @@ export const SortieDogsPlugin = async (input, options) => {
4035
4057
  });
4036
4058
  }
4037
4059
  manifest = manifestValidation.value;
4038
- authorizationGate = await createWriteGate(candidateProject, manifest);
4060
+ authorizationGate = await createWriteGate(candidateProject, manifest, input.directory);
4039
4061
  inspectedProjectRoot = candidateProject.root;
4040
4062
  }
4041
4063
  catch (error) {
@@ -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,
@@ -4330,7 +4352,7 @@ export const SortieDogsPlugin = async (input, options) => {
4330
4352
  idempotent: true,
4331
4353
  });
4332
4354
  }
4333
- const gate = await createWriteGate(candidate, validation.value);
4355
+ const gate = await createWriteGate(candidate, validation.value, input.directory);
4334
4356
  const readScopes = await canonicalManifestReadScopes(candidate, validation.value);
4335
4357
  const writeScopes = await canonicalManifestWriteScopes(candidate, validation.value);
4336
4358
  // Keep conflict detection and registration in one JavaScript turn so competing binds fail closed.
@@ -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;
@@ -6354,6 +6407,8 @@ export const SortieDogsPlugin = async (input, options) => {
6354
6407
  }
6355
6408
  catch (error) {
6356
6409
  activeState?.inFlightCalls.delete(toolInput.callID);
6410
+ if (error instanceof WriteDeniedError)
6411
+ goalValidationDefects.add(toolInput.sessionID);
6357
6412
  if (!(error instanceof WriteDeniedError) || error.reason === "repeated-denial")
6358
6413
  throw error;
6359
6414
  const signature = denialSignature(toolInput, output, error.reason);
@@ -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 {};