codebee 0.1.95 → 0.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
@@ -1,255 +1,255 @@
1
- """Durable task contracts, evidence, approvals and completion receipts."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import re
6
- import threading
7
- import time
8
- import uuid
9
- from pathlib import Path
10
-
11
- from . import paths
12
-
13
- _DIR = paths.DATA_DIR / "contracts"
14
- _LOCK = threading.RLock()
15
- _ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
16
- _CRITERIA_MAX = 40
17
- _EVIDENCE_MAX = 300
18
- _MISSING = object()
19
- _CASE_KEYWORDS = {
20
- "correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
21
- "performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
22
- "boundary": ("边界", "极端", "boundary", "edge"),
23
- "exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
24
- "readability": ("可读", "文档", "表达", "readability", "documentation"),
25
- "security": ("安全", "权限", "注入", "security", "auth", "injection"),
26
- }
27
-
28
-
29
- def _valid_id(task_id):
30
- return bool(_ID_RE.fullmatch(str(task_id or "")))
31
-
32
-
33
- def _path(task_id):
34
- if not _valid_id(task_id):
35
- raise ValueError("任务 ID 无效")
36
- return _DIR / (str(task_id) + ".json")
37
-
38
-
39
- def _now():
40
- return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
41
-
42
-
43
- def _save(item):
44
- p = _path(item["task_id"])
45
- p.parent.mkdir(parents=True, exist_ok=True)
46
- tmp = p.with_suffix(".tmp")
47
- tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
48
- tmp.replace(p)
49
-
50
-
51
- def _load(task_id):
52
- try:
53
- value = json.loads(_path(task_id).read_text(encoding="utf-8"))
54
- return value if isinstance(value, dict) else None
55
- except (OSError, ValueError):
56
- return None
57
-
58
-
59
- def _actor(value):
60
- return str(value or "local").strip()[:120] or "local"
61
-
62
-
63
- def create(task_id, payload=None):
64
- """Create or update a task's contract, preserving evidence and audit history."""
65
- payload = payload if isinstance(payload, dict) else {}
66
- raw_criteria = payload.get("acceptance_criteria", _MISSING)
67
- if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
68
- raise ValueError("acceptance_criteria 必须是列表")
69
- clean = []
70
- for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
71
- text = str(value or "").strip()[:500]
72
- if text and text not in clean:
73
- clean.append(text)
74
- with _LOCK:
75
- item = _load(task_id) or {
76
- "version": 1, "task_id": str(task_id), "created_at": _now(),
77
- "acceptance_criteria": [], "evidence": [], "approval_required": False,
78
- "approval": None, "blocked_reason": "", "completion_receipt": None,
79
- "audit": [], "status": "open",
80
- }
81
- # Partial updates must not erase an existing contract field merely
82
- # because the caller omitted it (MCP/UI frequently send sparse patches).
83
- if raw_criteria is not _MISSING:
84
- item["acceptance_criteria"] = clean
85
- if "approval_required" in payload:
86
- approval_required = payload.get("approval_required")
87
- if not isinstance(approval_required, bool):
88
- raise ValueError("approval_required 必须是布尔值")
89
- item["approval_required"] = approval_required
90
- item["updated_at"] = _now()
91
- item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
92
- "action": "contract_updated",
93
- "criteria_count": len(item.get("acceptance_criteria") or [])})
94
- _save(item)
95
- return item
96
-
97
-
98
- def get(task_id):
99
- with _LOCK:
100
- return _load(task_id)
101
-
102
-
103
- def add_evidence(task_id, evidence, actor="local"):
104
- if not isinstance(evidence, dict):
105
- raise ValueError("证据必须是对象")
106
- status = str(evidence.get("status") or "unknown").lower()
107
- if status not in ("passed", "failed", "unknown", "not_evaluated"):
108
- raise ValueError("证据状态无效")
109
- with _LOCK:
110
- item = _load(task_id)
111
- if not item:
112
- item = create(task_id, {})
113
- row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
114
- "criterion": str(evidence.get("criterion") or "")[:500], "status": status,
115
- "summary": str(evidence.get("summary") or "")[:2000],
116
- "source": str(evidence.get("source") or "manual")[:80], "at": _now(),
117
- "actor": _actor(actor)}
118
- item["evidence"].append(row)
119
- item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
120
- item["audit"].append({"at": row["at"], "actor": row["actor"],
121
- "action": "evidence_added", "evidence_id": row["id"]})
122
- item["updated_at"] = row["at"]
123
- _save(item)
124
- return item
125
-
126
-
127
- def record_acceptance_cases(task_id, cases, actor="system"):
128
- """Map acceptance-matrix cases onto matching user criteria.
129
-
130
- Matching is deliberately conservative: only criteria containing a known
131
- case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
132
- unmatched criterion remains unverified rather than being guessed green.
133
- """
134
- if not isinstance(cases, list):
135
- return get(task_id)
136
- item = get(task_id)
137
- if not item:
138
- return None
139
- criteria = item.get("acceptance_criteria") or []
140
- for case in cases:
141
- if not isinstance(case, dict):
142
- continue
143
- name = str(case.get("case") or "").strip().lower()
144
- status = str(case.get("status") or "unknown").lower()
145
- if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
146
- continue
147
- for criterion in criteria:
148
- text = str(criterion or "").strip()
149
- low = text.lower()
150
- explicit = low == "acceptance:" + name
151
- matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
152
- if not matched:
153
- continue
154
- add_evidence(task_id, {"criterion": text, "status": status,
155
- "summary": case.get("reason") or
156
- "acceptance matrix: " + name,
157
- "source": "acceptance"}, actor=actor)
158
- return get(task_id)
159
-
160
-
161
- def _decision(task_id, status, actor, note):
162
- with _LOCK:
163
- item = _load(task_id)
164
- if not item:
165
- return None, "任务契约不存在"
166
- decision = {"status": status, "actor": _actor(actor),
167
- "note": str(note or "").strip()[:1000], "at": _now()}
168
- item["approval"] = decision
169
- item["status"] = "approved" if status == "approved" else "open"
170
- item["audit"].append(dict(decision, action=status))
171
- item["updated_at"] = decision["at"]
172
- _save(item)
173
- return item, None
174
-
175
-
176
- def approve(task_id, actor="local", note=""):
177
- return _decision(task_id, "approved", actor, note)
178
-
179
-
180
- def reject(task_id, actor="local", note=""):
181
- if not str(note or "").strip():
182
- return None, "拒绝审批必须说明原因"
183
- return _decision(task_id, "rejected", actor, note)
184
-
185
-
186
- def set_blocked(task_id, reason, actor="local"):
187
- reason = str(reason or "").strip()[:1000]
188
- if not reason:
189
- return None, "Blocked 状态必须填写原因"
190
- with _LOCK:
191
- item = _load(task_id)
192
- if not item:
193
- return None, "任务契约不存在"
194
- item["status"] = "blocked"
195
- item["blocked_reason"] = reason
196
- item["updated_at"] = _now()
197
- item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
198
- "action": "blocked", "reason": reason})
199
- _save(item)
200
- return item, None
201
-
202
-
203
- def release_allowed(task_id):
204
- item = get(task_id)
205
- # Task creation persists the approval bit redundantly for backwards
206
- # compatibility. Combine both sources with OR so a damaged/tampered
207
- # sidecar cannot downgrade an approval-protected task to releasable.
208
- try:
209
- from . import store
210
- task = store.get_task(task_id) or {}
211
- except Exception:
212
- task = {}
213
- requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
214
- if not requires:
215
- return True
216
- if item is None:
217
- return False
218
- approval = item.get("approval") or {}
219
- return approval.get("status") == "approved"
220
-
221
-
222
- def complete(task_id, run):
223
- if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
224
- return None, "完成回执只能关联终态运行"
225
- with _LOCK:
226
- item = _load(task_id)
227
- if not item:
228
- item = create(task_id, {})
229
- criteria = item.get("acceptance_criteria") or []
230
- evidence = item.get("evidence") or []
231
- latest = {}
232
- for row in evidence:
233
- if row.get("criterion"):
234
- latest[row["criterion"]] = row
235
- checked = [latest.get(c) for c in criteria]
236
- receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
237
- "run_status": run.get("status"), "criteria_total": len(criteria),
238
- "criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
239
- "criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
240
- "criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
241
- "evidence_ids": [row.get("id") for row in checked if row],
242
- "cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
243
- "completed_at": _now()}
244
- # Completion is idempotent for the same run. A late duplicate status
245
- # write must not manufacture a second audit event or mutate the
246
- # original cost/token receipt.
247
- previous = item.get("completion_receipt") or {}
248
- if previous.get("run_id") == receipt["run_id"]:
249
- return previous, None
250
- item["completion_receipt"] = receipt
251
- item["audit"].append({"at": receipt["completed_at"], "actor": "system",
252
- "action": "completion_receipt_created", "run_id": run.get("id")})
253
- item["updated_at"] = receipt["completed_at"]
254
- _save(item)
255
- return receipt, None
1
+ """Durable task contracts, evidence, approvals and completion receipts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from . import paths
12
+
13
+ _DIR = paths.DATA_DIR / "contracts"
14
+ _LOCK = threading.RLock()
15
+ _ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
16
+ _CRITERIA_MAX = 40
17
+ _EVIDENCE_MAX = 300
18
+ _MISSING = object()
19
+ _CASE_KEYWORDS = {
20
+ "correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
21
+ "performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
22
+ "boundary": ("边界", "极端", "boundary", "edge"),
23
+ "exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
24
+ "readability": ("可读", "文档", "表达", "readability", "documentation"),
25
+ "security": ("安全", "权限", "注入", "security", "auth", "injection"),
26
+ }
27
+
28
+
29
+ def _valid_id(task_id):
30
+ return bool(_ID_RE.fullmatch(str(task_id or "")))
31
+
32
+
33
+ def _path(task_id):
34
+ if not _valid_id(task_id):
35
+ raise ValueError("任务 ID 无效")
36
+ return _DIR / (str(task_id) + ".json")
37
+
38
+
39
+ def _now():
40
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
41
+
42
+
43
+ def _save(item):
44
+ p = _path(item["task_id"])
45
+ p.parent.mkdir(parents=True, exist_ok=True)
46
+ tmp = p.with_suffix(".tmp")
47
+ tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
48
+ tmp.replace(p)
49
+
50
+
51
+ def _load(task_id):
52
+ try:
53
+ value = json.loads(_path(task_id).read_text(encoding="utf-8"))
54
+ return value if isinstance(value, dict) else None
55
+ except (OSError, ValueError):
56
+ return None
57
+
58
+
59
+ def _actor(value):
60
+ return str(value or "local").strip()[:120] or "local"
61
+
62
+
63
+ def create(task_id, payload=None):
64
+ """Create or update a task's contract, preserving evidence and audit history."""
65
+ payload = payload if isinstance(payload, dict) else {}
66
+ raw_criteria = payload.get("acceptance_criteria", _MISSING)
67
+ if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
68
+ raise ValueError("acceptance_criteria 必须是列表")
69
+ clean = []
70
+ for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
71
+ text = str(value or "").strip()[:500]
72
+ if text and text not in clean:
73
+ clean.append(text)
74
+ with _LOCK:
75
+ item = _load(task_id) or {
76
+ "version": 1, "task_id": str(task_id), "created_at": _now(),
77
+ "acceptance_criteria": [], "evidence": [], "approval_required": False,
78
+ "approval": None, "blocked_reason": "", "completion_receipt": None,
79
+ "audit": [], "status": "open",
80
+ }
81
+ # Partial updates must not erase an existing contract field merely
82
+ # because the caller omitted it (MCP/UI frequently send sparse patches).
83
+ if raw_criteria is not _MISSING:
84
+ item["acceptance_criteria"] = clean
85
+ if "approval_required" in payload:
86
+ approval_required = payload.get("approval_required")
87
+ if not isinstance(approval_required, bool):
88
+ raise ValueError("approval_required 必须是布尔值")
89
+ item["approval_required"] = approval_required
90
+ item["updated_at"] = _now()
91
+ item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
92
+ "action": "contract_updated",
93
+ "criteria_count": len(item.get("acceptance_criteria") or [])})
94
+ _save(item)
95
+ return item
96
+
97
+
98
+ def get(task_id):
99
+ with _LOCK:
100
+ return _load(task_id)
101
+
102
+
103
+ def add_evidence(task_id, evidence, actor="local"):
104
+ if not isinstance(evidence, dict):
105
+ raise ValueError("证据必须是对象")
106
+ status = str(evidence.get("status") or "unknown").lower()
107
+ if status not in ("passed", "failed", "unknown", "not_evaluated"):
108
+ raise ValueError("证据状态无效")
109
+ with _LOCK:
110
+ item = _load(task_id)
111
+ if not item:
112
+ item = create(task_id, {})
113
+ row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
114
+ "criterion": str(evidence.get("criterion") or "")[:500], "status": status,
115
+ "summary": str(evidence.get("summary") or "")[:2000],
116
+ "source": str(evidence.get("source") or "manual")[:80], "at": _now(),
117
+ "actor": _actor(actor)}
118
+ item["evidence"].append(row)
119
+ item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
120
+ item["audit"].append({"at": row["at"], "actor": row["actor"],
121
+ "action": "evidence_added", "evidence_id": row["id"]})
122
+ item["updated_at"] = row["at"]
123
+ _save(item)
124
+ return item
125
+
126
+
127
+ def record_acceptance_cases(task_id, cases, actor="system"):
128
+ """Map acceptance-matrix cases onto matching user criteria.
129
+
130
+ Matching is deliberately conservative: only criteria containing a known
131
+ case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
132
+ unmatched criterion remains unverified rather than being guessed green.
133
+ """
134
+ if not isinstance(cases, list):
135
+ return get(task_id)
136
+ item = get(task_id)
137
+ if not item:
138
+ return None
139
+ criteria = item.get("acceptance_criteria") or []
140
+ for case in cases:
141
+ if not isinstance(case, dict):
142
+ continue
143
+ name = str(case.get("case") or "").strip().lower()
144
+ status = str(case.get("status") or "unknown").lower()
145
+ if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
146
+ continue
147
+ for criterion in criteria:
148
+ text = str(criterion or "").strip()
149
+ low = text.lower()
150
+ explicit = low == "acceptance:" + name
151
+ matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
152
+ if not matched:
153
+ continue
154
+ add_evidence(task_id, {"criterion": text, "status": status,
155
+ "summary": case.get("reason") or
156
+ "acceptance matrix: " + name,
157
+ "source": "acceptance"}, actor=actor)
158
+ return get(task_id)
159
+
160
+
161
+ def _decision(task_id, status, actor, note):
162
+ with _LOCK:
163
+ item = _load(task_id)
164
+ if not item:
165
+ return None, "任务契约不存在"
166
+ decision = {"status": status, "actor": _actor(actor),
167
+ "note": str(note or "").strip()[:1000], "at": _now()}
168
+ item["approval"] = decision
169
+ item["status"] = "approved" if status == "approved" else "open"
170
+ item["audit"].append(dict(decision, action=status))
171
+ item["updated_at"] = decision["at"]
172
+ _save(item)
173
+ return item, None
174
+
175
+
176
+ def approve(task_id, actor="local", note=""):
177
+ return _decision(task_id, "approved", actor, note)
178
+
179
+
180
+ def reject(task_id, actor="local", note=""):
181
+ if not str(note or "").strip():
182
+ return None, "拒绝审批必须说明原因"
183
+ return _decision(task_id, "rejected", actor, note)
184
+
185
+
186
+ def set_blocked(task_id, reason, actor="local"):
187
+ reason = str(reason or "").strip()[:1000]
188
+ if not reason:
189
+ return None, "Blocked 状态必须填写原因"
190
+ with _LOCK:
191
+ item = _load(task_id)
192
+ if not item:
193
+ return None, "任务契约不存在"
194
+ item["status"] = "blocked"
195
+ item["blocked_reason"] = reason
196
+ item["updated_at"] = _now()
197
+ item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
198
+ "action": "blocked", "reason": reason})
199
+ _save(item)
200
+ return item, None
201
+
202
+
203
+ def release_allowed(task_id):
204
+ item = get(task_id)
205
+ # Task creation persists the approval bit redundantly for backwards
206
+ # compatibility. Combine both sources with OR so a damaged/tampered
207
+ # sidecar cannot downgrade an approval-protected task to releasable.
208
+ try:
209
+ from . import store
210
+ task = store.get_task(task_id) or {}
211
+ except Exception:
212
+ task = {}
213
+ requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
214
+ if not requires:
215
+ return True
216
+ if item is None:
217
+ return False
218
+ approval = item.get("approval") or {}
219
+ return approval.get("status") == "approved"
220
+
221
+
222
+ def complete(task_id, run):
223
+ if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
224
+ return None, "完成回执只能关联终态运行"
225
+ with _LOCK:
226
+ item = _load(task_id)
227
+ if not item:
228
+ item = create(task_id, {})
229
+ criteria = item.get("acceptance_criteria") or []
230
+ evidence = item.get("evidence") or []
231
+ latest = {}
232
+ for row in evidence:
233
+ if row.get("criterion"):
234
+ latest[row["criterion"]] = row
235
+ checked = [latest.get(c) for c in criteria]
236
+ receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
237
+ "run_status": run.get("status"), "criteria_total": len(criteria),
238
+ "criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
239
+ "criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
240
+ "criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
241
+ "evidence_ids": [row.get("id") for row in checked if row],
242
+ "cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
243
+ "completed_at": _now()}
244
+ # Completion is idempotent for the same run. A late duplicate status
245
+ # write must not manufacture a second audit event or mutate the
246
+ # original cost/token receipt.
247
+ previous = item.get("completion_receipt") or {}
248
+ if previous.get("run_id") == receipt["run_id"]:
249
+ return previous, None
250
+ item["completion_receipt"] = receipt
251
+ item["audit"].append({"at": receipt["completed_at"], "actor": "system",
252
+ "action": "completion_receipt_created", "run_id": run.get("id")})
253
+ item["updated_at"] = receipt["completed_at"]
254
+ _save(item)
255
+ return receipt, None