@promptwheel/mcp 0.6.0

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 (134) hide show
  1. package/README.md +178 -0
  2. package/dist/advance-helpers.d.ts +27 -0
  3. package/dist/advance-helpers.d.ts.map +1 -0
  4. package/dist/advance-helpers.js +127 -0
  5. package/dist/advance-helpers.js.map +1 -0
  6. package/dist/advance-prompts.d.ts +48 -0
  7. package/dist/advance-prompts.d.ts.map +1 -0
  8. package/dist/advance-prompts.js +420 -0
  9. package/dist/advance-prompts.js.map +1 -0
  10. package/dist/advance.d.ts +30 -0
  11. package/dist/advance.d.ts.map +1 -0
  12. package/dist/advance.js +752 -0
  13. package/dist/advance.js.map +1 -0
  14. package/dist/codebase-index.d.ts +5 -0
  15. package/dist/codebase-index.d.ts.map +1 -0
  16. package/dist/codebase-index.js +5 -0
  17. package/dist/codebase-index.js.map +1 -0
  18. package/dist/dedup-memory.d.ts +30 -0
  19. package/dist/dedup-memory.d.ts.map +1 -0
  20. package/dist/dedup-memory.js +76 -0
  21. package/dist/dedup-memory.js.map +1 -0
  22. package/dist/direct-client.d.ts +57 -0
  23. package/dist/direct-client.d.ts.map +1 -0
  24. package/dist/direct-client.js +92 -0
  25. package/dist/direct-client.js.map +1 -0
  26. package/dist/event-handlers-qa.d.ts +5 -0
  27. package/dist/event-handlers-qa.d.ts.map +1 -0
  28. package/dist/event-handlers-qa.js +186 -0
  29. package/dist/event-handlers-qa.js.map +1 -0
  30. package/dist/event-handlers-scout.d.ts +5 -0
  31. package/dist/event-handlers-scout.d.ts.map +1 -0
  32. package/dist/event-handlers-scout.js +270 -0
  33. package/dist/event-handlers-scout.js.map +1 -0
  34. package/dist/event-handlers-ticket.d.ts +5 -0
  35. package/dist/event-handlers-ticket.d.ts.map +1 -0
  36. package/dist/event-handlers-ticket.js +232 -0
  37. package/dist/event-handlers-ticket.js.map +1 -0
  38. package/dist/event-helpers.d.ts +46 -0
  39. package/dist/event-helpers.d.ts.map +1 -0
  40. package/dist/event-helpers.js +125 -0
  41. package/dist/event-helpers.js.map +1 -0
  42. package/dist/event-processor.d.ts +15 -0
  43. package/dist/event-processor.d.ts.map +1 -0
  44. package/dist/event-processor.js +111 -0
  45. package/dist/event-processor.js.map +1 -0
  46. package/dist/formulas.d.ts +27 -0
  47. package/dist/formulas.d.ts.map +1 -0
  48. package/dist/formulas.js +109 -0
  49. package/dist/formulas.js.map +1 -0
  50. package/dist/guidelines.d.ts +16 -0
  51. package/dist/guidelines.d.ts.map +1 -0
  52. package/dist/guidelines.js +44 -0
  53. package/dist/guidelines.js.map +1 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +42 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/learnings.d.ts +42 -0
  59. package/dist/learnings.d.ts.map +1 -0
  60. package/dist/learnings.js +117 -0
  61. package/dist/learnings.js.map +1 -0
  62. package/dist/project-metadata.d.ts +34 -0
  63. package/dist/project-metadata.d.ts.map +1 -0
  64. package/dist/project-metadata.js +617 -0
  65. package/dist/project-metadata.js.map +1 -0
  66. package/dist/proposals.d.ts +23 -0
  67. package/dist/proposals.d.ts.map +1 -0
  68. package/dist/proposals.js +201 -0
  69. package/dist/proposals.js.map +1 -0
  70. package/dist/qa-stats.d.ts +51 -0
  71. package/dist/qa-stats.d.ts.map +1 -0
  72. package/dist/qa-stats.js +121 -0
  73. package/dist/qa-stats.js.map +1 -0
  74. package/dist/run-manager.d.ts +97 -0
  75. package/dist/run-manager.d.ts.map +1 -0
  76. package/dist/run-manager.js +583 -0
  77. package/dist/run-manager.js.map +1 -0
  78. package/dist/run-state-bridge.d.ts +13 -0
  79. package/dist/run-state-bridge.d.ts.map +1 -0
  80. package/dist/run-state-bridge.js +101 -0
  81. package/dist/run-state-bridge.js.map +1 -0
  82. package/dist/scope-policy.d.ts +83 -0
  83. package/dist/scope-policy.d.ts.map +1 -0
  84. package/dist/scope-policy.js +278 -0
  85. package/dist/scope-policy.js.map +1 -0
  86. package/dist/server.d.ts +18 -0
  87. package/dist/server.d.ts.map +1 -0
  88. package/dist/server.js +35 -0
  89. package/dist/server.js.map +1 -0
  90. package/dist/spindle.d.ts +41 -0
  91. package/dist/spindle.d.ts.map +1 -0
  92. package/dist/spindle.js +230 -0
  93. package/dist/spindle.js.map +1 -0
  94. package/dist/state.d.ts +36 -0
  95. package/dist/state.d.ts.map +1 -0
  96. package/dist/state.js +50 -0
  97. package/dist/state.js.map +1 -0
  98. package/dist/ticket-worker.d.ts +37 -0
  99. package/dist/ticket-worker.d.ts.map +1 -0
  100. package/dist/ticket-worker.js +527 -0
  101. package/dist/ticket-worker.js.map +1 -0
  102. package/dist/tool-registry.d.ts +35 -0
  103. package/dist/tool-registry.d.ts.map +1 -0
  104. package/dist/tool-registry.js +129 -0
  105. package/dist/tool-registry.js.map +1 -0
  106. package/dist/tools/execute.d.ts +17 -0
  107. package/dist/tools/execute.d.ts.map +1 -0
  108. package/dist/tools/execute.js +418 -0
  109. package/dist/tools/execute.js.map +1 -0
  110. package/dist/tools/git.d.ts +7 -0
  111. package/dist/tools/git.d.ts.map +1 -0
  112. package/dist/tools/git.js +98 -0
  113. package/dist/tools/git.js.map +1 -0
  114. package/dist/tools/intelligence.d.ts +10 -0
  115. package/dist/tools/intelligence.d.ts.map +1 -0
  116. package/dist/tools/intelligence.js +432 -0
  117. package/dist/tools/intelligence.js.map +1 -0
  118. package/dist/tools/session.d.ts +7 -0
  119. package/dist/tools/session.d.ts.map +1 -0
  120. package/dist/tools/session.js +533 -0
  121. package/dist/tools/session.js.map +1 -0
  122. package/dist/tools/trajectory.d.ts +10 -0
  123. package/dist/tools/trajectory.d.ts.map +1 -0
  124. package/dist/tools/trajectory.js +374 -0
  125. package/dist/tools/trajectory.js.map +1 -0
  126. package/dist/trajectory-io.d.ts +21 -0
  127. package/dist/trajectory-io.d.ts.map +1 -0
  128. package/dist/trajectory-io.js +105 -0
  129. package/dist/trajectory-io.js.map +1 -0
  130. package/dist/types.d.ts +229 -0
  131. package/dist/types.d.ts.map +1 -0
  132. package/dist/types.js +13 -0
  133. package/dist/types.js.map +1 -0
  134. package/package.json +63 -0
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Advance Engine — the deterministic state machine.
3
+ *
4
+ * `advance()` is called by the client on every loop iteration.
5
+ * It returns what to do next (prompt + constraints) or STOP.
6
+ *
7
+ * State machine transitions (from docs/PLUGIN_ROADMAP.md):
8
+ *
9
+ * SCOUT → NEXT_TICKET | DONE | FAILED_BUDGET
10
+ * NEXT_TICKET → PLAN | SCOUT | DONE
11
+ * PLAN → EXECUTE | PLAN | BLOCKED_NEEDS_HUMAN | FAILED_BUDGET
12
+ * EXECUTE → QA | NEXT_TICKET | BLOCKED_NEEDS_HUMAN | FAILED_BUDGET | FAILED_SPINDLE
13
+ * QA → PR | EXECUTE | NEXT_TICKET | FAILED_BUDGET
14
+ * PR → NEXT_TICKET | FAILED_VALIDATION
15
+ */
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import { repos, EXECUTION_DEFAULTS } from '@promptwheel/core';
19
+ import { TERMINAL_PHASES } from './types.js';
20
+ import { deriveScopePolicy } from './scope-policy.js';
21
+ import { checkSpindle, getFileEditWarnings } from './spindle.js';
22
+ import { loadFormula } from './formulas.js';
23
+ import { loadGuidelines, formatGuidelinesForPrompt } from './guidelines.js';
24
+ import { detectProjectMetadata, formatMetadataForPrompt } from './project-metadata.js';
25
+ import { formatIndexForPrompt, refreshCodebaseIndex, hasStructuralChanges } from './codebase-index.js';
26
+ import { buildProposalReviewPrompt } from './proposals.js';
27
+ import { loadDedupMemory, formatDedupForPrompt } from './dedup-memory.js';
28
+ import { computeRetryRisk, scoreStrategies, buildCriticBlock, buildPlanRejectionCriticBlock, } from '@promptwheel/core/critic/shared';
29
+ import { pickNextSector as pickNextSectorCore, computeCoverage as computeCoverageCore, buildSectorSummary as buildSectorSummaryCore, } from '@promptwheel/core/sectors/shared';
30
+ import { getNextStep as getTrajectoryNextStep, formatTrajectoryForPrompt, } from '@promptwheel/core/trajectory/shared';
31
+ import { buildScoutPrompt, buildScoutEscalation, buildPlanPrompt, buildExecutePrompt, buildQaPrompt, buildPrPrompt, buildInlineTicketPrompt, } from './advance-prompts.js';
32
+ import { loadTrajectoryData, loadSectorsState, buildLearningsBlock, buildRiskContextBlock, getScoutAutoApprove, getExecuteAutoApprove, getQaAutoApprove, getPrAutoApprove, } from './advance-helpers.js';
33
+ const MAX_PLAN_REJECTIONS = 3;
34
+ const MAX_QA_RETRIES = EXECUTION_DEFAULTS.MAX_QA_RETRIES;
35
+ const MAX_SPINDLE_RECOVERIES = 3;
36
+ /**
37
+ * Core advance function. Called once per loop iteration.
38
+ * Returns the next action for the client to perform.
39
+ */
40
+ export async function advance(ctx) {
41
+ const { run, db } = ctx;
42
+ const s = run.require();
43
+ // Increment step
44
+ run.incrementStep();
45
+ run.appendEvent('ADVANCE_CALLED', { phase: s.phase, step: s.step_count });
46
+ // -----------------------------------------------------------------------
47
+ // Budget checks (run before any phase logic)
48
+ // -----------------------------------------------------------------------
49
+ const budgetResult = checkBudgets(run);
50
+ if (budgetResult) {
51
+ return budgetResult;
52
+ }
53
+ // -----------------------------------------------------------------------
54
+ // Fire budget warnings at 80%
55
+ // -----------------------------------------------------------------------
56
+ const warnings = run.getBudgetWarnings();
57
+ if (warnings.length > 0) {
58
+ run.appendEvent('BUDGET_WARNING', { warnings });
59
+ }
60
+ // -----------------------------------------------------------------------
61
+ // Spindle check (only in EXECUTE/QA — active work phases)
62
+ // -----------------------------------------------------------------------
63
+ if (s.phase === 'EXECUTE' || s.phase === 'QA') {
64
+ const spindleResult = checkSpindle(s.spindle);
65
+ // File edit frequency warnings
66
+ const fileWarnings = getFileEditWarnings(s.spindle);
67
+ if (fileWarnings.length > 0) {
68
+ run.appendEvent('SPINDLE_WARNING', { file_edit_warnings: fileWarnings });
69
+ }
70
+ if (spindleResult.shouldAbort) {
71
+ run.appendEvent('SPINDLE_ABORT', {
72
+ reason: spindleResult.reason,
73
+ confidence: spindleResult.confidence,
74
+ diagnostics: spindleResult.diagnostics,
75
+ });
76
+ if (s.current_ticket_id) {
77
+ await repos.tickets.updateStatus(db, s.current_ticket_id, 'blocked');
78
+ run.failTicket(`Spindle abort: ${spindleResult.reason}`);
79
+ }
80
+ s.spindle_recoveries++;
81
+ if (s.spindle_recoveries >= MAX_SPINDLE_RECOVERIES) {
82
+ run.setPhase('FAILED_SPINDLE');
83
+ return stopResponse(run, 'FAILED_SPINDLE', `Spindle loop detected: ${spindleResult.reason} (confidence: ${(spindleResult.confidence * 100).toFixed(0)}%) — recovery cap reached (${s.spindle_recoveries}/${MAX_SPINDLE_RECOVERIES})`);
84
+ }
85
+ run.resetForSpindleRecovery();
86
+ run.setPhase('NEXT_TICKET');
87
+ return advance(ctx);
88
+ }
89
+ if (spindleResult.shouldBlock) {
90
+ run.appendEvent('SPINDLE_WARNING', {
91
+ reason: spindleResult.reason,
92
+ diagnostics: spindleResult.diagnostics,
93
+ action: 'BLOCKED_NEEDS_HUMAN',
94
+ });
95
+ if (s.current_ticket_id) {
96
+ await repos.tickets.updateStatus(db, s.current_ticket_id, 'blocked');
97
+ run.failTicket(`Spindle block: ${spindleResult.reason}`);
98
+ }
99
+ s.spindle_recoveries++;
100
+ if (s.spindle_recoveries >= MAX_SPINDLE_RECOVERIES) {
101
+ run.setPhase('BLOCKED_NEEDS_HUMAN');
102
+ return stopResponse(run, 'BLOCKED_NEEDS_HUMAN', `Spindle: ${spindleResult.reason} — recovery cap reached (${s.spindle_recoveries}/${MAX_SPINDLE_RECOVERIES}). Needs human intervention.`);
103
+ }
104
+ run.resetForSpindleRecovery();
105
+ run.setPhase('NEXT_TICKET');
106
+ return advance(ctx);
107
+ }
108
+ }
109
+ // -----------------------------------------------------------------------
110
+ // Terminal state check
111
+ // -----------------------------------------------------------------------
112
+ if (TERMINAL_PHASES.has(s.phase)) {
113
+ return stopResponse(run, s.phase, terminalReason(s.phase));
114
+ }
115
+ // -----------------------------------------------------------------------
116
+ // Phase dispatch
117
+ // -----------------------------------------------------------------------
118
+ switch (s.phase) {
119
+ case 'SCOUT':
120
+ return advanceScout(ctx);
121
+ case 'NEXT_TICKET':
122
+ return advanceNextTicket(ctx);
123
+ case 'PLAN':
124
+ return advancePlan(ctx);
125
+ case 'EXECUTE':
126
+ return advanceExecute(ctx);
127
+ case 'QA':
128
+ return advanceQa(ctx);
129
+ case 'PR':
130
+ return advancePr(ctx);
131
+ case 'PARALLEL_EXECUTE':
132
+ return advanceParallelExecute(ctx);
133
+ default:
134
+ return stopResponse(run, 'FAILED_VALIDATION', `Unknown phase: ${s.phase}`);
135
+ }
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // Phase handlers
139
+ // ---------------------------------------------------------------------------
140
+ async function advanceScout(ctx) {
141
+ const { run, db } = ctx;
142
+ const s = run.require();
143
+ // If skip_review is on and stale pending_proposals exist, clear them
144
+ if (s.skip_review && s.pending_proposals !== null) {
145
+ s.pending_proposals = null;
146
+ }
147
+ // If pending proposals exist, return adversarial review prompt
148
+ if (!s.skip_review && s.pending_proposals !== null) {
149
+ // Convert raw proposals to ValidatedProposal shape for the review prompt
150
+ const forReview = s.pending_proposals.map(p => ({
151
+ category: p.category ?? 'unknown',
152
+ title: p.title ?? 'Untitled',
153
+ description: p.description ?? '',
154
+ acceptance_criteria: p.acceptance_criteria ?? [],
155
+ verification_commands: p.verification_commands ?? [],
156
+ allowed_paths: p.allowed_paths ?? [],
157
+ files: p.files ?? [],
158
+ confidence: p.confidence ?? 0,
159
+ impact_score: p.impact_score ?? 5,
160
+ rationale: p.rationale ?? '',
161
+ estimated_complexity: p.estimated_complexity ?? 'moderate',
162
+ risk: p.risk ?? 'medium',
163
+ touched_files_estimate: p.touched_files_estimate ?? 0,
164
+ rollback_note: p.rollback_note ?? '',
165
+ }));
166
+ const reviewPrompt = buildProposalReviewPrompt(forReview);
167
+ return promptResponse(run, 'SCOUT', reviewPrompt, 'Reviewing proposals adversarially', {
168
+ allowed_paths: [s.scope],
169
+ denied_paths: [],
170
+ denied_patterns: [],
171
+ max_files: 0,
172
+ max_lines: 0,
173
+ required_commands: [],
174
+ plan_required: false,
175
+ auto_approve_patterns: getScoutAutoApprove(),
176
+ });
177
+ }
178
+ // Check if we already have ready tickets in backlog
179
+ const readyTickets = await repos.tickets.listByProject(db, s.project_id, { status: 'ready', limit: 1 });
180
+ if (readyTickets.length > 0) {
181
+ // Tickets exist, move to assignment
182
+ run.setPhase('NEXT_TICKET');
183
+ return advance(ctx);
184
+ }
185
+ // No ready tickets — return scout prompt for client to execute
186
+ const recentTickets = await repos.tickets.getRecentlyCompleted(db, s.project_id, 20);
187
+ const dedupContext = recentTickets.map(t => t.title);
188
+ // Load dedup memory (with decay) — weighted, persistent awareness of completed work
189
+ const dedupMemory = loadDedupMemory(ctx.project.rootPath);
190
+ const dedupMemoryBlock = formatDedupForPrompt(dedupMemory);
191
+ const dedupBlock = dedupMemoryBlock ? dedupMemoryBlock + '\n\n' : '';
192
+ const hints = run.consumeHints();
193
+ // Load formula if specified
194
+ const formula = s.formula ? loadFormula(s.formula, ctx.project.rootPath) : null;
195
+ const guidelines = loadGuidelines(ctx.project.rootPath);
196
+ const guidelinesBlock = guidelines ? formatGuidelinesForPrompt(guidelines) + '\n\n' : '';
197
+ // Detect project metadata for tooling context
198
+ const projectMeta = detectProjectMetadata(ctx.project.rootPath);
199
+ const metadataBlock = formatMetadataForPrompt(projectMeta) + '\n\n';
200
+ // Refresh codebase index if structure changed (dirty flag from tickets, or external mtime check)
201
+ if (s.codebase_index) {
202
+ const needsRefresh = s.codebase_index_dirty
203
+ || hasStructuralChanges(s.codebase_index, ctx.project.rootPath);
204
+ if (needsRefresh) {
205
+ s.codebase_index = refreshCodebaseIndex(s.codebase_index, ctx.project.rootPath, s.scout_exclude_dirs);
206
+ s.codebase_index_dirty = false;
207
+ }
208
+ }
209
+ // Sector rotation: use core pickNextSector for full 9-tiebreaker sort
210
+ // Skip when a trajectory is active — trajectory scope takes priority
211
+ if (!s.active_trajectory) {
212
+ try {
213
+ const sectorsState = loadSectorsState(ctx.project.rootPath);
214
+ if (sectorsState) {
215
+ const picked = pickNextSectorCore(sectorsState, s.scout_cycles);
216
+ if (picked) {
217
+ s.scope = picked.scope;
218
+ s.selected_sector_path = picked.sector.path;
219
+ }
220
+ }
221
+ }
222
+ catch (err) {
223
+ run.appendEvent('SECTOR_ROTATION_FAILED', { error: err instanceof Error ? err.message : String(err) });
224
+ }
225
+ }
226
+ // Build codebase index block — use cycles + retries so retries advance the chunk
227
+ const chunkOffset = s.scout_cycles + s.scout_retries;
228
+ const indexBlock = s.codebase_index ? formatIndexForPrompt(s.codebase_index, chunkOffset) + '\n\n' : '';
229
+ // Build escalation block if retrying after 0 proposals
230
+ const escalationBlock = s.scout_retries > 0
231
+ ? buildScoutEscalation(s.scout_retries, s.scout_exploration_log, s.scouted_dirs, s.codebase_index) + '\n\n'
232
+ : '';
233
+ const learningsBlock = buildLearningsBlock(run, [s.scope, ...s.scouted_dirs], []);
234
+ const cov = run.buildDigest().coverage;
235
+ let sectorSummary;
236
+ let sectorPercent;
237
+ try {
238
+ const sectorsState = loadSectorsState(ctx.project.rootPath);
239
+ if (sectorsState) {
240
+ const metrics = computeCoverageCore(sectorsState);
241
+ sectorPercent = metrics.sectorPercent;
242
+ sectorSummary = buildSectorSummaryCore(sectorsState, s.selected_sector_path ?? '');
243
+ }
244
+ }
245
+ catch (err) {
246
+ console.warn(`[promptwheel] Sector summary failed: ${err instanceof Error ? err.message : String(err)}`);
247
+ }
248
+ const coverageCtx = cov.sectors_total > 0
249
+ ? { scannedSectors: cov.sectors_scanned, totalSectors: cov.sectors_total, percent: cov.percent, sectorPercent, sectorSummary }
250
+ : undefined;
251
+ // Trajectory context
252
+ let trajectoryBlock = '';
253
+ try {
254
+ const trajData = loadTrajectoryData(ctx.project.rootPath);
255
+ if (trajData) {
256
+ const currentStep = getTrajectoryNextStep(trajData.trajectory, trajData.state.stepStates);
257
+ if (currentStep) {
258
+ trajectoryBlock = formatTrajectoryForPrompt(trajData.trajectory, trajData.state.stepStates, currentStep) + '\n\n';
259
+ s.active_trajectory = trajData.trajectory.name;
260
+ s.trajectory_step_id = currentStep.id;
261
+ s.trajectory_step_title = currentStep.title;
262
+ // Override scope and categories from step
263
+ if (currentStep.scope)
264
+ s.scope = currentStep.scope;
265
+ if (currentStep.categories && currentStep.categories.length > 0) {
266
+ s.categories = currentStep.categories;
267
+ }
268
+ }
269
+ }
270
+ }
271
+ catch (err) {
272
+ console.warn(`[promptwheel] Trajectory load failed: ${err instanceof Error ? err.message : String(err)}`);
273
+ }
274
+ const prompt = guidelinesBlock + metadataBlock + indexBlock + dedupBlock + trajectoryBlock + learningsBlock + escalationBlock + buildScoutPrompt(s.scope, s.categories, s.min_confidence, s.max_proposals_per_scout, dedupContext, formula, hints, s.eco, s.min_impact_score, s.scouted_dirs, s.scout_exclude_dirs, coverageCtx);
275
+ // Reset scout_retries at the start of a fresh cycle (non-retry entry)
276
+ if (s.scout_retries === 0) {
277
+ s.scout_cycles++;
278
+ }
279
+ run.appendEvent('ADVANCE_RETURNED', { phase: 'SCOUT', has_prompt: true });
280
+ return promptResponse(run, 'SCOUT', prompt, 'Scouting for improvements', {
281
+ allowed_paths: [s.scope],
282
+ denied_paths: [],
283
+ denied_patterns: [],
284
+ max_files: 100,
285
+ max_lines: 0,
286
+ required_commands: [],
287
+ plan_required: false,
288
+ auto_approve_patterns: getScoutAutoApprove(),
289
+ });
290
+ }
291
+ async function advanceNextTicket(ctx) {
292
+ const { run, db } = ctx;
293
+ const s = run.require();
294
+ // Dry-run mode — stop after scouting, don't execute tickets
295
+ if (s.dry_run) {
296
+ run.setPhase('DONE');
297
+ return stopResponse(run, 'DONE', 'Dry-run mode — scout complete, no tickets executed.');
298
+ }
299
+ // Ensure learnings are loaded for scope policy derivation
300
+ run.ensureLearningsLoaded();
301
+ // Check if we've hit PR limit (only when creating PRs)
302
+ if (s.create_prs && s.prs_created >= s.max_prs) {
303
+ run.setPhase('DONE');
304
+ return stopResponse(run, 'DONE', `PR limit reached (${s.prs_created}/${s.max_prs})`);
305
+ }
306
+ const parallelCount = s.create_prs
307
+ ? Math.min(s.parallel, s.max_prs - s.prs_created)
308
+ : s.parallel;
309
+ // Find ready tickets
310
+ const readyTickets = await repos.tickets.listByProject(db, s.project_id, { status: 'ready', limit: parallelCount });
311
+ if (readyTickets.length === 0) {
312
+ // No more tickets — scout again if cycles remain, otherwise finish
313
+ if (s.scout_cycles < s.max_cycles) {
314
+ run.setPhase('SCOUT');
315
+ return advance(ctx);
316
+ }
317
+ run.setPhase('DONE');
318
+ const cov = run.buildDigest().coverage;
319
+ const covSuffix = cov.sectors_total > 0
320
+ ? ` (${cov.sectors_scanned}/${cov.sectors_total} sectors scanned, ${cov.percent}% coverage)`
321
+ : '';
322
+ return stopResponse(run, 'DONE', `No more tickets to process${covSuffix}`);
323
+ }
324
+ // If parallel > 1 and multiple tickets ready → dispatch batch
325
+ if (parallelCount > 1 && readyTickets.length > 1) {
326
+ const parallelTickets = [];
327
+ for (const ticket of readyTickets) {
328
+ await repos.tickets.updateStatus(db, ticket.id, 'in_progress');
329
+ run.initTicketWorker(ticket.id, ticket);
330
+ await repos.runs.create(db, {
331
+ projectId: s.project_id,
332
+ type: 'worker',
333
+ ticketId: ticket.id,
334
+ });
335
+ const policy = deriveScopePolicy({
336
+ allowedPaths: ticket.allowedPaths ?? [],
337
+ category: ticket.category ?? 'refactor',
338
+ maxLinesPerTicket: s.max_lines_per_ticket,
339
+ learnings: s.cached_learnings,
340
+ });
341
+ parallelTickets.push({
342
+ ticket_id: ticket.id,
343
+ title: ticket.title,
344
+ description: ticket.description ?? '',
345
+ constraints: {
346
+ allowed_paths: policy.allowed_paths,
347
+ denied_paths: policy.denied_paths,
348
+ denied_patterns: policy.denied_patterns.map(r => r.source),
349
+ max_files: policy.max_files,
350
+ max_lines: policy.max_lines,
351
+ required_commands: ticket.verificationCommands ?? [],
352
+ plan_required: policy.plan_required,
353
+ auto_approve_patterns: getExecuteAutoApprove(ticket.category ?? null),
354
+ },
355
+ inline_prompt: '', // filled below
356
+ });
357
+ }
358
+ run.setPhase('PARALLEL_EXECUTE');
359
+ // Build inline prompts for each ticket — subagents are self-contained
360
+ const guidelines = loadGuidelines(ctx.project.rootPath);
361
+ const guidelinesBlock = guidelines ? formatGuidelinesForPrompt(guidelines) + '\n\n' : '';
362
+ const projectMeta = detectProjectMetadata(ctx.project.rootPath);
363
+ const metadataBlock = formatMetadataForPrompt(projectMeta) + '\n\n';
364
+ // Read project setup command and QA baseline from .promptwheel/
365
+ let setupCommand;
366
+ let baselineFailures = [];
367
+ try {
368
+ const configPath = path.join(ctx.project.rootPath, '.promptwheel', 'config.json');
369
+ if (fs.existsSync(configPath)) {
370
+ const configData = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
371
+ setupCommand = configData.setup;
372
+ }
373
+ }
374
+ catch (err) {
375
+ console.warn(`[promptwheel] Failed to read config.json: ${err instanceof Error ? err.message : String(err)}`);
376
+ }
377
+ try {
378
+ const baselinePath = path.join(ctx.project.rootPath, '.promptwheel', 'qa-baseline.json');
379
+ if (fs.existsSync(baselinePath)) {
380
+ const data = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
381
+ baselineFailures = data.failures ?? [];
382
+ }
383
+ }
384
+ catch (err) {
385
+ console.warn(`[promptwheel] Failed to read qa-baseline.json: ${err instanceof Error ? err.message : String(err)}`);
386
+ }
387
+ for (const pt of parallelTickets) {
388
+ const ticket = readyTickets.find(t => t.id === pt.ticket_id);
389
+ pt.inline_prompt = buildInlineTicketPrompt(ticket, pt.constraints, guidelinesBlock, metadataBlock, s.create_prs, s.draft, s.direct, setupCommand, baselineFailures);
390
+ }
391
+ // Build orchestration prompt for the main agent
392
+ const ticketList = parallelTickets.map((t, i) => `${i + 1}. **${t.title}** (ID: \`${t.ticket_id}\`)`).join('\n');
393
+ const orchestrationPrompt = [
394
+ `# Parallel Execution — ${parallelTickets.length} tickets`,
395
+ '',
396
+ ticketList,
397
+ '',
398
+ '## Instructions',
399
+ '',
400
+ 'Use the **Task tool** to spawn one subagent per ticket. Send ALL Task calls in a **single message** for concurrency.',
401
+ '',
402
+ 'For each ticket in `parallel_tickets`:',
403
+ '```',
404
+ 'Task({',
405
+ ' subagent_type: "general-purpose",',
406
+ ' description: "Ticket: <title>",',
407
+ ' prompt: parallel_tickets[i].inline_prompt',
408
+ '})',
409
+ '```',
410
+ '',
411
+ 'The `inline_prompt` field contains everything the subagent needs — no MCP tools required.',
412
+ 'Subagents will edit code, run tests, commit, push, and create PRs independently.',
413
+ '',
414
+ '## After All Subagents Return',
415
+ '',
416
+ 'For each subagent result, call `promptwheel_ticket_event` to record the outcome:',
417
+ ...(s.create_prs
418
+ ? ['- Success: `type: "PR_CREATED"`, `payload: { ticket_id, url, branch }`']
419
+ : ['- Success: `type: "TICKET_RESULT"`, `payload: { ticket_id, status: "success", changed_files: [...] }`']),
420
+ '- Failure: `type: "TICKET_RESULT"`, `payload: { ticket_id, status: "failed", reason: "..." }`',
421
+ '',
422
+ 'Then call `promptwheel_advance` to continue.',
423
+ ].join('\n');
424
+ return {
425
+ next_action: 'PARALLEL_EXECUTE',
426
+ phase: 'PARALLEL_EXECUTE',
427
+ prompt: orchestrationPrompt,
428
+ reason: `Dispatching ${parallelTickets.length} tickets for parallel execution`,
429
+ constraints: emptyConstraints(),
430
+ digest: run.buildDigest(),
431
+ parallel_tickets: parallelTickets,
432
+ };
433
+ }
434
+ // Sequential flow (parallel=1 or only 1 ticket)
435
+ const ticket = readyTickets[0];
436
+ // Assign ticket
437
+ await repos.tickets.updateStatus(db, ticket.id, 'in_progress');
438
+ run.assignTicket(ticket.id);
439
+ // Create worker run record
440
+ await repos.runs.create(db, {
441
+ projectId: s.project_id,
442
+ type: 'worker',
443
+ ticketId: ticket.id,
444
+ });
445
+ // Derive scope policy for this ticket
446
+ const policy = deriveScopePolicy({
447
+ allowedPaths: ticket.allowedPaths ?? [],
448
+ category: ticket.category ?? 'refactor',
449
+ maxLinesPerTicket: s.max_lines_per_ticket,
450
+ learnings: s.cached_learnings,
451
+ });
452
+ const constraints = {
453
+ allowed_paths: policy.allowed_paths,
454
+ denied_paths: policy.denied_paths,
455
+ denied_patterns: policy.denied_patterns.map(r => r.source),
456
+ max_files: policy.max_files,
457
+ max_lines: policy.max_lines,
458
+ required_commands: ticket.verificationCommands ?? [],
459
+ plan_required: policy.plan_required,
460
+ auto_approve_patterns: getScoutAutoApprove(),
461
+ };
462
+ // Docs category: skip plan, go straight to execute
463
+ if (!policy.plan_required) {
464
+ s.plan_approved = true;
465
+ run.setPhase('EXECUTE');
466
+ return advanceExecute(ctx);
467
+ }
468
+ // Move to PLAN phase — require commit plan before execution
469
+ run.setPhase('PLAN');
470
+ const prompt = buildPlanPrompt(ticket);
471
+ return promptResponse(run, 'PLAN', prompt, `Planning ticket: ${ticket.title}`, { ...constraints, auto_approve_patterns: getScoutAutoApprove() });
472
+ }
473
+ async function advancePlan(ctx) {
474
+ const { run, db } = ctx;
475
+ const s = run.require();
476
+ // If plan is already approved, move to execute
477
+ if (s.plan_approved) {
478
+ run.setPhase('EXECUTE');
479
+ return advanceExecute(ctx);
480
+ }
481
+ // If too many rejections, block the ticket
482
+ if (s.plan_rejections >= MAX_PLAN_REJECTIONS) {
483
+ run.appendEvent('TICKET_FAILED', {
484
+ ticket_id: s.current_ticket_id,
485
+ reason: 'Plan rejected too many times',
486
+ });
487
+ if (s.current_ticket_id) {
488
+ await repos.tickets.updateStatus(db, s.current_ticket_id, 'blocked');
489
+ }
490
+ s.tickets_blocked++;
491
+ s.current_ticket_id = null;
492
+ s.current_ticket_plan = null;
493
+ run.setPhase('BLOCKED_NEEDS_HUMAN');
494
+ return stopResponse(run, 'BLOCKED_NEEDS_HUMAN', `Commit plan rejected ${MAX_PLAN_REJECTIONS} times. Needs human review.`);
495
+ }
496
+ // Request a commit plan from the client
497
+ const ticket = s.current_ticket_id
498
+ ? await repos.tickets.getById(db, s.current_ticket_id)
499
+ : null;
500
+ if (!ticket) {
501
+ run.setPhase('NEXT_TICKET');
502
+ return advance(ctx);
503
+ }
504
+ const policy = deriveScopePolicy({
505
+ allowedPaths: ticket.allowedPaths ?? [],
506
+ category: ticket.category ?? 'refactor',
507
+ maxLinesPerTicket: s.max_lines_per_ticket,
508
+ learnings: s.cached_learnings,
509
+ });
510
+ const learningsBlock = buildLearningsBlock(run, ticket.allowedPaths ?? [], ticket.verificationCommands ?? []);
511
+ const riskBlock = buildRiskContextBlock(policy.risk_assessment);
512
+ let basePlanPrompt;
513
+ if (s.plan_rejections > 0) {
514
+ const planCriticBlock = buildPlanRejectionCriticBlock({
515
+ rejection_reason: s.last_plan_rejection_reason ?? 'Plan did not pass scope validation',
516
+ attempt: s.plan_rejections + 1,
517
+ max_attempts: MAX_PLAN_REJECTIONS,
518
+ }, s.cached_learnings, ticket.allowedPaths ?? []);
519
+ const preamble = planCriticBlock
520
+ ? `${planCriticBlock}\n\n`
521
+ : `Your previous commit plan was rejected: ${s.last_plan_rejection_reason ?? 'scope violation'}. Please revise.\n\n`;
522
+ basePlanPrompt = preamble + buildPlanPrompt(ticket);
523
+ }
524
+ else {
525
+ basePlanPrompt = buildPlanPrompt(ticket);
526
+ }
527
+ const prompt = learningsBlock + riskBlock + basePlanPrompt;
528
+ return promptResponse(run, 'PLAN', prompt, s.plan_rejections > 0
529
+ ? `Re-planning (attempt ${s.plan_rejections + 1}/${MAX_PLAN_REJECTIONS})`
530
+ : `Awaiting commit plan for: ${ticket.title}`, {
531
+ allowed_paths: policy.allowed_paths,
532
+ denied_paths: policy.denied_paths,
533
+ denied_patterns: policy.denied_patterns.map(r => r.source),
534
+ max_files: policy.max_files,
535
+ max_lines: policy.max_lines,
536
+ required_commands: ticket.verificationCommands ?? [],
537
+ plan_required: policy.plan_required,
538
+ auto_approve_patterns: getScoutAutoApprove(),
539
+ });
540
+ }
541
+ async function advanceExecute(ctx) {
542
+ const { run, db } = ctx;
543
+ const s = run.require();
544
+ // Check ticket step budget
545
+ if (s.ticket_step_count >= s.ticket_step_budget) {
546
+ if (s.current_ticket_id) {
547
+ await repos.tickets.updateStatus(db, s.current_ticket_id, 'blocked');
548
+ run.failTicket('Ticket step budget exhausted');
549
+ }
550
+ run.setPhase('BLOCKED_NEEDS_HUMAN');
551
+ return stopResponse(run, 'BLOCKED_NEEDS_HUMAN', `Ticket step budget exhausted (${s.ticket_step_count}/${s.ticket_step_budget})`);
552
+ }
553
+ const ticket = s.current_ticket_id
554
+ ? await repos.tickets.getById(db, s.current_ticket_id)
555
+ : null;
556
+ if (!ticket) {
557
+ run.setPhase('NEXT_TICKET');
558
+ return advance(ctx);
559
+ }
560
+ const policy = deriveScopePolicy({
561
+ allowedPaths: ticket.allowedPaths ?? [],
562
+ category: ticket.category ?? 'refactor',
563
+ maxLinesPerTicket: s.max_lines_per_ticket,
564
+ learnings: s.cached_learnings,
565
+ });
566
+ const guidelines = loadGuidelines(ctx.project.rootPath);
567
+ const guidelinesBlock = guidelines ? formatGuidelinesForPrompt(guidelines) + '\n\n' : '';
568
+ const learningsBlock = buildLearningsBlock(run, ticket.allowedPaths ?? [], ticket.verificationCommands ?? []);
569
+ const riskBlock = buildRiskContextBlock(policy.risk_assessment);
570
+ // Build critic block for QA retries
571
+ let criticBlock = '';
572
+ if (s.qa_retries > 0 && s.last_qa_failure) {
573
+ const failureContext = {
574
+ failed_commands: s.last_qa_failure.failed_commands,
575
+ error_output: s.last_qa_failure.error_output,
576
+ attempt: s.qa_retries + 1,
577
+ max_attempts: MAX_QA_RETRIES,
578
+ };
579
+ const risk = computeRetryRisk(ticket.allowedPaths ?? [], ticket.verificationCommands ?? [], s.cached_learnings, failureContext);
580
+ const strategies = scoreStrategies(ticket.allowedPaths ?? [], failureContext, s.cached_learnings);
581
+ criticBlock = buildCriticBlock(failureContext, risk, strategies, s.cached_learnings);
582
+ if (criticBlock)
583
+ criticBlock += '\n\n';
584
+ }
585
+ const prompt = guidelinesBlock + learningsBlock + riskBlock + criticBlock + buildExecutePrompt(ticket, s.current_ticket_plan);
586
+ return promptResponse(run, 'EXECUTE', prompt, `Executing ticket: ${ticket.title}`, {
587
+ allowed_paths: policy.allowed_paths,
588
+ denied_paths: policy.denied_paths,
589
+ denied_patterns: policy.denied_patterns.map(r => r.source),
590
+ max_files: policy.max_files,
591
+ max_lines: policy.max_lines,
592
+ required_commands: ticket.verificationCommands ?? [],
593
+ plan_required: false,
594
+ auto_approve_patterns: getExecuteAutoApprove(ticket.category ?? null),
595
+ });
596
+ }
597
+ async function advanceQa(ctx) {
598
+ const { run, db } = ctx;
599
+ const s = run.require();
600
+ // If QA retries exceeded, give up on this ticket
601
+ if (s.qa_retries >= MAX_QA_RETRIES) {
602
+ if (s.current_ticket_id) {
603
+ await repos.tickets.updateStatus(db, s.current_ticket_id, 'blocked');
604
+ run.failTicket(`QA failed ${MAX_QA_RETRIES} times`);
605
+ }
606
+ run.setPhase('NEXT_TICKET');
607
+ return advance(ctx);
608
+ }
609
+ const ticket = s.current_ticket_id
610
+ ? await repos.tickets.getById(db, s.current_ticket_id)
611
+ : null;
612
+ if (!ticket) {
613
+ run.setPhase('NEXT_TICKET');
614
+ return advance(ctx);
615
+ }
616
+ // Merge session-level qa_commands with ticket verification commands
617
+ const ticketCommands = ticket.verificationCommands ?? [];
618
+ const sessionQaCommands = s.qa_commands ?? [];
619
+ const allCommands = [...new Set([...ticketCommands, ...sessionQaCommands])];
620
+ const qaTicket = allCommands.length !== ticketCommands.length
621
+ ? { ...ticket, verificationCommands: allCommands }
622
+ : ticket;
623
+ const learningsBlock = buildLearningsBlock(run, [], allCommands);
624
+ const crossVerifyPreamble = s.cross_verify
625
+ ? '## IMPORTANT — Independent Verification\n\nYou are verifying work as an INDEPENDENT verifier. Do NOT trust any prior claims of success. Run ALL commands yourself and report results honestly.\n\n'
626
+ : '';
627
+ const prompt = crossVerifyPreamble + learningsBlock + buildQaPrompt(qaTicket);
628
+ return promptResponse(run, 'QA', prompt, `Running QA for: ${ticket.title} (attempt ${s.qa_retries + 1}/${MAX_QA_RETRIES})`, {
629
+ allowed_paths: ticket.allowedPaths ?? [],
630
+ denied_paths: [],
631
+ denied_patterns: [],
632
+ max_files: 0,
633
+ max_lines: 0,
634
+ required_commands: allCommands,
635
+ plan_required: false,
636
+ auto_approve_patterns: getQaAutoApprove(),
637
+ });
638
+ }
639
+ async function advancePr(ctx) {
640
+ const { run } = ctx;
641
+ const s = run.require();
642
+ const ticket = s.current_ticket_id
643
+ ? await repos.tickets.getById(ctx.db, s.current_ticket_id)
644
+ : null;
645
+ const prompt = buildPrPrompt(ticket, s.draft);
646
+ return promptResponse(run, 'PR', prompt, 'Creating PR', {
647
+ allowed_paths: [],
648
+ denied_paths: [],
649
+ denied_patterns: [],
650
+ max_files: 0,
651
+ max_lines: 0,
652
+ required_commands: [],
653
+ plan_required: false,
654
+ auto_approve_patterns: getPrAutoApprove(),
655
+ });
656
+ }
657
+ /** Max session-level steps a worker can go without progress before timeout */
658
+ const WORKER_STALL_THRESHOLD = 50;
659
+ async function advanceParallelExecute(ctx) {
660
+ const { run } = ctx;
661
+ const s = run.require();
662
+ // Timeout check: fail workers that haven't made progress
663
+ for (const [id, w] of Object.entries(s.ticket_workers)) {
664
+ const stalledFor = s.step_count - (w.last_active_at_step ?? 0);
665
+ if (stalledFor >= WORKER_STALL_THRESHOLD) {
666
+ run.appendEvent('WORKER_TIMEOUT', { ticket_id: id, stalled_for: stalledFor });
667
+ run.failTicketWorker(id, `Worker timeout: no progress for ${stalledFor} steps`);
668
+ }
669
+ }
670
+ // Check if all ticket workers are done
671
+ if (run.allWorkersComplete()) {
672
+ run.setPhase('NEXT_TICKET');
673
+ return advance(ctx);
674
+ }
675
+ // Workers still active — return status
676
+ const activeWorkers = Object.entries(s.ticket_workers).map(([id, w]) => ({
677
+ ticket_id: id,
678
+ phase: w.phase,
679
+ step_count: w.step_count,
680
+ }));
681
+ return {
682
+ next_action: 'PROMPT',
683
+ phase: 'PARALLEL_EXECUTE',
684
+ prompt: `Parallel execution still in progress. ${activeWorkers.length} ticket(s) still active. Wait for all subagents to complete, then call promptwheel_advance again.`,
685
+ reason: `${activeWorkers.length} ticket workers still active`,
686
+ constraints: emptyConstraints(),
687
+ digest: run.buildDigest(),
688
+ };
689
+ }
690
+ // ---------------------------------------------------------------------------
691
+ // Budget checks
692
+ // ---------------------------------------------------------------------------
693
+ function checkBudgets(run) {
694
+ const s = run.require();
695
+ if (s.step_count >= s.step_budget) {
696
+ run.appendEvent('BUDGET_EXHAUSTED', { which: 'step_budget', value: s.step_count });
697
+ run.setPhase('FAILED_BUDGET');
698
+ return stopResponse(run, 'FAILED_BUDGET', `Step budget exhausted (${s.step_count}/${s.step_budget})`);
699
+ }
700
+ if (s.expires_at && new Date() >= new Date(s.expires_at)) {
701
+ run.appendEvent('BUDGET_EXHAUSTED', { which: 'time_budget' });
702
+ run.setPhase('FAILED_BUDGET');
703
+ return stopResponse(run, 'FAILED_BUDGET', 'Time budget exhausted');
704
+ }
705
+ return null;
706
+ }
707
+ // ---------------------------------------------------------------------------
708
+ // Response builders
709
+ // ---------------------------------------------------------------------------
710
+ function stopResponse(run, phase, reason) {
711
+ return {
712
+ next_action: 'STOP',
713
+ phase,
714
+ prompt: null,
715
+ reason,
716
+ constraints: emptyConstraints(),
717
+ digest: run.buildDigest(),
718
+ };
719
+ }
720
+ function promptResponse(run, phase, prompt, reason, constraints) {
721
+ return {
722
+ next_action: 'PROMPT',
723
+ phase,
724
+ prompt,
725
+ reason,
726
+ constraints,
727
+ digest: run.buildDigest(),
728
+ };
729
+ }
730
+ function emptyConstraints() {
731
+ return {
732
+ allowed_paths: [],
733
+ denied_paths: [],
734
+ denied_patterns: [],
735
+ max_files: 0,
736
+ max_lines: 0,
737
+ required_commands: [],
738
+ plan_required: false,
739
+ auto_approve_patterns: [],
740
+ };
741
+ }
742
+ function terminalReason(phase) {
743
+ switch (phase) {
744
+ case 'DONE': return 'Session completed successfully';
745
+ case 'BLOCKED_NEEDS_HUMAN': return 'Ticket blocked, needs human review';
746
+ case 'FAILED_BUDGET': return 'Budget exhausted';
747
+ case 'FAILED_VALIDATION': return 'Validation failed';
748
+ case 'FAILED_SPINDLE': return 'Loop detected by spindle';
749
+ default: return 'Terminal state';
750
+ }
751
+ }
752
+ //# sourceMappingURL=advance.js.map