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/steer.js ADDED
@@ -0,0 +1,124 @@
1
+ import { DEVELOPER_AGENT, STEERING_TOOL_ACCESS, STEERING_TOOLS } from "./agents.js";
2
+ import { createStateService } from "./state.js";
3
+
4
+ // Native /steer command owned by the installer. The hook in this module is
5
+ // the sole writer: it calls the existing state service directly so validation
6
+ // plus journaling stay identical to the submit tool with no parallel store.
7
+ export const STEER_COMMAND_NAME = "steer";
8
+ export const STEER_COMMAND_AGENT = DEVELOPER_AGENT;
9
+ export const STEER_COMMAND_DESCRIPTION =
10
+ "Submit bounded Developer steering for one Plan ID target (Developer only; flock roles denied).";
11
+ export const STEER_COMMAND_TEMPLATE =
12
+ "Submit Developer steering with arguments: $ARGUMENTS\n\nFormat $ARGUMENTS as `<content>` or `<planId> :: <content>`; omit planId only when exactly one active steering target exists.";
13
+
14
+ export function steerCommandEntry() {
15
+ return {
16
+ template: STEER_COMMAND_TEMPLATE,
17
+ description: STEER_COMMAND_DESCRIPTION,
18
+ agent: STEER_COMMAND_AGENT,
19
+ };
20
+ }
21
+
22
+ // Parse /steer arguments into the exact { content plus optional planId }
23
+ // shape the state service validates. `::` separates an explicit planId
24
+ // candidate from content when the left side is a single token (no whitespace);
25
+ // the candidate is passed through so the state service reports
26
+ // INVALID_PLAN_ID identically to the submit tool. A left side with whitespace
27
+ // is prose, so the whole string stays content and `::` inside sentences never
28
+ // misroutes.
29
+ export function parseSteerArguments(rawArguments) {
30
+ const raw = typeof rawArguments === "string" ? rawArguments : "";
31
+ const separator = raw.indexOf("::");
32
+ if (separator !== -1) {
33
+ const left = raw.slice(0, separator).trim();
34
+ const right = raw.slice(separator + 2).trim();
35
+ if (left.length > 0 && !/\s/.test(left)) {
36
+ return { planId: left, content: right };
37
+ }
38
+ }
39
+ return { content: raw.trim() };
40
+ }
41
+
42
+ // Single shared allowlist with the submit tool: only the explicit developer
43
+ // context passes. All seven orchestration roles plus unknown plus none plus
44
+ // unset fail closed.
45
+ export function isSteerAllowedAgent(agent) {
46
+ return STEERING_TOOL_ACCESS.get(STEERING_TOOLS.submit)?.has(agent) === true;
47
+ }
48
+
49
+ // Resolve the session agent via the SDK client, fail closed to undefined on
50
+ // any unresolvable shape. Prefers session messages (UserMessage.agent) then
51
+ // falls back to session get; any throw or missing agent is unresolvable.
52
+ export async function resolveSessionAgentViaClient(client, sessionID) {
53
+ if (!client || typeof sessionID !== "string" || sessionID.length === 0) return undefined;
54
+ try {
55
+ const session = client?.session;
56
+ if (!session) return undefined;
57
+ if (typeof session.messages === "function") {
58
+ const result = await session.messages({ path: { id: sessionID } });
59
+ const data = result?.data ?? result;
60
+ if (Array.isArray(data)) {
61
+ for (let index = data.length - 1; index >= 0; index -= 1) {
62
+ const agent = data[index]?.info?.agent;
63
+ if (typeof agent === "string" && agent.length > 0) return agent;
64
+ }
65
+ }
66
+ }
67
+ if (typeof session.get === "function") {
68
+ const result = await session.get({ path: { id: sessionID } });
69
+ const data = result?.data ?? result;
70
+ const agent = data?.agent ?? data?.info?.agent;
71
+ if (typeof agent === "string" && agent.length > 0) return agent;
72
+ }
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ function pushPart(output, text) {
80
+ const part = { type: "text", text };
81
+ if (output && Array.isArray(output.parts)) {
82
+ output.parts.push(part);
83
+ return;
84
+ }
85
+ if (output && output.parts === undefined) {
86
+ output.parts = [part];
87
+ }
88
+ }
89
+
90
+ // command.execute.before intercept for /steer. Performs the write directly
91
+ // through the existing state service, enforces the shared Developer-only
92
+ // allowlist, then throws to abort before any model turn. The throw carries
93
+ // the confirmation (steering id plus resolved target); output.parts carries
94
+ // the same text as a minimal fallback if error styling is unacceptable.
95
+ export function createSteerCommandHook({ client, stateOptions = {}, resolveAgent } = {}) {
96
+ const resolve = resolveAgent ?? ((sessionID) => resolveSessionAgentViaClient(client, sessionID));
97
+ return async function steerCommandBefore(input, output) {
98
+ if (!input || input.command !== STEER_COMMAND_NAME) return;
99
+ let agent;
100
+ try {
101
+ agent = await resolve(input.sessionID);
102
+ } catch {
103
+ agent = undefined;
104
+ }
105
+ if (typeof agent !== "string" || !isSteerAllowedAgent(agent)) {
106
+ const denial = `Steering denied: agent ${JSON.stringify(agent ?? "unknown")} may not use /${STEER_COMMAND_NAME}. Developer context only.`;
107
+ pushPart(output, denial);
108
+ throw new Error(denial);
109
+ }
110
+ const parsed = parseSteerArguments(input.arguments);
111
+ const state = createStateService(stateOptions);
112
+ const payload =
113
+ parsed.planId === undefined ? { content: parsed.content } : { planId: parsed.planId, content: parsed.content };
114
+ const result = await state.submitSteering(payload);
115
+ if (!result.ok) {
116
+ const failure = `Steering failed [${result.error.code}]: ${result.error.message}`;
117
+ pushPart(output, failure);
118
+ throw new Error(failure);
119
+ }
120
+ const confirmation = `Steering recorded: ${result.entry.id} for target ${result.entry.planId}#${result.entry.sequence}`;
121
+ pushPart(output, confirmation);
122
+ throw new Error(confirmation);
123
+ };
124
+ }