sortie-dogs 0.7.0 → 0.7.1

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.7.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.7.0)
27
+ Release: [v0.7.1](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.7.1)
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.51-state-observability-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.52-acceptance-continuity-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.51-state-observability-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.52-acceptance-continuity-v1";
@@ -0,0 +1,20 @@
1
+ export declare const ACCEPTANCE_CONTINUITY_EXTENSION: "sortie-dogs/acceptance-continuity";
2
+ export declare const ACCEPTANCE_CONTINUITY_SCHEMA_VERSION: "0.1";
3
+ export declare const ACCEPTANCE_CONTINUITY_AUTHORITY: "dispatch";
4
+ export declare const MAX_ACCEPTANCE_CONTINUITY_BYTES: number;
5
+ export declare const MAX_ACCEPTANCE_CRITERIA = 24;
6
+ export interface AcceptanceContinuityLedger {
7
+ readonly schema_version: "0.1";
8
+ readonly authority: "dispatch";
9
+ readonly task_id: string;
10
+ readonly criteria: readonly string[];
11
+ readonly fingerprint: string;
12
+ readonly parent_fingerprint: "none" | string;
13
+ }
14
+ export interface AcceptanceContinuityInspection {
15
+ readonly ledger: AcceptanceContinuityLedger | undefined;
16
+ readonly error: "absent" | "malformed" | "oversize" | undefined;
17
+ }
18
+ export declare function normalizeAcceptanceCriteria(criteria: readonly string[]): string[];
19
+ export declare function acceptanceContinuityFingerprint(criteria: readonly string[]): string;
20
+ export declare function inspectAcceptanceContinuity(handoff: unknown): AcceptanceContinuityInspection;
@@ -0,0 +1,61 @@
1
+ import { createHash } from "node:crypto";
2
+ export const ACCEPTANCE_CONTINUITY_EXTENSION = "sortie-dogs/acceptance-continuity";
3
+ export const ACCEPTANCE_CONTINUITY_SCHEMA_VERSION = "0.1";
4
+ export const ACCEPTANCE_CONTINUITY_AUTHORITY = "dispatch";
5
+ export const MAX_ACCEPTANCE_CONTINUITY_BYTES = 32 * 1024;
6
+ export const MAX_ACCEPTANCE_CRITERIA = 24;
7
+ const FINGERPRINT = /^sha256:[0-9a-f]{64}$/u;
8
+ const MAX_TASK_ID = 256;
9
+ const MAX_CRITERION = 2_000;
10
+ function object(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ export function normalizeAcceptanceCriteria(criteria) {
14
+ return criteria.map((criterion) => criterion.replace(/\r\n?/gu, "\n").normalize("NFC"));
15
+ }
16
+ export function acceptanceContinuityFingerprint(criteria) {
17
+ const canonical = JSON.stringify(normalizeAcceptanceCriteria(criteria));
18
+ return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
19
+ }
20
+ export function inspectAcceptanceContinuity(handoff) {
21
+ if (!object(handoff) || !object(handoff.ext) || !(ACCEPTANCE_CONTINUITY_EXTENSION in handoff.ext)) {
22
+ return { ledger: undefined, error: "absent" };
23
+ }
24
+ const raw = handoff.ext[ACCEPTANCE_CONTINUITY_EXTENSION];
25
+ let bytes;
26
+ try {
27
+ bytes = new TextEncoder().encode(JSON.stringify(raw)).byteLength;
28
+ }
29
+ catch {
30
+ return { ledger: undefined, error: "malformed" };
31
+ }
32
+ if (bytes > MAX_ACCEPTANCE_CONTINUITY_BYTES)
33
+ return { ledger: undefined, error: "oversize" };
34
+ if (!object(raw) || Object.keys(raw).some((key) => ![
35
+ "schema_version", "authority", "task_id", "criteria", "fingerprint", "parent_fingerprint",
36
+ ].includes(key)) || raw.schema_version !== ACCEPTANCE_CONTINUITY_SCHEMA_VERSION ||
37
+ raw.authority !== ACCEPTANCE_CONTINUITY_AUTHORITY || typeof raw.task_id !== "string" ||
38
+ raw.task_id.length === 0 || raw.task_id.length > MAX_TASK_ID || !Array.isArray(raw.criteria) ||
39
+ raw.criteria.length === 0 || raw.criteria.length > MAX_ACCEPTANCE_CRITERIA ||
40
+ !raw.criteria.every((criterion) => typeof criterion === "string" && criterion.length > 0 && criterion.length <= MAX_CRITERION) ||
41
+ new Set(raw.criteria).size !== raw.criteria.length || typeof raw.fingerprint !== "string" ||
42
+ !FINGERPRINT.test(raw.fingerprint) || !(raw.parent_fingerprint === "none" ||
43
+ (typeof raw.parent_fingerprint === "string" && FINGERPRINT.test(raw.parent_fingerprint)))) {
44
+ return { ledger: undefined, error: "malformed" };
45
+ }
46
+ const criteria = normalizeAcceptanceCriteria(raw.criteria);
47
+ if (new Set(criteria).size !== criteria.length || acceptanceContinuityFingerprint(criteria) !== raw.fingerprint) {
48
+ return { ledger: undefined, error: "malformed" };
49
+ }
50
+ return {
51
+ ledger: {
52
+ schema_version: ACCEPTANCE_CONTINUITY_SCHEMA_VERSION,
53
+ authority: ACCEPTANCE_CONTINUITY_AUTHORITY,
54
+ task_id: raw.task_id,
55
+ criteria,
56
+ fingerprint: raw.fingerprint,
57
+ parent_fingerprint: raw.parent_fingerprint,
58
+ },
59
+ error: undefined,
60
+ };
61
+ }
package/dist/index.d.ts CHANGED
@@ -19,5 +19,7 @@ export type { HandoffDenialReason, OpenCodeEvent, OpenCodeHooks, OpenCodePlugin,
19
19
  export { CONSULTATION_CAPABILITIES, CONSULTATION_ROLE_POLICY, MAX_REVIEW_ARTIFACT_BYTES, SOURCE_REVIEW_RISK_TAGS, STRATEGY_TRIGGERS, evaluateReviewAvailability, evaluateReviewGate, evaluateSourceReviewRequirement, isSourceReviewRiskTag, requiresSourceReview, shouldConsultStrategy, validateReviewArtifact, validateReviewVerdict, } from "./core/consultation.js";
20
20
  export type { ConsultationAdapter, ConsultationCapability, ConsultationRequest, ConsultationResult, ReviewArtifact, ReviewAvailability, ReviewFinding, ReviewFindingSeverity, ReviewGateInput, ReviewGateResult, ReviewVerdict, ReviewVerdictKind, SourceReviewConsultationRequest, SourceReviewConsultationResult, SourceReviewRequirement, SourceReviewRequirementInput, SourceReviewRiskTag, StrategyConsultationRequest, StrategyConsultationResult, StrategyTrigger, StrategyTriggerInput, UnavailableConsultationResult, ValidationResult, } from "./core/consultation.js";
21
21
  export type * from "./core/types.js";
22
+ export { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, MAX_ACCEPTANCE_CONTINUITY_BYTES, MAX_ACCEPTANCE_CRITERIA, normalizeAcceptanceCriteria, } from "./core/acceptance-continuity.js";
23
+ export type { AcceptanceContinuityInspection, AcceptanceContinuityLedger, } from "./core/acceptance-continuity.js";
22
24
  export { inspectRetainedStateCapsule, MAX_RETAINED_STATE_BYTES, MAX_RETAINED_STATE_ITEMS, MAX_RETAINED_STATE_WARNINGS, RETAINED_STATE_AUTHORITY, RETAINED_STATE_EXTENSION, RETAINED_STATE_SCHEMA_VERSION, } from "./core/retained-state.js";
23
25
  export type { AdmissionReceipt, NextEvidenceDecision, RetainedStateCapsule, RetainedStateInspection, RetainedStateWarning, RetainedValidationAttempt, } from "./core/retained-state.js";
package/dist/index.js CHANGED
@@ -15,4 +15,5 @@ export { SortieDogsPlugin } from "./plugin/index.js";
15
15
  */
16
16
  export { HandoffDeniedError, InvalidModelTargetError, ModelRoutingDeniedError, isExplicitTaskHandoff, } from "./plugin/index.js";
17
17
  export { CONSULTATION_CAPABILITIES, CONSULTATION_ROLE_POLICY, MAX_REVIEW_ARTIFACT_BYTES, SOURCE_REVIEW_RISK_TAGS, STRATEGY_TRIGGERS, evaluateReviewAvailability, evaluateReviewGate, evaluateSourceReviewRequirement, isSourceReviewRiskTag, requiresSourceReview, shouldConsultStrategy, validateReviewArtifact, validateReviewVerdict, } from "./core/consultation.js";
18
+ export { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, MAX_ACCEPTANCE_CONTINUITY_BYTES, MAX_ACCEPTANCE_CRITERIA, normalizeAcceptanceCriteria, } from "./core/acceptance-continuity.js";
18
19
  export { inspectRetainedStateCapsule, MAX_RETAINED_STATE_BYTES, MAX_RETAINED_STATE_ITEMS, MAX_RETAINED_STATE_WARNINGS, RETAINED_STATE_AUTHORITY, RETAINED_STATE_EXTENSION, RETAINED_STATE_SCHEMA_VERSION, } from "./core/retained-state.js";
@@ -89,25 +89,27 @@ const ROLLOVER_PROMPT = [
89
89
  "If a section has nothing, write exactly '- なし'. Never delete a heading.",
90
90
  "At most 2 content lines per section. Keep the whole output under 1200 characters.",
91
91
  "Copy task identity, manifest paths, commands, exit codes, and counter values character-for-character.",
92
+ "Never author, shorten, infer, or treat summary text as acceptance authority. Preserve the exact acceptance fingerprint and handoff path; the resumed coordinator rereads that immutable handoff before dispatch.",
93
+ "The unmet-user-requirements, constraints, candidate queue, blocker, and next-action sections never replace the immutable handoff acceptance ledger.",
92
94
  "The coordinator final report immediately before compaction is the newest source of truth. Its outcomes and next action override older context.",
93
95
  "Never list a unit as uncommitted or next when that final report says it was committed or completed.",
94
96
  "Drop the finished unit's conversation, raw logs, diffs, and tool output.",
95
- "Preserve task identity, every accepted fact, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, inventory fingerprint, bounded candidate queue, pending tracker updates, tracker flush state, blocker state, and the exact next action. If a value is absent, write なし; never guess one.",
97
+ "Preserve task identity, acceptance fingerprint, exact handoff path, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, inventory fingerprint, bounded candidate queue, pending tracker updates, tracker flush state, blocker state, and the exact next action. If a value is absent, write なし; never guess one.",
96
98
  "Never write credentials, API keys, tokens, personal data, or source code.",
97
99
  "Never output an HTML comment.",
98
100
  "",
99
101
  ROLLOVER_TOKEN,
100
102
  "",
101
103
  "## 未達のユーザー要求",
102
- "- <まだ満たされていない明示要求>",
104
+ "- <acceptance criteriaを除く、まだ満たされていない明示要求>",
103
105
  "",
104
106
  "## task identity と制約",
105
107
  "- <task_id・候補識別子・project root。該当なしはなし>",
106
- "- <守り続ける制約と確定済みの受け入れ基準>",
108
+ "- <守り続ける非acceptance制約。該当なしはなし>",
107
109
  "",
108
110
  "## manifest",
109
- "- source_manifest: <exact entries または none>",
110
- "- operation_manifest: <exact path または none>",
111
+ "- handoff_path: <exact path または none> / acceptance_fingerprint: <exact fingerprint または none>",
112
+ "- source_manifest: <exact entries または none> / operation_manifest: <exact path または none>",
111
113
  "",
112
114
  "## validation履歴",
113
115
  "- <command> | exit <code> | <fingerprint>",
@@ -116,7 +118,7 @@ const ROLLOVER_PROMPT = [
116
118
  "- batchTarget: <n> / batchAttempted: <n> / batchCommitted: <n> / batchReconciled: <n>",
117
119
  "",
118
120
  "## tracker batch state",
119
- "- inventoryFingerprint: <fingerprint> / candidateQueue: <bounded identity, status, ordering, implementation root, acceptance fingerprint, acceptance hashes, and redacted acceptance digest records>",
121
+ "- inventoryFingerprint: <fingerprint> / candidateQueue: <bounded identity, status, ordering, implementation root, exact handoff path, and opaque acceptance fingerprint only>",
120
122
  "- pendingTrackerUpdates: <terminal outcomes awaiting flush> / flushState: <pending | flushed | reconciliation-required>",
121
123
  "",
122
124
  "## 未解決blocker",
@@ -127,7 +129,7 @@ const ROLLOVER_PROMPT = [
127
129
  ].join("\n");
128
130
  const RECOVERY_ROLLOVER_PROMPT = ROLLOVER_PROMPT
129
131
  .replace("The coordinator final report immediately before compaction is the newest source of truth. Its outcomes and next action override older context.", "The recovery report overrides only terminal outcomes and batch counters. Preserve unmet user requirements, ordered scope, no-stop constraints, and the exact next unit from the conversation.")
130
- .replace("Preserve task identity, every accepted fact, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, inventory fingerprint, bounded candidate queue, pending tracker updates, tracker flush state, blocker state, and the exact next action. If a value is absent, write なし; never guess one.", "Preserve task identity, every accepted fact, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, inventory fingerprint, bounded candidate queue, pending tracker updates, tracker flush state, blocker state, unmet ordered scope, and the exact next unit. If a value is absent, write なし; never guess one.");
132
+ .replace("Preserve task identity, acceptance fingerprint, exact handoff path, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, inventory fingerprint, bounded candidate queue, pending tracker updates, tracker flush state, blocker state, and the exact next action. If a value is absent, write なし; never guess one.", "Preserve task identity, acceptance fingerprint, exact handoff path, both manifests, ordered validation history, batchTarget, batchAttempted, batchCommitted, batchReconciled, inventory fingerprint, bounded candidate queue, pending tracker updates, tracker flush state, blocker state, unmet ordered scope, and the exact next unit. Never derive acceptance from summary text. If a value is absent, write なし; never guess one.");
131
133
  const ROLLOVER_HEADINGS = [
132
134
  "## 未達のユーザー要求",
133
135
  "## task identity と制約",
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { lstat, mkdir, open, readFile, realpath, rm, stat } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
5
+ import { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, } from "../core/acceptance-continuity.js";
6
+ import { resolveGlobalConfigRoot } from "../core/initialize.js";
5
7
  import { normalizeRelativePath, RelativePathError } from "../core/path.js";
6
8
  import { ScopeLeaseError, ScopeLeaseRegistry } from "../core/scope-lease-registry.js";
7
9
  import { produceWorktreeCommitArtifact, recoverWorktreeCommitArtifact, resolveValidationExecutable, WorktreeCommitArtifactError, } from "../core/worktree-commit-artifact.js";
@@ -36,6 +38,7 @@ const GIT_POINTER_LIMIT = 4096;
36
38
  const PARALLEL_OUTCOME_MARKER = "SORTIE_PARALLEL_OUTCOME";
37
39
  const CANONICAL_CONTRACT_DIRECTORY = ".sortie-dogs/contracts";
38
40
  const CANONICAL_CONTRACT_HANDOFF = `${CANONICAL_CONTRACT_DIRECTORY}/handoff.json`;
41
+ const GENERATED_PARALLEL_ACCEPTANCE = "Complete the prepared parallel descriptor within its declared scope.";
39
42
  export const PARALLEL_COMMIT_ARTIFACT_CAPABILITY = "sortie_create_parallel_commit_artifact";
40
43
  const CONTRACT_DEFECTS = { limit: 8, pointerCharacters: 120 };
41
44
  function contractPointer(pointer) {
@@ -498,7 +501,7 @@ function taskHeaderCount(text, keys) {
498
501
  }
499
502
  function taskInlineValues(text, keys) {
500
503
  const aliases = keys.map((key) => key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")).join("|");
501
- return [...text.matchAll(new RegExp(`^\\s*(?:${aliases})\\s*:\\s*(\\S.*?)\\s*$`, "gimu"))]
504
+ return [...text.matchAll(new RegExp(`^[\\t ]*(?:${aliases})[\\t ]*:[\\t ]*(\\S.*?)[\\t ]*$`, "gimu"))]
502
505
  .map((match) => match[1]);
503
506
  }
504
507
  function taskBlockHasContent(text, keys) {
@@ -524,6 +527,45 @@ function taskBlockHasContent(text, keys) {
524
527
  }
525
528
  return false;
526
529
  }
530
+ function taskAcceptanceCriteria(text) {
531
+ const inline = taskInlineValues(text, ["acceptance"]);
532
+ if (inline.length === 1) {
533
+ const array = parseStringArray(inline[0]);
534
+ return normalizeAcceptanceCriteria(array ?? [unquoteValue(inline[0])]);
535
+ }
536
+ if (inline.length > 1)
537
+ return undefined;
538
+ const lines = text.split(/\r?\n/u);
539
+ const headers = lines.flatMap((line, index) => {
540
+ const match = /^(\s*)acceptance\s*:\s*$/iu.exec(line);
541
+ return match === null ? [] : [{ index, indent: match[1].replaceAll("\t", " ").length }];
542
+ });
543
+ if (headers.length !== 1)
544
+ return undefined;
545
+ const values = [];
546
+ for (let index = headers[0].index + 1; index < lines.length; index += 1) {
547
+ const line = lines[index];
548
+ if (line.trim().length === 0)
549
+ continue;
550
+ const indent = /^\s*/u.exec(line)[0].replaceAll("\t", " ").length;
551
+ if (indent <= headers[0].indent)
552
+ break;
553
+ const item = /^\s*-\s+(.+?)\s*$/u.exec(line)?.[1];
554
+ if (item === undefined)
555
+ return undefined;
556
+ let value;
557
+ try {
558
+ value = item.startsWith("\"") ? JSON.parse(item) : unquoteValue(item);
559
+ }
560
+ catch {
561
+ return undefined;
562
+ }
563
+ if (typeof value !== "string" || value.length === 0)
564
+ return undefined;
565
+ values.push(value);
566
+ }
567
+ return values.length === 0 ? undefined : normalizeAcceptanceCriteria(values);
568
+ }
527
569
  function taskContractText(text) {
528
570
  const lines = text.split(/\r?\n/u);
529
571
  const digestIndex = lines.findIndex((line) => /^\s*context_digest\s*:\s*$/iu.test(line));
@@ -801,7 +843,9 @@ export const SortieDogsPlugin = async (input, options) => {
801
843
  let manifestAbsent = false;
802
844
  let bootstrapRequired = false;
803
845
  let bootstrapCompleted = false;
804
- let assetVersionReported = false;
846
+ const assetVersionPins = new Map();
847
+ const rootAcceptanceContinuity = new Map();
848
+ const parallelAcceptanceContinuity = new Map();
805
849
  const globalConfig = await readOptionalGlobalConfig();
806
850
  const sessionOperationMetrics = new Map();
807
851
  function appLogInfo(message, sessionID, extra) {
@@ -957,7 +1001,6 @@ export const SortieDogsPlugin = async (input, options) => {
957
1001
  loading = (async () => {
958
1002
  try {
959
1003
  project ??= await createProjectPaths(resolveProjectRoot(input));
960
- await reportAssetVersionSkew(project);
961
1004
  const projectConfig = await readOptionalProjectConfig(project);
962
1005
  const environmentConfig = readEnvironmentConfig();
963
1006
  const parsed = resolvePluginConfigurationSourcesWithGlobal(globalConfig, projectConfig, environmentConfig, options);
@@ -998,19 +1041,54 @@ export const SortieDogsPlugin = async (input, options) => {
998
1041
  }
999
1042
  /**
1000
1043
  * Installed agents and this plugin implement two halves of one contract. A project that installed
1001
- * a different asset version is reported once so the mismatch is visible before it shows up as an
1002
- * unexplained handshake failure. Reporting never blocks: reinstalling is the operator's decision.
1044
+ * a different asset version is sticky for this plugin instance. Reinstalling files cannot update an
1045
+ * already-running coordinator prompt, so worker dispatch remains blocked until a fresh session loads.
1003
1046
  */
1004
- async function reportAssetVersionSkew(paths) {
1005
- if (assetVersionReported)
1006
- return;
1007
- assetVersionReported = true;
1008
- const marker = await readFile(paths.absolute(PROJECT_VERSION_MARKER), "utf8").catch(() => undefined);
1009
- const installed = marker?.trim();
1010
- if (installed === undefined || installed.length === 0 || installed === RUNTIME_ASSET_VERSION)
1011
- return;
1012
- console.warn(`Sortie-dogs: this project installed agent assets ${installed} but the loaded plugin ships ` +
1013
- `${RUNTIME_ASSET_VERSION}. Run "sortie-dogs init ." to reinstall the matching agents.`);
1047
+ async function readAssetVersionMarker(path) {
1048
+ try {
1049
+ const value = (await readFile(path, "utf8")).trim();
1050
+ return value.length === 0 ? { kind: "corrupt" } : { kind: "present", value };
1051
+ }
1052
+ catch (error) {
1053
+ return isRecord(error) && error.code === "ENOENT" ? { kind: "absent" } : { kind: "corrupt" };
1054
+ }
1055
+ }
1056
+ async function currentAssetVersionStatus(paths) {
1057
+ const local = await readAssetVersionMarker(paths.absolute(PROJECT_VERSION_MARKER));
1058
+ if (local.kind === "corrupt")
1059
+ return "mismatch";
1060
+ if (local.kind === "present")
1061
+ return local.value === RUNTIME_ASSET_VERSION ? "current" : "mismatch";
1062
+ let globalRoot;
1063
+ try {
1064
+ globalRoot = await resolveGlobalConfigRoot();
1065
+ }
1066
+ catch {
1067
+ return "mismatch";
1068
+ }
1069
+ const global = await readAssetVersionMarker(join(globalRoot, "sortie-dogs.version"));
1070
+ if (global.kind === "absent")
1071
+ return "unmarked";
1072
+ return global.kind === "present" && global.value === RUNTIME_ASSET_VERSION ? "current" : "mismatch";
1073
+ }
1074
+ async function pinAssetVersion(sessionID) {
1075
+ const pinned = assetVersionPins.get(sessionID);
1076
+ if (pinned !== undefined)
1077
+ return pinned;
1078
+ project ??= await createProjectPaths(resolveProjectRoot(input));
1079
+ const status = await currentAssetVersionStatus(project);
1080
+ assetVersionPins.set(sessionID, status);
1081
+ while (assetVersionPins.size > ACTIVE_SESSION_CACHE.maximum) {
1082
+ const candidate = assetVersionPins.keys().next().value;
1083
+ if (coordinatorRoots.has(candidate))
1084
+ break;
1085
+ assetVersionPins.delete(candidate);
1086
+ }
1087
+ if (status === "mismatch") {
1088
+ console.warn(`Sortie-dogs: installed agent assets do not match ${RUNTIME_ASSET_VERSION}. ` +
1089
+ "Repair the marker, then start a fresh coordinator session.");
1090
+ }
1091
+ return status;
1014
1092
  }
1015
1093
  /**
1016
1094
  * A project without an operation manifest never opted into the write gate. Enforcing a scope that
@@ -1277,6 +1355,20 @@ export const SortieDogsPlugin = async (input, options) => {
1277
1355
  operation_manifest: join(descriptor.managed_path, CANONICAL_CONTRACT_DIRECTORY, `${descriptor.task_id}.operation-manifest.json`),
1278
1356
  };
1279
1357
  }
1358
+ function parallelAcceptanceKey(descriptor) {
1359
+ return `${descriptor.run_id}\u0000${descriptor.dispatch_id}`;
1360
+ }
1361
+ function generatedParallelAcceptance(descriptor, parent) {
1362
+ const criteria = [...(parent?.criteria ?? []), GENERATED_PARALLEL_ACCEPTANCE];
1363
+ return {
1364
+ schema_version: ACCEPTANCE_CONTINUITY_SCHEMA_VERSION,
1365
+ authority: ACCEPTANCE_CONTINUITY_AUTHORITY,
1366
+ task_id: descriptor.task_id,
1367
+ criteria,
1368
+ fingerprint: acceptanceContinuityFingerprint(criteria),
1369
+ parent_fingerprint: parent?.fingerprint ?? "none",
1370
+ };
1371
+ }
1280
1372
  async function ensureParallelContractDirectory(canonicalRoot) {
1281
1373
  let parent = canonicalRoot;
1282
1374
  for (const segment of [".sortie-dogs", "contracts"]) {
@@ -1300,7 +1392,7 @@ export const SortieDogsPlugin = async (input, options) => {
1300
1392
  }
1301
1393
  return parent;
1302
1394
  }
1303
- async function createParallelControlFiles(descriptor, validationCommands) {
1395
+ async function createParallelControlFiles(descriptor, validationCommands, parentAcceptance) {
1304
1396
  const canonicalRoot = await realpath(descriptor.managed_path);
1305
1397
  if (!samePath(canonicalRoot, descriptor.managed_path)) {
1306
1398
  throw new ParallelDispatchError("lifecycle-failed", "Managed worktree identity changed before contract creation.");
@@ -1311,6 +1403,33 @@ export const SortieDogsPlugin = async (input, options) => {
1311
1403
  if (!samePath(contractDirectory, expectedContractDirectory)) {
1312
1404
  throw new ParallelDispatchError("lifecycle-failed", "Parallel contract directory escapes the managed worktree.");
1313
1405
  }
1406
+ const expectedAcceptance = generatedParallelAcceptance(descriptor, parentAcceptance);
1407
+ let acceptance = parallelAcceptanceContinuity.get(parallelAcceptanceKey(descriptor));
1408
+ if (acceptance === undefined) {
1409
+ const existing = await readFile(paths.handoff_path, "utf8").catch(() => undefined);
1410
+ if (existing !== undefined) {
1411
+ try {
1412
+ const parsed = JSON.parse(existing);
1413
+ const validated = validateHandoffSchema(parsed);
1414
+ const inspectedAcceptance = validated.ok ? inspectAcceptanceContinuity(validated.value).ledger : undefined;
1415
+ if (inspectedAcceptance?.task_id === descriptor.task_id &&
1416
+ inspectedAcceptance.criteria.at(-1) === GENERATED_PARALLEL_ACCEPTANCE &&
1417
+ (parentAcceptance === undefined ||
1418
+ (inspectedAcceptance.fingerprint === expectedAcceptance.fingerprint &&
1419
+ inspectedAcceptance.parent_fingerprint === expectedAcceptance.parent_fingerprint))) {
1420
+ acceptance = inspectedAcceptance;
1421
+ }
1422
+ }
1423
+ catch {
1424
+ // Exact generated-content comparison below rejects malformed or unrelated existing controls.
1425
+ }
1426
+ }
1427
+ }
1428
+ acceptance ??= expectedAcceptance;
1429
+ parallelAcceptanceContinuity.set(parallelAcceptanceKey(descriptor), acceptance);
1430
+ while (parallelAcceptanceContinuity.size > ACTIVE_SESSION_CACHE.maximum * 3) {
1431
+ parallelAcceptanceContinuity.delete(parallelAcceptanceContinuity.keys().next().value);
1432
+ }
1314
1433
  const manifestValue = {
1315
1434
  version: "0.1.0",
1316
1435
  task_id: descriptor.task_id,
@@ -1323,10 +1442,13 @@ export const SortieDogsPlugin = async (input, options) => {
1323
1442
  profile: "minimal",
1324
1443
  id: descriptor.task_id,
1325
1444
  created_at: new Date().toISOString(),
1326
- ext: { "sortie-dogs/write-gate": {
1445
+ ext: {
1446
+ "sortie-dogs/write-gate": {
1327
1447
  operation_manifest: relative(descriptor.managed_path, paths.operation_manifest).replaceAll("\\", "/"),
1328
1448
  project_root: descriptor.managed_path,
1329
- } },
1449
+ },
1450
+ [ACCEPTANCE_CONTINUITY_EXTENSION]: acceptance,
1451
+ },
1330
1452
  task: {
1331
1453
  title: `Parallel task ${descriptor.task_id}`,
1332
1454
  objective: "Complete the prepared parallel descriptor within its declared scope.",
@@ -1389,12 +1511,14 @@ export const SortieDogsPlugin = async (input, options) => {
1389
1511
  rm(paths.operation_manifest, { force: true }),
1390
1512
  ]);
1391
1513
  }
1392
- async function ensureParallelReadyControls(snapshot) {
1393
- if (snapshot.archived || snapshot.cancelled || snapshot.ready.length === 0)
1514
+ async function ensureParallelReadyControls(snapshot, parentAcceptance) {
1515
+ if (snapshot.archived || snapshot.cancelled)
1394
1516
  return;
1395
1517
  await ensureLoaded();
1396
1518
  const validationCommands = loaded?.manifest?.validation ?? [];
1397
- await Promise.all(snapshot.ready.map((descriptor) => createParallelControlFiles(descriptor, validationCommands)));
1519
+ await Promise.all(snapshot.tasks
1520
+ .filter(({ phase }) => phase === "pending" || phase === "reserved")
1521
+ .map(({ descriptor }) => createParallelControlFiles(descriptor, validationCommands, parentAcceptance)));
1398
1522
  }
1399
1523
  async function restoreActiveParallelControls(snapshot) {
1400
1524
  await ensureLoaded();
@@ -1410,7 +1534,16 @@ export const SortieDogsPlugin = async (input, options) => {
1410
1534
  cancelled: snapshot.cancelled,
1411
1535
  archived: snapshot.archived,
1412
1536
  terminal_reason: snapshot.terminal_reason,
1413
- ready: snapshot.ready.map((descriptor) => ({ ...descriptor, ...parallelControlPaths(descriptor) })),
1537
+ ready: snapshot.ready.map((descriptor) => {
1538
+ const acceptance = parallelAcceptanceContinuity.get(parallelAcceptanceKey(descriptor));
1539
+ return {
1540
+ ...descriptor,
1541
+ ...parallelControlPaths(descriptor),
1542
+ acceptance: acceptance?.criteria ?? [],
1543
+ acceptance_fingerprint: acceptance?.fingerprint,
1544
+ acceptance_parent_fingerprint: acceptance?.parent_fingerprint,
1545
+ };
1546
+ }),
1414
1547
  tasks: snapshot.tasks.map(({ descriptor, worktree_id, phase, call_id, child_session_id, outcome, artifact }) => ({
1415
1548
  task_id: descriptor.task_id,
1416
1549
  worktree_id,
@@ -1528,10 +1661,10 @@ export const SortieDogsPlugin = async (input, options) => {
1528
1661
  if (result.status === "serial-fallback")
1529
1662
  return JSON.stringify(result);
1530
1663
  try {
1531
- await ensureParallelReadyControls(result.snapshot);
1664
+ await ensureParallelReadyControls(result.snapshot, rootAcceptanceContinuity.get(ownerRoot));
1532
1665
  }
1533
1666
  catch (error) {
1534
- await Promise.all(result.snapshot.ready.map((descriptor) => removeParallelControlFiles(descriptor).catch(() => undefined)));
1667
+ await Promise.all(result.snapshot.tasks.map(({ descriptor }) => removeParallelControlFiles(descriptor).catch(() => undefined)));
1535
1668
  await coordinator.cancel(ownerRoot, result.snapshot.run_id).catch(() => undefined);
1536
1669
  throw error;
1537
1670
  }
@@ -1916,11 +2049,15 @@ export const SortieDogsPlugin = async (input, options) => {
1916
2049
  }
1917
2050
  preparedDirectories.push(...(extraction.createdDirectories ?? []).map((directory) => resolve(inspectedProjectRoot, directory)));
1918
2051
  }
2052
+ const continuity = inspectAcceptanceContinuity(validation.value);
1919
2053
  const identity = {
1920
2054
  explicitWriteGate: extension !== undefined,
2055
+ handoffID: validation.value.id,
1921
2056
  manifestPath,
1922
2057
  projectRoot: inspectedProjectRoot,
1923
2058
  validationCommands: new Set(manifest.validation.map(normalizeCommand)),
2059
+ acceptanceContinuity: continuity.ledger,
2060
+ acceptanceContinuityError: continuity.error,
1924
2061
  };
1925
2062
  if (sessionID === undefined)
1926
2063
  return identity;
@@ -2337,6 +2474,7 @@ export const SortieDogsPlugin = async (input, options) => {
2337
2474
  else {
2338
2475
  coordinatorRoots.delete(sessionID);
2339
2476
  explicitCoordinatorModels.delete(sessionID);
2477
+ assetVersionPins.delete(sessionID);
2340
2478
  }
2341
2479
  }
2342
2480
  const limit = ACTIVE_SESSION_CACHE.maximum - (reserveSlot ? 1 : 0);
@@ -2344,6 +2482,7 @@ export const SortieDogsPlugin = async (input, options) => {
2344
2482
  const sessionID = coordinatorRoots.keys().next().value;
2345
2483
  coordinatorRoots.delete(sessionID);
2346
2484
  explicitCoordinatorModels.delete(sessionID);
2485
+ assetVersionPins.delete(sessionID);
2347
2486
  }
2348
2487
  }
2349
2488
  async function rememberCoordinatorRoot(sessionID) {
@@ -2485,6 +2624,7 @@ export const SortieDogsPlugin = async (input, options) => {
2485
2624
  function evictSession(sessionID) {
2486
2625
  activeSessions.delete(sessionID);
2487
2626
  sessionOperationMetrics.delete(sessionID);
2627
+ rootAcceptanceContinuity.delete(sessionID);
2488
2628
  abandonSessionLease(sessionID);
2489
2629
  sessionAuthorizations.delete(sessionID);
2490
2630
  bindingPins.delete(sessionID);
@@ -2494,6 +2634,7 @@ export const SortieDogsPlugin = async (input, options) => {
2494
2634
  }
2495
2635
  expiredSessions.delete(sessionID);
2496
2636
  coordinatorRoots.delete(sessionID);
2637
+ assetVersionPins.delete(sessionID);
2497
2638
  explicitCoordinatorModels.delete(sessionID);
2498
2639
  bindingDenials.delete(sessionID);
2499
2640
  sessionTaskIDs.delete(sessionID);
@@ -2649,6 +2790,24 @@ export const SortieDogsPlugin = async (input, options) => {
2649
2790
  return undefined;
2650
2791
  }
2651
2792
  }
2793
+ function acceptanceParentPrefix(ledger) {
2794
+ if (ledger.parent_fingerprint === "none")
2795
+ return undefined;
2796
+ for (let length = 1; length < ledger.criteria.length; length += 1) {
2797
+ const prefix = ledger.criteria.slice(0, length);
2798
+ if (acceptanceContinuityFingerprint(prefix) === ledger.parent_fingerprint)
2799
+ return prefix;
2800
+ }
2801
+ return undefined;
2802
+ }
2803
+ async function recoverAcceptanceParent(sessionID, ledger, descriptor) {
2804
+ if (descriptor === undefined || acceptanceParentPrefix(ledger) === undefined ||
2805
+ ledger.parent_fingerprint === "none")
2806
+ return false;
2807
+ const snapshot = await (await getParallelCoordinator()).snapshot(sessionID, descriptor.run_id);
2808
+ return snapshot?.tasks.some(({ phase, descriptor: durable }) => (phase === "pending" || phase === "reserved" || phase === "running") &&
2809
+ sameParallelDescriptor(durable, descriptor)) === true;
2810
+ }
2652
2811
  async function assistantMessageText(sessionID, messageID, expectedAgent, partID) {
2653
2812
  const messages = input.client?.session?.messages;
2654
2813
  if (messages === undefined)
@@ -2715,6 +2874,7 @@ export const SortieDogsPlugin = async (input, options) => {
2715
2874
  if (persistedTurn !== undefined && persistedTurn.agent !== COORDINATOR_AGENT)
2716
2875
  return false;
2717
2876
  await rememberCoordinatorRoot(sessionID);
2877
+ await pinAssetVersion(sessionID);
2718
2878
  releaseSessionEnforcement(sessionID);
2719
2879
  fastLane.beginTurn(sessionID, persistedTurn?.synthetic ?? false);
2720
2880
  return true;
@@ -3021,6 +3181,8 @@ export const SortieDogsPlugin = async (input, options) => {
3021
3181
  ...(metrics ?? {}),
3022
3182
  ...operationMetricsSnapshot(textInput.sessionID),
3023
3183
  });
3184
+ if (runOutcome === "DONE")
3185
+ rootAcceptanceContinuity.delete(textInput.sessionID);
3024
3186
  }
3025
3187
  await completeContinuationText(textInput.sessionID, textOutput.text, false);
3026
3188
  },
@@ -3077,6 +3239,7 @@ export const SortieDogsPlugin = async (input, options) => {
3077
3239
  fastLane.beginTurn(chatInput.sessionID, synthetic);
3078
3240
  releaseSessionEnforcement(chatInput.sessionID);
3079
3241
  await rememberCoordinatorRoot(chatInput.sessionID);
3242
+ await pinAssetVersion(chatInput.sessionID);
3080
3243
  }
3081
3244
  else {
3082
3245
  if (!synthetic && isCoordinatorSession(chatInput.sessionID)) {
@@ -3400,7 +3563,14 @@ export const SortieDogsPlugin = async (input, options) => {
3400
3563
  let parallelWorkerAuthorized = false;
3401
3564
  let parallelWorkerAlreadyBound = false;
3402
3565
  let reservedParallelDescriptor;
3566
+ let validatedRootAcceptance;
3403
3567
  if (toolInput.tool === "task" && taskRole === "dog-worker" && isRecord(output.args)) {
3568
+ await ensureLoaded();
3569
+ const assetVersionStatus = await pinAssetVersion(toolInput.sessionID);
3570
+ if (assetVersionStatus === "mismatch") {
3571
+ throw new Error("SORTIE_FRESH_SESSION_REQUIRED: installed agent assets and the loaded plugin use different contracts; " +
3572
+ "run sortie-dogs init . and start a fresh coordinator session");
3573
+ }
3404
3574
  const prompt = typeof output.args.prompt === "string" ? output.args.prompt : "";
3405
3575
  const contractPrompt = taskContractText(prompt);
3406
3576
  reservedParallelDescriptor = parallelDescriptor(prompt);
@@ -3522,6 +3692,49 @@ export const SortieDogsPlugin = async (input, options) => {
3522
3692
  defects: [contractDefect("contract", "/", "dispatch_identity_mismatch")],
3523
3693
  });
3524
3694
  }
3695
+ if (assetVersionStatus === "current") {
3696
+ const ledger = identity.acceptanceContinuity;
3697
+ if (ledger === undefined) {
3698
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
3699
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", `acceptance_continuity_${identity.acceptanceContinuityError ?? "missing"}`)],
3700
+ });
3701
+ }
3702
+ const criteria = taskAcceptanceCriteria(contractPrompt);
3703
+ if (taskIDs.length !== 1 || taskIDs[0] !== identity.handoffID ||
3704
+ ledger.task_id !== identity.handoffID || criteria === undefined ||
3705
+ criteria.length !== ledger.criteria.length ||
3706
+ criteria.some((criterion, index) => criterion !== ledger.criteria[index])) {
3707
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
3708
+ defects: [contractDefect("contract", "/acceptance", "acceptance_continuity_mismatch")],
3709
+ });
3710
+ }
3711
+ const previous = rootAcceptanceContinuity.get(toolInput.sessionID);
3712
+ if (previous === undefined) {
3713
+ if (ledger.parent_fingerprint !== "none" &&
3714
+ !await recoverAcceptanceParent(toolInput.sessionID, ledger, reservedParallelDescriptor)) {
3715
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
3716
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
3717
+ });
3718
+ }
3719
+ }
3720
+ else if (previous.task_id === ledger.task_id && previous.fingerprint === ledger.fingerprint) {
3721
+ if (ledger.parent_fingerprint !== previous.parent_fingerprint) {
3722
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
3723
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
3724
+ });
3725
+ }
3726
+ }
3727
+ else {
3728
+ if (ledger.parent_fingerprint !== previous.fingerprint ||
3729
+ ledger.criteria.length <= previous.criteria.length ||
3730
+ previous.criteria.some((criterion, index) => criterion !== ledger.criteria[index])) {
3731
+ throw new HandoffDeniedError("contract-invalid", handoffPaths[0], {
3732
+ defects: [contractDefect("handoff", "/ext/sortie-dogs~1acceptance-continuity", "acceptance_parent_continuity_mismatch")],
3733
+ });
3734
+ }
3735
+ }
3736
+ validatedRootAcceptance = ledger;
3737
+ }
3525
3738
  }
3526
3739
  }
3527
3740
  if (reservedParallelDescriptor !== undefined) {
@@ -3546,6 +3759,13 @@ export const SortieDogsPlugin = async (input, options) => {
3546
3759
  parallelWorkerAlreadyBound,
3547
3760
  parallelWorkerAuthorized,
3548
3761
  });
3762
+ if (validatedRootAcceptance !== undefined && reservedParallelDescriptor === undefined) {
3763
+ rootAcceptanceContinuity.delete(toolInput.sessionID);
3764
+ rootAcceptanceContinuity.set(toolInput.sessionID, validatedRootAcceptance);
3765
+ while (rootAcceptanceContinuity.size > ACTIVE_SESSION_CACHE.maximum) {
3766
+ rootAcceptanceContinuity.delete(rootAcceptanceContinuity.keys().next().value);
3767
+ }
3768
+ }
3549
3769
  if (resumedWorkerSessionID !== undefined) {
3550
3770
  const recoverableParallel = parallelRecoverableChildren.get(resumedWorkerSessionID);
3551
3771
  if (recoverableParallel !== undefined) {