diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json
new file mode 100644
index 0000000..dd26159
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-architect.json
@@ -0,0 +1,59 @@
+{
+ "_doc": "1-unit (todo-be only) contract_mismatch scenario. seq[0]=init, seq[1]=patch (rev=2 + l2_rounds=1).",
+ "sequence": [
+ {
+ "_variant": "init",
+ "chunks": ["## SDD rev 1 + units.json (single unit: todo-be)\n"],
+ "result": "mock-ok devloop-architect[init]: SDD rev 1",
+ "files": [
+ {
+ "path": "sdd/index.html",
+ "content": "
SDD"
+ },
+ {
+ "path": "sdd/mvcp/control.html",
+ "content": "Control rev1C-1 POST /todos
201 on create (ambiguous on errors)
POST
v1
"
+ },
+ {
+ "path": "units.json",
+ "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"1\",\n \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0,\n \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
+ },
+ {
+ "path": "integration/spec.json",
+ "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
+ },
+ {
+ "path": "units/todo-be/spec.json",
+ "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"GET /todos returns array\",\"POST /todos empty returns 400\"]}"
+ },
+ {
+ "path": "history/sdd-rev-001.html",
+ "content": ""
+ }
+ ]
+ },
+ {
+ "_variant": "patch",
+ "chunks": ["## Patch: SDD rev 2, units.json l2_rounds=1, todo-be status=pending\n"],
+ "result": "mock-ok devloop-architect[patch]: SDD rev 2",
+ "files": [
+ {
+ "path": "sdd/mvcp/control.html",
+ "content": "Control rev 2C-1 POST /todos (clarified)
201 on create; 400 on missing text
POST
v2
"
+ },
+ {
+ "path": "sdd/index.html",
+ "content": "SDD rev2"
+ },
+ {
+ "path": "units.json",
+ "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 2,\n \"prd_rev\": \"1\",\n \"sdd_rev\": \"2\",\n \"updated_at\": \"2026-05-13T10:05:00.000Z\",\n \"l2_rounds\": 1,\n \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:00000000000000aa\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
+ },
+ {
+ "path": "history/sdd-rev-002.html",
+ "content": ""
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json
new file mode 100644
index 0000000..9c990ea
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-integrator.json
@@ -0,0 +1,42 @@
+{
+ "_doc": "L2 contract_mismatch: round 1 fails (auth-be 500 vs auth-fe expects 401), round 2 passes after Architect patches SDD + L1 reruns.",
+ "sequence": [
+ {
+ "_variant": "round1-fail",
+ "chunks": [
+ "## step 0 · 读 units.json + integration/spec.json\n",
+ "## step 1-4 · 起 be 进程,跑 case-add-list\n",
+ "case 失败: POST /todos 返 500, 期望 201/400 (contract 不一致)\n"
+ ],
+ "result": "mock-ok devloop-integrator[round1] FAILED: contract mismatch",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/result.json",
+ "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\", \"todo-fe\"],\n \"got\": { \"status\": 500, \"body\": {} },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"TypeError: cannot read property text\"\n }],\n \"duration_ms\": 1234\n}\n"
+ },
+ {
+ "path": "integration/runs/run-mock-l2-1/process.log",
+ "content": "[todo-be] starting on :18001\n[todo-be] TypeError at server.js:8\n[todo-be] exited code 1\n"
+ },
+ {
+ "path": "integration/runs/run-mock-l2-1/http-trace.ndjson",
+ "content": "{\"ts\":1747139000,\"unit\":\"todo-be\",\"method\":\"POST\",\"url\":\"http://127.0.0.1:18001/todos\",\"status\":500}\n"
+ }
+ ]
+ },
+ {
+ "_variant": "round2-pass",
+ "chunks": [
+ "## L2 round 2 (after SDD patched + L1 reran)\n",
+ "case-add-list 通过 ✓\n"
+ ],
+ "result": "mock-ok devloop-integrator[round2] PASS: integration green",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-2/result.json",
+ "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 980\n}\n"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json
new file mode 100644
index 0000000..aa2749e
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/contract_mismatch/devloop-triager.json
@@ -0,0 +1,16 @@
+{
+ "_doc": "Triager归因为 contract_mismatch (confidence 0.85) → LoopController will dispatch Architect(patch).",
+ "chunks": [
+ "## step 0 · 收集证据 (result.json + process.log + sdd + contracts)\n",
+ "## step 1 · category_classifier → contract_mismatch\n",
+ "## step 2 · 起草 proposed_sdd_patch\n",
+ "## step 3 · 写 triage.json + evidence 自检\n"
+ ],
+ "result": "mock-ok devloop-triager: contract_mismatch confidence=0.85 affected=[todo-be]",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/triage.json",
+ "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be 返 500 但 SDD 规定参数错应返 400; contract 描述与实现不齐\",\n \"category\": \"contract_mismatch\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": {\n \"target_section\": \"#unit-todo-be\",\n \"op\": \"update\",\n \"text\": \"明确: POST /todos 缺 text 字段必须返 400 not 500\"\n },\n \"confidence\": 0.85,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/result.json: status: 500\",\n \"integration/runs/run-mock-l2-1/process.log: TypeError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 320\n}\n"
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json
new file mode 100644
index 0000000..ea2ef6f
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-architect.json
@@ -0,0 +1,31 @@
+{
+ "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
+ "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
+ "result": "mock-ok devloop-architect[init]: SDD rev 1",
+ "files": [
+ {
+ "path": "sdd/index.html",
+ "content": "SDD"
+ },
+ {
+ "path": "sdd/mvcp/control.html",
+ "content": "ControlC-1 POST /todos
201 on create, 400 on missing text (explicit)
POST
api
"
+ },
+ {
+ "path": "units.json",
+ "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
+ },
+ {
+ "path": "integration/spec.json",
+ "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
+ },
+ {
+ "path": "units/todo-be/spec.json",
+ "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
+ },
+ {
+ "path": "history/sdd-rev-001.html",
+ "content": ""
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json
new file mode 100644
index 0000000..15078b5
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-integrator.json
@@ -0,0 +1,31 @@
+{
+ "_doc": "impl_bug: integration fails with assertion (status 500 but SDD said something specific). Round 2 passes after just resetting affected units.",
+ "sequence": [
+ {
+ "_variant": "round1-fail",
+ "chunks": ["## case fail: todo-be assertion mismatch\n"],
+ "result": "mock-ok devloop-integrator[round1]: impl_bug failure",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/result.json",
+ "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 500 },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"ReferenceError: passwrd is not defined\"\n }],\n \"duration_ms\": 1100\n}\n"
+ },
+ {
+ "path": "integration/runs/run-mock-l2-1/process.log",
+ "content": "[todo-be] ReferenceError at line 8\n"
+ }
+ ]
+ },
+ {
+ "_variant": "round2-pass",
+ "chunks": ["## pass after coder reset\n"],
+ "result": "mock-ok devloop-integrator[round2]: pass",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-2/result.json",
+ "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 950\n}\n"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json
new file mode 100644
index 0000000..646a68c
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/impl_bug/devloop-triager.json
@@ -0,0 +1,13 @@
+{
+ "_doc": "Triager: impl_bug — SDD 写得清楚,code 没遵守。不改 SDD,只 reset todo-be。",
+ "chunks": [
+ "## category_classifier → impl_bug (SDD 明确,code 实现错)\n"
+ ],
+ "result": "mock-ok devloop-triager: impl_bug confidence=0.78 affected=[todo-be]",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/triage.json",
+ "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"todo-be code/server.js 抛 ReferenceError; SDD 明确写 POST /todos 入参为 text 字段,代码没遵守\",\n \"category\": \"impl_bug\",\n \"affected_units\": [\"todo-be\"],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.78,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: ReferenceError\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 290\n}\n"
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json
new file mode 100644
index 0000000..ea2ef6f
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-architect.json
@@ -0,0 +1,31 @@
+{
+ "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
+ "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
+ "result": "mock-ok devloop-architect[init]: SDD rev 1",
+ "files": [
+ {
+ "path": "sdd/index.html",
+ "content": "SDD"
+ },
+ {
+ "path": "sdd/mvcp/control.html",
+ "content": "ControlC-1 POST /todos
201 on create, 400 on missing text (explicit)
POST
api
"
+ },
+ {
+ "path": "units.json",
+ "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
+ },
+ {
+ "path": "integration/spec.json",
+ "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
+ },
+ {
+ "path": "units/todo-be/spec.json",
+ "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
+ },
+ {
+ "path": "history/sdd-rev-001.html",
+ "content": ""
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json
new file mode 100644
index 0000000..97b9d25
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-integrator.json
@@ -0,0 +1,31 @@
+{
+ "_doc": "infra_flake: round 1 crashed (EADDRINUSE), round 2 passes after retry (no reset, no budget burn).",
+ "sequence": [
+ {
+ "_variant": "round1-crash",
+ "chunks": ["## EADDRINUSE on :18001\n"],
+ "result": "mock-ok devloop-integrator[round1] CRASH: EADDRINUSE",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/result.json",
+ "content": "{\n \"ok\": false,\n \"crashed\": true,\n \"run_id\": \"run-mock-l2-1\",\n \"crash_reason\": \"EADDRINUSE on :18001\",\n \"summary\": { \"total\": 0, \"passed\": 0, \"failed\": 0 },\n \"failures\": [],\n \"stderr_excerpt\": \"Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\"\n}\n"
+ },
+ {
+ "path": "integration/runs/run-mock-l2-1/process.log",
+ "content": "[todo-be] Error: listen EADDRINUSE: address already in use 127.0.0.1:18001\n"
+ }
+ ]
+ },
+ {
+ "_variant": "round2-pass",
+ "chunks": ["## retry: new port, pass\n"],
+ "result": "mock-ok devloop-integrator[round2]: pass",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-2/result.json",
+ "content": "{\n \"ok\": true,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-2\",\n \"summary\": { \"total\": 1, \"passed\": 1, \"failed\": 0 },\n \"failures\": [],\n \"duration_ms\": 920\n}\n"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json
new file mode 100644
index 0000000..3923410
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/infra_flake/devloop-triager.json
@@ -0,0 +1,11 @@
+{
+ "_doc": "Triager: infra_flake — env issue, just rerun. affected=[], no SDD change.",
+ "chunks": ["## stderr 含 EADDRINUSE → infra_flake (直接重跑)\n"],
+ "result": "mock-ok devloop-triager: infra_flake confidence=0.95",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/triage.json",
+ "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"端口 18001 已被占用 (EADDRINUSE),进程未起;与 code/SDD 无关\",\n \"category\": \"infra_flake\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.95,\n \"evidence\": [\n \"integration/runs/run-mock-l2-1/process.log: EADDRINUSE\"\n ],\n \"will_auto_apply\": true,\n \"evidence_verified\": true,\n \"duration_ms\": 180\n}\n"
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json
new file mode 100644
index 0000000..ea2ef6f
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-architect.json
@@ -0,0 +1,31 @@
+{
+ "_doc": "1-unit (todo-be) init for impl_bug scenario. No patch variant (impl_bug doesn't trigger Architect).",
+ "chunks": ["## SDD rev 1 (single unit todo-be)\n"],
+ "result": "mock-ok devloop-architect[init]: SDD rev 1",
+ "files": [
+ {
+ "path": "sdd/index.html",
+ "content": "SDD"
+ },
+ {
+ "path": "sdd/mvcp/control.html",
+ "content": "ControlC-1 POST /todos
201 on create, 400 on missing text (explicit)
POST
api
"
+ },
+ {
+ "path": "units.json",
+ "content": "{\n \"project_id\": \"_placeholder_\",\n \"rev\": 1, \"prd_rev\": \"1\", \"sdd_rev\": \"1\", \"created_at\": \"2026-05-13T10:01:00.000Z\",\n \"l2_rounds\": 0, \"l2_budget\": 3,\n \"units\": [\n {\"id\":\"todo-be\",\"type\":\"be\",\"depends_on\":[],\"contract_hash\":\"sha256:0000000000000002\",\"status\":\"pending\",\"retries\":0,\"retry_budget\":3}\n ]\n}\n"
+ },
+ {
+ "path": "integration/spec.json",
+ "content": "{\"rev\":1,\"cases\":[{\"id\":\"case-add\",\"involves\":[\"todo-be\"],\"steps\":[{\"unit\":\"todo-be\",\"call\":\"POST /todos\",\"body\":{\"text\":\"x\"},\"expect\":{\"status\":201}}]}]}"
+ },
+ {
+ "path": "units/todo-be/spec.json",
+ "content": "{\"unit_id\":\"todo-be\",\"kind\":\"be\",\"depends_on\":[],\"acceptance_criteria\":[\"POST /todos returns 201\",\"POST /todos empty body returns 400\"]}"
+ },
+ {
+ "path": "history/sdd-rev-001.html",
+ "content": ""
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json
new file mode 100644
index 0000000..4aba5a9
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-integrator.json
@@ -0,0 +1,11 @@
+{
+ "_doc": "spec_ambiguous: integration fails but root cause is SDD ambiguity. Triager confidence < 0.5 → human intervention.",
+ "chunks": ["## case fail (spec_ambiguous)\n"],
+ "result": "mock-ok devloop-integrator: failure due to ambiguous SDD",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/result.json",
+ "content": "{\n \"ok\": false,\n \"crashed\": false,\n \"run_id\": \"run-mock-l2-1\",\n \"summary\": { \"total\": 1, \"passed\": 0, \"failed\": 1 },\n \"failures\": [{\n \"case_id\": \"case-add-list\",\n \"failed_step\": 0,\n \"involves\": [\"todo-be\"],\n \"got\": { \"status\": 200, \"body\": { \"id\": 1 } },\n \"expected\": { \"status\": 201 },\n \"stderr_excerpt\": \"behavior undefined in SDD\"\n }],\n \"duration_ms\": 1000\n}\n"
+ }
+ ]
+}
diff --git a/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json
new file mode 100644
index 0000000..07c7ef2
--- /dev/null
+++ b/fixtures/devloop-mock-scenarios/spec_ambiguous/devloop-triager.json
@@ -0,0 +1,11 @@
+{
+ "_doc": "Triager: spec_ambiguous, confidence 0.4 → human_intervention_needed.",
+ "chunks": ["## SDD 未明确 token 续期 / POST 状态码 → spec_ambiguous\n"],
+ "result": "mock-ok devloop-triager: spec_ambiguous confidence=0.4 (升 L3)",
+ "files": [
+ {
+ "path": "integration/runs/run-mock-l2-1/triage.json",
+ "content": "{\n \"run_id\": \"run-mock-l2-1\",\n \"root_cause\": \"SDD 没明确规定 POST /todos 应返 200 还是 201; 两种解读都说得通\",\n \"category\": \"spec_ambiguous\",\n \"affected_units\": [],\n \"proposed_sdd_patch\": null,\n \"confidence\": 0.4,\n \"evidence\": [],\n \"will_auto_apply\": false,\n \"evidence_verified\": true,\n \"duration_ms\": 250\n}\n"
+ }
+ ]
+}
diff --git a/packages/acp-core/protocol/ClaudeCliProtocol.js b/packages/acp-core/protocol/ClaudeCliProtocol.js
index 3d85913..c6edeb8 100644
--- a/packages/acp-core/protocol/ClaudeCliProtocol.js
+++ b/packages/acp-core/protocol/ClaudeCliProtocol.js
@@ -49,6 +49,9 @@ class ClaudeCliProtocol extends EventEmitter {
this._mockMode = MOCK_MODE || options.mock === true;
this._mockFixtureDir = options.mockFixtureDir
|| path.resolve(__dirname, '../../../fixtures/devloop-mock');
+ // P3 场景测试: 如 scenario 目录无 {agent}.json,回落到 baseline 目录
+ this._mockFallbackDir = options.mockFallbackDir
+ || path.resolve(__dirname, '../../../fixtures/devloop-mock');
}
/**
@@ -536,21 +539,25 @@ class ClaudeCliProtocol extends EventEmitter {
async _sendMockPrompt(text) {
const agentName = this.agentInfo?.name || this.options.agentName || 'default';
const fixturePath = path.join(this._mockFixtureDir, `${agentName}.json`);
- const defaultPath = path.join(this._mockFixtureDir, 'default.json');
+ const fallbackPath = this._mockFallbackDir !== this._mockFixtureDir
+ ? path.join(this._mockFallbackDir, `${agentName}.json`)
+ : null;
+ const defaultPath = path.join(this._mockFallbackDir, 'default.json');
let raw;
- if (fs.existsSync(fixturePath)) {
+ const tryPaths = [fixturePath];
+ if (fallbackPath) tryPaths.push(fallbackPath);
+ tryPaths.push(defaultPath);
+
+ for (const p of tryPaths) {
+ if (!fs.existsSync(p)) continue;
try {
- raw = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
+ break;
} catch (e) {
- console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${fixturePath}): ${e.message}`);
+ console.warn(`[ClaudeCliProtocol mock] fixture parse failed (${p}): ${e.message}`);
}
}
- if (!raw && fs.existsSync(defaultPath)) {
- try {
- raw = JSON.parse(fs.readFileSync(defaultPath, 'utf8'));
- } catch { /* ignore */ }
- }
if (!raw) {
// echo fallback: 把 prompt 头 80 字符回显
raw = {
diff --git a/scripts/phase3-verify.js b/scripts/phase3-verify.js
new file mode 100644
index 0000000..7807dd3
--- /dev/null
+++ b/scripts/phase3-verify.js
@@ -0,0 +1,213 @@
+#!/usr/bin/env node
+/**
+ * scripts/phase3-verify.js
+ *
+ * P3 L1 + L2 联动验收 (mock 模式 · 4 类分支):
+ * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1 · sdd.updated SSE · changelog sdd_rev 条
+ * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0 · 仅 reset Unit
+ * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE
+ * ④ infra_flake → shipped · l2_rounds=0 · rerun 无 reset
+ * ⑤ Observability → bugs.ndjson · timeline.ndjson · stream.log SSE replay
+ *
+ * 用法: node scripts/phase3-verify.js
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+
+const serverNodeModules = path.resolve(__dirname, '../server/node_modules');
+require('module').globalPaths.push(serverNodeModules);
+const express = (() => {
+ try { return require('express'); }
+ catch {
+ return require(require.resolve('express', { paths: [path.resolve(__dirname, '../server')] }));
+ }
+})();
+
+const pathResolver = require('../server/services/devloop/path-resolver');
+const sseBroker = require('../server/services/devloop/sse-broker');
+const bugTracker = require('../server/services/devloop/bug-tracker');
+const lc = require('../server/src/core/devloop/loop-controller');
+
+const SCENARIO_BASE = path.resolve(__dirname, '../fixtures/devloop-mock-scenarios');
+
+const results = [];
+function step(name, fn) {
+ return (async () => {
+ try {
+ const r = await fn();
+ results.push({ name, ok: true, detail: r?.detail || '' });
+ return r;
+ } catch (e) {
+ results.push({ name, ok: false, reason: e.message });
+ throw e;
+ }
+ })();
+}
+
+function makeFakeRes(events) {
+ return {
+ setHeader() {}, flushHeaders() {}, end() {},
+ on() {},
+ write(line) {
+ const m = line.match(/^event: (.+)$/m);
+ if (m) events.push(m[1]);
+ },
+ };
+}
+
+async function runScenario(scenario) {
+ pathResolver._resetForTesting();
+ sseBroker._resetForTesting();
+ const cid = 'c-phase3-' + scenario + '-' + Date.now().toString(36);
+ const dir = path.join(SCENARIO_BASE, scenario);
+ const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'phase3 ' + scenario });
+ await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir });
+
+ const events = [];
+ sseBroker.subscribe(proj.pid, makeFakeRes(events), {});
+
+ const result = await lc.runProject(proj.pid, { mockFixtureDir: dir });
+ sseBroker.close(proj.pid);
+ return { pid: proj.pid, cid, result, events };
+}
+
+function cleanup(cid) {
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
+ } catch { /* ignore */ }
+}
+
+async function main() {
+ const cidsToClean = [];
+
+ // ① contract_mismatch
+ let cm;
+ try {
+ await step('① contract_mismatch → shipped (sdd_rev=2, l2_rounds=1)', async () => {
+ cm = await runScenario('contract_mismatch');
+ cidsToClean.push(cm.cid);
+ if (cm.result.final_state !== 'shipped') throw new Error('not shipped: ' + cm.result.final_state);
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(cm.pid), 'utf8'));
+ if (u.l2_rounds !== 1) throw new Error('l2_rounds=' + u.l2_rounds);
+ if (u.sdd_rev !== '2') throw new Error('sdd_rev=' + u.sdd_rev);
+ return { detail: `pid=${cm.pid} sdd_rev=2 l2_rounds=1` };
+ });
+
+ await step(' sdd.updated SSE event', () => {
+ if (!cm.events.includes('sdd.updated')) throw new Error('no sdd.updated in: ' + cm.events.join(','));
+ return { detail: 'sdd.updated emitted' };
+ });
+
+ await step(' changelog has sdd_rev=2 entry', () => {
+ const cl = pathResolver.resolveChangelog(cm.pid);
+ if (!fs.existsSync(cl)) throw new Error('changelog missing');
+ const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+ if (!lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)) throw new Error('no sdd_rev=2 in changelog');
+ return { detail: `${lines.length} changelog entries` };
+ });
+
+ await step(' bug opened → fixed', () => {
+ const bugs = bugTracker.list(cm.pid, {});
+ if (bugs.length !== 1) throw new Error('want 1 bug, got ' + bugs.length);
+ if (bugs[0].status !== 'fixed') throw new Error('bug.status=' + bugs[0].status);
+ return { detail: `${bugs[0].id} (${bugs[0].status})` };
+ });
+ } catch { /* errors recorded by step */ }
+
+ // ② impl_bug
+ let ib;
+ try {
+ await step('② impl_bug → shipped · SDD unchanged · l2_rounds=0', async () => {
+ ib = await runScenario('impl_bug');
+ cidsToClean.push(ib.cid);
+ if (ib.result.final_state !== 'shipped') throw new Error('state=' + ib.result.final_state);
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(ib.pid), 'utf8'));
+ if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds + ' (impl_bug should not burn)');
+ if (u.sdd_rev !== '1') throw new Error('sdd_rev=' + u.sdd_rev + ' (should remain 1)');
+ return { detail: `sdd_rev=1 l2_rounds=0` };
+ });
+ } catch {}
+
+ // ③ spec_ambiguous
+ let sa;
+ try {
+ await step('③ spec_ambiguous → l2_blocked + human_intervention_needed SSE', async () => {
+ sa = await runScenario('spec_ambiguous');
+ cidsToClean.push(sa.cid);
+ if (sa.result.final_state !== 'l2_blocked') throw new Error('state=' + sa.result.final_state);
+ if (!sa.events.includes('human_intervention_needed')) throw new Error('no human_intervention_needed SSE');
+ return { detail: 'l2_blocked · SSE fired' };
+ });
+ } catch {}
+
+ // ④ infra_flake
+ let inf;
+ try {
+ await step('④ infra_flake → shipped · l2_rounds=0 · rerun_integrator', async () => {
+ inf = await runScenario('infra_flake');
+ cidsToClean.push(inf.cid);
+ if (inf.result.final_state !== 'shipped') throw new Error('state=' + inf.result.final_state);
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(inf.pid), 'utf8'));
+ if (u.l2_rounds !== 0) throw new Error('l2_rounds=' + u.l2_rounds);
+ const actions = inf.result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
+ if (!actions.some(a => /rerun_integrator/.test(a))) throw new Error('no rerun_integrator action');
+ return { detail: 'shipped without budget burn' };
+ });
+ } catch {}
+
+ // ⑤ Observability cross-check
+ try {
+ await step('⑤ Observability — timeline / bugs / changelog / stream.log', () => {
+ // 用 contract_mismatch 项目验
+ if (!cm) throw new Error('cm scenario did not run');
+ const tl = pathResolver.resolveTimelineLog(cm.pid);
+ const bg = pathResolver.resolveBugsLog(cm.pid);
+ const cl = pathResolver.resolveChangelog(cm.pid);
+ const sl = pathResolver.resolveStreamLog(cm.pid);
+ for (const [name, p] of [['timeline', tl], ['bugs', bg], ['changelog', cl], ['stream.log', sl]]) {
+ if (!fs.existsSync(p)) throw new Error(`${name} missing`);
+ }
+ const tlKinds = fs.readFileSync(tl, 'utf8').trim().split('\n').filter(Boolean)
+ .map(l => { try { return JSON.parse(l).kind; } catch { return ''; }});
+ for (const must of ['unit.state', 'integration.result', 'triage.result', 'sdd.updated']) {
+ // unit.state is via sseBroker.emit not timeline; but we should at least have integration.result + sdd.updated as timeline kinds — actually those go via sseBroker.emit too. Check timeline has agent.call entries.
+ }
+ // 至少: project.created + agent.call (PM + Architect + L1 coder/tester + integrator + triager + architect-patch) + project.shipped
+ const expectedTimelineKinds = ['project.created', 'agent.call', 'project.shipped'];
+ const missing = expectedTimelineKinds.filter(k => !tlKinds.includes(k));
+ if (missing.length) throw new Error('timeline missing kinds: ' + missing.join(','));
+ return { detail: `timeline=${tlKinds.length}ev · bugs+changelog+stream.log present` };
+ });
+
+ await step('⑥ SSE replay via Last-Event-ID', async () => {
+ if (!cm) throw new Error('cm scenario did not run');
+ const replayed = [];
+ sseBroker.subscribe(cm.pid, makeFakeRes(replayed), { replayFromOffset: 3 });
+ sseBroker.close(cm.pid);
+ // 至少应回放出 sdd.updated / project.shipped 之类
+ if (replayed.length < 2) throw new Error('replay too few events: ' + replayed.length);
+ return { detail: `replayed ${replayed.length} events from id=3` };
+ });
+ } catch {}
+
+ for (const c of cidsToClean) cleanup(c);
+
+ let pass = 0, fail = 0;
+ console.log('\n┌─ DevLoop Phase 3 — L1+L2 联动 Acceptance ────────');
+ for (const r of results) {
+ const tag = r.ok ? '✓' : '✗';
+ console.log(`│ ${tag} ${r.name.padEnd(62)} ${r.ok ? r.detail : '— ' + r.reason}`);
+ r.ok ? pass++ : fail++;
+ }
+ console.log(`└─ ${pass}/${pass + fail} steps passed${fail ? ' · ' + fail + ' failed' : ''}\n`);
+ process.exit(fail ? 1 : 0);
+}
+
+main().catch(e => {
+ console.error('\n[phase3-verify] fatal:', e);
+ for (const r of results) console.log(' ', r.ok ? '✓' : '✗', r.name, r.ok ? r.detail : '— ' + r.reason);
+ process.exit(1);
+});
diff --git a/server/agent-server.js b/server/agent-server.js
index e2c5e40..5502f43 100644
--- a/server/agent-server.js
+++ b/server/agent-server.js
@@ -67,6 +67,7 @@ app.use('/api/company', require('./routes/company'));
app.use('/api/goals', require('./routes/goals'));
app.use('/api/tasks', require('./routes/tasks'));
app.use('/api/devloop', require('./routes/devloop'));
+app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
// Health
app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'agent-chat' }));
diff --git a/server/index.js b/server/index.js
index e23c94d..3064fe9 100644
--- a/server/index.js
+++ b/server/index.js
@@ -94,6 +94,7 @@ app.use('/api/goals', require('./routes/goals'));
app.use('/api/company', require('./routes/company'));
app.use('/api/tasks', require('./routes/tasks'));
app.use('/api/devloop', require('./routes/devloop'));
+app.use('/public', require('express').static(require('path').join(__dirname, 'public')));
// ─── 系统端点 ───
app.get('/api/health', (req, res) => res.json({
diff --git a/server/public/devloop-status.html b/server/public/devloop-status.html
new file mode 100644
index 0000000..c01d685
--- /dev/null
+++ b/server/public/devloop-status.html
@@ -0,0 +1,111 @@
+
+
+
+
+DevLoop · Project Status
+
+
+
+
+
+
+
+
+
pid: · root:
+
+
Units (DAG)
+
+
Recent timeline events
+
+
+
+
+
+
diff --git a/server/routes/devloop.js b/server/routes/devloop.js
index 6da93a5..297f894 100644
--- a/server/routes/devloop.js
+++ b/server/routes/devloop.js
@@ -20,6 +20,8 @@ const router = express.Router();
const loopController = require('../src/core/devloop/loop-controller');
const pathResolver = require('../services/devloop/path-resolver');
+const sseBroker = require('../services/devloop/sse-broker');
+const bugTracker = require('../services/devloop/bug-tracker');
const h = (fn) => async (req, res) => {
try { await fn(req, res); }
@@ -76,38 +78,78 @@ router.get('/projects', h(async (_req, res) => {
res.json({ projects });
}));
-// POST /api/devloop/projects/:pid/run?only=
-// P2: only= 跑单 unit;不带 only → 501 (P3 才补全 runProject)
+// POST /api/devloop/projects/:pid/run[?only=] [body: { mockFixtureDir? }]
+// - 不带 only: 跑完整 runProject (P3)
+// - only=: 单 unit drill-down (P2 兼容)
router.post('/projects/:pid/run', h(async (req, res) => {
const { pid } = req.params;
const { only } = req.query;
- if (!only) {
- return res.status(501).json({ error: 'P2 only supports ?only=; full project run lands in P3' });
- }
+ const { mockFixtureDir } = req.body || {};
try {
- const result = await loopController.runUnit(pid, only, { mock: true });
- res.json({
- ok: result.ok,
- pid, unit_id: only,
- attempts: result.attempts,
+ if (only) {
+ const result = await loopController.runUnit(pid, only, { mock: true, mockFixtureDir });
+ return res.json({
+ ok: result.ok, pid, unit_id: only,
+ attempts: result.attempts, final_state: result.final_state,
+ history: result.history.map(h => ({
+ agent: h.agent, attempt: h.attempt,
+ ok: h.ok, files: h.files?.length || 0,
+ commit: h.commit || null,
+ })),
+ });
+ }
+ // 完整 runProject
+ const result = await loopController.runProject(pid, { mock: true, mockFixtureDir });
+ return res.json({
+ ok: result.ok, pid,
final_state: result.final_state,
- history: result.history.map(h => ({
- agent: h.agent, attempt: h.attempt,
- ok: h.ok, files: h.files?.length || 0,
- commit: h.commit || null,
- })),
+ l2_rounds: result.l2_rounds,
+ run_id: result.run_id || null,
+ reason: result.reason || null,
+ history: result.history,
});
} catch (e) {
if (/Unknown project/.test(e.message)) {
return res.status(404).json({ error: 'unknown project: ' + pid });
}
if (/not found in project/.test(e.message)) {
- return res.status(404).json({ error: 'unknown unit: ' + only });
+ return res.status(404).json({ error: 'unknown unit: ' + (only || '?') });
}
throw e;
}
}));
+// GET /api/devloop/projects/:pid/stream — SSE
+// client may send Last-Event-ID header → broker replays from there
+router.get('/projects/:pid/stream', (req, res) => {
+ const { pid } = req.params;
+ try { pathResolver.resolveProjectRoot(pid); }
+ catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
+
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.flushHeaders?.();
+
+ const lastEventId = req.headers['last-event-id'] || req.query.lastEventId || null;
+ sseBroker.subscribe(pid, res, {
+ replayFromOffset: lastEventId != null ? lastEventId : undefined,
+ });
+});
+
+// GET /api/devloop/projects/:pid/bugs?status=open&unit_id=todo-be
+router.get('/projects/:pid/bugs', h(async (req, res) => {
+ const { pid } = req.params;
+ try { pathResolver.resolveProjectRoot(pid); }
+ catch { return res.status(404).json({ error: 'unknown project: ' + pid }); }
+ const filter = {};
+ if (req.query.status) filter.status = String(req.query.status).split(',');
+ if (req.query.unit_id) filter.unit_id = req.query.unit_id;
+ if (req.query.severity) filter.severity = String(req.query.severity).split(',');
+ const bugs = bugTracker.list(pid, filter);
+ res.json({ bugs, total: bugs.length });
+}));
+
// GET /api/devloop/projects/:pid/state
router.get('/projects/:pid/state', h(async (req, res) => {
try {
diff --git a/server/src/core/devloop/loop-controller.js b/server/src/core/devloop/loop-controller.js
index c4092d9..faaaf43 100644
--- a/server/src/core/devloop/loop-controller.js
+++ b/server/src/core/devloop/loop-controller.js
@@ -24,8 +24,17 @@ const pathResolver = require('../../../services/devloop/path-resolver');
const gitVault = require('../../../services/devloop/git-vault');
const timelineLogger = require('../../../services/devloop/timeline-logger');
const unitRegistry = require('../../../services/devloop/unit-registry');
+const bugTracker = require('../../../services/devloop/bug-tracker');
+const changelogWriter = require('../../../services/devloop/changelog-writer');
+const sseBroker = require('../../../services/devloop/sse-broker');
+const docStore = require('../../../services/devloop/doc-store');
const { ClaudeCliProtocol } = require('../../../../packages/acp-core/protocol/ClaudeCliProtocol');
+// 注入依赖:让 unit-registry / bug-tracker 内的 SSE/Timeline 调用都能落地
+unitRegistry.attachSseBroker(sseBroker);
+bugTracker.attachSseBroker(sseBroker);
+bugTracker.attachTimelineLogger(timelineLogger);
+
const TEMPLATES_DIR = path.resolve(__dirname, '../../../../system/templates');
/**
@@ -159,7 +168,10 @@ async function callAgent(pid, agentName, userPrompt, opts = {}) {
// 2. 启动 protocol
const protocol = new ClaudeCliProtocol(
{ name: agentName },
- { mock: opts.mock !== false }, // 默认 mock=true (P1 阶段)
+ {
+ mock: opts.mock !== false, // 默认 mock=true (P1+ 阶段)
+ mockFixtureDir: opts.mockFixtureDir, // 可选,P3 scenario 测试用
+ },
);
let resultParams = null;
@@ -246,17 +258,26 @@ async function runDocsPhase(pid, requirement, opts = {}) {
throw new Error('requirement (string) required');
}
+ // Docs 是项目第一步 — reset mock 计数,确保 PM/Architect 从 seq 0 开始
+ if (opts.resetMockSequence !== false) {
+ ClaudeCliProtocol.resetMockState();
+ }
+
const onChunk = opts.onChunk
? (agent) => (text) => opts.onChunk(agent, text)
: null;
+ const passthrough = {
+ mock: opts.mock !== false,
+ mockFixtureDir: opts.mockFixtureDir,
+ };
const pm = await callAgent(pid, 'devloop-pm',
`Requirement:\n${requirement}\n\nmode: init`,
- { onChunk: onChunk ? onChunk('devloop-pm') : undefined });
+ { ...passthrough, onChunk: onChunk ? onChunk('devloop-pm') : undefined });
const architect = await callAgent(pid, 'devloop-architect',
`Read prd/ from project root and produce SDD + units.json + integration/spec.json.\nRequirement context:\n${requirement}`,
- { onChunk: onChunk ? onChunk('devloop-architect') : undefined });
+ { ...passthrough, onChunk: onChunk ? onChunk('devloop-architect') : undefined });
// timeline 总结
try {
@@ -464,11 +485,272 @@ function _findLatestTesterResult(pid, unitId) {
}
}
+// ──────────────────────────────────────────────────────────────
+// P3 · L2 Integration Loop · runProject
+// ──────────────────────────────────────────────────────────────
+
+/**
+ * Topological wave grouping (Kahn). Returns string[][] — units per wave.
+ */
+function _groupUnitsByWave(units) {
+ const groups = [];
+ const remaining = new Set(units.map(u => u.id));
+ const deps = new Map(units.map(u => [u.id, (u.depends_on || []).slice()]));
+ while (remaining.size > 0) {
+ const wave = [];
+ for (const id of remaining) {
+ if (deps.get(id).every(d => !remaining.has(d))) wave.push(id);
+ }
+ if (wave.length === 0) {
+ throw new Error('cycle in units.json (no unit has all dependencies cleared)');
+ }
+ for (const id of wave) remaining.delete(id);
+ groups.push(wave);
+ }
+ return groups;
+}
+
+/** find latest run dir under integration/runs/ */
+function _findLatestIntegrationRunDir(pid) {
+ const dir = path.join(pathResolver.resolveProjectRoot(pid), 'integration', 'runs');
+ if (!fs.existsSync(dir)) return null;
+ const ids = fs.readdirSync(dir).filter(n => fs.statSync(path.join(dir, n)).isDirectory());
+ if (!ids.length) return null;
+ ids.sort();
+ return ids[ids.length - 1];
+}
+
+function _readJsonSafe(p) {
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
+}
+
+/**
+ * runProject — P3 主循环 (mock 模式)。
+ *
+ * 流程:
+ * while not done and not blocked:
+ * 1. 按 DAG wave 跑 L1 (per-unit runUnit)
+ * 2. 若 l2GateOpen → 跑 Integrator
+ * 3. result.ok=true → 项目完成
+ * 4. result.ok=false → Triager → dispatch:
+ * - contract_mismatch + conf≥0.5 → Architect(patch) + reset affected (架构师自己改 units.json)
+ * → l2_rounds++; 超 l2_budget → l2_exhausted
+ * - impl_bug + conf≥0.5 → reset affected (unit-registry.reset) → 不烧预算
+ * - infra_flake → 直接重跑 (不 reset, 不烧预算)
+ * - spec_ambiguous OR conf<0.5 → l2_blocked + SSE human_intervention_needed
+ *
+ * @param {string} pid
+ * @param {object} opts
+ * - mockFixtureDir?: string (P3 scenario 测试用)
+ * - maxRetries?: number (safety net 防 mock 死循环;默认 10)
+ * @returns { ok, pid, final_state: 'shipped'|'l2_blocked'|'l2_exhausted', l2_rounds, history }
+ */
+async function runProject(pid, opts = {}) {
+ // 注意: 不 resetMockState — runProject 是 runDocsPhase 的延续,
+ // 计数继续往下走才能让 Architect 第二次调用进入 patch 分支 (seq[1])。
+ // 调用方若想全新状态,在调 runDocsPhase 前自己 reset。
+ const projectRoot = pathResolver.resolveProjectRoot(pid);
+ const history = [];
+ const callOpts = {
+ mock: opts.mock !== false,
+ mockFixtureDir: opts.mockFixtureDir,
+ resetMockSequence: false, // 一次 runProject 只 reset 一次
+ };
+ const safety = opts.maxRetries || 10;
+ let iterations = 0;
+
+ // SSE start
+ sseBroker.emit(pid, 'project.run_started', { pid, ts: Date.now() });
+
+ while (iterations < safety) {
+ iterations++;
+
+ // ── (1) L1 wave 调度: 找所有 pending units 按 DAG 分波 ────────
+ const allUnits = unitRegistry.list(pid);
+ const pendingUnits = allUnits.filter(u => u.status === 'pending');
+ if (pendingUnits.length > 0) {
+ const waves = _groupUnitsByWave(pendingUnits);
+ for (let w = 0; w < waves.length; w++) {
+ const wave = waves[w];
+ sseBroker.emit(pid, 'l1.wave_start', { wave_index: w, units: wave });
+ // 串行跑(mock 同步快,不需并行)
+ for (const uid of wave) {
+ const r = await runUnit(pid, uid, { ...callOpts });
+ history.push({ phase: 'L1', wave: w, unit: uid, ok: r.ok, attempts: r.attempts });
+ if (!r.ok) {
+ sseBroker.emit(pid, 'project.l1_blocked', { unit: uid, attempts: r.attempts });
+ return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unit ${uid} blocked` };
+ }
+ }
+ }
+ }
+
+ // ── (2) L2 gate ────────────────────────────────────────────
+ if (!unitRegistry.l2GateOpen(pid)) {
+ // 还有非 passed 的 unit (e.g. blocked) → 视为 blocked
+ sseBroker.emit(pid, 'project.l2_blocked', { reason: 'l2GateClosed' });
+ return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'l2GateClosed' };
+ }
+
+ // ── (3) 跑 Integrator ──────────────────────────────────────
+ sseBroker.emit(pid, 'integration.run_start', { l2_round: _l2Rounds(pid) });
+ const integ = await callAgent(pid, 'devloop-integrator',
+ `Mode: ${_l2Rounds(pid) === 0 ? 'run' : 'retry'}\nRun integration tests per integration/spec.json. Spawn be units on free ports, execute cases, write result.json.`,
+ callOpts);
+ const runId = _findLatestIntegrationRunDir(pid);
+ const intResult = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'result.json')) : null;
+ history.push({ phase: 'L2.integrate', l2_round: _l2Rounds(pid), run_id: runId, ok: intResult?.ok, crashed: intResult?.crashed });
+ sseBroker.emit(pid, 'integration.result', { run_id: runId, ok: !!intResult?.ok, crashed: !!intResult?.crashed });
+
+ // ── (4) 成功 → 项目完成 ────────────────────────────────────
+ if (intResult?.ok === true) {
+ // 若有未关 bug → 转 fixed
+ const openBugs = bugTracker.list(pid, { status: ['open', 'fixing'] });
+ for (const b of openBugs) {
+ try { bugTracker.transition(pid, b.id, { to: 'fixed', by: 'devloop-integrator', note: 'integration passed' }); }
+ catch { /* skip invalid transitions */ }
+ }
+ sseBroker.emit(pid, 'project.shipped', { l2_rounds: _l2Rounds(pid), run_id: runId });
+ try {
+ timelineLogger.append(pid, {
+ source: 'system', kind: 'project.shipped', ref: runId,
+ summary: `Project shipped after ${_l2Rounds(pid)} L2 round(s)`,
+ });
+ } catch {}
+ return { ok: true, pid, final_state: 'shipped', l2_rounds: _l2Rounds(pid), history, run_id: runId };
+ }
+
+ // ── (5) 失败 → 开 bug + 调 Triager ──────────────────────────
+ const bug = bugTracker.open(pid, {
+ source: 'integration_test',
+ run_id: runId,
+ title: `integration ${runId} failed${intResult?.crashed ? ' (crashed)' : ''}`,
+ severity: intResult?.crashed ? 'high' : 'medium',
+ category: 'unknown',
+ detail: (intResult?.failures?.[0]?.stderr_excerpt || intResult?.crash_reason || 'unknown').slice(0, 200),
+ affected_units: intResult?.failures?.[0]?.involves || [],
+ by: 'devloop-integrator',
+ });
+ bugTracker.transition(pid, bug.id, { to: 'fixing', by: 'devloop-triager', note: 'triage in progress' });
+
+ await callAgent(pid, 'devloop-triager',
+ `Mode: triage\nrun_id: ${runId}\nAnalyze integration/runs/${runId}/result.json + process.log; write triage.json.`,
+ callOpts);
+ const triage = runId ? _readJsonSafe(path.join(projectRoot, 'integration/runs', runId, 'triage.json')) : null;
+ history.push({ phase: 'L2.triage', l2_round: _l2Rounds(pid), run_id: runId, category: triage?.category, confidence: triage?.confidence });
+ sseBroker.emit(pid, 'triage.result', {
+ run_id: runId,
+ category: triage?.category,
+ confidence: triage?.confidence,
+ affected_units: triage?.affected_units || [],
+ });
+
+ if (!triage) {
+ sseBroker.emit(pid, 'human_intervention_needed', { reason: 'triage_missing', run_id: runId });
+ return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: 'no triage produced' };
+ }
+
+ // ── (6) 分支调度 ────────────────────────────────────────────
+ const lowConfidence = (triage.confidence ?? 0) < 0.5;
+ const category = triage.category;
+
+ if (lowConfidence || category === 'spec_ambiguous') {
+ sseBroker.emit(pid, 'human_intervention_needed', {
+ reason: lowConfidence ? 'low_confidence_triage' : 'spec_ambiguous',
+ run_id: runId, category, confidence: triage.confidence,
+ });
+ return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `triage ${category} confidence=${triage.confidence}` };
+ }
+
+ if (category === 'infra_flake') {
+ // 不 reset 不烧预算,直接下一轮重跑 Integrator
+ history.push({ phase: 'L2.dispatch', action: 'rerun_integrator (infra_flake)' });
+ continue;
+ }
+
+ if (category === 'impl_bug') {
+ // reset affected units (不烧 l2_budget)
+ const affected = (triage.affected_units || []).filter(u => allUnits.find(x => x.id === u));
+ if (affected.length === 0) {
+ sseBroker.emit(pid, 'human_intervention_needed', { reason: 'impl_bug_no_affected', run_id: runId });
+ return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history };
+ }
+ unitRegistry.reset(pid, affected);
+ history.push({ phase: 'L2.dispatch', action: 'reset_units (impl_bug)', units: affected });
+ sseBroker.emit(pid, 'units.reset', { units: affected, cause: 'impl_bug' });
+ continue;
+ }
+
+ if (category === 'contract_mismatch') {
+ // 调 Architect patch (fixture 自己写 units.json,会把 affected_units 标 pending + l2_rounds++)
+ // 预检 l2_budget
+ const currentRounds = _l2Rounds(pid);
+ const l2Budget = _l2Budget(pid);
+ if (currentRounds + 1 > l2Budget) {
+ sseBroker.emit(pid, 'project.l2_exhausted', { l2_rounds: currentRounds, l2_budget: l2Budget });
+ return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: currentRounds, history };
+ }
+ const oldSddRev = _readSddRev(pid);
+ await callAgent(pid, 'devloop-architect',
+ `Mode: patch\nrun_id: ${runId}\nApply triage.proposed_sdd_patch; bump units.json contract_hash for affected units; increment l2_rounds.`,
+ callOpts);
+ const newSddRev = _readSddRev(pid);
+ if (newSddRev > oldSddRev) {
+ try {
+ changelogWriter.append(pid, {
+ kind: 'sdd_rev',
+ rev: newSddRev,
+ by: 'devloop-architect',
+ trigger: 'l2_triage',
+ summary: `SDD patched from rev ${oldSddRev} to ${newSddRev} per triage ${category}`,
+ diff: { changed_sections: [triage.proposed_sdd_patch?.target_section || '(unknown)'] },
+ });
+ } catch { /* swallow */ }
+ sseBroker.emit(pid, 'sdd.updated', { rev: newSddRev, summary: `patch applied (round ${_l2Rounds(pid)})` });
+ }
+ history.push({ phase: 'L2.dispatch', action: 'sdd_patch + reset', sdd_rev: newSddRev });
+ continue;
+ }
+
+ // Unknown category
+ sseBroker.emit(pid, 'human_intervention_needed', { reason: 'unknown_category', category });
+ return { ok: false, pid, final_state: 'l2_blocked', l2_rounds: _l2Rounds(pid), history, reason: `unknown category: ${category}` };
+ }
+
+ // safety net 超过
+ sseBroker.emit(pid, 'project.l2_exhausted', { reason: 'safety_net_iterations', iterations });
+ return { ok: false, pid, final_state: 'l2_exhausted', l2_rounds: _l2Rounds(pid), history };
+}
+
+function _l2Rounds(pid) {
+ const u = docStore.readUnits(pid);
+ return u?.l2_rounds || 0;
+}
+function _l2Budget(pid) {
+ const u = docStore.readUnits(pid);
+ return u?.l2_budget || 3;
+}
+function _readSddRev(pid) {
+ const root = pathResolver.resolveProjectRoot(pid);
+ const idx = path.join(root, 'sdd', 'index.html');
+ if (!fs.existsSync(idx)) return 0;
+ const m = fs.readFileSync(idx, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/);
+ if (m) return parseInt(m[1], 10);
+ // fallback: try mvcp/control.html (where patch goes)
+ const ctrl = path.join(root, 'sdd', 'mvcp', 'control.html');
+ if (fs.existsSync(ctrl)) {
+ const m2 = fs.readFileSync(ctrl, 'utf8').match(/]*data-doc="sdd"[^>]*data-rev="(\d+)"/);
+ if (m2) return parseInt(m2[1], 10);
+ }
+ return 1;
+}
+
module.exports = {
createProject,
commitAfterAgent,
callAgent,
runDocsPhase,
runUnit,
+ runProject,
getProjectState,
};
diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js
index 25cc2fc..7fe2227 100644
--- a/server/tests/devloop/routes.test.js
+++ b/server/tests/devloop/routes.test.js
@@ -106,9 +106,13 @@ describe('routes/devloop', () => {
expect(s.units).toHaveLength(3);
});
- it('POST /api/devloop/projects/:pid/run requires ?only (501 without)', async () => {
- const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' });
- expect(r.status).toBe(501);
+ it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => {
+ const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ });
+ expect(r.status).toBe(404);
});
it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => {
@@ -133,6 +137,32 @@ describe('routes/devloop', () => {
expect(j2.history).toHaveLength(4);
});
+ it('POST /run (full project, no ?only) drives runProject', async () => {
+ const cid = 'c-route-p3-' + Math.random().toString(36).slice(2, 7);
+ created.push(cid);
+ const r1 = await fetch(`${base}/api/devloop/projects`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }),
+ });
+ const pid = (await r1.json()).pid;
+ // ship via full project run (baseline fixtures = happy path)
+ const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ });
+ expect(r2.status).toBe(200);
+ const j = await r2.json();
+ expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state);
+ expect(typeof j.l2_rounds).toBe('number');
+ });
+
+ it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => {
+ const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`);
+ expect(r.status).toBe(404);
+ });
+
it('POST /run?only on unknown unit returns 404', async () => {
const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7);
created.push(cid);
diff --git a/server/tests/devloop/run-project.test.js b/server/tests/devloop/run-project.test.js
new file mode 100644
index 0000000..22e21f0
--- /dev/null
+++ b/server/tests/devloop/run-project.test.js
@@ -0,0 +1,166 @@
+/**
+ * loop-controller.runProject (P3 L2 联动) 单测
+ *
+ * 跑 4 个 L2 分支:
+ * ① contract_mismatch → shipped · sdd_rev=2 · l2_rounds=1
+ * ② impl_bug → shipped · sdd_rev=1 · l2_rounds=0
+ * ③ spec_ambiguous → l2_blocked · human_intervention_needed SSE
+ * ④ infra_flake → shipped · sdd_rev=1 · l2_rounds=0
+ */
+
+process.env.LEMONBOSS_CLAUDE_MODE = 'mock';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rp-'));
+process.env.HOME = TEST_HOME;
+
+const pathResolver = require('../../services/devloop/path-resolver');
+const sseBroker = require('../../services/devloop/sse-broker');
+const bugTracker = require('../../services/devloop/bug-tracker');
+const lc = require('../../src/core/devloop/loop-controller');
+
+const SCENARIO_BASE = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios');
+
+async function setupScenario(name) {
+ pathResolver._resetForTesting();
+ sseBroker._resetForTesting();
+ const cid = 'c-p3-' + name + '-' + Math.random().toString(36).slice(2, 7);
+ const dir = path.join(SCENARIO_BASE, name);
+ const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea: 'p3 ' + name });
+ await lc.runDocsPhase(proj.pid, 'TODO demo', { mockFixtureDir: dir });
+ return { pid: proj.pid, cid, dir };
+}
+
+function cleanup(cid) {
+ try {
+ fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true });
+ } catch { /* ignore */ }
+}
+
+describe('runProject · L2 branches', () => {
+ describe('① contract_mismatch', () => {
+ let pid, cid, dir, result;
+ const sseEvents = [];
+
+ beforeAll(async () => {
+ ({ pid, cid, dir } = await setupScenario('contract_mismatch'));
+ const fakeRes = {
+ setHeader() {}, flushHeaders() {}, end() {},
+ on() {}, // sse-broker subscribes res.on('close', ...)
+ write(line) {
+ const m = line.match(/^event: (.+)$/m);
+ if (m) sseEvents.push(m[1]);
+ },
+ };
+ sseBroker.subscribe(pid, fakeRes, {});
+ result = await lc.runProject(pid, { mockFixtureDir: dir });
+ });
+ afterAll(() => { sseBroker.close(pid); cleanup(cid); });
+
+ it('ships after 1 L2 round', () => {
+ expect(result.ok).toBe(true);
+ expect(result.final_state).toBe('shipped');
+ });
+
+ it('units.json l2_rounds=1 and sdd_rev bumped to 2', () => {
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
+ expect(u.l2_rounds).toBe(1);
+ expect(u.sdd_rev).toBe('2');
+ });
+
+ it('emits sdd.updated event', () => {
+ expect(sseEvents).toContain('sdd.updated');
+ });
+
+ it('changelog has sdd_rev entry', () => {
+ const cl = pathResolver.resolveChangelog(pid);
+ expect(fs.existsSync(cl)).toBe(true);
+ const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+ expect(lines.some(l => l.kind === 'sdd_rev' && l.rev === 2)).toBe(true);
+ });
+
+ it('bug opened then transitioned to fixed', () => {
+ const bugs = bugTracker.list(pid, {});
+ expect(bugs).toHaveLength(1);
+ expect(bugs[0].status).toBe('fixed');
+ });
+ });
+
+ describe('② impl_bug', () => {
+ let pid, cid, dir, result;
+
+ beforeAll(async () => {
+ ({ pid, cid, dir } = await setupScenario('impl_bug'));
+ result = await lc.runProject(pid, { mockFixtureDir: dir });
+ });
+ afterAll(() => { sseBroker.close(pid); cleanup(cid); });
+
+ it('ships without SDD change', () => {
+ expect(result.ok).toBe(true);
+ expect(result.final_state).toBe('shipped');
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
+ expect(u.l2_rounds).toBe(0);
+ expect(u.sdd_rev).toBe('1');
+ });
+
+ it('history shows impl_bug reset_units (no sdd_patch)', () => {
+ const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
+ expect(actions.some(a => /impl_bug/.test(a))).toBe(true);
+ expect(actions.some(a => /sdd_patch/.test(a))).toBe(false);
+ });
+ });
+
+ describe('③ spec_ambiguous', () => {
+ let pid, cid, dir, result;
+ const sseEvents = [];
+
+ beforeAll(async () => {
+ ({ pid, cid, dir } = await setupScenario('spec_ambiguous'));
+ const fakeRes = {
+ setHeader() {}, flushHeaders() {}, end() {},
+ on() {}, // sse-broker subscribes res.on('close', ...)
+ write(line) {
+ const m = line.match(/^event: (.+)$/m);
+ if (m) sseEvents.push(m[1]);
+ },
+ };
+ sseBroker.subscribe(pid, fakeRes, {});
+ result = await lc.runProject(pid, { mockFixtureDir: dir });
+ });
+ afterAll(() => { sseBroker.close(pid); cleanup(cid); });
+
+ it('returns l2_blocked', () => {
+ expect(result.ok).toBe(false);
+ expect(result.final_state).toBe('l2_blocked');
+ });
+
+ it('emits human_intervention_needed SSE', () => {
+ expect(sseEvents).toContain('human_intervention_needed');
+ });
+ });
+
+ describe('④ infra_flake', () => {
+ let pid, cid, dir, result;
+
+ beforeAll(async () => {
+ ({ pid, cid, dir } = await setupScenario('infra_flake'));
+ result = await lc.runProject(pid, { mockFixtureDir: dir });
+ });
+ afterAll(() => { sseBroker.close(pid); cleanup(cid); });
+
+ it('ships after re-run without budget burn', () => {
+ expect(result.ok).toBe(true);
+ expect(result.final_state).toBe('shipped');
+ const u = JSON.parse(fs.readFileSync(pathResolver.resolveUnitsJson(pid), 'utf8'));
+ expect(u.l2_rounds).toBe(0);
+ });
+
+ it('history records rerun_integrator', () => {
+ const actions = result.history.filter(h => h.phase === 'L2.dispatch').map(h => h.action);
+ expect(actions.some(a => /rerun_integrator/.test(a))).toBe(true);
+ });
+ });
+});