scai 0.1.172 → 0.1.173

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.
@@ -16,6 +16,7 @@ import { validateChangesStep } from './validateChangesStep.js';
16
16
  import { reasonNextTaskStep } from './reasonNextTaskStep.js';
17
17
  import { collaboratorStep } from './collaboratorStep.js';
18
18
  import { integrateFeedbackStep } from './integrateFeedbackStep.js';
19
+ import { researchPlanGenStep } from "./researchPlanGenStep.js";
19
20
  import { selectRelevantSourcesStep } from "./selectRelevantSourcesStep.js";
20
21
  import { iterationFileSelector } from "./iterationFileSelector.js";
21
22
  import { finalAnswerModule } from "../pipeline/modules/finalAnswerModule.js";
@@ -26,8 +27,10 @@ import { NUM_TOPFILES, RELATED_FILES_LIMIT } from "../constants.js";
26
27
  import { structuralPreloadStep } from "./structuralPreloadStep.js";
27
28
  import { extractFileReferences } from "../utils/extractFileReferences.js";
28
29
  import { PREFILTER_STOP_WORDS } from "../fileRules/stopWords.js";
30
+ import { MAX_WELL_KNOWN_REPO_FILES, WELL_KNOWN_REPO_FILE_BASENAMES } from "../fileRules/wellKnownRepoFiles.js";
29
31
  import chalk from "chalk";
30
32
  import path from "path";
33
+ import fs from "fs";
31
34
  /* ───────────────────────── registry ───────────────────────── */
32
35
  const MODULE_REGISTRY = Object.fromEntries(Object.entries(builtInModules).map(([name, mod]) => [name, mod]));
33
36
  function resolveModuleForAction(action) {
@@ -66,9 +69,14 @@ export class MainAgent {
66
69
  this.runCount = 0;
67
70
  await this.runBoot();
68
71
  await this.runScope();
69
- await this.runInitialRetrieval();
70
- await this.runGrounding();
71
- await this.runWorkLoop();
72
+ await this.runSearch();
73
+ await this.runVerify();
74
+ await this.runResearch();
75
+ const canProceedToExecution = this.isResearchGateSatisfied();
76
+ if (canProceedToExecution) {
77
+ await this.runPlan();
78
+ await this.runWorkLoop();
79
+ }
72
80
  await this.runFinalize();
73
81
  }
74
82
  finally {
@@ -100,12 +108,16 @@ export class MainAgent {
100
108
  await routingDecisionStep.run(this.context);
101
109
  const routing = this.context.analysis?.routingDecision;
102
110
  if (routing) {
103
- this.logLine("TASK", "Routing decision", undefined, `${routing.decision} | search=${routing.allowSearch} | transform=${routing.allowTransform} | scopeLocked=${routing.scopeLocked}`);
111
+ this.logLine("TASK", "Routing decision", undefined, `${routing.decision} | search=${routing.allowSearch} | research=${routing.allowResearch} | transform=${routing.allowTransform} | scopeLocked=${routing.scopeLocked}`);
104
112
  }
105
113
  this.logLine("TASK", "Scope classification complete");
106
114
  }
107
- /* ───────────── initial retrieval ───────────── */
108
- async runInitialRetrieval() {
115
+ /* ───────────── search ───────────── */
116
+ /**
117
+ * Seeds initial candidate files using semantic retrieval + deterministic prefilter.
118
+ * Example: query mentions "MainAgent" -> relatedFiles are narrowed before grounding.
119
+ */
120
+ async runSearch() {
109
121
  const { rawUserQuery, retrievalQuery } = this.resolveInitialRetrievalQueries();
110
122
  const t = this.startTimer();
111
123
  try {
@@ -114,20 +126,29 @@ export class MainAgent {
114
126
  const seededContext = await buildLightContext(promptArgs);
115
127
  const mergedRelatedCount = this.mergeSeededInitialContext(rawUserQuery, seededContext);
116
128
  const prefilter = this.applyDeterministicPreGroundingPrefilter(retrievalQuery);
117
- this.logLine("ANALYSIS", "initialRetrieval", t(), `${results.length} result(s), ${mergedRelatedCount} candidate file(s), prefilter ${prefilter.before} -> ${prefilter.after}`);
129
+ const repoDefaults = this.injectWellKnownRepoFiles(prefilter.after);
130
+ this.logLine("ANALYSIS", "initialRetrieval", t(), `${results.length} result(s), ${mergedRelatedCount} candidate file(s), prefilter ${prefilter.before} -> ${prefilter.after}, defaults +${repoDefaults.added} (${repoDefaults.reason})`);
118
131
  }
119
132
  catch (err) {
120
133
  this.logLine("ANALYSIS", "initialRetrieval", t(), `failed: ${String(err)}`);
121
134
  }
122
135
  }
123
- /* ───────────── grounding ───────────── */
124
- async runGrounding() {
136
+ /* ───────────── verify ───────────── */
137
+ /**
138
+ * Wave-based verify loop (evidence -> readiness -> optional info acquisition).
139
+ * Example: if readiness stays not-ready, run an info plan and try another wave.
140
+ */
141
+ async runVerify() {
125
142
  let ready = false;
126
143
  const maxGroundingWaves = this.getGroundingWaveBudget();
127
144
  let groundingWave = 0;
145
+ let stagnantWaves = 0;
146
+ const MAX_STAGNANT_WAVES = 2;
128
147
  while (groundingWave < maxGroundingWaves) {
129
148
  groundingWave++;
149
+ this.pruneMissingVerifyPaths();
130
150
  this.logLine("ANALYSIS", "groundingWave", undefined, `wave ${groundingWave}/${maxGroundingWaves}`);
151
+ const beforeFocus = this.captureVerifyFocusSnapshot();
131
152
  // ---------------- EVIDENCE PIPELINE ----------------
132
153
  // -------- STRUCTURAL PRELOAD --------
133
154
  const t0 = this.startTimer();
@@ -151,57 +172,207 @@ export class MainAgent {
151
172
  break;
152
173
  }
153
174
  // ---------------- INFORMATION ACQUISITION ----------------
154
- if (this.canExecutePhase("planning") &&
175
+ const canRouteSearchExpansion = this.canExecuteRoute("search-expand");
176
+ if (!canRouteSearchExpansion) {
177
+ this.logLine("PLAN", "infoPlanGen", undefined, "skipped (routing disallows search expansion)", { highlight: false });
178
+ }
179
+ else if (this.canExecutePhase("planning") &&
155
180
  this.canExecuteScope("planning")) {
156
181
  const t = this.startTimer();
157
182
  await infoPlanGenStep.run(this.context);
158
183
  const infoPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
159
- // If we are about to execute a new info acquisition wave,
160
- // wipe previous search results.
161
- if (infoPlan.steps.length > 0 && this.context.initContext && this.context.analysis?.focus) {
162
- this.context.initContext.relatedFiles = [];
163
- this.context.analysis.focus.candidateFiles = [];
164
- }
165
184
  for (const step of infoPlan.steps) {
166
185
  const stepIO = { query: this.query };
167
186
  await this.executeStep(step, stepIO);
168
187
  }
169
188
  this.logLine("PLAN", "infoPlanGen", t(), undefined, { highlight: false });
170
189
  }
190
+ const afterFocus = this.captureVerifyFocusSnapshot();
191
+ const hasFocusGrowth = this.logVerifyFocusDelta(beforeFocus, afterFocus);
192
+ stagnantWaves = hasFocusGrowth ? 0 : stagnantWaves + 1;
193
+ if (this.shouldStopVerifyForSaturation(stagnantWaves, MAX_STAGNANT_WAVES))
194
+ break;
171
195
  this.logLine("HASINFO", "Not ready — looping back to evidence collection", undefined, undefined, { highlight: false });
172
196
  }
173
- const selectedFiles = this.context.analysis?.focus?.selectedFiles ?? [];
174
- if (selectedFiles.length > 0) {
175
- this.context.analysis.readiness.decision = "ready";
176
- this.logLine("ANALYSIS", "readinessOverrideFromSelectedFiles", undefined, `${selectedFiles.length} selected file(s) available after grounding`);
197
+ // Grounding is the phase boundary that decides whether execution may start.
198
+ if (!this.isWorkLoopReady())
199
+ return;
200
+ this.ensureTaskForWorkLoop();
201
+ // Research gate is evaluated after runResearch() in run().
202
+ }
203
+ /* ───────────── research ───────────── */
204
+ /**
205
+ * Seeds explicit research task steps for complex repo-wide lanes.
206
+ * Example: enqueue research-impact-map, research-symbol-trace, and research-risk-check.
207
+ */
208
+ async runResearch() {
209
+ var _a, _b;
210
+ if (!this.canExecuteRoute("research"))
211
+ return;
212
+ if (!this.context.task)
213
+ return;
214
+ (_a = this.context.task).taskSteps || (_a.taskSteps = []);
215
+ await researchPlanGenStep.run(this.context);
216
+ const generatedSteps = (this.context.analysis?.planSuggestion?.plan?.steps ?? [])
217
+ .filter(step => typeof step.action === "string" && step.action.startsWith("research-"))
218
+ .map(step => {
219
+ const action = step.action;
220
+ const defaultFilePath = action === "research-impact-map"
221
+ ? "__research__/impact-map"
222
+ : action === "research-symbol-trace"
223
+ ? "__research__/symbol-trace"
224
+ : action === "research-risk-check"
225
+ ? "__research__/risk-check"
226
+ : "__research__/architecture-synthesis";
227
+ return {
228
+ action,
229
+ filePath: step.targetFile || defaultFilePath,
230
+ notes: step.description || `Run ${step.action}`,
231
+ };
232
+ });
233
+ const fallbackResearchSteps = [
234
+ {
235
+ action: "research-impact-map",
236
+ filePath: "__research__/impact-map",
237
+ notes: "Map cross-file impact before code changes.",
238
+ },
239
+ {
240
+ action: "research-symbol-trace",
241
+ filePath: "__research__/symbol-trace",
242
+ notes: "Trace key symbols across related files.",
243
+ },
244
+ {
245
+ action: "research-risk-check",
246
+ filePath: "__research__/risk-check",
247
+ notes: "Record risks, assumptions, and constraints before edits.",
248
+ },
249
+ {
250
+ action: "research-architecture-synthesis",
251
+ filePath: "__research__/architecture-synthesis",
252
+ notes: "Synthesize architecture summary, shared patterns, hotspots, and coupling points.",
253
+ },
254
+ ];
255
+ const researchSteps = generatedSteps.length > 0 ? generatedSteps : fallbackResearchSteps;
256
+ let seededCount = 0;
257
+ for (const step of researchSteps) {
258
+ const exists = this.context.task.taskSteps.some(s => s.filePath === step.filePath && s.action === step.action);
259
+ if (exists)
260
+ continue;
261
+ this.context.task.taskSteps.push({
262
+ taskId: this.context.task.id,
263
+ filePath: step.filePath,
264
+ action: step.action,
265
+ status: "pending",
266
+ notes: step.notes,
267
+ result: { phase: "research", seededBy: "runResearch" },
268
+ });
269
+ seededCount++;
177
270
  }
271
+ const plannedResearchSteps = this.context.task.taskSteps
272
+ .filter(s => typeof s.action === "string" && s.action.startsWith("research-"))
273
+ .map(s => ({
274
+ action: s.action,
275
+ filePath: s.filePath,
276
+ status: s.status,
277
+ notes: s.notes,
278
+ }));
279
+ logInputOutput("runResearch", "output", {
280
+ source: generatedSteps.length > 0 ? "generated" : "fallback",
281
+ seededCount,
282
+ totalResearchSteps: plannedResearchSteps.length,
283
+ steps: plannedResearchSteps,
284
+ });
285
+ (_b = this.context).analysis || (_b.analysis = {});
286
+ this.context.analysis.planSuggestion = undefined;
287
+ this.logLine("RESEARCH", "taskStepSeed", undefined, `${seededCount} research step(s) added (${generatedSteps.length > 0 ? "generated" : "fallback"})`);
178
288
  }
289
+ /* ───────────── plan ───────────── */
179
290
  /**
180
- * Resolves grounding wave budget from current routing metadata.
181
- * Example: scope=repo-wide + decision=needs-info => 4 waves.
291
+ * Seeds ordered execution task steps from selected files + research/verify artifacts.
292
+ * Example: prioritize files that are both selected and research-touched.
182
293
  */
183
- getGroundingWaveBudget() {
184
- const scope = this.context.analysis?.scopeType ?? "repo-wide";
185
- const decision = this.context.analysis?.routingDecision?.decision ?? "has-info";
186
- const allowSearch = this.context.analysis?.routingDecision?.allowSearch ?? true;
187
- let budget = 2;
188
- if (!allowSearch || scope === "none")
189
- budget = 1;
190
- else if (scope === "single-file" && decision === "has-info")
191
- budget = 2;
192
- else if (scope === "multi-file")
193
- budget = 3;
194
- else if (scope === "repo-wide" && decision === "needs-info")
195
- budget = 4;
196
- this.logLine("ANALYSIS", "groundingBudget", undefined, `scope=${scope}, decision=${decision}, search=${allowSearch}, maxWaves=${budget}`);
197
- return budget;
294
+ async runPlan() {
295
+ var _a, _b;
296
+ if (!this.context.task)
297
+ return;
298
+ if (!this.canExecutePhase("planning") || !this.canExecuteScope("planning"))
299
+ return;
300
+ (_a = this.context).analysis || (_a.analysis = {});
301
+ (_b = this.context.task).taskSteps || (_b.taskSteps = []);
302
+ const existingExecutionPaths = new Set(this.context.task.taskSteps
303
+ .filter(step => !!step.filePath &&
304
+ !step.filePath.startsWith("__research__/"))
305
+ .map(step => step.filePath));
306
+ const selectedFiles = this.context.analysis.focus?.selectedFiles ?? [];
307
+ const touchedFromResearch = this.context.analysis.researchArtifacts?.touchedFiles ?? [];
308
+ const verifyRelevantFiles = Object.entries(this.context.analysis.verify?.byFile ?? {})
309
+ .filter(([_, verify]) => verify?.isRelevant)
310
+ .map(([filePath]) => filePath);
311
+ const rankPath = (filePath) => {
312
+ const inSelected = selectedFiles.includes(filePath);
313
+ const inResearchTouched = touchedFromResearch.includes(filePath);
314
+ const inVerify = verifyRelevantFiles.includes(filePath);
315
+ if (inSelected && inResearchTouched)
316
+ return 0;
317
+ if (inSelected)
318
+ return 1;
319
+ if (inResearchTouched)
320
+ return 2;
321
+ if (inVerify)
322
+ return 3;
323
+ return 4;
324
+ };
325
+ const plannedPaths = Array.from(new Set([
326
+ ...selectedFiles,
327
+ ...touchedFromResearch,
328
+ ...verifyRelevantFiles,
329
+ ]))
330
+ .filter(filePath => !!filePath && !filePath.startsWith("__research__/") && fs.existsSync(filePath))
331
+ .sort((a, b) => rankPath(a) - rankPath(b))
332
+ .slice(0, 16);
333
+ let seededCount = 0;
334
+ const seeded = [];
335
+ for (const filePath of plannedPaths) {
336
+ if (existingExecutionPaths.has(filePath))
337
+ continue;
338
+ const rank = rankPath(filePath);
339
+ const notes = rank === 0
340
+ ? "Plan priority: selected + research-touched"
341
+ : rank === 1
342
+ ? "Plan priority: selected file"
343
+ : rank === 2
344
+ ? "Plan priority: research-touched file"
345
+ : "Plan priority: verify-relevant file";
346
+ this.context.task.taskSteps.push({
347
+ taskId: this.context.task.id,
348
+ filePath,
349
+ status: "pending",
350
+ notes,
351
+ result: {
352
+ phase: "plan",
353
+ seededBy: "runPlan",
354
+ priorityRank: rank,
355
+ },
356
+ });
357
+ seeded.push({ filePath, rank, notes });
358
+ seededCount++;
359
+ }
360
+ logInputOutput("runPlan", "output", {
361
+ seededCount,
362
+ totalPlannedPaths: plannedPaths.length,
363
+ selectedFileCount: selectedFiles.length,
364
+ researchTouchedCount: touchedFromResearch.length,
365
+ verifyRelevantCount: verifyRelevantFiles.length,
366
+ seeded,
367
+ });
368
+ this.logLine("PLAN", "taskStepSeed", undefined, `${seededCount} execution step(s) planned`);
198
369
  }
199
370
  /* ───────────── work loop ───────────── */
200
371
  async runWorkLoop() {
201
- if (!this.isWorkLoopReady())
372
+ if (this.context.task.status !== "active")
202
373
  return;
203
374
  this.ensureTaskForWorkLoop();
204
- const MAX_TASK_STEPS = 5;
375
+ const MAX_TASK_STEPS = this.getTaskStepBudget();
205
376
  let stepCount = 0;
206
377
  while (stepCount < MAX_TASK_STEPS &&
207
378
  this.context.task.status === "active") {
@@ -232,7 +403,17 @@ export class MainAgent {
232
403
  }
233
404
  this.logLine("TASK", "Max task step limit reached — stopping work loop", undefined, undefined, { highlight: false });
234
405
  }
406
+ /* ───────────── finalize ───────────── */
407
+ async runFinalize() {
408
+ await finalAnswerModule.run({ query: this.query, context: this.context });
409
+ persistTaskData(this.context, this.taskId, getDbForRepo(), this.logLine.bind(this));
410
+ this.logLine("TASK", "Finalize complete", undefined, undefined, { highlight: false });
411
+ }
235
412
  /* ───────────── step iterations ───────────── */
413
+ /**
414
+ * Iterates one task step until it completes, needs feedback, or asks for redo.
415
+ * Example: validation failure sets nextAction=redo-step and re-runs iteration.
416
+ */
236
417
  async runStepIterations(taskStep) {
237
418
  const MAX_ITERATIONS = 5;
238
419
  let loopCount = 0;
@@ -261,9 +442,17 @@ export class MainAgent {
261
442
  return "continue";
262
443
  }
263
444
  /* ───────────── work iteration ───────────── */
445
+ /**
446
+ * Executes one analyze/transform/validate pass for the current task step.
447
+ * Example: generate analysis plan, run one transform step, then validate.
448
+ */
264
449
  async runWorkIteration(taskStep) {
265
450
  if (!this.context.analysis)
266
451
  this.context.analysis = {};
452
+ if (taskStep.action?.startsWith("research-")) {
453
+ await this.executeResearchTaskStep(taskStep);
454
+ return;
455
+ }
267
456
  if (this.canExecutePhase("analysis") && this.canExecuteScope("analysis")) {
268
457
  const tAnalysis = this.startTimer();
269
458
  await analysisPlanGenStep.run(this.context);
@@ -309,6 +498,375 @@ export class MainAgent {
309
498
  await integrateFeedbackStep.run(this.context);
310
499
  this.logLine("FEEDBACK", "integrateFeedbackStep", tIntegrate());
311
500
  }
501
+ /**
502
+ * Executes deterministic research steps and marks them complete.
503
+ * Example: research-impact-map summarizes affected files and seeds understanding notes.
504
+ */
505
+ async executeResearchTaskStep(taskStep) {
506
+ var _a, _b;
507
+ const selectedFiles = this.context.analysis?.focus?.selectedFiles ?? [];
508
+ const candidateFiles = this.context.analysis?.focus?.candidateFiles ?? [];
509
+ const fileAnalysis = this.context.analysis?.fileAnalysis ?? {};
510
+ const researchTerms = this.buildResearchTerms();
511
+ const researchPaths = this.collectResearchPaths(24);
512
+ const corpus = this.loadResearchCorpus(researchPaths, 12, 12000);
513
+ const understanding = (_b = ((_a = this.context).analysis || (_a.analysis = {}))).understanding || (_b.understanding = {
514
+ assumptions: [],
515
+ constraints: [],
516
+ risks: [],
517
+ sharedPatterns: [],
518
+ hotspots: [],
519
+ couplingPoints: [],
520
+ });
521
+ const addUnique = (arr, value) => {
522
+ if (!arr)
523
+ return;
524
+ if (!arr.includes(value))
525
+ arr.push(value);
526
+ };
527
+ let summary = "";
528
+ let collectedData = {
529
+ selectedFiles: selectedFiles.slice(0, 12),
530
+ selectedFileCount: selectedFiles.length,
531
+ candidateFileCount: candidateFiles.length,
532
+ researchTerms,
533
+ corpusFilesRead: corpus.length,
534
+ corpusPaths: corpus.map(f => f.path).slice(0, 12),
535
+ };
536
+ switch (taskStep.action) {
537
+ case "research-impact-map": {
538
+ const touched = selectedFiles.length;
539
+ const impactRows = corpus
540
+ .map(file => {
541
+ const termHits = this.computeTermHits(file.content, researchTerms);
542
+ const termHitTotal = Object.values(termHits).reduce((acc, n) => acc + n, 0);
543
+ const importCount = this.countRegex(file.content, /\bimport\b|\brequire\s*\(/g);
544
+ const exportCount = this.countRegex(file.content, /\bexport\b|module\.exports/g);
545
+ const score = termHitTotal * 3 + importCount * 2 + exportCount;
546
+ return {
547
+ filePath: file.path,
548
+ score,
549
+ termHits,
550
+ importCount,
551
+ exportCount,
552
+ lineCount: file.lineCount,
553
+ };
554
+ })
555
+ .sort((a, b) => b.score - a.score)
556
+ .slice(0, 8);
557
+ summary = `Impact map across ${touched} selected file(s).`;
558
+ addUnique(understanding.constraints, `Refactor impact spans ${touched} file(s).`);
559
+ collectedData = {
560
+ ...collectedData,
561
+ touchedFiles: selectedFiles.slice(0, 20),
562
+ impactSignals: [
563
+ `selected=${selectedFiles.length}`,
564
+ `candidates=${candidateFiles.length}`,
565
+ ],
566
+ impactMap: impactRows,
567
+ };
568
+ break;
569
+ }
570
+ case "research-symbol-trace": {
571
+ const structuralSymbols = Object.values(fileAnalysis)
572
+ .flatMap(fa => fa.structural?.functions?.map(fn => fn.name).filter(Boolean) ?? [])
573
+ .slice(0, 24);
574
+ const fallbackSymbols = corpus
575
+ .flatMap(file => Array.from(file.content.matchAll(/\b(function|class|const|let|var)\s+([A-Za-z_]\w*)/g)).map(m => m[2]))
576
+ .filter(Boolean);
577
+ const symbolPool = Array.from(new Set([...structuralSymbols, ...fallbackSymbols])).slice(0, 18);
578
+ const traceRows = symbolPool
579
+ .map(symbol => {
580
+ const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
581
+ const re = new RegExp(`\\b${escaped}\\b`, "g");
582
+ const files = corpus
583
+ .map(file => ({ filePath: file.path, count: this.countRegex(file.content, re) }))
584
+ .filter(item => item.count > 0);
585
+ return {
586
+ symbol,
587
+ occurrenceCount: files.reduce((acc, f) => acc + f.count, 0),
588
+ files: files.slice(0, 8),
589
+ };
590
+ })
591
+ .filter(row => row.occurrenceCount > 0)
592
+ .sort((a, b) => b.occurrenceCount - a.occurrenceCount)
593
+ .slice(0, 10);
594
+ summary = traceRows.length
595
+ ? `Traced ${traceRows.length} symbol(s) from corpus.`
596
+ : "No structural symbols found; symbol trace used filename-level anchors.";
597
+ addUnique(understanding.assumptions, "Symbol trace coverage is partial and based on current selected files.");
598
+ collectedData = {
599
+ ...collectedData,
600
+ tracedSymbols: traceRows.map(s => s.symbol),
601
+ symbolTrace: traceRows,
602
+ analyzedFileCount: Object.values(fileAnalysis).filter(fa => fa?.semanticAnalyzed).length,
603
+ };
604
+ break;
605
+ }
606
+ case "research-risk-check": {
607
+ const riskPatterns = [
608
+ { id: "empty-catch", description: "Empty catch blocks", pattern: /catch\s*\(\s*[^)]*\)\s*\{\s*\}/g },
609
+ { id: "console-error", description: "Console error logging", pattern: /\bconsole\.error\s*\(/g },
610
+ { id: "forced-exit", description: "Process exit usage", pattern: /\bprocess\.exit\s*\(/g },
611
+ { id: "throws-string", description: "Throwing non-Error values", pattern: /\bthrow\s+['"`]/g },
612
+ ];
613
+ const riskRows = riskPatterns
614
+ .map(risk => {
615
+ const perFile = corpus
616
+ .map(file => ({ filePath: file.path, count: this.countRegex(file.content, risk.pattern) }))
617
+ .filter(hit => hit.count > 0);
618
+ return {
619
+ id: risk.id,
620
+ description: risk.description,
621
+ totalHits: perFile.reduce((acc, hit) => acc + hit.count, 0),
622
+ files: perFile.slice(0, 8),
623
+ };
624
+ })
625
+ .filter(risk => risk.totalHits > 0);
626
+ summary = "Recorded baseline risks/assumptions/constraints before transformation.";
627
+ addUnique(understanding.risks, "Cross-file regressions are possible without full symbol coverage.");
628
+ addUnique(understanding.risks, "Validation should run after each transform step.");
629
+ for (const risk of riskRows) {
630
+ addUnique(understanding.risks, `${risk.description}: ${risk.totalHits} hit(s)`);
631
+ }
632
+ collectedData = {
633
+ ...collectedData,
634
+ risks: understanding.risks?.slice(0, 12) ?? [],
635
+ assumptions: understanding.assumptions?.slice(0, 12) ?? [],
636
+ constraints: understanding.constraints?.slice(0, 12) ?? [],
637
+ riskSignals: riskRows,
638
+ };
639
+ break;
640
+ }
641
+ case "research-architecture-synthesis": {
642
+ const analyzedPaths = Object.entries(fileAnalysis)
643
+ .filter(([_, fa]) => fa?.semanticAnalyzed)
644
+ .map(([filePath]) => filePath);
645
+ const architectureFiles = (analyzedPaths.length > 0 ? analyzedPaths : corpus.map(file => file.path)).slice(0, 8);
646
+ understanding.problemStatement =
647
+ `Summarize repository architecture and identify weak coupling points across ${selectedFiles.length} scoped file(s).`;
648
+ for (const p of architectureFiles) {
649
+ const base = path.basename(p);
650
+ if (base.toLowerCase().includes("registry")) {
651
+ addUnique(understanding.hotspots, `${base}: central registry point with broad module fan-in.`);
652
+ addUnique(understanding.couplingPoints, `${base}: centralized module registration coupling.`);
653
+ }
654
+ if (base.toLowerCase().includes("module")) {
655
+ addUnique(understanding.sharedPatterns, `${base}: module-oriented pipeline pattern.`);
656
+ }
657
+ }
658
+ addUnique(understanding.sharedPatterns, "Pipeline modules follow a shared Module/ModuleIO contract.");
659
+ addUnique(understanding.couplingPoints, "Shared config/model utilities create cross-module coupling.");
660
+ addUnique(understanding.hotspots, "Core orchestration and registry layers are high-impact change zones.");
661
+ summary = `Architecture synthesis completed from ${architectureFiles.length} analyzed file(s).`;
662
+ const priorResearch = (this.context.task?.taskSteps ?? [])
663
+ .filter(step => step.action?.startsWith("research-") && step.status === "completed")
664
+ .map(step => ({
665
+ action: step.action,
666
+ summary: step.result?.research?.summary,
667
+ }));
668
+ collectedData = {
669
+ ...collectedData,
670
+ architectureInputFiles: architectureFiles,
671
+ priorResearchSummaries: priorResearch,
672
+ problemStatement: understanding.problemStatement ?? "",
673
+ sharedPatterns: understanding.sharedPatterns?.slice(0, 12) ?? [],
674
+ hotspots: understanding.hotspots?.slice(0, 12) ?? [],
675
+ couplingPoints: understanding.couplingPoints?.slice(0, 12) ?? [],
676
+ };
677
+ break;
678
+ }
679
+ default: {
680
+ summary = `Unknown research action: ${taskStep.action}`;
681
+ collectedData = {
682
+ ...collectedData,
683
+ warning: "No handler for research action",
684
+ };
685
+ break;
686
+ }
687
+ }
688
+ const completedAt = new Date().toISOString();
689
+ const researchEntry = {
690
+ action: taskStep.action,
691
+ summary,
692
+ collectedData,
693
+ selectedFileCount: selectedFiles.length,
694
+ completedAt,
695
+ };
696
+ taskStep.result || (taskStep.result = {});
697
+ taskStep.result.research = researchEntry;
698
+ taskStep.result.stepReasoning = {
699
+ nextAction: "complete",
700
+ rationale: `Research step completed: ${summary}`,
701
+ confidence: 0.95,
702
+ };
703
+ taskStep.status = "completed";
704
+ this.persistResearchArtifact(researchEntry);
705
+ logInputOutput("runResearchStep", "output", {
706
+ research: researchEntry,
707
+ stepReasoning: taskStep.result.stepReasoning,
708
+ status: taskStep.status,
709
+ });
710
+ }
711
+ /**
712
+ * Persists normalized research outputs into analysis.researchArtifacts.
713
+ * Example: latestByAction["research-risk-check"] stores current risk findings.
714
+ */
715
+ persistResearchArtifact(entry) {
716
+ var _a, _b;
717
+ (_a = this.context).analysis || (_a.analysis = {});
718
+ const store = (_b = this.context.analysis).researchArtifacts || (_b.researchArtifacts = {
719
+ latestByAction: {},
720
+ history: [],
721
+ touchedFiles: [],
722
+ lastUpdatedAt: entry.completedAt,
723
+ });
724
+ store.latestByAction || (store.latestByAction = {});
725
+ store.history || (store.history = []);
726
+ store.touchedFiles || (store.touchedFiles = []);
727
+ store.latestByAction[entry.action] = entry;
728
+ store.history.push(entry);
729
+ const data = entry.collectedData ?? {};
730
+ const touched = this.extractPathsFromResearchData(data);
731
+ const merged = new Set([...(store.touchedFiles ?? []), ...touched]);
732
+ store.touchedFiles = Array.from(merged);
733
+ store.lastUpdatedAt = entry.completedAt;
734
+ }
735
+ /**
736
+ * Extracts file paths from heterogeneous research payloads.
737
+ * Example: impactMap rows and architectureInputFiles are both merged into touchedFiles.
738
+ */
739
+ extractPathsFromResearchData(data) {
740
+ const paths = new Set();
741
+ const addPath = (value) => {
742
+ if (typeof value === "string" && value.trim().length > 0) {
743
+ paths.add(value);
744
+ }
745
+ };
746
+ const addPathArray = (value) => {
747
+ if (!Array.isArray(value))
748
+ return;
749
+ for (const item of value) {
750
+ addPath(item);
751
+ }
752
+ };
753
+ addPathArray(data.corpusPaths);
754
+ addPathArray(data.touchedFiles);
755
+ addPathArray(data.architectureInputFiles);
756
+ if (Array.isArray(data.impactMap)) {
757
+ for (const row of data.impactMap) {
758
+ addPath(row.filePath);
759
+ }
760
+ }
761
+ if (Array.isArray(data.symbolTrace)) {
762
+ for (const row of data.symbolTrace) {
763
+ const files = row.files;
764
+ if (!Array.isArray(files))
765
+ continue;
766
+ for (const fileRow of files) {
767
+ addPath(fileRow.filePath);
768
+ }
769
+ }
770
+ }
771
+ if (Array.isArray(data.riskSignals)) {
772
+ for (const row of data.riskSignals) {
773
+ const files = row.files;
774
+ if (!Array.isArray(files))
775
+ continue;
776
+ for (const fileRow of files) {
777
+ addPath(fileRow.filePath);
778
+ }
779
+ }
780
+ }
781
+ return Array.from(paths);
782
+ }
783
+ /**
784
+ * Builds lightweight query terms for deterministic research scanning.
785
+ * Example: "error handling test suite" -> ["error","handling","test","suite"].
786
+ */
787
+ buildResearchTerms() {
788
+ const query = this.context.analysis?.intent?.normalizedQuery ??
789
+ this.context.initContext?.userQuery ??
790
+ this.query;
791
+ const stopWords = new Set([
792
+ "the", "and", "for", "with", "from", "this", "that", "what", "how",
793
+ "is", "are", "was", "were", "can", "could", "should", "would", "into",
794
+ "about", "across", "repo", "codebase", "please",
795
+ ]);
796
+ return Array.from(new Set(query
797
+ .toLowerCase()
798
+ .split(/[^a-z0-9_]+/g)
799
+ .filter(token => token.length >= 3 && !stopWords.has(token)))).slice(0, 10);
800
+ }
801
+ /**
802
+ * Collects research candidate paths from selected, candidate, related, and working files.
803
+ * Example: selected files are prioritized before broader related file pool.
804
+ */
805
+ collectResearchPaths(maxPaths) {
806
+ const focus = this.context.analysis?.focus;
807
+ const workingPaths = (this.context.workingFiles ?? []).map(file => file.path);
808
+ const related = this.context.initContext?.relatedFiles ?? [];
809
+ const combined = [
810
+ ...(focus?.selectedFiles ?? []),
811
+ ...(focus?.candidateFiles ?? []),
812
+ ...workingPaths,
813
+ ...related,
814
+ ];
815
+ const unique = Array.from(new Set(combined));
816
+ return unique
817
+ .filter(filePath => !filePath.startsWith("__research__/") && fs.existsSync(filePath))
818
+ .slice(0, maxPaths);
819
+ }
820
+ /**
821
+ * Reads a bounded corpus from candidate paths.
822
+ * Example: read first 12 files, max 12k chars per file, skipping binary payloads.
823
+ */
824
+ loadResearchCorpus(filePaths, maxFiles, maxCharsPerFile) {
825
+ const corpus = [];
826
+ for (const filePath of filePaths.slice(0, maxFiles)) {
827
+ try {
828
+ const raw = fs.readFileSync(filePath, "utf-8");
829
+ if (raw.includes("\u0000"))
830
+ continue;
831
+ const content = raw.slice(0, maxCharsPerFile);
832
+ corpus.push({
833
+ path: filePath,
834
+ content,
835
+ lineCount: content.split("\n").length,
836
+ charCount: content.length,
837
+ });
838
+ }
839
+ catch {
840
+ // Ignore unreadable files and continue.
841
+ }
842
+ }
843
+ return corpus;
844
+ }
845
+ /**
846
+ * Counts regex matches safely.
847
+ * Example: countRegex(code, /import/g) -> number of import occurrences.
848
+ */
849
+ countRegex(content, pattern) {
850
+ const source = pattern.source;
851
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
852
+ const re = new RegExp(source, flags);
853
+ return Array.from(content.matchAll(re)).length;
854
+ }
855
+ /**
856
+ * Computes per-term match counts for a file body.
857
+ * Example: terms ["error","test"] -> { error: 4, test: 2 }.
858
+ */
859
+ computeTermHits(content, terms) {
860
+ const hits = {};
861
+ for (const term of terms) {
862
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
863
+ const count = this.countRegex(content, new RegExp(`\\b${escaped}\\b`, "gi"));
864
+ if (count > 0) {
865
+ hits[term] = count;
866
+ }
867
+ }
868
+ return hits;
869
+ }
312
870
  /* ───────────── step executor ───────────── */
313
871
  /**
314
872
  * Executes a single step using its corresponding module.
@@ -344,13 +902,7 @@ export class MainAgent {
344
902
  throw err;
345
903
  }
346
904
  }
347
- /* ───────────── finalize ───────────── */
348
- async runFinalize() {
349
- await finalAnswerModule.run({ query: this.query, context: this.context });
350
- persistTaskData(this.context, this.taskId, getDbForRepo(), this.logLine.bind(this));
351
- this.logLine("TASK", "Finalize complete", undefined, undefined, { highlight: false });
352
- }
353
- /* ───────────── extracted from runInitialRetrieval ───────────── */
905
+ /* ───────────── extracted from runSearch ───────────── */
354
906
  resolveInitialRetrievalQueries() {
355
907
  const rawUserQuery = this.context.initContext?.userQuery ?? this.query;
356
908
  const retrievalQuery = this.context.analysis?.intent?.normalizedQuery?.trim() || rawUserQuery;
@@ -391,6 +943,8 @@ export class MainAgent {
391
943
  };
392
944
  }
393
945
  mergeSeededInitialContext(rawUserQuery, seededContext) {
946
+ // Merge retrieval seed into initContext without losing previously discovered files.
947
+ // Example: keep old relatedFiles and append newly seeded files from buildLightContext.
394
948
  const existingInit = this.context.initContext ?? { userQuery: rawUserQuery };
395
949
  const seededInit = seededContext.initContext;
396
950
  const mergedRelatedFiles = Array.from(new Set([
@@ -419,6 +973,8 @@ export class MainAgent {
419
973
  return mergedRelatedFiles.length;
420
974
  }
421
975
  applyDeterministicPreGroundingPrefilter(retrievalQuery) {
976
+ // Rank and cap retrieval candidates before grounding to reduce noisy evidence passes.
977
+ // Example: explicit filename anchors are always kept even if BM25 score is low.
422
978
  const init = this.context.initContext;
423
979
  if (!init?.relatedFiles?.length)
424
980
  return { before: 0, after: 0 };
@@ -458,6 +1014,184 @@ export class MainAgent {
458
1014
  });
459
1015
  return { before, after: init.relatedFiles.length };
460
1016
  }
1017
+ injectWellKnownRepoFiles(currentCount) {
1018
+ // Add high-signal repo root files when scope is broad or retrieval is sparse.
1019
+ // Example: on repo-wide queries, include package.json/README if present.
1020
+ const init = this.context.initContext;
1021
+ if (!init)
1022
+ return { added: 0, reason: "none" };
1023
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1024
+ const shouldInjectByScope = scope === "repo-wide";
1025
+ const shouldInjectByFallback = currentCount < 3;
1026
+ if (!shouldInjectByScope && !shouldInjectByFallback) {
1027
+ return { added: 0, reason: "none" };
1028
+ }
1029
+ const reason = shouldInjectByScope ? "scope" : "fallback";
1030
+ const candidates = WELL_KNOWN_REPO_FILE_BASENAMES
1031
+ .map(fileName => path.join(process.cwd(), fileName))
1032
+ .filter(filePath => fs.existsSync(filePath))
1033
+ .slice(0, MAX_WELL_KNOWN_REPO_FILES);
1034
+ if (candidates.length === 0)
1035
+ return { added: 0, reason };
1036
+ const existing = new Set(init.relatedFiles ?? []);
1037
+ let added = 0;
1038
+ for (const filePath of candidates) {
1039
+ if (existing.has(filePath))
1040
+ continue;
1041
+ existing.add(filePath);
1042
+ added++;
1043
+ }
1044
+ init.relatedFiles = Array.from(existing);
1045
+ return { added, reason };
1046
+ }
1047
+ /**
1048
+ * Captures focus sizes before/after a verify wave for growth tracking.
1049
+ * Example: selected=3,candidate=7 before wave; selected=4,candidate=9 after wave.
1050
+ */
1051
+ captureVerifyFocusSnapshot() {
1052
+ return {
1053
+ selected: this.context.analysis?.focus?.selectedFiles?.length ?? 0,
1054
+ candidate: this.context.analysis?.focus?.candidateFiles?.length ?? 0,
1055
+ };
1056
+ }
1057
+ /**
1058
+ * Logs selected/candidate deltas for a verify wave and returns whether focus grew.
1059
+ * Example: selected 3->4 (+1), candidate 7->9 (+2) => growth=true.
1060
+ */
1061
+ logVerifyFocusDelta(before, after) {
1062
+ const selectedDelta = after.selected - before.selected;
1063
+ const candidateDelta = after.candidate - before.candidate;
1064
+ this.logLine("ANALYSIS", "groundingDelta", undefined, `selected ${before.selected}->${after.selected} (${selectedDelta >= 0 ? "+" : ""}${selectedDelta}), candidate ${before.candidate}->${after.candidate} (${candidateDelta >= 0 ? "+" : ""}${candidateDelta})`);
1065
+ return selectedDelta > 0 || candidateDelta > 0;
1066
+ }
1067
+ /**
1068
+ * Stops verify loop when focus has not grown for too many consecutive waves.
1069
+ * Example: 2 stagnant waves in a row => stop early to avoid useless loops.
1070
+ */
1071
+ shouldStopVerifyForSaturation(stagnantWaves, maxStagnantWaves) {
1072
+ if (stagnantWaves < maxStagnantWaves)
1073
+ return false;
1074
+ this.logLine("ANALYSIS", "groundingSaturated", undefined, `No focus growth for ${stagnantWaves} consecutive wave(s); stopping early`);
1075
+ return true;
1076
+ }
1077
+ /**
1078
+ * Drops missing files from retrieval/focus sets to avoid verifier ENOENT noise.
1079
+ * Example: if DB points to deleted explainModule.ts, remove it before evidence pass.
1080
+ */
1081
+ pruneMissingVerifyPaths() {
1082
+ const init = this.context.initContext;
1083
+ const focus = this.context.analysis?.focus;
1084
+ if (!init && !focus)
1085
+ return;
1086
+ const existsOrResearch = (filePath) => {
1087
+ if (filePath.startsWith("__research__/"))
1088
+ return true;
1089
+ return fs.existsSync(filePath);
1090
+ };
1091
+ let removedRelated = 0;
1092
+ let removedSelected = 0;
1093
+ let removedCandidate = 0;
1094
+ if (init?.relatedFiles?.length) {
1095
+ const before = init.relatedFiles.length;
1096
+ init.relatedFiles = init.relatedFiles.filter(existsOrResearch);
1097
+ removedRelated = before - init.relatedFiles.length;
1098
+ if (removedRelated > 0 && init.relatedFileScores) {
1099
+ init.relatedFileScores = Object.fromEntries(Object.entries(init.relatedFileScores).filter(([filePath]) => init.relatedFiles?.includes(filePath)));
1100
+ }
1101
+ }
1102
+ if (focus?.selectedFiles?.length) {
1103
+ const before = focus.selectedFiles.length;
1104
+ focus.selectedFiles = focus.selectedFiles.filter(existsOrResearch);
1105
+ removedSelected = before - focus.selectedFiles.length;
1106
+ }
1107
+ if (focus?.candidateFiles?.length) {
1108
+ const before = focus.candidateFiles.length;
1109
+ focus.candidateFiles = focus.candidateFiles.filter(existsOrResearch);
1110
+ removedCandidate = before - focus.candidateFiles.length;
1111
+ }
1112
+ if (removedRelated + removedSelected + removedCandidate > 0) {
1113
+ this.logLine("ANALYSIS", "verifyPruneMissing", undefined, `removed related=${removedRelated}, selected=${removedSelected}, candidate=${removedCandidate}`);
1114
+ }
1115
+ }
1116
+ /**
1117
+ * Route-aware grounding budget.
1118
+ * Example: repo-wide + needs-info => allow up to 4 verification waves.
1119
+ */
1120
+ getGroundingWaveBudget() {
1121
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1122
+ const decision = this.context.analysis?.routingDecision?.decision ?? "has-info";
1123
+ const allowSearch = this.context.analysis?.routingDecision?.allowSearch ?? true;
1124
+ let budget = 2;
1125
+ if (!allowSearch || scope === "none")
1126
+ budget = 1;
1127
+ else if (scope === "single-file" && decision === "has-info")
1128
+ budget = 2;
1129
+ else if (scope === "multi-file")
1130
+ budget = 3;
1131
+ else if (scope === "repo-wide" && decision === "needs-info")
1132
+ budget = 4;
1133
+ this.logLine("ANALYSIS", "groundingBudget", undefined, `scope=${scope}, decision=${decision}, search=${allowSearch}, maxWaves=${budget}`);
1134
+ return budget;
1135
+ }
1136
+ /**
1137
+ * Dynamic task-step cap by route complexity.
1138
+ * Example: research-required lanes get 10 steps instead of 5.
1139
+ */
1140
+ getTaskStepBudget() {
1141
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1142
+ if (this.canExecuteRoute("research"))
1143
+ return 10;
1144
+ if (scope === "multi-file")
1145
+ return 7;
1146
+ if (scope === "single-file")
1147
+ return 5;
1148
+ return 6;
1149
+ }
1150
+ /**
1151
+ * Blocks execution if repo-wide complex tasks lack minimum research signal.
1152
+ * Example: require at least two analyzed files plus one understanding signal.
1153
+ */
1154
+ isResearchGateSatisfied() {
1155
+ if (!this.canExecuteRoute("research"))
1156
+ return true;
1157
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1158
+ const researchPlanCount = this.context.task?.taskSteps?.filter(s => typeof s.action === "string" && s.action.startsWith("research-")).length ?? 0;
1159
+ const pendingResearchCount = this.context.task?.taskSteps?.filter(s => typeof s.action === "string" &&
1160
+ s.action.startsWith("research-") &&
1161
+ s.status !== "completed").length ?? 0;
1162
+ const requiredResearchSteps = scope === "repo-wide"
1163
+ ? 4
1164
+ : scope === "multi-file"
1165
+ ? 3
1166
+ : 1;
1167
+ const hasResearchPlan = researchPlanCount >= requiredResearchSteps;
1168
+ if (!hasResearchPlan) {
1169
+ this.context.task.status = "deferred";
1170
+ this.context.task.reason =
1171
+ `Research phase required before execution ` +
1172
+ `(scope=${scope}, researchSteps=${researchPlanCount}, required=${requiredResearchSteps})`;
1173
+ this.persistTaskDataForRun();
1174
+ this.logLine("TASK", "Research gate blocked work loop", undefined, this.context.task.reason, { highlight: true });
1175
+ return false;
1176
+ }
1177
+ if (pendingResearchCount > 0) {
1178
+ this.logLine("TASK", "Research gate queued", undefined, `researchSteps=${researchPlanCount}, pendingResearch=${pendingResearchCount}`);
1179
+ return true;
1180
+ }
1181
+ const understanding = this.context.analysis?.understanding;
1182
+ const understandingSignals = (understanding?.assumptions?.length ?? 0) +
1183
+ (understanding?.constraints?.length ?? 0) +
1184
+ (understanding?.risks?.length ?? 0);
1185
+ if (understandingSignals > 0) {
1186
+ this.logLine("TASK", "Research gate passed", undefined, `researchSteps=${researchPlanCount}, understandingSignals=${understandingSignals}`);
1187
+ return true;
1188
+ }
1189
+ this.context.task.status = "deferred";
1190
+ this.context.task.reason = `Research completed but produced insufficient understanding signals (${understandingSignals}).`;
1191
+ this.persistTaskDataForRun();
1192
+ this.logLine("TASK", "Research gate blocked work loop", undefined, this.context.task.reason, { highlight: true });
1193
+ return false;
1194
+ }
461
1195
  /* ───────────── extracted from runWorkLoop ───────────── */
462
1196
  isWorkLoopReady() {
463
1197
  const readinessDecision = this.context.analysis?.readiness?.decision;
@@ -495,28 +1229,43 @@ export class MainAgent {
495
1229
  taskStep.stepIndex = stepCount;
496
1230
  taskStep.status = "pending";
497
1231
  persistTaskStepInsert(taskStep, getDbForRepo());
498
- this.logLine("NEW STEP", `Processing taskStep ${stepCount}`, undefined, taskStep.filePath, { highlight: true });
1232
+ const displayPath = this.formatTaskStepDisplayPath(taskStep.filePath);
1233
+ this.logLine("NEW STEP", `Processing taskStep ${stepCount}`, undefined, displayPath, { highlight: true });
499
1234
  taskStep.startTime = Date.now();
500
1235
  persistTaskStepStart(taskStep, getDbForRepo());
501
1236
  }
502
1237
  finishTaskStep(taskStep, stepCount, stepAction) {
1238
+ const displayPath = this.formatTaskStepDisplayPath(taskStep.filePath);
503
1239
  taskStep.endTime = Date.now();
504
1240
  if (stepAction === "complete") {
505
1241
  taskStep.status = "completed";
506
1242
  persistTaskStepCompletion(taskStep, getDbForRepo());
507
- this.logLine("STEP-DONE", `Completed taskStep ${stepCount}`, undefined, taskStep.filePath, { highlight: false });
1243
+ this.logLine("STEP-DONE", `Completed taskStep ${stepCount}`, undefined, displayPath, { highlight: false });
508
1244
  return;
509
1245
  }
510
1246
  taskStep.status = "pending";
511
1247
  persistTaskStepCompletion(taskStep, getDbForRepo());
512
- this.logLine("STEP", `Pending taskStep ${stepCount}`, undefined, taskStep.filePath);
1248
+ this.logLine("STEP", `Pending taskStep ${stepCount}`, undefined, displayPath);
1249
+ }
1250
+ /**
1251
+ * Normalizes internal pseudo-paths for user-facing step logs.
1252
+ * Example: "__research__/symbol-trace" -> "research/symbol-trace".
1253
+ */
1254
+ formatTaskStepDisplayPath(filePath) {
1255
+ return filePath.startsWith("__research__/")
1256
+ ? filePath.replace("__research__/", "research/")
1257
+ : filePath;
513
1258
  }
514
1259
  /* ───────────── execution gates ───────────── */
515
1260
  /**
516
- * Determines whether a phase can be executed based on execution mode and constraints.
517
- *
518
- * @param phase - The phase to check execution for.
519
- * @returns True if the phase can be executed, false otherwise.
1261
+ * Gate model:
1262
+ * 1) Phase + scope gates decide coarse permissions (what broad work is allowed).
1263
+ * 2) Route gate decides finer sub-decisions within those allowed areas (what to do next).
1264
+ */
1265
+ /**
1266
+ * Gate 1: Is this kind of work allowed at all?
1267
+ * Plain meaning: checks capability rules (e.g. read-only vs file-writing).
1268
+ * Example: for docs-only mode, analysis/planning are blocked, and writes are limited.
520
1269
  */
521
1270
  canExecutePhase(phase) {
522
1271
  const constraints = this.context.executionControl?.constraints;
@@ -536,10 +1285,9 @@ export class MainAgent {
536
1285
  }
537
1286
  /* ───────────── scope gates ───────────── */
538
1287
  /**
539
- * Determines whether a phase can be executed based on the current scope.
540
- *
541
- * @param phase - The phase to check execution for.
542
- * @returns True if the phase can be executed, false otherwise.
1288
+ * Gate 2: Is this work allowed for the current scope size?
1289
+ * Plain meaning: checks scope rules (none/single/multi/repo-wide).
1290
+ * Example: if scope is "analysis", only analysis/planning run and transform/write are blocked.
543
1291
  */
544
1292
  canExecuteScope(phase) {
545
1293
  const scope = this.context.analysis?.scopeType ?? "repo-wide";
@@ -553,6 +1301,24 @@ export class MainAgent {
553
1301
  }
554
1302
  return allowed;
555
1303
  }
1304
+ /**
1305
+ * Gate 3: Does this request path want this action right now?
1306
+ * Plain meaning: checks route-specific intent from routingDecision.
1307
+ * Example: search expansion is skipped when routing says allowSearch=false.
1308
+ */
1309
+ canExecuteRoute(action) {
1310
+ const routing = this.context.analysis?.routingDecision;
1311
+ switch (action) {
1312
+ case "search-expand":
1313
+ return routing?.allowSearch ?? true;
1314
+ case "transform":
1315
+ return routing?.allowTransform ?? true;
1316
+ case "research":
1317
+ return routing?.allowResearch ?? false;
1318
+ default:
1319
+ return true;
1320
+ }
1321
+ }
556
1322
  /* ----------------------------------- */
557
1323
  /* ------------- helpers ------------- */
558
1324
  /* ----------------------------------- */