@shgroup/dsh-serenity-hooks 1.16.5 → 1.16.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.16.5",
3
+ "version": "1.16.7",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):真实 DSH 工具 cc_fs/session/acc_msm/eap/neat/cce/loop + 拦截缝机械约束(safe-mode/路径守卫/会话落盘)。适配 DSH 公开版(0.1.0-rc,deepseek-ai/deepseek-harness)。私有(dsh-external 组织)。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
2
  import { defineTool } from "@deepseek-ai/dsh-tools";
3
- import { appendFileSync, chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, join, relative, resolve } from "node:path";
5
- import { execFile, execFileSync, spawnSync } from "node:child_process";
5
+ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
6
6
  import { homedir, platform } from "node:os";
7
7
  import { promisify } from "node:util";
8
8
  import { fileURLToPath } from "node:url";
@@ -37,11 +37,29 @@ function findGitRoot(cwd) {
37
37
  current = parent;
38
38
  }
39
39
  }
40
+ /**
41
+ * 前缀判定:abs 是否位于 rootAbs 之内。
42
+ * caseInsensitive(Windows 盘符/路径大小写不敏感)由调用方按平台传入;
43
+ * 边界必须是路径分隔符(`\` 或 `/` 任一)——不依赖平台 sep,跨平台语义一致:
44
+ * - 跨盘符绝对路径(root 在 D:\、target 在 C:\)前缀不匹配 → outside
45
+ * - 兄弟目录前缀陷阱(home vs home2)边界非分隔符 → outside
46
+ * (旧实现用 path.relative().startsWith('..')——跨盘时 relative 返回绝对路径原文,
47
+ * 不以 `..` 开头 → 漏判放行,见 Windows 兼容审计问题 1。)
48
+ */
49
+ function pathInside(rootAbs, abs, caseInsensitive = process.platform === "win32") {
50
+ const r = caseInsensitive ? rootAbs.toLowerCase() : rootAbs;
51
+ const a = caseInsensitive ? abs.toLowerCase() : abs;
52
+ if (a === r) return true;
53
+ if (!a.startsWith(r)) return false;
54
+ const next = a[r.length];
55
+ return next === "\\" || next === "/";
56
+ }
40
57
  function classifyPath(p, root) {
41
- const rel = relative(resolve(root), resolve(p));
42
- if (rel === "") return "same";
43
- if (rel.startsWith("..")) return "outside";
44
- return "inside";
58
+ const rootAbs = resolve(root);
59
+ const abs = resolve(p);
60
+ const ci = process.platform === "win32";
61
+ if (ci ? abs.toLowerCase() === rootAbs.toLowerCase() : abs === rootAbs) return "same";
62
+ return pathInside(rootAbs, abs, ci) ? "inside" : "outside";
45
63
  }
46
64
  function resolveInside(root, p) {
47
65
  const abs = resolve(root, p);
@@ -221,8 +239,16 @@ function runCcFs(root, args) {
221
239
  else if (os === "linux") {
222
240
  const revealPath = statSync(abs).isDirectory() ? abs : dirname(abs);
223
241
  execFileSync("xdg-open", [revealPath], { timeout: 1e4 });
224
- } else if (os === "win32") execFileSync("explorer", ["/select,", abs], { timeout: 1e4 });
225
- else throw new Error(`unsupported platform: ${os}`);
242
+ } else if (os === "win32") {
243
+ const args = statSync(abs).isDirectory() ? [abs] : ["/select,", abs];
244
+ const child = spawn("explorer", args, {
245
+ detached: true,
246
+ stdio: "ignore",
247
+ windowsHide: false
248
+ });
249
+ child.on("error", () => {});
250
+ child.unref();
251
+ } else throw new Error(`unsupported platform: ${os}`);
226
252
  return {
227
253
  ok: true,
228
254
  revealed: safeRel(root, abs)
@@ -275,7 +301,7 @@ function runCcFs(root, args) {
275
301
  * 进程内注册(取代 v0.1 的 bash spawn runner):zod/schemastery 参数校验、
276
302
  * 规范 JSON 输出、纯 render 投影。逻辑在 fs-ops.ts(可单测)。
277
303
  */
278
- function agentCwd$6(exec) {
304
+ function agentCwd$7(exec) {
279
305
  return exec.agent?.session?.header?.cwd ?? process.cwd();
280
306
  }
281
307
  function renderText$9(value) {
@@ -333,7 +359,7 @@ const ccFsTool = defineTool({
333
359
  render: (args, value) => renderText$9(value)
334
360
  },
335
361
  async execute(args, exec) {
336
- const root = findSerenityRoot(agentCwd$6(exec));
362
+ const root = findSerenityRoot(agentCwd$7(exec));
337
363
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
338
364
  return runCcFs(root, args);
339
365
  }
@@ -348,6 +374,12 @@ const ccFsTool = defineTool({
348
374
  */
349
375
  const execFileAsync = promisify(execFile);
350
376
  const MSM_TIMEOUT_MS = 6e5;
377
+ /**
378
+ * Windows 兼容(审计观察点 A):`.cmd` 不能直接被 CreateProcess 解析——
379
+ * `execFile('npx')` / `spawnSync('npx')` 在 Windows 必 ENOENT(需 shell 或显式 .cmd)。
380
+ * bun 无扩展名(bun.exe 可被 libuv 按 PATHEXT 解析),保持 'bun'。
381
+ */
382
+ const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
351
383
  const MSM_ACTIONS = [
352
384
  "list",
353
385
  "exec",
@@ -435,7 +467,7 @@ function runMsm(root, args) {
435
467
  "pipe"
436
468
  ]
437
469
  });
438
- if (r.error && r.error.code === "ENOENT") r = spawnSync("npx", [
470
+ if (r.error && r.error.code === "ENOENT") r = spawnSync(NPX_BIN, [
439
471
  "tsx",
440
472
  entry.path,
441
473
  ...businessArgs
@@ -653,7 +685,7 @@ async function runMsmAsync(root, args) {
653
685
  } catch (e) {
654
686
  const err = e;
655
687
  if (err.code === "ENOENT") try {
656
- const r = await execFileAsync("npx", [
688
+ const r = await execFileAsync(NPX_BIN, [
657
689
  "tsx",
658
690
  entry.path,
659
691
  ...businessArgs
@@ -825,6 +857,55 @@ function readActiveSessionMd(root, scope = DEFAULT_SESSION_SCOPE) {
825
857
  if (!abs.startsWith(resolve(root))) return null;
826
858
  return abs;
827
859
  }
860
+ /** 列出全部 scope 的活动标记(含 scope / mdRel / mtime);目录不存在返回空 */
861
+ function listActiveMarkers(root) {
862
+ const dir = resolve(root, ACTIVE_SESSIONS_DIR);
863
+ if (!existsSync(dir)) return [];
864
+ const out = [];
865
+ for (const entry of readdirSync(dir)) {
866
+ const full = join(dir, entry);
867
+ if (!statSync(full).isFile()) continue;
868
+ try {
869
+ const rel = readFileSync(full, "utf-8").trim();
870
+ if (!rel) continue;
871
+ out.push({
872
+ scope: entry,
873
+ mdRel: rel,
874
+ mtime: statSync(full).mtimeMs
875
+ });
876
+ } catch {}
877
+ }
878
+ return out;
879
+ }
880
+ /**
881
+ * 重启恢复:当前 scope 无标记时,把"最近激活"(mtime 最新)且根内有效的标记
882
+ * 复制为当前 scope 标记(激活语义延续:use = 激活,重启自动恢复 = 重新激活)。
883
+ * 返回恢复的会话信息;已有标记 / 无候选 / 全部越界 → null。
884
+ * 调用方负责根会话判定(subagent / loop 牛马不恢复——见 context.ts shouldAutoRestore)。
885
+ */
886
+ function restoreActiveSession(root, scope = DEFAULT_SESSION_SCOPE) {
887
+ const marker = activeSessionMarker(root, scope);
888
+ if (existsSync(marker)) return null;
889
+ const scopeName = sanitizeScope(scope);
890
+ const candidates = listActiveMarkers(root).filter((m) => m.scope !== scopeName);
891
+ if (candidates.length === 0) return null;
892
+ const rootAbs = resolve(root);
893
+ const valid = candidates.filter((m) => pathInside(rootAbs, resolve(rootAbs, m.mdRel)));
894
+ if (valid.length === 0) return null;
895
+ const best = valid.sort((a, b) => b.mtime - a.mtime)[0];
896
+ mkdirSync(resolve(rootAbs, ACTIVE_SESSIONS_DIR), { recursive: true });
897
+ writeFileSync(marker, best.mdRel, "utf-8");
898
+ const mdPath = resolve(rootAbs, best.mdRel);
899
+ const dirName = basename(dirname(mdPath));
900
+ const idMatch = dirName.match(/S(\d{3,})/);
901
+ return {
902
+ dir: dirName,
903
+ id: idMatch ? `S${idMatch[1]}` : null,
904
+ mdPath,
905
+ restored: true,
906
+ from: best.scope
907
+ };
908
+ }
828
909
  function archiveSession(root, key) {
829
910
  const target = findSession(root, key);
830
911
  if (!target) throw new Error(`未找到会话: ${key}`);
@@ -902,7 +983,7 @@ function appendHeartbeat(sessionMd) {
902
983
  * AGENT_SESSIONS/ 全周期管理:list/show/create/health/qa/archive/summary。
903
984
  * 逻辑在 session-ops.ts(可单测)。
904
985
  */
905
- function agentCwd$5(exec) {
986
+ function agentCwd$6(exec) {
906
987
  return exec.agent?.session?.header?.cwd ?? process.cwd();
907
988
  }
908
989
  /** 当前 dsh 会话 id(use/close 按会话隔离的 scope) */
@@ -943,7 +1024,7 @@ const sessionTool = defineTool({
943
1024
  render: (args, value) => renderText$8(value)
944
1025
  },
945
1026
  async execute(args, exec) {
946
- const root = findSerenityRoot(agentCwd$5(exec));
1027
+ const root = findSerenityRoot(agentCwd$6(exec));
947
1028
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
948
1029
  if (findEntry(root, "session-tool")) {
949
1030
  const r = runMsm(root, {
@@ -1211,14 +1292,15 @@ function setSafeMode(root, on) {
1211
1292
  *
1212
1293
  * health: CCC 三原则检查(P1 .serenity / P2 git / 配置)
1213
1294
  * time: ISO 8601 时间戳
1214
- * wait: 同步等待 N
1295
+ * wait: 等待 N 秒(纯 Node setTimeout——不依赖外部 sleep 可执行文件,
1296
+ * Windows 无 GNU coreutils sleep,spawn 必 ENOENT,见 Windows 兼容审计问题 3)
1215
1297
  */
1216
1298
  const KIT_ACTIONS = [
1217
1299
  "health",
1218
1300
  "time",
1219
1301
  "wait"
1220
1302
  ];
1221
- function runKit(root, args) {
1303
+ async function runKit(root, args) {
1222
1304
  switch (args.action) {
1223
1305
  case "health": {
1224
1306
  const gitRoot = findGitRoot(root);
@@ -1253,7 +1335,7 @@ function runKit(root, args) {
1253
1335
  case "wait": {
1254
1336
  const n = args.seconds ?? 0;
1255
1337
  if (!Number.isFinite(n) || n < 0) throw new Error("wait 需要非负秒数");
1256
- execFileSync("sleep", [String(n)], { stdio: "ignore" });
1338
+ await new Promise((r) => setTimeout(r, Math.round(n * 1e3)));
1257
1339
  return { waited: n };
1258
1340
  }
1259
1341
  default: throw new Error(`未知 action: ${args.action}`);
@@ -1264,7 +1346,7 @@ function runKit(root, args) {
1264
1346
  /**
1265
1347
  * kit.ts — acc_kit 真实 DSH 工具定义(defineTool)
1266
1348
  */
1267
- function agentCwd$4(exec) {
1349
+ function agentCwd$5(exec) {
1268
1350
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1269
1351
  }
1270
1352
  function renderText$7(value) {
@@ -1293,12 +1375,284 @@ const kitTool = defineTool({
1293
1375
  render: (args, value) => renderText$7(value)
1294
1376
  },
1295
1377
  async execute(args, exec) {
1296
- const root = findSerenityRoot(agentCwd$4(exec));
1378
+ const root = findSerenityRoot(agentCwd$5(exec));
1297
1379
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
1298
- return runKit(root, args);
1380
+ return await runKit(root, args);
1299
1381
  }
1300
1382
  });
1301
1383
  //#endregion
1384
+ //#region src/localstore-ops.ts
1385
+ /**
1386
+ * localstore-ops.ts — localstore 纯逻辑层(零 DSH 依赖,可独立单测)
1387
+ *
1388
+ * S134 重设计(v1.16.7):存储从 ~/.serenity/ 迁到 **CCC 根根目录 localstore.json**
1389
+ * (JSON 格式——方便 MSM 直接 read + JSON.parse 读取,零解析依赖)。
1390
+ *
1391
+ * git 提交策略(可靠机制 × 用户自由):
1392
+ * - 配置:.dsh/serenity.json `localstore.gitTrack`: "allow"(可提交)| "deny"(禁提交)
1393
+ * - **缺省 deny(没配就是不提交)**;且 deny 的保证**不依赖 dsh 运行**——
1394
+ * 写入时自动确保 .gitignore 含 localstore.json(物理保证:即使 dsh 不在、
1395
+ * 用户手动 git commit 也不会误提交),cc_git 检查为第二道防线(拒绝 + 提示)
1396
+ * - allow:放行(文件可提交,用户自行管理 .gitignore)
1397
+ *
1398
+ * 存储结构(JSON 顶层分节):
1399
+ * { "credentials": { "HOME_GITLAB_TOKEN": "xxx" }, "<config节>": { "<key>": "v" } }
1400
+ * credentials 为保留节(credential 命名空间,key 大写蛇形);其余节归 config 命名空间
1401
+ * (path = section.key,如 loop.defaultModel)。
1402
+ */
1403
+ const LOCALSTORE_SCOPES = ["credential", "config"];
1404
+ /** 存储文件名(CCC 根根目录) */
1405
+ const LOCALSTORE_FILENAME = "localstore.json";
1406
+ /** 凭据保留节名(JSON 顶层) */
1407
+ const CREDENTIALS_SECTION = "credentials";
1408
+ /** 凭据 key 规范:大写蛇形,如 HOME_GITLAB_TOKEN */
1409
+ const CREDENTIAL_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
1410
+ /** 配置节名规范:小写字母数字连字符 */
1411
+ const CONFIG_SECTION_RE = /^[a-z][a-z0-9-]*$/;
1412
+ /** 配置 key 规范(节内):小驼峰,如 defaultModel */
1413
+ const CONFIG_KEY_RE = /^[a-z][a-zA-Z0-9_]*$/;
1414
+ /** 存储文件绝对路径(CCC 根根目录) */
1415
+ function localstorePath(root) {
1416
+ return join(root, LOCALSTORE_FILENAME);
1417
+ }
1418
+ /** git 策略:serenity.json localstore.gitTrack,缺省 deny(不提交) */
1419
+ function readGitTrack(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
1420
+ return loadSerenityConfig(root, paths).localstore?.gitTrack === "allow" ? "allow" : "deny";
1421
+ }
1422
+ /** .gitignore 是否已覆盖 localstore 文件(非空非注释行含文件名即算) */
1423
+ function isLocalstoreGitignored(root) {
1424
+ const gi = join(root, ".gitignore");
1425
+ if (!existsSync(gi)) return false;
1426
+ return readFileSync(gi, "utf-8").split("\n").some((l) => {
1427
+ const t = l.trim();
1428
+ return t !== "" && !t.startsWith("#") && t.includes("localstore.json");
1429
+ });
1430
+ }
1431
+ /**
1432
+ * 确保 .gitignore 含 localstore 文件(deny 时的物理保证,不依赖 dsh 运行):
1433
+ * 写入 localstore 时调用——deny(含缺省)且 .gitignore 未覆盖 → 自动追加一行。
1434
+ * allow → 不写入(放行,文件可提交)。
1435
+ */
1436
+ function ensureLocalstoreGitignored(root) {
1437
+ if (readGitTrack(root) === "allow") return { status: "allow" };
1438
+ if (isLocalstoreGitignored(root)) return { status: "ignored" };
1439
+ const gi = join(root, ".gitignore");
1440
+ const existing = existsSync(gi) ? readFileSync(gi, "utf-8") : "";
1441
+ const line = `${LOCALSTORE_FILENAME} # ACC localstore(localstore.gitTrack 缺省 deny,禁提交)`;
1442
+ writeFileSync(gi, (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n", "utf-8");
1443
+ return { status: "appended" };
1444
+ }
1445
+ /**
1446
+ * cc_git 联动检查(第二道防线):文件存在 && deny && .gitignore 未覆盖 → 不通过。
1447
+ * 调用方(cc_git commit)据此拒绝提交;status 可输出 warning。
1448
+ */
1449
+ function checkLocalstoreGitCompliance(root) {
1450
+ if (!existsSync(localstorePath(root))) return { ok: true };
1451
+ if (readGitTrack(root) === "allow") return { ok: true };
1452
+ if (isLocalstoreGitignored(root)) return { ok: true };
1453
+ return {
1454
+ ok: false,
1455
+ reason: `localstore.json 禁止提交(localstore.gitTrack=deny 缺省)但 .gitignore 未包含——请将 ${LOCALSTORE_FILENAME} 加入 .gitignore(或改配置 localstore.gitTrack=allow 显式放行)`
1456
+ };
1457
+ }
1458
+ /** 读取全文件(顶层分节);文件不存在/坏 JSON 返回空 */
1459
+ function readAll(root) {
1460
+ const p = localstorePath(root);
1461
+ if (!existsSync(p)) return {};
1462
+ try {
1463
+ const v = JSON.parse(readFileSync(p, "utf-8"));
1464
+ if (v && typeof v === "object" && !Array.isArray(v)) return v;
1465
+ return {};
1466
+ } catch {
1467
+ return {};
1468
+ }
1469
+ }
1470
+ /** 写回全文件(2 空格缩进 + 尾换行,方便 MSM 直接读取) */
1471
+ function writeAll(root, data) {
1472
+ writeFileSync(localstorePath(root), JSON.stringify(data, null, 2) + "\n", "utf-8");
1473
+ }
1474
+ /** 读取命名空间全部条目:credential → 扁平;config → 分节(剔除 credentials 保留节) */
1475
+ function readStore(root, scope) {
1476
+ const all = readAll(root);
1477
+ if (scope === "credential") return all["credentials"] ?? {};
1478
+ const { [CREDENTIALS_SECTION]: _cred, ...rest } = all;
1479
+ return rest;
1480
+ }
1481
+ /** 校验凭据 key 合法(大写蛇形);不合法抛错 */
1482
+ function assertCredentialKey(key) {
1483
+ if (!CREDENTIAL_KEY_RE.test(key)) throw new Error(`credential key "${key}" 必须匹配大写蛇形 ^[A-Z][A-Z0-9_]*$(如 HOME_GITLAB_TOKEN)`);
1484
+ }
1485
+ /** 校验 config 路径(节.key);不合法抛错 */
1486
+ function assertConfigPath(path) {
1487
+ const idx = path.indexOf(".");
1488
+ if (idx <= 0 || idx === path.length - 1) throw new Error(`config path "${path}" 必须为 section.key(如 loop.defaultModel)`);
1489
+ const section = path.slice(0, idx);
1490
+ const key = path.slice(idx + 1);
1491
+ if (!CONFIG_SECTION_RE.test(section)) throw new Error(`config 节 "${section}" 必须匹配 ^[a-z][a-z0-9-]*$`);
1492
+ if (!CONFIG_KEY_RE.test(key)) throw new Error(`config key "${key}" 必须匹配小驼峰 ^[a-z][a-zA-Z0-9_]*$(如 defaultModel)`);
1493
+ return {
1494
+ section,
1495
+ key
1496
+ };
1497
+ }
1498
+ /** 写入单个条目(自动建文件;deny 默认时同步确保 .gitignore 物理保证) */
1499
+ function writeEntry(root, scope, name, value) {
1500
+ const all = readAll(root);
1501
+ if (scope === "credential") {
1502
+ assertCredentialKey(name);
1503
+ all[CREDENTIALS_SECTION] ??= {};
1504
+ all[CREDENTIALS_SECTION][name] = value;
1505
+ } else {
1506
+ const { section, key } = assertConfigPath(name);
1507
+ all[section] ??= {};
1508
+ all[section][key] = value;
1509
+ }
1510
+ writeAll(root, all);
1511
+ ensureLocalstoreGitignored(root);
1512
+ }
1513
+ /** 删除单个条目(不存在返回 false);空节自动移除 */
1514
+ function unsetEntry(root, scope, name) {
1515
+ const all = readAll(root);
1516
+ if (scope === "credential") {
1517
+ assertCredentialKey(name);
1518
+ const sec = all[CREDENTIALS_SECTION];
1519
+ if (!sec || !(name in sec)) return false;
1520
+ delete sec[name];
1521
+ if (Object.keys(sec).length === 0) delete all[CREDENTIALS_SECTION];
1522
+ } else {
1523
+ const { section, key } = assertConfigPath(name);
1524
+ const sec = all[section];
1525
+ if (!sec || !(key in sec)) return false;
1526
+ delete sec[key];
1527
+ if (Object.keys(sec).length === 0) delete all[section];
1528
+ }
1529
+ writeAll(root, all);
1530
+ return true;
1531
+ }
1532
+ /** 读取单个条目值;不存在返回 null */
1533
+ function getEntry(root, scope, name) {
1534
+ const all = readAll(root);
1535
+ if (scope === "credential") {
1536
+ assertCredentialKey(name);
1537
+ return all["credentials"]?.[name] ?? null;
1538
+ }
1539
+ const { section, key } = assertConfigPath(name);
1540
+ return all[section]?.[key] ?? null;
1541
+ }
1542
+ /** 列出 key(凭据只返回 key 名,不返回值) */
1543
+ function listKeys(root, scope) {
1544
+ const data = readStore(root, scope);
1545
+ if (scope === "credential") return Object.keys(data);
1546
+ return Object.entries(data).flatMap(([section, entries]) => Object.keys(entries).map((key) => `${section}.${key}`));
1547
+ }
1548
+ /**
1549
+ * doc 说明文本:输出存储位置/格式/key 规范/git 策略/读写方法。
1550
+ * agent 据此可直接用 fs 工具(read/write)自己读写凭据/配置。
1551
+ */
1552
+ function docText(root) {
1553
+ const path = localstorePath(root);
1554
+ return [
1555
+ "# localstore — ACC 本地凭据/配置存储(标准,S134 重设计)",
1556
+ "",
1557
+ "一个工具管理两个命名空间:credential(凭据)+ config(偏好)。",
1558
+ `存储于 CCC 根根目录 ${path}(JSON 格式,MSM 可直接 read + JSON.parse)。`,
1559
+ "",
1560
+ "## git 提交策略",
1561
+ "- 配置:.dsh/serenity.json `localstore.gitTrack`:`\"allow\"`(可提交)| `\"deny\"`(禁提交)",
1562
+ "- **缺省 deny(没配就是不提交)**;deny 时写入会自动确保 .gitignore 含 localstore.json",
1563
+ " (物理保证,不依赖 dsh 运行);cc_git commit 会检查拒绝",
1564
+ "- 想提交:配置 `\"localstore\": { \"gitTrack\": \"allow\" }`,并从 .gitignore 移除 localstore.json",
1565
+ "",
1566
+ "## 格式(JSON 顶层分节)",
1567
+ "```json",
1568
+ "{",
1569
+ " \"credentials\": {",
1570
+ " \"HOME_GITLAB_TOKEN\": \"xxx\"",
1571
+ " },",
1572
+ " \"loop\": {",
1573
+ " \"defaultModel\": \"minimax-cn-coding-plan/MiniMax-M3\"",
1574
+ " }",
1575
+ "}",
1576
+ "```",
1577
+ "- credentials 为保留节(凭据,key 大写蛇形);其余节 = config(path = section.key)",
1578
+ "",
1579
+ "## key 规范",
1580
+ "- credential key: ^[A-Z][A-Z0-9_]*$(如 HOME_GITLAB_TOKEN)",
1581
+ "- config path: section.key(节 ^[a-z][a-z0-9-]*$,key 小驼峰 ^[a-z][a-zA-Z0-9_]*$,如 loop.defaultModel)",
1582
+ "",
1583
+ "## 读取方法(agent 可直接用 fs 工具)",
1584
+ `- 用 read 工具读 ${path} → JSON.parse 解析`,
1585
+ "- 或用 localstore get <name> [--scope credential|config]",
1586
+ "",
1587
+ "## 写入方法",
1588
+ "- 推荐:localstore set <name> <value> [--scope ...](自动建文件/保留其他条目/联动 .gitignore)",
1589
+ "- 或直接用 write/edit 工具修改文件,保持 JSON 合法",
1590
+ "",
1591
+ "## 安全边界",
1592
+ "- list/show 对 credential 只返回 key 名,不返回值",
1593
+ "- 凭据值应在 agent 内部使用,不要写入对话/日志",
1594
+ "- 默认 deny:文件不会提交 git(.gitignore 物理保证 + cc_git 检查兜底)",
1595
+ ""
1596
+ ].join("\n");
1597
+ }
1598
+ /** 运行 localstore 操作(纯逻辑;返回 JSON 值供工具 render) */
1599
+ function runLocalStore(root, args) {
1600
+ const scope = args.scope === "config" ? "config" : "credential";
1601
+ switch (args.action) {
1602
+ case "list": return {
1603
+ scope,
1604
+ keys: listKeys(root, scope)
1605
+ };
1606
+ case "get": {
1607
+ if (!args.name) throw new Error("get 需要 name");
1608
+ const value = getEntry(root, scope, args.name);
1609
+ if (value === null) throw new Error(`not found: ${args.name}(scope=${scope})`);
1610
+ return {
1611
+ scope,
1612
+ name: args.name,
1613
+ value,
1614
+ source: scope
1615
+ };
1616
+ }
1617
+ case "set": {
1618
+ if (!args.name) throw new Error("set 需要 name");
1619
+ if (args.value === void 0) throw new Error("set 需要 value");
1620
+ writeEntry(root, scope, args.name, args.value);
1621
+ const git = checkLocalstoreGitCompliance(root);
1622
+ return {
1623
+ scope,
1624
+ name: args.name,
1625
+ set: true,
1626
+ path: localstorePath(root),
1627
+ gitTrack: readGitTrack(root),
1628
+ gitOk: git.ok,
1629
+ git: git.reason ? { warning: git.reason } : null
1630
+ };
1631
+ }
1632
+ case "unset": {
1633
+ if (!args.name) throw new Error("unset 需要 name");
1634
+ const removed = unsetEntry(root, scope, args.name);
1635
+ return {
1636
+ scope,
1637
+ name: args.name,
1638
+ removed
1639
+ };
1640
+ }
1641
+ case "show": {
1642
+ if (!args.name) throw new Error("show 需要 name");
1643
+ const exists = getEntry(root, scope, args.name) !== null;
1644
+ return {
1645
+ scope,
1646
+ name: args.name,
1647
+ exists,
1648
+ path: localstorePath(root)
1649
+ };
1650
+ }
1651
+ case "doc": return { doc: docText(root) };
1652
+ default: throw new Error(`未知子命令: ${args.action}(可用 list/get/set/unset/show/doc)`);
1653
+ }
1654
+ }
1655
+ //#endregion
1302
1656
  //#region src/git-ops.ts
1303
1657
  /**
1304
1658
  * git-ops.ts — cc_git 纯操作层(零 DSH 依赖)
@@ -1337,14 +1691,24 @@ function git(root, args) {
1337
1691
  function runGit(root, args) {
1338
1692
  switch (args.action) {
1339
1693
  case "status": {
1340
- const r = git(root, ["status", "--porcelain"]);
1694
+ const r = git(root, [
1695
+ "-c",
1696
+ "core.quotepath=false",
1697
+ "status",
1698
+ "--porcelain"
1699
+ ]);
1341
1700
  if (!r.ok) throw new Error(`status 失败:${r.stderr.trim()}`);
1342
- return {
1701
+ const out = {
1343
1702
  clean: r.stdout.trim() === "",
1344
1703
  entries: r.stdout.trim() ? r.stdout.trim().split("\n") : []
1345
1704
  };
1705
+ const ls = checkLocalstoreGitCompliance(root);
1706
+ if (!ls.ok) out.warning = ls.reason;
1707
+ return out;
1346
1708
  }
1347
1709
  case "commit": {
1710
+ const ls = checkLocalstoreGitCompliance(root);
1711
+ if (!ls.ok) throw new Error(ls.reason);
1348
1712
  if (!args.message) throw new Error("commit 需要 message");
1349
1713
  const add = git(root, ["add", "-A"]);
1350
1714
  if (!add.ok) throw new Error(`git add 失败:${add.stderr.trim()}`);
@@ -1384,6 +1748,8 @@ function runGit(root, args) {
1384
1748
  case "log": {
1385
1749
  const n = args.count ?? 10;
1386
1750
  const r = git(root, [
1751
+ "-c",
1752
+ "core.quotepath=false",
1387
1753
  "log",
1388
1754
  "--oneline",
1389
1755
  "-n",
@@ -1400,7 +1766,7 @@ function runGit(root, args) {
1400
1766
  /**
1401
1767
  * git.ts — cc_git 真实 DSH 工具定义(defineTool)
1402
1768
  */
1403
- function agentCwd$3(exec) {
1769
+ function agentCwd$4(exec) {
1404
1770
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1405
1771
  }
1406
1772
  function renderText$6(value) {
@@ -1433,7 +1799,7 @@ const gitTool = defineTool({
1433
1799
  render: (args, value) => renderText$6(value)
1434
1800
  },
1435
1801
  async execute(args, exec) {
1436
- const root = findSerenityRoot(agentCwd$3(exec));
1802
+ const root = findSerenityRoot(agentCwd$4(exec));
1437
1803
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
1438
1804
  return runGit(root, args);
1439
1805
  }
@@ -1443,7 +1809,7 @@ const gitTool = defineTool({
1443
1809
  /**
1444
1810
  * msm.ts — acc_msm 真实 DSH 工具定义(defineTool)
1445
1811
  */
1446
- function agentCwd$2(exec) {
1812
+ function agentCwd$3(exec) {
1447
1813
  return exec.agent?.session?.header?.cwd ?? process.cwd();
1448
1814
  }
1449
1815
  function renderText$5(value) {
@@ -1493,7 +1859,7 @@ const msmTool = defineTool({
1493
1859
  render: (args, value) => renderText$5(value)
1494
1860
  },
1495
1861
  async execute(args, exec) {
1496
- const root = findSerenityRoot(agentCwd$2(exec));
1862
+ const root = findSerenityRoot(agentCwd$3(exec));
1497
1863
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
1498
1864
  return runMsmAsync(root, args);
1499
1865
  }
@@ -1798,9 +2164,9 @@ function splitModel(model) {
1798
2164
  }
1799
2165
  /** 轮次 prompt(对齐老 loop 结构:回顾进度 → 自由工作 → 汇报) */
1800
2166
  function buildRoundPrompt(opts) {
1801
- const { root, session, label, round, maxRounds, stopToken, progress, task } = opts;
2167
+ const { root, session, label, round, stopToken, progress, task } = opts;
1802
2168
  const resumeNote = progress && progress.round > 0 ? `上一轮(round ${progress.round})已完成:${progress.lastResponse.slice(0, 300)}\n永远从上次停止处继续,绝不重做已完成工作。` : "这是第一轮。";
1803
- return `# ${label} — 牛马循环 round ${round}/${maxRounds}
2169
+ return `# ${label} — 牛马循环 round ${round}
1804
2170
 
1805
2171
  CCC 根:${root}
1806
2172
  ${session ? `工作会话:${session}(AGENT_SESSIONS/${session}/SESSION.md 记录进度)` : ""}
@@ -1824,17 +2190,20 @@ ${resumeNote}
1824
2190
  * label(必)任务标签 → 进度文件 loop-<label>.md/.json
1825
2191
  * session(选)工作会话 S###(上下文提示)
1826
2192
  * model(选)provider/model(如 minimax-cn-coding-plan/MiniMax-M3);缺省读 loop.defaultModel
1827
- * maxRounds(默认 100)轮次上限;每轮等待 agent **无超时**(loop 可永续,agent 工作多久等多久)
2193
+ * (S134 修正:轮次不需要调用者指定——内部 while 驱动 agent 逐轮推进,
2194
+ * 对话轮次**无上限**(不完成不返回);agent 非正常停止时自动重启,
2195
+ * 重启次数上限 LOOP_MAX_RESTARTS=100(防死循环保险阀))
1828
2196
  *
1829
2197
  * 机制:ctx.agents.create()(带 setup 钩子)创建专用 agent(进程内),
1830
- * 每轮 followup → agent/status idle → 读 session.events 响应 → 写进度 → stop token 检查 → 续跑。
1831
- * 工厂模式:apply 时闭包捕获插件 ctx(工具 execute ctx 参数)。
2198
+ * 每轮 followup → agent/status idle → 读 session.events 响应 → 写进度 → stop token 检查 →
2199
+ * 未完成继续下一轮;followup/waitIdle 抛错(非正常停止)→ dispose 并重新 create agent
2200
+ * (重启计数,≤100),同一轮重试。工厂模式:apply 时闭包捕获插件 ctx(工具 execute 无 ctx 参数)。
1832
2201
  *
1833
2202
  * preset 继承:setup 钩子里对子 agent 执行 agentPresets.composeFrom(对齐 subagent 先例),
1834
2203
  * 使 loop agent 继承发起方会话的 agent preset 工具(read/write/edit 等 preset 层工具)。
1835
2204
  * agentPresets 是可选服务——无 preset 装配的环境(无 roster 部署)退化为空工具层(历史行为)。
1836
2205
  */
1837
- function agentCwd$1(exec) {
2206
+ function agentCwd$2(exec) {
1838
2207
  return (exec.agent?.session)?.header?.cwd ?? process.cwd();
1839
2208
  }
1840
2209
  function renderText$1(value) {
@@ -1876,7 +2245,7 @@ function lastAssistantText(agent) {
1876
2245
  function createLoopTool(ctx) {
1877
2246
  return defineTool({
1878
2247
  name: "loop",
1879
- description: "牛马循环(老 loop 等效):用指定模型创建专用 agent 反复执行任务直到完成。\n用法:loop 接受 task(要完成的目标)或依赖 session 上下文;模型缺省读 .dsh/serenity.json 的 loop.defaultModel(当前 minimax-cn-coding-plan/MiniMax-M3,廉价牛马)。\n行为:内部硬性 while 循环驱动 agent 逐轮推进任务,每轮等待无超时(agent 工作多久等多久)。唯一完成条件 = agent 精确回显本轮随机完成码(stop token),防止低智能模型提前结束。调用者不关心轮数——任务交给 loop,完成即返回。\n进度:写入 AGENT_SESSIONS/loop-<label>.md/.json;同 label 再次调用从上次轮次续跑(不重做)。\n约束:loop agent 受完整 Serenity 约束(ACC 身份/入口技能系统提示词/守卫/session-keeper)。\n示例:loop 执行「扫描 SQC 并修复 DC 问题」,label: sqc-scan",
2248
+ description: "牛马循环(老 loop 等效):用指定模型创建专用 agent 反复执行任务直到完成。\n用法:loop 接受 task(要完成的目标)或依赖 session 上下文;模型缺省读 .dsh/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",
1880
2249
  parameters: {
1881
2250
  task: {
1882
2251
  type: "string",
@@ -1894,10 +2263,6 @@ function createLoopTool(ctx) {
1894
2263
  model: {
1895
2264
  type: "string",
1896
2265
  description: "provider/model(如 minimax-cn-coding-plan/MiniMax-M3);缺省读 loop.defaultModel"
1897
- },
1898
- maxRounds: {
1899
- type: "integer",
1900
- description: "保险阀(防死循环,默认 100;调用者通常无需设置——loop 跑到完成或达此上限)"
1901
2266
  }
1902
2267
  },
1903
2268
  output: {
@@ -1905,12 +2270,11 @@ function createLoopTool(ctx) {
1905
2270
  render: (_args, value) => renderText$1(value)
1906
2271
  },
1907
2272
  async execute(args, exec) {
1908
- const root = findSerenityRoot(agentCwd$1(exec));
2273
+ const root = findSerenityRoot(agentCwd$2(exec));
1909
2274
  if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
1910
2275
  const cfg = loadSerenityConfig(root, DEFAULT_SERENITY_CONFIG_PATHS);
1911
2276
  const model = args.model ?? cfg.loop?.defaultModel;
1912
2277
  if (!model) throw new Error("loop 需要 model:传参或配置 .dsh/serenity.json loop.defaultModel");
1913
- const maxRounds = args.maxRounds ?? 100;
1914
2278
  const label = args.label;
1915
2279
  if (!ctx.agentLoop) throw new Error("loop: ctx.agentLoop 不可用");
1916
2280
  const { provider, model: modelName } = splitModel(model);
@@ -1920,48 +2284,62 @@ function createLoopTool(ctx) {
1920
2284
  const parentCtx = exec.agent?.ctx;
1921
2285
  const inherited = loopPresetInheritance(parentCtx);
1922
2286
  if (!ctx.agents) throw new Error("loop: ctx.agents 不可用");
1923
- const sessionId = `loop-${label}-${randomUUID()}`;
1924
- const handle = await ctx.agents.create({
1925
- sessionId,
1926
- meta: {
1927
- cwd: root,
1928
- ...inherited.agentPreset === void 0 ? {} : { agentPreset: inherited.agentPreset }
1929
- },
1930
- agentOptions: {
1931
- provider,
1932
- model: modelName
1933
- },
1934
- ...inherited.setup === void 0 ? {} : { setup: inherited.setup }
1935
- });
1936
- const loopAgent = handle.agent;
2287
+ let handle;
2288
+ let loopAgent;
2289
+ const spawnAgent = async () => {
2290
+ const sessionId = `loop-${label}-${randomUUID()}`;
2291
+ handle = await ctx.agents.create({
2292
+ sessionId,
2293
+ meta: {
2294
+ cwd: root,
2295
+ ...inherited.agentPreset === void 0 ? {} : { agentPreset: inherited.agentPreset }
2296
+ },
2297
+ agentOptions: {
2298
+ provider,
2299
+ model: modelName
2300
+ },
2301
+ ...inherited.setup === void 0 ? {} : { setup: inherited.setup }
2302
+ });
2303
+ loopAgent = handle.agent;
2304
+ };
2305
+ await spawnAgent();
1937
2306
  let done = false;
1938
2307
  let lastResponse = progress?.lastResponse ?? "";
1939
2308
  let finalRound = startRound - 1;
2309
+ let restarts = 0;
1940
2310
  try {
1941
- for (let round = startRound; round <= maxRounds; round++) {
2311
+ let round = startRound;
2312
+ while (true) {
1942
2313
  finalRound = round;
1943
2314
  const prompt = buildRoundPrompt({
1944
2315
  root,
1945
2316
  session: args.session,
1946
2317
  label,
1947
2318
  round,
1948
- maxRounds,
1949
2319
  stopToken,
1950
2320
  progress,
1951
2321
  task: args.task
1952
2322
  });
1953
- loopAgent.followup(createUserMessage({
1954
- content: [{
1955
- type: "text",
1956
- text: prompt
1957
- }],
1958
- source: {
1959
- kind: "plugin",
1960
- plugin: "dsh-serenity-hooks"
1961
- }
1962
- }));
1963
- await waitIdle(ctx, loopAgent);
1964
- lastResponse = lastAssistantText(loopAgent);
2323
+ try {
2324
+ loopAgent.followup(createUserMessage({
2325
+ content: [{
2326
+ type: "text",
2327
+ text: prompt
2328
+ }],
2329
+ source: {
2330
+ kind: "plugin",
2331
+ plugin: "dsh-serenity-hooks"
2332
+ }
2333
+ }));
2334
+ await waitIdle(ctx, loopAgent);
2335
+ lastResponse = lastAssistantText(loopAgent);
2336
+ } catch {
2337
+ restarts++;
2338
+ if (restarts > 100) break;
2339
+ await handle.dispose().catch(() => {});
2340
+ await spawnAgent();
2341
+ continue;
2342
+ }
1965
2343
  progress = {
1966
2344
  round,
1967
2345
  done: false,
@@ -1975,6 +2353,7 @@ function createLoopTool(ctx) {
1975
2353
  done = true;
1976
2354
  break;
1977
2355
  }
2356
+ round++;
1978
2357
  }
1979
2358
  writeProgress(root, label, {
1980
2359
  round: finalRound,
@@ -1991,322 +2370,29 @@ function createLoopTool(ctx) {
1991
2370
  return {
1992
2371
  done,
1993
2372
  rounds: finalRound,
2373
+ restarts,
1994
2374
  model,
1995
2375
  label,
1996
2376
  progressFile: json,
1997
2377
  lastResponse: lastResponse.slice(0, 2e3),
1998
2378
  usage: {
1999
- how: "loop 内部硬性 while 驱动 agent 逐轮推进,agent 精确回显随机完成码即终止",
2379
+ how: "loop 内部硬性 while 驱动 agent 逐轮推进,agent 精确回显随机完成码即终止;对话轮次无上限(不完成不返回),agent 非正常停止时自动重启(≤100 次)",
2000
2380
  progress: `进度在 AGENT_SESSIONS/loop-${label}.md 与 .json;同 label 再调 loop 会从下一轮续跑(不重做)`,
2001
2381
  constraints: "loop agent 受完整 Serenity 约束(ACC 身份/入口技能系统提示词/守卫)",
2002
- next: done ? "任务已完成;可查看进度文件收尾" : `任务未完成(已达保险阀 ${maxRounds} 轮);可同 label 续跑`
2382
+ next: done ? "任务已完成;可查看进度文件收尾" : `任务未完成(已达内部 100 次重启保险阀);可同 label 续跑`
2003
2383
  }
2004
2384
  };
2005
2385
  }
2006
2386
  });
2007
2387
  }
2008
2388
  //#endregion
2009
- //#region src/localstore-ops.ts
2010
- /**
2011
- * localstore-ops.ts — localstore 纯逻辑层(零 DSH 依赖,可独立单测)
2012
- *
2013
- * ACC 标准本地凭据/配置存储(S133 设计):
2014
- * - 一个工具管理两个命名空间:credential(凭据,0600)+ config(偏好,0644)
2015
- * - 存储于 ~/.serenity/(平台感知:win %USERPROFILE%\.serenity\)
2016
- * - 两个 YAML 文件:credentials.yaml(扁平 REF→value)+ settings.yaml(命名空间分节)
2017
- * - 目录 0700
2018
- *
2019
- * YAML 用轻量自实现子集(零依赖):扁平 `KEY: value` 映射 + `#` 注释。
2020
- * 凭据/配置本质是给 MSM/agent 用的普通 YAML 文件——agent 可按 doc 子命令
2021
- * 说明直接用 fs 工具(read/write)操作;本工具是管理入口 + 规范文档。
2022
- */
2023
- const LOCALSTORE_SCOPES = ["credential", "config"];
2024
- /** 根目录名(~/.serenity) */
2025
- const STORE_DIR_NAME = ".serenity";
2026
- /** 凭据文件(0600) */
2027
- const CREDENTIALS_FILENAME = "credentials.yaml";
2028
- /** 配置文件(0644) */
2029
- const SETTINGS_FILENAME = "settings.yaml";
2030
- /** 凭据 key 规范:大写蛇形(命名空间前缀),如 HOME_GITLAB_TOKEN */
2031
- const CREDENTIAL_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
2032
- /** 配置节名规范:小写字母数字连字符 */
2033
- const CONFIG_SECTION_RE = /^[a-z][a-z0-9-]*$/;
2034
- /** 配置 key 规范(节内):小驼峰(首字母小写,可含大写),如 defaultModel */
2035
- const CONFIG_KEY_RE = /^[a-z][a-zA-Z0-9_]*$/;
2036
- /** 解析 ~/.serenity 根(平台感知:os.homedir() 三平台统一) */
2037
- function serenityDir() {
2038
- return join(homedir(), STORE_DIR_NAME);
2039
- }
2040
- /** 各命名空间的文件绝对路径 */
2041
- function storeFilePath(scope) {
2042
- return scope === "credential" ? join(serenityDir(), CREDENTIALS_FILENAME) : join(serenityDir(), SETTINGS_FILENAME);
2043
- }
2044
- /** 确保目录存在并设 0700 */
2045
- function ensureDir(dir) {
2046
- mkdirSync(dir, { recursive: true });
2047
- try {
2048
- chmodSync(dir, 448);
2049
- } catch {}
2050
- }
2051
- /** 设置文件权限:credential 0600 / config 0644 */
2052
- function applyFileMode(scope, path) {
2053
- try {
2054
- chmodSync(path, scope === "credential" ? 384 : 420);
2055
- } catch {}
2056
- }
2057
- /** 解析扁平映射 YAML → Record<string,string>(忽略 # 注释与空行;值去引号) */
2058
- function parseFlatYaml(text) {
2059
- const out = {};
2060
- for (const rawLine of text.split("\n")) {
2061
- const line = rawLine.trim();
2062
- if (line === "" || line.startsWith("#")) continue;
2063
- const idx = line.indexOf(":");
2064
- if (idx <= 0) continue;
2065
- const key = line.slice(0, idx).trim();
2066
- let value = line.slice(idx + 1).trim();
2067
- if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
2068
- out[key] = value;
2069
- }
2070
- return out;
2071
- }
2072
- /** 序列化扁平映射 YAML(保持 key 顺序,值引号包裹含特殊字符的) */
2073
- function renderFlatYaml(entries) {
2074
- const lines = [];
2075
- for (const [key, value] of Object.entries(entries)) {
2076
- const rendered = /[:#\n]/.test(value) || value === "" || /^\s/.test(value) || /\s$/.test(value) ? JSON.stringify(value) : value;
2077
- lines.push(`${key}: ${rendered}`);
2078
- }
2079
- return lines.join("\n") + (lines.length > 0 ? "\n" : "");
2080
- }
2081
- /** 解析分节 YAML(config):节 → key → value */
2082
- function parseSectionedYaml(text) {
2083
- const out = {};
2084
- let section = "";
2085
- for (const rawLine of text.split("\n")) {
2086
- const line = rawLine.trim();
2087
- if (line === "" || line.startsWith("#")) continue;
2088
- if (rawLine.length - rawLine.trimStart().length === 0 && line.endsWith(":")) {
2089
- section = line.slice(0, -1).trim();
2090
- if (!out[section]) out[section] = {};
2091
- continue;
2092
- }
2093
- if (section === "") continue;
2094
- const idx = line.indexOf(":");
2095
- if (idx <= 0) continue;
2096
- const key = line.slice(0, idx).trim();
2097
- let value = line.slice(idx + 1).trim();
2098
- if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
2099
- const sectionMap = out[section] ??= {};
2100
- sectionMap[key] = value;
2101
- }
2102
- return out;
2103
- }
2104
- /** 序列化分节 YAML(config):节 → key → value */
2105
- function renderSectionedYaml(sections) {
2106
- const lines = [];
2107
- for (const [section, entries] of Object.entries(sections)) {
2108
- lines.push(`${section}:`);
2109
- for (const [key, value] of Object.entries(entries)) {
2110
- const rendered = /[:#\n]/.test(value) || value === "" || /^\s/.test(value) || /\s$/.test(value) ? JSON.stringify(value) : value;
2111
- lines.push(` ${key}: ${rendered}`);
2112
- }
2113
- }
2114
- return lines.join("\n") + (lines.length > 0 ? "\n" : "");
2115
- }
2116
- /** 读取命名空间全部条目(文件不存在返回空) */
2117
- function readStore(scope) {
2118
- const path = storeFilePath(scope);
2119
- if (!existsSync(path)) return scope === "credential" ? {} : {};
2120
- const text = readFileSync(path, "utf-8");
2121
- return scope === "credential" ? parseFlatYaml(text) : parseSectionedYaml(text);
2122
- }
2123
- /** 校验凭据 key 合法(大写蛇形);不合法抛错 */
2124
- function assertCredentialKey(key) {
2125
- if (!CREDENTIAL_KEY_RE.test(key)) throw new Error(`credential key "${key}" 必须匹配大写蛇形 ^[A-Z][A-Z0-9_]*$(如 HOME_GITLAB_TOKEN)`);
2126
- }
2127
- /** 校验 config 路径(节.key);不合法抛错 */
2128
- function assertConfigPath(path) {
2129
- const idx = path.indexOf(".");
2130
- if (idx <= 0 || idx === path.length - 1) throw new Error(`config path "${path}" 必须为 section.key(如 loop.defaultModel)`);
2131
- const section = path.slice(0, idx);
2132
- const key = path.slice(idx + 1);
2133
- if (!CONFIG_SECTION_RE.test(section)) throw new Error(`config 节 "${section}" 必须匹配 ^[a-z][a-z0-9-]*$`);
2134
- if (!CONFIG_KEY_RE.test(key)) throw new Error(`config key "${key}" 必须匹配小驼峰 ^[a-z][a-zA-Z0-9_]*$(如 defaultModel)`);
2135
- return {
2136
- section,
2137
- key
2138
- };
2139
- }
2140
- /** 写入单个条目(自动建目录/设权限;保留其他条目与注释行外内容) */
2141
- function writeEntry(scope, name, value) {
2142
- const path = storeFilePath(scope);
2143
- ensureDir(serenityDir());
2144
- if (scope === "credential") {
2145
- assertCredentialKey(name);
2146
- const entries = parseFlatYaml(existsSync(path) ? readFileSync(path, "utf-8") : "");
2147
- entries[name] = value;
2148
- writeFileSync(path, renderFlatYaml(entries), "utf-8");
2149
- } else {
2150
- const { section, key } = assertConfigPath(name);
2151
- const sections = parseSectionedYaml(existsSync(path) ? readFileSync(path, "utf-8") : "");
2152
- if (!sections[section]) sections[section] = {};
2153
- sections[section][key] = value;
2154
- writeFileSync(path, renderSectionedYaml(sections), "utf-8");
2155
- }
2156
- applyFileMode(scope, path);
2157
- }
2158
- /** 删除单个条目(不存在返回 false) */
2159
- function unsetEntry(scope, name) {
2160
- const path = storeFilePath(scope);
2161
- if (!existsSync(path)) return false;
2162
- if (scope === "credential") {
2163
- assertCredentialKey(name);
2164
- const entries = parseFlatYaml(readFileSync(path, "utf-8"));
2165
- if (!(name in entries)) return false;
2166
- delete entries[name];
2167
- writeFileSync(path, renderFlatYaml(entries), "utf-8");
2168
- return true;
2169
- }
2170
- const { section, key } = assertConfigPath(name);
2171
- const sections = parseSectionedYaml(readFileSync(path, "utf-8"));
2172
- const sectionMap = sections[section];
2173
- if (!sectionMap || !(key in sectionMap)) return false;
2174
- delete sectionMap[key];
2175
- if (Object.keys(sectionMap).length === 0) delete sections[section];
2176
- writeFileSync(path, renderSectionedYaml(sections), "utf-8");
2177
- return true;
2178
- }
2179
- /** 读取单个条目值;不存在返回 null */
2180
- function getEntry(scope, name) {
2181
- const path = storeFilePath(scope);
2182
- if (!existsSync(path)) return null;
2183
- if (scope === "credential") {
2184
- assertCredentialKey(name);
2185
- return parseFlatYaml(readFileSync(path, "utf-8"))[name] ?? null;
2186
- }
2187
- const { section, key } = assertConfigPath(name);
2188
- return parseSectionedYaml(readFileSync(path, "utf-8"))[section]?.[key] ?? null;
2189
- }
2190
- /** 列出 key(凭据只返回 key 名,不返回值) */
2191
- function listKeys(scope) {
2192
- const data = readStore(scope);
2193
- if (scope === "credential") return Object.keys(data);
2194
- return Object.entries(data).flatMap(([section, entries]) => Object.keys(entries).map((key) => `${section}.${key}`));
2195
- }
2196
- /**
2197
- * doc 说明文本:输出存储位置/格式/key 规范/权限/读写方法/安全边界。
2198
- * agent 据此可直接用 fs 工具(read/write)自己读写凭据/配置。
2199
- */
2200
- function docText() {
2201
- const dir = serenityDir();
2202
- const credPath = storeFilePath("credential");
2203
- const cfgPath = storeFilePath("config");
2204
- return [
2205
- "# localstore — ACC 本地凭据/配置存储(标准)",
2206
- "",
2207
- "一个工具管理两个命名空间:credential(凭据,0600)+ config(偏好,0644)。",
2208
- "存储于用户主目录,不在任何 CCC git 仓库内。",
2209
- "",
2210
- "## 存储位置(平台感知)",
2211
- `- 根目录: ${dir} (0700)`,
2212
- `- 凭据: ${credPath} (0600)`,
2213
- `- 配置: ${cfgPath} (0644)`,
2214
- "- Windows: %USERPROFILE%\\.serenity\\...(与 Linux/macOS 的 $HOME/.serenity 同构)",
2215
- "",
2216
- "## 格式",
2217
- "credentials.yaml(扁平映射,key = 大写蛇形命名空间前缀):",
2218
- "```yaml",
2219
- "HOME_GITLAB_TOKEN: xxx",
2220
- "SSH_UBUNTU_PASSWORD: xxx",
2221
- "ANYSEARCH_API_KEY: xxx",
2222
- "```",
2223
- "settings.yaml(命名空间分节):",
2224
- "```yaml",
2225
- "loop:",
2226
- " defaultModel: minimax-cn-coding-plan/MiniMax-M3",
2227
- "ui:",
2228
- " theme: dark",
2229
- "```",
2230
- "",
2231
- "## key 规范",
2232
- "- credential key: ^[A-Z][A-Z0-9_]*$(如 HOME_GITLAB_TOKEN)",
2233
- "- config path: section.key(节 ^[a-z][a-z0-9-]*$,key 小驼峰 ^[a-z][a-zA-Z0-9_]*$,如 loop.defaultModel)",
2234
- "",
2235
- "## 读取方法(agent 可直接用 fs 工具)",
2236
- "- 用 read 工具读对应文件 → 按上面格式解析 YAML",
2237
- "- 或用 localstore get <name> [--scope credential|config]",
2238
- "",
2239
- "## 写入方法",
2240
- "- 推荐:localstore set <name> <value> [--scope ...](自动建目录/设权限/保留其他条目)",
2241
- "- 或直接用 write/edit 工具修改文件,保持 YAML 合法(键值冒号分隔)",
2242
- "",
2243
- "## 安全边界",
2244
- "- list/show 对 credential 只返回 key 名,不返回值",
2245
- "- 凭据值应在 agent 内部使用,不要写入对话/日志",
2246
- "- 文件权限不符(非 0600/0644)时 get/set 会提示 chmod 修复",
2247
- "- 文件在用户主目录,天然不进入任何 git 仓库",
2248
- ""
2249
- ].join("\n");
2250
- }
2251
- /** 运行 localstore 操作(纯逻辑;返回 JSON 值供工具 render) */
2252
- function runLocalStore(args) {
2253
- const scope = args.scope === "config" ? "config" : "credential";
2254
- switch (args.action) {
2255
- case "list": return {
2256
- scope,
2257
- keys: listKeys(scope)
2258
- };
2259
- case "get": {
2260
- if (!args.name) throw new Error("get 需要 name");
2261
- const value = getEntry(scope, args.name);
2262
- if (value === null) throw new Error(`not found: ${args.name}(scope=${scope})`);
2263
- return {
2264
- scope,
2265
- name: args.name,
2266
- value,
2267
- source: scope
2268
- };
2269
- }
2270
- case "set":
2271
- if (!args.name) throw new Error("set 需要 name");
2272
- if (args.value === void 0) throw new Error("set 需要 value");
2273
- writeEntry(scope, args.name, args.value);
2274
- return {
2275
- scope,
2276
- name: args.name,
2277
- set: true
2278
- };
2279
- case "unset": {
2280
- if (!args.name) throw new Error("unset 需要 name");
2281
- const removed = unsetEntry(scope, args.name);
2282
- return {
2283
- scope,
2284
- name: args.name,
2285
- removed
2286
- };
2287
- }
2288
- case "show": {
2289
- if (!args.name) throw new Error("show 需要 name");
2290
- const exists = getEntry(scope, args.name) !== null;
2291
- return {
2292
- scope,
2293
- name: args.name,
2294
- exists,
2295
- path: storeFilePath(scope)
2296
- };
2297
- }
2298
- case "doc": return { doc: docText() };
2299
- default: throw new Error(`未知子命令: ${args.action}(可用 list/get/set/unset/show/doc)`);
2300
- }
2301
- }
2302
- //#endregion
2303
2389
  //#region src/tools/localstore.ts
2304
2390
  /**
2305
2391
  * localstore.ts — localstore 真实 DSH 工具定义(defineTool)
2306
2392
  *
2307
- * ACC 标准本地凭据/配置存储(S133 设计)。进程内注册,零 DSH 依赖逻辑在
2308
- * localstore-ops.ts(可单测)。doc 子命令输出存储规范,agent 可直接用 fs
2309
- * 工具(read/write)自己读写。
2393
+ * ACC 标准本地凭据/配置存储(S133 设计,S134 重设计:CCC 根 localstore.json)。
2394
+ * 进程内注册,零 DSH 依赖逻辑在 localstore-ops.ts(可单测)。
2395
+ * doc 子命令输出存储规范,agent 可直接用 fs 工具(read/write)自己读写。
2310
2396
  */
2311
2397
  const ACTIONS = [
2312
2398
  "list",
@@ -2316,6 +2402,9 @@ const ACTIONS = [
2316
2402
  "show",
2317
2403
  "doc"
2318
2404
  ];
2405
+ function agentCwd$1(exec) {
2406
+ return exec.agent?.session?.header?.cwd ?? process.cwd();
2407
+ }
2319
2408
  function renderText(value) {
2320
2409
  return [{
2321
2410
  type: "text",
@@ -2324,7 +2413,7 @@ function renderText(value) {
2324
2413
  }
2325
2414
  const localstoreTool = defineTool({
2326
2415
  name: "localstore",
2327
- description: "ACC 标准本地凭据/配置存储:一个工具管理两个命名空间 credential(凭据,0600)与 config(本地偏好,0644)。存储于用户主目录 ~/.serenity/(平台感知,不在任何 git 仓库内)。子命令:list(列 key,凭据不返回值)/ get <name>(读值)/ set <name> <value>(写)/ unset <name>(删)/ show <name>(元数据,凭据不打印值)/ doc(输出存储规范——路径/格式/key 规范/权限,agent 可按说明直接用 read/write 操作文件)。默认 scope=credential;config 需传 --scope config(路径为 section.key,如 loop.defaultModel)。",
2416
+ description: "ACC 标准本地凭据/配置存储:一个工具管理两个命名空间 credential(凭据)与 config(本地偏好)。存储于 CCC 根根目录 localstore.json(JSON 格式,MSM 可直接读取)。git 策略:.dsh/serenity.json localstore.gitTrack(allow 可提交 / deny 禁提交,缺省 deny)——deny 时写入自动确保 .gitignore 含该文件(物理保证),cc_git commit 会检查拒绝。子命令:list(列 key,凭据不返回值)/ get <name>(读值)/ set <name> <value>(写)/ unset <name>(删)/ show <name>(元数据,凭据不打印值)/ doc(输出存储规范——路径/格式/key 规范/git 策略,agent 可按说明直接用 read/write 操作文件)。默认 scope=credential;config 需传 --scope config(路径为 section.key,如 loop.defaultModel)。",
2328
2417
  parameters: {
2329
2418
  action: {
2330
2419
  type: "string",
@@ -2350,8 +2439,10 @@ const localstoreTool = defineTool({
2350
2439
  schema: { type: "json" },
2351
2440
  render: (args, value) => renderText(value)
2352
2441
  },
2353
- async execute(args) {
2354
- return runLocalStore(args);
2442
+ async execute(args, exec) {
2443
+ const root = findSerenityRoot(agentCwd$1(exec));
2444
+ if (!root) throw new Error("No CCC found: no .serenity file from agent cwd");
2445
+ return runLocalStore(root, args);
2355
2446
  }
2356
2447
  });
2357
2448
  //#endregion
@@ -2558,7 +2649,7 @@ function accBlock(root) {
2558
2649
  "",
2559
2650
  " cc_fs — CCC 内文件系统操作(root/resolve/exists/list/tree/relative/mkdir/rm/mv/cp/touch/append/reveal/info/find)",
2560
2651
  " session — session lifecycle(list/show/create/health/qa/archive/summary)",
2561
- " acc_kit — ACC utility kit(health: CCC three principles / time: now / wait: sleep N seconds)",
2652
+ " acc_kit — ACC utility kit(health: CCC three principles / time: now / wait: wait N seconds)",
2562
2653
  " cc_git — git operations(status/commit/push/log)",
2563
2654
  " acc_msm — MSM framework(list/exec/register/deregister/check/guide)",
2564
2655
  " eap — return the full EAP cognitive quality framework",
@@ -2858,6 +2949,22 @@ function agentKey(agent) {
2858
2949
  function agentScope(agent) {
2859
2950
  return agent.session.id ?? "default";
2860
2951
  }
2952
+ /**
2953
+ * 重启恢复的根会话判定(S134 需求):
2954
+ * 只有"conversation 根会话"才自动恢复最近激活的宁静号会话——
2955
+ * - subagent:session header `origin === 'subagent'`(DSH 路由语义,agent-lookup.ts)
2956
+ * - 派生会话:`parentSession` 存在(任何子会话)
2957
+ * - loop 牛马:sessionId 固定 `loop-` 前缀(tools/loop.ts 生成)
2958
+ * 三者都不恢复(避免把主会话激活注入子上下文,违背 v1.16.2 scope 隔离)。
2959
+ */
2960
+ function shouldAutoRestore(agent) {
2961
+ const session = agent.session;
2962
+ if (!session) return false;
2963
+ if (session.header?.origin === "subagent") return false;
2964
+ if (session.header?.parentSession) return false;
2965
+ if (session.id?.startsWith("loop-")) return false;
2966
+ return true;
2967
+ }
2861
2968
  function registerContext(ctx, opts = {}) {
2862
2969
  const configPaths = opts.configPaths ?? DEFAULT_SERENITY_CONFIG_PATHS;
2863
2970
  const entrySkillMaxChars = opts.entrySkillMaxChars ?? 3e4;
@@ -2866,6 +2973,12 @@ function registerContext(ctx, opts = {}) {
2866
2973
  if (!root) return;
2867
2974
  const key = agentKey(agent);
2868
2975
  registerEntrySkillSection(agent, root);
2976
+ if (shouldAutoRestore(agent)) try {
2977
+ if (loadSerenityConfig(root, configPaths).hooks?.autoRestoreSession ?? true) {
2978
+ const restored = restoreActiveSession(root, agentScope(agent));
2979
+ if (restored) console.log(`[serenity-hooks] ↻ 自动恢复激活会话: ${restored.dir}(from scope ${restored.from})`);
2980
+ }
2981
+ } catch {}
2869
2982
  if (injected.has(key)) return;
2870
2983
  injected.add(key);
2871
2984
  agent.inject(accMessage(root, configPaths, entrySkillMaxChars, agentScope(agent)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.16.5",
3
+ "version": "1.16.7",
4
4
  "description": "宁静号 ACC harness — Native Cordis 插件(DeepSeek Harness 运行时)。真实 DSH 工具注册(cc_fs/session/acc_msm 等 9 工具)+ 拦截缝机械约束(safe-mode/路径守卫/会话落盘)+ 系统提示词注入(ACC/CCE/Constraints/SKILL/Session 五块)。适配 DSH 公开版(deepseek-ai/deepseek-harness 0.1.0-rc)。",
5
5
  "license": "MIT",
6
6
  "repository": {