kortext 2.2.3 → 3.1.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 (275) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -65
  3. package/agents/architect.md +85 -0
  4. package/agents/compliance-expert.md +36 -75
  5. package/agents/copywriter.md +36 -92
  6. package/agents/db-admin.md +43 -91
  7. package/agents/designer.md +40 -77
  8. package/agents/devops-engineer.md +37 -113
  9. package/agents/growth-expert.md +45 -88
  10. package/agents/product-manager.md +38 -83
  11. package/agents/qa-engineer.md +51 -99
  12. package/agents/security-engineer.md +47 -92
  13. package/bin/kortext.js +2 -0
  14. package/dist/app.js +829 -0
  15. package/dist/cli-spawn.js +137 -0
  16. package/dist/daemon.js +40 -0
  17. package/dist/db.js +78 -0
  18. package/dist/design-preview.js +533 -0
  19. package/dist/docs.js +701 -0
  20. package/dist/engines.js +225 -0
  21. package/dist/index.js +109 -0
  22. package/dist/pick-directory.js +38 -0
  23. package/dist/projects.js +189 -0
  24. package/dist/readiness.js +288 -0
  25. package/dist/runner.js +771 -0
  26. package/dist/update.js +55 -0
  27. package/docs/CHANGELOG.md +11 -0
  28. package/package.json +53 -13
  29. package/templates/AGENTS.md +62 -0
  30. package/templates/docs/API.md +67 -0
  31. package/templates/docs/ARCHITECTURE.md +31 -0
  32. package/templates/docs/BRIEF.md +27 -0
  33. package/templates/docs/CONTENT.md +66 -0
  34. package/templates/docs/DATABASE.md +63 -0
  35. package/templates/docs/DESIGN.md +124 -0
  36. package/templates/docs/ENGINEERING.md +66 -0
  37. package/templates/docs/ENVIRONMENT.md +102 -0
  38. package/templates/docs/GROWTH.md +44 -0
  39. package/templates/docs/LEGAL.md +48 -0
  40. package/templates/docs/PRODUCT.md +43 -0
  41. package/templates/docs/SECURITY.md +65 -0
  42. package/templates/docs/STACK.md +57 -0
  43. package/templates/docs/STRUCTURE.md +73 -0
  44. package/templates/docs/TEST.md +62 -0
  45. package/ui/dist/assets/abnfDiagram-VCTEODGH-Bm7Q2BML.js +1 -0
  46. package/ui/dist/assets/arc-BV9hs0Hu.js +1 -0
  47. package/ui/dist/assets/architecture-7GRP2DOG-RckR3gBI.js +1 -0
  48. package/ui/dist/assets/architectureDiagram-5GKGNRK7-CHiJ6JFT.js +36 -0
  49. package/ui/dist/assets/array-BifhSqXX.js +1 -0
  50. package/ui/dist/assets/blockDiagram-I7D4REHJ-qCbaKVhd.js +129 -0
  51. package/ui/dist/assets/c4Diagram-7LVT6UL2-DidCDJrv.js +38 -0
  52. package/ui/dist/assets/channel-CNUQ11ne.js +1 -0
  53. package/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  54. package/ui/dist/assets/chunk-4HAMMTFA-CDGFjfVp.js +62 -0
  55. package/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  56. package/ui/dist/assets/chunk-75Z2AOVW-CbNT4sDh.js +2 -0
  57. package/ui/dist/assets/chunk-DU6HZSFF-Cq2BVv26.js +127 -0
  58. package/ui/dist/assets/chunk-F27PBJKO-CE365m0v.js +1 -0
  59. package/ui/dist/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
  60. package/ui/dist/assets/chunk-GMAD6QVW-DxWL1VC-.js +72 -0
  61. package/ui/dist/assets/chunk-GVQU2GXP-aARV8YbK.js +1 -0
  62. package/ui/dist/assets/chunk-IMKFNOWR-CC790Kt2.js +231 -0
  63. package/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  64. package/ui/dist/assets/chunk-L3NEJ4N5-BfE4VE8J.js +1 -0
  65. package/ui/dist/assets/chunk-OSK3NFVY-BImQbSWH.js +10 -0
  66. package/ui/dist/assets/chunk-P2QGCYS3-CN3XD313.js +1 -0
  67. package/ui/dist/assets/chunk-POPQ4Y6H-Dqb6jOrX.js +1 -0
  68. package/ui/dist/assets/chunk-PWAF6VOD-DfXX6rBk.js +1 -0
  69. package/ui/dist/assets/chunk-SHT3W25Y-DATyyqPK.js +168 -0
  70. package/ui/dist/assets/chunk-SVP7TREG-r1chwRmg.js +88 -0
  71. package/ui/dist/assets/chunk-TICWLB2K-DL4atHUC.js +206 -0
  72. package/ui/dist/assets/chunk-XXDRQBXY-DXrM6jh7.js +1 -0
  73. package/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  74. package/ui/dist/assets/classDiagram-ZZMXUADV-BpQxfIQ7.js +1 -0
  75. package/ui/dist/assets/classDiagram-v2-VYDZK3BY-BpQxfIQ7.js +1 -0
  76. package/ui/dist/assets/cose-bilkent-JH36ORCC-Bhe-ZBlS.js +1 -0
  77. package/ui/dist/assets/cynefin-OW5HDTMX-D1koLd-G.js +1 -0
  78. package/ui/dist/assets/cynefinDiagram-5FMLGOSQ-BMcw40hV.js +62 -0
  79. package/ui/dist/assets/cytoscape.esm-CGd-uY3x.js +321 -0
  80. package/ui/dist/assets/dagre-GXQ25YYZ-DBun-Rs2.js +4 -0
  81. package/ui/dist/assets/dagre-PrKaheQc.js +1 -0
  82. package/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  83. package/ui/dist/assets/diagram-S7CK7UJ4-C1fUIGvo.js +30 -0
  84. package/ui/dist/assets/diagram-UQ7AKVKN-B5FDfkvB.js +41 -0
  85. package/ui/dist/assets/diagram-VSXAHHWV-CCIpOCNC.js +3 -0
  86. package/ui/dist/assets/diagram-VX7I27RA-DV0q2xo_.js +24 -0
  87. package/ui/dist/assets/diagram-Z3DM3KII-B3LCdNgI.js +24 -0
  88. package/ui/dist/assets/dist-EcW76Zvr.js +1 -0
  89. package/ui/dist/assets/ebnfDiagram-PWID7BFC-De_oRbYe.js +1 -0
  90. package/ui/dist/assets/erDiagram-RLTQ6QDP-B9NikMr1.js +99 -0
  91. package/ui/dist/assets/eventmodeling-NTZA5JFV-DZ4t581k.js +1 -0
  92. package/ui/dist/assets/flowDiagram-HODETNUW-DuTOJp1C.js +1 -0
  93. package/ui/dist/assets/ganttDiagram-EL5Y4UJY-BVTqqlPw.js +292 -0
  94. package/ui/dist/assets/gitGraph-4MIJSDKK-xRFaEJdZ.js +1 -0
  95. package/ui/dist/assets/gitGraphDiagram-WWUBYQGX-CK5_GCmX.js +106 -0
  96. package/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  97. package/ui/dist/assets/index-CjkpNftW.js +46 -0
  98. package/ui/dist/assets/index-DVIDTWin.css +1 -0
  99. package/ui/dist/assets/info-A6RAGUB7-Dg7z-CTI.js +1 -0
  100. package/ui/dist/assets/infoDiagram-27XIBGKW-YaqsOXh0.js +2 -0
  101. package/ui/dist/assets/init-C-OQMol4.js +1 -0
  102. package/ui/dist/assets/ishikawaDiagram-5VMMS53U-C-wtxZPa.js +70 -0
  103. package/ui/dist/assets/journeyDiagram-3NMN7TZE-DsXSXf16.js +139 -0
  104. package/ui/dist/assets/kanban-definition-UXKFOSKX-Ct59fuWk.js +89 -0
  105. package/ui/dist/assets/katex-CXMH3UgJ.js +257 -0
  106. package/ui/dist/assets/line-BUVw9Mhs.js +1 -0
  107. package/ui/dist/assets/linear-D8ncLO0X.js +1 -0
  108. package/ui/dist/assets/mermaid-parser.core-BDC4bG-O.js +7 -0
  109. package/ui/dist/assets/mermaid.core-DZY3WwzX.js +44 -0
  110. package/ui/dist/assets/mindmap-definition-YA3MSWOX-DtiuF6Ce.js +96 -0
  111. package/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  112. package/ui/dist/assets/packet-AYTQ26CC-BX2-w9rD.js +1 -0
  113. package/ui/dist/assets/path-fybaL0A-.js +1 -0
  114. package/ui/dist/assets/pegDiagram-XKGWAZYB-CotwuIHB.js +1 -0
  115. package/ui/dist/assets/pie-WAS4IAKB-Hth8ZVpm.js +1 -0
  116. package/ui/dist/assets/pieDiagram-E7YTZNPT-D9-INs8B.js +39 -0
  117. package/ui/dist/assets/quadrantDiagram-AXDQQJYC-CMtBXZGV.js +7 -0
  118. package/ui/dist/assets/radar-RG4KPBEZ-6UuwQPsw.js +1 -0
  119. package/ui/dist/assets/railroad-74A4TZTK-BF5TZgj0.js +1 -0
  120. package/ui/dist/assets/railroad-abnf-HS5TGJTU-3KYovtYT.js +1 -0
  121. package/ui/dist/assets/railroad-ebnf-LZEXJU2U-DeP4WedE.js +1 -0
  122. package/ui/dist/assets/railroad-peg-WCYAUIDC-i42N1-yk.js +1 -0
  123. package/ui/dist/assets/railroadDiagram-O6MQD6OU-BS6zkU3Z.js +1 -0
  124. package/ui/dist/assets/requirementDiagram-BXWQKSXE-J4IOB-kP.js +84 -0
  125. package/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  126. package/ui/dist/assets/sankeyDiagram-P5KCCOFB-jYTCuYzQ.js +40 -0
  127. package/ui/dist/assets/sequenceDiagram-WJ2MYXX4-Cd2r6PW-.js +162 -0
  128. package/ui/dist/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  129. package/ui/dist/assets/src-7TsfZXRO.js +1 -0
  130. package/ui/dist/assets/stateDiagram-D77RDMKH-DXOdtOKe.js +1 -0
  131. package/ui/dist/assets/stateDiagram-v2-MP3YSRHH-9h7sIf3R.js +1 -0
  132. package/ui/dist/assets/swimlanes-42K2YHIH-BvJGmGs1.js +1 -0
  133. package/ui/dist/assets/swimlanesDiagram-VR7AAH4N-QA4gPi6a.js +8 -0
  134. package/ui/dist/assets/timeline-definition-24CTP7MA-CAaD6-yd.js +120 -0
  135. package/ui/dist/assets/treeView-Q6P3EWNA-4gDEXIyJ.js +1 -0
  136. package/ui/dist/assets/treemap-WGGIJYW6-CGel5TwW.js +1 -0
  137. package/ui/dist/assets/vennDiagram-4TSXK5OY-DpxsKboL.js +34 -0
  138. package/ui/dist/assets/wardley-WFR3VGLG-Ck38v5ns.js +1 -0
  139. package/ui/dist/assets/wardleyDiagram-VM6X3IG4-Ba0grrqs.js +78 -0
  140. package/ui/dist/assets/xychartDiagram-S5SC5T6Z-DH_gC96K.js +7 -0
  141. package/ui/dist/index.html +13 -0
  142. package/ui/dist/kortext-logo-dark.svg +41 -0
  143. package/ui/dist/kortext-logo.svg +41 -0
  144. package/workflows/existing-project-analysis.md +87 -0
  145. package/workflows/new-project-analysis.md +98 -0
  146. package/workflows/planning-pipeline.md +61 -0
  147. package/AGENTS.md +0 -28
  148. package/agents/backend-developer.md +0 -132
  149. package/agents/delivery-manager.md +0 -121
  150. package/agents/engineering-manager.md +0 -208
  151. package/agents/frontend-developer.md +0 -174
  152. package/agents/operation-manager.md +0 -122
  153. package/hooks/audit-logger.sh +0 -25
  154. package/hooks/auto-locker.sh +0 -74
  155. package/hooks/auto-unlocker.sh +0 -17
  156. package/hooks/backlog-sync-guard.sh +0 -12
  157. package/hooks/branch-guard.sh +0 -32
  158. package/hooks/commit-msg-guard.sh +0 -43
  159. package/hooks/git-pre-commit.sh +0 -76
  160. package/hooks/git-pre-push.sh +0 -17
  161. package/hooks/handover-guard.sh +0 -33
  162. package/hooks/kortext-init.sh +0 -194
  163. package/hooks/kortext-lib.sh +0 -151
  164. package/hooks/lint-guard.sh +0 -50
  165. package/hooks/secret-scanner.sh +0 -89
  166. package/hooks/size-guard.sh +0 -48
  167. package/hooks/snapshot-guard.sh +0 -39
  168. package/hooks/write-guard.sh +0 -77
  169. package/rules/behavior.md +0 -252
  170. package/rules/branching.md +0 -82
  171. package/rules/commands.md +0 -112
  172. package/rules/emergency.md +0 -160
  173. package/rules/mcp.md +0 -47
  174. package/rules/models.md +0 -93
  175. package/scripts/kortext-backlog-add.py +0 -123
  176. package/scripts/kortext-backlog-health.py +0 -112
  177. package/scripts/kortext-backlog-sync.py +0 -117
  178. package/scripts/kortext-bulk-plan.py +0 -63
  179. package/scripts/kortext-cli.py +0 -376
  180. package/scripts/kortext-consistency-check.py +0 -74
  181. package/scripts/kortext-context-check.py +0 -146
  182. package/scripts/kortext-handover.py +0 -107
  183. package/scripts/kortext-item-check.py +0 -61
  184. package/scripts/kortext-item-start.py +0 -70
  185. package/scripts/kortext-item-transition.py +0 -78
  186. package/scripts/kortext-lock.py +0 -174
  187. package/scripts/kortext-session-start.py +0 -127
  188. package/scripts/lock_kortext.sh +0 -34
  189. package/settings/.claude-settings.template.json +0 -48
  190. package/settings/CHANGELOG.md +0 -176
  191. package/settings/INTEGRATION-MAP.md +0 -276
  192. package/settings/README.md +0 -255
  193. package/settings/USER-GUIDE.md +0 -502
  194. package/settings/VERSION +0 -1
  195. package/settings/config.md +0 -8
  196. package/settings/runtime-adapters.md +0 -104
  197. package/skills/backend-developer/.gitkeep +0 -0
  198. package/skills/compliance-expert/.gitkeep +0 -0
  199. package/skills/copywriter/.gitkeep +0 -0
  200. package/skills/db-admin/.gitkeep +0 -0
  201. package/skills/delivery-manager/.gitkeep +0 -0
  202. package/skills/designer/.gitkeep +0 -0
  203. package/skills/devops-engineer/.gitkeep +0 -0
  204. package/skills/engineering-manager/.gitkeep +0 -0
  205. package/skills/frontend-developer/.gitkeep +0 -0
  206. package/skills/growth-expert/.gitkeep +0 -0
  207. package/skills/operation-manager/.gitkeep +0 -0
  208. package/skills/product-manager/.gitkeep +0 -0
  209. package/skills/qa-engineer/.gitkeep +0 -0
  210. package/skills/security-engineer/.gitkeep +0 -0
  211. package/workflows/00-kortext-setup.md +0 -53
  212. package/workflows/01a-analysis-pipeline.md +0 -117
  213. package/workflows/01b-onboarding-pipeline.md +0 -110
  214. package/workflows/02-planning-pipeline.md +0 -146
  215. package/workflows/02b-spike-workflow.md +0 -120
  216. package/workflows/03-environment-setup.md +0 -99
  217. package/workflows/04-development-cycle.md +0 -203
  218. package/workflows/05-test-cycle.md +0 -123
  219. package/workflows/06-deployment-cycle.md +0 -224
  220. package/workflows/07-rollback-pipeline.md +0 -203
  221. package/workflows/08-hotfix-pipeline.md +0 -196
  222. package/workflows/09-maintenance-cycle.md +0 -133
  223. package/workspace/archive/.gitkeep +0 -0
  224. package/workspace/backups/.gitkeep +0 -0
  225. package/workspace/memory/backlog/.gitkeep +0 -0
  226. package/workspace/memory/backlog/BXX-[bug-name].md +0 -2
  227. package/workspace/memory/backlog/DXX-[debt-name].md +0 -2
  228. package/workspace/memory/backlog/README.md +0 -31
  229. package/workspace/memory/backlog/TXX-[task-name].md +0 -2
  230. package/workspace/memory/backlog/debt-dashboard.md +0 -3
  231. package/workspace/memory/backlog/epic-dashboard.md +0 -10
  232. package/workspace/memory/backlog/version-dashboard.md +0 -7
  233. package/workspace/memory/context/.gitkeep +0 -0
  234. package/workspace/memory/context/README.md +0 -35
  235. package/workspace/memory/context/[agent-name]-active.md +0 -2
  236. package/workspace/memory/decisions.md +0 -17
  237. package/workspace/memory/handover.md +0 -40
  238. package/workspace/memory/learned.md +0 -21
  239. package/workspace/references/access.md +0 -47
  240. package/workspace/references/api-reference.md +0 -59
  241. package/workspace/references/blueprint.md +0 -29
  242. package/workspace/references/content-strategy.md +0 -39
  243. package/workspace/references/db-schema.md +0 -51
  244. package/workspace/references/design-system.md +0 -107
  245. package/workspace/references/dictionary.md +0 -42
  246. package/workspace/references/file-system.md +0 -42
  247. package/workspace/references/growth-strategy.md +0 -46
  248. package/workspace/references/legal-strategy.md +0 -35
  249. package/workspace/references/required-skills.md +0 -11
  250. package/workspace/references/security-rules.md +0 -34
  251. package/workspace/references/tech-stack.md +0 -49
  252. package/workspace/references/test-strategy.md +0 -48
  253. package/workspace/reports/analysis-reports.md +0 -40
  254. package/workspace/reports/content-reports.md +0 -29
  255. package/workspace/reports/delivery-reports.md +0 -26
  256. package/workspace/reports/growth-reports.md +0 -9
  257. package/workspace/reports/legal-reports.md +0 -29
  258. package/workspace/reports/product-requirements.md +0 -28
  259. package/workspace/reports/release-notes.md +0 -20
  260. package/workspace/reports/security-reports.md +0 -27
  261. package/workspace/reports/status-reports.md +0 -48
  262. package/workspace/reports/tech-requirements.md +0 -30
  263. package/workspace/reports/test-reports.md +0 -61
  264. package/workspace/templates/BXX-[bug-name].md +0 -34
  265. package/workspace/templates/DXX-[debt-name].md +0 -35
  266. package/workspace/templates/EXX-[epic-name].md +0 -48
  267. package/workspace/templates/HXX-[hotfix-name].md +0 -73
  268. package/workspace/templates/SXX-[spike-name].md +0 -51
  269. package/workspace/templates/TXX-[task-name].md +0 -34
  270. package/workspace/templates/[agent-name]-active.md +0 -4
  271. package/workspace/templates/[original-name]_[YYYY-MM-DD_HHMMSS].md +0 -5
  272. package/workspace/templates/adr-XXX.md +0 -66
  273. package/workspace/templates/debt-dashboard.md +0 -10
  274. package/workspace/templates/epic-dashboard.md +0 -10
  275. package/workspace/templates/version-dashboard.md +0 -10
package/dist/app.js ADDED
@@ -0,0 +1,829 @@
1
+ import express from 'express';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import { join, sep } from 'node:path';
4
+ import { createProject, listProjects, removeProject, scaffoldProject, setArchived, uninstallContract, } from './projects.js';
5
+ import { analysisComplete, deliverRequests, discardOutgoing, docPath, docVersion, listDocs, loadDocMap, listVersions, markRequestHandled, readVersion, removeRequest, recordVersion, setFrontmatterStatus, templateFor, unfilledPlaceholders, } from './docs.js';
6
+ import { renderDesignPreview, writeDesignPreview } from './design-preview.js';
7
+ import { pickDirectoryNative } from './pick-directory.js';
8
+ import { readdirSync } from 'node:fs';
9
+ import { detectEngines, engineFor, forgetDetectedEngines, onPath, selectedEngine, setSetting, ENGINES, } from './engines.js';
10
+ import { abortRuns, advance, explainDoc, failStaleJobs, hasActiveRuns, listJobs, runStep, nextStep, proposeRevision, removeRunLogs, recheckDependents, reviseDoc, resumeStoppedRevisions, runPlanning, runningDoc, writingDoc, runningJob, } from './runner.js';
11
+ import { isChecking, readReadiness } from './readiness.js';
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { logRootDir } from './db.js';
14
+ import { isNewer, latestVersion, selfUpdate } from './update.js';
15
+ export function buildApp(db, pkgRoot, dbPath) {
16
+ failStaleJobs(db);
17
+ const app = express();
18
+ // Reject non-loopback Host/Origin values to prevent DNS rebinding and cross-site requests
19
+ // from reading or deleting project files. Simple POST requests do not require a preflight.
20
+ // Allow local development ports and normalize hostname case while preserving bracketed IPv6.
21
+ const isLocal = (value) => {
22
+ if (!value)
23
+ return false;
24
+ const host = value
25
+ .replace(/^\w+:\/\//, '')
26
+ .replace(/:\d+$/, '')
27
+ .toLowerCase();
28
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
29
+ };
30
+ app.use((req, res, next) => {
31
+ if (!isLocal(req.headers.host)) {
32
+ return res.status(403).json({ error: 'kortext answers on localhost only' });
33
+ }
34
+ // Same-origin fetches from the panel send no Origin on GET and the panel's
35
+ // own origin on the rest; a cross-site request always carries the attacker's.
36
+ const origin = req.headers.origin;
37
+ if (origin !== undefined && !isLocal(origin)) {
38
+ return res.status(403).json({ error: 'cross-origin requests are refused' });
39
+ }
40
+ next();
41
+ });
42
+ app.use(express.json());
43
+ let updating = false;
44
+ let resetting = 0;
45
+ // The whole package is replaced: block every API reader/writer, including
46
+ // polling's scaffold, until npm finishes. Health uses only boot-time data.
47
+ app.use('/api', (req, res, next) => {
48
+ if (updating && req.path !== '/health') {
49
+ return res.status(409).json({ error: 'kortext is updating — wait for it to finish' });
50
+ }
51
+ next();
52
+ });
53
+ const kickChain = (project) => {
54
+ const engine = engineFor(db, project);
55
+ if (engine)
56
+ void advance(db, project, engine, pkgRoot);
57
+ };
58
+ // Read once at boot: this is the version of the code actually running, which
59
+ // is not the version on disk after an upgrade the process never picked up.
60
+ const version = (() => {
61
+ try {
62
+ return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
63
+ }
64
+ catch {
65
+ return '';
66
+ }
67
+ })();
68
+ app.get('/api/health', (_req, res) => {
69
+ res.json({ ok: true, db: dbPath, version });
70
+ });
71
+ const stepRunning = () => resetting > 0 ||
72
+ hasActiveRuns() ||
73
+ !!db.prepare("SELECT 1 FROM jobs WHERE status = 'running' LIMIT 1").get();
74
+ // Refuse shutdown while work is active; closing the browser tab does not stop the server.
75
+ app.post('/api/quit', (_req, res) => {
76
+ if (stepRunning()) {
77
+ return res.status(409).json({ error: 'a step is running — wait for it, then quit' });
78
+ }
79
+ res.json({ ok: true });
80
+ // Flush the response before exiting so the client can confirm shutdown.
81
+ setTimeout(() => process.exit(0), 100);
82
+ });
83
+ // Offer self-update only for package paths under node_modules, excluding normal dev checkouts.
84
+ const managed = pkgRoot.includes(`${sep}node_modules${sep}`);
85
+ app.get('/api/version', async (_req, res) => {
86
+ const latest = managed ? await latestVersion() : null;
87
+ res.json({ current: version, latest, stale: !!latest && isNewer(latest, version) });
88
+ });
89
+ // Installing replaces files on disk; the running process keeps its boot-time version until restarted.
90
+ app.post('/api/version/update', async (_req, res) => {
91
+ if (!managed)
92
+ return res.status(400).json({ error: 'not an npm install — update it yourself' });
93
+ // Wait for active work before npm replaces files read by the runner.
94
+ if (stepRunning()) {
95
+ return res.status(409).json({ error: 'a step is running — wait for it, then update' });
96
+ }
97
+ updating = true;
98
+ try {
99
+ const result = await selfUpdate();
100
+ res.status(result.ok ? 200 : 500).json(result);
101
+ }
102
+ finally {
103
+ updating = false;
104
+ }
105
+ });
106
+ app.get('/api/projects', (_req, res) => {
107
+ // Count approved and not-applicable documents as settled.
108
+ const projects = listProjects(db).map((p) => {
109
+ const docCounts = { settled: 0, total: 0 };
110
+ try {
111
+ for (const d of listDocs(db, p, pkgRoot)) {
112
+ docCounts.total++;
113
+ if (d.status === 'approved' || d.status === 'not-applicable')
114
+ docCounts.settled++;
115
+ }
116
+ }
117
+ catch {
118
+ /* repo may be gone; the card still renders */
119
+ }
120
+ return { ...p, docCounts };
121
+ });
122
+ res.json({ projects });
123
+ });
124
+ app.post('/api/projects', (req, res) => {
125
+ const { name, repoPath, kind, code, brief, docLang, engine } = req.body ?? {};
126
+ try {
127
+ const project = createProject(db, { name, repoPath, kind, code, brief, docLang, engine }, pkgRoot);
128
+ // Nothing runs on Add — the project lands paused and the user presses
129
+ // Start on the project screen (Start = the unpause endpoint).
130
+ db.prepare('UPDATE projects SET paused = 1 WHERE id = ?').run(project.id);
131
+ res.status(201).json({ project: { ...project, paused: 1 } });
132
+ }
133
+ catch (err) {
134
+ res.status(400).json({ error: err.message });
135
+ }
136
+ });
137
+ app.get('/api/engines', (_req, res) => {
138
+ res.json({ engines: detectEngines(), selected: selectedEngine(db)?.id ?? null });
139
+ });
140
+ app.put('/api/engines', (req, res) => {
141
+ const { id } = req.body ?? {};
142
+ forgetDetectedEngines(); // the user is looking at this list; read the disk again
143
+ if (!ENGINES.some((e) => e.id === id))
144
+ return res.status(400).json({ error: 'unknown engine' });
145
+ setSetting(db, 'engine', String(id));
146
+ res.json({ selected: id });
147
+ });
148
+ // A project's own engine — changed mid-flight when a quota runs out. Only the
149
+ // steps that start after it see the change; a running one finishes on the old CLI.
150
+ app.put('/api/projects/:id/engine', (req, res) => {
151
+ const project = projectOr404(req.params.id, res);
152
+ if (!project)
153
+ return;
154
+ const { id } = req.body ?? {};
155
+ if (!ENGINES.some((e) => e.id === id))
156
+ return res.status(400).json({ error: 'unknown engine' });
157
+ // A model name belongs to one CLI — `sonnet` means nothing to codex — so a
158
+ // switch drops it back to the new CLI's default unless the new CLI knows it.
159
+ const keeps = ENGINES.find((e) => e.id === id)?.models.includes(project.model ?? '') ?? false;
160
+ db.prepare('UPDATE projects SET engine = ?, model = ? WHERE id = ?').run(String(id), keeps ? project.model : '', project.id);
161
+ res.json({ engine: id, model: keeps ? project.model : '' });
162
+ });
163
+ // The model that CLI is told to use — free text, because each CLI names its
164
+ // models its own way and a new one appears before this list would. Empty
165
+ // means the CLI's own default. Like the engine, only later steps see it.
166
+ app.put('/api/projects/:id/model', (req, res) => {
167
+ const project = projectOr404(req.params.id, res);
168
+ if (!project)
169
+ return;
170
+ const model = String(req.body?.model ?? '').trim();
171
+ if (model.length > 80 || /[\s"'`]/.test(model))
172
+ return res.status(400).json({ error: 'a model name is one word' });
173
+ db.prepare('UPDATE projects SET model = ? WHERE id = ?').run(model, project.id);
174
+ res.json({ model });
175
+ });
176
+ app.get('/api/projects/:id/jobs', (req, res) => {
177
+ const project = projectOr404(req.params.id, res);
178
+ if (!project)
179
+ return;
180
+ res.json({ jobs: listJobs(db, project.id), running: runningJob(db, project.id) ?? null });
181
+ });
182
+ // Start the chain asynchronously; the panel polls jobs and documents for progress.
183
+ app.post('/api/projects/:id/run-next', (req, res) => {
184
+ const project = projectOr404(req.params.id, res);
185
+ if (!project)
186
+ return;
187
+ const engine = engineFor(db, project);
188
+ if (!engine)
189
+ return res.status(409).json({ error: 'no agent CLI installed' });
190
+ const step = nextStep(db, project, pkgRoot);
191
+ if (!step) {
192
+ return res.status(409).json({
193
+ error: runningJob(db, project.id) ? 'a step is already running' : 'nothing to run',
194
+ });
195
+ }
196
+ void advance(db, project, engine, pkgRoot);
197
+ res.status(202).json({ started: step.output });
198
+ });
199
+ app.post('/api/projects/:id/docs/retry', (req, res) => {
200
+ const project = projectOr404(req.params.id, res);
201
+ if (!project)
202
+ return;
203
+ const rel = String(req.body?.rel ?? '');
204
+ const job = listJobs(db, project.id).find((j) => j.doc_rel === rel);
205
+ if (!job || !['failed', 'stopped'].includes(job.status)) {
206
+ return res.status(409).json({ error: 'no failed attempt to retry' });
207
+ }
208
+ if (project.paused || runningDoc(db, project.id, rel)) {
209
+ return res
210
+ .status(409)
211
+ .json({ error: 'Continue the project and wait for this document to finish' });
212
+ }
213
+ const engine = engineFor(db, project);
214
+ if (!engine)
215
+ return res.status(409).json({ error: 'no agent CLI installed' });
216
+ if (job.kind === 'recheck') {
217
+ void advance(db, project, engine, pkgRoot);
218
+ }
219
+ else {
220
+ const step = loadDocMap(pkgRoot, project.kind ?? 'new').get(rel);
221
+ const doc = listDocs(db, project, pkgRoot).find((d) => d.rel === rel);
222
+ if (!step || doc?.blocked)
223
+ return res.status(409).json({ error: 'document inputs are not settled' });
224
+ const notes = JSON.parse(job.notes);
225
+ if (notes.length)
226
+ void reviseDoc(db, project, rel, notes, engine, pkgRoot);
227
+ else
228
+ void runStep(db, project, step, engine, pkgRoot).then((out) => {
229
+ if (out.ok)
230
+ kickChain(project);
231
+ });
232
+ }
233
+ res.status(202).json({ started: rel });
234
+ });
235
+ // Return the latest readiness verdict and whether a check is active.
236
+ app.get('/api/projects/:id/readiness', (req, res) => {
237
+ const project = projectOr404(req.params.id, res);
238
+ if (!project)
239
+ return;
240
+ // Expose a missing CLI as an actionable readiness error.
241
+ if (!engineFor(db, project)) {
242
+ return res.json({
243
+ readiness: {
244
+ ready: false,
245
+ stage: 'no-engine',
246
+ questions: [
247
+ 'Kortext drives your own agent CLI; none is installed yet.',
248
+ 'Install one — claude, codex, antigravity or gemini — then pick it in the header and press Start.',
249
+ ],
250
+ briefHash: '',
251
+ checkedAt: new Date().toISOString(),
252
+ },
253
+ checking: false,
254
+ });
255
+ }
256
+ res.json({ readiness: readReadiness(project), checking: isChecking(project.id) });
257
+ });
258
+ // Native folder chooser (macOS osascript; other platforms return null and
259
+ // the UI falls back to a typed path).
260
+ app.post('/api/pick-directory', (_req, res) => {
261
+ void pickDirectoryNative().then((path) => res.json({ path }));
262
+ });
263
+ // Archive without deleting the registry row or project files.
264
+ app.post('/api/projects/:id/archive', (req, res) => {
265
+ const project = projectOr404(req.params.id, res);
266
+ if (!project)
267
+ return;
268
+ const archived = req.body?.archived !== false;
269
+ setArchived(db, project.id, archived);
270
+ res.json({ archived: archived ? 1 : 0 });
271
+ });
272
+ app.delete('/api/projects/:id', (req, res) => {
273
+ const removed = removeProject(db, Number(req.params.id));
274
+ res.status(removed ? 200 : 404).json({ removed });
275
+ });
276
+ // Pause aborts active runs and stops scheduling; Continue resumes interrupted work and the chain.
277
+ app.post('/api/projects/:id/pause', (req, res) => {
278
+ const project = projectOr404(req.params.id, res);
279
+ if (!project)
280
+ return;
281
+ const paused = req.body?.paused ? 1 : 0;
282
+ db.prepare('UPDATE projects SET paused = ? WHERE id = ?').run(paused, project.id);
283
+ if (paused)
284
+ abortRuns(project.id);
285
+ else {
286
+ const resumed = { ...project, paused: 0 };
287
+ const engine = engineFor(db, resumed);
288
+ // A revision the pause stopped left the document at approved or
289
+ // not-applicable, where the chain cannot see it. Pick those up first.
290
+ if (engine)
291
+ void resumeStoppedRevisions(db, resumed, engine, pkgRoot);
292
+ kickChain(resumed);
293
+ }
294
+ res.json({ paused: !!paused });
295
+ });
296
+ // Restart: reset analysis outputs, keeping the human's brief and independent Kopeng work.
297
+ app.post('/api/projects/:id/restart', async (req, res) => {
298
+ const project = projectOr404(req.params.id, res);
299
+ if (!project)
300
+ return;
301
+ resetting++;
302
+ try {
303
+ // Pause before aborting so the chain cannot schedule more steps while cleanup waits.
304
+ db.prepare('UPDATE projects SET paused = 1 WHERE id = ?').run(project.id);
305
+ abortRuns(project.id);
306
+ // Wait longer than SIGTERM-to-SIGKILL escalation before deleting files the CLI may write.
307
+ await new Promise((r) => setTimeout(r, 2500));
308
+ const kortext = join(project.repo_path, '.kortext');
309
+ if (existsSync(kortext)) {
310
+ // Leave the brief in place: deleting then restoring risks losing it on a crash.
311
+ for (const name of readdirSync(kortext)) {
312
+ if (name !== 'BRIEF.md')
313
+ rmSync(join(kortext, name), { recursive: true, force: true });
314
+ }
315
+ }
316
+ db.prepare('DELETE FROM jobs WHERE project_id = ?').run(project.id);
317
+ db.prepare('DELETE FROM pending_rechecks WHERE project_id = ?').run(project.id);
318
+ scaffoldProject(project.repo_path, pkgRoot, { skipBrief: project.kind === 'existing' });
319
+ // Restart lands in the same ready state as a fresh Add: nothing runs
320
+ // until the user presses Start.
321
+ db.prepare('UPDATE projects SET paused = 1 WHERE id = ?').run(project.id);
322
+ res.json({ ok: true });
323
+ }
324
+ catch (err) {
325
+ res.status(400).json({ error: err.message });
326
+ }
327
+ finally {
328
+ resetting--;
329
+ }
330
+ });
331
+ // Cancel: the user is done with kortext for this project — remove every
332
+ // trace of its analysis (.kortext/, the AGENTS.md block and the CLAUDE.md
333
+ // pointer) and the registry row. Kopeng is independent and stays, as does
334
+ // the user's own content in AGENTS.md and CLAUDE.md.
335
+ app.post('/api/projects/:id/cancel', async (req, res) => {
336
+ const project = projectOr404(req.params.id, res);
337
+ if (!project)
338
+ return;
339
+ resetting++;
340
+ try {
341
+ // Pause before aborting, for the reason restart gives above.
342
+ db.prepare('UPDATE projects SET paused = 1 WHERE id = ?').run(project.id);
343
+ abortRuns(project.id);
344
+ await new Promise((r) => setTimeout(r, 2500)); // as restart: outlast the SIGKILL escalation
345
+ rmSync(join(project.repo_path, '.kortext'), { recursive: true, force: true });
346
+ uninstallContract(project.repo_path);
347
+ removeRunLogs(project.id, logRootDir(db));
348
+ removeProject(db, project.id);
349
+ // Abort any run registered during the cleanup delay.
350
+ abortRuns(project.id);
351
+ res.json({ ok: true });
352
+ }
353
+ catch (err) {
354
+ res.status(400).json({ error: err.message });
355
+ }
356
+ finally {
357
+ resetting--;
358
+ }
359
+ });
360
+ const projectOr404 = (id, res) => {
361
+ const p = db.prepare('SELECT * FROM projects WHERE id = ?').get(Number(id));
362
+ if (!p)
363
+ res.status(404).json({ error: 'project not found' });
364
+ return p;
365
+ };
366
+ // Both actions must apply to the exact text the person reviewed. A running
367
+ // writer is refused even if it has not changed the file yet.
368
+ const reviewedPath = (project, req, res) => {
369
+ const { rel, expectedVersion } = req.body ?? {};
370
+ const path = docPath(project, String(rel));
371
+ if (writingDoc(db, project.id, String(rel))) {
372
+ res.status(409).json({ error: `${rel} is being rewritten — wait for it to land` });
373
+ return null;
374
+ }
375
+ if (typeof expectedVersion !== 'string') {
376
+ res.status(428).json({ error: 'Reload the document before saving or approving it' });
377
+ return null;
378
+ }
379
+ if (docVersion(readFileSync(path, 'utf8')) !== expectedVersion) {
380
+ res.status(409).json({
381
+ error: 'This document changed. Keep your edits, then reopen it to review the latest text.',
382
+ });
383
+ return null;
384
+ }
385
+ return path;
386
+ };
387
+ app.get('/api/projects/:id/docs', (req, res) => {
388
+ const project = projectOr404(req.params.id, res);
389
+ if (!project)
390
+ return;
391
+ // Restore missing document skeletons and refresh the contract block during polling.
392
+ try {
393
+ scaffoldProject(project.repo_path, pkgRoot, { skipBrief: project.kind === 'existing' });
394
+ }
395
+ catch {
396
+ /* repo may be gone; listing still answers */
397
+ }
398
+ res.json({ docs: listDocs(db, project, pkgRoot) });
399
+ });
400
+ app.get('/api/projects/:id/docs/content', (req, res) => {
401
+ const project = projectOr404(req.params.id, res);
402
+ if (!project)
403
+ return;
404
+ try {
405
+ const rel = String(req.query.rel ?? '');
406
+ const content = readFileSync(docPath(project, rel), 'utf8');
407
+ res.json({ rel, content, version: docVersion(content) });
408
+ }
409
+ catch (err) {
410
+ res.status(400).json({ error: err.message });
411
+ }
412
+ });
413
+ // What this document said before. Only writes that rewrote the prose are here.
414
+ app.get('/api/projects/:id/docs/history', (req, res) => {
415
+ const project = projectOr404(req.params.id, res);
416
+ if (!project)
417
+ return;
418
+ const rel = String(req.query.rel ?? '');
419
+ if (!docPath(project, rel))
420
+ return res.status(400).json({ error: 'rel required' });
421
+ res.json({ versions: listVersions(db, project, rel) });
422
+ });
423
+ app.get('/api/projects/:id/docs/history/:versionId', (req, res) => {
424
+ const project = projectOr404(req.params.id, res);
425
+ if (!project)
426
+ return;
427
+ const row = readVersion(db, project, Number(req.params.versionId));
428
+ if (!row)
429
+ return res.status(404).json({ error: 'no such version' });
430
+ res.json(row);
431
+ });
432
+ // Direct edit from the drawer — writes the file as-is.
433
+ app.put('/api/projects/:id/docs/content', (req, res) => {
434
+ const project = projectOr404(req.params.id, res);
435
+ if (!project)
436
+ return;
437
+ const { rel, content, settleRequests } = req.body ?? {};
438
+ // Reject empty saves to prevent accidental loss of document contents and approval state.
439
+ if (typeof content !== 'string' || content.trim() === '') {
440
+ return res.status(400).json({ error: 'content is required' });
441
+ }
442
+ try {
443
+ const path = reviewedPath(project, req, res);
444
+ if (!path)
445
+ return;
446
+ const wasApproved = listDocs(db, project, pkgRoot).find((d) => d.rel === String(rel))?.status === 'approved';
447
+ const priorText = readFileSync(path, 'utf8');
448
+ writeFileSync(path, content, 'utf8');
449
+ recordVersion(db, project, String(rel), content, settleRequests ? 'proposal' : 'prime', priorText);
450
+ writeDesignPreview(project);
451
+ // Saving a requested proposal settles its incoming revision requests.
452
+ if (settleRequests) {
453
+ for (const r of listDocs(db, project, pkgRoot).find((d) => d.rel === String(rel))
454
+ ?.revisionRequests ?? []) {
455
+ removeRequest(project, String(rel), r.from, r.reason);
456
+ }
457
+ }
458
+ // Re-evaluate readiness and producibility after edits.
459
+ kickChain(project);
460
+ // Recheck approved readers against the updated source document.
461
+ if (wasApproved) {
462
+ const engine = engineFor(db, project);
463
+ recheckDependents(db, project, String(rel), engine, pkgRoot);
464
+ }
465
+ const saved = readFileSync(path, 'utf8');
466
+ res.json({ ok: true, content: saved, version: docVersion(saved) });
467
+ }
468
+ catch (err) {
469
+ res.status(400).json({ error: err.message });
470
+ }
471
+ });
472
+ // Render the current DESIGN.md tokens on each preview request.
473
+ app.get('/api/projects/:id/docs/design-preview', (req, res) => {
474
+ const project = projectOr404(req.params.id, res);
475
+ if (!project)
476
+ return;
477
+ const path = join(project.repo_path, '.kortext', 'DESIGN.md');
478
+ if (!existsSync(path))
479
+ return res.status(404).json({ error: 'no DESIGN.md in this project' });
480
+ const html = renderDesignPreview(readFileSync(path, 'utf8'), project.name);
481
+ // Also left on disk, so the page is shareable without the panel running.
482
+ try {
483
+ writeDesignPreview(project);
484
+ }
485
+ catch {
486
+ /* read-only repo still gets the page in the browser */
487
+ }
488
+ res.type('html').send(html);
489
+ });
490
+ // Prime approval: draft → approved (frontmatter is the source of truth).
491
+ app.post('/api/projects/:id/docs/approve', (req, res) => {
492
+ const project = projectOr404(req.params.id, res);
493
+ if (!project)
494
+ return;
495
+ const { rel, force } = req.body ?? {};
496
+ try {
497
+ const path = reviewedPath(project, req, res);
498
+ if (!path)
499
+ return;
500
+ const doc = listDocs(db, project, pkgRoot).find((d) => d.rel === String(rel));
501
+ if (doc?.status !== 'draft' || doc.openQuestions || doc.outgoing.length > 0) {
502
+ return res.status(409).json({
503
+ error: 'Only a draft with no open questions and no unsent change requests can be approved',
504
+ });
505
+ }
506
+ // Template lines the agent never replaced. A real test approved a
507
+ // DATABASE.md still carrying `### Table: `[table_name]``, which then reads
508
+ // as an approved database design. Prime can still insist.
509
+ if (!force) {
510
+ const left = unfilledPlaceholders(readFileSync(path, 'utf8'), templateFor(pkgRoot, doc.rel));
511
+ if (left.length > 0) {
512
+ return res.status(409).json({
513
+ error: 'This document still carries template placeholders',
514
+ placeholders: left,
515
+ });
516
+ }
517
+ }
518
+ setFrontmatterStatus(path, 'approved');
519
+ // Every request this document sends went out with prime's Send before
520
+ // approval could pass; this sweep is for documents approved before
521
+ // requests travelled.
522
+ deliverRequests(project, String(rel));
523
+ // Approving edits the file, so without this the recorded head no longer
524
+ // matches what is on disk and the panel refuses to diff — the diff would
525
+ // vanish the moment prime approved, although the body never changed.
526
+ recordVersion(db, project, String(rel), readFileSync(path, 'utf8'), 'prime', null);
527
+ writeDesignPreview(project);
528
+ kickChain(project);
529
+ // Recheck already-approved readers; there are none on the initial pass.
530
+ const engine = engineFor(db, project);
531
+ recheckDependents(db, project, String(rel), engine, pkgRoot);
532
+ res.json({ ok: true });
533
+ }
534
+ catch (err) {
535
+ res.status(400).json({ error: err.message });
536
+ }
537
+ });
538
+ // Draft the incoming revision requests from the documents, rather than accepting notes from the client.
539
+ app.post('/api/projects/:id/docs/propose', async (req, res) => {
540
+ const project = projectOr404(req.params.id, res);
541
+ if (!project)
542
+ return;
543
+ const engine = engineFor(db, project);
544
+ if (!engine)
545
+ return res.status(409).json({ error: 'no agent CLI installed' });
546
+ const { rel } = req.body ?? {};
547
+ try {
548
+ docPath(project, String(rel)); // validates rel
549
+ }
550
+ catch (err) {
551
+ return res.status(400).json({ error: err.message });
552
+ }
553
+ const notes = listDocs(db, project, pkgRoot)
554
+ .find((d) => d.rel === String(rel))
555
+ ?.revisionRequests.map((r) => r.reason) ?? [];
556
+ if (notes.length === 0) {
557
+ return res.status(409).json({ error: `nothing is asking ${rel} to change` });
558
+ }
559
+ try {
560
+ const { proposal } = await proposeRevision(db, project, String(rel), notes, engine, pkgRoot);
561
+ res.json({ proposal });
562
+ }
563
+ catch (err) {
564
+ res.status(500).json({ error: err.message });
565
+ }
566
+ });
567
+ app.post('/api/projects/:id/docs/revise', (req, res) => {
568
+ const project = projectOr404(req.params.id, res);
569
+ if (!project)
570
+ return;
571
+ const engine = engineFor(db, project);
572
+ if (!engine)
573
+ return res.status(409).json({ error: 'no agent CLI installed' });
574
+ const { rel, notes } = req.body ?? {};
575
+ if (!Array.isArray(notes) || notes.length === 0) {
576
+ return res.status(400).json({ error: 'notes required' });
577
+ }
578
+ try {
579
+ docPath(project, String(rel)); // validates rel
580
+ }
581
+ catch (err) {
582
+ return res.status(400).json({ error: err.message });
583
+ }
584
+ // Validate before returning 202 so a rejected revision does not discard the client's notes.
585
+ if (!loadDocMap(pkgRoot, project.kind ?? 'new').has(String(rel))) {
586
+ return res.status(409).json({ error: `${rel} is prime's own document — edit it here` });
587
+ }
588
+ if (runningDoc(db, project.id, String(rel))) {
589
+ return res.status(409).json({ error: `${rel} is being rewritten — wait for it to land` });
590
+ }
591
+ void reviseDoc(db, project, String(rel), notes.map(String), engine, pkgRoot);
592
+ res.status(202).json({ started: rel });
593
+ });
594
+ /*
595
+ * There is no route to settle a finding or a conflict. Both are records the
596
+ * next writer reads, not decisions owed to prime — a conflict was already
597
+ * decided when the change request was denied, and a finding names a file no
598
+ * document owns. Settling them was asking prime twice.
599
+ */
600
+ /**
601
+ * Settle everything owed on one document in a single decision.
602
+ *
603
+ * Prime's answers to the questions, the change requests they accepted and the
604
+ * ones they denied all arrive together, because they all rewrite the same
605
+ * document and a document is rewritten once. Sent separately, the first press
606
+ * would start a run and the rest would come back refused.
607
+ *
608
+ * A denied request stays in the document, ticked, with prime's reason under
609
+ * it: the asking is settled, the contradiction is not, and the next agent to
610
+ * rewrite this document reads that here.
611
+ */
612
+ app.post('/api/projects/:id/docs/settle-requests', (req, res) => {
613
+ const project = projectOr404(req.params.id, res);
614
+ if (!project)
615
+ return;
616
+ const { rel, apply, deny, answers, send, discard } = req.body ?? {};
617
+ const doc = listDocs(db, project, pkgRoot).find((d) => d.rel === String(rel ?? ''));
618
+ if (!doc)
619
+ return res.status(404).json({ error: `no such document: ${rel}` });
620
+ const pick = (list) => (Array.isArray(list) ? list : [])
621
+ .map((r) => {
622
+ const found = doc.revisionRequests.find((x) => x.from === String(r.from ?? '') && x.reason === String(r.reason ?? ''));
623
+ return found ? { ...found, note: String(r.note ?? '').trim() } : undefined;
624
+ })
625
+ .filter((r) => !!r);
626
+ const applying = pick(apply);
627
+ const denying = pick(deny);
628
+ const said = (Array.isArray(answers) ? answers : []).map(String).filter((a) => a.trim());
629
+ // What this document asks of others: sent now, or dropped before it goes.
630
+ const pickOut = (list) => (Array.isArray(list) ? list : [])
631
+ .map((r) => doc.outgoing.find((x) => x.target === String(r.target ?? '') && x.reason === String(r.reason ?? '')))
632
+ .filter((r) => !!r);
633
+ const sending = pickOut(send);
634
+ const discarding = pickOut(discard);
635
+ if (applying.length === 0 &&
636
+ denying.length === 0 &&
637
+ said.length === 0 &&
638
+ sending.length === 0 &&
639
+ discarding.length === 0) {
640
+ return res.status(409).json({ error: 'there is nothing left to settle here' });
641
+ }
642
+ for (const r of discarding)
643
+ discardOutgoing(project, doc.rel, r.target, r.reason);
644
+ if (sending.length > 0)
645
+ deliverRequests(project, doc.rel, sending);
646
+ // A refusal goes into the document's `## Decisions`, reason under it. That
647
+ // line IS the record — the next agent to rewrite this document reads it
648
+ // there, and the build phase inherits it from there.
649
+ for (const r of denying) {
650
+ markRequestHandled(project, doc.rel, r.from, r.reason, r.note || 'no change made');
651
+ }
652
+ // Denials, sends and discards change nothing in the text; nothing to rewrite.
653
+ if (applying.length === 0 && said.length === 0)
654
+ return res.json({
655
+ applied: 0,
656
+ denied: denying.length,
657
+ sent: sending.length,
658
+ discarded: discarding.length,
659
+ });
660
+ if (!doc.hasProducingStep) {
661
+ return res
662
+ .status(409)
663
+ .json({ error: `${doc.rel} is prime's own document — open it and draft the change there` });
664
+ }
665
+ const engine = engineFor(db, project);
666
+ if (!engine)
667
+ return res.status(409).json({ error: 'no agent CLI installed' });
668
+ if (runningDoc(db, project.id, doc.rel)) {
669
+ return res.status(409).json({ error: `${doc.rel} is being rewritten — wait for it to land` });
670
+ }
671
+ setFrontmatterStatus(docPath(project, doc.rel), 'draft');
672
+ void reviseDoc(db, project, doc.rel, [
673
+ ...said,
674
+ ...applying.flatMap((r) => r.note
675
+ ? [`[${r.from} asks] ${r.reason}`, `[prime decides] ${r.note}`]
676
+ : [`[${r.from} asks] ${r.reason}`]),
677
+ ], engine, pkgRoot).catch((err) => console.error(`settle-requests follow-up failed for ${doc.rel}:`, err));
678
+ res.status(202).json({
679
+ applied: applying.length,
680
+ denied: denying.length,
681
+ answered: said.length,
682
+ sent: sending.length,
683
+ discarded: discarding.length,
684
+ });
685
+ });
686
+ // Return line-anchored Q&A without modifying documents; CLI output is logged.
687
+ app.post('/api/projects/:id/docs/explain', (req, res) => {
688
+ const project = projectOr404(req.params.id, res);
689
+ if (!project)
690
+ return;
691
+ const engine = engineFor(db, project);
692
+ if (!engine)
693
+ return res.status(409).json({ error: 'no agent CLI installed' });
694
+ const { rel, excerpt, question } = req.body ?? {};
695
+ if (!question || typeof question !== 'string') {
696
+ return res.status(400).json({ error: 'question required' });
697
+ }
698
+ const history = Array.isArray(req.body?.history)
699
+ ? req.body.history.map((h) => ({
700
+ q: String(h.q ?? ''),
701
+ a: String(h.a ?? ''),
702
+ }))
703
+ : [];
704
+ explainDoc(db, project, String(rel ?? ''), String(excerpt ?? ''), question, history, engine, pkgRoot)
705
+ // The answer carries its author: the project's own CLI, which is not
706
+ // always the one the global setting names.
707
+ .then((r) => res.json({ ...r, answeredBy: engine.id }))
708
+ .catch((err) => res.status(500).json({ error: err.message }));
709
+ });
710
+ // Export the completed analysis as a Kopeng plan.
711
+ app.post('/api/projects/:id/transfer', (req, res) => {
712
+ const project = projectOr404(req.params.id, res);
713
+ if (!project)
714
+ return;
715
+ const engine = engineFor(db, project);
716
+ if (!engine)
717
+ return res.status(409).json({ error: 'no agent CLI installed' });
718
+ if (!analysisComplete(db, project, pkgRoot)) {
719
+ return res.status(409).json({ error: 'analysis is not complete yet' });
720
+ }
721
+ if (runningJob(db, project.id))
722
+ return res.status(409).json({ error: 'a job is already running' });
723
+ const notes = Array.isArray(req.body?.notes) ? req.body.notes.map(String) : [];
724
+ void runPlanning(db, project, engine, pkgRoot, notes);
725
+ res.status(202).json({ started: '.kopeng/' });
726
+ });
727
+ // Plan summary: what the split produced + its approval status.
728
+ app.get('/api/projects/:id/kopeng', (req, res) => {
729
+ const project = projectOr404(req.params.id, res);
730
+ if (!project)
731
+ return;
732
+ const dir = join(project.repo_path, '.kopeng');
733
+ const count = (sub, ext) => {
734
+ try {
735
+ return readdirSync(join(dir, sub)).filter((f) => f.endsWith(ext)).length;
736
+ }
737
+ catch {
738
+ return 0;
739
+ }
740
+ };
741
+ let status = null;
742
+ try {
743
+ status =
744
+ readFileSync(join(dir, 'project.yaml'), 'utf8')
745
+ .match(/^status:\s*(.+)$/m)?.[1]
746
+ ?.trim() ?? null;
747
+ }
748
+ catch {
749
+ /* not produced yet */
750
+ }
751
+ res.json({
752
+ exists: status !== null,
753
+ status,
754
+ versions: count('versions', '.yaml'),
755
+ epics: count('epics', '.yaml'),
756
+ tasks: count('tasks', '.md'),
757
+ });
758
+ });
759
+ // Prime approves the plan — the last act of the handshake.
760
+ app.post('/api/projects/:id/kopeng/approve', (req, res) => {
761
+ const project = projectOr404(req.params.id, res);
762
+ if (!project)
763
+ return;
764
+ const p = join(project.repo_path, '.kopeng', 'project.yaml');
765
+ // A plan is approvable when the split finished and left tasks behind — a
766
+ // failed split leaves project.yaml too, and an empty plan approved is a
767
+ // handshake over nothing.
768
+ const last = listJobs(db, project.id).find((j) => j.doc_rel === '.kopeng/');
769
+ if (last && last.status !== 'done') {
770
+ return res
771
+ .status(409)
772
+ .json({ error: `the last split ${last.status}: ${last.error ?? 'retry it first'}` });
773
+ }
774
+ let tasks = 0;
775
+ try {
776
+ tasks = readdirSync(join(project.repo_path, '.kopeng', 'tasks')).filter((f) => f.endsWith('.md')).length;
777
+ }
778
+ catch {
779
+ /* no tasks dir */
780
+ }
781
+ if (tasks === 0)
782
+ return res.status(409).json({ error: 'the plan has no tasks to approve' });
783
+ try {
784
+ const body = readFileSync(p, 'utf8');
785
+ writeFileSync(p, /^status:/m.test(body)
786
+ ? body.replace(/^status:.*$/m, 'status: approved')
787
+ : `status: approved
788
+ ${body}`, 'utf8');
789
+ res.json({ ok: true });
790
+ }
791
+ catch (err) {
792
+ res.status(400).json({ error: err.message });
793
+ }
794
+ });
795
+ // Handshake state: analysis done? kopeng around? tasks already exported?
796
+ app.get('/api/projects/:id/handshake', (req, res) => {
797
+ const project = projectOr404(req.params.id, res);
798
+ if (!project)
799
+ return;
800
+ const kopengDir = join(project.repo_path, '.kopeng');
801
+ let transferred = false;
802
+ try {
803
+ transferred = readdirSync(kopengDir).length > 0;
804
+ }
805
+ catch {
806
+ /* no .kopeng dir */
807
+ }
808
+ // The handover, counted rather than gated: a conflict or a finding is work
809
+ // deferred to the build phase, and prime should see how much of it there is
810
+ // without being asked to settle any of it here.
811
+ const docs = listDocs(db, project, pkgRoot).filter((d) => d.status !== 'uninitialized');
812
+ res.json({
813
+ analysisComplete: analysisComplete(db, project, pkgRoot),
814
+ kopengInstalled: onPath('kopeng'),
815
+ transferred,
816
+ documents: docs.length,
817
+ handedOver: docs.reduce((n, d) => n + d.denied.length + d.conflicts.length + d.warnings.length, 0),
818
+ });
819
+ });
820
+ // Return JSON for unknown API routes instead of serving the HTML application shell.
821
+ app.use('/api', (req, res) => res.status(404).json({ error: `no such endpoint: ${req.method} ${req.originalUrl}` }));
822
+ // Built panel (ui/dist) with SPA fallback; in dev the vite server proxies /api here.
823
+ const uiDist = join(pkgRoot, 'ui', 'dist');
824
+ if (existsSync(uiDist)) {
825
+ app.use(express.static(uiDist));
826
+ app.get(/^\/(?!api\/).*/, (_req, res) => res.sendFile(join(uiDist, 'index.html')));
827
+ }
828
+ return app;
829
+ }