hunter-harness 0.1.0

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 (184) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/bin.js +3356 -0
  4. package/package.json +34 -0
  5. package/resources/bootstrap-ir/manifest.json +19 -0
  6. package/resources/bootstrap-ir/skills/harness-apidoc.yaml +24 -0
  7. package/resources/bootstrap-ir/skills/harness-archive.yaml +24 -0
  8. package/resources/bootstrap-ir/skills/harness-codebase-map.yaml +24 -0
  9. package/resources/bootstrap-ir/skills/harness-knowledge-ingest.yaml +24 -0
  10. package/resources/bootstrap-ir/skills/harness-package.yaml +24 -0
  11. package/resources/bootstrap-ir/skills/harness-plan.yaml +24 -0
  12. package/resources/bootstrap-ir/skills/harness-review.yaml +24 -0
  13. package/resources/bootstrap-ir/skills/harness-run.yaml +24 -0
  14. package/resources/bootstrap-ir/skills/harness-skill-optimizer.yaml +28 -0
  15. package/resources/bootstrap-ir/skills/harness-submit.yaml +24 -0
  16. package/resources/bootstrap-ir/skills/harness-sync.yaml +24 -0
  17. package/resources/bootstrap-ir/skills/harness-test.yaml +24 -0
  18. package/resources/bootstrap-ir/templates/claude-code-skill.md +12 -0
  19. package/resources/harness/general/.harness-build.json +5 -0
  20. package/resources/harness/general/CONTEXT.md +65 -0
  21. package/resources/harness/general/README.md +478 -0
  22. package/resources/harness/general/agents/harness-evaluator.md +96 -0
  23. package/resources/harness/general/agents/harness-explorer.md +72 -0
  24. package/resources/harness/general/agents/harness-reviewer.md +69 -0
  25. package/resources/harness/general/harness-archive/SKILL.md +215 -0
  26. package/resources/harness/general/harness-archive/checklist.md +52 -0
  27. package/resources/harness/general/harness-archive/reference.md +103 -0
  28. package/resources/harness/general/harness-archive/scripts/gen-manifest.ps1 +31 -0
  29. package/resources/harness/general/harness-archive/templates/render-summary.mjs +171 -0
  30. package/resources/harness/general/harness-archive/templates/summary-data-template.json +128 -0
  31. package/resources/harness/general/harness-codebase-map/SKILL.md +112 -0
  32. package/resources/harness/general/harness-codebase-map/checklist.md +126 -0
  33. package/resources/harness/general/harness-codebase-map/reference.md +364 -0
  34. package/resources/harness/general/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  35. package/resources/harness/general/harness-codebase-map/templates/CONCERNS.md +49 -0
  36. package/resources/harness/general/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  37. package/resources/harness/general/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  38. package/resources/harness/general/harness-codebase-map/templates/STACK.md +55 -0
  39. package/resources/harness/general/harness-codebase-map/templates/STRUCTURE.md +69 -0
  40. package/resources/harness/general/harness-codebase-map/templates/TESTING.md +64 -0
  41. package/resources/harness/general/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  42. package/resources/harness/general/harness-codebase-map/templates/map-summary.md +64 -0
  43. package/resources/harness/general/harness-knowledge-ingest/SKILL.md +246 -0
  44. package/resources/harness/general/harness-knowledge-ingest/design.md +842 -0
  45. package/resources/harness/general/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +42 -0
  46. package/resources/harness/general/harness-knowledge-ingest/mcp-config.example.json +10 -0
  47. package/resources/harness/general/harness-knowledge-ingest/reference.md +309 -0
  48. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge.py +4082 -0
  49. package/resources/harness/general/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +273 -0
  50. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +43 -0
  51. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-02-14-ledger-snapshot-followup/reports/final/summary-data.json +38 -0
  52. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +36 -0
  53. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +1 -0
  54. package/resources/harness/general/harness-knowledge-ingest/tests/test_harness_knowledge.py +1792 -0
  55. package/resources/harness/general/harness-knowledge-query/SKILL.md +164 -0
  56. package/resources/harness/general/harness-plan/SKILL.md +127 -0
  57. package/resources/harness/general/harness-plan/checklist.md +243 -0
  58. package/resources/harness/general/harness-plan/protocols.md +124 -0
  59. package/resources/harness/general/harness-plan/reference.md +376 -0
  60. package/resources/harness/general/harness-review/SKILL.md +156 -0
  61. package/resources/harness/general/harness-review/checklist.md +124 -0
  62. package/resources/harness/general/harness-review/protocols.md +68 -0
  63. package/resources/harness/general/harness-review/reference.md +86 -0
  64. package/resources/harness/general/harness-run/SKILL.md +132 -0
  65. package/resources/harness/general/harness-run/checklist.md +259 -0
  66. package/resources/harness/general/harness-run/protocols.md +78 -0
  67. package/resources/harness/general/harness-run/reference.md +834 -0
  68. package/resources/harness/general/harness-submit/SKILL.md +159 -0
  69. package/resources/harness/general/harness-submit/checklist.md +407 -0
  70. package/resources/harness/general/harness-submit/reference.md +152 -0
  71. package/resources/harness/general/harness-sync/SKILL.md +82 -0
  72. package/resources/harness/general/harness-sync/reference.md +153 -0
  73. package/resources/harness/general/harness-test/SKILL.md +180 -0
  74. package/resources/harness/general/harness-test/checklist.md +319 -0
  75. package/resources/harness/general/harness-test/pitfalls.md +260 -0
  76. package/resources/harness/general/harness-test/reference.md +791 -0
  77. package/resources/harness/general/protocols/archive-report-protocol.md +175 -0
  78. package/resources/harness/general/protocols/evidence-based-reporting-protocol.md +134 -0
  79. package/resources/harness/general/protocols/ledger-protocol.md +270 -0
  80. package/resources/harness/general/protocols/powershell-protocol.md +181 -0
  81. package/resources/harness/general/protocols/report-pipeline-protocol.md +150 -0
  82. package/resources/harness/general/protocols/sensitive-info-protocol.md +92 -0
  83. package/resources/harness/general/protocols/state-layout-protocol.md +131 -0
  84. package/resources/harness/general/protocols/submit-protocol.md +78 -0
  85. package/resources/harness/general/scripts/harness_archive.py +2319 -0
  86. package/resources/harness/general/scripts/harness_deploy.py +613 -0
  87. package/resources/harness/general/scripts/harness_events.py +680 -0
  88. package/resources/harness/general/scripts/harness_ledger.py +671 -0
  89. package/resources/harness/general/scripts/harness_preflight.py +780 -0
  90. package/resources/harness/general/scripts/harness_service.py +1331 -0
  91. package/resources/harness/java/.harness-build.json +5 -0
  92. package/resources/harness/java/CONTEXT.md +65 -0
  93. package/resources/harness/java/README.md +478 -0
  94. package/resources/harness/java/agents/harness-evaluator.md +96 -0
  95. package/resources/harness/java/agents/harness-explorer.md +72 -0
  96. package/resources/harness/java/agents/harness-reviewer.md +69 -0
  97. package/resources/harness/java/harness-apidoc/SKILL.md +86 -0
  98. package/resources/harness/java/harness-apidoc/checklist.md +142 -0
  99. package/resources/harness/java/harness-apidoc/reference.md +233 -0
  100. package/resources/harness/java/harness-archive/SKILL.md +215 -0
  101. package/resources/harness/java/harness-archive/checklist.md +52 -0
  102. package/resources/harness/java/harness-archive/reference.md +103 -0
  103. package/resources/harness/java/harness-archive/scripts/gen-manifest.ps1 +31 -0
  104. package/resources/harness/java/harness-archive/templates/render-summary.mjs +171 -0
  105. package/resources/harness/java/harness-archive/templates/summary-data-template.json +128 -0
  106. package/resources/harness/java/harness-codebase-map/SKILL.md +112 -0
  107. package/resources/harness/java/harness-codebase-map/checklist.md +126 -0
  108. package/resources/harness/java/harness-codebase-map/reference.md +364 -0
  109. package/resources/harness/java/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  110. package/resources/harness/java/harness-codebase-map/templates/CONCERNS.md +49 -0
  111. package/resources/harness/java/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  112. package/resources/harness/java/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  113. package/resources/harness/java/harness-codebase-map/templates/STACK.md +55 -0
  114. package/resources/harness/java/harness-codebase-map/templates/STRUCTURE.md +69 -0
  115. package/resources/harness/java/harness-codebase-map/templates/TESTING.md +64 -0
  116. package/resources/harness/java/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  117. package/resources/harness/java/harness-codebase-map/templates/map-summary.md +64 -0
  118. package/resources/harness/java/harness-knowledge-ingest/SKILL.md +246 -0
  119. package/resources/harness/java/harness-knowledge-ingest/design.md +842 -0
  120. package/resources/harness/java/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +42 -0
  121. package/resources/harness/java/harness-knowledge-ingest/mcp-config.example.json +10 -0
  122. package/resources/harness/java/harness-knowledge-ingest/reference.md +309 -0
  123. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge.py +4082 -0
  124. package/resources/harness/java/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +273 -0
  125. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +43 -0
  126. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-02-14-ledger-snapshot-followup/reports/final/summary-data.json +38 -0
  127. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +36 -0
  128. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +1 -0
  129. package/resources/harness/java/harness-knowledge-ingest/tests/test_harness_knowledge.py +1792 -0
  130. package/resources/harness/java/harness-knowledge-query/SKILL.md +164 -0
  131. package/resources/harness/java/harness-package/SKILL.md +87 -0
  132. package/resources/harness/java/harness-package/checklist.md +322 -0
  133. package/resources/harness/java/harness-package/reference.md +210 -0
  134. package/resources/harness/java/harness-plan/SKILL.md +127 -0
  135. package/resources/harness/java/harness-plan/checklist.md +243 -0
  136. package/resources/harness/java/harness-plan/protocols.md +124 -0
  137. package/resources/harness/java/harness-plan/reference.md +376 -0
  138. package/resources/harness/java/harness-review/SKILL.md +156 -0
  139. package/resources/harness/java/harness-review/checklist.md +124 -0
  140. package/resources/harness/java/harness-review/protocols.md +68 -0
  141. package/resources/harness/java/harness-review/reference.md +86 -0
  142. package/resources/harness/java/harness-run/SKILL.md +148 -0
  143. package/resources/harness/java/harness-run/checklist.md +250 -0
  144. package/resources/harness/java/harness-run/protocols.md +78 -0
  145. package/resources/harness/java/harness-run/reference.md +814 -0
  146. package/resources/harness/java/harness-submit/SKILL.md +166 -0
  147. package/resources/harness/java/harness-submit/checklist.md +407 -0
  148. package/resources/harness/java/harness-submit/reference.md +152 -0
  149. package/resources/harness/java/harness-sync/SKILL.md +82 -0
  150. package/resources/harness/java/harness-sync/reference.md +153 -0
  151. package/resources/harness/java/harness-test/SKILL.md +192 -0
  152. package/resources/harness/java/harness-test/checklist.md +317 -0
  153. package/resources/harness/java/harness-test/pitfalls-java.md +16 -0
  154. package/resources/harness/java/harness-test/pitfalls.md +260 -0
  155. package/resources/harness/java/harness-test/reference.md +788 -0
  156. package/resources/harness/java/protocols/archive-report-protocol.md +175 -0
  157. package/resources/harness/java/protocols/evidence-based-reporting-protocol.md +134 -0
  158. package/resources/harness/java/protocols/ledger-protocol.md +270 -0
  159. package/resources/harness/java/protocols/powershell-protocol.md +181 -0
  160. package/resources/harness/java/protocols/report-pipeline-protocol.md +150 -0
  161. package/resources/harness/java/protocols/sensitive-info-protocol.md +92 -0
  162. package/resources/harness/java/protocols/state-layout-protocol.md +131 -0
  163. package/resources/harness/java/protocols/submit-protocol.md +78 -0
  164. package/resources/harness/java/scripts/harness_archive.py +2319 -0
  165. package/resources/harness/java/scripts/harness_deploy.py +613 -0
  166. package/resources/harness/java/scripts/harness_events.py +680 -0
  167. package/resources/harness/java/scripts/harness_ledger.py +671 -0
  168. package/resources/harness/java/scripts/harness_preflight.py +780 -0
  169. package/resources/harness/java/scripts/harness_service.py +1331 -0
  170. package/resources/harness/manifests/general.json +296 -0
  171. package/resources/harness/manifests/java.json +324 -0
  172. package/resources/manifest.json +19 -0
  173. package/resources/skills/harness-apidoc/SKILL.md +50 -0
  174. package/resources/skills/harness-archive/SKILL.md +48 -0
  175. package/resources/skills/harness-codebase-map/SKILL.md +53 -0
  176. package/resources/skills/harness-knowledge-ingest/SKILL.md +48 -0
  177. package/resources/skills/harness-package/SKILL.md +48 -0
  178. package/resources/skills/harness-plan/SKILL.md +51 -0
  179. package/resources/skills/harness-review/SKILL.md +50 -0
  180. package/resources/skills/harness-run/SKILL.md +48 -0
  181. package/resources/skills/harness-skill-optimizer/SKILL.md +54 -0
  182. package/resources/skills/harness-submit/SKILL.md +47 -0
  183. package/resources/skills/harness-sync/SKILL.md +48 -0
  184. package/resources/skills/harness-test/SKILL.md +50 -0
@@ -0,0 +1,1792 @@
1
+ import json
2
+ import shutil
3
+ import sqlite3
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ import xml.etree.ElementTree as ET
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ SCRIPT = ROOT / "scripts" / "harness_knowledge.py"
16
+ MCP_SCRIPT = ROOT / "scripts" / "harness_knowledge_mcp.py"
17
+ MCP_CONFIG = ROOT / "mcp-config.example.json"
18
+ EVALUATION_XML = ROOT / "evaluations" / "harness_knowledge_evaluation.xml"
19
+ FIXTURE_PROJECT = ROOT / "tests" / "fixtures" / "mcp-eval-project"
20
+
21
+
22
+ def write_json(path: Path, data: dict) -> None:
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
25
+
26
+
27
+ class HarnessKnowledgeCliTest(unittest.TestCase):
28
+ def make_project(self, root: Path, final_commit: str = "abc1234") -> Path:
29
+ project = root / "sample-project"
30
+ archive = project / ".harness" / "archive" / "2026-06-30-ai-check-job" / "reports" / "final" / "summary-data.json"
31
+ write_json(
32
+ archive,
33
+ {
34
+ "schemaVersion": "2.1",
35
+ "changeName": "ai-check-job",
36
+ "businessGoal": "实现异步 AI 检查 job,复用现有 LlmClient 和 draft.aiChecks 结果展示。",
37
+ "finalStatus": "OK",
38
+ "finalCommit": final_commit,
39
+ "baseCommit": "base0001",
40
+ "diffStat": {"filesChanged": 3, "insertions": 120, "deletions": 8, "range": "base0001..abc1234"},
41
+ "changedFiles": [
42
+ {
43
+ "path": "apps/server/src/registry/store.ts",
44
+ "summary": "新增 AI job 状态机和持久化字段",
45
+ "insertions": 80,
46
+ "deletions": 4,
47
+ },
48
+ {
49
+ "path": "apps/web/components/registry.tsx",
50
+ "summary": "展示 draft.aiChecks 异步结果",
51
+ "insertions": 40,
52
+ "deletions": 4,
53
+ },
54
+ ],
55
+ "verification": {
56
+ "unitTests": {"run": 12, "failures": 0, "errors": 0, "passRate": "12/12"},
57
+ "apiTests": {"status": "OK", "total": 3, "passed": 3, "failed": 0, "passRate": "3/3"},
58
+ },
59
+ "reviewSummary": {
60
+ "status": "ADVISORY",
61
+ "red": 0,
62
+ "yellow": 1,
63
+ "summary": "异步 job 轮询间隔后续可优化。",
64
+ },
65
+ "maintenanceNotes": [
66
+ "AI 检查 job 复用 LlmClient,不新增 provider 抽象。",
67
+ "draft.aiChecks 是前后端展示结果的唯一来源。",
68
+ ],
69
+ "knownRisks": [
70
+ "真实 provider key 与用量计费需要单独治理。",
71
+ ],
72
+ "manualActions": [
73
+ "部署前确认 AI provider 环境变量已配置。",
74
+ ],
75
+ },
76
+ )
77
+ return project
78
+
79
+ def add_followup_archive(self, project: Path) -> None:
80
+ archive = project / ".harness" / "archive" / "2026-07-01-ai-check-job-followup" / "reports" / "final" / "summary-data.json"
81
+ write_json(
82
+ archive,
83
+ {
84
+ "schemaVersion": "2.1",
85
+ "changeName": "ai-check-job-followup",
86
+ "businessGoal": "调整异步 AI 检查 job 展示与 registry store 持久化策略,替代上一版前端轮询假设。",
87
+ "finalStatus": "OK",
88
+ "finalCommit": "def5678",
89
+ "baseCommit": "abc1234",
90
+ "changedFiles": [
91
+ {
92
+ "path": "apps/server/src/registry/store.ts",
93
+ "summary": "调整 AI job store 字段和状态兼容逻辑",
94
+ },
95
+ {
96
+ "path": "apps/web/components/registry.tsx",
97
+ "summary": "调整 AI checks 展示入口",
98
+ },
99
+ ],
100
+ "maintenanceNotes": [
101
+ "新的 registry store 状态字段替代上一版 AI job 轮询假设。",
102
+ ],
103
+ "knownRisks": [],
104
+ "manualActions": [],
105
+ },
106
+ )
107
+
108
+ def add_candidate_archive_without_commit(self, project: Path) -> None:
109
+ archive = project / ".harness" / "archive" / "2026-07-01-candidate-note" / "reports" / "final" / "summary-data.json"
110
+ write_json(
111
+ archive,
112
+ {
113
+ "schemaVersion": "2.1",
114
+ "changeName": "candidate-note",
115
+ "businessGoal": "记录一个仍需人工确认的候选知识条目。",
116
+ "finalStatus": "OK",
117
+ "changedFiles": [
118
+ {
119
+ "path": "docs/candidate.md",
120
+ "summary": "候选知识说明",
121
+ }
122
+ ],
123
+ "maintenanceNotes": [],
124
+ "knownRisks": [],
125
+ "manualActions": [],
126
+ },
127
+ )
128
+
129
+ def add_old_archive_without_commit(self, project: Path) -> None:
130
+ archive = project / ".harness" / "archive" / "2026-01-01-old-decision" / "reports" / "final" / "summary-data.json"
131
+ write_json(
132
+ archive,
133
+ {
134
+ "schemaVersion": "2.1",
135
+ "changeName": "old-decision",
136
+ "businessGoal": "旧归档知识应在 TTL 过期后要求重新确认。",
137
+ "finalStatus": "OK",
138
+ "changedFiles": [
139
+ {
140
+ "path": "docs/old-decision.md",
141
+ "summary": "旧决策说明",
142
+ }
143
+ ],
144
+ "maintenanceNotes": [],
145
+ "knownRisks": [],
146
+ "manualActions": [],
147
+ },
148
+ )
149
+
150
+ def add_conflicting_archive(self, project: Path) -> None:
151
+ archive = project / ".harness" / "archive" / "2026-07-02-ai-check-conflict" / "reports" / "final" / "summary-data.json"
152
+ write_json(
153
+ archive,
154
+ {
155
+ "schemaVersion": "2.1",
156
+ "changeName": "ai-check-conflict",
157
+ "businessGoal": "不再使用 draft.aiChecks 作为唯一来源,改为 server aiJobResult 替代旧展示结果。",
158
+ "finalStatus": "OK",
159
+ "changedFiles": [
160
+ {
161
+ "path": "apps/web/components/registry.tsx",
162
+ "summary": "不再读取 draft.aiChecks,改为 aiJobResult 展示",
163
+ }
164
+ ],
165
+ "maintenanceNotes": [
166
+ "server aiJobResult 替代 draft.aiChecks,旧的唯一来源约束不再成立。",
167
+ ],
168
+ "knownRisks": [],
169
+ "manualActions": [],
170
+ },
171
+ )
172
+
173
+ def add_failed_verification_archive(self, project: Path) -> None:
174
+ archive = project / ".harness" / "archive" / "2026-07-03-ai-check-test-regression" / "reports" / "final" / "summary-data.json"
175
+ write_json(
176
+ archive,
177
+ {
178
+ "schemaVersion": "2.1",
179
+ "changeName": "ai-check-test-regression",
180
+ "businessGoal": "验证异步 AI 检查 job 后续改动时发现 registry store 相关测试退化。",
181
+ "finalStatus": "WARN",
182
+ "finalCommit": "fedcba9",
183
+ "baseCommit": "def5678",
184
+ "changedFiles": [
185
+ {
186
+ "path": "apps/server/src/registry/store.ts",
187
+ "summary": "调整 registry store 后测试出现失败",
188
+ }
189
+ ],
190
+ "verification": {
191
+ "unitTests": {"run": 12, "failures": 2, "errors": 0, "passed": 10, "passRate": "10/12"},
192
+ "apiTests": {"status": "FAILED", "total": 3, "passed": 2, "failed": 1, "passRate": "2/3"},
193
+ },
194
+ "maintenanceNotes": [],
195
+ "knownRisks": ["registry store 后续测试失败,需要重新确认旧测试证据。"],
196
+ "manualActions": [],
197
+ },
198
+ )
199
+
200
+ def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]:
201
+ return subprocess.run(
202
+ [sys.executable, str(SCRIPT), *args],
203
+ cwd=str(ROOT),
204
+ text=True,
205
+ encoding="utf-8",
206
+ stdout=subprocess.PIPE,
207
+ stderr=subprocess.PIPE,
208
+ )
209
+
210
+ def run_git(self, cwd: Path, *args: str) -> subprocess.CompletedProcess[str]:
211
+ return subprocess.run(
212
+ ["git", *args],
213
+ cwd=str(cwd),
214
+ text=True,
215
+ encoding="utf-8",
216
+ stdout=subprocess.PIPE,
217
+ stderr=subprocess.PIPE,
218
+ )
219
+
220
+ def test_later_failed_verification_marks_older_test_evidence_stale(self) -> None:
221
+ with tempfile.TemporaryDirectory() as tmp:
222
+ project = self.make_project(Path(tmp))
223
+ self.add_failed_verification_archive(project)
224
+
225
+ result = self.run_cli("ingest", "--project", str(project))
226
+
227
+ self.assertEqual(result.returncode, 0, result.stderr)
228
+ knowledge = project / ".harness" / "knowledge"
229
+ stale_files = list((knowledge / "entries" / "stale").glob("*.json"))
230
+ self.assertGreaterEqual(len(stale_files), 1)
231
+ stale_entries = [json.loads(path.read_text(encoding="utf-8")) for path in stale_files]
232
+ stale_test_entries = [entry for entry in stale_entries if entry["type"] == "test-evidence"]
233
+ self.assertGreaterEqual(len(stale_test_entries), 1)
234
+ reasons = "\n".join(
235
+ reason
236
+ for entry in stale_test_entries
237
+ for reason in entry["lifecycle"].get("staleReasons", [])
238
+ )
239
+ self.assertIn("newer verification degraded", reasons)
240
+ self.assertIn("2026-07-03-ai-check-test-regression", reasons)
241
+
242
+ def test_incremental_ingest_reuses_unchanged_archive_cache(self) -> None:
243
+ with tempfile.TemporaryDirectory() as tmp:
244
+ project = self.make_project(Path(tmp))
245
+
246
+ first = self.run_cli("ingest", "--project", str(project))
247
+ self.assertEqual(first.returncode, 0, first.stderr)
248
+ first_payload = json.loads(first.stdout)
249
+ self.assertEqual(first_payload["ingestMode"]["archivesExtracted"], 1)
250
+ self.assertEqual(first_payload["ingestMode"]["archivesReused"], 0)
251
+
252
+ second = self.run_cli("ingest", "--project", str(project))
253
+ self.assertEqual(second.returncode, 0, second.stderr)
254
+ second_payload = json.loads(second.stdout)
255
+ self.assertEqual(second_payload["ingestMode"]["archivesExtracted"], 0)
256
+ self.assertEqual(second_payload["ingestMode"]["archivesReused"], 1)
257
+ cache_files = list((project / ".harness" / "knowledge" / "cache" / "archive-entries").glob("*.json"))
258
+ self.assertEqual(len(cache_files), 1)
259
+
260
+ self.add_candidate_archive_without_commit(project)
261
+ third = self.run_cli("ingest", "--project", str(project))
262
+ self.assertEqual(third.returncode, 0, third.stderr)
263
+ third_payload = json.loads(third.stdout)
264
+ self.assertEqual(third_payload["ingestMode"]["archivesExtracted"], 1)
265
+ self.assertEqual(third_payload["ingestMode"]["archivesReused"], 1)
266
+
267
+ def test_mcp_entrypoint_describes_harness_knowledge_tools(self) -> None:
268
+ result = subprocess.run(
269
+ [sys.executable, str(MCP_SCRIPT), "--describe-tools"],
270
+ cwd=str(ROOT),
271
+ text=True,
272
+ encoding="utf-8",
273
+ stdout=subprocess.PIPE,
274
+ stderr=subprocess.PIPE,
275
+ )
276
+
277
+ self.assertEqual(result.returncode, 0, result.stderr)
278
+ payload = json.loads(result.stdout)
279
+ tool_names = {tool["name"] for tool in payload["tools"]}
280
+ self.assertIn("harness_knowledge_ingest", tool_names)
281
+ self.assertIn("harness_knowledge_sync", tool_names)
282
+ self.assertIn("harness_knowledge_query", tool_names)
283
+ self.assertIn("harness_knowledge_promote", tool_names)
284
+ self.assertIn("harness_knowledge_demote", tool_names)
285
+ self.assertIn("harness_knowledge_verify", tool_names)
286
+ self.assertIn("harness_knowledge_suggest_validators", tool_names)
287
+ self.assertIn("harness_knowledge_auto", tool_names)
288
+
289
+ config = json.loads(MCP_CONFIG.read_text(encoding="utf-8"))
290
+ server = config["mcpServers"]["harness-knowledge"]
291
+ self.assertEqual(server["command"], "python")
292
+ self.assertIn("harness_knowledge_mcp.py", server["args"][0])
293
+
294
+ def test_ingest_builds_index_entries_sqlite_and_views(self) -> None:
295
+ with tempfile.TemporaryDirectory() as tmp:
296
+ project = self.make_project(Path(tmp))
297
+
298
+ result = self.run_cli("ingest", "--project", str(project))
299
+
300
+ self.assertEqual(result.returncode, 0, result.stderr)
301
+ knowledge = project / ".harness" / "knowledge"
302
+ index_path = knowledge / "index.json"
303
+ sqlite_path = knowledge / "index.sqlite"
304
+ self.assertTrue(index_path.exists(), "index.json should be generated")
305
+ self.assertTrue(sqlite_path.exists(), "index.sqlite should be generated")
306
+ self.assertTrue((knowledge / "views" / "knowledge-dashboard.md").exists())
307
+ self.assertTrue((knowledge / "views" / "by-file.md").exists())
308
+ base_path = knowledge / "views" / "knowledge.base"
309
+ self.assertTrue(base_path.exists())
310
+ base = yaml.safe_load(base_path.read_text(encoding="utf-8"))
311
+ self.assertIn("views", base)
312
+ view_names = {view["name"] for view in base["views"]}
313
+ self.assertIn("Lifecycle Table", view_names)
314
+ self.assertIn("Needs Review", view_names)
315
+
316
+ index = json.loads(index_path.read_text(encoding="utf-8"))
317
+ self.assertGreaterEqual(index["stats"]["candidate"], 5)
318
+ self.assertEqual(index["archives"]["scanned"], 1)
319
+ self.assertEqual(index["archives"]["indexed"], 1)
320
+
321
+ candidate_files = list((knowledge / "entries" / "candidate").glob("*.json"))
322
+ self.assertGreaterEqual(len(candidate_files), 5)
323
+ entry = json.loads(candidate_files[0].read_text(encoding="utf-8"))
324
+ self.assertIn("source", entry)
325
+ self.assertIn("sourceCommit", entry["source"])
326
+ self.assertIn("sourceFiles", entry["scope"])
327
+
328
+ con = sqlite3.connect(sqlite_path)
329
+ try:
330
+ count = con.execute("select count(*) from entries").fetchone()[0]
331
+ file_count = con.execute("select count(*) from entry_files").fetchone()[0]
332
+ by_file = con.execute(
333
+ "select count(*) from entry_files where source_file = ?",
334
+ ("apps/server/src/registry/store.ts",),
335
+ ).fetchone()[0]
336
+ finally:
337
+ con.close()
338
+ self.assertEqual(count, index["stats"]["candidate"])
339
+ self.assertGreaterEqual(file_count, count)
340
+ self.assertGreaterEqual(by_file, 1)
341
+
342
+ def test_query_generates_context_pack_with_relevant_history(self) -> None:
343
+ with tempfile.TemporaryDirectory() as tmp:
344
+ project = self.make_project(Path(tmp))
345
+ ingest = self.run_cli("ingest", "--project", str(project))
346
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
347
+
348
+ result = self.run_cli("query", "--project", str(project), "--query", "异步 AI 检查 job")
349
+
350
+ self.assertEqual(result.returncode, 0, result.stderr)
351
+ payload = json.loads(result.stdout)
352
+ context_pack = Path(payload["contextPack"])
353
+ self.assertTrue(context_pack.exists())
354
+ text = context_pack.read_text(encoding="utf-8")
355
+ self.assertIn("异步 AI 检查 job", text)
356
+ self.assertIn("ai-check-job", text)
357
+ self.assertIn("apps/server/src/registry/store.ts", text)
358
+ self.assertIn("Before planning", text)
359
+ self.assertGreaterEqual(payload["matchCount"], 1)
360
+ self.assertIn("planInput", payload)
361
+ self.assertEqual(payload["planInput"]["kind"], "harness-knowledge-context-pack")
362
+ latest = context_pack.parent / "latest.json"
363
+ self.assertTrue(latest.exists())
364
+ latest_payload = json.loads(latest.read_text(encoding="utf-8"))
365
+ self.assertEqual(latest_payload["contextPack"], str(context_pack))
366
+ self.assertGreaterEqual(len(latest_payload["matchIds"]), 1)
367
+
368
+ def test_query_can_filter_by_source_file_and_status(self) -> None:
369
+ with tempfile.TemporaryDirectory() as tmp:
370
+ project = self.make_project(Path(tmp))
371
+ ingest = self.run_cli("ingest", "--project", str(project))
372
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
373
+
374
+ result = self.run_cli(
375
+ "query",
376
+ "--project",
377
+ str(project),
378
+ "--query",
379
+ "registry",
380
+ "--file",
381
+ "apps/web/components/registry.tsx",
382
+ "--status",
383
+ "candidate",
384
+ )
385
+
386
+ self.assertEqual(result.returncode, 0, result.stderr)
387
+ payload = json.loads(result.stdout)
388
+ self.assertGreaterEqual(payload["matchCount"], 1)
389
+ self.assertEqual(payload["filters"]["files"], ["apps/web/components/registry.tsx"])
390
+ self.assertEqual(payload["filters"]["statuses"], ["candidate"])
391
+ for match in payload["matches"]:
392
+ self.assertEqual(match["status"], "candidate")
393
+ self.assertIn("apps/web/components/registry.tsx", match["sourceFiles"])
394
+
395
+ def test_sync_detects_missing_current_and_changed_knowledge_index(self) -> None:
396
+ with tempfile.TemporaryDirectory() as tmp:
397
+ project = self.make_project(Path(tmp))
398
+
399
+ missing = self.run_cli("sync", "--project", str(project))
400
+ self.assertEqual(missing.returncode, 0, missing.stderr)
401
+ missing_payload = json.loads(missing.stdout)
402
+ self.assertFalse(missing_payload["upToDate"])
403
+ self.assertIn("index.json missing", missing_payload["reasons"])
404
+
405
+ ingest = self.run_cli("ingest", "--project", str(project))
406
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
407
+ current = self.run_cli("sync", "--project", str(project))
408
+ self.assertEqual(current.returncode, 0, current.stderr)
409
+ current_payload = json.loads(current.stdout)
410
+ self.assertTrue(current_payload["upToDate"])
411
+ self.assertEqual(current_payload["reasons"], [])
412
+
413
+ summary = project / ".harness" / "archive" / "2026-06-30-ai-check-job" / "reports" / "final" / "summary-data.json"
414
+ data = json.loads(summary.read_text(encoding="utf-8"))
415
+ data["knownRisks"].append("新增归档风险,知识索引应提示需要刷新")
416
+ write_json(summary, data)
417
+
418
+ changed = self.run_cli("sync", "--project", str(project))
419
+ self.assertEqual(changed.returncode, 0, changed.stderr)
420
+ changed_payload = json.loads(changed.stdout)
421
+ self.assertFalse(changed_payload["upToDate"])
422
+ self.assertIn("archive checksum changed", " ".join(changed_payload["reasons"]))
423
+
424
+ def test_config_ttl_marks_old_entries_stale(self) -> None:
425
+ with tempfile.TemporaryDirectory() as tmp:
426
+ project = Path(tmp) / "sample-project"
427
+ self.add_old_archive_without_commit(project)
428
+ write_json(project / ".harness" / "knowledge" / "config.json", {"staleTtlDays": 1})
429
+
430
+ result = self.run_cli("ingest", "--project", str(project))
431
+
432
+ self.assertEqual(result.returncode, 0, result.stderr)
433
+ stale_files = list((project / ".harness" / "knowledge" / "entries" / "stale").glob("*.json"))
434
+ self.assertGreaterEqual(len(stale_files), 1)
435
+ stale_text = "\n".join(path.read_text(encoding="utf-8") for path in stale_files)
436
+ self.assertIn("ttl expired", stale_text)
437
+
438
+ def test_audit_reports_top_candidate_and_stale_entries(self) -> None:
439
+ with tempfile.TemporaryDirectory() as tmp:
440
+ project = self.make_project(Path(tmp), final_commit="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
441
+ self.add_candidate_archive_without_commit(project)
442
+ (project / "apps" / "server" / "src" / "registry").mkdir(parents=True)
443
+ (project / "apps" / "server" / "src" / "registry" / "store.ts").write_text("export const x = 1;\n", encoding="utf-8")
444
+ self.assertEqual(self.run_git(project, "init").returncode, 0)
445
+ self.assertEqual(self.run_git(project, "config", "user.email", "test@example.com").returncode, 0)
446
+ self.assertEqual(self.run_git(project, "config", "user.name", "Test User").returncode, 0)
447
+ self.assertEqual(self.run_git(project, "add", ".").returncode, 0)
448
+ self.assertEqual(self.run_git(project, "commit", "-m", "initial").returncode, 0)
449
+ ingest = self.run_cli("ingest", "--project", str(project))
450
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
451
+
452
+ result = self.run_cli("audit", "--project", str(project), "--limit", "3")
453
+
454
+ self.assertEqual(result.returncode, 0, result.stderr)
455
+ payload = json.loads(result.stdout)
456
+ self.assertEqual(payload["limit"], 3)
457
+ self.assertTrue(Path(payload["report"]).exists())
458
+ self.assertGreaterEqual(len(payload["candidateReview"]), 1)
459
+ self.assertGreaterEqual(len(payload["staleReview"]), 1)
460
+ report_text = Path(payload["report"]).read_text(encoding="utf-8")
461
+ self.assertIn("Candidate Review", report_text)
462
+ self.assertIn("Stale Review", report_text)
463
+
464
+ def test_followup_archive_marks_older_overlapping_entries_superseded(self) -> None:
465
+ with tempfile.TemporaryDirectory() as tmp:
466
+ project = self.make_project(Path(tmp))
467
+ self.add_followup_archive(project)
468
+
469
+ result = self.run_cli("ingest", "--project", str(project))
470
+
471
+ self.assertEqual(result.returncode, 0, result.stderr)
472
+ knowledge = project / ".harness" / "knowledge"
473
+ index = json.loads((knowledge / "index.json").read_text(encoding="utf-8"))
474
+ self.assertGreaterEqual(index["stats"]["superseded"], 1)
475
+ superseded_files = list((knowledge / "entries" / "superseded").glob("*.json"))
476
+ self.assertGreaterEqual(len(superseded_files), 1)
477
+ superseded_text = "\n".join(path.read_text(encoding="utf-8") for path in superseded_files)
478
+ self.assertIn("overlapped by newer archive", superseded_text)
479
+ self.assertTrue((knowledge / "views" / "superseded-items.md").exists())
480
+
481
+ def test_conflicting_entries_are_marked_and_reported(self) -> None:
482
+ with tempfile.TemporaryDirectory() as tmp:
483
+ project = self.make_project(Path(tmp))
484
+ self.add_conflicting_archive(project)
485
+
486
+ result = self.run_cli("ingest", "--project", str(project))
487
+
488
+ self.assertEqual(result.returncode, 0, result.stderr)
489
+ knowledge = project / ".harness" / "knowledge"
490
+ index = json.loads((knowledge / "index.json").read_text(encoding="utf-8"))
491
+ self.assertGreaterEqual(index["stats"]["conflicted"], 2)
492
+ conflicted_files = list((knowledge / "entries" / "conflicted").glob("*.json"))
493
+ self.assertGreaterEqual(len(conflicted_files), 2)
494
+ conflicted_text = "\n".join(path.read_text(encoding="utf-8") for path in conflicted_files)
495
+ self.assertIn("potential conflict with", conflicted_text)
496
+ self.assertIn("conflictsWith", conflicted_text)
497
+ view = knowledge / "views" / "conflicted-items.md"
498
+ self.assertTrue(view.exists())
499
+ self.assertIn("Harness Conflicted Knowledge", view.read_text(encoding="utf-8"))
500
+
501
+ audit = self.run_cli("audit", "--project", str(project), "--limit", "5")
502
+ self.assertEqual(audit.returncode, 0, audit.stderr)
503
+ payload = json.loads(audit.stdout)
504
+ self.assertGreaterEqual(len(payload["conflictReview"]), 1)
505
+ report_text = Path(payload["report"]).read_text(encoding="utf-8")
506
+ self.assertIn("Conflict Review", report_text)
507
+
508
+ def test_promote_moves_candidate_to_active_and_updates_index(self) -> None:
509
+ with tempfile.TemporaryDirectory() as tmp:
510
+ project = self.make_project(Path(tmp))
511
+ ingest = self.run_cli("ingest", "--project", str(project))
512
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
513
+ index_path = project / ".harness" / "knowledge" / "index.json"
514
+ index = json.loads(index_path.read_text(encoding="utf-8"))
515
+ entry_id = next(entry["id"] for entry in index["entries"] if entry["status"] == "candidate")
516
+
517
+ promoted = self.run_cli(
518
+ "promote",
519
+ "--project",
520
+ str(project),
521
+ "--id",
522
+ entry_id,
523
+ "--note",
524
+ "人工确认可作为新需求上下文",
525
+ )
526
+
527
+ self.assertEqual(promoted.returncode, 0, promoted.stderr)
528
+ payload = json.loads(promoted.stdout)
529
+ self.assertEqual(payload["status"], "active")
530
+ active_files = list((project / ".harness" / "knowledge" / "entries" / "active").glob("*.json"))
531
+ self.assertEqual(len(active_files), 1)
532
+ active = json.loads(active_files[0].read_text(encoding="utf-8"))
533
+ self.assertEqual(active["id"], entry_id)
534
+ self.assertEqual(active["status"], "active")
535
+ self.assertEqual(active["lifecycle"]["promotionNote"], "人工确认可作为新需求上下文")
536
+ refreshed_index = json.loads(index_path.read_text(encoding="utf-8"))
537
+ self.assertEqual(refreshed_index["stats"]["active"], 1)
538
+
539
+ def test_ingest_writes_explainable_confidence_scores(self) -> None:
540
+ with tempfile.TemporaryDirectory() as tmp:
541
+ project = self.make_project(Path(tmp))
542
+
543
+ ingest = self.run_cli("ingest", "--project", str(project))
544
+
545
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
546
+ knowledge = project / ".harness" / "knowledge"
547
+ entries = [
548
+ json.loads(path.read_text(encoding="utf-8"))
549
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
550
+ ]
551
+ self.assertTrue(entries)
552
+ for entry in entries:
553
+ confidence = entry.get("confidence")
554
+ self.assertIsInstance(confidence, dict)
555
+ self.assertIsInstance(confidence.get("score"), float)
556
+ self.assertIn(confidence.get("level"), {"low", "medium", "high"})
557
+ self.assertIsInstance(confidence.get("signals"), list)
558
+ self.assertTrue(confidence["signals"])
559
+ index = json.loads((knowledge / "index.json").read_text(encoding="utf-8"))
560
+ self.assertIn("confidence", index["entries"][0])
561
+
562
+ def test_configured_auto_promote_only_activates_high_confidence_long_lived_entries(self) -> None:
563
+ with tempfile.TemporaryDirectory() as tmp:
564
+ project = self.make_project(Path(tmp))
565
+ knowledge = project / ".harness" / "knowledge"
566
+ write_json(
567
+ knowledge / "config.json",
568
+ {
569
+ "autoPromote": {
570
+ "enabled": True,
571
+ "minConfidence": 0.82,
572
+ "allowedTypes": ["requirement", "decision", "api-contract", "pitfall"],
573
+ "maxPerRun": 10,
574
+ }
575
+ },
576
+ )
577
+
578
+ result = self.run_cli("ingest", "--project", str(project))
579
+
580
+ self.assertEqual(result.returncode, 0, result.stderr)
581
+ payload = json.loads(result.stdout)
582
+ self.assertGreaterEqual(payload["ingestMode"]["candidateAutoPromoted"], 1)
583
+ active_entries = [
584
+ json.loads(path.read_text(encoding="utf-8"))
585
+ for path in (knowledge / "entries" / "active").glob("*.json")
586
+ ]
587
+ self.assertTrue(active_entries)
588
+ for entry in active_entries:
589
+ self.assertIn(entry["type"], {"requirement", "decision", "api-contract", "pitfall"})
590
+ self.assertGreaterEqual(entry["confidence"]["score"], 0.82)
591
+ self.assertTrue(entry["lifecycle"]["autoPromoted"])
592
+ self.assertIn("autoPromote", entry["lifecycle"]["promotionNote"])
593
+ self.assertFalse(any(entry["type"] == "implementation" for entry in active_entries))
594
+
595
+ def test_confidence_penalizes_stale_and_old_entries(self) -> None:
596
+ with tempfile.TemporaryDirectory() as tmp:
597
+ project = self.make_project(Path(tmp))
598
+ self.add_old_archive_without_commit(project)
599
+ knowledge = project / ".harness" / "knowledge"
600
+ write_json(knowledge / "config.json", {"staleTtlDays": 1})
601
+
602
+ result = self.run_cli("ingest", "--project", str(project))
603
+
604
+ self.assertEqual(result.returncode, 0, result.stderr)
605
+ stale_entries = [
606
+ json.loads(path.read_text(encoding="utf-8"))
607
+ for path in (knowledge / "entries" / "stale").glob("*.json")
608
+ ]
609
+ expired = next(
610
+ entry
611
+ for entry in stale_entries
612
+ if any("ttl expired" in reason for reason in entry["lifecycle"].get("staleReasons", []))
613
+ )
614
+ self.assertLess(expired["confidence"]["score"], 0.55)
615
+ self.assertIn("status_stale_penalty", expired["confidence"]["signals"])
616
+ self.assertTrue(any(signal.startswith("age_penalty:") for signal in expired["confidence"]["signals"]))
617
+
618
+ def test_auto_creates_enabled_autopromote_config_and_applies_when_index_is_current(self) -> None:
619
+ with tempfile.TemporaryDirectory() as tmp:
620
+ project = self.make_project(Path(tmp))
621
+ initial = self.run_cli("ingest", "--project", str(project))
622
+ self.assertEqual(initial.returncode, 0, initial.stderr)
623
+ initial_payload = json.loads(initial.stdout)
624
+ self.assertEqual(initial_payload["ingestMode"]["candidateAutoPromoted"], 0)
625
+ knowledge = project / ".harness" / "knowledge"
626
+ config_path = knowledge / "config.json"
627
+ self.assertFalse(config_path.exists())
628
+
629
+ result = self.run_cli("auto", "--project", str(project), "--limit", "5")
630
+
631
+ self.assertEqual(result.returncode, 0, result.stderr)
632
+ payload = json.loads(result.stdout)
633
+ self.assertTrue(payload["config"]["created"])
634
+ self.assertEqual(payload["config"]["path"], str(config_path))
635
+ config = json.loads(config_path.read_text(encoding="utf-8"))
636
+ self.assertTrue(config["autoPromote"]["enabled"])
637
+ index = json.loads((knowledge / "index.json").read_text(encoding="utf-8"))
638
+ self.assertGreaterEqual(index["ingestMode"]["candidateAutoPromoted"], 1)
639
+ self.assertGreaterEqual(index["stats"]["active"], 1)
640
+ active_entries = [
641
+ json.loads(path.read_text(encoding="utf-8"))
642
+ for path in (knowledge / "entries" / "active").glob("*.json")
643
+ ]
644
+ self.assertTrue(active_entries)
645
+ self.assertTrue(any(entry["lifecycle"].get("autoPromoted") for entry in active_entries))
646
+
647
+ def test_active_review_flags_promoted_entry_without_demoting_it(self) -> None:
648
+ with tempfile.TemporaryDirectory() as tmp:
649
+ project = self.make_project(Path(tmp))
650
+ ingest = self.run_cli("ingest", "--project", str(project))
651
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
652
+ knowledge = project / ".harness" / "knowledge"
653
+ candidate_entries = [
654
+ json.loads(path.read_text(encoding="utf-8"))
655
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
656
+ ]
657
+ entry_id = next(entry["id"] for entry in candidate_entries if "唯一来源" in entry["body"])
658
+ promoted = self.run_cli(
659
+ "promote",
660
+ "--project",
661
+ str(project),
662
+ "--id",
663
+ entry_id,
664
+ "--note",
665
+ "人工确认 draft.aiChecks 当时是唯一来源",
666
+ )
667
+ self.assertEqual(promoted.returncode, 0, promoted.stderr)
668
+ self.add_conflicting_archive(project)
669
+
670
+ ingest_after_conflict = self.run_cli("ingest", "--project", str(project))
671
+ self.assertEqual(ingest_after_conflict.returncode, 0, ingest_after_conflict.stderr)
672
+ audit = self.run_cli("audit", "--project", str(project), "--limit", "5")
673
+
674
+ self.assertEqual(audit.returncode, 0, audit.stderr)
675
+ payload = json.loads(audit.stdout)
676
+ self.assertGreaterEqual(len(payload["activeReview"]), 1)
677
+ self.assertEqual(payload["activeReview"][0]["status"], "active")
678
+ self.assertIn("requires manual review", " ".join(payload["activeReview"][0]["reviewReasons"]))
679
+ active = json.loads(next((knowledge / "entries" / "active").glob("*.json")).read_text(encoding="utf-8"))
680
+ self.assertEqual(active["status"], "active")
681
+ view = knowledge / "views" / "active-review.md"
682
+ self.assertTrue(view.exists())
683
+ self.assertIn("Harness Active Review", view.read_text(encoding="utf-8"))
684
+
685
+ def test_demote_moves_active_entry_to_stale_after_manual_review(self) -> None:
686
+ with tempfile.TemporaryDirectory() as tmp:
687
+ project = self.make_project(Path(tmp))
688
+ ingest = self.run_cli("ingest", "--project", str(project))
689
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
690
+ index_path = project / ".harness" / "knowledge" / "index.json"
691
+ index = json.loads(index_path.read_text(encoding="utf-8"))
692
+ entry_id = next(entry["id"] for entry in index["entries"] if entry["status"] == "candidate")
693
+ promoted = self.run_cli(
694
+ "promote",
695
+ "--project",
696
+ str(project),
697
+ "--id",
698
+ entry_id,
699
+ "--note",
700
+ "人工确认可作为新需求上下文",
701
+ )
702
+ self.assertEqual(promoted.returncode, 0, promoted.stderr)
703
+
704
+ demoted = self.run_cli(
705
+ "demote",
706
+ "--project",
707
+ str(project),
708
+ "--id",
709
+ entry_id,
710
+ "--status",
711
+ "stale",
712
+ "--reason",
713
+ "后续归档替代,人工确认降级",
714
+ )
715
+
716
+ self.assertEqual(demoted.returncode, 0, demoted.stderr)
717
+ payload = json.loads(demoted.stdout)
718
+ self.assertEqual(payload["status"], "stale")
719
+ self.assertFalse(list((project / ".harness" / "knowledge" / "entries" / "active").glob("*.json")))
720
+ stale_files = list((project / ".harness" / "knowledge" / "entries" / "stale").glob("*.json"))
721
+ self.assertEqual(len(stale_files), 1)
722
+ stale = json.loads(stale_files[0].read_text(encoding="utf-8"))
723
+ self.assertEqual(stale["id"], entry_id)
724
+ self.assertEqual(stale["status"], "stale")
725
+ self.assertEqual(stale["lifecycle"]["demotionReason"], "后续归档替代,人工确认降级")
726
+ self.assertIn("manual demotion: 后续归档替代,人工确认降级", stale["lifecycle"]["staleReasons"])
727
+ refreshed_index = json.loads(index_path.read_text(encoding="utf-8"))
728
+ self.assertEqual(refreshed_index["stats"]["active"], 0)
729
+
730
+ def test_configured_active_auto_demote_moves_reviewed_active_to_stale(self) -> None:
731
+ with tempfile.TemporaryDirectory() as tmp:
732
+ project = self.make_project(Path(tmp))
733
+ ingest = self.run_cli("ingest", "--project", str(project))
734
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
735
+ knowledge = project / ".harness" / "knowledge"
736
+ candidate_entries = [
737
+ json.loads(path.read_text(encoding="utf-8"))
738
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
739
+ ]
740
+ entry_id = next(entry["id"] for entry in candidate_entries if "唯一来源" in entry["body"])
741
+ promoted = self.run_cli(
742
+ "promote",
743
+ "--project",
744
+ str(project),
745
+ "--id",
746
+ entry_id,
747
+ "--note",
748
+ "人工确认 draft.aiChecks 当时是唯一来源",
749
+ )
750
+ self.assertEqual(promoted.returncode, 0, promoted.stderr)
751
+ write_json(
752
+ knowledge / "config.json",
753
+ {
754
+ "activeLifecycle": {
755
+ "autoDemote": True,
756
+ "targetStatus": "stale",
757
+ }
758
+ },
759
+ )
760
+ self.add_conflicting_archive(project)
761
+
762
+ result = self.run_cli("ingest", "--project", str(project))
763
+
764
+ self.assertEqual(result.returncode, 0, result.stderr)
765
+ payload = json.loads(result.stdout)
766
+ self.assertEqual(payload["ingestMode"]["activeAutoDemoted"], 1)
767
+ self.assertFalse(list((knowledge / "entries" / "active").glob("*.json")))
768
+ stale_files = list((knowledge / "entries" / "stale").glob("*.json"))
769
+ stale_entries = [json.loads(path.read_text(encoding="utf-8")) for path in stale_files]
770
+ demoted = next(entry for entry in stale_entries if entry["id"] == entry_id)
771
+ self.assertTrue(demoted["lifecycle"]["autoDemoted"])
772
+ self.assertIn("activeLifecycle auto-demotion", demoted["lifecycle"]["demotionReason"])
773
+ self.assertIn("auto demotion:", "\n".join(demoted["lifecycle"]["staleReasons"]))
774
+
775
+ def test_configured_validators_mark_active_stale_when_symbol_disappears(self) -> None:
776
+ with tempfile.TemporaryDirectory() as tmp:
777
+ project = self.make_project(Path(tmp))
778
+ source_file = project / "apps" / "web" / "components" / "registry.tsx"
779
+ source_file.parent.mkdir(parents=True)
780
+ source_file.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
781
+ ingest = self.run_cli("ingest", "--project", str(project))
782
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
783
+ knowledge = project / ".harness" / "knowledge"
784
+ candidate_entries = [
785
+ json.loads(path.read_text(encoding="utf-8"))
786
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
787
+ ]
788
+ entry_id = next(entry["id"] for entry in candidate_entries if "唯一来源" in entry["body"])
789
+ promoted = self.run_cli(
790
+ "promote",
791
+ "--project",
792
+ str(project),
793
+ "--id",
794
+ entry_id,
795
+ "--note",
796
+ "人工确认 draft.aiChecks 当时是唯一来源",
797
+ )
798
+ self.assertEqual(promoted.returncode, 0, promoted.stderr)
799
+ active_path = next((knowledge / "entries" / "active").glob("*.json"))
800
+ active_entry = json.loads(active_path.read_text(encoding="utf-8"))
801
+ active_entry["validators"] = [
802
+ {
803
+ "type": "symbol_exists",
804
+ "symbol": "draft.aiChecks",
805
+ "files": ["apps/web/components/registry.tsx"],
806
+ "description": "registry view still reads draft.aiChecks",
807
+ }
808
+ ]
809
+ write_json(active_path, active_entry)
810
+ write_json(
811
+ knowledge / "config.json",
812
+ {
813
+ "knowledgeValidation": {
814
+ "enabled": True,
815
+ "autoDemoteActive": True,
816
+ }
817
+ },
818
+ )
819
+
820
+ source_file.write_text("export const source = aiJobResult;\n", encoding="utf-8")
821
+ result = self.run_cli("ingest", "--project", str(project))
822
+
823
+ self.assertEqual(result.returncode, 0, result.stderr)
824
+ payload = json.loads(result.stdout)
825
+ self.assertEqual(payload["ingestMode"]["validationAutoDemoted"], 1)
826
+ self.assertFalse(list((knowledge / "entries" / "active").glob("*.json")))
827
+ stale_entries = [
828
+ json.loads(path.read_text(encoding="utf-8"))
829
+ for path in (knowledge / "entries" / "stale").glob("*.json")
830
+ ]
831
+ demoted = next(entry for entry in stale_entries if entry["id"] == entry_id)
832
+ validation = demoted["lifecycle"]["validation"]
833
+ self.assertEqual(validation["status"], "failed")
834
+ self.assertIn("validator failed:", "\n".join(demoted["lifecycle"]["staleReasons"]))
835
+
836
+ def test_verify_command_reports_validator_results_without_reingesting(self) -> None:
837
+ with tempfile.TemporaryDirectory() as tmp:
838
+ project = self.make_project(Path(tmp))
839
+ source_file = project / "apps" / "web" / "components" / "registry.tsx"
840
+ source_file.parent.mkdir(parents=True)
841
+ source_file.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
842
+ ingest = self.run_cli("ingest", "--project", str(project))
843
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
844
+ knowledge = project / ".harness" / "knowledge"
845
+ candidate_path = next((knowledge / "entries" / "candidate").glob("*.json"))
846
+ entry = json.loads(candidate_path.read_text(encoding="utf-8"))
847
+ entry["validators"] = [
848
+ {
849
+ "type": "file_contains",
850
+ "path": "apps/web/components/registry.tsx",
851
+ "pattern": "draft.aiChecks",
852
+ "description": "registry view still contains draft.aiChecks",
853
+ }
854
+ ]
855
+ write_json(candidate_path, entry)
856
+
857
+ result = self.run_cli("verify", "--project", str(project))
858
+
859
+ self.assertEqual(result.returncode, 0, result.stderr)
860
+ payload = json.loads(result.stdout)
861
+ self.assertEqual(payload["checked"], 1)
862
+ self.assertEqual(payload["passed"], 1)
863
+ self.assertEqual(payload["failed"], 0)
864
+ self.assertEqual(payload["entries"][0]["id"], entry["id"])
865
+ reports = list((knowledge / "reports").glob("verification-report-*.md"))
866
+ self.assertEqual(len(reports), 1)
867
+ self.assertIn("registry view still contains", reports[0].read_text(encoding="utf-8"))
868
+
869
+ def test_verify_preserves_sqlite_entries_without_file_copies(self) -> None:
870
+ with tempfile.TemporaryDirectory() as tmp:
871
+ project = self.make_project(Path(tmp))
872
+ ingest = self.run_cli("ingest", "--project", str(project))
873
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
874
+ knowledge = project / ".harness" / "knowledge"
875
+ sqlite_path = knowledge / "index.sqlite"
876
+ entry_path = next((knowledge / "entries" / "candidate").glob("*.json"))
877
+ entry = json.loads(entry_path.read_text(encoding="utf-8"))
878
+ virtual = json.loads(json.dumps(entry, ensure_ascii=False))
879
+ virtual["id"] = entry["id"] + ".virtual"
880
+ virtual["title"] = entry["title"] + " virtual"
881
+ virtual["summary"] = entry["summary"] + " virtual"
882
+
883
+ con = sqlite3.connect(sqlite_path)
884
+ try:
885
+ con.execute(
886
+ """
887
+ insert into entries (
888
+ id, project_id, type, status, title, summary, body, source_archive,
889
+ source_commit, source_files_json, keywords_json, entry_json
890
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
891
+ """,
892
+ (
893
+ virtual["id"],
894
+ virtual["projectId"],
895
+ virtual["type"],
896
+ virtual["status"],
897
+ virtual["title"],
898
+ virtual["summary"],
899
+ virtual["body"],
900
+ virtual["source"]["archive"],
901
+ virtual["source"]["sourceCommit"],
902
+ json.dumps(virtual["scope"]["sourceFiles"], ensure_ascii=False),
903
+ json.dumps(virtual["keywords"], ensure_ascii=False),
904
+ json.dumps(virtual, ensure_ascii=False),
905
+ ),
906
+ )
907
+ con.commit()
908
+ finally:
909
+ con.close()
910
+
911
+ result = self.run_cli("verify", "--project", str(project))
912
+
913
+ self.assertEqual(result.returncode, 0, result.stderr)
914
+ index = json.loads((knowledge / "index.json").read_text(encoding="utf-8"))
915
+ self.assertTrue(any(item["id"] == virtual["id"] for item in index["entries"]))
916
+
917
+ def test_suggest_validators_reports_candidates_without_applying(self) -> None:
918
+ with tempfile.TemporaryDirectory() as tmp:
919
+ project = self.make_project(Path(tmp))
920
+ source_file = project / "apps" / "web" / "components" / "registry.tsx"
921
+ source_file.parent.mkdir(parents=True)
922
+ source_file.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
923
+ ingest = self.run_cli("ingest", "--project", str(project))
924
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
925
+ knowledge = project / ".harness" / "knowledge"
926
+ candidate_path = next((knowledge / "entries" / "candidate").glob("*.json"))
927
+ before = json.loads(candidate_path.read_text(encoding="utf-8"))
928
+ self.assertNotIn("validators", before)
929
+
930
+ result = self.run_cli("suggest-validators", "--project", str(project), "--limit", "5")
931
+
932
+ self.assertEqual(result.returncode, 0, result.stderr)
933
+ payload = json.loads(result.stdout)
934
+ self.assertGreaterEqual(payload["suggested"], 1)
935
+ self.assertEqual(payload["applied"], 0)
936
+ suggestion_text = json.dumps(payload["entries"], ensure_ascii=False)
937
+ self.assertIn("file_exists", suggestion_text)
938
+ self.assertIn("draft.aiChecks", suggestion_text)
939
+ after = json.loads(candidate_path.read_text(encoding="utf-8"))
940
+ self.assertNotIn("validators", after)
941
+ reports = list((knowledge / "reports").glob("validator-suggestions-*.md"))
942
+ self.assertEqual(len(reports), 1)
943
+
944
+ def test_suggest_validators_apply_enables_verify_to_check_entries(self) -> None:
945
+ with tempfile.TemporaryDirectory() as tmp:
946
+ project = self.make_project(Path(tmp))
947
+ source_file = project / "apps" / "web" / "components" / "registry.tsx"
948
+ source_file.parent.mkdir(parents=True)
949
+ source_file.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
950
+ ingest = self.run_cli("ingest", "--project", str(project))
951
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
952
+ knowledge = project / ".harness" / "knowledge"
953
+
954
+ suggested = self.run_cli("suggest-validators", "--project", str(project), "--limit", "5", "--apply")
955
+ self.assertEqual(suggested.returncode, 0, suggested.stderr)
956
+ suggestion_payload = json.loads(suggested.stdout)
957
+ self.assertGreaterEqual(suggestion_payload["applied"], 1)
958
+ verify = self.run_cli("verify", "--project", str(project))
959
+
960
+ self.assertEqual(verify.returncode, 0, verify.stderr)
961
+ verify_payload = json.loads(verify.stdout)
962
+ self.assertGreaterEqual(verify_payload["checked"], 1)
963
+ self.assertGreaterEqual(verify_payload["passed"], 1)
964
+ applied_entries = [
965
+ json.loads(path.read_text(encoding="utf-8"))
966
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
967
+ if "validators" in json.loads(path.read_text(encoding="utf-8"))
968
+ ]
969
+ self.assertTrue(applied_entries)
970
+
971
+ def test_auto_runs_safe_default_workflow_without_applying_suggestions(self) -> None:
972
+ with tempfile.TemporaryDirectory() as tmp:
973
+ project = self.make_project(Path(tmp))
974
+ source_file = project / "apps" / "web" / "components" / "registry.tsx"
975
+ source_file.parent.mkdir(parents=True)
976
+ source_file.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
977
+
978
+ result = self.run_cli("auto", "--project", str(project), "--limit", "5")
979
+
980
+ self.assertEqual(result.returncode, 0, result.stderr)
981
+ payload = json.loads(result.stdout)
982
+ self.assertTrue(payload["sync"]["upToDate"])
983
+ self.assertGreaterEqual(payload["suggestions"]["suggested"], 1)
984
+ self.assertEqual(payload["suggestions"]["applied"], 0)
985
+ self.assertEqual(payload["verification"]["checked"], 0)
986
+ self.assertIn("candidateReview", payload["audit"])
987
+ knowledge = project / ".harness" / "knowledge"
988
+ entries_with_validators = [
989
+ path
990
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
991
+ if "validators" in json.loads(path.read_text(encoding="utf-8"))
992
+ ]
993
+ self.assertFalse(entries_with_validators)
994
+
995
+ def test_auto_apply_suggestions_writes_validators_and_verifies_them(self) -> None:
996
+ with tempfile.TemporaryDirectory() as tmp:
997
+ project = self.make_project(Path(tmp))
998
+ source_file = project / "apps" / "web" / "components" / "registry.tsx"
999
+ source_file.parent.mkdir(parents=True)
1000
+ source_file.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
1001
+
1002
+ result = self.run_cli(
1003
+ "auto",
1004
+ "--project",
1005
+ str(project),
1006
+ "--limit",
1007
+ "5",
1008
+ "--apply-suggestions",
1009
+ "--suggest-status",
1010
+ "candidate",
1011
+ )
1012
+
1013
+ self.assertEqual(result.returncode, 0, result.stderr)
1014
+ payload = json.loads(result.stdout)
1015
+ self.assertGreaterEqual(payload["suggestions"]["applied"], 1)
1016
+ self.assertGreaterEqual(payload["verification"]["checked"], 1)
1017
+ self.assertGreaterEqual(payload["verification"]["passed"], 1)
1018
+ self.assertTrue(payload["sync"]["upToDate"])
1019
+
1020
+ def test_mcp_evaluation_fixture_is_stable_and_queryable(self) -> None:
1021
+ self.assertTrue(EVALUATION_XML.exists())
1022
+ root = ET.parse(EVALUATION_XML).getroot()
1023
+ pairs = root.findall("qa_pair")
1024
+ self.assertEqual(len(pairs), 10)
1025
+ for pair in pairs:
1026
+ self.assertTrue(pair.findtext("question", "").strip())
1027
+ self.assertTrue(pair.findtext("answer", "").strip())
1028
+
1029
+ with tempfile.TemporaryDirectory() as tmp:
1030
+ project = Path(tmp) / "mcp-eval-project"
1031
+ shutil.copytree(FIXTURE_PROJECT, project)
1032
+ ingest = self.run_cli("ingest", "--project", str(project))
1033
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
1034
+ query = self.run_cli(
1035
+ "query",
1036
+ "--project",
1037
+ str(project),
1038
+ "--query",
1039
+ "billing reconciliation source of truth",
1040
+ "--limit",
1041
+ "5",
1042
+ )
1043
+ self.assertEqual(query.returncode, 0, query.stderr)
1044
+ payload = json.loads(query.stdout)
1045
+ text = json.dumps(payload, ensure_ascii=False)
1046
+ self.assertIn("LedgerReconciler", text)
1047
+
1048
+ def test_git_repo_missing_source_commit_has_specific_stale_reason(self) -> None:
1049
+ with tempfile.TemporaryDirectory() as tmp:
1050
+ project = self.make_project(Path(tmp), final_commit="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
1051
+ (project / "apps" / "server" / "src" / "registry").mkdir(parents=True)
1052
+ (project / "apps" / "server" / "src" / "registry" / "store.ts").write_text("export const x = 1;\n", encoding="utf-8")
1053
+ self.assertEqual(self.run_git(project, "init").returncode, 0)
1054
+ self.assertEqual(self.run_git(project, "config", "user.email", "test@example.com").returncode, 0)
1055
+ self.assertEqual(self.run_git(project, "config", "user.name", "Test User").returncode, 0)
1056
+ self.assertEqual(self.run_git(project, "add", ".").returncode, 0)
1057
+ self.assertEqual(self.run_git(project, "commit", "-m", "initial").returncode, 0)
1058
+
1059
+ result = self.run_cli("ingest", "--project", str(project))
1060
+
1061
+ self.assertEqual(result.returncode, 0, result.stderr)
1062
+ stale_files = list((project / ".harness" / "knowledge" / "entries" / "stale").glob("*.json"))
1063
+ self.assertGreaterEqual(len(stale_files), 1)
1064
+ stale_text = "\n".join(path.read_text(encoding="utf-8") for path in stale_files)
1065
+ self.assertIn("source commit missing from local git history", stale_text)
1066
+
1067
+ def add_long_name_archive(self, project: Path) -> None:
1068
+ archive = (
1069
+ project
1070
+ / ".harness"
1071
+ / "archive"
1072
+ / "2026-07-01-skill-center-per-agent-version-implementation-long-name-archive"
1073
+ / "reports"
1074
+ / "final"
1075
+ / "summary-data.json"
1076
+ )
1077
+ write_json(
1078
+ archive,
1079
+ {
1080
+ "schemaVersion": "2.1",
1081
+ "changeName": "skill-center-per-agent-version-implementation-long-name-archive",
1082
+ "businessGoal": "长归档名验证 entry 文件名末尾哈希不被 72 字符截断。",
1083
+ "finalStatus": "OK",
1084
+ "finalCommit": "long1234",
1085
+ "baseCommit": "base0001",
1086
+ "changedFiles": [
1087
+ {
1088
+ "path": "apps/server/src/skill/center/per-agent-store.ts",
1089
+ "summary": "per-agent skill 状态机字段",
1090
+ },
1091
+ {
1092
+ "path": "apps/server/src/skill/center/per-agent-router.ts",
1093
+ "summary": "per-agent skill 路由入口",
1094
+ },
1095
+ ],
1096
+ "maintenanceNotes": [],
1097
+ "knownRisks": [],
1098
+ "manualActions": [],
1099
+ },
1100
+ )
1101
+
1102
+ def _import_harness_knowledge(self):
1103
+ import importlib
1104
+
1105
+ sys.path.insert(0, str(SCRIPT.parent))
1106
+ try:
1107
+ import harness_knowledge
1108
+
1109
+ return importlib.reload(harness_knowledge)
1110
+ finally:
1111
+ sys.path.pop(0)
1112
+
1113
+ def test_long_archive_name_does_not_collapse_entries_into_one_file(self) -> None:
1114
+ with tempfile.TemporaryDirectory() as tmp:
1115
+ project = Path(tmp) / "long-name-project"
1116
+ self.add_long_name_archive(project)
1117
+
1118
+ result = self.run_cli("ingest", "--project", str(project))
1119
+
1120
+ self.assertEqual(result.returncode, 0, result.stderr)
1121
+ knowledge = project / ".harness" / "knowledge"
1122
+ index = json.loads((knowledge / "index.json").read_text(encoding="utf-8"))
1123
+ manifest_count = len(index["entries"])
1124
+ self.assertGreaterEqual(manifest_count, 2)
1125
+ disk_count = sum(1 for _ in (knowledge / "entries").rglob("*.json"))
1126
+ self.assertEqual(
1127
+ disk_count,
1128
+ manifest_count,
1129
+ f"filename collision: disk={disk_count} < manifest={manifest_count}",
1130
+ )
1131
+
1132
+ def test_collision_guard_records_failure_without_silent_overwrite(self) -> None:
1133
+ from unittest import mock
1134
+
1135
+ module = self._import_harness_knowledge()
1136
+ with tempfile.TemporaryDirectory() as tmp:
1137
+ project = Path(tmp) / "long-name-project"
1138
+ self.add_long_name_archive(project)
1139
+
1140
+ with mock.patch.object(
1141
+ module, "entry_filename", lambda entry: "collision.json", create=True
1142
+ ):
1143
+ index = module.build_index(project, incremental=False)
1144
+
1145
+ failures = index.get("failures", [])
1146
+ collision_failures = [
1147
+ f for f in failures if f.get("reason") == "filename collision"
1148
+ ]
1149
+ self.assertGreaterEqual(
1150
+ len(collision_failures),
1151
+ 1,
1152
+ f"expected filename collision failure, got: {failures}",
1153
+ )
1154
+ collision_files = list(
1155
+ (project / ".harness" / "knowledge" / "entries").rglob("collision.json")
1156
+ )
1157
+ self.assertEqual(len(collision_files), 1)
1158
+
1159
+ def test_entry_filename_preserves_full_hash_suffix(self) -> None:
1160
+ module = self._import_harness_knowledge()
1161
+ self.assertTrue(hasattr(module, "entry_filename"), "entry_filename helper missing")
1162
+ name = module.entry_filename(
1163
+ {"id": "proj.2026-07-01-long-arch.impl.58063efb81"}
1164
+ )
1165
+ self.assertTrue(name.endswith("-58063efb81.json"), f"hash suffix lost: {name}")
1166
+
1167
+ def test_entry_filename_keeps_hash_when_prefix_exceeds_limit(self) -> None:
1168
+ module = self._import_harness_knowledge()
1169
+ long_prefix = "proj." + ("a" * 85)
1170
+ entry_id = long_prefix + ".impl.abc123def0"
1171
+ name = module.entry_filename({"id": entry_id})
1172
+ self.assertTrue(name.endswith("-abc123def0.json"), f"hash lost when prefix>72: {name}")
1173
+
1174
+ def test_entry_filename_falls_back_when_id_has_no_hash_segment(self) -> None:
1175
+ module = self._import_harness_knowledge()
1176
+ name = module.entry_filename({"id": "nohash"})
1177
+ hash_part = name.rsplit("-", 1)[-1].removesuffix(".json")
1178
+ self.assertEqual(len(hash_part), 10, f"expected 10-char fallback hash: {name}")
1179
+
1180
+ def test_entry_filename_falls_back_when_hash_segment_too_short(self) -> None:
1181
+ module = self._import_harness_knowledge()
1182
+ name = module.entry_filename({"id": "proj.arch.impl.ab"})
1183
+ hash_part = name.rsplit("-", 1)[-1].removesuffix(".json")
1184
+ self.assertEqual(len(hash_part), 10, f"expected fallback hash for short segment: {name}")
1185
+
1186
+ def test_entry_filename_sanitizes_digest_with_special_chars(self) -> None:
1187
+ module = self._import_harness_knowledge()
1188
+ # 末段 "ab<cd>e" 长度 6(≥6)但含文件系统非法字符 <>,应被清理而非原样拼入
1189
+ name = module.entry_filename({"id": "proj.arch.impl.ab<cd>e"})
1190
+ self.assertNotIn("<", name, f"digest contains illegal '<': {name}")
1191
+ self.assertNotIn(">", name, f"digest contains illegal '>': {name}")
1192
+ self.assertTrue(name.endswith(".json"), f"missing .json suffix: {name}")
1193
+
1194
+ def _write_entry(self, knowledge: Path, entry: dict) -> Path:
1195
+ status = entry["status"]
1196
+ path = knowledge / "entries" / status / f"{entry['id'].replace('.', '-')}.json"
1197
+ write_json(path, entry)
1198
+ return path
1199
+
1200
+ def _minimal_entry(
1201
+ self,
1202
+ entry_id: str,
1203
+ *,
1204
+ status: str = "candidate",
1205
+ entry_type: str = "decision",
1206
+ title: str = "title",
1207
+ body: str = "body",
1208
+ archive: str = ".harness/archive/2026-06-30-sample",
1209
+ source_files: list[str] | None = None,
1210
+ keywords: list[str] | None = None,
1211
+ ) -> dict:
1212
+ return {
1213
+ "schemaVersion": 1,
1214
+ "id": entry_id,
1215
+ "projectId": "sample",
1216
+ "type": entry_type,
1217
+ "status": status,
1218
+ "title": title,
1219
+ "summary": body[:40],
1220
+ "body": body,
1221
+ "keywords": keywords or ["sample"],
1222
+ "source": {
1223
+ "archive": archive,
1224
+ "summaryData": archive + "/reports/final/summary-data.json",
1225
+ "summarySha256": "abc",
1226
+ "sourceCommit": "",
1227
+ "baseCommit": "",
1228
+ "changeName": "sample",
1229
+ "finalStatus": "OK",
1230
+ },
1231
+ "scope": {"sourceFiles": source_files or ["apps/server/src/registry/store.ts"]},
1232
+ "lifecycle": {
1233
+ "createdAt": "2026-06-30T00:00:00+08:00",
1234
+ "verifiedAt": "2026-06-30T00:00:00+08:00",
1235
+ "lastCheckedAt": "2026-06-30T00:00:00+08:00",
1236
+ "confidence": "medium",
1237
+ "supersedes": [],
1238
+ "supersededBy": None,
1239
+ "conflictsWith": [],
1240
+ "staleReasons": [],
1241
+ },
1242
+ }
1243
+
1244
+ def test_dedupe_merges_near_duplicates_and_keeps_provenance_union(self) -> None:
1245
+ with tempfile.TemporaryDirectory() as tmp:
1246
+ project = self.make_project(Path(tmp))
1247
+ ingest = self.run_cli("ingest", "--project", str(project))
1248
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
1249
+ knowledge = project / ".harness" / "knowledge"
1250
+ base = self._minimal_entry(
1251
+ "sample.dup.decision.aaaaaaaaaa",
1252
+ title="AI job 复用 LlmClient",
1253
+ body="AI 检查 job 复用 LlmClient,不新增 provider 抽象。",
1254
+ keywords=["llm"],
1255
+ source_files=["apps/server/src/registry/store.ts"],
1256
+ )
1257
+ near = self._minimal_entry(
1258
+ "sample.dup.decision.bbbbbbbbbb",
1259
+ title="AI job 复用 LlmClient",
1260
+ body="AI 检查 job 复用 LlmClient,不新增 provider 抽象层。",
1261
+ keywords=["client"],
1262
+ source_files=["apps/web/components/registry.tsx"],
1263
+ )
1264
+ self._write_entry(knowledge, base)
1265
+ self._write_entry(knowledge, near)
1266
+ # Refresh index so CLI can load the planted entries.
1267
+ module = self._import_harness_knowledge()
1268
+ module.refresh_outputs_from_entry_files(project, knowledge)
1269
+
1270
+ result = self.run_cli("dedupe", "--project", str(project), "--json")
1271
+
1272
+ self.assertEqual(result.returncode, 0, result.stderr)
1273
+ payload = json.loads(result.stdout)
1274
+ self.assertGreaterEqual(payload["merged"], 1)
1275
+ superseded = [
1276
+ json.loads(path.read_text(encoding="utf-8"))
1277
+ for path in (knowledge / "entries" / "superseded").glob("*.json")
1278
+ ]
1279
+ merged = [entry for entry in superseded if "near-duplicate merged into" in "\n".join(entry["lifecycle"].get("staleReasons", []))]
1280
+ self.assertGreaterEqual(len(merged), 1)
1281
+ kept_id = merged[0]["lifecycle"]["supersededBy"]
1282
+ kept_path = next(
1283
+ path
1284
+ for status in ["candidate", "active", "stale", "conflicted"]
1285
+ for path in (knowledge / "entries" / status).glob("*.json")
1286
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == kept_id
1287
+ )
1288
+ kept = json.loads(kept_path.read_text(encoding="utf-8"))
1289
+ self.assertTrue({"llm", "client"} <= set(kept.get("keywords") or []))
1290
+ self.assertTrue(
1291
+ {"apps/server/src/registry/store.ts", "apps/web/components/registry.tsx"}
1292
+ <= set(kept.get("scope", {}).get("sourceFiles") or [])
1293
+ )
1294
+ self.assertIn(merged[0]["id"], kept["lifecycle"].get("mergedFrom") or [])
1295
+
1296
+ def test_auto_supersede_only_touches_clear_same_topic_evolution(self) -> None:
1297
+ with tempfile.TemporaryDirectory() as tmp:
1298
+ project = Path(tmp) / "auto-supersede-project"
1299
+ knowledge = project / ".harness" / "knowledge"
1300
+ for status in ["candidate", "active", "stale", "superseded", "conflicted"]:
1301
+ (knowledge / "entries" / status).mkdir(parents=True, exist_ok=True)
1302
+ write_json(knowledge / "index.json", {"schemaVersion": 1, "entries": [], "stats": {}})
1303
+ older = self._minimal_entry(
1304
+ "sample.topic.decision.old0000001",
1305
+ title="registry store 状态字段",
1306
+ body="registry store 使用旧版 AI job 状态字段。",
1307
+ archive=".harness/archive/2026-06-01-old-topic",
1308
+ source_files=["apps/server/src/registry/store.ts"],
1309
+ )
1310
+ newer = self._minimal_entry(
1311
+ "sample.topic.decision.new0000001",
1312
+ title="registry store 状态字段",
1313
+ body="registry store 使用新版 AI job 状态字段,兼容旧字段。",
1314
+ archive=".harness/archive/2026-07-01-new-topic",
1315
+ source_files=["apps/server/src/registry/store.ts", "apps/web/components/registry.tsx"],
1316
+ )
1317
+ unrelated = self._minimal_entry(
1318
+ "sample.topic.risk.other00001",
1319
+ entry_type="risk",
1320
+ title="billing 风险",
1321
+ body="计费 provider key 需要单独治理。",
1322
+ archive=".harness/archive/2026-07-02-other",
1323
+ source_files=["apps/billing/provider.ts"],
1324
+ )
1325
+ conflict_old = self._minimal_entry(
1326
+ "sample.topic.decision.confold01",
1327
+ title="draft.aiChecks 唯一来源",
1328
+ body="draft.aiChecks 是前后端展示结果的唯一来源。",
1329
+ archive=".harness/archive/2026-06-15-conflict-old",
1330
+ source_files=["apps/web/components/registry.tsx"],
1331
+ )
1332
+ conflict_new = self._minimal_entry(
1333
+ "sample.topic.decision.confnew01",
1334
+ title="改为 aiJobResult",
1335
+ body="不再使用 draft.aiChecks 作为唯一来源,改为 server aiJobResult 替代旧展示结果。",
1336
+ archive=".harness/archive/2026-07-03-conflict-new",
1337
+ source_files=["apps/web/components/registry.tsx"],
1338
+ )
1339
+ for entry in [older, newer, unrelated, conflict_old, conflict_new]:
1340
+ self._write_entry(knowledge, entry)
1341
+ module = self._import_harness_knowledge()
1342
+ module.refresh_outputs_from_entry_files(project, knowledge)
1343
+
1344
+ result = self.run_cli("auto-supersede", "--project", str(project), "--json")
1345
+
1346
+ self.assertEqual(result.returncode, 0, result.stderr)
1347
+ payload = json.loads(result.stdout)
1348
+ self.assertGreaterEqual(payload["superseded"], 1)
1349
+ action_ids = {item["id"] for item in payload["actions"]}
1350
+ self.assertIn(older["id"], action_ids)
1351
+ self.assertNotIn(unrelated["id"], action_ids)
1352
+ self.assertNotIn(conflict_old["id"], action_ids)
1353
+ self.assertNotIn(conflict_new["id"], action_ids)
1354
+ older_after = json.loads(
1355
+ next(
1356
+ path
1357
+ for path in (knowledge / "entries" / "superseded").glob("*.json")
1358
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == older["id"]
1359
+ ).read_text(encoding="utf-8")
1360
+ )
1361
+ self.assertEqual(older_after["lifecycle"]["supersededBy"], newer["id"])
1362
+ unrelated_after = json.loads(
1363
+ next(
1364
+ path
1365
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
1366
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == unrelated["id"]
1367
+ ).read_text(encoding="utf-8")
1368
+ )
1369
+ self.assertEqual(unrelated_after["status"], "candidate")
1370
+
1371
+ def test_reverify_stale_restores_or_keeps_stale(self) -> None:
1372
+ with tempfile.TemporaryDirectory() as tmp:
1373
+ project = self.make_project(Path(tmp))
1374
+ source_ok = project / "apps" / "web" / "components" / "registry.tsx"
1375
+ source_ok.parent.mkdir(parents=True)
1376
+ source_ok.write_text("export const source = draft.aiChecks;\n", encoding="utf-8")
1377
+ ingest = self.run_cli("ingest", "--project", str(project))
1378
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
1379
+ knowledge = project / ".harness" / "knowledge"
1380
+
1381
+ restore_entry = self._minimal_entry(
1382
+ "sample.reverify.decision.rest0001",
1383
+ status="stale",
1384
+ title="可恢复 stale",
1385
+ body="文件仍存在时应恢复。",
1386
+ )
1387
+ restore_entry["lifecycle"]["previousStatus"] = "active"
1388
+ restore_entry["lifecycle"]["demotedAt"] = "2026-07-01T00:00:00+08:00"
1389
+ restore_entry["validators"] = [
1390
+ {
1391
+ "type": "file_exists",
1392
+ "path": "apps/web/components/registry.tsx",
1393
+ "description": "registry file exists",
1394
+ },
1395
+ {
1396
+ "type": "file_contains",
1397
+ "path": "apps/web/components/registry.tsx",
1398
+ "pattern": "draft.aiChecks",
1399
+ "description": "contains draft.aiChecks",
1400
+ },
1401
+ ]
1402
+ keep_entry = self._minimal_entry(
1403
+ "sample.reverify.decision.keep0001",
1404
+ status="stale",
1405
+ title="保持 stale",
1406
+ body="文件缺失时应保持 stale。",
1407
+ archive=".harness/archive/2026-07-02-keep-stale",
1408
+ )
1409
+ keep_entry["lifecycle"]["previousStatus"] = "candidate"
1410
+ keep_entry["validators"] = [
1411
+ {
1412
+ "type": "file_exists",
1413
+ "path": "apps/missing/not-there.ts",
1414
+ "description": "missing file",
1415
+ }
1416
+ ]
1417
+ self._write_entry(knowledge, restore_entry)
1418
+ self._write_entry(knowledge, keep_entry)
1419
+ module = self._import_harness_knowledge()
1420
+ module.refresh_outputs_from_entry_files(project, knowledge)
1421
+
1422
+ result = self.run_cli("reverify-stale", "--project", str(project), "--json")
1423
+
1424
+ self.assertEqual(result.returncode, 0, result.stderr)
1425
+ payload = json.loads(result.stdout)
1426
+ self.assertGreaterEqual(payload["restored"], 1)
1427
+ self.assertGreaterEqual(payload["keptStale"], 1)
1428
+ restored_ids = {item["id"] for item in payload["entries"]["restored"]}
1429
+ kept_ids = {item["id"] for item in payload["entries"]["keptStale"]}
1430
+ self.assertIn(restore_entry["id"], restored_ids)
1431
+ self.assertIn(keep_entry["id"], kept_ids)
1432
+ active = json.loads(
1433
+ next(
1434
+ path
1435
+ for path in (knowledge / "entries" / "active").glob("*.json")
1436
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == restore_entry["id"]
1437
+ ).read_text(encoding="utf-8")
1438
+ )
1439
+ self.assertEqual(active["status"], "active")
1440
+ kept = json.loads(
1441
+ next(
1442
+ path
1443
+ for path in (knowledge / "entries" / "stale").glob("*.json")
1444
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == keep_entry["id"]
1445
+ ).read_text(encoding="utf-8")
1446
+ )
1447
+ self.assertIn("reverify failed", "\n".join(kept["lifecycle"].get("staleReasons", [])))
1448
+
1449
+ def test_judge_export_apply_rollback_roundtrip(self) -> None:
1450
+ with tempfile.TemporaryDirectory() as tmp:
1451
+ project = self.make_project(Path(tmp))
1452
+ ingest = self.run_cli("ingest", "--project", str(project))
1453
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
1454
+ knowledge = project / ".harness" / "knowledge"
1455
+ candidate = self._minimal_entry(
1456
+ "sample.judge.decision.cand00001",
1457
+ title="待 promote 候选",
1458
+ body="这条候选知识需要 AI 裁决 promote。",
1459
+ )
1460
+ conflict_a = self._minimal_entry(
1461
+ "sample.judge.decision.confla001",
1462
+ status="conflicted",
1463
+ title="冲突 A",
1464
+ body="draft.aiChecks 是唯一来源。",
1465
+ archive=".harness/archive/2026-06-20-judge-a",
1466
+ )
1467
+ conflict_b = self._minimal_entry(
1468
+ "sample.judge.decision.conflb001",
1469
+ status="conflicted",
1470
+ title="冲突 B",
1471
+ body="不再使用 draft.aiChecks,改为 aiJobResult。",
1472
+ archive=".harness/archive/2026-07-04-judge-b",
1473
+ )
1474
+ conflict_a["lifecycle"]["conflictsWith"] = [conflict_b["id"]]
1475
+ conflict_b["lifecycle"]["conflictsWith"] = [conflict_a["id"]]
1476
+ for entry in [candidate, conflict_a, conflict_b]:
1477
+ self._write_entry(knowledge, entry)
1478
+ module = self._import_harness_knowledge()
1479
+ module.refresh_outputs_from_entry_files(project, knowledge)
1480
+
1481
+ export_path = Path(tmp) / "judge-export.json"
1482
+ exported = self.run_cli(
1483
+ "judge",
1484
+ "--project",
1485
+ str(project),
1486
+ "--export",
1487
+ str(export_path),
1488
+ "--json",
1489
+ )
1490
+ self.assertEqual(exported.returncode, 0, exported.stderr)
1491
+ export_payload = json.loads(exported.stdout)
1492
+ self.assertTrue(export_path.exists())
1493
+ self.assertGreaterEqual(export_payload["counts"]["promoteCandidates"], 1)
1494
+ self.assertGreaterEqual(export_payload["counts"]["conflicts"], 1)
1495
+
1496
+ before_candidate = json.loads(
1497
+ next(
1498
+ path
1499
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
1500
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == candidate["id"]
1501
+ ).read_text(encoding="utf-8")
1502
+ )
1503
+ decisions_path = Path(tmp) / "decisions.json"
1504
+ write_json(
1505
+ decisions_path,
1506
+ {
1507
+ "decisions": [
1508
+ {"id": candidate["id"], "action": "promote", "reason": "AI 确认可提升"},
1509
+ {
1510
+ "id": conflict_a["id"],
1511
+ "action": "supersede",
1512
+ "supersededBy": conflict_b["id"],
1513
+ "reason": "B 取代 A",
1514
+ },
1515
+ {"id": conflict_b["id"], "action": "keep-conflict", "reason": "保留观察"},
1516
+ ]
1517
+ },
1518
+ )
1519
+ applied = self.run_cli(
1520
+ "judge",
1521
+ "--project",
1522
+ str(project),
1523
+ "--apply",
1524
+ str(decisions_path),
1525
+ "--json",
1526
+ )
1527
+ self.assertEqual(applied.returncode, 0, applied.stderr)
1528
+ apply_payload = json.loads(applied.stdout)
1529
+ self.assertEqual(apply_payload["applied"], 3)
1530
+ judgement_path = Path(apply_payload["judgement"])
1531
+ self.assertTrue(judgement_path.exists())
1532
+ judgement = json.loads(judgement_path.read_text(encoding="utf-8"))
1533
+ self.assertEqual(len(judgement["applied"]), 3)
1534
+ self.assertEqual(judgement["applied"][0]["before"]["status"], "candidate")
1535
+
1536
+ promoted = json.loads(
1537
+ next(
1538
+ path
1539
+ for path in (knowledge / "entries" / "active").glob("*.json")
1540
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == candidate["id"]
1541
+ ).read_text(encoding="utf-8")
1542
+ )
1543
+ self.assertEqual(promoted["status"], "active")
1544
+
1545
+ rolled = self.run_cli(
1546
+ "rollback",
1547
+ "--project",
1548
+ str(project),
1549
+ "--judgement",
1550
+ str(judgement_path),
1551
+ "--json",
1552
+ )
1553
+ self.assertEqual(rolled.returncode, 0, rolled.stderr)
1554
+ rollback_payload = json.loads(rolled.stdout)
1555
+ self.assertEqual(rollback_payload["restored"], 3)
1556
+ restored_candidate = json.loads(
1557
+ next(
1558
+ path
1559
+ for path in (knowledge / "entries" / "candidate").glob("*.json")
1560
+ if json.loads(path.read_text(encoding="utf-8")).get("id") == candidate["id"]
1561
+ ).read_text(encoding="utf-8")
1562
+ )
1563
+ self.assertEqual(restored_candidate["status"], before_candidate["status"])
1564
+ self.assertEqual(restored_candidate["title"], before_candidate["title"])
1565
+
1566
+ def test_judge_apply_requires_force_when_manual_review_enabled(self) -> None:
1567
+ with tempfile.TemporaryDirectory() as tmp:
1568
+ project = self.make_project(Path(tmp))
1569
+ ingest = self.run_cli("ingest", "--project", str(project))
1570
+ self.assertEqual(ingest.returncode, 0, ingest.stderr)
1571
+ knowledge = project / ".harness" / "knowledge"
1572
+ write_json(
1573
+ project / ".harness" / "config" / "harness.json",
1574
+ {"schemaVersion": 1, "knowledge": {"manualReview": True}},
1575
+ )
1576
+ candidate = self._minimal_entry(
1577
+ "sample.force.decision.cand00001",
1578
+ title="manualReview 候选",
1579
+ body="需要 --force 才能 apply。",
1580
+ )
1581
+ self._write_entry(knowledge, candidate)
1582
+ module = self._import_harness_knowledge()
1583
+ module.refresh_outputs_from_entry_files(project, knowledge)
1584
+ decisions_path = Path(tmp) / "decisions.json"
1585
+ write_json(
1586
+ decisions_path,
1587
+ {"decisions": [{"id": candidate["id"], "action": "promote", "reason": "force test"}]},
1588
+ )
1589
+
1590
+ blocked = self.run_cli(
1591
+ "judge",
1592
+ "--project",
1593
+ str(project),
1594
+ "--apply",
1595
+ str(decisions_path),
1596
+ "--json",
1597
+ )
1598
+ self.assertEqual(blocked.returncode, 1, blocked.stdout)
1599
+ self.assertIn("manualReview", blocked.stderr)
1600
+
1601
+ forced = self.run_cli(
1602
+ "judge",
1603
+ "--project",
1604
+ str(project),
1605
+ "--apply",
1606
+ str(decisions_path),
1607
+ "--force",
1608
+ "--json",
1609
+ )
1610
+ self.assertEqual(forced.returncode, 0, forced.stderr)
1611
+ payload = json.loads(forced.stdout)
1612
+ self.assertEqual(payload["applied"], 1)
1613
+
1614
+ def test_ingest_runs_near_dedupe_and_keeps_old_index_compatible(self) -> None:
1615
+ with tempfile.TemporaryDirectory() as tmp:
1616
+ project = self.make_project(Path(tmp))
1617
+ # Plant a second near-duplicate decision via an extra archive with almost identical note.
1618
+ archive = project / ".harness" / "archive" / "2026-06-30-ai-check-job-dup" / "reports" / "final" / "summary-data.json"
1619
+ write_json(
1620
+ archive,
1621
+ {
1622
+ "schemaVersion": "2.1",
1623
+ "changeName": "ai-check-job-dup",
1624
+ "businessGoal": "实现异步 AI 检查 job,复用现有 LlmClient 和 draft.aiChecks 结果展示。",
1625
+ "finalStatus": "OK",
1626
+ "finalCommit": "abc1234",
1627
+ "baseCommit": "base0001",
1628
+ "changedFiles": [
1629
+ {
1630
+ "path": "apps/server/src/registry/store.ts",
1631
+ "summary": "新增 AI job 状态机和持久化字段",
1632
+ }
1633
+ ],
1634
+ "maintenanceNotes": [
1635
+ "AI 检查 job 复用 LlmClient,不新增 provider 抽象。",
1636
+ ],
1637
+ "knownRisks": [],
1638
+ "manualActions": [],
1639
+ },
1640
+ )
1641
+ # Force same archive name group by rewriting the planted archive folder name is different,
1642
+ # so near-dedupe within same archive is validated via module helper + ingestMode field.
1643
+ result = self.run_cli("ingest", "--project", str(project))
1644
+ self.assertEqual(result.returncode, 0, result.stderr)
1645
+ payload = json.loads(result.stdout)
1646
+ self.assertIn("nearDuplicatesMerged", payload["ingestMode"])
1647
+ self.assertIn("stats", payload)
1648
+ self.assertIn("candidate", payload["stats"])
1649
+
1650
+ module = self._import_harness_knowledge()
1651
+ same_archive_entries = [
1652
+ self._minimal_entry(
1653
+ "sample.ingest.decision.dupaaaaa1",
1654
+ title="同一归档近重复 A",
1655
+ body="同一归档内的近重复决策正文内容几乎完全一致。",
1656
+ archive=".harness/archive/2026-06-30-same",
1657
+ ),
1658
+ self._minimal_entry(
1659
+ "sample.ingest.decision.dupbbbbb1",
1660
+ title="同一归档近重复 A",
1661
+ body="同一归档内的近重复决策正文内容几乎完全一致!",
1662
+ archive=".harness/archive/2026-06-30-same",
1663
+ keywords=["extra"],
1664
+ source_files=["apps/web/components/registry.tsx"],
1665
+ ),
1666
+ ]
1667
+ near = module.dedupe_near_duplicates(same_archive_entries)
1668
+ self.assertEqual(near["merged"], 1)
1669
+ statuses = {entry["id"]: entry["status"] for entry in same_archive_entries}
1670
+ self.assertEqual(sum(1 for status in statuses.values() if status == "superseded"), 1)
1671
+
1672
+
1673
+ def _enqueue_pending(self, project: Path, archive_id: str) -> Path:
1674
+ outbox = project / ".harness" / "knowledge" / "maintenance-outbox"
1675
+ pending = outbox / "pending" / f"{archive_id}.json"
1676
+ write_json(
1677
+ pending,
1678
+ {
1679
+ "schemaVersion": 1,
1680
+ "archiveId": archive_id,
1681
+ "archivePath": f".harness/archive/{archive_id}",
1682
+ "archiveManifestHash": "",
1683
+ "status": "pending",
1684
+ "attempts": 0,
1685
+ "createdAt": "2026-07-10T00:00:00+08:00",
1686
+ "lastError": None,
1687
+ },
1688
+ )
1689
+ return outbox
1690
+
1691
+ def test_maintain_claims_and_completes_pending_item(self) -> None:
1692
+ hk = self._import_harness_knowledge()
1693
+ with tempfile.TemporaryDirectory() as tmp:
1694
+ project = self.make_project(Path(tmp))
1695
+ archive_id = "2026-06-30-ai-check-job"
1696
+ outbox = self._enqueue_pending(project, archive_id)
1697
+ result = hk.maintain_knowledge(project, archive_id)
1698
+ self.assertTrue(result["ok"], msg=result)
1699
+ self.assertIn(result["status"], {"completed", "pending-judge"})
1700
+ self.assertFalse((outbox / "pending" / f"{archive_id}.json").exists())
1701
+ self.assertFalse((outbox / "running" / f"{archive_id}.json").exists())
1702
+ self.assertTrue(any((outbox / state / f"{archive_id}.json").exists() for state in ("completed", "pending-judge")))
1703
+
1704
+ def test_maintain_failure_moves_item_to_failed_and_can_retry(self) -> None:
1705
+ from unittest import mock
1706
+
1707
+ hk = self._import_harness_knowledge()
1708
+ with tempfile.TemporaryDirectory() as tmp:
1709
+ project = self.make_project(Path(tmp))
1710
+ archive_id = "2026-06-30-ai-check-job"
1711
+ outbox = self._enqueue_pending(project, archive_id)
1712
+ with mock.patch.object(hk, "build_index", side_effect=RuntimeError("boom")):
1713
+ result = hk.maintain_knowledge(project, archive_id)
1714
+ self.assertFalse(result["ok"])
1715
+ self.assertEqual(result["status"], "failed")
1716
+ failed_path = outbox / "failed" / f"{archive_id}.json"
1717
+ self.assertTrue(failed_path.exists())
1718
+ item = json.loads(failed_path.read_text(encoding="utf-8"))
1719
+ self.assertEqual(item["status"], "failed")
1720
+ self.assertEqual(item["attempts"], 1)
1721
+ self.assertIn("boom", item["lastError"])
1722
+ # retry without the exception -> completed
1723
+ result2 = hk.maintain_knowledge(project, archive_id)
1724
+ self.assertTrue(result2["ok"], msg=result2)
1725
+ self.assertTrue(any((outbox / state / f"{archive_id}.json").exists() for state in ("completed", "pending-judge")))
1726
+ self.assertFalse((outbox / "failed" / f"{archive_id}.json").exists())
1727
+
1728
+ def test_maintain_is_idempotent_for_completed_archive(self) -> None:
1729
+ hk = self._import_harness_knowledge()
1730
+ with tempfile.TemporaryDirectory() as tmp:
1731
+ project = self.make_project(Path(tmp))
1732
+ archive_id = "2026-06-30-ai-check-job"
1733
+ outbox = self._enqueue_pending(project, archive_id)
1734
+ hk.maintain_knowledge(project, archive_id)
1735
+ entries_dir = project / ".harness" / "knowledge" / "entries"
1736
+ snap1 = sorted(
1737
+ p.relative_to(entries_dir).as_posix() for p in entries_dir.rglob("*.json")
1738
+ )
1739
+ ids1 = sorted(
1740
+ e["id"] for _, e in hk.load_entry_files(project / ".harness" / "knowledge")
1741
+ )
1742
+ r2 = hk.maintain_knowledge(project, archive_id)
1743
+ r3 = hk.maintain_knowledge(project, archive_id)
1744
+ self.assertTrue(r2["ok"] and r3["ok"])
1745
+ snap2 = sorted(
1746
+ p.relative_to(entries_dir).as_posix() for p in entries_dir.rglob("*.json")
1747
+ )
1748
+ self.assertEqual(snap1, snap2)
1749
+ ids2 = sorted(
1750
+ e["id"] for _, e in hk.load_entry_files(project / ".harness" / "knowledge")
1751
+ )
1752
+ self.assertEqual(ids1, ids2)
1753
+ item_path = next(
1754
+ outbox / state / f"{archive_id}.json"
1755
+ for state in ("completed", "pending-judge")
1756
+ if (outbox / state / f"{archive_id}.json").exists()
1757
+ )
1758
+ item = json.loads(item_path.read_text(encoding="utf-8"))
1759
+ self.assertIn(item["status"], {"completed", "pending-judge"})
1760
+
1761
+ def test_pending_judgements_are_not_reported_as_completed(self) -> None:
1762
+ hk = self._import_harness_knowledge()
1763
+ with tempfile.TemporaryDirectory() as tmp:
1764
+ project = self.make_project(Path(tmp))
1765
+ self.add_followup_archive(project) # semantic conflict with the first archive
1766
+ archive_id = "2026-07-01-ai-check-job-followup"
1767
+ self._enqueue_pending(project, archive_id)
1768
+ result = hk.maintain_knowledge(project, archive_id)
1769
+ self.assertEqual(
1770
+ result["status"], "pending-judge", msg=result
1771
+ )
1772
+ self.assertGreater(result["pendingJudgements"], 0)
1773
+
1774
+ def test_maintenance_auxiliary_failure_moves_item_to_failed(self) -> None:
1775
+ from unittest import mock
1776
+
1777
+ hk = self._import_harness_knowledge()
1778
+ with tempfile.TemporaryDirectory() as tmp:
1779
+ project = self.make_project(Path(tmp))
1780
+ archive_id = "2026-07-02-maintenance-failure"
1781
+ outbox = self._enqueue_pending(project, archive_id)
1782
+ with mock.patch.object(hk, "auto_supersede_knowledge", side_effect=RuntimeError("supersede failed")):
1783
+ result = hk.maintain_knowledge(project, archive_id)
1784
+ self.assertFalse(result["ok"], msg=result)
1785
+ self.assertEqual(result["status"], "failed")
1786
+ item = json.loads((outbox / "failed" / f"{archive_id}.json").read_text(encoding="utf-8"))
1787
+ self.assertEqual(item["attempts"], 1)
1788
+ self.assertIn("supersede failed", item["lastError"])
1789
+
1790
+
1791
+ if __name__ == "__main__":
1792
+ unittest.main()