@hydraharness/harness-plan-mode 0.1.1-rc.6
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 +98 -0
- package/lib/index.js +429 -0
- package/lib/invariant.js +41 -0
- package/lib/types/client.d.ts +10 -0
- package/lib/types/client.js +10 -0
- package/lib/types/index.d.ts +145 -0
- package/lib/types/index.js +449 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/invariant.js +43 -0
- package/lib/types/types.d.ts +28 -0
- package/lib/types/types.js +11 -0
- package/package.json +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
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,98 @@
|
|
|
1
|
+
# @hydraharness/harness-plan-mode
|
|
2
|
+
|
|
3
|
+
Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy enforce restrictions independently and do not read or write plan state.
|
|
4
|
+
|
|
5
|
+
Plan state follows the selected transcript version; selecting a version clears uncommitted local plan intents.
|
|
6
|
+
|
|
7
|
+
## Durable state
|
|
8
|
+
|
|
9
|
+
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
|
|
10
|
+
|
|
11
|
+
`ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
|
|
12
|
+
|
|
13
|
+
## Model and human interactions
|
|
14
|
+
|
|
15
|
+
While active, `plan:policy` renders the configured `section`. The plugin always registers `exit_plan_mode`, keeping tool schemas stable across the transition; its execute path accepts only active plan mode and leaves it only after an exact user approval through `ctx.userQuestions`.
|
|
16
|
+
|
|
17
|
+
The review question declares the `plan-review` presentation intent, naming `Approve` as the label that approves it, so a capable UI presents the plan as a decision instead of a generic question; the answer the tool reads is the same either way. A dismissed review — the user closing the request to speak instead — is reported to the model as such, telling it to stay in plan mode and wait for the message; every other review failure keeps the seam's own message.
|
|
18
|
+
|
|
19
|
+
When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request. The command declares `input.images`: composer image attachments ride the steered message ahead of its text block. Bare `/plan` with images steers an image-only user message, while `/plan off` with images returns a direct error before any mode change so the composer keeps them.
|
|
20
|
+
|
|
21
|
+
The Web client consumes the plugin-owned `/plan` command; other entry points may drive the same service directly without defining a second mode vocabulary.
|
|
22
|
+
|
|
23
|
+
## Session projection
|
|
24
|
+
|
|
25
|
+
When the composition mounts `ctx.sessionProjections` ([`@hydraharness/harness-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
- id: plan-mode
|
|
31
|
+
name: '@hydraharness/harness-plan-mode'
|
|
32
|
+
config:
|
|
33
|
+
section: |
|
|
34
|
+
You are in plan mode. Explore and design before presenting the complete
|
|
35
|
+
plan through exit_plan_mode.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy.
|
|
39
|
+
|
|
40
|
+
Design: [plan-specific collaboration state](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
|
|
41
|
+
|
|
42
|
+
## Model Experience
|
|
43
|
+
|
|
44
|
+
### Plan policy system prompt
|
|
45
|
+
|
|
46
|
+
#### What the model sees
|
|
47
|
+
|
|
48
|
+
While plan mode is active, the model sees the deployment's exact `section` text at prompt order 50; inactive mode contributes no text.
|
|
49
|
+
|
|
50
|
+
##### Configuration example
|
|
51
|
+
|
|
52
|
+
```markdown
|
|
53
|
+
You are in plan mode. Explore and design before presenting the complete plan through exit_plan_mode.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### Token effect
|
|
57
|
+
|
|
58
|
+
Inactive mode adds no tokens; active mode adds the configured section to every request.
|
|
59
|
+
|
|
60
|
+
#### KV Cache effect
|
|
61
|
+
|
|
62
|
+
The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward.
|
|
63
|
+
|
|
64
|
+
### Human command
|
|
65
|
+
|
|
66
|
+
#### What the model sees
|
|
67
|
+
|
|
68
|
+
`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it.
|
|
69
|
+
|
|
70
|
+
#### Token effect
|
|
71
|
+
|
|
72
|
+
The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice.
|
|
73
|
+
|
|
74
|
+
#### KV Cache effect
|
|
75
|
+
|
|
76
|
+
The user block is append-only conversation growth. Entering or leaving plan mode changes the earlier policy section; a narrated exit notice is appended after the reusable request prefix.
|
|
77
|
+
|
|
78
|
+
### Exit tool schema and review exchange
|
|
79
|
+
|
|
80
|
+
#### What the model sees
|
|
81
|
+
|
|
82
|
+
The [`exit_plan_mode` schema](../../../docs/tool-catalog.md#hydraharness-plan-mode) remains available in both states; execution outside plan mode fails, while an approved in-mode review returns the canonical `{ approved: true }` value and renders the existing confirmation text. Rejection remains a failed call carrying review feedback, and a dismissed review a failed call naming the user's takeover.
|
|
83
|
+
|
|
84
|
+
#### Token effect
|
|
85
|
+
|
|
86
|
+
The stable schema is paid according to ToolRuntime mode, and each plan argument and review result remains in conversation history.
|
|
87
|
+
|
|
88
|
+
#### KV Cache effect
|
|
89
|
+
|
|
90
|
+
Mode transitions do not change the tool catalog; plan arguments and review results extend the conversation normally.
|
|
91
|
+
|
|
92
|
+
## Known Limitations and Deferred Work
|
|
93
|
+
|
|
94
|
+
- Plan mode guides rather than enforces; deployments that need enforced restrictions must configure sandbox and approval controls independently.
|
|
95
|
+
- A selection made after the turn's final accepted pre-step is lost if the process exits before another accepted in-turn pre-step, so the UI must reapply it.
|
|
96
|
+
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
|
|
97
|
+
- A live child owned by another agent cannot open the `exit_plan_mode` review. The failed call tells the child to include the unresolved decision in its final result; durable fork lineage alone does not prevent a session resumed as a runtime root from opening the review.
|
|
98
|
+
- Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow.
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { Service } from "@hydraharness/cordis";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createUserMessage } from "@hydraharness/harness-llm";
|
|
4
|
+
import { defineTool } from "@hydraharness/harness-tools";
|
|
5
|
+
import { UserQuestionError } from "@hydraharness/harness-user-questions";
|
|
6
|
+
//#region lib/types/index.js
|
|
7
|
+
/**
|
|
8
|
+
* Plan mode is logged per-agent collaboration state: while active, a
|
|
9
|
+
* deployment-owned guidance section is included in each model request, and
|
|
10
|
+
* `exit_plan_mode` presents the completed plan for user review, while the
|
|
11
|
+
* `/plan off` command lets a user leave directly. Sandbox mode and approval
|
|
12
|
+
* policy enforce restrictions independently and do not read or write plan
|
|
13
|
+
* state.
|
|
14
|
+
*
|
|
15
|
+
* The state in force is folded from the session log (`plan/mode`, last one
|
|
16
|
+
* wins), so resume and fork restore it without a live mirror. User selections
|
|
17
|
+
* remain pending until the next accepted in-turn pre-step. The service includes
|
|
18
|
+
* the selected state in the proposed step assembly, then appends `plan/mode`
|
|
19
|
+
* from `agent/pre-step` only when the step is accepted. Same-step request
|
|
20
|
+
* retries reuse their assembly.
|
|
21
|
+
*
|
|
22
|
+
* The exit tool remains registered while plan mode is inactive, so entering
|
|
23
|
+
* or leaving plan mode changes only the prompt section, not the request tool
|
|
24
|
+
* catalog.
|
|
25
|
+
*
|
|
26
|
+
* Agent Note:
|
|
27
|
+
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
|
|
28
|
+
*
|
|
29
|
+
* @module @hydraharness/harness-plan-mode
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The model-facing exit tool's name. It stays registered while plan mode is
|
|
33
|
+
* inactive so the request tool catalog is stable across transitions.
|
|
34
|
+
*/
|
|
35
|
+
const EXIT_PLAN_MODE = "exit_plan_mode";
|
|
36
|
+
/** The review question's id, echoed in the answer this tool reads. */
|
|
37
|
+
const REVIEW_ID = "plan-review";
|
|
38
|
+
/** The review question's approve option label. */
|
|
39
|
+
const APPROVE_LABEL = "Approve";
|
|
40
|
+
/** The review question's keep-planning option label. */
|
|
41
|
+
const KEEP_PLANNING_LABEL = "Keep planning";
|
|
42
|
+
const EXIT_DESCRIPTION = "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.";
|
|
43
|
+
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
|
|
44
|
+
function firstHeading(plan) {
|
|
45
|
+
for (const line of plan.split("\n")) {
|
|
46
|
+
const match = /^#{1,6}\s+(.+?)\s*$/.exec(line);
|
|
47
|
+
if (match) return match[1];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate deployment-owned plan guidance. Missing, blank, non-string, or
|
|
52
|
+
* unknown fields fail at plugin load rather than being ignored.
|
|
53
|
+
*
|
|
54
|
+
* @param config Raw plugin config.
|
|
55
|
+
* @returns A detached validated config.
|
|
56
|
+
*/
|
|
57
|
+
function resolveConfig(config) {
|
|
58
|
+
const section = config.section;
|
|
59
|
+
if (typeof section !== "string") throw new Error("PlanModeConfig needs a string `section`");
|
|
60
|
+
if (section.trim() === "") throw new Error("PlanModeConfig needs a non-empty `section`");
|
|
61
|
+
const unknown = Object.keys(config).filter((key) => key !== "section");
|
|
62
|
+
if (unknown.length > 0) throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(", ")} — config is { section }`);
|
|
63
|
+
return { section };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether plan mode is active after the first `end` events. The last
|
|
67
|
+
* `plan/mode` wins; a prefix with none is inactive.
|
|
68
|
+
*
|
|
69
|
+
* @param events The session log or any prefix of it.
|
|
70
|
+
* @param end Fold `events[0, end)`; defaults to the whole log.
|
|
71
|
+
* @returns Whether plan mode is active.
|
|
72
|
+
*/
|
|
73
|
+
function foldPlanMode(events, end = events.length) {
|
|
74
|
+
let active = false;
|
|
75
|
+
let index = 0;
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
if (index >= end) break;
|
|
78
|
+
index++;
|
|
79
|
+
if (event.type === "plan/mode") active = event.data.active;
|
|
80
|
+
}
|
|
81
|
+
return active;
|
|
82
|
+
}
|
|
83
|
+
const planUnitStateSchema = z.object({
|
|
84
|
+
active: z.boolean(),
|
|
85
|
+
wanted: z.boolean().nullable(),
|
|
86
|
+
running: z.object({
|
|
87
|
+
commandId: z.string(),
|
|
88
|
+
wanted: z.boolean()
|
|
89
|
+
}).strict().nullable()
|
|
90
|
+
}).strict();
|
|
91
|
+
/** Wire payload schema of the `plan` projection. */
|
|
92
|
+
const planProjectionSchema = z.object({
|
|
93
|
+
active: z.boolean(),
|
|
94
|
+
pending: z.boolean()
|
|
95
|
+
});
|
|
96
|
+
/** Whether the log holds an opened turn without its closing `turn/end`. */
|
|
97
|
+
function hasOpenTurn(events) {
|
|
98
|
+
let open = false;
|
|
99
|
+
for (const event of events) if (event.type === "turn/start") open = true;
|
|
100
|
+
else if (event.type === "turn/end") open = false;
|
|
101
|
+
return open;
|
|
102
|
+
}
|
|
103
|
+
/** Plan state at the last logged request header, or `undefined` before the first header. */
|
|
104
|
+
function planModeAtLastHeader(events) {
|
|
105
|
+
let lastHeader = -1;
|
|
106
|
+
let index = 0;
|
|
107
|
+
for (const event of events) {
|
|
108
|
+
if (event.type === "request/header") lastHeader = index;
|
|
109
|
+
index++;
|
|
110
|
+
}
|
|
111
|
+
if (lastHeader < 0) return void 0;
|
|
112
|
+
return foldPlanMode(events, lastHeader + 1);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
|
|
116
|
+
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
|
|
117
|
+
* UIs observe committed flips through `session/event`; there is no live mirror.
|
|
118
|
+
*/
|
|
119
|
+
var PlanModeController = class extends Service {
|
|
120
|
+
static inject = ["tools", "systemPrompt"];
|
|
121
|
+
/** Validated deployment-owned guidance. */
|
|
122
|
+
section;
|
|
123
|
+
/**
|
|
124
|
+
* Latest selection per session awaiting the next accepted in-turn pre-step.
|
|
125
|
+
* `narrate` is true for user selections and false for the exit tool, whose
|
|
126
|
+
* result already narrates the transition.
|
|
127
|
+
*/
|
|
128
|
+
pendingIntents = /* @__PURE__ */ new WeakMap();
|
|
129
|
+
constructor(ctx, config = { section: "" }) {
|
|
130
|
+
super(ctx, "planMode");
|
|
131
|
+
this.section = resolveConfig(config).section;
|
|
132
|
+
ctx.on("session/event", (session, event) => {
|
|
133
|
+
if (event.type === "session/version" || event.type === "session/version-selected") this.pendingIntents.delete(session);
|
|
134
|
+
});
|
|
135
|
+
let disposed = false;
|
|
136
|
+
ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
|
|
137
|
+
const decision = await next();
|
|
138
|
+
const pending = this.pendingIntents.get(agent.session);
|
|
139
|
+
if (decision.kind === "reject" || signal.aborted || pending === void 0) return decision;
|
|
140
|
+
const narration = this.narration(agent.session, pending.active);
|
|
141
|
+
try {
|
|
142
|
+
this.onBoundary(agent.session);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
ctx.logger.warn("hydra-plan-mode: failed to append selected plan mode at step start: %o", error);
|
|
145
|
+
return decision;
|
|
146
|
+
}
|
|
147
|
+
return !pending.narrate || narration === void 0 ? decision : {
|
|
148
|
+
...decision,
|
|
149
|
+
messages: [...decision.messages, narration]
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
ctx.effect(() => () => {
|
|
153
|
+
disposed = true;
|
|
154
|
+
}, "hydra-plan-mode: close service lifetime");
|
|
155
|
+
ctx.systemPrompt.section({
|
|
156
|
+
name: "plan:policy",
|
|
157
|
+
order: 50,
|
|
158
|
+
text: (context) => {
|
|
159
|
+
if (context.agent === void 0) return "";
|
|
160
|
+
return this.pendingIntents.get(context.agent.session)?.active ?? foldPlanMode(context.agent.session.activeEvents) ? this.section : "";
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
ctx.inject(["sessionProjections"], (projectionCtx) => {
|
|
164
|
+
projectionCtx.sessionProjections.register({
|
|
165
|
+
key: "plan",
|
|
166
|
+
history: "active-version",
|
|
167
|
+
stateSchema: planUnitStateSchema,
|
|
168
|
+
init: () => ({
|
|
169
|
+
active: false,
|
|
170
|
+
wanted: null,
|
|
171
|
+
running: null
|
|
172
|
+
}),
|
|
173
|
+
apply: (state, event) => {
|
|
174
|
+
if (event.type === "command/run" && event.data.name === "plan") {
|
|
175
|
+
if (event.data.args === void 0) return state;
|
|
176
|
+
const wanted = event.data.args.trim() !== "off";
|
|
177
|
+
return {
|
|
178
|
+
...state,
|
|
179
|
+
running: {
|
|
180
|
+
commandId: event.data.commandId,
|
|
181
|
+
wanted
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (event.type === "command/done" && event.data.commandId === state.running?.commandId) {
|
|
186
|
+
const wanted = event.data.kind === "success" && state.running.wanted !== state.active ? state.running.wanted : null;
|
|
187
|
+
return {
|
|
188
|
+
...state,
|
|
189
|
+
wanted,
|
|
190
|
+
running: null
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (event.type === "plan/mode") return {
|
|
194
|
+
...state,
|
|
195
|
+
active: event.data.active,
|
|
196
|
+
wanted: null
|
|
197
|
+
};
|
|
198
|
+
return state;
|
|
199
|
+
},
|
|
200
|
+
wire: {
|
|
201
|
+
viewSchema: planProjectionSchema,
|
|
202
|
+
view: (state) => {
|
|
203
|
+
const wanted = state.running?.wanted ?? state.wanted;
|
|
204
|
+
return {
|
|
205
|
+
active: state.active,
|
|
206
|
+
pending: wanted !== null && wanted !== state.active
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
stateVersion: 3
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
ctx.inject(["commands"], (commandCtx) => {
|
|
214
|
+
commandCtx.commands.register({
|
|
215
|
+
name: "plan",
|
|
216
|
+
description: "Enter or leave plan mode",
|
|
217
|
+
input: {
|
|
218
|
+
hint: "[off|message]",
|
|
219
|
+
images: true
|
|
220
|
+
},
|
|
221
|
+
handler: ({ agent, rawInput, attachments }) => {
|
|
222
|
+
const message = rawInput.trim();
|
|
223
|
+
if (message === "off" && attachments.length > 0) return {
|
|
224
|
+
kind: "error",
|
|
225
|
+
text: "Image attachments cannot accompany /plan off."
|
|
226
|
+
};
|
|
227
|
+
if (message === "off") switch (this.set(agent, false)) {
|
|
228
|
+
case "committed": return {
|
|
229
|
+
kind: "success",
|
|
230
|
+
text: "Plan mode off."
|
|
231
|
+
};
|
|
232
|
+
case "queued": return {
|
|
233
|
+
kind: "success",
|
|
234
|
+
text: "Leaving plan mode (applies from the next step)."
|
|
235
|
+
};
|
|
236
|
+
case "cancelled": return {
|
|
237
|
+
kind: "success",
|
|
238
|
+
text: "Plan mode entry cancelled."
|
|
239
|
+
};
|
|
240
|
+
case "noop": return foldPlanMode(agent.session.activeEvents) ? {
|
|
241
|
+
kind: "success",
|
|
242
|
+
text: "Leaving plan mode (applies from the next step)."
|
|
243
|
+
} : {
|
|
244
|
+
kind: "success",
|
|
245
|
+
text: "Plan mode is already inactive."
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const outcome = this.set(agent, true);
|
|
249
|
+
if (message !== "" || attachments.length > 0) agent.steer(createUserMessage({
|
|
250
|
+
content: [...attachments, ...message === "" ? [] : [{
|
|
251
|
+
type: "text",
|
|
252
|
+
text: message
|
|
253
|
+
}]],
|
|
254
|
+
source: { kind: "user" }
|
|
255
|
+
}));
|
|
256
|
+
return {
|
|
257
|
+
kind: "success",
|
|
258
|
+
text: outcome === "committed" ? "Plan mode on. Use /plan off to leave." : "Entering plan mode (applies from the next step). Use /plan off to leave."
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
ctx.tools.register(defineTool({
|
|
264
|
+
name: EXIT_PLAN_MODE,
|
|
265
|
+
description: EXIT_DESCRIPTION,
|
|
266
|
+
parameters: { plan: {
|
|
267
|
+
type: "string",
|
|
268
|
+
required: true,
|
|
269
|
+
description: "The complete plan, as markdown, starting with a # heading that names it."
|
|
270
|
+
} },
|
|
271
|
+
output: {
|
|
272
|
+
schema: {
|
|
273
|
+
type: "object",
|
|
274
|
+
additionalProperties: false,
|
|
275
|
+
properties: { approved: {
|
|
276
|
+
type: "boolean",
|
|
277
|
+
const: true,
|
|
278
|
+
required: true
|
|
279
|
+
} }
|
|
280
|
+
},
|
|
281
|
+
render: () => [{
|
|
282
|
+
type: "text",
|
|
283
|
+
text: "Plan approved — plan mode exited; carry out the plan starting with your next step."
|
|
284
|
+
}]
|
|
285
|
+
},
|
|
286
|
+
execute: async (args, exec) => {
|
|
287
|
+
const agent = exec.agent;
|
|
288
|
+
if (agent === void 0) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`);
|
|
289
|
+
if (!foldPlanMode(agent.session.activeEvents)) throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`);
|
|
290
|
+
if (!/^#\s+\S/.test(args.plan.trim())) throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`);
|
|
291
|
+
const interaction = ctx.get("userQuestions");
|
|
292
|
+
if (interaction === void 0) throw new Error("no user-questions channel is available to review the plan; ask the user to switch the session mode instead");
|
|
293
|
+
const answer = await interaction.ask({
|
|
294
|
+
questions: [{
|
|
295
|
+
id: REVIEW_ID,
|
|
296
|
+
header: "Plan review",
|
|
297
|
+
question: "Approve this plan and leave plan mode?",
|
|
298
|
+
detail: args.plan,
|
|
299
|
+
options: [{
|
|
300
|
+
label: APPROVE_LABEL,
|
|
301
|
+
description: "Leave plan mode; the plan is carried out from the next step."
|
|
302
|
+
}, {
|
|
303
|
+
label: KEEP_PLANNING_LABEL,
|
|
304
|
+
description: "Stay in plan mode; feedback goes back to the model."
|
|
305
|
+
}],
|
|
306
|
+
intent: {
|
|
307
|
+
kind: "plan-review",
|
|
308
|
+
approve: APPROVE_LABEL
|
|
309
|
+
}
|
|
310
|
+
}],
|
|
311
|
+
agent,
|
|
312
|
+
signal: exec.signal
|
|
313
|
+
}).catch((cause) => {
|
|
314
|
+
if (cause instanceof UserQuestionError && cause.code === "ASK_CANCELLED") throw new Error("The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.");
|
|
315
|
+
throw cause;
|
|
316
|
+
});
|
|
317
|
+
if (disposed) throw new Error("the plan-mode service was reloaded while the plan was under review; present the plan again");
|
|
318
|
+
const reviewItems = answer.answers.filter((entry) => entry.id === REVIEW_ID);
|
|
319
|
+
const item = reviewItems.length === 1 ? reviewItems[0] : void 0;
|
|
320
|
+
if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== void 0) {
|
|
321
|
+
const feedback = item?.custom ?? "";
|
|
322
|
+
throw new Error(feedback === "" ? "The user chose to keep planning; revise the plan and present it again." : `The user chose to keep planning; their feedback: ${feedback}`);
|
|
323
|
+
}
|
|
324
|
+
this.pendingIntents.set(agent.session, {
|
|
325
|
+
active: false,
|
|
326
|
+
narrate: false
|
|
327
|
+
});
|
|
328
|
+
return { approved: true };
|
|
329
|
+
},
|
|
330
|
+
presentCall: (args) => ({
|
|
331
|
+
card: "generic",
|
|
332
|
+
title: firstHeading(args.plan) ?? "Plan",
|
|
333
|
+
kind: "other",
|
|
334
|
+
content: [{
|
|
335
|
+
type: "text",
|
|
336
|
+
text: args.plan
|
|
337
|
+
}]
|
|
338
|
+
}),
|
|
339
|
+
presentResult: (_args, result) => ({
|
|
340
|
+
card: "generic",
|
|
341
|
+
title: "Plan review",
|
|
342
|
+
content: result.content
|
|
343
|
+
})
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Read the logged plan state and any selected state awaiting the next
|
|
348
|
+
* accepted in-turn pre-step.
|
|
349
|
+
*
|
|
350
|
+
* @param agent The agent to read.
|
|
351
|
+
* @returns Current logged state plus a pending selection, when present.
|
|
352
|
+
*/
|
|
353
|
+
get(agent) {
|
|
354
|
+
const active = foldPlanMode(agent.session.activeEvents);
|
|
355
|
+
const pending = this.pendingIntents.get(agent.session);
|
|
356
|
+
return pending === void 0 ? { active } : {
|
|
357
|
+
active,
|
|
358
|
+
pending: pending.active
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Select whether plan mode should be active. Between turns the method
|
|
363
|
+
* appends the change immediately because no in-turn pre-step will run until
|
|
364
|
+
* another prompt starts a turn. The open-turn fold is the idle signal:
|
|
365
|
+
* agent status stays `running` through post-turn checkpointing, when no
|
|
366
|
+
* further in-turn pre-step runs. During an open turn the selection remains
|
|
367
|
+
* pending until the next accepted in-turn pre-step. Repeated selection of
|
|
368
|
+
* the current or already-pending state is a no-op.
|
|
369
|
+
*
|
|
370
|
+
* @param agent The agent to switch.
|
|
371
|
+
* @param active Whether plan mode should be active.
|
|
372
|
+
* @returns what happened: `committed` (logged now), `queued` (awaiting the
|
|
373
|
+
* next accepted in-turn pre-step), `cancelled` (an opposite pending selection
|
|
374
|
+
* was cleared; the logged state already matches), or `noop` (already in that
|
|
375
|
+
* state).
|
|
376
|
+
*/
|
|
377
|
+
set(agent, active) {
|
|
378
|
+
const session = agent.session;
|
|
379
|
+
if (active === (this.pendingIntents.get(session)?.active ?? foldPlanMode(session.activeEvents))) return "noop";
|
|
380
|
+
if (hasOpenTurn(session.activeEvents)) {
|
|
381
|
+
this.pendingIntents.set(session, {
|
|
382
|
+
active,
|
|
383
|
+
narrate: true
|
|
384
|
+
});
|
|
385
|
+
return foldPlanMode(session.activeEvents) === active ? "cancelled" : "queued";
|
|
386
|
+
}
|
|
387
|
+
if (active === foldPlanMode(session.activeEvents)) {
|
|
388
|
+
this.pendingIntents.delete(session);
|
|
389
|
+
return "cancelled";
|
|
390
|
+
}
|
|
391
|
+
session.append("plan/mode", { active });
|
|
392
|
+
this.pendingIntents.delete(session);
|
|
393
|
+
const narration = this.narration(session, active);
|
|
394
|
+
if (narration !== void 0) agent.inject(narration);
|
|
395
|
+
return "committed";
|
|
396
|
+
}
|
|
397
|
+
/** Append one pending selection before the next request assembly. */
|
|
398
|
+
onBoundary(session) {
|
|
399
|
+
const pending = this.pendingIntents.get(session);
|
|
400
|
+
if (pending === void 0) return;
|
|
401
|
+
const target = pending.active;
|
|
402
|
+
if (target === foldPlanMode(session.activeEvents)) {
|
|
403
|
+
this.pendingIntents.delete(session);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
session.append("plan/mode", { active: target });
|
|
407
|
+
this.pendingIntents.delete(session);
|
|
408
|
+
}
|
|
409
|
+
/** Build a user-switch notice when the last logged header described the other mode. */
|
|
410
|
+
narration(session, target) {
|
|
411
|
+
const told = planModeAtLastHeader(session.activeEvents);
|
|
412
|
+
if (told === void 0 || told === target) return;
|
|
413
|
+
const text = target ? "The user switched this session to plan mode." : "The user switched this session back to the default mode.";
|
|
414
|
+
return createUserMessage({
|
|
415
|
+
content: [{
|
|
416
|
+
type: "text",
|
|
417
|
+
text
|
|
418
|
+
}],
|
|
419
|
+
source: {
|
|
420
|
+
kind: "plugin",
|
|
421
|
+
plugin: "plan-mode",
|
|
422
|
+
form: "notice",
|
|
423
|
+
summary: text
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
//#endregion
|
|
429
|
+
export { EXIT_PLAN_MODE, PlanModeController, PlanModeController as default, foldPlanMode, resolveConfig };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/** Package-owned durable plan-mode invariants. @module @hydraharness/harness-plan-mode/invariant */
|
|
3
|
+
const PACKAGE_NAME = "@hydraharness/harness-plan-mode";
|
|
4
|
+
/** Cordis companion plugin name. */
|
|
5
|
+
const name = "plan-mode-invariant";
|
|
6
|
+
/** Service required before the companion can reserve package ownership. */
|
|
7
|
+
const inject = ["invariants"];
|
|
8
|
+
/**
|
|
9
|
+
* Validate one `plan/mode` event before it reaches the durable log.
|
|
10
|
+
* `plan/mode` is a standalone whole-value event: an idle selection commits
|
|
11
|
+
* between turns and a mid-turn selection commits at the step boundary, so
|
|
12
|
+
* no turn-enclosure relation exists — only the payload shape is checkable.
|
|
13
|
+
*/
|
|
14
|
+
function validateEvent(event, fail) {
|
|
15
|
+
if (event.type !== "plan/mode") return;
|
|
16
|
+
const active = event.data.active;
|
|
17
|
+
if (typeof active !== "boolean") fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`);
|
|
18
|
+
}
|
|
19
|
+
/** Install validation for loaded and newly appended plan-mode state. */
|
|
20
|
+
const install = Object.assign((ctx, fail) => {
|
|
21
|
+
const seed = (session) => {
|
|
22
|
+
for (const event of session.events) validateEvent(event, fail);
|
|
23
|
+
};
|
|
24
|
+
for (const session of ctx.sessions.list()) seed(session);
|
|
25
|
+
ctx.on("session/created", (session) => {
|
|
26
|
+
seed(session);
|
|
27
|
+
}, { global: true });
|
|
28
|
+
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
29
|
+
if (eventName !== "session/event") return;
|
|
30
|
+
const [, event] = args;
|
|
31
|
+
validateEvent(event, fail);
|
|
32
|
+
}, { global: true });
|
|
33
|
+
}, { inject: ["sessions"] });
|
|
34
|
+
/**
|
|
35
|
+
* Register the plan-mode invariant companion.
|
|
36
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
37
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
38
|
+
*/
|
|
39
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
40
|
+
//#endregion
|
|
41
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-namespace projection of the plan domain: a pure re-export of the package's
|
|
3
|
+
* types outlet. Client code imports ONLY the client namespace (repo
|
|
4
|
+
* discipline), so `./client` projects the same single-source content
|
|
5
|
+
* `./types` serves to host consumers — zero duplication.
|
|
6
|
+
*
|
|
7
|
+
* @module @hydraharness/harness-plan-mode/client
|
|
8
|
+
*/
|
|
9
|
+
export type * from './types.ts';
|
|
10
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-namespace projection of the plan domain: a pure re-export of the package's
|
|
3
|
+
* types outlet. Client code imports ONLY the client namespace (repo
|
|
4
|
+
* discipline), so `./client` projects the same single-source content
|
|
5
|
+
* `./types` serves to host consumers — zero duplication.
|
|
6
|
+
*
|
|
7
|
+
* @module @hydraharness/harness-plan-mode/client
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=client.js.map
|