From 78a8273705c46ef8d68116502d1ef5f4568b9e6c Mon Sep 17 00:00:00 2001 From: Beefiker Date: Thu, 23 Jul 2026 18:48:16 +0900 Subject: [PATCH 1/2] feat(runtime): align host and doctor truth Adopt host-native role identity and current Claude plugin guidance, enforce physical reviewability accounting, split doctor source and installed scopes, and bump the release to 0.12.3. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .codex/agents/nami.toml | 2 + README.es.md | 13 ++-- README.ja.md | 13 ++-- README.ko.md | 13 ++-- README.md | 14 ++-- README.zh-CN.md | 13 ++-- docs/superloopy-claude-validation.md | 4 +- docs/superloopy-host-contract.md | 22 ++++--- hooks/hooks.json | 4 +- legacy-agent-manifests.json | 11 ++++ package-lock.json | 4 +- package.json | 2 +- skills/superloopy-doctor/SKILL.md | 6 +- skills/superloopy-loop/SKILL.md | 6 +- src/audit-hooks.js | 6 +- src/audit.js | 2 +- src/cli.js | 95 +++++++++++++++++++++++----- src/doctor.js | 83 ++++++++++++------------ src/engineer.js | 14 ++-- src/hooks.js | 10 ++- src/receipt.js | 19 ++++-- test/audit.test.js | 17 +++-- test/claude-host-wiring.test.js | 52 +++++++++++++-- test/doctor.test.js | 84 +++++++++++++++++++++++- test/engineer.test.js | 27 ++++---- test/host-adapter.test.js | 31 +++++++-- 29 files changed, 410 insertions(+), 163 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2c56485..40a834f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "superloopy", "source": "./", "description": "Strict evidence loop harness: repo-local loop state, success criteria, append-only ledgers, evidence-gated completion, plus skills (loop, research, frontend, clone) and a read-only/worker crew. Works on Claude Code and Codex from one repo.", - "version": "0.12.2", + "version": "0.12.3", "license": "MIT", "keywords": [ "claude-code", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 844734c..19dac56 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superloopy", - "version": "0.12.2", + "version": "0.12.3", "description": "Lightweight loop harness with strict evidence gates — Claude Code edition.", "homepage": "https://github.com/beefiker/superloopy#readme", "repository": "https://github.com/beefiker/superloopy", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 167ec02..79a343a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superloopy", - "version": "0.12.2", + "version": "0.12.3", "description": "Lightweight loop harness with strict evidence gates.", "homepage": "https://github.com/beefiker/superloopy#readme", "repository": "https://github.com/beefiker/superloopy", diff --git a/.codex/agents/nami.toml b/.codex/agents/nami.toml index 5438e35..94f6bd7 100644 --- a/.codex/agents/nami.toml +++ b/.codex/agents/nami.toml @@ -3,6 +3,8 @@ description = "Read-only codebase navigator for Superloopy subagent-driven work. model = "gpt-5.6-luna" model_reasoning_effort = "low" service_tier = "fast" +sandbox_mode = "read-only" +approval_policy = "never" developer_instructions = """ You are nami, a read-only codebase navigator for Superloopy. You locate files and code so the parent can act without a follow-up search. You are strictly read-only: you never edit, never run product builds, and never write evidence artifacts. diff --git a/README.es.md b/README.es.md index cfaa73d..9b82bd1 100644 --- a/README.es.md +++ b/README.es.md @@ -167,15 +167,15 @@ Las instalaciones desde checkout no están gestionadas por `npx`. El self-update ### Claude Code -Actualiza el marketplace, reinstala para resolver la versión nueva y luego recarga; no hace falta reiniciar: +Actualiza el marketplace, actualiza el plugin instalado y luego recarga; no hace falta reiniciar: ``` /plugin marketplace update beefiker -/plugin install superloopy@beefiker +/plugin update superloopy@beefiker /reload-plugins ``` -No hay un comando `/plugin update` aparte: reinstalar desde el marketplace actualizado resuelve la versión nueva, y `/reload-plugins` la aplica en la sesión actual (sin reiniciar Claude Code, y los hooks no necesitan volver a aprobarse). Verifica con `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json`. Si cargaste un checkout con `--plugin-dir`, basta con `git pull --ff-only` y ejecutar `/reload-plugins`. +`/plugin update` resuelve la versión nueva desde el marketplace actualizado, y `/reload-plugins` la aplica en la sesión actual (sin reiniciar Claude Code, y los hooks no necesitan volver a aprobarse). Verifica con `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json`. Si cargaste un checkout con `--plugin-dir`, basta con `git pull --ff-only` y ejecutar `/reload-plugins`. ## Solución de problemas @@ -203,11 +203,16 @@ codex plugin marketplace remove beefiker Reinicia Codex después de desinstalar. Limpieza opcional del bootstrap local: eliminar el plugin cubre la config y cache de plugins de Codex, pero el wrapper `superloopy` y los agents personales copiados pueden quedar. Revísalos antes de borrarlos, sobre todo si personalizaste algún archivo de agent. -``` +```sh rm -f ~/.local/bin/superloopy rm -f ~/.codex/agents/franky.toml ~/.codex/agents/zoro.toml ~/.codex/agents/usopp.toml ~/.codex/agents/jinbe.toml ~/.codex/agents/robin.toml ~/.codex/agents/nami.toml ``` +```powershell +Remove-Item "$env:APPDATA\npm\superloopy.cmd" -ErrorAction SilentlyContinue +Remove-Item "$env:USERPROFILE\.codex\agents\franky.toml", "$env:USERPROFILE\.codex\agents\zoro.toml", "$env:USERPROFILE\.codex\agents\usopp.toml", "$env:USERPROFILE\.codex\agents\jinbe.toml", "$env:USERPROFILE\.codex\agents\robin.toml", "$env:USERPROFILE\.codex\agents\nami.toml" -ErrorAction SilentlyContinue +``` + Si instalaste con `CODEX_HOME`, `SUPERLOOPY_BIN_DIR` o `CODEX_LOCAL_BIN_DIR`, limpia esas rutas configuradas. ### Claude Code diff --git a/README.ja.md b/README.ja.md index 51c2ba4..911312d 100644 --- a/README.ja.md +++ b/README.ja.md @@ -167,15 +167,15 @@ checkout インストールは `npx` 管理ではありません。`npx` self-up ### Claude Code -marketplace を更新し、新しいバージョンを解決するために再インストールしてから再読み込みします。再起動は不要です。 +marketplace を更新し、インストール済みプラグインを更新してから再読み込みします。再起動は不要です。 ``` /plugin marketplace update beefiker -/plugin install superloopy@beefiker +/plugin update superloopy@beefiker /reload-plugins ``` -別途 `/plugin update` コマンドはありません。更新済み marketplace から再インストールすると新しいバージョンが解決され、`/reload-plugins` が現在のセッションにそれを適用します(Claude Code の再起動は不要で、hooks の再承認も必要ありません)。`node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json` で確認してください。`--plugin-dir` で checkout を読み込んでいる場合は、`git pull --ff-only` を実行して `/reload-plugins` するだけです。 +`/plugin update` が更新済み marketplace から新しいバージョンを解決し、`/reload-plugins` が現在のセッションにそれを適用します(Claude Code の再起動は不要で、hooks の再承認も必要ありません)。`node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json` で確認してください。`--plugin-dir` で checkout を読み込んでいる場合は、`git pull --ff-only` を実行して `/reload-plugins` するだけです。 ## トラブルシューティング @@ -203,11 +203,16 @@ codex plugin marketplace remove beefiker アンインストール後に Codex を再起動してください。任意の local bootstrap cleanup: plugin の削除は Codex の plugin config と cache を扱いますが、`superloopy` wrapper と個人ディレクトリへコピーされた agents は残ることがあります。agent ファイルをカスタマイズしている場合は、削除前に確認してください。 -``` +```sh rm -f ~/.local/bin/superloopy rm -f ~/.codex/agents/franky.toml ~/.codex/agents/zoro.toml ~/.codex/agents/usopp.toml ~/.codex/agents/jinbe.toml ~/.codex/agents/robin.toml ~/.codex/agents/nami.toml ``` +```powershell +Remove-Item "$env:APPDATA\npm\superloopy.cmd" -ErrorAction SilentlyContinue +Remove-Item "$env:USERPROFILE\.codex\agents\franky.toml", "$env:USERPROFILE\.codex\agents\zoro.toml", "$env:USERPROFILE\.codex\agents\usopp.toml", "$env:USERPROFILE\.codex\agents\jinbe.toml", "$env:USERPROFILE\.codex\agents\robin.toml", "$env:USERPROFILE\.codex\agents\nami.toml" -ErrorAction SilentlyContinue +``` + `CODEX_HOME`、`SUPERLOOPY_BIN_DIR`、`CODEX_LOCAL_BIN_DIR` を使ってインストール先を変えた場合は、その設定先を削除してください。 ### Claude Code diff --git a/README.ko.md b/README.ko.md index ac5a907..f6c90c7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -179,15 +179,15 @@ checkout 설치는 `npx` 관리 대상이 아닙니다. `npx` self-update는 안 ### Claude Code -marketplace를 갱신하고, 새 버전을 받도록 다시 설치한 뒤 리로드하세요. 재시작은 필요 없습니다. +marketplace를 갱신하고, 설치된 플러그인을 업데이트한 뒤 리로드하세요. 재시작은 필요 없습니다. ``` /plugin marketplace update beefiker -/plugin install superloopy@beefiker +/plugin update superloopy@beefiker /reload-plugins ``` -별도의 `/plugin update` 명령은 없습니다. 갱신된 marketplace에서 다시 설치하면 새 버전이 잡히고, `/reload-plugins`가 현재 세션에 적용합니다(Claude Code 재시작이 필요 없고, hooks도 다시 승인할 필요가 없습니다). 확인은 `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json`으로 합니다. `--plugin-dir`로 checkout을 로드했다면 `git pull --ff-only` 후 `/reload-plugins`만 하면 됩니다. +`/plugin update`가 갱신된 marketplace에서 새 버전을 받고, `/reload-plugins`가 현재 세션에 적용합니다(Claude Code 재시작이 필요 없고, hooks도 다시 승인할 필요가 없습니다). 확인은 `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json`으로 합니다. `--plugin-dir`로 checkout을 로드했다면 `git pull --ff-only` 후 `/reload-plugins`만 하면 됩니다. ## 트러블슈팅 @@ -215,11 +215,16 @@ codex plugin marketplace remove beefiker 제거 뒤 Codex를 재시작하세요. 선택적 local bootstrap cleanup: plugin 제거는 Codex의 plugin config와 cache를 지우지만, `superloopy` wrapper와 개인 agents 사본은 남을 수 있습니다. agent 파일을 수정한 적이 있다면 지우기 전에 꼭 확인하세요. -``` +```sh rm -f ~/.local/bin/superloopy rm -f ~/.codex/agents/franky.toml ~/.codex/agents/zoro.toml ~/.codex/agents/usopp.toml ~/.codex/agents/jinbe.toml ~/.codex/agents/robin.toml ~/.codex/agents/nami.toml ``` +```powershell +Remove-Item "$env:APPDATA\npm\superloopy.cmd" -ErrorAction SilentlyContinue +Remove-Item "$env:USERPROFILE\.codex\agents\franky.toml", "$env:USERPROFILE\.codex\agents\zoro.toml", "$env:USERPROFILE\.codex\agents\usopp.toml", "$env:USERPROFILE\.codex\agents\jinbe.toml", "$env:USERPROFILE\.codex\agents\robin.toml", "$env:USERPROFILE\.codex\agents\nami.toml" -ErrorAction SilentlyContinue +``` + `CODEX_HOME`, `SUPERLOOPY_BIN_DIR`, `CODEX_LOCAL_BIN_DIR`로 설치 위치를 바꿨다면 그 경로를 정리하세요. ### Claude Code diff --git a/README.md b/README.md index e9648b3..8882ea9 100644 --- a/README.md +++ b/README.md @@ -187,15 +187,15 @@ Checkout installs are not `npx`-managed. `npx` self-update is reserved for a fut ### Claude Code -Refresh the marketplace, reinstall to resolve the new version, then reload — no restart needed: +Refresh the marketplace, update the installed plugin, then reload — no restart needed: ``` /plugin marketplace update beefiker -/plugin install superloopy@beefiker +/plugin update superloopy@beefiker /reload-plugins ``` -There is no separate `/plugin update` command: reinstalling from the refreshed marketplace resolves the new version, and `/reload-plugins` applies it in the current session (no Claude Code restart, and hooks do not need re-approval). Verify with `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json`. If you loaded a checkout with `--plugin-dir`, just `git pull --ff-only` and run `/reload-plugins`. +`/plugin update` resolves the new version from the refreshed marketplace, and `/reload-plugins` applies it in the current session (no Claude Code restart, and hooks do not need re-approval). Verify with `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json`. If you loaded a checkout with `--plugin-dir`, just `git pull --ff-only` and run `/reload-plugins`. ## Troubleshooting @@ -223,12 +223,16 @@ codex plugin marketplace remove beefiker Restart Codex after uninstalling. Optional local bootstrap cleanup: plugin removal handles Codex's plugin config and cache, but the `superloopy` wrapper and copied personal agents can remain. Review before deleting them, especially if you customized any agent file. -``` +```sh rm -f ~/.local/bin/superloopy -Remove-Item "$env:APPDATA\npm\superloopy.cmd" -ErrorAction SilentlyContinue rm -f ~/.codex/agents/franky.toml ~/.codex/agents/zoro.toml ~/.codex/agents/usopp.toml ~/.codex/agents/jinbe.toml ~/.codex/agents/robin.toml ~/.codex/agents/nami.toml ``` +```powershell +Remove-Item "$env:APPDATA\npm\superloopy.cmd" -ErrorAction SilentlyContinue +Remove-Item "$env:USERPROFILE\.codex\agents\franky.toml", "$env:USERPROFILE\.codex\agents\zoro.toml", "$env:USERPROFILE\.codex\agents\usopp.toml", "$env:USERPROFILE\.codex\agents\jinbe.toml", "$env:USERPROFILE\.codex\agents\robin.toml", "$env:USERPROFILE\.codex\agents\nami.toml" -ErrorAction SilentlyContinue +``` + If you installed with `CODEX_HOME`, `SUPERLOOPY_BIN_DIR`, or `CODEX_LOCAL_BIN_DIR`, clean up those configured paths instead. ### Claude Code diff --git a/README.zh-CN.md b/README.zh-CN.md index 895ee4a..a028b25 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -167,15 +167,15 @@ checkout 安装不是 `npx` 管理的。`npx` self-update 只会在未来有稳 ### Claude Code -刷新 marketplace,重新安装以解析新版本,然后重新加载——无需重启: +刷新 marketplace,更新已安装的插件,然后重新加载——无需重启: ``` /plugin marketplace update beefiker -/plugin install superloopy@beefiker +/plugin update superloopy@beefiker /reload-plugins ``` -没有单独的 `/plugin update` 命令:从已刷新的 marketplace 重新安装即可解析出新版本,`/reload-plugins` 会在当前会话中应用它(无需重启 Claude Code,hooks 也不需要重新批准)。用 `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json` 验证。如果你用 `--plugin-dir` 加载了一个 checkout,只需 `git pull --ff-only` 再运行 `/reload-plugins`。 +`/plugin update` 会从已刷新的 marketplace 解析新版本,`/reload-plugins` 会在当前会话中应用它(无需重启 Claude Code,hooks 也不需要重新批准)。用 `node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" doctor --json` 验证。如果你用 `--plugin-dir` 加载了一个 checkout,只需 `git pull --ff-only` 再运行 `/reload-plugins`。 ## 故障排除 @@ -203,11 +203,16 @@ codex plugin marketplace remove beefiker 卸载后重启 Codex。可选的 local bootstrap cleanup:plugin 删除会移除 Codex 的 plugin config 和 cache,但 `superloopy` wrapper 以及复制到个人目录的 agents 可能会保留。删除前请先检查,尤其是你修改过 agent 文件时。 -``` +```sh rm -f ~/.local/bin/superloopy rm -f ~/.codex/agents/franky.toml ~/.codex/agents/zoro.toml ~/.codex/agents/usopp.toml ~/.codex/agents/jinbe.toml ~/.codex/agents/robin.toml ~/.codex/agents/nami.toml ``` +```powershell +Remove-Item "$env:APPDATA\npm\superloopy.cmd" -ErrorAction SilentlyContinue +Remove-Item "$env:USERPROFILE\.codex\agents\franky.toml", "$env:USERPROFILE\.codex\agents\zoro.toml", "$env:USERPROFILE\.codex\agents\usopp.toml", "$env:USERPROFILE\.codex\agents\jinbe.toml", "$env:USERPROFILE\.codex\agents\robin.toml", "$env:USERPROFILE\.codex\agents\nami.toml" -ErrorAction SilentlyContinue +``` + 如果安装时使用了 `CODEX_HOME`、`SUPERLOOPY_BIN_DIR` 或 `CODEX_LOCAL_BIN_DIR`,请清理对应路径。 ### Claude Code diff --git a/docs/superloopy-claude-validation.md b/docs/superloopy-claude-validation.md index fef5d3f..477f8e2 100644 --- a/docs/superloopy-claude-validation.md +++ b/docs/superloopy-claude-validation.md @@ -35,7 +35,7 @@ Then dispatch any subagent (e.g. start a `loopy team` task that spawns `franky`, - [ ] Functional check: have a worker end with `SUPERLOOPY_EVIDENCE: `. Expect the SubagentStop hook to **accept silently** (no "evidence receipt missing" re-prompt). A worker that omits the receipt should be re-prompted (up to 3 attempts). ### Linchpin B — agent_type namespacing -- [ ] Is `agent_type` `franky` or `superloopy:franky`? Either is fine — the matcher `(?:superloopy:)?(?:franky|…)` and `normalizeAgentType()` handle both. Confirm the worker hook actually fired for the dispatched role (and the `robin` auditor routes to `subagent-stop-audit`). +- [ ] Is `agent_type` exactly `superloopy:franky`? Claude's worker matcher is `^superloopy:(?:franky|zoro|usopp|jinbe|nami)$`; bare or foreign-namespaced identities must not satisfy it. Confirm the worker hook actually fired for the dispatched role and exact `superloopy:robin` routes to `subagent-stop-audit`. ## 3. Linchpin C — plugin env vars reach the hook subprocess @@ -65,7 +65,7 @@ End-to-end live run on real Claude Code (2026-07-01), hardened build loaded via | --- | --- | --- | --- | --- | | Install + components | superloopy + 6 agents + skills load | Plugin loaded from the checkout via `--plugin-dir`; 6 namespaced agents dispatchable (dispatched `superloopy:franky` ×2 and `superloopy:nami` live); skills present; `doctor` from the plugin root `ok:true`, 16 checks incl. `claudeHostWiring`/`claudeModelPolicy`. | ✅ | `doctor` audits its cwd — running it from an empty dir reports `ok:false`; run it from the plugin root. | | A: receipt field | valid receipt accepted silently; missing receipt re-prompted (≤3), transcript fallback recovers path | **Live 3-way contrast:** `franky` w/ valid `SUPERLOOPY_EVIDENCE:` + real artifact → **0 re-prompts (silent accept)**; `franky` told to omit the receipt → **re-prompted once** (`"Attempt 1 of 3"` injected by the hook), then wrote a real artifact + emitted the receipt; `nami` (excluded set) → 0 re-prompts. Deterministic replay of the real `franky` message: valid→empty accept, receipt-stripped→`decision:block`, receipt-for-nonexistent-file→`decision:block` (validates artifact existence, not just the string). | ✅ | Raw `last_assistant_message` not re-captured (mid-session observation hook didn't hot-reload); a prior session confirmed the direct field present. The live re-prompt/accept contrast proves recovery works on the real Claude payload regardless. | -| B: agent_type | matcher fires (bare or namespaced) | Live dispatches carried `agent_type` `superloopy:franky`/`superloopy:nami`. Anchored matcher `^(?:superloopy:)?(?:franky\|zoro\|usopp\|jinbe\|nami)$` matches namespaced **and** bare; `normalizeAgentType` → `franky`/`nami`; `robin`/`superloopy:robin` route to the separate audit matcher; `frankyx`/`superloopy:otherthing` correctly rejected. `nami` is outside the evidence-receipt set → silent no-op (correct for the read-only navigator). | ✅ | — | +| B: agent_type | exact namespaced matcher fires | Live dispatches carried `agent_type` `superloopy:franky`/`superloopy:nami`. The hardened contract now matches exact host-owned identities: Claude workers use `^superloopy:(?:franky\|zoro\|usopp\|jinbe\|nami)$`, the auditor uses exact `superloopy:robin`, and bare or foreign-namespaced identities are rejected. `nami` is outside the evidence-receipt set → silent no-op (correct for the read-only navigator). | ✅ | — | | C: env vars | Stop continuation while criteria remain + auto-context toggle | Exercised as subprocesses with the exact env Claude injects (`CLAUDE_PLUGIN_ROOT` set ⇒ host=claude; settings `env`): `Stop`+`SUPERLOOPY_STOP_HOOK=on`, criteria remaining → `decision:block` (continue) + `loop_iteration` recorded; unset → empty (inert). `SessionStart`/`UserPromptSubmit`+`SUPERLOOPY_AUTO_CONTEXT=on` → inject "Superloopy context"; unset → empty. Post-completion `Stop` → empty and `loop-control.json` cleared (engine terminates cleanly). | ✅ | Full end-to-end (settings.json `env` + restart) not run — no restart available this session. `settings.json` `env` is the toggle location and takes effect on next session start. | | D: steering JSON | directive applied, no parse error | `SUPERLOOPY_STEER: {"kind":"annotate",…}` → hook emitted **valid JSON** (keys `ok,kind,plan,summary,guide`), **no parse error**; the annotation landed durably in the ledger (`steering_annotated` with the exact `evidence`/`rationale`). | ✅ | The annotate envelope is not Claude's `hookSpecificOutput.additionalContext` shape, so Claude ignores its display fields (no error / no rejection) — the recorded annotation is the applied, degrade-safe effect. | | Full loop | gated begin→prove→check→finish | Ran `begin → prove(C001) → prove(C002) → check → finish` on the hardened build. `finish` refused at 0/2 proven (*"unresolved criteria: G001/C001, G001/C002"*) and at 1/2 (*"…G001/C002"*); completed only after both command-backed criteria passed — wrote a valid `gate.json` (`status:passed`, 2 artifacts) and set `aggregateComplete:true`. `finish` re-derived both criteria (audit `*-rerun.txt`). | ✅ | — | diff --git a/docs/superloopy-host-contract.md b/docs/superloopy-host-contract.md index 1cae02a..bbc2c36 100644 --- a/docs/superloopy-host-contract.md +++ b/docs/superloopy-host-contract.md @@ -21,16 +21,18 @@ For `loopy team`, `loopy crew`, `loopycrew`, and `ultrawork` runs, full-crew han The parent must still show a concise human-facing completion summary for each role: role, normalized verdict, artifact path, risk, and next action. Raw host close-agent output is not evidence and should not be the user's only signal that a role finished. -## Role routing is not guaranteed - -The host's spawn surface (for example `multi_agent_v1.spawn_agent`) accepts a `message` plus -flags such as `agent_type`, `fork_context`, and `model`. It does **not** reliably select a -bundled TOML role, model, reasoning effort, or service tier by name alone — name-based role -routing is unverified from Superloopy's side. Therefore a dispatch must be **self-contained**: the -parent pastes the role's requirements and the bounded assignment into the spawn message and -judges the result by the delivered evidence, not by the name it requested. Superloopy already does -this — the receipt gate (`SUPERLOOPY_EVIDENCE`/`SUPERLOOPY_AUDIT`) and the deterministic floor judge what -a worker actually produced, never which role label was asked for. +## Role routing and identity + +The parent uses the native subagent controls exposed by the current host and selects a configured +crew name when named selection is available. It must not invent arguments that the host's current +schema does not expose. Every dispatch remains **self-contained**: the parent pastes the role's +requirements and bounded assignment into the message. + +Configured names provide steering; the host-owned stop callback supplies the role identity that +Superloopy matches exactly. If the host cannot attest that identity or the resolved model, the +lane is reported as `role_unverified` or `model_unverified`. The receipt gate +(`SUPERLOOPY_EVIDENCE`/`SUPERLOOPY_AUDIT`) and deterministic floor still judge what the worker +actually produced. ## Model policy diff --git a/hooks/hooks.json b/hooks/hooks.json index 4cb1942..1a48fc2 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -23,13 +23,13 @@ ], "SubagentStop": [ { - "matcher": "^(?:superloopy:)?(?:franky|zoro|usopp|jinbe|nami)$", + "matcher": "^superloopy:(?:franky|zoro|usopp|jinbe|nami)$", "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/cli.js\" hook subagent-stop", "timeout": 5 } ] }, { - "matcher": "^(?:superloopy:)?robin$", + "matcher": "^superloopy:robin$", "hooks": [ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/src/cli.js\" hook subagent-stop-audit", "timeout": 5 } ] diff --git a/legacy-agent-manifests.json b/legacy-agent-manifests.json index 0f91d8a..1913358 100644 --- a/legacy-agent-manifests.json +++ b/legacy-agent-manifests.json @@ -11,6 +11,17 @@ "robin": { "sha256": "d861b93c75cdb45f6172c9c2ae3468721e0e8ce16f3a94c1bc5605e23b59378b" }, "nami": { "sha256": "4a38b64a31b86e87344eccc6f98d4708f19540a84070e6ac60770016f8566925" } } + }, + { + "id": "0.12.3", + "files": { + "franky": { "sha256": "68b00c8a6ae4fb2f97bf6764da70d2d8abee5f8672639cf75f587a74ba8f8217" }, + "zoro": { "sha256": "2bc7e1496c45f9972aa816c2e2ef921228e0089854c7c0a38bef249f12b4e62b" }, + "usopp": { "sha256": "abe34c3cf0ec5deb510cf8f80bbc77e2d66dc1f68a71734e2b24cde34360872f" }, + "jinbe": { "sha256": "758b81ecc1efca189a56d276f1d27988d02bc18f290b6f33655e256d21ff4c38" }, + "robin": { "sha256": "d861b93c75cdb45f6172c9c2ae3468721e0e8ce16f3a94c1bc5605e23b59378b" }, + "nami": { "sha256": "a82e77488e43e633a4c9c5c2e261b54327195aad10e110d3ff8057795babc05e" } + } } ] } diff --git a/package-lock.json b/package-lock.json index ef96563..13d398b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "superloopy", - "version": "0.12.2", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "superloopy", - "version": "0.12.2", + "version": "0.12.3", "license": "MIT", "bin": { "superloopy": "src/cli.js" diff --git a/package.json b/package.json index 5825bfa..aeca59a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superloopy", - "version": "0.12.2", + "version": "0.12.3", "description": "A lightweight loop harness for Codex and Claude Code with strict evidence gates.", "type": "module", "bin": { diff --git a/skills/superloopy-doctor/SKILL.md b/skills/superloopy-doctor/SKILL.md index c64806b..905e79b 100644 --- a/skills/superloopy-doctor/SKILL.md +++ b/skills/superloopy-doctor/SKILL.md @@ -15,18 +15,18 @@ Use this skill to prove whether a local Superloopy surface can support the Super This is a Superloopy health check, not a generic plugin drift audit. -- Treat `superloopy doctor --json` as the primary truth surface. +- Treat `superloopy doctor --scope source --json` as the source-checkout truth surface and `superloopy doctor --scope installed --json` as the machine-local installation truth surface. The JSON `scope` field records which verdict contract was used. - Treat loop commands as the behavioral truth surface, but keep their limits clear: `superloopy loop status --json`, `superloopy loop check`, `superloopy loop guide --json`, and evidence files under `.superloopy/evidence/` can show current plan and artifact readiness. - Read the installed plugin cache only to identify which Superloopy code the wrapper runs. - Do not clone external repos, search issue trackers, or compare unrelated harness layouts unless the user separately asks for research. -- `superloopy doctor` reports the plugin root it checked: by default it uses the current directory when that is a Superloopy checkout, otherwise it checks the installed CLI root. Use `superloopy doctor --root --json` when you need to pin the target explicitly. +- `superloopy doctor` reports the plugin root it checked: by default it uses `source` scope in a recognized Superloopy checkout and `installed` scope elsewhere. Pin a checkout with `superloopy doctor --root --scope source --json`; pin an installed cache with `superloopy doctor --root --scope installed --json`. Source verdicts still return machine-local diagnostics, but `installedModelPolicy` and `wrapper` do not gate source health. ## Diagnostic Spine 1. Identify the target root: `pwd`, `git rev-parse --show-toplevel` when it is a checkout, and the installed plugin cache path when the wrapper points into one. 2. Locate the user-visible command: `command -v superloopy`, `which -a superloopy`, and the wrapper text or symlink target. 3. Inspect Codex registry state with `codex plugin list --json` when Codex is available; use it only to locate `superloopy@beefiker` and its version. -4. Run `superloopy doctor --json` and record the reported `root`. If the wrapper is missing or stale, run `node src/cli.js doctor --root --json` from the checkout or installed cache being evaluated. +4. Run the matching scoped command and record the reported `scope` and `root`. For checkout health use `superloopy doctor --root --scope source --json`; for a machine-local wrapper, managed-agent fleet, or installed cache use `superloopy doctor --root --scope installed --json`. 5. Read the named doctor checks, especially `skills`, `fileAudit`, `reviewability`, `dispatchCoherence`, `hostContract`, `modelPolicy`, and `claudeHostWiring`. 6. Probe loop readiness without changing state: use `superloopy loop status --json` if a plan exists, otherwise note "no active plan" instead of creating one. Use `superloopy loop check` only to validate trace/artifact readiness for an existing plan; read-only diagnosis cannot prove completion safety because it does not re-run recorded commands. If the user asks whether completion is actually safe, point at the real gates: `superloopy loop review`, `superloopy loop checkpoint`, or `superloopy loop finish`. 7. Verify crew install shape by name: `franky`, `zoro`, `usopp`, `jinbe`, `robin`, and `nami`; judge by files and doctor checks, not by role labels alone. diff --git a/skills/superloopy-loop/SKILL.md b/skills/superloopy-loop/SKILL.md index b976835..e04f806 100644 --- a/skills/superloopy-loop/SKILL.md +++ b/skills/superloopy-loop/SKILL.md @@ -36,10 +36,10 @@ When the user opens a message with `loopy `, act as the loop engineer: The loop engineer directive is injected for every `loopy` prompt, and it scales to the work: -- **Solo (default).** A plain `loopy ` drives one agent through the loop. The directive still permits light delegation: if the work splits into 2+ genuinely independent slices, you may fan them out in parallel with the host's native `multi_agent_v1.spawn_agent` (self-contained `message`, `fork_context: false`); for a single cohesive change, stay solo. -- **Crew (`loopy team ` / `loopy crew `, the connected one-word `loopycrew `, or the standalone `ultrawork `).** The same engineer escalates into full fan-out: dispatch the crew across independent lanes, collect them with `multi_agent_v1.wait_agent`, and record only artifact-backed proof. The escalation keyword is stripped from the brief that seeds the loop. This is the active counterpart to "Optional Subagent-Driven Mode" below — same dispatch contract, receipt gate, and mandatory `handoff`/`fleet` tracking. +- **Solo (default).** A plain `loopy ` drives one agent through the loop. The directive still permits light delegation: if the work splits into 2+ genuinely independent slices, you may fan them out with the native subagent controls exposed by the current host; keep each assignment self-contained. For a single cohesive change, stay solo. +- **Crew (`loopy team ` / `loopy crew `, the connected one-word `loopycrew `, or the standalone `ultrawork `).** The same engineer escalates into full fan-out: dispatch the crew across independent lanes, collect them with the host's native lifecycle controls, and record only artifact-backed proof. The escalation keyword is stripped from the brief that seeds the loop. This is the active counterpart to "Optional Subagent-Driven Mode" below — same dispatch contract, receipt gate, and mandatory `handoff`/`fleet` tracking. -Each crew dispatch sets `agent_type` to the role so the host can load that role's TOML instructions and advisory model policy: `agent_type: "franky"` to build, `"zoro"` to review, `"usopp"` to test, `"jinbe"` to gate, `"robin"` to audit, `"nami"` to navigate. Because role routing and model defaults are best-effort across hosts, the `message` also stays self-contained (`TASK: act as ...`) so the worker behaves correctly even if the host ignores `agent_type`. +Each crew dispatch uses the configured name when the host exposes named selection: `franky` to build, `zoro` to review, `usopp` to test, `jinbe` to gate, `robin` to audit, and `nami` to navigate. The assignment also stays self-contained (`TASK: act as ...`). The host-owned stop callback observes the actual role identity; if the host cannot attest that identity or model, report `role_unverified` or `model_unverified`. Both tiers are steering, not enforcement: the directive instructs the main agent, and actual spawning depends on the host's native multi-agent tool being available. Superloopy never spawns; it gates the evidence workers deliver. See "Optional Subagent-Driven Mode" for the full dispatch contract and crew roles. diff --git a/src/audit-hooks.js b/src/audit-hooks.js index 70a5cf0..4125192 100644 --- a/src/audit-hooks.js +++ b/src/audit-hooks.js @@ -15,15 +15,15 @@ import { dirname, join } from "node:path"; import { resolveEvidenceArtifact } from "./artifacts.js"; import { auditMaxFails, auditOneCriterion, recordAuditFailure } from "./audit.js"; import { CONTEXT_PRESSURE_MARKERS, transcriptTailHasMarker } from "./continuation.js"; -import { auditReceiptFromPayload, normalizeAgentType, subagentTranscriptPath } from "./receipt.js"; +import { auditReceiptFromPayload, matchesAgentType, subagentTranscriptPath } from "./receipt.js"; import { validateAuditVerdict, verifyVerdictAgainstState } from "./audit-verdict.js"; import { appendLedger, auditStatePath, evidenceRelativeDir, goalsPath, nowIso, scopeFromSessionId, withFileLock, writeJsonAtomic } from "./store.js"; const MAX_AUDIT_ATTEMPTS = 3; -export async function runAuditorStopHook(payload) { +export async function runAuditorStopHook(payload, context = { host: "codex" }) { if (payload === null || typeof payload !== "object") return ""; if (payload.hook_event_name !== "SubagentStop") return ""; - if (normalizeAgentType(payload.agent_type) !== "robin") return ""; + if (!matchesAgentType({ host: context.host, agentType: payload.agent_type, role: "robin" })) return ""; if (typeof payload.cwd !== "string") return ""; // Read the SAME transcript the audit receipt recovery reads (agent_transcript_path on Claude), // so a marker in a different transcript can't skip the audit gate. diff --git a/src/audit.js b/src/audit.js index eafce5b..7108315 100644 --- a/src/audit.js +++ b/src/audit.js @@ -223,7 +223,7 @@ function renderAuditDispatch(scope, pending) { "" ]; for (const entry of pending) { - lines.push(`- ${entry.criterion}: \`task(subagent_type="robin", run_in_background=false)\` — cite re-run artifact \`${entry.rerunArtifact}\` against the scenario; write verdict to \`${evidenceRelativeDir(scope)}/audit/${entry.criterion.replace("/", "-")}-verdict.json\`.`); + lines.push(`- ${entry.criterion}: use the host's native subagent controls to dispatch the configured read-only \`robin\` auditor — cite re-run artifact \`${entry.rerunArtifact}\` against the scenario; write verdict to \`${evidenceRelativeDir(scope)}/audit/${entry.criterion.replace("/", "-")}-verdict.json\`. If named selection is unavailable, report \`named_agent_unavailable\` and \`trusted_authority_unavailable\`; do not fabricate a legacy receipt.`); } lines.push("", `The auditor must be read-only and skeptical. A pass requires citing the re-run artifact. Re-check status with \`superloopy loop audit${sessionFlag} --json\`.`); return lines.join("\n"); diff --git a/src/cli.js b/src/cli.js index c853a1c..6459173 100755 --- a/src/cli.js +++ b/src/cli.js @@ -10,7 +10,8 @@ import { formatBinInstallResult, formatBootstrapResult, installAgents, - installBinShim + installBinShim, + isClaudeHost } from "./agents.js"; import { auditLoop } from "./audit.js"; import { runAuditorStopHook } from "./audit-hooks.js"; @@ -25,6 +26,7 @@ import { formatCrewLine } from "./crew-lines.js"; import { trustLoop } from "./plan-trust.js"; import { proveLoop } from "./prove.js"; import { reportLoop } from "./report.js"; +import { isSourceCheckoutRoot } from "./source-checkout.js"; import { formatTraceResult, traceLoop } from "./trace.js"; import { checkpointLoop, @@ -47,6 +49,8 @@ import { const CLI_FILE = fileURLToPath(import.meta.url); const CLI_ROOT = dirname(dirname(CLI_FILE)); +class CliUsageError extends Error {} + async function main(argv, stdin, stdout, stderr, cwd) { const [command, subcommand, ...rest] = argv; try { @@ -76,7 +80,7 @@ async function main(argv, stdin, stdout, stderr, cwd) { return 1; } catch (error) { stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - return 1; + return error instanceof CliUsageError ? 2 : 1; } } @@ -128,24 +132,79 @@ async function runDoctorCommand(argv, stdout, cwd) { stdout.write(doctorHelp()); return 0; } - const json = hasFlag(argv, "--json"); - const comparisonPath = readFlag(argv, "--comparison-path"); - const result = await runDoctor(resolveDoctorRoot(cwd, argv), { - comparisonPath, + const parsed = parseDoctorArgs(argv); + const selection = resolveDoctorSelection(cwd, parsed); + const result = await runDoctor(selection.root, { + scope: selection.scope, + comparisonPath: parsed.comparisonPath, installedModelPolicy: { env: process.env, homeDir: homedir(), - refreshModels: hasFlag(argv, "--refresh-models") + refreshModels: parsed.refreshModels } }); - stdout.write(json ? `${JSON.stringify(result, null, 2)}\n` : formatDoctor(result)); + stdout.write(parsed.json ? `${JSON.stringify(result, null, 2)}\n` : formatDoctor(result)); return result.ok ? 0 : 1; } -function resolveDoctorRoot(cwd, argv) { - const explicit = readFlag(argv, "--root") ?? readFlag(argv, "--plugin-root"); - if (explicit !== undefined) return resolve(cwd, explicit); - return isLikelySuperloopyPluginRoot(cwd) ? cwd : CLI_ROOT; +function parseDoctorArgs(argv) { + const parsed = { + json: false, + refreshModels: false, + root: undefined, + scope: undefined, + comparisonPath: undefined + }; + const seen = new Set(); + let rootSelector; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--json") { + parsed.json = true; + continue; + } + if (token === "--refresh-models") { + parsed.refreshModels = true; + continue; + } + + const equals = token.indexOf("="); + const name = equals >= 0 ? token.slice(0, equals) : token; + if (!["--root", "--plugin-root", "--scope", "--comparison-path"].includes(name)) { + throw new CliUsageError(token.startsWith("-") ? `Unknown doctor option: ${token}.` : `Unexpected doctor argument: ${token}.`); + } + if (seen.has(name)) throw new CliUsageError(`Duplicate doctor option: ${name}.`); + seen.add(name); + const value = equals >= 0 ? token.slice(equals + 1) : argv[++index]; + if (value === undefined || value.length === 0 || (equals < 0 && value.startsWith("-"))) { + throw new CliUsageError(`Missing ${name}.`); + } + + if (name === "--root" || name === "--plugin-root") { + if (rootSelector !== undefined) { + throw new CliUsageError("Use only one of --root or --plugin-root."); + } + rootSelector = name; + parsed.root = value; + } else if (name === "--scope") { + if (value !== "source" && value !== "installed") { + throw new CliUsageError("--scope must be source or installed."); + } + parsed.scope = value; + } else { + parsed.comparisonPath = value; + } + } + return parsed; +} + +function resolveDoctorSelection(cwd, parsed) { + const explicitRoot = parsed.root === undefined ? undefined : resolve(cwd, parsed.root); + const recognizedCheckout = isLikelySuperloopyPluginRoot(cwd) && isSourceCheckoutRoot(cwd); + const scope = parsed.scope ?? (explicitRoot !== undefined || recognizedCheckout ? "source" : "installed"); + const root = explicitRoot ?? (scope === "source" && recognizedCheckout ? cwd : CLI_ROOT); + return { root, scope }; } function isLikelySuperloopyPluginRoot(cwd) { @@ -232,6 +291,7 @@ async function dispatchLoop(cwd, subcommand, argv) { async function runHook(subcommand, stdin, stdout) { const payload = parseJson(await readStdin(stdin)); + const context = { host: isClaudeHost(process.env) ? "claude" : "codex" }; if (subcommand === "session-start") { stdout.write(await runSessionStartHook(payload)); return 0; @@ -241,11 +301,11 @@ async function runHook(subcommand, stdin, stdout) { return 0; } if (subcommand === "subagent-stop") { - stdout.write(runSubagentStopHook(payload)); + stdout.write(runSubagentStopHook(payload, context)); return 0; } if (subcommand === "subagent-stop-audit") { - stdout.write(await runAuditorStopHook(payload)); + stdout.write(await runAuditorStopHook(payload, context)); return 0; } if (subcommand === "stop") { @@ -318,7 +378,7 @@ function topHelp() { " superloopy install [--bin-dir PATH] [--target PATH] [--refresh-models] [--compat] [--force] [--json]", " superloopy bin install [--bin-dir PATH] [--force] [--json]", " superloopy agents install [--target PATH] [--refresh-models] [--compat] [--force] [--json]", - " superloopy doctor [--json] [--root PATH] [--comparison-path PATH] [--refresh-models]", + " superloopy doctor [--scope source|installed] [--json] [--root PATH|--plugin-root PATH] [--comparison-path PATH] [--refresh-models]", " superloopy hook session-start|pre-tool-use|stop|subagent-stop|user-prompt-submit", "", helpText() @@ -353,9 +413,10 @@ function installHelp() { function doctorHelp() { return [ "Usage:", - " superloopy doctor [--json] [--root PATH] [--comparison-path PATH] [--refresh-models]", + " superloopy doctor [--scope source|installed] [--json] [--root PATH|--plugin-root PATH] [--comparison-path PATH] [--refresh-models]", "", - "Checks repository and installed Superloopy health without launching workers.", + "Checks source or installed Superloopy health without launching workers.", + "Default scope: source in a recognized checkout; installed elsewhere. An explicit root defaults to source.", "Use --refresh-models for one read-only live model comparison; no state or agent files are changed.", "" ].join("\n"); diff --git a/src/doctor.js b/src/doctor.js index 03fdc26..a8e8e60 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -33,6 +33,8 @@ const GENERATED_INSTALL_FILES = new Set([ ]); export async function runDoctor(cwd, options = {}) { + const scope = options.scope ?? "source"; + if (scope !== "source" && scope !== "installed") throw new Error(`Invalid doctor scope: ${scope}.`); const pluginManifest = await checkPluginManifest(cwd); const hooks = pluginManifest.ok ? await checkHooks(cwd, pluginManifest.manifest.hooks) : fail("Plugin manifest missing."); const skills = await checkSkills(cwd); @@ -58,14 +60,20 @@ export async function runDoctor(cwd, options = {}) { const installedModelPolicy = await checkInstalledModelPolicy(cwd, options.installedModelPolicy ?? {}); const checks = { pluginManifest, hooks, skills, cli, dependencies, runtimeBoundary, fileAudit, gateNotes, designAudit, comparisonSimilarity, reviewability, dispatchCoherence, claudeHostWiring, modelPolicy, claudeModelPolicy, installedModelPolicy, hostContract: checkHostContract(), interop: checkInterop(options), wrapper: checkWrapper({ ...options, diagnosedRoot: cwd }) }; return { - ok: Object.values(checks).every((check) => check.ok), + ok: doctorOverallOk(checks, scope), + scope, root: cwd, checks }; } +export function doctorOverallOk(checks, scope) { + const excluded = scope === "source" ? new Set(["installedModelPolicy", "wrapper"]) : new Set(); + return Object.entries(checks).every(([name, check]) => excluded.has(name) || check.ok); +} + export function formatDoctor(result) { - const lines = ["Superloopy doctor"]; + const lines = ["Superloopy doctor", `scope: ${result.scope ?? "source"}`]; for (const [name, check] of Object.entries(result.checks)) { const verdict = check.ok ? check.warning === true ? "warn" : "ok" : "fail"; lines.push(`- ${name}: ${verdict}${doctorDetail(name, check)}`); @@ -197,7 +205,7 @@ async function checkReviewability(cwd) { const files = listGitVisibleFiles(cwd).filter(isReviewableTextFile); const measured = await Promise.all(files.map(async (file) => ({ file, - lines: countLines(await readFile(join(cwd, file), "utf8")) + lines: countPhysicalLines(await readFile(join(cwd, file), "utf8")) }))); const oversized = measured.filter((item) => item.lines > MAX_REVIEWABLE_LINES); if (oversized.length > 0) { @@ -215,14 +223,15 @@ async function checkReviewability(cwd) { } } -function isReviewableTextFile(file) { +export function isReviewableTextFile(file) { // web-superloopy/public/_nuxt holds vendored minified orbit-runtime bundles audited by provenance, not line count. // skills/superloopy-slides/bold-template-pack holds the vendored frontend-slides template pack audited by provenance, not line count. - return /\.(js|md|json|yaml)$/u.test(file) && !file.endsWith("package-lock.json") && !file.startsWith("web-superloopy/public/_nuxt/") && !file.startsWith("web-superloopy/public/_payload.json") && !file.startsWith("skills/superloopy-slides/bold-template-pack/"); + return /\.(?:[cm]?js|md|json|ya?ml)$/u.test(file) && !file.endsWith("package-lock.json") && !file.startsWith("web-superloopy/public/_nuxt/") && !file.startsWith("web-superloopy/public/_payload.json") && !file.startsWith("skills/superloopy-slides/bold-template-pack/"); } -function countLines(content) { - return content.split("\n").length - 1; +export function countPhysicalLines(content) { + if (content.length === 0) return 0; + return content.split(/\r\n|\r|\n/u).length - (/(?:\r\n|\r|\n)$/u.test(content) ? 1 : 0); } function listGitVisibleFiles(cwd) { @@ -334,9 +343,8 @@ function collectCommands(value) { }); } -// Coherence between the agents Superloopy dispatches, the agents it installs, and the hook -// matchers that gate them. Catches the auditor-install gap class of bug: an agent named -// in a task() dispatch directive that is never installed or never matched by a hook. +// Coherence between the roles Superloopy configures, the agent definitions it installs, and the +// hook matchers that gate them. Catches the gap where a configured role is not installed or matched. async function checkDispatchCoherence(cwd) { try { const agentsDir = join(cwd, ".codex", "agents"); @@ -346,13 +354,13 @@ async function checkDispatchCoherence(cwd) { const missingToml = SUPERLOOPY_AGENT_NAMES.filter((name) => !hasToml(name)); const unmatched = SUPERLOOPY_AGENT_NAMES.filter((name) => !matched(name)); - const dispatched = await collectDispatchedAgentTypes(cwd); + const dispatched = [...SUPERLOOPY_AGENT_NAMES]; const undispatchable = dispatched.filter((name) => !SUPERLOOPY_AGENT_NAMES.includes(name) || !hasToml(name) || !matched(name)); const problems = []; if (missingToml.length > 0) problems.push(`installable agents missing .codex/agents/.toml: ${missingToml.join(", ")}`); if (unmatched.length > 0) problems.push(`installable agents with no SubagentStop matcher: ${unmatched.join(", ")}`); - if (undispatchable.length > 0) problems.push(`dispatched subagent_type not installable/matched: ${undispatchable.join(", ")}`); + if (undispatchable.length > 0) problems.push(`configured roles not installable/matched: ${undispatchable.join(", ")}`); if (problems.length > 0) { return { ok: false, policy: "dispatched-agents-are-installable-and-matched", agents: SUPERLOOPY_AGENT_NAMES, dispatched, message: `Dispatch coherence: ${problems.join("; ")}.` }; } @@ -375,22 +383,6 @@ async function collectSubagentStopMatchers(cwd) { return matchers; } -// Scan only the files that carry real dispatch directives (the audit dispatch builder and -// the user-facing flow docs), not every source file — a `subagent_type=` mention inside a -// comment or example elsewhere is not a dispatch and must not be treated as one. -async function collectDispatchedAgentTypes(cwd) { - const names = new Set(); - for (const rel of ["src/audit.js", "skills/superloopy-loop/SKILL.md", "README.md"]) { - const absolute = join(cwd, rel); - if (!existsSync(absolute)) continue; - const content = await readFile(absolute, "utf8"); - const pattern = /subagent_type\s*=\s*["']([a-z0-9-]+)["']/giu; - let match; - while ((match = pattern.exec(content)) !== null) names.add(match[1]); - } - return [...names]; -} - // Validate the Claude Code wiring that lives OUTSIDE the Codex plugin manifest: the pure-metadata // `.claude-plugin/plugin.json`, and `hooks/hooks.json` (auto-loaded by Claude). The Codex checks // above never touch these, so without this a broken Claude install would still show doctor green. @@ -431,26 +423,26 @@ export async function checkClaudeHostWiring(cwd) { if (subagentStop.length === 0) { problems.push("hooks/hooks.json declares no SubagentStop hooks"); } else { - // Coverage requires the SAME entry to BOTH match an agent AND invoke the CLI, so a broken - // command on one entry can't be masked by a healthy sibling, and a CLI entry that matches - // nothing real can't be propped up by a non-CLI entry that does. Matchers are compiled - // defensively (an invalid pattern is a reported problem, not a doctor crash) and tested with - // a FULL (anchored) match against the namespace Claude sends (`superloopy:`), so a bare - // Codex-style matcher that only substring-matches is rejected. Claude treats a missing/empty - // matcher as match-all. + const workerMatcher = "^superloopy:(?:franky|zoro|usopp|jinbe|nami)$"; + const robinMatcher = "^superloopy:robin$"; + const exactMatchers = new Set([workerMatcher, robinMatcher]); const entries = subagentStop.map((entry) => { const commands = (Array.isArray(entry.hooks) ? entry.hooks : []) .map((hook) => hook?.command) .filter((command) => typeof command === "string"); - const invokesCli = commands.some( - (command) => command.includes("${CLAUDE_PLUGIN_ROOT}/src/cli.js") && command.includes(" hook ") - ); const matcher = typeof entry.matcher === "string" ? entry.matcher : ""; if (matcher.length > 0) matcherSources.push(matcher); + const exactMatcher = exactMatchers.has(matcher); + if (!exactMatcher) { + problems.push(`hooks/hooks.json SubagentStop entry needs an exact plugin identity matcher: ${matcher || ""}`); + } + const expectedSubcommand = matcher === robinMatcher ? "subagent-stop-audit" : "subagent-stop"; + const invokesExpectedCli = commands.some( + (command) => command.includes("${CLAUDE_PLUGIN_ROOT}/src/cli.js") + && new RegExp(`(?:^|\\s)hook\\s+${expectedSubcommand}(?:\\s|$)`, "u").test(command) + ); let regex; - if (matcher.length === 0) { - regex = /.*/u; - } else { + if (matcher.length > 0) { try { regex = new RegExp(`^(?:${matcher})$`, "u"); } catch { @@ -458,11 +450,14 @@ export async function checkClaudeHostWiring(cwd) { problems.push(`hooks/hooks.json SubagentStop matcher is not a valid regex: ${matcher}`); } } - return { regex, invokesCli }; + return { regex, invokesExpectedCli, exactMatcher }; }); const uncovered = SUPERLOOPY_AGENT_NAMES.filter( - (name) => !entries.some((entry) => entry.invokesCli && entry.regex !== null && entry.regex.test(`superloopy:${name}`)) + (name) => !entries.some((entry) => entry.exactMatcher + && entry.invokesExpectedCli + && entry.regex !== null + && entry.regex.test(`superloopy:${name}`)) ); if (uncovered.length > 0) { problems.push(`no CLI-invoking SubagentStop hook covers plugin-namespaced agents: ${uncovered.map((n) => `superloopy:${n}`).join(", ")}`); @@ -492,7 +487,7 @@ function checkHostContract() { modelRoutingVerification: "unverified", unverifiedStatus: "model_unverified", cannotVerify: [ - "the host spawns custom agents from ~/.codex/agents by subagent_type", + "the host loads and launches each configured custom-agent definition through its native controls", "the host attests the resolved model used for each spawned agent", "the host emits SubagentStop with agent_type and agent_id populated", "the host honors a subagent hook returning a block decision by re-prompting it" diff --git a/src/engineer.js b/src/engineer.js index 7997b71..be58fd3 100644 --- a/src/engineer.js +++ b/src/engineer.js @@ -130,7 +130,7 @@ function renderResume(context, orchestrate, interop) { "A loop is already in progress. Resume as the loop engineer and run the next action yourself; do not start a second plan or ask the user to run Superloopy commands.", ...interopBlock(interop), ...(orchestrate - ? ["", "You opened this with `loopy team`: keep delegating independent slices to the crew via `multi_agent_v1.spawn_agent`, and record only artifact-backed proof.", "", ...orchestrationLines(interop)] + ? ["", "You opened this with `loopy team`: keep delegating independent slices with the native subagent controls exposed by the current host, and record only artifact-backed proof.", "", ...orchestrationLines(interop)] : []), "", context @@ -169,7 +169,7 @@ function renderComplete(status, interop) { // Tier 1 (always-on, conservative): one line that keeps a single cohesive change // solo but opens the door to parallel delegation when slices are truly independent. function baselineDelegationLine() { - return "- If the work splits into 2+ genuinely independent slices, you may delegate them in parallel with `multi_agent_v1.spawn_agent` (set `agent_type` to the matching crew role, self-contained `message`, `fork_context: false`) and record each worker's artifact-backed proof; for a single cohesive change, stay solo. Type `loopy team ` to run the full crew."; + return "- If the work splits into 2+ genuinely independent slices, you may delegate them in parallel with the native subagent controls exposed by the current host. Use the configured name when named selection is available, keep every assignment self-contained, and record each worker's artifact-backed proof; for a single cohesive change, stay solo. Type `loopy team ` to run the full crew."; } // Tier 2 (escalation): the crew fan-out playbook, wired to Superloopy's receipt gate. @@ -182,13 +182,13 @@ function orchestrationLines(interop) { : []), "- If the requested repository path differs from `cwd`, verify and state the exact target path before editing or dispatching workers.", "- This task is big enough to split. Delegate independent slices to parallel workers instead of doing everything in one thread.", - "- Spawn each worker with the host's native tool, and ALWAYS set `agent_type` to the crew role so the child loads that role's model and instructions, e.g.: `multi_agent_v1.spawn_agent({\"message\": \"TASK: act as franky — \", \"agent_type\": \"franky\", \"fork_context\": false})`.", - "- If the host spawn tool does not expose `agent_type` or resolved-model attestation, report the lane as `model_unverified`; never claim the configured GPT-5.6 pin was enforced at runtime.", - "- Match `agent_type` to the lane, one per crew role: `agent_type: \"franky\"` builds one slice, `\"zoro\"` reviews a diff, `\"usopp\"` tests, `\"jinbe\"` gates, `\"robin\"` audits (read-only), `\"nami\"` finds files (read-only). Dispatch `nami` first to scope a slice before assigning `franky`.", - "- Role routing by name is best-effort across hosts, so ALSO make the `message` self-contained: lead with `TASK: act as ` and paste all needed context, so the worker behaves correctly even if the host ignores `agent_type`.", + "- Use the native subagent controls exposed by the current host. Use the configured name when named selection is available, but do not invent arguments the host schema does not expose.", + "- Configured names provide steering, while the host-owned stop callback observes the actual role identity. If the host cannot attest that identity, report `role_unverified`; if it cannot attest the resolved model, report `model_unverified`. Never claim the configured GPT-5.6 pin was enforced without attestation.", + "- Route one lane per configured crew name: `franky` builds one slice, `zoro` reviews a diff, `usopp` tests, `jinbe` gates, `robin` audits (read-only), and `nami` finds files (read-only). Dispatch `nami` first to scope a slice before assigning `franky`.", + "- Make every assignment self-contained: lead with `TASK: act as ` and paste all needed context so the worker can execute the bounded lane correctly.", "- The implementation worker must own a real bounded implementation slice before the parent edits or completes that slice. If no safe independent implementation slice exists, stay solo or use a smaller read-only crew.", "- Parallelize read-heavy lanes first (`nami` navigation, `zoro` review, `usopp` QA); never run two editors on overlapping files at once.", - "- Each worker ends its report with `SUPERLOOPY_EVIDENCE: ` (`robin` uses `SUPERLOOPY_AUDIT:`). Collect them with `multi_agent_v1.wait_agent`; treat a running child as alive, not a timeout.", + "- Each worker ends its report with `SUPERLOOPY_EVIDENCE: ` (`robin` uses `SUPERLOOPY_AUDIT:`). Collect them with the host's native lifecycle controls; treat a running child as alive, not a timeout.", "- After each worker returns, show a concise role completion line with role, normalized verdict, artifact path, risk, and next action before closing or respawning that lane.", "- Give `jinbe` a Markdown final gate report such as `.superloopy/evidence/jinbe-final-gate-report.md`; keep it separate from the machine quality gate `.superloopy/evidence/gate.json`.", `- You own the plan: record a criterion pass only from a real artifact via \`${cli} loop prove\` or \`${cli} loop evidence\`, never from a worker's claim alone. Track each dispatch with \`${cli} loop handoff\` and run \`${cli} loop fleet --json\` before the final gate.`, diff --git a/src/hooks.js b/src/hooks.js index 4337825..3b89e80 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -5,7 +5,7 @@ import { parseJson } from "./args.js"; import { resolveEvidenceArtifact } from "./artifacts.js"; import { buildGuide, flowStepLine, proofPlanLine, recordedEvidenceLine } from "./guide.js"; import { CONTEXT_PRESSURE_MARKERS, decideContinuation, transcriptTailHasMarker } from "./continuation.js"; -import { normalizeAgentType, receiptFromPayload, subagentTranscriptPath } from "./receipt.js"; +import { matchesAgentType, receiptFromPayload, subagentTranscriptPath } from "./receipt.js"; import { hasEngineerTrigger, runEngineerTriggerHook } from "./engineer.js"; import { applySteering, statusLoop } from "./loop.js"; import { appendLedger, evidenceRelativeDir, goalsPath, scopeFromSessionId } from "./store.js"; @@ -26,10 +26,14 @@ const PROTECTED_STEERING_KEYS = new Set([ "completionStatus" ]); -export function runSubagentStopHook(payload) { +export function runSubagentStopHook(payload, context = { host: "codex" }) { if (!isRecord(payload)) return ""; if (payload.hook_event_name !== "SubagentStop") return ""; - if (!EVIDENCE_RECEIPT_AGENT_TYPES.has(normalizeAgentType(payload.agent_type))) return ""; + if (![...EVIDENCE_RECEIPT_AGENT_TYPES].some((role) => matchesAgentType({ + host: context.host, + agentType: payload.agent_type, + role + }))) return ""; // Read the SAME transcript the receipt recovery reads, so a context-pressure marker in a // different transcript (e.g. the parent session on Claude) can't skip the receipt gate. if (transcriptHasContextPressureMarker(subagentTranscriptPath(payload))) return ""; diff --git a/src/receipt.js b/src/receipt.js index 8af5e92..07fd7c3 100644 --- a/src/receipt.js +++ b/src/receipt.js @@ -11,6 +11,7 @@ // gates the loop, exactly as the host contract states. import { readTranscriptTail } from "./continuation.js"; +import { SUPERLOOPY_AGENT_NAMES } from "./agent-names.js"; // Capture the path as any run of non-whitespace, matching the original Codex behavior exactly. Both // sources we scan are plain text (the Codex direct field, or the DECODED transcript text where an @@ -114,10 +115,16 @@ function assistantRecordText(record) { return typeof record.text === "string" ? record.text : ""; } -// A host may namespace a plugin-bundled agent type (Claude sends `superloopy:franky`); Codex sends -// the bare `franky`. Strip any leading `:` so the SubagentStop matchers fire on both. -export function normalizeAgentType(agentType) { - if (typeof agentType !== "string") return agentType; - const idx = agentType.lastIndexOf(":"); - return idx >= 0 ? agentType.slice(idx + 1) : agentType; +const AGENT_NAMES = new Set(SUPERLOOPY_AGENT_NAMES); + +export function canonicalAgentType(host, role) { + if (!AGENT_NAMES.has(role)) return null; + if (host === "codex") return role; + if (host === "claude") return `superloopy:${role}`; + return null; +} + +export function matchesAgentType({ host, agentType, role } = {}) { + const expected = canonicalAgentType(host, role); + return expected !== null && agentType === expected; } diff --git a/test/audit.test.js b/test/audit.test.js index 0433c43..2925b88 100644 --- a/test/audit.test.js +++ b/test/audit.test.js @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { countPhysicalLines, isReviewableTextFile } from "../src/doctor.js"; const AUDIT_PATH = "docs/superloopy-file-audit.md"; const MAX_REVIEWABLE_LINES = 550; @@ -22,13 +23,21 @@ test("source and test files stay small enough to review file by file", async () const files = listRepoFiles().filter(isReviewableTextFile); const oversized = []; for (const file of files) { - const lineCount = (await readFile(file, "utf8")).split("\n").length - 1; + const lineCount = countPhysicalLines(await readFile(file, "utf8")); if (lineCount > MAX_REVIEWABLE_LINES) oversized.push(`${file}:${lineCount}`); } assert.deepEqual(oversized, []); }); +test("standalone reviewability uses the physical-line and extension contract", () => { + assert.equal(countPhysicalLines("one\ntwo"), 2); + assert.equal(countPhysicalLines("one\rtwo\r"), 2); + for (const file of ["a.mjs", "a.cjs", "a.yml"]) { + assert.equal(isReviewableTextFile(file), true, file); + } +}); + test("file audit weight note names the current largest source file", async () => { const audit = await readFile(AUDIT_PATH, "utf8"); const sourceFiles = listRepoFiles().filter((file) => file.startsWith("src/") && file.endsWith(".js")); @@ -36,7 +45,7 @@ test("file audit weight note names the current largest source file", async () => for (const file of sourceFiles) { measured.push({ file, - lines: (await readFile(file, "utf8")).split("\n").length - 1 + lines: countPhysicalLines(await readFile(file, "utf8")) }); } const largest = measured.toSorted((left, right) => right.lines - left.lines)[0]; @@ -57,7 +66,3 @@ function listRepoFiles() { .filter((file) => existsSync(file)) .sort(); } - -function isReviewableTextFile(file) { - return /\.(js|md|json|yaml)$/u.test(file) && !file.endsWith("package-lock.json") && !file.startsWith("web-superloopy/public/_nuxt/") && !file.startsWith("web-superloopy/public/_payload.json") && !file.startsWith("skills/superloopy-slides/bold-template-pack/"); -} diff --git a/test/claude-host-wiring.test.js b/test/claude-host-wiring.test.js index b496414..67ad374 100644 --- a/test/claude-host-wiring.test.js +++ b/test/claude-host-wiring.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -31,8 +31,8 @@ const auditCommand = 'node "${CLAUDE_PLUGIN_ROOT}/src/cli.js" hook subagent-stop const fullCrew = { hooks: { SubagentStop: [ - stopEntry("(?:superloopy:)?(?:franky|zoro|usopp|jinbe|nami)"), - stopEntry("(?:superloopy:)?robin", auditCommand) + stopEntry("^superloopy:(?:franky|zoro|usopp|jinbe|nami)$"), + stopEntry("^superloopy:robin$", auditCommand) ] } }; @@ -71,6 +71,20 @@ test("checkClaudeHostWiring requires the SAME entry to both match AND invoke the assert.match(result.message, /superloopy:robin/); }); +test("checkClaudeHostWiring requires each exact matcher to invoke its own stop handler", async () => { + const hooks = { + hooks: { + SubagentStop: [ + stopEntry("^superloopy:(?:franky|zoro|usopp|jinbe|nami)$", auditCommand), + stopEntry("^superloopy:robin$", auditCommand) + ] + } + }; + const result = await checkClaudeHostWiring(await repo({ hooks })); + assert.equal(result.ok, false); + assert.match(result.message, /superloopy:franky/); +}); + test("checkClaudeHostWiring rejects a CLI entry that matches nothing propped up by a non-CLI entry that covers [round2-5]", async () => { const hooks = { hooks: { @@ -90,8 +104,8 @@ test("checkClaudeHostWiring does not crash on a null element in the SubagentStop hooks: { SubagentStop: [ null, - stopEntry("(?:superloopy:)?(?:franky|zoro|usopp|jinbe|nami)"), - stopEntry("(?:superloopy:)?robin", auditCommand) + stopEntry("^superloopy:(?:franky|zoro|usopp|jinbe|nami)$"), + stopEntry("^superloopy:robin$", auditCommand) ] } }; @@ -99,10 +113,11 @@ test("checkClaudeHostWiring does not crash on a null element in the SubagentStop assert.equal(result.ok, true); // null entry safely skipped, rest is a full valid crew }); -test("checkClaudeHostWiring accepts a catch-all (empty/absent matcher = Claude match-all) that invokes the CLI [round2-10]", async () => { +test("checkClaudeHostWiring rejects a catch-all because role identities must be exact", async () => { const hooks = { hooks: { SubagentStop: [{ hooks: [{ type: "command", command: cliCommand }] }] } }; const result = await checkClaudeHostWiring(await repo({ hooks })); - assert.equal(result.ok, true); + assert.equal(result.ok, false); + assert.match(result.message, /exact plugin identity matcher/); }); test("checkClaudeHostWiring treats a literal `null` hooks.json as broken, not green [round2-3]", async () => { @@ -132,3 +147,26 @@ test("checkClaudeHostWiring fails when .claude-plugin/plugin.json is missing or assert.equal(misnamed.ok, false); assert.match(misnamed.message, /name must be superloopy/); }); + +test("README locales use Claude's update command and shell-specific cleanup blocks", async () => { + const locales = ["README.md", "README.ko.md", "README.zh-CN.md", "README.ja.md", "README.es.md"]; + + for (const file of locales) { + const content = await readFile(file, "utf8"); + assert.equal(content.match(/\/plugin install superloopy@beefiker/g)?.length, 1, `${file}: install command`); + assert.equal(content.match(/\/plugin update superloopy@beefiker/g)?.length, 1, `${file}: update command`); + assert.match(content, /```sh\nrm -f ~\/\.local\/bin\/superloopy/); + assert.match(content, /```powershell\nRemove-Item "\$env:APPDATA\\npm\\superloopy\.cmd"/); + + const mixedBlocks = [...content.matchAll(/```[^\n]*\n([\s\S]*?)```/g)] + .map((match) => match[1]) + .filter((body) => body.includes("rm -f ") && body.includes("Remove-Item ")); + assert.deepEqual(mixedBlocks, [], `${file}: cleanup commands must not share a shell block`); + } +}); + +test("Claude validation guide documents exact namespaced role identities", async () => { + const guide = await readFile("docs/superloopy-claude-validation.md", "utf8"); + assert.match(guide, /\^superloopy:\(\?:franky\|zoro\|usopp\|jinbe\|nami\)\$/); + assert.doesNotMatch(guide, /normalizeAgentType|\(\?:superloopy:\)\?/); +}); diff --git a/test/doctor.test.js b/test/doctor.test.js index cf9fdb7..0b858df 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -1,16 +1,46 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; import { checkComparisonSimilarity, normalizeComparisonPath } from "../src/comparison-similarity.js"; -import { formatDoctor, runDoctor } from "../src/doctor.js"; +import { + countPhysicalLines, + doctorOverallOk, + formatDoctor, + isReviewableTextFile, + runDoctor +} from "../src/doctor.js"; const EXPECTED_SKILLS = ["humanize-korean", "superloopy-clone", "superloopy-doctor", "superloopy-frontend", "superloopy-loop", "superloopy-research", "superloopy-slides"]; +test("reviewability counts physical lines and recognizes supported script/config extensions", () => { + assert.equal(countPhysicalLines(""), 0); + assert.equal(countPhysicalLines("one"), 1); + assert.equal(countPhysicalLines("one\n"), 1); + assert.equal(countPhysicalLines("one\r\ntwo"), 2); + assert.equal(countPhysicalLines("one\rtwo\r"), 2); + + for (const file of ["a.js", "a.mjs", "a.cjs", "a.md", "a.json", "a.yaml", "a.yml"]) { + assert.equal(isReviewableTextFile(file), true, file); + } + assert.equal(isReviewableTextFile("package-lock.json"), false); +}); + +test("doctor scope aggregation separates source health from machine-local installation health", () => { + const checks = { + sourceCheck: { ok: true }, + installedModelPolicy: { ok: false }, + wrapper: { ok: false } + }; + assert.equal(doctorOverallOk(checks, "source"), true); + assert.equal(doctorOverallOk(checks, "installed"), false); + assert.equal(doctorOverallOk({ ...checks, sourceCheck: { ok: false } }, "source"), false); +}); + function runCli(args, options = {}) { return spawnSync(process.execPath, [join(process.cwd(), "src/cli.js"), ...args], { cwd: options.cwd ?? process.cwd(), @@ -63,6 +93,7 @@ test("doctor --json reports Superloopy packaging, audit, and reviewability check assert.equal(result.status, 0, result.stderr); const parsed = JSON.parse(result.stdout); assert.equal(parsed.ok, true); + assert.equal(parsed.scope, "source"); assert.equal(parsed.root, process.cwd()); assert.deepEqual(Object.keys(parsed.checks), [ "pluginManifest", @@ -128,6 +159,7 @@ test("doctor --json reports Superloopy packaging, audit, and reviewability check assert.ok(parsed.checks.hostContract.cannotVerify.some((item) => /resolved model/u.test(item))); assert.equal(parsed.checks.hostContract.modelRoutingVerification, "unverified"); assert.equal(parsed.checks.hostContract.unverifiedStatus, "model_unverified"); + assert.doesNotMatch(JSON.stringify(parsed.checks.hostContract), /multi_agent_v1|fork_context|run_in_background|subagent_type/); assert.equal(parsed.checks.claudeHostWiring.ok, true); assert.equal(parsed.checks.claudeHostWiring.policy, "claude-host-wiring-present-and-namespaced"); assert.ok(parsed.checks.claudeHostWiring.matchers.length >= 1); @@ -162,10 +194,26 @@ test("doctor CLI falls back to the CLI root outside a Superloopy checkout", asyn assert.equal(result.status, 0, result.stderr); const parsed = JSON.parse(result.stdout); assert.equal(parsed.ok, true); + assert.equal(parsed.scope, "installed"); assert.equal(parsed.root, process.cwd()); assert.equal(parsed.checks.pluginManifest.ok, true); }); +test("doctor CLI defaults a non-Git package root to installed scope", async () => { + const repo = await tempRepoCopy({ initGit: false, prefix: "superloopy-cli-cache-" }); + const result = spawnSync(process.execPath, [join(repo, "src", "cli.js"), "doctor", "--json"], { + cwd: repo, + encoding: "utf8", + env: { ...process.env, CODEX_HOME: join(tmpdir(), `superloopy-doctor-empty-cache-${process.pid}`) }, + timeout: 10_000 + }); + + assert.equal(result.status, 0, result.stderr); + const parsed = JSON.parse(result.stdout); + assert.equal(parsed.scope, "installed"); + assert.equal(parsed.root, await realpath(repo)); +}); + test("doctor CLI accepts an explicit diagnostic root", async () => { const repo = await tempRepoCopy(); const result = runCli(["doctor", "--root", repo, "--json"], { cwd: tmpdir() }); @@ -173,10 +221,42 @@ test("doctor CLI accepts an explicit diagnostic root", async () => { assert.equal(result.status, 0, result.stderr); const parsed = JSON.parse(result.stdout); assert.equal(parsed.ok, true); + assert.equal(parsed.scope, "source"); assert.equal(parsed.root, repo); assert.deepEqual(parsed.checks.skills.skills, EXPECTED_SKILLS); }); +test("doctor CLI supports explicit scope and equals-form selectors", () => { + const installed = runCli(["doctor", "--scope=installed", "--json"]); + assert.equal(installed.status, 0, installed.stderr); + assert.equal(JSON.parse(installed.stdout).scope, "installed"); + + const source = runCli(["doctor", `--root=${process.cwd()}`, "--scope=source", "--json"], { cwd: tmpdir() }); + assert.equal(source.status, 0, source.stderr); + assert.equal(JSON.parse(source.stdout).scope, "source"); +}); + +test("doctor CLI rejects ambiguous or malformed selectors before running checks", () => { + const invalid = [ + ["doctor", "--unknown"], + ["doctor", "source"], + ["doctor", "--root"], + ["doctor", "--scope", "other"], + ["doctor", "--root", process.cwd(), "--root", process.cwd()], + ["doctor", "--root", process.cwd(), "--plugin-root", process.cwd()], + ["doctor", "--comparison-path="] + ]; + + for (const args of invalid) { + const result = runCli(args); + assert.equal(result.status, 2, `${args.join(" ")}\n${result.stderr}`); + assert.notEqual(result.stderr.trim(), ""); + } + + const help = runCli(["doctor", "--help", "--unknown"]); + assert.equal(help.status, 0, help.stderr); +}); + test("doctor model policy fails when bundled agent defaults drift", async () => { const repo = await tempRepoCopy(); const agentPath = join(repo, ".codex", "agents", "nami.toml"); diff --git a/test/engineer.test.js b/test/engineer.test.js index 015b700..8cccef8 100644 --- a/test/engineer.test.js +++ b/test/engineer.test.js @@ -83,17 +83,17 @@ test("runUserPromptSubmitHook injects the crew fan-out playbook in team mode, wi }); const context = JSON.parse(output).hookSpecificOutput.additionalContext; assert.match(context, /Crew fan-out \(team mode\)/); - assert.match(context, /multi_agent_v1\.spawn_agent/); - assert.match(context, /multi_agent_v1\.wait_agent/); - // agent_type must be set per crew role so the child loads that role's TOML. - assert.match(context, /"agent_type": "franky"/); + assert.match(context, /native subagent controls exposed by the current host/); + assert.doesNotMatch(context, /multi_agent_v1|fork_context|run_in_background/); + assert.match(context, /configured name when named selection is available/); assert.match(context, /model_unverified/); - assert.match(context, /does not expose `agent_type` or resolved-model attestation/); - assert.match(context, /"zoro"/); - assert.match(context, /"usopp"/); - assert.match(context, /"jinbe"/); - assert.match(context, /"robin"/); - assert.match(context, /"nami"/); + assert.match(context, /role_unverified/); + assert.match(context, /franky/); + assert.match(context, /zoro/); + assert.match(context, /usopp/); + assert.match(context, /jinbe/); + assert.match(context, /robin/); + assert.match(context, /nami/); assert.match(context, /requested repository path differs from `cwd`/); assert.match(context, /implementation worker must own a real bounded implementation slice/); assert.match(context, /jinbe-final-gate-report\.md/); @@ -116,7 +116,7 @@ test("runUserPromptSubmitHook treats the connected loopycrew form as team mode w }); const context = JSON.parse(output).hookSpecificOutput.additionalContext; assert.match(context, /Crew fan-out \(team mode\)/); - assert.match(context, /multi_agent_v1\.spawn_agent/); + assert.match(context, /native subagent controls exposed by the current host/); // The connected keyword is stripped from the brief that seeds the loop. assert.match(context, /superloopy loop begin --brief 'migrate the auth module'/); assert.equal(existsSync(join(repo, ".superloopy", "goals.json")), false); @@ -131,7 +131,7 @@ test("runUserPromptSubmitHook treats the standalone ultrawork keyword as team mo }); const context = JSON.parse(output).hookSpecificOutput.additionalContext; assert.match(context, /Crew fan-out \(team mode\)/); - assert.match(context, /multi_agent_v1\.spawn_agent/); + assert.match(context, /native subagent controls exposed by the current host/); // The ultrawork keyword is stripped from the brief that seeds the loop. assert.match(context, /superloopy loop begin --brief 'migrate the auth module'/); assert.equal(existsSync(join(repo, ".superloopy", "goals.json")), false); @@ -210,7 +210,8 @@ test("runUserPromptSubmitHook re-injects the crew playbook when resuming with lo const context = JSON.parse(output).hookSpecificOutput.additionalContext; assert.match(context, /A loop is already in progress/); assert.match(context, /Crew fan-out \(team mode\)/); - assert.match(context, /multi_agent_v1\.spawn_agent/); + assert.match(context, /native subagent controls exposed by the current host/); + assert.doesNotMatch(context, /multi_agent_v1|fork_context|run_in_background/); assert.match(context, /run `superloopy loop fleet --json` before the final gate/); }); diff --git a/test/host-adapter.test.js b/test/host-adapter.test.js index 3a3c38f..52064c0 100644 --- a/test/host-adapter.test.js +++ b/test/host-adapter.test.js @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { auditReceiptFromPayload, normalizeAgentType, receiptFromPayload } from "../src/receipt.js"; +import { + auditReceiptFromPayload, + canonicalAgentType, + matchesAgentType, + receiptFromPayload +} from "../src/receipt.js"; import { bootstrapSuperloopy, isClaudeHost } from "../src/agents.js"; async function transcript(lines) { @@ -172,11 +177,23 @@ test("auditReceiptFromPayload mirrors the same direct/transcript fallback for SU assert.equal(auditReceiptFromPayload({ agent_transcript_path: path }), ".superloopy/evidence/audit/v2.json"); }); -test("normalizeAgentType strips a host namespace so SubagentStop matchers fire on both hosts", () => { - assert.equal(normalizeAgentType("franky"), "franky"); // Codex bare - assert.equal(normalizeAgentType("superloopy:franky"), "franky"); // Claude plugin-namespaced - assert.equal(normalizeAgentType("robin"), "robin"); - assert.equal(normalizeAgentType(undefined), undefined); +test("agent identity matching is exact and host-specific", () => { + for (const role of ["franky", "zoro", "usopp", "jinbe", "robin", "nami"]) { + assert.equal(canonicalAgentType("codex", role), role); + assert.equal(canonicalAgentType("claude", role), `superloopy:${role}`); + assert.equal(matchesAgentType({ host: "codex", agentType: role, role }), true); + assert.equal(matchesAgentType({ host: "claude", agentType: `superloopy:${role}`, role }), true); + } + assert.equal(matchesAgentType({ host: "codex", agentType: "foreign:robin", role: "robin" }), false); + assert.equal(matchesAgentType({ host: "claude", agentType: "robin", role: "robin" }), false); + assert.equal(canonicalAgentType("unknown", "robin"), null); + assert.equal(canonicalAgentType("codex", "unknown"), null); +}); + +test("Codex Nami has restrictive configured defaults", async () => { + const nami = await readFile(".codex/agents/nami.toml", "utf8"); + assert.match(nami, /^sandbox_mode = "read-only"$/mu); + assert.match(nami, /^approval_policy = "never"$/mu); }); test("bootstrapSuperloopy is a clean no-op on Claude Code (no ~/.codex install)", async () => { From cf411d5514a0e52c79930d0fcfb6043c17273284 Mon Sep 17 00:00:00 2001 From: Beefiker Date: Thu, 23 Jul 2026 18:53:14 +0900 Subject: [PATCH 2/2] fix(test): make runtime truth checks portable --- test/claude-host-wiring.test.js | 2 +- test/doctor.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/claude-host-wiring.test.js b/test/claude-host-wiring.test.js index 67ad374..44fac5d 100644 --- a/test/claude-host-wiring.test.js +++ b/test/claude-host-wiring.test.js @@ -152,7 +152,7 @@ test("README locales use Claude's update command and shell-specific cleanup bloc const locales = ["README.md", "README.ko.md", "README.zh-CN.md", "README.ja.md", "README.es.md"]; for (const file of locales) { - const content = await readFile(file, "utf8"); + const content = (await readFile(file, "utf8")).replace(/\r\n|\r/gu, "\n"); assert.equal(content.match(/\/plugin install superloopy@beefiker/g)?.length, 1, `${file}: install command`); assert.equal(content.match(/\/plugin update superloopy@beefiker/g)?.length, 1, `${file}: update command`); assert.match(content, /```sh\nrm -f ~\/\.local\/bin\/superloopy/); diff --git a/test/doctor.test.js b/test/doctor.test.js index 0b858df..77e0bae 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -211,7 +211,7 @@ test("doctor CLI defaults a non-Git package root to installed scope", async () = assert.equal(result.status, 0, result.stderr); const parsed = JSON.parse(result.stdout); assert.equal(parsed.scope, "installed"); - assert.equal(parsed.root, await realpath(repo)); + assert.equal(await realpath(parsed.root), await realpath(repo)); }); test("doctor CLI accepts an explicit diagnostic root", async () => {