@pushpalsdev/cli 1.0.93 → 1.0.94

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.93",
3
+ "version": "1.0.94",
4
4
  "description": "PushPals terminal CLI for LocalBuddy -> RemoteBuddy orchestration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,8 +45,8 @@ Execution policy:
45
45
  - `scope.read_anywhere` should default to `true` (do not set `false` unless user explicitly requested restrictive reading)
46
46
  - `scope.write_allowed` should default to `true`
47
47
  - `scope.write_globs` should be included as starting-point/relevance hints, not as hard write boundaries
48
- - `scope.forbidden_globs` should be included only when specific paths must be blocked
49
- - `scope.max_files_to_edit` should be included only when a cap is needed
48
+ - `scope.forbidden_globs` should be included only as review guardrail hints, not as hard write blockers
49
+ - `scope.max_files_to_edit` should be included only as a planning/review hint; WorkerPal write access is repo-sandbox-wide
50
50
 
51
51
  Quality gates:
52
52
 
@@ -4264,6 +4264,9 @@ var IDEATION_SYSTEM_PROMPT = loadPromptTemplate("remotebuddy/autonomy_ideation_s
4264
4264
  var SCORING_SYSTEM_PROMPT = loadPromptTemplate("remotebuddy/autonomy_scoring_system_prompt.md").trim();
4265
4265
  var PLANNING_SYSTEM_PROMPT = loadPromptTemplate("remotebuddy/autonomy_planning_system_prompt.md").trim();
4266
4266
  var IDEATION_TIMEOUT_RECOVERY_INSTRUCTION = "Previous ideation timed out before you returned JSON. For this round only, stay within the time budget: prioritize the top 1-3 highest-confidence candidates, keep reasoning brief, avoid exhaustive exploration, and return valid JSON as soon as possible.";
4267
+ var STARTUP_FAST_TICK_MAX_ATTEMPTS = 4;
4268
+ var STARTUP_FAST_TICK_MAX_DELAY_MS = 15000;
4269
+ var STARTUP_STALE_LOCK_AFTER_MS = 30000;
4267
4270
  var VISION_DOC_FNAME = "vision.md";
4268
4271
  var MAX_VISION_SECTION_CHARS = 1200;
4269
4272
  var DOCS_MIN_IMPACT_SIGNAL_FOR_NO_PENALTY = 0.45;
@@ -6100,6 +6103,92 @@ function buildEngineInspirationContext(params) {
6100
6103
  commit_history_hints: commitHistoryHints
6101
6104
  };
6102
6105
  }
6106
+ function compactIdeationText(value, maxChars) {
6107
+ const text = asString2(value).trim();
6108
+ if (text.length <= maxChars)
6109
+ return text;
6110
+ return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}...`;
6111
+ }
6112
+ function compactIdeationTextList(values, maxItems, maxChars) {
6113
+ return values.slice(0, maxItems).map((value) => compactIdeationText(value, maxChars)).filter(Boolean);
6114
+ }
6115
+ function compactVisionContextForIdeationRetry(vision) {
6116
+ const compactKeyItems = Object.fromEntries(Object.entries(vision.key_items).map(([key, value]) => [
6117
+ key,
6118
+ Array.isArray(value) ? compactIdeationTextList(value, 6, 260) : compactIdeationText(value, 260)
6119
+ ]));
6120
+ return {
6121
+ one_sentence: compactIdeationText(vision.one_sentence, 360),
6122
+ sections: vision.sections.slice(0, 4).map((section) => ({
6123
+ number: section.number,
6124
+ title: compactIdeationText(section.title, 160),
6125
+ markdown: compactIdeationText(section.markdown, 500),
6126
+ truncated: section.truncated || section.markdown.length > 500
6127
+ })),
6128
+ key_items: compactKeyItems,
6129
+ section_numbers: vision.section_numbers.slice(0, 8),
6130
+ truncated: vision.truncated
6131
+ };
6132
+ }
6133
+ function compactEngineInspirationForIdeationRetry(context) {
6134
+ return {
6135
+ compiled_repo_objectives: context.compiled_repo_objectives.slice(0, 4).map((objective) => ({
6136
+ id: objective.id,
6137
+ title: objective.title,
6138
+ weight: objective.weight,
6139
+ section_ref: objective.section_ref,
6140
+ category: objective.category,
6141
+ success_criteria: compactIdeationTextList(objective.success_criteria, 3, 220),
6142
+ validation_expectations: compactIdeationTextList(objective.validation_expectations, 3, 220)
6143
+ })),
6144
+ compiled_objectives: context.compiled_objectives.slice(0, 4).map((objective) => ({
6145
+ id: objective.id,
6146
+ title: compactIdeationText(objective.title, 220),
6147
+ weight: objective.weight,
6148
+ evidence: compactIdeationTextList(objective.evidence, 3, 220)
6149
+ })),
6150
+ opportunity_gaps: context.opportunity_gaps.slice(0, 4).map((gap) => ({
6151
+ id: gap.id,
6152
+ label: compactIdeationText(gap.label, 220),
6153
+ score: gap.score,
6154
+ evidence: compactIdeationTextList(gap.evidence, 3, 220)
6155
+ })),
6156
+ building_blocks: context.building_blocks.slice(0, 6).map((block) => ({
6157
+ id: block.id,
6158
+ algorithm: block.algorithm,
6159
+ summary: compactIdeationText(block.summary, 260),
6160
+ hypothesis: compactIdeationText(block.hypothesis, 260),
6161
+ score: block.score,
6162
+ objective_ids: block.objective_ids.slice(0, 3),
6163
+ gap_ids: block.gap_ids.slice(0, 3),
6164
+ candidate_shape: {
6165
+ objective_type: block.candidate_shape.objective_type,
6166
+ trigger_type: block.candidate_shape.trigger_type,
6167
+ component_area: block.candidate_shape.component_area,
6168
+ target_paths: block.candidate_shape.target_paths.slice(0, 4),
6169
+ write_globs: block.candidate_shape.write_globs.slice(0, 4)
6170
+ }
6171
+ })),
6172
+ source_patterns: context.source_patterns.slice(0, 4).map((pattern) => ({
6173
+ id: pattern.id,
6174
+ algorithm: pattern.algorithm,
6175
+ summary: compactIdeationText(pattern.summary, 260),
6176
+ tags: compactIdeationTextList(pattern.tags, 5, 80),
6177
+ quality_score: pattern.quality_score,
6178
+ freshness_score: pattern.freshness_score,
6179
+ source_trust_score: pattern.source_trust_score
6180
+ })),
6181
+ commit_history_hints: context.commit_history_hints.slice(0, 4).map((hint) => ({
6182
+ motif_id: hint.motif_id,
6183
+ label: compactIdeationText(hint.label, 220),
6184
+ count: hint.count,
6185
+ signal: hint.signal,
6186
+ objective_ids: hint.objective_ids.slice(0, 3),
6187
+ gap_ids: hint.gap_ids.slice(0, 3),
6188
+ sample_subjects: compactIdeationTextList(hint.sample_subjects, 3, 180)
6189
+ }))
6190
+ };
6191
+ }
6103
6192
  function selectVisionSectionRefs(sectionRefs) {
6104
6193
  const preferred = ["6", "7", "8", "4", "3", "0", "5"];
6105
6194
  const normalized = sectionRefs.map((value) => asString2(value)).filter(Boolean);
@@ -6199,7 +6288,7 @@ function buildRepoVisionFallbackCandidates(params) {
6199
6288
  const target = chooseRepoObjectiveTargetProfile(params.repoTargets ?? [], objective);
6200
6289
  const targetPaths = target?.target_paths ?? [objective.section_ref ? `vision.md` : "README.md"];
6201
6290
  const writeGlobs = target?.write_globs ?? targetPaths;
6202
- const componentArea = target?.component_area ?? (normalizeAutonomyComponentArea(pathDirname(targetPaths[0]) || targetPaths[0]) ?? "docs");
6291
+ const componentArea = target?.component_area ?? normalizeAutonomyComponentArea(pathDirname(targetPaths[0]) || targetPaths[0]) ?? "docs";
6203
6292
  const triggerType = categoryTriggerType(objective.category, params.snapshotTopSignals);
6204
6293
  const signalIds = pickSignalIdsForTrigger(params.snapshotTopSignals, triggerType);
6205
6294
  const sectionRef = objective.section_ref || sectionRefs[0] || "";
@@ -6409,9 +6498,11 @@ class RemoteBuddyAutonomousEngine {
6409
6498
  cfg;
6410
6499
  runtimeEnabled = true;
6411
6500
  timer = null;
6501
+ startupFastTickTimer = null;
6412
6502
  heartbeatTimer = null;
6413
6503
  inFlight = false;
6414
6504
  nextTickAtMs = 0;
6505
+ startupFastTickAttemptsRemaining = 0;
6415
6506
  currentRunId = null;
6416
6507
  currentPhase = "idle";
6417
6508
  currentPhaseStartedAtMs = 0;
@@ -6441,6 +6532,8 @@ class RemoteBuddyAutonomousEngine {
6441
6532
  this.runtimeEnabled = Boolean(enabled);
6442
6533
  if (!this.runtimeEnabled) {
6443
6534
  this.nextTickAtMs = 0;
6535
+ this.startupFastTickAttemptsRemaining = 0;
6536
+ this.clearStartupFastTickTimer();
6444
6537
  if (!this.currentRunId) {
6445
6538
  this.lastOutcome = "skipped";
6446
6539
  this.lastDetail = "disabled_by_runtime_config";
@@ -6494,6 +6587,38 @@ class RemoteBuddyAutonomousEngine {
6494
6587
  lockStaleAfterMs() {
6495
6588
  return Math.max(this.phaseTimeoutMs("ideation") + 30000, this.cfg.heartbeatLogMs * 2, 120000);
6496
6589
  }
6590
+ startupLockStaleAfterMs() {
6591
+ return Math.min(this.lockStaleAfterMs(), Math.max(5000, Math.min(STARTUP_STALE_LOCK_AFTER_MS, Math.floor(this.cfg.tickIntervalMs / 4))));
6592
+ }
6593
+ lockStaleAfterMsForAcquire() {
6594
+ return this.startupFastTickAttemptsRemaining > 0 ? this.startupLockStaleAfterMs() : this.lockStaleAfterMs();
6595
+ }
6596
+ startupFastTickDelayMs() {
6597
+ return Math.max(1000, Math.min(STARTUP_FAST_TICK_MAX_DELAY_MS, Math.floor(this.cfg.tickIntervalMs / 10)));
6598
+ }
6599
+ clearStartupFastTickTimer() {
6600
+ if (this.startupFastTickTimer) {
6601
+ clearTimeout(this.startupFastTickTimer);
6602
+ this.startupFastTickTimer = null;
6603
+ }
6604
+ }
6605
+ scheduleStartupFastTick(reason) {
6606
+ if (!this.runtimeEnabled || !this.timer || this.startupFastTickTimer)
6607
+ return;
6608
+ if (this.startupFastTickAttemptsRemaining <= 0)
6609
+ return;
6610
+ const delayMs = this.startupFastTickDelayMs();
6611
+ this.startupFastTickAttemptsRemaining -= 1;
6612
+ this.nextTickAtMs = Date.now() + delayMs;
6613
+ console.log(`[RemoteBuddyAutonomousEngine] startup fast tick scheduled in ${delayMs}ms after ${reason} (remaining=${this.startupFastTickAttemptsRemaining}).`);
6614
+ this.startupFastTickTimer = setTimeout(() => {
6615
+ this.startupFastTickTimer = null;
6616
+ if (!this.runtimeEnabled || !this.timer)
6617
+ return;
6618
+ this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
6619
+ this.tick();
6620
+ }, delayMs);
6621
+ }
6497
6622
  cycleBudgetMs() {
6498
6623
  const ideationTimeoutMs = this.phaseTimeoutMs("ideation");
6499
6624
  const scoringTimeoutMs = this.phaseTimeoutMs("scoring");
@@ -6806,7 +6931,7 @@ class RemoteBuddyAutonomousEngine {
6806
6931
  sessionId: this.sessionId,
6807
6932
  runId,
6808
6933
  ttlMs,
6809
- staleAfterMs: this.lockStaleAfterMs()
6934
+ staleAfterMs: this.lockStaleAfterMsForAcquire()
6810
6935
  })
6811
6936
  });
6812
6937
  if (res.ok)
@@ -7122,6 +7247,8 @@ ${JSON.stringify(input.messages ?? [])}`),
7122
7247
  outcomeDetail = lockResult.reason ? compactStatusDetail(`lock_not_acquired:${lockResult.reason}`) : "lock_not_acquired";
7123
7248
  return;
7124
7249
  }
7250
+ this.startupFastTickAttemptsRemaining = 0;
7251
+ this.clearStartupFastTickTimer();
7125
7252
  this.setPhase("prepare_worktree");
7126
7253
  const ready = await this.ensureAutonomyRepoReady(runId);
7127
7254
  if (!ready) {
@@ -7230,58 +7357,79 @@ ${JSON.stringify(input.messages ?? [])}`),
7230
7357
  return;
7231
7358
  }
7232
7359
  this.setPhase("ideation");
7233
- const ideationRecovery = this.consumeIdeationTimeoutRecovery();
7234
- if (ideationRecovery) {
7235
- console.warn(`[RemoteBuddyAutonomousEngine] tick ${runId}: applying one-shot ideation timeout recovery from ${ideationRecovery.previousRunId} after ${ideationRecovery.timeoutMs}ms timeout.`);
7236
- }
7237
- const ideationTopSignals = snapshot.top_signals.slice(0, ideationRecovery ? 10 : 16);
7238
- const ideationStateTraits = snapshot.state_traits.slice(0, ideationRecovery ? 14 : 24);
7239
- const ideationFeedbackPriors = snapshot.feedback_priors.slice(0, ideationRecovery ? 12 : 20);
7240
- const ideationEngineIdeaPriors = (snapshot.engine_idea_priors ?? []).slice(0, ideationRecovery ? 12 : 20);
7241
- const ideationOpenObjectives = snapshot.open_objectives.slice(0, ideationRecovery ? 12 : 20);
7242
- const ideationActiveCooldowns = snapshot.active_cooldowns.slice(0, ideationRecovery ? 12 : 20);
7243
- const ideationRepoTargets = repoTargets.slice(0, ideationRecovery ? 8 : repoTargets.length);
7244
- const ideationPhase = await this.llmPhase("ideation", runId, snapshot.snapshot_id, {
7245
- system: IDEATION_SYSTEM_PROMPT,
7246
- json: true,
7247
- maxTokens: ideationRecovery ? 1400 : 2800,
7248
- temperature: 0.2,
7249
- messages: [
7250
- ...ideationRecovery ? [
7360
+ const buildIdeationInput = (ideationRecovery2, compactRetry) => {
7361
+ const reduced = compactRetry || Boolean(ideationRecovery2);
7362
+ const ideationTopSignals = snapshot.top_signals.slice(0, reduced ? 5 : 16);
7363
+ const ideationStateTraits = snapshot.state_traits.slice(0, reduced ? 6 : 24);
7364
+ const ideationFeedbackPriors = snapshot.feedback_priors.slice(0, reduced ? 4 : 20);
7365
+ const ideationEngineIdeaPriors = (snapshot.engine_idea_priors ?? []).slice(0, reduced ? 4 : 20);
7366
+ const ideationOpenObjectives = snapshot.open_objectives.slice(0, reduced ? 4 : 20);
7367
+ const ideationActiveCooldowns = snapshot.active_cooldowns.slice(0, reduced ? 4 : 20);
7368
+ const ideationRepoTargets = repoTargets.slice(0, reduced ? 4 : repoTargets.length);
7369
+ return {
7370
+ system: IDEATION_SYSTEM_PROMPT,
7371
+ json: true,
7372
+ maxTokens: reduced ? 900 : 2800,
7373
+ temperature: 0.2,
7374
+ messages: [
7375
+ ...ideationRecovery2 ? [
7376
+ {
7377
+ role: "user",
7378
+ content: `${IDEATION_TIMEOUT_RECOVERY_INSTRUCTION} Previous timed-out run: ${ideationRecovery2.previousRunId}. Timeout budget for this round: ${this.phaseTimeoutMs("ideation")}ms.`
7379
+ }
7380
+ ] : [],
7251
7381
  {
7252
7382
  role: "user",
7253
- content: `${IDEATION_TIMEOUT_RECOVERY_INSTRUCTION} Previous timed-out run: ${ideationRecovery.previousRunId}. Timeout budget for this round: ${this.phaseTimeoutMs("ideation")}ms.`
7383
+ content: JSON.stringify({
7384
+ snapshot: {
7385
+ snapshot_id: snapshot.snapshot_id,
7386
+ top_signals: ideationTopSignals,
7387
+ state_traits: ideationStateTraits,
7388
+ feedback_priors: ideationFeedbackPriors,
7389
+ engine_idea_priors: ideationEngineIdeaPriors,
7390
+ open_objectives: ideationOpenObjectives,
7391
+ active_cooldowns: ideationActiveCooldowns
7392
+ },
7393
+ vision: reduced ? compactVisionContextForIdeationRetry(visionContext) : visionContext,
7394
+ repo_targets: ideationRepoTargets.map((target) => ({
7395
+ component_area: target.component_area,
7396
+ target_paths: target.target_paths,
7397
+ write_globs: target.write_globs,
7398
+ label: target.label,
7399
+ keywords: target.keywords.slice(0, reduced ? 4 : 8)
7400
+ })),
7401
+ engine_inspiration: reduced ? compactEngineInspirationForIdeationRetry(engineInspiration) : engineInspiration,
7402
+ limits: {
7403
+ ideation_max_candidates: reduced ? Math.max(1, Math.min(3, this.cfg.ideationMaxCandidates)) : this.cfg.ideationMaxCandidates,
7404
+ min_confidence: this.cfg.minConfidence
7405
+ }
7406
+ }, null, reduced ? 0 : 2)
7254
7407
  }
7255
- ] : [],
7256
- {
7257
- role: "user",
7258
- content: JSON.stringify({
7259
- snapshot: {
7260
- snapshot_id: snapshot.snapshot_id,
7261
- top_signals: ideationTopSignals,
7262
- state_traits: ideationStateTraits,
7263
- feedback_priors: ideationFeedbackPriors,
7264
- engine_idea_priors: ideationEngineIdeaPriors,
7265
- open_objectives: ideationOpenObjectives,
7266
- active_cooldowns: ideationActiveCooldowns
7267
- },
7268
- vision: visionContext,
7269
- repo_targets: ideationRepoTargets.map((target) => ({
7270
- component_area: target.component_area,
7271
- target_paths: target.target_paths,
7272
- write_globs: target.write_globs,
7273
- label: target.label,
7274
- keywords: target.keywords.slice(0, 8)
7275
- })),
7276
- engine_inspiration: engineInspiration,
7277
- limits: {
7278
- ideation_max_candidates: this.cfg.ideationMaxCandidates,
7279
- min_confidence: this.cfg.minConfidence
7280
- }
7281
- }, null, 2)
7282
- }
7283
- ]
7284
- });
7408
+ ]
7409
+ };
7410
+ };
7411
+ let ideationRecovery = this.consumeIdeationTimeoutRecovery();
7412
+ if (ideationRecovery) {
7413
+ console.warn(`[RemoteBuddyAutonomousEngine] tick ${runId}: applying one-shot ideation timeout recovery from ${ideationRecovery.previousRunId} after ${ideationRecovery.timeoutMs}ms timeout.`);
7414
+ }
7415
+ let ideationPhase;
7416
+ try {
7417
+ ideationPhase = await this.llmPhase("ideation", runId, snapshot.snapshot_id, buildIdeationInput(ideationRecovery, Boolean(ideationRecovery)));
7418
+ } catch (error) {
7419
+ if (error instanceof Error && error.message === "autonomy ideation phase timeout" && !ideationRecovery) {
7420
+ ideationRecovery = {
7421
+ previousRunId: runId,
7422
+ timedOutAt: new Date().toISOString(),
7423
+ timeoutMs: this.phaseTimeoutMs("ideation")
7424
+ };
7425
+ this.pendingIdeationTimeoutRecovery = null;
7426
+ console.warn(`[RemoteBuddyAutonomousEngine] tick ${runId}: ideation timed out; retrying once immediately with reduced context and budget-focused guidance.`);
7427
+ ideationPhase = await this.llmPhase("ideation", runId, snapshot.snapshot_id, buildIdeationInput(ideationRecovery, true));
7428
+ this.pendingIdeationTimeoutRecovery = null;
7429
+ } else {
7430
+ throw error;
7431
+ }
7432
+ }
7285
7433
  llmCalls.push(ideationPhase.llmCall);
7286
7434
  const ideationJson = ideationPhase.json;
7287
7435
  if (this.isSnapshotExpired(snapshot) || Date.now() > cycleDeadline) {
@@ -7907,6 +8055,9 @@ Scope:
7907
8055
  await this.releaseDispatchLock(runId);
7908
8056
  this.inFlight = false;
7909
8057
  this.markTickDone(outcome, outcomeDetail);
8058
+ if (!lockAcquired && outcomeDetail.startsWith("lock_not_acquired")) {
8059
+ this.scheduleStartupFastTick("dispatch lock contention");
8060
+ }
7910
8061
  }
7911
8062
  }
7912
8063
  async enqueueFromAnalysis(instruction, autonomyCtx, originRequestId) {
@@ -7931,7 +8082,8 @@ Scope:
7931
8082
  if (!this.runtimeEnabled || this.timer)
7932
8083
  return;
7933
8084
  console.log(`[RemoteBuddyAutonomousEngine] Using dedicated autonomy worktree ${this.autonomyRepo} (remote=${this.gitRemote} integration=${this.integrationBranch} base=${this.baseBranch}).`);
7934
- this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
8085
+ this.startupFastTickAttemptsRemaining = STARTUP_FAST_TICK_MAX_ATTEMPTS;
8086
+ this.nextTickAtMs = Date.now();
7935
8087
  this.timer = setInterval(() => {
7936
8088
  this.nextTickAtMs = Date.now() + this.cfg.tickIntervalMs;
7937
8089
  this.tick();
@@ -7943,6 +8095,7 @@ Scope:
7943
8095
  this.tick();
7944
8096
  }
7945
8097
  stop() {
8098
+ this.clearStartupFastTickTimer();
7946
8099
  if (this.timer) {
7947
8100
  clearInterval(this.timer);
7948
8101
  this.timer = null;
@@ -7951,6 +8104,7 @@ Scope:
7951
8104
  clearInterval(this.heartbeatTimer);
7952
8105
  this.heartbeatTimer = null;
7953
8106
  }
8107
+ this.startupFastTickAttemptsRemaining = 0;
7954
8108
  this.nextTickAtMs = 0;
7955
8109
  }
7956
8110
  }
@@ -8281,7 +8435,7 @@ function buildExecutionGuidance(plan, targetPaths, requiredValidationSteps = [])
8281
8435
  lines.push(`- ${glob}`);
8282
8436
  }
8283
8437
  if (Array.isArray(plan.scope.forbidden_globs) && plan.scope.forbidden_globs.length > 0) {
8284
- lines.push("Forbidden globs:");
8438
+ lines.push("Review guardrail hints:");
8285
8439
  for (const glob of plan.scope.forbidden_globs)
8286
8440
  lines.push(`- ${glob}`);
8287
8441
  }
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import json
11
11
  import os
12
12
  import re
13
- from shutil import which
13
+ from shutil import rmtree, which
14
14
  import shlex
15
15
  import signal
16
16
  import subprocess
@@ -21,7 +21,7 @@ import time
21
21
  import traceback
22
22
  from dataclasses import dataclass
23
23
  from pathlib import Path
24
- from typing import Any, Dict, List, Optional
24
+ from typing import Any, Dict, List, Optional, Tuple
25
25
 
26
26
  _SHARED_DIR = Path(__file__).resolve().parents[1] / "shared"
27
27
  if str(_SHARED_DIR) not in sys.path:
@@ -386,6 +386,87 @@ def _is_git_repo(repo: str) -> bool:
386
386
  return False
387
387
 
388
388
 
389
+ def _codex_project_config_roots(repo: str, env: Dict[str, str]) -> List[Path]:
390
+ roots: List[Path] = []
391
+ seen: set[str] = set()
392
+
393
+ def add(raw: object) -> None:
394
+ text = str(raw or "").strip()
395
+ if not text:
396
+ return
397
+ try:
398
+ path = Path(text).resolve()
399
+ except Exception:
400
+ return
401
+ key = str(path)
402
+ if key in seen:
403
+ return
404
+ seen.add(key)
405
+ roots.append(path)
406
+
407
+ add(repo)
408
+ try:
409
+ proc = subprocess.run(
410
+ ["git", "rev-parse", "--show-toplevel"],
411
+ cwd=repo,
412
+ capture_output=True,
413
+ text=True,
414
+ timeout=10,
415
+ check=False,
416
+ )
417
+ if proc.returncode == 0:
418
+ add((proc.stdout or "").strip())
419
+ except Exception:
420
+ pass
421
+
422
+ for key in (
423
+ "PUSHPALS_REPO_ROOT_OVERRIDE",
424
+ "PUSHPALS_PROJECT_ROOT_OVERRIDE",
425
+ "PUSHPALS_ASSIGNED_REPO_ROOT",
426
+ "PUSHPALS_REPO_PATH",
427
+ ):
428
+ add(env.get(key))
429
+ return roots
430
+
431
+
432
+ def _mask_repo_local_codex_files(repo: str, env: Dict[str, str]) -> List[Tuple[Path, Path]]:
433
+ masked: List[Tuple[Path, Path]] = []
434
+ for root in _codex_project_config_roots(repo, env):
435
+ codex_path = root / ".codex"
436
+ if not os.path.lexists(codex_path):
437
+ continue
438
+ if codex_path.is_dir():
439
+ continue
440
+ backup = root / f".codex.pushpals-masked-{os.getpid()}-{len(masked)}"
441
+ suffix = 0
442
+ while os.path.lexists(backup):
443
+ suffix += 1
444
+ backup = root / f".codex.pushpals-masked-{os.getpid()}-{len(masked)}-{suffix}"
445
+ try:
446
+ os.replace(codex_path, backup)
447
+ masked.append((codex_path, backup))
448
+ log.info(
449
+ f"Temporarily masked repo-local .codex file so Codex CLI can use CODEX_HOME: {codex_path}"
450
+ )
451
+ except Exception as exc:
452
+ log.warning(f"Failed to mask repo-local .codex file {codex_path}: {exc}")
453
+ return masked
454
+
455
+
456
+ def _restore_repo_local_codex_files(masked: List[Tuple[Path, Path]]) -> None:
457
+ for codex_path, backup in reversed(masked):
458
+ try:
459
+ if os.path.lexists(codex_path):
460
+ if codex_path.is_dir() and not codex_path.is_symlink():
461
+ rmtree(codex_path)
462
+ else:
463
+ codex_path.unlink()
464
+ if os.path.lexists(backup):
465
+ os.replace(backup, codex_path)
466
+ except Exception as exc:
467
+ log.warning(f"Failed to restore repo-local .codex file {codex_path}: {exc}")
468
+
469
+
389
470
  def _resolve_codex_command_prefix(config: OpenAICodexRuntimeConfig) -> List[str]:
390
471
  override_json = config.codex_bin_json
391
472
  if override_json:
@@ -698,7 +779,7 @@ def _summarize_json_event(obj: Dict[str, Any]) -> str:
698
779
  event_type = "event"
699
780
  # Skip noisy streaming deltas unless they contain meaningful text fragments.
700
781
  delta_like = event_type.endswith(".delta") or event_type.endswith("_delta")
701
- # Reasoning/thinking events are always surfaced they show the model's reasoning process.
782
+ # Reasoning/thinking events are always surfaced because they show the model's reasoning process.
702
783
  reasoning_like = _contains_reasoning_marker(event_type) or _event_contains_reasoning(obj)
703
784
 
704
785
  tool_name = ""
@@ -1487,7 +1568,11 @@ def _run_codex_task(
1487
1568
  env.pop("OPENAI_API_KEY", None)
1488
1569
  env.pop("OPENAI_BASE_URL", None)
1489
1570
  env.pop("OPENAI_API_BASE", None)
1490
- login_status = _run_codex_login_status(codex_cmd_prefix, repo, env)
1571
+ codex_project_mask = _mask_repo_local_codex_files(repo, env)
1572
+ try:
1573
+ login_status = _run_codex_login_status(codex_cmd_prefix, repo, env)
1574
+ finally:
1575
+ _restore_repo_local_codex_files(codex_project_mask)
1491
1576
  if not login_status.get("ok"):
1492
1577
  detail = (
1493
1578
  str(login_status.get("stderr") or "").strip()
@@ -1554,148 +1639,152 @@ def _run_codex_task(
1554
1639
  if communicate_timeout_s:
1555
1640
  log.debug(f"communicate timeout: {communicate_timeout_s}s")
1556
1641
 
1557
- proc = subprocess.Popen(
1558
- cmd,
1559
- cwd=repo,
1560
- env=env,
1561
- stdout=subprocess.PIPE,
1562
- stderr=subprocess.PIPE,
1563
- stdin=subprocess.PIPE,
1564
- text=True,
1565
- encoding="utf-8",
1566
- errors="replace",
1567
- )
1568
- _ACTIVE_CHILD = proc
1569
- started_at = time.monotonic()
1570
- progress_interval_s = _resolve_progress_log_interval_seconds(runtime_config)
1571
-
1572
- stdout_chunks: List[str] = []
1573
- stderr_chunks: List[str] = []
1574
- stdout_trace_state = _empty_codex_trace()
1575
- trace_lock = threading.Lock()
1576
- last_activity_at = {"ts": started_at}
1577
- wrapper_rejection_state: Dict[str, Any] = {"count": 0, "commands": []}
1578
-
1579
- def _drain_stdout() -> None:
1580
- stream = proc.stdout
1581
- if stream is None:
1582
- return
1583
- try:
1584
- for chunk in iter(stream.readline, ""):
1585
- if chunk == "":
1586
- break
1587
- stdout_chunks.append(chunk)
1588
- line = chunk.strip()
1589
- if not line:
1590
- continue
1591
- with trace_lock:
1592
- last_activity_at["ts"] = time.monotonic()
1593
- _record_live_codex_stdout_line(line, use_json, stdout_trace_state)
1594
- except Exception:
1595
- pass
1596
- finally:
1642
+ codex_project_mask = _mask_repo_local_codex_files(repo, env)
1643
+ try:
1644
+ proc = subprocess.Popen(
1645
+ cmd,
1646
+ cwd=repo,
1647
+ env=env,
1648
+ stdout=subprocess.PIPE,
1649
+ stderr=subprocess.PIPE,
1650
+ stdin=subprocess.PIPE,
1651
+ text=True,
1652
+ encoding="utf-8",
1653
+ errors="replace",
1654
+ )
1655
+ _ACTIVE_CHILD = proc
1656
+ started_at = time.monotonic()
1657
+ progress_interval_s = _resolve_progress_log_interval_seconds(runtime_config)
1658
+
1659
+ stdout_chunks: List[str] = []
1660
+ stderr_chunks: List[str] = []
1661
+ stdout_trace_state = _empty_codex_trace()
1662
+ trace_lock = threading.Lock()
1663
+ last_activity_at = {"ts": started_at}
1664
+ wrapper_rejection_state: Dict[str, Any] = {"count": 0, "commands": []}
1665
+
1666
+ def _drain_stdout() -> None:
1667
+ stream = proc.stdout
1668
+ if stream is None:
1669
+ return
1597
1670
  try:
1598
- stream.close()
1671
+ for chunk in iter(stream.readline, ""):
1672
+ if chunk == "":
1673
+ break
1674
+ stdout_chunks.append(chunk)
1675
+ line = chunk.strip()
1676
+ if not line:
1677
+ continue
1678
+ with trace_lock:
1679
+ last_activity_at["ts"] = time.monotonic()
1680
+ _record_live_codex_stdout_line(line, use_json, stdout_trace_state)
1599
1681
  except Exception:
1600
1682
  pass
1601
-
1602
- def _drain_stderr() -> None:
1603
- stream = proc.stderr
1604
- if stream is None:
1605
- return
1606
- try:
1607
- for chunk in iter(stream.readline, ""):
1608
- if chunk == "":
1609
- break
1610
- stderr_chunks.append(chunk)
1611
- rejected_commands = _collect_disallowed_shell_wrapper_rejections(chunk)
1612
- if rejected_commands:
1613
- with trace_lock:
1614
- wrapper_rejection_state["count"] = to_int(
1615
- wrapper_rejection_state.get("count"), 0
1616
- ) + len(rejected_commands)
1617
- tracked = wrapper_rejection_state.get("commands")
1618
- if not isinstance(tracked, list):
1619
- tracked = []
1620
- for command in rejected_commands:
1621
- lowered = command.lower()
1622
- if any(str(item).lower() == lowered for item in tracked):
1623
- continue
1624
- tracked.append(command)
1625
- wrapper_rejection_state["commands"] = tracked[:6]
1626
- except Exception:
1627
- pass
1628
- finally:
1683
+ finally:
1684
+ try:
1685
+ stream.close()
1686
+ except Exception:
1687
+ pass
1688
+
1689
+ def _drain_stderr() -> None:
1690
+ stream = proc.stderr
1691
+ if stream is None:
1692
+ return
1629
1693
  try:
1630
- stream.close()
1694
+ for chunk in iter(stream.readline, ""):
1695
+ if chunk == "":
1696
+ break
1697
+ stderr_chunks.append(chunk)
1698
+ rejected_commands = _collect_disallowed_shell_wrapper_rejections(chunk)
1699
+ if rejected_commands:
1700
+ with trace_lock:
1701
+ wrapper_rejection_state["count"] = to_int(
1702
+ wrapper_rejection_state.get("count"), 0
1703
+ ) + len(rejected_commands)
1704
+ tracked = wrapper_rejection_state.get("commands")
1705
+ if not isinstance(tracked, list):
1706
+ tracked = []
1707
+ for command in rejected_commands:
1708
+ lowered = command.lower()
1709
+ if any(str(item).lower() == lowered for item in tracked):
1710
+ continue
1711
+ tracked.append(command)
1712
+ wrapper_rejection_state["commands"] = tracked[:6]
1713
+ except Exception:
1714
+ pass
1715
+ finally:
1716
+ try:
1717
+ stream.close()
1718
+ except Exception:
1719
+ pass
1720
+
1721
+ stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
1722
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
1723
+ stdout_thread.start()
1724
+ stderr_thread.start()
1725
+
1726
+ if proc.stdin is not None:
1727
+ try:
1728
+ proc.stdin.write(prompt)
1729
+ proc.stdin.close()
1631
1730
  except Exception:
1632
1731
  pass
1633
1732
 
1634
- stdout_thread = threading.Thread(target=_drain_stdout, daemon=True)
1635
- stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
1636
- stdout_thread.start()
1637
- stderr_thread.start()
1638
-
1639
- if proc.stdin is not None:
1640
- try:
1641
- proc.stdin.write(prompt)
1642
- proc.stdin.close()
1643
- except Exception:
1644
- pass
1645
-
1646
- deadline = (
1647
- started_at + float(communicate_timeout_s)
1648
- if communicate_timeout_s and communicate_timeout_s > 0
1649
- else None
1650
- )
1651
- next_progress_at = started_at + float(progress_interval_s)
1652
- timed_out = False
1653
- command_policy_rejection_loop = False
1654
-
1655
- while proc.poll() is None:
1656
- now = time.monotonic()
1657
- if deadline is not None and now >= deadline:
1658
- timed_out = True
1659
- _terminate_active_child()
1660
- break
1733
+ deadline = (
1734
+ started_at + float(communicate_timeout_s)
1735
+ if communicate_timeout_s and communicate_timeout_s > 0
1736
+ else None
1737
+ )
1738
+ next_progress_at = started_at + float(progress_interval_s)
1739
+ timed_out = False
1740
+ command_policy_rejection_loop = False
1661
1741
 
1662
- with trace_lock:
1663
- wrapper_rejections = to_int(wrapper_rejection_state.get("count"), 0)
1664
- if wrapper_rejections >= 3:
1665
- command_policy_rejection_loop = True
1666
- _terminate_active_child()
1667
- break
1742
+ while proc.poll() is None:
1743
+ now = time.monotonic()
1744
+ if deadline is not None and now >= deadline:
1745
+ timed_out = True
1746
+ _terminate_active_child()
1747
+ break
1668
1748
 
1669
- if now >= next_progress_at:
1670
- elapsed = int(max(0.0, now - started_at))
1671
1749
  with trace_lock:
1672
- last_event = float(last_activity_at.get("ts", started_at))
1673
- valid_json = to_int(stdout_trace_state.get("valid_json"), 0)
1674
- total_lines = to_int(stdout_trace_state.get("line_count"), 0)
1675
- idle_for = int(max(0.0, now - last_event))
1676
- if use_json:
1677
- log.info(
1678
- f"codex exec still running ({elapsed}s elapsed, json_events={valid_json}, idle={idle_for}s)"
1679
- )
1680
- else:
1681
- log.info(
1682
- f"codex exec still running ({elapsed}s elapsed, stdout_lines={total_lines}, idle={idle_for}s)"
1683
- )
1684
- next_progress_at = now + float(progress_interval_s)
1750
+ wrapper_rejections = to_int(wrapper_rejection_state.get("count"), 0)
1751
+ if wrapper_rejections >= 3:
1752
+ command_policy_rejection_loop = True
1753
+ _terminate_active_child()
1754
+ break
1755
+
1756
+ if now >= next_progress_at:
1757
+ elapsed = int(max(0.0, now - started_at))
1758
+ with trace_lock:
1759
+ last_event = float(last_activity_at.get("ts", started_at))
1760
+ valid_json = to_int(stdout_trace_state.get("valid_json"), 0)
1761
+ total_lines = to_int(stdout_trace_state.get("line_count"), 0)
1762
+ idle_for = int(max(0.0, now - last_event))
1763
+ if use_json:
1764
+ log.info(
1765
+ f"codex exec still running ({elapsed}s elapsed, json_events={valid_json}, idle={idle_for}s)"
1766
+ )
1767
+ else:
1768
+ log.info(
1769
+ f"codex exec still running ({elapsed}s elapsed, stdout_lines={total_lines}, idle={idle_for}s)"
1770
+ )
1771
+ next_progress_at = now + float(progress_interval_s)
1685
1772
 
1686
- time.sleep(1.0)
1773
+ time.sleep(1.0)
1687
1774
 
1688
- try:
1689
- proc.wait(timeout=5)
1690
- except Exception:
1691
1775
  try:
1692
- proc.kill()
1693
1776
  proc.wait(timeout=5)
1694
1777
  except Exception:
1695
- pass
1778
+ try:
1779
+ proc.kill()
1780
+ proc.wait(timeout=5)
1781
+ except Exception:
1782
+ pass
1696
1783
 
1697
- stdout_thread.join(timeout=2)
1698
- stderr_thread.join(timeout=2)
1784
+ stdout_thread.join(timeout=2)
1785
+ stderr_thread.join(timeout=2)
1786
+ finally:
1787
+ _restore_repo_local_codex_files(codex_project_mask)
1699
1788
 
1700
1789
  return_code = proc.returncode
1701
1790
  _ACTIVE_CHILD = None
@@ -33,7 +33,9 @@ from openai_codex_executor import (
33
33
  _detect_codex_workaround_signal,
34
34
  _extract_usage_counts,
35
35
  _load_prompt_template,
36
+ _mask_repo_local_codex_files,
36
37
  _repo_root_for_prompt_loading,
38
+ _restore_repo_local_codex_files,
37
39
  _resolve_codex_command_prefix,
38
40
  _unwrap_shell_wrapper_command,
39
41
  _usage_from_trace_or_estimate,
@@ -80,6 +82,61 @@ class OpenAICodexRuntimeConfigTests(unittest.TestCase):
80
82
  self.assertEqual(cfg.reasoning_effort, "xhigh")
81
83
  self.assertFalse(cfg.json_output)
82
84
 
85
+ def test_masks_and_restores_repo_local_codex_file(self) -> None:
86
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-mask-") as root:
87
+ repo = Path(root) / "repo"
88
+ repo.mkdir()
89
+ codex_file = repo / ".codex"
90
+ codex_file.write_text("tracked repo sentinel\n", encoding="utf-8")
91
+
92
+ masked = _mask_repo_local_codex_files(str(repo), {})
93
+ try:
94
+ self.assertFalse(codex_file.exists())
95
+ self.assertEqual(len(masked), 1)
96
+ self.assertTrue(masked[0][1].exists())
97
+ finally:
98
+ _restore_repo_local_codex_files(masked)
99
+
100
+ self.assertEqual(codex_file.read_text(encoding="utf-8"), "tracked repo sentinel\n")
101
+
102
+ def test_masks_project_root_override_codex_file_for_worktree_runs(self) -> None:
103
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-mask-root-") as root:
104
+ project_root = Path(root) / "project"
105
+ worktree = project_root / ".worktrees" / "job-123"
106
+ worktree.mkdir(parents=True)
107
+ root_codex_file = project_root / ".codex"
108
+ worktree_codex_file = worktree / ".codex"
109
+ root_codex_file.write_text("root sentinel\n", encoding="utf-8")
110
+ worktree_codex_file.write_text("worktree sentinel\n", encoding="utf-8")
111
+
112
+ masked = _mask_repo_local_codex_files(
113
+ str(worktree),
114
+ {"PUSHPALS_REPO_ROOT_OVERRIDE": str(project_root)},
115
+ )
116
+ try:
117
+ self.assertFalse(root_codex_file.exists())
118
+ self.assertFalse(worktree_codex_file.exists())
119
+ self.assertEqual(len(masked), 2)
120
+ finally:
121
+ _restore_repo_local_codex_files(masked)
122
+
123
+ self.assertEqual(root_codex_file.read_text(encoding="utf-8"), "root sentinel\n")
124
+ self.assertEqual(worktree_codex_file.read_text(encoding="utf-8"), "worktree sentinel\n")
125
+
126
+ def test_does_not_mask_repo_local_codex_directory(self) -> None:
127
+ with tempfile.TemporaryDirectory(prefix="pushpals-codex-mask-dir-") as root:
128
+ repo = Path(root) / "repo"
129
+ codex_dir = repo / ".codex"
130
+ codex_dir.mkdir(parents=True)
131
+ (codex_dir / "config.toml").write_text("[hooks]\n", encoding="utf-8")
132
+
133
+ masked = _mask_repo_local_codex_files(str(repo), {})
134
+ try:
135
+ self.assertEqual(masked, [])
136
+ self.assertTrue((codex_dir / "config.toml").exists())
137
+ finally:
138
+ _restore_repo_local_codex_files(masked)
139
+
83
140
  def test_reasoning_effort_defaults_to_extra_high_for_default_gpt_5_5(self) -> None:
84
141
  cfg = OpenAICodexRuntimeConfig.from_sources(
85
142
  SettingsResolver(env={}, config_loader=lambda: {}),
@@ -3,10 +3,9 @@
3
3
  * Used by both the host Worker (direct mode) and the Docker job runner.
4
4
  */
5
5
 
6
- import { existsSync, readFileSync, rmSync, unlinkSync } from "fs";
6
+ import { existsSync, lstatSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
7
7
  import { resolve } from "path";
8
8
  import {
9
- deriveAutonomyComponentArea,
10
9
  buildGitCommitArgs as buildSourceControlGitCommitArgs,
11
10
  explicitSourceControlCommitIdentityFromEnv,
12
11
  loadPromptTemplate,
@@ -15,11 +14,9 @@ import {
15
14
  extractVisionKeyItems,
16
15
  formatToolRequirement,
17
16
  matchesGlob,
18
- normalizeAutonomyComponentArea,
19
17
  normalizeTargetPath,
20
18
  requirementsForValidationCommand,
21
19
  sanitizeSourceControlIdentityField,
22
- type AutonomyComponentArea,
23
20
  type SourceControlCommitIdentity,
24
21
  type ToolRequirement,
25
22
  } from "shared";
@@ -388,13 +385,6 @@ export function shouldEnqueueNoChangeReviewCompletion(
388
385
  return extractReviewFixContext(params) == null;
389
386
  }
390
387
 
391
- function reviewAgentAllowsMultiRootScope(value: unknown): boolean {
392
- const normalized = String(value ?? "")
393
- .trim()
394
- .toLowerCase();
395
- return normalized === "review_fix" || normalized === "merge_conflict";
396
- }
397
-
398
388
  export function deriveQualityGatePolicy(
399
389
  params: Record<string, unknown> | null | undefined,
400
390
  runtimeConfig: WorkerpalsRuntimeConfig = DEFAULT_CONFIG,
@@ -1655,7 +1645,7 @@ async function runDeterministicQualityGate(
1655
1645
  if (scopedValidationFailure === "outside_task_scope") {
1656
1646
  onLog?.(
1657
1647
  "stderr",
1658
- "[ValidationGate] Required validation failures appear outside the task write scope; treating them as publish blockers, not repair instructions.",
1648
+ "[ValidationGate] Required validation failures appear outside the task target/relevance hints; treating them as publish blockers, not repair instructions.",
1659
1649
  );
1660
1650
  }
1661
1651
 
@@ -2176,6 +2166,12 @@ export type WorkerGitCommitIdentity = SourceControlCommitIdentity;
2176
2166
 
2177
2167
  export const explicitWorkerCommitIdentityFromEnv = explicitSourceControlCommitIdentityFromEnv;
2178
2168
 
2169
+ async function unstageSandboxArtifactPaths(
2170
+ repo: string,
2171
+ ): Promise<{ ok: boolean; stdout: string; stderr: string }> {
2172
+ return git(repo, ["reset", "-q", "--", ...SANDBOX_STAGE_ARTIFACT_PATHS]);
2173
+ }
2174
+
2179
2175
  async function resolveGitConfigValue(repo: string, key: string): Promise<string> {
2180
2176
  const value = await git(repo, ["config", "--get", key]);
2181
2177
  return value.ok ? sanitizeSourceControlIdentityField(value.stdout) : "";
@@ -2292,19 +2288,21 @@ export async function createJobCommit(
2292
2288
  console.warn(
2293
2289
  `[WorkerPals] Stage target invalid/missing for ${job.kind}; retrying with fallback "git add -A".`,
2294
2290
  );
2295
- result = await git(repo, [
2296
- "add",
2297
- "-A",
2298
- "--",
2299
- ".",
2300
- ":(exclude)workspace/**",
2301
- ":(exclude)outputs/**",
2302
- ]);
2291
+ result = await git(repo, ["add", "-A"]);
2303
2292
  }
2304
2293
  if (!result.ok) {
2305
2294
  return { ok: false, error: `Failed to stage changes: ${result.stderr || result.stdout}` };
2306
2295
  }
2307
2296
  }
2297
+ if (job.kind === "task.execute") {
2298
+ const unstageArtifacts = await unstageSandboxArtifactPaths(repo);
2299
+ if (!unstageArtifacts.ok) {
2300
+ return {
2301
+ ok: false,
2302
+ error: `Failed to unstage sandbox artifact paths: ${unstageArtifacts.stderr || unstageArtifacts.stdout}`,
2303
+ };
2304
+ }
2305
+ }
2308
2306
 
2309
2307
  // Check if there are changes to commit
2310
2308
  result = await git(repo, ["diff", "--cached", "--quiet"]);
@@ -2504,16 +2502,7 @@ function buildStageTargets(kind: string, params?: Record<string, unknown>): stri
2504
2502
 
2505
2503
  export function buildStageCommand(kind: string, params?: Record<string, unknown>): string[] | null {
2506
2504
  if (kind === "task.execute") {
2507
- return [
2508
- "add",
2509
- "-A",
2510
- "--",
2511
- ".",
2512
- ":(exclude)workspace/**",
2513
- ":(exclude)outputs/**",
2514
- ":(exclude).codex",
2515
- ":(exclude).codex/**",
2516
- ];
2505
+ return ["add", "-A"];
2517
2506
  }
2518
2507
  const targets = buildStageTargets(kind, params);
2519
2508
  if (targets.length === 0) {
@@ -3058,25 +3047,11 @@ export async function resumePreparedMergeConflictRebase(
3058
3047
  "stdout",
3059
3048
  `[MergeConflict] Stage target invalid/missing for ${kind}; retrying with fallback "git add -A".`,
3060
3049
  );
3061
- stageResult = await git(repo, [
3062
- "add",
3063
- "-A",
3064
- "--",
3065
- ".",
3066
- ":(exclude)workspace/**",
3067
- ":(exclude)outputs/**",
3068
- ]);
3050
+ stageResult = await git(repo, ["add", "-A"]);
3069
3051
  }
3070
3052
  }
3071
3053
  } else {
3072
- stageResult = await git(repo, [
3073
- "add",
3074
- "-A",
3075
- "--",
3076
- ".",
3077
- ":(exclude)workspace/**",
3078
- ":(exclude)outputs/**",
3079
- ]);
3054
+ stageResult = await git(repo, ["add", "-A"]);
3080
3055
  }
3081
3056
  if (!stageResult.ok) {
3082
3057
  return {
@@ -3086,6 +3061,15 @@ export async function resumePreparedMergeConflictRebase(
3086
3061
  combinedGitOutput(stageResult),
3087
3062
  };
3088
3063
  }
3064
+ const unstageArtifacts = await unstageSandboxArtifactPaths(repo);
3065
+ if (!unstageArtifacts.ok) {
3066
+ return {
3067
+ ok: false,
3068
+ error:
3069
+ "Failed to unstage sandbox artifact paths before continuing rebase: " +
3070
+ combinedGitOutput(unstageArtifacts),
3071
+ };
3072
+ }
3089
3073
 
3090
3074
  let rebaseContinue = await git(repo, ["-c", "core.editor=true", "rebase", "--continue"]);
3091
3075
  let continueOutput = combinedGitOutput(rebaseContinue);
@@ -3235,19 +3219,19 @@ async function createMergeConflictJobCommit(
3235
3219
  console.warn(
3236
3220
  `[WorkerPals] Stage target invalid/missing for merge-conflict job ${job.id}; retrying with fallback "git add -A".`,
3237
3221
  );
3238
- result = await git(repo, [
3239
- "add",
3240
- "-A",
3241
- "--",
3242
- ".",
3243
- ":(exclude)workspace/**",
3244
- ":(exclude)outputs/**",
3245
- ]);
3222
+ result = await git(repo, ["add", "-A"]);
3246
3223
  }
3247
3224
  if (!result.ok) {
3248
3225
  return { ok: false, error: `Failed to stage merge-conflict changes: ${result.stderr || result.stdout}` };
3249
3226
  }
3250
3227
  }
3228
+ const unstageArtifacts = await unstageSandboxArtifactPaths(repo);
3229
+ if (!unstageArtifacts.ok) {
3230
+ return {
3231
+ ok: false,
3232
+ error: `Failed to unstage sandbox artifact paths: ${unstageArtifacts.stderr || unstageArtifacts.stdout}`,
3233
+ };
3234
+ }
3251
3235
 
3252
3236
  const cachedDiffQuiet = await git(repo, ["diff", "--cached", "--quiet"]);
3253
3237
  let headSha = await currentRefSha(repo, "HEAD");
@@ -3610,6 +3594,85 @@ export function shouldUseCodexCliForExecutor(executor: string): boolean {
3610
3594
  return executor.trim().toLowerCase() === "openai_codex";
3611
3595
  }
3612
3596
 
3597
+ type MaskedRepoLocalCodexFile = {
3598
+ codexPath: string;
3599
+ backupPath: string;
3600
+ };
3601
+
3602
+ function codexProjectConfigRoots(repo: string, env: Record<string, string>): string[] {
3603
+ const roots: string[] = [];
3604
+ const seen = new Set<string>();
3605
+ const add = (raw: unknown) => {
3606
+ const text = String(raw ?? "").trim();
3607
+ if (!text) return;
3608
+ const root = resolve(text);
3609
+ const key = root.toLowerCase();
3610
+ if (seen.has(key)) return;
3611
+ seen.add(key);
3612
+ roots.push(root);
3613
+ };
3614
+ add(repo);
3615
+ for (const key of [
3616
+ "PUSHPALS_REPO_ROOT_OVERRIDE",
3617
+ "PUSHPALS_PROJECT_ROOT_OVERRIDE",
3618
+ "PUSHPALS_ASSIGNED_REPO_ROOT",
3619
+ "PUSHPALS_REPO_PATH",
3620
+ ]) {
3621
+ add(env[key]);
3622
+ }
3623
+ return roots;
3624
+ }
3625
+
3626
+ function maskRepoLocalCodexFilesForCodexCli(
3627
+ repo: string,
3628
+ env: Record<string, string>,
3629
+ ): MaskedRepoLocalCodexFile[] {
3630
+ const masked: MaskedRepoLocalCodexFile[] = [];
3631
+ for (const root of codexProjectConfigRoots(repo, env)) {
3632
+ const codexPath = resolve(root, ".codex");
3633
+ if (!existsSync(codexPath)) continue;
3634
+ try {
3635
+ if (lstatSync(codexPath).isDirectory()) continue;
3636
+ let backupPath = resolve(root, `.codex.pushpals-masked-${process.pid}-${masked.length}`);
3637
+ let suffix = 0;
3638
+ while (existsSync(backupPath)) {
3639
+ suffix += 1;
3640
+ backupPath = resolve(
3641
+ root,
3642
+ `.codex.pushpals-masked-${process.pid}-${masked.length}-${suffix}`,
3643
+ );
3644
+ }
3645
+ renameSync(codexPath, backupPath);
3646
+ masked.push({ codexPath, backupPath });
3647
+ console.warn(
3648
+ `[WorkerPals] Temporarily masked repo-local .codex file so Codex CLI can use CODEX_HOME: ${codexPath}`,
3649
+ );
3650
+ } catch (error) {
3651
+ console.warn(
3652
+ `[WorkerPals] Failed to mask repo-local .codex file ${codexPath}: ${String(error)}`,
3653
+ );
3654
+ }
3655
+ }
3656
+ return masked;
3657
+ }
3658
+
3659
+ function restoreRepoLocalCodexFilesForCodexCli(masked: MaskedRepoLocalCodexFile[]): void {
3660
+ for (const entry of [...masked].reverse()) {
3661
+ try {
3662
+ if (existsSync(entry.codexPath)) {
3663
+ rmSync(entry.codexPath, { recursive: true, force: true });
3664
+ }
3665
+ if (existsSync(entry.backupPath)) {
3666
+ renameSync(entry.backupPath, entry.codexPath);
3667
+ }
3668
+ } catch (error) {
3669
+ console.warn(
3670
+ `[WorkerPals] Failed to restore repo-local .codex file ${entry.codexPath}: ${String(error)}`,
3671
+ );
3672
+ }
3673
+ }
3674
+ }
3675
+
3613
3676
  function normalizeCodexReasoningEffort(
3614
3677
  value: unknown,
3615
3678
  model = "",
@@ -3720,11 +3783,13 @@ async function generateCommitMessageFromDiffViaCodex(
3720
3783
  if (model) cmd.push("-m", model);
3721
3784
  cmd.push("-");
3722
3785
 
3786
+ const env = buildWorkerSandboxWritableEnv(repo);
3787
+ const codexMask = maskRepoLocalCodexFilesForCodexCli(repo, env);
3723
3788
  try {
3724
3789
  const stdinText = `${prompt.systemPrompt}\n\n${prompt.userMessage}`;
3725
3790
  const proc = Bun.spawn(cmd, {
3726
3791
  cwd: repo,
3727
- env: buildWorkerSandboxWritableEnv(repo),
3792
+ env,
3728
3793
  stdout: "pipe",
3729
3794
  stderr: "pipe",
3730
3795
  stdin: new Blob([stdinText]),
@@ -3759,6 +3824,7 @@ async function generateCommitMessageFromDiffViaCodex(
3759
3824
  } catch {
3760
3825
  return null;
3761
3826
  } finally {
3827
+ restoreRepoLocalCodexFilesForCodexCli(codexMask);
3762
3828
  try {
3763
3829
  unlinkSync(tmpOutputPath);
3764
3830
  } catch {
@@ -3938,9 +4004,7 @@ function hasInvalidRepoPathHint(values: string[]): boolean {
3938
4004
  return values.some((entry) => normalizeStagePath(entry) === null);
3939
4005
  }
3940
4006
 
3941
- function asAutonomyComponentArea(value: unknown): AutonomyComponentArea | null {
3942
- return normalizeAutonomyComponentArea(value);
3943
- }
4007
+ const SANDBOX_STAGE_ARTIFACT_PATHS = ["workspace", "outputs", ".codex"];
3944
4008
 
3945
4009
  function taskExecuteOrigin(params: Record<string, unknown>): "autonomy" | "user" {
3946
4010
  const explicit = String(params.origin ?? "")
@@ -3961,20 +4025,11 @@ export function collectWriteScopeIssuesFromChangedPaths(
3961
4025
  changedPaths: string[],
3962
4026
  planning: TaskExecutePlanning,
3963
4027
  ): string[] {
3964
- const normalizedChangedPaths = changedPaths
3965
- .map((entry) => normalizeStagePath(entry))
3966
- .filter((entry): entry is string => Boolean(entry) && entry !== ".");
3967
- if (normalizedChangedPaths.length === 0) return [];
3968
-
3969
- const forbidden = toStringArray(planning.scope.forbiddenGlobs ?? []);
3970
- const issues: string[] = [];
3971
- const forbiddenTouched = normalizedChangedPaths.filter((path) =>
3972
- forbidden.some((glob) => matchesGlob(path, glob)),
3973
- );
3974
- if (forbiddenTouched.length > 0) {
3975
- issues.push(`modified paths matching forbiddenGlobs: ${forbiddenTouched.join(", ")}`);
3976
- }
3977
- return issues;
4028
+ void changedPaths;
4029
+ void planning;
4030
+ // WorkerPals run in isolated worktrees and may write anywhere in that repo sandbox.
4031
+ // Scope hints guide planning/review, but they are not hard write privileges.
4032
+ return [];
3978
4033
  }
3979
4034
 
3980
4035
  function sanitizeTaskExecutePlanningPathHints(value: unknown): unknown {
@@ -4096,37 +4151,6 @@ function validateTaskExecutePlanning(
4096
4151
  message: "task.execute planning.targetPaths must contain literal repo-relative paths",
4097
4152
  };
4098
4153
  }
4099
- const normalizedWriteGlobs = isStringArray(scope.writeGlobs)
4100
- ? toStringArray(scope.writeGlobs)
4101
- : [];
4102
- const allowMultiRootAutonomyScope =
4103
- origin === "autonomy" &&
4104
- reviewAgentAllowsMultiRootScope(options?.reviewAgentResolutionType);
4105
- if (origin === "autonomy") {
4106
- const declaredComponentArea = asAutonomyComponentArea(options?.autonomyComponentArea);
4107
- if (!allowMultiRootAutonomyScope && declaredComponentArea) {
4108
- const inferredComponentArea = deriveAutonomyComponentArea(
4109
- normalizedTargetPaths,
4110
- normalizedWriteGlobs,
4111
- );
4112
- if (inferredComponentArea && declaredComponentArea !== inferredComponentArea) {
4113
- return {
4114
- ok: false,
4115
- message: "task.execute planning.targetPaths do not match autonomy componentArea",
4116
- };
4117
- }
4118
- }
4119
- } else if (normalizedWriteGlobs.length > 0) {
4120
- const uncoveredPaths = normalizedTargetPaths.filter(
4121
- (targetPath) => !normalizedWriteGlobs.some((glob) => matchesGlob(targetPath, glob)),
4122
- );
4123
- if (uncoveredPaths.length > 0) {
4124
- return {
4125
- ok: false,
4126
- message: `task.execute planning.targetPaths must be covered by planning.scope.writeGlobs: ${uncoveredPaths.join(", ")}`,
4127
- };
4128
- }
4129
- }
4130
4154
  }
4131
4155
 
4132
4156
  if (planning.discovery !== undefined) {
@@ -4353,10 +4377,12 @@ async function runCodexCriticReview(
4353
4377
  "-",
4354
4378
  ];
4355
4379
 
4380
+ const env = buildWorkerSandboxWritableEnv(repo);
4381
+ const codexMask = maskRepoLocalCodexFilesForCodexCli(repo, env);
4356
4382
  try {
4357
4383
  const proc = Bun.spawn(cmd, {
4358
4384
  cwd: repo,
4359
- env: buildWorkerSandboxWritableEnv(repo),
4385
+ env,
4360
4386
  stdout: "pipe",
4361
4387
  stderr: "pipe",
4362
4388
  stdin: new Blob([criticInstruction]),
@@ -4436,6 +4462,13 @@ async function runCodexCriticReview(
4436
4462
  } catch (err) {
4437
4463
  onLog?.("stderr", `[CriticGate] Codex error: ${toSingleLine(err, 220)} (skipping).`);
4438
4464
  return null;
4465
+ } finally {
4466
+ restoreRepoLocalCodexFilesForCodexCli(codexMask);
4467
+ try {
4468
+ unlinkSync(tmpOutputPath);
4469
+ } catch {
4470
+ /* ignore */
4471
+ }
4439
4472
  }
4440
4473
  }
4441
4474
 
@@ -4750,7 +4783,7 @@ export async function executeJob(
4750
4783
  [
4751
4784
  result.stderr ?? "",
4752
4785
  validationOutsideTaskScope
4753
- ? "Validation failures appear outside the task write scope and are treated as pre-existing repo blockers."
4786
+ ? "Validation failures appear outside the task target/relevance hints and are treated as pre-existing repo blockers."
4754
4787
  : "",
4755
4788
  ...quality.validationRuns.flatMap((run) => [run.stdout, run.stderr]).filter(Boolean),
4756
4789
  ]