@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.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/did-resolver-registry.d.ts +15 -0
- package/dist/lib/did-resolver-registry.js +9 -0
- package/dist/lib/engine-agents.d.ts +71 -0
- package/dist/lib/engine-agents.js +835 -0
- package/dist/lib/engine-coding-agent.d.ts +17 -0
- package/dist/lib/engine-coding-agent.js +890 -0
- package/dist/lib/engine-helpers.d.ts +10 -0
- package/dist/lib/engine-helpers.js +319 -0
- package/dist/lib/engine-loaders.d.ts +5 -0
- package/dist/lib/engine-loaders.js +241 -0
- package/dist/lib/engine-orchestrator.d.ts +46 -0
- package/dist/lib/engine-orchestrator.js +1491 -0
- package/dist/lib/engine-primitives.d.ts +141 -0
- package/dist/lib/engine-primitives.js +748 -0
- package/dist/lib/orchestrate-engine.d.ts +14 -1
- package/dist/lib/orchestrate-engine.js +26 -4333
- package/dist/lib/plumber/extractor.d.ts +18 -0
- package/dist/lib/plumber/extractor.js +213 -0
- package/dist/lib/plumber/fixer.d.ts +75 -0
- package/dist/lib/plumber/fixer.js +165 -0
- package/dist/lib/plumber/index.d.ts +3 -0
- package/dist/lib/plumber/index.js +3 -0
- package/dist/lib/plumber/patcher.d.ts +44 -0
- package/dist/lib/plumber/patcher.js +210 -0
- package/dist/lib/preamble-provider-registry.d.ts +12 -0
- package/dist/lib/preamble-provider-registry.js +7 -0
- package/package.json +1 -1
|
@@ -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
|