karajan-code 4.70.4 → 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 (380) 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/dir-setup.js +34 -1
  61. package/src/checks/native-build.js +21 -39
  62. package/src/checks/runner.js +128 -142
  63. package/src/cli/advanced-commands.js +6 -6
  64. package/src/cli/register-meta.js +42 -82
  65. package/src/cli/register-pipeline.js +8 -143
  66. package/src/cli/register-plan.js +9 -100
  67. package/src/cli/retired-commands.js +49 -0
  68. package/src/cli.js +18 -5
  69. package/src/commands/agent-run.js +40 -26
  70. package/src/commands/audit.js +127 -124
  71. package/src/commands/board.js +5 -9
  72. package/src/commands/check.js +29 -16
  73. package/src/commands/doctor.js +2 -0
  74. package/src/commands/env.js +176 -150
  75. package/src/commands/go.js +71 -53
  76. package/src/commands/harden.js +90 -81
  77. package/src/commands/hu-start.js +52 -0
  78. package/src/commands/hu.js +288 -221
  79. package/src/commands/identity.js +111 -78
  80. package/src/commands/init-atril.js +47 -0
  81. package/src/commands/init.js +11 -40
  82. package/src/commands/install-tools.js +70 -85
  83. package/src/commands/main-guard.js +78 -0
  84. package/src/commands/policy.js +226 -184
  85. package/src/commands/qmd.js +27 -21
  86. package/src/commands/rag.js +75 -48
  87. package/src/commands/review-gate.js +421 -341
  88. package/src/commands/start.js +4 -5
  89. package/src/commands/steward.js +96 -47
  90. package/src/commands/tournament.js +46 -37
  91. package/src/commands/worktree.js +94 -25
  92. package/src/config/loader.js +34 -17
  93. package/src/config/secrets.js +36 -0
  94. package/src/config.js +1 -0
  95. package/src/environment/adr.js +16 -2
  96. package/src/git/karajan-label.js +20 -8
  97. package/src/guards/main-guard.js +54 -0
  98. package/src/harden/hook-templates.js +16 -1
  99. package/src/harden/sentinel/posttooluse.mjs +18 -6
  100. package/src/harden/sentinel/pretooluse-sentinel.mjs +64 -36
  101. package/src/harden/sentinel/sentinel-lib.mjs +129 -26
  102. package/src/harden/sentinel/sentinel-shell.mjs +5 -16
  103. package/src/harden/sentinel/sessionstart.mjs +3 -2
  104. package/src/harden/sentinel/stop.mjs +5 -4
  105. package/src/harden/workflow-templates.js +1 -1
  106. package/src/hu/board-lookup.js +29 -0
  107. package/src/hu/gh-identity.js +23 -0
  108. package/src/hu/github-board.js +4 -2
  109. package/src/hu/lane-prune.js +15 -7
  110. package/src/hu/lane-setup.js +65 -0
  111. package/src/hu/lanes.js +66 -0
  112. package/src/hu/team-board-mirror.js +3 -2
  113. package/src/infrastructure/command-runner.js +1 -1
  114. package/src/infrastructure/file-system-service.js +1 -1
  115. package/src/mcp/handlers/direct-handlers.js +2 -292
  116. package/src/mcp/handlers/hu-handlers.js +1 -35
  117. package/src/mcp/handlers/management-handlers.js +2 -42
  118. package/src/mcp/server-handlers.js +16 -22
  119. package/src/mcp/tools.js +1 -196
  120. package/src/plan/lane-plans.js +54 -0
  121. package/src/plan/plan-store.js +7 -4
  122. package/src/policy/add.js +26 -12
  123. package/src/policy/adr-number-check.js +31 -0
  124. package/src/prompts/audit.js +299 -339
  125. package/src/prompts/card-context.js +10 -5
  126. package/src/prompts/reviewer.js +5 -2
  127. package/src/rag/auto-update.js +56 -43
  128. package/src/rag/bootstrap-index.js +28 -54
  129. package/src/rag/migrate.js +1 -1
  130. package/src/rag/onnx-fallback.js +2 -2
  131. package/src/rag/pid-lease.js +70 -0
  132. package/src/rag/project-store.js +2 -12
  133. package/src/review/card-first.js +57 -46
  134. package/src/review/gate-gitignore.js +23 -4
  135. package/src/review/gate-hook.js +12 -4
  136. package/src/review/one-shot-review.js +45 -35
  137. package/src/review/publish-pr.js +14 -2
  138. package/src/review/repo-docs.js +73 -0
  139. package/src/review/repo-stack.js +59 -0
  140. package/src/review/reviewer-fallback.js +19 -4
  141. package/src/review/seal-commit.js +29 -0
  142. package/src/review/sonar-pregate.js +59 -44
  143. package/src/review/third-party-review.js +90 -16
  144. package/src/review/third-party-size.js +43 -0
  145. package/src/review/third-party-skills.js +54 -0
  146. package/src/review/verify-pr.js +1 -24
  147. package/src/roles/audit-role.js +106 -125
  148. package/src/skills/atril-skills.js +5 -4
  149. package/src/sonar/scanner.js +116 -101
  150. package/src/steward/invariants.js +5 -4
  151. package/src/steward/phantom-sweep.js +66 -0
  152. package/src/tournament/judge.js +60 -53
  153. package/src/utils/gh-account.js +37 -0
  154. package/src/utils/git.js +0 -13
  155. package/src/utils/home-migration.js +7 -1
  156. package/src/utils/main-checkout.js +53 -0
  157. package/src/utils/qmd-collection.js +3 -1
  158. package/src/utils/welcome.js +3 -3
  159. package/templates/{skills → atril/commands}/kj-architect.md +5 -0
  160. package/templates/{skills → atril/commands}/kj-audit.md +5 -0
  161. package/templates/{skills → atril/commands}/kj-board.md +5 -0
  162. package/templates/{skills → atril/commands}/kj-code.md +5 -0
  163. package/templates/{skills → atril/commands}/kj-csv-transform.md +5 -0
  164. package/templates/{skills → atril/commands}/kj-data-report.md +5 -0
  165. package/templates/{skills → atril/commands}/kj-discover.md +5 -0
  166. package/templates/{skills → atril/commands}/kj-rag-query.md +5 -0
  167. package/templates/{skills → atril/commands}/kj-review.md +5 -0
  168. package/templates/{skills → atril/commands}/kj-security.md +5 -0
  169. package/templates/{skills → atril/commands}/kj-sonar.md +5 -0
  170. package/templates/{skills → atril/commands}/kj-sql-analysis.md +5 -0
  171. package/templates/{skills → atril/commands}/kj-test.md +5 -0
  172. package/packages/hu-board/public/utils/run-launcher.js +0 -169
  173. package/packages/hu-board/src/run-tracker.js +0 -121
  174. package/src/activity-log.js +0 -59
  175. package/src/agents/host-agent.js +0 -38
  176. package/src/agents/model-registry.js +0 -142
  177. package/src/audit/plan-adherence.js +0 -224
  178. package/src/autonomy/arbiter-role.js +0 -71
  179. package/src/autonomy/arbiter.js +0 -22
  180. package/src/autonomy/decider.js +0 -18
  181. package/src/autonomy/decision-resolver.js +0 -58
  182. package/src/autonomy/outcome-report.js +0 -55
  183. package/src/autonomy/stage-ask.js +0 -30
  184. package/src/brain/decision-tracker.js +0 -125
  185. package/src/brain/decisor.js +0 -243
  186. package/src/brain/solomon-consult.js +0 -171
  187. package/src/brain/standby-scheduler.js +0 -8
  188. package/src/budget/comparison.js +0 -96
  189. package/src/canvas/parse-md.js +0 -238
  190. package/src/canvas/schema.js +0 -101
  191. package/src/canvas/to-flat.js +0 -85
  192. package/src/canvas/validate.js +0 -163
  193. package/src/ci/dispatch.js +0 -99
  194. package/src/ci/index.js +0 -3
  195. package/src/ci/pr-diff.js +0 -26
  196. package/src/cli/register-standby.js +0 -26
  197. package/src/commands/architect.js +0 -111
  198. package/src/commands/autorun.js +0 -49
  199. package/src/commands/researcher.js +0 -54
  200. package/src/commands/resume.js +0 -72
  201. package/src/commands/review.js +0 -89
  202. package/src/commands/run.js +0 -185
  203. package/src/commands/standby.js +0 -117
  204. package/src/commands/status.js +0 -36
  205. package/src/commands/triage.js +0 -78
  206. package/src/commands/undo.js +0 -62
  207. package/src/domains/domain-loader.js +0 -245
  208. package/src/domains/domain-registry.js +0 -140
  209. package/src/domains/domain-synthesizer.js +0 -129
  210. package/src/git/automation.js +0 -433
  211. package/src/git/coder-commit-guard.js +0 -40
  212. package/src/git/hu-automation.js +0 -243
  213. package/src/git/hu-snapshot.js +0 -10
  214. package/src/git/symlink-targets.js +0 -57
  215. package/src/golden/asserter.js +0 -92
  216. package/src/golden/loader.js +0 -52
  217. package/src/golden/runner.js +0 -92
  218. package/src/golden/schema.js +0 -41
  219. package/src/guards/duplicate-members.js +0 -86
  220. package/src/guards/intent-guard.js +0 -161
  221. package/src/guards/perf-guard.js +0 -126
  222. package/src/guards/policy-guard.js +0 -90
  223. package/src/guards/policy-resolver.js +0 -85
  224. package/src/guards/secret-redactor.js +0 -71
  225. package/src/hu/acceptance-runner.js +0 -138
  226. package/src/hu/lazy-planner.js +0 -109
  227. package/src/hu/parallel-executor.js +0 -94
  228. package/src/hu/routing-metrics.js +0 -17
  229. package/src/hu/split-validator.js +0 -143
  230. package/src/hu/splitting-detector.js +0 -158
  231. package/src/hu/splitting-generator.js +0 -175
  232. package/src/hu/store.js +0 -394
  233. package/src/infrastructure/mocks.js +0 -182
  234. package/src/mcp/handlers/run-handler.js +0 -318
  235. package/src/mcp/sovereignty-guard.js +0 -113
  236. package/src/mcp/tool-arg-normalizers.js +0 -16
  237. package/src/mutate/reviewer-signal.js +0 -76
  238. package/src/orchestrator/agent-fallback.js +0 -81
  239. package/src/orchestrator/brain-coordinator.js +0 -186
  240. package/src/orchestrator/ci-integration.js +0 -139
  241. package/src/orchestrator/config-init.js +0 -426
  242. package/src/orchestrator/direct-actions.js +0 -217
  243. package/src/orchestrator/drivers/error-recovery.js +0 -287
  244. package/src/orchestrator/drivers/init-context.js +0 -238
  245. package/src/orchestrator/drivers/iteration-loop.js +0 -382
  246. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +0 -69
  247. package/src/orchestrator/drivers/iteration-phases/guards.js +0 -91
  248. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +0 -29
  249. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +0 -125
  250. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +0 -62
  251. package/src/orchestrator/drivers/post-loop.js +0 -404
  252. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +0 -137
  253. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +0 -47
  254. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +0 -84
  255. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +0 -393
  256. package/src/orchestrator/drivers/pre-loop.js +0 -524
  257. package/src/orchestrator/drivers/run-hu-batch.js +0 -468
  258. package/src/orchestrator/feedback-enrichment.js +0 -140
  259. package/src/orchestrator/feedback-queue.js +0 -151
  260. package/src/orchestrator/flow-control.js +0 -170
  261. package/src/orchestrator/flow-runner.js +0 -365
  262. package/src/orchestrator/fs-leak-detector.js +0 -228
  263. package/src/orchestrator/hu-scheduler.js +0 -114
  264. package/src/orchestrator/hu-sub-pipeline.js +0 -642
  265. package/src/orchestrator/iteration-gate.js +0 -73
  266. package/src/orchestrator/iteration-stages.js +0 -9
  267. package/src/orchestrator/parallel-limiter.js +0 -74
  268. package/src/orchestrator/pipeline-context.js +0 -72
  269. package/src/orchestrator/post-loop-stages.js +0 -468
  270. package/src/orchestrator/pre-loop-stages.js +0 -11
  271. package/src/orchestrator/preflight-checks.js +0 -487
  272. package/src/orchestrator/repair/repair-detector.js +0 -117
  273. package/src/orchestrator/repair/repair-runner.js +0 -142
  274. package/src/orchestrator/reviewer-fallback.js +0 -45
  275. package/src/orchestrator/role-output-compressor.js +0 -189
  276. package/src/orchestrator/session-journal.js +0 -308
  277. package/src/orchestrator/solomon-escalation.js +0 -331
  278. package/src/orchestrator/solomon-rules.js +0 -212
  279. package/src/orchestrator/stages/acceptance-coverage-stage.js +0 -47
  280. package/src/orchestrator/stages/architect-stage.js +0 -172
  281. package/src/orchestrator/stages/coder-stage.js +0 -506
  282. package/src/orchestrator/stages/domain-curator-stage.js +0 -82
  283. package/src/orchestrator/stages/format-stage.js +0 -61
  284. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +0 -127
  285. package/src/orchestrator/stages/hu-reviewer-stage.js +0 -419
  286. package/src/orchestrator/stages/perf-stage.js +0 -140
  287. package/src/orchestrator/stages/planner-stage.js +0 -85
  288. package/src/orchestrator/stages/rag-context-stage.js +0 -80
  289. package/src/orchestrator/stages/rag-preload-decisor.js +0 -58
  290. package/src/orchestrator/stages/research-stage.js +0 -69
  291. package/src/orchestrator/stages/reviewer-stage.js +0 -431
  292. package/src/orchestrator/stages/sonar-stage.js +0 -439
  293. package/src/orchestrator/stages/stage-classes.js +0 -84
  294. package/src/orchestrator/stages/stage-executor.js +0 -155
  295. package/src/orchestrator/stages/suite-green-stage.js +0 -62
  296. package/src/orchestrator/stages/tdd-discipline-stage.js +0 -56
  297. package/src/orchestrator/stages/tool-judge-stage.js +0 -53
  298. package/src/orchestrator/stages/triage-stage.js +0 -172
  299. package/src/orchestrator/standby.js +0 -71
  300. package/src/orchestrator/verification-gate.js +0 -183
  301. package/src/orchestrator.js +0 -22
  302. package/src/plan/adr-generator.js +0 -170
  303. package/src/plan/adr-loader.js +0 -60
  304. package/src/plan/plan-executor.js +0 -100
  305. package/src/plan/plan-validation.js +0 -4
  306. package/src/prompts/acceptance-coverage.js +0 -32
  307. package/src/prompts/arbiter.js +0 -38
  308. package/src/prompts/architect.js +0 -103
  309. package/src/prompts/hu-reviewer.js +0 -244
  310. package/src/prompts/tool-correctness-judge.js +0 -31
  311. package/src/prompts/triage.js +0 -57
  312. package/src/rag/embedder.js +0 -2
  313. package/src/rag/embedders/_cloud-base.js +0 -2
  314. package/src/rag/embedders/cohere.js +0 -2
  315. package/src/rag/embedders/mistral.js +0 -2
  316. package/src/rag/embedders/onnx.js +0 -2
  317. package/src/rag/embedders/openai.js +0 -2
  318. package/src/rag/embedders/voyage.js +0 -2
  319. package/src/rag/rerank.js +0 -4
  320. package/src/rag/where-parser.js +0 -4
  321. package/src/repeat-detector.js +0 -88
  322. package/src/review/diff-generator.js +0 -112
  323. package/src/review/git-stash-helper.js +0 -104
  324. package/src/review/schema.js +0 -31
  325. package/src/review/scope-filter.js +0 -152
  326. package/src/review/snapshot-diff.js +0 -122
  327. package/src/review/tdd-discipline.js +0 -90
  328. package/src/review/tdd-policy.js +0 -69
  329. package/src/roles/acceptance-coverage-role.js +0 -78
  330. package/src/roles/architect-role.js +0 -77
  331. package/src/roles/coder-role.js +0 -113
  332. package/src/roles/commiter-role.js +0 -113
  333. package/src/roles/domain-curator-role.js +0 -134
  334. package/src/roles/hu-reviewer-role.js +0 -64
  335. package/src/roles/impeccable-role.js +0 -85
  336. package/src/roles/index.js +0 -18
  337. package/src/roles/karajan-brain-role.js +0 -134
  338. package/src/roles/perf-role.js +0 -122
  339. package/src/roles/planner-role.js +0 -151
  340. package/src/roles/refactorer-role.js +0 -30
  341. package/src/roles/repairer-role.js +0 -181
  342. package/src/roles/researcher-role.js +0 -70
  343. package/src/roles/reviewer-role.js +0 -115
  344. package/src/roles/security-role.js +0 -73
  345. package/src/roles/solomon-role.js +0 -271
  346. package/src/roles/sonar-role.js +0 -72
  347. package/src/roles/tester-role.js +0 -235
  348. package/src/roles/tool-correctness-judge-role.js +0 -78
  349. package/src/roles/triage-role.js +0 -99
  350. package/src/session/journal/decisions-writer.js +0 -200
  351. package/src/session/journal/iteration-logger.js +0 -194
  352. package/src/session/journal/summary-writer.js +0 -320
  353. package/src/session/journal/tree-writer.js +0 -164
  354. package/src/session/journal/write-all.js +0 -89
  355. package/src/session-cleanup.js +0 -116
  356. package/src/skills/semantic-detector.js +0 -167
  357. package/src/skills/skill-detector.js +0 -385
  358. package/src/sonar/cloud-scanner.js +0 -78
  359. package/src/tracker-bootstrap.js +0 -44
  360. package/src/types/index.js +0 -38
  361. package/src/types/policy.js +0 -43
  362. package/src/types/solomon.js +0 -87
  363. package/src/utils/async-lock.js +0 -31
  364. package/src/utils/budget.js +0 -229
  365. package/src/utils/cwd-confirm.js +0 -39
  366. package/src/utils/display/event-handlers.js +0 -403
  367. package/src/utils/display/formatters.js +0 -132
  368. package/src/utils/display/header.js +0 -47
  369. package/src/utils/display/session-summary.js +0 -118
  370. package/src/utils/display/solomon.js +0 -42
  371. package/src/utils/pricing.js +0 -29
  372. package/src/utils/quota-simulator.js +0 -56
  373. package/src/utils/role-assigner.js +0 -137
  374. package/src/utils/rtk-wrapper.js +0 -87
  375. package/src/utils/run-registry.js +0 -9
  376. package/src/utils/status-dashboard.js +0 -254
  377. package/src/utils/tool-call-extractor.js +0 -56
  378. package/src/webperf/devtools-detect.js +0 -112
  379. package/src/webperf/webperf-role-integration.js +0 -66
  380. package/templates/skills/kj-run.md +0 -73
@@ -1,1382 +1,35 @@
1
+ // The /api root of the board (KJC-TSK-0541): it only composes the routers of each resource,
2
+ // so server.js mounts one router and every test that mounts it reaches every endpoint.
1
3
  import { Router } from 'express';
2
- import fs from 'node:fs';
3
- import fsp from 'node:fs/promises';
4
-
5
- /** Async existence probe (KJC-TSK-0539 — no sync fs in handlers). */
6
- const pathExists = (p) => fsp.access(p).then(() => true, () => false);
7
-
8
- /**
9
- * mtime-keyed cache for parsed plan JSON (KJC-TSK-0539). The plan scans
10
- * used to re-read and re-parse every plan file on EVERY request; with N
11
- * plans and the board polling, that was O(N) parses per poll. The cache
12
- * serves the parsed object while the file's mtime is unchanged and
13
- * re-reads transparently when kj writes the plan.
14
- */
15
- const planFileCache = new Map();
16
- export async function readPlanCached(p) {
17
- const { mtimeMs } = await fsp.stat(p);
18
- const hit = planFileCache.get(p);
19
- if (hit && hit.mtimeMs === mtimeMs) return hit.plan;
20
- const plan = JSON.parse(await fsp.readFile(p, 'utf8'));
21
- planFileCache.set(p, { mtimeMs, plan });
22
- return plan;
23
- }
24
- import path from 'node:path';
25
- import { spawn as spawnChild } from 'node:child_process';
26
- import {
27
- getDashboardStats,
28
- getProjects,
29
- getStoriesByProject,
30
- getStoryDetail,
31
- getSessionsByProject,
32
- getSessionDetail,
33
- deleteProject,
34
- deleteSession,
35
- getKjHome,
36
- getHuBoardRunsDir,
37
- getHuBoardPlansDir,
38
- getHuBoardPlansDirs,
39
- getStoryRow,
40
- listPlanIdsForProject,
41
- updateStoryStatus,
42
- setProjectIsTest,
43
- getProjectCost,
44
- } from '../db.js';
45
- import { fullScan } from '../sync.js';
46
- import { setHuStatus, setHuFields, markPlanReady, runPlan, renameProject, revertHuFromSnapshot } from '../plan-mutations.js';
47
- import { getActiveRuns } from '../run-tracker.js';
48
- import { subscribe as subscribeEvents } from '../event-bus.js';
49
- import { runKjCommand, listSupportedCommands } from '../command-runner.js';
50
- import { runPreflight } from '../preflight.js';
51
- import { readConfig, writeConfigPatch } from '../config-yaml.js';
52
- import { requestProjectDir } from '../project-dir.js';
53
- import { BOOT_TIME, PKG_VERSION } from '../boot-info.js';
54
- import { addTombstone, getDb, isTombstoned as isTombstonedFn, removeTombstone, listTombstones } from '../db.js';
55
- import { publish as publishEvent } from '../event-bus.js';
4
+ import systemRouter from './api/system.js';
5
+ import projectsRouter from './api/projects.js';
6
+ import storiesRouter from './api/stories.js';
7
+ import sessionsRouter from './api/sessions.js';
8
+ import promptsRouter from './api/prompts.js';
9
+ import plansRouter from './api/plans.js';
10
+ import tombstonesRouter from './api/tombstones.js';
11
+ import commandsRouter from './api/commands.js';
56
12
 
57
13
  const router = Router();
58
14
 
59
- /**
60
- * GET /api/version - Server version + boot timestamp.
61
- * The client polls this every 30s; if `boot_time` differs from the first
62
- * response, the server restarted and the client triggers a reload to pick
63
- * up any new HTML/JS (KJC-TSK-0379).
64
- */
65
- router.get('/version', (_req, res) => {
66
- res.set('Cache-Control', 'no-store');
67
- res.json({ version: PKG_VERSION, boot_time: BOOT_TIME });
68
- });
69
-
70
- /**
71
- * GET /api/standby — KJC-TSK-0414 PR4. Lista sesiones hibernadas.
72
- * Devuelve [{ sessionId, planId, huId, role, cooldownUntil, reason, ... }]
73
- * El front renderiza un badge "💤 standby · resume en HH:MM:SS" por sesión.
74
- */
75
- router.get('/standby', async (_req, res) => {
76
- try {
77
- // KJC-TSK-0632: resolve from karajan-core directly — the CLI's
78
- // src/brain/standby-store.js is just a re-export shim of this.
79
- const { listPendingStandby } = await import('karajan-core/standby-store');
80
- const sessions = listPendingStandby();
81
- res.set('Cache-Control', 'no-store');
82
- res.json({ sessions });
83
- } catch (err) {
84
- res.status(500).json({ error: err.message });
85
- }
86
- });
87
-
88
- /**
89
- * Resolve the hu-stories dir where batch.json files live.
90
- */
91
- function huStoriesDir() {
92
- return path.join(getKjHome(), 'hu-stories');
93
- }
94
-
95
- /**
96
- * GET /api/dashboard - Global dashboard statistics.
97
- */
98
- router.get('/dashboard', (_req, res) => {
99
- try {
100
- const stats = getDashboardStats();
101
- res.json(stats);
102
- } catch (err) {
103
- res.status(500).json({ error: err.message });
104
- }
105
- });
106
-
107
- /**
108
- * GET /api/projects - List all projects with story counts.
109
- */
110
- router.get('/projects', (_req, res) => {
111
- try {
112
- const projects = getProjects();
113
- res.json(projects);
114
- } catch (err) {
115
- res.status(500).json({ error: err.message });
116
- }
117
- });
118
-
119
- /**
120
- * GET /api/projects/:id - Project detail.
121
- */
122
- router.get('/projects/:id', (req, res) => {
123
- try {
124
- const projects = getProjects();
125
- const project = projects.find((p) => p.id === req.params.id);
126
- if (!project) return res.status(404).json({ error: 'Project not found' });
127
- res.json(project);
128
- } catch (err) {
129
- res.status(500).json({ error: err.message });
130
- }
131
- });
132
-
133
- /**
134
- * PUT /api/projects/:id/name — PR-G: rename a project.
135
- *
136
- * Updates plan.name on every plan JSON belonging to the project AND
137
- * the projects.name column directly so the UI reflects the new name
138
- * on the next render. Body: { name: "Linux Assistant Orchestrator" }.
139
- */
140
- router.put('/projects/:id/name', (req, res) => {
141
- try {
142
- const result = renameProject({ projectId: req.params.id, name: req.body?.name });
143
- if (!result.ok) {
144
- const code = /no encontrado/i.test(result.error) ? 404 : 400;
145
- return res.status(code).json({ error: result.error });
146
- }
147
- res.json(result);
148
- } catch (err) {
149
- res.status(500).json({ error: err.message });
150
- }
151
- });
152
-
153
- /**
154
- * PATCH /api/projects/:id/is-test — KJC-TSK-0371 (board polish #3).
155
- *
156
- * Sets/clears the per-project ephemeral-cleanup override. The
157
- * heuristic (id under tmp_/test_/demo_/kj-test- AND last_activity
158
- * older than 24h → auto-delete on next board start) is the default.
159
- * `is_test` lets the user override per-project:
160
- * - body { is_test: 1 } → always treat as ephemeral
161
- * - body { is_test: 0 } → NEVER auto-clean (keep this one)
162
- * - body { is_test: null } → revert to the default heuristic
163
- *
164
- * Sending anything else is a 400 — explicit ternary, no truthy/
165
- * falsy coercion, because that exact distinction is what the
166
- * cleaner relies on.
167
- */
168
- router.patch('/projects/:id/is-test', (req, res) => {
169
- const raw = req.body?.is_test;
170
- // Accept the three legal sentinel values explicitly — undefined
171
- // (key missing) is rejected because this endpoint only exists to
172
- // set or clear, not to "leave as-is".
173
- const allowed = raw === 0 || raw === 1 || raw === null;
174
- if (!allowed) {
175
- return res.status(400).json({ error: 'is_test must be 0, 1, or null' });
176
- }
177
- try {
178
- const ok = setProjectIsTest(req.params.id, raw);
179
- if (!ok) return res.status(404).json({ error: 'project not found' });
180
- res.json({ ok: true, project_id: req.params.id, is_test: raw });
181
- } catch (err) {
182
- res.status(500).json({ error: err.message });
183
- }
184
- });
185
-
186
- /**
187
- * GET /api/projects/:id/preflight — Project readiness checks.
188
- *
189
- * Returns the same shape regardless of the project's state. Each
190
- * check carries a status (ok|warn|fail|info), a plain-Spanish
191
- * detail and (when relevant) a `consequence` describing what the
192
- * user loses if it stays unfixed. Powers both:
193
- * - the panel at the top of the board (always visible)
194
- * - the safety modal that appears before ▶ Run plan / ▶ Run HU
195
- * when there are blockers or warnings
196
- *
197
- * The route resolves projectDir by reading the first plan it can
198
- * find for this project (plans carry projectDir since v2.7.4). If
199
- * the project has no plans on disk we still return the env-only
200
- * checks (Node, Karajan, agents, config) so the user gets feedback
201
- * even before they generate their first plan.
202
- */
203
- router.get('/projects/:id/preflight', async (req, res) => {
204
- try {
205
- const projectId = req.params.id;
206
- let projectDir = null;
207
- // KJC-BUG-0059 (v2.19.3): scan plans in BOTH the canonical
208
- // ~/.karajan/plans/ AND the legacy ~/.kj/plans/ — the legacy
209
- // fallback covers users who started the board before running any
210
- // `kj` command on a fresh install (so the auto-migrator from
211
- // v2.19.0 has not fired yet). Pre-fix, this endpoint hard-coded
212
- // ~/.kj/plans/ as the default, missing every plan post-2.19.0
213
- // → board card showed "directorio del proyecto: no detectado"
214
- // even though the plan on disk had a valid projectDir.
215
- try {
216
- outer: for (const root of getHuBoardPlansDirs()) {
217
- const plansDir = path.join(root, projectId);
218
- if (!(await pathExists(plansDir))) continue;
219
- for (const f of (await fsp.readdir(plansDir)).filter((x) => x.endsWith('.json'))) {
220
- try {
221
- const plan = await readPlanCached(path.join(plansDir, f));
222
- if (plan?.projectDir) { projectDir = plan.projectDir; break outer; }
223
- } catch { /* try next file */ }
224
- }
225
- }
226
- } catch { /* projectDir stays null → preflight will report it */ }
227
- const result = await runPreflight({ projectId, projectDir });
228
- res.json(result);
229
- } catch (err) {
230
- res.status(500).json({ error: err.message });
231
- }
232
- });
233
-
234
- /**
235
- * GET /api/projects/:id/plans-outcome — Plan-level rollups (PR3).
236
- *
237
- * Reads every plan JSON for the project and returns the lightweight
238
- * shape the board needs to render the "Plan finalizado · 8 done · 2
239
- * failed · 15 min" banner. Plans that haven't finished yet have a
240
- * null outcome — the front handles that by hiding the banner.
241
- */
242
- router.get('/projects/:id/plans-outcome', async (req, res) => {
243
- try {
244
- const projectId = req.params.id;
245
- // KJC-BUG-0059 (v2.19.3): scan canonical + legacy roots so users
246
- // who have not yet run the auto-migrator still see their plans.
247
- const out = [];
248
- for (const root of getHuBoardPlansDirs()) {
249
- const plansDir = path.join(root, projectId);
250
- if (!(await pathExists(plansDir))) continue;
251
- for (const f of (await fsp.readdir(plansDir)).filter(n => n.endsWith('.json'))) {
252
- try {
253
- const plan = await readPlanCached(path.join(plansDir, f));
254
- if (plan?.version !== 2) continue;
255
- out.push({
256
- planId: plan.planId,
257
- name: plan.name || (plan.task ? plan.task.slice(0, 80) : plan.planId),
258
- status: plan.status || 'draft',
259
- updatedAt: plan.updatedAt || null,
260
- outcome: plan.outcome || null,
261
- huCount: Array.isArray(plan.hus) ? plan.hus.length : 0,
262
- });
263
- } catch { /* skip unreadable plan */ }
264
- }
265
- }
266
- res.json({ projectId, plans: out });
267
- } catch (err) {
268
- res.status(500).json({ error: err.message });
269
- }
270
- });
271
-
272
- /**
273
- * GET /api/projects/:id/stories - Stories for a project.
274
- */
275
- router.get('/projects/:id/stories', (req, res) => {
276
- try {
277
- const stories = getStoriesByProject(req.params.id);
278
- res.json(stories);
279
- } catch (err) {
280
- res.status(500).json({ error: err.message });
281
- }
282
- });
283
-
284
- /**
285
- * GET /api/stories/:id - Story detail with quality scores and context requests.
286
- */
287
- router.get('/stories/:id', (req, res) => {
288
- try {
289
- const story = getStoryDetail(req.params.id);
290
- if (!story) return res.status(404).json({ error: 'Story not found' });
291
- res.json(story);
292
- } catch (err) {
293
- res.status(500).json({ error: err.message });
294
- }
295
- });
296
-
297
- /**
298
- * GET /api/projects/:id/cost - Aggregated $ cost for a project (KJC-TSK-0516).
299
- * Sums stories.cost_usd, groups by plan_id, returns 404 if project missing.
300
- */
301
- router.get('/projects/:id/cost', (req, res) => {
302
- try {
303
- const cost = getProjectCost(req.params.id);
304
- if (!cost) return res.status(404).json({ error: 'Project not found' });
305
- res.json(cost);
306
- } catch (err) {
307
- res.status(500).json({ error: err.message });
308
- }
309
- });
310
-
311
- /**
312
- * GET /api/projects/:id/sessions - Sessions for a project.
313
- */
314
- router.get('/projects/:id/sessions', (req, res) => {
315
- try {
316
- const sessions = getSessionsByProject(req.params.id);
317
- res.json(sessions);
318
- } catch (err) {
319
- res.status(500).json({ error: err.message });
320
- }
321
- });
322
-
323
- /**
324
- * GET /api/sessions/:id - Session detail with stages, commits, duration.
325
- */
326
- router.get('/sessions/:id', (req, res) => {
327
- try {
328
- const session = getSessionDetail(req.params.id);
329
- if (!session) return res.status(404).json({ error: 'Session not found' });
330
- // Parse JSON fields for the response
331
- const parsed = { ...session };
332
- for (const field of ['checkpoints', 'llm_calls', 'config_snapshot', 'budget', 'commits', 'stages_completed']) {
333
- if (parsed[field] && typeof parsed[field] === 'string') {
334
- try { parsed[field] = JSON.parse(parsed[field]); } catch { /* keep as string */ }
335
- }
336
- }
337
- res.json(parsed);
338
- } catch (err) {
339
- res.status(500).json({ error: err.message });
340
- }
341
- });
342
-
343
- /**
344
- * GET /api/sessions - All sessions across all projects.
345
- */
346
- router.get('/sessions', (_req, res) => {
347
- try {
348
- const allProjects = getProjects();
349
- const sessions = [];
350
- for (const p of allProjects) {
351
- sessions.push(...getSessionsByProject(p.id));
352
- }
353
- // Also get default project sessions
354
- sessions.push(...getSessionsByProject('default'));
355
- // Deduplicate
356
- const seen = new Set();
357
- const unique = sessions.filter((s) => {
358
- if (seen.has(s.id)) return false;
359
- seen.add(s.id);
360
- return true;
361
- });
362
- unique.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
363
- res.json(unique);
364
- } catch (err) {
365
- res.status(500).json({ error: err.message });
366
- }
367
- });
368
-
369
- /**
370
- * DELETE /api/projects/:id - Cascade delete project + its stories + sessions.
371
- * Also removes the hu-stories/<id>/ directory from disk so the next sync
372
- * does not re-import it.
373
- */
374
- router.delete('/projects/:id', async (req, res) => {
375
- try {
376
- const id = req.params.id;
377
- // Gather filesystem paths to remove BEFORE deleting DB rows so we
378
- // can stop the next sync from resurrecting anything.
379
- const db = getDb();
380
- const storyIds = db.prepare('SELECT id FROM stories WHERE project_id = ?').all(id).map((r) => r.id);
381
- const sessionIds = db.prepare('SELECT id FROM sessions WHERE project_id = ?').all(id).map((r) => r.id);
382
- // KJC-BUG-0055: honour KJ_PLANS_DIR (the env override the sync /
383
- // cleanup-zombies layers already respect). Previously hardcoded to
384
- // ~/.kj/plans relative to KJ_HOME's parent, which broke when the
385
- // two homes were configured separately and left orphan plan dirs
386
- // on disk after a 🗑️ delete.
387
- // KJC-BUG-0059 (v2.19.3): when removing a deleted project's
388
- // residual plan dirs, sweep BOTH the canonical and legacy roots
389
- // so the cleanup is consistent for users mid-migration.
390
- const fsPaths = [
391
- ...storyIds.map((sid) => path.join(huStoriesDir(), sid)),
392
- ...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
393
- ...getHuBoardPlansDirs().map((root) => path.join(root, id)),
394
- ];
395
-
396
- const existed = deleteProject(id);
397
- if (!existed) return res.status(404).json({ error: 'Project not found' });
398
-
399
- // Tombstone the project AND its children so chokidar / fullScan
400
- // can't bring them back via syncStoryFile / syncSessionFile.
401
- addTombstone('project', id, { source: 'api', fsPaths });
402
- for (const sid of storyIds) addTombstone('story', sid, { source: 'api' });
403
- for (const sid of sessionIds) addTombstone('session', sid, { source: 'api' });
404
-
405
- // Best-effort fs cleanup; failure does NOT undo the tombstones.
406
- let pathsRemoved = 0;
407
- for (const p of fsPaths) {
408
- try { await fsp.rm(p, { recursive: true, force: true }); pathsRemoved++; } catch { /* */ }
409
- }
410
- res.json({ deleted: true, pathsRemoved, tombstoned: 1 + storyIds.length + sessionIds.length });
411
- } catch (err) {
412
- res.status(500).json({ error: err.message });
413
- }
414
- });
415
-
416
- /**
417
- * DELETE /api/prompts/:promptId - Discard a pending prompt the runner left
418
- * behind (KJC-TSK-0380). Removes the RPC files from disk, tombstones the
419
- * prompt id so a stale chokidar event can't recreate it, and emits a
420
- * `prompt:resolved` event so all open boards close the modal.
421
- */
422
- router.delete('/prompts/:promptId', async (req, res) => {
423
- try {
424
- const promptId = req.params.promptId;
425
- const dir = promptsDir();
426
- const main = path.join(dir, `${promptId}.json`);
427
- const answer = path.join(dir, `${promptId}.answer.json`);
428
- let removed = 0;
429
- for (const p of [main, answer]) {
430
- try { await fsp.unlink(p); removed++; } catch { /* missing is fine */ }
431
- }
432
- addTombstone('prompt', promptId, { source: 'api', fsPaths: [main, answer] });
433
- // Re-use the same event the runner emits when answered, so any open
434
- // modal in any connected board client closes itself.
435
- publishEvent({ type: 'prompt-resolved', promptId });
436
- res.json({ deleted: true, filesRemoved: removed });
437
- } catch (err) {
438
- res.status(500).json({ error: err.message });
439
- }
440
- });
441
-
442
- /**
443
- * DELETE /api/stories/:id - Delete a single story.
444
- *
445
- * Tombstones the story (KJC-TSK-0380) and removes its batch directory
446
- * from `~/.karajan/hu-stories/<id>/` so the next sync does NOT re-import
447
- * it. Pre-tombstones this endpoint was a DB-only soft hide that the next
448
- * chokidar event undid silently.
449
- */
450
- router.delete('/stories/:id', (req, res) => {
451
- // KJC-TSK-0671 (absolute product rule): stories are NEVER deleted — the
452
- // delete-and-recreate "fix" agents improvise loses history and produced
453
- // the field saga this closes. Discard goes through the lifecycle instead.
454
- res.status(405).json({
455
- error: 'Stories are never deleted. Discard one with: kj hu move <id> skipped '
456
- + '(archived, history kept). Dashboard warnings are fixed with the command '
457
- + 'they name — see kj brief board.',
458
- });
459
- });
460
-
461
- /**
462
- * DELETE /api/sessions/:id - Delete a single session.
463
- *
464
- * Tombstones + removes `~/.karajan/sessions/<id>/` (KJC-TSK-0380).
465
- */
466
- router.delete('/sessions/:id', async (req, res) => {
467
- try {
468
- const id = req.params.id;
469
- const dir = path.join(getKjHome(), 'sessions', id);
470
- const ok = deleteSession(id);
471
- if (!ok) return res.status(404).json({ error: 'Session not found' });
472
- addTombstone('session', id, { source: 'api', fsPaths: [dir] });
473
- let dirRemoved = false;
474
- try { await fsp.rm(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
475
- res.json({ deleted: true, dirRemoved });
476
- } catch (err) {
477
- res.status(500).json({ error: err.message });
478
- }
479
- });
480
-
481
- /**
482
- * DELETE /api/plans/:planId - Delete a single plan file.
483
- *
484
- * Removes `~/.kj/plans/<projectSlug>/plan-<planId>.json` and tombstones
485
- * the plan id so the next watcher event doesn't re-sync it. Does NOT
486
- * delete the project: a project can have multiple plans, deleting one
487
- * plan only removes that plan's HUs from the board.
488
- */
489
- router.delete('/plans/:planId', async (req, res) => {
490
- try {
491
- const planId = req.params.planId;
492
- // Locate the plan file by scanning both canonical and legacy plans
493
- // roots — KJC-BUG-0059 (v2.19.3). Pre-fix this hard-coded
494
- // `getKjHome()/../.kj/plans` which silently misses plans saved by
495
- // post-2.19.0 callers under `~/.karajan/plans/`.
496
- let foundPath = null;
497
- try {
498
- outer: for (const plansRoot of getHuBoardPlansDirs()) {
499
- if (!(await pathExists(plansRoot))) continue;
500
- for (const projSlug of await fsp.readdir(plansRoot)) {
501
- const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
502
- if (await pathExists(candidate)) { foundPath = candidate; break outer; }
503
- }
504
- }
505
- } catch { /* plansRoot missing */ }
506
- addTombstone('plan', planId, { source: 'api', fsPaths: foundPath ? [foundPath] : [] });
507
- if (foundPath) { try { await fsp.unlink(foundPath); } catch { /* */ } }
508
- res.json({ deleted: true, fileRemoved: !!foundPath });
509
- } catch (err) {
510
- res.status(500).json({ error: err.message });
511
- }
512
- });
513
-
514
- /**
515
- * GET /api/tombstones - List every active tombstone for diagnostics.
516
- *
517
- * Returns rows ordered by deleted_at DESC; useful for the diagnostics
518
- * pane and for the cleanup script (KJC-TSK-0380 PR-C).
519
- */
520
- router.get('/tombstones', (_req, res) => {
521
- try {
522
- res.json(listTombstones());
523
- } catch (err) {
524
- res.status(500).json({ error: err.message });
525
- }
526
- });
527
-
528
- /**
529
- * POST /api/tombstones/:type/:id/restore - Bring a buried id back to
530
- * life. The next sync will then re-import the resource if its filesystem
531
- * source still exists. Idempotent: returns false when nothing to remove.
532
- */
533
- router.post('/tombstones/:type/:id/restore', (req, res) => {
534
- try {
535
- const removed = removeTombstone(req.params.type, req.params.id);
536
- if (!removed) return res.status(404).json({ error: 'Tombstone not found' });
537
- res.json({ restored: true });
538
- } catch (err) {
539
- res.status(500).json({ error: err.message });
540
- }
541
- });
542
-
543
- /**
544
- * PATCH /api/stories/:id - Edit a single HU.
545
- *
546
- * Accepts two shapes in the same call (both optional, either or both
547
- * can be present):
548
- *
549
- * { status: "pending" | "certified" | "done" | "needs_context" }
550
- * → changes the lifecycle state (original PATCH semantics).
551
- *
552
- * { title?, scope?, task_type?, acceptance_criteria?,
553
- * acceptance_tests?, blocked_by? }
554
- * → edits the user-facing fields in place. Whitelist mirrors
555
- * plan-hu-ops::updateHu so CLI and board stay aligned.
556
- *
557
- * When both are present we apply status last — the status change
558
- * transitions the plan lifecycle and can rewrite plan.status, whereas
559
- * field edits are local to the HU and should never fight the status
560
- * auto-promotion.
561
- *
562
- * The row id on the board is `${projectId}::${huId}` — split here to
563
- * feed the plan-mutation helpers. The source-of-truth plan JSON is
564
- * rewritten before we ack, then re-synced into SQLite, so a reload
565
- * renders the committed state.
566
- */
567
- // KJC-TSK-0394 PR6 (AC 6): el PATCH endpoint sólo acepta el modelo
568
- // canónico {pending, running, done}. Status legacy (`certified`,
569
- // `coding`, `reviewing`, `failed`, `blocked`, `needs_context`) siguen
570
- // vivos en plans existentes y los traduce la UI con canonicalStatus(),
571
- // pero NO pueden ser seteados por escrituras nuevas — devolvemos 400
572
- // con `suggestion` apuntando al mapping correcto. `running` queda fuera
573
- // del dropdown del modal (lifecycle del orquestador), pero el endpoint
574
- // lo acepta por si un programa externo lo necesita.
575
- const ALLOWED_STORY_STATUSES = new Set(['pending', 'running', 'done']);
576
- // Mapping canónico para la sugerencia del 400 cuando el caller manda
577
- // un valor legacy. Mantenemos la regla viva en src/plan/plan-schema.js
578
- // (mapLegacyStatusToResult) — esta tabla es sólo el subset accionable
579
- // desde el endpoint (status + result).
580
- const LEGACY_STATUS_SUGGESTION = {
581
- certified: { status: 'done', result: 'pass' },
582
- failed: { status: 'pending', result: 'fail' },
583
- coding: { status: 'running', result: null },
584
- reviewing: { status: 'running', result: null },
585
- blocked: { status: 'pending', result: null },
586
- needs_context: { status: 'pending', result: null },
587
- };
588
- // KJC-TSK-0406: coder_model y reviewer_model son editables desde el
589
- // modal del board. Independientes — el usuario puede subir el reviewer
590
- // y bajar el coder sin afectar al resto del plan.
591
- // KJC-PRP-0002 PR6: `assignee` is editable from the board modal when the
592
- // project is team-shared. The whitelist itself is project-agnostic — the
593
- // modal hides the input on non-shared projects, but accepting the field
594
- // here doesn't hurt (sync just stores it).
595
- const EDITABLE_HU_FIELDS = ['title', 'scope', 'task_type', 'acceptance_criteria', 'acceptance_tests', 'blocked_by', 'coder_model', 'reviewer_model', 'coder_provider', 'reviewer_provider', 'assignee'];
596
-
597
- router.patch('/stories/:id', (req, res) => {
598
- try {
599
- const body = req.body || {};
600
- const hasStatus = Object.prototype.hasOwnProperty.call(body, 'status');
601
- const fieldPatch = {};
602
- for (const k of EDITABLE_HU_FIELDS) {
603
- if (Object.prototype.hasOwnProperty.call(body, k)) fieldPatch[k] = body[k];
604
- }
605
- const hasFieldEdit = Object.keys(fieldPatch).length > 0;
606
-
607
- if (!hasStatus && !hasFieldEdit) {
608
- return res.status(400).json({
609
- error: `Body must include status or one of: ${EDITABLE_HU_FIELDS.join(', ')}`,
610
- });
611
- }
612
- if (hasStatus && !ALLOWED_STORY_STATUSES.has(body.status)) {
613
- // KJC-TSK-0394 PR6 (AC 6): si el caller manda un status legacy,
614
- // devolvemos un 400 con `suggestion` apuntando al mapping canónico.
615
- // Una herramienta externa puede leer el body, aplicar el mapeo y
616
- // re-intentar — sin tener que descubrir la tabla por ensayo y error.
617
- const suggestion = LEGACY_STATUS_SUGGESTION[body.status] || null;
618
- return res.status(400).json({
619
- error: `status must be one of: ${[...ALLOWED_STORY_STATUSES].join(', ')}`,
620
- suggestion,
621
- });
622
- }
623
-
624
- const row = getStoryRow(req.params.id);
625
- if (!row) return res.status(404).json({ error: 'Story not found' });
626
- if (!row.plan_id) {
627
- return res.status(409).json({
628
- error: 'Story is not backed by a plan file (legacy row). Re-run `kj plan` to re-import it.',
629
- });
630
- }
631
- const huId = req.params.id.includes('::')
632
- ? req.params.id.split('::').slice(1).join('::')
633
- : req.params.id;
634
-
635
- let updatedHu;
636
- if (hasFieldEdit) {
637
- const fieldResult = setHuFields({
638
- planId: row.plan_id,
639
- huId,
640
- patch: fieldPatch,
641
- projectId: row.project_id,
642
- });
643
- if (!fieldResult.ok) {
644
- // KJC-TSK-0401: validación de blocked_by devuelve 400 con cycle?
645
- // info para que el board destaque la cadena en la UI. Resto de
646
- // fallos siguen devolviendo 404 (plan / hu no encontrados).
647
- if (fieldResult.code === 'INVALID_BLOCKED_BY') {
648
- return res.status(400).json({ error: fieldResult.error, cycle: fieldResult.cycle });
649
- }
650
- return res.status(404).json({ error: fieldResult.error });
651
- }
652
- updatedHu = fieldResult.hu;
653
- }
654
-
655
- let statusResult;
656
- if (hasStatus) {
657
- statusResult = setHuStatus({
658
- planId: row.plan_id,
659
- huId,
660
- status: body.status,
661
- projectId: row.project_id,
662
- });
663
- if (!statusResult.ok) return res.status(404).json({ error: statusResult.error });
664
- }
665
-
666
- res.json({
667
- updated: true,
668
- id: req.params.id,
669
- status: statusResult ? statusResult.status : undefined,
670
- planStatus: statusResult ? statusResult.planStatus : undefined,
671
- hu: updatedHu,
672
- });
673
- } catch (err) {
674
- res.status(500).json({ error: err.message });
675
- }
676
- });
677
-
678
- /**
679
- * POST /api/stories/:id/undo — KJC-TSK-0408: revert a HU.
680
- *
681
- * Restaura los ficheros del workspace al snapshot pre-run guardado por
682
- * la pipeline. Marca status=pending + result=null + outcome.reverted=true
683
- * para que el usuario pueda relanzar la HU con otros modelos.
684
- *
685
- * Falla con 400 si la HU no tiene snapshot (HUs anteriores al hook).
686
- * Falla con 404 si plan/HU no existe.
687
- */
688
- router.post('/stories/:id/undo', async (req, res) => {
689
- try {
690
- const row = getStoryRow(req.params.id);
691
- if (!row) return res.status(404).json({ error: 'Story not found' });
692
- if (!row.plan_id) {
693
- return res.status(409).json({ error: 'Story sin plan_id — legacy row no soportada.' });
694
- }
695
- const huId = req.params.id.includes('::')
696
- ? req.params.id.split('::').slice(1).join('::')
697
- : req.params.id;
698
- const result = await revertHuFromSnapshot({ planId: row.plan_id, huId, projectId: row.project_id });
699
- if (!result.ok) {
700
- const code = /not found|no encontrado/i.test(result.error) ? 404 : 400;
701
- return res.status(code).json({ error: result.error });
702
- }
703
- res.json({ ok: true, id: req.params.id, sha: result.sha });
704
- } catch (err) {
705
- res.status(500).json({ error: err.message });
706
- }
707
- });
708
-
709
- /**
710
- * POST /api/plans/:planId/ready - Bulk-certify every pending HU of a plan.
711
- * Equivalent to the CLI `kj plan ready <planId>`. Body is optional — if the
712
- * client knows the projectId we use it as a fast path, otherwise we fall
713
- * back to scanning the plans dir.
714
- */
715
- router.post('/plans/:planId/ready', (req, res) => {
716
- try {
717
- const { projectId } = req.body || {};
718
- const result = markPlanReady({ planId: req.params.planId, projectId });
719
- if (!result.ok) return res.status(404).json({ error: result.error });
720
- res.json({ ready: true, planId: req.params.planId, count: result.count, planStatus: result.planStatus });
721
- } catch (err) {
722
- res.status(500).json({ error: err.message });
723
- }
724
- });
725
-
726
- /**
727
- * POST /api/projects/:id/ready - Sugar: mark every plan of a project ready
728
- * in one call. Lets the board expose a single "Mark all certified" button
729
- * per project view without the UI having to enumerate plan ids first.
730
- */
731
- router.post('/projects/:id/ready', (req, res) => {
732
- try {
733
- const planIds = listPlanIdsForProject(req.params.id);
734
- if (planIds.length === 0) {
735
- return res.status(404).json({ error: 'No plan-backed stories for this project' });
736
- }
737
- const results = [];
738
- for (const planId of planIds) {
739
- const r = markPlanReady({ planId, projectId: req.params.id });
740
- results.push({ planId, ...r });
741
- }
742
- const totalCertified = results
743
- .filter((r) => r.ok)
744
- .reduce((acc, r) => acc + r.count, 0);
745
- res.json({
746
- ready: true,
747
- projectId: req.params.id,
748
- plans: results,
749
- totalCertified,
750
- });
751
- } catch (err) {
752
- res.status(500).json({ error: err.message });
753
- }
754
- });
755
-
756
- /**
757
- * Resolve the prompts directory the runner writes RPC packets to.
758
- * Same convention as src/utils/board-prompt-bridge.js on the runner.
759
- */
760
- function promptsDir() {
761
- return path.join(getKjHome(), 'prompts');
762
- }
763
-
764
- /**
765
- * GET /api/prompts - List currently pending prompts. The runner is
766
- * blocked on each one waiting for an answer; the board reads this on
767
- * boot to surface modals for prompts that were already pending when
768
- * the page loaded (the SSE event fires only on new arrivals).
769
- */
770
- // KJC-BUG-0038: prompts whose runner crashed leave the .json behind in
771
- // ~/.kj/prompts/. Without a TTL, every board reload re-shows the zombie
772
- // modal "Karajan needs an answer" with no runner behind it — the user
773
- // can answer but nothing happens. 30 minutes covers the longest legitimate
774
- // Solomon escalation; anything older is a crashed runner.
775
- const PROMPT_ZOMBIE_TTL_MS = 30 * 60 * 1000;
776
-
777
- router.get('/prompts', async (_req, res) => {
778
- try {
779
- const dir = promptsDir();
780
- if (!(await pathExists(dir))) return res.json([]);
781
- const entries = await fsp.readdir(dir);
782
- const result = [];
783
- const now = Date.now();
784
- for (const name of entries) {
785
- if (!name.endsWith('.json') || name.endsWith('.answer.json')) continue;
786
- const promptId = name.replace(/\.json$/, '');
787
- const mainPath = path.join(dir, name);
788
- const answerPath = path.join(dir, `${promptId}.answer.json`);
789
- // KJC-TSK-0380 — skip tombstoned prompts and clean their files.
790
- if (isTombstonedFn('prompt', promptId)) {
791
- try { await fsp.unlink(mainPath); } catch { /* */ }
792
- try { await fsp.unlink(answerPath); } catch { /* */ }
793
- continue;
794
- }
795
- let parsed;
796
- try {
797
- parsed = JSON.parse(await fsp.readFile(mainPath, 'utf-8'));
798
- } catch { continue; /* malformed — skip */ }
799
- // KJC-BUG-0038: zombie detection. Prefer parsed.createdAt; fall back
800
- // to mtime if the runner skipped the timestamp (older formats).
801
- const createdMs = Date.parse(parsed?.createdAt || '');
802
- const ageRef = Number.isFinite(createdMs) ? createdMs : await safeMtime(mainPath);
803
- if (ageRef && (now - ageRef) > PROMPT_ZOMBIE_TTL_MS) {
804
- try { await fsp.unlink(mainPath); } catch { /* */ }
805
- try { await fsp.unlink(answerPath); } catch { /* */ }
806
- addTombstone('prompt', promptId, { source: 'zombie-ttl', fsPaths: [mainPath, answerPath] });
807
- continue;
808
- }
809
- result.push(parsed);
810
- }
811
- result.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
812
- res.json(result);
813
- } catch (err) {
814
- res.status(500).json({ error: err.message });
815
- }
816
- });
817
-
818
- async function safeMtime(p) {
819
- try { return (await fsp.stat(p)).mtimeMs; } catch { return 0; }
820
- }
821
-
822
- /**
823
- * POST /api/prompts/:promptId/answer - Body { answer }. Writes a
824
- * sibling .answer.json the runner is polling for. The runner reads
825
- * it, deletes both files, and resolves askQuestion. An answer of
826
- * "stop" tells the runner to abort the session.
827
- */
828
- router.post('/prompts/:promptId/answer', async (req, res) => {
829
- try {
830
- const { answer } = req.body || {};
831
- if (typeof answer !== 'string') {
832
- return res.status(400).json({ error: 'Body must contain an "answer" string' });
833
- }
834
- const dir = promptsDir();
835
- await fsp.mkdir(dir, { recursive: true });
836
- const promptPath = path.join(dir, `${req.params.promptId}.json`);
837
- if (!(await pathExists(promptPath))) {
838
- return res.status(404).json({ error: `Prompt not found: ${req.params.promptId}` });
839
- }
840
- const answerPath = path.join(dir, `${req.params.promptId}.answer.json`);
841
- await fsp.writeFile(
842
- answerPath,
843
- JSON.stringify({ answer, answeredAt: new Date().toISOString() }, null, 2),
844
- 'utf-8'
845
- );
846
- res.json({ ok: true });
847
- } catch (err) {
848
- res.status(500).json({ error: err.message });
849
- }
850
- });
851
-
852
- /**
853
- * GET /api/events - Server-Sent Events stream for live board updates.
854
- *
855
- * Replaces the old "sync → full page re-render" dance. The chokidar
856
- * watcher publishes a small event on every sync (plan/session/batch);
857
- * this endpoint pipes those events to every connected client. The
858
- * client patches the affected card in place, so scroll position and
859
- * any open modal survive the update.
860
- *
861
- * The connection stays open for the life of the tab. Browsers
862
- * auto-reconnect on socket drop — we send a keep-alive comment every
863
- * 25s so intermediate proxies don't kill the idle socket.
864
- */
865
- router.get('/events', (req, res) => {
866
- res.writeHead(200, {
867
- 'Content-Type': 'text/event-stream',
868
- 'Cache-Control': 'no-cache, no-transform',
869
- Connection: 'keep-alive',
870
- // Disable Express compression on this route — SSE must be a raw
871
- // stream, gzip buffers and breaks the "push on every event" model.
872
- 'X-Accel-Buffering': 'no',
873
- });
874
- res.flushHeaders?.();
875
- // Initial hello so the client knows the stream is open. Useful for
876
- // the integration test too: the EventSource-equivalent supertest
877
- // client can wait on the first line.
878
- res.write('retry: 2000\n');
879
- res.write('event: hello\ndata: {"ok":true}\n\n');
880
-
881
- const send = (event) => {
882
- try {
883
- res.write(`data: ${JSON.stringify(event)}\n\n`);
884
- } catch {
885
- // Client gone — the close handler will clean up shortly.
886
- }
887
- };
888
- const unsubscribe = subscribeEvents(send);
889
-
890
- // 25s is well under the default 60-90s idle timeout of every sane
891
- // reverse proxy, but high enough that it doesn't spam the log.
892
- const keepAlive = setInterval(() => {
893
- try { res.write(': keep-alive\n\n'); } catch { /* ignore */ }
894
- }, 25000);
895
-
896
- req.on('close', () => {
897
- clearInterval(keepAlive);
898
- unsubscribe();
899
- res.end?.();
900
- });
901
- });
902
-
903
- /**
904
- * GET /api/plans/:planId/log - Tail the detached run's log file.
905
- *
906
- * Accepts ?offset=N so a polling UI can request only the new bytes
907
- * since last read (the "tail -f" UX without SSE). Returns:
908
- *
909
- * { content: string, size: number, exists: boolean }
910
- *
911
- * - `exists` is false when the run hasn't started yet (the log path is
912
- * deterministic, so we know where to look).
913
- * - `size` is the current file length; clients pass `offset=size` next
914
- * time to get only the delta.
915
- *
916
- * Path is the same one that `runPlan` wrote to:
917
- * ~/.karajan/hu-board-runs/<planId>.log (honours KJ_HOME in tests).
918
- */
919
- router.get('/plans/:planId/log', async (req, res) => {
920
- try {
921
- const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
922
- const logPath = path.join(runsDir, `${req.params.planId}.log`);
923
- if (!(await pathExists(logPath))) {
924
- return res.json({ exists: false, size: 0, content: '' });
925
- }
926
- const stat = await fsp.stat(logPath);
927
- const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
928
- // Cap per-request read to 1 MiB so a huge log can't blow up the
929
- // response. The client will keep polling and catch up.
930
- const MAX_READ = 1024 * 1024;
931
- const length = Math.min(MAX_READ, stat.size - offset);
932
- let content = '';
933
- if (length > 0) {
934
- const fh = await fsp.open(logPath, 'r');
935
- try {
936
- const buf = Buffer.alloc(length);
937
- await fh.read(buf, 0, length, offset);
938
- content = buf.toString('utf-8');
939
- } finally {
940
- await fh.close();
941
- }
942
- }
943
- res.json({ exists: true, size: stat.size, content });
944
- } catch (err) {
945
- res.status(500).json({ error: err.message });
946
- }
947
- });
948
-
949
- /**
950
- * POST /api/plans/:planId/run - Launch `kj run --plan <planId>` as a
951
- * detached child. This is the "Run plan" button's endpoint — the board
952
- * no longer requires the user to drop to a terminal to kick off the
953
- * pipeline.
954
- *
955
- * Body: { projectId?: string, task?: string } — both optional. projectId
956
- * makes the plan-file lookup O(1); task overrides plan.task as the
957
- * pipeline's headline description.
958
- *
959
- * Returns the child's pid + the log path so a future "tail this run"
960
- * viewer can attach.
961
- */
962
- router.post('/plans/:planId/run', (req, res) => {
963
- try {
964
- const { projectId, task, huIds } = req.body || {};
965
- // PR4: callers may pass `huIds: ["hu_x_001"]` to scope the run
966
- // to a single HU (or comma-separated subset). When omitted, the
967
- // whole plan runs as before.
968
- const result = runPlan({
969
- planId: req.params.planId,
970
- projectId,
971
- taskOverride: task,
972
- huIds: Array.isArray(huIds) ? huIds : null,
973
- });
974
- if (!result.ok) {
975
- // 404 for "plan not found" (preserves the contract the existing
976
- // test asserts on), 400 for validation errors (unknown HU id,
977
- // missing projectDir, etc.).
978
- const code = /^plan not found/.test(result.error) ? 404 : 400;
979
- return res.status(code).json({ error: result.error });
980
- }
981
- res.json({
982
- launched: true,
983
- planId: req.params.planId,
984
- pid: result.pid,
985
- logPath: result.logPath,
986
- huIds: Array.isArray(huIds) && huIds.length > 0 ? huIds : null,
987
- });
988
- } catch (err) {
989
- res.status(500).json({ error: err.message });
990
- }
991
- });
992
-
993
- /**
994
- * POST /api/hus/:huId/run — PR4: launch a single HU (or, when the
995
- * caller passes additional ids in body.extraHuIds, a small bundle).
996
- *
997
- * The HU's plan is resolved from the SQLite stories table so the
998
- * front doesn't have to know the planId — it only needs the story
999
- * id from the card.
1000
- */
1001
- router.post('/hus/:huId/run', (req, res) => {
1002
- try {
1003
- const huId = req.params.huId;
1004
- const row = getStoryRow(huId);
1005
- if (!row) return res.status(404).json({ error: `unknown HU: ${huId}` });
1006
- if (!row.plan_id) return res.status(400).json({ error: `HU ${huId} is not backed by a plan (legacy row)` });
1007
-
1008
- // Story id on disk uses just the HU local id (hu_<plan>_NNN),
1009
- // not the "<projectId>::<huId>" composite the board persists.
1010
- // Strip the prefix back off before forwarding to runPlan.
1011
- const planLocalHuId = huId.includes('::') ? huId.split('::').pop() : huId;
1012
- const extraIds = Array.isArray(req.body?.extraHuIds) ? req.body.extraHuIds.map((x) => (x.includes('::') ? x.split('::').pop() : x)) : [];
1013
- const huIds = [planLocalHuId, ...extraIds];
1014
-
1015
- // Read the HU title from the plan JSON so kj run receives a
1016
- // meaningful task description instead of the generic plan.task.
1017
- let taskOverride;
1018
- try {
1019
- const planPath = path.join(getHuBoardPlansDir(), row.project_id, `${row.plan_id}.json`);
1020
- const planRaw = fs.readFileSync(planPath, 'utf8');
1021
- const planData = JSON.parse(planRaw);
1022
- const hu = (planData.hus || []).find((h) => h.id === planLocalHuId);
1023
- if (hu?.title) taskOverride = hu.title;
1024
- } catch { /* fall through — runPlan will use plan.task */ }
1025
-
1026
- const result = runPlan({ planId: row.plan_id, projectId: row.project_id, huIds, taskOverride });
1027
- if (!result.ok) return res.status(400).json({ error: result.error });
1028
-
1029
- // Move the launched HU(s) to "coding" in BOTH the SQLite mirror and the
1030
- // source-of-truth plan JSON. The plan JSON write is what makes it stick
1031
- // — the next /api/sync re-reads the plan file and would otherwise
1032
- // overwrite the SQLite update back to "certified". Without this the
1033
- // card flashes to Running for ~1s and snaps back, which the user reads
1034
- // as "nothing happened".
1035
- //
1036
- // The orchestrator owns the lifecycle once it boots (it'll move the HU
1037
- // through coding → reviewing → done/failed and persist back to the plan
1038
- // JSON), so this is just the bootstrap nudge to give the user immediate
1039
- // visual feedback.
1040
- const allHuIds = [planLocalHuId, ...extraIds];
1041
- for (const localId of allHuIds) {
1042
- try { setHuStatus({ planId: row.plan_id, huId: localId, status: 'coding', projectId: row.project_id }); } catch { /* non-fatal */ }
1043
- // Also stamp the SQLite row so the next renderBoard() (before any
1044
- // sync tick) shows it in Running.
1045
- const compositeId = localId.includes('::') ? localId : `${row.project_id}::${localId}`;
1046
- try { updateStoryStatus(compositeId, 'coding'); } catch { /* non-fatal */ }
1047
- }
1048
-
1049
- res.json({
1050
- launched: true,
1051
- planId: row.plan_id,
1052
- huIds,
1053
- pid: result.pid,
1054
- logPath: result.logPath,
1055
- });
1056
- } catch (err) {
1057
- res.status(500).json({ error: err.message });
1058
- }
1059
- });
1060
-
1061
- /**
1062
- * POST /api/projects/:id/run - Sugar: launch every plan of a project. For
1063
- * the common case where each project has exactly one active plan this
1064
- * behaves the same as POST /plans/:planId/run; with multiple plans it
1065
- * kicks each one in its own detached child.
1066
- */
1067
- router.post('/projects/:id/run', (req, res) => {
1068
- try {
1069
- const planIds = listPlanIdsForProject(req.params.id);
1070
- if (planIds.length === 0) {
1071
- return res.status(404).json({ error: 'No plan-backed stories for this project' });
1072
- }
1073
- const results = [];
1074
- for (const planId of planIds) {
1075
- const r = runPlan({ planId, projectId: req.params.id });
1076
- results.push({ planId, ...r });
1077
- }
1078
- const launched = results.filter((r) => r.ok).length;
1079
- res.json({ launched, total: planIds.length, results });
1080
- } catch (err) {
1081
- res.status(500).json({ error: err.message });
1082
- }
1083
- });
1084
-
1085
- /**
1086
- * GET /api/config — read the editable subset of the kj.config.yml.
1087
- * Powers the board's settings modal; non-editable keys in the yml are
1088
- * left untouched on PUT (see writeConfigPatch's whitelist semantics).
1089
- *
1090
- * v2.30.0 PR4 — supports `?scope=global|project`:
1091
- * - `global` (default): reads ~/.karajan/kj.config.yml.
1092
- * - `project`: reads <projectDir>/.karajan/kj.config.yml.
1093
- * Unknown scopes are 400'd. Response includes the resolved `scope` and
1094
- * `path` so the UI can show which file backs the current view.
1095
- */
1096
- router.get('/config', (req, res) => {
1097
- try {
1098
- const scope = req.query?.scope || 'global';
1099
- // KJC-TSK-0885: a scoped view names its project; the daemon's cwd is not it.
1100
- const data = readConfig({ scope, projectDir: requestProjectDir(req.query?.project) });
1101
- res.json(data);
1102
- } catch (err) {
1103
- res.status(400).json({ error: err.message });
1104
- }
1105
- });
1106
-
1107
- /**
1108
- * PUT /api/config — apply a patch to the yml. Body is
1109
- * `{ patch: { key: value }, scope?: 'global'|'project' }` with the keys
1110
- * defined in EDITABLE_FIELDS. Unknown keys are 400'd; type errors are
1111
- * 400'd; validation errors are 400'd. Writes are atomic (tmp + rename)
1112
- * and a `.bak` is kept of the previous content.
1113
- *
1114
- * v2.30.0 PR4 — `scope` decides which file is patched. Default 'global'
1115
- * preserves the previous behavior.
1116
- */
1117
- router.put('/config', (req, res) => {
1118
- try {
1119
- const patch = req.body?.patch;
1120
- const scope = req.body?.scope || 'global';
1121
- let projectDir;
1122
- try { projectDir = requestProjectDir(req.body?.project); } catch (err) { return res.status(400).json({ error: err.message }); }
1123
- const result = writeConfigPatch(patch, { scope, projectDir });
1124
- if (!result.written) {
1125
- return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
1126
- }
1127
- res.json(result);
1128
- } catch (err) {
1129
- res.status(500).json({ error: err.message });
1130
- }
1131
- });
1132
-
1133
- /**
1134
- * GET /api/commands - List the kj commands the board can launch.
1135
- * The UI uses this to render the Actions panel without hardcoding
1136
- * the command list on the client.
1137
- */
1138
- router.get('/commands', (_req, res) => {
1139
- res.json({ commands: listSupportedCommands() });
1140
- });
1141
-
1142
- /**
1143
- * POST /api/commands/:command - Spawn a kj <command> as a detached
1144
- * child. Body fields depend on the command (see command-runner.js
1145
- * for the per-command input schema). Returns a commandId the client
1146
- * can pass to GET /api/runs/:commandId/log to tail the output.
1147
- *
1148
- * The board never executes free-form arg arrays — the command name
1149
- * must be in the whitelist and each command builds its argv from a
1150
- * typed schema. Anything outside that fails 400.
1151
- */
1152
- router.post('/commands/:command', (req, res) => {
1153
- try {
1154
- const result = runKjCommand({ command: req.params.command, input: req.body || {} });
1155
- if (!result.ok) return res.status(400).json({ error: result.error });
1156
- res.json({
1157
- ok: true,
1158
- commandId: result.commandId,
1159
- pid: result.pid,
1160
- logPath: result.logPath,
1161
- });
1162
- } catch (err) {
1163
- res.status(500).json({ error: err.message });
1164
- }
1165
- });
1166
-
1167
- /**
1168
- * GET /api/runs/:commandId/log - Generalisation of the per-plan log
1169
- * tail. The original `/api/plans/:planId/log` reads
1170
- * `<runsDir>/<planId>.log`; this one reads `<runsDir>/<commandId>.log`
1171
- * where `commandId` is what `/api/commands/:command` returned.
1172
- *
1173
- * Same offset semantics + 1 MiB cap as the plans endpoint.
1174
- */
1175
- /**
1176
- * GET /api/runs/:planId/active - Devuelve los runs activos (PIDs vivos)
1177
- * de un plan. Usado por el frontend para decidir si mostrar el botón
1178
- * ⏹ Stop. Respuesta: { active: [{pid, huIds, startedAt}, ...] }.
1179
- *
1180
- * KJC-TSK-0396.
1181
- */
1182
- router.get('/runs/:planId/active', (req, res) => {
1183
- try {
1184
- const active = getActiveRuns(req.params.planId);
1185
- res.json({ active });
1186
- } catch (err) {
1187
- res.status(500).json({ error: err.message });
1188
- }
1189
- });
1190
-
1191
- /**
1192
- * POST /api/runs/:planId/stop - Mata todos los procesos del orquestador
1193
- * asociados a este plan. SIGTERM primero, espera 5s, escala a SIGKILL
1194
- * si el proceso no respondió. Devuelve { stopped, killed, errors }.
1195
- *
1196
- * Tras matar el proceso, las HUs que estaban en coding/reviewing se
1197
- * resetean a pending (no a failed) vía hu-zombie-reaper logic — pero
1198
- * eso lo hace el reaper en el siguiente arranque del board. Para
1199
- * efecto inmediato, marcamos aquí las HUs running del plan a pending.
1200
- *
1201
- * KJC-TSK-0396.
1202
- */
1203
- router.post('/runs/:planId/stop', async (req, res) => {
1204
- const { planId } = req.params;
1205
- const stopTimeoutMs = Number(req.body?.timeoutMs ?? 5000);
1206
- try {
1207
- const errors = [];
1208
- let stopped = 0;
1209
- let killed = 0;
1210
- const active = getActiveRuns(planId);
1211
- // Fase 1: SIGTERM a todos los procesos trackeados (si los hay).
1212
- for (const run of active) {
1213
- try { process.kill(run.pid, 'SIGTERM'); stopped += 1; }
1214
- catch (err) { errors.push({ pid: run.pid, phase: 'SIGTERM', error: err.message }); }
1215
- }
1216
- // Fase 2: esperar `stopTimeoutMs` y escalar a SIGKILL los que sigan
1217
- // vivos. Saltamos la espera si no había nada que matar.
1218
- if (active.length > 0) {
1219
- await new Promise((resolve) => setTimeout(resolve, stopTimeoutMs));
1220
- const stillAlive = getActiveRuns(planId);
1221
- for (const run of stillAlive) {
1222
- try { process.kill(run.pid, 'SIGKILL'); killed += 1; }
1223
- catch (err) { errors.push({ pid: run.pid, phase: 'SIGKILL', error: err.message }); }
1224
- }
1225
- }
1226
- // Fase 3: SIEMPRE reset de HUs en coding/reviewing del plan → pending.
1227
- // El reset corre aunque no hubiera PIDs trackeados (caso: el run lo
1228
- // lanzaste manualmente desde la terminal, killeaste tú mismo el
1229
- // proceso, pero la DB sigue mostrando HUs en coding y necesitas
1230
- // dejarlas en pending para relanzar limpio).
1231
- let hu_reset_count = 0;
1232
- try {
1233
- const db = getDb();
1234
- const result = db.prepare(
1235
- "UPDATE stories SET status = 'pending', updated_at = ? WHERE plan_id = ? AND status IN ('coding', 'reviewing', 'running')"
1236
- ).run(new Date().toISOString(), planId);
1237
- hu_reset_count = result.changes;
1238
- } catch (err) {
1239
- errors.push({ phase: 'hu_reset', error: err.message });
1240
- }
1241
- const out = { stopped, killed, errors, hu_reset_count };
1242
- if (stopped === 0 && killed === 0 && hu_reset_count === 0) {
1243
- out.message = 'No active runs and no stuck HUs for this plan.';
1244
- }
1245
- res.json(out);
1246
- } catch (err) {
1247
- res.status(500).json({ error: err.message });
1248
- }
1249
- });
15
+ // Version, dashboard, live events, config, restart and shutdown, sync, RAG search.
16
+ router.use(systemRouter);
1250
17
 
1251
- router.get('/runs/:commandId/log', async (req, res) => {
1252
- try {
1253
- const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
1254
- const logPath = path.join(runsDir, `${req.params.commandId}.log`);
1255
- if (!(await pathExists(logPath))) {
1256
- return res.json({ exists: false, size: 0, content: '' });
1257
- }
1258
- const stat = await fsp.stat(logPath);
1259
- const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
1260
- const MAX_READ = 1024 * 1024;
1261
- const length = Math.min(MAX_READ, stat.size - offset);
1262
- let content = '';
1263
- if (length > 0) {
1264
- const fh = await fsp.open(logPath, 'r');
1265
- try {
1266
- const buf = Buffer.alloc(length);
1267
- await fh.read(buf, 0, length, offset);
1268
- content = buf.toString('utf-8');
1269
- } finally {
1270
- await fh.close();
1271
- }
1272
- }
1273
- res.json({ exists: true, size: stat.size, content });
1274
- } catch (err) {
1275
- res.status(500).json({ error: err.message });
1276
- }
1277
- });
18
+ // Projects: list, detail, rename, is-test, preflight, plans-outcome, cost, delete, ready.
19
+ router.use(projectsRouter);
1278
20
 
1279
- /**
1280
- * POST /api/board/restart - Respawn the board server in place. The
1281
- * server can't truly restart itself (it'd kill its own process), so
1282
- * we use the detached-child + parent-exit pattern: spawn a copy with
1283
- * the same argv + cwd + env, unref it, ack the client, then exit
1284
- * after a 250 ms delay so the response actually flushes.
1285
- *
1286
- * The new server overwrites the PID file and bindes the same port
1287
- * (or the next free one if the old one hasn't released yet — handled
1288
- * by the existing find-available-port logic in startBoard).
1289
- *
1290
- * The browser tab's EventSource auto-reconnects, so the user sees a
1291
- * brief blip and the board comes back live.
1292
- */
1293
- /**
1294
- * POST /api/board/shutdown - Tell the server to exit cleanly. Used
1295
- * by `kj board stop` as a fallback when the PID file is missing or
1296
- * stale (typical after the user has been hot-pulling Karajan and
1297
- * the running server is from a build that didn't write the PID
1298
- * file). The body of the response is sent before we exit, then a
1299
- * 250 ms delay to let the response flush, then process.exit(0).
1300
- */
1301
- router.post('/board/shutdown', (_req, res) => {
1302
- if (process.env.KJ_BOARD_RESTART_MODE === 'echo') {
1303
- return res.json({ ok: true, mode: 'echo' });
1304
- }
1305
- res.json({ ok: true, shuttingDownInMs: 250 });
1306
- setTimeout(() => process.exit(0), 250);
1307
- });
21
+ // Stories (list, detail, edit, the refused delete) and sessions.
22
+ router.use(storiesRouter);
23
+ router.use(sessionsRouter);
1308
24
 
1309
- router.post('/board/restart', (_req, res) => {
1310
- // Tests / programmatic callers can short-circuit the actual self-
1311
- // exec via env so the suite doesn't have its node process killed
1312
- // mid-test by a misfire.
1313
- if (process.env.KJ_BOARD_RESTART_MODE === 'echo') {
1314
- return res.json({ ok: true, restartingInMs: 0, mode: 'echo' });
1315
- }
1316
- try {
1317
- res.json({ ok: true, restartingInMs: 250 });
1318
- setTimeout(() => {
1319
- try {
1320
- const child = spawnChild(
1321
- process.execPath,
1322
- process.argv.slice(1),
1323
- { cwd: process.cwd(), detached: true, stdio: 'ignore', env: process.env }
1324
- );
1325
- child.unref();
1326
- } catch { /* if respawn fails, the user can `kj board start` manually */ }
1327
- process.exit(0);
1328
- }, 250);
1329
- } catch (err) {
1330
- res.status(500).json({ error: err.message });
1331
- }
1332
- });
25
+ // Prompts the runner leaves for the board: list, answer, discard.
26
+ router.use(promptsRouter);
1333
27
 
1334
- /**
1335
- * POST /api/sync - Force a full re-scan of hu-stories/ and sessions/ on disk.
1336
- * Useful when chokidar's file watcher misses new subdirectories created after
1337
- * the board started (known limitation with glob patterns).
1338
- */
1339
- router.post('/sync', (_req, res) => {
1340
- try {
1341
- fullScan();
1342
- const projects = getProjects();
1343
- res.json({ synced: true, projects: projects.length });
1344
- } catch (err) {
1345
- res.status(500).json({ error: err.message });
1346
- }
1347
- });
28
+ // Plans (delete, ready) and tombstones (list, restore).
29
+ router.use(plansRouter);
30
+ router.use(tombstonesRouter);
1348
31
 
1349
- /**
1350
- * POST /api/rag/query - Semantic search over the indexed RAG corpus
1351
- * (KJC-PCS-0049 Step 8). Body: { text, topK?, scope? }. Response:
1352
- * { hits: [...], empty, topK, scope }. Empty store carries empty=true
1353
- * so the UI can show an actionable hint instead of just empty results.
1354
- */
1355
- router.post('/rag/query', async (req, res) => {
1356
- const { text, topK = 5, scope = 'all' } = req.body || {};
1357
- if (typeof text !== 'string' || text.length === 0) {
1358
- return res.status(400).json({ error: "'text' is required (non-empty string)" });
1359
- }
1360
- // KJC-TSK-0885 (ADR 0011): a scoped view searches ITS project's index; the
1361
- // machine-wide store (no filter) returned chunks of any repo.
1362
- let projectDir;
1363
- try { projectDir = requestProjectDir(req.body?.project); } catch (err) { return res.status(400).json({ error: err.message }); }
1364
- try {
1365
- const { openVecStore, countChunks } = await import('karajan-core/vec-store');
1366
- // KJC-TSK-0632: resolved from karajan-core — the board carries zero
1367
- // relative imports into the CLI src tree (see no-cli-imports test).
1368
- const { makeEmbedder } = await import('karajan-core/rag/embedders/factory');
1369
- const { query } = await import('karajan-core/rag/retriever');
1370
- const db = openVecStore({ dim: 768, ...(projectDir ? { path: path.join(projectDir, '.karajan', 'rag.db') } : {}) });
1371
- try {
1372
- if (countChunks(db) === 0) return res.json({ hits: [], empty: true, topK, scope });
1373
- const embedder = makeEmbedder({});
1374
- const hits = await query(db, embedder, text, { topK: Number(topK), scope });
1375
- res.json({ hits, empty: false, topK, scope });
1376
- } finally { db.close(); }
1377
- } catch (err) {
1378
- res.status(500).json({ error: err.message });
1379
- }
1380
- });
32
+ // The kj commands the board launches, their run logs and the retired `kj run` routes.
33
+ router.use(commandsRouter);
1381
34
 
1382
35
  export default router;