@hunter-harness/workflow-harness 0.2.41 → 0.2.42

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 (130) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +42 -16
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +57 -6
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +16 -10
  13. package/harness/bundles/general/claude-code/harness-sync/reference.md +34 -14
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  16. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  17. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  18. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
  19. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +57 -6
  20. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  21. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +16 -10
  27. package/harness/bundles/general/codebuddy/harness-sync/reference.md +34 -14
  28. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  29. package/harness/bundles/general/codex/.harness-build.json +1 -1
  30. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  31. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  32. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +41 -15
  33. package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +57 -6
  34. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  35. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  36. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-sync/SKILL.md +16 -10
  41. package/harness/bundles/general/codex/harness-sync/reference.md +34 -14
  42. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  43. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  44. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  45. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  46. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +41 -15
  47. package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +57 -6
  48. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  49. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  50. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  51. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  52. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  53. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-sync/SKILL.md +16 -10
  55. package/harness/bundles/general/cursor/harness-sync/reference.md +34 -14
  56. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  57. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  58. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  59. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  60. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  61. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +42 -16
  62. package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +57 -6
  63. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  64. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  65. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  66. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  67. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  68. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  69. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  70. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +16 -10
  71. package/harness/bundles/java/claude-code/harness-sync/reference.md +34 -14
  72. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  73. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  74. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  75. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  76. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  77. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
  78. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +57 -6
  79. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  80. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  81. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  82. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  83. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  84. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  85. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  86. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +16 -10
  87. package/harness/bundles/java/codebuddy/harness-sync/reference.md +34 -14
  88. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  89. package/harness/bundles/java/codex/.harness-build.json +1 -1
  90. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +41 -15
  94. package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +57 -6
  95. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  96. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  97. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  98. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  99. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  100. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  101. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  102. package/harness/bundles/java/codex/harness-sync/SKILL.md +16 -10
  103. package/harness/bundles/java/codex/harness-sync/reference.md +34 -14
  104. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  105. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  106. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  107. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  108. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  109. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +41 -15
  110. package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +57 -6
  111. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
  112. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  113. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  114. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  115. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  116. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  117. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  118. package/harness/bundles/java/cursor/harness-sync/SKILL.md +16 -10
  119. package/harness/bundles/java/cursor/harness-sync/reference.md +34 -14
  120. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  121. package/harness/manifests/general/claude-code.json +19 -19
  122. package/harness/manifests/general/codebuddy.json +19 -19
  123. package/harness/manifests/general/codex.json +19 -19
  124. package/harness/manifests/general/cursor.json +19 -19
  125. package/harness/manifests/java/claude-code.json +21 -21
  126. package/harness/manifests/java/codebuddy.json +21 -21
  127. package/harness/manifests/java/codex.json +21 -21
  128. package/harness/manifests/java/cursor.json +21 -21
  129. package/hunter-workflow-family.json +10 -10
  130. package/package.json +1 -1
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import datetime as dt
14
+ import gzip
14
15
  import hashlib
15
16
  import json
16
17
  import os
@@ -20,6 +21,7 @@ import sqlite3
20
21
  import subprocess
21
22
  import sys
22
23
  import time
24
+ import unicodedata
23
25
  from difflib import SequenceMatcher
24
26
  from pathlib import Path
25
27
  from typing import Any
@@ -42,8 +44,8 @@ ENTRY_TYPES = {
42
44
  }
43
45
 
44
46
  ENTRY_ID_SCHEMA_VERSION = 2
45
- ARCHIVE_CACHE_SCHEMA_VERSION = 2
46
- EXTRACTOR_SCHEMA_VERSION = 2
47
+ ARCHIVE_CACHE_SCHEMA_VERSION = 3
48
+ EXTRACTOR_SCHEMA_VERSION = 3
47
49
  _GIT_REPO_CACHE: dict[str, bool] = {}
48
50
  _GIT_COMMIT_CACHE: dict[tuple[str, str], bool] = {}
49
51
  _GIT_DIFF_CACHE: dict[tuple[str, str, tuple[str, ...]], list[str] | None] = {}
@@ -133,6 +135,92 @@ def json_clone(value: Any) -> Any:
133
135
  return json.loads(json.dumps(value, ensure_ascii=False))
134
136
 
135
137
 
138
+ def normalize_knowledge_text(value: Any) -> str:
139
+ """Canonical text used for identity-independent duplicate detection."""
140
+ text = unicodedata.normalize("NFKC", str(value or "")).strip()
141
+ if not text:
142
+ return ""
143
+ if text[:1] in {"{", "["}:
144
+ try:
145
+ parsed = json.loads(text)
146
+ except (json.JSONDecodeError, TypeError):
147
+ pass
148
+ else:
149
+ return json.dumps(
150
+ parsed,
151
+ ensure_ascii=False,
152
+ sort_keys=True,
153
+ separators=(",", ":"),
154
+ ).casefold()
155
+ text = text.translate(str.maketrans({
156
+ ",": ",",
157
+ "。": ".",
158
+ ";": ";",
159
+ ":": ":",
160
+ "!": "!",
161
+ "?": "?",
162
+ "“": '"',
163
+ "”": '"',
164
+ "‘": "'",
165
+ "’": "'",
166
+ }))
167
+ text = " ".join(text.split()).casefold()
168
+ return re.sub(r"\s*([,.;:!?])\s*", r"\1", text)
169
+
170
+
171
+ def sha256_text(value: str) -> str:
172
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
173
+
174
+
175
+ def _report_hash_payload(payload: dict[str, Any]) -> dict[str, Any]:
176
+ stable = json_clone(payload)
177
+ stable.pop("generatedAt", None)
178
+ stable.pop("exportPath", None)
179
+ stable.pop("judgement", None)
180
+ return stable
181
+
182
+
183
+ def update_latest_report_pointer(
184
+ knowledge: Path,
185
+ kind: str,
186
+ report_path: Path,
187
+ digest: str,
188
+ ) -> None:
189
+ latest_path = knowledge / "reports" / "latest.json"
190
+ latest: dict[str, Any] = {"schemaVersion": 1, "reports": {}}
191
+ if latest_path.exists():
192
+ try:
193
+ current = read_json(latest_path)
194
+ if isinstance(current, dict) and isinstance(current.get("reports"), dict):
195
+ latest = current
196
+ except (OSError, json.JSONDecodeError):
197
+ pass
198
+ reports = dict(latest.get("reports") or {})
199
+ reports[kind] = {
200
+ "path": str(report_path.relative_to(knowledge)).replace("\\", "/"),
201
+ "sha256": digest,
202
+ }
203
+ write_json_if_changed(
204
+ latest_path,
205
+ {"schemaVersion": 1, "reports": dict(sorted(reports.items()))},
206
+ )
207
+
208
+
209
+ def write_content_addressed_json_report(
210
+ knowledge: Path,
211
+ kind: str,
212
+ payload: dict[str, Any],
213
+ ) -> tuple[Path, str]:
214
+ stable = _report_hash_payload(payload)
215
+ digest = sha256_text(
216
+ json.dumps(stable, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
217
+ )
218
+ path = knowledge / "reports" / f"{kind}-{digest}.json"
219
+ write_json_if_changed(path, payload)
220
+ update_latest_report_pointer(knowledge, kind, path, digest)
221
+ return path, digest
222
+
223
+
136
224
  def load_config(knowledge: Path) -> dict[str, Any]:
137
225
  config_path = knowledge / "config.json"
138
226
  if not config_path.exists():
@@ -612,12 +700,48 @@ def discover_archive_summary_paths(project: Path) -> list[Path]:
612
700
  return selected
613
701
 
614
702
 
703
+ def legacy_publication_attestation(
704
+ project: Path,
705
+ archive_rel: str,
706
+ summary_sha256: str,
707
+ ) -> dict[str, Any] | None:
708
+ path = project / ".harness" / "knowledge" / "publication-attestations.json"
709
+ if not path.exists():
710
+ return None
711
+ try:
712
+ payload = read_json(path)
713
+ except (OSError, json.JSONDecodeError):
714
+ return None
715
+ records = payload.get("records") if isinstance(payload, dict) else None
716
+ record = records.get(archive_rel) if isinstance(records, dict) else None
717
+ if (
718
+ isinstance(record, dict)
719
+ and record.get("status") == "verified"
720
+ and record.get("summarySha256") == summary_sha256
721
+ ):
722
+ return record
723
+ return None
724
+
725
+
615
726
  def _publication_status_from_resolution(
616
727
  project: Path,
617
728
  archive_dir: Path,
618
729
  resolution: dict[str, Any],
619
730
  ) -> dict[str, Any]:
620
- result: dict[str, Any] = {"status": "missing", "allowed": False, "reasons": []}
731
+ result: dict[str, Any] = {
732
+ "status": "missing",
733
+ "allowed": False,
734
+ "reasons": [],
735
+ "archiveReleaseEligibility": {
736
+ "status": "UNKNOWN",
737
+ "eligible": False,
738
+ },
739
+ "knowledgePublicationEligibility": {
740
+ "status": "missing",
741
+ "allowed": False,
742
+ "reasons": [],
743
+ },
744
+ }
621
745
  if resolution.get("version"):
622
746
  result["authoritativeVersion"] = resolution["version"]
623
747
 
@@ -652,10 +776,58 @@ def _publication_status_from_resolution(
652
776
  result["reasons"].append("summary-data.json must be an object")
653
777
  return result
654
778
 
779
+ final_status = str(summary.get("finalStatus") or "").upper()
780
+ result["archiveReleaseEligibility"] = {
781
+ "status": final_status or "UNKNOWN",
782
+ "eligible": final_status in {"OK", "CONDITIONAL_OK"},
783
+ }
784
+ explicit_gate = summary.get("knowledgePublicationEligibility")
785
+ if isinstance(explicit_gate, dict):
786
+ explicit_allowed = explicit_gate.get("allowed") is True
787
+ explicit_reasons = [
788
+ str(reason) for reason in (explicit_gate.get("reasons") or [])
789
+ ]
790
+ result["knowledgePublicationEligibility"] = {
791
+ "status": str(explicit_gate.get("status") or (
792
+ "ok" if explicit_allowed else "unverified"
793
+ )).lower(),
794
+ "allowed": explicit_allowed,
795
+ "reasons": explicit_reasons,
796
+ }
797
+ result["status"] = (
798
+ "ok" if explicit_allowed
799
+ else result["knowledgePublicationEligibility"]["status"]
800
+ )
801
+ result["allowed"] = explicit_allowed
802
+ result["reasons"] = explicit_reasons
803
+ return result
804
+
655
805
  consistency = (summary.get("reportPipeline") or {}).get("sourceConsistency")
656
806
  if consistency is None:
807
+ archive_rel = rel_to_project(project, archive_dir)
808
+ attestation = legacy_publication_attestation(
809
+ project,
810
+ archive_rel,
811
+ summary_hash,
812
+ )
813
+ if attestation is not None:
814
+ result["status"] = "ok"
815
+ result["allowed"] = True
816
+ result["knowledgePublicationEligibility"] = {
817
+ "status": "ok",
818
+ "allowed": True,
819
+ "reasons": [],
820
+ "evidence": "legacy-publication-attestation",
821
+ "attestationSha256": attestation.get("attestationSha256"),
822
+ }
823
+ return result
657
824
  result["status"] = "unverified"
658
825
  result["reasons"].append("source consistency never ran")
826
+ result["knowledgePublicationEligibility"] = {
827
+ "status": "unverified",
828
+ "allowed": False,
829
+ "reasons": list(result["reasons"]),
830
+ }
659
831
  return result
660
832
  if not consistency.get("ok"):
661
833
  result["status"] = "failed"
@@ -667,18 +839,24 @@ def _publication_status_from_resolution(
667
839
  result["reasons"].append(
668
840
  "source consistency failed" + (f": {', '.join(codes)}" if codes else "")
669
841
  )
670
- return result
671
-
672
- final_status = str(summary.get("finalStatus") or "").upper()
673
- if final_status not in {"OK", "CONDITIONAL_OK"}:
674
- result["status"] = "degraded" if final_status == "DEGRADED" else "unverified"
675
- result["reasons"].append(
676
- f"authoritative finalStatus is not publishable: {final_status or 'missing'}"
677
- )
842
+ result["knowledgePublicationEligibility"] = {
843
+ "status": "failed",
844
+ "allowed": False,
845
+ "reasons": list(result["reasons"]),
846
+ }
678
847
  return result
679
848
 
680
849
  result["status"] = "ok"
681
850
  result["allowed"] = True
851
+ result["knowledgePublicationEligibility"] = {
852
+ "status": "ok",
853
+ "allowed": True,
854
+ "reasons": [],
855
+ }
856
+ if final_status not in {"OK", "CONDITIONAL_OK"}:
857
+ result["archiveReleaseEligibility"]["advisory"] = (
858
+ "Archive release status does not blanket-block source-consistent knowledge."
859
+ )
682
860
  return result
683
861
 
684
862
 
@@ -1061,10 +1239,97 @@ def keyword_candidates(summary: dict[str, Any], extra: list[str] | None = None)
1061
1239
  return tokens[:40]
1062
1240
 
1063
1241
 
1242
+ def looks_like_process_observation(text: str) -> bool:
1243
+ normalized = normalize_knowledge_text(text).lower()
1244
+ process_markers = (
1245
+ "worktree",
1246
+ "preflight",
1247
+ "reviewer path",
1248
+ "git diff",
1249
+ "git status",
1250
+ "commit status",
1251
+ "baseline dirty",
1252
+ "user baseline",
1253
+ "test slow threshold",
1254
+ ".gitattributes",
1255
+ "harness execution",
1256
+ )
1257
+ return any(marker in normalized for marker in process_markers)
1258
+
1259
+
1260
+ def extract_explicit_knowledge_candidates(
1261
+ project: Path,
1262
+ project_name: str,
1263
+ summary_path: Path,
1264
+ summary: dict[str, Any],
1265
+ summary_hash: str,
1266
+ fallback_files: list[str],
1267
+ ) -> list[dict[str, Any]] | None:
1268
+ raw_candidates = summary.get("knowledgeCandidates")
1269
+ if not isinstance(raw_candidates, list):
1270
+ return None
1271
+ entries: list[dict[str, Any]] = []
1272
+ for item in raw_candidates:
1273
+ if not isinstance(item, dict):
1274
+ continue
1275
+ category = str(item.get("category") or "").strip().lower()
1276
+ title = str(item.get("title") or "").strip()
1277
+ body = str(item.get("body") or item.get("summary") or "").strip()
1278
+ if category == "process-observation" or looks_like_process_observation(
1279
+ f"{title} {body}"
1280
+ ):
1281
+ continue
1282
+ entry_type = str(item.get("type") or "implementation")
1283
+ if entry_type not in ENTRY_TYPES or not title or not body:
1284
+ continue
1285
+ raw_files = item.get("sourceFiles")
1286
+ source_files = (
1287
+ [str(path) for path in raw_files if path]
1288
+ if isinstance(raw_files, list)
1289
+ else fallback_files
1290
+ )
1291
+ raw_keywords = item.get("keywords")
1292
+ keywords = keyword_candidates(
1293
+ summary,
1294
+ [str(value) for value in raw_keywords if value]
1295
+ if isinstance(raw_keywords, list)
1296
+ else [entry_type],
1297
+ )
1298
+ confidence = str(item.get("confidence") or "medium")
1299
+ if confidence not in {"low", "medium", "high"}:
1300
+ confidence = "medium"
1301
+ entries.append(
1302
+ make_entry(
1303
+ project=project,
1304
+ project_name=project_name,
1305
+ summary_path=summary_path,
1306
+ summary_hash=summary_hash,
1307
+ summary=summary,
1308
+ entry_type=entry_type,
1309
+ title=title,
1310
+ body=body,
1311
+ source_files=source_files,
1312
+ keywords=keywords,
1313
+ confidence=confidence,
1314
+ )
1315
+ )
1316
+ return entries
1317
+
1318
+
1064
1319
  def extract_entries(project: Path, project_name: str, summary_path: Path) -> list[dict[str, Any]]:
1065
1320
  summary = read_json(summary_path)
1066
1321
  summary_hash = sha256_file(summary_path)
1067
1322
  files = changed_file_paths(summary)
1323
+ explicit = extract_explicit_knowledge_candidates(
1324
+ project,
1325
+ project_name,
1326
+ summary_path,
1327
+ summary,
1328
+ summary_hash,
1329
+ files,
1330
+ )
1331
+ if explicit is not None:
1332
+ return explicit
1068
1333
  entries: list[dict[str, Any]] = []
1069
1334
  goal = str(summary.get("businessGoal") or "").strip()
1070
1335
 
@@ -1087,7 +1352,7 @@ def extract_entries(project: Path, project_name: str, summary_path: Path) -> lis
1087
1352
 
1088
1353
  for idx, note in enumerate(summary.get("maintenanceNotes") or [], start=1):
1089
1354
  text = str(note).strip()
1090
- if not text:
1355
+ if not text or looks_like_process_observation(text):
1091
1356
  continue
1092
1357
  entry_type = "decision" if looks_like_decision(text) else "implementation"
1093
1358
  entries.append(
@@ -1188,7 +1453,11 @@ def extract_entries(project: Path, project_name: str, summary_path: Path) -> lis
1188
1453
  )
1189
1454
 
1190
1455
  review = summary.get("reviewSummary")
1191
- if isinstance(review, dict) and review.get("summary"):
1456
+ if (
1457
+ isinstance(review, dict)
1458
+ and review.get("summary")
1459
+ and not looks_like_process_observation(str(review.get("summary")))
1460
+ ):
1192
1461
  body = str(review.get("summary"))
1193
1462
  entries.append(
1194
1463
  make_entry(
@@ -1831,6 +2100,39 @@ def subject_terms(entry: dict[str, Any]) -> set[str]:
1831
2100
  return {term for term in terms if term not in stop}
1832
2101
 
1833
2102
 
2103
+ def entity_keys(entry: dict[str, Any]) -> set[str]:
2104
+ text = " ".join(
2105
+ str(value or "")
2106
+ for value in (entry.get("title"), entry.get("summary"), entry.get("body"))
2107
+ )
2108
+ keys = {
2109
+ match.strip()
2110
+ for match in re.findall(r"`([^`\n]{2,120})`", text)
2111
+ if match.strip()
2112
+ }
2113
+ keys.update(
2114
+ re.findall(
2115
+ r"\b[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+\b",
2116
+ text,
2117
+ )
2118
+ )
2119
+ keys.update(
2120
+ token
2121
+ for token in re.findall(r"\b[A-Za-z_][A-Za-z0-9_]{3,}\b", text)
2122
+ if "_" in token or any(character.isupper() for character in token[1:])
2123
+ )
2124
+ stop = {
2125
+ "Harness",
2126
+ "README",
2127
+ "SKILL",
2128
+ "changeName",
2129
+ "finalStatus",
2130
+ "sourceFiles",
2131
+ "summaryData",
2132
+ }
2133
+ return {key for key in keys if key not in stop}
2134
+
2135
+
1834
2136
  def has_replacement_signal(text: str) -> bool:
1835
2137
  needles = [
1836
2138
  "不再",
@@ -1854,61 +2156,70 @@ def has_stability_signal(text: str) -> bool:
1854
2156
  return any(needle in text for needle in needles)
1855
2157
 
1856
2158
 
1857
- def entries_conflict(left: dict[str, Any], right: dict[str, Any]) -> bool:
2159
+ def conflict_evidence(
2160
+ left: dict[str, Any],
2161
+ right: dict[str, Any],
2162
+ ) -> dict[str, Any] | None:
1858
2163
  comparable_types = {"requirement", "decision", "api-contract"}
1859
2164
  if left.get("type") != right.get("type") or left.get("type") not in comparable_types:
1860
- return False
1861
- if left.get("status") in {"active", "stale", "superseded", "conflicted"}:
1862
- return False
1863
- if right.get("status") in {"active", "stale", "superseded", "conflicted"}:
1864
- return False
1865
-
2165
+ return None
2166
+ if (
2167
+ str(left.get("category") or "") == "process-observation"
2168
+ or str(right.get("category") or "") == "process-observation"
2169
+ or looks_like_process_observation(normalized_entry_text(left))
2170
+ or looks_like_process_observation(normalized_entry_text(right))
2171
+ ):
2172
+ return None
1866
2173
  left_files = set(left.get("scope", {}).get("sourceFiles") or [])
1867
2174
  right_files = set(right.get("scope", {}).get("sourceFiles") or [])
1868
2175
  if not left_files or not right_files or not (left_files & right_files):
1869
- return False
1870
-
1871
- shared_terms = subject_terms(left) & subject_terms(right)
1872
- if not shared_terms:
1873
- return False
1874
-
2176
+ return None
2177
+ shared_entities = sorted(entity_keys(left) & entity_keys(right))
2178
+ if not shared_entities:
2179
+ return None
1875
2180
  left_text = normalized_entry_text(left)
1876
2181
  right_text = normalized_entry_text(right)
1877
- return (
2182
+ mutually_exclusive = (
1878
2183
  has_replacement_signal(left_text)
1879
2184
  and has_stability_signal(right_text)
1880
2185
  ) or (
1881
2186
  has_replacement_signal(right_text)
1882
2187
  and has_stability_signal(left_text)
1883
2188
  )
2189
+ if not mutually_exclusive:
2190
+ return None
2191
+ return {
2192
+ "sharedEntities": shared_entities,
2193
+ "sharedSourceFiles": sorted(left_files & right_files),
2194
+ "mutuallyExclusiveAssertions": True,
2195
+ "leftSignals": {
2196
+ "replacement": has_replacement_signal(left_text),
2197
+ "stability": has_stability_signal(left_text),
2198
+ },
2199
+ "rightSignals": {
2200
+ "replacement": has_replacement_signal(right_text),
2201
+ "stability": has_stability_signal(right_text),
2202
+ },
2203
+ }
1884
2204
 
1885
2205
 
1886
- def entries_conflict_for_review(left: dict[str, Any], right: dict[str, Any]) -> bool:
1887
- comparable_types = {"requirement", "decision", "api-contract"}
1888
- if left.get("type") != right.get("type") or left.get("type") not in comparable_types:
2206
+ def entries_conflict(left: dict[str, Any], right: dict[str, Any]) -> bool:
2207
+ if left.get("status") in {"active", "stale", "superseded", "conflicted"}:
1889
2208
  return False
1890
-
1891
- left_files = set(left.get("scope", {}).get("sourceFiles") or [])
1892
- right_files = set(right.get("scope", {}).get("sourceFiles") or [])
1893
- if not left_files or not right_files or not (left_files & right_files):
2209
+ if right.get("status") in {"active", "stale", "superseded", "conflicted"}:
1894
2210
  return False
2211
+ return conflict_evidence(left, right) is not None
1895
2212
 
1896
- shared_terms = subject_terms(left) & subject_terms(right)
1897
- if not shared_terms:
1898
- return False
1899
2213
 
1900
- left_text = normalized_entry_text(left)
1901
- right_text = normalized_entry_text(right)
1902
- return (
1903
- has_replacement_signal(left_text)
1904
- and has_stability_signal(right_text)
1905
- ) or (
1906
- has_replacement_signal(right_text)
1907
- and has_stability_signal(left_text)
1908
- )
2214
+ def entries_conflict_for_review(left: dict[str, Any], right: dict[str, Any]) -> bool:
2215
+ return conflict_evidence(left, right) is not None
1909
2216
 
1910
2217
 
1911
- def mark_conflict(left: dict[str, Any], right: dict[str, Any]) -> None:
2218
+ def mark_conflict(
2219
+ left: dict[str, Any],
2220
+ right: dict[str, Any],
2221
+ evidence: dict[str, Any] | None = None,
2222
+ ) -> None:
1912
2223
  for entry, other in [(left, right), (right, left)]:
1913
2224
  entry["status"] = "conflicted"
1914
2225
  lifecycle = entry.setdefault("lifecycle", {})
@@ -1919,6 +2230,8 @@ def mark_conflict(left: dict[str, Any], right: dict[str, Any]) -> None:
1919
2230
  reason = "potential conflict with: " + other["id"]
1920
2231
  if reason not in reasons:
1921
2232
  reasons.append(reason)
2233
+ if evidence is not None:
2234
+ lifecycle.setdefault("conflictEvidence", {})[other["id"]] = evidence
1922
2235
  lifecycle["lastCheckedAt"] = now_iso()
1923
2236
 
1924
2237
 
@@ -1926,8 +2239,9 @@ def mark_conflicting_generated_entries(entries: list[dict[str, Any]]) -> None:
1926
2239
  ordered = sorted(entries, key=archive_sort_key)
1927
2240
  for idx, left in enumerate(ordered):
1928
2241
  for right in ordered[idx + 1 :]:
2242
+ evidence = conflict_evidence(left, right)
1929
2243
  if entries_conflict(left, right):
1930
- mark_conflict(left, right)
2244
+ mark_conflict(left, right, evidence)
1931
2245
 
1932
2246
 
1933
2247
  def number_value(value: Any) -> int | None:
@@ -2523,6 +2837,9 @@ def compute_inputs_hash_from_preserved(
2523
2837
  schema_fingerprint = json.dumps(
2524
2838
  {
2525
2839
  "entrySchemaVersion": 1,
2840
+ "entryIdentitySchemaVersion": ENTRY_ID_SCHEMA_VERSION,
2841
+ "archiveCacheSchemaVersion": ARCHIVE_CACHE_SCHEMA_VERSION,
2842
+ "extractorSchemaVersion": EXTRACTOR_SCHEMA_VERSION,
2526
2843
  "indexSchemaVersion": 1,
2527
2844
  "sqliteSchemaVersion": SQLITE_SCHEMA_VERSION,
2528
2845
  },
@@ -2703,6 +3020,52 @@ def restore_unchanged_entry_state(
2703
3020
  entry["lifecycle"] = json_clone(previous.get("lifecycle", entry.get("lifecycle", {})))
2704
3021
 
2705
3022
 
3023
+ def deduplicate_normalized_entries(
3024
+ entries: list[dict[str, Any]],
3025
+ ) -> dict[str, Any]:
3026
+ exact_seen: set[str] = set()
3027
+ normalized_seen: set[str] = set()
3028
+ normalized_body_seen: set[str] = set()
3029
+ unique: list[dict[str, Any]] = []
3030
+ exact_removed = 0
3031
+ normalized_removed = 0
3032
+ for entry in entries:
3033
+ archive = str((entry.get("source") or {}).get("archive") or "")
3034
+ entry_type = str(entry.get("type") or "")
3035
+ exact = json.dumps(
3036
+ (entry_type, entry.get("title"), entry.get("body"), archive),
3037
+ ensure_ascii=False,
3038
+ sort_keys=True,
3039
+ )
3040
+ normalized_title = normalize_knowledge_text(entry.get("title"))
3041
+ normalized_body = normalize_knowledge_text(entry.get("body"))
3042
+ normalized = json.dumps(
3043
+ (archive, entry_type, normalized_title, normalized_body),
3044
+ ensure_ascii=False,
3045
+ sort_keys=True,
3046
+ )
3047
+ body_only = json.dumps(
3048
+ (archive, entry_type, normalized_body),
3049
+ ensure_ascii=False,
3050
+ sort_keys=True,
3051
+ )
3052
+ if exact in exact_seen:
3053
+ exact_removed += 1
3054
+ continue
3055
+ if normalized in normalized_seen or body_only in normalized_body_seen:
3056
+ normalized_removed += 1
3057
+ continue
3058
+ exact_seen.add(exact)
3059
+ normalized_seen.add(normalized)
3060
+ normalized_body_seen.add(body_only)
3061
+ unique.append(entry)
3062
+ return {
3063
+ "entries": unique,
3064
+ "exactDuplicatesRemoved": exact_removed,
3065
+ "normalizedDuplicatesRemoved": normalized_removed,
3066
+ }
3067
+
3068
+
2706
3069
  def build_index(
2707
3070
  project: Path,
2708
3071
  incremental: bool = True,
@@ -2890,22 +3253,13 @@ def build_index(
2890
3253
  ingest_mode["activeAutoDemoted"] += publication_reconciliation["demoted"]
2891
3254
  ingest_mode["entriesWritten"] += publication_reconciliation["written"]
2892
3255
 
2893
- seen: set[str] = set()
2894
- deduped: list[dict[str, Any]] = []
2895
- duplicates = 0
2896
- for entry in entries:
2897
- fingerprint = (
2898
- entry["type"],
2899
- entry["title"],
2900
- entry["body"],
2901
- entry["source"]["archive"],
2902
- )
2903
- fp = json.dumps(fingerprint, ensure_ascii=False, sort_keys=True)
2904
- if fp in seen:
2905
- duplicates += 1
2906
- continue
2907
- seen.add(fp)
2908
- deduped.append(entry)
3256
+ normalized_dedupe = deduplicate_normalized_entries(entries)
3257
+ deduped = normalized_dedupe["entries"]
3258
+ exact_duplicates = normalized_dedupe["exactDuplicatesRemoved"]
3259
+ normalized_duplicates = normalized_dedupe["normalizedDuplicatesRemoved"]
3260
+ duplicates = exact_duplicates + normalized_duplicates
3261
+ ingest_mode["exactDuplicatesRemoved"] = exact_duplicates
3262
+ ingest_mode["normalizedDuplicatesRemoved"] = normalized_duplicates
2909
3263
 
2910
3264
  freshness_entries = [
2911
3265
  entry
@@ -3179,27 +3533,32 @@ def audit_entries(project: Path, limit: int = 10) -> dict[str, Any]:
3179
3533
  if not sync["upToDate"]:
3180
3534
  build_index(project)
3181
3535
  entries = load_indexed_entries(project)
3182
- candidates = sorted(
3536
+ all_candidates = sorted(
3183
3537
  [entry for entry in entries if entry["status"] == "candidate"],
3184
3538
  key=lambda entry: score_entry(entry, " ".join(entry.get("keywords") or [])),
3185
3539
  reverse=True,
3186
- )[:limit]
3187
- stale = sorted(
3540
+ )
3541
+ candidates = all_candidates[:limit]
3542
+ all_stale = sorted(
3188
3543
  [entry for entry in entries if entry["status"] == "stale"],
3189
3544
  key=lambda entry: (len(entry.get("lifecycle", {}).get("staleReasons") or []), score_entry(entry, "")),
3190
3545
  reverse=True,
3191
- )[:limit]
3192
- superseded = sorted(
3546
+ )
3547
+ stale = all_stale[:limit]
3548
+ all_superseded = sorted(
3193
3549
  [entry for entry in entries if entry["status"] == "superseded"],
3194
3550
  key=archive_sort_key,
3195
3551
  reverse=True,
3196
- )[:limit]
3197
- conflicted = sorted(
3552
+ )
3553
+ superseded = all_superseded[:limit]
3554
+ all_conflicted = sorted(
3198
3555
  [entry for entry in entries if entry["status"] == "conflicted"],
3199
3556
  key=archive_sort_key,
3200
3557
  reverse=True,
3201
- )[:limit]
3558
+ )
3559
+ conflicted = all_conflicted[:limit]
3202
3560
  active_review = active_review_items(entries, limit)
3561
+ judge = collect_judge_work(entries, limit=limit)
3203
3562
  report_path = knowledge / "reports" / f"audit-report-{timestamp()}.md"
3204
3563
  lines = [
3205
3564
  "# Harness Knowledge Audit Report",
@@ -3245,6 +3604,14 @@ def audit_entries(project: Path, limit: int = 10) -> dict[str, Any]:
3245
3604
  "supersededReview": [audit_summary(entry) for entry in superseded],
3246
3605
  "conflictReview": [audit_summary(entry) for entry in conflicted],
3247
3606
  "activeReview": [audit_summary(entry) for entry in active_review],
3607
+ "counts": {
3608
+ **judge["counts"],
3609
+ "totalStaleEntries": len(all_stale),
3610
+ "totalSupersededEntries": len(all_superseded),
3611
+ "displayedStaleEntries": len(stale),
3612
+ "displayedSupersededEntries": len(superseded),
3613
+ "displayedConflictEntries": len(conflicted),
3614
+ },
3248
3615
  }
3249
3616
 
3250
3617
 
@@ -3344,6 +3711,12 @@ def make_manifest(
3344
3711
  "stats": stats,
3345
3712
  "byType": by_type,
3346
3713
  "duplicatesSkipped": duplicates,
3714
+ "health": summarize_knowledge_health(
3715
+ entries,
3716
+ duplicate_groups=int(
3717
+ (ingest_mode or {}).get("normalizedDuplicatesRemoved") or 0
3718
+ ),
3719
+ ),
3347
3720
  "ingestMode": ingest_mode or {
3348
3721
  "incremental": False,
3349
3722
  "archivesExtracted": len(summary_paths) - len(failures),
@@ -3985,11 +4358,17 @@ def suggest_validators(
3985
4358
  if not (knowledge / "index.json").exists():
3986
4359
  build_index(project)
3987
4360
  selected_statuses = statuses or ["active", "candidate"]
3988
- suggestions: list[dict[str, Any]] = []
4361
+ eligible: list[dict[str, Any]] = []
4362
+ unavailable = 0
4363
+ covered = 0
3989
4364
  applied = 0
3990
4365
  for entry_path, entry in load_entry_files(knowledge, selected_statuses):
4366
+ if isinstance(entry.get("validators"), list) and entry["validators"]:
4367
+ covered += 1
4368
+ continue
3991
4369
  validators = suggest_validators_for_entry(project, entry)
3992
4370
  if not validators:
4371
+ unavailable += 1
3993
4372
  continue
3994
4373
  item = {
3995
4374
  "id": entry["id"],
@@ -3998,21 +4377,27 @@ def suggest_validators(
3998
4377
  "path": str(entry_path),
3999
4378
  "validators": validators,
4000
4379
  }
4001
- suggestions.append(item)
4380
+ eligible.append(item)
4381
+ selected = eligible[: max(0, limit)]
4382
+ for item in selected:
4002
4383
  if apply:
4003
- entry["validators"] = validators
4004
- write_json(entry_path, entry)
4384
+ entry = read_json(Path(item["path"]))
4385
+ entry["validators"] = item["validators"]
4386
+ write_json(Path(item["path"]), entry)
4005
4387
  applied += 1
4006
- if len(suggestions) >= max(1, limit):
4007
- break
4008
4388
  summary = {
4009
4389
  "generatedAt": now_iso(),
4010
4390
  "project": str(project),
4011
4391
  "statuses": selected_statuses,
4012
4392
  "limit": limit,
4013
- "suggested": len(suggestions),
4393
+ "eligible": len(eligible),
4394
+ "selected": len(selected),
4395
+ "suggested": len(selected),
4014
4396
  "applied": applied,
4015
- "entries": suggestions,
4397
+ "remaining": max(0, len(eligible) - len(selected)),
4398
+ "unavailable": unavailable,
4399
+ "covered": covered,
4400
+ "entries": selected,
4016
4401
  }
4017
4402
  report = write_validator_suggestions_report(knowledge, summary)
4018
4403
  if apply and applied:
@@ -4054,7 +4439,10 @@ def auto_knowledge(
4054
4439
  verification = verify_knowledge(project)
4055
4440
  audit = audit_entries(project, limit=audit_limit)
4056
4441
  ingest_mode = sync.get("index", {}).get("ingestMode", {}) if isinstance(sync.get("index"), dict) else {}
4442
+ judge_counts = audit.get("counts") or {}
4443
+ final_status = sync_status(project, check=True)
4057
4444
  return {
4445
+ "command": "auto",
4058
4446
  "project": str(project),
4059
4447
  "generatedAt": now_iso(),
4060
4448
  "mode": {
@@ -4065,6 +4453,10 @@ def auto_knowledge(
4065
4453
  "incremental": incremental,
4066
4454
  },
4067
4455
  "config": config_summary,
4456
+ "ok": final_status["freshness"]["status"] == "OK",
4457
+ "upToDate": final_status["freshness"]["status"] == "OK",
4458
+ "freshness": final_status["freshness"],
4459
+ "health": final_status["health"],
4068
4460
  "sync": {
4069
4461
  "upToDate": sync["upToDate"],
4070
4462
  "action": sync["action"],
@@ -4073,8 +4465,13 @@ def auto_knowledge(
4073
4465
  "paths": sync["paths"],
4074
4466
  },
4075
4467
  "suggestions": {
4468
+ "eligible": suggestions["eligible"],
4469
+ "selected": suggestions["selected"],
4076
4470
  "suggested": suggestions["suggested"],
4077
4471
  "applied": suggestions["applied"],
4472
+ "remaining": suggestions["remaining"],
4473
+ "unavailable": suggestions["unavailable"],
4474
+ "covered": suggestions["covered"],
4078
4475
  "report": suggestions["report"],
4079
4476
  },
4080
4477
  "verification": {
@@ -4092,7 +4489,9 @@ def auto_knowledge(
4092
4489
  "supersededReview": len(audit["supersededReview"]),
4093
4490
  "conflictReview": len(audit["conflictReview"]),
4094
4491
  "activeReview": len(audit["activeReview"]),
4492
+ "counts": audit.get("counts", {}),
4095
4493
  },
4494
+ "judge": judge_counts,
4096
4495
  "lifecycle": {
4097
4496
  "validatorsApplied": suggestions["applied"],
4098
4497
  "candidateAutoPromoted": ingest_mode.get(
@@ -4100,7 +4499,15 @@ def auto_knowledge(
4100
4499
  ),
4101
4500
  "activeAutoDemoted": ingest_mode.get("activeAutoDemoted", 0),
4102
4501
  "validationAutoDemoted": ingest_mode.get("validationAutoDemoted", verification["autoDemoted"]),
4103
- "pendingAgentJudge": len(audit["conflictReview"]) + len(audit["candidateReview"]),
4502
+ "pendingAgentJudge": judge_counts.get("requiredDecisionCount", 0),
4503
+ "pendingAgentJudgeSummary": {
4504
+ "requiredDecisionCount": judge_counts.get("requiredDecisionCount", 0),
4505
+ "pendingWorkItemCount": judge_counts.get("pendingWorkItemCount", 0),
4506
+ "previewCount": judge_counts.get("previewCount", 0),
4507
+ "previewEntryCount": judge_counts.get("previewEntryCount", 0),
4508
+ "deferredByLimitCount": judge_counts.get("deferredByLimitCount", 0),
4509
+ "quarantinedCount": judge_counts.get("quarantinedCount", 0),
4510
+ },
4104
4511
  },
4105
4512
  }
4106
4513
 
@@ -4211,6 +4618,7 @@ def sync_status(
4211
4618
  incremental: bool = True,
4212
4619
  *,
4213
4620
  progress: ProgressReporter | None = None,
4621
+ check: bool = False,
4214
4622
  ) -> dict[str, Any]:
4215
4623
  project = project.resolve()
4216
4624
  snapshot = build_snapshot(project)
@@ -4269,6 +4677,7 @@ def sync_status(
4269
4677
  )
4270
4678
  action = "ingested"
4271
4679
  reasons = []
4680
+ index = refreshed
4272
4681
 
4273
4682
  # HH-KNOW-20260730-001: an up-to-date index is not the whole story --
4274
4683
  # maintenance-outbox items enqueued by `harness archive` still need to be
@@ -4288,7 +4697,27 @@ def sync_status(
4288
4697
  "maintain --project <project> --drain"
4289
4698
  )
4290
4699
 
4291
- if index_current:
4700
+ if check:
4701
+ pending_count = len(list((outbox_root / "pending").glob("*.json"))) if outbox_root.is_dir() else 0
4702
+ failed_count = len(list((outbox_root / "failed").glob("*.json"))) if outbox_root.is_dir() else 0
4703
+ running_count = len(list((outbox_root / "running").glob("*.json"))) if outbox_root.is_dir() else 0
4704
+ outbox_ok = pending_count == 0 and failed_count == 0 and running_count == 0
4705
+ maintenance = {
4706
+ "attempted": False,
4707
+ "skipped": True,
4708
+ "readOnly": True,
4709
+ "skippedReason": "read-only check",
4710
+ "ok": outbox_ok,
4711
+ "pending": pending_count,
4712
+ "failed": failed_count,
4713
+ "running": running_count,
4714
+ }
4715
+ if not outbox_ok:
4716
+ next_action = (
4717
+ "Read-only check found maintenance work. Run the default auto workflow "
4718
+ "or `hunter-harness sync --apply safe`."
4719
+ )
4720
+ elif index_current:
4292
4721
  drain_result = drain_maintenance_outbox(project)
4293
4722
  outbox_ok = bool(drain_result.get("ok")) and drain_result.get("remaining", 0) == 0
4294
4723
  maintenance = {
@@ -4326,6 +4755,24 @@ def sync_status(
4326
4755
 
4327
4756
  ok = index_current and bool(maintenance.get("ok"))
4328
4757
 
4758
+ indexed_count = (
4759
+ int((index.get("archives") or {}).get("indexed") or 0)
4760
+ if isinstance(index, dict)
4761
+ else 0
4762
+ )
4763
+ freshness = {
4764
+ "status": "OK" if index_current else "WARN",
4765
+ "archivesScanned": len(current_records),
4766
+ "archivesIndexed": indexed_count,
4767
+ "headCurrent": index_current,
4768
+ }
4769
+ entries = [entry for _, entry in load_entry_files(knowledge)]
4770
+ ingest_mode = index.get("ingestMode", {}) if isinstance(index, dict) else {}
4771
+ duplicate_groups = int(ingest_mode.get("normalizedDuplicatesRemoved") or 0)
4772
+ health = summarize_knowledge_health(
4773
+ entries,
4774
+ duplicate_groups=duplicate_groups,
4775
+ )
4329
4776
  result = {
4330
4777
  "project": str(project),
4331
4778
  "upToDate": not reasons,
@@ -4337,6 +4784,8 @@ def sync_status(
4337
4784
  "sqlite": str(sqlite_path),
4338
4785
  },
4339
4786
  "maintenance": maintenance,
4787
+ "freshness": freshness,
4788
+ "health": health,
4340
4789
  "ok": ok,
4341
4790
  "nextAction": next_action,
4342
4791
  }
@@ -5169,6 +5618,250 @@ def reverify_stale_knowledge(project: Path) -> dict[str, Any]:
5169
5618
  }
5170
5619
 
5171
5620
 
5621
+ def review_evidence_fingerprints(entry: dict[str, Any]) -> dict[str, str]:
5622
+ source = entry.get("source") or {}
5623
+ lifecycle = entry.get("lifecycle") or {}
5624
+ return {
5625
+ "summarySha256": str(source.get("summarySha256") or ""),
5626
+ "entryBodySha256": sha256_text(normalize_knowledge_text(entry.get("body"))),
5627
+ "publicationGateFingerprint": sha256_text(
5628
+ json.dumps(
5629
+ {
5630
+ "publishBlocked": lifecycle.get("publishBlocked") or [],
5631
+ "publicationState": lifecycle.get("publicationState"),
5632
+ "finalStatus": source.get("finalStatus"),
5633
+ },
5634
+ ensure_ascii=False,
5635
+ sort_keys=True,
5636
+ separators=(",", ":"),
5637
+ )
5638
+ ),
5639
+ "validatorFingerprint": sha256_text(
5640
+ json.dumps(
5641
+ entry.get("validators") or [],
5642
+ ensure_ascii=False,
5643
+ sort_keys=True,
5644
+ separators=(",", ":"),
5645
+ )
5646
+ ),
5647
+ "conflictFingerprint": sha256_text(
5648
+ json.dumps(
5649
+ {
5650
+ "status": entry.get("status"),
5651
+ "conflictsWith": sorted(lifecycle.get("conflictsWith") or []),
5652
+ "conflictEvidence": lifecycle.get("conflictEvidence") or [],
5653
+ },
5654
+ ensure_ascii=False,
5655
+ sort_keys=True,
5656
+ separators=(",", ":"),
5657
+ )
5658
+ ),
5659
+ }
5660
+
5661
+
5662
+ def defer_is_current(entry: dict[str, Any]) -> bool:
5663
+ lifecycle = entry.get("lifecycle") or {}
5664
+ if lifecycle.get("judgeAction") != "defer":
5665
+ return False
5666
+ recorded = lifecycle.get("deferEvidence")
5667
+ if not isinstance(recorded, dict):
5668
+ return False
5669
+ if recorded != review_evidence_fingerprints(entry):
5670
+ return False
5671
+ review_after = str(lifecycle.get("reviewAfter") or "")
5672
+ return bool(review_after and review_after > dt.date.today().isoformat())
5673
+
5674
+
5675
+ def judge_entry_payload(entry: dict[str, Any]) -> dict[str, Any]:
5676
+ return {
5677
+ "id": entry.get("id"),
5678
+ "type": entry.get("type"),
5679
+ "status": entry.get("status"),
5680
+ "title": entry.get("title"),
5681
+ "body": entry.get("body"),
5682
+ "summary": entry.get("summary"),
5683
+ "source": entry.get("source"),
5684
+ "scope": entry.get("scope"),
5685
+ "confidence": entry.get("confidence"),
5686
+ "lifecycle": {
5687
+ "conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
5688
+ "conflictEvidence": entry.get("lifecycle", {}).get("conflictEvidence") or {},
5689
+ "staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
5690
+ },
5691
+ }
5692
+
5693
+
5694
+ def collect_judge_work(
5695
+ entries: list[dict[str, Any]],
5696
+ limit: int = 100,
5697
+ ) -> dict[str, Any]:
5698
+ by_id = {
5699
+ str(entry.get("id")): entry
5700
+ for entry in entries
5701
+ if entry.get("id")
5702
+ }
5703
+ blocked_ids = {
5704
+ entry_id
5705
+ for entry_id, entry in by_id.items()
5706
+ if (entry.get("lifecycle") or {}).get("publishBlocked")
5707
+ }
5708
+ all_conflicts: list[tuple[tuple[str, str], dict[str, Any], dict[str, Any] | None]] = []
5709
+ seen_pairs: set[tuple[str, str]] = set()
5710
+ for entry in entries:
5711
+ if entry.get("status") != "conflicted":
5712
+ continue
5713
+ for other_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
5714
+ pair = tuple(sorted([str(entry["id"]), str(other_id)]))
5715
+ if pair in seen_pairs:
5716
+ continue
5717
+ seen_pairs.add(pair)
5718
+ all_conflicts.append((pair, entry, by_id.get(str(other_id))))
5719
+
5720
+ eligible_conflicts = []
5721
+ for pair, left, right in all_conflicts:
5722
+ pair_entries = [item for item in (left, right) if item is not None]
5723
+ if any(entry_id in blocked_ids for entry_id in pair):
5724
+ continue
5725
+ if pair_entries and all(
5726
+ (item.get("lifecycle") or {}).get("judgeAction") == "keep-conflict"
5727
+ or defer_is_current(item)
5728
+ for item in pair_entries
5729
+ ):
5730
+ continue
5731
+ eligible_conflicts.append(
5732
+ {
5733
+ "kind": "conflict",
5734
+ "ids": list(pair),
5735
+ "entries": [judge_entry_payload(item) for item in pair_entries],
5736
+ }
5737
+ )
5738
+
5739
+ candidates = [entry for entry in entries if entry.get("status") == "candidate"]
5740
+ eligible_candidates = [
5741
+ entry
5742
+ for entry in candidates
5743
+ if str(entry.get("id")) not in blocked_ids and not defer_is_current(entry)
5744
+ ]
5745
+ bounded_limit = max(0, limit)
5746
+ displayed_conflicts = eligible_conflicts[:bounded_limit]
5747
+ displayed_candidates = eligible_candidates[:bounded_limit]
5748
+ conflict_entry_ids = {
5749
+ entry_id
5750
+ for item in eligible_conflicts
5751
+ for entry_id in item["ids"]
5752
+ }
5753
+ required_ids = conflict_entry_ids | {
5754
+ str(entry["id"]) for entry in eligible_candidates
5755
+ }
5756
+ displayed_ids = {
5757
+ entry_id
5758
+ for item in displayed_conflicts
5759
+ for entry_id in item["ids"]
5760
+ } | {
5761
+ str(entry["id"]) for entry in displayed_candidates
5762
+ }
5763
+ pending_work_item_count = len(eligible_conflicts) + len(eligible_candidates)
5764
+ preview_count = len(displayed_conflicts) + len(displayed_candidates)
5765
+ all_conflict_entry_ids = {
5766
+ entry_id for pair, _, _ in all_conflicts for entry_id in pair
5767
+ }
5768
+ return {
5769
+ "conflicts": displayed_conflicts,
5770
+ "promoteCandidates": [
5771
+ {"kind": "promote-candidate", **judge_entry_payload(entry)}
5772
+ for entry in displayed_candidates
5773
+ ],
5774
+ "counts": {
5775
+ "totalCandidateEntries": len(candidates),
5776
+ "totalConflictGroups": len(all_conflicts),
5777
+ "totalConflictEntries": len(all_conflict_entry_ids),
5778
+ "requiredDecisionCount": len(required_ids),
5779
+ "pendingWorkItemCount": pending_work_item_count,
5780
+ "previewCount": preview_count,
5781
+ "previewEntryCount": len(displayed_ids),
5782
+ "deferredByLimitCount": max(0, pending_work_item_count - preview_count),
5783
+ "displayedCandidateEntries": len(displayed_candidates),
5784
+ "displayedConflictGroups": len(displayed_conflicts),
5785
+ "quarantinedEntries": len(blocked_ids),
5786
+ "quarantinedCount": len(blocked_ids),
5787
+ },
5788
+ }
5789
+
5790
+
5791
+ def summarize_knowledge_health(
5792
+ entries: list[dict[str, Any]],
5793
+ *,
5794
+ duplicate_groups: int = 0,
5795
+ ) -> dict[str, Any]:
5796
+ lifecycle_names = [
5797
+ "active",
5798
+ "candidate",
5799
+ "stale",
5800
+ "superseded",
5801
+ "conflicted",
5802
+ ]
5803
+ content_lifecycle = {
5804
+ status: sum(1 for entry in entries if entry.get("status") == status)
5805
+ for status in lifecycle_names
5806
+ }
5807
+ review_state = {
5808
+ "pending": 0,
5809
+ "deferred": 0,
5810
+ "decided": 0,
5811
+ "quarantined": 0,
5812
+ }
5813
+ publication_state = {"publishable": 0, "blocked": 0, "unverified": 0}
5814
+ validation_state = {"covered": 0, "uncovered": 0, "failed": 0}
5815
+ for entry in entries:
5816
+ lifecycle = entry.get("lifecycle") or {}
5817
+ blocked = bool(lifecycle.get("publishBlocked"))
5818
+ if blocked:
5819
+ review_state["quarantined"] += 1
5820
+ publication_state["blocked"] += 1
5821
+ elif lifecycle.get("publicationState") == "unverified":
5822
+ publication_state["unverified"] += 1
5823
+ else:
5824
+ publication_state["publishable"] += 1
5825
+
5826
+ if not blocked and defer_is_current(entry):
5827
+ review_state["deferred"] += 1
5828
+ elif lifecycle.get("judgeAction") and lifecycle.get("judgeAction") != "defer":
5829
+ review_state["decided"] += 1
5830
+ elif not blocked and entry.get("status") in {"candidate", "conflicted"}:
5831
+ review_state["pending"] += 1
5832
+
5833
+ validation = lifecycle.get("validation") or {}
5834
+ if validation.get("status") == "failed":
5835
+ validation_state["failed"] += 1
5836
+ if isinstance(entry.get("validators"), list) and entry["validators"]:
5837
+ validation_state["covered"] += 1
5838
+ else:
5839
+ validation_state["uncovered"] += 1
5840
+
5841
+ status = "OK"
5842
+ if (
5843
+ publication_state["blocked"] > 0
5844
+ or content_lifecycle["conflicted"] > 0
5845
+ or validation_state["failed"] > 0
5846
+ or duplicate_groups > 0
5847
+ ):
5848
+ status = "WARN"
5849
+ elif (
5850
+ review_state["pending"] > 0
5851
+ or review_state["deferred"] > 0
5852
+ or validation_state["uncovered"] > 0
5853
+ ):
5854
+ status = "ADVISORY"
5855
+ return {
5856
+ "status": status,
5857
+ "contentLifecycle": content_lifecycle,
5858
+ "reviewState": review_state,
5859
+ "publicationState": publication_state,
5860
+ "validationState": validation_state,
5861
+ "duplicateGroups": duplicate_groups,
5862
+ }
5863
+
5864
+
5172
5865
  def snapshot_entry_state(entry: dict[str, Any]) -> dict[str, Any]:
5173
5866
  return json_clone(
5174
5867
  {
@@ -5196,104 +5889,33 @@ def judge_export(project: Path) -> dict[str, Any]:
5196
5889
  if not (knowledge / "index.json").exists():
5197
5890
  build_index(project)
5198
5891
  entries = [entry for _, entry in load_entry_files(knowledge)]
5199
- by_id = {entry["id"]: entry for entry in entries}
5200
-
5201
- conflicts: list[dict[str, Any]] = []
5202
- seen_pairs: set[tuple[str, str]] = set()
5203
- for entry in entries:
5204
- if entry.get("status") != "conflicted":
5205
- continue
5206
- if entry.get("lifecycle", {}).get("judgeAction") in {"keep-conflict", "defer"}:
5207
- continue
5208
- for other_id in entry.get("lifecycle", {}).get("conflictsWith") or []:
5209
- pair = tuple(sorted([entry["id"], str(other_id)]))
5210
- if pair in seen_pairs:
5211
- continue
5212
- seen_pairs.add(pair)
5213
- other = by_id.get(str(other_id))
5214
- conflicts.append(
5215
- {
5216
- "kind": "conflict",
5217
- "ids": list(pair),
5218
- "entries": [
5219
- {
5220
- "id": entry["id"],
5221
- "type": entry.get("type"),
5222
- "status": entry.get("status"),
5223
- "title": entry.get("title"),
5224
- "body": entry.get("body"),
5225
- "summary": entry.get("summary"),
5226
- "source": entry.get("source"),
5227
- "scope": entry.get("scope"),
5228
- "lifecycle": {
5229
- "conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
5230
- "staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
5231
- },
5232
- },
5233
- {
5234
- "id": other.get("id") if other else other_id,
5235
- "type": other.get("type") if other else None,
5236
- "status": other.get("status") if other else None,
5237
- "title": other.get("title") if other else None,
5238
- "body": other.get("body") if other else None,
5239
- "summary": other.get("summary") if other else None,
5240
- "source": other.get("source") if other else None,
5241
- "scope": other.get("scope") if other else None,
5242
- "lifecycle": {
5243
- "conflictsWith": (other.get("lifecycle", {}) or {}).get("conflictsWith") or [],
5244
- "staleReasons": (other.get("lifecycle", {}) or {}).get("staleReasons") or [],
5245
- }
5246
- if other
5247
- else {},
5248
- },
5249
- ],
5250
- }
5251
- )
5252
-
5253
- promote_candidates: list[dict[str, Any]] = []
5254
- for entry in entries:
5255
- if entry.get("status") != "candidate":
5256
- continue
5257
- if entry.get("lifecycle", {}).get("judgeAction") == "defer":
5258
- review_after = str(entry.get("lifecycle", {}).get("reviewAfter") or "")
5259
- if not review_after or review_after > dt.date.today().isoformat():
5260
- continue
5261
- promote_candidates.append(
5262
- {
5263
- "kind": "promote-candidate",
5264
- "id": entry["id"],
5265
- "type": entry.get("type"),
5266
- "status": entry.get("status"),
5267
- "title": entry.get("title"),
5268
- "body": entry.get("body"),
5269
- "summary": entry.get("summary"),
5270
- "source": entry.get("source"),
5271
- "scope": entry.get("scope"),
5272
- "confidence": entry.get("confidence"),
5273
- "lifecycle": {
5274
- "staleReasons": entry.get("lifecycle", {}).get("staleReasons") or [],
5275
- "conflictsWith": entry.get("lifecycle", {}).get("conflictsWith") or [],
5276
- },
5277
- }
5278
- )
5892
+ max_candidates = judge_config(load_config(knowledge))["maxCandidatesPerRun"]
5893
+ work = collect_judge_work(entries, limit=max_candidates)
5894
+ counts = {
5895
+ **work["counts"],
5896
+ # Compatibility aliases retained for two releases.
5897
+ "conflicts": work["counts"]["displayedConflictGroups"],
5898
+ "promoteCandidates": work["counts"]["displayedCandidateEntries"],
5899
+ "pending": work["counts"]["pendingWorkItemCount"],
5900
+ }
5279
5901
 
5280
5902
  payload = {
5281
5903
  "schemaVersion": 1,
5282
5904
  "generatedAt": now_iso(),
5283
5905
  "project": str(project),
5284
5906
  "manualReview": manual_review_enabled(project),
5285
- "counts": {
5286
- "conflicts": len(conflicts),
5287
- "promoteCandidates": len(promote_candidates),
5288
- "pending": len(conflicts) + len(promote_candidates),
5289
- },
5290
- "conflicts": conflicts,
5291
- "promoteCandidates": promote_candidates,
5907
+ "counts": counts,
5908
+ "conflicts": work["conflicts"],
5909
+ "promoteCandidates": work["promoteCandidates"],
5292
5910
  "actions": sorted(JUDGE_ACTIONS),
5293
5911
  }
5294
- export_path = knowledge / "reports" / f"judge-export-{timestamp()}.json"
5295
- write_json(export_path, payload)
5912
+ export_path, digest = write_content_addressed_json_report(
5913
+ knowledge,
5914
+ "judge-export",
5915
+ payload,
5916
+ )
5296
5917
  payload["exportPath"] = str(export_path)
5918
+ payload["exportSha256"] = digest
5297
5919
  return payload
5298
5920
 
5299
5921
 
@@ -5370,6 +5992,7 @@ def apply_judge_decision(
5370
5992
  lifecycle["judgeReason"] = reason
5371
5993
  lifecycle["deferredAt"] = now_iso()
5372
5994
  lifecycle["reviewAfter"] = decision.get("reviewAfter")
5995
+ lifecycle["deferEvidence"] = review_evidence_fingerprints(entry)
5373
5996
  lifecycle["lastCheckedAt"] = now_iso()
5374
5997
  relocate_entry_file(knowledge, entry, source_path)
5375
5998
  path_by_id[entry["id"]] = (
@@ -5418,6 +6041,48 @@ def persist_judge_decisions(knowledge: Path, applied: list[dict[str, Any]]) -> P
5418
6041
  return path
5419
6042
 
5420
6043
 
6044
+ def store_rollback_snapshot(
6045
+ knowledge: Path,
6046
+ snapshot: dict[str, Any],
6047
+ ) -> dict[str, str]:
6048
+ raw = json.dumps(
6049
+ snapshot,
6050
+ ensure_ascii=False,
6051
+ sort_keys=True,
6052
+ separators=(",", ":"),
6053
+ ).encode("utf-8")
6054
+ digest = hashlib.sha256(raw).hexdigest()
6055
+ relative = Path("reports") / "blobs" / f"rollback-{digest}.json.gz"
6056
+ path = knowledge / relative
6057
+ if not path.exists():
6058
+ path.parent.mkdir(parents=True, exist_ok=True)
6059
+ path.write_bytes(gzip.compress(raw, compresslevel=9, mtime=0))
6060
+ return {
6061
+ "path": str(relative).replace("\\", "/"),
6062
+ "sha256": digest,
6063
+ }
6064
+
6065
+
6066
+ def load_rollback_snapshot(
6067
+ knowledge: Path,
6068
+ pointer: dict[str, Any],
6069
+ ) -> dict[str, Any] | None:
6070
+ relative = str(pointer.get("path") or "")
6071
+ expected = str(pointer.get("sha256") or "")
6072
+ path = (knowledge / relative).resolve()
6073
+ reports_root = (knowledge / "reports").resolve()
6074
+ if not _path_is_within(path, reports_root) or not path.is_file():
6075
+ return None
6076
+ try:
6077
+ raw = gzip.decompress(path.read_bytes())
6078
+ if hashlib.sha256(raw).hexdigest() != expected:
6079
+ return None
6080
+ payload = json.loads(raw.decode("utf-8"))
6081
+ except (OSError, gzip.BadGzipFile, UnicodeDecodeError, json.JSONDecodeError):
6082
+ return None
6083
+ return payload if isinstance(payload, dict) else None
6084
+
6085
+
5421
6086
  def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dict[str, Any]:
5422
6087
  project = project.resolve()
5423
6088
  knowledge = project / ".harness" / "knowledge"
@@ -5456,6 +6121,8 @@ def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dic
5456
6121
  continue
5457
6122
  try:
5458
6123
  record = apply_judge_decision(knowledge, found, source_path, item, by_id, path_by_id)
6124
+ before = record.pop("before")
6125
+ record["beforeSnapshot"] = store_rollback_snapshot(knowledge, before)
5459
6126
  applied.append(record)
5460
6127
  except ValueError as exc:
5461
6128
  errors.append({"id": entry_id, "error": str(exc)})
@@ -5470,14 +6137,18 @@ def judge_apply(project: Path, decisions_path: Path, force: bool = False) -> dic
5470
6137
  "applied": applied,
5471
6138
  "errors": errors,
5472
6139
  }
5473
- judgement_path = knowledge / "reports" / f"judgements-{timestamp()}.json"
5474
- write_json(judgement_path, judgement)
6140
+ judgement_path, judgement_sha256 = write_content_addressed_json_report(
6141
+ knowledge,
6142
+ "judgements",
6143
+ judgement,
6144
+ )
5475
6145
  decisions_ledger = persist_judge_decisions(knowledge, applied)
5476
6146
  index = refresh_outputs_from_entry_files(project, knowledge)
5477
6147
  return {
5478
6148
  "project": str(project),
5479
6149
  "generatedAt": judgement["generatedAt"],
5480
6150
  "judgement": str(judgement_path),
6151
+ "judgementSha256": judgement_sha256,
5481
6152
  "decisionsLedger": str(decisions_ledger),
5482
6153
  "applied": len(applied),
5483
6154
  "errors": errors,
@@ -5533,6 +6204,13 @@ def rollback_judgement(project: Path, judgement_path: Path) -> dict[str, Any]:
5533
6204
  if not isinstance(item, dict):
5534
6205
  continue
5535
6206
  before = item.get("before")
6207
+ if not isinstance(before, dict):
6208
+ pointer = item.get("beforeSnapshot")
6209
+ before = (
6210
+ load_rollback_snapshot(knowledge, pointer)
6211
+ if isinstance(pointer, dict)
6212
+ else None
6213
+ )
5536
6214
  if not isinstance(before, dict) or not before.get("id"):
5537
6215
  continue
5538
6216
  entry_id = str(before["id"])
@@ -5726,7 +6404,217 @@ def drain_maintenance_outbox(
5726
6404
  }
5727
6405
 
5728
6406
 
6407
+ def repair_legacy_publication_gates(
6408
+ project: Path,
6409
+ *,
6410
+ apply: bool = True,
6411
+ ) -> dict[str, Any]:
6412
+ project = project.resolve()
6413
+ knowledge = project / ".harness" / "knowledge"
6414
+ path = knowledge / "publication-attestations.json"
6415
+ records: dict[str, Any] = {}
6416
+ if path.exists():
6417
+ try:
6418
+ current = read_json(path)
6419
+ if isinstance(current, dict) and isinstance(current.get("records"), dict):
6420
+ records = dict(current["records"])
6421
+ except (OSError, json.JSONDecodeError):
6422
+ pass
6423
+ verified: list[str] = []
6424
+ needs_confirmation: list[str] = []
6425
+ for summary_path in discover_archive_summary_paths(project):
6426
+ try:
6427
+ summary = read_json(summary_path)
6428
+ except (OSError, json.JSONDecodeError):
6429
+ continue
6430
+ if not isinstance(summary, dict):
6431
+ continue
6432
+ consistency = (summary.get("reportPipeline") or {}).get("sourceConsistency")
6433
+ if consistency is not None:
6434
+ continue
6435
+ resolution = resolve_archive_summary(summary_path)
6436
+ archive_dir = archive_dir_from_summary(summary_path)
6437
+ archive_rel = rel_to_project(project, archive_dir)
6438
+ summary_hash = str(resolution.get("summarySha256") or "")
6439
+ source_commit = str(
6440
+ summary.get("finalCommit") or summary.get("final_commit") or ""
6441
+ )
6442
+ source_commit_verified = bool(
6443
+ source_commit
6444
+ and is_git_repo(project)
6445
+ and git_commit_exists(project, source_commit)
6446
+ )
6447
+ authority_verified = bool(resolution.get("allowed") and summary_hash)
6448
+ if not (authority_verified and source_commit_verified):
6449
+ needs_confirmation.append(archive_rel)
6450
+ continue
6451
+ record = {
6452
+ "status": "verified",
6453
+ "summarySha256": summary_hash,
6454
+ "sourceCommit": source_commit,
6455
+ "authorityStatus": resolution.get("status"),
6456
+ "verifiedAt": now_iso(),
6457
+ "method": "authoritative-pointer-and-source-commit",
6458
+ }
6459
+ record["attestationSha256"] = sha256_text(
6460
+ json.dumps(
6461
+ record,
6462
+ ensure_ascii=False,
6463
+ sort_keys=True,
6464
+ separators=(",", ":"),
6465
+ )
6466
+ )
6467
+ records[archive_rel] = record
6468
+ verified.append(archive_rel)
6469
+ if apply and verified:
6470
+ write_json(
6471
+ path,
6472
+ {
6473
+ "schemaVersion": 1,
6474
+ "updatedAt": now_iso(),
6475
+ "records": dict(sorted(records.items())),
6476
+ },
6477
+ )
6478
+ return {
6479
+ "verified": len(verified),
6480
+ "verifiedArchives": verified,
6481
+ "needsConfirmation": len(needs_confirmation),
6482
+ "needsConfirmationArchives": needs_confirmation,
6483
+ "applied": apply and bool(verified),
6484
+ "path": str(path),
6485
+ }
6486
+
6487
+
6488
+ def prune_knowledge_reports(knowledge: Path, keep: int = 10) -> dict[str, int]:
6489
+ reports = knowledge / "reports"
6490
+ pinned: set[Path] = set()
6491
+ latest_path = reports / "latest.json"
6492
+ if latest_path.exists():
6493
+ try:
6494
+ latest = read_json(latest_path)
6495
+ latest_reports = (
6496
+ latest.get("reports")
6497
+ if isinstance(latest, dict)
6498
+ else None
6499
+ )
6500
+ if isinstance(latest_reports, dict):
6501
+ for pointer in latest_reports.values():
6502
+ if not isinstance(pointer, dict):
6503
+ continue
6504
+ relative = str(pointer.get("path") or "")
6505
+ candidate = (knowledge / relative).resolve()
6506
+ if _path_is_within(candidate, reports.resolve()):
6507
+ pinned.add(candidate)
6508
+ except (OSError, json.JSONDecodeError):
6509
+ pass
6510
+ removed: dict[str, int] = {}
6511
+ for prefix in (
6512
+ "audit-report-",
6513
+ "ingest-report-",
6514
+ "validator-suggestions-",
6515
+ "verification-report-",
6516
+ "judge-export-",
6517
+ "judgements-",
6518
+ ):
6519
+ paths = sorted(
6520
+ reports.glob(f"{prefix}*"),
6521
+ key=lambda item: item.stat().st_mtime_ns,
6522
+ reverse=True,
6523
+ )
6524
+ removed[prefix.rstrip("-")] = 0
6525
+ retained = set(paths[: max(0, keep)]) | pinned
6526
+ for path in paths:
6527
+ if path.resolve() in {item.resolve() for item in retained}:
6528
+ continue
6529
+ path.unlink()
6530
+ removed[prefix.rstrip("-")] += 1
6531
+
6532
+ referenced_blobs: set[Path] = set()
6533
+ for path in reports.glob("judgements-*.json"):
6534
+ try:
6535
+ judgement = read_json(path)
6536
+ except (OSError, json.JSONDecodeError):
6537
+ continue
6538
+ if not isinstance(judgement, dict):
6539
+ continue
6540
+ for item in judgement.get("applied") or []:
6541
+ if not isinstance(item, dict):
6542
+ continue
6543
+ pointer = item.get("beforeSnapshot")
6544
+ if not isinstance(pointer, dict):
6545
+ continue
6546
+ relative = str(pointer.get("path") or "")
6547
+ candidate = (knowledge / relative).resolve()
6548
+ if _path_is_within(candidate, reports.resolve()):
6549
+ referenced_blobs.add(candidate)
6550
+ removed["rollback-blobs"] = 0
6551
+ for path in (reports / "blobs").glob("rollback-*.json.gz"):
6552
+ if path.resolve() in referenced_blobs:
6553
+ continue
6554
+ path.unlink()
6555
+ removed["rollback-blobs"] += 1
6556
+ return removed
6557
+
6558
+
6559
+ def repair_knowledge(
6560
+ project: Path,
6561
+ action: str = "all",
6562
+ *,
6563
+ apply: bool = True,
6564
+ ) -> dict[str, Any]:
6565
+ project = project.resolve()
6566
+ if action not in {"all", "legacy-publication-gates"}:
6567
+ raise ValueError(f"unsupported repair action: {action}")
6568
+ legacy = repair_legacy_publication_gates(project, apply=apply)
6569
+ index = (
6570
+ build_index(project, incremental=False)
6571
+ if action == "all" and apply
6572
+ else None
6573
+ )
6574
+ retention = (
6575
+ prune_knowledge_reports(project / ".harness" / "knowledge")
6576
+ if action == "all" and apply
6577
+ else {}
6578
+ )
6579
+ status = sync_status(project, check=True)
6580
+ return {
6581
+ "command": "repair",
6582
+ "project": str(project),
6583
+ "action": action,
6584
+ "applied": apply,
6585
+ "legacyPublicationGates": legacy,
6586
+ "dedupe": {
6587
+ "exactDuplicatesRemoved": (
6588
+ index.get("ingestMode", {}).get("exactDuplicatesRemoved", 0)
6589
+ if isinstance(index, dict)
6590
+ else 0
6591
+ ),
6592
+ "normalizedDuplicatesRemoved": (
6593
+ index.get("ingestMode", {}).get("normalizedDuplicatesRemoved", 0)
6594
+ if isinstance(index, dict)
6595
+ else 0
6596
+ ),
6597
+ "nearDuplicatesMerged": (
6598
+ index.get("ingestMode", {}).get("nearDuplicatesMerged", 0)
6599
+ if isinstance(index, dict)
6600
+ else 0
6601
+ ),
6602
+ },
6603
+ "reportRetentionRemoved": retention,
6604
+ "freshness": status["freshness"],
6605
+ "health": status["health"],
6606
+ }
6607
+
6608
+
5729
6609
  def main(argv: list[str] | None = None) -> int:
6610
+ effective_argv = list(sys.argv[1:] if argv is None else argv)
6611
+ if not effective_argv:
6612
+ effective_argv = ["auto"]
6613
+ if effective_argv[0] == "admin":
6614
+ effective_argv = effective_argv[1:]
6615
+ if not effective_argv:
6616
+ print("admin requires an advanced command", file=sys.stderr)
6617
+ return 2
5730
6618
  parser = argparse.ArgumentParser(description="Build and query Harness knowledge indexes.")
5731
6619
  sub = parser.add_subparsers(dest="command", required=True)
5732
6620
 
@@ -5747,6 +6635,11 @@ def main(argv: list[str] | None = None) -> int:
5747
6635
  sync = sub.add_parser("sync", help="Check whether .harness/knowledge is current")
5748
6636
  sync.add_argument("--project", default=".", help="Project root containing .harness/archive")
5749
6637
  sync.add_argument("--update", action="store_true", help="Rebuild the index when it is out of date")
6638
+ sync.add_argument(
6639
+ "--check",
6640
+ action="store_true",
6641
+ help="Perform a complete read-only freshness and health check",
6642
+ )
5750
6643
  sync.add_argument(
5751
6644
  "--no-incremental",
5752
6645
  action="store_true",
@@ -5782,6 +6675,19 @@ def main(argv: list[str] | None = None) -> int:
5782
6675
  )
5783
6676
  auto.add_argument("--no-incremental", action="store_true", help="Refresh without reusing the archive entry cache")
5784
6677
 
6678
+ status = sub.add_parser("status", help="Read-only knowledge freshness and health")
6679
+ status.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
6680
+
6681
+ repair = sub.add_parser("repair", help="Repair legacy gates, duplicates, conflicts, and report retention")
6682
+ repair.add_argument(
6683
+ "repair_action",
6684
+ nargs="?",
6685
+ default="all",
6686
+ choices=["all", "legacy-publication-gates"],
6687
+ )
6688
+ repair.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
6689
+ repair.add_argument("--no-apply", action="store_true", help="Diagnose without writing repairs")
6690
+
5785
6691
  audit = sub.add_parser("audit", help="Generate review lists for candidate, stale, and superseded entries")
5786
6692
  audit.add_argument("--project", default=".", help="Project root containing .harness/knowledge")
5787
6693
  audit.add_argument("--limit", type=int, default=10)
@@ -5871,7 +6777,7 @@ def main(argv: list[str] | None = None) -> int:
5871
6777
  maintain.add_argument("--limit", type=int, default=50, help="Maximum items processed by --drain")
5872
6778
  maintain.add_argument("--json", action="store_true", help="Emit machine-readable JSON (default behavior)")
5873
6779
 
5874
- args = parser.parse_args(argv)
6780
+ args = parser.parse_args(effective_argv)
5875
6781
  if args.command == "ingest":
5876
6782
  index = build_index(
5877
6783
  Path(args.project),
@@ -5886,9 +6792,26 @@ def main(argv: list[str] | None = None) -> int:
5886
6792
  args.update,
5887
6793
  incremental=not args.no_incremental,
5888
6794
  progress=ProgressReporter(args.progress),
6795
+ check=args.check,
5889
6796
  )
5890
6797
  print(json.dumps(result, ensure_ascii=False, indent=2))
5891
6798
  return 0
6799
+ if args.command == "status":
6800
+ result = sync_status(Path(args.project), check=True)
6801
+ print(json.dumps(result, ensure_ascii=False, indent=2))
6802
+ return 0
6803
+ if args.command == "repair":
6804
+ try:
6805
+ result = repair_knowledge(
6806
+ Path(args.project),
6807
+ args.repair_action,
6808
+ apply=not args.no_apply,
6809
+ )
6810
+ except ValueError as exc:
6811
+ print(str(exc), file=sys.stderr)
6812
+ return 1
6813
+ print(json.dumps(result, ensure_ascii=False, indent=2))
6814
+ return 0
5892
6815
  if args.command == "auto":
5893
6816
  result = auto_knowledge(
5894
6817
  Path(args.project),