sortie-dogs 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,8 +42,8 @@ npm install --save-dev sortie-dogs
42
42
  npx sortie-dogs init .
43
43
  ```
44
44
 
45
- The package retains the `v010` profile/namespace for compatibility. Add the OpenCode V2 plugin and the required
46
- two-level subagent depth to `.opencode/opencode.json`, preserving existing values:
45
+ The package retains the `v010` profile/namespace for compatibility. `init` adds the OpenCode V2 plugin and the required
46
+ two-level subagent depth to `.opencode/opencode.json(c)`, preserving existing settings:
47
47
 
48
48
  ```json
49
49
  {
@@ -62,8 +62,8 @@ Selecting `dog-operator` directly starts the same workflow. `dog-operator` is th
62
62
  only user-facing v0.10 authority. `dogs-coordinator` and every `*-v010` role are
63
63
  internal children and must not be selected as task entry points.
64
64
 
65
- `init` installs runtime assets; the `plugins` entry loads runtime enforcement and
66
- model routing. Both are required. A new session alone does not reload an updated
65
+ `init` installs runtime assets and merges the required OpenCode settings; the `plugins` entry loads runtime enforcement and
66
+ model routing. A new session alone does not reload an updated
67
67
  plugin process, so restart OpenCode after installation or upgrade.
68
68
 
69
69
  ## v0.12.0 workflow
@@ -262,9 +262,8 @@ npm install --global sortie-dogs
262
262
  sortie-dogs init --global --profile v010
263
263
  ```
264
264
 
265
- Then add `sortie-dogs` and `experimental.subagent_depth: 2` to the global OpenCode config.
266
- Global initialization installs assets only; it does not silently change the
267
- default agent or merge user settings.
265
+ Global initialization also merges `sortie-dogs` and `experimental.subagent_depth: 2` into the
266
+ global OpenCode config, preserving unrelated settings and the default agent.
268
267
 
269
268
  ## Updates and removal
270
269
 
@@ -67,8 +67,8 @@ export declare class FailureSwarmRuntime {
67
67
  probe_iterations: number;
68
68
  model_attempts: number;
69
69
  }>;
70
- capsule_id: string;
71
70
  proposal: string;
71
+ capsule_id: string;
72
72
  recovery_kind: Exclude<import("./run-flight-ledger.js").RecoveryKind, "read_only_diagnosis">;
73
73
  diagnosis_id: string;
74
74
  plan_id: string;
@@ -11,6 +11,7 @@ import { constants } from "node:fs";
11
11
  import { lstat, mkdir, open, readFile, realpath, rm, rmdir, stat } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
14
+ import { applyEdits, modify, parse } from "jsonc-parser";
14
15
  import { RUNTIME_PROFILES } from "./runtime-profile.js";
15
16
  const assets = await import(__rewriteRelativeImportExtension(`../runtime-assets.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`));
16
17
  const { runtimeAssets } = assets;
@@ -348,6 +349,46 @@ function layoutLegacyPath(asset, layout) {
348
349
  ? asset.relativePath.slice(`${OPEN_CODE_DIRECTORY}/`.length)
349
350
  : asset.relativePath;
350
351
  }
352
+ async function v010Config(root, global) {
353
+ const prefix = global ? "" : `${OPEN_CODE_DIRECTORY}/`;
354
+ const json = `${prefix}opencode.json`, jsonc = `${prefix}opencode.jsonc`;
355
+ const presentJSON = await assertSafeExistingPath(root, json, true);
356
+ const presentJSONC = await assertSafeExistingPath(root, jsonc, true);
357
+ if (presentJSON && presentJSONC)
358
+ throw new ProjectInitializationError("conflict", "Both OpenCode JSON and JSONC configs exist; choose one before initialization.");
359
+ const relativePath = presentJSONC ? jsonc : json;
360
+ const previous = presentJSONC || presentJSON ? await readFile(resolve(root, relativePath)) : undefined;
361
+ let content = previous?.toString("utf8") ?? "{}\n";
362
+ const errors = [];
363
+ const config = parse(content, errors, { allowTrailingComma: true });
364
+ if (errors.length || config === null || typeof config !== "object" || Array.isArray(config)) {
365
+ throw new ProjectInitializationError("conflict", `OpenCode config ${relativePath} must be a valid JSON/JSONC object.`);
366
+ }
367
+ const current = config;
368
+ const plugins = current.plugins;
369
+ if (plugins !== undefined && (!Array.isArray(plugins) || !plugins.every(item => typeof item === "string" ||
370
+ (item !== null && typeof item === "object" && !Array.isArray(item) && typeof item.package === "string")))) {
371
+ throw new ProjectInitializationError("conflict", `OpenCode config ${relativePath} has an invalid plugins list.`);
372
+ }
373
+ if (current.experimental !== undefined && (current.experimental === null || typeof current.experimental !== "object" ||
374
+ Array.isArray(current.experimental)))
375
+ throw new ProjectInitializationError("conflict", `OpenCode config ${relativePath} has invalid experimental settings.`);
376
+ const depth = current.experimental?.subagent_depth;
377
+ if (depth !== undefined && (!Number.isSafeInteger(depth) || depth < 0)) {
378
+ throw new ProjectInitializationError("conflict", `OpenCode config ${relativePath} has an invalid subagent depth.`);
379
+ }
380
+ const options = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
381
+ const update = (path, value) => { content = applyEdits(content, modify(content, path, value, options)); };
382
+ if (!plugins?.some(item => item === "sortie-dogs" ||
383
+ (item !== null && typeof item === "object" && item.package === "sortie-dogs"))) {
384
+ update(["plugins"], [...(plugins ?? []), "sortie-dogs"]);
385
+ }
386
+ if (depth === undefined || depth < 2)
387
+ update(["experimental", "subagent_depth"], 2);
388
+ if (previous?.toString("utf8") === content)
389
+ return undefined;
390
+ return { entry: { relativePath, content }, previous };
391
+ }
351
392
  async function initializeRoot(requestedRoot, layout, installAssets = runtimeAssets) {
352
393
  const root = resolve(requestedRoot);
353
394
  const legacyAssets = layout.legacyAssets ?? LEGACY_RUNTIME_ASSETS;
@@ -355,6 +396,7 @@ async function initializeRoot(requestedRoot, layout, installAssets = runtimeAsse
355
396
  if (rootInfo === undefined || !rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
356
397
  throw new ProjectInitializationError("invalid-project", layout.invalidRootMessage);
357
398
  }
399
+ const configUpdate = layout.configureV010 ? await v010Config(root, layout.assetPrefix === "") : undefined;
358
400
  const version = assetVersion(installAssets);
359
401
  const assetEntries = installAssets.map(({ installPath, content }) => ({
360
402
  relativePath: safeAssetPath(installPath, layout.assetPrefix),
@@ -381,7 +423,7 @@ async function initializeRoot(requestedRoot, layout, installAssets = runtimeAsse
381
423
  const markerText = existing[markerIndex];
382
424
  const matches = (entry, index) => existing[index]?.equals(Buffer.from(entry.content)) ?? false;
383
425
  const assetsMatch = assetEntries.every(matches);
384
- if (markerText !== undefined && parseMarker(markerText.toString("utf8")) === version && assetsMatch) {
426
+ if (markerText !== undefined && parseMarker(markerText.toString("utf8")) === version && assetsMatch && !configUpdate) {
385
427
  if (layout.controlIgnore ?? !layout.preserveAllLegacy)
386
428
  await ensureProjectLunaControlIgnore(root);
387
429
  return {
@@ -449,6 +491,18 @@ async function initializeRoot(requestedRoot, layout, installAssets = runtimeAsse
449
491
  });
450
492
  }
451
493
  }
494
+ if (configUpdate) {
495
+ const { entry, previous } = configUpdate;
496
+ const parent = dirname(entry.relativePath).replaceAll("\\", "/");
497
+ if (parent !== ".")
498
+ await ensureDirectory(root, parent, createdDirectories);
499
+ if (previous === undefined)
500
+ await createFile(resolve(root, entry.relativePath), entry.content, createdFiles);
501
+ else
502
+ await overwriteFileSafely(root, entry.relativePath, entry.content, () => {
503
+ modifiedFiles.push({ relativePath: entry.relativePath, content: previous });
504
+ });
505
+ }
452
506
  for (const { asset } of removableLegacyFiles) {
453
507
  const state = await readableOwnedLegacyFile(root, asset);
454
508
  if (!Buffer.isBuffer(state)) {
@@ -478,7 +532,7 @@ async function initializeRoot(requestedRoot, layout, installAssets = runtimeAsse
478
532
  return {
479
533
  status: "installed",
480
534
  version,
481
- installedPaths: entries.map(({ relativePath }) => relativePath),
535
+ installedPaths: [...entries.map(({ relativePath }) => relativePath), ...(configUpdate ? [configUpdate.entry.relativePath] : [])],
482
536
  preservedLegacyPaths,
483
537
  };
484
538
  }
@@ -502,7 +556,7 @@ export async function resolveGlobalConfigRoot(env = process.env, home = homedir(
502
556
  return resolve(env.XDG_CONFIG_HOME, "opencode");
503
557
  return resolve(home, ".config", "opencode");
504
558
  }
505
- /** Installs the packaged runtime into one existing project without changing user settings. */
559
+ /** Installs the packaged runtime into one existing project, preserving unrelated user settings. */
506
560
  async function profileInstallation(id, global) {
507
561
  if (!Object.hasOwn(RUNTIME_PROFILES, id))
508
562
  throw new ProjectInitializationError("invalid-project", "Unknown runtime profile.");
@@ -515,7 +569,7 @@ async function profileInstallation(id, global) {
515
569
  return { layout: { ...(global ? GLOBAL_LAYOUT : PROJECT_LAYOUT),
516
570
  markerPath: global ? profile.markerFile : `${OPEN_CODE_DIRECTORY}/${profile.markerFile}`,
517
571
  preserveAllLegacy: false, controlIgnore: false, legacyAssets: V010_ROLE_NAME_LEGACY_ASSETS,
518
- renamedTargets: ["agent/dog-operator.md", "agent/dogs-coordinator.md"] }, assets: module.runtimeAssets };
572
+ renamedTargets: ["agent/dog-operator.md", "agent/dogs-coordinator.md"], configureV010: true }, assets: module.runtimeAssets };
519
573
  }
520
574
  export async function initializeProject(projectRoot = process.cwd(), profile = "stable") {
521
575
  const install = await profileInstallation(profile, false);
@@ -22,6 +22,8 @@ export interface OperatorMission {
22
22
  callID: string | null;
23
23
  dispatchOpen: boolean;
24
24
  runID: string | null;
25
+ /** A cancelled run replaced by a later real user turn; never reuse its acceptance or evidence. */
26
+ supersededRunID?: string;
25
27
  plans: number;
26
28
  progress: {
27
29
  unit: string;
@@ -51,6 +53,10 @@ export declare class OperatorMissionRuntime {
51
53
  constructor(projectRoot: string, profile: RuntimeProfile);
52
54
  private file;
53
55
  private load;
56
+ /** Recover the predecessor link for missions started before this fix, using one exact archived mission. */
57
+ private loadMission;
58
+ /** Find exactly one archived cancelled mission that owned this run; ambiguity never grants recovery. */
59
+ archivedRun(root: string, runID: string): Promise<OperatorMission | undefined>;
54
60
  private save;
55
61
  private serial;
56
62
  read(root: string): Promise<OperatorMission | undefined>;
@@ -60,6 +66,8 @@ export declare class OperatorMissionRuntime {
60
66
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
61
67
  task(state: OperatorMission): OperatorTask;
62
68
  admit(root: string, callID: string, args: Record<string, unknown>): Promise<OperatorMission>;
69
+ /** Reopen only the exact dispatch whose native Task has a terminal record after a server restart. */
70
+ reconcileFinishedDispatch(root: string, missionID: string, callID: string): Promise<OperatorMission>;
63
71
  claim(root: string, child: string, prompt: string): Promise<string>;
64
72
  brief(state: OperatorMission): string;
65
73
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { normalizeRelativePath } from "./path.js";
5
5
  import { parseOperatorPlan } from "./operator-runtime.js";
@@ -40,6 +40,59 @@ export class OperatorMissionRuntime {
40
40
  throw error;
41
41
  }
42
42
  }
43
+ /** Recover the predecessor link for missions started before this fix, using one exact archived mission. */
44
+ async loadMission(root) {
45
+ const state = await this.load(this.file(root));
46
+ if (!state || state.supersededRunID !== undefined || state.runID !== null ||
47
+ !["open", "running", "submitted"].includes(state.phase) || state.requests.length === 0)
48
+ return state;
49
+ const directory = join(this.projectRoot, this.profile.stateDirectory, "missions");
50
+ let entries;
51
+ try {
52
+ entries = await readdir(directory);
53
+ }
54
+ catch (error) {
55
+ if (error.code === "ENOENT")
56
+ return state;
57
+ throw error;
58
+ }
59
+ const prefix = `${digest(root)}.mission-`;
60
+ const candidates = entries.filter(name => name.startsWith(prefix) && /^mission-[a-f0-9-]+\.json$/u.test(name.slice(digest(root).length + 1)));
61
+ const matches = [];
62
+ for (const name of candidates) {
63
+ const previous = await this.load(join(directory, name));
64
+ if (previous?.root === root && previous.phase === "cancelled" && previous.runID !== null &&
65
+ previous.requests[0]?.id !== state.requests[0].id &&
66
+ previous.requests.some(request => request.id === state.requests[0].id))
67
+ matches.push(previous);
68
+ }
69
+ if (matches.length === 1)
70
+ state.supersededRunID = matches[0].runID;
71
+ return state;
72
+ }
73
+ /** Find exactly one archived cancelled mission that owned this run; ambiguity never grants recovery. */
74
+ async archivedRun(root, runID) {
75
+ const directory = join(this.projectRoot, this.profile.stateDirectory, "missions");
76
+ let entries;
77
+ try {
78
+ entries = await readdir(directory);
79
+ }
80
+ catch (error) {
81
+ if (error.code === "ENOENT")
82
+ return undefined;
83
+ throw error;
84
+ }
85
+ const prefix = `${digest(root)}.mission-`;
86
+ const matches = [];
87
+ for (const name of entries.filter(name => name.startsWith(prefix) &&
88
+ /^mission-[a-f0-9-]+\.json$/u.test(name.slice(digest(root).length + 1)))) {
89
+ const state = await this.load(join(directory, name));
90
+ if (state?.root === root && state.phase === "cancelled" && state.runID === runID &&
91
+ state.coordinator !== null && state.callID !== null)
92
+ matches.push(state);
93
+ }
94
+ return matches.length === 1 ? matches[0] : undefined;
95
+ }
43
96
  async save(file, value) {
44
97
  const temporary = `${file}.${randomUUID()}.tmp`;
45
98
  await mkdir(join(this.projectRoot, this.profile.stateDirectory, "missions"), { recursive: true });
@@ -64,7 +117,7 @@ export class OperatorMissionRuntime {
64
117
  }
65
118
  async read(root) {
66
119
  await this.writes.get(root);
67
- const state = await this.load(this.file(root));
120
+ const state = await this.loadMission(root);
68
121
  if (state && (state.version !== "0.12" || state.root !== root))
69
122
  throw new Error("mission-state-invalid");
70
123
  return state;
@@ -81,7 +134,7 @@ export class OperatorMissionRuntime {
81
134
  await this.serial(root, async () => {
82
135
  // Captured before prompt rewriting, including exact whitespace. Never ask a model to recopy it.
83
136
  await this.save(this.file(root, ".request"), request);
84
- const state = await this.load(this.file(root));
137
+ const state = await this.loadMission(root);
85
138
  if (state && !["completed", "cancelled"].includes(state.phase) && !state.requests.some(item => item.id === request.id)) {
86
139
  state.requests.push(request);
87
140
  await this.save(this.file(root), state);
@@ -97,7 +150,7 @@ export class OperatorMissionRuntime {
97
150
  const request = await this.load(this.file(root, ".request"));
98
151
  if (!request)
99
152
  throw new Error("mission-original-request-unavailable");
100
- const previous = await this.load(this.file(root));
153
+ const previous = await this.loadMission(root);
101
154
  if (previous && !["completed", "cancelled"].includes(previous.phase)) {
102
155
  if (previous.requirements.some((item, index) => requirements[index] !== item.text)) {
103
156
  throw new Error("mission-requirements-preserved: keep the existing ordered requirements and append user additions");
@@ -110,14 +163,16 @@ export class OperatorMissionRuntime {
110
163
  await this.save(this.file(root, `.${previous.id}`), previous);
111
164
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
112
165
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
113
- coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null };
166
+ coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
167
+ ...(previous?.phase === "cancelled" && previous.runID !== null && request.id !== previous.requests[0]?.id
168
+ ? { supersededRunID: previous.runID } : {}) };
114
169
  await this.save(this.file(root), state);
115
170
  return state;
116
171
  });
117
172
  }
118
173
  update(root, change) {
119
174
  return this.serial(root, async () => {
120
- const state = await this.load(this.file(root));
175
+ const state = await this.loadMission(root);
121
176
  if (!state)
122
177
  throw new Error("mission-missing");
123
178
  change(state);
@@ -144,6 +199,15 @@ export class OperatorMissionRuntime {
144
199
  state.submission = null;
145
200
  });
146
201
  }
202
+ /** Reopen only the exact dispatch whose native Task has a terminal record after a server restart. */
203
+ reconcileFinishedDispatch(root, missionID, callID) {
204
+ return this.update(root, state => {
205
+ if (state.id !== missionID || state.callID !== callID || !state.dispatchOpen ||
206
+ ["cancelled", "completed"].includes(state.phase))
207
+ throw new Error("mission-dispatch-reconciliation-stale");
208
+ state.dispatchOpen = false;
209
+ });
210
+ }
147
211
  async claim(root, child, prompt) {
148
212
  const state = await this.update(root, state => {
149
213
  if (state.phase !== "running" || !state.dispatchOpen || !state.callID ||
@@ -172,6 +172,8 @@ export interface OperatorState {
172
172
  readonly sourceRefs: readonly string[];
173
173
  readonly createdAt: string;
174
174
  readonly parentRunID: string | null;
175
+ /** Archived cancelled run replaced by a later user-authorized mission. */
176
+ readonly supersededRunID?: string;
175
177
  readonly priorAcceptedUnits: readonly OperatorAcceptanceAnchor[];
176
178
  readonly remediationParent: {
177
179
  readonly taskID: string;
@@ -243,7 +245,7 @@ export declare class OperatorRuntime {
243
245
  prepareMission(root: string, raw: unknown, dispatcher?: {
244
246
  sessionID: string;
245
247
  callID: string;
246
- }): Promise<OperatorState>;
248
+ }, supersededRunID?: string): Promise<OperatorState>;
247
249
  retireMissionRun(root: string): Promise<void>;
248
250
  private prepareOnce;
249
251
  operatorTask(state: OperatorState): OperatorTask;
@@ -299,7 +299,8 @@ export class OperatorRuntime {
299
299
  !identifier(state.runID) || !Array.isArray(state.units) || state.units.length > OPERATOR_LIMITS.units ||
300
300
  !strings(state.acceptance, true) || !Array.isArray(state.acceptanceProof) || state.acceptanceProof.length !== state.acceptance.length ||
301
301
  state.acceptanceProof.some(ids => !strings(ids, true)) || state.acceptanceFingerprint !== acceptanceContinuityFingerprint(state.acceptance) ||
302
- (state.parentRunID !== null && !identifier(state.parentRunID)) || !Array.isArray(state.priorAcceptedUnits) ||
302
+ (state.parentRunID !== null && !identifier(state.parentRunID)) ||
303
+ (state.supersededRunID !== undefined && !identifier(state.supersededRunID)) || !Array.isArray(state.priorAcceptedUnits) ||
303
304
  state.priorAcceptedUnits.some(anchor => !record(anchor) || !identifier(anchor.taskID) || typeof anchor.handoffPath !== "string" || !isAbsolute(anchor.handoffPath) ||
304
305
  typeof anchor.handoffHash !== "string" || !/^[a-f0-9]{64}$/u.test(anchor.handoffHash)) ||
305
306
  (state.remediationParent !== null && (!record(state.remediationParent) || !identifier(state.remediationParent.taskID) ||
@@ -677,8 +678,8 @@ export class OperatorRuntime {
677
678
  }
678
679
  }
679
680
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
680
- prepareMission(root, raw, dispatcher) {
681
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher }));
681
+ prepareMission(root, raw, dispatcher, supersededRunID) {
682
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID }));
682
683
  }
683
684
  retireMissionRun(root) {
684
685
  return this.serial(root, async () => {
@@ -718,18 +719,30 @@ export class OperatorRuntime {
718
719
  immutableReplacement = { ...immutableReplacement, goal_declaration: declaration };
719
720
  }
720
721
  const plan = parseOperatorPlan(immutableReplacement);
722
+ const validatedPlanHash = hash(JSON.stringify(plan));
723
+ if (mission?.supersededRunID !== undefined && previous?.supersededRunID === mission.supersededRunID &&
724
+ previous.planHash === validatedPlanHash && previous.phase !== "cancelled")
725
+ return previous;
726
+ const superseding = mission?.supersededRunID !== undefined && previous?.runID === mission.supersededRunID &&
727
+ previous.phase === "cancelled";
728
+ if (mission?.supersededRunID !== undefined && !superseding)
729
+ throw new Error("mission-superseded-run-mismatch");
730
+ if (superseding && previous && (previous.decision !== "explicit-cancellation" || previous.gitLifecycle !== null ||
731
+ previous.repairResidualPaths.length > 0 || previous.priorAcceptedUnits.length > 0 ||
732
+ previous.units.some(unit => unit.childSessionID !== null || unit.status === "succeeded"))) {
733
+ throw new Error("mission-superseded-run-has-work: reconcile prior workers and evidence before changing acceptance");
734
+ }
721
735
  if (plan.git_lifecycle !== undefined) {
722
736
  await ensureGitManagedStateExcluded(this.projectRoot, this.profile, this.gitPath);
723
737
  }
724
- const planHash = hash(JSON.stringify(plan));
725
738
  if (previous && !["completed", "cancelled"].includes(previous.phase)) {
726
- if (previous.planHash === planHash)
739
+ if (previous.planHash === validatedPlanHash)
727
740
  return previous;
728
741
  throw new Error("operator-active-contract-immutable");
729
742
  }
730
743
  // Cancellation stops execution, not the accepted user order. A replacement
731
744
  // plan must carry the original ordered criteria; only a completed root clears them.
732
- const parent = previous?.phase === "cancelled" ? previous : undefined;
745
+ const parent = !superseding && previous?.phase === "cancelled" ? previous : undefined;
733
746
  if (parent?.decision === "operator-contract-repair-unavailable-after-cancel") {
734
747
  // The diagnosed transients, not the cancellation itself, block a replacement. Once they are
735
748
  // gone the same accepted order may proceed; while they remain the refusal names them exactly.
@@ -878,9 +891,9 @@ export class OperatorRuntime {
878
891
  childSessionID: null, evidence: [], resultClass: null, repairValidationAttempts: 0, repairValidation: null });
879
892
  }
880
893
  const gitLifecycle = await this.createGitLifecycle(plan, remediationHead);
881
- const state = { schema_version: "0.1", profile: this.profile.id, rootSessionID: root, runID, planHash,
894
+ const state = { schema_version: "0.1", profile: this.profile.id, rootSessionID: root, runID, planHash: validatedPlanHash,
882
895
  acceptance: plan.acceptance, acceptanceProof: plan.acceptance_proof, acceptanceFingerprint, sourceRefs: plan.source_refs, createdAt: new Date().toISOString(),
883
- parentRunID: parent?.runID ?? null, priorAcceptedUnits,
896
+ parentRunID: parent?.runID ?? null, ...(superseding ? { supersededRunID: previous.runID } : {}), priorAcceptedUnits,
884
897
  remediationParent: remediationTaskID && remediationHead && parent?.gitLifecycle ? {
885
898
  taskID: remediationTaskID, committedHead: remediationHead, runID: parent.runID,
886
899
  acceptanceFingerprint: parent.acceptanceFingerprint, approvedWriteUnion: parent.gitLifecycle.writeUnion,
@@ -908,6 +921,17 @@ export class OperatorRuntime {
908
921
  await handle.close();
909
922
  }
910
923
  }
924
+ if (superseding && previous) {
925
+ const archive = `${this.file(root)}.${previous.runID}.archive`;
926
+ try {
927
+ await writeFile(archive, JSON.stringify(previous), { flag: "wx", mode: 0o600 });
928
+ }
929
+ catch (error) {
930
+ if (error.code !== "EEXIST" ||
931
+ await readFile(archive, "utf8") !== JSON.stringify(previous))
932
+ throw error;
933
+ }
934
+ }
911
935
  await this.save(state);
912
936
  return state;
913
937
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { default } from "./plugin/v2.js";
1
2
  export { lintHandoff } from "./core/validate-semantics.js";
2
3
  export { validateWorktreeParallelContract } from "./core/validate-worktree-parallel.js";
3
4
  export { LUNA_FABRIC_CONTRACT_VERSION, admitLunaFabric, } from "./core/luna-fabric-contract.js";
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // OpenCode V2 loads the package root named in `plugins`; keep the public named API intact.
2
+ export { default } from "./plugin/v2.js";
1
3
  export { lintHandoff } from "./core/validate-semantics.js";
2
4
  export { validateWorktreeParallelContract } from "./core/validate-worktree-parallel.js";
3
5
  export { LUNA_FABRIC_CONTRACT_VERSION, admitLunaFabric, } from "./core/luna-fabric-contract.js";
@@ -13,6 +13,7 @@ import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
13
13
  import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES, GOAL_CRITERION_ENUMS, expandGoalDeclaration } from "../core/goal-declaration-format.js";
14
14
  import { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, } from "../core/acceptance-continuity.js";
15
15
  import { resolveGlobalConfigRoot } from "../core/initialize.js";
16
+ import { OperatorMissionRuntime } from "../core/operator-mission.js";
16
17
  import { admitLunaFabric } from "../core/luna-fabric-contract.js";
17
18
  import { summarizeExperienceEvidence } from "../core/experience-evidence-summary.js";
18
19
  import { selectExperienceRoute } from "../core/experience-route-policy.js";
@@ -1609,6 +1610,82 @@ export const SortieDogsPlugin = async (input, options) => {
1609
1610
  const data = isRecord(response) ? response.data : undefined;
1610
1611
  if (!Array.isArray(data))
1611
1612
  return;
1613
+ const rootHistory = data;
1614
+ async function terminalCancelledMissionTask(unitID, reservationID, goalID) {
1615
+ const runID = unitID.replace(/-[1-9][0-9]*$/u, "");
1616
+ if (runID === unitID || !/^operator-[a-f0-9-]+$/u.test(runID) ||
1617
+ input.client?.session?.get === undefined)
1618
+ return undefined;
1619
+ const sessionAPI = input.client.session;
1620
+ const childrenMethod = sessionAPI.children;
1621
+ const getMethod = sessionAPI.get;
1622
+ const mission = await new OperatorMissionRuntime(input.directory, runtimeProfile).archivedRun(root, runID);
1623
+ if (!mission || !rootHistory.some(message => isRecord(message) && isRecord(message.info) &&
1624
+ message.info.sessionID === root && message.info.role === "assistant" && Array.isArray(message.parts) &&
1625
+ message.parts.some(part => isRecord(part) && part.type === "tool" && part.tool === "task" &&
1626
+ part.callID === mission.callID && isRecord(part.state) && part.state.status === "completed" &&
1627
+ isRecord(part.state.input) && typeof part.state.input.prompt === "string" &&
1628
+ part.state.input.prompt.startsWith("SORTIE_MISSION_REF ") && (() => {
1629
+ try {
1630
+ const ref = JSON.parse(part.state.input.prompt.slice("SORTIE_MISSION_REF ".length));
1631
+ return isRecord(ref) && ref.r === root && ref.m === mission.id;
1632
+ }
1633
+ catch {
1634
+ return false;
1635
+ }
1636
+ })())))
1637
+ return undefined;
1638
+ const coordinatorResponse = await getMethod.call(sessionAPI, { path: { id: mission.coordinator }, query: { directory: input.directory } });
1639
+ const coordinatorInfo = isRecord(coordinatorResponse) ? coordinatorResponse.data : undefined;
1640
+ if (!isRecord(coordinatorInfo) || coordinatorInfo.id !== mission.coordinator ||
1641
+ coordinatorInfo.parentID !== root || coordinatorInfo.agent !== "dogs-coordinator" ||
1642
+ coordinatorInfo.outcome !== "succeeded")
1643
+ return undefined;
1644
+ if (childrenMethod !== undefined) {
1645
+ const childrenResponse = await childrenMethod.call(sessionAPI, { path: { id: root }, query: { directory: input.directory } });
1646
+ const children = isRecord(childrenResponse) && Array.isArray(childrenResponse.data) ? childrenResponse.data : [];
1647
+ if (!children.some(child => isRecord(child) && child.id === mission.coordinator &&
1648
+ child.parentID === root && child.agent === "dogs-coordinator"))
1649
+ return undefined;
1650
+ }
1651
+ const coordinator = mission.coordinator;
1652
+ const historyResponse = await messages.call(sessionAPI, { path: { id: coordinator }, query: { directory: input.directory } });
1653
+ const history = isRecord(historyResponse) && Array.isArray(historyResponse.data) ? historyResponse.data : [];
1654
+ const candidates = [];
1655
+ for (const message of history) {
1656
+ if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant" ||
1657
+ message.info.sessionID !== coordinator || !Array.isArray(message.parts))
1658
+ continue;
1659
+ for (const part of message.parts) {
1660
+ if (!isRecord(part) || part.type !== "tool" || part.tool !== "task" ||
1661
+ !isRecord(part.state) || part.state.status !== "error" || !isRecord(part.state.input) ||
1662
+ typeof part.state.input.prompt !== "string" || !part.state.input.prompt.startsWith("SORTIE_OPERATOR_TASK_REF ") ||
1663
+ typeof part.callID !== "string")
1664
+ continue;
1665
+ try {
1666
+ const ref = JSON.parse(part.state.input.prompt.slice("SORTIE_OPERATOR_TASK_REF ".length));
1667
+ if (isRecord(ref) && ref.r === root && ref.n === runID && ref.t === unitID &&
1668
+ /^[a-f0-9]{64}$/u.test(String(ref.p)) && /^[a-f0-9]{64}$/u.test(String(ref.h)) &&
1669
+ goalFingerprint({ goal_id: goalID, unit_id: unitID, call_id: part.callID }) === reservationID) {
1670
+ const error = isRecord(part.state.error) ? part.state.error : undefined;
1671
+ candidates.push({ callID: part.callID, depthRefused: error?.type === "tool.execution" &&
1672
+ error.message === 'Subagent depth limit reached (1). Increase "experimental.subagent_depth" to allow nested subagents.' });
1673
+ }
1674
+ }
1675
+ catch { /* malformed native reference cannot prove this reservation */ }
1676
+ }
1677
+ }
1678
+ if (candidates.length !== 1)
1679
+ return undefined;
1680
+ if (childrenMethod !== undefined) {
1681
+ const descendantsResponse = await childrenMethod.call(sessionAPI, { path: { id: coordinator }, query: { directory: input.directory } });
1682
+ if (!isRecord(descendantsResponse) || !Array.isArray(descendantsResponse.data) || descendantsResponse.data.length !== 0)
1683
+ return undefined;
1684
+ }
1685
+ else if (mission.submission?.status !== "blocked" || !candidates[0].depthRefused)
1686
+ return undefined;
1687
+ return candidates[0].callID;
1688
+ }
1612
1689
  for (const reservation of pending) {
1613
1690
  const matches = [];
1614
1691
  for (const message of data) {
@@ -1686,8 +1763,12 @@ export const SortieDogsPlugin = async (input, options) => {
1686
1763
  matches.push({ callID: part.callID, status: String(part.state.status), elapsed });
1687
1764
  }
1688
1765
  }
1689
- if (matches.length !== 1)
1690
- continue;
1766
+ if (matches.length !== 1) {
1767
+ const callID = matches.length === 0 ? await terminalCancelledMissionTask(reservation.unit_id, reservation.reservation_id, state.goal_id) : undefined;
1768
+ if (callID === undefined)
1769
+ continue;
1770
+ matches.push({ callID, status: "error", elapsed: null });
1771
+ }
1691
1772
  const match = matches[0];
1692
1773
  // Reconcile lifecycle accounting only. Lost in-memory validation bindings cannot be
1693
1774
  // reconstructed from worker prose and must not manufacture acceptance evidence.
@@ -7743,6 +7824,9 @@ export const SortieDogsPlugin = async (input, options) => {
7743
7824
  currentBudget: async (root) => {
7744
7825
  if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
7745
7826
  throw new Error("operator-coordinator-required");
7827
+ // Planning must account for terminal native Tasks before reserving new units.
7828
+ // Otherwise a cancelled predecessor keeps a stale unit reservation across missions.
7829
+ await recoverCompletedGoalReservations(root);
7746
7830
  const state = await currentGoal(root);
7747
7831
  if (state.goal_id === null || state.budget === null)
7748
7832
  return null;
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Dedicated import entry point for OpenCode plugin wrappers.
2
+ * OpenCode V2's package loader resolves the `./plugin` export. Expose its
3
+ * default definition here as well as the named V1 factory used by wrappers.
3
4
  *
4
- * OpenCode configuration lists the package name in the `plugin` array:
5
- * `"plugin": ["sortie-dogs"]`. Files such as `.opencode/plugins/*.ts` use
5
+ * OpenCode configuration lists the package name in the `plugins` array:
6
+ * `"plugins": ["sortie-dogs"]`. V1 wrappers can still use
6
7
  * `import { SortieDogsPlugin } from "sortie-dogs/plugin"`.
7
8
  *
8
- * OpenCode treats every runtime export of a loaded plugin module as a plugin factory and calls
9
- * each one with the plugin input. A non-factory runtime export therefore fails the whole module
10
- * load and silently disables the plugin. This entry exposes the factory and nothing else; every
11
- * other runtime symbol stays on the package root.
9
+ * OpenCode V2 requires a default definition with an id and setup or effect function.
10
+ * Keep other runtime symbols on the package root rather than this shared entry.
12
11
  */
13
12
  export { SortieDogsV010Plugin as SortieDogsPlugin } from "./profiled.js";
13
+ export { default } from "./v2.js";
14
14
  export type { OpenCodeEvent, OpenCodeHooks, OpenCodePlugin, OpenCodePluginInput, SortieDogsPluginOptions, } from "./index.js";
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Dedicated import entry point for OpenCode plugin wrappers.
2
+ * OpenCode V2's package loader resolves the `./plugin` export. Expose its
3
+ * default definition here as well as the named V1 factory used by wrappers.
3
4
  *
4
- * OpenCode configuration lists the package name in the `plugin` array:
5
- * `"plugin": ["sortie-dogs"]`. Files such as `.opencode/plugins/*.ts` use
5
+ * OpenCode configuration lists the package name in the `plugins` array:
6
+ * `"plugins": ["sortie-dogs"]`. V1 wrappers can still use
6
7
  * `import { SortieDogsPlugin } from "sortie-dogs/plugin"`.
7
8
  *
8
- * OpenCode treats every runtime export of a loaded plugin module as a plugin factory and calls
9
- * each one with the plugin input. A non-factory runtime export therefore fails the whole module
10
- * load and silently disables the plugin. This entry exposes the factory and nothing else; every
11
- * other runtime symbol stays on the package root.
9
+ * OpenCode V2 requires a default definition with an id and setup or effect function.
10
+ * Keep other runtime symbols on the package root rather than this shared entry.
12
11
  */
13
12
  export { SortieDogsV010Plugin as SortieDogsPlugin } from "./profiled.js";
13
+ export { default } from "./v2.js";
@@ -376,6 +376,35 @@ export function createProfiledPlugin(profile, assetVersion) {
376
376
  throw new Error("mission-controller-required");
377
377
  return { root, mission };
378
378
  }
379
+ async function reconcileMissionDispatch(root) {
380
+ const mission = await missions.read(root);
381
+ if (!mission?.dispatchOpen || !mission.callID || ["cancelled", "completed"].includes(mission.phase) ||
382
+ taskOwners.has(mission.callID))
383
+ return mission;
384
+ const history = await messages(root);
385
+ const finished = history.flatMap(message => Array.isArray(message.parts) ? message.parts : []).filter(part => record(part) && part.type === "tool" && part.tool === "task" && part.callID === mission.callID &&
386
+ record(part.state) && ["completed", "error"].includes(String(part.state.status)) &&
387
+ record(part.state.input) && part.state.input.subagent_type === profileAgent(profile, "dog-operator") &&
388
+ (part.state.input.task_id === undefined || part.state.input.task_id === mission.coordinator) &&
389
+ (mission.coordinator !== null || (part.state.input.prompt === missions.task(mission).prompt &&
390
+ part.state.input.task_id === undefined)));
391
+ if (finished.length !== 1)
392
+ return mission;
393
+ if (mission.coordinator !== null) {
394
+ const who = await identity(mission.coordinator);
395
+ if (who.parent !== root || who.role !== "dog-operator")
396
+ return mission;
397
+ }
398
+ return missions.reconcileFinishedDispatch(root, mission.id, mission.callID);
399
+ }
400
+ function missionDispatchPacket(mission, run) {
401
+ const packet = missionPacket(mission, run);
402
+ if (!mission.dispatchOpen && ["open", "running"].includes(mission.phase)) {
403
+ return { ...packet, task: missions.task(mission),
404
+ 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." };
405
+ }
406
+ return packet;
407
+ }
379
408
  async function restorePriorAcceptance(root, state) {
380
409
  const succeeded = [...state.units].reverse().find(unit => unit.status === "succeeded");
381
410
  const current = succeeded === undefined ? undefined : {
@@ -608,12 +637,17 @@ export function createProfiledPlugin(profile, assetVersion) {
608
637
  const root = await rootFor(context.sessionID);
609
638
  if (!root)
610
639
  throw new Error("operator-grant-invalid");
640
+ const activeMission = await reconcileMissionDispatch(root);
641
+ if (activeMission && context.sessionID === root && !activeMission.dispatchOpen &&
642
+ ["open", "running"].includes(activeMission.phase)) {
643
+ return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget: await control.currentBudget(root) });
644
+ }
611
645
  const result = await operators.next(root, context.sessionID);
612
646
  const mission = await missions.read(root);
613
647
  if (mission && !["cancelled", "completed"].includes(mission.phase)) {
614
648
  if (record(result) && record(result.task))
615
649
  return JSON.stringify(result);
616
- return JSON.stringify({ ...missionPacket(mission, await operators.required(root)), budget: await control.currentBudget(root) });
650
+ return JSON.stringify({ ...missionDispatchPacket(mission, await operators.required(root)), budget: await control.currentBudget(root) });
617
651
  }
618
652
  if (context.sessionID === root && record(result) && record(result.task)) {
619
653
  const state = await operators.required(root);
@@ -652,9 +686,9 @@ export function createProfiledPlugin(profile, assetVersion) {
652
686
  tools[status] = { description: "Read the durable root-owned operator outcome and host budget counters (max_units, consumed_units, reserved_units, remaining_units) without claiming acceptance or retrying work. An investigating proposal returns its exact short Task reference only before a Task has been admitted; an existing admission never yields a redispatch Task.",
653
687
  args: {}, execute: async (_args, context) => {
654
688
  const root = await rootFor(context.sessionID);
655
- const mission = root ? await missions.read(root) : undefined;
689
+ const mission = root && context.sessionID === root ? await reconcileMissionDispatch(root) : root ? await missions.read(root) : undefined;
656
690
  if (mission && (context.sessionID === root || context.sessionID === mission.coordinator)) {
657
- return JSON.stringify({ ...missionPacket(mission, await operators.read(root)), budget: await control.currentBudget(root) });
691
+ return JSON.stringify({ ...missionDispatchPacket(mission, await operators.read(root)), budget: await control.currentBudget(root) });
658
692
  }
659
693
  await requireRoot(context.sessionID);
660
694
  const state = await operators.read(context.sessionID);
@@ -1083,9 +1117,12 @@ export function createProfiledPlugin(profile, assetVersion) {
1083
1117
  tools[startMission] = { description: "Operator: save a few one-line requirements/negative constraints. The original user message, IDs and ownership are captured automatically. Dispatch the returned Coordinator task immediately, or use plan_units for a simple single-unit Fast-lane task.",
1084
1118
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 } }, execute: async (args, context) => {
1085
1119
  await requireRoot(context.sessionID);
1120
+ await reconcileMissionDispatch(context.sessionID);
1086
1121
  const mission = await missions.start(context.sessionID, args.requirements);
1087
- return JSON.stringify({ mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
1088
- next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." });
1122
+ return JSON.stringify(mission.dispatchOpen
1123
+ ? missionDispatchPacket(mission, await operators.read(context.sessionID))
1124
+ : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
1125
+ next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." });
1089
1126
  } };
1090
1127
  async function declareMissionUnits(root, actor, mission, raw, reason) {
1091
1128
  const plan = missionPlan(mission, raw);
@@ -1102,7 +1139,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1102
1139
  if (budget && budget.remaining_units < plan.units.length && !same)
1103
1140
  throw new Error("mission-budget-exhausted: report the required cumulative extension to Operator");
1104
1141
  const state = await operators.prepareMission(root, plan, actor === root ? undefined
1105
- : { sessionID: actor, callID: mission.callID });
1142
+ : { sessionID: actor, callID: mission.callID }, mission.supersededRunID);
1106
1143
  await restorePriorAcceptance(root, state);
1107
1144
  await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
1108
1145
  control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
@@ -1112,6 +1149,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1112
1149
  item.runID = state.runID;
1113
1150
  item.phase = "running";
1114
1151
  item.submission = null;
1152
+ delete item.supersededRunID;
1115
1153
  });
1116
1154
  return JSON.stringify(await operators.next(root, actor));
1117
1155
  }
@@ -1498,6 +1536,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1498
1536
  if (request.tool === "task" && args.subagent_type === profileAgent(profile, "dog-operator") &&
1499
1537
  mission && !["completed", "cancelled"].includes(mission.phase)) {
1500
1538
  await requireRoot(request.sessionID);
1539
+ await reconcileMissionDispatch(root);
1501
1540
  await missions.admit(root, request.callID, args);
1502
1541
  taskOwners.set(request.callID, { root, actor: request.sessionID, operator: false, mission: true });
1503
1542
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",
@@ -72,7 +72,8 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "ajv": "^8.17.1",
75
- "ajv-formats": "^3.0.1"
75
+ "ajv-formats": "^3.0.1",
76
+ "jsonc-parser": "^3.3.1"
76
77
  },
77
78
  "devDependencies": {
78
79
  "@types/node": "^22.0.0",