agentxchain 2.156.0 → 2.158.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.
@@ -124,6 +124,8 @@ import { benchmarkCommand } from '../src/commands/benchmark.js';
124
124
  import { benchmarkWorkloadsCommand } from '../src/commands/benchmark-workloads.js';
125
125
  import { historyCommand } from '../src/commands/history.js';
126
126
  import { decisionsCommand } from '../src/commands/decisions.js';
127
+ import { shipStatusCommand } from '../src/commands/ship-status.js';
128
+ import { attentionCommand } from '../src/commands/attention.js';
127
129
  import { diffCommand } from '../src/commands/diff.js';
128
130
  import { eventsCommand } from '../src/commands/events.js';
129
131
  import { connectorCapabilitiesCommand, connectorCheckCommand, connectorValidateCommand } from '../src/commands/connector.js';
@@ -420,6 +422,22 @@ program
420
422
  .option('-d, --dir <path>', 'Project directory')
421
423
  .action(decisionsCommand);
422
424
 
425
+ program
426
+ .command('ship-status')
427
+ .description('Evaluate whether the current project is ready to ship')
428
+ .option('-j, --json', 'Machine-readable JSON output')
429
+ .option('-v, --verbose', 'Per-dimension detail breakdown')
430
+ .option('-d, --dir <path>', 'Project directory')
431
+ .action(shipStatusCommand);
432
+
433
+ program
434
+ .command('attention')
435
+ .description('Show the cross-run human-decision queue — what needs you, and nothing else (govern by exception)')
436
+ .option('-j, --json', 'Machine-readable JSON output (full HumanAttentionReport)')
437
+ .option('-a, --all', 'Include informational/non-blocking items (e.g. pending approved intents)')
438
+ .option('-d, --dir <path>', 'Project directory')
439
+ .action(attentionCommand);
440
+
423
441
  program
424
442
  .command('diff <left_ref> <right_ref>')
425
443
  .description('Compare two recorded governed runs or two export artifacts')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.156.0",
3
+ "version": "2.158.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CLI command: agentxchain attention
3
+ *
4
+ * Answers "what needs MY attention right now — and nothing else?" by composing the
5
+ * cross-category human-decision queue into a single HumanAttentionReport. All evaluation
6
+ * logic lives in lib/human-attention.js — this command is presentation only
7
+ * (Architecture Invariant #6).
8
+ *
9
+ * It is a status surface, not a gate: it exits 0 in BOTH the clear and attention states.
10
+ * When the queue is empty it prints "Nothing needs your attention." — the operational
11
+ * proof that the human can step back and let governed autonomy run (VISION.md:51).
12
+ */
13
+
14
+ import { resolve } from 'node:path';
15
+ import chalk from 'chalk';
16
+ import { findProjectRoot } from '../lib/config.js';
17
+ import { evaluateHumanAttention } from '../lib/human-attention.js';
18
+
19
+ export async function attentionCommand(opts) {
20
+ const dir = opts.dir ? resolve(opts.dir) : process.cwd();
21
+ const root = findProjectRoot(dir);
22
+ if (!root) {
23
+ console.error(chalk.red('No agentxchain project found. Run "agentxchain init" first.'));
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ const report = evaluateHumanAttention(root);
29
+
30
+ if (opts.json) {
31
+ // --json always emits the full, schema-valid HumanAttentionReport; exit 0 in both states.
32
+ console.log(JSON.stringify(report, null, 2));
33
+ return;
34
+ }
35
+
36
+ if (report.overall === 'clear') {
37
+ console.log(chalk.green('Nothing needs your attention.'));
38
+ return;
39
+ }
40
+
41
+ // Default view surfaces blocking + escalation items first; informational pending-intent
42
+ // items are summarized as a count unless --all is passed.
43
+ const visible = opts.all ? report.items : report.items.filter((item) => item.blocking);
44
+ const hidden = report.items.length - visible.length;
45
+
46
+ console.log(
47
+ `\n${chalk.bold('Attention needed')}: ${report.items_count} `
48
+ + `item${report.items_count === 1 ? '' : 's'} (${report.blocking_count} blocking)`,
49
+ );
50
+
51
+ visible.forEach((item, idx) => {
52
+ const tag = chalk.cyan(String(item.category).padEnd(16));
53
+ const run = item.run_id ? `${chalk.dim(item.run_id)} ` : '';
54
+ console.log(` [${idx + 1}] ${tag} ${run}${item.summary}`);
55
+ console.log(` ${chalk.dim('→')} ${item.action_hint}`);
56
+ });
57
+
58
+ if (hidden > 0) {
59
+ console.log(
60
+ chalk.dim(` (+${hidden} informational item${hidden === 1 ? '' : 's'} — run with --all to show)`),
61
+ );
62
+ }
63
+
64
+ console.log('');
65
+ // Status surface, not a gate — no non-zero exit even in the attention state.
66
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * CLI command: agentxchain ship-status
3
+ *
4
+ * Answers "is this ready to ship?" by composing five evidence dimensions into a
5
+ * single ShipStatusReport. All evaluation logic lives in lib/ship-status.js — this
6
+ * command is presentation only (Architecture Invariant #5).
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { resolve, join } from 'node:path';
11
+ import chalk from 'chalk';
12
+ import { findProjectRoot } from '../lib/config.js';
13
+ import { COORDINATOR_CONFIG_FILE } from '../lib/coordinator-config.js';
14
+ import { evaluateShipStatus, evaluateCoordinatorShipStatus } from '../lib/ship-status.js';
15
+
16
+ function statusIcon(status) {
17
+ if (status === 'pass') return chalk.green('✓');
18
+ if (status === 'fail') return chalk.red('✗');
19
+ return chalk.yellow('?');
20
+ }
21
+
22
+ function overallLabel(overall) {
23
+ if (overall === 'pass') return chalk.green.bold('YES');
24
+ if (overall === 'fail') return chalk.red.bold('NO');
25
+ return chalk.yellow.bold('PENDING');
26
+ }
27
+
28
+ function countPassed(report) {
29
+ return (report.dimensions || []).filter((d) => d.status === 'pass').length;
30
+ }
31
+
32
+ function printDimensions(dimensions, indent) {
33
+ for (const dim of dimensions || []) {
34
+ console.log(`${indent}${statusIcon(dim.status)} ${dim.name}: ${dim.detail}`);
35
+ }
36
+ }
37
+
38
+ function printSingle(report, opts) {
39
+ const passed = countPassed(report);
40
+ const total = (report.dimensions || []).length;
41
+ const blocking = report.blocking_reasons || [];
42
+ const suffix = blocking.length ? `, ${blocking.length} blocking` : '';
43
+ console.log(`\nShip Status: ${overallLabel(report.overall)} (${passed}/${total} dimensions pass${suffix})`);
44
+
45
+ if (opts.verbose) {
46
+ console.log('');
47
+ printDimensions(report.dimensions, ' ');
48
+ } else {
49
+ for (const reason of blocking) {
50
+ console.log(` ${chalk.dim('-')} ${reason}`);
51
+ }
52
+ }
53
+ console.log('');
54
+ }
55
+
56
+ export async function shipStatusCommand(opts) {
57
+ const dir = opts.dir ? resolve(opts.dir) : process.cwd();
58
+ const coordinatorConfigPath = join(dir, COORDINATOR_CONFIG_FILE);
59
+
60
+ let report;
61
+ let isCoordinator = false;
62
+
63
+ if (existsSync(coordinatorConfigPath)) {
64
+ isCoordinator = true;
65
+ report = evaluateCoordinatorShipStatus(dir);
66
+ } else {
67
+ const root = findProjectRoot(dir);
68
+ if (!root) {
69
+ console.error(chalk.red('No agentxchain project found. Run "agentxchain init" first.'));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ report = evaluateShipStatus(root);
74
+ }
75
+
76
+ if (opts.json) {
77
+ console.log(JSON.stringify(report, null, 2));
78
+ } else if (isCoordinator) {
79
+ const blocking = report.blocking_repos || [];
80
+ const total = (report.repos || []).length;
81
+ const shippable = total - blocking.length;
82
+ console.log(`\nCoordinator Ship Status: ${overallLabel(report.overall)} (${shippable}/${total} repos shippable)`);
83
+ for (const entry of report.repos || []) {
84
+ const passed = countPassed(entry.ship_status);
85
+ const dimTotal = (entry.ship_status.dimensions || []).length;
86
+ console.log(`\n ${chalk.bold(entry.repo_id)}: ${overallLabel(entry.ship_status.overall)} (${passed}/${dimTotal})`);
87
+ if (opts.verbose) {
88
+ printDimensions(entry.ship_status.dimensions, ' ');
89
+ } else {
90
+ for (const reason of entry.ship_status.blocking_reasons || []) {
91
+ console.log(` ${chalk.dim('-')} ${reason}`);
92
+ }
93
+ }
94
+ }
95
+ console.log('');
96
+ } else {
97
+ printSingle(report, opts);
98
+ }
99
+
100
+ // Non-zero exit when not shippable (useful for CI gating).
101
+ if (report.overall === 'fail') {
102
+ process.exitCode = 1;
103
+ }
104
+ }
@@ -44,6 +44,10 @@ import {
44
44
  parseRateLimitResetMs,
45
45
  resolveClaudeCompatibleNodeBinary,
46
46
  } from '../claude-local-auth.js';
47
+ import {
48
+ createStreamJsonCostParser,
49
+ buildCostFromStreamJson,
50
+ } from '../stream-json-cost-parser.js';
47
51
 
48
52
  const DIAGNOSTIC_ENV_KEYS = [
49
53
  'PATH',
@@ -147,6 +151,16 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
147
151
  };
148
152
  }
149
153
 
154
+ // Create stream-json cost parser for Claude runtimes with stream-json output.
155
+ // Only Claude runtimes emit parseable NDJSON with result events; other runtimes
156
+ // (Codex, Cursor, Windsurf, OpenCode) have different stdout formats.
157
+ const tokens = [command, ...args].filter((t) => typeof t === 'string');
158
+ const usesStreamJson = tokens.includes('--output-format=stream-json')
159
+ || (tokens.includes('--output-format') && tokens[tokens.indexOf('--output-format') + 1] === 'stream-json');
160
+ const costParser = isClaudeLocalCliRuntime(runtime) && usesStreamJson
161
+ ? createStreamJsonCostParser()
162
+ : null;
163
+
150
164
  // Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
151
165
  const timeoutMs = options.dispatchTimeoutMs != null
152
166
  ? options.dispatchTimeoutMs
@@ -425,6 +439,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
425
439
  stdoutBytes += Buffer.byteLength(text);
426
440
  recordFirstOutput('stdout');
427
441
  logs.push(text);
442
+ if (costParser) costParser.push(text);
428
443
  if (onStdout) onStdout(text);
429
444
  });
430
445
  }
@@ -558,6 +573,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
558
573
  appendDiagnostic(logs, 'process_exit', exitDiagnostic);
559
574
 
560
575
  if (hasResult) {
576
+ // Enrich cost from stream-json if available (best-effort, never blocks settle)
577
+ if (costParser) {
578
+ try {
579
+ const parsedCost = costParser.getResult();
580
+ if (parsedCost) {
581
+ const stagingPath = join(root, getTurnStagingResultPath(turn.turn_id));
582
+ const raw = readFileSync(stagingPath, 'utf8');
583
+ const turnResult = JSON.parse(raw);
584
+ turnResult.cost = buildCostFromStreamJson(parsedCost, config);
585
+ writeFileSync(stagingPath, JSON.stringify(turnResult, null, 2) + '\n');
586
+ appendDiagnostic(logs, 'stream_json_cost_enriched', {
587
+ input_tokens: parsedCost.input_tokens,
588
+ output_tokens: parsedCost.output_tokens,
589
+ cost_usd: parsedCost.cost_usd,
590
+ model: parsedCost.model,
591
+ computed_usd: turnResult.cost.usd,
592
+ });
593
+ }
594
+ } catch {
595
+ // Cost enrichment is best-effort — if it fails, agent-reported cost is preserved
596
+ }
597
+ }
561
598
  settle({ ok: true, exitCode, timedOut: false, aborted: false, logs, firstOutputAt });
562
599
  } else if (isClaudeLocalCliRuntime(runtime) && hasClaudeAuthFailureOutput(logs)) {
563
600
  const recovery = 'Refresh Claude credentials before resuming: export a valid ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, then run agentxchain step --resume.';
@@ -1,5 +1,22 @@
1
1
  import { appendFileSync } from 'node:fs';
2
2
 
3
+ /**
4
+ * Normalize gate_summary to [gateName, statusString] entries.
5
+ * Handles both array format ([{ gate_id, status }]) from the real report pipeline
6
+ * and object format ({ gateName: statusString }) from legacy/synthetic fixtures.
7
+ */
8
+ function normalizeGateEntries(gates) {
9
+ if (Array.isArray(gates)) {
10
+ return gates
11
+ .filter(g => typeof g?.gate_id === 'string' && typeof g?.status === 'string')
12
+ .map(g => [g.gate_id, g.status]);
13
+ }
14
+ if (gates && typeof gates === 'object') {
15
+ return Object.entries(gates).filter(([, v]) => typeof v === 'string');
16
+ }
17
+ return [];
18
+ }
19
+
3
20
  /**
4
21
  * Detect CI environment from process.env.
5
22
  * @returns {{ provider: string, run_url: string|null, run_id: string|null, ref: string|null, sha: string|null } | null}
@@ -55,16 +72,12 @@ export function formatGitHubAnnotations(report) {
55
72
  }
56
73
 
57
74
  // Gate annotations
58
- const gates = report.subject?.run?.gate_summary;
59
- if (gates && typeof gates === 'object') {
60
- for (const [gateName, gateStatus] of Object.entries(gates)) {
61
- if (typeof gateStatus !== 'string') continue;
62
- const normalizedStatus = gateStatus.toLowerCase();
63
- if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
64
- lines.push(`::notice title=Gate ${gateName}::satisfied`);
65
- } else {
66
- lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
67
- }
75
+ for (const [gateName, gateStatus] of normalizeGateEntries(report.subject?.run?.gate_summary)) {
76
+ const normalizedStatus = gateStatus.toLowerCase();
77
+ if (normalizedStatus === 'satisfied' || normalizedStatus === 'pass' || normalizedStatus === 'passed') {
78
+ lines.push(`::notice title=Gate ${gateName}::satisfied`);
79
+ } else {
80
+ lines.push(`::warning title=Gate ${gateName}::${gateStatus}`);
68
81
  }
69
82
  }
70
83
 
@@ -113,7 +126,6 @@ export function writeGitHubOutputVars(report, outputPath) {
113
126
  */
114
127
  export function formatJUnitXml(report) {
115
128
  const run = report.subject?.run || {};
116
- const gates = run.gate_summary || {};
117
129
  const turns = run.turns || [];
118
130
 
119
131
  // Escape XML special characters
@@ -124,7 +136,7 @@ export function formatJUnitXml(report) {
124
136
  .replace(/"/g, '&quot;');
125
137
 
126
138
  // Build gate test cases
127
- const gateEntries = Object.entries(gates).filter(([, v]) => typeof v === 'string');
139
+ const gateEntries = normalizeGateEntries(run.gate_summary);
128
140
  const gateFailures = gateEntries.filter(([, status]) => {
129
141
  const s = status.toLowerCase();
130
142
  return s !== 'satisfied' && s !== 'pass' && s !== 'passed';
@@ -1141,10 +1141,27 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
1141
1141
  }
1142
1142
 
1143
1143
  const errorClass = result.error_class || 'reconcile_refused';
1144
+ // RB-14: `reconcile-state --accept-operator-head` runs the SAME safety check, so it
1145
+ // cannot clear an unsafe-edit refusal — recommending it for governance_state_modified
1146
+ // or critical_artifact_deleted is a dead end. Give the recovery that actually applies.
1147
+ const offendingCommit = result.offending_commit || result.commit || null;
1148
+ const offendingPath = result.offending_path || result.path || null;
1149
+ let recoveryAction;
1150
+ let recoveryDetailTail;
1151
+ if (errorClass === 'governance_state_modified') {
1152
+ recoveryAction = offendingCommit ? `git revert ${offendingCommit.slice(0, 12)}` : 'git revert <offending-commit>';
1153
+ recoveryDetailTail = `${offendingPath || 'A commit'} edits governed state under .agentxchain/ directly, which the anti-tamper guard never auto-accepts (reconcile-state --accept-operator-head enforces the same check). Revert it (${recoveryAction}) to restore the governed lineage, or restart from an explicit prior checkpoint.`;
1154
+ } else if (errorClass === 'critical_artifact_deleted') {
1155
+ recoveryAction = (offendingCommit && offendingPath) ? `git checkout ${offendingCommit.slice(0, 12)}^ -- ${offendingPath}` : 'git checkout <commit>^ -- <deleted-path>';
1156
+ recoveryDetailTail = `Critical governed evidence ${offendingPath || ''} was deleted; restore it (${recoveryAction}), then resume.`;
1157
+ } else {
1158
+ recoveryAction = 'agentxchain reconcile-state --accept-operator-head';
1159
+ recoveryDetailTail = `Run: ${recoveryAction} once the unsafe changes are resolved, or revert them.`;
1160
+ }
1144
1161
  const detailLines = [
1145
1162
  `Operator-commit auto-reconcile refused (${errorClass}).`,
1146
1163
  result.error || 'Unsafe operator commits detected; manual recovery required.',
1147
- 'Run: agentxchain reconcile-state --accept-operator-head once the unsafe changes are resolved, or revert them.',
1164
+ recoveryDetailTail,
1148
1165
  ];
1149
1166
  const detail = detailLines.join(' ');
1150
1167
 
@@ -1164,7 +1181,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
1164
1181
  ...((state.blocked_reason || {}).recovery || {}),
1165
1182
  typed_reason: 'operator_commit_reconcile_refused',
1166
1183
  owner: 'human',
1167
- recovery_action: 'agentxchain reconcile-state --accept-operator-head',
1184
+ recovery_action: recoveryAction,
1168
1185
  turn_retained: false,
1169
1186
  detail,
1170
1187
  },
@@ -1196,7 +1213,7 @@ export function maybeAutoReconcileOperatorCommits(context, session, contOpts, lo
1196
1213
  status: 'blocked',
1197
1214
  action: 'operator_commit_reconcile_refused',
1198
1215
  run_id: session.current_run_id,
1199
- recovery_action: 'agentxchain reconcile-state --accept-operator-head',
1216
+ recovery_action: recoveryAction,
1200
1217
  blocked_category: 'operator_commit_reconcile_refused',
1201
1218
  error_class: errorClass,
1202
1219
  };
@@ -2579,10 +2596,47 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
2579
2596
  process.on('SIGINT', sigHandler);
2580
2597
 
2581
2598
  try {
2599
+ // RB-13: circuit-breaker for the recover-and-continue path. A deterministic,
2600
+ // recurring failure (e.g. an unclean baseline) must escalate, not busy-loop
2601
+ // forever — without this, the same start error spun 582 times in seconds.
2602
+ // Track consecutive identical recoveries; halt and escalate after a cap, and
2603
+ // back off between attempts so even allowed retries can't tight-loop.
2604
+ let consecutiveRecoveries = 0;
2605
+ let lastRecoveryReason = null;
2606
+ const maxConsecutiveRecoveries = Math.max(1, contOpts.maxConsecutiveRecoveries ?? 3);
2607
+
2582
2608
  while (!stopping) {
2583
2609
  const step = await advanceContinuousRunOnce(context, session, contOpts, executeGovernedRun, log);
2584
2610
 
2585
2611
  if (step.status === 'failed' && isGovernedRunStillActiveForSession(root, context.config, session)) {
2612
+ const recoveryReason = step.stop_reason || step.action || 'unknown';
2613
+ if (recoveryReason === lastRecoveryReason) {
2614
+ consecutiveRecoveries += 1;
2615
+ } else {
2616
+ consecutiveRecoveries = 1;
2617
+ lastRecoveryReason = recoveryReason;
2618
+ }
2619
+
2620
+ if (consecutiveRecoveries >= maxConsecutiveRecoveries) {
2621
+ // Circuit open: the same recoverable failure keeps recurring without
2622
+ // progress. Escalate to a human instead of spinning the loop.
2623
+ session.status = 'blocked';
2624
+ writeContinuousSession(root, session);
2625
+ emitRunEvent(root, 'continuous_recovery_circuit_open', {
2626
+ run_id: session.current_run_id || null,
2627
+ phase: null,
2628
+ status: 'blocked',
2629
+ payload: {
2630
+ session_id: session.session_id,
2631
+ failed_action: step.action || null,
2632
+ failed_reason: recoveryReason,
2633
+ consecutive_recoveries: consecutiveRecoveries,
2634
+ },
2635
+ });
2636
+ log(`Continuous loop halted (circuit-breaker): the same recoverable failure recurred ${consecutiveRecoveries} times with no progress — escalating to a human instead of spinning. Reason: ${recoveryReason}`);
2637
+ return { exitCode: 1, session };
2638
+ }
2639
+
2586
2640
  session.status = 'running';
2587
2641
  writeContinuousSession(root, session);
2588
2642
  emitRunEvent(root, 'session_failed_recovered_active_run', {
@@ -2593,12 +2647,20 @@ export async function executeContinuousRun(context, contOpts, executeGovernedRun
2593
2647
  session_id: session.session_id,
2594
2648
  failed_action: step.action || null,
2595
2649
  failed_reason: step.stop_reason || null,
2650
+ consecutive_recoveries: consecutiveRecoveries,
2596
2651
  },
2597
2652
  });
2598
- log('Session failure recovered - governed run still active, continuing.');
2653
+ log(`Session failure recovered - governed run still active, continuing (recovery ${consecutiveRecoveries}/${maxConsecutiveRecoveries}).`);
2654
+ // Backoff before retrying so a recurring failure cannot busy-loop.
2655
+ const recoveryBackoffMs = Math.max(1000, (contOpts.cooldownSeconds ?? 5) * 1000);
2656
+ await new Promise((resolve) => setTimeout(resolve, recoveryBackoffMs));
2599
2657
  continue;
2600
2658
  }
2601
2659
 
2660
+ // A non-recovery step means the loop made forward progress — reset the breaker.
2661
+ consecutiveRecoveries = 0;
2662
+ lastRecoveryReason = null;
2663
+
2602
2664
  // Terminal states
2603
2665
  if (step.status === 'completed' || step.status === 'idle_exit' || step.status === 'failed' || step.status === 'blocked' || step.status === 'stopped' || step.status === 'vision_exhausted' || step.status === 'vision_expansion_exhausted' || step.status === 'session_budget') {
2604
2666
  const terminalMessage = describeContinuousTerminalStep(step, contOpts);
@@ -471,6 +471,18 @@ function renderPrompt(role, roleId, turn, state, config, root) {
471
471
  lines.push('');
472
472
  }
473
473
 
474
+ // Single-shot execution model — prevents ghost turns from async / background-wait patterns.
475
+ lines.push('## Single-Shot Execution (read carefully)');
476
+ lines.push('');
477
+ lines.push('You run as a **single, non-interactive `--print` subprocess**. You get exactly ONE invocation with no "later": there is no async wake-up, no second message, and nothing will notify you after you stop responding. The moment you end your turn the subprocess exits — if you have not written your turn result by then, the entire turn is **discarded as a ghost** (no result, wasted run).');
478
+ lines.push('');
479
+ lines.push('Therefore:');
480
+ lines.push('- Do ALL work **synchronously** within this one turn. Run every command to completion in the foreground and read its output inline before continuing.');
481
+ lines.push('- **NEVER** start a background job and then wait for it. Do not use `ScheduleWakeup`; do not start a `Monitor` or `tail -f` and wait for events; do not background a process with `&` and poll for a notification; do not defer any conclusion to "after the suite/build finishes". You will not be re-invoked to finish.');
482
+ lines.push('- If you need a long command or test suite result, run it **blocking in the foreground** (e.g. `npm test`) and wait for it to return in-line — the per-turn timeout is generous, so budget your time for it.');
483
+ lines.push('- Writing the structured turn result below is your **final action**: gather every piece of evidence first, then write the result before you finish.');
484
+ lines.push('');
485
+
474
486
  // Output format with complete JSON template
475
487
  lines.push('## Required Output');
476
488
  lines.push('');