Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
12 changes: 10 additions & 2 deletions config/platform-capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,不自动安装宿主"
}
}
]
Expand Down
44 changes: 43 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,48 @@ 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 指向当前验收源码。
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`
(只指定 `thinloop@thinloop`),由宿主管理缓存事务,不手工改插件数据库。
该路径要求既有 Thinloop marketplace 已指向当前验收源码的本地目录;若不是,
先核对真实市场来源,按既有设置的更新方式处理,不能静默替换市场来源。其他
插件的无关诊断不影响 Thinloop 验证。原生更新异常或超时后先重新只读检查,
不能从命令退出码推断成功或盲目重试。

新任务/会话才能可靠读取更新后的指令;正在运行的会话不自动重启。缺失客户端、
禁用插件或无法核验时保留明确缺口,不把它升级为已同步。

## 更新已有安装

先更新源码仓库:
Expand Down Expand Up @@ -258,7 +300,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 源码仓库运行只读检查器:
Expand Down
18 changes: 13 additions & 5 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 恢复为默认浅克隆。
Expand Down Expand Up @@ -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 源码仓库运行统一的只读检查:

Expand Down Expand Up @@ -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` 不加入规范载荷。
Expand Down Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions scripts/refresh-install.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/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 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;
}

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;
}
}
Loading