opencode-herdr-orchestration 0.2.0 → 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/src/index.js CHANGED
@@ -1,12 +1,26 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
 
3
- import { createAgents, mergeAgent, STATE_TOOL_ACCESS, STATE_TOOLS } from "./agents.js";
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 repository's shared Git
28
- // common directory, so linked worktrees share state while separate clones
29
- // never do. The state service invokes only read-only `git rev-parse`; it
30
- // never writes arbitrary Git metadata.
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 { createAgents, mergeAgent, modeForAgent, STATE_TOOL_ACCESS, STATE_TOOLS };
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, "-");