写代码前读一遍。规则按项目特点定:零依赖的 Node 脚本、一条 stdio 协议、落盘文件当状态、 每次真机投递都花钱。有条规则挡了路,先改这份文件再破例。
- Node ≥ 22,ESM,文件后缀
.mjs。不写 TypeScript,不加构建步骤。例外:types/index.d.ts是观察面板 mod 的状态类型声明,只给claude plugin validate核对,不参与运行。 - 零运行时依赖。要加依赖必须先在 decisions.md 写一条理由。测试也不加框架,用
node --test。 - 文件按 AGENTS.md 的分层表放:协议层
lib/appserver.mjs、lib/session.mjs、lib/providers.mjs、lib/offpeak-provider.mjs、lib/scrub.mjs; 工作流层lib/config.mjs、lib/credentials.mjs、lib/offpeak.mjs、lib/offpeak-check.mjs、lib/offpeak-send.mjs、lib/offpeak-run.mjs、lib/registry.mjs、lib/tiers.mjs、lib/models.mjs、lib/runs.mjs、lib/queue.mjs、lib/run.mjs、lib/intent.mjs、lib/tool-summary.mjs、lib/snapshot.mjs; 闸门lib/gate.mjs、lib/pending.mjs、lib/review/;外壳bin/、lib/cli/、skills/、templates/、hooks/。hooks/是 Claude Code 的 mod,跑在引擎自己的环境里(没有 Node):不 importlib/,数据只从watch子命令来;能测的纯函数放hooks/view.mjs,用node --test测(decisions D22)。 跨层共用的只有lib/errors.mjs(ExecutorError)。新文件先想清楚归哪层再建。 - 层间只能向下依赖:外壳 → 工作流 → 协议。闸门由工作流调用,闸门只依赖协议层。协议层不知道
runs/目录、登记簿、退出码。例外:外壳可以直接用闸门层的纯函数(readPending、optionClass、questionResponse), 为的是 CLI 与 runner 共用同一份匹配规则;不经工作流层转调。 - 一个文件一个职责。文件超过五百行先想拆,不是加。
- 文件名小写连字符;标识符 camelCase;常量 UPPER_SNAKE;只有需要持状态的东西才写 class(协议客户端算一个)。
- 代码、输出、文档统一用 CONTEXT.md 的词:session、task file、send、turn、permission request、 question、gate、hard rule、review(模型审批)、pending、tier、thoughtLevel。
- 不要出现 judge、verdict(模型审批的结果叫 review result)、run(会话目录叫
runs/是历史沿用,代码里 不造 Run 概念)、prompt(指投递用 send,指提示词用 promptText)。 - 面向人的输出写中文;标识符、事件类型、JSON 字段名写英文。
- 两个空格缩进,单引号,句末分号。行宽不设硬数字,一行说一件事、别让人横向滚动。不引入格式化工具;提交前跑
node --check。 - 优先
export function,模块顶层不放会产生副作用的语句(读文件、起进程)。 - 不用
default export。 - 错误统一一个
ExecutorError,带exitCode。抛错信息写中文、说清怎么办(「会话不在登记簿里,用zcode-executor list看有哪些」),不写堆栈式短语。 - 不吞错误。捕获了不处理的一律 rethrow;确实要忽略的写一行注释说为什么可以忽略。
- JSON-RPC 错误记
error.data.details,message只有一句概括,不够排障。 - 异步一律
async/await,不混.then。子进程和文件的等待都要有超时。
- 每个文件头一段:这个文件负责什么、明确不负责什么、和谁打交道。
- 行内注释写「为什么」,不写「做了什么」。代码本身说得清的不注释。
- 引用真机事实时标出处和日期:
// verified.md 2026-09-07:未答的反向请求每秒重发一次。 - 从外部搬来的代码在文件头标来源、版本、许可证,并记入
NOTICE。改动过的地方标// 改:…。 - 刻意的简化用
// 权宜:<现在的上限>,<什么时候要升级成什么>标出来,例如// 权宜:无信号不兜底,真机挂死过一次再加超时。没有上限说明的简化不算权宜,算没想清楚。 - 不留 TODO。要做的事写进
docs/PLAN.md(本地记录)或 decisions.md 的暂缓项。
- stdout 只放结果:人读的摘要,或
--json时一个 JSON 对象一行。进度、警告、--stream都走 stderr。 - 输出一律经
process.stdout/process.stderr(console.*也是):入口给这两个流包了一层,把终端控制字符换成字面的\uXXXX(lib/cli/terminal.mjs)—— 工具名、理由、模型回复、文件名都是执行端可控的文本。别绕开它直接写 fd(fs.writeSync(1, …)、stdio: 'inherit'的子进程)。 - 每条 stderr 信息一行,以命令名或阶段开头:
new: cwd 不是 worktree,照常建。 --json的结构只加字段不删字段、不改字段含义。改了就在 PRD 第 4 节同步。- 退出码表固定为 0 / 1 / 2 / 3 / 4 / 5,含义见 PRD。新情况归入既有码,不加新码。
- 数据目录只从配置解析一次,所有路径用绝对路径。
- 写 JSON 文件先写同目录临时文件再
rename,避免读到半截。临时文件名带随机段、排他创建(wx):猜得到的名字会被人预先放上符号链接。 走lib/config.mjs的writeJsonAtomic/writeFileAtomic,别自己拼临时名(闸门层的pending.json不能 import 工作流层,另有一份同样的写法)。 - 本工具落盘的文件 0600,
runs/与其下的目录 0700(PRIVATE_FILE_MODE、PRIVATE_DIR_MODE,lib/runs.mjs的ensureRunsDir)。数据目录的根不收紧:默认的执行副本根也在它下面。 这层挡的是同机别的用户;执行端与 runner 是同一个用户,挡它靠闸门(§8)。 events.jsonl只追加,一行一个对象,本项目自己的事件type以executor.开头。- 时间一律 ISO 8601 UTC 字符串。
runs/、worktrees/不进仓库;测试用mkdtemp建临时目录并在after()里清掉。~/.zcode/v2/config.json只读不写;读到的 apiKey 只写进 mkdtemp 出来的 0600 临时个人 provider 文件 (decisions D14),子进程收场即删,永不进runs/、日志和任何输出。~/.zcode/v2/credentials.json同样只读不写;只解账号型 coding plan 要的那四个键,闲时投递另解zcodejwttoken(D20,只进内存与 JSON-RPC 参数,不落盘),其余键一律不读不解; 解出的 key 与 config.json 的 apiKey 同一待遇(decisions D19)。
- 每个请求带超时,超时视为失败并说明是哪个方法。
- 反向请求按
params.requestId去重,没有 requestId 的按「方法 + 参数全文」(重发的参数一字不差;参数不同就是两个请求)。 已出结果的应答只留到回合边界,Session.send开头清掉:对端复用 requestId 时,上一回合的放行不能原样答给新请求。没有处理器的反向请求回-32601错误并打一行 stderr, 不能不答(不答对端每秒重发)。例外:审批请求和提问由会话层接管,没给处理器时刻意不答让回合停住(这就是挂起), 客户端对同一请求只保留最近 5 个信封 id,应答时只回这几个(挂起 75 秒后 approve 真机验过够用)。 - 未知事件类型原样落盘,不报错。
- 审批应答形状是
{decision:'allow'|'deny'}(verified.md「审批」),放行前必须确认 options 里有kind为allow_once的项,没有就转人工。 - 子进程 stderr 全部转到本进程 stderr,前面加
zcode:前缀。 - 拉起 zcode 子进程不带宿主代理自己的凭据环境变量(
lib/appserver.mjs的HOST_CREDENTIAL_ENV,不论来自本进程环境还是调用方的env)。 要加名字就加进这张表;不改成白名单——HOME、PATH这类漏一个就坏功能。
- 模型审批的出口只有 allow 和 ask。代码里不存在把 deny 应答给 zcode 的分支,除了人跑
deny和cancel。 approve只在 options 含allow_once时放行,且只放行这一次。不实现「一直允许」。判定用lib/pending.mjs的hasAllowOnce,闸门放行、挂起落盘、approve三处共用同一份,不各写各的。- runner 只认
answer.json里 requestId 与当前挂起一致的应答,对不上的丢弃:上一轮留下的答案不能放行这一轮。 - 白名单检查在
new做一次,runner 启动时对登记簿里的 cwd 再做一次。 - API key、token 一类字符串永不打印。落盘只允许两处:D14 的 0600 临时个人 provider 文件,以及用户明确配置的
~/.zcode-executor/config.json中review.jev.apiKey(D15;含 key 时必须当前 uid 拥有、普通非符号链接且权限不宽于 0600)。doctor只报 key 是否配置,永不报 key 本身。 - 红线表是代码常量,不读配置。机械红线两条(越界、自保)在
lib/review/hard.mjs:新加的检查,失败方向只能是转人工,判不了的一律命中,不许把哪条 ask 路径改成自动放行或自动拒绝。 - 路径判界不许先折叠再解析:
path.resolve、path.normalize、fs.realpathSync(它的 JS 实现一上来就path.resolve)都会把link/..词法折掉, 而内核是先进链接目标再退一级。判界走hard.mjs的逐段解析。 - 执行端碰本工具的数据目录、调本工具的 CLI,机械转人工(
lib/review/self-guard.mjs)。只认直白写法,拦截面不往宽里加——执行副本默认就在数据目录里,宽了就是误报海(decisions D23)。 - 模型审批的意图只读入队时留的任务单快照(
runs/<id>/tasks/),不读执行副本里的原文件:那个文件执行端改得动。 - runner 自己在执行副本里起的进程(审批探针的 git)不经闸门,仓库配置能让它跑程序的开关一律在命令行上关掉(
lib/review/evidence.mjs的GIT_SAFE_OPTIONS与filterOverrides)。
- 每个外部行为一个用例,用例名写行为不写实现:「白名单外的 cwd 退出码 2」。
- 断言三样:退出码、stdout 的 JSON、落盘文件和 mock 的记录文件。不断言内部函数被调了几次。
- 只对
test/mock-appserver.mjs与test/mock-offpeak.mjs(闲时服务)跑。npm test任何情况下不碰真机、不花额度。 - 真机脚本放
scripts/,名字带real-;会花额度的脚本跑前打印「这次会花额度」并要求--yes。 - 起了后台进程的用例在
after()里统一 SIGKILL,包括子进程的子进程;mock 靠 stdin EOF 自灭。 - 测试用的剧本 JSON 就近放在用例里,不建 fixtures 目录。
- 改协议层行为时先改 mock 再改客户端,让 mock 一直是「真机行为的复刻」,剧本字段注明对应的真机事实。
- 一件任务一到几个提交,每个提交能独立跑过
npm test。 - 提交信息中文,首行
<范围>: <做了什么>,范围用层名或命令名:协议: 反向请求按 requestId 去重、send: --wait 超时改为发 session/stop。正文写为什么,不复述 diff。 - 不提交
runs/、worktrees/、日志、临时目录。 - 主分支上直接开发;派给执行端的任务在
task/T-<编号>分支上做,验收后合并。
- 改行为先改 PRD.md 或 SPEC.md,再改代码。
- 真机新发现写进 verified.md,带日期,标明是哪个版本。
- 做了取舍写进 decisions.md:定了什么、为什么、什么情况下重开。
- 新词先进 CONTEXT.md,再用。
- 搬代码更新
NOTICE。 - 做完一件任务在
docs/PLAN.md打勾;阶段结束时把检查点结果记在对应 handoff 里。这两处是本地记录,不进仓库。