@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 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.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
5
+ README.md: bbc4723e014568e13b2204155a551e8d52b942ac
6
+ README.zh.md: b1f4d75081dc6daaa4e902937552ebcc32d2ee91
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @stackstackstack/dsh-plan-mode
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ 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.
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.
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` ([`@stackstackstack/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). 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: '@stackstackstack/dsh-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 trimmed user text block through `agent.steer()` after plan mode is selected. 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 text separately; bare `/plan` and `/plan off` add none. 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#deepseek-aidsh-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/README.zh.md ADDED
@@ -0,0 +1,98 @@
1
+ # @stackstackstack/dsh-plan-mode
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略各自强制执行限制,且不读写 plan 状态。
6
+
7
+ ## 持久状态
8
+
9
+ `plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。
10
+
11
+ `ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个提示词之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
12
+
13
+ ## 模型与人类交互
14
+
15
+ 激活时,`plan:policy` 会渲染已配置的 `section`。插件始终注册 `exit_plan_mode`,使工具 schema 在转换期间保持稳定;其 execute 路径只接受已激活的 plan mode,且只有通过 `ctx.userQuestions` 获得用户明确批准后才退出。
16
+
17
+ 评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅——用户关闭请求,转而发言——会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。
18
+
19
+ 组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。
20
+
21
+ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
22
+
23
+ ## 会话投影
24
+
25
+ 当组合挂载 `ctx.sessionProjections`([`@stackstackstack/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
26
+
27
+ ## 配置
28
+
29
+ ```yaml
30
+ - id: plan-mode
31
+ name: '@stackstackstack/dsh-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` 必填且非空。出现未知键时,插件会加载失败。该包不接受任意命名的 mode、工具过滤器、沙箱设置或批准策略。
39
+
40
+ 设计:[plan 专用协作状态](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md)。
41
+
42
+ ## 模型体验
43
+
44
+ ### Plan 策略系统提示词
45
+
46
+ #### 模型所见内容
47
+
48
+ Plan mode 激活时,模型会在提示词顺序 50 处看到部署方提供的原样 `section` 文本;未激活 mode 不贡献文本。
49
+
50
+ ##### 配置示例
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 影响
57
+
58
+ 未激活 mode 不增加 token;mode 激活时,每个请求都会加入已配置的段落。
59
+
60
+ #### KV Cache 影响
61
+
62
+ 该段在 plan mode 内稳定,但进入或退出会从顺序 50 开始改变系统提示词。
63
+
64
+ ### 人类命令
65
+
66
+ #### 模型所见内容
67
+
68
+ `/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择 plan mode 后,通过 `agent.steer()` 成为一个已去除首尾空白的用户文本块。plan mode 已激活时,选择 `/plan off` 只会在最后一个请求头描述了 plan mode 的情况下追加标准的已记录用户切换通知;取消待生效进入不会贡献通知,因为没有请求观测到它。
69
+
70
+ #### Token 影响
71
+
72
+ 可选消息的历史 token 成本与单独提交该文本相同;不带参数的 `/plan` 和 `/plan off` 不增加 token。一次带有切换通知的已激活状态退出会追加一条简短且会保留的通知。
73
+
74
+ #### KV Cache 影响
75
+
76
+ 用户块是仅追加的对话增长。进入或退出 plan mode 会改变更早的策略段;退出转换的记录通知会追加在可复用请求前缀之后。
77
+
78
+ ### 退出工具 schema 与评审交互
79
+
80
+ #### 模型所见内容
81
+
82
+ [`exit_plan_mode` schema](../../../docs/tool-catalog.md#deepseek-aidsh-plan-mode) 在两种状态下均可用;在 plan mode 外执行会失败,而 plan mode 内经批准的评审会返回规范的 `{ approved: true }` 值,并渲染既有的确认文本。拒绝仍是携带评审反馈的失败调用,放弃审阅则是一次指明用户接手的失败调用。
83
+
84
+ #### Token 影响
85
+
86
+ 稳定 schema 的成本取决于 ToolRuntime mode,每次传入的 plan 参数和评审结果都会保留在对话历史中。
87
+
88
+ #### KV Cache 影响
89
+
90
+ mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩展对话。
91
+
92
+ ## 已知限制与暂缓事项
93
+
94
+ - Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。
95
+ - 如果进程在另一个被接受的轮内 pre-step 之前退出,某轮最后一个被接受的 pre-step 之后作出的选择会丢失,因此 UI 必须重新应用它。
96
+ - Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。
97
+ - 由另一个 agent 所有的存活子级无法打开 `exit_plan_mode` 审阅。该调用失败时会提示子级在最终结果中包含尚未解决的决策;仅有持久化 fork 谱系并不会阻止恢复为运行时根的会话打开该审阅。
98
+ - 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。
package/lib/index.js ADDED
@@ -0,0 +1,391 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import { z } from "zod";
3
+ import { createUserMessage } from "@stackstackstack/dsh-llm";
4
+ import { defineTool } from "@stackstackstack/dsh-tools";
5
+ import { UserQuestionError } from "@stackstackstack/dsh-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 @stackstackstack/dsh-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
+ /** Wire payload schema of the `plan` projection. */
84
+ const planProjectionSchema = z.object({
85
+ active: z.boolean(),
86
+ pending: z.boolean()
87
+ });
88
+ /** Whether the log holds an opened turn without its closing `turn/end`. */
89
+ function hasOpenTurn(events) {
90
+ let open = false;
91
+ for (const event of events) if (event.type === "turn/start") open = true;
92
+ else if (event.type === "turn/end") open = false;
93
+ return open;
94
+ }
95
+ /** Plan state at the last logged request header, or `undefined` before the first header. */
96
+ function planModeAtLastHeader(events) {
97
+ let lastHeader = -1;
98
+ let index = 0;
99
+ for (const event of events) {
100
+ if (event.type === "request/header") lastHeader = index;
101
+ index++;
102
+ }
103
+ if (lastHeader < 0) return void 0;
104
+ return foldPlanMode(events, lastHeader + 1);
105
+ }
106
+ /**
107
+ * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
108
+ * the `plan:policy` section, the `/plan` command, and the stable exit tool.
109
+ * UIs observe committed flips through `session/event`; there is no live mirror.
110
+ */
111
+ var PlanModeController = class extends Service {
112
+ static inject = ["tools", "systemPrompt"];
113
+ /** Validated deployment-owned guidance. */
114
+ section;
115
+ /**
116
+ * Latest selection per session awaiting the next accepted in-turn pre-step.
117
+ * `narrate` is true for user selections and false for the exit tool, whose
118
+ * result already narrates the transition.
119
+ */
120
+ pendingIntents = /* @__PURE__ */ new WeakMap();
121
+ constructor(ctx, config = { section: "" }) {
122
+ super(ctx, "planMode");
123
+ this.section = resolveConfig(config).section;
124
+ let disposed = false;
125
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
126
+ const decision = await next();
127
+ const pending = this.pendingIntents.get(agent.session);
128
+ if (decision.kind === "reject" || signal.aborted || pending === void 0) return decision;
129
+ const narration = this.narration(agent.session, pending.active);
130
+ try {
131
+ this.onBoundary(agent.session);
132
+ } catch (error) {
133
+ ctx.logger.warn("dsh-plan-mode: failed to append selected plan mode at step start: %o", error);
134
+ return decision;
135
+ }
136
+ return !pending.narrate || narration === void 0 ? decision : {
137
+ ...decision,
138
+ messages: [...decision.messages, narration]
139
+ };
140
+ });
141
+ ctx.effect(() => () => {
142
+ disposed = true;
143
+ }, "dsh-plan-mode: close service lifetime");
144
+ ctx.systemPrompt.section({
145
+ name: "plan:policy",
146
+ order: 50,
147
+ text: (context) => {
148
+ if (context.agent === void 0) return "";
149
+ return this.pendingIntents.get(context.agent.session)?.active ?? foldPlanMode(context.agent.session.events) ? this.section : "";
150
+ }
151
+ });
152
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
153
+ projectionCtx.sessionProjections.register({
154
+ key: "plan",
155
+ schema: planProjectionSchema,
156
+ init: () => ({
157
+ active: false,
158
+ wanted: null
159
+ }),
160
+ apply: (state, event) => {
161
+ if (event.type === "command/run" && event.data.name === "plan") {
162
+ if (event.data.args === void 0) return state;
163
+ const wanted = event.data.args.trim() !== "off";
164
+ return wanted === state.wanted ? state : {
165
+ active: state.active,
166
+ wanted
167
+ };
168
+ }
169
+ if (event.type === "plan/mode") return {
170
+ active: event.data.active,
171
+ wanted: null
172
+ };
173
+ return state;
174
+ },
175
+ view: (state) => ({
176
+ active: state.active,
177
+ pending: state.wanted !== null && state.wanted !== state.active
178
+ }),
179
+ stateVersion: 1
180
+ });
181
+ });
182
+ ctx.inject(["commands"], (commandCtx) => {
183
+ commandCtx.commands.register({
184
+ name: "plan",
185
+ description: "Enter or leave plan mode",
186
+ input: { hint: "[off|message]" },
187
+ handler: ({ agent, rawInput }) => {
188
+ const message = rawInput.trim();
189
+ if (message === "off") switch (this.set(agent, false)) {
190
+ case "committed": return {
191
+ kind: "success",
192
+ text: "Plan mode off."
193
+ };
194
+ case "queued": return {
195
+ kind: "success",
196
+ text: "Leaving plan mode (applies from the next step)."
197
+ };
198
+ case "cancelled": return {
199
+ kind: "success",
200
+ text: "Plan mode entry cancelled."
201
+ };
202
+ case "noop": return foldPlanMode(agent.session.events) ? {
203
+ kind: "success",
204
+ text: "Leaving plan mode (applies from the next step)."
205
+ } : {
206
+ kind: "success",
207
+ text: "Plan mode is already inactive."
208
+ };
209
+ }
210
+ const outcome = this.set(agent, true);
211
+ if (message !== "") agent.steer(createUserMessage({
212
+ content: [{
213
+ type: "text",
214
+ text: message
215
+ }],
216
+ source: { kind: "user" }
217
+ }));
218
+ return {
219
+ kind: "success",
220
+ text: outcome === "committed" ? "Plan mode on. Use /plan off to leave." : "Entering plan mode (applies from the next step). Use /plan off to leave."
221
+ };
222
+ }
223
+ });
224
+ });
225
+ ctx.tools.register(defineTool({
226
+ name: EXIT_PLAN_MODE,
227
+ description: EXIT_DESCRIPTION,
228
+ parameters: { plan: {
229
+ type: "string",
230
+ required: true,
231
+ description: "The complete plan, as markdown, starting with a # heading that names it."
232
+ } },
233
+ output: {
234
+ schema: {
235
+ type: "object",
236
+ additionalProperties: false,
237
+ properties: { approved: {
238
+ type: "boolean",
239
+ const: true,
240
+ required: true
241
+ } }
242
+ },
243
+ render: () => [{
244
+ type: "text",
245
+ text: "Plan approved — plan mode exited; carry out the plan starting with your next step."
246
+ }]
247
+ },
248
+ execute: async (args, exec) => {
249
+ const agent = exec.agent;
250
+ if (agent === void 0) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`);
251
+ if (!foldPlanMode(agent.session.events)) throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`);
252
+ if (!/^#\s+\S/.test(args.plan.trim())) throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`);
253
+ const interaction = ctx.get("userQuestions");
254
+ 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");
255
+ const answer = await interaction.ask({
256
+ questions: [{
257
+ id: REVIEW_ID,
258
+ header: "Plan review",
259
+ question: "Approve this plan and leave plan mode?",
260
+ detail: args.plan,
261
+ options: [{
262
+ label: APPROVE_LABEL,
263
+ description: "Leave plan mode; the plan is carried out from the next step."
264
+ }, {
265
+ label: KEEP_PLANNING_LABEL,
266
+ description: "Stay in plan mode; feedback goes back to the model."
267
+ }],
268
+ intent: {
269
+ kind: "plan-review",
270
+ approve: APPROVE_LABEL
271
+ }
272
+ }],
273
+ agent,
274
+ signal: exec.signal
275
+ }).catch((cause) => {
276
+ 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.");
277
+ throw cause;
278
+ });
279
+ if (disposed) throw new Error("the plan-mode service was reloaded while the plan was under review; present the plan again");
280
+ const reviewItems = answer.answers.filter((entry) => entry.id === REVIEW_ID);
281
+ const item = reviewItems.length === 1 ? reviewItems[0] : void 0;
282
+ if (item?.selected.length !== 1 || item.selected[0] !== APPROVE_LABEL || item.custom !== void 0) {
283
+ const feedback = item?.custom ?? "";
284
+ 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}`);
285
+ }
286
+ this.pendingIntents.set(agent.session, {
287
+ active: false,
288
+ narrate: false
289
+ });
290
+ return { approved: true };
291
+ },
292
+ presentCall: (args) => ({
293
+ card: "generic",
294
+ title: firstHeading(args.plan) ?? "Plan",
295
+ kind: "other",
296
+ content: [{
297
+ type: "text",
298
+ text: args.plan
299
+ }]
300
+ }),
301
+ presentResult: (_args, result) => ({
302
+ card: "generic",
303
+ title: "Plan review",
304
+ content: result.content
305
+ })
306
+ }));
307
+ }
308
+ /**
309
+ * Read the logged plan state and any selected state awaiting the next
310
+ * accepted in-turn pre-step.
311
+ *
312
+ * @param agent The agent to read.
313
+ * @returns Current logged state plus a pending selection, when present.
314
+ */
315
+ get(agent) {
316
+ const active = foldPlanMode(agent.session.events);
317
+ const pending = this.pendingIntents.get(agent.session);
318
+ return pending === void 0 ? { active } : {
319
+ active,
320
+ pending: pending.active
321
+ };
322
+ }
323
+ /**
324
+ * Select whether plan mode should be active. Between turns the method
325
+ * appends the change immediately because no in-turn pre-step will run until
326
+ * another prompt starts a turn. The open-turn fold is the idle signal:
327
+ * agent status stays `running` through post-turn checkpointing, when no
328
+ * further in-turn pre-step runs. During an open turn the selection remains
329
+ * pending until the next accepted in-turn pre-step. Repeated selection of
330
+ * the current or already-pending state is a no-op.
331
+ *
332
+ * @param agent The agent to switch.
333
+ * @param active Whether plan mode should be active.
334
+ * @returns what happened: `committed` (logged now), `queued` (awaiting the
335
+ * next accepted in-turn pre-step), `cancelled` (an opposite pending selection
336
+ * was cleared; the logged state already matches), or `noop` (already in that
337
+ * state).
338
+ */
339
+ set(agent, active) {
340
+ const session = agent.session;
341
+ if (active === (this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events))) return "noop";
342
+ if (hasOpenTurn(session.events)) {
343
+ this.pendingIntents.set(session, {
344
+ active,
345
+ narrate: true
346
+ });
347
+ return foldPlanMode(session.events) === active ? "cancelled" : "queued";
348
+ }
349
+ if (active === foldPlanMode(session.events)) {
350
+ this.pendingIntents.delete(session);
351
+ return "cancelled";
352
+ }
353
+ session.append("plan/mode", { active });
354
+ this.pendingIntents.delete(session);
355
+ const narration = this.narration(session, active);
356
+ if (narration !== void 0) agent.inject(narration);
357
+ return "committed";
358
+ }
359
+ /** Append one pending selection before the next request assembly. */
360
+ onBoundary(session) {
361
+ const pending = this.pendingIntents.get(session);
362
+ if (pending === void 0) return;
363
+ const target = pending.active;
364
+ if (target === foldPlanMode(session.events)) {
365
+ this.pendingIntents.delete(session);
366
+ return;
367
+ }
368
+ session.append("plan/mode", { active: target });
369
+ this.pendingIntents.delete(session);
370
+ }
371
+ /** Build a user-switch notice when the last logged header described the other mode. */
372
+ narration(session, target) {
373
+ const told = planModeAtLastHeader(session.events);
374
+ if (told === void 0 || told === target) return;
375
+ const text = target ? "The user switched this session to plan mode." : "The user switched this session back to the default mode.";
376
+ return createUserMessage({
377
+ content: [{
378
+ type: "text",
379
+ text
380
+ }],
381
+ source: {
382
+ kind: "plugin",
383
+ plugin: "plan-mode",
384
+ form: "notice",
385
+ summary: text
386
+ }
387
+ });
388
+ }
389
+ };
390
+ //#endregion
391
+ 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 @stackstackstack/dsh-plan-mode/invariant */
3
+ const PACKAGE_NAME = "@stackstackstack/dsh-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 @stackstackstack/dsh-plan-mode/client
8
+ */
9
+ export type * from './types.ts';
10
+ //# sourceMappingURL=client.d.ts.map