opencode-herdr-orchestration 0.1.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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/orchestration.js +70 -0
- package/hooks/pre-push +62 -0
- package/package.json +46 -0
- package/src/agents.js +258 -0
- package/src/index.js +46 -0
- package/src/prompts.js +98 -0
- package/src/response.js +390 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# opencode-herdr-orchestration
|
|
2
|
+
|
|
3
|
+
Capability-separated OpenCode agents for planning, implementation, independent review, and delivery through Herdr.
|
|
4
|
+
|
|
5
|
+
This package registers the agents, provides complete structured worker-response retrieval, injects session-specific orchestration mode into shell environments, and ships a reproducible Git `pre-push` policy for new and existing repositories.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22.22.2 or newer when running the package CLI or tests.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
shepherd-plan
|
|
13
|
+
└── sheep-plan
|
|
14
|
+
|
|
15
|
+
shepherd-build
|
|
16
|
+
├── sheep-plan
|
|
17
|
+
├── sheep-build
|
|
18
|
+
├── shearer-review-low
|
|
19
|
+
└── shearer-review-medium
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Agent | Direct mutation | Delegation | Git authority | Model |
|
|
23
|
+
| --- | --- | --- | --- | --- |
|
|
24
|
+
| `shepherd-plan` | Markdown planning artifacts | `sheep-plan` only | Markdown commits; current non-protected branch push | Active strong model |
|
|
25
|
+
| `shepherd-build` | Markdown handoffs/review notes | All four leaf roles | Integration, push, PR, merge, delivery | Active strong model |
|
|
26
|
+
| `sheep-plan` | None | None | Read-only inspection | `litellm/glm-5.3-flash` |
|
|
27
|
+
| `sheep-build` | Assigned implementation | None | Local task commit only | `litellm/glm-5.3-flash` |
|
|
28
|
+
| `shearer-review-low` | None | None | Read-only inspection | GPT-5.6 Terra, low |
|
|
29
|
+
| `shearer-review-medium` | None | None | Read-only inspection | GPT-5.6 Terra, medium |
|
|
30
|
+
|
|
31
|
+
The build shepherd chooses low review for localized mechanical changes with strong deterministic coverage. It chooses medium review for security, architecture, migrations, public APIs, deployment, concurrency, cross-component work, weak coverage, or material uncertainty.
|
|
32
|
+
|
|
33
|
+
## Install the plugin
|
|
34
|
+
|
|
35
|
+
During local development, reference the source directly in the global OpenCode configuration:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"$schema": "https://opencode.ai/config.json",
|
|
40
|
+
"plugin": [
|
|
41
|
+
"file:///C:/Dev/opencode-herdr-orchestration/src/index.js"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
After publishing, use the package name instead:
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{
|
|
50
|
+
"plugin": ["opencode-herdr-orchestration"]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Plugin tuple options can override model defaults:
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
{
|
|
58
|
+
"plugin": [
|
|
59
|
+
[
|
|
60
|
+
"opencode-herdr-orchestration",
|
|
61
|
+
{
|
|
62
|
+
"workerModel": "litellm/glm-5.3-flash",
|
|
63
|
+
"reviewerModel": "litellm-responses/gpt-5.6-terra"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Local agent definitions with the same names are merged over plugin defaults. This permits deliberate user customization without losing unspecified plugin permissions or prompts.
|
|
71
|
+
|
|
72
|
+
During migration, existing files such as `~/.config/opencode/agents/shepherd-build.md` continue to override the plugin prompt. Keep them while existing sessions depend on them. After those sessions end and the plugin has been validated, archive or remove the duplicate standalone definitions so the package becomes the single source of truth. Do not remove files merely to activate the plugin in already-running OpenCode processes; configuration is loaded at process startup.
|
|
73
|
+
|
|
74
|
+
OpenCode loads plugins and agent definitions at startup. Restart OpenCode when intentionally enabling or updating the plugin. The installer never stops or restarts an OpenCode process.
|
|
75
|
+
|
|
76
|
+
## Install the Git policy
|
|
77
|
+
|
|
78
|
+
Run once after installing the npm package:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx opencode-herdr-orchestration install-hooks
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or run directly from this checkout:
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
node C:\Dev\opencode-herdr-orchestration\bin\orchestration.js install-hooks
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The command copies the hook to a stable user location and configures:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
~/.config/opencode-herdr-orchestration/hooks
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
as global `core.hooksPath`. Every new clone, initialized repository, and linked worktree then receives the same policy automatically.
|
|
97
|
+
|
|
98
|
+
The installer refuses to replace an existing global `core.hooksPath`. Review and compose existing hooks first; `--force` is available only for an intentional replacement.
|
|
99
|
+
|
|
100
|
+
Inspect or remove the setting from an installed package:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx opencode-herdr-orchestration status
|
|
104
|
+
npx opencode-herdr-orchestration uninstall-hooks
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
From this checkout:
|
|
108
|
+
|
|
109
|
+
```powershell
|
|
110
|
+
node .\bin\orchestration.js status
|
|
111
|
+
node .\bin\orchestration.js uninstall-hooks
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Add repository-specific protected branches:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
git config --add orchestration.protectedBranch production
|
|
118
|
+
git config --add orchestration.protectedBranch release
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`main` and `master` are always protected in planning mode.
|
|
122
|
+
|
|
123
|
+
## Push policy
|
|
124
|
+
|
|
125
|
+
The plugin tracks the selected agent per OpenCode session and injects `SHEPHERD_MODE` into that session's shell environment.
|
|
126
|
+
|
|
127
|
+
| Mode | Hook behavior |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `plan` | Allows only the attached current branch to the same non-protected remote branch |
|
|
130
|
+
| `build` | Does not add planning restrictions; shepherd-build owns delivery |
|
|
131
|
+
| `sheep-plan` | Denies every push |
|
|
132
|
+
| `sheep-build` | Denies every push |
|
|
133
|
+
| `review` | Denies every push |
|
|
134
|
+
| Missing/unknown | Does not interfere with normal human Git use |
|
|
135
|
+
|
|
136
|
+
The hook rejects detached-HEAD planning pushes, protected branches, ref renames, unrelated refs, and deletions.
|
|
137
|
+
|
|
138
|
+
### Security boundary
|
|
139
|
+
|
|
140
|
+
The hook and OpenCode Bash matchers are defense in depth, not a security sandbox. A process with arbitrary local command execution may be able to alter environment variables, Git configuration, hooks, or invoke remote protocols outside ordinary `git push`. Keep server-side protected branches and repository permissions as the authoritative control. A future integration can strengthen mode provenance with signed, session-scoped policy data, but cannot replace remote authorization.
|
|
141
|
+
|
|
142
|
+
## Planning handoff
|
|
143
|
+
|
|
144
|
+
Final plans include:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
Plan-ID: <topic>-<YYYYMMDD>-<sequence>
|
|
148
|
+
Base-Commit: <full commit hash>
|
|
149
|
+
Status: PROPOSED
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Switching the session to `shepherd-build` approves that plan. Build startup reports the plan ID, approved base, and current HEAD, then inspects divergence before delegation.
|
|
153
|
+
|
|
154
|
+
Implementation contracts include the objective, ownership and forbidden paths, dependencies, acceptance criteria, verification, escalation conditions, and required deliverables. `sheep-build` hands a verified local commit upward. `shepherd-build` runs deterministic checks before requesting semantic review.
|
|
155
|
+
|
|
156
|
+
Reviewer verdicts are:
|
|
157
|
+
|
|
158
|
+
- `PASS`: integrate after shepherd checks.
|
|
159
|
+
- `REWORK`: return actionable findings to the responsible sheep and review the correction.
|
|
160
|
+
- `ESCALATE`: research, re-plan, or request user judgment.
|
|
161
|
+
|
|
162
|
+
Two failed semantic review cycles escalate instead of looping indefinitely.
|
|
163
|
+
|
|
164
|
+
## Worker interruption
|
|
165
|
+
|
|
166
|
+
Herdr currently exposes `send-keys`, not a narrower agent interrupt command. Shepherds retain it to send Ctrl+C only after confirming that a worker is genuinely stuck. Prompts prohibit using worker terminals to type implementation commands or bypass shepherd permissions.
|
|
167
|
+
|
|
168
|
+
This restriction is not hard-enforced by Herdr. A native `herdr agent interrupt <target>` command would close that capability gap.
|
|
169
|
+
|
|
170
|
+
## Worker response retrieval
|
|
171
|
+
|
|
172
|
+
The `herdr_agent_response` OpenCode tool retrieves completed responses from Herdr-managed OpenCode workers without reading terminal scrollback or creating response files.
|
|
173
|
+
|
|
174
|
+
Initial call:
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
{
|
|
178
|
+
"target": "frontend_worker",
|
|
179
|
+
"maxBytes": 8192
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Continuation call:
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"cursor": "<opaque-signed-cursor>",
|
|
188
|
+
"maxBytes": 8192
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Successful page:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"ok": true,
|
|
197
|
+
"target": "frontend_worker",
|
|
198
|
+
"sessionID": "ses_...",
|
|
199
|
+
"messageID": "msg_...",
|
|
200
|
+
"role": "sheep-build",
|
|
201
|
+
"finish": "stop",
|
|
202
|
+
"offset": 0,
|
|
203
|
+
"nextOffset": 8192,
|
|
204
|
+
"totalBytes": 24500,
|
|
205
|
+
"complete": false,
|
|
206
|
+
"cursor": "<opaque-signed-cursor>",
|
|
207
|
+
"text": "..."
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Shepherds must continue until `complete` is true before acting on the response. Pages use UTF-8 byte offsets and never split a multibyte character.
|
|
212
|
+
|
|
213
|
+
The tool:
|
|
214
|
+
|
|
215
|
+
- resolves Herdr's tracked OpenCode session ID;
|
|
216
|
+
- runs `opencode export <sessionID>` with an argv API rather than shell interpolation;
|
|
217
|
+
- selects the latest completed final assistant message after the latest user prompt;
|
|
218
|
+
- excludes intermediate tool-call steps, errors, ignored text, reasoning, and terminal rendering;
|
|
219
|
+
- pins continuation reads to the original session, message, response digest, and offset;
|
|
220
|
+
- HMAC-signs opaque cursors with a random per-plugin-process secret;
|
|
221
|
+
- paginates on UTF-8-safe boundaries with a bounded tool-output budget;
|
|
222
|
+
- supports concurrent shepherds without response files or shared mutable retrieval state;
|
|
223
|
+
- allows only `shepherd-plan` and `shepherd-build`, with a second authorization check inside the tool;
|
|
224
|
+
- accepts only settled Herdr targets with a trusted `herdr:opencode` session mapping and an approved leaf role.
|
|
225
|
+
|
|
226
|
+
The export backend is intentional. Herdr starts each worker as a separate OpenCode process, so the shepherd plugin's SDK client may be tied to a different OpenCode server. `opencode export` reads the shared OpenCode session store across those process boundaries.
|
|
227
|
+
|
|
228
|
+
Normal lifecycle failures return structured errors such as:
|
|
229
|
+
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"ok": false,
|
|
233
|
+
"error": {
|
|
234
|
+
"code": "AGENT_NOT_SETTLED",
|
|
235
|
+
"message": "Herdr agent frontend_worker is working; wait for idle or done.",
|
|
236
|
+
"retryable": true
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Cursor continuations do not query Herdr again, so a worker may begin another turn or leave the live agent list after the initial page. The response remains pinned as long as the OpenCode session and message still exist and are unchanged. Cursors expire after six hours by default and do not survive a restart of the shepherd's OpenCode plugin process.
|
|
242
|
+
|
|
243
|
+
The previous file helper may remain for already-running OpenCode sessions. Once those processes end and this plugin is active, it is no longer needed by new sessions.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npm run check
|
|
249
|
+
npm test
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Tests cover topology, model variants, permissions, override merging, session mode isolation, response selection, signed cursors, UTF-8 pagination, concurrent response reads, tool authorization, and Git hook behavior on protected, worker, review, build, and planning pushes.
|
|
253
|
+
|
|
254
|
+
## Releases
|
|
255
|
+
|
|
256
|
+
CI runs syntax checks, tests, and an npm package dry run on every branch push and pull request.
|
|
257
|
+
|
|
258
|
+
Releases are triggered only by tags shaped like `vMAJOR.MINOR.PATCH`. The release workflow verifies that the strict semantic version in the tag exactly matches `package.json`, reruns all checks, publishes the public npm package with provenance, and creates a GitHub release with generated notes.
|
|
259
|
+
|
|
260
|
+
The release job uses the protected GitHub environment named `npm`. Configure the repository Actions secret once before the first release:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
gh secret set NPM_TOKEN --repo CodingJinxx/opencode-herdr-orchestration
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Use an npm automation or granular access token authorized to publish `opencode-herdr-orchestration`. Then release by updating `package.json` and `package-lock.json`, committing, and pushing the matching tag:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
npm version patch
|
|
270
|
+
git push origin master --follow-tags
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The workflow intentionally fails before publishing when the tag and package version differ. It does not publish prerelease tags such as `v1.0.0-beta.1`.
|
|
274
|
+
|
|
275
|
+
For stronger release control, configure required reviewers on the `npm` environment and require the CI check on the protected default branch. The release is retry-safe: if npm already contains the same version from the same commit, it skips republishing and resumes GitHub release creation; a version published from another commit fails closed.
|
|
276
|
+
|
|
277
|
+
## Known limitations
|
|
278
|
+
|
|
279
|
+
- OpenCode command patterns cannot prove semantic Git intent; the hook adds checks but server-side protection is still required.
|
|
280
|
+
- `herdr agent send-keys` is broader than interrupt-only authority.
|
|
281
|
+
- Global `core.hooksPath` is singular. Existing hook frameworks must be composed rather than overwritten.
|
|
282
|
+
- PR commands are narrowly available to `shepherd-build`, but work only in GitHub repositories with an authenticated `gh` installation.
|
|
283
|
+
- Agent registration and environment hooks take effect only in newly started OpenCode processes.
|
|
284
|
+
- Worker session exports are capped at 64 MiB by default to bound host memory use. The response tool returns `SESSION_EXPORT_TOO_LARGE` rather than loading a larger session.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
const hooksPath = join(homedir(), ".config", "opencode-herdr-orchestration", "hooks");
|
|
11
|
+
const sourceHook = join(packageRoot, "hooks", "pre-push");
|
|
12
|
+
const installedHook = join(hooksPath, "pre-push");
|
|
13
|
+
const [command, ...flags] = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
function git(args, options = {}) {
|
|
16
|
+
return execFileSync("git", args, { encoding: "utf8", windowsHide: true, ...options }).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function currentHooksPath() {
|
|
20
|
+
try {
|
|
21
|
+
return git(["config", "--global", "--get", "core.hooksPath"]);
|
|
22
|
+
} catch {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function install() {
|
|
28
|
+
const existing = currentHooksPath();
|
|
29
|
+
const force = flags.includes("--force");
|
|
30
|
+
if (existing && existing !== hooksPath && !force) {
|
|
31
|
+
throw new Error(`Global core.hooksPath is already ${existing}. Re-run with --force only after reviewing that hook setup.`);
|
|
32
|
+
}
|
|
33
|
+
mkdirSync(hooksPath, { recursive: true });
|
|
34
|
+
copyFileSync(sourceHook, installedHook);
|
|
35
|
+
try { chmodSync(installedHook, 0o755); } catch {}
|
|
36
|
+
git(["config", "--global", "core.hooksPath", hooksPath]);
|
|
37
|
+
process.stdout.write(`Installed pre-push hook and set global core.hooksPath=${hooksPath}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function uninstall() {
|
|
41
|
+
if (currentHooksPath() === hooksPath) {
|
|
42
|
+
git(["config", "--global", "--unset", "core.hooksPath"]);
|
|
43
|
+
rmSync(hooksPath, { recursive: true, force: true });
|
|
44
|
+
process.stdout.write("Removed the orchestration global core.hooksPath setting and installed hook.\n");
|
|
45
|
+
} else {
|
|
46
|
+
process.stdout.write("Global core.hooksPath does not point at this package; nothing changed.\n");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function status() {
|
|
51
|
+
process.stdout.write(`${JSON.stringify({
|
|
52
|
+
configuredHooksPath: currentHooksPath() || null,
|
|
53
|
+
expectedHooksPath: hooksPath,
|
|
54
|
+
hookInstalled: existsSync(installedHook),
|
|
55
|
+
active: currentHooksPath() === hooksPath && existsSync(installedHook),
|
|
56
|
+
}, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
if (command === "install-hooks") install();
|
|
61
|
+
else if (command === "uninstall-hooks") uninstall();
|
|
62
|
+
else if (command === "status") status();
|
|
63
|
+
else {
|
|
64
|
+
process.stderr.write("Usage: opencode-herdr-orchestration <install-hooks [--force]|uninstall-hooks|status>\n");
|
|
65
|
+
process.exitCode = 2;
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
process.stderr.write(`${error.message}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
package/hooks/pre-push
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
mode=${SHEPHERD_MODE:-none}
|
|
6
|
+
case "$mode" in
|
|
7
|
+
sheep-build|sheep-plan|review)
|
|
8
|
+
echo "opencode-herdr-orchestration: $mode may not push; remote Git belongs to shepherd-build." >&2
|
|
9
|
+
exit 1
|
|
10
|
+
;;
|
|
11
|
+
plan)
|
|
12
|
+
;;
|
|
13
|
+
*)
|
|
14
|
+
exit 0
|
|
15
|
+
;;
|
|
16
|
+
esac
|
|
17
|
+
|
|
18
|
+
current_branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)
|
|
19
|
+
if [ -z "$current_branch" ]; then
|
|
20
|
+
echo "opencode-herdr-orchestration: shepherd-plan may not push from detached HEAD." >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
is_protected() {
|
|
25
|
+
candidate=$1
|
|
26
|
+
case "$candidate" in
|
|
27
|
+
main|master) return 0 ;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
git config --get-all orchestration.protectedBranch 2>/dev/null |
|
|
31
|
+
grep -F -x -- "$candidate" >/dev/null 2>&1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if is_protected "$current_branch"; then
|
|
35
|
+
echo "opencode-herdr-orchestration: shepherd-plan may not push protected branch $current_branch." >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
zero=0000000000000000000000000000000000000000
|
|
40
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
41
|
+
[ -n "${local_ref:-}" ] || continue
|
|
42
|
+
|
|
43
|
+
if [ "$local_sha" = "$zero" ]; then
|
|
44
|
+
echo "opencode-herdr-orchestration: shepherd-plan may not delete remote refs." >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
expected_local="refs/heads/$current_branch"
|
|
49
|
+
expected_remote="refs/heads/$current_branch"
|
|
50
|
+
if [ "$local_ref" != "$expected_local" ] || [ "$remote_ref" != "$expected_remote" ]; then
|
|
51
|
+
echo "opencode-herdr-orchestration: shepherd-plan may push only $expected_local to the same remote branch." >&2
|
|
52
|
+
exit 1
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
remote_branch=${remote_ref#refs/heads/}
|
|
56
|
+
if is_protected "$remote_branch"; then
|
|
57
|
+
echo "opencode-herdr-orchestration: shepherd-plan may not update protected branch $remote_branch." >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
done
|
|
61
|
+
|
|
62
|
+
exit 0
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-herdr-orchestration",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Capability-separated Herdr orchestration agents for OpenCode",
|
|
5
|
+
"author": "CodingJinxx",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/CodingJinxx/opencode-herdr-orchestration.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/CodingJinxx/opencode-herdr-orchestration#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/CodingJinxx/opencode-herdr-orchestration/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": "./src/index.js",
|
|
16
|
+
"bin": {
|
|
17
|
+
"opencode-herdr-orchestration": "bin/orchestration.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"hooks",
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test",
|
|
28
|
+
"check": "node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check bin/orchestration.js"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.22.2"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@opencode-ai/plugin": ">=1.3.0 <2"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@opencode-ai/plugin": "1.3.14"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"opencode",
|
|
41
|
+
"herdr",
|
|
42
|
+
"orchestration",
|
|
43
|
+
"agents"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SHEEP_BUILD_PROMPT,
|
|
3
|
+
SHEEP_PLAN_PROMPT,
|
|
4
|
+
SHEPHERD_BUILD_PROMPT,
|
|
5
|
+
SHEPHERD_PLAN_PROMPT,
|
|
6
|
+
SHEARER_REVIEW_PROMPT,
|
|
7
|
+
} from "./prompts.js";
|
|
8
|
+
|
|
9
|
+
const separatorDenials = {
|
|
10
|
+
"*;*": "deny",
|
|
11
|
+
"*&&*": "deny",
|
|
12
|
+
"*||*": "deny",
|
|
13
|
+
"*|*": "deny",
|
|
14
|
+
"*>*": "deny",
|
|
15
|
+
"*<*": "deny",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const safeGitInspection = {
|
|
19
|
+
"git status": "allow",
|
|
20
|
+
"git status --short": "allow",
|
|
21
|
+
"git status --porcelain": "allow",
|
|
22
|
+
"git diff": "allow",
|
|
23
|
+
"git diff --cached": "allow",
|
|
24
|
+
"git diff --stat": "allow",
|
|
25
|
+
"git diff --cached --stat": "allow",
|
|
26
|
+
"git log": "allow",
|
|
27
|
+
"git log --oneline": "allow",
|
|
28
|
+
"git log --oneline -10": "allow",
|
|
29
|
+
"git show": "allow",
|
|
30
|
+
"git branch --show-current": "allow",
|
|
31
|
+
"git branch --list": "allow",
|
|
32
|
+
"git rev-parse HEAD": "allow",
|
|
33
|
+
"git rev-parse --show-toplevel": "allow",
|
|
34
|
+
"git rev-parse --abbrev-ref HEAD": "allow",
|
|
35
|
+
"git merge-base HEAD main": "allow",
|
|
36
|
+
"git merge-base HEAD master": "allow",
|
|
37
|
+
"git ls-files": "allow",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const herdrInspection = {
|
|
41
|
+
"Get-Item Env:HERDR_ENV": "allow",
|
|
42
|
+
"herdr --help": "allow",
|
|
43
|
+
"herdr agent": "allow",
|
|
44
|
+
"herdr agent list*": "allow",
|
|
45
|
+
"herdr agent prompt*": "allow",
|
|
46
|
+
"herdr agent wait*": "allow",
|
|
47
|
+
"herdr agent get*": "allow",
|
|
48
|
+
"herdr agent read*": "allow",
|
|
49
|
+
"herdr agent send-keys*": "allow",
|
|
50
|
+
"herdr pane current*": "allow",
|
|
51
|
+
"herdr pane list*": "allow",
|
|
52
|
+
"herdr pane layout*": "allow",
|
|
53
|
+
"herdr pane split*": "allow",
|
|
54
|
+
"herdr pane read*": "allow",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const markdownOnly = {
|
|
58
|
+
"*": "deny",
|
|
59
|
+
"*.md": "allow",
|
|
60
|
+
"**/*.md": "allow",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function mergeRecord(base, override) {
|
|
64
|
+
if (!base || typeof base !== "object" || Array.isArray(base)) return override ?? base;
|
|
65
|
+
if (!override || typeof override !== "object" || Array.isArray(override)) return override ?? base;
|
|
66
|
+
const merged = { ...base };
|
|
67
|
+
for (const [key, value] of Object.entries(override)) {
|
|
68
|
+
merged[key] = mergeRecord(base[key], value);
|
|
69
|
+
}
|
|
70
|
+
return merged;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function mergeAgent(defaults, override) {
|
|
74
|
+
return mergeRecord(defaults, override);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createAgents(options = {}) {
|
|
78
|
+
const workerModel = options.workerModel ?? "litellm/glm-5.3-flash";
|
|
79
|
+
const reviewerModel = options.reviewerModel ?? "litellm-responses/gpt-5.6-terra";
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"shepherd-plan": {
|
|
83
|
+
mode: "primary",
|
|
84
|
+
description: "Researches and presents implementation-ready plans through sheep-plan workers without implementing them.",
|
|
85
|
+
prompt: SHEPHERD_PLAN_PROMPT,
|
|
86
|
+
permission: {
|
|
87
|
+
grep: "allow",
|
|
88
|
+
todowrite: "allow",
|
|
89
|
+
edit: markdownOnly,
|
|
90
|
+
apply_patch: markdownOnly,
|
|
91
|
+
herdr_agent_response: "allow",
|
|
92
|
+
bash: {
|
|
93
|
+
"*": "deny",
|
|
94
|
+
...herdrInspection,
|
|
95
|
+
"herdr agent start * --kind opencode --pane * -- --agent sheep-plan": "allow",
|
|
96
|
+
"git status*": "allow",
|
|
97
|
+
"git diff*": "allow",
|
|
98
|
+
"git log*": "allow",
|
|
99
|
+
"git show*": "allow",
|
|
100
|
+
"git branch*": "allow",
|
|
101
|
+
"git rev-parse*": "allow",
|
|
102
|
+
"git merge-base*": "allow",
|
|
103
|
+
"git worktree*": "allow",
|
|
104
|
+
"git add *.md": "allow",
|
|
105
|
+
"git add **/*.md": "allow",
|
|
106
|
+
"git commit*": "allow",
|
|
107
|
+
"git push origin HEAD": "allow",
|
|
108
|
+
"git push -u origin HEAD": "allow",
|
|
109
|
+
"git push --set-upstream origin HEAD": "allow",
|
|
110
|
+
"git commit --no-verify*": "deny",
|
|
111
|
+
"git commit * --no-verify*": "deny",
|
|
112
|
+
"git push *main*": "deny",
|
|
113
|
+
"git push *master*": "deny",
|
|
114
|
+
"git push *:*": "deny",
|
|
115
|
+
"git push *--no-verify*": "deny",
|
|
116
|
+
"git push *--force*": "deny",
|
|
117
|
+
"git push *-f*": "deny",
|
|
118
|
+
"git push *--delete*": "deny",
|
|
119
|
+
"git branch -D*": "deny",
|
|
120
|
+
"git branch -d*": "deny",
|
|
121
|
+
"git worktree remove * --force*": "deny",
|
|
122
|
+
...separatorDenials,
|
|
123
|
+
},
|
|
124
|
+
task: "deny",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
"shepherd-build": {
|
|
129
|
+
mode: "primary",
|
|
130
|
+
description: "Executes approved plans through planning, implementation, and independent review workers.",
|
|
131
|
+
prompt: SHEPHERD_BUILD_PROMPT,
|
|
132
|
+
permission: {
|
|
133
|
+
grep: "allow",
|
|
134
|
+
edit: markdownOnly,
|
|
135
|
+
apply_patch: markdownOnly,
|
|
136
|
+
herdr_agent_response: "allow",
|
|
137
|
+
"ia-forge_deployment_status": "allow",
|
|
138
|
+
"ia-forge_file_logs": "allow",
|
|
139
|
+
"ia-forge_run_preset": "allow",
|
|
140
|
+
"ia-forge_service_control": "allow",
|
|
141
|
+
"ia-forge_service_logs": "allow",
|
|
142
|
+
bash: {
|
|
143
|
+
"*": "deny",
|
|
144
|
+
...herdrInspection,
|
|
145
|
+
"herdr agent start * --kind opencode --pane * -- --agent sheep-plan": "allow",
|
|
146
|
+
"herdr agent start * --kind opencode --pane * -- --agent sheep-build": "allow",
|
|
147
|
+
"herdr agent start * --kind opencode --pane * -- --agent shearer-review-low": "allow",
|
|
148
|
+
"herdr agent start * --kind opencode --pane * -- --agent shearer-review-medium": "allow",
|
|
149
|
+
"git status*": "allow",
|
|
150
|
+
"git diff*": "allow",
|
|
151
|
+
"git log*": "allow",
|
|
152
|
+
"git show*": "allow",
|
|
153
|
+
"git branch*": "allow",
|
|
154
|
+
"git rev-parse*": "allow",
|
|
155
|
+
"git merge-base*": "allow",
|
|
156
|
+
"git worktree*": "allow",
|
|
157
|
+
"git add *.md": "allow",
|
|
158
|
+
"git add **/*.md": "allow",
|
|
159
|
+
"git commit*": "allow",
|
|
160
|
+
"git push*": "allow",
|
|
161
|
+
"git merge*": "allow",
|
|
162
|
+
"git fetch*": "allow",
|
|
163
|
+
"git pull --ff-only*": "allow",
|
|
164
|
+
"git push --force*": "deny",
|
|
165
|
+
"git push * --force*": "deny",
|
|
166
|
+
"git push -f*": "deny",
|
|
167
|
+
"git push * -f*": "deny",
|
|
168
|
+
"git push *--delete*": "deny",
|
|
169
|
+
"git push *--no-verify*": "deny",
|
|
170
|
+
"git commit *--no-verify*": "deny",
|
|
171
|
+
"git merge *--no-verify*": "deny",
|
|
172
|
+
"git branch -D*": "deny",
|
|
173
|
+
"git branch -d*": "deny",
|
|
174
|
+
"git worktree remove * --force*": "deny",
|
|
175
|
+
"gh pr create*": "allow",
|
|
176
|
+
"gh pr view*": "allow",
|
|
177
|
+
"gh pr checks*": "allow",
|
|
178
|
+
...separatorDenials,
|
|
179
|
+
},
|
|
180
|
+
task: "deny",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
"sheep-plan": {
|
|
185
|
+
mode: "primary",
|
|
186
|
+
model: workerModel,
|
|
187
|
+
description: "Performs read-only repository research for a shepherd.",
|
|
188
|
+
prompt: SHEEP_PLAN_PROMPT,
|
|
189
|
+
permission: {
|
|
190
|
+
read: "allow",
|
|
191
|
+
glob: "allow",
|
|
192
|
+
grep: "allow",
|
|
193
|
+
list: "allow",
|
|
194
|
+
todowrite: "allow",
|
|
195
|
+
edit: "deny",
|
|
196
|
+
apply_patch: "deny",
|
|
197
|
+
herdr_agent_response: "deny",
|
|
198
|
+
bash: { "*": "deny", ...safeGitInspection, ...separatorDenials },
|
|
199
|
+
task: "deny",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
"sheep-build": {
|
|
204
|
+
mode: "primary",
|
|
205
|
+
model: workerModel,
|
|
206
|
+
description: "Implements a bounded task, verifies it, and hands a local commit to shepherd-build.",
|
|
207
|
+
prompt: SHEEP_BUILD_PROMPT,
|
|
208
|
+
permission: {
|
|
209
|
+
grep: "allow",
|
|
210
|
+
herdr_agent_response: "deny",
|
|
211
|
+
bash: {
|
|
212
|
+
"*": "allow",
|
|
213
|
+
"git push*": "deny",
|
|
214
|
+
"git merge*": "deny",
|
|
215
|
+
"git pull*": "deny",
|
|
216
|
+
"git branch -D*": "deny",
|
|
217
|
+
"git branch -d*": "deny",
|
|
218
|
+
"git rebase*": "deny",
|
|
219
|
+
"git reset*": "deny",
|
|
220
|
+
"git commit *--no-verify*": "deny",
|
|
221
|
+
"git commit --no-verify*": "deny",
|
|
222
|
+
"git *;*": "deny",
|
|
223
|
+
"git *&&*": "deny",
|
|
224
|
+
"git *||*": "deny",
|
|
225
|
+
"git *|*": "deny",
|
|
226
|
+
"git *>*": "deny",
|
|
227
|
+
"git *<*": "deny",
|
|
228
|
+
},
|
|
229
|
+
task: "deny",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
"shearer-review-low": reviewerAgent(reviewerModel, "low"),
|
|
234
|
+
"shearer-review-medium": reviewerAgent(reviewerModel, "medium"),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function reviewerAgent(model, variant) {
|
|
239
|
+
return {
|
|
240
|
+
mode: "primary",
|
|
241
|
+
model,
|
|
242
|
+
variant,
|
|
243
|
+
description: `Independently reviews an implementation with GPT-5.6 Terra ${variant} reasoning.`,
|
|
244
|
+
prompt: SHEARER_REVIEW_PROMPT,
|
|
245
|
+
permission: {
|
|
246
|
+
read: "allow",
|
|
247
|
+
glob: "allow",
|
|
248
|
+
grep: "allow",
|
|
249
|
+
list: "allow",
|
|
250
|
+
edit: "deny",
|
|
251
|
+
apply_patch: "deny",
|
|
252
|
+
todowrite: "deny",
|
|
253
|
+
herdr_agent_response: "deny",
|
|
254
|
+
bash: { "*": "deny", ...safeGitInspection, ...separatorDenials },
|
|
255
|
+
task: "deny",
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createAgents, mergeAgent } from "./agents.js";
|
|
2
|
+
import { createResponseTool } from "./response.js";
|
|
3
|
+
|
|
4
|
+
const SESSION_MODES = new Map();
|
|
5
|
+
|
|
6
|
+
function modeForAgent(agent) {
|
|
7
|
+
if (agent === "shepherd-plan") return "plan";
|
|
8
|
+
if (agent === "shepherd-build") return "build";
|
|
9
|
+
if (agent === "shearer-review-low" || agent === "shearer-review-medium") return "review";
|
|
10
|
+
if (agent === "sheep-plan") return "sheep-plan";
|
|
11
|
+
if (agent === "sheep-build") return "sheep-build";
|
|
12
|
+
return "none";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const HerdrOrchestrationPlugin = async (_input, options = {}) => ({
|
|
16
|
+
tool: {
|
|
17
|
+
herdr_agent_response: createResponseTool(options.response),
|
|
18
|
+
},
|
|
19
|
+
config(config) {
|
|
20
|
+
config.agent ??= {};
|
|
21
|
+
const agents = createAgents(options);
|
|
22
|
+
for (const [name, defaults] of Object.entries(agents)) {
|
|
23
|
+
config.agent[name] = mergeAgent(defaults, config.agent[name]);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async "chat.message"(input) {
|
|
28
|
+
if (input.sessionID) SESSION_MODES.set(input.sessionID, modeForAgent(input.agent));
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async "shell.env"(input, output) {
|
|
32
|
+
output.env.SHEPHERD_MODE = input.sessionID
|
|
33
|
+
? (SESSION_MODES.get(input.sessionID) ?? "none")
|
|
34
|
+
: "none";
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async event({ event }) {
|
|
38
|
+
if (event?.type === "session.deleted") {
|
|
39
|
+
const sessionID = event.properties?.info?.id ?? event.properties?.sessionID;
|
|
40
|
+
if (sessionID) SESSION_MODES.delete(sessionID);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export default HerdrOrchestrationPlugin;
|
|
46
|
+
export { createAgents, modeForAgent };
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const SHEPHERD_PLAN_PROMPT = String.raw`
|
|
2
|
+
You are shepherd-plan, a planning orchestrator. Research the user's goal through repository evidence and sheep-plan workers, then present an implementation-ready plan. Planning may mutate planning infrastructure and Markdown planning artifacts, but never product implementation.
|
|
3
|
+
|
|
4
|
+
Before using Herdr, verify HERDR_ENV=1. If absent, explain that orchestration requires a Herdr-managed pane and stop. Inspect repository instructions, status, branches, worktrees, HEAD, and relevant history. Preserve unrelated changes. Use todos for substantial planning.
|
|
5
|
+
|
|
6
|
+
You may write only Markdown plans, research notes, task briefs, and handoffs. You may commit intended Markdown artifacts and push only the current attached non-protected branch using an approved HEAD push command. Immediately before pushing, inspect the current branch and stop on main, master, detached HEAD, or any repository-defined protected branch. Never merge, deploy, implement, or spawn an implementation-capable agent.
|
|
7
|
+
|
|
8
|
+
Spawn only sheep-plan, using exactly:
|
|
9
|
+
|
|
10
|
+
herdr agent start <name> --kind opencode --pane <pane-id> -- --agent sheep-plan
|
|
11
|
+
|
|
12
|
+
Never pass another agent, model, --auto, or extra OpenCode argument. Use send-keys only to interrupt a genuinely stuck worker with Ctrl+C after inspection. Never type commands, answer arbitrary prompts, or use a worker terminal as a capability bypass.
|
|
13
|
+
|
|
14
|
+
After a worker settles, use herdr_agent_response as the authoritative result channel. Call it first with the worker name, then call it with each returned cursor until complete is true. Do not summarize, decide, or act on the worker result until every page has been read in order. Use herdr agent read only for live status, blocked dialogs, and stuck-worker diagnosis; terminal snapshots are never the completed worker response. If retrieval says the worker is not settled, wait and retry. If an interrupted worker has no completed response, inspect its actual partial state and redesign the task.
|
|
15
|
+
|
|
16
|
+
Assume workers may fail on very large one-shot writes. Identify large-file work and plan generators or coherent bounded stages with valid checkpoints. If a worker later fails on a large write, the shepherd owns recovery: inspect partial state, preserve valid work, and redesign the task rather than repeating the same oversized prompt.
|
|
17
|
+
|
|
18
|
+
Every final plan must begin with:
|
|
19
|
+
|
|
20
|
+
Plan-ID: <short-project-topic>-<YYYYMMDD>-<sequence>
|
|
21
|
+
Base-Commit: <full commit hash>
|
|
22
|
+
Status: PROPOSED
|
|
23
|
+
|
|
24
|
+
Include scope, ordered tasks, likely files and symbols, dependencies, delegation boundaries, acceptance criteria, verification, integration order, risks, and unresolved decisions. Present the plan and stop. Switching to shepherd-build is approval; planning completion alone is not.
|
|
25
|
+
`.trim();
|
|
26
|
+
|
|
27
|
+
export const SHEPHERD_BUILD_PROMPT = String.raw`
|
|
28
|
+
You are shepherd-build, a delivery orchestrator. Selecting this agent after shepherd-plan approves the latest presented plan. You coordinate; every non-Markdown implementation change must come from a sheep-build commit. You may directly write only Markdown task briefs, handoffs, and review notes.
|
|
29
|
+
|
|
30
|
+
Before using Herdr, verify HERDR_ENV=1. Inspect repository instructions, status, branches, worktrees, current HEAD, and history. Preserve unrelated changes and never force-push, bypass hooks, or rewrite history.
|
|
31
|
+
|
|
32
|
+
At startup identify and report:
|
|
33
|
+
|
|
34
|
+
Executing Plan-ID: <id>
|
|
35
|
+
Approved Base-Commit: <hash>
|
|
36
|
+
Current HEAD: <hash>
|
|
37
|
+
|
|
38
|
+
Inspect divergence from the approved base. Continue through mechanical drift, reporting deviations. Re-plan or escalate when changes invalidate approved architecture, scope, or assumptions. For direct requests without a plan, establish an equivalent bounded execution contract before implementation.
|
|
39
|
+
|
|
40
|
+
Delegate using structured contracts containing, where relevant: task_id, plan_id, base_commit, objective, owned_paths, forbidden_paths, dependencies, acceptance_criteria, verification, escalate_if, and deliver. Resolve global ambiguity before delegating. Workers must escalate rather than guess when evidence contradicts the task, scope expands, public APIs or migrations change unexpectedly, a product or architecture decision is required, permissions block work, or repeated attempts fail.
|
|
41
|
+
|
|
42
|
+
Spawn only these configured workers with no extra OpenCode arguments:
|
|
43
|
+
|
|
44
|
+
herdr agent start <name> --kind opencode --pane <pane-id> -- --agent sheep-plan
|
|
45
|
+
herdr agent start <name> --kind opencode --pane <pane-id> -- --agent sheep-build
|
|
46
|
+
herdr agent start <name> --kind opencode --pane <pane-id> -- --agent shearer-review-low
|
|
47
|
+
herdr agent start <name> --kind opencode --pane <pane-id> -- --agent shearer-review-medium
|
|
48
|
+
|
|
49
|
+
Use sheep-plan for research and sheep-build for implementation. Choose Terra low review for localized, mechanical changes with strong deterministic coverage. Choose Terra medium for security, architecture, migrations, public APIs, deployment, concurrency, cross-component work, weak coverage, or material uncertainty.
|
|
50
|
+
|
|
51
|
+
Use send-keys only to interrupt a genuinely stuck worker with Ctrl+C after inspection. Never type implementation commands, answer arbitrary prompts, or use a worker terminal as a capability bypass. After interruption, inspect any completed response and actual diff, preserve valid partial work, and issue a bounded recovery task.
|
|
52
|
+
|
|
53
|
+
After any worker or shearer settles, use herdr_agent_response as the authoritative result channel. Call it first with the agent name, then call it with each returned cursor until complete is true. Do not summarize, review, integrate, or act on the result until every page has been read in order. Use herdr agent read only for live status, blocked dialogs, and stuck-worker diagnosis; terminal snapshots are never a completed response. If retrieval says the agent is not settled, wait and retry. If an interrupted worker has no completed response, inspect its actual partial state and redesign the task.
|
|
54
|
+
|
|
55
|
+
Assume workers may fail on very large one-shot writes. Prefer repository-native generators or coherent bounded stages with valid checkpoints. If a write fails, inspect partial state and redesign the prompt; never discard correct work, reduce required functionality, or repeat the same oversized prompt blindly.
|
|
56
|
+
|
|
57
|
+
Require sheep-build to return a local commit, files changed, checks and results, assumptions, risks, and blockers. Sheep never pushes, merges, opens PRs, or delivers. Run or delegate deterministic repository-native checks before semantic review. Give the shearer fresh bounded context: user goal, approved plan, task contract, base and implementation commits, diff, and verification results, not the worker conversation.
|
|
58
|
+
|
|
59
|
+
Review verdicts are PASS, REWORK, or ESCALATE. PASS permits integration after your own checks. REWORK returns concrete findings to the responsible sheep-build and requires review of the correction. ESCALATE returns to you for research, re-planning, or user judgment. After two failed semantic review cycles for the same task, escalate rather than loop indefinitely.
|
|
60
|
+
|
|
61
|
+
Integrate only reviewed committed work. Perform repository-level verification after integration. Own push, PR, merge, and deployment according to repository instructions and user scope. Use gh only for GitHub repositories and only when PR delivery is requested. Finish with plan ID, assignments, commits, checks, review verdicts, integration and delivery result, deviations, and unresolved risks.
|
|
62
|
+
|
|
63
|
+
When ia-forge tools are available, use them as the source of truth for managed deployments and services. After pushing an infrastructure change, poll deployment status until the attempt finishes; success requires a successful attempt at the pushed commit. On failure inspect deployment service logs, delegate the correction to sheep-build, and repeat verification and review before another delivery attempt.
|
|
64
|
+
`.trim();
|
|
65
|
+
|
|
66
|
+
export const SHEEP_PLAN_PROMPT = String.raw`
|
|
67
|
+
You are sheep-plan, a read-only research worker. Investigate the bounded question from shepherd-plan or shepherd-build using repository instructions, source, tests, configuration, documentation, and allowed Git inspection.
|
|
68
|
+
|
|
69
|
+
Use todos when research has multiple steps. Do not edit, commit, change branches or worktrees, push, merge, install dependencies, run mutating commands, or spawn agents. Trace behavior across relevant boundaries and cite files and symbols. Stop rather than guess when evidence is unavailable or a product or architecture decision is required.
|
|
70
|
+
|
|
71
|
+
Return implementation-ready findings: current behavior, recommended approach, alternatives and tradeoffs, dependencies, edge cases, risks, likely files and symbols, acceptance criteria, verification commands, uncertainties, and blockers. Do not implement.
|
|
72
|
+
`.trim();
|
|
73
|
+
|
|
74
|
+
export const SHEEP_BUILD_PROMPT = String.raw`
|
|
75
|
+
You are sheep-build, an implementation leaf. Execute only the bounded task contract from shepherd-build. Inspect repository instructions and existing code first, preserve unrelated work, and make the smallest complete change within owned scope.
|
|
76
|
+
|
|
77
|
+
You may implement, run repository-native checks, inspect your diff, and create a local task commit. You must not spawn agents, push, pull, merge, rebase, reset history, delete branches, open PRs, deploy, bypass hooks, or perform remote Git operations.
|
|
78
|
+
|
|
79
|
+
Large files may exceed one-shot tool limits. Prefer repository-native generators or coherent bounded edits that preserve valid checkpoints. If a write fails, inspect actual partial state and continue safely in smaller sections. If the intended result cannot be completed reliably, stop and report the precise failure and partial state rather than omitting content.
|
|
80
|
+
|
|
81
|
+
Escalate instead of guessing when evidence contradicts the assignment, ownership must expand, a public API or migration changes unexpectedly, a product or architecture decision is needed, permissions block required work, or repeated attempts fail.
|
|
82
|
+
|
|
83
|
+
Complete relevant checks, inspect the final diff, and commit all intended changes. Report: task ID, plan ID, commit hash, files changed, verification commands and results, assumptions, remaining risks, and blockers. The shepherd owns everything after the local commit.
|
|
84
|
+
`.trim();
|
|
85
|
+
|
|
86
|
+
export const SHEARER_REVIEW_PROMPT = String.raw`
|
|
87
|
+
You are shearer-review, an independent read-only semantic reviewer. Judge the implementation from repository evidence, the approved plan, task contract, base revision, implementation commit, diff, and verification results. Do not rely on the implementation worker's reasoning and never repair your own findings.
|
|
88
|
+
|
|
89
|
+
You must not edit, implement, commit, change branches or worktrees, merge, push, spawn agents, or run mutating commands. Deterministic tooling should decide formatting, lint, types, tests, builds, generated consistency, and secret scanning. Focus on semantic correctness: task and plan compliance, functional behavior, edge cases, regressions, scope violations, unnecessary complexity, meaningful tests, security and safety, and unresolved risk.
|
|
90
|
+
|
|
91
|
+
Return exactly one high-level verdict:
|
|
92
|
+
|
|
93
|
+
PASS - The task and plan are satisfied with adequate verification and no material unresolved issue.
|
|
94
|
+
REWORK - Concrete defects are fixable within the approved task. Give ordered, actionable findings with file and symbol references, expected behavior, and verification.
|
|
95
|
+
ESCALATE - The plan is invalid, requirements conflict, product or architecture judgment is needed, or scope materially expanded. State the decision required and supporting evidence.
|
|
96
|
+
|
|
97
|
+
Keep summaries secondary to findings. Never implement fixes.
|
|
98
|
+
`.trim();
|
package/src/response.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
import { tool } from "@opencode-ai/plugin";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const ALLOWED_CALLERS = new Set(["shepherd-plan", "shepherd-build"]);
|
|
9
|
+
const ALLOWED_TARGET_ROLES = new Set([
|
|
10
|
+
"sheep-plan",
|
|
11
|
+
"sheep-build",
|
|
12
|
+
"shearer-review-low",
|
|
13
|
+
"shearer-review-medium",
|
|
14
|
+
]);
|
|
15
|
+
const SETTLED_STATES = new Set(["idle", "done"]);
|
|
16
|
+
const DEFAULT_PAGE_BYTES = 8192;
|
|
17
|
+
const MIN_PAGE_BYTES = 1024;
|
|
18
|
+
const MAX_PAGE_BYTES = 16384;
|
|
19
|
+
const MAX_TOOL_OUTPUT_BYTES = 32768;
|
|
20
|
+
const DEFAULT_CURSOR_TTL_MS = 6 * 60 * 60 * 1000;
|
|
21
|
+
const DEFAULT_MAX_EXPORT_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
const MAX_HERDR_OUTPUT_BYTES = 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
function error(code, message, retryable = false) {
|
|
25
|
+
return { ok: false, error: { code, message, retryable } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function processErrorDetail(cause) {
|
|
29
|
+
const value = String(cause?.stderr ?? cause?.message ?? cause).replace(/\s+/g, " ").trim();
|
|
30
|
+
return value.length > 1000 ? `${value.slice(0, 1000)}...` : value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sha256(value) {
|
|
34
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encodeCursor(payload, secret) {
|
|
38
|
+
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
39
|
+
const signature = createHmac("sha256", secret).update(encoded).digest("base64url");
|
|
40
|
+
return `${encoded}.${signature}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodeCursor(cursor, secret, now) {
|
|
44
|
+
if (typeof cursor !== "string" || cursor.length > 4096) {
|
|
45
|
+
return error("INVALID_CURSOR", "The response cursor is malformed.");
|
|
46
|
+
}
|
|
47
|
+
const [encoded, signature, extra] = cursor.split(".");
|
|
48
|
+
if (!encoded || !signature || extra) {
|
|
49
|
+
return error("INVALID_CURSOR", "The response cursor is malformed.");
|
|
50
|
+
}
|
|
51
|
+
const expected = createHmac("sha256", secret).update(encoded).digest();
|
|
52
|
+
let supplied;
|
|
53
|
+
try {
|
|
54
|
+
supplied = Buffer.from(signature, "base64url");
|
|
55
|
+
} catch {
|
|
56
|
+
return error("INVALID_CURSOR", "The response cursor signature is malformed.");
|
|
57
|
+
}
|
|
58
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
|
|
59
|
+
return error("INVALID_CURSOR", "The response cursor signature is invalid.");
|
|
60
|
+
}
|
|
61
|
+
let payload;
|
|
62
|
+
try {
|
|
63
|
+
payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
64
|
+
} catch {
|
|
65
|
+
return error("INVALID_CURSOR", "The response cursor payload is invalid.");
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
payload?.v !== 1 ||
|
|
69
|
+
typeof payload.target !== "string" ||
|
|
70
|
+
typeof payload.sessionID !== "string" ||
|
|
71
|
+
typeof payload.messageID !== "string" ||
|
|
72
|
+
typeof payload.role !== "string" ||
|
|
73
|
+
typeof payload.digest !== "string" ||
|
|
74
|
+
!Number.isSafeInteger(payload.offset) ||
|
|
75
|
+
payload.offset < 0 ||
|
|
76
|
+
!Number.isSafeInteger(payload.expiresAt)
|
|
77
|
+
) {
|
|
78
|
+
return error("INVALID_CURSOR", "The response cursor payload is incomplete.");
|
|
79
|
+
}
|
|
80
|
+
if (now() > payload.expiresAt) {
|
|
81
|
+
return error("EXPIRED_CURSOR", "The response cursor has expired. Start retrieval again.");
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, payload };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseJsonOutput(output, label) {
|
|
87
|
+
const start = output.indexOf("{");
|
|
88
|
+
if (start === -1) throw new Error(`${label} returned no JSON object.`);
|
|
89
|
+
return JSON.parse(output.slice(start));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function defaultRun(command, args, signal, maxBuffer) {
|
|
93
|
+
const { stdout } = await execFileAsync(command, args, {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
maxBuffer,
|
|
96
|
+
windowsHide: true,
|
|
97
|
+
signal,
|
|
98
|
+
});
|
|
99
|
+
return stdout;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function completedText(message) {
|
|
103
|
+
return message?.parts
|
|
104
|
+
?.filter(
|
|
105
|
+
(part) =>
|
|
106
|
+
part?.type === "text" &&
|
|
107
|
+
typeof part.text === "string" &&
|
|
108
|
+
!part.ignored,
|
|
109
|
+
)
|
|
110
|
+
.map((part) => part.text)
|
|
111
|
+
.join("\n\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function selectLatestCompletedResponse(exported) {
|
|
115
|
+
const messages = Array.isArray(exported?.messages) ? exported.messages : [];
|
|
116
|
+
let latestUser;
|
|
117
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
118
|
+
if (messages[index]?.info?.role === "user") {
|
|
119
|
+
latestUser = messages[index];
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!latestUser) return error("NO_COMPLETED_RESPONSE", "The worker session has no user prompt.", true);
|
|
124
|
+
|
|
125
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
126
|
+
const message = messages[index];
|
|
127
|
+
const info = message?.info;
|
|
128
|
+
const text = completedText(message);
|
|
129
|
+
if (
|
|
130
|
+
info?.role === "assistant" &&
|
|
131
|
+
info.parentID === latestUser.info.id &&
|
|
132
|
+
info.time?.completed &&
|
|
133
|
+
!info.error &&
|
|
134
|
+
info.finish !== "tool-calls" &&
|
|
135
|
+
text
|
|
136
|
+
) {
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
response: {
|
|
140
|
+
sessionID: info.sessionID,
|
|
141
|
+
messageID: info.id,
|
|
142
|
+
role: info.agent ?? info.mode,
|
|
143
|
+
finish: info.finish ?? "unknown",
|
|
144
|
+
text,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return error(
|
|
150
|
+
"NO_COMPLETED_RESPONSE",
|
|
151
|
+
"The latest worker turn has no completed final text response. Wait for the worker and retry.",
|
|
152
|
+
true,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function findPinnedResponse(exported, messageID) {
|
|
157
|
+
const message = exported?.messages?.find((candidate) => candidate?.info?.id === messageID);
|
|
158
|
+
const info = message?.info;
|
|
159
|
+
const text = completedText(message);
|
|
160
|
+
if (
|
|
161
|
+
info?.role !== "assistant" ||
|
|
162
|
+
!info.time?.completed ||
|
|
163
|
+
info.error ||
|
|
164
|
+
info.finish === "tool-calls" ||
|
|
165
|
+
!text
|
|
166
|
+
) {
|
|
167
|
+
return error("MESSAGE_CHANGED", "The pinned worker response is no longer available in completed form.");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
response: {
|
|
172
|
+
sessionID: info.sessionID,
|
|
173
|
+
messageID: info.id,
|
|
174
|
+
role: info.agent ?? info.mode,
|
|
175
|
+
finish: info.finish ?? "unknown",
|
|
176
|
+
text,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizePageBytes(value) {
|
|
182
|
+
if (value === undefined) return { ok: true, value: DEFAULT_PAGE_BYTES };
|
|
183
|
+
if (!Number.isSafeInteger(value) || value < MIN_PAGE_BYTES || value > MAX_PAGE_BYTES) {
|
|
184
|
+
return error(
|
|
185
|
+
"INVALID_REQUEST",
|
|
186
|
+
`maxBytes must be an integer from ${MIN_PAGE_BYTES} through ${MAX_PAGE_BYTES}.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return { ok: true, value };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function utf8PageEnd(buffer, offset, requestedBytes) {
|
|
193
|
+
let end = Math.min(buffer.length, offset + requestedBytes);
|
|
194
|
+
while (end > offset && end < buffer.length && (buffer[end] & 0xc0) === 0x80) end -= 1;
|
|
195
|
+
return end;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function makePage({ response, target, offset, requestedBytes, secret, expiresAt }) {
|
|
199
|
+
const bytes = Buffer.from(response.text, "utf8");
|
|
200
|
+
if (offset > bytes.length) return error("INVALID_CURSOR", "The response cursor offset exceeds the message length.");
|
|
201
|
+
const digest = sha256(bytes);
|
|
202
|
+
let pageBytes = requestedBytes;
|
|
203
|
+
|
|
204
|
+
while (pageBytes >= MIN_PAGE_BYTES) {
|
|
205
|
+
const end = utf8PageEnd(bytes, offset, pageBytes);
|
|
206
|
+
const complete = end === bytes.length;
|
|
207
|
+
const payload = {
|
|
208
|
+
v: 1,
|
|
209
|
+
target,
|
|
210
|
+
sessionID: response.sessionID,
|
|
211
|
+
messageID: response.messageID,
|
|
212
|
+
role: response.role,
|
|
213
|
+
digest,
|
|
214
|
+
offset: end,
|
|
215
|
+
expiresAt,
|
|
216
|
+
};
|
|
217
|
+
const result = {
|
|
218
|
+
ok: true,
|
|
219
|
+
target,
|
|
220
|
+
sessionID: response.sessionID,
|
|
221
|
+
messageID: response.messageID,
|
|
222
|
+
role: response.role,
|
|
223
|
+
finish: response.finish,
|
|
224
|
+
offset,
|
|
225
|
+
nextOffset: end,
|
|
226
|
+
totalBytes: bytes.length,
|
|
227
|
+
complete,
|
|
228
|
+
cursor: complete ? null : encodeCursor(payload, secret),
|
|
229
|
+
text: bytes.subarray(offset, end).toString("utf8"),
|
|
230
|
+
};
|
|
231
|
+
if (Buffer.byteLength(JSON.stringify(result), "utf8") <= MAX_TOOL_OUTPUT_BYTES) return result;
|
|
232
|
+
pageBytes = Math.floor(pageBytes / 2);
|
|
233
|
+
}
|
|
234
|
+
return error("OUTPUT_BUDGET_EXCEEDED", "A response page could not fit within the tool output budget.");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validateTarget(target) {
|
|
238
|
+
if (typeof target !== "string" || !/^(?:[a-z][a-z0-9_-]{0,31}|w[0-9A-Za-z]+:p[0-9A-Za-z]+)$/.test(target)) {
|
|
239
|
+
return error("INVALID_REQUEST", "target must be a Herdr agent name or pane ID.");
|
|
240
|
+
}
|
|
241
|
+
return { ok: true };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function resolveInitialResponse(target, run, signal, maxExportBytes) {
|
|
245
|
+
let agent;
|
|
246
|
+
try {
|
|
247
|
+
const output = await run("herdr", ["agent", "get", target], signal, MAX_HERDR_OUTPUT_BYTES);
|
|
248
|
+
agent = parseJsonOutput(output, "herdr agent get").result?.agent;
|
|
249
|
+
} catch (cause) {
|
|
250
|
+
const detail = processErrorDetail(cause);
|
|
251
|
+
if (detail.includes("agent_not_found")) return error("AGENT_NOT_FOUND", `Herdr agent ${target} was not found.`);
|
|
252
|
+
return error("HERDR_UNAVAILABLE", `Unable to query Herdr agent ${target}: ${detail}`, true);
|
|
253
|
+
}
|
|
254
|
+
if (!SETTLED_STATES.has(agent?.agent_status)) {
|
|
255
|
+
return error(
|
|
256
|
+
"AGENT_NOT_SETTLED",
|
|
257
|
+
`Herdr agent ${target} is ${agent?.agent_status ?? "unknown"}; wait for idle or done.`,
|
|
258
|
+
true,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const session = agent.agent_session;
|
|
262
|
+
if (
|
|
263
|
+
session?.agent !== "opencode" ||
|
|
264
|
+
session.kind !== "id" ||
|
|
265
|
+
session.source !== "herdr:opencode" ||
|
|
266
|
+
typeof session.value !== "string"
|
|
267
|
+
) {
|
|
268
|
+
return error("AGENT_NOT_OPENCODE", `Herdr agent ${target} does not expose a trusted OpenCode session ID.`);
|
|
269
|
+
}
|
|
270
|
+
const exported = await exportSession(session.value, run, signal, maxExportBytes);
|
|
271
|
+
if (!exported.ok) return exported;
|
|
272
|
+
const selected = selectLatestCompletedResponse(exported.value);
|
|
273
|
+
if (!selected.ok) return selected;
|
|
274
|
+
if (selected.response.sessionID !== session.value) {
|
|
275
|
+
return error("SESSION_EXPORT_FAILED", "The exported response belongs to a different OpenCode session.");
|
|
276
|
+
}
|
|
277
|
+
if (!ALLOWED_TARGET_ROLES.has(selected.response.role)) {
|
|
278
|
+
return error(
|
|
279
|
+
"UNSUPPORTED_WORKER_ROLE",
|
|
280
|
+
`Herdr target ${target} completed as unsupported agent role ${selected.response.role ?? "unknown"}.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return selected;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function exportSession(sessionID, run, signal, maxExportBytes) {
|
|
287
|
+
try {
|
|
288
|
+
const output = await run("opencode", ["export", sessionID], signal, maxExportBytes);
|
|
289
|
+
return { ok: true, value: parseJsonOutput(output, "opencode export") };
|
|
290
|
+
} catch (cause) {
|
|
291
|
+
const detail = processErrorDetail(cause);
|
|
292
|
+
const code =
|
|
293
|
+
cause?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
|
294
|
+
? "SESSION_EXPORT_TOO_LARGE"
|
|
295
|
+
: detail.toLowerCase().includes("not found")
|
|
296
|
+
? "SESSION_NOT_FOUND"
|
|
297
|
+
: "SESSION_EXPORT_FAILED";
|
|
298
|
+
return error(code, `Unable to export OpenCode session ${sessionID}: ${detail}`, code === "SESSION_EXPORT_FAILED");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function createResponseService(options = {}) {
|
|
303
|
+
const run = options.run ?? defaultRun;
|
|
304
|
+
const secret = options.secret ?? randomBytes(32);
|
|
305
|
+
const now = options.now ?? Date.now;
|
|
306
|
+
const cursorTtlMs = options.cursorTtlMs ?? DEFAULT_CURSOR_TTL_MS;
|
|
307
|
+
const maxExportBytes = options.maxExportBytes ?? DEFAULT_MAX_EXPORT_BYTES;
|
|
308
|
+
|
|
309
|
+
if (!Number.isSafeInteger(maxExportBytes) || maxExportBytes < 1024 * 1024) {
|
|
310
|
+
throw new TypeError("maxExportBytes must be an integer of at least 1048576 bytes.");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return async function retrieve(args, context) {
|
|
314
|
+
if (!ALLOWED_CALLERS.has(context?.agent)) {
|
|
315
|
+
return error("UNAUTHORIZED_AGENT", `Agent ${context?.agent ?? "unknown"} may not retrieve worker responses.`);
|
|
316
|
+
}
|
|
317
|
+
const pageSize = normalizePageBytes(args.maxBytes);
|
|
318
|
+
if (!pageSize.ok) return pageSize;
|
|
319
|
+
if (Boolean(args.target) === Boolean(args.cursor)) {
|
|
320
|
+
return error("INVALID_REQUEST", "Provide exactly one of target or cursor.");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (args.target) {
|
|
324
|
+
const valid = validateTarget(args.target);
|
|
325
|
+
if (!valid.ok) return valid;
|
|
326
|
+
const selected = await resolveInitialResponse(args.target, run, context.abort, maxExportBytes);
|
|
327
|
+
if (!selected.ok) return selected;
|
|
328
|
+
return makePage({
|
|
329
|
+
response: selected.response,
|
|
330
|
+
target: args.target,
|
|
331
|
+
offset: 0,
|
|
332
|
+
requestedBytes: pageSize.value,
|
|
333
|
+
secret,
|
|
334
|
+
expiresAt: now() + cursorTtlMs,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const decoded = decodeCursor(args.cursor, secret, now);
|
|
339
|
+
if (!decoded.ok) return decoded;
|
|
340
|
+
const exported = await exportSession(decoded.payload.sessionID, run, context.abort, maxExportBytes);
|
|
341
|
+
if (!exported.ok) return exported;
|
|
342
|
+
const pinned = findPinnedResponse(exported.value, decoded.payload.messageID);
|
|
343
|
+
if (!pinned.ok) return pinned;
|
|
344
|
+
if (
|
|
345
|
+
pinned.response.sessionID !== decoded.payload.sessionID ||
|
|
346
|
+
pinned.response.role !== decoded.payload.role ||
|
|
347
|
+
!ALLOWED_TARGET_ROLES.has(pinned.response.role) ||
|
|
348
|
+
sha256(Buffer.from(pinned.response.text, "utf8")) !== decoded.payload.digest
|
|
349
|
+
) {
|
|
350
|
+
return error("MESSAGE_CHANGED", "The pinned worker response changed after pagination began.");
|
|
351
|
+
}
|
|
352
|
+
return makePage({
|
|
353
|
+
response: pinned.response,
|
|
354
|
+
target: decoded.payload.target,
|
|
355
|
+
offset: decoded.payload.offset,
|
|
356
|
+
requestedBytes: pageSize.value,
|
|
357
|
+
secret,
|
|
358
|
+
expiresAt: decoded.payload.expiresAt,
|
|
359
|
+
});
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function createResponseTool(options = {}) {
|
|
364
|
+
const retrieve = createResponseService(options);
|
|
365
|
+
return tool({
|
|
366
|
+
description:
|
|
367
|
+
"Retrieve the complete final response from a settled Herdr-managed OpenCode worker. Start with target, then follow each returned cursor until complete is true.",
|
|
368
|
+
args: {
|
|
369
|
+
target: tool.schema.string().optional().describe("Herdr agent name or pane ID for the initial page."),
|
|
370
|
+
cursor: tool.schema.string().optional().describe("Opaque cursor returned by the preceding page."),
|
|
371
|
+
maxBytes: tool.schema
|
|
372
|
+
.number()
|
|
373
|
+
.int()
|
|
374
|
+
.min(MIN_PAGE_BYTES)
|
|
375
|
+
.max(MAX_PAGE_BYTES)
|
|
376
|
+
.optional()
|
|
377
|
+
.describe("Maximum UTF-8 response bytes requested for this page."),
|
|
378
|
+
},
|
|
379
|
+
async execute(args, context) {
|
|
380
|
+
const result = await retrieve(args, context);
|
|
381
|
+
context.metadata({
|
|
382
|
+
title: result.ok ? `Worker response: ${result.target}` : `Worker response: ${result.error.code}`,
|
|
383
|
+
metadata: result.ok
|
|
384
|
+
? { sessionID: result.sessionID, messageID: result.messageID, complete: result.complete }
|
|
385
|
+
: { error: result.error.code },
|
|
386
|
+
});
|
|
387
|
+
return JSON.stringify(result);
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|