@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 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 };
@@ -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