@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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Plan mode is logged per-agent collaboration state: while active, a
3
+ * deployment-owned guidance section is included in each model request, and
4
+ * `exit_plan_mode` presents the completed plan for user review, while the
5
+ * `/plan off` command lets a user leave directly. Sandbox mode and approval
6
+ * policy enforce restrictions independently and do not read or write plan
7
+ * state.
8
+ *
9
+ * The state in force is folded from the session log (`plan/mode`, last one
10
+ * wins), so resume and fork restore it without a live mirror. User selections
11
+ * remain pending until the next accepted in-turn pre-step. The service includes
12
+ * the selected state in the proposed step assembly, then appends `plan/mode`
13
+ * from `agent/pre-step` only when the step is accepted. Same-step request
14
+ * retries reuse their assembly.
15
+ *
16
+ * The exit tool remains registered while plan mode is inactive, so entering
17
+ * or leaving plan mode changes only the prompt section, not the request tool
18
+ * catalog.
19
+ *
20
+ * Agent Note:
21
+ * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
22
+ *
23
+ * @module @hydraharness/harness-plan-mode
24
+ */
25
+ import { Context, Service } from '@hydraharness/cordis';
26
+ import type { Agent } from '@hydraharness/harness-agent';
27
+ import type { SessionEvent } from '@hydraharness/harness-session';
28
+ import type { CommandId } from '@hydraharness/harness-commands';
29
+ export type * from './types.ts';
30
+ declare module '@hydraharness/harness-session/types' {
31
+ interface SessionEventMap {
32
+ /**
33
+ * Whether plan mode is in force from this point on: log-only, non-surface,
34
+ * whole-value replace. The last `plan/mode` wins; a log with none folds to
35
+ * inactive through {@link foldPlanMode}.
36
+ */
37
+ 'plan/mode': {
38
+ active: boolean;
39
+ };
40
+ }
41
+ }
42
+ declare module '@hydraharness/cordis' {
43
+ interface Context {
44
+ planMode: PlanModeController;
45
+ }
46
+ }
47
+ /**
48
+ * The model-facing exit tool's name. It stays registered while plan mode is
49
+ * inactive so the request tool catalog is stable across transitions.
50
+ */
51
+ export declare const EXIT_PLAN_MODE = "exit_plan_mode";
52
+ /** Deployment-owned plan guidance. */
53
+ export interface PlanModeConfig {
54
+ /** Guidance rendered as the `plan:policy` prompt section while plan mode is active. */
55
+ section: string;
56
+ }
57
+ /**
58
+ * Validate deployment-owned plan guidance. Missing, blank, non-string, or
59
+ * unknown fields fail at plugin load rather than being ignored.
60
+ *
61
+ * @param config Raw plugin config.
62
+ * @returns A detached validated config.
63
+ */
64
+ export declare function resolveConfig(config: PlanModeConfig): PlanModeConfig;
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
+ export declare function foldPlanMode(events: readonly SessionEvent[], end?: number): boolean;
74
+ /**
75
+ * Projection unit state: the logged mode, the latest successful `/plan`
76
+ * selection not yet resolved by a `plan/mode` commit, and an execution whose
77
+ * paired `command/done` has not settled. Plain JSON (persisted-cache
78
+ * precondition).
79
+ */
80
+ interface PlanUnitState {
81
+ active: boolean;
82
+ /** The selection's target mode; null when no selection is outstanding. */
83
+ wanted: boolean | null;
84
+ /** The latest plan command awaiting its paired settlement. */
85
+ running: {
86
+ commandId: CommandId;
87
+ wanted: boolean;
88
+ } | null;
89
+ }
90
+ declare module '@hydraharness/harness-session-projection/types' {
91
+ interface SessionProjectionStateMap {
92
+ plan: PlanUnitState;
93
+ }
94
+ }
95
+ /**
96
+ * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
97
+ * the `plan:policy` section, the `/plan` command, and the stable exit tool.
98
+ * UIs observe committed flips through `session/event`; there is no live mirror.
99
+ */
100
+ export declare class PlanModeController extends Service {
101
+ static inject: string[];
102
+ /** Validated deployment-owned guidance. */
103
+ private readonly section;
104
+ /**
105
+ * Latest selection per session awaiting the next accepted in-turn pre-step.
106
+ * `narrate` is true for user selections and false for the exit tool, whose
107
+ * result already narrates the transition.
108
+ */
109
+ private readonly pendingIntents;
110
+ constructor(ctx: Context, config?: PlanModeConfig);
111
+ /**
112
+ * Read the logged plan state and any selected state awaiting the next
113
+ * accepted in-turn pre-step.
114
+ *
115
+ * @param agent The agent to read.
116
+ * @returns Current logged state plus a pending selection, when present.
117
+ */
118
+ get(agent: Agent): {
119
+ active: boolean;
120
+ pending?: boolean;
121
+ };
122
+ /**
123
+ * Select whether plan mode should be active. Between turns the method
124
+ * appends the change immediately because no in-turn pre-step will run until
125
+ * another prompt starts a turn. The open-turn fold is the idle signal:
126
+ * agent status stays `running` through post-turn checkpointing, when no
127
+ * further in-turn pre-step runs. During an open turn the selection remains
128
+ * pending until the next accepted in-turn pre-step. Repeated selection of
129
+ * the current or already-pending state is a no-op.
130
+ *
131
+ * @param agent The agent to switch.
132
+ * @param active Whether plan mode should be active.
133
+ * @returns what happened: `committed` (logged now), `queued` (awaiting the
134
+ * next accepted in-turn pre-step), `cancelled` (an opposite pending selection
135
+ * was cleared; the logged state already matches), or `noop` (already in that
136
+ * state).
137
+ */
138
+ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop';
139
+ /** Append one pending selection before the next request assembly. */
140
+ private onBoundary;
141
+ /** Build a user-switch notice when the last logged header described the other mode. */
142
+ private narration;
143
+ }
144
+ export default PlanModeController;
145
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Plan mode is logged per-agent collaboration state: while active, a
3
+ * deployment-owned guidance section is included in each model request, and
4
+ * `exit_plan_mode` presents the completed plan for user review, while the
5
+ * `/plan off` command lets a user leave directly. Sandbox mode and approval
6
+ * policy enforce restrictions independently and do not read or write plan
7
+ * state.
8
+ *
9
+ * The state in force is folded from the session log (`plan/mode`, last one
10
+ * wins), so resume and fork restore it without a live mirror. User selections
11
+ * remain pending until the next accepted in-turn pre-step. The service includes
12
+ * the selected state in the proposed step assembly, then appends `plan/mode`
13
+ * from `agent/pre-step` only when the step is accepted. Same-step request
14
+ * retries reuse their assembly.
15
+ *
16
+ * The exit tool remains registered while plan mode is inactive, so entering
17
+ * or leaving plan mode changes only the prompt section, not the request tool
18
+ * catalog.
19
+ *
20
+ * Agent Note:
21
+ * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
22
+ *
23
+ * @module @hydraharness/harness-plan-mode
24
+ */
25
+ import { Service } from '@hydraharness/cordis';
26
+ import { z as zod } from 'zod';
27
+ import { createUserMessage } from '@hydraharness/harness-llm';
28
+ import { defineTool } from '@hydraharness/harness-tools';
29
+ import { UserQuestionError } from '@hydraharness/harness-user-questions';
30
+ /**
31
+ * The model-facing exit tool's name. It stays registered while plan mode is
32
+ * inactive so the request tool catalog is stable across transitions.
33
+ */
34
+ export const EXIT_PLAN_MODE = 'exit_plan_mode';
35
+ /** The review question's id, echoed in the answer this tool reads. */
36
+ const REVIEW_ID = 'plan-review';
37
+ /** The review question's approve option label. */
38
+ const APPROVE_LABEL = 'Approve';
39
+ /** The review question's keep-planning option label. */
40
+ const KEEP_PLANNING_LABEL = 'Keep planning';
41
+ const EXIT_DESCRIPTION = 'Use only in plan mode. Present your plan for the user\'s review and, on approval, leave plan mode. '
42
+ + 'Send the COMPLETE plan as markdown, starting with a # heading that names it. '
43
+ + 'The user may approve (carry out the plan from your next step) or keep '
44
+ + 'planning — their feedback comes back in the tool result; revise and present again.';
45
+ /** The plan's first markdown heading (any level), or `undefined` when it has none. */
46
+ function firstHeading(plan) {
47
+ for (const line of plan.split('\n')) {
48
+ const match = /^#{1,6}\s+(.+?)\s*$/.exec(line);
49
+ if (match)
50
+ return match[1];
51
+ }
52
+ return undefined;
53
+ }
54
+ /**
55
+ * Validate deployment-owned plan guidance. Missing, blank, non-string, or
56
+ * unknown fields fail at plugin load rather than being ignored.
57
+ *
58
+ * @param config Raw plugin config.
59
+ * @returns A detached validated config.
60
+ */
61
+ export function resolveConfig(config) {
62
+ const section = config.section;
63
+ if (typeof section !== 'string') {
64
+ throw new Error('PlanModeConfig needs a string `section`');
65
+ }
66
+ if (section.trim() === '') {
67
+ throw new Error('PlanModeConfig needs a non-empty `section`');
68
+ }
69
+ const unknown = Object.keys(config).filter(key => key !== 'section');
70
+ if (unknown.length > 0) {
71
+ throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(', ')} — config is { section }`);
72
+ }
73
+ return { section };
74
+ }
75
+ /**
76
+ * Whether plan mode is active after the first `end` events. The last
77
+ * `plan/mode` wins; a prefix with none is inactive.
78
+ *
79
+ * @param events The session log or any prefix of it.
80
+ * @param end Fold `events[0, end)`; defaults to the whole log.
81
+ * @returns Whether plan mode is active.
82
+ */
83
+ export function foldPlanMode(events, end = events.length) {
84
+ let active = false;
85
+ let index = 0;
86
+ for (const event of events) {
87
+ if (index >= end)
88
+ break;
89
+ index++;
90
+ if (event.type === 'plan/mode')
91
+ active = event.data.active;
92
+ }
93
+ return active;
94
+ }
95
+ const planUnitStateSchema = zod.object({
96
+ active: zod.boolean(),
97
+ wanted: zod.boolean().nullable(),
98
+ running: zod.object({
99
+ commandId: zod.string(),
100
+ wanted: zod.boolean(),
101
+ }).strict().nullable(),
102
+ }).strict();
103
+ /** Wire payload schema of the `plan` projection. */
104
+ const planProjectionSchema = zod.object({
105
+ active: zod.boolean(),
106
+ pending: zod.boolean(),
107
+ });
108
+ /** Whether the log holds an opened turn without its closing `turn/end`. */
109
+ function hasOpenTurn(events) {
110
+ let open = false;
111
+ for (const event of events) {
112
+ if (event.type === 'turn/start')
113
+ open = true;
114
+ else if (event.type === 'turn/end')
115
+ open = false;
116
+ }
117
+ return open;
118
+ }
119
+ /** Plan state at the last logged request header, or `undefined` before the first header. */
120
+ function planModeAtLastHeader(events) {
121
+ let lastHeader = -1;
122
+ let index = 0;
123
+ for (const event of events) {
124
+ if (event.type === 'request/header')
125
+ lastHeader = index;
126
+ index++;
127
+ }
128
+ if (lastHeader < 0)
129
+ return undefined;
130
+ return foldPlanMode(events, lastHeader + 1);
131
+ }
132
+ /**
133
+ * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
134
+ * the `plan:policy` section, the `/plan` command, and the stable exit tool.
135
+ * UIs observe committed flips through `session/event`; there is no live mirror.
136
+ */
137
+ export class PlanModeController extends Service {
138
+ static inject = ['tools', 'systemPrompt'];
139
+ /** Validated deployment-owned guidance. */
140
+ section;
141
+ /**
142
+ * Latest selection per session awaiting the next accepted in-turn pre-step.
143
+ * `narrate` is true for user selections and false for the exit tool, whose
144
+ * result already narrates the transition.
145
+ */
146
+ pendingIntents = new WeakMap();
147
+ constructor(ctx, config = { section: '' }) {
148
+ super(ctx, 'planMode');
149
+ this.section = resolveConfig(config).section;
150
+ ctx.on('session/event', (session, event) => {
151
+ if (event.type === 'session/version' || event.type === 'session/version-selected')
152
+ this.pendingIntents.delete(session);
153
+ });
154
+ let disposed = false;
155
+ // Pre-step is outside Session.append publication, so it can append the
156
+ // log-only mode event inside an open turn without re-entering the session.
157
+ // A failed append remains pending for a later accepted in-turn pre-step,
158
+ // and policy cannot block the step.
159
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
160
+ const decision = await next();
161
+ const pending = this.pendingIntents.get(agent.session);
162
+ if (decision.kind === 'reject' || signal.aborted || pending === undefined)
163
+ return decision;
164
+ const narration = this.narration(agent.session, pending.active);
165
+ try {
166
+ this.onBoundary(agent.session);
167
+ }
168
+ catch (error) {
169
+ ctx.logger.warn('hydra-plan-mode: failed to append selected plan mode at step start: %o', error);
170
+ return decision;
171
+ }
172
+ return !pending.narrate || narration === undefined
173
+ ? decision
174
+ : { ...decision, messages: [...decision.messages, narration] };
175
+ });
176
+ ctx.effect(() => () => { disposed = true; }, 'hydra-plan-mode: close service lifetime');
177
+ ctx.systemPrompt.section({
178
+ name: 'plan:policy',
179
+ order: 50,
180
+ text: (context) => {
181
+ if (context.agent === undefined)
182
+ return '';
183
+ const pending = this.pendingIntents.get(context.agent.session);
184
+ return (pending?.active ?? foldPlanMode(context.agent.session.activeEvents)) ? this.section : '';
185
+ },
186
+ });
187
+ // The plan projection unit (session-projection RFC): a pure event fold
188
+ // serving clients the whole {active, pending} value. `command/run`
189
+ // records the user's logged /plan selection, its paired `command/done`
190
+ // keeps only successful selections, and `plan/mode` records that
191
+ // selection and clears it. Pending is thereby a pure
192
+ // replay quantity: host restarts, other tabs, and cold reads all recover
193
+ // it from the log alone. The unit child activates only when a projection
194
+ // registry is composed (headless assemblies stay unaffected).
195
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
196
+ projectionCtx.sessionProjections.register({
197
+ key: 'plan',
198
+ history: 'active-version',
199
+ stateSchema: planUnitStateSchema,
200
+ init: () => ({ active: false, wanted: null, running: null }),
201
+ apply: (state, event) => {
202
+ if (event.type === 'command/run' && event.data.name === 'plan') {
203
+ if (event.data.args === undefined)
204
+ return state;
205
+ const wanted = event.data.args.trim() !== 'off';
206
+ return { ...state, running: { commandId: event.data.commandId, wanted } };
207
+ }
208
+ if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) {
209
+ const wanted = event.data.kind === 'success' && state.running.wanted !== state.active
210
+ ? state.running.wanted
211
+ : null;
212
+ return { ...state, wanted, running: null };
213
+ }
214
+ if (event.type === 'plan/mode') {
215
+ return { ...state, active: event.data.active, wanted: null };
216
+ }
217
+ return state;
218
+ },
219
+ wire: {
220
+ viewSchema: planProjectionSchema,
221
+ view: (state) => {
222
+ const wanted = state.running?.wanted ?? state.wanted;
223
+ return { active: state.active, pending: wanted !== null && wanted !== state.active };
224
+ },
225
+ },
226
+ stateVersion: 3,
227
+ });
228
+ });
229
+ // The command child activates only when a command registry is composed.
230
+ ctx.inject(['commands'], (commandCtx) => {
231
+ commandCtx.commands.register({
232
+ name: 'plan',
233
+ description: 'Enter or leave plan mode',
234
+ input: { hint: '[off|message]', images: true },
235
+ handler: ({ agent, rawInput, attachments }) => {
236
+ const message = rawInput.trim();
237
+ if (message === 'off' && attachments.length > 0) {
238
+ return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' };
239
+ }
240
+ if (message === 'off') {
241
+ switch (this.set(agent, false)) {
242
+ case 'committed':
243
+ return { kind: 'success', text: 'Plan mode off.' };
244
+ case 'queued':
245
+ return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' };
246
+ case 'cancelled':
247
+ return { kind: 'success', text: 'Plan mode entry cancelled.' };
248
+ case 'noop':
249
+ // Repeat the queued wording while an exit still awaits the
250
+ // next accepted pre-step; only a truly inactive session reads
251
+ // idempotent.
252
+ return foldPlanMode(agent.session.activeEvents)
253
+ ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
254
+ : { kind: 'success', text: 'Plan mode is already inactive.' };
255
+ }
256
+ }
257
+ const outcome = this.set(agent, true);
258
+ if (message !== '' || attachments.length > 0) {
259
+ agent.steer(createUserMessage({
260
+ content: [
261
+ ...attachments,
262
+ ...(message === '' ? [] : [{ type: 'text', text: message }]),
263
+ ],
264
+ source: { kind: 'user' },
265
+ }));
266
+ }
267
+ return {
268
+ kind: 'success',
269
+ text: outcome === 'committed'
270
+ ? 'Plan mode on. Use /plan off to leave.'
271
+ : 'Entering plan mode (applies from the next step). Use /plan off to leave.',
272
+ };
273
+ },
274
+ });
275
+ });
276
+ ctx.tools.register(defineTool({
277
+ name: EXIT_PLAN_MODE,
278
+ description: EXIT_DESCRIPTION,
279
+ parameters: {
280
+ plan: { type: 'string', required: true, description: 'The complete plan, as markdown, starting with a # heading that names it.' },
281
+ },
282
+ output: {
283
+ schema: {
284
+ type: 'object',
285
+ additionalProperties: false,
286
+ properties: {
287
+ approved: { type: 'boolean', const: true, required: true },
288
+ },
289
+ },
290
+ render: () => [{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }],
291
+ },
292
+ execute: async (args, exec) => {
293
+ const agent = exec.agent;
294
+ if (agent === undefined)
295
+ throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`);
296
+ if (!foldPlanMode(agent.session.activeEvents)) {
297
+ throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`);
298
+ }
299
+ if (!/^#\s+\S/.test(args.plan.trim())) {
300
+ throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`);
301
+ }
302
+ const interaction = ctx.get('userQuestions');
303
+ if (interaction === undefined) {
304
+ throw new Error('no user-questions channel is available to review the plan; ask the user to switch the session mode instead');
305
+ }
306
+ const answer = await interaction.ask({
307
+ questions: [{
308
+ id: REVIEW_ID,
309
+ header: 'Plan review',
310
+ question: 'Approve this plan and leave plan mode?',
311
+ detail: args.plan,
312
+ options: [
313
+ { label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
314
+ { label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
315
+ ],
316
+ // Presentation only: a capable UI renders the plan as a review
317
+ // decision instead of a generic question, and answers with one of
318
+ // the labels above either way.
319
+ intent: { kind: 'plan-review', approve: APPROVE_LABEL },
320
+ }],
321
+ agent,
322
+ signal: exec.signal,
323
+ }).catch((cause) => {
324
+ // A dismissed review is not a failed one: the user took the turn back
325
+ // to say something the two options do not cover. Say so, because the
326
+ // generic channel message names ask_user_question, which the model
327
+ // never called. An abort (turn cancel, provider teardown) keeps its
328
+ // own message — there is no user to wait for.
329
+ if (cause instanceof UserQuestionError && cause.code === 'ASK_CANCELLED') {
330
+ throw new Error('The user dismissed the plan review to speak instead; '
331
+ + 'stay in plan mode, stop here, and wait for their message.');
332
+ }
333
+ throw cause;
334
+ });
335
+ // A review may outlive this plugin fiber. Without its pre-step listener,
336
+ // an approved selection could never be appended, so fail and keep planning.
337
+ if (disposed) {
338
+ throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again');
339
+ }
340
+ const reviewItems = answer.answers.filter(entry => entry.id === REVIEW_ID);
341
+ const item = reviewItems.length === 1 ? reviewItems[0] : undefined;
342
+ if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== undefined) {
343
+ const feedback = item?.custom ?? '';
344
+ throw new Error(feedback === ''
345
+ ? 'The user chose to keep planning; revise the plan and present it again.'
346
+ : `The user chose to keep planning; their feedback: ${feedback}`);
347
+ }
348
+ // Keep plan guidance for the rest of this assistant tool batch. The
349
+ // silent selection is appended at the next accepted in-turn pre-step,
350
+ // before its request assembly.
351
+ this.pendingIntents.set(agent.session, { active: false, narrate: false });
352
+ return { approved: true };
353
+ },
354
+ presentCall: args => ({
355
+ card: 'generic',
356
+ title: firstHeading(args.plan) ?? 'Plan',
357
+ kind: 'other',
358
+ content: [{ type: 'text', text: args.plan }],
359
+ }),
360
+ presentResult: (_args, result) => ({
361
+ card: 'generic',
362
+ title: 'Plan review',
363
+ content: result.content,
364
+ }),
365
+ }));
366
+ }
367
+ /**
368
+ * Read the logged plan state and any selected state awaiting the next
369
+ * accepted in-turn pre-step.
370
+ *
371
+ * @param agent The agent to read.
372
+ * @returns Current logged state plus a pending selection, when present.
373
+ */
374
+ get(agent) {
375
+ const active = foldPlanMode(agent.session.activeEvents);
376
+ const pending = this.pendingIntents.get(agent.session);
377
+ return pending === undefined ? { active } : { active, pending: pending.active };
378
+ }
379
+ /**
380
+ * Select whether plan mode should be active. Between turns the method
381
+ * appends the change immediately because no in-turn pre-step will run until
382
+ * another prompt starts a turn. The open-turn fold is the idle signal:
383
+ * agent status stays `running` through post-turn checkpointing, when no
384
+ * further in-turn pre-step runs. During an open turn the selection remains
385
+ * pending until the next accepted in-turn pre-step. Repeated selection of
386
+ * the current or already-pending state is a no-op.
387
+ *
388
+ * @param agent The agent to switch.
389
+ * @param active Whether plan mode should be active.
390
+ * @returns what happened: `committed` (logged now), `queued` (awaiting the
391
+ * next accepted in-turn pre-step), `cancelled` (an opposite pending selection
392
+ * was cleared; the logged state already matches), or `noop` (already in that
393
+ * state).
394
+ */
395
+ set(agent, active) {
396
+ const session = agent.session;
397
+ const pending = this.pendingIntents.get(session);
398
+ const target = pending?.active ?? foldPlanMode(session.activeEvents);
399
+ if (active === target)
400
+ return 'noop';
401
+ if (hasOpenTurn(session.activeEvents)) {
402
+ this.pendingIntents.set(session, { active, narrate: true });
403
+ return foldPlanMode(session.activeEvents) === active ? 'cancelled' : 'queued';
404
+ }
405
+ // No open turn: commit now. Delete only after append succeeds so a
406
+ // failed durable write leaves the selection retryable, not dropped.
407
+ if (active === foldPlanMode(session.activeEvents)) {
408
+ this.pendingIntents.delete(session);
409
+ return 'cancelled';
410
+ }
411
+ session.append('plan/mode', { active });
412
+ this.pendingIntents.delete(session);
413
+ const narration = this.narration(session, active);
414
+ if (narration !== undefined)
415
+ agent.inject(narration);
416
+ return 'committed';
417
+ }
418
+ /** Append one pending selection before the next request assembly. */
419
+ onBoundary(session) {
420
+ const pending = this.pendingIntents.get(session);
421
+ if (pending === undefined)
422
+ return;
423
+ const target = pending.active;
424
+ if (target === foldPlanMode(session.activeEvents)) {
425
+ this.pendingIntents.delete(session);
426
+ return;
427
+ }
428
+ session.append('plan/mode', { active: target });
429
+ // Delete only after append succeeds so a later accepted in-turn pre-step
430
+ // can retry a failed durable write.
431
+ this.pendingIntents.delete(session);
432
+ }
433
+ /** Build a user-switch notice when the last logged header described the other mode. */
434
+ narration(session, target) {
435
+ const told = planModeAtLastHeader(session.activeEvents);
436
+ if (told === undefined || told === target)
437
+ return;
438
+ const text = target
439
+ ? 'The user switched this session to plan mode.'
440
+ : 'The user switched this session back to the default mode.';
441
+ return createUserMessage({
442
+ content: [{ type: 'text', text }],
443
+ // The narration is already one sentence, so it is its own summary.
444
+ source: { kind: 'plugin', plugin: 'plan-mode', form: 'notice', summary: text },
445
+ });
446
+ }
447
+ }
448
+ export default PlanModeController;
449
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /** Package-owned durable plan-mode invariants. @module @hydraharness/harness-plan-mode/invariant */
2
+ import type { Context } from '@hydraharness/cordis';
3
+ /** Cordis companion plugin name. */
4
+ export declare const name = "plan-mode-invariant";
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export declare const inject: string[];
7
+ /**
8
+ * Register the plan-mode invariant companion.
9
+ * @param ctx - Cordis context carrying the invariant service.
10
+ * @returns the installed registration's disposer after setup succeeds.
11
+ */
12
+ export declare const apply: (ctx: Context) => Promise<() => void>;
13
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,43 @@
1
+ /** Package-owned durable plan-mode invariants. @module @hydraharness/harness-plan-mode/invariant */
2
+ const PACKAGE_NAME = '@hydraharness/harness-plan-mode';
3
+ /** Cordis companion plugin name. */
4
+ export const name = 'plan-mode-invariant';
5
+ /** Service required before the companion can reserve package ownership. */
6
+ export const inject = ['invariants'];
7
+ /**
8
+ * Validate one `plan/mode` event before it reaches the durable log.
9
+ * `plan/mode` is a standalone whole-value event: an idle selection commits
10
+ * between turns and a mid-turn selection commits at the step boundary, so
11
+ * no turn-enclosure relation exists — only the payload shape is checkable.
12
+ */
13
+ function validateEvent(event, fail) {
14
+ if (event.type !== 'plan/mode')
15
+ return;
16
+ const active = event.data.active;
17
+ if (typeof active !== 'boolean') {
18
+ fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`);
19
+ }
20
+ }
21
+ /** Install validation for loaded and newly appended plan-mode state. */
22
+ const install = Object.assign((ctx, fail) => {
23
+ const seed = (session) => {
24
+ for (const event of session.events)
25
+ validateEvent(event, fail);
26
+ };
27
+ for (const session of ctx.sessions.list())
28
+ seed(session);
29
+ ctx.on('session/created', (session) => { seed(session); }, { global: true });
30
+ ctx.on('internal/dispatch', (_mode, eventName, args) => {
31
+ if (eventName !== 'session/event')
32
+ return;
33
+ const [, event] = args;
34
+ validateEvent(event, fail);
35
+ }, { global: true });
36
+ }, { inject: ['sessions'] });
37
+ /**
38
+ * Register the plan-mode invariant companion.
39
+ * @param ctx - Cordis context carrying the invariant service.
40
+ * @returns the installed registration's disposer after setup succeeds.
41
+ */
42
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
43
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pure types of the plan domain: the ONE home of the `plan` projection-key
3
+ * declaration, free of this package's host-side value imports (cordis
4
+ * service, @hydraharness/harness-tools, @hydraharness/harness-agent). Two namespace projections serve it —
5
+ * `./types` for host consumers, `./client` for client aggregates — with zero
6
+ * content duplication.
7
+ *
8
+ * @module @hydraharness/harness-plan-mode/types
9
+ */
10
+ /**
11
+ * The plan projection's wire value. `active` is the logged state in force
12
+ * (the last `plan/mode`, inactive before the first); `pending` is true while
13
+ * a logged `/plan` selection targets a state other than `active`, has not
14
+ * failed through its paired `command/done`, and no later `plan/mode` event has
15
+ * recorded that state. Capability absence (plan-mode not composed) is the
16
+ * key's absence, never a value.
17
+ */
18
+ export interface PlanProjection {
19
+ active: boolean;
20
+ pending: boolean;
21
+ }
22
+ declare module '@hydraharness/harness-session-projection/types' {
23
+ interface SessionProjectionMap {
24
+ /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */
25
+ plan: PlanProjection;
26
+ }
27
+ }
28
+ //# sourceMappingURL=types.d.ts.map