@hunter-harness/workflow-harness 0.2.1 → 0.2.3

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 (274) 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-archive/reference.md +2 -8
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +2 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  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 +2 -1
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +5 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/checklist.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  13. package/harness/bundles/general/claude-code/harness-submit/checklist.md +30 -6
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +2 -2
  16. package/harness/bundles/general/claude-code/harness-test/checklist.md +1 -1
  17. package/harness/bundles/general/claude-code/harness-test/pitfalls.md +16 -0
  18. package/harness/bundles/general/claude-code/harness-test/scripts/runtime-helpers.mjs +216 -0
  19. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +1 -1
  20. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +55 -2
  21. package/harness/bundles/general/claude-code/protocols/report-pipeline-protocol.md +1 -8
  22. package/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +13 -0
  23. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_archive.py +31 -14
  25. package/harness/bundles/general/claude-code/scripts/harness_check_gate.py +111 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +179 -4
  27. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +269 -3
  28. package/harness/bundles/general/claude-code/scripts/harness_preflight.py +72 -382
  29. package/harness/bundles/general/claude-code/scripts/harness_profile.py +742 -0
  30. package/harness/bundles/general/claude-code/scripts/harness_service.py +99 -2
  31. package/harness/bundles/general/claude-code/scripts/harness_state.py +179 -0
  32. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  33. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-archive/reference.md +2 -8
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +2 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  38. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +2 -1
  40. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +5 -1
  41. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-run/checklist.md +1 -1
  43. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  44. package/harness/bundles/general/codebuddy/harness-submit/checklist.md +30 -6
  45. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  46. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +2 -2
  47. package/harness/bundles/general/codebuddy/harness-test/checklist.md +1 -1
  48. package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +16 -0
  49. package/harness/bundles/general/codebuddy/harness-test/scripts/runtime-helpers.mjs +216 -0
  50. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +1 -1
  51. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +55 -2
  52. package/harness/bundles/general/codebuddy/protocols/report-pipeline-protocol.md +1 -8
  53. package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +13 -0
  54. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -0
  55. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +31 -14
  56. package/harness/bundles/general/codebuddy/scripts/harness_check_gate.py +111 -0
  57. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +179 -4
  58. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +269 -3
  59. package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +72 -382
  60. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +742 -0
  61. package/harness/bundles/general/codebuddy/scripts/harness_service.py +99 -2
  62. package/harness/bundles/general/codebuddy/scripts/harness_state.py +179 -0
  63. package/harness/bundles/general/codex/.harness-build.json +1 -1
  64. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-archive/reference.md +2 -8
  66. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +2 -1
  67. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  69. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  70. package/harness/bundles/general/codex/harness-plan/SKILL.md +2 -1
  71. package/harness/bundles/general/codex/harness-review/SKILL.md +5 -1
  72. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  73. package/harness/bundles/general/codex/harness-run/checklist.md +1 -1
  74. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  75. package/harness/bundles/general/codex/harness-submit/checklist.md +30 -6
  76. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  77. package/harness/bundles/general/codex/harness-test/SKILL.md +2 -2
  78. package/harness/bundles/general/codex/harness-test/checklist.md +1 -1
  79. package/harness/bundles/general/codex/harness-test/pitfalls.md +16 -0
  80. package/harness/bundles/general/codex/harness-test/scripts/runtime-helpers.mjs +216 -0
  81. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +1 -1
  82. package/harness/bundles/general/codex/protocols/ledger-protocol.md +55 -2
  83. package/harness/bundles/general/codex/protocols/report-pipeline-protocol.md +1 -8
  84. package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +13 -0
  85. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -0
  86. package/harness/bundles/general/codex/scripts/harness_archive.py +31 -14
  87. package/harness/bundles/general/codex/scripts/harness_check_gate.py +111 -0
  88. package/harness/bundles/general/codex/scripts/harness_deploy.py +179 -4
  89. package/harness/bundles/general/codex/scripts/harness_ledger.py +269 -3
  90. package/harness/bundles/general/codex/scripts/harness_preflight.py +72 -382
  91. package/harness/bundles/general/codex/scripts/harness_profile.py +742 -0
  92. package/harness/bundles/general/codex/scripts/harness_service.py +99 -2
  93. package/harness/bundles/general/codex/scripts/harness_state.py +179 -0
  94. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  95. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-archive/reference.md +2 -8
  97. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +2 -1
  98. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  99. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  100. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  101. package/harness/bundles/general/cursor/harness-plan/SKILL.md +2 -1
  102. package/harness/bundles/general/cursor/harness-review/SKILL.md +5 -1
  103. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  104. package/harness/bundles/general/cursor/harness-run/checklist.md +1 -1
  105. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  106. package/harness/bundles/general/cursor/harness-submit/checklist.md +30 -6
  107. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  108. package/harness/bundles/general/cursor/harness-test/SKILL.md +2 -2
  109. package/harness/bundles/general/cursor/harness-test/checklist.md +1 -1
  110. package/harness/bundles/general/cursor/harness-test/pitfalls.md +16 -0
  111. package/harness/bundles/general/cursor/harness-test/scripts/runtime-helpers.mjs +216 -0
  112. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +1 -1
  113. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +55 -2
  114. package/harness/bundles/general/cursor/protocols/report-pipeline-protocol.md +1 -8
  115. package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +13 -0
  116. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -0
  117. package/harness/bundles/general/cursor/scripts/harness_archive.py +31 -14
  118. package/harness/bundles/general/cursor/scripts/harness_check_gate.py +111 -0
  119. package/harness/bundles/general/cursor/scripts/harness_deploy.py +179 -4
  120. package/harness/bundles/general/cursor/scripts/harness_ledger.py +269 -3
  121. package/harness/bundles/general/cursor/scripts/harness_preflight.py +72 -382
  122. package/harness/bundles/general/cursor/scripts/harness_profile.py +742 -0
  123. package/harness/bundles/general/cursor/scripts/harness_service.py +99 -2
  124. package/harness/bundles/general/cursor/scripts/harness_state.py +179 -0
  125. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  126. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  127. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  128. package/harness/bundles/java/claude-code/harness-archive/reference.md +2 -8
  129. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +2 -1
  130. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  131. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  132. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  133. package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -3
  134. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +2 -1
  135. package/harness/bundles/java/claude-code/harness-review/SKILL.md +5 -1
  136. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  137. package/harness/bundles/java/claude-code/harness-run/checklist.md +3 -1
  138. package/harness/bundles/java/claude-code/harness-run/reference.md +6 -0
  139. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -3
  140. package/harness/bundles/java/claude-code/harness-submit/checklist.md +30 -6
  141. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  142. package/harness/bundles/java/claude-code/harness-test/SKILL.md +5 -5
  143. package/harness/bundles/java/claude-code/harness-test/checklist.md +3 -1
  144. package/harness/bundles/java/claude-code/harness-test/pitfalls.md +16 -0
  145. package/harness/bundles/java/claude-code/harness-test/reference.md +2 -0
  146. package/harness/bundles/java/claude-code/harness-test/scripts/runtime-helpers.mjs +216 -0
  147. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +1 -1
  148. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +55 -2
  149. package/harness/bundles/java/claude-code/protocols/report-pipeline-protocol.md +1 -8
  150. package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +13 -0
  151. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -0
  152. package/harness/bundles/java/claude-code/scripts/harness_archive.py +31 -14
  153. package/harness/bundles/java/claude-code/scripts/harness_check_gate.py +111 -0
  154. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +179 -4
  155. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +269 -3
  156. package/harness/bundles/java/claude-code/scripts/harness_preflight.py +72 -382
  157. package/harness/bundles/java/claude-code/scripts/harness_profile.py +742 -0
  158. package/harness/bundles/java/claude-code/scripts/harness_service.py +99 -2
  159. package/harness/bundles/java/claude-code/scripts/harness_state.py +179 -0
  160. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  161. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  162. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  163. package/harness/bundles/java/codebuddy/harness-archive/reference.md +2 -8
  164. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +2 -1
  165. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  166. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  167. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  168. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +3 -3
  169. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +2 -1
  170. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +5 -1
  171. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  172. package/harness/bundles/java/codebuddy/harness-run/checklist.md +3 -1
  173. package/harness/bundles/java/codebuddy/harness-run/reference.md +6 -0
  174. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -3
  175. package/harness/bundles/java/codebuddy/harness-submit/checklist.md +30 -6
  176. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  177. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +5 -5
  178. package/harness/bundles/java/codebuddy/harness-test/checklist.md +3 -1
  179. package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +16 -0
  180. package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -0
  181. package/harness/bundles/java/codebuddy/harness-test/scripts/runtime-helpers.mjs +216 -0
  182. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +1 -1
  183. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +55 -2
  184. package/harness/bundles/java/codebuddy/protocols/report-pipeline-protocol.md +1 -8
  185. package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +13 -0
  186. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -0
  187. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +31 -14
  188. package/harness/bundles/java/codebuddy/scripts/harness_check_gate.py +111 -0
  189. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +179 -4
  190. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +269 -3
  191. package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +72 -382
  192. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +742 -0
  193. package/harness/bundles/java/codebuddy/scripts/harness_service.py +99 -2
  194. package/harness/bundles/java/codebuddy/scripts/harness_state.py +179 -0
  195. package/harness/bundles/java/codex/.harness-build.json +1 -1
  196. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  197. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  198. package/harness/bundles/java/codex/harness-archive/reference.md +2 -8
  199. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +2 -1
  200. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  201. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  202. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  203. package/harness/bundles/java/codex/harness-package/SKILL.md +3 -3
  204. package/harness/bundles/java/codex/harness-plan/SKILL.md +2 -1
  205. package/harness/bundles/java/codex/harness-review/SKILL.md +5 -1
  206. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  207. package/harness/bundles/java/codex/harness-run/checklist.md +3 -1
  208. package/harness/bundles/java/codex/harness-run/reference.md +6 -0
  209. package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -3
  210. package/harness/bundles/java/codex/harness-submit/checklist.md +30 -6
  211. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  212. package/harness/bundles/java/codex/harness-test/SKILL.md +5 -5
  213. package/harness/bundles/java/codex/harness-test/checklist.md +3 -1
  214. package/harness/bundles/java/codex/harness-test/pitfalls.md +16 -0
  215. package/harness/bundles/java/codex/harness-test/reference.md +2 -0
  216. package/harness/bundles/java/codex/harness-test/scripts/runtime-helpers.mjs +216 -0
  217. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +1 -1
  218. package/harness/bundles/java/codex/protocols/ledger-protocol.md +55 -2
  219. package/harness/bundles/java/codex/protocols/report-pipeline-protocol.md +1 -8
  220. package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +13 -0
  221. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -0
  222. package/harness/bundles/java/codex/scripts/harness_archive.py +31 -14
  223. package/harness/bundles/java/codex/scripts/harness_check_gate.py +111 -0
  224. package/harness/bundles/java/codex/scripts/harness_deploy.py +179 -4
  225. package/harness/bundles/java/codex/scripts/harness_ledger.py +269 -3
  226. package/harness/bundles/java/codex/scripts/harness_preflight.py +72 -382
  227. package/harness/bundles/java/codex/scripts/harness_profile.py +742 -0
  228. package/harness/bundles/java/codex/scripts/harness_service.py +99 -2
  229. package/harness/bundles/java/codex/scripts/harness_state.py +179 -0
  230. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  231. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  232. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  233. package/harness/bundles/java/cursor/harness-archive/reference.md +2 -8
  234. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +2 -1
  235. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  236. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +474 -122
  237. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  238. package/harness/bundles/java/cursor/harness-package/SKILL.md +3 -3
  239. package/harness/bundles/java/cursor/harness-plan/SKILL.md +2 -1
  240. package/harness/bundles/java/cursor/harness-review/SKILL.md +5 -1
  241. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  242. package/harness/bundles/java/cursor/harness-run/checklist.md +3 -1
  243. package/harness/bundles/java/cursor/harness-run/reference.md +6 -0
  244. package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -3
  245. package/harness/bundles/java/cursor/harness-submit/checklist.md +30 -6
  246. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-test/SKILL.md +5 -5
  248. package/harness/bundles/java/cursor/harness-test/checklist.md +3 -1
  249. package/harness/bundles/java/cursor/harness-test/pitfalls.md +16 -0
  250. package/harness/bundles/java/cursor/harness-test/reference.md +2 -0
  251. package/harness/bundles/java/cursor/harness-test/scripts/runtime-helpers.mjs +216 -0
  252. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +1 -1
  253. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +55 -2
  254. package/harness/bundles/java/cursor/protocols/report-pipeline-protocol.md +1 -8
  255. package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +13 -0
  256. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -0
  257. package/harness/bundles/java/cursor/scripts/harness_archive.py +31 -14
  258. package/harness/bundles/java/cursor/scripts/harness_check_gate.py +111 -0
  259. package/harness/bundles/java/cursor/scripts/harness_deploy.py +179 -4
  260. package/harness/bundles/java/cursor/scripts/harness_ledger.py +269 -3
  261. package/harness/bundles/java/cursor/scripts/harness_preflight.py +72 -382
  262. package/harness/bundles/java/cursor/scripts/harness_profile.py +742 -0
  263. package/harness/bundles/java/cursor/scripts/harness_service.py +99 -2
  264. package/harness/bundles/java/cursor/scripts/harness_state.py +179 -0
  265. package/harness/manifests/general/claude-code.json +44 -28
  266. package/harness/manifests/general/codebuddy.json +44 -28
  267. package/harness/manifests/general/codex.json +44 -28
  268. package/harness/manifests/general/cursor.json +44 -28
  269. package/harness/manifests/java/claude-code.json +48 -32
  270. package/harness/manifests/java/codebuddy.json +48 -32
  271. package/harness/manifests/java/codex.json +48 -32
  272. package/harness/manifests/java/cursor.json +48 -32
  273. package/hunter-workflow-family.json +2 -2
  274. package/package.json +1 -1
@@ -97,6 +97,29 @@ def write_text(path: Path, text: str) -> None:
97
97
  path.write_text(text, encoding="utf-8")
98
98
 
99
99
 
100
+ def _serialize_json_bytes(data: Any) -> bytes:
101
+ return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
102
+
103
+
104
+ def write_json_if_changed(path: Path, data: Any) -> bool:
105
+ """Write only when byte content would differ; returns True iff a write occurred."""
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ payload = _serialize_json_bytes(data)
108
+ if path.exists() and path.read_bytes() == payload:
109
+ return False
110
+ path.write_bytes(payload)
111
+ return True
112
+
113
+
114
+ def write_text_if_changed(path: Path, text: str) -> bool:
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ payload = text.encode("utf-8")
117
+ if path.exists() and path.read_bytes() == payload:
118
+ return False
119
+ path.write_bytes(payload)
120
+ return True
121
+
122
+
100
123
  def json_clone(value: Any) -> Any:
101
124
  return json.loads(json.dumps(value, ensure_ascii=False))
102
125
 
@@ -621,21 +644,7 @@ def looks_like_contract_path(path: str) -> bool:
621
644
  return any(part in lower for part in ["openapi", "contract", "schema", "protocol", "api"])
622
645
 
623
646
 
624
- def reset_generated_knowledge(knowledge: Path) -> None:
625
- for sub in ["entries/candidate", "entries/stale", "entries/conflicted", "entries/superseded"]:
626
- path = knowledge / sub
627
- if path.exists():
628
- if sub in {"entries/candidate", "entries/stale"}:
629
- for entry_path in sorted(path.glob("*.json")):
630
- try:
631
- entry = read_json(entry_path)
632
- except (OSError, json.JSONDecodeError):
633
- entry_path.unlink(missing_ok=True)
634
- continue
635
- if not isinstance(entry, dict) or not entry.get("lifecycle", {}).get("demotedAt"):
636
- entry_path.unlink(missing_ok=True)
637
- else:
638
- shutil.rmtree(path)
647
+ def ensure_knowledge_dirs(knowledge: Path) -> None:
639
648
  for sub in [
640
649
  "entries/candidate",
641
650
  "entries/active",
@@ -650,6 +659,50 @@ def reset_generated_knowledge(knowledge: Path) -> None:
650
659
  (knowledge / sub).mkdir(parents=True, exist_ok=True)
651
660
 
652
661
 
662
+ def prune_generated_entries(knowledge: Path, current_entries: list[dict[str, Any]]) -> int:
663
+ """Remove generated entry files that disappeared or moved to another status dir.
664
+
665
+ Replaces the old reset-and-rewrite sweep. Preserved entries (active, and
666
+ candidate/stale carrying a manual ``demotedAt``) are never removed, so the
667
+ write-if-changed persist step can detect unchanged entries by content.
668
+ Returns the number of files removed.
669
+ """
670
+ current_by_id: dict[str, dict[str, Any]] = {}
671
+ for entry in current_entries:
672
+ if isinstance(entry, dict) and entry.get("id"):
673
+ current_by_id[str(entry["id"])] = entry
674
+ preserved_ids = {entry["id"] for entry in load_preserved_entries(knowledge)}
675
+ removed = 0
676
+ for status in ["candidate", "stale", "superseded", "conflicted"]:
677
+ status_dir = knowledge / "entries" / status
678
+ if not status_dir.exists():
679
+ continue
680
+ for path in sorted(status_dir.glob("*.json")):
681
+ try:
682
+ entry = read_json(path)
683
+ except (OSError, json.JSONDecodeError):
684
+ path.unlink(missing_ok=True)
685
+ removed += 1
686
+ continue
687
+ eid = entry.get("id") if isinstance(entry, dict) else None
688
+ if eid is None:
689
+ path.unlink(missing_ok=True)
690
+ removed += 1
691
+ continue
692
+ if eid in preserved_ids:
693
+ continue
694
+ replacement = current_by_id.get(str(eid))
695
+ if replacement is None:
696
+ path.unlink(missing_ok=True)
697
+ removed += 1
698
+ elif str(replacement.get("status")) != status:
699
+ # entry moved to another status dir; the new file was already
700
+ # written by the persist step, so remove the stale old file.
701
+ path.unlink(missing_ok=True)
702
+ removed += 1
703
+ return removed
704
+
705
+
653
706
  def load_entries_from_dir(path: Path) -> list[dict[str, Any]]:
654
707
  entries: list[dict[str, Any]] = []
655
708
  if not path.exists():
@@ -676,7 +729,6 @@ def load_cached_archive_entries(
676
729
  *,
677
730
  summary_path: str,
678
731
  summary_hash: str,
679
- head_commit: str | None,
680
732
  ) -> list[dict[str, Any]] | None:
681
733
  if not cache_path.exists():
682
734
  return None
@@ -692,8 +744,9 @@ def load_cached_archive_entries(
692
744
  return None
693
745
  if payload.get("summarySha256") != summary_hash:
694
746
  return None
695
- if payload.get("headCommit") != head_commit:
696
- return None
747
+ # headCommit is intentionally ignored: archive extraction depends only on the
748
+ # summary-data.json content, so an unrelated HEAD change must not invalidate
749
+ # the extraction cache (design §3.5, cluster 6 — true incremental).
697
750
  entries = payload.get("entries")
698
751
  if not isinstance(entries, list):
699
752
  return None
@@ -707,7 +760,6 @@ def write_cached_archive_entries(
707
760
  *,
708
761
  summary_path: str,
709
762
  summary_hash: str,
710
- head_commit: str | None,
711
763
  entries: list[dict[str, Any]],
712
764
  ) -> None:
713
765
  write_json(
@@ -717,7 +769,6 @@ def write_cached_archive_entries(
717
769
  "generatedAt": now_iso(),
718
770
  "summaryData": summary_path,
719
771
  "summarySha256": summary_hash,
720
- "headCommit": head_commit,
721
772
  "entries": entries,
722
773
  },
723
774
  )
@@ -863,7 +914,25 @@ def calculate_confidence(entry: dict[str, Any], config: dict[str, Any]) -> dict[
863
914
 
864
915
  def apply_confidence_scores(entries: list[dict[str, Any]], config: dict[str, Any]) -> None:
865
916
  for entry in entries:
866
- entry["confidence"] = calculate_confidence(entry, config)
917
+ fresh = calculate_confidence(entry, config)
918
+ existing = entry.get("confidence") if isinstance(entry.get("confidence"), dict) else None
919
+ if existing is not None and (
920
+ existing.get("score") == fresh["score"]
921
+ and existing.get("level") == fresh["level"]
922
+ and existing.get("signals") == fresh["signals"]
923
+ ):
924
+ # The inputs that determine confidence are unchanged; keep the previous
925
+ # lastCalculatedAt so the entry content (and mtime) does not churn on
926
+ # every ingest. This is what makes write-if-changed effective for
927
+ # unchanged entries.
928
+ entry["confidence"] = {
929
+ "score": fresh["score"],
930
+ "level": fresh["level"],
931
+ "signals": fresh["signals"],
932
+ "lastCalculatedAt": existing.get("lastCalculatedAt"),
933
+ }
934
+ else:
935
+ entry["confidence"] = fresh
867
936
 
868
937
 
869
938
  def should_auto_promote(entry: dict[str, Any], policy: dict[str, Any]) -> bool:
@@ -926,14 +995,47 @@ def apply_auto_promote_policy(entries: list[dict[str, Any]], config: dict[str, A
926
995
  return actions
927
996
 
928
997
 
929
- def persist_entry_updates(knowledge: Path, entries: list[dict[str, Any]]) -> None:
998
+ def _preserve_confidence_timestamp(target: Path, entry: dict[str, Any]) -> None:
999
+ """Keep the on-disk ``lastCalculatedAt`` when confidence is otherwise unchanged.
1000
+
1001
+ Extraction cache stores raw entries whose ``confidence`` is still the legacy
1002
+ string, so the first ``apply_confidence_scores`` of every build would mint a
1003
+ fresh ``lastCalculatedAt`` and churn unchanged entries. By re-reading the
1004
+ on-disk entry just before writing, an unchanged entry keeps its previous
1005
+ timestamp and ``write_json_if_changed`` becomes a true no-op (cluster 6).
1006
+ """
1007
+ if not target.exists():
1008
+ return
1009
+ try:
1010
+ disk = read_json(target)
1011
+ except (OSError, json.JSONDecodeError):
1012
+ return
1013
+ if not isinstance(disk, dict) or disk.get("id") != entry.get("id"):
1014
+ return
1015
+ disk_conf = disk.get("confidence") if isinstance(disk.get("confidence"), dict) else None
1016
+ new_conf = entry.get("confidence") if isinstance(entry.get("confidence"), dict) else None
1017
+ if not (disk_conf and new_conf):
1018
+ return
1019
+ if (
1020
+ disk_conf.get("score") == new_conf.get("score")
1021
+ and disk_conf.get("level") == new_conf.get("level")
1022
+ and disk_conf.get("signals") == new_conf.get("signals")
1023
+ ):
1024
+ entry["confidence"]["lastCalculatedAt"] = disk_conf.get("lastCalculatedAt")
1025
+
1026
+
1027
+ def persist_entry_updates(knowledge: Path, entries: list[dict[str, Any]]) -> int:
1028
+ written = 0
930
1029
  for entry in entries:
931
1030
  status = str(entry.get("status") or "")
932
1031
  if status not in {"candidate", "active", "stale", "superseded", "conflicted"}:
933
1032
  continue
934
1033
  path = knowledge / "entries" / status / entry_filename(entry)
935
1034
  if path.exists():
936
- write_json(path, entry)
1035
+ _preserve_confidence_timestamp(path, entry)
1036
+ if write_json_if_changed(path, entry):
1037
+ written += 1
1038
+ return written
937
1039
 
938
1040
 
939
1041
  def normalized_entry_text(entry: dict[str, Any]) -> str:
@@ -1614,21 +1716,196 @@ def apply_ttl_stale(entries: list[dict[str, Any]], config: dict[str, Any]) -> No
1614
1716
  lifecycle["lastCheckedAt"] = now_iso()
1615
1717
 
1616
1718
 
1617
- def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1719
+ SQLITE_SCHEMA_VERSION = 1
1720
+
1721
+
1722
+ def compute_inputs_hash(
1723
+ records: list[dict[str, Any]], config: dict[str, Any], knowledge: Path
1724
+ ) -> str:
1725
+ """Stable hash of the inputs that determine knowledge index content.
1726
+
1727
+ Excludes HEAD on purpose: a business-code commit that does not touch any
1728
+ archive must not invalidate the index (design §3.5, cluster 6 — true
1729
+ incremental). Inputs are archive checksums + knowledge config + schema
1730
+ versions + preserved entries (active / manually demoted), so promote/demote
1731
+ correctly invalidate the no-op fast path.
1732
+ """
1733
+ archive_fingerprint = json.dumps(
1734
+ sorted(
1735
+ (
1736
+ {"path": r.get("summaryData"), "sha256": r.get("summarySha256")}
1737
+ for r in records
1738
+ ),
1739
+ key=lambda item: item["path"] or "",
1740
+ ),
1741
+ ensure_ascii=False,
1742
+ sort_keys=True,
1743
+ )
1744
+ config_fingerprint = json.dumps(config, ensure_ascii=False, sort_keys=True)
1745
+ schema_fingerprint = json.dumps(
1746
+ {
1747
+ "entrySchemaVersion": 1,
1748
+ "indexSchemaVersion": 1,
1749
+ "sqliteSchemaVersion": SQLITE_SCHEMA_VERSION,
1750
+ },
1751
+ sort_keys=True,
1752
+ )
1753
+ preserved = load_preserved_entries(knowledge)
1754
+ preserved_fingerprint = json.dumps(
1755
+ sorted(
1756
+ (
1757
+ {
1758
+ "id": e.get("id"),
1759
+ "status": e.get("status"),
1760
+ "lifecycle": e.get("lifecycle", {}),
1761
+ }
1762
+ for e in preserved
1763
+ ),
1764
+ key=lambda item: item["id"] or "",
1765
+ ),
1766
+ ensure_ascii=False,
1767
+ sort_keys=True,
1768
+ )
1769
+ digest = hashlib.sha256()
1770
+ digest.update(archive_fingerprint.encode("utf-8"))
1771
+ digest.update(b"|config|")
1772
+ digest.update(config_fingerprint.encode("utf-8"))
1773
+ digest.update(b"|schema|")
1774
+ digest.update(schema_fingerprint.encode("utf-8"))
1775
+ digest.update(b"|preserved|")
1776
+ digest.update(preserved_fingerprint.encode("utf-8"))
1777
+ return digest.hexdigest()
1778
+
1779
+
1780
+ class KnowledgeSnapshot:
1781
+ """Shared per-invocation snapshot (design §3.5 / cluster 6 要点2).
1782
+
1783
+ Loads config + archive records + inputs_hash once so auto/maintain/sync/query
1784
+ can pass it through instead of independently recomputing inputs_hash, reloading
1785
+ config, or rescanning archives. HEAD is intentionally not part of the snapshot
1786
+ -- an unrelated business-code commit must not invalidate the index.
1787
+ """
1788
+
1789
+ __slots__ = (
1790
+ "project",
1791
+ "knowledge",
1792
+ "pname",
1793
+ "config",
1794
+ "summary_paths",
1795
+ "archive_records",
1796
+ "inputs_hash",
1797
+ )
1798
+
1799
+ def __init__(
1800
+ self,
1801
+ project: Path,
1802
+ knowledge: Path,
1803
+ pname: str,
1804
+ config: dict[str, Any],
1805
+ summary_paths: list[Path],
1806
+ archive_records: list[dict[str, Any]],
1807
+ inputs_hash: str,
1808
+ ) -> None:
1809
+ self.project = project
1810
+ self.knowledge = knowledge
1811
+ self.pname = pname
1812
+ self.config = config
1813
+ self.summary_paths = summary_paths
1814
+ self.archive_records = archive_records
1815
+ self.inputs_hash = inputs_hash
1816
+
1817
+
1818
+ def build_snapshot(project: Path) -> KnowledgeSnapshot:
1819
+ """Load config + archive records + inputs_hash exactly once for one invocation."""
1618
1820
  project = project.resolve()
1619
- harness = project / ".harness"
1620
- archive_root = harness / "archive"
1621
- knowledge = harness / "knowledge"
1821
+ knowledge = project / ".harness" / "knowledge"
1622
1822
  pname = project_id(project)
1623
1823
  config = load_config(knowledge)
1624
- current_head = git_head(project)
1625
- reset_generated_knowledge(knowledge)
1824
+ summary_paths = sorted(
1825
+ (project / ".harness" / "archive").glob("*/reports/final/summary-data.json")
1826
+ )
1827
+ archive_records = archive_summary_records(project, summary_paths)
1828
+ inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
1829
+ return KnowledgeSnapshot(
1830
+ project, knowledge, pname, config, summary_paths, archive_records, inputs_hash
1831
+ )
1626
1832
 
1627
- summary_paths = sorted(archive_root.glob("*/reports/final/summary-data.json"))
1833
+
1834
+ def build_index(
1835
+ project: Path,
1836
+ incremental: bool = True,
1837
+ *,
1838
+ snapshot: KnowledgeSnapshot | None = None,
1839
+ ) -> dict[str, Any]:
1840
+ project = project.resolve()
1841
+ if snapshot is not None:
1842
+ # Reuse the single-invocation snapshot: no recomputation of
1843
+ # config / archive records / inputs_hash (design §3.5, cluster 6 要点2).
1844
+ knowledge = snapshot.knowledge
1845
+ pname = snapshot.pname
1846
+ config = snapshot.config
1847
+ summary_paths = snapshot.summary_paths
1848
+ archive_records = snapshot.archive_records
1849
+ inputs_hash = snapshot.inputs_hash
1850
+ else:
1851
+ knowledge = project / ".harness" / "knowledge"
1852
+ pname = project_id(project)
1853
+ config = load_config(knowledge)
1854
+ summary_paths = sorted(
1855
+ (project / ".harness" / "archive").glob("*/reports/final/summary-data.json")
1856
+ )
1857
+ archive_records = archive_summary_records(project, summary_paths)
1858
+ inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
1859
+ ensure_knowledge_dirs(knowledge)
1860
+
1861
+ # No-op fast path: inputs (archive checksums + config + schema) are unchanged.
1862
+ # Write nothing — entries, sqlite, index and views all stay byte-identical,
1863
+ # so a repeated ingest is a true no-op (design §3.5, cluster 6, UT-025).
1864
+ # Also require index.sqlite to exist so a query never sees a stale index.json
1865
+ # pointing at a missing sqlite (API-009 single ensure-current).
1866
+ if incremental:
1867
+ old_index: dict[str, Any] | None = None
1868
+ index_path = knowledge / "index.json"
1869
+ if index_path.exists():
1870
+ try:
1871
+ old_index = read_json(index_path)
1872
+ except (OSError, json.JSONDecodeError):
1873
+ old_index = None
1874
+ if (
1875
+ isinstance(old_index, dict)
1876
+ and old_index.get("inputsHash") == inputs_hash
1877
+ and (knowledge / "index.sqlite").exists()
1878
+ ):
1879
+ stale_mode = dict(old_index.get("ingestMode", {}))
1880
+ stale_mode.update(
1881
+ {
1882
+ "mode": "no-op",
1883
+ "incremental": incremental,
1884
+ "inputsHash": inputs_hash,
1885
+ "entriesWritten": 0,
1886
+ "entriesPruned": 0,
1887
+ "archivesExtracted": 0,
1888
+ "archivesReused": len(summary_paths),
1889
+ "cacheWrites": 0,
1890
+ "sqliteRebuild": 0,
1891
+ "sqliteUpsert": 0,
1892
+ "sqliteDelete": 0,
1893
+ }
1894
+ )
1895
+ result = dict(old_index)
1896
+ result["ingestMode"] = stale_mode
1897
+ return result
1898
+
1899
+ mode = "cold" if not (knowledge / "index.json").exists() else "warm"
1900
+ current_head = git_head(project) # recorded in manifest only; not an invalidation key
1628
1901
  entries: list[dict[str, Any]] = []
1629
1902
  failures: list[dict[str, str]] = []
1630
1903
  ingest_mode: dict[str, Any] = {
1631
1904
  "incremental": incremental,
1905
+ "mode": mode,
1906
+ "inputsHash": inputs_hash,
1907
+ "entriesWritten": 0,
1908
+ "entriesPruned": 0,
1632
1909
  "archivesExtracted": 0,
1633
1910
  "archivesReused": 0,
1634
1911
  "cacheWrites": 0,
@@ -1638,6 +1915,9 @@ def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1638
1915
  "validationChecked": 0,
1639
1916
  "validationFailed": 0,
1640
1917
  "validationAutoDemoted": 0,
1918
+ "sqliteRebuild": 0,
1919
+ "sqliteUpsert": 0,
1920
+ "sqliteDelete": 0,
1641
1921
  }
1642
1922
 
1643
1923
  for summary_path in summary_paths:
@@ -1651,7 +1931,6 @@ def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1651
1931
  cache_path,
1652
1932
  summary_path=summary_rel,
1653
1933
  summary_hash=summary_hash,
1654
- head_commit=current_head,
1655
1934
  )
1656
1935
  if archive_entries is None:
1657
1936
  archive_entries = extract_entries(project, pname, summary_path)
@@ -1661,7 +1940,6 @@ def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1661
1940
  cache_path,
1662
1941
  summary_path=summary_rel,
1663
1942
  summary_hash=summary_hash,
1664
- head_commit=current_head,
1665
1943
  entries=archive_entries,
1666
1944
  )
1667
1945
  ingest_mode["cacheWrites"] += 1
@@ -1716,11 +1994,13 @@ def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1716
1994
  failures.append({"id": entry["id"], "reason": "filename collision",
1717
1995
  "path": str(target), "conflictsWith": existing.get("id")})
1718
1996
  continue
1719
- write_json(target, entry)
1997
+ _preserve_confidence_timestamp(target, entry)
1998
+ if write_json_if_changed(target, entry):
1999
+ ingest_mode["entriesWritten"] += 1
1720
2000
 
1721
2001
  indexed_entries = combine_generated_with_preserved(knowledge, deduped)
1722
2002
  apply_confidence_scores(indexed_entries, config)
1723
- persist_entry_updates(knowledge, indexed_entries)
2003
+ ingest_mode["entriesWritten"] += persist_entry_updates(knowledge, indexed_entries)
1724
2004
  auto_demotions = apply_active_lifecycle_policy(knowledge, indexed_entries, config)
1725
2005
  ingest_mode["activeAutoDemoted"] = len(auto_demotions)
1726
2006
  validation = apply_knowledge_validation(project, knowledge, config)
@@ -1732,10 +2012,12 @@ def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1732
2012
  if validation["checked"]:
1733
2013
  indexed_entries = [entry for _, entry in load_entry_files(knowledge)]
1734
2014
  apply_confidence_scores(indexed_entries, config)
1735
- persist_entry_updates(knowledge, indexed_entries)
2015
+ ingest_mode["entriesWritten"] += persist_entry_updates(knowledge, indexed_entries)
1736
2016
  ingest_mode["confidenceScored"] = len(indexed_entries)
1737
- archive_records = archive_summary_records(project, summary_paths)
1738
- write_sqlite(knowledge / "index.sqlite", indexed_entries)
2017
+ ingest_mode["entriesPruned"] = prune_generated_entries(knowledge, indexed_entries)
2018
+
2019
+ sqlite_stats = write_sqlite(knowledge / "index.sqlite", indexed_entries)
2020
+ ingest_mode.update(sqlite_stats)
1739
2021
  index = make_manifest(
1740
2022
  project,
1741
2023
  pname,
@@ -1746,7 +2028,7 @@ def build_index(project: Path, incremental: bool = True) -> dict[str, Any]:
1746
2028
  duplicates,
1747
2029
  ingest_mode,
1748
2030
  )
1749
- write_json(knowledge / "index.json", index)
2031
+ write_json_if_changed(knowledge / "index.json", index)
1750
2032
  write_views(knowledge, index, indexed_entries)
1751
2033
  write_ingest_report(knowledge, index, failures, duplicates)
1752
2034
  return index
@@ -2009,6 +2291,7 @@ def make_manifest(
2009
2291
  "projectId": pname,
2010
2292
  "projectRoot": str(project),
2011
2293
  "headCommit": git_head(project),
2294
+ "inputsHash": (ingest_mode or {}).get("inputsHash"),
2012
2295
  "archives": {
2013
2296
  "scanned": len(summary_paths),
2014
2297
  "indexed": len(summary_paths) - len(failures),
@@ -2035,90 +2318,153 @@ def make_manifest(
2035
2318
  }
2036
2319
 
2037
2320
 
2038
- def write_sqlite(path: Path, entries: list[dict[str, Any]]) -> None:
2321
+ def write_sqlite(path: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
2322
+ """Persist entries to SQLite using a transactional dirty-set.
2323
+
2324
+ Reads existing rows, then upserts only changed entries and deletes removed
2325
+ ones within a single transaction. When the schema version changes or the
2326
+ table is missing, performs a full rebuild. If nothing changed the file is
2327
+ not touched (cluster 6, design §3.5 — true incremental).
2328
+ """
2039
2329
  path.parent.mkdir(parents=True, exist_ok=True)
2040
- if path.exists():
2041
- path.unlink()
2330
+ stats: dict[str, Any] = {
2331
+ "sqliteRebuild": 0,
2332
+ "sqliteUpsert": 0,
2333
+ "sqliteDelete": 0,
2334
+ "sqliteUnchanged": 0,
2335
+ }
2042
2336
  con = sqlite3.connect(path)
2043
2337
  try:
2044
2338
  con.execute("pragma journal_mode=wal")
2045
- con.execute(
2046
- """
2047
- create table entries (
2048
- id text primary key,
2049
- project_id text not null,
2050
- type text not null,
2051
- status text not null,
2052
- title text not null,
2053
- summary text not null,
2054
- body text not null,
2055
- source_archive text not null,
2056
- source_commit text,
2057
- source_files_json text not null,
2058
- keywords_json text not null,
2059
- entry_json text not null
2060
- )
2061
- """
2339
+ table_exists = con.execute(
2340
+ "select name from sqlite_master where type='table' and name='entries'"
2341
+ ).fetchone()
2342
+ existing_version = (
2343
+ con.execute("pragma user_version").fetchone()[0] if table_exists else 0
2062
2344
  )
2063
- con.execute(
2064
- """
2065
- create table entry_files (
2066
- entry_id text not null,
2067
- source_file text not null,
2068
- primary key (entry_id, source_file),
2069
- foreign key (entry_id) references entries(id)
2070
- )
2071
- """
2072
- )
2073
- con.execute("create virtual table entries_fts using fts5(id, title, summary, body, keywords)")
2074
- con.execute("create index idx_entries_status on entries(status)")
2075
- con.execute("create index idx_entries_type on entries(type)")
2076
- con.execute("create index idx_entries_source_archive on entries(source_archive)")
2077
- con.execute("create index idx_entry_files_source_file on entry_files(source_file)")
2345
+ need_rebuild = (not table_exists) or existing_version != SQLITE_SCHEMA_VERSION
2346
+
2347
+ existing_map: dict[str, str] = {}
2348
+ if not need_rebuild:
2349
+ rows = con.execute("select id, entry_json from entries").fetchall()
2350
+ existing_map = {str(row[0]): str(row[1]) for row in rows}
2351
+
2352
+ new_map: dict[str, str] = {}
2078
2353
  for entry in entries:
2079
- source_files_json = json.dumps(entry["scope"]["sourceFiles"], ensure_ascii=False)
2080
- keywords_json = json.dumps(entry["keywords"], ensure_ascii=False)
2081
- entry_json = json.dumps(entry, ensure_ascii=False)
2354
+ if isinstance(entry, dict) and entry.get("id"):
2355
+ new_map[str(entry["id"])] = json.dumps(entry, ensure_ascii=False)
2356
+
2357
+ to_upsert = [eid for eid in new_map if new_map[eid] != existing_map.get(eid)]
2358
+ to_delete = [eid for eid in existing_map if eid not in new_map]
2359
+ stats["sqliteUnchanged"] = len(new_map) - len(to_upsert)
2360
+
2361
+ if not need_rebuild and not to_upsert and not to_delete:
2362
+ # nothing to do; leave the file (and its mtime) untouched
2363
+ return stats
2364
+
2365
+ if need_rebuild:
2366
+ con.execute("drop table if exists entries")
2367
+ con.execute("drop table if exists entry_files")
2368
+ con.execute("drop table if exists entries_fts")
2082
2369
  con.execute(
2083
2370
  """
2084
- insert into entries (
2085
- id, project_id, type, status, title, summary, body, source_archive,
2086
- source_commit, source_files_json, keywords_json, entry_json
2087
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2088
- """,
2089
- (
2090
- entry["id"],
2091
- entry["projectId"],
2092
- entry["type"],
2093
- entry["status"],
2094
- entry["title"],
2095
- entry["summary"],
2096
- entry["body"],
2097
- entry["source"]["archive"],
2098
- entry["source"]["sourceCommit"],
2099
- source_files_json,
2100
- keywords_json,
2101
- entry_json,
2102
- ),
2371
+ create table entries (
2372
+ id text primary key,
2373
+ project_id text not null,
2374
+ type text not null,
2375
+ status text not null,
2376
+ title text not null,
2377
+ summary text not null,
2378
+ body text not null,
2379
+ source_archive text not null,
2380
+ source_commit text,
2381
+ source_files_json text not null,
2382
+ keywords_json text not null,
2383
+ entry_json text not null
2384
+ )
2385
+ """
2103
2386
  )
2104
2387
  con.execute(
2105
- "insert into entries_fts (id, title, summary, body, keywords) values (?, ?, ?, ?, ?)",
2106
- (
2107
- entry["id"],
2108
- entry["title"],
2109
- entry["summary"],
2110
- entry["body"],
2111
- " ".join(entry["keywords"]),
2112
- ),
2388
+ """
2389
+ create table entry_files (
2390
+ entry_id text not null,
2391
+ source_file text not null,
2392
+ primary key (entry_id, source_file),
2393
+ foreign key (entry_id) references entries(id)
2394
+ )
2395
+ """
2113
2396
  )
2114
- for source_file in entry["scope"]["sourceFiles"]:
2397
+ con.execute("create virtual table entries_fts using fts5(id, title, summary, body, keywords)")
2398
+ con.execute("create index idx_entries_status on entries(status)")
2399
+ con.execute("create index idx_entries_type on entries(type)")
2400
+ con.execute("create index idx_entries_source_archive on entries(source_archive)")
2401
+ con.execute("create index idx_entry_files_source_file on entry_files(source_file)")
2402
+ con.execute(f"pragma user_version = {SQLITE_SCHEMA_VERSION}")
2403
+ stats["sqliteRebuild"] = 1
2404
+ # full rebuild => every entry must be (re)inserted
2405
+ to_upsert = list(new_map.keys())
2406
+ to_delete = []
2407
+
2408
+ con.execute("begin")
2409
+ try:
2410
+ for eid in to_delete:
2411
+ con.execute("delete from entries where id=?", (eid,))
2412
+ con.execute("delete from entries_fts where id=?", (eid,))
2413
+ con.execute("delete from entry_files where entry_id=?", (eid,))
2414
+ for entry in entries:
2415
+ eid = str(entry["id"])
2416
+ if eid not in to_upsert:
2417
+ continue
2418
+ source_files_json = json.dumps(entry["scope"]["sourceFiles"], ensure_ascii=False)
2419
+ keywords_json = json.dumps(entry["keywords"], ensure_ascii=False)
2420
+ con.execute(
2421
+ """
2422
+ insert or replace into entries (
2423
+ id, project_id, type, status, title, summary, body, source_archive,
2424
+ source_commit, source_files_json, keywords_json, entry_json
2425
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2426
+ """,
2427
+ (
2428
+ entry["id"],
2429
+ entry["projectId"],
2430
+ entry["type"],
2431
+ entry["status"],
2432
+ entry["title"],
2433
+ entry["summary"],
2434
+ entry["body"],
2435
+ entry["source"]["archive"],
2436
+ entry["source"]["sourceCommit"],
2437
+ source_files_json,
2438
+ keywords_json,
2439
+ new_map[eid],
2440
+ ),
2441
+ )
2442
+ con.execute("delete from entries_fts where id=?", (eid,))
2115
2443
  con.execute(
2116
- "insert or ignore into entry_files (entry_id, source_file) values (?, ?)",
2117
- (entry["id"], source_file),
2444
+ "insert into entries_fts (id, title, summary, body, keywords) values (?, ?, ?, ?, ?)",
2445
+ (
2446
+ entry["id"],
2447
+ entry["title"],
2448
+ entry["summary"],
2449
+ entry["body"],
2450
+ " ".join(entry["keywords"]),
2451
+ ),
2118
2452
  )
2119
- con.commit()
2453
+ con.execute("delete from entry_files where entry_id=?", (eid,))
2454
+ for source_file in entry["scope"]["sourceFiles"]:
2455
+ con.execute(
2456
+ "insert or ignore into entry_files (entry_id, source_file) values (?, ?)",
2457
+ (eid, source_file),
2458
+ )
2459
+ stats["sqliteUpsert"] = len(to_upsert)
2460
+ stats["sqliteDelete"] = len(to_delete)
2461
+ con.commit()
2462
+ except Exception:
2463
+ con.rollback()
2464
+ raise
2120
2465
  finally:
2121
2466
  con.close()
2467
+ return stats
2122
2468
 
2123
2469
 
2124
2470
  def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str, Any]]) -> None:
@@ -2141,7 +2487,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2141
2487
  for entry in entries[:50]:
2142
2488
  dashboard.append(f"- **{entry['type']}** `{entry['status']}` {entry['title']}")
2143
2489
  dashboard.append(f" - source: `{entry['source']['archive']}`")
2144
- write_text(knowledge / "views" / "knowledge-dashboard.md", "\n".join(dashboard) + "\n")
2490
+ write_text_if_changed(knowledge / "views" / "knowledge-dashboard.md", "\n".join(dashboard) + "\n")
2145
2491
 
2146
2492
  by_file: dict[str, list[dict[str, Any]]] = {}
2147
2493
  for entry in entries:
@@ -2153,7 +2499,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2153
2499
  for entry in by_file[source_file]:
2154
2500
  lines.append(f"- **{entry['type']}** `{entry['status']}` {entry['title']}")
2155
2501
  lines.append("")
2156
- write_text(knowledge / "views" / "by-file.md", "\n".join(lines))
2502
+ write_text_if_changed(knowledge / "views" / "by-file.md", "\n".join(lines))
2157
2503
 
2158
2504
  stale = [entry for entry in entries if entry["status"] == "stale"]
2159
2505
  lines = ["# Harness Stale Knowledge", ""]
@@ -2163,7 +2509,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2163
2509
  lines.append(f"- **{entry['type']}** {entry['title']}")
2164
2510
  for reason in entry["lifecycle"]["staleReasons"]:
2165
2511
  lines.append(f" - {reason}")
2166
- write_text(knowledge / "views" / "stale-items.md", "\n".join(lines) + "\n")
2512
+ write_text_if_changed(knowledge / "views" / "stale-items.md", "\n".join(lines) + "\n")
2167
2513
 
2168
2514
  superseded = [entry for entry in entries if entry["status"] == "superseded"]
2169
2515
  lines = ["# Harness Superseded Knowledge", ""]
@@ -2176,7 +2522,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2176
2522
  lines.append(f" - supersededBy: `{superseded_by}`")
2177
2523
  for reason in entry["lifecycle"].get("staleReasons") or []:
2178
2524
  lines.append(f" - {reason}")
2179
- write_text(knowledge / "views" / "superseded-items.md", "\n".join(lines) + "\n")
2525
+ write_text_if_changed(knowledge / "views" / "superseded-items.md", "\n".join(lines) + "\n")
2180
2526
 
2181
2527
  conflicted = [entry for entry in entries if entry["status"] == "conflicted"]
2182
2528
  lines = ["# Harness Conflicted Knowledge", ""]
@@ -2188,7 +2534,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2188
2534
  lines.append(f" - conflictsWith: `{conflict_id}`")
2189
2535
  for reason in entry["lifecycle"].get("staleReasons") or []:
2190
2536
  lines.append(f" - {reason}")
2191
- write_text(knowledge / "views" / "conflicted-items.md", "\n".join(lines) + "\n")
2537
+ write_text_if_changed(knowledge / "views" / "conflicted-items.md", "\n".join(lines) + "\n")
2192
2538
 
2193
2539
  active_review = active_review_items(entries)
2194
2540
  lines = ["# Harness Active Review", ""]
@@ -2199,7 +2545,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2199
2545
  lines.append(f" - id: `{entry['id']}`")
2200
2546
  for reason in entry.get("reviewReasons") or []:
2201
2547
  lines.append(f" - {reason}")
2202
- write_text(knowledge / "views" / "active-review.md", "\n".join(lines) + "\n")
2548
+ write_text_if_changed(knowledge / "views" / "active-review.md", "\n".join(lines) + "\n")
2203
2549
 
2204
2550
  base = [
2205
2551
  'filters:',
@@ -2234,7 +2580,7 @@ def write_views(knowledge: Path, index: dict[str, Any], entries: list[dict[str,
2234
2580
  ' - file.folder',
2235
2581
  ' - file.mtime',
2236
2582
  ]
2237
- write_text(knowledge / "views" / "knowledge.base", "\n".join(base) + "\n")
2583
+ write_text_if_changed(knowledge / "views" / "knowledge.base", "\n".join(base) + "\n")
2238
2584
 
2239
2585
 
2240
2586
  def write_ingest_report(
@@ -2639,11 +2985,12 @@ def query_index(
2639
2985
  project = project.resolve()
2640
2986
  knowledge = project / ".harness" / "knowledge"
2641
2987
  sqlite_path = knowledge / "index.sqlite"
2642
- if not sqlite_path.exists():
2643
- build_index(project)
2644
- sync = sync_status(project)
2645
- if not sync["upToDate"]:
2646
- build_index(project)
2988
+ # API-009: one ensure-current. Build the shared snapshot once (inputs_hash
2989
+ # computed exactly once) and a single build_index call whose no-op fast path
2990
+ # keeps an up-to-date project a true no-op. Replaces the old sync_status +
2991
+ # build_index double orchestration, which computed inputs_hash twice.
2992
+ snapshot = build_snapshot(project)
2993
+ build_index(project, snapshot=snapshot)
2647
2994
  entries = search_entries(sqlite_path, query, limit, file_filters, statuses, types)
2648
2995
  context_path = write_context_pack(project, knowledge, query, entries)
2649
2996
  filters = {
@@ -2738,9 +3085,14 @@ def sync_status(project: Path, update: bool = False, incremental: bool = True) -
2738
3085
  if current_by_path[path].get("summarySha256") != indexed_by_path[path].get("summarySha256"):
2739
3086
  reasons.append("archive checksum changed: " + str(path))
2740
3087
 
2741
- current_head = git_head(project)
2742
- if current_head != index.get("headCommit"):
2743
- reasons.append("head commit changed since last ingest")
3088
+ # HEAD is intentionally not an invalidation key (cluster 6, UT-027): a
3089
+ # business-code commit that does not touch any archive must not force a
3090
+ # rebuild. The archive checksum checks above already cover archive
3091
+ # changes; compare the full input fingerprint to catch config/schema
3092
+ # drift that the per-archive checks would miss.
3093
+ current_inputs_hash = compute_inputs_hash(current_records, load_config(knowledge), knowledge)
3094
+ if current_inputs_hash != index.get("inputsHash") and not reasons:
3095
+ reasons.append("knowledge inputs changed (config or schema)")
2744
3096
 
2745
3097
  action = "none"
2746
3098
  refreshed: dict[str, Any] | None = None