Commit 53eda8d2d677cfd49700a38579286f366fc7363d
1 parent
fd86ebf1
chore(prd): snapshot build-manifest.json (52 MVCP defs, 100% extraction)
Output of scripts/extract-manifest.js against docs/prd/newfunction/. Captures the current state: - 52 MVCP definitions (model=15, view=11, control=17, process=9) - 100% path + shape extraction coverage - Sized ~52KB This file is regenerable (just run the script), but committing the snapshot lets reviewers and external Claude Code sessions diff the schema surface without rebuilding. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
1 changed file
with
570 additions
and
0 deletions
build-manifest.json
0 → 100644
| 1 | +{ | |
| 2 | + "meta": { | |
| 3 | + "source": "docs/prd/newfunction", | |
| 4 | + "prdRev": "v0.2", | |
| 5 | + "extractedAt": "2026-05-13T04:16:35.145Z" | |
| 6 | + }, | |
| 7 | + "model": [ | |
| 8 | + { | |
| 9 | + "name": "ProjectMeta", | |
| 10 | + "path": "data/companies/{cid}/projects/{pid}/project.json", | |
| 11 | + "shape": "// TypeBox schema\nconst ProjectMeta = Type.Object({\n id: Type.String({ pattern: '^p-[a-z0-9]{6}$' }),\n company_id: Type.String(),\n name: Type.String(),\n status: Type.Union([\n Type.Literal('prd_drafting'),\n Type.Literal('running'),\n Type.Literal('blocked'),\n Type.Literal('shipped'),\n Type.Literal('shipped-final'),\n Type.Literal('abandoned'),\n ]),\n created_at: Type.String({ format: 'date-time' }),\n updated_at: Type.String({ format: 'date-time' }),\n owner: Type.String(),\n token_budget: Type.Number({ default: 5_000_000 }),\n token_used: Type.Number({ default: 0 }),\n compressOldRuns: Type.Boolean({ default: false }),\n});", | |
| 12 | + "contract": [ | |
| 13 | + "id 全局唯一,跨 company 不重复", | |
| 14 | + "status 只能按状态机合法转换(见 sdd-process.html · M-process-project)", | |
| 15 | + "token_used > token_budget 时 LoopController 自动挂起项目" | |
| 16 | + ], | |
| 17 | + "tests": [] | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "name": "UnitsJson", | |
| 21 | + "path": "data/companies/{cid}/projects/{pid}/units.json", | |
| 22 | + "shape": "const Unit = Type.Object({\n id: Type.String({ pattern: '^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$' }),\n type: Type.Union([Type.Literal('be'), Type.Literal('fe'), Type.Literal('mw'), Type.Literal('db')]),\n status: Type.Union([\n Type.Literal('pending'), Type.Literal('coding'),\n Type.Literal('testing'), Type.Literal('failed'),\n Type.Literal('passed'), Type.Literal('blocked'),\n ]),\n depends_on: Type.Array(Type.String()),\n retries: Type.Number({ default: 0 }),\n retry_budget: Type.Number({ default: 5 }),\n contract_hash: Type.String({ pattern: '^sha256:[a-f0-9]{16}$' }),\n last_run: Type.Optional(Type.String()),\n});\nconst UnitsJson = Type.Object({\n project_id: Type.String(),\n prd_rev: Type.String(),\n sdd_rev: Type.String(),\n units: Type.Array(Unit),\n l2_rounds: Type.Number({ default: 0 }),\n l2_budget: Type.Number({ default: 3 }),\n});", | |
| 23 | + "contract": [ | |
| 24 | + "DAG 必须无环(DocStore 写入前用拓扑排序校验)", | |
| 25 | + "depends_on 引用的 id 必须存在于本文件 units 中", | |
| 26 | + "contract_hash = sha256(SDD 中对应 section <pre data-contract> 文本) 截前 16 位" | |
| 27 | + ], | |
| 28 | + "tests": [ | |
| 29 | + "循环依赖 → 拒绝写入", | |
| 30 | + "未知 id → 拒绝写入", | |
| 31 | + "所有 status 为 passed 且 hash 与当前 SDD 匹配 → l2GateOpen 返回 true" | |
| 32 | + ] | |
| 33 | + }, | |
| 34 | + { | |
| 35 | + "name": "PRD-Site", | |
| 36 | + "path": "data/companies/{cid}/projects/{pid}/prd/ (目录)", | |
| 37 | + "shape": "// 目录布局 (PM Agent 产出,见 agents-pm.html §2.1)\nprd/\n├── index.html // Hero + reading guide entry\n├── reading-guide.html\n├── assets/\n│ ├── shell.css // 复制自模板\n│ ├── shell.js // 3 级菜单 + 搜索\n│ └── search-index.json\n├── product/\n│ ├── background.html\n│ ├── personas.html\n│ ├── original-req.html\n│ ├── scope.html\n│ ├── success-metrics.html\n│ └── glossary.html\n├── feature/\n│ └── feature-tree.html\n├── flow/\n│ └── user-flows.html\n└── prototype/\n └── proto-{viewName}.html\n\n// 每个 chapter HTML 内的结构 (cheerio 校验):\n<article data-doc=\"prd\" data-rev=\"N\">\n <section data-rev=\"M\" data-origin=\"{user@iso}\">\n <h3>...</h3>\n <p>...</p>\n </section>\n <aside data-conflict>...</aside> // 可选,index.html only\n <footer data-changelog>...</footer>\n</article>", | |
| 38 | + "contract": [ | |
| 39 | + "顶级必须是 <article data-doc=\"prd\">,每页都有", | |
| 40 | + "每个 section 必含 <h3> + 至少一个 <p>", | |
| 41 | + "section data-rev 单调递增(页内),article data-rev 取该页内最大", | |
| 42 | + "每页至少覆盖 4/8 类 HTML 信息(见 agents-pm §2.2)", | |
| 43 | + "data-conflict aside 仅出现在 index.html 顶部,存在时整个项目暂停等人", | |
| 44 | + "11 个必备章节缺一个 → validate_prd_site 失败" | |
| 45 | + ], | |
| 46 | + "tests": [ | |
| 47 | + "init 模式后 11 章节全在", | |
| 48 | + "incremental 模式后仅 affected 章节被改", | |
| 49 | + "多 rev 后 history/ 含旧版归档" | |
| 50 | + ] | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "name": "SDD-HTML", | |
| 54 | + "path": "data/companies/{cid}/projects/{pid}/sdd.html", | |
| 55 | + "shape": "<article data-doc=\"sdd\" data-derived-from=\"prd@revN\">\n <section\n data-unit-id=\"auth-be\"\n data-type=\"be\"\n data-depends=\"\">\n <h3>Unit · auth-be</h3>\n <p>...功能描述...</p>\n <pre data-contract=\"openapi\">\n POST /api/auth/login ...\n </pre>\n <ul data-acceptance>\n <li>acceptance criteria 1</li>\n </ul>\n </section>\n</article>", | |
| 56 | + "contract": [ | |
| 57 | + "每个 unit section 必含 contract + acceptance", | |
| 58 | + "data-depends 空格分隔 unit id 列表,可为空", | |
| 59 | + "data-derived-from 指向产生它的 prd 版本" | |
| 60 | + ], | |
| 61 | + "tests": [] | |
| 62 | + }, | |
| 63 | + { | |
| 64 | + "name": "ContractJson", | |
| 65 | + "path": ".../units/{unitId}/contract.json", | |
| 66 | + "shape": "const Contract = Type.Object({\n unit_id: Type.String(),\n version: Type.String({ pattern: '^sha256:' }),\n exports: Type.Object({\n http: Type.Optional(Type.Array(HttpEndpoint)),\n ts_types: Type.Optional(Type.String()),\n events: Type.Optional(Type.Array(EventSpec)),\n }),\n});\n\nconst HttpEndpoint = Type.Object({\n method: Type.String(),\n path: Type.String(),\n request: Type.Optional(Type.Object({}, { additionalProperties: true })),\n responses:Type.Object({}, { additionalProperties: true }),\n});", | |
| 67 | + "contract": [], | |
| 68 | + "tests": [] | |
| 69 | + }, | |
| 70 | + { | |
| 71 | + "name": "IntegrationSpec", | |
| 72 | + "path": ".../integration/spec.json", | |
| 73 | + "shape": "const IntegrationSpec = Type.Object({\n version: Type.String(),\n cases: Type.Array(Type.Object({\n id: Type.String(),\n steps: Type.Array(Type.Object({\n unit: Type.String(),\n call: Type.String(), // \"POST /api/x\"\n body: Type.Optional(Type.Any()),\n headers: Type.Optional(Type.Record(Type.String(), Type.String())),\n expect: Type.Object({\n status: Type.Number(),\n json: Type.Optional(Type.Any()),\n }),\n })),\n involves: Type.Array(Type.String()),\n })),\n});", | |
| 74 | + "contract": [ | |
| 75 | + "involves 列表必须是 cases.steps.unit 的去重并集,BugTriager 用它做归因粗筛", | |
| 76 | + "headers / body 中可用 {{stepN.field}} 占位符引用前面步骤结果" | |
| 77 | + ], | |
| 78 | + "tests": [] | |
| 79 | + }, | |
| 80 | + { | |
| 81 | + "name": "RunResult", | |
| 82 | + "path": ".../units/{unitId}/runs/{runId}/result.json", | |
| 83 | + "shape": "const RunResult = Type.Object({\n run_id: Type.String({ pattern: '^run-\\\\d{8}-\\\\d{4}$' }),\n loop: Type.Literal('L1'),\n unit_id: Type.String(),\n agent: Type.Union([Type.Literal('coder'), Type.Literal('tester')]),\n attempt: Type.Number(),\n ok: Type.Boolean(),\n summary: Type.Object({\n total: Type.Number(),\n passed: Type.Number(),\n failed: Type.Number(),\n }),\n failures: Type.Array(Type.Object({\n test: Type.String(),\n expected: Type.Any(),\n got: Type.Any(),\n stack: Type.String(),\n hint: Type.String(),\n })),\n next_action: Type.Optional(Type.String()),\n retry_budget_left: Type.Optional(Type.Number()),\n duration_ms: Type.Number(),\n});", | |
| 84 | + "contract": [], | |
| 85 | + "tests": [] | |
| 86 | + }, | |
| 87 | + { | |
| 88 | + "name": "IntegrationResult", | |
| 89 | + "path": "data/companies/{cid}/projects/{pid}/integration/runs/{runId}/result.json", | |
| 90 | + "shape": "const IntResult = Type.Object({\n run_id: Type.String({ pattern: '^run-int-\\\\d{8}-\\\\d{4}$' }),\n ok: Type.Boolean(),\n failures: Type.Array(Type.Object({\n case: Type.String(),\n failed_step: Type.Number(),\n got: Type.Any(),\n involves: Type.Array(Type.String()),\n stderr_excerpt: Type.String(),\n })),\n process_log: Type.String(),\n duration_ms: Type.Number(),\n});", | |
| 91 | + "contract": [], | |
| 92 | + "tests": [] | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "name": "TriageJson", | |
| 96 | + "path": ".../integration/runs/{runId}/triage.json", | |
| 97 | + "shape": "const Triage = Type.Object({\n run_id: Type.String(),\n root_cause: Type.String({ minLength: 20 }),\n category: Type.Union([\n Type.Literal('contract_mismatch'),\n Type.Literal('impl_bug'),\n Type.Literal('spec_ambiguous'),\n Type.Literal('infra_flake'),\n ]),\n affected_units: Type.Array(Type.String()),\n proposed_sdd_patch: Type.Union([Type.Object({\n target_section: Type.String(),\n op: Type.String(),\n text: Type.String(),\n }), Type.Null()]),\n confidence: Type.Number({ minimum: 0, maximum: 1 }),\n evidence: Type.Array(Type.String()),\n});", | |
| 98 | + "contract": [ | |
| 99 | + "category = contract_mismatch → proposed_sdd_patch 必填", | |
| 100 | + "category = impl_bug → proposed_sdd_patch 必为 null(不改 SDD,只 reset Unit)", | |
| 101 | + "confidence < 0.5 → LoopController 上报 L3 不自动应用", | |
| 102 | + "每条 evidence 必须在原文件中能 grep 到" | |
| 103 | + ], | |
| 104 | + "tests": [] | |
| 105 | + }, | |
| 106 | + { | |
| 107 | + "name": "AgentRuntimeState", | |
| 108 | + "path": "data/companies/{cid}/projects/{pid}/agent-state/{agent}.json", | |
| 109 | + "shape": "const AgentRuntimeState = Type.Object({\n agent: Type.Union([Type.Literal('pm'), Type.Literal('architect'),\n Type.Literal('coder'), Type.Literal('tester'),\n Type.Literal('integrator'), Type.Literal('bug-triager')]),\n unit_id: Type.Optional(Type.String()), // Coder/Tester 时填\n status: Type.Union([\n Type.Literal('idle'), Type.Literal('running'),\n Type.Literal('paused'), Type.Literal('done'),\n Type.Literal('error')\n ]),\n call_id: Type.String(), // 单次调用唯一 id\n current_step: Type.Number(), // 0-based, 引用 todolist.items[i]\n total_steps: Type.Number(),\n last_input_ref: Type.String(), // 输入快照路径\n last_output_ref: Type.Optional(Type.String()),\n session_id: Type.Optional(Type.String()), // ACP session 引用\n resume_token: Type.Optional(Type.String()), // 冷启恢复用\n started_at: Type.String({ format: 'date-time' }),\n updated_at: Type.String({ format: 'date-time' }),\n error: Type.Optional(Type.Object({\n code: Type.String(),\n message: Type.String(),\n stack: Type.Optional(Type.String())\n })),\n});", | |
| 110 | + "contract": [ | |
| 111 | + "每次 LoopController.callAgent 前写一次(status=running)", | |
| 112 | + "Agent 自己在跨越主要 checkpoint 时通过 update_runtime_state 工具更新 current_step", | |
| 113 | + "调用结束(成功或失败)由 LoopController 写终态 status=done|error", | |
| 114 | + "Server SIGTERM 时所有 status=running 自动改 status=paused 并写 resume_token", | |
| 115 | + "Server 启动时扫所有 paused,发 SSE resume_decision_needed 等人决策" | |
| 116 | + ], | |
| 117 | + "tests": [ | |
| 118 | + "Agent 崩溃 → state.json status=error,error 字段有 code/message", | |
| 119 | + "Server kill -9 → 重启后 state.json status=paused,resume_token 非空", | |
| 120 | + "Dashboard 能从 state.json 渲染「Architect at step 3/5」" | |
| 121 | + ] | |
| 122 | + }, | |
| 123 | + { | |
| 124 | + "name": "AgentMemory", | |
| 125 | + "path": "data/companies/{cid}/memory/devloop/{agent}/{topic}.md", | |
| 126 | + "shape": "// Markdown + frontmatter,人可读 + 机器可读\n---\nagent: architect\ntopic: project-conventions\nproject_refs: [p-731042, p-845001]\nlast_updated: 2026-05-13T10:31:00Z\nrelevance_score: 0.92 # 0-1, agent 自评\nhits: 14 # 被注入 prompt 的次数\n---\n\n- 用户偏好 RESTful API + 完整 OpenAPI 注解\n- Token 续期实测:5s 内人类可接受\n- 认证方式:JWT 放 Authorization header,不用 cookie\n- 数据库统一用 better-sqlite3,不要 mysql/postgres", | |
| 127 | + "contract": [ | |
| 128 | + "每个 Agent 启动时扫 memory/devloop/{agent}/*.md", | |
| 129 | + "按 relevance_score × recency 排序,取前 5 条注入 system prompt", | |
| 130 | + "Agent 自己可通过 record_memory(topic, content, relevance) 工具新增", | |
| 131 | + "Dedup:同 agent + 同 topic 是 upsert(覆盖 + 累加 hits),不新建文件。文件名 = {topic-slug}.md,topic-slug 由 record_memory 工具按规则计算(小写 · 空格转 -)", | |
| 132 | + "memory 跨项目(同 company 内)共享,不跨 company", | |
| 133 | + "frontmatter 必须含 agent / topic / last_updated 三字段", | |
| 134 | + "单文件 ≤ 8KB,超出 record_memory 自动 truncate 旧内容(保留新加 + 最相关)", | |
| 135 | + "v1 注意:如果初版 token 紧张可以关 memory 注入(top-5 改 top-0),完全跳过。memory 在 ≥ 5 个项目后才开始有积累价值。" | |
| 136 | + ], | |
| 137 | + "tests": [ | |
| 138 | + "新 Agent 调用 → memory dir 存在则 system prompt 含 \"## Memory\" section", | |
| 139 | + "不存在该 agent dir → fallback 空 memory,不报错", | |
| 140 | + "record_memory 写入后下次启动能读到" | |
| 141 | + ] | |
| 142 | + }, | |
| 143 | + { | |
| 144 | + "name": "AgentTodolist", | |
| 145 | + "path": "data/companies/{cid}/projects/{pid}/agent-state/{agent}-todo.json", | |
| 146 | + "shape": "const AgentTodolist = Type.Object({\n agent: Type.String(),\n call_id: Type.String(), // 与 AgentRuntimeState.call_id 关联\n created_at: Type.String(),\n items: Type.Array(Type.Object({\n id: Type.String({ pattern: '^step-\\\\d{3}$' }),\n description: Type.String(),\n status: Type.Union([Type.Literal('todo'), Type.Literal('doing'),\n Type.Literal('done'), Type.Literal('failed')]),\n started_at: Type.Optional(Type.String()),\n finished_at: Type.Optional(Type.String()),\n output_ref: Type.Optional(Type.String()), // 产物路径\n depends_on: Type.Optional(Type.Array(Type.String())),\n error: Type.Optional(Type.String()),\n })),\n current: Type.String(), // 当前 item.id\n progress_pct: Type.Number({ minimum:0, maximum:100 }),\n});\n\n// 典型 Architect generate 模式的 todolist 示例:\nitems: [\n { id:'step-001', description:'读 prd.html 并解析所有 section', status:'done' },\n { id:'step-002', description:'识别功能列表(共 5 个特性)', status:'done' },\n { id:'step-003', description:'拆分每个特性为 be/fe Unit', status:'doing' },\n { id:'step-004', description:'为每个 Unit 写 OpenAPI contract', status:'todo' },\n { id:'step-005', description:'生成 integration/spec.json', status:'todo' },\n]", | |
| 147 | + "contract": [ | |
| 148 | + "Agent 在 system prompt 指引下,开始时必须先写完整 todolist(todo 状态)", | |
| 149 | + "每完成一项立即调 update_todo_item(id, status, output_ref) 工具", | |
| 150 | + "progress_pct = done 数 / total × 100", | |
| 151 | + "Dashboard 从这里渲染「Architect: 3/5 拆分 OAuth 功能为 3 个 Unit」", | |
| 152 | + "失败 → status=failed,触发 retry 时 todolist 清空重写" | |
| 153 | + ], | |
| 154 | + "tests": [] | |
| 155 | + }, | |
| 156 | + { | |
| 157 | + "name": "ProcessTimeline", | |
| 158 | + "path": "data/companies/{cid}/projects/{pid}/timeline.ndjson", | |
| 159 | + "shape": "// NDJSON,append-only,一行一事件,每行 ≤ 1KB\nconst TimelineEvent = Type.Object({\n ts: Type.Number(), // unix seconds\n source: Type.Union([\n Type.Literal('system'), // LoopController / DocStore / etc\n Type.Literal('human'), // 人类显式动作\n Type.Literal('pm-agent'),\n Type.Literal('architect'),\n Type.Literal('coder'),\n Type.Literal('tester'),\n Type.Literal('integrator'),\n Type.Literal('bug-triager'),\n ]),\n kind: Type.String(), // \"project_created\" / \"prd_drafted\" / etc\n ref: Type.Optional(Type.String()), // 关联资源,如 \"prd@rev3\"\n summary: Type.String({ maxLength: 280 }), // 人话摘要\n payload: Type.Optional(Type.Object({}, { additionalProperties:true })),\n});\n\n// 示例 (一行 = 一事件):\n{\"ts\":1747120731,\"source\":\"human\",\"kind\":\"project_created\",\"summary\":\"yibo 创建项目'做个 TODO API'\"}\n{\"ts\":1747120745,\"source\":\"pm-agent\",\"kind\":\"prd_drafted\",\"ref\":\"prd@rev1\",\"summary\":\"PRD rev 1 完成\"}\n{\"ts\":1747120798,\"source\":\"architect\",\"kind\":\"sdd_generated\",\"ref\":\"sdd@rev1\",\"summary\":\"拆 3 个 Unit\"}\n{\"ts\":1747120821,\"source\":\"coder\",\"kind\":\"unit_coded\",\"ref\":\"unit/todo-be\",\"summary\":\"todo-be 实现完成\"}\n{\"ts\":1747120833,\"source\":\"tester\",\"kind\":\"unit_failed\",\"ref\":\"unit/todo-be\",\"summary\":\"invalid-pwd 期望 401 收 500\"}", | |
| 160 | + "contract": [ | |
| 161 | + "Append-only,永不修改已有行", | |
| 162 | + "所有 state 转移、文档变更、Agent 调用、Human 动作都必须 append 一条", | |
| 163 | + "是 audit log 也是 Dashboard 时间线视图数据源", | |
| 164 | + "每条 ≤ 1KB;超长内容(如完整 stderr)放 payload.{x}_ref 引用", | |
| 165 | + "由 timelineLogger.append(pid, event) 写入(C-15 工具)" | |
| 166 | + ], | |
| 167 | + "tests": [] | |
| 168 | + }, | |
| 169 | + { | |
| 170 | + "name": "BugsNdjson", | |
| 171 | + "path": "data/companies/{cid}/projects/{pid}/bugs.ndjson", | |
| 172 | + "shape": "// NDJSON,append + 状态机更新\nconst Bug = Type.Object({\n id: Type.String({ pattern: '^bug-\\\\d{8}-\\\\d{4}-[a-z0-9]{4}$' }),\n discovered_at:Type.String({ format: 'date-time' }),\n source: Type.Union([Type.Literal('unit_test'),\n Type.Literal('integration_test'),\n Type.Literal('triage'),\n Type.Literal('human')]),\n unit_id: Type.Optional(Type.String()),\n run_id: Type.Optional(Type.String()), // runs/{runId} 或 integration/runs/{runId}\n test_name: Type.Optional(Type.String()),\n category: Type.Union([Type.Literal('contract_mismatch'),\n Type.Literal('impl_bug'),\n Type.Literal('spec_ambiguous'),\n Type.Literal('infra_flake'),\n Type.Literal('unknown')]),\n severity: Type.Union([Type.Literal('critical'), Type.Literal('high'),\n Type.Literal('medium'), Type.Literal('low')]),\n title: Type.String({ maxLength: 120 }),\n detail: Type.String(), // 完整 stack/expected/got\n affected_units: Type.Array(Type.String()),\n status: Type.Union([Type.Literal('open'),\n Type.Literal('fixing'),\n Type.Literal('fixed'),\n Type.Literal('verified'),\n Type.Literal('wontfix'),\n Type.Literal('duplicate')]),\n status_history: Type.Array(Type.Object({\n ts: Type.String(),\n from: Type.String(), to: Type.String(),\n by: Type.String(), // agent/system/human id\n note: Type.Optional(Type.String()),\n })),\n fix_refs: Type.Array(Type.String()), // 关联的 run_id / triage / commit\n duplicate_of: Type.Optional(Type.String()), // 另一个 bug id\n});\n\n// 示例行:\n{\"id\":\"bug-20260513-0731-7f3a\",\"source\":\"unit_test\",\"unit_id\":\"auth-be\",\"run_id\":\"run-..0731\",\n \"category\":\"impl_bug\",\"severity\":\"high\",\"status\":\"fixed\",\n \"title\":\"login.invalid-pwd 期望 401 收 500\",\"affected_units\":[\"auth-be\"],\"fix_refs\":[\"run-..0734\"]}", | |
| 173 | + "contract": [ | |
| 174 | + "每次 Tester / Integrator / BugTriager 失败 → 自动 append 1 条 bug,status=open", | |
| 175 | + "同一 unit 同一 test 连续两轮失败 → 视同同一 bug(合并),不重复 append", | |
| 176 | + "status 转换走 P-9 状态机,每次转换 append 一条 status_history", | |
| 177 | + "append-only:bug 内容修改通过新写一行 status=... 的更新事件实现(与 timeline 同理)", | |
| 178 | + "读取时取每个 bug.id 的最新一行作为当前状态" | |
| 179 | + ], | |
| 180 | + "tests": [ | |
| 181 | + "单测红 → bugs.ndjson 新增 status=open 行", | |
| 182 | + "下一轮 Coder 修复 → 新增 status=fixed 行 with fix_refs=[新 run_id]", | |
| 183 | + "L2 通过 → 该 bug 状态最终 verified" | |
| 184 | + ] | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "name": "ChangelogNdjson", | |
| 188 | + "path": "data/companies/{cid}/projects/{pid}/changelog.ndjson", | |
| 189 | + "shape": "const ChangelogEntry = Type.Object({\n ts: Type.String({ format: 'date-time' }),\n kind: Type.Union([\n Type.Literal('prd_rev'),\n Type.Literal('sdd_rev'),\n Type.Literal('units_rev'),\n Type.Literal('integration_spec_rev'),\n ]),\n rev: Type.Number(),\n by: Type.String(), // agent or human id\n trigger: Type.String(), // \"human_feedback\" | \"triage_patch\" | \"init\"\n summary: Type.String({ maxLength: 280 }),\n diff: Type.Object({ // 影响面快照\n sections_added: Type.Array(Type.String()),\n sections_changed: Type.Array(Type.String()),\n sections_removed: Type.Array(Type.String()),\n units_added: Type.Array(Type.String()),\n units_changed: Type.Array(Type.String()),\n units_removed: Type.Array(Type.String()),\n }),\n archive_ref: Type.String(), // history/prd-rev-{N}.html 路径\n});\n\n// 示例:\n{\"kind\":\"prd_rev\",\"rev\":3,\"by\":\"pm-agent\",\"trigger\":\"human_feedback\",\n \"summary\":\"+ OAuth 登录,影响 auth-mw 新增\",\"diff\":{\"sections_added\":[\"#oauth\"],...}}\n{\"kind\":\"sdd_rev\",\"rev\":5,\"by\":\"architect\",\"trigger\":\"prd@rev3\",\n \"summary\":\"3 个新 Unit: oauth-be / oauth-fe / auth-mw\",...}\n{\"kind\":\"sdd_rev\",\"rev\":6,\"by\":\"architect\",\"trigger\":\"triage_patch\",\n \"summary\":\"L2 修复:auth-be 401 明确\",...}", | |
| 190 | + "contract": [ | |
| 191 | + "每次 DocStore.writePRD/writeSDD/writeUnits 成功后必 append 一行", | |
| 192 | + "diff 字段在 PM/Architect Agent 输出后由 DocStore 自动计算(cheerio + 拓扑 diff)", | |
| 193 | + "archive_ref 指向归档文件,可被 Dashboard 直接打开", | |
| 194 | + "这是 history/ 目录的线性视图:history/ 是物,changelog 是事" | |
| 195 | + ], | |
| 196 | + "tests": [] | |
| 197 | + } | |
| 198 | + ], | |
| 199 | + "view": [ | |
| 200 | + { | |
| 201 | + "name": "DevLoopDashboard", | |
| 202 | + "path": "web/src/views/DevLoopDashboard.vue", | |
| 203 | + "shape": "// Composition API · setup script\nconst props = defineProps<{}>(); // 无 props,从 route param 取 pid\n\nconst route = useRoute();\nconst pid = computed(() => route.params.pid as string);\nconst state = ref<ProjectState | null>(null);\nconst sse = useEventSource(`/api/devloop/projects/${pid.value}/stream`);\nconst modalOpen = ref(false);\nconst drawer = ref<Unit | null>(null);\n\n// 组件树:\n// DashboardHeader → LoopStatusBar → UnitsDAG → LiveAgentStream + PrdDiffPanel\n// 浮层:FeedbackModal · UnitDrawer", | |
| 204 | + "contract": [ | |
| 205 | + "挂载 5 秒内必须发起 GET /state 请求", | |
| 206 | + "SSE 断线自动重连(指数退避,最长 30s)", | |
| 207 | + "页面卸载时关 SSE" | |
| 208 | + ], | |
| 209 | + "tests": [] | |
| 210 | + }, | |
| 211 | + { | |
| 212 | + "name": "DashboardHeader", | |
| 213 | + "path": "web/src/components/devloop/DashboardHeader.vue", | |
| 214 | + "shape": "defineProps<{\n project: { id: string, name: string, status: string }\n}>();\ndefineEmits<{\n feedback: [] // 点击 + NEW REQUIREMENT\n}>();\n\n// 渲染:\n// - 黑底标题栏\n// - 项目名(Fraunces 22px)\n// - 右上紫色按钮", | |
| 215 | + "contract": [], | |
| 216 | + "tests": [] | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "name": "LoopStatusBar", | |
| 220 | + "path": "web/src/components/devloop/LoopStatusBar.vue", | |
| 221 | + "shape": "defineProps<{\n l1: { passed: number, testing: number, pending: number, failed: number },\n l2: { lastRunAt?: string, status: 'idle'|'running'|'failed'|'shipped', rounds: number },\n l3: { rev: number, awaitingFeedback: boolean }\n}>();", | |
| 222 | + "contract": [ | |
| 223 | + "三张卡片对应 emerald / amber / violet 三色,CSS 用 --L1/L2/L3 token", | |
| 224 | + "L2 running 时 amber 卡片有 pulse 动画", | |
| 225 | + "L3 awaitingFeedback=true 时显示 toast" | |
| 226 | + ], | |
| 227 | + "tests": [] | |
| 228 | + }, | |
| 229 | + { | |
| 230 | + "name": "UnitsDAG", | |
| 231 | + "path": "web/src/components/devloop/UnitsDAG.vue", | |
| 232 | + "shape": "defineProps<{\n units: Unit[]\n}>();\ndefineEmits<{\n select: [unit: Unit]\n}>();\n\n// 内部使用 SVG <rect> + <line>\n// > 50 unit 时切 d3-force 自动布局", | |
| 233 | + "contract": [ | |
| 234 | + "节点配色由 unit.status 决定(见 ui-design-system 色板)", | |
| 235 | + "节点点击 emit select 事件,父组件打开 UnitDrawer", | |
| 236 | + "箭头方向 = depends_on (上游 → 下游)" | |
| 237 | + ], | |
| 238 | + "tests": [] | |
| 239 | + }, | |
| 240 | + { | |
| 241 | + "name": "LiveAgentStream", | |
| 242 | + "path": "web/src/components/devloop/LiveAgentStream.vue", | |
| 243 | + "shape": "defineProps<{\n events: AgentStreamEvent[] // 由父组件从 SSE 过滤 agent.stream 事件\n}>();\n\n// 渲染:\n// - 黑底 #0E0E10 终端样式\n// - 最近 500 行,老的丢\n// - PASS / FAIL 着色(绿/橙)\n// - 自动滚到底部,除非用户手动滚", | |
| 244 | + "contract": [], | |
| 245 | + "tests": [] | |
| 246 | + }, | |
| 247 | + { | |
| 248 | + "name": "PrdDiffPanel", | |
| 249 | + "path": "web/src/components/devloop/PrdDiffPanel.vue", | |
| 250 | + "shape": "defineProps<{\n prdHtml: string,\n prevRev: number | null\n}>();\n\n// 内部用 diff-dom 客户端算 section 级 diff\n// 新增 section 绿色 + 标记 / 删除红色删除线 / 修改黄色", | |
| 251 | + "contract": [], | |
| 252 | + "tests": [] | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "name": "FeedbackModal", | |
| 256 | + "path": "web/src/components/devloop/FeedbackModal.vue", | |
| 257 | + "shape": "defineProps<{ open: boolean }>();\ndefineEmits<{\n submit: [text: string],\n close: [],\n}>();\n\n// 单 textarea + 字数计数 + Ctrl+Enter 提交\n// 提交后立即 emit close,父组件负责发请求", | |
| 258 | + "contract": [], | |
| 259 | + "tests": [ | |
| 260 | + "空文本 → 提交按钮 disabled", | |
| 261 | + "> 4000 字 → 警告但不阻止", | |
| 262 | + "Esc 触发 close" | |
| 263 | + ] | |
| 264 | + }, | |
| 265 | + { | |
| 266 | + "name": "UnitDrawer", | |
| 267 | + "path": "web/src/components/devloop/UnitDrawer.vue", | |
| 268 | + "shape": "defineProps<{\n unit: Unit | null,\n runs: RunResult[] // 最近 3 次 run\n}>();\ndefineEmits<{ close: [] }>();\n\n// 右侧抽屉,从 right 滑入\n// 显示:spec.json 摘要 · contract.json · 最近 3 次 run 摘要 · 跳转完整日志按钮", | |
| 269 | + "contract": [], | |
| 270 | + "tests": [] | |
| 271 | + }, | |
| 272 | + { | |
| 273 | + "name": "Router", | |
| 274 | + "path": "web/src/router/index.js", | |
| 275 | + "shape": "// 在现有 routes 数组追加\n{\n path: '/devloop/:pid',\n name: 'DevLoop',\n component: () => import('@/views/DevLoopDashboard.vue'),\n meta: { requiresAuth: true },\n},\n{\n path: '/devloop', // 项目列表\n name: 'DevLoopList',\n component: () => import('@/views/DevLoopList.vue'),\n}", | |
| 276 | + "contract": [], | |
| 277 | + "tests": [] | |
| 278 | + }, | |
| 279 | + { | |
| 280 | + "name": "I18nKeys", | |
| 281 | + "path": "web/src/i18n/{zh-CN,en-US,...}/devloop.json", | |
| 282 | + "shape": "// 每个 locale 一份扁平 JSON:Record<string, string>\n{\n \"devloop.loop.l1\": \"...\",\n \"devloop.state.pending\": \"...\",\n \"devloop.btn.newRequirement\":\"...\"\n // ...完整 15 个 key 见下表\n}", | |
| 283 | + "contract": [], | |
| 284 | + "tests": [] | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "name": "DesignTokens", | |
| 288 | + "path": "web/src/styles/devloop.css", | |
| 289 | + "shape": ":root {\n --L1: #0E8C5A; --L1-tint: #E2F2EA;\n --L2: #C77C2C; --L2-tint: #F7E8D2;\n --L3: #6E48C3; --L3-tint: #ECE4FB;\n --alert: #C7341A;\n}\n// 与 LemonBoss 全局 token 共存,前缀避免冲突", | |
| 290 | + "contract": [], | |
| 291 | + "tests": [] | |
| 292 | + } | |
| 293 | + ], | |
| 294 | + "control": [ | |
| 295 | + { | |
| 296 | + "name": "REST.createProject", | |
| 297 | + "path": "server/routes/devloop.js", | |
| 298 | + "shape": "router.post('/projects', requireAuth, async (req, res) => {\n const { company_id, name, requirement } = req.body;\n const pid = await loopController.createProject({ company_id, name, owner: req.user.id });\n loopController.onHumanFeedback(pid, { text: requirement, origin: req.user.id });\n res.status(201).json({\n project_id: pid,\n status: 'prd_drafting',\n stream_url: `/api/devloop/projects/${pid}/stream`,\n });\n});", | |
| 299 | + "contract": [ | |
| 300 | + "请求体 {company_id, name, requirement} 三字段必填", | |
| 301 | + "company_id 必须与 JWT 中的匹配(防越权)", | |
| 302 | + "立即返回 201 + project_id;PM Agent 异步起" | |
| 303 | + ], | |
| 304 | + "tests": [ | |
| 305 | + "合法请求 → 201 + 含 project_id", | |
| 306 | + "company_id 不匹配 JWT → 403", | |
| 307 | + "缺字段 → 400 + 字段名" | |
| 308 | + ] | |
| 309 | + }, | |
| 310 | + { | |
| 311 | + "name": "REST.postRequirement", | |
| 312 | + "path": "server/routes/devloop.js", | |
| 313 | + "shape": "router.post('/projects/:pid/requirement', requireAuth, async (req, res) => {\n const { text, origin } = req.body;\n loopController.onHumanFeedback(req.params.pid, { text, origin: origin || req.user.id });\n res.status(202).json({ accepted: true, queued: true });\n});", | |
| 314 | + "contract": [ | |
| 315 | + "立即 202,不等 PM 跑完", | |
| 316 | + "多个反馈同时到 → 队列化,按时序处理", | |
| 317 | + "项目 status=shipped-final 或 abandoned → 拒绝(409)" | |
| 318 | + ], | |
| 319 | + "tests": [] | |
| 320 | + }, | |
| 321 | + { | |
| 322 | + "name": "REST.run", | |
| 323 | + "path": "server/routes/devloop.js", | |
| 324 | + "shape": "router.post('/projects/:pid/run', requireAuth, async (req, res) => {\n // 续跑(如果之前 blocked / paused,重启循环)\n loopController.runProject(req.params.pid);\n res.json({ running: true });\n});", | |
| 325 | + "contract": [ | |
| 326 | + "等价于「resume」,对 status=running 是 no-op", | |
| 327 | + "blocked / l2_exhausted 状态调用此端点 → 重置预算并继续" | |
| 328 | + ], | |
| 329 | + "tests": [] | |
| 330 | + }, | |
| 331 | + { | |
| 332 | + "name": "REST.state", | |
| 333 | + "path": "server/routes/devloop.js", | |
| 334 | + "shape": "router.get('/projects/:pid/state', requireAuth, (req, res) => {\n res.json(loopController.getState(req.params.pid));\n});\n\n// 返回结构:\n{\n project_id, prd_rev, sdd_rev,\n current_loop: 'L1' | 'L2' | 'L3' | 'idle',\n units: Unit[],\n l2_rounds, l2_budget,\n agents_active: string[],\n token_used, token_budget,\n}", | |
| 335 | + "contract": [ | |
| 336 | + "读时不加锁(不一致几毫秒可接受)", | |
| 337 | + "response 体限 100KB 以内(大项目 unit 多)" | |
| 338 | + ], | |
| 339 | + "tests": [] | |
| 340 | + }, | |
| 341 | + { | |
| 342 | + "name": "REST.stream", | |
| 343 | + "path": "server/routes/devloop.js", | |
| 344 | + "shape": "router.get('/projects/:pid/stream', requireAuth, (req, res) => {\n res.writeHead(200, {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'Connection': 'keep-alive',\n });\n sseBroker.subscribe(req.params.pid, res, {\n replayFromOffset: req.query.last_event_id,\n });\n});", | |
| 345 | + "contract": [ | |
| 346 | + "支持 Last-Event-ID 断线重连", | |
| 347 | + "心跳:每 25s 发一条 :keepalive 注释", | |
| 348 | + "项目结束(shipped/abandoned)→ 发 close 帧后关闭" | |
| 349 | + ], | |
| 350 | + "tests": [] | |
| 351 | + }, | |
| 352 | + { | |
| 353 | + "name": "REST.signOff", | |
| 354 | + "path": "server/routes/devloop.js", | |
| 355 | + "shape": "router.post('/projects/:pid/sign-off', requireAuth, (req, res) => {\n loopController.signOff(req.params.pid, req.user.id);\n res.json({ status: 'shipped-final' });\n});", | |
| 356 | + "contract": [], | |
| 357 | + "tests": [] | |
| 358 | + }, | |
| 359 | + { | |
| 360 | + "name": "REST.abandon", | |
| 361 | + "path": "server/routes/devloop.js", | |
| 362 | + "shape": "router.post('/projects/:pid/abandon', requireAuth, (req, res) => {\n loopController.abandon(req.params.pid, req.user.id);\n res.json({ status: 'abandoned' });\n});", | |
| 363 | + "contract": [], | |
| 364 | + "tests": [] | |
| 365 | + }, | |
| 366 | + { | |
| 367 | + "name": "LoopController", | |
| 368 | + "path": "server/src/core/loop-controller.js", | |
| 369 | + "shape": "module.exports.loopController = {\n createProject({ company_id, name, owner }): Promise<string>,\n\n runProject(pid: string): Promise<'shipped'|'blocked'|'l2_exhausted'>,\n runUnit(pid: string, unitId: string): Promise<UnitResult>,\n\n onHumanFeedback(pid: string, { text, origin }): void,\n onL2Failure(pid: string, runId: string): Promise<void>,\n\n signOff(pid: string, byUser: string): void,\n abandon(pid: string, byUser: string): void,\n\n getState(pid: string): ProjectState,\n};", | |
| 370 | + "contract": [ | |
| 371 | + "所有 Agent 调用通过 agentManager.callAgent(name, input),不直接起进程", | |
| 372 | + "runProject 是幂等的:同一 pid 多次调用合并为一个执行", | |
| 373 | + "onHumanFeedback 同 pid 多次调用按时序队列处理" | |
| 374 | + ], | |
| 375 | + "tests": [] | |
| 376 | + }, | |
| 377 | + { | |
| 378 | + "name": "DocStore", | |
| 379 | + "path": "server/services/doc-store.js", | |
| 380 | + "shape": "module.exports.docStore = {\n // 读\n readPRD(pid): Promise<string>,\n readSDD(pid): Promise<string>,\n readUnits(pid): Promise<UnitsJson>,\n readIntegrationSpec(pid): Promise<IntegrationSpec>,\n\n // 写(自动归档)\n writePRD(pid, html): Promise<{ rev: number }>,\n writeSDD(pid, html): Promise<{ rev: number }>,\n writeUnits(pid, json): Promise<{ rev: number }>,\n writeIntegrationSpec(pid, json): Promise<void>,\n\n // 校验\n validateHTML(html, kind): { ok: boolean, errors: string[] },\n validateUnitsSchema(json): { ok: boolean, errors: string[] },\n\n // diff / 归档\n diffPRD(pid, fromRev, toRev): SectionDiff[],\n archive(pid, kind, oldContent): Promise<void>,\n\n // 工具\n hashContract(contractText): string, // → 'sha256:...'\n};", | |
| 381 | + "contract": [ | |
| 382 | + "writeXxx 调用前先 validate;不通过抛 ValidationError", | |
| 383 | + "写入前自动归档旧版到 history/", | |
| 384 | + "所有方法可并发安全(用文件锁或队列)" | |
| 385 | + ], | |
| 386 | + "tests": [] | |
| 387 | + }, | |
| 388 | + { | |
| 389 | + "name": "UnitRegistry", | |
| 390 | + "path": "server/services/unit-registry.js", | |
| 391 | + "shape": "module.exports.unitRegistry = {\n list(pid): Unit[],\n pending(pid): Unit[],\n passed(pid): Unit[],\n blocked(pid): Unit[],\n anyBlocked(pid): boolean,\n\n l2GateOpen(pid): boolean,\n\n markCoding(pid, unitId): void,\n markTesting(pid, unitId): void,\n pass(pid, unitId, contractHash): void,\n fail(pid, unitId, reason): void,\n block(pid, unitId, reason): void,\n reset(pid, unitIds: string[]): void,\n\n diffWithSDD(pid): { added: string[], changed: string[], removed: string[] },\n};", | |
| 392 | + "contract": [ | |
| 393 | + "所有 markXxx 内部走 FSM 校验(非法转换抛错)", | |
| 394 | + "每次状态变化同步发 SSE unit.state", | |
| 395 | + "状态来源单一:units.json + 内存缓存" | |
| 396 | + ], | |
| 397 | + "tests": [] | |
| 398 | + }, | |
| 399 | + { | |
| 400 | + "name": "SSEBroker", | |
| 401 | + "path": "server/services/sse-broker.js", | |
| 402 | + "shape": "module.exports.sseBroker = {\n subscribe(pid: string, res: ServerResponse, opts?: {\n replayFromOffset?: string\n }): void,\n\n emit(pid: string, event: string, data: any): void,\n\n close(pid: string): void, // 项目结束\n};", | |
| 403 | + "contract": [ | |
| 404 | + "同一 pid 多订阅者广播", | |
| 405 | + "事件落盘到 stream.log,支持断线 replay", | |
| 406 | + "每 25s 心跳" | |
| 407 | + ], | |
| 408 | + "tests": [] | |
| 409 | + }, | |
| 410 | + { | |
| 411 | + "name": "Middleware", | |
| 412 | + "path": "server/middleware/", | |
| 413 | + "shape": "// 中间件标准签名 (Express 4)\ntype Middleware = (req: Request, res: Response, next: NextFunction) => void;\n// 4 个中间件:requireAuth · jsonSanitizer · jsonGuard(NEW) · tokenAccountant(NEW)", | |
| 414 | + "contract": [], | |
| 415 | + "tests": [] | |
| 416 | + }, | |
| 417 | + { | |
| 418 | + "name": "AgentCallConvention", | |
| 419 | + "path": "server/src/core/agent-manager.js · 复用既有,不新增文件", | |
| 420 | + "shape": "agentManager.callAgent(name: string, input: {\n pid: string, // 项目 id\n unit_id?: string, // Coder/Tester 时必填\n mode?: 'generate' | 'patch', // Architect 专属\n history?: any[], // retry 时上次失败\n ...其余 Agent 特定参数\n}): Promise<AgentResult>\n\n// AgentResult 必须含:\n{\n ok: boolean,\n outputs: string[], // 写入的文件路径\n duration_ms: number,\n tokens_used: number,\n error?: string,\n}", | |
| 421 | + "contract": [ | |
| 422 | + "SessionPool 按 (name, cid, pid) 复用会话", | |
| 423 | + "每次调用先校验 name 在 _registry.json 中存在", | |
| 424 | + "失败时 stderr 自动落 stream.log" | |
| 425 | + ], | |
| 426 | + "tests": [] | |
| 427 | + }, | |
| 428 | + { | |
| 429 | + "name": "LoopController.getProgress", | |
| 430 | + "path": "server/src/core/loop-controller.js · 新方法", | |
| 431 | + "shape": "// 聚合进度 API,Dashboard 主用\ngetProgress(pid: string): {\n percent: number, // 0-100 整体加权进度\n current_phase: 'prd_drafting' | 'sdd_drafting' | 'L1' | 'L2' | 'shipped' | 'blocked',\n eta_seconds: number, // 估算剩余秒,基于历史数据\n breakdown: {\n prd: { ok: boolean, rev: number },\n sdd: { ok: boolean, rev: number },\n units: { passed: number, total: number, failed: number, blocked: number },\n l2: { rounds: number, budget: number },\n tokens: { used: number, budget: number },\n },\n active_agents: [{ // 当前正在跑的 Agent\n agent: string,\n unit_id?: string,\n step: string, // \"3/5\"\n desc: string, // 当前 todo item description\n started_at: string,\n }],\n recent_events: TimelineEvent[], // 最近 10 条\n}", | |
| 432 | + "contract": [ | |
| 433 | + "聚合自 UnitsJson · agent-state/*.json · timeline.ndjson 三处", | |
| 434 | + "读时不加锁(毫秒级偏差可接受)", | |
| 435 | + "percent 加权算法:PRD(10) + SDD(20) + Units(50) + L2(15) + Ship(5) = 100", | |
| 436 | + "eta 基于历史项目均值 + 当前阶段剩余项数线性外推" | |
| 437 | + ], | |
| 438 | + "tests": [ | |
| 439 | + "空项目 → percent=0, current_phase='prd_drafting'", | |
| 440 | + "所有 unit passed 但 L2 未跑 → percent ≈ 80", | |
| 441 | + "shipped → percent=100, eta_seconds=0" | |
| 442 | + ] | |
| 443 | + }, | |
| 444 | + { | |
| 445 | + "name": "TimelineLogger", | |
| 446 | + "path": "server/services/timeline-logger.js", | |
| 447 | + "shape": "module.exports.timelineLogger = {\n append(pid: string, event: {\n source: string, kind: string,\n ref?: string, summary: string, payload?: object\n }): void,\n\n read(pid: string, opts?: {\n limit?: number, // 默认 100\n since?: number, // unix ts\n source?: string,\n kind?: string,\n }): TimelineEvent[],\n\n tail(pid: string, n: number = 10): TimelineEvent[],\n};", | |
| 448 | + "contract": [ | |
| 449 | + "append 是同步操作(fs.appendFileSync),确保事件原子写入", | |
| 450 | + "ts 自动填当前时间,调用方不传", | |
| 451 | + "summary > 280 字符自动截断 + 省略号", | |
| 452 | + "payload 太大(> 4KB)→ 强制改为引用形式 { x_ref: \"...\" }", | |
| 453 | + "read 用流式 readline 解析,不一次性 load 全文件" | |
| 454 | + ], | |
| 455 | + "tests": [] | |
| 456 | + }, | |
| 457 | + { | |
| 458 | + "name": "BugTracker", | |
| 459 | + "path": "server/services/bug-tracker.js", | |
| 460 | + "shape": "module.exports.bugTracker = {\n // 写\n open(pid: string, bug: Omit<Bug, 'id'|'discovered_at'|'status'|'status_history'>): Bug,\n transition(pid: string, bugId: string, {\n to: BugStatus,\n by: string,\n note?: string,\n fix_refs?: string[],\n }): void,\n\n // 读 (流式 + 终态聚合)\n list(pid: string, filter?: {\n status?: BugStatus[],\n severity?: string[],\n unit_id?: string,\n since?: string,\n }): Bug[], // 返回每个 bug.id 的最新态\n\n history(pid: string, bugId: string): BugStatusHistory[],\n\n stats(pid: string): {\n open: number, fixing: number, fixed: number,\n verified: number, wontfix: number,\n by_severity: Record<string, number>,\n },\n\n // 工具\n dedupKey(bug: Partial<Bug>): string, // \"auth-be:login.invalid-pwd\" 用于检测重复\n};", | |
| 461 | + "contract": [ | |
| 462 | + "open() 内部先算 dedupKey,命中已存在 open/fixing 的 bug → 不重复,返回旧 id", | |
| 463 | + "transition() 必经 P-9 状态机校验,非法转换抛错", | |
| 464 | + "list() 内部流式读 NDJSON(不一次性加载)", | |
| 465 | + "同时写入一条 timeline 事件 { kind: 'bug_opened'|'bug_transitioned', ref:bugId }", | |
| 466 | + "所有写操作同步发 SSE bug.* 事件" | |
| 467 | + ], | |
| 468 | + "tests": [ | |
| 469 | + "同一 test 两次失败 → dedupKey 命中,只有一条 bug", | |
| 470 | + "transition open → verified(跳级)→ 抛错", | |
| 471 | + "stats() 与 list() 长度一致" | |
| 472 | + ] | |
| 473 | + }, | |
| 474 | + { | |
| 475 | + "name": "ChangelogWriter", | |
| 476 | + "path": "server/services/changelog-writer.js", | |
| 477 | + "shape": "module.exports.changelogWriter = {\n append(pid: string, {\n kind, rev, by, trigger, summary, diff, archive_ref\n }): void,\n\n read(pid: string, opts?: { limit?, kind?, since? }): ChangelogEntry[],\n\n latest(pid: string, kind: string): ChangelogEntry | null,\n};\n\n// 被 DocStore 内部调用\n// DocStore.writePRD/writeSDD 成功后 → changelogWriter.append", | |
| 478 | + "contract": [ | |
| 479 | + "由 DocStore 内部触发,外部不直接调", | |
| 480 | + "diff 字段计算由 DocStore 提供(cheerio 算 section 差异 + 拓扑算 units 差异)" | |
| 481 | + ], | |
| 482 | + "tests": [] | |
| 483 | + } | |
| 484 | + ], | |
| 485 | + "process": [ | |
| 486 | + { | |
| 487 | + "name": "Process.L1", | |
| 488 | + "path": "loop-controller.js → runUnit()", | |
| 489 | + "shape": "async function runUnit(pid, unitId) {\n const unit = unitRegistry.list(pid).find(u => u.id === unitId);\n let failures = [];\n\n for (let i = unit.retries; i < unit.retry_budget; i++) {\n unitRegistry.markCoding(pid, unitId);\n const coderRes = await agentManager.callAgent('coder', {\n pid, unit_id: unitId, history: failures\n });\n if (!coderRes.ok) { unitRegistry.fail(pid, unitId, 'coder_error'); continue; }\n\n unitRegistry.markTesting(pid, unitId);\n const testerRes = await agentManager.callAgent('tester', { pid, unit_id: unitId });\n if (testerRes.ok) {\n const hash = docStore.hashContract(await readContract(pid, unitId));\n unitRegistry.pass(pid, unitId, hash);\n return { ok: true, retries: i };\n }\n failures.push(testerRes.failures);\n unitRegistry.fail(pid, unitId, 'test_red');\n }\n\n unitRegistry.block(pid, unitId, 'budget_exceeded');\n return { ok: false };\n}", | |
| 490 | + "contract": [ | |
| 491 | + "同一 Unit 任何时刻只允许一个 runUnit 实例(用 Map<unitId, Promise> 去重)", | |
| 492 | + "retry 之间不复用 Agent session(每次新 ACP session,保证干净)" | |
| 493 | + ], | |
| 494 | + "tests": [] | |
| 495 | + }, | |
| 496 | + { | |
| 497 | + "name": "Process.L2", | |
| 498 | + "path": "loop-controller.js → onL2Failure() · runProject() L2 段", | |
| 499 | + "shape": "function l2GateOpen(pid) {\n const units = unitRegistry.list(pid);\n const sdd = docStore.readSDD(pid);\n return units.every(u => {\n if (u.status !== 'passed') return false;\n const expectedHash = contractHashFromSDD(sdd, u.id);\n return u.contract_hash === expectedHash;\n });\n}", | |
| 500 | + "contract": [], | |
| 501 | + "tests": [] | |
| 502 | + }, | |
| 503 | + { | |
| 504 | + "name": "Process.L3", | |
| 505 | + "path": "loop-controller.js → onHumanFeedback()", | |
| 506 | + "shape": "// 每个项目维护一个反馈队列,串行消费\nconst feedbackQueues = new Map<string, Feedback[]>();\n\nfunction onHumanFeedback(pid, feedback) {\n if (!feedbackQueues.has(pid)) feedbackQueues.set(pid, []);\n feedbackQueues.get(pid).push(feedback);\n\n // 单 worker per pid\n if (!isWorking(pid)) drainFeedback(pid);\n}\n\nasync function drainFeedback(pid) {\n while (feedbackQueues.get(pid).length) {\n const fb = feedbackQueues.get(pid).shift();\n\n // 1. 等当前 L1 / L2 跑完(不打断)\n await waitForCurrentLoop(pid);\n\n // 2. PM 增量补 PRD\n await agentManager.callAgent('pm', { pid, feedback: fb });\n\n // 3. Architect 重算 SDD + units.json (mode=generate)\n await agentManager.callAgent('architect', { pid, mode: 'generate' });\n\n // 4. UnitRegistry.diffWithSDD → reset changed/removed\n const diff = unitRegistry.diffWithSDD(pid);\n unitRegistry.reset(pid, [...diff.changed, ...diff.removed]);\n\n // 5. 触发 runProject(增量 L1 + L2)\n runProject(pid);\n }\n}", | |
| 507 | + "contract": [ | |
| 508 | + "同一 pid 永远只有一个 worker 在 drain(保证文档一致性)", | |
| 509 | + "多 pid 之间互不阻塞", | |
| 510 | + "worker 崩了,重启 server 后会自动 resume(从 stream.log 反推队列状态)" | |
| 511 | + ], | |
| 512 | + "tests": [] | |
| 513 | + }, | |
| 514 | + { | |
| 515 | + "name": "Process.AgentLifecycle", | |
| 516 | + "path": "server/src/core/session-pool.js · packages/acp-core/protocol/ClaudeCliProtocol.js · 复用既有", | |
| 517 | + "shape": "// 复用 LemonBoss SessionPool\n// key = (agentName, cid, pid)\n\n// 1. 首次 callAgent\n// ↓ SessionPool 检查无该 key\n// ↓ 通过 ProcessLauncher 起一个 claude CLI 子进程\n// ↓ 走 ClaudeCliProtocol.initialize() + newSession()\n// ↓ 注入 system prompt (system/templates/devloop-*.md)\n// ↓ sendPrompt(input)\n// ↓ 流式累积 → AgentResult\n\n// 2. 第二次同 key 的 callAgent\n// ↓ SessionPool 命中,复用 session\n// ↓ sendPrompt(input) ← 上下文延续\n\n// 3. 项目结束(shipped/abandoned)\n// ↓ SessionPool.closeProject(pid)\n// ↓ 杀掉所有该 pid 的 claude 进程\n\n// 4. 项目空闲 30 分钟\n// ↓ 自动 sweep 该 pid 的 session(省内存)\n// ↓ 下次 callAgent 重启冷启 OK", | |
| 518 | + "contract": [ | |
| 519 | + "每个 Agent 进程绑死自己的 cwd(沙箱)", | |
| 520 | + "Bash 工具受 jsonGuard 中间件保护(防 inline JSON)", | |
| 521 | + "process-guard 看护:5s SIGTERM → 10s SIGKILL" | |
| 522 | + ], | |
| 523 | + "tests": [] | |
| 524 | + }, | |
| 525 | + { | |
| 526 | + "name": "Process.SSE", | |
| 527 | + "path": "server/services/sse-broker.js", | |
| 528 | + "shape": "// 每订阅一个 pid,open 一份连接列表\n// subscriptions: Map<pid, Set<ServerResponse>>\n\nfunction subscribe(pid, res, opts) {\n subscriptions.get(pid)?.add(res) || subscriptions.set(pid, new Set([res]));\n\n // Replay:从 stream.log 重放给当前订阅者\n if (opts?.replayFromOffset) replayTo(res, pid, opts.replayFromOffset);\n\n // 心跳\n const heartbeat = setInterval(() => res.write(':keepalive\\n\\n'), 25_000);\n res.on('close', () => { clearInterval(heartbeat); subscriptions.get(pid).delete(res); });\n}\n\nfunction emit(pid, event, data) {\n const id = ++eventCounter;\n const line = `id: ${id}\\nevent: ${event}\\ndata: ${JSON.stringify(data)}\\n\\n`;\n\n // 1. 落盘 stream.log\n fs.appendFileSync(streamLogPath(pid), line);\n // 2. 广播给所有订阅者\n for (const res of subscriptions.get(pid) || []) res.write(line);\n}", | |
| 529 | + "contract": [ | |
| 530 | + "stream.log 保留 7 天,期间任何时刻 replay 都能精确", | |
| 531 | + "同事件不重复广播(去重以 event id 为准)" | |
| 532 | + ], | |
| 533 | + "tests": [] | |
| 534 | + }, | |
| 535 | + { | |
| 536 | + "name": "Process.Cleanup", | |
| 537 | + "path": "分布在 doc-store.js · unit-registry.js · sse-broker.js · scripts/devloop-cleanup-cron.js (NEW)", | |
| 538 | + "shape": "// 清理钩子标准签名(被各服务调用 + 一个日 cron)\ntype CleanupHook = (pid: string) => Promise<void>;\n// cron: scripts/devloop-cleanup-cron.js · 每日 04:00 跑", | |
| 539 | + "contract": [], | |
| 540 | + "tests": [] | |
| 541 | + }, | |
| 542 | + { | |
| 543 | + "name": "Process.Budget", | |
| 544 | + "path": "server/middleware/token-accountant.js (NEW) · 阈值嵌入 loop-controller.js", | |
| 545 | + "shape": "// 预算配置 schema(落在 project.json)\ntype ProjectBudgets = {\n retry_budget: number; // 默认 5 · 每 Unit\n l2_budget: number; // 默认 3 · 每 PRD rev\n token_budget: number; // 默认 5_000_000 · 项目\n agent_wallclock_ms:number; // 默认 600_000 · 单 Agent 调用\n integrator_wallclock_ms: number; // 默认 300_000 · 单 L2 run\n};", | |
| 546 | + "contract": [], | |
| 547 | + "tests": [] | |
| 548 | + }, | |
| 549 | + { | |
| 550 | + "name": "Process.StatePersistence", | |
| 551 | + "path": "loop-controller.js · agent-state/*.json · timeline.ndjson", | |
| 552 | + "shape": "// LoopController.callAgent 的完整包裹流程\nasync function callAgentWithPersistence(name, input) {\n const call_id = genCallId();\n const statePath = `${pidDir}/agent-state/${name}.json`;\n\n // ── 调用前 ──\n const inputSnap = await snapshot(input); // → agent-state/snapshots/{call_id}-in.json\n fs.writeFileSync(statePath, JSON.stringify({\n agent: name, call_id, status: 'running',\n current_step: 0, total_steps: 0, // Agent 自己稍后会写\n last_input_ref: inputSnap,\n started_at: now(), updated_at: now(),\n }));\n timelineLogger.append(pid, {\n source: 'system', kind: 'agent_started',\n ref: `agent/${name}/${call_id}`,\n summary: `${name} started (unit=${input.unit_id || '-'})`\n });\n\n try {\n // ── 调用中 ── (Agent 自己通过工具更新)\n // - record_memory(topic, content, relevance)\n // - write_todolist(items) → agent-state/{agent}-todo.json\n // - update_todo_item(id, status, ref) → 更新单条 item + 同步 progress_pct\n // - update_runtime_state(current_step) → 修 agent-state/{agent}.json\n // - timeline_append(kind, ref, summary)→ 写 timeline.ndjson\n const result = await agentManager.callAgent(name, input);\n\n // ── 调用后 (成功) ──\n const outSnap = await snapshot(result);\n fs.writeFileSync(statePath, JSON.stringify({\n ...readState(statePath),\n status: 'done',\n last_output_ref: outSnap,\n updated_at: now(),\n }));\n timelineLogger.append(pid, {\n source: name, kind: 'agent_done',\n ref: `agent/${name}/${call_id}`,\n summary: `${name} done in ${result.duration_ms}ms`,\n payload: { tokens: result.tokens_used }\n });\n return result;\n } catch (e) {\n // ── 调用后 (失败) ──\n fs.writeFileSync(statePath, JSON.stringify({\n ...readState(statePath),\n status: 'error',\n error: { code: e.code, message: e.message, stack: e.stack },\n updated_at: now(),\n }));\n timelineLogger.append(pid, {\n source: 'system', kind: 'agent_error',\n ref: `agent/${name}/${call_id}`,\n summary: e.message\n });\n throw e;\n }\n}", | |
| 553 | + "contract": [ | |
| 554 | + "statePath 写入是原子的(fs.writeFileSync + sync flag)", | |
| 555 | + "resume_token 有效期 24 小时,过期视同 restart_fresh", | |
| 556 | + "memory 注入不超过 5 条,避免 prompt 膨胀", | |
| 557 | + "relevance < 0.5 的 memory 不注入但保留文件", | |
| 558 | + "同一 (pid, agent) 任意时刻只允许一个 status=running" | |
| 559 | + ], | |
| 560 | + "tests": [] | |
| 561 | + }, | |
| 562 | + { | |
| 563 | + "name": "Process.BugLifecycle", | |
| 564 | + "path": "server/services/bug-tracker.js → transition()", | |
| 565 | + "shape": "// L1 内:Tester 失败 → 开 bug + 设 unit failed\ntesterResult.failures.forEach(f => {\n bugTracker.open(pid, {\n source: 'unit_test', unit_id: unit.id, run_id: runId,\n test_name: f.test, category: 'unknown',\n severity: 'high', title: f.test + ' ' + brief(f),\n detail: JSON.stringify(f), affected_units: [unit.id]\n });\n});\n\n// 下一轮 Coder 进入 → 关联 bug 状态转 fixing\nbugTracker.list(pid, { unit_id: unit.id, status: ['open'] })\n .forEach(b => bugTracker.transition(pid, b.id, { to:'fixing', by:'coder' }));\n\n// L2 通过 → 所有 fixed → verified\nif (integratorResult.ok) {\n bugTracker.list(pid, { status: ['fixed'] })\n .forEach(b => bugTracker.transition(pid, b.id, { to:'verified', by:'system' }));\n}", | |
| 566 | + "contract": [], | |
| 567 | + "tests": [] | |
| 568 | + } | |
| 569 | + ] | |
| 570 | +} | |
| 0 | 571 | \ No newline at end of file | ... | ... |