@shgroup/dsh-serenity-hooks 1.16.13 → 1.17.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/dsh.plugin.json +1 -1
- package/lib/index.js +1426 -528
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import z from "@deepseek-ai/schemastery";
|
|
2
2
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
-
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { homedir, platform } from "node:os";
|
|
@@ -104,8 +104,13 @@ function matchBlacklist(relPath, rules) {
|
|
|
104
104
|
/**
|
|
105
105
|
* fs-ops.ts — cc_fs 纯操作层(零 DSH 依赖,可独立单测)
|
|
106
106
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
107
|
+
* 行为对齐 osp(opencode-serenity-plugin/src/fs/file-system-tool.ts)——osp 是 ACC 工具 spec:
|
|
108
|
+
* - rm:目录需 recursive 才删除;非空目录无 recursive → [SKIP];保护 .serenity 与 CCC 根;dry-run 预览
|
|
109
|
+
* - cp:目录需 recursive;dst 已存在报错;自动建父目录
|
|
110
|
+
* - mv:dst 已存在报错;自动建父目录
|
|
111
|
+
* - list/tree/exists/info/find:输出结构与 osp 一致(JSON 元数据 / 嵌套树 / glob+fuzzy 搜索)
|
|
112
|
+
* - touch:存在更新 mtime、不存在创建(自动建父目录);append:自动建父目录 + 返回字节数
|
|
113
|
+
* 保留 dsp 增强:win32 reveal 用 spawn+unref(explorer GUI 进程退出码不可靠,fire-and-forget)。
|
|
109
114
|
*/
|
|
110
115
|
const CC_FS_ACTIONS = [
|
|
111
116
|
"root",
|
|
@@ -124,9 +129,50 @@ const CC_FS_ACTIONS = [
|
|
|
124
129
|
"info",
|
|
125
130
|
"find"
|
|
126
131
|
];
|
|
132
|
+
function detectFileType(stat) {
|
|
133
|
+
if (stat.isDirectory()) return "dir";
|
|
134
|
+
if (stat.isFile()) return "file";
|
|
135
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
136
|
+
return "other";
|
|
137
|
+
}
|
|
138
|
+
function humanSize(bytes) {
|
|
139
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
140
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
141
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
142
|
+
}
|
|
143
|
+
function getFileInfo(absPath, name) {
|
|
144
|
+
try {
|
|
145
|
+
const stat = statSync(absPath);
|
|
146
|
+
return {
|
|
147
|
+
name,
|
|
148
|
+
type: detectFileType(stat),
|
|
149
|
+
size: stat.size,
|
|
150
|
+
sizeHuman: humanSize(stat.size),
|
|
151
|
+
mtime: stat.mtime.toISOString()
|
|
152
|
+
};
|
|
153
|
+
} catch {
|
|
154
|
+
return {
|
|
155
|
+
name,
|
|
156
|
+
type: "other",
|
|
157
|
+
size: 0,
|
|
158
|
+
sizeHuman: "?",
|
|
159
|
+
mtime: "?"
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
127
163
|
function safeRel(root, abs) {
|
|
128
164
|
return relative(root, abs) || ".";
|
|
129
165
|
}
|
|
166
|
+
function validateWritePath(root, target) {
|
|
167
|
+
const absPath = target.startsWith("/") ? resolve(target) : resolveInside(root, target);
|
|
168
|
+
if (target.startsWith("/") && !absPath.startsWith(root)) throw new Error(`cc-fs: path "${target}" resolves to "${absPath}" which is outside serenity root "${root}"`);
|
|
169
|
+
if (absPath.endsWith("/mech-registry.json") && absPath.includes("/.opencode/skills/")) throw new Error(`cc-fs: refusing to directly modify mech-registry.json — use acc_msm register/deregister instead`);
|
|
170
|
+
return absPath;
|
|
171
|
+
}
|
|
172
|
+
function assertNotProtected(root, absPath, targetLabel) {
|
|
173
|
+
if (absPath === resolve(root, ".serenity")) throw new Error(`cc-fs: refusing to delete protected path: ${targetLabel} (.serenity is the CCC marker)`);
|
|
174
|
+
if (absPath === root) throw new Error(`cc-fs: refusing to delete the CCC root directory: ${targetLabel}`);
|
|
175
|
+
}
|
|
130
176
|
function runCcFs(root, args) {
|
|
131
177
|
const a = args.action;
|
|
132
178
|
switch (a) {
|
|
@@ -134,118 +180,177 @@ function runCcFs(root, args) {
|
|
|
134
180
|
case "resolve":
|
|
135
181
|
if (!args.path) throw new Error("resolve 需要 path");
|
|
136
182
|
return resolveInside(root, args.path);
|
|
137
|
-
case "exists":
|
|
183
|
+
case "exists": {
|
|
138
184
|
if (!args.path) throw new Error("exists 需要 path");
|
|
139
|
-
|
|
185
|
+
const absPath = args.path.startsWith("/") ? resolve(args.path) : resolveInside(root, args.path);
|
|
186
|
+
return existsSync(absPath) ? "true" : "false";
|
|
187
|
+
}
|
|
140
188
|
case "list": {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
const relPath = args.path || ".";
|
|
190
|
+
const absPath = relPath.startsWith("/") ? resolve(relPath) : resolveInside(root, relPath);
|
|
191
|
+
if (!existsSync(absPath)) throw new Error(`list: path "${absPath}" does not exist`);
|
|
192
|
+
const entries = readdirSync(absPath).sort().map((name) => getFileInfo(join(absPath, name), name));
|
|
193
|
+
return {
|
|
194
|
+
path: absPath,
|
|
195
|
+
entries,
|
|
196
|
+
count: entries.length
|
|
197
|
+
};
|
|
147
198
|
}
|
|
148
199
|
case "tree": {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
if (!existsSync(
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
});
|
|
161
|
-
if (e.isDirectory()) walk(full, depth + 1);
|
|
200
|
+
const relPath = args.path || ".";
|
|
201
|
+
const absPath = relPath.startsWith("/") ? resolve(relPath) : resolveInside(root, relPath);
|
|
202
|
+
if (!existsSync(absPath)) throw new Error(`tree: path "${absPath}" does not exist`);
|
|
203
|
+
const maxDepth = args.depth ?? 3;
|
|
204
|
+
const filesOnly = args.filesOnly ?? false;
|
|
205
|
+
const dirsOnly = args.dirsOnly ?? false;
|
|
206
|
+
if (filesOnly && dirsOnly) throw new Error("tree: files-only and dirs-only are mutually exclusive");
|
|
207
|
+
const filterTree = (entries, keepType) => entries.filter((e) => {
|
|
208
|
+
if (e.type === keepType) {
|
|
209
|
+
if (e.children) e.children = filterTree(e.children, keepType);
|
|
210
|
+
return true;
|
|
162
211
|
}
|
|
212
|
+
return false;
|
|
213
|
+
});
|
|
214
|
+
const walk = (dir, currentDepth) => {
|
|
215
|
+
if (currentDepth > maxDepth) return [];
|
|
216
|
+
return readdirSync(dir).sort().map((name) => {
|
|
217
|
+
const full = join(dir, name);
|
|
218
|
+
const stat = statSync(full);
|
|
219
|
+
const entry = {
|
|
220
|
+
name,
|
|
221
|
+
type: detectFileType(stat),
|
|
222
|
+
size: stat.size,
|
|
223
|
+
sizeHuman: humanSize(stat.size)
|
|
224
|
+
};
|
|
225
|
+
if (stat.isDirectory()) entry.children = walk(full, currentDepth + 1);
|
|
226
|
+
return entry;
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
let entries = walk(absPath, 1);
|
|
230
|
+
if (filesOnly) entries = filterTree(entries, "file");
|
|
231
|
+
if (dirsOnly) entries = filterTree(entries, "dir");
|
|
232
|
+
return {
|
|
233
|
+
path: absPath,
|
|
234
|
+
entries,
|
|
235
|
+
maxDepth
|
|
163
236
|
};
|
|
164
|
-
walk(dir, 0);
|
|
165
|
-
return out;
|
|
166
237
|
}
|
|
167
|
-
case "relative":
|
|
238
|
+
case "relative": {
|
|
168
239
|
if (!args.path) throw new Error("relative 需要 path");
|
|
169
|
-
|
|
240
|
+
const absPath = args.path.startsWith("/") ? resolve(args.path) : resolveInside(root, args.path);
|
|
241
|
+
if (!absPath.startsWith(root)) throw new Error(`relative: path "${args.path}" resolves to "${absPath}" which is outside serenity root "${root}"`);
|
|
242
|
+
return safeRel(root, absPath);
|
|
243
|
+
}
|
|
170
244
|
case "mkdir": {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
};
|
|
245
|
+
if (!args.path) throw new Error("mkdir 需要 path");
|
|
246
|
+
const absPath = validateWritePath(root, args.path);
|
|
247
|
+
if (existsSync(absPath)) {
|
|
248
|
+
if (statSync(absPath).isDirectory()) return `directory already exists: ${args.path}`;
|
|
249
|
+
throw new Error(`mkdir: path "${args.path}" exists but is not a directory`);
|
|
250
|
+
}
|
|
251
|
+
mkdirSync(absPath, { recursive: true });
|
|
252
|
+
return `created directory: ${args.path}`;
|
|
178
253
|
}
|
|
179
254
|
case "rm": {
|
|
180
|
-
const targets = args.paths
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
255
|
+
const targets = [...args.paths ?? []];
|
|
256
|
+
if (args.path) targets.push(args.path);
|
|
257
|
+
if (targets.length === 0) throw new Error("rm 需要至少一个 path 参数(path 或 paths)");
|
|
258
|
+
const dryRun = args.dryRun ?? false;
|
|
259
|
+
const recursive = args.recursive ?? false;
|
|
260
|
+
const results = [];
|
|
261
|
+
for (const target of targets) {
|
|
262
|
+
const absPath = validateWritePath(root, target);
|
|
263
|
+
if (!existsSync(absPath)) {
|
|
264
|
+
results.push(`[SKIP] not found: ${target}`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const isDir = statSync(absPath).isDirectory();
|
|
268
|
+
try {
|
|
269
|
+
assertNotProtected(root, absPath, target);
|
|
270
|
+
} catch (e) {
|
|
271
|
+
results.push(`[SKIP] ${e.message}`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const relLabel = safeRel(root, absPath);
|
|
275
|
+
if (dryRun) {
|
|
276
|
+
const extra = isDir ? recursive ? " (recursive)" : "" : "";
|
|
277
|
+
results.push(`[DRY-RUN] ${isDir ? "directory" : "file"}: ${relLabel}${extra}`);
|
|
189
278
|
continue;
|
|
190
279
|
}
|
|
191
|
-
|
|
280
|
+
if (isDir && !recursive) {
|
|
281
|
+
const entries = readdirSync(absPath);
|
|
282
|
+
if (entries.length > 0) {
|
|
283
|
+
results.push(`[SKIP] directory not empty (${entries.length} items), use recursive: ${relLabel}`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
rmdirSync(absPath);
|
|
287
|
+
} else if (isDir) rmSync(absPath, {
|
|
192
288
|
recursive: true,
|
|
193
|
-
force:
|
|
289
|
+
force: false
|
|
194
290
|
});
|
|
195
|
-
|
|
291
|
+
else unlinkSync(absPath);
|
|
292
|
+
results.push(`[OK] deleted: ${relLabel}`);
|
|
196
293
|
}
|
|
197
|
-
return
|
|
198
|
-
ok: true,
|
|
199
|
-
removed
|
|
200
|
-
};
|
|
294
|
+
return results.join("\n");
|
|
201
295
|
}
|
|
202
|
-
case "mv":
|
|
296
|
+
case "mv": {
|
|
203
297
|
if (!args.src || !args.dst) throw new Error("mv 需要 src + dst");
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
};
|
|
210
|
-
|
|
298
|
+
const srcAbs = validateWritePath(root, args.src);
|
|
299
|
+
const dstAbs = validateWritePath(root, args.dst);
|
|
300
|
+
if (!existsSync(srcAbs)) throw new Error(`mv: source not found: ${args.src}`);
|
|
301
|
+
if (existsSync(dstAbs)) throw new Error(`mv: destination already exists: ${args.dst}`);
|
|
302
|
+
const parentDir = dirname(dstAbs);
|
|
303
|
+
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
304
|
+
renameSync(srcAbs, dstAbs);
|
|
305
|
+
return `moved: ${args.src} → ${args.dst}`;
|
|
306
|
+
}
|
|
307
|
+
case "cp": {
|
|
211
308
|
if (!args.src || !args.dst) throw new Error("cp 需要 src + dst");
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
309
|
+
const srcAbs = validateWritePath(root, args.src);
|
|
310
|
+
const dstAbs = validateWritePath(root, args.dst);
|
|
311
|
+
if (!existsSync(srcAbs)) throw new Error(`cp: source not found: ${args.src}`);
|
|
312
|
+
if (existsSync(dstAbs)) throw new Error(`cp: destination already exists: ${args.dst}`);
|
|
313
|
+
if (statSync(srcAbs).isDirectory() && !(args.recursive ?? false)) throw new Error(`cp: source is a directory, use recursive to copy: ${args.src}`);
|
|
314
|
+
const parentDir = dirname(dstAbs);
|
|
315
|
+
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
316
|
+
cpSync(srcAbs, dstAbs, { recursive: args.recursive ?? false });
|
|
317
|
+
return `copied: ${args.src} → ${args.dst}`;
|
|
318
|
+
}
|
|
218
319
|
case "touch": {
|
|
219
320
|
if (!args.path) throw new Error("touch 需要 path");
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
321
|
+
const absPath = validateWritePath(root, args.path);
|
|
322
|
+
if (existsSync(absPath)) {
|
|
323
|
+
const now = /* @__PURE__ */ new Date();
|
|
324
|
+
utimesSync(absPath, now, now);
|
|
325
|
+
return `updated timestamp: ${args.path}`;
|
|
326
|
+
}
|
|
327
|
+
const parentDir = dirname(absPath);
|
|
328
|
+
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
329
|
+
writeFileSync(absPath, "", "utf-8");
|
|
330
|
+
return `created empty file: ${args.path}`;
|
|
226
331
|
}
|
|
227
332
|
case "append": {
|
|
228
333
|
if (!args.path || args.content === void 0) throw new Error("append 需要 path + content");
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
334
|
+
const absPath = validateWritePath(root, args.path);
|
|
335
|
+
const parentDir = dirname(absPath);
|
|
336
|
+
if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
|
|
337
|
+
const content = args.content;
|
|
338
|
+
appendFileSync(absPath, content, "utf-8");
|
|
339
|
+
return `appended ${Buffer.byteLength(content, "utf8")} bytes to ${args.path}`;
|
|
235
340
|
}
|
|
236
341
|
case "reveal": {
|
|
237
342
|
if (!args.path) throw new Error("reveal 需要 path");
|
|
238
|
-
const
|
|
239
|
-
if (!existsSync(
|
|
343
|
+
const absPath = resolveInside(root, args.path);
|
|
344
|
+
if (!existsSync(absPath)) throw new Error(`no such path: ${absPath}`);
|
|
240
345
|
const os = platform();
|
|
241
346
|
try {
|
|
242
|
-
if (os === "darwin") execFileSync("open", ["-R",
|
|
347
|
+
if (os === "darwin") execFileSync("open", ["-R", absPath], { timeout: 1e4 });
|
|
243
348
|
else if (os === "linux") {
|
|
244
|
-
const revealPath = statSync(
|
|
349
|
+
const revealPath = statSync(absPath).isDirectory() ? absPath : dirname(absPath);
|
|
245
350
|
execFileSync("xdg-open", [revealPath], { timeout: 1e4 });
|
|
246
351
|
} else if (os === "win32") {
|
|
247
|
-
const
|
|
248
|
-
const child = spawn("explorer",
|
|
352
|
+
const winArgs = statSync(absPath).isDirectory() ? [absPath] : ["/select,", absPath];
|
|
353
|
+
const child = spawn("explorer", winArgs, {
|
|
249
354
|
detached: true,
|
|
250
355
|
stdio: "ignore",
|
|
251
356
|
windowsHide: false
|
|
@@ -253,46 +358,78 @@ function runCcFs(root, args) {
|
|
|
253
358
|
child.on("error", () => {});
|
|
254
359
|
child.unref();
|
|
255
360
|
} else throw new Error(`unsupported platform: ${os}`);
|
|
256
|
-
return {
|
|
257
|
-
ok: true,
|
|
258
|
-
revealed: safeRel(root, abs)
|
|
259
|
-
};
|
|
361
|
+
return `revealed in file manager: ${args.path}`;
|
|
260
362
|
} catch (err) {
|
|
261
363
|
const msg = err instanceof Error ? err.message : String(err);
|
|
262
|
-
throw new Error(`reveal failed to open "${
|
|
364
|
+
throw new Error(`reveal failed to open "${args.path}": ${msg}`);
|
|
263
365
|
}
|
|
264
366
|
}
|
|
265
367
|
case "info": {
|
|
266
368
|
if (!args.path) throw new Error("info 需要 path");
|
|
267
|
-
const
|
|
268
|
-
if (!existsSync(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
369
|
+
const absPath = args.path.startsWith("/") ? resolve(args.path) : resolveInside(root, args.path);
|
|
370
|
+
if (!existsSync(absPath)) throw new Error(`info: path "${absPath}" does not exist`);
|
|
371
|
+
const stat = statSync(absPath);
|
|
372
|
+
const fileType = detectFileType(stat);
|
|
373
|
+
const modeStr = stat.mode.toString(8).slice(-4);
|
|
374
|
+
return [
|
|
375
|
+
`path: ${safeRel(root, absPath)}`,
|
|
376
|
+
`type: ${fileType}`,
|
|
377
|
+
`size: ${stat.size} (${humanSize(stat.size)})`,
|
|
378
|
+
`mtime: ${stat.mtime.toISOString()}`,
|
|
379
|
+
`mode: ${modeStr}`,
|
|
380
|
+
`uid: ${stat.uid}`,
|
|
381
|
+
`gid: ${stat.gid}`
|
|
382
|
+
].join("\n");
|
|
280
383
|
}
|
|
281
384
|
case "find": {
|
|
282
385
|
if (!args.pattern) throw new Error("find 需要 pattern");
|
|
386
|
+
const relPath = args.path || ".";
|
|
387
|
+
const absPath = relPath.startsWith("/") ? resolve(relPath) : resolveInside(root, relPath);
|
|
388
|
+
if (!existsSync(absPath)) throw new Error(`find: path "${absPath}" does not exist`);
|
|
283
389
|
const pattern = args.pattern;
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
390
|
+
const absolutePaths = args.absolute ?? false;
|
|
391
|
+
const maxDepth = args.maxDepth ?? -1;
|
|
392
|
+
const hasGlobChars = /[*?]/.test(pattern);
|
|
393
|
+
const matchFilename = (name) => {
|
|
394
|
+
if (hasGlobChars) {
|
|
395
|
+
const regexStr = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$";
|
|
396
|
+
try {
|
|
397
|
+
return new RegExp(regexStr).test(name);
|
|
398
|
+
} catch {
|
|
399
|
+
return name.includes(pattern);
|
|
400
|
+
}
|
|
292
401
|
}
|
|
402
|
+
return name.toLowerCase().includes(pattern.toLowerCase());
|
|
403
|
+
};
|
|
404
|
+
const matches = [];
|
|
405
|
+
const walkFind = (dir, depth) => {
|
|
406
|
+
if (maxDepth >= 0 && depth > maxDepth) return;
|
|
407
|
+
let names;
|
|
408
|
+
try {
|
|
409
|
+
names = readdirSync(dir);
|
|
410
|
+
} catch {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
for (const name of names.sort()) {
|
|
414
|
+
const full = join(dir, name);
|
|
415
|
+
let stat;
|
|
416
|
+
try {
|
|
417
|
+
stat = statSync(full);
|
|
418
|
+
} catch {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (matchFilename(name)) matches.push(absolutePaths ? full : safeRel(root, full));
|
|
422
|
+
if (stat.isDirectory()) walkFind(full, depth + 1);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
walkFind(absPath, 1);
|
|
426
|
+
matches.sort();
|
|
427
|
+
return {
|
|
428
|
+
path: absPath,
|
|
429
|
+
pattern,
|
|
430
|
+
matches,
|
|
431
|
+
count: matches.length
|
|
293
432
|
};
|
|
294
|
-
walk(root);
|
|
295
|
-
return out;
|
|
296
433
|
}
|
|
297
434
|
default: throw new Error(`未知 action: ${a}`);
|
|
298
435
|
}
|
|
@@ -347,15 +484,35 @@ const ccFsTool = defineTool({
|
|
|
347
484
|
},
|
|
348
485
|
pattern: {
|
|
349
486
|
type: "string",
|
|
350
|
-
description: "find
|
|
487
|
+
description: "find 匹配(glob *? 或大小写不敏感子串)"
|
|
351
488
|
},
|
|
352
489
|
depth: {
|
|
353
490
|
type: "integer",
|
|
354
|
-
description: "tree
|
|
491
|
+
description: "tree 最大深度(1-10,默认 3)"
|
|
355
492
|
},
|
|
356
493
|
dryRun: {
|
|
357
494
|
type: "boolean",
|
|
358
|
-
description: "rm 预览模式"
|
|
495
|
+
description: "rm/archive 预览模式"
|
|
496
|
+
},
|
|
497
|
+
recursive: {
|
|
498
|
+
type: "boolean",
|
|
499
|
+
description: "rm 删目录 / cp 复制目录需 recursive"
|
|
500
|
+
},
|
|
501
|
+
filesOnly: {
|
|
502
|
+
type: "boolean",
|
|
503
|
+
description: "tree 只显示文件(与 dirsOnly 互斥)"
|
|
504
|
+
},
|
|
505
|
+
dirsOnly: {
|
|
506
|
+
type: "boolean",
|
|
507
|
+
description: "tree 只显示目录(与 filesOnly 互斥)"
|
|
508
|
+
},
|
|
509
|
+
absolute: {
|
|
510
|
+
type: "boolean",
|
|
511
|
+
description: "find 返回绝对路径"
|
|
512
|
+
},
|
|
513
|
+
maxDepth: {
|
|
514
|
+
type: "integer",
|
|
515
|
+
description: "find 最大递归深度(缺省不限)"
|
|
359
516
|
}
|
|
360
517
|
},
|
|
361
518
|
output: {
|
|
@@ -369,6 +526,21 @@ const ccFsTool = defineTool({
|
|
|
369
526
|
}
|
|
370
527
|
});
|
|
371
528
|
//#endregion
|
|
529
|
+
//#region src/constants.ts
|
|
530
|
+
/** 常量(纯模块,零 DSH 依赖) */
|
|
531
|
+
/**
|
|
532
|
+
* ACC 版本:自动从 package.json 读取(单一真相源,消除与 CHANGELOG 的漂移)。
|
|
533
|
+
* 发布时只需改 package.json 的 version。
|
|
534
|
+
*/
|
|
535
|
+
const ACC_VERSION = (() => {
|
|
536
|
+
try {
|
|
537
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
538
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version ?? "0.0.0";
|
|
539
|
+
} catch {
|
|
540
|
+
return "0.0.0";
|
|
541
|
+
}
|
|
542
|
+
})();
|
|
543
|
+
//#endregion
|
|
372
544
|
//#region src/msm-ops.ts
|
|
373
545
|
/**
|
|
374
546
|
* msm-ops.ts — acc_msm 纯操作层(零 DSH 依赖)
|
|
@@ -378,6 +550,37 @@ const ccFsTool = defineTool({
|
|
|
378
550
|
*/
|
|
379
551
|
const execFileAsync = promisify(execFile);
|
|
380
552
|
const MSM_TIMEOUT_MS = 6e5;
|
|
553
|
+
/** CCC 名:从 .serenity 首行解析(对齐 osp readSerenityCccName) */
|
|
554
|
+
function readCccName$1(root) {
|
|
555
|
+
try {
|
|
556
|
+
return readFileSync(resolve(root, ".serenity"), "utf-8").trim().split("\n")[0]?.trim() || null;
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* path-arg 逃逸校验:根内 + symlink 防御(对齐 osp validatePathArgsFromTokens)。
|
|
563
|
+
* symlink 指向根外 → 拒绝(realpath 解析后与根前缀比对)。
|
|
564
|
+
*/
|
|
565
|
+
function assertPathInsideRoot(root, value, flagName) {
|
|
566
|
+
const abs = resolve(root, value);
|
|
567
|
+
if (classifyPath(abs, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value} 越出 CCC 根`);
|
|
568
|
+
if (existsSync(abs)) try {
|
|
569
|
+
const real = realpathSync(abs);
|
|
570
|
+
if (classifyPath(real, root) === "outside") throw new Error(`Path escape blocked: --${flagName}=${value} 经 symlink 指向根外 (${real})`);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
if (e instanceof Error && e.message.includes("symlink")) throw e;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** 业务子进程 env:注入 SERENITY_ROOT / SERENITY_CCC / SERENITY_VERSION(对齐 osp) */
|
|
576
|
+
function buildMsmEnv(root) {
|
|
577
|
+
return {
|
|
578
|
+
...process.env,
|
|
579
|
+
SERENITY_ROOT: root,
|
|
580
|
+
SERENITY_CCC: readCccName$1(root) ?? "",
|
|
581
|
+
SERENITY_VERSION: ACC_VERSION
|
|
582
|
+
};
|
|
583
|
+
}
|
|
381
584
|
/**
|
|
382
585
|
* Windows 兼容(审计观察点 A):`.cmd` 不能直接被 CreateProcess 解析——
|
|
383
586
|
* `execFile('npx')` / `spawnSync('npx')` 在 Windows 必 ENOENT(需 shell 或显式 .cmd)。
|
|
@@ -390,26 +593,91 @@ const MSM_ACTIONS = [
|
|
|
390
593
|
"register",
|
|
391
594
|
"deregister",
|
|
392
595
|
"check",
|
|
393
|
-
"guide"
|
|
596
|
+
"guide",
|
|
597
|
+
"ccc-config"
|
|
394
598
|
];
|
|
395
599
|
const MSM_GUIDE = `MSM 开发手册(Mech & Semi-Mech 框架)
|
|
396
600
|
|
|
397
601
|
## 是什么
|
|
398
602
|
MSM = 可执行单元层。Mech 纯 TS 零 LLM 推理;Semi-Mech TS 框架 + LLM 决策点。
|
|
603
|
+
MSM 是 ACC 的确定性可执行单元层——所有 shell/exec 操作走 MSM,不可绕过。
|
|
399
604
|
|
|
400
605
|
## 注册新 MSM(acc_msm register)
|
|
401
606
|
1. 在 <skill>/scripts/ 写脚本(tsx 可跑;必须带 main() CLI 守卫 import.meta.url 检查)
|
|
402
607
|
2. acc_msm register <name> --skill <s> --path <脚本相对根路径> --category <mech|semi-mech> --description <desc>
|
|
403
|
-
3. 自动写入 mech-registry.json
|
|
608
|
+
3. 自动写入 mech-registry.json(保留原格式)+ git commit(只提交注册表文件)
|
|
609
|
+
4. 校验:path 必须根内、脚本必须存在、name 全局唯一
|
|
610
|
+
|
|
611
|
+
## flag schema(v1)
|
|
612
|
+
flags 是 new-style 对象数组,用于参数校验与 path 逃逸守卫:
|
|
613
|
+
[{"name":"output","type":"string","description":"输出路径"},
|
|
614
|
+
{"name":"target","type":"path","description":"操作目标(type:path 启用根内校验 + symlink 防御)"},
|
|
615
|
+
{"name":"force","type":"boolean","description":"强制模式","default":false}]
|
|
616
|
+
- {name, type} 格式 — new style,type:"path" 启用 path-escape 守卫
|
|
617
|
+
- {flag, description} 格式 — old style,CLI flag 描述字符串
|
|
618
|
+
- 注册时 flags 经 acc_msm register --flags '<json>' 传入(工具当前解析 name 风格)
|
|
404
619
|
|
|
405
620
|
## 脚本约定
|
|
406
621
|
- 顶部文档:用途/用法/退出码
|
|
407
|
-
- 退出码:0 成功 / 1 user / 2 system / 3 operator
|
|
408
|
-
-
|
|
409
|
-
|
|
622
|
+
- 退出码:0 成功 / 1 user / 2 system / 3 operator(对齐 ACC 协议分类)
|
|
623
|
+
- main() CLI 守卫(DC-M2):脚本顶部必须有
|
|
624
|
+
if (import.meta.url === \`file://\${process.argv[1]}\`) { main() }
|
|
625
|
+
或等价 isMain / require.main === 判断——vitest import 时不触发顶层代码
|
|
626
|
+
- 配对 .test.ts 或 .spec.ts(DC-M1,vitest)
|
|
627
|
+
- 业务子进程环境:注入 SERENITY_ROOT / SERENITY_CCC / SERENITY_VERSION
|
|
628
|
+
|
|
629
|
+
## 品质检查(acc_msm check,DC-M1~M4)
|
|
630
|
+
DC-M1 有 .test.ts/.spec.ts;DC-M2 有 main() 守卫(function main( / isMain / require.main === / import.meta.url);
|
|
631
|
+
DC-M3 双向:脚本未注册 + 注册表引用脚本缺失;DC-M4 路径型 flag 标记 type:"path"
|
|
410
632
|
|
|
411
|
-
##
|
|
412
|
-
|
|
633
|
+
## 自描述(协议 flag,仅限参数首位)
|
|
634
|
+
acc_msm exec <name> --list — 列出全部 MSM
|
|
635
|
+
acc_msm exec <name> --schema <n> — 查看某 MSM 的参数 schema
|
|
636
|
+
acc_msm exec <name> --format=json — JSON 输出模式(其余参数无损透传)
|
|
637
|
+
`;
|
|
638
|
+
/** CCC 配置参考(对齐 osp ccc-config action) */
|
|
639
|
+
const CCC_CONFIG_REFERENCE = `═══ CCC Configuration Reference ═══
|
|
640
|
+
|
|
641
|
+
CCC-level features are configured in .opencode/serenity.json.
|
|
642
|
+
Below are all available configuration sections.
|
|
643
|
+
|
|
644
|
+
── 1. loop.defaultModel ──
|
|
645
|
+
loop 工具缺省模型(未配置且未传 model 参数时 loop 报错)。
|
|
646
|
+
|
|
647
|
+
Config:
|
|
648
|
+
{ "loop": { "defaultModel": "provider/model-name" } }
|
|
649
|
+
|
|
650
|
+
Example:
|
|
651
|
+
{ "loop": { "defaultModel": "minimax-cn-coding-plan/MiniMax-M3" } }
|
|
652
|
+
|
|
653
|
+
── 2. sessionKeeper.threshold ──
|
|
654
|
+
SESSION-KEEPER 提醒机制的积分阈值(非 headless 主 agent)。
|
|
655
|
+
按工具调用加权 + 耗时计分;达到阈值注入提醒,要求模型回复 ACK 码。
|
|
656
|
+
|
|
657
|
+
Config:
|
|
658
|
+
{ "sessionKeeper": { "threshold": 150 } }
|
|
659
|
+
|
|
660
|
+
计分:write/edit = 3,task = 10,read/grep/glob/msm 等 = 1,时间 = 1/分钟
|
|
661
|
+
默认:150
|
|
662
|
+
|
|
663
|
+
── 3. localstore.gitTrack ──
|
|
664
|
+
localstore.json 的 git 策略:allow 可提交 / deny 禁提交(默认 deny)。
|
|
665
|
+
deny 时写入自动确保 .gitignore 含该文件(物理保证),cc_git commit 会检查拒绝。
|
|
666
|
+
|
|
667
|
+
Config:
|
|
668
|
+
{ "localstore": { "gitTrack": "allow" } }
|
|
669
|
+
|
|
670
|
+
── 4. hooks.autoRestoreSession ──
|
|
671
|
+
会话自动恢复(默认 true;受 events 门控——仅根会话 + 已有对话历史才恢复)。
|
|
672
|
+
|
|
673
|
+
Config:
|
|
674
|
+
{ "hooks": { "autoRestoreSession": false } }
|
|
675
|
+
|
|
676
|
+
── 5. safeMode / blacklist ──
|
|
677
|
+
safe-mode 由 WebUI 开关控制(写 .serenity-safe-on 标记);黑名单路径受 guards seam 拦截。
|
|
678
|
+
|
|
679
|
+
Config:
|
|
680
|
+
{ "safeMode": { "blacklist": [".secrets/"] } }
|
|
413
681
|
`;
|
|
414
682
|
function parseRegistry(raw) {
|
|
415
683
|
const data = JSON.parse(raw);
|
|
@@ -437,28 +705,47 @@ function loadMsmEntries(root) {
|
|
|
437
705
|
function findEntry(root, name) {
|
|
438
706
|
return loadMsmEntries(root).find((e) => e.name === name) ?? null;
|
|
439
707
|
}
|
|
708
|
+
/** 扫描各 skill scripts/ 下的非测试脚本(DC-M3 正向基准,对齐 osp) */
|
|
709
|
+
function scanSkillScripts(root) {
|
|
710
|
+
const out = [];
|
|
711
|
+
const skillsDir = join(root, ".opencode", "skills");
|
|
712
|
+
if (!existsSync(skillsDir)) return out;
|
|
713
|
+
for (const skill of readdirSync(skillsDir)) {
|
|
714
|
+
const scriptsDir = join(skillsDir, skill, "scripts");
|
|
715
|
+
if (!existsSync(scriptsDir)) continue;
|
|
716
|
+
for (const f of readdirSync(scriptsDir)) if (/\.(ts|js|mjs)$/.test(f) && !/\.(test|spec)\./.test(f)) out.push(join(".opencode", "skills", skill, "scripts", f));
|
|
717
|
+
}
|
|
718
|
+
return out.sort();
|
|
719
|
+
}
|
|
440
720
|
function registryPathFor(root, skill) {
|
|
441
721
|
return skill ? join(root, ".opencode", "skills", skill, "references", "mech-registry.json") : join(root, "mech-registry.json");
|
|
442
722
|
}
|
|
443
|
-
function writeRegistry(path, entries) {
|
|
723
|
+
function writeRegistry(path, entries, isV1Wrapped = true) {
|
|
444
724
|
mkdirSync(dirname(path), { recursive: true });
|
|
445
|
-
|
|
725
|
+
const payload = isV1Wrapped ? JSON.stringify({
|
|
446
726
|
version: 1,
|
|
447
727
|
description: "MSM registry (managed by acc-msm / dsh-serenity-hooks)",
|
|
448
728
|
entries
|
|
449
|
-
}, null, 2) + "\n", "
|
|
729
|
+
}, null, 2) + "\n" : JSON.stringify(entries, null, 2) + "\n";
|
|
730
|
+
writeFileSync(path, payload, "utf-8");
|
|
450
731
|
}
|
|
451
732
|
function runMsm(root, args) {
|
|
452
733
|
switch (args.action) {
|
|
453
|
-
case "list":
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
734
|
+
case "list": {
|
|
735
|
+
const entries = loadMsmEntries(root);
|
|
736
|
+
const cccName = readCccName$1(root) ?? "unknown";
|
|
737
|
+
const header = `(serenity-plugin v${ACC_VERSION}) CCC:${cccName} Root:${root}`;
|
|
738
|
+
if (entries.length === 0) return `${header}\n(no MSM registered)`;
|
|
739
|
+
const lines = entries.map((e) => {
|
|
740
|
+
const base = `${e.name} | ${e.skill ?? "-"} | ${e.category ?? "-"} | ${e.description ?? ""}`;
|
|
741
|
+
if (e.flags && e.flags.length > 0) return `${base} [flags: ${e.flags.map((f) => `--${f.name} <${f.type ?? "string"}>`).join(", ")}]`;
|
|
742
|
+
return base;
|
|
743
|
+
});
|
|
744
|
+
return `${header}\n` + lines.join("\n");
|
|
745
|
+
}
|
|
459
746
|
case "guide": return { guide: MSM_GUIDE };
|
|
460
747
|
case "exec": {
|
|
461
|
-
const { entry, businessArgs, fmtJson, protocol } = prepareExec(root, args);
|
|
748
|
+
const { entry, businessArgs, fmtJson, hasHelp, protocol } = prepareExec(root, args);
|
|
462
749
|
const p = protocolResult(protocol);
|
|
463
750
|
if (p !== void 0) return p;
|
|
464
751
|
let r = spawnSync("bun", [entry.path, ...businessArgs], {
|
|
@@ -469,7 +756,8 @@ function runMsm(root, args) {
|
|
|
469
756
|
"pipe",
|
|
470
757
|
"pipe",
|
|
471
758
|
"pipe"
|
|
472
|
-
]
|
|
759
|
+
],
|
|
760
|
+
env: buildMsmEnv(root)
|
|
473
761
|
});
|
|
474
762
|
if (r.error && r.error.code === "ENOENT") r = spawnSync(NPX_BIN, [
|
|
475
763
|
"tsx",
|
|
@@ -483,36 +771,55 @@ function runMsm(root, args) {
|
|
|
483
771
|
"pipe",
|
|
484
772
|
"pipe",
|
|
485
773
|
"pipe"
|
|
486
|
-
]
|
|
774
|
+
],
|
|
775
|
+
env: buildMsmEnv(root)
|
|
487
776
|
});
|
|
488
|
-
return msmExecResult(entry.name, r.status ?? 2, r.stdout ?? "", r.stderr ?? "", fmtJson);
|
|
777
|
+
return msmExecResult(entry.name, r.status ?? 2, r.stdout ?? "", r.stderr ?? "", fmtJson, hasHelp);
|
|
489
778
|
}
|
|
490
779
|
case "register": {
|
|
491
780
|
const name = args.name ?? "";
|
|
492
781
|
const { skill, path, category, description } = args;
|
|
782
|
+
if (!name) throw new Error("register 需要 name");
|
|
493
783
|
if (!path || !category || !description) throw new Error("register 需要 path/category/description");
|
|
784
|
+
const scriptAbs = resolve(root, path);
|
|
785
|
+
if (classifyPath(scriptAbs, root) === "outside") throw new Error(`MSM register: path "${path}" escapes CCC root "${root}"`);
|
|
786
|
+
if (!existsSync(scriptAbs)) throw new Error(`MSM script not found: "${path}"`);
|
|
787
|
+
if (loadMsmEntries(root).some((e) => e.name === name)) throw new Error(`MSM already registered: "${name}"`);
|
|
494
788
|
const regPath = registryPathFor(root, skill);
|
|
789
|
+
const isV1Wrapped = existsSync(regPath) && !Array.isArray(JSON.parse(readFileSync(regPath, "utf-8")));
|
|
495
790
|
const entries = existsSync(regPath) ? parseRegistry(readFileSync(regPath, "utf-8")) : [];
|
|
496
|
-
|
|
791
|
+
let flags;
|
|
792
|
+
if (args.flags) try {
|
|
793
|
+
const parsed = JSON.parse(args.flags);
|
|
794
|
+
if (!Array.isArray(parsed)) throw new Error("flags must be a JSON array");
|
|
795
|
+
flags = parsed;
|
|
796
|
+
} catch (e) {
|
|
797
|
+
throw new Error(`register flags 解析失败:${e instanceof Error ? e.message : String(e)}`);
|
|
798
|
+
}
|
|
497
799
|
entries.push({
|
|
498
800
|
name,
|
|
499
801
|
path,
|
|
500
802
|
skill,
|
|
501
803
|
category,
|
|
502
804
|
description,
|
|
503
|
-
usage: `
|
|
504
|
-
flags: []
|
|
805
|
+
usage: args.usage ?? `acc_msm exec ${name} [args...]`,
|
|
806
|
+
flags: flags ?? []
|
|
505
807
|
});
|
|
506
|
-
writeRegistry(regPath, entries);
|
|
808
|
+
writeRegistry(regPath, entries, isV1Wrapped);
|
|
507
809
|
try {
|
|
508
|
-
|
|
810
|
+
const relRegistry = relative(root, regPath);
|
|
811
|
+
execFileSync("git", [
|
|
812
|
+
"add",
|
|
813
|
+
"--",
|
|
814
|
+
relRegistry
|
|
815
|
+
], {
|
|
509
816
|
cwd: root,
|
|
510
817
|
stdio: "pipe"
|
|
511
818
|
});
|
|
512
819
|
execFileSync("git", [
|
|
513
820
|
"commit",
|
|
514
821
|
"-m",
|
|
515
|
-
`msm: register ${name}`
|
|
822
|
+
`chore(msm): register ${name}`
|
|
516
823
|
], {
|
|
517
824
|
cwd: root,
|
|
518
825
|
stdio: "pipe"
|
|
@@ -526,20 +833,26 @@ function runMsm(root, args) {
|
|
|
526
833
|
case "deregister": {
|
|
527
834
|
const name = args.name ?? "";
|
|
528
835
|
for (const regPath of findRegistries(root)) {
|
|
836
|
+
const isV1Wrapped = !Array.isArray(JSON.parse(readFileSync(regPath, "utf-8")));
|
|
529
837
|
const entries = parseRegistry(readFileSync(regPath, "utf-8"));
|
|
530
838
|
const idx = entries.findIndex((e) => e.name === name);
|
|
531
839
|
if (idx >= 0) {
|
|
532
840
|
entries.splice(idx, 1);
|
|
533
|
-
writeRegistry(regPath, entries);
|
|
841
|
+
writeRegistry(regPath, entries, isV1Wrapped);
|
|
534
842
|
try {
|
|
535
|
-
|
|
843
|
+
const relRegistry = relative(root, regPath);
|
|
844
|
+
execFileSync("git", [
|
|
845
|
+
"add",
|
|
846
|
+
"--",
|
|
847
|
+
relRegistry
|
|
848
|
+
], {
|
|
536
849
|
cwd: root,
|
|
537
850
|
stdio: "pipe"
|
|
538
851
|
});
|
|
539
852
|
execFileSync("git", [
|
|
540
853
|
"commit",
|
|
541
854
|
"-m",
|
|
542
|
-
`msm: deregister ${name}`
|
|
855
|
+
`chore(msm): deregister ${name}`
|
|
543
856
|
], {
|
|
544
857
|
cwd: root,
|
|
545
858
|
stdio: "pipe"
|
|
@@ -550,9 +863,16 @@ function runMsm(root, args) {
|
|
|
550
863
|
}
|
|
551
864
|
throw new Error(`MSM not registered: "${name}"`);
|
|
552
865
|
}
|
|
866
|
+
case "ccc-config": return CCC_CONFIG_REFERENCE;
|
|
553
867
|
case "check": {
|
|
554
868
|
const entries = loadMsmEntries(root);
|
|
555
869
|
const issues = [];
|
|
870
|
+
const registeredPaths = new Set(entries.map((e) => e.path));
|
|
871
|
+
for (const scriptPath of scanSkillScripts(root)) if (!registeredPaths.has(scriptPath)) issues.push({
|
|
872
|
+
name: scriptPath,
|
|
873
|
+
check: "M3",
|
|
874
|
+
detail: "script not registered in mech-registry"
|
|
875
|
+
});
|
|
556
876
|
for (const e of entries) {
|
|
557
877
|
const script = join(root, e.path);
|
|
558
878
|
const scriptExists = existsSync(script);
|
|
@@ -561,16 +881,25 @@ function runMsm(root, args) {
|
|
|
561
881
|
check: "M3",
|
|
562
882
|
detail: `script missing (${e.path})`
|
|
563
883
|
});
|
|
564
|
-
const
|
|
565
|
-
|
|
884
|
+
const testFileTs = script.replace(/\.ts$/, ".test.ts");
|
|
885
|
+
const testFileSpec = script.replace(/\.ts$/, ".spec.ts");
|
|
886
|
+
if (!existsSync(testFileTs) && !existsSync(testFileSpec)) issues.push({
|
|
566
887
|
name: e.name,
|
|
567
888
|
check: "M1",
|
|
568
|
-
detail: "no .test.ts"
|
|
889
|
+
detail: "no .test.ts / .spec.ts"
|
|
569
890
|
});
|
|
570
|
-
if (scriptExists
|
|
891
|
+
if (scriptExists) {
|
|
892
|
+
const src = readFileSync(script, "utf-8");
|
|
893
|
+
if (!(/function main\(/.test(src) || /\bisMain\b/.test(src) || /require\.main\s*===/.test(src) || /import\.meta\.url/.test(src))) issues.push({
|
|
894
|
+
name: e.name,
|
|
895
|
+
check: "M2",
|
|
896
|
+
detail: "no main() guard"
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
for (const f of e.flags ?? []) if ("name" in f && /path|file|dir/i.test(f.name) && f.type !== "path") issues.push({
|
|
571
900
|
name: e.name,
|
|
572
|
-
check: "
|
|
573
|
-
detail:
|
|
901
|
+
check: "M4",
|
|
902
|
+
detail: `flag --${f.name} should be type:"path"`
|
|
574
903
|
});
|
|
575
904
|
}
|
|
576
905
|
return {
|
|
@@ -586,24 +915,25 @@ function prepareExec(root, args) {
|
|
|
586
915
|
const entry = findEntry(root, name);
|
|
587
916
|
if (!entry) throw new Error(`MSM not registered: "${name}"`);
|
|
588
917
|
const business = args.args ?? [];
|
|
589
|
-
if (business
|
|
918
|
+
if (business[0] === "--list") return {
|
|
590
919
|
entry,
|
|
591
920
|
businessArgs: [],
|
|
592
921
|
fmtJson: false,
|
|
922
|
+
hasHelp: false,
|
|
593
923
|
protocol: { list: loadMsmEntries(root).map((e) => ({
|
|
594
924
|
name: e.name,
|
|
595
925
|
category: e.category ?? null
|
|
596
926
|
})) }
|
|
597
927
|
};
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const target = business[schemaIdx + 1];
|
|
928
|
+
if (business[0] === "--schema") {
|
|
929
|
+
const target = business[1];
|
|
601
930
|
const found = target ? loadMsmEntries(root).find((e) => e.name === target) : null;
|
|
602
931
|
if (!found) throw new Error(`MSM not registered: "${target}"`);
|
|
603
932
|
return {
|
|
604
933
|
entry,
|
|
605
934
|
businessArgs: [],
|
|
606
935
|
fmtJson: false,
|
|
936
|
+
hasHelp: false,
|
|
607
937
|
protocol: { schema: {
|
|
608
938
|
name: found.name,
|
|
609
939
|
path: found.path,
|
|
@@ -615,20 +945,16 @@ function prepareExec(root, args) {
|
|
|
615
945
|
} }
|
|
616
946
|
};
|
|
617
947
|
}
|
|
618
|
-
const fmtJson = business
|
|
619
|
-
const businessArgs = business.
|
|
948
|
+
const fmtJson = business[0] === "--format=json";
|
|
949
|
+
const businessArgs = fmtJson ? business.slice(1) : business;
|
|
950
|
+
const hasHelp = businessArgs.includes("--help") || businessArgs.includes("-h");
|
|
620
951
|
for (const flag of entry.flags ?? []) {
|
|
621
952
|
if (flag.type !== "path") continue;
|
|
622
953
|
const eq = businessArgs.find((a) => a.startsWith(`--${flag.name}=`));
|
|
623
|
-
if (eq)
|
|
624
|
-
|
|
625
|
-
if (classifyPath(resolve(root, value), root) === "outside") throw new Error(`Path escape blocked: --${flag.name}=${value} 越出 CCC 根`);
|
|
626
|
-
} else {
|
|
954
|
+
if (eq) assertPathInsideRoot(root, eq.slice(flag.name.length + 3), flag.name);
|
|
955
|
+
else {
|
|
627
956
|
const idx = businessArgs.indexOf(`--${flag.name}`);
|
|
628
|
-
if (idx >= 0 && businessArgs[idx + 1])
|
|
629
|
-
const value = businessArgs[idx + 1];
|
|
630
|
-
if (classifyPath(resolve(root, value), root) === "outside") throw new Error(`Path escape blocked: --${flag.name} ${value} 越出 CCC 根`);
|
|
631
|
-
}
|
|
957
|
+
if (idx >= 0 && businessArgs[idx + 1]) assertPathInsideRoot(root, businessArgs[idx + 1], flag.name);
|
|
632
958
|
}
|
|
633
959
|
}
|
|
634
960
|
const script = resolve(root, entry.path);
|
|
@@ -640,7 +966,8 @@ function prepareExec(root, args) {
|
|
|
640
966
|
path: script
|
|
641
967
|
},
|
|
642
968
|
businessArgs,
|
|
643
|
-
fmtJson
|
|
969
|
+
fmtJson,
|
|
970
|
+
hasHelp
|
|
644
971
|
};
|
|
645
972
|
}
|
|
646
973
|
/** 协议结果扁平化:{list|schema} 包装 → 顶层值(兼容旧契约);非协议返回 undefined */
|
|
@@ -649,7 +976,12 @@ function protocolResult(protocol) {
|
|
|
649
976
|
if ("list" in protocol) return protocol.list;
|
|
650
977
|
if ("schema" in protocol) return protocol.schema;
|
|
651
978
|
}
|
|
652
|
-
|
|
979
|
+
/** 失败 TIP(对齐 osp:业务 exit≠0 且未传 --help 时追加提示) */
|
|
980
|
+
function helpTip() {
|
|
981
|
+
return "\n[TIP] Pass \"--help\" as the first arg to see this MSM's usage and required flags.";
|
|
982
|
+
}
|
|
983
|
+
function msmExecResult(name, status, stdout, stderr, fmtJson, hasHelp = false) {
|
|
984
|
+
const tip = status !== 0 && !hasHelp ? helpTip() : "";
|
|
653
985
|
if (fmtJson) return status === 0 ? {
|
|
654
986
|
name,
|
|
655
987
|
exit: 0,
|
|
@@ -659,13 +991,13 @@ function msmExecResult(name, status, stdout, stderr, fmtJson) {
|
|
|
659
991
|
name,
|
|
660
992
|
exit: status,
|
|
661
993
|
ok: false,
|
|
662
|
-
error: stderr.trim() || stdout.trim()
|
|
994
|
+
error: (stderr.trim() || stdout.trim()) + tip
|
|
663
995
|
};
|
|
664
996
|
return {
|
|
665
997
|
name,
|
|
666
998
|
exit: status,
|
|
667
999
|
stdout,
|
|
668
|
-
stderr
|
|
1000
|
+
stderr: stderr + tip
|
|
669
1001
|
};
|
|
670
1002
|
}
|
|
671
1003
|
/**
|
|
@@ -675,7 +1007,7 @@ function msmExecResult(name, status, stdout, stderr, fmtJson) {
|
|
|
675
1007
|
*/
|
|
676
1008
|
async function runMsmAsync(root, args) {
|
|
677
1009
|
if (args.action !== "exec") return runMsm(root, args);
|
|
678
|
-
const { entry, businessArgs, fmtJson, protocol } = prepareExec(root, args);
|
|
1010
|
+
const { entry, businessArgs, fmtJson, hasHelp, protocol } = prepareExec(root, args);
|
|
679
1011
|
const p = protocolResult(protocol);
|
|
680
1012
|
if (p !== void 0) return p;
|
|
681
1013
|
try {
|
|
@@ -683,9 +1015,10 @@ async function runMsmAsync(root, args) {
|
|
|
683
1015
|
cwd: root,
|
|
684
1016
|
encoding: "utf-8",
|
|
685
1017
|
timeout: MSM_TIMEOUT_MS,
|
|
686
|
-
maxBuffer: 67108864
|
|
1018
|
+
maxBuffer: 67108864,
|
|
1019
|
+
env: buildMsmEnv(root)
|
|
687
1020
|
});
|
|
688
|
-
return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson);
|
|
1021
|
+
return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson, hasHelp);
|
|
689
1022
|
} catch (e) {
|
|
690
1023
|
const err = e;
|
|
691
1024
|
if (err.code === "ENOENT") try {
|
|
@@ -697,20 +1030,21 @@ async function runMsmAsync(root, args) {
|
|
|
697
1030
|
cwd: root,
|
|
698
1031
|
encoding: "utf-8",
|
|
699
1032
|
timeout: MSM_TIMEOUT_MS,
|
|
700
|
-
maxBuffer: 67108864
|
|
1033
|
+
maxBuffer: 67108864,
|
|
1034
|
+
env: buildMsmEnv(root)
|
|
701
1035
|
});
|
|
702
|
-
return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson);
|
|
1036
|
+
return msmExecResult(entry.name, 0, r.stdout, r.stderr, fmtJson, hasHelp);
|
|
703
1037
|
} catch (e2) {
|
|
704
1038
|
const err2 = e2;
|
|
705
1039
|
const status = err2.killed ? 124 : typeof err2.code === "number" ? err2.code : 2;
|
|
706
1040
|
const stdout = err2.stdout ?? "";
|
|
707
1041
|
const stderr = err2.killed ? `MSM timed out after ${MSM_TIMEOUT_MS}ms` : err2.stderr ?? err2.message ?? "";
|
|
708
|
-
return msmExecResult(entry.name, status, stdout, stderr, fmtJson);
|
|
1042
|
+
return msmExecResult(entry.name, status, stdout, stderr, fmtJson, hasHelp);
|
|
709
1043
|
}
|
|
710
1044
|
const status = err.killed ? 124 : typeof err.code === "number" ? err.code : 2;
|
|
711
1045
|
const stdout = err.stdout ?? "";
|
|
712
1046
|
const stderr = err.killed ? `MSM timed out after ${MSM_TIMEOUT_MS}ms` : err.stderr ?? err.message ?? "";
|
|
713
|
-
return msmExecResult(entry.name, status, stdout, stderr, fmtJson);
|
|
1047
|
+
return msmExecResult(entry.name, status, stdout, stderr, fmtJson, hasHelp);
|
|
714
1048
|
}
|
|
715
1049
|
}
|
|
716
1050
|
//#endregion
|
|
@@ -718,8 +1052,13 @@ async function runMsmAsync(root, args) {
|
|
|
718
1052
|
/**
|
|
719
1053
|
* session-ops.ts — session 工具纯操作层(零 DSH 依赖,可独立单测)
|
|
720
1054
|
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
1055
|
+
* 行为对齐 osp(opencode-serenity-plugin/src/session/lib.ts)——osp 是 ACC 工具 spec:
|
|
1056
|
+
* - create:--desc / --issue 二选一(互斥、缺省报错);issue 模式目录 YYYY-MM-DD--<issue>
|
|
1057
|
+
* (无 S###,sessionId=issue);desc 模式 YYYY-MM-DD--S###--<desc>;goal 写入目标段;dry-run 预览
|
|
1058
|
+
* - close:需 name + confirm=true;标记 [x] 已完成+已关闭 + 进度记录"关闭"
|
|
1059
|
+
* - archive:name 缺省 → 批量归档(completed + ≥7 天 → 移动 _archived/);单会话需 completed + grace
|
|
1060
|
+
* - list/show/health/qa/summary:文本输出格式与 osp 一致
|
|
1061
|
+
* 保留 dsp S134 活跃会话机制(内存 Map + events 恢复,不落盘)——osp 同为内存 active-state。
|
|
723
1062
|
*/
|
|
724
1063
|
const SESSION_ACTIONS = [
|
|
725
1064
|
"list",
|
|
@@ -730,245 +1069,551 @@ const SESSION_ACTIONS = [
|
|
|
730
1069
|
"health",
|
|
731
1070
|
"qa",
|
|
732
1071
|
"archive",
|
|
733
|
-
"summary"
|
|
1072
|
+
"summary",
|
|
1073
|
+
"hook-develop-guide"
|
|
734
1074
|
];
|
|
735
|
-
const
|
|
1075
|
+
const SESSION_MD = "SESSION.md";
|
|
1076
|
+
const ARCHIVE_DIR_NAME = "_archived";
|
|
1077
|
+
const HEALTH_STALE_DAYS = 7;
|
|
1078
|
+
const HEALTH_STALLED_PCT = 30;
|
|
1079
|
+
const HEALTH_STALLED_DAYS = 3;
|
|
1080
|
+
const HEALTH_GHOST_DAYS = 2;
|
|
736
1081
|
const DAY = 864e5;
|
|
737
|
-
function today() {
|
|
738
|
-
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
739
|
-
}
|
|
740
1082
|
function sessionsRoot(root) {
|
|
741
1083
|
return join(root, "AGENT_SESSIONS");
|
|
742
1084
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1085
|
+
/** 解析 SESSION.md 状态元数据(对齐 osp parseSessionMd) */
|
|
1086
|
+
function parseSessionMd(filePath) {
|
|
1087
|
+
try {
|
|
1088
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1089
|
+
return {
|
|
1090
|
+
hasSessionMd: true,
|
|
1091
|
+
completed: /\[\s*x\s*\]/i.test(content),
|
|
1092
|
+
completedCount: (content.match(/\[\s*x\s*\]/gi) ?? []).length,
|
|
1093
|
+
pendingCount: (content.match(/\[\s*[ \t]\s*\]/g) ?? []).length,
|
|
1094
|
+
unresolvedCount: (content.match(/(未解决|open|question|TODO)/gi) ?? []).length
|
|
1095
|
+
};
|
|
1096
|
+
} catch {
|
|
1097
|
+
return {
|
|
1098
|
+
hasSessionMd: false,
|
|
1099
|
+
completed: false,
|
|
1100
|
+
completedCount: 0,
|
|
1101
|
+
pendingCount: 0,
|
|
1102
|
+
unresolvedCount: 0
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function readSessionEntry(dirPath) {
|
|
1107
|
+
try {
|
|
1108
|
+
const st = statSync(dirPath);
|
|
1109
|
+
if (!st.isDirectory()) return null;
|
|
1110
|
+
const dirName = basename(dirPath);
|
|
1111
|
+
const mdPath = join(dirPath, SESSION_MD);
|
|
1112
|
+
const status = existsSync(mdPath) ? parseSessionMd(mdPath) : {
|
|
1113
|
+
hasSessionMd: false,
|
|
1114
|
+
completed: false,
|
|
1115
|
+
completedCount: 0,
|
|
1116
|
+
pendingCount: 0,
|
|
1117
|
+
unresolvedCount: 0
|
|
1118
|
+
};
|
|
1119
|
+
return {
|
|
1120
|
+
dirName,
|
|
1121
|
+
path: dirPath,
|
|
1122
|
+
mtime: st.mtime,
|
|
759
1123
|
status
|
|
760
|
-
}
|
|
1124
|
+
};
|
|
1125
|
+
} catch {
|
|
1126
|
+
return null;
|
|
761
1127
|
}
|
|
762
|
-
out.sort((a, b) => a.dir < b.dir ? 1 : -1);
|
|
763
|
-
return out;
|
|
764
1128
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1129
|
+
/** 读取 AGENT_SESSIONS 中所有会话,活跃(未完成)排前(对齐 osp readAllSessions) */
|
|
1130
|
+
function readAllSessions(sessionsDir) {
|
|
1131
|
+
try {
|
|
1132
|
+
return readdirSync(sessionsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => readSessionEntry(join(sessionsDir, e.name))).filter((s) => s !== null).sort((a, b) => {
|
|
1133
|
+
if (!a.status.completed && b.status.completed) return -1;
|
|
1134
|
+
if (a.status.completed && !b.status.completed) return 1;
|
|
1135
|
+
return b.mtime.getTime() - a.mtime.getTime();
|
|
1136
|
+
});
|
|
1137
|
+
} catch {
|
|
1138
|
+
return [];
|
|
770
1139
|
}
|
|
771
|
-
return `S${String(max + 1).padStart(3, "0")}`;
|
|
772
1140
|
}
|
|
773
|
-
|
|
774
|
-
|
|
1141
|
+
/** 提取目录名中的会话 ID(S### 或 issue 名);无匹配返回 '' */
|
|
1142
|
+
function extractSessionId(dirName) {
|
|
1143
|
+
const m = dirName.match(/--S(\d{3,})--/);
|
|
1144
|
+
return m ? `S${m[1]}` : "";
|
|
775
1145
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
return
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1146
|
+
/**
|
|
1147
|
+
* 根据 key 查找会话(对齐 osp findSession):
|
|
1148
|
+
* 精确目录名 → S### ID(允许 S31→031)→ 唯一模糊子串匹配(多个则报错)
|
|
1149
|
+
*/
|
|
1150
|
+
function findSession(sessionsDir, key) {
|
|
1151
|
+
const all = readAllSessions(sessionsDir);
|
|
1152
|
+
const byName = all.find((s) => s.dirName === key);
|
|
1153
|
+
if (byName) return byName;
|
|
1154
|
+
const searchId = key.replace(/^S/, "").padStart(3, "0");
|
|
1155
|
+
const byId = all.find((s) => {
|
|
1156
|
+
const m = s.dirName.match(/--S(\d{3,})--/);
|
|
1157
|
+
return m && m[1] === searchId;
|
|
1158
|
+
});
|
|
1159
|
+
if (byId) return byId;
|
|
1160
|
+
const lower = key.toLowerCase();
|
|
1161
|
+
const fuzzy = all.filter((s) => s.dirName.toLowerCase().includes(lower));
|
|
1162
|
+
if (fuzzy.length === 1) return fuzzy[0] ?? null;
|
|
1163
|
+
if (fuzzy.length > 1) throw new Error(`Found ${fuzzy.length} sessions matching "${key}": ` + fuzzy.map((s) => s.dirName).join(", ") + ". Use a more specific query.");
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
/** list 子命令(对齐 osp listSessions 文本格式 + active 标记) */
|
|
1167
|
+
function listSessions(root, activeId) {
|
|
1168
|
+
const sessions = readAllSessions(sessionsRoot(root));
|
|
1169
|
+
if (sessions.length === 0) return "(no sessions in AGENT_SESSIONS/)";
|
|
1170
|
+
const lines = sessions.map((s) => {
|
|
1171
|
+
const age = Math.floor((Date.now() - s.mtime.getTime()) / DAY);
|
|
1172
|
+
const sessionId = extractSessionId(s.dirName);
|
|
1173
|
+
return `${activeId !== void 0 && sessionId !== "" && sessionId === activeId ? "●" : s.status.completed ? "✓" : "○"} ${s.dirName} (${age}d ago)`;
|
|
1174
|
+
});
|
|
1175
|
+
return `AGENT_SESSIONS/ (${sessions.length} sessions)\n` + lines.join("\n");
|
|
788
1176
|
}
|
|
1177
|
+
/** show 子命令(对齐 osp showSession:`# dirName\n\n` + SESSION.md 内容) */
|
|
789
1178
|
function showSession(root, key) {
|
|
790
|
-
const
|
|
791
|
-
if (!
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1179
|
+
const session = findSession(sessionsRoot(root), key);
|
|
1180
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
1181
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
1182
|
+
if (!existsSync(mdPath)) return `Session ${session.dirName} (no SESSION.md — directory exists but is empty)`;
|
|
1183
|
+
const content = readFileSync(mdPath, "utf-8");
|
|
1184
|
+
return `# ${session.dirName}\n\n${content}`;
|
|
1185
|
+
}
|
|
1186
|
+
/** 生成 SESSION.md 模板(对齐 osp:goal 写入目标段,时间戳 YYYY-MM-DD HH:mm) */
|
|
1187
|
+
function sessionMdTemplate(title, id, goal, now) {
|
|
1188
|
+
const ts = now.toISOString().slice(0, 16).replace("T", " ");
|
|
1189
|
+
return `# SESSION: ${title}\n- ID: ${id}\n\n## 目标\n${goal ?? "(待补充)"}\n\n## 状态\n- [ ] 进行中\n\n## 关键决策\n| # | 决策 | 理由 |\n|---|------|------|\n| 1 | | |\n\n## 进度记录\n- ${ts} — 创建\n\n## 产出物\n- \n\n## 未解决的问题\n- \n`;
|
|
1190
|
+
}
|
|
1191
|
+
/** create 子命令(对齐 osp createSession:--desc/--issue 二选一 + dry-run + 长度限制) */
|
|
1192
|
+
function createSession(opts) {
|
|
1193
|
+
const { root, desc, issue, goal, dryRun } = opts;
|
|
1194
|
+
const sessionsDir = sessionsRoot(root);
|
|
1195
|
+
const now = /* @__PURE__ */ new Date();
|
|
1196
|
+
const datePrefix = now.toISOString().slice(0, 10);
|
|
1197
|
+
if (!desc && !issue) throw new Error("create requires either --desc or --issue");
|
|
1198
|
+
if (desc && issue) throw new Error("--desc and --issue are mutually exclusive");
|
|
1199
|
+
if (issue) {
|
|
1200
|
+
if (issue.length > 100) throw new Error(`issue too long: ${issue.length} chars (max 100)`);
|
|
1201
|
+
const dirName = `${datePrefix}--${issue}`;
|
|
1202
|
+
const sessionPath = join(sessionsDir, dirName);
|
|
1203
|
+
if (!dryRun && existsSync(sessionPath)) throw new Error(`Session directory already exists: "${dirName}"`);
|
|
1204
|
+
if (dryRun) return {
|
|
1205
|
+
message: `[dry-run] Would create: ${dirName}/`,
|
|
1206
|
+
dirName,
|
|
1207
|
+
sessionPath,
|
|
1208
|
+
sessionId: issue
|
|
1209
|
+
};
|
|
1210
|
+
mkdirSync(sessionPath, { recursive: true });
|
|
1211
|
+
writeFileSync(join(sessionPath, SESSION_MD), sessionMdTemplate(issue, issue, goal, now), "utf-8");
|
|
1212
|
+
return {
|
|
1213
|
+
message: `Created: ${dirName}/`,
|
|
1214
|
+
dirName,
|
|
1215
|
+
sessionPath,
|
|
1216
|
+
sessionId: issue
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (!desc || desc.length === 0) throw new Error("description cannot be empty");
|
|
1220
|
+
if (desc.length > 200) throw new Error(`description too long: ${desc.length} chars (max 200)`);
|
|
1221
|
+
const sessions = readAllSessions(sessionsDir);
|
|
1222
|
+
let maxId = 0;
|
|
1223
|
+
for (const s of sessions) {
|
|
1224
|
+
const m = s.dirName.match(/--S(\d{3,})--/);
|
|
1225
|
+
if (m) {
|
|
1226
|
+
const num = parseInt(m[1], 10);
|
|
1227
|
+
if (num > maxId) maxId = num;
|
|
798
1228
|
}
|
|
799
|
-
throw new Error(`未找到会话: ${key}`);
|
|
800
1229
|
}
|
|
801
|
-
const
|
|
802
|
-
|
|
1230
|
+
const nextId = String(maxId + 1).padStart(3, "0");
|
|
1231
|
+
const dirName = `${datePrefix}--S${nextId}--${desc}`;
|
|
1232
|
+
const sessionPath = join(sessionsDir, dirName);
|
|
1233
|
+
if (!dryRun && existsSync(sessionPath)) throw new Error(`Session directory already exists: "${dirName}"`);
|
|
1234
|
+
if (dryRun) return {
|
|
1235
|
+
message: `[dry-run] Would create: ${dirName}/\n goal=${goal ?? "(none)"}`,
|
|
1236
|
+
dirName,
|
|
1237
|
+
sessionPath,
|
|
1238
|
+
sessionId: `S${nextId}`
|
|
1239
|
+
};
|
|
1240
|
+
mkdirSync(sessionPath, { recursive: true });
|
|
1241
|
+
writeFileSync(join(sessionPath, SESSION_MD), sessionMdTemplate(desc, `S${nextId}`, goal, now), "utf-8");
|
|
803
1242
|
return {
|
|
804
|
-
|
|
805
|
-
|
|
1243
|
+
message: `Created: ${dirName}/ (S${nextId})`,
|
|
1244
|
+
dirName,
|
|
1245
|
+
sessionPath,
|
|
1246
|
+
sessionId: `S${nextId}`
|
|
806
1247
|
};
|
|
807
1248
|
}
|
|
808
1249
|
/**
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
* 读取不再回退(隔离优先;升级后重新 use 一次即可)。
|
|
1250
|
+
* 活动会话跟踪(S134 v1.16.14 内存化,对齐 osp active-state):
|
|
1251
|
+
* **不落盘**——活跃会话状态在内存 Map(key = scope = dsh 会话 id),避免落盘标记
|
|
1252
|
+
* 文件累积与跨会话串台(落盘版 `.dsh/active-sessions/<scope>` 已被此方案取代)。
|
|
1253
|
+
* 进程重启恢复:从**当前会话历史(events)**解析 `[SESSION CONTEXT]` 标记(use 时注入),
|
|
1254
|
+
* 只扫自己会话——无全局扫描、无跨会话污染。
|
|
815
1255
|
*/
|
|
816
|
-
const ACTIVE_SESSIONS_DIR = join(".dsh", "active-sessions");
|
|
817
|
-
/** 旧版全局标记路径(v1.16.1 及以前 use 写入;新版本仅清理不再读取) */
|
|
818
|
-
const LEGACY_ACTIVE_SESSION_MARKER = join(".dsh", "active-session");
|
|
819
|
-
/** 默认 scope(agent.session.id 缺失时) */
|
|
820
1256
|
const DEFAULT_SESSION_SCOPE = "default";
|
|
821
|
-
/**
|
|
822
|
-
|
|
823
|
-
|
|
1257
|
+
/** [SESSION CONTEXT] 恢复标记(use 时注入 events 历史;进程重启后从 events 解析) */
|
|
1258
|
+
const SESSION_CONTEXT_MARKER = "[SESSION CONTEXT] Activated:";
|
|
1259
|
+
/** 内存活跃会话:scope(dsh 会话 id)→ 会话信息(不落盘;并行多会话各自 key 隔离) */
|
|
1260
|
+
const activeStore = /* @__PURE__ */ new Map();
|
|
1261
|
+
/** 全局最近活跃(对齐 osp lastActive;供无 scope 上下文使用) */
|
|
1262
|
+
let lastActive = null;
|
|
1263
|
+
function getActiveSessionInfo(scope) {
|
|
1264
|
+
return activeStore.get(scope) ?? null;
|
|
1265
|
+
}
|
|
1266
|
+
function setActiveSessionInfo(scope, info) {
|
|
1267
|
+
activeStore.set(scope, info);
|
|
1268
|
+
lastActive = info;
|
|
1269
|
+
}
|
|
1270
|
+
function clearActiveSessionInfo(scope) {
|
|
1271
|
+
activeStore.delete(scope);
|
|
1272
|
+
if (lastActive && ![...activeStore.values()].some((v) => v === lastActive)) lastActive = null;
|
|
1273
|
+
}
|
|
1274
|
+
/** 当前 scope 的活跃会话 SESSION.md 绝对路径;无激活返回 null(读内存,不落盘) */
|
|
1275
|
+
function readActiveSessionMd(_root, scope = DEFAULT_SESSION_SCOPE) {
|
|
1276
|
+
return getActiveSessionInfo(scope)?.mdPath ?? null;
|
|
824
1277
|
}
|
|
825
|
-
/**
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1278
|
+
/**
|
|
1279
|
+
* use 子命令:激活会话(写内存 Map)+ 返回对齐 osp 的输出文本
|
|
1280
|
+
* (含 [SESSION CONTEXT] 标记 + todowrite 指令;标记随工具结果进 events 历史,
|
|
1281
|
+
* 进程重启后从当前会话 events 解析恢复)。
|
|
1282
|
+
*/
|
|
830
1283
|
function useSession(root, key, scope = DEFAULT_SESSION_SCOPE) {
|
|
831
|
-
const
|
|
832
|
-
if (!
|
|
833
|
-
const
|
|
834
|
-
if (!existsSync(
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1284
|
+
const session = findSession(sessionsRoot(root), key);
|
|
1285
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
1286
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
1287
|
+
if (!existsSync(mdPath)) throw new Error(`Session "${session.dirName}" has no SESSION.md — nothing to load.`);
|
|
1288
|
+
const sessionId = extractSessionId(session.dirName) || basename(session.dirName);
|
|
1289
|
+
const dirName = session.dirName;
|
|
1290
|
+
const shortName = dirName.replace(/^\d{4}-\d{2}-\d{2}--/, "");
|
|
1291
|
+
setActiveSessionInfo(scope, {
|
|
1292
|
+
sessionId,
|
|
1293
|
+
dirName,
|
|
1294
|
+
mdPath
|
|
1295
|
+
});
|
|
841
1296
|
return {
|
|
842
|
-
dir:
|
|
843
|
-
mdPath
|
|
1297
|
+
dir: dirName,
|
|
1298
|
+
mdPath,
|
|
1299
|
+
context: [
|
|
1300
|
+
`───────────────────────────────────────────────────────────────`,
|
|
1301
|
+
`${SESSION_CONTEXT_MARKER} ${dirName}`,
|
|
1302
|
+
`───────────────────────────────────────────────────────────────`,
|
|
1303
|
+
`Use "session show ${sessionId}" to view session details.`,
|
|
1304
|
+
`SESSION.md path: ${mdPath}`,
|
|
1305
|
+
``,
|
|
1306
|
+
`→ All subsequent work should refer back to this session.`,
|
|
1307
|
+
` Use "session show ${sessionId}" to check current progress.`,
|
|
1308
|
+
` After advancing work, update the "进度记录" (progress) section in SESSION.md.`,
|
|
1309
|
+
``,
|
|
1310
|
+
`→ BEFORE responding to the user, you MUST call todowrite immediately`,
|
|
1311
|
+
` with the session todo list. The first item MUST be:`,
|
|
1312
|
+
` content: "SESSION: ${sessionId} — ${shortName}"`,
|
|
1313
|
+
` status: "completed", priority: "low"`,
|
|
1314
|
+
` Follow with any tasks parsed from SESSION.md.`,
|
|
1315
|
+
`───────────────────────────────────────────────────────────────`
|
|
1316
|
+
].join("\n")
|
|
844
1317
|
};
|
|
845
1318
|
}
|
|
846
|
-
/**
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
}
|
|
864
|
-
/** 列出全部 scope 的活动标记(含 scope / mdRel / mtime);目录不存在返回空 */
|
|
865
|
-
function listActiveMarkers(root) {
|
|
866
|
-
const dir = resolve(root, ACTIVE_SESSIONS_DIR);
|
|
867
|
-
if (!existsSync(dir)) return [];
|
|
868
|
-
const out = [];
|
|
869
|
-
for (const entry of readdirSync(dir)) {
|
|
870
|
-
const full = join(dir, entry);
|
|
871
|
-
if (!statSync(full).isFile()) continue;
|
|
872
|
-
try {
|
|
873
|
-
const rel = readFileSync(full, "utf-8").trim();
|
|
874
|
-
if (!rel) continue;
|
|
875
|
-
out.push({
|
|
876
|
-
scope: entry,
|
|
877
|
-
mdRel: rel,
|
|
878
|
-
mtime: statSync(full).mtimeMs
|
|
879
|
-
});
|
|
880
|
-
} catch {}
|
|
881
|
-
}
|
|
882
|
-
return out;
|
|
1319
|
+
/**
|
|
1320
|
+
* close 子命令(对齐 osp closeSession):需 name + confirm=true;
|
|
1321
|
+
* 标记 SESSION.md 为 [x] 已完成 + [x] 已关闭 + 进度记录"关闭";清除该会话的活跃状态。
|
|
1322
|
+
*/
|
|
1323
|
+
function closeSession(root, key, confirm, scope = DEFAULT_SESSION_SCOPE) {
|
|
1324
|
+
if (!confirm) return "⚠ Close requires explicit confirmation.\n Re-run with --confirm to confirm closing this session.";
|
|
1325
|
+
const session = findSession(sessionsRoot(root), key);
|
|
1326
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
1327
|
+
if (session.status.completed) return `Session "${session.dirName}" is already completed.`;
|
|
1328
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
1329
|
+
if (!existsSync(mdPath)) throw new Error(`Session "${session.dirName}" has no SESSION.md — nothing to close.`);
|
|
1330
|
+
let content = readFileSync(mdPath, "utf-8");
|
|
1331
|
+
content = content.replace(/## 状态\n\n?- \[ \] 进行中/, "## 状态\n- [x] 已完成\n- [x] 已关闭");
|
|
1332
|
+
const now = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
|
|
1333
|
+
if (!content.includes("-- 关闭")) content = content.replace(/(## 进度记录\n)/, `$1- ${now} — 关闭\n`);
|
|
1334
|
+
writeFileSync(mdPath, content, "utf-8");
|
|
1335
|
+
clearActiveSessionInfo(scope);
|
|
1336
|
+
return `Session "${session.dirName}" closed and marked as completed.`;
|
|
883
1337
|
}
|
|
884
1338
|
/**
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
* 返回恢复的会话信息;已有标记 / 无候选 / 全部越界 → null。
|
|
888
|
-
* 调用方负责根会话判定(subagent / loop 牛马不恢复——见 context.ts shouldAutoRestore)。
|
|
1339
|
+
* 从会话历史(events)解析最后一条 [SESSION CONTEXT] 标记(进程重启恢复;
|
|
1340
|
+
* 只扫**当前会话**自己的历史——无跨会话串台)。
|
|
889
1341
|
*/
|
|
890
|
-
function
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
from: best.scope
|
|
911
|
-
};
|
|
1342
|
+
function parseSessionContextFromEvents(events) {
|
|
1343
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1344
|
+
const strs = [];
|
|
1345
|
+
collectStrings(events[i], strs);
|
|
1346
|
+
for (const s of strs) {
|
|
1347
|
+
const idx = s.indexOf(SESSION_CONTEXT_MARKER);
|
|
1348
|
+
if (idx < 0) continue;
|
|
1349
|
+
const dirName = s.slice(idx + 28).trim().split("\n")[0]?.trim() ?? "";
|
|
1350
|
+
const mdMatch = s.match(/SESSION\.md path:\s*(\S+)/);
|
|
1351
|
+
if (/^\d{4}-\d{2}-\d{2}--/.test(dirName) && mdMatch) {
|
|
1352
|
+
const idMatch = dirName.match(/--S(\d{3,})--/);
|
|
1353
|
+
return {
|
|
1354
|
+
sessionId: idMatch ? `S${idMatch[1]}` : basename(dirName),
|
|
1355
|
+
dirName,
|
|
1356
|
+
mdPath: mdMatch[1]
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return null;
|
|
912
1362
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
if (
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
if (!/\[x\]|\[X\]/.test(content)) content = content.replace(/^## 状态$/m, "## 状态\n- [x] 已完成");
|
|
921
|
-
writeFileSync(md, content, "utf-8");
|
|
922
|
-
appendFileSync(md, `\n> 已归档: ${today()}\n`, "utf-8");
|
|
923
|
-
return { dir: target.dir };
|
|
1363
|
+
/** 递归收集对象/数组/字符串中的全部字符串(保留原文,无 JSON 转义) */
|
|
1364
|
+
function collectStrings(v, out) {
|
|
1365
|
+
if (typeof v === "string") {
|
|
1366
|
+
out.push(v);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (v && typeof v === "object") for (const val of Object.values(v)) collectStrings(val, out);
|
|
924
1370
|
}
|
|
1371
|
+
/** health 子命令(对齐 osp healthCheck:stale/stalled/ghost/drift 四类检查,文本输出) */
|
|
925
1372
|
function healthCheck(root) {
|
|
926
|
-
const
|
|
1373
|
+
const sessions = readAllSessions(sessionsRoot(root));
|
|
1374
|
+
if (sessions.length === 0) return "No sessions found — nothing to check.";
|
|
927
1375
|
const now = Date.now();
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1376
|
+
const issues = [];
|
|
1377
|
+
for (const s of sessions) {
|
|
1378
|
+
const ageDays = (now - s.mtime.getTime()) / DAY;
|
|
1379
|
+
const st = s.status;
|
|
1380
|
+
if (ageDays > HEALTH_STALE_DAYS && !st.completed) issues.push({
|
|
1381
|
+
dirName: s.dirName,
|
|
1382
|
+
issue: `No activity for ${Math.floor(ageDays)}d`,
|
|
1383
|
+
severity: "stale"
|
|
1384
|
+
});
|
|
1385
|
+
const totalTasks = st.completedCount + st.pendingCount;
|
|
1386
|
+
if (totalTasks > 0) {
|
|
1387
|
+
const pct = Math.round(st.completedCount / totalTasks * 100);
|
|
1388
|
+
if (pct < HEALTH_STALLED_PCT && ageDays > HEALTH_STALLED_DAYS && !st.completed) issues.push({
|
|
1389
|
+
dirName: s.dirName,
|
|
1390
|
+
issue: `Only ${pct}% done after ${Math.floor(ageDays)}d`,
|
|
1391
|
+
severity: "stalled"
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
if (!st.hasSessionMd && ageDays > HEALTH_GHOST_DAYS) issues.push({
|
|
1395
|
+
dirName: s.dirName,
|
|
1396
|
+
issue: "No SESSION.md (ghost directory)",
|
|
1397
|
+
severity: "ghost"
|
|
934
1398
|
});
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1399
|
+
if (st.unresolvedCount > 3 && !st.completed) issues.push({
|
|
1400
|
+
dirName: s.dirName,
|
|
1401
|
+
issue: `${st.unresolvedCount} unresolved items`,
|
|
1402
|
+
severity: "drift"
|
|
939
1403
|
});
|
|
940
1404
|
}
|
|
941
|
-
return
|
|
1405
|
+
if (issues.length === 0) return "All sessions healthy — no issues found.";
|
|
1406
|
+
const lines = issues.map((i) => `[${i.severity.toUpperCase()}] ${i.dirName}: ${i.issue}`);
|
|
1407
|
+
return `${issues.length} issue(s) found:\n` + lines.join("\n");
|
|
1408
|
+
}
|
|
1409
|
+
/** archive 子命令(对齐 osp archiveSessions:移动 _archived/;name 缺省批量) */
|
|
1410
|
+
function archiveSessions(root, opts) {
|
|
1411
|
+
const { name, dryRun } = opts;
|
|
1412
|
+
const sessionsDir = sessionsRoot(root);
|
|
1413
|
+
const now = Date.now();
|
|
1414
|
+
const archiveDir = join(sessionsDir, ARCHIVE_DIR_NAME);
|
|
1415
|
+
if (name) {
|
|
1416
|
+
const session = findSession(sessionsDir, name);
|
|
1417
|
+
if (!session) throw new Error(`Session not found: "${name}"`);
|
|
1418
|
+
if (!session.status.completed) return `Session "${session.dirName}" is not completed — skipping.`;
|
|
1419
|
+
const ageDays = (now - session.mtime.getTime()) / DAY;
|
|
1420
|
+
if (ageDays < 7) return `Session "${session.dirName}" completed ${Math.floor(ageDays)}d ago — needs ${7 - Math.floor(ageDays)} more days before archiving.`;
|
|
1421
|
+
if (dryRun) return `[dry-run] Would archive: ${session.dirName} → ${ARCHIVE_DIR_NAME}/`;
|
|
1422
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
1423
|
+
renameSync(session.path, join(archiveDir, session.dirName));
|
|
1424
|
+
return `Archived: ${session.dirName} → _archived/`;
|
|
1425
|
+
}
|
|
1426
|
+
const toArchive = readAllSessions(sessionsDir).filter((s) => {
|
|
1427
|
+
if (!s.status.completed) return false;
|
|
1428
|
+
return (now - s.mtime.getTime()) / DAY >= 7;
|
|
1429
|
+
});
|
|
1430
|
+
if (toArchive.length === 0) return "No sessions eligible for archiving.";
|
|
1431
|
+
if (dryRun) return `[dry-run] Would archive ${toArchive.length} session(s):\n` + toArchive.map((s) => ` ${s.dirName}`).join("\n");
|
|
1432
|
+
if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
|
|
1433
|
+
let count = 0;
|
|
1434
|
+
for (const s of toArchive) {
|
|
1435
|
+
renameSync(s.path, join(archiveDir, s.dirName));
|
|
1436
|
+
count++;
|
|
1437
|
+
}
|
|
1438
|
+
return `Archived ${count} session(s) → _archived/`;
|
|
942
1439
|
}
|
|
1440
|
+
/** summary 子命令(对齐 osp sessionSummary 文本仪表盘) */
|
|
943
1441
|
function summarize(root) {
|
|
944
|
-
const sessions =
|
|
945
|
-
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1442
|
+
const sessions = readAllSessions(sessionsRoot(root));
|
|
1443
|
+
if (sessions.length === 0) return "AGENT_SESSIONS/ is empty.";
|
|
1444
|
+
const now = Date.now();
|
|
1445
|
+
const completed = sessions.filter((s) => s.status.completed).length;
|
|
1446
|
+
const active = sessions.length - completed;
|
|
1447
|
+
const stale = sessions.filter((s) => !s.status.completed && (now - s.mtime.getTime()) / DAY > HEALTH_STALE_DAYS).length;
|
|
1448
|
+
const ghost = sessions.filter((s) => !s.status.hasSessionMd).length;
|
|
1449
|
+
const recent = sessions.slice(0, 5);
|
|
1450
|
+
const lines = [
|
|
1451
|
+
`AGENT_SESSIONS Summary`,
|
|
1452
|
+
`────────────────────────`,
|
|
1453
|
+
`Total: ${sessions.length}`,
|
|
1454
|
+
`Active: ${active}`,
|
|
1455
|
+
`Completed: ${completed}`,
|
|
1456
|
+
`Stale: ${stale}`,
|
|
1457
|
+
`Ghost: ${ghost}`,
|
|
1458
|
+
``,
|
|
1459
|
+
`Recent activity (top 5):`,
|
|
1460
|
+
...recent.map((s) => {
|
|
1461
|
+
const age = Math.floor((now - s.mtime.getTime()) / DAY);
|
|
1462
|
+
return ` ${s.status.completed ? "✓" : "○"} ${s.dirName} (${age}d ago)`;
|
|
1463
|
+
})
|
|
1464
|
+
];
|
|
1465
|
+
if (stale > 0) lines.push("", "⚠ Warning: Stale sessions found — run \"session health\" for details.");
|
|
1466
|
+
return lines.join("\n");
|
|
954
1467
|
}
|
|
955
|
-
/** 事实核对:SESSION.md
|
|
1468
|
+
/** 事实核对:SESSION.md 声明 vs 实际情况(结构/一致性/新鲜度/决策质量/产出物) */
|
|
956
1469
|
function qaCheck(root, key) {
|
|
957
|
-
const
|
|
1470
|
+
const session = findSession(sessionsRoot(root), key);
|
|
1471
|
+
if (!session) throw new Error(`Session not found: "${key}". Use "list" to see available sessions.`);
|
|
1472
|
+
const mdPath = join(session.path, SESSION_MD);
|
|
1473
|
+
if (!existsSync(mdPath)) return `[ERROR] Session "${session.dirName}" has no SESSION.md — nothing to verify.`;
|
|
1474
|
+
const content = readFileSync(mdPath, "utf-8");
|
|
958
1475
|
const issues = [];
|
|
959
|
-
for (const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1476
|
+
for (const section of [
|
|
1477
|
+
{
|
|
1478
|
+
heading: "目标",
|
|
1479
|
+
label: "目标 (goal)"
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
heading: "状态",
|
|
1483
|
+
label: "状态 (status)"
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
heading: "关键决策",
|
|
1487
|
+
label: "关键决策 (key decisions)"
|
|
1488
|
+
},
|
|
1489
|
+
{
|
|
1490
|
+
heading: "进度记录",
|
|
1491
|
+
label: "进度记录 (progress)"
|
|
1492
|
+
},
|
|
1493
|
+
{
|
|
1494
|
+
heading: "产出物",
|
|
1495
|
+
label: "产出物 (outputs)"
|
|
1496
|
+
},
|
|
1497
|
+
{
|
|
1498
|
+
heading: "未解决的问题",
|
|
1499
|
+
label: "未解决的问题 (unresolved)"
|
|
1500
|
+
}
|
|
1501
|
+
]) {
|
|
1502
|
+
const headingRegex = new RegExp(`^##\\s*${section.heading}[\\s\\S]*?(?=^##|(?![\\s\\S]))`, "m");
|
|
1503
|
+
const match = content.match(headingRegex);
|
|
1504
|
+
if (!match) {
|
|
1505
|
+
issues.push({
|
|
1506
|
+
severity: "warning",
|
|
1507
|
+
category: "structure",
|
|
1508
|
+
message: `Missing section: ${section.label}`
|
|
1509
|
+
});
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
const headingLineRegex = new RegExp(`^##\\s*${section.heading}\\s*$`, "m");
|
|
1513
|
+
const body = match[0].replace(headingLineRegex, "").trim();
|
|
1514
|
+
if (!body || /^[-*]\s*$/.test(body)) issues.push({
|
|
1515
|
+
severity: "warning",
|
|
1516
|
+
category: "structure",
|
|
1517
|
+
message: `Section "${section.label}" is empty (only placeholder)`
|
|
966
1518
|
});
|
|
967
1519
|
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1520
|
+
const completedTasks = (content.match(/\[\s*x\s*\]/gi) ?? []).length;
|
|
1521
|
+
const pendingTasks = (content.match(/\[\s*[ \t]\s*\]/gi) ?? []).length;
|
|
1522
|
+
const statusSection = content.match(/^##\s*状态[\s\S]*?(?=^##|(?![^]))/im);
|
|
1523
|
+
const statusBody = statusSection ? statusSection[0].replace(/^##\s*状态.*$/m, "").trim() : "";
|
|
1524
|
+
const hasCompletionMark = statusBody ? /#+\s*(?:完成|done|completed|closed)\b/i.test(statusBody) || /(?:全部完成|已全部完成|所有.*任务.*完成|任务.*全部完成|已完成.*所有)/i.test(statusBody) : false;
|
|
1525
|
+
const unresolvedSection = content.match(/^##\s*未解决的问题[\s\S]*?(?=^##|(?![^]))/im);
|
|
1526
|
+
const unresolvedBody = unresolvedSection ? unresolvedSection[0].replace(/^##\s*未解决的问题.*$/m, "").trim() : "";
|
|
1527
|
+
const unresolvedCount = unresolvedBody ? (unresolvedBody.match(/(?:未解决|open|question|TODO)/gi) ?? []).length : 0;
|
|
1528
|
+
if (hasCompletionMark && pendingTasks > 0) issues.push({
|
|
1529
|
+
severity: "error",
|
|
1530
|
+
category: "consistency",
|
|
1531
|
+
message: `Session marked as completed but has ${pendingTasks} pending task(s)`
|
|
1532
|
+
});
|
|
1533
|
+
if (hasCompletionMark && unresolvedCount > 0) issues.push({
|
|
1534
|
+
severity: "warning",
|
|
1535
|
+
category: "consistency",
|
|
1536
|
+
message: `Session marked as completed but has ${unresolvedCount} unresolved item(s)`
|
|
1537
|
+
});
|
|
1538
|
+
if (completedTasks > 0 && pendingTasks === 0 && !hasCompletionMark) issues.push({
|
|
1539
|
+
severity: "info",
|
|
1540
|
+
category: "consistency",
|
|
1541
|
+
message: `All ${completedTasks} task(s) completed but session not marked complete`
|
|
1542
|
+
});
|
|
1543
|
+
const progressSection = content.match(/##\s*进度记录[\s\S]*?(?=^##|\z)/m);
|
|
1544
|
+
if (progressSection) {
|
|
1545
|
+
const dateMatches = progressSection[0].match(/\b(\d{4}-\d{2}-\d{2})\b/g);
|
|
1546
|
+
if (dateMatches && dateMatches.length > 0) {
|
|
1547
|
+
const lastDateStr = dateMatches[dateMatches.length - 1];
|
|
1548
|
+
const lastDate = new Date(lastDateStr);
|
|
1549
|
+
const daysSince = Math.floor((Date.now() - lastDate.getTime()) / DAY);
|
|
1550
|
+
if (daysSince > HEALTH_STALE_DAYS && pendingTasks > 0) issues.push({
|
|
1551
|
+
severity: "warning",
|
|
1552
|
+
category: "stale",
|
|
1553
|
+
message: `No progress entry for ${daysSince} days (last: ${lastDateStr}), session still has ${pendingTasks} pending task(s)`
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const decisionSection = content.match(/##\s*关键决策[\s\S]*?(?=^##|\z)/m);
|
|
1558
|
+
if (decisionSection) {
|
|
1559
|
+
const decisionLines = decisionSection[0].split("\n").filter((l) => /^\|\s*\d+\s*\|/.test(l));
|
|
1560
|
+
if (decisionLines.length > 0) {
|
|
1561
|
+
const emptyDecisions = decisionLines.filter((l) => {
|
|
1562
|
+
const cells = l.split("|").map((c) => c.trim());
|
|
1563
|
+
return cells.length >= 4 && (!cells[2] || !cells[3] || cells[2] === "-" || cells[3] === "-");
|
|
1564
|
+
});
|
|
1565
|
+
if (emptyDecisions.length > 0) issues.push({
|
|
1566
|
+
severity: "info",
|
|
1567
|
+
category: "quality",
|
|
1568
|
+
message: `${emptyDecisions.length} decision(s) have empty reason — consider filling gaps`
|
|
1569
|
+
});
|
|
1570
|
+
} else if (!hasCompletionMark) issues.push({
|
|
1571
|
+
severity: "info",
|
|
1572
|
+
category: "quality",
|
|
1573
|
+
message: "No decisions recorded yet — add key decisions as the session progresses"
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
const outputSection = content.match(/##\s*产出物[\s\S]*?(?=^##|\z)/m);
|
|
1577
|
+
if (outputSection) {
|
|
1578
|
+
const outputLines = outputSection[0].split("\n").filter((l) => /^\s*[-*]\s/.test(l));
|
|
1579
|
+
const fileRefs = [];
|
|
1580
|
+
for (const line of outputLines) {
|
|
1581
|
+
const refs = line.match(/`[^`]+`/g) ?? [];
|
|
1582
|
+
fileRefs.push(...refs.map((r) => r.replace(/`/g, "")));
|
|
1583
|
+
const inlineRefs = line.match(/\b[\w./-]+\.[a-zA-Z]{1,5}\b/g) ?? [];
|
|
1584
|
+
fileRefs.push(...inlineRefs.filter((r) => r.includes("/") || r.includes(".")));
|
|
1585
|
+
}
|
|
1586
|
+
if (fileRefs.length > 0) {
|
|
1587
|
+
const missing = fileRefs.filter((ref) => !existsSync(join(root, ref)));
|
|
1588
|
+
if (missing.length > 0 && completedTasks > 0) issues.push({
|
|
1589
|
+
severity: "warning",
|
|
1590
|
+
category: "outputs",
|
|
1591
|
+
message: `${missing.length} referenced file(s) not found: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? `... (+${missing.length - 3} more)` : ""}`
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
1596
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
1597
|
+
const infoCount = issues.filter((i) => i.severity === "info").length;
|
|
1598
|
+
const verified = errorCount === 0 && warningCount === 0;
|
|
1599
|
+
const lines = [
|
|
1600
|
+
`QA Report: ${session.dirName}`,
|
|
1601
|
+
`────────────────${"─".repeat(session.dirName.length)}`,
|
|
1602
|
+
`Summary: ${issues.length} issue(s) found (${errorCount} error, ${warningCount} warning, ${infoCount} info)`,
|
|
1603
|
+
`Status: ${verified ? "✓ Verified" : "⚠ Issues found"}`
|
|
1604
|
+
];
|
|
1605
|
+
if (issues.length > 0) {
|
|
1606
|
+
lines.push("");
|
|
1607
|
+
for (const issue of issues) {
|
|
1608
|
+
const tag = issue.severity === "error" ? "ERR" : issue.severity === "warning" ? "WRN" : "INF";
|
|
1609
|
+
lines.push(` [${tag}:${issue.category}] ${issue.message}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
lines.push("", "Recommendations:");
|
|
1613
|
+
if (errorCount > 0) lines.push(" • Fix errors before closing the session (status vs content mismatch)");
|
|
1614
|
+
if (warningCount > 0) lines.push(" • Review warnings — they may indicate incomplete or outdated information");
|
|
1615
|
+
if (verified) lines.push(" • Session looks clean — no issues detected");
|
|
1616
|
+
return lines.join("\n");
|
|
972
1617
|
}
|
|
973
1618
|
/** 追加会话心跳(turn-stopping 机械落盘用) */
|
|
974
1619
|
function appendHeartbeat(sessionMd) {
|
|
@@ -984,8 +1629,14 @@ function appendHeartbeat(sessionMd) {
|
|
|
984
1629
|
/**
|
|
985
1630
|
* session.ts — session 真实 DSH 工具定义(defineTool)
|
|
986
1631
|
*
|
|
987
|
-
* AGENT_SESSIONS/ 全周期管理:list/show/create/health/qa/archive/summary。
|
|
988
|
-
*
|
|
1632
|
+
* AGENT_SESSIONS/ 全周期管理:list/show/create/use/close/health/qa/archive/summary。
|
|
1633
|
+
* 行为对齐 osp(opencode-serenity-plugin/src/session/session-tool.ts)——osp 是 ACC 工具 spec:
|
|
1634
|
+
* - create:--desc <desc> [--goal <goal>] 或 --issue <id>(二选一)
|
|
1635
|
+
* - close:需 --confirm 防误关
|
|
1636
|
+
* - archive:name 可缺省(批量归档)
|
|
1637
|
+
* - hook-develop-guide 子命令 + CCC session-tool MSM 扩展提示(extHint)
|
|
1638
|
+
* CCC 扩展采用 osp 的"钩子后处理"模型(create-transform),而非整命令委派。
|
|
1639
|
+
* 活跃会话跟踪:写内存 Map(.dsh/active-sessions/<scope> 语义)+ events 恢复(S134)。
|
|
989
1640
|
*/
|
|
990
1641
|
function agentCwd$6(exec) {
|
|
991
1642
|
return exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
@@ -1000,27 +1651,122 @@ function renderText$8(value) {
|
|
|
1000
1651
|
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1001
1652
|
}];
|
|
1002
1653
|
}
|
|
1654
|
+
/** 从 flags 中查找 name 匹配的 flag,仅在 new-style 对象上检查 */
|
|
1655
|
+
function findFlagByName(flags, name) {
|
|
1656
|
+
if (!flags) return void 0;
|
|
1657
|
+
for (const f of flags) if ("name" in f && f.name === name) return f;
|
|
1658
|
+
}
|
|
1659
|
+
/** 从 CCC 的 session-tool MSM flags 中提取支持的钩子名列表 */
|
|
1660
|
+
function discoverCccHooks(entries) {
|
|
1661
|
+
const hookFlag = findFlagByName(entries.find((e) => e.name === "session-tool")?.flags, "hook");
|
|
1662
|
+
if (!hookFlag?.description) return [];
|
|
1663
|
+
return hookFlag.description.split("|").map((s) => s.trim()).filter(Boolean);
|
|
1664
|
+
}
|
|
1665
|
+
/** 从 CCC 的 session-tool MSM flags 中提取自定义子命令清单 */
|
|
1666
|
+
function discoverCccSubcommands(entries) {
|
|
1667
|
+
const subFlag = findFlagByName(entries.find((e) => e.name === "session-tool")?.flags, "subcommand");
|
|
1668
|
+
if (!subFlag?.description) return [];
|
|
1669
|
+
return subFlag.description.split("|").map((s) => s.trim()).filter(Boolean);
|
|
1670
|
+
}
|
|
1671
|
+
/** 生成扩展提示(对齐 osp buildExtHint) */
|
|
1672
|
+
function buildExtHint(hasSessionTool, hooks, subcommands) {
|
|
1673
|
+
if (!hasSessionTool) return "\n\n[CCC] 如需扩展会话能力,可注册 session-tool MSM (acc_msm register),详见 session hook-develop-guide";
|
|
1674
|
+
const parts = [];
|
|
1675
|
+
if (hooks.length > 0) parts.push(`钩子: ${hooks.join(", ")}`);
|
|
1676
|
+
if (subcommands.length > 0) parts.push(`扩展子命令 (acc_msm exec session-tool): ${subcommands.join(", ")}`);
|
|
1677
|
+
return `\n\n[CCC] session-tool MSM 已注册${parts.length > 0 ? ` (${parts.join("; ")})` : ""}`;
|
|
1678
|
+
}
|
|
1679
|
+
/** hook-develop-guide 内容(对齐 osp getHookDevelopGuide) */
|
|
1680
|
+
function getHookDevelopGuide(hasSessionTool) {
|
|
1681
|
+
return [
|
|
1682
|
+
"═══ Session Extension Protocol (SEP) v1 — 开发指南 ═══",
|
|
1683
|
+
"",
|
|
1684
|
+
"CCC 可以通过注册 session-tool MSM 来扩展 ACC session 工具的能力,",
|
|
1685
|
+
"而无需修改 plugin 代码。ACC 的行为不会缩水——CCC 只在 ACC 完成后做后处理。",
|
|
1686
|
+
"",
|
|
1687
|
+
"── 口子一:后处理钩子 (Hooks) ──",
|
|
1688
|
+
"",
|
|
1689
|
+
"ACC 的某些子命令执行完成后,会检查 CCC 的 session-tool MSM 是否",
|
|
1690
|
+
"注册了对应的钩子。如果注册了,ACC 会调用 MSM 做后处理。",
|
|
1691
|
+
"",
|
|
1692
|
+
"可用钩子:",
|
|
1693
|
+
"",
|
|
1694
|
+
" create-transform",
|
|
1695
|
+
" 触发时机:create 写完默认 SESSION.md 后",
|
|
1696
|
+
" 调用方式:acc_msm exec session-tool --hook=create-transform --session-dir=<path>",
|
|
1697
|
+
" 允许行为:读取 SESSION.md,原地修改内容(追加字段、换模板、调 API 等)",
|
|
1698
|
+
" 注意事项:ACC 已确保目录和 SESSION.md 存在,CCC 只做修改",
|
|
1699
|
+
"",
|
|
1700
|
+
"── 口子二:新子命令 (Custom Subcommands) ──",
|
|
1701
|
+
"",
|
|
1702
|
+
"LLM 可以直接调用 acc_msm exec session-tool <subcommand> 来执行 CCC 专属的子命令,",
|
|
1703
|
+
"如 reindex、export、batch-create 等。这些子命令不走 ACC session tool 的 enum。",
|
|
1704
|
+
"",
|
|
1705
|
+
"── 如何注册 session-tool MSM ──",
|
|
1706
|
+
"",
|
|
1707
|
+
"1. 编写脚本,放在 CCC 的 skills 目录下:",
|
|
1708
|
+
" .opencode/skills/<ccc-name>/scripts/session-tool.ts",
|
|
1709
|
+
"",
|
|
1710
|
+
"2. 注册到 mech-registry.json:",
|
|
1711
|
+
" acc_msm register session-tool \\",
|
|
1712
|
+
" --skill <ccc-name> --path .opencode/skills/<ccc-name>/scripts/session-tool.ts \\",
|
|
1713
|
+
" --category semi-mech \\",
|
|
1714
|
+
" --description \"CCC session 扩展: 钩子 + 自定义子命令\" \\",
|
|
1715
|
+
" --flags '[",
|
|
1716
|
+
" {\"name\":\"hook\",\"type\":\"string\",\"description\":\"create-transform\"},",
|
|
1717
|
+
" {\"name\":\"subcommand\",\"type\":\"string\",\"description\":\"reindex | export\"},",
|
|
1718
|
+
" {\"name\":\"session-dir\",\"type\":\"path\",\"description\":\"session 目录路径\"},",
|
|
1719
|
+
" {\"name\":\"dry-run\",\"type\":\"boolean\",\"description\":\"预览模式\"}",
|
|
1720
|
+
" ]'",
|
|
1721
|
+
"",
|
|
1722
|
+
"3. 钩子声明约定:",
|
|
1723
|
+
" flags 中的 --hook description 字段按 | 分割枚举支持的钩子名。",
|
|
1724
|
+
" ACC 发现 create-transform 在列表中时,就会在 create 后调用。",
|
|
1725
|
+
"",
|
|
1726
|
+
"4. 子命令声明约定:",
|
|
1727
|
+
" flags 中的 --subcommand description 字段按 | 分割枚举支持的子命令名。",
|
|
1728
|
+
" LLM 看到提示后可调用 acc_msm exec session-tool <subcommand>。",
|
|
1729
|
+
"",
|
|
1730
|
+
hasSessionTool ? "✅ 当前 CCC 已注册 session-tool MSM" : "ℹ️ 当前 CCC 尚未注册 session-tool MSM — 使用 acc_msm register 开始",
|
|
1731
|
+
"",
|
|
1732
|
+
"── 更多信息 ──",
|
|
1733
|
+
"",
|
|
1734
|
+
"参考 ACC 源码: src/tools/session.ts (hook 调用逻辑)"
|
|
1735
|
+
].join("\n");
|
|
1736
|
+
}
|
|
1003
1737
|
const sessionTool = defineTool({
|
|
1004
1738
|
name: "session",
|
|
1005
|
-
description: "工作会话全周期管理(AGENT_SESSIONS/,home-session 约定)。list/show/create/use/close/health/qa/archive/summary
|
|
1739
|
+
description: "工作会话全周期管理(AGENT_SESSIONS/,home-session 约定)。list/show/create/use/close/health/qa/archive/summary/hook-develop-guide。create 需 --desc <desc> [--goal] 或 --issue <工单号>(二选一);close 需 --confirm;use 激活当前 dsh 会话的活跃会话(内存 + events 恢复,按 dsh 会话隔离不泄露)。",
|
|
1006
1740
|
parameters: {
|
|
1007
1741
|
action: {
|
|
1008
1742
|
type: "string",
|
|
1009
1743
|
enum: [...SESSION_ACTIONS],
|
|
1010
1744
|
required: true,
|
|
1011
|
-
description: "
|
|
1745
|
+
description: "子命令:list(状态摘要)/ show(S### 或目录名或模糊关键词)/ create(--desc 或 --issue)/ use(激活上下文,closed 可重开)/ close(需 --name + --confirm,不可撤销)/ health(stale/stalled/drift/ghost)/ qa(事实核对)/ archive(归档,name 缺省批量)/ summary(仪表盘)/ hook-develop-guide(CCC 扩展指南)"
|
|
1012
1746
|
},
|
|
1013
|
-
|
|
1747
|
+
name: {
|
|
1014
1748
|
type: "string",
|
|
1015
|
-
description: "show/use/archive/qa 的会话标识(S### 或目录名或关键词)"
|
|
1749
|
+
description: "show/use/close/archive/qa 的会话标识(S### 或目录名或关键词)"
|
|
1016
1750
|
},
|
|
1017
|
-
|
|
1751
|
+
desc: {
|
|
1752
|
+
type: "string",
|
|
1753
|
+
description: "create 的短描述(任意语言,≤5 词;与 issue 互斥)"
|
|
1754
|
+
},
|
|
1755
|
+
issue: {
|
|
1018
1756
|
type: "string",
|
|
1019
|
-
description: "create
|
|
1757
|
+
description: "create 的工单号(如 apaas-26116;目录命名 YYYY-MM-DD--<issue>;与 desc 互斥)"
|
|
1020
1758
|
},
|
|
1021
|
-
|
|
1759
|
+
goal: {
|
|
1022
1760
|
type: "string",
|
|
1023
|
-
description: "create
|
|
1761
|
+
description: "create 的一句话目标(可选)"
|
|
1762
|
+
},
|
|
1763
|
+
confirm: {
|
|
1764
|
+
type: "boolean",
|
|
1765
|
+
description: "close 必须为 true(防误关)"
|
|
1766
|
+
},
|
|
1767
|
+
dryRun: {
|
|
1768
|
+
type: "boolean",
|
|
1769
|
+
description: "create/archive 预览模式(不实际修改)"
|
|
1024
1770
|
}
|
|
1025
1771
|
},
|
|
1026
1772
|
output: {
|
|
@@ -1030,62 +1776,58 @@ const sessionTool = defineTool({
|
|
|
1030
1776
|
async execute(args, exec) {
|
|
1031
1777
|
const root = findSerenityRoot(agentCwd$6(exec));
|
|
1032
1778
|
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
});
|
|
1039
|
-
if (!(r.exit !== void 0 && r.exit !== 0 || r.ok === false)) {
|
|
1040
|
-
const out = r.ok !== void 0 ? r.ok ? r.data : r.error : r.stdout;
|
|
1041
|
-
return out !== void 0 ? {
|
|
1042
|
-
delegated: true,
|
|
1043
|
-
exit: r.exit ?? 0,
|
|
1044
|
-
output: out
|
|
1045
|
-
} : {
|
|
1046
|
-
delegated: true,
|
|
1047
|
-
exit: r.exit ?? 0
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1779
|
+
const entries = loadMsmEntries(root);
|
|
1780
|
+
const hasSessionTool = entries.some((e) => e.name === "session-tool");
|
|
1781
|
+
const cccHooks = discoverCccHooks(entries);
|
|
1782
|
+
const extHint = buildExtHint(hasSessionTool, cccHooks, discoverCccSubcommands(entries));
|
|
1783
|
+
if (args.action === "hook-develop-guide") return getHookDevelopGuide(hasSessionTool);
|
|
1051
1784
|
switch (args.action) {
|
|
1052
|
-
case "list": return listSessions(root);
|
|
1053
|
-
case "create": return createSession(root, args.name ?? "untitled", args.title ?? args.name ?? "untitled");
|
|
1785
|
+
case "list": return listSessions(root) + extHint;
|
|
1054
1786
|
case "show":
|
|
1055
|
-
if (!args.
|
|
1056
|
-
return showSession(root, args.
|
|
1787
|
+
if (!args.name) throw new Error("show 需要 name(S### 或目录名)");
|
|
1788
|
+
return showSession(root, args.name) + extHint;
|
|
1789
|
+
case "create": {
|
|
1790
|
+
const result = createSession({
|
|
1791
|
+
root,
|
|
1792
|
+
desc: args.desc,
|
|
1793
|
+
issue: args.issue,
|
|
1794
|
+
goal: args.goal,
|
|
1795
|
+
dryRun: args.dryRun ?? false
|
|
1796
|
+
});
|
|
1797
|
+
let message = result.message;
|
|
1798
|
+
if (!(args.dryRun ?? false) && cccHooks.includes("create-transform")) try {
|
|
1799
|
+
const hookResult = await runMsmAsync(root, {
|
|
1800
|
+
action: "exec",
|
|
1801
|
+
name: "session-tool",
|
|
1802
|
+
args: ["--hook=create-transform", `--session-dir=${result.sessionPath}`]
|
|
1803
|
+
});
|
|
1804
|
+
const hookOut = hookResult.ok !== void 0 && hookResult.ok === false ? hookResult.data ?? "" : hookResult.stdout ?? "";
|
|
1805
|
+
message += `\n [create-transform] ${hookOut.trim()}`;
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
message += `\n [WARN] create-transform hook failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
1808
|
+
}
|
|
1809
|
+
return message + extHint;
|
|
1810
|
+
}
|
|
1057
1811
|
case "use":
|
|
1058
|
-
if (!args.
|
|
1059
|
-
return useSession(root, args.
|
|
1060
|
-
case "close":
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1812
|
+
if (!args.name) throw new Error("use 需要 name(S### 或目录名)");
|
|
1813
|
+
return useSession(root, args.name, agentScope$2(exec));
|
|
1814
|
+
case "close":
|
|
1815
|
+
if (!args.name) throw new Error("close 需要 name(S### 或目录名)");
|
|
1816
|
+
return closeSession(root, args.name, args.confirm ?? false, agentScope$2(exec));
|
|
1817
|
+
case "archive": return archiveSessions(root, {
|
|
1818
|
+
name: args.name,
|
|
1819
|
+
dryRun: args.dryRun ?? false
|
|
1820
|
+
}) + extHint;
|
|
1821
|
+
case "health": return healthCheck(root) + extHint;
|
|
1822
|
+
case "summary": return summarize(root) + extHint;
|
|
1066
1823
|
case "qa":
|
|
1067
|
-
if (!args.
|
|
1068
|
-
return qaCheck(root, args.
|
|
1824
|
+
if (!args.name) throw new Error("qa 需要 name(S### 或目录名)");
|
|
1825
|
+
return qaCheck(root, args.name) + extHint;
|
|
1069
1826
|
default: throw new Error(`未知 action: ${args.action}`);
|
|
1070
1827
|
}
|
|
1071
1828
|
}
|
|
1072
1829
|
});
|
|
1073
1830
|
//#endregion
|
|
1074
|
-
//#region src/constants.ts
|
|
1075
|
-
/** 常量(纯模块,零 DSH 依赖) */
|
|
1076
|
-
/**
|
|
1077
|
-
* ACC 版本:自动从 package.json 读取(单一真相源,消除与 CHANGELOG 的漂移)。
|
|
1078
|
-
* 发布时只需改 package.json 的 version。
|
|
1079
|
-
*/
|
|
1080
|
-
const ACC_VERSION = (() => {
|
|
1081
|
-
try {
|
|
1082
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
1083
|
-
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")).version ?? "0.0.0";
|
|
1084
|
-
} catch {
|
|
1085
|
-
return "0.0.0";
|
|
1086
|
-
}
|
|
1087
|
-
})();
|
|
1088
|
-
//#endregion
|
|
1089
1831
|
//#region src/seams/guards.ts
|
|
1090
1832
|
/**
|
|
1091
1833
|
* guards.ts — 拦截缝:安全模式 + 路径守卫(P3 语义的机械层)
|
|
@@ -1294,53 +2036,84 @@ function setSafeMode(root, on) {
|
|
|
1294
2036
|
/**
|
|
1295
2037
|
* kit-ops.ts — acc_kit 纯操作层(零 DSH 依赖)
|
|
1296
2038
|
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
2039
|
+
* 行为对齐 osp(opencode-serenity-plugin/src/acc-kit.ts)——osp 是 ACC 工具 spec:
|
|
2040
|
+
* - health:{ccc, root, version, status: healthy|degraded, principles: {P1_rooted, P2_git_managed, P3_binary_permissions}}
|
|
2041
|
+
* - time:{now_iso, now_local, epoch_ms}
|
|
2042
|
+
* - wait:缺省 1s(正整数秒),返回 'waited Ns' 文本
|
|
2043
|
+
* 平台适配:P3 在 osp 检查 opencode.json,DSH 无 opencode.json → 检查 DSH/opencode 配置路径
|
|
2044
|
+
* (.opencode/serenity.json / .dsh/serenity.json 等,见 DEFAULT_SERENITY_CONFIG_PATHS)。
|
|
2045
|
+
* CCC 缺失时返回 degraded 报告而非抛错(对齐 osp 未激活语义)。
|
|
2046
|
+
* 保留 dsp 增强:accVersion/dshVersion 版本自省字段。
|
|
2047
|
+
* wait 用纯 Node setTimeout——不依赖外部 sleep 可执行文件(Windows 无 GNU coreutils sleep)。
|
|
1301
2048
|
*/
|
|
1302
2049
|
const KIT_ACTIONS = [
|
|
1303
2050
|
"health",
|
|
1304
2051
|
"time",
|
|
1305
2052
|
"wait"
|
|
1306
2053
|
];
|
|
2054
|
+
/** CCC 名:从 .serenity 首行解析(对齐 osp readSerenityCccName) */
|
|
2055
|
+
function readCccName(root) {
|
|
2056
|
+
if (!root) return null;
|
|
2057
|
+
try {
|
|
2058
|
+
return readFileSync(resolve(root, ".serenity"), "utf-8").trim().split("\n")[0]?.trim() || null;
|
|
2059
|
+
} catch {
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
1307
2063
|
async function runKit(root, args) {
|
|
1308
2064
|
switch (args.action) {
|
|
1309
2065
|
case "health": {
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
configPath = p;
|
|
2066
|
+
const cccName = readCccName(root);
|
|
2067
|
+
const serenityPath = root ? resolve(root, ".serenity") : null;
|
|
2068
|
+
const p1Pass = serenityPath !== null && existsSync(serenityPath) && statSync(serenityPath).size > 0;
|
|
2069
|
+
const p2Pass = (root ? findGitRoot(root) : null) !== null;
|
|
2070
|
+
let p3Pass = false;
|
|
2071
|
+
let p3Detail = "config not found at CCC root";
|
|
2072
|
+
if (root) {
|
|
2073
|
+
for (const candidate of DEFAULT_SERENITY_CONFIG_PATHS) if (existsSync(resolve(root, candidate))) {
|
|
2074
|
+
p3Pass = true;
|
|
2075
|
+
p3Detail = `${candidate} found`;
|
|
2076
|
+
break;
|
|
1322
2077
|
}
|
|
1323
|
-
break;
|
|
1324
2078
|
}
|
|
2079
|
+
const report = {
|
|
2080
|
+
ccc: cccName,
|
|
2081
|
+
root,
|
|
2082
|
+
version: ACC_VERSION,
|
|
2083
|
+
status: p1Pass && p2Pass && p3Pass ? "healthy" : "degraded",
|
|
2084
|
+
principles: {
|
|
2085
|
+
P1_rooted: {
|
|
2086
|
+
pass: p1Pass,
|
|
2087
|
+
detail: p1Pass ? ".serenity marker found" : ".serenity marker missing"
|
|
2088
|
+
},
|
|
2089
|
+
P2_git_managed: {
|
|
2090
|
+
pass: p2Pass,
|
|
2091
|
+
detail: p2Pass ? "git repository verified" : "not in a git repository"
|
|
2092
|
+
},
|
|
2093
|
+
P3_binary_permissions: {
|
|
2094
|
+
pass: p3Pass,
|
|
2095
|
+
detail: p3Detail
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
if (root) report.config = loadSerenityConfig(root);
|
|
2100
|
+
report.accVersion = ACC_VERSION;
|
|
2101
|
+
report.dshVersion = readDshVersion();
|
|
2102
|
+
return report;
|
|
2103
|
+
}
|
|
2104
|
+
case "time": {
|
|
2105
|
+
const now = /* @__PURE__ */ new Date();
|
|
1325
2106
|
return {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
config,
|
|
1330
|
-
configPath,
|
|
1331
|
-
p1: findSerenityRoot(root) !== null,
|
|
1332
|
-
p2: gitRoot !== null,
|
|
1333
|
-
p3: "enforced-by-dsh-fs-sandbox",
|
|
1334
|
-
accVersion: ACC_VERSION,
|
|
1335
|
-
dshVersion: readDshVersion()
|
|
2107
|
+
now_iso: now.toISOString(),
|
|
2108
|
+
now_local: now.toString(),
|
|
2109
|
+
epoch_ms: now.getTime()
|
|
1336
2110
|
};
|
|
1337
2111
|
}
|
|
1338
|
-
case "time": return (/* @__PURE__ */ new Date()).toISOString();
|
|
1339
2112
|
case "wait": {
|
|
1340
|
-
const
|
|
1341
|
-
if (!Number.
|
|
1342
|
-
await new Promise((r) => setTimeout(r,
|
|
1343
|
-
return
|
|
2113
|
+
const seconds = args.seconds ?? 1;
|
|
2114
|
+
if (!Number.isInteger(seconds) || seconds <= 0) throw new Error("wait 需要正整数秒数(缺省 1)");
|
|
2115
|
+
await new Promise((r) => setTimeout(r, seconds * 1e3));
|
|
2116
|
+
return `waited ${seconds}s`;
|
|
1344
2117
|
}
|
|
1345
2118
|
default: throw new Error(`未知 action: ${args.action}`);
|
|
1346
2119
|
}
|
|
@@ -1361,7 +2134,7 @@ function renderText$7(value) {
|
|
|
1361
2134
|
}
|
|
1362
2135
|
const kitTool = defineTool({
|
|
1363
2136
|
name: "acc_kit",
|
|
1364
|
-
description: "ACC 通用能力工具包:health(CCC 三原则检查 P1/P2
|
|
2137
|
+
description: "ACC 通用能力工具包:health(CCC 三原则检查 P1/P2/配置,healthy/degraded 报告)/ time(now_iso/now_local/epoch_ms)/ wait(等待 N 秒,缺省 1)。进入 CCC 工作前的例行自检。",
|
|
1365
2138
|
parameters: {
|
|
1366
2139
|
action: {
|
|
1367
2140
|
type: "string",
|
|
@@ -1370,8 +2143,8 @@ const kitTool = defineTool({
|
|
|
1370
2143
|
description: "子命令"
|
|
1371
2144
|
},
|
|
1372
2145
|
seconds: {
|
|
1373
|
-
type: "
|
|
1374
|
-
description: "wait
|
|
2146
|
+
type: "integer",
|
|
2147
|
+
description: "wait 的秒数(正整数,缺省 1)"
|
|
1375
2148
|
}
|
|
1376
2149
|
},
|
|
1377
2150
|
output: {
|
|
@@ -1379,9 +2152,7 @@ const kitTool = defineTool({
|
|
|
1379
2152
|
render: (args, value) => renderText$7(value)
|
|
1380
2153
|
},
|
|
1381
2154
|
async execute(args, exec) {
|
|
1382
|
-
|
|
1383
|
-
if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
|
|
1384
|
-
return await runKit(root, args);
|
|
2155
|
+
return await runKit(findSerenityRoot(agentCwd$5(exec)), args);
|
|
1385
2156
|
}
|
|
1386
2157
|
});
|
|
1387
2158
|
//#endregion
|
|
@@ -1661,37 +2432,62 @@ function runLocalStore(root, args) {
|
|
|
1661
2432
|
/**
|
|
1662
2433
|
* git-ops.ts — cc_git 纯操作层(零 DSH 依赖)
|
|
1663
2434
|
*
|
|
1664
|
-
*
|
|
2435
|
+
* 行为对齐 osp(opencode-serenity-plugin/src/git/cc-git-tool.ts)——osp 是 ACC 工具 spec:
|
|
2436
|
+
* - status:JSON {clean, files:[{status,file}], summary}
|
|
2437
|
+
* - commit:git add -A + commit -m;clean 返回 '(nothing to commit — working tree clean)'
|
|
2438
|
+
* - push:无 remote 报错;非快进 → [REJECTED] + 操作建议(绝不自动 force)
|
|
2439
|
+
* - pull:git fetch + merge --ff-only;up-to-date / [REJECTED] 建议
|
|
2440
|
+
* - log:--oneline [-n <count>]
|
|
2441
|
+
* - diff:git diff [--cached] [<ref>] [-- <path>]
|
|
2442
|
+
* 保留 dsp 增强:localstore git 合规联动(S134:deny 且 .gitignore 未覆盖 → status 提示 / commit 拒绝)。
|
|
1665
2443
|
*/
|
|
1666
2444
|
const GIT_ACTIONS = [
|
|
1667
2445
|
"status",
|
|
1668
2446
|
"commit",
|
|
1669
2447
|
"push",
|
|
1670
|
-
"log"
|
|
2448
|
+
"log",
|
|
2449
|
+
"pull",
|
|
2450
|
+
"diff"
|
|
1671
2451
|
];
|
|
1672
2452
|
function git(root, args) {
|
|
1673
2453
|
try {
|
|
1674
2454
|
return {
|
|
1675
|
-
ok: true,
|
|
1676
2455
|
stdout: execFileSync("git", args, {
|
|
1677
2456
|
cwd: root,
|
|
1678
|
-
encoding: "
|
|
2457
|
+
encoding: "utf8",
|
|
1679
2458
|
stdio: [
|
|
1680
|
-
"
|
|
2459
|
+
"ignore",
|
|
1681
2460
|
"pipe",
|
|
1682
2461
|
"pipe"
|
|
1683
|
-
]
|
|
1684
|
-
|
|
2462
|
+
],
|
|
2463
|
+
maxBuffer: 1048576
|
|
2464
|
+
}).trimEnd(),
|
|
1685
2465
|
stderr: ""
|
|
1686
2466
|
};
|
|
1687
2467
|
} catch (err) {
|
|
1688
2468
|
return {
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
stderr: err.stderr?.toString() ?? ""
|
|
2469
|
+
stdout: (err.stdout?.toString() ?? "").trimEnd(),
|
|
2470
|
+
stderr: (err.stderr?.toString() ?? "").trimEnd()
|
|
1692
2471
|
};
|
|
1693
2472
|
}
|
|
1694
2473
|
}
|
|
2474
|
+
function getCurrentBranch(root) {
|
|
2475
|
+
const { stdout } = git(root, [
|
|
2476
|
+
"rev-parse",
|
|
2477
|
+
"--abbrev-ref",
|
|
2478
|
+
"HEAD"
|
|
2479
|
+
]);
|
|
2480
|
+
if (!stdout) throw new Error("cc_git: cannot determine current branch");
|
|
2481
|
+
return stdout;
|
|
2482
|
+
}
|
|
2483
|
+
function hasChanges(root) {
|
|
2484
|
+
const { stdout } = git(root, ["status", "--porcelain"]);
|
|
2485
|
+
return stdout.length > 0;
|
|
2486
|
+
}
|
|
2487
|
+
function hasRemote(root) {
|
|
2488
|
+
const { stdout } = git(root, ["remote"]);
|
|
2489
|
+
return stdout.length > 0;
|
|
2490
|
+
}
|
|
1695
2491
|
function runGit(root, args) {
|
|
1696
2492
|
switch (args.action) {
|
|
1697
2493
|
case "status": {
|
|
@@ -1701,10 +2497,17 @@ function runGit(root, args) {
|
|
|
1701
2497
|
"status",
|
|
1702
2498
|
"--porcelain"
|
|
1703
2499
|
]);
|
|
1704
|
-
if (
|
|
2500
|
+
if (r.stderr) throw new Error(`status 失败:${r.stderr.trim()}`);
|
|
2501
|
+
const files = (r.stdout ? r.stdout.split("\n") : []).map((line) => {
|
|
2502
|
+
return {
|
|
2503
|
+
status: line.slice(0, 2).trim() || "??",
|
|
2504
|
+
file: line.slice(3)
|
|
2505
|
+
};
|
|
2506
|
+
});
|
|
1705
2507
|
const out = {
|
|
1706
|
-
clean:
|
|
1707
|
-
|
|
2508
|
+
clean: files.length === 0,
|
|
2509
|
+
files,
|
|
2510
|
+
summary: files.length === 0 ? "(clean)" : `${files.length} file(s) with changes`
|
|
1708
2511
|
};
|
|
1709
2512
|
const ls = checkLocalstoreGitCompliance(root);
|
|
1710
2513
|
if (!ls.ok) out.warning = ls.reason;
|
|
@@ -1713,44 +2516,68 @@ function runGit(root, args) {
|
|
|
1713
2516
|
case "commit": {
|
|
1714
2517
|
const ls = checkLocalstoreGitCompliance(root);
|
|
1715
2518
|
if (!ls.ok) throw new Error(ls.reason);
|
|
1716
|
-
if (!args.message) throw new Error("commit
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2519
|
+
if (!args.message || args.message.trim() === "") throw new Error("cc_git commit: missing required arg \"message\"");
|
|
2520
|
+
if (!hasChanges(root)) return "(nothing to commit — working tree clean)";
|
|
2521
|
+
const addResult = git(root, ["add", "-A"]);
|
|
2522
|
+
if (addResult.stderr) throw new Error(`cc_git commit: git add failed\n${addResult.stderr}`);
|
|
2523
|
+
const commitResult = git(root, [
|
|
1720
2524
|
"commit",
|
|
1721
2525
|
"-m",
|
|
1722
2526
|
args.message
|
|
1723
2527
|
]);
|
|
1724
|
-
if (
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
reason: "nothing to commit"
|
|
1728
|
-
};
|
|
1729
|
-
throw new Error(`git commit 失败:${commit.stderr.trim()}`);
|
|
1730
|
-
}
|
|
1731
|
-
return {
|
|
1732
|
-
committed: true,
|
|
1733
|
-
message: args.message
|
|
1734
|
-
};
|
|
2528
|
+
if (commitResult.stderr && commitResult.stderr.includes("nothing to commit")) return "(nothing to commit — working tree clean)";
|
|
2529
|
+
if (commitResult.stderr && !commitResult.stdout) throw new Error(`cc_git commit: git commit failed\n${commitResult.stderr}`);
|
|
2530
|
+
return (commitResult.stdout || commitResult.stderr || "committed").trimEnd();
|
|
1735
2531
|
}
|
|
1736
2532
|
case "push": {
|
|
1737
|
-
|
|
2533
|
+
if (!hasRemote(root)) throw new Error("cc_git push: no remote configured. Add one with:\n git remote add origin <url>");
|
|
2534
|
+
const branch = getCurrentBranch(root);
|
|
2535
|
+
try {
|
|
2536
|
+
git(root, [
|
|
2537
|
+
"fetch",
|
|
2538
|
+
"origin",
|
|
2539
|
+
branch
|
|
2540
|
+
]);
|
|
2541
|
+
} catch {}
|
|
2542
|
+
const { stdout, stderr } = git(root, [
|
|
1738
2543
|
"push",
|
|
1739
2544
|
"origin",
|
|
1740
|
-
|
|
2545
|
+
branch
|
|
1741
2546
|
]);
|
|
1742
|
-
if (
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2547
|
+
if (!stderr || stderr.includes("->") || stderr === "") return stdout || `Pushed to origin/${branch}`;
|
|
2548
|
+
if (stderr.includes("non-fast-forward") || stderr.includes("rejected") || stderr.includes("[rejected]")) return `[REJECTED] Push to origin/${branch} was rejected (non-fast-forward).\n\n远程有新的提交,本地落后。操作建议:\n 1. 先用 bash: git fetch origin ${branch}\n 2. 查看远程变更: git log HEAD..origin/${branch}\n 3. 合并或变基: git merge origin/${branch} 或 git rebase origin/${branch}\n 4. 有冲突则手动解决后: git add ... && git commit\n 5. 再次推送: cc_git push`;
|
|
2549
|
+
throw new Error(`cc_git push failed:\n${stderr}`);
|
|
2550
|
+
}
|
|
2551
|
+
case "pull": {
|
|
2552
|
+
if (!hasRemote(root)) throw new Error("cc_git pull: no remote configured. Add one with:\n git remote add origin <url>");
|
|
2553
|
+
const branch = getCurrentBranch(root);
|
|
2554
|
+
const fetchResult = git(root, [
|
|
2555
|
+
"fetch",
|
|
2556
|
+
"origin",
|
|
2557
|
+
branch
|
|
2558
|
+
]);
|
|
2559
|
+
if (fetchResult.stderr) return `[WARN] fetch had stderr:\n${fetchResult.stderr}`;
|
|
2560
|
+
const revResult = git(root, [
|
|
2561
|
+
"rev-list",
|
|
2562
|
+
"--count",
|
|
2563
|
+
"HEAD..FETCH_HEAD"
|
|
2564
|
+
]);
|
|
2565
|
+
if (revResult.stderr) return `[WARN] cannot check ahead count:\n${revResult.stderr}`;
|
|
2566
|
+
if (revResult.stdout === "0" || revResult.stdout === "") return "Already up to date.";
|
|
2567
|
+
const mergeResult = git(root, [
|
|
2568
|
+
"merge",
|
|
2569
|
+
"--ff-only",
|
|
2570
|
+
"FETCH_HEAD"
|
|
2571
|
+
]);
|
|
2572
|
+
if (!mergeResult.stderr) {
|
|
2573
|
+
const msg = mergeResult.stdout || "Pulled successfully.";
|
|
2574
|
+
return msg.endsWith("\n") ? msg.trimEnd() : msg;
|
|
2575
|
+
}
|
|
2576
|
+
if (mergeResult.stderr.includes("non-fast-forward") || mergeResult.stderr.includes("Not possible to fast-forward") || mergeResult.stderr.includes("rejected") || mergeResult.stderr.includes("could not be applied")) return `[REJECTED] Pull from origin/${branch} was rejected (non-fast-forward).\n\n远程有新的提交,本地的历史与远程产生了分歧(非快进)。操作建议:\n 1. 查看差异: cc_git log HEAD..origin/${branch}\n 2. 用 bash 手动合并: git merge origin/${branch}\n 3. 或用 rebase: git rebase origin/${branch}\n 4. 有冲突则手动解决后: git add <file> && git commit\n 5. 推送: cc_git push`;
|
|
2577
|
+
throw new Error(`cc_git pull failed:\n${mergeResult.stderr}`);
|
|
1751
2578
|
}
|
|
1752
2579
|
case "log": {
|
|
1753
|
-
const n = args.count ?? 10;
|
|
2580
|
+
const n = Math.min(Math.max(args.count ?? 10, 1), 100);
|
|
1754
2581
|
const r = git(root, [
|
|
1755
2582
|
"-c",
|
|
1756
2583
|
"core.quotepath=false",
|
|
@@ -1759,8 +2586,19 @@ function runGit(root, args) {
|
|
|
1759
2586
|
"-n",
|
|
1760
2587
|
String(n)
|
|
1761
2588
|
]);
|
|
1762
|
-
if (
|
|
1763
|
-
|
|
2589
|
+
if (r.stderr) throw new Error(`git log 失败:${r.stderr.trim()}`);
|
|
2590
|
+
if (!r.stdout) return "(no commits)";
|
|
2591
|
+
return r.stdout;
|
|
2592
|
+
}
|
|
2593
|
+
case "diff": {
|
|
2594
|
+
const diffArgs = ["diff"];
|
|
2595
|
+
if (args.staged) diffArgs.push("--cached");
|
|
2596
|
+
if (args.ref) diffArgs.push(args.ref);
|
|
2597
|
+
if (args.path) diffArgs.push("--", args.path);
|
|
2598
|
+
const { stdout, stderr } = git(root, diffArgs);
|
|
2599
|
+
if (stderr) return `[WARN] git diff had stderr:\n${stderr}`;
|
|
2600
|
+
if (!stdout) return "(no diff)";
|
|
2601
|
+
return stdout;
|
|
1764
2602
|
}
|
|
1765
2603
|
default: throw new Error(`未知 action: ${args.action}`);
|
|
1766
2604
|
}
|
|
@@ -1781,21 +2619,35 @@ function renderText$6(value) {
|
|
|
1781
2619
|
}
|
|
1782
2620
|
const gitTool = defineTool({
|
|
1783
2621
|
name: "cc_git",
|
|
1784
|
-
description: "CCC 内 git 操作(cc-git
|
|
2622
|
+
description: "CCC 内 git 操作(cc-git 语义,对齐 osp)。status/commit/push/log/pull/diff;push/pull 非快进时输出 [REJECTED] + 操作建议(绝不自动 force)。merge/rebase/冲突解决走 bash。",
|
|
1785
2623
|
parameters: {
|
|
1786
2624
|
action: {
|
|
1787
2625
|
type: "string",
|
|
1788
2626
|
enum: [...GIT_ACTIONS],
|
|
1789
2627
|
required: true,
|
|
1790
|
-
description: "
|
|
2628
|
+
description: "子命令:status/commit/push/log/pull/diff"
|
|
1791
2629
|
},
|
|
1792
2630
|
message: {
|
|
1793
2631
|
type: "string",
|
|
1794
|
-
description: "commit
|
|
2632
|
+
description: "commit 消息(commit 必填)"
|
|
1795
2633
|
},
|
|
1796
2634
|
count: {
|
|
1797
2635
|
type: "integer",
|
|
1798
|
-
|
|
2636
|
+
minimum: 1,
|
|
2637
|
+
maximum: 100,
|
|
2638
|
+
description: "log 条数(默认 10,max 100)"
|
|
2639
|
+
},
|
|
2640
|
+
staged: {
|
|
2641
|
+
type: "boolean",
|
|
2642
|
+
description: "diff: 显示暂存区变更(--cached)"
|
|
2643
|
+
},
|
|
2644
|
+
ref: {
|
|
2645
|
+
type: "string",
|
|
2646
|
+
description: "diff: 对比 ref(如 HEAD~1 / main / origin/main)"
|
|
2647
|
+
},
|
|
2648
|
+
path: {
|
|
2649
|
+
type: "string",
|
|
2650
|
+
description: "diff: 限定路径(如 src/、package.json)"
|
|
1799
2651
|
}
|
|
1800
2652
|
},
|
|
1801
2653
|
output: {
|
|
@@ -1824,13 +2676,13 @@ function renderText$5(value) {
|
|
|
1824
2676
|
}
|
|
1825
2677
|
const msmTool = defineTool({
|
|
1826
2678
|
name: "acc_msm",
|
|
1827
|
-
description: "MSM(Mech & Semi-Mech)框架:list 列出注册 MSM
|
|
2679
|
+
description: "MSM(Mech & Semi-Mech)框架:list 列出注册 MSM(header+flags 展示);exec 执行(600s 超时,path 逃逸+symlink 阻断,注入 SERENITY_ROOT/CCC/VERSION env,失败追加 --help TIP;参数首位 --list/--schema/--format=json 为协议);register/deregister 管理注册表(path 根内+脚本存在+全局唯一校验,自动 git 精提交);check 品质检查 DC-M1~M4;guide 开发手册;ccc-config CCC 配置参考(loop.defaultModel/sessionKeeper.threshold/localstore.gitTrack/hooks.autoRestoreSession)。复用 CCC 的 mech-registry.json。",
|
|
1828
2680
|
parameters: {
|
|
1829
2681
|
action: {
|
|
1830
2682
|
type: "string",
|
|
1831
2683
|
enum: [...MSM_ACTIONS],
|
|
1832
2684
|
required: true,
|
|
1833
|
-
description: "
|
|
2685
|
+
description: "子命令:list/exec/register/deregister/check/guide/ccc-config"
|
|
1834
2686
|
},
|
|
1835
2687
|
name: {
|
|
1836
2688
|
type: "string",
|
|
@@ -1839,7 +2691,7 @@ const msmTool = defineTool({
|
|
|
1839
2691
|
args: {
|
|
1840
2692
|
type: "array",
|
|
1841
2693
|
items: { type: "string" },
|
|
1842
|
-
description: "exec
|
|
2694
|
+
description: "exec 的业务参数(参数首位 --list/--schema <n>/--format=json 为协议 flag,其余无损透传)"
|
|
1843
2695
|
},
|
|
1844
2696
|
skill: {
|
|
1845
2697
|
type: "string",
|
|
@@ -1847,7 +2699,7 @@ const msmTool = defineTool({
|
|
|
1847
2699
|
},
|
|
1848
2700
|
path: {
|
|
1849
2701
|
type: "string",
|
|
1850
|
-
description: "register
|
|
2702
|
+
description: "register 的脚本相对路径(必须根内且存在)"
|
|
1851
2703
|
},
|
|
1852
2704
|
category: {
|
|
1853
2705
|
type: "string",
|
|
@@ -1856,6 +2708,14 @@ const msmTool = defineTool({
|
|
|
1856
2708
|
description: {
|
|
1857
2709
|
type: "string",
|
|
1858
2710
|
description: "register 的描述"
|
|
2711
|
+
},
|
|
2712
|
+
flags: {
|
|
2713
|
+
type: "string",
|
|
2714
|
+
description: "register 的 flags JSON 字符串(new-style 对象数组,type:\"path\" 启用逃逸校验)"
|
|
2715
|
+
},
|
|
2716
|
+
usage: {
|
|
2717
|
+
type: "string",
|
|
2718
|
+
description: "register 的自定义 usage(缺省自动生成)"
|
|
1859
2719
|
}
|
|
1860
2720
|
},
|
|
1861
2721
|
output: {
|
|
@@ -2136,6 +2996,7 @@ function writeProgress(root, label, p) {
|
|
|
2136
2996
|
mkdirSync(join(root, "AGENT_SESSIONS"), { recursive: true });
|
|
2137
2997
|
writeFileSync(json, JSON.stringify({
|
|
2138
2998
|
...p,
|
|
2999
|
+
status: p.status ?? "running",
|
|
2139
3000
|
updated: (/* @__PURE__ */ new Date()).toISOString()
|
|
2140
3001
|
}, null, 2) + "\n", "utf-8");
|
|
2141
3002
|
const lines = [
|
|
@@ -2151,6 +3012,23 @@ function writeProgress(root, label, p) {
|
|
|
2151
3012
|
];
|
|
2152
3013
|
writeFileSync(md, lines.join("\n"), "utf-8");
|
|
2153
3014
|
}
|
|
3015
|
+
/** 失败状态落盘(对齐 osp writeFailedStatus:done=true / status=failed / errorCode) */
|
|
3016
|
+
function writeFailedStatus(root, label, info) {
|
|
3017
|
+
const { json } = loopProgressPaths(root, label);
|
|
3018
|
+
mkdirSync(join(root, "AGENT_SESSIONS"), { recursive: true });
|
|
3019
|
+
const prev = readProgress(root, label);
|
|
3020
|
+
writeFileSync(json, JSON.stringify({
|
|
3021
|
+
round: prev?.round ?? 0,
|
|
3022
|
+
done: true,
|
|
3023
|
+
label,
|
|
3024
|
+
model: prev?.model ?? "",
|
|
3025
|
+
status: "failed",
|
|
3026
|
+
errorCode: info.errorCode,
|
|
3027
|
+
errorMessage: info.errorMessage,
|
|
3028
|
+
updated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3029
|
+
lastResponse: prev?.lastResponse ?? ""
|
|
3030
|
+
}, null, 2) + "\n", "utf-8");
|
|
3031
|
+
}
|
|
2154
3032
|
function newStopToken() {
|
|
2155
3033
|
return `SERENITY_LOOP_DONE_${randomBytes(8).toString("hex")}`;
|
|
2156
3034
|
}
|
|
@@ -2224,7 +3102,7 @@ const LOOP_GUIDE = `# loop — 规模化使用指引(guide)
|
|
|
2224
3102
|
- 汇总:并行 loop 各自产出进度后,主 agent 汇总合并(或再派一个汇总 loop)
|
|
2225
3103
|
|
|
2226
3104
|
## 完成判定
|
|
2227
|
-
- 唯一完成条件 = loop 内部 agent 精确回显本轮随机验证码(stop token
|
|
3105
|
+
- 唯一完成条件 = loop 内部 agent 精确回显本轮随机验证码(stop token);对话轮次上限 100(对齐 osp 保险阀,超限强制结束可续跑)
|
|
2228
3106
|
- agent 非正常停止时自动重启(≤100 次防死循环)
|
|
2229
3107
|
|
|
2230
3108
|
## 等待界面
|
|
@@ -2317,16 +3195,17 @@ function lastAssistantText(agent) {
|
|
|
2317
3195
|
function createLoopTool(ctx) {
|
|
2318
3196
|
return defineTool({
|
|
2319
3197
|
name: "loop",
|
|
2320
|
-
description: "牛马循环(老 loop 等效):用指定模型创建专用 agent 反复执行任务直到完成。\n用法:loop guide(输出规模化使用指引——使用前先加载 eap 设计规模化方案);loop 接受 task(要完成的目标)或依赖 session 上下文;模型缺省读 .opencode/serenity.json 的 loop.defaultModel(当前 minimax-cn-coding-plan/MiniMax-M3,廉价牛马)。\n行为:内部硬性 while 循环驱动 agent 逐轮推进任务,每轮等待无超时(agent 工作多久等多久)。唯一完成条件 = agent 精确回显本轮随机完成码(stop token
|
|
3198
|
+
description: "牛马循环(老 loop 等效):用指定模型创建专用 agent 反复执行任务直到完成。\n用法:loop guide(输出规模化使用指引——使用前先加载 eap 设计规模化方案);loop 接受 task(要完成的目标)或依赖 session 上下文;模型缺省读 .opencode/serenity.json 的 loop.defaultModel(当前 minimax-cn-coding-plan/MiniMax-M3,廉价牛马)。\n行为:内部硬性 while 循环驱动 agent 逐轮推进任务,每轮等待无超时(agent 工作多久等多久)。唯一完成条件 = agent 精确回显本轮随机完成码(stop token),防止低智能模型提前结束。轮次不需要调用者指定——对话轮次上限 100(对齐 osp 保险阀,超限强制结束可续跑),agent 非正常停止时自动重启(重启 ≤100 次,防死循环保险阀)。\n进度:写入 AGENT_SESSIONS/loop-<label>.md/.json;同 label 再次调用从上次轮次续跑(不重做)。\n约束:loop agent 受完整 Serenity 约束(ACC 身份/入口技能系统提示词/守卫/session-keeper)。\n示例:loop 执行「扫描 SQC 并修复 DC 问题」,label: sqc-scan;loop guide",
|
|
2321
3199
|
parameters: {
|
|
2322
3200
|
task: {
|
|
2323
3201
|
type: "string",
|
|
2324
|
-
|
|
3202
|
+
required: true,
|
|
3203
|
+
description: "要完成的任务目标(必填:告诉 loop agent 做什么)"
|
|
2325
3204
|
},
|
|
2326
3205
|
label: {
|
|
2327
3206
|
type: "string",
|
|
2328
3207
|
required: true,
|
|
2329
|
-
description: "
|
|
3208
|
+
description: "任务标签(1-50 字符;进度文件命名 loop-<label>.md/.json)"
|
|
2330
3209
|
},
|
|
2331
3210
|
session: {
|
|
2332
3211
|
type: "string",
|
|
@@ -2387,9 +3266,14 @@ function createLoopTool(ctx) {
|
|
|
2387
3266
|
let lastResponse = progress?.lastResponse ?? "";
|
|
2388
3267
|
let finalRound = startRound - 1;
|
|
2389
3268
|
let restarts = 0;
|
|
3269
|
+
let finishReason = "done";
|
|
2390
3270
|
try {
|
|
2391
3271
|
let round = startRound;
|
|
2392
3272
|
while (true) {
|
|
3273
|
+
if (round > 100) {
|
|
3274
|
+
finishReason = "max_rounds";
|
|
3275
|
+
break;
|
|
3276
|
+
}
|
|
2393
3277
|
finalRound = round;
|
|
2394
3278
|
const prompt = buildRoundPrompt({
|
|
2395
3279
|
root,
|
|
@@ -2415,7 +3299,10 @@ function createLoopTool(ctx) {
|
|
|
2415
3299
|
lastResponse = lastAssistantText(loopAgent);
|
|
2416
3300
|
} catch {
|
|
2417
3301
|
restarts++;
|
|
2418
|
-
if (restarts > 100)
|
|
3302
|
+
if (restarts > 100) {
|
|
3303
|
+
finishReason = "restart_exceeded";
|
|
3304
|
+
break;
|
|
3305
|
+
}
|
|
2419
3306
|
await handle.dispose().catch(() => {});
|
|
2420
3307
|
await spawnAgent();
|
|
2421
3308
|
continue;
|
|
@@ -2431,6 +3318,7 @@ function createLoopTool(ctx) {
|
|
|
2431
3318
|
writeProgress(root, label, progress);
|
|
2432
3319
|
if (lastResponse.includes(stopToken)) {
|
|
2433
3320
|
done = true;
|
|
3321
|
+
finishReason = "done";
|
|
2434
3322
|
break;
|
|
2435
3323
|
}
|
|
2436
3324
|
round++;
|
|
@@ -2441,7 +3329,12 @@ function createLoopTool(ctx) {
|
|
|
2441
3329
|
label,
|
|
2442
3330
|
model,
|
|
2443
3331
|
updated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2444
|
-
lastResponse
|
|
3332
|
+
lastResponse,
|
|
3333
|
+
status: done ? "done" : "running"
|
|
3334
|
+
});
|
|
3335
|
+
if (finishReason !== "done") writeFailedStatus(root, label, {
|
|
3336
|
+
errorCode: finishReason,
|
|
3337
|
+
errorMessage: finishReason === "max_rounds" ? `Reached 100 rounds without completion — resume with same label to continue.` : `Agent restarted 100 times without progress — resume with same label to continue.`
|
|
2445
3338
|
});
|
|
2446
3339
|
} finally {
|
|
2447
3340
|
await handle.dispose().catch(() => {});
|
|
@@ -2450,16 +3343,17 @@ function createLoopTool(ctx) {
|
|
|
2450
3343
|
return {
|
|
2451
3344
|
done,
|
|
2452
3345
|
rounds: finalRound,
|
|
3346
|
+
finishReason,
|
|
2453
3347
|
restarts,
|
|
2454
3348
|
model,
|
|
2455
3349
|
label,
|
|
2456
3350
|
progressFile: json,
|
|
2457
3351
|
lastResponse: lastResponse.slice(0, 2e3),
|
|
2458
3352
|
usage: {
|
|
2459
|
-
how:
|
|
3353
|
+
how: `loop 内部硬性 while 驱动 agent 逐轮推进,agent 精确回显随机完成码即终止;对话轮次上限 100(对齐 osp 保险阀),agent 非正常停止时自动重启(≤100 次)`,
|
|
2460
3354
|
progress: `进度在 AGENT_SESSIONS/loop-${label}.md 与 .json;同 label 再调 loop 会从下一轮续跑(不重做)`,
|
|
2461
3355
|
constraints: "loop agent 受完整 Serenity 约束(ACC 身份/入口技能系统提示词/守卫)",
|
|
2462
|
-
next: done ? "任务已完成;可查看进度文件收尾" :
|
|
3356
|
+
next: done ? "任务已完成;可查看进度文件收尾" : `任务未完成(${finishReason},已达 100 轮或 100 次重启保险阀);可同 label 续跑`
|
|
2463
3357
|
}
|
|
2464
3358
|
};
|
|
2465
3359
|
}
|
|
@@ -3113,10 +4007,14 @@ function registerContext(ctx, opts = {}) {
|
|
|
3113
4007
|
if (!root) return;
|
|
3114
4008
|
const key = agentKey(agent);
|
|
3115
4009
|
registerEntrySkillSection(agent, root);
|
|
3116
|
-
|
|
4010
|
+
const scope = agentScope(agent);
|
|
4011
|
+
if (shouldRestoreActive(agent) && getActiveSessionInfo(scope) === null) try {
|
|
3117
4012
|
if (loadSerenityConfig(root, configPaths).hooks?.autoRestoreSession ?? true) {
|
|
3118
|
-
const
|
|
3119
|
-
if (
|
|
4013
|
+
const info = parseSessionContextFromEvents(agent.session.events ?? []);
|
|
4014
|
+
if (info) {
|
|
4015
|
+
setActiveSessionInfo(scope, info);
|
|
4016
|
+
console.log(`[serenity-hooks] ↻ 从历史恢复激活会话: ${info.dirName}`);
|
|
4017
|
+
}
|
|
3120
4018
|
}
|
|
3121
4019
|
} catch {}
|
|
3122
4020
|
if (injected.has(key)) return;
|