@carljia/omd-dsh 0.1.1 → 0.1.3

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/lib/plan.js ADDED
@@ -0,0 +1,146 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { scopeOf } from "@deepseek-ai/dsh-scope";
4
+ /**
5
+ * @module @carljia/omd-dsh/plan
6
+ *
7
+ * omd-plan: plan persistence for the OMD planner mode. It wraps the
8
+ * `tools/post-execute` waterfall and intercepts a successful
9
+ * `exit_plan_mode` approval: the approved plan text is written into the
10
+ * workspace's plan directory (a fixed, code-level convention -- never
11
+ * mentioned in any persona/prompt text), and the tool result content is
12
+ * enriched with the saved file name so the planner's fixed Start Work
13
+ * final step can hand it to the user.
14
+ *
15
+ * Plan directory convention (hardcoded here and in omd-start-work only):
16
+ * <session cwd>/.omd/plans/<slug>-<timestamp>.md
17
+ * The slug derives from the plan's first markdown heading; a timestamp
18
+ * suffix keeps repeated interviews from overwriting each other.
19
+ */
20
+ /** Cordis plugin name. */
21
+ const name = "omd-plan";
22
+ /** No service injection: this row only registers a scoped event listener. */
23
+ const inject = [];
24
+ /** Plan directory segments relative to the session workspace root (cwd). */
25
+ const PLAN_DIR_SEGMENTS = [".omd", "plans"];
26
+ /** The exit tool whose approved plan we persist. */
27
+ const EXIT_PLAN_MODE = "exit_plan_mode";
28
+ /** Maximum slug length (characters). */
29
+ const SLUG_MAX = 48;
30
+ /** The plan's first markdown heading (any level), or undefined when it has none. */
31
+ function firstHeading(plan) {
32
+ for (const line of plan.split("\n")) {
33
+ const match = /^#{1,6}\s+(.+?)\s*$/.exec(line);
34
+ if (match)
35
+ return match[1];
36
+ }
37
+ return undefined;
38
+ }
39
+ /** Derive a filesystem-safe slug from the plan title. */
40
+ function slugify(title) {
41
+ const slug = String(title)
42
+ .normalize("NFKD")
43
+ .toLowerCase()
44
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, SLUG_MAX);
47
+ return slug === "" ? "plan" : slug;
48
+ }
49
+ /** Compact local-ish UTC timestamp for the file name: YYYYMMDD-HHmmss. */
50
+ function timestamp() {
51
+ const d = new Date();
52
+ const pad = (n) => String(n).padStart(2, "0");
53
+ return (d.getUTCFullYear() +
54
+ pad(d.getUTCMonth() + 1) +
55
+ pad(d.getUTCDate()) +
56
+ "-" +
57
+ pad(d.getUTCHours()) +
58
+ pad(d.getUTCMinutes()) +
59
+ pad(d.getUTCSeconds()));
60
+ }
61
+ /** Write the plan into <cwd>/.omd/plans/ and return the absolute file path. */
62
+ async function savePlan(cwd, plan) {
63
+ const dir = join(cwd, ...PLAN_DIR_SEGMENTS);
64
+ await mkdir(dir, { recursive: true });
65
+ const file = join(dir, slugify(firstHeading(plan) ?? "") + "-" + timestamp() + ".md");
66
+ await writeFile(file, plan, "utf8");
67
+ return file;
68
+ }
69
+ /** Display path used in result enrichment and messages (forward slashes). */
70
+ function displayPath(saved) {
71
+ return PLAN_DIR_SEGMENTS.join("/") + "/" + basename(saved);
72
+ }
73
+ /** Subagents never own the plan review -- only the top-level planner does. */
74
+ function isSubagent(agent) {
75
+ return (agent !== undefined &&
76
+ agent !== null &&
77
+ agent.options !== undefined &&
78
+ agent.options !== null &&
79
+ typeof agent.options.subagentDepth === "number" &&
80
+ agent.options.subagentDepth > 0);
81
+ }
82
+ function apply(ctx) {
83
+ if (scopeOf(ctx) === undefined) {
84
+ throw new Error("omd-plan: refusing to mount outside a scoped context; mount this row inside an agent preset");
85
+ }
86
+ ctx.on("tools/post-execute", async (exec, result, next) => {
87
+ const decision = await next();
88
+ if (decision.kind !== "accept" || decision.value !== undefined)
89
+ return decision;
90
+ if (exec === undefined || exec.name !== EXIT_PLAN_MODE)
91
+ return decision;
92
+ if (result.isError)
93
+ return decision;
94
+ const agent = exec.agent;
95
+ if (agent === undefined || isSubagent(agent))
96
+ return decision;
97
+ const args = exec.arguments;
98
+ const plan = args !== undefined && args !== null && typeof args.plan === "string" ? args.plan : undefined;
99
+ if (plan === undefined)
100
+ return decision;
101
+ const cwd = agent.session !== undefined &&
102
+ agent.session.header !== undefined &&
103
+ typeof agent.session.header.cwd === "string"
104
+ ? agent.session.header.cwd
105
+ : "";
106
+ if (cwd === "") {
107
+ // No workspace root to save into: fail closed but tell the model, so
108
+ // the planner does not promise a file name it never produced.
109
+ return withNotice(decision, result, {
110
+ type: "text",
111
+ text: "The approved plan could NOT be saved automatically: this session has no workspace directory. Ask the user how to proceed.",
112
+ });
113
+ }
114
+ try {
115
+ const saved = await savePlan(cwd, plan);
116
+ return withNotice(decision, result, {
117
+ type: "text",
118
+ text: "Plan saved to " +
119
+ displayPath(saved) +
120
+ ". Start work: run /start-work " +
121
+ basename(saved) +
122
+ " in an omd-executor session, or switch this session with /mode omd-executor and continue here.",
123
+ });
124
+ }
125
+ catch (error) {
126
+ return withNotice(decision, result, {
127
+ type: "text",
128
+ text: "The approved plan could NOT be saved automatically: " +
129
+ (error instanceof Error ? error.message : String(error)) +
130
+ ". Ask the user how to proceed.",
131
+ });
132
+ }
133
+ });
134
+ }
135
+ /** Keep the accepted decision, appending one text block to its content. */
136
+ function withNotice(decision, result, block) {
137
+ const base = Array.isArray(decision.content) ? decision.content : result.content ?? [];
138
+ return {
139
+ kind: "accept",
140
+ content: [...base, block],
141
+ ...(decision.additionalContexts !== undefined
142
+ ? { additionalContexts: decision.additionalContexts }
143
+ : {}),
144
+ };
145
+ }
146
+ export { apply, inject, name };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @module @carljia/omd-dsh/startwork
3
+ *
4
+ * omd-start-work: human-facing `/start-work` command -- the "start work"
5
+ * trigger at the end of the OMD planning workflow. It resolves the named
6
+ * plan file inside the workspace's plan directory (a fixed, code-level
7
+ * convention -- never mentioned in any persona/prompt text), arms a goal
8
+ * whose objective references the plan's absolute path, and goal
9
+ * auto-continuation then drives the agent to execute the plan without
10
+ * further input.
11
+ */
12
+ /** Cordis plugin name. */
13
+ declare const name = "omd-start-work";
14
+ /** The goal domain is already required by tool-goal in the same preset. */
15
+ declare const inject: string[];
16
+ declare function apply(ctx: any): void;
17
+ export { apply, inject, name };
@@ -0,0 +1,147 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { scopeOf } from "@deepseek-ai/dsh-scope";
4
+ /**
5
+ * @module @carljia/omd-dsh/startwork
6
+ *
7
+ * omd-start-work: human-facing `/start-work` command -- the "start work"
8
+ * trigger at the end of the OMD planning workflow. It resolves the named
9
+ * plan file inside the workspace's plan directory (a fixed, code-level
10
+ * convention -- never mentioned in any persona/prompt text), arms a goal
11
+ * whose objective references the plan's absolute path, and goal
12
+ * auto-continuation then drives the agent to execute the plan without
13
+ * further input.
14
+ */
15
+ /** Cordis plugin name. */
16
+ const name = "omd-start-work";
17
+ /** The goal domain is already required by tool-goal in the same preset. */
18
+ const inject = ["goals"];
19
+ /** Plan directory segments relative to the session workspace root (cwd). */
20
+ const PLAN_DIR_SEGMENTS = [".omd", "plans"];
21
+ /** The prefix accepted when a user pastes the full relative plan path. */
22
+ const PLAN_DIR_PREFIX = ".omd/plans/";
23
+ /** Plan directory for one workspace root. */
24
+ function plansDir(cwd) {
25
+ return join(cwd, ...PLAN_DIR_SEGMENTS);
26
+ }
27
+ /**
28
+ * Resolve the user's file-name input to a candidate path inside the plan
29
+ * directory. Accepts a bare file name ("foo.md" / "foo") or the full
30
+ * relative path (".omd/plans/foo.md"); rejects absolute paths and anything
31
+ * that would escape the plan directory.
32
+ */
33
+ function resolveCandidate(cwd, input) {
34
+ const trimmed = String(input).trim().replace(/\\/g, "/");
35
+ if (trimmed === "" || isAbsolute(trimmed))
36
+ return undefined;
37
+ let name = trimmed.replace(/^\.\//, "");
38
+ if (name.includes("/")) {
39
+ if (!name.startsWith(PLAN_DIR_PREFIX))
40
+ return undefined;
41
+ name = name.slice(PLAN_DIR_PREFIX.length);
42
+ }
43
+ if (name === "" || name.includes("/") || name === "." || name === "..")
44
+ return undefined;
45
+ if (name.startsWith("."))
46
+ return undefined; // no hidden-file tricks
47
+ return join(plansDir(cwd), name);
48
+ }
49
+ /** One /start-work invocation through the goal domain. */
50
+ async function executeStartWork(ctx, invocation) {
51
+ const agent = invocation.agent;
52
+ const cwd = agent !== undefined &&
53
+ agent.session !== undefined &&
54
+ agent.session.header !== undefined &&
55
+ typeof agent.session.header.cwd === "string"
56
+ ? agent.session.header.cwd
57
+ : "";
58
+ if (cwd === "") {
59
+ return {
60
+ kind: "error",
61
+ text: "This session has no workspace directory; /start-work needs one to find the plan file.",
62
+ };
63
+ }
64
+ const candidate = resolveCandidate(cwd, invocation.rawInput);
65
+ if (candidate === undefined) {
66
+ return {
67
+ kind: "error",
68
+ text: "Usage: /start-work <plan file name> — the file must live inside " + PLAN_DIR_SEGMENTS.join("/") + "/.",
69
+ };
70
+ }
71
+ let file = candidate;
72
+ const usable = async (path) => {
73
+ try {
74
+ await access(path);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ };
81
+ if (!(await usable(file)) && !file.endsWith(".md") && (await usable(file + ".md"))) {
82
+ file = file + ".md";
83
+ }
84
+ else if (!(await usable(file))) {
85
+ return {
86
+ kind: "error",
87
+ text: "Plan file not found: " + PLAN_DIR_SEGMENTS.join("/") + "/" + file.slice(plansDir(cwd).length + 1),
88
+ };
89
+ }
90
+ let text;
91
+ try {
92
+ text = await readFile(file, "utf8");
93
+ }
94
+ catch (error) {
95
+ return {
96
+ kind: "error",
97
+ text: "Cannot read the plan file: " + (error instanceof Error ? error.message : String(error)),
98
+ };
99
+ }
100
+ if (text.trim() === "") {
101
+ return { kind: "error", text: "The plan file is empty." };
102
+ }
103
+ try {
104
+ const current = ctx.goals.get(agent);
105
+ if (current !== undefined && current.phase !== "complete") {
106
+ return {
107
+ kind: "error",
108
+ text: `A goal is already ${current.phase}. Run /goal clear first, then /start-work <plan file name>.`,
109
+ };
110
+ }
111
+ ctx.goals.create(agent, {
112
+ objective: "Execute the approved plan file at " +
113
+ file +
114
+ ". Read the file in full, then carry out every step autonomously: implement, verify, and iterate until the plan's goal and success criteria are met. Work through goal continuation rounds until done.",
115
+ });
116
+ return {
117
+ kind: "success",
118
+ text: "Start work armed — executing the plan to completion.\nPlan: " +
119
+ PLAN_DIR_SEGMENTS.join("/") +
120
+ "/" +
121
+ file.slice(plansDir(cwd).length + 1),
122
+ };
123
+ }
124
+ catch (error) {
125
+ return {
126
+ kind: "error",
127
+ text: "start-work failed: " + (error instanceof Error ? error.message : String(error)),
128
+ };
129
+ }
130
+ }
131
+ function apply(ctx) {
132
+ if (scopeOf(ctx) === undefined) {
133
+ throw new Error("omd-start-work: refusing to mount outside a scoped context; mount this row inside an agent preset");
134
+ }
135
+ ctx.inject(["commands"], (commandCtx) => {
136
+ commandCtx.commands.register({
137
+ name: "start-work",
138
+ description: "start work: arm a goal that executes the named plan file to completion",
139
+ input: {
140
+ hint: "<plan file name>",
141
+ images: false,
142
+ },
143
+ handler: (invocation) => executeStartWork(ctx, invocation),
144
+ });
145
+ });
146
+ }
147
+ export { apply, inject, name };
@@ -0,0 +1,145 @@
1
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
+ import { scopeOf } from "@deepseek-ai/dsh-scope";
3
+ /**
4
+ * @module @carljia/omd-dsh/mode
5
+ *
6
+ * omd-mode-switch: human-facing `/mode` command -- switch the CURRENT
7
+ * session to another OMD agent preset, including mid-conversation.
8
+ *
9
+ * DSH's native `agentPreset.select` host API refuses to recompose a
10
+ * session that has already started (its preset is fixed at the UI level),
11
+ * and the AgentPresets.recompose method itself performs no history check
12
+ * ("the CALLER owns that check"). This row deliberately performs the
13
+ * in-session recompose, then keeps the log honest:
14
+ * - appends `agent-preset/selected`, so resume/fork rebuild the same
15
+ * composition ("model-visible <-> logged" rule);
16
+ * - appends `plan/mode { active: false }` when plan mode is still
17
+ * active, since the switch itself is a mode decision;
18
+ * - steers a notice message so the model knows the tool set changed.
19
+ *
20
+ * Mitigations for the swapped tool catalog: the omd-planner catalog is a
21
+ * subset of omd-executor's (the executor preset also mounts the plan-mode
22
+ * row), so logged planner tool calls stay renderable under the executor
23
+ * composition. Switching between other omd presets follows the same rule
24
+ * and the model simply receives the new catalog on the next request.
25
+ */
26
+ /** Cordis plugin name. */
27
+ const name = "omd-mode-switch";
28
+ /**
29
+ * No mount-time injection: the roster service is resolved at runtime so a
30
+ * rosterless deployment fails only the /mode command, never the preset
31
+ * mount itself.
32
+ */
33
+ const inject = [];
34
+ /** The OMD presets /mode may switch to. */
35
+ const OMD_PRESET_IDS = [
36
+ "omd-executor",
37
+ "omd-ultraworker",
38
+ "omd-planner",
39
+ "omd-reviewer",
40
+ "omd-explorer",
41
+ "omd-librarian",
42
+ "omd-chat",
43
+ ];
44
+ /** Normalize the command input to a valid omd preset id, or undefined. */
45
+ function normalizeTarget(rawInput) {
46
+ const trimmed = String(rawInput).trim().toLowerCase();
47
+ if (trimmed === "")
48
+ return undefined;
49
+ const candidate = trimmed.startsWith("omd-") ? trimmed : "omd-" + trimmed;
50
+ return OMD_PRESET_IDS.includes(candidate) ? candidate : undefined;
51
+ }
52
+ /** Fold the session's plan/mode events (last one wins). */
53
+ function planModeActive(events) {
54
+ let active = false;
55
+ for (const event of events ?? []) {
56
+ if (event !== undefined && event.type === "plan/mode") {
57
+ active = event.data !== undefined && event.data !== null && event.data.active === true;
58
+ }
59
+ }
60
+ return active;
61
+ }
62
+ /** Execute one /mode invocation through the roster service. */
63
+ async function executeSwitch(ctx, invocation) {
64
+ const agent = invocation.agent;
65
+ const target = normalizeTarget(invocation.rawInput);
66
+ if (target === undefined) {
67
+ return {
68
+ kind: "error",
69
+ text: "Usage: /mode <preset> — valid: " + OMD_PRESET_IDS.join(", "),
70
+ };
71
+ }
72
+ let presets;
73
+ try {
74
+ presets = ctx.get("agentPresets");
75
+ }
76
+ catch {
77
+ presets = undefined;
78
+ }
79
+ if (presets === undefined || presets === null) {
80
+ return {
81
+ kind: "error",
82
+ text: "/mode is unavailable: this deployment composes no agent presets.",
83
+ };
84
+ }
85
+ let current;
86
+ try {
87
+ current = presets.composedPreset(agent.ctx);
88
+ }
89
+ catch {
90
+ current = undefined;
91
+ }
92
+ if (current === target) {
93
+ return { kind: "success", text: "Already running " + target + "." };
94
+ }
95
+ try {
96
+ const preset = await presets.recompose(agent.ctx, target);
97
+ agent.session.append("agent-preset/selected", { agentPreset: preset.id });
98
+ if (planModeActive(agent.session.events)) {
99
+ agent.session.append("plan/mode", { active: false });
100
+ }
101
+ agent.steer(createUserMessage({
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text: "The session switched to the " +
106
+ preset.id +
107
+ " agent preset. Continue in this mode with its tool set, persona, and model routing.",
108
+ },
109
+ ],
110
+ source: {
111
+ kind: "plugin",
112
+ plugin: "omd-mode-switch",
113
+ form: "notice",
114
+ summary: "Session mode switched to " + preset.id,
115
+ },
116
+ }));
117
+ return {
118
+ kind: "success",
119
+ text: "Session preset switched to " + preset.id + " — the next turn runs with that mode's tools and model.",
120
+ };
121
+ }
122
+ catch (error) {
123
+ return {
124
+ kind: "error",
125
+ text: "/mode failed: " + (error instanceof Error ? error.message : String(error)),
126
+ };
127
+ }
128
+ }
129
+ function apply(ctx) {
130
+ if (scopeOf(ctx) === undefined) {
131
+ throw new Error("omd-mode-switch: refusing to mount outside a scoped context; mount this row inside an agent preset");
132
+ }
133
+ ctx.inject(["commands"], (commandCtx) => {
134
+ commandCtx.commands.register({
135
+ name: "mode",
136
+ description: "switch this session to another omd agent preset (tool set + model)",
137
+ input: {
138
+ hint: "<omd-* preset id>",
139
+ images: false,
140
+ },
141
+ handler: (invocation) => executeSwitch(ctx, invocation),
142
+ });
143
+ });
144
+ }
145
+ export { apply, inject, name };
@@ -0,0 +1,146 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { scopeOf } from "@deepseek-ai/dsh-scope";
4
+ /**
5
+ * @module @carljia/omd-dsh/plan
6
+ *
7
+ * omd-plan: plan persistence for the OMD planner mode. It wraps the
8
+ * `tools/post-execute` waterfall and intercepts a successful
9
+ * `exit_plan_mode` approval: the approved plan text is written into the
10
+ * workspace's plan directory (a fixed, code-level convention -- never
11
+ * mentioned in any persona/prompt text), and the tool result content is
12
+ * enriched with the saved file name so the planner's fixed Start Work
13
+ * final step can hand it to the user.
14
+ *
15
+ * Plan directory convention (hardcoded here and in omd-start-work only):
16
+ * <session cwd>/.omd/plans/<slug>-<timestamp>.md
17
+ * The slug derives from the plan's first markdown heading; a timestamp
18
+ * suffix keeps repeated interviews from overwriting each other.
19
+ */
20
+ /** Cordis plugin name. */
21
+ const name = "omd-plan";
22
+ /** No service injection: this row only registers a scoped event listener. */
23
+ const inject = [];
24
+ /** Plan directory segments relative to the session workspace root (cwd). */
25
+ const PLAN_DIR_SEGMENTS = [".omd", "plans"];
26
+ /** The exit tool whose approved plan we persist. */
27
+ const EXIT_PLAN_MODE = "exit_plan_mode";
28
+ /** Maximum slug length (characters). */
29
+ const SLUG_MAX = 48;
30
+ /** The plan's first markdown heading (any level), or undefined when it has none. */
31
+ function firstHeading(plan) {
32
+ for (const line of plan.split("\n")) {
33
+ const match = /^#{1,6}\s+(.+?)\s*$/.exec(line);
34
+ if (match)
35
+ return match[1];
36
+ }
37
+ return undefined;
38
+ }
39
+ /** Derive a filesystem-safe slug from the plan title. */
40
+ function slugify(title) {
41
+ const slug = String(title)
42
+ .normalize("NFKD")
43
+ .toLowerCase()
44
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, SLUG_MAX);
47
+ return slug === "" ? "plan" : slug;
48
+ }
49
+ /** Compact local-ish UTC timestamp for the file name: YYYYMMDD-HHmmss. */
50
+ function timestamp() {
51
+ const d = new Date();
52
+ const pad = (n) => String(n).padStart(2, "0");
53
+ return (d.getUTCFullYear() +
54
+ pad(d.getUTCMonth() + 1) +
55
+ pad(d.getUTCDate()) +
56
+ "-" +
57
+ pad(d.getUTCHours()) +
58
+ pad(d.getUTCMinutes()) +
59
+ pad(d.getUTCSeconds()));
60
+ }
61
+ /** Write the plan into <cwd>/.omd/plans/ and return the absolute file path. */
62
+ async function savePlan(cwd, plan) {
63
+ const dir = join(cwd, ...PLAN_DIR_SEGMENTS);
64
+ await mkdir(dir, { recursive: true });
65
+ const file = join(dir, slugify(firstHeading(plan) ?? "") + "-" + timestamp() + ".md");
66
+ await writeFile(file, plan, "utf8");
67
+ return file;
68
+ }
69
+ /** Display path used in result enrichment and messages (forward slashes). */
70
+ function displayPath(saved) {
71
+ return PLAN_DIR_SEGMENTS.join("/") + "/" + basename(saved);
72
+ }
73
+ /** Subagents never own the plan review -- only the top-level planner does. */
74
+ function isSubagent(agent) {
75
+ return (agent !== undefined &&
76
+ agent !== null &&
77
+ agent.options !== undefined &&
78
+ agent.options !== null &&
79
+ typeof agent.options.subagentDepth === "number" &&
80
+ agent.options.subagentDepth > 0);
81
+ }
82
+ function apply(ctx) {
83
+ if (scopeOf(ctx) === undefined) {
84
+ throw new Error("omd-plan: refusing to mount outside a scoped context; mount this row inside an agent preset");
85
+ }
86
+ ctx.on("tools/post-execute", async (exec, result, next) => {
87
+ const decision = await next();
88
+ if (decision.kind !== "accept" || decision.value !== undefined)
89
+ return decision;
90
+ if (exec === undefined || exec.name !== EXIT_PLAN_MODE)
91
+ return decision;
92
+ if (result.isError)
93
+ return decision;
94
+ const agent = exec.agent;
95
+ if (agent === undefined || isSubagent(agent))
96
+ return decision;
97
+ const args = exec.arguments;
98
+ const plan = args !== undefined && args !== null && typeof args.plan === "string" ? args.plan : undefined;
99
+ if (plan === undefined)
100
+ return decision;
101
+ const cwd = agent.session !== undefined &&
102
+ agent.session.header !== undefined &&
103
+ typeof agent.session.header.cwd === "string"
104
+ ? agent.session.header.cwd
105
+ : "";
106
+ if (cwd === "") {
107
+ // No workspace root to save into: fail closed but tell the model, so
108
+ // the planner does not promise a file name it never produced.
109
+ return withNotice(decision, result, {
110
+ type: "text",
111
+ text: "The approved plan could NOT be saved automatically: this session has no workspace directory. Ask the user how to proceed.",
112
+ });
113
+ }
114
+ try {
115
+ const saved = await savePlan(cwd, plan);
116
+ return withNotice(decision, result, {
117
+ type: "text",
118
+ text: "Plan saved to " +
119
+ displayPath(saved) +
120
+ ". Start work: run /start-work " +
121
+ basename(saved) +
122
+ " in an omd-executor session, or switch this session with /mode omd-executor and continue here.",
123
+ });
124
+ }
125
+ catch (error) {
126
+ return withNotice(decision, result, {
127
+ type: "text",
128
+ text: "The approved plan could NOT be saved automatically: " +
129
+ (error instanceof Error ? error.message : String(error)) +
130
+ ". Ask the user how to proceed.",
131
+ });
132
+ }
133
+ });
134
+ }
135
+ /** Keep the accepted decision, appending one text block to its content. */
136
+ function withNotice(decision, result, block) {
137
+ const base = Array.isArray(decision.content) ? decision.content : result.content ?? [];
138
+ return {
139
+ kind: "accept",
140
+ content: [...base, block],
141
+ ...(decision.additionalContexts !== undefined
142
+ ? { additionalContexts: decision.additionalContexts }
143
+ : {}),
144
+ };
145
+ }
146
+ export { apply, inject, name };