kantban-cli 0.1.14 → 0.1.16
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/chunk-4IUZAIFL.js +102 -0
- package/dist/chunk-4IUZAIFL.js.map +1 -0
- package/dist/chunk-CQP4B53A.js +140 -0
- package/dist/chunk-CQP4B53A.js.map +1 -0
- package/dist/chunk-FF77FM7X.js +1159 -0
- package/dist/chunk-FF77FM7X.js.map +1 -0
- package/dist/chunk-MN4H5NSU.js +3149 -0
- package/dist/chunk-MN4H5NSU.js.map +1 -0
- package/dist/{cron-AZPDPON3.js → cron-FJVZR2JW.js} +10 -18
- package/dist/cron-FJVZR2JW.js.map +1 -0
- package/dist/index.js +6 -138
- package/dist/index.js.map +1 -1
- package/dist/lib/gate-proxy-server.d.ts +1 -0
- package/dist/lib/gate-proxy-server.js +462 -0
- package/dist/lib/gate-proxy-server.js.map +1 -0
- package/dist/{pipeline-7OFX75AU.js → pipeline-6SDPVNFK.js} +494 -386
- package/dist/pipeline-6SDPVNFK.js.map +1 -0
- package/package.json +3 -1
- package/dist/chunk-KGS3M2MY.js +0 -4067
- package/dist/chunk-KGS3M2MY.js.map +0 -1
- package/dist/cron-AZPDPON3.js.map +0 -1
- package/dist/pipeline-7OFX75AU.js.map +0 -1
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VALID_BRANCH_RE
|
|
3
|
+
} from "./chunk-MN4H5NSU.js";
|
|
4
|
+
|
|
5
|
+
// src/lib/stuck-detector.ts
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// src/lib/parse-utils.ts
|
|
9
|
+
function parseJsonFromLlmOutput(raw) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(raw.trim());
|
|
12
|
+
} catch {
|
|
13
|
+
}
|
|
14
|
+
const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
15
|
+
if (fenceMatch?.[1]) {
|
|
16
|
+
const fenced = fenceMatch[1].trim();
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fenced);
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
for (let i = 0; i < raw.length; i++) {
|
|
23
|
+
if (raw[i] !== "{") continue;
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let inString = false;
|
|
26
|
+
let escape = false;
|
|
27
|
+
for (let j = i; j < raw.length; j++) {
|
|
28
|
+
const ch = raw[j];
|
|
29
|
+
if (escape) {
|
|
30
|
+
escape = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (ch === "\\" && inString) {
|
|
34
|
+
escape = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === '"') {
|
|
38
|
+
inString = !inString;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (inString) continue;
|
|
42
|
+
if (ch === "{") depth++;
|
|
43
|
+
if (ch === "}") depth--;
|
|
44
|
+
if (depth === 0) {
|
|
45
|
+
const candidate = raw.slice(i, j + 1);
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(candidate);
|
|
48
|
+
} catch {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Invalid JSON in LLM output: ${raw.slice(0, 120)}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/lib/stuck-detector.ts
|
|
58
|
+
function shouldCheckStuckDetection(config, iteration) {
|
|
59
|
+
if (!config.enabled) return false;
|
|
60
|
+
const firstCheck = config.firstCheck ?? 3;
|
|
61
|
+
const interval = config.interval ?? 2;
|
|
62
|
+
if (interval <= 0) return iteration === firstCheck;
|
|
63
|
+
if (iteration < firstCheck) return false;
|
|
64
|
+
if (iteration === firstCheck) return true;
|
|
65
|
+
return (iteration - firstCheck) % interval === 0;
|
|
66
|
+
}
|
|
67
|
+
function composeStuckDetectionPrompt(input) {
|
|
68
|
+
const commentLines = input.recentComments.length > 0 ? input.recentComments.map((c) => ` [${c.author}] ${c.body.slice(0, 200)}`).join("\n") : " (no comments)";
|
|
69
|
+
return `You are a pipeline trajectory classifier. Analyze recent agent activity and classify the trajectory.
|
|
70
|
+
|
|
71
|
+
Ticket: #${String(input.ticketNumber)} "${input.ticketTitle}"
|
|
72
|
+
Column: ${input.columnName}
|
|
73
|
+
Iteration: ${String(input.iteration)} of ${String(input.maxIterations)}
|
|
74
|
+
|
|
75
|
+
Recent iteration comments:
|
|
76
|
+
${commentLines}
|
|
77
|
+
|
|
78
|
+
Classify as one of:
|
|
79
|
+
- "progressing" \u2014 each iteration makes distinct forward progress (new code, new tests, new fields set)
|
|
80
|
+
- "spinning" \u2014 agent is active but repeating similar actions without advancing (same error, same approach retried, output duplicated)
|
|
81
|
+
- "blocked" \u2014 agent cannot make progress due to external dependency, missing information, or fundamental task issue
|
|
82
|
+
|
|
83
|
+
Respond with ONLY a JSON object:
|
|
84
|
+
{"status": "progressing|spinning|blocked", "confidence": 0.0-1.0, "evidence": "one sentence"}`;
|
|
85
|
+
}
|
|
86
|
+
var StuckDetectionResponseSchema = z.object({
|
|
87
|
+
status: z.enum(["progressing", "spinning", "blocked"]),
|
|
88
|
+
confidence: z.number().min(0).max(1),
|
|
89
|
+
evidence: z.string()
|
|
90
|
+
});
|
|
91
|
+
function parseStuckDetectionResponse(raw) {
|
|
92
|
+
let parsed;
|
|
93
|
+
try {
|
|
94
|
+
parsed = parseJsonFromLlmOutput(raw);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new Error(`StuckDetectionResponse: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
if (parsed && typeof parsed === "object" && "confidence" in parsed) {
|
|
99
|
+
const p = parsed;
|
|
100
|
+
if (typeof p.confidence === "number") {
|
|
101
|
+
p.confidence = Math.min(1, Math.max(0, p.confidence));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const result = StuckDetectionResponseSchema.safeParse(parsed);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
throw new Error(`StuckDetectionResponse: validation failed \u2014 ${result.error.message}`);
|
|
107
|
+
}
|
|
108
|
+
return result.data;
|
|
109
|
+
}
|
|
110
|
+
function classifyTrajectory(snapshots) {
|
|
111
|
+
if (snapshots.length === 0) {
|
|
112
|
+
return { status: "progressing", evidence: "no data", confidence: 0.5 };
|
|
113
|
+
}
|
|
114
|
+
const recent = snapshots.slice(-3);
|
|
115
|
+
const deltas = recent.map((s) => s.delta_from_previous);
|
|
116
|
+
const meaningful = deltas.filter((d) => d !== "first_check");
|
|
117
|
+
if (meaningful.length === 0) {
|
|
118
|
+
return { status: "progressing", evidence: "only initial checks", confidence: 0.5 };
|
|
119
|
+
}
|
|
120
|
+
if (meaningful.length < 2) {
|
|
121
|
+
return { status: "progressing", evidence: "insufficient data for trajectory", confidence: 0.3 };
|
|
122
|
+
}
|
|
123
|
+
if (meaningful.every((d) => d === "same")) {
|
|
124
|
+
return { status: "spinning", evidence: "identical gate results", confidence: 1 };
|
|
125
|
+
}
|
|
126
|
+
if (meaningful.every((d) => d === "regressed" || d === "same")) {
|
|
127
|
+
return { status: "regressing", evidence: "gate results degrading", confidence: 1 };
|
|
128
|
+
}
|
|
129
|
+
if (meaningful.some((d) => d === "improved")) {
|
|
130
|
+
const lastMeaningful = meaningful[meaningful.length - 1];
|
|
131
|
+
if (lastMeaningful === "improved") {
|
|
132
|
+
return { status: "progressing", evidence: "gate results improving", confidence: 1 };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (meaningful.some((d) => d === "improved") && meaningful.some((d) => d === "regressed")) {
|
|
136
|
+
return { status: "spinning", evidence: "oscillating gate results", confidence: 1 };
|
|
137
|
+
}
|
|
138
|
+
if (meaningful.some((d) => d === "improved")) {
|
|
139
|
+
return { status: "spinning", evidence: "stale improvement \u2014 no recent progress", confidence: 0.8 };
|
|
140
|
+
}
|
|
141
|
+
return { status: "spinning", evidence: "no improvement detected", confidence: 1 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/lib/prompt-composer.ts
|
|
145
|
+
var PROMPT_BUDGETS = {
|
|
146
|
+
system_preamble: 800,
|
|
147
|
+
gate_results: 500,
|
|
148
|
+
column_prompt: Infinity,
|
|
149
|
+
// NEVER truncated
|
|
150
|
+
lookahead: 1e3,
|
|
151
|
+
run_memory: 1e3,
|
|
152
|
+
rejection: 500,
|
|
153
|
+
ticket_details: 1500,
|
|
154
|
+
comments: 2e3,
|
|
155
|
+
transition_rules: 500,
|
|
156
|
+
transition_history: 1e3,
|
|
157
|
+
dependency_requirements: 500,
|
|
158
|
+
linked_documents: 2e3,
|
|
159
|
+
metadata: 200
|
|
160
|
+
};
|
|
161
|
+
function estimateTokens(text) {
|
|
162
|
+
return Math.ceil(text.length / 4);
|
|
163
|
+
}
|
|
164
|
+
function truncateToTokens(text, maxTokens) {
|
|
165
|
+
if (maxTokens === Infinity) return text;
|
|
166
|
+
const maxChars = maxTokens * 4;
|
|
167
|
+
if (text.length <= maxChars) return text;
|
|
168
|
+
return text.slice(0, maxChars) + "\n[...truncated]";
|
|
169
|
+
}
|
|
170
|
+
function windowComments(comments, maxTokens) {
|
|
171
|
+
const pinned = comments.filter((c) => c.pinned);
|
|
172
|
+
const unpinned = comments.filter((c) => !c.pinned);
|
|
173
|
+
const recentFull = unpinned.slice(-3);
|
|
174
|
+
const older = unpinned.slice(0, -3);
|
|
175
|
+
const parts = [];
|
|
176
|
+
for (const c of pinned) {
|
|
177
|
+
parts.push(`[pinned] **${c.author}** (${c.created_at}):
|
|
178
|
+
${c.body}
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
for (const c of recentFull) {
|
|
182
|
+
parts.push(`**${c.author}** (${c.created_at}):
|
|
183
|
+
${c.body}
|
|
184
|
+
`);
|
|
185
|
+
}
|
|
186
|
+
for (const c of older) {
|
|
187
|
+
const firstLine = c.body.split("\n")[0]?.slice(0, 100) ?? "";
|
|
188
|
+
parts.push(`- ${c.author}: ${firstLine}`);
|
|
189
|
+
}
|
|
190
|
+
const joined = parts.join("\n");
|
|
191
|
+
return truncateToTokens(joined, maxTokens);
|
|
192
|
+
}
|
|
193
|
+
function composePrompt(columnContext, ticketContext, meta) {
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (!columnContext.prompt_document?.content) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Column "${columnContext.column.name}" has no prompt document. Configure a prompt document for this column before running the pipeline.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const hasUnresolvedBlockers = ticketContext.ticket_links.some(
|
|
201
|
+
(l) => l.direction === "inward" && l.link_type === "blocks" && !l.resolved
|
|
202
|
+
);
|
|
203
|
+
parts.push(`# Pipeline Agent Instructions
|
|
204
|
+
|
|
205
|
+
You are a pipeline automation agent processing ticket #${String(ticketContext.ticket.ticket_number)}: "${ticketContext.ticket.title}".
|
|
206
|
+
|
|
207
|
+
## Your Goal
|
|
208
|
+
Advance this ticket through the pipeline. Your success criteria:
|
|
209
|
+
1. Complete the work described in the ticket and column prompt below
|
|
210
|
+
2. Move the ticket to the next column using \`${ticketContext.tool_prefix}move_ticket\`
|
|
211
|
+
3. Or mark it complete using \`${ticketContext.tool_prefix}complete_task\`
|
|
212
|
+
|
|
213
|
+
## Iteration ${meta.iteration} of ${meta.maxIterations}
|
|
214
|
+
${meta.iteration >= meta.maxIterations - 1 ? "**FINAL ITERATIONS** \u2014 prioritize moving the ticket NOW or it will be marked stalled." : `You have ${meta.maxIterations - meta.iteration} iterations remaining. Make meaningful progress each iteration.`}
|
|
215
|
+
${meta.gutterCount > 0 ? `
|
|
216
|
+
## Progress Warning
|
|
217
|
+
No meaningful progress detected for ${meta.gutterCount} consecutive iteration(s).
|
|
218
|
+
${meta.gutterThreshold - meta.gutterCount} iteration(s) remain before this loop is terminated as stalled.
|
|
219
|
+
|
|
220
|
+
You MUST change approach. What you've been doing is not working. Consider:
|
|
221
|
+
- Breaking the problem into smaller steps
|
|
222
|
+
- Setting a field value to record partial progress
|
|
223
|
+
- Creating a comment explaining what's blocking you
|
|
224
|
+
- Asking for help via a signal
|
|
225
|
+
` : ""}
|
|
226
|
+
## Available Tools (prefix: ${ticketContext.tool_prefix})
|
|
227
|
+
- **${ticketContext.tool_prefix}check_transition** \u2014 ALWAYS call this before moving. Returns allowed/blocked with recovery steps.
|
|
228
|
+
Params: \`{ projectId, boardId, ticketId, targetColumnId }\`
|
|
229
|
+
- **${ticketContext.tool_prefix}move_ticket** \u2014 Move ticket to a new column. Include handoff data for the next agent.
|
|
230
|
+
Params: \`{ projectId, ticketId, column_id, handoff: { branch?, commit_sha?, build_status?, notes? } }\`
|
|
231
|
+
- **${ticketContext.tool_prefix}complete_task** \u2014 Move to a done column with handoff data.
|
|
232
|
+
Params: \`{ projectId, ticketId, handoff: { ... } }\`
|
|
233
|
+
- **${ticketContext.tool_prefix}set_field_value** \u2014 Set a required field before moving (check transition rules).
|
|
234
|
+
Params: \`{ projectId, ticketId, fieldId, value }\`
|
|
235
|
+
- **${ticketContext.tool_prefix}create_signal** \u2014 Leave knowledge for future agents at any scope.
|
|
236
|
+
- **${ticketContext.tool_prefix}create_comment** \u2014 Add progress notes to the ticket.
|
|
237
|
+
- **${ticketContext.tool_prefix}append_run_memory** \u2014 Share discoveries with future agents.
|
|
238
|
+
Params: \`{ section: 'conventions'|'interfaces'|'failures'|'decisions', content: string }\`
|
|
239
|
+
|
|
240
|
+
## Knowledge Sharing
|
|
241
|
+
Before moving the ticket to the next column, you MUST:
|
|
242
|
+
1. **Write a signal** via \`${ticketContext.tool_prefix}create_signal\` summarizing the key outcome of your work:
|
|
243
|
+
- What you discovered, built, validated, or decided
|
|
244
|
+
- Scope it to the ticket (\`ticketId\`) so future agents on this ticket see it
|
|
245
|
+
- One concise signal is better than none \u2014 don't skip this step
|
|
246
|
+
2. **Contribute to run memory** via \`${ticketContext.tool_prefix}append_run_memory\` if you discovered:
|
|
247
|
+
- Conventions or patterns others should follow (\`section: 'conventions'\`)
|
|
248
|
+
- Interfaces or APIs consumed or created (\`section: 'interfaces'\`)
|
|
249
|
+
- Failures or dead-ends to avoid (\`section: 'failures'\`)
|
|
250
|
+
- Design decisions and their rationale (\`section: 'decisions'\`)
|
|
251
|
+
|
|
252
|
+
Signals and run memory are how you communicate with future agents. Without them, the next agent starts blind.
|
|
253
|
+
|
|
254
|
+
## Iteration Summary
|
|
255
|
+
If you made meaningful progress this iteration, create a comment using \`${ticketContext.tool_prefix}create_comment\` summarizing:
|
|
256
|
+
- What you accomplished
|
|
257
|
+
- What remains to be done
|
|
258
|
+
- Any blockers or issues encountered
|
|
259
|
+
|
|
260
|
+
## Critical Rules
|
|
261
|
+
1. **Always call ${ticketContext.tool_prefix}check_transition before ${ticketContext.tool_prefix}move_ticket** \u2014 moves that violate workflow rules will fail.
|
|
262
|
+
2. **If the ticket has UNRESOLVED blockers, do not attempt to move it.** Create a comment explaining you are waiting, then stop.
|
|
263
|
+
3. **Include handoff data** when moving \u2014 the next agent needs context (branch name, build status, etc.).
|
|
264
|
+
4. **If you cannot make progress**, create a comment explaining why and stop. Do not burn iterations.
|
|
265
|
+
${hasUnresolvedBlockers ? "\n**WARNING: This ticket has UNRESOLVED blockers. Do NOT attempt to move it. Document your status and stop.**\n" : ""}
|
|
266
|
+
---
|
|
267
|
+
`);
|
|
268
|
+
const worktreeConfig = columnContext.agent_config?.worktree;
|
|
269
|
+
if (worktreeConfig?.enabled) {
|
|
270
|
+
const integrationBranch = worktreeConfig.integration_branch ?? "main";
|
|
271
|
+
if (!VALID_BRANCH_RE.test(integrationBranch)) {
|
|
272
|
+
parts.push(`## Git Worktree
|
|
273
|
+
|
|
274
|
+
You are working in an isolated git worktree. Integration branch name is invalid \u2014 contact the pipeline operator.`);
|
|
275
|
+
} else {
|
|
276
|
+
parts.push(`## Git Worktree
|
|
277
|
+
|
|
278
|
+
You are working in an isolated git worktree. Before starting any code changes:
|
|
279
|
+
1. Merge \`${integrationBranch}\` into your current branch: \`git merge '${integrationBranch}'\`
|
|
280
|
+
2. Resolve any merge conflicts before proceeding with ticket work
|
|
281
|
+
3. Never rebase \u2014 always merge (rebase destroys traceability across the pipeline)
|
|
282
|
+
|
|
283
|
+
After completing your work, commit all changes to your worktree branch.
|
|
284
|
+
`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (ticketContext.signals.length > 0) {
|
|
288
|
+
parts.push(`
|
|
289
|
+
## Signals (Guardrails)
|
|
290
|
+
`);
|
|
291
|
+
for (const s of ticketContext.signals) {
|
|
292
|
+
parts.push(`- ${s}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (meta.gateResults && meta.gateResults.length > 0) {
|
|
296
|
+
parts.push(`
|
|
297
|
+
## Previous Gate Results
|
|
298
|
+
`);
|
|
299
|
+
for (const r of meta.gateResults) {
|
|
300
|
+
const status = r.passed ? "PASS" : "FAIL";
|
|
301
|
+
const req = r.required ? "(required)" : "(advisory)";
|
|
302
|
+
parts.push(`- **${r.name}** ${status} ${req} [${String(r.duration_ms)}ms]`);
|
|
303
|
+
if (!r.passed && r.output) {
|
|
304
|
+
parts.push(` \`\`\`
|
|
305
|
+
${truncateToTokens(r.output, 200)}
|
|
306
|
+
\`\`\``);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (meta.rejectionFindings) {
|
|
311
|
+
parts.push(`
|
|
312
|
+
## Previous Rejection (fix these before resubmitting)
|
|
313
|
+
`);
|
|
314
|
+
parts.push(truncateToTokens(meta.rejectionFindings, PROMPT_BUDGETS.rejection));
|
|
315
|
+
} else {
|
|
316
|
+
const rejectionComment = ticketContext.comments.slice().reverse().find((c) => c.body.startsWith("QA REJECTION:") || c.body.startsWith("REJECTION:"));
|
|
317
|
+
if (rejectionComment) {
|
|
318
|
+
parts.push(`
|
|
319
|
+
## Previous Rejection (fix these before resubmitting)
|
|
320
|
+
`);
|
|
321
|
+
parts.push(truncateToTokens(rejectionComment.body, PROMPT_BUDGETS.rejection));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (columnContext.prompt_document?.content) {
|
|
325
|
+
parts.push(columnContext.prompt_document.content);
|
|
326
|
+
}
|
|
327
|
+
if (meta.lookaheadDocument?.content) {
|
|
328
|
+
parts.push(`
|
|
329
|
+
## Downstream Criteria (build to pass these)
|
|
330
|
+
`);
|
|
331
|
+
parts.push(`*From: ${meta.lookaheadDocument.title}*
|
|
332
|
+
`);
|
|
333
|
+
parts.push(truncateToTokens(meta.lookaheadDocument.content, PROMPT_BUDGETS.lookahead));
|
|
334
|
+
}
|
|
335
|
+
if (meta.runMemoryContent) {
|
|
336
|
+
parts.push(`
|
|
337
|
+
## Run Memory
|
|
338
|
+
`);
|
|
339
|
+
parts.push(truncateToTokens(meta.runMemoryContent, PROMPT_BUDGETS.run_memory));
|
|
340
|
+
}
|
|
341
|
+
const ticketParts = [];
|
|
342
|
+
ticketParts.push(`## Current Ticket
|
|
343
|
+
`);
|
|
344
|
+
ticketParts.push(`**Title:** ${ticketContext.ticket.title}`);
|
|
345
|
+
ticketParts.push(`**Ticket ID:** ${ticketContext.ticket.id}`);
|
|
346
|
+
ticketParts.push(`**Ticket Number:** ${String(ticketContext.ticket.ticket_number)}`);
|
|
347
|
+
if (ticketContext.ticket.description) {
|
|
348
|
+
ticketParts.push(`
|
|
349
|
+
${ticketContext.ticket.description}`);
|
|
350
|
+
}
|
|
351
|
+
if (ticketContext.ticket.assignee) {
|
|
352
|
+
ticketParts.push(`**Assignee:** ${ticketContext.ticket.assignee.name}`);
|
|
353
|
+
}
|
|
354
|
+
if (ticketContext.ticket.column) {
|
|
355
|
+
ticketParts.push(`**Current Column:** ${ticketContext.ticket.column.name} (${ticketContext.ticket.column.type})`);
|
|
356
|
+
}
|
|
357
|
+
if (ticketContext.ticket.backward_transitions > 0) {
|
|
358
|
+
ticketParts.push(`**Backward Transitions:** ${String(ticketContext.ticket.backward_transitions)}`);
|
|
359
|
+
}
|
|
360
|
+
if (ticketContext.field_values.length > 0) {
|
|
361
|
+
ticketParts.push(`
|
|
362
|
+
## Field Values
|
|
363
|
+
`);
|
|
364
|
+
for (const fv of ticketContext.field_values) {
|
|
365
|
+
ticketParts.push(`- **${fv.field_name}:** ${fv.value !== null ? JSON.stringify(fv.value) : "(not set)"}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (ticketContext.parent) {
|
|
369
|
+
ticketParts.push(`
|
|
370
|
+
## Parent Ticket
|
|
371
|
+
`);
|
|
372
|
+
ticketParts.push(`- #${String(ticketContext.parent.ticket_number)}: ${ticketContext.parent.title}`);
|
|
373
|
+
}
|
|
374
|
+
if (ticketContext.children.length > 0) {
|
|
375
|
+
ticketParts.push(`
|
|
376
|
+
## Child Tickets
|
|
377
|
+
`);
|
|
378
|
+
for (const child of ticketContext.children) {
|
|
379
|
+
ticketParts.push(`- #${String(child.ticket_number)}: ${child.title}${child.column_name ? ` (${child.column_name})` : ""}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (ticketContext.transitions.length > 0) {
|
|
383
|
+
ticketParts.push(`
|
|
384
|
+
## Transition History
|
|
385
|
+
`);
|
|
386
|
+
let transitionTokens = 0;
|
|
387
|
+
for (const t of ticketContext.transitions) {
|
|
388
|
+
let line = `- ${t.from ?? "Backlog"} \u2192 ${t.to}`;
|
|
389
|
+
if (t.handoff) line += ` (handoff: ${JSON.stringify(t.handoff)})`;
|
|
390
|
+
const lineTokens = estimateTokens(line);
|
|
391
|
+
if (transitionTokens + lineTokens > PROMPT_BUDGETS.transition_history) {
|
|
392
|
+
ticketParts.push(`- [...truncated \u2014 ${String(ticketContext.transitions.length)} total transitions]`);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
ticketParts.push(line);
|
|
396
|
+
transitionTokens += lineTokens;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (ticketContext.ticket_links.length > 0) {
|
|
400
|
+
ticketParts.push(`
|
|
401
|
+
## Ticket Links
|
|
402
|
+
`);
|
|
403
|
+
const blockers = ticketContext.ticket_links.filter((l) => l.direction === "inward" && l.link_type === "blocks");
|
|
404
|
+
const blocking = ticketContext.ticket_links.filter((l) => l.direction === "outward" && l.link_type === "blocks");
|
|
405
|
+
const related = ticketContext.ticket_links.filter((l) => l.link_type === "relates_to");
|
|
406
|
+
if (blockers.length > 0) {
|
|
407
|
+
ticketParts.push(`**Blocked by:**`);
|
|
408
|
+
for (const l of blockers) {
|
|
409
|
+
const status = l.resolved ? "(resolved)" : "UNRESOLVED";
|
|
410
|
+
ticketParts.push(`- #${String(l.ticket_number)}: ${l.title} [${l.column_name ?? "backlog"}] ${status}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (blocking.length > 0) {
|
|
414
|
+
ticketParts.push(`**Blocks:**`);
|
|
415
|
+
for (const l of blocking) {
|
|
416
|
+
ticketParts.push(`- #${String(l.ticket_number)}: ${l.title} [${l.column_name ?? "backlog"}]`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (related.length > 0) {
|
|
420
|
+
ticketParts.push(`**Related:**`);
|
|
421
|
+
for (const l of related) {
|
|
422
|
+
ticketParts.push(`- #${String(l.ticket_number)}: ${l.title}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
parts.push(truncateToTokens(ticketParts.join("\n"), PROMPT_BUDGETS.ticket_details));
|
|
427
|
+
if (ticketContext.comments.length > 0) {
|
|
428
|
+
parts.push(`
|
|
429
|
+
## Comments
|
|
430
|
+
`);
|
|
431
|
+
parts.push(windowComments(ticketContext.comments, PROMPT_BUDGETS.comments));
|
|
432
|
+
}
|
|
433
|
+
if (ticketContext.transition_rules) {
|
|
434
|
+
parts.push(`
|
|
435
|
+
## Transition Rules
|
|
436
|
+
`);
|
|
437
|
+
parts.push(truncateToTokens(ticketContext.transition_rules, PROMPT_BUDGETS.transition_rules));
|
|
438
|
+
}
|
|
439
|
+
if (ticketContext.dependency_requirements && ticketContext.dependency_requirements !== "No dependency or field requirements configured.") {
|
|
440
|
+
parts.push(`
|
|
441
|
+
## Dependency & Field Requirements
|
|
442
|
+
`);
|
|
443
|
+
parts.push(truncateToTokens(ticketContext.dependency_requirements, PROMPT_BUDGETS.dependency_requirements));
|
|
444
|
+
}
|
|
445
|
+
if (ticketContext.linked_documents.length > 0) {
|
|
446
|
+
parts.push(`
|
|
447
|
+
## Linked Documents
|
|
448
|
+
`);
|
|
449
|
+
let remainingTokens = PROMPT_BUDGETS.linked_documents;
|
|
450
|
+
for (const doc of ticketContext.linked_documents) {
|
|
451
|
+
if (doc.content) {
|
|
452
|
+
const docHeader = `### ${doc.title}
|
|
453
|
+
`;
|
|
454
|
+
const headerTokens = estimateTokens(docHeader);
|
|
455
|
+
const contentTokens = estimateTokens(doc.content);
|
|
456
|
+
if (headerTokens + contentTokens <= remainingTokens) {
|
|
457
|
+
parts.push(docHeader);
|
|
458
|
+
parts.push(doc.content);
|
|
459
|
+
remainingTokens -= headerTokens + contentTokens;
|
|
460
|
+
} else if (remainingTokens > headerTokens + 50) {
|
|
461
|
+
parts.push(docHeader);
|
|
462
|
+
parts.push(truncateToTokens(doc.content, remainingTokens - headerTokens));
|
|
463
|
+
remainingTokens = 0;
|
|
464
|
+
} else {
|
|
465
|
+
parts.push(`- ${doc.title} (truncated \u2014 token budget exceeded)`);
|
|
466
|
+
remainingTokens = 0;
|
|
467
|
+
}
|
|
468
|
+
} else if (doc.truncated) {
|
|
469
|
+
parts.push(`- ${doc.title} (document too large \u2014 read via kantban_get_document)`);
|
|
470
|
+
}
|
|
471
|
+
if (remainingTokens <= 0) break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
parts.push(`
|
|
475
|
+
## Pipeline Metadata
|
|
476
|
+
`);
|
|
477
|
+
parts.push(`- Iteration: ${meta.iteration} / ${meta.maxIterations}`);
|
|
478
|
+
parts.push(`- Project ID: ${meta.projectId}`);
|
|
479
|
+
parts.push(`- Tool Prefix: ${ticketContext.tool_prefix}`);
|
|
480
|
+
parts.push(`- Column: ${columnContext.column.name}`);
|
|
481
|
+
parts.push(`- Goal: ${columnContext.column.goal ?? "No goal set"}`);
|
|
482
|
+
return parts.join("\n");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/lib/ralph-loop.ts
|
|
486
|
+
var API_TIMEOUT_MS = 3e4;
|
|
487
|
+
function withTimeout(promise, ms, label) {
|
|
488
|
+
let timer;
|
|
489
|
+
return Promise.race([
|
|
490
|
+
promise.finally(() => clearTimeout(timer)),
|
|
491
|
+
new Promise((_, reject) => {
|
|
492
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
493
|
+
})
|
|
494
|
+
]);
|
|
495
|
+
}
|
|
496
|
+
var RalphLoop = class {
|
|
497
|
+
ticketId;
|
|
498
|
+
columnId;
|
|
499
|
+
config;
|
|
500
|
+
deps;
|
|
501
|
+
stopped = false;
|
|
502
|
+
currentIteration = 0;
|
|
503
|
+
constructor(ticketId, columnId, config, deps) {
|
|
504
|
+
this.ticketId = ticketId;
|
|
505
|
+
this.columnId = columnId;
|
|
506
|
+
this.config = config;
|
|
507
|
+
this.deps = deps;
|
|
508
|
+
}
|
|
509
|
+
stop() {
|
|
510
|
+
this.stopped = true;
|
|
511
|
+
}
|
|
512
|
+
get iteration() {
|
|
513
|
+
return this.currentIteration;
|
|
514
|
+
}
|
|
515
|
+
looksLike404(err) {
|
|
516
|
+
const statusCode = err?.statusCode ?? err?.status;
|
|
517
|
+
if (statusCode === 404) return true;
|
|
518
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
519
|
+
return message.includes("API error 404");
|
|
520
|
+
}
|
|
521
|
+
async run() {
|
|
522
|
+
let gutterCount = this.config.startGutterCount ?? 0;
|
|
523
|
+
let lastFingerprint = this.config.startFingerprint ?? null;
|
|
524
|
+
const log = this.deps.log ?? (() => {
|
|
525
|
+
});
|
|
526
|
+
const resolvedModel = this.config.model ?? "default";
|
|
527
|
+
const gateSnapshots = [];
|
|
528
|
+
let cumulativeTokensIn = 0;
|
|
529
|
+
let cumulativeTokensOut = 0;
|
|
530
|
+
let cumulativeToolCalls = 0;
|
|
531
|
+
let cumulativeDurationMs = 0;
|
|
532
|
+
const withCosts = (r) => ({
|
|
533
|
+
...r,
|
|
534
|
+
tokensIn: cumulativeTokensIn,
|
|
535
|
+
tokensOut: cumulativeTokensOut,
|
|
536
|
+
toolCallCount: cumulativeToolCalls,
|
|
537
|
+
durationMs: cumulativeDurationMs
|
|
538
|
+
});
|
|
539
|
+
let lastOutput = "";
|
|
540
|
+
const startIter = this.config.startIteration ?? 1;
|
|
541
|
+
for (let i = startIter; i <= this.config.maxIterations; i++) {
|
|
542
|
+
if (this.stopped) return withCosts({ reason: "stopped", iterations: i - 1, gutterCount, model: resolvedModel });
|
|
543
|
+
if (this.config.isBudgetExhausted?.()) {
|
|
544
|
+
log(`Budget exhausted \u2014 stopping after ${i - 1} iterations`);
|
|
545
|
+
return withCosts({ reason: "budget", iterations: i - 1, gutterCount, model: resolvedModel });
|
|
546
|
+
}
|
|
547
|
+
this.currentIteration = i;
|
|
548
|
+
log(`Iteration ${i}/${this.config.maxIterations} starting`);
|
|
549
|
+
if (!lastFingerprint) {
|
|
550
|
+
try {
|
|
551
|
+
lastFingerprint = await withTimeout(
|
|
552
|
+
this.deps.fetchFingerprint(this.ticketId),
|
|
553
|
+
API_TIMEOUT_MS,
|
|
554
|
+
"fetchFingerprint (baseline)"
|
|
555
|
+
);
|
|
556
|
+
} catch (err) {
|
|
557
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
558
|
+
log(`Baseline fingerprint fetch failed: ${message}`);
|
|
559
|
+
if (this.looksLike404(err)) {
|
|
560
|
+
return withCosts({ reason: "deleted", iterations: i, gutterCount, model: resolvedModel });
|
|
561
|
+
}
|
|
562
|
+
return withCosts({ reason: "error", iterations: i, gutterCount, lastError: `Baseline fingerprint failed: ${message}`, model: resolvedModel });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
let ticketCtx;
|
|
566
|
+
let columnCtx;
|
|
567
|
+
try {
|
|
568
|
+
[ticketCtx, columnCtx] = await Promise.all([
|
|
569
|
+
withTimeout(this.deps.fetchTicketContext(this.ticketId), API_TIMEOUT_MS, "fetchTicketContext"),
|
|
570
|
+
withTimeout(this.deps.fetchColumnContext(this.columnId), API_TIMEOUT_MS, "fetchColumnContext")
|
|
571
|
+
]);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
574
|
+
log(`Context fetch failed: ${message}`);
|
|
575
|
+
if (this.looksLike404(err)) {
|
|
576
|
+
return withCosts({ reason: "deleted", iterations: i, gutterCount, model: resolvedModel });
|
|
577
|
+
}
|
|
578
|
+
return withCosts({ reason: "error", iterations: i, gutterCount, lastError: `Context fetch failed: ${message}`, model: resolvedModel });
|
|
579
|
+
}
|
|
580
|
+
if (ticketCtx.ticket.column && ticketCtx.ticket.column.id !== this.columnId) {
|
|
581
|
+
log(`Ticket already in column ${ticketCtx.ticket.column.name}, not ${this.columnId} \u2014 exiting as moved`);
|
|
582
|
+
return withCosts({ reason: "moved", iterations: i - 1, gutterCount, model: resolvedModel });
|
|
583
|
+
}
|
|
584
|
+
const runMemoryContent = this.deps.fetchRunMemoryContent ? await this.deps.fetchRunMemoryContent().catch(() => "") : void 0;
|
|
585
|
+
const lookaheadDocument = this.deps.fetchLookaheadDocument ? await this.deps.fetchLookaheadDocument().catch(() => void 0) : void 0;
|
|
586
|
+
let prompt;
|
|
587
|
+
try {
|
|
588
|
+
prompt = composePrompt(columnCtx, ticketCtx, {
|
|
589
|
+
iteration: i,
|
|
590
|
+
maxIterations: this.config.maxIterations,
|
|
591
|
+
projectId: this.deps.projectId,
|
|
592
|
+
gutterCount,
|
|
593
|
+
gutterThreshold: this.config.gutterThreshold,
|
|
594
|
+
runMemoryContent: runMemoryContent || void 0,
|
|
595
|
+
lookaheadDocument
|
|
596
|
+
});
|
|
597
|
+
} catch (err) {
|
|
598
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
599
|
+
log(`Prompt composition failed: ${message}`);
|
|
600
|
+
return withCosts({ reason: "error", iterations: i, gutterCount, lastError: `Prompt composition failed: ${message}`, model: resolvedModel });
|
|
601
|
+
}
|
|
602
|
+
if (this.stopped) return withCosts({ reason: "stopped", iterations: i - 1, gutterCount, model: resolvedModel });
|
|
603
|
+
log(`Invoking ${this.deps.provider.displayName} (model=${this.config.model ?? "default"})`);
|
|
604
|
+
const iterationRunId = `${this.config.runId ?? this.ticketId}-iter-${String(i)}`;
|
|
605
|
+
this.deps.onSessionStart?.({
|
|
606
|
+
runId: iterationRunId,
|
|
607
|
+
model: this.config.model ?? "default",
|
|
608
|
+
iteration: i
|
|
609
|
+
});
|
|
610
|
+
const streamContext = { runId: iterationRunId, ticketId: this.ticketId, columnId: this.columnId };
|
|
611
|
+
const agentRequest = {
|
|
612
|
+
prompt,
|
|
613
|
+
...this.config.model != null && { model: this.config.model },
|
|
614
|
+
...this.config.maxBudgetUsd != null && { maxTurns: Math.max(1, Math.ceil(this.config.maxBudgetUsd * 10)) },
|
|
615
|
+
...this.config.worktreeName != null && { workingDirectory: this.config.worktreeName },
|
|
616
|
+
...this.deps.mcpConfig != null && { mcpConfig: this.deps.mcpConfig },
|
|
617
|
+
...this.config.toolRestrictions != null && {
|
|
618
|
+
toolRestrictions: { ...this.config.toolRestrictions, includeMcpConfig: this.config.toolRestrictions.includeMcpConfig ?? true }
|
|
619
|
+
},
|
|
620
|
+
onStreamEvent: (event) => this.deps.onStreamEvent?.(event, streamContext)
|
|
621
|
+
};
|
|
622
|
+
const { exitCode, output, toolCallCount, usage, durationMs: iterationDurationMs, degradedCapabilities } = await this.deps.provider.invoke(agentRequest);
|
|
623
|
+
const tokensIn = usage.inputTokens;
|
|
624
|
+
const tokensOut = usage.outputTokens;
|
|
625
|
+
if (i === startIter && degradedCapabilities?.length) {
|
|
626
|
+
log(`Provider ${this.deps.provider.id} degraded: ${degradedCapabilities.join(", ")}`);
|
|
627
|
+
}
|
|
628
|
+
cumulativeTokensIn += tokensIn;
|
|
629
|
+
cumulativeTokensOut += tokensOut;
|
|
630
|
+
cumulativeToolCalls += toolCallCount;
|
|
631
|
+
cumulativeDurationMs += iterationDurationMs;
|
|
632
|
+
this.deps.onSessionEnd?.({
|
|
633
|
+
runId: iterationRunId,
|
|
634
|
+
exitCode,
|
|
635
|
+
tokensIn,
|
|
636
|
+
tokensOut,
|
|
637
|
+
toolCallCount,
|
|
638
|
+
durationMs: iterationDurationMs
|
|
639
|
+
});
|
|
640
|
+
if (exitCode !== 0) {
|
|
641
|
+
const snippet = output.slice(-200);
|
|
642
|
+
log(`${this.deps.provider.displayName} exited with code ${exitCode}: ${snippet}`);
|
|
643
|
+
return withCosts({ reason: "error", iterations: i, gutterCount, lastError: `non-zero exit code: ${exitCode}. Last output: ${snippet}`, model: resolvedModel });
|
|
644
|
+
}
|
|
645
|
+
log(`${this.deps.provider.displayName} exited successfully`);
|
|
646
|
+
lastOutput = output;
|
|
647
|
+
let afterFp;
|
|
648
|
+
try {
|
|
649
|
+
const retryDelayMs = this.config.postMoveRetryDelayMs ?? 1500;
|
|
650
|
+
afterFp = await withTimeout(
|
|
651
|
+
this.deps.fetchFingerprint(this.ticketId),
|
|
652
|
+
API_TIMEOUT_MS,
|
|
653
|
+
"fetchFingerprint (post-iteration)"
|
|
654
|
+
);
|
|
655
|
+
if (afterFp.column_id === this.columnId) {
|
|
656
|
+
for (let retry = 0; retry < 2; retry++) {
|
|
657
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
658
|
+
if (this.stopped) break;
|
|
659
|
+
try {
|
|
660
|
+
afterFp = await withTimeout(
|
|
661
|
+
this.deps.fetchFingerprint(this.ticketId),
|
|
662
|
+
API_TIMEOUT_MS,
|
|
663
|
+
"fetchFingerprint (retry)"
|
|
664
|
+
);
|
|
665
|
+
} catch (err) {
|
|
666
|
+
log(`Fingerprint retry ${retry + 1} failed: ${err instanceof Error ? err.message : String(err)} \u2014 using last good value`);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
if (afterFp.column_id !== this.columnId) break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
} catch (err) {
|
|
673
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
674
|
+
log(`Post-iteration fingerprint failed: ${message}`);
|
|
675
|
+
if (this.looksLike404(err)) {
|
|
676
|
+
return withCosts({ reason: "deleted", iterations: i, gutterCount, model: resolvedModel });
|
|
677
|
+
}
|
|
678
|
+
return withCosts({ reason: "error", iterations: i, gutterCount, lastError: `Post-iteration fingerprint failed: ${message}`, model: resolvedModel });
|
|
679
|
+
}
|
|
680
|
+
if (afterFp.column_id !== this.columnId) {
|
|
681
|
+
log(`Ticket moved to column ${afterFp.column_id ?? "null"}`);
|
|
682
|
+
return withCosts({ reason: "moved", iterations: i, gutterCount, model: resolvedModel, output: lastOutput });
|
|
683
|
+
}
|
|
684
|
+
const gutterBeforeGates = gutterCount;
|
|
685
|
+
if (this.config.onPostIterationGates) {
|
|
686
|
+
try {
|
|
687
|
+
const snapshot = await this.config.onPostIterationGates(this.ticketId, i);
|
|
688
|
+
gateSnapshots.push(snapshot);
|
|
689
|
+
const fieldDelta = lastFingerprint ? afterFp.field_value_count !== lastFingerprint.field_value_count : false;
|
|
690
|
+
switch (snapshot.delta_from_previous) {
|
|
691
|
+
case "improved":
|
|
692
|
+
if (gutterCount > 0) log(`Gate improvement detected \u2014 gutter counter reset`);
|
|
693
|
+
gutterCount = 0;
|
|
694
|
+
break;
|
|
695
|
+
case "same":
|
|
696
|
+
if (!fieldDelta) {
|
|
697
|
+
gutterCount++;
|
|
698
|
+
log(`No gate progress (gutter ${gutterCount}/${this.config.gutterThreshold})`);
|
|
699
|
+
} else {
|
|
700
|
+
log(`Gates unchanged but fields changed \u2014 not incrementing gutter`);
|
|
701
|
+
}
|
|
702
|
+
break;
|
|
703
|
+
case "regressed":
|
|
704
|
+
gutterCount += 2;
|
|
705
|
+
log(`Gate regression detected (gutter ${gutterCount}/${this.config.gutterThreshold})`);
|
|
706
|
+
break;
|
|
707
|
+
case "first_check":
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
} catch (err) {
|
|
711
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
712
|
+
log(`Post-iteration gate check failed (non-blocking): ${message}`);
|
|
713
|
+
if (lastFingerprint && fingerprintsMatch(lastFingerprint, afterFp)) {
|
|
714
|
+
gutterCount++;
|
|
715
|
+
log(`No progress detected via fingerprint fallback (gutter ${gutterCount}/${this.config.gutterThreshold})`);
|
|
716
|
+
} else {
|
|
717
|
+
if (gutterCount > 0) log(`Progress detected \u2014 gutter counter reset`);
|
|
718
|
+
gutterCount = 0;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
if (lastFingerprint && fingerprintsMatch(lastFingerprint, afterFp)) {
|
|
723
|
+
gutterCount++;
|
|
724
|
+
log(`No progress detected (gutter ${gutterCount}/${this.config.gutterThreshold})`);
|
|
725
|
+
} else {
|
|
726
|
+
if (gutterCount > 0) log(`Progress detected \u2014 gutter counter reset`);
|
|
727
|
+
gutterCount = 0;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
lastFingerprint = afterFp;
|
|
731
|
+
if (this.config.stuckDetection && shouldCheckStuckDetection(this.config.stuckDetection, i)) {
|
|
732
|
+
try {
|
|
733
|
+
let sdStatus;
|
|
734
|
+
let sdEvidence;
|
|
735
|
+
let sdConfidence;
|
|
736
|
+
if (gateSnapshots.length > 0) {
|
|
737
|
+
const trajectory = classifyTrajectory(gateSnapshots);
|
|
738
|
+
sdStatus = trajectory.status === "regressing" ? "spinning" : trajectory.status;
|
|
739
|
+
sdEvidence = trajectory.evidence;
|
|
740
|
+
sdConfidence = trajectory.confidence;
|
|
741
|
+
log(`Gate-based stuck detection: ${sdStatus} (confidence=${String(sdConfidence)}) \u2014 ${sdEvidence}`);
|
|
742
|
+
} else if (this.config.invokeStuckDetection) {
|
|
743
|
+
const sdInput = {
|
|
744
|
+
ticketNumber: ticketCtx.ticket.ticket_number,
|
|
745
|
+
ticketTitle: ticketCtx.ticket.title,
|
|
746
|
+
columnName: columnCtx.column.name,
|
|
747
|
+
iteration: i,
|
|
748
|
+
maxIterations: this.config.maxIterations,
|
|
749
|
+
recentComments: ticketCtx.comments.slice(-3).map((c) => ({
|
|
750
|
+
author: c.author,
|
|
751
|
+
body: c.body
|
|
752
|
+
}))
|
|
753
|
+
};
|
|
754
|
+
const sdResult = await this.config.invokeStuckDetection(sdInput);
|
|
755
|
+
sdStatus = sdResult.status;
|
|
756
|
+
sdEvidence = sdResult.evidence;
|
|
757
|
+
sdConfidence = sdResult.confidence;
|
|
758
|
+
log(`Stuck detection: ${sdStatus} (confidence=${String(sdConfidence)}) \u2014 ${sdEvidence}`);
|
|
759
|
+
} else {
|
|
760
|
+
sdStatus = "progressing";
|
|
761
|
+
sdEvidence = "no detection method";
|
|
762
|
+
sdConfidence = 0;
|
|
763
|
+
}
|
|
764
|
+
switch (sdStatus) {
|
|
765
|
+
case "progressing":
|
|
766
|
+
if (gutterCount > 0) {
|
|
767
|
+
log(`Stuck detection override: resetting gutter counter (was ${String(gutterCount)})`);
|
|
768
|
+
gutterCount = 0;
|
|
769
|
+
}
|
|
770
|
+
break;
|
|
771
|
+
case "spinning":
|
|
772
|
+
gutterCount = gutterBeforeGates + 2;
|
|
773
|
+
log(`Stuck detection: spinning \u2014 gutter set to ${String(gutterCount)}/${String(this.config.gutterThreshold)}`);
|
|
774
|
+
break;
|
|
775
|
+
case "blocked":
|
|
776
|
+
log(`Stuck detection: blocked \u2014 exiting immediately`);
|
|
777
|
+
return withCosts({
|
|
778
|
+
reason: "stalled",
|
|
779
|
+
iterations: i,
|
|
780
|
+
gutterCount: this.config.gutterThreshold,
|
|
781
|
+
model: resolvedModel,
|
|
782
|
+
output: lastOutput,
|
|
783
|
+
...gateSnapshots.length > 0 && { finalGateSnapshot: gateSnapshots[gateSnapshots.length - 1] }
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
788
|
+
log(`Stuck detection failed (non-blocking): ${message}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if (gutterCount >= this.config.gutterThreshold) {
|
|
792
|
+
return withCosts({
|
|
793
|
+
reason: "stalled",
|
|
794
|
+
iterations: i,
|
|
795
|
+
gutterCount,
|
|
796
|
+
model: resolvedModel,
|
|
797
|
+
output: lastOutput,
|
|
798
|
+
...gateSnapshots.length > 0 && { finalGateSnapshot: gateSnapshots[gateSnapshots.length - 1] }
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
if (this.config.onCheckpoint) {
|
|
802
|
+
try {
|
|
803
|
+
await this.config.onCheckpoint(this.ticketId, {
|
|
804
|
+
run_id: this.config.runId ?? "00000000-0000-0000-0000-000000000000",
|
|
805
|
+
column_id: this.columnId,
|
|
806
|
+
iteration: i,
|
|
807
|
+
gutter_count: gutterCount,
|
|
808
|
+
advisor_invocations: 0,
|
|
809
|
+
// tracked by orchestrator, not loop
|
|
810
|
+
model_tier: resolvedModel,
|
|
811
|
+
last_fingerprint: afterFp,
|
|
812
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
813
|
+
worktree_name: this.config.worktreeName
|
|
814
|
+
});
|
|
815
|
+
} catch (err) {
|
|
816
|
+
log(`Checkpoint write failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return withCosts({
|
|
821
|
+
reason: "max_iterations",
|
|
822
|
+
iterations: this.config.maxIterations,
|
|
823
|
+
gutterCount,
|
|
824
|
+
model: resolvedModel,
|
|
825
|
+
output: lastOutput,
|
|
826
|
+
...gateSnapshots.length > 0 && { finalGateSnapshot: gateSnapshots[gateSnapshots.length - 1] }
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
function fingerprintsMatch(a, b) {
|
|
831
|
+
return a.column_id === b.column_id && a.field_value_count === b.field_value_count && a.comment_count === b.comment_count;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/lib/mcp-config.ts
|
|
835
|
+
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
|
|
836
|
+
import { join, dirname } from "path";
|
|
837
|
+
import { fileURLToPath } from "url";
|
|
838
|
+
import { homedir } from "os";
|
|
839
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
840
|
+
var __dirname = dirname(__filename);
|
|
841
|
+
function generateMcpConfig(apiUrl, apiToken, boardId) {
|
|
842
|
+
const localMcpPath = existsSync(join(__dirname, "..", "..", "mcp", "dist", "index.js")) ? join(__dirname, "..", "..", "mcp", "dist", "index.js") : join(__dirname, "..", "..", "..", "mcp", "dist", "index.js");
|
|
843
|
+
const useLocal = existsSync(localMcpPath);
|
|
844
|
+
const kantbanServer = useLocal ? {
|
|
845
|
+
command: "node",
|
|
846
|
+
args: [localMcpPath],
|
|
847
|
+
env: {
|
|
848
|
+
KANTBAN_API_TOKEN: apiToken,
|
|
849
|
+
KANTBAN_API_URL: apiUrl
|
|
850
|
+
}
|
|
851
|
+
} : {
|
|
852
|
+
command: "npx",
|
|
853
|
+
args: ["-y", "kantban-mcp@latest"],
|
|
854
|
+
env: {
|
|
855
|
+
KANTBAN_API_TOKEN: apiToken,
|
|
856
|
+
KANTBAN_API_URL: apiUrl
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
const config = {
|
|
860
|
+
mcpServers: {
|
|
861
|
+
kantban: kantbanServer
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
const dir = join(homedir(), ".kantban", "pipelines", boardId);
|
|
865
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
866
|
+
const filePath = join(dir, "mcp-config.json");
|
|
867
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
868
|
+
return filePath;
|
|
869
|
+
}
|
|
870
|
+
function cleanupMcpConfig(filePath) {
|
|
871
|
+
try {
|
|
872
|
+
if (existsSync(filePath)) {
|
|
873
|
+
unlinkSync(filePath);
|
|
874
|
+
}
|
|
875
|
+
} catch {
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function generateGateProxyMcpConfig(apiUrl, apiToken, boardId, gateConfigPath, columnId, columnName, projectId) {
|
|
879
|
+
const localMcpPath = existsSync(join(__dirname, "..", "..", "mcp", "dist", "index.js")) ? join(__dirname, "..", "..", "mcp", "dist", "index.js") : join(__dirname, "..", "..", "..", "mcp", "dist", "index.js");
|
|
880
|
+
const useLocal = existsSync(localMcpPath);
|
|
881
|
+
const kantbanServer = useLocal ? { command: "node", args: [localMcpPath], env: { KANTBAN_API_TOKEN: apiToken, KANTBAN_API_URL: apiUrl, KANTBAN_HIDDEN_TOOLS: "kantban_move_ticket,kantban_move_tickets,kantban_complete_task,kantban_move_to_board" } } : { command: "npx", args: ["-y", "kantban-mcp@latest"], env: { KANTBAN_API_TOKEN: apiToken, KANTBAN_API_URL: apiUrl, KANTBAN_HIDDEN_TOOLS: "kantban_move_ticket,kantban_move_tickets,kantban_complete_task,kantban_move_to_board" } };
|
|
882
|
+
const gateProxyPath = existsSync(join(__dirname, "lib", "gate-proxy-server.js")) ? join(__dirname, "lib", "gate-proxy-server.js") : join(__dirname, "gate-proxy-server.js");
|
|
883
|
+
const gateProxyServer = {
|
|
884
|
+
command: "node",
|
|
885
|
+
args: [gateProxyPath],
|
|
886
|
+
env: {
|
|
887
|
+
GATE_CONFIG_PATH: gateConfigPath,
|
|
888
|
+
COLUMN_ID: columnId,
|
|
889
|
+
COLUMN_NAME: columnName,
|
|
890
|
+
PROJECT_ID: projectId,
|
|
891
|
+
KANTBAN_API_TOKEN: apiToken,
|
|
892
|
+
KANTBAN_API_URL: apiUrl
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
const config = {
|
|
896
|
+
mcpServers: {
|
|
897
|
+
kantban: kantbanServer,
|
|
898
|
+
"kantban-gates": gateProxyServer
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
const dir = join(homedir(), ".kantban", "pipelines", boardId);
|
|
902
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
903
|
+
const filePath = join(dir, `mcp-config-${columnId}.json`);
|
|
904
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
905
|
+
return filePath;
|
|
906
|
+
}
|
|
907
|
+
function cleanupGateProxyConfigs(pipelineDir) {
|
|
908
|
+
try {
|
|
909
|
+
const files = readdirSync(pipelineDir);
|
|
910
|
+
for (const f of files) {
|
|
911
|
+
if (f.startsWith("mcp-config-") && f.endsWith(".json")) {
|
|
912
|
+
try {
|
|
913
|
+
unlinkSync(join(pipelineDir, f));
|
|
914
|
+
} catch {
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
} catch {
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// src/providers/claude-provider.ts
|
|
923
|
+
import { spawn } from "child_process";
|
|
924
|
+
import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
925
|
+
import { join as join2 } from "path";
|
|
926
|
+
import { homedir as homedir2 } from "os";
|
|
927
|
+
|
|
928
|
+
// src/providers/claude-stream-parser.ts
|
|
929
|
+
var ClaudeStreamParser = class {
|
|
930
|
+
buffer = "";
|
|
931
|
+
toolCallCount = 0;
|
|
932
|
+
inputTokens = 0;
|
|
933
|
+
outputTokens = 0;
|
|
934
|
+
lastOutput = "";
|
|
935
|
+
onEvent = () => {
|
|
936
|
+
};
|
|
937
|
+
onError = () => {
|
|
938
|
+
};
|
|
939
|
+
feed(chunk) {
|
|
940
|
+
this.buffer += chunk;
|
|
941
|
+
const lines = this.buffer.split("\n");
|
|
942
|
+
this.buffer = lines.pop() ?? "";
|
|
943
|
+
for (const line of lines) {
|
|
944
|
+
this.parseLine(line.trim());
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
flush() {
|
|
948
|
+
const trimmed = this.buffer.trim();
|
|
949
|
+
this.buffer = "";
|
|
950
|
+
if (trimmed) this.parseLine(trimmed);
|
|
951
|
+
}
|
|
952
|
+
getToolCallCount() {
|
|
953
|
+
return this.toolCallCount;
|
|
954
|
+
}
|
|
955
|
+
getUsage() {
|
|
956
|
+
return { inputTokens: this.inputTokens, outputTokens: this.outputTokens };
|
|
957
|
+
}
|
|
958
|
+
getLastOutput() {
|
|
959
|
+
return this.lastOutput;
|
|
960
|
+
}
|
|
961
|
+
reset() {
|
|
962
|
+
this.buffer = "";
|
|
963
|
+
this.toolCallCount = 0;
|
|
964
|
+
this.inputTokens = 0;
|
|
965
|
+
this.outputTokens = 0;
|
|
966
|
+
this.lastOutput = "";
|
|
967
|
+
}
|
|
968
|
+
parseLine(line) {
|
|
969
|
+
if (!line) return;
|
|
970
|
+
try {
|
|
971
|
+
const raw = JSON.parse(line);
|
|
972
|
+
this.translateEvent(raw);
|
|
973
|
+
} catch {
|
|
974
|
+
this.onError(new Error(`Failed to parse stream-json line: ${line.slice(0, 100)}`));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
translateEvent(raw) {
|
|
978
|
+
const message = raw.message;
|
|
979
|
+
const content = message?.content ?? raw.content;
|
|
980
|
+
if (raw.type === "assistant" && Array.isArray(content)) {
|
|
981
|
+
for (const block of content) {
|
|
982
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
983
|
+
this.onEvent({ type: "text", text: block.text });
|
|
984
|
+
} else if (block.type === "tool_use") {
|
|
985
|
+
this.toolCallCount++;
|
|
986
|
+
this.onEvent({
|
|
987
|
+
type: "tool_call",
|
|
988
|
+
tool: block.name ?? "unknown",
|
|
989
|
+
input: block.input
|
|
990
|
+
});
|
|
991
|
+
} else if (block.type === "tool_result") {
|
|
992
|
+
this.onEvent({
|
|
993
|
+
type: "tool_result",
|
|
994
|
+
tool: block.name ?? "unknown",
|
|
995
|
+
output: block.content ?? block.output
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
} else if (raw.type === "result") {
|
|
1000
|
+
const usage = raw.usage;
|
|
1001
|
+
const inTok = usage?.input_tokens ?? 0;
|
|
1002
|
+
const outTok = usage?.output_tokens ?? 0;
|
|
1003
|
+
this.inputTokens += inTok;
|
|
1004
|
+
this.outputTokens += outTok;
|
|
1005
|
+
if (inTok || outTok) {
|
|
1006
|
+
this.onEvent({ type: "usage", inputTokens: inTok, outputTokens: outTok });
|
|
1007
|
+
}
|
|
1008
|
+
if (typeof raw.result === "string") {
|
|
1009
|
+
this.lastOutput = raw.result;
|
|
1010
|
+
this.onEvent({ type: "done", result: raw.result });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
// src/providers/claude-provider.ts
|
|
1017
|
+
var CLAUDE_TIMEOUT_MS = 60 * 60 * 1e3;
|
|
1018
|
+
var ClaudeProvider = class {
|
|
1019
|
+
id = "claude";
|
|
1020
|
+
displayName = "Claude Code";
|
|
1021
|
+
capabilities() {
|
|
1022
|
+
return {
|
|
1023
|
+
supportsToolAllowlist: true,
|
|
1024
|
+
supportsToolDenylist: true,
|
|
1025
|
+
supportsBuiltinToolStripping: true,
|
|
1026
|
+
supportsMaxTurns: true,
|
|
1027
|
+
supportsMcpConfigInjection: true,
|
|
1028
|
+
supportsMcpConfigOverride: false,
|
|
1029
|
+
supportsWorktreeFlag: true,
|
|
1030
|
+
supportsSandboxModes: false,
|
|
1031
|
+
supportedModels: [
|
|
1032
|
+
{ id: "claude-haiku-4-5-20251001", displayName: "Haiku 4.5", tier: "fast" },
|
|
1033
|
+
{ id: "claude-sonnet-4-6", displayName: "Sonnet 4.6", tier: "default" },
|
|
1034
|
+
{ id: "claude-opus-4-6", displayName: "Opus 4.6", tier: "thorough" }
|
|
1035
|
+
],
|
|
1036
|
+
streamFormat: "stream-json"
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
async invoke(request) {
|
|
1040
|
+
const args = this.buildArgs(request);
|
|
1041
|
+
const startTime = Date.now();
|
|
1042
|
+
return new Promise((resolve) => {
|
|
1043
|
+
const child = spawn("claude", args, {
|
|
1044
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1045
|
+
...request.workingDirectory && { cwd: request.workingDirectory }
|
|
1046
|
+
});
|
|
1047
|
+
const parser = new ClaudeStreamParser();
|
|
1048
|
+
parser.onEvent = (event) => request.onStreamEvent?.(event);
|
|
1049
|
+
parser.onError = (err) => process.stderr.write(`[claude-stream] ${err.message}
|
|
1050
|
+
`);
|
|
1051
|
+
let stderr = "";
|
|
1052
|
+
child.stdout?.on("data", (chunk) => parser.feed(chunk.toString()));
|
|
1053
|
+
child.stderr?.on("data", (chunk) => {
|
|
1054
|
+
stderr += chunk.toString();
|
|
1055
|
+
});
|
|
1056
|
+
child.stdin?.end();
|
|
1057
|
+
if (request.abortSignal) {
|
|
1058
|
+
request.abortSignal.addEventListener("abort", () => {
|
|
1059
|
+
try {
|
|
1060
|
+
child.kill("SIGTERM");
|
|
1061
|
+
} catch {
|
|
1062
|
+
}
|
|
1063
|
+
}, { once: true });
|
|
1064
|
+
}
|
|
1065
|
+
let killTimer;
|
|
1066
|
+
const timeoutHandle = setTimeout(() => {
|
|
1067
|
+
try {
|
|
1068
|
+
child.kill("SIGTERM");
|
|
1069
|
+
} catch {
|
|
1070
|
+
}
|
|
1071
|
+
killTimer = setTimeout(() => {
|
|
1072
|
+
try {
|
|
1073
|
+
child.kill("SIGKILL");
|
|
1074
|
+
} catch {
|
|
1075
|
+
}
|
|
1076
|
+
}, 5e3);
|
|
1077
|
+
}, CLAUDE_TIMEOUT_MS);
|
|
1078
|
+
let resolved = false;
|
|
1079
|
+
const finish = (code, errorMsg) => {
|
|
1080
|
+
if (resolved) return;
|
|
1081
|
+
resolved = true;
|
|
1082
|
+
clearTimeout(timeoutHandle);
|
|
1083
|
+
if (killTimer) clearTimeout(killTimer);
|
|
1084
|
+
parser.flush();
|
|
1085
|
+
const usage = parser.getUsage();
|
|
1086
|
+
resolve({
|
|
1087
|
+
exitCode: code ?? 1,
|
|
1088
|
+
output: errorMsg ?? (parser.getLastOutput() || stderr),
|
|
1089
|
+
toolCallCount: parser.getToolCallCount(),
|
|
1090
|
+
usage,
|
|
1091
|
+
durationMs: Date.now() - startTime
|
|
1092
|
+
});
|
|
1093
|
+
};
|
|
1094
|
+
child.on("close", (code) => finish(code));
|
|
1095
|
+
child.on("error", (err) => finish(1, err.message));
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
async preflight() {
|
|
1099
|
+
try {
|
|
1100
|
+
const whichModule = await import("which");
|
|
1101
|
+
const syncFn = whichModule.default?.sync ?? whichModule.sync;
|
|
1102
|
+
syncFn("claude");
|
|
1103
|
+
return { available: true, authenticated: true };
|
|
1104
|
+
} catch {
|
|
1105
|
+
return { available: false, authenticated: false, error: "claude binary not found on PATH" };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
buildArgs(request) {
|
|
1109
|
+
const args = [
|
|
1110
|
+
"-p",
|
|
1111
|
+
request.prompt,
|
|
1112
|
+
"--dangerously-skip-permissions",
|
|
1113
|
+
"--output-format",
|
|
1114
|
+
"stream-json",
|
|
1115
|
+
"--verbose"
|
|
1116
|
+
];
|
|
1117
|
+
if (request.mcpConfig && request.toolRestrictions?.includeMcpConfig !== false) {
|
|
1118
|
+
const configPath = this.writeMcpConfigJson(request.mcpConfig);
|
|
1119
|
+
args.push("--mcp-config", configPath);
|
|
1120
|
+
}
|
|
1121
|
+
if (request.model) args.push("--model", request.model);
|
|
1122
|
+
if (request.maxTurns) {
|
|
1123
|
+
args.push("--max-turns", String(request.maxTurns));
|
|
1124
|
+
}
|
|
1125
|
+
if (request.workingDirectory) {
|
|
1126
|
+
args.push("--worktree", request.workingDirectory);
|
|
1127
|
+
}
|
|
1128
|
+
if (request.toolRestrictions) {
|
|
1129
|
+
const tr = request.toolRestrictions;
|
|
1130
|
+
if (tr.tools !== void 0) args.push("--tools", tr.tools);
|
|
1131
|
+
if (tr.allowedTools?.length) args.push("--allowedTools", ...tr.allowedTools);
|
|
1132
|
+
if (tr.disallowedTools?.length) args.push("--disallowedTools", ...tr.disallowedTools);
|
|
1133
|
+
}
|
|
1134
|
+
return args;
|
|
1135
|
+
}
|
|
1136
|
+
writeMcpConfigJson(mcpConfig) {
|
|
1137
|
+
if (!mcpConfig) return "";
|
|
1138
|
+
const config = { mcpServers: mcpConfig.servers };
|
|
1139
|
+
const dir = join2(homedir2(), ".kantban", "tmp");
|
|
1140
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
1141
|
+
const filePath = join2(dir, `mcp-config-${Date.now()}.json`);
|
|
1142
|
+
writeFileSync2(filePath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
1143
|
+
return filePath;
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
export {
|
|
1148
|
+
parseJsonFromLlmOutput,
|
|
1149
|
+
composeStuckDetectionPrompt,
|
|
1150
|
+
parseStuckDetectionResponse,
|
|
1151
|
+
classifyTrajectory,
|
|
1152
|
+
RalphLoop,
|
|
1153
|
+
generateMcpConfig,
|
|
1154
|
+
cleanupMcpConfig,
|
|
1155
|
+
generateGateProxyMcpConfig,
|
|
1156
|
+
cleanupGateProxyConfigs,
|
|
1157
|
+
ClaudeProvider
|
|
1158
|
+
};
|
|
1159
|
+
//# sourceMappingURL=chunk-FF77FM7X.js.map
|