dsh-trellis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SajoLuo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # dsh-trellis
2
+
3
+ DeepSeek Harness (dsh) host 插件:把 [Trellis](https://github.com/mindfold-ai/Trellis) 工作流带进 dsh,并尽量沿用 DSH 原生能力补齐 Codex / Claude Code 级体验:
4
+
5
+ 1. **每轮 workflow-state 面包屑注入** — 在每个 `agent/pre-step`,解析项目 `.trellis/workflow.md` 的 `[workflow-state:*]` 块 + 活跃任务状态(no_task / planning / in_progress),把对应面包屑注入会话(与 `dsh-agent-instructions` 的注入管道相同)。未变化不重复注入;被压缩后自动重注入;提示词里出现独立单词 `no-trellis` 可跳过当轮。
6
+ 2. **隔离的原生会话上下文身份** — 从当前 agent 的 DSH 原生 session header 生成受管的 `DSH_TRELLIS_CONTEXT_ID`,让 `task.py start/create/current` 解析到会话级 active-task 指针,并在子代理身份不同于 shell 自身 session 时优先使用被转发的身份;插件命令启动的子进程也显式使用同一个 DSH 身份。
7
+ 3. **原生子代理同步** — 安装插件后,Trellis 角色可用 DSH continuable 后台子代理;主会话先继续独立工作,耗尽后调用事件驱动的 `trellis_wait`。它监听 DSH 的 `subagent/end`,只在原生 settlement notice 已排入父会话后返回,不用 shell sleep、轮询或 `job_output`。
8
+ 4. **`/trellis` 命令** — `/trellis-status`(活跃任务 + git 状态)、`/trellis-finish`(只读检查 + 安全收尾清单,不提前清 active-task 指针)。命令输出不进模型历史;真正的会话收尾走技能面 `/trellis-finish-work`,由技能先归档再写 journal。
9
+
10
+ 非 Trellis 项目不注入面包屑;命令只有被用户显式调用时才会检查并返回“未初始化 Trellis”。
11
+
12
+ 这是 Trellis DSH 适配的**可选伴侣插件**,不是使用 `trellis init --dsh` 的前置条件。workflow 会按能力选择路径:存在 `trellis_wait` 时才使用 continuable 后台派发和事件汇合;未安装插件时,依赖结果的子代理从一开始就用 `run_in_background: false` 前台派发。两条路径都禁止 sleep 或轮询,Trellis CLI 也不会自动安装任何 DSH profile 插件。
13
+
14
+ ## 安装
15
+
16
+ ```powershell
17
+ # 装进实际使用的 profile(均在该 profile 下次启动时生效)
18
+ dsh plugin --profile web add dsh-trellis
19
+ dsh plugin --profile headless add dsh-trellis
20
+
21
+ # 如果你维护独立的 tui profile,也需要装进该 profile
22
+ dsh plugin --profile tui add dsh-trellis
23
+ ```
24
+
25
+ `dsh plugin` 会把插件安装到指定 profile;每个实际使用的 profile 都需要单独安装。升级到 npm 上的最新版本:
26
+
27
+ ```powershell
28
+ dsh plugin --profile web update dsh-trellis
29
+ dsh plugin --profile headless update dsh-trellis
30
+ ```
31
+
32
+ 对 `tui` profile 使用同样的 update,并在该 profile 下次启动时生效。
33
+
34
+ 如果要从源码开发或验证尚未发布的版本,可以改用本地 `file:` 安装:
35
+
36
+ ```powershell
37
+ git clone https://github.com/SajoLuo/dsh-trellis.git
38
+ cd dsh-trellis
39
+ pnpm install --frozen-lockfile
40
+ dsh plugin --profile headless add file:C:/path/to/dsh-trellis
41
+ ```
42
+
43
+ `file:` 插件会作为 profile 内的 pnpm 快照安装;拉取源码更新后,尤其是版本新增文件时,需要先 remove 再 add 刷新该 profile。
44
+
45
+ 配套要求:项目的 Trellis 平台需包含 dsh(`trellis init --dsh`,见 Trellis-DeepSeekHarness 适配分支),且 `.trellis/scripts` 需包含读取原生 `DSH_SESSION_ID` 的适配(已含在同一分支)。插件目标版本为 DSH `0.1.0-rc.6`;升级 DSH 时应重跑本仓测试。
46
+
47
+ ## 配置
48
+
49
+ 在 profile 的 `cordis.patch.yml` 里覆盖(整行替换):
50
+
51
+ ```yaml
52
+ - id: dsh-trellis
53
+ config:
54
+ maxBytes: 4096 # 面包屑注入的字节预算(0 = 关闭注入)
55
+ skipKeyword: no-trellis # 提示词中的独立单词可跳过当轮注入(空 = 禁用)
56
+ pythonCmd: "" # 空 = 自动选择:Windows 先 py -3 再 python;其他系统先 python3 再 python
57
+ commandsEnabled: true
58
+ ```
59
+
60
+ ## 工作原理
61
+
62
+ - **状态解析**(`lib/workflow.js`):向上找项目根 → 读 `.trellis/workflow.md` 解析状态块 → 先看当前会话指针 `.trellis/.runtime/sessions/dsh_<id>.json`。当前指针缺失时只允许 Trellis 官方的“唯一 session 文件”回退;存在 0 个或 2 个以上 session 文件就拒绝猜测,避免多个 DSH 窗口串任务。
63
+ - **注入去重**:面包屑带 digest,与最近一次注入相同且仍在可见表面则不重复注入。
64
+ - **会话身份**:DSH 原生提供 `DSH_SESSION_ID = agent.session.header.id`,并先丢弃环境中已有的 `DSH_*` 再重建受管命名空间。Trellis beta 因此会在同时看到 `DSH_SHELL=1` 与 `DSH_SESSION_ID` 时优先解析当前 DSH 身份,即使没有插件也不会被外层 host 继承的 `TRELLIS_CONTEXT_ID` 串任务。插件通过 `shellEnv` 为每次执行额外生成 `DSH_TRELLIS_CONTEXT_ID = dsh_<session-id>`,用于转发可能不同于 shell 自身 session 的子代理身份;主会话与子代理仍各自保留 DSH 身份,子代理通过派发 prompt 首行的 `Active task:` 和角色 prelude 取得父任务上下文。
65
+ - **Headless 会话**:每次 `dsh --profile headless` 调用都是新的 DSH session。需要跨轮保留 active-task 指针时,应保持同一会话或显式 resume 返回的 session id,不能把多个独立 headless 调用当成同一 session。
66
+ - **取消与生命周期**:命令和 `trellis_wait` 都继承 DSH invocation 的 `AbortSignal`;取消后命令不会继续尝试另一个 Python 启动器,等待工具也会立即注销临时事件监听器。插件卸载时只注销自己的命令、工具和监听器。
67
+ - **子代理并发**:workflow 只有在发现 `trellis_wait` 时才使用 DSH 原生 continuable 后台 `subagent`。主会话并行做独立工作;需要汇合时调用一次 `trellis_wait <subagent_id>` 等待原生结算事件。没有该工具时,首次派发直接设置 `run_in_background: false`,绝不留下无法事件汇合的后台子代理。
68
+
69
+ ## 开发
70
+
71
+ ```powershell
72
+ pnpm install
73
+ pnpm test # node --test test/*.test.js
74
+ ```
75
+
76
+ 无构建步骤:纯 ESM JavaScript,`main` 直接指向 `lib/index.js`。
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,5 @@
1
+ # dsh-trellis bundle patch: one insert over the composed profile tree.
2
+ # Service injection (commands, tools, and subagents) is declared by the plugin module.
3
+ - insert:
4
+ - id: dsh-trellis
5
+ name: dsh-trellis
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Per-turn workflow-state breadcrumb: composition, dedupe, and visibility.
3
+ *
4
+ * @module dsh-trellis/breadcrumb
5
+ */
6
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
+ import { join } from "node:path";
8
+ import { resolveTrellisState, sanitizeContextKey } from "./workflow.js";
9
+ import { nodeFs } from "./node-fs.js";
10
+
11
+ export const SOURCE_KIND = "dsh-trellis";
12
+ export const SOURCE_FORM = "breadcrumb";
13
+
14
+ /**
15
+ * Truncate UTF-8 text to a byte budget without splitting a code point.
16
+ * @param {string} value
17
+ * @param {number} maxBytes
18
+ * @returns {string}
19
+ */
20
+ export function truncateUtf8(value, maxBytes) {
21
+ const bytes = Buffer.from(value, "utf8");
22
+ if (bytes.length <= maxBytes) return value;
23
+ let end = Math.max(0, Math.trunc(maxBytes));
24
+ while (end > 0 && (bytes.readUInt8(end) & 0xc0) === 0x80) end -= 1;
25
+ return bytes.subarray(0, end).toString("utf8");
26
+ }
27
+
28
+ /**
29
+ * Compose the model-visible breadcrumb message for a resolved state.
30
+ * @param {{status: string, taskPath: string | null, body: string, digest: string}} state
31
+ * @param {number} maxBytes
32
+ * @returns {ReturnType<typeof createUserMessage>}
33
+ */
34
+ export function breadcrumbMessage(state, maxBytes) {
35
+ const taskLine =
36
+ state.taskPath === null ? "" : `\nActive task: ${state.taskPath}`;
37
+ const text = truncateUtf8(
38
+ `<system-reminder>\nTrellis workflow state: ${state.status}${taskLine}\n\n${state.body}\n</system-reminder>`,
39
+ maxBytes,
40
+ );
41
+ return createUserMessage({
42
+ content: [{ type: "text", text }],
43
+ source: {
44
+ kind: SOURCE_KIND,
45
+ form: SOURCE_FORM,
46
+ status: state.status,
47
+ task: state.taskPath,
48
+ digest: state.digest,
49
+ },
50
+ });
51
+ }
52
+
53
+ /** Whether a user message carries our breadcrumb source. */
54
+ export function isBreadcrumbMessage(message) {
55
+ const source = message?.source;
56
+ return (
57
+ typeof source === "object" &&
58
+ source !== null &&
59
+ source.kind === SOURCE_KIND &&
60
+ source.form === SOURCE_FORM
61
+ );
62
+ }
63
+
64
+ /** Whether the same breadcrumb payload is already visible in the session surface. */
65
+ export function isBreadcrumbVisible(agent, digest) {
66
+ const visibleSeqs = new Set(agent.session.surface.nodes);
67
+ for (const [seq, event] of agent.session.events.entries()) {
68
+ if (!visibleSeqs.has(seq) || event.type !== "user/message") continue;
69
+ const source = event.data?.source;
70
+ if (
71
+ source?.kind === SOURCE_KIND &&
72
+ source?.form === SOURCE_FORM &&
73
+ source.digest === digest
74
+ ) {
75
+ return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+
81
+ /**
82
+ * Does the claimed prompt batch contain the skip keyword as a standalone word?
83
+ * @param {Array<{content?: unknown}>} messages
84
+ * @param {string} keyword
85
+ */
86
+ export function hasSkipKeyword(messages, keyword) {
87
+ if (!keyword) return false;
88
+ const pattern = new RegExp(
89
+ `\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
90
+ "i",
91
+ );
92
+ for (const message of messages) {
93
+ const content = message?.content;
94
+ const parts = Array.isArray(content)
95
+ ? content
96
+ : [{ type: "text", text: String(content ?? "") }];
97
+ for (const part of parts) {
98
+ if (typeof part?.text === "string" && pattern.test(part.text))
99
+ return true;
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * Build the plugin-owned pre-step composer.
107
+ * @param {object} resolved - normalized plugin config.
108
+ * @param {import("@deepseek-ai/cordis").Context} ctx
109
+ * @returns {{ compose(agent: any, messages: any[], signal: AbortSignal): Promise<any> }}
110
+ */
111
+ export function createBreadcrumbComposer(resolved, ctx, dependencies = {}) {
112
+ const fs = dependencies.fs ?? nodeFs;
113
+ const cache = new Map();
114
+ const lastInjected = new WeakMap();
115
+
116
+ const compose = async (agent, messages, signal) => {
117
+ if (resolved.maxBytes <= 0 || !Number.isFinite(resolved.maxBytes))
118
+ return undefined;
119
+ if (hasSkipKeyword(messages, resolved.skipKeyword)) return undefined;
120
+ const cwd = agent.session.header.cwd ?? process.cwd();
121
+ const sessionId = agent.session.header.id ?? "";
122
+ const contextKey =
123
+ sessionId === "" ? "dsh" : `dsh_${sanitizeContextKey(sessionId)}`;
124
+ signal?.throwIfAborted();
125
+ const state = await resolveTrellisState({
126
+ cwd,
127
+ markers: resolved.projectRootMarkers,
128
+ contextKey,
129
+ fs,
130
+ cache,
131
+ });
132
+ signal?.throwIfAborted();
133
+ if (state === null) return undefined;
134
+ const previous = lastInjected.get(agent.session);
135
+ if (previous !== undefined && previous.digest === state.digest) {
136
+ if (isBreadcrumbVisible(agent, state.digest)) return undefined;
137
+ }
138
+ lastInjected.set(agent.session, { digest: state.digest });
139
+ return breadcrumbMessage(state, resolved.maxBytes);
140
+ };
141
+
142
+ return { compose };
143
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Human-facing /trellis commands: status and finish.
3
+ *
4
+ * Command names follow the dsh grammar (lowercase letters, digits, `_`, `-`;
5
+ * no `:`), and deliberately avoid the skill names (`trellis-start`,
6
+ * `trellis-continue`, `trellis-finish-work`): a command sharing a skill name
7
+ * would shadow the skill's slash-pipeline injection. The Trellis session
8
+ * rituals stay on the skill surface; these commands cover the deterministic,
9
+ * zero-token side — pointer inspection and wrap-up.
10
+ *
11
+ * @module dsh-trellis/commands
12
+ */
13
+ import { execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
15
+ import { join } from "node:path";
16
+ import { findProjectRoot } from "./workflow.js";
17
+ import { nodeFs } from "./node-fs.js";
18
+ import { dshContextKey } from "./session-env.js";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ /** Platform-aware Python launch candidates. An explicit config disables fallback. */
23
+ export function pythonCandidates(config, platform = process.platform) {
24
+ if (config.pythonCmd) return [{ command: config.pythonCmd, prefixArgs: [] }];
25
+ if (platform === "win32") {
26
+ return [
27
+ { command: "py", prefixArgs: ["-3"] },
28
+ { command: "python", prefixArgs: [] },
29
+ ];
30
+ }
31
+ return [
32
+ { command: "python3", prefixArgs: [] },
33
+ { command: "python", prefixArgs: [] },
34
+ ];
35
+ }
36
+
37
+ /** Run a project Trellis script with bounded time and output. */
38
+ export async function runTrellisScript(
39
+ config,
40
+ root,
41
+ sessionId,
42
+ scriptArgs,
43
+ dependencies = {},
44
+ ) {
45
+ const execute = dependencies.execFile ?? execFileAsync;
46
+ const platform = dependencies.platform ?? process.platform;
47
+ const signal = dependencies.signal;
48
+ const contextKey = dshContextKey(sessionId);
49
+ const env = {
50
+ ...process.env,
51
+ ...(contextKey
52
+ ? {
53
+ TRELLIS_CONTEXT_ID: contextKey,
54
+ DSH_TRELLIS_CONTEXT_ID: contextKey,
55
+ DSH_SHELL: "1",
56
+ DSH_SESSION_ID: sessionId,
57
+ }
58
+ : {}),
59
+ };
60
+ const script = join(root, ".trellis", "scripts", "task.py");
61
+ const candidates = pythonCandidates(config, platform);
62
+ for (const [index, candidate] of candidates.entries()) {
63
+ const args = [...candidate.prefixArgs, script, ...scriptArgs];
64
+ try {
65
+ const { stdout, stderr } = await execute(candidate.command, args, {
66
+ cwd: root,
67
+ env,
68
+ windowsHide: true,
69
+ timeout: 15_000,
70
+ maxBuffer: 256 * 1024,
71
+ ...(signal ? { signal } : {}),
72
+ });
73
+ const text = `${stdout}${stderr ? `\n${stderr}` : ""}`.trim();
74
+ return { kind: "success", text };
75
+ } catch (error) {
76
+ if (signal?.aborted || error?.name === "AbortError") {
77
+ return { kind: "error", text: "Trellis command cancelled." };
78
+ }
79
+ const missing = error?.code === "ENOENT";
80
+ if (missing && index < candidates.length - 1) continue;
81
+ const attempted = candidates
82
+ .slice(0, index + 1)
83
+ .map(({ command, prefixArgs }) => [command, ...prefixArgs].join(" "))
84
+ .join(", ");
85
+ const message = missing
86
+ ? `Python command not found (tried: ${attempted}). Set the plugin's pythonCmd config to a Python 3 executable.`
87
+ : `Trellis script failed via ${[candidate.command, ...candidate.prefixArgs].join(" ")}: ${error?.message ?? String(error)}`;
88
+ return { kind: "error", text: message };
89
+ }
90
+ }
91
+ return {
92
+ kind: "error",
93
+ text: "No Python command candidates are configured.",
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Register the /trellis command family.
99
+ * @param {object} ctx - cordis context exposing `ctx.commands`.
100
+ * @param {object} resolved - normalized plugin config.
101
+ * @returns {() => void} disposer.
102
+ */
103
+ export function registerCommands(ctx, resolved, dependencies = {}) {
104
+ const fs = dependencies.fs ?? nodeFs;
105
+ const execute = dependencies.execFile ?? execFileAsync;
106
+ const platform = dependencies.platform ?? process.platform;
107
+ const disposers = [];
108
+
109
+ const withProject = async (invocation, fn) => {
110
+ const cwd = invocation.agent?.session?.header?.cwd ?? process.cwd();
111
+ const root = await findProjectRoot(cwd, resolved.projectRootMarkers, fs);
112
+ if (root === null) {
113
+ return {
114
+ kind: "error",
115
+ text: "No project root found from the configured markers.",
116
+ };
117
+ }
118
+ const workflow = join(root, ".trellis", "workflow.md");
119
+ const taskScript = join(root, ".trellis", "scripts", "task.py");
120
+ if (!(await fs.exists(workflow)) || !(await fs.exists(taskScript))) {
121
+ return {
122
+ kind: "error",
123
+ text: "No Trellis project found (expected .trellis/workflow.md and .trellis/scripts/task.py).",
124
+ };
125
+ }
126
+ const sessionId = invocation.agent?.session?.header?.id ?? "";
127
+ return fn(root, sessionId, invocation.signal);
128
+ };
129
+
130
+ disposers.push(
131
+ ctx.commands.register({
132
+ name: "trellis-status",
133
+ description: "Show the Trellis active task and git status.",
134
+ handler: (invocation) =>
135
+ withProject(invocation, async (root, sessionId, signal) => {
136
+ const task = await runTrellisScript(
137
+ resolved,
138
+ root,
139
+ sessionId,
140
+ ["current", "--source"],
141
+ { execFile: execute, platform, signal },
142
+ );
143
+ if (signal?.aborted) return task;
144
+ let git = "";
145
+ try {
146
+ const { stdout } = await execute(
147
+ "git",
148
+ ["status", "--short", "--branch"],
149
+ {
150
+ cwd: root,
151
+ windowsHide: true,
152
+ timeout: 10_000,
153
+ maxBuffer: 128 * 1024,
154
+ ...(signal ? { signal } : {}),
155
+ },
156
+ );
157
+ git = `\n\nGit status:\n${stdout.trim()}`;
158
+ } catch {
159
+ git = "";
160
+ }
161
+ const hint =
162
+ task.kind === "success"
163
+ ? `\n\nSession rituals: type /trellis-start (start), /trellis-continue (resume), /trellis-finish-work (skill wrap-up).`
164
+ : "";
165
+ return { kind: task.kind, text: `${task.text}${git}${hint}` };
166
+ }),
167
+ }),
168
+ );
169
+
170
+ disposers.push(
171
+ ctx.commands.register({
172
+ name: "trellis-finish",
173
+ description:
174
+ "Inspect the active task and print the safe wrap-up checklist.",
175
+ handler: (invocation) =>
176
+ withProject(invocation, async (root, sessionId, signal) => {
177
+ const result = await runTrellisScript(
178
+ resolved,
179
+ root,
180
+ sessionId,
181
+ ["current", "--source"],
182
+ { execFile: execute, platform, signal },
183
+ );
184
+ if (signal?.aborted) return result;
185
+ const checklist =
186
+ 'Wrap-up checklist (this command did not clear or archive anything):\n 1. Commit this task\'s changes (workflow.md Phase 3.4; do not push).\n 2. Type /trellis-finish-work so the skill can archive the task before the pointer is removed and then record the session journal.\n\nIf you must do it manually, archive first with the configured Python 3 runtime: .trellis/scripts/task.py archive <task-name>; then run .trellis/scripts/add_session.py --title "..." --commit <hash> --summary "...".';
187
+ return { kind: result.kind, text: `${result.text}\n\n${checklist}` };
188
+ }),
189
+ }),
190
+ );
191
+
192
+ return () => {
193
+ for (const dispose of disposers) dispose();
194
+ };
195
+ }
package/lib/config.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * dsh-trellis plugin configuration (Schemastery).
3
+ *
4
+ * @module dsh-trellis/config
5
+ */
6
+ import z from "@deepseek-ai/schemastery";
7
+
8
+ export const Config = z.object({
9
+ /** Master switch: when false, the plugin injects nothing and registers nothing. */
10
+ enabled: z.boolean().default(true),
11
+ /** UTF-8 byte budget for the injected breadcrumb text. 0 disables injection. */
12
+ maxBytes: z.number().step(1).min(0).default(4096),
13
+ /** Directory markers that identify a project root (walked upward from the session cwd). */
14
+ projectRootMarkers: z.array(z.string()).default([".git"]),
15
+ /** Standalone word (case-insensitive) in the user prompt that skips breadcrumb injection for that turn. Empty disables. */
16
+ skipKeyword: z.string().default("no-trellis"),
17
+ /** Python executable used by /trellis commands. Empty selects a platform-aware fallback chain. */
18
+ pythonCmd: z.string().default(""),
19
+ /** Register the deterministic /trellis-status and read-only /trellis-finish commands. */
20
+ commandsEnabled: z.boolean().default(true),
21
+ });
22
+
23
+ /**
24
+ * Normalized runtime configuration.
25
+ * @param {Partial<Record<string, unknown>>} config
26
+ * @returns {{
27
+ * enabled: boolean,
28
+ * maxBytes: number,
29
+ * projectRootMarkers: string[],
30
+ * skipKeyword: string,
31
+ * pythonCmd: string,
32
+ * commandsEnabled: boolean,
33
+ * }}
34
+ */
35
+ export function resolveConfig(config) {
36
+ return {
37
+ enabled: config.enabled !== false,
38
+ maxBytes: config.maxBytes ?? 4096,
39
+ projectRootMarkers: config.projectRootMarkers ?? [".git"],
40
+ skipKeyword: config.skipKeyword ?? "no-trellis",
41
+ pythonCmd:
42
+ typeof config.pythonCmd === "string" ? config.pythonCmd.trim() : "",
43
+ commandsEnabled: config.commandsEnabled !== false,
44
+ };
45
+ }
package/lib/index.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * dsh-trellis: Trellis workflow integration for DeepSeek Harness.
3
+ *
4
+ * - Per-turn workflow-state breadcrumb: at every eligible `agent/pre-step`,
5
+ * resolves the project's Trellis state (.trellis/workflow.md blocks +
6
+ * active task) and injects the matching [workflow-state:*] body into the
7
+ * session, exactly where the workspace-instruction baseline lands.
8
+ * - `/trellis-status` and read-only `/trellis-finish` utility commands.
9
+ * Session rituals remain skills (`/trellis-start`, `/trellis-continue`,
10
+ * `/trellis-finish-work`) so command registration cannot shadow them.
11
+ * - `trellis_wait`, an event-driven synchronization tool for a continuable
12
+ * role agent after the main session has exhausted independent work.
13
+ * - A managed DSH_TRELLIS_CONTEXT_ID derived per shell execution from the
14
+ * native DSH session, so an outer host's inherited TRELLIS_CONTEXT_ID cannot
15
+ * silently claim the inner session. Utility commands set the same identity.
16
+ *
17
+ * In directories without a Trellis project it injects no breadcrumb; utility
18
+ * commands validate the project only when a user explicitly invokes them.
19
+ *
20
+ * @module dsh-trellis
21
+ */
22
+ import { isDeepStrictEqual } from "node:util";
23
+ import { Config, resolveConfig } from "./config.js";
24
+ import { createBreadcrumbComposer, isBreadcrumbMessage } from "./breadcrumb.js";
25
+ import { registerCommands } from "./commands.js";
26
+ import { registerSessionEnv } from "./session-env.js";
27
+ import { registerWaitTool } from "./wait-tool.js";
28
+
29
+ export const name = "dsh-trellis";
30
+
31
+ /** @type {string[]} */
32
+ export const inject = ["commands", "shellEnv", "subagents", "tools"];
33
+
34
+ export { Config };
35
+
36
+ function sameBreadcrumbPayload(left, right) {
37
+ return (
38
+ isDeepStrictEqual(left.content, right.content) &&
39
+ isDeepStrictEqual(left.source, right.source)
40
+ );
41
+ }
42
+
43
+ /** @param {import("@deepseek-ai/cordis").Context} ctx */
44
+ export function apply(ctx, config = {}) {
45
+ const resolved = resolveConfig(config);
46
+ if (!resolved.enabled) return;
47
+
48
+ // --- /trellis commands ---------------------------------------------------
49
+ let disposeCommands = () => {};
50
+ ctx.effect(() => {
51
+ if (resolved.commandsEnabled) {
52
+ disposeCommands = registerCommands(ctx, resolved);
53
+ }
54
+ return () => disposeCommands();
55
+ }, "dsh-trellis.commands");
56
+
57
+ // --- native continuable-subagent synchronization -----------------------
58
+ ctx.effect(
59
+ () => registerWaitTool(ctx),
60
+ "dsh-trellis.wait-tool",
61
+ );
62
+
63
+ // --- session-scoped shell identity --------------------------------------
64
+ ctx.effect(
65
+ () => registerSessionEnv(ctx),
66
+ "dsh-trellis.session-env",
67
+ );
68
+
69
+ // --- per-turn breadcrumb injection --------------------------------------
70
+ const { compose } = createBreadcrumbComposer(resolved, ctx);
71
+
72
+ const syncInbox = (agent, desired) => {
73
+ const pending = agent.inbox.nextStep.filter(isBreadcrumbMessage);
74
+ const alreadySupplied =
75
+ desired === undefined ||
76
+ agent.session.surface.nodes.some((seq) => {
77
+ const event = agent.session.events[seq];
78
+ return (
79
+ event?.type === "user/message" &&
80
+ event.data !== undefined &&
81
+ isBreadcrumbMessage(event.data) &&
82
+ sameBreadcrumbPayload(event.data, desired)
83
+ );
84
+ });
85
+ if (alreadySupplied) {
86
+ for (const message of pending) agent.inbox.remove(message.id);
87
+ return;
88
+ }
89
+ const reusable = pending.find((message) =>
90
+ sameBreadcrumbPayload(message, desired),
91
+ );
92
+ if (reusable !== undefined) {
93
+ for (const message of pending)
94
+ if (message !== reusable) agent.inbox.remove(message.id);
95
+ return;
96
+ }
97
+ const replaced = pending[0];
98
+ if (replaced === undefined) agent.inbox.prepend("next-step", desired);
99
+ else agent.inbox.replace(replaced.id, desired);
100
+ for (const message of pending.slice(1)) agent.inbox.remove(message.id);
101
+ };
102
+
103
+ ctx.on("agent/pre-step", async ({ agent, messages, step, signal }, next) => {
104
+ const decision = await next();
105
+ let desired;
106
+ try {
107
+ desired = await compose(agent, messages, signal);
108
+ } catch (error) {
109
+ if (signal !== undefined && signal.aborted) throw error;
110
+ ctx.logger?.warn?.(
111
+ "dsh-trellis breadcrumb composition failed: %o",
112
+ error,
113
+ );
114
+ return decision;
115
+ }
116
+ signal?.throwIfAborted();
117
+ if (desired === undefined) {
118
+ const pending = agent.inbox.nextStep.filter(isBreadcrumbMessage);
119
+ for (const message of pending) agent.inbox.remove(message.id);
120
+ return decision;
121
+ }
122
+ if (
123
+ decision.kind === "reject" ||
124
+ (step === 1 && decision.messages.length === 0)
125
+ ) {
126
+ syncInbox(agent, desired);
127
+ return decision;
128
+ }
129
+ const pending = agent.inbox.nextStep.filter(isBreadcrumbMessage);
130
+ for (const message of pending) agent.inbox.remove(message.id);
131
+ if (
132
+ decision.messages.some((message) =>
133
+ sameBreadcrumbPayload(message, desired),
134
+ )
135
+ ) {
136
+ return decision;
137
+ }
138
+ const lastClaimedIndex = decision.messages.findLastIndex((message) =>
139
+ messages.includes(message),
140
+ );
141
+ return {
142
+ kind: "enter",
143
+ messages: decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired),
144
+ };
145
+ });
146
+ }
package/lib/node-fs.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Node filesystem shim implementing the narrow surface workflow.js needs.
3
+ *
4
+ * @module dsh-trellis/node-fs
5
+ */
6
+ import { stat as nodeStat, readFile as nodeReadFile, readdir as nodeReaddir } from "node:fs/promises";
7
+
8
+ /** @type {import("./workflow.js").FsShim} */
9
+ export const nodeFs = {
10
+ async exists(path) {
11
+ try {
12
+ await nodeStat(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ },
18
+ async stat(path) {
19
+ try {
20
+ const info = await nodeStat(path);
21
+ return { mtimeMs: info.mtimeMs };
22
+ } catch {
23
+ return null;
24
+ }
25
+ },
26
+ async readFile(path) {
27
+ try {
28
+ return await nodeReadFile(path, "utf8");
29
+ } catch {
30
+ return null;
31
+ }
32
+ },
33
+ async listDir(path) {
34
+ try {
35
+ return await nodeReaddir(path);
36
+ } catch {
37
+ return [];
38
+ }
39
+ },
40
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Session-scoped context identity for Trellis's Python scripts.
3
+ *
4
+ * DSH rebuilds the managed `DSH_*` namespace for every model shell execution,
5
+ * discarding ambient `DSH_*` values first. This contributor therefore carries
6
+ * the current DSH session identity without inheriting an outer host's Trellis
7
+ * context. The beta Trellis adapter resolves it before the generic
8
+ * `TRELLIS_CONTEXT_ID` subprocess override.
9
+ *
10
+ * @module dsh-trellis/session-env
11
+ */
12
+ import { sanitizeContextKey } from "./workflow.js";
13
+
14
+ export const SESSION_ENV_CONTRIBUTOR = "dsh-trellis-session";
15
+
16
+ /** Return the Trellis context key for a native DSH session id. */
17
+ export function dshContextKey(sessionId) {
18
+ const sanitized = sanitizeContextKey(sessionId ?? "");
19
+ return sanitized === "" ? "" : `dsh_${sanitized}`;
20
+ }
21
+
22
+ /**
23
+ * Register the trusted, per-execution shell identity contribution.
24
+ * @param {object} ctx - Cordis context exposing `ctx.shellEnv`.
25
+ * @returns {() => void} disposer.
26
+ */
27
+ export function registerSessionEnv(ctx) {
28
+ return ctx.shellEnv.register({
29
+ name: SESSION_ENV_CONTRIBUTOR,
30
+ variables: {
31
+ DSH_TRELLIS_CONTEXT_ID: {
32
+ description:
33
+ "Current DSH session's Trellis context key; overrides an inherited outer TRELLIS_CONTEXT_ID.",
34
+ },
35
+ },
36
+ resolve(execution) {
37
+ const contextKey = dshContextKey(
38
+ execution.agent?.session?.header?.id ?? "",
39
+ );
40
+ return contextKey === "" ? {} : { DSH_TRELLIS_CONTEXT_ID: contextKey };
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Event-driven waiting for DeepSeek Harness continuable subagents.
3
+ *
4
+ * Trellis dispatches role agents in DSH's default background mode so the main
5
+ * agent can keep doing independent work. When that work is exhausted, this
6
+ * tool provides a native synchronization point: it waits for `subagent/end`,
7
+ * which DSH emits only after the child's settlement notice has been queued for
8
+ * the parent. It deliberately replaces shell sleeps and polling loops.
9
+ *
10
+ * @module dsh-trellis/wait-tool
11
+ */
12
+ import { defineTool } from "@deepseek-ai/dsh-tools";
13
+
14
+ function createSettlementWaiter(parentCtx, subagentId, signal) {
15
+ let disposeListener = () => {};
16
+ let settled = false;
17
+ let resolvePromise;
18
+ let rejectPromise;
19
+
20
+ const cleanup = () => {
21
+ disposeListener?.();
22
+ signal?.removeEventListener("abort", onAbort);
23
+ };
24
+ const onAbort = () => {
25
+ if (settled) return;
26
+ settled = true;
27
+ cleanup();
28
+ rejectPromise(signal.reason ?? new Error("Trellis subagent wait cancelled."));
29
+ };
30
+ const promise = new Promise((resolve, reject) => {
31
+ resolvePromise = resolve;
32
+ rejectPromise = reject;
33
+ });
34
+
35
+ disposeListener =
36
+ parentCtx.on("subagent/end", (info) => {
37
+ if (settled || info.id !== subagentId) return;
38
+ settled = true;
39
+ cleanup();
40
+ resolvePromise(info.stopReason);
41
+ }) ?? (() => {});
42
+ signal?.addEventListener("abort", onAbort, { once: true });
43
+ if (signal?.aborted) onAbort();
44
+
45
+ return {
46
+ promise,
47
+ cancel() {
48
+ if (settled) return;
49
+ settled = true;
50
+ cleanup();
51
+ },
52
+ };
53
+ }
54
+
55
+ /** Register the Trellis-only, event-driven subagent wait tool. */
56
+ export function registerWaitTool(ctx) {
57
+ return ctx.tools.register(
58
+ defineTool({
59
+ name: "trellis_wait",
60
+ description:
61
+ "Wait efficiently for one direct background Trellis subagent after independent work is exhausted. This is event-driven: it returns only after DeepSeek Harness has queued the child's native settlement notice. Use the subagent id returned by `subagent`; never replace this with shell sleep, polling, or job_output.",
62
+ parameters: {
63
+ subagent_id: {
64
+ type: "string",
65
+ required: true,
66
+ description:
67
+ "Direct continuable subagent id returned by the `subagent` tool.",
68
+ },
69
+ },
70
+ output: {
71
+ schema: {
72
+ type: "object",
73
+ additionalProperties: false,
74
+ properties: {
75
+ subagentId: { type: "string", required: true },
76
+ state: { type: "string", required: true },
77
+ stopReason: { type: "string", required: true },
78
+ },
79
+ },
80
+ render(_args, value) {
81
+ if (value.state === "already-inactive") {
82
+ return [
83
+ {
84
+ type: "text",
85
+ text: `subagent ${value.subagentId} is already inactive; consume its queued settlement notice or inspect its transcript`,
86
+ },
87
+ ];
88
+ }
89
+ return [
90
+ {
91
+ type: "text",
92
+ text: `subagent ${value.subagentId} settled (${value.stopReason}); its native settlement notice is queued`,
93
+ },
94
+ ];
95
+ },
96
+ },
97
+ async execute(args, exec) {
98
+ const parent = exec.agent;
99
+ if (!parent)
100
+ throw new Error(
101
+ "trellis_wait requires a calling agent (exec.agent was undefined)",
102
+ );
103
+
104
+ // Install the listener before reading the durable catalog so settlement
105
+ // cannot slip through the validation/read-to-wait window.
106
+ const waiter = createSettlementWaiter(
107
+ parent.ctx,
108
+ args.subagent_id,
109
+ exec.signal,
110
+ );
111
+ try {
112
+ const entries = await ctx.subagents.listChildren(
113
+ parent.id,
114
+ exec.signal,
115
+ );
116
+ const child = entries.find(
117
+ (entry) =>
118
+ entry.kind === "child" &&
119
+ entry.mode === "continuable" &&
120
+ entry.id === args.subagent_id,
121
+ );
122
+ if (child === undefined) {
123
+ throw new Error(
124
+ `trellis_wait can only wait for a direct continuable subagent of this session: ${args.subagent_id}`,
125
+ );
126
+ }
127
+ if (child.activity === "inactive") {
128
+ waiter.cancel();
129
+ return {
130
+ subagentId: args.subagent_id,
131
+ state: "already-inactive",
132
+ stopReason: "unknown",
133
+ };
134
+ }
135
+
136
+ const stopReason = await waiter.promise;
137
+ return {
138
+ subagentId: args.subagent_id,
139
+ state: "settled",
140
+ stopReason,
141
+ };
142
+ } catch (error) {
143
+ waiter.cancel();
144
+ throw error;
145
+ }
146
+ },
147
+ }),
148
+ );
149
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Trellis project state resolution: project root, workflow-state blocks,
3
+ * active task pointer, and status mapping.
4
+ *
5
+ * Pure logic with an injectable filesystem shim so unit tests run without
6
+ * touching a real Trellis project.
7
+ *
8
+ * @module dsh-trellis/workflow
9
+ */
10
+ import { join, dirname, resolve } from "node:path";
11
+ import { createHash } from "node:crypto";
12
+
13
+ /** Workflow-state block charset, mirrored from Trellis's workflow.md contract. */
14
+ const STATUS_RE =
15
+ /\[workflow-state:([A-Za-z0-9_-]+)\]([\s\S]*?)\[\/workflow-state:\1\]/g;
16
+
17
+ /** Same sanitization Trellis task.py applies to context keys. */
18
+ export function sanitizeContextKey(value) {
19
+ return String(value)
20
+ .replace(/[^A-Za-z0-9._-]+/g, "_")
21
+ .replace(/^[._-]+|[._-]+$/g, "");
22
+ }
23
+
24
+ /** The degrading fallback when workflow.md lacks a block for the resolved status. */
25
+ export const FALLBACK_BREADCRUMB =
26
+ "Refer to `.trellis/workflow.md` for the current workflow step (no matching [workflow-state] block found).";
27
+
28
+ /**
29
+ * Parse every `[workflow-state:STATUS] ... [/workflow-state:STATUS]` block.
30
+ * @param {string} content - workflow.md content.
31
+ * @returns {Map<string, string>} status → trimmed body.
32
+ */
33
+ export function parseWorkflowStateBlocks(content) {
34
+ const blocks = new Map();
35
+ for (const match of content.matchAll(STATUS_RE)) {
36
+ blocks.set(match[1], match[2].trim());
37
+ }
38
+ return blocks;
39
+ }
40
+
41
+ /**
42
+ * Walk upward from cwd to the first directory containing one of the markers.
43
+ * @param {string} cwd - absolute starting directory.
44
+ * @param {string[]} markers - child names that identify a project root.
45
+ * @param {{exists(path: string): Promise<boolean>}} fs - filesystem shim.
46
+ * @returns {Promise<string | null>}
47
+ */
48
+ export async function findProjectRoot(cwd, markers, fs) {
49
+ let current = resolve(cwd);
50
+ for (;;) {
51
+ for (const marker of markers) {
52
+ if (await fs.exists(join(current, marker))) return current;
53
+ }
54
+ const parent = dirname(current);
55
+ if (parent === current) return null;
56
+ current = parent;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read a file, returning null when absent. Cache by (path, mtimeMs) so the
62
+ * per-turn hot path does not re-read workflow.md on every step.
63
+ * @param {string} path
64
+ * @param {{readFile(path: string): Promise<string | null>, stat(path: string): Promise<{mtimeMs: number} | null>}} fs
65
+ * @param {Map<string, {mtimeMs: number, content: string | null}>} cache
66
+ * @returns {Promise<string | null>}
67
+ */
68
+ export async function readCached(path, fs, cache) {
69
+ try {
70
+ const info = await fs.stat(path);
71
+ if (info === null) return null;
72
+ const hit = cache.get(path);
73
+ if (hit !== undefined && hit.mtimeMs === info.mtimeMs) return hit.content;
74
+ const content = await fs.readFile(path);
75
+ cache.set(path, { mtimeMs: info.mtimeMs, content });
76
+ return content;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Resolve the active task for a dsh session.
84
+ *
85
+ * Canonical Trellis semantics are session-pointer-driven. If this session has
86
+ * no usable pointer, mirror `active_task.py`'s conservative class-2 fallback:
87
+ * infer a task only when the runtime contains exactly one session JSON file.
88
+ * Two or more files means multiple windows may be active, so the resolver
89
+ * refuses to guess even when one task happens to be newer.
90
+ *
91
+ * @param {string} root - project root.
92
+ * @param {string} contextKey - sanitized `dsh_<session-id>` key for this agent.
93
+ * @param {{listDir(path: string): Promise<string[]>, readFile(path: string): Promise<string | null>, stat(path: string): Promise<{mtimeMs: number} | null>}} fs
94
+ * @returns {Promise<{path: string, status: string} | null>} task dir-relative path + status.
95
+ */
96
+ export async function resolveActiveTask(root, contextKey, fs) {
97
+ // 1. Session-scoped pointer (task.py start / create).
98
+ const sessionsDir = join(root, ".trellis", ".runtime", "sessions");
99
+ let names = [];
100
+ try {
101
+ names = await fs.listDir(sessionsDir);
102
+ } catch {
103
+ names = [];
104
+ }
105
+ for (const name of names) {
106
+ if (name !== `${contextKey}.json`) continue;
107
+ let data = null;
108
+ try {
109
+ data = JSON.parse((await fs.readFile(join(sessionsDir, name))) ?? "null");
110
+ } catch {
111
+ continue;
112
+ }
113
+ if (
114
+ data === null ||
115
+ typeof data !== "object" ||
116
+ typeof data.current_task !== "string"
117
+ )
118
+ continue;
119
+ const status = await readTaskStatus(root, data.current_task, fs);
120
+ if (status !== null) return { path: data.current_task, status };
121
+ }
122
+
123
+ // 2. Official single-session fallback. Count files before parsing so one
124
+ // malformed pointer cannot make a multi-window runtime look unambiguous.
125
+ const sessionFiles = names.filter((name) => name.endsWith(".json"));
126
+ if (sessionFiles.length !== 1) return null;
127
+
128
+ let fallback = null;
129
+ try {
130
+ fallback = JSON.parse(
131
+ (await fs.readFile(join(sessionsDir, sessionFiles[0]))) ?? "null",
132
+ );
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (
137
+ fallback === null ||
138
+ typeof fallback !== "object" ||
139
+ typeof fallback.current_task !== "string" ||
140
+ fallback.current_task === ""
141
+ ) {
142
+ return null;
143
+ }
144
+ const status = await readTaskStatus(root, fallback.current_task, fs);
145
+ return status === null ? null : { path: fallback.current_task, status };
146
+ }
147
+
148
+ /**
149
+ * Read one task.json status, or null when missing/unparseable.
150
+ * @param {string} root
151
+ * @param {string} taskPath - repo-root relative task dir (e.g. `.trellis/tasks/04-17-foo`).
152
+ * @param {{readFile(path: string): Promise<string | null>}} fs
153
+ * @returns {Promise<string | null>}
154
+ */
155
+ export async function readTaskStatus(root, taskPath, fs) {
156
+ try {
157
+ const data = JSON.parse(
158
+ (await fs.readFile(join(root, taskPath, "task.json"))) ?? "null",
159
+ );
160
+ if (
161
+ data !== null &&
162
+ typeof data === "object" &&
163
+ typeof data.status === "string"
164
+ ) {
165
+ return data.status;
166
+ }
167
+ return null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Map a task.json status to a workflow-state breadcrumb status.
175
+ * @param {string | null} status
176
+ * @returns {"planning" | "in_progress" | "no_task"}
177
+ */
178
+ export function mapStatus(status) {
179
+ if (status === "planning") return "planning";
180
+ if (status === "in_progress") return "in_progress";
181
+ return "no_task";
182
+ }
183
+
184
+ /**
185
+ * Resolve the full Trellis state for one session step.
186
+ * @param {object} options
187
+ * @param {string} options.cwd
188
+ * @param {string[]} options.markers
189
+ * @param {string} options.contextKey - sanitized session context key.
190
+ * @param {object} options.fs - filesystem shim ({exists, listDir, readFile, stat}).
191
+ * @param {Map<string, {mtimeMs: number, content: string | null}>} options.cache
192
+ * @returns {Promise<{root: string, status: string, taskPath: string | null, body: string, digest: string} | null>}
193
+ */
194
+ export async function resolveTrellisState({
195
+ cwd,
196
+ markers,
197
+ contextKey,
198
+ fs,
199
+ cache,
200
+ }) {
201
+ const root = await findProjectRoot(cwd, markers, fs);
202
+ if (root === null) return null;
203
+ const workflowPath = join(root, ".trellis", "workflow.md");
204
+ const content = await readCached(workflowPath, fs, cache);
205
+ if (content === null || content === undefined) return null;
206
+ const blocks = parseWorkflowStateBlocks(content);
207
+ if (blocks.size === 0) return null;
208
+ const task = await resolveActiveTask(root, contextKey, fs);
209
+ const status = mapStatus(task?.status ?? null);
210
+ const body = blocks.get(status) ?? FALLBACK_BREADCRUMB;
211
+ const digest = createHash("sha1")
212
+ .update(`${status}\n${task?.path ?? ""}\n${body}`)
213
+ .digest("hex");
214
+ return { root, status, taskPath: task?.path ?? null, body, digest };
215
+ }
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "dsh-trellis",
3
+ "version": "0.1.0",
4
+ "description": "Trellis workflow integration for DeepSeek Harness: per-turn workflow-state breadcrumbs, utility commands, and native session identity.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js"
9
+ },
10
+ "files": [
11
+ "lib",
12
+ "cordis.patch.yml"
13
+ ],
14
+ "author": "SajoLuo",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/SajoLuo/dsh-trellis.git"
19
+ },
20
+ "homepage": "https://github.com/SajoLuo/dsh-trellis#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/SajoLuo/dsh-trellis/issues"
23
+ },
24
+ "keywords": [
25
+ "deepseek-harness",
26
+ "dsh",
27
+ "trellis",
28
+ "workflow",
29
+ "ai-agent"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "packageManager": "pnpm@10.33.0",
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "cordis.patch.yml"
38
+ }
39
+ },
40
+ "scripts": {
41
+ "test": "node --test \"test/*.test.js\""
42
+ },
43
+ "peerDependencies": {
44
+ "@deepseek-ai/cordis": "^4.0.1",
45
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
46
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
47
+ "@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6",
48
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
49
+ "@deepseek-ai/schemastery": "^3.18.1"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@deepseek-ai/cordis": {
53
+ "optional": true
54
+ },
55
+ "@deepseek-ai/dsh-commands": {
56
+ "optional": true
57
+ },
58
+ "@deepseek-ai/dsh-llm": {
59
+ "optional": true
60
+ },
61
+ "@deepseek-ai/dsh-shell-env": {
62
+ "optional": true
63
+ },
64
+ "@deepseek-ai/dsh-tools": {
65
+ "optional": true
66
+ },
67
+ "@deepseek-ai/schemastery": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "devDependencies": {
72
+ "@deepseek-ai/cordis": "^4.0.1",
73
+ "@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
74
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
75
+ "@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6",
76
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
77
+ "@deepseek-ai/schemastery": "^3.18.1"
78
+ }
79
+ }