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 @@

- version + version CI license platform node - tests + tests

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"], };