opencode-herdr-orchestration 0.2.1 → 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/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
@@ -125,6 +133,199 @@ export function findConfigFile(configDir) {
125
133
  return join(configDir, "opencode.jsonc");
126
134
  }
127
135
 
136
+ // 15-M1 project config semantics (live-evidenced on opencode 1.18.29 via
137
+ // `opencode debug config` plus `opencode debug skill` in OS temp probes).
138
+ // Project filename: `opencode.json` in the project root per docs Per project
139
+ // ("Add `opencode.json` in your project root ... traverses up to the nearest
140
+ // Git directory"); live probes show both `opencode.json` and `opencode.jsonc`
141
+ // in cwd load as scope "local" (plugin_origins source project file, scope
142
+ // local) while empty cwd shows global-only (2 globals); when both exist both
143
+ // load as separate local layers, so helpers edit only the found file and
144
+ // preserve the other. Default for a new project file is `opencode.json` per
145
+ // docs; an existing `opencode.jsonc` is preferred when present for JSONC
146
+ // comment coherence with the global helper.
147
+ // Per-key merge: docs Locations ("merged together, not replaced ... later
148
+ // overrides earlier only for conflicting keys") plus Permissions Agents
149
+ // ("merged with the global config, and agent rules take precedence"); live
150
+ // per-key probe shows project `agent.shepherd.permission.bash` probe key
151
+ // merges with plugin keys (`herdr --help` preserved, `*` deny preserved) and
152
+ // a conflicting project `deny` overrides a plugin `allow`; a project custom
153
+ // agent preserves plugin agents (shepherd plus governor plus sheepdog plus
154
+ // grazer plus sheep plus shearers); top-level `permission` does not leak into
155
+ // agent blocks, so helpers target `["agent", name, "permission", ...]` only.
156
+ // Reload: config loads at startup; existing processes keep old config, so quit
157
+ // and restart intentionally, then verify the merged view with
158
+ // `opencode debug config` in the project; invalid project JSON fails that
159
+ // command with "not valid JSON(C)" and no automatic fallback, while
160
+ // `OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode debug config` shows the
161
+ // global-only fallback (live-evidenced OK pluginLen 2 vs FAIL). Helpers below
162
+ // reuse parse plus modify plus applyEdits and backup discipline, stay fail
163
+ // closed inside the project root, and never touch global config.
164
+ // Skill decision: `opencode debug skill` shows 4 skills with 3 file-based
165
+ // globals (`graphify` in ~/.claude/skills plus `herdr` plus `find-skills` in
166
+ // ~/.agents/skills) and zero project `.opencode/skills` files, so M1 stays
167
+ // prompt-embedded with no native SKILL.md; see src/prompts.js plus README.
168
+ export const PROJECT_CONFIG_FILE = "opencode.json";
169
+ export const PROJECT_CONFIG_FILENAMES = Object.freeze(["opencode.jsonc", "opencode.json"]);
170
+
171
+ export function findProjectConfigFile(projectRoot) {
172
+ if (typeof projectRoot !== "string" || projectRoot.length === 0) {
173
+ throw new Error("Project root must be a non-empty path.");
174
+ }
175
+ const root = resolve(projectRoot);
176
+ for (const name of PROJECT_CONFIG_FILENAMES) {
177
+ const candidate = join(root, name);
178
+ if (existsSync(candidate)) return candidate;
179
+ }
180
+ return join(root, PROJECT_CONFIG_FILE);
181
+ }
182
+
183
+ function isInsideDir(parent, child) {
184
+ const sep = join("a", "b").slice(1, -1) || (process.platform === "win32" ? "\\" : "/");
185
+ const resolvedParent = resolve(parent);
186
+ const resolvedChild = resolve(child);
187
+ if (process.platform === "win32") {
188
+ const lowerParent = resolvedParent.toLowerCase();
189
+ const lowerChild = resolvedChild.toLowerCase();
190
+ if (lowerChild === lowerParent) return true;
191
+ return lowerChild.startsWith(lowerParent + sep.toLowerCase());
192
+ }
193
+ if (resolvedChild === resolvedParent) return true;
194
+ return resolvedChild.startsWith(resolvedParent + sep);
195
+ }
196
+
197
+ function projectConfigConfinement(projectRoot, targetFile) {
198
+ if (typeof projectRoot !== "string" || projectRoot.length === 0) {
199
+ throw new Error("Project root must be a non-empty path.");
200
+ }
201
+ if (typeof targetFile !== "string" || targetFile.length === 0) {
202
+ throw new Error("Project config file must be a non-empty path.");
203
+ }
204
+ const root = resolve(projectRoot);
205
+ const target = resolve(targetFile);
206
+ const globalDir = resolve(configDirectory());
207
+ const globalFile = resolve(findConfigFile(globalDir));
208
+ if (target === globalFile) {
209
+ throw new Error(`Refusing global config file ${target}; project helpers never touch global config.`);
210
+ }
211
+ if (isInsideDir(globalDir, target)) {
212
+ throw new Error(`Refusing path inside global config directory ${globalDir}; project helpers stay inside the project root.`);
213
+ }
214
+ if (root === globalDir || isInsideDir(globalDir, root)) {
215
+ throw new Error(`Refusing global config directory as project root ${root}; use a project checkout.`);
216
+ }
217
+ if (target !== root && !isInsideDir(root, target)) {
218
+ throw new Error(`Refusing outside path ${target}; project helpers stay inside ${root}.`);
219
+ }
220
+ const base = target.split(/[\\/]/).pop();
221
+ if (!PROJECT_CONFIG_FILENAMES.includes(base)) {
222
+ throw new Error(`Refusing non-project filename ${base}; project helpers edit only opencode.jsonc or opencode.json.`);
223
+ }
224
+ const directParent = resolve(join(target, ".."));
225
+ if (directParent !== root) {
226
+ throw new Error(`Refusing nested path ${target}; project helpers edit only the project root file.`);
227
+ }
228
+ return { root, target };
229
+ }
230
+
231
+ export function resolveProjectConfigFile(projectRoot, explicitFile) {
232
+ if (explicitFile !== undefined) {
233
+ const candidate = resolve(projectRoot, explicitFile);
234
+ projectConfigConfinement(projectRoot, candidate);
235
+ return candidate;
236
+ }
237
+ const found = findProjectConfigFile(projectRoot);
238
+ projectConfigConfinement(projectRoot, found);
239
+ return found;
240
+ }
241
+
242
+ const PROJECT_AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
243
+ const PROJECT_PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
244
+
245
+ function assertProjectAgentName(agentName) {
246
+ if (typeof agentName !== "string" || !PROJECT_AGENT_NAME_PATTERN.test(agentName)) {
247
+ throw new Error(`Invalid agent name ${JSON.stringify(agentName)}; use 1-64 letters, digits, dot, underscore, or hyphen starting alphanumeric.`);
248
+ }
249
+ }
250
+
251
+ function assertProjectPermissionUpdates(permissionUpdates) {
252
+ if (!permissionUpdates || typeof permissionUpdates !== "object" || Array.isArray(permissionUpdates)) {
253
+ throw new Error("Permission updates must be a non-array object mapping tool to action or pattern map.");
254
+ }
255
+ for (const [tool, value] of Object.entries(permissionUpdates)) {
256
+ if (typeof tool !== "string" || tool.length === 0 || tool.includes("/") || tool.includes("\\")) {
257
+ throw new Error(`Invalid permission tool ${JSON.stringify(tool)}.`);
258
+ }
259
+ if (value === undefined) continue;
260
+ if (typeof value === "string") {
261
+ if (!PROJECT_PERMISSION_ACTIONS.has(value)) {
262
+ throw new Error(`Invalid action ${JSON.stringify(value)} for ${tool}; use allow, ask, deny, or undefined to delete.`);
263
+ }
264
+ continue;
265
+ }
266
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
267
+ throw new Error(`Invalid permission value for ${tool}; use allow, ask, deny, undefined, or a pattern map.`);
268
+ }
269
+ for (const [pattern, action] of Object.entries(value)) {
270
+ if (typeof pattern !== "string" || pattern.length === 0) {
271
+ throw new Error(`Invalid permission pattern ${JSON.stringify(pattern)} for ${tool}.`);
272
+ }
273
+ if (action !== undefined && !PROJECT_PERMISSION_ACTIONS.has(action)) {
274
+ throw new Error(`Invalid action ${JSON.stringify(action)} for ${tool} pattern ${JSON.stringify(pattern)}.`);
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ function parseProjectConfigText(text) {
281
+ const errors = [];
282
+ parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false });
283
+ if (errors.length) {
284
+ const first = errors[0];
285
+ throw new Error(`Invalid OpenCode JSONC at offset ${first.offset}: ${printParseErrorCode(first.error)}.`);
286
+ }
287
+ }
288
+
289
+ // Per-key merge for one agent permission block. String values set the tool
290
+ // shorthand; undefined deletes the tool; object values set per-pattern rules
291
+ // with undefined deleting that pattern. Unrelated keys, plugins, tuples, and
292
+ // sibling agents are preserved via modify plus applyEdits. Reapplying the
293
+ // same updates is idempotent (no edits when values already match).
294
+ export function updateProjectAgentPermissions(text, agentName, permissionUpdates) {
295
+ assertProjectAgentName(agentName);
296
+ assertProjectPermissionUpdates(permissionUpdates);
297
+ parseProjectConfigText(text);
298
+ const source = text || "{}";
299
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes("\r\n") ? "\r\n" : "\n" };
300
+ let updated = source;
301
+ for (const [tool, value] of Object.entries(permissionUpdates)) {
302
+ if (value === undefined) {
303
+ updated = applyEdits(updated, modify(updated, ["agent", agentName, "permission", tool], undefined, { formattingOptions }));
304
+ } else if (typeof value === "string") {
305
+ updated = applyEdits(updated, modify(updated, ["agent", agentName, "permission", tool], value, { formattingOptions }));
306
+ } else {
307
+ for (const [pattern, action] of Object.entries(value)) {
308
+ updated = applyEdits(updated, modify(updated, ["agent", agentName, "permission", tool, pattern], action, { formattingOptions }));
309
+ }
310
+ }
311
+ }
312
+ return updated;
313
+ }
314
+
315
+ export function writeProjectAgentPermissions(projectRoot, agentName, permissionUpdates, explicitFile) {
316
+ assertProjectAgentName(agentName);
317
+ assertProjectPermissionUpdates(permissionUpdates);
318
+ const file = resolveProjectConfigFile(projectRoot, explicitFile);
319
+ mkdirSync(resolve(projectRoot), { recursive: true });
320
+ const existed = existsSync(file);
321
+ const previous = existed ? readFileSync(file, "utf8") : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
322
+ const next = updateProjectAgentPermissions(previous, agentName, permissionUpdates);
323
+ if (next === previous) return { file, backup: null, changed: false, existed };
324
+ const backup = backupFile(file);
325
+ writeFileSync(file, next, "utf8");
326
+ return { file, backup, changed: true, existed };
327
+ }
328
+
128
329
  export function backupFile(file, now = new Date()) {
129
330
  if (!existsSync(file)) return null;
130
331
  const stamp = now.toISOString().replace(/[:.]/g, "-");
@@ -198,6 +399,64 @@ export function writePluginConfig(configDir, remove = false, models) {
198
399
  return { file, backup, changed: true, existed };
199
400
  }
200
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
+
201
460
  export function restoreBackup(file, backup, existed = true) {
202
461
  if (backup && existsSync(backup)) copyFileSync(backup, file);
203
462
  else if (!existed) rmSync(file, { force: true });
@@ -359,9 +618,16 @@ export function validateOpenCode(configDir) {
359
618
  export function status(configDir, packageRoot) {
360
619
  const file = findConfigFile(configDir);
361
620
  let configured = false;
621
+ let steerCommandConfigured = false;
362
622
  if (existsSync(file)) {
363
- 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 });
364
625
  configured = Array.isArray(config?.plugin) && config.plugin.some(isOrchestrationPlugin);
626
+ try {
627
+ steerCommandConfigured = isSteerCommandConfigured(text);
628
+ } catch {
629
+ steerCommandConfigured = false;
630
+ }
365
631
  }
366
632
  const installed = installedVersion(configDir);
367
633
  let detectedAgents = [];
@@ -393,6 +659,7 @@ export function status(configDir, packageRoot) {
393
659
  updateAvailable: Boolean(installed && latest && installed !== latest),
394
660
  configFile: file,
395
661
  pluginConfigured: configured,
662
+ steerCommandConfigured,
396
663
  detectedAgents,
397
664
  agentsReady: detectedAgents.length === AGENT_NAMES.length,
398
665
  obsoleteAgentFiles,