opencode-herdr-orchestration 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
 
@@ -22,6 +22,7 @@ import {
22
22
  validateOpenCode,
23
23
  writeAgentFilesManifest,
24
24
  writePluginConfig,
25
+ writeSteerCommand,
25
26
  } from "../src/installer.js";
26
27
 
27
28
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -127,12 +128,14 @@ async function installOrUpdate(useLatest) {
127
128
  restoreBackup(result.file, result.backup, result.existed);
128
129
  throw new Error(`OpenCode validation failed; restored the previous config. ${error.message}`);
129
130
  }
131
+ const steerResult = writeSteerCommand(configDir, false);
130
132
  const reconciliation = reconcileAgentFiles(configDir, { remove: false });
131
133
  writeAgentFilesManifest(configDir, reconciliation.manifestEntries, version);
132
134
  if (flags.includes("--with-hooks")) installHooks();
133
135
  process.stdout.write(`Configured ${PACKAGE_NAME}@${version} in ${result.file}\n`);
134
136
  reportReconciliation(reconciliation);
135
137
  if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
138
+ if (steerResult.backup && steerResult.backup !== result.backup) process.stdout.write(`Backup: ${steerResult.backup}\n`);
136
139
  for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
137
140
  process.stdout.write("Restart OpenCode intentionally to load the new configuration.\n");
138
141
  }
@@ -157,6 +160,7 @@ async function configureAgents() {
157
160
 
158
161
  function uninstallOrchestration() {
159
162
  const configDir = configDirectory();
163
+ const steerResult = writeSteerCommand(configDir, true);
160
164
  const result = writePluginConfig(configDir, true);
161
165
  const packageBackups = uninstallPackage(configDir);
162
166
  const reconciliation = reconcileAgentFiles(configDir, { remove: true });
@@ -167,7 +171,8 @@ function uninstallOrchestration() {
167
171
  process.stdout.write(`Removed package-owned agent file: ${relPath}\n`);
168
172
  }
169
173
  reportReconciliation(reconciliation);
170
- if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
174
+ if (steerResult.backup) process.stdout.write(`Backup: ${steerResult.backup}\n`);
175
+ if (result.backup && result.backup !== steerResult.backup) process.stdout.write(`Backup: ${result.backup}\n`);
171
176
  for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
172
177
  }
173
178
 
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.1",
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 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/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
+ }