opencode-herdr-orchestration 0.2.1 → 0.3.0
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/package.json +4 -3
- package/src/agents.js +254 -4
- package/src/diagnostics.js +117 -0
- package/src/index.js +264 -6
- package/src/installer.js +193 -0
- package/src/prompts.js +213 -6
- package/src/response.js +71 -2
- package/src/state.js +2252 -11
package/src/index.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createAgents,
|
|
5
|
+
DEVELOPER_AGENT,
|
|
6
|
+
mergeAgent,
|
|
7
|
+
OWNERSHIP_TOOL_ACCESS,
|
|
8
|
+
OWNERSHIP_TOOLS,
|
|
9
|
+
RAW_STEERING_TOOL_ACCESS,
|
|
10
|
+
RAW_STEERING_TOOLS,
|
|
11
|
+
SHEPHERD_PHASES,
|
|
12
|
+
STATE_TOOL_ACCESS,
|
|
13
|
+
STATE_TOOLS,
|
|
14
|
+
STEERING_TOOL_ACCESS,
|
|
15
|
+
STEERING_TOOLS,
|
|
16
|
+
} from "./agents.js";
|
|
4
17
|
import { createResponseTool } from "./response.js";
|
|
5
18
|
import { createStateService } from "./state.js";
|
|
6
19
|
|
|
7
20
|
const SESSION_MODES = new Map();
|
|
8
21
|
|
|
9
22
|
function modeForAgent(agent) {
|
|
23
|
+
if (agent === DEVELOPER_AGENT) return "developer";
|
|
10
24
|
if (agent === "shepherd") return "shepherd";
|
|
11
25
|
if (agent === "shepherd-governor") return "governor";
|
|
12
26
|
if (agent === "sheepdog") return "sheepdog";
|
|
@@ -24,10 +38,14 @@ function stateError(code, message, retryable = false) {
|
|
|
24
38
|
// tools. Enforcement mirrors STATE_TOOL_ACCESS in src/agents.js: the planning
|
|
25
39
|
// shepherd writes and reads plan artifacts; shepherd-governor and sheepdog
|
|
26
40
|
// read the authoritative plan; sheepdog writes and reads execution artifacts.
|
|
27
|
-
// Artifacts are durable Markdown files under the
|
|
28
|
-
// common
|
|
29
|
-
//
|
|
30
|
-
//
|
|
41
|
+
// Artifacts are durable Markdown files under the canonical
|
|
42
|
+
// `<git-common-dir>/flocky` state root in the repository's shared Git common
|
|
43
|
+
// directory, so linked worktrees share state while separate clones never do.
|
|
44
|
+
// Legacy `<git-common-dir>/herdr` artifacts are reconciled into the canonical
|
|
45
|
+
// root before every plan or execution operation — copied, accepted, or failed
|
|
46
|
+
// closed on conflict — and the legacy root is never auto-deleted. The state
|
|
47
|
+
// service invokes only read-only `git rev-parse`; it never writes arbitrary
|
|
48
|
+
// Git metadata.
|
|
31
49
|
export function createStateTools(stateOptions = {}) {
|
|
32
50
|
const state = createStateService(stateOptions);
|
|
33
51
|
|
|
@@ -103,10 +121,236 @@ export function createStateTools(stateOptions = {}) {
|
|
|
103
121
|
return tools;
|
|
104
122
|
}
|
|
105
123
|
|
|
124
|
+
// Developer steering submission (M2, Option A, trusted Developer only).
|
|
125
|
+
// The sole submitter is the explicit non-flock `developer` context. The
|
|
126
|
+
// allowlist holds only Developer; every flock role plus unknown, ambiguous,
|
|
127
|
+
// none, and unset are denied fail-closed with no filesystem write. Developer
|
|
128
|
+
// is never inferred from session mode, directory, environment text, or prompt
|
|
129
|
+
// content: only an exact `context.agent === "developer"` passes. The runtime
|
|
130
|
+
// check stays authoritative over static per-agent permission overrides.
|
|
131
|
+
export function createSteeringTools(stateOptions = {}) {
|
|
132
|
+
const state = createStateService(stateOptions);
|
|
133
|
+
const name = STEERING_TOOLS.submit;
|
|
134
|
+
return {
|
|
135
|
+
[name]: tool({
|
|
136
|
+
description:
|
|
137
|
+
"Submit bounded Developer steering for one Plan ID target. Developer context only; flock roles are denied. Provide explicit planId, or omit it only when exactly one active steering target exists.",
|
|
138
|
+
args: {
|
|
139
|
+
planId: tool.schema
|
|
140
|
+
.string()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("Explicit steering target Plan ID; required unless exactly one active target exists."),
|
|
143
|
+
content: tool.schema
|
|
144
|
+
.string()
|
|
145
|
+
.min(1)
|
|
146
|
+
.describe("Bounded steering content; at most 8192 UTF-8 bytes."),
|
|
147
|
+
},
|
|
148
|
+
async execute(args, context) {
|
|
149
|
+
const allowed = STEERING_TOOL_ACCESS.get(name);
|
|
150
|
+
if (!allowed?.has(context?.agent)) {
|
|
151
|
+
return JSON.stringify(
|
|
152
|
+
stateError(
|
|
153
|
+
"UNAUTHORIZED_AGENT",
|
|
154
|
+
`Agent ${context?.agent ?? "unknown"} may not use ${name}.`,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const result = await state.submitSteering(args);
|
|
159
|
+
context.metadata({
|
|
160
|
+
title: result.ok ? `${name}: ${result.entry.planId}#${result.entry.sequence}` : `${name}: ${result.error.code}`,
|
|
161
|
+
metadata: result.ok
|
|
162
|
+
? { planId: result.entry.planId, steeringId: result.entry.id, sequence: result.entry.sequence }
|
|
163
|
+
: { error: result.error.code },
|
|
164
|
+
});
|
|
165
|
+
return JSON.stringify(result);
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Shepherd-only raw steering plus ownership lifecycle tools (M3).
|
|
172
|
+
// Only `shepherd` and `shepherd-governor` pass the runtime allowlist;
|
|
173
|
+
// sheepdog, grazer, sheep, shearers, developer, unknown, and unset are
|
|
174
|
+
// denied fail-closed with no filesystem write. State-level session plus
|
|
175
|
+
// generation fencing stays authoritative inside src/state.js (NOT
|
|
176
|
+
// AUTHORITATIVE PHASE for non-owners). Steering never authorizes push,
|
|
177
|
+
// tag, publish, deploy, merge, or any consequential action; existing
|
|
178
|
+
// approvals still required.
|
|
179
|
+
export function createRawSteeringTools(stateOptions = {}) {
|
|
180
|
+
const state = createStateService(stateOptions);
|
|
181
|
+
const definitions = [
|
|
182
|
+
{
|
|
183
|
+
name: RAW_STEERING_TOOLS.check,
|
|
184
|
+
description:
|
|
185
|
+
"Check unread Developer steering for one Plan ID target without loading bodies. Shepherd phases only with authoritative phase, session, and generation; non-owners receive NOT AUTHORITATIVE PHASE.",
|
|
186
|
+
run: (args) => state.checkSteering(args),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: RAW_STEERING_TOOLS.read,
|
|
190
|
+
description:
|
|
191
|
+
"Read ordered exact unread Developer steering with no mutation. Shepherd phases only with authoritative phase, session, and generation.",
|
|
192
|
+
run: (args) => state.readSteering(args),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: RAW_STEERING_TOOLS.consume,
|
|
196
|
+
description:
|
|
197
|
+
"Consume Developer steering only after the authoritative owner recorded sync disposition for the sync point; idempotent. Shepherd phases only. Steering never authorizes consequential actions.",
|
|
198
|
+
run: (args) => state.consumeSteering(args),
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
const tools = {};
|
|
202
|
+
for (const definition of definitions) {
|
|
203
|
+
tools[definition.name] = tool({
|
|
204
|
+
description: definition.description,
|
|
205
|
+
args: {
|
|
206
|
+
planId: tool.schema.string().describe("Explicit steering target Plan ID."),
|
|
207
|
+
phase: tool.schema.string().describe("Owner phase: planning or governance."),
|
|
208
|
+
session: tool.schema.string().describe("Authoritative session fencing the ownership record."),
|
|
209
|
+
generation: tool.schema.number().int().min(1).describe("Authoritative generation fencing the ownership record."),
|
|
210
|
+
...(definition.name === RAW_STEERING_TOOLS.consume
|
|
211
|
+
? {
|
|
212
|
+
ids: tool.schema.array(tool.schema.string()).min(1).max(1000).describe("Steering ids to consume idempotently."),
|
|
213
|
+
syncPoint: tool.schema.string().describe("Closed sync point whose disposition was recorded before consume."),
|
|
214
|
+
disposition: tool.schema.string().describe("Closed disposition recorded before consume."),
|
|
215
|
+
}
|
|
216
|
+
: {}),
|
|
217
|
+
},
|
|
218
|
+
async execute(args, context) {
|
|
219
|
+
const allowed = RAW_STEERING_TOOL_ACCESS.get(definition.name);
|
|
220
|
+
if (!allowed?.has(context?.agent)) {
|
|
221
|
+
return JSON.stringify(
|
|
222
|
+
stateError(
|
|
223
|
+
"UNAUTHORIZED_AGENT",
|
|
224
|
+
`Agent ${context?.agent ?? "unknown"} may not use ${definition.name}.`,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const result = await definition.run(args);
|
|
229
|
+
context.metadata({
|
|
230
|
+
title: result.ok ? `${definition.name}: ${args.planId}` : `${definition.name}: ${result.error.code}`,
|
|
231
|
+
metadata: result.ok ? { planId: args.planId } : { error: result.error.code },
|
|
232
|
+
});
|
|
233
|
+
return JSON.stringify(result);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return tools;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function createOwnershipTools(stateOptions = {}) {
|
|
241
|
+
const state = createStateService(stateOptions);
|
|
242
|
+
const definitions = [
|
|
243
|
+
{
|
|
244
|
+
name: OWNERSHIP_TOOLS.claim,
|
|
245
|
+
description:
|
|
246
|
+
"Claim or hand off validated target lifecycle ownership for one Plan ID with session plus generation fencing. Shepherd phases only; generations must increase and both phases cannot race on the same generation.",
|
|
247
|
+
run: (args) => state.claimOwnership(args),
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: OWNERSHIP_TOOLS.read,
|
|
251
|
+
description: "Read the validated lifecycle record for one Plan ID. Shepherd phases only; non-owners receive NOT AUTHORITATIVE PHASE.",
|
|
252
|
+
run: (args) => state.readOwnership(args),
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: OWNERSHIP_TOOLS.sync,
|
|
256
|
+
description:
|
|
257
|
+
"Record semantic synchronization disposition for one closed sync point (planning-start, pre-plan, pre-assignment, milestone-executing, result-received, continue, finalize, consequential-preparation). Shepherd phases only; disposition is recorded before consume and consume is idempotent.",
|
|
258
|
+
run: (args) => state.recordSync(args),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: OWNERSHIP_TOOLS.snapshot,
|
|
262
|
+
description:
|
|
263
|
+
"Record a bounded lifecycle snapshot for one stage (planning, executing, result-evaluation, consequential-preparation). Shepherd phases only; pending consequential action must be recorded before the consequential-preparation snapshot and check.",
|
|
264
|
+
run: (args) => state.recordSnapshot(args),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: OWNERSHIP_TOOLS.correct,
|
|
268
|
+
description:
|
|
269
|
+
"Route semantic correction to sheepdog as normal corrective instructions, never raw records. Shepherd phases only; steering never authorizes consequential actions.",
|
|
270
|
+
run: (args) => state.routeCorrection(args),
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
const tools = {};
|
|
274
|
+
for (const definition of definitions) {
|
|
275
|
+
const isClaim = definition.name === OWNERSHIP_TOOLS.claim;
|
|
276
|
+
const isSync = definition.name === OWNERSHIP_TOOLS.sync;
|
|
277
|
+
const isSnapshot = definition.name === OWNERSHIP_TOOLS.snapshot;
|
|
278
|
+
const isCorrect = definition.name === OWNERSHIP_TOOLS.correct;
|
|
279
|
+
tools[definition.name] = tool({
|
|
280
|
+
description: definition.description,
|
|
281
|
+
args: {
|
|
282
|
+
planId: tool.schema.string().describe("Lifecycle target Plan ID."),
|
|
283
|
+
phase: tool.schema.string().describe("Owner phase: planning or governance."),
|
|
284
|
+
session: tool.schema.string().describe("Authoritative session fencing the ownership record."),
|
|
285
|
+
...(isClaim || isSync || isSnapshot || isCorrect
|
|
286
|
+
? { generation: tool.schema.number().int().min(1).describe("Authoritative generation fencing the ownership record.") }
|
|
287
|
+
: {}),
|
|
288
|
+
...(isClaim
|
|
289
|
+
? {
|
|
290
|
+
milestone: tool.schema.string().min(1).describe("Bounded milestone summary."),
|
|
291
|
+
lifecycleState: tool.schema.string().describe("Closed lifecycle state."),
|
|
292
|
+
currentObjective: tool.schema.string().optional().describe("Bounded current objective semantic summary."),
|
|
293
|
+
currentAction: tool.schema.string().optional().describe("Bounded current action semantic summary."),
|
|
294
|
+
activeSheepdogTarget: tool.schema.string().optional().describe("Active sheepdog target or empty when yielded."),
|
|
295
|
+
relevantRevision: tool.schema.string().optional().describe("Relevant revision summary."),
|
|
296
|
+
pendingConsequentialAction: tool.schema.string().optional().describe("Pending consequential action summary."),
|
|
297
|
+
}
|
|
298
|
+
: {}),
|
|
299
|
+
...(isSync
|
|
300
|
+
? {
|
|
301
|
+
syncPoint: tool.schema.string().describe("Closed sync point."),
|
|
302
|
+
disposition: tool.schema.string().describe("Closed disposition recorded before consume."),
|
|
303
|
+
note: tool.schema.string().optional().describe("Bounded semantic note."),
|
|
304
|
+
}
|
|
305
|
+
: {}),
|
|
306
|
+
...(isSnapshot
|
|
307
|
+
? {
|
|
308
|
+
stage: tool.schema.string().describe("Snapshot stage."),
|
|
309
|
+
milestone: tool.schema.string().optional().describe("Bounded milestone override."),
|
|
310
|
+
lifecycleState: tool.schema.string().optional().describe("Closed lifecycle state override."),
|
|
311
|
+
currentObjective: tool.schema.string().optional().describe("Bounded current objective override."),
|
|
312
|
+
currentAction: tool.schema.string().optional().describe("Bounded current action override."),
|
|
313
|
+
activeSheepdogTarget: tool.schema.string().optional().describe("Active sheepdog target override."),
|
|
314
|
+
relevantRevision: tool.schema.string().optional().describe("Relevant revision override."),
|
|
315
|
+
pendingConsequentialAction: tool.schema.string().optional().describe("Pending consequential action override."),
|
|
316
|
+
}
|
|
317
|
+
: {}),
|
|
318
|
+
...(isCorrect
|
|
319
|
+
? {
|
|
320
|
+
correction: tool.schema.string().min(1).describe("Normal semantic corrective instructions for sheepdog, never raw records."),
|
|
321
|
+
syncPoint: tool.schema.string().optional().describe("Related closed sync point."),
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
},
|
|
325
|
+
async execute(args, context) {
|
|
326
|
+
const allowed = OWNERSHIP_TOOL_ACCESS.get(definition.name);
|
|
327
|
+
if (!allowed?.has(context?.agent)) {
|
|
328
|
+
return JSON.stringify(
|
|
329
|
+
stateError(
|
|
330
|
+
"UNAUTHORIZED_AGENT",
|
|
331
|
+
`Agent ${context?.agent ?? "unknown"} may not use ${definition.name}.`,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const result = await definition.run(args);
|
|
336
|
+
context.metadata({
|
|
337
|
+
title: result.ok ? `${definition.name}: ${args.planId}` : `${definition.name}: ${result.error.code}`,
|
|
338
|
+
metadata: result.ok ? { planId: args.planId } : { error: result.error.code },
|
|
339
|
+
});
|
|
340
|
+
return JSON.stringify(result);
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return tools;
|
|
345
|
+
}
|
|
346
|
+
|
|
106
347
|
export const HerdrOrchestrationPlugin = async (_input, options = {}) => ({
|
|
107
348
|
tool: {
|
|
108
349
|
herdr_agent_response: createResponseTool(options.response),
|
|
109
350
|
...createStateTools(options.state),
|
|
351
|
+
...createSteeringTools(options.state),
|
|
352
|
+
...createRawSteeringTools(options.state),
|
|
353
|
+
...createOwnershipTools(options.state),
|
|
110
354
|
},
|
|
111
355
|
config(config) {
|
|
112
356
|
config.agent ??= {};
|
|
@@ -135,4 +379,18 @@ export const HerdrOrchestrationPlugin = async (_input, options = {}) => ({
|
|
|
135
379
|
});
|
|
136
380
|
|
|
137
381
|
export default HerdrOrchestrationPlugin;
|
|
138
|
-
export {
|
|
382
|
+
export {
|
|
383
|
+
createAgents,
|
|
384
|
+
DEVELOPER_AGENT,
|
|
385
|
+
mergeAgent,
|
|
386
|
+
modeForAgent,
|
|
387
|
+
OWNERSHIP_TOOL_ACCESS,
|
|
388
|
+
OWNERSHIP_TOOLS,
|
|
389
|
+
RAW_STEERING_TOOL_ACCESS,
|
|
390
|
+
RAW_STEERING_TOOLS,
|
|
391
|
+
SHEPHERD_PHASES,
|
|
392
|
+
STATE_TOOL_ACCESS,
|
|
393
|
+
STATE_TOOLS,
|
|
394
|
+
STEERING_TOOL_ACCESS,
|
|
395
|
+
STEERING_TOOLS,
|
|
396
|
+
};
|
package/src/installer.js
CHANGED
|
@@ -125,6 +125,199 @@ export function findConfigFile(configDir) {
|
|
|
125
125
|
return join(configDir, "opencode.jsonc");
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// 15-M1 project config semantics (live-evidenced on opencode 1.18.29 via
|
|
129
|
+
// `opencode debug config` plus `opencode debug skill` in OS temp probes).
|
|
130
|
+
// Project filename: `opencode.json` in the project root per docs Per project
|
|
131
|
+
// ("Add `opencode.json` in your project root ... traverses up to the nearest
|
|
132
|
+
// Git directory"); live probes show both `opencode.json` and `opencode.jsonc`
|
|
133
|
+
// in cwd load as scope "local" (plugin_origins source project file, scope
|
|
134
|
+
// local) while empty cwd shows global-only (2 globals); when both exist both
|
|
135
|
+
// load as separate local layers, so helpers edit only the found file and
|
|
136
|
+
// preserve the other. Default for a new project file is `opencode.json` per
|
|
137
|
+
// docs; an existing `opencode.jsonc` is preferred when present for JSONC
|
|
138
|
+
// comment coherence with the global helper.
|
|
139
|
+
// Per-key merge: docs Locations ("merged together, not replaced ... later
|
|
140
|
+
// overrides earlier only for conflicting keys") plus Permissions Agents
|
|
141
|
+
// ("merged with the global config, and agent rules take precedence"); live
|
|
142
|
+
// per-key probe shows project `agent.shepherd.permission.bash` probe key
|
|
143
|
+
// merges with plugin keys (`herdr --help` preserved, `*` deny preserved) and
|
|
144
|
+
// a conflicting project `deny` overrides a plugin `allow`; a project custom
|
|
145
|
+
// agent preserves plugin agents (shepherd plus governor plus sheepdog plus
|
|
146
|
+
// grazer plus sheep plus shearers); top-level `permission` does not leak into
|
|
147
|
+
// agent blocks, so helpers target `["agent", name, "permission", ...]` only.
|
|
148
|
+
// Reload: config loads at startup; existing processes keep old config, so quit
|
|
149
|
+
// and restart intentionally, then verify the merged view with
|
|
150
|
+
// `opencode debug config` in the project; invalid project JSON fails that
|
|
151
|
+
// command with "not valid JSON(C)" and no automatic fallback, while
|
|
152
|
+
// `OPENCODE_DISABLE_PROJECT_CONFIG=1 opencode debug config` shows the
|
|
153
|
+
// global-only fallback (live-evidenced OK pluginLen 2 vs FAIL). Helpers below
|
|
154
|
+
// reuse parse plus modify plus applyEdits and backup discipline, stay fail
|
|
155
|
+
// closed inside the project root, and never touch global config.
|
|
156
|
+
// Skill decision: `opencode debug skill` shows 4 skills with 3 file-based
|
|
157
|
+
// globals (`graphify` in ~/.claude/skills plus `herdr` plus `find-skills` in
|
|
158
|
+
// ~/.agents/skills) and zero project `.opencode/skills` files, so M1 stays
|
|
159
|
+
// prompt-embedded with no native SKILL.md; see src/prompts.js plus README.
|
|
160
|
+
export const PROJECT_CONFIG_FILE = "opencode.json";
|
|
161
|
+
export const PROJECT_CONFIG_FILENAMES = Object.freeze(["opencode.jsonc", "opencode.json"]);
|
|
162
|
+
|
|
163
|
+
export function findProjectConfigFile(projectRoot) {
|
|
164
|
+
if (typeof projectRoot !== "string" || projectRoot.length === 0) {
|
|
165
|
+
throw new Error("Project root must be a non-empty path.");
|
|
166
|
+
}
|
|
167
|
+
const root = resolve(projectRoot);
|
|
168
|
+
for (const name of PROJECT_CONFIG_FILENAMES) {
|
|
169
|
+
const candidate = join(root, name);
|
|
170
|
+
if (existsSync(candidate)) return candidate;
|
|
171
|
+
}
|
|
172
|
+
return join(root, PROJECT_CONFIG_FILE);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isInsideDir(parent, child) {
|
|
176
|
+
const sep = join("a", "b").slice(1, -1) || (process.platform === "win32" ? "\\" : "/");
|
|
177
|
+
const resolvedParent = resolve(parent);
|
|
178
|
+
const resolvedChild = resolve(child);
|
|
179
|
+
if (process.platform === "win32") {
|
|
180
|
+
const lowerParent = resolvedParent.toLowerCase();
|
|
181
|
+
const lowerChild = resolvedChild.toLowerCase();
|
|
182
|
+
if (lowerChild === lowerParent) return true;
|
|
183
|
+
return lowerChild.startsWith(lowerParent + sep.toLowerCase());
|
|
184
|
+
}
|
|
185
|
+
if (resolvedChild === resolvedParent) return true;
|
|
186
|
+
return resolvedChild.startsWith(resolvedParent + sep);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function projectConfigConfinement(projectRoot, targetFile) {
|
|
190
|
+
if (typeof projectRoot !== "string" || projectRoot.length === 0) {
|
|
191
|
+
throw new Error("Project root must be a non-empty path.");
|
|
192
|
+
}
|
|
193
|
+
if (typeof targetFile !== "string" || targetFile.length === 0) {
|
|
194
|
+
throw new Error("Project config file must be a non-empty path.");
|
|
195
|
+
}
|
|
196
|
+
const root = resolve(projectRoot);
|
|
197
|
+
const target = resolve(targetFile);
|
|
198
|
+
const globalDir = resolve(configDirectory());
|
|
199
|
+
const globalFile = resolve(findConfigFile(globalDir));
|
|
200
|
+
if (target === globalFile) {
|
|
201
|
+
throw new Error(`Refusing global config file ${target}; project helpers never touch global config.`);
|
|
202
|
+
}
|
|
203
|
+
if (isInsideDir(globalDir, target)) {
|
|
204
|
+
throw new Error(`Refusing path inside global config directory ${globalDir}; project helpers stay inside the project root.`);
|
|
205
|
+
}
|
|
206
|
+
if (root === globalDir || isInsideDir(globalDir, root)) {
|
|
207
|
+
throw new Error(`Refusing global config directory as project root ${root}; use a project checkout.`);
|
|
208
|
+
}
|
|
209
|
+
if (target !== root && !isInsideDir(root, target)) {
|
|
210
|
+
throw new Error(`Refusing outside path ${target}; project helpers stay inside ${root}.`);
|
|
211
|
+
}
|
|
212
|
+
const base = target.split(/[\\/]/).pop();
|
|
213
|
+
if (!PROJECT_CONFIG_FILENAMES.includes(base)) {
|
|
214
|
+
throw new Error(`Refusing non-project filename ${base}; project helpers edit only opencode.jsonc or opencode.json.`);
|
|
215
|
+
}
|
|
216
|
+
const directParent = resolve(join(target, ".."));
|
|
217
|
+
if (directParent !== root) {
|
|
218
|
+
throw new Error(`Refusing nested path ${target}; project helpers edit only the project root file.`);
|
|
219
|
+
}
|
|
220
|
+
return { root, target };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function resolveProjectConfigFile(projectRoot, explicitFile) {
|
|
224
|
+
if (explicitFile !== undefined) {
|
|
225
|
+
const candidate = resolve(projectRoot, explicitFile);
|
|
226
|
+
projectConfigConfinement(projectRoot, candidate);
|
|
227
|
+
return candidate;
|
|
228
|
+
}
|
|
229
|
+
const found = findProjectConfigFile(projectRoot);
|
|
230
|
+
projectConfigConfinement(projectRoot, found);
|
|
231
|
+
return found;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const PROJECT_AGENT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
235
|
+
const PROJECT_PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
|
|
236
|
+
|
|
237
|
+
function assertProjectAgentName(agentName) {
|
|
238
|
+
if (typeof agentName !== "string" || !PROJECT_AGENT_NAME_PATTERN.test(agentName)) {
|
|
239
|
+
throw new Error(`Invalid agent name ${JSON.stringify(agentName)}; use 1-64 letters, digits, dot, underscore, or hyphen starting alphanumeric.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function assertProjectPermissionUpdates(permissionUpdates) {
|
|
244
|
+
if (!permissionUpdates || typeof permissionUpdates !== "object" || Array.isArray(permissionUpdates)) {
|
|
245
|
+
throw new Error("Permission updates must be a non-array object mapping tool to action or pattern map.");
|
|
246
|
+
}
|
|
247
|
+
for (const [tool, value] of Object.entries(permissionUpdates)) {
|
|
248
|
+
if (typeof tool !== "string" || tool.length === 0 || tool.includes("/") || tool.includes("\\")) {
|
|
249
|
+
throw new Error(`Invalid permission tool ${JSON.stringify(tool)}.`);
|
|
250
|
+
}
|
|
251
|
+
if (value === undefined) continue;
|
|
252
|
+
if (typeof value === "string") {
|
|
253
|
+
if (!PROJECT_PERMISSION_ACTIONS.has(value)) {
|
|
254
|
+
throw new Error(`Invalid action ${JSON.stringify(value)} for ${tool}; use allow, ask, deny, or undefined to delete.`);
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
259
|
+
throw new Error(`Invalid permission value for ${tool}; use allow, ask, deny, undefined, or a pattern map.`);
|
|
260
|
+
}
|
|
261
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
262
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
263
|
+
throw new Error(`Invalid permission pattern ${JSON.stringify(pattern)} for ${tool}.`);
|
|
264
|
+
}
|
|
265
|
+
if (action !== undefined && !PROJECT_PERMISSION_ACTIONS.has(action)) {
|
|
266
|
+
throw new Error(`Invalid action ${JSON.stringify(action)} for ${tool} pattern ${JSON.stringify(pattern)}.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseProjectConfigText(text) {
|
|
273
|
+
const errors = [];
|
|
274
|
+
parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false });
|
|
275
|
+
if (errors.length) {
|
|
276
|
+
const first = errors[0];
|
|
277
|
+
throw new Error(`Invalid OpenCode JSONC at offset ${first.offset}: ${printParseErrorCode(first.error)}.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Per-key merge for one agent permission block. String values set the tool
|
|
282
|
+
// shorthand; undefined deletes the tool; object values set per-pattern rules
|
|
283
|
+
// with undefined deleting that pattern. Unrelated keys, plugins, tuples, and
|
|
284
|
+
// sibling agents are preserved via modify plus applyEdits. Reapplying the
|
|
285
|
+
// same updates is idempotent (no edits when values already match).
|
|
286
|
+
export function updateProjectAgentPermissions(text, agentName, permissionUpdates) {
|
|
287
|
+
assertProjectAgentName(agentName);
|
|
288
|
+
assertProjectPermissionUpdates(permissionUpdates);
|
|
289
|
+
parseProjectConfigText(text);
|
|
290
|
+
const source = text || "{}";
|
|
291
|
+
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes("\r\n") ? "\r\n" : "\n" };
|
|
292
|
+
let updated = source;
|
|
293
|
+
for (const [tool, value] of Object.entries(permissionUpdates)) {
|
|
294
|
+
if (value === undefined) {
|
|
295
|
+
updated = applyEdits(updated, modify(updated, ["agent", agentName, "permission", tool], undefined, { formattingOptions }));
|
|
296
|
+
} else if (typeof value === "string") {
|
|
297
|
+
updated = applyEdits(updated, modify(updated, ["agent", agentName, "permission", tool], value, { formattingOptions }));
|
|
298
|
+
} else {
|
|
299
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
300
|
+
updated = applyEdits(updated, modify(updated, ["agent", agentName, "permission", tool, pattern], action, { formattingOptions }));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return updated;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function writeProjectAgentPermissions(projectRoot, agentName, permissionUpdates, explicitFile) {
|
|
308
|
+
assertProjectAgentName(agentName);
|
|
309
|
+
assertProjectPermissionUpdates(permissionUpdates);
|
|
310
|
+
const file = resolveProjectConfigFile(projectRoot, explicitFile);
|
|
311
|
+
mkdirSync(resolve(projectRoot), { recursive: true });
|
|
312
|
+
const existed = existsSync(file);
|
|
313
|
+
const previous = existed ? readFileSync(file, "utf8") : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
|
|
314
|
+
const next = updateProjectAgentPermissions(previous, agentName, permissionUpdates);
|
|
315
|
+
if (next === previous) return { file, backup: null, changed: false, existed };
|
|
316
|
+
const backup = backupFile(file);
|
|
317
|
+
writeFileSync(file, next, "utf8");
|
|
318
|
+
return { file, backup, changed: true, existed };
|
|
319
|
+
}
|
|
320
|
+
|
|
128
321
|
export function backupFile(file, now = new Date()) {
|
|
129
322
|
if (!existsSync(file)) return null;
|
|
130
323
|
const stamp = now.toISOString().replace(/[:.]/g, "-");
|