opencode-herdr-orchestration 0.3.0 → 0.3.2

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
@@ -595,11 +595,11 @@ This restriction is not hard-enforced by Herdr. Matchers are string globs and ca
595
595
 
596
596
  ## Developer steering
597
597
 
598
- Developer steering is a trusted-Developer queue scoped per Plan ID target under `<git-common-dir>/flocky/steering/<planId>/`. There is exactly one submission tool, `herdr_steering_submit`, registered through the existing plugin mechanism. The submission allowlist holds only the explicit non-flock `developer` context; `shepherd`, `shepherd-governor`, `sheepdog`, `grazer`, `sheep`, `shearer-low`, `shearer-medium`, `unknown`, `ambiguous`, `none`, and unset contexts are denied fail-closed with no filesystem write. Developer is never inferred from session mode, directory, environment text, or prompt content. Static per-agent permissions deny the tool for all seven orchestration roles as defense in depth; the runtime `context.agent` check stays authoritative over static overrides.
598
+ Developer steering is a trusted-Developer queue scoped per Plan ID target under `<git-common-dir>/flocky/steering/<planId>/`. There is exactly one submission tool, `herdr_steering_submit`, plus one native `/steer` command hook as a second enforcement point with parity: both share the single `STEERING_TOOL_ACCESS` allowlist and both write directly through the existing state service with identical validation plus journaling and no parallel store. The submission allowlist holds only the explicit non-flock `developer` context; `shepherd`, `shepherd-governor`, `sheepdog`, `grazer`, `sheep`, `shearer-low`, `shearer-medium`, `unknown`, `ambiguous`, `none`, and unset contexts are denied fail-closed with no filesystem write. Developer is never inferred from session mode, directory, environment text, or prompt content. Static per-agent permissions deny the tool for all seven orchestration roles as defense in depth; the runtime `context.agent` check plus the `/steer` SDK session-agent check stays authoritative over static overrides.
599
599
 
600
600
  ### Steering UX through the existing integration
601
601
 
602
- There is no `herdr steer` command and no package CLI steering command. The Developer submits only through the `herdr_steering_submit` plugin tool in a Developer context:
602
+ There is no `herdr steer` command and no package CLI steering command; the native `/steer` opencode command is installer-owned (`command.steer` with agent pin `developer`, `$ARGUMENTS` template, and description) and intercepted by `command.execute.before`, which writes directly via the existing state service then throws to abort before any model turn with confirmation carrying the steering id plus resolved target and a minimal text-parts fallback. The Developer submits either through `/steer` or through the `herdr_steering_submit` plugin tool in a Developer context:
603
603
 
604
604
  ```json
605
605
  {
@@ -627,7 +627,7 @@ The recorded `developer` submitter is integration-asserted by the OpenCode plugi
627
627
  }
628
628
  ```
629
629
 
630
- Any session that can present as `developer` — including a custom-configured `developer` agent — can submit. Flock roles cannot present as Developer: `developer` is not a registered orchestration agent, no spawn matrix entry creates it, static per-agent permissions deny `herdr_steering_submit` for all seven orchestration roles, and the runtime allowlist holds only `developer`. The runtime check stays authoritative over static overrides: even a local config that flips a flock role to `allow` still fails with `UNAUTHORIZED_AGENT` and writes nothing. Developer is mapped to an explicit non-flock `SHEPHERD_MODE` value `developer`, distinct from `shepherd`, `governor`, `sheepdog`, `grazer`, `sheep`, `shearer`, and `none`. Keep remote authorization and human review as the authoritative controls for anything consequential. There is no `herdr steer` command and no package CLI steering command; `bin/orchestration.js` and `package.json` expose only `opencode-herdr-orchestration` with `install`, `update`, `configure-agents`, `status`, `uninstall`, `install-hooks`, and `uninstall-hooks`.
630
+ Any session that can present as `developer` — including the registered `developer` profile — can submit. Flock roles cannot present as Developer: the `developer` profile holds only `herdr_steering_submit` with no spawn rights and no spawn matrix entry creates or targets it, static per-agent permissions deny `herdr_steering_submit` for all seven orchestration roles, and the runtime allowlist holds only `developer`. The runtime check plus the `/steer` SDK session-agent resolution stays authoritative over static overrides: even a local config that flips a flock role to `allow` still fails with `UNAUTHORIZED_AGENT` and writes nothing, and an unresolvable session agent fails closed with no write. Developer is mapped to an explicit non-flock `SHEPHERD_MODE` value `developer`, distinct from `shepherd`, `governor`, `sheepdog`, `grazer`, `sheep`, `shearer`, and `none`. Keep remote authorization and human review as the authoritative controls for anything consequential. There is still no `herdr steer` command and no package CLI steering command; `bin/orchestration.js` and `package.json` expose only `opencode-herdr-orchestration` with `install`, `update`, `configure-agents`, `status`, `uninstall`, `install-hooks`, and `uninstall-hooks`, while the installer owns `command.steer` in the OpenCode config. `/steer` arguments are `<content>` or `<planId> :: <content>`; omit `planId` only for a single active target.
631
631
 
632
632
  ## Shepherd ownership and lifecycle synchronization
633
633
 
@@ -715,7 +715,7 @@ Live-verified on `herdr 0.8.2` plus `opencode 1.18.29` plus package `0.2.1` with
715
715
 
716
716
  **Cause:** On Windows PowerShell `opencode` may resolve to the direct exe (`node_modules/opencode-ai/bin/opencode.exe`) or to a shim (`opencode.cmd`, `opencode.ps1`, `opencode.exe` in the npm prefix) depending on `PATH` order. `PATH` here means the session `PATH` inspected read-only with `$env:PATH`. File-version text is not authoritative next to CLI `opencode --version` output `1.18.29`. Herdr panes inherit the Herdr server environment, not the current session `PATH`. A global `opencode upgrade` recreates the npm shims, so a previously durable order can flap back to shim-first with a bumped `opencode --version` versus `npm view opencode-ai version`.
717
717
 
718
- **Check:** Inspect the resolver without changing it. `(Get-Command opencode).Source` shows the winning launcher. `(Get-Command opencode -All).Source` lists every candidate in order and reveals a reappearing shim after a global update. `(Get-Command herdr).Source` plus `(Get-Command herdr -All).Source` confirms Herdr resolves to a single `bin` exe with no shim confusion. Compare `opencode --version` against the full-path exe and shim forms; healthy hosts report the same `1.18.29` from each, while a bumped `opencode --version` against `npm view opencode-ai version` signals a recent global update. Confirm the process-spawn path with `Start-Process` using `-FilePath` from the resolved launcher plus `-ArgumentList --version -NoNewWindow -Wait`; live the extensionless shim fails with `%1 is not a valid Win32 application` while the direct exe reports `1.18.29`. Inspect order read-only with `$env:PATH`. Confirm the server split with `herdr status` showing `status: running` for `server` separate from client. This package never overwrites the global environment. Config resolution only reads the environment in `src/installer.js:40` (`configDirectory`) and validation runs isolated with `OPENCODE_DISABLE_PROJECT_CONFIG` in `src/installer.js:529` (`validateOpenCode`), with status reporting in `src/installer.js:552` (`status`). Autoupdate context is `npm view opencode-herdr-orchestration version` reporting `0.2.1` matching `node bin/orchestration.js status` plus `herdr channel show` reporting `stable` plus `herdr update --help` describing the handoff option plus `opencode upgrade --help` listing upgrade targets and methods.
718
+ **Check:** Inspect the resolver without changing it. `(Get-Command opencode).Source` shows the winning launcher. `(Get-Command opencode -All).Source` lists every candidate in order and reveals a reappearing shim after a global update. `(Get-Command herdr).Source` plus `(Get-Command herdr -All).Source` confirms Herdr resolves to a single `bin` exe with no shim confusion. Compare `opencode --version` against the full-path exe and shim forms; healthy hosts report the same `1.18.29` from each, while a bumped `opencode --version` against `npm view opencode-ai version` signals a recent global update. Confirm the process-spawn path with `Start-Process` using `-FilePath` from the resolved launcher plus `-ArgumentList --version -NoNewWindow -Wait`; live the extensionless shim fails with `%1 is not a valid Win32 application` while the direct exe reports `1.18.29`. Inspect order read-only with `$env:PATH`. Confirm the server split with `herdr status` showing `status: running` for `server` separate from client. This package never overwrites the global environment. Config resolution only reads the environment in `src/installer.js:48` (`configDirectory`) and validation runs isolated with `OPENCODE_DISABLE_PROJECT_CONFIG` in `src/installer.js:594` (`validateOpenCode`), with status reporting in `src/installer.js:617` (`status`). Autoupdate context is `npm view opencode-herdr-orchestration version` reporting `0.2.1` matching `node bin/orchestration.js status` plus `herdr channel show` reporting `stable` plus `herdr update --help` describing the handoff option plus `opencode upgrade --help` listing upgrade targets and methods.
719
719
 
720
720
  **Fix:** For current-session relief use the winning full exe path directly. For a durable fix apply a persistent user-chosen `PATH` reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready, then follow [Upgrade](#upgrade) for the package update path. Session-local changes alone never fix Herdr spawns because panes inherit the server environment. This package never reorders global state automatically and never sets global environment values automatically; the persistent reorder is an operator-chosen Windows user environment change, not a package write.
721
721
 
@@ -752,6 +752,8 @@ npm run check
752
752
  npm test
753
753
  ```
754
754
 
755
+ Launcher diagnostics are read-only via `node bin/orchestration.js doctor`, which reports the winning launcher plus ordered candidates plus per-candidate versions plus agreement plus spawn probe plus integration presence plus flapping signals as JSON plus a human summary with operator-executed remedies. See [Troubleshooting](#troubleshooting) with [Missing Herdr OpenCode integration](#missing-herdr-opencode-integration) and [Windows exe versus shim launcher resolution](#windows-exe-versus-shim-launcher-resolution) for symptom plus check plus verify lineage, and [Installation](#installation), [Manual Installation](#manual-installation), [Upgrade](#upgrade), and [Recovery](#recovery) for procedures instead of duplicating them here.
756
+
755
757
  Tests cover topology, model variants, permissions, override merging, session mode isolation, response selection, signed cursors, UTF-8 pagination, concurrent response reads, tool authorization, orchestration state storage and access, Developer steering submission allowlists and denials, append-only ordering, concurrent submissions, unread detection, read-without-consume, restart recovery, idempotent consume, Plan ID isolation, lock and journal recovery, permission parity, absence of steering CLIs, shepherd ownership lifecycle validation, session plus generation fencing, non-owner denial, both-phase race fencing, submission during milestone with consumption after sheepdog yields, each mandatory sync point with disposition before consume, snapshots with pending consequential gating, correction routing with raw-record rejection, no consequential authorization, worker raw-access denial, sensitive-data exclusion, M2 regression, M4 regression across a real linked worktree common directory plus migration across service instances and processes plus full end to end M1 through M3 submit plus ownership plus sync plus consume plus fail closed conflicts, project permission skill disposable-repo end to end from human instruction to skill application to git diff to debug config plus negative global refusal plus denial plus consequential plus flock preservation with widened project tools, and Git hook behavior on protected, worker, review, governance, and planning pushes.
756
758
 
757
759
  ## Releases
@@ -22,7 +22,9 @@ import {
22
22
  validateOpenCode,
23
23
  writeAgentFilesManifest,
24
24
  writePluginConfig,
25
+ writeSteerCommand,
25
26
  } from "../src/installer.js";
27
+ import { collectDoctorReport } from "../src/doctor.js";
26
28
 
27
29
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
28
30
  const hooksPath = join(homedir(), ".config", "opencode-herdr-orchestration", "hooks");
@@ -127,12 +129,14 @@ async function installOrUpdate(useLatest) {
127
129
  restoreBackup(result.file, result.backup, result.existed);
128
130
  throw new Error(`OpenCode validation failed; restored the previous config. ${error.message}`);
129
131
  }
132
+ const steerResult = writeSteerCommand(configDir, false);
130
133
  const reconciliation = reconcileAgentFiles(configDir, { remove: false });
131
134
  writeAgentFilesManifest(configDir, reconciliation.manifestEntries, version);
132
135
  if (flags.includes("--with-hooks")) installHooks();
133
136
  process.stdout.write(`Configured ${PACKAGE_NAME}@${version} in ${result.file}\n`);
134
137
  reportReconciliation(reconciliation);
135
138
  if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
139
+ if (steerResult.backup && steerResult.backup !== result.backup) process.stdout.write(`Backup: ${steerResult.backup}\n`);
136
140
  for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
137
141
  process.stdout.write("Restart OpenCode intentionally to load the new configuration.\n");
138
142
  }
@@ -157,6 +161,7 @@ async function configureAgents() {
157
161
 
158
162
  function uninstallOrchestration() {
159
163
  const configDir = configDirectory();
164
+ const steerResult = writeSteerCommand(configDir, true);
160
165
  const result = writePluginConfig(configDir, true);
161
166
  const packageBackups = uninstallPackage(configDir);
162
167
  const reconciliation = reconcileAgentFiles(configDir, { remove: true });
@@ -167,7 +172,8 @@ function uninstallOrchestration() {
167
172
  process.stdout.write(`Removed package-owned agent file: ${relPath}\n`);
168
173
  }
169
174
  reportReconciliation(reconciliation);
170
- if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
175
+ if (steerResult.backup) process.stdout.write(`Backup: ${steerResult.backup}\n`);
176
+ if (result.backup && result.backup !== steerResult.backup) process.stdout.write(`Backup: ${result.backup}\n`);
171
177
  for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
172
178
  }
173
179
 
@@ -185,6 +191,11 @@ function captureHookStatus() {
185
191
  });
186
192
  }
187
193
 
194
+ function runDoctor() {
195
+ const report = collectDoctorReport();
196
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
197
+ }
198
+
188
199
  try {
189
200
  if (command === "install") await installOrUpdate(false);
190
201
  else if (command === "update") await installOrUpdate(true);
@@ -193,8 +204,9 @@ try {
193
204
  else if (command === "install-hooks") installHooks();
194
205
  else if (command === "uninstall-hooks") uninstallHooks();
195
206
  else if (command === "status") fullStatus();
207
+ else if (command === "doctor") runDoctor();
196
208
  else {
197
- process.stderr.write("Usage: opencode-herdr-orchestration <install|update|configure-agents|status|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
209
+ process.stderr.write("Usage: opencode-herdr-orchestration <install|update|configure-agents|status|doctor|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
198
210
  process.exitCode = 2;
199
211
  }
200
212
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-herdr-orchestration",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Capability-separated Herdr orchestration agents for OpenCode",
5
5
  "author": "CodingJinxx",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "test": "node --test",
31
- "check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check bin/orchestration.js"
31
+ "check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check src/steer.js && node --check src/installer.js && node --check src/doctor.js && node --check bin/orchestration.js"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=20"
package/src/agents.js CHANGED
@@ -105,11 +105,15 @@ function stateToolPermissions(allowedTools) {
105
105
  // Developer steering submission (M2, Option A, trusted Developer only).
106
106
  // The explicit non-flock `developer` context is the sole submitter; all
107
107
  // seven orchestration roles are denied as defense in depth. The runtime
108
- // context-agent check in src/index.js stays authoritative over these static
109
- // entries: even a user override flipping one to "allow" must not bypass the
110
- // allowlist. No flock role may present as Developer: developer is not a
111
- // registered orchestration agent and no spawn matrix entry creates it.
108
+ // context-agent check in src/index.js plus the /steer hook in src/steer.js
109
+ // stays authoritative over these static entries: even a user override
110
+ // flipping one to "allow" must not bypass the allowlist. No flock role may
111
+ // present as Developer: the developer profile below holds only the submit
112
+ // tool and no spawn matrix entry creates or targets it.
112
113
  export const DEVELOPER_AGENT = "developer";
114
+ export const DEVELOPER_PROMPT = String.raw`
115
+ You are developer, the trusted steering submitter. Submit bounded steering via /steer as <content> or <planId> :: <content>; omit planId only when exactly one active steering target exists. You hold only herdr_steering_submit; you never implement flock work, never spawn workers, and never read raw steering.
116
+ `.trim();
113
117
  export const ORCHESTRATION_ROLES = Object.freeze([
114
118
  "shepherd",
115
119
  "shepherd-governor",
@@ -629,6 +633,23 @@ export function createAgents(options = {}) {
629
633
 
630
634
  "shearer-low": reviewerAgent(reviewerModel, "low"),
631
635
  "shearer-medium": reviewerAgent(reviewerModel, "medium"),
636
+
637
+ developer: {
638
+ mode: "primary",
639
+ description: "Trusted Developer steering submitter; submits bounded steering via /steer; never implements flock work.",
640
+ prompt: DEVELOPER_PROMPT,
641
+ permission: {
642
+ ...stateToolPermissions([]),
643
+ ...steeringToolPermissions([STEERING_TOOLS.submit]),
644
+ ...rawSteeringToolPermissions([]),
645
+ ...ownershipToolPermissions([]),
646
+ herdr_agent_response: "deny",
647
+ task: "deny",
648
+ edit: "deny",
649
+ apply_patch: "deny",
650
+ bash: { "*": "deny", ...separatorDenials },
651
+ },
652
+ },
632
653
  };
633
654
  }
634
655
 
package/src/doctor.js ADDED
@@ -0,0 +1,383 @@
1
+ // Read-only launcher diagnostics (doctor M1).
2
+ // Inspects the Windows opencode launcher resolution without changing state.
3
+ // Every collector below only reads: PATH inspection, Get-Command ordering,
4
+ // per-candidate --version probes, spawn probe observation, integration
5
+ // presence checks, and flapping signals. No global environment mutation,
6
+ // no shell profile change, no file creation, and no Herdr or OpenCode
7
+ // invocation beyond read-only status and version queries.
8
+ // Operator-executed remedies are reported as text for the human operator;
9
+ // this module never applies them.
10
+ import spawn from "cross-spawn";
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { platform } from "node:os";
14
+ import { delimiter, join } from "node:path";
15
+
16
+ export const DOCTOR_ANCHORS = Object.freeze([
17
+ "#installation",
18
+ "#manual-installation",
19
+ "#upgrade",
20
+ "#recovery",
21
+ "#troubleshooting",
22
+ "#missing-herdr-opencode-integration",
23
+ "#windows-exe-versus-shim-launcher-resolution",
24
+ ]);
25
+
26
+ export const README_ANCHORS = DOCTOR_ANCHORS;
27
+
28
+ export const HEALTHY_OPENCODE_VERSION = "1.18.29";
29
+ export const HEALTHY_HERDR_VERSION = "0.8.2";
30
+ export const HEALTHY_INTEGRATION_STATUS = "opencode: current (v10)";
31
+ export const MISSING_INTEGRATION_STATUS = "opencode: not installed";
32
+ export const WIN32_SPAWN_ERROR = "%1 is not a valid Win32 application";
33
+ export const STABLE_CHANNEL = "stable";
34
+
35
+ function normalizeSource(source) {
36
+ return typeof source === "string" ? source.trim() : "";
37
+ }
38
+
39
+ export function classifyLauncher(source) {
40
+ const value = normalizeSource(source);
41
+ if (!value) return { source: value, kind: "unknown" };
42
+ const lower = value.toLowerCase().replaceAll("/", "\\");
43
+ const base = value.split(/[\\/]/).pop().toLowerCase();
44
+ if (base === "opencode.cmd") return { source: value, kind: "shim-cmd" };
45
+ if (base === "opencode.ps1") return { source: value, kind: "shim-ps1" };
46
+ if (base === "opencode" || base === "opencode.sh") return { source: value, kind: "extensionless-shim" };
47
+ if (base === "opencode.exe") {
48
+ if (lower.includes("\\node_modules\\opencode-ai\\bin\\opencode.exe")) {
49
+ return { source: value, kind: "direct-exe" };
50
+ }
51
+ return { source: value, kind: "shim-exe" };
52
+ }
53
+ return { source: value, kind: "unknown" };
54
+ }
55
+
56
+ export function orderCandidates(sources) {
57
+ const list = Array.isArray(sources) ? sources : [];
58
+ const ordered = [];
59
+ const seen = new Set();
60
+ for (const raw of list) {
61
+ const value = normalizeSource(raw);
62
+ if (!value || seen.has(value.toLowerCase())) continue;
63
+ seen.add(value.toLowerCase());
64
+ const classified = classifyLauncher(value);
65
+ ordered.push({ source: classified.source, kind: classified.kind, order: ordered.length });
66
+ }
67
+ return ordered;
68
+ }
69
+
70
+ export function parseVersionText(text) {
71
+ if (typeof text !== "string") return null;
72
+ const match = text.match(/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/);
73
+ return match ? match[1] : null;
74
+ }
75
+
76
+ export function checkVersionAgreement(versionEntries) {
77
+ const entries = Array.isArray(versionEntries) ? versionEntries : [];
78
+ const normalized = entries.map((entry) => ({
79
+ source: normalizeSource(entry?.source),
80
+ version: typeof entry?.version === "string" && entry.version.length > 0 ? entry.version : null,
81
+ error: typeof entry?.error === "string" && entry.error.length > 0 ? entry.error : null,
82
+ }));
83
+ const versions = normalized.map((entry) => entry.version);
84
+ const distinct = [...new Set(versions.filter((version) => version !== null))];
85
+ const allHaveVersion = normalized.length > 0 && normalized.every((entry) => entry.version !== null && entry.error === null);
86
+ const agree = allHaveVersion && distinct.length === 1;
87
+ return {
88
+ agree,
89
+ agreement: agree,
90
+ versions: normalized,
91
+ distinct,
92
+ detail: agree
93
+ ? `All ${normalized.length} launcher(s) agree on ${distinct[0]}.`
94
+ : `Launchers disagree or are missing versions: ${normalized.map((entry) => `${entry.source || "(empty)"}=${entry.version || entry.error || "unknown"}`).join(", ") || "no candidates"}.`,
95
+ };
96
+ }
97
+
98
+ export function evaluateSpawnProbe({ source, stdout, stderr, status }) {
99
+ const origin = normalizeSource(source);
100
+ const outText = typeof stdout === "string" ? stdout : "";
101
+ const errText = typeof stderr === "string" ? stderr : "";
102
+ const combined = `${outText}\n${errText}`;
103
+ if (combined.includes(WIN32_SPAWN_ERROR)) {
104
+ return { source: origin, ok: false, version: parseVersionText(outText), error: WIN32_SPAWN_ERROR };
105
+ }
106
+ if (status !== 0) {
107
+ const detail = errText.replace(/\s+/g, " ").trim().slice(0, 500) || `exit status ${String(status)}`;
108
+ return { source: origin, ok: false, version: parseVersionText(outText), error: detail };
109
+ }
110
+ const version = parseVersionText(outText);
111
+ if (!version) {
112
+ return { source: origin, ok: false, version: null, error: "no version in probe output" };
113
+ }
114
+ return { source: origin, ok: true, version, error: null };
115
+ }
116
+
117
+ export function checkIntegrationPresence({ integrationStatusText, herdrHelpText, integrationInstallHelpText } = {}) {
118
+ const statusText = typeof integrationStatusText === "string" ? integrationStatusText : "";
119
+ const helpText = typeof herdrHelpText === "string" ? herdrHelpText : "";
120
+ const installHelpText = typeof integrationInstallHelpText === "string" ? integrationInstallHelpText : "";
121
+ const present = statusText.includes(HEALTHY_INTEGRATION_STATUS);
122
+ const missing = statusText.includes(MISSING_INTEGRATION_STATUS);
123
+ const hasIntegrationSubcommand = helpText.includes("herdr integration") || helpText.includes("integration <subcommand>");
124
+ const installListsOpencode = installHelpText.includes("opencode");
125
+ return {
126
+ present,
127
+ presence: present,
128
+ statusText: statusText ? statusText.slice(0, 2000) : null,
129
+ missing,
130
+ hasIntegrationSubcommand,
131
+ installListsOpencode,
132
+ detail: present
133
+ ? `Integration present: ${HEALTHY_INTEGRATION_STATUS}.`
134
+ : `Integration not present: expected ${HEALTHY_INTEGRATION_STATUS}${missing ? ` (saw ${MISSING_INTEGRATION_STATUS})` : ""}.`,
135
+ };
136
+ }
137
+
138
+ export function detectFlapping({ candidates, versions, npmViewVersion } = {}) {
139
+ const ordered = Array.isArray(candidates) ? candidates : [];
140
+ const versionEntries = Array.isArray(versions) ? versions : [];
141
+ const npmVersion = typeof npmViewVersion === "string" && npmViewVersion.length > 0 ? npmViewVersion : null;
142
+ const hasShim = ordered.some((candidate) => {
143
+ const kind = candidate?.kind || classifyLauncher(candidate?.source || "").kind;
144
+ return kind === "shim-cmd" || kind === "shim-ps1" || kind === "shim-exe" || kind === "extensionless-shim";
145
+ });
146
+ const reappearingShim = ordered.length > 1 && hasShim;
147
+ const winnerVersion = versionEntries[0]?.version || null;
148
+ const bumpedVersion = Boolean(winnerVersion && npmVersion && winnerVersion !== npmVersion);
149
+ const distinct = [...new Set(versionEntries.map((entry) => entry?.version).filter(Boolean))];
150
+ const versionSplit = distinct.length > 1;
151
+ const signals = [];
152
+ if (reappearingShim) signals.push("reappearing shim among ordered candidates after a global update");
153
+ if (bumpedVersion) signals.push(`bumped launcher version ${winnerVersion} versus npm view ${npmVersion}`);
154
+ if (versionSplit) signals.push(`version split across candidates: ${distinct.join(", ")}`);
155
+ if (ordered.length > 1 && ordered[0]?.kind !== "direct-exe" && hasShim) {
156
+ signals.push("winner is a shim while a direct exe exists elsewhere in order");
157
+ }
158
+ const flapping = signals.length > 0;
159
+ return {
160
+ flapping,
161
+ reappearingShim,
162
+ bumpedVersion,
163
+ versionSplit,
164
+ npmViewVersion: npmVersion,
165
+ signals,
166
+ detail: flapping ? `Flapping signals: ${signals.join("; ")}.` : "No flapping signals.",
167
+ };
168
+ }
169
+
170
+ function describeCandidate(candidate, index) {
171
+ return `${index}: ${candidate.source} [${candidate.kind}]`;
172
+ }
173
+
174
+ export function formatHumanSummary(report) {
175
+ const winner = report?.winner ? `${report.winner.source} [${report.winner.kind}]` : "(no winner)";
176
+ const candidates = Array.isArray(report?.candidates) ? report.candidates : [];
177
+ const versions = Array.isArray(report?.versions) ? report.versions : [];
178
+ const agreement = report?.agreement || {};
179
+ const spawnProbe = report?.spawnProbe || {};
180
+ const integration = report?.integration || {};
181
+ const flapping = report?.flapping || {};
182
+ const lines = [];
183
+ lines.push("Launcher diagnostics (read-only, no changes applied).");
184
+ lines.push(`Winner per (Get-Command opencode).Source: ${winner}.`);
185
+ lines.push(`Ordered candidates per (Get-Command opencode -All).Source (${candidates.length}):`);
186
+ for (let index = 0; index < candidates.length; index += 1) {
187
+ const version = versions[index]?.version || versions[index]?.error || "unknown";
188
+ lines.push(`- ${describeCandidate(candidates[index], index)} reports ${version}.`);
189
+ }
190
+ lines.push(
191
+ agreement.agree
192
+ ? `Agreement: all candidates agree on ${agreement.distinct?.[0] || HEALTHY_OPENCODE_VERSION}.`
193
+ : `Agreement: ${agreement.detail || "versions disagree"}. Compare opencode --version against each full-path exe and shim form; healthy hosts report the same ${HEALTHY_OPENCODE_VERSION} from each.`,
194
+ );
195
+ lines.push(
196
+ spawnProbe.ok
197
+ ? `Spawn probe: Start-Process with -FilePath from the resolved launcher plus -ArgumentList --version -NoNewWindow -Wait reports ${spawnProbe.version} without ${WIN32_SPAWN_ERROR}.`
198
+ : `Spawn probe: Start-Process with -FilePath from ${spawnProbe.source || "winner"} plus -ArgumentList --version -NoNewWindow -Wait did not succeed (${spawnProbe.error || "unknown"}); live the extensionless shim fails with ${WIN32_SPAWN_ERROR} while the direct exe reports ${HEALTHY_OPENCODE_VERSION}.`,
199
+ );
200
+ lines.push(
201
+ integration.present
202
+ ? `Integration presence: herdr integration status shows ${HEALTHY_INTEGRATION_STATUS}. herdr --help lists herdr integration <subcommand> and herdr integration install --help lists opencode as a valid target.`
203
+ : `Integration presence: herdr integration status does not show ${HEALTHY_INTEGRATION_STATUS}${integration.missing ? ` (saw ${MISSING_INTEGRATION_STATUS})` : ""}. Check opencode agent list plus opencode debug agent shepherd plus node bin/orchestration.js status.`,
204
+ );
205
+ lines.push(
206
+ flapping.flapping
207
+ ? `Flapping signals: ${flapping.detail || flapping.signals?.join("; ")}. A global opencode upgrade recreates the npm shims, so a previously durable order can flap back to shim-first with a bumped opencode --version versus npm view opencode-ai version. Re-inspect (Get-Command opencode -All).Source for a reappearing shim.`
208
+ : "Flapping signals: none. No reappearing shim and no bumped version versus npm view opencode-ai version.",
209
+ );
210
+ lines.push(`PATH inspected read-only via $env:PATH plus (Get-Command herdr).Source plus (Get-Command herdr -All).Source; Herdr resolves to a single bin exe with no shim confusion per herdr --version ${HEALTHY_HERDR_VERSION}. Confirm the server split with herdr status showing status: running for server separate from client. Autoupdate context is npm view opencode-herdr-orchestration version plus herdr channel show reporting ${STABLE_CHANNEL} plus herdr update --help plus opencode upgrade --help.`);
211
+ lines.push("Operator-executed remedies (this package never overwrites the global environment and never sets global environment values automatically):");
212
+ lines.push("- For current-session relief use the winning full exe path directly.");
213
+ lines.push("- For a durable fix apply a persistent user-chosen PATH reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready.");
214
+ lines.push("- Session-local changes alone never fix Herdr spawns because panes inherit the Herdr server environment; the persistent reorder is an operator-chosen Windows user environment change, not a package change.");
215
+ lines.push("See [Installation](#installation), [Manual Installation](#manual-installation), [Upgrade](#upgrade), and [Recovery](#recovery) for procedures instead of duplicating them here, with [Troubleshooting](#troubleshooting) plus [Missing Herdr OpenCode integration](#missing-herdr-opencode-integration) plus [Windows exe versus shim launcher resolution](#windows-exe-versus-shim-launcher-resolution) for symptom plus check plus verify lineage.");
216
+ return lines.join("\n");
217
+ }
218
+
219
+ export function buildDoctorReport({ candidates, versions, spawnProbe, integration, flapping, pathEntries } = {}) {
220
+ const ordered = orderCandidates(candidates || []);
221
+ const versionEntries = Array.isArray(versions) && versions.length > 0
222
+ ? versions.map((entry, index) => ({
223
+ source: normalizeSource(entry?.source || ordered[index]?.source || ""),
224
+ version: typeof entry?.version === "string" && entry.version ? entry.version : null,
225
+ error: typeof entry?.error === "string" && entry.error ? entry.error : null,
226
+ }))
227
+ : ordered.map((candidate) => ({ source: candidate.source, version: null, error: "unknown" }));
228
+ const agreement = checkVersionAgreement(versionEntries);
229
+ const winner = ordered[0] || null;
230
+ const probe = spawnProbe && typeof spawnProbe === "object" && spawnProbe.source
231
+ ? spawnProbe
232
+ : { source: winner?.source || null, ok: false, version: versionEntries[0]?.version || null, error: versionEntries[0]?.error || "unknown" };
233
+ const integrationResult = integration && typeof integration === "object" ? integration : checkIntegrationPresence({});
234
+ const flappingResult = flapping && typeof flapping === "object" && Array.isArray(flapping.signals)
235
+ ? flapping
236
+ : { flapping: false, reappearingShim: false, bumpedVersion: false, versionSplit: false, npmViewVersion: null, signals: [], detail: "No flapping signals." };
237
+ const pathList = Array.isArray(pathEntries) ? pathEntries : [];
238
+ const base = {
239
+ tool: "doctor",
240
+ readOnly: true,
241
+ winner,
242
+ candidates: ordered,
243
+ orderedCandidates: ordered,
244
+ versions: versionEntries,
245
+ candidateVersions: versionEntries,
246
+ agreement,
247
+ spawnProbe: probe,
248
+ spawnProbes: versionEntries.map((entry) => ({
249
+ source: entry.source,
250
+ ok: entry.version !== null && entry.error === null,
251
+ version: entry.version,
252
+ error: entry.error,
253
+ })),
254
+ integration: integrationResult,
255
+ integrationPresence: integrationResult,
256
+ flapping: flappingResult,
257
+ flappingSignals: flappingResult.signals || [],
258
+ path: { entries: pathList, inspectedReadOnly: true },
259
+ remedies: [
260
+ "For current-session relief use the winning full exe path directly.",
261
+ "For a durable fix apply a persistent user-chosen PATH reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready.",
262
+ "Session-local changes alone never fix Herdr spawns because panes inherit the Herdr server environment.",
263
+ ],
264
+ anchors: [...DOCTOR_ANCHORS],
265
+ readmeAnchors: [...DOCTOR_ANCHORS],
266
+ };
267
+ const summary = formatHumanSummary(base);
268
+ return {
269
+ ...base,
270
+ operatorRemedies: [...base.remedies],
271
+ summary,
272
+ humanSummary: summary,
273
+ };
274
+ }
275
+
276
+ function runCrossSpawnVersion(candidate) {
277
+ try {
278
+ const result = spawn.sync(candidate, ["--version"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
279
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
280
+ const stderr = typeof result.stderr === "string" ? result.stderr : (result.error ? String(result.error.message || result.error) : "");
281
+ const version = parseVersionText(stdout);
282
+ if (result.error) {
283
+ return { source: candidate, version, error: String(result.error.message || result.error).slice(0, 500) };
284
+ }
285
+ if (result.status !== 0) {
286
+ return { source: candidate, version, error: (stderr || `exit status ${String(result.status)}`).replace(/\s+/g, " ").trim().slice(0, 500) };
287
+ }
288
+ if (!version) return { source: candidate, version: null, error: "no version in output" };
289
+ return { source: candidate, version, error: null };
290
+ } catch (error) {
291
+ return { source: candidate, version: null, error: String(error?.message || error).slice(0, 500) };
292
+ }
293
+ }
294
+
295
+ function runHelpCapture(command, args) {
296
+ try {
297
+ const result = spawn.sync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15000 });
298
+ if (result.error) return "";
299
+ return typeof result.stdout === "string" ? result.stdout : "";
300
+ } catch {
301
+ return "";
302
+ }
303
+ }
304
+
305
+ function collectLiveCandidates(envPath) {
306
+ const currentPlatform = platform();
307
+ if (currentPlatform === "win32") {
308
+ try {
309
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "(Get-Command opencode -All).Source"], {
310
+ encoding: "utf8",
311
+ windowsHide: true,
312
+ timeout: 15000,
313
+ });
314
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
315
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
316
+ if (lines.length > 0) return lines;
317
+ } catch {}
318
+ }
319
+ const raw = typeof envPath === "string" ? envPath : "";
320
+ const entries = raw.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
321
+ const found = [];
322
+ const names = currentPlatform === "win32" ? ["opencode.cmd", "opencode.ps1", "opencode.exe", "opencode"] : ["opencode"];
323
+ for (const dir of entries) {
324
+ for (const name of names) {
325
+ const candidate = join(dir.replace(/[\\/]+$/, ""), name);
326
+ try {
327
+ if (existsSync(candidate)) found.push(candidate);
328
+ } catch {}
329
+ }
330
+ if (found.length >= 8) break;
331
+ }
332
+ return found;
333
+ }
334
+
335
+ function collectLivePathEntries(envPath) {
336
+ const raw = typeof envPath === "string" ? envPath : "";
337
+ return raw.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
338
+ }
339
+
340
+ export function collectDoctorReport(options = {}) {
341
+ const envPath = options.envPath !== undefined ? options.envPath : (process.env.PATH || process.env.Path || "");
342
+ const liveCandidates = options.candidates !== undefined ? options.candidates : collectLiveCandidates(envPath);
343
+ const ordered = orderCandidates(liveCandidates);
344
+ const liveVersions = options.versions !== undefined
345
+ ? options.versions
346
+ : ordered.map((candidate) => runCrossSpawnVersion(candidate.source));
347
+ const agreement = checkVersionAgreement(liveVersions);
348
+ const winnerProbeInput = options.spawnProbe !== undefined ? options.spawnProbe : (() => {
349
+ const winnerSource = ordered[0]?.source;
350
+ if (!winnerSource) return { source: null, ok: false, version: null, error: "no candidates" };
351
+ const match = liveVersions[0];
352
+ if (match && match.error && String(match.error).includes(WIN32_SPAWN_ERROR)) {
353
+ return evaluateSpawnProbe({ source: winnerSource, stdout: "", stderr: match.error, status: 1 });
354
+ }
355
+ if (match && match.version) {
356
+ return { source: winnerSource, ok: match.error === null, version: match.version, error: match.error };
357
+ }
358
+ return { source: winnerSource, ok: false, version: null, error: (match && match.error) || "unknown" };
359
+ })();
360
+ const integration = options.integration !== undefined ? options.integration : checkIntegrationPresence({
361
+ integrationStatusText: runHelpCapture("herdr", ["integration", "status"]),
362
+ herdrHelpText: runHelpCapture("herdr", ["--help"]),
363
+ integrationInstallHelpText: runHelpCapture("herdr", ["integration", "install", "--help"]),
364
+ });
365
+ let npmViewVersion = null;
366
+ if (options.npmViewVersion !== undefined) {
367
+ npmViewVersion = options.npmViewVersion;
368
+ } else {
369
+ const npmText = runHelpCapture("npm", ["view", "opencode-ai", "version"]);
370
+ npmViewVersion = parseVersionText(npmText);
371
+ }
372
+ const flapping = options.flapping !== undefined
373
+ ? options.flapping
374
+ : detectFlapping({ candidates: ordered, versions: liveVersions, npmViewVersion });
375
+ return buildDoctorReport({
376
+ candidates: ordered.map((candidate) => candidate.source),
377
+ versions: liveVersions,
378
+ spawnProbe: winnerProbeInput,
379
+ integration,
380
+ flapping,
381
+ pathEntries: collectLivePathEntries(envPath),
382
+ });
383
+ }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from "./agents.js";
17
17
  import { createResponseTool } from "./response.js";
18
18
  import { createStateService } from "./state.js";
19
+ import { createSteerCommandHook } from "./steer.js";
19
20
 
20
21
  const SESSION_MODES = new Map();
21
22
 
@@ -344,39 +345,56 @@ export function createOwnershipTools(stateOptions = {}) {
344
345
  return tools;
345
346
  }
346
347
 
347
- export const HerdrOrchestrationPlugin = async (_input, options = {}) => ({
348
- tool: {
349
- herdr_agent_response: createResponseTool(options.response),
350
- ...createStateTools(options.state),
351
- ...createSteeringTools(options.state),
352
- ...createRawSteeringTools(options.state),
353
- ...createOwnershipTools(options.state),
354
- },
355
- config(config) {
356
- config.agent ??= {};
357
- const agents = createAgents(options);
358
- for (const [name, defaults] of Object.entries(agents)) {
359
- config.agent[name] = mergeAgent(defaults, config.agent[name]);
360
- }
361
- },
348
+ export const HerdrOrchestrationPlugin = async (pluginInput = {}, options = {}) => {
349
+ const stateOptions = { ...(options.state ?? {}) };
350
+ if (stateOptions.cwd === undefined) {
351
+ const fallback = pluginInput?.directory ?? pluginInput?.worktree;
352
+ if (typeof fallback === "string" && fallback.length > 0) stateOptions.cwd = fallback;
353
+ }
354
+ const resolveAgent = options.steerResolveAgent ?? options.steer?.resolveAgent;
355
+ const steerBefore = createSteerCommandHook({
356
+ client: pluginInput?.client,
357
+ stateOptions,
358
+ ...(resolveAgent ? { resolveAgent } : {}),
359
+ });
360
+ return {
361
+ tool: {
362
+ herdr_agent_response: createResponseTool(options.response),
363
+ ...createStateTools(options.state),
364
+ ...createSteeringTools(options.state),
365
+ ...createRawSteeringTools(options.state),
366
+ ...createOwnershipTools(options.state),
367
+ },
368
+ config(config) {
369
+ config.agent ??= {};
370
+ const agents = createAgents(options);
371
+ for (const [name, defaults] of Object.entries(agents)) {
372
+ config.agent[name] = mergeAgent(defaults, config.agent[name]);
373
+ }
374
+ },
362
375
 
363
- async "chat.message"(input) {
364
- if (input.sessionID) SESSION_MODES.set(input.sessionID, modeForAgent(input.agent));
365
- },
376
+ async "command.execute.before"(input, output) {
377
+ return steerBefore(input, output);
378
+ },
366
379
 
367
- async "shell.env"(input, output) {
368
- output.env.SHEPHERD_MODE = input.sessionID
369
- ? (SESSION_MODES.get(input.sessionID) ?? "none")
370
- : "none";
371
- },
380
+ async "chat.message"(input) {
381
+ if (input.sessionID) SESSION_MODES.set(input.sessionID, modeForAgent(input.agent));
382
+ },
372
383
 
373
- async event({ event }) {
374
- if (event?.type === "session.deleted") {
375
- const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
376
- if (sessionID) SESSION_MODES.delete(sessionID);
377
- }
378
- },
379
- });
384
+ async "shell.env"(input, output) {
385
+ output.env.SHEPHERD_MODE = input.sessionID
386
+ ? (SESSION_MODES.get(input.sessionID) ?? "none")
387
+ : "none";
388
+ },
389
+
390
+ async event({ event }) {
391
+ if (event?.type === "session.deleted") {
392
+ const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
393
+ if (sessionID) SESSION_MODES.delete(sessionID);
394
+ }
395
+ },
396
+ };
397
+ };
380
398
 
381
399
  export default HerdrOrchestrationPlugin;
382
400
  export {
package/src/installer.js CHANGED
@@ -5,6 +5,13 @@ import { join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import spawn from "cross-spawn";
7
7
  import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
8
+ import {
9
+ STEER_COMMAND_AGENT as STEER_AGENT,
10
+ STEER_COMMAND_DESCRIPTION as STEER_DESCRIPTION,
11
+ STEER_COMMAND_NAME as STEER_NAME,
12
+ STEER_COMMAND_TEMPLATE as STEER_TEMPLATE,
13
+ steerCommandEntry as steerEntry,
14
+ } from "./steer.js";
8
15
 
9
16
  export const PACKAGE_NAME = "opencode-herdr-orchestration";
10
17
  const NPM_COMMAND = "npm";
@@ -17,6 +24,7 @@ export const AGENT_NAMES = [
17
24
  "sheep",
18
25
  "shearer-low",
19
26
  "shearer-medium",
27
+ "developer",
20
28
  ];
21
29
 
22
30
  // Agents register dynamically through the plugin, so the package owns no agent
@@ -391,6 +399,64 @@ export function writePluginConfig(configDir, remove = false, models) {
391
399
  return { file, backup, changed: true, existed };
392
400
  }
393
401
 
402
+ // Native /steer command owned by the installer. Single source for the shape
403
+ // lives in src/steer.js; these helpers persist it with the same JSONC
404
+ // preserve-comments plus backup discipline as the plugin config. The entry
405
+ // pins agent developer, carries an $ARGUMENTS template, and describes the
406
+ // Developer-only direct-write hook.
407
+ export {
408
+ STEER_COMMAND_AGENT,
409
+ STEER_COMMAND_DESCRIPTION,
410
+ STEER_COMMAND_NAME,
411
+ STEER_COMMAND_TEMPLATE,
412
+ steerCommandEntry,
413
+ } from "./steer.js";
414
+
415
+ export function updateCommandConfig(text, commandName, entry, remove = false) {
416
+ const errors = [];
417
+ const parsed = parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false });
418
+ if (errors.length) {
419
+ const first = errors[0];
420
+ throw new Error(`Invalid OpenCode JSONC at offset ${first.offset}: ${printParseErrorCode(first.error)}.`);
421
+ }
422
+ if (typeof commandName !== "string" || commandName.length === 0) {
423
+ throw new Error("Command name must be a non-empty string.");
424
+ }
425
+ const source = text || "{}";
426
+ if (remove && parsed?.command?.[commandName] === undefined) return source;
427
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes("\r\n") ? "\r\n" : "\n" };
428
+ const value = remove ? undefined : entry;
429
+ return applyEdits(source, modify(source, ["command", commandName], value, { formattingOptions }));
430
+ }
431
+
432
+ export function updateSteerCommand(text, entry = steerEntry(), remove = false) {
433
+ return updateCommandConfig(text, STEER_NAME, entry, remove);
434
+ }
435
+
436
+ export function isSteerCommandConfigured(text) {
437
+ const config = parse(text || "{}", [], { allowTrailingComma: true, disallowComments: false });
438
+ const entry = config?.command?.[STEER_NAME];
439
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
440
+ return (
441
+ entry.agent === STEER_AGENT &&
442
+ typeof entry.template === "string" &&
443
+ entry.template.includes("$ARGUMENTS") &&
444
+ entry.description === STEER_DESCRIPTION
445
+ );
446
+ }
447
+
448
+ export function writeSteerCommand(configDir, remove = false) {
449
+ mkdirSync(configDir, { recursive: true });
450
+ const file = findConfigFile(configDir);
451
+ const existed = existsSync(file);
452
+ const previous = existed ? readFileSync(file, "utf8") : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
453
+ const next = updateSteerCommand(previous, steerEntry(), remove);
454
+ if (next === previous) return { file, backup: null, changed: false, existed };
455
+ const backup = backupFile(file);
456
+ writeFileSync(file, next, "utf8");
457
+ return { file, backup, changed: true, existed };
458
+ }
459
+
394
460
  export function restoreBackup(file, backup, existed = true) {
395
461
  if (backup && existsSync(backup)) copyFileSync(backup, file);
396
462
  else if (!existed) rmSync(file, { force: true });
@@ -552,9 +618,16 @@ export function validateOpenCode(configDir) {
552
618
  export function status(configDir, packageRoot) {
553
619
  const file = findConfigFile(configDir);
554
620
  let configured = false;
621
+ let steerCommandConfigured = false;
555
622
  if (existsSync(file)) {
556
- const config = parse(readFileSync(file, "utf8"), [], { allowTrailingComma: true, disallowComments: false });
623
+ const text = readFileSync(file, "utf8");
624
+ const config = parse(text, [], { allowTrailingComma: true, disallowComments: false });
557
625
  configured = Array.isArray(config?.plugin) && config.plugin.some(isOrchestrationPlugin);
626
+ try {
627
+ steerCommandConfigured = isSteerCommandConfigured(text);
628
+ } catch {
629
+ steerCommandConfigured = false;
630
+ }
558
631
  }
559
632
  const installed = installedVersion(configDir);
560
633
  let detectedAgents = [];
@@ -586,6 +659,7 @@ export function status(configDir, packageRoot) {
586
659
  updateAvailable: Boolean(installed && latest && installed !== latest),
587
660
  configFile: file,
588
661
  pluginConfigured: configured,
662
+ steerCommandConfigured,
589
663
  detectedAgents,
590
664
  agentsReady: detectedAgents.length === AGENT_NAMES.length,
591
665
  obsoleteAgentFiles,
package/src/steer.js ADDED
@@ -0,0 +1,124 @@
1
+ import { DEVELOPER_AGENT, STEERING_TOOL_ACCESS, STEERING_TOOLS } from "./agents.js";
2
+ import { createStateService } from "./state.js";
3
+
4
+ // Native /steer command owned by the installer. The hook in this module is
5
+ // the sole writer: it calls the existing state service directly so validation
6
+ // plus journaling stay identical to the submit tool with no parallel store.
7
+ export const STEER_COMMAND_NAME = "steer";
8
+ export const STEER_COMMAND_AGENT = DEVELOPER_AGENT;
9
+ export const STEER_COMMAND_DESCRIPTION =
10
+ "Submit bounded Developer steering for one Plan ID target (Developer only; flock roles denied).";
11
+ export const STEER_COMMAND_TEMPLATE =
12
+ "Submit Developer steering with arguments: $ARGUMENTS\n\nFormat $ARGUMENTS as `<content>` or `<planId> :: <content>`; omit planId only when exactly one active steering target exists.";
13
+
14
+ export function steerCommandEntry() {
15
+ return {
16
+ template: STEER_COMMAND_TEMPLATE,
17
+ description: STEER_COMMAND_DESCRIPTION,
18
+ agent: STEER_COMMAND_AGENT,
19
+ };
20
+ }
21
+
22
+ // Parse /steer arguments into the exact { content plus optional planId }
23
+ // shape the state service validates. `::` separates an explicit planId
24
+ // candidate from content when the left side is a single token (no whitespace);
25
+ // the candidate is passed through so the state service reports
26
+ // INVALID_PLAN_ID identically to the submit tool. A left side with whitespace
27
+ // is prose, so the whole string stays content and `::` inside sentences never
28
+ // misroutes.
29
+ export function parseSteerArguments(rawArguments) {
30
+ const raw = typeof rawArguments === "string" ? rawArguments : "";
31
+ const separator = raw.indexOf("::");
32
+ if (separator !== -1) {
33
+ const left = raw.slice(0, separator).trim();
34
+ const right = raw.slice(separator + 2).trim();
35
+ if (left.length > 0 && !/\s/.test(left)) {
36
+ return { planId: left, content: right };
37
+ }
38
+ }
39
+ return { content: raw.trim() };
40
+ }
41
+
42
+ // Single shared allowlist with the submit tool: only the explicit developer
43
+ // context passes. All seven orchestration roles plus unknown plus none plus
44
+ // unset fail closed.
45
+ export function isSteerAllowedAgent(agent) {
46
+ return STEERING_TOOL_ACCESS.get(STEERING_TOOLS.submit)?.has(agent) === true;
47
+ }
48
+
49
+ // Resolve the session agent via the SDK client, fail closed to undefined on
50
+ // any unresolvable shape. Prefers session messages (UserMessage.agent) then
51
+ // falls back to session get; any throw or missing agent is unresolvable.
52
+ export async function resolveSessionAgentViaClient(client, sessionID) {
53
+ if (!client || typeof sessionID !== "string" || sessionID.length === 0) return undefined;
54
+ try {
55
+ const session = client?.session;
56
+ if (!session) return undefined;
57
+ if (typeof session.messages === "function") {
58
+ const result = await session.messages({ path: { id: sessionID } });
59
+ const data = result?.data ?? result;
60
+ if (Array.isArray(data)) {
61
+ for (let index = data.length - 1; index >= 0; index -= 1) {
62
+ const agent = data[index]?.info?.agent;
63
+ if (typeof agent === "string" && agent.length > 0) return agent;
64
+ }
65
+ }
66
+ }
67
+ if (typeof session.get === "function") {
68
+ const result = await session.get({ path: { id: sessionID } });
69
+ const data = result?.data ?? result;
70
+ const agent = data?.agent ?? data?.info?.agent;
71
+ if (typeof agent === "string" && agent.length > 0) return agent;
72
+ }
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ function pushPart(output, text) {
80
+ const part = { type: "text", text };
81
+ if (output && Array.isArray(output.parts)) {
82
+ output.parts.push(part);
83
+ return;
84
+ }
85
+ if (output && output.parts === undefined) {
86
+ output.parts = [part];
87
+ }
88
+ }
89
+
90
+ // command.execute.before intercept for /steer. Performs the write directly
91
+ // through the existing state service, enforces the shared Developer-only
92
+ // allowlist, then throws to abort before any model turn. The throw carries
93
+ // the confirmation (steering id plus resolved target); output.parts carries
94
+ // the same text as a minimal fallback if error styling is unacceptable.
95
+ export function createSteerCommandHook({ client, stateOptions = {}, resolveAgent } = {}) {
96
+ const resolve = resolveAgent ?? ((sessionID) => resolveSessionAgentViaClient(client, sessionID));
97
+ return async function steerCommandBefore(input, output) {
98
+ if (!input || input.command !== STEER_COMMAND_NAME) return;
99
+ let agent;
100
+ try {
101
+ agent = await resolve(input.sessionID);
102
+ } catch {
103
+ agent = undefined;
104
+ }
105
+ if (typeof agent !== "string" || !isSteerAllowedAgent(agent)) {
106
+ const denial = `Steering denied: agent ${JSON.stringify(agent ?? "unknown")} may not use /${STEER_COMMAND_NAME}. Developer context only.`;
107
+ pushPart(output, denial);
108
+ throw new Error(denial);
109
+ }
110
+ const parsed = parseSteerArguments(input.arguments);
111
+ const state = createStateService(stateOptions);
112
+ const payload =
113
+ parsed.planId === undefined ? { content: parsed.content } : { planId: parsed.planId, content: parsed.content };
114
+ const result = await state.submitSteering(payload);
115
+ if (!result.ok) {
116
+ const failure = `Steering failed [${result.error.code}]: ${result.error.message}`;
117
+ pushPart(output, failure);
118
+ throw new Error(failure);
119
+ }
120
+ const confirmation = `Steering recorded: ${result.entry.id} for target ${result.entry.planId}#${result.entry.sequence}`;
121
+ pushPart(output, confirmation);
122
+ throw new Error(confirmation);
123
+ };
124
+ }