@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 +4 -4
- package/lib/cli.js +132 -24
- package/lib/mode.d.ts +33 -0
- package/lib/mode.js +145 -0
- package/lib/plan.d.ts +22 -0
- package/lib/plan.js +146 -0
- package/lib/startwork.d.ts +17 -0
- package/lib/startwork.js +147 -0
- package/lib/vendor/omd-mode-switch.mjs +145 -0
- package/lib/vendor/omd-plan.mjs +146 -0
- package/lib/vendor/omd-start-work.mjs +147 -0
- package/{omd-matrix.json → omd-matrix.default.json} +3 -9
- package/package.json +2 -2
- package/presets/omd-chat/agent.cordis.yml +2 -0
- package/presets/omd-executor/agent.cordis.yml +28 -1
- package/presets/omd-explorer/agent.cordis.yml +2 -0
- package/presets/omd-librarian/agent.cordis.yml +2 -0
- package/presets/omd-planner/agent.cordis.yml +8 -1
- package/presets/omd-reviewer/agent.cordis.yml +2 -0
- package/presets/{omd-architect → omd-ultraworker}/agent.cordis.yml +16 -2
- package/presets/omd-ultraworker/preset.yml +3 -0
- package/presets/omd-architect/preset.yml +0 -3
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
|
|
42
|
-
- `omd-dsh setup` — 交互式向导:先读取 DSH 已有模型,再引导逐模式/逐 tier 选择模型,写回
|
|
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
|
-
##
|
|
47
|
+
## 集中配置:<DSH_HOME>/omd-matrix.json
|
|
48
48
|
|
|
49
|
-
所有模式的模型路由(provider/model/reasoningEffort)与 omd_task 各 tier
|
|
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
|
|
15
|
-
*
|
|
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
|
|
23
|
-
* omd-
|
|
24
|
-
* .agent-presets/.omd-vendor/ with bare @deepseek-ai/*
|
|
25
|
-
* absolute file:// URLs into the harness node_modules
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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) {
|
|
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
|
-
|
|
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", "
|
|
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 };
|