sortie-dogs 0.12.11 → 0.12.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.12.11-operation-recovery-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.13-mission-cold-recovery-v1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.12.11-operation-recovery-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.13-mission-cold-recovery-v1";
@@ -38,7 +38,7 @@ export interface GoalEvidence {
38
38
  readonly source_paths: readonly string[];
39
39
  readonly candidate_paths: readonly string[];
40
40
  /** Missing on legacy evidence: retain its original all-paths snapshot recipe. */
41
- readonly source_policy?: "project-files-v1";
41
+ readonly source_policy?: "project-files-v1" | "declared-paths-v1";
42
42
  };
43
43
  readonly execution: {
44
44
  readonly command: readonly string[];
@@ -167,6 +167,15 @@ export type GoalFlightEvent = (GoalEventBase & {
167
167
  readonly evidence: readonly GoalEvidence[];
168
168
  readonly elapsed_ms: number | null;
169
169
  readonly cost_usd: number | null;
170
+ readonly native_session_id?: string;
171
+ readonly native_started_at?: string;
172
+ }) | (GoalEventBase & {
173
+ readonly kind: "unit.usage-reconciled";
174
+ readonly goal_id: string;
175
+ readonly reservation_id: string;
176
+ readonly native_session_id: string;
177
+ readonly cost_usd: number;
178
+ readonly source: "native-usage-price-table";
170
179
  }) | (GoalEventBase & {
171
180
  readonly kind: "unit.evidence-reconciled";
172
181
  readonly goal_id: string;
@@ -62,7 +62,8 @@ export function validGoalEvidence(value, state) {
62
62
  text(protectedBinding.project_root) && text(protectedBinding.manifest_path) && Array.isArray(protectedBinding.source_paths) &&
63
63
  protectedBinding.source_paths.every(text) && Array.isArray(protectedBinding.candidate_paths) &&
64
64
  protectedBinding.candidate_paths.every(text) &&
65
- (protectedBinding.source_policy === undefined || protectedBinding.source_policy === "project-files-v1");
65
+ (protectedBinding.source_policy === undefined || protectedBinding.source_policy === "project-files-v1" ||
66
+ protectedBinding.source_policy === "declared-paths-v1");
66
67
  const matches = criteria.length > 0 && criteria.length === value.measurement.criterion_ids.length &&
67
68
  criteria.every((criterion) => criterion.target === value.measurement.target &&
68
69
  criterion.entrypoint === value.measurement.entrypoint && criterion.workload === value.measurement.workload &&
@@ -114,6 +115,7 @@ export function reduceGoalFlight(records) {
114
115
  let legacyResetRevision = false;
115
116
  let previous = null;
116
117
  const processDefects = new Map();
118
+ const unitCosts = new Map();
117
119
  const validationAdmissions = new Map();
118
120
  const validationSettlements = new Map();
119
121
  const reopenedValidationEvidence = new Set();
@@ -127,6 +129,7 @@ export function reduceGoalFlight(records) {
127
129
  continue;
128
130
  requireState(instant(event.at), "invalid", "Goal event timestamp is invalid.");
129
131
  if (event.kind === "goal.accepted") {
132
+ unitCosts.clear();
130
133
  requireState(state.goal_id === null || state.phase === "terminal" || state.phase === "stopped", "transition", "An active goal already owns this root.");
131
134
  requireState(text(event.goal_id) && HASH.test(event.acceptance_fingerprint) && text(event.origin_user_message_id) &&
132
135
  text(event.origin_session_id) && text(event.selected_agent) && event.budget.max_units > 0 &&
@@ -221,6 +224,7 @@ export function reduceGoalFlight(records) {
221
224
  : state.no_progress_results;
222
225
  const elapsed = state.consumed_time_ms === null || event.elapsed_ms === null ? null : state.consumed_time_ms + event.elapsed_ms;
223
226
  const cost = state.consumed_cost_usd === null || event.cost_usd === null ? null : state.consumed_cost_usd + event.cost_usd;
227
+ unitCosts.set(event.reservation_id, { cost: event.cost_usd, session: event.native_session_id });
224
228
  state = { ...state, consumed_units: state.consumed_units + 1, consumed_time_ms: elapsed, consumed_cost_usd: cost,
225
229
  no_progress_results: nextNoProgress, replan_required: nextNoProgress >= 2,
226
230
  outstanding_reservations: state.outstanding_reservations.filter((entry) => entry.reservation_id !== event.reservation_id),
@@ -231,6 +235,17 @@ export function reduceGoalFlight(records) {
231
235
  processDefects.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch });
232
236
  }
233
237
  }
238
+ else if (event.kind === "unit.usage-reconciled") {
239
+ const prior = unitCosts.get(event.reservation_id);
240
+ requireState(event.goal_id === state.goal_id && prior !== undefined && prior.session === event.native_session_id &&
241
+ event.source === "native-usage-price-table" &&
242
+ Number.isFinite(event.cost_usd) && event.cost_usd >= 0 &&
243
+ (prior.cost === null || prior.cost === event.cost_usd), "transition", "Usage reconciliation must identify one settled native unit.");
244
+ unitCosts.set(event.reservation_id, { cost: event.cost_usd, session: event.native_session_id });
245
+ const costs = [...unitCosts.values()];
246
+ state = { ...state, consumed_cost_usd: costs.some(item => item.cost === null) ? null
247
+ : costs.reduce((sum, item) => sum + item.cost, 0) };
248
+ }
234
249
  else if (event.kind === "unit.evidence-reconciled") {
235
250
  const old = processDefects.get(event.previous_receipt_id);
236
251
  requireState(state.phase === "active" && state.receipt === null && old?.unitID === event.unit_id &&
@@ -17,6 +17,8 @@ export interface OperatorMission {
17
17
  id: string;
18
18
  text: string;
19
19
  }[];
20
+ /** The current user intentionally replaced the predecessor's requirements. */
21
+ requirementsReplaced?: boolean;
20
22
  phase: "open" | "running" | "submitted" | "completed" | "cancelled";
21
23
  coordinator: string | null;
22
24
  callID: string | null;
@@ -74,7 +76,7 @@ export declare class OperatorMissionRuntime {
74
76
  read(root: string): Promise<OperatorMission | undefined>;
75
77
  required(root: string): Promise<OperatorMission>;
76
78
  capture(root: string, request: MissionRequest): Promise<void>;
77
- start(root: string, requirements: unknown): Promise<OperatorMission>;
79
+ start(root: string, requirements: unknown, replaceRequirements?: boolean): Promise<OperatorMission>;
78
80
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
79
81
  /** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
80
82
  carryForward(root: string, missionID: string, acceptance: readonly string[], supersededRunID?: string): Promise<OperatorMission>;
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { normalizeRelativeScope } from "./path.js";
4
+ import { normalizeExecutionScope } from "./path.js";
5
5
  import { parseOperatorPlan } from "./operator-runtime.js";
6
6
  import { profileAgent } from "./runtime-profile.js";
7
7
  const digest = (value) => createHash("sha256").update(value).digest("hex");
@@ -160,7 +160,7 @@ export class OperatorMissionRuntime {
160
160
  }
161
161
  });
162
162
  }
163
- start(root, requirements) {
163
+ start(root, requirements, replaceRequirements = false) {
164
164
  return this.serial(root, async () => {
165
165
  if (!Array.isArray(requirements) || requirements.length === 0 || requirements.length > 64 ||
166
166
  !requirements.every(item => typeof item === "string" && item.trim() && !/[\r\n]/u.test(item))) {
@@ -182,6 +182,7 @@ export class OperatorMissionRuntime {
182
182
  await this.save(this.file(root, `.${previous.id}`), previous);
183
183
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
184
184
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
185
+ ...(replaceRequirements ? { requirementsReplaced: true } : {}),
185
186
  coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
186
187
  ...(previous?.phase === "cancelled" &&
187
188
  (previous.runID === null || request.id !== previous.requests[0]?.id) &&
@@ -286,7 +287,7 @@ export function missionPlan(mission, raw) {
286
287
  const entries = value[field] ?? [];
287
288
  if (!Array.isArray(entries) || !entries.every(item => typeof item === "string"))
288
289
  throw new Error(`mission-unit-${index + 1}: ${field} must be paths`);
289
- return [...new Set(entries.map(item => normalizeRelativeScope(item)))];
290
+ return [...new Set(entries.map(item => normalizeExecutionScope(item)))];
290
291
  };
291
292
  const ids = value.requirement_ids ?? mission.requirements.map(item => item.id);
292
293
  if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id => mission.requirements.some(item => item.id === id))) {
@@ -328,12 +329,18 @@ export function missionPlan(mission, raw) {
328
329
  // Keep all detailed instructions in the handoff, not in a 512-character wire label.
329
330
  criteria: units.map(unit => ({ criterion_id: unit.id, target: unit.title.slice(0, 512),
330
331
  entrypoint: (unit.write[0] ?? unit.read[0] ?? unit.id).slice(0, 512), validation_command: unit.validation.at(-1) })) },
331
- units });
332
+ units }, "execution");
332
333
  }
333
334
  export function missionPacket(mission, run) {
335
+ const predecessor = run && mission.runID !== run.runID && (mission.supersededRunID === run.runID || run.phase === "cancelled") ? run : undefined;
336
+ if (predecessor)
337
+ run = undefined;
334
338
  const currentReview = mission.review !== undefined && mission.review.runID === (run?.runID ?? mission.runID);
335
339
  const reviewAccepted = currentReview && missionReviewAccepted(mission.review);
336
340
  return { mission_id: mission.id, phase: mission.phase, coordinator_session_id: mission.coordinator,
341
+ ...(predecessor ? { predecessor: { run_id: predecessor.runID, status: predecessor.phase,
342
+ completed_units: predecessor.units.filter(unit => unit.status === "succeeded").length,
343
+ note: "Historical results and spend are retained; they do not complete the current requirements." } } : {}),
337
344
  requirements: mission.requirements, original_request_refs: mission.requests.map(item => `user:${item.id}`),
338
345
  submission: mission.submission, progress: mission.progress,
339
346
  execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
@@ -350,11 +357,14 @@ export function missionPacket(mission, run) {
350
357
  passed: currentReview && mission.review.verdict === "PASS", permits_submission: reviewAccepted } : null,
351
358
  ...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
352
359
  units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
353
- child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence })) } : {}),
360
+ child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
361
+ ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
354
362
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
355
363
  : mission.phase === "submitted" && mission.submission?.status === "ready"
356
364
  ? "Operator: compare the submitted candidate with the original requirements and actual evidence, then complete_mission if satisfied. Report remaining evidence gaps; they are not a review PASS."
357
- : run?.phase === "awaiting-decision" ? "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative."
365
+ : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
366
+ ? "Coordinator: inspect units[].dispatch_denial before changing the plan. Correct only its diagnosed cause; do not repeat an unchanged refused Task or replan for a host-state mismatch. Report an unresolved runtime mismatch with the loaded runtime identity; preserve requirements and cumulative spend."
367
+ : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
358
368
  : run?.phase === "awaiting-acceptance" ? (currentReview && mission.review?.verdict === "evidence-gaps" && !reviewAccepted
359
369
  ? "Coordinator: the Reviewer found only missing evidence. Supply it through review_mission traces (or at most one evidence-only unit) and re-review once; do not re-implement. At the evidence-gap limit, submit_mission ready with the gaps listed."
360
370
  : reviewAccepted
@@ -115,6 +115,7 @@ interface UnitState {
115
115
  childSessionID: string | null;
116
116
  evidence: readonly GoalEvidence[];
117
117
  resultClass: string | null;
118
+ dispatchDenial?: string;
118
119
  repairValidationAttempts: number;
119
120
  repairValidation: {
120
121
  readonly repair_fingerprint: string;
@@ -203,6 +204,8 @@ export interface OperatorState {
203
204
  operatorCallID: string | null;
204
205
  operatorSessionID: string | null;
205
206
  dispatched: number;
207
+ /** Native interrupt acknowledgements for this cancelled run, persisted before settlement. */
208
+ stoppedChildren?: string[];
206
209
  units: UnitState[];
207
210
  decision: string | null;
208
211
  receipt: GoalTerminalReceipt | null;
@@ -210,7 +213,7 @@ export interface OperatorState {
210
213
  /** A later mission may replace an unexecuted cancellation, but must retain settled work's acceptance. */
211
214
  export declare function cancelledMissionRetainsAcceptance(state: OperatorState): boolean;
212
215
  export declare function operatorGitPathAuthorized(path: string, scopes: readonly string[], platform?: NodeJS.Platform): boolean;
213
- export declare function parseOperatorPlan(value: unknown): OperatorPlan;
216
+ export declare function parseOperatorPlan(value: unknown, scopeFormat?: "repository" | "execution"): OperatorPlan;
214
217
  /** Root-owned durable queue. It never executes code, changes acceptance, or accepts a candidate. */
215
218
  export declare class OperatorRuntime {
216
219
  readonly profile: RuntimeProfile;
@@ -247,7 +250,7 @@ export declare class OperatorRuntime {
247
250
  prepareMission(root: string, raw: unknown, dispatcher?: {
248
251
  sessionID: string;
249
252
  callID: string;
250
- }, supersededRunID?: string, terminalChildren?: readonly string[]): Promise<OperatorState>;
253
+ }, supersededRunID?: string, terminalChildren?: readonly string[], replaceRequirements?: boolean): Promise<OperatorState>;
251
254
  /** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
252
255
  replanMission(root: string, runID: string, raw: unknown, dispatcher?: {
253
256
  sessionID: string;
@@ -284,7 +287,7 @@ export declare class OperatorRuntime {
284
287
  private nextOnce;
285
288
  admitWorker(root: string, actor: string, callID: string, args: unknown): Promise<OperatorTask>;
286
289
  private admitWorkerOnce;
287
- rejectedAdmission(root: string, callID: string): Promise<void>;
290
+ rejectedAdmission(root: string, callID: string, reason?: string): Promise<void>;
288
291
  rejectDispatch(root: string, callID: string, decision?: string): Promise<void>;
289
292
  private rejectDispatchOnce;
290
293
  private repairPathProtected;
@@ -322,6 +325,7 @@ export declare class OperatorRuntime {
322
325
  processReplacementRequired(root: string, runID: string): Promise<OperatorState>;
323
326
  private operatorReturnedOnce;
324
327
  interrupted(root: string, reason: string): Promise<readonly string[]>;
328
+ recordStoppedChild(root: string, runID: string, child: string): Promise<void>;
325
329
  private interruptedOnce;
326
330
  terminal(root: string, receipt: GoalTerminalReceipt): Promise<void>;
327
331
  private terminalOnce;
@@ -5,7 +5,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, ACCEPTANCE_CONTINUITY_EXTENSION, MAX_ACCEPTANCE_CONTINUITY_BYTES, MAX_ACCEPTANCE_CRITERIA } from "./acceptance-continuity.js";
7
7
  import { expandGoalDeclaration, goalDeclarationDefaults, goalDeclarationFieldDiagnostics, hasGoalCommandAliasConflict } from "./goal-declaration-format.js";
8
- import { normalizeManifestPath, normalizeManifestScope, normalizeRelativePath } from "./path.js";
8
+ import { normalizeExecutionScope, normalizeManifestPath, normalizeManifestScope, normalizeRelativePath } from "./path.js";
9
9
  import { CONTRACT_TEXT_LIMITS, validateHandoffSchema, validateOperationManifestSchema } from "./validate-schema.js";
10
10
  import { validateManifest } from "./validate-manifest.js";
11
11
  import { profileAgent, RUNTIME_PROFILES } from "./runtime-profile.js";
@@ -83,7 +83,7 @@ function scopePaths(value, limit) {
83
83
  };
84
84
  return paths.every(normalized) && new Set(paths).size === paths.length;
85
85
  }
86
- export function parseOperatorPlan(value) {
86
+ export function parseOperatorPlan(value, scopeFormat = "repository") {
87
87
  if (!record(value) || !exactKeys(value, ["schema_version", "acceptance", "acceptance_proof", "source_refs", "goal_declaration", "units", "git_lifecycle"])) {
88
88
  return planError("/", "operator-plan-invalid", "operator-plan-fields");
89
89
  }
@@ -207,11 +207,11 @@ export function parseOperatorPlan(value) {
207
207
  for (const [pathIndex, name] of unit[field].entries()) {
208
208
  let valid = false;
209
209
  try {
210
- valid = normalizeRelativePath(name) === name;
210
+ valid = (scopeFormat === "execution" ? normalizeExecutionScope(name) : normalizeRelativePath(name)) === name;
211
211
  }
212
212
  catch { /* Return only a typed pointer, never the path value. */ }
213
213
  if (!valid)
214
- return planError(`/units/${unitIndex}/${field}/${pathIndex}`, "operator-scope-invalid", "repository-relative-scope");
214
+ return planError(`/units/${unitIndex}/${field}/${pathIndex}`, "operator-scope-invalid", scopeFormat === "execution" ? "normalized-execution-scope" : "repository-relative-scope");
215
215
  }
216
216
  }
217
217
  for (const name of unit.write) {
@@ -686,8 +686,8 @@ export class OperatorRuntime {
686
686
  }
687
687
  }
688
688
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
689
- prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = []) {
690
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren }));
689
+ prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = [], replaceRequirements = false) {
690
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren, replaceRequirements }));
691
691
  }
692
692
  /** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
693
693
  replanMission(root, runID, raw, dispatcher) {
@@ -716,7 +716,7 @@ export class OperatorRuntime {
716
716
  }
717
717
  immutableReplacement = { ...immutableReplacement, goal_declaration: declaration };
718
718
  }
719
- const plan = parseOperatorPlan(immutableReplacement);
719
+ const plan = parseOperatorPlan(immutableReplacement, mission ? "execution" : "repository");
720
720
  const validatedPlanHash = hash(JSON.stringify(plan));
721
721
  const replanning = mission?.replaceRunID !== undefined;
722
722
  if (replanning) {
@@ -758,12 +758,12 @@ export class OperatorRuntime {
758
758
  return previous;
759
759
  throw new Error("operator-active-contract-immutable");
760
760
  }
761
- // Cancellation stops execution, not the accepted user order. A replacement
762
- // plan must carry the original ordered criteria; only a completed root clears them.
761
+ // Same-request continuation retains acceptance. Explicit user replacement keeps the
762
+ // predecessor's history/spend but uses the current requirements instead of an old prefix.
763
763
  // Treat the predecessor as retired only in this staged replacement. Its durable state remains
764
764
  // intact through schema checks, control writes and archive creation, until save atomically switches it.
765
765
  const parent = replanning ? { ...previous, phase: "cancelled", decision: "mission-replan" }
766
- : (!superseding || retainAcceptance) && previous?.phase === "cancelled" ? previous : undefined;
766
+ : (!superseding || (retainAcceptance && !mission?.replaceRequirements)) && previous?.phase === "cancelled" ? previous : undefined;
767
767
  if (parent && ["explicit-cancellation", "agent-changed"].includes(parent.decision ?? "")) {
768
768
  const cancelled = parent.units.flatMap(unit => (parent.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null
769
769
  ? [unit.childSessionID] : []);
@@ -837,6 +837,13 @@ export class OperatorRuntime {
837
837
  const units = [];
838
838
  const controls = [{ path: declarationPath, content: declaration }];
839
839
  for (const [index, unit] of plan.units.entries()) {
840
+ for (const name of unit.write) {
841
+ const scoped = relative(this.projectRoot, resolve(this.projectRoot, normalizeManifestScope(name).path)).replaceAll("\\", "/");
842
+ if ([".git", ...Object.values(RUNTIME_PROFILES).map(profile => profile.stateDirectory)]
843
+ .some(directory => scoped === directory || scoped.startsWith(`${directory}/`))) {
844
+ return planError(`/units/${index}/write`, "operator-control-write-forbidden", "control-write-forbidden");
845
+ }
846
+ }
840
847
  const taskID = `${runID}-${index + 1}`;
841
848
  const manifestPath = join(directory, `${taskID}.operation-manifest.json`);
842
849
  const manifestRelative = `${this.profile.stateDirectory}/contracts/${taskID}.operation-manifest.json`;
@@ -1232,19 +1239,21 @@ export class OperatorRuntime {
1232
1239
  await this.save(state);
1233
1240
  return admitted;
1234
1241
  }
1235
- async rejectedAdmission(root, callID) {
1236
- await this.rejectDispatch(root, callID, "dispatch-admission-rejected");
1242
+ async rejectedAdmission(root, callID, reason) {
1243
+ await this.serial(root, () => this.rejectDispatchOnce(root, callID, "dispatch-admission-rejected", reason));
1237
1244
  }
1238
1245
  rejectDispatch(root, callID, decision = "native-task-rejected") {
1239
1246
  return this.serial(root, () => this.rejectDispatchOnce(root, callID, decision));
1240
1247
  }
1241
- async rejectDispatchOnce(root, callID, decision) {
1248
+ async rejectDispatchOnce(root, callID, decision, reason) {
1242
1249
  const state = await this.required(root);
1243
1250
  const unit = state.units.find(item => item.callID === callID && item.status === "running");
1244
1251
  if (!unit)
1245
1252
  return;
1246
1253
  unit.status = "failed";
1247
1254
  unit.resultClass = "process-defect";
1255
+ if (reason)
1256
+ unit.dispatchDenial = reason.slice(0, 4000);
1248
1257
  state.phase = "awaiting-decision";
1249
1258
  state.decision = decision;
1250
1259
  await this.save(state);
@@ -1570,6 +1579,15 @@ export class OperatorRuntime {
1570
1579
  interrupted(root, reason) {
1571
1580
  return this.serial(root, () => this.interruptedOnce(root, reason));
1572
1581
  }
1582
+ recordStoppedChild(root, runID, child) {
1583
+ return this.serial(root, async () => {
1584
+ const state = await this.required(root);
1585
+ if (state.runID !== runID || state.phase !== "cancelled")
1586
+ return;
1587
+ state.stoppedChildren = [...new Set([...(state.stoppedChildren ?? []), child])];
1588
+ await this.save(state);
1589
+ });
1590
+ }
1573
1591
  async interruptedOnce(root, reason) {
1574
1592
  const state = await this.read(root);
1575
1593
  if (!state || state.phase === "completed")
@@ -28,3 +28,5 @@ export declare function normalizeManifestScope(input: string): ManifestPath & {
28
28
  readonly directory: boolean;
29
29
  };
30
30
  export declare function normalizeRelativeScope(input: string): string;
31
+ /** Execution scopes may name native absolute destinations (for example global installs). */
32
+ export declare function normalizeExecutionScope(input: string): string;
package/dist/core/path.js CHANGED
@@ -67,3 +67,8 @@ export function normalizeRelativeScope(input) {
67
67
  throw new RelativePathError("absolute");
68
68
  return scope.path + (scope.directory ? "/**" : "");
69
69
  }
70
+ /** Execution scopes may name native absolute destinations (for example global installs). */
71
+ export function normalizeExecutionScope(input) {
72
+ const scope = normalizeManifestScope(input);
73
+ return scope.path + (scope.directory ? "/**" : "");
74
+ }
@@ -918,6 +918,10 @@ export class RunFlightLedger {
918
918
  const records = await this.#readGoalRecords();
919
919
  if (guard !== undefined)
920
920
  guard(reduceGoalFlight(records));
921
+ if (event.kind === "unit.usage-reconciled" && records.some(({ event: stored }) => stored.kind === "unit.usage-reconciled" && stored.goal_id === event.goal_id &&
922
+ stored.reservation_id === event.reservation_id && stored.native_session_id === event.native_session_id &&
923
+ stored.cost_usd === event.cost_usd))
924
+ return reduceGoalFlight(records);
921
925
  if (event.kind === "goal.accepted") {
922
926
  const prior = records.find(({ event: stored }) => stored.kind === "goal.accepted" &&
923
927
  stored.origin_user_message_id === event.origin_user_message_id);
@@ -0,0 +1,8 @@
1
+ /** Explicit external artifacts are filesystem inputs, not Git pathspecs. Stream complete bytes;
2
+ * previews are bounded independently. Follow package links only inside a declared physical root.
3
+ */
4
+ export declare function declaredArtifacts(paths: readonly string[], previewLimit?: number): Promise<{
5
+ entries: (readonly [string, string, (string | undefined)?])[];
6
+ excerpt: string;
7
+ truncated: boolean;
8
+ }>;
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, readdir, readlink, realpath } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve } from "node:path";
5
+ const within = (root, path) => {
6
+ const rest = relative(root, path);
7
+ return rest === "" || (rest !== ".." && !rest.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(rest));
8
+ };
9
+ /** Explicit external artifacts are filesystem inputs, not Git pathspecs. Stream complete bytes;
10
+ * previews are bounded independently. Follow package links only inside a declared physical root.
11
+ */
12
+ export async function declaredArtifacts(paths, previewLimit = 0) {
13
+ const roots = [...new Set(paths.map(path => resolve(path)))].sort();
14
+ const physical = await Promise.all(roots.map(path => realpath(path).catch((error) => {
15
+ if (error.code === "ENOENT")
16
+ return path;
17
+ throw error;
18
+ })));
19
+ const entries = [];
20
+ const excerpts = [];
21
+ let remaining = previewLimit, truncated = false;
22
+ const visited = new Set();
23
+ const visit = async (path, ancestors) => {
24
+ if (visited.has(path))
25
+ return;
26
+ visited.add(path);
27
+ const label = path.replaceAll("\\", "/");
28
+ const metadata = await lstat(path).catch((error) => {
29
+ if (error.code === "ENOENT")
30
+ return undefined;
31
+ throw error;
32
+ });
33
+ if (!metadata) {
34
+ entries.push([label, "missing"]);
35
+ return;
36
+ }
37
+ const canonical = await realpath(path);
38
+ if (metadata.isSymbolicLink()) {
39
+ entries.push([label, `symlink:${await readlink(path)}`, canonical]);
40
+ if (!physical.some(root => within(root, canonical)))
41
+ throw new Error("declared-artifact-link-outside-scope");
42
+ if (ancestors.has(canonical))
43
+ return; // The link identity is recorded; do not traverse cycles.
44
+ await visit(canonical, ancestors);
45
+ return;
46
+ }
47
+ if (metadata.isDirectory()) {
48
+ entries.push([label, "directory", canonical]);
49
+ const next = new Set([...ancestors, canonical]);
50
+ for (const child of (await readdir(path)).sort())
51
+ await visit(join(path, child), next);
52
+ return;
53
+ }
54
+ if (!metadata.isFile())
55
+ throw new Error("declared-artifact-not-file-or-directory");
56
+ const hash = createHash("sha256");
57
+ const room = remaining;
58
+ let preview = Buffer.alloc(0);
59
+ for await (const part of createReadStream(path)) {
60
+ const bytes = Buffer.isBuffer(part) ? part : Buffer.from(part);
61
+ hash.update(bytes);
62
+ if (preview.length < room)
63
+ preview = Buffer.concat([preview, bytes.subarray(0, room - preview.length)]);
64
+ }
65
+ entries.push([label, `file:${metadata.mode}`, hash.digest("hex")]);
66
+ if (previewLimit > 0) {
67
+ if (room) {
68
+ const excerpt = `\n--- external file: ${label} ---\n${preview.includes(0) ? "[binary artifact: bytes fingerprinted]" : preview.toString("utf8")}`;
69
+ const bounded = Buffer.from(excerpt).subarray(0, remaining);
70
+ excerpts.push(bounded.toString("utf8"));
71
+ remaining -= bounded.length;
72
+ }
73
+ if (metadata.size > room || remaining === 0)
74
+ truncated = true;
75
+ }
76
+ };
77
+ for (const path of roots)
78
+ await visit(path, new Set());
79
+ return { entries, excerpt: excerpts.join(""), truncated };
80
+ }
@@ -1175,7 +1175,7 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
1175
1175
  const absolute = resolve(manifestPath.path);
1176
1176
  if (await project.contains(absolute)) {
1177
1177
  const normalized = await project.toRelativePath(absolute);
1178
- if (!await isWritable(normalized))
1178
+ if (!await isWritable(normalized) && !await isExternalWritable(absolute))
1179
1179
  throw new WriteDeniedError("manifest-scope", normalized);
1180
1180
  return;
1181
1181
  }
@@ -1240,21 +1240,16 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
1240
1240
  : undefined;
1241
1241
  if (command !== undefined && declaredValidation.has(command))
1242
1242
  return;
1243
+ // Shell syntax is not an execution permission boundary. Mission workers already use
1244
+ // native host permissions; only declared checks produce formal validation evidence.
1245
+ if (options?.investigativeShell && ["bash", "shell"].includes(_input.tool))
1246
+ return;
1243
1247
  const extracted = extractWritePaths(_input.tool, output.args);
1244
1248
  if (!extracted.applies)
1245
1249
  return;
1246
- // Mission workers may investigate with repository-specific executables without registering
1247
- // every diagnostic as a formal check. Known write destinations/Git mutations still use the
1248
- // same scope gate; this option never makes diagnostic output acceptance evidence.
1249
- if (options?.investigativeShell && ["bash", "shell"].includes(_input.tool) &&
1250
- extracted.issue?.cause === "executable-not-allowlisted" && extracted.paths.length === 0 &&
1251
- !extracted.gitMutation && !extracted.remoteMutation)
1252
- return;
1253
1250
  if (extracted.ambiguous || (extracted.paths.length === 0 && !extracted.gitCommit)) {
1254
1251
  if (extracted.issue !== undefined) {
1255
- const formatCorrection = options?.investigativeShell === true &&
1256
- ["unsupported-curl-form", "unsupported-git-archive-form", "unsupported-webrequest-form"].includes(extracted.issue.cause);
1257
- throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue, formatCorrection));
1252
+ throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue));
1258
1253
  }
1259
1254
  throw new WriteDeniedError("path-required", "<missing-path>");
1260
1255
  }