@pushpalsdev/cli 1.0.64 → 1.0.66

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushpalsdev/cli",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -335,6 +335,8 @@ function commonRepoAncestor(paths) {
335
335
  const normalized = paths.map((entry) => normalizeRepoRelativePath(entry)).filter((entry) => Boolean(entry));
336
336
  if (normalized.length === 0)
337
337
  return null;
338
+ if (normalized.length === 1)
339
+ return normalized[0] ?? null;
338
340
  const segments = normalized.map((entry) => entry.split("/"));
339
341
  const shared = [];
340
342
  const first = segments[0] ?? [];
@@ -349,7 +351,7 @@ function commonRepoAncestor(paths) {
349
351
  break;
350
352
  }
351
353
  if (shared.length === 0)
352
- return normalized[0] ?? null;
354
+ return null;
353
355
  return shared.join("/");
354
356
  }
355
357
  function normalizeAutonomyComponentArea(value) {
@@ -368,6 +370,33 @@ function deriveAutonomyComponentArea(targetPathsInput, writeGlobsInput) {
368
370
  return null;
369
371
  return commonRepoAncestor(targetSeeds);
370
372
  }
373
+ function collectScopeSeedPaths(targetPathsInput, writeGlobsInput) {
374
+ const seeds = new Set;
375
+ if (Array.isArray(writeGlobsInput)) {
376
+ for (const raw of writeGlobsInput) {
377
+ const normalized = normalizeWriteGlob(raw);
378
+ if (!normalized)
379
+ continue;
380
+ const prefix = literalPrefix(normalized);
381
+ if (!prefix)
382
+ continue;
383
+ const seed = scopeSeedPath(prefix);
384
+ if (seed)
385
+ seeds.add(seed);
386
+ }
387
+ }
388
+ if (Array.isArray(targetPathsInput)) {
389
+ for (const raw of targetPathsInput) {
390
+ const normalized = normalizeTargetPath(raw);
391
+ if (!normalized)
392
+ continue;
393
+ const seed = scopeSeedPath(normalized);
394
+ if (seed)
395
+ seeds.add(seed);
396
+ }
397
+ }
398
+ return [...seeds];
399
+ }
371
400
  function componentRootPrefix(area) {
372
401
  const normalized = normalizeAutonomyComponentArea(area);
373
402
  if (!normalized)
@@ -556,7 +585,12 @@ function hasForbiddenBroadGlob(glob) {
556
585
  }
557
586
  function validateScopeInvariants(componentArea, targetPathsInput, writeGlobsInput, options) {
558
587
  const errors = [];
588
+ const scopeSeeds = collectScopeSeedPaths(targetPathsInput, writeGlobsInput);
559
589
  const normalizedComponentArea = normalizeAutonomyComponentArea(componentArea) ?? deriveAutonomyComponentArea(targetPathsInput, writeGlobsInput);
590
+ const allowMultipleComponentRoots = options?.allowMultipleComponentRoots === true;
591
+ if (!normalizedComponentArea && scopeSeeds.length > 1 && !allowMultipleComponentRoots) {
592
+ errors.push(`scope spans multiple component roots: ${scopeSeeds.slice(0, 6).join(", ")}`);
593
+ }
560
594
  const rootPrefix = normalizedComponentArea ? componentRootPrefix(normalizedComponentArea) : "";
561
595
  const normalizedTargetPaths = [];
562
596
  const targetSeen = new Set;
@@ -620,7 +654,7 @@ function validateScopeInvariants(componentArea, targetPathsInput, writeGlobsInpu
620
654
  errors.push(`target_path not covered by write_globs: ${targetPath}`);
621
655
  }
622
656
  }
623
- if (!normalizedComponentArea) {
657
+ if (!normalizedComponentArea && !allowMultipleComponentRoots) {
624
658
  errors.push("component_area could not be derived from scope");
625
659
  }
626
660
  const breadth = classifyGlobBreadth(normalizedWriteGlobs);
@@ -96,6 +96,8 @@ _VALID_COLORS = {"always", "never", "auto"}
96
96
  _VALID_AUTH_MODES = {"auto", "api_key", "chatgpt"}
97
97
  _VALID_REASONING_EFFORTS = {"low", "medium", "high", "xhigh"}
98
98
  _MAX_WRAPPER_RECOVERY_ATTEMPTS = 2
99
+ _MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS = 1_200
100
+ _MAX_WRAPPER_BOOTSTRAP_TOTAL_CHARS = 5_000
99
101
 
100
102
 
101
103
  def _model_supports_xhigh_reasoning(model: str) -> bool:
@@ -1108,6 +1110,177 @@ def _build_wrapper_recovery_guidance(rejected_commands: List[str], *, hard: bool
1108
1110
  return "\n".join(guidance_lines)
1109
1111
 
1110
1112
 
1113
+ def _truncate_wrapper_bootstrap_output(text: str) -> str:
1114
+ value = str(text or "").replace("\r\n", "\n").strip()
1115
+ if len(value) <= _MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS:
1116
+ return value
1117
+ return f"{value[:_MAX_WRAPPER_BOOTSTRAP_OUTPUT_CHARS].rstrip()}\n...(truncated)"
1118
+
1119
+
1120
+ def _resolve_repo_scoped_path(repo: str, raw_path: str) -> Optional[Path]:
1121
+ candidate = str(raw_path or "").strip()
1122
+ if not candidate:
1123
+ return None
1124
+ repo_root = Path(repo).resolve()
1125
+ resolved = (repo_root / candidate).resolve()
1126
+ try:
1127
+ common = os.path.commonpath([str(repo_root), str(resolved)])
1128
+ except ValueError:
1129
+ return None
1130
+ if common != str(repo_root):
1131
+ return None
1132
+ return resolved
1133
+
1134
+
1135
+ def _run_wrapper_bootstrap_command(repo: str, command: str) -> str:
1136
+ normalized = _normalize_command_text(command)
1137
+ if not normalized:
1138
+ return ""
1139
+ try:
1140
+ args = shlex.split(normalized, posix=True)
1141
+ except ValueError:
1142
+ return ""
1143
+ if not args:
1144
+ return ""
1145
+ program = str(args[0] or "").strip().lower()
1146
+ if program == "pwd" and len(args) == 1:
1147
+ return repo
1148
+ if program == "ls":
1149
+ target = Path(repo).resolve()
1150
+ if len(args) == 2 and not str(args[1]).startswith("-"):
1151
+ resolved = _resolve_repo_scoped_path(repo, str(args[1]))
1152
+ if not resolved:
1153
+ return ""
1154
+ target = resolved
1155
+ elif len(args) > 1:
1156
+ return ""
1157
+ if not target.exists():
1158
+ return f"{target.name or str(target)} (missing)"
1159
+ if target.is_file():
1160
+ return target.name
1161
+ entries = sorted(child.name for child in target.iterdir())
1162
+ return "\n".join(entries[:120])
1163
+ if program == "git" and len(args) >= 2:
1164
+ safe_git_args: Optional[List[str]] = None
1165
+ if args[1:] == ["branch", "--show-current"]:
1166
+ safe_git_args = ["git", "--no-pager", "branch", "--show-current"]
1167
+ elif args[1:] == ["status", "--porcelain"]:
1168
+ safe_git_args = ["git", "--no-pager", "status", "--porcelain"]
1169
+ elif len(args) >= 3 and args[1] == "diff":
1170
+ diff_args = list(args[2:])
1171
+ sanitized_paths: List[str] = []
1172
+ if diff_args == ["--name-only"]:
1173
+ safe_git_args = [
1174
+ "git",
1175
+ "--no-pager",
1176
+ "diff",
1177
+ "--no-ext-diff",
1178
+ "--no-textconv",
1179
+ "--name-only",
1180
+ ]
1181
+ elif len(diff_args) >= 2 and diff_args[0] == "--name-only" and diff_args[1] == "--":
1182
+ for raw_path in diff_args[2:]:
1183
+ resolved = _resolve_repo_scoped_path(repo, str(raw_path))
1184
+ if not resolved:
1185
+ return ""
1186
+ sanitized_paths.append(os.path.relpath(str(resolved), repo))
1187
+ safe_git_args = [
1188
+ "git",
1189
+ "--no-pager",
1190
+ "diff",
1191
+ "--no-ext-diff",
1192
+ "--no-textconv",
1193
+ "--name-only",
1194
+ "--",
1195
+ *sanitized_paths,
1196
+ ]
1197
+ elif diff_args and diff_args[0] == "--":
1198
+ for raw_path in diff_args[1:]:
1199
+ resolved = _resolve_repo_scoped_path(repo, str(raw_path))
1200
+ if not resolved:
1201
+ return ""
1202
+ sanitized_paths.append(os.path.relpath(str(resolved), repo))
1203
+ safe_git_args = [
1204
+ "git",
1205
+ "--no-pager",
1206
+ "diff",
1207
+ "--no-ext-diff",
1208
+ "--no-textconv",
1209
+ "--",
1210
+ *sanitized_paths,
1211
+ ]
1212
+ if not safe_git_args:
1213
+ return ""
1214
+ proc = subprocess.run(
1215
+ safe_git_args,
1216
+ cwd=repo,
1217
+ capture_output=True,
1218
+ text=True,
1219
+ timeout=15,
1220
+ check=False,
1221
+ )
1222
+ output = proc.stdout.strip()
1223
+ if proc.returncode != 0:
1224
+ detail = proc.stderr.strip() or output
1225
+ return f"(command failed: {detail})" if detail else "(command failed)"
1226
+ return output
1227
+ if program == "cat" and len(args) == 2:
1228
+ resolved = _resolve_repo_scoped_path(repo, str(args[1]))
1229
+ if not resolved or not resolved.is_file():
1230
+ return ""
1231
+ return resolved.read_text(encoding="utf-8", errors="replace")
1232
+ if program == "sed" and len(args) == 4 and args[1] == "-n":
1233
+ match = re.fullmatch(r"(\d+),(\d+)p", str(args[2] or "").strip())
1234
+ if not match:
1235
+ return ""
1236
+ start = max(1, int(match.group(1)))
1237
+ end = max(start, int(match.group(2)))
1238
+ resolved = _resolve_repo_scoped_path(repo, str(args[3]))
1239
+ if not resolved or not resolved.is_file():
1240
+ return ""
1241
+ lines = resolved.read_text(encoding="utf-8", errors="replace").splitlines()
1242
+ return "\n".join(lines[start - 1 : end])
1243
+ return ""
1244
+
1245
+
1246
+ def _build_wrapper_bootstrap_context(repo: str, rejected_commands: List[str]) -> str:
1247
+ blocks: List[str] = []
1248
+ total_chars = 0
1249
+ seen: set[str] = set()
1250
+ for rejected in rejected_commands:
1251
+ direct = _unwrap_shell_wrapper_command(rejected)
1252
+ key = direct.lower()
1253
+ if not direct or key in seen:
1254
+ continue
1255
+ seen.add(key)
1256
+ output = _run_wrapper_bootstrap_command(repo, direct)
1257
+ if not output:
1258
+ continue
1259
+ truncated = _truncate_wrapper_bootstrap_output(output)
1260
+ block = (
1261
+ f"- Direct command: `{direct}`\n"
1262
+ f" Rejected wrapper: `{rejected}`\n"
1263
+ " Output:\n"
1264
+ " ```text\n"
1265
+ f"{truncated}\n"
1266
+ " ```"
1267
+ )
1268
+ if total_chars + len(block) > _MAX_WRAPPER_BOOTSTRAP_TOTAL_CHARS and blocks:
1269
+ break
1270
+ blocks.append(block)
1271
+ total_chars += len(block)
1272
+ if not blocks:
1273
+ return ""
1274
+ return "\n".join(
1275
+ [
1276
+ "Direct command context bootstrap:",
1277
+ "The backend already ran safe read-only direct replacements for some rejected wrapper commands.",
1278
+ "Use these outputs as current repo context and do not rerun the wrapped variants.",
1279
+ *blocks[:6],
1280
+ ]
1281
+ )
1282
+
1283
+
1111
1284
  def _merge_usage_records(first: Any, second: Any) -> Dict[str, Any]:
1112
1285
  first_record = first if isinstance(first, dict) else {}
1113
1286
  second_record = second if isinstance(second, dict) else {}
@@ -1547,10 +1720,16 @@ def _run_codex_task(
1547
1720
  hard=hard_recovery,
1548
1721
  )
1549
1722
  if recovery_guidance:
1723
+ bootstrap_context = (
1724
+ _build_wrapper_bootstrap_context(repo, rejected_shell_wrappers)
1725
+ if hard_recovery
1726
+ else ""
1727
+ )
1550
1728
  log.warning(
1551
1729
  "Codex hit a shell-wrapper rejection loop; retrying once with "
1552
1730
  + (
1553
1731
  "strict no-wrapper recovery guidance."
1732
+ + (" Added direct-command context bootstrap." if bootstrap_context else "")
1554
1733
  if hard_recovery
1555
1734
  else "direct-command recovery guidance."
1556
1735
  )
@@ -1558,7 +1737,11 @@ def _run_codex_task(
1558
1737
  retry_result = _run_codex_task(
1559
1738
  repo,
1560
1739
  instruction,
1561
- [*effective_supplemental_guidance, recovery_guidance],
1740
+ [
1741
+ *effective_supplemental_guidance,
1742
+ *( [bootstrap_context] if bootstrap_context else [] ),
1743
+ recovery_guidance,
1744
+ ],
1562
1745
  wrapper_recovery_attempt=wrapper_recovery_attempt + 1,
1563
1746
  baseline_changes=baseline_snapshot,
1564
1747
  )
@@ -24,6 +24,7 @@ from executor_base import (
24
24
  from openai_codex_executor import (
25
25
  OpenAICodexRuntimeConfig,
26
26
  _augment_supplemental_guidance,
27
+ _build_wrapper_bootstrap_context,
27
28
  _build_wrapper_recovery_guidance,
28
29
  _run_codex_task,
29
30
  _resolve_reasoning_effort,
@@ -295,6 +296,56 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
295
296
  self.assertIn("first command invocation on this retry must be one of the direct replacements", lowered)
296
297
  self.assertIn("`/bin/bash -lc 'git status --porcelain'` -> `git status --porcelain`", guidance)
297
298
 
299
+ def test_build_wrapper_bootstrap_context_runs_safe_direct_replacements(self) -> None:
300
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-wrapper-bootstrap-") as temp_dir:
301
+ repo = Path(temp_dir) / "repo"
302
+ repo.mkdir(parents=True, exist_ok=True)
303
+ (repo / "README.md").write_text("# wrapper bootstrap test\n", encoding="utf-8")
304
+ subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True)
305
+ subprocess.run(
306
+ ["git", "config", "user.name", "PushPals Test"],
307
+ cwd=repo,
308
+ check=True,
309
+ capture_output=True,
310
+ text=True,
311
+ )
312
+ subprocess.run(
313
+ ["git", "config", "user.email", "pushpals-tests@example.com"],
314
+ cwd=repo,
315
+ check=True,
316
+ capture_output=True,
317
+ text=True,
318
+ )
319
+ subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True, text=True)
320
+ subprocess.run(
321
+ ["git", "commit", "-m", "chore: seed wrapper bootstrap repo"],
322
+ cwd=repo,
323
+ check=True,
324
+ capture_output=True,
325
+ text=True,
326
+ )
327
+
328
+ guidance = _build_wrapper_bootstrap_context(
329
+ str(repo),
330
+ [
331
+ "/bin/bash -lc pwd",
332
+ "/bin/bash -c pwd",
333
+ "/bin/bash -lc ls",
334
+ "/bin/bash -lc 'git branch --show-current'",
335
+ "/bin/bash -lc 'cat README.md'",
336
+ "/bin/bash -lc 'git diff --output=leak.txt'",
337
+ ],
338
+ )
339
+
340
+ self.assertIn("Direct command context bootstrap:", guidance)
341
+ self.assertIn("Direct command: `pwd`", guidance)
342
+ self.assertIn("Direct command: `ls`", guidance)
343
+ self.assertIn("Direct command: `git branch --show-current`", guidance)
344
+ self.assertIn("Direct command: `cat README.md`", guidance)
345
+ self.assertIn("README.md", guidance)
346
+ self.assertIn("# wrapper bootstrap test", guidance)
347
+ self.assertNotIn("git diff --output=leak.txt", guidance)
348
+
298
349
  def test_run_codex_task_escalates_wrapper_recovery_and_recovers(self) -> None:
299
350
  with tempfile.TemporaryDirectory(prefix="pushpals-codex-wrapper-recovery-") as temp_dir:
300
351
  repo = Path(temp_dir) / "repo"
@@ -341,13 +392,16 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
341
392
  "",
342
393
  "prompt = sys.stdin.read()",
343
394
  "hard_marker = 'Your first command invocation on this retry must be one of the direct replacements listed below'",
344
- "if hard_marker in prompt:",
395
+ "bootstrap_marker = 'Direct command context bootstrap:'",
396
+ "pwd_marker = 'Direct command: `pwd`'",
397
+ "branch_marker = 'Direct command: `git branch --show-current`'",
398
+ "if hard_marker in prompt and bootstrap_marker in prompt and pwd_marker in prompt and branch_marker in prompt:",
345
399
  " if last_message_path:",
346
400
  " Path(last_message_path).write_text(",
347
- " 'Recovered by switching to direct commands after strict wrapper recovery.',",
401
+ " 'Recovered by using backend-supplied direct command bootstrap after strict wrapper recovery.',",
348
402
  " encoding='utf-8',",
349
403
  " )",
350
- " print('item.completed | Used direct commands after strict recovery guidance.', flush=True)",
404
+ " print('item.completed | Used backend bootstrap context after strict recovery guidance.', flush=True)",
351
405
  " sys.exit(0)",
352
406
  "",
353
407
  "for line in (",
@@ -380,6 +434,7 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
380
434
  self.assertTrue(result.get("ok"), result)
381
435
  self.assertIn("Recovered after Codex attempts hit command-router shell-wrapper rejections.", str(result.get("stdout") or ""))
382
436
  self.assertIn("strict wrapper recovery", str(result.get("stdout") or "").lower())
437
+ self.assertIn("backend-supplied direct command bootstrap", str(result.get("stdout") or ""))
383
438
 
384
439
  def test_usage_falls_back_to_estimate_when_trace_has_no_usage(self) -> None:
385
440
  usage = _usage_from_trace_or_estimate({}, "abc" * 30, "done", model="gpt-5.4")
@@ -333,6 +333,13 @@ export function shouldEnqueueNoChangeReviewCompletion(
333
333
  return extractReviewFixContext(params) == null;
334
334
  }
335
335
 
336
+ function reviewAgentAllowsMultiRootScope(value: unknown): boolean {
337
+ const normalized = String(value ?? "")
338
+ .trim()
339
+ .toLowerCase();
340
+ return normalized === "review_fix" || normalized === "merge_conflict";
341
+ }
342
+
336
343
  export function deriveQualityGatePolicy(
337
344
  params: Record<string, unknown> | null | undefined,
338
345
  runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
@@ -3217,6 +3224,7 @@ function validateTaskExecutePlanning(
3217
3224
  options?: {
3218
3225
  origin?: "autonomy" | "user";
3219
3226
  autonomyComponentArea?: unknown;
3227
+ reviewAgentResolutionType?: unknown;
3220
3228
  },
3221
3229
  ): { ok: true } | { ok: false; message: string } {
3222
3230
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -3300,14 +3308,18 @@ function validateTaskExecutePlanning(
3300
3308
  const normalizedWriteGlobs = isStringArray(scope.writeGlobs)
3301
3309
  ? toStringArray(scope.writeGlobs)
3302
3310
  : [];
3311
+ const allowMultiRootAutonomyScope =
3312
+ origin === "autonomy" &&
3313
+ reviewAgentAllowsMultiRootScope(options?.reviewAgentResolutionType);
3303
3314
  if (origin === "autonomy") {
3304
3315
  const declaredComponentArea = asAutonomyComponentArea(options?.autonomyComponentArea);
3305
- const inferredComponentArea = deriveAutonomyComponentArea(
3306
- normalizedTargetPaths,
3307
- normalizedWriteGlobs,
3308
- );
3309
- const componentArea = declaredComponentArea ?? inferredComponentArea;
3310
- if (!componentArea) {
3316
+ const inferredComponentArea = allowMultiRootAutonomyScope
3317
+ ? null
3318
+ : deriveAutonomyComponentArea(normalizedTargetPaths, normalizedWriteGlobs);
3319
+ const componentArea = allowMultiRootAutonomyScope
3320
+ ? declaredComponentArea
3321
+ : declaredComponentArea ?? inferredComponentArea;
3322
+ if (!allowMultiRootAutonomyScope && !componentArea) {
3311
3323
  return {
3312
3324
  ok: false,
3313
3325
  message:
@@ -3315,6 +3327,7 @@ function validateTaskExecutePlanning(
3315
3327
  };
3316
3328
  }
3317
3329
  if (
3330
+ !allowMultiRootAutonomyScope &&
3318
3331
  declaredComponentArea &&
3319
3332
  inferredComponentArea &&
3320
3333
  declaredComponentArea !== inferredComponentArea
@@ -3328,7 +3341,7 @@ function validateTaskExecutePlanning(
3328
3341
  componentArea,
3329
3342
  normalizedTargetPaths,
3330
3343
  normalizedWriteGlobs,
3331
- { requireWriteGlobs: false },
3344
+ { requireWriteGlobs: false, allowMultipleComponentRoots: allowMultiRootAutonomyScope },
3332
3345
  );
3333
3346
  if (!validatedScope.ok) {
3334
3347
  return {
@@ -3686,9 +3699,14 @@ export async function executeJob(
3686
3699
  params.autonomy && typeof params.autonomy === "object" && !Array.isArray(params.autonomy)
3687
3700
  ? (params.autonomy as Record<string, unknown>)
3688
3701
  : null;
3702
+ const reviewAgent =
3703
+ params.reviewAgent && typeof params.reviewAgent === "object" && !Array.isArray(params.reviewAgent)
3704
+ ? (params.reviewAgent as Record<string, unknown>)
3705
+ : null;
3689
3706
  const planningValidation = validateTaskExecutePlanning(params.planning, {
3690
3707
  origin,
3691
3708
  autonomyComponentArea: autonomyScope?.componentArea ?? autonomyScope?.component_area,
3709
+ reviewAgentResolutionType: reviewAgent?.resolutionType,
3692
3710
  });
3693
3711
  if (!planningValidation.ok) {
3694
3712
  return {
@@ -66,6 +66,7 @@ function commonRepoAncestor(paths: string[]): string | null {
66
66
  .map((entry) => normalizeRepoRelativePath(entry))
67
67
  .filter((entry): entry is string => Boolean(entry));
68
68
  if (normalized.length === 0) return null;
69
+ if (normalized.length === 1) return normalized[0] ?? null;
69
70
  const segments = normalized.map((entry) => entry.split("/"));
70
71
  const shared: string[] = [];
71
72
  const first = segments[0] ?? [];
@@ -78,7 +79,7 @@ function commonRepoAncestor(paths: string[]): string | null {
78
79
  }
79
80
  break;
80
81
  }
81
- if (shared.length === 0) return normalized[0] ?? null;
82
+ if (shared.length === 0) return null;
82
83
  return shared.join("/");
83
84
  }
84
85
 
@@ -114,6 +115,29 @@ export function deriveAutonomyComponentArea(
114
115
  return commonRepoAncestor(targetSeeds);
115
116
  }
116
117
 
118
+ function collectScopeSeedPaths(targetPathsInput: unknown[], writeGlobsInput?: unknown[]): string[] {
119
+ const seeds = new Set<string>();
120
+ if (Array.isArray(writeGlobsInput)) {
121
+ for (const raw of writeGlobsInput) {
122
+ const normalized = normalizeWriteGlob(raw);
123
+ if (!normalized) continue;
124
+ const prefix = literalPrefix(normalized);
125
+ if (!prefix) continue;
126
+ const seed = scopeSeedPath(prefix);
127
+ if (seed) seeds.add(seed);
128
+ }
129
+ }
130
+ if (Array.isArray(targetPathsInput)) {
131
+ for (const raw of targetPathsInput) {
132
+ const normalized = normalizeTargetPath(raw);
133
+ if (!normalized) continue;
134
+ const seed = scopeSeedPath(normalized);
135
+ if (seed) seeds.add(seed);
136
+ }
137
+ }
138
+ return [...seeds];
139
+ }
140
+
117
141
  export function componentRootPrefix(area: AutonomyComponentArea): string {
118
142
  const normalized = normalizeAutonomyComponentArea(area);
119
143
  if (!normalized) return "";
@@ -295,12 +319,19 @@ export function validateScopeInvariants(
295
319
  componentArea: AutonomyComponentArea | null | undefined,
296
320
  targetPathsInput: unknown[],
297
321
  writeGlobsInput: unknown[],
298
- options?: { requireWriteGlobs?: boolean },
322
+ options?: { requireWriteGlobs?: boolean; allowMultipleComponentRoots?: boolean },
299
323
  ): ScopeValidationResult {
300
324
  const errors: string[] = [];
325
+ const scopeSeeds = collectScopeSeedPaths(targetPathsInput, writeGlobsInput);
301
326
  const normalizedComponentArea =
302
327
  normalizeAutonomyComponentArea(componentArea) ??
303
328
  deriveAutonomyComponentArea(targetPathsInput, writeGlobsInput);
329
+ const allowMultipleComponentRoots = options?.allowMultipleComponentRoots === true;
330
+ if (!normalizedComponentArea && scopeSeeds.length > 1 && !allowMultipleComponentRoots) {
331
+ errors.push(
332
+ `scope spans multiple component roots: ${scopeSeeds.slice(0, 6).join(", ")}`,
333
+ );
334
+ }
304
335
  const rootPrefix = normalizedComponentArea ? componentRootPrefix(normalizedComponentArea) : "";
305
336
  const normalizedTargetPaths: string[] = [];
306
337
  const targetSeen = new Set<string>();
@@ -368,7 +399,7 @@ export function validateScopeInvariants(
368
399
  if (!covered) errors.push(`target_path not covered by write_globs: ${targetPath}`);
369
400
  }
370
401
  }
371
- if (!normalizedComponentArea) {
402
+ if (!normalizedComponentArea && !allowMultipleComponentRoots) {
372
403
  errors.push("component_area could not be derived from scope");
373
404
  }
374
405