gekto 0.0.13 → 0.0.15

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.
@@ -6,6 +6,7 @@ import { getState, mutate, mutateBatch, addClient, removeClient, sendSnapshot, g
6
6
  import { persistEntity } from '../entityStore.js';
7
7
  import fs from 'fs';
8
8
  import nodePath from 'path';
9
+ import { getPostHog, getDistinctId } from '../posthog.js';
9
10
  let gektoInitialized = false;
10
11
  function broadcastGektoState(state) {
11
12
  const message = JSON.stringify({ type: 'gekto_state', state });
@@ -160,6 +161,11 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
160
161
  type: 'kill_all_result',
161
162
  killed: killedCount,
162
163
  }));
164
+ getPostHog().capture({
165
+ distinctId: getDistinctId(),
166
+ event: 'all agents killed',
167
+ properties: { killed_count: killedCount },
168
+ });
163
169
  // Notify about state changes
164
170
  for (const session of getActiveSessions()) {
165
171
  ws.send(JSON.stringify({ type: 'state', lizardId: session.lizardId, state: 'ready' }));
@@ -193,6 +199,11 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
193
199
  mutate('activePlanId', null);
194
200
  broadcastActivePlans();
195
201
  broadcastActivePlanId();
202
+ getPostHog().capture({
203
+ distinctId: getDistinctId(),
204
+ event: 'all agents cleared',
205
+ properties: { agent_count: agentIds.length },
206
+ });
196
207
  return;
197
208
  }
198
209
  case 'set_active_plan': {
@@ -299,6 +310,14 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
299
310
  planId: msg.planId,
300
311
  prompt: msg.prompt,
301
312
  }));
313
+ getPostHog().capture({
314
+ distinctId: getDistinctId(),
315
+ event: 'gekto message sent',
316
+ properties: {
317
+ plan_id: msg.planId,
318
+ has_images: Boolean(planImagePaths?.length),
319
+ },
320
+ });
302
321
  const planResult = await processWithTools(msg.prompt, msg.planId, getWorkingDir(), getActiveSessions(), planCallbacks, msg.existingPlan, planImagePaths);
303
322
  // Replace streamed JSON with clean message (always send to overwrite raw JSON)
304
323
  // For delegate, the system message handles display — clear the streaming text
@@ -348,6 +367,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
348
367
  planId: msg.planId,
349
368
  plan: planResult.plan,
350
369
  }));
370
+ getPostHog().capture({
371
+ distinctId: getDistinctId(),
372
+ event: 'plan created',
373
+ properties: {
374
+ plan_id: planResult.plan.id,
375
+ plan_title: planResult.plan.title,
376
+ action: planExistedBefore ? 'update_plan' : 'create_plan',
377
+ },
378
+ });
351
379
  }
352
380
  else if (planResult.type === 'remove' && planResult.removedAgents) {
353
381
  // Remove agents from server state
@@ -412,6 +440,62 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
412
440
  }));
413
441
  }
414
442
  }
443
+ else if (planResult.type === 'spawn_agent' && planResult.spawn) {
444
+ // Restore plan state — only delete if it was a temporary entry
445
+ if (msg.planId && getState().activePlans[msg.planId]?.status === 'planning') {
446
+ if (planExistedBefore && previousStatus) {
447
+ mutate(`activePlans.${msg.planId}.status`, previousStatus);
448
+ broadcastSinglePlan(msg.planId);
449
+ }
450
+ else {
451
+ mutate(`activePlans.${msg.planId}`, undefined);
452
+ broadcastSinglePlan(msg.planId);
453
+ }
454
+ }
455
+ // Spin up a brand-new free agent (no plan) and hand the prompt to it.
456
+ // Uses the `agent_` prefix so it shows up in the floating LizardsList swarm UI
457
+ // (the `worker_` prefix is filtered out — those agents only render on the whiteboard).
458
+ const { agentName, taskDescription, prompt: workerPrompt } = planResult.spawn;
459
+ const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
460
+ const newAgentId = `agent_${suffix}`;
461
+ const newTaskId = `task_${suffix}`;
462
+ const newTask = {
463
+ id: newTaskId,
464
+ name: agentName,
465
+ description: taskDescription,
466
+ prompt: workerPrompt,
467
+ status: 'in_progress',
468
+ dependencies: [],
469
+ files: [],
470
+ assignedAgentId: newAgentId,
471
+ };
472
+ const newAgent = {
473
+ id: newAgentId,
474
+ taskId: newTaskId,
475
+ personaId: 'plain',
476
+ status: 'working',
477
+ name: agentName,
478
+ createdAt: new Date().toISOString(),
479
+ };
480
+ mutateBatch([
481
+ { path: `tasks.${newTaskId}`, value: newTask },
482
+ { path: `agents.${newAgentId}`, value: newAgent },
483
+ ]);
484
+ broadcastTask(newTaskId);
485
+ broadcastAgent(newAgentId);
486
+ // Notify the master chat so the "Delegated to <name>" badge renders.
487
+ // Intentionally omit planId — this worker is a free agent, not part of any plan.
488
+ ws.send(JSON.stringify({
489
+ type: 'gekto_delegate',
490
+ agentId: newAgentId,
491
+ agentName,
492
+ message: planResult.message || `Delegated to ${agentName}.`,
493
+ }));
494
+ // Fire-and-forget: send the prompt to the new worker's session.
495
+ sendMessage(newAgentId, workerPrompt, ws).catch(err => {
496
+ console.error(`[Agent] Spawn delegate to ${newAgentId} failed:`, err);
497
+ });
498
+ }
415
499
  else if (planResult.type === 'chat') {
416
500
  // Restore plan state — only delete if it was a temporary entry
417
501
  if (msg.planId && getState().activePlans[msg.planId]?.status === 'planning') {
@@ -441,6 +525,7 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
441
525
  }
442
526
  catch (err) {
443
527
  console.error('[Agent] Gekto processing failed:', err);
528
+ getPostHog().captureException(err, getDistinctId(), { plan_id: msg.planId });
444
529
  ws.send(JSON.stringify({
445
530
  type: 'gekto_chat',
446
531
  planId: msg.planId,
@@ -531,6 +616,14 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
531
616
  planId: msg.planId,
532
617
  taskCount: tasks.length,
533
618
  }));
619
+ getPostHog().capture({
620
+ distinctId: getDistinctId(),
621
+ event: 'plan tasks generated',
622
+ properties: {
623
+ plan_id: msg.planId,
624
+ task_count: tasks.length,
625
+ },
626
+ });
534
627
  },
535
628
  onError: (error) => {
536
629
  ws.send(JSON.stringify({
@@ -547,6 +640,7 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
547
640
  }
548
641
  catch (err) {
549
642
  console.error('[Agent] Task generation failed:', err);
643
+ getPostHog().captureException(err, getDistinctId(), { plan_id: msg.planId });
550
644
  ws.send(JSON.stringify({
551
645
  type: 'gekto_chat',
552
646
  planId: msg.planId,
@@ -570,9 +664,22 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
570
664
  mutate(`activePlans.${msg.planId}.status`, 'executing');
571
665
  broadcastSinglePlan(msg.planId);
572
666
  }
667
+ getPostHog().capture({
668
+ distinctId: getDistinctId(),
669
+ event: 'plan executed',
670
+ properties: {
671
+ plan_id: msg.planId,
672
+ task_count: currentState.activePlans[msg.planId]?.taskIds?.length ?? 0,
673
+ },
674
+ });
573
675
  return;
574
676
  }
575
677
  case 'cancel_plan': {
678
+ getPostHog().capture({
679
+ distinctId: getDistinctId(),
680
+ event: 'plan canceled',
681
+ properties: { plan_id: msg.planId },
682
+ });
576
683
  const currentState = getState();
577
684
  const cancelPlan = currentState.activePlans[msg.planId];
578
685
  if (cancelPlan) {
@@ -625,6 +732,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
625
732
  ]);
626
733
  broadcastTask(task.id);
627
734
  broadcastAgent(agent.id);
735
+ getPostHog().capture({
736
+ distinctId: getDistinctId(),
737
+ event: 'worker agent spawned',
738
+ properties: {
739
+ agent_id: agent.id,
740
+ task_id: task.id,
741
+ plan_id: task.planId,
742
+ },
743
+ });
628
744
  return;
629
745
  }
630
746
  // Client updates a chat message list — store on agent
@@ -695,6 +811,16 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
695
811
  broadcastTask(msg.taskId);
696
812
  if (agentId)
697
813
  broadcastAgent(agentId);
814
+ getPostHog().capture({
815
+ distinctId: getDistinctId(),
816
+ event: 'task completed',
817
+ properties: {
818
+ task_id: msg.taskId,
819
+ task_name: taskState?.name,
820
+ plan_id: taskState?.planId,
821
+ agent_id: agentId,
822
+ },
823
+ });
698
824
  }
699
825
  return;
700
826
  }
@@ -713,16 +839,38 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
713
839
  broadcastTask(msg.taskId);
714
840
  if (agentId)
715
841
  broadcastAgent(agentId);
842
+ getPostHog().capture({
843
+ distinctId: getDistinctId(),
844
+ event: 'task failed',
845
+ properties: {
846
+ task_id: msg.taskId,
847
+ task_name: taskState?.name,
848
+ plan_id: taskState?.planId,
849
+ agent_id: agentId,
850
+ error: msg.error,
851
+ },
852
+ });
716
853
  }
717
854
  return;
718
855
  }
719
856
  case 'task_started': {
720
857
  if (msg.taskId) {
858
+ const taskState = getState().tasks[msg.taskId];
721
859
  mutateBatch([
722
860
  { path: `tasks.${msg.taskId}.status`, value: 'in_progress' },
723
861
  { path: `tasks.${msg.taskId}.assignedAgentId`, value: msg.lizardId },
724
862
  ]);
725
863
  broadcastTask(msg.taskId);
864
+ getPostHog().capture({
865
+ distinctId: getDistinctId(),
866
+ event: 'task started',
867
+ properties: {
868
+ task_id: msg.taskId,
869
+ task_name: taskState?.name,
870
+ plan_id: taskState?.planId,
871
+ agent_id: msg.lizardId,
872
+ },
873
+ });
726
874
  }
727
875
  return;
728
876
  }
@@ -930,6 +1078,14 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
930
1078
  broadcastAgent(lizardId);
931
1079
  }
932
1080
  const images = msg.images;
1081
+ getPostHog().capture({
1082
+ distinctId: getDistinctId(),
1083
+ event: 'agent message sent',
1084
+ properties: {
1085
+ agent_id: lizardId,
1086
+ has_images: Boolean(images?.length),
1087
+ },
1088
+ });
933
1089
  await sendMessage(lizardId, msg.content, ws, images);
934
1090
  }
935
1091
  catch (err) {
@@ -943,6 +1099,11 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
943
1099
  else {
944
1100
  resetSession(lizardId);
945
1101
  }
1102
+ getPostHog().capture({
1103
+ distinctId: getDistinctId(),
1104
+ event: 'agent reset',
1105
+ properties: { agent_id: lizardId },
1106
+ });
946
1107
  ws.send(JSON.stringify({ type: 'state', lizardId, state: 'ready' }));
947
1108
  break;
948
1109
  case 'revert_files': {
@@ -953,6 +1114,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
953
1114
  reverted: revertResult.reverted,
954
1115
  failed: revertResult.failed,
955
1116
  }));
1117
+ getPostHog().capture({
1118
+ distinctId: getDistinctId(),
1119
+ event: 'files reverted',
1120
+ properties: {
1121
+ agent_id: lizardId,
1122
+ reverted_count: revertResult.reverted.length,
1123
+ failed_count: revertResult.failed.length,
1124
+ },
1125
+ });
956
1126
  // Remove reverted file changes from agent state
957
1127
  const agentState = getState().agents[lizardId];
958
1128
  if (agentState?.fileChanges) {
@@ -8,7 +8,7 @@ export const GEKTO_OUTPUT_SCHEMA = JSON.stringify({
8
8
  properties: {
9
9
  action: {
10
10
  type: 'string',
11
- enum: ['create_plan', 'reply', 'clarify', 'remove_agents', 'update_plan', 'delegate', 'add_task'],
11
+ enum: ['create_plan', 'reply', 'clarify', 'remove_agents', 'update_plan', 'delegate', 'add_task', 'spawn_agent'],
12
12
  },
13
13
  message: { type: 'string' },
14
14
  title: { type: 'string' },
@@ -16,6 +16,7 @@ export const GEKTO_OUTPUT_SCHEMA = JSON.stringify({
16
16
  buildPrompt: { type: 'string' },
17
17
  target: { type: 'string' },
18
18
  agentId: { type: 'string' },
19
+ agentName: { type: 'string' },
19
20
  taskName: { type: 'string' },
20
21
  taskDescription: { type: 'string' },
21
22
  taskFiles: { type: 'array', items: { type: 'string' } },
@@ -40,6 +41,7 @@ How you act:
40
41
  - If the user wants to modify the active plan (marked [ACTIVE] in CURRENT STATE), use "update_plan" with the updated abstract. "update_plan" ALWAYS applies to the active plan. You MUST also include a short "message" for the chat confirming what changed.
41
42
  - If the user wants to remove agents, use "remove_agents" with a target.
42
43
  - If there is an existing agent that has context about the relevant files or task, use "delegate" with "agentId" (the agent's ID from CURRENT STATE) and "message" (a clear instruction). The agent already has session context — delegating is faster than creating a new plan. Always include a short "message" for the chat confirming what you delegated.
44
+ - If the user wants a single piece of work handed off and no existing agent fits (e.g. they invoked "/delegate" and none of the active agents match), use "spawn_agent" — it creates one new worker without a multi-step plan. Provide: "agentName" (2-3 word descriptive name, e.g. "AuthRefactor" or "ApiDocs"), "taskDescription" (1 sentence describing what the worker will do), and "message" (the full self-contained, actionable prompt that will be sent to the new worker). Use "spawn_agent" instead of "create_plan" when the work is a single agent's job, not a multi-step plan.
43
45
  - ALWAYS research the codebase first (Read, Glob, Grep) before creating plans. Understand the project structure, frameworks, and conventions.
44
46
 
45
47
  Conflict awareness:
@@ -219,6 +219,20 @@ If the user wants to change this plan, respond with "update_plan" and the FULL u
219
219
  delegateMessage: parsed.message,
220
220
  message: parsed.message,
221
221
  };
222
+ case 'spawn_agent': {
223
+ const agentName = (parsed.agentName || parsed.taskName || 'Worker').trim();
224
+ const taskDescription = (parsed.taskDescription || parsed.message || 'Handle delegated task').trim();
225
+ const workerPrompt = (parsed.message || prompt).trim();
226
+ return {
227
+ type: 'spawn_agent',
228
+ spawn: {
229
+ agentName,
230
+ taskDescription,
231
+ prompt: workerPrompt,
232
+ },
233
+ message: `Delegated to ${agentName}.`,
234
+ };
235
+ }
222
236
  default:
223
237
  return { type: 'chat', message: parsed.message || "I'm not sure how to help with that." };
224
238
  }
@@ -0,0 +1,14 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { CLAUDE_PATH } from './claudePath.js';
3
+ export function detectClaude() {
4
+ return new Promise(resolve => {
5
+ const opts = { timeout: 3000, shell: process.platform === 'win32' };
6
+ execFile(CLAUDE_PATH, ['--version'], opts, (err, stdout) => {
7
+ if (err)
8
+ return resolve({ available: false });
9
+ const out = stdout.trim();
10
+ const version = (out.match(/\d+\.\d+\.\d+[^\s)]*/) || [out])[0];
11
+ resolve({ available: true, version, path: CLAUDE_PATH });
12
+ });
13
+ });
14
+ }
@@ -0,0 +1,185 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import { CLAUDE_PATH } from './claudePath.js';
5
+ export const DEV_TEST_APP_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../test-app');
6
+ const INSPECT_PROMPT = `You are inspecting a software project to auto-configure a dev tool called Gekto.
7
+
8
+ Use Glob, Grep, and Read (read-only) to look at the project's manifest and config files. Likely candidates:
9
+ - package.json, pyproject.toml, requirements.txt, Cargo.toml, go.mod, Gemfile, composer.json, pom.xml, build.gradle(.kts), mix.exs, *.csproj
10
+ - vite.config.*, next.config.*, astro.config.*, nuxt.config.*, angular.json, webpack.config.*, rollup.config.*
11
+ - .env, .env.local, .env.development
12
+ - Dockerfile, docker-compose.yml, Procfile
13
+
14
+ Return ONLY a single JSON object — no prose, no markdown fences — with exactly this shape:
15
+
16
+ {
17
+ "language": "<primary language, lowercase: typescript, javascript, python, go, ruby, rust, java, kotlin, csharp, php, elixir, swift, etc.>",
18
+ "framework": "<main UI / meta-framework / app framework, lowercase — see rules below>",
19
+ "bundler": "<build tool, lowercase: vite, webpack, turbopack, esbuild, rollup, parcel, rspack — or null>",
20
+ "runtime": "<runtime, e.g. node, bun, deno, python, ruby, go, jvm, dotnet, php — or null>",
21
+ "packageManager": "<e.g. npm, pnpm, yarn, bun, pip, poetry, uv, cargo, gem, composer, maven, gradle — or null>",
22
+ "hasWebUI": true | false,
23
+ "port": <integer dev-server port, or null>,
24
+ "devCommand": "<shell command to start the dev server, e.g. 'npm run dev', 'pnpm dev', 'bun dev', 'python manage.py runserver', 'flask run', 'uvicorn app:app --reload', 'rails server' — or null if uncertain>"
25
+ }
26
+
27
+ Framework field rules (read carefully — this is the most common mistake):
28
+ - "framework" is the **UI library or app framework**, NOT the bundler. Vite, Webpack, Turbopack, esbuild, Rollup, Parcel, Rspack are **bundlers** — they go in the "bundler" field, never in "framework".
29
+ - For frontend projects, prefer the UI library: "react", "vue", "svelte", "solid", "preact", "lit". Detect this from package.json dependencies (e.g. presence of "react", "vue", "svelte", "solid-js").
30
+ - If the project uses a meta-framework (which implies its own UI library), use the meta-framework name instead: "next", "nuxt", "sveltekit", "remix", "astro", "gatsby", "qwik", "angular".
31
+ - For backend projects, use the server framework: "express", "nestjs", "fastify", "koa", "hono", "django", "flask", "fastapi", "rails", "sinatra", "laravel", "symfony", "spring-boot", "phoenix", "actix", "axum", "gin", "echo".
32
+ - If nothing identifiable, set framework to null.
33
+
34
+ Other rules:
35
+ - "hasWebUI" is true if the project serves a web frontend that runs on a dev server. Backend-only servers, CLI tools, mobile apps, and libraries: false.
36
+ - "port" must be an integer when hasWebUI is true. Use the explicit port from config if found; otherwise the framework/bundler default (Vite 5173, Next 3000, CRA 3000, Astro 4321, Nuxt 3000, Angular 4200, SvelteKit 5173, Webpack-dev-server 8080).
37
+ - "port" must be null when hasWebUI is false.
38
+ - "devCommand" should be the canonical way to start the dev server based on the package manager (check the lockfile) and the framework. For JS projects: read package.json "scripts" — prefer "dev", then "start", then "serve". Use the right package manager (pnpm/yarn/bun/npm) based on the lockfile. Set null if you can't determine it.
39
+ - Output JSON only, nothing else.`;
40
+ function summarizeTool(name, input) {
41
+ if (!input)
42
+ return name;
43
+ switch (name) {
44
+ case 'Read':
45
+ return `Reading ${String(input.file_path ?? '').split('/').pop() || input.file_path}`;
46
+ case 'Glob':
47
+ return `Searching for ${input.pattern}`;
48
+ case 'Grep':
49
+ return `Grep "${input.pattern}"${input.path ? ` in ${input.path}` : ''}`;
50
+ default:
51
+ return name;
52
+ }
53
+ }
54
+ function parseStack(text) {
55
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
56
+ if (!jsonMatch)
57
+ return null;
58
+ try {
59
+ const parsed = JSON.parse(jsonMatch[0]);
60
+ if (!parsed.language || typeof parsed.hasWebUI !== 'boolean')
61
+ return null;
62
+ const port = typeof parsed.port === 'number' ? parsed.port : undefined;
63
+ return {
64
+ language: String(parsed.language).toLowerCase(),
65
+ framework: parsed.framework ? String(parsed.framework).toLowerCase() : undefined,
66
+ bundler: parsed.bundler ? String(parsed.bundler).toLowerCase() : undefined,
67
+ runtime: parsed.runtime ? String(parsed.runtime).toLowerCase() : undefined,
68
+ packageManager: parsed.packageManager ? String(parsed.packageManager).toLowerCase() : undefined,
69
+ hasWebUI: parsed.hasWebUI,
70
+ port: parsed.hasWebUI ? port : undefined,
71
+ devCommand: typeof parsed.devCommand === 'string' && parsed.devCommand.trim() ? parsed.devCommand.trim() : undefined,
72
+ };
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ export async function inspectRepo(opts = {}) {
79
+ const prompt = opts.correction
80
+ ? `${INSPECT_PROMPT}\n\nIMPORTANT: A previous inspection was incorrect. The user said:\n"${opts.correction}"\n\nRe-inspect the project, taking this correction into account. Verify against the actual files before answering.`
81
+ : INSPECT_PROMPT;
82
+ if (opts.dry) {
83
+ console.log('--- inspectRepo prompt ---');
84
+ console.log(prompt);
85
+ return null;
86
+ }
87
+ const args = [
88
+ '-p', prompt,
89
+ '--output-format', 'stream-json',
90
+ '--verbose',
91
+ '--model', 'claude-opus-4-6',
92
+ '--dangerously-skip-permissions',
93
+ '--allowed-tools', 'Read,Glob,Grep',
94
+ ];
95
+ return new Promise(resolve => {
96
+ const proc = spawn(CLAUDE_PATH, args, {
97
+ cwd: opts.cwd || process.cwd(),
98
+ env: process.env,
99
+ stdio: ['pipe', 'pipe', 'pipe'],
100
+ });
101
+ proc.stdin?.end();
102
+ const timeout = setTimeout(() => {
103
+ proc.kill('SIGTERM');
104
+ }, opts.timeoutMs ?? 60_000);
105
+ let buffer = '';
106
+ let resultText = '';
107
+ const handleLine = (line) => {
108
+ if (!line.trim())
109
+ return;
110
+ try {
111
+ const event = JSON.parse(line);
112
+ if (event.type === 'assistant' && event.message?.content) {
113
+ for (const block of event.message.content) {
114
+ if (block.type === 'tool_use' && block.name) {
115
+ opts.onEvent?.({
116
+ type: 'tool',
117
+ name: block.name,
118
+ summary: summarizeTool(block.name, block.input),
119
+ });
120
+ }
121
+ else if (block.type === 'text' && block.text) {
122
+ opts.onEvent?.({ type: 'text', text: block.text });
123
+ }
124
+ else if (block.type === 'thinking' && block.thinking) {
125
+ opts.onEvent?.({ type: 'thinking', text: block.thinking });
126
+ }
127
+ }
128
+ }
129
+ else if (event.type === 'result' && typeof event.result === 'string') {
130
+ resultText = event.result;
131
+ }
132
+ }
133
+ catch {
134
+ // ignore parse errors on partial / non-JSON lines
135
+ }
136
+ };
137
+ proc.stdout.on('data', chunk => {
138
+ buffer += chunk.toString();
139
+ const lines = buffer.split('\n');
140
+ buffer = lines.pop() || '';
141
+ for (const line of lines)
142
+ handleLine(line);
143
+ });
144
+ proc.stderr.on('data', () => { });
145
+ proc.on('close', () => {
146
+ clearTimeout(timeout);
147
+ if (buffer.trim())
148
+ handleLine(buffer);
149
+ resolve(parseStack(resultText));
150
+ });
151
+ proc.on('error', () => {
152
+ clearTimeout(timeout);
153
+ resolve(null);
154
+ });
155
+ });
156
+ }
157
+ export function describeStack(stack) {
158
+ const parts = [
159
+ stack.framework,
160
+ stack.language,
161
+ stack.bundler && stack.bundler !== stack.framework ? `bundler:${stack.bundler}` : null,
162
+ stack.runtime,
163
+ stack.packageManager,
164
+ stack.hasWebUI ? `port:${stack.port ?? '?'}` : 'no-ui',
165
+ ].filter(Boolean);
166
+ return parts.join(' · ');
167
+ }
168
+ // CLI entry — `bun run src/inspectRepo.ts [--dry] [--dev]`
169
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
170
+ const dry = process.argv.includes('--dry');
171
+ const dev = process.argv.includes('--dev');
172
+ const cwd = dev ? DEV_TEST_APP_DIR : process.cwd();
173
+ if (dev)
174
+ console.log(`[inspectRepo] dev mode — inspecting ${cwd}`);
175
+ const result = await inspectRepo({
176
+ dry,
177
+ cwd,
178
+ onEvent: event => {
179
+ if (event.type === 'tool')
180
+ console.log(` · ${event.summary}`);
181
+ },
182
+ });
183
+ console.log('\nInspection:', result);
184
+ process.exit(0);
185
+ }