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/index.js CHANGED
@@ -1,12 +1,27 @@
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";
19
+ import { createSteerCommandHook } from "./steer.js";
6
20
 
7
21
  const SESSION_MODES = new Map();
8
22
 
9
23
  function modeForAgent(agent) {
24
+ if (agent === DEVELOPER_AGENT) return "developer";
10
25
  if (agent === "shepherd") return "shepherd";
11
26
  if (agent === "shepherd-governor") return "governor";
12
27
  if (agent === "sheepdog") return "sheepdog";
@@ -24,10 +39,14 @@ function stateError(code, message, retryable = false) {
24
39
  // tools. Enforcement mirrors STATE_TOOL_ACCESS in src/agents.js: the planning
25
40
  // shepherd writes and reads plan artifacts; shepherd-governor and sheepdog
26
41
  // 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.
42
+ // Artifacts are durable Markdown files under the canonical
43
+ // `<git-common-dir>/flocky` state root in the repository's shared Git common
44
+ // directory, so linked worktrees share state while separate clones never do.
45
+ // Legacy `<git-common-dir>/herdr` artifacts are reconciled into the canonical
46
+ // root before every plan or execution operation — copied, accepted, or failed
47
+ // closed on conflict — and the legacy root is never auto-deleted. The state
48
+ // service invokes only read-only `git rev-parse`; it never writes arbitrary
49
+ // Git metadata.
31
50
  export function createStateTools(stateOptions = {}) {
32
51
  const state = createStateService(stateOptions);
33
52
 
@@ -103,36 +122,293 @@ export function createStateTools(stateOptions = {}) {
103
122
  return tools;
104
123
  }
105
124
 
106
- export const HerdrOrchestrationPlugin = async (_input, options = {}) => ({
107
- tool: {
108
- herdr_agent_response: createResponseTool(options.response),
109
- ...createStateTools(options.state),
110
- },
111
- config(config) {
112
- config.agent ??= {};
113
- const agents = createAgents(options);
114
- for (const [name, defaults] of Object.entries(agents)) {
115
- config.agent[name] = mergeAgent(defaults, config.agent[name]);
116
- }
117
- },
118
-
119
- async "chat.message"(input) {
120
- if (input.sessionID) SESSION_MODES.set(input.sessionID, modeForAgent(input.agent));
121
- },
122
-
123
- async "shell.env"(input, output) {
124
- output.env.SHEPHERD_MODE = input.sessionID
125
- ? (SESSION_MODES.get(input.sessionID) ?? "none")
126
- : "none";
127
- },
128
-
129
- async event({ event }) {
130
- if (event?.type === "session.deleted") {
131
- const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
132
- if (sessionID) SESSION_MODES.delete(sessionID);
133
- }
134
- },
135
- });
125
+ // Developer steering submission (M2, Option A, trusted Developer only).
126
+ // The sole submitter is the explicit non-flock `developer` context. The
127
+ // allowlist holds only Developer; every flock role plus unknown, ambiguous,
128
+ // none, and unset are denied fail-closed with no filesystem write. Developer
129
+ // is never inferred from session mode, directory, environment text, or prompt
130
+ // content: only an exact `context.agent === "developer"` passes. The runtime
131
+ // check stays authoritative over static per-agent permission overrides.
132
+ export function createSteeringTools(stateOptions = {}) {
133
+ const state = createStateService(stateOptions);
134
+ const name = STEERING_TOOLS.submit;
135
+ return {
136
+ [name]: tool({
137
+ description:
138
+ "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.",
139
+ args: {
140
+ planId: tool.schema
141
+ .string()
142
+ .optional()
143
+ .describe("Explicit steering target Plan ID; required unless exactly one active target exists."),
144
+ content: tool.schema
145
+ .string()
146
+ .min(1)
147
+ .describe("Bounded steering content; at most 8192 UTF-8 bytes."),
148
+ },
149
+ async execute(args, context) {
150
+ const allowed = STEERING_TOOL_ACCESS.get(name);
151
+ if (!allowed?.has(context?.agent)) {
152
+ return JSON.stringify(
153
+ stateError(
154
+ "UNAUTHORIZED_AGENT",
155
+ `Agent ${context?.agent ?? "unknown"} may not use ${name}.`,
156
+ ),
157
+ );
158
+ }
159
+ const result = await state.submitSteering(args);
160
+ context.metadata({
161
+ title: result.ok ? `${name}: ${result.entry.planId}#${result.entry.sequence}` : `${name}: ${result.error.code}`,
162
+ metadata: result.ok
163
+ ? { planId: result.entry.planId, steeringId: result.entry.id, sequence: result.entry.sequence }
164
+ : { error: result.error.code },
165
+ });
166
+ return JSON.stringify(result);
167
+ },
168
+ }),
169
+ };
170
+ }
171
+
172
+ // Shepherd-only raw steering plus ownership lifecycle tools (M3).
173
+ // Only `shepherd` and `shepherd-governor` pass the runtime allowlist;
174
+ // sheepdog, grazer, sheep, shearers, developer, unknown, and unset are
175
+ // denied fail-closed with no filesystem write. State-level session plus
176
+ // generation fencing stays authoritative inside src/state.js (NOT
177
+ // AUTHORITATIVE PHASE for non-owners). Steering never authorizes push,
178
+ // tag, publish, deploy, merge, or any consequential action; existing
179
+ // approvals still required.
180
+ export function createRawSteeringTools(stateOptions = {}) {
181
+ const state = createStateService(stateOptions);
182
+ const definitions = [
183
+ {
184
+ name: RAW_STEERING_TOOLS.check,
185
+ description:
186
+ "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.",
187
+ run: (args) => state.checkSteering(args),
188
+ },
189
+ {
190
+ name: RAW_STEERING_TOOLS.read,
191
+ description:
192
+ "Read ordered exact unread Developer steering with no mutation. Shepherd phases only with authoritative phase, session, and generation.",
193
+ run: (args) => state.readSteering(args),
194
+ },
195
+ {
196
+ name: RAW_STEERING_TOOLS.consume,
197
+ description:
198
+ "Consume Developer steering only after the authoritative owner recorded sync disposition for the sync point; idempotent. Shepherd phases only. Steering never authorizes consequential actions.",
199
+ run: (args) => state.consumeSteering(args),
200
+ },
201
+ ];
202
+ const tools = {};
203
+ for (const definition of definitions) {
204
+ tools[definition.name] = tool({
205
+ description: definition.description,
206
+ args: {
207
+ planId: tool.schema.string().describe("Explicit steering target Plan ID."),
208
+ phase: tool.schema.string().describe("Owner phase: planning or governance."),
209
+ session: tool.schema.string().describe("Authoritative session fencing the ownership record."),
210
+ generation: tool.schema.number().int().min(1).describe("Authoritative generation fencing the ownership record."),
211
+ ...(definition.name === RAW_STEERING_TOOLS.consume
212
+ ? {
213
+ ids: tool.schema.array(tool.schema.string()).min(1).max(1000).describe("Steering ids to consume idempotently."),
214
+ syncPoint: tool.schema.string().describe("Closed sync point whose disposition was recorded before consume."),
215
+ disposition: tool.schema.string().describe("Closed disposition recorded before consume."),
216
+ }
217
+ : {}),
218
+ },
219
+ async execute(args, context) {
220
+ const allowed = RAW_STEERING_TOOL_ACCESS.get(definition.name);
221
+ if (!allowed?.has(context?.agent)) {
222
+ return JSON.stringify(
223
+ stateError(
224
+ "UNAUTHORIZED_AGENT",
225
+ `Agent ${context?.agent ?? "unknown"} may not use ${definition.name}.`,
226
+ ),
227
+ );
228
+ }
229
+ const result = await definition.run(args);
230
+ context.metadata({
231
+ title: result.ok ? `${definition.name}: ${args.planId}` : `${definition.name}: ${result.error.code}`,
232
+ metadata: result.ok ? { planId: args.planId } : { error: result.error.code },
233
+ });
234
+ return JSON.stringify(result);
235
+ },
236
+ });
237
+ }
238
+ return tools;
239
+ }
240
+
241
+ export function createOwnershipTools(stateOptions = {}) {
242
+ const state = createStateService(stateOptions);
243
+ const definitions = [
244
+ {
245
+ name: OWNERSHIP_TOOLS.claim,
246
+ description:
247
+ "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.",
248
+ run: (args) => state.claimOwnership(args),
249
+ },
250
+ {
251
+ name: OWNERSHIP_TOOLS.read,
252
+ description: "Read the validated lifecycle record for one Plan ID. Shepherd phases only; non-owners receive NOT AUTHORITATIVE PHASE.",
253
+ run: (args) => state.readOwnership(args),
254
+ },
255
+ {
256
+ name: OWNERSHIP_TOOLS.sync,
257
+ description:
258
+ "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.",
259
+ run: (args) => state.recordSync(args),
260
+ },
261
+ {
262
+ name: OWNERSHIP_TOOLS.snapshot,
263
+ description:
264
+ "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.",
265
+ run: (args) => state.recordSnapshot(args),
266
+ },
267
+ {
268
+ name: OWNERSHIP_TOOLS.correct,
269
+ description:
270
+ "Route semantic correction to sheepdog as normal corrective instructions, never raw records. Shepherd phases only; steering never authorizes consequential actions.",
271
+ run: (args) => state.routeCorrection(args),
272
+ },
273
+ ];
274
+ const tools = {};
275
+ for (const definition of definitions) {
276
+ const isClaim = definition.name === OWNERSHIP_TOOLS.claim;
277
+ const isSync = definition.name === OWNERSHIP_TOOLS.sync;
278
+ const isSnapshot = definition.name === OWNERSHIP_TOOLS.snapshot;
279
+ const isCorrect = definition.name === OWNERSHIP_TOOLS.correct;
280
+ tools[definition.name] = tool({
281
+ description: definition.description,
282
+ args: {
283
+ planId: tool.schema.string().describe("Lifecycle target Plan ID."),
284
+ phase: tool.schema.string().describe("Owner phase: planning or governance."),
285
+ session: tool.schema.string().describe("Authoritative session fencing the ownership record."),
286
+ ...(isClaim || isSync || isSnapshot || isCorrect
287
+ ? { generation: tool.schema.number().int().min(1).describe("Authoritative generation fencing the ownership record.") }
288
+ : {}),
289
+ ...(isClaim
290
+ ? {
291
+ milestone: tool.schema.string().min(1).describe("Bounded milestone summary."),
292
+ lifecycleState: tool.schema.string().describe("Closed lifecycle state."),
293
+ currentObjective: tool.schema.string().optional().describe("Bounded current objective semantic summary."),
294
+ currentAction: tool.schema.string().optional().describe("Bounded current action semantic summary."),
295
+ activeSheepdogTarget: tool.schema.string().optional().describe("Active sheepdog target or empty when yielded."),
296
+ relevantRevision: tool.schema.string().optional().describe("Relevant revision summary."),
297
+ pendingConsequentialAction: tool.schema.string().optional().describe("Pending consequential action summary."),
298
+ }
299
+ : {}),
300
+ ...(isSync
301
+ ? {
302
+ syncPoint: tool.schema.string().describe("Closed sync point."),
303
+ disposition: tool.schema.string().describe("Closed disposition recorded before consume."),
304
+ note: tool.schema.string().optional().describe("Bounded semantic note."),
305
+ }
306
+ : {}),
307
+ ...(isSnapshot
308
+ ? {
309
+ stage: tool.schema.string().describe("Snapshot stage."),
310
+ milestone: tool.schema.string().optional().describe("Bounded milestone override."),
311
+ lifecycleState: tool.schema.string().optional().describe("Closed lifecycle state override."),
312
+ currentObjective: tool.schema.string().optional().describe("Bounded current objective override."),
313
+ currentAction: tool.schema.string().optional().describe("Bounded current action override."),
314
+ activeSheepdogTarget: tool.schema.string().optional().describe("Active sheepdog target override."),
315
+ relevantRevision: tool.schema.string().optional().describe("Relevant revision override."),
316
+ pendingConsequentialAction: tool.schema.string().optional().describe("Pending consequential action override."),
317
+ }
318
+ : {}),
319
+ ...(isCorrect
320
+ ? {
321
+ correction: tool.schema.string().min(1).describe("Normal semantic corrective instructions for sheepdog, never raw records."),
322
+ syncPoint: tool.schema.string().optional().describe("Related closed sync point."),
323
+ }
324
+ : {}),
325
+ },
326
+ async execute(args, context) {
327
+ const allowed = OWNERSHIP_TOOL_ACCESS.get(definition.name);
328
+ if (!allowed?.has(context?.agent)) {
329
+ return JSON.stringify(
330
+ stateError(
331
+ "UNAUTHORIZED_AGENT",
332
+ `Agent ${context?.agent ?? "unknown"} may not use ${definition.name}.`,
333
+ ),
334
+ );
335
+ }
336
+ const result = await definition.run(args);
337
+ context.metadata({
338
+ title: result.ok ? `${definition.name}: ${args.planId}` : `${definition.name}: ${result.error.code}`,
339
+ metadata: result.ok ? { planId: args.planId } : { error: result.error.code },
340
+ });
341
+ return JSON.stringify(result);
342
+ },
343
+ });
344
+ }
345
+ return tools;
346
+ }
347
+
348
+ export const HerdrOrchestrationPlugin = async (pluginInput = {}, options = {}) => {
349
+ const stateOptions = { ...(options.state ?? {}) };
350
+ if (stateOptions.cwd === undefined) {
351
+ const fallback = pluginInput?.directory ?? pluginInput?.worktree;
352
+ if (typeof fallback === "string" && fallback.length > 0) stateOptions.cwd = fallback;
353
+ }
354
+ const resolveAgent = options.steerResolveAgent ?? options.steer?.resolveAgent;
355
+ const steerBefore = createSteerCommandHook({
356
+ client: pluginInput?.client,
357
+ stateOptions,
358
+ ...(resolveAgent ? { resolveAgent } : {}),
359
+ });
360
+ return {
361
+ tool: {
362
+ herdr_agent_response: createResponseTool(options.response),
363
+ ...createStateTools(options.state),
364
+ ...createSteeringTools(options.state),
365
+ ...createRawSteeringTools(options.state),
366
+ ...createOwnershipTools(options.state),
367
+ },
368
+ config(config) {
369
+ config.agent ??= {};
370
+ const agents = createAgents(options);
371
+ for (const [name, defaults] of Object.entries(agents)) {
372
+ config.agent[name] = mergeAgent(defaults, config.agent[name]);
373
+ }
374
+ },
375
+
376
+ async "command.execute.before"(input, output) {
377
+ return steerBefore(input, output);
378
+ },
379
+
380
+ async "chat.message"(input) {
381
+ if (input.sessionID) SESSION_MODES.set(input.sessionID, modeForAgent(input.agent));
382
+ },
383
+
384
+ async "shell.env"(input, output) {
385
+ output.env.SHEPHERD_MODE = input.sessionID
386
+ ? (SESSION_MODES.get(input.sessionID) ?? "none")
387
+ : "none";
388
+ },
389
+
390
+ async event({ event }) {
391
+ if (event?.type === "session.deleted") {
392
+ const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
393
+ if (sessionID) SESSION_MODES.delete(sessionID);
394
+ }
395
+ },
396
+ };
397
+ };
136
398
 
137
399
  export default HerdrOrchestrationPlugin;
138
- export { createAgents, mergeAgent, modeForAgent, STATE_TOOL_ACCESS, STATE_TOOLS };
400
+ export {
401
+ createAgents,
402
+ DEVELOPER_AGENT,
403
+ mergeAgent,
404
+ modeForAgent,
405
+ OWNERSHIP_TOOL_ACCESS,
406
+ OWNERSHIP_TOOLS,
407
+ RAW_STEERING_TOOL_ACCESS,
408
+ RAW_STEERING_TOOLS,
409
+ SHEPHERD_PHASES,
410
+ STATE_TOOL_ACCESS,
411
+ STATE_TOOLS,
412
+ STEERING_TOOL_ACCESS,
413
+ STEERING_TOOLS,
414
+ };