hunter-harness 0.1.0 → 0.1.2

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 (71) hide show
  1. package/dist/bin.js +1033 -219
  2. package/package.json +2 -2
  3. package/resources/harness/general/.harness-build.json +1 -1
  4. package/resources/harness/general/harness-archive/SKILL.md +215 -215
  5. package/resources/harness/general/harness-codebase-map/SKILL.md +112 -112
  6. package/resources/harness/general/harness-codebase-map/templates/map-summary.md +3 -3
  7. package/resources/harness/general/harness-knowledge-ingest/SKILL.md +246 -246
  8. package/resources/harness/general/harness-knowledge-query/SKILL.md +164 -164
  9. package/resources/harness/general/harness-plan/SKILL.md +127 -127
  10. package/resources/harness/general/harness-review/SKILL.md +156 -156
  11. package/resources/harness/general/harness-run/SKILL.md +132 -132
  12. package/resources/harness/general/harness-submit/SKILL.md +159 -159
  13. package/resources/harness/general/harness-sync/SKILL.md +82 -82
  14. package/resources/harness/general/harness-test/SKILL.md +180 -180
  15. package/resources/harness/general/scripts/harness_deploy.py +580 -580
  16. package/resources/harness/java/.harness-build.json +1 -1
  17. package/resources/harness/java/harness-apidoc/SKILL.md +86 -86
  18. package/resources/harness/java/harness-archive/SKILL.md +215 -215
  19. package/resources/harness/java/harness-codebase-map/SKILL.md +112 -112
  20. package/resources/harness/java/harness-codebase-map/templates/map-summary.md +3 -3
  21. package/resources/harness/java/harness-knowledge-ingest/SKILL.md +246 -246
  22. package/resources/harness/java/harness-knowledge-query/SKILL.md +164 -164
  23. package/resources/harness/java/harness-package/SKILL.md +87 -87
  24. package/resources/harness/java/harness-plan/SKILL.md +127 -127
  25. package/resources/harness/java/harness-review/SKILL.md +156 -156
  26. package/resources/harness/java/harness-run/SKILL.md +148 -148
  27. package/resources/harness/java/harness-submit/SKILL.md +166 -166
  28. package/resources/harness/java/harness-sync/SKILL.md +82 -82
  29. package/resources/harness/java/harness-test/SKILL.md +192 -192
  30. package/resources/harness/java/scripts/harness_deploy.py +580 -580
  31. package/resources/harness/manifests/general.json +13 -33
  32. package/resources/harness/manifests/java.json +15 -35
  33. package/resources/harness/migrations/0.1.1/general.json +358 -0
  34. package/resources/harness/migrations/0.1.1/java.json +398 -0
  35. package/resources/bootstrap-ir/manifest.json +0 -19
  36. package/resources/bootstrap-ir/skills/harness-apidoc.yaml +0 -24
  37. package/resources/bootstrap-ir/skills/harness-archive.yaml +0 -24
  38. package/resources/bootstrap-ir/skills/harness-codebase-map.yaml +0 -24
  39. package/resources/bootstrap-ir/skills/harness-knowledge-ingest.yaml +0 -24
  40. package/resources/bootstrap-ir/skills/harness-package.yaml +0 -24
  41. package/resources/bootstrap-ir/skills/harness-plan.yaml +0 -24
  42. package/resources/bootstrap-ir/skills/harness-review.yaml +0 -24
  43. package/resources/bootstrap-ir/skills/harness-run.yaml +0 -24
  44. package/resources/bootstrap-ir/skills/harness-skill-optimizer.yaml +0 -28
  45. package/resources/bootstrap-ir/skills/harness-submit.yaml +0 -24
  46. package/resources/bootstrap-ir/skills/harness-sync.yaml +0 -24
  47. package/resources/bootstrap-ir/skills/harness-test.yaml +0 -24
  48. package/resources/bootstrap-ir/templates/claude-code-skill.md +0 -12
  49. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +0 -43
  50. 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 +0 -38
  51. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +0 -36
  52. package/resources/harness/general/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +0 -1
  53. package/resources/harness/general/harness-knowledge-ingest/tests/test_harness_knowledge.py +0 -1792
  54. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-01-10-ledger-reconciliation/reports/final/summary-data.json +0 -43
  55. 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 +0 -38
  56. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/archive/2026-03-05-webhook-contract/reports/final/summary-data.json +0 -36
  57. package/resources/harness/java/harness-knowledge-ingest/tests/fixtures/mcp-eval-project/.harness/project.yaml +0 -1
  58. package/resources/harness/java/harness-knowledge-ingest/tests/test_harness_knowledge.py +0 -1792
  59. package/resources/manifest.json +0 -19
  60. package/resources/skills/harness-apidoc/SKILL.md +0 -50
  61. package/resources/skills/harness-archive/SKILL.md +0 -48
  62. package/resources/skills/harness-codebase-map/SKILL.md +0 -53
  63. package/resources/skills/harness-knowledge-ingest/SKILL.md +0 -48
  64. package/resources/skills/harness-package/SKILL.md +0 -48
  65. package/resources/skills/harness-plan/SKILL.md +0 -51
  66. package/resources/skills/harness-review/SKILL.md +0 -50
  67. package/resources/skills/harness-run/SKILL.md +0 -48
  68. package/resources/skills/harness-skill-optimizer/SKILL.md +0 -54
  69. package/resources/skills/harness-submit/SKILL.md +0 -47
  70. package/resources/skills/harness-sync/SKILL.md +0 -48
  71. package/resources/skills/harness-test/SKILL.md +0 -50
@@ -1,1792 +0,0 @@
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()