@shgroup/dsh-serenity-hooks 1.45.0 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.45.0",
3
+ "version": "1.46.0",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
@@ -24,10 +24,12 @@
24
24
  * **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
25
25
  * tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
26
26
  *
27
- * 🔴 硬约束三:**两条闸必须继续分开传**(v1.34 S-1 解耦)
27
+ * 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
28
28
  *
29
- * 唤醒调度器 `wakeSchedulerEnabled`(**缺省开**)|autopilot `autopilotWakeEnabled`
30
- * (**缺省关**)——取值方向相反。工厂取 `gate` **回调**(每次现读),**不做**任何合并或缓存。
29
+ * 现只余 **autopilot `autopilotWakeEnabled`**(**缺省关**)一条;**唤醒调度器已无闸**
30
+ * (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
31
+ * = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
32
+ * **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
31
33
  *
32
34
  * ───────────────────────────────────────────────────────────────────────────
33
35
  * 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
package/lib/index.js CHANGED
@@ -2,13 +2,13 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { a as hostSettings, c as hostWebServer, i as hostSessions, n as hostInjected, o as hostSubagents, r as hostService, s as hostWeb, t as hostAgents } from "./access-fiehjxV6.js";
3
3
  import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$1, f as readExclusiveTools, g as resolveInside, i as findGitRoot, l as pathInside, m as readTrajectorySkills, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readHandymanConfig, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-DG4Oc7I1.js";
4
4
  import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-FxWXuZPw.js";
5
- import { C as summarize, E as localFileStamp, S as showSession, T as isoLocal, _ as readActiveSessionMd, a as resolveSessionTrajectoryLabel, b as sessionsRoot, c as DEFAULT_SESSION_SCOPE, d as createSession, f as extractSessionMdPathFromText, g as parseSessionContextFromEvents, h as listSessions, i as readLastBound, l as TRAJECTORY_ACTIONS, m as getActiveSessionInfo, o as setBindingStore, p as findSession, r as pruneMissingBindings, s as supersedeOtherBindings, t as appendBound, u as clearActiveSessionInfo, v as resolveSessionByTitle, w as useSession, x as setActiveSessionInfo, y as sessionEvents$1 } from "./trajectory-bound-BzdzsDPI.js";
5
+ import { C as useSession, S as summarize, T as localFileStamp, _ as resolveSessionByTitle, a as resolveSessionTrajectoryLabel, b as setActiveSessionInfo, c as TRAJECTORY_ACTIONS, d as extractSessionMdPathFromText, f as findSession, g as readActiveSessionMd, h as parseSessionContextFromEvents, i as readLastBound, l as clearActiveSessionInfo, m as listSessions, o as setBindingStore, p as getActiveSessionInfo, r as pruneMissingBindings, s as DEFAULT_SESSION_SCOPE, t as appendBound, u as createSession, v as sessionEvents$1, w as isoLocal, x as showSession, y as sessionsRoot } from "./trajectory-bound-BPWKkzJC.js";
6
6
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-CWMRa36W.js";
7
7
  import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, o as ACC_VERSION, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-BjIX_KD1.js";
8
8
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-KqQA6pkr.js";
9
9
  import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-CIuyoSOX.js";
10
10
  import { r as sessionsRootDir, t as hasSessionLogById } from "./session-cleanup-BtkSmry8.js";
11
- import { a as registerDisposer, c as addWake, i as registerCroTurnTracking, l as listWakes, n as sendToTrajectory, o as buildCroSnapshot, r as wakeSchedulerState, s as WAKE_CATCH_UP_MS, t as registerWakeScheduler } from "./wake-scheduler-fVe71jkg.js";
11
+ import { a as registerDisposer, c as addWake, i as registerCroTurnTracking, l as listWakes, n as sendToTrajectory, o as buildCroSnapshot, r as wakeSchedulerState, s as WAKE_CATCH_UP_MS, t as registerWakeScheduler } from "./wake-scheduler-DF-YWcX5.js";
12
12
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
13
13
  import z from "@deepseek-ai/schemastery";
14
14
  import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -1915,14 +1915,16 @@ function waitAgentIdle(ctx, agent) {
1915
1915
  /**
1916
1916
  * skills-discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
1917
1917
  *
1918
- * 发现全部入口技能(原文全量,不截断):
1918
+ * 发现全部入口技能(原文全量,不截断)—— **现行三个来源**(原为四个,**来源 3 已于 2026-09-21 删除**):
1919
1919
  * 0. **`.serenity` 记号文件内容 = 顶层入口 skill 名**(CCC 记号文件的权威语义,
1920
1920
  * tiangong-serenity 的 .serenity 内容为 `tg-serenity`)—— 最高优先
1921
1921
  * 1. `.dsh/entry-skill` 指针文件(内容 = skill 名)—— 兼容旧约定
1922
1922
  * 2. `.opencode/skills/*-serenity/SKILL.md` —— 自动扫描该 CCC 的顶层入口
1923
1923
  * (home-serenity / tg-serenity / pangu-serenity …,命名模式 `*-serenity`)
1924
- * 3. `.dsh/skills/*-serenity/SKILL.md` —— 自动扫描 ACC/harness 入口(acc-serenity 等)
1925
- * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口 → dsh 入口。
1924
+ * ~~3. `.dsh/skills/*-serenity/SKILL.md`(扫 ACC 装进 CCC 的技能副本)~~ —— 🔴 **已删**:
1925
+ * owner 2026-09-21 令「连模板和安装命令一起删」(B 案)。**判据、实测证据与日期逐字记在
1926
+ * `findEntrySkills()` 体内那一段注释里**——本头部只留结论,**不复述判据**(避免两处会漂的真相源)。
1927
+ * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口。
1926
1928
  *
1927
1929
  * 任何 CCC 都能自动注入其顶层入口 skill 全文(不硬编码名字)。
1928
1930
  */
@@ -1991,7 +1993,6 @@ function findEntrySkills(root) {
1991
1993
  }
1992
1994
  }
1993
1995
  for (const dir of scanSerenityDirs(resolve(root, ".opencode", "skills"))) add(join(dir, "SKILL.md"), "opencode");
1994
- for (const dir of scanSerenityDirs(resolve(root, ".dsh", "skills"))) add(join(dir, "SKILL.md"), "dsh");
1995
1996
  return out;
1996
1997
  }
1997
1998
  function truncateContent(content, maxChars) {
@@ -2153,11 +2154,13 @@ function mergeSkillNames(cccSkills, trajSkills) {
2153
2154
  * @param mdPath 轨迹的 SESSION.md 路径(**来自绑定的 `mdPath`**,不用 label 拼)
2154
2155
  * @param maxChars 总长上限(字符)
2155
2156
  * @param cccSkills CCC 级声明(`readTrajectorySkills`;缺省空 = 只有轨迹级,行为与 v1.43 一致)
2157
+ * @param onInjectedNames 观测回调(本次请求实际带上的 skill 名;**纯观测**,供用量统计用——
2158
+ * 回调抛错被吞,**不影响注入正文**)
2156
2159
  * @returns 注入正文;两条来源皆无声明 → `''`(空串 = 宿主不产出该 section 块);
2157
2160
  * SESSION.md 缺失/读失败 → 响亮提示(若同时有 CCC 级声明,则提示在前、正文随后——
2158
2161
  * **提示不因"别处有内容"而被吞掉**)
2159
2162
  */
2160
- function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS_MAX_CHARS, cccSkills = []) {
2163
+ function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS_MAX_CHARS, cccSkills = [], onInjectedNames) {
2161
2164
  const abs = absoluteMdPath(root, mdPath);
2162
2165
  let trajNames = [];
2163
2166
  let notice = "";
@@ -2169,6 +2172,9 @@ function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS
2169
2172
  }
2170
2173
  const names = mergeSkillNames(cccSkills, trajNames);
2171
2174
  if (names.length === 0) return notice;
2175
+ if (onInjectedNames) try {
2176
+ onInjectedNames(names);
2177
+ } catch {}
2172
2178
  const blocks = [];
2173
2179
  for (const name of names) {
2174
2180
  if (!isSafeSkillName(name)) {
@@ -2189,6 +2195,274 @@ function buildTrajectorySkillsSection(root, mdPath, maxChars = TRAJECTORY_SKILLS
2189
2195
  const body = blocks.join("\n\n");
2190
2196
  return truncateContent(notice ? `${notice}\n\n${body}` : body, maxChars);
2191
2197
  }
2198
+ //#endregion
2199
+ //#region src/usage-stats.ts
2200
+ /**
2201
+ * usage-stats.ts — ACC 用量统计(**skill 加载次数** + **MSM 执行次数**;落该 CCC 的 `_tmp`)
2202
+ *
2203
+ * ## 所有者令(本模块的存在理由)
2204
+ *
2205
+ * 「我需要对 ACC 增加一个统计功能,包括 **skill 加载次数统计,msm 执行次数统计**;
2206
+ * 要求这个统计**自动生成在 CCC 的 `_tmp` 内的指定文件**」
2207
+ * +(对齐时确认)「**skill 加载**两个口径**都记、用字段区分**」「**只记名字、不记参数**」
2208
+ * 「**重启后读旧文件续算**」。
2209
+ *
2210
+ * ## 它解决什么麻烦(白话)
2211
+ *
2212
+ * 在此之前,「**哪些 skill 真的被加载过、哪些 MSM 真的在跑**」在 ACC 侧**一个数都没有** ——
2213
+ * 要判断「这东西还有人用吗」,只能靠翻会话记录**猜**。
2214
+ * ⇒ 本模块让每个 CCC 留下一份**纯计数**账本:**用了哪个、多少次、第一次和最近一次是什么时候**。
2215
+ * 用途 = 清理/精简的**依据**(没有人用的东西与天天在用的东西,不再靠猜)。
2216
+ *
2217
+ * ## 🔴 铁律(与 `cro-log.ts` 同族)
2218
+ *
2219
+ * **本模块每个导出函数都不抛错**:读盘 / 解析 / 写盘失败一律返回结构化失败,
2220
+ * 由调用方只记一行日志(或什么都不做)⇒ **统计的问题绝不影响工具执行与既有链路**。
2221
+ * 调用点在 `tools/post-execute` 的中途 ⇒ **更不能抛**(否则等于用统计打断会话)。
2222
+ *
2223
+ * ## 🔴 只记名字(所有者令;也是本模块唯一的"不入盘"约束)
2224
+ *
2225
+ * **绝不写 `arguments`**:MSM 参数里可能有凭据 / 路径 / 正文。
2226
+ * ⇒ 本模块只保留**计数键**(skill 名 / MSM 名),并对其做**长度与数量上限**(见常量),
2227
+ * 使文件规模**结构上有界**(不需要轮转,也不会被异常名字撑爆)。
2228
+ *
2229
+ * ## 🔴 重启后必须续算(所有者令)
2230
+ *
2231
+ * 宿主会被重启(本容器实测 24h 内 3 次)⇒ 计数**不能只活在内存里**:
2232
+ * 每次写盘 = **读旧文件 → 在旧值上自增 → 原子写**(`tmp + rename`,与 `wake-registry.ts` /
2233
+ * `cro-log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
2234
+ *
2235
+ * ## 口径(两个字段,勿合并)
2236
+ *
2237
+ * | 字段 | 含义 | 喂它的地方 |
2238
+ * |---|---|---|
2239
+ * | `skill.loads` | 模型**主动**调宿主 `skill` 工具(把某 skill 的 `SKILL.md` 全文载入) | `tools/post-execute`(工具名 = `skill`) |
2240
+ * | `skill.injections` | ACC **每请求注入**的 skill 段(`trajectory.skills` / 轨迹 frontmatter 声明) | `seams/system-prompt.ts` 的 trajectory-skills section 求值回调 |
2241
+ * | `msm` | `msm` 工具的一次执行,按 **MSM 名**分桶 | `tools/post-execute`(工具名 = `msm`) |
2242
+ *
2243
+ * 🔴 **两者差好几个数量级**:`loads` 是模型的**动作**(稀疏),`injections` 是**请求的属性**(稠密)。
2244
+ * 合并成一个数就**再也读不出**「这个 skill 是被人主动用的,还是只是被配上了」。
2245
+ */
2246
+ /** 落点目录(所有者令:CCC 的 `_tmp` 内) */
2247
+ const USAGE_STATS_SUBDIR = "_tmp";
2248
+ /** 固定文件名(所有者令「指定文件」;对齐时我取的名字,owner 未反对 ⇒ 定为常量) */
2249
+ const USAGE_STATS_FILENAME = "acc-usage.json";
2250
+ /** 每个桶的键数上限(**结构上界**,见文件头"只记名字"节) */
2251
+ const USAGE_MAX_KEYS_PER_BUCKET = 1e3;
2252
+ const USAGE_STATS_VERSION = 1;
2253
+ /** 统计文件绝对路径(**纯函数**) */
2254
+ function usageStatsPath(root) {
2255
+ return join(root, USAGE_STATS_SUBDIR, USAGE_STATS_FILENAME);
2256
+ }
2257
+ /** 空账本(**纯函数**) */
2258
+ function emptyUsageStats() {
2259
+ return {
2260
+ version: USAGE_STATS_VERSION,
2261
+ updatedAt: isoLocal(0),
2262
+ skill: {
2263
+ loads: {},
2264
+ injections: {}
2265
+ },
2266
+ msm: {}
2267
+ };
2268
+ }
2269
+ /** 单条计数形状校验(容忍手改:字段缺失/类型不符 ⇒ 丢弃该条,不阻断整档) */
2270
+ function asCounter(value) {
2271
+ const c = value;
2272
+ if (!c || typeof c !== "object") return null;
2273
+ if (typeof c.count !== "number" || !Number.isFinite(c.count) || c.count < 0) return null;
2274
+ const firstAt = typeof c.firstAt === "string" ? c.firstAt : "";
2275
+ const lastAt = typeof c.lastAt === "string" ? c.lastAt : firstAt;
2276
+ return {
2277
+ count: Math.floor(c.count),
2278
+ firstAt,
2279
+ lastAt
2280
+ };
2281
+ }
2282
+ /** 桶校验(非对象 ⇒ 空桶;逐键过滤非法条) */
2283
+ function asBucket(value) {
2284
+ const out = {};
2285
+ if (!value || typeof value !== "object") return out;
2286
+ for (const [k, v] of Object.entries(value)) {
2287
+ const c = asCounter(v);
2288
+ if (c) out[k] = c;
2289
+ }
2290
+ return out;
2291
+ }
2292
+ /**
2293
+ * 读取账本(**永不抛**;读坏 ⇒ 空账本 + 返回 error 文本,**不静默吞掉**)。
2294
+ * 文件不存在 = 正常初态(`error: null` 且空账本)。
2295
+ */
2296
+ function loadUsageStats(root) {
2297
+ const path = usageStatsPath(root);
2298
+ if (!existsSync(path)) return {
2299
+ stats: emptyUsageStats(),
2300
+ error: null
2301
+ };
2302
+ try {
2303
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
2304
+ const skill = parsed.skill ?? {};
2305
+ return {
2306
+ stats: {
2307
+ version: USAGE_STATS_VERSION,
2308
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : isoLocal(0),
2309
+ skill: {
2310
+ loads: asBucket(skill.loads),
2311
+ injections: asBucket(skill.injections)
2312
+ },
2313
+ msm: asBucket(parsed.msm)
2314
+ },
2315
+ error: null
2316
+ };
2317
+ } catch (err) {
2318
+ return {
2319
+ stats: emptyUsageStats(),
2320
+ error: `用量统计解析失败(${String(err?.message ?? err)})`
2321
+ };
2322
+ }
2323
+ }
2324
+ /** 键是否可记(非空 + 不超长;**不截断**——截断会造出一个不存在的名字) */
2325
+ function isRecordableUsageName(name) {
2326
+ return typeof name === "string" && name !== "" && [...name].length <= 120;
2327
+ }
2328
+ /**
2329
+ * 在桶上自增一格(**纯函数**:不改入参,返回新桶)。
2330
+ * 新键超桶上限 ⇒ **不新增**(但仍自增已有键)⇒ 键数**结构上有界**。
2331
+ */
2332
+ function bumpBucket(bucket, name, at, maxKeys = USAGE_MAX_KEYS_PER_BUCKET) {
2333
+ const prev = bucket[name];
2334
+ if (!prev && Object.keys(bucket).length >= maxKeys) return bucket;
2335
+ const next = prev ? {
2336
+ count: prev.count + 1,
2337
+ firstAt: prev.firstAt || at,
2338
+ lastAt: at
2339
+ } : {
2340
+ count: 1,
2341
+ firstAt: at,
2342
+ lastAt: at
2343
+ };
2344
+ return {
2345
+ ...bucket,
2346
+ [name]: next
2347
+ };
2348
+ }
2349
+ /** 从工具入参里取名字(首个命中的键胜出;取不到 ⇒ `null`) */
2350
+ function nameFromArgs(args, keys) {
2351
+ if (args === null || typeof args !== "object") return null;
2352
+ const a = args;
2353
+ for (const k of keys) if (isRecordableUsageName(a[k])) return a[k];
2354
+ return null;
2355
+ }
2356
+ /** `msm` 工具的名字键(`msm(name, args)` 的第一个参数) */
2357
+ const MSM_NAME_KEYS = ["name"];
2358
+ /** `skill` 工具的名字键(宿主 `skill` 工具用 `name`;另两个是防御性容错) */
2359
+ const SKILL_NAME_KEYS = [
2360
+ "name",
2361
+ "skill",
2362
+ "skillName"
2363
+ ];
2364
+ const NOTHING = {
2365
+ ok: true,
2366
+ error: null,
2367
+ recorded: null,
2368
+ name: null,
2369
+ wrote: false
2370
+ };
2371
+ /**
2372
+ * 读 → 自增 → 原子写(**本模块唯一写盘通道;永不抛**)。
2373
+ *
2374
+ * @param root CCC 根
2375
+ * @param mutate 在已载入的账本上做一次自增(返回值 = 本次记录的结论;返回 `NOTHING` 则不写盘)
2376
+ * @param nowMs 当前毫秒(可注入 ⇒ 便于测 firstAt/lastAt)
2377
+ */
2378
+ function writeThrough(root, mutate, nowMs = Date.now()) {
2379
+ const path = usageStatsPath(root);
2380
+ try {
2381
+ const at = isoLocal(nowMs);
2382
+ const loaded = loadUsageStats(root);
2383
+ const outcome = mutate(loaded.stats, at);
2384
+ if (outcome.recorded === null) return outcome;
2385
+ loaded.stats.updatedAt = at;
2386
+ mkdirSync(join(root, USAGE_STATS_SUBDIR), { recursive: true });
2387
+ const tmp = `${path}.tmp`;
2388
+ writeFileSync(tmp, `${JSON.stringify(loaded.stats, null, 2)}\n`, "utf-8");
2389
+ renameSync(tmp, path);
2390
+ return {
2391
+ ...outcome,
2392
+ ok: true,
2393
+ error: null,
2394
+ wrote: true
2395
+ };
2396
+ } catch (err) {
2397
+ return {
2398
+ ok: false,
2399
+ error: `用量统计写入失败(${String(err?.message ?? err)})`,
2400
+ recorded: null,
2401
+ name: null,
2402
+ wrote: false
2403
+ };
2404
+ }
2405
+ }
2406
+ /**
2407
+ * 记录一次工具调用的用量(**`tools/post-execute` 的喂入口**)。
2408
+ *
2409
+ * 只认两类工具名;其余一律 `NOTHING`(**不产生文件、不产生噪声**)。
2410
+ * 🔴 **不看 `arguments` 的其余内容**——只取名字键。
2411
+ */
2412
+ function recordToolUsage(root, toolName, args, nowMs = Date.now()) {
2413
+ if (toolName === "msm") {
2414
+ const name = nameFromArgs(args, MSM_NAME_KEYS);
2415
+ if (!name) return NOTHING;
2416
+ return writeThrough(root, (stats, at) => {
2417
+ stats.msm = bumpBucket(stats.msm, name, at);
2418
+ return {
2419
+ ok: true,
2420
+ error: null,
2421
+ recorded: "msm",
2422
+ name,
2423
+ wrote: false
2424
+ };
2425
+ }, nowMs);
2426
+ }
2427
+ if (toolName === "skill") {
2428
+ const name = nameFromArgs(args, SKILL_NAME_KEYS);
2429
+ if (!name) return NOTHING;
2430
+ return writeThrough(root, (stats, at) => {
2431
+ stats.skill.loads = bumpBucket(stats.skill.loads, name, at);
2432
+ return {
2433
+ ok: true,
2434
+ error: null,
2435
+ recorded: "skill.load",
2436
+ name,
2437
+ wrote: false
2438
+ };
2439
+ }, nowMs);
2440
+ }
2441
+ return NOTHING;
2442
+ }
2443
+ /**
2444
+ * 记录一次 **skill 注入**(每个请求的 skill 段装配时调用)。
2445
+ *
2446
+ * ⚠️ 与 `recordToolUsage` 不同,本函数的调用频度 = **请求频度**(稠密):
2447
+ * 调用方应**只在真的注入了非空内容时**调用(空 section 由宿主丢弃 ⇒ 不产生噪声)。
2448
+ * 一次调用只写一次盘(`names` 内的多个 skill 合并在**同一次**读改写里)。
2449
+ */
2450
+ function recordSkillInjections(root, names, nowMs = Date.now()) {
2451
+ const usable = names.filter((n) => isRecordableUsageName(n));
2452
+ if (usable.length === 0) return NOTHING;
2453
+ return writeThrough(root, (stats, at) => {
2454
+ let bucket = stats.skill.injections;
2455
+ for (const n of usable) bucket = bumpBucket(bucket, n, at);
2456
+ stats.skill.injections = bucket;
2457
+ return {
2458
+ ok: true,
2459
+ error: null,
2460
+ recorded: "skill.injection",
2461
+ name: usable.join(","),
2462
+ wrote: false
2463
+ };
2464
+ }, nowMs);
2465
+ }
2192
2466
  /** 验证窗口:当前时步 ± 1(容忍时钟漂移/生成延迟) */
2193
2467
  const TOTP_WINDOW = 1;
2194
2468
  /** 输出位数(标准 6 位) */
@@ -3159,7 +3433,7 @@ function registerTrajectorySkillSection(agent, root) {
3159
3433
  text: () => {
3160
3434
  const current = readLastBound(session);
3161
3435
  if (!current) return "";
3162
- return buildTrajectorySkillsSection(root, current.mdPath, TRAJECTORY_SKILLS_MAX_CHARS, readTrajectorySkills(root));
3436
+ return buildTrajectorySkillsSection(root, current.mdPath, TRAJECTORY_SKILLS_MAX_CHARS, readTrajectorySkills(root), (names) => recordSkillInjections(root, names));
3163
3437
  }
3164
3438
  });
3165
3439
  trajectorySkillAgents.add(key);
@@ -4497,6 +4771,9 @@ ${renderCroSampleSnapshot()}
4497
4771
  * ⇒ 族名取共享词干 **`send-`**、轴取 **`-now` / `-later`**(同构)。**硬切无别名**(D46/D61 先例);
4498
4772
  * 机制层词汇(`wake-registry.json` / `w-*` id / `WakeEntry` / 调度器名 / 配置键 `wakeSchedulerEnabled`)
4499
4773
  * **一律不动** —— 接受"机制叫 wake、动作叫 send-later"的**分层命名**(二者本是两层)。
4774
+ * 🔴 **2026-09-21 补注(不改写历史,只标失效时点)**:上句里那个**配置键 `wakeSchedulerEnabled`
4775
+ * 已随后续所有者令被砍**(唤醒调度器无闸、恒武装)⇒ 该词从"机制层词汇"里**自然减员**;
4776
+ * 现行总闸键是 CRO 阶段的 **`croEnabled`**。
4500
4777
  *
4501
4778
  * 与旧面(logbook 11 动作 + trajectory 12 动作)的对应(用户逐条裁决,R↓):
4502
4779
  * · `summary` → 并入 `list`(全库统计随清单一起给)
@@ -4520,10 +4797,21 @@ ${renderCroSampleSnapshot()}
4520
4797
  function agentScope$1(exec) {
4521
4798
  return exec.agent?.session?.id ?? "default";
4522
4799
  }
4523
- /** 当前 dsh 会话对象(append bound 用);无 → null */
4800
+ /**
4801
+ * 当前 dsh 会话对象(绑定的读写都要用它);无 `append` → null。
4802
+ *
4803
+ * 🔴 **2026-09-21 修 F3(原实现是 F1 的上游成因)**:本函数原先把会话**削成只带 `append` 的包装对象**。
4804
+ * 但 `appendBound` / `readLastBound` 定位"这是哪条会话、哪个 CCC 根"靠的是
4805
+ * `session.header.id` 与 `session.header.cwd`(见 `trajectory-bound.ts` 的 `sessionHeader`)——
4806
+ * **包装对象没有 header** ⇒ ① `appendBound` **在读 id 处直接 `return false`**(静默 no-op)
4807
+ * ⇒ **`use` 这条路从来不写当前载体的绑定**(实证吻合:S142 §7.4 记录"当前载体的绑定**直到
4808
+ * 17:05 那次 rebuild 才写入**")② `readLastBound` 同样恒 null ⇒ G1 切换守卫只能靠内存回退。
4809
+ * ⇒ 改为**返回原会话对象**(只增字段、不削字段):只用 `append` 的调用点行为不变,
4810
+ * 而绑定读写从此拿得到 header。
4811
+ */
4524
4812
  function agentDshSession(exec) {
4525
4813
  const s = exec.agent?.session;
4526
- return s && typeof s.append === "function" ? { append: s.append } : null;
4814
+ return s && typeof s.append === "function" ? s : null;
4527
4815
  }
4528
4816
  /**
4529
4817
  * 当前绑定的权威 dirName(优先持久化 bound——防内存被误 use 污染;无 bound 回退内存 active)。
@@ -4759,9 +5047,6 @@ function createTrajectoryTool(ctx) {
4759
5047
  };
4760
5048
  if (advisory) out.advisory = advisory;
4761
5049
  if (dsh) {
4762
- const selfId = String(dsh.header?.id ?? "");
4763
- const superseded = supersedeOtherBindings(root, info?.dirName ?? targetDirName, selfId === "" ? /* @__PURE__ */ new Set() : /* @__PURE__ */ new Set([selfId]));
4764
- if (superseded.length > 0) out.supersededBindings = superseded;
4765
5050
  const pruned = pruneMissingBindings(root, (id) => !hasSessionLogById(sessionsRootDir(), id));
4766
5051
  if (pruned.length > 0) out.prunedBindings = pruned;
4767
5052
  }
@@ -6353,6 +6638,9 @@ function registerKeeper(ctx, opts = {}) {
6353
6638
  const tracker = trackerFor(exec);
6354
6639
  const shouldRemind = skiffKeeper ? tracker.step(exec.name) : false;
6355
6640
  const downstream = await next();
6641
+ try {
6642
+ recordToolUsage(root, exec.name, exec.arguments);
6643
+ } catch {}
6356
6644
  const blocks = [];
6357
6645
  if (shouldRemind) {
6358
6646
  const code = tracker.ack();
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * skills-discovery.ts — CCC 入口 skill 自动发现(纯逻辑,零 DSH 依赖)
3
3
  *
4
- * 发现全部入口技能(原文全量,不截断):
4
+ * 发现全部入口技能(原文全量,不截断)—— **现行三个来源**(原为四个,**来源 3 已于 2026-09-21 删除**):
5
5
  * 0. **`.serenity` 记号文件内容 = 顶层入口 skill 名**(CCC 记号文件的权威语义,
6
6
  * tiangong-serenity 的 .serenity 内容为 `tg-serenity`)—— 最高优先
7
7
  * 1. `.dsh/entry-skill` 指针文件(内容 = skill 名)—— 兼容旧约定
8
8
  * 2. `.opencode/skills/*-serenity/SKILL.md` —— 自动扫描该 CCC 的顶层入口
9
9
  * (home-serenity / tg-serenity / pangu-serenity …,命名模式 `*-serenity`)
10
- * 3. `.dsh/skills/*-serenity/SKILL.md` —— 自动扫描 ACC/harness 入口(acc-serenity 等)
11
- * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口 → dsh 入口。
10
+ * ~~3. `.dsh/skills/*-serenity/SKILL.md`(扫 ACC 装进 CCC 的技能副本)~~ —— 🔴 **已删**:
11
+ * owner 2026-09-21 令「连模板和安装命令一起删」(B 案)。**判据、实测证据与日期逐字记在
12
+ * `findEntrySkills()` 体内那一段注释里**——本头部只留结论,**不复述判据**(避免两处会漂的真相源)。
13
+ * 按名去重;顺序 = 记号文件 → 指针 → opencode 入口。
12
14
  *
13
15
  * 任何 CCC 都能自动注入其顶层入口 skill 全文(不硬编码名字)。
14
16
  */
@@ -15,6 +15,9 @@
15
15
  * ⇒ 族名取共享词干 **`send-`**、轴取 **`-now` / `-later`**(同构)。**硬切无别名**(D46/D61 先例);
16
16
  * 机制层词汇(`wake-registry.json` / `w-*` id / `WakeEntry` / 调度器名 / 配置键 `wakeSchedulerEnabled`)
17
17
  * **一律不动** —— 接受"机制叫 wake、动作叫 send-later"的**分层命名**(二者本是两层)。
18
+ * 🔴 **2026-09-21 补注(不改写历史,只标失效时点)**:上句里那个**配置键 `wakeSchedulerEnabled`
19
+ * 已随后续所有者令被砍**(唤醒调度器无闸、恒武装)⇒ 该词从"机制层词汇"里**自然减员**;
20
+ * 现行总闸键是 CRO 阶段的 **`croEnabled`**。
18
21
  *
19
22
  * 与旧面(logbook 11 动作 + trajectory 12 动作)的对应(用户逐条裁决,R↓):
20
23
  * · `summary` → 并入 `list`(全库统计随清单一起给)
@@ -801,66 +801,30 @@ function listBoundSessionIds(root, dirName) {
801
801
  return hits.sort((a, b) => b.at - a.at).map((h) => h.id);
802
802
  }
803
803
  /**
804
- * **(b) 同一 trajectory 多 live 会话的机械守卫**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
804
+ * 🔴 **「只留一条」的唯一判据**(2026-09-21 收进单一实现;此前同一判据在域侧/文件侧各写一遍)。
805
805
  *
806
- * 把绑定到 `dirName` 的**其它**会话 id 标记为 `supersededAt` —— **保留记录**(沿革/正向查询不丢),
807
- * 只切断"**唤醒还会找到它**"这条路。语义上即"解绑",且**不动那个会话本身**(它仍可被人工使用)。
806
+ * 判据:同 `dirName` **不是** `keepIds` 里的 ∧ **尚未** `supersededAt`。
807
+ * 域侧与文件侧**同判据**——历史缺陷(2026-09-19)正是"只在一侧收集 ⇒ 返回值恒空"
808
+ * 见 {@link supersedeOtherBindings} 的注释。
808
809
  *
809
- * 为什么必须有机制(R↓):`.bindings.json` 的语义是"latest-wins 覆盖**本条会话记录**",
810
- * 对**同一 trajectory 的其它会话**没有任何约束 多代会话自然堆积(实测 S142 4 代 / S185 2 代),
811
- * 而 `acquireWakeAgent` 是"遍历全部绑定 id、任一条 live 就用它"——**两条 live 都能被选中**。
812
- *
813
- * @param root CCC 根
814
- * @param dirName 目标 trajectory 目录名
815
- * @param keepIds 保留(不标记)的会话 id 集合——通常是"当前正在 use 的这条"
816
- * @returns **实际被标记**的会话 id(升序稳定;供调用方如实报告,未标记任何一条 → `[]`)
810
+ * @param fileSessions 旧文件表的记录集
811
+ * @param domainEntries 域侧条目(域不可用时传 `undefined` = 不参与判定)
817
812
  */
818
- function supersedeOtherBindings(root, dirName, keepIds) {
819
- const path = join(root, BINDINGS_REL_PATH);
820
- const file = readBindingsFile(path);
821
- const at = Date.now();
822
- /**
823
- * 需要退休的会话 id 集合。
824
- *
825
- * 🔴 必须取**域 ∪ 文件的并集**(实测缺陷,2026-09-19):
826
- * 首版实现只在**文件循环**里 `marked.push(id)`,于是"记录只在域里"时
827
- * 返回值恒为 `[]` —— 而调用方(`tools/trajectory.ts` 的 `use`)**靠这个返回值**决定
828
- * 是否回报 `supersededBindings`,并且早先用 `marked.length === 0` 短路**跳过了写盘**。
829
- * ⇒ 症状是"D69 只留一条**静默不生效**"(记录没被标记,返回也看不出异常)。
830
- * 由 `tests/trajectory-bound.test.ts` 的 D69 域路径用例抓到。
831
- */
813
+ function collectSupersedeTargets(dirName, keepIds, fileSessions, domainEntries) {
832
814
  const toMark = /* @__PURE__ */ new Set();
833
- if (domainStore?.available) for (const [id, raw] of domainStore.entries()) {
815
+ if (domainEntries) for (const [id, raw] of domainEntries) {
834
816
  const rec = fromDomainRecord(raw);
835
817
  if (rec.dirName !== dirName) continue;
836
818
  if (keepIds.has(id) || rec.supersededAt !== void 0) continue;
837
819
  toMark.add(id);
838
820
  }
839
- for (const [id, raw] of Object.entries(file.sessions)) {
821
+ for (const [id, raw] of Object.entries(fileSessions)) {
840
822
  const rec = asBoundRecord(raw);
841
823
  if (!rec || rec.dirName !== dirName) continue;
842
824
  if (keepIds.has(id) || rec.supersededAt !== void 0) continue;
843
825
  toMark.add(id);
844
826
  }
845
- if (toMark.size === 0) return [];
846
- if (domainStore?.available) for (const id of toMark) {
847
- const raw = domainStore.get(id);
848
- if (!raw) continue;
849
- domainStore.put(id, toDomainRecord({
850
- ...fromDomainRecord(raw),
851
- supersededAt: at
852
- })).catch(() => {});
853
- }
854
- for (const id of toMark) {
855
- const rec = asBoundRecord(file.sessions[id]);
856
- if (!rec) continue;
857
- rec.supersededAt = at;
858
- file.sessions[id] = rec;
859
- }
860
- try {
861
- writeBindingsFile(path, file);
862
- } catch {}
863
- return [...toMark].sort();
827
+ return toMark;
864
828
  }
865
829
  /**
866
830
  * **(c) 绑定一致性兜底**(S142 §0y,所有者 2026-09-18 裁「c 也算个兜底」):
@@ -906,6 +870,11 @@ function pruneMissingBindings(root, isMissing) {
906
870
  * 写入绑定(latest-wins 覆盖该会话记录)。
907
871
  *
908
872
  * 绑定持久化尽力而为:无法定位 CCC 根 / 无会话 id / 写盘失败 → 返回 false,不阻断主流程。
873
+ *
874
+ * 🔴 **同时是「只留一条」(D69)的唯一写入口**(2026-09-21 起):每写一条,就在**同一次 RMW** 里
875
+ * 把同轨迹的**其它**条标 `supersededAt`(自己除外)。⇒ 任何调用路径(`create`/`activate`/`switch`/
876
+ * `rebuild`/`reconcile`)**都自动满足"只留一条"**,调用方**不需要**(也**不应当**)自己算 selfId。
877
+ *
909
878
  * @param session 目标 dsh 会话(需 `header.id` + `header.cwd` 可定位 CCC 根)
910
879
  * @param action 绑定动作
911
880
  * @param rec 绑定记录(dirName + mdPath 必填;sessionId 可选展示码)
@@ -927,11 +896,26 @@ function appendBound(session, action, rec) {
927
896
  try {
928
897
  const file = readBindingsFile(path);
929
898
  file.sessions[id] = record;
899
+ const targets = collectSupersedeTargets(rec.dirName, /* @__PURE__ */ new Set([id]), file.sessions, domainStore?.available ? domainStore.entries() : void 0);
900
+ for (const other of targets) {
901
+ const otherRec = asBoundRecord(file.sessions[other]);
902
+ if (!otherRec) continue;
903
+ otherRec.supersededAt = record.at;
904
+ file.sessions[other] = otherRec;
905
+ }
930
906
  writeBindingsFile(path, file);
907
+ if (domainStore?.available) for (const other of targets) {
908
+ const raw = domainStore.get(other);
909
+ if (!raw) continue;
910
+ domainStore.put(other, toDomainRecord({
911
+ ...fromDomainRecord(raw),
912
+ supersededAt: record.at
913
+ })).catch(() => {});
914
+ }
931
915
  return true;
932
916
  } catch {
933
917
  return domainStore?.available === true;
934
918
  }
935
919
  }
936
920
  //#endregion
937
- export { summarize as C, localHuman as D, localFileStamp as E, localIdStamp as O, showSession as S, isoLocal as T, readActiveSessionMd as _, resolveSessionTrajectoryLabel as a, sessionsRoot as b, DEFAULT_SESSION_SCOPE as c, createSession as d, extractSessionMdPathFromText as f, parseSessionContextFromEvents as g, listSessions as h, readLastBound as i, TRAJECTORY_ACTIONS as l, getActiveSessionInfo as m, listBoundSessionIds as n, setBindingStore as o, findSession as p, pruneMissingBindings as r, supersedeOtherBindings as s, appendBound as t, clearActiveSessionInfo as u, resolveSessionByTitle as v, useSession as w, setActiveSessionInfo as x, sessionEvents as y };
921
+ export { useSession as C, localIdStamp as D, localHuman as E, summarize as S, localFileStamp as T, resolveSessionByTitle as _, resolveSessionTrajectoryLabel as a, setActiveSessionInfo as b, TRAJECTORY_ACTIONS as c, extractSessionMdPathFromText as d, findSession as f, readActiveSessionMd as g, parseSessionContextFromEvents as h, readLastBound as i, clearActiveSessionInfo as l, listSessions as m, listBoundSessionIds as n, setBindingStore as o, getActiveSessionInfo as p, pruneMissingBindings as r, DEFAULT_SESSION_SCOPE as s, appendBound as t, createSession as u, sessionEvents as v, isoLocal as w, showSession as x, sessionsRoot as y };
@@ -169,6 +169,11 @@ export declare function pruneMissingBindings(root: string, isMissing: (sessionId
169
169
  * 写入绑定(latest-wins 覆盖该会话记录)。
170
170
  *
171
171
  * 绑定持久化尽力而为:无法定位 CCC 根 / 无会话 id / 写盘失败 → 返回 false,不阻断主流程。
172
+ *
173
+ * 🔴 **同时是「只留一条」(D69)的唯一写入口**(2026-09-21 起):每写一条,就在**同一次 RMW** 里
174
+ * 把同轨迹的**其它**条标 `supersededAt`(自己除外)。⇒ 任何调用路径(`create`/`activate`/`switch`/
175
+ * `rebuild`/`reconcile`)**都自动满足"只留一条"**,调用方**不需要**(也**不应当**)自己算 selfId。
176
+ *
172
177
  * @param session 目标 dsh 会话(需 `header.id` + `header.cwd` 可定位 CCC 根)
173
178
  * @param action 绑定动作
174
179
  * @param rec 绑定记录(dirName + mdPath 必填;sessionId 可选展示码)
@@ -81,8 +81,10 @@ export declare function mergeSkillNames(cccSkills: readonly string[], trajSkills
81
81
  * @param mdPath 轨迹的 SESSION.md 路径(**来自绑定的 `mdPath`**,不用 label 拼)
82
82
  * @param maxChars 总长上限(字符)
83
83
  * @param cccSkills CCC 级声明(`readTrajectorySkills`;缺省空 = 只有轨迹级,行为与 v1.43 一致)
84
+ * @param onInjectedNames 观测回调(本次请求实际带上的 skill 名;**纯观测**,供用量统计用——
85
+ * 回调抛错被吞,**不影响注入正文**)
84
86
  * @returns 注入正文;两条来源皆无声明 → `''`(空串 = 宿主不产出该 section 块);
85
87
  * SESSION.md 缺失/读失败 → 响亮提示(若同时有 CCC 级声明,则提示在前、正文随后——
86
88
  * **提示不因"别处有内容"而被吞掉**)
87
89
  */
88
- export declare function buildTrajectorySkillsSection(root: string, mdPath: string, maxChars?: number, cccSkills?: readonly string[]): string;
90
+ export declare function buildTrajectorySkillsSection(root: string, mdPath: string, maxChars?: number, cccSkills?: readonly string[], onInjectedNames?: (names: readonly string[]) => void): string;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * usage-stats.ts — ACC 用量统计(**skill 加载次数** + **MSM 执行次数**;落该 CCC 的 `_tmp`)
3
+ *
4
+ * ## 所有者令(本模块的存在理由)
5
+ *
6
+ * 「我需要对 ACC 增加一个统计功能,包括 **skill 加载次数统计,msm 执行次数统计**;
7
+ * 要求这个统计**自动生成在 CCC 的 `_tmp` 内的指定文件**」
8
+ * +(对齐时确认)「**skill 加载**两个口径**都记、用字段区分**」「**只记名字、不记参数**」
9
+ * 「**重启后读旧文件续算**」。
10
+ *
11
+ * ## 它解决什么麻烦(白话)
12
+ *
13
+ * 在此之前,「**哪些 skill 真的被加载过、哪些 MSM 真的在跑**」在 ACC 侧**一个数都没有** ——
14
+ * 要判断「这东西还有人用吗」,只能靠翻会话记录**猜**。
15
+ * ⇒ 本模块让每个 CCC 留下一份**纯计数**账本:**用了哪个、多少次、第一次和最近一次是什么时候**。
16
+ * 用途 = 清理/精简的**依据**(没有人用的东西与天天在用的东西,不再靠猜)。
17
+ *
18
+ * ## 🔴 铁律(与 `cro-log.ts` 同族)
19
+ *
20
+ * **本模块每个导出函数都不抛错**:读盘 / 解析 / 写盘失败一律返回结构化失败,
21
+ * 由调用方只记一行日志(或什么都不做)⇒ **统计的问题绝不影响工具执行与既有链路**。
22
+ * 调用点在 `tools/post-execute` 的中途 ⇒ **更不能抛**(否则等于用统计打断会话)。
23
+ *
24
+ * ## 🔴 只记名字(所有者令;也是本模块唯一的"不入盘"约束)
25
+ *
26
+ * **绝不写 `arguments`**:MSM 参数里可能有凭据 / 路径 / 正文。
27
+ * ⇒ 本模块只保留**计数键**(skill 名 / MSM 名),并对其做**长度与数量上限**(见常量),
28
+ * 使文件规模**结构上有界**(不需要轮转,也不会被异常名字撑爆)。
29
+ *
30
+ * ## 🔴 重启后必须续算(所有者令)
31
+ *
32
+ * 宿主会被重启(本容器实测 24h 内 3 次)⇒ 计数**不能只活在内存里**:
33
+ * 每次写盘 = **读旧文件 → 在旧值上自增 → 原子写**(`tmp + rename`,与 `wake-registry.ts` /
34
+ * `cro-log.ts` 同款形态)⇒ **进程重启后计数自然延续**,无需额外的 flush/落盘时机。
35
+ *
36
+ * ## 口径(两个字段,勿合并)
37
+ *
38
+ * | 字段 | 含义 | 喂它的地方 |
39
+ * |---|---|---|
40
+ * | `skill.loads` | 模型**主动**调宿主 `skill` 工具(把某 skill 的 `SKILL.md` 全文载入) | `tools/post-execute`(工具名 = `skill`) |
41
+ * | `skill.injections` | ACC **每请求注入**的 skill 段(`trajectory.skills` / 轨迹 frontmatter 声明) | `seams/system-prompt.ts` 的 trajectory-skills section 求值回调 |
42
+ * | `msm` | `msm` 工具的一次执行,按 **MSM 名**分桶 | `tools/post-execute`(工具名 = `msm`) |
43
+ *
44
+ * 🔴 **两者差好几个数量级**:`loads` 是模型的**动作**(稀疏),`injections` 是**请求的属性**(稠密)。
45
+ * 合并成一个数就**再也读不出**「这个 skill 是被人主动用的,还是只是被配上了」。
46
+ */
47
+ /** 落点目录(所有者令:CCC 的 `_tmp` 内) */
48
+ export declare const USAGE_STATS_SUBDIR = "_tmp";
49
+ /** 固定文件名(所有者令「指定文件」;对齐时我取的名字,owner 未反对 ⇒ 定为常量) */
50
+ export declare const USAGE_STATS_FILENAME = "acc-usage.json";
51
+ /** 单个计数的形态 */
52
+ export interface UsageCounter {
53
+ /** 累计次数 */
54
+ count: number;
55
+ /** 首次计入时刻(当地 RFC3339 带偏移,遵 D67) */
56
+ firstAt: string;
57
+ /** 最近一次计入时刻 */
58
+ lastAt: string;
59
+ }
60
+ /** 文件形态(`{version, updatedAt, skill:{loads,injections}, msm}`) */
61
+ export interface UsageStats {
62
+ version: number;
63
+ /** 最近一次写盘时刻 */
64
+ updatedAt: string;
65
+ skill: {
66
+ /** 模型主动调 `skill` 工具(全文载入) */
67
+ loads: Record<string, UsageCounter>;
68
+ /** ACC 每请求注入的 skill 段 */
69
+ injections: Record<string, UsageCounter>;
70
+ };
71
+ /** 按 MSM 名分桶的执行次数 */
72
+ msm: Record<string, UsageCounter>;
73
+ }
74
+ /** 计数键长度上限(防异常名字把文件撑大;超长 ⇒ **不记**,不截断成假名字) */
75
+ export declare const USAGE_NAME_MAX_CHARS = 120;
76
+ /** 每个桶的键数上限(**结构上界**,见文件头"只记名字"节) */
77
+ export declare const USAGE_MAX_KEYS_PER_BUCKET = 1000;
78
+ /** 统计文件绝对路径(**纯函数**) */
79
+ export declare function usageStatsPath(root: string): string;
80
+ /** 空账本(**纯函数**) */
81
+ export declare function emptyUsageStats(): UsageStats;
82
+ /**
83
+ * 读取账本(**永不抛**;读坏 ⇒ 空账本 + 返回 error 文本,**不静默吞掉**)。
84
+ * 文件不存在 = 正常初态(`error: null` 且空账本)。
85
+ */
86
+ export declare function loadUsageStats(root: string): {
87
+ stats: UsageStats;
88
+ error: string | null;
89
+ };
90
+ /** 键是否可记(非空 + 不超长;**不截断**——截断会造出一个不存在的名字) */
91
+ export declare function isRecordableUsageName(name: unknown): name is string;
92
+ /**
93
+ * 在桶上自增一格(**纯函数**:不改入参,返回新桶)。
94
+ * 新键超桶上限 ⇒ **不新增**(但仍自增已有键)⇒ 键数**结构上有界**。
95
+ */
96
+ export declare function bumpBucket(bucket: Record<string, UsageCounter>, name: string, at: string, maxKeys?: number): Record<string, UsageCounter>;
97
+ /** 从工具入参里取名字(首个命中的键胜出;取不到 ⇒ `null`) */
98
+ export declare function nameFromArgs(args: unknown, keys: readonly string[]): string | null;
99
+ /** `msm` 工具的名字键(`msm(name, args)` 的第一个参数) */
100
+ export declare const MSM_NAME_KEYS: readonly ["name"];
101
+ /** `skill` 工具的名字键(宿主 `skill` 工具用 `name`;另两个是防御性容错) */
102
+ export declare const SKILL_NAME_KEYS: readonly ["name", "skill", "skillName"];
103
+ /** 一次记录的结论(供调用方决定要不要留痕;**本模块不自己打日志**) */
104
+ export interface UsageRecordResult {
105
+ ok: boolean;
106
+ error: string | null;
107
+ /** 记入的是哪一类;`null` = 本次没有可记的东西(不算失败) */
108
+ recorded: 'skill.load' | 'skill.injection' | 'msm' | null;
109
+ /** 记入的键(`recorded` 为 `null` 时也为 `null`) */
110
+ name: string | null;
111
+ /** 本次是否真的落盘(`recorded` 非空即 true;失败时 false) */
112
+ wrote: boolean;
113
+ }
114
+ /**
115
+ * 记录一次工具调用的用量(**`tools/post-execute` 的喂入口**)。
116
+ *
117
+ * 只认两类工具名;其余一律 `NOTHING`(**不产生文件、不产生噪声**)。
118
+ * 🔴 **不看 `arguments` 的其余内容**——只取名字键。
119
+ */
120
+ export declare function recordToolUsage(root: string, toolName: string, args: unknown, nowMs?: number): UsageRecordResult;
121
+ /**
122
+ * 记录一次 **skill 注入**(每个请求的 skill 段装配时调用)。
123
+ *
124
+ * ⚠️ 与 `recordToolUsage` 不同,本函数的调用频度 = **请求频度**(稠密):
125
+ * 调用方应**只在真的注入了非空内容时**调用(空 section 由宿主丢弃 ⇒ 不产生噪声)。
126
+ * 一次调用只写一次盘(`names` 内的多个 skill 合并在**同一次**读改写里)。
127
+ */
128
+ export declare function recordSkillInjections(root: string, names: readonly string[], nowMs?: number): UsageRecordResult;
@@ -1,7 +1,7 @@
1
1
  import { r as hostService, t as hostAgents } from "./access-fiehjxV6.js";
2
2
  import { g as resolveInside } from "./ccc-DG4Oc7I1.js";
3
3
  import { o as listCccs } from "./ccc-roots-FxWXuZPw.js";
4
- import { D as localHuman, O as localIdStamp, T as isoLocal, b as sessionsRoot, n as listBoundSessionIds, p as findSession } from "./trajectory-bound-BzdzsDPI.js";
4
+ import { D as localIdStamp, E as localHuman, f as findSession, n as listBoundSessionIds, w as isoLocal, y as sessionsRoot } from "./trajectory-bound-BPWKkzJC.js";
5
5
  import { t as readSimpleSettings } from "./settings-section-CIuyoSOX.js";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
@@ -1190,10 +1190,12 @@ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
1190
1190
  * **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
1191
1191
  * tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
1192
1192
  *
1193
- * 🔴 硬约束三:**两条闸必须继续分开传**(v1.34 S-1 解耦)
1193
+ * 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
1194
1194
  *
1195
- * 唤醒调度器 `wakeSchedulerEnabled`(**缺省开**)|autopilot `autopilotWakeEnabled`
1196
- * (**缺省关**)——取值方向相反。工厂取 `gate` **回调**(每次现读),**不做**任何合并或缓存。
1195
+ * 现只余 **autopilot `autopilotWakeEnabled`**(**缺省关**)一条;**唤醒调度器已无闸**
1196
+ * (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
1197
+ * = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
1198
+ * **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
1197
1199
  *
1198
1200
  * ───────────────────────────────────────────────────────────────────────────
1199
1201
  * 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
@@ -18,8 +18,9 @@
18
18
  * 而非"投递失败",见 `WakeDeliveryResult.notReady` —— 启动那一拍早于懒服务就绪是常态)
19
19
  * 4. 投递:`agent.followup(...)`("Queue an ordinary follow-up turn and wake the driver")
20
20
  *
21
- * 守卫(I-4 默认 + v1.34 S-1 解耦):全局 `wakeSchedulerEnabled`(**缺省开**;**不再**回退旧键
22
- * `autopilotEnabled`——关周期自唤醒不得连带关一次性唤醒)|
21
+ * 守卫(2026-09-21 起**本钟无闸**):🔴 **恒武装**——原 `wakeSchedulerEnabled` 已砍,
22
+ * 本钟不再有「可被误关的开关」;旧配置残留该键**静默忽略**,且**不**回退 `autopilotEnabled`|
23
+ * CRO 阶段另有**独立**总闸 `croEnabled`(只停 CRO,**不影响**本注册表投递)|
23
24
  * 补跑窗口 2h(超窗置 missed 留痕)|同 tick 串行|唤起后维持 live(人类可介入)。
24
25
  */
25
26
  import type { Context } from 'cordis';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.45.0",
3
+ "version": "1.46.0",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2。",
5
5
  "license": "MIT",
6
6
  "repository": {