sdd-model.md 21.3 KB

Model · 数据结构全集

所有持久化数据结构。9 条核心定义,每条带 TypeBox schema(运行时校验)+ TS interface(类型推导)+ 落盘路径。Coder Agent 读这页就能写出所有数据访问代码。

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 文本) 截前 16 位 - tests: 循环依赖 → 拒绝写入 - tests: 未知 id → 拒绝写入 - tests: 所有 status 为 passed 且 hash 与当前 SDD 匹配 → l2GateOpen 返回 true

M-3 · prd/ 多页 PRD 站点 · data/companies/{cid}/projects/{pid}/prd/ (目录) ```ts // 目录布局 (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 校验):

...

...

... // 可选,index.html only

...



- 顶级必须是 `
`,每页都有
- 每个 section 必含 `
### ` + 至少一个 `
` - section data-rev 单调递增(页内),**article data-rev** 取该页内最大 - 每页至少覆盖 4/8 类 HTML 信息(见 agents-pm §2.2) - data-conflict aside 仅出现在 index.html 顶部,存在时整个项目暂停等人 - 11 个必备章节缺一个 → validate_prd_site 失败 - tests: init 模式后 11 章节全在 - tests: incremental 模式后仅 affected 章节被改 - tests: 多 rev 后 history/ 含旧版归档 

 ## M-4 · sdd.html 结构 · `data/companies/{cid}/projects/{pid}/sdd.html` ```ts 

 <h3>Unit · auth-be <p>...功能描述...


POST /api/auth/login ...



- acceptance criteria 1



  • 每个 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
  • 每条 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 等人决策

  • tests: Agent 崩溃 → state.json status=error,error 字段有 code/message

  • tests: Server kill -9 → 重启后 state.json status=paused,resume_token 非空

  • tests: 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 个项目后才开始有积累价值。

  • tests: 新 Agent 调用 → memory dir 存在则 system prompt 含 "## Memory" section

  • tests: 不存在该 agent dir → fallback 空 memory,不报错

  • tests: 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 的最新一行作为当前状态

  • tests: 单测红 → bugs.ndjson 新增 status=open 行

  • tests: 下一轮 Coder 修复 → 新增 status=fixed 行 with fix_refs=[新 run_id]

  • tests: 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`

PRD/SDD 现在都是多页站点,归档单元是整个子目录而非单 html。所有 Agent 用 archivedoc 工具,传 kind 即可('prd'/'sdd'/'units'),路径由 DocStore 计算。_

无 SQL · 无新表

整个 DevLoop 系统不引入新 SQLite 表。所有持久化通过文件系统完成。LemonBoss 既有的 data/users.sqlite 等不动。

这是有意为之的简化 — 让外部 Claude Code 复现项目时不用先想 schema migration。