前置/后置/不变式 - 验收用例(给 Tester 转 vitest)
[C] Unit id 严格命名: /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/ 示例: auth-be · profile-fe · session-mw · todo-db 禁止: AuthBE · auth_be · auth-backend · auth (无后缀) [D] 每个 Unit 必须有: - contract (非空,OpenAPI/TS/schema) - contract_hash (sha256:{16 chars},由 hash_contract 工具计算) - depends_on (string[],可为空) - 在 sdd/mvcp/*.html 中至少出现 1 次 [E] integration/spec.json 每 case 必须: - 至少 2 步 - involves 字段是步骤 unit 的并集 - 引用的所有 unit 在 units.json 存在 # MANDATORY WORKFLOW ## step 0 · 读取上下文 ├─ 必读 prd/ 全部章节(尤其 prd/feature/feature-tree.html · prd/flow/user-flows.html) ├─ 必读 changelog.ndjson 最后 5 条 ├─ generate / patch 模式: 读现有 sdd/ + units.json + integration/spec.json (若存在) ├─ patch 模式: 必读 integration/runs/{runId}/triage.json └─ memory section 已在 prompt 顶部注入,不重复读 ## step 1 · write_todolist ### generate 模式典型 todolist (12 项): step-001 解析 prd/feature/feature-tree.html → 列出 N 个 feature step-002 对每个 feature 决定 be/fe/mw/db 拆分 step-003 为每个 Unit 起草 contract (OpenAPI / TS interface / schema) step-004 调 hash_contract 算 contract_hash step-005 构造 DAG (depends_on) + 拓扑排序验环 step-006 写 sdd/index.html + overview.html step-007 写 sdd/mvcp/model.html (所有 data schema) step-008 写 sdd/mvcp/view.html (所有 fe 组件) step-009 写 sdd/mvcp/control.html (所有 be/mw 接口) step-010 写 sdd/mvcp/process.html (长进程,可能空) step-011 写 units.json (合并所有 Unit 元数据) step-012 写 integration/spec.json (e2e cases) step-013 update_search_index('sdd') + validate_sdd_consistency step-014 timeline_append + record_memory (可选) ### patch 模式典型 todolist (6 项): step-001 读 triage.json + 定位影响的 sdd section step-002 sdd-section-patcher 应用 proposed_sdd_patch step-003 受影响 Unit 重算 contract_hash step-004 更新 units.json (changed 数组标记) step-005 (如 triage 暗示) 更新 integration/spec.json step-006 validate + update_search_index + timeline ## step N+1 · 收尾 ├─ validate_sdd_consistency(pid) → 必须 ok=true ├─ update_search_index(pid, 'sdd') ├─ timeline_append('sdd_generated'|'sdd_patched', ref, summary) └─ record_memory 如果发现可复用的架构模式(如 "项目偏好 better-sqlite3") # RULES (硬约束) 1. 永远不读、不写 prd/。PRD 是 PM 的领地。 2. 永远不写代码、不动 units/{id}/code/。代码是 Coder 的活。 3. 同一 feature 涉及 BE + FE 必拆 2 个 Unit。不允许"全栈 Unit"。 4. contract_hash 必须用 hash_contract 工具,不要手算/猜测。 5. patch 模式只动 triage.affected_units 涉及的 section,其他原样保留。 6. units.json 写入前必须 typebox 校验 + 拓扑排序验环。 7. integration/spec.json 的 involves 必须是 cases.steps.unit 的精确并集。 8. 任何带 JSON body 的 Bash 命令 → /tmp/*.json + -d @file (CLAUDE.md L91-99)。 9. 沙箱: sdd/** · units.json · integration/spec.json · memory/devloop/architect/ 10. 不调 call_sub_agent。你不分派。 # DECISION HEURISTICS (写 SDD 时常用判断) ### 何时拆 mw Unit - 跨 ≥2 个 Unit 共享的能力(认证、限流、CORS) - 引入新的请求/响应钩子 - 否则不拆,内联在调用方的 Unit 里。 ### 何时拆 db Unit - 新表/列的 SQL DDL - 跨多 Unit 共享的 schema (放 db 的好处:Coder 不必各自重复) - 否则,schema 写在调用方 be Unit 内。 ### 何时升 Unit 颗粒 - 一个 Unit code 估算 > 400 行 → 拆 2 个 - 单 Unit 测试用例 > 15 个 → 同上 - 同 feature 内有显著独立的子能力(如 auth 含 login + register + token-refresh) # OUTPUT VOICE - 中文写描述,英文写 token/code 标识符 - 不写"完美""强大",数字 > 形容词 - contract 段落要简洁但完整,不写无意义的注释 # SELF-CHECK 1. validate_sdd_consistency(pid) 返回 ok=true 2. units.json 通过 typebox 校验 3. units.json DAG 无环 (topoSort 通过) 4. 每个 Unit 在 sdd/mvcp/ 至少出现 1 次 5. 每个 Unit contract_hash 重算与文件中一致 6. integration/spec.json involves 都在 units.json 7. agent-state/architect.json status=done 8. todolist 全 done 9. timeline 含 sdd_generated 或 sdd_patched 10. patch 模式: 仅 affected_units 涉及的 unit contract_hash 变化 任一不过 → patch 后重跑 (≤2 次)。第 3 次仍不过 → 报 validation_failed 上报 L3。 ``` ### 2.3 工具集(含签名) | 工具 | 签名 | 用途 | 访问边界 | | --- | --- | --- | --- | | `Read` | (path) | 读 prd/ · sdd/ · triage.json · changelog | 项目只读 | | `Write` | (path, content) | 底层落盘 | 沙箱白名单 | | `bootstrap_sdd_site` NEW | (pid) | generate 模式:建 sdd/ 目录、复制 assets | 项目内 | | `write_mvcp_page` NEW | (pid, mvcp, defs[]) | 批量写 mvcp/{model|view|control|process}.html(含校验) | sdd/ 写 | | `hash_contract` NEW | (contract: object) → "sha256:..." | canonical JSON + sha256 截 16 位 | — | | `topo_sort` NEW | (units[]) → {ok, order, cycle?} | DAG 拓扑排序 + 环检测 | — | | `write_units_json` NEW | (pid, units) | typebox 校验 + 写盘 + 自动 hash 计算 | units.json 写 | | `write_integration_spec` NEW | (pid, spec) | 校验 involves ↔ units → 写盘 | integration/spec.json 写 | | `diff_units` NEW | (pid, old, new) → {added, changed, removed} | 对比两版 units.json | — | | `sdd_section_patcher` NEW | (pid, patch) | patch 模式专用:精准应用 triage 提议 | sdd/ 写 | | `validate_sdd_consistency` NEW | (pid) → {ok, issues[]} | SDD ↔ units.json ↔ spec 三方一致性 | — | | `update_search_index` | (pid, target='sdd') | 同 PM 工具,参数指定 sdd 子站 | — | | `archive_doc` | (pid, kind, oldFile) | 归档到 history/sdd-rev-N/ | — | | `write_todolist` | (items) | 同 PM | agent-state/ | | `update_todo_item` | (id, status, output_ref?) | 同 PM | agent-state/ | | `update_runtime_state` | (step, total) | 同 PM | agent-state/ | | `timeline_append` | (kind, ref, summary, payload?) | 同 PM | — | | `record_memory` | (topic, content, relevance) | 同 PM,写 memory/devloop/architect/ | memory/devloop/architect/ | ### 2.4 禁用的工具 / 行为
- **不允许 Bash** — Architect 不跑命令;所有操作走专用工具 - **不允许 Edit** — 章节级写就行,不需要细粒度行 patch - **不允许 call_sub_agent** - **不允许写**:`prd/` · `units/*/code/` · `units/*/tests/` · `units/*/contract.json`(Coder 的领地) · `integration/runs/`(Integrator 的领地)
## ③ 持久化集成(M-10/11/12/13 接入) 与 PM Agent 同模式:开工前 LoopController 注入 memory,结束前可写新 memory。todolist + runtime state + timeline 在每个 step 进出时同步更新。
### 3.1 Architect 写 memory 的判断
仅在**架构模式**非平凡时记录:
- ✓ "用户偏 SQL DDL 写在 db Unit 而非 be Unit"
- ✓ "中型项目 mw 拆分阈值:≥3 个 Unit 共享时拆"
- ✗ 不记录具体项目的 schema 细节(那是 PRD/SDD 内容)
- ✗ 不记录具体 Unit 命名(feature-tree 已固化)
## ④ I/O 契约
### 4.1 输入 schema
```
const ArchitectInput = Type.Object({
pid: Type.String(),
mode: Type.Union([Type.Literal('generate'),
Type.Literal('patch'),
Type.Literal('resume')]),
// generate 模式
prd_rev: Type.Optional(Type.Number()),
// patch 模式
triage: Type.Optional(Type.Object({
run_id: Type.String(),
proposed_sdd_patch: Type.Object({
target_section: Type.String(),
op: Type.String(),
text: Type.String(),
}),
affected_units: Type.Array(Type.String()),
category: Type.String(),
confidence: Type.Number(),
})),
// resume 模式
resume_token: Type.Optional(Type.String()),
// 自动注入
call_id: Type.String(),
company_id: Type.String(),
});
```
### 4.2 输出 schema
```
const ArchitectOutput = Type.Object({
ok: Type.Boolean(),
mode: Type.String(),
sdd_rev: Type.Number(),
files_written: Type.Array(Type.String()),
files_archived: Type.Array(Type.String()),
units_diff: Type.Object({
added: Type.Array(Type.String()),
changed: Type.Array(Type.String()), // contract_hash 变化
removed: Type.Array(Type.String()),
}),
units_count: Type.Number(),
integration_cases_count: Type.Number(),
validation: Type.Object({
ok: Type.Boolean(),
issues: Type.Array(Type.String()),
}),
change_summary: Type.String({ maxLength: 500 }),
memory_recorded: Type.Array(Type.Object({ topic, relevance })),
duration_ms: Type.Number(),
tokens_used: Type.Number(),
});
```
## ⑤ 上下游传递
#### ⬆ 上游
- **PM Agent** · 通过 prd/ 站点 + changelog.ndjson
- **BugTriager** · 通过 triage.json(patch 模式)
- **LoopController** · onHumanFeedback 后 / onL2Failure 后 callAgent
- **M-11 Memory** · 启动时由 LoopController 注入
- **历史 sdd/ + units.json** · 读以决定增量 vs 重写
#### ⬇ 下游
- **Coder Agent** · 读 sdd/mvcp/*.html + Unit spec 写代码
- **Tester Agent** · 读 sdd/mvcp/ tests 列表派生 vitest 用例
- **Integrator Agent** · 读 integration/spec.json 跑 e2e
- **BugTriager Agent** · 读 sdd/ + units.json 做归因
- **UnitRegistry** · 读 units_diff 决定哪些 Unit reset 到 pending
- **LoopController** · 读 units_diff 调度 L1 重跑
- **SSE** · sdd.updated / agent.* · changelog.ndjson append
## ⑥ 完整执行样例
```
# ─────────── INPUT ───────────
{
"pid": "p-731042",
"mode": "generate",
"prd_rev": 1,
"call_id": "call-20260513-1031-a1b2",
"company_id": "c-lemonv-001"
}
# ─────────── INJECTED MEMORY ───────────
## Memory (from prior projects)
### sql-conventions (relevance: 0.91)
- SQL DDL 偏好写在独立 db Unit (跨 be 复用)
- better-sqlite3 是默认选择,不用 mysql/postgres
### auth-patterns (relevance: 0.85)
- 认证逻辑只要不止 2 处用到就拆 mw Unit
- JWT 在 Authorization header, 不存 cookie
# ─────────── 内部执行 ───────────
# step 0 · 读 prd/feature/feature-tree.html
# 发现 2 个特性:
# F1: TODO 列表 API (POST /todos, GET /todos)
# F2: TODO Vue 前端
# step 1 · write_todolist (12 项 generate 模板)
# step 2 · 拆 Unit (架构判断):
# F1 → todo-be · 单独 schema 较简单 → 暂不拆 db Unit (写在 be 内)
# (后续 F1 复杂化或加缓存才拆)
# F2 → todo-fe
# 无认证 → 不拆 mw
# step 3 · 起草 contracts
todo-be contract:
POST /api/todos { title: string } → 201 { id, title, created_at } | 400 { error }
GET /api/todos → 200 { items: TodoItem[] }
todo-fe contract:
Component: TodoListPage
Props: {}
Emits: {}
consumes: todo-be (HTTP)
# step 4 · hash_contract
hash_contract(todo-be.contract) → "sha256:4a7c8e2f9d1b3a5e"
hash_contract(todo-fe.contract) → "sha256:b2d4f6a8e0c1d3f5"
# step 5 · DAG
todo-be: depends_on []
todo-fe: depends_on ["todo-be"]
topo_sort → ok, order=[todo-be, todo-fe]
# step 6 · write_mvcp_page('model', defs)
# model.html 含 TodoItem schema + db schema 内嵌注释
# step 7-10 · 写 view/control/process
# step 11 · write_units_json
{
"project_id": "p-731042", "prd_rev": "1", "sdd_rev": "1",
"units": [
{ "id":"todo-be", "type":"be", "status":"pending",
"depends_on":[], "retries":0, "retry_budget":5,
"contract_hash":"sha256:4a7c8e2f9d1b3a5e" },
{ "id":"todo-fe", "type":"fe", "status":"pending",
"depends_on":["todo-be"], "contract_hash":"sha256:b2d4f6a8e0c1d3f5" }
],
"l2_rounds":0, "l2_budget":3
}
# step 12 · write_integration_spec
{
"version": "sdd-rev-1",
"cases": [
{ "id": "create-then-list",
"steps": [
{ "unit":"todo-be", "call":"POST /api/todos",
"body":{"title":"buy milk"}, "expect":{"status":201} },
{ "unit":"todo-be", "call":"GET /api/todos",
"expect":{"status":200, "json":{"items":"$.length > 0"}} }
], "involves": ["todo-be"] },
{ "id": "ui-renders-from-api",
"steps": [
{ "unit":"todo-be", "call":"POST /api/todos", ... },
{ "unit":"todo-fe", "call":"mount ",
"expect":{"dom":"li:contains('buy milk')"} }
], "involves": ["todo-be", "todo-fe"] }
]
}
# step 13 · validate_sdd_consistency → ok=true
# step 14 · timeline_append("sdd_generated", "sdd@rev1", "2 units, 2 cases")
# ─────────── OUTPUT ───────────
{
"ok": true, "mode": "generate", "sdd_rev": 1,
"files_written": [
"sdd/index.html", "sdd/overview.html",
"sdd/assets/shell.css", "sdd/assets/shell.js", "sdd/assets/search-index.json",
"sdd/mvcp/model.html", "sdd/mvcp/view.html",
"sdd/mvcp/control.html", "sdd/mvcp/process.html",
"units.json", "integration/spec.json"
],
"units_diff": { "added":["todo-be","todo-fe"], "changed":[], "removed":[] },
"units_count": 2, "integration_cases_count": 2,
"validation": { "ok":true, "issues":[] },
"change_summary": "SDD rev 1: 2 units (todo-be · todo-fe), 2 e2e cases",
"memory_recorded": [],
"duration_ms": 68_120, "tokens_used": 14_823
}
```
```
# ─────────── INPUT (patch 模式) ───────────
{
"pid": "p-731042",
"mode": "patch",
"triage": {
"run_id": "run-int-20260513-1031",
"proposed_sdd_patch": {
"target_section": "#unit-auth-be",
"op": "update_contract",
"text": "明确:登录失败必须 401 with {error:'invalid_credentials'}"
},
"affected_units": ["auth-be", "auth-fe"],
"category": "contract_mismatch",
"confidence": 0.86
},
...
}
# step 1 · 读 triage + 定位 sdd/mvcp/control.html 内 data-def-name="auth-be" section
# step 2 · sdd_section_patcher.apply()
# 在 #unit-auth-be 的 contract 段落更新 401 响应描述
# 同时同步 #unit-auth-fe 内对 401 期望的描述(因为 contract_mismatch 是双侧)
# step 3 · 重算 contract_hash
# auth-be.contract 文本变了 → 新 hash sha256:8e1f...
# auth-fe.contract 文本变了 → 新 hash sha256:9c4d...
# step 4 · write_units_json (incremental patch)
# 找到 units[].id == 'auth-be' → 更新 contract_hash
# 找到 units[].id == 'auth-fe' → 更新 contract_hash
# 其他 unit 不动
# step 5 · integration/spec.json 不动 (triage 没要求改)
# step 6 · validate_sdd_consistency → ok=true
# timeline_append("sdd_patched", "sdd@rev2",
# "+ 401 invalid_credentials · affected: auth-be, auth-fe")
# ─────────── OUTPUT ───────────
{
"ok": true, "mode": "patch", "sdd_rev": 2,
"files_written": [
"sdd/mvcp/control.html", // 仅这一页改
"units.json",
"sdd/assets/search-index.json"
],
"files_archived": [
"history/sdd-rev-1/control.html",
"history/units-rev-1.json"
],
"units_diff": {
"added":[],
"changed":["auth-be","auth-fe"], // contract_hash 变化 → UnitRegistry reset
"removed":[]
},
...
}
# 下游连锁:
# UnitRegistry 见 changed=[auth-be, auth-fe]
# → 两个 Unit status: passed → pending
# → LoopController 回 L1 重跑
```
```
# ─────────── INPUT (incremental,PRD 加了 OAuth) ───────────
{
"pid": "p-731042",
"mode": "generate", // PRD 变化 → 走 generate,不是 patch
"prd_rev": 2,
...
}
# 注意: PRD 变化触发的 Architect 调用是 mode=generate (重新派生整套 SDD)
# patch 模式专门给 BugTriager 用
# step 0 · 读 prd/feature/feature-tree.html (rev 2)
# 原 2 特性 + 1 新特性 (OAuth)
# step 2 · 拆 Unit
# 原:todo-be / todo-fe (保留)
# 新 OAuth → 多处复用认证(todo-be 也得保护) → 拆 mw
# - auth-be: 处理 GitHub OAuth callback
# - auth-fe: 登录按钮 + redirect 处理
# - auth-mw: JWT 校验中间件 (todo-be 会用到)
# step 5 · DAG
# todo-be : depends_on []
# auth-be : depends_on []
# auth-mw : depends_on []
# todo-be 现在依赖 auth-mw → 加 dependency: depends_on:["auth-mw"]
# todo-fe : depends_on ["todo-be","auth-fe"]
# auth-fe : depends_on ["auth-be"]
# topo_sort → ok
# diff_units(old, new):
# added: ["auth-be","auth-fe","auth-mw"]
# changed: ["todo-be"] ← 加了 auth-mw 依赖,contract 变了
# removed: []
# OUTPUT
{
"ok": true, "mode": "generate", "sdd_rev": 3,
"units_diff": {
"added": ["auth-be", "auth-fe", "auth-mw"],
"changed": ["todo-be"], // 受 auth-mw 添加影响
"removed": []
},
"units_count": 5,
"integration_cases_count": 5, // 新加 3 个 oauth flow + 1 个回归
"change_summary": "+ OAuth 3 units · todo-be 加 auth-mw 依赖",
"memory_recorded": [
{ "topic":"oauth-arch", "relevance":0.72 }
]
}
# UnitRegistry:
# added [auth-be, auth-fe, auth-mw] → status: pending (入 L1 队列)
# changed [todo-be] → status: passed → pending (重新跑)
# todo-fe 不在 diff → status 保持 (passed 的不动)
```
## ⑦ 助手工具源码(4 个)
### 7.1 `hash-contract.js` · 固定算法 contract hash
```
// server/services/agent-tools/hash-contract.js · ~40 行
const crypto = require('crypto');
/**
* Canonical JSON:递归排序 key,统一引号,无空白。
* 保证同样 contract 在任意机器/任意时间产生同样 hash。
*/
function canonical(v) {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']';
const keys = Object.keys(v).sort();
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}';
}
function hashContract(contract) {
if (!contract || typeof contract !== 'object')
throw new Error('hashContract: contract must be object');
const body = contract.exports || contract;
const normalized = canonical(body);
const full = crypto.createHash('sha256').update(normalized).digest('hex');
return 'sha256:' + full.slice(0, 16); // 截 16 hex chars
}
/**
* 验证某 Unit 的 contract_hash 是否与文件中标记一致。
* 用于 self-check + L2 gate 判定。
*/
function verifyContractHash(unit, contract) {
const recomputed = hashContract(contract);
return {
ok: recomputed === unit.contract_hash,
expected: unit.contract_hash,
actual: recomputed,
};
}
module.exports = { hashContract, verifyContractHash, canonical };
```
### 7.2 `unit-extractor.js` · 从 PRD feature-tree 派生 Unit 草案
```
// server/services/agent-tools/unit-extractor.js · ~80 行
const cheerio = require('cheerio');
const fs = require('fs');
/**
* 读 prd/feature/feature-tree.html,识别每个 feature,
* 给 Architect 返回 Unit 拆分草案(架构判断仍由 Agent 做)。
*/
function extractUnitDrafts(prdFeatureTreePath) {
const $ = cheerio.load(fs.readFileSync(prdFeatureTreePath, 'utf8'));
const drafts = [];
// feature-tree 约定: 每个特性是一个
$('section[data-feature-id]').each((_, el) => {
const id = $(el).attr('data-feature-id');
const name = $(el).find('h3').first().text().trim();
const desc = $(el).find('p').first().text().trim();
// 启发式判断需要哪些 Unit type
const hint = $(el).attr('data-needs') || ''; // "be,fe" 或 "be,fe,mw,db"
const needs = hint.split(',').map(s => s.trim()).filter(Boolean);
const defaultNeeds = needs.length ? needs : ['be', 'fe']; // 默认全栈
for (const type of defaultNeeds) {
drafts.push({
unit_id: `${id}-${type}`,
feature_id: id,
feature_name: name,
type,
description: desc,
suggested_depends: suggestDeps(id, type, defaultNeeds),
});
}
});
return drafts;
}
function suggestDeps(featureId, type, allNeeds) {
// fe 默认依赖同 feature 的 be
if (type === 'fe' && allNeeds.includes('be')) return [`${featureId}-be`];
// be 默认无依赖 (Architect 后续可加 mw/db 依赖)
return [];
}
function validateUnitId(id) {
return /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/.test(id);
}
module.exports = { extractUnitDrafts, validateUnitId };
```
### 7.3 `sdd-section-patcher.js` · patch 模式精准应用
```
// server/services/agent-tools/sdd-section-patcher.js · ~70 行
const cheerio = require('cheerio');
const fs = require('fs');
const path = require('path');
/**
* 应用 triage.proposed_sdd_patch 到 sdd/mvcp/ 内对应文件。
* 不重写整页,只替换 target_section 的内容。
*/
function applySddPatch({ sddDir, patch, by }) {
// target_section 形如 "#unit-auth-be" 或 "#def-auth-be-contract"
// 在 sdd/mvcp/*.html 中查找含有该 id (或 data-unit-id 匹配) 的 section
const candidates = ['model.html', 'view.html', 'control.html', 'process.html'];
let applied = [];
for (const f of candidates) {
const p = path.join(sddDir, 'mvcp', f);
if (!fs.existsSync(p)) continue;
const raw = fs.readFileSync(p, 'utf8');
const $ = cheerio.load(raw, { decodeEntities: false });
// 尝试匹配 target_section
const selector = patch.target_section.replace('#unit-', 'section[data-unit-id="').replace(/$/, '"]');
const target = $(selector);
if (target.length === 0) continue;
if (patch.op === 'update_contract') {
const pre = target.find('pre[data-contract]').first();
if (pre.length === 0) {
throw new Error(`${f}: target section has no
`); } // 追加 patch.text 作为新行 (而非替换,以保留历史) pre.append('\n // PATCH (' + by + '):\n ' + patch.text + '\n'); // 升 section data-rev const curRev = parseInt(target.attr('data-rev') || '1', 10); target.attr('data-rev', String(curRev + 1)); fs.writeFileSync(p, $.html()); applied.push({ file: f, section: patch.target_section, new_rev: curRev + 1 }); } else if (patch.op === 'append_acceptance') { const ul = target.find('ul[data-acceptance]').first(); ul.append('