@hunter-harness/workflow-harness 0.2.27 → 0.2.29

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 (226) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +34 -2
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
  5. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  6. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  9. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +26 -2
  16. package/harness/bundles/general/claude-code/harness-test/checklist.md +15 -0
  17. package/harness/bundles/general/claude-code/harness-test/reference.md +28 -4
  18. package/harness/bundles/general/claude-code/scripts/harness_archive.py +2061 -187
  19. package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
  20. package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
  21. package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
  23. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
  26. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +974 -0
  27. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +87 -0
  28. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  29. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +34 -2
  30. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  32. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  33. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  36. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +26 -2
  43. package/harness/bundles/general/codebuddy/harness-test/checklist.md +15 -0
  44. package/harness/bundles/general/codebuddy/harness-test/reference.md +28 -4
  45. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2061 -187
  46. package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
  47. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
  48. package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
  49. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
  50. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
  52. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
  53. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +974 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +87 -0
  55. package/harness/bundles/general/codex/.harness-build.json +1 -1
  56. package/harness/bundles/general/codex/contracts/workflow-policy.json +34 -2
  57. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
  59. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
  60. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  63. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-test/SKILL.md +26 -2
  70. package/harness/bundles/general/codex/harness-test/checklist.md +15 -0
  71. package/harness/bundles/general/codex/harness-test/reference.md +28 -4
  72. package/harness/bundles/general/codex/scripts/harness_archive.py +2061 -187
  73. package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
  74. package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
  75. package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
  76. package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
  77. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
  78. package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
  79. package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
  80. package/harness/bundles/general/codex/scripts/harness_test_runner.py +974 -0
  81. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +87 -0
  82. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  83. package/harness/bundles/general/cursor/contracts/workflow-policy.json +34 -2
  84. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  85. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
  86. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
  87. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  90. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  91. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  94. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-test/SKILL.md +26 -2
  97. package/harness/bundles/general/cursor/harness-test/checklist.md +15 -0
  98. package/harness/bundles/general/cursor/harness-test/reference.md +28 -4
  99. package/harness/bundles/general/cursor/scripts/harness_archive.py +2061 -187
  100. package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
  101. package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
  102. package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
  103. package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
  104. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
  105. package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
  106. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
  107. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +974 -0
  108. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +87 -0
  109. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  110. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +34 -2
  111. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  113. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
  114. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
  115. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  118. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  120. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-test/SKILL.md +26 -2
  126. package/harness/bundles/java/claude-code/scripts/harness_archive.py +2061 -187
  127. package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
  128. package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
  129. package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
  130. package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
  131. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
  132. package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
  133. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
  134. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +974 -0
  135. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +87 -0
  136. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  137. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +34 -2
  138. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
  141. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
  142. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  145. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  148. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +26 -2
  153. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2061 -187
  154. package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
  155. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
  156. package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
  157. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
  158. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
  159. package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
  160. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
  161. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +974 -0
  162. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +87 -0
  163. package/harness/bundles/java/codex/.harness-build.json +1 -1
  164. package/harness/bundles/java/codex/contracts/workflow-policy.json +34 -2
  165. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  166. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
  168. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
  169. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  170. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  172. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  176. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-test/SKILL.md +26 -2
  180. package/harness/bundles/java/codex/scripts/harness_archive.py +2061 -187
  181. package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
  182. package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
  183. package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
  184. package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
  185. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
  186. package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
  187. package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
  188. package/harness/bundles/java/codex/scripts/harness_test_runner.py +974 -0
  189. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +87 -0
  190. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  191. package/harness/bundles/java/cursor/contracts/workflow-policy.json +34 -2
  192. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  193. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  194. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
  195. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
  196. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  197. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  198. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
  199. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  201. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  202. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  203. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  204. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  206. package/harness/bundles/java/cursor/harness-test/SKILL.md +26 -2
  207. package/harness/bundles/java/cursor/scripts/harness_archive.py +2061 -187
  208. package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
  209. package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
  210. package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
  211. package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
  212. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
  213. package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
  214. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
  215. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +974 -0
  216. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +87 -0
  217. package/harness/manifests/general/claude-code.json +34 -26
  218. package/harness/manifests/general/codebuddy.json +34 -26
  219. package/harness/manifests/general/codex.json +34 -26
  220. package/harness/manifests/general/cursor.json +34 -26
  221. package/harness/manifests/java/claude-code.json +34 -26
  222. package/harness/manifests/java/codebuddy.json +34 -26
  223. package/harness/manifests/java/codex.json +34 -26
  224. package/harness/manifests/java/cursor.json +34 -26
  225. package/hunter-workflow-family.json +2 -2
  226. package/package.json +1 -1
@@ -353,65 +353,267 @@ def changed_file_paths(summary: dict[str, Any]) -> list[str]:
353
353
  return files
354
354
 
355
355
 
356
- def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any]:
357
- """Knowledge publication gate for one archive (API-006/RET-40).
356
+ AUTHORITATIVE_RESOLVER_VERSION = 1
357
+
358
+
359
+ def _path_is_within(path: Path, root: Path) -> bool:
360
+ try:
361
+ path.relative_to(root)
362
+ return True
363
+ except ValueError:
364
+ return False
365
+
366
+
367
+ def _authority_file_state(path: Path, root: Path) -> dict[str, Any]:
368
+ """Describe one repair-control file without reading outside its allowed root."""
369
+ if not os.path.lexists(path):
370
+ return {"state": "missing", "sha256": None}
371
+ try:
372
+ resolved = path.resolve(strict=True)
373
+ except OSError:
374
+ return {"state": "unreadable", "sha256": None}
375
+ if not _path_is_within(resolved, root):
376
+ target_hash = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()
377
+ return {"state": "path-escape", "sha256": None, "targetHash": target_hash}
378
+ if not resolved.is_file():
379
+ return {"state": "not-regular", "sha256": None}
380
+ try:
381
+ return {"state": "file", "sha256": sha256_file(resolved)}
382
+ except OSError:
383
+ return {"state": "unreadable", "sha256": None}
384
+
358
385
 
359
- Blocks judge/apply/promote when: source consistency never ran or failed,
360
- the authoritative version is missing or hash-mismatched, or the archive
361
- is DEGRADED/UNVERIFIED. Ordinary ingest may still build quarantined
362
- candidates, but nothing from a blocked archive becomes active.
386
+ def resolve_archive_summary(summary_path: Path) -> dict[str, Any]:
387
+ """Resolve an archive's original or hash-valid authoritative summary.
388
+
389
+ Invalid repair metadata never becomes authoritative. The original path is
390
+ retained as a quarantined extraction fallback so one broken repair cannot
391
+ remove an archive from the knowledge index.
363
392
  """
393
+ archive_dir = archive_dir_from_summary(Path(summary_path))
394
+ archive_root = archive_dir.resolve()
395
+ original_path = archive_dir / "reports" / "final" / "summary-data.json"
396
+ fingerprint: dict[str, Any] = {
397
+ "resolverVersion": AUTHORITATIVE_RESOLVER_VERSION,
398
+ "status": "original",
399
+ "allowed": True,
400
+ "version": None,
401
+ "reasons": [],
402
+ "original": {"state": "unknown", "sha256": None},
403
+ "pointer": {"state": "missing", "sha256": None},
404
+ "record": {"state": "not-applicable", "sha256": None},
405
+ "summary": {"state": "not-applicable", "sha256": None},
406
+ }
407
+ result: dict[str, Any] = {
408
+ "summaryPath": original_path,
409
+ "summarySha256": None,
410
+ "status": "original",
411
+ "allowed": True,
412
+ "version": None,
413
+ "reasons": [],
414
+ "fingerprint": fingerprint,
415
+ }
416
+
417
+ def degraded(reason: str) -> dict[str, Any]:
418
+ result["status"] = "degraded"
419
+ result["allowed"] = False
420
+ result["reasons"] = [reason]
421
+ fingerprint["status"] = "degraded"
422
+ fingerprint["allowed"] = False
423
+ fingerprint["reasons"] = [reason]
424
+ return result
425
+
426
+ original_state = _authority_file_state(original_path, archive_root)
427
+ fingerprint["original"] = {
428
+ **original_state,
429
+ "sha256": None,
430
+ }
431
+ if original_state["state"] == "path-escape":
432
+ return degraded("summary-data.json escapes archive root")
433
+ if original_state["state"] == "missing":
434
+ return degraded("summary-data.json missing")
435
+ if original_state["state"] == "not-regular":
436
+ return degraded("summary-data.json is not a regular file")
437
+ if original_state["state"] != "file":
438
+ return degraded("summary-data.json unreadable")
439
+ result["summarySha256"] = original_state["sha256"]
440
+
441
+ derived_path = archive_dir / "derived"
442
+ pointer_path = derived_path / "authoritative.json"
443
+ if not os.path.lexists(pointer_path):
444
+ return result
445
+
446
+ derived_root = derived_path.resolve()
447
+ if not _path_is_within(derived_root, archive_root):
448
+ fingerprint["pointer"] = {
449
+ "state": "path-escape",
450
+ "sha256": None,
451
+ "targetHash": hashlib.sha256(str(derived_root).encode("utf-8")).hexdigest(),
452
+ }
453
+ return degraded("authoritative pointer escapes archive root")
454
+
455
+ pointer_state = _authority_file_state(pointer_path, derived_root)
456
+ fingerprint["pointer"] = pointer_state
457
+ if pointer_state["state"] == "path-escape":
458
+ return degraded("authoritative pointer escapes derived root")
459
+ if pointer_state["state"] == "missing":
460
+ return degraded("authoritative pointer missing")
461
+ if pointer_state["state"] == "not-regular":
462
+ return degraded("authoritative pointer is not a regular file")
463
+ if pointer_state["state"] != "file":
464
+ return degraded("authoritative pointer unreadable")
465
+
466
+ try:
467
+ pointer = read_json(pointer_path.resolve(strict=True))
468
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
469
+ return degraded("authoritative pointer unreadable")
470
+ if not isinstance(pointer, dict):
471
+ return degraded("authoritative pointer must be an object")
472
+
473
+ version = str(pointer.get("version") or "")
474
+ fingerprint["version"] = version or None
475
+ result["version"] = version or None
476
+ if re.fullmatch(r"v[0-9]+", version) is None:
477
+ return degraded("invalid authoritative version")
478
+
479
+ version_path = derived_path / version
480
+ try:
481
+ version_root = version_path.resolve(strict=True)
482
+ except OSError:
483
+ return degraded("authoritative version missing")
484
+ if not _path_is_within(version_root, derived_root):
485
+ return degraded("authoritative version escapes derived root")
486
+ if not version_root.is_dir():
487
+ return degraded("authoritative version is not a directory")
488
+
489
+ record_path = version_path / "repair-record.json"
490
+ record_state = _authority_file_state(record_path, version_root)
491
+ fingerprint["record"] = record_state
492
+ if record_state["state"] == "path-escape":
493
+ return degraded("repair record escapes authoritative version")
494
+ if record_state["state"] == "missing":
495
+ return degraded("repair record missing")
496
+ if record_state["state"] == "not-regular":
497
+ return degraded("repair record is not a regular file")
498
+ if record_state["state"] != "file":
499
+ return degraded("repair record unreadable")
500
+
501
+ derived_summary_path = version_path / "summary-data.json"
502
+ summary_state = _authority_file_state(derived_summary_path, version_root)
503
+ fingerprint["summary"] = summary_state
504
+ if summary_state["state"] == "path-escape":
505
+ return degraded("authoritative summary escapes authoritative version")
506
+ if summary_state["state"] == "missing":
507
+ return degraded("authoritative summary missing")
508
+ if summary_state["state"] == "not-regular":
509
+ return degraded("authoritative summary is not a regular file")
510
+ if summary_state["state"] != "file":
511
+ return degraded("authoritative summary unreadable")
512
+
513
+ try:
514
+ record = read_json(record_path.resolve(strict=True))
515
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
516
+ return degraded("repair record unreadable")
517
+ if not isinstance(record, dict):
518
+ return degraded("repair record must be an object")
519
+ record_version = record.get("version")
520
+ if not isinstance(record_version, str) or record_version != version:
521
+ return degraded("repair record version mismatch")
522
+
523
+ actual = "sha256:" + str(summary_state["sha256"])
524
+ if record.get("summarySha256") != actual:
525
+ return degraded("repair record summary hash mismatch")
526
+ if pointer.get("summarySha256") != actual:
527
+ return degraded("authoritative pointer hash mismatch")
528
+
529
+ try:
530
+ summary = read_json(derived_summary_path.resolve(strict=True))
531
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
532
+ return degraded("authoritative summary unreadable")
533
+ if not isinstance(summary, dict):
534
+ return degraded("authoritative summary must be an object")
535
+
536
+ result["summaryPath"] = derived_summary_path.resolve(strict=True)
537
+ result["summarySha256"] = summary_state["sha256"]
538
+ result["status"] = "authoritative"
539
+ result["version"] = version
540
+ fingerprint["status"] = "authoritative"
541
+ fingerprint["version"] = version
542
+ return result
543
+
544
+
545
+ def discover_archive_summary_paths(project: Path) -> list[Path]:
546
+ archive_root = project / ".harness" / "archive"
547
+ resolved_archive_root = archive_root.resolve()
548
+ originals = sorted(archive_root.glob("*/reports/final/summary-data.json"))
549
+ selected: list[Path] = []
550
+ for original in originals:
551
+ resolved_archive = archive_dir_from_summary(original).resolve()
552
+ if (
553
+ not _path_is_within(resolved_archive, resolved_archive_root)
554
+ or resolved_archive.parent != resolved_archive_root
555
+ ):
556
+ continue
557
+ resolution = resolve_archive_summary(original)
558
+ original_state = (resolution.get("fingerprint") or {}).get("original") or {}
559
+ if original_state.get("state") != "file":
560
+ continue
561
+ try:
562
+ selected_path = Path(resolution["summaryPath"]).resolve(strict=True)
563
+ except (KeyError, OSError):
564
+ continue
565
+ if (
566
+ not _path_is_within(selected_path, resolved_archive)
567
+ or not selected_path.is_file()
568
+ ):
569
+ continue
570
+ selected.append(selected_path)
571
+ return selected
572
+
573
+
574
+ def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any]:
575
+ """Knowledge publication gate for one archive (API-006/RET-40)."""
364
576
  result: dict[str, Any] = {"status": "missing", "allowed": False, "reasons": []}
577
+ archive_root = (Path(project) / ".harness" / "archive").resolve()
365
578
  archive_dir = (Path(project) / archive_rel).resolve()
366
- summary_path = archive_dir / "reports" / "final" / "summary-data.json"
367
- if not summary_path.is_file():
579
+ if not _path_is_within(archive_dir, archive_root) or archive_dir.parent != archive_root:
580
+ result["reasons"].append("archive path escapes archive root")
581
+ return result
582
+
583
+ original_path = archive_dir / "reports" / "final" / "summary-data.json"
584
+ resolution = resolve_archive_summary(original_path)
585
+ if resolution.get("version"):
586
+ result["authoritativeVersion"] = resolution["version"]
587
+
588
+ try:
589
+ summary_path = Path(resolution["summaryPath"]).resolve(strict=True)
590
+ except (KeyError, OSError):
368
591
  result["reasons"].append("summary-data.json missing")
369
592
  return result
593
+ if not _path_is_within(summary_path, archive_dir) or not summary_path.is_file():
594
+ result["status"] = "degraded"
595
+ result["reasons"].append("resolved summary escapes archive root")
596
+ return result
370
597
  try:
371
- summary = json.loads(summary_path.read_text(encoding="utf-8"))
372
- except (OSError, json.JSONDecodeError):
598
+ summary_hash = sha256_file(summary_path)
599
+ except OSError:
600
+ result["reasons"].append("summary-data.json unreadable")
601
+ return result
602
+ result["summaryData"] = rel_to_project(project, summary_path)
603
+ result["summarySha256"] = summary_hash
604
+ if not resolution.get("allowed"):
605
+ result["status"] = "degraded"
606
+ result["reasons"] = list(resolution.get("reasons") or [])
607
+ return result
608
+ try:
609
+ summary = read_json(summary_path)
610
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
373
611
  result["reasons"].append("summary-data.json unreadable")
374
612
  return result
375
613
  if not isinstance(summary, dict):
376
614
  result["reasons"].append("summary-data.json must be an object")
377
615
  return result
378
616
 
379
- # A versioned repair becomes authoritative only after its pointer and
380
- # repair-record hashes are verified. Gate the authoritative summary, not
381
- # the known-bad original that caused the repair.
382
- pointer_path = archive_dir / "derived" / "authoritative.json"
383
- if pointer_path.is_file():
384
- try:
385
- pointer = json.loads(pointer_path.read_text(encoding="utf-8"))
386
- version = str(pointer.get("version") or "")
387
- if re.fullmatch(r"v[0-9]+", version) is None:
388
- raise ValueError("invalid authoritative version")
389
- version_dir = (archive_dir / "derived" / version).resolve()
390
- if not version_dir.is_relative_to((archive_dir / "derived").resolve()):
391
- raise ValueError("authoritative version escapes derived root")
392
- record = json.loads(
393
- (version_dir / "repair-record.json").read_text(encoding="utf-8")
394
- )
395
- derived_path = version_dir / "summary-data.json"
396
- summary_bytes = derived_path.read_bytes()
397
- actual = "sha256:" + hashlib.sha256(summary_bytes).hexdigest()
398
- if record.get("summarySha256") != actual:
399
- result["status"] = "degraded"
400
- result["reasons"].append("repair record summary hash mismatch")
401
- return result
402
- if pointer.get("summarySha256") not in {None, actual}:
403
- result["status"] = "degraded"
404
- result["reasons"].append("authoritative pointer hash mismatch")
405
- return result
406
- summary = json.loads(summary_bytes.decode("utf-8"))
407
- if not isinstance(summary, dict):
408
- raise ValueError("authoritative summary must be an object")
409
- result["authoritativeVersion"] = version
410
- except (OSError, json.JSONDecodeError, UnicodeDecodeError, ValueError):
411
- result["status"] = "degraded"
412
- result["reasons"].append("authoritative version unreadable")
413
- return result
414
-
415
617
  consistency = (summary.get("reportPipeline") or {}).get("sourceConsistency")
416
618
  if consistency is None:
417
619
  result["status"] = "unverified"
@@ -442,16 +644,69 @@ def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any
442
644
  return result
443
645
 
444
646
 
647
+ def archive_publication_statuses(project: Path) -> dict[str, dict[str, Any]]:
648
+ """Resolve publication state for every safe immediate archive directory."""
649
+ archive_root = Path(project) / ".harness" / "archive"
650
+ try:
651
+ resolved_archive_root = archive_root.resolve(strict=True)
652
+ children = sorted(archive_root.iterdir())
653
+ except OSError:
654
+ return {}
655
+
656
+ publications: dict[str, dict[str, Any]] = {}
657
+ for child in children:
658
+ try:
659
+ archive_dir = child.resolve(strict=True)
660
+ except OSError:
661
+ continue
662
+ if (
663
+ not archive_dir.is_dir()
664
+ or not _path_is_within(archive_dir, resolved_archive_root)
665
+ or archive_dir.parent != resolved_archive_root
666
+ ):
667
+ continue
668
+ archive_rel = rel_to_project(project, archive_dir)
669
+ publications[archive_rel] = archive_publication_status(project, archive_rel)
670
+ return publications
671
+
672
+
445
673
  def archive_summary_records(project: Path, summary_paths: list[Path]) -> list[dict[str, Any]]:
446
674
  records = []
675
+ archive_root = (Path(project) / ".harness" / "archive").resolve()
447
676
  for summary_path in summary_paths:
448
- stat = summary_path.stat()
677
+ resolution = resolve_archive_summary(summary_path)
678
+ archive_dir = archive_dir_from_summary(summary_path).resolve()
679
+ if (
680
+ not _path_is_within(archive_dir, archive_root)
681
+ or archive_dir.parent != archive_root
682
+ ):
683
+ continue
684
+ original_state = (resolution.get("fingerprint") or {}).get("original") or {}
685
+ if original_state.get("state") != "file":
686
+ continue
687
+ try:
688
+ selected_path = Path(resolution["summaryPath"]).resolve(strict=True)
689
+ except (KeyError, OSError):
690
+ continue
691
+ if (
692
+ not _path_is_within(selected_path, archive_dir)
693
+ or not selected_path.is_file()
694
+ ):
695
+ continue
696
+ stat = selected_path.stat()
449
697
  records.append(
450
698
  {
451
- "archive": rel_to_project(project, archive_dir_from_summary(summary_path)),
452
- "summaryData": rel_to_project(project, summary_path),
453
- "summarySha256": sha256_file(summary_path),
699
+ "archive": rel_to_project(project, archive_dir_from_summary(selected_path)),
700
+ "summaryData": rel_to_project(project, selected_path),
701
+ "summarySha256": sha256_file(selected_path),
454
702
  "mtime": dt.datetime.fromtimestamp(stat.st_mtime, dt.timezone.utc).isoformat(timespec="seconds"),
703
+ "_authority": {
704
+ "status": resolution["status"],
705
+ "allowed": resolution["allowed"],
706
+ "version": resolution["version"],
707
+ "reasons": list(resolution["reasons"]),
708
+ "fingerprint": resolution["fingerprint"],
709
+ },
455
710
  }
456
711
  )
457
712
  return records
@@ -778,15 +1033,18 @@ def prune_generated_entries(knowledge: Path, current_entries: list[dict[str, Any
778
1033
  path.unlink(missing_ok=True)
779
1034
  removed += 1
780
1035
  continue
781
- if eid in preserved_ids:
782
- continue
783
1036
  replacement = current_by_id.get(str(eid))
784
- if replacement is None:
1037
+ if replacement is not None:
1038
+ if (
1039
+ str(replacement.get("status") or "") == status
1040
+ and source_identity(entry) == source_identity(replacement)
1041
+ ):
1042
+ continue
785
1043
  path.unlink(missing_ok=True)
786
1044
  removed += 1
787
- elif str(replacement.get("status")) != status:
788
- # entry moved to another status dir; the new file was already
789
- # written by the persist step, so remove the stale old file.
1045
+ elif eid in preserved_ids:
1046
+ continue
1047
+ else:
790
1048
  path.unlink(missing_ok=True)
791
1049
  removed += 1
792
1050
  return removed
@@ -875,12 +1133,129 @@ def load_preserved_entries(knowledge: Path) -> list[dict[str, Any]]:
875
1133
  return active_entries + manually_decided_entries
876
1134
 
877
1135
 
1136
+ def source_identity(entry: dict[str, Any]) -> tuple[str, str]:
1137
+ source = entry.get("source")
1138
+ if not isinstance(source, dict):
1139
+ return ("", "")
1140
+ return (
1141
+ str(source.get("summaryData") or ""),
1142
+ str(source.get("summarySha256") or ""),
1143
+ )
1144
+
1145
+
1146
+ def entry_matches_publication(
1147
+ entry: dict[str, Any],
1148
+ publication: dict[str, Any],
1149
+ ) -> bool:
1150
+ selected = (
1151
+ str(publication.get("summaryData") or ""),
1152
+ str(publication.get("summarySha256") or ""),
1153
+ )
1154
+ return bool(all(selected)) and source_identity(entry) == selected
1155
+
1156
+
878
1157
  def combine_generated_with_preserved(knowledge: Path, generated: list[dict[str, Any]]) -> list[dict[str, Any]]:
879
1158
  preserved_entries = load_preserved_entries(knowledge)
1159
+ generated_by_id = {
1160
+ str(entry["id"]): entry
1161
+ for entry in generated
1162
+ if isinstance(entry, dict) and entry.get("id")
1163
+ }
1164
+ preserved_entries = [
1165
+ entry
1166
+ for entry in preserved_entries
1167
+ if not (
1168
+ str(entry.get("id") or "") in generated_by_id
1169
+ and source_identity(entry)
1170
+ != source_identity(generated_by_id[str(entry["id"])])
1171
+ )
1172
+ ]
880
1173
  preserved_ids = {entry["id"] for entry in preserved_entries}
881
1174
  return preserved_entries + [entry for entry in generated if entry["id"] not in preserved_ids]
882
1175
 
883
1176
 
1177
+ def reconcile_archive_publication_entries(
1178
+ knowledge: Path,
1179
+ publications: dict[str, dict[str, Any]],
1180
+ ) -> dict[str, int]:
1181
+ """Demote preserved active entries when their source archive becomes blocked."""
1182
+ demoted = 0
1183
+ written = 0
1184
+ for status in ["active", "candidate", "stale", "superseded", "conflicted"]:
1185
+ status_dir = knowledge / "entries" / status
1186
+ if not status_dir.exists():
1187
+ continue
1188
+ for source_path in sorted(status_dir.glob("*.json")):
1189
+ try:
1190
+ entry = read_json(source_path)
1191
+ except (OSError, json.JSONDecodeError):
1192
+ continue
1193
+ if not isinstance(entry, dict):
1194
+ continue
1195
+ source = entry.get("source")
1196
+ archive = source.get("archive") if isinstance(source, dict) else None
1197
+ publication = publications.get(str(archive)) if archive else None
1198
+ if publication is None:
1199
+ continue
1200
+
1201
+ lifecycle = entry.setdefault("lifecycle", {})
1202
+ publication_allowed = bool(publication.get("allowed"))
1203
+ source_matches = entry_matches_publication(entry, publication)
1204
+ allowed = publication_allowed and source_matches
1205
+ reasons = list(publication.get("reasons") or [])
1206
+ if publication_allowed and not source_matches:
1207
+ reasons = ["entry source is not current authoritative summary"]
1208
+ changed = False
1209
+ target_path = source_path
1210
+ if not allowed:
1211
+ if lifecycle.get("publishBlocked") != reasons:
1212
+ lifecycle["publishBlocked"] = reasons
1213
+ lifecycle["lastCheckedAt"] = now_iso()
1214
+ changed = True
1215
+ if status == "active":
1216
+ entry["status"] = "candidate"
1217
+ lifecycle["previousStatus"] = "active"
1218
+ lifecycle["demotedAt"] = now_iso()
1219
+ lifecycle["demotionReason"] = (
1220
+ "archive publication blocked"
1221
+ + (": " + str(reasons[0]) if reasons else "")
1222
+ )
1223
+ lifecycle["publicationAutoDemoted"] = True
1224
+ lifecycle["lastCheckedAt"] = now_iso()
1225
+ target_path = (
1226
+ knowledge / "entries" / "candidate" / entry_filename(entry)
1227
+ )
1228
+ changed = True
1229
+ demoted += 1
1230
+ elif lifecycle.get("publicationAutoDemoted"):
1231
+ restore_active = (
1232
+ status == "candidate"
1233
+ and lifecycle.get("previousStatus") == "active"
1234
+ and not lifecycle.get("judgeAction")
1235
+ )
1236
+ lifecycle.pop("publishBlocked", None)
1237
+ lifecycle.pop("publicationAutoDemoted", None)
1238
+ if not lifecycle.get("judgeAction"):
1239
+ lifecycle.pop("demotedAt", None)
1240
+ lifecycle.pop("demotionReason", None)
1241
+ lifecycle.pop("previousStatus", None)
1242
+ if restore_active:
1243
+ entry["status"] = "active"
1244
+ target_path = (
1245
+ knowledge / "entries" / "active" / entry_filename(entry)
1246
+ )
1247
+ lifecycle["lastCheckedAt"] = now_iso()
1248
+ changed = True
1249
+
1250
+ if not changed:
1251
+ continue
1252
+ if write_json_if_changed(target_path, entry):
1253
+ written += 1
1254
+ if source_path != target_path and source_path.exists():
1255
+ source_path.unlink()
1256
+ return {"demoted": demoted, "written": written}
1257
+
1258
+
884
1259
  def archive_sort_key(entry: dict[str, Any]) -> tuple[str, str]:
885
1260
  archive = entry.get("source", {}).get("archive", "")
886
1261
  match = re.search(r"(\d{4}-\d{2}-\d{2})", archive)
@@ -1126,8 +1501,8 @@ def persist_entry_updates(knowledge: Path, entries: list[dict[str, Any]]) -> int
1126
1501
  path = knowledge / "entries" / status / entry_filename(entry)
1127
1502
  if path.exists():
1128
1503
  _preserve_confidence_timestamp(path, entry)
1129
- if write_json_if_changed(path, entry):
1130
- written += 1
1504
+ if write_json_if_changed(path, entry):
1505
+ written += 1
1131
1506
  return written
1132
1507
 
1133
1508
 
@@ -1839,7 +2214,11 @@ def compute_inputs_hash_from_preserved(
1839
2214
  archive_fingerprint = json.dumps(
1840
2215
  sorted(
1841
2216
  (
1842
- {"path": r.get("summaryData"), "sha256": r.get("summarySha256")}
2217
+ {
2218
+ "path": r.get("summaryData"),
2219
+ "sha256": r.get("summarySha256"),
2220
+ "authority": r.get("_authority", {}),
2221
+ }
1843
2222
  for r in records
1844
2223
  ),
1845
2224
  key=lambda item: item["path"] or "",
@@ -1926,9 +2305,7 @@ def build_snapshot(project: Path) -> KnowledgeSnapshot:
1926
2305
  knowledge = project / ".harness" / "knowledge"
1927
2306
  pname = project_id(project)
1928
2307
  config = load_config(knowledge)
1929
- summary_paths = sorted(
1930
- (project / ".harness" / "archive").glob("*/reports/final/summary-data.json")
1931
- )
2308
+ summary_paths = discover_archive_summary_paths(project)
1932
2309
  archive_records = archive_summary_records(project, summary_paths)
1933
2310
  inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
1934
2311
  return KnowledgeSnapshot(
@@ -1956,9 +2333,7 @@ def build_index(
1956
2333
  knowledge = project / ".harness" / "knowledge"
1957
2334
  pname = project_id(project)
1958
2335
  config = load_config(knowledge)
1959
- summary_paths = sorted(
1960
- (project / ".harness" / "archive").glob("*/reports/final/summary-data.json")
1961
- )
2336
+ summary_paths = discover_archive_summary_paths(project)
1962
2337
  archive_records = archive_summary_records(project, summary_paths)
1963
2338
  inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
1964
2339
  ensure_knowledge_dirs(knowledge)
@@ -2005,6 +2380,7 @@ def build_index(
2005
2380
  current_head = git_head(project) # recorded in manifest only; not an invalidation key
2006
2381
  entries: list[dict[str, Any]] = []
2007
2382
  failures: list[dict[str, str]] = []
2383
+ publications = archive_publication_statuses(project)
2008
2384
  ingest_mode: dict[str, Any] = {
2009
2385
  "incremental": incremental,
2010
2386
  "mode": mode,
@@ -2053,7 +2429,10 @@ def build_index(
2053
2429
  # API-006/RET-40: entries from archives failing the publication
2054
2430
  # gate stay quarantined (candidate only, never active/promoted).
2055
2431
  archive_rel = rel_to_project(project, archive_dir_from_summary(summary_path))
2056
- publication = archive_publication_status(project, archive_rel)
2432
+ publication = publications.get(archive_rel)
2433
+ if publication is None:
2434
+ publication = archive_publication_status(project, archive_rel)
2435
+ publications[archive_rel] = publication
2057
2436
  if not publication.get("allowed"):
2058
2437
  for entry in archive_entries:
2059
2438
  lifecycle = entry.setdefault("lifecycle", {})
@@ -2065,6 +2444,13 @@ def build_index(
2065
2444
  except Exception as exc: # keep one bad archive from blocking the index
2066
2445
  failures.append({"path": rel_to_project(project, summary_path), "error": str(exc)})
2067
2446
 
2447
+ publication_reconciliation = reconcile_archive_publication_entries(
2448
+ knowledge,
2449
+ publications,
2450
+ )
2451
+ ingest_mode["activeAutoDemoted"] += publication_reconciliation["demoted"]
2452
+ ingest_mode["entriesWritten"] += publication_reconciliation["written"]
2453
+
2068
2454
  seen: set[str] = set()
2069
2455
  deduped: list[dict[str, Any]] = []
2070
2456
  duplicates = 0
@@ -2118,7 +2504,7 @@ def build_index(
2118
2504
  apply_confidence_scores(indexed_entries, config)
2119
2505
  ingest_mode["entriesWritten"] += persist_entry_updates(knowledge, indexed_entries)
2120
2506
  auto_demotions = apply_active_lifecycle_policy(knowledge, indexed_entries, config)
2121
- ingest_mode["activeAutoDemoted"] = len(auto_demotions)
2507
+ ingest_mode["activeAutoDemoted"] += len(auto_demotions)
2122
2508
  validation = apply_knowledge_validation(project, knowledge, config)
2123
2509
  ingest_mode["validationChecked"] = validation["checked"]
2124
2510
  ingest_mode["validationFailed"] = validation["failed"]
@@ -2198,10 +2584,16 @@ def promote_entry(project: Path, entry_id: str, note: str, allow_stale: bool = F
2198
2584
  archive_rel = (entry.get("source") or {}).get("archive")
2199
2585
  if archive_rel:
2200
2586
  publication = archive_publication_status(project, archive_rel)
2201
- if not publication.get("allowed"):
2587
+ if (
2588
+ not publication.get("allowed")
2589
+ or not entry_matches_publication(entry, publication)
2590
+ ):
2591
+ reasons = list(publication.get("reasons") or [])
2592
+ if publication.get("allowed"):
2593
+ reasons = ["entry source is not current authoritative summary"]
2202
2594
  raise ValueError(
2203
2595
  "publication blocked: "
2204
- + "; ".join(str(r) for r in (publication.get("reasons") or []))
2596
+ + "; ".join(str(r) for r in reasons)
2205
2597
  )
2206
2598
  entry["status"] = "active"
2207
2599
  lifecycle = entry.setdefault("lifecycle", {})
@@ -2424,6 +2816,15 @@ def make_manifest(
2424
2816
  }
2425
2817
  for e in sorted(entries, key=lambda item: (item["source"]["archive"], item["type"], item["title"]))
2426
2818
  ]
2819
+ manifest_archive_records = [
2820
+ {
2821
+ "archive": record.get("archive"),
2822
+ "summaryData": record.get("summaryData"),
2823
+ "summarySha256": record.get("summarySha256"),
2824
+ "mtime": record.get("mtime"),
2825
+ }
2826
+ for record in archive_records
2827
+ ]
2427
2828
 
2428
2829
  return {
2429
2830
  "schemaVersion": 1,
@@ -2436,7 +2837,7 @@ def make_manifest(
2436
2837
  "scanned": len(summary_paths),
2437
2838
  "indexed": len(summary_paths) - len(failures),
2438
2839
  "failed": len(failures),
2439
- "items": archive_records,
2840
+ "items": manifest_archive_records,
2440
2841
  },
2441
2842
  "stats": stats,
2442
2843
  "byType": by_type,
@@ -2793,8 +3194,13 @@ def refresh_outputs_from_entry_files(
2793
3194
  knowledge: Path,
2794
3195
  ingest_mode: dict[str, Any] | None = None,
2795
3196
  ) -> dict[str, Any]:
2796
- summary_paths = sorted((project / ".harness" / "archive").glob("*/reports/final/summary-data.json"))
3197
+ summary_paths = discover_archive_summary_paths(project)
2797
3198
  archive_records = archive_summary_records(project, summary_paths)
3199
+ publications = archive_publication_statuses(project)
3200
+ publication_reconciliation = reconcile_archive_publication_entries(
3201
+ knowledge,
3202
+ publications,
3203
+ )
2798
3204
  existing: dict[str, Any] = {}
2799
3205
  index_path = knowledge / "index.json"
2800
3206
  if index_path.exists():
@@ -2820,6 +3226,20 @@ def refresh_outputs_from_entry_files(
2820
3226
  entries.extend(entry for entry in file_entries if entry["id"] not in existing_ids)
2821
3227
  else:
2822
3228
  entries = file_entries
3229
+ effective_ingest_mode = dict(
3230
+ ingest_mode or existing.get("ingestMode") or {}
3231
+ )
3232
+ effective_ingest_mode["activeAutoDemoted"] = int(
3233
+ effective_ingest_mode.get("activeAutoDemoted") or 0
3234
+ ) + publication_reconciliation["demoted"]
3235
+ effective_ingest_mode["entriesWritten"] = int(
3236
+ effective_ingest_mode.get("entriesWritten") or 0
3237
+ ) + publication_reconciliation["written"]
3238
+ effective_ingest_mode["inputsHash"] = compute_inputs_hash_from_preserved(
3239
+ archive_records,
3240
+ load_config(knowledge),
3241
+ load_preserved_entries(knowledge),
3242
+ )
2823
3243
  index = make_manifest(
2824
3244
  project,
2825
3245
  project_id(project),
@@ -2828,7 +3248,7 @@ def refresh_outputs_from_entry_files(
2828
3248
  entries,
2829
3249
  existing.get("failures") or [],
2830
3250
  int(existing.get("duplicatesSkipped") or 0),
2831
- ingest_mode or existing.get("ingestMode") or {},
3251
+ effective_ingest_mode,
2832
3252
  )
2833
3253
  write_sqlite(knowledge / "index.sqlite", entries)
2834
3254
  write_json(index_path, index)
@@ -3212,8 +3632,7 @@ def sync_status(project: Path, update: bool = False, incremental: bool = True) -
3212
3632
  knowledge = project / ".harness" / "knowledge"
3213
3633
  index_path = knowledge / "index.json"
3214
3634
  sqlite_path = knowledge / "index.sqlite"
3215
- archive_root = project / ".harness" / "archive"
3216
- summary_paths = sorted(archive_root.glob("*/reports/final/summary-data.json"))
3635
+ summary_paths = discover_archive_summary_paths(project)
3217
3636
  current_records = archive_summary_records(project, summary_paths)
3218
3637
  reasons: list[str] = []
3219
3638
  index: dict[str, Any] | None = None