From b287bda616a6e2184080a76a5a7cbd3ad260b220 Mon Sep 17 00:00:00 2001 From: mindcarver Date: Sat, 5 Sep 2026 11:06:41 +0800 Subject: [PATCH 1/2] feat(install): verify and refresh all three required clients --- AGENTS.md | 20 ++-- config/platform-capabilities.json | 12 ++- docs/installation.md | 40 ++++++- docs/verification.md | 18 +++- scripts/refresh-install.mjs | 155 +++++++++++++++++++++++++++ scripts/verify-install.mjs | 39 ++++++- tests/platform-capabilities.test.mjs | 104 +++++++++++++++++- tests/refresh-install.test.mjs | 128 ++++++++++++++++++++++ 8 files changed, 491 insertions(+), 25 deletions(-) create mode 100644 scripts/refresh-install.mjs create mode 100644 tests/refresh-install.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 55d182e..f4adf5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,16 +31,16 @@ delivery sequence: returns `PASS`, `FAIL`, or `BLOCKED` with direct evidence, then merge only after `PASS`; 6. synchronize local `main` and confirm it contains the accepted change; -7. only when the merged change modifies installed Thinloop payload or runtime - behavior—such as `skills/**`, `hooks/**`, plugin manifests, version metadata, - or installation mechanics—reinstall or refresh the affected supported local - agents, then verify the installed version, skill names, and hooks that each - platform supports; WorkBuddy and ZCode are exempt from verification - (`verification.mode: skip` in `config/platform-capabilities.json`) and are - never required to be refreshed or UI-checked; -8. do not refresh local agents for repository-only changes such as `README.md`, - `docs/**`, tests, evaluations, CI configuration, or assets that installed - Skills and plugins do not load; +7. after every Thinloop delivery, check installed ZCode, Claude Code and Codex + against merged `main`, including repository-only changes: refresh Thinloop + payload or source links whenever the version or content has drifted, then + verify version, complete skills and each platform's supported hooks. Do not + install missing clients or upgrade host apps. Missing required evidence stays + UNVERIFIED and must be reported, never treated as PASS; +8. for other supported installed agents, refresh when the merged change modifies + installed payload or runtime behavior. WorkBuddy alone remains exempt + (`verification.mode: skip`). Preserve unrelated plugin settings, data and + authentication; use native update commands/APIs and recheck their result; 9. close the Issue only after the merged version, required installation state, and all three completion-audit ledgers still support `PASS`. diff --git a/config/platform-capabilities.json b/config/platform-capabilities.json index 9c90ffd..4d9f25a 100644 --- a/config/platform-capabilities.json +++ b/config/platform-capabilities.json @@ -241,8 +241,16 @@ ] }, "verification": { - "mode": "skip", - "summary": "不验证:ZCode 无可用 CLI;已取消 Settings → Plugins 核验要求" + "mode": "plugin-cli", + "command": [ + "zcode", + "plugins", + "list", + "--json" + ], + "pluginId": "thinloop@thinloop", + "summary": "`zcode plugins list --json` 提供 enabled、version、rootPath、skillCount 与 hookDetails;检查完整 Skill/Hook 载荷和两个可运行 Hook", + "manualFallback": "ZCode CLI 不可用时保持 UNVERIFIED,不自动安装宿主" } } ] diff --git a/docs/installation.md b/docs/installation.md index 552f90e..521bb48 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -212,6 +212,44 @@ WorkBuddy 5.3.5 内置的 CodeBuddy 运行时读取 上下文注入。ZCode 不支持 Codex 专用的 `PreCompact` 事件,因此当前运行时会 记录一条 warning 并只跳过该事件,不影响上述两个 ZCode Hook。 +## 每次交付核对三端 + +每次 Thinloop 交付后都核对已安装的 ZCode、Claude Code 和 Codex;只改 README、 +测试或 CI 也不能跳过已有版本或内容漂移。源码必须是验收通过并已同步的 `main`。 +只更新 Thinloop 插件载荷/Skill 链接,不升级宿主 App、不安装缺失客户端,也不改 +无关插件设置、数据或认证。WorkBuddy 仍为 SKIP。 + +```bash +node scripts/verify-install.mjs --platform codex +node scripts/verify-install.mjs --platform claude-code +node scripts/verify-install.mjs --platform zcode +``` + +逐项读取状态;`UNVERIFIED` 不是通过。发现漂移后,只对对应的已安装端运行: + +```bash +node scripts/refresh-install.mjs --platform codex +node scripts/refresh-install.mjs --platform claude-code +node scripts/refresh-install.mjs --platform zcode +``` + +刷新脚本会再次运行只读检查,必须得到 `PASS` 才报告成功。Codex 遵循 +`CODEX_HOME`,只替换有 Thinloop 来源证据的 Skill 链接;碰到实体目录、其他来源 +或不存在的安装会停止,不覆盖用户文件。Claude 使用原生 +`claude plugin marketplace update thinloop` 和 +`claude plugin update thinloop@thinloop --scope user`;插件必须已经以 user scope 安装并启用,且 Thinloop marketplace 指向当前验收源码。 + +ZCode 0.16.5 的 `plugins` CLI 没有 update 子命令。脚本通过实际 app-server 协议 +依次发送 `plugins/marketplace/update`(只指定 `thinloop`)和 `plugins/update` +(只指定 `thinloop@thinloop`),由宿主管理缓存事务,不手工改插件数据库。 +该路径要求既有 Thinloop marketplace 已指向当前验收源码的本地目录;若不是, +先核对真实市场来源,按既有设置的更新方式处理,不能静默替换市场来源。其他 +插件的无关诊断不影响 Thinloop 验证。原生更新异常或超时后先重新只读检查, +不能从命令退出码推断成功或盲目重试。 + +新任务/会话才能可靠读取更新后的指令;正在运行的会话不自动重启。缺失客户端、 +禁用插件或无法核验时保留明确缺口,不把它升级为已同步。 + ## 更新已有安装 先更新源码仓库: @@ -258,7 +296,7 @@ codebuddy plugin update thinloop@thinloop --scope user 允许 HTML、泛型与 Markdown 自动链接。Claude Stop 首次要求纠正,重复纠正仍失败时 输出未解决状态交接并结束自动纠正;DSH 对同一 Agent 的未改变错误状态不重复 steer, 状态改变或恢复后可重新纠正。更新后新建会话使新 Hook 生效,版本与验证范围见 - [`docs/releases/v0.16.1.md`](./releases/v0.16.1.md)。WorkBuddy / ZCode 维持 SKIP。 + [`docs/releases/v0.16.1.md`](./releases/v0.16.1.md)。WorkBuddy 维持 SKIP;ZCode 后续交付按三端规则核验。 - 若从 v0.6.x 升级,另确认旧 `scd-dev-loop` 已消失。 更新后可以在 Thinloop 源码仓库运行只读检查器: diff --git a/docs/verification.md b/docs/verification.md index b7f2670..7860542 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -38,7 +38,7 @@ npm exec --yes --package=@anthropic-ai/claude-code@2.1.197 -- claude plugin vali ``` CI 只运行无密钥、无桌面依赖且结果确定的检查。真实模型 `smoke` / `full` 评测、 -本机 Agent 安装验证和 WorkBuddy / ZCode 界面检查不在该 workflow 中;这些路径 +本机 Agent 安装验证和 WorkBuddy 界面检查不在该 workflow 中;这些路径 继续按下文的发布或安装验收边界执行,不能由绿色 CI 代替。 仓库测试会解析固定的历史评测提交,因此 CI 必须使用完整 Git 历史,不能把 `actions/checkout` 恢复为默认浅克隆。 @@ -75,7 +75,7 @@ node evals/knowledge/runner/run.mjs --mode full | DeepSeek Harness | 十二个 Skill 链接均指向当前源码;新会话的 skill 工具可发现 `scd-next`、`scd-execute`、`scd-project` 与 `scd-quickdev` | | Claude Code | `claude plugin list --json` 提供版本、enabled 与安装路径;检查器从该路径核对十二个 Skill 和两个 Hook,包括 `scd-next` 与 `scd-execute` | | WorkBuddy | 不验证:WorkBuddy 无可靠只读 CLI 探测;已取消插件页核验要求 | -| ZCode | 不验证:ZCode 无可用 CLI;已取消 Settings → Plugins 核验要求 | +| ZCode | `zcode plugins list --json` 提供 enabled、version、rootPath、skillCount 与 hookDetails;检查完整 Skill/Hook 载荷和两个可运行 Hook | 在 Thinloop 源码仓库运行统一的只读检查: @@ -107,7 +107,7 @@ node scripts/verify-install.mjs --platform dsh `CODEWHALE_SKILLS_DIR`、`~/.reasonix/skills` 与 `DSH_HOME`(默认 `~/.dsh`)。 检查器不会安装、更新、覆盖、重启或重新加载任何 Agent。 -Claude 插件内容以当前源码 Git 跟踪清单为准:逐文件比较 `skills/**`(含参考文档、 +Claude 和 ZCode 插件内容以当前源码 Git 跟踪清单为准:逐文件比较 `skills/**`(含参考文档、 脚本、模板和 Agent 元数据)以及 Hook 所在目录 `hooks/**` 的原始字节。缺失或 内容改变均为 `FAIL`,包括入口间接使用的 `hooks/validate-state.mjs` 和子目录依赖; 不会导入或执行安装目录中的代码。未跟踪的缓存和 `.DS_Store` 不加入规范载荷。 @@ -144,8 +144,16 @@ printf '%s\n' '{"type":"get_commands"}' \ 结果应恰好包含十二个 `skill:scd-*` 命令,路径均位于当前 Pi Skill 根。该检查 只证明 Skill 发现,不证明 Pi 存在 Thinloop 的连续性 Hook。 -WorkBuddy 与 ZCode 按用户决定不参与验证:统一检查器将其记为 `SKIP`,不再 -要求在插件页或 Settings → Plugins 中核验。 +WorkBuddy 按用户决定不参与验证,维持 `SKIP`。ZCode 已纳入必查三端: +`zcode plugins list --json` 返回 `{ plugins, diagnostics }`,检查器只选择 +`thinloop@thinloop`,不因其他插件的无关诊断误判 Thinloop。除完整载荷外,核对 +启用状态、版本、实际根目录、清单路径、Skill 数量以及 `Stop` 和 +`SessionStart(compact)` 的来源与 runnable。缺失字段保持 `UNVERIFIED`, +已确认缺失、禁用、内容漂移或不可运行 Hook 返回 `FAIL`。 + +每次 Thinloop 交付(包括只修改仓库文档、测试或 CI)都核对已安装的 ZCode、 +Claude Code、Codex,发现旧版本或内容漂移必须刷新后重新检查。缺少客户端不自动 +安装,也不升级宿主 App;缺少必需证据必须报告,不能将退出码 0 当作 PASS。 OpenCode、Pi 与 CodeWhale 当前都不声明连续性阻断能力,因为尚未核验到与 Claude Code、WorkBuddy、ZCode Stop Hook 等价的可取消完成协议;CodeWhale diff --git a/scripts/refresh-install.mjs b/scripts/refresh-install.mjs new file mode 100644 index 0000000..172d79a --- /dev/null +++ b/scripts/refresh-install.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { inspectInstallations } from "./verify-install.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pluginId = "thinloop@thinloop"; + +function run(command, context) { + const result = spawnSync(command[0], command.slice(1), { + cwd: context.sourceRoot, env: context.environment, encoding: "utf8", + timeout: 60_000, maxBuffer: 8 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + throw new Error(`${command.slice(0, 3).join(" ")} failed; inspect client diagnostics locally`); + } + return result.stdout; +} + +// ZCode 0.16.5 app-server uses newline-delimited {id, method, params}, without +// the JSON-RPC jsonrpc field. Only plugin management requests are sent here. +export function zcodeRequest(method, params, context) { + return new Promise((resolve, reject) => { + const child = spawn("zcode", ["app-server"], { + cwd: context.sourceRoot, env: context.environment, stdio: ["pipe", "pipe", "pipe"], + }); + let buffer = ""; + let settled = false; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + if (error) reject(error); else resolve(result); + }; + const timer = setTimeout(() => finish(new Error(`ZCode ${method} timed out; recheck installation before retrying`)), 60_000); + child.on("error", () => finish(new Error("ZCode app-server unavailable"))); + child.on("exit", () => finish(new Error("ZCode app-server ended without a response"))); + child.stderr.resume(); // Do not echo client logs that may contain credentials. + child.stdout.on("data", (chunk) => { + buffer += chunk; + if (buffer.length > 8 * 1024 * 1024) return finish(new Error("ZCode response exceeded limit")); + let newline; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + let message; + try { message = JSON.parse(line); } + catch { return finish(new Error("Invalid ZCode protocol response")); } + if (message.id !== 1) continue; + if (message.error) return finish(new Error(`ZCode ${method} failed (${message.error.code})`)); + finish(null, message.result); + } + }); + child.stdin.on("error", () => finish(new Error("ZCode request could not be sent"))); + child.stdin.write(`${JSON.stringify({ id: 1, method, params })}\n`); + }); +} + +export async function refreshInstallation({ + platformId, sourceRoot = root, homeDir = os.homedir(), environment = process.env, + runCommand = run, request = zcodeRequest, +} = {}) { + if (!["codex", "claude-code", "zcode"].includes(platformId)) { + throw new Error("--platform must be codex, claude-code or zcode"); + } + const context = { sourceRoot: path.resolve(sourceRoot), environment: { ...environment, HOME: homeDir } }; + const registry = JSON.parse(fs.readFileSync(path.join(sourceRoot, "config/platform-capabilities.json"), "utf8")); + const platform = registry.platforms.find(entry => entry.id === platformId); + if (platformId === "codex") { + const skillsRoot = path.join(environment.CODEX_HOME || path.join(homeDir, ".codex"), "skills"); + const names = fs.readdirSync(path.join(sourceRoot, "skills")) + .filter(name => fs.existsSync(path.join(sourceRoot, "skills", name, "SKILL.md"))); + const links = names.map(name => ({ name, destination: path.join(skillsRoot, name), target: path.join(context.sourceRoot, "skills", name) })); + let present = false; + for (const link of links) { + let stat; + try { stat = fs.lstatSync(link.destination); } + catch (error) { if (error.code === "ENOENT") continue; throw error; } + present = true; + if (!stat.isSymbolicLink()) throw new Error(`Refusing to replace non-link: ${link.destination}`); + const old = fs.realpathSync(link.destination); + const manifest = path.resolve(old, "..", "..", ".codex-plugin/plugin.json"); + if (JSON.parse(fs.readFileSync(manifest, "utf8")).name !== "thinloop" || path.basename(old) !== link.name) { + throw new Error(`Not an owned Thinloop link: ${link.destination}`); + } + } + if (!present) throw new Error("No installed Codex Thinloop links; do not install a missing client implicitly"); + for (const link of links) { + const temporary = `${link.destination}.thinloop-${process.pid}`; + fs.symlinkSync(link.target, temporary, process.platform === "win32" ? "junction" : "dir"); + try { fs.renameSync(temporary, link.destination); } + finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); } + } + } else { + const response = JSON.parse(runCommand(platform.verification.command, context)); + const plugins = platformId === "zcode" ? response.plugins : response; + const matches = Array.isArray(plugins) ? plugins.filter(entry => entry.id === pluginId) : []; + if (matches.length !== 1 || matches[0].enabled !== true) { + throw new Error("Thinloop must already be installed and enabled; no installation or enablement was attempted"); + } + if (platformId === "claude-code") { + if (matches[0].scope !== "user") throw new Error("Claude refresh requires an existing user-scope Thinloop installation"); + const marketplaces = JSON.parse(runCommand(["claude", "plugin", "marketplace", "list", "--json"], context)); + const marketplace = marketplaces.find(entry => entry.name === "thinloop"); + if (marketplace?.source !== "directory" || + fs.realpathSync(marketplace.path) !== fs.realpathSync(context.sourceRoot)) { + throw new Error("Claude Thinloop marketplace must point to this accepted local source"); + } + runCommand(["claude", "plugin", "marketplace", "update", "thinloop"], context); + runCommand(["claude", "plugin", "update", pluginId, "--scope", "user"], context); + } else { + const workspace = { workspacePath: context.sourceRoot, workspaceKey: context.sourceRoot }; + const overview = await request("plugins/overview", { workspace }, context); + const marketplace = overview?.marketplaces?.find(entry => entry.id === "thinloop"); + if (marketplace?.source?.source !== "directory" || + fs.realpathSync(marketplace.source.path) !== fs.realpathSync(context.sourceRoot)) { + throw new Error("ZCode Thinloop marketplace must point to this accepted local source; keep other market settings unchanged"); + } + for (const [method, params] of [ + ["plugins/marketplace/update", { workspace, marketplace: "thinloop" }], + ["plugins/update", { workspace, pluginId }], + ]) { + const result = await request(method, params, context); + if (result?.diagnostics?.some(entry => entry.severity === "error" && + (!entry.pluginId || entry.pluginId === pluginId))) { + throw new Error(`ZCode ${method} reported an error; recheck before retrying`); + } + } + } + } + const report = inspectInstallations({ + sourceRoot, homeDir, environment, platformId, + registryPath: path.join(sourceRoot, "config/platform-capabilities.json"), + runCommand: command => ({ status: 0, stdout: runCommand(command, context) }), + }); + if (report.results[0].status !== "PASS") throw new Error(`Refresh did not verify PASS; run verify-install.mjs --platform ${platformId}`); + return report; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + try { + if (args.length !== 2 || args[0] !== "--platform") throw new Error("Usage: node scripts/refresh-install.mjs --platform codex|claude-code|zcode"); + const report = await refreshInstallation({ platformId: args[1] }); + process.stdout.write(`PASS ${args[1]} Thinloop ${report.expectedVersion}; new task/session required for loaded instructions\n`); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-install.mjs b/scripts/verify-install.mjs index 7dd89b9..cef2a8b 100644 --- a/scripts/verify-install.mjs +++ b/scripts/verify-install.mjs @@ -19,6 +19,7 @@ const DEFAULT_REGISTRY = path.join( const RESULT_ORDER = ["PASS", "FAIL", "UNVERIFIED", "MANUAL", "SKIP"]; const READ_ONLY_PROBES = new Map([ ["claude-code", ["claude", "plugin", "list", "--json"]], + ["zcode", ["zcode", "plugins", "list", "--json"]], ["codewhale", ["codewhale", "doctor", "--json"]], ]); const MANUAL_PROBES = new Map([ @@ -619,8 +620,10 @@ function inspectPlugin(platform, expected, runCommand, context) { } let plugins; + let response; try { - plugins = JSON.parse(commandResult.stdout); + response = JSON.parse(commandResult.stdout); + plugins = platform.id === "zcode" ? response?.plugins : response; } catch { return singleCheckResult( platform, @@ -639,7 +642,7 @@ function inspectPlugin(platform, expected, runCommand, context) { } const plugin = plugins.find( - (entry) => entry.id === platform.verification.pluginId, + (entry) => entry?.id === platform.verification.pluginId, ); if (!plugin) { return singleCheckResult( @@ -650,6 +653,17 @@ function inspectPlugin(platform, expected, runCommand, context) { ); } + if (platform.id === "zcode") { + if (plugins.filter((entry) => entry?.id === platform.verification.pluginId).length !== 1) { + return singleCheckResult(platform, "plugin", "FAIL", "ambiguous Thinloop plugin records"); + } + if (Array.isArray(response.diagnostics) && response.diagnostics.some(entry => + entry?.pluginId === platform.verification.pluginId && entry.severity === "error")) { + return singleCheckResult(platform, "plugin", "FAIL", "ZCode reported a Thinloop plugin error; inspect diagnostics locally"); + } + plugin.installPath = plugin.rootPath; + } + const checks = [ makeCheck( "enabled", @@ -698,6 +712,27 @@ function inspectPlugin(platform, expected, runCommand, context) { ); } else { checks.push(makeCheck("install-path", "PASS", plugin.installPath)); + if (platform.id === "zcode") { + const manifestPath = resolveFrom(plugin.installPath, platform.installation.manifest); + checks.push(makeCheck("runtime-manifest", + typeof plugin.manifestPath !== "string" ? "UNVERIFIED" : + path.resolve(plugin.manifestPath) === manifestPath ? "PASS" : "FAIL", + "reported manifest must select .zcode-plugin/plugin.json inside rootPath")); + checks.push(makeCheck("runtime-skills", + typeof plugin.skillCount !== "number" ? "UNVERIFIED" : + plugin.skillCount === expected.skillNames.length ? "PASS" : "FAIL", + `${plugin.skillCount ?? "unknown"} discovered; ${expected.skillNames.length} expected`)); + const details = plugin.hookDetails; + const missing = platform.capabilities.hooks.filter((hook) => + !Array.isArray(details) || !details.some((entry) => + entry?.event === hook.event && entry.matcher === hook.matcher && + entry.runnable === true && typeof entry.sourcePath === "string" && + path.resolve(entry.sourcePath) === resolveFrom(plugin.installPath, hook.source))); + checks.push(makeCheck("runtime-hooks", + !Array.isArray(details) ? "UNVERIFIED" : missing.length ? "FAIL" : "PASS", + missing.length ? `missing or non-runnable: ${missing.map(hook => hook.event).join(", ")}` : + `${platform.capabilities.hooks.length} supported hooks discovered and runnable`)); + } checks.push(inspectInstalledSkills(plugin.installPath, platform, expected)); checks.push(inspectInstalledHooks(plugin.installPath, platform, expected)); checks.push( diff --git a/tests/platform-capabilities.test.mjs b/tests/platform-capabilities.test.mjs index a8ac791..51458d6 100644 --- a/tests/platform-capabilities.test.mjs +++ b/tests/platform-capabilities.test.mjs @@ -289,7 +289,7 @@ test("read-only checker verifies complete automatic installs", () => { assert.equal(results.dsh.status, "MANUAL"); assert.equal(results["claude-code"].status, "PASS"); assert.equal(results.workbuddy.status, "SKIP"); - assert.equal(results.zcode.status, "SKIP"); + assert.equal(results.zcode.status, "UNVERIFIED"); assert.match(formatText(report), /Mode: read-only/); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); @@ -441,7 +441,7 @@ test("checker reports missing, partial, stale, and hook-mismatched installs", () assert.equal(results.reasonix.status, "PASS"); assert.equal(results["claude-code"].status, "FAIL"); assert.equal(results.workbuddy.status, "SKIP"); - assert.equal(results.zcode.status, "SKIP"); + assert.equal(results.zcode.status, "UNVERIFIED"); assert.match( results["claude-code"].checks.find((check) => check.name === "version").detail, /0\.0\.0 installed/, @@ -494,7 +494,7 @@ test("unavailable automatic plugin CLI stays unverified instead of failing", () assert.equal(results.dsh.status, "MANUAL"); assert.equal(results["claude-code"].status, "UNVERIFIED"); assert.equal(results.workbuddy.status, "SKIP"); - assert.equal(results.zcode.status, "SKIP"); + assert.equal(results.zcode.status, "UNVERIFIED"); } finally { fs.rmSync(homeDir, { recursive: true, force: true }); } @@ -760,6 +760,7 @@ test("checker never executes probes registered as manual", () => { assert.deepEqual(calls, [ ["codewhale", "doctor", "--json"], ["claude", "plugin", "list", "--json"], + ["zcode", "plugins", "list", "--json"], ]); assert.equal(runtimeCheck.status, "MANUAL"); assert.match(runtimeCheck.detail, /可能写入日志/); @@ -813,8 +814,8 @@ test("checker source and registered probes are read-only", () => { ]); assert.equal(platform("workbuddy").verification.mode, "skip"); assert.match(platform("workbuddy").verification.summary, /不验证/); - assert.equal(platform("zcode").verification.mode, "skip"); - assert.match(platform("zcode").verification.summary, /不验证/); + assert.equal(platform("zcode").verification.mode, "plugin-cli"); + assert.deepEqual(platform("zcode").verification.command, ["zcode", "plugins", "list", "--json"]); }); test("checker can target Pi without probing unrelated platforms", () => { @@ -969,3 +970,96 @@ test("checker reserves exit code 2 for invalid invocation", () => { assert.equal(unknownPlatform.status, 2); assert.match(unknownPlatform.stderr, /Unknown platform: unknown/); }); + + +function zcodeRecord(installPath) { + return { + id: "thinloop@thinloop", enabled: true, version: expectedVersion, + rootPath: installPath, manifestPath: path.join(installPath, ".zcode-plugin/plugin.json"), + skillCount: expectedSkills.length, + hookDetails: platform("zcode").capabilities.hooks.map(hook => ({ + event: hook.event, matcher: hook.matcher, runnable: true, + sourcePath: path.join(installPath, hook.source), + })), + }; +} + +test("ZCode native envelope verifies complete payload and ignores unrelated diagnostics", () => { + const installPath = makePluginInstall("zcode"); + try { + const calls = []; + const report = inspectInstallations({ + registryPath, sourceRoot: root, platformId: "zcode", + runCommand: command => { + calls.push(command); + return { status: 0, stdout: JSON.stringify({ cwd: root, + diagnostics: [{ pluginId: "other@market", severity: "error", message: "missing ZCODE_BASE_URL" }], + plugins: [zcodeRecord(installPath)] }) }; + }, + }); + assert.equal(report.results[0].status, "PASS"); + assert.deepEqual(calls, [["zcode", "plugins", "list", "--json"]]); + } finally { fs.rmSync(installPath, { recursive: true, force: true }); } +}); + +for (const [label, mutate, expected] of [ + ["disabled", p => { p.enabled = false; }, "FAIL"], + ["stale", p => { p.version = "0.0.0"; }, "FAIL"], + ["root missing", p => { delete p.rootPath; }, "UNVERIFIED"], + ["manifest missing", p => { delete p.manifestPath; }, "UNVERIFIED"], + ["manifest wrong", p => { p.manifestPath = "/wrong/plugin.json"; }, "FAIL"], + ["skills incomplete", p => { p.skillCount = 1; }, "FAIL"], + ["hooks missing", p => { delete p.hookDetails; }, "UNVERIFIED"], + ["hook not runnable", p => { p.hookDetails[0].runnable = false; }, "FAIL"], + ["hook matcher wrong", p => { p.hookDetails[0].matcher = "other"; }, "FAIL"], + ["hook source wrong", p => { p.hookDetails[0].sourcePath = "/wrong/hooks.json"; }, "FAIL"], +]) { + test(`ZCode ${label} does not produce false PASS`, () => { + const installPath = makePluginInstall("zcode"); + try { + const record = zcodeRecord(installPath); + mutate(record); + const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: pluginRunner({ zcode: { plugins: [record], diagnostics: [] } }), + }); + assert.equal(report.results[0].status, expected); + } finally { fs.rmSync(installPath, { recursive: true, force: true }); } + }); +} + +test("ZCode rejects missing and altered nested payload without executing it", () => { + const installPath = makePluginInstall("zcode"); + try { + for (const file of ["hooks/validate-state.mjs", "skills/scd-quickdev/references/evidence-contract.md"]) { + const target = path.join(installPath, file); + fs.unlinkSync(target); + const inspect = () => inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: pluginRunner({ zcode: { plugins: [zcodeRecord(installPath)] } }), + }); + assert.equal(inspect().results[0].status, "FAIL"); + fs.writeFileSync(target, "broken payload"); + assert.equal(inspect().results[0].status, "FAIL"); + fs.copyFileSync(path.join(root, file), target); + assert.equal(inspect().results[0].status, "PASS"); + } + } finally { fs.rmSync(installPath, { recursive: true, force: true }); } +}); + + +test("ZCode distinguishes missing, ambiguous and failed plugin evidence", () => { + const installPath = makePluginInstall("zcode"); + try { + for (const [response, expected] of [ + [{ plugins: [] }, "FAIL"], + [{ plugins: [null] }, "FAIL"], + [{ plugins: [zcodeRecord(installPath), zcodeRecord(installPath)] }, "FAIL"], + [{ plugins: [zcodeRecord(installPath)], diagnostics: [{ pluginId: "thinloop@thinloop", severity: "error" }] }, "FAIL"], + [{ plugins: "unavailable" }, "UNVERIFIED"], + ]) { + const report = inspectInstallations({ registryPath, sourceRoot: root, platformId: "zcode", + runCommand: pluginRunner({ zcode: response }), + }); + assert.equal(report.results[0].status, expected); + } + } finally { fs.rmSync(installPath, { recursive: true, force: true }); } +}); diff --git a/tests/refresh-install.test.mjs b/tests/refresh-install.test.mjs new file mode 100644 index 0000000..93f9c99 --- /dev/null +++ b/tests/refresh-install.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { refreshInstallation } from "../scripts/refresh-install.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const version = JSON.parse(fs.readFileSync(path.join(root, ".codex-plugin/plugin.json"))).version; +const names = fs.readdirSync(path.join(root, "skills")); +const fixture = () => fs.mkdtempSync(path.join(os.tmpdir(), "thinloop-refresh-")); + +function pluginPayload(target) { + for (const dir of ["skills", "hooks", ".claude-plugin", ".zcode-plugin"]) { + fs.cpSync(path.join(root, dir), path.join(target, dir), { recursive: true }); + } +} + +test("Codex refresh is repeatable, repairs stale owned links and preserves other skills", async () => { + const home = fixture(); + const old = path.join(home, "old-thinloop"); + const skillRoot = path.join(home, "custom-codex", "skills"); + try { + fs.cpSync(path.join(root, "skills"), path.join(old, "skills"), { recursive: true }); + fs.cpSync(path.join(root, ".codex-plugin"), path.join(old, ".codex-plugin"), { recursive: true }); + fs.mkdirSync(skillRoot, { recursive: true }); + for (const name of names.slice(0, -1)) fs.symlinkSync(path.join(old, "skills", name), path.join(skillRoot, name), "dir"); + fs.writeFileSync(path.join(skillRoot, "unrelated"), "preserve"); + for (let attempt = 0; attempt < 2; attempt++) { + const report = await refreshInstallation({ platformId: "codex", sourceRoot: root, homeDir: home, + environment: { CODEX_HOME: path.join(home, "custom-codex") }, + runCommand() { throw new Error("Codex links should not call a plugin CLI"); }, + }); + assert.equal(report.results[0].status, "PASS"); + for (const name of names) assert.equal(fs.realpathSync(path.join(skillRoot, name)), path.join(root, "skills", name)); + assert.equal(fs.readFileSync(path.join(skillRoot, "unrelated"), "utf8"), "preserve"); + } + } finally { fs.rmSync(home, { recursive: true, force: true }); } +}); + +test("Codex refuses a missing installation or colliding non-link before any changes", async () => { + const home = fixture(); + const skills = path.join(home, ".codex/skills"); + try { + const options = { platformId: "codex", sourceRoot: root, homeDir: home, environment: {} }; + await assert.rejects(refreshInstallation(options), /No installed Codex Thinloop/); + assert.equal(fs.existsSync(skills), false); + fs.mkdirSync(path.join(skills, names[0]), { recursive: true }); + await assert.rejects(refreshInstallation(options), /Refusing to replace non-link/); + assert.deepEqual(fs.readdirSync(skills), [names[0]]); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +}); + +test("Claude refresh scopes native commands to Thinloop and rechecks payload", async () => { + const home = fixture(); + const installed = path.join(home, "installed"); + const commands = []; + let current = "0.0.0"; + try { + const report = await refreshInstallation({ platformId: "claude-code", sourceRoot: root, homeDir: home, + runCommand(command) { + commands.push(command); + if (command[2] === "list") return JSON.stringify([{ id: "thinloop@thinloop", version: current, enabled: true, scope: "user", installPath: installed }]); + if (command[2] === "marketplace" && command[3] === "list") return JSON.stringify([{ name: "thinloop", source: "directory", path: root }]); + if (command[2] === "update") { pluginPayload(installed); current = version; } + return ""; + }, + }); + assert.equal(report.results[0].status, "PASS"); + assert.deepEqual(commands, [ + ["claude", "plugin", "list", "--json"], + ["claude", "plugin", "marketplace", "list", "--json"], + ["claude", "plugin", "marketplace", "update", "thinloop"], + ["claude", "plugin", "update", "thinloop@thinloop", "--scope", "user"], + ["claude", "plugin", "list", "--json"], + ]); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +}); + +test("native updater refuses absent or disabled plugins without mutation", async () => { + for (const platformId of ["zcode", "claude-code"]) { + for (const records of [[], [{ id: "thinloop@thinloop", enabled: false }]]) { + const calls = []; + await assert.rejects(refreshInstallation({ platformId, sourceRoot: root, + runCommand(command) { calls.push(command); return JSON.stringify(platformId === "zcode" ? { plugins: records } : records); }, + request() { throw new Error("must not call update API"); }, + }), /already be installed and enabled/); + assert.equal(calls.length, 1); + } + } +}); + +test("ZCode refuses wrong marketplace and unverified update results", async () => { + for (const source of ["/not-the-source", root]) { + const requests = []; + const options = { platformId: "zcode", sourceRoot: root, + runCommand: () => JSON.stringify({ plugins: [{ id: "thinloop@thinloop", enabled: true }] }), + async request(method, params) { + requests.push([method, params]); + if (method === "plugins/overview") return { marketplaces: [{ id: "thinloop", source: { source: "directory", path: source } }] }; + return { diagnostics: [{ severity: "error", pluginId: "thinloop@thinloop" }] }; + }, + }; + await assert.rejects(refreshInstallation(options)); + assert.equal(requests.length, source === root ? 2 : 1); + if (source === root) assert.equal(requests[1][1].marketplace, "thinloop"); + } +}); + + +test("Codex preflight refuses an unrelated same-name link without changing other links", async () => { + const home = fixture(); + try { + const skillRoot = path.join(home, ".codex/skills"); + const other = path.join(home, "other", "skills", names.at(-1)); + fs.mkdirSync(other, { recursive: true }); + fs.mkdirSync(path.join(home, "other", ".codex-plugin")); + fs.writeFileSync(path.join(home, "other", ".codex-plugin/plugin.json"), JSON.stringify({ name: "other" })); + fs.mkdirSync(skillRoot, { recursive: true }); + fs.symlinkSync(path.join(root, "skills", names[0]), path.join(skillRoot, names[0]), "dir"); + fs.symlinkSync(other, path.join(skillRoot, names.at(-1)), "dir"); + const before = fs.readlinkSync(path.join(skillRoot, names[0])); + await assert.rejects(refreshInstallation({ platformId: "codex", sourceRoot: root, homeDir: home, environment: {} }), /Not an owned Thinloop link/); + assert.equal(fs.readlinkSync(path.join(skillRoot, names[0])), before); + assert.equal(fs.readlinkSync(path.join(skillRoot, names.at(-1))), other); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +}); From 03ac704575e505c0e2e1213f7eb06df28c5b9473 Mon Sep 17 00:00:00 2001 From: mindcarver Date: Sat, 5 Sep 2026 11:18:43 +0800 Subject: [PATCH 2/2] fix(install): repair Claude same-version payload drift natively --- docs/installation.md | 4 +++ scripts/refresh-install.mjs | 15 ++++++++++- tests/refresh-install.test.mjs | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/installation.md b/docs/installation.md index 521bb48..d4c24ab 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -238,6 +238,10 @@ node scripts/refresh-install.mjs --platform zcode 或不存在的安装会停止,不覆盖用户文件。Claude 使用原生 `claude plugin marketplace update thinloop` 和 `claude plugin update thinloop@thinloop --scope user`;插件必须已经以 user scope 安装并启用,且 Thinloop marketplace 指向当前验收源码。 +Claude 会跳过已安装的同版本;若更新后已确认版本一致但 Skill/Hook/清单字节仍 +漂移,脚本只执行一次原生 `uninstall --keep-data` 后 `install`,保留 Thinloop +持久数据并再次核验。原生 CLI 可能重排设置文件的 JSON 键,但不会为此修改 +设置含义、认证或信任。证据缺失、安装路径异常或版本仍不匹配不触发该重装。 ZCode 0.16.5 的 `plugins` CLI 没有 update 子命令。脚本通过实际 app-server 协议 依次发送 `plugins/marketplace/update`(只指定 `thinloop`)和 `plugins/update` diff --git a/scripts/refresh-install.mjs b/scripts/refresh-install.mjs index 172d79a..a2c5462 100644 --- a/scripts/refresh-install.mjs +++ b/scripts/refresh-install.mjs @@ -133,11 +133,24 @@ export async function refreshInstallation({ } } } - const report = inspectInstallations({ + const inspect = () => inspectInstallations({ sourceRoot, homeDir, environment, platformId, registryPath: path.join(sourceRoot, "config/platform-capabilities.json"), runCommand: command => ({ status: 0, stdout: runCommand(command, context) }), }); + let report = inspect(); + const checks = report.results[0].checks; + const payloadDrift = checks.filter(check => check.status === "FAIL"); + if (platformId === "claude-code" && payloadDrift.length > 0 && + checks.every(check => ["PASS", "FAIL"].includes(check.status)) && + checks.some(check => check.name === "version" && check.status === "PASS") && + payloadDrift.every(check => ["skills", "hooks", "manifest"].includes(check.name))) { + // Claude's update/install skip an already installed version. Reinstall only + // after confirming content drift, preserving persistent plugin data. + runCommand(["claude", "plugin", "uninstall", pluginId, "--scope", "user", "--keep-data"], context); + runCommand(["claude", "plugin", "install", pluginId, "--scope", "user"], context); + report = inspect(); + } if (report.results[0].status !== "PASS") throw new Error(`Refresh did not verify PASS; run verify-install.mjs --platform ${platformId}`); return report; } diff --git a/tests/refresh-install.test.mjs b/tests/refresh-install.test.mjs index 93f9c99..2eb38c4 100644 --- a/tests/refresh-install.test.mjs +++ b/tests/refresh-install.test.mjs @@ -126,3 +126,49 @@ test("Codex preflight refuses an unrelated same-name link without changing other assert.equal(fs.readlinkSync(path.join(skillRoot, names.at(-1))), other); } finally { fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("Claude repairs confirmed same-version payload drift through one native keep-data reinstall", async () => { + const home = fixture(); + const installed = path.join(home, "installed"); + const commands = []; + try { + pluginPayload(installed); + const changed = path.join(installed, "hooks/validate-state.mjs"); + fs.appendFileSync(changed, "\n// same-version installed drift\n"); + const runCommand = command => { + commands.push(command); + if (command[2] === "list") return JSON.stringify([{ id: "thinloop@thinloop", version, enabled: true, scope: "user", installPath: installed }]); + if (command[2] === "marketplace" && command[3] === "list") return JSON.stringify([{ name: "thinloop", source: "directory", path: root }]); + if (command[2] === "install") pluginPayload(installed); + return ""; + }; + for (let attempt = 0; attempt < 2; attempt++) { + const report = await refreshInstallation({ platformId: "claude-code", sourceRoot: root, homeDir: home, runCommand }); + assert.equal(report.results[0].status, "PASS"); + assert.deepEqual(fs.readFileSync(changed), fs.readFileSync(path.join(root, "hooks/validate-state.mjs"))); + } + assert.deepEqual(commands.filter(command => ["uninstall", "install"].includes(command[2])), [ + ["claude", "plugin", "uninstall", "thinloop@thinloop", "--scope", "user", "--keep-data"], + ["claude", "plugin", "install", "thinloop@thinloop", "--scope", "user"], + ]); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +}); + +test("Claude does not reinstall when update leaves version or install-path evidence unresolved", async () => { + const home = fixture(); + try { + for (const versionAfterUpdate of ["0.0.0", undefined]) { + const calls = []; + await assert.rejects(refreshInstallation({ platformId: "claude-code", sourceRoot: root, homeDir: home, + runCommand(command) { + calls.push(command); + if (command[2] === "list") return JSON.stringify([{ id: "thinloop@thinloop", version: versionAfterUpdate, enabled: true, scope: "user", installPath: path.join(home, "missing") }]); + if (command[2] === "marketplace" && command[3] === "list") return JSON.stringify([{ name: "thinloop", source: "directory", path: root }]); + return ""; + }, + }), /did not verify PASS/); + assert.equal(calls.some(command => ["uninstall", "install"].includes(command[2])), false); + } + } finally { fs.rmSync(home, { recursive: true, force: true }); } +});