@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/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, 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]",
@@ -37,7 +46,7 @@ function usage() {
37
46
  " setup interactive: discover DSH models, then guide per-mode/tier model selection",
38
47
  " models print the discovered DSH model catalog",
39
48
  " options:",
40
- " --harness <path> node_modules dir of the DSH harness install (auto-detected via the dsh executable)",
49
+ " --harness <path> node_modules dir of the DSH harness install (saved locally after first use; otherwise auto-detected)",
41
50
  " --dry-run (sync) print planned writes without touching the filesystem",
42
51
  " --verbose (sync) print per-file detail",
43
52
  ].join("\n");
@@ -55,6 +64,60 @@ function findNodeModules(start) {
55
64
  current = parent;
56
65
  }
57
66
  }
67
+ function harnessCachePath() { return join(dshHome(), "omd-dsh-harness.json"); }
68
+ /** Read the cached harness node_modules, ignoring a stale/missing entry. */
69
+ function readCachedHarness() {
70
+ try {
71
+ const p = harnessCachePath();
72
+ if (!existsSync(p))
73
+ return undefined;
74
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
75
+ const nm = parsed && typeof parsed === "object" ? parsed.harnessNodeModules : undefined;
76
+ if (typeof nm !== "string" || nm === "")
77
+ return undefined;
78
+ if (!existsSync(join(nm, "@deepseek-ai", "dsh-scope", "package.json")))
79
+ return undefined;
80
+ return nm;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /** Persist the resolved harness node_modules for later runs (best-effort). */
87
+ function writeCachedHarness(harnessNodeModules) {
88
+ try {
89
+ writeFileSync(harnessCachePath(), JSON.stringify({ harnessNodeModules }, null, 2) + "\n", "utf8");
90
+ }
91
+ catch { /* best-effort */ }
92
+ }
93
+ /**
94
+ * Resolve the DSH harness node_modules:
95
+ * 1. --harness flag (and cache it for later);
96
+ * 2. auto-detect via the dsh executable on PATH;
97
+ * 3. fall back to the locally cached value.
98
+ */
99
+ function resolveHarness(flags) {
100
+ let nm;
101
+ if (flags.harness !== undefined) {
102
+ nm = findNodeModules(flags.harness);
103
+ if (nm !== undefined) {
104
+ try {
105
+ nm = realpathSync(nm);
106
+ writeCachedHarness(nm);
107
+ }
108
+ catch { /* keep nm as-is */ }
109
+ }
110
+ return nm;
111
+ }
112
+ nm = locateHarnessViaDsh() ?? locateHarnessViaNpxCache() ?? readCachedHarness();
113
+ if (nm !== undefined) {
114
+ try {
115
+ nm = realpathSync(nm);
116
+ }
117
+ catch { /* keep */ }
118
+ }
119
+ return nm;
120
+ }
58
121
  function locateHarnessViaDsh() {
59
122
  const candidates = [];
60
123
  try {
@@ -79,6 +142,51 @@ function locateHarnessViaDsh() {
79
142
  }
80
143
  return undefined;
81
144
  }
145
+ /** Candidate npx cache roots where a non-global DSH install may live. */
146
+ function npxCacheRoots() {
147
+ const roots = [];
148
+ if (process.platform === "win32") {
149
+ const localAppData = process.env.LOCALAPPDATA;
150
+ if (localAppData)
151
+ roots.push(join(localAppData, "npm-cache", "_npx"));
152
+ const appData = process.env.APPDATA;
153
+ if (appData)
154
+ roots.push(join(appData, "npm-cache", "_npx"));
155
+ }
156
+ else {
157
+ roots.push(join(homedir(), ".npm", "_npx"));
158
+ }
159
+ return roots;
160
+ }
161
+ /**
162
+ * Best-effort scan of the npx cache for a DSH install whose node_modules
163
+ * carries @deepseek-ai/dsh-scope. Picks the most recently touched one.
164
+ */
165
+ function locateHarnessViaNpxCache() {
166
+ const matches = [];
167
+ for (const root of npxCacheRoots()) {
168
+ let entries;
169
+ try {
170
+ entries = readdirSync(root);
171
+ }
172
+ catch {
173
+ continue;
174
+ }
175
+ for (const entry of entries) {
176
+ const nm = join(root, entry, "node_modules");
177
+ if (!existsSync(join(nm, "@deepseek-ai", "dsh-scope", "package.json")))
178
+ continue;
179
+ let mtime = 0;
180
+ try {
181
+ mtime = statSync(join(root, entry)).mtimeMs;
182
+ }
183
+ catch { /* keep 0 */ }
184
+ matches.push({ nm, mtime });
185
+ }
186
+ }
187
+ matches.sort((a, b) => b.mtime - a.mtime);
188
+ return matches.length > 0 ? matches[0].nm : undefined;
189
+ }
82
190
  function resolveHarnessModule(harnessNodeModules, specifier) {
83
191
  const segments = specifier.split("/");
84
192
  const scope = segments[0].startsWith("@") ? segments[0] + "/" + segments[1] : segments[0];
@@ -152,15 +260,66 @@ async function collectSourceFiles(rootDir) {
152
260
  return out;
153
261
  }
154
262
  // ── matrix ──
155
- function loadMatrix() {
156
- if (!existsSync(MATRIX_PATH))
157
- throw new Error("omd-dsh: missing " + MATRIX_PATH + " (run `omd-dsh setup` to create it, or restore it from source control)");
158
- const parsed = JSON.parse(readFileSync(MATRIX_PATH, "utf8"));
159
- if (parsed === null || typeof parsed !== "object" || parsed.modes === null || typeof parsed.modes !== "object")
160
- 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)");
161
320
  return parsed;
162
321
  }
163
- 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"); }
164
323
  // ── row rendering (relative indents; the splice prepends the fence indent) ──
165
324
  function q(s) { return JSON.stringify(s); }
166
325
  function renderModeRow(modeId, cfg) {
@@ -271,14 +430,14 @@ function discoverModels() {
271
430
  }
272
431
  // ── sync ──
273
432
  async function runSync(flags, harnessNodeModules) {
274
- const matrix = loadMatrix();
433
+ const matrix = loadMatrix(flags);
275
434
  const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
276
435
  const sourceVersion = manifest.version;
277
436
  const presetsSourceDir = join(PACKAGE_ROOT, "presets");
278
437
  const vendorSourceDir = join(PACKAGE_ROOT, "lib", "vendor");
279
438
  const agentPresetsRoot = join(dshHome(), ".agent-presets");
280
439
  const presetNames = (await fs.readdir(presetsSourceDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
281
- const report = { synced: [], updated: [], conflicts: [], skipped: [], orphan: [] };
440
+ const report = { synced: [], updated: [], conflicts: [], skipped: [], orphan: [], removed: [] };
282
441
  const log = (kind, text) => report[kind].push(text);
283
442
  for (const presetName of presetNames) {
284
443
  const modeId = presetName.replace(/^omd-/, "");
@@ -389,19 +548,67 @@ async function runSync(flags, harnessNodeModules) {
389
548
  continue;
390
549
  if (presetNames.includes(entry.name))
391
550
  continue;
392
- 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 {
393
568
  log("orphan", entry.name + "/ (was installed by omd-dsh but no longer ships with v" + sourceVersion + " -- left untouched)");
569
+ }
394
570
  }
395
571
  }
396
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`)");
397
574
  console.log("omd-dsh sync: harness node_modules=" + harnessNodeModules);
398
575
  console.log("omd-dsh sync: source version=" + sourceVersion + (flags.dryRun ? " (dry-run)" : ""));
399
- for (const key of ["synced", "updated", "skipped", "conflicts", "orphan"])
576
+ for (const key of ["synced", "updated", "skipped", "conflicts", "orphan", "removed"])
400
577
  for (const line of report[key])
401
578
  console.log(" [" + key + "] " + line);
402
- 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(", ");
403
580
  console.log("omd-dsh sync: " + summary + (flags.dryRun ? " (dry-run)" : ""));
404
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
+ }
405
612
  // ── setup (interactive) ──
406
613
  function splitModel(answer) {
407
614
  const a = answer.trim();
@@ -412,7 +619,7 @@ function splitModel(answer) {
412
619
  return { provider: "deepseek-official", model: a };
413
620
  }
414
621
  async function runSetup(flags, harnessNodeModules) {
415
- const matrix = loadMatrix();
622
+ const matrix = loadMatrix(flags);
416
623
  const { models, currentDefault } = discoverModels();
417
624
  console.log("omd-dsh setup: 发现 DSH 已有模型:");
418
625
  for (const m of models)
@@ -422,7 +629,7 @@ async function runSetup(flags, harnessNodeModules) {
422
629
  console.log("");
423
630
  const rl = createInterface({ input: process.stdin, output: process.stdout });
424
631
  const ask = async (question) => (await rl.question(question)).trim();
425
- const modeOrder = ["executor", "architect", "planner", "reviewer", "explorer", "librarian", "chat"];
632
+ const modeOrder = ["executor", "ultraworker", "planner", "reviewer", "explorer", "librarian", "chat"];
426
633
  for (const modeId of modeOrder) {
427
634
  const cfg = matrix.modes[modeId] ?? {};
428
635
  const cur = cfg.provider && cfg.model ? cfg.provider + "/" + cfg.model : "";
@@ -451,7 +658,7 @@ async function runSetup(flags, harnessNodeModules) {
451
658
  rl.close();
452
659
  if (syncNow === "" || /^y/i.test(syncNow)) {
453
660
  if (harnessNodeModules === undefined) {
454
- console.error("omd-dsh setup: 无法定位 DSH harness node_modules,跳过同步。稍后运行 `omd-dsh sync --harness <路径>`。");
661
+ console.error("omd-dsh setup: 无法定位 DSH harness node_modules,跳过同步。首次请运行 `omd-dsh sync --harness <路径>`(之后会自动缓存,无需重复指定)。");
455
662
  }
456
663
  else {
457
664
  await runSync(flags, harnessNodeModules);
@@ -501,19 +708,15 @@ async function main() {
501
708
  return;
502
709
  }
503
710
  if (command === "setup") {
504
- let harnessNodeModules = flags.harness !== undefined ? findNodeModules(flags.harness) : locateHarnessViaDsh();
505
- if (harnessNodeModules !== undefined)
506
- harnessNodeModules = realpathSync(harnessNodeModules);
507
- await runSetup(flags, harnessNodeModules);
711
+ await runSetup(flags, resolveHarness(flags));
508
712
  return;
509
713
  }
510
- let harnessNodeModules = flags.harness !== undefined ? findNodeModules(flags.harness) : locateHarnessViaDsh();
714
+ const harnessNodeModules = resolveHarness(flags);
511
715
  if (harnessNodeModules === undefined || !existsSync(join(harnessNodeModules, "@deepseek-ai", "dsh-scope", "package.json"))) {
512
- console.error("omd-dsh sync: cannot locate the DSH harness node_modules.\n - ensure the dsh executable is on PATH, or\n - pass --harness <path to the harness node_modules directory>.\n\n" + usage());
716
+ console.error("omd-dsh sync: cannot locate the DSH harness node_modules.\n - ensure the dsh executable is on PATH, or\n - pass --harness <path to the harness node_modules directory> (saved for later runs).\n\n" + usage());
513
717
  process.exitCode = 2;
514
718
  return;
515
719
  }
516
- harnessNodeModules = realpathSync(harnessNodeModules);
517
720
  await runSync(flags, harnessNodeModules);
518
721
  }
519
722
  main().catch((error) => {
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 };