opencode-herdr-orchestration 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,45 @@ Capability-separated OpenCode agents for planning, implementation, independent r
4
4
 
5
5
  This package registers the agents, provides complete structured worker-response retrieval, injects session-specific orchestration mode into shell environments, and ships a reproducible Git `pre-push` policy for new and existing repositories.
6
6
 
7
- Requires Node.js 22.22.2 or newer when running the package CLI or tests.
7
+ Requires Node.js 20 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.2",
3
+ "version": "0.1.4",
4
4
  "description": "Capability-separated Herdr orchestration agents for OpenCode",
5
5
  "author": "CodingJinxx",
6
6
  "repository": {
@@ -31,13 +31,7 @@
31
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 bin/orchestration.js"
32
32
  },
33
33
  "engines": {
34
- "node": ">=22.22.2"
35
- },
36
- "peerDependencies": {
37
- "@opencode-ai/plugin": ">=1.3.0 <2"
38
- },
39
- "devDependencies": {
40
- "@opencode-ai/plugin": "1.3.14"
34
+ "node": ">=20"
41
35
  },
42
36
  "keywords": [
43
37
  "opencode",
@@ -45,5 +39,10 @@
45
39
  "orchestration",
46
40
  "agents"
47
41
  ],
48
- "license": "MIT"
42
+ "license": "MIT",
43
+ "dependencies": {
44
+ "@opencode-ai/plugin": "1.3.14",
45
+ "cross-spawn": "7.0.6",
46
+ "jsonc-parser": "3.3.1"
47
+ }
49
48
  }
@@ -0,0 +1,199 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import spawn from "cross-spawn";
6
+ import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
7
+
8
+ export const PACKAGE_NAME = "opencode-herdr-orchestration";
9
+ const NPM_COMMAND = "npm";
10
+ const OPENCODE_COMMAND = "opencode";
11
+ export const AGENT_NAMES = [
12
+ "shepherd-plan",
13
+ "shepherd-build",
14
+ "sheep-plan",
15
+ "sheep-build",
16
+ "shearer-review-low",
17
+ "shearer-review-medium",
18
+ ];
19
+
20
+ export function configDirectory(env = process.env) {
21
+ if (env.OPENCODE_CONFIG_DIR) return resolve(env.OPENCODE_CONFIG_DIR);
22
+ const home = env.HOME || env.USERPROFILE || homedir();
23
+ return join(home, ".config", "opencode");
24
+ }
25
+
26
+ export function packageEntry(configDir) {
27
+ return pathToFileURL(join(configDir, "node_modules", PACKAGE_NAME, "src", "plugin.js")).href;
28
+ }
29
+
30
+ export function isOrchestrationPlugin(entry) {
31
+ const spec = Array.isArray(entry) ? entry[0] : entry;
32
+ return (
33
+ typeof spec === "string" &&
34
+ (spec === PACKAGE_NAME || spec.startsWith(`${PACKAGE_NAME}@`) || spec.includes(`/node_modules/${PACKAGE_NAME}/`))
35
+ );
36
+ }
37
+
38
+ export function updatePluginConfig(text, entry, remove = false) {
39
+ const errors = [];
40
+ const config = parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false });
41
+ if (errors.length) {
42
+ const first = errors[0];
43
+ throw new Error(`Invalid OpenCode JSONC at offset ${first.offset}: ${printParseErrorCode(first.error)}.`);
44
+ }
45
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: text.includes("\r\n") ? "\r\n" : "\n" };
46
+ const plugins = Array.isArray(config?.plugin) ? config.plugin : [];
47
+ const matches = plugins.map((candidate, index) => isOrchestrationPlugin(candidate) ? index : -1).filter((index) => index >= 0);
48
+ const existing = matches.length ? plugins[matches[0]] : undefined;
49
+ const source = text || "{}";
50
+
51
+ if (matches.length === 1 && !remove) {
52
+ const replacement = Array.isArray(existing) ? [entry, existing[1] ?? {}] : entry;
53
+ return applyEdits(source, modify(source, ["plugin", matches[0]], replacement, { formattingOptions }));
54
+ }
55
+ if (matches.length && (remove || matches.length > 1)) {
56
+ const remaining = plugins.filter((candidate) => !isOrchestrationPlugin(candidate));
57
+ if (!remove) remaining.push(Array.isArray(existing) ? [entry, existing[1] ?? {}] : entry);
58
+ return applyEdits(source, modify(source, ["plugin"], remaining, { formattingOptions }));
59
+ }
60
+ if (!remove) {
61
+ const path = Array.isArray(config?.plugin) ? ["plugin", -1] : ["plugin"];
62
+ const value = Array.isArray(config?.plugin) ? entry : [entry];
63
+ return applyEdits(source, modify(source, path, value, { formattingOptions }));
64
+ }
65
+ return source;
66
+ }
67
+
68
+ export function findConfigFile(configDir) {
69
+ for (const name of ["opencode.jsonc", "opencode.json"]) {
70
+ const candidate = join(configDir, name);
71
+ if (existsSync(candidate)) return candidate;
72
+ }
73
+ return join(configDir, "opencode.jsonc");
74
+ }
75
+
76
+ export function backupFile(file, now = new Date()) {
77
+ if (!existsSync(file)) return null;
78
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
79
+ const backup = `${file}.backup-${stamp}`;
80
+ copyFileSync(file, backup);
81
+ return backup;
82
+ }
83
+
84
+ function run(command, args, options = {}) {
85
+ const result = spawn.sync(command, args, {
86
+ encoding: "utf8",
87
+ windowsHide: true,
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ ...options,
90
+ });
91
+ if (result.error) throw result.error;
92
+ if (result.status !== 0) {
93
+ const error = new Error(`${command} exited with status ${result.status}.`);
94
+ error.status = result.status;
95
+ error.stdout = result.stdout;
96
+ error.stderr = result.stderr;
97
+ throw error;
98
+ }
99
+ return result.stdout.trim();
100
+ }
101
+
102
+ export function installedVersion(configDir) {
103
+ const manifest = join(configDir, "node_modules", PACKAGE_NAME, "package.json");
104
+ if (!existsSync(manifest)) return null;
105
+ return JSON.parse(readFileSync(manifest, "utf8")).version;
106
+ }
107
+
108
+ export function packageVersion(packageRoot) {
109
+ return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
110
+ }
111
+
112
+ export function latestVersion() {
113
+ return JSON.parse(run(NPM_COMMAND, ["view", PACKAGE_NAME, "version", "--json", "--prefer-online"]));
114
+ }
115
+
116
+ export function installPackage(configDir, version) {
117
+ mkdirSync(configDir, { recursive: true });
118
+ const backups = ["package.json", "package-lock.json"]
119
+ .map((name) => backupFile(join(configDir, name)))
120
+ .filter(Boolean);
121
+ run(NPM_COMMAND, ["install", "--save-exact", `${PACKAGE_NAME}@${version}`], { cwd: configDir });
122
+ return backups;
123
+ }
124
+
125
+ export function uninstallPackage(configDir) {
126
+ const backups = ["package.json", "package-lock.json"]
127
+ .map((name) => backupFile(join(configDir, name)))
128
+ .filter(Boolean);
129
+ try {
130
+ run(NPM_COMMAND, ["uninstall", PACKAGE_NAME], { cwd: configDir });
131
+ } catch {}
132
+ return backups;
133
+ }
134
+
135
+ export function writePluginConfig(configDir, remove = false) {
136
+ mkdirSync(configDir, { recursive: true });
137
+ const file = findConfigFile(configDir);
138
+ const existed = existsSync(file);
139
+ const previous = existed ? readFileSync(file, "utf8") : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
140
+ const next = updatePluginConfig(previous, packageEntry(configDir), remove);
141
+ if (next === previous) return { file, backup: null, changed: false, existed };
142
+ const backup = backupFile(file);
143
+ writeFileSync(file, next, "utf8");
144
+ return { file, backup, changed: true, existed };
145
+ }
146
+
147
+ export function restoreBackup(file, backup, existed = true) {
148
+ if (backup && existsSync(backup)) copyFileSync(backup, file);
149
+ else if (!existed) rmSync(file, { force: true });
150
+ }
151
+
152
+ export function validateOpenCode(configDir) {
153
+ const output = run(OPENCODE_COMMAND, ["debug", "agent", "shepherd-build"], {
154
+ cwd: configDir,
155
+ env: { ...process.env, OPENCODE_DISABLE_PROJECT_CONFIG: "1" },
156
+ maxBuffer: 32 * 1024 * 1024,
157
+ });
158
+ const parsed = JSON.parse(output);
159
+ if (parsed.name !== "shepherd-build") throw new Error("OpenCode did not resolve shepherd-build after installation.");
160
+ return true;
161
+ }
162
+
163
+ export function status(configDir, packageRoot) {
164
+ const file = findConfigFile(configDir);
165
+ let configured = false;
166
+ if (existsSync(file)) {
167
+ const config = parse(readFileSync(file, "utf8"), [], { allowTrailingComma: true, disallowComments: false });
168
+ configured = Array.isArray(config?.plugin) && config.plugin.some(isOrchestrationPlugin);
169
+ }
170
+ const installed = installedVersion(configDir);
171
+ let detectedAgents = [];
172
+ try {
173
+ const output = run(OPENCODE_COMMAND, ["agent", "list"], {
174
+ cwd: configDir,
175
+ env: { ...process.env, OPENCODE_DISABLE_PROJECT_CONFIG: "1" },
176
+ maxBuffer: 32 * 1024 * 1024,
177
+ });
178
+ detectedAgents = AGENT_NAMES.filter((name) => output.includes(`${name} (primary)`));
179
+ } catch {}
180
+ let latest = null;
181
+ let latestVersionError = null;
182
+ try {
183
+ latest = latestVersion();
184
+ } catch (error) {
185
+ latestVersionError = String(error?.stderr ?? error?.message ?? error).replace(/\s+/g, " ").trim().slice(0, 500);
186
+ }
187
+ return {
188
+ package: PACKAGE_NAME,
189
+ cliVersion: packageVersion(packageRoot),
190
+ installedVersion: installed,
191
+ latestVersion: latest,
192
+ latestVersionError,
193
+ updateAvailable: Boolean(installed && latest && installed !== latest),
194
+ configFile: file,
195
+ pluginConfigured: configured,
196
+ detectedAgents,
197
+ agentsReady: detectedAgents.length === AGENT_NAMES.length,
198
+ };
199
+ }