# 跨切约定(Conventions) 补全各 spec 没有点明的"细节但全局"的实现约定。Coder Agent 写代码前先读这页。 本文件是**所有 spec 的隐含前提**——任何与下面约定冲突的具体 spec 文本以 spec 为准。 --- ## 1 · 时间格式 所有 `Type.String({format:'date-time'})` 字段一律 **ISO 8601 with milliseconds + Z**: ``` 2026-05-13T10:31:00.000Z ``` - 写入:`new Date().toISOString()` - 比较:字符串字典序即时序 - timeline.ndjson 例外:用 unix seconds(`number`)以省字节 - runId 命名里的 `YYYYMMDD-HHMM` 是**本地时区**(与服务器 TZ 一致),但事件 ts 字段始终 UTC --- ## 2 · ID 生成器 | 函数 | 格式 | 示例 | 实现 | |---|---|---|---| | `genProjectId()` | `p-` + 6 位 base36 | `p-731042` | `'p-' + Date.now().toString(36).slice(-6)` | | `genCallId()` | `call-{YYYYMMDD-HHMM}-{4hex}` | `call-20260513-1031-7f3a` | timestamp + `crypto.randomBytes(2).toString('hex')` | | `genRunId(loop)` | L1: `run-{YYYYMMDD-HHMM}` · L2: `run-int-{YYYYMMDD-HHMM}` | `run-20260513-0731` | `Date` formatted | | `genBugId()` | `bug-{YYYYMMDD-HHMM}-{4hex}` | `bug-20260513-0731-7f3a` | 同 genCallId 形式 | | `genResumeToken()` | `rt-{16hex}` | `rt-7f3a91c80b2e4a6d` | `'rt-' + crypto.randomBytes(8).toString('hex')` | 不变量: - ID 都是 ASCII,无空格无特殊字符(直接做文件名安全) - 时间戳部分**不**保证全局唯一——靠后缀随机位避免碰撞;同一秒可以有多条 - 所有 ID 落盘后**不可变**(即使重命名也保留旧 ID) --- ## 3 · 错误响应(REST) **对齐 LemonBoss 既有惯例**(见 `server/routes/evolution.js`): ```ts res.status().json({ error: '' }) ``` - 一律 `{error: string}` 单字段 - error 值是**人类可读的简短英文短句**或**reason_code**(snake_case),看上下文 - 4xx 时 error 描述出错原因;5xx 时通常是 `error: e.message` **例外**: - 验证失败带字段名:`{error: 'missing field: company_id'}` - 5xx 内部异常允许带 stack:`{error, stack}`(仅 NODE_ENV=development) 不要发明 `{code, message}`、`{error: {code, message}}`、`{success:false, error:...}` 等其它形态——保持单字段。 --- ## 4 · DocStore 内部工具 | 工具 | 签名 | 行为 | |---|---|---| | `now()` | `() => string` | 等价 `new Date().toISOString()` | | `snapshot(obj)` | `(obj) => Promise` | JSON.stringify + sha1 → 写到 `agent-state/snapshots/{call_id}-{in\|out}.json`,返回相对路径 | | `dedupKey(bug)` | `(bug) => string` | `${unit_id}:${test_name || hash(detail).slice(0,8)}` | | `pidDir(cid, pid)` | `(cid, pid) => string` | `path.join(dataRoot, 'companies', cid, 'projects', pid)` | | `companyDir(cid)` | `(cid) => string` | `path.join(dataRoot, 'companies', cid)` | | `streamLogPath(pid)` | | `path.join(pidDir, 'stream.log')` | --- ## 5 · 文件写入(一致性 / 原子性) | 类型 | 工具 | 原因 | |---|---|---| | Agent state(`agent-state/*.json`)| `fs.writeFileSync(p, JSON.stringify(o), {flag:'w'})` | 小文件,覆盖式原子 | | ndjson append(timeline / bugs / changelog / stream.log)| `fs.appendFileSync(p, line)` | append-only,POSIX 保证 ≤PIPE_BUF (4KB) 原子 | | HTML/JSON 大文件(PRD/SDD/units/contract)| 写到 `${target}.tmp` → `fs.renameSync` | rename 是原子的,避免读到半文件 | `writeFileSync` 自带 `fsync` 在 Linux/macOS 不保证 — 关键路径需要显式 `fs.fsyncSync(fd)`。但 P1-P4 范围**不要求** fsync(接受偶发 power-loss 数据丢失,避免性能 30% 下降)。 --- ## 6 · 并发与锁 | 资源 | 锁机制 | |---|---| | 同 (pid, agent) 的 callAgent | `Map` 单飞,第二个等第一个完成 | | 同 pid 的 runProject / runUnit | 同上 | | 同 pid 的 onHumanFeedback drain | `Map` worker 标志,串行 | | ndjson 文件 append | 依赖 OS append 原子性,无文件锁 | | HTML 文件 write | tmp + rename,无文件锁 | 不引入 `proper-lockfile` 或类似库。所有协调都在内存里。 --- ## 7 · SSE 事件 ID `stream.log` 里每条事件的 `id` 字段: - 进程内 **counter**,从启动时读 `stream.log` 末行 id 作为起点(无文件则 0) - 不持久化 counter;重启后从文件末尾恢复 - counter 溢出 `Number.MAX_SAFE_INTEGER` 才需要 wrap,实际不会触发 --- ## 8 · Agent 调用上下文 `callAgent(name, input)` 默认注入到 system prompt 的上下文: 1. **Memory section**(M-11):top-5 by relevance × recency 2. **Project facts**:`{ pid, prd_rev, sdd_rev, unit_count }` 3. **Available tools**:从 `system/templates/_registry.json` 该 agent 的 toolPolicy 读 不在 input 里重复传 pid/cid——它们由 SessionPool key 隐含。Agent 通过工具读取自己的 cwd 即可。 --- ## 9 · 日志策略 | 文件 | 内容 | 滚动 | |---|---|---| | `stream.log` | SSE 客户端视角,1:1 重放 | 7 天 + gzip | | `timeline.ndjson` | 事件视角(audit log)| 永不删 | | `bugs.ndjson` / `changelog.ndjson` | 状态机轨迹 | 永不删 | | `units/{id}/coder.log` | Coder 决策日志 | 项目交付后压缩 | | `units/{id}/runs/{runId}/stdout.txt` | vitest 完整输出 | 14 天后 .gz(可配)| `console.log` / `console.error` 的逃逸:禁止。所有日志走 `timelineLogger.append` 或 `unitLog.write`。 --- ## 10 · 测试约定 - 单测:vitest,每 unit 自己的 `tests/` - 集成测试:`integration/spec.json` 驱动,Integrator 跑 - spec 里 `tests:` 行 = **必须存在的测试断言**,而非穷举(Coder 可以加更多) - spec 里没列的边角 case,Coder 自己判断要不要测 --- ## 11 · 命名风格速查 | 场景 | 风格 | 例 | |---|---|---| | JS 函数 / 变量 | camelCase | `loopController.runProject` | | JS 类 / 类型 | PascalCase | `ProjectMeta`, `RunResult` | | 文件名 | kebab-case | `loop-controller.js`, `bug-tracker.js` | | REST path | kebab-case | `/sign-off`, `/devloop/projects` | | Event 名 | dot.snake_case | `unit.state`, `agent.todolist` | | timeline.kind | snake_case | `prd_drafted`, `agent_started` | | Bug category | snake_case | `contract_mismatch`, `impl_bug` | | SSE event id | 数字 counter | `id: 1234` | --- ## 12 · 不变量速查 - 任意 `(pid, agent)` 同时只允许 1 个 status=running - 任意 `(pid, unit)` 同时只允许 1 个 runUnit - 任意 `pid` 同时只允许 1 个 onHumanFeedback worker - 任意 ndjson 文件 append 后**永不修改已有行** - DocStore 写入前**必须** validate;不通过抛 ValidationError - 跨 unit 读取**只允许** `../{otherUnit}/contract.json`,**禁止**读 code/