scai 0.1.175 → 0.1.176

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.
@@ -28,6 +28,8 @@ import { structuralPreloadStep } from "./structuralPreloadStep.js";
28
28
  import { extractFileReferences } from "../utils/extractFileReferences.js";
29
29
  import { PREFILTER_STOP_WORDS } from "../fileRules/stopWords.js";
30
30
  import { MAX_WELL_KNOWN_REPO_FILES, WELL_KNOWN_REPO_FILE_BASENAMES } from "../fileRules/wellKnownRepoFiles.js";
31
+ import { isPathIgnoredByFolderGlobs } from "../fileRules/ignoredPaths.js";
32
+ import { canExecutePhase, canExecuteRoute, canExecuteScope } from "./guardPolicy.js";
31
33
  import chalk from "chalk";
32
34
  import path from "path";
33
35
  import fs from "fs";
@@ -172,12 +174,12 @@ export class MainAgent {
172
174
  break;
173
175
  }
174
176
  // ---------------- INFORMATION ACQUISITION ----------------
175
- const canRouteSearchExpansion = this.canExecuteRoute("search-expand");
177
+ const canRouteSearchExpansion = canExecuteRoute(this.context, "search-expand");
176
178
  if (!canRouteSearchExpansion) {
177
179
  this.logLine("PLAN", "infoPlanGen", undefined, "skipped (routing disallows search expansion)", { highlight: false });
178
180
  }
179
- else if (this.canExecutePhase("planning") &&
180
- this.canExecuteScope("planning")) {
181
+ else if (canExecutePhase(this.context, "planning") &&
182
+ canExecuteScope(this.context, "planning")) {
181
183
  const t = this.startTimer();
182
184
  await infoPlanGenStep.run(this.context);
183
185
  const infoPlan = this.context.analysis?.planSuggestion?.plan ?? { steps: [] };
@@ -208,7 +210,7 @@ export class MainAgent {
208
210
  */
209
211
  async runResearch() {
210
212
  var _a, _b;
211
- if (!this.canExecuteRoute("research")) {
213
+ if (!canExecuteRoute(this.context, "research")) {
212
214
  this.logLine("RESEARCH", "taskStepSeed", undefined, "skipped (route disallows research)");
213
215
  return;
214
216
  }
@@ -298,7 +300,7 @@ export class MainAgent {
298
300
  var _a, _b;
299
301
  if (!this.context.task)
300
302
  return;
301
- if (!this.canExecutePhase("planning") || !this.canExecuteScope("planning"))
303
+ if (!canExecutePhase(this.context, "planning") || !canExecuteScope(this.context, "planning"))
302
304
  return;
303
305
  (_a = this.context).analysis || (_a.analysis = {});
304
306
  (_b = this.context.task).taskSteps || (_b.taskSteps = []);
@@ -478,6 +480,9 @@ export class MainAgent {
478
480
  // Step-level iterations
479
481
  // ---------------------------
480
482
  const stepAction = await this.runStepIterations(taskStep);
483
+ if (stepAction === "expand-scope") {
484
+ this.applyScopeExpansionRouteHint();
485
+ }
481
486
  this.finishTaskStep(taskStep, stepCount, stepAction);
482
487
  }
483
488
  this.logLine("TASK", "Max task step limit reached — stopping work loop", undefined, undefined, { highlight: false });
@@ -517,6 +522,8 @@ export class MainAgent {
517
522
  return "request-feedback";
518
523
  if (nextAction === "redo-step")
519
524
  continue;
525
+ if (nextAction === "expand-scope")
526
+ return "expand-scope";
520
527
  }
521
528
  return "continue";
522
529
  }
@@ -532,7 +539,7 @@ export class MainAgent {
532
539
  await this.executeResearchTaskStep(taskStep);
533
540
  return;
534
541
  }
535
- if (this.canExecutePhase("analysis") && this.canExecuteScope("analysis")) {
542
+ if (canExecutePhase(this.context, "analysis") && canExecuteScope(this.context, "analysis")) {
536
543
  const tAnalysis = this.startTimer();
537
544
  await analysisPlanGenStep.run(this.context);
538
545
  this.logLine("PLAN", "analysisPlanGen", tAnalysis(), undefined, { highlight: false });
@@ -545,7 +552,9 @@ export class MainAgent {
545
552
  if (this.context.analysis)
546
553
  this.context.analysis.planSuggestion = undefined;
547
554
  }
548
- if (this.canExecutePhase("transform") && this.canExecuteScope("transform")) {
555
+ if (canExecutePhase(this.context, "transform") &&
556
+ canExecuteScope(this.context, "transform") &&
557
+ canExecuteRoute(this.context, "transform")) {
549
558
  const tTransform = this.startTimer();
550
559
  await transformPlanGenStep.run(this.context);
551
560
  this.logLine("PLAN", "transformPlanGen", tTransform(), undefined, { highlight: false });
@@ -558,7 +567,7 @@ export class MainAgent {
558
567
  }
559
568
  if (this.context.analysis)
560
569
  this.context.analysis.planSuggestion = undefined;
561
- if (this.canExecutePhase("write") && this.canExecuteScope("write")) {
570
+ if (canExecutePhase(this.context, "write") && canExecuteScope(this.context, "write")) {
562
571
  const tWrite = this.startTimer();
563
572
  await writeFileStep.run({ query: this.query, context: this.context });
564
573
  this.logLine("WRITE", "writeFileStep", tWrite());
@@ -1165,6 +1174,8 @@ export class MainAgent {
1165
1174
  const existsOrResearch = (filePath) => {
1166
1175
  if (filePath.startsWith("__research__/"))
1167
1176
  return true;
1177
+ if (isPathIgnoredByFolderGlobs(filePath))
1178
+ return false;
1168
1179
  return fs.existsSync(filePath);
1169
1180
  };
1170
1181
  let removedRelated = 0;
@@ -1218,7 +1229,7 @@ export class MainAgent {
1218
1229
  */
1219
1230
  getTaskStepBudget() {
1220
1231
  const scope = this.context.analysis?.scopeType ?? "repo-wide";
1221
- if (this.canExecuteRoute("research"))
1232
+ if (canExecuteRoute(this.context, "research"))
1222
1233
  return 10;
1223
1234
  if (scope === "multi-file")
1224
1235
  return 7;
@@ -1231,7 +1242,7 @@ export class MainAgent {
1231
1242
  * Example: require at least two analyzed files plus one understanding signal.
1232
1243
  */
1233
1244
  isResearchGateSatisfied() {
1234
- if (!this.canExecuteRoute("research"))
1245
+ if (!canExecuteRoute(this.context, "research"))
1235
1246
  return true;
1236
1247
  const scope = this.context.analysis?.scopeType ?? "repo-wide";
1237
1248
  const researchPlanCount = this.context.task?.taskSteps?.filter(s => typeof s.action === "string" && s.action.startsWith("research-")).length ?? 0;
@@ -1302,29 +1313,45 @@ export class MainAgent {
1302
1313
  this.context.task.status = status;
1303
1314
  this.persistTaskDataForRun();
1304
1315
  }
1316
+ applyScopeExpansionRouteHint() {
1317
+ var _a;
1318
+ (_a = this.context).analysis || (_a.analysis = {});
1319
+ const route = this.context.analysis.routingDecision;
1320
+ if (!route)
1321
+ return;
1322
+ route.decision = "needs-info";
1323
+ route.allowSearch = true;
1324
+ route.scopeLocked = false;
1325
+ route.rationale = `${route.rationale}; stepReasoning=expand-scope`;
1326
+ this.logLine("TASK", "Route updated from step reasoning", undefined, "expand-scope requested; re-enabled search expansion");
1327
+ }
1305
1328
  startTaskStep(taskStep, stepCount) {
1306
1329
  this.ensureWorkingFilesLoaded([taskStep.filePath], "Current task step");
1330
+ const db = getDbForRepo();
1331
+ this.ensureTaskIdentityForPersistence(db);
1307
1332
  this.context.task.currentStep = taskStep;
1308
1333
  taskStep.taskId = this.taskId;
1309
1334
  taskStep.stepIndex = stepCount;
1310
1335
  taskStep.status = "pending";
1311
- persistTaskStepInsert(taskStep, getDbForRepo());
1336
+ persistTaskStepInsert(taskStep, db);
1312
1337
  const displayPath = this.formatTaskStepDisplayPath(taskStep.filePath);
1313
1338
  this.logLine("NEW STEP", `Processing taskStep ${stepCount}`, undefined, displayPath, { highlight: true });
1314
1339
  taskStep.startTime = Date.now();
1315
- persistTaskStepStart(taskStep, getDbForRepo());
1340
+ persistTaskStepStart(taskStep, db);
1316
1341
  }
1317
1342
  finishTaskStep(taskStep, stepCount, stepAction) {
1343
+ const db = getDbForRepo();
1344
+ this.ensureTaskIdentityForPersistence(db);
1318
1345
  const displayPath = this.formatTaskStepDisplayPath(taskStep.filePath);
1319
1346
  taskStep.endTime = Date.now();
1320
1347
  if (stepAction === "complete") {
1321
1348
  taskStep.status = "completed";
1322
- persistTaskStepCompletion(taskStep, getDbForRepo());
1349
+ persistTaskStepCompletion(taskStep, db);
1323
1350
  this.logLine("STEP-DONE", `Completed taskStep ${stepCount}`, undefined, displayPath, { highlight: false });
1324
1351
  return;
1325
1352
  }
1326
1353
  taskStep.status = "pending";
1327
- persistTaskStepCompletion(taskStep, getDbForRepo());
1354
+ persistTaskStepCompletion(taskStep, db);
1328
1355
  this.logLine("STEP", `Pending taskStep ${stepCount}`, undefined, displayPath);
1329
1356
  }
1330
1357
  /**
@@ -1336,69 +1363,6 @@ export class MainAgent {
1336
1363
  ? filePath.replace("__research__/", "research/")
1337
1364
  : filePath;
1338
1365
  }
1339
- /* ───────────── execution gates ───────────── */
1340
- /**
1341
- * Gate model:
1342
- * 1) Phase + scope gates decide coarse permissions (what broad work is allowed).
1343
- * 2) Route gate decides finer sub-decisions within those allowed areas (what to do next).
1344
- */
1345
- /**
1346
- * Gate 1: Is this kind of work allowed at all?
1347
- * Plain meaning: checks capability rules (e.g. read-only vs file-writing).
1348
- * Example: for docs-only mode, analysis/planning are blocked, and writes are limited.
1349
- */
1350
- canExecutePhase(phase) {
1351
- const constraints = this.context.executionControl?.constraints;
1352
- const docsOnly = constraints?.docsOnly ?? false;
1353
- let allowed = false;
1354
- switch (phase) {
1355
- case "analysis":
1356
- case "planning":
1357
- allowed = !docsOnly;
1358
- break;
1359
- case "transform":
1360
- case "write":
1361
- allowed = constraints?.allowFileWrites ?? false;
1362
- break;
1363
- }
1364
- return allowed;
1365
- }
1366
- /* ───────────── scope gates ───────────── */
1367
- /**
1368
- * Gate 2: Is this work allowed for the current scope size?
1369
- * Plain meaning: checks scope rules (none/single/multi/repo-wide).
1370
- * Example: if scope is "analysis", only analysis/planning run and transform/write are blocked.
1371
- */
1372
- canExecuteScope(phase) {
1373
- const scope = this.context.analysis?.scopeType ?? "repo-wide";
1374
- let allowed = false;
1375
- switch (scope) {
1376
- case "none":
1377
- allowed = phase === "analysis" || phase === "planning";
1378
- break;
1379
- default:
1380
- allowed = true;
1381
- }
1382
- return allowed;
1383
- }
1384
- /**
1385
- * Gate 3: Does this request path want this action right now?
1386
- * Plain meaning: checks route-specific intent from routingDecision.
1387
- * Example: search expansion is skipped when routing says allowSearch=false.
1388
- */
1389
- canExecuteRoute(action) {
1390
- const routing = this.context.analysis?.routingDecision;
1391
- switch (action) {
1392
- case "search-expand":
1393
- return routing?.allowSearch ?? true;
1394
- case "transform":
1395
- return routing?.allowTransform ?? true;
1396
- case "research":
1397
- return routing?.allowResearch ?? false;
1398
- default:
1399
- return true;
1400
- }
1401
- }
1402
1366
  /* ----------------------------------- */
1403
1367
  /* ------------- helpers ------------- */
1404
1368
  /* ----------------------------------- */
@@ -1479,6 +1443,33 @@ export class MainAgent {
1479
1443
  }
1480
1444
  }
1481
1445
  }
1446
+ /**
1447
+ * Ensures the current task id exists in the active DB before step persistence.
1448
+ * Example: if repo/db context switched, re-create task row and rebind step taskIds.
1449
+ */
1450
+ ensureTaskIdentityForPersistence(db) {
1451
+ var _a;
1452
+ const activeTaskId = this.taskId ?? this.context.task?.id;
1453
+ if (!activeTaskId) {
1454
+ this.taskId = bootTaskForRepo(this.context, db, this.logLine.bind(this));
1455
+ this.context.task.id = this.taskId;
1456
+ return;
1457
+ }
1458
+ const row = db.prepare("SELECT id FROM tasks WHERE id = ?").get(activeTaskId);
1459
+ if (row?.id) {
1460
+ this.taskId = activeTaskId;
1461
+ this.context.task.id = activeTaskId;
1462
+ return;
1463
+ }
1464
+ const reboundTaskId = bootTaskForRepo(this.context, db, this.logLine.bind(this));
1465
+ this.taskId = reboundTaskId;
1466
+ this.context.task.id = reboundTaskId;
1467
+ (_a = this.context.task).taskSteps || (_a.taskSteps = []);
1468
+ for (const step of this.context.task.taskSteps) {
1469
+ step.taskId = reboundTaskId;
1470
+ }
1471
+ this.logLine("TASK", "taskId rebound", undefined, `previous=${activeTaskId}, rebound=${reboundTaskId}`);
1472
+ }
1482
1473
  }
1483
1474
  function scoreCandidateFiles(filePaths, relatedFileScores, retrievalQuery) {
1484
1475
  const explicitRefs = extractFileReferences(retrievalQuery, { lowercase: true });
@@ -3,6 +3,7 @@ import { generate } from "../lib/generate.js";
3
3
  import { logInputOutput } from "../utils/promptLogHelper.js";
4
4
  import { cleanupModule } from "../pipeline/modules/cleanupModule.js";
5
5
  import path from "path";
6
+ import { isPathIgnoredByFolderGlobs } from "../fileRules/ignoredPaths.js";
6
7
  export async function fileCheckStep(context) {
7
8
  var _a;
8
9
  context.analysis ?? (context.analysis = {});
@@ -10,9 +11,7 @@ export async function fileCheckStep(context) {
10
11
  const intent = context.analysis.intent;
11
12
  const planSuggestion = context.analysis.planSuggestion?.text ?? "";
12
13
  // Step 1: gather known files from initContext only
13
- const knownFiles = new Set([
14
- ...(context.initContext?.relatedFiles ?? []),
15
- ]);
14
+ const knownFiles = new Set((context.initContext?.relatedFiles ?? []).filter(filePath => !isPathIgnoredByFolderGlobs(filePath)));
16
15
  // Step 2: extract file names from normalizedQuery or planSuggestion
17
16
  const extractedFiles = extractFilesFromAnalysis(context.analysis);
18
17
  // Step 3: populate focus with safe defaults
@@ -138,16 +137,25 @@ Task:
138
137
  // Merge parsed output safely
139
138
  if (Array.isArray(parsed.selectedFiles)) {
140
139
  const existing = new Set(context.analysis.focus.selectedFiles);
140
+ const safeSelected = parsed.selectedFiles
141
+ .filter((f) => typeof f === "string" && knownFiles.has(f) && !isPathIgnoredByFolderGlobs(f));
141
142
  context.analysis.focus.selectedFiles = [
142
143
  ...context.analysis.focus.selectedFiles,
143
- ...parsed.selectedFiles.filter((f) => !existing.has(f))
144
+ ...safeSelected.filter((f) => !existing.has(f))
144
145
  ];
145
146
  }
146
147
  if (Array.isArray(parsed.candidateFiles)) {
147
148
  const existing = new Set(context.analysis.focus.candidateFiles);
149
+ const safeCandidates = parsed.candidateFiles
150
+ .filter((f) => typeof f === "string")
151
+ .filter((f) => {
152
+ if (knownFiles.has(f))
153
+ return !isPathIgnoredByFolderGlobs(f);
154
+ return /^[\w\-./]+\.(js|ts|jsx|tsx|py|java|go|rs|cpp|c|cs|md)$/i.test(f);
155
+ });
148
156
  context.analysis.focus.candidateFiles = [
149
157
  ...context.analysis.focus.candidateFiles,
150
- ...parsed.candidateFiles.filter((f) => !existing.has(f))
158
+ ...safeCandidates.filter((f) => !existing.has(f))
151
159
  ];
152
160
  }
153
161
  if (typeof parsed.rationale === "string") {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Gate 1: capability constraints (execution control).
3
+ */
4
+ export function canExecutePhase(context, phase) {
5
+ const constraints = context.executionControl?.constraints;
6
+ const docsOnly = constraints?.docsOnly ?? false;
7
+ switch (phase) {
8
+ case "analysis":
9
+ case "planning":
10
+ return !docsOnly;
11
+ case "transform":
12
+ case "write":
13
+ return constraints?.allowFileWrites ?? false;
14
+ default:
15
+ return false;
16
+ }
17
+ }
18
+ /**
19
+ * Gate 2: scope constraints (none/single/multi/repo-wide).
20
+ */
21
+ export function canExecuteScope(context, phase) {
22
+ const scope = context.analysis?.scopeType ?? "repo-wide";
23
+ if (scope === "none") {
24
+ return phase === "analysis" || phase === "planning";
25
+ }
26
+ return true;
27
+ }
28
+ /**
29
+ * Gate 3: route constraints (dynamic routing decisions).
30
+ */
31
+ export function canExecuteRoute(context, action) {
32
+ const routing = context.analysis?.routingDecision;
33
+ switch (action) {
34
+ case "search-expand":
35
+ return routing?.allowSearch ?? true;
36
+ case "transform":
37
+ return routing?.allowTransform ?? true;
38
+ case "research":
39
+ return routing?.allowResearch ?? false;
40
+ default:
41
+ return true;
42
+ }
43
+ }
@@ -43,3 +43,20 @@ export const IGNORED_FOLDER_GLOBS = [
43
43
  '**/debug.log',
44
44
  '**/Dockerfile',
45
45
  ];
46
+ export const IGNORED_DIR_NAMES = Array.from(new Set(IGNORED_FOLDER_GLOBS
47
+ .map((pattern) => {
48
+ const match = pattern.match(/^\*\*\/([^*\/]+)\/\*\*$/);
49
+ return match?.[1];
50
+ })
51
+ .filter((name) => !!name)));
52
+ /**
53
+ * Coarse path guard used by runtime search/selection fallback paths.
54
+ * Matches the same ignore-glob intent used during indexing.
55
+ */
56
+ export function isPathIgnoredByFolderGlobs(filePath) {
57
+ const normalizedPath = String(filePath ?? "").replace(/\\/g, "/");
58
+ return IGNORED_FOLDER_GLOBS.some((pattern) => {
59
+ const cleanPattern = pattern.replace(/^\*\*\/?/, "").replace(/\/\*\*$/, "");
60
+ return cleanPattern.length > 0 && normalizedPath.includes(cleanPattern);
61
+ });
62
+ }
@@ -1,11 +1,12 @@
1
1
  // File: src/modules/fileSearchModule.ts
2
- import { execSync } from "child_process";
2
+ import { execFileSync } from "child_process";
3
3
  import path from "path";
4
4
  import fs from "fs";
5
5
  import { plannerSearchFiles } from "../../db/fileIndex.js";
6
6
  import { Config } from "../../config.js";
7
7
  import { getDbForRepo } from "../../db/client.js";
8
8
  import { IGNORED_EXTENSIONS } from "../../fileRules/ignoredExtensions.js";
9
+ import { IGNORED_DIR_NAMES, isPathIgnoredByFolderGlobs } from "../../fileRules/ignoredPaths.js";
9
10
  import { logInputOutput } from "../../utils/promptLogHelper.js";
10
11
  async function fetchSummariesForPaths(paths) {
11
12
  if (paths.length === 0)
@@ -63,14 +64,15 @@ export const fileSearchModule = {
63
64
  // -------------------------------------------------
64
65
  if (results.length === 0) {
65
66
  try {
66
- const exclude = IGNORED_EXTENSIONS
67
- .map(ext => `--exclude=*${ext}`)
68
- .join(" ");
69
- const stdout = execSync(`grep -ril ${exclude} "${subQuery}" "${repoRoot}"`, { encoding: "utf8" });
67
+ const excludeExtArgs = IGNORED_EXTENSIONS.map(ext => `--exclude=*${ext}`);
68
+ const excludeDirArgs = IGNORED_DIR_NAMES.map(dir => `--exclude-dir=${dir}`);
69
+ const grepArgs = ["-ril", ...excludeDirArgs, ...excludeExtArgs, subQuery, repoRoot];
70
+ const stdout = execFileSync("grep", grepArgs, { encoding: "utf8" });
70
71
  results = stdout
71
72
  .split("\n")
72
73
  .filter(Boolean)
73
- .map(f => ({ path: f }));
74
+ .map(f => ({ path: f }))
75
+ .filter(record => !isPathIgnoredByFolderGlobs(record.path));
74
76
  }
75
77
  catch (err) {
76
78
  if (err?.status !== 1) {
@@ -1,8 +1,11 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
+ import { isPathIgnoredByFolderGlobs } from "../fileRules/ignoredPaths.js";
3
4
  export async function resolveTargetsToFiles(targets, exts) {
4
5
  const files = [];
5
6
  for (const target of targets) {
7
+ if (isPathIgnoredByFolderGlobs(target))
8
+ continue;
6
9
  const stat = await fs.stat(target);
7
10
  if (stat.isDirectory()) {
8
11
  files.push(...await collectFilesRecursive(target, exts));
@@ -18,6 +21,8 @@ async function collectFilesRecursive(dir, exts) {
18
21
  const files = [];
19
22
  for (const entry of entries) {
20
23
  const fullPath = path.join(dir, entry.name);
24
+ if (isPathIgnoredByFolderGlobs(fullPath))
25
+ continue;
21
26
  if (entry.isDirectory()) {
22
27
  files.push(...await collectFilesRecursive(fullPath, exts));
23
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.175",
3
+ "version": "0.1.176",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"