@kognai/orchestrator-core 0.1.1 → 0.1.3

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.
@@ -0,0 +1,890 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CodingAgent = void 0;
37
+ /**
38
+ * engine-coding-agent.ts — CodingAgent extracted from orchestrate-engine.ts
39
+ * (TICKET-231 engine split). Imports shared primitives (log/callLLM/etc.) + the
40
+ * AgentTask/ReviewResult types back from the engine via a runtime-safe circular import.
41
+ */
42
+ const fs_1 = require("fs");
43
+ const child_process_1 = require("child_process");
44
+ const failure_library_1 = require("./failure-library");
45
+ const orchestrate_engine_1 = require("./orchestrate-engine");
46
+ class CodingAgent {
47
+ name;
48
+ systemPrompt;
49
+ constructor(name, systemPrompt) { this.name = name; this.systemPrompt = systemPrompt; }
50
+ async execute(task, previousReview) {
51
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.cyan, `\n[${this.name}] Executing: ${task.id} (${task.priority})`);
52
+ const deliverables = [...(task.deliverables.code || []), ...(task.deliverables.tests || []), ...(task.deliverables.docs || [])];
53
+ // Complexity-aware model routing:
54
+ // Claude Sonnet → complex tasks (many files, complex keywords, large existing files)
55
+ // MiniMax M2.5 → simple tasks (small edits, config, stubs) + truncation retry as safety net
56
+ let { provider, model, routingReason } = await (0, orchestrate_engine_1.assessTaskComplexity)(task, deliverables);
57
+ // 2026-05-28 model-escalation pact (consumes flag set in Orchestrator.executeTask
58
+ // after a TRUNCATION or INTEGRITY_FAILED rejection). When the cheap default
59
+ // (DeepSeek) couldn't hold the file's contract on a prior attempt, upgrade
60
+ // THIS attempt to Sonnet via ClawRouter — deterministic, no LLM round-trip,
61
+ // routed through the existing x402 wallet rail. Cleared after consumption so
62
+ // a subsequent un-escalated reason doesn't piggyback off the upgrade.
63
+ const escalation = task._escalateNext;
64
+ if (escalation) {
65
+ provider = 'clawrouter';
66
+ model = 'anthropic/claude-sonnet-4.6';
67
+ routingReason = `ESCALATE: prior attempt ${escalation} → ClawRouter/Sonnet (was: ${routingReason})`;
68
+ delete task._escalateNext;
69
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.magenta, ` ⤴ [ESCALATE] ${task.id}: prior ${escalation} → upgrading to ${model}`);
70
+ }
71
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` -> Using ${model} [${routingReason}]`);
72
+ // B.12: Compress context before cloud calls to reduce token spend 70-80%
73
+ if (provider === 'clawrouter' || provider === 'anthropic') {
74
+ task = { ...task, context: await (0, orchestrate_engine_1.compressContext)(task.context) };
75
+ }
76
+ // Sprint-063: Emit JSONL routing log (non-fatal — never block execution)
77
+ try {
78
+ (0, fs_1.mkdirSync)('logs/routing', { recursive: true });
79
+ const { generateExecutionId, logRoutingDecision } = await Promise.resolve().then(() => __importStar(require('./task-router')));
80
+ const sprintId = task.sprint_id ?? 'unknown';
81
+ const execId = task.execution_id ?? generateExecutionId(sprintId, task.id);
82
+ logRoutingDecision({
83
+ execution_id: execId,
84
+ sprint_id: sprintId,
85
+ task_id: task.id,
86
+ task_target: (task.task_target ?? 'cloud-code'),
87
+ provider,
88
+ model,
89
+ queued_at: task.queued_at ?? new Date().toISOString(),
90
+ execution_source: 'orchestrate-agents-v2',
91
+ });
92
+ }
93
+ catch (err) {
94
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` [WARN] Routing log write failed: ${err.message}`);
95
+ }
96
+ // Pre-flight: only enforce pre-existence for tasks that genuinely modify
97
+ // existing files in place. Everything else (create / research / feature /
98
+ // docs / content / audit / setup / etc.) is allowed to produce new files.
99
+ // Inverted from the prior opt-out list because new task types kept being
100
+ // added that legitimately create files (research, content_creation, audit,
101
+ // implementation, setup) and tripped pre-flight by default — sprint-1548
102
+ // amd24_research being the recent example.
103
+ const MODIFY_TYPES = new Set(['modify', 'bugfix', 'fix', 'edit', 'refactor', 'enhancement']);
104
+ if (MODIFY_TYPES.has(task.type)) {
105
+ const missing = deliverables.filter(f => !(0, fs_1.existsSync)(f));
106
+ if (missing.length > 0) {
107
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Pre-flight FAILED: File(s) not found: ${missing.join(', ')}`);
108
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Skipping task ${task.id} — deliverable files do not exist in repo`);
109
+ throw new Error(`PREFLIGHT_FAILED: Files not found: ${missing.join(', ')}`);
110
+ }
111
+ }
112
+ // Pre-flight: validate new-file paths are inside real project directories
113
+ // CEO sometimes hallucinates paths like 'agents/src/core/' or 'packages/agents/src/'
114
+ // which don't exist. Catch these before generating anything.
115
+ const VALID_PATH_PREFIXES = [
116
+ 'backend/', 'frontend/', 'agents/', 'scripts/', 'shared/',
117
+ 'website/', 'docs-site/', 'apps/', 'sdk/', 'x402-base/', 'x402-evm/', 'x402-test/',
118
+ 'supabase/', 'infrastructure/',
119
+ // Kognai v16 directories (S68)
120
+ 'acp/', 'codebook/', 'failure-library/', 'skills/', 'skill-bank/',
121
+ // Kognai runtime paths (S66-002)
122
+ 'runtime/', 'dashboard/', 'kognai-agents/', 'workspace/', 'docs/', 'logs/', 'tests/',
123
+ // Public surfaces + npm packages (sprint-1548, sprint-1549)
124
+ 'landing/', 'packages/', 'data/',
125
+ // Smart contracts (sprint-1571 — KognaiSkin ERC-721 + EIP-5192 soulbound)
126
+ 'contracts/',
127
+ ];
128
+ // Invalid patterns: paths that look like monorepo sub-dirs that don't exist
129
+ const INVALID_PATH_PATTERNS = [
130
+ /^agents\/src\//, // agents/src/... — real agent dirs are agents/<name>/
131
+ /^src\/agents\//, // no src/agents/ dir
132
+ ];
133
+ for (const filepath of deliverables) {
134
+ // Root-level dotfiles, config files, and absolute paths are always valid.
135
+ // Absolute paths (starting with /) indicate cross-project tasks (e.g., Voxight).
136
+ const isRootFile = !filepath.includes('/') || filepath.startsWith('.') || filepath.startsWith('/');
137
+ const isValidPrefix = isRootFile || VALID_PATH_PREFIXES.some(p => filepath.startsWith(p));
138
+ const isInvalidPattern = INVALID_PATH_PATTERNS.some(r => r.test(filepath));
139
+ if (!isValidPrefix || isInvalidPattern) {
140
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Path validation FAILED: "${filepath}" is not in a valid project directory`);
141
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Valid prefixes: ${VALID_PATH_PREFIXES.join(', ')}`);
142
+ throw new Error(`INVALID_PATH: "${filepath}" is not in a recognized project directory`);
143
+ }
144
+ }
145
+ let rejectionContext = '';
146
+ if (previousReview && previousReview.verdict !== 'APPROVED') {
147
+ const issueList = (previousReview.issues || []).map(i => `- [${i.severity}] ${i.file}: ${i.description}`).join('\n');
148
+ rejectionContext = `\n## IMPORTANT: Previous Attempt Was REJECTED\nScore: ${previousReview.score}/100. Reason: ${previousReview.summary}\n\nSpecific issues to fix:\n${issueList}\n\nYou MUST address ALL issues.\n`;
149
+ }
150
+ // TICKET-152 Gap 2: cross-run failure memory. `previousReview` only remembers
151
+ // THIS run's attempts; the failure-library remembers every prior rejection of
152
+ // this task across all sprints (e.g. ksl_batch_runner's 75 truncation rejects).
153
+ // Inject the persistent avoidance brief so a task that has failed before sees
154
+ // its own history — even on attempt 1 of a fresh run. Bounded (≤5 attempts,
155
+ // truncated reasons) and best-effort: retrieval must never block execution.
156
+ try {
157
+ const prior = (0, failure_library_1.retrieveTaskFailures)(task.id);
158
+ if (prior.brief)
159
+ rejectionContext += `\n${prior.brief}\n`;
160
+ }
161
+ catch { /* non-fatal — never block execution on retrieval */ }
162
+ const createdFiles = [];
163
+ for (let i = 0; i < deliverables.length; i++) {
164
+ const filepath = deliverables[i];
165
+ // TICKET-090: EDIT-MODE — for surgical-edit tasks on existing files, ask
166
+ // the LLM for a list of {old, new} substitutions instead of regenerating
167
+ // the whole file. Drops output tokens ~10× (4kB file rewrite → ~200B
168
+ // diff) and slashes wall-clock under the 25-min PER_RUN_HARD_TIMEOUT.
169
+ //
170
+ // TICKET-209 (2026-05-29): broadened engagement. Previously gated behind
171
+ // ~9 narrow-scope context keywords AND file ≥50 lines, so founder-authored
172
+ // modify tasks (TICKET-204, TICKET-207) that didn't use the exact magic
173
+ // phrases fell into regenerate-mode and blew the 100k token budget
174
+ // (~313k tokens spent on a 5-edit task to a 700-line file).
175
+ //
176
+ // Now engages when:
177
+ // (a) file exists AND is at least 50 lines (unchanged)
178
+ // (b) EITHER task.type is in MODIFY_TYPES (new default for all modify tasks)
179
+ // OR task context contains the legacy narrow-scope keywords
180
+ // ("ONE LINE EDIT", "SINGLE FIELD", "MINIMAL EDIT", "rename only",
181
+ // "single property", "verify-only", "literal-string",
182
+ // "DO NOT regenerate", "surgical", "no-op verify", "MUST still start")
183
+ // Falls back to regenerate-mode if the LLM's edit response is malformed
184
+ // or any `old` substring isn't uniquely present in the file.
185
+ const existingLineCount = (0, fs_1.existsSync)(filepath)
186
+ ? (0, fs_1.readFileSync)(filepath, 'utf-8').split('\n').length
187
+ : 0;
188
+ const hasNarrowScopeKeywords = /\b(ONE LINE EDIT|SINGLE FIELD|MINIMAL EDIT|rename only|single property|verify-only|literal[- ]string|DO NOT regenerate|surgical|no-op verify|MUST still start)\b/i.test(task.context || '');
189
+ const isModifyTask = MODIFY_TYPES.has(task.type);
190
+ const editModeEligible = (0, fs_1.existsSync)(filepath)
191
+ && existingLineCount >= 50
192
+ && (isModifyTask || hasNarrowScopeKeywords);
193
+ if (editModeEligible) {
194
+ const edited = await this.tryEditMode(filepath, task, rejectionContext, provider, model);
195
+ if (edited !== null) {
196
+ createdFiles.push({ path: filepath, content: edited });
197
+ continue; // success, skip regenerate-mode for this file
198
+ }
199
+ // 2026-05-27 diagnostic patch: for MODIFY tasks on large files, refuse
200
+ // the regenerate-mode fallback. Regeneration of a 200+ line file from
201
+ // scratch trips the integrity-check (which preserves the original on
202
+ // disk) — net result is a silent no-op. Better to surface the failure
203
+ // with a structured reason than to log "No files produced" with no
204
+ // context. Founder triage: split the file, not the task.
205
+ const existingLines = (0, fs_1.existsSync)(filepath) ? (0, fs_1.readFileSync)(filepath, 'utf-8').split('\n').length : 0;
206
+ if (MODIFY_TYPES.has(task.type) && existingLines > 150) {
207
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Edit-mode FAILED and regenerate-mode REFUSED for ${filepath} (${existingLines} lines, MODIFY task — split file, not task)`);
208
+ task._failureReasons = [
209
+ ...(task._failureReasons || []),
210
+ `edit-mode-empty:${filepath}:${existingLines}lines`,
211
+ ];
212
+ continue; // skip this deliverable — surfaces via silent-failure enrichment upstream
213
+ }
214
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Edit-mode fell back to regenerate-mode for ${filepath}`);
215
+ }
216
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` -> Generating file ${i + 1}/${deliverables.length}: ${filepath}`);
217
+ const priorCtx = createdFiles.length > 0
218
+ ? '\n## Already Generated Files\n' + createdFiles.map(f => `### ${f.path}\n\`\`\`typescript\n${f.content.substring(0, 2000)}\n\`\`\``).join('\n\n') + '\n'
219
+ : '';
220
+ const fileList = deliverables.map((f, idx) => `${idx + 1}. ${f}${f === filepath ? ' ← THIS ONE' : ''}`).join('\n');
221
+ const isTestFile = filepath.includes('test') || filepath.includes('spec');
222
+ const existingLines = (0, fs_1.existsSync)(filepath) ? (0, fs_1.readFileSync)(filepath, 'utf-8').split('\n').length : 0;
223
+ const existingContent = (0, fs_1.existsSync)(filepath)
224
+ ? `\n\n## EXISTING FILE — SURGICAL EDIT ONLY\nDo NOT rewrite the entire file. Output the COMPLETE updated file with your changes merged in.\nIf you add a function, append it. If you edit a line, change only that line.\nFile has ${existingLines} lines — preserve ALL existing code.\n\n### Current Content\n\`\`\`typescript\n${(0, fs_1.readFileSync)(filepath, 'utf-8').substring(0, 3000)}\n\`\`\`\n`
225
+ : `\n\n## Note: This is a NEW file — create it from scratch.\n`;
226
+ const testConstraint = isTestFile
227
+ ? `\n\n## CRITICAL: TEST FILE SIZE LIMIT
228
+ This is a test file. You MUST keep it SHORT to avoid truncation:
229
+ - Maximum 5-6 test cases (describe + it blocks)
230
+ - Maximum 80 lines total
231
+ - NO verbose setup — use inline mocks
232
+ - NO redundant tests — one test per behavior
233
+ - Cover: happy path, error case, edge case, defaults — that's it
234
+ - If you write more than 80 lines, the file WILL be truncated and REJECTED\n`
235
+ : '';
236
+ // EXACT CONTENT mode: task description contains code block(s) with the exact file content.
237
+ // Extract them deterministically and bypass LLM to prevent model hallucination.
238
+ // This is the correct fix for "EXACT CONTENT:" tasks — the model must NOT interpret
239
+ // the spec, it must copy it verbatim. Bypass the LLM entirely for these tasks.
240
+ // NOTE: check task.description first — when sprint JSON has BOTH description AND context fields,
241
+ // the normalization at loadTasks() only copies description→context when context is absent.
242
+ // EXACT CONTENT blocks always live in the description field.
243
+ const rawSpec = task.description ?? task.context;
244
+ const exactBlocks = [...rawSpec.matchAll(/EXACT CONTENT:\s*\n\n?```[\w.+-]*\n([\s\S]*?)```(?:\n|$)/g)]
245
+ .map((m) => m[1].trimEnd());
246
+ if (exactBlocks.length > 0) {
247
+ // Use block[i] for deliverable[i] when multiple blocks present; else use block[0]
248
+ const exactFileContent = exactBlocks.length > i ? exactBlocks[i] : exactBlocks[0];
249
+ const blockLabel = `block ${Math.min(i, exactBlocks.length - 1) + 1}/${exactBlocks.length}`;
250
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.cyan, ` -> EXACT CONTENT mode: ${filepath} (${blockLabel}) — deterministic, no LLM`);
251
+ createdFiles.push({ path: filepath, content: exactFileContent });
252
+ continue;
253
+ }
254
+ const userPrompt = `You are ${this.name}, a coding agent at Countable.
255
+ ${rejectionContext}
256
+ ## Task
257
+ ${task.context}
258
+
259
+ ## All Deliverable Files
260
+ ${fileList}
261
+
262
+ ## Generate ONLY: ${filepath}
263
+ ${existingContent}${priorCtx}${testConstraint}
264
+ Write ONLY the content for "${filepath}". Rules:
265
+ - S64-001: Output the raw file content using FILE: format as described in the system prompt
266
+ - Do NOT wrap output in markdown code fences (\`\`\`) — for .md files especially, output RAW markdown text, NOT inside a \`\`\`markdown or \`\`\`typescript block
267
+ - For .sh/.bash scripts, start with #!/bin/bash — do NOT wrap in a code fence
268
+ - Production quality, no TODOs or placeholders
269
+ - Include all imports, types, error handling
270
+ - If this file depends on others listed above, import from them correctly
271
+ - No explanatory text — output file content only`;
272
+ try {
273
+ const startTime = Date.now();
274
+ const response = await (0, orchestrate_engine_1.callLLM)(provider, model, this.systemPrompt, userPrompt, 480000, this.name, task.id); // 8 min — qwen3:14b needs time for large files
275
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
276
+ let content = response.choices?.[0]?.message?.content || '';
277
+ // Strip MiniMax <think>...</think> tags that leak into responses
278
+ content = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
279
+ const tokens = response.usage?.total_tokens || 0;
280
+ // Check for MiniMax errors
281
+ if (response.base_resp?.status_code && response.base_resp.status_code !== 0) {
282
+ throw new Error(`MiniMax API error: ${response.base_resp.status_msg}`);
283
+ }
284
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` -> Response: ${elapsed}s, ${tokens} tokens, ${content.length} chars`);
285
+ // CTO-005: Extract code from fenced block with enhanced fence stripping
286
+ const codeBlocks = this.extractCodeBlocks(content);
287
+ let fileContent;
288
+ if (codeBlocks.length === 0) {
289
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! No code block found for ${filepath}, using raw content (with fence strip)`);
290
+ fileContent = this.stripResidualFences(content);
291
+ }
292
+ else {
293
+ fileContent = codeBlocks[0];
294
+ }
295
+ // CTO-005: Final fence sanitization BEFORE adding to createdFiles
296
+ // CEO condition: stripping must happen BEFORE file is written to disk
297
+ fileContent = this.stripResidualFences(fileContent);
298
+ // Last-resort nuclear strip: if content still starts with a fence, skip all leading
299
+ // fence lines and trailing fence. Handles MiniMax ```typescript{ (no newline) pattern.
300
+ if (/^\s*```/.test(fileContent)) {
301
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Residual fence detected after stripResidualFences — applying nuclear strip for ${filepath}`);
302
+ const lines = fileContent.split('\n');
303
+ const firstContentLine = lines.findIndex(l => !l.trim().startsWith('```') && l.trim() !== '');
304
+ if (firstContentLine > 0) {
305
+ fileContent = lines.slice(firstContentLine).join('\n').replace(/\n\s*```\s*$/, '').trim();
306
+ }
307
+ else if (firstContentLine === -1) {
308
+ fileContent = lines.filter(l => !l.trim().startsWith('```')).join('\n').trim();
309
+ }
310
+ }
311
+ // File-type-aware post-processing: final safety net per file extension
312
+ fileContent = this.postProcessContent(fileContent, filepath);
313
+ // B.13: For JSON files that are still invalid after postProcessContent, try qwen3:0.6b repair
314
+ if (filepath.endsWith('.json')) {
315
+ try {
316
+ JSON.parse(fileContent);
317
+ }
318
+ catch {
319
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! JSON invalid in ${filepath} — attempting qwen3:0.6b repair`);
320
+ fileContent = await this.fixJsonWithOllama(fileContent, filepath);
321
+ }
322
+ }
323
+ // TRUNCATION PRE-CHECK: Detect if MiniMax cut off output mid-function
324
+ // If code ends inside an open block (unclosed braces) or with an incomplete statement,
325
+ // retry once with a "continue" prompt before sending to supervisor review.
326
+ const truncationDetected = this.detectTruncation(fileContent);
327
+ if (truncationDetected && (provider === 'clawrouter' || provider === 'ollama')) {
328
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! TRUNCATION detected in ${filepath} — retrying with continuation prompt...`);
329
+ const continuationPrompt = `The previous response for "${filepath}" was TRUNCATED — it ended mid-function or with an incomplete block. Here is what was generated so far:
330
+
331
+ \`\`\`typescript
332
+ ${fileContent.substring(fileContent.length - 1500)}
333
+ \`\`\`
334
+
335
+ Continue from where it left off and output ONLY the remaining code (no duplicated content). Output a COMPLETE, valid TypeScript/JavaScript file ending with the final closing brace.`;
336
+ try {
337
+ const contResponse = await (0, orchestrate_engine_1.callLLM)(provider, model, this.systemPrompt, continuationPrompt, 120000, this.name, `${task.id}_continuation`);
338
+ let contContent = contResponse.choices?.[0]?.message?.content || '';
339
+ contContent = contContent.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
340
+ const contBlocks = this.extractCodeBlocks(contContent);
341
+ const continuation = contBlocks.length > 0 ? contBlocks[0] : this.stripResidualFences(contContent);
342
+ if (continuation.length > 50) {
343
+ // Merge: use the original up to the last complete line, then append continuation
344
+ fileContent = fileContent + '\n' + continuation;
345
+ fileContent = this.stripResidualFences(fileContent);
346
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.green, ` ✓ Continuation merged for ${filepath} (+${continuation.length} chars)`);
347
+ }
348
+ }
349
+ catch (contErr) {
350
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Continuation failed: ${contErr.message}`);
351
+ }
352
+ }
353
+ createdFiles.push({ path: filepath, content: fileContent });
354
+ }
355
+ catch (error) {
356
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Failed to generate ${filepath}: ${error.message}`);
357
+ // Create minimal placeholder so build doesn't break
358
+ createdFiles.push({ path: filepath, content: `// ERROR: Generation failed - ${error.message}\n// Task: ${task.id}\n` });
359
+ }
360
+ }
361
+ // CTO-004: File integrity check — detect destructive MiniMax rewrites
362
+ // For bugfix tasks (and feature tasks editing existing files), reject if new file
363
+ // is <50% the size of the original. Configurable threshold.
364
+ const INTEGRITY_THRESHOLD = 0.5; // Reject if new < 50% of original
365
+ const integrityCheckTypes = ['bugfix']; // Task types that always get integrity check
366
+ const integrityCheckAllExisting = true; // Also check feature tasks editing existing files
367
+ for (const file of createdFiles) {
368
+ if ((0, fs_1.existsSync)(file.path)) {
369
+ try {
370
+ const originalContent = (0, fs_1.readFileSync)(file.path, 'utf-8');
371
+ const originalLines = originalContent.split('\n').length;
372
+ const newLines = file.content.split('\n').length;
373
+ const ratio = originalLines > 0 ? newLines / originalLines : 1;
374
+ const shouldCheck = integrityCheckTypes.includes(task.type) ||
375
+ (integrityCheckAllExisting && originalLines > 10);
376
+ if (shouldCheck && ratio < INTEGRITY_THRESHOLD) {
377
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ INTEGRITY CHECK FAILED: ${file.path}`);
378
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` Original: ${originalLines} lines → New: ${newLines} lines (${(ratio * 100).toFixed(0)}%)`);
379
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` Possible destructive rewrite detected — file shrank from ${originalLines} to ${newLines} lines`);
380
+ // TICKET-091 FIX: ACTUALLY preserve the original. Prior version
381
+ // assigned a warning comment to file.content, which the writer
382
+ // then wrote to disk as a 4-line stub — destroying the original.
383
+ // Read on-disk original so the subsequent writeFileSync is a no-op.
384
+ try {
385
+ file.content = (0, fs_1.readFileSync)(file.path, 'utf-8');
386
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` Original file restored from disk (${originalLines} lines preserved)`);
387
+ }
388
+ catch (readErr) {
389
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` WARN: could not read original from disk: ${(readErr?.message || '').slice(0, 100)}`);
390
+ }
391
+ task._integrityFailed = true;
392
+ task._integrityDetails = `File ${file.path} shrank from ${originalLines} to ${newLines} lines (${(ratio * 100).toFixed(0)}%). Possible destructive rewrite. Original preserved on disk; task should be rejected and retried with edit-mode constraint.`;
393
+ }
394
+ else if (originalLines > 0) {
395
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` -> Integrity OK: ${file.path} (${originalLines} → ${newLines} lines, ${(ratio * 100).toFixed(0)}%)`);
396
+ }
397
+ }
398
+ catch { /* File exists but can't read — skip check */ }
399
+ }
400
+ }
401
+ // FP-007: File size guard — refuse writes to files >2000 lines
402
+ // Prevents swarm from destructively rewriting large files (telegram-bot.ts disaster)
403
+ const FP007_LINE_LIMIT = 2000;
404
+ for (const file of createdFiles) {
405
+ if ((0, fs_1.existsSync)(file.path)) {
406
+ try {
407
+ const existingLines = (0, fs_1.readFileSync)(file.path, 'utf-8').split('\n').length;
408
+ if (existingLines > FP007_LINE_LIMIT) {
409
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ FP-007 GUARD: ${file.path} has ${existingLines} lines (limit: ${FP007_LINE_LIMIT})`);
410
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` Refusing write — file too large for safe swarm edit. Use plumber edits or split.`);
411
+ // BUG-2 (TICKET-231): FP-007 refuses by making the write a NO-OP — preserve the
412
+ // original; NEVER write a stub over the file (the TICKET-091 pattern, previously
413
+ // only applied to the integrity check above). The task is still rejected via _fp007Blocked.
414
+ file.content = (0, fs_1.readFileSync)(file.path, 'utf-8');
415
+ task._fp007Blocked = true;
416
+ }
417
+ }
418
+ catch { /* can't read — allow write */ }
419
+ }
420
+ }
421
+ // Write all files to disk
422
+ const writtenFiles = [];
423
+ for (const file of createdFiles) {
424
+ try {
425
+ const dir = file.path.substring(0, file.path.lastIndexOf('/'));
426
+ if (dir)
427
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
428
+ (0, fs_1.writeFileSync)(file.path, file.content);
429
+ writtenFiles.push(file.path);
430
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.green, ` ✓ Written: ${file.path} (${file.content.length} chars)`);
431
+ }
432
+ catch (error) {
433
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ Write failed: ${file.path}: ${error.message}`);
434
+ }
435
+ }
436
+ // TICKET-205: stub-detection guard. The integrity check at line 2459 only
437
+ // catches destructive shrink of EXISTING files. For NEW files (type=create),
438
+ // a coder agent producing a near-empty stub passes through and gets committed
439
+ // BEFORE dual-review fires. Live incident 2026-05-29: docs/specs/
440
+ // orchestrator-workspace.md generated as 98-byte stub, dual-rejected 3× at
441
+ // 20/100, but commit 3ca603315 landed it on main anyway. This guard skips the
442
+ // commit when any written file is suspiciously small for its type — the file
443
+ // stays on disk so dual-review can inspect + reject it on its own merits.
444
+ const STUB_MIN_BYTES = {
445
+ '.md': 1500, // markdown specs typically ask for many sections
446
+ '.ts': 200,
447
+ '.tsx': 200,
448
+ '.js': 200,
449
+ '.jsx': 200,
450
+ '.yaml': 100,
451
+ '.yml': 100,
452
+ '.json': 100,
453
+ '.html': 100,
454
+ '.css': 100,
455
+ };
456
+ const DEFAULT_STUB_MIN = 200;
457
+ let stubPath = null;
458
+ let stubSize = 0;
459
+ let stubMin = 0;
460
+ for (const path of writtenFiles) {
461
+ const dotIdx = path.lastIndexOf('.');
462
+ const ext = dotIdx >= 0 ? path.slice(dotIdx) : '';
463
+ let min = STUB_MIN_BYTES[ext] ?? DEFAULT_STUB_MIN;
464
+ // .md only enforces the high threshold when the task context is substantial
465
+ // (a real spec ask); for short asks (e.g. README scaffolds), use 300.
466
+ if (ext === '.md' && (task.context?.length ?? 0) < 1500)
467
+ min = 300;
468
+ try {
469
+ const size = (0, fs_1.statSync)(path).size;
470
+ if (size < min) {
471
+ stubPath = path;
472
+ stubSize = size;
473
+ stubMin = min;
474
+ break;
475
+ }
476
+ }
477
+ catch { /* can't stat, skip */ }
478
+ }
479
+ if (stubPath) {
480
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.red, ` ✗ STUB DETECTED: ${stubPath} (${stubSize} bytes < ${stubMin} expected for ${task.type})`);
481
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` Skipping commit — file stays on disk for dual-review to reject. Task will retry.`);
482
+ task._stubDetected = true;
483
+ task._stubReason = `Generated file ${stubPath} is ${stubSize} bytes; expected ≥${stubMin} for type=${task.type}`;
484
+ return { files: writtenFiles, model };
485
+ }
486
+ // Commit changes
487
+ this.commitChanges(task, writtenFiles);
488
+ return { files: writtenFiles, model };
489
+ }
490
+ // TICKET-090: EDIT-MODE — ask the LLM for {old, new} substitutions on an
491
+ // existing file, instead of regenerating the whole file. Returns the post-
492
+ // edit file content on success, or null to fall back to regenerate-mode.
493
+ //
494
+ // Failure modes that trigger fallback (null):
495
+ // - LLM response isn't parseable JSON
496
+ // - "edits" key missing or empty
497
+ // - any old_str isn't found in the file (typo / hallucination)
498
+ // - any old_str appears more than once (ambiguous edit)
499
+ // - any edit's "old" is identical to its "new" (no-op disguised)
500
+ async tryEditMode(filepath, task, rejectionContext, provider, model) {
501
+ const originalContent = (0, fs_1.readFileSync)(filepath, 'utf-8');
502
+ const lineCount = originalContent.split('\n').length;
503
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.cyan, ` -> EDIT-MODE: ${filepath} (${lineCount} lines, surgical-edit task) — diff-only output`);
504
+ const userPrompt = `You are ${this.name}, a coding agent at Countable, applying a SURGICAL EDIT to an existing file.
505
+
506
+ ${rejectionContext}
507
+ ## Task
508
+ ${task.context}
509
+
510
+ ## File: ${filepath}
511
+ \`\`\`typescript
512
+ ${originalContent}
513
+ \`\`\`
514
+
515
+ ## Output format — JSON object only, no commentary, no fences
516
+
517
+ {
518
+ "edits": [
519
+ {"old": "<exact substring currently in the file>", "new": "<replacement>"}
520
+ ]
521
+ }
522
+
523
+ ## Rules — VIOLATIONS WILL CAUSE THE TASK TO BE REJECTED
524
+
525
+ 1. Each "old" string MUST appear EXACTLY ONCE in the file. If you need to edit a non-unique substring, include enough surrounding context (a few extra characters before and after) to make it unique.
526
+ 2. Each "old" string MUST match the file VERBATIM — same whitespace, same quote style, same indentation. Do not paraphrase.
527
+ 3. "new" must be different from "old". No-op edits are rejected.
528
+ 4. Prefer FEWER, LARGER edits. 1-3 edits is ideal. 10+ edits suggests you're rewriting — switch to a different approach.
529
+ 5. Output ONLY the JSON object. No \`\`\`json fences. No prose before or after.`;
530
+ let response;
531
+ try {
532
+ const startTime = Date.now();
533
+ response = await (0, orchestrate_engine_1.callLLM)(provider, model, this.systemPrompt, userPrompt, 120_000, this.name, task.id);
534
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
535
+ const tokens = response.usage?.total_tokens || 0;
536
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` edit response: ${elapsed}s, ${tokens} tokens`);
537
+ }
538
+ catch (e) {
539
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode LLM call failed: ${(e.message || '').slice(0, 120)}`);
540
+ return null;
541
+ }
542
+ let raw = (response.choices?.[0]?.message?.content || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
543
+ // Strip any fences the model added despite instructions (json/jsonc/none)
544
+ const fenced = raw.match(/```(?:json|jsonc)?\s*([\s\S]*?)```/);
545
+ if (fenced)
546
+ raw = fenced[1].trim();
547
+ // TICKET-092 FIX: walk forward from first `{` tracking brace depth through
548
+ // string literals (with escape-char handling), extract the FIRST balanced
549
+ // JSON object. Prior slice(firstBrace,lastBrace+1) caught multi-object
550
+ // responses + trailing prose as one mega-string and broke JSON.parse.
551
+ const firstBrace = raw.indexOf('{');
552
+ if (firstBrace < 0) {
553
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: no JSON object in response`);
554
+ return null;
555
+ }
556
+ let depth = 0, inStr = false, strChar = '', escape = false, end = -1;
557
+ for (let i = firstBrace; i < raw.length; i++) {
558
+ const ch = raw[i];
559
+ if (escape) {
560
+ escape = false;
561
+ continue;
562
+ }
563
+ if (inStr) {
564
+ if (ch === '\\')
565
+ escape = true;
566
+ else if (ch === strChar)
567
+ inStr = false;
568
+ continue;
569
+ }
570
+ if (ch === '"' || ch === "'") {
571
+ inStr = true;
572
+ strChar = ch;
573
+ continue;
574
+ }
575
+ if (ch === '{')
576
+ depth++;
577
+ else if (ch === '}') {
578
+ depth--;
579
+ if (depth === 0) {
580
+ end = i;
581
+ break;
582
+ }
583
+ }
584
+ }
585
+ if (end < 0) {
586
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: unterminated JSON object (no matching closing brace)`);
587
+ return null;
588
+ }
589
+ raw = raw.slice(firstBrace, end + 1);
590
+ let parsed;
591
+ try {
592
+ parsed = JSON.parse(raw);
593
+ }
594
+ catch (e) {
595
+ // Trailing-comma forgiveness retry (common LLM quirk).
596
+ try {
597
+ parsed = JSON.parse(raw.replace(/,(\s*[}\]])/g, '$1'));
598
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` edit-mode: trailing-comma repair applied`);
599
+ }
600
+ catch {
601
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: JSON parse failed: ${(e.message || '').slice(0, 80)}`);
602
+ return null;
603
+ }
604
+ }
605
+ const edits = parsed.edits;
606
+ if (!Array.isArray(edits) || edits.length === 0) {
607
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: no edits array or empty`);
608
+ return null;
609
+ }
610
+ let working = originalContent;
611
+ for (const e of edits) {
612
+ if (typeof e.old !== 'string' || typeof e.new !== 'string') {
613
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: edit missing string fields`);
614
+ return null;
615
+ }
616
+ if (e.old === e.new) {
617
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: no-op edit (old===new): ${e.old.slice(0, 60)}`);
618
+ return null;
619
+ }
620
+ // TICKET-100: per-edit suspicious-shrink guard. If `new` is dramatically
621
+ // shorter than `old`, the LLM likely matched too greedy a chunk (e.g.
622
+ // captured the trailing portion of the file in `old` and only included
623
+ // the first part in `new`, silently truncating). 43475fd56 broke prod
624
+ // exactly this way: edit removed the tail of generateReport function.
625
+ // Threshold: if old is >= 200 chars AND new < 30% of old length, reject.
626
+ if (e.old.length >= 200 && e.new.length < e.old.length * 0.3) {
627
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: suspicious shrink — old=${e.old.length} chars, new=${e.new.length} chars (< 30%). Rejecting to avoid truncation.`);
628
+ return null;
629
+ }
630
+ const occurrences = working.split(e.old).length - 1;
631
+ if (occurrences === 0) {
632
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: old_str not found in file: "${e.old.slice(0, 80)}"`);
633
+ return null;
634
+ }
635
+ if (occurrences > 1) {
636
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: old_str appears ${occurrences}× (must be unique): "${e.old.slice(0, 80)}"`);
637
+ return null;
638
+ }
639
+ working = working.replace(e.old, e.new);
640
+ }
641
+ // TICKET-100: post-edit integrity check. Same INTEGRITY_THRESHOLD pattern
642
+ // localQAGate uses for regen-mode results — if the post-edit content is
643
+ // dramatically shorter than the original (>30% loss), reject as suspicious
644
+ // truncation. Catches the case where individual edits each look reasonable
645
+ // but cumulatively destroy the file.
646
+ const INTEGRITY_THRESHOLD = 0.7; // must retain at least 70% of original lines
647
+ const newLineCount = working.split('\n').length;
648
+ const lineRatio = lineCount > 0 ? newLineCount / lineCount : 1;
649
+ if (lineRatio < INTEGRITY_THRESHOLD) {
650
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: post-edit integrity FAIL — ${lineCount} → ${newLineCount} lines (${(lineRatio * 100).toFixed(0)}%). Rejecting to avoid destructive write.`);
651
+ return null;
652
+ }
653
+ // TICKET-100: detect mid-statement truncation. If the last non-empty line
654
+ // doesn't end with a structural terminator (} ; > etc.), the file likely
655
+ // got cut mid-expression. Catches LLM output that stops mid-call.
656
+ const lastNonEmpty = working.split('\n').reverse().find(l => l.trim().length > 0) || '';
657
+ const last = lastNonEmpty.trim();
658
+ const terminatorOk = /[}\];>)\.]\s*$|^\/\/|^\/\*|^\*\//.test(last);
659
+ if (!terminatorOk && working.length > 200) {
660
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` edit-mode: last line "${last.slice(-60)}" doesn't end with a terminator — possible mid-statement truncation. Rejecting.`);
661
+ return null;
662
+ }
663
+ const deltaLines = newLineCount - lineCount;
664
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.green, ` ✓ edit-mode applied ${edits.length} edit(s) (${deltaLines >= 0 ? '+' : ''}${deltaLines} lines)`);
665
+ return working;
666
+ }
667
+ // CTO-005: Enhanced code fence stripping — handles all MiniMax output variants
668
+ // Catches: ```tsx, ```typescript, leading whitespace, fences at any position,
669
+ // markdown headers before code, and incomplete closing fences
670
+ extractCodeBlocks(content) {
671
+ const blocks = [];
672
+ // Normalize: MiniMax sometimes outputs ```typescript{ with no newline — insert one
673
+ const normalized = content.replace(/```([\w.+-]*)\s*([^\s\n`])/g, '```$1\n$2');
674
+ // Broader regex: optional whitespace before fences, any language tag, flexible spacing
675
+ const regex = /^\s*```[\w.+-]*\s*\n([\s\S]*?)^\s*```\s*$/gm;
676
+ let match;
677
+ while ((match = regex.exec(normalized)) !== null) {
678
+ if (match[1].trim().length > 0)
679
+ blocks.push(match[1].trim());
680
+ }
681
+ // Fallback: try simpler pattern if multiline didn't match
682
+ if (blocks.length === 0) {
683
+ // S64-001: Added python|py|toml|env|sql|xml|md|markdown — MiniMax/qwen3 often labels files incorrectly
684
+ const simpleRegex = /```(?:typescript|tsx|ts|javascript|jsx|js|json|yaml|yml|dockerfile|sh|bash|python|py|toml|env|sql|xml|md|markdown|css|html|scss|less|txt)?\s*\n([\s\S]*?)```/g;
685
+ while ((match = simpleRegex.exec(normalized)) !== null) {
686
+ if (match[1].trim().length > 0)
687
+ blocks.push(match[1].trim());
688
+ }
689
+ }
690
+ return blocks;
691
+ }
692
+ // CTO-005: Aggressive fence sanitization — strips ANY remaining fences from content
693
+ // Applied BEFORE file is written to disk (CEO condition)
694
+ stripResidualFences(content) {
695
+ let cleaned = content;
696
+ // Remove lines that are ONLY a fence marker (with optional language tag)
697
+ cleaned = cleaned.replace(/^\s*```[\w.+-]*\s*$/gm, '');
698
+ // TICKET-096: if the response contains a `FILE: <path>` marker line
699
+ // anywhere in the first 20 lines, slice from the line AFTER it. This
700
+ // catches the "prose explanation + FILE: <path> + real content" pattern
701
+ // that the v12r_citizen_mock_state agent produced (broke prod 2026-05-27
702
+ // 09:23 with line 1 = "The current file already has `state: 'idle'`...").
703
+ // The prior loop only recognized prose openers like "Here", "Below",
704
+ // "The following" — anything else broke through. FILE marker is the
705
+ // canonical separator per the agent system prompt; trust it.
706
+ const linesForFile = cleaned.split('\n');
707
+ let fileMarkerIdx = -1;
708
+ for (let i = 0; i < Math.min(linesForFile.length, 20); i++) {
709
+ if (/^\s*FILE:\s+\S+/.test(linesForFile[i])) {
710
+ fileMarkerIdx = i;
711
+ break;
712
+ }
713
+ }
714
+ if (fileMarkerIdx >= 0) {
715
+ cleaned = linesForFile.slice(fileMarkerIdx + 1).join('\n');
716
+ }
717
+ // Legacy: also strip simple prose prefixes from the FIRST 5 lines for
718
+ // responses that don't use the FILE marker convention.
719
+ const lines = cleaned.split('\n');
720
+ let firstCodeLine = 0;
721
+ for (let i = 0; i < Math.min(lines.length, 5); i++) {
722
+ const line = lines[i].trim();
723
+ if ((line.startsWith('#') && !line.startsWith('#!')) || line.startsWith('Here') || line.startsWith('Below') ||
724
+ line.startsWith('The following') || line.startsWith('FILE:') || line === '') {
725
+ firstCodeLine = i + 1;
726
+ }
727
+ else {
728
+ break;
729
+ }
730
+ }
731
+ if (firstCodeLine > 0) {
732
+ cleaned = lines.slice(firstCodeLine).join('\n');
733
+ }
734
+ // Remove trailing fence if present at end
735
+ cleaned = cleaned.replace(/\n\s*```\s*$/, '');
736
+ return cleaned.trim();
737
+ }
738
+ // B.13: T0 NANO JSON repair via ClawRouter v2.0 — called when postProcessContent still yields invalid JSON
739
+ async fixJsonWithOllama(content, _filepath) {
740
+ try {
741
+ const repairPrompt = `Fix this malformed JSON so it is syntactically valid. Return ONLY the corrected JSON, no explanation or markdown fences:\n\n${content.substring(0, 3000)}`;
742
+ const result = await (0, orchestrate_engine_1.routeCall)({
743
+ task_type: 'json_repair', tier_class: 'text', complexity: 'nano',
744
+ context_tokens: Math.ceil(repairPrompt.length / 4), constitutional_flag: false,
745
+ agent_id: 'json-repair',
746
+ payload: { prompt: repairPrompt, max_tokens: 2048 },
747
+ });
748
+ const fixed = result.content.trim();
749
+ try {
750
+ JSON.parse(fixed);
751
+ return fixed;
752
+ }
753
+ catch {
754
+ return content;
755
+ }
756
+ }
757
+ catch {
758
+ return content;
759
+ }
760
+ }
761
+ // File-type-aware post-processing: validates and cleans content per file extension.
762
+ // This is the FINAL safety net after all fence stripping has run.
763
+ postProcessContent(content, filepath) {
764
+ const filename = filepath.split('/').pop() || '';
765
+ const ext = filename.includes('.') ? filename.split('.').pop().toLowerCase() : '';
766
+ // .gitkeep must ALWAYS be completely empty — no exceptions
767
+ if (filename === '.gitkeep' || filepath.endsWith('.gitkeep')) {
768
+ return '';
769
+ }
770
+ // JSON files: ensure the content is valid JSON, strip any fence artifacts
771
+ if (ext === 'json') {
772
+ try {
773
+ JSON.parse(content);
774
+ return content; // already valid
775
+ }
776
+ catch { /* fall through to extraction */ }
777
+ // Try to extract a JSON object or array
778
+ const jsonMatch = content.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
779
+ if (jsonMatch) {
780
+ try {
781
+ JSON.parse(jsonMatch[1]);
782
+ return jsonMatch[1];
783
+ }
784
+ catch { /* fall through */ }
785
+ }
786
+ // Strip all fence lines and retry
787
+ const stripped = content.replace(/^\s*```[\w.+-]*\s*$/gm, '').trim();
788
+ try {
789
+ JSON.parse(stripped);
790
+ return stripped;
791
+ }
792
+ catch { /* fall through */ }
793
+ return stripped; // return best effort even if not valid JSON
794
+ }
795
+ // Code/script/markdown files: strip any remaining fence markers aggressively
796
+ if (['sh', 'bash', 'py', 'ts', 'js', 'tsx', 'jsx', 'mts', 'mjs', 'md', 'markdown'].includes(ext)) {
797
+ return content.replace(/^\s*```[\w.+-]*\s*$/gm, '').trim();
798
+ }
799
+ return content;
800
+ }
801
+ // TRUNCATION DETECTION: Check if generated code ends mid-function
802
+ // Returns true if the code appears to be truncated (open braces, incomplete statement, etc.)
803
+ detectTruncation(content) {
804
+ if (!content || content.length < 100)
805
+ return false;
806
+ const trimmed = content.trimEnd();
807
+ const lastLine = trimmed.split('\n').pop()?.trim() || '';
808
+ const last200 = trimmed.substring(Math.max(0, trimmed.length - 200));
809
+ // Signs of truncation:
810
+ // 1. Ends with a partial statement (no semicolon, no closing brace on last line)
811
+ const endsAbruptly = lastLine.length > 0 && !lastLine.match(/^[}\]);,]/);
812
+ // 2. Ends mid-string or mid-comment
813
+ const endsMidString = (trimmed.match(/`/g) || []).length % 2 !== 0;
814
+ // 3. Significantly more open braces than close braces (>3 imbalance)
815
+ const openBraces = (content.match(/\{/g) || []).length;
816
+ const closeBraces = (content.match(/\}/g) || []).length;
817
+ const braceImbalance = openBraces - closeBraces;
818
+ // 4. Last meaningful content is a function signature or opening block
819
+ const endsOnOpener = /(\{|=>|then\(|catch\(|=>\s*)$/.test(last200.trimEnd());
820
+ if (braceImbalance > 3) {
821
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Truncation signal: brace imbalance ${openBraces} open vs ${closeBraces} close`);
822
+ return true;
823
+ }
824
+ if (endsMidString) {
825
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Truncation signal: odd number of backticks (mid-template-string)`);
826
+ return true;
827
+ }
828
+ if (endsOnOpener && endsAbruptly) {
829
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Truncation signal: ends on opener with no closing`);
830
+ return true;
831
+ }
832
+ return false;
833
+ }
834
+ commitChanges(task, files) {
835
+ if (files.length === 0)
836
+ return;
837
+ // Branch-isolation guard: the runner's `git commit` lands on whichever
838
+ // branch is currently checked out. If a developer has a feature branch
839
+ // checked out while the runner is alive (per the PR #18 incident
840
+ // 2026-05-21, where swarm commits polluted fix/clawrouter-*), DO NOT
841
+ // commit — the deliverables stay on disk, the orchestrator continues,
842
+ // and the user can rebase/cherry-pick onto main when they're done.
843
+ // Always-safe vs cleverness: skipping a commit is recoverable; polluting
844
+ // a feature branch is not. See feedback_branch_hygiene_during_sprint_runner.md.
845
+ let currentBranch = 'unknown';
846
+ try {
847
+ currentBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', { timeout: 5000 }).toString().trim();
848
+ }
849
+ catch { /* if git itself is broken, the commit below will fail; let it */ }
850
+ if (currentBranch !== 'main') {
851
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Commit skipped — checkout is on '${currentBranch}', not 'main'. Files left on disk for ${task.id}; rebase to main manually if you want them committed. (branch-isolation guard)`);
852
+ return;
853
+ }
854
+ try {
855
+ const filesList = files.join(' ');
856
+ (0, child_process_1.execSync)(`git add ${filesList}`, { timeout: 10000 });
857
+ // No --no-verify: pre-commit hooks (secret scan, lint, etc) get to run.
858
+ // If this ever blocks legitimate commits, fix the hook — don't bypass.
859
+ (0, child_process_1.execSync)(`git commit -m "feat(${task.agent}): ${task.id} - ${task.type}"`, { timeout: 15000 });
860
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.green, ` ✓ Committed: ${files.length} files for ${task.id}`);
861
+ // TICKET-093 FIX: actually push. Prior version committed but never
862
+ // pushed — 25 commits stranded local overnight. Best-effort.
863
+ //
864
+ // TICKET-095 (REVERTED 2026-05-27): tried `git pull --rebase` on
865
+ // non-fast-forward rejection but that resets working-tree files to
866
+ // origin. The sprint JSON file (mid-flight pending → done status
867
+ // updates written by sprint-runner sync) is uncommitted, so the
868
+ // rebase nuked it back to the all-pending state committed at sprint
869
+ // start. Net effect: a single divergent push CAUSED the disk-state
870
+ // revert TICKET-094 was supposed to fix. Removing the rebase
871
+ // entirely — failed pushes stay local, founder reconciles manually.
872
+ // The cost (some lost autonomy on divergent remotes) is lower than
873
+ // the cost (silent destruction of sprint-state tracking).
874
+ try {
875
+ (0, child_process_1.execSync)('git push origin main', { timeout: 30000, stdio: 'pipe' });
876
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.gray, ` → pushed to origin/main`);
877
+ }
878
+ catch (pushErr) {
879
+ const msg = (pushErr?.stderr?.toString() || pushErr?.message || '').slice(0, 200).replace(/\s+/g, ' ');
880
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Push failed: ${msg.slice(0, 150)} (commit local; founder reconciles)`);
881
+ }
882
+ }
883
+ catch (error) {
884
+ (0, orchestrate_engine_1.log)(orchestrate_engine_1.c.yellow, ` ! Commit skipped: ${error.message?.substring(0, 100)}`);
885
+ }
886
+ }
887
+ }
888
+ exports.CodingAgent = CodingAgent;
889
+ // ===== Orchestrator (Dynamic Agent Pipeline) =====
890
+ // <<<EXTRACT-END-coding-agent