karajan-code 4.70.3 → 5.0.0

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 (388) hide show
  1. package/README.md +2 -2
  2. package/package.json +6 -14
  3. package/packages/hu-board/package.json +1 -1
  4. package/packages/hu-board/public/app.js +4 -18
  5. package/packages/hu-board/public/index.html +5 -2
  6. package/packages/hu-board/public/styles.css +26 -0
  7. package/packages/hu-board/public/utils/api.js +0 -74
  8. package/packages/hu-board/public/utils/board-tour.js +62 -0
  9. package/packages/hu-board/public/utils/board-view.js +49 -144
  10. package/packages/hu-board/public/utils/command-launcher.js +8 -70
  11. package/packages/hu-board/public/utils/formatters.js +10 -0
  12. package/packages/hu-board/public/utils/governance-view.js +13 -1
  13. package/packages/hu-board/public/utils/hu-actions.js +17 -68
  14. package/packages/hu-board/public/utils/init-listeners.js +7 -8
  15. package/packages/hu-board/public/utils/log-panel.js +3 -16
  16. package/packages/hu-board/public/utils/maggle-mode.js +2 -12
  17. package/packages/hu-board/public/utils/modals.js +7 -0
  18. package/packages/hu-board/public/utils/project-picker-view.js +1 -1
  19. package/packages/hu-board/public/utils/server-push.js +3 -22
  20. package/packages/hu-board/public/utils/shared-board-notice.js +33 -0
  21. package/packages/hu-board/public/utils/story-detail-view.js +5 -19
  22. package/packages/hu-board/src/cleanup-zombies.js +0 -1
  23. package/packages/hu-board/src/command-runner.js +36 -25
  24. package/packages/hu-board/src/db-connection.js +282 -0
  25. package/packages/hu-board/src/db-paths.js +113 -0
  26. package/packages/hu-board/src/db-queries.js +188 -0
  27. package/packages/hu-board/src/db-tombstones.js +51 -0
  28. package/packages/hu-board/src/db.js +28 -619
  29. package/packages/hu-board/src/ephemeral-cleaner.js +2 -3
  30. package/packages/hu-board/src/hu-zombie-reaper.js +5 -1
  31. package/packages/hu-board/src/plan-mutations.js +3 -153
  32. package/packages/hu-board/src/plans-service.js +55 -0
  33. package/packages/hu-board/src/routes/api/commands.js +122 -0
  34. package/packages/hu-board/src/routes/api/plans.js +55 -0
  35. package/packages/hu-board/src/routes/api/projects.js +267 -0
  36. package/packages/hu-board/src/routes/api/prompts.js +135 -0
  37. package/packages/hu-board/src/routes/api/sessions.js +95 -0
  38. package/packages/hu-board/src/routes/api/stories.js +183 -0
  39. package/packages/hu-board/src/routes/api/system.js +241 -0
  40. package/packages/hu-board/src/routes/api/tombstones.js +38 -0
  41. package/packages/hu-board/src/routes/api.js +24 -1371
  42. package/packages/hu-board/src/routes/governance.js +23 -1
  43. package/packages/hu-board/src/routes/lanes.js +40 -0
  44. package/packages/hu-board/src/routes/rag.js +4 -3
  45. package/packages/hu-board/src/server.js +20 -34
  46. package/packages/hu-board/src/sync-files.js +318 -0
  47. package/packages/hu-board/src/sync-project.js +99 -0
  48. package/packages/hu-board/src/sync.js +22 -566
  49. package/packages/hu-board/src/zombie-reaper.js +1 -1
  50. package/scripts/dual-publish.mjs +19 -3
  51. package/scripts/postinstall.js +15 -2
  52. package/scripts/verify-pack-mcp.mjs +11 -7
  53. package/scripts/verify-pack.mjs +4 -4
  54. package/src/audit/audit-history.js +3 -2
  55. package/src/audit/dead-exports.js +68 -59
  56. package/src/audit/deterministic-summary.js +33 -13
  57. package/src/audit/npm-signatures.js +41 -0
  58. package/src/audit/sbom.js +35 -0
  59. package/src/checks/agent-token.js +112 -0
  60. package/src/checks/ai-surface.js +9 -7
  61. package/src/checks/config-files.js +8 -7
  62. package/src/checks/dir-setup.js +34 -1
  63. package/src/checks/native-build.js +21 -39
  64. package/src/checks/runner.js +128 -142
  65. package/src/cli/advanced-commands.js +6 -6
  66. package/src/cli/register-meta.js +42 -82
  67. package/src/cli/register-pipeline.js +8 -140
  68. package/src/cli/register-plan.js +9 -100
  69. package/src/cli/retired-commands.js +49 -0
  70. package/src/cli.js +18 -5
  71. package/src/commands/agent-run.js +40 -26
  72. package/src/commands/audit.js +127 -124
  73. package/src/commands/board.js +5 -9
  74. package/src/commands/check.js +29 -16
  75. package/src/commands/doctor.js +2 -0
  76. package/src/commands/env.js +176 -148
  77. package/src/commands/go.js +76 -55
  78. package/src/commands/harden.js +105 -86
  79. package/src/commands/hu-start.js +52 -0
  80. package/src/commands/hu.js +288 -221
  81. package/src/commands/identity.js +111 -78
  82. package/src/commands/init-atril.js +47 -0
  83. package/src/commands/init.js +11 -40
  84. package/src/commands/install-tools.js +70 -85
  85. package/src/commands/main-guard.js +78 -0
  86. package/src/commands/policy.js +226 -184
  87. package/src/commands/qmd.js +27 -21
  88. package/src/commands/rag.js +75 -48
  89. package/src/commands/review-gate.js +422 -339
  90. package/src/commands/start.js +4 -5
  91. package/src/commands/steward.js +96 -47
  92. package/src/commands/tournament.js +46 -37
  93. package/src/commands/worktree.js +94 -25
  94. package/src/config/loader.js +34 -17
  95. package/src/config/overrides.js +2 -1
  96. package/src/config/secrets.js +36 -0
  97. package/src/config.js +1 -0
  98. package/src/environment/adr.js +16 -2
  99. package/src/environment/board-access.js +11 -9
  100. package/src/git/karajan-label.js +20 -8
  101. package/src/guards/main-guard.js +54 -0
  102. package/src/harden/branch-protection.js +36 -3
  103. package/src/harden/hook-templates.js +16 -1
  104. package/src/harden/sentinel/posttooluse.mjs +18 -6
  105. package/src/harden/sentinel/pretooluse-sentinel.mjs +81 -35
  106. package/src/harden/sentinel/sentinel-lib.mjs +149 -23
  107. package/src/harden/sentinel/sentinel-shell.mjs +64 -0
  108. package/src/harden/sentinel/sessionstart.mjs +3 -2
  109. package/src/harden/sentinel/stop.mjs +5 -4
  110. package/src/harden/workflow-engine.js +12 -1
  111. package/src/harden/workflow-templates.js +1 -1
  112. package/src/hu/board-lookup.js +29 -0
  113. package/src/hu/gh-identity.js +23 -0
  114. package/src/hu/github-board.js +4 -2
  115. package/src/hu/lane-prune.js +15 -7
  116. package/src/hu/lane-setup.js +65 -0
  117. package/src/hu/lanes.js +66 -0
  118. package/src/hu/team-board-mirror.js +3 -2
  119. package/src/infrastructure/command-runner.js +1 -1
  120. package/src/infrastructure/file-system-service.js +1 -1
  121. package/src/mcp/handlers/direct-handlers.js +2 -292
  122. package/src/mcp/handlers/hu-handlers.js +44 -55
  123. package/src/mcp/handlers/management-handlers.js +2 -42
  124. package/src/mcp/server-handlers.js +16 -22
  125. package/src/mcp/tools.js +5 -200
  126. package/src/plan/lane-plans.js +54 -0
  127. package/src/plan/plan-store.js +7 -4
  128. package/src/policy/add.js +26 -12
  129. package/src/policy/adr-number-check.js +31 -0
  130. package/src/prompts/audit.js +299 -339
  131. package/src/prompts/card-context.js +10 -5
  132. package/src/prompts/reviewer.js +5 -2
  133. package/src/rag/auto-update.js +56 -43
  134. package/src/rag/bootstrap-index.js +28 -54
  135. package/src/rag/migrate.js +1 -1
  136. package/src/rag/onnx-fallback.js +2 -2
  137. package/src/rag/pid-lease.js +70 -0
  138. package/src/rag/project-store.js +2 -12
  139. package/src/review/card-first.js +57 -46
  140. package/src/review/dual-review.js +5 -4
  141. package/src/review/gate-gitignore.js +23 -4
  142. package/src/review/gate-hook.js +47 -0
  143. package/src/review/one-shot-review.js +51 -37
  144. package/src/review/publish-pr.js +18 -3
  145. package/src/review/repo-docs.js +73 -0
  146. package/src/review/repo-stack.js +59 -0
  147. package/src/review/reviewer-fallback.js +34 -3
  148. package/src/review/seal-commit.js +29 -0
  149. package/src/review/sonar-pregate.js +59 -44
  150. package/src/review/third-party-review.js +90 -16
  151. package/src/review/third-party-size.js +43 -0
  152. package/src/review/third-party-skills.js +54 -0
  153. package/src/review/verify-pr.js +2 -26
  154. package/src/roles/audit-role.js +106 -125
  155. package/src/skills/atril-skills.js +5 -4
  156. package/src/sonar/scanner.js +116 -101
  157. package/src/steward/invariants.js +5 -4
  158. package/src/steward/phantom-sweep.js +66 -0
  159. package/src/tournament/judge.js +60 -53
  160. package/src/utils/agent-home.js +33 -0
  161. package/src/utils/gh-account.js +37 -0
  162. package/src/utils/git.js +0 -13
  163. package/src/utils/home-migration.js +7 -1
  164. package/src/utils/main-checkout.js +53 -0
  165. package/src/utils/qmd-collection.js +3 -1
  166. package/src/utils/welcome.js +3 -3
  167. package/templates/{skills → atril/commands}/kj-architect.md +5 -0
  168. package/templates/{skills → atril/commands}/kj-audit.md +5 -0
  169. package/templates/{skills → atril/commands}/kj-board.md +5 -0
  170. package/templates/{skills → atril/commands}/kj-code.md +5 -0
  171. package/templates/{skills → atril/commands}/kj-csv-transform.md +5 -0
  172. package/templates/{skills → atril/commands}/kj-data-report.md +5 -0
  173. package/templates/{skills → atril/commands}/kj-discover.md +5 -0
  174. package/templates/{skills → atril/commands}/kj-rag-query.md +5 -0
  175. package/templates/{skills → atril/commands}/kj-review.md +5 -0
  176. package/templates/{skills → atril/commands}/kj-security.md +5 -0
  177. package/templates/{skills → atril/commands}/kj-sonar.md +5 -0
  178. package/templates/{skills → atril/commands}/kj-sql-analysis.md +5 -0
  179. package/templates/{skills → atril/commands}/kj-test.md +5 -0
  180. package/packages/hu-board/public/utils/run-launcher.js +0 -169
  181. package/packages/hu-board/src/run-tracker.js +0 -121
  182. package/src/activity-log.js +0 -59
  183. package/src/agents/host-agent.js +0 -38
  184. package/src/agents/model-registry.js +0 -142
  185. package/src/audit/plan-adherence.js +0 -224
  186. package/src/autonomy/arbiter-role.js +0 -71
  187. package/src/autonomy/arbiter.js +0 -22
  188. package/src/autonomy/decider.js +0 -18
  189. package/src/autonomy/decision-resolver.js +0 -58
  190. package/src/autonomy/outcome-report.js +0 -55
  191. package/src/autonomy/stage-ask.js +0 -30
  192. package/src/brain/decision-tracker.js +0 -125
  193. package/src/brain/decisor.js +0 -243
  194. package/src/brain/solomon-consult.js +0 -171
  195. package/src/brain/standby-scheduler.js +0 -8
  196. package/src/budget/comparison.js +0 -96
  197. package/src/canvas/parse-md.js +0 -238
  198. package/src/canvas/schema.js +0 -101
  199. package/src/canvas/to-flat.js +0 -85
  200. package/src/canvas/validate.js +0 -163
  201. package/src/ci/dispatch.js +0 -99
  202. package/src/ci/index.js +0 -3
  203. package/src/ci/pr-diff.js +0 -26
  204. package/src/cli/register-standby.js +0 -26
  205. package/src/commands/architect.js +0 -111
  206. package/src/commands/autorun.js +0 -49
  207. package/src/commands/researcher.js +0 -54
  208. package/src/commands/resume.js +0 -72
  209. package/src/commands/review.js +0 -89
  210. package/src/commands/run.js +0 -185
  211. package/src/commands/standby.js +0 -117
  212. package/src/commands/status.js +0 -36
  213. package/src/commands/triage.js +0 -78
  214. package/src/commands/undo.js +0 -62
  215. package/src/domains/domain-loader.js +0 -245
  216. package/src/domains/domain-registry.js +0 -140
  217. package/src/domains/domain-synthesizer.js +0 -129
  218. package/src/git/automation.js +0 -433
  219. package/src/git/coder-commit-guard.js +0 -40
  220. package/src/git/hu-automation.js +0 -243
  221. package/src/git/hu-snapshot.js +0 -10
  222. package/src/git/symlink-targets.js +0 -57
  223. package/src/golden/asserter.js +0 -92
  224. package/src/golden/loader.js +0 -52
  225. package/src/golden/runner.js +0 -92
  226. package/src/golden/schema.js +0 -41
  227. package/src/guards/duplicate-members.js +0 -86
  228. package/src/guards/intent-guard.js +0 -161
  229. package/src/guards/perf-guard.js +0 -126
  230. package/src/guards/policy-guard.js +0 -90
  231. package/src/guards/policy-resolver.js +0 -85
  232. package/src/guards/secret-redactor.js +0 -71
  233. package/src/hu/acceptance-runner.js +0 -138
  234. package/src/hu/lazy-planner.js +0 -109
  235. package/src/hu/parallel-executor.js +0 -94
  236. package/src/hu/routing-metrics.js +0 -17
  237. package/src/hu/split-validator.js +0 -143
  238. package/src/hu/splitting-detector.js +0 -158
  239. package/src/hu/splitting-generator.js +0 -175
  240. package/src/hu/store.js +0 -394
  241. package/src/infrastructure/mocks.js +0 -182
  242. package/src/mcp/handlers/run-handler.js +0 -318
  243. package/src/mcp/sovereignty-guard.js +0 -113
  244. package/src/mcp/tool-arg-normalizers.js +0 -16
  245. package/src/mutate/reviewer-signal.js +0 -76
  246. package/src/orchestrator/agent-fallback.js +0 -81
  247. package/src/orchestrator/brain-coordinator.js +0 -186
  248. package/src/orchestrator/ci-integration.js +0 -139
  249. package/src/orchestrator/config-init.js +0 -426
  250. package/src/orchestrator/direct-actions.js +0 -217
  251. package/src/orchestrator/drivers/error-recovery.js +0 -287
  252. package/src/orchestrator/drivers/init-context.js +0 -238
  253. package/src/orchestrator/drivers/iteration-loop.js +0 -382
  254. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +0 -69
  255. package/src/orchestrator/drivers/iteration-phases/guards.js +0 -91
  256. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +0 -29
  257. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +0 -125
  258. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +0 -62
  259. package/src/orchestrator/drivers/post-loop.js +0 -404
  260. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +0 -137
  261. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +0 -47
  262. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +0 -84
  263. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +0 -393
  264. package/src/orchestrator/drivers/pre-loop.js +0 -524
  265. package/src/orchestrator/drivers/run-hu-batch.js +0 -468
  266. package/src/orchestrator/feedback-enrichment.js +0 -140
  267. package/src/orchestrator/feedback-queue.js +0 -151
  268. package/src/orchestrator/flow-control.js +0 -170
  269. package/src/orchestrator/flow-runner.js +0 -365
  270. package/src/orchestrator/fs-leak-detector.js +0 -228
  271. package/src/orchestrator/hu-scheduler.js +0 -114
  272. package/src/orchestrator/hu-sub-pipeline.js +0 -642
  273. package/src/orchestrator/iteration-gate.js +0 -73
  274. package/src/orchestrator/iteration-stages.js +0 -9
  275. package/src/orchestrator/parallel-limiter.js +0 -74
  276. package/src/orchestrator/pipeline-context.js +0 -72
  277. package/src/orchestrator/post-loop-stages.js +0 -468
  278. package/src/orchestrator/pre-loop-stages.js +0 -11
  279. package/src/orchestrator/preflight-checks.js +0 -487
  280. package/src/orchestrator/repair/repair-detector.js +0 -117
  281. package/src/orchestrator/repair/repair-runner.js +0 -142
  282. package/src/orchestrator/reviewer-fallback.js +0 -45
  283. package/src/orchestrator/role-output-compressor.js +0 -189
  284. package/src/orchestrator/session-journal.js +0 -308
  285. package/src/orchestrator/solomon-escalation.js +0 -331
  286. package/src/orchestrator/solomon-rules.js +0 -212
  287. package/src/orchestrator/stages/acceptance-coverage-stage.js +0 -47
  288. package/src/orchestrator/stages/architect-stage.js +0 -172
  289. package/src/orchestrator/stages/coder-stage.js +0 -506
  290. package/src/orchestrator/stages/domain-curator-stage.js +0 -82
  291. package/src/orchestrator/stages/format-stage.js +0 -61
  292. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +0 -127
  293. package/src/orchestrator/stages/hu-reviewer-stage.js +0 -419
  294. package/src/orchestrator/stages/perf-stage.js +0 -140
  295. package/src/orchestrator/stages/planner-stage.js +0 -85
  296. package/src/orchestrator/stages/rag-context-stage.js +0 -80
  297. package/src/orchestrator/stages/rag-preload-decisor.js +0 -58
  298. package/src/orchestrator/stages/research-stage.js +0 -69
  299. package/src/orchestrator/stages/reviewer-stage.js +0 -431
  300. package/src/orchestrator/stages/sonar-stage.js +0 -439
  301. package/src/orchestrator/stages/stage-classes.js +0 -84
  302. package/src/orchestrator/stages/stage-executor.js +0 -155
  303. package/src/orchestrator/stages/suite-green-stage.js +0 -62
  304. package/src/orchestrator/stages/tdd-discipline-stage.js +0 -56
  305. package/src/orchestrator/stages/tool-judge-stage.js +0 -53
  306. package/src/orchestrator/stages/triage-stage.js +0 -172
  307. package/src/orchestrator/standby.js +0 -71
  308. package/src/orchestrator/verification-gate.js +0 -183
  309. package/src/orchestrator.js +0 -22
  310. package/src/plan/adr-generator.js +0 -170
  311. package/src/plan/adr-loader.js +0 -60
  312. package/src/plan/plan-executor.js +0 -100
  313. package/src/plan/plan-validation.js +0 -4
  314. package/src/prompts/acceptance-coverage.js +0 -32
  315. package/src/prompts/arbiter.js +0 -38
  316. package/src/prompts/architect.js +0 -103
  317. package/src/prompts/hu-reviewer.js +0 -244
  318. package/src/prompts/tool-correctness-judge.js +0 -31
  319. package/src/prompts/triage.js +0 -57
  320. package/src/rag/embedder.js +0 -2
  321. package/src/rag/embedders/_cloud-base.js +0 -2
  322. package/src/rag/embedders/cohere.js +0 -2
  323. package/src/rag/embedders/mistral.js +0 -2
  324. package/src/rag/embedders/onnx.js +0 -2
  325. package/src/rag/embedders/openai.js +0 -2
  326. package/src/rag/embedders/voyage.js +0 -2
  327. package/src/rag/rerank.js +0 -4
  328. package/src/rag/where-parser.js +0 -4
  329. package/src/repeat-detector.js +0 -88
  330. package/src/review/diff-generator.js +0 -112
  331. package/src/review/git-stash-helper.js +0 -104
  332. package/src/review/schema.js +0 -31
  333. package/src/review/scope-filter.js +0 -152
  334. package/src/review/snapshot-diff.js +0 -122
  335. package/src/review/tdd-discipline.js +0 -90
  336. package/src/review/tdd-policy.js +0 -69
  337. package/src/roles/acceptance-coverage-role.js +0 -78
  338. package/src/roles/architect-role.js +0 -77
  339. package/src/roles/coder-role.js +0 -113
  340. package/src/roles/commiter-role.js +0 -113
  341. package/src/roles/domain-curator-role.js +0 -134
  342. package/src/roles/hu-reviewer-role.js +0 -64
  343. package/src/roles/impeccable-role.js +0 -85
  344. package/src/roles/index.js +0 -18
  345. package/src/roles/karajan-brain-role.js +0 -134
  346. package/src/roles/perf-role.js +0 -122
  347. package/src/roles/planner-role.js +0 -151
  348. package/src/roles/refactorer-role.js +0 -30
  349. package/src/roles/repairer-role.js +0 -181
  350. package/src/roles/researcher-role.js +0 -70
  351. package/src/roles/reviewer-role.js +0 -115
  352. package/src/roles/security-role.js +0 -73
  353. package/src/roles/solomon-role.js +0 -271
  354. package/src/roles/sonar-role.js +0 -72
  355. package/src/roles/tester-role.js +0 -235
  356. package/src/roles/tool-correctness-judge-role.js +0 -78
  357. package/src/roles/triage-role.js +0 -99
  358. package/src/session/journal/decisions-writer.js +0 -200
  359. package/src/session/journal/iteration-logger.js +0 -194
  360. package/src/session/journal/summary-writer.js +0 -320
  361. package/src/session/journal/tree-writer.js +0 -164
  362. package/src/session/journal/write-all.js +0 -89
  363. package/src/session-cleanup.js +0 -116
  364. package/src/skills/semantic-detector.js +0 -167
  365. package/src/skills/skill-detector.js +0 -385
  366. package/src/sonar/cloud-scanner.js +0 -78
  367. package/src/tracker-bootstrap.js +0 -44
  368. package/src/types/index.js +0 -38
  369. package/src/types/policy.js +0 -43
  370. package/src/types/solomon.js +0 -87
  371. package/src/utils/async-lock.js +0 -31
  372. package/src/utils/budget.js +0 -229
  373. package/src/utils/cwd-confirm.js +0 -39
  374. package/src/utils/display/event-handlers.js +0 -403
  375. package/src/utils/display/formatters.js +0 -132
  376. package/src/utils/display/header.js +0 -47
  377. package/src/utils/display/session-summary.js +0 -118
  378. package/src/utils/display/solomon.js +0 -42
  379. package/src/utils/pricing.js +0 -29
  380. package/src/utils/quota-simulator.js +0 -56
  381. package/src/utils/role-assigner.js +0 -137
  382. package/src/utils/rtk-wrapper.js +0 -87
  383. package/src/utils/run-registry.js +0 -9
  384. package/src/utils/status-dashboard.js +0 -254
  385. package/src/utils/tool-call-extractor.js +0 -56
  386. package/src/webperf/devtools-detect.js +0 -112
  387. package/src/webperf/webperf-role-integration.js +0 -66
  388. package/templates/skills/kj-run.md +0 -73
@@ -12,7 +12,7 @@
12
12
  * and says so: the coder learns the card exists and where to ask for it,
13
13
  * instead of receiving a silently empty context.
14
14
  */
15
- import { getHu } from "../hu/store.js";
15
+ import { findBoardHu } from "../hu/board-lookup.js";
16
16
 
17
17
  /** Ids minted by the HU Board; anything else belongs to an external board. */
18
18
  const LOCAL_HU = /^HU-/i;
@@ -25,9 +25,14 @@ const externalSection = (ref) =>
25
25
  "Treat the task below as the card's statement, and if something the card should answer is missing, ASK instead of guessing.",
26
26
  ].join("\n");
27
27
 
28
+ // KJC-BUG-0454: a board card keeps its criteria as a list; older HUs as one string.
29
+ const criteriaOf = (hu) => (Array.isArray(hu.acceptance_criteria) ? hu.acceptance_criteria.join("\n") : hu.acceptanceCriteria ?? "").trim();
30
+ const idOf = (hu) => hu.short_id || hu.id;
31
+
28
32
  const localSection = (hu) => {
29
- const lines = [`## Card ${hu.id} — ${hu.title}`, ""];
33
+ const lines = [`## Card ${idOf(hu)} — ${hu.title}`, ""];
30
34
  if (hu.description?.trim()) lines.push(hu.description.trim(), "");
35
+ if (hu.scope?.trim()) lines.push(`Scope: ${hu.scope.trim()}`, "");
31
36
  lines.push(`Status on the board: ${hu.status}. The card is the contract: done means its statement is literally true.`);
32
37
  return lines.join("\n");
33
38
  };
@@ -45,10 +50,10 @@ export async function resolveCardContext({ projectDir, ref, deps = {} }) {
45
50
  }
46
51
  // A missing local HU is an error, never an empty context: the caller asked
47
52
  // for a card by id and kj either delivers it or says it does not exist.
48
- const hu = await (deps.getHu || getHu)(projectDir, id);
49
- const criteria = hu.acceptanceCriteria?.trim();
53
+ const hu = await (deps.getHu || findBoardHu)(projectDir, id);
54
+ const criteria = criteriaOf(hu);
50
55
  return {
51
- huId: hu.id,
56
+ huId: idOf(hu),
52
57
  title: hu.title,
53
58
  section: localSection(hu),
54
59
  acceptanceTests: criteria ? [{ type: "gherkin", content: criteria }] : null,
@@ -35,7 +35,7 @@ const SERENA_INSTRUCTIONS = [
35
35
  * on it; the per-review content (task context + git diff, up to 12 KB
36
36
  * and always different) goes last.
37
37
  */
38
- export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null }) {
38
+ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null, roleSkillBlock = null }) {
39
39
  const { body: clippedDiff, note: clipNote } = clipDiff(diff);
40
40
  // KJC-BUG-0138: full diff on purpose — the moved-to side survives clipping.
41
41
  const splitSignal = buildSplitSignal(diff);
@@ -44,7 +44,10 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
44
44
  const rtkSnippet = buildRtkInstructions({ rtkAvailable });
45
45
  // KJC-TSK-1065: the reviewer gets the skills of its role and of the task, up to 3.
46
46
  let skillSection = null;
47
- if (projectDir) {
47
+ // KJC-TSK-1272: a block the caller resolved (a third-party repo's stack) wins over the project's.
48
+ if (roleSkillBlock) {
49
+ skillSection = buildRoleSkillSection(roleSkillBlock, { provider }) || null;
50
+ } else if (projectDir) {
48
51
  skillSection = buildRoleSkillSection(await roleSkills({ role: "reviewer", task, projectDir }), { provider }) || null;
49
52
  }
50
53
 
@@ -31,6 +31,57 @@ const countSources = (db, slug, projectDir) => {
31
31
  export const DEFAULT_MAX_DRIFT_FILES = 25;
32
32
  const HOOK_SRC = resolve(fileURLToPath(import.meta.url), "../../../scripts/git-hooks/post-merge");
33
33
 
34
+ // KJC-TSK-1174 (RAG-A2): no index yet. It is built in the background, once; the session
35
+ // does not wait. A recent attempt that died (no Ollama) is not retried blindly.
36
+ function bootstrapInBackground({ projectDir, slug, head, logger }) {
37
+ const boot = startBootstrap(projectDir);
38
+ if (boot.busy) {
39
+ warnOnce(logger, `bootstrap-busy:${projectDir}`, `[rag] ${slug} has no index yet: another background index is running (${boot.busy.slug ?? "?"}, pid ${boot.busy.pid ?? "?"}); it is tried again on the next command`);
40
+ return { skipped: true, reason: "bootstrap-queued", holder: boot.busy, head };
41
+ }
42
+ if (boot.recent) {
43
+ warnOnce(logger, `bootstrap:${projectDir}`, `[rag] ${slug} has no index and the background indexing did not finish: run kj rag index --with-sources`);
44
+ return { skipped: true, reason: "bootstrap-failed", head };
45
+ }
46
+ warnOnce(logger, `bootstrap:${projectDir}`, `[rag] ${slug} has no index yet: indexing it in the background (pid ${boot.pid}); queries answer from it once it is done`);
47
+ return { skipped: true, reason: "bootstrapping", pid: boot.pid, head };
48
+ }
49
+
50
+ // KJC-TSK-1171 (RAG-B): an index built before the embedded header holds vectors a
51
+ // query by file or symbol name cannot reach. Small, it is rebuilt here; large, the
52
+ // query answers from it and says once how to rebuild.
53
+ async function rebuildOldSchema({ db, slug, projectDir, config, logger, head, maxFiles }, schema) {
54
+ const sources = countSources(db, slug, projectDir);
55
+ if (sources > maxFiles) {
56
+ warnOnce(logger, projectDir, `[rag] the index of ${slug} predates the embedded header (schema ${schema} < ${EMBED_SCHEMA}): answering from it as it is; rebuild it with kj rag index --with-sources`);
57
+ return { skipped: true, reason: "embed-schema-old", sources, head };
58
+ }
59
+ logger.info?.(`[rag] the index of ${slug} predates the embedded header (schema ${schema} < ${EMBED_SCHEMA}); rebuilding ${sources} source(s)`);
60
+ const totals = await indexProject(projectDir, { db, embedder: makeGovernedEmbedder(config), karajanHome: getKarajanHome(), logger, withSources: true, exclude: ragExclude(config) });
61
+ if (totals.head) setLastIndexedCommit(db, slug, totals.head);
62
+ return { ran: true, rebuilt: true, totals };
63
+ }
64
+
65
+ // KJC-TSK-1174 (RAG-A1): the drift is measured up to the files on disk, untracked ones
66
+ // included, so a query sees what the session is writing before it is committed.
67
+ // The dirty paths of the last refresh count too: reverted or deleted since, they hold stale chunks.
68
+ async function deltaUpdate({ db, slug, projectDir, config, logger, head, maxFiles }, since) {
69
+ const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-only", "-z", since]);
70
+ const dirty = [...stdout.split("\0").filter(Boolean), ...(await untrackedFiles(projectDir))];
71
+ const reconcile = readRefreshedPaths(projectDir).filter((p) => !dirty.includes(p));
72
+ const changed = dirty.length + reconcile.length;
73
+ if (changed === 0) return { skipped: true, head };
74
+ if (changed > maxFiles) {
75
+ logger.warn?.(`[rag] ${changed} file(s) changed since the last index (more than ${maxFiles}): answering from the index as it is; bring it up to date with kj rag index --since auto`);
76
+ return { skipped: true, reason: "drift-too-large", changed, head };
77
+ }
78
+ logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}, ${changed} file(s)); running delta update`);
79
+ const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config), worktree: true, reconcile });
80
+ if (totals.head) setLastIndexedCommit(db, slug, totals.head);
81
+ writeRefreshedPaths(projectDir, dirty);
82
+ return { ran: true, totals };
83
+ }
84
+
34
85
  export async function maybeAutoUpdate({ projectDir, config, logger = console, flags = {} } = {}) {
35
86
  // commander turns `--no-rag-update` into flags.ragUpdate === false.
36
87
  if (flags?.ragUpdate === false || config?.rag?.autoUpdate?.onRun === false) return { skipped: true };
@@ -42,53 +93,15 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
42
93
  const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
43
94
  try {
44
95
  const since = getLastIndexedCommit(db, slug);
45
- if (!since) {
46
- // KJC-TSK-1174 (RAG-A2): no index yet. It is built in the background, once; the session
47
- // does not wait. A recent attempt that died (no Ollama) is not retried blindly.
48
- const boot = startBootstrap(projectDir);
49
- if (boot.recent) {
50
- warnOnce(logger, `bootstrap:${projectDir}`, `[rag] ${slug} has no index and the background indexing did not finish: run kj rag index --with-sources`);
51
- return { skipped: true, reason: "bootstrap-failed", head };
52
- }
53
- warnOnce(logger, `bootstrap:${projectDir}`, `[rag] ${slug} has no index yet: indexing it in the background (pid ${boot.pid}); queries answer from it once it is done`);
54
- return { skipped: true, reason: "bootstrapping", pid: boot.pid, head };
55
- }
96
+ if (!since) return bootstrapInBackground({ projectDir, slug, head, logger });
56
97
  // KJC-BUG-0304: a drift of hundreds of files (an index weeks old) reindexed the
57
98
  // repo for half an hour before the query answered. Past the bound the query
58
99
  // answers from the index as it is, and says how to bring it up to date.
59
- const maxFiles = config?.rag?.autoUpdate?.maxFiles ?? DEFAULT_MAX_DRIFT_FILES;
60
- // KJC-TSK-1171 (RAG-B): an index built before the embedded header holds vectors a
61
- // query by file or symbol name cannot reach. Small, it is rebuilt here; large, the
62
- // query answers from it and says once how to rebuild.
100
+ const ctx = { db, slug, projectDir, config, logger, head, maxFiles: config?.rag?.autoUpdate?.maxFiles ?? DEFAULT_MAX_DRIFT_FILES };
63
101
  const schema = readEmbedSchema(projectDir);
64
- if (schema < EMBED_SCHEMA) {
65
- const sources = countSources(db, slug, projectDir);
66
- if (sources > maxFiles) {
67
- warnOnce(logger, projectDir, `[rag] the index of ${slug} predates the embedded header (schema ${schema} < ${EMBED_SCHEMA}): answering from it as it is; rebuild it with kj rag index --with-sources`);
68
- return { skipped: true, reason: "embed-schema-old", sources, head };
69
- }
70
- logger.info?.(`[rag] the index of ${slug} predates the embedded header (schema ${schema} < ${EMBED_SCHEMA}); rebuilding ${sources} source(s)`);
71
- const totals = await indexProject(projectDir, { db, embedder: makeGovernedEmbedder(config), karajanHome: getKarajanHome(), logger, withSources: true, exclude: ragExclude(config) });
72
- if (totals.head) setLastIndexedCommit(db, slug, totals.head);
73
- return { ran: true, rebuilt: true, totals };
74
- }
75
- // KJC-TSK-1174 (RAG-A1): the drift is measured up to the files on disk, untracked ones
76
- // included, so a query sees what the session is writing before it is committed.
77
- // The dirty paths of the last refresh count too: reverted or deleted since, they hold stale chunks.
78
- const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-only", "-z", since]);
79
- const dirty = [...stdout.split("\0").filter(Boolean), ...(await untrackedFiles(projectDir))];
80
- const reconcile = readRefreshedPaths(projectDir).filter((p) => !dirty.includes(p));
81
- const changed = dirty.length + reconcile.length;
82
- if (changed === 0) return { skipped: true, head };
83
- if (changed > maxFiles) {
84
- logger.warn?.(`[rag] ${changed} file(s) changed since the last index (more than ${maxFiles}): answering from the index as it is; bring it up to date with kj rag index --since auto`);
85
- return { skipped: true, reason: "drift-too-large", changed, head };
86
- }
87
- logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}, ${changed} file(s)); running delta update`);
88
- const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config), worktree: true, reconcile });
89
- if (totals.head) setLastIndexedCommit(db, slug, totals.head);
90
- writeRefreshedPaths(projectDir, dirty);
91
- return { ran: true, totals };
102
+ // Awaited here, inside the try: the catch must see their errors and the finally must not close the store under them.
103
+ if (schema < EMBED_SCHEMA) return await rebuildOldSchema(ctx, schema);
104
+ return await deltaUpdate(ctx, since);
92
105
  } catch (err) {
93
106
  // Said once per process: an embedder that is down would repeat it on every query otherwise.
94
107
  warnOnce(logger, `failed:${projectDir}`, `[rag] auto-update failed (${err.message}); continuing without refresh`);
@@ -1,52 +1,28 @@
1
1
  // KJC-TSK-1174 (RAG-A2): a project with no index gets one in the background, once, at the first
2
2
  // command that needs it. The session goes on; queries answer from the index when it is done.
3
3
  import { spawn } from "node:child_process";
4
- import { mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { mkdirSync, rmdirSync, unlinkSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { projectDbPath } from "./project-store.js";
8
+ import { projectSlug } from "./vec-store.js";
9
+ import { GRACE_MS, MAX_RUN_MS, indexLeasePath, readLease, takeLock, unlessGone, withMutex, writeLease } from "./pid-lease.js";
8
10
 
9
11
  const KJ_BIN = resolve(fileURLToPath(import.meta.url), "../../../bin/kj.js");
10
12
  // An attempt that died (no Ollama, no model) is not repeated on every command: this long apart.
11
13
  export const RETRY_MS = 10 * 60 * 1000;
12
- // A marker that does not parse, or a lock, older than this was left by a crashed command: taken over.
13
- export const GRACE_MS = 30 * 1000;
14
- // An indexer is not trusted to run longer than this: past it, its pid may belong to someone else.
15
- export const MAX_RUN_MS = 60 * 60 * 1000;
14
+ export { GRACE_MS, MAX_RUN_MS };
16
15
  const markerPath = (projectDir) => `${projectDbPath(projectDir)}.bootstrap`;
17
16
 
18
- const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
19
-
20
17
  /** What the marker says of the last attempt: `{ pid, at, running }`, or null when none was ever made. */
21
- const unlessGone = (fn) => {
22
- try { return fn(); }
23
- catch (err) {
24
- if (err.code === "ENOENT") { return null; }
25
- throw err;
26
- }
27
- };
28
-
29
- export function readBootstrap(projectDir, { now = Date.now() } = {}) {
30
- const file = markerPath(projectDir);
31
- // Read first, never check-then-read: another command may set the marker aside in between.
32
- const raw = unlessGone(() => readFileSync(file, "utf8"));
33
- if (raw === null) return null;
34
- try {
35
- const note = JSON.parse(raw);
36
- return { ...note, running: alive(note.pid) && now - note.at < MAX_RUN_MS };
37
- } catch {
38
- // Another command is writing it this very moment: running for a moment; past the grace, a crash.
39
- const stat = unlessGone(() => statSync(file));
40
- return stat === null ? null : { pid: null, at: 0, running: now - stat.mtimeMs < GRACE_MS };
41
- }
42
- }
18
+ export const readBootstrap = (projectDir, o) => readLease(markerPath(projectDir), o);
43
19
 
44
20
  /**
45
21
  * Starts `kj rag index --with-sources` detached, or reuses the one running. Returns
46
22
  * `{ started, pid }`, or `{ started: false, recent: true }` when the last attempt died less
47
23
  * than RETRY_MS ago: the index is then asked for by hand instead of being retried blindly.
48
24
  */
49
- export function startBootstrap(projectDir, { spawnFn = spawn, bin = KJ_BIN, now = Date.now() } = {}) {
25
+ export function startBootstrap(projectDir, { spawnFn = spawn, bin = KJ_BIN, now = Date.now(), lease = indexLeasePath() } = {}) {
50
26
  const file = markerPath(projectDir);
51
27
  mkdirSync(dirname(file), { recursive: true });
52
28
  // One claimant at a time: the lock directory is created atomically, so of two commands arriving
@@ -58,29 +34,27 @@ export function startBootstrap(projectDir, { spawnFn = spawn, bin = KJ_BIN, now
58
34
  const last = readBootstrap(projectDir, { now });
59
35
  if (last?.running) return { started: false, pid: last.pid };
60
36
  if (last && now - last.at < RETRY_MS) return { started: false, recent: true, pid: null };
61
- // The marker lands whole each time (written apart, then renamed): first with the claimant's
62
- // pid, so it reads as running meanwhile, then with the indexer's.
63
- const note = (pid) => { const tmp = `${file}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify({ pid, at: now }), "utf8"); renameSync(tmp, file); };
64
- note(process.pid);
65
- const child = spawnFn(process.execPath, [bin, "rag", "index", "--with-sources"], { cwd: projectDir, detached: true, stdio: "ignore" });
66
- child.unref?.();
67
- note(child.pid);
68
- return { started: true, pid: child.pid };
37
+ // One indexer per machine: the lease is taken inside the project mutex, before the marker is
38
+ // written, so a queued project does not spend its RETRY_MS. Both mutexes yield, never wait.
39
+ const slug = projectSlug(projectDir);
40
+ const res = withMutex(lease, now, () => {
41
+ const holder = readLease(lease, { now });
42
+ if (holder?.running && holder.pid !== process.pid) return { started: false, busy: { projectDir: holder.projectDir, slug: holder.slug, pid: holder.pid } };
43
+ // Marker and lease land whole each time: first with the claimant's pid, so they read as
44
+ // running meanwhile, then with the indexer's.
45
+ const note = (pid) => { writeLease(file, { pid, at: now }); writeLease(lease, { pid, at: now, projectDir, slug }); };
46
+ note(process.pid);
47
+ try {
48
+ const child = spawnFn(process.execPath, [bin, "rag", "index", "--with-sources"], { cwd: projectDir, detached: true, stdio: "ignore" });
49
+ child.unref?.();
50
+ note(child.pid);
51
+ return { started: true, pid: child.pid };
52
+ } catch (err) {
53
+ // No indexer exists: neither the marker nor the lease may keep naming this (maybe long-lived) process.
54
+ unlessGone(() => unlinkSync(file)); unlessGone(() => unlinkSync(lease));
55
+ throw err;
56
+ }
57
+ });
58
+ return res.busy === true ? { started: false, busy: {} } : res;
69
59
  } finally { unlessGone(() => rmdirSync(lock)); }
70
60
  }
71
-
72
- const takeLock = (lock, now) => {
73
- for (let attempt = 0; attempt < 2; attempt += 1) {
74
- try { mkdirSync(lock); return true; }
75
- catch (err) {
76
- if (err.code !== "EEXIST") throw err;
77
- const held = unlessGone(() => statSync(lock));
78
- if (held && now - held.mtimeMs < GRACE_MS) return false;
79
- // The holder crashed. The lock is set aside under a unique name first: only the one caller
80
- // whose rename succeeds removes it, so nobody removes a lock another caller has just taken.
81
- const aside = `${lock}.${process.pid}.${now}.stale`;
82
- if (unlessGone(() => renameSync(lock, aside)) !== null) rmdirSync(aside);
83
- }
84
- }
85
- return false;
86
- };
@@ -10,7 +10,7 @@ import { existsSync } from "node:fs";
10
10
  import { sep } from "node:path";
11
11
 
12
12
  import { insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
13
- import { openVecStoreExplained as openVecStore } from "./project-store.js";
13
+ import { openVecStore } from "./vec-store.js";
14
14
 
15
15
  export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
16
16
 
@@ -9,7 +9,7 @@
9
9
  import fs from "node:fs/promises";
10
10
  import { existsSync, rmSync } from "node:fs";
11
11
  import path from "node:path";
12
- import Database from "better-sqlite3";
12
+ import { openDatabase } from "karajan-core/db";
13
13
  import { parseDocument } from "yaml";
14
14
  import { getProjectConfigPath } from "../config/loader.js";
15
15
  import { projectDbPath } from "./project-store.js";
@@ -31,7 +31,7 @@ export function resetEmptyStore(projectDir = process.cwd()) {
31
31
  if (!existsSync(storePath)) return true;
32
32
  // Read-only, schema-agnostic inspection: no DDL, no vec extension, no
33
33
  // dimension assumptions — the store is only ever OPENED to be counted.
34
- const db = new Database(storePath, { readonly: true });
34
+ const db = openDatabase(storePath, { readOnly: true });
35
35
  try {
36
36
  const hasChunksTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'").get();
37
37
  if (hasChunksTable && db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n > 0) return false;
@@ -0,0 +1,70 @@
1
+ // HU-2757: a lease names the pid that holds a resource and when it took it. Nobody releases it:
2
+ // a dead pid, or an age of MAX_RUN_MS, means free. A mkdir mutex makes the decision atomic.
3
+ import { mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { getKarajanHome } from "../utils/paths.js";
6
+
7
+ // A lease that does not parse, or a lock, older than this was left by a crashed command: taken over.
8
+ export const GRACE_MS = 30 * 1000;
9
+ // An indexer is not trusted to run longer than this: past it, its pid may belong to someone else.
10
+ export const MAX_RUN_MS = 60 * 60 * 1000;
11
+
12
+ export const indexLeasePath = () => join(getKarajanHome(), "locks", "rag-index.json");
13
+
14
+ // EPERM (a pid of another user) counts as dead: a single-user machine has no such indexer.
15
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
16
+
17
+ export const unlessGone = (fn) => {
18
+ try { return fn(); }
19
+ catch (err) {
20
+ if (err.code === "ENOENT") { return null; }
21
+ throw err;
22
+ }
23
+ };
24
+
25
+ /** What the lease says: `{ pid, at, running, ... }`, or null when none was ever written. */
26
+ export function readLease(file, { now = Date.now() } = {}) {
27
+ // Read first, never check-then-read: another command may set the file aside in between.
28
+ const raw = unlessGone(() => readFileSync(file, "utf8"));
29
+ if (raw === null) return null;
30
+ try {
31
+ const note = JSON.parse(raw);
32
+ return { ...note, running: alive(note.pid) && now - note.at < MAX_RUN_MS };
33
+ } catch {
34
+ // Another command is writing it this very moment: running for a moment; past the grace, a crash.
35
+ const stat = unlessGone(() => statSync(file));
36
+ return stat === null ? null : { pid: null, at: 0, running: now - stat.mtimeMs < GRACE_MS };
37
+ }
38
+ }
39
+
40
+ /** The lease lands whole: written apart, then renamed. */
41
+ export function writeLease(file, record) {
42
+ mkdirSync(dirname(file), { recursive: true });
43
+ const tmp = `${file}.${process.pid}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify(record), "utf8");
45
+ renameSync(tmp, file);
46
+ }
47
+
48
+ export const takeLock = (lock, now) => {
49
+ for (let attempt = 0; attempt < 2; attempt += 1) {
50
+ try { mkdirSync(lock); return true; }
51
+ catch (err) {
52
+ if (err.code !== "EEXIST") throw err;
53
+ const held = unlessGone(() => statSync(lock));
54
+ if (held && now - held.mtimeMs < GRACE_MS) return false;
55
+ // The holder crashed. The lock is set aside under a unique name first: only the one caller
56
+ // whose rename succeeds removes it, so nobody removes a lock another caller has just taken.
57
+ const aside = `${lock}.${process.pid}.${now}.stale`;
58
+ if (unlessGone(() => renameSync(lock, aside)) !== null) rmdirSync(aside);
59
+ }
60
+ }
61
+ return false;
62
+ };
63
+
64
+ /** Runs `fn` under the mutex `${file}.lock`, or returns `{ busy: true }` when another command holds it. */
65
+ export function withMutex(file, now, fn) {
66
+ const lock = `${file}.lock`;
67
+ mkdirSync(dirname(lock), { recursive: true });
68
+ if (!takeLock(lock, now)) return { busy: true };
69
+ try { return fn(); } finally { unlessGone(() => rmdirSync(lock)); }
70
+ }
@@ -17,7 +17,6 @@ import { execFileSync } from "node:child_process";
17
17
  import path from "node:path";
18
18
 
19
19
  import { openVecStore } from "./vec-store.js";
20
- import { explainNativeLoadError } from "../checks/native-build.js";
21
20
  import { getKarajanHome } from "../utils/paths.js";
22
21
 
23
22
  function projectRoot(dir) {
@@ -40,20 +39,11 @@ export function libraryDbPath() {
40
39
  return process.env.KJ_LIBRARY_DB || path.join(getKarajanHome(), "library.db");
41
40
  }
42
41
 
43
- // KJC-BUG-0426: an unbuilt better-sqlite3 addon says how to build it, not "bindings file".
44
- // Every opening of a RAG store goes through here.
45
- export function openVecStoreExplained(opts) {
46
- try {
47
- return openVecStore(opts);
48
- } catch (err) {
49
- throw explainNativeLoadError(err);
50
- }
51
- }
52
42
 
53
43
  export function openLibraryStore({ dim = 768 } = {}) {
54
- return openVecStoreExplained({ dim, path: libraryDbPath() });
44
+ return openVecStore({ dim, path: libraryDbPath() });
55
45
  }
56
46
 
57
47
  export function openProjectStore({ projectDir = process.cwd(), dim = 768 } = {}) {
58
- return openVecStoreExplained({ dim, path: projectDbPath(projectDir) });
48
+ return openVecStore({ dim, path: projectDbPath(projectDir) });
59
49
  }
@@ -41,7 +41,8 @@ async function findLiveCardInBranch(projectDir, branch) {
41
41
  const plan = await loadPlan(projectDir, meta.planId);
42
42
  for (const h of plan.hus || []) {
43
43
  // KJC-BUG-0353: an HU is also named by the id of its replica in an external tracker.
44
- for (const ref of [h.short_id, h.id, h.external_ref]) {
44
+ // KJC-BUG-0443: a shared-board HU carries "repo#n · ref"; each part names it.
45
+ for (const ref of [h.short_id, h.id, ...String(h.external_ref ?? "").split(" · ")]) {
45
46
  if (ref && refInBranch(ref, needle)) {
46
47
  if (LIVE_STATUSES.has(h.status)) return { ref, live: true, status: h.status };
47
48
  closedHit = { ref, live: false, status: h.status };
@@ -52,7 +53,12 @@ async function findLiveCardInBranch(projectDir, branch) {
52
53
  return closedHit;
53
54
  }
54
55
 
55
- export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, deps = {} }) {
56
+ // A failed check under the effective policy: blocks, or passes with a warning.
57
+ const failedCheck = (effective, reason, extra = {}) => (effective === "block"
58
+ ? { ok: false, mode: "block", reason, ...extra }
59
+ : { ok: true, mode: "warn", reason, ...extra });
60
+
61
+ function exemption(config, branch) {
56
62
  const exempt = config.method_gates?.card_first_exempt_branches || DEFAULT_EXEMPT_PREFIXES;
57
63
  if (exempt.some((p) => branch.startsWith(p))) {
58
64
  return { ok: true, mode: "exempt", reason: `branch prefix exempt (${branch.split("/")[0]}/…)` };
@@ -62,59 +68,64 @@ export async function checkCardFirst({ config = {}, projectDir = process.cwd(),
62
68
  if ([config.base_branch || "main", "main", "master", "HEAD"].includes(branch)) {
63
69
  return { ok: true, mode: "exempt", reason: "base branch — the branch-first gate owns this case" };
64
70
  }
71
+ return null;
72
+ }
65
73
 
66
- const backend = config.state_backend || "hu-board";
67
- const policy = config.method_gates?.card_first || "auto";
74
+ async function huBoardCheck({ projectDir, branch, policy }) {
75
+ const hit = await findLiveCardInBranch(projectDir, branch);
76
+ if (hit?.live) return { ok: true, mode: "pass", ref: hit.ref };
77
+ const reason = hit
78
+ ? `branch references ${hit.ref} but its card is "${hit.status}" — card-first needs a live card (reopen it or create one: kj hu add)`
79
+ : `no live card referenced in branch "${branch}" — create it first (kj hu add "…" --id <REF>) and name the branch after it`;
80
+ return failedCheck(policy === "auto" ? "block" : policy, reason);
81
+ }
68
82
 
69
- if (backend === "hu-board") {
70
- const hit = await findLiveCardInBranch(projectDir, branch);
71
- if (hit?.live) return { ok: true, mode: "pass", ref: hit.ref };
72
- const effective = policy === "auto" ? "block" : policy;
73
- const reason = hit
74
- ? `branch references ${hit.ref} but its card is "${hit.status}" — card-first needs a live card (reopen it or create one: kj hu add)`
75
- : `no live card referenced in branch "${branch}" — create it first (kj hu add "…" --id <REF>) and name the branch after it`;
76
- return effective === "block"
77
- ? { ok: false, mode: "block", reason }
78
- : { ok: true, mode: "warn", reason };
83
+ // A card-shaped reference in the branch, verified against the tracker when an adapter is declared.
84
+ async function trackerRefCheck({ config, projectDir, branch, deps, boardName, effective }) {
85
+ const ref = branch.match(CARD_REF_RE)[0];
86
+ const { verifyFn = verifyCardAlive } = deps;
87
+ const v = await verifyFn({ ref, verifyCmd: config.board?.verify_cmd, projectDir });
88
+ if (v.verified === false) {
89
+ const reason = `branch references ${ref} but ${boardName} reports it "${v.status}" — card-first needs a live card in the tracker (create/reopen it there)`;
90
+ return failedCheck(effective, reason, { level: v.level });
79
91
  }
92
+ // KJC-TSK-0878 (issue #1371): "no he podido verificar" y "he verificado y
93
+ // esta mal" no son lo mismo, y ambas pueden merecer una parada. El proyecto
94
+ // decide si la primera para, y el mensaje dice cual de las dos es: un gate
95
+ // que aprueba lo que no ha comprobado da una confianza que no ha ganado.
96
+ // Sin adaptador declarado no hay nada que bloquear: la opcion no castiga a
97
+ // quien aun no tiene verify_cmd.
98
+ if (v.verified === null && config.board?.unverifiable === "block" && config.board?.verify_cmd) {
99
+ return {
100
+ ok: false, mode: "block", level: v.level,
101
+ reason: `branch references ${ref} but the card could not be verified against ${boardName} — ${v.note} (board.unverifiable: block refuses to approve a check kj did not make)`,
102
+ };
103
+ }
104
+ const huNote = `no live HU on the HU Board links ${ref}: kj hu add "…" --ref ${ref}`;
105
+ return { ok: true, mode: "pass", ref, level: v.level, note: v.note ? `${v.note}; ${huNote}` : huNote };
106
+ }
80
107
 
81
- // planning-game / external: presence check, upgraded to LIVENESS against
82
- // the real tracker when board.verify_cmd declares an adapter
83
- // (KJC-TSK-0732, issue #1371). Unverifiable degrades honestly — the
84
- // result names its level so nobody mistakes branch-ref for tracker.
108
+ // planning-game / external: presence check, upgraded to LIVENESS against
109
+ // the real tracker when board.verify_cmd declares an adapter
110
+ // (KJC-TSK-0732, issue #1371). Unverifiable degrades honestly — the
111
+ // result names its level so nobody mistakes branch-ref for tracker.
112
+ async function externalBoardCheck({ config, projectDir, branch, deps, backend, policy }) {
85
113
  const boardName = config.board?.name || backend;
86
114
  const effective = policy === "auto" ? "warn" : policy;
87
115
  // KJC-BUG-0353: the HU Board is always kj's board (KJC-BUG-0305) — a live HU
88
116
  // the branch names, or whose replica it names, is checked here, not guessed.
89
117
  const huHit = await findLiveCardInBranch(projectDir, branch);
90
118
  if (huHit?.live) return { ok: true, mode: "pass", ref: huHit.ref, level: "hu-board" };
91
- if (CARD_REF_RE.test(branch)) {
92
- const ref = branch.match(CARD_REF_RE)[0];
93
- const { verifyFn = verifyCardAlive } = deps;
94
- const v = await verifyFn({ ref, verifyCmd: config.board?.verify_cmd, projectDir });
95
- if (v.verified === false) {
96
- const reason = `branch references ${ref} but ${boardName} reports it "${v.status}" — card-first needs a live card in the tracker (create/reopen it there)`;
97
- return effective === "block"
98
- ? { ok: false, mode: "block", reason, level: v.level }
99
- : { ok: true, mode: "warn", reason, level: v.level };
100
- }
101
- // KJC-TSK-0878 (issue #1371): "no he podido verificar" y "he verificado y
102
- // esta mal" no son lo mismo, y ambas pueden merecer una parada. El proyecto
103
- // decide si la primera para, y el mensaje dice cual de las dos es: un gate
104
- // que aprueba lo que no ha comprobado da una confianza que no ha ganado.
105
- // Sin adaptador declarado no hay nada que bloquear: la opcion no castiga a
106
- // quien aun no tiene verify_cmd.
107
- if (v.verified === null && config.board?.unverifiable === "block" && config.board?.verify_cmd) {
108
- return {
109
- ok: false, mode: "block", level: v.level,
110
- reason: `branch references ${ref} but the card could not be verified against ${boardName} — ${v.note} (board.unverifiable: block refuses to approve a check kj did not make)`,
111
- };
112
- }
113
- const huNote = `no live HU on the HU Board links ${ref}: kj hu add "…" --ref ${ref}`;
114
- return { ok: true, mode: "pass", ref, level: v.level, note: v.note ? `${v.note}; ${huNote}` : huNote };
115
- }
119
+ if (CARD_REF_RE.test(branch)) return trackerRefCheck({ config, projectDir, branch, deps, boardName, effective });
116
120
  const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
117
- return effective === "block"
118
- ? { ok: false, mode: "block", reason }
119
- : { ok: true, mode: "warn", reason };
121
+ return failedCheck(effective, reason);
122
+ }
123
+
124
+ export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, deps = {} }) {
125
+ const exempt = exemption(config, branch);
126
+ if (exempt) return exempt;
127
+ const backend = config.state_backend || "hu-board";
128
+ const policy = config.method_gates?.card_first || "auto";
129
+ if (backend === "hu-board") return huBoardCheck({ projectDir, branch, policy });
130
+ return externalBoardCheck({ config, projectDir, branch, deps, backend, policy });
120
131
  }
@@ -47,10 +47,11 @@ function verdictOf(sides, findings, coverage) {
47
47
  if (blockers.length) return { status: "rejected", approved: false, reason: `confirmed blocker(s): ${blockers.map(where).join(", ")}`, summary };
48
48
  const rejecting = sides.filter((s) => !s.verdict?.approved).map((s) => s.reviewer);
49
49
  if (rejecting.length === 0 && !coverage.truncated) return { status: "approved", approved: true, summary };
50
- const reason = rejecting.length
51
- ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family`
52
- : "not every file was reviewed";
53
- return { status: "partial", approved: false, reason, summary };
50
+ const dispute = rejecting.length ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family` : null;
51
+ // KJC-BUG-0453 (#2921): "partial" only when files were left out (they are in coverage.unreviewed);
52
+ // everything reviewed and the reviewers disagreeing is "disputed".
53
+ if (coverage.truncated) return { status: "partial", approved: false, reason: ["not every file was reviewed", dispute].filter(Boolean).join("; "), summary };
54
+ return { status: "disputed", approved: false, reason: dispute, summary };
54
55
  }
55
56
 
56
57
  /** The two sides (reviewer, model, verdict, coverage, findings, outcomes, verification) as one result. */
@@ -46,6 +46,14 @@ export const CONTRACT_BLOCK = [
46
46
  "!.karajan/security-level.json",
47
47
  // KJC-TSK-1163 (ADR 0025): which HU Board the repo uses; the team shares it.
48
48
  "!.karajan/board.yml",
49
+ // ADR 0032 (KJC-TSK-1231): the team's own state, as the board: the rules the roles
50
+ // apply and atril's config with its lockfile. Shared plans travel in .karajan-shared/.
51
+ "!.karajan/coder-rules.md",
52
+ "!.karajan/review-rules.md",
53
+ // KJC-TSK-1234: the project config, once the pre-commit refuses it with a secret in it.
54
+ "!.karajan/kj.config.yml",
55
+ "!.karajan/atril.json",
56
+ "!.karajan/atril.lock.json",
49
57
  ];
50
58
 
51
59
  /**
@@ -117,6 +125,8 @@ export async function excludeLocalArtifacts(projectDir, run = runCommand) {
117
125
  }
118
126
  }
119
127
 
128
+ const SHADOWING = new Set(["review-rules.md", "coder-rules.md"]);
129
+
120
130
  export async function ensureGateTrackable(projectDir) {
121
131
  const file = path.join(projectDir, ".gitignore");
122
132
  let text;
@@ -126,14 +136,22 @@ export async function ensureGateTrackable(projectDir) {
126
136
  // a partial state (e.g. a hand-written marker re-include without the hook
127
137
  // ones) must still be completed, not silently accepted.
128
138
  const reIncludes = CONTRACT_BLOCK.filter((l) => l.startsWith("!"));
129
- if (reIncludes.every((l) => text.includes(l))) return { changed: false };
130
-
131
139
  const isRootExclude = (l) => {
132
140
  const t = l.trim();
133
141
  return t === ".karajan/" || t === ".karajan" || t === ".karajan/*";
134
142
  };
135
143
  const lines = text.split("\n");
136
144
  const idx = lines.findIndex(isRootExclude);
145
+ // ADR 0032: a bare rules line an older kj wrote after its re-include (or with none) undoes
146
+ // it, since the last match wins: it goes. One after the root exclude but before its own
147
+ // re-include was already overridden, so it is kept and moved ahead of the block, where it
148
+ // stays overridden. Lines before the root exclude are never touched.
149
+ const placed = (l, i) => {
150
+ if (idx === -1 || i <= idx || !SHADOWING.has(l.trim())) return "keep";
151
+ const reInclude = lines.findLastIndex((x) => x.trim() === `!.karajan/${l.trim()}`);
152
+ return reInclude === -1 || i > reInclude ? "drop" : "move";
153
+ };
154
+ if (reIncludes.every((l) => text.includes(l)) && lines.every((l, i) => placed(l, i) === "keep")) return { changed: false };
137
155
  if (idx === -1) return { changed: false };
138
156
 
139
157
  // Drop EVERY .karajan root exclude (a later duplicate would override the
@@ -142,9 +160,10 @@ export async function ensureGateTrackable(projectDir) {
142
160
  // position of the first exclude. Exactly one coherent block, idempotent.
143
161
  const partials = new Set(CONTRACT_BLOCK);
144
162
  const marker = "@@kj-contract-block@@";
163
+ const moved = lines.filter((l, i) => placed(l, i) === "move");
145
164
  const cleaned = lines.map((l, i) => (i === idx ? marker : l))
146
- .filter((l) => l === marker || (!isRootExclude(l) && !partials.has(l.trim())));
147
- const result = cleaned.flatMap((l) => (l === marker ? CONTRACT_BLOCK : [l]));
165
+ .filter((l, i) => l === marker || (!isRootExclude(l) && !partials.has(l.trim()) && placed(l, i) === "keep"));
166
+ const result = cleaned.flatMap((l) => (l === marker ? [...moved, ...CONTRACT_BLOCK] : [l]));
148
167
  await fs.writeFile(file, result.join("\n"));
149
168
  return { changed: true };
150
169
  }