@carljia/omd-dsh 0.1.2 → 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/README.md CHANGED
@@ -38,15 +38,15 @@ tier 差异化子代理委派(等价 OMD 的 task(category=…))。仅限 pr
38
38
  omd-dsh <command> [--harness <路径>] [--dry-run]
39
39
  ```
40
40
 
41
- - `omd-dsh sync` — 把 presets/ 与 vendored 行模块同步到 `<DSH_HOME>/.agent-presets/`(hash 保护、orphan 报告、非管理目录零操作)。每个 preset 的 omd-mode / omd-task 行由 `omd-matrix.json` 渲染。
42
- - `omd-dsh setup` — 交互式向导:先读取 DSH 已有模型,再引导逐模式/逐 tier 选择模型,写回 `omd-matrix.json` 并可选立即同步。
41
+ - `omd-dsh sync` — 把 presets/ 与 vendored 行模块同步到 `<DSH_HOME>/.agent-presets/`(hash 保护、orphan 报告、非管理目录零操作)。每个 preset 的 omd-mode / omd-task 行由用户矩阵 `<DSH_HOME>/omd-matrix.json` 渲染。
42
+ - `omd-dsh setup` — 交互式向导:先读取 DSH 已有模型,再引导逐模式/逐 tier 选择模型,写回 `<DSH_HOME>/omd-matrix.json` 并可选立即同步。
43
43
  - `omd-dsh models` — 打印发现的 DSH 模型目录(非交互)。
44
44
 
45
45
  细节见 docs/ARCHITECTURE.md 的「vendored 分发与跨树符号风险」。
46
46
 
47
- ## 集中配置:omd-matrix.json
47
+ ## 集中配置:<DSH_HOME>/omd-matrix.json
48
48
 
49
- 所有模式的模型路由(provider/model/reasoningEffort)与 omd_task 各 tier 的模型集中在一个 `omd-matrix.json` 里:`omd-dsh setup` 生成/更新它,`omd-dsh sync` 读取它并渲染进各 preset。preset 里 `# [omd-dsh:mode:start] / [omd-dsh:mode:end]` 与 `# [omd-dsh:task:start] / [omd-dsh:task:end]` 之间的区域是自动生成的——改模型请改矩阵后跑 sync,不要手改 fence 之间的内容。
49
+ 所有模式的模型路由(provider/model/reasoningEffort)与 omd_task 各 tier 的模型集中在用户矩阵 `<DSH_HOME>/omd-matrix.json`(默认 `~/.dsh/omd-matrix.json`):`omd-dsh sync` 首次运行把随包的 deepseek 默认矩阵 `omd-matrix.default.json` 复制为默认配置(或从旧版本包内位置迁移一次),`omd-dsh setup` 更新它,`omd-dsh sync` 读取它并渲染进各 preset。仓库与 npm 包只携带默认矩阵文件,**不含任何个人模型配置**——个人模型配置只留在本机。preset 里 `# [omd-dsh:mode:start] / [omd-dsh:mode:end]` 与 `# [omd-dsh:task:start] / [omd-dsh:task:end]` 之间的区域是自动生成的——改模型请改矩阵后跑 sync,不要手改 fence 之间的内容。
50
50
 
51
51
  ## 开发
52
52
 
package/lib/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { promises as fs, existsSync, realpathSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { promises as fs, existsSync, mkdirSync, realpathSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { dirname, join, relative, resolve, basename } from "node:path";
5
5
  import { homedir } from "node:os";
@@ -10,25 +10,34 @@ import { createInterface } from "node:readline/promises";
10
10
  * omd-dsh CLI
11
11
  *
12
12
  * omd-dsh sync materialize presets into <DSH_HOME>/.agent-presets,
13
- * rendering each preset's omd-mode / omd-task rows from
14
- * omd-matrix.json (the single source for model routing and
15
- * tier delegation).
13
+ * rendering each preset's omd-mode / omd-task rows from the
14
+ * user's model matrix at <DSH_HOME>/omd-matrix.json. On
15
+ * first run the shipped deepseek default matrix
16
+ * (omd-matrix.default.json) is copied there; personal
17
+ * model settings stay on the user's machine and are never
18
+ * shipped or uploaded.
16
19
  * omd-dsh setup interactive wizard: discover the models DSH already has,
17
20
  * then guide per-mode and per-tier model selection.
18
21
  * omd-dsh models print the discovered model catalog (non-interactive).
19
22
  *
20
23
  * Distribution model (vendored + harness-anchored imports) is unchanged from
21
24
  * the original omd-dsh sync: presets/omd-* are copied into .agent-presets/;
22
- * their omd-mode/omd-task rows reference ../.omd-vendor/omd-mode.mjs /
23
- * omd-task.mjs by relative path; the vendored modules are copied into
24
- * .agent-presets/.omd-vendor/ with bare @deepseek-ai/* imports rewritten to
25
- * absolute file:// URLs into the harness node_modules tree.
25
+ * their omd-mode / omd-task / omd-plan / omd-start-work / omd-mode-switch
26
+ * rows reference ../.omd-vendor/*.mjs by relative path; the vendored modules
27
+ * are copied into .agent-presets/.omd-vendor/ with bare @deepseek-ai/*
28
+ * imports rewritten to absolute file:// URLs into the harness node_modules
29
+ * tree.
26
30
  */
27
31
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
- const VENDOR_SOURCES = ["omd-mode.mjs", "omd-task.mjs", "omd-ulw.mjs"];
29
- const MATRIX_PATH = join(PACKAGE_ROOT, "omd-matrix.json");
32
+ const VENDOR_SOURCES = ["omd-mode.mjs", "omd-task.mjs", "omd-ulw.mjs", "omd-plan.mjs", "omd-start-work.mjs", "omd-mode-switch.mjs"];
33
+ /** User-owned model matrix: lives under DSH_HOME, never inside the package or the repo. */
34
+ const MATRIX_PATH = join(dshHome(), "omd-matrix.json");
35
+ /** Pre-migration location (package root) — migrated to MATRIX_PATH once when present. */
36
+ const LEGACY_MATRIX_PATH = join(PACKAGE_ROOT, "omd-matrix.json");
30
37
  const MODE_FENCE = { start: "# [omd-dsh:mode:start]", end: "# [omd-dsh:mode:end]" };
31
38
  const TASK_FENCE = { start: "# [omd-dsh:task:start]", end: "# [omd-dsh:task:end]" };
39
+ /** Presets that were renamed: old directory name -> new preset name. */
40
+ const RENAMED_FROM = { "omd-architect": "omd-ultraworker" };
32
41
  function usage() {
33
42
  return [
34
43
  "omd-dsh <command> [options]",
@@ -251,15 +260,66 @@ async function collectSourceFiles(rootDir) {
251
260
  return out;
252
261
  }
253
262
  // ── matrix ──
254
- function loadMatrix() {
255
- if (!existsSync(MATRIX_PATH))
256
- throw new Error("omd-dsh: missing " + MATRIX_PATH + " (run `omd-dsh setup` to create it, or restore it from source control)");
257
- const parsed = JSON.parse(readFileSync(MATRIX_PATH, "utf8"));
258
- if (parsed === null || typeof parsed !== "object" || parsed.modes === null || typeof parsed.modes !== "object")
259
- throw new Error("omd-dsh: malformed " + MATRIX_PATH);
263
+ /**
264
+ * The shipped default matrix (deepseek models) copied to
265
+ * <DSH_HOME>/omd-matrix.json on first run. The repo and the npm package
266
+ * ship ONLY this defaults file — personal model settings live exclusively
267
+ * in the user's <DSH_HOME>/omd-matrix.json and are never uploaded.
268
+ */
269
+ const DEFAULT_MATRIX_PATH = join(PACKAGE_ROOT, "omd-matrix.default.json");
270
+ /** Parse matrix text; undefined when it is not a usable matrix document. */
271
+ function parseMatrix(text) {
272
+ try {
273
+ const parsed = JSON.parse(text);
274
+ if (parsed !== null && typeof parsed === "object" && parsed.modes !== null && typeof parsed.modes === "object")
275
+ return parsed;
276
+ }
277
+ catch { /* fall through */ }
278
+ return undefined;
279
+ }
280
+ /** Read the shipped default matrix, failing loud when the package is broken. */
281
+ function readDefaultMatrix() {
282
+ if (!existsSync(DEFAULT_MATRIX_PATH))
283
+ throw new Error("omd-dsh: missing default matrix file " + DEFAULT_MATRIX_PATH + " (broken package — reinstall @carljia/omd-dsh)");
284
+ const parsed = parseMatrix(readFileSync(DEFAULT_MATRIX_PATH, "utf8"));
285
+ if (parsed === undefined)
286
+ throw new Error("omd-dsh: malformed default matrix file " + DEFAULT_MATRIX_PATH + " (broken package — reinstall @carljia/omd-dsh)");
287
+ return parsed;
288
+ }
289
+ /** Write a matrix file, creating its parent directory when needed. */
290
+ function writeMatrixFile(path, text) {
291
+ mkdirSync(dirname(path), { recursive: true });
292
+ writeFileSync(path, text, "utf8");
293
+ }
294
+ /**
295
+ * Load the user's model matrix from <DSH_HOME>/omd-matrix.json, creating it
296
+ * on first run: a still-valid package-root matrix (previous versions) is
297
+ * migrated over, otherwise the shipped deepseek default matrix is written.
298
+ * Dry runs never touch the filesystem and use the shipped defaults in memory.
299
+ */
300
+ function loadMatrix(flags) {
301
+ if (!existsSync(MATRIX_PATH)) {
302
+ if (flags.dryRun)
303
+ return JSON.parse(JSON.stringify(readDefaultMatrix()));
304
+ const legacyText = existsSync(LEGACY_MATRIX_PATH) ? readFileSync(LEGACY_MATRIX_PATH, "utf8") : undefined;
305
+ if (legacyText !== undefined && parseMatrix(legacyText) !== undefined) {
306
+ writeMatrixFile(MATRIX_PATH, legacyText);
307
+ console.log("omd-dsh: migrated omd-matrix.json: " + LEGACY_MATRIX_PATH + " -> " + MATRIX_PATH);
308
+ console.log("omd-dsh: customize the model matrix any time with `omd-dsh setup`.");
309
+ }
310
+ else {
311
+ const defaults = readDefaultMatrix();
312
+ writeMatrixFile(MATRIX_PATH, JSON.stringify(defaults, null, 2) + "\n");
313
+ console.log("omd-dsh: generated " + MATRIX_PATH + " from the shipped deepseek default matrix; customize it any time with `omd-dsh setup`.");
314
+ }
315
+ }
316
+ const text = readFileSync(MATRIX_PATH, "utf8");
317
+ const parsed = parseMatrix(text);
318
+ if (parsed === undefined)
319
+ throw new Error("omd-dsh: malformed " + MATRIX_PATH + " (restore it, or delete it and run omd-dsh setup)");
260
320
  return parsed;
261
321
  }
262
- function saveMatrix(m) { writeFileSync(MATRIX_PATH, JSON.stringify(m, null, 2) + "\n", "utf8"); }
322
+ function saveMatrix(m) { writeMatrixFile(MATRIX_PATH, JSON.stringify(m, null, 2) + "\n"); }
263
323
  // ── row rendering (relative indents; the splice prepends the fence indent) ──
264
324
  function q(s) { return JSON.stringify(s); }
265
325
  function renderModeRow(modeId, cfg) {
@@ -370,14 +430,14 @@ function discoverModels() {
370
430
  }
371
431
  // ── sync ──
372
432
  async function runSync(flags, harnessNodeModules) {
373
- const matrix = loadMatrix();
433
+ const matrix = loadMatrix(flags);
374
434
  const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
375
435
  const sourceVersion = manifest.version;
376
436
  const presetsSourceDir = join(PACKAGE_ROOT, "presets");
377
437
  const vendorSourceDir = join(PACKAGE_ROOT, "lib", "vendor");
378
438
  const agentPresetsRoot = join(dshHome(), ".agent-presets");
379
439
  const presetNames = (await fs.readdir(presetsSourceDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
380
- const report = { synced: [], updated: [], conflicts: [], skipped: [], orphan: [] };
440
+ const report = { synced: [], updated: [], conflicts: [], skipped: [], orphan: [], removed: [] };
381
441
  const log = (kind, text) => report[kind].push(text);
382
442
  for (const presetName of presetNames) {
383
443
  const modeId = presetName.replace(/^omd-/, "");
@@ -488,19 +548,67 @@ async function runSync(flags, harnessNodeModules) {
488
548
  continue;
489
549
  if (presetNames.includes(entry.name))
490
550
  continue;
491
- if (readMeta(join(agentPresetsRoot, entry.name)) !== undefined)
551
+ const orphanDir = join(agentPresetsRoot, entry.name);
552
+ const meta = readMeta(orphanDir);
553
+ if (meta === undefined)
554
+ continue;
555
+ const renamedTo = RENAMED_FROM[entry.name];
556
+ if (renamedTo !== undefined && presetNames.includes(renamedTo)) {
557
+ const dirty = await locallyModified(orphanDir, meta);
558
+ if (dirty === undefined) {
559
+ if (!flags.dryRun)
560
+ await fs.rm(orphanDir, { recursive: true, force: true });
561
+ log("removed", entry.name + "/ (renamed to " + renamedTo + " and unmodified -- removed" + (flags.dryRun ? ", dry-run" : "") + ")");
562
+ }
563
+ else {
564
+ log("conflicts", entry.name + "/ (renamed to " + renamedTo + " but locally modified -- keeping your version: " + dirty + ")");
565
+ }
566
+ }
567
+ else {
492
568
  log("orphan", entry.name + "/ (was installed by omd-dsh but no longer ships with v" + sourceVersion + " -- left untouched)");
569
+ }
493
570
  }
494
571
  }
495
572
  console.log("omd-dsh sync: DSH_HOME=" + dshHome());
573
+ console.log("omd-dsh sync: matrix=" + MATRIX_PATH + " (customize the model matrix any time with `omd-dsh setup`)");
496
574
  console.log("omd-dsh sync: harness node_modules=" + harnessNodeModules);
497
575
  console.log("omd-dsh sync: source version=" + sourceVersion + (flags.dryRun ? " (dry-run)" : ""));
498
- for (const key of ["synced", "updated", "skipped", "conflicts", "orphan"])
576
+ for (const key of ["synced", "updated", "skipped", "conflicts", "orphan", "removed"])
499
577
  for (const line of report[key])
500
578
  console.log(" [" + key + "] " + line);
501
- const summary = ["synced", "updated", "conflicts", "orphan"].map((key) => report[key].length + " " + key).join(", ");
579
+ const summary = ["synced", "updated", "conflicts", "orphan", "removed"].map((key) => report[key].length + " " + key).join(", ");
502
580
  console.log("omd-dsh sync: " + summary + (flags.dryRun ? " (dry-run)" : ""));
503
581
  }
582
+ /**
583
+ * Whether one omd-dsh-managed preset directory differs from the hashes its
584
+ * .omd-meta.json recorded. Returns a description of the first discrepancy,
585
+ * or undefined when every recorded file is present and unmodified and no
586
+ * extra files exist.
587
+ */
588
+ async function locallyModified(dir, meta) {
589
+ const recorded = meta.files ?? {};
590
+ const current = {};
591
+ const walk = async (d) => {
592
+ for (const entry of await fs.readdir(d, { withFileTypes: true })) {
593
+ const full = join(d, entry.name);
594
+ if (entry.isDirectory())
595
+ await walk(full);
596
+ else if (entry.name !== ".omd-meta.json") {
597
+ const rel = relative(dir, full).split("\\").join("/");
598
+ current[rel] = sha256(await fs.readFile(full, "utf8"));
599
+ }
600
+ }
601
+ };
602
+ await walk(dir);
603
+ for (const rel of new Set([...Object.keys(recorded), ...Object.keys(current)])) {
604
+ if (current[rel] === undefined)
605
+ return "missing file " + rel;
606
+ const recordedHash = recorded[rel] !== undefined && typeof recorded[rel] === "object" && recorded[rel] !== null ? recorded[rel].sha256 : undefined;
607
+ if (typeof recordedHash !== "string" || recordedHash !== current[rel])
608
+ return "modified file " + rel;
609
+ }
610
+ return undefined;
611
+ }
504
612
  // ── setup (interactive) ──
505
613
  function splitModel(answer) {
506
614
  const a = answer.trim();
@@ -511,7 +619,7 @@ function splitModel(answer) {
511
619
  return { provider: "deepseek-official", model: a };
512
620
  }
513
621
  async function runSetup(flags, harnessNodeModules) {
514
- const matrix = loadMatrix();
622
+ const matrix = loadMatrix(flags);
515
623
  const { models, currentDefault } = discoverModels();
516
624
  console.log("omd-dsh setup: 发现 DSH 已有模型:");
517
625
  for (const m of models)
@@ -521,7 +629,7 @@ async function runSetup(flags, harnessNodeModules) {
521
629
  console.log("");
522
630
  const rl = createInterface({ input: process.stdin, output: process.stdout });
523
631
  const ask = async (question) => (await rl.question(question)).trim();
524
- const modeOrder = ["executor", "architect", "planner", "reviewer", "explorer", "librarian", "chat"];
632
+ const modeOrder = ["executor", "ultraworker", "planner", "reviewer", "explorer", "librarian", "chat"];
525
633
  for (const modeId of modeOrder) {
526
634
  const cfg = matrix.modes[modeId] ?? {};
527
635
  const cur = cfg.provider && cfg.model ? cfg.provider + "/" + cfg.model : "";
package/lib/mode.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @module @carljia/omd-dsh/mode
3
+ *
4
+ * omd-mode-switch: human-facing `/mode` command -- switch the CURRENT
5
+ * session to another OMD agent preset, including mid-conversation.
6
+ *
7
+ * DSH's native `agentPreset.select` host API refuses to recompose a
8
+ * session that has already started (its preset is fixed at the UI level),
9
+ * and the AgentPresets.recompose method itself performs no history check
10
+ * ("the CALLER owns that check"). This row deliberately performs the
11
+ * in-session recompose, then keeps the log honest:
12
+ * - appends `agent-preset/selected`, so resume/fork rebuild the same
13
+ * composition ("model-visible <-> logged" rule);
14
+ * - appends `plan/mode { active: false }` when plan mode is still
15
+ * active, since the switch itself is a mode decision;
16
+ * - steers a notice message so the model knows the tool set changed.
17
+ *
18
+ * Mitigations for the swapped tool catalog: the omd-planner catalog is a
19
+ * subset of omd-executor's (the executor preset also mounts the plan-mode
20
+ * row), so logged planner tool calls stay renderable under the executor
21
+ * composition. Switching between other omd presets follows the same rule
22
+ * and the model simply receives the new catalog on the next request.
23
+ */
24
+ /** Cordis plugin name. */
25
+ declare const name = "omd-mode-switch";
26
+ /**
27
+ * No mount-time injection: the roster service is resolved at runtime so a
28
+ * rosterless deployment fails only the /mode command, never the preset
29
+ * mount itself.
30
+ */
31
+ declare const inject: never[];
32
+ declare function apply(ctx: any): void;
33
+ export { apply, inject, name };
package/lib/mode.js ADDED
@@ -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 };
package/lib/plan.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @module @carljia/omd-dsh/plan
3
+ *
4
+ * omd-plan: plan persistence for the OMD planner mode. It wraps the
5
+ * `tools/post-execute` waterfall and intercepts a successful
6
+ * `exit_plan_mode` approval: the approved plan text is written into the
7
+ * workspace's plan directory (a fixed, code-level convention -- never
8
+ * mentioned in any persona/prompt text), and the tool result content is
9
+ * enriched with the saved file name so the planner's fixed Start Work
10
+ * final step can hand it to the user.
11
+ *
12
+ * Plan directory convention (hardcoded here and in omd-start-work only):
13
+ * <session cwd>/.omd/plans/<slug>-<timestamp>.md
14
+ * The slug derives from the plan's first markdown heading; a timestamp
15
+ * suffix keeps repeated interviews from overwriting each other.
16
+ */
17
+ /** Cordis plugin name. */
18
+ declare const name = "omd-plan";
19
+ /** No service injection: this row only registers a scoped event listener. */
20
+ declare const inject: never[];
21
+ declare function apply(ctx: any): void;
22
+ export { apply, inject, name };
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 };