@shgroup/dsh-serenity-hooks 1.16.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/cordis.patch.yml +8 -0
- package/dsh.plugin.json +23 -0
- package/lib/index.js +2683 -0
- package/lib/invariant.js +48 -0
- package/package.json +90 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2683 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { execFile, execFileSync, spawnSync } from "node:child_process";
|
|
6
|
+
import { platform } from "node:os";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
//#region src/ccc.ts
|
|
12
|
+
/**
|
|
13
|
+
* ccc.ts — CCC 纯逻辑层(零 DSH 依赖,可独立单测)
|
|
14
|
+
*
|
|
15
|
+
* 职责:CCC 根检测(P1)、git 检测(P2)、.dsh/serenity.json 配置读取、
|
|
16
|
+
* 路径守卫(P3 语义)、安全模式黑名单匹配。
|
|
17
|
+
*
|
|
18
|
+
* 由 tools/ 与 seams/ 复用;逻辑移植自 dsh-serenity-plugin v0.1-v0.2
|
|
19
|
+
* runner(本项目自有代码,非 opencode-serenity-plugin 源码)。
|
|
20
|
+
*/
|
|
21
|
+
function findSerenityRoot(cwd) {
|
|
22
|
+
let current = resolve(cwd);
|
|
23
|
+
while (true) {
|
|
24
|
+
const marker = resolve(current, ".serenity");
|
|
25
|
+
if (existsSync(marker) && statSync(marker).isFile()) return current;
|
|
26
|
+
const parent = dirname(current);
|
|
27
|
+
if (parent === current) return null;
|
|
28
|
+
current = parent;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function findGitRoot(cwd) {
|
|
32
|
+
let current = resolve(cwd);
|
|
33
|
+
while (true) {
|
|
34
|
+
if (existsSync(resolve(current, ".git"))) return current;
|
|
35
|
+
const parent = dirname(current);
|
|
36
|
+
if (parent === current) return null;
|
|
37
|
+
current = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function classifyPath(p, root) {
|
|
41
|
+
const rel = relative(resolve(root), resolve(p));
|
|
42
|
+
if (rel === "") return "same";
|
|
43
|
+
if (rel.startsWith("..")) return "outside";
|
|
44
|
+
return "inside";
|
|
45
|
+
}
|
|
46
|
+
function resolveInside(root, p) {
|
|
47
|
+
const abs = resolve(root, p);
|
|
48
|
+
if (classifyPath(abs, root) === "outside") throw new Error(`Path escape blocked: "${p}" resolves outside "${root}"`);
|
|
49
|
+
return abs;
|
|
50
|
+
}
|
|
51
|
+
const DEFAULT_SERENITY_CONFIG_PATHS = [".dsh/serenity.json", ".opencode/serenity.json"];
|
|
52
|
+
function loadSerenityConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
53
|
+
for (const candidate of paths) {
|
|
54
|
+
const p = resolve(root, candidate);
|
|
55
|
+
if (!existsSync(p)) continue;
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const SAFE_MODE_MARKER = ".serenity-safe-on";
|
|
65
|
+
function isSafeModeOn(root) {
|
|
66
|
+
return existsSync(resolve(root, SAFE_MODE_MARKER));
|
|
67
|
+
}
|
|
68
|
+
function readBlacklist(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
69
|
+
const rules = loadSerenityConfig(root, paths).safeMode?.blacklist;
|
|
70
|
+
return Array.isArray(rules) ? rules.map(String) : [];
|
|
71
|
+
}
|
|
72
|
+
/** 匹配黑名单规则;命中返回规则,未命中返回 null。前缀匹配 / regex: 前缀 */
|
|
73
|
+
function matchBlacklist(relPath, rules) {
|
|
74
|
+
for (const rule of rules) if (rule.startsWith("regex:")) try {
|
|
75
|
+
if (new RegExp(rule.slice(6)).test(relPath)) return rule;
|
|
76
|
+
} catch {}
|
|
77
|
+
else if (relPath.startsWith(rule)) return rule;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/fs-ops.ts
|
|
82
|
+
/**
|
|
83
|
+
* fs-ops.ts — cc_fs 纯操作层(零 DSH 依赖,可独立单测)
|
|
84
|
+
*
|
|
85
|
+
* 移植自 dsh-serenity-plugin v0.1 acc-fs runner(本项目自有代码)。
|
|
86
|
+
* 每个操作返回规范 JSON 值(由工具层 render 成模型可见文本)。
|
|
87
|
+
*/
|
|
88
|
+
const CC_FS_ACTIONS = [
|
|
89
|
+
"root",
|
|
90
|
+
"resolve",
|
|
91
|
+
"exists",
|
|
92
|
+
"list",
|
|
93
|
+
"tree",
|
|
94
|
+
"relative",
|
|
95
|
+
"mkdir",
|
|
96
|
+
"rm",
|
|
97
|
+
"mv",
|
|
98
|
+
"cp",
|
|
99
|
+
"touch",
|
|
100
|
+
"append",
|
|
101
|
+
"reveal",
|
|
102
|
+
"info",
|
|
103
|
+
"find"
|
|
104
|
+
];
|
|
105
|
+
function safeRel(root, abs) {
|
|
106
|
+
return relative(root, abs) || ".";
|
|
107
|
+
}
|
|
108
|
+
function runCcFs(root, args) {
|
|
109
|
+
const a = args.action;
|
|
110
|
+
switch (a) {
|
|
111
|
+
case "root": return root;
|
|
112
|
+
case "resolve":
|
|
113
|
+
if (!args.path) throw new Error("resolve 需要 path");
|
|
114
|
+
return resolveInside(root, args.path);
|
|
115
|
+
case "exists":
|
|
116
|
+
if (!args.path) throw new Error("exists 需要 path");
|
|
117
|
+
return existsSync(resolveInside(root, args.path));
|
|
118
|
+
case "list": {
|
|
119
|
+
const dir = args.path ? resolveInside(root, args.path) : root;
|
|
120
|
+
if (!existsSync(dir)) throw new Error(`no such dir: ${dir}`);
|
|
121
|
+
return readdirSync(dir, { withFileTypes: true }).map((e) => ({
|
|
122
|
+
name: e.name,
|
|
123
|
+
type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other"
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
case "tree": {
|
|
127
|
+
const dir = args.path ? resolveInside(root, args.path) : root;
|
|
128
|
+
const maxDepth = args.depth ?? Infinity;
|
|
129
|
+
if (!existsSync(dir)) throw new Error(`no such dir: ${dir}`);
|
|
130
|
+
const out = [];
|
|
131
|
+
const walk = (cur, depth) => {
|
|
132
|
+
if (depth > maxDepth) return;
|
|
133
|
+
for (const e of readdirSync(cur, { withFileTypes: true })) {
|
|
134
|
+
const full = join(cur, e.name);
|
|
135
|
+
out.push({
|
|
136
|
+
path: safeRel(root, full),
|
|
137
|
+
type: e.isDirectory() ? "dir" : "file"
|
|
138
|
+
});
|
|
139
|
+
if (e.isDirectory()) walk(full, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(dir, 0);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
case "relative":
|
|
146
|
+
if (!args.path) throw new Error("relative 需要 path");
|
|
147
|
+
return safeRel(root, resolveInside(root, args.path));
|
|
148
|
+
case "mkdir": {
|
|
149
|
+
const targets = args.paths?.length ? args.paths : args.path ? [args.path] : [];
|
|
150
|
+
if (targets.length === 0) throw new Error("mkdir 需要 path(s)");
|
|
151
|
+
for (const t of targets) mkdirSync(resolveInside(root, t), { recursive: true });
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
created: targets
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case "rm": {
|
|
158
|
+
const targets = args.paths?.length ? args.paths : args.path ? [args.path] : [];
|
|
159
|
+
if (targets.length === 0) throw new Error("rm 需要 path(s)");
|
|
160
|
+
const removed = [];
|
|
161
|
+
for (const t of targets) {
|
|
162
|
+
const abs = resolveInside(root, t);
|
|
163
|
+
if (abs === root) throw new Error("拒绝删除 CCC 根本身");
|
|
164
|
+
if (!existsSync(abs)) continue;
|
|
165
|
+
if (args.dryRun) {
|
|
166
|
+
removed.push(`${t} [dry-run]`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
rmSync(abs, {
|
|
170
|
+
recursive: true,
|
|
171
|
+
force: true
|
|
172
|
+
});
|
|
173
|
+
removed.push(t);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
removed
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
case "mv":
|
|
181
|
+
if (!args.src || !args.dst) throw new Error("mv 需要 src + dst");
|
|
182
|
+
renameSync(resolveInside(root, args.src), resolveInside(root, args.dst));
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
from: args.src,
|
|
186
|
+
to: args.dst
|
|
187
|
+
};
|
|
188
|
+
case "cp":
|
|
189
|
+
if (!args.src || !args.dst) throw new Error("cp 需要 src + dst");
|
|
190
|
+
cpSync(resolveInside(root, args.src), resolveInside(root, args.dst), { recursive: true });
|
|
191
|
+
return {
|
|
192
|
+
ok: true,
|
|
193
|
+
from: args.src,
|
|
194
|
+
to: args.dst
|
|
195
|
+
};
|
|
196
|
+
case "touch": {
|
|
197
|
+
if (!args.path) throw new Error("touch 需要 path");
|
|
198
|
+
const abs = resolveInside(root, args.path);
|
|
199
|
+
if (!existsSync(abs)) writeFileSync(abs, "", "utf-8");
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
path: safeRel(root, abs)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
case "append": {
|
|
206
|
+
if (!args.path || args.content === void 0) throw new Error("append 需要 path + content");
|
|
207
|
+
const abs = resolveInside(root, args.path);
|
|
208
|
+
appendFileSync(abs, args.content, "utf-8");
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
path: safeRel(root, abs)
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
case "reveal": {
|
|
215
|
+
if (!args.path) throw new Error("reveal 需要 path");
|
|
216
|
+
const abs = resolveInside(root, args.path);
|
|
217
|
+
if (!existsSync(abs)) throw new Error(`no such path: ${abs}`);
|
|
218
|
+
const os = platform();
|
|
219
|
+
try {
|
|
220
|
+
if (os === "darwin") execFileSync("open", ["-R", abs], { timeout: 1e4 });
|
|
221
|
+
else if (os === "linux") {
|
|
222
|
+
const revealPath = statSync(abs).isDirectory() ? abs : dirname(abs);
|
|
223
|
+
execFileSync("xdg-open", [revealPath], { timeout: 1e4 });
|
|
224
|
+
} else if (os === "win32") execFileSync("explorer", ["/select,", abs], { timeout: 1e4 });
|
|
225
|
+
else throw new Error(`unsupported platform: ${os}`);
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
revealed: safeRel(root, abs)
|
|
229
|
+
};
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
232
|
+
throw new Error(`reveal failed to open "${safeRel(root, abs)}": ${msg}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
case "info": {
|
|
236
|
+
if (!args.path) throw new Error("info 需要 path");
|
|
237
|
+
const abs = resolveInside(root, args.path);
|
|
238
|
+
if (!existsSync(abs)) return {
|
|
239
|
+
exists: false,
|
|
240
|
+
path: safeRel(root, abs)
|
|
241
|
+
};
|
|
242
|
+
const st = statSync(abs);
|
|
243
|
+
return {
|
|
244
|
+
exists: true,
|
|
245
|
+
path: safeRel(root, abs),
|
|
246
|
+
type: st.isDirectory() ? "dir" : st.isFile() ? "file" : "other",
|
|
247
|
+
size: st.size,
|
|
248
|
+
mtime: st.mtime.toISOString()
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
case "find": {
|
|
252
|
+
if (!args.pattern) throw new Error("find 需要 pattern");
|
|
253
|
+
const pattern = args.pattern;
|
|
254
|
+
const isRegex = pattern.startsWith("regex:");
|
|
255
|
+
const re = isRegex ? new RegExp(pattern.slice(6)) : null;
|
|
256
|
+
const out = [];
|
|
257
|
+
const walk = (cur) => {
|
|
258
|
+
for (const e of readdirSync(cur, { withFileTypes: true })) {
|
|
259
|
+
const full = join(cur, e.name);
|
|
260
|
+
if (isRegex ? re.test(e.name) : e.name.includes(pattern)) out.push(safeRel(root, full));
|
|
261
|
+
if (e.isDirectory()) walk(full);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
walk(root);
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
default: throw new Error(`未知 action: ${a}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/tools/cc-fs.ts
|
|
272
|
+
/**
|
|
273
|
+
* cc-fs.ts — cc_fs 真实 DSH 工具定义(defineTool)
|
|
274
|
+
*
|
|
275
|
+
* 进程内注册(取代 v0.1 的 bash spawn runner):zod/schemastery 参数校验、
|
|
276
|
+
* 规范 JSON 输出、纯 render 投影。逻辑在 fs-ops.ts(可单测)。
|
|
277
|
+
*/
|
|
278
|
+
function agentCwd$6(exec) {
|
|
279
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
280
|
+
}
|
|
281
|
+
function renderText$8(value) {
|
|
282
|
+
return [{
|
|
283
|
+
type: "text",
|
|
284
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
285
|
+
}];
|
|
286
|
+
}
|
|
287
|
+
const ccFsTool = defineTool({
|
|
288
|
+
name: "cc_fs",
|
|
289
|
+
description: "CCC 内文件系统操作(cc-fs 语义,DSH 原生版)。15 子命令:root/resolve/exists/list/tree/relative/mkdir/rm/mv/cp/touch/append/reveal/info/find。全部路径限定在 CCC 根内,路径逃逸自动阻断。reveal 在 OS 文件管理器中打开路径(Linux xdg-open / macOS Finder / Windows Explorer)。与 read/write/edit 互补(结构性操作)。",
|
|
290
|
+
parameters: {
|
|
291
|
+
action: {
|
|
292
|
+
type: "string",
|
|
293
|
+
enum: [...CC_FS_ACTIONS],
|
|
294
|
+
required: true,
|
|
295
|
+
description: "子命令"
|
|
296
|
+
},
|
|
297
|
+
path: {
|
|
298
|
+
type: "string",
|
|
299
|
+
description: "主路径参数(resolve/exists/list/tree/relative/touch/append/info/rm 单路径)"
|
|
300
|
+
},
|
|
301
|
+
paths: {
|
|
302
|
+
type: "array",
|
|
303
|
+
items: { type: "string" },
|
|
304
|
+
description: "批量路径(mkdir/rm)"
|
|
305
|
+
},
|
|
306
|
+
src: {
|
|
307
|
+
type: "string",
|
|
308
|
+
description: "mv/cp 源路径"
|
|
309
|
+
},
|
|
310
|
+
dst: {
|
|
311
|
+
type: "string",
|
|
312
|
+
description: "mv/cp 目标路径"
|
|
313
|
+
},
|
|
314
|
+
content: {
|
|
315
|
+
type: "string",
|
|
316
|
+
description: "append 内容"
|
|
317
|
+
},
|
|
318
|
+
pattern: {
|
|
319
|
+
type: "string",
|
|
320
|
+
description: "find 匹配(名称包含;regex: 前缀为正则)"
|
|
321
|
+
},
|
|
322
|
+
depth: {
|
|
323
|
+
type: "integer",
|
|
324
|
+
description: "tree 最大深度"
|
|
325
|
+
},
|
|
326
|
+
dryRun: {
|
|
327
|
+
type: "boolean",
|
|
328
|
+
description: "rm 预览模式"
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
output: {
|
|
332
|
+
schema: { type: "json" },
|
|
333
|
+
render: (args, value) => renderText$8(value)
|
|
334
|
+
},
|
|
335
|
+
async execute(args, exec) {
|
|
336
|
+
const root = findSerenityRoot(agentCwd$6(exec));
|
|
337
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
338
|
+
return runCcFs(root, args);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/msm-ops.ts
|
|
343
|
+
/**
|
|
344
|
+
* msm-ops.ts — acc_msm 纯操作层(零 DSH 依赖)
|
|
345
|
+
*
|
|
346
|
+
* MSM(Mech & Semi-Mech)框架:list / exec / admin(register|deregister|check)。
|
|
347
|
+
* 复用 CCC 的 mech-registry.json(v1 或数组格式)。cwd 钉在 CCC 根。
|
|
348
|
+
*/
|
|
349
|
+
const execFileAsync = promisify(execFile);
|
|
350
|
+
const MSM_TIMEOUT_MS = 6e5;
|
|
351
|
+
const MSM_ACTIONS = [
|
|
352
|
+
"list",
|
|
353
|
+
"exec",
|
|
354
|
+
"register",
|
|
355
|
+
"deregister",
|
|
356
|
+
"check",
|
|
357
|
+
"guide"
|
|
358
|
+
];
|
|
359
|
+
const MSM_GUIDE = `MSM 开发手册(Mech & Semi-Mech 框架)
|
|
360
|
+
|
|
361
|
+
## 是什么
|
|
362
|
+
MSM = 可执行单元层。Mech 纯 TS 零 LLM 推理;Semi-Mech TS 框架 + LLM 决策点。
|
|
363
|
+
|
|
364
|
+
## 注册新 MSM(acc_msm register)
|
|
365
|
+
1. 在 <skill>/scripts/ 写脚本(tsx 可跑;必须带 main() CLI 守卫 import.meta.url 检查)
|
|
366
|
+
2. acc_msm register <name> --skill <s> --path <脚本相对根路径> --category <mech|semi-mech> --description <desc>
|
|
367
|
+
3. 自动写入 mech-registry.json + git commit
|
|
368
|
+
|
|
369
|
+
## 脚本约定
|
|
370
|
+
- 顶部文档:用途/用法/退出码
|
|
371
|
+
- 退出码:0 成功 / 1 user / 2 system / 3 operator
|
|
372
|
+
- flags 中 type:"path" 的参数会被逃逸校验(根内强制)
|
|
373
|
+
- 配对 .test.ts(vitest)
|
|
374
|
+
|
|
375
|
+
## 品质检查(acc_msm check)
|
|
376
|
+
DC-M1 有 .test.ts;DC-M2 有 main() 守卫;M3 脚本存在;M4 path flag 标记 type:"path"
|
|
377
|
+
`;
|
|
378
|
+
function parseRegistry(raw) {
|
|
379
|
+
const data = JSON.parse(raw);
|
|
380
|
+
if (Array.isArray(data)) return data;
|
|
381
|
+
const entries = data.entries;
|
|
382
|
+
if (!Array.isArray(entries)) throw new Error("invalid registry: missing entries[]");
|
|
383
|
+
return entries;
|
|
384
|
+
}
|
|
385
|
+
function findRegistries(root) {
|
|
386
|
+
const out = [];
|
|
387
|
+
const skillsDir = join(root, ".opencode", "skills");
|
|
388
|
+
if (existsSync(skillsDir)) for (const skill of readdirSync(skillsDir)) {
|
|
389
|
+
const p = join(skillsDir, skill, "references", "mech-registry.json");
|
|
390
|
+
if (existsSync(p)) out.push(p);
|
|
391
|
+
}
|
|
392
|
+
const rootRegistry = join(root, "mech-registry.json");
|
|
393
|
+
if (existsSync(rootRegistry)) out.push(rootRegistry);
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
function loadMsmEntries(root) {
|
|
397
|
+
const byName = /* @__PURE__ */ new Map();
|
|
398
|
+
for (const regPath of findRegistries(root)) for (const entry of parseRegistry(readFileSync(regPath, "utf-8"))) if (!byName.has(entry.name)) byName.set(entry.name, entry);
|
|
399
|
+
return [...byName.values()];
|
|
400
|
+
}
|
|
401
|
+
function findEntry(root, name) {
|
|
402
|
+
return loadMsmEntries(root).find((e) => e.name === name) ?? null;
|
|
403
|
+
}
|
|
404
|
+
function registryPathFor(root, skill) {
|
|
405
|
+
return skill ? join(root, ".opencode", "skills", skill, "references", "mech-registry.json") : join(root, "mech-registry.json");
|
|
406
|
+
}
|
|
407
|
+
function writeRegistry(path, entries) {
|
|
408
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
409
|
+
writeFileSync(path, JSON.stringify({
|
|
410
|
+
version: 1,
|
|
411
|
+
description: "MSM registry (managed by acc-msm / dsh-serenity-hooks)",
|
|
412
|
+
entries
|
|
413
|
+
}, null, 2) + "\n", "utf-8");
|
|
414
|
+
}
|
|
415
|
+
function runMsm(root, args) {
|
|
416
|
+
switch (args.action) {
|
|
417
|
+
case "list": return loadMsmEntries(root).map((e) => ({
|
|
418
|
+
name: e.name,
|
|
419
|
+
skill: e.skill ?? null,
|
|
420
|
+
category: e.category ?? null,
|
|
421
|
+
description: e.description ?? ""
|
|
422
|
+
}));
|
|
423
|
+
case "guide": return { guide: MSM_GUIDE };
|
|
424
|
+
case "exec": {
|
|
425
|
+
const { entry, businessArgs, fmtJson, protocol } = prepareExec(root, args);
|
|
426
|
+
const p = protocolResult(protocol);
|
|
427
|
+
if (p !== void 0) return p;
|
|
428
|
+
let r = spawnSync("bun", [entry.path, ...businessArgs], {
|
|
429
|
+
cwd: root,
|
|
430
|
+
encoding: "utf-8",
|
|
431
|
+
timeout: MSM_TIMEOUT_MS,
|
|
432
|
+
stdio: [
|
|
433
|
+
"pipe",
|
|
434
|
+
"pipe",
|
|
435
|
+
"pipe"
|
|
436
|
+
]
|
|
437
|
+
});
|
|
438
|
+
if (r.error && r.error.code === "ENOENT") r = spawnSync("npx", [
|
|
439
|
+
"tsx",
|
|
440
|
+
entry.path,
|
|
441
|
+
...businessArgs
|
|
442
|
+
], {
|
|
443
|
+
cwd: root,
|
|
444
|
+
encoding: "utf-8",
|
|
445
|
+
timeout: MSM_TIMEOUT_MS,
|
|
446
|
+
stdio: [
|
|
447
|
+
"pipe",
|
|
448
|
+
"pipe",
|
|
449
|
+
"pipe"
|
|
450
|
+
]
|
|
451
|
+
});
|
|
452
|
+
return msmExecResult(entry.name, r.status ?? 2, r.stdout ?? "", r.stderr ?? "", fmtJson);
|
|
453
|
+
}
|
|
454
|
+
case "register": {
|
|
455
|
+
const name = args.name ?? "";
|
|
456
|
+
const { skill, path, category, description } = args;
|
|
457
|
+
if (!path || !category || !description) throw new Error("register 需要 path/category/description");
|
|
458
|
+
const regPath = registryPathFor(root, skill);
|
|
459
|
+
const entries = existsSync(regPath) ? parseRegistry(readFileSync(regPath, "utf-8")) : [];
|
|
460
|
+
if (entries.some((e) => e.name === name)) throw new Error(`MSM already registered: "${name}"`);
|
|
461
|
+
entries.push({
|
|
462
|
+
name,
|
|
463
|
+
path,
|
|
464
|
+
skill,
|
|
465
|
+
category,
|
|
466
|
+
description,
|
|
467
|
+
usage: `msm_exec ${name} [args...]`,
|
|
468
|
+
flags: []
|
|
469
|
+
});
|
|
470
|
+
writeRegistry(regPath, entries);
|
|
471
|
+
try {
|
|
472
|
+
execFileSync("git", ["add", "-A"], {
|
|
473
|
+
cwd: root,
|
|
474
|
+
stdio: "pipe"
|
|
475
|
+
});
|
|
476
|
+
execFileSync("git", [
|
|
477
|
+
"commit",
|
|
478
|
+
"-m",
|
|
479
|
+
`msm: register ${name}`
|
|
480
|
+
], {
|
|
481
|
+
cwd: root,
|
|
482
|
+
stdio: "pipe"
|
|
483
|
+
});
|
|
484
|
+
} catch {}
|
|
485
|
+
return {
|
|
486
|
+
registered: name,
|
|
487
|
+
registry: relative(root, regPath)
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
case "deregister": {
|
|
491
|
+
const name = args.name ?? "";
|
|
492
|
+
for (const regPath of findRegistries(root)) {
|
|
493
|
+
const entries = parseRegistry(readFileSync(regPath, "utf-8"));
|
|
494
|
+
const idx = entries.findIndex((e) => e.name === name);
|
|
495
|
+
if (idx >= 0) {
|
|
496
|
+
entries.splice(idx, 1);
|
|
497
|
+
writeRegistry(regPath, entries);
|
|
498
|
+
try {
|
|
499
|
+
execFileSync("git", ["add", "-A"], {
|
|
500
|
+
cwd: root,
|
|
501
|
+
stdio: "pipe"
|
|
502
|
+
});
|
|
503
|
+
execFileSync("git", [
|
|
504
|
+
"commit",
|
|
505
|
+
"-m",
|
|
506
|
+
`msm: deregister ${name}`
|
|
507
|
+
], {
|
|
508
|
+
cwd: root,
|
|
509
|
+
stdio: "pipe"
|
|
510
|
+
});
|
|
511
|
+
} catch {}
|
|
512
|
+
return { deregistered: name };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
throw new Error(`MSM not registered: "${name}"`);
|
|
516
|
+
}
|
|
517
|
+
case "check": {
|
|
518
|
+
const entries = loadMsmEntries(root);
|
|
519
|
+
const issues = [];
|
|
520
|
+
for (const e of entries) {
|
|
521
|
+
const script = join(root, e.path);
|
|
522
|
+
const scriptExists = existsSync(script);
|
|
523
|
+
if (!scriptExists) issues.push({
|
|
524
|
+
name: e.name,
|
|
525
|
+
check: "M3",
|
|
526
|
+
detail: `script missing (${e.path})`
|
|
527
|
+
});
|
|
528
|
+
const testFile = script.replace(/\.ts$/, ".test.ts");
|
|
529
|
+
if (!existsSync(testFile)) issues.push({
|
|
530
|
+
name: e.name,
|
|
531
|
+
check: "M1",
|
|
532
|
+
detail: "no .test.ts"
|
|
533
|
+
});
|
|
534
|
+
if (scriptExists && !readFileSync(script, "utf-8").includes("import.meta.url")) issues.push({
|
|
535
|
+
name: e.name,
|
|
536
|
+
check: "M2",
|
|
537
|
+
detail: "no main() guard"
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
checked: entries.length,
|
|
542
|
+
issues
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
default: throw new Error(`未知 action: ${args.action}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function prepareExec(root, args) {
|
|
549
|
+
const name = args.name ?? "";
|
|
550
|
+
const entry = findEntry(root, name);
|
|
551
|
+
if (!entry) throw new Error(`MSM not registered: "${name}"`);
|
|
552
|
+
const business = args.args ?? [];
|
|
553
|
+
if (business.includes("--list")) return {
|
|
554
|
+
entry,
|
|
555
|
+
businessArgs: [],
|
|
556
|
+
fmtJson: false,
|
|
557
|
+
protocol: { list: loadMsmEntries(root).map((e) => ({
|
|
558
|
+
name: e.name,
|
|
559
|
+
category: e.category ?? null
|
|
560
|
+
})) }
|
|
561
|
+
};
|
|
562
|
+
const schemaIdx = business.indexOf("--schema");
|
|
563
|
+
if (schemaIdx >= 0) {
|
|
564
|
+
const target = business[schemaIdx + 1];
|
|
565
|
+
const found = target ? loadMsmEntries(root).find((e) => e.name === target) : null;
|
|
566
|
+
if (!found) throw new Error(`MSM not registered: "${target}"`);
|
|
567
|
+
return {
|
|
568
|
+
entry,
|
|
569
|
+
businessArgs: [],
|
|
570
|
+
fmtJson: false,
|
|
571
|
+
protocol: { schema: {
|
|
572
|
+
name: found.name,
|
|
573
|
+
path: found.path,
|
|
574
|
+
flags: (found.flags ?? []).map((f) => ({
|
|
575
|
+
name: f.name,
|
|
576
|
+
type: f.type ?? null,
|
|
577
|
+
description: f.description ?? null
|
|
578
|
+
}))
|
|
579
|
+
} }
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const fmtJson = business.includes("--format=json");
|
|
583
|
+
const businessArgs = business.filter((a) => a !== "--format=json");
|
|
584
|
+
for (const flag of entry.flags ?? []) {
|
|
585
|
+
if (flag.type !== "path") continue;
|
|
586
|
+
const eq = businessArgs.find((a) => a.startsWith(`--${flag.name}=`));
|
|
587
|
+
if (eq) {
|
|
588
|
+
const value = eq.slice(flag.name.length + 3);
|
|
589
|
+
if (classifyPath(resolve(root, value), root) === "outside") throw new Error(`Path escape blocked: --${flag.name}=${value} 越出 CCC 根`);
|
|
590
|
+
} else {
|
|
591
|
+
const idx = businessArgs.indexOf(`--${flag.name}`);
|
|
592
|
+
if (idx >= 0 && businessArgs[idx + 1]) {
|
|
593
|
+
const value = businessArgs[idx + 1];
|
|
594
|
+
if (classifyPath(resolve(root, value), root) === "outside") throw new Error(`Path escape blocked: --${flag.name} ${value} 越出 CCC 根`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const script = resolve(root, entry.path);
|
|
599
|
+
if (classifyPath(script, root) === "outside") throw new Error(`MSM script escapes CCC root: "${entry.path}"`);
|
|
600
|
+
if (!existsSync(script)) throw new Error(`MSM script not found: "${entry.path}"`);
|
|
601
|
+
return {
|
|
602
|
+
entry: {
|
|
603
|
+
...entry,
|
|
604
|
+
path: script
|
|
605
|
+
},
|
|
606
|
+
businessArgs,
|
|
607
|
+
fmtJson
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/** 协议结果扁平化:{list|schema} 包装 → 顶层值(兼容旧契约);非协议返回 undefined */
|
|
611
|
+
function protocolResult(protocol) {
|
|
612
|
+
if (!protocol) return void 0;
|
|
613
|
+
if ("list" in protocol) return protocol.list;
|
|
614
|
+
if ("schema" in protocol) return protocol.schema;
|
|
615
|
+
}
|
|
616
|
+
function msmExecResult(name, status, stdout, stderr, fmtJson) {
|
|
617
|
+
if (fmtJson) return status === 0 ? {
|
|
618
|
+
name,
|
|
619
|
+
exit: 0,
|
|
620
|
+
ok: true,
|
|
621
|
+
data: stdout.trim()
|
|
622
|
+
} : {
|
|
623
|
+
name,
|
|
624
|
+
exit: status,
|
|
625
|
+
ok: false,
|
|
626
|
+
error: stderr.trim() || stdout.trim()
|
|
627
|
+
};
|
|
628
|
+
return {
|
|
629
|
+
name,
|
|
630
|
+
exit: status,
|
|
631
|
+
stdout,
|
|
632
|
+
stderr
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* 异步执行 MSM(acc_msm 工具主路径):
|
|
637
|
+
* 用 execFile + promisify + timeout(超时自动 kill),**不阻塞 Node 事件循环**。
|
|
638
|
+
* (同步 spawnSync 版会阻塞 web 事件循环 → MSM 脚本自请求 3080 时死锁,见 postmortem。)
|
|
639
|
+
*/
|
|
640
|
+
async function runMsmAsync(root, args) {
|
|
641
|
+
if (args.action !== "exec") return runMsm(root, args);
|
|
642
|
+
const { entry, businessArgs, fmtJson, protocol } = prepareExec(root, args);
|
|
643
|
+
const p = protocolResult(protocol);
|
|
644
|
+
if (p !== void 0) return p;
|
|
645
|
+
try {
|
|
646
|
+
const r = await execFileAsync("bun", [entry.path, ...businessArgs], {
|
|
647
|
+
cwd: root,
|
|
648
|
+
encoding: "utf-8",
|
|
649
|
+
timeout: MSM_TIMEOUT_MS,
|
|
650
|
+
maxBuffer: 67108864
|
|
651
|
+
});
|
|
652
|
+
return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson);
|
|
653
|
+
} catch (e) {
|
|
654
|
+
const err = e;
|
|
655
|
+
if (err.code === "ENOENT") try {
|
|
656
|
+
const r = await execFileAsync("npx", [
|
|
657
|
+
"tsx",
|
|
658
|
+
entry.path,
|
|
659
|
+
...businessArgs
|
|
660
|
+
], {
|
|
661
|
+
cwd: root,
|
|
662
|
+
encoding: "utf-8",
|
|
663
|
+
timeout: MSM_TIMEOUT_MS,
|
|
664
|
+
maxBuffer: 67108864
|
|
665
|
+
});
|
|
666
|
+
return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson);
|
|
667
|
+
} catch (e2) {
|
|
668
|
+
const err2 = e2;
|
|
669
|
+
const status = err2.killed ? 124 : typeof err2.code === "number" ? err2.code : 2;
|
|
670
|
+
const stdout = err2.stdout ?? "";
|
|
671
|
+
const stderr = err2.killed ? `MSM timed out after ${MSM_TIMEOUT_MS}ms` : err2.stderr ?? err2.message ?? "";
|
|
672
|
+
return msmExecResult(entry.name, status, stdout, stderr, fmtJson);
|
|
673
|
+
}
|
|
674
|
+
const status = err.killed ? 124 : typeof err.code === "number" ? err.code : 2;
|
|
675
|
+
const stdout = err.stdout ?? "";
|
|
676
|
+
const stderr = err.killed ? `MSM timed out after ${MSM_TIMEOUT_MS}ms` : err.stderr ?? err.message ?? "";
|
|
677
|
+
return msmExecResult(entry.name, status, stdout, stderr, fmtJson);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
//#endregion
|
|
681
|
+
//#region src/session-ops.ts
|
|
682
|
+
/**
|
|
683
|
+
* session-ops.ts — session 工具纯操作层(零 DSH 依赖,可独立单测)
|
|
684
|
+
*
|
|
685
|
+
* 移植自 dsh-serenity-plugin v0.1 acc-session runner(本项目自有代码)。
|
|
686
|
+
* 操作 CCC 根的 AGENT_SESSIONS/ 目录,返回规范 JSON 值。
|
|
687
|
+
*/
|
|
688
|
+
const SESSION_ACTIONS = [
|
|
689
|
+
"list",
|
|
690
|
+
"show",
|
|
691
|
+
"create",
|
|
692
|
+
"use",
|
|
693
|
+
"close",
|
|
694
|
+
"health",
|
|
695
|
+
"qa",
|
|
696
|
+
"archive",
|
|
697
|
+
"summary"
|
|
698
|
+
];
|
|
699
|
+
const SESSION_DIR_RE = /^(\d{4}-\d{2}-\d{2})--(S\d{3})--(.+)$/;
|
|
700
|
+
const DAY = 864e5;
|
|
701
|
+
function today() {
|
|
702
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
703
|
+
}
|
|
704
|
+
function sessionsRoot(root) {
|
|
705
|
+
return join(root, "AGENT_SESSIONS");
|
|
706
|
+
}
|
|
707
|
+
function listSessions(root) {
|
|
708
|
+
const sessRoot = sessionsRoot(root);
|
|
709
|
+
if (!existsSync(sessRoot)) return [];
|
|
710
|
+
const out = [];
|
|
711
|
+
for (const entry of readdirSync(sessRoot)) {
|
|
712
|
+
const full = join(sessRoot, entry);
|
|
713
|
+
if (!statSync(full).isDirectory()) continue;
|
|
714
|
+
const md = join(full, "SESSION.md");
|
|
715
|
+
const m = SESSION_DIR_RE.exec(entry);
|
|
716
|
+
let status = null;
|
|
717
|
+
if (existsSync(md)) status = /\[x\]|\[X\]/.test(readFileSync(md, "utf-8")) ? "done" : "open";
|
|
718
|
+
out.push({
|
|
719
|
+
dir: entry,
|
|
720
|
+
id: m?.[2] ?? null,
|
|
721
|
+
hasSessionMd: existsSync(md),
|
|
722
|
+
mtime: statSync(full).mtime.toISOString(),
|
|
723
|
+
status
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
out.sort((a, b) => a.dir < b.dir ? 1 : -1);
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
function nextSessionId(sessions) {
|
|
730
|
+
let max = 0;
|
|
731
|
+
for (const s of sessions) if (s.id) {
|
|
732
|
+
const n = Number(s.id.slice(1));
|
|
733
|
+
if (n > max) max = n;
|
|
734
|
+
}
|
|
735
|
+
return `S${String(max + 1).padStart(3, "0")}`;
|
|
736
|
+
}
|
|
737
|
+
function findSession(root, key) {
|
|
738
|
+
return listSessions(root).find((s) => s.dir.includes(key) || (s.id ?? "") === key.toUpperCase()) ?? null;
|
|
739
|
+
}
|
|
740
|
+
function createSession(root, name, title) {
|
|
741
|
+
const id = nextSessionId(listSessions(root));
|
|
742
|
+
const dirName = `${today()}--${id}--${name}`;
|
|
743
|
+
const dir = join(sessionsRoot(root), dirName);
|
|
744
|
+
mkdirSync(dir, { recursive: true });
|
|
745
|
+
const md = join(dir, "SESSION.md");
|
|
746
|
+
writeFileSync(md, `# SESSION: ${title}\n- ID: ${id}\n\n## 目标\n<一句话描述本次会话要完成的事情>\n\n## 状态\n- [ ] 进行中\n\n## 关键决策\n| # | 决策 | 理由 |\n|---|------|------|\n| 1 | | |\n\n## 进度记录\n- ${today()} — 会话创建\n\n## 产出物\n- \n\n## 未解决的问题\n- \n`, "utf-8");
|
|
747
|
+
return {
|
|
748
|
+
dir: dirName,
|
|
749
|
+
id,
|
|
750
|
+
sessionMd: md
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function showSession(root, key) {
|
|
754
|
+
const target = findSession(root, key);
|
|
755
|
+
if (!target) {
|
|
756
|
+
for (const s of listSessions(root)) {
|
|
757
|
+
const md = join(sessionsRoot(root), s.dir, "SESSION.md");
|
|
758
|
+
if (existsSync(md) && readFileSync(md, "utf-8").includes(key)) return {
|
|
759
|
+
dir: s.dir,
|
|
760
|
+
content: readFileSync(md, "utf-8")
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
throw new Error(`未找到会话: ${key}`);
|
|
764
|
+
}
|
|
765
|
+
const md = join(sessionsRoot(root), target.dir, "SESSION.md");
|
|
766
|
+
if (!existsSync(md)) throw new Error(`会话 ${target.dir} 缺少 SESSION.md`);
|
|
767
|
+
return {
|
|
768
|
+
dir: target.dir,
|
|
769
|
+
content: readFileSync(md, "utf-8")
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
/** 活动会话标记文件(sessionBlock 系统提示词注入读取) */
|
|
773
|
+
const ACTIVE_SESSION_MARKER = join(".dsh", "active-session");
|
|
774
|
+
/** 激活会话:写 .dsh/active-session 标记(内容 = 相对 CCC 根的 SESSION.md 路径) */
|
|
775
|
+
function useSession(root, key) {
|
|
776
|
+
const target = findSession(root, key);
|
|
777
|
+
if (!target) throw new Error(`未找到会话: ${key}`);
|
|
778
|
+
const md = join(sessionsRoot(root), target.dir, "SESSION.md");
|
|
779
|
+
if (!existsSync(md)) throw new Error(`会话 ${target.dir} 缺少 SESSION.md`);
|
|
780
|
+
const marker = resolve(root, ACTIVE_SESSION_MARKER);
|
|
781
|
+
mkdirSync(resolve(root, ".dsh"), { recursive: true });
|
|
782
|
+
const relMd = relative(root, md);
|
|
783
|
+
writeFileSync(marker, relMd, "utf-8");
|
|
784
|
+
return {
|
|
785
|
+
dir: target.dir,
|
|
786
|
+
mdPath: md
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/** 关闭活动会话:删除 .dsh/active-session 标记 */
|
|
790
|
+
function closeSession(root) {
|
|
791
|
+
const marker = resolve(root, ACTIVE_SESSION_MARKER);
|
|
792
|
+
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
793
|
+
return { dir: "active-session cleared" };
|
|
794
|
+
}
|
|
795
|
+
function archiveSession(root, key) {
|
|
796
|
+
const target = findSession(root, key);
|
|
797
|
+
if (!target) throw new Error(`未找到会话: ${key}`);
|
|
798
|
+
const md = join(sessionsRoot(root), target.dir, "SESSION.md");
|
|
799
|
+
if (!existsSync(md)) throw new Error(`会话 ${target.dir} 缺少 SESSION.md`);
|
|
800
|
+
let content = readFileSync(md, "utf-8");
|
|
801
|
+
content = content.replace(/^-\s*\[ \]\s*进行中$/m, "- [x] 已完成").replace(/^-\s*\[ \]\s*已关闭(未完成)$/m, "- [x] 已关闭(未完成)");
|
|
802
|
+
if (!/\[x\]|\[X\]/.test(content)) content = content.replace(/^## 状态$/m, "## 状态\n- [x] 已完成");
|
|
803
|
+
writeFileSync(md, content, "utf-8");
|
|
804
|
+
appendFileSync(md, `\n> 已归档: ${today()}\n`, "utf-8");
|
|
805
|
+
return { dir: target.dir };
|
|
806
|
+
}
|
|
807
|
+
function healthCheck(root) {
|
|
808
|
+
const problems = [];
|
|
809
|
+
const now = Date.now();
|
|
810
|
+
for (const s of listSessions(root)) {
|
|
811
|
+
const age = (now - new Date(s.mtime).getTime()) / DAY;
|
|
812
|
+
if (!s.hasSessionMd) problems.push({
|
|
813
|
+
dir: s.dir,
|
|
814
|
+
kind: "missing-md",
|
|
815
|
+
detail: "缺少 SESSION.md"
|
|
816
|
+
});
|
|
817
|
+
else if (age > 14) problems.push({
|
|
818
|
+
dir: s.dir,
|
|
819
|
+
kind: "stale",
|
|
820
|
+
detail: `${Math.round(age)} 天未更新`
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return problems;
|
|
824
|
+
}
|
|
825
|
+
function summarize(root) {
|
|
826
|
+
const sessions = listSessions(root);
|
|
827
|
+
const done = sessions.filter((s) => s.status === "done").length;
|
|
828
|
+
const stale = sessions.filter((s) => (Date.now() - new Date(s.mtime).getTime()) / DAY > 14).length;
|
|
829
|
+
return {
|
|
830
|
+
total: sessions.length,
|
|
831
|
+
open: sessions.length - done,
|
|
832
|
+
done,
|
|
833
|
+
stale,
|
|
834
|
+
recent: sessions.slice(0, 5)
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/** 事实核对:SESSION.md 中记录的产出物路径(- `path` — 说明 行)是否真实存在 */
|
|
838
|
+
function qaCheck(root, key) {
|
|
839
|
+
const { dir, content } = showSession(root, key);
|
|
840
|
+
const issues = [];
|
|
841
|
+
for (const line of content.split("\n")) {
|
|
842
|
+
const m = /^-\s*(`[^`]+`|[^\s|]+)\s*—/.exec(line.trim());
|
|
843
|
+
if (!m) continue;
|
|
844
|
+
const p = m[1].replace(/`/g, "");
|
|
845
|
+
if (!existsSync(resolve(root, p))) issues.push({
|
|
846
|
+
path: p,
|
|
847
|
+
kind: "missing"
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
dir,
|
|
852
|
+
issues
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/** 追加会话心跳(turn-stopping 机械落盘用) */
|
|
856
|
+
function appendHeartbeat(sessionMd) {
|
|
857
|
+
try {
|
|
858
|
+
appendFileSync(sessionMd, `- ${(/* @__PURE__ */ new Date()).toISOString()} — [auto] turn heartbeat (dsh-serenity-hooks)\n`, "utf-8");
|
|
859
|
+
return true;
|
|
860
|
+
} catch {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
//#endregion
|
|
865
|
+
//#region src/tools/session.ts
|
|
866
|
+
/**
|
|
867
|
+
* session.ts — session 真实 DSH 工具定义(defineTool)
|
|
868
|
+
*
|
|
869
|
+
* AGENT_SESSIONS/ 全周期管理:list/show/create/health/qa/archive/summary。
|
|
870
|
+
* 逻辑在 session-ops.ts(可单测)。
|
|
871
|
+
*/
|
|
872
|
+
function agentCwd$5(exec) {
|
|
873
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
874
|
+
}
|
|
875
|
+
function renderText$7(value) {
|
|
876
|
+
return [{
|
|
877
|
+
type: "text",
|
|
878
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
879
|
+
}];
|
|
880
|
+
}
|
|
881
|
+
const sessionTool = defineTool({
|
|
882
|
+
name: "session",
|
|
883
|
+
description: "工作会话全周期管理(AGENT_SESSIONS/,home-session 约定)。list/show/create/use/close/health/qa/archive/summary。多步骤工作必须先 create 会话,use 激活当前会话(写 .dsh/active-session 标记 → 系统提示词 Session 块生效)。",
|
|
884
|
+
parameters: {
|
|
885
|
+
action: {
|
|
886
|
+
type: "string",
|
|
887
|
+
enum: [...SESSION_ACTIONS],
|
|
888
|
+
required: true,
|
|
889
|
+
description: "子命令"
|
|
890
|
+
},
|
|
891
|
+
key: {
|
|
892
|
+
type: "string",
|
|
893
|
+
description: "show/use/archive/qa 的会话标识(S### 或目录名或关键词)"
|
|
894
|
+
},
|
|
895
|
+
name: {
|
|
896
|
+
type: "string",
|
|
897
|
+
description: "create 的短描述(小写英文连词符,≤5 词)"
|
|
898
|
+
},
|
|
899
|
+
title: {
|
|
900
|
+
type: "string",
|
|
901
|
+
description: "create 的标题"
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
output: {
|
|
905
|
+
schema: { type: "json" },
|
|
906
|
+
render: (args, value) => renderText$7(value)
|
|
907
|
+
},
|
|
908
|
+
async execute(args, exec) {
|
|
909
|
+
const root = findSerenityRoot(agentCwd$5(exec));
|
|
910
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
911
|
+
if (findEntry(root, "session-tool")) {
|
|
912
|
+
const r = runMsm(root, {
|
|
913
|
+
action: "exec",
|
|
914
|
+
name: "session-tool",
|
|
915
|
+
args: [args.action, ...args.key ? [args.key] : []]
|
|
916
|
+
});
|
|
917
|
+
if (!(r.exit !== void 0 && r.exit !== 0 || r.ok === false)) {
|
|
918
|
+
const out = r.ok !== void 0 ? r.ok ? r.data : r.error : r.stdout;
|
|
919
|
+
return out !== void 0 ? {
|
|
920
|
+
delegated: true,
|
|
921
|
+
exit: r.exit ?? 0,
|
|
922
|
+
output: out
|
|
923
|
+
} : {
|
|
924
|
+
delegated: true,
|
|
925
|
+
exit: r.exit ?? 0
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
switch (args.action) {
|
|
930
|
+
case "list": return listSessions(root);
|
|
931
|
+
case "create": return createSession(root, args.name ?? "untitled", args.title ?? args.name ?? "untitled");
|
|
932
|
+
case "show":
|
|
933
|
+
if (!args.key) throw new Error("show 需要 key");
|
|
934
|
+
return showSession(root, args.key);
|
|
935
|
+
case "use":
|
|
936
|
+
if (!args.key) throw new Error("use 需要 key");
|
|
937
|
+
return useSession(root, args.key);
|
|
938
|
+
case "close": return closeSession(root);
|
|
939
|
+
case "archive":
|
|
940
|
+
if (!args.key) throw new Error("archive 需要 key");
|
|
941
|
+
return archiveSession(root, args.key);
|
|
942
|
+
case "health": return { problems: healthCheck(root) };
|
|
943
|
+
case "summary": return summarize(root);
|
|
944
|
+
case "qa":
|
|
945
|
+
if (!args.key) throw new Error("qa 需要 key");
|
|
946
|
+
return qaCheck(root, args.key);
|
|
947
|
+
default: throw new Error(`未知 action: ${args.action}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
//#endregion
|
|
952
|
+
//#region src/kit-ops.ts
|
|
953
|
+
/**
|
|
954
|
+
* kit-ops.ts — acc_kit 纯操作层(零 DSH 依赖)
|
|
955
|
+
*
|
|
956
|
+
* health: CCC 三原则检查(P1 .serenity / P2 git / 配置)
|
|
957
|
+
* time: ISO 8601 时间戳
|
|
958
|
+
* wait: 同步等待 N 秒
|
|
959
|
+
*/
|
|
960
|
+
const KIT_ACTIONS = [
|
|
961
|
+
"health",
|
|
962
|
+
"time",
|
|
963
|
+
"wait"
|
|
964
|
+
];
|
|
965
|
+
function runKit(root, args) {
|
|
966
|
+
switch (args.action) {
|
|
967
|
+
case "health": {
|
|
968
|
+
const gitRoot = findGitRoot(root);
|
|
969
|
+
let config = null;
|
|
970
|
+
let configPath = null;
|
|
971
|
+
for (const candidate of DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
972
|
+
const p = resolve(root, candidate);
|
|
973
|
+
if (!existsSync(p)) continue;
|
|
974
|
+
try {
|
|
975
|
+
config = JSON.parse(readFileSync(p, "utf-8"));
|
|
976
|
+
configPath = p;
|
|
977
|
+
} catch {
|
|
978
|
+
config = { parseError: true };
|
|
979
|
+
configPath = p;
|
|
980
|
+
}
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
cwd: root,
|
|
985
|
+
serenityRoot: findSerenityRoot(root),
|
|
986
|
+
gitRoot,
|
|
987
|
+
config,
|
|
988
|
+
configPath,
|
|
989
|
+
p1: findSerenityRoot(root) !== null,
|
|
990
|
+
p2: gitRoot !== null,
|
|
991
|
+
p3: "enforced-by-dsh-fs-sandbox"
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
case "time": return (/* @__PURE__ */ new Date()).toISOString();
|
|
995
|
+
case "wait": {
|
|
996
|
+
const n = args.seconds ?? 0;
|
|
997
|
+
if (!Number.isFinite(n) || n < 0) throw new Error("wait 需要非负秒数");
|
|
998
|
+
execFileSync("sleep", [String(n)], { stdio: "ignore" });
|
|
999
|
+
return { waited: n };
|
|
1000
|
+
}
|
|
1001
|
+
default: throw new Error(`未知 action: ${args.action}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
//#endregion
|
|
1005
|
+
//#region src/tools/kit.ts
|
|
1006
|
+
/**
|
|
1007
|
+
* kit.ts — acc_kit 真实 DSH 工具定义(defineTool)
|
|
1008
|
+
*/
|
|
1009
|
+
function agentCwd$4(exec) {
|
|
1010
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
1011
|
+
}
|
|
1012
|
+
function renderText$6(value) {
|
|
1013
|
+
return [{
|
|
1014
|
+
type: "text",
|
|
1015
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1016
|
+
}];
|
|
1017
|
+
}
|
|
1018
|
+
const kitTool = defineTool({
|
|
1019
|
+
name: "acc_kit",
|
|
1020
|
+
description: "ACC 通用能力工具包:health(CCC 三原则检查 P1/P2/配置)/ time(ISO 时间戳)/ wait(等待 N 秒)。进入 CCC 工作前的例行自检。",
|
|
1021
|
+
parameters: {
|
|
1022
|
+
action: {
|
|
1023
|
+
type: "string",
|
|
1024
|
+
enum: [...KIT_ACTIONS],
|
|
1025
|
+
required: true,
|
|
1026
|
+
description: "子命令"
|
|
1027
|
+
},
|
|
1028
|
+
seconds: {
|
|
1029
|
+
type: "number",
|
|
1030
|
+
description: "wait 的秒数"
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
output: {
|
|
1034
|
+
schema: { type: "json" },
|
|
1035
|
+
render: (args, value) => renderText$6(value)
|
|
1036
|
+
},
|
|
1037
|
+
async execute(args, exec) {
|
|
1038
|
+
const root = findSerenityRoot(agentCwd$4(exec));
|
|
1039
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
1040
|
+
return runKit(root, args);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/git-ops.ts
|
|
1045
|
+
/**
|
|
1046
|
+
* git-ops.ts — cc_git 纯操作层(零 DSH 依赖)
|
|
1047
|
+
*
|
|
1048
|
+
* status / commit / push / log;push 非快进时输出操作建议(绝不自动 force)。
|
|
1049
|
+
*/
|
|
1050
|
+
const GIT_ACTIONS = [
|
|
1051
|
+
"status",
|
|
1052
|
+
"commit",
|
|
1053
|
+
"push",
|
|
1054
|
+
"log"
|
|
1055
|
+
];
|
|
1056
|
+
function git(root, args) {
|
|
1057
|
+
try {
|
|
1058
|
+
return {
|
|
1059
|
+
ok: true,
|
|
1060
|
+
stdout: execFileSync("git", args, {
|
|
1061
|
+
cwd: root,
|
|
1062
|
+
encoding: "utf-8",
|
|
1063
|
+
stdio: [
|
|
1064
|
+
"pipe",
|
|
1065
|
+
"pipe",
|
|
1066
|
+
"pipe"
|
|
1067
|
+
]
|
|
1068
|
+
}),
|
|
1069
|
+
stderr: ""
|
|
1070
|
+
};
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
return {
|
|
1073
|
+
ok: false,
|
|
1074
|
+
stdout: err.stdout?.toString() ?? "",
|
|
1075
|
+
stderr: err.stderr?.toString() ?? ""
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function runGit(root, args) {
|
|
1080
|
+
switch (args.action) {
|
|
1081
|
+
case "status": {
|
|
1082
|
+
const r = git(root, ["status", "--porcelain"]);
|
|
1083
|
+
if (!r.ok) throw new Error(`status 失败:${r.stderr.trim()}`);
|
|
1084
|
+
return {
|
|
1085
|
+
clean: r.stdout.trim() === "",
|
|
1086
|
+
entries: r.stdout.trim() ? r.stdout.trim().split("\n") : []
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
case "commit": {
|
|
1090
|
+
if (!args.message) throw new Error("commit 需要 message");
|
|
1091
|
+
const add = git(root, ["add", "-A"]);
|
|
1092
|
+
if (!add.ok) throw new Error(`git add 失败:${add.stderr.trim()}`);
|
|
1093
|
+
const commit = git(root, [
|
|
1094
|
+
"commit",
|
|
1095
|
+
"-m",
|
|
1096
|
+
args.message
|
|
1097
|
+
]);
|
|
1098
|
+
if (!commit.ok) {
|
|
1099
|
+
if (commit.stderr.includes("nothing to commit")) return {
|
|
1100
|
+
committed: false,
|
|
1101
|
+
reason: "nothing to commit"
|
|
1102
|
+
};
|
|
1103
|
+
throw new Error(`git commit 失败:${commit.stderr.trim()}`);
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
committed: true,
|
|
1107
|
+
message: args.message
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
case "push": {
|
|
1111
|
+
const r = git(root, [
|
|
1112
|
+
"push",
|
|
1113
|
+
"origin",
|
|
1114
|
+
"HEAD"
|
|
1115
|
+
]);
|
|
1116
|
+
if (r.ok) return { pushed: true };
|
|
1117
|
+
const isNonFF = /non-fast-forward|rejected|fetch first|被拒绝/i.test(r.stderr);
|
|
1118
|
+
const out = {
|
|
1119
|
+
pushed: false,
|
|
1120
|
+
nonFastForward: isNonFF,
|
|
1121
|
+
stderr: r.stderr.trim()
|
|
1122
|
+
};
|
|
1123
|
+
if (isNonFF) out.suggestion = "1. git pull --rebase # 先合并远程变更\n2. 重新 push\n3. 若确需覆盖远程:git push --force-with-lease(人工确认后)";
|
|
1124
|
+
return out;
|
|
1125
|
+
}
|
|
1126
|
+
case "log": {
|
|
1127
|
+
const n = args.count ?? 10;
|
|
1128
|
+
const r = git(root, [
|
|
1129
|
+
"log",
|
|
1130
|
+
"--oneline",
|
|
1131
|
+
"-n",
|
|
1132
|
+
String(n)
|
|
1133
|
+
]);
|
|
1134
|
+
if (!r.ok) throw new Error(`git log 失败:${r.stderr.trim()}`);
|
|
1135
|
+
return { entries: r.stdout.trim() ? r.stdout.trim().split("\n") : [] };
|
|
1136
|
+
}
|
|
1137
|
+
default: throw new Error(`未知 action: ${args.action}`);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
//#endregion
|
|
1141
|
+
//#region src/tools/git.ts
|
|
1142
|
+
/**
|
|
1143
|
+
* git.ts — cc_git 真实 DSH 工具定义(defineTool)
|
|
1144
|
+
*/
|
|
1145
|
+
function agentCwd$3(exec) {
|
|
1146
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
1147
|
+
}
|
|
1148
|
+
function renderText$5(value) {
|
|
1149
|
+
return [{
|
|
1150
|
+
type: "text",
|
|
1151
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1152
|
+
}];
|
|
1153
|
+
}
|
|
1154
|
+
const gitTool = defineTool({
|
|
1155
|
+
name: "cc_git",
|
|
1156
|
+
description: "CCC 内 git 操作(cc-git 语义)。status/commit/push/log;push 非快进时输出操作建议(绝不自动 force)。pull/merge/rebase/冲突解决走 bash。",
|
|
1157
|
+
parameters: {
|
|
1158
|
+
action: {
|
|
1159
|
+
type: "string",
|
|
1160
|
+
enum: [...GIT_ACTIONS],
|
|
1161
|
+
required: true,
|
|
1162
|
+
description: "子命令"
|
|
1163
|
+
},
|
|
1164
|
+
message: {
|
|
1165
|
+
type: "string",
|
|
1166
|
+
description: "commit 消息"
|
|
1167
|
+
},
|
|
1168
|
+
count: {
|
|
1169
|
+
type: "integer",
|
|
1170
|
+
description: "log 条数(默认 10)"
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
output: {
|
|
1174
|
+
schema: { type: "json" },
|
|
1175
|
+
render: (args, value) => renderText$5(value)
|
|
1176
|
+
},
|
|
1177
|
+
async execute(args, exec) {
|
|
1178
|
+
const root = findSerenityRoot(agentCwd$3(exec));
|
|
1179
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
1180
|
+
return runGit(root, args);
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
//#endregion
|
|
1184
|
+
//#region src/tools/msm.ts
|
|
1185
|
+
/**
|
|
1186
|
+
* msm.ts — acc_msm 真实 DSH 工具定义(defineTool)
|
|
1187
|
+
*/
|
|
1188
|
+
function agentCwd$2(exec) {
|
|
1189
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
1190
|
+
}
|
|
1191
|
+
function renderText$4(value) {
|
|
1192
|
+
return [{
|
|
1193
|
+
type: "text",
|
|
1194
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1195
|
+
}];
|
|
1196
|
+
}
|
|
1197
|
+
const msmTool = defineTool({
|
|
1198
|
+
name: "acc_msm",
|
|
1199
|
+
description: "MSM(Mech & Semi-Mech)框架:list 列出注册 MSM;exec 执行(600s 超时,path 逃逸阻断);register/deregister 管理注册表(自动 git commit);check 品质检查 DC-M1~M4。复用 CCC 的 mech-registry.json。",
|
|
1200
|
+
parameters: {
|
|
1201
|
+
action: {
|
|
1202
|
+
type: "string",
|
|
1203
|
+
enum: [...MSM_ACTIONS],
|
|
1204
|
+
required: true,
|
|
1205
|
+
description: "子命令"
|
|
1206
|
+
},
|
|
1207
|
+
name: {
|
|
1208
|
+
type: "string",
|
|
1209
|
+
description: "MSM 名(exec/register/deregister)"
|
|
1210
|
+
},
|
|
1211
|
+
args: {
|
|
1212
|
+
type: "array",
|
|
1213
|
+
items: { type: "string" },
|
|
1214
|
+
description: "exec 的业务参数"
|
|
1215
|
+
},
|
|
1216
|
+
skill: {
|
|
1217
|
+
type: "string",
|
|
1218
|
+
description: "register 的所属 skill"
|
|
1219
|
+
},
|
|
1220
|
+
path: {
|
|
1221
|
+
type: "string",
|
|
1222
|
+
description: "register 的脚本相对路径"
|
|
1223
|
+
},
|
|
1224
|
+
category: {
|
|
1225
|
+
type: "string",
|
|
1226
|
+
description: "register 的类别(mech/semi-mech)"
|
|
1227
|
+
},
|
|
1228
|
+
description: {
|
|
1229
|
+
type: "string",
|
|
1230
|
+
description: "register 的描述"
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
output: {
|
|
1234
|
+
schema: { type: "json" },
|
|
1235
|
+
render: (args, value) => renderText$4(value)
|
|
1236
|
+
},
|
|
1237
|
+
async execute(args, exec) {
|
|
1238
|
+
const root = findSerenityRoot(agentCwd$2(exec));
|
|
1239
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
1240
|
+
return runMsmAsync(root, args);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/tools/eap.ts
|
|
1245
|
+
/**
|
|
1246
|
+
* eap.ts — EAP 认知质量框架工具(渐进式披露,ACC 标准工具化)
|
|
1247
|
+
*
|
|
1248
|
+
* 内嵌框架内容(自包含,不依赖已安装技能);可选 section 参数聚焦某原则。
|
|
1249
|
+
*/
|
|
1250
|
+
const EAP_CONTENT = `# EAP 认知质量框架(显式抽象原则)
|
|
1251
|
+
|
|
1252
|
+
> "思维的功能价值与其外部可重建性成正比。"
|
|
1253
|
+
|
|
1254
|
+
## 三变量
|
|
1255
|
+
| 变量 | 含义 | 提升手段 |
|
|
1256
|
+
|------|------|---------|
|
|
1257
|
+
| E↑ 显式度 | 变量/实体/关系被明确定义的程度 | 定义变量、指明关系方向与基数、划定边界 |
|
|
1258
|
+
| R↓ 重建成本 | 未来重建原始推理的成本 | 记录决策理由、上下文、约束、备选方案 |
|
|
1259
|
+
| S↑ 稳定性 | 同一输入反复产生一致结果的程度 | 结构固化、协议化、避免依赖隐含上下文 |
|
|
1260
|
+
|
|
1261
|
+
## 输出前自检清单
|
|
1262
|
+
- [ ] 变量/实体明确定义(E↑)
|
|
1263
|
+
- [ ] 关系指明方向/基数(E↑)
|
|
1264
|
+
- [ ] 边界划定——什么在范围内/什么不在(E↑)
|
|
1265
|
+
- [ ] 不使用歧义词汇:"处理""优化""问题"→ 具体化(E↑)
|
|
1266
|
+
- [ ] 关键决策记录理由与备选(R↓)
|
|
1267
|
+
- [ ] 不跳级讨论——先对齐上层再进入下层(R↓)
|
|
1268
|
+
- [ ] 结构可重复执行/生成(S↑)
|
|
1269
|
+
|
|
1270
|
+
## 与 ACC 的关系
|
|
1271
|
+
ACC(插件/模板)结构编码为代码(E↑);从 ACC 生成 CCC 确定(R↓);多 CCC 一致(S↑)。
|
|
1272
|
+
CCC(home-serenity 等)认知内容编码为 skill/SESSION/设计文档。产出物用本清单自检。
|
|
1273
|
+
|
|
1274
|
+
## 参考
|
|
1275
|
+
https://github.com/tellmewhattodo/theory-eap`;
|
|
1276
|
+
function renderText$3(value) {
|
|
1277
|
+
return [{
|
|
1278
|
+
type: "text",
|
|
1279
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1280
|
+
}];
|
|
1281
|
+
}
|
|
1282
|
+
const eapTool = defineTool({
|
|
1283
|
+
name: "eap",
|
|
1284
|
+
description: "EAP 认知质量框架(渐进式披露):定义 E↑ 显式度 / R↓ 重建成本 / S↑ 稳定性 + 输出前自检清单。无 section 返回完整框架;指定 section 聚焦对应内容。",
|
|
1285
|
+
parameters: { section: {
|
|
1286
|
+
type: "string",
|
|
1287
|
+
enum: [
|
|
1288
|
+
"variables",
|
|
1289
|
+
"checklist",
|
|
1290
|
+
"acc"
|
|
1291
|
+
],
|
|
1292
|
+
description: "聚焦片段:variables(三变量)/ checklist(自检清单)/ acc(与 ACC 关系)"
|
|
1293
|
+
} },
|
|
1294
|
+
output: {
|
|
1295
|
+
schema: { type: "string" },
|
|
1296
|
+
render: (_args, value) => renderText$3(value)
|
|
1297
|
+
},
|
|
1298
|
+
async execute(args) {
|
|
1299
|
+
if (args.section === "variables") return EAP_CONTENT.split("## 输出前自检清单")[0];
|
|
1300
|
+
if (args.section === "checklist") return EAP_CONTENT.match(/## 输出前自检清单[\s\S]*?(?=## )/)?.[0] ?? EAP_CONTENT;
|
|
1301
|
+
if (args.section === "acc") return EAP_CONTENT.match(/## 与 ACC 的关系[\s\S]*/)?.[0] ?? EAP_CONTENT;
|
|
1302
|
+
return EAP_CONTENT;
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
//#endregion
|
|
1306
|
+
//#region src/tools/neat.ts
|
|
1307
|
+
/**
|
|
1308
|
+
* neat.ts — Neat 设计协作协议工具(渐进式披露,ACC 标准工具化)
|
|
1309
|
+
*/
|
|
1310
|
+
const NEAT_CONTENT = `# Neat 设计协作协议
|
|
1311
|
+
|
|
1312
|
+
> 复杂设计不是一次想出来的,是小步对齐走出来的。
|
|
1313
|
+
|
|
1314
|
+
## 四条铁律
|
|
1315
|
+
| 铁律 | 含义 | 反例 |
|
|
1316
|
+
|------|------|------|
|
|
1317
|
+
| 小步对齐 | 一次只推进一个决策,确认后再走下一步 | 一次抛 10 个方案 |
|
|
1318
|
+
| 显式决策 | 每个选择记录理由与备选 | "我觉得这样好"(无理由) |
|
|
1319
|
+
| 文档驱动 | 结论沉淀到具名文件(<subject>-<scope>-<type>.md) | 结论只存在于对话里 |
|
|
1320
|
+
| 不跳级 | 严格按层级推进 | 需求未对齐就写实现 |
|
|
1321
|
+
|
|
1322
|
+
## 五层推进顺序(不跳级)
|
|
1323
|
+
需求层 → 范围层 → 方案层 → 接口层 → 实现层
|
|
1324
|
+
| 层 | 产物 | 问题 |
|
|
1325
|
+
|----|------|------|
|
|
1326
|
+
| 需求层 | 需求描述 | "要解决什么问题?" |
|
|
1327
|
+
| 范围层 | 范围清单(in/out) | "做哪些、明确不做哪些?" |
|
|
1328
|
+
| 方案层 | 方案对比 + 选定 | "怎么做?选哪个?为什么?" |
|
|
1329
|
+
| 接口层 | 接口/协议定义 | "边界怎么交互?" |
|
|
1330
|
+
| 实现层 | 代码/文档 | "逐层落地" |
|
|
1331
|
+
|
|
1332
|
+
## 协作节奏
|
|
1333
|
+
1. 提出当前层一个决策点(带上下文 + 建议 + 理由)
|
|
1334
|
+
2. 等待确认或修正(小步)
|
|
1335
|
+
3. 确认后记录决策(写入会话 SESSION.md 或设计文档)
|
|
1336
|
+
4. 推进到下一决策点`;
|
|
1337
|
+
function renderText$2(value) {
|
|
1338
|
+
return [{
|
|
1339
|
+
type: "text",
|
|
1340
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1341
|
+
}];
|
|
1342
|
+
}
|
|
1343
|
+
const neatTool = defineTool({
|
|
1344
|
+
name: "neat",
|
|
1345
|
+
description: "Neat 设计协作协议(渐进式披露):小步对齐 / 显式决策 / 文档驱动 / 不跳级(需求→范围→方案→接口→实现)。无 section 返回完整协议。",
|
|
1346
|
+
parameters: { section: {
|
|
1347
|
+
type: "string",
|
|
1348
|
+
enum: ["rules", "layers"],
|
|
1349
|
+
description: "聚焦片段:rules(四条铁律)/ layers(五层推进)"
|
|
1350
|
+
} },
|
|
1351
|
+
output: {
|
|
1352
|
+
schema: { type: "string" },
|
|
1353
|
+
render: (_args, value) => renderText$2(value)
|
|
1354
|
+
},
|
|
1355
|
+
async execute(args) {
|
|
1356
|
+
if (args.section === "rules") return NEAT_CONTENT.match(/## 四条铁律[\s\S]*?(?=## )/)?.[0] ?? NEAT_CONTENT;
|
|
1357
|
+
if (args.section === "layers") return NEAT_CONTENT.match(/## 五层推进顺序[\s\S]*?(?=## )/)?.[0] ?? NEAT_CONTENT;
|
|
1358
|
+
return NEAT_CONTENT;
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
//#endregion
|
|
1362
|
+
//#region src/tools/cce.ts
|
|
1363
|
+
/**
|
|
1364
|
+
* cce.ts — CCE 认知连续性工程工具(渐进式披露,ACC 标准工具化)
|
|
1365
|
+
*
|
|
1366
|
+
* 内容:认知连续性工程(Cognitive Continuity Engineering)——在有限资源与
|
|
1367
|
+
* 不可逆不确定性约束下,维持认知实体身份、可达性与演化能力的工程学科。
|
|
1368
|
+
* 来源:home-serenity `.opencode/skills/cce/SKILL.md` + CCE 理论。
|
|
1369
|
+
*/
|
|
1370
|
+
const CCE_CONTENT = `# CCE — 认知连续性工程(Cognitive Continuity Engineering)
|
|
1371
|
+
|
|
1372
|
+
> **认知连续性工程是在有限资源与不可逆不确定性的约束下,维持一个认知实体的身份、可达性与演化能力的工程学科。**
|
|
1373
|
+
> CCE 不优化认知。它维护认知得以继续的条件。—— 存续工程(Persistence Engineering),非绩效工程
|
|
1374
|
+
|
|
1375
|
+
## 核心命题
|
|
1376
|
+
- **连续性属于容器,而非任何个体参与者**——智能体来来去去,但 CCC 的认知轨迹持续存在
|
|
1377
|
+
- **组织必须至少与积累同步**——否则操作化认知熵(H_op)无界增长,可达性丧失
|
|
1378
|
+
- **重建优于保存**——产物的价值由其使未来智能体重建原始推理的能力决定
|
|
1379
|
+
|
|
1380
|
+
## 认知容器(Cognitive Container)
|
|
1381
|
+
一个有界的认知空间,认知可在其中积累、重组和演化。5 个定义属性:
|
|
1382
|
+
| 属性 | 功能 |
|
|
1383
|
+
|------|------|
|
|
1384
|
+
| 身份(Identity) | 区分此认知系统与其他系统 |
|
|
1385
|
+
| 边界(Boundaries) | 定义什么在认知空间内/外 |
|
|
1386
|
+
| 持久记忆(Persistent Memory) | 跨时间保留积累的认知 |
|
|
1387
|
+
| 操作约束(Operational Constraints) | 定义容器内允许哪些操作 |
|
|
1388
|
+
| 演化历史(Evolutionary History) | 记录认知变化轨迹,使重建可能 |
|
|
1389
|
+
|
|
1390
|
+
## 操作化认知熵(H_op)
|
|
1391
|
+
不度量总体熵(不可操作),只度量智能体在容器内完成任务的**多余认知成本**:
|
|
1392
|
+
> H_op(C, t) = cost(task | C, t) − cost(task | ideal)
|
|
1393
|
+
维持条件:**H_op(C, t) ≤ H_critical** —— 智能体仍可在合理成本内完成任务
|
|
1394
|
+
|
|
1395
|
+
## 连续性维护条件
|
|
1396
|
+
> **ΔH_org ≥ ΔH_in** —— 组织必须至少与积累同步
|
|
1397
|
+
|
|
1398
|
+
## 六阶段生命周期
|
|
1399
|
+
Experience → Accumulation → Organization → Abstraction → Reconstruction → Evolution →(循环)
|
|
1400
|
+
| 阶段 | 工程关切 |
|
|
1401
|
+
|------|---------|
|
|
1402
|
+
| Experience | 输入是否携带足够结构 |
|
|
1403
|
+
| Accumulation | 信息是否无损存储 |
|
|
1404
|
+
| Organization | 熵管理 — ΔH_org 抵消 ΔH_in |
|
|
1405
|
+
| Abstraction | 抽象是否显式编码 |
|
|
1406
|
+
| Reconstruction | 推理结构能否从产物恢复 |
|
|
1407
|
+
| Evolution | 演化保持连贯还是引入漂移 |
|
|
1408
|
+
|
|
1409
|
+
## 与 EAP 的关系
|
|
1410
|
+
EAP 回答"一段知识应如何被结构化"(显式度 E↑ / 重建成本 R↓ / 稳定性 S↑);
|
|
1411
|
+
CCE 回答"有结构的知识应如何跨时间持续演化而不丧失连贯性"。两者互补:EAP 是静态质量,CCE 是动态存续。
|
|
1412
|
+
|
|
1413
|
+
## 与 Serenity 的关系
|
|
1414
|
+
Serenity 的会话系统、会话追踪、熵管理机制(SQC 品质循环)都是 CCE 的工程实现;
|
|
1415
|
+
CCC 系统提示词中嵌入的行为约束即来自 CCE。`;
|
|
1416
|
+
function renderText$1(value) {
|
|
1417
|
+
return [{
|
|
1418
|
+
type: "text",
|
|
1419
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1420
|
+
}];
|
|
1421
|
+
}
|
|
1422
|
+
const cceTool = defineTool({
|
|
1423
|
+
name: "cce",
|
|
1424
|
+
description: "CCE 认知连续性工程(渐进式披露):在有限资源与不可逆不确定性约束下,维持认知实体身份/可达性/演化能力的工程学科。无 section 返回完整框架;指定 section 聚焦对应内容。",
|
|
1425
|
+
parameters: { section: {
|
|
1426
|
+
type: "string",
|
|
1427
|
+
enum: [
|
|
1428
|
+
"container",
|
|
1429
|
+
"entropy",
|
|
1430
|
+
"lifecycle",
|
|
1431
|
+
"eap"
|
|
1432
|
+
],
|
|
1433
|
+
description: "聚焦片段:container(认知容器 5 属性)/ entropy(操作化认知熵 H_op)/ lifecycle(六阶段)/ eap(与 EAP 关系)"
|
|
1434
|
+
} },
|
|
1435
|
+
output: {
|
|
1436
|
+
schema: { type: "string" },
|
|
1437
|
+
render: (_args, value) => renderText$1(value)
|
|
1438
|
+
},
|
|
1439
|
+
async execute(args) {
|
|
1440
|
+
const section = args.section;
|
|
1441
|
+
const blocks = {
|
|
1442
|
+
container: {
|
|
1443
|
+
start: "## 认知容器(Cognitive Container)",
|
|
1444
|
+
end: "## 操作化认知熵"
|
|
1445
|
+
},
|
|
1446
|
+
entropy: {
|
|
1447
|
+
start: "## 操作化认知熵(H_op)",
|
|
1448
|
+
end: "## 连续性维护条件"
|
|
1449
|
+
},
|
|
1450
|
+
lifecycle: { start: "## 六阶段生命周期" },
|
|
1451
|
+
eap: { start: "## 与 EAP 的关系" }
|
|
1452
|
+
};
|
|
1453
|
+
if (section) {
|
|
1454
|
+
const b = blocks[section];
|
|
1455
|
+
if (b) {
|
|
1456
|
+
const startIdx = CCE_CONTENT.indexOf(b.start);
|
|
1457
|
+
if (startIdx >= 0) return (b.end ? CCE_CONTENT.slice(startIdx, CCE_CONTENT.indexOf(b.end, startIdx)) : CCE_CONTENT.slice(startIdx)).trim();
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return CCE_CONTENT;
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
//#endregion
|
|
1464
|
+
//#region src/loop-ops.ts
|
|
1465
|
+
/**
|
|
1466
|
+
* loop-ops.ts — acc_loop 纯逻辑层(零 DSH 依赖,可独立单测)
|
|
1467
|
+
*
|
|
1468
|
+
* 对齐 opencode-serenity-plugin 老 loop 语义:进度文件(loop-<label>.md/.json)、
|
|
1469
|
+
* 续跑、轮次 prompt 结构、stop token。
|
|
1470
|
+
*/
|
|
1471
|
+
function loopProgressPaths(root, label) {
|
|
1472
|
+
const dir = join(root, "AGENT_SESSIONS");
|
|
1473
|
+
return {
|
|
1474
|
+
md: join(dir, `loop-${label}.md`),
|
|
1475
|
+
json: join(dir, `loop-${label}.json`)
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
/** 读取进度(续跑);无文件返回 round 0 */
|
|
1479
|
+
function readProgress(root, label) {
|
|
1480
|
+
const { json } = loopProgressPaths(root, label);
|
|
1481
|
+
if (!existsSync(json)) return null;
|
|
1482
|
+
try {
|
|
1483
|
+
return JSON.parse(readFileSync(json, "utf-8"));
|
|
1484
|
+
} catch {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function writeProgress(root, label, p) {
|
|
1489
|
+
const { md, json } = loopProgressPaths(root, label);
|
|
1490
|
+
mkdirSync(join(root, "AGENT_SESSIONS"), { recursive: true });
|
|
1491
|
+
writeFileSync(json, JSON.stringify({
|
|
1492
|
+
...p,
|
|
1493
|
+
updated: (/* @__PURE__ */ new Date()).toISOString()
|
|
1494
|
+
}, null, 2) + "\n", "utf-8");
|
|
1495
|
+
const lines = [
|
|
1496
|
+
`# loop: ${label}`,
|
|
1497
|
+
`- 模型: ${p.model}`,
|
|
1498
|
+
`- 轮次: ${p.round}`,
|
|
1499
|
+
`- 完成: ${p.done}`,
|
|
1500
|
+
"",
|
|
1501
|
+
`## 最近响应`,
|
|
1502
|
+
"",
|
|
1503
|
+
p.lastResponse,
|
|
1504
|
+
""
|
|
1505
|
+
];
|
|
1506
|
+
writeFileSync(md, lines.join("\n"), "utf-8");
|
|
1507
|
+
}
|
|
1508
|
+
function newStopToken() {
|
|
1509
|
+
return `SERENITY_LOOP_DONE_${randomBytes(8).toString("hex")}`;
|
|
1510
|
+
}
|
|
1511
|
+
/** 解析 model 字符串(provider/model)→ {provider, model};无 / 视为 model-only */
|
|
1512
|
+
function splitModel(model) {
|
|
1513
|
+
const idx = model.indexOf("/");
|
|
1514
|
+
if (idx < 0) return {
|
|
1515
|
+
provider: void 0,
|
|
1516
|
+
model
|
|
1517
|
+
};
|
|
1518
|
+
return {
|
|
1519
|
+
provider: model.slice(0, idx),
|
|
1520
|
+
model: model.slice(idx + 1)
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
/** 轮次 prompt(对齐老 loop 结构:回顾进度 → 自由工作 → 汇报) */
|
|
1524
|
+
function buildRoundPrompt(opts) {
|
|
1525
|
+
const { root, session, label, round, maxRounds, stopToken, progress, task } = opts;
|
|
1526
|
+
const resumeNote = progress && progress.round > 0 ? `上一轮(round ${progress.round})已完成:${progress.lastResponse.slice(0, 300)}\n永远从上次停止处继续,绝不重做已完成工作。` : "这是第一轮。";
|
|
1527
|
+
return `# ${label} — 牛马循环 round ${round}/${maxRounds}
|
|
1528
|
+
|
|
1529
|
+
CCC 根:${root}
|
|
1530
|
+
${session ? `工作会话:${session}(AGENT_SESSIONS/${session}/SESSION.md 记录进度)` : ""}
|
|
1531
|
+
${task ? `任务:${task}` : `任务:以 label「${label}」对应的工作为准(若存在工作会话,先读 SESSION.md 明确目标)`}
|
|
1532
|
+
${resumeNote}
|
|
1533
|
+
|
|
1534
|
+
本轮内你可以自由工作:读文件、改代码、执行命令,尽一切手段推进任务。
|
|
1535
|
+
每轮结束时汇报:
|
|
1536
|
+
1. 本轮做了什么(具体)
|
|
1537
|
+
2. 下一步计划
|
|
1538
|
+
3. 是否已完成(若完成,输出 ${stopToken})
|
|
1539
|
+
|
|
1540
|
+
若已完成任务,只输出 ${stopToken}。`;
|
|
1541
|
+
}
|
|
1542
|
+
//#endregion
|
|
1543
|
+
//#region src/tools/loop.ts
|
|
1544
|
+
/**
|
|
1545
|
+
* loop.ts — acc_loop 真实 DSH 工具(老 loop 等效:廉价模型牛马循环)
|
|
1546
|
+
*
|
|
1547
|
+
* 语义对齐 opencode-serenity-plugin 老 loop:
|
|
1548
|
+
* label(必)任务标签 → 进度文件 loop-<label>.md/.json
|
|
1549
|
+
* session(选)工作会话 S###(上下文提示)
|
|
1550
|
+
* model(选)provider/model(如 minimax-cn-coding-plan/MiniMax-M3);缺省读 loop.defaultModel
|
|
1551
|
+
* maxRounds(默认 100)轮次上限;每轮等待 agent **无超时**(loop 可永续,agent 工作多久等多久)
|
|
1552
|
+
*
|
|
1553
|
+
* 机制:ctx.agentLoop.create({provider, model}) 创建专用 agent(进程内),
|
|
1554
|
+
* 每轮 followup → agent/status idle → 读 session.events 响应 → 写进度 → stop token 检查 → 续跑。
|
|
1555
|
+
* 工厂模式:apply 时闭包捕获插件 ctx(工具 execute 无 ctx 参数)。
|
|
1556
|
+
*/
|
|
1557
|
+
function agentCwd$1(exec) {
|
|
1558
|
+
return (exec.agent?.session)?.header?.cwd ?? process.cwd();
|
|
1559
|
+
}
|
|
1560
|
+
function renderText(value) {
|
|
1561
|
+
return [{
|
|
1562
|
+
type: "text",
|
|
1563
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1564
|
+
}];
|
|
1565
|
+
}
|
|
1566
|
+
/** 等待 agent 空闲(agent/status → idle);无超时(loop 可永续,agent 工作多久等多久) */
|
|
1567
|
+
function waitIdle(ctx, agent) {
|
|
1568
|
+
return new Promise((resolve) => {
|
|
1569
|
+
let settled = false;
|
|
1570
|
+
const finish = () => {
|
|
1571
|
+
if (settled) return;
|
|
1572
|
+
settled = true;
|
|
1573
|
+
dispose();
|
|
1574
|
+
resolve();
|
|
1575
|
+
};
|
|
1576
|
+
const dispose = ctx.on("agent/status", (payload) => {
|
|
1577
|
+
if (payload.agent === agent && payload.status === "idle") finish();
|
|
1578
|
+
});
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
/** 读取会话最后一个 assistant/message 文本 */
|
|
1582
|
+
function lastAssistantText(agent) {
|
|
1583
|
+
const events = agent.session.events;
|
|
1584
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1585
|
+
const e = events[i];
|
|
1586
|
+
if (e && e.type === "assistant/message") {
|
|
1587
|
+
const data = e.data;
|
|
1588
|
+
const text = (data?.message?.content ?? data?.content ?? []).filter((b) => b.type === "text" && b.text).map((b) => b.text).join("\n");
|
|
1589
|
+
if (text) return text;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return "";
|
|
1593
|
+
}
|
|
1594
|
+
/** 创建 loop 工具(闭包捕获插件 ctx → 可访问 ctx.agentLoop) */
|
|
1595
|
+
function createLoopTool(ctx) {
|
|
1596
|
+
return defineTool({
|
|
1597
|
+
name: "loop",
|
|
1598
|
+
description: "牛马循环(老 loop 等效):用指定模型创建专用 agent 反复执行任务直到完成或达轮次上限。\n用法:loop 接受 task(要完成的目标)或依赖 session 上下文;模型缺省读 .dsh/serenity.json 的 loop.defaultModel(当前 minimax-cn-coding-plan/MiniMax-M3,廉价牛马)。\n行为:每轮创建全新 agent 工作(读文件/改代码/执行命令),汇报进度后进入下一轮;完成时输出停止标记即终止。**每轮等待无超时**(loop 永续:agent 工作多久等多久,不被超时打断)。\n进度:写入 AGENT_SESSIONS/loop-<label>.md/.json;同 label 再次调用从上次轮次续跑(不重做)。\n约束:loop agent 受完整 Serenity 约束(ACC 身份/入口技能系统提示词/守卫/session-keeper)。\n示例:loop 执行「扫描 SQC 并修复 DC 问题」,label: sqc-scan,maxRounds: 5",
|
|
1599
|
+
parameters: {
|
|
1600
|
+
task: {
|
|
1601
|
+
type: "string",
|
|
1602
|
+
description: "要完成的任务目标(必填语义:告诉 loop agent 做什么;缺省则从 session 上下文推断)"
|
|
1603
|
+
},
|
|
1604
|
+
label: {
|
|
1605
|
+
type: "string",
|
|
1606
|
+
required: true,
|
|
1607
|
+
description: "任务标签(进度文件命名 loop-<label>.md/.json)"
|
|
1608
|
+
},
|
|
1609
|
+
session: {
|
|
1610
|
+
type: "string",
|
|
1611
|
+
description: "工作会话 S###(上下文提示,进度记录参考)"
|
|
1612
|
+
},
|
|
1613
|
+
model: {
|
|
1614
|
+
type: "string",
|
|
1615
|
+
description: "provider/model(如 minimax-cn-coding-plan/MiniMax-M3);缺省读 loop.defaultModel"
|
|
1616
|
+
},
|
|
1617
|
+
maxRounds: {
|
|
1618
|
+
type: "integer",
|
|
1619
|
+
description: "轮次上限(默认 100)"
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
output: {
|
|
1623
|
+
schema: { type: "json" },
|
|
1624
|
+
render: (_args, value) => renderText(value)
|
|
1625
|
+
},
|
|
1626
|
+
async execute(args, exec) {
|
|
1627
|
+
const root = findSerenityRoot(agentCwd$1(exec));
|
|
1628
|
+
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
1629
|
+
const cfg = loadSerenityConfig(root, DEFAULT_SERENITY_CONFIG_PATHS);
|
|
1630
|
+
const model = args.model ?? cfg.loop?.defaultModel;
|
|
1631
|
+
if (!model) throw new Error("loop 需要 model:传参或配置 .dsh/serenity.json loop.defaultModel");
|
|
1632
|
+
const maxRounds = args.maxRounds ?? 100;
|
|
1633
|
+
const label = args.label;
|
|
1634
|
+
if (!ctx.agentLoop) throw new Error("loop: ctx.agentLoop 不可用");
|
|
1635
|
+
const { provider, model: modelName } = splitModel(model);
|
|
1636
|
+
const stopToken = newStopToken();
|
|
1637
|
+
let progress = readProgress(root, label);
|
|
1638
|
+
const startRound = progress ? Math.min(progress.round + 1, maxRounds) : 1;
|
|
1639
|
+
const loopAgent = ctx.agentLoop.create(`loop-${label}`, {
|
|
1640
|
+
provider,
|
|
1641
|
+
model: modelName
|
|
1642
|
+
}, { cwd: root });
|
|
1643
|
+
let done = false;
|
|
1644
|
+
let lastResponse = progress?.lastResponse ?? "";
|
|
1645
|
+
let finalRound = startRound - 1;
|
|
1646
|
+
try {
|
|
1647
|
+
for (let round = startRound; round <= maxRounds; round++) {
|
|
1648
|
+
finalRound = round;
|
|
1649
|
+
const prompt = buildRoundPrompt({
|
|
1650
|
+
root,
|
|
1651
|
+
session: args.session,
|
|
1652
|
+
label,
|
|
1653
|
+
round,
|
|
1654
|
+
maxRounds,
|
|
1655
|
+
stopToken,
|
|
1656
|
+
progress,
|
|
1657
|
+
task: args.task
|
|
1658
|
+
});
|
|
1659
|
+
loopAgent.followup(createUserMessage({
|
|
1660
|
+
content: [{
|
|
1661
|
+
type: "text",
|
|
1662
|
+
text: prompt
|
|
1663
|
+
}],
|
|
1664
|
+
source: {
|
|
1665
|
+
kind: "plugin",
|
|
1666
|
+
plugin: "dsh-serenity-hooks"
|
|
1667
|
+
}
|
|
1668
|
+
}));
|
|
1669
|
+
await waitIdle(ctx, loopAgent);
|
|
1670
|
+
lastResponse = lastAssistantText(loopAgent);
|
|
1671
|
+
progress = {
|
|
1672
|
+
round,
|
|
1673
|
+
done: false,
|
|
1674
|
+
label,
|
|
1675
|
+
model,
|
|
1676
|
+
updated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1677
|
+
lastResponse
|
|
1678
|
+
};
|
|
1679
|
+
writeProgress(root, label, progress);
|
|
1680
|
+
if (lastResponse.includes(stopToken)) {
|
|
1681
|
+
done = true;
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
writeProgress(root, label, {
|
|
1686
|
+
round: finalRound,
|
|
1687
|
+
done,
|
|
1688
|
+
label,
|
|
1689
|
+
model,
|
|
1690
|
+
updated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1691
|
+
lastResponse
|
|
1692
|
+
});
|
|
1693
|
+
} finally {}
|
|
1694
|
+
const { json } = loopProgressPaths(root, label);
|
|
1695
|
+
return {
|
|
1696
|
+
done,
|
|
1697
|
+
rounds: finalRound,
|
|
1698
|
+
model,
|
|
1699
|
+
label,
|
|
1700
|
+
progressFile: json,
|
|
1701
|
+
lastResponse: lastResponse.slice(0, 2e3),
|
|
1702
|
+
usage: {
|
|
1703
|
+
how: "loop 用指定模型(默认 M3)创建专用 agent 每轮独立执行任务,完成时输出停止标记即终止",
|
|
1704
|
+
progress: `进度在 AGENT_SESSIONS/loop-${label}.md 与 .json;同 label 再调 loop 会从下一轮续跑(不重做)`,
|
|
1705
|
+
constraints: "loop agent 受完整 Serenity 约束(ACC 身份/入口技能系统提示词/守卫)",
|
|
1706
|
+
next: done ? "任务已完成;可查看进度文件收尾" : `任务未完成(${finalRound}/${maxRounds} 轮);可同 label 续跑或调整 maxRounds`
|
|
1707
|
+
}
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1713
|
+
//#region src/seams/guards.ts
|
|
1714
|
+
/**
|
|
1715
|
+
* guards.ts — 拦截缝:安全模式 + 路径守卫(P3 语义的机械层)
|
|
1716
|
+
*
|
|
1717
|
+
* 纯决策逻辑(decideGuard)与 DSH 注册(registerGuards)分离:
|
|
1718
|
+
* 前者零 DSH 依赖可单测,后者把决策接到 tools/pre-execute 瀑布 + ctx.tools.guard 终局。
|
|
1719
|
+
*
|
|
1720
|
+
* 对应 opencode-serenity-plugin 的 tool.execute.before 路径守卫 + bash 开关 + 黑名单。
|
|
1721
|
+
*/
|
|
1722
|
+
/**
|
|
1723
|
+
* 安全模式 + 黑名单 + P3 路径守卫的纯决策。
|
|
1724
|
+
* 对齐 opencode-serenity-plugin 标准:安全模式 = **bash 禁用** + 写入黑名单;
|
|
1725
|
+
* write/edit 等工具仅受路径逃逸与黑名单约束(不整体禁用)。
|
|
1726
|
+
* 优先级:safe-mode bash > 路径越界 > 黑名单命中。
|
|
1727
|
+
*/
|
|
1728
|
+
function decideGuard(input) {
|
|
1729
|
+
const { root, toolName, safeModeOn, blacklist, pathArg } = input;
|
|
1730
|
+
if (safeModeOn && toolName === "bash") return {
|
|
1731
|
+
deny: `bash: 没有这个工具`,
|
|
1732
|
+
kind: "deny"
|
|
1733
|
+
};
|
|
1734
|
+
if (pathArg !== void 0) {
|
|
1735
|
+
const rel = relative(root, resolve(root, pathArg));
|
|
1736
|
+
if (rel.startsWith("..")) return {
|
|
1737
|
+
deny: `path escape blocked: "${pathArg}" 越出 CCC 根`,
|
|
1738
|
+
kind: "deny"
|
|
1739
|
+
};
|
|
1740
|
+
if (rel === ".serenity-safe-on" || rel === ".serenity" || rel.startsWith(".serenity-safe-on/") || rel.startsWith(".serenity/")) return {
|
|
1741
|
+
deny: `CCC 治理文件 "${rel}" 保留给用户,agent 不可写`,
|
|
1742
|
+
kind: "deny"
|
|
1743
|
+
};
|
|
1744
|
+
const hit = matchBlacklist(rel, blacklist);
|
|
1745
|
+
if (hit) return {
|
|
1746
|
+
deny: `blacklist blocked: "${pathArg}" 命中规则 "${hit}"`,
|
|
1747
|
+
kind: "deny"
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
return { kind: "allow" };
|
|
1751
|
+
}
|
|
1752
|
+
/** 从 exec 提取 agent 会话 cwd(CCC 根检测基准);无则回退进程 cwd */
|
|
1753
|
+
function resolveAgentCwd(exec) {
|
|
1754
|
+
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
1755
|
+
}
|
|
1756
|
+
/** 安全模式开启时从模型工具列表隐藏的工具(只隐藏 bash;write/edit 保留) */
|
|
1757
|
+
const SAFE_MODE_DENY_TOOLS = ["bash"];
|
|
1758
|
+
/** agent key → restrict disposer(安全模式工具隐藏状态) */
|
|
1759
|
+
const safeModeRestrictions = /* @__PURE__ */ new Map();
|
|
1760
|
+
const restrictDiag = {
|
|
1761
|
+
lastKey: null,
|
|
1762
|
+
lastAttemptAt: null,
|
|
1763
|
+
lastSuccess: null,
|
|
1764
|
+
lastError: null,
|
|
1765
|
+
activeKeys: []
|
|
1766
|
+
};
|
|
1767
|
+
function getRestrictDiagnostics() {
|
|
1768
|
+
return {
|
|
1769
|
+
...restrictDiag,
|
|
1770
|
+
activeKeys: [...safeModeRestrictions.keys()]
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
/** 诊断落盘:AGENT_SESSIONS/.restrict-diag.json(文件通道,避免 HTTP 自锁) */
|
|
1774
|
+
function writeRestrictDiag(root) {
|
|
1775
|
+
try {
|
|
1776
|
+
const dir = resolve(root, "AGENT_SESSIONS");
|
|
1777
|
+
mkdirSync(dir, { recursive: true });
|
|
1778
|
+
writeFileSync(resolve(dir, ".restrict-diag.json"), JSON.stringify({
|
|
1779
|
+
...getRestrictDiagnostics(),
|
|
1780
|
+
cccRoot: root
|
|
1781
|
+
}, null, 2) + "\n", "utf-8");
|
|
1782
|
+
} catch {}
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* 同步安全模式工具隐藏:标记存在 → agent.ctx.tools.restrict deny 隐藏写工具;
|
|
1786
|
+
* 标记消失 → 解除。pre-step 每步调用 → 切换实时生效。
|
|
1787
|
+
*/
|
|
1788
|
+
function syncSafeModeRestriction(agent, root) {
|
|
1789
|
+
const key = agent.session.id ?? "global";
|
|
1790
|
+
const on = isSafeModeOn(root);
|
|
1791
|
+
const existing = safeModeRestrictions.get(key);
|
|
1792
|
+
if (on && !existing) try {
|
|
1793
|
+
const dispose = agent.ctx.tools.restrict({ deny: [...SAFE_MODE_DENY_TOOLS] });
|
|
1794
|
+
safeModeRestrictions.set(key, dispose);
|
|
1795
|
+
restrictDiag.lastKey = key;
|
|
1796
|
+
restrictDiag.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1797
|
+
restrictDiag.lastSuccess = true;
|
|
1798
|
+
restrictDiag.lastError = null;
|
|
1799
|
+
} catch (e) {
|
|
1800
|
+
restrictDiag.lastKey = key;
|
|
1801
|
+
restrictDiag.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1802
|
+
restrictDiag.lastSuccess = false;
|
|
1803
|
+
restrictDiag.lastError = e.message;
|
|
1804
|
+
console.error(`[serenity-hooks] restrict 失败 (key=${key}):`, e.message);
|
|
1805
|
+
}
|
|
1806
|
+
else if (!on && existing) {
|
|
1807
|
+
try {
|
|
1808
|
+
existing();
|
|
1809
|
+
} catch (e) {
|
|
1810
|
+
console.error(`[serenity-hooks] restrict 解除失败 (key=${key}):`, e.message);
|
|
1811
|
+
}
|
|
1812
|
+
safeModeRestrictions.delete(key);
|
|
1813
|
+
}
|
|
1814
|
+
writeRestrictDiag(root);
|
|
1815
|
+
}
|
|
1816
|
+
/** 从 exec 参数中提取常见路径字段(write/edit 工具);宽松读取 */
|
|
1817
|
+
function extractPathArg(exec) {
|
|
1818
|
+
const args = exec.arguments;
|
|
1819
|
+
if (args === null || typeof args !== "object") return void 0;
|
|
1820
|
+
const a = args;
|
|
1821
|
+
for (const key of [
|
|
1822
|
+
"path",
|
|
1823
|
+
"file_path",
|
|
1824
|
+
"target",
|
|
1825
|
+
"dst"
|
|
1826
|
+
]) {
|
|
1827
|
+
const v = a[key];
|
|
1828
|
+
if (typeof v === "string") return v;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* 注册守卫:tools/pre-execute 瀑布 + ctx.tools.guard 终局。
|
|
1833
|
+
* 两者都从 CCC 根实时读取配置(无状态、无缓存)。
|
|
1834
|
+
*/
|
|
1835
|
+
function registerGuards(ctx, opts = {}) {
|
|
1836
|
+
const configPaths = opts.configPaths;
|
|
1837
|
+
const evaluate = (exec) => {
|
|
1838
|
+
const root = findSerenityRoot(resolveAgentCwd(exec));
|
|
1839
|
+
if (!root) return { kind: "allow" };
|
|
1840
|
+
const safeModeOn = isSafeModeOn(root);
|
|
1841
|
+
const blacklist = readBlacklist(root, configPaths);
|
|
1842
|
+
const pathArg = extractPathArg(exec);
|
|
1843
|
+
return decideGuard({
|
|
1844
|
+
root,
|
|
1845
|
+
toolName: exec.name,
|
|
1846
|
+
safeModeOn,
|
|
1847
|
+
blacklist,
|
|
1848
|
+
pathArg
|
|
1849
|
+
});
|
|
1850
|
+
};
|
|
1851
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
1852
|
+
const d = evaluate(exec);
|
|
1853
|
+
if (d.deny) return {
|
|
1854
|
+
kind: "deny",
|
|
1855
|
+
reason: d.deny
|
|
1856
|
+
};
|
|
1857
|
+
return next();
|
|
1858
|
+
});
|
|
1859
|
+
ctx.tools.guard((exec) => {
|
|
1860
|
+
return evaluate(exec).deny;
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
//#endregion
|
|
1864
|
+
//#region src/seams/loop.ts
|
|
1865
|
+
/**
|
|
1866
|
+
* loop.ts — 拦截缝:回合生命周期(agent/turn-stopping 机械落盘)
|
|
1867
|
+
*
|
|
1868
|
+
* 每个自然停止边界:若 agent 工作目录在 CCC 内且存在 `.dsh/active-session`
|
|
1869
|
+
* 标记(内容为相对 CCC 根的 SESSION.md 路径),自动追加心跳行——
|
|
1870
|
+
* 会话进度不再依赖模型自觉。
|
|
1871
|
+
*/
|
|
1872
|
+
/** 解析活动会话 SESSION.md 绝对路径;无标记/越界返回 null */
|
|
1873
|
+
function resolveActiveSession(root) {
|
|
1874
|
+
const marker = resolve(root, ".dsh", "active-session");
|
|
1875
|
+
if (!existsSync(marker)) return null;
|
|
1876
|
+
const rel = readFileSync(marker, "utf-8").trim();
|
|
1877
|
+
if (!rel) return null;
|
|
1878
|
+
const abs = resolve(root, rel);
|
|
1879
|
+
if (!abs.startsWith(resolve(root))) return null;
|
|
1880
|
+
return abs;
|
|
1881
|
+
}
|
|
1882
|
+
/** 注册 agent/turn-stopping:CCC 内活动会话自动心跳落盘 */
|
|
1883
|
+
function registerTurnFlush(ctx) {
|
|
1884
|
+
ctx.on("agent/turn-stopping", async (payload) => {
|
|
1885
|
+
const root = findSerenityRoot(payload.agent.session?.header?.cwd ?? process.cwd());
|
|
1886
|
+
if (!root) return;
|
|
1887
|
+
const sessionMd = resolveActiveSession(root);
|
|
1888
|
+
if (sessionMd) appendHeartbeat(sessionMd);
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
//#endregion
|
|
1892
|
+
//#region src/seams/keeper.ts
|
|
1893
|
+
const SCORES = {
|
|
1894
|
+
write: 3,
|
|
1895
|
+
edit: 3,
|
|
1896
|
+
str_replace_editor: 3,
|
|
1897
|
+
task: 10,
|
|
1898
|
+
read: 1,
|
|
1899
|
+
grep: 1,
|
|
1900
|
+
glob: 1,
|
|
1901
|
+
skill: 1,
|
|
1902
|
+
acc_msm: 1,
|
|
1903
|
+
cc_fs: 1
|
|
1904
|
+
};
|
|
1905
|
+
function scoreTool(toolName) {
|
|
1906
|
+
return SCORES[toolName] ?? 0;
|
|
1907
|
+
}
|
|
1908
|
+
var KeeperTracker = class {
|
|
1909
|
+
threshold;
|
|
1910
|
+
now;
|
|
1911
|
+
score = 0;
|
|
1912
|
+
lastTs = 0;
|
|
1913
|
+
counter = 0;
|
|
1914
|
+
constructor(threshold, now = Date.now) {
|
|
1915
|
+
this.threshold = threshold;
|
|
1916
|
+
this.now = now;
|
|
1917
|
+
}
|
|
1918
|
+
/** 记录一次工具调用 + 经过时间;返回是否应触发提醒 */
|
|
1919
|
+
step(toolName) {
|
|
1920
|
+
const now = this.now();
|
|
1921
|
+
if (this.lastTs > 0) this.score += Math.floor((now - this.lastTs) / 6e4);
|
|
1922
|
+
this.lastTs = now;
|
|
1923
|
+
this.score += scoreTool(toolName);
|
|
1924
|
+
return this.score >= this.threshold;
|
|
1925
|
+
}
|
|
1926
|
+
/** 生成确认码并清零积分 */
|
|
1927
|
+
ack() {
|
|
1928
|
+
this.counter += 1;
|
|
1929
|
+
this.score = 0;
|
|
1930
|
+
this.lastTs = 0;
|
|
1931
|
+
return `K${this.counter}`;
|
|
1932
|
+
}
|
|
1933
|
+
get currentScore() {
|
|
1934
|
+
return this.score;
|
|
1935
|
+
}
|
|
1936
|
+
};
|
|
1937
|
+
function reminderText(code, score) {
|
|
1938
|
+
return `[SESSION-KEEPER] 积分已达阈值 (${score})。请回应 [SESSION-KEEPER-recorded-${code}] 确认本轮进度已沉淀到工作会话(acc-session show),随后我清零积分。`;
|
|
1939
|
+
}
|
|
1940
|
+
const PLUGIN_SOURCE$1 = {
|
|
1941
|
+
kind: "plugin",
|
|
1942
|
+
plugin: "dsh-serenity-hooks"
|
|
1943
|
+
};
|
|
1944
|
+
/** 每 agent 一个跟踪器(进程内存态,agent token 维度) */
|
|
1945
|
+
const trackers = /* @__PURE__ */ new Map();
|
|
1946
|
+
function registerKeeper(ctx, opts = {}) {
|
|
1947
|
+
const defaultThreshold = opts.defaultThreshold ?? 150;
|
|
1948
|
+
const trackerFor = (exec) => {
|
|
1949
|
+
const key = exec.agent?.session?.id ?? "global";
|
|
1950
|
+
let t = trackers.get(key);
|
|
1951
|
+
if (!t) {
|
|
1952
|
+
const root = findSerenityRoot(exec.agent?.session?.header?.cwd ?? process.cwd());
|
|
1953
|
+
t = new KeeperTracker(root ? loadSerenityConfig(root, opts.configPaths).sessionKeeper?.threshold ?? defaultThreshold : defaultThreshold);
|
|
1954
|
+
trackers.set(key, t);
|
|
1955
|
+
}
|
|
1956
|
+
return t;
|
|
1957
|
+
};
|
|
1958
|
+
ctx.on("tools/post-execute", async (exec, _result, next) => {
|
|
1959
|
+
if (!exec.agent) return next();
|
|
1960
|
+
if (!findSerenityRoot(exec.agent?.session?.header?.cwd ?? process.cwd())) return next();
|
|
1961
|
+
const tracker = trackerFor(exec);
|
|
1962
|
+
const shouldRemind = tracker.step(exec.name);
|
|
1963
|
+
const downstream = await next();
|
|
1964
|
+
if (!shouldRemind) return downstream;
|
|
1965
|
+
const content = [{
|
|
1966
|
+
type: "text",
|
|
1967
|
+
text: reminderText(tracker.ack(), tracker.currentScore)
|
|
1968
|
+
}];
|
|
1969
|
+
const reminder = createUserMessage({
|
|
1970
|
+
content,
|
|
1971
|
+
source: PLUGIN_SOURCE$1
|
|
1972
|
+
});
|
|
1973
|
+
if (downstream.kind === "block") return {
|
|
1974
|
+
kind: "block",
|
|
1975
|
+
feedback: downstream.feedback,
|
|
1976
|
+
additionalContexts: [reminder, ...downstream.additionalContexts ?? []]
|
|
1977
|
+
};
|
|
1978
|
+
return {
|
|
1979
|
+
...downstream,
|
|
1980
|
+
additionalContexts: [reminder, ...downstream.additionalContexts ?? []]
|
|
1981
|
+
};
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
//#endregion
|
|
1985
|
+
//#region src/constants.ts
|
|
1986
|
+
/** 常量(纯模块,零 DSH 依赖) */
|
|
1987
|
+
/**
|
|
1988
|
+
* ACC 版本:自动从 package.json 读取(单一真相源,消除与 CHANGELOG 的漂移)。
|
|
1989
|
+
* 发布时只需改 package.json 的 version。
|
|
1990
|
+
*/
|
|
1991
|
+
const ACC_VERSION = (() => {
|
|
1992
|
+
try {
|
|
1993
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1994
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version ?? "0.0.0";
|
|
1995
|
+
} catch {
|
|
1996
|
+
return "0.0.0";
|
|
1997
|
+
}
|
|
1998
|
+
})();
|
|
1999
|
+
//#endregion
|
|
2000
|
+
//#region src/skills-discovery.ts
|
|
2001
|
+
/**
|
|
2002
|
+
* skills-discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
|
|
2003
|
+
*
|
|
2004
|
+
* 发现全部入口技能(原文全量,不截断):
|
|
2005
|
+
* 0. **`.serenity` 记号文件内容 = 顶层入口 skill 名**(CCC 记号文件的权威语义,
|
|
2006
|
+
* tiangong-serenity 的 .serenity 内容为 `tg-serenity`)—— 最高优先
|
|
2007
|
+
* 1. `.dsh/entry-skill` 指针文件(内容 = skill 名)—— 兼容旧约定
|
|
2008
|
+
* 2. `.opencode/skills/*-serenity/SKILL.md` —— 自动扫描该 CCC 的顶层入口
|
|
2009
|
+
* (home-serenity / tg-serenity / pangu-serenity …,命名模式 `*-serenity`)
|
|
2010
|
+
* 3. `.dsh/skills/*-serenity/SKILL.md` —— 自动扫描 ACC/harness 入口(acc-serenity 等)
|
|
2011
|
+
* 按名去重;顺序 = 记号文件 → 指针 → opencode 入口 → dsh 入口。
|
|
2012
|
+
*
|
|
2013
|
+
* 任何 CCC 都能自动注入其顶层入口 skill 全文(不硬编码名字)。
|
|
2014
|
+
*/
|
|
2015
|
+
/** 顶层入口命名模式:目录名以 -serenity 结尾(home-serenity / tg-serenity / acc-serenity …) */
|
|
2016
|
+
const SERENITY_SUFFIX = "-serenity";
|
|
2017
|
+
function isSerenityEntry(name) {
|
|
2018
|
+
return name.endsWith(SERENITY_SUFFIX);
|
|
2019
|
+
}
|
|
2020
|
+
/** 扫描某 skills 根下所有 `*-serenity` 目录(含 SKILL.md 的) */
|
|
2021
|
+
function scanSerenityDirs(skillsRoot) {
|
|
2022
|
+
if (!existsSync(skillsRoot)) return [];
|
|
2023
|
+
return readdirSync(skillsRoot).filter((name) => isSerenityEntry(name)).map((name) => join(skillsRoot, name)).filter((dir) => statSync(dir).isDirectory() && existsSync(join(dir, "SKILL.md"))).sort();
|
|
2024
|
+
}
|
|
2025
|
+
/** 按名在两个 skills 根下定位 SKILL.md(.dsh/skills 优先,其次 .opencode/skills) */
|
|
2026
|
+
function findSkillMd(root, name) {
|
|
2027
|
+
for (const base of [".dsh", ".opencode"]) {
|
|
2028
|
+
const p = resolve(root, base, "skills", name, "SKILL.md");
|
|
2029
|
+
if (existsSync(p)) return p;
|
|
2030
|
+
}
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
/** 发现全部入口技能(原文全量,不截断) */
|
|
2034
|
+
function findEntrySkills(root) {
|
|
2035
|
+
const out = [];
|
|
2036
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2037
|
+
const add = (path, source, forcedName) => {
|
|
2038
|
+
if (!existsSync(path)) return;
|
|
2039
|
+
const name = forcedName ?? basename(dirname(path));
|
|
2040
|
+
if (seen.has(name)) return;
|
|
2041
|
+
seen.add(name);
|
|
2042
|
+
out.push({
|
|
2043
|
+
name,
|
|
2044
|
+
content: readFileSync(path, "utf-8"),
|
|
2045
|
+
source
|
|
2046
|
+
});
|
|
2047
|
+
};
|
|
2048
|
+
const marker = resolve(root, ".serenity");
|
|
2049
|
+
if (existsSync(marker)) {
|
|
2050
|
+
const name = readFileSync(marker, "utf-8").trim();
|
|
2051
|
+
if (name) {
|
|
2052
|
+
const md = findSkillMd(root, name);
|
|
2053
|
+
if (md) add(md, "serenity-marker", name);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
const pointer = resolve(root, ".dsh", "entry-skill");
|
|
2057
|
+
if (existsSync(pointer)) {
|
|
2058
|
+
const name = readFileSync(pointer, "utf-8").trim();
|
|
2059
|
+
if (name) {
|
|
2060
|
+
const md = findSkillMd(root, name);
|
|
2061
|
+
if (md) add(md, "pointer", name);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
for (const dir of scanSerenityDirs(resolve(root, ".opencode", "skills"))) add(join(dir, "SKILL.md"), "opencode");
|
|
2065
|
+
for (const dir of scanSerenityDirs(resolve(root, ".dsh", "skills"))) add(join(dir, "SKILL.md"), "dsh");
|
|
2066
|
+
return out;
|
|
2067
|
+
}
|
|
2068
|
+
function truncateContent(content, maxChars) {
|
|
2069
|
+
if (content.length <= maxChars) return content;
|
|
2070
|
+
return content.slice(0, maxChars) + `\n...(已截断,原长 ${content.length} 字符)`;
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
2073
|
+
//#region src/seams/system-prompt.ts
|
|
2074
|
+
/** 过滤掉对 agent 隐藏的内容(safe-mode 是用户能力,不对 agent 提及) */
|
|
2075
|
+
const HIDDEN_LINES = /安全模式|safe-mode|\.serenity-safe-on/g;
|
|
2076
|
+
function sanitizeSkillContent(content) {
|
|
2077
|
+
return content.split("\n").filter((line) => !HIDDEN_LINES.test(line)).join("\n");
|
|
2078
|
+
}
|
|
2079
|
+
/** 1) ACC 块:身份 + CCC 名/Root + 内置工具清单(工具名换本插件真实 9 工具) */
|
|
2080
|
+
function accBlock(root) {
|
|
2081
|
+
const cccName = basename(root);
|
|
2082
|
+
return [
|
|
2083
|
+
"",
|
|
2084
|
+
"=== Serenity ACC ===",
|
|
2085
|
+
`ACC: dsh-serenity-hooks v${ACC_VERSION}`,
|
|
2086
|
+
`CCC: ${cccName} Root: ${root}`,
|
|
2087
|
+
"",
|
|
2088
|
+
"You are running inside a Concrete Cognitive Container (CCC) —",
|
|
2089
|
+
"the runtime instance of an Abstract Cognitive Container (ACC).",
|
|
2090
|
+
"The ACC (this plugin) provides the following built-in tools:",
|
|
2091
|
+
"",
|
|
2092
|
+
" cc_fs — CCC 内文件系统操作(root/resolve/exists/list/tree/relative/mkdir/rm/mv/cp/touch/append/reveal/info/find)",
|
|
2093
|
+
" session — session lifecycle(list/show/create/health/qa/archive/summary)",
|
|
2094
|
+
" acc_kit — ACC utility kit(health: CCC three principles / time: now / wait: sleep N seconds)",
|
|
2095
|
+
" cc_git — git operations(status/commit/push/log)",
|
|
2096
|
+
" acc_msm — MSM framework(list/exec/register/deregister/check/guide)",
|
|
2097
|
+
" eap — return the full EAP cognitive quality framework",
|
|
2098
|
+
" neat — return the full Neat design collaboration protocol",
|
|
2099
|
+
" cce — return the full Cognitive Continuity Engineering framework",
|
|
2100
|
+
" loop — 牛马循环:指定模型专用 agent 反复执行",
|
|
2101
|
+
"",
|
|
2102
|
+
"The DSH platform tools remain available too (read/write/edit/glob/grep/web_search/ask_user_question/subagent/workflow/goal and more) — the ACC tools above are the serenity-native layer, not the only tools.",
|
|
2103
|
+
"",
|
|
2104
|
+
"Additional MSMs registered by this CCC are available — call acc_msm list to discover them.",
|
|
2105
|
+
""
|
|
2106
|
+
].join("\n");
|
|
2107
|
+
}
|
|
2108
|
+
/** 2) CCE 块:逐字对齐 osp(CCE 5 行为约束 + H_op 操作熵) */
|
|
2109
|
+
function cceBlock() {
|
|
2110
|
+
return [
|
|
2111
|
+
"",
|
|
2112
|
+
"=== Serenity CCE ===",
|
|
2113
|
+
"",
|
|
2114
|
+
"You are operating inside a Cognitive Container governed by Cognitive Continuity",
|
|
2115
|
+
"Engineering (CCE) — the engineering discipline of maintaining identity, accessibility,",
|
|
2116
|
+
"and evolution of a cognitive entity through time under bounded resources.",
|
|
2117
|
+
"",
|
|
2118
|
+
"CCE does not optimize cognition. It preserves the conditions under which cognition",
|
|
2119
|
+
"can continue.",
|
|
2120
|
+
"",
|
|
2121
|
+
"FIVE BEHAVIORAL CONSTRAINTS (engineering requirements, not suggestions):",
|
|
2122
|
+
"",
|
|
2123
|
+
"1. Continuity — every interaction modifies the container's future state. Before",
|
|
2124
|
+
" acting, consult what came before — prior decisions, abstractions, constraints.",
|
|
2125
|
+
" You are part of a trajectory, not a fresh start.",
|
|
2126
|
+
"",
|
|
2127
|
+
"2. Bounded Space — the container has boundaries. Respect them. Do not assume",
|
|
2128
|
+
" knowledge that has not been accumulated within this container.",
|
|
2129
|
+
"",
|
|
2130
|
+
"3. Entropy is Intrinsic — every cognitive system accumulates entropy (duplication,",
|
|
2131
|
+
" obsolescence, conflict, fragmentation, drift). When you produce output, consider",
|
|
2132
|
+
" whether you are adding entropy or reducing it. Favor entropy-reducing actions —",
|
|
2133
|
+
" organizing, deduplicating, cross-referencing, abstracting.",
|
|
2134
|
+
"",
|
|
2135
|
+
"4. Reconstruction > Preservation — stored artifacts have value only insofar as",
|
|
2136
|
+
" they enable future cognition to recover the reasoning that produced them. When",
|
|
2137
|
+
" recording decisions, ensure reconstruction is possible — not just conclusions,",
|
|
2138
|
+
" but rationale, alternatives considered, and constraints that shaped the choice.",
|
|
2139
|
+
"",
|
|
2140
|
+
"5. Multi-Agent Cognition — the container is shared. Continuity belongs to the",
|
|
2141
|
+
" container, not to any individual agent. Write for future agents who will enter",
|
|
2142
|
+
" after you leave. They should be able to pick up where you left off.",
|
|
2143
|
+
"",
|
|
2144
|
+
"OPERATIONAL ENTROPY: The container's health metric is operational cognitive entropy",
|
|
2145
|
+
"(H_op) — the excess cognitive cost for agents to complete tasks due to disorder.",
|
|
2146
|
+
"The container is healthy when H_op ≤ H_critical (agents can still function). The",
|
|
2147
|
+
"continuity condition: organization must at minimum match accumulation (ΔH_org ≥ ΔH_in).",
|
|
2148
|
+
"Your actions affect H_op — unorganized output increases it, organization decreases it.",
|
|
2149
|
+
"",
|
|
2150
|
+
"CCE AND EAP: EAP governs artifact quality (how explicit to be). CCE governs temporal",
|
|
2151
|
+
"coherence (how to maintain consistency over time). When structuring a document, apply",
|
|
2152
|
+
"EAP (E↑ R↓ S↑). When maintaining cross-session coherence, apply CCE.",
|
|
2153
|
+
"",
|
|
2154
|
+
"THIS IS PERSISTENCE ENGINEERING: The goal is not to become greater. The goal is to",
|
|
2155
|
+
"remain coherent. CCE has no terminal KPI — continuity is maintained while the entity",
|
|
2156
|
+
"exists, not optimized toward an endpoint.",
|
|
2157
|
+
""
|
|
2158
|
+
].join("\n");
|
|
2159
|
+
}
|
|
2160
|
+
/** 3) Constraints 块:逐字对齐 osp(Root + 文件边界 + shell + subagent + session-first) */
|
|
2161
|
+
function constraintsBlock(root) {
|
|
2162
|
+
return [
|
|
2163
|
+
"",
|
|
2164
|
+
"=== Serenity Constraints ===",
|
|
2165
|
+
`Root: ${root}`,
|
|
2166
|
+
" • File access — read/edit/write/grep/glob are confined to Root; paths outside Root are rejected (RR5)",
|
|
2167
|
+
" • Shell — use acc_msm by default. Note: bash may be disabled",
|
|
2168
|
+
" • Subagent — copies ALL parent constraints: file boundary, shell rules, session rules (no bypass)",
|
|
2169
|
+
" • Session-first — before starting multi-step work, propose an existing or new AGENT_SESSIONS entry; wait for user \"use\" or \"使用\" to confirm",
|
|
2170
|
+
""
|
|
2171
|
+
].join("\n");
|
|
2172
|
+
}
|
|
2173
|
+
/** 4) SKILL.md 全文:该 CCC 顶层入口 skill 原文(对齐 osp:原文直推,无包裹头;仅过滤治理内容) */
|
|
2174
|
+
function entrySkillSectionText(root) {
|
|
2175
|
+
const skills = findEntrySkills(root);
|
|
2176
|
+
if (skills.length === 0) return "";
|
|
2177
|
+
return skills.map((s) => sanitizeSkillContent(s.content)).filter((c) => c.length > 0).join("\n\n");
|
|
2178
|
+
}
|
|
2179
|
+
/** 活跃会话解析:.dsh/active-session 标记(内容 = 相对 CCC 根的 SESSION.md 路径) */
|
|
2180
|
+
function resolveActiveSessionInfo(root) {
|
|
2181
|
+
try {
|
|
2182
|
+
const marker = resolve(root, ".dsh", "active-session");
|
|
2183
|
+
if (!existsSync(marker)) return null;
|
|
2184
|
+
const rel = readFileSync(marker, "utf-8").trim();
|
|
2185
|
+
if (!rel) return null;
|
|
2186
|
+
const abs = resolve(root, rel);
|
|
2187
|
+
if (!abs.startsWith(resolve(root))) return null;
|
|
2188
|
+
const dirName = basename(dirname(abs));
|
|
2189
|
+
const idMatch = dirName.match(/S(\d{3,})/);
|
|
2190
|
+
return {
|
|
2191
|
+
sessionId: idMatch ? `S${idMatch[1]}` : dirName,
|
|
2192
|
+
dirName,
|
|
2193
|
+
mdPath: abs
|
|
2194
|
+
};
|
|
2195
|
+
} catch {
|
|
2196
|
+
return null;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/** 5) Session 块:逐字对齐 osp(活跃会话 + todowrite 首位约定) */
|
|
2200
|
+
function sessionBlock(root) {
|
|
2201
|
+
const active = resolveActiveSessionInfo(root);
|
|
2202
|
+
if (!active) return "";
|
|
2203
|
+
return [
|
|
2204
|
+
"",
|
|
2205
|
+
"=== Serenity Session ===",
|
|
2206
|
+
`Active session: ${active.sessionId} — ${active.dirName}`,
|
|
2207
|
+
`SESSION.md path: ${active.mdPath}`,
|
|
2208
|
+
"",
|
|
2209
|
+
"Rules:",
|
|
2210
|
+
" • Record all progress into this SESSION.md",
|
|
2211
|
+
" • Update the \"进度记录\" section after advancing work",
|
|
2212
|
+
" • Reference this session in all subsequent messages",
|
|
2213
|
+
"",
|
|
2214
|
+
"IMPORTANT: Read SESSION.md now. Parse the \"剩余工作\" / \"进度记录\" /",
|
|
2215
|
+
"\"变更日志\" sections and call todowrite to synchronize the built-in todo",
|
|
2216
|
+
"list. Keep todos in sync with SESSION.md as work progresses.",
|
|
2217
|
+
"",
|
|
2218
|
+
"CRITICAL: When calling todowrite, the first item in the todos array MUST",
|
|
2219
|
+
"always be:",
|
|
2220
|
+
` { content: "SESSION: ${active.sessionId} — ${active.dirName.replace(/^\d{4}-\d{2}-\d{2}--/, "")}",`,
|
|
2221
|
+
" status: \"completed\", priority: \"low\" }",
|
|
2222
|
+
"This preserves the session context across todo updates.",
|
|
2223
|
+
"Do NOT remove or reorder this item — keep it at position 0.",
|
|
2224
|
+
""
|
|
2225
|
+
].join("\n");
|
|
2226
|
+
}
|
|
2227
|
+
/** 完整系统提示词注入文本:ACC + CCE + Constraints + SKILL 全文 + Session(osp 顺序) */
|
|
2228
|
+
function serenitySystemPrompt(root) {
|
|
2229
|
+
const parts = [
|
|
2230
|
+
accBlock(root),
|
|
2231
|
+
cceBlock(),
|
|
2232
|
+
constraintsBlock(root)
|
|
2233
|
+
];
|
|
2234
|
+
const skill = entrySkillSectionText(root);
|
|
2235
|
+
if (skill) parts.push(skill);
|
|
2236
|
+
const session = sessionBlock(root);
|
|
2237
|
+
if (session) parts.push(session);
|
|
2238
|
+
return parts.join("\n\n");
|
|
2239
|
+
}
|
|
2240
|
+
/** 从 assembly context 解析 agent cwd(subagent/后台 agent 同样带 agent) */
|
|
2241
|
+
function agentCwd(context) {
|
|
2242
|
+
return (context.agent?.session)?.header?.cwd;
|
|
2243
|
+
}
|
|
2244
|
+
/**
|
|
2245
|
+
* 全局注册系统提示词 section(osp system.transform 的 DSH 等价)。
|
|
2246
|
+
* text 回调按 context.agent 的 cwd 上溯 .serenity → 返回该 CCC 的完整注入文本;
|
|
2247
|
+
* 非 CCC / 无 agent 返回空(不注入)。
|
|
2248
|
+
*/
|
|
2249
|
+
function registerEntrySkillSectionGlobal(ctx) {
|
|
2250
|
+
try {
|
|
2251
|
+
ctx.systemPrompt.section({
|
|
2252
|
+
name: "serenity-entry",
|
|
2253
|
+
order: -50,
|
|
2254
|
+
text: (context) => {
|
|
2255
|
+
const cwd = agentCwd(context);
|
|
2256
|
+
if (!cwd) return "";
|
|
2257
|
+
const root = findSerenityRoot(cwd);
|
|
2258
|
+
if (!root) return "";
|
|
2259
|
+
return serenitySystemPrompt(root);
|
|
2260
|
+
}
|
|
2261
|
+
});
|
|
2262
|
+
console.log("[serenity-hooks] ✓ 全局入口 skill section 已注册(systemPrompt 就绪)");
|
|
2263
|
+
} catch (err) {
|
|
2264
|
+
console.error(`[serenity-hooks] ✗ 全局入口 skill section 注册失败: ${err.message}(检查 inject 是否含 systemPrompt)`);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
//#endregion
|
|
2268
|
+
//#region src/seams/context.ts
|
|
2269
|
+
const DEFAULT_ENTRY_SKILL_MAX_CHARS = 3e4;
|
|
2270
|
+
function accIdentityText(root, configPaths = DEFAULT_SERENITY_CONFIG_PATHS, entrySkillMaxChars = DEFAULT_ENTRY_SKILL_MAX_CHARS) {
|
|
2271
|
+
const loop = loadSerenityConfig(root, configPaths).loop?.defaultModel;
|
|
2272
|
+
const phase2 = existsSync(resolve(root, ".dsh", "PHASE2-PROMPT.md"));
|
|
2273
|
+
const lines = [
|
|
2274
|
+
`[ACC] 宁静号认知容器已激活(dsh-serenity-hooks v${ACC_VERSION})`,
|
|
2275
|
+
`- CCC 根:${root}`,
|
|
2276
|
+
`- 约束:路径隔离(P3,fs 沙箱)+ 会话追踪(AGENT_SESSIONS/)`,
|
|
2277
|
+
`- 知识:加载 acc-serenity 入口技能;设计协作走 acc-eap / acc-neat`
|
|
2278
|
+
];
|
|
2279
|
+
if (loop) lines.push(`- loop 默认模型:${loop}`);
|
|
2280
|
+
if (phase2) {
|
|
2281
|
+
lines.push("- ⚠️ **Phase 2 认知对齐访谈待完成**:请按下方 5 个 Topic 逐项访谈并沉淀答案到 AGENT_SESSIONS/ 会话");
|
|
2282
|
+
try {
|
|
2283
|
+
const prompt = readFileSync(resolve(root, ".dsh", "PHASE2-PROMPT.md"), "utf-8");
|
|
2284
|
+
lines.push(truncateContent(prompt, Math.min(entrySkillMaxChars, 8e3)));
|
|
2285
|
+
} catch {}
|
|
2286
|
+
}
|
|
2287
|
+
return lines.join("\n");
|
|
2288
|
+
}
|
|
2289
|
+
const PLUGIN_SOURCE = {
|
|
2290
|
+
kind: "plugin",
|
|
2291
|
+
plugin: "dsh-serenity-hooks"
|
|
2292
|
+
};
|
|
2293
|
+
/**
|
|
2294
|
+
* ACC 注入消息:完整内容 = 简短身份头 + 完整系统提示词(ACC 5 块 + CCC 顶层 skill 原文)。
|
|
2295
|
+
* 用户要求:在此处注入完整 ACC 系统提示词内容 + CCC 顶层 skill 原文(对齐 osp system.transform)。
|
|
2296
|
+
*/
|
|
2297
|
+
function accMessage(root, configPaths, entrySkillMaxChars) {
|
|
2298
|
+
const content = [{
|
|
2299
|
+
type: "text",
|
|
2300
|
+
text: `${accIdentityText(root, configPaths, entrySkillMaxChars)}\n\n${serenitySystemPrompt(root)}`
|
|
2301
|
+
}];
|
|
2302
|
+
return createUserMessage({
|
|
2303
|
+
content,
|
|
2304
|
+
source: PLUGIN_SOURCE
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
/** 每 agent 是否已注入过(进程内存态) */
|
|
2308
|
+
const injected = /* @__PURE__ */ new Set();
|
|
2309
|
+
function agentKey(agent) {
|
|
2310
|
+
return agent.session.id ?? "global";
|
|
2311
|
+
}
|
|
2312
|
+
function registerContext(ctx, opts = {}) {
|
|
2313
|
+
const configPaths = opts.configPaths ?? DEFAULT_SERENITY_CONFIG_PATHS;
|
|
2314
|
+
const entrySkillMaxChars = opts.entrySkillMaxChars ?? 3e4;
|
|
2315
|
+
const seed = (agent) => {
|
|
2316
|
+
const root = findSerenityRoot(agent.session?.header?.cwd ?? process.cwd());
|
|
2317
|
+
if (!root) return;
|
|
2318
|
+
const key = agentKey(agent);
|
|
2319
|
+
if (injected.has(key)) return;
|
|
2320
|
+
injected.add(key);
|
|
2321
|
+
agent.inject(accMessage(root, configPaths, entrySkillMaxChars));
|
|
2322
|
+
syncSafeModeRestriction(agent, root);
|
|
2323
|
+
};
|
|
2324
|
+
if (opts.seedOnStart ?? true) ctx.on("agent/session-start", (payload) => {
|
|
2325
|
+
try {
|
|
2326
|
+
seed(payload.agent);
|
|
2327
|
+
} catch {}
|
|
2328
|
+
});
|
|
2329
|
+
if (opts.injectOnPrompt ?? true) ctx.on("agent/pre-step", async (payload, next) => {
|
|
2330
|
+
const { agent, messages } = payload;
|
|
2331
|
+
const root = findSerenityRoot(agent.session?.header?.cwd ?? process.cwd());
|
|
2332
|
+
const key = agentKey(agent);
|
|
2333
|
+
const downstream = await next();
|
|
2334
|
+
if (root) try {
|
|
2335
|
+
syncSafeModeRestriction(agent, root);
|
|
2336
|
+
} catch {}
|
|
2337
|
+
if (!root || injected.has(key) || downstream.kind !== "enter") return downstream;
|
|
2338
|
+
injected.add(key);
|
|
2339
|
+
return {
|
|
2340
|
+
kind: "enter",
|
|
2341
|
+
messages: [
|
|
2342
|
+
accMessage(root, configPaths, entrySkillMaxChars),
|
|
2343
|
+
...messages,
|
|
2344
|
+
...downstream.messages
|
|
2345
|
+
]
|
|
2346
|
+
};
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
//#endregion
|
|
2350
|
+
//#region src/seams/compact.ts
|
|
2351
|
+
/**
|
|
2352
|
+
* 注册压缩保留:compact/end(成功)后重注入 ACC 身份。
|
|
2353
|
+
* 仅当 agent 工作目录在 CCC 内时生效(激活门控)。
|
|
2354
|
+
*/
|
|
2355
|
+
function registerCompactRetention(ctx, opts = {}) {
|
|
2356
|
+
const configPaths = opts.configPaths ?? DEFAULT_SERENITY_CONFIG_PATHS;
|
|
2357
|
+
const entrySkillMaxChars = opts.entrySkillMaxChars ?? 3e4;
|
|
2358
|
+
ctx.on("session/event", (session, event) => {
|
|
2359
|
+
if (event.type !== "compaction/end") return;
|
|
2360
|
+
if (event.data.error) return;
|
|
2361
|
+
const agent = ctx.agents.get(session.id);
|
|
2362
|
+
if (!agent) return;
|
|
2363
|
+
const root = findSerenityRoot(agent.session?.header?.cwd ?? process.cwd());
|
|
2364
|
+
if (!root) return;
|
|
2365
|
+
try {
|
|
2366
|
+
agent.inject(accMessage(root, configPaths, entrySkillMaxChars));
|
|
2367
|
+
} catch {}
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
//#endregion
|
|
2371
|
+
//#region src/status.ts
|
|
2372
|
+
/**
|
|
2373
|
+
* status.ts — 状态与安全模式操作(纯逻辑,零 DSH 依赖,可独立单测)
|
|
2374
|
+
*
|
|
2375
|
+
* WebUI 停靠栏的数据源:ACC 版本 / CCC 根 / safe-mode 状态 / 黑名单 / keeper 阈值 / loop 模型。
|
|
2376
|
+
* setSafeMode 直接读写 .serenity-safe-on 标记(守卫实时读取,写即生效)。
|
|
2377
|
+
*/
|
|
2378
|
+
function getStatus(cwd, configPaths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
2379
|
+
const root = findSerenityRoot(cwd);
|
|
2380
|
+
const restrict = getRestrictDiagnostics();
|
|
2381
|
+
if (!root) return {
|
|
2382
|
+
root: null,
|
|
2383
|
+
accVersion: ACC_VERSION,
|
|
2384
|
+
safeModeOn: false,
|
|
2385
|
+
blacklist: [],
|
|
2386
|
+
threshold: null,
|
|
2387
|
+
loopModel: null,
|
|
2388
|
+
restrict
|
|
2389
|
+
};
|
|
2390
|
+
const cfg = loadSerenityConfig(root, configPaths);
|
|
2391
|
+
return {
|
|
2392
|
+
root,
|
|
2393
|
+
accVersion: ACC_VERSION,
|
|
2394
|
+
safeModeOn: isSafeModeOn(root),
|
|
2395
|
+
blacklist: readBlacklist(root, configPaths),
|
|
2396
|
+
threshold: cfg.sessionKeeper?.threshold ?? null,
|
|
2397
|
+
loopModel: cfg.loop?.defaultModel ?? null,
|
|
2398
|
+
restrict
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
/** 切换安全模式(写/删标记文件);返回实际生效状态 */
|
|
2402
|
+
function setSafeMode(root, on) {
|
|
2403
|
+
const marker = resolve(root, SAFE_MODE_MARKER);
|
|
2404
|
+
if (on) {
|
|
2405
|
+
if (!existsSync(marker)) writeFileSync(marker, (/* @__PURE__ */ new Date()).toISOString() + "\n", "utf-8");
|
|
2406
|
+
} else rmSync(marker, { force: true });
|
|
2407
|
+
return { on: isSafeModeOn(root) };
|
|
2408
|
+
}
|
|
2409
|
+
//#endregion
|
|
2410
|
+
//#region src/api.ts
|
|
2411
|
+
const ROUTE_PATH = "/serenity/status";
|
|
2412
|
+
function readBody(req) {
|
|
2413
|
+
return new Promise((resolve, reject) => {
|
|
2414
|
+
let data = "";
|
|
2415
|
+
req.on("data", (chunk) => {
|
|
2416
|
+
data += chunk.toString("utf-8");
|
|
2417
|
+
if (data.length > 65536) {
|
|
2418
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
2419
|
+
req.destroy();
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
req.on("end", () => resolve(data));
|
|
2423
|
+
req.on("error", reject);
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
function sendJson(res, code, body) {
|
|
2427
|
+
const payload = JSON.stringify(body);
|
|
2428
|
+
res.writeHead(code, {
|
|
2429
|
+
"content-type": "application/json; charset=utf-8",
|
|
2430
|
+
"cache-control": "no-store"
|
|
2431
|
+
});
|
|
2432
|
+
res.end(payload);
|
|
2433
|
+
}
|
|
2434
|
+
/** 从请求参数解析 workspace:优先 sessionId → 会话 header.cwd;其次 workspace 参数;最后进程 cwd */
|
|
2435
|
+
function resolveWorkspace(ctx, params) {
|
|
2436
|
+
if (params.sessionId) {
|
|
2437
|
+
const session = ctx.sessions?.get?.(params.sessionId);
|
|
2438
|
+
if (session?.header?.cwd) return session.header.cwd;
|
|
2439
|
+
}
|
|
2440
|
+
if (typeof params.workspace === "string" && params.workspace) return params.workspace;
|
|
2441
|
+
return process.cwd();
|
|
2442
|
+
}
|
|
2443
|
+
function registerStatusApi(ctx, opts = {}) {
|
|
2444
|
+
const configPaths = opts.configPaths ?? DEFAULT_SERENITY_CONFIG_PATHS;
|
|
2445
|
+
ctx.webServer.register({
|
|
2446
|
+
kind: "exact",
|
|
2447
|
+
path: ROUTE_PATH,
|
|
2448
|
+
handler: async (req, res) => {
|
|
2449
|
+
try {
|
|
2450
|
+
if (req.method === "GET") {
|
|
2451
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
2452
|
+
sendJson(res, 200, getStatus(resolveWorkspace(ctx, {
|
|
2453
|
+
sessionId: url.searchParams.get("sessionId") ?? void 0,
|
|
2454
|
+
workspace: url.searchParams.get("workspace") ?? void 0
|
|
2455
|
+
}), configPaths));
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
if (req.method === "POST") {
|
|
2459
|
+
if (req.headers["x-serenity-ui"] !== "1") {
|
|
2460
|
+
sendJson(res, 403, { error: "safe-mode 切换仅限 WebUI(agent 不可自行开关)" });
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
const raw = await readBody(req);
|
|
2464
|
+
const body = JSON.parse(raw);
|
|
2465
|
+
const workspace = resolveWorkspace(ctx, {
|
|
2466
|
+
sessionId: body.sessionId,
|
|
2467
|
+
workspace: body.workspace
|
|
2468
|
+
});
|
|
2469
|
+
const root = findSerenityRoot(workspace);
|
|
2470
|
+
if (!root) {
|
|
2471
|
+
sendJson(res, 404, { error: `no CCC found from workspace: ${workspace}` });
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
const result = setSafeMode(root, body.on === true);
|
|
2475
|
+
sendJson(res, 200, {
|
|
2476
|
+
...getStatus(workspace, configPaths),
|
|
2477
|
+
...result
|
|
2478
|
+
});
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
sendJson(res, 400, { error: err.message ?? String(err) });
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
//#endregion
|
|
2489
|
+
//#region src/seams/env.ts
|
|
2490
|
+
/** 纯解析:给定 cwd,返回可注入的 DSH_SERENITY_* 事实(非 CCC 返回空) */
|
|
2491
|
+
function resolveSerenityEnv(cwd) {
|
|
2492
|
+
const root = findSerenityRoot(cwd);
|
|
2493
|
+
if (!root) return {};
|
|
2494
|
+
return {
|
|
2495
|
+
DSH_SERENITY_ROOT: root,
|
|
2496
|
+
DSH_SERENITY_CCC: basename(root),
|
|
2497
|
+
DSH_SERENITY_VERSION: ACC_VERSION
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
function registerEnv(ctx) {
|
|
2501
|
+
ctx.shellEnv.register({
|
|
2502
|
+
name: "dsh-serenity-hooks",
|
|
2503
|
+
variables: {
|
|
2504
|
+
DSH_SERENITY_ROOT: { description: "CCC 根目录(宁静号认知容器)" },
|
|
2505
|
+
DSH_SERENITY_CCC: { description: "CCC 名称(根目录 basename)" },
|
|
2506
|
+
DSH_SERENITY_VERSION: { description: "ACC 插件版本" }
|
|
2507
|
+
},
|
|
2508
|
+
resolve(execution) {
|
|
2509
|
+
return resolveSerenityEnv((execution.agent?.session)?.header?.cwd ?? process.cwd());
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
//#endregion
|
|
2514
|
+
//#region src/skills/opencode-scan.ts
|
|
2515
|
+
/**
|
|
2516
|
+
* opencode-scan.ts — opencode skill 标准扫描(纯逻辑,零 DSH 依赖)
|
|
2517
|
+
*
|
|
2518
|
+
* 兼容 opencode 的 skill 标准:`.opencode/skills/<name>/SKILL.md`
|
|
2519
|
+
* (frontmatter: name/description/whenToUse;结构含 references/、scripts/)。
|
|
2520
|
+
*/
|
|
2521
|
+
/** 解析 --- 分隔的 YAML frontmatter(只需 name/description/whenToUse) */
|
|
2522
|
+
function parseFrontmatter(raw) {
|
|
2523
|
+
const m = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(raw);
|
|
2524
|
+
if (!m) return {
|
|
2525
|
+
meta: {
|
|
2526
|
+
name: "",
|
|
2527
|
+
description: ""
|
|
2528
|
+
},
|
|
2529
|
+
content: raw
|
|
2530
|
+
};
|
|
2531
|
+
const fm = m[1];
|
|
2532
|
+
const body = raw.slice(m[0].length);
|
|
2533
|
+
const grab = (key) => {
|
|
2534
|
+
const line = fm.split("\n").find((l) => l.startsWith(`${key}:`));
|
|
2535
|
+
if (!line) return void 0;
|
|
2536
|
+
return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
2537
|
+
};
|
|
2538
|
+
const meta = {
|
|
2539
|
+
name: grab("name") ?? "",
|
|
2540
|
+
description: grab("description") ?? ""
|
|
2541
|
+
};
|
|
2542
|
+
const whenToUse = grab("whenToUse");
|
|
2543
|
+
if (whenToUse) meta.whenToUse = whenToUse;
|
|
2544
|
+
return {
|
|
2545
|
+
meta,
|
|
2546
|
+
content: body
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
/** 扫描 CCC 的 .opencode/skills(一级目录 + SKILL.md 存在性) */
|
|
2550
|
+
function listOpencodeSkillDirs(root) {
|
|
2551
|
+
const dir = join(root, ".opencode", "skills");
|
|
2552
|
+
if (!existsSync(dir)) return [];
|
|
2553
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(dir, e.name)).filter((d) => existsSync(join(d, "SKILL.md")));
|
|
2554
|
+
}
|
|
2555
|
+
/** 加载一个 opencode skill(frontmatter 解析 + 正文) */
|
|
2556
|
+
function loadOpencodeSkill(skillDir) {
|
|
2557
|
+
const { meta, content } = parseFrontmatter(readFileSync(join(skillDir, "SKILL.md"), "utf-8"));
|
|
2558
|
+
return {
|
|
2559
|
+
name: meta.name,
|
|
2560
|
+
dir: skillDir,
|
|
2561
|
+
meta,
|
|
2562
|
+
content,
|
|
2563
|
+
path: join(skillDir, "SKILL.md")
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
//#endregion
|
|
2567
|
+
//#region src/seams/opencode-skills.ts
|
|
2568
|
+
const OPENCODE_PROVIDER = "opencode-skills";
|
|
2569
|
+
function registerOpencodeSkills(ctx) {
|
|
2570
|
+
const provider = {
|
|
2571
|
+
name: OPENCODE_PROVIDER,
|
|
2572
|
+
async list(options) {
|
|
2573
|
+
const root = findSerenityRoot(options.cwd ?? process.cwd());
|
|
2574
|
+
if (!root) return {
|
|
2575
|
+
candidates: [],
|
|
2576
|
+
complete: true
|
|
2577
|
+
};
|
|
2578
|
+
const candidates = [];
|
|
2579
|
+
for (const dir of listOpencodeSkillDirs(root)) {
|
|
2580
|
+
const skill = loadOpencodeSkill(dir);
|
|
2581
|
+
if (!skill.meta.name) continue;
|
|
2582
|
+
candidates.push({
|
|
2583
|
+
name: skill.meta.name,
|
|
2584
|
+
description: skill.meta.description || "(opencode skill)",
|
|
2585
|
+
...skill.meta.whenToUse ? { whenToUse: skill.meta.whenToUse } : {},
|
|
2586
|
+
invocation: {
|
|
2587
|
+
modelInvocable: true,
|
|
2588
|
+
userInvocable: true
|
|
2589
|
+
},
|
|
2590
|
+
source: "opencode-skills",
|
|
2591
|
+
provider: OPENCODE_PROVIDER,
|
|
2592
|
+
resourceBase: {
|
|
2593
|
+
kind: "directory",
|
|
2594
|
+
path: dir
|
|
2595
|
+
},
|
|
2596
|
+
rank: 250,
|
|
2597
|
+
locator: dir,
|
|
2598
|
+
path: skill.path
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
return {
|
|
2602
|
+
candidates,
|
|
2603
|
+
complete: true
|
|
2604
|
+
};
|
|
2605
|
+
},
|
|
2606
|
+
async get(candidate) {
|
|
2607
|
+
const dir = candidate.locator;
|
|
2608
|
+
const skill = loadOpencodeSkill(dir);
|
|
2609
|
+
return {
|
|
2610
|
+
name: skill.meta.name,
|
|
2611
|
+
description: skill.meta.description || candidate.description,
|
|
2612
|
+
...skill.meta.whenToUse ? { whenToUse: skill.meta.whenToUse } : {},
|
|
2613
|
+
invocation: candidate.invocation,
|
|
2614
|
+
source: candidate.source,
|
|
2615
|
+
provider: OPENCODE_PROVIDER,
|
|
2616
|
+
resourceBase: candidate.resourceBase,
|
|
2617
|
+
content: skill.content,
|
|
2618
|
+
path: skill.path
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
};
|
|
2622
|
+
ctx.skills.registerProvider(() => provider);
|
|
2623
|
+
}
|
|
2624
|
+
//#endregion
|
|
2625
|
+
//#region src/index.ts
|
|
2626
|
+
const name = "dsh-serenity-hooks";
|
|
2627
|
+
/** 主动调用的服务;其余(agent 事件)随 harness 装配必然存在 */
|
|
2628
|
+
const inject = [
|
|
2629
|
+
"tools",
|
|
2630
|
+
"webServer",
|
|
2631
|
+
"sessions",
|
|
2632
|
+
"shellEnv",
|
|
2633
|
+
"skills",
|
|
2634
|
+
"agentLoop",
|
|
2635
|
+
"systemPrompt"
|
|
2636
|
+
];
|
|
2637
|
+
const Config = z.object({
|
|
2638
|
+
serenityConfigPaths: z.array(z.string()).default([...DEFAULT_SERENITY_CONFIG_PATHS]),
|
|
2639
|
+
tools: z.boolean().default(true),
|
|
2640
|
+
guards: z.boolean().default(true),
|
|
2641
|
+
turnFlush: z.boolean().default(true),
|
|
2642
|
+
keeper: z.boolean().default(true),
|
|
2643
|
+
keeperThreshold: z.number().default(150),
|
|
2644
|
+
context: z.boolean().default(true),
|
|
2645
|
+
compactRetention: z.boolean().default(true),
|
|
2646
|
+
api: z.boolean().default(true),
|
|
2647
|
+
entrySkillMaxChars: z.number().default(3e4),
|
|
2648
|
+
env: z.boolean().default(true),
|
|
2649
|
+
opencodeSkills: z.boolean().default(true)
|
|
2650
|
+
});
|
|
2651
|
+
function apply(ctx, config) {
|
|
2652
|
+
if (config.tools) {
|
|
2653
|
+
ctx.tools.register(ccFsTool);
|
|
2654
|
+
ctx.tools.register(sessionTool);
|
|
2655
|
+
ctx.tools.register(kitTool);
|
|
2656
|
+
ctx.tools.register(gitTool);
|
|
2657
|
+
ctx.tools.register(msmTool);
|
|
2658
|
+
ctx.tools.register(eapTool);
|
|
2659
|
+
ctx.tools.register(neatTool);
|
|
2660
|
+
ctx.tools.register(cceTool);
|
|
2661
|
+
ctx.tools.register(createLoopTool(ctx));
|
|
2662
|
+
}
|
|
2663
|
+
if (config.guards) registerGuards(ctx, { configPaths: config.serenityConfigPaths });
|
|
2664
|
+
if (config.turnFlush) registerTurnFlush(ctx);
|
|
2665
|
+
if (config.keeper) registerKeeper(ctx, {
|
|
2666
|
+
configPaths: config.serenityConfigPaths,
|
|
2667
|
+
defaultThreshold: config.keeperThreshold
|
|
2668
|
+
});
|
|
2669
|
+
if (config.context) registerContext(ctx, {
|
|
2670
|
+
configPaths: config.serenityConfigPaths,
|
|
2671
|
+
entrySkillMaxChars: config.entrySkillMaxChars
|
|
2672
|
+
});
|
|
2673
|
+
registerEntrySkillSectionGlobal(ctx);
|
|
2674
|
+
if (config.compactRetention) registerCompactRetention(ctx, {
|
|
2675
|
+
configPaths: config.serenityConfigPaths,
|
|
2676
|
+
entrySkillMaxChars: config.entrySkillMaxChars
|
|
2677
|
+
});
|
|
2678
|
+
if (config.api) registerStatusApi(ctx, { configPaths: config.serenityConfigPaths });
|
|
2679
|
+
if (config.env) registerEnv(ctx);
|
|
2680
|
+
if (config.opencodeSkills) registerOpencodeSkills(ctx);
|
|
2681
|
+
}
|
|
2682
|
+
//#endregion
|
|
2683
|
+
export { Config, apply, inject, name };
|