M-1 · ProjectMeta
data/companies/{cid}/projects/{pid}/project.json
// TypeBox schema const ProjectMeta = Type.Object({ id: Type.String({ pattern: '^p-[a-z0-9]{6}$' }), company_id: Type.String(), name: Type.String(), // 项目根 (见 arch-project-root) mode: Type.Union([ Type.Literal('sandbox'), // 默认,根在 data/.../projects/{pid}/ Type.Literal('linked-new'), // 用户指定空目录 Type.Literal('linked-existing'), // v2:复用已有 git repo ]), root_path: Type.String(), // 绝对路径,所有派生路径的源 status: Type.Union([ Type.Literal('prd_drafting'), Type.Literal('running'), Type.Literal('blocked'), Type.Literal('shipped'), Type.Literal('shipped-final'), Type.Literal('abandoned'), ]), created_at: Type.String({ format: 'date-time' }), updated_at: Type.String({ format: 'date-time' }), owner: Type.String(), token_budget: Type.Number({ default: 5_000_000 }), token_used: Type.Number({ default: 0 }), compressOldRuns: Type.Boolean({ default: false }), });
- id 全局唯一,跨 company 不重复
- status 只能按状态机合法转换(见 sdd-process.html · M-process-project)
- token_used > token_budget 时 LoopController 自动挂起项目
M-2 · units.json
data/companies/{cid}/projects/{pid}/units.json
const Unit = Type.Object({ id: Type.String({ pattern: '^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$' }), type: Type.Union([Type.Literal('be'), Type.Literal('fe'), Type.Literal('mw'), Type.Literal('db')]), status: Type.Union([ Type.Literal('pending'), Type.Literal('coding'), Type.Literal('testing'), Type.Literal('failed'), Type.Literal('passed'), Type.Literal('blocked'), ]), depends_on: Type.Array(Type.String()), retries: Type.Number({ default: 0 }), retry_budget: Type.Number({ default: 5 }), contract_hash: Type.String({ pattern: '^sha256:[a-f0-9]{16}$' }), last_run: Type.Optional(Type.String()), }); const UnitsJson = Type.Object({ project_id: Type.String(), prd_rev: Type.String(), sdd_rev: Type.String(), units: Type.Array(Unit), l2_rounds: Type.Number({ default: 0 }), l2_budget: Type.Number({ default: 3 }), });
- DAG 必须无环(DocStore 写入前用拓扑排序校验)
- depends_on 引用的 id 必须存在于本文件 units 中
- contract_hash = sha256(SDD 中对应 section <pre data-contract> 文本) 截前 16 位
- 循环依赖 → 拒绝写入
- 未知 id → 拒绝写入
- 所有 status 为 passed 且 hash 与当前 SDD 匹配 → l2GateOpen 返回 true
M-3 · prd/ 多页 PRD 站点
data/companies/{cid}/projects/{pid}/prd/ (目录)
// 目录布局 (PM Agent 产出,见 agents-pm.html §2.1) prd/ ├── index.html // Hero + reading guide entry ├── reading-guide.html ├── assets/ │ ├── shell.css // 复制自模板 │ ├── shell.js // 3 级菜单 + 搜索 │ └── search-index.json ├── product/ │ ├── background.html │ ├── personas.html │ ├── original-req.html │ ├── scope.html │ ├── success-metrics.html │ └── glossary.html ├── feature/ │ └── feature-tree.html ├── flow/ │ └── user-flows.html └── prototype/ └── proto-{viewName}.html // 每个 chapter HTML 内的结构 (cheerio 校验): <article data-doc="prd" data-rev="N"> <section data-rev="M" data-origin="{user@iso}"> <h3>...</h3> <p>...</p> </section> <aside data-conflict>...</aside> // 可选,index.html only <footer data-changelog>...</footer> </article>
- 顶级必须是
<article data-doc="prd">,每页都有 - 每个 section 必含
<h3>+ 至少一个<p> - section data-rev 单调递增(页内),article data-rev 取该页内最大
- 每页至少覆盖 4/8 类 HTML 信息(见 agents-pm §2.2)
- data-conflict aside 仅出现在 index.html 顶部,存在时整个项目暂停等人
- 11 个必备章节缺一个 → validate_prd_site 失败
- init 模式后 11 章节全在
- incremental 模式后仅 affected 章节被改
- 多 rev 后 history/ 含旧版归档
M-4 · sdd.html 结构
data/companies/{cid}/projects/{pid}/sdd.html
<article data-doc="sdd" data-derived-from="prd@revN"> <section data-unit-id="auth-be" data-type="be" data-depends=""> <h3>Unit · auth-be</h3> <p>...功能描述...</p> <pre data-contract="openapi"> POST /api/auth/login ... </pre> <ul data-acceptance> <li>acceptance criteria 1</li> </ul> </section> </article>
- 每个 unit section 必含 contract + acceptance
- data-depends 空格分隔 unit id 列表,可为空
- data-derived-from 指向产生它的 prd 版本
M-5 · contract.json(每 Unit)
.../units/{unitId}/contract.json
const Contract = Type.Object({ unit_id: Type.String(), version: Type.String({ pattern: '^sha256:' }), exports: Type.Object({ http: Type.Optional(Type.Array(HttpEndpoint)), ts_types: Type.Optional(Type.String()), events: Type.Optional(Type.Array(EventSpec)), }), }); const HttpEndpoint = Type.Object({ method: Type.String(), path: Type.String(), request: Type.Optional(Type.Object({}, { additionalProperties: true })), responses:Type.Object({}, { additionalProperties: true }), });
完整规格见 api-contracts。
M-6 · integration/spec.json
.../integration/spec.json
const IntegrationSpec = Type.Object({ version: Type.String(), cases: Type.Array(Type.Object({ id: Type.String(), steps: Type.Array(Type.Object({ unit: Type.String(), call: Type.String(), // "POST /api/x" body: Type.Optional(Type.Any()), headers: Type.Optional(Type.Record(Type.String(), Type.String())), expect: Type.Object({ status: Type.Number(), json: Type.Optional(Type.Any()), }), })), involves: Type.Array(Type.String()), })), });
- involves 列表必须是 cases.steps.unit 的去重并集,BugTriager 用它做归因粗筛
- headers / body 中可用
{{stepN.field}}占位符引用前面步骤结果
M-7 · runs/result.json (L1)
.../units/{unitId}/runs/{runId}/result.json
const RunResult = Type.Object({ run_id: Type.String({ pattern: '^run-\\d{8}-\\d{4}$' }), loop: Type.Literal('L1'), unit_id: Type.String(), agent: Type.Union([Type.Literal('coder'), Type.Literal('tester')]), attempt: Type.Number(), ok: Type.Boolean(), summary: Type.Object({ total: Type.Number(), passed: Type.Number(), failed: Type.Number(), }), failures: Type.Array(Type.Object({ test: Type.String(), expected: Type.Any(), got: Type.Any(), stack: Type.String(), hint: Type.String(), })), next_action: Type.Optional(Type.String()), retry_budget_left: Type.Optional(Type.Number()), duration_ms: Type.Number(), });
M-8 · integration/runs/result.json (L2)
data/companies/{cid}/projects/{pid}/integration/runs/{runId}/result.json
const IntResult = Type.Object({ run_id: Type.String({ pattern: '^run-int-\\d{8}-\\d{4}$' }), ok: Type.Boolean(), failures: Type.Array(Type.Object({ case: Type.String(), failed_step: Type.Number(), got: Type.Any(), involves: Type.Array(Type.String()), stderr_excerpt: Type.String(), })), process_log: Type.String(), duration_ms: Type.Number(), });
M-9 · triage.json
.../integration/runs/{runId}/triage.json
const Triage = Type.Object({ run_id: Type.String(), root_cause: Type.String({ minLength: 20 }), category: Type.Union([ Type.Literal('contract_mismatch'), Type.Literal('impl_bug'), Type.Literal('spec_ambiguous'), Type.Literal('infra_flake'), ]), affected_units: Type.Array(Type.String()), proposed_sdd_patch: Type.Union([Type.Object({ target_section: Type.String(), op: Type.String(), text: Type.String(), }), Type.Null()]), confidence: Type.Number({ minimum: 0, maximum: 1 }), evidence: Type.Array(Type.String()), });
- category = contract_mismatch → proposed_sdd_patch 必填
- category = impl_bug → proposed_sdd_patch 必为 null(不改 SDD,只 reset Unit)
- confidence < 0.5 → LoopController 上报 L3 不自动应用
- 每条 evidence 必须在原文件中能 grep 到
M-10 · AgentRuntimeState · 持久化运行态
data/companies/{cid}/projects/{pid}/agent-state/{agent}.json
const AgentRuntimeState = Type.Object({ agent: Type.Union([Type.Literal('pm'), Type.Literal('architect'), Type.Literal('coder'), Type.Literal('tester'), Type.Literal('integrator'), Type.Literal('bug-triager')]), unit_id: Type.Optional(Type.String()), // Coder/Tester 时填 status: Type.Union([ Type.Literal('idle'), Type.Literal('running'), Type.Literal('paused'), Type.Literal('done'), Type.Literal('error') ]), call_id: Type.String(), // 单次调用唯一 id current_step: Type.Number(), // 0-based, 引用 todolist.items[i] total_steps: Type.Number(), last_input_ref: Type.String(), // 输入快照路径 last_output_ref: Type.Optional(Type.String()), session_id: Type.Optional(Type.String()), // ACP session 引用 resume_token: Type.Optional(Type.String()), // 冷启恢复用 started_at: Type.String({ format: 'date-time' }), updated_at: Type.String({ format: 'date-time' }), error: Type.Optional(Type.Object({ code: Type.String(), message: Type.String(), stack: Type.Optional(Type.String()) })), });
- 每次 LoopController.callAgent 前写一次(status=running)
- Agent 自己在跨越主要 checkpoint 时通过
update_runtime_state工具更新 current_step - 调用结束(成功或失败)由 LoopController 写终态 status=done|error
- Server SIGTERM 时所有 status=running 自动改 status=paused 并写 resume_token
- Server 启动时扫所有 paused,发 SSE
resume_decision_needed等人决策
- Agent 崩溃 → state.json status=error,error 字段有 code/message
- Server kill -9 → 重启后 state.json status=paused,resume_token 非空
- Dashboard 能从 state.json 渲染「Architect at step 3/5」
M-11 · AgentMemory · 跨项目长期记忆
data/companies/{cid}/memory/devloop/{agent}/{topic}.md
// Markdown + frontmatter,人可读 + 机器可读 --- agent: architect topic: project-conventions project_refs: [p-731042, p-845001] last_updated: 2026-05-13T10:31:00Z relevance_score: 0.92 # 0-1, agent 自评 hits: 14 # 被注入 prompt 的次数 --- - 用户偏好 RESTful API + 完整 OpenAPI 注解 - Token 续期实测:5s 内人类可接受 - 认证方式:JWT 放 Authorization header,不用 cookie - 数据库统一用 better-sqlite3,不要 mysql/postgres
- 每个 Agent 启动时扫
memory/devloop/{agent}/*.md - 按 relevance_score × recency 排序,取前 5 条注入 system prompt
- Agent 自己可通过
record_memory(topic, content, relevance)工具新增 - Dedup:同 agent + 同 topic 是 upsert(覆盖 + 累加 hits),不新建文件。文件名 =
{topic-slug}.md,topic-slug 由 record_memory 工具按规则计算(小写 · 空格转 -) - memory 跨项目(同 company 内)共享,不跨 company
- frontmatter 必须含 agent / topic / last_updated 三字段
- 单文件 ≤ 8KB,超出 record_memory 自动 truncate 旧内容(保留新加 + 最相关)
- v1 注意:如果初版 token 紧张可以关 memory 注入(top-5 改 top-0),完全跳过。memory 在 ≥ 5 个项目后才开始有积累价值。
- 新 Agent 调用 → memory dir 存在则 system prompt 含 "## Memory" section
- 不存在该 agent dir → fallback 空 memory,不报错
- record_memory 写入后下次启动能读到
M-12 · AgentTodolist · 单次调用的子任务清单
data/companies/{cid}/projects/{pid}/agent-state/{agent}-todo.json
const AgentTodolist = Type.Object({ agent: Type.String(), call_id: Type.String(), // 与 AgentRuntimeState.call_id 关联 created_at: Type.String(), items: Type.Array(Type.Object({ id: Type.String({ pattern: '^step-\\d{3}$' }), description: Type.String(), status: Type.Union([Type.Literal('todo'), Type.Literal('doing'), Type.Literal('done'), Type.Literal('failed')]), started_at: Type.Optional(Type.String()), finished_at: Type.Optional(Type.String()), output_ref: Type.Optional(Type.String()), // 产物路径 depends_on: Type.Optional(Type.Array(Type.String())), error: Type.Optional(Type.String()), })), current: Type.String(), // 当前 item.id progress_pct: Type.Number({ minimum:0, maximum:100 }), }); // 典型 Architect generate 模式的 todolist 示例: items: [ { id:'step-001', description:'读 prd.html 并解析所有 section', status:'done' }, { id:'step-002', description:'识别功能列表(共 5 个特性)', status:'done' }, { id:'step-003', description:'拆分每个特性为 be/fe Unit', status:'doing' }, { id:'step-004', description:'为每个 Unit 写 OpenAPI contract', status:'todo' }, { id:'step-005', description:'生成 integration/spec.json', status:'todo' }, ]
- Agent 在 system prompt 指引下,开始时必须先写完整 todolist(todo 状态)
- 每完成一项立即调
update_todo_item(id, status, output_ref)工具 - progress_pct = done 数 / total × 100
- Dashboard 从这里渲染「Architect: 3/5 拆分 OAuth 功能为 3 个 Unit」
- 失败 → status=failed,触发 retry 时 todolist 清空重写
M-13 · ProcessTimeline · 全流程统一日志
data/companies/{cid}/projects/{pid}/timeline.ndjson
// NDJSON,append-only,一行一事件,每行 ≤ 1KB const TimelineEvent = Type.Object({ ts: Type.Number(), // unix seconds source: Type.Union([ Type.Literal('system'), // LoopController / DocStore / etc Type.Literal('human'), // 人类显式动作 Type.Literal('pm-agent'), Type.Literal('architect'), Type.Literal('coder'), Type.Literal('tester'), Type.Literal('integrator'), Type.Literal('bug-triager'), ]), kind: Type.String(), // "project_created" / "prd_drafted" / etc ref: Type.Optional(Type.String()), // 关联资源,如 "prd@rev3" summary: Type.String({ maxLength: 280 }), // 人话摘要 payload: Type.Optional(Type.Object({}, { additionalProperties:true })), }); // 示例 (一行 = 一事件): {"ts":1747120731,"source":"human","kind":"project_created","summary":"yibo 创建项目'做个 TODO API'"} {"ts":1747120745,"source":"pm-agent","kind":"prd_drafted","ref":"prd@rev1","summary":"PRD rev 1 完成"} {"ts":1747120798,"source":"architect","kind":"sdd_generated","ref":"sdd@rev1","summary":"拆 3 个 Unit"} {"ts":1747120821,"source":"coder","kind":"unit_coded","ref":"unit/todo-be","summary":"todo-be 实现完成"} {"ts":1747120833,"source":"tester","kind":"unit_failed","ref":"unit/todo-be","summary":"invalid-pwd 期望 401 收 500"}
- Append-only,永不修改已有行
- 所有 state 转移、文档变更、Agent 调用、Human 动作都必须 append 一条
- 是 audit log 也是 Dashboard 时间线视图数据源
- 每条 ≤ 1KB;超长内容(如完整 stderr)放 payload.{x}_ref 引用
- 由
timelineLogger.append(pid, event)写入(C-15 工具)
与 stream.log(SSE 视角)的区别:timeline.ndjson 是事件视角,stream.log 是客户端视角。前者永远存,后者 7 天滚动。
M-14 · bugs.ndjson · 跨 run 跨 unit Bug 聚合
data/companies/{cid}/projects/{pid}/bugs.ndjson
// NDJSON,append + 状态机更新 const Bug = Type.Object({ id: Type.String({ pattern: '^bug-\\d{8}-\\d{4}-[a-z0-9]{4}$' }), discovered_at:Type.String({ format: 'date-time' }), source: Type.Union([Type.Literal('unit_test'), Type.Literal('integration_test'), Type.Literal('triage'), Type.Literal('human')]), unit_id: Type.Optional(Type.String()), run_id: Type.Optional(Type.String()), // runs/{runId} 或 integration/runs/{runId} test_name: Type.Optional(Type.String()), category: Type.Union([Type.Literal('contract_mismatch'), Type.Literal('impl_bug'), Type.Literal('spec_ambiguous'), Type.Literal('infra_flake'), Type.Literal('unknown')]), severity: Type.Union([Type.Literal('critical'), Type.Literal('high'), Type.Literal('medium'), Type.Literal('low')]), title: Type.String({ maxLength: 120 }), detail: Type.String(), // 完整 stack/expected/got affected_units: Type.Array(Type.String()), status: Type.Union([Type.Literal('open'), Type.Literal('fixing'), Type.Literal('fixed'), Type.Literal('verified'), Type.Literal('wontfix'), Type.Literal('duplicate')]), status_history: Type.Array(Type.Object({ ts: Type.String(), from: Type.String(), to: Type.String(), by: Type.String(), // agent/system/human id note: Type.Optional(Type.String()), })), fix_refs: Type.Array(Type.String()), // 关联的 run_id / triage / commit duplicate_of: Type.Optional(Type.String()), // 另一个 bug id }); // 示例行: {"id":"bug-20260513-0731-7f3a","source":"unit_test","unit_id":"auth-be","run_id":"run-..0731", "category":"impl_bug","severity":"high","status":"fixed", "title":"login.invalid-pwd 期望 401 收 500","affected_units":["auth-be"],"fix_refs":["run-..0734"]}
- 每次 Tester / Integrator / BugTriager 失败 → 自动 append 1 条 bug,status=open
- 同一 unit 同一 test 连续两轮失败 → 视同同一 bug(合并),不重复 append
- status 转换走 P-9 状态机,每次转换 append 一条 status_history
- append-only:bug 内容修改通过新写一行 status=... 的更新事件实现(与 timeline 同理)
- 读取时取每个 bug.id 的最新一行作为当前状态
- 单测红 → bugs.ndjson 新增 status=open 行
- 下一轮 Coder 修复 → 新增 status=fixed 行 with fix_refs=[新 run_id]
- L2 通过 → 该 bug 状态最终 verified
M-15 · changelog.ndjson · 版本变更流
data/companies/{cid}/projects/{pid}/changelog.ndjson
const ChangelogEntry = Type.Object({ ts: Type.String({ format: 'date-time' }), kind: Type.Union([ Type.Literal('prd_rev'), Type.Literal('sdd_rev'), Type.Literal('units_rev'), Type.Literal('integration_spec_rev'), ]), rev: Type.Number(), by: Type.String(), // agent or human id trigger: Type.String(), // "human_feedback" | "triage_patch" | "init" summary: Type.String({ maxLength: 280 }), diff: Type.Object({ // 影响面快照 sections_added: Type.Array(Type.String()), sections_changed: Type.Array(Type.String()), sections_removed: Type.Array(Type.String()), units_added: Type.Array(Type.String()), units_changed: Type.Array(Type.String()), units_removed: Type.Array(Type.String()), }), archive_ref: Type.String(), // history/prd-rev-{N}.html 路径 }); // 示例: {"kind":"prd_rev","rev":3,"by":"pm-agent","trigger":"human_feedback", "summary":"+ OAuth 登录,影响 auth-mw 新增","diff":{"sections_added":["#oauth"],...}} {"kind":"sdd_rev","rev":5,"by":"architect","trigger":"prd@rev3", "summary":"3 个新 Unit: oauth-be / oauth-fe / auth-mw",...} {"kind":"sdd_rev","rev":6,"by":"architect","trigger":"triage_patch", "summary":"L2 修复:auth-be 401 明确",...}
- 每次 DocStore.writePRD/writeSDD/writeUnits 成功后必 append 一行
- diff 字段在 PM/Architect Agent 输出后由 DocStore 自动计算(cheerio + 拓扑 diff)
- archive_ref 指向归档文件,可被 Dashboard 直接打开
- 这是 history/ 目录的线性视图:history/ 是物,changelog 是事
归档命名约定(统一目录形式)
| 原产物 | 归档路径 | 形态 |
|---|---|---|
| prd/ 整站 | history/prd-rev-{N}/ | 目录(含整 prd/ 拷贝) |
| sdd/ 整站 | history/sdd-rev-{N}/ | 目录 |
| units.json | history/units-rev-{N}.json | 单文件 |
| integration/spec.json | history/integration-spec-rev-{N}.json | 单文件 |
| removed unit dir | history/removed/{unitId}-{ts}/ | 目录 + 时间戳防重 |
| agent-state snapshots | agent-state/snapshots/{call_id}-(in|out).json | 不归到 history/(用于 retry 回放) |
PRD/SDD 现在都是多页站点,归档单元是整个子目录而非单 html。所有 Agent 用 archive_doc 工具,传 kind 即可('prd'/'sdd'/'units'),路径由 DocStore 计算。
无 SQL · 无新表
整个 DevLoop 系统不引入新 SQLite 表。所有持久化通过文件系统完成。LemonBoss 既有的 data/users.sqlite 等不动。
这是有意为之的简化 — 让外部 Claude Code 复现项目时不用先想 schema migration。