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/README.md +312 -5
- package/bin/orchestration.js +6 -1
- package/package.json +4 -3
- package/src/agents.js +275 -4
- package/src/diagnostics.js +117 -0
- package/src/index.js +312 -36
- package/src/installer.js +268 -1
- package/src/prompts.js +213 -6
- package/src/response.js +71 -2
- package/src/state.js +2252 -11
- package/src/steer.js +124 -0
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
|
|
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,
|