@minidoll/agentkit-dsh-plugin 0.0.0-stage → 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.
@@ -0,0 +1,74 @@
1
+ /** 注入内容的硬上限(字节)。超过就截断 —— 注入不是"越多越好"。 */
2
+ export declare const MAX_CONTEXT_BYTES = 900;
3
+ /** 内容块(照抄 `dsh-llm` 的 TextBlock)。 */
4
+ interface TextBlock {
5
+ type: 'text';
6
+ text: string;
7
+ }
8
+ /**
9
+ * 构造一条合法的 UserMessage。
10
+ *
11
+ * `createUserMessage` 拿得到就用它(id 由官方生成,最保险);
12
+ * 拿不到时**照抄契约**手工构造 —— 形状必须与 `dsh-llm` 的
13
+ * `MessageBase`/`UserMessage`/`TextBlock` 完全一致。
14
+ */
15
+ export interface UserMessageLike {
16
+ role: 'user';
17
+ id: string;
18
+ content: TextBlock[];
19
+ source: {
20
+ kind: 'user';
21
+ };
22
+ }
23
+ /**
24
+ * 断言"这真的是一个可以被宿主接受的 UserMessage"。
25
+ *
26
+ * 事故就是没做这一步。断言失败 → 调用方**不注入**(不是抛给宿主)。
27
+ */
28
+ export declare function assertUserMessage(value: unknown): value is UserMessageLike;
29
+ /** 项目级注入开关文件(跟随仓库走,不需要每次设环境变量)。 */
30
+ export declare const INJECT_CONFIG_REL = ".agent/inject.json";
31
+ /**
32
+ * 这次调用是否该注入(**默认关**)。
33
+ *
34
+ * 两级开关,任一为真即开:
35
+ * 1. `.agent/inject.json` 的 `{"enabled": true}` —— 项目自己决定(推荐)
36
+ * 2. `AGENTKIT_CONTEXT_INJECT=1` —— 环境变量(临时试验用)
37
+ *
38
+ * 默认关的理由:这是 2026-10-25 事故区,未经真实会话验证的注入不默认生效。
39
+ */
40
+ export declare function injectEnabled(root?: string): boolean;
41
+ /** 诊断:注入成功/失败/跳过都留痕(排在插件 trace 里)。 */
42
+ export interface InjectionTrace {
43
+ (line: string): void;
44
+ }
45
+ export interface BuildHintInput {
46
+ /** 工作区根(已由根解析确定)。 */
47
+ root: string;
48
+ /** 本次动作涉及的文件(workspace 相对或绝对)。 */
49
+ filePath?: string;
50
+ /** 本次动作涉及的其它候选路径(用于从命令里提取的项目内文件)。 */
51
+ extraPaths?: readonly string[];
52
+ }
53
+ /** 生成注入文本;没有可注入内容时返回 null(**不注入空内容**)。 */
54
+ export declare function buildInjectText(input: BuildHintInput): string | null;
55
+ export interface InjectResult {
56
+ ok: boolean;
57
+ /** 为什么没注入(ok=false 时给出原因,便于诊断)。 */
58
+ reason?: string;
59
+ /** 构造好的 UserMessage[](ok=true 时非空)——直接放进 additionalContexts。 */
60
+ messages?: UserMessageLike[];
61
+ }
62
+ /**
63
+ * 构造要放进 `PostToolDecision.additionalContexts` 的数组。
64
+ *
65
+ * **这是唯一对外入口** —— 所有校验、工厂/兜底选择、上限都在这里收口。
66
+ */
67
+ export declare function buildAdditionalContexts(input: {
68
+ root: string;
69
+ filePath?: string;
70
+ extraPaths?: readonly string[];
71
+ sessionId?: string | null;
72
+ trace?: InjectionTrace;
73
+ }): Promise<InjectResult>;
74
+ export {};
package/dist/inject.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * DSH 侧"按需上下文"注入(2026-10-30,见 docs/design/0016)。
3
+ *
4
+ * ## 事故教训(2026-10-25,4 个会话损坏)
5
+ *
6
+ * 往 `additionalContexts` 塞了**裸字符串**,而它的真实类型是 `UserMessage[]`:
7
+ *
8
+ * interface MessageBase { id; content: ContentBlock[]; source: MessageSource }
9
+ * interface UserMessage extends MessageBase { role: 'user' }
10
+ * TextBlock = { type: 'text'; text: string }
11
+ *
12
+ * 宿主读 `message.id` / `message.kind` 时直接炸。**不是"注入危险",是"形状错"。**
13
+ *
14
+ * ## 三条防线(缺一不可)
15
+ *
16
+ * 1. **优先用官方工厂**:`createUserMessage({content, source})`(`@deepseek-ai/dsh-llm`)
17
+ * —— 但实测 profile 与全局 node_modules 都解析不到它(pnpm 传递依赖),
18
+ * 所以工厂拿不到时用**照抄契约的手工构造**(不是重写状态机,是复制 5 行)。
19
+ * 2. **注入前断言**:`role/content/source/id` 逐项检查;不合法就**不注入**。
20
+ * 3. **失败降级**:整个包 try/catch,任何异常都返回"不注入",绝不打断工具调用。
21
+ *
22
+ * ## 内容纪律
23
+ *
24
+ * - 上限 900B(超了截断并标记)—— 注入即污染,这条比"多给点信息"重要
25
+ * - 同 session+文件只注入一次(否则每次编辑都刷同一段话)
26
+ * - 默认**关**(`AGENTKIT_CONTEXT_INJECT=1` 才开):未经真实会话验证的注入不默认生效
27
+ */
28
+ import { randomUUID } from 'node:crypto';
29
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ /** 注入内容的硬上限(字节)。超过就截断 —— 注入不是"越多越好"。 */
32
+ export const MAX_CONTEXT_BYTES = 900;
33
+ /** 尝试加载官方工厂(结果缓存;拿不到就是 null,不重试)。 */
34
+ let factoryLoader = null;
35
+ async function loadFactory() {
36
+ factoryLoader ??= (async () => {
37
+ try {
38
+ // 用变量形式 import:tsc 不静态解析(该包在 profile 里不可达,
39
+ // 解析失败由 catch 兜住 → 退回手工构造)
40
+ const pkg = '@deepseek-ai/dsh-llm';
41
+ const mod = (await import(pkg));
42
+ return typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null;
43
+ }
44
+ catch {
45
+ // 解析不到(pnpm 传递依赖/无依赖)→ 走手工兜底,**不让插件因此崩**
46
+ return null;
47
+ }
48
+ })();
49
+ return factoryLoader;
50
+ }
51
+ /** 手工构造(照抄契约)。只在这一处实现,便于审计。 */
52
+ function handcraft(text) {
53
+ return {
54
+ role: 'user',
55
+ id: randomUUID(),
56
+ content: [{ type: 'text', text }],
57
+ source: { kind: 'user' },
58
+ };
59
+ }
60
+ /**
61
+ * 断言"这真的是一个可以被宿主接受的 UserMessage"。
62
+ *
63
+ * 事故就是没做这一步。断言失败 → 调用方**不注入**(不是抛给宿主)。
64
+ */
65
+ export function assertUserMessage(value) {
66
+ if (typeof value !== 'object' || value === null)
67
+ return false;
68
+ const m = value;
69
+ if (m.role !== 'user')
70
+ return false;
71
+ if (typeof m.id !== 'string' || m.id === '')
72
+ return false;
73
+ const src = m.source;
74
+ if (src === undefined || src.kind !== 'user')
75
+ return false;
76
+ const content = m.content;
77
+ if (!Array.isArray(content) || content.length === 0)
78
+ return false;
79
+ return content.every((b) => {
80
+ const blk = b;
81
+ return blk.type === 'text' && typeof blk.text === 'string' && blk.text.length > 0;
82
+ });
83
+ }
84
+ /** 项目级注入开关文件(跟随仓库走,不需要每次设环境变量)。 */
85
+ export const INJECT_CONFIG_REL = '.agent/inject.json';
86
+ /**
87
+ * 这次调用是否该注入(**默认关**)。
88
+ *
89
+ * 两级开关,任一为真即开:
90
+ * 1. `.agent/inject.json` 的 `{"enabled": true}` —— 项目自己决定(推荐)
91
+ * 2. `AGENTKIT_CONTEXT_INJECT=1` —— 环境变量(临时试验用)
92
+ *
93
+ * 默认关的理由:这是 2026-10-25 事故区,未经真实会话验证的注入不默认生效。
94
+ */
95
+ export function injectEnabled(root) {
96
+ if (process.env.AGENTKIT_CONTEXT_INJECT === '1')
97
+ return true;
98
+ if (root === undefined || root === '')
99
+ return false;
100
+ try {
101
+ const f = join(root, INJECT_CONFIG_REL);
102
+ if (!existsSync(f))
103
+ return false;
104
+ const doc = JSON.parse(readFileSync(f, 'utf8'));
105
+ return doc.enabled === true;
106
+ }
107
+ catch {
108
+ // 配置坏了 = 不开(宁可少注入,不可乱注入)
109
+ return false;
110
+ }
111
+ }
112
+ /** 同一 session+文件只注入一次。 */
113
+ const injected = new Set();
114
+ /**
115
+ * 生成"按需上下文"文本(与 OpenCode 侧同一套规则,保持跨客户端一致)。
116
+ *
117
+ * 注意:这里**不依赖 @agentkit/core**(插件是独立发布物),规则表内联。
118
+ */
119
+ const PATH_RULES = [
120
+ { pattern: /^contracts\//, hint: '契约变更:实现必须同步(AGENTS 铁律),并在交接单声明契约影响与证据级别 L1-L4' },
121
+ { pattern: /^docs\/rules\//, hint: '规则文档:改它等于改所有人的行为约束 —— 先确认与 protocol.yml / AGENTS.md 不冲突' },
122
+ { pattern: /^docs\/design\//, hint: '设计文档:需与 codemap 里的模块对应(M4 会校验"文档提到该模块")' },
123
+ { pattern: /^\.agent\/decisions\/\d{4}-/, hint: 'ADR:accepted 的有约束力 —— 要推翻先写新 ADR 标 superseded,不要直接改旧结论' },
124
+ { pattern: /^AGENTS\.md$/, hint: 'AGENTS.md 是派生文件(由 protocol.yml 生成)—— 改它请改 protocol.yml 再 akit sync' },
125
+ ];
126
+ /** 生成注入文本;没有可注入内容时返回 null(**不注入空内容**)。 */
127
+ export function buildInjectText(input) {
128
+ const lines = [];
129
+ const rels = [];
130
+ if (input.filePath !== undefined && input.filePath !== '')
131
+ rels.push(input.filePath);
132
+ for (const p of input.extraPaths ?? [])
133
+ if (p !== '')
134
+ rels.push(p);
135
+ // 1) 路径规则(取第一条命中的,避免叠加成噪音)
136
+ for (const raw of rels) {
137
+ const rel = String(raw).replace(/\\/g, '/').replace(/^\.\//, '');
138
+ for (const rule of PATH_RULES) {
139
+ if (rule.pattern.test(rel)) {
140
+ lines.push(`- 纪律:${rule.hint}`);
141
+ break;
142
+ }
143
+ }
144
+ if (lines.length > 0)
145
+ break;
146
+ }
147
+ // 2) codemap:模块职责 + 设计文档
148
+ for (const raw of rels) {
149
+ const rel = String(raw).replace(/\\/g, '/').replace(/^\.\//, '');
150
+ try {
151
+ const cm = join(input.root, '.agent', 'codemap.json');
152
+ if (!existsSync(cm))
153
+ break;
154
+ const map = JSON.parse(readFileSync(cm, 'utf8'));
155
+ const mod = (map.modules ?? []).find((m) => String(m.path ?? '').replace(/^\.\//, '') === rel);
156
+ if (mod !== undefined) {
157
+ if (mod.purpose !== undefined && mod.purpose !== '')
158
+ lines.push(`- 模块职责(codemap):${mod.purpose}`);
159
+ if (mod.doc !== undefined && mod.doc !== '')
160
+ lines.push(`- 设计文档:${mod.doc}`);
161
+ break;
162
+ }
163
+ }
164
+ catch {
165
+ /* codemap 读不出 ⇒ 不注入这部分 */
166
+ }
167
+ }
168
+ // 3) ADR:正文提到该文件主干的,只给标题与状态
169
+ for (const raw of rels) {
170
+ if (lines.length >= 4)
171
+ break;
172
+ const rel = String(raw).replace(/\\/g, '/');
173
+ const stem = rel.split('/').pop()?.replace(/\.(ts|mjs|java|kt|py|go|md)$/, '') ?? '';
174
+ if (stem.length < 3)
175
+ continue;
176
+ try {
177
+ const dir = join(input.root, '.agent', 'decisions');
178
+ if (!existsSync(dir))
179
+ continue;
180
+ for (const name of readdirSync(dir).sort()) {
181
+ if (!/^\d{4}-.*\.md$/.test(name))
182
+ continue;
183
+ const body = readFileSync(join(dir, name), 'utf8');
184
+ if (!body.includes(stem) && !body.includes(rel))
185
+ continue;
186
+ const title = /^#\s+(.+)$/m.exec(body)?.[1] ?? name;
187
+ const status = /-?\s*\*\*状态\*\*[::]\s*(.+)/.exec(body)?.[1] ?? '';
188
+ lines.push(`- 相关决策:${name.replace(/\.md$/, '')} ${title}${status ? `(${status})` : ''}`);
189
+ if (lines.length >= 4)
190
+ break;
191
+ }
192
+ }
193
+ catch {
194
+ /* 读不出 ⇒ 不注入 */
195
+ }
196
+ }
197
+ if (lines.length === 0)
198
+ return null;
199
+ let text = ['<!-- agentkit: 按需上下文(只读,来自 codemap/ADR) -->', ...lines].join('\n');
200
+ if (text.length > MAX_CONTEXT_BYTES)
201
+ text = `${text.slice(0, MAX_CONTEXT_BYTES - 20)}\n…(已截断)`;
202
+ return text;
203
+ }
204
+ /**
205
+ * 构造要放进 `PostToolDecision.additionalContexts` 的数组。
206
+ *
207
+ * **这是唯一对外入口** —— 所有校验、工厂/兜底选择、上限都在这里收口。
208
+ */
209
+ export async function buildAdditionalContexts(input) {
210
+ try {
211
+ if (!injectEnabled(input.root)) {
212
+ // 2026-10-30:**没做也要出声** —— 否则"开关关着"与"根本没走到这里"在日志上无法区分
213
+ input.trace?.('inject skipped: disabled (no .agent/inject.json / env)');
214
+ return { ok: false, reason: 'inject disabled (no .agent/inject.json / env)' };
215
+ }
216
+ const key = `${input.sessionId ?? ''}|${input.filePath ?? ''}`;
217
+ if (injected.has(key)) {
218
+ input.trace?.('inject skipped: already injected for this session+file');
219
+ return { ok: false, reason: 'already injected for this session+file' };
220
+ }
221
+ const text = buildInjectText({
222
+ root: input.root,
223
+ ...(input.filePath !== undefined ? { filePath: input.filePath } : {}),
224
+ ...(input.extraPaths !== undefined ? { extraPaths: input.extraPaths } : {}),
225
+ });
226
+ if (text === null) {
227
+ input.trace?.('inject skipped: nothing to inject for this path');
228
+ return { ok: false, reason: 'nothing to inject for this path' };
229
+ }
230
+ const factory = await loadFactory();
231
+ const msg = factory !== null
232
+ ? factory({ content: [{ type: 'text', text }], source: { kind: 'user' } })
233
+ : handcraft(text);
234
+ if (!assertUserMessage(msg)) {
235
+ // 形状不合法 ⇒ **不注入**(而不是把坏形状交给宿主,那正是事故)
236
+ input.trace?.(`inject rejected: shape check failed (${factory !== null ? 'factory' : 'handcraft'})`);
237
+ return { ok: false, reason: 'shape check failed' };
238
+ }
239
+ injected.add(key);
240
+ input.trace?.(`inject ok: ${String(text.length)}B via ${factory !== null ? 'factory' : 'handcraft'}`);
241
+ return { ok: true, messages: [msg] };
242
+ }
243
+ catch (err) {
244
+ // 第三条防线:任何异常都不影响工具调用
245
+ input.trace?.(`inject skipped: ${err.message}`);
246
+ return { ok: false, reason: err.message };
247
+ }
248
+ }
@@ -0,0 +1,81 @@
1
+ export declare const ROW_ID = "project-hooks";
2
+ export declare const ROW_NAME = "@minidoll/agentkit-dsh-plugin";
3
+ export declare const STATE_DIR = ".agentkit-landing";
4
+ export interface LandingPaths {
5
+ home: string;
6
+ profile: string;
7
+ patch: string;
8
+ stateDir: string;
9
+ }
10
+ export declare function landingPaths(home: string, profile: string): LandingPaths;
11
+ export interface InstallOptions {
12
+ home: string;
13
+ profile: string;
14
+ /** 配置文件绝对路径(可选):不传则插件用默认(项目内发现)。 */
15
+ configFile?: string;
16
+ }
17
+ /**
18
+ * 把插件**包本身**装进 profile(T-008 完整路径的第 1–2 步):
19
+ * 1. 在 profile 目录执行 `pnpm add <tarball|spec>` —— 代码落到
20
+ * <profile>/node_modules(DSH loader 就是从那里加载 bundle 的)
21
+ * 2. 把包名加进 package.json 的 `dsh.profile.bundles`(顺序在末尾追加)
22
+ *
23
+ * 第 3 步(写 patch 行)由 bundle 自带的 cordis.patch.yml 完成 —— 它的
24
+ * package.json 声明了 `dsh.bundle.patch`。只有需要 configFile 覆盖时我们才
25
+ * 额外写一行(避免重复条目)。
26
+ */
27
+ export interface BundleInstallOptions {
28
+ home: string;
29
+ profile: string;
30
+ /** tarball 路径或 npm 规格。 */
31
+ from: string;
32
+ /** 测试用:跳过真实 pnpm(只验证 package.json 与 patch 的文件效果)。 */
33
+ skipPackageManager?: boolean;
34
+ }
35
+ export interface BundleInstallResult {
36
+ ok: boolean;
37
+ added: boolean;
38
+ alreadyInstalled: boolean;
39
+ error?: string;
40
+ }
41
+ export interface InstallResult {
42
+ ok: boolean;
43
+ changed: boolean;
44
+ path: string;
45
+ backup?: string;
46
+ reason?: string;
47
+ }
48
+ export declare function install(opts: InstallOptions): InstallResult;
49
+ export declare function rollback(opts: {
50
+ home: string;
51
+ profile: string;
52
+ }): InstallResult;
53
+ export interface StatusResult {
54
+ ok: boolean;
55
+ installed: boolean;
56
+ path: string;
57
+ backups: string[];
58
+ configFile?: string;
59
+ }
60
+ export declare function status(opts: {
61
+ home: string;
62
+ profile: string;
63
+ }): StatusResult;
64
+ /** 第 1–2 步:装包 + 登记 bundle。幂等(已装则跳过)。 */
65
+ export declare function installBundle(opts: BundleInstallOptions): BundleInstallResult;
66
+ /**
67
+ * 完整回滚:撤 patch 条目 **+** 撤 bundle 注册(deps/bundles)+ 卸包。
68
+ *
69
+ * 教训(0.0.2 事故):只恢复 patch 文件是不够的 —— bundle 仍在
70
+ * dsh.profile.bundles 里,重启后照样被组合。撤销必须覆盖**注册面**。
71
+ */
72
+ export declare function rollbackBundle(opts: {
73
+ home: string;
74
+ profile: string;
75
+ skipPackageManager?: boolean;
76
+ }): {
77
+ ok: boolean;
78
+ removed: boolean;
79
+ patchRollback: InstallResult;
80
+ error?: string;
81
+ };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * DSH 宿主侧的落地(install / rollback / status)。
3
+ *
4
+ * 这是 client-landing 分工里归本层的那部分:**宿主改动是显式部署动作**
5
+ * (agentkit RFC-004 §7.8:项目侧自包含,宿主侧可审计、可撤销)。
6
+ *
7
+ * 设计约束(与 agentkit 自己的 install 同源):
8
+ * - **幂等**:已有我们的条目就不再插(重复安装不该产生重复配置)
9
+ * - **备份**:写入前把原文件复制到 .agentkit-landing/backup-<ts>.yml
10
+ * - **可回滚**:rollback 恢复最近一次备份(或指定的)
11
+ * - **审计**:每次动作写一条 JSON 到 .agentkit-landing/ledger.jsonl
12
+ * - **不吞别人的配置**:只追加我们这一行,不改写既有条目
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { homedir } from 'node:os';
16
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ export const ROW_ID = 'project-hooks';
19
+ export const ROW_NAME = '@minidoll/agentkit-dsh-plugin';
20
+ export const STATE_DIR = '.agentkit-landing';
21
+ export function landingPaths(home, profile) {
22
+ const patch = join(home, 'profiles', profile, 'cordis.patch.yml');
23
+ return { home, profile, patch, stateDir: join(home, 'profiles', profile, STATE_DIR) };
24
+ }
25
+ function rowPresent(patchText) {
26
+ // 简单而稳:按我们自己的 id 与 name 判断
27
+ return patchText.includes(`id: ${ROW_ID}`) || patchText.includes(ROW_NAME);
28
+ }
29
+ function renderRow(configFile) {
30
+ const lines = [`- id: ${ROW_ID}`, ` name: ${ROW_NAME}`];
31
+ if (configFile !== undefined) {
32
+ lines.push(' config:', ` configFile: ${configFile}`);
33
+ }
34
+ return `${lines.join('\n')}\n`;
35
+ }
36
+ function audit(paths, action, detail) {
37
+ mkdirSync(paths.stateDir, { recursive: true });
38
+ const rec = { at: new Date().toISOString(), action, profile: paths.profile, ...detail };
39
+ appendFileSync(join(paths.stateDir, 'ledger.jsonl'), `${JSON.stringify(rec)}\n`, 'utf8');
40
+ }
41
+ export function install(opts) {
42
+ const paths = landingPaths(opts.home, opts.profile);
43
+ if (!existsSync(join(opts.home, 'profiles', opts.profile))) {
44
+ return { ok: false, changed: false, path: paths.patch, reason: `profile not found: ${opts.home}/profiles/${opts.profile}` };
45
+ }
46
+ if (!existsSync(paths.patch)) {
47
+ // 没有 patch 文件就创建 —— 它是 profile 的用户层,允许为空后追加
48
+ mkdirSync(paths.stateDir, { recursive: true });
49
+ writeFileSync(paths.patch, `${renderRow(opts.configFile)}`, 'utf8');
50
+ audit(paths, 'install', { backup: null, configFile: opts.configFile ?? null, created: true });
51
+ return { ok: true, changed: true, path: paths.patch, reason: 'created' };
52
+ }
53
+ const before = readFileSync(paths.patch, 'utf8');
54
+ if (rowPresent(before)) {
55
+ audit(paths, 'install-noop', { reason: 'already present' });
56
+ return { ok: true, changed: false, path: paths.patch, reason: 'already present' };
57
+ }
58
+ mkdirSync(paths.stateDir, { recursive: true });
59
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
60
+ const backup = join(paths.stateDir, `backup-${stamp}.yml`);
61
+ copyFileSync(paths.patch, backup);
62
+ // 只**追加**我们的行,不改写既有条目(别人的 patch 不该被我们动)
63
+ const next = before.endsWith('\n') || before === '' ? `${before}${renderRow(opts.configFile)}` : `${before}\n${renderRow(opts.configFile)}`;
64
+ writeFileSync(paths.patch, next, 'utf8');
65
+ audit(paths, 'install', { backup, configFile: opts.configFile ?? null });
66
+ return { ok: true, changed: true, path: paths.patch, backup };
67
+ }
68
+ export function rollback(opts) {
69
+ const paths = landingPaths(opts.home, opts.profile);
70
+ if (!existsSync(paths.stateDir)) {
71
+ return { ok: false, changed: false, path: paths.patch, reason: 'no state directory — nothing to roll back' };
72
+ }
73
+ const backups = readdirSync(paths.stateDir)
74
+ .filter((f) => f.startsWith('backup-') && f.endsWith('.yml'))
75
+ .sort();
76
+ const last = backups.at(-1);
77
+ if (last === undefined) {
78
+ return { ok: false, changed: false, path: paths.patch, reason: 'no backup recorded' };
79
+ }
80
+ copyFileSync(join(paths.stateDir, last), paths.patch);
81
+ audit(paths, 'rollback', { restoredFrom: last });
82
+ return { ok: true, changed: true, path: paths.patch, backup: join(paths.stateDir, last) };
83
+ }
84
+ export function status(opts) {
85
+ const paths = landingPaths(opts.home, opts.profile);
86
+ if (!existsSync(paths.patch)) {
87
+ return { ok: true, installed: false, path: paths.patch, backups: [] };
88
+ }
89
+ const text = readFileSync(paths.patch, 'utf8');
90
+ const m = new RegExp(`configFile:\\s*(\\S+)`).exec(text);
91
+ const backups = existsSync(paths.stateDir)
92
+ ? readdirSync(paths.stateDir).filter((f) => f.startsWith('backup-')).sort()
93
+ : [];
94
+ return { ok: true, installed: rowPresent(text), path: paths.patch, backups, ...(m?.[1] !== undefined ? { configFile: m[1] } : {}) };
95
+ }
96
+ function readProfileJson(home, profile) {
97
+ const p = join(home, 'profiles', profile, 'package.json');
98
+ if (!existsSync(p))
99
+ return null;
100
+ try {
101
+ return JSON.parse(readFileSync(p, 'utf8'));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /** 第 1–2 步:装包 + 登记 bundle。幂等(已装则跳过)。 */
108
+ export function installBundle(opts) {
109
+ const profileDir = join(opts.home, 'profiles', opts.profile);
110
+ if (!existsSync(join(opts.home, 'profiles', opts.profile))) {
111
+ return { ok: false, added: false, alreadyInstalled: false, error: `profile not found: ${profileDir}` };
112
+ }
113
+ const pkg = readProfileJson(opts.home, opts.profile);
114
+ if (pkg === null)
115
+ return { ok: false, added: false, alreadyInstalled: false, error: `cannot read ${join(profileDir, 'package.json')}` };
116
+ const deps = (pkg.dependencies ?? {});
117
+ const dsh = (pkg.dsh ?? {});
118
+ const bundles = dsh.profile?.bundles ?? [];
119
+ const installed = Object.keys(deps).some((k) => k === ROW_NAME) || bundles.includes(ROW_NAME);
120
+ if (installed)
121
+ return { ok: true, added: false, alreadyInstalled: true };
122
+ if (opts.skipPackageManager !== true) {
123
+ // 1. 真装:代码必须落到 node_modules,loader 才加载得到。
124
+ // pnpm 不一定是全局命令(常见于 nvm/pnpm 独立安装),按候选路径解析;
125
+ // 解析不到要给**可操作**的错误,而不是空 stderr。
126
+ const pm = resolvePnpm();
127
+ if (pm === null) {
128
+ return {
129
+ ok: false,
130
+ added: false,
131
+ alreadyInstalled: false,
132
+ error: 'pnpm not found (tried PATH, ~/.local/share/pnpm/pnpm, npx). ' +
133
+ 'Install pnpm, or rerun with --skip-pnpm and install the package into the profile by hand.',
134
+ };
135
+ }
136
+ const r = spawnSync(pm, ['add', opts.from], { cwd: profileDir, encoding: 'utf8', timeout: 180_000 });
137
+ if (r.status !== 0) {
138
+ const detail = [r.error?.message, r.stdout, r.stderr].filter((x) => typeof x === 'string').join('\n').trim();
139
+ return { ok: false, added: false, alreadyInstalled: false, error: `${pm} add failed (exit ${String(r.status)})\n${detail.slice(-600)}` };
140
+ }
141
+ }
142
+ // 2. 登记 bundle(顺序追加,与 plugin-manager 的行为一致)
143
+ const next = {
144
+ ...pkg,
145
+ dependencies: { ...deps, [ROW_NAME]: opts.skipPackageManager === true ? 'file:.' : opts.from },
146
+ dsh: { ...dsh, profile: { ...(dsh.profile ?? {}), bundles: [...bundles, ROW_NAME] } },
147
+ };
148
+ writeFileSync(join(profileDir, 'package.json'), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
149
+ audit(landingPaths(opts.home, opts.profile), 'install-bundle', { from: opts.from });
150
+ return { ok: true, added: true, alreadyInstalled: false };
151
+ }
152
+ /** pnpm 的可用路径:先 PATH,再常见的独立安装位置,再 npx。 */
153
+ function resolvePnpm() {
154
+ const candidates = ['pnpm', join(homedirPath(), '.local/share/pnpm/pnpm')];
155
+ for (const c of candidates) {
156
+ const probe = spawnSync(c, ['--version'], { encoding: 'utf8', timeout: 15_000 });
157
+ if (probe.status === 0)
158
+ return c;
159
+ }
160
+ const viaNpx = spawnSync('npx', ['--no-install', 'pnpm', '--version'], { encoding: 'utf8', timeout: 20_000 });
161
+ if (viaNpx.status === 0)
162
+ return 'npx pnpm';
163
+ return null;
164
+ }
165
+ function homedirPath() {
166
+ try {
167
+ return homedir();
168
+ }
169
+ catch {
170
+ return process.env.HOME ?? '';
171
+ }
172
+ }
173
+ /**
174
+ * 完整回滚:撤 patch 条目 **+** 撤 bundle 注册(deps/bundles)+ 卸包。
175
+ *
176
+ * 教训(0.0.2 事故):只恢复 patch 文件是不够的 —— bundle 仍在
177
+ * dsh.profile.bundles 里,重启后照样被组合。撤销必须覆盖**注册面**。
178
+ */
179
+ export function rollbackBundle(opts) {
180
+ const patchRollback = rollback({ home: opts.home, profile: opts.profile });
181
+ const profileDir = join(opts.home, 'profiles', opts.profile);
182
+ const pkg = readProfileJson(opts.home, opts.profile);
183
+ if (pkg === null) {
184
+ return { ok: false, removed: false, patchRollback, error: `cannot read ${join(profileDir, 'package.json')}` };
185
+ }
186
+ const deps = (pkg.dependencies ?? {});
187
+ const dsh = (pkg.dsh ?? {});
188
+ const bundles = dsh.profile?.bundles ?? [];
189
+ const had = ROW_NAME in deps || bundles.includes(ROW_NAME);
190
+ if (!had) {
191
+ audit(landingPaths(opts.home, opts.profile), 'rollback-bundle-noop', { reason: 'not registered' });
192
+ return { ok: true, removed: false, patchRollback };
193
+ }
194
+ const nextDeps = { ...deps };
195
+ delete nextDeps[ROW_NAME];
196
+ const nextBundles = bundles.filter((b) => b !== ROW_NAME);
197
+ const next = { ...pkg, dependencies: nextDeps, dsh: { ...dsh, profile: { ...(dsh.profile ?? {}), bundles: nextBundles } } };
198
+ writeFileSync(join(profileDir, 'package.json'), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
199
+ if (opts.skipPackageManager !== true) {
200
+ const pm = resolvePnpm();
201
+ if (pm !== null) {
202
+ const r = spawnSync(pm, ['remove', ROW_NAME], { cwd: profileDir, encoding: 'utf8', timeout: 180_000 });
203
+ if (r.status !== 0) {
204
+ // 注册已撤,包没卸掉 —— 如实报出,不假装成功
205
+ const detail = [r.stdout, r.stderr].filter((x) => typeof x === 'string').join('\n').trim();
206
+ audit(landingPaths(opts.home, opts.profile), 'rollback-bundle-partial', { detail: detail.slice(-300) });
207
+ return { ok: false, removed: false, patchRollback, error: `package removal failed: ${detail.slice(-300)}` };
208
+ }
209
+ }
210
+ }
211
+ audit(landingPaths(opts.home, opts.profile), 'rollback-bundle', {});
212
+ return { ok: true, removed: true, patchRollback };
213
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 钩子 payload 构造(纯函数)。
3
+ *
4
+ * 字段以 `dsh-plugin-hooks/lib/config.js` 的 buildPayload 为实证依据(它读的是
5
+ * DSH 自己的词汇表);本项目与它的差异只在**配置发现位置**(项目内 vs 宿主)。
6
+ */
7
+ export interface HookPayload {
8
+ event: string;
9
+ hookId: string;
10
+ toolName: string;
11
+ args: Record<string, unknown>;
12
+ sessionId: string | null;
13
+ cwd: string | null;
14
+ }
15
+ export declare function buildPayload(input: Omit<HookPayload, 'cwd'> & {
16
+ cwd?: string | null;
17
+ }): HookPayload;
18
+ /** payload 走 stdin:一行 JSON + 换行(与 DSH shell 服务的词汇表一致)。 */
19
+ export declare function payloadStdin(payload: HookPayload): string;
20
+ /**
21
+ * 环境变量。
22
+ *
23
+ * **必须显式带上 PATH**:harness 的执行器给命令的是**干净环境** ——
24
+ * `env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }`,**没有 process.env**。
25
+ * 于是 `node .agent/generated/dsh-hook-bridge.mjs Gx` 在里面找不到 node,
26
+ * 命令静默失败、不写台账 —— 这正是"真实 seam 有痕迹但台账不动"的根因
27
+ * (我自己的探针能跑通,是因为探针进程有 PATH)。
28
+ */
29
+ export declare function hookEnv(cwd: string, hookId: string, sessionId: string | null): Record<string, string>;
30
+ /**
31
+ * 找到能跑 `node` 的目录。
32
+ *
33
+ * `process.execPath` 在插件跑在 Node 里时就是 node 本身;但 DSH 桌面端
34
+ * 可能把它跑在 Electron 宿主里(execPath 是 dsh-desktop)—— 那样要另外探测。
35
+ * 顺序:execPath 目录(若含 node)→ PATH 里第一个含 node 的目录 → 常见安装位置。
36
+ */
37
+ export declare function nodeBinDir(): string | null;