@slope-dev/slope 1.25.0 → 1.25.2

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.
Files changed (46) hide show
  1. package/dist/adapters.d.ts +3 -0
  2. package/dist/adapters.d.ts.map +1 -1
  3. package/dist/adapters.js +2 -0
  4. package/dist/adapters.js.map +1 -1
  5. package/dist/cli/commands/doctor.d.ts.map +1 -1
  6. package/dist/cli/commands/doctor.js +1 -0
  7. package/dist/cli/commands/doctor.js.map +1 -1
  8. package/dist/cli/commands/init.d.ts +1 -0
  9. package/dist/cli/commands/init.d.ts.map +1 -1
  10. package/dist/cli/commands/init.js +77 -13
  11. package/dist/cli/commands/init.js.map +1 -1
  12. package/dist/cli/commands/loop.d.ts.map +1 -1
  13. package/dist/cli/commands/loop.js +148 -1
  14. package/dist/cli/commands/loop.js.map +1 -1
  15. package/dist/cli/guards/pr-review.js +2 -2
  16. package/dist/cli/guards/pr-review.js.map +1 -1
  17. package/dist/cli/guards/review-tier.d.ts.map +1 -1
  18. package/dist/cli/guards/review-tier.js +12 -4
  19. package/dist/cli/guards/review-tier.js.map +1 -1
  20. package/dist/cli/loop/aider-executor.d.ts +10 -0
  21. package/dist/cli/loop/aider-executor.d.ts.map +1 -0
  22. package/dist/cli/loop/aider-executor.js +239 -0
  23. package/dist/cli/loop/aider-executor.js.map +1 -0
  24. package/dist/cli/loop/executor-adapter.d.ts +18 -0
  25. package/dist/cli/loop/executor-adapter.d.ts.map +1 -0
  26. package/dist/cli/loop/executor-adapter.js +37 -0
  27. package/dist/cli/loop/executor-adapter.js.map +1 -0
  28. package/dist/cli/loop/executor.d.ts.map +1 -1
  29. package/dist/cli/loop/executor.js +159 -182
  30. package/dist/cli/loop/executor.js.map +1 -1
  31. package/dist/cli/loop/slope-executor.d.ts +35 -0
  32. package/dist/cli/loop/slope-executor.d.ts.map +1 -0
  33. package/dist/cli/loop/slope-executor.js +794 -0
  34. package/dist/cli/loop/slope-executor.js.map +1 -0
  35. package/dist/cli/loop/types.d.ts +46 -0
  36. package/dist/cli/loop/types.d.ts.map +1 -1
  37. package/dist/cli/loop/types.js.map +1 -1
  38. package/dist/core/adapters/ob1.d.ts +41 -0
  39. package/dist/core/adapters/ob1.d.ts.map +1 -0
  40. package/dist/core/adapters/ob1.js +313 -0
  41. package/dist/core/adapters/ob1.js.map +1 -0
  42. package/dist/core/harness.d.ts +1 -1
  43. package/dist/core/harness.d.ts.map +1 -1
  44. package/dist/core/harness.js +1 -1
  45. package/dist/core/harness.js.map +1 -1
  46. package/package.json +2 -1
@@ -0,0 +1,794 @@
1
+ /**
2
+ * SlopeExecutor — custom agentic tool loop using the Anthropic Messages API.
3
+ *
4
+ * Implements ExecutorAdapter with:
5
+ * - 6 tools: read_file, write_file, edit_file, bash, glob, grep
6
+ * - Token/cost tracking from response.usage
7
+ * - Full transcript recording
8
+ * - Stuck detection (repeated identical tool calls)
9
+ * - Timeout enforcement
10
+ */
11
+ import { execSync, execFileSync } from 'node:child_process';
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
+ import { join, resolve, dirname } from 'node:path';
14
+ import { loadConfig } from '../../core/config.js';
15
+ import { loadScorecards } from '../../core/loader.js';
16
+ import { computeHandicapCard } from '../../core/handicap.js';
17
+ import { extractHazardIndex, filterCommonIssues } from '../../core/briefing.js';
18
+ import { extractKeywords } from './planner.js';
19
+ // ── Constants ───────────────────────────────────────
20
+ const MAX_TURNS_DEFAULT = 50;
21
+ const MAX_REPEATED_CALLS = 3;
22
+ const MAX_GUARD_RETRIES = 2;
23
+ const MAX_OVERLOAD_RETRIES = 3;
24
+ const MAX_CONSECUTIVE_BASH = 5;
25
+ /** Turn budget per club — smaller tickets get fewer turns to prevent flailing */
26
+ const CLUB_TURN_LIMITS = {
27
+ putter: 20,
28
+ wedge: 30,
29
+ short_iron: 40,
30
+ long_iron: 50,
31
+ driver: 50,
32
+ };
33
+ const DEFAULT_MAX_TOKENS = 8192;
34
+ const TOOL_BASH_TIMEOUT = 60_000;
35
+ const TOOL_OUTPUT_CAP = 50_000;
36
+ const FILE_READ_CAP = 100_000;
37
+ const TRANSCRIPT_CAP = 2000;
38
+ const TRUNCATE_KEEP_RECENT = 20; // keep last 10 turns fully intact
39
+ // Approximate cost per million tokens — not intended to be precise
40
+ const COST_TABLE = {
41
+ 'claude-haiku-4-5': { in: 0.80, out: 4.00 },
42
+ 'claude-sonnet-4-5': { in: 3.00, out: 15.00 },
43
+ 'claude-sonnet-4-6': { in: 3.00, out: 15.00 },
44
+ 'claude-opus-4-6': { in: 15.00, out: 75.00 },
45
+ };
46
+ const DEFAULT_COST = { in: 1.00, out: 5.00 };
47
+ // Destructive command blocklist — [pattern, human-readable reason]
48
+ const BLOCKED_COMMANDS = [
49
+ [/\brm\s+-\w*r\w*\s+\//, 'rm with recursive flag targeting absolute path'],
50
+ [/\bgit\s+push\b/, 'push is handled by the loop, not the executor'],
51
+ [/\bmkfs\b/, 'filesystem format commands are not allowed'],
52
+ [/\bdd\b.*\bof=\//, 'dd write to absolute path is not allowed'],
53
+ [/\b(shutdown|reboot|halt|poweroff)\b/, 'system power commands are not allowed'],
54
+ [/\bcurl\b.*\|\s*(ba)?sh/, 'piping curl to shell is not allowed'],
55
+ [/\bpnpm\s+slope\b|\bslope\s+/, 'use the slope tool instead of running slope via bash'],
56
+ ];
57
+ // ── Tool definitions (Anthropic API format) ─────────
58
+ const TOOLS = [
59
+ {
60
+ name: 'read_file',
61
+ description: 'Read the full contents of a file. Always read a file before editing it.',
62
+ input_schema: {
63
+ type: 'object',
64
+ properties: {
65
+ path: { type: 'string', description: 'Relative path from the repo root' },
66
+ },
67
+ required: ['path'],
68
+ },
69
+ },
70
+ {
71
+ name: 'write_file',
72
+ description: 'Create a new file or completely overwrite an existing one. Use edit_file for targeted changes.',
73
+ input_schema: {
74
+ type: 'object',
75
+ properties: {
76
+ path: { type: 'string', description: 'Relative path from the repo root' },
77
+ content: { type: 'string', description: 'Complete file content' },
78
+ },
79
+ required: ['path', 'content'],
80
+ },
81
+ },
82
+ {
83
+ name: 'edit_file',
84
+ description: 'Replace an exact string in a file. The old_string must match exactly (including whitespace/indentation). Only the first occurrence is replaced.',
85
+ input_schema: {
86
+ type: 'object',
87
+ properties: {
88
+ path: { type: 'string', description: 'Relative path from the repo root' },
89
+ old_string: { type: 'string', description: 'Exact text to find (must be unique enough to match once)' },
90
+ new_string: { type: 'string', description: 'Replacement text' },
91
+ },
92
+ required: ['path', 'old_string', 'new_string'],
93
+ },
94
+ },
95
+ {
96
+ name: 'bash',
97
+ description: 'Run a shell command. Use for git operations, running tests (pnpm test), type checking (pnpm typecheck), and other CLI tools. Commands time out after 60s.',
98
+ input_schema: {
99
+ type: 'object',
100
+ properties: {
101
+ command: { type: 'string', description: 'Shell command to execute' },
102
+ },
103
+ required: ['command'],
104
+ },
105
+ },
106
+ {
107
+ name: 'glob',
108
+ description: 'Find files matching a glob pattern. Returns relative paths, one per line.',
109
+ input_schema: {
110
+ type: 'object',
111
+ properties: {
112
+ pattern: { type: 'string', description: 'Glob pattern (e.g., "src/**/*.ts", "*.json")' },
113
+ },
114
+ required: ['pattern'],
115
+ },
116
+ },
117
+ {
118
+ name: 'grep',
119
+ description: 'Search file contents with a regex pattern. Returns matching lines with file paths and line numbers.',
120
+ input_schema: {
121
+ type: 'object',
122
+ properties: {
123
+ pattern: { type: 'string', description: 'Search pattern (regex supported)' },
124
+ path: { type: 'string', description: 'File or directory to search (default: current directory)' },
125
+ include: { type: 'string', description: 'File glob filter (e.g., "*.ts")' },
126
+ },
127
+ required: ['pattern'],
128
+ },
129
+ },
130
+ {
131
+ name: 'slope',
132
+ description: 'Run a SLOPE CLI command (read-only). Available: search, context, briefing, card, validate, map, plan, prep, status, next, flows, doctor. Example: slope({ command: "briefing --categories=testing" })',
133
+ input_schema: {
134
+ type: 'object',
135
+ properties: {
136
+ command: {
137
+ type: 'string',
138
+ description: 'SLOPE subcommand and flags (e.g., "search --query=handicap", "briefing", "map")',
139
+ },
140
+ },
141
+ required: ['command'],
142
+ },
143
+ },
144
+ ];
145
+ /** Allowlisted read-only slope subcommands */
146
+ const SLOPE_ALLOWLIST = new Set([
147
+ 'search', 'context', 'briefing', 'card', 'validate',
148
+ 'map', 'plan', 'prep', 'status', 'next', 'flows',
149
+ 'doctor', 'version',
150
+ ]);
151
+ const SLOPE_TOOL_OUTPUT_CAP = 4000;
152
+ // ── Path security ───────────────────────────────────
153
+ export function safePath(relPath, cwd) {
154
+ const abs = resolve(cwd, relPath);
155
+ if (!abs.startsWith(resolve(cwd))) {
156
+ throw new Error(`Path traversal blocked: ${relPath}`);
157
+ }
158
+ return abs;
159
+ }
160
+ // ── Model ID resolution ─────────────────────────────
161
+ export function resolveModelId(model) {
162
+ return model
163
+ .replace(/^openrouter\/anthropic\//, '')
164
+ .replace(/^anthropic\//, '');
165
+ }
166
+ export function lookupCost(modelId) {
167
+ for (const [key, cost] of Object.entries(COST_TABLE)) {
168
+ if (modelId.includes(key))
169
+ return cost;
170
+ }
171
+ return DEFAULT_COST;
172
+ }
173
+ // ── Executor ────────────────────────────────────────
174
+ export const slopeExecutor = {
175
+ id: 'slope',
176
+ async execute(ctx, config, cwd, log) {
177
+ const start = Date.now();
178
+ const transcript = [];
179
+ let totalIn = 0;
180
+ let totalOut = 0;
181
+ let client;
182
+ try {
183
+ const { default: AnthropicSDK } = await import('@anthropic-ai/sdk');
184
+ // Supports ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL from env
185
+ const baseURL = process.env.ANTHROPIC_BASE_URL;
186
+ client = new AnthropicSDK(baseURL ? { baseURL } : undefined);
187
+ }
188
+ catch (err) {
189
+ const msg = err instanceof Error ? err.message : String(err);
190
+ const hint = msg.includes('Cannot find') || msg.includes('MODULE_NOT_FOUND')
191
+ ? 'Install: pnpm add @anthropic-ai/sdk'
192
+ : 'Is ANTHROPIC_API_KEY set?';
193
+ log.error(`Anthropic client init failed (${hint}): ${msg}`);
194
+ return errorResult(transcript, start);
195
+ }
196
+ const modelId = resolveModelId(ctx.model);
197
+ const costRate = lookupCost(modelId);
198
+ const deadline = start + ctx.timeout * 1000;
199
+ const systemPrompt = buildSystemPrompt(ctx, config, cwd);
200
+ // Message history for the agentic loop
201
+ const messages = [
202
+ { role: 'user', content: ctx.prompt },
203
+ ];
204
+ const maxTurns = CLUB_TURN_LIMITS[ctx.ticket.club] ?? MAX_TURNS_DEFAULT;
205
+ const recentSigs = [];
206
+ let outcome = 'completed';
207
+ let turn = 0;
208
+ let guardRetries = 0;
209
+ let overloadRetries = 0;
210
+ let innerGuardsPassed = false;
211
+ let consecutiveBash = 0;
212
+ // ── Agent loop ──
213
+ while (turn < maxTurns) {
214
+ const remaining = deadline - Date.now();
215
+ if (remaining <= 0) {
216
+ log.warn(`Timed out after ${ctx.timeout}s`);
217
+ outcome = 'timeout';
218
+ break;
219
+ }
220
+ turn++;
221
+ // Truncate old tool results to stay within context limits
222
+ truncateOldMessages(messages);
223
+ // AbortSignal for per-call timeout
224
+ const controller = new AbortController();
225
+ const callTimeout = setTimeout(() => controller.abort(), remaining);
226
+ let response;
227
+ try {
228
+ response = await client.messages.create({
229
+ model: modelId,
230
+ max_tokens: DEFAULT_MAX_TOKENS,
231
+ system: systemPrompt,
232
+ tools: TOOLS,
233
+ messages,
234
+ }, { signal: controller.signal });
235
+ }
236
+ catch (err) {
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ if (controller.signal.aborted) {
239
+ log.warn(`API call aborted (deadline reached)`);
240
+ outcome = 'timeout';
241
+ break;
242
+ }
243
+ log.error(`API error (turn ${turn}): ${msg}`);
244
+ if (msg.includes('overloaded') && overloadRetries < MAX_OVERLOAD_RETRIES) {
245
+ overloadRetries++;
246
+ await sleep(5000 * overloadRetries);
247
+ continue;
248
+ }
249
+ outcome = 'error';
250
+ break;
251
+ }
252
+ finally {
253
+ clearTimeout(callTimeout);
254
+ }
255
+ // Accumulate usage
256
+ if (response.usage) {
257
+ totalIn += response.usage.input_tokens;
258
+ totalOut += response.usage.output_tokens;
259
+ }
260
+ // Model thinks it's done — run inner guards before accepting
261
+ if (response.stop_reason === 'end_turn') {
262
+ messages.push({ role: 'assistant', content: response.content });
263
+ // Skip inner guards if we've exhausted retries or are past deadline
264
+ if (guardRetries >= MAX_GUARD_RETRIES || Date.now() > deadline) {
265
+ break;
266
+ }
267
+ const guardFailure = runInnerGuards(config, cwd, log);
268
+ if (!guardFailure) {
269
+ log.info('Inner guards passed');
270
+ innerGuardsPassed = true;
271
+ break;
272
+ }
273
+ // Feed the error back so the model can self-correct
274
+ guardRetries++;
275
+ log.warn(`Inner guard failed (attempt ${guardRetries}/${MAX_GUARD_RETRIES}): ${guardFailure.guard}`);
276
+ messages.push({
277
+ role: 'user',
278
+ content: `Your changes have an issue that must be fixed before this ticket is complete.\n\n## ${guardFailure.guard} failed\n\`\`\`\n${guardFailure.output}\n\`\`\`\n\nPlease fix the issue and verify again.`,
279
+ });
280
+ continue;
281
+ }
282
+ if (response.stop_reason !== 'tool_use') {
283
+ log.warn(`Unexpected stop_reason: ${response.stop_reason}`);
284
+ break;
285
+ }
286
+ // Extract tool_use blocks
287
+ const toolBlocks = response.content.filter((b) => b.type === 'tool_use');
288
+ // Stuck detection — same tool calls N times in a row
289
+ const sig = toolBlocks.map(b => `${b.name}:${JSON.stringify(b.input)}`).join('|');
290
+ recentSigs.push(sig);
291
+ if (recentSigs.length > MAX_REPEATED_CALLS)
292
+ recentSigs.shift();
293
+ if (recentSigs.length === MAX_REPEATED_CALLS &&
294
+ recentSigs.every(s => s === sig)) {
295
+ log.warn('Stuck: identical tool calls repeated — stopping');
296
+ outcome = 'stuck';
297
+ break;
298
+ }
299
+ // Execute each tool
300
+ const toolResults = [];
301
+ for (const block of toolBlocks) {
302
+ const toolStart = Date.now();
303
+ const res = runTool(block.name, block.input, cwd, log);
304
+ const duration_ms = Date.now() - toolStart;
305
+ transcript.push({
306
+ timestamp: new Date().toISOString(),
307
+ tool: block.name,
308
+ input: block.input,
309
+ output: res.output.slice(0, TRANSCRIPT_CAP),
310
+ duration_ms,
311
+ });
312
+ toolResults.push({
313
+ type: 'tool_result',
314
+ tool_use_id: block.id,
315
+ content: res.output,
316
+ ...(res.isError ? { is_error: true } : {}),
317
+ });
318
+ }
319
+ messages.push({ role: 'assistant', content: response.content });
320
+ messages.push({ role: 'user', content: toolResults });
321
+ log.info(`Turn ${turn}: ${toolBlocks.map(b => b.name).join(', ')}`);
322
+ // Bash-loop breaker: if the model runs bash N+ times in a row without
323
+ // reading or editing files, inject a nudge to break the spiral
324
+ const allBash = toolBlocks.every(b => b.name === 'bash');
325
+ consecutiveBash = allBash ? consecutiveBash + 1 : 0;
326
+ if (consecutiveBash >= MAX_CONSECUTIVE_BASH) {
327
+ log.warn(`Bash loop detected (${consecutiveBash} consecutive) — injecting nudge`);
328
+ messages.push({
329
+ role: 'user',
330
+ content: `You have run ${consecutiveBash} consecutive bash commands without reading or editing any files. This is usually a sign you are stuck. Stop and think about what is actually failing. Use read_file to examine the error, or use grep/glob to find the right file to edit. Do not run another bash command until you have read the relevant code.`,
331
+ });
332
+ consecutiveBash = 0;
333
+ }
334
+ }
335
+ if (turn >= maxTurns) {
336
+ log.warn(`Hit max turns (${maxTurns})`);
337
+ if (outcome === 'completed')
338
+ outcome = 'stuck';
339
+ }
340
+ // Commit any uncommitted changes
341
+ const filesChanged = gitCommit(ctx.ticketKey, ctx.ticket.title, cwd, log);
342
+ const duration_s = Math.round((Date.now() - start) / 1000);
343
+ const cost_usd = (totalIn * costRate.in + totalOut * costRate.out) / 1_000_000;
344
+ return {
345
+ outcome,
346
+ noop: false, // caller checks SHA diff
347
+ tokens_in: totalIn,
348
+ tokens_out: totalOut,
349
+ cost_usd: Math.round(cost_usd * 10000) / 10000,
350
+ duration_s,
351
+ transcript,
352
+ files_changed: filesChanged,
353
+ innerGuardsPassed: innerGuardsPassed && filesChanged.length === 0,
354
+ };
355
+ },
356
+ };
357
+ // ── System prompt ───────────────────────────────────
358
+ export function buildSystemPrompt(ctx, config, cwd) {
359
+ let guide = '';
360
+ let guideWordCount = 0;
361
+ const guidePath = join(cwd, config.agentGuide);
362
+ if (existsSync(guidePath)) {
363
+ const raw = readFileSync(guidePath, 'utf8');
364
+ guideWordCount = raw.split(/\s+/).length;
365
+ if (guideWordCount <= config.agentGuideMaxWords) {
366
+ guide = `\n\n## Agent Guide\n${raw}`;
367
+ }
368
+ }
369
+ const modules = ctx.ticket.modules;
370
+ const scopeSection = modules.length > 0
371
+ ? `\n## Allowed Files (scope)\nOnly modify files in or related to these modules:\n${modules.map((m) => `- ${m}`).join('\n')}\nYou may create new test files for these modules. Do NOT edit files outside this scope.`
372
+ : '';
373
+ // SLOPE sprint context (Layer 1) — hazards, common issues, handicap
374
+ let slopeContext = '';
375
+ try {
376
+ slopeContext = buildSlopeContext(ctx.ticket, config, cwd, guideWordCount);
377
+ if (slopeContext)
378
+ slopeContext = '\n\n' + slopeContext;
379
+ }
380
+ catch { /* non-blocking */ }
381
+ return `You are an autonomous coding agent working on the SLOPE project.
382
+ This is a TypeScript monorepo (pnpm, vitest, strict TypeScript).
383
+
384
+ ## Working Directory
385
+ ${cwd}
386
+
387
+ ## Ticket: ${ctx.ticketKey}
388
+ ${ctx.ticket.title}
389
+ ${scopeSection}
390
+
391
+ ## Rules
392
+ - ALWAYS read a file before editing it — understand existing patterns first
393
+ - Make real, substantive changes — never add only comments or whitespace
394
+ - Keep changes minimal and focused on this ticket only
395
+ - Do NOT edit files outside the allowed scope above
396
+ - After all changes, run: pnpm typecheck && pnpm test
397
+ - If tests fail, read the error output carefully before attempting a fix
398
+ - If stuck after multiple bash attempts, stop and re-read the relevant source files
399
+ - Do NOT run git commit — the system auto-commits after verification
400
+
401
+ ## Tools
402
+ - read_file: Read file contents (always do this first)
403
+ - edit_file: Surgical string replacement (preferred for changes)
404
+ - write_file: Create new files or full rewrites only
405
+ - bash: Shell commands (tests, typecheck, git, etc.)
406
+ - glob: Find files by pattern
407
+ - grep: Search file contents
408
+ - slope: Query SLOPE sprint data (briefing, search, card, map, etc.)${guide}${slopeContext}`;
409
+ }
410
+ // ── SLOPE Context Injection (Layer 1) ───────────────
411
+ /**
412
+ * Build SLOPE sprint context for injection into the system prompt.
413
+ * Each section is independently wrapped in try/catch for graceful degradation.
414
+ */
415
+ export function buildSlopeContext(ticket, config, cwd, guideWordCount = 0) {
416
+ const wordBudget = Math.min(2000, config.agentGuideMaxWords - guideWordCount - 500);
417
+ if (wordBudget <= 0)
418
+ return '';
419
+ const sections = [];
420
+ const keywords = extractKeywords(`${ticket.title} ${ticket.description} ${ticket.modules.join(' ')}`, 5);
421
+ let slopeConfig;
422
+ try {
423
+ slopeConfig = loadConfig(cwd);
424
+ }
425
+ catch {
426
+ return '';
427
+ }
428
+ let scorecards;
429
+ try {
430
+ scorecards = loadScorecards(slopeConfig, cwd);
431
+ }
432
+ catch {
433
+ scorecards = [];
434
+ }
435
+ // Section 1: Hazard briefing
436
+ try {
437
+ if (scorecards.length > 0) {
438
+ const hazards = extractHazardIndex(scorecards);
439
+ const recentHazards = hazards.shot_hazards
440
+ .filter(h => keywords.some(kw => h.description.toLowerCase().includes(kw)))
441
+ .slice(0, 5);
442
+ if (recentHazards.length > 0) {
443
+ sections.push('### Hazard Warnings');
444
+ for (const h of recentHazards) {
445
+ sections.push(`- [S${h.sprint}] ${h.type}: ${h.description}`);
446
+ }
447
+ }
448
+ }
449
+ }
450
+ catch { /* skip section */ }
451
+ // Section 2: Common issues
452
+ try {
453
+ const issuesPath = join(cwd, slopeConfig.commonIssuesPath);
454
+ if (existsSync(issuesPath)) {
455
+ const issues = JSON.parse(readFileSync(issuesPath, 'utf8'));
456
+ const filtered = filterCommonIssues(issues, { keywords });
457
+ if (filtered.length > 0) {
458
+ sections.push('### Known Gotchas');
459
+ for (const p of filtered.slice(0, 5)) {
460
+ sections.push(`- [${p.category}] ${p.title}`);
461
+ sections.push(` Prevention: ${p.prevention.slice(0, 120)}`);
462
+ }
463
+ }
464
+ }
465
+ }
466
+ catch { /* skip section */ }
467
+ // Section 3: Codebase map section
468
+ try {
469
+ const mapPath = join(cwd, 'CODEBASE.md');
470
+ if (existsSync(mapPath)) {
471
+ const mapContent = readFileSync(mapPath, 'utf8');
472
+ const relevantSection = extractMapSection(mapContent, ticket.modules);
473
+ if (relevantSection) {
474
+ sections.push('### Codebase Context');
475
+ sections.push(relevantSection);
476
+ }
477
+ }
478
+ }
479
+ catch { /* skip section */ }
480
+ // Section 4: Handicap snapshot
481
+ try {
482
+ if (scorecards.length > 0) {
483
+ const card = computeHandicapCard(scorecards);
484
+ const last5 = card.last_5;
485
+ sections.push('### Handicap Snapshot (last 5)');
486
+ sections.push(`- Handicap: +${last5.handicap.toFixed(1)}`);
487
+ sections.push(`- GIR: ${last5.gir_pct.toFixed(1)}%`);
488
+ sections.push(`- Avg Putts: ${last5.avg_putts.toFixed(1)}`);
489
+ sections.push(`- Penalties: ${last5.penalties_per_round.toFixed(1)}/round`);
490
+ const mp = last5.miss_pattern;
491
+ const totalMisses = mp.long + mp.short + mp.left + mp.right;
492
+ if (totalMisses > 0) {
493
+ const dirs = ['long', 'short', 'left', 'right']
494
+ .filter(d => mp[d] > 0)
495
+ .map(d => `${d}:${mp[d]}`);
496
+ sections.push(`- Miss pattern: ${dirs.join(' ')}`);
497
+ }
498
+ }
499
+ }
500
+ catch { /* skip section */ }
501
+ if (sections.length === 0)
502
+ return '';
503
+ let result = '## Sprint Context\n\n' + sections.join('\n');
504
+ const words = result.split(/\s+/);
505
+ if (words.length > wordBudget) {
506
+ result = words.slice(0, wordBudget).join(' ') + '\n...(truncated)';
507
+ }
508
+ return result;
509
+ }
510
+ /**
511
+ * Extract the relevant section from CODEBASE.md based on module paths.
512
+ */
513
+ export function extractMapSection(mapContent, modules) {
514
+ if (modules.length === 0)
515
+ return null;
516
+ const lines = mapContent.split('\n');
517
+ const matchedLines = [];
518
+ let capturing = false;
519
+ let captureDepth = 0;
520
+ for (const line of lines) {
521
+ const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
522
+ if (headingMatch) {
523
+ const depth = headingMatch[1].length;
524
+ const title = headingMatch[2].toLowerCase();
525
+ const matches = modules.some(mod => {
526
+ const parts = mod.split('/').filter(p => p.length > 2 && !p.includes('.'));
527
+ return parts.some(part => title.includes(part.toLowerCase()));
528
+ });
529
+ if (matches && !capturing) {
530
+ capturing = true;
531
+ captureDepth = depth;
532
+ matchedLines.push(line);
533
+ }
534
+ else if (capturing && depth <= captureDepth) {
535
+ capturing = false;
536
+ if (matches) {
537
+ capturing = true;
538
+ captureDepth = depth;
539
+ matchedLines.push(line);
540
+ }
541
+ }
542
+ else if (capturing) {
543
+ matchedLines.push(line);
544
+ }
545
+ }
546
+ else if (capturing) {
547
+ matchedLines.push(line);
548
+ }
549
+ }
550
+ const result = matchedLines.join('\n').trim();
551
+ return result.length > 0 ? result : null;
552
+ }
553
+ export function runTool(name, input, cwd, log) {
554
+ try {
555
+ switch (name) {
556
+ case 'read_file': return toolReadFile(input, cwd);
557
+ case 'write_file': return toolWriteFile(input, cwd);
558
+ case 'edit_file': return toolEditFile(input, cwd);
559
+ case 'bash': return toolBash(input, cwd);
560
+ case 'glob': return toolGlob(input, cwd);
561
+ case 'grep': return toolGrep(input, cwd);
562
+ case 'slope': return toolSlope(input, cwd);
563
+ default: return { output: `Unknown tool: ${name}`, isError: true };
564
+ }
565
+ }
566
+ catch (err) {
567
+ const msg = err instanceof Error ? err.message : String(err);
568
+ log.warn(`Tool ${name} error: ${msg}`);
569
+ return { output: msg, isError: true };
570
+ }
571
+ }
572
+ function toolReadFile(input, cwd) {
573
+ const abs = safePath(input.path, cwd);
574
+ if (!existsSync(abs))
575
+ return { output: `File not found: ${input.path}`, isError: true };
576
+ let content = readFileSync(abs, 'utf8');
577
+ if (content.length > FILE_READ_CAP) {
578
+ content = content.slice(0, FILE_READ_CAP) + '\n... (truncated at 100KB)';
579
+ }
580
+ return { output: content, isError: false };
581
+ }
582
+ function toolWriteFile(input, cwd) {
583
+ const abs = safePath(input.path, cwd);
584
+ mkdirSync(dirname(abs), { recursive: true });
585
+ writeFileSync(abs, input.content);
586
+ return { output: `Written: ${input.path}`, isError: false };
587
+ }
588
+ function toolEditFile(input, cwd) {
589
+ const abs = safePath(input.path, cwd);
590
+ if (!existsSync(abs))
591
+ return { output: `File not found: ${input.path}`, isError: true };
592
+ const content = readFileSync(abs, 'utf8');
593
+ const oldStr = input.old_string;
594
+ if (!content.includes(oldStr)) {
595
+ return {
596
+ output: `old_string not found in ${input.path}. Ensure exact match including whitespace and indentation.`,
597
+ isError: true,
598
+ };
599
+ }
600
+ writeFileSync(abs, content.replace(oldStr, input.new_string));
601
+ return { output: `Edited: ${input.path}`, isError: false };
602
+ }
603
+ function toolBash(input, cwd) {
604
+ const cmd = input.command;
605
+ // Block destructive commands — the model should not push (loop handles it),
606
+ // delete broad filesystem paths, or run system-level commands
607
+ const blocked = BLOCKED_COMMANDS.find(([re]) => re.test(cmd));
608
+ if (blocked) {
609
+ return { output: `Blocked: ${blocked[1]}`, isError: true };
610
+ }
611
+ try {
612
+ const output = execSync(cmd, {
613
+ cwd,
614
+ encoding: 'utf8',
615
+ timeout: TOOL_BASH_TIMEOUT,
616
+ maxBuffer: 1024 * 1024,
617
+ stdio: ['pipe', 'pipe', 'pipe'],
618
+ });
619
+ return {
620
+ output: (output.length > TOOL_OUTPUT_CAP
621
+ ? output.slice(0, TOOL_OUTPUT_CAP) + '\n... (truncated)'
622
+ : output) || '(no output)',
623
+ isError: false,
624
+ };
625
+ }
626
+ catch (err) {
627
+ const e = err;
628
+ const out = (e.stderr || '') + (e.stdout || '') || e.message || 'Command failed';
629
+ return {
630
+ output: typeof out === 'string' ? out.slice(0, TOOL_OUTPUT_CAP) : 'Command failed',
631
+ isError: true,
632
+ };
633
+ }
634
+ }
635
+ function toolGlob(input, cwd) {
636
+ const pattern = input.pattern;
637
+ try {
638
+ const output = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '--', pattern], { cwd, encoding: 'utf8', timeout: 10_000 });
639
+ const files = output.split('\n').filter(Boolean);
640
+ if (files.length === 0)
641
+ return { output: '(no matches)', isError: false };
642
+ return { output: files.slice(0, 200).join('\n'), isError: false };
643
+ }
644
+ catch {
645
+ return { output: '(no matches)', isError: false };
646
+ }
647
+ }
648
+ function toolGrep(input, cwd) {
649
+ const pattern = input.pattern;
650
+ const searchPath = input.path || '.';
651
+ const include = input.include;
652
+ try {
653
+ const args = ['-rn', '--color=never'];
654
+ if (include) {
655
+ args.push(`--include=${include}`);
656
+ }
657
+ else {
658
+ args.push('--include=*.ts', '--include=*.js', '--include=*.json', '--include=*.md', '--include=*.sh');
659
+ }
660
+ args.push('--', pattern, searchPath);
661
+ const output = execFileSync('grep', args, {
662
+ cwd,
663
+ encoding: 'utf8',
664
+ timeout: 15_000,
665
+ maxBuffer: 512 * 1024,
666
+ });
667
+ const lines = output.split('\n').filter(Boolean);
668
+ if (lines.length === 0)
669
+ return { output: '(no matches)', isError: false };
670
+ return { output: lines.slice(0, 100).join('\n'), isError: false };
671
+ }
672
+ catch {
673
+ // grep exit code 1 = no matches
674
+ return { output: '(no matches)', isError: false };
675
+ }
676
+ }
677
+ function toolSlope(input, cwd) {
678
+ const command = (input.command ?? '').trim();
679
+ const parts = command.split(/\s+/);
680
+ const subcommand = parts[0];
681
+ if (!subcommand || !SLOPE_ALLOWLIST.has(subcommand)) {
682
+ return {
683
+ output: `Command "${subcommand ?? ''}" not in allowlist. Use one of: ${[...SLOPE_ALLOWLIST].join(', ')}`,
684
+ isError: true,
685
+ };
686
+ }
687
+ try {
688
+ const output = execFileSync('pnpm', ['slope', ...parts], {
689
+ cwd,
690
+ encoding: 'utf8',
691
+ timeout: 30_000,
692
+ });
693
+ if (output.length > SLOPE_TOOL_OUTPUT_CAP) {
694
+ return { output: output.slice(0, SLOPE_TOOL_OUTPUT_CAP) + '\n...(output truncated)', isError: false };
695
+ }
696
+ return { output: output || '(no output)', isError: false };
697
+ }
698
+ catch (err) {
699
+ const msg = err instanceof Error ? err.message : String(err);
700
+ return { output: `slope ${command} failed: ${msg.slice(0, 200)}`, isError: true };
701
+ }
702
+ }
703
+ // ── Git helpers ─────────────────────────────────────
704
+ function gitCommit(ticketKey, title, cwd, log) {
705
+ try {
706
+ const status = execFileSync('git', ['status', '--porcelain'], {
707
+ cwd,
708
+ encoding: 'utf8',
709
+ }).trim();
710
+ if (!status)
711
+ return [];
712
+ const files = status.split('\n').map(l => l.slice(3).trim()).filter(Boolean);
713
+ execFileSync('git', ['add', '-A'], { cwd, stdio: 'pipe' });
714
+ execFileSync('git', ['commit', '-m', `${ticketKey}: ${title}`], {
715
+ cwd,
716
+ stdio: 'pipe',
717
+ });
718
+ log.info(`Committed: ${ticketKey}: ${title} (${files.length} files)`);
719
+ return files;
720
+ }
721
+ catch (err) {
722
+ log.warn(`Commit helper failed: ${err instanceof Error ? err.message : err}`);
723
+ return [];
724
+ }
725
+ }
726
+ /**
727
+ * Run typecheck + tests inside the executor loop, giving the model
728
+ * a chance to self-correct before the outer guards revert everything.
729
+ * Returns null on success, or the failure details.
730
+ */
731
+ function runInnerGuards(config, cwd, log) {
732
+ // Guard 1: Typecheck
733
+ try {
734
+ execSync('pnpm typecheck', { cwd, stdio: 'pipe', timeout: 120_000 });
735
+ log.info('Inner guard: typecheck passed');
736
+ }
737
+ catch (err) {
738
+ const e = err;
739
+ const output = ((e.stderr || '') + (e.stdout || '')).slice(0, 3000) || 'typecheck failed';
740
+ log.warn(`Inner guard failed: typecheck`);
741
+ return { guard: 'typecheck', output };
742
+ }
743
+ // Guard 2: Tests
744
+ try {
745
+ execSync(config.loopTestCmd, { cwd, stdio: 'pipe', timeout: 300_000 });
746
+ log.info('Inner guard: tests passed');
747
+ }
748
+ catch (err) {
749
+ const e = err;
750
+ const output = ((e.stderr || '') + (e.stdout || '')).slice(0, 3000) || 'tests failed';
751
+ log.warn(`Inner guard failed: tests`);
752
+ return { guard: 'tests', output };
753
+ }
754
+ return null;
755
+ }
756
+ // ── Message truncation ──────────────────────────────
757
+ /**
758
+ * Truncate old tool result contents to prevent context window overflow.
759
+ * Keeps the first message (task prompt) and recent turns fully intact.
760
+ * Older tool results are shrunk to 200 chars.
761
+ */
762
+ function truncateOldMessages(messages) {
763
+ if (messages.length <= TRUNCATE_KEEP_RECENT + 2)
764
+ return;
765
+ for (let i = 2; i < messages.length - TRUNCATE_KEEP_RECENT; i++) {
766
+ const msg = messages[i];
767
+ if (msg.role === 'user' && Array.isArray(msg.content)) {
768
+ for (const block of msg.content) {
769
+ if (block.type === 'tool_result' &&
770
+ typeof block.content === 'string' &&
771
+ block.content.length > 200) {
772
+ block.content = block.content.slice(0, 200) + '\n[truncated]';
773
+ }
774
+ }
775
+ }
776
+ }
777
+ }
778
+ // ── Utilities ───────────────────────────────────────
779
+ function sleep(ms) {
780
+ return new Promise(r => setTimeout(r, ms));
781
+ }
782
+ function errorResult(transcript, start) {
783
+ return {
784
+ outcome: 'error',
785
+ noop: false,
786
+ tokens_in: 0,
787
+ tokens_out: 0,
788
+ cost_usd: 0,
789
+ duration_s: Math.round((Date.now() - start) / 1000),
790
+ transcript,
791
+ files_changed: [],
792
+ };
793
+ }
794
+ //# sourceMappingURL=slope-executor.js.map