agentxchain 2.149.1 → 2.150.0

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/README.md CHANGED
@@ -89,7 +89,7 @@ agentxchain step --role pm
89
89
 
90
90
  If you skipped `--goal` during scaffold, run `agentxchain config --set project.goal "Build an API change planner for release teams"` before the first governed turn instead of re-running init in place.
91
91
 
92
- The default governed dev runtime is `claude --print --dangerously-skip-permissions` with stdin prompt delivery. The non-interactive governed path needs write access, so do not pretend bare `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
92
+ The default governed dev runtime is `claude --print --dangerously-skip-permissions --bare` with stdin prompt delivery. The non-interactive governed path needs write access and env-based auth, so do not pretend plain `claude --print` is sufficient for unattended implementation turns. If your local coding agent uses a different launch contract, set it during scaffold creation:
93
93
 
94
94
  ```bash
95
95
  agentxchain init --governed --dir my-agentxchain-project --dev-command ./scripts/dev-agent.sh --dev-prompt-transport dispatch_bundle_only -y
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.149.1",
3
+ "version": "2.150.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "test": "npm run test:vitest && npm run test:node",
29
29
  "test:vitest": "vitest run --reporter=verbose",
30
30
  "test:beta": "node --test test/beta-tester-scenarios/*.test.js",
31
- "test:node": "node --test test/*.test.js test/beta-tester-scenarios/*.test.js",
31
+ "test:node": "node --test --test-timeout=60000 --test-concurrency=4 test/*.test.js test/beta-tester-scenarios/*.test.js",
32
32
  "preflight:release": "bash scripts/release-preflight.sh",
33
33
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
34
34
  "check:release-alignment": "node scripts/check-release-alignment.mjs",
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # Prepublish gate — local-first quality floor before `git tag`.
3
+ #
4
+ # Replaces per-commit remote CI coverage that `.github/workflows/ci.yml` will
5
+ # drop when CICD-SHRINK lands. Runs the same checks the GitHub-hosted runners
6
+ # ran, in-process on the agent's box, before any tag or publish-workflow is
7
+ # triggered. See .planning/CICD_REDUCTION_PLAN.md §7.
8
+ #
9
+ # Usage:
10
+ # bash cli/scripts/prepublish-gate.sh <target-version>
11
+ #
12
+ # Exit 0 + prints "PREPUBLISH GATE PASSED for <version>" → safe to tag/push.
13
+ # Exit non-zero → do NOT tag, do NOT push. Fix the failure locally first.
14
+ #
15
+ # Discipline rule (CICD-SHRINK acceptance, new in DEC-RELEASE-CUT-AND-PUSH-AS-ATOMIC-001):
16
+ # the release-cut turn MUST include this script's "PREPUBLISH GATE PASSED" line
17
+ # in the turn's Evidence block before `git tag` is created.
18
+
19
+ set -uo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+ CLI_DIR="${SCRIPT_DIR}/.."
23
+ cd "$CLI_DIR"
24
+
25
+ usage() {
26
+ echo "Usage: bash cli/scripts/prepublish-gate.sh <target-version>" >&2
27
+ echo " <target-version> Semver string (e.g., 2.150.0)." >&2
28
+ }
29
+
30
+ if [[ $# -ne 1 ]]; then
31
+ usage
32
+ exit 1
33
+ fi
34
+
35
+ TARGET_VERSION="$1"
36
+ if ! [[ "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
37
+ echo "Error: invalid semver '${TARGET_VERSION}'" >&2
38
+ usage
39
+ exit 1
40
+ fi
41
+
42
+ echo "Prepublish gate — target version ${TARGET_VERSION}"
43
+ echo "================================================="
44
+ echo "cwd: ${CLI_DIR}"
45
+ echo ""
46
+
47
+ STEP_STATUS=0
48
+ step_fail() {
49
+ echo ""
50
+ echo " FAIL: $1"
51
+ STEP_STATUS=1
52
+ }
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Step 1 — Full test suite (replaces per-push CI).
56
+ #
57
+ # Runs via `npm test` so Vitest + Node test phases are both exercised, and the
58
+ # Node phase inherits the `--test-timeout=60000 --test-concurrency=4` caps from
59
+ # package.json (DEC-BUG57-FAILFAST-NODE-TEST-001). We pass `--test-timeout`
60
+ # explicitly too so the gate cannot be silently weakened by a future
61
+ # package.json change. The node runner honors the last `--test-timeout` wins.
62
+ # ---------------------------------------------------------------------------
63
+ echo "[1/4] Full test suite — cd cli && npm test -- --test-timeout=60000"
64
+ if npm test -- --test-timeout=60000; then
65
+ echo " PASS: full test suite green"
66
+ else
67
+ step_fail "full test suite failed"
68
+ fi
69
+ echo ""
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Step 2 — Release preflight in publish-gate mode.
73
+ #
74
+ # `release-preflight.sh --publish-gate` enforces strict mode (clean tree,
75
+ # bumped package.json, CHANGELOG entry present, release-alignment manifest
76
+ # aligned, pack dry-run succeeds) and runs only the release-gate-critical
77
+ # test subset. Step 1 already ran the full suite; step 2 enforces the
78
+ # release-specific invariants.
79
+ # ---------------------------------------------------------------------------
80
+ echo "[2/4] Release preflight — bash scripts/release-preflight.sh --publish-gate"
81
+ if bash "${SCRIPT_DIR}/release-preflight.sh" --publish-gate --target-version "${TARGET_VERSION}"; then
82
+ echo " PASS: release-preflight gate green"
83
+ else
84
+ step_fail "release-preflight gate failed"
85
+ fi
86
+ echo ""
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Step 3 — npm pack --dry-run (claim-reality coverage).
90
+ #
91
+ # Proves the tarball the publish workflow will upload is reproducible from
92
+ # HEAD. The publish workflow itself re-runs this; we catch pack failures here
93
+ # before the tag is even created, so a broken `files:` glob or missing dist
94
+ # artifact never reaches remote CI.
95
+ # ---------------------------------------------------------------------------
96
+ echo "[3/4] Pack dry-run — npm pack --dry-run"
97
+ if npm pack --dry-run >/dev/null 2>&1; then
98
+ echo " PASS: npm pack --dry-run succeeded"
99
+ else
100
+ step_fail "npm pack --dry-run failed (rerun with streamed output for details)"
101
+ npm pack --dry-run 2>&1 | tail -20 || true
102
+ fi
103
+ echo ""
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Step 4 — Release-alignment manifest (17-surface drift gate).
107
+ #
108
+ # `check-release-alignment.mjs` owns the shared manifest of every surface that
109
+ # must reference the target version (CHANGELOG, website release pages,
110
+ # capabilities.json, implementor guide, launch evidence, onboarding docs,
111
+ # marketing drafts, llms.txt, homebrew mirror, package.json). Step 2 runs the
112
+ # same check, but we invoke it directly so the final status line is visible
113
+ # in the gate's own output — agents reading this log get an explicit
114
+ # alignment-pass signal without having to scrape the preflight's mid-run
115
+ # block.
116
+ # ---------------------------------------------------------------------------
117
+ echo "[4/4] Release alignment — node scripts/check-release-alignment.mjs --scope current"
118
+ if node "${SCRIPT_DIR}/check-release-alignment.mjs" --scope current --target-version "${TARGET_VERSION}"; then
119
+ echo " PASS: release alignment green"
120
+ else
121
+ step_fail "release alignment failed"
122
+ fi
123
+ echo ""
124
+
125
+ echo "================================================="
126
+ if [[ "${STEP_STATUS}" -ne 0 ]]; then
127
+ echo "PREPUBLISH GATE FAILED for ${TARGET_VERSION} — do NOT tag, do NOT push."
128
+ exit 1
129
+ fi
130
+
131
+ echo "PREPUBLISH GATE PASSED for ${TARGET_VERSION} — safe to tag and push."
132
+ exit 0
@@ -342,12 +342,12 @@ else
342
342
  PREFLIGHT_FAILED=0
343
343
 
344
344
  # 8.5a. Full test suite with release env vars
345
- if env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test >/dev/null 2>&1; then
345
+ if env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test -- --test-timeout=60000 >/dev/null 2>&1; then
346
346
  echo " OK: test suite passed"
347
347
  else
348
348
  echo " FAIL: test suite failed" >&2
349
349
  echo " Re-running with output for diagnostics..." >&2
350
- env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test 2>&1 | tail -30 >&2
350
+ env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test -- --test-timeout=60000 2>&1 | tail -30 >&2
351
351
  PREFLIGHT_FAILED=1
352
352
  fi
353
353
 
@@ -57,20 +57,27 @@ function extractSummaryParagraph(text, version) {
57
57
  }
58
58
 
59
59
  function extractAggregateEvidenceLine(text) {
60
- const matches = [...text.matchAll(/^-\s+.*\b(\d+)\s+tests\b.*\b0 failures\b.*$/gm)];
60
+ const matches = [...text.matchAll(/^-\s+(.*\b(\d+)\s+tests\b.*\b0 failures\b.*)$/gm)];
61
61
  if (matches.length === 0) {
62
62
  throw new Error('Concrete aggregate evidence line missing from governed release page');
63
63
  }
64
64
 
65
65
  const aggregate = matches.reduce((best, match) => {
66
- const count = Number(match[1]);
66
+ const count = Number(match[2]);
67
67
  if (!best || count > best.count) {
68
- return { count, line: match[0] };
68
+ return { count, line: match[1] };
69
69
  }
70
70
  return best;
71
71
  }, null);
72
72
 
73
- return aggregate.line.replace(/\*\*/g, '').replace(/`/g, '').replace(/,/g, '').trim();
73
+ const line = aggregate.line.replace(/\*\*/g, '').replace(/`/g, '').replace(/,/g, '').trim();
74
+ const evidenceMatch = line.match(/\b\d+\s+tests\b.*\b0 failures\b.*/);
75
+ if (!evidenceMatch) {
76
+ throw new Error('Concrete aggregate evidence line missing from governed release page');
77
+ }
78
+ const prefix = line.slice(0, evidenceMatch.index).trim();
79
+ const evidence = evidenceMatch[0].trim();
80
+ return `- ${evidence}${prefix ? ` — ${prefix.replace(/[→-]\s*$/, '').trim()}` : ''}`;
74
81
  }
75
82
 
76
83
  function getPreviousVersionTag(repoRoot, version) {
@@ -58,7 +58,7 @@
58
58
  * purpose), and does NOT require the governed dispatcher to be running.
59
59
  */
60
60
 
61
- import { spawn } from 'child_process';
61
+ import { spawn, spawnSync } from 'child_process';
62
62
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
63
63
  import { join, resolve } from 'path';
64
64
  import { fileURLToPath } from 'url';
@@ -315,20 +315,6 @@ async function runOneAttempt({
315
315
  attempt.spawn_attached_elapsed_ms = now - t0;
316
316
  });
317
317
 
318
- if (child.stdin) {
319
- child.stdin.on('error', (err) => {
320
- // Capture but do not fail — adapter behavior matches: stdin EPIPE is
321
- // logged and the spawn continues to play out via close/error events.
322
- attempt.stderr += `[repro:stdin_error] ${err?.code || ''} ${err?.message || ''}\n`;
323
- });
324
- try {
325
- if (transport === 'stdin') child.stdin.write(fullPrompt);
326
- child.stdin.end();
327
- } catch (err) {
328
- attempt.stderr += `[repro:stdin_throw] ${err?.code || ''} ${err?.message || ''}\n`;
329
- }
330
- }
331
-
332
318
  if (child.stdout) {
333
319
  child.stdout.on('data', (chunk) => {
334
320
  const text = chunk.toString();
@@ -343,6 +329,20 @@ async function runOneAttempt({
343
329
  });
344
330
  }
345
331
 
332
+ if (child.stdin) {
333
+ child.stdin.on('error', (err) => {
334
+ // Capture but do not fail — adapter behavior matches: stdin EPIPE is
335
+ // logged and the spawn continues to play out via close/error events.
336
+ attempt.stderr += `[repro:stdin_error] ${err?.code || ''} ${err?.message || ''}\n`;
337
+ });
338
+ try {
339
+ if (transport === 'stdin') child.stdin.write(fullPrompt);
340
+ child.stdin.end();
341
+ } catch (err) {
342
+ attempt.stderr += `[repro:stdin_throw] ${err?.code || ''} ${err?.message || ''}\n`;
343
+ }
344
+ }
345
+
346
346
  if (child.stderr) {
347
347
  child.stderr.on('data', (chunk) => {
348
348
  const text = chunk.toString();
@@ -496,6 +496,7 @@ async function main() {
496
496
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt) : 0;
497
497
  const diagnosticArgs = redactArgs(args, fullPrompt, transport);
498
498
  const envSnapshot = snapshotEnv(spawnEnv);
499
+ const commandProbe = probeCommand(command, runtimeCwd, spawnEnv);
499
500
 
500
501
  const header = {
501
502
  repro_version: 1,
@@ -510,6 +511,7 @@ async function main() {
510
511
  stdin_bytes: stdinBytes,
511
512
  prompt_source: promptSource,
512
513
  env_snapshot: envSnapshot,
514
+ command_probe: commandProbe,
513
515
  watchdog_ms: opts.noWatchdog ? null : watchdogMs,
514
516
  no_watchdog: opts.noWatchdog,
515
517
  attempts_planned: opts.attempts,
@@ -529,6 +531,11 @@ async function main() {
529
531
  console.error(`[repro] prompt : ${promptSource.kind} (${promptSource.length_bytes} bytes)`);
530
532
  console.error(`[repro] watchdog_ms : ${header.watchdog_ms ?? 'disabled'}`);
531
533
  console.error(`[repro] auth env : ${JSON.stringify(envSnapshot.auth_env_present)}`);
534
+ if (commandProbe.kind === 'claude_version') {
535
+ console.error(`[repro] claude probe : status=${commandProbe.status ?? '-'} signal=${commandProbe.signal ?? '-'} stdout=${JSON.stringify(commandProbe.stdout || '')}`);
536
+ } else {
537
+ console.error(`[repro] command probe: ${commandProbe.kind} (${commandProbe.reason})`);
538
+ }
532
539
  console.error(`[repro] attempts : ${header.attempts_planned}`);
533
540
  console.error('');
534
541
 
@@ -617,6 +624,65 @@ function summarize(attempts) {
617
624
  };
618
625
  }
619
626
 
627
+ function probeCommand(command, cwd, env) {
628
+ if (!isClaudeCommand(command)) {
629
+ return {
630
+ kind: 'skipped',
631
+ reason: 'not a claude command',
632
+ };
633
+ }
634
+ try {
635
+ const result = spawnSync(command, ['--version'], {
636
+ cwd,
637
+ env,
638
+ encoding: 'utf8',
639
+ timeout: 10_000,
640
+ maxBuffer: 1024 * 1024,
641
+ });
642
+ return {
643
+ kind: 'claude_version',
644
+ command,
645
+ args: ['--version'],
646
+ timeout_ms: 10_000,
647
+ status: result.status,
648
+ signal: result.signal,
649
+ stdout: result.stdout || '',
650
+ stderr: result.stderr || '',
651
+ error: result.error ? {
652
+ code: result.error.code ?? null,
653
+ errno: result.error.errno ?? null,
654
+ syscall: result.error.syscall ?? null,
655
+ message: result.error.message || String(result.error),
656
+ } : null,
657
+ timed_out: result.error?.code === 'ETIMEDOUT',
658
+ };
659
+ } catch (err) {
660
+ return {
661
+ kind: 'claude_version',
662
+ command,
663
+ args: ['--version'],
664
+ timeout_ms: 10_000,
665
+ status: null,
666
+ signal: null,
667
+ stdout: '',
668
+ stderr: '',
669
+ error: {
670
+ code: err?.code ?? null,
671
+ errno: err?.errno ?? null,
672
+ syscall: err?.syscall ?? null,
673
+ message: err?.message || String(err),
674
+ },
675
+ timed_out: err?.code === 'ETIMEDOUT',
676
+ };
677
+ }
678
+ }
679
+
680
+ function isClaudeCommand(command) {
681
+ if (typeof command !== 'string') return false;
682
+ const normalized = command.replace(/\\/g, '/');
683
+ return normalized === 'claude' || normalized.endsWith('/claude');
684
+ }
685
+
620
686
  main().catch((err) => {
621
687
  console.error(`[repro] fatal: ${err?.stack || err?.message || err}`);
622
688
  process.exit(99);
@@ -59,7 +59,7 @@ export async function doctorCommand(opts = {}) {
59
59
 
60
60
  // ── Governed (v4) Doctor ────────────────────────────────────────────────────
61
61
 
62
- function governedDoctor(root, rawConfig, opts) {
62
+ async function governedDoctor(root, rawConfig, opts) {
63
63
  const checks = [];
64
64
  const cliVersionHealth = getCliVersionHealth();
65
65
  let stateRunId = null;
@@ -93,7 +93,7 @@ function governedDoctor(root, rawConfig, opts) {
93
93
  const runtimes = (normalized && normalized.runtimes) || rawConfig.runtimes || {};
94
94
  const rolesByRuntime = buildRolesByRuntime(normalized?.roles || {});
95
95
  for (const [rtId, rt] of Object.entries(runtimes)) {
96
- const check = checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
96
+ const check = await checkRuntimeReachable(root, rtId, rt, rolesByRuntime[rtId] || []);
97
97
  checks.push(check);
98
98
  }
99
99
  const connectorProbe = getConnectorProbeRecommendation(runtimes);
@@ -488,7 +488,7 @@ function buildCliVersionCheck(cliVersionHealth) {
488
488
  };
489
489
  }
490
490
 
491
- function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
491
+ async function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
492
492
  const base = { id: `runtime_${rtId}`, name: `Runtime: ${rtId}` };
493
493
 
494
494
  if (!rt || !rt.type) {
@@ -502,7 +502,7 @@ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
502
502
  case 'local_cli': {
503
503
  const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
504
504
  if (probe.ok) {
505
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(rt);
505
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(rt);
506
506
  if (claudeAuthIssue) {
507
507
  return attachRuntimeContract({
508
508
  ...base,
@@ -98,7 +98,7 @@ const GOVERNED_ROLES = {
98
98
 
99
99
  const DEFAULT_GOVERNED_LOCAL_DEV_RUNTIME = Object.freeze({
100
100
  type: 'local_cli',
101
- command: ['claude', '--print', '--dangerously-skip-permissions'],
101
+ command: ['claude', '--print', '--dangerously-skip-permissions', '--bare'],
102
102
  cwd: '.',
103
103
  prompt_transport: 'stdin',
104
104
  });
@@ -1377,7 +1377,9 @@ export async function missionPlanAutopilotCommand(planTarget, opts) {
1377
1377
  }
1378
1378
 
1379
1379
  if (waveNum === maxWaves) {
1380
- terminalReason = 'wave_limit_reached';
1380
+ terminalReason = totalFailed > 0
1381
+ ? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
1382
+ : 'wave_limit_reached';
1381
1383
  break;
1382
1384
  }
1383
1385
 
@@ -2030,7 +2032,9 @@ async function coordinatorAutopilot(planTarget, opts, context, mission) {
2030
2032
  }
2031
2033
 
2032
2034
  if (waveNum === maxWaves) {
2033
- terminalReason = 'wave_limit_reached';
2035
+ terminalReason = totalFailed > 0
2036
+ ? (continueOnFailure ? 'plan_incomplete' : 'failure_stopped')
2037
+ : 'wave_limit_reached';
2034
2038
  break;
2035
2039
  }
2036
2040
 
@@ -219,7 +219,7 @@ export async function restartCommand(opts) {
219
219
  process.exit(1);
220
220
  }
221
221
 
222
- if (state.status === 'blocked') {
222
+ if (state.status === 'blocked' && !state.pending_phase_transition && !state.pending_run_completion) {
223
223
  console.log(chalk.red('Run is blocked. Resolve the blocker first.'));
224
224
  const recovery = deriveRecoveryDescriptor(state, config);
225
225
  if (recovery) {
@@ -120,7 +120,14 @@ function buildArtifactIndex(root, turnId) {
120
120
  }
121
121
 
122
122
  function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
123
- const elapsedMs = getElapsedMs(turn.started_at);
123
+ // Effective start time for display: BUG-51 hardening clears `started_at`
124
+ // when a turn transitions to `dispatched` so ghost-turn detection can key
125
+ // off `dispatched_at`. For manual runtimes (no subprocess), no later
126
+ // `starting` transition re-sets `started_at`, so fall back to
127
+ // `dispatched_at` (operator-dispatched = effective start) and
128
+ // `assigned_at` as a last resort so the timing surface stays populated.
129
+ const effectiveStartedAt = turn.started_at || turn.dispatched_at || turn.assigned_at || null;
130
+ const elapsedMs = getElapsedMs(effectiveStartedAt);
124
131
  const payload = {
125
132
  turn_id: turnId,
126
133
  run_id: state.run_id || assignment?.run_id || null,
@@ -129,7 +136,7 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
129
136
  runtime: turn.runtime_id,
130
137
  status: turn.status,
131
138
  attempt: turn.attempt,
132
- started_at: turn.started_at || null,
139
+ started_at: effectiveStartedAt,
133
140
  elapsed_ms: elapsedMs,
134
141
  dispatch_dir: getDispatchTurnDir(turnId),
135
142
  staging_result_path: assignment?.staging_result_path || null,
@@ -152,7 +159,9 @@ function buildTurnPayload(turnId, turn, state, artifacts, assignment, config) {
152
159
  }
153
160
 
154
161
  function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
155
- const elapsedMs = getElapsedMs(turn.started_at);
162
+ // See buildTurnPayload for rationale on fallback ordering.
163
+ const effectiveStartedAt = turn.started_at || turn.dispatched_at || turn.assigned_at || null;
164
+ const elapsedMs = getElapsedMs(effectiveStartedAt);
156
165
  console.log('');
157
166
  console.log(chalk.bold(` Turn: ${chalk.cyan(turnId)}`));
158
167
  console.log(chalk.dim(' ' + '─'.repeat(44)));
@@ -162,8 +171,8 @@ function printTurnSummary(turnId, turn, state, artifacts, assignment, config) {
162
171
  console.log(` ${chalk.dim('Runtime:')} ${turn.runtime_id}`);
163
172
  console.log(` ${chalk.dim('Status:')} ${turn.status}`);
164
173
  console.log(` ${chalk.dim('Attempt:')} ${turn.attempt}`);
165
- if (turn.started_at) {
166
- console.log(` ${chalk.dim('Started:')} ${turn.started_at}`);
174
+ if (effectiveStartedAt) {
175
+ console.log(` ${chalk.dim('Started:')} ${effectiveStartedAt}`);
167
176
  }
168
177
  if (elapsedMs != null) {
169
178
  console.log(` ${chalk.dim('Elapsed:')} ${formatElapsed(elapsedMs)}`);
@@ -128,13 +128,14 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
128
128
  const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
129
129
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
130
130
  const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
131
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime, spawnEnv);
131
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, spawnEnv);
132
132
 
133
133
  if (claudeAuthIssue) {
134
134
  appendDiagnostic(logs, 'claude_auth_preflight_failed', {
135
135
  runtime_id: runtimeId,
136
136
  turn_id: turn.turn_id,
137
137
  auth_env_present: claudeAuthIssue.auth_env_present,
138
+ smoke_probe: claudeAuthIssue.smoke_probe,
138
139
  recommendation: claudeAuthIssue.fix,
139
140
  });
140
141
  return {
@@ -263,7 +264,29 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
263
264
  armStartupWatchdog();
264
265
  });
265
266
 
266
- // Deliver prompt via stdin if transport is "stdin"; otherwise close immediately
267
+ // Collect stdout/stderr
268
+ if (child.stdout) {
269
+ child.stdout.on('data', (chunk) => {
270
+ const text = chunk.toString();
271
+ stdoutBytes += Buffer.byteLength(text);
272
+ recordFirstOutput('stdout');
273
+ logs.push(text);
274
+ if (onStdout) onStdout(text);
275
+ });
276
+ }
277
+
278
+ if (child.stderr) {
279
+ child.stderr.on('data', (chunk) => {
280
+ const text = chunk.toString();
281
+ stderrBytes += Buffer.byteLength(text);
282
+ stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
283
+ logs.push('[stderr] ' + text);
284
+ if (onStderr) onStderr(text);
285
+ });
286
+ }
287
+
288
+ // Deliver prompt only after output listeners are registered. This removes
289
+ // the remaining adapter-side ordering risk for fast stdin-driven children.
267
290
  if (child.stdin) {
268
291
  child.stdin.on('error', (err) => {
269
292
  appendDiagnostic(logs, 'stdin_error', {
@@ -286,27 +309,6 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
286
309
  }
287
310
  }
288
311
 
289
- // Collect stdout/stderr
290
- if (child.stdout) {
291
- child.stdout.on('data', (chunk) => {
292
- const text = chunk.toString();
293
- stdoutBytes += Buffer.byteLength(text);
294
- recordFirstOutput('stdout');
295
- logs.push(text);
296
- if (onStdout) onStdout(text);
297
- });
298
- }
299
-
300
- if (child.stderr) {
301
- child.stderr.on('data', (chunk) => {
302
- const text = chunk.toString();
303
- stderrBytes += Buffer.byteLength(text);
304
- stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
305
- logs.push('[stderr] ' + text);
306
- if (onStderr) onStderr(text);
307
- });
308
- }
309
-
310
312
  // Timeout handling per §20.4
311
313
  let timeoutHandle;
312
314
  let sigkillHandle;
@@ -1,3 +1,5 @@
1
+ import { spawn } from 'node:child_process';
2
+
1
3
  const CLAUDE_ENV_AUTH_KEYS = [
2
4
  'ANTHROPIC_API_KEY',
3
5
  'CLAUDE_API_KEY',
@@ -6,6 +8,9 @@ const CLAUDE_ENV_AUTH_KEYS = [
6
8
  'CLAUDE_CODE_USE_BEDROCK',
7
9
  ];
8
10
 
11
+ const DEFAULT_SMOKE_PROBE_TIMEOUT_MS = 10_000;
12
+ const DEFAULT_SMOKE_PROBE_STDIN = 'ok';
13
+
9
14
  function normalizeCommandTokens(runtime) {
10
15
  if (Array.isArray(runtime?.command)) {
11
16
  return runtime.command.flatMap((element) =>
@@ -41,7 +46,26 @@ export function hasClaudeEnvAuth(env = process.env) {
41
46
  return Object.values(getClaudeEnvAuthPresence(env)).some(Boolean);
42
47
  }
43
48
 
44
- export function getClaudeSubprocessAuthIssue(runtime, env = process.env) {
49
+ function buildClaudeSubprocessAuthIssue(env, smokeProbe = null) {
50
+ const auth_env_present = getClaudeEnvAuthPresence(env);
51
+ return {
52
+ auth_env_present,
53
+ smoke_probe: smokeProbe,
54
+ detail: 'Claude local_cli runtime has no env-based auth and is missing "--bare"; non-interactive subprocesses can hang on macOS keychain reads.',
55
+ fix: 'Export ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN before running AgentXchain, or add "--bare" to the Claude command if you intentionally want env-only auth.',
56
+ };
57
+ }
58
+
59
+ function resolveSmokeProbeTimeoutMs(env, options = {}) {
60
+ if (Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0) {
61
+ return options.timeoutMs;
62
+ }
63
+ const raw = env?.AGENTXCHAIN_CLAUDE_AUTH_PROBE_TIMEOUT_MS;
64
+ const parsed = Number.parseInt(raw, 10);
65
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SMOKE_PROBE_TIMEOUT_MS;
66
+ }
67
+
68
+ export async function getClaudeSubprocessAuthIssue(runtime, env = process.env, options = {}) {
45
69
  if (!isClaudeLocalCliRuntime(runtime)) {
46
70
  return null;
47
71
  }
@@ -50,12 +74,164 @@ export function getClaudeSubprocessAuthIssue(runtime, env = process.env) {
50
74
  return null;
51
75
  }
52
76
 
53
- const auth_env_present = getClaudeEnvAuthPresence(env);
54
- return {
55
- auth_env_present,
56
- detail: 'Claude local_cli runtime has no env-based auth and is missing "--bare"; non-interactive subprocesses can hang on macOS keychain reads.',
57
- fix: 'Export ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN before running AgentXchain, or add "--bare" to the Claude command if you intentionally want env-only auth.',
58
- };
77
+ const smokeProbe = await runClaudeSmokeProbe({
78
+ runtime,
79
+ env,
80
+ timeoutMs: resolveSmokeProbeTimeoutMs(env, options),
81
+ stdinPayload: options?.stdinPayload,
82
+ spawnImpl: options?.spawnImpl,
83
+ });
84
+
85
+ if (smokeProbe.kind === 'stdout_observed' || smokeProbe.kind === 'spawn_error' || smokeProbe.kind === 'skipped') {
86
+ return null;
87
+ }
88
+
89
+ if (smokeProbe.kind === 'hang' || smokeProbe.kind === 'exit_nonzero' || smokeProbe.kind === 'stderr_only') {
90
+ return buildClaudeSubprocessAuthIssue(env, smokeProbe);
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Bounded smoke probe that spawns the runtime's actual Claude command with a
98
+ * tiny prompt on stdin and a watchdog. Returns a classification:
99
+ *
100
+ * { kind: 'stdout_observed' } — real stdout arrived before watchdog;
101
+ * the setup is NOT hanging on auth.
102
+ * { kind: 'hang', elapsed_ms } — watchdog fired with no stdout/stderr
103
+ * bytes; the keychain-hang shape (BUG-54).
104
+ * { kind: 'stderr_only', ... } — process wrote stderr but no stdout
105
+ * before watchdog (auth error or similar).
106
+ * { kind: 'exit_nonzero', ... } — process exited non-zero with no stdout
107
+ * (explicit auth failure — not a hang).
108
+ * { kind: 'spawn_error', ... } — spawn itself failed (ENOENT / EPERM).
109
+ * { kind: 'skipped', reason } — probe disabled or unavailable.
110
+ *
111
+ * This is the positive-case-testable alternative to the static shape-check in
112
+ * `getClaudeSubprocessAuthIssue`: it observes what the subprocess actually
113
+ * does rather than predicting what it *might* do from config shape alone.
114
+ *
115
+ * Added 2026-04-21 for the BUG-56 false-positive fix. See
116
+ * `.planning/BUG_56_FALSE_POSITIVE_RETRO.md` for the decision trail.
117
+ *
118
+ * @param {{ runtime: object, env?: object, timeoutMs?: number, stdinPayload?: string, spawnImpl?: Function }} opts
119
+ * @returns {Promise<object>}
120
+ */
121
+ export async function runClaudeSmokeProbe(opts) {
122
+ const runtime = opts?.runtime ?? null;
123
+ const env = opts?.env ?? process.env;
124
+ const timeoutMs = Number.isFinite(opts?.timeoutMs) ? opts.timeoutMs : DEFAULT_SMOKE_PROBE_TIMEOUT_MS;
125
+ const stdinPayload = typeof opts?.stdinPayload === 'string' ? opts.stdinPayload : DEFAULT_SMOKE_PROBE_STDIN;
126
+ const spawnImpl = typeof opts?.spawnImpl === 'function' ? opts.spawnImpl : spawn;
127
+
128
+ if (!isClaudeLocalCliRuntime(runtime)) {
129
+ return { kind: 'skipped', reason: 'not_claude_local_cli' };
130
+ }
131
+
132
+ const tokens = normalizeCommandTokens(runtime);
133
+ if (tokens.length === 0) {
134
+ return { kind: 'skipped', reason: 'empty_command' };
135
+ }
136
+ const [command, ...args] = tokens;
137
+
138
+ return new Promise((resolve) => {
139
+ let child;
140
+ try {
141
+ child = spawnImpl(command, args, {
142
+ stdio: ['pipe', 'pipe', 'pipe'],
143
+ env,
144
+ });
145
+ } catch (error) {
146
+ resolve({
147
+ kind: 'spawn_error',
148
+ errno: error?.errno ?? null,
149
+ code: error?.code ?? null,
150
+ message: error?.message ?? String(error),
151
+ });
152
+ return;
153
+ }
154
+
155
+ if (!child || typeof child.on !== 'function') {
156
+ resolve({ kind: 'spawn_error', code: 'NO_CHILD_HANDLE', message: 'spawn returned no child handle' });
157
+ return;
158
+ }
159
+
160
+ const start = Date.now();
161
+ let stdoutBytes = 0;
162
+ let stderrBytes = 0;
163
+ let stderrBuf = '';
164
+ let settled = false;
165
+
166
+ const finish = (result) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
170
+ resolve(result);
171
+ };
172
+
173
+ const watchdog = setTimeout(() => {
174
+ const elapsed_ms = Date.now() - start;
175
+ if (stdoutBytes > 0) {
176
+ finish({ kind: 'stdout_observed', elapsed_ms });
177
+ } else if (stderrBytes > 0) {
178
+ finish({ kind: 'stderr_only', elapsed_ms, stderr_snippet: stderrBuf.slice(0, 500) });
179
+ } else {
180
+ finish({ kind: 'hang', elapsed_ms });
181
+ }
182
+ }, timeoutMs);
183
+ if (typeof watchdog.unref === 'function') watchdog.unref();
184
+
185
+ child.stdout?.on('data', (chunk) => {
186
+ stdoutBytes += chunk.length;
187
+ if (stdoutBytes > 0 && !settled) {
188
+ clearTimeout(watchdog);
189
+ finish({ kind: 'stdout_observed', elapsed_ms: Date.now() - start });
190
+ }
191
+ });
192
+
193
+ child.stderr?.on('data', (chunk) => {
194
+ stderrBytes += chunk.length;
195
+ stderrBuf += chunk.toString('utf8');
196
+ });
197
+
198
+ child.on('error', (error) => {
199
+ clearTimeout(watchdog);
200
+ finish({
201
+ kind: 'spawn_error',
202
+ errno: error?.errno ?? null,
203
+ code: error?.code ?? null,
204
+ message: error?.message ?? String(error),
205
+ });
206
+ });
207
+
208
+ child.on('exit', (code, signal) => {
209
+ if (settled) return;
210
+ clearTimeout(watchdog);
211
+ const elapsed_ms = Date.now() - start;
212
+ if (stdoutBytes > 0) {
213
+ finish({ kind: 'stdout_observed', elapsed_ms });
214
+ } else if (code !== 0) {
215
+ finish({
216
+ kind: 'exit_nonzero',
217
+ elapsed_ms,
218
+ exit_code: code,
219
+ exit_signal: signal,
220
+ stderr_snippet: stderrBuf.slice(0, 500),
221
+ });
222
+ } else if (stderrBytes > 0) {
223
+ finish({ kind: 'stderr_only', elapsed_ms, stderr_snippet: stderrBuf.slice(0, 500) });
224
+ } else {
225
+ finish({ kind: 'hang', elapsed_ms });
226
+ }
227
+ });
228
+
229
+ try {
230
+ child.stdin?.end(`${stdinPayload}\n`);
231
+ } catch {
232
+ // best-effort; error will surface via 'error' event if real
233
+ }
234
+ });
59
235
  }
60
236
 
61
237
  export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };
@@ -165,30 +165,30 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {
165
165
  };
166
166
  }
167
167
 
168
- const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
169
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
170
-
171
- // DEC-BUG54-CLAUDE-AUTH-PREFLIGHT-001 / DEC-BUG54-VALIDATE-AUTH-PREFLIGHT-001
172
- // Auth-preflight is a config-shape defect that must fire regardless of whether
173
- // the binary currently resolves on PATH. Matches connector-validate.js:108-138
174
- // ordering: a Claude local_cli runtime with no env auth and no --bare is a
175
- // deterministic hang-on-spawn shape the operator must fix before anything
176
- // else. If they fix auth (or add --bare) but still do not have claude
177
- // installed, the next connector check surfaces command_presence after they
178
- // fix the config — that is the correct operator progression.
168
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime, process.env, {
169
+ timeoutMs: options.claudeAuthProbeTimeoutMs,
170
+ });
171
+
172
+ // DEC-BUG56-PREFLIGHT-PROBE-OVER-SHAPE-CHECK-001
173
+ // Auth-preflight is observation-based: a no-env/no-bare Claude runtime is
174
+ // only refused when a bounded smoke probe actually hangs or fails without
175
+ // stdout. Working Claude Max keychain setups must pass this gate.
179
176
  if (claudeAuthIssue) {
180
177
  return {
181
178
  ...base,
182
179
  level: 'fail',
183
180
  probe_kind: 'auth_preflight',
184
- command: spawnProbe.command || head,
181
+ command: formatTarget(runtime) || head,
185
182
  error_code: 'claude_auth_preflight_failed',
186
183
  detail: claudeAuthIssue.detail,
187
184
  fix: claudeAuthIssue.fix,
188
185
  auth_env_present: claudeAuthIssue.auth_env_present,
186
+ smoke_probe: claudeAuthIssue.smoke_probe,
189
187
  };
190
188
  }
191
189
 
190
+ const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
191
+
192
192
  if (!spawnProbe.ok) {
193
193
  return {
194
194
  ...base,
@@ -401,8 +401,6 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
401
401
  // Prompt transport validation
402
402
  const transport = runtime.prompt_transport || 'dispatch_bundle_only';
403
403
  const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
404
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
405
-
406
404
  if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
407
405
  warnings.push({
408
406
  probe_kind: 'transport_intent',
@@ -422,15 +420,6 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
422
420
  });
423
421
  }
424
422
 
425
- if (claudeAuthIssue) {
426
- warnings.push({
427
- probe_kind: 'auth_preflight',
428
- level: 'warn',
429
- detail: claudeAuthIssue.detail,
430
- fix: claudeAuthIssue.fix,
431
- });
432
- }
433
-
434
423
  return { warnings };
435
424
  }
436
425
 
@@ -105,11 +105,10 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
105
105
  };
106
106
  }
107
107
 
108
- // DEC-BUG54-CLAUDE-AUTH-PREFLIGHT-001 — refuse the known-hanging Claude
109
- // local_cli shape before burning the scratch-workspace + synthetic-dispatch
110
- // ceremony. The adapter also refuses this shape via `claude_auth_preflight_failed`,
111
- // but the operator gets a faster, identical-fix message if we catch it here.
112
- const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
108
+ // DEC-BUG56-PREFLIGHT-PROBE-OVER-SHAPE-CHECK-001 — refuse Claude local_cli
109
+ // auth-hang shapes only after a bounded smoke probe observes no stdout.
110
+ // Working Claude Max keychain setups must pass instead of false-positive.
111
+ const claudeAuthIssue = await getClaudeSubprocessAuthIssue(runtime);
113
112
  if (claudeAuthIssue) {
114
113
  return {
115
114
  ok: false,
@@ -131,6 +130,7 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
131
130
  error_code: 'claude_auth_preflight_failed',
132
131
  error: claudeAuthIssue.detail,
133
132
  auth_env_present: claudeAuthIssue.auth_env_present,
133
+ smoke_probe: claudeAuthIssue.smoke_probe,
134
134
  fix: claudeAuthIssue.fix,
135
135
  dispatch: null,
136
136
  validation: null,
@@ -1383,6 +1383,10 @@ function classifyAcceptanceOverlap(targetTurn, conflictFiles, historyEntries, co
1383
1383
  const forwardRevisionTurns = new Map();
1384
1384
 
1385
1385
  for (const entry of historyEntries) {
1386
+ if (targetTurn?.run_id && entry.run_id && entry.run_id !== targetTurn.run_id) {
1387
+ continue;
1388
+ }
1389
+
1386
1390
  if ((entry.accepted_sequence || 0) <= (targetTurn.assigned_sequence || 0)) {
1387
1391
  continue;
1388
1392
  }
@@ -2096,6 +2100,24 @@ function inferApprovalPauseFromState(state, config) {
2096
2100
  };
2097
2101
  }
2098
2102
 
2103
+ function shouldNormalizeApprovalPauseBlock(state, inferred) {
2104
+ if (!state || typeof state !== 'object') {
2105
+ return false;
2106
+ }
2107
+
2108
+ const blockedOn = typeof state.blocked_on === 'string' ? state.blocked_on : '';
2109
+ const recovery = state.blocked_reason?.recovery;
2110
+ const typedReason = recovery?.typed_reason || null;
2111
+
2112
+ if (blockedOn.startsWith('human_approval:')) {
2113
+ return true;
2114
+ }
2115
+
2116
+ return state.status === 'paused'
2117
+ && state.blocked_reason != null
2118
+ && (!typedReason || typedReason === inferred.typedReason);
2119
+ }
2120
+
2099
2121
  export function reconcileApprovalPausesWithConfig(state, config) {
2100
2122
  if (!state || typeof state !== 'object' || !config) {
2101
2123
  return { state, changed: false };
@@ -2117,7 +2139,7 @@ export function reconcileApprovalPausesWithConfig(state, config) {
2117
2139
  changed = true;
2118
2140
  }
2119
2141
 
2120
- if (nextState.status === 'blocked' || nextState.blocked_reason != null) {
2142
+ if (shouldNormalizeApprovalPauseBlock(nextState, inferred)) {
2121
2143
  nextState = {
2122
2144
  ...nextState,
2123
2145
  status: 'paused',
@@ -3034,12 +3056,20 @@ export function assignGovernedTurn(root, config, roleId, options = {}) {
3034
3056
  const concurrentWith = Object.keys(activeTurns);
3035
3057
 
3036
3058
  // Build the new turn object
3059
+ // `started_at` is seeded at assignment so that direct assign→accept flows
3060
+ // (non-dispatched, non-subprocess turns) still carry timing into history and
3061
+ // the turn_accepted event payload (per TURN_TIMING_OBSERVABILITY_SPEC.md).
3062
+ // BUG-51's dispatched-lifecycle path explicitly deletes this and the
3063
+ // starting/running transitions re-set it, so dispatch-driven turns still
3064
+ // reflect true subprocess-startup timing.
3037
3065
  const newTurn = {
3038
3066
  turn_id: turnId,
3067
+ run_id: state.run_id,
3039
3068
  assigned_role: roleId,
3040
3069
  status: 'assigned',
3041
3070
  attempt: 1,
3042
3071
  assigned_at: now,
3072
+ started_at: now,
3043
3073
  deadline_at: new Date(Date.now() + timeoutMinutes * 60 * 1000).toISOString(),
3044
3074
  runtime_id: runtimeId,
3045
3075
  baseline,
@@ -489,8 +489,8 @@ function isAcceptedRepoHistoryEntry(entry) {
489
489
  return Boolean(entry?.accepted_at) || entry?.status === 'accepted';
490
490
  }
491
491
 
492
- const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'rejected', 'retrying', 'conflicted']);
493
- const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance']);
492
+ const REPO_FAILURE_STATUSES = new Set(['failed_acceptance', 'failed', 'failed_start', 'rejected', 'retrying', 'conflicted']);
493
+ const RETRYABLE_COORDINATOR_FAILURE_STATUSES = new Set(['failed', 'failed_acceptance', 'failed_start']);
494
494
 
495
495
  function getLatestRepoDispatches(launchRecord) {
496
496
  const latestByRepo = new Map();
@@ -708,6 +708,18 @@ function synchronizeCoordinatorWorkstreamStatuses(root, plan, coordinatorConfig,
708
708
  continue;
709
709
  }
710
710
 
711
+ if (launchRecord?.status === 'failed' || (launchRecord?.terminal_reason && launchRecord.terminal_reason !== 'completed')) {
712
+ if (ws.launch_status !== 'needs_attention') {
713
+ ws.launch_status = 'needs_attention';
714
+ changed = true;
715
+ }
716
+ if (plan.status !== 'needs_attention') {
717
+ plan.status = 'needs_attention';
718
+ changed = true;
719
+ }
720
+ continue;
721
+ }
722
+
711
723
  if ((launchRecord?.repo_dispatches?.length || 0) > 0 || progress.accepted_repo_count > 0) {
712
724
  if (ws.launch_status !== 'launched') {
713
725
  ws.launch_status = 'launched';
@@ -80,7 +80,7 @@
80
80
  "manual-pm": { "type": "manual" },
81
81
  "local-dev": {
82
82
  "type": "local_cli",
83
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
83
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
84
84
  "cwd": ".",
85
85
  "prompt_transport": "stdin"
86
86
  },
@@ -44,25 +44,25 @@
44
44
  "runtimes": {
45
45
  "local-pm": {
46
46
  "type": "local_cli",
47
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
47
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
48
48
  "cwd": ".",
49
49
  "prompt_transport": "stdin"
50
50
  },
51
51
  "local-dev": {
52
52
  "type": "local_cli",
53
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
53
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
54
54
  "cwd": ".",
55
55
  "prompt_transport": "stdin"
56
56
  },
57
57
  "local-qa": {
58
58
  "type": "local_cli",
59
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
59
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
60
60
  "cwd": ".",
61
61
  "prompt_transport": "stdin"
62
62
  },
63
63
  "local-director": {
64
64
  "type": "local_cli",
65
- "command": ["claude", "--print", "--dangerously-skip-permissions"],
65
+ "command": ["claude", "--print", "--dangerously-skip-permissions", "--bare"],
66
66
  "cwd": ".",
67
67
  "prompt_transport": "stdin"
68
68
  }