@njeybe/nativ 2.4.1 → 2.5.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/CHANGELOG.md +20 -0
- package/README.md +26 -0
- package/dist/commands/db/db-formatter.d.ts +5 -0
- package/dist/commands/db/db-formatter.js +116 -0
- package/dist/commands/db/db-introspect.d.ts +11 -0
- package/dist/commands/db/db-introspect.js +84 -0
- package/dist/commands/db.js +4 -206
- package/dist/commands/profile.d.ts +3 -0
- package/dist/commands/profile.js +39 -0
- package/dist/commands/task/task-add.d.ts +11 -0
- package/dist/commands/task/task-add.js +169 -0
- package/dist/commands/task/task-common.d.ts +57 -0
- package/dist/commands/task/task-common.js +45 -0
- package/dist/commands/task/task-escalate.d.ts +7 -0
- package/dist/commands/task/task-escalate.js +119 -0
- package/dist/commands/task/task-lifecycle.d.ts +5 -0
- package/dist/commands/task/task-lifecycle.js +252 -0
- package/dist/commands/task/task-query.d.ts +10 -0
- package/dist/commands/task/task-query.js +269 -0
- package/dist/commands/task.d.ts +9 -87
- package/dist/commands/task.js +8 -850
- package/dist/commands/triage/triage-store.d.ts +56 -0
- package/dist/commands/triage/triage-store.js +108 -0
- package/dist/commands/triage/triage-ui.d.ts +14 -0
- package/dist/commands/triage/triage-ui.js +104 -0
- package/dist/commands/triage.d.ts +3 -52
- package/dist/commands/triage.js +8 -216
- package/dist/core/enforcement.d.ts +1 -0
- package/dist/core/enforcement.js +19 -0
- package/dist/core/human-gate.d.ts +2 -1
- package/dist/core/human-gate.js +9 -2
- package/dist/core/profiles.d.ts +14 -0
- package/dist/core/profiles.js +82 -0
- package/dist/core/setup/config-merger.d.ts +59 -0
- package/dist/core/setup/config-merger.js +107 -0
- package/dist/core/setup/session-context.d.ts +4 -0
- package/dist/core/setup/session-context.js +65 -0
- package/dist/core/setup/setup-planner.d.ts +14 -0
- package/dist/core/setup/setup-planner.js +180 -0
- package/dist/core/setup/template-planner.d.ts +8 -0
- package/dist/core/setup/template-planner.js +32 -0
- package/dist/core/setup/types.d.ts +65 -0
- package/dist/core/setup/types.js +55 -0
- package/dist/core/setup-assets.d.ts +8 -119
- package/dist/core/setup-assets.js +7 -437
- package/dist/core/tier1-liaison.d.ts +2 -0
- package/dist/core/tier1-liaison.js +64 -3
- package/dist/core/verifier/executor.d.ts +5 -0
- package/dist/core/verifier/executor.js +62 -0
- package/dist/core/verifier/phases.d.ts +5 -0
- package/dist/core/verifier/phases.js +71 -0
- package/dist/core/verifier/task-verifier.d.ts +37 -0
- package/dist/core/verifier/task-verifier.js +233 -0
- package/dist/core/verifier/types.d.ts +51 -0
- package/dist/core/verifier/types.js +1 -0
- package/dist/core/verifier.d.ts +5 -93
- package/dist/core/verifier.js +5 -359
- package/dist/db/introspector.js +59 -59
- package/dist/governor/evaluator.d.ts +4 -64
- package/dist/governor/evaluator.js +21 -686
- package/dist/governor/patch-applier.d.ts +8 -0
- package/dist/governor/patch-applier.js +121 -0
- package/dist/governor/self-healing.d.ts +24 -0
- package/dist/governor/self-healing.js +351 -0
- package/dist/governor/test-integrity.d.ts +41 -0
- package/dist/governor/test-integrity.js +237 -0
- package/dist/index.js +8 -0
- package/dist/mcp/capture.d.ts +4 -0
- package/dist/mcp/capture.js +33 -0
- package/dist/mcp/resources.d.ts +39 -0
- package/dist/mcp/resources.js +44 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +14 -89
- package/dist/mcp/tools.d.ts +1 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/runner/agent-supervisor.d.ts +8 -302
- package/dist/runner/agent-supervisor.js +41 -1030
- package/dist/runner/cli-config.d.ts +84 -0
- package/dist/runner/cli-config.js +215 -0
- package/dist/runner/native-driver.d.ts +18 -0
- package/dist/runner/native-driver.js +208 -0
- package/dist/runner/native-engine.d.ts +27 -0
- package/dist/runner/native-engine.js +184 -0
- package/dist/runner/native-tools.d.ts +31 -0
- package/dist/runner/native-tools.js +241 -0
- package/dist/runner/process-utils.d.ts +12 -0
- package/dist/runner/process-utils.js +96 -0
- package/dist/runner/run-lifecycle.d.ts +6 -0
- package/dist/runner/run-lifecycle.js +42 -0
- package/dist/runner/types.d.ts +215 -0
- package/dist/runner/types.js +41 -0
- package/dist/scanner/detector.js +42 -663
- package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
- package/dist/scanner/detectors/ecosystem-detector.js +354 -0
- package/dist/scanner/detectors/orm-detector.d.ts +5 -0
- package/dist/scanner/detectors/orm-detector.js +59 -0
- package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
- package/dist/scanner/detectors/workspace-detector.js +266 -0
- package/dist/server/cli-runner.d.ts +15 -0
- package/dist/server/cli-runner.js +43 -0
- package/dist/server/event-hub.d.ts +42 -0
- package/dist/server/event-hub.js +169 -0
- package/dist/server/http-utils.d.ts +15 -0
- package/dist/server/http-utils.js +56 -0
- package/dist/server/plan-utils.d.ts +9 -0
- package/dist/server/plan-utils.js +31 -0
- package/dist/server/routes/db-routes.d.ts +57 -0
- package/dist/server/routes/db-routes.js +484 -0
- package/dist/server/routes/task-routes.d.ts +266 -0
- package/dist/server/routes/task-routes.js +285 -0
- package/dist/server/routes/telemetry-routes.d.ts +128 -0
- package/dist/server/routes/telemetry-routes.js +179 -0
- package/dist/server/routes/triage-routes.d.ts +81 -0
- package/dist/server/routes/triage-routes.js +244 -0
- package/dist/server/routes/worktree-routes.d.ts +31 -0
- package/dist/server/routes/worktree-routes.js +144 -0
- package/dist/server/studio-server.d.ts +4 -11
- package/dist/server/studio-server.js +34 -1578
- package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
- package/dist/server/studio-ui/shell/triage.js +10 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +4 -2
- package/plugin/agents/verifier.md +1 -1
- package/plugin/agents/worker.md +7 -4
- package/plugin/skills/nativ/SKILL.md +1 -0
- package/templates/.claudeignore +62 -45
- package/templates/AGENTS.md +1 -0
- package/templates/CLAUDE.md +13 -0
- package/templates/claude-agents/architect.md +4 -2
- package/templates/claude-agents/verifier.md +1 -1
- package/templates/claude-agents/worker.md +7 -4
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
- package/dist/commands/session.d.ts +0 -22
- package/dist/commands/session.js +0 -106
- package/dist/core/session-memory.d.ts +0 -42
- package/dist/core/session-memory.js +0 -121
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function detectWorkspacesAndServices(resolvedTarget, fileExists, readFileSafe) {
|
|
4
|
+
const monorepoIndicators = [];
|
|
5
|
+
const monorepoWorkspaces = [];
|
|
6
|
+
const keyDirectories = [];
|
|
7
|
+
const ecosystemManifests = [];
|
|
8
|
+
let detectedSubpackagesCount = 0;
|
|
9
|
+
if (fileExists('pnpm-workspace.yaml')) {
|
|
10
|
+
ecosystemManifests.push('pnpm-workspace.yaml');
|
|
11
|
+
monorepoIndicators.push('pnpm workspaces');
|
|
12
|
+
const pnpmWorkspace = readFileSafe(path.join(resolvedTarget, 'pnpm-workspace.yaml'));
|
|
13
|
+
const packageLines = pnpmWorkspace
|
|
14
|
+
.split('\n')
|
|
15
|
+
.map((l) => l.trim())
|
|
16
|
+
.filter((l) => l.startsWith('-'))
|
|
17
|
+
.map((l) => l.replace(/^-\s*['"]?/, '').replace(/['"]?$/, '').trim());
|
|
18
|
+
if (packageLines.length > 0) {
|
|
19
|
+
monorepoWorkspaces.push(...packageLines);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (fileExists('turbo.json')) {
|
|
23
|
+
ecosystemManifests.push('turbo.json');
|
|
24
|
+
monorepoIndicators.push('Turborepo');
|
|
25
|
+
}
|
|
26
|
+
if (fileExists('nx.json')) {
|
|
27
|
+
ecosystemManifests.push('nx.json');
|
|
28
|
+
monorepoIndicators.push('Nx');
|
|
29
|
+
}
|
|
30
|
+
if (fileExists('lerna.json')) {
|
|
31
|
+
ecosystemManifests.push('lerna.json');
|
|
32
|
+
monorepoIndicators.push('Lerna');
|
|
33
|
+
}
|
|
34
|
+
const candidateWorkspaceDirs = ['apps', 'packages', 'services', 'libs', 'modules'];
|
|
35
|
+
for (const wDir of candidateWorkspaceDirs) {
|
|
36
|
+
const fullWDir = path.join(resolvedTarget, wDir);
|
|
37
|
+
if (fs.existsSync(fullWDir) && fs.statSync(fullWDir).isDirectory()) {
|
|
38
|
+
keyDirectories.push(wDir);
|
|
39
|
+
if (!monorepoWorkspaces.includes(`${wDir}/*`)) {
|
|
40
|
+
monorepoWorkspaces.push(`${wDir}/*`);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const children = fs.readdirSync(fullWDir);
|
|
44
|
+
for (const child of children) {
|
|
45
|
+
const childPath = path.join(fullWDir, child);
|
|
46
|
+
if (fs.statSync(childPath).isDirectory()) {
|
|
47
|
+
if (fs.existsSync(path.join(childPath, 'package.json')) ||
|
|
48
|
+
fs.existsSync(path.join(childPath, 'go.mod')) ||
|
|
49
|
+
fs.existsSync(path.join(childPath, 'pom.xml')) ||
|
|
50
|
+
fs.existsSync(path.join(childPath, 'composer.json'))) {
|
|
51
|
+
detectedSubpackagesCount++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore read error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const independentServices = [];
|
|
62
|
+
try {
|
|
63
|
+
const rootItems = fs.readdirSync(resolvedTarget);
|
|
64
|
+
for (const item of rootItems) {
|
|
65
|
+
if (item.startsWith('.') ||
|
|
66
|
+
item === 'node_modules' ||
|
|
67
|
+
item === 'vendor' ||
|
|
68
|
+
item === 'dist' ||
|
|
69
|
+
item === 'build' ||
|
|
70
|
+
item === 'templates' ||
|
|
71
|
+
item === 'bin' ||
|
|
72
|
+
item === 'public') {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const itemPath = path.join(resolvedTarget, item);
|
|
76
|
+
if (fs.existsSync(itemPath) && fs.statSync(itemPath).isDirectory()) {
|
|
77
|
+
const subPathItem = (filename) => fs.existsSync(path.join(itemPath, filename));
|
|
78
|
+
const subManifests = [];
|
|
79
|
+
if (subPathItem('package.json'))
|
|
80
|
+
subManifests.push('package.json');
|
|
81
|
+
if (subPathItem('composer.json'))
|
|
82
|
+
subManifests.push('composer.json');
|
|
83
|
+
if (subPathItem('go.mod'))
|
|
84
|
+
subManifests.push('go.mod');
|
|
85
|
+
if (subPathItem('Cargo.toml'))
|
|
86
|
+
subManifests.push('Cargo.toml');
|
|
87
|
+
if (subPathItem('pom.xml'))
|
|
88
|
+
subManifests.push('pom.xml');
|
|
89
|
+
if (subPathItem('build.gradle'))
|
|
90
|
+
subManifests.push('build.gradle');
|
|
91
|
+
if (subPathItem('build.gradle.kts'))
|
|
92
|
+
subManifests.push('build.gradle.kts');
|
|
93
|
+
if (subPathItem('pyproject.toml'))
|
|
94
|
+
subManifests.push('pyproject.toml');
|
|
95
|
+
if (subPathItem('requirements.txt'))
|
|
96
|
+
subManifests.push('requirements.txt');
|
|
97
|
+
if (subPathItem('pubspec.yaml'))
|
|
98
|
+
subManifests.push('pubspec.yaml');
|
|
99
|
+
if (subPathItem('Podfile'))
|
|
100
|
+
subManifests.push('Podfile');
|
|
101
|
+
if (subPathItem('AndroidManifest.xml'))
|
|
102
|
+
subManifests.push('AndroidManifest.xml');
|
|
103
|
+
if (subManifests.length > 0) {
|
|
104
|
+
if (!keyDirectories.includes(item)) {
|
|
105
|
+
keyDirectories.push(item);
|
|
106
|
+
}
|
|
107
|
+
let subTech = 'Service';
|
|
108
|
+
let subRuntime = '';
|
|
109
|
+
let subFramework = '';
|
|
110
|
+
let subOrm = '';
|
|
111
|
+
let subOrmConfig = '';
|
|
112
|
+
if (subPathItem('pubspec.yaml')) {
|
|
113
|
+
subTech = 'Flutter Mobile App';
|
|
114
|
+
subRuntime = 'Dart / Flutter';
|
|
115
|
+
subFramework = 'Flutter';
|
|
116
|
+
}
|
|
117
|
+
else if (subPathItem('Podfile') || subPathItem('Info.plist') || fs.existsSync(path.join(itemPath, 'ios'))) {
|
|
118
|
+
subTech = 'Native iOS App (Swift/Obj-C)';
|
|
119
|
+
subRuntime = 'Swift / Objective-C';
|
|
120
|
+
subFramework = 'iOS Native';
|
|
121
|
+
}
|
|
122
|
+
else if (subPathItem('AndroidManifest.xml') || (subPathItem('build.gradle') && !subPathItem('package.json'))) {
|
|
123
|
+
subTech = 'Native Android App (Kotlin/Java)';
|
|
124
|
+
subRuntime = 'Kotlin / Java (Android)';
|
|
125
|
+
subFramework = 'Android Native';
|
|
126
|
+
}
|
|
127
|
+
else if (subPathItem('composer.json')) {
|
|
128
|
+
subRuntime = 'PHP';
|
|
129
|
+
subTech = 'PHP Backend';
|
|
130
|
+
try {
|
|
131
|
+
const subComposer = JSON.parse(readFileSafe(path.join(itemPath, 'composer.json')));
|
|
132
|
+
const allReq = { ...subComposer.require, ...subComposer['require-dev'] };
|
|
133
|
+
if (allReq['laravel/framework']) {
|
|
134
|
+
subFramework = 'Laravel';
|
|
135
|
+
subTech = 'Laravel Backend (PHP)';
|
|
136
|
+
subOrm = 'Eloquent ORM';
|
|
137
|
+
}
|
|
138
|
+
else if (allReq['symfony/framework-bundle']) {
|
|
139
|
+
subFramework = 'Symfony';
|
|
140
|
+
subTech = 'Symfony Backend (PHP)';
|
|
141
|
+
subOrm = 'Doctrine ORM';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
if (fs.existsSync(path.join(itemPath, 'database/migrations'))) {
|
|
146
|
+
subOrmConfig = `${item}/database/migrations`;
|
|
147
|
+
if (!subOrm)
|
|
148
|
+
subOrm = 'SQL Migrations';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (subPathItem('pyproject.toml') || subPathItem('requirements.txt')) {
|
|
152
|
+
subRuntime = 'Python';
|
|
153
|
+
subTech = 'Python Backend';
|
|
154
|
+
const pyStr = readFileSafe(path.join(itemPath, 'requirements.txt')) + readFileSafe(path.join(itemPath, 'pyproject.toml'));
|
|
155
|
+
if (pyStr.includes('fastapi')) {
|
|
156
|
+
subFramework = 'FastAPI';
|
|
157
|
+
subTech = 'FastAPI Backend (Python)';
|
|
158
|
+
}
|
|
159
|
+
else if (pyStr.includes('django')) {
|
|
160
|
+
subFramework = 'Django';
|
|
161
|
+
subTech = 'Django Backend (Python)';
|
|
162
|
+
subOrm = 'Django ORM';
|
|
163
|
+
}
|
|
164
|
+
else if (pyStr.includes('flask')) {
|
|
165
|
+
subFramework = 'Flask';
|
|
166
|
+
subTech = 'Flask Backend (Python)';
|
|
167
|
+
}
|
|
168
|
+
if (pyStr.includes('sqlalchemy') || fs.existsSync(path.join(itemPath, 'alembic.ini'))) {
|
|
169
|
+
subOrm = 'SQLAlchemy';
|
|
170
|
+
if (fs.existsSync(path.join(itemPath, 'alembic.ini'))) {
|
|
171
|
+
subOrmConfig = `${item}/alembic.ini`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (subPathItem('go.mod')) {
|
|
176
|
+
subRuntime = 'Go';
|
|
177
|
+
subTech = 'Go Backend';
|
|
178
|
+
const goStr = readFileSafe(path.join(itemPath, 'go.mod'));
|
|
179
|
+
if (goStr.includes('gin-gonic/gin')) {
|
|
180
|
+
subFramework = 'Gin';
|
|
181
|
+
subTech = 'Gin Backend (Go)';
|
|
182
|
+
}
|
|
183
|
+
else if (goStr.includes('gofiber/fiber')) {
|
|
184
|
+
subFramework = 'Fiber';
|
|
185
|
+
subTech = 'Fiber Backend (Go)';
|
|
186
|
+
}
|
|
187
|
+
if (goStr.includes('gorm.io/gorm'))
|
|
188
|
+
subOrm = 'GORM';
|
|
189
|
+
}
|
|
190
|
+
else if (subPathItem('package.json')) {
|
|
191
|
+
subRuntime = 'Node.js';
|
|
192
|
+
subTech = 'Node.js Service';
|
|
193
|
+
try {
|
|
194
|
+
const subPkg = JSON.parse(readFileSafe(path.join(itemPath, 'package.json')));
|
|
195
|
+
const subDeps = { ...subPkg.dependencies, ...subPkg.devDependencies };
|
|
196
|
+
if (subDeps.typescript || fs.existsSync(path.join(itemPath, 'tsconfig.json'))) {
|
|
197
|
+
subRuntime = 'Node.js (TypeScript)';
|
|
198
|
+
}
|
|
199
|
+
if (subDeps['react-native'] || subDeps['expo']) {
|
|
200
|
+
subTech = 'React Native / Expo Mobile App';
|
|
201
|
+
subFramework = subDeps.expo ? 'Expo Mobile App' : 'React Native';
|
|
202
|
+
}
|
|
203
|
+
else if (subDeps.next) {
|
|
204
|
+
subTech = 'Next.js Web App';
|
|
205
|
+
subFramework = 'Next.js';
|
|
206
|
+
}
|
|
207
|
+
else if (subDeps.nuxt) {
|
|
208
|
+
subTech = 'Nuxt Web App';
|
|
209
|
+
subFramework = 'Nuxt';
|
|
210
|
+
}
|
|
211
|
+
else if (subDeps.vite || subDeps.react || subDeps.vue) {
|
|
212
|
+
subTech = 'React / Web Frontend';
|
|
213
|
+
subFramework = subDeps.vite ? 'Vite Frontend' : (subDeps.react ? 'React SPA' : 'Vue SPA');
|
|
214
|
+
}
|
|
215
|
+
else if (subDeps.express || subDeps['@nestjs/core'] || subDeps.fastify) {
|
|
216
|
+
subTech = 'Node.js Backend';
|
|
217
|
+
subFramework = subDeps['@nestjs/core'] ? 'NestJS' : (subDeps.fastify ? 'Fastify' : 'Express.js');
|
|
218
|
+
}
|
|
219
|
+
if (subDeps.prisma || fs.existsSync(path.join(itemPath, 'prisma/schema.prisma'))) {
|
|
220
|
+
subOrm = 'Prisma ORM';
|
|
221
|
+
subOrmConfig = `${item}/prisma/schema.prisma`;
|
|
222
|
+
}
|
|
223
|
+
else if (subDeps['drizzle-orm'] || fs.existsSync(path.join(itemPath, 'drizzle.config.ts'))) {
|
|
224
|
+
subOrm = 'Drizzle ORM';
|
|
225
|
+
subOrmConfig = `${item}/drizzle.config.ts`;
|
|
226
|
+
}
|
|
227
|
+
else if (subDeps.typeorm) {
|
|
228
|
+
subOrm = 'TypeORM';
|
|
229
|
+
}
|
|
230
|
+
else if (subDeps.mongoose) {
|
|
231
|
+
subOrm = 'Mongoose';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
subTech = 'Node.js / Web App';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
independentServices.push({
|
|
239
|
+
name: item,
|
|
240
|
+
dirPath: itemPath,
|
|
241
|
+
manifests: subManifests,
|
|
242
|
+
tech: subTech,
|
|
243
|
+
runtime: subRuntime,
|
|
244
|
+
framework: subFramework,
|
|
245
|
+
orm: subOrm,
|
|
246
|
+
ormConfig: subOrmConfig,
|
|
247
|
+
});
|
|
248
|
+
if (!monorepoWorkspaces.includes(`${item}/*`) && !candidateWorkspaceDirs.includes(item)) {
|
|
249
|
+
monorepoWorkspaces.push(`${item}/*`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// ignore read error
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
monorepoIndicators,
|
|
260
|
+
monorepoWorkspaces,
|
|
261
|
+
keyDirectories,
|
|
262
|
+
detectedSubpackagesCount,
|
|
263
|
+
independentServices,
|
|
264
|
+
ecosystemManifests,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function truncateOutput(text: string): string;
|
|
2
|
+
export interface CapturedRun<T> {
|
|
3
|
+
result: T;
|
|
4
|
+
/** Console output with ANSI colors stripped. */
|
|
5
|
+
output: string;
|
|
6
|
+
/** True when the handler set a non-zero process.exitCode. */
|
|
7
|
+
failed: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The CLI command handlers report through console.* and process.exitCode, which are process-global,
|
|
11
|
+
* so captured runs are serialized (same approach as the MCP server).
|
|
12
|
+
*/
|
|
13
|
+
export declare function runCaptured<T>(fn: () => Promise<T>): Promise<CapturedRun<T>>;
|
|
14
|
+
/** `--json` CLI handlers print `{ success: false, error }` on failure. */
|
|
15
|
+
export declare function jsonErrorMessage(output: string): string | null;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const MAX_ERROR_OUTPUT_CHARS = 4000;
|
|
2
|
+
const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
|
|
3
|
+
export function truncateOutput(text) {
|
|
4
|
+
return text.length > MAX_ERROR_OUTPUT_CHARS ? `${text.slice(0, MAX_ERROR_OUTPUT_CHARS)}…` : text;
|
|
5
|
+
}
|
|
6
|
+
let captureQueue = Promise.resolve();
|
|
7
|
+
/**
|
|
8
|
+
* The CLI command handlers report through console.* and process.exitCode, which are process-global,
|
|
9
|
+
* so captured runs are serialized (same approach as the MCP server).
|
|
10
|
+
*/
|
|
11
|
+
export function runCaptured(fn) {
|
|
12
|
+
const run = async () => {
|
|
13
|
+
const lines = [];
|
|
14
|
+
const original = { log: console.log, error: console.error, warn: console.warn, info: console.info };
|
|
15
|
+
const previousExitCode = process.exitCode;
|
|
16
|
+
process.exitCode = undefined;
|
|
17
|
+
console.log = console.error = console.warn = console.info = (...args) => {
|
|
18
|
+
lines.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
const result = await fn();
|
|
22
|
+
const failed = process.exitCode !== undefined && process.exitCode !== 0;
|
|
23
|
+
return { result, failed, output: lines.join('\n').replace(ANSI_PATTERN, '').trim() };
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
Object.assign(console, original);
|
|
27
|
+
process.exitCode = previousExitCode;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const result = captureQueue.then(run, run);
|
|
31
|
+
captureQueue = result.catch(() => undefined);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
/** `--json` CLI handlers print `{ success: false, error }` on failure. */
|
|
35
|
+
export function jsonErrorMessage(output) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(output);
|
|
38
|
+
return typeof parsed?.error === 'string' ? parsed.error : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
export declare const DEFAULT_HEARTBEAT_MS = 15000;
|
|
3
|
+
export declare const EVENT_DEBOUNCE_MS = 100;
|
|
4
|
+
export declare const WATCH_POLL_INTERVAL_MS = 1000;
|
|
5
|
+
export type PipelineEventName = 'plan_change' | 'telemetry_change';
|
|
6
|
+
/** Files in .ai/ that drive /api/events, mapped to the event a change emits. Anything else (tmp/lock files) is ignored. */
|
|
7
|
+
export declare const WATCHED_FILES: Map<string, PipelineEventName>;
|
|
8
|
+
/**
|
|
9
|
+
* Fan-out for GET /api/events. Watches .ai/ only while a client is connected, coalesces write bursts
|
|
10
|
+
* (tmp file + atomic rename + lock files) into one event per type, and sends heartbeats so idle
|
|
11
|
+
* streams survive proxies and sleep/wake.
|
|
12
|
+
*
|
|
13
|
+
* Frames: `event: plan_change` / `event: telemetry_change` with `data: {"files":[...],"at":"<ISO>"}`,
|
|
14
|
+
* and `event: heartbeat` with `data: {"at":"<ISO>"}`.
|
|
15
|
+
*/
|
|
16
|
+
export declare class PipelineEventHub {
|
|
17
|
+
private readonly aiDir;
|
|
18
|
+
private readonly heartbeatMs;
|
|
19
|
+
private readonly onFileChange?;
|
|
20
|
+
private readonly clients;
|
|
21
|
+
private readonly pending;
|
|
22
|
+
private watcher;
|
|
23
|
+
private pollers;
|
|
24
|
+
private heartbeat;
|
|
25
|
+
private flushTimer;
|
|
26
|
+
private closed;
|
|
27
|
+
constructor(aiDir: string, heartbeatMs: number, onFileChange?: ((file: string) => void) | undefined);
|
|
28
|
+
subscribe(res: http.ServerResponse): void;
|
|
29
|
+
/** Pushes an agent-supervisor event (`runner_status`, `runner_log`, `runner_token_usage`) to every connected client. */
|
|
30
|
+
publish(event: 'runner_status' | 'runner_log' | 'runner_token_usage', data: unknown): void;
|
|
31
|
+
publishPlanChange(files?: string[]): void;
|
|
32
|
+
/** SSE responses never finish on their own; drop them so http.Server#close() can complete. */
|
|
33
|
+
close(): void;
|
|
34
|
+
private start;
|
|
35
|
+
private startWatcher;
|
|
36
|
+
private startPolling;
|
|
37
|
+
private stop;
|
|
38
|
+
private closeWatcher;
|
|
39
|
+
private queue;
|
|
40
|
+
private flush;
|
|
41
|
+
private broadcast;
|
|
42
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sendPipelineError } from './http-utils.js';
|
|
4
|
+
export const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
5
|
+
export const EVENT_DEBOUNCE_MS = 100;
|
|
6
|
+
export const WATCH_POLL_INTERVAL_MS = 1000;
|
|
7
|
+
/** Files in .ai/ that drive /api/events, mapped to the event a change emits. Anything else (tmp/lock files) is ignored. */
|
|
8
|
+
export const WATCHED_FILES = new Map([
|
|
9
|
+
['master_plan.json', 'plan_change'],
|
|
10
|
+
['escalation.json', 'plan_change'],
|
|
11
|
+
['.governor_ledger.json', 'plan_change'],
|
|
12
|
+
['context.md', 'plan_change'],
|
|
13
|
+
['db_schema.json', 'plan_change'],
|
|
14
|
+
['api_contracts.json', 'plan_change'],
|
|
15
|
+
['ui_specs.md', 'plan_change'],
|
|
16
|
+
['telemetry.json', 'telemetry_change'],
|
|
17
|
+
['benchmark_report.json', 'telemetry_change'],
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Fan-out for GET /api/events. Watches .ai/ only while a client is connected, coalesces write bursts
|
|
21
|
+
* (tmp file + atomic rename + lock files) into one event per type, and sends heartbeats so idle
|
|
22
|
+
* streams survive proxies and sleep/wake.
|
|
23
|
+
*
|
|
24
|
+
* Frames: `event: plan_change` / `event: telemetry_change` with `data: {"files":[...],"at":"<ISO>"}`,
|
|
25
|
+
* and `event: heartbeat` with `data: {"at":"<ISO>"}`.
|
|
26
|
+
*/
|
|
27
|
+
export class PipelineEventHub {
|
|
28
|
+
aiDir;
|
|
29
|
+
heartbeatMs;
|
|
30
|
+
onFileChange;
|
|
31
|
+
clients = new Set();
|
|
32
|
+
pending = new Map();
|
|
33
|
+
watcher = null;
|
|
34
|
+
pollers = [];
|
|
35
|
+
heartbeat = null;
|
|
36
|
+
flushTimer = null;
|
|
37
|
+
closed = false;
|
|
38
|
+
constructor(aiDir, heartbeatMs, onFileChange) {
|
|
39
|
+
this.aiDir = aiDir;
|
|
40
|
+
this.heartbeatMs = heartbeatMs;
|
|
41
|
+
this.onFileChange = onFileChange;
|
|
42
|
+
this.startWatcher();
|
|
43
|
+
}
|
|
44
|
+
subscribe(res) {
|
|
45
|
+
if (this.closed) {
|
|
46
|
+
sendPipelineError(res, 503, 'SHUTTING_DOWN', 'Studio server is shutting down');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
51
|
+
'Cache-Control': 'no-store',
|
|
52
|
+
Connection: 'keep-alive',
|
|
53
|
+
'X-Accel-Buffering': 'no',
|
|
54
|
+
'X-Content-Type-Options': 'nosniff',
|
|
55
|
+
});
|
|
56
|
+
res.socket?.setNoDelay(true);
|
|
57
|
+
// Reconnect delay for EventSource; the comment line flushes the headers right away.
|
|
58
|
+
res.write('retry: 3000\n: connected\n\n');
|
|
59
|
+
this.clients.add(res);
|
|
60
|
+
res.on('close', () => {
|
|
61
|
+
this.clients.delete(res);
|
|
62
|
+
if (this.clients.size === 0 && this.heartbeat) {
|
|
63
|
+
clearInterval(this.heartbeat);
|
|
64
|
+
this.heartbeat = null;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
if (!this.heartbeat) {
|
|
68
|
+
this.heartbeat = setInterval(() => this.broadcast('heartbeat', { at: new Date().toISOString() }), this.heartbeatMs);
|
|
69
|
+
this.heartbeat.unref();
|
|
70
|
+
}
|
|
71
|
+
this.startWatcher();
|
|
72
|
+
}
|
|
73
|
+
/** Pushes an agent-supervisor event (`runner_status`, `runner_log`, `runner_token_usage`) to every connected client. */
|
|
74
|
+
publish(event, data) {
|
|
75
|
+
if (this.closed || this.clients.size === 0)
|
|
76
|
+
return;
|
|
77
|
+
this.broadcast(event, data);
|
|
78
|
+
}
|
|
79
|
+
publishPlanChange(files = ['escalation.json']) {
|
|
80
|
+
if (this.closed || this.clients.size === 0)
|
|
81
|
+
return;
|
|
82
|
+
this.broadcast('plan_change', { files, at: new Date().toISOString() });
|
|
83
|
+
}
|
|
84
|
+
/** SSE responses never finish on their own; drop them so http.Server#close() can complete. */
|
|
85
|
+
close() {
|
|
86
|
+
this.closed = true;
|
|
87
|
+
for (const res of this.clients)
|
|
88
|
+
res.destroy();
|
|
89
|
+
this.clients.clear();
|
|
90
|
+
this.stop();
|
|
91
|
+
}
|
|
92
|
+
start() {
|
|
93
|
+
this.startWatcher();
|
|
94
|
+
}
|
|
95
|
+
startWatcher() {
|
|
96
|
+
if (this.watcher || this.pollers.length)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
this.watcher = fs.watch(this.aiDir, { persistent: false }, (_event, filename) => {
|
|
100
|
+
if (filename)
|
|
101
|
+
this.queue(String(filename));
|
|
102
|
+
});
|
|
103
|
+
this.watcher.on('error', () => {
|
|
104
|
+
this.closeWatcher();
|
|
105
|
+
this.startPolling();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// .ai/ is missing (studio started before `nativ init`) or not watchable: poll the files instead.
|
|
110
|
+
this.startPolling();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
startPolling() {
|
|
114
|
+
if (this.pollers.length)
|
|
115
|
+
return;
|
|
116
|
+
for (const file of WATCHED_FILES.keys()) {
|
|
117
|
+
// Missing files report zeroed stats, so an unchanged mtime means nothing happened.
|
|
118
|
+
const listener = (curr, prev) => {
|
|
119
|
+
if (curr.mtimeMs !== prev.mtimeMs)
|
|
120
|
+
this.queue(file);
|
|
121
|
+
};
|
|
122
|
+
fs.watchFile(path.join(this.aiDir, file), { interval: WATCH_POLL_INTERVAL_MS, persistent: false }, listener);
|
|
123
|
+
this.pollers.push({ file, listener });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
stop() {
|
|
127
|
+
if (this.heartbeat)
|
|
128
|
+
clearInterval(this.heartbeat);
|
|
129
|
+
if (this.flushTimer)
|
|
130
|
+
clearTimeout(this.flushTimer);
|
|
131
|
+
this.heartbeat = this.flushTimer = null;
|
|
132
|
+
this.pending.clear();
|
|
133
|
+
this.closeWatcher();
|
|
134
|
+
for (const { file, listener } of this.pollers)
|
|
135
|
+
fs.unwatchFile(path.join(this.aiDir, file), listener);
|
|
136
|
+
this.pollers = [];
|
|
137
|
+
}
|
|
138
|
+
closeWatcher() {
|
|
139
|
+
this.watcher?.close();
|
|
140
|
+
this.watcher = null;
|
|
141
|
+
}
|
|
142
|
+
queue(file) {
|
|
143
|
+
try {
|
|
144
|
+
this.onFileChange?.(file);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Ignore consumer callback error
|
|
148
|
+
}
|
|
149
|
+
const event = WATCHED_FILES.get(file);
|
|
150
|
+
if (!event)
|
|
151
|
+
return;
|
|
152
|
+
const files = this.pending.get(event) ?? new Set();
|
|
153
|
+
files.add(file);
|
|
154
|
+
this.pending.set(event, files);
|
|
155
|
+
this.flushTimer ??= setTimeout(() => this.flush(), EVENT_DEBOUNCE_MS);
|
|
156
|
+
}
|
|
157
|
+
flush() {
|
|
158
|
+
this.flushTimer = null;
|
|
159
|
+
const at = new Date().toISOString();
|
|
160
|
+
for (const [event, files] of this.pending)
|
|
161
|
+
this.broadcast(event, { files: [...files], at });
|
|
162
|
+
this.pending.clear();
|
|
163
|
+
}
|
|
164
|
+
broadcast(event, data) {
|
|
165
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
166
|
+
for (const res of this.clients)
|
|
167
|
+
res.write(frame);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
import type { DatabaseEnv } from '../db/types.js';
|
|
3
|
+
export declare const MAX_BODY_BYTES: number;
|
|
4
|
+
export declare const LOOPBACK_HOSTS: Set<string>;
|
|
5
|
+
export declare class HttpError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly code: string;
|
|
8
|
+
constructor(status: number, code: string, message: string);
|
|
9
|
+
}
|
|
10
|
+
export declare function sendJson(res: http.ServerResponse, status: number, body: unknown): void;
|
|
11
|
+
export declare function sendError(res: http.ServerResponse, status: number, code: string, message: string): void;
|
|
12
|
+
/** /api/pipeline/* errors use the contract's `{ ok: false, error }` envelope. */
|
|
13
|
+
export declare function sendPipelineError(res: http.ServerResponse, status: number, code: string, message: string): void;
|
|
14
|
+
export declare function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>>;
|
|
15
|
+
export declare function parseEnv(value: unknown, field: string): DatabaseEnv;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const MAX_BODY_BYTES = 64 * 1024;
|
|
2
|
+
export const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
3
|
+
export class HttpError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
constructor(status, code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function sendJson(res, status, body) {
|
|
13
|
+
const payload = JSON.stringify(body);
|
|
14
|
+
res.writeHead(status, {
|
|
15
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
16
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
17
|
+
'Cache-Control': 'no-store',
|
|
18
|
+
'X-Content-Type-Options': 'nosniff',
|
|
19
|
+
});
|
|
20
|
+
res.end(payload);
|
|
21
|
+
}
|
|
22
|
+
export function sendError(res, status, code, message) {
|
|
23
|
+
sendJson(res, status, { error: { code, message } });
|
|
24
|
+
}
|
|
25
|
+
/** /api/pipeline/* errors use the contract's `{ ok: false, error }` envelope. */
|
|
26
|
+
export function sendPipelineError(res, status, code, message) {
|
|
27
|
+
sendJson(res, status, { ok: false, error: message, code });
|
|
28
|
+
}
|
|
29
|
+
export async function readJsonBody(req) {
|
|
30
|
+
const contentType = String(req.headers['content-type'] ?? '');
|
|
31
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
32
|
+
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json');
|
|
33
|
+
}
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let size = 0;
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
size += chunk.length;
|
|
38
|
+
if (size > MAX_BODY_BYTES)
|
|
39
|
+
throw new HttpError(413, 'PAYLOAD_TOO_LARGE', 'Request body exceeds 64KB');
|
|
40
|
+
chunks.push(chunk);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
45
|
+
throw new Error('not an object');
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new HttpError(400, 'INVALID_JSON', 'Request body must be a JSON object');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function parseEnv(value, field) {
|
|
53
|
+
if (value === 'dev' || value === 'prod')
|
|
54
|
+
return value;
|
|
55
|
+
throw new HttpError(400, 'VALIDATION_ERROR', `"${field}" must be 'dev' or 'prod'`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MasterPlan, MasterPlanMilestone, MasterPlanTask } from '../scanner/types.js';
|
|
2
|
+
export declare const AI_DIR = ".ai";
|
|
3
|
+
export declare const TASK_ID_PATTERN: RegExp;
|
|
4
|
+
export declare function readPlan(root: string): MasterPlan | null;
|
|
5
|
+
/** Milestones with a usable task list; tolerates hand-edited or half-written plans. */
|
|
6
|
+
export declare function planMilestones(plan: MasterPlan | null): MasterPlanMilestone[];
|
|
7
|
+
export declare function findTask(plan: MasterPlan | null, taskId: string): MasterPlanTask | null;
|
|
8
|
+
/** Task ids reach git branch names and shell commands in the worktree handlers, so the charset is strict. */
|
|
9
|
+
export declare function parseTaskId(value: unknown): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadPlan } from '../core/lock-manager.js';
|
|
3
|
+
import { HttpError } from './http-utils.js';
|
|
4
|
+
export const AI_DIR = '.ai';
|
|
5
|
+
export const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
export function readPlan(root) {
|
|
7
|
+
return loadPlan(path.join(root, AI_DIR, 'master_plan.json'), { silent: true, retries: 3 });
|
|
8
|
+
}
|
|
9
|
+
/** Milestones with a usable task list; tolerates hand-edited or half-written plans. */
|
|
10
|
+
export function planMilestones(plan) {
|
|
11
|
+
const milestones = plan?.milestones;
|
|
12
|
+
if (!Array.isArray(milestones))
|
|
13
|
+
return [];
|
|
14
|
+
return milestones.filter((m) => m && Array.isArray(m.tasks));
|
|
15
|
+
}
|
|
16
|
+
export function findTask(plan, taskId) {
|
|
17
|
+
for (const m of planMilestones(plan)) {
|
|
18
|
+
const task = m.tasks.find((t) => t.id === taskId);
|
|
19
|
+
if (task)
|
|
20
|
+
return task;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** Task ids reach git branch names and shell commands in the worktree handlers, so the charset is strict. */
|
|
25
|
+
export function parseTaskId(value) {
|
|
26
|
+
const taskId = typeof value === 'string' ? value.trim() : '';
|
|
27
|
+
if (!TASK_ID_PATTERN.test(taskId)) {
|
|
28
|
+
throw new HttpError(400, 'VALIDATION_ERROR', '"taskId" must be a task id (letters, digits, ".", "_" or "-")');
|
|
29
|
+
}
|
|
30
|
+
return taskId;
|
|
31
|
+
}
|