sortie-dogs 0.12.3 → 0.12.4

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
 
@@ -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);
@@ -60,6 +60,8 @@ export declare class OperatorMissionRuntime {
60
60
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
61
61
  task(state: OperatorMission): OperatorTask;
62
62
  admit(root: string, callID: string, args: Record<string, unknown>): Promise<OperatorMission>;
63
+ /** Reopen only the exact dispatch whose native Task has a terminal record after a server restart. */
64
+ reconcileFinishedDispatch(root: string, missionID: string, callID: string): Promise<OperatorMission>;
63
65
  claim(root: string, child: string, prompt: string): Promise<string>;
64
66
  brief(state: OperatorMission): string;
65
67
  }
@@ -144,6 +144,15 @@ export class OperatorMissionRuntime {
144
144
  state.submission = null;
145
145
  });
146
146
  }
147
+ /** Reopen only the exact dispatch whose native Task has a terminal record after a server restart. */
148
+ reconcileFinishedDispatch(root, missionID, callID) {
149
+ return this.update(root, state => {
150
+ if (state.id !== missionID || state.callID !== callID || !state.dispatchOpen ||
151
+ ["cancelled", "completed"].includes(state.phase))
152
+ throw new Error("mission-dispatch-reconciliation-stale");
153
+ state.dispatchOpen = false;
154
+ });
155
+ }
147
156
  async claim(root, child, prompt) {
148
157
  const state = await this.update(root, state => {
149
158
  if (state.phase !== "running" || !state.dispatchOpen || !state.callID ||
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";
@@ -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);
@@ -1498,6 +1535,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1498
1535
  if (request.tool === "task" && args.subagent_type === profileAgent(profile, "dog-operator") &&
1499
1536
  mission && !["completed", "cancelled"].includes(mission.phase)) {
1500
1537
  await requireRoot(request.sessionID);
1538
+ await reconcileMissionDispatch(root);
1501
1539
  await missions.admit(root, request.callID, args);
1502
1540
  taskOwners.set(request.callID, { root, actor: request.sessionID, operator: false, mission: true });
1503
1541
  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.4",
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",