opencode-herdr-orchestration 0.1.1 → 0.1.3

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
@@ -8,6 +8,41 @@ Requires Node.js 22.22.2 or newer when running the package CLI or tests.
8
8
 
9
9
  ## Installation
10
10
 
11
+ Install or update the plugin with the cross-platform npm CLI:
12
+
13
+ ```bash
14
+ npx -y opencode-herdr-orchestration@latest install
15
+ npx -y opencode-herdr-orchestration@latest update
16
+ ```
17
+
18
+ The installer:
19
+
20
+ - locates the global OpenCode config directory;
21
+ - installs an exact package version there;
22
+ - preserves existing JSONC comments, trailing commas, plugins, and tuple options;
23
+ - adds the stable file URL required by current OpenCode npm plugin loading;
24
+ - creates timestamped backups of changed config and npm manifest files;
25
+ - validates `shepherd-build` through a short-lived OpenCode debug process;
26
+ - never restarts or signals a running OpenCode process.
27
+
28
+ The shared Git push policy remains opt-in:
29
+
30
+ ```bash
31
+ npx -y opencode-herdr-orchestration@latest install --with-hooks
32
+ ```
33
+
34
+ Inspect or remove the installation:
35
+
36
+ ```bash
37
+ npx -y opencode-herdr-orchestration@latest status
38
+ npx -y opencode-herdr-orchestration@latest uninstall
39
+ npx -y opencode-herdr-orchestration@latest uninstall --with-hooks
40
+ ```
41
+
42
+ After installation or update, quit and restart OpenCode intentionally when ready. Existing processes keep their already-loaded configuration.
43
+
44
+ ## Manual Installation
45
+
11
46
  Add the published package to the global OpenCode configuration at `~/.config/opencode/opencode.json` or `~/.config/opencode/opencode.jsonc`:
12
47
 
13
48
  ```jsonc
@@ -19,6 +54,23 @@ Add the published package to the global OpenCode configuration at `~/.config/ope
19
54
 
20
55
  If `plugin` already contains entries, append `"opencode-herdr-orchestration"` instead of replacing them. OpenCode installs npm plugins automatically when it starts.
21
56
 
57
+ Install the package into that config directory so the stable file URL can resolve it:
58
+
59
+ ```bash
60
+ cd ~/.config/opencode
61
+ npm install --save-exact opencode-herdr-orchestration@latest
62
+ ```
63
+
64
+ Current OpenCode releases may record but not execute npm plugins referenced only by package name. If that occurs, use the installed entry path in the plugin tuple instead:
65
+
66
+ ```jsonc
67
+ {
68
+ "plugin": [
69
+ "file:///ABSOLUTE/PATH/TO/.config/opencode/node_modules/opencode-herdr-orchestration/src/plugin.js"
70
+ ]
71
+ }
72
+ ```
73
+
22
74
  Optionally install the shared Git push policy for all current and future repositories:
23
75
 
24
76
  ```bash
@@ -5,6 +5,18 @@ import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { execFileSync } from "node:child_process";
8
+ import {
9
+ configDirectory,
10
+ installPackage,
11
+ latestVersion,
12
+ PACKAGE_NAME,
13
+ packageVersion,
14
+ restoreBackup,
15
+ status as installationStatus,
16
+ uninstallPackage,
17
+ validateOpenCode,
18
+ writePluginConfig,
19
+ } from "../src/installer.js";
8
20
 
9
21
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
10
22
  const hooksPath = join(homedir(), ".config", "opencode-herdr-orchestration", "hooks");
@@ -24,7 +36,7 @@ function currentHooksPath() {
24
36
  }
25
37
  }
26
38
 
27
- function install() {
39
+ function installHooks() {
28
40
  const existing = currentHooksPath();
29
41
  const force = flags.includes("--force");
30
42
  if (existing && existing !== hooksPath && !force) {
@@ -37,7 +49,7 @@ function install() {
37
49
  process.stdout.write(`Installed pre-push hook and set global core.hooksPath=${hooksPath}\n`);
38
50
  }
39
51
 
40
- function uninstall() {
52
+ function uninstallHooks() {
41
53
  if (currentHooksPath() === hooksPath) {
42
54
  git(["config", "--global", "--unset", "core.hooksPath"]);
43
55
  rmSync(hooksPath, { recursive: true, force: true });
@@ -47,21 +59,57 @@ function uninstall() {
47
59
  }
48
60
  }
49
61
 
50
- function status() {
51
- process.stdout.write(`${JSON.stringify({
62
+ function installOrUpdate(useLatest) {
63
+ const configDir = configDirectory();
64
+ const version = useLatest ? latestVersion() : packageVersion(packageRoot);
65
+ const packageBackups = installPackage(configDir, version);
66
+ const result = writePluginConfig(configDir);
67
+ try {
68
+ validateOpenCode(configDir);
69
+ } catch (error) {
70
+ restoreBackup(result.file, result.backup, result.existed);
71
+ throw new Error(`OpenCode validation failed; restored the previous config. ${error.message}`);
72
+ }
73
+ if (flags.includes("--with-hooks")) installHooks();
74
+ process.stdout.write(`Configured ${PACKAGE_NAME}@${version} in ${result.file}\n`);
75
+ if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
76
+ for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
77
+ process.stdout.write("Restart OpenCode intentionally to load the new configuration.\n");
78
+ }
79
+
80
+ function uninstallOrchestration() {
81
+ const configDir = configDirectory();
82
+ const result = writePluginConfig(configDir, true);
83
+ const packageBackups = uninstallPackage(configDir);
84
+ if (flags.includes("--with-hooks")) uninstallHooks();
85
+ process.stdout.write(`Removed orchestration plugin configuration from ${result.file}\n`);
86
+ if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
87
+ for (const backup of packageBackups) process.stdout.write(`Backup: ${backup}\n`);
88
+ }
89
+
90
+ function fullStatus() {
91
+ const result = { ...installationStatus(configDirectory(), packageRoot), hooks: JSON.parse(captureHookStatus()) };
92
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
93
+ }
94
+
95
+ function captureHookStatus() {
96
+ return JSON.stringify({
52
97
  configuredHooksPath: currentHooksPath() || null,
53
98
  expectedHooksPath: hooksPath,
54
99
  hookInstalled: existsSync(installedHook),
55
100
  active: currentHooksPath() === hooksPath && existsSync(installedHook),
56
- }, null, 2)}\n`);
101
+ });
57
102
  }
58
103
 
59
104
  try {
60
- if (command === "install-hooks") install();
61
- else if (command === "uninstall-hooks") uninstall();
62
- else if (command === "status") status();
105
+ if (command === "install") installOrUpdate(false);
106
+ else if (command === "update") installOrUpdate(true);
107
+ else if (command === "uninstall") uninstallOrchestration();
108
+ else if (command === "install-hooks") installHooks();
109
+ else if (command === "uninstall-hooks") uninstallHooks();
110
+ else if (command === "status") fullStatus();
63
111
  else {
64
- process.stderr.write("Usage: opencode-herdr-orchestration <install-hooks [--force]|uninstall-hooks|status>\n");
112
+ process.stderr.write("Usage: opencode-herdr-orchestration <install|update|status|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
65
113
  process.exitCode = 2;
66
114
  }
67
115
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-herdr-orchestration",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Capability-separated Herdr orchestration agents for OpenCode",
5
5
  "author": "CodingJinxx",
6
6
  "repository": {
@@ -45,5 +45,8 @@
45
45
  "orchestration",
46
46
  "agents"
47
47
  ],
48
- "license": "MIT"
48
+ "license": "MIT",
49
+ "dependencies": {
50
+ "jsonc-parser": "3.3.1"
51
+ }
49
52
  }
package/src/agents.js CHANGED
@@ -52,6 +52,12 @@ const herdrInspection = {
52
52
  "herdr pane layout*": "allow",
53
53
  "herdr pane split*": "allow",
54
54
  "herdr pane read*": "allow",
55
+ "herdr worktree": "allow",
56
+ "herdr worktree list*": "allow",
57
+ "herdr worktree create *": "allow",
58
+ "herdr worktree open *": "allow",
59
+ "herdr worktree remove --workspace *": "allow",
60
+ "herdr worktree remove * --force*": "deny",
55
61
  };
56
62
 
57
63
  const markdownOnly = {
@@ -0,0 +1,207 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
7
+
8
+ export const PACKAGE_NAME = "opencode-herdr-orchestration";
9
+ const NPM_COMMAND = process.platform === "win32" ? process.execPath : "npm";
10
+ const NPM_PREFIX = process.platform === "win32" ? [resolveNpmCli()] : [];
11
+ const OPENCODE_COMMAND = process.platform === "win32" ? "opencode.exe" : "opencode";
12
+ export const AGENT_NAMES = [
13
+ "shepherd-plan",
14
+ "shepherd-build",
15
+ "sheep-plan",
16
+ "sheep-build",
17
+ "shearer-review-low",
18
+ "shearer-review-medium",
19
+ ];
20
+
21
+ function resolveWindowsCommand(name) {
22
+ try {
23
+ return execFileSync("where.exe", [name], { encoding: "utf8", windowsHide: true })
24
+ .split(/\r?\n/)
25
+ .find(Boolean)
26
+ .trim();
27
+ } catch {
28
+ return name;
29
+ }
30
+ }
31
+
32
+ function resolveNpmCli() {
33
+ const npmShim = resolveWindowsCommand("npm.cmd");
34
+ return join(dirname(npmShim), "node_modules", "npm", "bin", "npm-cli.js");
35
+ }
36
+
37
+ export function configDirectory(env = process.env) {
38
+ if (env.OPENCODE_CONFIG_DIR) return resolve(env.OPENCODE_CONFIG_DIR);
39
+ const home = env.HOME || env.USERPROFILE || homedir();
40
+ return join(home, ".config", "opencode");
41
+ }
42
+
43
+ export function packageEntry(configDir) {
44
+ return pathToFileURL(join(configDir, "node_modules", PACKAGE_NAME, "src", "plugin.js")).href;
45
+ }
46
+
47
+ export function isOrchestrationPlugin(entry) {
48
+ const spec = Array.isArray(entry) ? entry[0] : entry;
49
+ return (
50
+ typeof spec === "string" &&
51
+ (spec === PACKAGE_NAME || spec.startsWith(`${PACKAGE_NAME}@`) || spec.includes(`/node_modules/${PACKAGE_NAME}/`))
52
+ );
53
+ }
54
+
55
+ export function updatePluginConfig(text, entry, remove = false) {
56
+ const errors = [];
57
+ const config = parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false });
58
+ if (errors.length) {
59
+ const first = errors[0];
60
+ throw new Error(`Invalid OpenCode JSONC at offset ${first.offset}: ${printParseErrorCode(first.error)}.`);
61
+ }
62
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: text.includes("\r\n") ? "\r\n" : "\n" };
63
+ const plugins = Array.isArray(config?.plugin) ? config.plugin : [];
64
+ const matches = plugins.map((candidate, index) => isOrchestrationPlugin(candidate) ? index : -1).filter((index) => index >= 0);
65
+ const existing = matches.length ? plugins[matches[0]] : undefined;
66
+ const source = text || "{}";
67
+
68
+ if (matches.length === 1 && !remove) {
69
+ const replacement = Array.isArray(existing) ? [entry, existing[1] ?? {}] : entry;
70
+ return applyEdits(source, modify(source, ["plugin", matches[0]], replacement, { formattingOptions }));
71
+ }
72
+ if (matches.length && (remove || matches.length > 1)) {
73
+ const remaining = plugins.filter((candidate) => !isOrchestrationPlugin(candidate));
74
+ if (!remove) remaining.push(Array.isArray(existing) ? [entry, existing[1] ?? {}] : entry);
75
+ return applyEdits(source, modify(source, ["plugin"], remaining, { formattingOptions }));
76
+ }
77
+ if (!remove) {
78
+ const path = Array.isArray(config?.plugin) ? ["plugin", -1] : ["plugin"];
79
+ const value = Array.isArray(config?.plugin) ? entry : [entry];
80
+ return applyEdits(source, modify(source, path, value, { formattingOptions }));
81
+ }
82
+ return source;
83
+ }
84
+
85
+ export function findConfigFile(configDir) {
86
+ for (const name of ["opencode.jsonc", "opencode.json"]) {
87
+ const candidate = join(configDir, name);
88
+ if (existsSync(candidate)) return candidate;
89
+ }
90
+ return join(configDir, "opencode.jsonc");
91
+ }
92
+
93
+ export function backupFile(file, now = new Date()) {
94
+ if (!existsSync(file)) return null;
95
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
96
+ const backup = `${file}.backup-${stamp}`;
97
+ copyFileSync(file, backup);
98
+ return backup;
99
+ }
100
+
101
+ function run(command, args, options = {}) {
102
+ return execFileSync(command, args, {
103
+ encoding: "utf8",
104
+ windowsHide: true,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ ...options,
107
+ }).trim();
108
+ }
109
+
110
+ export function installedVersion(configDir) {
111
+ const manifest = join(configDir, "node_modules", PACKAGE_NAME, "package.json");
112
+ if (!existsSync(manifest)) return null;
113
+ return JSON.parse(readFileSync(manifest, "utf8")).version;
114
+ }
115
+
116
+ export function packageVersion(packageRoot) {
117
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
118
+ }
119
+
120
+ export function latestVersion() {
121
+ return JSON.parse(run(NPM_COMMAND, [...NPM_PREFIX, "view", PACKAGE_NAME, "version", "--json", "--prefer-online"]));
122
+ }
123
+
124
+ export function installPackage(configDir, version) {
125
+ mkdirSync(configDir, { recursive: true });
126
+ const backups = ["package.json", "package-lock.json"]
127
+ .map((name) => backupFile(join(configDir, name)))
128
+ .filter(Boolean);
129
+ run(NPM_COMMAND, [...NPM_PREFIX, "install", "--save-exact", `${PACKAGE_NAME}@${version}`], { cwd: configDir });
130
+ return backups;
131
+ }
132
+
133
+ export function uninstallPackage(configDir) {
134
+ const backups = ["package.json", "package-lock.json"]
135
+ .map((name) => backupFile(join(configDir, name)))
136
+ .filter(Boolean);
137
+ try {
138
+ run(NPM_COMMAND, [...NPM_PREFIX, "uninstall", PACKAGE_NAME], { cwd: configDir });
139
+ } catch {}
140
+ return backups;
141
+ }
142
+
143
+ export function writePluginConfig(configDir, remove = false) {
144
+ mkdirSync(configDir, { recursive: true });
145
+ const file = findConfigFile(configDir);
146
+ const existed = existsSync(file);
147
+ const previous = existed ? readFileSync(file, "utf8") : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
148
+ const next = updatePluginConfig(previous, packageEntry(configDir), remove);
149
+ if (next === previous) return { file, backup: null, changed: false, existed };
150
+ const backup = backupFile(file);
151
+ writeFileSync(file, next, "utf8");
152
+ return { file, backup, changed: true, existed };
153
+ }
154
+
155
+ export function restoreBackup(file, backup, existed = true) {
156
+ if (backup && existsSync(backup)) copyFileSync(backup, file);
157
+ else if (!existed) rmSync(file, { force: true });
158
+ }
159
+
160
+ export function validateOpenCode(configDir) {
161
+ const output = run(OPENCODE_COMMAND, ["debug", "agent", "shepherd-build"], {
162
+ cwd: configDir,
163
+ env: { ...process.env, OPENCODE_DISABLE_PROJECT_CONFIG: "1" },
164
+ maxBuffer: 32 * 1024 * 1024,
165
+ });
166
+ const parsed = JSON.parse(output);
167
+ if (parsed.name !== "shepherd-build") throw new Error("OpenCode did not resolve shepherd-build after installation.");
168
+ return true;
169
+ }
170
+
171
+ export function status(configDir, packageRoot) {
172
+ const file = findConfigFile(configDir);
173
+ let configured = false;
174
+ if (existsSync(file)) {
175
+ const config = parse(readFileSync(file, "utf8"), [], { allowTrailingComma: true, disallowComments: false });
176
+ configured = Array.isArray(config?.plugin) && config.plugin.some(isOrchestrationPlugin);
177
+ }
178
+ const installed = installedVersion(configDir);
179
+ let detectedAgents = [];
180
+ try {
181
+ const output = run(OPENCODE_COMMAND, ["agent", "list"], {
182
+ cwd: configDir,
183
+ env: { ...process.env, OPENCODE_DISABLE_PROJECT_CONFIG: "1" },
184
+ maxBuffer: 32 * 1024 * 1024,
185
+ });
186
+ detectedAgents = AGENT_NAMES.filter((name) => output.includes(`${name} (primary)`));
187
+ } catch {}
188
+ let latest = null;
189
+ let latestVersionError = null;
190
+ try {
191
+ latest = latestVersion();
192
+ } catch (error) {
193
+ latestVersionError = String(error?.stderr ?? error?.message ?? error).replace(/\s+/g, " ").trim().slice(0, 500);
194
+ }
195
+ return {
196
+ package: PACKAGE_NAME,
197
+ cliVersion: packageVersion(packageRoot),
198
+ installedVersion: installed,
199
+ latestVersion: latest,
200
+ latestVersionError,
201
+ updateAvailable: Boolean(installed && latest && installed !== latest),
202
+ configFile: file,
203
+ pluginConfigured: configured,
204
+ detectedAgents,
205
+ agentsReady: detectedAgents.length === AGENT_NAMES.length,
206
+ };
207
+ }
package/src/prompts.js CHANGED
@@ -13,6 +13,8 @@ Never pass another agent, model, --auto, or extra OpenCode argument. Use send-ke
13
13
 
14
14
  Worker names must be unique and satisfy Herdr's naming rules. Assign bounded work with herdr agent prompt <name> "..." --wait --timeout <milliseconds>. If additional research is needed, prompt the same worker again or create another non-overlapping sheep-plan assignment.
15
15
 
16
+ When isolated planning needs a worktree, use Herdr's installed worktree commands. A worktree created from another worktree is a peer that shares the same Git common repository, not its child. Inspect existing worktrees first, use an explicit non-protected branch and base commit, and place the checkout outside the current worktree rather than nesting it. Record the worker, branch, path, and base commit. Remove only worktrees you created, only after their planning artifacts are preserved and the worktree is clean; never force removal.
17
+
16
18
  After a worker settles, use herdr_agent_response as the authoritative result channel. Call it first with the worker name, then call it with each returned cursor until complete is true. Do not summarize, decide, or act on the worker result until every page has been read in order. Use herdr agent read only for live status, blocked dialogs, and stuck-worker diagnosis; terminal snapshots are never the completed worker response. If retrieval says the worker is not settled, wait and retry. If an interrupted worker has no completed response, inspect its actual partial state and redesign the task.
17
19
 
18
20
  If a worker is blocked, inspect it with herdr agent get and herdr agent read; do not answer approvals or questions without applying the user's safety constraints. Treat unknown as inconclusive, not complete. Synthesize worker findings instead of forwarding raw reports. Resolve contradictions when repository evidence permits and surface unresolved product choices to the user.
@@ -45,6 +47,8 @@ Delegate using structured contracts containing, where relevant: task_id, plan_id
45
47
 
46
48
  Parallelize only tasks that will not conflict. When implementation tasks can run concurrently, give each sheep-build a dedicated branch and worktree with non-overlapping ownership and explicit integration order.
47
49
 
50
+ Use Herdr's installed worktree commands for worker isolation. Worktrees created while you are already in a worktree are peers sharing the same Git common repository. Inspect the existing worktree list before creation, create each worker branch from the approved base commit, and never nest a worker checkout inside the current worktree. Record worker name, branch, path, base commit, and owned scope before delegation. Never reuse a branch checked out elsewhere. Remove only worktrees you created, only after their commits are integrated or otherwise preserved and the worktree is clean; never force removal.
51
+
48
52
  Spawn only these configured workers with no extra OpenCode arguments:
49
53
 
50
54
  herdr agent start <name> --kind opencode --pane <pane-id> -- --agent sheep-plan