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