@rigour-labs/core 6.7.10 → 6.8.1-rc.1

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 (346) hide show
  1. package/dist/brief/briefing.d.ts +59 -0
  2. package/dist/brief/briefing.js +126 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +2 -0
  5. package/dist/inference/cloud-provider.js +12 -2
  6. package/dist/review/backtest-init.d.ts +8 -5
  7. package/dist/review/backtest-init.js +27 -9
  8. package/dist/review/backtest-last.js +3 -1
  9. package/dist/review/reviewer/adapters.d.ts +4 -0
  10. package/dist/review/reviewer/adapters.js +23 -0
  11. package/dist/review/reviewer/inputs.d.ts +4 -1
  12. package/dist/review/reviewer/inputs.js +36 -2
  13. package/dist/review/reviewer/record.d.ts +5 -0
  14. package/dist/review/reviewer/record.js +3 -3
  15. package/dist/review/reviewer/verdict.d.ts +14 -0
  16. package/dist/review/reviewer/verdict.js +46 -6
  17. package/dist/review/reviewer.d.ts +2 -0
  18. package/dist/review/reviewer.js +24 -5
  19. package/dist/review-learning/repo-rules.d.ts +14 -2
  20. package/dist/review-learning/repo-rules.js +59 -9
  21. package/dist/task/thread.d.ts +47 -0
  22. package/dist/task/thread.js +234 -0
  23. package/dist/templates/universal-config.js +4 -0
  24. package/dist/types/index.d.ts +21 -0
  25. package/dist/types/index.js +7 -0
  26. package/package.json +11 -8
  27. package/dist/context/automatic-index-cache.test.d.ts +0 -1
  28. package/dist/context/automatic-index-cache.test.js +0 -30
  29. package/dist/context/automatic-index.test.d.ts +0 -1
  30. package/dist/context/automatic-index.test.js +0 -45
  31. package/dist/context/cache-engine.test.d.ts +0 -1
  32. package/dist/context/cache-engine.test.js +0 -99
  33. package/dist/context/dependency-graph.test.d.ts +0 -1
  34. package/dist/context/dependency-graph.test.js +0 -13
  35. package/dist/context/index-status.test.d.ts +0 -1
  36. package/dist/context/index-status.test.js +0 -26
  37. package/dist/context.test.d.ts +0 -1
  38. package/dist/context.test.js +0 -228
  39. package/dist/deep/agent-review.test.d.ts +0 -1
  40. package/dist/deep/agent-review.test.js +0 -53
  41. package/dist/deep/code-context.test.d.ts +0 -1
  42. package/dist/deep/code-context.test.js +0 -48
  43. package/dist/deep/code-pass.test.d.ts +0 -1
  44. package/dist/deep/code-pass.test.js +0 -112
  45. package/dist/deep/code-review-prompt.test.d.ts +0 -1
  46. package/dist/deep/code-review-prompt.test.js +0 -20
  47. package/dist/deep/code-verifier.test.d.ts +0 -1
  48. package/dist/deep/code-verifier.test.js +0 -54
  49. package/dist/deep/diff-tests/calls.test.d.ts +0 -1
  50. package/dist/deep/diff-tests/calls.test.js +0 -33
  51. package/dist/deep/diff-tests/run.test.d.ts +0 -1
  52. package/dist/deep/diff-tests/run.test.js +0 -58
  53. package/dist/deep/fact-extractor.test.d.ts +0 -1
  54. package/dist/deep/fact-extractor.test.js +0 -581
  55. package/dist/deep/parse-findings.test.d.ts +0 -1
  56. package/dist/deep/parse-findings.test.js +0 -25
  57. package/dist/deep/pr-review.test.d.ts +0 -1
  58. package/dist/deep/pr-review.test.js +0 -80
  59. package/dist/deep/prompts.test.d.ts +0 -1
  60. package/dist/deep/prompts.test.js +0 -235
  61. package/dist/deep/reference-pack.test.d.ts +0 -1
  62. package/dist/deep/reference-pack.test.js +0 -46
  63. package/dist/deep/related-changes.test.d.ts +0 -1
  64. package/dist/deep/related-changes.test.js +0 -33
  65. package/dist/deep/review-context-export.test.d.ts +0 -1
  66. package/dist/deep/review-context-export.test.js +0 -32
  67. package/dist/deep/review-tools.test.d.ts +0 -1
  68. package/dist/deep/review-tools.test.js +0 -39
  69. package/dist/deep/risk.test.d.ts +0 -1
  70. package/dist/deep/risk.test.js +0 -101
  71. package/dist/deep/verifier.test.d.ts +0 -1
  72. package/dist/deep/verifier.test.js +0 -635
  73. package/dist/discovery.test.d.ts +0 -1
  74. package/dist/discovery.test.js +0 -93
  75. package/dist/environment.test.d.ts +0 -1
  76. package/dist/environment.test.js +0 -94
  77. package/dist/firewall/firewall.test.d.ts +0 -1
  78. package/dist/firewall/firewall.test.js +0 -117
  79. package/dist/firewall/trust-boundaries.test.d.ts +0 -1
  80. package/dist/firewall/trust-boundaries.test.js +0 -64
  81. package/dist/firewall/trusted-control.test.d.ts +0 -1
  82. package/dist/firewall/trusted-control.test.js +0 -188
  83. package/dist/gates/agent-team.test.d.ts +0 -1
  84. package/dist/gates/agent-team.test.js +0 -113
  85. package/dist/gates/ast.test.d.ts +0 -1
  86. package/dist/gates/ast.test.js +0 -112
  87. package/dist/gates/checkpoint.test.d.ts +0 -1
  88. package/dist/gates/checkpoint.test.js +0 -105
  89. package/dist/gates/content.test.d.ts +0 -1
  90. package/dist/gates/content.test.js +0 -73
  91. package/dist/gates/coverage.test.d.ts +0 -1
  92. package/dist/gates/coverage.test.js +0 -53
  93. package/dist/gates/dedupe-failures.test.d.ts +0 -1
  94. package/dist/gates/dedupe-failures.test.js +0 -12
  95. package/dist/gates/deep-analysis.test.d.ts +0 -1
  96. package/dist/gates/deep-analysis.test.js +0 -86
  97. package/dist/gates/deep-intent.test.d.ts +0 -1
  98. package/dist/gates/deep-intent.test.js +0 -51
  99. package/dist/gates/deep-timeout.test.d.ts +0 -1
  100. package/dist/gates/deep-timeout.test.js +0 -10
  101. package/dist/gates/deprecated-apis.test.d.ts +0 -1
  102. package/dist/gates/deprecated-apis.test.js +0 -318
  103. package/dist/gates/deprecated-dependencies.test.d.ts +0 -1
  104. package/dist/gates/deprecated-dependencies.test.js +0 -55
  105. package/dist/gates/frontend-secret-exposure.test.d.ts +0 -1
  106. package/dist/gates/frontend-secret-exposure.test.js +0 -148
  107. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.d.ts +0 -1
  108. package/dist/gates/hallucinated-imports/framework-modules-nuxt.test.js +0 -27
  109. package/dist/gates/hallucinated-imports/js-resolver-types.test.d.ts +0 -1
  110. package/dist/gates/hallucinated-imports/js-resolver-types.test.js +0 -14
  111. package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +0 -1
  112. package/dist/gates/hallucinated-imports-sveltekit.test.js +0 -132
  113. package/dist/gates/hallucinated-imports.test.d.ts +0 -1
  114. package/dist/gates/hallucinated-imports.test.js +0 -1206
  115. package/dist/gates/js-style-context.test.d.ts +0 -1
  116. package/dist/gates/js-style-context.test.js +0 -35
  117. package/dist/gates/logic-drift.test.d.ts +0 -1
  118. package/dist/gates/logic-drift.test.js +0 -52
  119. package/dist/gates/phantom-apis.test.d.ts +0 -1
  120. package/dist/gates/phantom-apis.test.js +0 -396
  121. package/dist/gates/promise-safety.test.d.ts +0 -1
  122. package/dist/gates/promise-safety.test.js +0 -34
  123. package/dist/gates/runner.test.d.ts +0 -1
  124. package/dist/gates/runner.test.js +0 -77
  125. package/dist/gates/scoped-gates.test.d.ts +0 -1
  126. package/dist/gates/scoped-gates.test.js +0 -52
  127. package/dist/gates/security-patterns-owasp.test.d.ts +0 -1
  128. package/dist/gates/security-patterns-owasp.test.js +0 -186
  129. package/dist/gates/security-patterns.test.d.ts +0 -1
  130. package/dist/gates/security-patterns.test.js +0 -194
  131. package/dist/gates/semantic-bugs.test.d.ts +0 -1
  132. package/dist/gates/semantic-bugs.test.js +0 -76
  133. package/dist/gates/side-effect-analysis.test.d.ts +0 -1
  134. package/dist/gates/side-effect-analysis.test.js +0 -162
  135. package/dist/gates/style-drift.test.d.ts +0 -1
  136. package/dist/gates/style-drift.test.js +0 -26
  137. package/dist/gates/test-quality.test.d.ts +0 -1
  138. package/dist/gates/test-quality.test.js +0 -325
  139. package/dist/gates/trusted-reviews.test.d.ts +0 -1
  140. package/dist/gates/trusted-reviews.test.js +0 -16
  141. package/dist/gates/unindexed-reads/queries.test.d.ts +0 -1
  142. package/dist/gates/unindexed-reads/queries.test.js +0 -54
  143. package/dist/gates/unindexed-reads/schema.test.d.ts +0 -1
  144. package/dist/gates/unindexed-reads/schema.test.js +0 -81
  145. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +0 -1
  146. package/dist/gates/unindexed-reads/unindexed-reads.test.js +0 -90
  147. package/dist/hooks/checker.test.d.ts +0 -1
  148. package/dist/hooks/checker.test.js +0 -159
  149. package/dist/hooks/dlp-confidence.test.d.ts +0 -1
  150. package/dist/hooks/dlp-confidence.test.js +0 -51
  151. package/dist/hooks/dlp-feedback.test.d.ts +0 -1
  152. package/dist/hooks/dlp-feedback.test.js +0 -131
  153. package/dist/hooks/input-validator.test.d.ts +0 -1
  154. package/dist/hooks/input-validator.test.js +0 -329
  155. package/dist/hooks/templates.test.d.ts +0 -1
  156. package/dist/hooks/templates.test.js +0 -27
  157. package/dist/inference/brain-placeholder.test.d.ts +0 -1
  158. package/dist/inference/brain-placeholder.test.js +0 -28
  159. package/dist/inference/cloud-provider.test.d.ts +0 -1
  160. package/dist/inference/cloud-provider.test.js +0 -139
  161. package/dist/inference/executable.test.d.ts +0 -1
  162. package/dist/inference/executable.test.js +0 -41
  163. package/dist/inference/http-download.test.d.ts +0 -1
  164. package/dist/inference/http-download.test.js +0 -109
  165. package/dist/inference/llama-engine-checksum.test.d.ts +0 -1
  166. package/dist/inference/llama-engine-checksum.test.js +0 -27
  167. package/dist/inference/llama-engine.test.d.ts +0 -1
  168. package/dist/inference/llama-engine.test.js +0 -51
  169. package/dist/inference/llama-process.test.d.ts +0 -1
  170. package/dist/inference/llama-process.test.js +0 -61
  171. package/dist/inference/local-model.test.d.ts +0 -1
  172. package/dist/inference/local-model.test.js +0 -23
  173. package/dist/inference/model-download.test.d.ts +0 -1
  174. package/dist/inference/model-download.test.js +0 -125
  175. package/dist/inference/model-manager.test.d.ts +0 -1
  176. package/dist/inference/model-manager.test.js +0 -24
  177. package/dist/inference/types.test.d.ts +0 -1
  178. package/dist/inference/types.test.js +0 -19
  179. package/dist/memory/recall.test.d.ts +0 -1
  180. package/dist/memory/recall.test.js +0 -36
  181. package/dist/pattern-index/indexer.test.d.ts +0 -6
  182. package/dist/pattern-index/indexer.test.js +0 -197
  183. package/dist/pattern-index/matcher.test.d.ts +0 -6
  184. package/dist/pattern-index/matcher.test.js +0 -238
  185. package/dist/pattern-index/pattern-reuse.test.d.ts +0 -1
  186. package/dist/pattern-index/pattern-reuse.test.js +0 -76
  187. package/dist/pattern-index/semantic-runtime.test.d.ts +0 -1
  188. package/dist/pattern-index/semantic-runtime.test.js +0 -31
  189. package/dist/pattern-index/staleness.test.d.ts +0 -6
  190. package/dist/pattern-index/staleness.test.js +0 -211
  191. package/dist/review/agent-fixes.test.d.ts +0 -1
  192. package/dist/review/agent-fixes.test.js +0 -32
  193. package/dist/review/backtest-init.test.d.ts +0 -1
  194. package/dist/review/backtest-init.test.js +0 -98
  195. package/dist/review/backtest-judges.test.d.ts +0 -1
  196. package/dist/review/backtest-judges.test.js +0 -32
  197. package/dist/review/backtest-last.test.d.ts +0 -1
  198. package/dist/review/backtest-last.test.js +0 -109
  199. package/dist/review/backtest.test.d.ts +0 -1
  200. package/dist/review/backtest.test.js +0 -183
  201. package/dist/review/baseline.test.d.ts +0 -1
  202. package/dist/review/baseline.test.js +0 -22
  203. package/dist/review/branch-checks.test.d.ts +0 -1
  204. package/dist/review/branch-checks.test.js +0 -48
  205. package/dist/review/check-outcomes.test.d.ts +0 -1
  206. package/dist/review/check-outcomes.test.js +0 -56
  207. package/dist/review/code-patterns.test.d.ts +0 -1
  208. package/dist/review/code-patterns.test.js +0 -142
  209. package/dist/review/dead-code.test.d.ts +0 -1
  210. package/dist/review/dead-code.test.js +0 -154
  211. package/dist/review/deep-runs.test.d.ts +0 -1
  212. package/dist/review/deep-runs.test.js +0 -23
  213. package/dist/review/effectiveness.test.d.ts +0 -1
  214. package/dist/review/effectiveness.test.js +0 -42
  215. package/dist/review/fix-scope.test.d.ts +0 -1
  216. package/dist/review/fix-scope.test.js +0 -104
  217. package/dist/review/generated-files.test.d.ts +0 -1
  218. package/dist/review/generated-files.test.js +0 -25
  219. package/dist/review/migration-order.test.d.ts +0 -1
  220. package/dist/review/migration-order.test.js +0 -62
  221. package/dist/review/quiet.test.d.ts +0 -1
  222. package/dist/review/quiet.test.js +0 -38
  223. package/dist/review/receipt.test.d.ts +0 -1
  224. package/dist/review/receipt.test.js +0 -60
  225. package/dist/review/review-task.test.d.ts +0 -1
  226. package/dist/review/review-task.test.js +0 -89
  227. package/dist/review/review.test.d.ts +0 -1
  228. package/dist/review/review.test.js +0 -203
  229. package/dist/review/reviewer/adapters.test.d.ts +0 -1
  230. package/dist/review/reviewer/adapters.test.js +0 -60
  231. package/dist/review/reviewer/api-judge.test.d.ts +0 -1
  232. package/dist/review/reviewer/api-judge.test.js +0 -108
  233. package/dist/review/reviewer/background.test.d.ts +0 -1
  234. package/dist/review/reviewer/background.test.js +0 -118
  235. package/dist/review/reviewer/context.test.d.ts +0 -1
  236. package/dist/review/reviewer/context.test.js +0 -45
  237. package/dist/review/reviewer/exec.test.d.ts +0 -1
  238. package/dist/review/reviewer/exec.test.js +0 -59
  239. package/dist/review/reviewer/inputs.test.d.ts +0 -1
  240. package/dist/review/reviewer/inputs.test.js +0 -26
  241. package/dist/review/reviewer/panel.test.d.ts +0 -1
  242. package/dist/review/reviewer/panel.test.js +0 -110
  243. package/dist/review/reviewer/record.test.d.ts +0 -1
  244. package/dist/review/reviewer/record.test.js +0 -32
  245. package/dist/review/reviewer/rule-writer.test.d.ts +0 -1
  246. package/dist/review/reviewer/rule-writer.test.js +0 -52
  247. package/dist/review/reviewer/settings.test.d.ts +0 -1
  248. package/dist/review/reviewer/settings.test.js +0 -57
  249. package/dist/review/reviewer/usage.test.d.ts +0 -1
  250. package/dist/review/reviewer/usage.test.js +0 -14
  251. package/dist/review/reviewer.test.d.ts +0 -1
  252. package/dist/review/reviewer.test.js +0 -705
  253. package/dist/review/stories.test.d.ts +0 -1
  254. package/dist/review/stories.test.js +0 -58
  255. package/dist/review/toolchain.test.d.ts +0 -1
  256. package/dist/review/toolchain.test.js +0 -108
  257. package/dist/review/typed/redundancy.test.d.ts +0 -1
  258. package/dist/review/typed/redundancy.test.js +0 -315
  259. package/dist/review/typed/schema-nullability.test.d.ts +0 -1
  260. package/dist/review/typed/schema-nullability.test.js +0 -63
  261. package/dist/review-learning/human-edits.test.d.ts +0 -1
  262. package/dist/review-learning/human-edits.test.js +0 -47
  263. package/dist/review-learning/repo-rules.test.d.ts +0 -1
  264. package/dist/review-learning/repo-rules.test.js +0 -55
  265. package/dist/review-learning/review-learning.test.d.ts +0 -1
  266. package/dist/review-learning/review-learning.test.js +0 -249
  267. package/dist/safety.test.d.ts +0 -1
  268. package/dist/safety.test.js +0 -42
  269. package/dist/semantic/benchmark.test.d.ts +0 -1
  270. package/dist/semantic/benchmark.test.js +0 -22
  271. package/dist/semantic/intent/intent.test.d.ts +0 -1
  272. package/dist/semantic/intent/intent.test.js +0 -46
  273. package/dist/semantic/learn/learn.test.d.ts +0 -1
  274. package/dist/semantic/learn/learn.test.js +0 -67
  275. package/dist/semantic/origins.test.d.ts +0 -1
  276. package/dist/semantic/origins.test.js +0 -77
  277. package/dist/semantic/project-facts.test.d.ts +0 -1
  278. package/dist/semantic/project-facts.test.js +0 -45
  279. package/dist/semantic/sites/call-sites.test.d.ts +0 -1
  280. package/dist/semantic/sites/call-sites.test.js +0 -46
  281. package/dist/services/adaptive-thresholds.test.d.ts +0 -1
  282. package/dist/services/adaptive-thresholds.test.js +0 -53
  283. package/dist/services/agent-history.test.d.ts +0 -1
  284. package/dist/services/agent-history.test.js +0 -69
  285. package/dist/services/context-scope-summary.test.d.ts +0 -1
  286. package/dist/services/context-scope-summary.test.js +0 -17
  287. package/dist/services/context-telemetry-service.test.d.ts +0 -1
  288. package/dist/services/context-telemetry-service.test.js +0 -182
  289. package/dist/services/cursor-usage-sync.test.d.ts +0 -1
  290. package/dist/services/cursor-usage-sync.test.js +0 -173
  291. package/dist/services/engineering-knowledge-graph.test.d.ts +0 -1
  292. package/dist/services/engineering-knowledge-graph.test.js +0 -76
  293. package/dist/services/model-pricing.test.d.ts +0 -1
  294. package/dist/services/model-pricing.test.js +0 -44
  295. package/dist/services/observed-savings.test.d.ts +0 -1
  296. package/dist/services/observed-savings.test.js +0 -37
  297. package/dist/services/score-history.test.d.ts +0 -1
  298. package/dist/services/score-history.test.js +0 -61
  299. package/dist/smoke.test.d.ts +0 -1
  300. package/dist/smoke.test.js +0 -17
  301. package/dist/storage/cache-cleanup.test.d.ts +0 -1
  302. package/dist/storage/cache-cleanup.test.js +0 -54
  303. package/dist/storage/context-telemetry.test.d.ts +0 -1
  304. package/dist/storage/context-telemetry.test.js +0 -80
  305. package/dist/storage/db.test.d.ts +0 -1
  306. package/dist/storage/db.test.js +0 -46
  307. package/dist/storage/fix-lessons.test.d.ts +0 -1
  308. package/dist/storage/fix-lessons.test.js +0 -61
  309. package/dist/storage/lessons.test.d.ts +0 -1
  310. package/dist/storage/lessons.test.js +0 -81
  311. package/dist/storage/local-encryption.test.d.ts +0 -1
  312. package/dist/storage/local-encryption.test.js +0 -34
  313. package/dist/storage/local-memory.test.d.ts +0 -1
  314. package/dist/storage/local-memory.test.js +0 -55
  315. package/dist/storage/share-memory.test.d.ts +0 -1
  316. package/dist/storage/share-memory.test.js +0 -34
  317. package/dist/storage/team-diagnostics.test.d.ts +0 -1
  318. package/dist/storage/team-diagnostics.test.js +0 -51
  319. package/dist/storage/team-scope.test.d.ts +0 -1
  320. package/dist/storage/team-scope.test.js +0 -22
  321. package/dist/storage/team-store.test.d.ts +0 -1
  322. package/dist/storage/team-store.test.js +0 -11
  323. package/dist/storage/team-sync-scope.test.d.ts +0 -1
  324. package/dist/storage/team-sync-scope.test.js +0 -100
  325. package/dist/storage/team-vector-store.test.d.ts +0 -1
  326. package/dist/storage/team-vector-store.test.js +0 -56
  327. package/dist/storage/telemetry-scope.test.d.ts +0 -1
  328. package/dist/storage/telemetry-scope.test.js +0 -38
  329. package/dist/telemetry/telemetry.test.d.ts +0 -1
  330. package/dist/telemetry/telemetry.test.js +0 -64
  331. package/dist/types/index.test.d.ts +0 -1
  332. package/dist/types/index.test.js +0 -33
  333. package/dist/utils/command-line.test.d.ts +0 -1
  334. package/dist/utils/command-line.test.js +0 -8
  335. package/dist/utils/diff-removed.test.d.ts +0 -1
  336. package/dist/utils/diff-removed.test.js +0 -29
  337. package/dist/utils/diff.test.d.ts +0 -1
  338. package/dist/utils/diff.test.js +0 -38
  339. package/dist/utils/glob-paths.test.d.ts +0 -1
  340. package/dist/utils/glob-paths.test.js +0 -40
  341. package/dist/utils/profile.test.d.ts +0 -1
  342. package/dist/utils/profile.test.js +0 -68
  343. package/dist/utils/scanner.test.d.ts +0 -1
  344. package/dist/utils/scanner.test.js +0 -48
  345. package/dist/utils/scope.test.d.ts +0 -1
  346. package/dist/utils/scope.test.js +0 -38
@@ -12,6 +12,34 @@ import { createHash } from 'crypto';
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { textSimilarity } from './consensus.js';
15
+ /** How alike a judge's prior point and a labelled line of the review must read to take the reviewer's label. */
16
+ const LABEL_SIMILARITY = 0.4;
17
+ /**
18
+ * The point with the severity its reviewer wrote, when the review labels it; the judge's reading otherwise. The reviewer
19
+ * is matched by login; the review by the date the judge names (judges write a timestamp, a date, or reformat it), and
20
+ * when no review of that reviewer has that date, by that reviewer's latest labelled review. A disagreement is said.
21
+ */
22
+ function labelled(p, labels) {
23
+ const [login, ...rest] = (p.review ?? '').trim().split(/\s+/);
24
+ const day = /\d{4}-\d{2}-\d{2}/.exec(rest.join(' '))?.[0];
25
+ const theirs = login ? labels.filter(l => l.login === login) : [];
26
+ const sameDay = day ? theirs.filter(l => l.at.startsWith(day)) : [];
27
+ const latest = theirs.reduce((max, l) => (l.at > max ? l.at : max), '');
28
+ const candidates = sameDay.length ? sameDay : theirs.filter(l => l.at === latest);
29
+ const asItem = (issue) => ({ id: '', kind: 'prior', class: 'prior point', issue });
30
+ let best;
31
+ for (const label of candidates) {
32
+ const score = textSimilarity(asItem(p.point), asItem(label.text));
33
+ if (score >= LABEL_SIMILARITY && (!best || score > best.score))
34
+ best = { label, score };
35
+ }
36
+ if (!best)
37
+ return { point: p, took: false, disagreed: false };
38
+ if (best.label.severity === p.severity)
39
+ return { point: p, took: true, disagreed: false };
40
+ const note = `the review labels it ${best.label.severity}${p.severity ? `; the judge read ${p.severity}` : ''}`;
41
+ return { point: { ...p, severity: best.label.severity, evidence: p.evidence ? `${p.evidence}; ${note}` : note }, took: true, disagreed: true };
42
+ }
15
43
  const NO_PRIOR_CHECKS = { approvals: [], inCheckout: () => undefined };
16
44
  /** The touched lines of a unified diff (`git diff base...head`). */
17
45
  export function changedLinesOf(diff) {
@@ -214,7 +242,10 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
214
242
  const notes = [];
215
243
  const advisory = [];
216
244
  const seen = new Set();
217
- const accepted = verdict.prior_points.filter(p => p.severity === 'non-blocking');
245
+ // The reviewer's own label wins over the judge's reading of it: a judge that calls a blocker a should-fix would demote it silently.
246
+ const read = verdict.prior_points.map(p => labelled(p, prior.labels ?? []));
247
+ const points = read.map(r => r.point);
248
+ const accepted = points.filter(p => p.severity === 'non-blocking');
218
249
  // A should-fix is shown only when the judge could show it: a quote Rigour finds. One that cannot be checked is not a claim worth a person's time.
219
250
  const advise = (item) => {
220
251
  if (seen.has(item.id))
@@ -243,7 +274,7 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
243
274
  open.push(item);
244
275
  };
245
276
  const answerInReply = [];
246
- for (const p of verdict.prior_points) {
277
+ for (const p of points) {
247
278
  if (p.resolved)
248
279
  continue;
249
280
  if (p.severity === 'non-blocking') {
@@ -270,7 +301,11 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
270
301
  continue;
271
302
  }
272
303
  // Still open only where the judge quotes the code that keeps it open: a point a later commit already fixed never blocks.
273
- add(item);
304
+ // A should-fix keeps the tier its reviewer gave it: shown with its quote, never a block, like a should-fix finding.
305
+ if (p.severity === 'should-fix')
306
+ advise(item);
307
+ else
308
+ add(item);
274
309
  }
275
310
  for (const r of verdict.redundant) {
276
311
  if (r.removed === false)
@@ -374,7 +409,7 @@ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS)
374
409
  }
375
410
  }
376
411
  }
377
- return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory) };
412
+ return { open: onePerRootCause(open), unverified, resolved, answerInReply, notes, advisory: onePerRootCause(advisory), labels: { served: prior.labels?.length ?? 0, taken: read.filter(r => r.took).length, disagreed: read.filter(r => r.disagreed).length } };
378
413
  }
379
414
  /** How alike two items' words must be to be the same point made in two places; and, on the same lines, to be one point said two ways. */
380
415
  const SAME_POINT = 0.6;
@@ -387,9 +422,14 @@ const SAME_LINES = 3;
387
422
  function onePerRootCause(items) {
388
423
  const kept = [];
389
424
  for (const item of items) {
390
- // The same class in the same words anywhere, or any two non-human items on the same lines that read alike (a rule break and the finding it caused).
425
+ // The same class in the same words anywhere, or any two non-human items on the same lines that read alike (a rule break and
426
+ // the finding it caused). A human's point owns its lines: a rule break or finding there in like words is that point found
427
+ // again and folds into it; the human's words stay. One in other words is a different problem and stays its own item, so
428
+ // fixing the human's point does not leave it for the next round. Two human points never merge.
391
429
  const nearby = (k) => !!k.file && k.file === item.file && k.line !== undefined && item.line !== undefined && Math.abs(k.line - item.line) <= SAME_LINES;
392
- const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind !== 'prior' && ((k.class === item.class && textSimilarity(k, item) >= SAME_POINT) || (nearby(k) && textSimilarity(k, item) >= SAME_PLACE)));
430
+ const same = item.kind === 'prior' ? undefined : kept.find(k => k.kind === 'prior'
431
+ ? nearby(k) && textSimilarity(k, item) >= SAME_PLACE
432
+ : (k.class === item.class && textSimilarity(k, item) >= SAME_POINT) || (nearby(k) && textSimilarity(k, item) >= SAME_PLACE));
393
433
  if (!same) {
394
434
  kept.push(item);
395
435
  continue;
@@ -84,6 +84,8 @@ export interface ReviewerResult {
84
84
  /** The latest human review it worked from (`<login>, <date> (<n> reviews)`). */
85
85
  previousReview?: string;
86
86
  pr?: number;
87
+ /** The pull request's title, when one was read. */
88
+ prTitle?: string;
87
89
  }
88
90
  export interface ModeRecord {
89
91
  asked: 'single' | 'cross' | 'full' | 'panel';
@@ -36,6 +36,7 @@ import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './
36
36
  import { buildRecord } from './reviewer/record.js';
37
37
  import { account, attachServedRules, changedLinesOf, checkoutSearch, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
38
38
  import { judgeUnset } from './reviewer/judge-env.js';
39
+ import { appendTaskEvent } from '../task/thread.js';
39
40
  export { defaultExec, githubEnv, githubToken, parseJsonArrays } from './reviewer/exec.js';
40
41
  export { itemLine } from './reviewer/verdict.js';
41
42
  /** Findings and no verdict stop a push; a skipped review does not, and is reported as owed. */
@@ -48,7 +49,16 @@ const MAX_DELTA_LINES = 400;
48
49
  /** The reviewer, and one anonymous usage event for what it did (only when the person opted in to telemetry). */
49
50
  export async function runReviewer(cwd, base, config, exec = defaultExec, progress = message => process.stderr.write(`${message}\n`), options = {}) {
50
51
  const result = await review(cwd, base, config, exec, progress, options);
51
- await trackUsage('reviewer_completed', reviewerUsage(result, options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review')));
52
+ const trigger = options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review');
53
+ await trackUsage('reviewer_completed', reviewerUsage(result, trigger));
54
+ // On the task's thread, unless it replays history: a backtest's worktree is not anyone's task.
55
+ if (trigger !== 'backtest' && result.outcome !== 'skipped')
56
+ appendTaskEvent(cwd, {
57
+ kind: 'review', trigger, outcome: result.outcome, blocking: result.items.length, should_fix: result.advisory.length,
58
+ ...(result.pr ? { pr: result.pr } : {}),
59
+ ...(result.prTitle ? { pr_title: result.prTitle } : {}),
60
+ ...(result.record ? { integrity: result.record.integrity, cost_usd: result.record.judges.reduce((sum, j) => sum + (j.cost_usd ?? 0), 0), judges: result.record.judges.map(j => j.reviewer) } : {}),
61
+ });
52
62
  return result;
53
63
  }
54
64
  async function review(cwd, base, config, exec, progress, options) {
@@ -107,7 +117,7 @@ async function review(cwd, base, config, exec, progress, options) {
107
117
  if (skip)
108
118
  return none('skipped', skip, { reviewers, pr: pr?.number });
109
119
  }
110
- let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [] };
120
+ let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [], labels: [] };
111
121
  if (gh && pr) {
112
122
  const read = await humanReviews(gh, pr, options.reviewsBefore);
113
123
  if (read.error)
@@ -187,12 +197,17 @@ async function review(cwd, base, config, exec, progress, options) {
187
197
  const openFile = store.openPath(verdictFile);
188
198
  const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
189
199
  const verify = checkoutVerifier(cwd);
190
- const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff) };
200
+ const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff), labels: reviews.labels };
191
201
  const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
192
202
  // The record of the review, written beside the verdict once and rebuilt from the same verdict on a cached read.
203
+ // What a judge reads besides the repository and Rigour's inputs, on this machine: on the record, so "the same judge" is a claim it can check.
204
+ const outsideOf = (reviewer) => {
205
+ const outside = ADAPTERS[reviewer]?.outsideRepo?.(os.homedir(), installed.get(reviewer)?.version);
206
+ return outside ? { outside_repo: outside } : {};
207
+ };
193
208
  const withRecord = (accounted, verdict, cached) => {
194
209
  const res = result(accounted, verdict, reviewers, scope, why, cached, reviews, pr, modeRecord, settings.dismissals);
195
- const judges = (verdict.reviewers ?? []).map(r => ({ reviewer: r.reviewer, ...(installed.get(r.reviewer)?.version ? { version: installed.get(r.reviewer).version } : {}), ...(modelFor(r.reviewer) ? { model: modelFor(r.reviewer) } : {}), ...(typeof r.cost_usd === 'number' ? { cost_usd: r.cost_usd } : {}), ...(r.trace?.turns ? { turns: r.trace.turns } : {}) }));
210
+ const judges = (verdict.reviewers ?? []).map(r => ({ reviewer: r.reviewer, ...(installed.get(r.reviewer)?.version ? { version: installed.get(r.reviewer).version } : {}), ...(modelFor(r.reviewer) ? { model: modelFor(r.reviewer) } : {}), ...(typeof r.cost_usd === 'number' ? { cost_usd: r.cost_usd } : {}), ...(r.trace?.turns ? { turns: r.trace.turns } : {}), ...outsideOf(r.reviewer) }));
196
211
  const recordPath = store.recordPath(verdictFile);
197
212
  const record = (cached && store.readJson(recordPath)) || buildRecord({ head, base: baseSha, scope, verdict, accounted, judges, lessonsServed: context.lessons, humanReviews: reviews.count });
198
213
  if (!cached || !fs.existsSync(recordPath))
@@ -212,7 +227,7 @@ async function review(cwd, base, config, exec, progress, options) {
212
227
  let inlineInputs = [];
213
228
  const runJudge = (name, prompt, model) => name === 'api'
214
229
  ? runApiJudge(prompt, { url: settings.api.url, model: settings.api.model, key: process.env[settings.api.key_env] ?? '', maxTurns: settings.api.max_turns, timeoutMs: settings.timeout_ms, cwd, roots: [cwd, work], inputs: inlineInputs, ...(settings.reasoning[name] ? { reasoning: settings.reasoning[name] } : {}), ...(options.fetch ? { fetchImpl: options.fetch } : {}) })
215
- : exec(installed.get(name).binary, ADAPTERS[name].args(prompt, model, { reasoning: settings.reasoning[name] }), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
230
+ : exec(installed.get(name).binary, ADAPTERS[name].args(prompt, model, { reasoning: settings.reasoning[name] }), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env), ...(ADAPTERS[name].env ? { env: ADAPTERS[name].env } : {}) });
216
231
  try {
217
232
  const file = (name, text) => {
218
233
  const target = path.join(work, name);
@@ -336,6 +351,9 @@ async function review(cwd, base, config, exec, progress, options) {
336
351
  verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
337
352
  }
338
353
  const accounted = decide(verdict, previousOpen, verify, prior, dismissals);
354
+ // Said every run where the reviews carry labels, so a matcher that takes none is visible rather than silent.
355
+ if (accounted.labels?.served)
356
+ progress(`Rigour reviewer: ${accounted.labels.taken} of ${verdict.prior_points.length} prior point(s) took the review's own severity label (${accounted.labels.served} labelled line(s)); the judge read ${accounted.labels.disagreed} otherwise`);
339
357
  store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
340
358
  store.writeJson(openFile, accounted.open);
341
359
  store.writeJson(store.decidedPath(verdictFile), accounted);
@@ -462,5 +480,6 @@ function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr,
462
480
  cached,
463
481
  ...(reviews.label ? { previousReview: reviews.label } : {}),
464
482
  ...(pr ? { pr: pr.number } : {}),
483
+ ...(pr?.title ? { prTitle: pr.title } : {}),
465
484
  };
466
485
  }
@@ -5,14 +5,26 @@ export interface RepoRule {
5
5
  text: string;
6
6
  /** Paths the rule names (files or directories). */
7
7
  paths: string[];
8
+ /** Where the rule applies: the folder of the nested rules file it came from (`services/billing/`); the whole repository when absent. */
9
+ scope?: string;
8
10
  /** Identifiers the rule names. */
9
11
  symbols: string[];
10
12
  /** Worded as a requirement: a break can block. Guidance otherwise: a break is shown. */
11
13
  requirement: boolean;
12
14
  }
15
+ /**
16
+ * Every rule file of the repository: the root ones, the AGENTS.md and CLAUDE.md files in folders below it (outside
17
+ * vendored folders), the rule directories, and every file a rule file imports with an `@path` line (relative to the
18
+ * importing file, inside the repository). A judge does not load these by itself; this is how the repository's rules
19
+ * reach it. A nested file's rules apply to its own folder only, and so do the rules of the files it imports.
20
+ */
13
21
  export declare function readRepoRules(cwd: string): RepoRule[];
14
22
  /** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
15
23
  export declare function splitRules(source: string, text: string): RepoRule[];
16
24
  export declare function rulesSection(rules: RepoRule[]): string;
17
- /** The rules that apply to a diff's changed files and added identifiers, the top `limit`. */
18
- export declare function rulesForDiff(cwd: string, diff: string, enabled?: boolean, limit?: number): RepoRule[];
25
+ /**
26
+ * The rules that apply to a diff's changed files and added identifiers, the top `limit`. `namedOnly`: only rules that
27
+ * name a path or identifier the change touches (a briefing has no code to judge applicability against, so a rule that
28
+ * merely shares words with a large change is noise there).
29
+ */
30
+ export declare function rulesForDiff(cwd: string, diff: string, enabled?: boolean, limit?: number, namedOnly?: boolean): RepoRule[];
@@ -8,6 +8,7 @@
8
8
  * is better served by the few rules that name a file or identifier the
9
9
  * change actually touches.
10
10
  */
11
+ import { spawnSync } from 'child_process';
11
12
  import fs from 'fs';
12
13
  import path from 'path';
13
14
  import crypto from 'crypto';
@@ -20,12 +21,55 @@ const MAX_RULES = 5;
20
21
  const CONTINUES = /^\**\s*(why|how to apply|example|examples|evidence|exception|exceptions|fix|note)\b\s*:?\**\s*:?/i;
21
22
  /** Worded as a requirement: the team said must, never, always, only, every, do not. A rule without these is guidance. */
22
23
  const REQUIREMENT = /\b(must|never|always|only|every|do not|don't|forbidden|required|non-negotiable)\b/i;
24
+ /** The most rule files read, imports included: a loop or a sprawling import tree stops here. */
25
+ const MAX_RULE_SOURCES = 50;
26
+ /** Folders of someone else's code a repository carries: a rules file in one is that project's, not this team's. */
27
+ const VENDORED = /(^|\/)(vendor|vendors|third_party|third-party|node_modules|bower_components|external|externals)\//;
28
+ /**
29
+ * Every rule file of the repository: the root ones, the AGENTS.md and CLAUDE.md files in folders below it (outside
30
+ * vendored folders), the rule directories, and every file a rule file imports with an `@path` line (relative to the
31
+ * importing file, inside the repository). A judge does not load these by itself; this is how the repository's rules
32
+ * reach it. A nested file's rules apply to its own folder only, and so do the rules of the files it imports.
33
+ */
23
34
  export function readRepoRules(cwd) {
24
- const files = [
25
- ...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))),
26
- ...RULE_DIRS.flatMap(dir => listRuleFiles(cwd, dir)),
35
+ const queue = [
36
+ ...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))).map(file => ({ file })),
37
+ ...nestedRuleFiles(cwd).map(file => ({ file, scope: `${path.posix.dirname(file)}/` })),
38
+ ...RULE_DIRS.flatMap(dir => listRuleFiles(cwd, dir)).map(file => ({ file })),
27
39
  ];
28
- return files.flatMap(file => splitRules(file, fs.readFileSync(path.join(cwd, file), 'utf8')));
40
+ const read = new Set();
41
+ const rules = [];
42
+ while (queue.length && read.size < MAX_RULE_SOURCES) {
43
+ const { file, scope } = queue.shift();
44
+ // The same file imported from two places is read once, with the scope it was first reached with: a root import first.
45
+ if (read.has(file))
46
+ continue;
47
+ read.add(file);
48
+ let text;
49
+ try {
50
+ text = fs.readFileSync(path.join(cwd, file), 'utf8');
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ rules.push(...splitRules(file, text).map(rule => (scope ? { ...rule, scope } : rule)));
56
+ for (const m of text.matchAll(/^@(\S+)\s*$/gm)) {
57
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(file), m[1].replace(/^\.\//, '')));
58
+ if (target.startsWith('..') || path.isAbsolute(m[1]) || m[1].startsWith('~') || !fs.existsSync(path.join(cwd, target)))
59
+ continue;
60
+ // Root imports go to the front, so a file both a root and a nested file import keeps the repository-wide scope.
61
+ if (scope)
62
+ queue.push({ file: target, scope });
63
+ else
64
+ queue.unshift({ file: target });
65
+ }
66
+ }
67
+ return rules;
68
+ }
69
+ /** AGENTS.md and CLAUDE.md in folders below the root, as git tracks them, outside vendored folders. */
70
+ function nestedRuleFiles(cwd) {
71
+ const listed = spawnSync('git', ['ls-files', '-z', '--', '*/AGENTS.md', '*/CLAUDE.md'], { cwd, encoding: 'utf8', timeout: 5000 });
72
+ return listed.status === 0 ? listed.stdout.split('\0').filter(f => f && !VENDORED.test(f)) : [];
29
73
  }
30
74
  /** One rule per top-level bullet or paragraph; headings and import lines are not rules. */
31
75
  export function splitRules(source, text) {
@@ -74,7 +118,9 @@ export function splitRules(source, text) {
74
118
  * not filtering: on a large change most rules share some words, so the judge decides applicability
75
119
  * rule by rule from the top `limit`.
76
120
  */
77
- function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
121
+ function rulesForChange(rules, files, symbols, limit = MAX_RULES, namedOnly = false) {
122
+ // A folder's own rules are served only when the change touches that folder: never checked, so never broken, elsewhere.
123
+ rules = rules.filter(rule => !rule.scope || files.some(f => f.startsWith(rule.scope)));
78
124
  const changeWords = new Set([...files.flatMap(f => f.split(/[/._-]+/)), ...symbols].flatMap(meaningfulWords));
79
125
  const scored = rules.map(rule => {
80
126
  const pathHits = rule.paths.filter(p => files.some(f => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`) || f.endsWith(`/${p}`))).length;
@@ -82,7 +128,7 @@ function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
82
128
  const shared = new Set(meaningfulWords(rule.text).filter(w => changeWords.has(w))).size;
83
129
  return { rule, named: 3 * pathHits + 2 * symbolHits, shared };
84
130
  });
85
- return scored.filter(s => s.named > 0 || s.shared >= 2).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
131
+ return scored.filter(s => s.named > 0 || (!namedOnly && s.shared >= 2)).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
86
132
  }
87
133
  export function rulesSection(rules) {
88
134
  if (rules.length === 0)
@@ -97,11 +143,15 @@ function listRuleFiles(cwd, dir) {
97
143
  return [];
98
144
  }
99
145
  }
100
- /** The rules that apply to a diff's changed files and added identifiers, the top `limit`. */
101
- export function rulesForDiff(cwd, diff, enabled = false, limit = MAX_RULES) {
146
+ /**
147
+ * The rules that apply to a diff's changed files and added identifiers, the top `limit`. `namedOnly`: only rules that
148
+ * name a path or identifier the change touches (a briefing has no code to judge applicability against, so a rule that
149
+ * merely shares words with a large change is noise there).
150
+ */
151
+ export function rulesForDiff(cwd, diff, enabled = false, limit = MAX_RULES, namedOnly = false) {
102
152
  if (!enabled)
103
153
  return [];
104
154
  const files = [...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)].map(m => m[1].trim());
105
155
  const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
106
- return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit);
156
+ return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit, namedOnly);
107
157
  }
@@ -0,0 +1,47 @@
1
+ /** Under the repository's git folder (the common one, shared by every worktree). */
2
+ export declare const THREADS_DIR = "rigour/threads";
3
+ export type TaskEventKind = 'edit-check' | 'stop-review' | 'push' | 'review' | 'brief';
4
+ export interface TaskEvent {
5
+ kind: TaskEventKind;
6
+ /** The agent session it happened in, when a hook knows it. */
7
+ session?: string;
8
+ /** The agent (claude, cursor, codex, ...) or `person`, when known. */
9
+ agent?: string;
10
+ /** The pull request, once a review of it ran. */
11
+ pr?: number;
12
+ /** What happened, in a few fields; each kind documents its own. */
13
+ [field: string]: unknown;
14
+ }
15
+ export interface ThreadEvent extends TaskEvent {
16
+ at: string;
17
+ task: string;
18
+ branch: string;
19
+ head?: string;
20
+ }
21
+ /**
22
+ * The task the checkout is working on: the ticket its branch names when one of the branch's own commit subjects names
23
+ * it too, else the branch; undefined on a detached head.
24
+ */
25
+ export declare function taskOf(cwd: string): {
26
+ key: string;
27
+ branch: string;
28
+ head?: string;
29
+ } | undefined;
30
+ /** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
31
+ export declare function appendTaskEvent(cwd: string, event: TaskEvent): ThreadEvent | undefined;
32
+ /**
33
+ * The thread for a key, oldest first: a ticket (`PROJ-123`, every branch whose events carry it, from their first
34
+ * event), a branch name, a pull request (`#42` or `42`: the branches a review of it ran on), or nothing for the
35
+ * checkout's own task. Matching is on the keys stored in the events, never on file names. Unreadable lines are skipped.
36
+ */
37
+ export declare function readThread(cwd: string, key?: string): {
38
+ task: string;
39
+ events: ThreadEvent[];
40
+ } | undefined;
41
+ /** The thread as a person reads it: who worked on it, what was caught and fixed, what blocked, then the events in order. */
42
+ export declare function threadText(thread: {
43
+ task: string;
44
+ events: ThreadEvent[];
45
+ }): string[];
46
+ /** Where the repository's threads are: its common git folder, so every worktree of it writes to the same threads. */
47
+ export declare function threadsDir(cwd: string): string | undefined;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The engineering task: one piece of work, whatever agents and people touch it, and its thread, everything that
3
+ * happened to it in order. Events are kept per branch, one file each (`<git dir>/rigour/threads/<branch>-<hash>.jsonl`,
4
+ * the hash of the exact branch name, so no two branches share a file). The task is the ticket the branch names when
5
+ * the branch's own commit subjects, or the title of its pull request as a review recorded it, write it as a ticket
6
+ * (`PROJ-123`): a version token in a branch name (`pin-node-22`, `utf-8`) is not a ticket. Otherwise the task is the
7
+ * branch. The task is worked out once per commit and kept: the edit hook runs on every edit. Reading a ticket gathers every branch that worked on it;
8
+ * a pull request joins when a review of it runs. Writers only append, and never fail the hook or command they run in.
9
+ * The files live in the repository's common git folder, shared by its worktrees; nothing enters the working tree.
10
+ *
11
+ * What a thread holds is what Rigour saw: hooked agent sessions (the edit check, the stop review), the push gate, and
12
+ * reviews. Work done where no hook ran (an agent without hooks, a person in an editor) shows only through the commits
13
+ * and reviews that follow it.
14
+ */
15
+ import { spawnSync } from 'child_process';
16
+ import { createHash } from 'crypto';
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ /** Under the repository's git folder (the common one, shared by every worktree). */
20
+ export const THREADS_DIR = 'rigour/threads';
21
+ /** A ticket key in a branch name: `feat/proj-123-thing` → `PROJ-123`. */
22
+ const TICKET = /(?:^|[^A-Za-z0-9])([A-Za-z][A-Za-z0-9]{1,9}-\d{1,7})(?=$|[^0-9])/;
23
+ /**
24
+ * The task the checkout is working on: the ticket its branch names when one of the branch's own commit subjects names
25
+ * it too, else the branch; undefined on a detached head.
26
+ */
27
+ export function taskOf(cwd) {
28
+ const task = taskIn(cwd);
29
+ return task ? { key: task.key, branch: task.branch, ...(task.head ? { head: task.head } : {}) } : undefined;
30
+ }
31
+ /** The task and the folder its thread is in, with one lookup of each: the edit hook pays for this on every edit. */
32
+ function taskIn(cwd) {
33
+ const branch = git(cwd, ['symbolic-ref', '--short', '-q', 'HEAD']); // a branch with no commit yet has a task too
34
+ if (!branch)
35
+ return undefined;
36
+ const head = git(cwd, ['rev-parse', '-q', '--verify', 'HEAD']);
37
+ const dir = threadsDir(cwd);
38
+ const cache = dir ? path.join(dir, '..', 'task-cache.json') : undefined;
39
+ const cached = cache ? readJson(cache)[branch] : undefined;
40
+ if (cached && cached.head === (head ?? '') && typeof cached.key === 'string')
41
+ return { key: cached.key, branch, ...(head ? { head } : {}), ...(dir ? { dir } : {}) };
42
+ const ticket = TICKET.exec(branch)?.[1]?.toUpperCase();
43
+ // Written as a ticket (upper case, as trackers print it): `node-22` in a subject is a version, not a ticket.
44
+ const written = ticket ? new RegExp(`(^|[^A-Za-z0-9])${ticket.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![0-9])`) : undefined;
45
+ const titles = dir ? readEvents(path.join(dir, branchFile(branch))).map(e => e.pr_title).filter((t) => typeof t === 'string') : [];
46
+ const confirmed = !!written && [...titles, ...branchSubjects(cwd)].some(text => written.test(text));
47
+ const key = confirmed ? ticket : `branch:${branch}`;
48
+ if (cache)
49
+ writeCache(cache, branch, { head: head ?? '', key });
50
+ return { key, branch, ...(head ? { head } : {}), ...(dir ? { dir } : {}) };
51
+ }
52
+ function readJson(file) {
53
+ try {
54
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ return parsed && typeof parsed === 'object' ? parsed : {};
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ /** The task worked out for a branch at a commit; `undefined` forgets it (a new pull request title may confirm a ticket). */
62
+ function writeCache(file, branch, entry) {
63
+ try {
64
+ const all = readJson(file);
65
+ if (entry)
66
+ all[branch] = entry;
67
+ else
68
+ delete all[branch];
69
+ fs.mkdirSync(path.dirname(file), { recursive: true });
70
+ fs.writeFileSync(file, JSON.stringify(all));
71
+ }
72
+ catch {
73
+ // a cache that cannot be written is worked out again next time
74
+ }
75
+ }
76
+ /** The subjects of the branch's own commits: since it left the main branch, or its last 50 when no main branch is found. */
77
+ function branchSubjects(cwd) {
78
+ const main = ['origin/main', 'main', 'origin/master', 'master'].find(ref => git(cwd, ['rev-parse', '--verify', '-q', ref]) !== undefined);
79
+ const base = main ? git(cwd, ['merge-base', 'HEAD', main]) : undefined;
80
+ const range = base && base !== git(cwd, ['rev-parse', 'HEAD']) ? [`${base}..HEAD`] : ['-50', 'HEAD'];
81
+ return (git(cwd, ['log', '--format=%s', ...range]) ?? '').split('\n').filter(Boolean);
82
+ }
83
+ /** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
84
+ export function appendTaskEvent(cwd, event) {
85
+ try {
86
+ const task = taskIn(cwd);
87
+ if (!task?.dir)
88
+ return undefined;
89
+ const dir = task.dir;
90
+ const line = { at: new Date().toISOString(), task: task.key, branch: task.branch, ...(task.head ? { head: task.head } : {}), ...event };
91
+ fs.mkdirSync(dir, { recursive: true });
92
+ fs.appendFileSync(path.join(dir, branchFile(task.branch)), JSON.stringify(line) + '\n');
93
+ // A pull request title can confirm the branch's ticket: work the task out again next time.
94
+ if (typeof event.pr_title === 'string' && task.key.startsWith('branch:'))
95
+ writeCache(path.join(dir, '..', 'task-cache.json'), task.branch, undefined);
96
+ return line;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ /**
103
+ * The thread for a key, oldest first: a ticket (`PROJ-123`, every branch whose events carry it, from their first
104
+ * event), a branch name, a pull request (`#42` or `42`: the branches a review of it ran on), or nothing for the
105
+ * checkout's own task. Matching is on the keys stored in the events, never on file names. Unreadable lines are skipped.
106
+ */
107
+ export function readThread(cwd, key) {
108
+ const dir = threadsDir(cwd);
109
+ if (!dir)
110
+ return undefined;
111
+ const wanted = key?.trim();
112
+ if (!wanted) {
113
+ const own = taskOf(cwd);
114
+ if (!own)
115
+ return undefined;
116
+ return own.key.startsWith('branch:') ? { task: own.key, events: readEvents(path.join(dir, branchFile(own.branch))).filter(e => e.branch === own.branch) } : byTask(dir, own.key) ?? { task: own.key, events: [] };
117
+ }
118
+ const pr = /^#?(\d+)$/.exec(wanted)?.[1];
119
+ if (pr) {
120
+ const found = gather(dir, `#${pr}`, events => events.some(e => e.pr === Number(pr)));
121
+ return found ? { task: found.events[found.events.length - 1].task, events: found.events } : undefined;
122
+ }
123
+ const branch = wanted.replace(/^branch:/, '');
124
+ const own = readEvents(path.join(dir, branchFile(branch))).filter(e => e.branch === branch);
125
+ if (own.length)
126
+ return { task: own[own.length - 1].task, events: own };
127
+ return byTask(dir, wanted.toUpperCase());
128
+ }
129
+ /** Every branch whose events name the task, whole, as one thread. */
130
+ function byTask(dir, task) {
131
+ return gather(dir, task, events => events.some(e => e.task === task));
132
+ }
133
+ function gather(dir, task, wanted) {
134
+ const events = listThreads(dir).map(file => readEvents(path.join(dir, file))).filter(wanted).flat().sort(byTime);
135
+ return events.length ? { task, events } : undefined;
136
+ }
137
+ /** The thread as a person reads it: who worked on it, what was caught and fixed, what blocked, then the events in order. */
138
+ export function threadText(thread) {
139
+ const { task, events } = thread;
140
+ if (events.length === 0)
141
+ return [`${task}: nothing recorded yet`];
142
+ const sessions = new Set(events.map(e => e.session).filter(Boolean));
143
+ const agents = [...new Set(events.map(e => e.agent).filter((a) => typeof a === 'string'))];
144
+ const prs = [...new Set(events.map(e => e.pr).filter((p) => typeof p === 'number'))];
145
+ const branches = [...new Set(events.map(e => e.branch))];
146
+ const checks = events.filter(e => e.kind === 'edit-check');
147
+ const caught = checks.reduce((n, e) => n + num(e.findings), 0);
148
+ const lines = [
149
+ `${task} · ${branches.join(', ')}${prs.length ? ` · PR ${prs.map(p => `#${p}`).join(', ')}` : ''}`,
150
+ `${events[0].at} → ${events[events.length - 1].at} · ${sessions.size} agent session(s)${agents.length ? ` (${agents.join(', ')})` : ''}`,
151
+ `edit checks: ${checks.length}, findings caught while writing: ${caught}${caught ? `, files clean again after a finding: ${cleanedAfter(checks)}` : ''}`,
152
+ ];
153
+ const stops = events.filter(e => e.kind === 'stop-review');
154
+ if (stops.length)
155
+ lines.push(`stop reviews: ${stops.length}, blocked: ${stops.filter(e => e.blocked).length}`);
156
+ const pushes = events.filter(e => e.kind === 'push');
157
+ if (pushes.length)
158
+ lines.push(`pushes: ${pushes.length}, blocked: ${pushes.filter(e => e.passed === false).length}`);
159
+ const reviews = events.filter(e => e.kind === 'review');
160
+ if (reviews.length)
161
+ lines.push(`reviews: ${reviews.length}, last: ${String(reviews[reviews.length - 1].outcome)} with ${num(reviews[reviews.length - 1].blocking)} blocking${typeof reviews[reviews.length - 1].integrity === 'string' ? ` (record ${String(reviews[reviews.length - 1].integrity).slice(0, 16)})` : ''}`);
162
+ lines.push('');
163
+ for (const e of events)
164
+ lines.push(` ${e.at.slice(0, 19).replace('T', ' ')} ${e.kind.padEnd(11)} ${describe(e)}`);
165
+ return lines;
166
+ }
167
+ /** Files that had a finding at one edit check and none at a later one: caught while writing, fixed before push. */
168
+ function cleanedAfter(checks) {
169
+ const flagged = new Set();
170
+ const cleaned = new Set();
171
+ for (const e of checks) {
172
+ const files = Array.isArray(e.files) ? e.files.filter((f) => typeof f === 'string') : [];
173
+ if (num(e.findings) > 0)
174
+ files.forEach(f => flagged.add(f));
175
+ else
176
+ files.filter(f => flagged.has(f)).forEach(f => cleaned.add(f));
177
+ }
178
+ return cleaned.size;
179
+ }
180
+ function describe(e) {
181
+ const who = [e.agent, e.session ? `session ${String(e.session).slice(0, 8)}` : undefined].filter(Boolean).join(', ');
182
+ const files = Array.isArray(e.files) ? `${e.files.length} file(s)` : '';
183
+ const what = e.kind === 'edit-check' ? `${files}, ${num(e.findings)} finding(s)`
184
+ : e.kind === 'stop-review' ? (e.blocked ? `blocked: ${num(e.blocking)} to fix` : 'passed')
185
+ : e.kind === 'push' ? (e.passed === false ? `blocked: ${num(e.failed)} check(s) failed` : 'passed')
186
+ : e.kind === 'review' ? `${String(e.outcome)}, ${num(e.blocking)} blocking, ${num(e.should_fix)} should-fix${e.pr ? ` on #${e.pr}` : ''}`
187
+ : e.kind === 'brief' ? `${num(e.items)} item(s) briefed` : '';
188
+ return [what, who ? `(${who})` : '', e.head ? `@${String(e.head).slice(0, 9)}` : ''].filter(Boolean).join(' ');
189
+ }
190
+ function readEvents(file) {
191
+ let text;
192
+ try {
193
+ text = fs.readFileSync(file, 'utf8');
194
+ }
195
+ catch {
196
+ return [];
197
+ }
198
+ return text.split('\n').filter(Boolean).flatMap(line => {
199
+ try {
200
+ const e = JSON.parse(line);
201
+ return e && typeof e.at === 'string' && typeof e.task === 'string' && typeof e.kind === 'string' ? [e] : [];
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }).sort(byTime);
207
+ }
208
+ /** Where the repository's threads are: its common git folder, so every worktree of it writes to the same threads. */
209
+ export function threadsDir(cwd) {
210
+ const common = git(cwd, ['rev-parse', '--git-common-dir']);
211
+ return common ? path.join(path.resolve(cwd, common), THREADS_DIR) : undefined;
212
+ }
213
+ function listThreads(dir) {
214
+ try {
215
+ return fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
216
+ }
217
+ catch {
218
+ return [];
219
+ }
220
+ }
221
+ /** A branch's thread file: a readable form of its name, and a hash of the exact name, so `a/b` and `a_b` never share one. */
222
+ function branchFile(branch) {
223
+ return `${branch.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80)}-${createHash('sha1').update(branch).digest('hex').slice(0, 8)}.jsonl`;
224
+ }
225
+ function byTime(a, b) {
226
+ return a.at < b.at ? -1 : a.at > b.at ? 1 : 0;
227
+ }
228
+ function num(v) {
229
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
230
+ }
231
+ function git(cwd, args) {
232
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
233
+ return result.status === 0 ? result.stdout.trim() : undefined;
234
+ }
@@ -275,6 +275,10 @@ export const UNIVERSAL_CONFIG = {
275
275
  output: {
276
276
  report_path: 'rigour-report.json',
277
277
  },
278
+ brief: {
279
+ enabled: true,
280
+ max_items: 10,
281
+ },
278
282
  review: {
279
283
  include_heuristics: false,
280
284
  show_preexisting: false,