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
@@ -7,13 +7,13 @@
7
7
  import console from "node:console";
8
8
  import process from "node:process";
9
9
  import { dirname, join, relative, resolve } from "node:path";
10
- import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
10
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { homedir } from "node:os";
13
13
  import { fileURLToPath } from "node:url";
14
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
14
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, enterLane, holdLane, laneFor, laneRefusal, laneTop, load, save, session, boardGate, pendingMoves, pendingText, stagedVerdict } from "./sentinel-lib.mjs";
15
15
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
16
- import { isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
16
+ import { commitStaging, isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
17
17
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
18
18
  import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
19
19
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -26,7 +26,8 @@ let raw = "";
26
26
  process.stdin.on("data", (d) => { raw += d; });
27
27
  process.stdin.on("end", () => {
28
28
  try {
29
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
29
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null, cwd = null } = JSON.parse(raw);
30
+ enterLane(load(), sid, cwd);
30
31
  // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
31
32
  // KJC-TSK-0968 (HUM-E, ADR 0018): and leaves a record next to the state, with the
32
33
  // gate's own words: kj handoff takes the why from here, never from the agent.
@@ -50,7 +51,7 @@ process.stdin.on("end", () => {
50
51
  // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
51
52
  if (Object.hasOwn(touch, relT)) return;
52
53
  // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
53
- const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
54
+ const gs = spawnSync("git", ["-C", laneTop(), "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
54
55
  const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
55
56
  Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
56
57
  save(st);
@@ -60,7 +61,9 @@ process.stdin.on("end", () => {
60
61
  // only the human, editing outside it.
61
62
  if (EDIT_TOOLS.includes(tool)) {
62
63
  const target = input.file_path || input.notebook_path;
63
- const relT = target ? relative(ROOT, String(target)).replaceAll("\\", "/") : "";
64
+ // ADR 0034: judged in the lane the write lands in (its supervisor, its config).
65
+ const targetTop = target ? laneFor(target) : laneTop();
66
+ const relT = target ? relative(targetTop, String(target)).replaceAll("\\", "/") : "";
64
67
  if (relT && PROTECTED.test(relT)) {
65
68
  console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
66
69
  process.exit(2);
@@ -74,7 +77,8 @@ process.stdin.on("end", () => {
74
77
  console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
75
78
  process.exit(2);
76
79
  }
77
- if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
80
+ // The ledger is keyed by path in the session's lane: a write the lane guard will deny records nothing.
81
+ if (targetTop === laneTop() && relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
78
82
  }
79
83
  // KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
80
84
  if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
@@ -202,9 +206,9 @@ process.stdin.on("end", () => {
202
206
  };
203
207
  const declaredGh = idLine("gh_user");
204
208
  const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
205
- const activeGh = () => {
206
- if (!existsSync(ghHostsPath)) return null;
207
- const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
209
+ const activeGh = (hostsPath = ghHostsPath) => {
210
+ if (!existsSync(hostsPath)) return null;
211
+ const lines = readFileSync(hostsPath, "utf8").split(ESC_NL);
208
212
  const start = lines.findIndex((l) => l.startsWith("github.com:"));
209
213
  if (start < 0) return null;
210
214
  for (let k = start + 1; k < lines.length; k += 1) {
@@ -216,6 +220,19 @@ process.stdin.on("end", () => {
216
220
  };
217
221
  const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
218
222
  const headOf = (ws) => { let k = 0; while (k < ws.length && ws[k].indexOf("=") > 0 && ws[k].indexOf("=") < ws[k].length) k += 1; return ws.slice(k); };
223
+ // KJC-BUG-0459: `GH_CONFIG_DIR=<dir> gh ...` reads <dir>, not the hook's config.
224
+ const hostsOf = (ws) => {
225
+ const a = ws.slice(0, ws.length - headOf(ws).length).findLast((w) => w.startsWith("GH_CONFIG_DIR="));
226
+ if (!a) return null; // no assignment of its own (not the same as one naming the default dir)
227
+ const d = bare(a.slice("GH_CONFIG_DIR=".length)).replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, homedir());
228
+ return join(d, "hosts.yml");
229
+ };
230
+ // KJC-BUG-0459: a per-account gh (gh-tribbu, gh-ox: ADR 0040). The accounts guide makes
231
+ // gh-<account> run gh with GH_CONFIG_DIR=~/.config/gh-<account>: its config is READ, the
232
+ // wrapper is never executed by the hook.
233
+ const wrapperName = (w) => w.slice(w.lastIndexOf("/") + 1);
234
+ const isGhWrapper = (w) => /^gh-[A-Za-z0-9_.-]+$/.test(wrapperName(w));
235
+ const wrapperHosts = (w) => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), wrapperName(w), "hosts.yml");
219
236
  // Wrapper shells hide the payload in a string (review catch): scan inside.
220
237
  const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
221
238
  let switchedTo = null;
@@ -223,12 +240,21 @@ process.stdin.on("end", () => {
223
240
  // $( and backticks are separators too (review catch): their payload is its own segment.
224
241
  const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
225
242
  const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
226
- const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth); };
227
- const checkSeg = (ws, depth) => {
243
+ // A rescanned payload inherits the config resolved outside it (`GH_CONFIG_DIR=<dir> env gh ...`).
244
+ const scan = (text, depth, inherited = null) => {
245
+ if (depth > 3) return;
246
+ for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) {
247
+ checkSeg(headOf(words(seg)), depth, hostsOf(words(seg)) ?? inherited);
248
+ }
249
+ };
250
+ // hostsPath: the config an explicit GH_CONFIG_DIR assignment selects, or null without one.
251
+ const checkSeg = (ws, depth, hostsPath = null) => {
228
252
  if (ws.length === 0) return;
229
253
  if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/wrapper token (review catch)
230
- const j = ws.findIndex((w, i) => i > 0 && ["gh", ...WRAPPERS].includes(bare(w)));
231
- if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
254
+ const j = ws.findIndex((w, i) => i > 0 && (["gh", ...WRAPPERS].includes(bare(w)) || isGhWrapper(bare(w))));
255
+ // `env GH_CONFIG_DIR=<dir> gh ...`: the assignments before the command go with it (review catch).
256
+ const assigns = ws.slice(1, Math.max(j, 1)).map(bare).filter((w) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
257
+ if (j > 0) scan([...assigns, ...ws.slice(j).map(bare)].join(" "), depth + 1, hostsPath);
232
258
  return;
233
259
  }
234
260
  if (ws[0] === "gh") {
@@ -237,8 +263,15 @@ process.stdin.on("end", () => {
237
263
  return; // session commands ARE the remedy — never blocked
238
264
  }
239
265
  if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
240
- const eff = switchedTo || activeGh();
241
- if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
266
+ const eff = hostsPath ? activeGh(hostsPath) : (switchedTo || activeGh());
267
+ if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ... (or use the gh of that account, e.g. gh-<account>)");
268
+ }
269
+ if (isGhWrapper(ws[0])) {
270
+ if (ws[1] === "auth") return; // its session commands are the remedy too
271
+ if (!declaredGh) { problems.push(ws[0] + " without a declared identity for this clone — run: kj identity set"); return; }
272
+ // Its own config even with an inline GH_CONFIG_DIR: the wrapper sets that variable itself.
273
+ const eff = activeGh(wrapperHosts(ws[0]));
274
+ if (eff !== declaredGh) problems.push(wrapperName(ws[0]) + " runs as '" + (eff || "no session") + "' (" + wrapperHosts(ws[0]) + ") but this clone is declared as '" + declaredGh + "' — use the gh of that account");
242
275
  }
243
276
  // KJC-TSK-1085 (ADR 0015): git commit/tag/rebase/push are no longer read here:
244
277
  // pre-commit checks the author, pre-push every pushed commit and the gh session.
@@ -249,24 +282,18 @@ process.stdin.on("end", () => {
249
282
  process.exit(2);
250
283
  }
251
284
  }
252
- // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
253
- // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
285
+ // MONO-0 (ADR 0002) under ADR 0034 (KJC-TSK-1244): a write in another lane of the SAME
286
+ // repo is allowed while no other live session holds that lane; the session then holds it.
287
+ // A held lane is denied BEFORE the damage. Reads are free.
288
+ // Another lane of this repo => its toplevel; the session's own lane or no repo => null.
254
289
  const laneOf = (p) => {
255
- // Sube al primer ancestro EXISTENTE (un subdir aun no creado escapaba).
256
- let d = String(p);
257
- if (!(existsSync(d) && statSync(d).isDirectory())) d = dirname(d);
258
- while (d && !existsSync(d)) {
259
- const up = dirname(d);
260
- if (up === d) return null;
261
- d = up;
262
- }
263
- // Canonicaliza (reviewer catch: un symlink local hacia otro carril
264
- // esquivaba la clasificacion por ruta literal).
265
- try { d = realpathSync(d); } catch { /* se clasifica tal cual */ }
266
- return d ? foreignLane(d) : null;
290
+ const top = laneFor(p);
291
+ return top === laneTop() ? null : top;
267
292
  };
268
293
  const laneDeny = (what, lane) => {
269
- console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
294
+ const why = holdLane(lane);
295
+ if (!why) return false;
296
+ console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo " + why + (laneRefusal() ? " " + laneRefusal() : "") + ". Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
270
297
  return true;
271
298
  };
272
299
  if (EDIT_TOOLS.includes(tool)) {
@@ -523,10 +550,12 @@ process.stdin.on("end", () => {
523
550
  }
524
551
  } catch { /* no report or unreadable — the Steward is not adopted here */ }
525
552
  const file = input.file_path || input.notebook_path;
526
- const rel = file ? relative(ROOT, String(file)).replaceAll("\\", "/") : "";
553
+ // ADR 0034: the card, the branch and the RAG of the lane the write lands in.
554
+ const top = file ? laneFor(file) : laneTop();
555
+ const rel = file ? relative(top, String(file)).replaceAll("\\", "/") : "";
527
556
  // KJC-BUG-0279 (#1981): outside the repository there is no source to govern.
528
557
  if (rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/") && CODE.test(rel) && !TESTS.test(rel)) {
529
- const branch = branchOf();
558
+ const branch = branchOf(top);
530
559
  const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
531
560
  // ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
532
561
  if (why) {
@@ -549,7 +578,7 @@ process.stdin.on("end", () => {
549
578
  // Solo se pregunta a git en el camino lento, cuando el gate iba a
550
579
  // denegar: exit 1 = git responde que no lo conoce; cualquier otro
551
580
  // resultado se comporta como hasta ahora y bloquea.
552
- const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", ROOT, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
581
+ const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", top, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
553
582
  // A path outside this tree is another lane's business (the lane guard
554
583
  // above rules on it) and the ledger only holds in-repo paths.
555
584
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
@@ -570,7 +599,7 @@ process.stdin.on("end", () => {
570
599
  // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
571
600
  // y sigue bloqueando, porque si no el gate desapareceria en
572
601
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
573
- const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
602
+ const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: top, encoding: "utf8" });
574
603
  let blind = null;
575
604
  let absent = null;
576
605
  try {
@@ -635,6 +664,23 @@ process.stdin.on("end", () => {
635
664
  console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
636
665
  process.exit(2);
637
666
  }
667
+ // KJC-BUG-0455 (#2933): the verdict of the staged diff, checked HERE as well as in
668
+ // the pre-commit hook: a repo whose hook does not run the gate, or a review piped into
669
+ // tail that hid its failure, let a commit through with no verdict at all.
670
+ const plan = commitStaging(cmd);
671
+ if (plan.commits) {
672
+ const verdict = stagedVerdict(plan.dir ? resolve(cwd || ROOT, plan.dir) : (cwd || ROOT));
673
+ if (verdict.gate && plan.stages) {
674
+ console.error("karajan sentinel: stagea y commitea en comandos separados — el veredicto se comprueba sobre lo que ya esta stageado, y este comando stagea a la vez que commitea (git add/rm/mv, -a o rutas en git commit). Stagea por nombre, pasa kj review --staged y luego git commit." + doc("commit-verdict"));
675
+ process.exit(2);
676
+ }
677
+ // Only an approved verdict passes, or nothing staged (a merge: git itself decides).
678
+ if (verdict.gate && verdict.state !== "approved" && verdict.state !== "empty") {
679
+ const why = { rejected: "el review de este diff fue RECHAZADO por " + (verdict.reviewer || "el reviewer") + " — corrige y vuelve a pasar kj review --staged", unknown: "git no pudo leer el diff stageado, asi que el veredicto no se puede comprobar" }[verdict.state] || "no hay veredicto para el diff stageado — pasa kj review --staged (solo, sin tuberias que oculten si falla)";
680
+ console.error("karajan sentinel: commit sin veredicto aprobado: " + why + "." + doc("commit-verdict"));
681
+ process.exit(2);
682
+ }
683
+ }
638
684
  // KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
639
685
  // git add -A / git add . / git add -u put a Claude scheduler lock into
640
686
  // main; and the agent's runtime files (the .claude locks, .kj/, the local
@@ -2,7 +2,8 @@
2
2
  // Single source for every sentinel script: state, branch, classification,
3
3
  // and violations.
4
4
  import process from "node:process";
5
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
6
7
  import { execSync, spawnSync } from "node:child_process";
7
8
  import { basename, dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -34,24 +35,124 @@ export const session = (state, sid) => {
34
35
  state.sessions ||= {};
35
36
  return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
36
37
  };
37
- export const branchOf = () => {
38
- try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
39
- catch { return null; }
40
- };
41
38
  const _git = (args, cwd) => {
42
39
  try { return execSync("git " + args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
43
40
  };
41
+ // MONO-0 (ADR 0002, amended by #2566): lane identity = worktree toplevel; repo
42
+ // identity = git-common-dir. The session's lane is where the HOST says it works
43
+ // (the hook payload's cwd, which EnterWorktree moves) when that is a worktree of
44
+ // this same repo; otherwise the dir the guard is installed in. ROOT keeps the
45
+ // harness (state, config); laneTop() is the git tree the session works in.
46
+ // ADR 0033 (KJC-TSK-1236): a session switches into a lane only when no other live session
47
+ // is there. Each session keeps one presence mark (its lane and when it last resolved it) in
48
+ // the repo's git common dir. Entering is intent first, then a look: the session writes the
49
+ // new lane, then reads the others' marks and steps back if another live one is there. Two
50
+ // sessions entering together never both stay: the later write is seen by the later look.
51
+ const LANE_TTL_MS = 30 * 60 * 1000;
52
+ let _cwd = null;
53
+ let _sid = null;
44
54
  let _home;
45
- // MONO-0 (ADR 0002): lane identity = worktree toplevel; repo identity =
46
- // git-common-dir. Same repo but another toplevel than OUR root => foreign
47
- // lane (returns its toplevel); anything else => null.
48
- export const foreignLane = (targetDir) => {
49
- const top = _git("rev-parse --show-toplevel", targetDir);
50
- if (!top) return null;
51
- _home ||= { top: _git("rev-parse --show-toplevel", ROOT), common: _git("rev-parse --path-format=absolute --git-common-dir", ROOT) };
52
- if (!_home.top || top === _home.top) return null;
53
- const common = _git("rev-parse --path-format=absolute --git-common-dir", targetDir);
54
- return common && common === _home.common ? top : null;
55
+ let _refusal = null;
56
+ const homeOf = (dir) => {
57
+ const [top = "", common = ""] = _git("rev-parse --show-toplevel --path-format=absolute --git-common-dir", dir).split("\n");
58
+ return { top, common };
59
+ };
60
+ export const enterLane = (_state, sid, cwd) => {
61
+ _cwd = cwd;
62
+ _sid = sid;
63
+ _home = undefined;
64
+ _refusal = null;
65
+ };
66
+ /** Why the session was kept out of the lane its cwd is in, or null. */
67
+ export const laneRefusal = () => _refusal;
68
+ const laneKey = (s) => createHash("sha256").update(String(s)).digest("hex").slice(0, 16);
69
+ const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
70
+ const live = (at, now) => typeof at === "number" && now - at < LANE_TTL_MS;
71
+ // ADR 0034: a mark also lists the lanes the session wrote in (`held`); another session holds
72
+ // a lane when it is in it or wrote there within the period. Another session's mark that cannot
73
+ // be read or parsed throws: the caller refuses rather than read it as a free lane.
74
+ const heldByOther = (dir, top, now) => readdirSync(dir).filter((f) => f.startsWith("session-"))
75
+ .map((f) => JSON.parse(readFileSync(join(dir, f), "utf8")))
76
+ .some((r) => r?.sid !== _sid && ((r?.lane === top && live(r.at, now)) || live(r?.held?.[top], now)));
77
+ const liveHeld = (mark, now) => Object.fromEntries(Object.entries(mark?.held ?? {}).filter(([, at]) => live(at, now)));
78
+ // The lane the session may take: `lane` when it is home, already its live lane, or free.
79
+ const claimLane = (root, lane, now = Date.now()) => {
80
+ if (!_sid || !root.common) return lane;
81
+ const dir = join(root.common, "kj-lanes");
82
+ const mark = join(dir, "session-" + laneKey(_sid) + ".json");
83
+ const write = (top) => writeFileSync(mark, JSON.stringify({ sid: _sid, lane: top, at: now, held: liveHeld(readJson(mark), now) }));
84
+ try {
85
+ mkdirSync(dir, { recursive: true });
86
+ const prev = readJson(mark);
87
+ if (lane.top !== root.top && !(prev?.lane === lane.top && live(prev.at, now))) {
88
+ write(lane.top);
89
+ if (heldByOther(dir, lane.top, now)) {
90
+ _refusal = "(" + lane.top + " tiene otra sesión viva: un carril, una sesión, ADR 0033)";
91
+ lane = root;
92
+ }
93
+ }
94
+ write(lane.top);
95
+ } catch {
96
+ if (lane.top !== root.top) {
97
+ _refusal = "(no se pudo reclamar " + lane.top + ")";
98
+ lane = root;
99
+ // Best effort: a mark left in the refused lane would keep others out for 30 min.
100
+ try { write(root.top); } catch { /* the session stays home either way */ }
101
+ }
102
+ }
103
+ return lane;
104
+ };
105
+ // Resolved on first use: a hook that never asks git pays no spawn.
106
+ const home = () => {
107
+ if (_home) return _home;
108
+ const root = homeOf(ROOT);
109
+ const here = _cwd ? homeOf(_cwd) : null;
110
+ // git that cannot answer ("" common dir) never matches: fail closed to the install lane.
111
+ return (_home = claimLane(root, here?.top && root.common && here.common === root.common ? here : root));
112
+ };
113
+ export const laneTop = () => home().top || ROOT;
114
+ /**
115
+ * ADR 0034 (KJC-TSK-1244): why the session may not write in lane `top`, or null once it holds it.
116
+ * Intent first, then a look, as a switch does (ADR 0033): the hold is written, then another
117
+ * live session in that lane takes it back out. Anything unreadable refuses.
118
+ */
119
+ export const holdLane = (top, now = Date.now()) => {
120
+ const own = home();
121
+ if (top === laneTop()) return null;
122
+ if (!_sid || !own.common) return "(sin sesión identificada no se reclama " + top + ")";
123
+ const dir = join(own.common, "kj-lanes");
124
+ const mark = join(dir, "session-" + laneKey(_sid) + ".json");
125
+ try {
126
+ mkdirSync(dir, { recursive: true });
127
+ const mine = { sid: _sid, lane: own.top, at: now, ...readJson(mark) };
128
+ const held = liveHeld(mine, now);
129
+ writeFileSync(mark, JSON.stringify({ ...mine, held: { ...held, [top]: now } }));
130
+ if (!heldByOther(dir, top, now)) return null;
131
+ writeFileSync(mark, JSON.stringify({ ...mine, held }));
132
+ return "(" + top + " tiene otra sesión viva: un carril, una sesión, ADR 0033/0034)";
133
+ } catch {
134
+ return "(no se pudo reclamar " + top + ")";
135
+ }
136
+ };
137
+ export const branchOf = (top = laneTop()) => {
138
+ try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: top, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
139
+ catch { return null; }
140
+ };
141
+ // ADR 0034 (KJC-TSK-1243): the lane a write lands in is the worktree holding its path when
142
+ // that worktree is of this repo; any other path is judged in the session's lane. It walks up
143
+ // to the first existing dir (a subdir not created yet escaped) and canonicalizes it (a local
144
+ // symlink into another lane was classified by its literal path).
145
+ export const laneFor = (p) => {
146
+ let d = String(p);
147
+ while (d && !(existsSync(d) && statSync(d).isDirectory())) {
148
+ const up = dirname(d);
149
+ if (up === d) return laneTop();
150
+ d = up;
151
+ }
152
+ try { d = realpathSync(d); } catch { /* classified as is */ }
153
+ const target = homeOf(d);
154
+ const own = home();
155
+ return target.top && own.top && target.common && target.common === own.common ? target.top : laneTop();
55
156
  };
56
157
  // KJC-TSK-0765 board-sync: cards merged in this session but not yet moved in
57
158
  // the tracker. The method does not advance until the board is true.
@@ -65,6 +166,27 @@ export const bootstrapPhase = () => {
65
166
  try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
66
167
  catch { return true; }
67
168
  };
169
+ // KJC-BUG-0455 (#2933): the cross-AI verdict of the diff staged in `dir`, read the way
170
+ // `kj review --check` reads it (sha256 of `git diff --cached`, trailing blanks trimmed,
171
+ // in <top>/.karajan/reviews). gate:false when the repo has not committed the gate marker.
172
+ // state: approved | rejected | missing | empty (nothing staged) | unknown (git failed).
173
+ export const stagedVerdict = (dir) => {
174
+ const git = (args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
175
+ // No commit yet (or no repo): no committed marker exists. A git error fails closed.
176
+ if (git(["rev-parse", "--verify", "-q", "HEAD"]).status !== 0) return { gate: false };
177
+ const marker = git(["ls-tree", "--name-only", "HEAD", ".karajan/review-gate"]);
178
+ if (marker.status !== 0) return { gate: true, state: "unknown" };
179
+ if (!marker.stdout.trim()) return { gate: false };
180
+ const top = git(["rev-parse", "--show-toplevel"]).stdout?.trim();
181
+ const diff = git(["diff", "--cached"]);
182
+ if (!top || diff.status !== 0) return { gate: true, state: "unknown" };
183
+ if (!diff.stdout.trim()) return { gate: true, state: "empty" };
184
+ const hash = createHash("sha256").update(diff.stdout.trimEnd(), "utf8").digest("hex");
185
+ try {
186
+ const v = JSON.parse(readFileSync(join(top, ".karajan", "reviews", hash + ".json"), "utf8"));
187
+ return { gate: true, state: v.verdict === "approved" ? "approved" : "rejected", reviewer: v.reviewer || null };
188
+ } catch { return { gate: true, state: "missing" }; }
189
+ };
68
190
  // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
69
191
  // open. kj decides with that checkable fact, the same answer for commit and push
70
192
  // (the Stop gate still holds the turn: the board owes the move); when kj does not
@@ -81,31 +203,35 @@ export const boardGate = (sid, pend) => {
81
203
  // files dirty, or local commits the remote does not have), not the ledger: after a
82
204
  // merged PR and a sync of main the ledger stays full while the base holds nothing.
83
205
  // Anything git cannot answer (no upstream, odd path) counts as work: fail closed.
84
- const baseHoldsWork = (files) => {
206
+ const baseHoldsWork = (files, top) => {
85
207
  if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
86
208
  try {
87
- if (execSync("git status --porcelain -- " + files.join(" "), { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
88
- return execSync("git rev-list @{u}..HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
209
+ if (execSync("git status --porcelain -- " + files.join(" "), { cwd: top, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
210
+ return execSync("git rev-list @{u}..HEAD", { cwd: top, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
89
211
  } catch { return true; }
90
212
  };
91
- export const violations = (s, branch, pending = pendingMoves(s)) => {
213
+ // `top` is the tree the recorded paths are relative to: ROOT, or another lane (ADR 0034).
214
+ // laneViolations: those of every other lane the session wrote in, each named by its lane.
215
+ export const laneViolations = (s) => Object.entries(s?.lanes ?? {})
216
+ .flatMap(([top, rec]) => violations(rec, branchOf(top), [], top).map((x) => "[" + top + "] " + x));
217
+ export const violations = (s, branch, pending = pendingMoves(s), top = ROOT) => {
92
218
  const v = pending.map(pendingText);
93
219
  if (!s || !(s.edited_sources || []).length) return v;
94
220
  if (bootstrapPhase()) return v;
95
- if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
221
+ if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources, top)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
96
222
  else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
97
- if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
223
+ if (!(s.edited_tests || []).length && sessionAddsCode(s, top)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
98
224
  return v;
99
225
  };
100
226
  // KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
101
227
  // source edits only REMOVED lines owes no test. Unreadable git = conservative
102
228
  // (the violation stands): a gate must never stand down on a shrug.
103
- const sessionAddsCode = (s) => {
229
+ const sessionAddsCode = (s, top) => {
104
230
  const files = s.edited_sources || [];
105
231
  // _git builds a shell string: any path outside a strict charset stays out of
106
232
  // it — and the gate stays ARMED for it (conservative, and injection-proof).
107
233
  if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
108
- const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
234
+ const stat = _git("diff HEAD --numstat -- " + files.join(" "), top);
109
235
  if (!stat) return true;
110
236
  return stat.split(String.fromCharCode(10)).some((l) => {
111
237
  const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
@@ -300,3 +300,67 @@ export const isReadOnlyCommand = (cmd) => {
300
300
 
301
301
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
302
302
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
303
+
304
+ // KJC-BUG-0455 (#2933): a commit that stages in the same command commits a diff nobody
305
+ // could have reviewed yet: the verdict is checked against what is staged BEFORE it runs.
306
+ const COMMIT_VALUE_OPTS = new Set(["-m", "-F", "-C", "-c", "-t", "--message", "--file", "--reuse-message", "--reedit-message", "--template", "--author", "--date", "--cleanup", "--fixup", "--squash", "--trailer", "--pathspec-from-file"]);
307
+ const COMMIT_VALUE_SHORT = new Set(["m", "F", "C", "c", "t"]);
308
+ const INDEX_VERBS = new Set(["add", "rm", "mv", "stage", "restore", "reset", "apply", "update-index"]);
309
+
310
+ /** Where `git` is in a segment and its verb, skipping git's global options; null when not git. */
311
+ const gitVerbOf = (words) => {
312
+ if (words[0]?.slice(words[0].lastIndexOf("/") + 1) !== "git") return null;
313
+ let i = 1;
314
+ let dir = null;
315
+ while (i < words.length) {
316
+ const w = words[i];
317
+ if (GIT_GLOBAL_WITH_VALUE.has(w)) {
318
+ if (w === "-C") dir = words[i + 1] ?? null;
319
+ i += 2; // the option and its value
320
+ } else if (GIT_GLOBAL_FLAG.test(w)) {
321
+ i += 1;
322
+ } else {
323
+ break;
324
+ }
325
+ }
326
+ return { verb: words[i], at: i, dir };
327
+ };
328
+
329
+ /** How many words a git commit argument takes, and whether it makes the commit stage on its own. */
330
+ const commitArg = (a) => {
331
+ if (a === "--all") return { stages: true, words: 1 };
332
+ if (a.startsWith("--")) {
333
+ // --message=x carries its value; --message x takes the next word.
334
+ return { stages: false, words: COMMIT_VALUE_OPTS.has(a) ? 2 : 1 };
335
+ }
336
+ if (/^-[a-zA-Z]/.test(a)) {
337
+ const cluster = a.slice(1);
338
+ const valueAt = [...cluster].findIndex((c) => COMMIT_VALUE_SHORT.has(c));
339
+ const flags = valueAt < 0 ? cluster : cluster.slice(0, valueAt);
340
+ if (flags.includes("a")) return { stages: true, words: 1 };
341
+ // -m x takes the next word; -mx carries its value.
342
+ return { stages: false, words: valueAt === cluster.length - 1 ? 2 : 1 };
343
+ }
344
+ return { stages: true, words: 1 }; // a pathspec
345
+ };
346
+
347
+ /** -a/--all, or a path (with or without --), makes git commit stage on its own. */
348
+ const commitStagesItself = (args) => {
349
+ let i = 0;
350
+ while (i < args.length) {
351
+ if (args[i] === "--") return i + 1 < args.length;
352
+ const { stages, words } = commitArg(args[i]);
353
+ if (stages) return true;
354
+ i += words;
355
+ }
356
+ return false;
357
+ };
358
+
359
+ /** Does the command run git commit, and does it stage in the same command? → { commits, stages, dir } */
360
+ export const commitStaging = (cmd) => {
361
+ const gits = shellSegments(cmd).map((words) => ({ words, g: gitVerbOf(words) })).filter((s) => s.g);
362
+ const commits = gits.filter((s) => s.g.verb === "commit");
363
+ if (commits.length === 0) return { commits: false, stages: false, dir: null };
364
+ const stages = gits.some((s) => INDEX_VERBS.has(s.g.verb)) || commits.some((s) => commitStagesItself(s.words.slice(s.g.at + 1)));
365
+ return { commits: true, stages, dir: commits[0].g.dir };
366
+ };
@@ -6,7 +6,7 @@
6
6
  // Copied byte for byte into .karajan/harness; never fails a session (exit 0).
7
7
  import process from "node:process";
8
8
  import { spawnSync } from "node:child_process";
9
- import { CARD, ROOT, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
9
+ import { CARD, ROOT, branchOf, enterLane, load, pendingMoves } from "./sentinel-lib.mjs";
10
10
  import { rulesDriftNotice } from "./sentinel-rules.mjs";
11
11
 
12
12
  const RULES = [
@@ -25,7 +25,8 @@ let raw = "";
25
25
  process.stdin.on("data", (d) => { raw += d; });
26
26
  process.stdin.on("end", () => {
27
27
  try {
28
- const { session_id: sid = "default", source } = JSON.parse(raw || "{}");
28
+ const { session_id: sid = "default", source, cwd = null } = JSON.parse(raw || "{}");
29
+ enterLane(load(), sid, cwd);
29
30
  // KJC-TSK-0949: an MD changed and its compiled rules fell behind. Said at
30
31
  // every start, a new session included: that one gets this line and nothing else.
31
32
  const drift = rulesDriftNotice({ root: ROOT, run: spawnSync });
@@ -10,7 +10,7 @@ import { spawnSync } from "node:child_process";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
- import { doc, load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
13
+ import { doc, load, save, branchOf, enterLane, laneViolations, violations, ROOT } from "./sentinel-lib.mjs";
14
14
  // KJC-TSK-0774: when the turn ends green with cards closed, remind the user
15
15
  // where to look — the board URL if it runs (pid file alive), else how to start it.
16
16
  const boardHint = () => {
@@ -27,7 +27,7 @@ if (process.argv.includes("--status")) {
27
27
  if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
28
28
  for (const [sid, s] of sessions) {
29
29
  console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : "") + " rag=" + (s.rag_queries || []).length + " queries/" + (s.rag_hits || []).length + " sources");
30
- for (const x of violations(s, branch)) console.log(" ROJO: " + x);
30
+ for (const x of [...violations(s, branch), ...laneViolations(s)]) console.log(" ROJO: " + x);
31
31
  }
32
32
  process.exit(0);
33
33
  }
@@ -36,8 +36,9 @@ process.stdin.on("data", (d) => { raw += d; });
36
36
  process.stdin.on("end", () => {
37
37
  try {
38
38
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
39
- const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
39
+ const { session_id: sid = "default", transcript_path: transcript = null, cwd = null } = JSON.parse(raw);
40
40
  const state = load();
41
+ enterLane(state, sid, cwd);
41
42
  // Tamper check runs regardless of session state: shell indirection leaves
42
43
  // no edit-tool record, but it cannot survive `kj sentinel verify`, whose
43
44
  // root of trust is the INSTALLED kj package (outside this project tree) —
@@ -87,7 +88,7 @@ process.stdin.on("end", () => {
87
88
  if ((p.stdout || "").trim()) console.error(p.stdout.trim());
88
89
  }
89
90
  }
90
- const v = violations(s, branchOf());
91
+ const v = [...violations(s, branchOf()), ...laneViolations(s)];
91
92
  if (claimsBlock) v.push(claimsBlock);
92
93
  if (!v.length) {
93
94
  s.blocks = 0;
@@ -57,9 +57,20 @@ export function isPublishableNpm(projectDir) {
57
57
  }
58
58
  }
59
59
 
60
- /** Lockfile-detected package manager (KJC-BUG-0131): npm ci kills pnpm/yarn CI. */
60
+ const KNOWN_PMS = new Set(["npm", "pnpm", "yarn", "bun"]);
61
+
62
+ /**
63
+ * The project's package manager (KJC-BUG-0131: npm ci kills pnpm/yarn CI).
64
+ * KJC-BUG-0450 (#2789): the same answer for hooks and workflows; the
65
+ * packageManager field of package.json first, then the lockfile.
66
+ */
61
67
  export function detectPackageManager(projectDir) {
68
+ try {
69
+ const declared = String(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).packageManager || "").split("@")[0];
70
+ if (KNOWN_PMS.has(declared)) return declared;
71
+ } catch { /* no or unreadable package.json: the lockfile decides */ }
62
72
  if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
73
+ if (existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))) return "bun";
63
74
  if (existsSync(join(projectDir, "yarn.lock"))) return "yarn";
64
75
  return "npm";
65
76
  }
@@ -19,7 +19,7 @@ export const PINNED_ACTIONS = {
19
19
  setupNode: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4",
20
20
  setupPython: "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5",
21
21
  setupGo: "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5",
22
- setupPhp: "shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2",
22
+ setupPhp: "shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2",
23
23
  };
24
24
 
25
25
  export const NO_AI_ATTRIBUTION_WORKFLOW = [