diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 521827b..0856b85 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# 架构说明
-Runtime Self-Learning `v4.3.22` 当前由 `1` 个入口点、`87` 个 `lib` 模块和一组工具面组成。整体目标不是“尽量自动化”,而是“在不放宽边界的前提下,把本地经验整理成后续可复用的受限提示和低风险动作”。
+Runtime Self-Learning `v4.3.23` 当前由 `1` 个入口点、`88` 个 `lib` 模块和一组工具面组成。整体目标不是“尽量自动化”,而是“在不放宽边界的前提下,把本地经验整理成后续可复用的受限提示和低风险动作”。
---
@@ -188,6 +188,7 @@ flowchart TD
| `event-log.js` | 审计事件追加、回放和校验。 |
| `config-defaults.js` | 默认配置。 |
| `hana-runtime-compat.js` | Hanako 插件系统兼容层。 |
+| `complexity.js` | 复杂度预算的单一事实源(扫描、限额、违规判定),供 CLI 与发布门共用。 |
---
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7360808..8a2d532 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
本文档记录 Runtime Self-Learning 的版本演进。`v4.x` 为 LTS 维护线,因此该阶段的记录重点放在缺陷修复、审计加固、性能整理和发布治理,不再扩张自动化边界。
+## 4.3.23
+
+- 新增复杂度治理门:`lib/complexity.js` 定义 hard limit / soft target,`npm run complexity:check` 在超出 hard limit 时失败,`npm run complexity:report` 生成 `docs/COMPLEXITY_REPORT.md`。
+- 发布门纳入 `complexity.within_budget` 检查,并补充 `docs/COMPLEXITY_BUDGET.md` / `docs/COMPLEXITY_DEBT.md`,把 v4.x LTS 的模块数、单文件 LOC、import/export 数和 TODO/FIXME 预算写成可审计规则。
+- 拆分控制面热点复杂度:新增 `tools/control-parameters.js`、`tools/control-summaries.js` 与 `tools/control-handlers/*`,让 `tools/control.js` 从 712 LOC 收敛到约 602 LOC,并保留行为特征回归。
+- 测试总数 `606 -> 665`:新增控制面参数、摘要、脱敏、handler characterization 与 release-readiness 复杂度门回归;README 徽章和发布门默认测试基线同步到 665。
+- 边界未放宽:本版只增加本地静态治理、文档和控制面结构整理,无新增自动放行、网络、发布或外部副作用能力。
+
## 4.3.22
- **新增自学习控制台(`chat.surface`,Hanako v0.344+)**:新增只读工具 `self_learning_console`,把"最近活动 + 待处理提案"快照投递进一条插件自有的 `plugin_private` 会话,并以原生 `chat.surface` transcript 卡片在当前聊天内嵌展示,可点开滚动查看历史快照。这是 UX/呈现层的可选增强,**不扩张自动化边界**:控台只读、由用户显式调用工具触发,不自动应用任何动作、不在后台主动推送。
diff --git a/INSTALL.md b/INSTALL.md
index 7747566..5b29f8d 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -50,10 +50,10 @@ OK skills/self-learning/SKILL.md
## 固定版本安装
-如需固定到某个 release,例如 `v4.3.22`:
+如需固定到某个 release,例如 `v4.3.23`:
```powershell
-git clone --branch v4.3.22 https://github.com/326sun/Hanako-runtime-learner.git
+git clone --branch v4.3.23 https://github.com/326sun/Hanako-runtime-learner.git
cd Hanako-runtime-learner
npm run install-plugin
```
diff --git a/README.md b/README.md
index 9d10878..4f9086d 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,12 @@
-
+
-
+
Runtime Self-Learning 会观察本地 Hanako 对话中的重复工作流、用户纠正、常见报错和大上下文使用模式,把经过证据约束的经验整理成后续会话可用的保守提示。
@@ -60,7 +60,7 @@ npm run install-plugin
固定版本安装:
```powershell
-git clone --branch v4.3.22 https://github.com/326sun/Hanako-runtime-learner.git
+git clone --branch v4.3.23 https://github.com/326sun/Hanako-runtime-learner.git
cd Hanako-runtime-learner
npm run install-plugin
```
@@ -176,10 +176,12 @@ flowchart LR
```powershell
npm run check # 语法与源代码检查
-npm test # 606 个测试
+npm test # 665 个测试
npm run benchmark # 17 个内置基准场景
npm run perf # 热路径微基准
-npm run release:check # 发布元数据与 LTS 契约检查
+npm run complexity:check # 复杂度预算门禁(超 hard limit 即失败)
+npm run complexity:report # 生成 docs/COMPLEXITY_REPORT.md
+npm run release:check # 发布元数据与 LTS 契约检查(含复杂度预算)
```
当前热路径基线(以有界运行规模 `N=100 = MAX_PATTERN_COUNT * 2` 为准):
@@ -208,12 +210,12 @@ npm run perf -- --json
npm run release:check
```
-`v4.3.22` 的预期结果:
+`v4.3.23` 的预期结果:
```text
-package version: 4.3.22
+package version: 4.3.23
npm run check: passed
-npm test: 606 tests, 601 passed, 5 skipped
+npm test: 665 tests, 665 passed, 0 skipped
npm run benchmark: passed, 17 scenarios
npm run perf: passed, no threshold breaches
npm run release:check: Score 100
@@ -238,7 +240,7 @@ npm run release:check: Score 100
| [docs/MIGRATION_v3_to_v4.md](docs/MIGRATION_v3_to_v4.md) | v3 到 v4 迁移说明。 |
| [docs/LTS_MAINTENANCE_PLAN.md](docs/LTS_MAINTENANCE_PLAN.md) | v4.x LTS 维护策略。 |
| [docs/DESIGN_GOAL_COMPLETION_MATRIX.md](docs/DESIGN_GOAL_COMPLETION_MATRIX.md) | 设计目标完成矩阵。 |
-| [docs/ACCEPTANCE-v4.3.22.md](docs/ACCEPTANCE-v4.3.22.md) | 当前版本验收记录。 |
+| [docs/ACCEPTANCE-v4.3.23.md](docs/ACCEPTANCE-v4.3.23.md) | 当前版本验收记录。 |
| [CHANGELOG.md](CHANGELOG.md) | 版本历史。 |
## 许可证
diff --git a/docs/ACCEPTANCE-v4.3.23.md b/docs/ACCEPTANCE-v4.3.23.md
new file mode 100644
index 0000000..8aefc92
--- /dev/null
+++ b/docs/ACCEPTANCE-v4.3.23.md
@@ -0,0 +1,49 @@
+# v4.3.23 验收记录
+
+## 版本目标
+
+`v4.3.23` 在 `v4.3.22` 的自学习控制台版本基础上,引入 v4.x LTS 的复杂度治理基础设施,并完成 C-001(`tools/control.js`)低风险拆分。本版目标是让复杂度预算、复杂度债务和发布门形成可审计闭环,同时不放宽运行时安全边界。
+
+## 实现范围
+
+- 新增 `lib/complexity.js` 作为复杂度预算单一事实源,扫描 `lib/`、`scripts/`、`tests/`、`tools/` 的 LOC、import/export 数和 TODO/FIXME 标记。
+- 新增 `scripts/complexity-check.js` 与 `scripts/complexity-report.js`,分别提供 hard-limit 门禁与 `docs/COMPLEXITY_REPORT.md` 报告生成。
+- 新增 `docs/COMPLEXITY_BUDGET.md` 与 `docs/COMPLEXITY_DEBT.md`,把 v4.x LTS 的复杂度预算、软警告和 deferred 项写入文档。
+- `lib/release-readiness.js` 新增 `complexity.within_budget` 检查项,作为发布就绪度的一部分,保持本地 in-process 检查,无新增外部副作用。
+- C-001 低风险拆分:抽出 `tools/control-parameters.js`、`tools/control-summaries.js`、`tools/control-handlers/skill-policy.js`、`tools/control-handlers/events.js`,降低 `tools/control.js` 热点复杂度。
+- C-004 公共面收敛:`lib/json-io.js` 对外导出面从 17 收敛到 15。
+- 新增 characterization tests,覆盖 control parameters、summaries、redaction、handlers 与 release-readiness complexity gate。
+
+## 边界确认
+
+| 项目 | 结论 |
+|---|---|
+| 运行时依赖 | 未新增 |
+| 安全策略 | 未放宽 |
+| `execute` / `sessionPermission` / `*_ACTIONS` / `describeControlSideEffect` | 未改语义边界 |
+| 自动放行 / 网络 / 发布 / 外部副作用能力 | 未新增 |
+| 高风险 control handler 迁移 | deferred,不纳入本版 |
+
+## 验收结果
+
+| 项目 | 结果 |
+|---|---|
+| `npm run check` | 通过 |
+| `npm test` | 665 个测试,665 通过,0 跳过 |
+| `npm run complexity:check` | 通过,0 violation,3 soft warning |
+| `npm run complexity:report` | 通过,生成 `docs/COMPLEXITY_REPORT.md` |
+| `npm run release:check` | Score 100 |
+| `npm run benchmark` | 17/17 通过 |
+| `npm run perf` | 通过,无阈值越界 |
+
+## 本版确认项
+
+1. 复杂度预算成为发布门的一部分,`complexity.within_budget` 可在 `release:check` 中审计。
+2. 复杂度治理只读取本地源码并生成本地报告,不引入网络、凭证、外部写入或发布动作。
+3. `tools/control.js` 的低风险拆分保持参数 schema、摘要输出、脱敏行为和 handler 行为回归稳定。
+4. `docs/COMPLEXITY_DEBT.md` 明确记录 C-001 中高风险/低收益部分为 deferred,避免为了追求 LOC 指标扩大重构风险。
+5. README、CHANGELOG、设计矩阵、package/manifest/package-lock 版本号同步至 `4.3.23`。
+
+## 结论
+
+`v4.3.23` 满足当前 release gate,可作为 v4.x LTS 的复杂度治理正式版准备发布。实际 tag、GitHub Release 与合并动作仍应由维护者在 PR 审查通过后显式执行。
diff --git a/docs/COMPLEXITY_BUDGET.md b/docs/COMPLEXITY_BUDGET.md
new file mode 100644
index 0000000..1b36b41
--- /dev/null
+++ b/docs/COMPLEXITY_BUDGET.md
@@ -0,0 +1,73 @@
+# Complexity Budget (v4.x LTS)
+
+本文件定义 Runtime Self-Learning 在 v4.x LTS 维护期的复杂度预算与治理规则。
+它是 hard limit / soft target 的**权威说明**;可机读的实际数值定义在
+[`lib/complexity.js`](../lib/complexity.js) 的 `COMPLEXITY_HARD_LIMITS` /
+`COMPLEXITY_SOFT_TARGETS` 常量中,两者必须保持一致。
+
+- 状态报告:`npm run complexity:report` → [COMPLEXITY_REPORT.md](COMPLEXITY_REPORT.md)
+- 门禁检查:`npm run complexity:check`(超出 hard limit 时 `exit 1`)
+- 发布集成:`npm run release:check` 含 `complexity.within_budget` 检查项
+
+## 设计原则
+
+v4.x 已进入 LTS 维护期,复杂度预算的目的是**防止膨胀**,不是强迫重构。
+
+1. **冻结优先**:维护期不为新功能放宽预算。新增复杂度需要明确依据。
+2. **可机读、可验证**:预算落在代码常量与脚本里,不只是散文约定。
+3. **headroom 而非现状卡死**:hard limit 高于当前最大值,留出维护余量;
+ soft target 贴近现状,作为优先治理信号。
+4. **零运行时依赖不可动摇**:复杂度治理本身不得引入任何运行时依赖。
+
+## 扫描范围
+
+`lib/`、`scripts/`、`tests/`、`tools/` 下的 `.js` / `.cjs` / `.mjs` 文件。
+度量为轻量启发式(非完整 AST 解析):LOC、import+require 数、export 数、TODO/FIXME 数。
+TODO/FIXME 仅统计约定式标记(`TODO:` / `FIXME:` / `TODO(author):`),
+代码或字符串中单纯出现这两个词(含本治理工具自身)不计入,避免自指误报。
+
+## Hard limits(超出即 `release:check` 失败)
+
+| 维度 | Hard limit |
+|---|---|
+| 单文件 LOC | 900 |
+| 单文件 import + require 数 | 35 |
+| 单文件 export 数 | 25 |
+| TODO/FIXME 总数 | 40 |
+| `lib/` 模块数 | 110 |
+
+## Soft targets(超出记为债务,不阻断发布)
+
+| 维度 | Soft target |
+|---|---|
+| 单文件 LOC | 600 |
+| 单文件 import + require 数 | 20 |
+| 单文件 export 数 | 18 |
+| TODO/FIXME 总数 | 10 |
+| `lib/` 模块数 | 95 |
+
+超出 soft target 但在 hard limit 内的项,会在复杂度报告中列为 soft 警告,并应登记到
+[COMPLEXITY_DEBT.md](COMPLEXITY_DEBT.md)。
+
+## 模块新增规则
+
+1. **禁止新增无依据模块**。任何新增 `lib/` 模块必须有明确依据(治理/安全/兼容),
+ 并在 PR 描述与 CHANGELOG 中说明,否则视为预算违规。
+2. **one-in-one-out**:维护期内每新增一个非必要模块,应同步合并/删除一个等量模块,
+ 使 `lib/` 模块数保持稳定(目标 ≤ soft target 95)。
+3. 治理/基础设施工具(如本复杂度治理自身)可作为一次性有依据的例外,但仍计入 `lib/` 模块数预算。
+
+## 依赖规则
+
+- **不允许新增运行时依赖**,除非经过明确批准并记录在案。
+- 复杂度治理工具仅使用 Node 内置模块(`fs` / `path`),不引入任何第三方包。
+- `package.json` 不得出现 `dependencies` 字段;如需新增请先走批准流程。
+
+## 调整预算的流程
+
+收紧或放宽任一 limit 都是**显式治理动作**:
+
+1. 修改 `lib/complexity.js` 中的常量。
+2. 同步更新本文件对应表格。
+3. 在 CHANGELOG 记录原因。
+4. 确认 `npm run check`、`npm test`、`npm run complexity:check`、`npm run release:check` 全部通过。
diff --git a/docs/COMPLEXITY_DEBT.md b/docs/COMPLEXITY_DEBT.md
new file mode 100644
index 0000000..f0ea788
--- /dev/null
+++ b/docs/COMPLEXITY_DEBT.md
@@ -0,0 +1,154 @@
+# Complexity Debt Ledger
+
+复杂度债务清单。记录已知的复杂度热点,便于维护期有计划地治理,而**不是**要求在 LTS 期立刻重构。
+
+- 预算与规则:[COMPLEXITY_BUDGET.md](COMPLEXITY_BUDGET.md)
+- 实时数据:[COMPLEXITY_REPORT.md](COMPLEXITY_REPORT.md)(`npm run complexity:report` 刷新)
+
+## 记录格式
+
+每条债务包含以下字段:
+
+- **ID**:`C-NNN`,单调递增,永不复用。
+- **Area**:所属区域 / 文件。
+- **Symptom**:复杂度表现。
+- **Evidence**:可验证的度量(来自复杂度报告)。
+- **Risk**:放任不管的风险。
+- **Fix**:建议的低风险治理方向(不强制立刻执行)。
+- **Status**:`open` / `accepted`(接受为 LTS 期常态)/ `in-progress` / `resolved`。
+
+债务超出 hard limit 时必须升级处理(会阻断 `release:check`);仅超出 soft target 时记为
+`open` 或 `accepted`,作为优先治理对象。
+
+---
+
+## C-001 — tools/control.js 控制分发器过大
+
+- **Area**: `tools/control.js`
+- **Symptom**: 单文件 LOC 与 import 数初始为全仓最高,承担过多控制面动作分发职责。
+- **Evidence(LOC 轨迹)**: 初始 712 LOC / 28 imports(均超 soft 600/20,仍在 hard 900/35 内)→ 3a 后 667 / 29 → 3b 后 620 / 30 → 3c(仅测试)620 / 30 → 拆分试点 610 / 31 → events 域迁移 **601 / 32**。**累计 712 → 601 LOC(−111,−15.6%)**;imports 28 → 32(每次只读抽取需在 control.js import 回用,故 imports 不降反小升,已多次预判并验证)。control.js **已不再是全仓最大文件**(现最大为 `tests/pattern-detector.test.js` 648 LOC)。
+- **Risk**: 控制面入口持续累积分支;imports 偏高但稳定处于 hard limit(35)内,距上限仍有余量。
+- **Status**: **closed-low-risk(第十阶段 2026-06-24 阶段性收口)** —— 低风险治理全部完成;高风险/收益递减部分明确 deferred(见下"收口结论")。LTS 维护期不再继续拆分。
+
+### 第三阶段职责地图(2026-06-24,纯审计,未改代码)
+
+| 区域 | 行号 | 职责 | 依赖/外部模块 | 被哪些动作使用 | 适合抽出? |
+|---|---|---|---|---|---|
+| imports | 1–28 | 27 条 import,覆盖 24 个源模块 | 几乎所有 lib 子系统 + doctor/_shared | 全部 | 否(是症状,非原因;只有拆 handler 才会下降) |
+| 共享/skill 助手 | 30–57 | `buildSkill`/`regenerateSkill`(skill 渲染+落盘)、`redactConfig`+`SENSITIVE_CONFIG_KEYS`、`readPluginVersion` | skill-lifecycle、common、fs | 多个 mutation 动作 + status | 部分(见下) |
+| 纯汇总/格式化 | 59–103 | `countByStatus`、`summarizeDecoratedPatterns`、`countWaitingAgentTasks`、`validationNextAction`、`reviewPanelNextActions` | 无(纯函数) | status / review_panel / validate_proposal | **是(最佳候选)** |
+| HANDLERS 动作表 | 105–559 | ~45 个动作处理器(读/改/审批/迁移/报告/外部模型/诊断) | 全部子系统 | execute 路由 | 否(action 执行链,本期排除) |
+| 工具元信息 | 560–562 | `name` / `description` | — | 宿主契约 | 否 |
+| **安全分类(边界)** | 564–635 | `*_ACTIONS` 五个集合 + `describeControlSideEffect` + `sessionPermission` | — | 宿主权限门 | **否(安全边界,明确不抽)** |
+| parameters schema | 637–696 | 输入 JSON Schema(action enum + ~50 属性) | 引用 `Object.keys(HANDLERS)` | 宿主契约 | 是(纯数据,属性表可抽) |
+| execute 调度 | 698–712 | 加载 config/patterns、查表、包装结果 | common | 入口 | 否(核心路由) |
+
+**关键结论**:control.js 的两个头部指标里,**712 LOC 可由纯函数/schema 抽取适度降低,但 28 imports 不会**——import 数由 HANDLERS 需要几乎所有子系统驱动,只有把 handler 按域拆分(属 action 执行链,本期排除)才能下降。因此本期只规划「降 LOC、不动执行链与边界」的安全抽取,import 治理留待后续专项。
+
+- **Fix plan(分级,低风险优先)**:
+ 1. **3a 抽纯汇总/格式化函数 ✅ 已完成(第四阶段 2026-06-24)** → 新建 `tools/control-summaries.js`,迁移 `countByStatus` / `summarizeDecoratedPatterns` / `countWaitingAgentTasks` / `validationNextAction` / `reviewPanelNextActions` 5 个纯函数(函数体逐字不变),control.js import 回用。新增 `tests/control-summaries.test.js`(18 个直接单测)。control.js 712 → 667 LOC;测试 606 → 624(计数已同步 README 徽章/文案、release-readiness 默认与夹具)。四门全绿、0 violations。
+ 2. **3b 抽 parameters 属性表 ✅ 已完成(第五阶段 2026-06-24)** → 新建 `tools/control-parameters.js` 导出 `CONTROL_PARAM_PROPERTIES`(除 `action` 外的全部属性,逐字迁移)。control.js 保留 `action`(含 `enum: Object.keys(HANDLERS)`)并以 `properties: { action, ...CONTROL_PARAM_PROPERTIES }` 展开,键顺序不变、schema 等价。新增 `tests/control-parameters.test.js`(7 个单测,校验字段/顺序/required/enum 等价)。control.js 667 → 620 LOC;测试 624 → 631(计数已同步)。**注**:原 schema 不含 `additionalProperties`,为保持行为未新增该字段(不改变输入校验契约)。四门全绿、0 violations。
+ 3. **3c 评估型处理 ✅ 已完成(第六阶段 2026-06-24,仅测试+文档,未改生产代码)**:
+ - **`redactConfig`**:确认与 `lib/audit-bundle.js` 同名函数**语义不同(见下表)**,**保留分离、不合并**。新增 `tests/control-redaction.test.js`(7 个特征测试),分别经公共入口 `execute({action:"status"})` 与 `buildAuditBundle({config})` 锁定两者行为,未导出/未移动任何涉密私有函数。
+ - **`readPluginVersion`**:纯只读、显式 `pluginDir` 入参、无 control 上下文依赖 → 原则上可抽;但抽出会给本就 import 偏多的 `control.js` **再加一条 import**(仅省 ~2 LOC),得不偿失,**保持原地**,留待 handler 按域拆分专项时随 import 重组一并处理。
+ 4. **后续专项(非本期)**:HANDLERS 按域拆子模块(proposals / reviews / agent-tasks / transfers / reports / skill-promotion),每组自带 import——这是唯一能同时降 LOC 和 imports 的路径,但属 action 执行链,须先补 characterization 测试再分组,单独评审。
+ - **回归网准备 ✅(第七阶段 2026-06-24)**:新增 `tests/control-handlers.characterization.test.js`(27 个用例),经 `execute()` 锁定拆分前各 handler 域的当前行为:status/doctor/list、proposals/reviews 查询、events、agent-tasks、transfers、skill-promotion/policy-profiles、set_config(成功回显 + 校验拒绝)、run_model_advisor(disabled / 无 key,均不触网)、安全拒绝路径(conservative 挡 apply_proposal、未知 review、trust_project_scripts 无脚本拒绝、unknown action)。未改任何生产代码。
+ - **拆分试点 ✅(第八阶段 2026-06-24)**:迁移**一个低风险只读域**到 `tools/control-handlers/skill-policy.js`(导出 `skillPolicyHandlers`:`list_skill_candidates` / `list_active_skills` / `list_policy_profiles`,函数体逐字不变),control.js 以 `...skillPolicyHandlers` 挂回 HANDLERS 汇总表。子模块仅实现 handler 体,**不持有权限判定**;`execute` / `*_ACTIONS` / `describeControlSideEffect` / `sessionPermission` 全部留在 control.js 未改。control.js 620 → 610 LOC;imports 30 → 31(此只读域的 `loadSkillCandidates`/`loadActiveSkills` 仍被 `status` 使用,故不随迁移移出——**印证审计结论:只有“该域 import 变为子模块独占”时 control.js imports 才会下降**;本试点目的是验证迁移机制而非降 imports)。子模块 28 LOC / 2 imports。第七阶段 27 个 characterization 全绿、四门全绿、0 violations。后续可按此模式迁移更多**纯只读且 import 可独占**的域(如 events、transfers 查询)以真正压低 control.js imports;写副作用/安全相关 handler 仍不迁移。
+ - **候选审计 + events 域迁移 ✅(第九阶段 2026-06-24)**:审计 events / transfers 查询 / agent-tasks 查询三域,结论——三者迁移后 control.js imports **均减 0**(各域至少一个函数仍被 `status` 或 `export_audit_bundle` 使用,故 lib import 行保留;LOC 减幅均 <25)。三者仅满足准入条件 3(新模块 imports ≤5 且边界清晰)。选**收益最高、风险最低**的 events 域迁移到 `tools/control-handlers/events.js`(`eventHandlers`:`list_events` / `event_summary` / `verify_event_log`,纯只读、逐字不变)。control.js 610 → 601 LOC;imports 31 → 32(+1 子模块 import;`event-log.js` 行保留供 `readEvents`/`appendEvent`/`replayEventState` 用,仅移除 control.js 中已无引用的 `verifyEventLog` 绑定)。子模块 27 LOC / 1 import。第七阶段 events 域 3 个 characterization 全绿、四门全绿、0 violations。
+
+ **候选审计表(第九阶段)**
+
+ | 域 | handler | 新模块 imports | 该域 import 是否 control.js 独占 | control.js imports 变化 | control.js LOC 变化 | 写副作用/网络/credential/安全 | 准入 |
+ |---|---|---|---|---|---|---|---|
+ | events | list_events / event_summary / verify_event_log | 1 | 否(readEvents/replayEventState 仍用于 export_audit_bundle;appendEvent 全局) | 0(净 +1:新增子模块 import) | −9 | 无(纯读) | 条件 3 ✓ → **已迁移** |
+ | transfers 查询 | list_transfer_candidates / show_transfer_candidate | 1 | 否(listTransferCandidateRecords 用于 status+audit;summarizeTransferCandidate 用于写 handler) | 0 | ~−7 | 无(纯读) | 条件 3 ✓(未选) |
+ | agent-tasks 查询 | list_agent_tasks / show_agent_task | 1 | 否(listAgentTaskStates 用于 status) | 0 | ~−8 | 无(纯读) | 条件 3 ✓(未选) |
+
+ **结论**:纯只读域因依赖与 status/audit-bundle 共享,逐域迁移**无法降 control.js imports**,只能小幅降 LOC。要真正压低 imports,需把 status/export_audit_bundle 对这些函数的使用一并纳入领域聚合(更大重构,超出"单域只读迁移"边界)。后续单域迁移收益递减,建议达成既定 LOC 目标后停止增量拆分,或另立专项处理聚合。
+ - **未覆盖/留待 fixture 的 handler**:含写副作用且需多步 fixture 的 happy-path(`apply_proposal`/`apply_review` 成功、`preview_proposal`/`validate_proposal`、`approve`/`reject`、`regenerate_skill`/`regenerate_memfs`、`rollback`、`run_benchmarks`/`export_audit_bundle`/`generate_audit_dashboard`/`release_readiness`、`run_skill_promotion_loop`、agent-task 审批/恢复、transfer 注册/校验/过期)。其中 proposals/reviews 工作流已由 `tests/review-governance.test.js` 行为覆盖,release/benchmark 由 `tests/control-runtime-package.test.js` 覆盖,credential 路径由 `tests/control-credentials.test.js` 覆盖;其余(agent-task 恢复链、transfer 全生命周期、audit-bundle/dashboard 内容断言)需较重 fixture,**登记为拆分专项启动前需补的回归项**,本期不写脆弱测试。
+- **不建议现在抽**:HANDLERS 执行体、安全分类集合 + `describeControlSideEffect` + `sessionPermission`、`execute` 调度、credential 处理(`run_model_advisor`/`set_config`)、脚本信任(`trust_project_scripts`)、proposal apply(`apply_proposal`/`apply_review`)。
+- **现有测试覆盖**:`control-credentials` / `control-runtime-package` / `runtime-e2e` / `review-governance` / `audit-dashboard` / `disk-sync` / `event-log` 经 `execute()` 行为级覆盖,可作为后续抽取的回归网(但未直接单测上述纯函数)。
+
+#### 两处 `redactConfig` 行为差异(已由 `tests/control-redaction.test.js` 锁定,禁止合并)
+
+| 维度 | `tools/control.js` redactConfig | `lib/audit-bundle.js` redactConfig |
+|---|---|---|
+| 屏蔽哪些 key | 固定白名单:`modelAdvisorApiKey`、`semanticEmbeddingApiKey` | 正则 `/api.?key\|token\|secret\|password/i` 匹配的任意 key |
+| 屏蔽掩码 | `"***"` | `"[redacted]"` |
+| URL/endpoint | **不处理**(原样保留) | 匹配 `/url\|endpoint/i` 的字符串值 → 取 `new URL(...).origin` |
+| token/secret/password | 不单独处理(除非命中固定 2 key) | 处理(正则命中) |
+| 空值(falsy) | 不屏蔽(仅 truthy 才屏蔽) | 不替换(`out[key]=out[key]`,等效保留) |
+| 调用入口 | `status` / `set_config` 的输出 `config` 字段 | `buildAuditBundle(...).config` |
+
+两者服务不同场景(控制面回显 vs 审计包导出),掩码风格与覆盖面均不同,**合并会改变两侧输出语义且涉密,明确不做**。
+
+#### 收口结论(第十阶段 2026-06-24)
+
+C-001 的**低风险治理已全部完成并收口**,进入 LTS 维护期常态;高风险/收益递减部分明确 deferred。
+
+**已完成**
+
+| 子项 | 状态 | 落点 |
+|---|---|---|
+| 3a 纯汇总/格式化抽取 | ✅ | `tools/control-summaries.js` + 单测 |
+| 3b parameters 属性表抽取 | ✅ | `tools/control-parameters.js` + 单测 |
+| 3c redact/readPluginVersion 评估 | ✅ | 行为锁定测试 `tools/control-redaction` + 文档;redactConfig 保留分离、readPluginVersion 留原地 |
+| HANDLERS characterization 回归网 | ✅ | `tests/control-handlers.characterization.test.js`(27 用例) |
+| skill/policy handler 拆分试点 | ✅ | `tools/control-handlers/skill-policy.js` |
+| events handler 域迁移 | ✅ | `tools/control-handlers/events.js` |
+
+**Deferred(LTS 维护期不做)**
+
+- **transfers / agent-tasks 小域迁移**:暂缓。审计已证明这两域迁移后 control.js imports **减 0**、LOC 仅减 ~7/~8,收益递减;每迁一域反而新增一条子模块 import。与已迁移的 events 相比无额外结构收益,不值得继续制造改动面。
+- **import 聚合专项**:deferred。control.js imports 偏高的根因是 `status` / `export_audit_bundle` 这两个"聚合型" handler 跨域引用了几乎所有子系统的函数,使各只读域的依赖无法被子模块独占。要真正压低 imports,必须重组 status/audit-bundle 的共享依赖(聚合层重构),**风险高于当前 LTS 维护目标**,且 imports 当前 32 仍稳处 hard limit(35)内、距上限有余量——不构成发布阻断。故延后,待有明确必要时另立专项评审。
+
+**当前结论**
+
+- control.js 已从 **712 → 601 LOC(−15.6%)**,退出"全仓最大文件"。
+- imports 仍偏高(32),但在 hard limit(35)内,非发布阻断项。
+- 低风险部分完成,高风险/低收益部分延后;C-001 以 `closed-low-risk` 收口,不再继续拆分。
+
+## C-002 — 大型 test 文件
+
+- **Area**: `tests/pattern-detector.test.js` 等
+- **Symptom**: 个别测试文件体量大,单文件覆盖过多场景,定位与维护成本上升。
+- **Evidence**: `tests/pattern-detector.test.js` 648 LOC(> soft 600);`tests/common.test.js` 530;`tests/observer.test.js` 456。
+- **Risk**: 测试文件膨胀降低可读性,但不影响运行时安全;优先级低于 lib/tools。
+- **Fix**: 按行为分组拆分为更小的 `*.test.js`;纯测试改动,零运行时风险。
+- **Status**: accepted
+
+## C-003 — 核心运行时模块体量偏大
+
+- **Area**: `lib/observer.js`、`lib/action-registry.js`
+- **Symptom**: 核心模块接近 soft target,属架构中心节点,改动半径大。
+- **Evidence**: `lib/observer.js` 496 LOC、`lib/action-registry.js` 476 LOC(soft 600 以内但偏高)。
+- **Risk**: 属 v4.x 核心架构,LTS 期**禁止大改**;强行拆分风险高于收益。
+- **Fix**: 维护期仅监控,不主动拆分;若未来逼近 soft/hard 再评估抽取纯函数辅助模块。
+- **Status**: accepted
+
+## C-004 — 基础设施 export 面偏宽
+
+- **Area**: `lib/helpers.js`、`lib/json-io.js`
+- **Symptom**: grab-bag 式工具模块导出项多,接近单文件 export soft target。
+- **Evidence**: 初始 `lib/helpers.js` 与 `lib/json-io.js` 各 17 exports(soft target 18,逼近)。
+- **Risk**: 工具模块持续吸附新导出,越过 soft target 后成为隐性耦合中心。
+- **Fix**: 新增工具函数前先评估归属,避免无依据堆积到 `helpers.js`;必要时按主题拆分。
+- **Status**: in-progress
+
+### 第二阶段处置(2026-06-24)
+
+逐个统计了两模块每个 export 的引用位置后,只做了零行为变更、零签名变更的整理:
+
+- **`lib/json-io.js` 17 → 15**:`hanakoPreferencesPath`、`normalizeLogSessionRow` 两个 export
+ **零外部消费者**(仅被本模块内部调用,且经 `common.js` facade 无谓透传,未列入冻结 API),
+ 降为模块私有函数并从 `common.js` 重导出列表移除。沿用 v4.3.2 审计「对仅内部使用的函数
+ 去掉多余 export」的既有实践。
+- **`lib/helpers.js` 保持 17**:全部 17 个 export 均有 ≥1 个真实外部消费者,按
+ 「不删除仍被引用的 export」一律保留。其中 `sessionTargetDisplay` / `toolCategory` /
+ `sanitizeAdvice` / `isUsageFailure` 为单一消费者函数,理论上可内聚到调用方,但:
+ ① 内聚会拆散 session 三件套等内聚分组、反降可读性;② 指标已在 soft target(18)以内。
+ 权衡后**维护期不做搬迁**,仅在此登记为低优先候选。
+
+风险已降低:移除了 2 个对外暴露却无人使用的 export,缩小了 facade 公共面;`helpers.js`
+单一消费者函数留待未来专门重构再评估。验证:`check` / `test`(606) / `complexity:check` /
+`release:check`(100) 全绿。
diff --git a/docs/COMPLEXITY_REPORT.md b/docs/COMPLEXITY_REPORT.md
new file mode 100644
index 0000000..603e107
--- /dev/null
+++ b/docs/COMPLEXITY_REPORT.md
@@ -0,0 +1,81 @@
+# Complexity Report
+
+> 自动生成,请勿手工编辑。运行 `npm run complexity:report` 刷新。
+> 预算与规则见 [COMPLEXITY_BUDGET.md](COMPLEXITY_BUDGET.md),债务清单见 [COMPLEXITY_DEBT.md](COMPLEXITY_DEBT.md)。
+
+Generated at: 2026-06-24T11:43:28.674Z
+Scan scope: lib, scripts, tests, tools
+Status: within budget
+
+## 摘要
+
+| 指标 | 当前值 | hard limit | soft target |
+|---|---|---|---|
+| 文件数 | 185 | - | - |
+| lib 模块数 | 88 | 110 | 95 |
+| 总 LOC | 26920 | - | - |
+| 总代码 LOC | 22461 | - | - |
+| 单文件最大 LOC | 648 | 900 | 600 |
+| 单文件最大 imports | 32 | 35 | 20 |
+| 单文件最大 exports | 17 | 25 | 18 |
+| TODO/FIXME 总数 | 0 | 40 | 10 |
+| soft 警告数 | 3 | - | 0 |
+| hard 违规数 | 0 | 0 | - |
+
+## Top 10 最大文件 (LOC)
+
+| 文件 | LOC | 代码 LOC |
+|---|---|---|
+| tests/pattern-detector.test.js | 648 | 556 |
+| tools/control.js | 602 | 533 |
+| tests/common.test.js | 530 | 436 |
+| lib/observer.js | 496 | 379 |
+| lib/action-registry.js | 476 | 428 |
+| tests/observer.test.js | 456 | 365 |
+| tools/doctor.js | 430 | 361 |
+| lib/scope-gate.js | 428 | 305 |
+| lib/proposals.js | 426 | 372 |
+| lib/pattern-detector.js | 406 | 274 |
+
+## Top 10 import 最多文件
+
+| 文件 | imports |
+|---|---|
+| tools/control.js | 32 |
+| tests/runtime-e2e.test.js | 15 |
+| tests/action-runtime.test.js | 14 |
+| tests/review-governance.test.js | 14 |
+| tests/agent-resume.test.js | 12 |
+| lib/action-executor.js | 11 |
+| lib/evaluation-runner.js | 11 |
+| tests/audit-dashboard.test.js | 11 |
+| tools/doctor.js | 11 |
+| tools/search.js | 11 |
+
+## Top 10 export 最多文件
+
+| 文件 | exports |
+|---|---|
+| lib/helpers.js | 17 |
+| lib/json-io.js | 15 |
+| lib/proposals.js | 15 |
+| lib/action-types.js | 10 |
+| lib/scoring.js | 10 |
+| lib/credentials.js | 9 |
+| lib/action-registry.js | 8 |
+| lib/action-runtime.js | 8 |
+| lib/model-advisor.js | 8 |
+| lib/review-queue.js | 8 |
+
+## TODO / FIXME 统计
+
+总计 0 处,分布于 0 个文件。
+
+## Soft target 警告
+
+以下项目超出 soft target 但仍在 hard limit 内,是优先治理对象(参见 COMPLEXITY_DEBT.md)。
+
+- tests/pattern-detector.test.js has 648 LOC > soft target 600
+- tools/control.js has 602 LOC > soft target 600
+- tools/control.js has 32 imports > soft target 20
+
diff --git a/docs/DESIGN_GOAL_COMPLETION_MATRIX.md b/docs/DESIGN_GOAL_COMPLETION_MATRIX.md
index 73a9866..0815d6c 100644
--- a/docs/DESIGN_GOAL_COMPLETION_MATRIX.md
+++ b/docs/DESIGN_GOAL_COMPLETION_MATRIX.md
@@ -1,6 +1,6 @@
# 设计目标完成矩阵
-当前版本:`v4.3.22`
+当前版本:`v4.3.23`
## 总体状态
@@ -18,7 +18,7 @@
| 项目 | 预期 |
|---|---|
| `npm run check` | 通过 |
-| `npm test` | 570 个测试,565 通过,5 跳过 |
+| `npm test` | 665 个测试,665 通过,0 跳过 |
| `npm run benchmark` | 17/17 通过 |
| `npm run perf` | 无阈值越界 |
| `npm run release:check` | Score 100 |
diff --git a/lib/common.js b/lib/common.js
index c1ddc1f..125ed79 100644
--- a/lib/common.js
+++ b/lib/common.js
@@ -14,11 +14,9 @@ export {
countValues,
describeOfficialUtilityModel,
hanakoHome,
- hanakoPreferencesPath,
inspectSessionIdentityCoverage,
learnerDir,
loadLearnerConfig,
- normalizeLogSessionRow,
readHanakoPreferences,
readJson,
readRecentJsonl,
diff --git a/lib/complexity.js b/lib/complexity.js
new file mode 100644
index 0000000..02de751
--- /dev/null
+++ b/lib/complexity.js
@@ -0,0 +1,165 @@
+import fs from "fs";
+import path from "path";
+
+// Complexity governance for v4.x LTS.
+//
+// This module is the single source of truth for the complexity budget. Both the
+// CLI tooling (scripts/complexity-check.js, scripts/complexity-report.js) and the
+// release-readiness gate (lib/release-readiness.js) consume the same pure scan so
+// that "what the report says" and "what the gate enforces" can never drift.
+//
+// Limits are deliberately set with headroom above the current code base so that
+// turning the gate on does not retroactively block an already-shipping LTS line.
+// Tightening a limit is a deliberate governance act; see docs/COMPLEXITY_BUDGET.md.
+
+export const COMPLEXITY_SCAN_DIRS = ["lib", "scripts", "tests", "tools"];
+
+export const COMPLEXITY_HARD_LIMITS = Object.freeze({
+ fileLoc: 900, // per-file total lines
+ fileImports: 35, // per-file import + require count
+ fileExports: 25, // per-file export count
+ totalTodos: 40, // TODO/FIXME across all scanned files
+ libModuleCount: 110, // module count under lib/
+});
+
+export const COMPLEXITY_SOFT_TARGETS = Object.freeze({
+ fileLoc: 600,
+ fileImports: 20,
+ fileExports: 18,
+ totalTodos: 10,
+ libModuleCount: 95,
+});
+
+const JS_EXTENSIONS = new Set([".js", ".cjs", ".mjs"]);
+
+function listJsFiles(root, dir) {
+ const base = path.join(root, dir);
+ let entries;
+ try {
+ entries = fs.readdirSync(base, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ const out = [];
+ for (const entry of entries) {
+ const rel = path.posix.join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...listJsFiles(root, rel));
+ else if (entry.isFile() && JS_EXTENSIONS.has(path.extname(entry.name))) out.push(rel);
+ }
+ return out;
+}
+
+// Lightweight, dependency-free source metrics. Intentionally heuristic: this is a
+// governance signal, not a parser. It must never throw on odd input.
+export function analyzeSource(text) {
+ const lines = String(text).split(/\r?\n/);
+ const loc = lines.length;
+ let codeLoc = 0;
+ let inBlockComment = false;
+ for (const raw of lines) {
+ const line = raw.trim();
+ if (line === "") continue;
+ if (inBlockComment) {
+ if (line.includes("*/")) inBlockComment = false;
+ continue;
+ }
+ if (line.startsWith("//")) continue;
+ if (line.startsWith("/*")) {
+ if (!line.includes("*/")) inBlockComment = true;
+ continue;
+ }
+ codeLoc += 1;
+ }
+ const imports = (text.match(/^\s*import\b/gm) || []).length + (text.match(/\brequire\s*\(/g) || []).length;
+ const exports = (text.match(/^\s*export\b/gm) || []).length;
+ // Count only tag-form markers: the word, an optional (author) group, then a colon.
+ // Merely mentioning the words in code/strings (including this tool) is not flagged.
+ const todos = (text.match(/\b(?:TODO|FIXME)(?:\([^)\n]*\))?:/g) || []).length;
+ return { loc, codeLoc, imports, exports, todos };
+}
+
+function pushViolation(list, entry) {
+ list.push(entry);
+}
+
+export function scanComplexity(projectRoot = process.cwd(), options = {}) {
+ const root = path.resolve(projectRoot);
+ const dirs = options.dirs || COMPLEXITY_SCAN_DIRS;
+ const hardLimits = { ...COMPLEXITY_HARD_LIMITS, ...(options.hardLimits || {}) };
+ const softTargets = { ...COMPLEXITY_SOFT_TARGETS, ...(options.softTargets || {}) };
+
+ const files = [];
+ for (const dir of dirs) {
+ for (const rel of listJsFiles(root, dir)) {
+ let text;
+ try {
+ text = fs.readFileSync(path.join(root, rel), "utf-8");
+ } catch {
+ continue;
+ }
+ files.push({ path: rel, dir, ...analyzeSource(text) });
+ }
+ }
+
+ const sum = (key) => files.reduce((acc, f) => acc + f[key], 0);
+ const max = (key) => files.reduce((acc, f) => Math.max(acc, f[key]), 0);
+ const totals = {
+ fileCount: files.length,
+ libModuleCount: files.filter((f) => f.dir === "lib").length,
+ loc: sum("loc"),
+ codeLoc: sum("codeLoc"),
+ imports: sum("imports"),
+ exports: sum("exports"),
+ todos: sum("todos"),
+ maxLoc: max("loc"),
+ maxImports: max("imports"),
+ maxExports: max("exports"),
+ };
+
+ const violations = [];
+ const softWarnings = [];
+ for (const f of files) {
+ if (f.loc > hardLimits.fileLoc) pushViolation(violations, { kind: "file_loc", path: f.path, value: f.loc, limit: hardLimits.fileLoc, message: `${f.path} has ${f.loc} LOC > hard limit ${hardLimits.fileLoc}` });
+ else if (f.loc > softTargets.fileLoc) softWarnings.push({ kind: "file_loc", path: f.path, value: f.loc, target: softTargets.fileLoc, message: `${f.path} has ${f.loc} LOC > soft target ${softTargets.fileLoc}` });
+ if (f.imports > hardLimits.fileImports) pushViolation(violations, { kind: "file_imports", path: f.path, value: f.imports, limit: hardLimits.fileImports, message: `${f.path} has ${f.imports} imports > hard limit ${hardLimits.fileImports}` });
+ else if (f.imports > softTargets.fileImports) softWarnings.push({ kind: "file_imports", path: f.path, value: f.imports, target: softTargets.fileImports, message: `${f.path} has ${f.imports} imports > soft target ${softTargets.fileImports}` });
+ if (f.exports > hardLimits.fileExports) pushViolation(violations, { kind: "file_exports", path: f.path, value: f.exports, limit: hardLimits.fileExports, message: `${f.path} has ${f.exports} exports > hard limit ${hardLimits.fileExports}` });
+ else if (f.exports > softTargets.fileExports) softWarnings.push({ kind: "file_exports", path: f.path, value: f.exports, target: softTargets.fileExports, message: `${f.path} has ${f.exports} exports > soft target ${softTargets.fileExports}` });
+ }
+ if (totals.todos > hardLimits.totalTodos) pushViolation(violations, { kind: "total_todos", value: totals.todos, limit: hardLimits.totalTodos, message: `total TODO/FIXME ${totals.todos} > hard limit ${hardLimits.totalTodos}` });
+ else if (totals.todos > softTargets.totalTodos) softWarnings.push({ kind: "total_todos", value: totals.todos, target: softTargets.totalTodos, message: `total TODO/FIXME ${totals.todos} > soft target ${softTargets.totalTodos}` });
+ if (totals.libModuleCount > hardLimits.libModuleCount) pushViolation(violations, { kind: "lib_module_count", value: totals.libModuleCount, limit: hardLimits.libModuleCount, message: `lib module count ${totals.libModuleCount} > hard limit ${hardLimits.libModuleCount}` });
+ else if (totals.libModuleCount > softTargets.libModuleCount) softWarnings.push({ kind: "lib_module_count", value: totals.libModuleCount, target: softTargets.libModuleCount, message: `lib module count ${totals.libModuleCount} > soft target ${softTargets.libModuleCount}` });
+
+ return {
+ schemaVersion: 1,
+ generatedAt: new Date().toISOString(),
+ projectRoot: root,
+ dirs,
+ hardLimits,
+ softTargets,
+ files,
+ totals,
+ violations,
+ softWarnings,
+ ok: violations.length === 0,
+ };
+}
+
+// Machine-readable digest for release-readiness and CI artifacts.
+export function summarizeComplexity(scan) {
+ return {
+ schemaVersion: scan.schemaVersion,
+ generatedAt: scan.generatedAt,
+ ok: scan.ok,
+ totals: scan.totals,
+ hardLimits: scan.hardLimits,
+ softTargets: scan.softTargets,
+ violations: scan.violations,
+ softWarningCount: scan.softWarnings.length,
+ };
+}
+
+export function topFilesBy(scan, key, limit = 10) {
+ return [...scan.files].sort((a, b) => b[key] - a[key] || a.path.localeCompare(b.path)).slice(0, limit);
+}
diff --git a/lib/json-io.js b/lib/json-io.js
index 743bc38..ffec507 100644
--- a/lib/json-io.js
+++ b/lib/json-io.js
@@ -10,7 +10,9 @@ export function hanakoHome() {
return process.env.HANA_HOME || path.join(os.homedir(), ".hanako");
}
-export function hanakoPreferencesPath() {
+// Internal-only: resolves the Hanako preferences file. Used by readHanakoPreferences
+// below; not part of the public facade (no external consumers).
+function hanakoPreferencesPath() {
const home = hanakoHome();
const candidates = [
process.env.HANAKO_PREFERENCES_FILE,
@@ -133,7 +135,9 @@ export function readRecentJsonl(file, cutoff, { maxLines = 5000 } = {}) {
return rows;
}
-export function normalizeLogSessionRow(row = {}) {
+// Internal-only: normalizes a raw JSONL log row's session identity. Used by the
+// readers/summarizers in this module; not part of the public facade.
+function normalizeLogSessionRow(row = {}) {
const session = normalizeSessionTarget(row, row.session, row.sessionTarget, row.attribution);
const sessionKey = sessionIdentityKey(session);
return {
diff --git a/lib/release-readiness.js b/lib/release-readiness.js
index 25ab146..f1b06e8 100644
--- a/lib/release-readiness.js
+++ b/lib/release-readiness.js
@@ -1,6 +1,7 @@
import fs from "fs";
import path from "path";
import { loadBenchmarkCorpus } from "./benchmark-corpus.js";
+import { scanComplexity } from "./complexity.js";
export const REQUIRED_LTS_DOCS = [
"docs/ACTION_API.md",
@@ -170,6 +171,23 @@ function checkBenchmarkCorpus(projectRoot, minBenchmarkScenarios) {
}
}
+function checkComplexityBudget(projectRoot, options = {}) {
+ try {
+ const scan = scanComplexity(projectRoot, options.complexity || {});
+ const t = scan.totals;
+ return makeCheck(
+ "complexity.within_budget",
+ scan.ok,
+ scan.ok
+ ? `complexity within budget: ${t.fileCount} files, max ${t.maxLoc} LOC, ${t.todos} TODO/FIXME (${scan.softWarnings.length} soft warning(s))`
+ : `complexity budget exceeded: ${scan.violations.map((v) => v.message).join("; ")}`,
+ { totals: t, violations: scan.violations, softWarningCount: scan.softWarnings.length, dirs: scan.dirs },
+ );
+ } catch (err) {
+ return makeCheck("complexity.within_budget", false, `complexity check failed: ${err.message}`, { error: err.message });
+ }
+}
+
export function buildReleaseReadiness(projectRoot = process.cwd(), options = {}) {
const root = path.resolve(projectRoot);
const packageJsonPath = path.join(root, "package.json");
@@ -231,11 +249,12 @@ export function buildReleaseReadiness(projectRoot = process.cwd(), options = {})
{ baseline: "benchmarks/baseline-v4.0.9.json", thresholds: "benchmarks/thresholds.json" },
),
checkReadmeVersionBadge(root, version),
- checkReadmeTestBadge(root, options.expectedTestCount ?? 606),
+ checkReadmeTestBadge(root, options.expectedTestCount ?? 665),
checkReadmeCloneBranch(root, version),
checkManifestVersion(root, version),
checkApiFreezeVersion(root, version),
- checkReadmeTestCount(root, options.expectedTestCount ?? 606),
+ checkReadmeTestCount(root, options.expectedTestCount ?? 665),
+ checkComplexityBudget(root, options),
];
const failed = checks.filter((check) => !check.ok);
diff --git a/manifest.json b/manifest.json
index 82b28f1..9f6755a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,7 +2,7 @@
"manifestVersion": 1,
"id": "hanako-runtime-learner",
"name": "Runtime Self-Learning",
- "version": "4.3.22",
+ "version": "4.3.23",
"description": "运行时自学习引擎:观察本地交互,归纳重复工作流、偏好与错误,并生成保守的经验提示。",
"author": "Sun",
"trust": "full-access",
diff --git a/package-lock.json b/package-lock.json
index 0bffa57..3b17763 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "hanako-runtime-learner",
- "version": "4.3.22",
+ "version": "4.3.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hanako-runtime-learner",
- "version": "4.3.22",
+ "version": "4.3.23",
"license": "MIT"
}
}
diff --git a/package.json b/package.json
index 8f5ab06..d3b5d78 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hanako-runtime-learner",
- "version": "4.3.22",
+ "version": "4.3.23",
"description": "Runtime self-learning engine for Hanako: observe local interaction patterns, learn repeated workflows and errors, inject conservative skill hints.",
"author": "Sun",
"license": "MIT",
@@ -25,6 +25,8 @@
"test": "node scripts/run.js test",
"benchmark": "node scripts/run-benchmarks.js",
"perf": "node scripts/perf-bench.js",
+ "complexity:check": "node scripts/complexity-check.js",
+ "complexity:report": "node scripts/complexity-report.js",
"release:check": "node scripts/release-readiness.js"
}
}
diff --git a/scripts/complexity-check.js b/scripts/complexity-check.js
new file mode 100644
index 0000000..bc9b123
--- /dev/null
+++ b/scripts/complexity-check.js
@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+import fs from "fs";
+import path from "path";
+import { scanComplexity, summarizeComplexity } from "../lib/complexity.js";
+
+function parseArgs(argv) {
+ const args = { projectRoot: process.cwd(), json: false, out: null };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === "--project-root") args.projectRoot = argv[++i];
+ else if (arg === "--json") args.json = true;
+ else if (arg === "--out") args.out = argv[++i];
+ else if (arg === "--help") args.help = true;
+ else throw new Error(`unknown argument: ${arg}`);
+ }
+ return args;
+}
+
+function printHelp() {
+ console.log(
+ [
+ "Usage: node scripts/complexity-check.js [options]",
+ "",
+ "Scans lib/, scripts/, tests/, tools/ and enforces the v4.x complexity budget.",
+ "Exits 1 when any hard limit is exceeded; 0 otherwise.",
+ "",
+ "Options:",
+ " --project-root Project root. Default: cwd",
+ " --json Print the machine-readable summary as JSON",
+ " --out Also write the JSON summary to ",
+ ].join("\n"),
+ );
+}
+
+function formatHuman(scan) {
+ const t = scan.totals;
+ const lines = [];
+ lines.push("Complexity check");
+ lines.push(` scope: ${scan.dirs.join(", ")}`);
+ lines.push(` files: ${t.fileCount} (lib modules: ${t.libModuleCount})`);
+ lines.push(` total LOC: ${t.loc} (code LOC: ${t.codeLoc})`);
+ lines.push(` max file LOC: ${t.maxLoc} / hard ${scan.hardLimits.fileLoc} / soft ${scan.softTargets.fileLoc}`);
+ lines.push(` max imports: ${t.maxImports} / hard ${scan.hardLimits.fileImports} / soft ${scan.softTargets.fileImports}`);
+ lines.push(` max exports: ${t.maxExports} / hard ${scan.hardLimits.fileExports} / soft ${scan.softTargets.fileExports}`);
+ lines.push(` TODO/FIXME markers: ${t.todos} / hard ${scan.hardLimits.totalTodos} / soft ${scan.softTargets.totalTodos}`);
+ lines.push(` soft warnings: ${scan.softWarnings.length}`);
+ if (scan.violations.length > 0) {
+ lines.push(" VIOLATIONS:");
+ for (const v of scan.violations) lines.push(` - ${v.message}`);
+ }
+ lines.push(` status: ${scan.ok ? "OK" : "FAILED"}`);
+ return lines.join("\n");
+}
+
+try {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.help) {
+ printHelp();
+ process.exit(0);
+ }
+ const scan = scanComplexity(args.projectRoot);
+ const summary = summarizeComplexity(scan);
+ if (args.out) {
+ fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
+ fs.writeFileSync(path.resolve(args.out), `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
+ }
+ if (args.json) console.log(JSON.stringify(summary, null, 2));
+ else console.log(formatHuman(scan));
+ process.exit(scan.ok ? 0 : 1);
+} catch (err) {
+ console.error(err.stack || err.message);
+ process.exit(2);
+}
diff --git a/scripts/complexity-report.js b/scripts/complexity-report.js
new file mode 100644
index 0000000..b6f21f1
--- /dev/null
+++ b/scripts/complexity-report.js
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+import fs from "fs";
+import path from "path";
+import { scanComplexity, topFilesBy } from "../lib/complexity.js";
+
+function parseArgs(argv) {
+ const args = { projectRoot: process.cwd(), out: null };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ if (arg === "--project-root") args.projectRoot = argv[++i];
+ else if (arg === "--out") args.out = argv[++i];
+ else if (arg === "--help") args.help = true;
+ else throw new Error(`unknown argument: ${arg}`);
+ }
+ return args;
+}
+
+function printHelp() {
+ console.log(
+ [
+ "Usage: node scripts/complexity-report.js [options]",
+ "",
+ "Generates docs/COMPLEXITY_REPORT.md from a fresh complexity scan.",
+ "Read-only with respect to business logic; only writes the report file.",
+ "",
+ "Options:",
+ " --project-root Project root. Default: cwd",
+ " --out Output path. Default: docs/COMPLEXITY_REPORT.md",
+ ].join("\n"),
+ );
+}
+
+function table(header, rows) {
+ const lines = [];
+ lines.push(`| ${header.join(" | ")} |`);
+ lines.push(`|${header.map(() => "---").join("|")}|`);
+ for (const row of rows) lines.push(`| ${row.join(" | ")} |`);
+ return lines.join("\n");
+}
+
+function buildReport(scan) {
+ const t = scan.totals;
+ const lines = [];
+ lines.push("# Complexity Report");
+ lines.push("");
+ lines.push("> 自动生成,请勿手工编辑。运行 `npm run complexity:report` 刷新。");
+ lines.push("> 预算与规则见 [COMPLEXITY_BUDGET.md](COMPLEXITY_BUDGET.md),债务清单见 [COMPLEXITY_DEBT.md](COMPLEXITY_DEBT.md)。");
+ lines.push("");
+ lines.push(`Generated at: ${scan.generatedAt}`);
+ lines.push(`Scan scope: ${scan.dirs.join(", ")}`);
+ lines.push(`Status: ${scan.ok ? "within budget" : "OVER BUDGET"}`);
+ lines.push("");
+ lines.push("## 摘要");
+ lines.push("");
+ lines.push(
+ table(
+ ["指标", "当前值", "hard limit", "soft target"],
+ [
+ ["文件数", String(t.fileCount), "-", "-"],
+ ["lib 模块数", String(t.libModuleCount), String(scan.hardLimits.libModuleCount), String(scan.softTargets.libModuleCount)],
+ ["总 LOC", String(t.loc), "-", "-"],
+ ["总代码 LOC", String(t.codeLoc), "-", "-"],
+ ["单文件最大 LOC", String(t.maxLoc), String(scan.hardLimits.fileLoc), String(scan.softTargets.fileLoc)],
+ ["单文件最大 imports", String(t.maxImports), String(scan.hardLimits.fileImports), String(scan.softTargets.fileImports)],
+ ["单文件最大 exports", String(t.maxExports), String(scan.hardLimits.fileExports), String(scan.softTargets.fileExports)],
+ ["TODO/FIXME 总数", String(t.todos), String(scan.hardLimits.totalTodos), String(scan.softTargets.totalTodos)],
+ ["soft 警告数", String(scan.softWarnings.length), "-", "0"],
+ ["hard 违规数", String(scan.violations.length), "0", "-"],
+ ],
+ ),
+ );
+ lines.push("");
+
+ lines.push("## Top 10 最大文件 (LOC)");
+ lines.push("");
+ lines.push(table(["文件", "LOC", "代码 LOC"], topFilesBy(scan, "loc").map((f) => [f.path, String(f.loc), String(f.codeLoc)])));
+ lines.push("");
+
+ lines.push("## Top 10 import 最多文件");
+ lines.push("");
+ lines.push(table(["文件", "imports"], topFilesBy(scan, "imports").map((f) => [f.path, String(f.imports)])));
+ lines.push("");
+
+ lines.push("## Top 10 export 最多文件");
+ lines.push("");
+ lines.push(table(["文件", "exports"], topFilesBy(scan, "exports").map((f) => [f.path, String(f.exports)])));
+ lines.push("");
+
+ lines.push("## TODO / FIXME 统计");
+ lines.push("");
+ const todoFiles = scan.files.filter((f) => f.todos > 0).sort((a, b) => b.todos - a.todos);
+ lines.push(`总计 ${t.todos} 处,分布于 ${todoFiles.length} 个文件。`);
+ lines.push("");
+ if (todoFiles.length > 0) {
+ lines.push(table(["文件", "TODO/FIXME"], todoFiles.map((f) => [f.path, String(f.todos)])));
+ lines.push("");
+ }
+
+ if (scan.violations.length > 0) {
+ lines.push("## Hard limit 违规");
+ lines.push("");
+ for (const v of scan.violations) lines.push(`- ${v.message}`);
+ lines.push("");
+ }
+
+ if (scan.softWarnings.length > 0) {
+ lines.push("## Soft target 警告");
+ lines.push("");
+ lines.push("以下项目超出 soft target 但仍在 hard limit 内,是优先治理对象(参见 COMPLEXITY_DEBT.md)。");
+ lines.push("");
+ for (const w of scan.softWarnings.sort((a, b) => b.value - a.value)) lines.push(`- ${w.message}`);
+ lines.push("");
+ }
+
+ return `${lines.join("\n")}\n`;
+}
+
+try {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.help) {
+ printHelp();
+ process.exit(0);
+ }
+ const scan = scanComplexity(args.projectRoot);
+ const outPath = path.resolve(args.out || path.join(args.projectRoot, "docs", "COMPLEXITY_REPORT.md"));
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
+ fs.writeFileSync(outPath, buildReport(scan), "utf-8");
+ console.log(`Complexity report written to: ${outPath}`);
+ console.log(`Status: ${scan.ok ? "within budget" : "OVER BUDGET"} (${scan.violations.length} violation(s), ${scan.softWarnings.length} soft warning(s))`);
+} catch (err) {
+ console.error(err.stack || err.message);
+ process.exit(2);
+}
diff --git a/tests/control-handlers.characterization.test.js b/tests/control-handlers.characterization.test.js
new file mode 100644
index 0000000..22322d3
--- /dev/null
+++ b/tests/control-handlers.characterization.test.js
@@ -0,0 +1,314 @@
+/**
+ * Characterization tests for tools/control.js HANDLERS (C-001 phase 4 / pre-split).
+ *
+ * Purpose: lock the CURRENT observable behavior of control handler domains that
+ * are not already covered elsewhere, so a future "split HANDLERS by domain"
+ * refactor has a regression net. These assert behavior as-is; they do not change it.
+ *
+ * Already covered elsewhere (NOT duplicated here):
+ * - proposals/reviews happy + reject paths: tests/review-governance.test.js
+ * - run_model_advisor mock-key decrypt path + set_config non-persist:
+ * tests/control-credentials.test.js
+ * - redactConfig behaviors: tests/control-redaction.test.js
+ * - release_readiness / runtime-package resolution: tests/control-runtime-package.test.js
+ *
+ * No network is exercised; no real user directory is written (HANA_HOME → tmp).
+ */
+import { describe, it, before, after } from "node:test";
+import assert from "node:assert/strict";
+import fs from "fs";
+import os from "os";
+import path from "path";
+import { execute } from "../tools/control.js";
+import { parseToolResult, unwrapToolResult } from "./_test-utils.js";
+
+const savedHanaHome = process.env.HANA_HOME;
+const savedFetch = globalThis.fetch;
+
+before(() => {
+ // Any network attempt during these tests is a failure.
+ globalThis.fetch = async () => { throw new Error("network must not be reached in characterization tests"); };
+});
+
+after(() => {
+ if (savedHanaHome === undefined) delete process.env.HANA_HOME;
+ else process.env.HANA_HOME = savedHanaHome;
+ globalThis.fetch = savedFetch;
+});
+
+/**
+ * Run `fn(ctx)` against a fresh, isolated learner home. Optionally seeds
+ * runtime-config.json with `config`. Restores HANA_HOME afterward.
+ */
+async function withLearner({ config } = {}, fn) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), `control-char-${process.pid}-${Date.now()}-`));
+ process.env.HANA_HOME = home;
+ const learner = path.join(home, "self-learning");
+ fs.mkdirSync(learner, { recursive: true });
+ fs.writeFileSync(path.join(learner, "patterns.json"), "[]", "utf-8");
+ if (config) fs.writeFileSync(path.join(learner, "runtime-config.json"), JSON.stringify(config), "utf-8");
+ try {
+ return await fn({ pluginDir: home, learner });
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
+}
+
+const run = (action, extra = {}, ctx) => execute({ action, ...extra }, ctx);
+const json = async (action, extra, ctx) => parseToolResult(await run(action, extra, ctx));
+
+// ── Domain 1: status / doctor read-only output ──
+
+describe("status / doctor read-only output", () => {
+ it("status reports a zeroed snapshot with redacted config on a fresh store", async () => {
+ await withLearner({}, async (ctx) => {
+ const out = await json("status", {}, ctx);
+ assert.equal(out.patterns, 0);
+ assert.equal(out.injectable, 0);
+ assert.deepEqual(out.proposals, { pending: 0, applied: 0, rejected: 0, dir: out.proposals.dir });
+ assert.deepEqual(out.reviews, { queued: 0, blocked: 0, approved: 0 });
+ assert.deepEqual(out.agentTasks, { total: 0, waiting: 0 });
+ assert.deepEqual(out.transferCandidates, { total: 0, pending: 0, validated: 0, failed: 0 });
+ assert.deepEqual(out.skillPromotion, { candidates: 0, active: 0 });
+ assert.equal(out.config.governanceProfile, "balanced");
+ assert.ok(out.dataDir.endsWith(path.join("self-learning")) || out.dataDir.includes("self-learning"));
+ });
+ });
+
+ it("doctor format=json returns a structured report (status good on empty store)", async () => {
+ await withLearner({}, async (ctx) => {
+ const report = await json("doctor", { format: "json" }, ctx);
+ assert.equal(report.status, "good");
+ assert.equal(report.score, 100);
+ assert.deepEqual(report.issues, []);
+ assert.equal(typeof report.generatedAt, "string");
+ });
+ });
+
+ it("doctor default format returns a non-JSON text report", async () => {
+ await withLearner({}, async (ctx) => {
+ const text = unwrapToolResult(await run("doctor", {}, ctx));
+ assert.equal(typeof text, "string");
+ assert.ok(text.length > 0);
+ assert.ok(!text.trimStart().startsWith("{"), "default doctor output should be text, not JSON");
+ });
+ });
+
+ it("list returns an empty array on a fresh store", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list", {}, ctx), []);
+ });
+ });
+});
+
+// ── Domain 2: proposals / reviews query output (empty-state shape) ──
+
+describe("proposals / reviews query output", () => {
+ it("list_proposals returns ok + empty list + nextAction", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_proposals", {}, ctx), {
+ ok: true, proposals: [], nextAction: "show_proposal or preview_proposal",
+ });
+ });
+ });
+
+ it("list_reviews returns ok + empty list + nextAction", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_reviews", {}, ctx), {
+ ok: true, reviews: [], nextAction: "show_proposal then preview_proposal",
+ });
+ });
+ });
+
+ it("review_panel recommends no action on an empty store", async () => {
+ await withLearner({}, async (ctx) => {
+ const panel = await json("review_panel", {}, ctx);
+ assert.deepEqual(panel.recommendedNextActions, ["no review action needed"]);
+ });
+ });
+
+ it("show_proposal without an id is rejected", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("show_proposal", {}, ctx), /proposalId is required/);
+ });
+ });
+});
+
+// ── Domain 3: events / event log ──
+
+describe("events / event log output", () => {
+ it("list_events returns an empty list", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_events", {}, ctx), { ok: true, events: [] });
+ });
+ });
+
+ it("event_summary returns an empty replay summary", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("event_summary", {}, ctx), {
+ ok: true, summary: { count: 0, byType: {}, entities: {} },
+ });
+ });
+ });
+
+ it("verify_event_log reports an intact (empty) chain", async () => {
+ await withLearner({}, async (ctx) => {
+ const out = await json("verify_event_log", {}, ctx);
+ assert.equal(out.ok, true);
+ assert.equal(out.events, 0);
+ assert.equal(out.brokenAt, null);
+ });
+ });
+});
+
+// ── Domain 4: agent-tasks query + waiting state ──
+
+describe("agent-tasks query output", () => {
+ it("list_agent_tasks returns ok + empty tasks", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_agent_tasks", {}, ctx), {
+ ok: true, tasks: [], nextAction: "show_agent_task",
+ });
+ });
+ });
+
+ it("show_agent_task without a taskId is rejected", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("show_agent_task", {}, ctx), /taskId is required/);
+ });
+ });
+});
+
+// ── Domain 5: transfers query ──
+
+describe("transfers query output", () => {
+ it("list_transfer_candidates returns ok + empty candidates", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_transfer_candidates", {}, ctx), {
+ ok: true, candidates: [], nextAction: "show_transfer_candidate or record_transfer_validation",
+ });
+ });
+ });
+
+ it("show_transfer_candidate without a candidateId is rejected", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("show_transfer_candidate", {}, ctx), /candidateId is required/);
+ });
+ });
+});
+
+// ── Domain 6: skill promotion queries + policy profiles ──
+
+describe("skill promotion / policy profile output", () => {
+ it("list_skill_candidates returns ok + empty candidates", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_skill_candidates", {}, ctx), {
+ ok: true, candidates: [], nextAction: "run_skill_promotion_loop or list_active_skills",
+ });
+ });
+ });
+
+ it("list_active_skills returns ok + empty skills", async () => {
+ await withLearner({}, async (ctx) => {
+ assert.deepEqual(await json("list_active_skills", {}, ctx), {
+ ok: true, skills: [], nextAction: "export_audit_bundle",
+ });
+ });
+ });
+
+ it("list_policy_profiles lists the three profiles with balanced current by default", async () => {
+ await withLearner({}, async (ctx) => {
+ const out = await json("list_policy_profiles", {}, ctx);
+ assert.equal(out.ok, true);
+ assert.equal(out.current, "balanced");
+ assert.deepEqual(out.profiles.map((p) => p.name), ["conservative", "balanced", "autonomous"]);
+ });
+ });
+});
+
+// ── Domain 7: set_config sensitive output / config boundary ──
+
+describe("set_config output and validation boundary", () => {
+ it("applies a valid numeric config and echoes the persisted value", async () => {
+ await withLearner({}, async (ctx) => {
+ const out = await json("set_config", { minInjectCount: 5 }, ctx);
+ assert.equal(out.ok, true);
+ assert.equal(out.config.minInjectCount, 5);
+ assert.ok(out.validation);
+ // persisted: a follow-up status reflects it
+ const status = await json("status", {}, ctx);
+ assert.equal(status.config.minInjectCount, 5);
+ });
+ });
+
+ it("rejects an invalid config value without partial application", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("set_config", { minInjectScore: "bad" }, ctx), /config validation failed/);
+ });
+ });
+});
+
+// ── Domain 8: run_model_advisor disabled / no-key (no network) ──
+
+describe("run_model_advisor without network", () => {
+ it("short-circuits when the advisor is disabled (default config)", async () => {
+ await withLearner({}, async (ctx) => {
+ const out = await json("run_model_advisor", {}, ctx);
+ assert.equal(out.ok, false);
+ assert.equal(out.error, "disabled");
+ });
+ });
+
+ it("reports a missing key when enabled for a private endpoint with no key", async () => {
+ await withLearner({ config: { modelAdvisorEnabled: true, modelAdvisorSource: "private", modelAdvisorBaseUrl: "https://x.example.com", modelAdvisorModel: "m" } }, async (ctx) => {
+ const out = await json("run_model_advisor", {}, ctx);
+ assert.equal(out.ok, false);
+ assert.match(out.error, /api key missing/i);
+ });
+ });
+});
+
+// ── Domain 9: safe-rejection / security boundary paths ──
+
+describe("safe-rejection and security boundary paths", () => {
+ it("apply_proposal is blocked under the conservative governance profile", async () => {
+ await withLearner({ config: { governanceProfile: "conservative" } }, async (ctx) => {
+ await assert.rejects(
+ () => run("apply_proposal", { proposalId: "p-does-not-exist" }, ctx),
+ /conservative profile requires review-first flow/,
+ );
+ });
+ });
+
+ it("apply_proposal without an id is rejected", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("apply_proposal", {}, ctx), /proposalId is required/);
+ });
+ });
+
+ it("approve_review / apply_review reject unknown reviews", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("approve_review", {}, ctx), /id or proposalId is required/);
+ await assert.rejects(() => run("approve_review", { id: "review:nope" }, ctx), /review not found/);
+ await assert.rejects(() => run("apply_review", { id: "review:nope" }, ctx), /review not found/);
+ });
+ });
+
+ it("trust_project_scripts refuses a workspace whose package.json has no scripts", async () => {
+ await withLearner({}, async (ctx) => {
+ const ws = fs.mkdtempSync(path.join(os.tmpdir(), "control-char-ws-"));
+ fs.writeFileSync(path.join(ws, "package.json"), JSON.stringify({ name: "x" }), "utf-8");
+ try {
+ await assert.rejects(() => run("trust_project_scripts", { workspaceRoot: ws }, ctx), /no scripts found in package\.json/);
+ } finally {
+ fs.rmSync(ws, { recursive: true, force: true });
+ }
+ });
+ });
+
+ it("an unknown action is rejected by execute", async () => {
+ await withLearner({}, async (ctx) => {
+ await assert.rejects(() => run("__no_such_action__", {}, ctx), /unknown action/);
+ });
+ });
+});
diff --git a/tests/control-parameters.test.js b/tests/control-parameters.test.js
new file mode 100644
index 0000000..6ce32f4
--- /dev/null
+++ b/tests/control-parameters.test.js
@@ -0,0 +1,62 @@
+/**
+ * Unit tests for tools/control-parameters.js — the parameter schema property
+ * table extracted from tools/control.js (C-001 phase 3b).
+ * Verifies the extracted properties and that control.js still composes an
+ * equivalent schema (same fields, same required, action enum intact).
+ * Run: node --test tests/control-parameters.test.js
+ */
+
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { CONTROL_PARAM_PROPERTIES } from "../tools/control-parameters.js";
+import { parameters } from "../tools/control.js";
+
+describe("CONTROL_PARAM_PROPERTIES", () => {
+ it("contains the key non-action fields with unchanged shapes", () => {
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.id, { type: "string", description: "Pattern id for approve/reject." });
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.proposalId, { type: "string", description: "Proposal id for show/apply/reject proposal actions." });
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.validationStatus, { type: "string", enum: ["passed", "failed"], description: "Target validation status for record_transfer_validation." });
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.evidence, { type: "array", items: { type: "string" }, description: "Validation evidence lines for transfer registry actions." });
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.format, { type: "string", enum: ["text", "json"], description: "Output format for the doctor action. Default text." });
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.governanceProfile, { type: "string", enum: ["conservative", "balanced", "autonomous"], description: "Governance policy profile to apply." });
+ assert.deepEqual(CONTROL_PARAM_PROPERTIES.semanticCacheMaxEntries, { type: "number" });
+ });
+
+ it("does NOT contain the action property (action stays in control.js)", () => {
+ assert.equal("action" in CONTROL_PARAM_PROPERTIES, false);
+ });
+});
+
+describe("control.js parameters schema after extraction", () => {
+ it("keeps the top-level shape: object + required action", () => {
+ assert.equal(parameters.type, "object");
+ assert.deepEqual(parameters.required, ["action"]);
+ });
+
+ it("preserves the pre-extraction behavior of not declaring additionalProperties", () => {
+ // The original schema had no `additionalProperties` key; adding one would be
+ // a contract change, so extraction must leave it absent.
+ assert.equal("additionalProperties" in parameters, false);
+ });
+
+ it("keeps the action property with its HANDLERS-derived enum", () => {
+ assert.equal(parameters.properties.action.type, "string");
+ assert.equal(parameters.properties.action.description, "Control action to run.");
+ assert.ok(Array.isArray(parameters.properties.action.enum));
+ assert.ok(parameters.properties.action.enum.includes("status"));
+ assert.ok(parameters.properties.action.enum.includes("set_config"));
+ assert.ok(parameters.properties.action.enum.length > 20);
+ });
+
+ it("lists action first, then every CONTROL_PARAM_PROPERTIES field in order", () => {
+ const keys = Object.keys(parameters.properties);
+ assert.equal(keys[0], "action");
+ assert.deepEqual(keys.slice(1), Object.keys(CONTROL_PARAM_PROPERTIES));
+ });
+
+ it("references the extracted properties object (every non-action field present and identical)", () => {
+ for (const [key, value] of Object.entries(CONTROL_PARAM_PROPERTIES)) {
+ assert.deepEqual(parameters.properties[key], value, `field ${key} differs`);
+ }
+ });
+});
diff --git a/tests/control-redaction.test.js b/tests/control-redaction.test.js
new file mode 100644
index 0000000..1a594d5
--- /dev/null
+++ b/tests/control-redaction.test.js
@@ -0,0 +1,118 @@
+/**
+ * Characterization tests that LOCK the two distinct config-redaction behaviors
+ * (C-001 phase 3c). They are deliberately separate functions with different
+ * semantics and must NOT be merged:
+ *
+ * - tools/control.js redactConfig (via execute "status"): masks only a fixed
+ * two-key allowlist with "***"; leaves URLs and everything else untouched.
+ * - lib/audit-bundle.js redactConfig (via buildAuditBundle): regex-masks any
+ * api-key/token/secret/password key with "[redacted]", and reduces url/
+ * endpoint values to their origin.
+ *
+ * Both are exercised through their public entry points, so no private function
+ * is exported and no redaction logic is moved.
+ */
+import { describe, it, before, after } from "node:test";
+import assert from "node:assert/strict";
+import fs from "fs";
+import path from "path";
+import os from "os";
+import { buildAuditBundle } from "../lib/audit-bundle.js";
+
+// ── control.js redactConfig (fixed allowlist, "***", URLs untouched) ──
+
+const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), `control-redact-${process.pid}-${Date.now()}`));
+const savedHanaHome = process.env.HANA_HOME;
+process.env.HANA_HOME = tmpHome;
+
+let control;
+const learnerDir = path.join(tmpHome, "self-learning");
+
+before(async () => {
+ fs.mkdirSync(learnerDir, { recursive: true });
+ control = await import("../tools/control.js");
+ fs.writeFileSync(path.join(learnerDir, "patterns.json"), "[]", "utf-8");
+ fs.writeFileSync(path.join(learnerDir, "runtime-config.json"), JSON.stringify({
+ modelAdvisorApiKey: "sk-secret-advisor",
+ semanticEmbeddingApiKey: "sk-secret-embedding",
+ modelAdvisorBaseUrl: "https://api.example.com/v1/private-path",
+ modelAdvisorEnabled: true,
+ }), "utf-8");
+});
+
+after(() => {
+ if (savedHanaHome === undefined) delete process.env.HANA_HOME;
+ else process.env.HANA_HOME = savedHanaHome;
+ fs.rmSync(tmpHome, { recursive: true, force: true });
+});
+
+async function statusConfig() {
+ const raw = await control.execute({ action: "status" }, { pluginDir: tmpHome });
+ const text = raw?.content?.[0]?.text ?? (typeof raw === "string" ? raw : JSON.stringify(raw));
+ return JSON.parse(text).config;
+}
+
+describe("control.js redactConfig (via execute status)", () => {
+ it("masks exactly the two sensitive keys with '***'", async () => {
+ const cfg = await statusConfig();
+ assert.equal(cfg.modelAdvisorApiKey, "***");
+ assert.equal(cfg.semanticEmbeddingApiKey, "***");
+ });
+
+ it("does NOT touch URL/base-url values (key difference vs audit-bundle)", async () => {
+ const cfg = await statusConfig();
+ assert.equal(cfg.modelAdvisorBaseUrl, "https://api.example.com/v1/private-path");
+ });
+
+ it("leaves non-sensitive keys unchanged", async () => {
+ const cfg = await statusConfig();
+ assert.equal(cfg.modelAdvisorEnabled, true);
+ });
+});
+
+// ── audit-bundle.js redactConfig (regex mask "[redacted]", URL→origin) ──
+
+describe("lib/audit-bundle.js redactConfig (via buildAuditBundle)", () => {
+ function redactedConfig(config) {
+ return buildAuditBundle({ config }).config;
+ }
+
+ it("regex-masks api-key / token / secret / password keys with '[redacted]'", () => {
+ const out = redactedConfig({
+ modelAdvisorApiKey: "sk-x",
+ authToken: "tok-y",
+ mySecret: "s",
+ password: "p",
+ });
+ assert.equal(out.modelAdvisorApiKey, "[redacted]");
+ assert.equal(out.authToken, "[redacted]");
+ assert.equal(out.mySecret, "[redacted]");
+ assert.equal(out.password, "[redacted]");
+ });
+
+ it("reduces url/endpoint string values to their origin", () => {
+ const out = redactedConfig({
+ modelAdvisorBaseUrl: "https://api.example.com/v1/private-path?token=abc",
+ someEndpoint: "https://host.example.org:8443/deep/path",
+ });
+ assert.equal(out.modelAdvisorBaseUrl, "https://api.example.com");
+ assert.equal(out.someEndpoint, "https://host.example.org:8443");
+ });
+
+ it("keeps falsy sensitive values and non-matching keys unchanged", () => {
+ const out = redactedConfig({
+ modelAdvisorApiKey: "", // sensitive but falsy → left as-is
+ modelAdvisorEnabled: true, // non-matching → untouched
+ plainNumber: 42,
+ });
+ assert.equal(out.modelAdvisorApiKey, "");
+ assert.equal(out.modelAdvisorEnabled, true);
+ assert.equal(out.plainNumber, 42);
+ });
+
+ it("uses a different mask token than control.js ('[redacted]' not '***')", () => {
+ const out = redactedConfig({ modelAdvisorApiKey: "sk-x" });
+ assert.notEqual(out.modelAdvisorApiKey, "***");
+ assert.equal(out.modelAdvisorApiKey, "[redacted]");
+ });
+});
diff --git a/tests/control-summaries.test.js b/tests/control-summaries.test.js
new file mode 100644
index 0000000..c15649a
--- /dev/null
+++ b/tests/control-summaries.test.js
@@ -0,0 +1,135 @@
+/**
+ * Unit tests for tools/control-summaries.js — pure summary/formatting helpers
+ * extracted from tools/control.js (C-001 phase 3a).
+ * Run: node --test tests/control-summaries.test.js
+ */
+
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import {
+ countByStatus,
+ summarizeDecoratedPatterns,
+ countWaitingAgentTasks,
+ validationNextAction,
+ reviewPanelNextActions,
+} from "../tools/control-summaries.js";
+
+describe("countByStatus", () => {
+ it("returns an empty object for an empty array", () => {
+ assert.deepEqual(countByStatus([]), {});
+ });
+
+ it("returns an empty object when called with no arguments", () => {
+ assert.deepEqual(countByStatus(), {});
+ });
+
+ it("counts mixed statuses", () => {
+ const rows = [
+ { status: "pending" },
+ { status: "applied" },
+ { status: "pending" },
+ { status: "rejected" },
+ ];
+ assert.deepEqual(countByStatus(rows), { pending: 2, applied: 1, rejected: 1 });
+ });
+
+ it("buckets missing/empty status under 'unknown'", () => {
+ const rows = [{ status: "pending" }, {}, { status: "" }, null];
+ assert.deepEqual(countByStatus(rows), { pending: 1, unknown: 3 });
+ });
+
+ it("supports counting by an alternate field", () => {
+ const rows = [{ state: "a" }, { state: "b" }, { state: "a" }];
+ assert.deepEqual(countByStatus(rows, "state"), { a: 2, b: 1 });
+ });
+});
+
+describe("summarizeDecoratedPatterns", () => {
+ it("returns a zeroed summary for an empty array", () => {
+ assert.deepEqual(summarizeDecoratedPatterns([]), {
+ total: 0, injectable: 0, pending: 0, approved: 0, rejected: 0,
+ });
+ });
+
+ it("aggregates totals, injectable flag, and per-status counts", () => {
+ const patterns = [
+ { injectable: true, status: "pending" },
+ { injectable: false, status: "approved" },
+ { injectable: true, status: "rejected" },
+ { injectable: true, status: "pending" },
+ { status: "other" }, // counts toward total only
+ ];
+ assert.deepEqual(summarizeDecoratedPatterns(patterns), {
+ total: 5, injectable: 3, pending: 2, approved: 1, rejected: 1,
+ });
+ });
+});
+
+describe("countWaitingAgentTasks", () => {
+ it("returns 0 for an empty array", () => {
+ assert.equal(countWaitingAgentTasks([]), 0);
+ });
+
+ it("counts only tasks waiting_for_human among a mix of states", () => {
+ const tasks = [
+ { state: "waiting_for_human" },
+ { state: "pending" },
+ { state: "done" },
+ { state: "waiting_for_human" },
+ { state: "running" },
+ ];
+ assert.equal(countWaitingAgentTasks(tasks), 2);
+ });
+
+ it("returns 0 when no task is waiting_for_human", () => {
+ assert.equal(countWaitingAgentTasks([{ state: "pending" }, { state: "done" }]), 0);
+ });
+});
+
+describe("validationNextAction", () => {
+ it("recommends the approve/apply path when validation is ok", () => {
+ assert.equal(validationNextAction({ ok: true }), "approve_review then apply_review");
+ });
+
+ it("recommends fix/reject when validation is not ok", () => {
+ assert.equal(validationNextAction({ ok: false }), "fix proposal or reject_proposal");
+ });
+
+ it("treats missing/undefined validation as not ok", () => {
+ assert.equal(validationNextAction(undefined), "fix proposal or reject_proposal");
+ assert.equal(validationNextAction({}), "fix proposal or reject_proposal");
+ });
+});
+
+describe("reviewPanelNextActions", () => {
+ it("returns the no-op action when there is nothing to do", () => {
+ assert.deepEqual(reviewPanelNextActions(), ["no review action needed"]);
+ assert.deepEqual(reviewPanelNextActions({ counts: {} }), ["no review action needed"]);
+ });
+
+ it("surfaces blocked reviews", () => {
+ const actions = reviewPanelNextActions({ counts: { blockedReviews: 2 } });
+ assert.deepEqual(actions, ["validate blocked reviews, then fix or reject them"]);
+ });
+
+ it("surfaces pending reviews", () => {
+ const actions = reviewPanelNextActions({ counts: { pendingReviews: 1 } });
+ assert.deepEqual(actions, ["preview queued reviews, then approve_review or reject_review"]);
+ });
+
+ it("surfaces pending proposals", () => {
+ const actions = reviewPanelNextActions({ counts: { pendingProposals: 3 } });
+ assert.deepEqual(actions, ["validate_proposal for pending proposals not yet reviewed"]);
+ });
+
+ it("combines all branches in order when all are present", () => {
+ const actions = reviewPanelNextActions({
+ counts: { blockedReviews: 1, pendingReviews: 1, pendingProposals: 1 },
+ });
+ assert.deepEqual(actions, [
+ "validate blocked reviews, then fix or reject them",
+ "preview queued reviews, then approve_review or reject_review",
+ "validate_proposal for pending proposals not yet reviewed",
+ ]);
+ });
+});
diff --git a/tests/release-readiness.test.js b/tests/release-readiness.test.js
index 163443b..f14e57f 100644
--- a/tests/release-readiness.test.js
+++ b/tests/release-readiness.test.js
@@ -10,7 +10,7 @@ function write(filePath, content) {
fs.writeFileSync(filePath, content, "utf-8");
}
-function makeProject({ version = "4.0.18-lts", lockVersion = version, scenarios = 16, omitAcceptance = false, testCount = 606 } = {}) {
+function makeProject({ version = "4.0.18-lts", lockVersion = version, scenarios = 16, omitAcceptance = false, testCount = 665 } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hanako-release-readiness-"));
const baseVersion = version.replace(/-lts$/, "");
write(path.join(root, "package.json"), JSON.stringify({ name: "hanako-runtime-learner", version }, null, 2));
diff --git a/tools/control-handlers/events.js b/tools/control-handlers/events.js
new file mode 100644
index 0000000..9649099
--- /dev/null
+++ b/tools/control-handlers/events.js
@@ -0,0 +1,27 @@
+// Event-log read-only control handlers (C-001 HANDLERS split — events domain).
+//
+// Extracted verbatim from tools/control.js. Pure read handlers: they take
+// (input, p), read the event log under p.learnerDir, and return a JSON string.
+// They mutate nothing and own NO permission/side-effect decisions — control.js
+// keeps the action dispatch, the *_ACTIONS classification sets,
+// describeControlSideEffect and sessionPermission. This module only implements
+// the handler bodies and is spread back into the control HANDLERS table under
+// the same action names.
+
+import { readEvents, replayEventState, verifyEventLog } from "../../lib/event-log.js";
+
+export const eventHandlers = {
+ list_events(input, p) {
+ return JSON.stringify({ ok: true, events: readEvents(p.learnerDir, { limit: input.limit || 50, entityId: input.id || null }) }, null, 2);
+ },
+
+ event_summary(input, p) {
+ const events = readEvents(p.learnerDir, { limit: input.limit || 5000, entityId: input.id || null });
+ return JSON.stringify({ ok: true, summary: replayEventState(events) }, null, 2);
+ },
+
+ verify_event_log(input, p) {
+ const result = verifyEventLog(p.learnerDir);
+ return JSON.stringify({ ...result, nextAction: result.ok ? "export_audit_bundle or continue" : "inspect event_log.jsonl and restore from trusted backup" }, null, 2);
+ },
+};
diff --git a/tools/control-handlers/skill-policy.js b/tools/control-handlers/skill-policy.js
new file mode 100644
index 0000000..0ea1898
--- /dev/null
+++ b/tools/control-handlers/skill-policy.js
@@ -0,0 +1,28 @@
+// Skill-promotion & policy read-only control handlers (C-001 HANDLERS split pilot).
+//
+// Extracted verbatim from tools/control.js. These are pure read handlers: they
+// take (input, p, config), read from p.learnerDir, and return a JSON string.
+// They own NO permission/side-effect decisions — control.js keeps the action
+// dispatch, the *_ACTIONS classification sets, describeControlSideEffect and
+// sessionPermission. This module only implements the handler bodies and is
+// spread back into the control HANDLERS table under the same action names.
+
+import { loadActiveSkills, loadSkillCandidates } from "../../lib/skill-promotion-loop.js";
+import { listPolicyProfiles } from "../../lib/policy-profiles.js";
+
+export const skillPolicyHandlers = {
+ list_skill_candidates(input, p) {
+ const store = loadSkillCandidates(p.learnerDir);
+ const candidates = store.candidates.slice(0, input.limit || 50).map((c) => ({ id: c.id, status: c.status, rule: c.rule, evidence: c.evidence, scope: c.scope, updatedAt: c.updatedAt }));
+ return JSON.stringify({ ok: true, candidates, nextAction: "run_skill_promotion_loop or list_active_skills" }, null, 2);
+ },
+
+ list_active_skills(input, p) {
+ const registry = loadActiveSkills(p.learnerDir);
+ return JSON.stringify({ ok: true, skills: registry.skills.slice(0, input.limit || 50), nextAction: "export_audit_bundle" }, null, 2);
+ },
+
+ list_policy_profiles(input, p, config) {
+ return JSON.stringify({ ok: true, profiles: listPolicyProfiles(), current: config.governanceProfile || "balanced" }, null, 2);
+ },
+};
diff --git a/tools/control-parameters.js b/tools/control-parameters.js
new file mode 100644
index 0000000..718ff83
--- /dev/null
+++ b/tools/control-parameters.js
@@ -0,0 +1,63 @@
+// Parameter schema properties extracted from tools/control.js (C-001 phase 3b).
+//
+// Pure declarative data: the JSON-schema property definitions for the
+// self_learning_control tool's input, minus the `action` property. `action`
+// stays in control.js because its enum is `Object.keys(HANDLERS)` and must not
+// depend on the handler table from here. control.js composes the final schema as:
+// properties: { action: { ... }, ...CONTROL_PARAM_PROPERTIES }
+// which preserves the original key order (action first, then these in order).
+//
+// Field names, descriptions, types, items, enums and defaults are unchanged from
+// control.js. No behavior change.
+
+export const CONTROL_PARAM_PROPERTIES = {
+ id: { type: "string", description: "Pattern id for approve/reject." },
+ proposalId: { type: "string", description: "Proposal id for show/apply/reject proposal actions." },
+ taskId: { type: "string", description: "Agent task id for agent task show/approve/reject/resume actions." },
+ candidateId: { type: "string", description: "Cross-project transfer candidate id for transfer registry actions." },
+ benchmarkId: { type: "string", description: "Optional benchmark scenario id for run_benchmarks." },
+ benchmarkOutputDir: { type: "string", description: "Optional output directory for benchmark reports." },
+ benchmarkRunsDir: { type: "string", description: "Optional benchmark-runs directory for audit dashboard lookup." },
+ benchmarkReportPath: { type: "string", description: "Optional explicit benchmark-report.json path for audit dashboard lookup." },
+ releaseOutputDir: { type: "string", description: "Optional output directory for release readiness reports." },
+ projectRoot: { type: "string", description: "Optional source checkout root for release/benchmark actions." },
+ sourceRoot: { type: "string", description: "Alias of projectRoot for runtime package source checkout resolution." },
+ candidate: { type: "object", description: "Cross-project transfer candidate object for register_transfer_candidate." },
+ validationStatus: { type: "string", enum: ["passed", "failed"], description: "Target validation status for record_transfer_validation." },
+ evidence: { type: "array", items: { type: "string" }, description: "Validation evidence lines for transfer registry actions." },
+ requestId: { type: "string", description: "Approval request id for agent task approval actions." },
+ reason: { type: "string", description: "Optional reason for proposal rejection." },
+ status: { type: "string", description: "Optional proposal status filter: pending, applied, or rejected." },
+ format: { type: "string", enum: ["text", "json"], description: "Output format for the doctor action. Default text." },
+ governanceProfile: { type: "string", enum: ["conservative", "balanced", "autonomous"], description: "Governance policy profile to apply." },
+ limit: { type: "number", description: "Maximum number of events/reviews to return for list actions." },
+ autoInjectHighConfidence: { type: "boolean" },
+ autoApproveHighConfidence: { type: "boolean" },
+ minInjectScore: { type: "number" },
+ minInjectCount: { type: "number" },
+ decayHalfLifeDays: { type: "number" },
+ includePendingPreferences: { type: "boolean" },
+ learnFromUsage: { type: "boolean" },
+ includeUsageInAdvisorPrompt: { type: "boolean" },
+ officialMemoryBridgeEnabled: { type: "boolean" },
+ officialMemoryBridgeMaxResults: { type: "number" },
+ durableMemoryMaxCount: { type: "number" },
+ largeUsageTokenThreshold: { type: "number" },
+ officialUtilityModelDisplay: { type: "string" },
+ modelAdvisorEnabled: { type: "boolean" },
+ modelAdvisorSource: { type: "string", enum: ["official", "private", "off"] },
+ modelAdvisorBaseUrl: { type: "string" },
+ modelAdvisorApiKey: { type: "string" },
+ modelAdvisorModel: { type: "string" },
+ modelAdvisorMaxTokens: { type: "number" },
+ modelAdvisorMinIntervalMinutes: { type: "number" },
+ workStatusEnabled: { type: "boolean" },
+ workStatusText: { type: "string" },
+ proposalChatNotificationsEnabled: { type: "boolean" },
+ requireReviewForAutoApply: { type: "boolean" },
+ semanticSearchEnabled: { type: "boolean" },
+ semanticEmbeddingBaseUrl: { type: "string" },
+ semanticEmbeddingApiKey: { type: "string" },
+ semanticEmbeddingModel: { type: "string" },
+ semanticCacheMaxEntries: { type: "number" },
+};
diff --git a/tools/control-summaries.js b/tools/control-summaries.js
new file mode 100644
index 0000000..6f684a6
--- /dev/null
+++ b/tools/control-summaries.js
@@ -0,0 +1,52 @@
+// Pure summary / formatting helpers extracted from tools/control.js (C-001 phase 3a).
+//
+// These functions are side-effect free: they only aggregate or map plain data
+// for the control tool's JSON responses. They were module-private in control.js
+// and had no external consumers; moving them here shrinks the control dispatcher
+// and makes them directly unit-testable. Bodies are unchanged from control.js.
+
+export function countByStatus(rows = [], field = "status") {
+ const counts = {};
+ for (const row of rows) {
+ const key = row?.[field] || "unknown";
+ counts[key] = (counts[key] || 0) + 1;
+ }
+ return counts;
+}
+
+export function summarizeDecoratedPatterns(patterns = []) {
+ const summary = { total: 0, injectable: 0, pending: 0, approved: 0, rejected: 0 };
+ for (const pattern of patterns) {
+ summary.total += 1;
+ if (pattern.injectable) summary.injectable += 1;
+ if (pattern.status === "pending") summary.pending += 1;
+ else if (pattern.status === "approved") summary.approved += 1;
+ else if (pattern.status === "rejected") summary.rejected += 1;
+ }
+ return summary;
+}
+
+export function countWaitingAgentTasks(tasks = []) {
+ let waiting = 0;
+ for (const task of tasks) {
+ if (task.state === "waiting_for_human") waiting += 1;
+ }
+ return waiting;
+}
+
+export function validationNextAction(validation) {
+ return validation?.ok
+ ? "approve_review then apply_review"
+ : "fix proposal or reject_proposal";
+}
+
+export function reviewPanelNextActions(panel = {}) {
+ const actions = [];
+ const blocked = panel.counts?.blockedReviews || 0;
+ const pending = panel.counts?.pendingReviews || 0;
+ if (blocked > 0) actions.push("validate blocked reviews, then fix or reject them");
+ if (pending > 0) actions.push("preview queued reviews, then approve_review or reject_review");
+ if (panel.counts?.pendingProposals > 0) actions.push("validate_proposal for pending proposals not yet reviewed");
+ if (!actions.length) actions.push("no review action needed");
+ return actions;
+}
diff --git a/tools/control.js b/tools/control.js
index 613e818..60b5349 100644
--- a/tools/control.js
+++ b/tools/control.js
@@ -7,12 +7,12 @@ import { listProposals, readProposal, rejectProposal, previewProposalDiff, verif
import { applyProposalSafely } from "../lib/proposal-apply-safe.js";
import { validateConfigPatch, validateProposal } from "../lib/validation-gate.js";
import { enqueueReviewForProposal, listReviews, readReview, reviewPanel, updateReviewStatus } from "../lib/review-queue.js";
-import { readEvents, appendEvent, replayEventState, verifyEventLog } from "../lib/event-log.js";
+import { readEvents, appendEvent, replayEventState } from "../lib/event-log.js";
import { writeSkillIfChanged } from "../lib/skill-lifecycle.js";
import { runDoctorFromDisk, formatReport } from "./doctor.js";
import { generateMemFS } from "../lib/memfs.js";
import { loadFacts } from "../lib/facts.js";
-import { applyPolicyProfile, listPolicyProfiles } from "../lib/policy-profiles.js";
+import { applyPolicyProfile } from "../lib/policy-profiles.js";
import { buildAuditBundle, exportAuditBundle } from "../lib/audit-bundle.js";
import { buildAuditDashboard, exportAuditDashboard } from "../lib/audit-dashboard.js";
import { extractAndSaveCredentials, mergeCredentials, sanitizeCredentialPatch } from "../lib/credentials.js";
@@ -26,6 +26,10 @@ import { exportReleaseReadiness, formatReleaseReadinessReport } from "../lib/rel
import { resolveProjectRoot } from "../lib/project-root.js";
import { normalizeSessionTarget } from "../lib/helpers.js";
import { toolPaths } from "./_shared.js";
+import { countByStatus, summarizeDecoratedPatterns, countWaitingAgentTasks, validationNextAction, reviewPanelNextActions } from "./control-summaries.js";
+import { CONTROL_PARAM_PROPERTIES } from "./control-parameters.js";
+import { skillPolicyHandlers } from "./control-handlers/skill-policy.js";
+import { eventHandlers } from "./control-handlers/events.js";
const MAX_SKILL_HISTORY = 20;
@@ -56,52 +60,6 @@ function readPluginVersion(pluginDir) {
try { return JSON.parse(fs.readFileSync(path.join(pluginDir, "package.json"), "utf-8")).version; } catch { return "unknown"; }
}
-function countByStatus(rows = [], field = "status") {
- const counts = {};
- for (const row of rows) {
- const key = row?.[field] || "unknown";
- counts[key] = (counts[key] || 0) + 1;
- }
- return counts;
-}
-
-function summarizeDecoratedPatterns(patterns = []) {
- const summary = { total: 0, injectable: 0, pending: 0, approved: 0, rejected: 0 };
- for (const pattern of patterns) {
- summary.total += 1;
- if (pattern.injectable) summary.injectable += 1;
- if (pattern.status === "pending") summary.pending += 1;
- else if (pattern.status === "approved") summary.approved += 1;
- else if (pattern.status === "rejected") summary.rejected += 1;
- }
- return summary;
-}
-
-function countWaitingAgentTasks(tasks = []) {
- let waiting = 0;
- for (const task of tasks) {
- if (task.state === "waiting_for_human") waiting += 1;
- }
- return waiting;
-}
-
-function validationNextAction(validation) {
- return validation?.ok
- ? "approve_review then apply_review"
- : "fix proposal or reject_proposal";
-}
-
-function reviewPanelNextActions(panel = {}) {
- const actions = [];
- const blocked = panel.counts?.blockedReviews || 0;
- const pending = panel.counts?.pendingReviews || 0;
- if (blocked > 0) actions.push("validate blocked reviews, then fix or reject them");
- if (pending > 0) actions.push("preview queued reviews, then approve_review or reject_review");
- if (panel.counts?.pendingProposals > 0) actions.push("validate_proposal for pending proposals not yet reviewed");
- if (!actions.length) actions.push("no review action needed");
- return actions;
-}
-
const HANDLERS = {
status(input, p, config, patterns) {
const decorated = decoratePatterns(patterns, config);
@@ -321,19 +279,9 @@ const HANDLERS = {
return JSON.stringify({ ok: true, reviews, nextAction: "show_proposal then preview_proposal" }, null, 2);
},
- list_events(input, p) {
- return JSON.stringify({ ok: true, events: readEvents(p.learnerDir, { limit: input.limit || 50, entityId: input.id || null }) }, null, 2);
- },
-
- event_summary(input, p) {
- const events = readEvents(p.learnerDir, { limit: input.limit || 5000, entityId: input.id || null });
- return JSON.stringify({ ok: true, summary: replayEventState(events) }, null, 2);
- },
-
- verify_event_log(input, p) {
- const result = verifyEventLog(p.learnerDir);
- return JSON.stringify({ ...result, nextAction: result.ok ? "export_audit_bundle or continue" : "inspect event_log.jsonl and restore from trusted backup" }, null, 2);
- },
+ // Event-log read-only handlers live in control-handlers/events.js
+ // (C-001 HANDLERS split — events domain): list_events, event_summary, verify_event_log.
+ ...eventHandlers,
list_agent_tasks(input, p) {
const tasks = listAgentTaskStates(p.learnerDir, { limit: input.limit || 50 });
@@ -440,26 +388,15 @@ const HANDLERS = {
return JSON.stringify({ ok: result.ok, counts: result.counts, autoSkillFileWriteBlocked: result.autoSkillFileWriteBlocked, nextAction: "list_skill_candidates or export_audit_bundle" }, null, 2);
},
- list_skill_candidates(input, p) {
- const store = loadSkillCandidates(p.learnerDir);
- const candidates = store.candidates.slice(0, input.limit || 50).map((c) => ({ id: c.id, status: c.status, rule: c.rule, evidence: c.evidence, scope: c.scope, updatedAt: c.updatedAt }));
- return JSON.stringify({ ok: true, candidates, nextAction: "run_skill_promotion_loop or list_active_skills" }, null, 2);
- },
-
- list_active_skills(input, p) {
- const registry = loadActiveSkills(p.learnerDir);
- return JSON.stringify({ ok: true, skills: registry.skills.slice(0, input.limit || 50), nextAction: "export_audit_bundle" }, null, 2);
- },
+ // Skill-promotion & policy read-only handlers live in control-handlers/skill-policy.js
+ // (C-001 HANDLERS split pilot): list_skill_candidates, list_active_skills, list_policy_profiles.
+ ...skillPolicyHandlers,
doctor(input, p) {
const report = runDoctorFromDisk(p.learnerDir);
return input.format === "json" ? JSON.stringify(report, null, 2) : formatReport(report);
},
- list_policy_profiles(input, p, config) {
- return JSON.stringify({ ok: true, profiles: listPolicyProfiles(), current: config.governanceProfile || "balanced" }, null, 2);
- },
-
set_policy_profile(input, p, config, patterns) {
const profileName = input.governanceProfile || input.id || "balanced";
const result = applyPolicyProfile(config, profileName);
@@ -642,55 +579,7 @@ export const parameters = {
enum: Object.keys(HANDLERS),
description: "Control action to run.",
},
- id: { type: "string", description: "Pattern id for approve/reject." },
- proposalId: { type: "string", description: "Proposal id for show/apply/reject proposal actions." },
- taskId: { type: "string", description: "Agent task id for agent task show/approve/reject/resume actions." },
- candidateId: { type: "string", description: "Cross-project transfer candidate id for transfer registry actions." },
- benchmarkId: { type: "string", description: "Optional benchmark scenario id for run_benchmarks." },
- benchmarkOutputDir: { type: "string", description: "Optional output directory for benchmark reports." },
- benchmarkRunsDir: { type: "string", description: "Optional benchmark-runs directory for audit dashboard lookup." },
- benchmarkReportPath: { type: "string", description: "Optional explicit benchmark-report.json path for audit dashboard lookup." },
- releaseOutputDir: { type: "string", description: "Optional output directory for release readiness reports." },
- projectRoot: { type: "string", description: "Optional source checkout root for release/benchmark actions." },
- sourceRoot: { type: "string", description: "Alias of projectRoot for runtime package source checkout resolution." },
- candidate: { type: "object", description: "Cross-project transfer candidate object for register_transfer_candidate." },
- validationStatus: { type: "string", enum: ["passed", "failed"], description: "Target validation status for record_transfer_validation." },
- evidence: { type: "array", items: { type: "string" }, description: "Validation evidence lines for transfer registry actions." },
- requestId: { type: "string", description: "Approval request id for agent task approval actions." },
- reason: { type: "string", description: "Optional reason for proposal rejection." },
- status: { type: "string", description: "Optional proposal status filter: pending, applied, or rejected." },
- format: { type: "string", enum: ["text", "json"], description: "Output format for the doctor action. Default text." },
- governanceProfile: { type: "string", enum: ["conservative", "balanced", "autonomous"], description: "Governance policy profile to apply." },
- limit: { type: "number", description: "Maximum number of events/reviews to return for list actions." },
- autoInjectHighConfidence: { type: "boolean" },
- autoApproveHighConfidence: { type: "boolean" },
- minInjectScore: { type: "number" },
- minInjectCount: { type: "number" },
- decayHalfLifeDays: { type: "number" },
- includePendingPreferences: { type: "boolean" },
- learnFromUsage: { type: "boolean" },
- includeUsageInAdvisorPrompt: { type: "boolean" },
- officialMemoryBridgeEnabled: { type: "boolean" },
- officialMemoryBridgeMaxResults: { type: "number" },
- durableMemoryMaxCount: { type: "number" },
- largeUsageTokenThreshold: { type: "number" },
- officialUtilityModelDisplay: { type: "string" },
- modelAdvisorEnabled: { type: "boolean" },
- modelAdvisorSource: { type: "string", enum: ["official", "private", "off"] },
- modelAdvisorBaseUrl: { type: "string" },
- modelAdvisorApiKey: { type: "string" },
- modelAdvisorModel: { type: "string" },
- modelAdvisorMaxTokens: { type: "number" },
- modelAdvisorMinIntervalMinutes: { type: "number" },
- workStatusEnabled: { type: "boolean" },
- workStatusText: { type: "string" },
- proposalChatNotificationsEnabled: { type: "boolean" },
- requireReviewForAutoApply: { type: "boolean" },
- semanticSearchEnabled: { type: "boolean" },
- semanticEmbeddingBaseUrl: { type: "string" },
- semanticEmbeddingApiKey: { type: "string" },
- semanticEmbeddingModel: { type: "string" },
- semanticCacheMaxEntries: { type: "number" },
+ ...CONTROL_PARAM_PROPERTIES,
},
required: ["action"],
};