@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/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
- * 移植自 dsh-serenity-plugin v0.1 acc-fs runner(本项目自有代码)。
108
- * 每个操作返回规范 JSON 值(由工具层 render 成模型可见文本)。
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
- return existsSync(resolveInside(root, args.path));
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 dir = args.path ? resolveInside(root, args.path) : root;
142
- if (!existsSync(dir)) throw new Error(`no such dir: ${dir}`);
143
- return readdirSync(dir, { withFileTypes: true }).map((e) => ({
144
- name: e.name,
145
- type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other"
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 dir = args.path ? resolveInside(root, args.path) : root;
150
- const maxDepth = args.depth ?? Infinity;
151
- if (!existsSync(dir)) throw new Error(`no such dir: ${dir}`);
152
- const out = [];
153
- const walk = (cur, depth) => {
154
- if (depth > maxDepth) return;
155
- for (const e of readdirSync(cur, { withFileTypes: true })) {
156
- const full = join(cur, e.name);
157
- out.push({
158
- path: safeRel(root, full),
159
- type: e.isDirectory() ? "dir" : "file"
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
- return safeRel(root, resolveInside(root, args.path));
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
- const targets = args.paths?.length ? args.paths : args.path ? [args.path] : [];
172
- if (targets.length === 0) throw new Error("mkdir 需要 path(s)");
173
- for (const t of targets) mkdirSync(resolveInside(root, t), { recursive: true });
174
- return {
175
- ok: true,
176
- created: targets
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?.length ? args.paths : args.path ? [args.path] : [];
181
- if (targets.length === 0) throw new Error("rm 需要 path(s)");
182
- const removed = [];
183
- for (const t of targets) {
184
- const abs = resolveInside(root, t);
185
- if (abs === root) throw new Error("拒绝删除 CCC 根本身");
186
- if (!existsSync(abs)) continue;
187
- if (args.dryRun) {
188
- removed.push(`${t} [dry-run]`);
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
- rmSync(abs, {
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: true
289
+ force: false
194
290
  });
195
- removed.push(t);
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
- renameSync(resolveInside(root, args.src), resolveInside(root, args.dst));
205
- return {
206
- ok: true,
207
- from: args.src,
208
- to: args.dst
209
- };
210
- case "cp":
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
- cpSync(resolveInside(root, args.src), resolveInside(root, args.dst), { recursive: true });
213
- return {
214
- ok: true,
215
- from: args.src,
216
- to: args.dst
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 abs = resolveInside(root, args.path);
221
- if (!existsSync(abs)) writeFileSync(abs, "", "utf-8");
222
- return {
223
- ok: true,
224
- path: safeRel(root, abs)
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 abs = resolveInside(root, args.path);
230
- appendFileSync(abs, args.content, "utf-8");
231
- return {
232
- ok: true,
233
- path: safeRel(root, abs)
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 abs = resolveInside(root, args.path);
239
- if (!existsSync(abs)) throw new Error(`no such path: ${abs}`);
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", abs], { timeout: 1e4 });
347
+ if (os === "darwin") execFileSync("open", ["-R", absPath], { timeout: 1e4 });
243
348
  else if (os === "linux") {
244
- const revealPath = statSync(abs).isDirectory() ? abs : dirname(abs);
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 args = statSync(abs).isDirectory() ? [abs] : ["/select,", abs];
248
- const child = spawn("explorer", args, {
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 "${safeRel(root, abs)}": ${msg}`);
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 abs = resolveInside(root, args.path);
268
- if (!existsSync(abs)) return {
269
- exists: false,
270
- path: safeRel(root, abs)
271
- };
272
- const st = statSync(abs);
273
- return {
274
- exists: true,
275
- path: safeRel(root, abs),
276
- type: st.isDirectory() ? "dir" : st.isFile() ? "file" : "other",
277
- size: st.size,
278
- mtime: st.mtime.toISOString()
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 isRegex = pattern.startsWith("regex:");
285
- const re = isRegex ? new RegExp(pattern.slice(6)) : null;
286
- const out = [];
287
- const walk = (cur) => {
288
- for (const e of readdirSync(cur, { withFileTypes: true })) {
289
- const full = join(cur, e.name);
290
- if (isRegex ? re.test(e.name) : e.name.includes(pattern)) out.push(safeRel(root, full));
291
- if (e.isDirectory()) walk(full);
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 匹配(名称包含;regex: 前缀为正则)"
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 + git commit
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
- - flags type:"path" 的参数会被逃逸校验(根内强制)
409
- - 配对 .test.ts(vitest)
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
- ## 品质检查(acc_msm check)
412
- DC-M1 .test.ts;DC-M2 main() 守卫;M3 脚本存在;M4 path flag 标记 type:"path"
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
- writeFileSync(path, JSON.stringify({
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", "utf-8");
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": return loadMsmEntries(root).map((e) => ({
454
- name: e.name,
455
- skill: e.skill ?? null,
456
- category: e.category ?? null,
457
- description: e.description ?? ""
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
- if (entries.some((e) => e.name === name)) throw new Error(`MSM already registered: "${name}"`);
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: `msm_exec ${name} [args...]`,
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
- execFileSync("git", ["add", "-A"], {
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
- execFileSync("git", ["add", "-A"], {
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 testFile = script.replace(/\.ts$/, ".test.ts");
565
- if (!existsSync(testFile)) issues.push({
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 && !readFileSync(script, "utf-8").includes("import.meta.url")) issues.push({
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: "M2",
573
- detail: "no main() guard"
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.includes("--list")) return {
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
- const schemaIdx = business.indexOf("--schema");
599
- if (schemaIdx >= 0) {
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.includes("--format=json");
619
- const businessArgs = business.filter((a) => a !== "--format=json");
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
- const value = eq.slice(flag.name.length + 3);
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
- function msmExecResult(name, status, stdout, stderr, fmtJson) {
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
- * 移植自 dsh-serenity-plugin v0.1 acc-session runner(本项目自有代码)。
722
- * 操作 CCC 根的 AGENT_SESSIONS/ 目录,返回规范 JSON 值。
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 SESSION_DIR_RE = /^(\d{4}-\d{2}-\d{2})--(S\d{3})--(.+)$/;
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
- function listSessions(root) {
744
- const sessRoot = sessionsRoot(root);
745
- if (!existsSync(sessRoot)) return [];
746
- const out = [];
747
- for (const entry of readdirSync(sessRoot)) {
748
- const full = join(sessRoot, entry);
749
- if (!statSync(full).isDirectory()) continue;
750
- const md = join(full, "SESSION.md");
751
- const m = SESSION_DIR_RE.exec(entry);
752
- let status = null;
753
- if (existsSync(md)) status = /\[x\]|\[X\]/.test(readFileSync(md, "utf-8")) ? "done" : "open";
754
- out.push({
755
- dir: entry,
756
- id: m?.[2] ?? null,
757
- hasSessionMd: existsSync(md),
758
- mtime: statSync(full).mtime.toISOString(),
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
- function nextSessionId(sessions) {
766
- let max = 0;
767
- for (const s of sessions) if (s.id) {
768
- const n = Number(s.id.slice(1));
769
- if (n > max) max = n;
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
- function findSession(root, key) {
774
- return listSessions(root).find((s) => s.dir.includes(key) || (s.id ?? "") === key.toUpperCase()) ?? null;
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
- function createSession(root, name, title) {
777
- const id = nextSessionId(listSessions(root));
778
- const dirName = `${today()}--${id}--${name}`;
779
- const dir = join(sessionsRoot(root), dirName);
780
- mkdirSync(dir, { recursive: true });
781
- const md = join(dir, "SESSION.md");
782
- writeFileSync(md, `# SESSION: ${title}\n- ID: ${id}\n\n## 目标\n<一句话描述本次会话要完成的事情>\n\n## 状态\n- [ ] 进行中\n\n## 关键决策\n| # | 决策 | 理由 |\n|---|------|------|\n| 1 | | |\n\n## 进度记录\n- ${today()} 会话创建\n\n## 产出物\n- \n\n## 未解决的问题\n- \n`, "utf-8");
783
- return {
784
- dir: dirName,
785
- id,
786
- sessionMd: md
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 target = findSession(root, key);
791
- if (!target) {
792
- for (const s of listSessions(root)) {
793
- const md = join(sessionsRoot(root), s.dir, "SESSION.md");
794
- if (existsSync(md) && readFileSync(md, "utf-8").includes(key)) return {
795
- dir: s.dir,
796
- content: readFileSync(md, "utf-8")
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 md = join(sessionsRoot(root), target.dir, "SESSION.md");
802
- if (!existsSync(md)) throw new Error(`会话 ${target.dir} 缺少 SESSION.md`);
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
- dir: target.dir,
805
- content: readFileSync(md, "utf-8")
1243
+ message: `Created: ${dirName}/ (S${nextId})`,
1244
+ dirName,
1245
+ sessionPath,
1246
+ sessionId: `S${nextId}`
806
1247
  };
807
1248
  }
808
1249
  /**
809
- * 活动会话标记:按 DSH 会话(agent.session.id)隔离,不再使用 CCC 级全局单文件。
810
- * 每个 dsh 会话一个标记文件(.dsh/active-sessions/<scope>),系统提示词 Session
811
- * 只注入当前 dsh 会话 use 的活跃会话 —— 多开 conversation / subagent / loop 牛马互不泄露。
812
- *
813
- * 旧版全局标记 `.dsh/active-session`(v1.16.1 及以前):use 时删除(迁移清理),
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
- /** scope 用作文件名:仅保留安全字符(agent.session.id 可能含 / 等;`.` 排除以杜绝 `..` 路径段穿越) */
822
- function sanitizeScope(scope) {
823
- return scope.replace(/[^A-Za-z0-9_-]/g, "_") || "default";
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
- /** scope 标记文件绝对路径 */
826
- function activeSessionMarker(root, scope) {
827
- return resolve(root, ACTIVE_SESSIONS_DIR, sanitizeScope(scope));
828
- }
829
- /** 激活会话:写 <scope> 标记(内容 = 相对 CCC 根的 SESSION.md 路径);顺带清理旧全局标记 */
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 target = findSession(root, key);
832
- if (!target) throw new Error(`未找到会话: ${key}`);
833
- const md = join(sessionsRoot(root), target.dir, "SESSION.md");
834
- if (!existsSync(md)) throw new Error(`会话 ${target.dir} 缺少 SESSION.md`);
835
- const marker = activeSessionMarker(root, scope);
836
- mkdirSync(resolve(root, ".dsh", "active-sessions"), { recursive: true });
837
- const relMd = relative(root, md);
838
- writeFileSync(marker, relMd, "utf-8");
839
- const legacy = resolve(root, LEGACY_ACTIVE_SESSION_MARKER);
840
- if (existsSync(legacy)) rmSync(legacy, { force: true });
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: target.dir,
843
- mdPath: md
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
- /** 关闭活动会话:删除 <scope> 标记 + 旧全局标记 */
847
- function closeSession(root, scope = DEFAULT_SESSION_SCOPE) {
848
- const marker = activeSessionMarker(root, scope);
849
- if (existsSync(marker)) rmSync(marker, { force: true });
850
- const legacy = resolve(root, LEGACY_ACTIVE_SESSION_MARKER);
851
- if (existsSync(legacy)) rmSync(legacy, { force: true });
852
- return { dir: "active-session cleared" };
853
- }
854
- /** 读取指定 scope 的活跃会话 SESSION.md 绝对路径;无标记/越界返回 null */
855
- function readActiveSessionMd(root, scope = DEFAULT_SESSION_SCOPE) {
856
- const marker = activeSessionMarker(root, scope);
857
- if (!existsSync(marker)) return null;
858
- const rel = readFileSync(marker, "utf-8").trim();
859
- if (!rel) return null;
860
- const abs = resolve(root, rel);
861
- if (!abs.startsWith(resolve(root))) return null;
862
- return abs;
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
- * 重启恢复:当前 scope 无标记时,把"最近激活"(mtime 最新)且根内有效的标记
886
- * 复制为当前 scope 标记(激活语义延续:use = 激活,重启自动恢复 = 重新激活)。
887
- * 返回恢复的会话信息;已有标记 / 无候选 / 全部越界 → null。
888
- * 调用方负责根会话判定(subagent / loop 牛马不恢复——见 context.ts shouldAutoRestore)。
1339
+ * 从会话历史(events)解析最后一条 [SESSION CONTEXT] 标记(进程重启恢复;
1340
+ * 只扫**当前会话**自己的历史——无跨会话串台)。
889
1341
  */
890
- function restoreActiveSession(root, scope = DEFAULT_SESSION_SCOPE) {
891
- const marker = activeSessionMarker(root, scope);
892
- if (existsSync(marker)) return null;
893
- const scopeName = sanitizeScope(scope);
894
- const candidates = listActiveMarkers(root).filter((m) => m.scope !== scopeName);
895
- if (candidates.length === 0) return null;
896
- const rootAbs = resolve(root);
897
- const valid = candidates.filter((m) => pathInside(rootAbs, resolve(rootAbs, m.mdRel)));
898
- if (valid.length === 0) return null;
899
- const best = valid.sort((a, b) => b.mtime - a.mtime)[0];
900
- mkdirSync(resolve(rootAbs, ACTIVE_SESSIONS_DIR), { recursive: true });
901
- writeFileSync(marker, best.mdRel, "utf-8");
902
- const mdPath = resolve(rootAbs, best.mdRel);
903
- const dirName = basename(dirname(mdPath));
904
- const idMatch = dirName.match(/S(\d{3,})/);
905
- return {
906
- dir: dirName,
907
- id: idMatch ? `S${idMatch[1]}` : null,
908
- mdPath,
909
- restored: true,
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
- function archiveSession(root, key) {
914
- const target = findSession(root, key);
915
- if (!target) throw new Error(`未找到会话: ${key}`);
916
- const md = join(sessionsRoot(root), target.dir, "SESSION.md");
917
- if (!existsSync(md)) throw new Error(`会话 ${target.dir} 缺少 SESSION.md`);
918
- let content = readFileSync(md, "utf-8");
919
- content = content.replace(/^-\s*\[ \]\s*进行中$/m, "- [x] 已完成").replace(/^-\s*\[ \]\s*已关闭(未完成)$/m, "- [x] 已关闭(未完成)");
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 problems = [];
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
- for (const s of listSessions(root)) {
929
- const age = (now - new Date(s.mtime).getTime()) / DAY;
930
- if (!s.hasSessionMd) problems.push({
931
- dir: s.dir,
932
- kind: "missing-md",
933
- detail: "缺少 SESSION.md"
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
- else if (age > 14) problems.push({
936
- dir: s.dir,
937
- kind: "stale",
938
- detail: `${Math.round(age)} 天未更新`
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 problems;
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 = listSessions(root);
945
- const done = sessions.filter((s) => s.status === "done").length;
946
- const stale = sessions.filter((s) => (Date.now() - new Date(s.mtime).getTime()) / DAY > 14).length;
947
- return {
948
- total: sessions.length,
949
- open: sessions.length - done,
950
- done,
951
- stale,
952
- recent: sessions.slice(0, 5)
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 中记录的产出物路径(- `path` 说明 行)是否真实存在 */
1468
+ /** 事实核对:SESSION.md 声明 vs 实际情况(结构/一致性/新鲜度/决策质量/产出物) */
956
1469
  function qaCheck(root, key) {
957
- const { dir, content } = showSession(root, key);
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 line of content.split("\n")) {
960
- const m = /^-\s*(`[^`]+`|[^\s|]+)\s*—/.exec(line.trim());
961
- if (!m) continue;
962
- const p = m[1].replace(/`/g, "");
963
- if (!existsSync(resolve(root, p))) issues.push({
964
- path: p,
965
- kind: "missing"
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
- return {
969
- dir,
970
- issues
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
- * 逻辑在 session-ops.ts(可单测)。
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。多步骤工作必须先 create 会话,use 激活当前 dsh 会话的活跃会话(写 .dsh/active-sessions/<scope> 标记 系统提示词 Session 块生效,按 dsh 会话隔离不泄露)。",
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
- key: {
1747
+ name: {
1014
1748
  type: "string",
1015
- description: "show/use/archive/qa 的会话标识(S### 或目录名或关键词)"
1749
+ description: "show/use/close/archive/qa 的会话标识(S### 或目录名或关键词)"
1016
1750
  },
1017
- name: {
1751
+ desc: {
1752
+ type: "string",
1753
+ description: "create 的短描述(任意语言,≤5 词;与 issue 互斥)"
1754
+ },
1755
+ issue: {
1018
1756
  type: "string",
1019
- description: "create 的短描述(小写英文连词符,≤5 词)"
1757
+ description: "create 的工单号(如 apaas-26116;目录命名 YYYY-MM-DD--<issue>;与 desc 互斥)"
1020
1758
  },
1021
- title: {
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
- if (findEntry(root, "session-tool")) {
1034
- const r = runMsm(root, {
1035
- action: "exec",
1036
- name: "session-tool",
1037
- args: [args.action, ...args.key ? [args.key] : []]
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.key) throw new Error("show 需要 key");
1056
- return showSession(root, args.key);
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.key) throw new Error("use 需要 key");
1059
- return useSession(root, args.key, agentScope$2(exec));
1060
- case "close": return closeSession(root, agentScope$2(exec));
1061
- case "archive":
1062
- if (!args.key) throw new Error("archive 需要 key");
1063
- return archiveSession(root, args.key);
1064
- case "health": return { problems: healthCheck(root) };
1065
- case "summary": return summarize(root);
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.key) throw new Error("qa 需要 key");
1068
- return qaCheck(root, args.key);
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
- * health: CCC 三原则检查(P1 .serenity / P2 git / 配置)
1298
- * time: ISO 8601 时间戳
1299
- * wait: 等待 N 秒(纯 Node setTimeout——不依赖外部 sleep 可执行文件,
1300
- * Windows GNU coreutils sleep,spawn 必 ENOENT,见 Windows 兼容审计问题 3)
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 gitRoot = findGitRoot(root);
1311
- let config = null;
1312
- let configPath = null;
1313
- for (const candidate of DEFAULT_SERENITY_CONFIG_PATHS) {
1314
- const p = resolve(root, candidate);
1315
- if (!existsSync(p)) continue;
1316
- try {
1317
- config = JSON.parse(readFileSync(p, "utf-8"));
1318
- configPath = p;
1319
- } catch {
1320
- config = { parseError: true };
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
- cwd: root,
1327
- serenityRoot: findSerenityRoot(root),
1328
- gitRoot,
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 n = args.seconds ?? 0;
1341
- if (!Number.isFinite(n) || n < 0) throw new Error("wait 需要非负秒数");
1342
- await new Promise((r) => setTimeout(r, Math.round(n * 1e3)));
1343
- return { waited: n };
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/配置)/ time(ISO 时间戳)/ wait(等待 N 秒)。进入 CCC 工作前的例行自检。",
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: "number",
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
- const root = findSerenityRoot(agentCwd$5(exec));
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
- * status / commit / push / log;push 非快进时输出操作建议(绝不自动 force)。
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: "utf-8",
2457
+ encoding: "utf8",
1679
2458
  stdio: [
1680
- "pipe",
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
- ok: false,
1690
- stdout: err.stdout?.toString() ?? "",
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 (!r.ok) throw new Error(`status 失败:${r.stderr.trim()}`);
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: r.stdout.trim() === "",
1707
- entries: r.stdout.trim() ? r.stdout.trim().split("\n") : []
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 需要 message");
1717
- const add = git(root, ["add", "-A"]);
1718
- if (!add.ok) throw new Error(`git add 失败:${add.stderr.trim()}`);
1719
- const commit = git(root, [
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 (!commit.ok) {
1725
- if (commit.stderr.includes("nothing to commit")) return {
1726
- committed: false,
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
- const r = git(root, [
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
- "HEAD"
2545
+ branch
1741
2546
  ]);
1742
- if (r.ok) return { pushed: true };
1743
- const isNonFF = /non-fast-forward|rejected|fetch first|被拒绝/i.test(r.stderr);
1744
- const out = {
1745
- pushed: false,
1746
- nonFastForward: isNonFF,
1747
- stderr: r.stderr.trim()
1748
- };
1749
- if (isNonFF) out.suggestion = "1. git pull --rebase # 先合并远程变更\n2. 重新 push\n3. 若确需覆盖远程:git push --force-with-lease(人工确认后)";
1750
- return out;
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 (!r.ok) throw new Error(`git log 失败:${r.stderr.trim()}`);
1763
- return { entries: r.stdout.trim() ? r.stdout.trim().split("\n") : [] };
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 语义)。status/commit/push/log;push 非快进时输出操作建议(绝不自动 force)。pull/merge/rebase/冲突解决走 bash。",
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
- description: "log 条数(默认 10)"
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 列出注册 MSMexec 执行(600s 超时,path 逃逸阻断);register/deregister 管理注册表(自动 git commit);check 品质检查 DC-M1~M4。复用 CCC 的 mech-registry.json。",
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),防止低智能模型提前结束。轮次不需要调用者指定——对话轮次**无上限**(不完成不返回),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",
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
- description: "要完成的任务目标(必填语义:告诉 loop agent 做什么;缺省则从 session 上下文推断)"
3202
+ required: true,
3203
+ description: "要完成的任务目标(必填:告诉 loop agent 做什么)"
2325
3204
  },
2326
3205
  label: {
2327
3206
  type: "string",
2328
3207
  required: true,
2329
- description: "任务标签(进度文件命名 loop-<label>.md/.json)"
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) break;
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: "loop 内部硬性 while 驱动 agent 逐轮推进,agent 精确回显随机完成码即终止;对话轮次无上限(不完成不返回),agent 非正常停止时自动重启(≤100 次)",
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 ? "任务已完成;可查看进度文件收尾" : `任务未完成(已达内部 100 次重启保险阀);可同 label 续跑`
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
- if (shouldRestoreActive(agent)) try {
4010
+ const scope = agentScope(agent);
4011
+ if (shouldRestoreActive(agent) && getActiveSessionInfo(scope) === null) try {
3117
4012
  if (loadSerenityConfig(root, configPaths).hooks?.autoRestoreSession ?? true) {
3118
- const restored = restoreActiveSession(root, agentScope(agent));
3119
- if (restored) console.log(`[serenity-hooks] ↻ 自动恢复激活会话: ${restored.dir}(from scope ${restored.from})`);
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;