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.
- package/LICENSE +21 -0
- package/README.md +175 -65
- package/agents/architect.md +85 -0
- package/agents/compliance-expert.md +36 -75
- package/agents/copywriter.md +36 -92
- package/agents/db-admin.md +43 -91
- package/agents/designer.md +40 -77
- package/agents/devops-engineer.md +37 -113
- package/agents/growth-expert.md +45 -88
- package/agents/product-manager.md +38 -83
- package/agents/qa-engineer.md +51 -99
- package/agents/security-engineer.md +47 -92
- package/bin/kortext.js +2 -0
- package/dist/app.js +829 -0
- package/dist/cli-spawn.js +137 -0
- package/dist/daemon.js +40 -0
- package/dist/db.js +78 -0
- package/dist/design-preview.js +533 -0
- package/dist/docs.js +701 -0
- package/dist/engines.js +225 -0
- package/dist/index.js +109 -0
- package/dist/pick-directory.js +38 -0
- package/dist/projects.js +189 -0
- package/dist/readiness.js +288 -0
- package/dist/runner.js +771 -0
- package/dist/update.js +55 -0
- package/docs/CHANGELOG.md +11 -0
- package/package.json +53 -13
- package/templates/AGENTS.md +62 -0
- package/templates/docs/API.md +67 -0
- package/templates/docs/ARCHITECTURE.md +31 -0
- package/templates/docs/BRIEF.md +27 -0
- package/templates/docs/CONTENT.md +66 -0
- package/templates/docs/DATABASE.md +63 -0
- package/templates/docs/DESIGN.md +124 -0
- package/templates/docs/ENGINEERING.md +66 -0
- package/templates/docs/ENVIRONMENT.md +102 -0
- package/templates/docs/GROWTH.md +44 -0
- package/templates/docs/LEGAL.md +48 -0
- package/templates/docs/PRODUCT.md +43 -0
- package/templates/docs/SECURITY.md +65 -0
- package/templates/docs/STACK.md +57 -0
- package/templates/docs/STRUCTURE.md +73 -0
- package/templates/docs/TEST.md +62 -0
- package/ui/dist/assets/abnfDiagram-VCTEODGH-Bm7Q2BML.js +1 -0
- package/ui/dist/assets/arc-BV9hs0Hu.js +1 -0
- package/ui/dist/assets/architecture-7GRP2DOG-RckR3gBI.js +1 -0
- package/ui/dist/assets/architectureDiagram-5GKGNRK7-CHiJ6JFT.js +36 -0
- package/ui/dist/assets/array-BifhSqXX.js +1 -0
- package/ui/dist/assets/blockDiagram-I7D4REHJ-qCbaKVhd.js +129 -0
- package/ui/dist/assets/c4Diagram-7LVT6UL2-DidCDJrv.js +38 -0
- package/ui/dist/assets/channel-CNUQ11ne.js +1 -0
- package/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/ui/dist/assets/chunk-4HAMMTFA-CDGFjfVp.js +62 -0
- package/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/ui/dist/assets/chunk-75Z2AOVW-CbNT4sDh.js +2 -0
- package/ui/dist/assets/chunk-DU6HZSFF-Cq2BVv26.js +127 -0
- package/ui/dist/assets/chunk-F27PBJKO-CE365m0v.js +1 -0
- package/ui/dist/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- package/ui/dist/assets/chunk-GMAD6QVW-DxWL1VC-.js +72 -0
- package/ui/dist/assets/chunk-GVQU2GXP-aARV8YbK.js +1 -0
- package/ui/dist/assets/chunk-IMKFNOWR-CC790Kt2.js +231 -0
- package/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/ui/dist/assets/chunk-L3NEJ4N5-BfE4VE8J.js +1 -0
- package/ui/dist/assets/chunk-OSK3NFVY-BImQbSWH.js +10 -0
- package/ui/dist/assets/chunk-P2QGCYS3-CN3XD313.js +1 -0
- package/ui/dist/assets/chunk-POPQ4Y6H-Dqb6jOrX.js +1 -0
- package/ui/dist/assets/chunk-PWAF6VOD-DfXX6rBk.js +1 -0
- package/ui/dist/assets/chunk-SHT3W25Y-DATyyqPK.js +168 -0
- package/ui/dist/assets/chunk-SVP7TREG-r1chwRmg.js +88 -0
- package/ui/dist/assets/chunk-TICWLB2K-DL4atHUC.js +206 -0
- package/ui/dist/assets/chunk-XXDRQBXY-DXrM6jh7.js +1 -0
- package/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/ui/dist/assets/classDiagram-ZZMXUADV-BpQxfIQ7.js +1 -0
- package/ui/dist/assets/classDiagram-v2-VYDZK3BY-BpQxfIQ7.js +1 -0
- package/ui/dist/assets/cose-bilkent-JH36ORCC-Bhe-ZBlS.js +1 -0
- package/ui/dist/assets/cynefin-OW5HDTMX-D1koLd-G.js +1 -0
- package/ui/dist/assets/cynefinDiagram-5FMLGOSQ-BMcw40hV.js +62 -0
- package/ui/dist/assets/cytoscape.esm-CGd-uY3x.js +321 -0
- package/ui/dist/assets/dagre-GXQ25YYZ-DBun-Rs2.js +4 -0
- package/ui/dist/assets/dagre-PrKaheQc.js +1 -0
- package/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/ui/dist/assets/diagram-S7CK7UJ4-C1fUIGvo.js +30 -0
- package/ui/dist/assets/diagram-UQ7AKVKN-B5FDfkvB.js +41 -0
- package/ui/dist/assets/diagram-VSXAHHWV-CCIpOCNC.js +3 -0
- package/ui/dist/assets/diagram-VX7I27RA-DV0q2xo_.js +24 -0
- package/ui/dist/assets/diagram-Z3DM3KII-B3LCdNgI.js +24 -0
- package/ui/dist/assets/dist-EcW76Zvr.js +1 -0
- package/ui/dist/assets/ebnfDiagram-PWID7BFC-De_oRbYe.js +1 -0
- package/ui/dist/assets/erDiagram-RLTQ6QDP-B9NikMr1.js +99 -0
- package/ui/dist/assets/eventmodeling-NTZA5JFV-DZ4t581k.js +1 -0
- package/ui/dist/assets/flowDiagram-HODETNUW-DuTOJp1C.js +1 -0
- package/ui/dist/assets/ganttDiagram-EL5Y4UJY-BVTqqlPw.js +292 -0
- package/ui/dist/assets/gitGraph-4MIJSDKK-xRFaEJdZ.js +1 -0
- package/ui/dist/assets/gitGraphDiagram-WWUBYQGX-CK5_GCmX.js +106 -0
- package/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/ui/dist/assets/index-CjkpNftW.js +46 -0
- package/ui/dist/assets/index-DVIDTWin.css +1 -0
- package/ui/dist/assets/info-A6RAGUB7-Dg7z-CTI.js +1 -0
- package/ui/dist/assets/infoDiagram-27XIBGKW-YaqsOXh0.js +2 -0
- package/ui/dist/assets/init-C-OQMol4.js +1 -0
- package/ui/dist/assets/ishikawaDiagram-5VMMS53U-C-wtxZPa.js +70 -0
- package/ui/dist/assets/journeyDiagram-3NMN7TZE-DsXSXf16.js +139 -0
- package/ui/dist/assets/kanban-definition-UXKFOSKX-Ct59fuWk.js +89 -0
- package/ui/dist/assets/katex-CXMH3UgJ.js +257 -0
- package/ui/dist/assets/line-BUVw9Mhs.js +1 -0
- package/ui/dist/assets/linear-D8ncLO0X.js +1 -0
- package/ui/dist/assets/mermaid-parser.core-BDC4bG-O.js +7 -0
- package/ui/dist/assets/mermaid.core-DZY3WwzX.js +44 -0
- package/ui/dist/assets/mindmap-definition-YA3MSWOX-DtiuF6Ce.js +96 -0
- package/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/ui/dist/assets/packet-AYTQ26CC-BX2-w9rD.js +1 -0
- package/ui/dist/assets/path-fybaL0A-.js +1 -0
- package/ui/dist/assets/pegDiagram-XKGWAZYB-CotwuIHB.js +1 -0
- package/ui/dist/assets/pie-WAS4IAKB-Hth8ZVpm.js +1 -0
- package/ui/dist/assets/pieDiagram-E7YTZNPT-D9-INs8B.js +39 -0
- package/ui/dist/assets/quadrantDiagram-AXDQQJYC-CMtBXZGV.js +7 -0
- package/ui/dist/assets/radar-RG4KPBEZ-6UuwQPsw.js +1 -0
- package/ui/dist/assets/railroad-74A4TZTK-BF5TZgj0.js +1 -0
- package/ui/dist/assets/railroad-abnf-HS5TGJTU-3KYovtYT.js +1 -0
- package/ui/dist/assets/railroad-ebnf-LZEXJU2U-DeP4WedE.js +1 -0
- package/ui/dist/assets/railroad-peg-WCYAUIDC-i42N1-yk.js +1 -0
- package/ui/dist/assets/railroadDiagram-O6MQD6OU-BS6zkU3Z.js +1 -0
- package/ui/dist/assets/requirementDiagram-BXWQKSXE-J4IOB-kP.js +84 -0
- package/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/ui/dist/assets/sankeyDiagram-P5KCCOFB-jYTCuYzQ.js +40 -0
- package/ui/dist/assets/sequenceDiagram-WJ2MYXX4-Cd2r6PW-.js +162 -0
- package/ui/dist/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/ui/dist/assets/src-7TsfZXRO.js +1 -0
- package/ui/dist/assets/stateDiagram-D77RDMKH-DXOdtOKe.js +1 -0
- package/ui/dist/assets/stateDiagram-v2-MP3YSRHH-9h7sIf3R.js +1 -0
- package/ui/dist/assets/swimlanes-42K2YHIH-BvJGmGs1.js +1 -0
- package/ui/dist/assets/swimlanesDiagram-VR7AAH4N-QA4gPi6a.js +8 -0
- package/ui/dist/assets/timeline-definition-24CTP7MA-CAaD6-yd.js +120 -0
- package/ui/dist/assets/treeView-Q6P3EWNA-4gDEXIyJ.js +1 -0
- package/ui/dist/assets/treemap-WGGIJYW6-CGel5TwW.js +1 -0
- package/ui/dist/assets/vennDiagram-4TSXK5OY-DpxsKboL.js +34 -0
- package/ui/dist/assets/wardley-WFR3VGLG-Ck38v5ns.js +1 -0
- package/ui/dist/assets/wardleyDiagram-VM6X3IG4-Ba0grrqs.js +78 -0
- package/ui/dist/assets/xychartDiagram-S5SC5T6Z-DH_gC96K.js +7 -0
- package/ui/dist/index.html +13 -0
- package/ui/dist/kortext-logo-dark.svg +41 -0
- package/ui/dist/kortext-logo.svg +41 -0
- package/workflows/existing-project-analysis.md +87 -0
- package/workflows/new-project-analysis.md +98 -0
- package/workflows/planning-pipeline.md +61 -0
- package/AGENTS.md +0 -28
- package/agents/backend-developer.md +0 -132
- package/agents/delivery-manager.md +0 -121
- package/agents/engineering-manager.md +0 -208
- package/agents/frontend-developer.md +0 -174
- package/agents/operation-manager.md +0 -122
- package/hooks/audit-logger.sh +0 -25
- package/hooks/auto-locker.sh +0 -74
- package/hooks/auto-unlocker.sh +0 -17
- package/hooks/backlog-sync-guard.sh +0 -12
- package/hooks/branch-guard.sh +0 -32
- package/hooks/commit-msg-guard.sh +0 -43
- package/hooks/git-pre-commit.sh +0 -76
- package/hooks/git-pre-push.sh +0 -17
- package/hooks/handover-guard.sh +0 -33
- package/hooks/kortext-init.sh +0 -194
- package/hooks/kortext-lib.sh +0 -151
- package/hooks/lint-guard.sh +0 -50
- package/hooks/secret-scanner.sh +0 -89
- package/hooks/size-guard.sh +0 -48
- package/hooks/snapshot-guard.sh +0 -39
- package/hooks/write-guard.sh +0 -77
- package/rules/behavior.md +0 -252
- package/rules/branching.md +0 -82
- package/rules/commands.md +0 -112
- package/rules/emergency.md +0 -160
- package/rules/mcp.md +0 -47
- package/rules/models.md +0 -93
- package/scripts/kortext-backlog-add.py +0 -123
- package/scripts/kortext-backlog-health.py +0 -112
- package/scripts/kortext-backlog-sync.py +0 -117
- package/scripts/kortext-bulk-plan.py +0 -63
- package/scripts/kortext-cli.py +0 -376
- package/scripts/kortext-consistency-check.py +0 -74
- package/scripts/kortext-context-check.py +0 -146
- package/scripts/kortext-handover.py +0 -107
- package/scripts/kortext-item-check.py +0 -61
- package/scripts/kortext-item-start.py +0 -70
- package/scripts/kortext-item-transition.py +0 -78
- package/scripts/kortext-lock.py +0 -174
- package/scripts/kortext-session-start.py +0 -127
- package/scripts/lock_kortext.sh +0 -34
- package/settings/.claude-settings.template.json +0 -48
- package/settings/CHANGELOG.md +0 -176
- package/settings/INTEGRATION-MAP.md +0 -276
- package/settings/README.md +0 -255
- package/settings/USER-GUIDE.md +0 -502
- package/settings/VERSION +0 -1
- package/settings/config.md +0 -8
- package/settings/runtime-adapters.md +0 -104
- package/skills/backend-developer/.gitkeep +0 -0
- package/skills/compliance-expert/.gitkeep +0 -0
- package/skills/copywriter/.gitkeep +0 -0
- package/skills/db-admin/.gitkeep +0 -0
- package/skills/delivery-manager/.gitkeep +0 -0
- package/skills/designer/.gitkeep +0 -0
- package/skills/devops-engineer/.gitkeep +0 -0
- package/skills/engineering-manager/.gitkeep +0 -0
- package/skills/frontend-developer/.gitkeep +0 -0
- package/skills/growth-expert/.gitkeep +0 -0
- package/skills/operation-manager/.gitkeep +0 -0
- package/skills/product-manager/.gitkeep +0 -0
- package/skills/qa-engineer/.gitkeep +0 -0
- package/skills/security-engineer/.gitkeep +0 -0
- package/workflows/00-kortext-setup.md +0 -53
- package/workflows/01a-analysis-pipeline.md +0 -117
- package/workflows/01b-onboarding-pipeline.md +0 -110
- package/workflows/02-planning-pipeline.md +0 -146
- package/workflows/02b-spike-workflow.md +0 -120
- package/workflows/03-environment-setup.md +0 -99
- package/workflows/04-development-cycle.md +0 -203
- package/workflows/05-test-cycle.md +0 -123
- package/workflows/06-deployment-cycle.md +0 -224
- package/workflows/07-rollback-pipeline.md +0 -203
- package/workflows/08-hotfix-pipeline.md +0 -196
- package/workflows/09-maintenance-cycle.md +0 -133
- package/workspace/archive/.gitkeep +0 -0
- package/workspace/backups/.gitkeep +0 -0
- package/workspace/memory/backlog/.gitkeep +0 -0
- package/workspace/memory/backlog/BXX-[bug-name].md +0 -2
- package/workspace/memory/backlog/DXX-[debt-name].md +0 -2
- package/workspace/memory/backlog/README.md +0 -31
- package/workspace/memory/backlog/TXX-[task-name].md +0 -2
- package/workspace/memory/backlog/debt-dashboard.md +0 -3
- package/workspace/memory/backlog/epic-dashboard.md +0 -10
- package/workspace/memory/backlog/version-dashboard.md +0 -7
- package/workspace/memory/context/.gitkeep +0 -0
- package/workspace/memory/context/README.md +0 -35
- package/workspace/memory/context/[agent-name]-active.md +0 -2
- package/workspace/memory/decisions.md +0 -17
- package/workspace/memory/handover.md +0 -40
- package/workspace/memory/learned.md +0 -21
- package/workspace/references/access.md +0 -47
- package/workspace/references/api-reference.md +0 -59
- package/workspace/references/blueprint.md +0 -29
- package/workspace/references/content-strategy.md +0 -39
- package/workspace/references/db-schema.md +0 -51
- package/workspace/references/design-system.md +0 -107
- package/workspace/references/dictionary.md +0 -42
- package/workspace/references/file-system.md +0 -42
- package/workspace/references/growth-strategy.md +0 -46
- package/workspace/references/legal-strategy.md +0 -35
- package/workspace/references/required-skills.md +0 -11
- package/workspace/references/security-rules.md +0 -34
- package/workspace/references/tech-stack.md +0 -49
- package/workspace/references/test-strategy.md +0 -48
- package/workspace/reports/analysis-reports.md +0 -40
- package/workspace/reports/content-reports.md +0 -29
- package/workspace/reports/delivery-reports.md +0 -26
- package/workspace/reports/growth-reports.md +0 -9
- package/workspace/reports/legal-reports.md +0 -29
- package/workspace/reports/product-requirements.md +0 -28
- package/workspace/reports/release-notes.md +0 -20
- package/workspace/reports/security-reports.md +0 -27
- package/workspace/reports/status-reports.md +0 -48
- package/workspace/reports/tech-requirements.md +0 -30
- package/workspace/reports/test-reports.md +0 -61
- package/workspace/templates/BXX-[bug-name].md +0 -34
- package/workspace/templates/DXX-[debt-name].md +0 -35
- package/workspace/templates/EXX-[epic-name].md +0 -48
- package/workspace/templates/HXX-[hotfix-name].md +0 -73
- package/workspace/templates/SXX-[spike-name].md +0 -51
- package/workspace/templates/TXX-[task-name].md +0 -34
- package/workspace/templates/[agent-name]-active.md +0 -4
- package/workspace/templates/[original-name]_[YYYY-MM-DD_HHMMSS].md +0 -5
- package/workspace/templates/adr-XXX.md +0 -66
- package/workspace/templates/debt-dashboard.md +0 -10
- package/workspace/templates/epic-dashboard.md +0 -10
- 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
|
+
}
|