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/runner.js ADDED
@@ -0,0 +1,771 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { logPathFor } from './db.js';
5
+ import { spawnCli } from './cli-spawn.js';
6
+ import { detectEngines, engineArgs, engineEnv } from './engines.js';
7
+ import { writeDesignPreview } from './design-preview.js';
8
+ import { appendIncomingRequest, docPath, listDocs, loadDocMap, readFrontmatter, removeRequest, recordVersion, restoreRequests, setFrontmatterStatus, templateFor, unfilledPlaceholders, workflowNameFor, } from './docs.js';
9
+ import { scaffoldProject } from './projects.js';
10
+ import { ensureReadiness } from './readiness.js';
11
+ const STEP_TIMEOUT_MS = 15 * 60 * 1000;
12
+ const PLAN_TIMEOUT_MS = 30 * 60 * 1000;
13
+ export function listJobs(db, projectId) {
14
+ return db
15
+ .prepare('SELECT * FROM jobs WHERE project_id = ? ORDER BY id DESC LIMIT 50')
16
+ .all(projectId);
17
+ }
18
+ export function runningJob(db, projectId) {
19
+ return db
20
+ .prepare("SELECT * FROM jobs WHERE project_id = ? AND status = 'running' LIMIT 1")
21
+ .get(projectId);
22
+ }
23
+ /** Is this one document being written right now? */
24
+ // The row as it stands now — engine and model can change while a chain runs,
25
+ // and each spawn should carry what the panel shows.
26
+ function liveProject(db, project) {
27
+ return (db.prepare('SELECT * FROM projects WHERE id = ?').get(project.id) ??
28
+ project);
29
+ }
30
+ // A run that will write the document. A recheck only reads it, so prime may
31
+ // still edit while one runs — the verdict lands against whatever prime saved.
32
+ export function writingDoc(db, projectId, rel) {
33
+ return (db
34
+ .prepare("SELECT 1 FROM jobs WHERE project_id = ? AND doc_rel = ? AND status = 'running' AND kind != 'recheck'")
35
+ .get(projectId, rel) !== undefined);
36
+ }
37
+ export function runningDoc(db, projectId, rel) {
38
+ return (db
39
+ .prepare("SELECT 1 FROM jobs WHERE project_id = ? AND doc_rel = ? AND status = 'running'")
40
+ .get(projectId, rel) !== undefined);
41
+ }
42
+ // All currently producible docs: unwritten, inputs settled, not already
43
+ // being written. Dependency-depth order (listDocs is sorted).
44
+ export function producibleSteps(db, project, pkgRoot) {
45
+ const running = new Set(db
46
+ .prepare("SELECT doc_rel FROM jobs WHERE project_id = ? AND status = 'running'")
47
+ .all(project.id).map((r) => r.doc_rel));
48
+ const docs = listDocs(db, project, pkgRoot);
49
+ const map = loadDocMap(pkgRoot, project.kind ?? 'new');
50
+ const steps = [];
51
+ for (const doc of docs) {
52
+ if (doc.status !== 'uninitialized' || doc.blocked || running.has(doc.rel))
53
+ continue;
54
+ const step = map.get(doc.rel);
55
+ if (step)
56
+ steps.push(step);
57
+ }
58
+ return steps;
59
+ }
60
+ export function nextStep(db, project, pkgRoot) {
61
+ if (runningJob(db, project.id))
62
+ return null;
63
+ return producibleSteps(db, project, pkgRoot)[0] ?? null;
64
+ }
65
+ // Builds the headless step prompt. The CLI runs inside the project folder, so
66
+ // the prompt points at files rather than inlining them.
67
+ export function buildStepPrompt(project, step, workflowStepText, personaBody, reviseNotes = [],
68
+ /** Change requests other documents made about this one before it existed. */
69
+ waiting = []) {
70
+ const lines = [
71
+ 'You are executing ONE step of a Kortext analysis flow, headless, inside the project folder.',
72
+ `Project: ${project.name} (kind: ${project.kind ?? 'new'}).`,
73
+ '',
74
+ 'FIRST DECIDE SCOPE, THEN WRITE:',
75
+ // An existing project's first step has no document inputs at all. Telling it
76
+ // the inputs are the only evidence told it it had none, and it wrote the
77
+ // document without opening the repository it was standing in.
78
+ ...(step.inputs.length === 0
79
+ ? [
80
+ '- This step has NO document inputs. THE CODEBASE IN THIS FOLDER IS YOUR EVIDENCE. Read it before you write a line: source files, configuration, CI workflow files, migrations, the strings a user reads, package manifests. Record what is there. Absence of evidence is a finding, not a blank to fill.',
81
+ ]
82
+ : [
83
+ '- Read the step inputs first:',
84
+ ...step.inputs.map((i) => ` .kortext/${i}`),
85
+ project.kind === 'existing'
86
+ ? '- The codebase in this folder is evidence too, and it outranks the inputs where they disagree — the code is what the project actually does.'
87
+ : '- They are the only evidence you have.',
88
+ ]),
89
+ "- Decide whether this document applies to THIS project, using the step's `n/a when` condition. If it is met, write the file with status: not-applicable and one line saying why, and stop. That is a complete, correct outcome — not a gap and not a failure. Leave nothing but the title and that one line: a skeleton of empty headings reads to the next author as work waiting to be done.",
90
+ '- Write only what your evidence supports. Where it is silent, say so and leave the question to prime; never fill a section by assuming what the product is probably like.',
91
+ '- You may write something you did not find but believe the project should have. Every such line starts with `**Suggestion —**` and says why you are proposing it. A line without that marker is a fact you observed. Writing a suggestion as a fact misleads everyone who later uses this document as a contract — a target, a threshold and a schedule are facts only if the evidence carries them.',
92
+ "- Every question you leave for the human goes under the document's `## Questions for Prime` heading, one `- ` item each, and nowhere else. Leave that section empty when there is nothing to ask — an empty section is the signal that the document stands on its own.",
93
+ '- A finding about something no document owns — a config file, a workflow, a tracked secret, a live endpoint — goes under `## Findings` in THIS document, one line each, starting with the path in backticks: `` - `.gitignore` — `.env` is tracked and holds live credentials ``. Do not aim a revision request at it: a demand can only ask a document to change, and one aimed anywhere else is a finding nobody can act on. Nothing under `.kortext/` is a finding: `.kortext/DESIGN.html` is the preview kortext itself renders from `DESIGN.md`, and the dot-files there are its own bookkeeping — generated, owned, and not yours to report.',
94
+ '- When an ALREADY-WRITTEN document must change because of what you found, that is not prose: put one line under `## Change Requests`, starting with the target file in backticks — `` - `ENVIRONMENT.md` — the access-log lines must follow the no-logs decision `` — and say what must change and why. The panel turns each line into an action the human can take; a demand written anywhere else in the document is a demand nobody can act on. Leave the section empty when nothing upstream needs to change.',
95
+ '- Under the same heading you will find lines that start with `from` — `` - [ ] from `STACK.md` — … ``. Those are the OTHER direction: what other documents asked of THIS one. They are not yours to write, reword or remove; the human decides them in the panel, and kortext ticks them. Keep every one of them exactly as it is. And read `## Decisions` before you write a request: each line there is a request the human has ALREADY refused, with the reason under it — do not raise the point again, in either direction, unless your evidence has actually changed since, and then say what changed, in so many words. Keep that section exactly as it is too. Repeating a settled request with a fresh wording is how a document set argues with itself forever.',
96
+ '',
97
+ 'HARD RULES:',
98
+ `- Produce EXACTLY this file and nothing else: .kortext/${step.output}`,
99
+ '- Fill the skeleton template already at that path. A heading that CONTAINS a bracketed span — `### [Module Name]`, ``### Table: `[table_name]` `` — is a pattern, not a heading: rename it to the real thing, repeat the whole block once per real item, and delete the block entirely when the project has none of them. Every other heading is fixed: keep it VERBATIM and replace the placeholder content under it.',
100
+ `- Frontmatter must end up as: status: draft, author: ${step.author ?? '+agent'}${step.approver ? `, approver: ${step.approver}` : ''}.`,
101
+ '- NEVER set status to approved — approval belongs to the human.',
102
+ '- Write the file in place, on whatever branch the folder is on. Do not create a branch, do not commit, do not run git at all: the human keeps the history, and a run that stops to ask for a branch has produced nothing.',
103
+ '- `## Decisions` is load-bearing: each line there and the indented reason beneath it record a decision the human made. Reproduce the section EXACTLY — same lines, same reasons, same place — no matter how much of the document you rewrite. It is the only reason the next author does not re-open a settled question; drop a line and the decision is gone with it.',
104
+ project.doc_lang
105
+ ? `- Document language: write the PROSE in ${project.doc_lang}. This is prime's stated choice — it overrides the language of the inputs, the repository and the README.`
106
+ : '- Document language: write the PROSE in the language of .kortext/BRIEF.md; if there is no brief (existing project), match the language of the already-approved .kortext documents, else the language of the repo README; default to English.',
107
+ "- ENGLISH ALWAYS, whatever the document language: the section headings (they are structure, and other documents cite them by name), code and code samples, identifiers, file and folder names, commands, environment-variable names, database table and column names, API paths and field names, branch and commit conventions, and every frontmatter key. Only the prose under the headings is written in the brief's language — never translate a name something is called by.",
108
+ "- Product copy is the one exception: strings a user of the product will read (microcopy, error messages, page copy, email text) are written in the product's interface language from the brief — which may differ from the language of this document.",
109
+ '',
110
+ 'STEP DEFINITION (from the workflow):',
111
+ workflowStepText.trim(),
112
+ ];
113
+ if (waiting.length > 0) {
114
+ lines.push('', 'CHANGE REQUESTS ALREADY WAITING FOR THIS DOCUMENT — the `from` lines under its', '`## Change Requests`: other documents asked for these while this one did not exist yet, so', 'nobody could decide them. They are yours to satisfy in this first write: write the document', 'so each is already true, in the section where it belongs, and leave the lines themselves', 'exactly as they are — kortext ticks them. Do not quote them and do not answer them as prose.', 'If your evidence contradicts one, follow your evidence and put a line under', '`## Change Requests` aimed back at the document that asked, saying why it cannot be as asked.', '', ...waiting.map((r) => `- [${r.from} asks] ${r.reason}`));
115
+ }
116
+ if (personaBody) {
117
+ lines.push('', 'AUTHOR PERSONA PERSPECTIVE:', personaBody.trim());
118
+ }
119
+ if (reviseNotes.length > 0) {
120
+ lines.push('', 'REVISION REQUEST — the human reviewed the current draft and asks for changes.', 'Rewrite the document addressing EVERY note below (keep what was not objected to), and carry', '`## Decisions` and every `from` line under `## Change Requests` across unchanged.', 'A note is written in `[the line it was left on] the note`. When that line is one of your own', 'open questions, the note IS the answer: fold it into the document as a settled fact, in the', 'section where it belongs, and DELETE that question from `## Questions for Prime`. An', 'answered question is not restated, not moved, and not kept "for reference" — it is gone, and', 'the fact it established is now part of the document. Keep only the questions still unanswered;', 'if none remain, leave the section empty.', '', 'NOTES:', ...reviseNotes.map((n) => `- ${n}`));
121
+ }
122
+ return lines.join('\n');
123
+ }
124
+ // Extracts the numbered step block for an output from the workflow markdown —
125
+ // the same text a human reads, handed to the engine verbatim.
126
+ export function stepTextFor(pkgRoot, project, outputRel) {
127
+ const wf = readFileSync(join(pkgRoot, 'workflows', `${workflowNameFor(project.kind ?? 'new')}.md`), 'utf8');
128
+ const blocks = wf.split(/\n(?=\d+\. \*\*\+)/);
129
+ return blocks.find((b) => b.includes(`\`.kortext/${outputRel}\``) && /- outputs:/.test(b)) ?? '';
130
+ }
131
+ export function personaBodyFor(pkgRoot, step) {
132
+ if (!step.author)
133
+ return null;
134
+ const p = join(pkgRoot, 'agents', `${step.author.replace(/^\+/, '')}.md`);
135
+ return existsSync(p) ? readFileSync(p, 'utf8') : null;
136
+ }
137
+ // One chain loop per project, with capped parallel document runs.
138
+ // Approvals wake the loop to fill available slots without waiting for a running step.
139
+ const advancing = new Map();
140
+ const MAX_PARALLEL = 3;
141
+ // Tries per document within one chain loop, before the loop leaves it alone.
142
+ const MAX_STEP_ATTEMPTS = 3;
143
+ // Live spawn registry — pause/restart/cancel abort every running CLI for the
144
+ // project (SIGTERM→SIGKILL via cli-spawn) instead of letting it finish and
145
+ // rewrite files that were just wiped.
146
+ const liveRuns = new Map();
147
+ export function hasActiveRuns() {
148
+ return advancing.size > 0 || liveRuns.size > 0;
149
+ }
150
+ function trackRun(projectId) {
151
+ const ctrl = new AbortController();
152
+ let set = liveRuns.get(projectId);
153
+ if (!set)
154
+ liveRuns.set(projectId, (set = new Set()));
155
+ set.add(ctrl);
156
+ return {
157
+ ctrl,
158
+ done: () => {
159
+ set.delete(ctrl);
160
+ if (set.size === 0)
161
+ liveRuns.delete(projectId);
162
+ },
163
+ };
164
+ }
165
+ export function abortRuns(projectId) {
166
+ for (const c of liveRuns.get(projectId) ?? [])
167
+ c.abort();
168
+ }
169
+ // The server is going down: take every CLI with it. Each runs in its own
170
+ // process group, so left alone it outlives the server and writes into a
171
+ // document the next server has already marked failed.
172
+ export function abortAllRuns() {
173
+ for (const set of liveRuns.values())
174
+ for (const c of set)
175
+ c.abort();
176
+ }
177
+ // The CLI the project picks now, or the caller's when it picks none. Read per
178
+ // spawn, not per loop: a quota runs out mid-chain and the panel's switch must
179
+ // reach the next step and the next recheck alike.
180
+ function pickedEngine(db, project, fallback) {
181
+ const picked = db.prepare('SELECT engine FROM projects WHERE id = ?').get(project.id)?.engine;
182
+ return (picked && detectEngines().find((e) => e.id === picked && e.available)) || fallback;
183
+ }
184
+ function runningJobs(db, projectId) {
185
+ return db
186
+ .prepare("SELECT count(*) AS n FROM jobs WHERE project_id = ? AND status = 'running'")
187
+ .get(projectId).n;
188
+ }
189
+ // A revision or a retry started from the panel is not scheduled by the loop,
190
+ // so it waits here for a slot instead of running as a fourth CLI. The wait is
191
+ // a tracked run: a Pause, Restart or Cancel that lands while it waits aborts
192
+ // it like any run in flight, and it does not start when a slot opens. A
193
+ // revision asked for on an already-paused project still runs — prime asked.
194
+ // False means do not run.
195
+ // ponytail: 2s poll, not a semaphore — the loop counts running jobs in the db anyway.
196
+ async function waitForRoom(db, projectId) {
197
+ const wait = trackRun(projectId);
198
+ try {
199
+ while (runningJobs(db, projectId) >= MAX_PARALLEL) {
200
+ if (wait.ctrl.signal.aborted)
201
+ return false;
202
+ await new Promise((r) => setTimeout(r, 2000));
203
+ }
204
+ return !wait.ctrl.signal.aborted;
205
+ }
206
+ finally {
207
+ wait.done();
208
+ }
209
+ }
210
+ export async function advance(db, project, engine, pkgRoot) {
211
+ const active = advancing.get(project.id);
212
+ if (active) {
213
+ active(); // already looping — just wake it to re-scan
214
+ return;
215
+ }
216
+ let wake = () => { };
217
+ const arm = () => new Promise((resolve) => (wake = resolve));
218
+ // Claim the loop before awaiting the gate so concurrent approvals cannot start duplicate pools.
219
+ advancing.set(project.id, () => wake());
220
+ try {
221
+ const checked = new Set();
222
+ const inFlight = new Set();
223
+ // Cap attempts per document to prevent repeated failures from consuming unlimited quota.
224
+ // A new chain loop starts with a fresh count.
225
+ const attempts = new Map();
226
+ // One scheduling loop, run twice: rechecks alone before the readiness
227
+ // gate — a reader owes its verdict whatever the brief says — then rechecks
228
+ // and steps together once the gate has passed.
229
+ const pump = async (steps) => {
230
+ for (;;) {
231
+ // Stop scheduling while paused; wait for in-flight promises to settle.
232
+ const paused = db.prepare('SELECT paused FROM projects WHERE id = ?').get(project.id)?.paused;
233
+ // Room is what the db shows running, not what this loop started: a
234
+ // revision from the panel takes a slot too.
235
+ let room = paused ? 0 : MAX_PARALLEL - Math.max(inFlight.size, runningJobs(db, project.id));
236
+ const current = pickedEngine(db, project, engine);
237
+ // Rechecks share the pool with steps and take it first: they are short,
238
+ // and a reader that needs a change should say so before a step that
239
+ // reads it starts.
240
+ for (const p of startRechecks(db, project, current, pkgRoot, checked, room)) {
241
+ const q = p.finally(() => inFlight.delete(q));
242
+ inFlight.add(q);
243
+ room -= 1;
244
+ }
245
+ if (steps && room > 0) {
246
+ for (const step of producibleSteps(db, project, pkgRoot).slice(0, room)) {
247
+ const tried = attempts.get(step.output) ?? 0;
248
+ if (tried >= MAX_STEP_ATTEMPTS)
249
+ continue;
250
+ attempts.set(step.output, tried + 1);
251
+ const p = runStep(db, project, step, current, pkgRoot).finally(() => inFlight.delete(p));
252
+ inFlight.add(p);
253
+ }
254
+ }
255
+ if (inFlight.size === 0)
256
+ return; // nothing running, nothing producible
257
+ await Promise.race([...inFlight, arm()]); // completion OR an approval nudge
258
+ }
259
+ };
260
+ await pump(false);
261
+ // Check pause/removal before the readiness gate to avoid starting an unwanted CLI run.
262
+ const before = db.prepare('SELECT paused FROM projects WHERE id = ?').get(project.id);
263
+ if (!before || before.paused)
264
+ return;
265
+ // Restore missing skeletons so the chain can recover without panel polling.
266
+ try {
267
+ scaffoldProject(project.repo_path, pkgRoot, {
268
+ skipBrief: (project.kind ?? 'new') === 'existing',
269
+ });
270
+ }
271
+ catch {
272
+ /* repo may be gone; the gate below reports it */
273
+ }
274
+ // Require readiness before analysis; cache new-project judgments by brief version.
275
+ // Track the gate run so pause, restart and cancel can abort it.
276
+ const gate = trackRun(project.id);
277
+ let ready = false;
278
+ try {
279
+ ready = (await ensureReadiness(db, project, engine, gate.ctrl.signal)).ready;
280
+ }
281
+ finally {
282
+ gate.done();
283
+ }
284
+ if (!ready)
285
+ return;
286
+ await pump(true);
287
+ }
288
+ finally {
289
+ advancing.delete(project.id);
290
+ }
291
+ }
292
+ // Record refusals as failed jobs because fire-and-forget callers cannot display a returned error.
293
+ function refuse(db, project, rel, error) {
294
+ db.prepare("INSERT INTO jobs (project_id, doc_rel, status, error, finished_at) VALUES (?, ?, 'failed', ?, datetime('now'))").run(project.id, rel, error);
295
+ return { ok: false, error };
296
+ }
297
+ export async function reviseDoc(db, project, rel, notes, engine, pkgRoot) {
298
+ const step = loadDocMap(pkgRoot, project.kind ?? 'new').get(rel);
299
+ if (!step)
300
+ return refuse(db, project, rel, `no producing step for ${rel}`);
301
+ // Reject concurrent writers to this document; other documents may be revised in parallel.
302
+ if (runningDoc(db, project.id, rel)) {
303
+ return refuse(db, project, rel, `${rel} is already being rewritten — wait for it to land`);
304
+ }
305
+ if (!(await waitForRoom(db, project.id))) {
306
+ // Stopped, with its notes, so Continue resumes it like a revision the pause
307
+ // caught mid-run — not failed, nothing went wrong.
308
+ db.prepare(`INSERT INTO jobs (project_id, doc_rel, kind, status, error, notes, finished_at)
309
+ VALUES (?, ?, 'doc', 'stopped', 'stopped by pause before it could start', ?, datetime('now'))`).run(project.id, rel, JSON.stringify(notes));
310
+ return { ok: false, error: 'paused' };
311
+ }
312
+ const out = await runStep(db, project, step, pickedEngine(db, project, engine), pkgRoot, notes);
313
+ if (out.ok)
314
+ await advance(db, project, engine, pkgRoot);
315
+ return out;
316
+ }
317
+ // Resume stopped revisions and plan revisions from their saved notes.
318
+ // Their previous outputs may still appear settled, so normal producibility checks miss them.
319
+ export async function resumeStoppedRevisions(db, project, engine, pkgRoot) {
320
+ const stopped = db
321
+ .prepare(`SELECT doc_rel, kind, notes FROM jobs
322
+ WHERE project_id = ? AND kind IN ('doc', 'plan') AND status = 'stopped'
323
+ AND id IN (SELECT MAX(id) FROM jobs WHERE project_id = ? GROUP BY doc_rel)`)
324
+ .all(project.id, project.id);
325
+ await Promise.all(stopped.map((job) => {
326
+ const notes = JSON.parse(job.notes || '[]');
327
+ // Initial document writes are handled by the chain; initial planning remains user-triggered.
328
+ if (!notes.length)
329
+ return null;
330
+ return job.kind === 'plan'
331
+ ? runPlanning(db, project, engine, pkgRoot, notes)
332
+ : reviseDoc(db, project, job.doc_rel, notes, engine, pkgRoot);
333
+ }));
334
+ }
335
+ // Line-anchored Q&A: the author persona answers about its own document.
336
+ // No document is modified; the answer is returned to the panel and CLI output is logged.
337
+ export async function explainDoc(db, project, rel, excerpt, question, history, engine, pkgRoot) {
338
+ const map = loadDocMap(pkgRoot, project.kind ?? 'new');
339
+ const author = map.get(rel)?.author ?? '+agent';
340
+ const prompt = [
341
+ `You are ${author}, the author of the document .kortext/${rel} in this project.`,
342
+ 'The human reviewer selected a passage and is having an inline conversation about it.',
343
+ 'Answer briefly and concretely in the language of the question.',
344
+ 'DO NOT modify, create or write any file — reply with the answer text only.',
345
+ '',
346
+ `SELECTED PASSAGE:\n${excerpt || '(whole document)'}`,
347
+ ...(history.length > 0
348
+ ? ['', 'CONVERSATION SO FAR:', ...history.flatMap((h) => [`Q: ${h.q}`, `A: ${h.a}`])]
349
+ : []),
350
+ '',
351
+ `QUESTION:\n${question}`,
352
+ ].join('\n');
353
+ // Track Q&A so pause, restart and cancel can abort it before deleting project files.
354
+ const run = trackRun(project.id);
355
+ let res;
356
+ try {
357
+ res = await spawnCli({
358
+ binary: engine.binary,
359
+ args: engineArgs(engine, liveProject(db, project)),
360
+ promptFlag: engine.promptFlag,
361
+ env: engineEnv(engine, liveProject(db, project)),
362
+ cwd: project.repo_path,
363
+ stdin: prompt,
364
+ logPath: logPathFor(db, `p${project.id}-explain.log`),
365
+ signal: run.ctrl.signal,
366
+ timeoutMs: 3 * 60 * 1000,
367
+ });
368
+ }
369
+ finally {
370
+ run.done();
371
+ }
372
+ if (res.aborted)
373
+ throw new Error('the question was stopped');
374
+ if (res.exitCode !== 0) {
375
+ throw new Error(`${engine.id} CLI failed: ${(res.stderrTail || res.stdoutTail).trim().slice(-300)}`);
376
+ }
377
+ return { answer: res.stdoutTail.trim() };
378
+ }
379
+ // ---------------------------------------------------------------------------
380
+ // Re-reading a document against an input that moved
381
+ // ---------------------------------------------------------------------------
382
+ /**
383
+ * A recheck's verdict becomes a request in the document that must change,
384
+ * made by the document that changed. Kortext writes it, so it goes straight
385
+ * where it will be decided — there is no draft for it to wait in.
386
+ */
387
+ export function appendRevisionRequest(project, sourceRel, targetRel, reason) {
388
+ appendIncomingRequest(project, targetRel, sourceRel, reason);
389
+ }
390
+ /** The CLI judges the changed input; the server records any resulting revision request. */
391
+ async function runRecheck(db, project, readerRel, sourceRel, engine) {
392
+ const job = db
393
+ .prepare("INSERT INTO jobs (project_id, doc_rel, kind) VALUES (?, ?, 'recheck') RETURNING *")
394
+ .get(project.id, readerRel);
395
+ const settle = (status, error) => db
396
+ .prepare("UPDATE jobs SET status = ?, error = ?, finished_at = datetime('now') WHERE id = ?")
397
+ .run(status, error ?? null, job.id);
398
+ const run = trackRun(project.id);
399
+ // Per run, not per reader: two sources re-judging the same document at once
400
+ // would otherwise read and delete each other's verdict.
401
+ const verdictRel = `.kortext/.recheck-${randomUUID().slice(0, 8)}.json`;
402
+ const verdictPath = join(project.repo_path, verdictRel);
403
+ rmSync(verdictPath, { force: true });
404
+ const prompt = [
405
+ `.kortext/${sourceRel} has just been rewritten and approved. .kortext/${readerRel} was written against the OLD text and is still approved.`,
406
+ '',
407
+ 'Read both. Decide ONE thing: does the reader now say something the new source contradicts, or leave out something the new source requires?',
408
+ '',
409
+ 'HARD RULES:',
410
+ `- Write your verdict to ${verdictRel} and NOTHING else. Modify no document.`,
411
+ '- Shape: { "needsChange": true|false, "reason": "one sentence naming what must change in the reader and why" }',
412
+ '- `needsChange: false` is the normal answer. Say true only for a real contradiction or a real gap — not for wording you would have phrased differently.',
413
+ '- The reason is read by the human as a demand on the reader, so write it in the language of the documents.',
414
+ ].join('\n');
415
+ try {
416
+ const sourceBefore = readFileSync(docPath(project, sourceRel), 'utf8');
417
+ const readerBefore = readFileSync(docPath(project, readerRel), 'utf8');
418
+ const res = await spawnCli({
419
+ binary: engine.binary,
420
+ args: engineArgs(engine, liveProject(db, project)),
421
+ promptFlag: engine.promptFlag,
422
+ env: engineEnv(engine, liveProject(db, project)),
423
+ cwd: project.repo_path,
424
+ stdin: prompt,
425
+ logPath: logPathFor(db, `p${project.id}-recheck.log`),
426
+ signal: run.ctrl.signal,
427
+ timeoutMs: 5 * 60 * 1000,
428
+ });
429
+ if (res.aborted) {
430
+ rmSync(verdictPath, { force: true });
431
+ settle('stopped', 'stopped by pause/restart/cancel');
432
+ return false;
433
+ }
434
+ if (res.exitCode !== 0 || !existsSync(verdictPath)) {
435
+ settle('failed', `${engine.id} returned no verdict for ${readerRel}`);
436
+ return false;
437
+ }
438
+ const verdict = JSON.parse(readFileSync(verdictPath, 'utf8'));
439
+ rmSync(verdictPath, { force: true });
440
+ if (typeof verdict.needsChange !== 'boolean' ||
441
+ (verdict.needsChange && (typeof verdict.reason !== 'string' || !verdict.reason.trim()))) {
442
+ throw new Error('the recheck returned an invalid verdict');
443
+ }
444
+ if (readFileSync(docPath(project, sourceRel), 'utf8') !== sourceBefore ||
445
+ readFileSync(docPath(project, readerRel), 'utf8') !== readerBefore) {
446
+ settle('stopped', 'a document changed during the recheck — retry');
447
+ return false;
448
+ }
449
+ if (verdict.needsChange && (verdict.reason ?? '').trim()) {
450
+ appendRevisionRequest(project, sourceRel, readerRel, String(verdict.reason));
451
+ }
452
+ settle('done');
453
+ return true;
454
+ }
455
+ catch (err) {
456
+ rmSync(verdictPath, { force: true });
457
+ settle('failed', err.message);
458
+ return false;
459
+ }
460
+ finally {
461
+ run.done();
462
+ }
463
+ }
464
+ /** Queue rechecks for approved readers when their source is edited or approved. */
465
+ export function recheckDependents(db, project, sourceRel, engine, pkgRoot) {
466
+ const readers = listDocs(db, project, pkgRoot).filter((d) => d.status === 'approved' && d.inputs.includes(sourceRel));
467
+ const enqueue = db.prepare(`INSERT INTO pending_rechecks (project_id, source_rel, reader_rel)
468
+ VALUES (?, ?, ?) ON CONFLICT(project_id, source_rel, reader_rel)
469
+ DO UPDATE SET generation = generation + 1`);
470
+ db.transaction(() => {
471
+ for (const r of readers)
472
+ enqueue.run(project.id, sourceRel, r.rel);
473
+ })();
474
+ if (engine)
475
+ void advance(db, project, engine, pkgRoot);
476
+ }
477
+ // The queue survives pause and process restarts. Each generation is attempted
478
+ // once per chain loop; a failed judgment stays pending for Continue/Retry.
479
+ // Start up to `room` pending rechecks and return their promises; the chain
480
+ // loop races them with its steps. Two rechecks never share a reader: the
481
+ // running-doc check below sees the job the first one inserted.
482
+ function startRechecks(db, project, engine, pkgRoot, attempted, room) {
483
+ const started = [];
484
+ if (room <= 0)
485
+ return started;
486
+ const pending = db
487
+ .prepare('SELECT * FROM pending_rechecks WHERE project_id = ?')
488
+ .all(project.id);
489
+ const clear = (item) => db
490
+ .prepare(`DELETE FROM pending_rechecks WHERE project_id = ?
491
+ AND source_rel = ? AND reader_rel = ? AND generation = ?`)
492
+ .run(project.id, item.source_rel, item.reader_rel, item.generation);
493
+ for (const item of pending) {
494
+ if (started.length >= room)
495
+ break;
496
+ const key = `${item.source_rel}:${item.reader_rel}:${item.generation}`;
497
+ if (attempted.has(key) ||
498
+ runningDoc(db, project.id, item.reader_rel) ||
499
+ runningDoc(db, project.id, item.source_rel))
500
+ continue;
501
+ attempted.add(key);
502
+ const reader = listDocs(db, project, pkgRoot).find((d) => d.rel === item.reader_rel);
503
+ // A fresh draft needs human approval; it no longer claims to be settled.
504
+ if (reader?.status !== 'approved') {
505
+ clear(item);
506
+ continue;
507
+ }
508
+ started.push(runRecheck(db, project, item.reader_rel, item.source_rel, engine).then((done) => {
509
+ if (done)
510
+ clear(item);
511
+ }));
512
+ }
513
+ return started;
514
+ }
515
+ // Draft a revision into a temporary text file, return it to the editor, then delete it.
516
+ // The human must save the proposal before the document changes.
517
+ export async function proposeRevision(db, project, rel, notes, engine, pkgRoot) {
518
+ // One file per call: two proposals in flight would otherwise overwrite each
519
+ // other and hand both callers whichever draft finished last.
520
+ // ponytail: .txt, not .md — listDocs scans .kortext/*.md and would list it as a document
521
+ const scratchRel = `.proposal-${randomUUID().slice(0, 8)}.txt`;
522
+ const scratch = join(project.repo_path, '.kortext', scratchRel);
523
+ const author = loadDocMap(pkgRoot, project.kind ?? 'new').get(rel)?.author ?? '+agent';
524
+ const prompt = [
525
+ `Another document has asked .kortext/${rel} to change. Draft that change.`,
526
+ ...(author !== '+agent'
527
+ ? [`Write as ${author}, but the document belongs to the human — you propose, they decide.`]
528
+ : []),
529
+ '',
530
+ 'HARD RULES:',
531
+ `- Read .kortext/${rel}. Write the FULL revised document to .kortext/${scratchRel} — the whole file, frontmatter included, not a fragment and not a diff.`,
532
+ '- Touch NO other file. Do not modify the document itself; the human applies your draft.',
533
+ '- Keep the frontmatter exactly as it is, including the status line.',
534
+ '- Keep every section heading verbatim, and keep the document in its own language.',
535
+ '- Change ONLY what the requests below ask for. Everything they do not mention stays word for word.',
536
+ '',
537
+ 'REQUESTS:',
538
+ ...notes.map((n) => `- ${n}`),
539
+ ].join('\n');
540
+ // Track proposals so cancellation cannot leave a CLI writing into removed project files.
541
+ const run = trackRun(project.id);
542
+ let res;
543
+ try {
544
+ res = await spawnCli({
545
+ binary: engine.binary,
546
+ args: engineArgs(engine, liveProject(db, project)),
547
+ promptFlag: engine.promptFlag,
548
+ env: engineEnv(engine, liveProject(db, project)),
549
+ cwd: project.repo_path,
550
+ stdin: prompt,
551
+ logPath: logPathFor(db, `p${project.id}-propose.log`),
552
+ signal: run.ctrl.signal,
553
+ timeoutMs: 5 * 60 * 1000,
554
+ });
555
+ }
556
+ finally {
557
+ run.done();
558
+ }
559
+ if (res.aborted) {
560
+ rmSync(scratch, { force: true });
561
+ throw new Error('the draft was stopped');
562
+ }
563
+ if (res.exitCode !== 0) {
564
+ throw new Error(`${engine.id} CLI failed: ${(res.stderrTail || res.stdoutTail).trim().slice(-300)}`);
565
+ }
566
+ if (!existsSync(scratch)) {
567
+ throw new Error(`${engine.id} wrote no proposal — nothing was changed`);
568
+ }
569
+ const proposal = readFileSync(scratch, 'utf8');
570
+ rmSync(scratch, { force: true });
571
+ if (proposal.trim().length === 0)
572
+ throw new Error('the proposal came back empty');
573
+ return { proposal };
574
+ }
575
+ // Export Version, Epic and Task files under .kopeng/ as one plan job; notes request a revision.
576
+ export async function runPlanning(db, project, engine, pkgRoot, reviseNotes = []) {
577
+ // Persist plan revision notes so Continue can resume an interrupted run.
578
+ const job = db
579
+ .prepare("INSERT INTO jobs (project_id, doc_rel, kind, notes) VALUES (?, '.kopeng/', 'plan', ?) RETURNING *")
580
+ .get(project.id, JSON.stringify(reviseNotes));
581
+ const workflow = readFileSync(join(pkgRoot, 'workflows', 'planning-pipeline.md'), 'utf8');
582
+ const lines = [
583
+ 'You are executing the Kortext task-split flow, headless, inside the project folder.',
584
+ `Project: ${project.name} — project code: ${project.code || 'PROJ'} (use it as the id prefix).`,
585
+ '',
586
+ 'HARD RULES:',
587
+ '- Write ONLY files under .kopeng/ (create the directory tree).',
588
+ '- Follow the workflow below EXACTLY — file layout, task body sections, id convention.',
589
+ '- Read the approved .kortext/ documents listed as inputs before splitting.',
590
+ '- project.yaml must end with status: draft — the human approves it in the panel.',
591
+ '- Language: write task/epic/version prose in the same language as the .kortext documents.',
592
+ '',
593
+ 'WORKFLOW:',
594
+ workflow.trim(),
595
+ ];
596
+ if (reviseNotes.length > 0) {
597
+ lines.push('', 'REVISION REQUEST — the human reviewed the current plan and asks for changes.', 'Rewrite the .kopeng/ files addressing EVERY note (keep what was not objected to):', ...reviseNotes.map((n) => `- ${n}`));
598
+ }
599
+ const settle = (status, error) => {
600
+ db.prepare("UPDATE jobs SET status = ?, error = ?, finished_at = datetime('now') WHERE id = ?").run(status, error ?? null, job.id);
601
+ return status === 'done' ? { ok: true } : { ok: false, error };
602
+ };
603
+ const run = trackRun(project.id);
604
+ try {
605
+ const res = await spawnCli({
606
+ binary: engine.binary,
607
+ args: engineArgs(engine, liveProject(db, project)),
608
+ promptFlag: engine.promptFlag,
609
+ env: engineEnv(engine, liveProject(db, project)),
610
+ cwd: project.repo_path,
611
+ stdin: lines.join('\n'),
612
+ logPath: logPathFor(db, `p${project.id}-plan.log`),
613
+ signal: run.ctrl.signal,
614
+ timeoutMs: PLAN_TIMEOUT_MS,
615
+ });
616
+ if (res.timedOut) {
617
+ return settle('failed', `the split ran past ${PLAN_TIMEOUT_MS / 60000} minutes and was stopped`);
618
+ }
619
+ if (res.aborted)
620
+ return settle('stopped', 'stopped by pause/restart/cancel');
621
+ if (res.exitCode !== 0) {
622
+ return settle('failed', `${engine.id} CLI failed (exit ${res.exitCode}): ${(res.stderrTail || res.stdoutTail).trim().slice(-400) || 'no output'}`);
623
+ }
624
+ const kopeng = join(project.repo_path, '.kopeng');
625
+ if (!existsSync(join(kopeng, 'project.yaml'))) {
626
+ return settle('failed', 'engine finished without producing .kopeng/project.yaml');
627
+ }
628
+ let taskCount = 0;
629
+ try {
630
+ taskCount = readdirSync(join(kopeng, 'tasks')).filter((f) => f.endsWith('.md')).length;
631
+ }
632
+ catch {
633
+ /* no tasks dir */
634
+ }
635
+ if (taskCount === 0)
636
+ return settle('failed', '.kopeng/tasks/ is empty — no tasks produced');
637
+ return settle('done');
638
+ }
639
+ catch (err) {
640
+ return settle('failed', err.message);
641
+ }
642
+ finally {
643
+ run.done();
644
+ }
645
+ }
646
+ /** Remove logs belonging to this project from the database-specific log directory. */
647
+ export function removeRunLogs(projectId, dir) {
648
+ let entries;
649
+ try {
650
+ entries = readdirSync(dir);
651
+ }
652
+ catch {
653
+ return; // no logs directory yet
654
+ }
655
+ for (const f of entries.filter((f) => f.startsWith(`p${projectId}-`))) {
656
+ rmSync(join(dir, f), { force: true });
657
+ }
658
+ }
659
+ // A server restart orphans 'running' rows — settle them so Retry works.
660
+ export function failStaleJobs(db) {
661
+ db.prepare("UPDATE jobs SET status = 'failed', error = 'kortext restarted mid-step — retry', finished_at = datetime('now') WHERE status = 'running'").run();
662
+ }
663
+ // Run one document step and record its outcome; callers prevent concurrent writes to the same document.
664
+ export async function runStep(db, project, step, engine, pkgRoot, reviseNotes = []) {
665
+ const job = db
666
+ .prepare('INSERT INTO jobs (project_id, doc_rel, notes) VALUES (?, ?, ?) RETURNING *')
667
+ .get(project.id, step.output, JSON.stringify(reviseNotes));
668
+ // A first write inherits every change request aimed at this document while it
669
+ // did not exist. On a revision they already arrived as notes.
670
+ const waiting = reviseNotes.length > 0
671
+ ? []
672
+ : (listDocs(db, project, pkgRoot).find((d) => d.rel === step.output)?.revisionRequests ?? []);
673
+ const prompt = buildStepPrompt(project, step, stepTextFor(pkgRoot, project, step.output), personaBodyFor(pkgRoot, step), reviseNotes, waiting);
674
+ const logPath = logPathFor(db, `p${project.id}-${step.output.replace(/\//g, '_')}.log`);
675
+ const settle = (status, error) => {
676
+ db.prepare("UPDATE jobs SET status = ?, error = ?, finished_at = datetime('now') WHERE id = ?").run(status, error ?? null, job.id);
677
+ return status === 'done' ? { ok: true } : { ok: false, error };
678
+ };
679
+ const outPath = join(project.repo_path, '.kortext', step.output);
680
+ // What the document said going in. The history wants it on every write; the
681
+ // "nothing changed" guard below wants it only on a revision, where standing
682
+ // still is a failure — on a first write it is what the skeleton looked like.
683
+ const priorText = existsSync(outPath) ? readFileSync(outPath, 'utf8') : null;
684
+ // Compare contents as well as existence: a successful CLI exit may leave the prior document unchanged.
685
+ const before = reviseNotes.length > 0 ? priorText : null;
686
+ const run = trackRun(project.id);
687
+ try {
688
+ const res = await spawnCli({
689
+ binary: engine.binary,
690
+ args: engineArgs(engine, liveProject(db, project)),
691
+ promptFlag: engine.promptFlag,
692
+ env: engineEnv(engine, liveProject(db, project)),
693
+ cwd: project.repo_path,
694
+ stdin: prompt,
695
+ logPath,
696
+ signal: run.ctrl.signal,
697
+ timeoutMs: STEP_TIMEOUT_MS,
698
+ });
699
+ // A run killed by its own clock is not a run the human stopped.
700
+ if (res.timedOut) {
701
+ return settle('failed', `${step.output} ran past ${STEP_TIMEOUT_MS / 60000} minutes and was stopped — retry, or narrow the brief`);
702
+ }
703
+ if (res.aborted)
704
+ return settle('stopped', 'stopped by pause/restart/cancel');
705
+ if (res.exitCode !== 0) {
706
+ return settle('failed', `${engine.id} CLI failed (exit ${res.exitCode}): ${(res.stderrTail || res.stdoutTail).trim().slice(-400) || 'no output'}`);
707
+ }
708
+ if (!existsSync(outPath)) {
709
+ return settle('failed', `engine finished without producing ${step.output}`);
710
+ }
711
+ let written = readFileSync(outPath, 'utf8');
712
+ if (before !== null && written === before) {
713
+ return settle('failed', `${engine.id} left ${step.output} exactly as it was — the change was not made`);
714
+ }
715
+ let status = readFrontmatter(written).status;
716
+ // The agent does not approve: a document it marked `approved` would open
717
+ // every step that reads it without prime ever seeing it. The text stays,
718
+ // the status is prime's — back to draft, and the run goes on as one.
719
+ if (status === 'approved') {
720
+ setFrontmatterStatus(outPath, 'draft');
721
+ written = readFileSync(outPath, 'utf8');
722
+ status = 'draft';
723
+ }
724
+ // Generate the design preview after a successful DESIGN.md write.
725
+ if (step.output === 'DESIGN.md')
726
+ writeDesignPreview(project);
727
+ if (status !== 'draft' && status !== 'not-applicable') {
728
+ // Any other status is a write nobody asked for; the file goes back to
729
+ // what stood, so a failed run leaves nothing new for the chain to read.
730
+ if (priorText !== null)
731
+ writeFileSync(outPath, priorText, 'utf8');
732
+ return settle('failed', `${step.output} written but status is '${status}' (expected draft)`);
733
+ }
734
+ // A document that does not apply is a title and one line. Anything else is
735
+ // the skeleton left standing, which the next author reads as work waiting.
736
+ if (status === 'not-applicable') {
737
+ const left = unfilledPlaceholders(written, templateFor(pkgRoot, step.output));
738
+ if (left.length > 0) {
739
+ return settle('failed', `${step.output} is not-applicable but still carries the skeleton — delete it, leaving the title and the one line saying why: ${left.slice(0, 3).join(' / ')}`);
740
+ }
741
+ }
742
+ // A skeleton is not a version of the document: recording it would make the
743
+ // first draft read as a rewrite of the placeholders, every line marked.
744
+ const priorVersion = priorText !== null && readFrontmatter(priorText).status === 'uninitialized'
745
+ ? null
746
+ : priorText;
747
+ // What was asked of this document is not the agent's to drop: put back any
748
+ // line the rewrite lost. Then the requests this run answered — the ones
749
+ // prime chose, and the ones the first write inherited — are done, and a
750
+ // done request leaves the document: the text now says what it asked for.
751
+ restoreRequests(project, step.output, priorText);
752
+ for (const request of listDocs(db, project, pkgRoot).find((d) => d.rel === step.output)
753
+ ?.revisionRequests ?? []) {
754
+ const chosen = reviseNotes.includes(`[${request.from} asks] ${request.reason}`);
755
+ const inherited = waiting.some((w) => w.from === request.from && w.reason === request.reason);
756
+ if (chosen || inherited)
757
+ removeRequest(project, step.output, request.from, request.reason);
758
+ }
759
+ // Record the file as it stands after those repairs: the version the panel
760
+ // diffs against must be the text on disk, or the picker offers a version
761
+ // nobody saw and the real change hides behind the restored lines.
762
+ recordVersion(db, project, step.output, readFileSync(outPath, 'utf8'), 'agent', priorVersion, job.id);
763
+ return settle('done');
764
+ }
765
+ catch (err) {
766
+ return settle('failed', err.message);
767
+ }
768
+ finally {
769
+ run.done();
770
+ }
771
+ }