scai 0.1.172 → 0.1.174

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,276 @@ 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
+ this.recalibrateRoutingAfterVerify();
202
+ // Research gate is evaluated after runResearch() in run().
203
+ }
204
+ /* ───────────── research ───────────── */
205
+ /**
206
+ * Seeds explicit research task steps for complex repo-wide lanes.
207
+ * Example: enqueue research-impact-map, research-symbol-trace, and research-risk-check.
208
+ */
209
+ async runResearch() {
210
+ var _a, _b;
211
+ if (!this.canExecuteRoute("research")) {
212
+ this.logLine("RESEARCH", "taskStepSeed", undefined, "skipped (route disallows research)");
213
+ return;
177
214
  }
215
+ if (!this.context.task)
216
+ return;
217
+ (_a = this.context.task).taskSteps || (_a.taskSteps = []);
218
+ await researchPlanGenStep.run(this.context);
219
+ const generatedSteps = (this.context.analysis?.planSuggestion?.plan?.steps ?? [])
220
+ .filter(step => typeof step.action === "string" && step.action.startsWith("research-"))
221
+ .map(step => {
222
+ const action = step.action;
223
+ const defaultFilePath = action === "research-impact-map"
224
+ ? "__research__/impact-map"
225
+ : action === "research-symbol-trace"
226
+ ? "__research__/symbol-trace"
227
+ : action === "research-risk-check"
228
+ ? "__research__/risk-check"
229
+ : "__research__/architecture-synthesis";
230
+ return {
231
+ action,
232
+ filePath: step.targetFile || defaultFilePath,
233
+ notes: step.description || `Run ${step.action}`,
234
+ };
235
+ });
236
+ const fallbackResearchSteps = [
237
+ {
238
+ action: "research-impact-map",
239
+ filePath: "__research__/impact-map",
240
+ notes: "Map cross-file impact before code changes.",
241
+ },
242
+ {
243
+ action: "research-symbol-trace",
244
+ filePath: "__research__/symbol-trace",
245
+ notes: "Trace key symbols across related files.",
246
+ },
247
+ {
248
+ action: "research-risk-check",
249
+ filePath: "__research__/risk-check",
250
+ notes: "Record risks, assumptions, and constraints before edits.",
251
+ },
252
+ {
253
+ action: "research-architecture-synthesis",
254
+ filePath: "__research__/architecture-synthesis",
255
+ notes: "Synthesize architecture summary, shared patterns, hotspots, and coupling points.",
256
+ },
257
+ ];
258
+ const researchSteps = generatedSteps.length > 0 ? generatedSteps : fallbackResearchSteps;
259
+ let seededCount = 0;
260
+ for (const step of researchSteps) {
261
+ const exists = this.context.task.taskSteps.some(s => s.filePath === step.filePath && s.action === step.action);
262
+ if (exists)
263
+ continue;
264
+ this.context.task.taskSteps.push({
265
+ taskId: this.context.task.id,
266
+ filePath: step.filePath,
267
+ action: step.action,
268
+ status: "pending",
269
+ notes: step.notes,
270
+ result: { phase: "research", seededBy: "runResearch" },
271
+ });
272
+ seededCount++;
273
+ }
274
+ const plannedResearchSteps = this.context.task.taskSteps
275
+ .filter(s => typeof s.action === "string" && s.action.startsWith("research-"))
276
+ .map(s => ({
277
+ action: s.action,
278
+ filePath: s.filePath,
279
+ status: s.status,
280
+ notes: s.notes,
281
+ }));
282
+ logInputOutput("runResearch", "output", {
283
+ source: generatedSteps.length > 0 ? "generated" : "fallback",
284
+ seededCount,
285
+ totalResearchSteps: plannedResearchSteps.length,
286
+ steps: plannedResearchSteps,
287
+ });
288
+ (_b = this.context).analysis || (_b.analysis = {});
289
+ this.context.analysis.planSuggestion = undefined;
290
+ this.logLine("RESEARCH", "taskStepSeed", undefined, `${seededCount} research step(s) added (${generatedSteps.length > 0 ? "generated" : "fallback"})`);
178
291
  }
292
+ /* ───────────── plan ───────────── */
179
293
  /**
180
- * Resolves grounding wave budget from current routing metadata.
181
- * Example: scope=repo-wide + decision=needs-info => 4 waves.
294
+ * Seeds ordered execution task steps from selected files + research/verify artifacts.
295
+ * Example: prioritize files that are both selected and research-touched.
182
296
  */
183
- getGroundingWaveBudget() {
297
+ async runPlan() {
298
+ var _a, _b;
299
+ if (!this.context.task)
300
+ return;
301
+ if (!this.canExecutePhase("planning") || !this.canExecuteScope("planning"))
302
+ return;
303
+ (_a = this.context).analysis || (_a.analysis = {});
304
+ (_b = this.context.task).taskSteps || (_b.taskSteps = []);
305
+ const existingExecutionPaths = new Set(this.context.task.taskSteps
306
+ .filter(step => !!step.filePath &&
307
+ !step.filePath.startsWith("__research__/"))
308
+ .map(step => step.filePath));
309
+ const selectedFiles = this.context.analysis.focus?.selectedFiles ?? [];
310
+ const touchedFromResearch = this.context.analysis.researchArtifacts?.touchedFiles ?? [];
311
+ const route = this.context.analysis.routingDecision;
312
+ const useFocusedSelectedPlanOnly = this.context.analysis.readiness?.decision === "ready" &&
313
+ (route?.decision === "has-info") &&
314
+ (route?.scopeLocked ?? false) &&
315
+ (route?.allowSearch === false) &&
316
+ selectedFiles.length > 0;
317
+ const verifyMinConfidence = this.getVerifyConfidenceThresholdForPlan();
318
+ const verifyEntries = Object.entries(this.context.analysis.verify?.byFile ?? {});
319
+ const verifyRelevantFiles = verifyEntries
320
+ .filter(([_, verify]) => verify?.isRelevant &&
321
+ (verify.fileConfidence ?? 0) >= verifyMinConfidence)
322
+ .map(([filePath]) => filePath);
323
+ const verifySkippedLowConfidenceCount = verifyEntries.filter(([_, verify]) => !!verify?.isRelevant &&
324
+ (verify.fileConfidence ?? 0) < verifyMinConfidence).length;
325
+ const rankPath = (filePath) => {
326
+ const inSelected = selectedFiles.includes(filePath);
327
+ const inResearchTouched = touchedFromResearch.includes(filePath);
328
+ const inVerify = verifyRelevantFiles.includes(filePath);
329
+ if (inSelected && inResearchTouched)
330
+ return 0;
331
+ if (inSelected)
332
+ return 1;
333
+ if (inResearchTouched)
334
+ return 2;
335
+ if (inVerify)
336
+ return 3;
337
+ return 4;
338
+ };
339
+ const plannedPathsSource = useFocusedSelectedPlanOnly
340
+ ? Array.from(new Set(selectedFiles))
341
+ : Array.from(new Set([
342
+ ...selectedFiles,
343
+ ...touchedFromResearch,
344
+ ...verifyRelevantFiles,
345
+ ]));
346
+ const plannedPaths = plannedPathsSource
347
+ .filter(filePath => !!filePath && !filePath.startsWith("__research__/") && fs.existsSync(filePath))
348
+ .sort((a, b) => rankPath(a) - rankPath(b))
349
+ .slice(0, 16);
350
+ let seededCount = 0;
351
+ const seeded = [];
352
+ for (const filePath of plannedPaths) {
353
+ if (existingExecutionPaths.has(filePath))
354
+ continue;
355
+ const rank = rankPath(filePath);
356
+ const notes = rank === 0
357
+ ? "Plan priority: selected + research-touched"
358
+ : rank === 1
359
+ ? "Plan priority: selected file"
360
+ : rank === 2
361
+ ? "Plan priority: research-touched file"
362
+ : "Plan priority: verify-relevant file";
363
+ this.context.task.taskSteps.push({
364
+ taskId: this.context.task.id,
365
+ filePath,
366
+ status: "pending",
367
+ notes,
368
+ result: {
369
+ phase: "plan",
370
+ seededBy: "runPlan",
371
+ priorityRank: rank,
372
+ },
373
+ });
374
+ seeded.push({ filePath, rank, notes });
375
+ seededCount++;
376
+ }
377
+ logInputOutput("runPlan", "output", {
378
+ seededCount,
379
+ totalPlannedPaths: plannedPaths.length,
380
+ selectedFileCount: selectedFiles.length,
381
+ researchTouchedCount: touchedFromResearch.length,
382
+ verifyRelevantCount: verifyRelevantFiles.length,
383
+ focusedSelectedOnly: useFocusedSelectedPlanOnly,
384
+ verifyMinConfidence,
385
+ verifySkippedLowConfidenceCount,
386
+ seeded,
387
+ });
388
+ this.logLine("PLAN", "taskStepSeed", undefined, `${seededCount} execution step(s) planned`);
389
+ }
390
+ /**
391
+ * Sets minimum verify confidence before a file can be plan-seeded from verify-only signal.
392
+ * Example: single-file lanes require higher confidence than repo-wide lanes.
393
+ */
394
+ getVerifyConfidenceThresholdForPlan() {
184
395
  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;
396
+ if (scope === "single-file")
397
+ return 0.45;
398
+ if (scope === "multi-file")
399
+ return 0.35;
400
+ return 0.3;
401
+ }
402
+ /**
403
+ * Re-routes after verify when evidence converges on selected files with high confidence.
404
+ * Example: selected files strongly verified => disable expansion/research and lock focused execution.
405
+ */
406
+ recalibrateRoutingAfterVerify() {
407
+ var _a;
408
+ (_a = this.context).analysis || (_a.analysis = {});
409
+ const routing = this.context.analysis.routingDecision;
410
+ if (!routing)
411
+ return;
412
+ const selectedFiles = this.context.analysis.focus?.selectedFiles ?? [];
413
+ if (selectedFiles.length === 0)
414
+ return;
415
+ const readinessConfidence = this.context.analysis.readiness?.confidence ?? 0;
416
+ const intentConfidence = this.context.analysis.intent?.confidence ?? 0;
417
+ const minFileConfidence = 0.28;
418
+ const strongSelected = selectedFiles.filter(filePath => {
419
+ const verify = this.context.analysis?.verify?.byFile?.[filePath];
420
+ return verify?.isRelevant === true && (verify.fileConfidence ?? 0) >= minFileConfidence;
421
+ });
422
+ const convergedSingle = selectedFiles.length === 1 &&
423
+ strongSelected.length === 1 &&
424
+ readinessConfidence >= 0.9 &&
425
+ intentConfidence >= 0.8;
426
+ const convergedMulti = selectedFiles.length >= 2 &&
427
+ strongSelected.length >= 2 &&
428
+ readinessConfidence >= 0.9 &&
429
+ intentConfidence >= 0.75;
430
+ if (!convergedSingle && !convergedMulti)
431
+ return;
432
+ routing.decision = "has-info";
433
+ routing.allowSearch = false;
434
+ routing.allowResearch = false;
435
+ routing.scopeLocked = true;
436
+ routing.rationale = `${routing.rationale}; postVerify=focused-selection(${strongSelected.length})`;
437
+ this.logLine("TASK", "Routing recalibrated", undefined, `focused=${selectedFiles.length} selected, strong=${strongSelected.length}`);
198
438
  }
199
439
  /* ───────────── work loop ───────────── */
200
440
  async runWorkLoop() {
201
- if (!this.isWorkLoopReady())
441
+ if (this.context.task.status !== "active")
202
442
  return;
203
443
  this.ensureTaskForWorkLoop();
204
- const MAX_TASK_STEPS = 5;
444
+ const MAX_TASK_STEPS = this.getTaskStepBudget();
205
445
  let stepCount = 0;
206
446
  while (stepCount < MAX_TASK_STEPS &&
207
447
  this.context.task.status === "active") {
@@ -232,7 +472,17 @@ export class MainAgent {
232
472
  }
233
473
  this.logLine("TASK", "Max task step limit reached — stopping work loop", undefined, undefined, { highlight: false });
234
474
  }
475
+ /* ───────────── finalize ───────────── */
476
+ async runFinalize() {
477
+ await finalAnswerModule.run({ query: this.query, context: this.context });
478
+ persistTaskData(this.context, this.taskId, getDbForRepo(), this.logLine.bind(this));
479
+ this.logLine("TASK", "Finalize complete", undefined, undefined, { highlight: false });
480
+ }
235
481
  /* ───────────── step iterations ───────────── */
482
+ /**
483
+ * Iterates one task step until it completes, needs feedback, or asks for redo.
484
+ * Example: validation failure sets nextAction=redo-step and re-runs iteration.
485
+ */
236
486
  async runStepIterations(taskStep) {
237
487
  const MAX_ITERATIONS = 5;
238
488
  let loopCount = 0;
@@ -261,9 +511,17 @@ export class MainAgent {
261
511
  return "continue";
262
512
  }
263
513
  /* ───────────── work iteration ───────────── */
514
+ /**
515
+ * Executes one analyze/transform/validate pass for the current task step.
516
+ * Example: generate analysis plan, run one transform step, then validate.
517
+ */
264
518
  async runWorkIteration(taskStep) {
265
519
  if (!this.context.analysis)
266
520
  this.context.analysis = {};
521
+ if (taskStep.action?.startsWith("research-")) {
522
+ await this.executeResearchTaskStep(taskStep);
523
+ return;
524
+ }
267
525
  if (this.canExecutePhase("analysis") && this.canExecuteScope("analysis")) {
268
526
  const tAnalysis = this.startTimer();
269
527
  await analysisPlanGenStep.run(this.context);
@@ -309,6 +567,375 @@ export class MainAgent {
309
567
  await integrateFeedbackStep.run(this.context);
310
568
  this.logLine("FEEDBACK", "integrateFeedbackStep", tIntegrate());
311
569
  }
570
+ /**
571
+ * Executes deterministic research steps and marks them complete.
572
+ * Example: research-impact-map summarizes affected files and seeds understanding notes.
573
+ */
574
+ async executeResearchTaskStep(taskStep) {
575
+ var _a, _b;
576
+ const selectedFiles = this.context.analysis?.focus?.selectedFiles ?? [];
577
+ const candidateFiles = this.context.analysis?.focus?.candidateFiles ?? [];
578
+ const fileAnalysis = this.context.analysis?.fileAnalysis ?? {};
579
+ const researchTerms = this.buildResearchTerms();
580
+ const researchPaths = this.collectResearchPaths(24);
581
+ const corpus = this.loadResearchCorpus(researchPaths, 12, 12000);
582
+ const understanding = (_b = ((_a = this.context).analysis || (_a.analysis = {}))).understanding || (_b.understanding = {
583
+ assumptions: [],
584
+ constraints: [],
585
+ risks: [],
586
+ sharedPatterns: [],
587
+ hotspots: [],
588
+ couplingPoints: [],
589
+ });
590
+ const addUnique = (arr, value) => {
591
+ if (!arr)
592
+ return;
593
+ if (!arr.includes(value))
594
+ arr.push(value);
595
+ };
596
+ let summary = "";
597
+ let collectedData = {
598
+ selectedFiles: selectedFiles.slice(0, 12),
599
+ selectedFileCount: selectedFiles.length,
600
+ candidateFileCount: candidateFiles.length,
601
+ researchTerms,
602
+ corpusFilesRead: corpus.length,
603
+ corpusPaths: corpus.map(f => f.path).slice(0, 12),
604
+ };
605
+ switch (taskStep.action) {
606
+ case "research-impact-map": {
607
+ const touched = selectedFiles.length;
608
+ const impactRows = corpus
609
+ .map(file => {
610
+ const termHits = this.computeTermHits(file.content, researchTerms);
611
+ const termHitTotal = Object.values(termHits).reduce((acc, n) => acc + n, 0);
612
+ const importCount = this.countRegex(file.content, /\bimport\b|\brequire\s*\(/g);
613
+ const exportCount = this.countRegex(file.content, /\bexport\b|module\.exports/g);
614
+ const score = termHitTotal * 3 + importCount * 2 + exportCount;
615
+ return {
616
+ filePath: file.path,
617
+ score,
618
+ termHits,
619
+ importCount,
620
+ exportCount,
621
+ lineCount: file.lineCount,
622
+ };
623
+ })
624
+ .sort((a, b) => b.score - a.score)
625
+ .slice(0, 8);
626
+ summary = `Impact map across ${touched} selected file(s).`;
627
+ addUnique(understanding.constraints, `Refactor impact spans ${touched} file(s).`);
628
+ collectedData = {
629
+ ...collectedData,
630
+ touchedFiles: selectedFiles.slice(0, 20),
631
+ impactSignals: [
632
+ `selected=${selectedFiles.length}`,
633
+ `candidates=${candidateFiles.length}`,
634
+ ],
635
+ impactMap: impactRows,
636
+ };
637
+ break;
638
+ }
639
+ case "research-symbol-trace": {
640
+ const structuralSymbols = Object.values(fileAnalysis)
641
+ .flatMap(fa => fa.structural?.functions?.map(fn => fn.name).filter(Boolean) ?? [])
642
+ .slice(0, 24);
643
+ const fallbackSymbols = corpus
644
+ .flatMap(file => Array.from(file.content.matchAll(/\b(function|class|const|let|var)\s+([A-Za-z_]\w*)/g)).map(m => m[2]))
645
+ .filter(Boolean);
646
+ const symbolPool = Array.from(new Set([...structuralSymbols, ...fallbackSymbols])).slice(0, 18);
647
+ const traceRows = symbolPool
648
+ .map(symbol => {
649
+ const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
650
+ const re = new RegExp(`\\b${escaped}\\b`, "g");
651
+ const files = corpus
652
+ .map(file => ({ filePath: file.path, count: this.countRegex(file.content, re) }))
653
+ .filter(item => item.count > 0);
654
+ return {
655
+ symbol,
656
+ occurrenceCount: files.reduce((acc, f) => acc + f.count, 0),
657
+ files: files.slice(0, 8),
658
+ };
659
+ })
660
+ .filter(row => row.occurrenceCount > 0)
661
+ .sort((a, b) => b.occurrenceCount - a.occurrenceCount)
662
+ .slice(0, 10);
663
+ summary = traceRows.length
664
+ ? `Traced ${traceRows.length} symbol(s) from corpus.`
665
+ : "No structural symbols found; symbol trace used filename-level anchors.";
666
+ addUnique(understanding.assumptions, "Symbol trace coverage is partial and based on current selected files.");
667
+ collectedData = {
668
+ ...collectedData,
669
+ tracedSymbols: traceRows.map(s => s.symbol),
670
+ symbolTrace: traceRows,
671
+ analyzedFileCount: Object.values(fileAnalysis).filter(fa => fa?.semanticAnalyzed).length,
672
+ };
673
+ break;
674
+ }
675
+ case "research-risk-check": {
676
+ const riskPatterns = [
677
+ { id: "empty-catch", description: "Empty catch blocks", pattern: /catch\s*\(\s*[^)]*\)\s*\{\s*\}/g },
678
+ { id: "console-error", description: "Console error logging", pattern: /\bconsole\.error\s*\(/g },
679
+ { id: "forced-exit", description: "Process exit usage", pattern: /\bprocess\.exit\s*\(/g },
680
+ { id: "throws-string", description: "Throwing non-Error values", pattern: /\bthrow\s+['"`]/g },
681
+ ];
682
+ const riskRows = riskPatterns
683
+ .map(risk => {
684
+ const perFile = corpus
685
+ .map(file => ({ filePath: file.path, count: this.countRegex(file.content, risk.pattern) }))
686
+ .filter(hit => hit.count > 0);
687
+ return {
688
+ id: risk.id,
689
+ description: risk.description,
690
+ totalHits: perFile.reduce((acc, hit) => acc + hit.count, 0),
691
+ files: perFile.slice(0, 8),
692
+ };
693
+ })
694
+ .filter(risk => risk.totalHits > 0);
695
+ summary = "Recorded baseline risks/assumptions/constraints before transformation.";
696
+ addUnique(understanding.risks, "Cross-file regressions are possible without full symbol coverage.");
697
+ addUnique(understanding.risks, "Validation should run after each transform step.");
698
+ for (const risk of riskRows) {
699
+ addUnique(understanding.risks, `${risk.description}: ${risk.totalHits} hit(s)`);
700
+ }
701
+ collectedData = {
702
+ ...collectedData,
703
+ risks: understanding.risks?.slice(0, 12) ?? [],
704
+ assumptions: understanding.assumptions?.slice(0, 12) ?? [],
705
+ constraints: understanding.constraints?.slice(0, 12) ?? [],
706
+ riskSignals: riskRows,
707
+ };
708
+ break;
709
+ }
710
+ case "research-architecture-synthesis": {
711
+ const analyzedPaths = Object.entries(fileAnalysis)
712
+ .filter(([_, fa]) => fa?.semanticAnalyzed)
713
+ .map(([filePath]) => filePath);
714
+ const architectureFiles = (analyzedPaths.length > 0 ? analyzedPaths : corpus.map(file => file.path)).slice(0, 8);
715
+ understanding.problemStatement =
716
+ `Summarize repository architecture and identify weak coupling points across ${selectedFiles.length} scoped file(s).`;
717
+ for (const p of architectureFiles) {
718
+ const base = path.basename(p);
719
+ if (base.toLowerCase().includes("registry")) {
720
+ addUnique(understanding.hotspots, `${base}: central registry point with broad module fan-in.`);
721
+ addUnique(understanding.couplingPoints, `${base}: centralized module registration coupling.`);
722
+ }
723
+ if (base.toLowerCase().includes("module")) {
724
+ addUnique(understanding.sharedPatterns, `${base}: module-oriented pipeline pattern.`);
725
+ }
726
+ }
727
+ addUnique(understanding.sharedPatterns, "Pipeline modules follow a shared Module/ModuleIO contract.");
728
+ addUnique(understanding.couplingPoints, "Shared config/model utilities create cross-module coupling.");
729
+ addUnique(understanding.hotspots, "Core orchestration and registry layers are high-impact change zones.");
730
+ summary = `Architecture synthesis completed from ${architectureFiles.length} analyzed file(s).`;
731
+ const priorResearch = (this.context.task?.taskSteps ?? [])
732
+ .filter(step => step.action?.startsWith("research-") && step.status === "completed")
733
+ .map(step => ({
734
+ action: step.action,
735
+ summary: step.result?.research?.summary,
736
+ }));
737
+ collectedData = {
738
+ ...collectedData,
739
+ architectureInputFiles: architectureFiles,
740
+ priorResearchSummaries: priorResearch,
741
+ problemStatement: understanding.problemStatement ?? "",
742
+ sharedPatterns: understanding.sharedPatterns?.slice(0, 12) ?? [],
743
+ hotspots: understanding.hotspots?.slice(0, 12) ?? [],
744
+ couplingPoints: understanding.couplingPoints?.slice(0, 12) ?? [],
745
+ };
746
+ break;
747
+ }
748
+ default: {
749
+ summary = `Unknown research action: ${taskStep.action}`;
750
+ collectedData = {
751
+ ...collectedData,
752
+ warning: "No handler for research action",
753
+ };
754
+ break;
755
+ }
756
+ }
757
+ const completedAt = new Date().toISOString();
758
+ const researchEntry = {
759
+ action: taskStep.action,
760
+ summary,
761
+ collectedData,
762
+ selectedFileCount: selectedFiles.length,
763
+ completedAt,
764
+ };
765
+ taskStep.result || (taskStep.result = {});
766
+ taskStep.result.research = researchEntry;
767
+ taskStep.result.stepReasoning = {
768
+ nextAction: "complete",
769
+ rationale: `Research step completed: ${summary}`,
770
+ confidence: 0.95,
771
+ };
772
+ taskStep.status = "completed";
773
+ this.persistResearchArtifact(researchEntry);
774
+ logInputOutput("runResearchStep", "output", {
775
+ research: researchEntry,
776
+ stepReasoning: taskStep.result.stepReasoning,
777
+ status: taskStep.status,
778
+ });
779
+ }
780
+ /**
781
+ * Persists normalized research outputs into analysis.researchArtifacts.
782
+ * Example: latestByAction["research-risk-check"] stores current risk findings.
783
+ */
784
+ persistResearchArtifact(entry) {
785
+ var _a, _b;
786
+ (_a = this.context).analysis || (_a.analysis = {});
787
+ const store = (_b = this.context.analysis).researchArtifacts || (_b.researchArtifacts = {
788
+ latestByAction: {},
789
+ history: [],
790
+ touchedFiles: [],
791
+ lastUpdatedAt: entry.completedAt,
792
+ });
793
+ store.latestByAction || (store.latestByAction = {});
794
+ store.history || (store.history = []);
795
+ store.touchedFiles || (store.touchedFiles = []);
796
+ store.latestByAction[entry.action] = entry;
797
+ store.history.push(entry);
798
+ const data = entry.collectedData ?? {};
799
+ const touched = this.extractPathsFromResearchData(data);
800
+ const merged = new Set([...(store.touchedFiles ?? []), ...touched]);
801
+ store.touchedFiles = Array.from(merged);
802
+ store.lastUpdatedAt = entry.completedAt;
803
+ }
804
+ /**
805
+ * Extracts file paths from heterogeneous research payloads.
806
+ * Example: impactMap rows and architectureInputFiles are both merged into touchedFiles.
807
+ */
808
+ extractPathsFromResearchData(data) {
809
+ const paths = new Set();
810
+ const addPath = (value) => {
811
+ if (typeof value === "string" && value.trim().length > 0) {
812
+ paths.add(value);
813
+ }
814
+ };
815
+ const addPathArray = (value) => {
816
+ if (!Array.isArray(value))
817
+ return;
818
+ for (const item of value) {
819
+ addPath(item);
820
+ }
821
+ };
822
+ addPathArray(data.corpusPaths);
823
+ addPathArray(data.touchedFiles);
824
+ addPathArray(data.architectureInputFiles);
825
+ if (Array.isArray(data.impactMap)) {
826
+ for (const row of data.impactMap) {
827
+ addPath(row.filePath);
828
+ }
829
+ }
830
+ if (Array.isArray(data.symbolTrace)) {
831
+ for (const row of data.symbolTrace) {
832
+ const files = row.files;
833
+ if (!Array.isArray(files))
834
+ continue;
835
+ for (const fileRow of files) {
836
+ addPath(fileRow.filePath);
837
+ }
838
+ }
839
+ }
840
+ if (Array.isArray(data.riskSignals)) {
841
+ for (const row of data.riskSignals) {
842
+ const files = row.files;
843
+ if (!Array.isArray(files))
844
+ continue;
845
+ for (const fileRow of files) {
846
+ addPath(fileRow.filePath);
847
+ }
848
+ }
849
+ }
850
+ return Array.from(paths);
851
+ }
852
+ /**
853
+ * Builds lightweight query terms for deterministic research scanning.
854
+ * Example: "error handling test suite" -> ["error","handling","test","suite"].
855
+ */
856
+ buildResearchTerms() {
857
+ const query = this.context.analysis?.intent?.normalizedQuery ??
858
+ this.context.initContext?.userQuery ??
859
+ this.query;
860
+ const stopWords = new Set([
861
+ "the", "and", "for", "with", "from", "this", "that", "what", "how",
862
+ "is", "are", "was", "were", "can", "could", "should", "would", "into",
863
+ "about", "across", "repo", "codebase", "please",
864
+ ]);
865
+ return Array.from(new Set(query
866
+ .toLowerCase()
867
+ .split(/[^a-z0-9_]+/g)
868
+ .filter(token => token.length >= 3 && !stopWords.has(token)))).slice(0, 10);
869
+ }
870
+ /**
871
+ * Collects research candidate paths from selected, candidate, related, and working files.
872
+ * Example: selected files are prioritized before broader related file pool.
873
+ */
874
+ collectResearchPaths(maxPaths) {
875
+ const focus = this.context.analysis?.focus;
876
+ const workingPaths = (this.context.workingFiles ?? []).map(file => file.path);
877
+ const related = this.context.initContext?.relatedFiles ?? [];
878
+ const combined = [
879
+ ...(focus?.selectedFiles ?? []),
880
+ ...(focus?.candidateFiles ?? []),
881
+ ...workingPaths,
882
+ ...related,
883
+ ];
884
+ const unique = Array.from(new Set(combined));
885
+ return unique
886
+ .filter(filePath => !filePath.startsWith("__research__/") && fs.existsSync(filePath))
887
+ .slice(0, maxPaths);
888
+ }
889
+ /**
890
+ * Reads a bounded corpus from candidate paths.
891
+ * Example: read first 12 files, max 12k chars per file, skipping binary payloads.
892
+ */
893
+ loadResearchCorpus(filePaths, maxFiles, maxCharsPerFile) {
894
+ const corpus = [];
895
+ for (const filePath of filePaths.slice(0, maxFiles)) {
896
+ try {
897
+ const raw = fs.readFileSync(filePath, "utf-8");
898
+ if (raw.includes("\u0000"))
899
+ continue;
900
+ const content = raw.slice(0, maxCharsPerFile);
901
+ corpus.push({
902
+ path: filePath,
903
+ content,
904
+ lineCount: content.split("\n").length,
905
+ charCount: content.length,
906
+ });
907
+ }
908
+ catch {
909
+ // Ignore unreadable files and continue.
910
+ }
911
+ }
912
+ return corpus;
913
+ }
914
+ /**
915
+ * Counts regex matches safely.
916
+ * Example: countRegex(code, /import/g) -> number of import occurrences.
917
+ */
918
+ countRegex(content, pattern) {
919
+ const source = pattern.source;
920
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
921
+ const re = new RegExp(source, flags);
922
+ return Array.from(content.matchAll(re)).length;
923
+ }
924
+ /**
925
+ * Computes per-term match counts for a file body.
926
+ * Example: terms ["error","test"] -> { error: 4, test: 2 }.
927
+ */
928
+ computeTermHits(content, terms) {
929
+ const hits = {};
930
+ for (const term of terms) {
931
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
932
+ const count = this.countRegex(content, new RegExp(`\\b${escaped}\\b`, "gi"));
933
+ if (count > 0) {
934
+ hits[term] = count;
935
+ }
936
+ }
937
+ return hits;
938
+ }
312
939
  /* ───────────── step executor ───────────── */
313
940
  /**
314
941
  * Executes a single step using its corresponding module.
@@ -344,13 +971,7 @@ export class MainAgent {
344
971
  throw err;
345
972
  }
346
973
  }
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 ───────────── */
974
+ /* ───────────── extracted from runSearch ───────────── */
354
975
  resolveInitialRetrievalQueries() {
355
976
  const rawUserQuery = this.context.initContext?.userQuery ?? this.query;
356
977
  const retrievalQuery = this.context.analysis?.intent?.normalizedQuery?.trim() || rawUserQuery;
@@ -391,6 +1012,8 @@ export class MainAgent {
391
1012
  };
392
1013
  }
393
1014
  mergeSeededInitialContext(rawUserQuery, seededContext) {
1015
+ // Merge retrieval seed into initContext without losing previously discovered files.
1016
+ // Example: keep old relatedFiles and append newly seeded files from buildLightContext.
394
1017
  const existingInit = this.context.initContext ?? { userQuery: rawUserQuery };
395
1018
  const seededInit = seededContext.initContext;
396
1019
  const mergedRelatedFiles = Array.from(new Set([
@@ -419,6 +1042,8 @@ export class MainAgent {
419
1042
  return mergedRelatedFiles.length;
420
1043
  }
421
1044
  applyDeterministicPreGroundingPrefilter(retrievalQuery) {
1045
+ // Rank and cap retrieval candidates before grounding to reduce noisy evidence passes.
1046
+ // Example: explicit filename anchors are always kept even if BM25 score is low.
422
1047
  const init = this.context.initContext;
423
1048
  if (!init?.relatedFiles?.length)
424
1049
  return { before: 0, after: 0 };
@@ -458,6 +1083,184 @@ export class MainAgent {
458
1083
  });
459
1084
  return { before, after: init.relatedFiles.length };
460
1085
  }
1086
+ injectWellKnownRepoFiles(currentCount) {
1087
+ // Add high-signal repo root files when scope is broad or retrieval is sparse.
1088
+ // Example: on repo-wide queries, include package.json/README if present.
1089
+ const init = this.context.initContext;
1090
+ if (!init)
1091
+ return { added: 0, reason: "none" };
1092
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1093
+ const shouldInjectByScope = scope === "repo-wide";
1094
+ const shouldInjectByFallback = currentCount < 3;
1095
+ if (!shouldInjectByScope && !shouldInjectByFallback) {
1096
+ return { added: 0, reason: "none" };
1097
+ }
1098
+ const reason = shouldInjectByScope ? "scope" : "fallback";
1099
+ const candidates = WELL_KNOWN_REPO_FILE_BASENAMES
1100
+ .map(fileName => path.join(process.cwd(), fileName))
1101
+ .filter(filePath => fs.existsSync(filePath))
1102
+ .slice(0, MAX_WELL_KNOWN_REPO_FILES);
1103
+ if (candidates.length === 0)
1104
+ return { added: 0, reason };
1105
+ const existing = new Set(init.relatedFiles ?? []);
1106
+ let added = 0;
1107
+ for (const filePath of candidates) {
1108
+ if (existing.has(filePath))
1109
+ continue;
1110
+ existing.add(filePath);
1111
+ added++;
1112
+ }
1113
+ init.relatedFiles = Array.from(existing);
1114
+ return { added, reason };
1115
+ }
1116
+ /**
1117
+ * Captures focus sizes before/after a verify wave for growth tracking.
1118
+ * Example: selected=3,candidate=7 before wave; selected=4,candidate=9 after wave.
1119
+ */
1120
+ captureVerifyFocusSnapshot() {
1121
+ return {
1122
+ selected: this.context.analysis?.focus?.selectedFiles?.length ?? 0,
1123
+ candidate: this.context.analysis?.focus?.candidateFiles?.length ?? 0,
1124
+ };
1125
+ }
1126
+ /**
1127
+ * Logs selected/candidate deltas for a verify wave and returns whether focus grew.
1128
+ * Example: selected 3->4 (+1), candidate 7->9 (+2) => growth=true.
1129
+ */
1130
+ logVerifyFocusDelta(before, after) {
1131
+ const selectedDelta = after.selected - before.selected;
1132
+ const candidateDelta = after.candidate - before.candidate;
1133
+ this.logLine("ANALYSIS", "groundingDelta", undefined, `selected ${before.selected}->${after.selected} (${selectedDelta >= 0 ? "+" : ""}${selectedDelta}), candidate ${before.candidate}->${after.candidate} (${candidateDelta >= 0 ? "+" : ""}${candidateDelta})`);
1134
+ return selectedDelta > 0 || candidateDelta > 0;
1135
+ }
1136
+ /**
1137
+ * Stops verify loop when focus has not grown for too many consecutive waves.
1138
+ * Example: 2 stagnant waves in a row => stop early to avoid useless loops.
1139
+ */
1140
+ shouldStopVerifyForSaturation(stagnantWaves, maxStagnantWaves) {
1141
+ if (stagnantWaves < maxStagnantWaves)
1142
+ return false;
1143
+ this.logLine("ANALYSIS", "groundingSaturated", undefined, `No focus growth for ${stagnantWaves} consecutive wave(s); stopping early`);
1144
+ return true;
1145
+ }
1146
+ /**
1147
+ * Drops missing files from retrieval/focus sets to avoid verifier ENOENT noise.
1148
+ * Example: if DB points to deleted explainModule.ts, remove it before evidence pass.
1149
+ */
1150
+ pruneMissingVerifyPaths() {
1151
+ const init = this.context.initContext;
1152
+ const focus = this.context.analysis?.focus;
1153
+ if (!init && !focus)
1154
+ return;
1155
+ const existsOrResearch = (filePath) => {
1156
+ if (filePath.startsWith("__research__/"))
1157
+ return true;
1158
+ return fs.existsSync(filePath);
1159
+ };
1160
+ let removedRelated = 0;
1161
+ let removedSelected = 0;
1162
+ let removedCandidate = 0;
1163
+ if (init?.relatedFiles?.length) {
1164
+ const before = init.relatedFiles.length;
1165
+ init.relatedFiles = init.relatedFiles.filter(existsOrResearch);
1166
+ removedRelated = before - init.relatedFiles.length;
1167
+ if (removedRelated > 0 && init.relatedFileScores) {
1168
+ init.relatedFileScores = Object.fromEntries(Object.entries(init.relatedFileScores).filter(([filePath]) => init.relatedFiles?.includes(filePath)));
1169
+ }
1170
+ }
1171
+ if (focus?.selectedFiles?.length) {
1172
+ const before = focus.selectedFiles.length;
1173
+ focus.selectedFiles = focus.selectedFiles.filter(existsOrResearch);
1174
+ removedSelected = before - focus.selectedFiles.length;
1175
+ }
1176
+ if (focus?.candidateFiles?.length) {
1177
+ const before = focus.candidateFiles.length;
1178
+ focus.candidateFiles = focus.candidateFiles.filter(existsOrResearch);
1179
+ removedCandidate = before - focus.candidateFiles.length;
1180
+ }
1181
+ if (removedRelated + removedSelected + removedCandidate > 0) {
1182
+ this.logLine("ANALYSIS", "verifyPruneMissing", undefined, `removed related=${removedRelated}, selected=${removedSelected}, candidate=${removedCandidate}`);
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Route-aware grounding budget.
1187
+ * Example: repo-wide + needs-info => allow up to 4 verification waves.
1188
+ */
1189
+ getGroundingWaveBudget() {
1190
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1191
+ const decision = this.context.analysis?.routingDecision?.decision ?? "has-info";
1192
+ const allowSearch = this.context.analysis?.routingDecision?.allowSearch ?? true;
1193
+ let budget = 2;
1194
+ if (!allowSearch || scope === "none")
1195
+ budget = 1;
1196
+ else if (scope === "single-file" && decision === "has-info")
1197
+ budget = 2;
1198
+ else if (scope === "multi-file")
1199
+ budget = 3;
1200
+ else if (scope === "repo-wide" && decision === "needs-info")
1201
+ budget = 4;
1202
+ this.logLine("ANALYSIS", "groundingBudget", undefined, `scope=${scope}, decision=${decision}, search=${allowSearch}, maxWaves=${budget}`);
1203
+ return budget;
1204
+ }
1205
+ /**
1206
+ * Dynamic task-step cap by route complexity.
1207
+ * Example: research-required lanes get 10 steps instead of 5.
1208
+ */
1209
+ getTaskStepBudget() {
1210
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1211
+ if (this.canExecuteRoute("research"))
1212
+ return 10;
1213
+ if (scope === "multi-file")
1214
+ return 7;
1215
+ if (scope === "single-file")
1216
+ return 5;
1217
+ return 6;
1218
+ }
1219
+ /**
1220
+ * Blocks execution if repo-wide complex tasks lack minimum research signal.
1221
+ * Example: require at least two analyzed files plus one understanding signal.
1222
+ */
1223
+ isResearchGateSatisfied() {
1224
+ if (!this.canExecuteRoute("research"))
1225
+ return true;
1226
+ const scope = this.context.analysis?.scopeType ?? "repo-wide";
1227
+ const researchPlanCount = this.context.task?.taskSteps?.filter(s => typeof s.action === "string" && s.action.startsWith("research-")).length ?? 0;
1228
+ const pendingResearchCount = this.context.task?.taskSteps?.filter(s => typeof s.action === "string" &&
1229
+ s.action.startsWith("research-") &&
1230
+ s.status !== "completed").length ?? 0;
1231
+ const requiredResearchSteps = scope === "repo-wide"
1232
+ ? 4
1233
+ : scope === "multi-file"
1234
+ ? 3
1235
+ : 1;
1236
+ const hasResearchPlan = researchPlanCount >= requiredResearchSteps;
1237
+ if (!hasResearchPlan) {
1238
+ this.context.task.status = "deferred";
1239
+ this.context.task.reason =
1240
+ `Research phase required before execution ` +
1241
+ `(scope=${scope}, researchSteps=${researchPlanCount}, required=${requiredResearchSteps})`;
1242
+ this.persistTaskDataForRun();
1243
+ this.logLine("TASK", "Research gate blocked work loop", undefined, this.context.task.reason, { highlight: true });
1244
+ return false;
1245
+ }
1246
+ if (pendingResearchCount > 0) {
1247
+ this.logLine("TASK", "Research gate queued", undefined, `researchSteps=${researchPlanCount}, pendingResearch=${pendingResearchCount}`);
1248
+ return true;
1249
+ }
1250
+ const understanding = this.context.analysis?.understanding;
1251
+ const understandingSignals = (understanding?.assumptions?.length ?? 0) +
1252
+ (understanding?.constraints?.length ?? 0) +
1253
+ (understanding?.risks?.length ?? 0);
1254
+ if (understandingSignals > 0) {
1255
+ this.logLine("TASK", "Research gate passed", undefined, `researchSteps=${researchPlanCount}, understandingSignals=${understandingSignals}`);
1256
+ return true;
1257
+ }
1258
+ this.context.task.status = "deferred";
1259
+ this.context.task.reason = `Research completed but produced insufficient understanding signals (${understandingSignals}).`;
1260
+ this.persistTaskDataForRun();
1261
+ this.logLine("TASK", "Research gate blocked work loop", undefined, this.context.task.reason, { highlight: true });
1262
+ return false;
1263
+ }
461
1264
  /* ───────────── extracted from runWorkLoop ───────────── */
462
1265
  isWorkLoopReady() {
463
1266
  const readinessDecision = this.context.analysis?.readiness?.decision;
@@ -495,28 +1298,43 @@ export class MainAgent {
495
1298
  taskStep.stepIndex = stepCount;
496
1299
  taskStep.status = "pending";
497
1300
  persistTaskStepInsert(taskStep, getDbForRepo());
498
- this.logLine("NEW STEP", `Processing taskStep ${stepCount}`, undefined, taskStep.filePath, { highlight: true });
1301
+ const displayPath = this.formatTaskStepDisplayPath(taskStep.filePath);
1302
+ this.logLine("NEW STEP", `Processing taskStep ${stepCount}`, undefined, displayPath, { highlight: true });
499
1303
  taskStep.startTime = Date.now();
500
1304
  persistTaskStepStart(taskStep, getDbForRepo());
501
1305
  }
502
1306
  finishTaskStep(taskStep, stepCount, stepAction) {
1307
+ const displayPath = this.formatTaskStepDisplayPath(taskStep.filePath);
503
1308
  taskStep.endTime = Date.now();
504
1309
  if (stepAction === "complete") {
505
1310
  taskStep.status = "completed";
506
1311
  persistTaskStepCompletion(taskStep, getDbForRepo());
507
- this.logLine("STEP-DONE", `Completed taskStep ${stepCount}`, undefined, taskStep.filePath, { highlight: false });
1312
+ this.logLine("STEP-DONE", `Completed taskStep ${stepCount}`, undefined, displayPath, { highlight: false });
508
1313
  return;
509
1314
  }
510
1315
  taskStep.status = "pending";
511
1316
  persistTaskStepCompletion(taskStep, getDbForRepo());
512
- this.logLine("STEP", `Pending taskStep ${stepCount}`, undefined, taskStep.filePath);
1317
+ this.logLine("STEP", `Pending taskStep ${stepCount}`, undefined, displayPath);
1318
+ }
1319
+ /**
1320
+ * Normalizes internal pseudo-paths for user-facing step logs.
1321
+ * Example: "__research__/symbol-trace" -> "research/symbol-trace".
1322
+ */
1323
+ formatTaskStepDisplayPath(filePath) {
1324
+ return filePath.startsWith("__research__/")
1325
+ ? filePath.replace("__research__/", "research/")
1326
+ : filePath;
513
1327
  }
514
1328
  /* ───────────── execution gates ───────────── */
515
1329
  /**
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.
1330
+ * Gate model:
1331
+ * 1) Phase + scope gates decide coarse permissions (what broad work is allowed).
1332
+ * 2) Route gate decides finer sub-decisions within those allowed areas (what to do next).
1333
+ */
1334
+ /**
1335
+ * Gate 1: Is this kind of work allowed at all?
1336
+ * Plain meaning: checks capability rules (e.g. read-only vs file-writing).
1337
+ * Example: for docs-only mode, analysis/planning are blocked, and writes are limited.
520
1338
  */
521
1339
  canExecutePhase(phase) {
522
1340
  const constraints = this.context.executionControl?.constraints;
@@ -536,10 +1354,9 @@ export class MainAgent {
536
1354
  }
537
1355
  /* ───────────── scope gates ───────────── */
538
1356
  /**
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.
1357
+ * Gate 2: Is this work allowed for the current scope size?
1358
+ * Plain meaning: checks scope rules (none/single/multi/repo-wide).
1359
+ * Example: if scope is "analysis", only analysis/planning run and transform/write are blocked.
543
1360
  */
544
1361
  canExecuteScope(phase) {
545
1362
  const scope = this.context.analysis?.scopeType ?? "repo-wide";
@@ -553,6 +1370,24 @@ export class MainAgent {
553
1370
  }
554
1371
  return allowed;
555
1372
  }
1373
+ /**
1374
+ * Gate 3: Does this request path want this action right now?
1375
+ * Plain meaning: checks route-specific intent from routingDecision.
1376
+ * Example: search expansion is skipped when routing says allowSearch=false.
1377
+ */
1378
+ canExecuteRoute(action) {
1379
+ const routing = this.context.analysis?.routingDecision;
1380
+ switch (action) {
1381
+ case "search-expand":
1382
+ return routing?.allowSearch ?? true;
1383
+ case "transform":
1384
+ return routing?.allowTransform ?? true;
1385
+ case "research":
1386
+ return routing?.allowResearch ?? false;
1387
+ default:
1388
+ return true;
1389
+ }
1390
+ }
556
1391
  /* ----------------------------------- */
557
1392
  /* ------------- helpers ------------- */
558
1393
  /* ----------------------------------- */