sortie-dogs 0.12.11 → 0.12.12

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.12-mission-lineage-external-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.12-mission-lineage-external-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[];
@@ -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 &&
@@ -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");
@@ -286,7 +286,7 @@ export function missionPlan(mission, raw) {
286
286
  const entries = value[field] ?? [];
287
287
  if (!Array.isArray(entries) || !entries.every(item => typeof item === "string"))
288
288
  throw new Error(`mission-unit-${index + 1}: ${field} must be paths`);
289
- return [...new Set(entries.map(item => normalizeRelativeScope(item)))];
289
+ return [...new Set(entries.map(item => normalizeExecutionScope(item)))];
290
290
  };
291
291
  const ids = value.requirement_ids ?? mission.requirements.map(item => item.id);
292
292
  if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id => mission.requirements.some(item => item.id === id))) {
@@ -328,7 +328,7 @@ export function missionPlan(mission, raw) {
328
328
  // Keep all detailed instructions in the handoff, not in a 512-character wire label.
329
329
  criteria: units.map(unit => ({ criterion_id: unit.id, target: unit.title.slice(0, 512),
330
330
  entrypoint: (unit.write[0] ?? unit.read[0] ?? unit.id).slice(0, 512), validation_command: unit.validation.at(-1) })) },
331
- units });
331
+ units }, "execution");
332
332
  }
333
333
  export function missionPacket(mission, run) {
334
334
  const currentReview = mission.review !== undefined && mission.review.runID === (run?.runID ?? mission.runID);
@@ -350,11 +350,14 @@ export function missionPacket(mission, run) {
350
350
  passed: currentReview && mission.review.verdict === "PASS", permits_submission: reviewAccepted } : null,
351
351
  ...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
352
352
  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 })) } : {}),
353
+ child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
354
+ ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
354
355
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
355
356
  : mission.phase === "submitted" && mission.submission?.status === "ready"
356
357
  ? "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."
358
+ : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
359
+ ? "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."
360
+ : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
358
361
  : run?.phase === "awaiting-acceptance" ? (currentReview && mission.review?.verdict === "evidence-gaps" && !reviewAccepted
359
362
  ? "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
363
  : 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;
@@ -210,7 +211,7 @@ export interface OperatorState {
210
211
  /** A later mission may replace an unexecuted cancellation, but must retain settled work's acceptance. */
211
212
  export declare function cancelledMissionRetainsAcceptance(state: OperatorState): boolean;
212
213
  export declare function operatorGitPathAuthorized(path: string, scopes: readonly string[], platform?: NodeJS.Platform): boolean;
213
- export declare function parseOperatorPlan(value: unknown): OperatorPlan;
214
+ export declare function parseOperatorPlan(value: unknown, scopeFormat?: "repository" | "execution"): OperatorPlan;
214
215
  /** Root-owned durable queue. It never executes code, changes acceptance, or accepts a candidate. */
215
216
  export declare class OperatorRuntime {
216
217
  readonly profile: RuntimeProfile;
@@ -284,7 +285,7 @@ export declare class OperatorRuntime {
284
285
  private nextOnce;
285
286
  admitWorker(root: string, actor: string, callID: string, args: unknown): Promise<OperatorTask>;
286
287
  private admitWorkerOnce;
287
- rejectedAdmission(root: string, callID: string): Promise<void>;
288
+ rejectedAdmission(root: string, callID: string, reason?: string): Promise<void>;
288
289
  rejectDispatch(root: string, callID: string, decision?: string): Promise<void>;
289
290
  private rejectDispatchOnce;
290
291
  private repairPathProtected;
@@ -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) {
@@ -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) {
@@ -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);
@@ -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
+ }
@@ -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
  }
@@ -6830,58 +6830,64 @@ export const SortieDogsPlugin = async (input, options) => {
6830
6830
  output.args.prompt = canonical;
6831
6831
  prompt = canonical;
6832
6832
  }
6833
- const previous = rootAcceptanceContinuity.get(toolInput.sessionID);
6834
- if (previous === undefined) {
6835
- if (ledger.parent_fingerprint !== "none" &&
6836
- !await recoverAcceptanceParent(toolInput.sessionID, ledger, reservedParallelDescriptor)) {
6837
- throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6838
- defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6839
- });
6840
- }
6841
- }
6842
- else if (previous.task_id === ledger.task_id && previous.fingerprint === ledger.fingerprint) {
6843
- if (ledger.parent_fingerprint !== previous.parent_fingerprint) {
6844
- throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6845
- defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6846
- });
6833
+ // Mission admission already binds the exact generated Task to its durable run and
6834
+ // ordered requirements. The legacy session-wide chain is a second, incompatible
6835
+ // authority across cancelled missions and cold reloads (including later units).
6836
+ const missionDispatch = await input.runtimeBridge?.ownsMissionDispatch?.(toolInput.sessionID, toolInput.callID, ledger.task_id) === true;
6837
+ if (!missionDispatch) {
6838
+ const previous = rootAcceptanceContinuity.get(toolInput.sessionID);
6839
+ if (previous === undefined) {
6840
+ if (ledger.parent_fingerprint !== "none" &&
6841
+ !await recoverAcceptanceParent(toolInput.sessionID, ledger, reservedParallelDescriptor)) {
6842
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6843
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6844
+ });
6845
+ }
6847
6846
  }
6848
- }
6849
- else {
6850
- const exactCarryForward = ledger.criteria.length === previous.criteria.length &&
6851
- previous.criteria.every((criterion, index) => criterion === ledger.criteria[index]);
6852
- const strictAppend = ledger.criteria.length > previous.criteria.length &&
6853
- previous.criteria.every((criterion, index) => criterion === ledger.criteria[index]);
6854
- // Serial parent identity is controller-owned. Repair only an omitted link on
6855
- // an otherwise exact carry-forward/append; explicit conflicting links still fail.
6856
- // Persist it so the worker's Read and later recovery see the same contract.
6857
- if (reservedParallelDescriptor === undefined && ledger.parent_fingerprint === "none" &&
6858
- (exactCarryForward || strictAppend)) {
6859
- const source = await readFile(handoffPaths[0], "utf8");
6860
- const handoff = JSON.parse(source);
6861
- const validated = validateHandoffSchema(handoff);
6862
- const current = validated.ok ? inspectAcceptanceContinuity(validated.value).ledger : undefined;
6863
- if (current === undefined || JSON.stringify(current) !== JSON.stringify(ledger)) {
6847
+ else if (previous.task_id === ledger.task_id && previous.fingerprint === ledger.fingerprint) {
6848
+ if (ledger.parent_fingerprint !== previous.parent_fingerprint) {
6864
6849
  throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6865
6850
  defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6866
6851
  });
6867
6852
  }
6868
- ledger = { ...ledger, parent_fingerprint: previous.fingerprint };
6869
- handoff.ext[ACCEPTANCE_CONTINUITY_EXTENSION] = ledger;
6870
- const repaired = `${JSON.stringify(handoff, null, 2)}\n`;
6871
- await writeFile(handoffPaths[0], repaired);
6872
- try {
6873
- await input.runtimeBridge?.onHostHandoffRepaired?.(toolInput.sessionID, ledger.task_id, handoffPaths[0], source, repaired);
6853
+ }
6854
+ else {
6855
+ const exactCarryForward = ledger.criteria.length === previous.criteria.length &&
6856
+ previous.criteria.every((criterion, index) => criterion === ledger.criteria[index]);
6857
+ const strictAppend = ledger.criteria.length > previous.criteria.length &&
6858
+ previous.criteria.every((criterion, index) => criterion === ledger.criteria[index]);
6859
+ // Serial parent identity is controller-owned. Repair only an omitted link on
6860
+ // an otherwise exact carry-forward/append; explicit conflicting links still fail.
6861
+ // Persist it so the worker's Read and later recovery see the same contract.
6862
+ if (reservedParallelDescriptor === undefined && ledger.parent_fingerprint === "none" &&
6863
+ (exactCarryForward || strictAppend)) {
6864
+ const source = await readFile(handoffPaths[0], "utf8");
6865
+ const handoff = JSON.parse(source);
6866
+ const validated = validateHandoffSchema(handoff);
6867
+ const current = validated.ok ? inspectAcceptanceContinuity(validated.value).ledger : undefined;
6868
+ if (current === undefined || JSON.stringify(current) !== JSON.stringify(ledger)) {
6869
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6870
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6871
+ });
6872
+ }
6873
+ ledger = { ...ledger, parent_fingerprint: previous.fingerprint };
6874
+ handoff.ext[ACCEPTANCE_CONTINUITY_EXTENSION] = ledger;
6875
+ const repaired = `${JSON.stringify(handoff, null, 2)}\n`;
6876
+ await writeFile(handoffPaths[0], repaired);
6877
+ try {
6878
+ await input.runtimeBridge?.onHostHandoffRepaired?.(toolInput.sessionID, ledger.task_id, handoffPaths[0], source, repaired);
6879
+ }
6880
+ catch (error) {
6881
+ await writeFile(handoffPaths[0], source);
6882
+ throw error;
6883
+ }
6884
+ await inspect(handoffPaths[0], undefined, { report: true });
6874
6885
  }
6875
- catch (error) {
6876
- await writeFile(handoffPaths[0], source);
6877
- throw error;
6886
+ if (ledger.parent_fingerprint !== previous.fingerprint || (!exactCarryForward && !strictAppend)) {
6887
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6888
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6889
+ });
6878
6890
  }
6879
- await inspect(handoffPaths[0], undefined, { report: true });
6880
- }
6881
- if (ledger.parent_fingerprint !== previous.fingerprint || (!exactCarryForward && !strictAppend)) {
6882
- throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
6883
- defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
6884
- });
6885
6891
  }
6886
6892
  }
6887
6893
  validatedRootAcceptance = ledger;
@@ -2,13 +2,14 @@ import { createHash } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
3
  import { createReadStream } from "node:fs";
4
4
  import { lstat, readlink } from "node:fs/promises";
5
- import { resolve } from "node:path";
5
+ import { isAbsolute, relative, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { TOOL_ENVIRONMENT } from "../runtime-mission-assets.js";
8
8
  import { MISSION_REVIEW_REFERENCE } from "../core/operator-mission.js";
9
9
  import { canonicalAgent } from "../core/runtime-profile.js";
10
10
  import { taskChildSessionID } from "./task-result-repair.js";
11
11
  import { normalizeManifestScope } from "../core/path.js";
12
+ import { declaredArtifacts } from "./declared-artifacts.js";
12
13
  const exec = promisify(execFile);
13
14
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
14
15
  /** Recover from native completion records, never from the pending review's inherited child ID. */
@@ -81,16 +82,24 @@ export async function missionReviewSource(directory, run) {
81
82
  return { fingerprint: `sha256:${hash.digest("hex")}`,
82
83
  excerpt: "[Read-only units: no declared output files. Review the supplied observations, traces and validation evidence.]" };
83
84
  // The shared dependency environment is local tooling, never reviewed or pinned source.
84
- const scopes = [...writes, `:(exclude)${TOOL_ENVIRONMENT}`];
85
+ const external = [], local = [];
86
+ for (const path of writes) {
87
+ const scoped = relative(directory, resolve(directory, path)).replaceAll("\\", "/");
88
+ if (scoped === ".." || scoped.startsWith("../") || isAbsolute(scoped))
89
+ external.push(resolve(path));
90
+ else
91
+ local.push(scoped || ".");
92
+ }
93
+ const scopes = [...local, `:(exclude)${TOOL_ENVIRONMENT}`];
85
94
  const git = async (args) => (await exec("git", args, { cwd: directory, maxBuffer: 8 * 1024 * 1024 })).stdout;
86
- const names = await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...scopes]);
87
- const untracked = new Set((await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...scopes])).split("\0").filter(Boolean));
95
+ const names = local.length ? await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...scopes]) : "";
96
+ const untracked = new Set((local.length ? await git(["ls-files", "-z", "--others", "--exclude-standard", "--", ...scopes]) : "").split("\0").filter(Boolean));
88
97
  // Explicitly declared outputs are review evidence even when gitignored (for example a probe
89
98
  // JSON in _testenv). Their bytes must participate in staleness checks as well as the excerpt.
90
- const ignored = await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...scopes]);
99
+ const ignored = local.length ? await git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ...scopes]) : "";
91
100
  for (const path of ignored.split("\0").filter(Boolean))
92
101
  untracked.add(path);
93
- let excerpt = await git(["diff", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...scopes]).catch(() => git(["diff", "--no-ext-diff", "--no-textconv", "--", ...scopes]));
102
+ let excerpt = local.length ? await git(["diff", "--no-ext-diff", "--no-textconv", "HEAD", "--", ...scopes]).catch(() => git(["diff", "--no-ext-diff", "--no-textconv", "--", ...scopes])) : "";
94
103
  const unchanged = excerpt.length === 0;
95
104
  const paths = [...new Set([...names.split("\0"), ...ignored.split("\0")].filter(Boolean))].sort();
96
105
  const omitted = [];
@@ -129,6 +138,13 @@ export async function missionReviewSource(directory, run) {
129
138
  hash.update("deleted");
130
139
  }
131
140
  }
141
+ if (external.length) {
142
+ const artifacts = await declaredArtifacts(external, Math.max(1, 24_000 - Buffer.byteLength(excerpt)));
143
+ hash.update(JSON.stringify(artifacts.entries));
144
+ excerpt += artifacts.excerpt;
145
+ if (artifacts.truncated)
146
+ omitted.push("external artifacts");
147
+ }
132
148
  const bytes = Buffer.from(excerpt);
133
149
  return { fingerprint: `sha256:${hash.digest("hex")}`, excerpt: (bytes.length > 24_000 ? bytes.subarray(0, 24_000).toString("utf8") : excerpt) +
134
150
  (bytes.length > 24_000 || omitted.length ? `\n[EXCERPT TRUNCATED: ${omitted.slice(0, 20).join(", ")}; supply focused traces for missing sections, not another implementation unit]` : "") };
@@ -323,10 +323,11 @@ export function createProfiledPlugin(profile, assetVersion) {
323
323
  continuationCheckpoint: async (root) => {
324
324
  const mission = await missions.read(root);
325
325
  const run = await operators.read(root);
326
- // Cancellation after settled work remains an unfinished accepted goal. Keep its spend and
327
- // evidence bound through a real user restart; the cancelled packet grants no dispatch itself.
326
+ // Cancellation never refunds admitted work. Keep the ledger even when the last Worker
327
+ // was interrupted before validation; acceptance supersession is decided by mission planning,
328
+ // not by resetting the goal budget. This checkpoint grants no dispatch itself.
328
329
  if (mission && (!["completed", "cancelled"].includes(mission.phase) ||
329
- (mission.phase === "cancelled" && run && cancelledMissionRetainsAcceptance(run)))) {
330
+ (mission.phase === "cancelled" && run))) {
330
331
  return JSON.stringify(missionPacket(mission, run));
331
332
  }
332
333
  return await operators.continuationCheckpoint(root) ?? await proposals.continuationCheckpoint(root);
@@ -351,6 +352,16 @@ export function createProfiledPlugin(profile, assetVersion) {
351
352
  return active && unit.childSessionID === child &&
352
353
  unit.unit.validation.some(candidate => normalizeCommand(candidate) === command);
353
354
  },
355
+ ownsMissionDispatch: async (root, callID, taskID) => {
356
+ const owner = taskOwners.get(callID);
357
+ if (owner?.root !== root || owner.operator)
358
+ return false;
359
+ const mission = await missions.read(root), state = await operators.read(root);
360
+ return mission !== undefined && state !== undefined && mission.runID === state.runID &&
361
+ mission.phase === "running" && state.phase === "running" &&
362
+ state.units.some(unit => unit.status === "running" && unit.callID === callID &&
363
+ /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1] === taskID);
364
+ },
354
365
  allowsInvestigativeShell: async (child) => {
355
366
  const root = await rootFor(child);
356
367
  if (!root)
@@ -466,7 +477,8 @@ export function createProfiledPlugin(profile, assetVersion) {
466
477
  if (!mission.dispatchOpen && (["open", "running"].includes(mission.phase) ||
467
478
  (mission.phase === "submitted" && mission.submission?.status !== "ready"))) {
468
479
  return { ...packet, task: missions.task(mission),
469
- next_action: "The previous Coordinator Task is finished. Dispatch this exact Task to continue the same mission and Coordinator session; keep the original requirements and cumulative budget." };
480
+ next_action: (run?.units.some(unit => unit.dispatchDenial) ? `${packet.next_action}\n` : "") +
481
+ "The previous Coordinator Task is finished. Dispatch this exact Task to continue the same mission and Coordinator session; keep the original requirements and cumulative budget." };
470
482
  }
471
483
  return packet;
472
484
  }
@@ -1264,7 +1276,6 @@ export function createProfiledPlugin(profile, assetVersion) {
1264
1276
  const dispatcher = actor === root ? undefined : { sessionID: actor, callID: mission.callID };
1265
1277
  const state = replanning ? await operators.replanMission(root, previous.runID, plan, dispatcher)
1266
1278
  : await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren);
1267
- await restorePriorAcceptance(root, state);
1268
1279
  await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
1269
1280
  control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
1270
1281
  await missions.update(root, item => {
@@ -1277,7 +1288,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1277
1288
  });
1278
1289
  return JSON.stringify(await operators.next(root, actor));
1279
1290
  }
1280
- tools[planUnits] = { description: "Coordinator (or single-unit Fast-lane Operator): declare useful units, then dispatch the returned Worker immediately. Host generates IDs, handoff, manifest and proof mapping. Keep every original requirement covered. Use write: [] for read-only verification; use dir/** for directory outputs including not-yet-created trees. Final validation command in each unit proves that unit; read-only diagnostic commands need no registration. Recalling with reason replaces settled work within unchanged requirements and cumulative budget; include required scope extensions here. Rejected budget, contract or control-storage preparation preserves the existing run so you can correct the plan directly.",
1291
+ tools[planUnits] = { description: "Coordinator (or single-unit Fast-lane Operator): declare useful units, then dispatch the returned Worker immediately. Host generates IDs, handoff, manifest and proof mapping. Keep every original requirement covered. Use write: [] for read-only verification; use dir/** for directory outputs including not-yet-created trees. Native absolute paths support global installs and external outputs under host permissions; include their actual paths in read/write for evidence. Final validation command in each unit proves that unit; read-only diagnostic commands need no registration. Recalling with reason replaces settled work within unchanged requirements and cumulative budget; include required scope extensions here. Rejected budget, contract or control-storage preparation preserves the existing run so you can correct the plan directly.",
1281
1292
  args: { units: { type: "array", minItems: 1, maxItems: 32, items: { type: "object", additionalProperties: false,
1282
1293
  properties: { title: { type: "string" }, objective: { type: "string" }, read: stringList, write: stringList,
1283
1294
  validation: stringList, requirement_ids: stringList }, required: ["title", "objective", "write", "validation"] } },
@@ -1780,7 +1791,8 @@ export function createProfiledPlugin(profile, assetVersion) {
1780
1791
  if (delegated) {
1781
1792
  if (!mission || mission.runID !== state.runID)
1782
1793
  await proposals.assertExecutionApproved(root, state.planHash);
1783
- await restorePriorAcceptance(root, state);
1794
+ if (!mission || mission.runID !== state.runID)
1795
+ await restorePriorAcceptance(root, state);
1784
1796
  expandedWorker = await operators.admitWorker(root, request.sessionID, request.callID, args);
1785
1797
  taskOwners.set(request.callID, { root, actor: request.sessionID, operator: false });
1786
1798
  }
@@ -1847,7 +1859,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1847
1859
  catch (error) {
1848
1860
  if (delegated) {
1849
1861
  taskOwners.delete(request.callID);
1850
- await operators.rejectedAdmission(root, request.callID);
1862
+ await operators.rejectedAdmission(root, request.callID, error instanceof Error ? error.message : String(error));
1851
1863
  }
1852
1864
  throw error;
1853
1865
  }
@@ -4,6 +4,7 @@ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { goalFingerprint } from "../core/goal-bound.js";
5
5
  import { normalizeManifestScope } from "../core/path.js";
6
6
  import { RUNTIME_PROFILES } from "../core/runtime-profile.js";
7
+ import { declaredArtifacts } from "./declared-artifacts.js";
7
8
  const controlRoots = new Set([".git", ...Object.values(RUNTIME_PROFILES).map(profile => profile.stateDirectory)]);
8
9
  /** Live orchestration/Git bookkeeping is readable context, not the source being validated. */
9
10
  export function isRuntimeControlPath(path) {
@@ -57,6 +58,19 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash, sourcePoli
57
58
  return undefined;
58
59
  return goalFingerprint({ manifest_hash: `sha256:${manifestHash}`, entries });
59
60
  }
61
+ function outside(projectRoot, path) {
62
+ const scoped = relative(projectRoot, path).replaceAll("\\", "/");
63
+ return scoped === ".." || scoped.startsWith("../") || isAbsolute(scoped);
64
+ }
65
+ async function declaredScopeDigest(projectRoot, paths, manifestHash, source) {
66
+ const local = paths.filter(path => !outside(projectRoot, path));
67
+ const external = paths.filter(path => outside(projectRoot, path));
68
+ const project = await protectedScopeDigest(projectRoot, local, manifestHash, source ? "project-files-v1" : undefined);
69
+ if (project === undefined)
70
+ return undefined;
71
+ const artifacts = await declaredArtifacts(external).catch(() => undefined);
72
+ return artifacts && goalFingerprint({ project, external: artifacts.entries });
73
+ }
60
74
  export async function protectedSnapshot(authorization) {
61
75
  const manifestSource = await readFile(authorization.manifestPath).catch(() => undefined);
62
76
  if (manifestSource === undefined)
@@ -72,15 +86,18 @@ export async function protectedSnapshot(authorization) {
72
86
  });
73
87
  const candidatePaths = actualPaths(manifest.write);
74
88
  const sourcePaths = [...new Set([...actualPaths(manifest.read), ...candidatePaths])];
75
- const source = await protectedScopeDigest(authorization.projectRoot, sourcePaths, manifestHash, "project-files-v1");
89
+ const external = sourcePaths.some(path => outside(authorization.projectRoot, path));
90
+ const source = external ? await declaredScopeDigest(authorization.projectRoot, sourcePaths, manifestHash, true)
91
+ : await protectedScopeDigest(authorization.projectRoot, sourcePaths, manifestHash, "project-files-v1");
76
92
  // Explicit outputs and the exact operation manifest remain pinned, including control-like paths.
77
- const candidate = await protectedScopeDigest(authorization.projectRoot, candidatePaths, manifestHash);
93
+ const candidate = external ? await declaredScopeDigest(authorization.projectRoot, candidatePaths, manifestHash, false)
94
+ : await protectedScopeDigest(authorization.projectRoot, candidatePaths, manifestHash);
78
95
  if (source === undefined || candidate === undefined || relativePath.startsWith("../") || isAbsolute(relativePath))
79
96
  return undefined;
80
97
  return { binding: { manifest_hash: `sha256:${manifestHash}`, project_root: authorization.projectRoot,
81
- manifest_path: relativePath, source_policy: "project-files-v1",
82
- source_paths: sourcePaths.map(path => relative(authorization.projectRoot, path).replaceAll("\\", "/")),
83
- candidate_paths: candidatePaths.map(path => relative(authorization.projectRoot, path).replaceAll("\\", "/")) }, source, candidate };
98
+ manifest_path: relativePath, source_policy: external ? "declared-paths-v1" : "project-files-v1",
99
+ source_paths: sourcePaths.map(path => (outside(authorization.projectRoot, path) ? path : relative(authorization.projectRoot, path)).replaceAll("\\", "/")),
100
+ candidate_paths: candidatePaths.map(path => (outside(authorization.projectRoot, path) ? path : relative(authorization.projectRoot, path)).replaceAll("\\", "/")) }, source, candidate };
84
101
  }
85
102
  export async function refreshProtectedSnapshot(projectRoot, binding) {
86
103
  const manifestSource = await readFile(resolve(projectRoot, binding.manifest_path)).catch(() => undefined);
@@ -89,6 +106,11 @@ export async function refreshProtectedSnapshot(projectRoot, binding) {
89
106
  const manifestHash = createHash("sha256").update(manifestSource).digest("hex");
90
107
  if (`sha256:${manifestHash}` !== binding.manifest_hash)
91
108
  return undefined;
109
+ if (binding.source_policy === "declared-paths-v1") {
110
+ const source = await declaredScopeDigest(projectRoot, binding.source_paths.map(path => resolve(projectRoot, path)), manifestHash, true);
111
+ const candidate = await declaredScopeDigest(projectRoot, binding.candidate_paths.map(path => resolve(projectRoot, path)), manifestHash, false);
112
+ return source === undefined || candidate === undefined ? undefined : { source, candidate };
113
+ }
92
114
  // Legacy evidence retains its original recipe; a new policy cannot relabel stale proof as fresh.
93
115
  if (binding.source_policy !== undefined && binding.source_policy !== "project-files-v1")
94
116
  return undefined;
@@ -29,6 +29,8 @@ export interface RuntimeBridge {
29
29
  completedReviewPrompts?(rootSessionID: string, requestedPrompt: string): Promise<readonly string[]>;
30
30
  requiresExplicitAcceptance?(rootSessionID: string): Promise<boolean>;
31
31
  ownsCanonicalValidation?(rootSessionID: string, unitID: string, childSessionID: string, command: string): Promise<boolean>;
32
+ /** A mission's hash-pinned Task has already passed durable run/acceptance admission. */
33
+ ownsMissionDispatch?(rootSessionID: string, callID: string, taskID: string): Promise<boolean>;
32
34
  onHostHandoffRepaired?(rootSessionID: string, taskID: string, handoffPath: string, original: string, repaired: string): Promise<void>;
33
35
  allowsInvestigativeShell?(sessionID: string): Promise<boolean>;
34
36
  onSerialSettlement?(settlement: SerialDispatchSettlement): Promise<void>;
package/dist/plugin/v2.js CHANGED
@@ -3,10 +3,17 @@ import { bindMissionProgress } from "./mission-progress.js";
3
3
  import { owningServiceSessionList } from "./v2-session-history.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
+ import { V010_RUNTIME_ASSET_VERSION } from "../asset-version.js";
6
7
  // Capture once when this module evaluates. A later package replacement must not make an old
7
8
  // process report the replacement's bytes as its loaded adapter.
8
9
  const loadedAdapter = Object.freeze({ adapter_url: import.meta.url,
9
10
  adapter_sha256: createHash("sha256").update(readFileSync(new URL(import.meta.url))).digest("hex"),
11
+ runtime_asset_version: V010_RUNTIME_ASSET_VERSION,
12
+ // Named module snapshots, not a claim to identify every transitive dependency. Retain these
13
+ // bytes across package replacement so an unchanged adapter cannot conceal an old implementation.
14
+ implementation_sha256: Object.freeze(Object.fromEntries([
15
+ "index", "profiled", "gate", "protected-snapshot", "declared-artifacts",
16
+ ].map(name => [`${name}.js`, createHash("sha256").update(readFileSync(new URL(`./${name}.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`, import.meta.url))).digest("hex")]))),
10
17
  loaded_at: new Date().toISOString(), pid: process.pid });
11
18
  const childSelectionKey = (id) => `v2-child-model-selection:${id}`;
12
19
  function record(value) {
@@ -114,6 +114,10 @@ Investigate only enough to start the first useful Worker. Prefer a targeted read
114
114
  inventory or speculative full design. Call ${profile.toolPrefix}plan_units with concise units:
115
115
  title, objective, read/write file or directory scopes, validation commands, optionally requirement_ids.
116
116
  For read-only verification, use write: []; do not invent an output file or request write access to inputs.
117
+ Use absolute native paths for requested global installations or other external outputs; dir/** declares
118
+ a directory including a not-yet-created tree. These are execution/evidence scopes, not an additional
119
+ permission grant: the host's native permissions still apply. Include the actual external input/output
120
+ paths in read/write so validation and review observe them; do not substitute a repository symlink.
117
121
  Keep all original requirements covered; omitted requirement_ids means all. Last validation command proves
118
122
  that unit. If your quick check shows repository-declared dependencies or the test runner are missing, keep
119
123
  setup inside the first unit: declare its checks through the repository-local tool environment ${TOOL_ENVIRONMENT}/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.11",
3
+ "version": "0.12.12",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",