From 229af004966e7f97ab2882dcd837a889b8528bfc Mon Sep 17 00:00:00 2001 From: Chang Luo <33987852+luochang212@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:17:29 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(autoresearch):=20=E6=B3=A8=E5=86=8C=20?= =?UTF-8?q?autoresearch=20v0.1.0=20=E8=87=AA=E4=B8=BB=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - plugins/autoresearch/: 完整插件(MCP server + hooks + skills + commands,Node 标准库零依赖) - marketplace.json: 注册条目(category developer-tools,author luochang212) - assets/autoresearch/icon.png + icon-sources.json: 原创图标(源 SVG 见贡献者仓库 assets/icon.svg) --- assets/autoresearch/icon.png | Bin 0 -> 11158 bytes assets/icon-sources.json | 7 + marketplace.json | 16 + plugins/autoresearch/.mcp.json | 17 + .../autoresearch/.zcode-plugin/plugin.json | 40 + plugins/autoresearch/README.md | 121 +++ plugins/autoresearch/README_CN.md | 121 +++ plugins/autoresearch/commands/autoresearch.md | 14 + plugins/autoresearch/commands/clear.md | 11 + plugins/autoresearch/commands/export.md | 11 + plugins/autoresearch/commands/finalize.md | 28 + plugins/autoresearch/commands/off.md | 10 + .../hooks/examples/after/auto-tag-winners.sh | 24 + .../hooks/examples/after/learnings-journal.sh | 18 + .../hooks/examples/after/macos-notify.sh | 21 + .../hooks/examples/before/anti-thrash.sh | 35 + .../examples/before/hypothesis-reflection.sh | 18 + .../hooks/examples/before/idea-rotator.sh | 26 + plugins/autoresearch/hooks/guard-frozen.mjs | 42 + plugins/autoresearch/hooks/hooks.json | 87 ++ plugins/autoresearch/hooks/memory-inject.mjs | 104 ++ .../autoresearch/hooks/permission-gate.mjs | 49 + plugins/autoresearch/hooks/session-start.mjs | 36 + plugins/autoresearch/hooks/stop-continue.mjs | 73 ++ .../autoresearch/mcp/lib/dashboard-server.mjs | 74 ++ plugins/autoresearch/mcp/lib/dashboard.mjs | 126 +++ plugins/autoresearch/mcp/lib/experiment.mjs | 226 +++++ plugins/autoresearch/mcp/lib/git.mjs | 90 ++ plugins/autoresearch/mcp/lib/html.mjs | 8 + plugins/autoresearch/mcp/lib/ledger.mjs | 156 +++ plugins/autoresearch/mcp/lib/paths.mjs | 21 + plugins/autoresearch/mcp/lib/validate.mjs | 111 ++ plugins/autoresearch/mcp/server.mjs | 957 ++++++++++++++++++ plugins/autoresearch/scripts/finalize.sh | 111 ++ .../skills/autoresearch-hooks/SKILL.md | 63 ++ .../autoresearch/skills/autoresearch/SKILL.md | 64 ++ .../autoresearch/references/loop-protocol.md | 55 + .../autoresearch/references/setup-guide.md | 98 ++ plugins/autoresearch/tests/dashboard.test.mjs | 179 ++++ plugins/autoresearch/tests/examples.test.mjs | 177 ++++ .../autoresearch/tests/experiment.test.mjs | 278 +++++ plugins/autoresearch/tests/finalize.test.mjs | 114 +++ plugins/autoresearch/tests/git.test.mjs | 114 +++ plugins/autoresearch/tests/hooks.test.mjs | 339 +++++++ plugins/autoresearch/tests/ledger.test.mjs | 120 +++ .../tests/mcp-integration.test.mjs | 344 +++++++ plugins/autoresearch/tests/validate.test.mjs | 97 ++ 47 files changed, 4851 insertions(+) create mode 100644 assets/autoresearch/icon.png create mode 100644 plugins/autoresearch/.mcp.json create mode 100644 plugins/autoresearch/.zcode-plugin/plugin.json create mode 100644 plugins/autoresearch/README.md create mode 100644 plugins/autoresearch/README_CN.md create mode 100644 plugins/autoresearch/commands/autoresearch.md create mode 100644 plugins/autoresearch/commands/clear.md create mode 100644 plugins/autoresearch/commands/export.md create mode 100644 plugins/autoresearch/commands/finalize.md create mode 100644 plugins/autoresearch/commands/off.md create mode 100755 plugins/autoresearch/hooks/examples/after/auto-tag-winners.sh create mode 100755 plugins/autoresearch/hooks/examples/after/learnings-journal.sh create mode 100755 plugins/autoresearch/hooks/examples/after/macos-notify.sh create mode 100755 plugins/autoresearch/hooks/examples/before/anti-thrash.sh create mode 100755 plugins/autoresearch/hooks/examples/before/hypothesis-reflection.sh create mode 100755 plugins/autoresearch/hooks/examples/before/idea-rotator.sh create mode 100644 plugins/autoresearch/hooks/guard-frozen.mjs create mode 100644 plugins/autoresearch/hooks/hooks.json create mode 100644 plugins/autoresearch/hooks/memory-inject.mjs create mode 100644 plugins/autoresearch/hooks/permission-gate.mjs create mode 100644 plugins/autoresearch/hooks/session-start.mjs create mode 100644 plugins/autoresearch/hooks/stop-continue.mjs create mode 100644 plugins/autoresearch/mcp/lib/dashboard-server.mjs create mode 100644 plugins/autoresearch/mcp/lib/dashboard.mjs create mode 100644 plugins/autoresearch/mcp/lib/experiment.mjs create mode 100644 plugins/autoresearch/mcp/lib/git.mjs create mode 100644 plugins/autoresearch/mcp/lib/html.mjs create mode 100644 plugins/autoresearch/mcp/lib/ledger.mjs create mode 100644 plugins/autoresearch/mcp/lib/paths.mjs create mode 100644 plugins/autoresearch/mcp/lib/validate.mjs create mode 100644 plugins/autoresearch/mcp/server.mjs create mode 100755 plugins/autoresearch/scripts/finalize.sh create mode 100644 plugins/autoresearch/skills/autoresearch-hooks/SKILL.md create mode 100644 plugins/autoresearch/skills/autoresearch/SKILL.md create mode 100644 plugins/autoresearch/skills/autoresearch/references/loop-protocol.md create mode 100644 plugins/autoresearch/skills/autoresearch/references/setup-guide.md create mode 100644 plugins/autoresearch/tests/dashboard.test.mjs create mode 100644 plugins/autoresearch/tests/examples.test.mjs create mode 100644 plugins/autoresearch/tests/experiment.test.mjs create mode 100644 plugins/autoresearch/tests/finalize.test.mjs create mode 100644 plugins/autoresearch/tests/git.test.mjs create mode 100644 plugins/autoresearch/tests/hooks.test.mjs create mode 100644 plugins/autoresearch/tests/ledger.test.mjs create mode 100644 plugins/autoresearch/tests/mcp-integration.test.mjs create mode 100644 plugins/autoresearch/tests/validate.test.mjs diff --git a/assets/autoresearch/icon.png b/assets/autoresearch/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9695ac0cc3a767e6e4d76c07f58982b3183f794b GIT binary patch literal 11158 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 literal 0 HcmV?d00001 diff --git a/assets/icon-sources.json b/assets/icon-sources.json index 18b6b04..8d7e5a5 100644 --- a/assets/icon-sources.json +++ b/assets/icon-sources.json @@ -2049,5 +2049,12 @@ "homepage": "https://www.zyte.com", "mimeType": "image/png", "sha256": "8613b1dc383a3b6090a8a81aefc13f4867667fbf1902dc35471f8d554e69a1cd" + }, + { + "name": "autoresearch", + "icon": "autoresearch/icon.png", + "source": "https://github.com/luochang212/zcode-autoresearch/blob/main/assets/icon.svg", + "mimeType": "image/png", + "sha256": "990e83531f283f2a24f456c8dec76fef87fc0970ca3710463ca4af51efbbd686" } ] diff --git a/marketplace.json b/marketplace.json index 1e49d0d..2e2be88 100644 --- a/marketplace.json +++ b/marketplace.json @@ -151,6 +151,22 @@ "webapp", "motion" ] + }, + { + "name": "autoresearch", + "source": "./plugins/autoresearch", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/autoresearch/icon.png", + "description": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate — measure, keep what works, revert what doesn't, repeat. Provides init/run/log experiment tools (MCP), a loop protocol skill, guardrails (frozen benchmark, checks backpressure, memory injection, Stop continuation), and a static dashboard export.", + "description_i18n": { + "en": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate — measure, keep what works, revert what doesn't, repeat.", + "zh-CN": "ZCode 自主实验循环:设定目标与机械度量,让 agent 迭代——测量、保留有效改动、回滚无效改动、循环往复。" + }, + "version": "0.1.0", + "author": { + "name": "luochang212" + }, + "category": "developer-tools", + "keywords": ["autoresearch", "experiment", "optimization", "loop", "benchmark", "mcp"] } ] } diff --git a/plugins/autoresearch/.mcp.json b/plugins/autoresearch/.mcp.json new file mode 100644 index 0000000..2c9ee6b --- /dev/null +++ b/plugins/autoresearch/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "autoresearch": { + "type": "stdio", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/mcp/server.mjs"], + "cwd": "${ZCODE_PROJECT_DIR}", + "env": { + "AR_MAX_ITERATIONS": "${user_config.maxIterations}", + "AR_BENCHMARK_TIMEOUT_MS": "${user_config.benchmarkTimeoutMs}", + "AR_CHECKS_TIMEOUT_MS": "${user_config.checksTimeoutMs}" + }, + "enabled": true, + "timeoutMs": 60000 + } + } +} diff --git a/plugins/autoresearch/.zcode-plugin/plugin.json b/plugins/autoresearch/.zcode-plugin/plugin.json new file mode 100644 index 0000000..62702f6 --- /dev/null +++ b/plugins/autoresearch/.zcode-plugin/plugin.json @@ -0,0 +1,40 @@ +{ + "name": "autoresearch", + "description": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate — measure, keep what works, revert what doesn't, repeat. Provides init/run/log experiment tools (MCP), a loop protocol skill, guardrails (frozen benchmark, checks backpressure, memory injection, Stop continuation), and a static dashboard export.", + "description_i18n": { + "en": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate — measure, keep what works, revert what doesn't, repeat.", + "zh-CN": "ZCode 自主实验循环:设定目标与机械度量,让 agent 迭代——测量、保留有效改动、回滚无效改动、循环往复。" + }, + "version": "0.1.0", + "author": { + "name": "luochang212" + }, + "keywords": [ + "autoresearch", + "experiment", + "optimization", + "loop", + "benchmark", + "mcp" + ], + "userConfig": { + "maxIterations": { + "title": "Max Iterations", + "description": "Default iteration cap per experiment segment. Overridable per session via .auto/config.json.", + "type": "number", + "default": 20 + }, + "benchmarkTimeoutMs": { + "title": "Benchmark Timeout (ms)", + "description": "Wall-clock timeout for run_experiment commands.", + "type": "number", + "default": 600000 + }, + "checksTimeoutMs": { + "title": "Checks Timeout (ms)", + "description": "Wall-clock timeout for the correctness check script (.auto/checks.sh).", + "type": "number", + "default": 300000 + } + } +} diff --git a/plugins/autoresearch/README.md b/plugins/autoresearch/README.md new file mode 100644 index 0000000..c14fdc3 --- /dev/null +++ b/plugins/autoresearch/README.md @@ -0,0 +1,121 @@ +# autoresearch + +[中文文档](./README_CN.md) + +An autonomous experiment loop for ZCode: set a fixed, mechanical metric and let the coding agent iterate — modify code → run the benchmark → keep improvements, revert regressions → repeat. + +Based on research into [karpathy/autoresearch](https://github.com/karpathy/autoresearch) and [pi-autoresearch](https://github.com/yourduskqubis/pi-autoresearch) (see `docs/research/autoresearch-survey.md`). Architecture decisions live in `adr/decisions/`. + +## Security and side effects + +This plugin executes code and operates on a git repository. Enabling it grants code-execution trust (official marketplace convention). Specifically, it: + +- **Runs commands**: `run_experiment` executes the benchmark script you author (`.auto/measure.sh`) and, when present, the correctness gate (`.auto/checks.sh`); +- **Runs git operations automatically**: `git commit` on keep, automatic rollback on non-keep (`.auto/` is exempt from rollback); +- **Installs ZCode hooks**: Stop (loop continuation), PreToolUse (frozen-file write protection), PermissionRequest (experiment-tool gating), UserPromptSubmit/SessionStart (ledger memory injection); +- **Serves a local HTTP dashboard** on 127.0.0.1 via `export_dashboard`; +- **Writes session state** to `.auto/` files (`log.jsonl`, `config.json`) in the project directory. + +No third-party npm dependencies: the MCP server and hooks are Node-stdlib-only scripts. + +## Install + +This repository is itself a plugin marketplace (`marketplace.json` points to `./plugin`). In ZCode: + +1. Add the marketplace: this repository's URL (or a local directory). +2. In **Settings → Plugin Management**, install and enable `autoresearch`. +3. The plugin provides: an MCP server (5 tools), the `autoresearch` skill, 5 slash commands, and 5 hooks. + +## Usage + +``` +/autoresearch:autoresearch # enter/resume autoresearch mode (runs setup if there is no session) +/autoresearch:export # export a static dashboard (autoresearch-dashboard.html) +/autoresearch:off # pause loop continuation (sets autoresearchOff: true) +/autoresearch:clear # reset the session ledger +/autoresearch:finalize # organize kept experiments into a clean branch (scripts/finalize.sh) +``` + +Or let the skill trigger on its own (descriptions containing "autoresearch", "autonomous optimization", etc.). A full loop: + +1. **Setup**: pick a mechanical metric → write `.auto/measure.sh` (emits `METRIC name=value` lines) → optionally `.auto/checks.sh` (correctness gate) → write the `.auto/prompt.md` charter → create an experiment branch `git checkout -b autoresearch/`. +2. `init_experiment` (metric_name, direction) → run a baseline. +3. **Loop**: one focused change → `run_experiment` → `log_experiment` (keep auto-commits / non-keep auto-rolls-back, `.auto/` exempt). + +## Tools (MCP) + +| Tool | What it does | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `init_experiment` | Start/restart an experiment segment (name, primary metric, direction lower/higher) | +| `run_experiment` | Run the benchmark: times it, parses `METRIC name=value` lines, returns a truncated tail (10 lines / 4KB), kills the process group on timeout, takes the median over `repeat` runs, runs the `before.sh` hook | +| `log_experiment` | Record the outcome: keep auto-commits (`experiment:` prefix); non-keep auto-rolls-back (`.auto/` exempt); returns baseline/best/delta/confidence/plateau plus a next-action hint; runs the `after.sh` hook | +| `export_dashboard` | Serve a live local dashboard (127.0.0.1 + SSE auto-refresh) and write a static HTML fallback | +| `clear_experiments` | Delete `.auto/log.jsonl` and reset the session (keeps measure/checks/prompt) | + +## Guardrails + +- **Benchmark locking**: when `.auto/measure.sh` exists, `run_experiment` only executes that script (validated after stripping env/time/nice wrappers). +- **Correctness backpressure**: when `.auto/checks.sh` exists, it runs automatically after a passing benchmark; a failing gate forbids keep and rolls back. +- **Write protection**: a PreToolUse hook denies writes to `.auto/measure.sh` / `.auto/checks.sh`. +- **Tool gating (approximate)**: a PermissionRequest hook denies experiment tools when there is no session (only covers calls that go through the permission prompt). +- **Auto-resume hint**: SessionStart injects a continuation hint when an active session is detected; `/autoresearch:off` pauses it (`autoresearchOff: true`). +- **Memory injection**: UserPromptSubmit/SessionStart hooks inject an aggregated summary (progress + deduped tried directions + best trajectory + ASI distillation) so progress survives compaction; repeated/oscillating attempts (doom-loop) trigger a hint to switch direction. +- **Loop continuation**: the Stop hook blocks (`decision:block`) while a loop is unfinished (zcode platform limit: 3 consecutive windows). +- **Iteration hooks**: `.auto/hooks/before.sh` (pre-benchmark) and `after.sh` (post-record) run on every experiment (fail-open, 30s timeout, stdout → `*_steer`). +- **Hook ecosystem**: `skills/autoresearch-hooks` tutorial + 6 ready-to-use examples in `hooks/examples/` (anti-thrash, hypothesis reflection, idea rotator, learnings journal, auto-tag winners, macOS notify) — copy to `.auto/hooks/` and go (parsed with Node, no jq dependency). +- **Stop-loss**: after `consecutiveFailures` in a row (default 3, configurable in `.auto/config.json`) the plugin hints you to stop. +- **Ledger audit**: `log_experiment` validates invariants before writing (keep must be a real improvement, a discarded real improvement must have failed the guard, event ordering, commit field); violations are rejected; a crashed segment that wasn't rolled back blocks continuation. `auditBypass: true` in `.auto/config.json` explicitly skips it (not recommended). +- **Benchmark drift detection**: `init_experiment` records hashes of measure.sh/checks.sh; `run_experiment` compares — a mid-run benchmark change returns a `benchmark_drift` warning (prevents "faking the metric by editing the benchmark"). +- **Secondary-metric constraints** (opt-in): `log_experiment` supports `constraints: [{name, maxPct}]` — on keep, secondary metrics are checked not to exceed maxPct% of the first run's value, rejected otherwise (prevents reward hacking like "trading memory for speed"). + +## Directory structure + +```text +plugin/ +├── .zcode-plugin/plugin.json # manifest (userConfig: maxIterations / timeouts) +├── .mcp.json # MCP stdio server declaration +├── mcp/ +│ ├── server.mjs # JSON-RPC line protocol + tools +│ └── lib/ # pure logic: experiment / ledger / git / validate / dashboard / dashboard-server / html / paths +├── hooks/ +│ ├── hooks.json # Stop / PreToolUse / PermissionRequest / UserPromptSubmit / SessionStart +│ ├── stop-continue.mjs # loop unfinished → block +│ ├── guard-frozen.mjs # frozen-file write protection → deny +│ ├── permission-gate.mjs # experiment-tool gating → deny +│ ├── memory-inject.mjs # ledger tail injection +│ ├── session-start.mjs # session resume hints +│ └── examples/ # 6 ready-to-use before/after iteration hooks +├── skills/ +│ ├── autoresearch/ # SKILL.md thin router + references/ +│ └── autoresearch-hooks/ # iteration-hook tutorial +├── commands/ # autoresearch / export / off / clear / finalize +├── scripts/finalize.sh # /autoresearch:finalize implementation +└── tests/ # node --test unit tests +``` + +## workingDir + +Setting `"workingDir": "work/"` in `.auto/config.json` separates the research directory from the project directory (ledger/benchmark/git/dashboard all act on work/, config stays in the project). + +## Session state (`.auto/`) + +| File | Purpose | +| ------------- | -------------------------------------------------------------------------------------------------- | +| `log.jsonl` | **append-only single source of truth**: config lines + run lines; segments advance on config lines | +| `prompt.md` | session charter (goal/metric/scope/Off Limits/What's Been Tried) | +| `measure.sh` | benchmark script (frozen) | +| `checks.sh` | optional correctness gate (frozen) | +| `config.json` | optional `{ "maxIterations": N }` | +| `ideas.md` | optional hypothesis list | + +## Known limits (research-backed, see `docs/research/autoresearch-survey.md` §4.1) + +- **No session-injection API**: no overnight unattended runs; rely on the 3-window Stop-hook allowance plus user re-triggering to continue. +- **Headless mode (`--prompt`) does not run hooks**: guardrails take effect in interactive sessions; run autoresearch in an interactive session. +- `git add -A` commits unrelated dirty files together (known pi inheritance) — commit a clean baseline during setup. + +## Development + +```bash +cd plugin && node --test tests/*.test.mjs # unit tests +``` diff --git a/plugins/autoresearch/README_CN.md b/plugins/autoresearch/README_CN.md new file mode 100644 index 0000000..2d36927 --- /dev/null +++ b/plugins/autoresearch/README_CN.md @@ -0,0 +1,121 @@ +# autoresearch + +[English](./README.md) + +让 ZCode 的 coding agent 在**固定度量**上自主迭代优化:改代码 → 跑基准 → 保留改进、回滚退化 → 循环。 + +基于 [karpathy/autoresearch](https://github.com/karpathy/autoresearch) 与 [pi-autoresearch](https://github.com/yourduskqubis/pi-autoresearch) 的调研(见仓库根 `docs/research/autoresearch-survey.md`)。架构决策见 `adr/decisions/1-*.md`、`2-*.md`。 + +## 安装 + +本仓库即一个插件市场(`marketplace.json` 指向 `./plugin`)。在 ZCode 中: + +1. 添加市场:本地目录 / 本仓库地址。 +2. 在 **Settings → Plugin Management** 安装并启用 `autoresearch`。 +3. 插件提供:MCP 服务(5 个工具)、skill `autoresearch`、5 个命令、5 个 hook。启用插件即授予代码执行信任(官方约定)。 + +> 无第三方 npm 依赖:MCP server 与 hooks 均为 Node 标准库脚本。 + +## 安全与副作用 + +启用本插件即授予代码执行信任(官方市场约定)。插件会: + +- **执行命令**:`run_experiment` 运行你编写的基准脚本(`.auto/measure.sh`),以及存在时的正确性门禁(`.auto/checks.sh`); +- **自动执行 git 操作**:keep 时自动 `git commit`,非 keep 时自动回滚(`.auto/` 豁免回滚); +- **安装 ZCode hooks**:Stop(循环续跑)、PreToolUse(冻结文件写保护)、PermissionRequest(实验工具门禁)、UserPromptSubmit/SessionStart(账本记忆注入); +- **启动本地 HTTP dashboard**:`export_dashboard` 监听 127.0.0.1; +- **写入会话状态**:项目目录下的 `.auto/`(`log.jsonl`、`config.json`)。 + +## 用法 + +``` +/autoresearch:autoresearch <目标> # 进入/恢复 autoresearch 模式(无会话则走 setup) +/autoresearch:export # 导出静态 dashboard(autoresearch-dashboard.html) +/autoresearch:off # 暂停循环续跑(autoresearchOff: true) +/autoresearch:clear # 重置会话账本 +/autoresearch:finalize # 把 kept 实验整理为干净分支(scripts/finalize.sh) +``` + +或让 skill 自动触发(描述含 "autoresearch"、"自主优化" 等)。一次完整循环: + +1. **Setup**:定机械度量 → 写 `.auto/measure.sh`(输出 `METRIC name=value` 行)→ 可选 `.auto/checks.sh`(正确性门禁)→ 写 `.auto/prompt.md` 章程 → 建实验分支 `git checkout -b autoresearch/`。 +2. `init_experiment`(metric_name、direction)→ 跑一次 baseline。 +3. **循环**:一次聚焦改动 → `run_experiment` → `log_experiment`(keep 自动 commit / 非 keep 自动回滚,`.auto/` 豁免)。 + +## 工具(MCP) + +| 工具 | 作用 | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `init_experiment` | 建立/重启实验 segment(名称、主度量、方向 lower/higher) | +| `run_experiment` | 跑基准:计时、`METRIC name=value` 解析、10 行/4KB 截断回传、超时杀进程组、`repeat` 取中位数、执行 `before.sh` 钩子 | +| `log_experiment` | 记录结果:keep 自动 `git commit`(`experiment:` 前缀);非 keep 自动回滚(豁免 `.auto/`);返回 baseline/best/delta/confidence/plateau 与下一步提示、执行 `after.sh` 钩子 | +| `export_dashboard` | 起本地 live dashboard(127.0.0.1 + SSE 自动刷新)并写静态 HTML 兜底 | +| `clear_experiments` | 删除 `.auto/log.jsonl` 重置会话(保留 measure/checks/prompt) | + +## 护栏 + +- **benchmark 锁定**:`.auto/measure.sh` 存在时,`run_experiment` 只执行该脚本(剥 env/time/nice 包装后校验)。 +- **正确性背压**:`.auto/checks.sh` 存在时 benchmark 通过后自动执行;失败禁 keep 并自动回滚。 +- **写保护**:PreToolUse hook deny 对 `.auto/measure.sh` / `.auto/checks.sh` 的写入。 +- **工具门禁(近似)**:无会话时 PermissionRequest hook deny 实验工具(仅覆盖经权限询问的调用)。 +- **自动激活提示**:SessionStart 检测活动会话时注入续跑引导;`/autoresearch:off` 可暂停(`autoresearchOff: true`)。 +- **记忆注入**:UserPromptSubmit/SessionStart hook 注入聚合摘要(进度 + 已尝试方向去重 + best 轨迹 + ASI 提炼),compaction 后不丢进度;检测到重复/震荡尝试(doom-loop)时提示换方向。 +- **循环续跑**:Stop hook 在循环未结束时 `decision:block`(zcode 平台限制连续 3 次窗口)。 +- **迭代钩子**:`.auto/hooks/before.sh`(基准前)与 `after.sh`(记录后)每次实验自动执行(fail-open,30s 超时,stdout→`*_steer`)。 +- **钩子生态**:`skills/autoresearch-hooks` 教学 + `hooks/examples/` 6 个现成示例(防重复失败/换思路/假设反思/学习日志/通知/最优打标),复制到 `.auto/hooks/` 即用(node 解析,无 jq 依赖)。 +- **止损**:连续失败达 `.auto/config.json` 的 `consecutiveFailures`(默认 3,可配)时提示停止。 +- **账本审计**:`log_experiment` 写入前校验不变量(keep 必须真实改进、discard 真改进须 failed guard、事件顺序、commit 字段),违规拒收;crash 未回滚禁止续跑。`.auto/config.json` 的 `auditBypass: true` 可显式跳过(不推荐)。 +- **基准漂移检测**:`init_experiment` 记录 measure.sh/checks.sh 哈希,`run_experiment` 比对——基准中途变更时返回 `benchmark_drift` 警告(防"改基准造假 metric")。 +- **次级度量约束**(opt-in):`log_experiment` 支持 `constraints: [{name, maxPct}]`——keep 时校验次级度量不超首轮值的 maxPct%,超界拒收(防"用内存换速度"类 reward hacking)。 + +## 目录结构 + +``` +plugin/ +├── .zcode-plugin/plugin.json # manifest(userConfig: maxIterations / 超时) +├── .mcp.json # MCP stdio server 声明 +├── mcp/ +│ ├── server.mjs # JSON-RPC 换行协议 + 5 个工具 +│ └── lib/ # 纯逻辑:experiment / ledger / git / validate / dashboard / dashboard-server / html / paths +├── hooks/ +│ ├── hooks.json # Stop / PreToolUse / PermissionRequest / UserPromptSubmit / SessionStart +│ ├── stop-continue.mjs # 循环未结束 → block +│ ├── guard-frozen.mjs # 冻结文件写保护 → deny +│ ├── permission-gate.mjs # 实验工具门禁 → deny +│ ├── memory-inject.mjs # 账本尾行注入 +│ ├── session-start.mjs # 会话恢复提示 +│ └── examples/ # 6 个现成的 before/after 迭代钩子示例 +├── skills/ +│ ├── autoresearch/ # SKILL.md 薄路由 + references/ +│ └── autoresearch-hooks/ # 迭代钩子教学 +├── commands/ # autoresearch / export / off / clear / finalize +├── scripts/finalize.sh # /autoresearch:finalize 实现 +└── tests/ # node --test 单元测试 +``` + +## workingDir + +在 `.auto/config.json` 设 `"workingDir": "work/"` 可将研究目录与项目目录分离(账本/基准/git/dashboard 全部作用于 work/,config 留在项目)。 + +## 会话状态(`.auto/`) + +| 文件 | 作用 | +| ------------- | ------------------------------------------------------------------------ | +| `log.jsonl` | **append-only 单一事实源**:config 行 + run 行;segment 由 config 行推进 | +| `prompt.md` | 会话章程(目标/度量/范围/Off Limits/What's Been Tried) | +| `measure.sh` | 基准脚本(冻结) | +| `checks.sh` | 可选正确性门禁(冻结) | +| `config.json` | 可选 `{ "maxIterations": N }` | +| `ideas.md` | 可选假设清单 | + +## 已知边界(研究实证,详见报告 §4.1) + +- **无会话注入 API**:无过夜无人值守;靠 Stop hook 3 次窗口 + 用户再触发续跑。 +- **无头模式(`--prompt`)不执行 hooks**:护栏在交互式会话生效;请用交互式会话跑 autoresearch。 +- `git add -A` 会把无关脏文件一起 commit(继承 pi 的已知弱点)——setup 时先提交干净基线。 + +## 开发 + +```bash +cd plugin && node --test tests/*.test.mjs # 单元测试 +``` diff --git a/plugins/autoresearch/commands/autoresearch.md b/plugins/autoresearch/commands/autoresearch.md new file mode 100644 index 0000000..54b8252 --- /dev/null +++ b/plugins/autoresearch/commands/autoresearch.md @@ -0,0 +1,14 @@ +--- +description: Enter autoresearch mode — continue an existing session from .auto/prompt.md, or set one up from your goal. Usage: /autoresearch:autoresearch +--- + +Enter autoresearch mode for this workspace. + +1. If `.auto/log.jsonl` and `.auto/prompt.md` exist, **resume**: read the charter and the ledger, then continue the loop (one focused change → `run_experiment` → `log_experiment`). +2. Otherwise **set up a new session** from the goal in $ARGUMENTS: + - Load the skill `autoresearch` (or read `skills/autoresearch/SKILL.md`). + - Follow `references/setup-guide.md`: pick a mechanical metric, create `.auto/measure.sh` (prints `METRIC name=value`), optional `.auto/checks.sh`, write `.auto/prompt.md` charter. + - `init_experiment` with metric name and direction, run a baseline, then start the loop. +3. Remind the user: keep the benchmark frozen (`.auto/measure.sh` / `.auto/checks.sh` are write-protected by the plugin hook), and use `/autoresearch:export` for a dashboard. + +$ARGUMENTS diff --git a/plugins/autoresearch/commands/clear.md b/plugins/autoresearch/commands/clear.md new file mode 100644 index 0000000..a3974ea --- /dev/null +++ b/plugins/autoresearch/commands/clear.md @@ -0,0 +1,11 @@ +--- +description: Clear the autoresearch session — delete .auto/log.jsonl and start fresh. Keeps measure.sh / checks.sh / prompt.md. Usage: /autoresearch:clear +--- + +Clear the current autoresearch session. + +1. Confirm with the user that they want to wipe the experiment history (this cannot be undone — the ledger and all `experiment:` commits stay in git history, but the session state is gone). +2. Call the `clear_experiments` tool. +3. Report the result. A fresh target can now start with `/autoresearch:autoresearch ` or `init_experiment`. + +Note: kept `experiment:` commits remain in git history — this only resets the `.auto/` session ledger. diff --git a/plugins/autoresearch/commands/export.md b/plugins/autoresearch/commands/export.md new file mode 100644 index 0000000..ff42423 --- /dev/null +++ b/plugins/autoresearch/commands/export.md @@ -0,0 +1,11 @@ +--- +description: Export the autoresearch dashboard — render .auto/log.jsonl into autoresearch-dashboard.html. Usage: /autoresearch:export +--- + +Export the autoresearch experiment dashboard. + +1. Call the `export_dashboard` tool (or run `node ${ZCODE_PLUGIN_ROOT}/mcp/server.mjs`'s export logic — prefer the MCP tool). +2. If the tool is unavailable, fall back to: read `.auto/log.jsonl`, summarize experiments (status, metric, delta vs baseline, direction), and write a self-contained `autoresearch-dashboard.html` in the workspace root. +3. Tell the user the file path (`autoresearch-dashboard.html`) and a 2-3 line summary of progress (experiments run, kept, best metric). + +If there is no `.auto/log.jsonl`, say so and suggest `/autoresearch:autoresearch` to start a session first. diff --git a/plugins/autoresearch/commands/finalize.md b/plugins/autoresearch/commands/finalize.md new file mode 100644 index 0000000..0b1a542 --- /dev/null +++ b/plugins/autoresearch/commands/finalize.md @@ -0,0 +1,28 @@ +--- +description: Finalize the autoresearch session — split kept experiments into clean topic branches you can PR. Usage: /autoresearch:finalize +--- + +Finalize the experiment session into clean, PR-able topic branches. + +1. Read `.auto/log.jsonl`, collect the **kept** experiments (status=keep with a commit). +2. Group them by file dependency: two experiments may share a branch only if their changed files overlap; group small, keep order. +3. Write `groups.json` at the project root: + ```json + { + "base": "", + "goal": "", + "groups": [ + { + "title": "perf: sieve", + "body": "...", + "last_commit": "", + "slug": "sieve" + } + ] + } + ``` + `last_commit` must be the full kept commit hash (`git rev-parse `). +4. Run `bash ${ZCODE_PLUGIN_ROOT}/scripts/finalize.sh `. +5. Report: the created branches (`autoresearch//NN-`), the overall metric improvement, and cleanup notes (`git branch -D` + `rm -r .auto` when done). + +If the script reports a file appearing in multiple groups, merge those groups or re-split and rerun. diff --git a/plugins/autoresearch/commands/off.md b/plugins/autoresearch/commands/off.md new file mode 100644 index 0000000..84c54e4 --- /dev/null +++ b/plugins/autoresearch/commands/off.md @@ -0,0 +1,10 @@ +--- +description: Turn off autoresearch auto-resume hints — keep the session but stop being prompted to continue. Resume anytime with /autoresearch:autoresearch. Usage: /autoresearch:off +--- + +Pause autoresearch without wiping the session. + +1. Set `autoresearchOff: true` in `.auto/config.json` (create the file if missing). The SessionStart hook will stop injecting "resume" hints for this workspace. +2. The ledger and all experiment commits stay intact. +3. To resume: run `/autoresearch:autoresearch` (it ignores the off marker), or clear the marker (`autoresearchOff: false`) for hints again. +4. To start completely fresh: `/autoresearch:clear`. diff --git a/plugins/autoresearch/hooks/examples/after/auto-tag-winners.sh b/plugins/autoresearch/hooks/examples/after/auto-tag-winners.sh new file mode 100755 index 0000000..9d4d225 --- /dev/null +++ b/plugins/autoresearch/hooks/examples/after/auto-tag-winners.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# auto-tag-winners: tag every new best with a sortable git tag so +# `git log --tags` reads as a progression record. after hook. +# Pure side effect — no steer output. +set -euo pipefail + +payload="$(cat)" + +node - "$payload" <<'NODE' +const { execFileSync } = require('child_process'); +const p = JSON.parse(process.argv[2]); +const run = p.run_entry; +const session = p.session; +if (run.status !== 'keep') process.exit(0); +const best = session?.best_metric; +if (best == null || run.metric == null) process.exit(0); +if (run.metric !== best) process.exit(0); // not a new best +const tag = `autoresearch/best-run-${run.run}-${run.metric}`; +try { + execFileSync('git', ['-C', p.cwd, 'tag', '-f', tag], { stdio: 'ignore' }); +} catch { + /* not a git repo → silent */ +} +NODE diff --git a/plugins/autoresearch/hooks/examples/after/learnings-journal.sh b/plugins/autoresearch/hooks/examples/after/learnings-journal.sh new file mode 100755 index 0000000..eb4c94a --- /dev/null +++ b/plugins/autoresearch/hooks/examples/after/learnings-journal.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# learnings-journal: append one markdown line per experiment to +# .auto/learnings.md — a human-readable diary that survives the loop. +# after hook. Pure side effect, no steer. +set -euo pipefail + +payload="$(cat)" + +node - "$payload" <<'NODE' +const p = JSON.parse(process.argv[2]); +const fs = require('fs'); +const path = require('path'); +const run = p.run_entry; +const journal = `${p.cwd}/.auto/learnings.md`; +fs.mkdirSync(path.dirname(journal), { recursive: true }); +const line = `- run ${run.run} [${run.status}] metric=${run.metric ?? '—'} — ${run.description ?? ''}`; +fs.appendFileSync(journal, line + '\n'); +NODE diff --git a/plugins/autoresearch/hooks/examples/after/macos-notify.sh b/plugins/autoresearch/hooks/examples/after/macos-notify.sh new file mode 100755 index 0000000..37d68fa --- /dev/null +++ b/plugins/autoresearch/hooks/examples/after/macos-notify.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# macos-notify: post a macOS notification when an experiment completes. +# after hook. macOS only (osascript); silently no-ops elsewhere. +# Pure side effect — no steer output. +set -euo pipefail + +payload="$(cat)" + +node - "$payload" <<'NODE' +const { execFileSync } = require('child_process'); +const p = JSON.parse(process.argv[2]); +const run = p.run_entry; +const session = p.session; +const title = `autoresearch run ${run.run}: ${run.status}`; +const body = `metric=${run.metric ?? '—'} (best=${session?.best_metric ?? '—'}) ${run.description ?? ''}`; +try { + execFileSync('osascript', ['-e', `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`], { stdio: 'ignore' }); +} catch { + /* no osascript (non-macOS) → silent */ +} +NODE diff --git a/plugins/autoresearch/hooks/examples/before/anti-thrash.sh b/plugins/autoresearch/hooks/examples/before/anti-thrash.sh new file mode 100755 index 0000000..bdd3bcf --- /dev/null +++ b/plugins/autoresearch/hooks/examples/before/anti-thrash.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# anti-thrash: after N consecutive discards/crashes, suggest a structural rethink. +# before hook. Reads the ledger tail via stdin payload (.cwd). Silent otherwise. +# +# Contract fields used: event, cwd, last_run, session.run_count +set -euo pipefail + +readonly STREAK_THRESHOLD=3 +readonly WINDOW=5 + +payload="$(cat)" + +node - "$payload" <<'NODE' +const p = JSON.parse(process.argv[2]); +const fs = require('fs'); +const log = `${p.cwd}/.auto/log.jsonl`; +if (!fs.existsSync(log)) process.exit(0); + +const tail = fs.readFileSync(log, 'utf8') + .split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }) + .filter(e => e && e.type === 'run') + .slice(-5); + +let streak = 0; +for (const r of [...tail].reverse()) { + if (r.status === 'keep') break; + streak += 1; +} +if (streak < 3) process.exit(0); + +console.log(`⚠️ ${streak} consecutive non-keep results. Consider:`); +console.log(' - re-reading .auto/prompt.md and the benchmark script'); +console.log(' - something structurally different, not another variation of the same idea'); +console.log(' - measuring where time/space is actually spent before the next change'); +NODE diff --git a/plugins/autoresearch/hooks/examples/before/hypothesis-reflection.sh b/plugins/autoresearch/hooks/examples/before/hypothesis-reflection.sh new file mode 100755 index 0000000..b115354 --- /dev/null +++ b/plugins/autoresearch/hooks/examples/before/hypothesis-reflection.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# hypothesis-reflection: before each run, remind the agent to state a clear +# hypothesis when the previous run recorded none (asi.hypothesis missing). +# before hook. Silent when the last run already had one. +set -euo pipefail + +payload="$(cat)" + +node - "$payload" <<'NODE' +const p = JSON.parse(process.argv[2]); +const last = p.last_run; +if (!last) process.exit(0); +if (last.asi && last.asi.hypothesis) process.exit(0); + +console.log('🧪 The last run had no recorded hypothesis (asi.hypothesis).'); +console.log(' Before this run, state in one line what you are testing and why it should help,'); +console.log(' then pass it as asi.hypothesis in log_experiment.'); +NODE diff --git a/plugins/autoresearch/hooks/examples/before/idea-rotator.sh b/plugins/autoresearch/hooks/examples/before/idea-rotator.sh new file mode 100755 index 0000000..c848b72 --- /dev/null +++ b/plugins/autoresearch/hooks/examples/before/idea-rotator.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# idea-rotator: pick the next untried idea from .auto/ideas.md (one idea per +# line, lines starting with '#' are ignored) and steer the agent to try it. +# before hook. Silent when there is no ideas file or no untried ideas left. +set -euo pipefail + +payload="$(cat)" + +node - "$payload" <<'NODE' +const p = JSON.parse(process.argv[2]); +const fs = require('fs'); +const ideasFile = `${p.cwd}/.auto/ideas.md`; +if (!fs.existsSync(ideasFile)) process.exit(0); + +const lines = fs.readFileSync(ideasFile, 'utf8') + .split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')); + +if (lines.length === 0) process.exit(0); + +// Rotate using the run counter so each experiment surfaces a different idea. +const idx = (p.session?.run_count ?? 0) % lines.length; +console.log(`💡 untried idea to consider: ${lines[idx]}`); +console.log(' (add/remove lines in .auto/ideas.md to control the pool)'); +NODE diff --git a/plugins/autoresearch/hooks/guard-frozen.mjs b/plugins/autoresearch/hooks/guard-frozen.mjs new file mode 100644 index 0000000..ebfe841 --- /dev/null +++ b/plugins/autoresearch/hooks/guard-frozen.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// PreToolUse hook: deny writes to the frozen benchmark scripts +// (.auto/measure.sh, .auto/checks.sh). The matcher limits this to +// Write|Edit|ApplyPatch; path filtering happens here, per zcode docs. +import { resolve, relative } from "node:path"; +import { resolveWorkCwd } from "../mcp/lib/paths.mjs"; + +const cwd = resolveWorkCwd(process.argv[2] || process.cwd()); +const FROZEN = new Set([".auto/measure.sh", ".auto/checks.sh"]); + +let raw = ""; +process.stdin.setEncoding("utf8"); +for await (const chunk of process.stdin) raw += chunk; + +let input = {}; +try { + input = raw.trim() ? JSON.parse(raw) : {}; +} catch { + process.exit(0); // fail open +} + +const ti = input.tool_input || input.toolInput || {}; +const fp = ti.file_path || ti.path || ""; +if (!fp) process.exit(0); + +let rel; +try { + rel = relative(cwd, resolve(cwd, fp)); +} catch { + process.exit(0); +} +if (!FROZEN.has(rel)) process.exit(0); + +process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: `[autoresearch] ${rel} is frozen — the benchmark metric must not change during the loop. If you really need a new metric, start over: init_experiment with a new target.`, + }, + }), +); diff --git a/plugins/autoresearch/hooks/hooks.json b/plugins/autoresearch/hooks/hooks.json new file mode 100644 index 0000000..737bd69 --- /dev/null +++ b/plugins/autoresearch/hooks/hooks.json @@ -0,0 +1,87 @@ +{ + "description": "autoresearch plugin hooks: loop continuation (Stop), frozen benchmark write protection (PreToolUse), ledger memory injection (UserPromptSubmit/SessionStart). All fail-open.", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/stop-continue.mjs", + "${ZCODE_PROJECT_DIR}" + ], + "timeoutMs": 5000, + "statusMessage": "autoresearch: checking loop continuation…" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Write|Edit|ApplyPatch", + "hooks": [ + { + "type": "process", + "command": "node", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/guard-frozen.mjs", + "${ZCODE_PROJECT_DIR}" + ], + "timeoutMs": 5000, + "statusMessage": "autoresearch: checking frozen files…" + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/permission-gate.mjs", + "${ZCODE_PROJECT_DIR}" + ], + "timeoutMs": 5000, + "statusMessage": "autoresearch: gating experiment tools…" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "process", + "command": "node", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/memory-inject.mjs", + "${ZCODE_PROJECT_DIR}" + ], + "timeoutMs": 5000, + "statusMessage": "autoresearch: injecting ledger memory…" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "process", + "command": "node", + "args": [ + "${ZCODE_PLUGIN_ROOT}/hooks/session-start.mjs", + "${ZCODE_PROJECT_DIR}" + ], + "timeoutMs": 5000, + "statusMessage": "autoresearch: session context…" + } + ] + } + ] + } +} diff --git a/plugins/autoresearch/hooks/memory-inject.mjs b/plugins/autoresearch/hooks/memory-inject.mjs new file mode 100644 index 0000000..ac506f6 --- /dev/null +++ b/plugins/autoresearch/hooks/memory-inject.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// UserPromptSubmit hook: inject an aggregated session-memory summary so the +// loop survives compaction and long sessions — progress, deduplicated tried +// directions, best trajectory, recent runs with ASI, and a doom-loop warning. +import { rebuildState, readSessionConfig } from "../mcp/lib/ledger.mjs"; +import { resolveWorkCwd } from "../mcp/lib/paths.mjs"; +import { + directionLabel, + detectDoomLoop, + normalizeHypothesis, + hypothesesSimilar, +} from "../mcp/lib/experiment.mjs"; + +const projectCwd = process.argv[2] || process.cwd(); +const cwd = resolveWorkCwd(projectCwd); + +function pass() { + process.exit(0); +} + +let state; +try { + const cfg = readSessionConfig(projectCwd); + const max = Number(cfg.maxIterations); + state = rebuildState(cwd, { + maxIterations: Number.isFinite(max) && max > 0 ? max : 20, + }); +} catch { + pass(); +} +if (!state.config || state.runs.length === 0) pass(); + +const cfg = state.config; +const lines = []; + +// Progress line +lines.push( + `[autoresearch 记忆] segment ${state.segment}(metric=${cfg.metricName},direction=${cfg.direction ?? "lower"})` + + `已跑 ${state.runs.length}/${state.maxIterations ?? 20} 次,baseline=${state.baseline ?? "—"},best=${state.best ?? "—"}。`, +); + +// Deduplicated tried directions (similarity-based, most recent label kept) +const tried = []; +const triedNorm = []; +for (const r of state.runs) { + const label = directionLabel(r); + const n = normalizeHypothesis(label) ?? label; + if (triedNorm.some((t) => hypothesesSimilar(t, n))) continue; + triedNorm.push(n); + tried.push(label); +} +const triedList = tried.slice(-8); +if (triedList.length > 0) + lines.push(`已尝试方向:${triedList.join("、")}(避免重复尝试)。`); + +// Best trajectory: baseline → improving keeps (≤6 steps) +const kept = state.runs.filter((r) => r.status === "keep" && r.metric != null); +const steps = kept.filter((r) => r.metric !== state.baseline).slice(-6); +if (steps.length > 0) { + const traj = steps.map( + (r) => `${r.metric}(${directionLabel(r).slice(0, 14)})`, + ); + lines.push(`best 轨迹:${state.baseline ?? "—"} → ${traj.join(" → ")}。`); +} + +// Recent runs with ASI extraction +const recent = state.runs + .slice(-3) + .map((r) => { + let line = `#${r.run} ${r.status} metric=${r.metric ?? "—"} ${r.description ?? ""}`; + if (r.asi && typeof r.asi === "object") { + const parts = []; + if (r.asi.hypothesis) parts.push(`hyp: ${r.asi.hypothesis}`); + if (r.asi.next_action_hint) parts.push(`next: ${r.asi.next_action_hint}`); + if (r.asi.rollback) parts.push(`rollback: ${r.asi.rollback}`); + if (parts.length) line += "\n " + parts.join("\n "); + } + return line; + }) + .join("\n"); +lines.push(`最近记录:\n${recent}`); + +// Doom-loop warning +const doom = detectDoomLoop(state.runs); +if (doom) { + lines.push( + doom.pattern === "oscillate" + ? "⚠️ 检测到 A→B→A→B 震荡尝试——停止在两个方向上反复,换一个结构性不同的方向。" + : "⚠️ 检测到连续重复尝试——停止重复同一假设,换一个结构性不同的方向。", + ); +} + +lines.push( + "如果你在运行 autoresearch 循环,请基于上述进度选择下一个假设并继续 run_experiment → log_experiment。", +); + +process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: lines.join("\n"), + }, + }), +); diff --git a/plugins/autoresearch/hooks/permission-gate.mjs b/plugins/autoresearch/hooks/permission-gate.mjs new file mode 100644 index 0000000..d6bff83 --- /dev/null +++ b/plugins/autoresearch/hooks/permission-gate.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// PermissionRequest hook: approximate tool gate (pi-gap M3, #1). +// When the workspace has no active experiment session (.auto/log.jsonl), deny +// permission prompts for the experiment tools so the loop cannot be started by +// accident. With a session, allow. This is an approximation — calls that are +// auto-approved never reach PermissionRequest; tool-internal checks and the +// skill remain the backstop. +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { resolveWorkCwd } from "../mcp/lib/paths.mjs"; + +const projectCwd = process.argv[2] || process.cwd(); +const cwd = resolveWorkCwd(projectCwd); +const EXPERIMENT_TOOLS = new Set([ + "init_experiment", + "run_experiment", + "log_experiment", + "export_dashboard", + "clear_experiments", +]); + +let raw = ""; +process.stdin.setEncoding("utf8"); +for await (const chunk of process.stdin) raw += chunk; + +let input = {}; +try { + input = raw.trim() ? JSON.parse(raw) : {}; +} catch { + process.exit(0); // fail open +} + +const tool = input.tool_name || input.toolName || ""; +if (!EXPERIMENT_TOOLS.has(tool)) process.exit(0); + +const hasSession = existsSync(join(cwd, ".auto", "log.jsonl")); +if (hasSession) process.exit(0); + +process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PermissionRequest", + decision: { + behavior: "deny", + message: `[autoresearch] 当前工作区没有实验会话(.auto/log.jsonl 不存在),${tool} 已被拦截。请先通过 /autoresearch:autoresearch 建立会话。`, + }, + }, + }), +); diff --git a/plugins/autoresearch/hooks/session-start.mjs b/plugins/autoresearch/hooks/session-start.mjs new file mode 100644 index 0000000..c5f9a5a --- /dev/null +++ b/plugins/autoresearch/hooks/session-start.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// SessionStart hook: point the model at an existing autoresearch session +// (auto-activation prompt). Respects an explicit `autoresearchOff: true` +// decision in .auto/config.json — after /autoresearch:off no resume hint is +// injected, though the session can still be entered manually. +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveWorkCwd } from "../mcp/lib/paths.mjs"; + +const cwd = resolveWorkCwd(process.argv[2] || process.cwd()); +const log = join(cwd, ".auto", "log.jsonl"); +if (!existsSync(log)) process.exit(0); + +let off = false; +try { + const cfg = JSON.parse( + readFileSync(join(cwd, ".auto", "config.json"), "utf8"), + ); + off = cfg.autoresearchOff === true; +} catch { + /* no config → treat as active */ +} + +if (off) process.exit(0); + +process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: + `本工作区存在 autoresearch 会话(.auto/log.jsonl)。` + + `可用 /autoresearch:autoresearch 继续循环,或 /autoresearch:export 导出 dashboard;` + + `暂停可用 /autoresearch:off,重置可用 /autoresearch:clear。`, + }, + }), +); diff --git a/plugins/autoresearch/hooks/stop-continue.mjs b/plugins/autoresearch/hooks/stop-continue.mjs new file mode 100644 index 0000000..fcbc51c --- /dev/null +++ b/plugins/autoresearch/hooks/stop-continue.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Stop hook: keep the autoresearch loop running while it is not finished. +// zcode grants at most 3 consecutive Stop continuations per window, so this +// hook only fires when the ledger shows the loop should continue. +import { rebuildState, readSessionConfig } from "../mcp/lib/ledger.mjs"; +import { resolveWorkCwd } from "../mcp/lib/paths.mjs"; +import { isStopReached, detectDoomLoop } from "../mcp/lib/experiment.mjs"; + +const projectCwd = process.argv[2] || process.cwd(); +const cwd = resolveWorkCwd(projectCwd); + +function failOpen() { + process.exit(0); +} + +let state; +try { + const cfg = readSessionConfig(projectCwd); + const max = Number(cfg.maxIterations); + const fails = Number(cfg.consecutiveFailures); + state = rebuildState(cwd, { + maxIterations: Number.isFinite(max) && max > 0 ? max : 20, + consecutiveFailures: Number.isFinite(fails) && fails > 0 ? fails : 3, + }); +} catch { + failOpen(); +} + +// No active session → let the model finish normally. +if (!state.config || state.runs.length === 0) failOpen(); + +const finished = isStopReached( + state.runs, + state.maxIterations ?? 20, + state.failureThreshold, +); +if (finished) failOpen(); + +// Plateau convergence: recent runs improved < 1% → let the model wrap up. +if (state.plateau) { + const reason = + `[autoresearch] 循环已进入平台期(最近 5 轮改善 < 1%,best=${state.best ?? "—"})。` + + `建议:用 run_experiment repeat:3 复测确认,或 init_experiment 开启新 segment,或就此收尾总结。`; + process.stdout.write(JSON.stringify({ decision: "block", reason })); +} else { + const dir = state.config?.direction ?? "lower"; + const tail = state.runs + .slice(-3) + .map((r) => { + let line = `#${r.run} ${r.status} metric=${r.metric ?? "—"} ${r.description ?? ""}`; + if (r.asi && typeof r.asi === "object") { + const parts = []; + if (r.asi.hypothesis) parts.push(`hyp: ${r.asi.hypothesis}`); + if (r.asi.next_action_hint) + parts.push(`next: ${r.asi.next_action_hint}`); + if (r.asi.rollback) parts.push(`rollback: ${r.asi.rollback}`); + if (parts.length) line += "\n " + parts.join("\n "); + } + return line; + }) + .join("\n"); + + const reason = + `[autoresearch] 实验循环未结束:segment ${state.segment} 已跑 ${state.runs.length}/${state.maxIterations ?? 20} 次,` + + `direction=${dir},baseline=${state.baseline ?? "—"},best=${state.best ?? "—"}。` + + `最近记录:\n${tail}\n` + + (detectDoomLoop(state.runs) + ? `⚠️ 检测到重复/震荡尝试——停止重复同一假设,换一个结构性不同的方向。\n` + : "") + + `请继续下一个假设:修改代码 → run_experiment → log_experiment(keep/discard)。`; + + process.stdout.write(JSON.stringify({ decision: "block", reason })); +} diff --git a/plugins/autoresearch/mcp/lib/dashboard-server.mjs b/plugins/autoresearch/mcp/lib/dashboard-server.mjs new file mode 100644 index 0000000..cfa0889 --- /dev/null +++ b/plugins/autoresearch/mcp/lib/dashboard-server.mjs @@ -0,0 +1,74 @@ +// Local HTTP + SSE dashboard server, hosted inside the MCP server process. +// Routes: / (live HTML), /autoresearch.jsonl (ledger raw), /events (SSE). +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { rebuildState, readSessionConfig } from "./ledger.mjs"; +import { renderLiveDashboard } from "./dashboard.mjs"; + +const clients = new Set(); +let server = null; +let boundPort = null; + +function broadcast() { + for (const res of clients) { + res.write(`event: jsonl-updated\ndata: ${Date.now()}\n\n`); + } +} + +function start(workCwd) { + if (server) return { port: boundPort, url: `http://127.0.0.1:${boundPort}` }; + const srv = createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + if (url.pathname === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write("retry: 2000\n\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } + if (url.pathname === "/autoresearch.jsonl") { + const log = join(workCwd, ".auto", "log.jsonl"); + res.writeHead(200, { + "Content-Type": "application/x-ndjson; charset=utf-8", + }); + res.end(readFileSync(log, "utf8")); + return; + } + if (url.pathname === "/" || url.pathname === "") { + const state = rebuildState(workCwd, { + maxIterations: Number(readSessionConfig(workCwd).maxIterations) || 20, + }); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderLiveDashboard(state)); + return; + } + res.writeHead(404); + res.end("not found"); + }); + return new Promise((resolve, reject) => { + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + boundPort = addr.port; + server = srv; + resolve({ port: boundPort, url: `http://127.0.0.1:${boundPort}` }); + }); + }); +} + +export function ensureDashboardServer(workCwd) { + return start(workCwd); +} + +export function broadcastDashboardUpdate() { + if (server) broadcast(); +} + +export function dashboardServerInfo() { + return server ? { url: `http://127.0.0.1:${boundPort}` } : null; +} diff --git a/plugins/autoresearch/mcp/lib/dashboard.mjs b/plugins/autoresearch/mcp/lib/dashboard.mjs new file mode 100644 index 0000000..c5e3f24 --- /dev/null +++ b/plugins/autoresearch/mcp/lib/dashboard.mjs @@ -0,0 +1,126 @@ +// Static dashboard renderer: pure function from ledger state to self-contained HTML. +import { escapeHtml } from "./html.mjs"; +import { deltaFor } from "./ledger.mjs"; + +const STATUS_LABEL = { + keep: "keep", + discard: "discard", + crash: "crash", + checks_failed: "checks_failed", + noop: "no-op", +}; + +export function renderDashboard(state) { + return renderBody(state, false); +} + +/** Live variant: same body plus an SSE client that auto-reloads on updates. */ +export function renderLiveDashboard(state) { + return renderBody(state, true); +} + +function renderBody(state, live) { + const cfg = state.config; + const direction = cfg?.direction ?? "lower"; + const rows = state.runs + .map((r, i) => { + const delta = deltaFor(state, r.metric); + const deltaText = + delta == null ? "—" : (delta >= 0 ? "+" : "") + delta.toFixed(4); + const improved = delta != null && delta > 0; + return { + i: i + 1, + run: r, + deltaText, + improved, + cls: + r.status === "keep" + ? "keep" + : r.status === "discard" + ? "discard" + : "crash", + }; + }) + .reverse(); // newest first + + const kept = state.runs.filter((r) => r.status === "keep"); + const failures = state.runs.filter((r) => r.status !== "keep"); + const conf = state.confidence; + + return ` + + + + +autoresearch — ${escapeHtml(cfg?.name ?? "session")} + + + +

autoresearch · ${escapeHtml(cfg?.name ?? "session")}

+
+ ${cfg ? `metric ${escapeHtml(cfg.metricName ?? "")} · direction ${escapeHtml(direction)}` : "no session yet"} + ${cfg?.metricUnit ? ` · unit ${escapeHtml(cfg.metricUnit)}` : ""} +
+
+
${state.runs.length}
experiments
+
${kept.length}
kept
+
${failures.length}
reverted
+
${state.baseline ?? "—"}
baseline
+
${state.best ?? "—"}
best
+ ${conf ? `
${escapeHtml(conf.level)}
confidence
` : ""} +
+${ + rows.length === 0 + ? "

暂无实验记录。

" + : ` + + + +${rows + .map( + (r) => ` + + + + + + +`, + ) + .join("\n")} + +
#statusmetricΔ vs baselinecommitdescription
${r.i}${STATUS_LABEL[r.run.status] ?? escapeHtml(r.run.status)}${r.run.metric ?? "—"}${r.improved ? "▲" : "▼"} ${escapeHtml(r.deltaText)}${r.run.commit ? `${escapeHtml(r.run.commit)}` : "—"}${escapeHtml(r.run.description ?? "")}
` +} +${ + live + ? `` + : "" +} + +`; +} diff --git a/plugins/autoresearch/mcp/lib/experiment.mjs b/plugins/autoresearch/mcp/lib/experiment.mjs new file mode 100644 index 0000000..ce2be96 --- /dev/null +++ b/plugins/autoresearch/mcp/lib/experiment.mjs @@ -0,0 +1,226 @@ +// Pure functions for the autoresearch experiment loop. +// No I/O here so they are unit-testable without a workspace. + +export const METRIC_RE = /^METRIC\s+([\w.µ]+)=(\S+)$/; + +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** + * Parse `METRIC name=value` lines out of command output. + * Returns { metrics, primary } where primary is metrics[metricName] if present. + * Same-name keys: last wins. Dangerous key names are rejected. + */ +export function parseMetricLines(output, metricName) { + const metrics = {}; + let primary; + for (const line of String(output ?? "").split("\n")) { + const m = METRIC_RE.exec(line.trim()); + if (!m) continue; + const [, name, rawValue] = m; + if (FORBIDDEN_KEYS.has(name)) continue; + const value = Number(rawValue); + if (!Number.isFinite(value)) continue; + metrics[name] = value; + if (name === metricName) primary = value; + } + return { metrics, primary }; +} + +/** + * Direction-aware improvement test. + * direction: "lower" (default) or "higher". + */ +export function isBetter(current, best, direction = "lower") { + if (current == null || best == null) return false; + return direction === "higher" ? current > best : current < best; +} + +/** + * MAD-based noise floor for the current segment. + * Returns null when there are fewer than 3 data points or MAD is 0. + */ +export function median(values) { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +export function computeConfidence({ values, baseline, best }) { + if (values.length < 3 || baseline == null || best == null) return null; + const med = median(values); + if (med == null || med === 0) return null; + const deviations = values.map((v) => Math.abs(v - med)); + const mad = median(deviations); + if (mad == null || mad === 0) return null; + const ratio = Math.abs(best - baseline) / mad; + let level = "red"; + if (ratio >= 2.0) level = "green"; + else if (ratio >= 1.0) level = "yellow"; + return { confidence: ratio, level }; +} + +/** + * Enforce that a run_experiment command is (a wrapper around) the benchmark + * script. Strips leading `FOO=bar` assignments and `env/time/nice/nohup` + * wrappers, then requires the core command to start with the measure script + * path. Returns the unwrapped command string, or null when the command is + * not the benchmark script. Prevents `evil; ./measure.sh` chained injection. + */ +const WRAP_RE = /^(env|time|nice|nohup)\s+/; +// leading env assignment including its value: `FOO=1 ` or `FOO="a b" ` +const ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=\S*\s*/; + +export function unwrapMeasureCommand(command, measureScript) { + let cmd = String(command ?? "").trim(); + if (!cmd) return null; + // Strip leading env assignments and wrapper prefixes, alternating, until + // stable (`env X=1 bash .auto/measure.sh` needs env→assignment→bash). + let prev; + do { + prev = cmd; + cmd = cmd.replace(ASSIGN_RE, "").trim(); + cmd = cmd.replace(WRAP_RE, "").trim(); + } while (cmd !== prev); + if (!cmd) return null; + // 3) core must be the measure script itself (optional ./ and .auto/ prefix, + // optional bash wrapper) + const variants = [ + measureScript, + `./${measureScript}`, + `.auto/${measureScript}`, + `./.auto/${measureScript}`, + `bash ${measureScript}`, + `bash ./${measureScript}`, + `bash .auto/${measureScript}`, + `bash ./.auto/${measureScript}`, + ]; + const match = variants.find((v) => cmd === v || cmd.startsWith(v + " ")); + if (!match) return null; + // 4) no shell metacharacters after the script (rejects `; evil` chaining) + const rest = cmd.slice(match.length).trim(); + if (/[;&|`]/.test(rest) || rest.includes("$(")) return null; + return cmd; +} + +/** + * Decide whether the loop has reached a stop condition. + * Stop when: current segment runs >= maxIterations, or the last N (default 3) + * results are all failures (discard/crash/checks_failed). + */ +export function isStopReached(runs, maxIterations, consecutiveFailures = 3) { + if (maxIterations != null && runs.length >= maxIterations) return true; + if (runs.length === 0) return false; + const tail = runs.slice(-consecutiveFailures); + return ( + tail.length >= consecutiveFailures && tail.every((r) => r.status !== "keep") + ); +} + +/** + * Normalize a hypothesis/description for comparison: lowercase, strip + * non-alphanumerics, sort tokens. Returns null when there is no signal. + */ +export function normalizeHypothesis(text) { + const tokens = String(text ?? "") + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ") + .split(/\s+/) + .filter((t) => t.length >= 2); + if (tokens.length === 0) return null; + return [...tokens].sort().join(" "); +} + +/** Jaccard similarity of two normalized hypotheses (token sets), or subset. */ +export function hypothesesSimilar(a, b) { + if (a === b) return true; + if (!a || !b) return false; + const A = new Set(a.split(" ")); + const B = new Set(b.split(" ")); + const inter = [...A].filter((t) => B.has(t)).length; + const union = new Set([...A, ...B]).size; + if (inter === Math.min(A.size, B.size)) return true; // one is a subset + return union > 0 && inter / union >= 0.5; +} + +/** + * Direction label for a run: prefer asi.hypothesis, else description; take the + * leading clause (up to first comma/period/semicolon), capped at 40 chars. + */ +export function directionLabel(run) { + const raw = run?.asi?.hypothesis || run?.description || ""; + const clause = + String(raw) + .split(/[,.;,。;]/)[0] + ?.trim() || ""; + return clause.length > 40 + ? clause.slice(0, 40) + "…" + : clause || (run?.status ?? "?"); +} + +/** + * Doom-loop detection (ml-intern idea, text-layer): repeated or oscillating + * hypotheses. Returns { doomLoop, pattern } or null. + * - repeat: last 3 runs have similar normalized hypotheses. + * - oscillate: last 4 runs are [X, Y, X, Y] (X~X, Y~Y, X!~Y). + */ +export function detectDoomLoop(runs, { window = 6 } = {}) { + const norm = runs + .filter((r) => r.description || r?.asi?.hypothesis) + .slice(-window) + .map((r) => normalizeHypothesis(r.asi?.hypothesis || r.description)) + .filter(Boolean); + if (norm.length < 3) return null; + + // 3+ consecutive repeats (needs 3) + const last3 = norm.slice(-3); + if ( + hypothesesSimilar(last3[0], last3[1]) && + hypothesesSimilar(last3[1], last3[2]) + ) { + return { doomLoop: true, pattern: "repeat" }; + } + + // A→B→A→B oscillation (needs 4) + if (norm.length >= 4) { + const [a, b, c, d] = norm.slice(-4); + if ( + hypothesesSimilar(a, c) && + hypothesesSimilar(b, d) && + !hypothesesSimilar(a, b) + ) { + return { doomLoop: true, pattern: "oscillate" }; + } + } + + return null; +} + +/** + * Plateau detection: within the last `window` runs with a valid metric, the + * best direction-aware improvement relative to the window's first metric is + * below `minImprovement`. Returns false when there are fewer than `window` + * valid records (not enough data to judge). + */ +export function detectPlateau( + runs, + { window = 5, minImprovement = 0.01, direction = "lower" } = {}, +) { + const valid = runs + .filter((r) => r.metric != null && Number.isFinite(r.metric)) + .slice(-window); + if (valid.length < window) return false; + const first = valid[0].metric; + let best = first; + for (const r of valid) { + if (direction === "higher" ? r.metric > best : r.metric < best) + best = r.metric; + } + const improvement = + first === 0 + ? Math.abs(best - first) + : Math.abs(best - first) / Math.abs(first); + return improvement < minImprovement; +} diff --git a/plugins/autoresearch/mcp/lib/git.mjs b/plugins/autoresearch/mcp/lib/git.mjs new file mode 100644 index 0000000..b76cc57 --- /dev/null +++ b/plugins/autoresearch/mcp/lib/git.mjs @@ -0,0 +1,90 @@ +// Git operations for the experiment loop (ADR-2 semantics): +// keep → commit with `experiment:` prefix + structured Result JSON. +// discard/crash/checks_failed → drop working-tree changes, exempt `.auto/`. +import { execFileSync } from "node:child_process"; + +export function git(cwd, args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +export function isGitRepo(cwd) { + try { + execFileSync("git", ["rev-parse", "--git-dir"], { cwd, stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export function isDirty(cwd) { + try { + return git(cwd, ["status", "--porcelain"]).length > 0; + } catch { + return false; + } +} + +export function shortHash(cwd) { + return git(cwd, ["rev-parse", "--short=7", "HEAD"]); +} + +/** + * Commit all tracked+untracked changes as one experiment. + * Returns the short hash, or null when there is nothing to commit. + */ +export function commitExperiment(cwd, { description, result }) { + git(cwd, ["add", "-A"]); + // git diff --cached --quiet exits 0 when there are no staged changes. + const hasStaged = (() => { + try { + execFileSync("git", ["diff", "--cached", "--quiet"], { + cwd, + stdio: "ignore", + }); + return false; + } catch { + return true; + } + })(); + if (!hasStaged) return null; + const body = `experiment: ${description}\n\nResult: ${JSON.stringify(result)}`; + execFileSync("git", ["commit", "-m", body], { cwd, stdio: "ignore" }); + return shortHash(cwd); +} + +/** + * Discard every working-tree + index change while keeping the `.auto/` + * session directory intact. Uses `checkout HEAD` (not `checkout --`) so + * staged-but-uncommitted experiment changes are reverted to HEAD too. + */ +export function rollbackWorkingTree(cwd) { + execFileSync( + "git", + ["checkout", "HEAD", "--", ".", ":(exclude,glob)**/.auto/**"], + { cwd, stdio: "ignore" }, + ); + // Unstage anything (e.g. accidentally staged .auto content) without touching files. + execFileSync("git", ["reset", "-q"], { cwd, stdio: "ignore" }); + execFileSync( + "git", + [ + "clean", + "-fd", + "-e", + ".auto", + "-e", + ".auto/", + "-e", + "autoresearch-dashboard.html", + ], + { cwd, stdio: "ignore" }, + ); +} + +export function currentBranch(cwd) { + try { + return git(cwd, ["branch", "--show-current"]); + } catch { + return ""; + } +} diff --git a/plugins/autoresearch/mcp/lib/html.mjs b/plugins/autoresearch/mcp/lib/html.mjs new file mode 100644 index 0000000..e7d1d42 --- /dev/null +++ b/plugins/autoresearch/mcp/lib/html.mjs @@ -0,0 +1,8 @@ +export function escapeHtml(s) { + return String(s ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/plugins/autoresearch/mcp/lib/ledger.mjs b/plugins/autoresearch/mcp/lib/ledger.mjs new file mode 100644 index 0000000..3304a11 --- /dev/null +++ b/plugins/autoresearch/mcp/lib/ledger.mjs @@ -0,0 +1,156 @@ +// `.auto/` ledger: append-only JSONL source of truth + segment rebuild. +import { + readFileSync, + writeFileSync, + appendFileSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { join } from "node:path"; +import { computeConfidence, isBetter, detectPlateau } from "./experiment.mjs"; + +export const AUTO_DIR = ".auto"; +export const LOG_FILE = join(AUTO_DIR, "log.jsonl"); +export const PROMPT_FILE = join(AUTO_DIR, "prompt.md"); +export const MEASURE_FILE = join(AUTO_DIR, "measure.sh"); +export const CHECKS_FILE = join(AUTO_DIR, "checks.sh"); +export const CONFIG_FILE = join(AUTO_DIR, "config.json"); +export const IDEAS_FILE = join(AUTO_DIR, "ideas.md"); +export const DASHBOARD_FILE = "autoresearch-dashboard.html"; + +export function autoPaths(cwd) { + return { + root: join(cwd, AUTO_DIR), + log: join(cwd, LOG_FILE), + prompt: join(cwd, PROMPT_FILE), + measure: join(cwd, MEASURE_FILE), + checks: join(cwd, CHECKS_FILE), + config: join(cwd, CONFIG_FILE), + ideas: join(cwd, IDEAS_FILE), + dashboard: join(cwd, DASHBOARD_FILE), + }; +} + +export function ensureAutoDir(cwd) { + mkdirSync(join(cwd, AUTO_DIR), { recursive: true }); +} + +export function appendLedgerEntry(cwd, entry) { + ensureAutoDir(cwd); + appendFileSync(join(cwd, LOG_FILE), JSON.stringify(entry) + "\n", "utf8"); +} + +export function readLedger(cwd) { + const log = join(cwd, LOG_FILE); + if (!existsSync(log)) return []; + return readFileSync(log, "utf8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => { + try { + return JSON.parse(l); + } catch { + return null; + } + }) + .filter(Boolean); +} + +/** + * Rebuild the session state from the ledger file. + * - segment advances on every `config` entry. + * - runs after a config entry belong to that config's segment. + * - baseline = first run's primary metric in the segment. + * - best = best kept run's metric in the segment (direction-aware). + */ +export function rebuildState(cwd, options = {}) { + const entries = readLedger(cwd); + const state = { + config: null, + segment: 0, + runs: [], + baseline: null, + best: null, + lastRunChecksFailed: false, + lastRun: null, + totalExperiments: 0, + consecutiveFailures: 0, + }; + for (const e of entries) { + if (e.type === "config") { + state.config = e; + state.segment = e.segment ?? state.segment + 1; + state.runs = []; + state.baseline = null; + state.best = null; + } else if (e.type === "run") { + const run = { ...e, segment: state.segment, config: state.config }; + state.runs.push(run); + state.totalExperiments += 1; + if (state.baseline == null && run.metric != null) + state.baseline = run.metric; + if (run.status === "keep" && run.metric != null) { + const dir = state.config?.direction ?? "lower"; + if (state.best == null || isBetter(run.metric, state.best, dir)) + state.best = run.metric; + } + if (run.status === "keep") state.consecutiveFailures = 0; + else state.consecutiveFailures += 1; + if (run.checksFailed) state.lastRunChecksFailed = true; + state.lastRun = run; + } + } + // Confidence over the current segment's values. + const values = state.runs + .map((r) => r.metric) + .filter((v) => v != null && Number.isFinite(v)); + if (state.config && values.length > 0) { + state.confidence = computeConfidence({ + values, + baseline: state.baseline, + best: state.best, + }); + } else { + state.confidence = null; + } + if (options.maxIterations != null) + state.maxIterations = options.maxIterations; + state.failureThreshold = options.consecutiveFailures ?? 3; + // Plateau over the current segment's recent runs. + if (state.config && state.runs.length >= (options.plateauWindow ?? 5)) { + state.plateau = detectPlateau(state.runs, { + window: options.plateauWindow ?? 5, + minImprovement: options.plateauMinImprovement ?? 0.01, + direction: state.config.direction ?? "lower", + }); + } else { + state.plateau = false; + } + return state; +} + +/** + * Delta of a run's metric against the segment baseline, direction-aware: + * positive = improvement (lower metric + baseline was higher, or higher metric). + */ +export function deltaFor(state, metric) { + if (metric == null || state.baseline == null) return null; + const dir = state.config?.direction ?? "lower"; + const raw = metric - state.baseline; + return dir === "higher" ? raw : -raw; +} + +export function readSessionConfig(cwd) { + const cfg = join(cwd, CONFIG_FILE); + if (!existsSync(cfg)) return {}; + try { + return JSON.parse(readFileSync(cfg, "utf8")); + } catch { + return {}; + } +} + +export function writeDashboard(cwd, html) { + writeFileSync(join(cwd, DASHBOARD_FILE), html, "utf8"); + return join(cwd, DASHBOARD_FILE); +} diff --git a/plugins/autoresearch/mcp/lib/paths.mjs b/plugins/autoresearch/mcp/lib/paths.mjs new file mode 100644 index 0000000..426ad2f --- /dev/null +++ b/plugins/autoresearch/mcp/lib/paths.mjs @@ -0,0 +1,21 @@ +// Resolve the effective research directory. `.auto/config.json` in the project +// dir may set `workingDir` (relative to the project or absolute); when it +// exists, all experiment operations happen there (config stays in the project). +import { readFileSync, statSync } from "node:fs"; +import { resolve, isAbsolute, join } from "node:path"; + +export function resolveWorkCwd(projectCwd) { + try { + const cfg = JSON.parse( + readFileSync(join(projectCwd, ".auto", "config.json"), "utf8"), + ); + const wd = cfg.workingDir; + if (typeof wd === "string" && wd.trim()) { + const target = isAbsolute(wd) ? wd : resolve(projectCwd, wd); + if (statSync(target).isDirectory()) return target; + } + } catch { + /* no config or no workingDir → project dir */ + } + return projectCwd; +} diff --git a/plugins/autoresearch/mcp/lib/validate.mjs b/plugins/autoresearch/mcp/lib/validate.mjs new file mode 100644 index 0000000..f3cc7bc --- /dev/null +++ b/plugins/autoresearch/mcp/lib/validate.mjs @@ -0,0 +1,111 @@ +// Ledger audit invariants (leo-inspired): the ledger must be a replayable +// state machine. Pure function, zero I/O, unit-testable. +import { isBetter } from "./experiment.mjs"; + +const VALID_STATUS = new Set([ + "keep", + "discard", + "crash", + "checks_failed", + "noop", +]); + +/** + * Validate a run sequence against the session config. Returns a list of + * { code, run, message } violations; empty list means the ledger is sound. + * + * Invariants: + * - event order: run numbers contiguous, segment matches config, status valid + * - baseline first: config must precede any run + * - keep must improve: every keep after the baseline must beat the current + * retained metric (direction-aware) + * - a discarded improvement needs a failed guard: if a non-keep run's metric + * beats the retained value, only `checks_failed` may discard it + * - commit field: keep rows must carry a commit, non-keep rows must not + */ +export function validateLedger(runs, config) { + const violations = []; + const direction = config?.direction ?? "lower"; + let retained = null; // current best kept metric (null until first keep) + let expectedRun = 1; + + const push = (code, run, message) => violations.push({ code, run, message }); + + for (const r of runs) { + if (r.type === "config") { + expectedRun = 1; // a new segment restarts run numbering + continue; + } + + // ---- event order / baseline ---- + if (r.type !== "run") { + push("event_order", r.run, `unknown row type ${r.type}`); + continue; + } + if (!VALID_STATUS.has(r.status)) { + push("event_order", r.run, `invalid status ${r.status}`); + } + if (r.run !== expectedRun) { + push( + "event_order", + r.run, + `run number ${r.run} != expected ${expectedRun}`, + ); + } + expectedRun += 1; + if (r.segment !== config?.segment) { + push( + "event_order", + r.run, + `segment ${r.segment} != config segment ${config?.segment}`, + ); + } + + // ---- commit field consistency (non-keep rows must not carry a commit; + // keep rows' commit is generated by the tool after the git step) ---- + if (r.status !== "keep" && r.commit) { + push("commit_field", r.run, "non-keep row must not carry a commit"); + } + + // ---- keep must improve / discard needs failed guard ---- + const metric = r.metric; + if (metric == null || !Number.isFinite(metric)) { + if (r.status !== "crash") + push("event_order", r.run, "non-crash row missing metric"); + continue; + } + + if (r.status === "keep") { + if (retained == null) { + retained = metric; // baseline / first keep + } else if (!isBetter(metric, retained, direction)) { + push( + "keep_without_improvement", + r.run, + `keep metric ${metric} does not beat retained ${retained} (${direction})`, + ); + } else { + retained = metric; + } + } else if (r.status === "noop") { + // noop does not change the retained value and needs no commit + } else if (r.status === "crash") { + // crash metric (0) is a placeholder — no measurement semantics + } else { + // discard / checks_failed + if ( + retained != null && + isBetter(metric, retained, direction) && + r.status !== "checks_failed" + ) { + push( + "discarded_improvement", + r.run, + `metric ${metric} beats retained ${retained} but status is ${r.status}, not checks_failed`, + ); + } + } + } + + return violations; +} diff --git a/plugins/autoresearch/mcp/server.mjs b/plugins/autoresearch/mcp/server.mjs new file mode 100644 index 0000000..3d90a62 --- /dev/null +++ b/plugins/autoresearch/mcp/server.mjs @@ -0,0 +1,957 @@ +#!/usr/bin/env node +// zcode-autoresearch MCP server (stdio, newline-delimited JSON-RPC). +// Tools: init_experiment / run_experiment / log_experiment / export_dashboard. +// Design: experiment/autoresearch + ADR-1 (MCP tools carry mechanism). +import { spawn } from "node:child_process"; +import { + appendFileSync, + existsSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + parseMetricLines, + unwrapMeasureCommand, + median, + detectDoomLoop, +} from "./lib/experiment.mjs"; +import { + autoPaths, + appendLedgerEntry, + rebuildState, + readSessionConfig, + writeDashboard, +} from "./lib/ledger.mjs"; +import { + commitExperiment, + rollbackWorkingTree, + isGitRepo, + isDirty, + currentBranch, +} from "./lib/git.mjs"; +import { renderDashboard } from "./lib/dashboard.mjs"; +import { resolveWorkCwd } from "./lib/paths.mjs"; +import { validateLedger } from "./lib/validate.mjs"; +import { + ensureDashboardServer, + broadcastDashboardUpdate, +} from "./lib/dashboard-server.mjs"; + +const projectCwd = process.cwd(); +// Effective research directory: `.auto/config.json` may set workingDir. +const cwd = resolveWorkCwd(projectCwd); +const paths = autoPaths(cwd); + +const envMax = Number(process.env.AR_MAX_ITERATIONS); +const DEFAULT_MAX_ITERATIONS = + Number.isFinite(envMax) && envMax > 0 ? envMax : 20; +const BENCHMARK_TIMEOUT_MS = + Number(process.env.AR_BENCHMARK_TIMEOUT_MS) || 600_000; +const CHECKS_TIMEOUT_MS = Number(process.env.AR_CHECKS_TIMEOUT_MS) || 300_000; + +// LLM-facing output budget (mirrors pi-autoresearch): tight truncation. +const LLM_MAX_LINES = 10; +const LLM_MAX_BYTES = 4096; + +// --------------------------------------------------------------------------- +// JSON-RPC transport (newline-delimited on stdout; logs on stderr) +// --------------------------------------------------------------------------- + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + "\n"); +} + +function result(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +function error(id, code, message) { + send({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function log(...args) { + process.stderr.write(`[autoresearch] ${args.join(" ")}\n`); +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function maxIterations() { + const cfg = readSessionConfig(projectCwd); + const v = Number(cfg.maxIterations); + return Number.isFinite(v) && v > 0 ? v : DEFAULT_MAX_ITERATIONS; +} + +function consecutiveFailures() { + const cfg = readSessionConfig(projectCwd); + const v = Number(cfg.consecutiveFailures); + return Number.isFinite(v) && v > 0 ? v : 3; +} + +function sessionState() { + return rebuildState(cwd, { + maxIterations: maxIterations(), + consecutiveFailures: consecutiveFailures(), + }); +} + +function truncateTail( + text, + maxLines = LLM_MAX_LINES, + maxBytes = LLM_MAX_BYTES, +) { + if (text == null) return ""; + const lines = String(text).split("\n"); + let out = lines.slice(-maxLines).join("\n"); + if (Buffer.byteLength(out, "utf8") > maxBytes) { + out = Buffer.from(out, "utf8").subarray(0, maxBytes).toString("utf8"); + } + return out; +} + +function runCommand(command, timeoutMs) { + return new Promise((resolve) => { + const started = Date.now(); + const proc = spawn("bash", ["-c", command], { + cwd, + detached: true, // own process group so we can kill the tree + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks = []; + let totalBytes = 0; + let logFile = null; + proc.stdout.on("data", (d) => { + chunks.push(d); + totalBytes += d.length; + if (totalBytes > 2 * 1024 * 1024 && !logFile) { + logFile = join( + tmpdir(), + `pi-experiment-${process.pid}-${Date.now()}.log`, + ); + writeFileSync(logFile, Buffer.concat(chunks)); + chunks.length = 0; + log("output overflowed, spilling to", logFile); + } + }); + proc.stderr.on("data", (d) => { + chunks.push(d); + totalBytes += d.length; + if (totalBytes > 2 * 1024 * 1024 && !logFile) { + logFile = join( + tmpdir(), + `pi-experiment-${process.pid}-${Date.now()}.log`, + ); + writeFileSync(logFile, Buffer.concat(chunks)); + chunks.length = 0; + } + }); + let didTimeout = false; + const kill = () => { + didTimeout = true; + try { + process.kill(-proc.pid, "SIGTERM"); + } catch { + /* already gone */ + } + }; + const timer = setTimeout(kill, timeoutMs); + proc.on("close", (code, signal) => { + clearTimeout(timer); + const elapsed = Date.now() - started; + const all = Buffer.concat(chunks).toString("utf8"); + if (logFile) { + try { + appendFileSync(logFile, all); + } catch { + /* ignore */ + } + } + resolve({ + exitCode: code, + signal, + durationMs: elapsed, + output: logFile ? "" : all, + logFile, + timedOut: didTimeout, + }); + }); + }); +} + +async function runChecks(checksFile) { + const res = await runCommand(`bash ${checksFile}`, CHECKS_TIMEOUT_MS); + return { + failed: res.exitCode !== 0, + exitCode: res.exitCode, + durationMs: res.durationMs, + outputTail: truncateTail(res.output, 80, 4096), + }; +} + +// --------------------------------------------------------------------------- +// Iteration hooks (.auto/hooks/before.sh / after.sh) — pi-gap M1 (#23) +// --------------------------------------------------------------------------- + +const HOOK_TIMEOUT_MS = 30_000; +const HOOK_MAX_BYTES = 8 * 1024; + +function isExecutable(file) { + if (!existsSync(file)) return false; + try { + return (statSync(file).mode & 0o111) !== 0; + } catch { + return false; + } +} + +// --- benchmark drift detection (frozen-file hashes) ------------------------- +function sha256File(file) { + if (!existsSync(file)) return null; + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function currentBenchmarkHashes() { + return { + measure: sha256File(paths.measure), + checks: sha256File(paths.checks), + }; +} + +function readBenchmarkHashes() { + try { + return readSessionConfig(projectCwd).benchmarkHashes ?? null; + } catch { + return null; + } +} + +function writeBenchmarkHashes(hashes) { + const cfgPath = join(projectCwd, ".auto", "config.json"); + let cfg = {}; + try { + cfg = JSON.parse(readFileSync(cfgPath, "utf8")); + } catch { + /* start fresh */ + } + cfg.benchmarkHashes = hashes; + writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); +} + +/** + * Compare current frozen-file hashes against the session's recorded ones. + * Returns { drift, reason } where drift is true when a recorded hash changed. + * First sighting (recorded null but file exists) records and reports "recorded". + */ +function checkBenchmarkDrift() { + const recorded = readBenchmarkHashes(); + const current = currentBenchmarkHashes(); + if (!recorded) { + writeBenchmarkHashes(current); + return { drift: false, reason: "recorded", hashes: current }; + } + for (const key of ["measure", "checks"]) { + if ( + recorded[key] != null && + current[key] != null && + recorded[key] !== current[key] + ) { + return { drift: true, reason: key, hashes: current }; + } + } + return { drift: false, reason: null, hashes: current }; +} + +/** + * Run an iteration hook: bash ` : "" diff --git a/plugins/autoresearch/mcp/lib/experiment.ts b/plugins/autoresearch/mcp/lib/experiment.ts index 1e78d26..0698102 100644 --- a/plugins/autoresearch/mcp/lib/experiment.ts +++ b/plugins/autoresearch/mcp/lib/experiment.ts @@ -118,16 +118,21 @@ export function unwrapMeasureCommand( ]; const match = variants.find((v) => cmd === v || cmd.startsWith(v + " ")); if (!match) return null; - // 4) no shell metacharacters after the script (rejects `; evil` chaining) - const rest = cmd.slice(match.length).trim(); - if (/[;&|`]/.test(rest) || rest.includes("$(")) return null; + // 4) args after the script must be plain tokens — whitelist characters only. + // A blacklist cannot enumerate the shell injection surface (newlines, CR, + // redirection, quotes, backticks, $, globs, ...), so anything outside + // [word chars . / : = + - space tab] is rejected. + const rest = cmd.slice(match.length); + if (!/^[\w./:=+ \t-]*$/.test(rest)) return null; return cmd; } /** * Decide whether the loop has reached a stop condition. - * Stop when: current segment runs >= maxIterations, or the last N (default 3) - * results are all failures (discard/crash/checks_failed). + * Stop when: current segment runs >= maxIterations, or the trailing run of + * real failures (discard/crash/checks_failed) reaches consecutiveFailures. + * noop neither counts as a failure nor keeps the streak alive — it breaks + * the chain, like keep does. */ export function isStopReached( runs: RunLike[], @@ -136,10 +141,13 @@ export function isStopReached( ): boolean { if (maxIterations != null && runs.length >= maxIterations) return true; if (runs.length === 0) return false; - const tail = runs.slice(-consecutiveFailures); - return ( - tail.length >= consecutiveFailures && tail.every((r) => r.status !== "keep") - ); + let streak = 0; + for (let i = runs.length - 1; i >= 0; i--) { + const s = runs[i].status; + if (s === "discard" || s === "crash" || s === "checks_failed") streak += 1; + else break; + } + return streak >= consecutiveFailures; } /** diff --git a/plugins/autoresearch/mcp/lib/git.ts b/plugins/autoresearch/mcp/lib/git.ts index 2767915..d161b85 100644 --- a/plugins/autoresearch/mcp/lib/git.ts +++ b/plugins/autoresearch/mcp/lib/git.ts @@ -16,9 +16,17 @@ export function isGitRepo(cwd: string): boolean { } } +/** + * Whether the working tree has real (non-`.auto/`) changes. Session-file + * writes (ledger, config) must not count: e.g. the crash-unresolved gate + * would otherwise block forever right after logging the crash itself. + */ export function isDirty(cwd: string): boolean { try { - return git(cwd, ["status", "--porcelain"]).length > 0; + return ( + git(cwd, ["status", "--porcelain", "--", ".", ":(exclude).auto"]).length > + 0 + ); } catch { return false; } @@ -29,14 +37,16 @@ export function shortHash(cwd: string): string { } /** - * Commit all tracked+untracked changes as one experiment. + * Commit all tracked+untracked changes as one experiment, excluding the + * `.auto/` session dir (ledger noise must not ride along, and a keep with + * only session-file changes must hit the "nothing to commit" path). * Returns the short hash, or null when there is nothing to commit. */ export function commitExperiment( cwd: string, { description, result }: { description: string; result: unknown }, ): string | null { - git(cwd, ["add", "-A"]); + git(cwd, ["add", "-A", "--", ".", ":(exclude).auto"]); // git diff --cached --quiet exits 0 when there are no staged changes. const hasStaged = (() => { try { diff --git a/plugins/autoresearch/mcp/lib/ledger.ts b/plugins/autoresearch/mcp/lib/ledger.ts index 997f666..3666ad8 100644 --- a/plugins/autoresearch/mcp/lib/ledger.ts +++ b/plugins/autoresearch/mcp/lib/ledger.ts @@ -124,9 +124,16 @@ export function rebuildState( if (state.best == null || isBetter(run.metric, state.best, dir)) state.best = run.metric; } - if (run.status === "keep") state.consecutiveFailures = 0; + // Consecutive-failure streak (guardrails spec): only real failures + // count; keep and noop both break the chain. + if (run.status === "keep" || run.status === "noop") + state.consecutiveFailures = 0; else state.consecutiveFailures += 1; - if (run.checksFailed) state.lastRunChecksFailed = true; + // Per-run overwrite (no one-way latch): the flag always describes the + // latest ledger run. Status alone marks checks failure; the explicit + // field covers hand-written/legacy rows. + state.lastRunChecksFailed = + run.checksFailed === true || run.status === "checks_failed"; state.lastRun = run; } } diff --git a/plugins/autoresearch/mcp/lib/types.ts b/plugins/autoresearch/mcp/lib/types.ts index 0c32c8a..fd33406 100644 --- a/plugins/autoresearch/mcp/lib/types.ts +++ b/plugins/autoresearch/mcp/lib/types.ts @@ -101,5 +101,7 @@ export interface SessionConfig { auditBypass?: boolean; autoresearchOff?: boolean; benchmarkHashes?: { measure: string | null; checks: string | null } | null; + /** Server-managed: checks outcome of the latest run_experiment (keep gate). */ + pendingChecksFailed?: boolean; [key: string]: unknown; } diff --git a/plugins/autoresearch/mcp/lib/validate.ts b/plugins/autoresearch/mcp/lib/validate.ts index 3920c1a..f098dd1 100644 --- a/plugins/autoresearch/mcp/lib/validate.ts +++ b/plugins/autoresearch/mcp/lib/validate.ts @@ -114,7 +114,8 @@ export function validateLedger( } else if (r.status === "noop") { // noop does not change the retained value and needs no commit } else if (r.status === "crash") { - // crash metric (0) is a placeholder — no measurement semantics + // crash metric is null — a crash measured nothing, so it must not + // touch the retained value (legacy rows carrying 0 are tolerated) } else { // discard / checks_failed if ( diff --git a/plugins/autoresearch/mcp/server.ts b/plugins/autoresearch/mcp/server.ts index 4c06050..1bf374e 100644 --- a/plugins/autoresearch/mcp/server.ts +++ b/plugins/autoresearch/mcp/server.ts @@ -58,7 +58,10 @@ interface RunOutcome { exitCode: number | null; signal: NodeJS.Signals | null; durationMs: number; + /** Metric-parseable text: the full output, or just the METRIC lines when the output spilled to a file. */ output: string; + /** Display-tail source: the full output, or a bounded tail when spilled. */ + outputTail: string; logFile: string | null; timedOut: boolean; } @@ -170,6 +173,15 @@ function truncateTail( return out; } +// Output accounting: under the spill threshold the full output stays in +// memory; once it spills, data streams straight to the spill file and only +// the METRIC lines (scanned incrementally, position-independent) plus a +// bounded tail survive in memory. +const SPILL_THRESHOLD_BYTES = 2 * 1024 * 1024; +const TAIL_CAP_BYTES = 64 * 1024; +const MAX_METRIC_LINES = 1000; +const KILL_GRACE_MS = 5_000; + function runCommand(command: string, timeoutMs: number): Promise { return new Promise((resolve) => { const started = Date.now(); @@ -178,35 +190,59 @@ function runCommand(command: string, timeoutMs: number): Promise { detached: true, // own process group so we can kill the tree stdio: ["ignore", "pipe", "pipe"], }); - const chunks: Buffer[] = []; + const chunks: Buffer[] = []; // full output, only while under the threshold let totalBytes = 0; let logFile: string | null = null; - proc.stdout.on("data", (d: Buffer) => { - chunks.push(d); + const tail: Buffer[] = []; + let tailBytes = 0; + const metricLines: string[] = []; + let carry = ""; // partial line carried between data events + const onData = (d: Buffer) => { totalBytes += d.length; - if (totalBytes > 2 * 1024 * 1024 && !logFile) { - logFile = join( - tmpdir(), - `pi-experiment-${process.pid}-${Date.now()}.log`, - ); - writeFileSync(logFile, Buffer.concat(chunks)); - chunks.length = 0; - log("output overflowed, spilling to", logFile); + if (!logFile) { + chunks.push(d); + if (totalBytes > SPILL_THRESHOLD_BYTES) { + logFile = join( + tmpdir(), + `pi-experiment-${process.pid}-${Date.now()}.log`, + ); + writeFileSync(logFile, Buffer.concat(chunks)); + chunks.length = 0; + log("output overflowed, spilling to", logFile); + } + } else { + try { + appendFileSync(logFile, d); + } catch { + /* ignore */ + } } - }); - proc.stderr.on("data", (d: Buffer) => { - chunks.push(d); - totalBytes += d.length; - if (totalBytes > 2 * 1024 * 1024 && !logFile) { - logFile = join( - tmpdir(), - `pi-experiment-${process.pid}-${Date.now()}.log`, - ); - writeFileSync(logFile, Buffer.concat(chunks)); - chunks.length = 0; + tail.push(d); + tailBytes += d.length; + while (tailBytes > TAIL_CAP_BYTES && tail.length > 0) { + const excess = tailBytes - TAIL_CAP_BYTES; + const first = tail[0]; + if (first.length <= excess) { + tailBytes -= first.length; + tail.shift(); + } else { + tail[0] = first.subarray(excess); + tailBytes -= excess; + } } - }); + carry += d.toString("utf8"); + const lines = carry.split("\n"); + carry = lines.pop() ?? ""; + if (carry.length > TAIL_CAP_BYTES) carry = carry.slice(-TAIL_CAP_BYTES); + for (const line of lines) { + if (metricLines.length < MAX_METRIC_LINES && line.startsWith("METRIC ")) + metricLines.push(line); + } + }; + proc.stdout.on("data", onData); + proc.stderr.on("data", onData); let didTimeout = false; + let killTimer: NodeJS.Timeout | null = null; const kill = () => { didTimeout = true; try { @@ -214,24 +250,28 @@ function runCommand(command: string, timeoutMs: number): Promise { } catch { /* already gone */ } + // A benchmark that traps/ignores SIGTERM must not hang the tool call: + // escalate to SIGKILL (uncatchable) on the whole process group. + killTimer = setTimeout(() => { + try { + if (proc.pid != null) process.kill(-proc.pid, "SIGKILL"); + } catch { + /* already gone */ + } + }, KILL_GRACE_MS); }; const timer = setTimeout(kill, timeoutMs); proc.on("close", (code, signal) => { clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); const elapsed = Date.now() - started; - const all = Buffer.concat(chunks).toString("utf8"); - if (logFile) { - try { - appendFileSync(logFile, all); - } catch { - /* ignore */ - } - } + const full = Buffer.concat(chunks).toString("utf8"); resolve({ exitCode: code, signal, durationMs: elapsed, - output: logFile ? "" : all, + output: logFile ? metricLines.join("\n") : full, + outputTail: logFile ? Buffer.concat(tail).toString("utf8") : full, logFile, timedOut: didTimeout, }); @@ -245,7 +285,7 @@ async function runChecks(checksFile: string) { failed: res.exitCode !== 0, exitCode: res.exitCode, durationMs: res.durationMs, - outputTail: truncateTail(res.output, 80, 4096), + outputTail: truncateTail(res.outputTail, 80, 4096), }; } @@ -289,10 +329,8 @@ function readBenchmarkHashes() { } } -function writeBenchmarkHashes(hashes: { - measure: string | null; - checks: string | null; -}): void { +/** Merge a patch into the project's `.auto/config.json` (creates if missing). */ +function patchSessionConfig(patch: Record): void { const cfgPath = join(projectCwd, ".auto", "config.json"); let cfg: Record = {}; try { @@ -300,32 +338,72 @@ function writeBenchmarkHashes(hashes: { } catch { /* start fresh */ } - cfg.benchmarkHashes = hashes; + Object.assign(cfg, patch); writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); } +function writeBenchmarkHashes(hashes: { + measure: string | null; + checks: string | null; +}): void { + patchSessionConfig({ benchmarkHashes: hashes }); +} + +/** + * Persist the checks outcome of the latest run_experiment so log_experiment's + * keep gate works even across an MCP server restart (pi `runtime.lastRunChecks` + * equivalent, but on disk). `failed` is true only when checks ran and failed. + */ +function setPendingChecksFailed(failed: boolean): void { + patchSessionConfig({ pendingChecksFailed: failed }); +} + +function pendingChecksFailed(): boolean { + return readSessionConfig(projectCwd).pendingChecksFailed === true; +} + /** * Compare current frozen-file hashes against the session's recorded ones. - * Returns { drift, reason } where drift is true when a recorded hash changed. - * First sighting (recorded null but file exists) records and reports "recorded". + * Returns { drift, reason, deleted } where drift is true when a recorded hash + * changed (deleted=false) or a recorded file was deleted (deleted=true). + * First sighting (recorded null but file exists) records the hash without + * warning. */ function checkBenchmarkDrift() { const recorded = readBenchmarkHashes(); const current = currentBenchmarkHashes(); if (!recorded) { writeBenchmarkHashes(current); - return { drift: false, reason: "recorded", hashes: current }; + return { + drift: false, + reason: "recorded", + deleted: false, + hashes: current, + }; } + const merged = { ...recorded }; + let firstSeen = false; for (const key of ["measure", "checks"] as const) { - if ( - recorded[key] != null && - current[key] != null && - recorded[key] !== current[key] - ) { - return { drift: true, reason: key, hashes: current }; + if (recorded[key] == null && current[key] != null) { + merged[key] = current[key]; // first sighting: record, no warning + firstSeen = true; + continue; + } + // changed (hash mismatch) or deleted (current null) → drift + if (recorded[key] != null && current[key] == null) { + return { drift: true, reason: key, deleted: true, hashes: current }; + } + if (recorded[key] != null && recorded[key] !== current[key]) { + return { drift: true, reason: key, deleted: false, hashes: current }; } } - return { drift: false, reason: null, hashes: current }; + if (firstSeen) writeBenchmarkHashes(merged); + return { + drift: false, + reason: firstSeen ? "recorded" : null, + deleted: false, + hashes: current, + }; } /** @@ -484,6 +562,14 @@ async function toolInitExperiment( if (direction !== "lower" && direction !== "higher") { return { ok: false, error: "direction must be lower or higher" }; } + // The loop's keep/discard semantics (commit + rollback) need git; a non-git + // research dir would only fail later at log time, in a half-initialized state. + if (!isGitRepo(cwd)) { + return { + ok: false, + error: `research directory is not a git repository — the experiment loop needs git commit/rollback semantics. Run git init there (or point .auto/config.json workingDir at a repo) first.`, + }; + } const state = sessionState(); const segment = (state.segment ?? 0) + 1; appendLedgerEntry(cwd, { @@ -497,8 +583,10 @@ async function toolInitExperiment( }); // Record frozen-file hashes as the benchmark baseline for this session. writeBenchmarkHashes(currentBenchmarkHashes()); + // New segment: any pending checks outcome from a previous session is stale. + setPendingChecksFailed(false); broadcastDashboardUpdate(); - const branch = isGitRepo(cwd) ? currentBranch(cwd) : ""; + const branch = currentBranch(cwd); return { ok: true, segment, @@ -532,7 +620,7 @@ async function toolRunExperiment( // Benchmark drift: frozen files changed since the session baseline. const drift = checkBenchmarkDrift(); const driftWarn = drift.drift - ? `benchmark_drift: ${drift.reason === "measure" ? "measure.sh" : "checks.sh"} changed since session start — metrics are no longer comparable. Start a new segment (init_experiment) or confirm the change.` + ? `benchmark_drift: ${drift.reason === "measure" ? "measure.sh" : "checks.sh"} ${drift.deleted ? "was deleted" : "changed"} since session start — metrics are no longer comparable. Start a new segment (init_experiment) or confirm the change.` : null; const rawCommand = String(args.command ?? ""); if (!rawCommand.trim()) return { ok: false, error: "command is required" }; @@ -594,6 +682,9 @@ async function toolRunExperiment( if (existsSync(paths.checks) && last.exitCode === 0) { checks = await runChecks(paths.checks); } + // Persist the checks outcome for log_experiment's keep gate (see + // setPendingChecksFailed). No checks.sh / benchmark crashed → not failed. + setPendingChecksFailed(checks?.failed === true); const values = runs .map((r) => r.metric) @@ -602,6 +693,20 @@ async function toolRunExperiment( repeat > 1 && values.length > 0 ? median(values) : (runs[0]?.metric ?? null); + // Secondary metrics aggregate to per-name medians across the repetitions, + // the same source as median_metric (the primary is not special-cased). + const metricNames = new Set(); + for (const r of runs) { + for (const n of Object.keys(r.metrics)) metricNames.add(n); + } + const aggMetrics: Record = {}; + for (const n of metricNames) { + const vs = runs + .map((r) => r.metrics[n]) + .filter((v): v is number => v != null && Number.isFinite(v)); + const m = vs.length > 0 ? median(vs) : null; + if (m != null) aggMetrics[n] = m; + } const ret = { ok: true, @@ -611,7 +716,7 @@ async function toolRunExperiment( signal: last.signal ?? null, duration_ms: last.durationMs, timed_out: last.timedOut, - metrics: runs[0]?.metrics ?? {}, + metrics: aggMetrics, metric: medianMetric, median_metric: repeat > 1 ? medianMetric : undefined, checks: checks @@ -622,7 +727,7 @@ async function toolRunExperiment( output_tail: checks.outputTail, } : { ran: false }, - output_tail: truncateTail(last.output), + output_tail: truncateTail(last.outputTail), log_file: last.logFile ?? null, ...(before ? { before_steer: before.steer } : {}), ...(driftWarn ? { benchmark_drift: true, warning: driftWarn } : {}), @@ -663,8 +768,10 @@ async function toolLogExperiment( ) : []; - // keep gate: previous run's checks failed → refuse keep. - if (status === "keep" && state.lastRunChecksFailed) { + // keep gate: the just-run benchmark's checks failed → refuse keep. The + // outcome is persisted by run_experiment (pendingChecksFailed), because the + // ledger cannot know about a run that has not been logged yet. + if (status === "keep" && pendingChecksFailed()) { return { ok: false, error: @@ -718,7 +825,9 @@ async function toolLogExperiment( run: state.runs.length + 1, segment: state.segment, status: status as RunStatus, - metric: status === "crash" ? 0 : metric, + // null (not a 0 placeholder): a crash measured nothing and must not + // pollute baseline/best/confidence downstream. + metric: status === "crash" ? null : metric, commit: args.commit ?? null, }; const cfgAudit = readSessionConfig(projectCwd); @@ -773,15 +882,19 @@ async function toolLogExperiment( run: state.runs.length + 1, segment: state.segment, status: status as RunStatus, - metric: status === "crash" ? 0 : metric, + metric: status === "crash" ? null : metric, metrics, asi, description, commit, + // audit trail: a checks_failed row carries the flag explicitly + ...(status === "checks_failed" ? { checksFailed: true } : {}), timestamp: new Date().toISOString(), }; appendLedgerEntry(cwd, entry); + // The pending run is now accounted for — clear the keep-gate state. + setPendingChecksFailed(false); broadcastDashboardUpdate(); // Iteration hook: .auto/hooks/after.sh runs after the record (fail-open). @@ -833,6 +946,7 @@ async function toolLogExperiment( } async function toolClearExperiments() { + setPendingChecksFailed(false); if (!existsSync(paths.log)) return { ok: true, message: "no active session to clear" }; const { rmSync } = await import("node:fs"); @@ -927,7 +1041,8 @@ const TOOLS = [ }, metric: { type: "number", - description: "primary metric value from run_experiment (0 for crash)", + description: + "primary metric value from run_experiment (omit for crash — the ledger row records null)", }, description: { type: "string", diff --git a/plugins/autoresearch/scripts/finalize.sh b/plugins/autoresearch/scripts/finalize.sh index f47eb5b..46de4df 100755 --- a/plugins/autoresearch/scripts/finalize.sh +++ b/plugins/autoresearch/scripts/finalize.sh @@ -8,52 +8,75 @@ # "last_commit": "", "slug": "..." } ] } # # Each group becomes autoresearch//NN-, created from the merge-base -# of the trunk with the kept commits; group file sets must not overlap; the -# union of all group branches must equal the original branch's changes (minus -# session files). On any failure everything is rolled back. +# of the trunk with the kept commits; each branch carries only its own group's +# incremental file set (prev group's last_commit → this group's last_commit); +# group file sets must not overlap; the union of all group branches must equal +# the original branch's changes (minus session files). On any failure — +# including mid-construction errors and branch-name conflicts — everything is +# rolled back (original branch restored, created branches deleted) so a fixed +# invocation can be rerun immediately. set -euo pipefail PROJECT="${1:?projectCwd required}" GJSON="${2:?groups.json required}" + +# Normalize groups.json to an absolute path BEFORE cd: relative paths resolve +# against the caller's cwd, not the project dir. +case "$GJSON" in + /*) ;; + *) GJSON="$(cd "$(dirname "$GJSON")" && pwd)/$(basename "$GJSON")" ;; +esac + cd "$PROJECT" -jq_or_node() { node -e "$1"; } +# Pass GJSON via argv (never string-interpolated): readFileSync+JSON.parse has +# no require() relative-path rules, no quote injection, no extension dispatch. +jq_or_node() { node -e "$1" "$GJSON"; } +GJSON_READ='const g=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));' ORIG_BRANCH="$(git branch --show-current)" if [[ -z "$ORIG_BRANCH" || "$ORIG_BRANCH" == "HEAD" ]]; then echo "FATAL: must be on a feature branch (not detached)" >&2; exit 2 fi -BASE="$(jq_or_node "const g=require('$GJSON');process.stdout.write(g.base||'main')")" -GOAL="$(jq_or_node "const g=require('$GJSON');process.stdout.write(g.goal||'experiment')")" +BASE="$(jq_or_node "${GJSON_READ}process.stdout.write(g.base||'main')")" +GOAL="$(jq_or_node "${GJSON_READ}process.stdout.write(g.goal||'experiment')")" MB="$(git merge-base "$BASE" HEAD)" -# Collect per-group file sets; reject overlaps and session files. -declare -a GROUPS_TITLES GROUPS_COMMITS GROUPS_SLUGS -declare -a ALL_FILES=() -ALL_FILES_SET=" " # space-delimited set for overlap checks (bash 3.2 safe) -GROUP_COUNT="$(jq_or_node "const g=require('$GJSON');process.stdout.write(String(g.groups.length))")" +# Collect per-group incremental file sets; reject overlaps and session files. +# GROUPS_FILES[$i] holds newline-delimited "STATUSpath" entries (from +# `git diff --name-status -z --no-renames`); the SAME sets are reused when +# constructing branches below, so what is validated is what is built. +declare -a GROUPS_TITLES GROUPS_BODIES GROUPS_COMMITS GROUPS_SLUGS GROUPS_FILES +ALL_FILES_SET="" # newline-delimited set for overlap checks (bash 3.2 safe) +GROUP_COUNT="$(jq_or_node "${GJSON_READ}process.stdout.write(String(g.groups.length))")" [[ "$GROUP_COUNT" -gt 0 ]] || { echo "FATAL: no groups" >&2; exit 2; } PREV="$MB" i=0 while [[ $i -lt $GROUP_COUNT ]]; do - LAST="$(jq_or_node "const g=require('$GJSON');process.stdout.write(g.groups[$i].last_commit)")" - SLUG="$(jq_or_node "const g=require('$GJSON');process.stdout.write(g.groups[$i].slug||String($i))")" - TITLE="$(jq_or_node "const g=require('$GJSON');process.stdout.write(g.groups[$i].title||'experiment')")" - BODY="$(jq_or_node "const g=require('$GJSON');process.stdout.write(g.groups[$i].body||'')")" - FILES="$(git diff --name-only "$PREV" "$LAST" | grep -v -E '(^|/)\.auto/|autoresearch-dashboard\.html$' || true)" - if [[ -z "$FILES" ]]; then + LAST="$(jq_or_node "${GJSON_READ}process.stdout.write(g.groups[$i].last_commit)")" + SLUG="$(jq_or_node "${GJSON_READ}process.stdout.write(g.groups[$i].slug||String($i))")" + TITLE="$(jq_or_node "${GJSON_READ}process.stdout.write(g.groups[$i].title||'experiment')")" + BODY="$(jq_or_node "${GJSON_READ}process.stdout.write(g.groups[$i].body||'')")" + # NUL-separated enumeration: status\0path\0 pairs; safe for spaced filenames. + ENTRIES="" + while IFS= read -r -d '' STATUS && IFS= read -r -d '' FP; do + case "$FP" in + .auto/* | */.auto/* | autoresearch-dashboard.html | */autoresearch-dashboard.html) continue ;; + esac + if printf '%s\n' "$ALL_FILES_SET" | grep -Fxq -- "$FP"; then + echo "FATAL: file '$FP' appears in multiple groups (merge groups or re-split)" >&2; exit 2 + fi + ALL_FILES_SET="${ALL_FILES_SET}${FP}"$'\n' + ENTRIES="${ENTRIES}${STATUS}"$'\t'"${FP}"$'\n' + done < <(git diff --name-status -z --no-renames "$PREV" "$LAST") + if [[ -z "$ENTRIES" ]]; then echo "FATAL: group $i has no non-session files" >&2; exit 2 fi - for f in $FILES; do - if [[ "$ALL_FILES_SET" == *" $f "* ]]; then - echo "FATAL: file '$f' appears in multiple groups (merge groups or re-split)" >&2; exit 2 - fi - ALL_FILES+=("$f") - ALL_FILES_SET="$ALL_FILES_SET$f " - done - GROUPS_TITLES[$i]="$TITLE"; GROUPS_BODIES[$i]="$BODY"; GROUPS_COMMITS[$i]="$LAST"; GROUPS_SLUGS[$i]="$SLUG" + GROUPS_TITLES[$i]="$TITLE"; GROUPS_BODIES[$i]="$BODY" + GROUPS_COMMITS[$i]="$LAST"; GROUPS_SLUGS[$i]="$SLUG" + GROUPS_FILES[$i]="$ENTRIES" PREV="$LAST" i=$((i+1)) done @@ -64,8 +87,11 @@ ORIG_CHANGES="$(git diff --name-only "$MB" HEAD | grep -v -E '(^|/)\.auto/|autor CREATED=() rollback() { echo "FAILED — rolling back" >&2 + # Restore the original branch FIRST (-f: staged/worktree state here is + # mid-construction content already safe in git objects); only then can the + # created branches be deleted (deleting the checked-out branch would fail). + git checkout -q -f "$ORIG_BRANCH" 2>/dev/null || true for br in "${CREATED[@]:-}"; do git branch -D "$br" >/dev/null 2>&1 || true; done - git checkout -q "$ORIG_BRANCH" 2>/dev/null || true exit 1 } trap rollback ERR @@ -74,12 +100,22 @@ i=0 while [[ $i -lt $GROUP_COUNT ]]; do NAME="autoresearch/$GOAL/$(printf '%02d' $((i+1)))-${GROUPS_SLUGS[$i]}" if git rev-parse --verify "refs/heads/$NAME" >/dev/null 2>&1; then - echo "FATAL: branch $NAME already exists" >&2; exit 2 + echo "FATAL: branch $NAME already exists" >&2 + rollback fi - FILES="$(git diff --name-only "$MB" "${GROUPS_COMMITS[$i]}" | grep -v -E '(^|/)\.auto/|autoresearch-dashboard\.html$' || true)" git checkout -q --detach "$MB" git checkout -q -b "$NAME" - for f in $FILES; do git checkout -q "${GROUPS_COMMITS[$i]}" -- "$f"; done + CREATED+=("$NAME") # enlist immediately so mid-construction failures roll back too + while IFS=$'\t' read -r STATUS FP; do + [[ -n "$STATUS" ]] || continue + if [[ "$STATUS" == "D" ]]; then + # Deleted in this group; --ignore-unmatch covers add-then-delete within + # the same group (file absent from the merge-base tree). + git rm -q --ignore-unmatch -- "$FP" &2 diff <(printf '%s' "$UNION") <(printf '%s' "$ORIG_SORTED") >&2 || true - exit 1 + rollback fi trap - ERR diff --git a/plugins/autoresearch/tests/dashboard.test.ts b/plugins/autoresearch/tests/dashboard.test.ts index fbabce0..ed20ff3 100644 --- a/plugins/autoresearch/tests/dashboard.test.ts +++ b/plugins/autoresearch/tests/dashboard.test.ts @@ -5,6 +5,7 @@ import { spawn } from "node:child_process"; import { mkdtempSync, writeFileSync, + appendFileSync, mkdirSync, existsSync, readFileSync, @@ -13,6 +14,47 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { renderDashboard } from "../mcp/lib/dashboard.ts"; +import type { LedgerRun, SessionState } from "../mcp/lib/types.ts"; + +function sampleState( + runs: Array<[status: string, metric: number | null]> = [ + ["keep", 100], + ["discard", 105], + ["keep", 60], + ["noop", null], + ["keep", 40], + ], +): SessionState { + const entries: LedgerRun[] = runs.map(([status, metric], i) => ({ + type: "run", + run: i + 1, + segment: 1, + status: status as LedgerRun["status"], + metric, + description: status === "noop" ? "no code change" : `hypothesis ${i + 1}`, + })); + return { + config: { + type: "config", + segment: 1, + name: "t", + metricName: "time_ms", + direction: "lower", + }, + segment: 1, + runs: entries, + baseline: 100, + best: 40, + lastRunChecksFailed: false, + lastRun: entries[entries.length - 1], + totalExperiments: entries.length, + consecutiveFailures: 0, + confidence: { confidence: 3.0, level: "green" }, + plateau: false, + failureThreshold: 3, + }; +} const ROOT = fileURLToPath(new URL("..", import.meta.url)); const SERVER = join(ROOT, "mcp", "server.ts"); @@ -124,6 +166,8 @@ test("export_dashboard starts a live server with HTML, ledger and SSE routes", a await withServer(cwd, async (s) => { await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + // keep requires a real (non-.auto) change since the .auto exclusion fix + appendFileSync(join(cwd, "code.js"), "// change\n"); await s.tool("log_experiment", { status: "keep", metric: 42, @@ -200,6 +244,8 @@ test("workingDir redirects the ledger, benchmark and git to the research dir", a command: "bash .auto/measure.sh", }); assert.equal(run.metric, 7); + // keep requires a real (non-.auto) change in the research dir + writeFileSync(join(cwd, "work", "code.js"), "v2\n"); await s.tool("log_experiment", { status: "keep", metric: 7, @@ -217,3 +263,73 @@ test("workingDir redirects the ledger, benchmark and git to the research dir", a assert.match(state, /"type":"run"/); }); }); + +test("live server survives a deleted ledger (404) and dead SSE clients", async () => { + const cwd = tempRepo(); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + const exp = await s.tool("export_dashboard", {}); + // an SSE client connects, then dies abruptly (aborted fetch) + const ctrl = new AbortController(); + const sseRes = await fetch(String(exp.url) + "/events", { + signal: ctrl.signal, + }); + ctrl.abort(); + // the ledger legitimately disappears (clear_experiments) + await s.tool("clear_experiments", {}); + const gone = await fetch(String(exp.url) + "/autoresearch.jsonl"); + assert.equal(gone.status, 404); + // a later broadcast (init writes the ledger again) must not crash the + // process even though a dead client may still be in the broadcast set + await s.tool("init_experiment", { name: "t2", metric_name: "time_ms" }); + const home = await fetch(String(exp.url) + "/"); + assert.equal(home.status, 200); + await sseRes.body?.cancel().catch(() => {}); + }); +}); + +test("renderer: equal-width card grid, dual theme, overflow guards, neutral no-op", () => { + const html = renderDashboard(sampleState()); + assert.match(html, /box-sizing: border-box/); + assert.match( + html, + /grid-template-columns: repeat\(auto-fit, minmax\(110px, 1fr\)\)/, + ); + assert.match(html, /prefers-color-scheme: dark/); + assert.match(html, /overflow-wrap: anywhere/); + assert.match(html, /class="tablewrap"/); + assert.match(html, /overflow-x: auto/); + // no-op is neutral, never the crash-red fallback + assert.match(html, /badge noop">no-op/); + assert.doesNotMatch(html, /badge crash">no-op/); + // confidence card carries its value next to the level + assert.match(html, /confidence 3\.00/); +}); + +test("renderer: SVG trend line with per-status points and baseline reference", () => { + const html = renderDashboard(sampleState()); + assert.match(html, /50<\/text>/); // nice ticks land on round values + assert.match(html, />75<\/text>/); + assert.match(html, />100<\/text>/); + assert.match(html, /c-keep/); // keep points filled + assert.match(html, /c-discard/); // discard points hollow +}); + +test("renderer: no trend below two valid points", () => { + const html = renderDashboard( + sampleState([ + ["keep", 100], + ["noop", null], + ["crash", null], + ]), + ); + assert.doesNotMatch(html, /no-op/); +}); diff --git a/plugins/autoresearch/tests/experiment.test.ts b/plugins/autoresearch/tests/experiment.test.ts index dbc8a65..8714350 100644 --- a/plugins/autoresearch/tests/experiment.test.ts +++ b/plugins/autoresearch/tests/experiment.test.ts @@ -117,6 +117,58 @@ test("unwrapMeasureCommand rejects non-benchmark or chained commands", () => { assert.equal(unwrapMeasureCommand(null, "measure.sh"), null); }); +test("unwrapMeasureCommand rejects shell injection after the script (whitelist)", () => { + // newline / CR chaining + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh \necho PWNED", "measure.sh"), + null, + ); + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh \recho PWNED", "measure.sh"), + null, + ); + // redirection + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh > /tmp/x", "measure.sh"), + null, + ); + // backtick / dollar / quotes / glob + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh `id`", "measure.sh"), + null, + ); + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh $HOME", "measure.sh"), + null, + ); + assert.equal( + unwrapMeasureCommand('bash .auto/measure.sh --name="a b"', "measure.sh"), + null, + ); + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh *.js", "measure.sh"), + null, + ); +}); + +test("unwrapMeasureCommand allows plain benchmark args (whitelist)", () => { + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh --verbose", "measure.sh"), + "bash .auto/measure.sh --verbose", + ); + assert.equal( + unwrapMeasureCommand("bash .auto/measure.sh --foo=bar -n 3", "measure.sh"), + "bash .auto/measure.sh --foo=bar -n 3", + ); + assert.equal( + unwrapMeasureCommand( + "bash .auto/measure.sh --input=./data/a_b-c.txt:2 +x", + "measure.sh", + ), + "bash .auto/measure.sh --input=./data/a_b-c.txt:2 +x", + ); +}); + test("isStopReached on cap and consecutive failures", () => { const runs = [1, 2, 3].map((n) => ({ run: n, status: "keep" })); assert.equal(isStopReached(runs, 3), true); @@ -276,3 +328,51 @@ test("detectDoomLoop flags repeats and oscillation, not normal progress", () => // too few runs → null assert.equal(detectDoomLoop([run("a"), run("a")]), null); }); + +test("isStopReached: noop neither counts as a failure nor keeps the streak", () => { + const mk = (status: string) => ({ status }); + // [discard, crash, noop]: trailing failure streak is 0 — no stop + assert.equal( + isStopReached( + [mk("discard"), mk("crash"), mk("noop")].map((r, i) => ({ + ...r, + run: i + 1, + })), + 20, + ), + false, + ); + // [discard, crash, noop, discard]: streak restarts at 1 — no stop + assert.equal( + isStopReached( + [mk("discard"), mk("crash"), mk("noop"), mk("discard")].map((r, i) => ({ + ...r, + run: i + 1, + })), + 20, + ), + false, + ); + // three real failures in a row still stop + assert.equal( + isStopReached( + [mk("discard"), mk("crash"), mk("checks_failed")].map((r, i) => ({ + ...r, + run: i + 1, + })), + 20, + ), + true, + ); + // all noop never stops + assert.equal( + isStopReached( + [mk("noop"), mk("noop"), mk("noop"), mk("noop")].map((r, i) => ({ + ...r, + run: i + 1, + })), + 20, + ), + false, + ); +}); diff --git a/plugins/autoresearch/tests/finalize.test.ts b/plugins/autoresearch/tests/finalize.test.ts index 419faf5..85abc99 100644 --- a/plugins/autoresearch/tests/finalize.test.ts +++ b/plugins/autoresearch/tests/finalize.test.ts @@ -1,4 +1,5 @@ -// finalize.sh behavior tests (basic grouping, overlap rejection, rollback). +// finalize.sh behavior tests (basic grouping, overlap rejection, rollback, +// incremental file sets, deletions, whitespace paths, relative groups.json). import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync } from "node:fs"; @@ -115,3 +116,165 @@ test("finalize rejects overlapping files across groups and rolls back", () => { const branches = git(cwd, ["branch", "--list", "autoresearch/opt/*"]).trim(); assert.equal(branches, "", "no leftover finalize branches"); }); + +test("finalize gives the Nth branch only its own group's files", () => { + const { cwd, c1, c2 } = setup(); + const g = groups(cwd, "opt", [ + { title: "t1", last_commit: c1, slug: "a" }, + { title: "t2", last_commit: c2, slug: "b" }, + ]); + execFileSync("bash", [FINALIZE, cwd, g], { encoding: "utf8" }); + // branch 02 must contain only b.js — not group 1's a.js (incremental set) + const diff = git(cwd, [ + "diff", + "--name-only", + "main", + "autoresearch/opt/02-b", + ]).trim(); + assert.equal(diff, "b.js"); + // a.js stays at its base content on branch 02 + assert.equal(git(cwd, ["show", "autoresearch/opt/02-b:a.js"]), "v1\n"); +}); + +test("finalize handles a group containing a deleted file", () => { + // dedicated fixture: group 1 touches b.js only, group 2 deletes a.js + // (setup()'s c1 also touches a.js, which would be a genuine overlap) + const cwd = mkdtempSync(join(tmpdir(), "ar-fin-del-")); + for (const a of [ + ["init", "-q", "-b", "main"], + ["config", "user.email", "t@t"], + ["config", "user.name", "t"], + ]) { + execFileSync("git", a, { cwd, stdio: "ignore" }); + } + writeFileSync(join(cwd, "a.js"), "v1\n"); + writeFileSync(join(cwd, "b.js"), "v1\n"); + execFileSync("git", ["add", "-A"], { cwd, stdio: "ignore" }); + execFileSync("git", ["commit", "-qm", "base"], { cwd, stdio: "ignore" }); + execFileSync("git", ["checkout", "-qb", "autoresearch/exp"], { + cwd, + stdio: "ignore", + }); + writeFileSync(join(cwd, "b.js"), "v2\n"); + execFileSync("git", ["add", "-A"], { cwd, stdio: "ignore" }); + execFileSync("git", ["commit", "-qm", "experiment: b"], { + cwd, + stdio: "ignore", + }); + const c1 = git(cwd, ["rev-parse", "HEAD"]).trim(); + execFileSync("git", ["rm", "-q", "a.js"], { cwd, stdio: "ignore" }); + execFileSync("git", ["commit", "-qm", "experiment: rm a"], { + cwd, + stdio: "ignore", + }); + const c2 = git(cwd, ["rev-parse", "HEAD"]).trim(); + const g = groups(cwd, "opt", [ + { title: "t1", last_commit: c1, slug: "b" }, + { title: "t2", last_commit: c2, slug: "rm" }, + ]); + const out = execFileSync("bash", [FINALIZE, cwd, g], { encoding: "utf8" }); + assert.match(out, /autoresearch\/opt\/02-rm/); + // a.js absent from branch 02's tree, untouched on branch 01 + let exists = true; + try { + git(cwd, ["show", "autoresearch/opt/02-rm:a.js"]); + } catch { + exists = false; + } + assert.equal(exists, false, "deleted file must not exist on branch 02"); + assert.equal(git(cwd, ["show", "autoresearch/opt/01-b:a.js"]), "v1\n"); +}); + +test("finalize rollback leaves no residue and rerun succeeds", () => { + const { cwd, c1, c2 } = setup(); + // pre-create the name group 2 will want, forcing a mid-construction failure + execFileSync("git", ["branch", "autoresearch/opt/02-b"], { + cwd, + stdio: "ignore", + }); + const g = groups(cwd, "opt", [ + { title: "t1", last_commit: c1, slug: "a" }, + { title: "t2", last_commit: c2, slug: "b" }, + ]); + let threw = false; + try { + execFileSync("bash", [FINALIZE, cwd, g], { encoding: "utf8" }); + } catch (e) { + threw = true; + assert.match( + String((e as { stderr?: unknown }).stderr ?? ""), + /already exists|FAILED/, + ); + } + assert.equal(threw, true, "branch name conflict must fail"); + // rolled back: on original branch; branch 01 created by this run is gone; + // the pre-existing 02-b is untouched + assert.equal( + git(cwd, ["branch", "--show-current"]).trim(), + "autoresearch/exp", + ); + assert.equal( + git(cwd, ["branch", "--list", "autoresearch/opt/01-a"]).trim(), + "", + ); + assert.notEqual( + git(cwd, ["branch", "--list", "autoresearch/opt/02-b"]).trim(), + "", + "pre-existing branch must survive rollback", + ); + // remove the blocker and rerun — must succeed immediately + execFileSync("git", ["branch", "-D", "autoresearch/opt/02-b"], { + cwd, + stdio: "ignore", + }); + const out = execFileSync("bash", [FINALIZE, cwd, g], { encoding: "utf8" }); + assert.match(out, /autoresearch\/opt\/02-b/); +}); + +test("finalize handles file names containing spaces", () => { + const cwd = mkdtempSync(join(tmpdir(), "ar-fin-space-")); + for (const a of [ + ["init", "-q", "-b", "main"], + ["config", "user.email", "t@t"], + ["config", "user.name", "t"], + ]) { + execFileSync("git", a, { cwd, stdio: "ignore" }); + } + writeFileSync(join(cwd, "my file.js"), "v1\n"); + execFileSync("git", ["add", "-A"], { cwd, stdio: "ignore" }); + execFileSync("git", ["commit", "-qm", "base"], { cwd, stdio: "ignore" }); + execFileSync("git", ["checkout", "-qb", "autoresearch/exp"], { + cwd, + stdio: "ignore", + }); + writeFileSync(join(cwd, "my file.js"), "v2\n"); + execFileSync("git", ["add", "-A"], { cwd, stdio: "ignore" }); + execFileSync("git", ["commit", "-qm", "experiment: spaced"], { + cwd, + stdio: "ignore", + }); + const c1 = git(cwd, ["rev-parse", "HEAD"]).trim(); + const g = groups(cwd, "opt", [ + { title: "t1", last_commit: c1, slug: "spaced" }, + ]); + execFileSync("bash", [FINALIZE, cwd, g], { encoding: "utf8" }); + assert.equal( + git(cwd, ["show", "autoresearch/opt/01-spaced:my file.js"]), + "v2\n", + ); +}); + +test("finalize accepts a bare relative groups.json path", () => { + const { cwd, c1, c2 } = setup(); + groups(cwd, "opt", [ + { title: "t1", last_commit: c1, slug: "a" }, + { title: "t2", last_commit: c2, slug: "b" }, + ]); + // invoke from inside the project with "." and a bare relative groups.json + const out = execFileSync("bash", [FINALIZE, ".", "groups.json"], { + cwd, + encoding: "utf8", + }); + assert.match(out, /autoresearch\/opt\/01-a/); + assert.match(out, /autoresearch\/opt\/02-b/); +}); diff --git a/plugins/autoresearch/tests/git.test.ts b/plugins/autoresearch/tests/git.test.ts index a5a9729..01fc6d6 100644 --- a/plugins/autoresearch/tests/git.test.ts +++ b/plugins/autoresearch/tests/git.test.ts @@ -63,6 +63,33 @@ test("commitExperiment returns null when nothing changed", () => { assert.equal(hash, null); }); +test("commitExperiment excludes the .auto session dir from staging", () => { + const cwd = tempRepo(); + mkdirSync(join(cwd, ".auto"), { recursive: true }); + // session files only (no code change) → nothing to commit + writeFileSync(join(cwd, ".auto", "log.jsonl"), '{"type":"config"}\n'); + writeFileSync(join(cwd, ".auto", "config.json"), "{}"); + assert.equal( + commitExperiment(cwd, { description: "ledger only", result: {} }), + null, + ); + // a real code change commits, but without any .auto noise + writeFileSync(join(cwd, "main.js"), "v2\n"); + const hash = commitExperiment(cwd, { + description: "real change", + result: { metric: 1 }, + }); + assert.ok(hash); + const files = git(cwd, ["show", "--name-only", "--format=", "HEAD"]); + assert.ok(files.includes("main.js")); + assert.ok( + !files.split("\n").some((f) => f.startsWith(".auto/") || f === ".auto"), + `commit must not contain .auto files, got: ${files}`, + ); + // .auto files stay on disk and untracked-pending (not committed) + assert.ok(existsSync(join(cwd, ".auto", "log.jsonl"))); +}); + test("rollbackWorkingTree discards changes but keeps .auto/", () => { const cwd = tempRepo(); mkdirSync(join(cwd, ".auto"), { recursive: true }); @@ -76,7 +103,19 @@ test("rollbackWorkingTree discards changes but keeps .auto/", () => { existsSync(join(cwd, ".auto", "log.jsonl")), ".auto survives clean", ); - assert.equal(isDirty(cwd), true); // .auto/log.jsonl untracked -> still dirty, that's expected + assert.equal( + isDirty(cwd), + false, // .auto/log.jsonl untracked but excluded — session files are not real changes + ); +}); + +test("isDirty ignores .auto-only changes but sees real ones", () => { + const cwd = tempRepo(); + mkdirSync(join(cwd, ".auto"), { recursive: true }); + writeFileSync(join(cwd, ".auto", "log.jsonl"), '{"type":"config"}\n'); + assert.equal(isDirty(cwd), false); // session files alone are not dirty + writeFileSync(join(cwd, "main.js"), "v2\n"); + assert.equal(isDirty(cwd), true); // a real change still counts }); test("rollbackWorkingTree leaves staged-but-uncommitted changes reverted too", () => { diff --git a/plugins/autoresearch/tests/hooks.test.ts b/plugins/autoresearch/tests/hooks.test.ts index 7167a05..84df2cd 100644 --- a/plugins/autoresearch/tests/hooks.test.ts +++ b/plugins/autoresearch/tests/hooks.test.ts @@ -235,7 +235,7 @@ test("stop-continue blocks while the loop is unfinished, with progress", () => { assert.match(out.reason, /baseline=10/); }); -test("stop-continue reports plateau convergence", () => { +test("stop-continue allows the stop on plateau convergence (spec: 放行)", () => { const cwd = tempCwd(); const flat: LedgerEntry[] = [cfgLine]; for (let i = 1; i <= 5; i++) @@ -248,15 +248,15 @@ test("stop-continue reports plateau convergence", () => { description: `flat ${i}`, } as LedgerRun); seedLedger(cwd, flat); - const out = JSON.parse( + // plateau → the hook must NOT block; advisory goes to stderr, stdout empty + assert.equal( runHook( "stop-continue.ts", cwd, JSON.stringify({ hook_event_name: "Stop" }), ), + "", ); - assert.equal(out.decision, "block"); - assert.match(out.reason, /平台期/); }); test("session-start announces an existing session", () => { @@ -289,6 +289,42 @@ test("session-start respects autoresearchOff decision", () => { ); }); +test("session-start respects autoresearchOff set in the project config under workingDir", () => { + const project = tempCwd(); + mkdirSync(join(project, ".auto"), { recursive: true }); + mkdirSync(join(project, "work", ".auto"), { recursive: true }); + // the ledger lives in the research dir; the off switch in the project config + writeFileSync( + join(project, ".auto", "config.json"), + JSON.stringify({ workingDir: "work", autoresearchOff: true }), + ); + writeFileSync( + join(project, "work", ".auto", "log.jsonl"), + JSON.stringify(cfgLine) + "\n", + ); + assert.equal( + runHook( + "session-start.ts", + project, + JSON.stringify({ hook_event_name: "SessionStart" }), + ), + "", + ); + // without the off switch the resume hint comes back + writeFileSync( + join(project, ".auto", "config.json"), + JSON.stringify({ workingDir: "work" }), + ); + const out = JSON.parse( + runHook( + "session-start.ts", + project, + JSON.stringify({ hook_event_name: "SessionStart" }), + ), + ); + assert.match(out.hookSpecificOutput.additionalContext, /autoresearch 会话/); +}); + test("permission-gate denies experiment tools without a session, allows with one", () => { const cwd = tempCwd(); // no session → deny init_experiment diff --git a/plugins/autoresearch/tests/ledger.test.ts b/plugins/autoresearch/tests/ledger.test.ts index 5b28dbc..55a5d76 100644 --- a/plugins/autoresearch/tests/ledger.test.ts +++ b/plugins/autoresearch/tests/ledger.test.ts @@ -101,6 +101,34 @@ test("lastRunChecksFailed is set by a failed check", () => { assert.equal(state.consecutiveFailures, 1); }); +test("lastRunChecksFailed follows the latest run (no one-way latch)", () => { + const cwd = tempCwd(); + appendLedgerEntry(cwd, { + type: "config", + segment: 1, + name: "s", + metricName: "m", + direction: "lower", + }); + // status alone marks checks failure (no checksFailed field needed) + appendLedgerEntry(cwd, { + type: "run", + run: 1, + status: "checks_failed", + metric: 42, + }); + assert.equal(rebuildState(cwd).lastRunChecksFailed, true); + // a subsequent checks-passing run resets the flag (overwrite, not latch) + appendLedgerEntry(cwd, { + type: "run", + run: 2, + status: "keep", + metric: 40, + commit: "abc1234", + }); + assert.equal(rebuildState(cwd).lastRunChecksFailed, false); +}); + test("session config file is read", () => { const cwd = tempCwd(); mkdirSync(join(cwd, ".auto"), { recursive: true }); @@ -119,3 +147,40 @@ test("rebuild from empty cwd yields empty state", () => { assert.equal(state.config, null); assert.ok(!existsSync(join(tempCwd(), LOG_FILE))); }); + +test("rebuildState: noop resets the consecutive-failure streak without counting", () => { + const cwd = mkdtempSync(join(tmpdir(), "ar-ledger-")); + mkdirSync(join(cwd, ".auto"), { recursive: true }); + appendLedgerEntry(cwd, { + type: "config", + segment: 1, + name: "t", + metricName: "time_ms", + direction: "lower", + }); + appendLedgerEntry(cwd, { + type: "run", + run: 1, + status: "discard", + metric: 99, + }); + appendLedgerEntry(cwd, { + type: "run", + run: 2, + status: "crash", + metric: null, + }); + let state = rebuildState(cwd); + assert.equal(state.consecutiveFailures, 2); + appendLedgerEntry(cwd, { type: "run", run: 3, status: "noop", metric: null }); + state = rebuildState(cwd); + assert.equal(state.consecutiveFailures, 0); // noop breaks the chain + appendLedgerEntry(cwd, { + type: "run", + run: 4, + status: "discard", + metric: 98, + }); + state = rebuildState(cwd); + assert.equal(state.consecutiveFailures, 1); // restarts from zero +}); diff --git a/plugins/autoresearch/tests/mcp-integration.test.ts b/plugins/autoresearch/tests/mcp-integration.test.ts index fe59751..8003b00 100644 --- a/plugins/autoresearch/tests/mcp-integration.test.ts +++ b/plugins/autoresearch/tests/mcp-integration.test.ts @@ -5,9 +5,11 @@ import { spawn } from "node:child_process"; import { mkdtempSync, writeFileSync, + appendFileSync, mkdirSync, existsSync, readFileSync, + rmSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -34,7 +36,7 @@ interface McpClient { close(): void; } -function tempRepo() { +function tempRepo(opts: { withMeasure?: boolean } = {}) { const cwd = mkdtempSync(join(tmpdir(), "ar-mcp-")); execFileSync("git", ["init", "-q"], { cwd, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "t@t"], { @@ -43,10 +45,12 @@ function tempRepo() { }); execFileSync("git", ["config", "user.name", "t"], { cwd, stdio: "ignore" }); mkdirSync(join(cwd, ".auto"), { recursive: true }); - writeFileSync( - join(cwd, ".auto", "measure.sh"), - '#!/usr/bin/env bash\necho "METRIC time_ms=42"\n', - ); + if (opts.withMeasure !== false) { + writeFileSync( + join(cwd, ".auto", "measure.sh"), + '#!/usr/bin/env bash\necho "METRIC time_ms=42"\n', + ); + } writeFileSync(join(cwd, "code.js"), "v1\n"); execFileSync("git", ["add", "-A"], { cwd, stdio: "ignore" }); execFileSync("git", ["commit", "-qm", "init"], { cwd, stdio: "ignore" }); @@ -143,6 +147,7 @@ test("iteration hooks: before runs before benchmark, after after logging, steer }); assert.equal(run.before_steer, "BEFORE-STEER"); assert.equal(run.metric, 42); + appendFileSync(join(cwd, "code.js"), "// change\n"); const log = await s.tool("log_experiment", { status: "keep", metric: 42, @@ -195,6 +200,7 @@ test("iteration hooks: payload carries asi (last_run and run_entry)", async () = await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); // first run: no asi anywhere yet — hooks stay silent (last_run null) + appendFileSync(join(cwd, "code.js"), "// c1\n"); await s.tool("log_experiment", { status: "keep", metric: 42, @@ -231,6 +237,7 @@ test("iteration hooks: every fire appends a type:hook ledger entry", async () => await withServer(cwd, async (s) => { await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + appendFileSync(join(cwd, "code.js"), "// change\n"); await s.tool("log_experiment", { status: "keep", metric: 42, @@ -333,6 +340,7 @@ test("secondary metric constraints: keep rejected when a constraint is exceeded" command: "bash .auto/measure.sh", }); assert.equal((r1.metrics as Record).memory_mb, 100); + appendFileSync(join(cwd, "code.js"), "// base\n"); await s.tool("log_experiment", { status: "keep", metric: 42, @@ -340,6 +348,7 @@ test("secondary metric constraints: keep rejected when a constraint is exceeded" metrics: { memory_mb: 100 }, }); // constraint within band → keep passes (vs baseline memory_mb=100) + appendFileSync(join(cwd, "code.js"), "// c2\n"); const ok = await s.tool("log_experiment", { status: "keep", metric: 41, @@ -351,7 +360,9 @@ test("secondary metric constraints: keep rejected when a constraint is exceeded" assert.deepEqual(ok.constraints, [ { name: "memory_mb", status: "pass", value: 100, limit: 105 }, ]); - // now a keep that blows the constraint → rejected + // now a keep that blows the constraint → rejected (before any git op, + // so the change stays in the working tree for the next keep) + appendFileSync(join(cwd, "code.js"), "// c3\n"); const bad = await s.tool("log_experiment", { status: "keep", metric: 40, @@ -375,3 +386,295 @@ test("secondary metric constraints: keep rejected when a constraint is exceeded" assert.equal(free.constraints, undefined); }); }); + +test("checks gate: keep rejected after failed checks, allowed again after a passing run", async () => { + const cwd = tempRepo(); + // checks fail while code.js contains "broken" + writeFileSync( + join(cwd, ".auto", "checks.sh"), + "#!/usr/bin/env bash\n! grep -q broken code.js\n", + ); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + appendFileSync(join(cwd, "code.js"), "// broken\n"); + const r1 = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal((r1.checks as Record).failed, true); + // dishonest keep of the checks-failed run → rejected (guardrail live) + const bad = await s.tool("log_experiment", { + status: "keep", + metric: 42, + description: "keep despite failed checks", + }); + assert.equal(bad.ok, false); + assert.match(String(bad.error), /correctness checks/); + // honest checks_failed → logged with checksFailed: true, tree rolled back + const cf = await s.tool("log_experiment", { + status: "checks_failed", + metric: 42, + description: "honest checks_failed row", + }); + assert.equal(cf.ok, true); + const rows = readFileSync(join(cwd, ".auto", "log.jsonl"), "utf8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)); + const cfRow = rows.find( + (r) => r.type === "run" && r.status === "checks_failed", + ); + assert.equal(cfRow.checksFailed, true); + assert.ok( + !readFileSync(join(cwd, "code.js"), "utf8").includes("broken"), + "checks_failed rolls back the broken change", + ); + // fix the code, rerun (checks pass) → keep allowed (no one-way latch) + appendFileSync(join(cwd, "code.js"), "// fixed\n"); + const r2 = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal((r2.checks as Record).failed, false); + const good = await s.tool("log_experiment", { + status: "keep", + metric: 42, + description: "keep after checks pass", + }); + assert.equal(good.ok, true); + assert.equal(typeof good.commit, "string"); + }); +}); + +test("benchmark drift: measure.sh created mid-session is recorded, then modification warns", async () => { + const cwd = tempRepo({ withMeasure: false }); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + // first sighting: create the benchmark mid-session → recorded, no warning + writeFileSync( + join(cwd, ".auto", "measure.sh"), + '#!/usr/bin/env bash\necho "METRIC time_ms=42"\n', + ); + const r1 = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(r1.metric, 42); + assert.equal(r1.benchmark_drift, undefined); + const cfg = JSON.parse( + readFileSync(join(cwd, ".auto", "config.json"), "utf8"), + ); + assert.equal(typeof cfg.benchmarkHashes?.measure, "string"); + // silently changing the now-recorded benchmark → drift warning + writeFileSync( + join(cwd, ".auto", "measure.sh"), + '#!/usr/bin/env bash\necho "METRIC time_ms=1"\n', + ); + const r2 = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(r2.benchmark_drift, true); + assert.match(String(r2.warning), /no longer comparable/); + }); +}); + +test("benchmark drift: deleting a frozen file warns", async () => { + const cwd = tempRepo(); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + const r1 = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(r1.benchmark_drift, undefined); + rmSync(join(cwd, ".auto", "measure.sh")); + const r2 = await s.tool("run_experiment", { + command: 'echo "METRIC time_ms=0"', + }); + assert.equal(r2.benchmark_drift, true); + assert.match(String(r2.warning), /no longer comparable/); + }); +}); + +test("keep commit excludes .auto; keep with only session changes is rejected", async () => { + const cwd = tempRepo(); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + // no code change: only .auto/ has drifted → keep must hit the no-changes audit + const noopKeep = await s.tool("log_experiment", { + status: "keep", + metric: 42, + description: "nothing changed", + }); + assert.equal(noopKeep.ok, false); + assert.match(String(noopKeep.error), /no changes to commit/); + // a real change keeps, and the commit carries no .auto files + appendFileSync(join(cwd, "code.js"), "// change\n"); + const keep = await s.tool("log_experiment", { + status: "keep", + metric: 42, + description: "real change", + }); + assert.equal(keep.ok, true); + const files = execFileSync( + "git", + ["show", "--name-only", "--format=", "HEAD"], + { cwd, encoding: "utf8" }, + ).trim(); + assert.ok(files.includes("code.js")); + assert.ok( + !files.split("\n").some((f) => f.startsWith(".auto/") || f === ".auto"), + `keep commit must not contain .auto files, got: ${files}`, + ); + }); +}); + +test("timeout escalates to SIGKILL when the benchmark ignores SIGTERM", async () => { + const cwd = tempRepo({ withMeasure: false }); + writeFileSync( + join(cwd, ".auto", "measure.sh"), + "#!/usr/bin/env bash\ntrap '' TERM\necho stuck >&2\nsleep 39\n", + ); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + const t0 = Date.now(); + const r = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + timeout_seconds: 1, + }); + const waited = Date.now() - t0; + assert.equal(r.timed_out, true); + // SIGTERM at 1s + 5s grace -> SIGKILL; must return long before sleep 39 ends + assert.ok(waited < 15_000, `tool call took ${waited}ms`); + }); + await new Promise((r) => setTimeout(r, 300)); + let stray = ""; + try { + stray = execFileSync("pgrep", ["-fl", "sleep 39"], { encoding: "utf8" }); + } catch { + /* pgrep exits 1 when nothing matches — the expected case */ + } + assert.equal(stray, "", "no stray benchmark process survives"); +}); + +test("overflowed output still yields the metric (spill: METRIC at end)", async () => { + const cwd = tempRepo({ withMeasure: false }); + writeFileSync( + join(cwd, ".auto", "measure.sh"), + String.raw`#!/usr/bin/env bash +node -e 'process.stdout.write("x".repeat(3000000)+"\n")' +echo "METRIC time_ms=42" +`, + ); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + const r = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(r.metric, 42); + assert.ok(r.log_file, "spill log file returned"); + assert.ok( + String(r.output_tail ?? "").length > 0, + "output_tail non-empty after spill", + ); + }); +}); + +test("overflowed output still yields the metric (spill: METRIC at start)", async () => { + const cwd = tempRepo({ withMeasure: false }); + writeFileSync( + join(cwd, ".auto", "measure.sh"), + String.raw`#!/usr/bin/env bash +echo "METRIC time_ms=42" +node -e 'process.stdout.write("x".repeat(3000000)+"\n")' +`, + ); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + const r = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(r.metric, 42); + assert.ok(r.log_file, "spill log file returned"); + }); +}); + +test("repeat>1 aggregates metrics to per-name medians", async () => { + const cwd = tempRepo({ withMeasure: false }); + writeFileSync( + join(cwd, ".auto", "measure.sh"), + '#!/usr/bin/env bash\nn=$(cat .auto/rep 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > .auto/rep\necho "METRIC time_ms=$((40+n))"\necho "METRIC rss_mb=$((100+10*n))"\n', + ); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + const r = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + repeat: 3, + }); + assert.equal(r.median_metric, 42); // 41/42/43 + assert.deepEqual(r.metrics, { time_ms: 42, rss_mb: 120 }); // 110/120/130 + }); +}); + +test("crash rows record metric null and do not pollute the baseline", async () => { + const cwd = tempRepo(); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + await s.tool("log_experiment", { status: "crash", description: "boom" }); + const rows = readFileSync(join(cwd, ".auto", "log.jsonl"), "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l) as Record); + const crashRow = rows.find((r) => r.type === "run" && r.status === "crash"); + assert.equal(crashRow?.metric, null); // not the 0 placeholder + appendFileSync(join(cwd, "code.js"), "// v2\n"); + await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + const keep1 = await s.tool("log_experiment", { + status: "keep", + metric: 50, + description: "first real keep", + }); + assert.equal(keep1.baseline, 50); // crash's null did not seed baseline 0 + appendFileSync(join(cwd, "code.js"), "// v3\n"); + await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + const keep2 = await s.tool("log_experiment", { + status: "keep", + metric: 42, + description: "improve", + }); + assert.equal(keep2.delta, 8); // 42 vs baseline 50, lower=better -> +8 (was reversed before) + }); +}); + +test("init_experiment rejects a non-git research directory", async () => { + const cwd = mkdtempSync(join(tmpdir(), "ar-nogit-")); + mkdirSync(join(cwd, ".auto"), { recursive: true }); + await withServer(cwd, async (s) => { + const r = await s.tool("init_experiment", { + name: "t", + metric_name: "time_ms", + }); + assert.equal(r.ok, false); + assert.match(String(r.error), /git/); + }); + assert.equal(existsSync(join(cwd, ".auto", "log.jsonl")), false); +}); + +test("logging a crash does not block the next run via .auto dirtiness", async () => { + const cwd = tempRepo(); + await withServer(cwd, async (s) => { + await s.tool("init_experiment", { name: "t", metric_name: "time_ms" }); + await s.tool("run_experiment", { command: "bash .auto/measure.sh" }); + await s.tool("log_experiment", { status: "crash", description: "boom" }); + // the crash row itself dirtied .auto/log.jsonl — that must not block + const r = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(r.ok, true); + // a real (non-.auto) change still trips the crash-unresolved gate + appendFileSync(join(cwd, "code.js"), "// dirty\n"); + const blocked = await s.tool("run_experiment", { + command: "bash .auto/measure.sh", + }); + assert.equal(blocked.ok, false); + assert.match(String(blocked.error), /crash/); + }); +}); From d990bd261f713fb9bd3a396911843c5886b78a8c Mon Sep 17 00:00:00 2001 From: Chang Luo <33987852+luochang212@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:57:32 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(autoresearch):=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E5=9B=BE=E6=A0=87=E4=B8=BA=E6=AD=A3=E5=BC=8F?= =?UTF-8?q?=20logo=EF=BC=88=E6=B7=B1=E5=BA=95=20+=20=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E7=AE=AD=E5=A4=B4=20+=20=E9=87=91=E9=97=AA=E7=94=B5=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E6=8D=A2=E6=9F=A0=E6=AA=AC=E5=B8=86=E8=88=B9?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/autoresearch/icon.png | Bin 11158 -> 11736 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/autoresearch/icon.png b/assets/autoresearch/icon.png index 9695ac0cc3a767e6e4d76c07f58982b3183f794b..4c98ea03bd2f8034ace656808972ed3bb08d207d 100644 GIT binary patch literal 11736 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 %nhcy*p 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 From d8fc9db1842d61448a0f47c3c4d0009eeb1ccbd1 Mon Sep 17 00:00:00 2001 From: Chang Luo <33987852+luochang212@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:15:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs(autoresearch):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=96=87=E6=A1=88=E5=8E=BB=20AI=20=E5=91=B3?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=87=B3=2009-03=EF=BC=88=E5=91=BD=E4=BB=A4/?= =?UTF-8?q?=E6=8A=80=E8=83=BD=20description=E3=80=81=E4=B8=AD=E8=8B=B1=20R?= =?UTF-8?q?EADME=E3=80=81MCP=20=E6=8A=A5=E9=94=99=E4=B8=8E=E9=92=A9?= =?UTF-8?q?=E5=AD=90=E6=8F=90=E7=A4=BA=E5=8E=BB=E7=A0=B4=E6=8A=98=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E5=8F=A5=E5=BC=8F=E9=87=8D=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../autoresearch/.zcode-plugin/plugin.json | 6 +-- plugins/autoresearch/README.md | 12 +++--- plugins/autoresearch/README_CN.md | 8 ++-- plugins/autoresearch/commands/autoresearch.md | 2 +- plugins/autoresearch/commands/clear.md | 6 +-- plugins/autoresearch/commands/export.md | 6 +-- plugins/autoresearch/commands/finalize.md | 2 +- plugins/autoresearch/commands/off.md | 2 +- .../hooks/examples/after/learnings-journal.sh | 2 +- plugins/autoresearch/hooks/guard-frozen.ts | 2 +- plugins/autoresearch/hooks/memory-inject.ts | 4 +- plugins/autoresearch/hooks/stop-continue.ts | 2 +- plugins/autoresearch/mcp/lib/dashboard.ts | 2 +- plugins/autoresearch/mcp/server.ts | 32 +++++++-------- .../skills/autoresearch-hooks/SKILL.md | 20 +++++----- .../autoresearch/skills/autoresearch/SKILL.md | 40 +++++++++---------- .../autoresearch/references/loop-protocol.md | 16 ++++---- .../autoresearch/references/setup-guide.md | 12 +++--- plugins/autoresearch/tests/examples.test.ts | 2 +- 19 files changed, 89 insertions(+), 89 deletions(-) diff --git a/plugins/autoresearch/.zcode-plugin/plugin.json b/plugins/autoresearch/.zcode-plugin/plugin.json index 36bc0c6..9fd286e 100644 --- a/plugins/autoresearch/.zcode-plugin/plugin.json +++ b/plugins/autoresearch/.zcode-plugin/plugin.json @@ -1,9 +1,9 @@ { "name": "autoresearch", - "description": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate — measure, keep what works, revert what doesn't, repeat. Provides init/run/log experiment tools (MCP), a loop protocol skill, guardrails (frozen benchmark, checks backpressure, memory injection, Stop continuation), and static + live dashboards.", + "description": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate. It measures, keeps what works, reverts what doesn't, and repeats. Provides init/run/log experiment tools (MCP), a loop protocol skill, guardrails (frozen benchmark, checks backpressure, memory injection, Stop continuation), and static + live dashboards.", "description_i18n": { - "en": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate — measure, keep what works, revert what doesn't, repeat.", - "zh-CN": "ZCode 自主实验循环:设定目标与机械度量,让 agent 迭代——测量、保留有效改动、回滚无效改动、循环往复。" + "en": "Autonomous experiment loop for ZCode: set a goal, pick a mechanical metric, and let the agent iterate. It measures, keeps what works, reverts what doesn't, and repeats.", + "zh-CN": "ZCode 自主实验循环:设定目标与机械度量,让 agent 迭代。每一轮测量、保留有效改动、回滚无效改动,如此往复。" }, "version": "0.1.0", "author": { diff --git a/plugins/autoresearch/README.md b/plugins/autoresearch/README.md index da36c9f..076b482 100644 --- a/plugins/autoresearch/README.md +++ b/plugins/autoresearch/README.md @@ -2,7 +2,7 @@ [中文文档](./README_CN.md) -An autonomous experiment loop for ZCode: set a fixed, mechanical metric and let the coding agent iterate — modify code → run the benchmark → keep improvements, revert regressions → repeat. +Let the ZCode coding agent iterate autonomously on a fixed, mechanical metric: modify code → run the benchmark → keep improvements, revert regressions → repeat. Based on research into [karpathy/autoresearch](https://github.com/karpathy/autoresearch) and [pi-autoresearch](https://github.com/davebcn87/pi-autoresearch) (see `docs/research/autoresearch-survey.md`). Architecture decisions live in `adr/decisions/`. @@ -16,7 +16,7 @@ This plugin executes code and operates on a git repository. Enabling it grants c - **Serves a local HTTP dashboard** on 127.0.0.1 via `export_dashboard`; - **Writes session state** to `.auto/` files (`log.jsonl`, `config.json`) in the project directory. -No third-party npm dependencies: the MCP server and hooks are Node-stdlib TypeScript scripts (Node ≥24, types stripped natively — no build step). +No third-party npm dependencies: the MCP server and hooks are Node-stdlib TypeScript scripts (Node ≥24, types stripped natively, no build step). ## Install @@ -62,11 +62,11 @@ Or let the skill trigger on its own (descriptions containing "autoresearch", "au - **Memory injection**: UserPromptSubmit/SessionStart hooks inject an aggregated summary (progress + deduped tried directions + best trajectory + ASI distillation) so progress survives compaction; repeated/oscillating attempts (doom-loop) trigger a hint to switch direction. - **Loop continuation**: the Stop hook blocks (`decision:block`) while a loop is unfinished (zcode platform limit: 3 consecutive windows). - **Iteration hooks**: `.auto/hooks/before.sh` (pre-benchmark) and `after.sh` (post-record) run on every experiment (fail-open, 30s timeout, stdout → `*_steer`). -- **Hook ecosystem**: `skills/autoresearch-hooks` tutorial + 6 ready-to-use examples in `hooks/examples/` (anti-thrash, hypothesis reflection, idea rotator, learnings journal, auto-tag winners, macOS notify) — copy to `.auto/hooks/` and go (parsed with Node, no jq dependency). +- **Hook ecosystem**: `skills/autoresearch-hooks` tutorial + 6 ready-to-use examples in `hooks/examples/` (anti-thrash, hypothesis reflection, idea rotator, learnings journal, auto-tag winners, macOS notify); copy one to `.auto/hooks/` and go (parsed with Node, no jq dependency). - **Stop-loss**: after `consecutiveFailures` in a row (default 3, configurable in `.auto/config.json`) the plugin hints you to stop. - **Ledger audit**: `log_experiment` validates invariants before writing (keep must be a real improvement, a discarded real improvement must have failed the guard, event ordering, commit field); violations are rejected; a crashed segment that wasn't rolled back blocks continuation. `auditBypass: true` in `.auto/config.json` explicitly skips it (not recommended). -- **Benchmark drift detection**: `init_experiment` records hashes of measure.sh/checks.sh; `run_experiment` compares — a mid-run benchmark change returns a `benchmark_drift` warning (prevents "faking the metric by editing the benchmark"). -- **Secondary-metric constraints** (opt-in): `log_experiment` supports `constraints: [{name, maxPct}]` — on keep, secondary metrics are checked not to exceed maxPct% of the first run's value, rejected otherwise (prevents reward hacking like "trading memory for speed"). +- **Benchmark drift detection**: `init_experiment` records hashes of measure.sh/checks.sh; `run_experiment` compares them, and a mid-run benchmark change returns a `benchmark_drift` warning (prevents "faking the metric by editing the benchmark"). +- **Secondary-metric constraints** (opt-in): `log_experiment` supports `constraints: [{name, maxPct}]`. On keep, secondary metrics must stay within maxPct% of the first run's value; anything beyond rejects the keep (prevents reward hacking like "trading memory for speed"). ## Directory structure @@ -112,7 +112,7 @@ Setting `"workingDir": "work/"` in `.auto/config.json` separates the research di - **No session-injection API**: no overnight unattended runs; rely on the 3-window Stop-hook allowance plus user re-triggering to continue. - **Headless mode (`--prompt`) does not run hooks**: guardrails take effect in interactive sessions; run autoresearch in an interactive session. -- `git add -A` commits unrelated dirty files together (known pi inheritance) — commit a clean baseline during setup. +- `git add -A` commits unrelated dirty files together (known pi inheritance); commit a clean baseline during setup. ## Development diff --git a/plugins/autoresearch/README_CN.md b/plugins/autoresearch/README_CN.md index c896beb..690ee85 100644 --- a/plugins/autoresearch/README_CN.md +++ b/plugins/autoresearch/README_CN.md @@ -20,7 +20,7 @@ 启用本插件即授予代码执行信任(官方市场约定)。插件会: -- **执行命令**:`run_experiment` 运行你编写的基准脚本(`.auto/measure.sh`),以及存在时的正确性门禁(`.auto/checks.sh`); +- **执行命令**:`run_experiment` 运行你编写的基准脚本 `.auto/measure.sh`,以及存在时的正确性门禁 `.auto/checks.sh`; - **自动执行 git 操作**:keep 时自动 `git commit`,非 keep 时自动回滚(`.auto/` 豁免回滚); - **安装 ZCode hooks**:Stop(循环续跑)、PreToolUse(冻结文件写保护)、PermissionRequest(实验工具门禁)、UserPromptSubmit/SessionStart(账本记忆注入); - **启动本地 HTTP dashboard**:`export_dashboard` 监听 127.0.0.1; @@ -65,8 +65,8 @@ - **钩子生态**:`skills/autoresearch-hooks` 教学 + `hooks/examples/` 6 个现成示例(防重复失败/换思路/假设反思/学习日志/通知/最优打标),复制到 `.auto/hooks/` 即用(node 解析,无 jq 依赖)。 - **止损**:连续失败达 `.auto/config.json` 的 `consecutiveFailures`(默认 3,可配)时提示停止。 - **账本审计**:`log_experiment` 写入前校验不变量(keep 必须真实改进、discard 真改进须 failed guard、事件顺序、commit 字段),违规拒收;crash 未回滚禁止续跑。`.auto/config.json` 的 `auditBypass: true` 可显式跳过(不推荐)。 -- **基准漂移检测**:`init_experiment` 记录 measure.sh/checks.sh 哈希,`run_experiment` 比对——基准中途变更时返回 `benchmark_drift` 警告(防"改基准造假 metric")。 -- **次级度量约束**(opt-in):`log_experiment` 支持 `constraints: [{name, maxPct}]`——keep 时校验次级度量不超首轮值的 maxPct%,超界拒收(防"用内存换速度"类 reward hacking)。 +- **基准漂移检测**:`init_experiment` 记录 measure.sh/checks.sh 哈希,`run_experiment` 比对;基准中途变更时返回 `benchmark_drift` 警告(防"改基准造假 metric")。 +- **次级度量约束**(opt-in):`log_experiment` 支持 `constraints: [{name, maxPct}]`。keep 时校验次级度量不超首轮值的 maxPct%,超界拒收(防"用内存换速度"类 reward hacking)。 ## 目录结构 @@ -112,7 +112,7 @@ plugin/ - **无会话注入 API**:无过夜无人值守;靠 Stop hook 3 次窗口 + 用户再触发续跑。 - **无头模式(`--prompt`)不执行 hooks**:护栏在交互式会话生效;请用交互式会话跑 autoresearch。 -- `git add -A` 会把无关脏文件一起 commit(继承 pi 的已知弱点)——setup 时先提交干净基线。 +- `git add -A` 会把无关脏文件一起 commit(继承 pi 的已知弱点);setup 时先提交干净基线。 ## 开发 diff --git a/plugins/autoresearch/commands/autoresearch.md b/plugins/autoresearch/commands/autoresearch.md index 54b8252..85ba472 100644 --- a/plugins/autoresearch/commands/autoresearch.md +++ b/plugins/autoresearch/commands/autoresearch.md @@ -1,5 +1,5 @@ --- -description: Enter autoresearch mode — continue an existing session from .auto/prompt.md, or set one up from your goal. Usage: /autoresearch:autoresearch +description: Enter autoresearch mode. Resumes from .auto/prompt.md when a session exists, otherwise sets one up from your goal. Usage: /autoresearch:autoresearch --- Enter autoresearch mode for this workspace. diff --git a/plugins/autoresearch/commands/clear.md b/plugins/autoresearch/commands/clear.md index a3974ea..1a82499 100644 --- a/plugins/autoresearch/commands/clear.md +++ b/plugins/autoresearch/commands/clear.md @@ -1,11 +1,11 @@ --- -description: Clear the autoresearch session — delete .auto/log.jsonl and start fresh. Keeps measure.sh / checks.sh / prompt.md. Usage: /autoresearch:clear +description: Clear the autoresearch session by deleting .auto/log.jsonl, then start fresh. Keeps measure.sh / checks.sh / prompt.md. Usage: /autoresearch:clear --- Clear the current autoresearch session. -1. Confirm with the user that they want to wipe the experiment history (this cannot be undone — the ledger and all `experiment:` commits stay in git history, but the session state is gone). +1. Confirm with the user that they want to wipe the experiment history. This cannot be undone: the session state is gone, though the ledger and all `experiment:` commits remain in git history. 2. Call the `clear_experiments` tool. 3. Report the result. A fresh target can now start with `/autoresearch:autoresearch ` or `init_experiment`. -Note: kept `experiment:` commits remain in git history — this only resets the `.auto/` session ledger. +Note: kept `experiment:` commits remain in git history; only the `.auto/` session ledger is reset. diff --git a/plugins/autoresearch/commands/export.md b/plugins/autoresearch/commands/export.md index eaefcb5..e88a61f 100644 --- a/plugins/autoresearch/commands/export.md +++ b/plugins/autoresearch/commands/export.md @@ -1,11 +1,11 @@ --- -description: Export the autoresearch dashboard — render .auto/log.jsonl into autoresearch-dashboard.html. Usage: /autoresearch:export +description: Render .auto/log.jsonl into autoresearch-dashboard.html. Usage: /autoresearch:export --- Export the autoresearch experiment dashboard. -1. Call the `export_dashboard` tool (or run `node ${ZCODE_PLUGIN_ROOT}/mcp/server.ts`'s export logic — prefer the MCP tool). -2. If the tool is unavailable, fall back to: read `.auto/log.jsonl`, summarize experiments (status, metric, delta vs baseline, direction), and write a self-contained `autoresearch-dashboard.html` in the workspace root. +1. Call the `export_dashboard` tool (prefer the MCP tool; the same export logic also lives in `${ZCODE_PLUGIN_ROOT}/mcp/server.ts`). +2. If the tool is unavailable, fall back to reading `.auto/log.jsonl` yourself, summarizing experiments (status, metric, delta vs baseline, direction), and writing a self-contained `autoresearch-dashboard.html` in the workspace root. 3. Tell the user the file path (`autoresearch-dashboard.html`) and a 2-3 line summary of progress (experiments run, kept, best metric). If there is no `.auto/log.jsonl`, say so and suggest `/autoresearch:autoresearch` to start a session first. diff --git a/plugins/autoresearch/commands/finalize.md b/plugins/autoresearch/commands/finalize.md index 0b1a542..6652daa 100644 --- a/plugins/autoresearch/commands/finalize.md +++ b/plugins/autoresearch/commands/finalize.md @@ -1,5 +1,5 @@ --- -description: Finalize the autoresearch session — split kept experiments into clean topic branches you can PR. Usage: /autoresearch:finalize +description: Split kept experiments into clean topic branches you can PR. Usage: /autoresearch:finalize --- Finalize the experiment session into clean, PR-able topic branches. diff --git a/plugins/autoresearch/commands/off.md b/plugins/autoresearch/commands/off.md index 84c54e4..ed06592 100644 --- a/plugins/autoresearch/commands/off.md +++ b/plugins/autoresearch/commands/off.md @@ -1,5 +1,5 @@ --- -description: Turn off autoresearch auto-resume hints — keep the session but stop being prompted to continue. Resume anytime with /autoresearch:autoresearch. Usage: /autoresearch:off +description: Stop the auto-resume hints while keeping the session. Resume anytime with /autoresearch:autoresearch. Usage: /autoresearch:off --- Pause autoresearch without wiping the session. diff --git a/plugins/autoresearch/hooks/examples/after/learnings-journal.sh b/plugins/autoresearch/hooks/examples/after/learnings-journal.sh index eb4c94a..2d66a6c 100755 --- a/plugins/autoresearch/hooks/examples/after/learnings-journal.sh +++ b/plugins/autoresearch/hooks/examples/after/learnings-journal.sh @@ -13,6 +13,6 @@ const path = require('path'); const run = p.run_entry; const journal = `${p.cwd}/.auto/learnings.md`; fs.mkdirSync(path.dirname(journal), { recursive: true }); -const line = `- run ${run.run} [${run.status}] metric=${run.metric ?? '—'} — ${run.description ?? ''}`; +const line = `- run ${run.run} [${run.status}] metric=${run.metric ?? '—'}: ${run.description ?? ''}`; fs.appendFileSync(journal, line + '\n'); NODE diff --git a/plugins/autoresearch/hooks/guard-frozen.ts b/plugins/autoresearch/hooks/guard-frozen.ts index 03c4aed..61abb91 100644 --- a/plugins/autoresearch/hooks/guard-frozen.ts +++ b/plugins/autoresearch/hooks/guard-frozen.ts @@ -41,7 +41,7 @@ process.stdout.write( hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", - permissionDecisionReason: `[autoresearch] ${rel} is frozen — the benchmark metric must not change during the loop. If you really need a new metric, start over: init_experiment with a new target.`, + permissionDecisionReason: `[autoresearch] ${rel} is frozen: the benchmark metric must not change during the loop. If you really need a new metric, start over: init_experiment with a new target.`, }, }), ); diff --git a/plugins/autoresearch/hooks/memory-inject.ts b/plugins/autoresearch/hooks/memory-inject.ts index 13febeb..ef94d6a 100644 --- a/plugins/autoresearch/hooks/memory-inject.ts +++ b/plugins/autoresearch/hooks/memory-inject.ts @@ -86,8 +86,8 @@ const doom = detectDoomLoop(state.runs); if (doom) { lines.push( doom.pattern === "oscillate" - ? "⚠️ 检测到 A→B→A→B 震荡尝试——停止在两个方向上反复,换一个结构性不同的方向。" - : "⚠️ 检测到连续重复尝试——停止重复同一假设,换一个结构性不同的方向。", + ? "⚠️ 检测到 A→B→A→B 震荡尝试:请停止在两个方向上反复,换一个结构性不同的方向。" + : "⚠️ 检测到连续重复尝试:请停止重复同一假设,换一个结构性不同的方向。", ); } diff --git a/plugins/autoresearch/hooks/stop-continue.ts b/plugins/autoresearch/hooks/stop-continue.ts index b97dbde..35267c2 100644 --- a/plugins/autoresearch/hooks/stop-continue.ts +++ b/plugins/autoresearch/hooks/stop-continue.ts @@ -69,7 +69,7 @@ if (state.plateau) { `direction=${dir},baseline=${state.baseline ?? "—"},best=${state.best ?? "—"}。` + `最近记录:\n${tail}\n` + (detectDoomLoop(state.runs) - ? `⚠️ 检测到重复/震荡尝试——停止重复同一假设,换一个结构性不同的方向。\n` + ? `⚠️ 检测到重复/震荡尝试:请停止重复同一假设,换一个结构性不同的方向。\n` : "") + `请继续下一个假设:修改代码 → run_experiment → log_experiment(keep/discard)。`; diff --git a/plugins/autoresearch/mcp/lib/dashboard.ts b/plugins/autoresearch/mcp/lib/dashboard.ts index d4130fa..f844daf 100644 --- a/plugins/autoresearch/mcp/lib/dashboard.ts +++ b/plugins/autoresearch/mcp/lib/dashboard.ts @@ -151,7 +151,7 @@ function renderBody(state: SessionState, live: boolean): string { -autoresearch — ${escapeHtml(cfg?.name ?? "session")} +autoresearch: ${escapeHtml(cfg?.name ?? "session")}