From d06e6179589821d1db29f59dc9c607ff9c03dbc8 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 21:03:35 +0200 Subject: [PATCH] Reduce launch tax when bare gx finds drift The default entrypoint now keeps the fast status overview, but can hand off straight into doctor when auto-repair is enabled for the session. Degraded status copy now points operators at 'gx doctor', and the auto-doctor handoff shows a transient spinner so the CLI does not look frozen while doctor starts. Constraint: Bare 'gx' must stay non-mutating in non-interactive status checks Rejected: Always auto-run doctor on every degraded bare 'gx' invocation | too surprising for scripts and CI Confidence: high Scope-risk: moderate Directive: Keep bare 'gx' auto-repair gated; do not make default status mutating in non-interactive mode without new operator intent Tested: node --test test/status.test.js Tested: node --check bin/multiagent-safety.js Tested: openspec validate agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23 --type change --strict Tested: openspec validate --specs Not-tested: Interactive TTY rendering of the transient spinner in a manual shell --- .../.openspec.yaml | 2 + .../proposal.md | 16 +++ .../specs/doctor-workflow/spec.md | 28 +++++ .../tasks.md | 37 ++++++ src/cli/main.js | 113 ++++++++++++++++-- src/output/index.js | 54 +++++++++ test/status.test.js | 53 +++++++- 7 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/.openspec.yaml create mode 100644 openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/proposal.md create mode 100644 openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/specs/doctor-workflow/spec.md create mode 100644 openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/tasks.md diff --git a/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/.openspec.yaml b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/.openspec.yaml new file mode 100644 index 00000000..8b394c66 --- /dev/null +++ b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-23 diff --git a/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/proposal.md b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/proposal.md new file mode 100644 index 00000000..0b98cdd8 --- /dev/null +++ b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/proposal.md @@ -0,0 +1,16 @@ +## Why + +- Running bare `gx` currently stops at a static degraded status summary, so humans still need a second command (`gx doctor`) to reach the actual repair path. +- That handoff feels stalled and unfriendly in the exact moment when the repo needs help, especially after the CLI already detected the drift. + +## What Changes + +- Let bare `gx` auto-run `gx doctor` when the repo is degraded and the shell explicitly allows interactive auto-repair. +- Keep non-interactive/default status mode safe and non-mutating, but make the degraded summary point humans at `gx doctor` instead of only `scan`. +- Add a lightweight transient prep spinner so the auto-doctor handoff looks active instead of frozen. + +## Impact + +- Affects only the no-argument `gx` entrypoint plus degraded-status copy; explicit `gx status` and `gx doctor` flows keep their current contracts. +- Main risk is surprise mutation from bare `gx`, so the auto-repair path stays gated behind interactive shells by default and can be forced or disabled via env for tests/operators. +- Verification needs focused CLI regression coverage because the new behavior crosses status rendering, subprocess handoff, and doctor repair output. diff --git a/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/specs/doctor-workflow/spec.md b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/specs/doctor-workflow/spec.md new file mode 100644 index 00000000..9a61d19b --- /dev/null +++ b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/specs/doctor-workflow/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: bare `gx` can hand off directly into doctor repair +The default no-argument `gx` entrypoint SHALL be able to hand off directly into `gx doctor` when repo safety is degraded and auto-repair is enabled for the current session. + +#### Scenario: degraded bare `gx` auto-runs doctor in auto-repair mode +- **GIVEN** bare `gx` runs against a repo whose safety service is degraded +- **AND** auto-repair is enabled for the current session +- **WHEN** the default status summary finishes rendering +- **THEN** the CLI SHALL print an explicit auto-repair handoff line +- **AND** it SHALL run the same doctor workflow a human would get from `gx doctor` +- **AND** the resulting exit code SHALL match that doctor run + +#### Scenario: status-only degraded bare `gx` stays non-mutating when auto-repair is disabled +- **GIVEN** bare `gx` runs against a degraded repo +- **AND** auto-repair is disabled for the current session +- **WHEN** the default status summary renders +- **THEN** the CLI SHALL remain status-only and SHALL NOT run doctor automatically +- **AND** it SHALL tell the human to run `gx doctor` for repair + +### Requirement: auto-doctor handoff stays visibly active +When bare `gx` auto-starts doctor in human-readable mode, the handoff SHALL stay visibly active instead of appearing frozen. + +#### Scenario: auto-doctor startup shows transient progress before doctor output starts +- **GIVEN** bare `gx` is auto-starting `gx doctor` in a human shell +- **WHEN** the doctor subprocess has not emitted its first output yet +- **THEN** the CLI SHALL show a transient progress indicator for the doctor handoff +- **AND** that indicator SHALL clear once doctor output begins or the subprocess exits diff --git a/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/tasks.md b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/tasks.md new file mode 100644 index 00000000..161d50ea --- /dev/null +++ b/openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/tasks.md @@ -0,0 +1,37 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23`; branch=`agent/codex/takeover-task-30ac51386203-2026-04-23-20-52`; scope=`src/cli/main.js`, `src/output/index.js`, `test/status.test.js`; action=`ship bare-gx auto-doctor with friendlier degraded UX, then verify and finish`. +- Copy prompt: Continue `agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23` on branch `agent/codex/takeover-task-30ac51386203-2026-04-23-20-52`. Work inside the existing sandbox, review `openspec/changes/agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/takeover-task-30ac51386203-2026-04-23-20-52 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23`. +- [x] 1.2 Define normative requirements in `specs/doctor-workflow/spec.md`. + +## 2. Implementation + +- [x] 2.1 Let bare `gx` auto-run `gx doctor` when degraded and auto-repair is enabled for the session. +- [x] 2.2 Make degraded status output point humans at `gx doctor` and add a lightweight doctor handoff spinner. +- [x] 2.3 Add/update focused regression coverage for status-only and auto-doctor default invocation paths. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands (`node --test test/status.test.js`, `node --test test/doctor.test.js` if touched, `node --check bin/multiagent-safety.js` if needed). +- [x] 3.2 Run `openspec validate agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +Verification note: `node --test test/status.test.js` passed with 17/17 tests, including the new non-interactive status-only degraded path and `GUARDEX_AUTO_DOCTOR=yes` bare-`gx` auto-repair path. `node --check bin/multiagent-safety.js` passed. `openspec validate agent-codex-auto-run-gx-doctor-from-default-status-2026-04-23 --type change --strict` passed, and `openspec validate --specs` returned `No items found to validate.` + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/takeover-task-30ac51386203-2026-04-23-20-52 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/src/cli/main.js b/src/cli/main.js index 6c3ef050..e041d8a4 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -148,6 +148,7 @@ const { printToolLogsSummary, usage, formatElapsedDuration, + startTransientSpinner, compactAutoFinishPathSegments, detectRecoverableAutoFinishConflict, printAutoFinishSummary, @@ -889,6 +890,80 @@ function parseBooleanLike(raw) { return null; } +function autoDoctorEnabledForCurrentSession() { + const explicit = parseBooleanLike(process.env.GUARDEX_AUTO_DOCTOR); + if (explicit != null) { + return explicit; + } + return isInteractiveTerminal(); +} + +function shouldAutoRunDoctorFromStatus(statusPayload) { + const repo = statusPayload?.repo || {}; + return Boolean( + autoDoctorEnabledForCurrentSession() + && repo.inGitRepo + && repo.guardexEnabled !== false + && repo.serviceStatus === 'degraded' + && repo.scan + && Number(repo.scan.findings || 0) > 0, + ); +} + +function runCliSubprocessWithSpinner(args, options = {}) { + return new Promise((resolve, reject) => { + const spinner = options.spinnerMessage + ? startTransientSpinner(options.spinnerMessage, { + prefix: options.spinnerPrefix || `[${TOOL_NAME}]`, + }) + : { stop() {} }; + const child = cp.spawn(process.execPath, [path.resolve(__filename), ...args], { + cwd: options.cwd || process.cwd(), + env: { + ...process.env, + GUARDEX_AUTO_DOCTOR: '0', + }, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + const stopSpinner = () => spinner.stop(); + child.stdout.on('data', (chunk) => { + stopSpinner(); + process.stdout.write(chunk); + }); + child.stderr.on('data', (chunk) => { + stopSpinner(); + process.stderr.write(chunk); + }); + child.on('error', (error) => { + stopSpinner(); + reject(error); + }); + child.on('close', (code) => { + stopSpinner(); + resolve(typeof code === 'number' ? code : 1); + }); + }); +} + +async function maybeAutoRunDoctorFromDefaultStatus(statusPayload) { + if (!shouldAutoRunDoctorFromStatus(statusPayload)) { + return false; + } + + const target = statusPayload?.repo?.target || process.cwd(); + console.log(`[${TOOL_NAME}] Auto-repair: repo safety is degraded. Running '${SHORT_TOOL_NAME} doctor' now.`); + process.exitCode = await runCliSubprocessWithSpinner( + ['doctor', '--target', target], + { + cwd: target, + spinnerPrefix: `[${TOOL_NAME}] Auto-repair:`, + spinnerMessage: 'preparing doctor workspace', + }, + ); + return true; +} + function parseDotenvAssignmentValue(raw) { let value = String(raw || '').trim(); if (!value) return ''; @@ -1672,6 +1747,22 @@ function setExitCodeFromScan(scan) { process.exitCode = 0; } +function printStatusRepairHint(scanResult) { + if (!scanResult || scanResult.guardexEnabled === false) { + return; + } + if (scanResult.errors === 0 && scanResult.warnings === 0) { + return; + } + + const scanHint = scanResult.errors === 0 + ? `review warning details with '${SHORT_TOOL_NAME} scan'` + : `inspect detailed findings with '${SHORT_TOOL_NAME} scan'`; + console.log( + `[${TOOL_NAME}] Quick fix: run '${SHORT_TOOL_NAME} doctor' to repair drift, or ${scanHint}.`, + ); +} + function status(rawArgs) { const options = parseCommonArgs(rawArgs, { target: process.cwd(), @@ -1752,7 +1843,7 @@ function status(rawArgs) { if (options.json) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); process.exitCode = 0; - return; + return payload; } console.log(`[${TOOL_NAME}] CLI: ${payload.cli.runtime}`); @@ -1800,7 +1891,7 @@ function status(rawArgs) { `[${TOOL_NAME}] Repo safety service: ${statusDot('inactive')} inactive (no git repository at target).`, ); process.exitCode = 0; - return; + return payload; } if (scanResult.guardexEnabled === false) { @@ -1811,7 +1902,7 @@ function status(rawArgs) { console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`); printToolLogsSummary(); process.exitCode = 0; - return; + return payload; } if (scanResult.errors === 0 && scanResult.warnings === 0) { @@ -1820,23 +1911,22 @@ function status(rawArgs) { console.log( `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.warnings} warning(s)).`, ); - console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' to review warning details.`); } else if (scanResult.warnings === 0) { console.log( `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s)).`, ); - console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`); } else { console.log( `[${TOOL_NAME}] Repo safety service: ${statusDot('degraded')} degraded (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`, ); - console.log(`[${TOOL_NAME}] Run '${TOOL_NAME} scan' for detailed findings.`); } + printStatusRepairHint(scanResult); console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`); console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`); printToolLogsSummary(); process.exitCode = 0; + return payload; } function install(rawArgs) { @@ -3246,13 +3336,14 @@ function protect(rawArgs) { throw new Error(`Unknown protect subcommand: ${subcommand}`); } -function main() { +async function main() { const args = process.argv.slice(2); if (args.length === 0) { toolchainModule.maybeSelfUpdateBeforeStatus(); toolchainModule.maybeOpenSpecUpdateBeforeStatus(); - status([]); + const statusPayload = status([]); + await maybeAutoRunDoctorFromDefaultStatus(statusPayload); return; } @@ -3322,9 +3413,9 @@ function main() { throw new Error(`Unknown command: ${command}`); } -function runFromBin() { +async function runFromBin() { try { - main(); + await main(); } catch (error) { console.error(`[${TOOL_NAME}] ${error.message}`); process.exitCode = 1; @@ -3332,7 +3423,7 @@ function runFromBin() { } if (require.main === module) { - runFromBin(); + void runFromBin(); } module.exports = { diff --git a/src/output/index.js b/src/output/index.js index 17ce9bf8..c3928981 100644 --- a/src/output/index.js +++ b/src/output/index.js @@ -294,6 +294,59 @@ function formatElapsedDuration(ms) { return `${Math.round(durationMs / 1000)}s`; } +function startTransientSpinner(message, options = {}) { + const stream = options.stream || process.stdout; + if (!stream || !stream.isTTY || typeof stream.write !== 'function') { + return { + stop() {}, + }; + } + + const frames = supportsAnsiColors() + ? ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + : ['-', '\\', '|', '/']; + const intervalMs = Number.isFinite(options.intervalMs) ? Math.max(60, options.intervalMs) : 80; + const prefix = String(options.prefix || `[${TOOL_NAME}]`).trim(); + const text = String(message || '').trim(); + let frameIndex = 0; + let stopped = false; + + const render = () => { + const frame = frames[frameIndex % frames.length]; + frameIndex += 1; + const indicator = supportsAnsiColors() ? colorize(frame, '36') : frame; + stream.write(`\r${prefix} ${indicator} ${text}`); + }; + + const clear = () => { + stream.write('\r'); + if (typeof stream.clearLine === 'function') { + stream.clearLine(0); + } + if (typeof stream.cursorTo === 'function') { + stream.cursorTo(0); + } + }; + + render(); + const timer = setInterval(render, intervalMs); + if (typeof timer.unref === 'function') { + timer.unref(); + } + + return { + stop(finalLine = '') { + if (stopped) return; + stopped = true; + clearInterval(timer); + clear(); + if (finalLine) { + stream.write(`${finalLine}\n`); + } + }, + }; +} + function truncateMiddle(value, maxLength) { const text = String(value || ''); const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0; @@ -456,6 +509,7 @@ module.exports = { printToolLogsSummary, usage, formatElapsedDuration, + startTransientSpinner, truncateMiddle, truncateTail, compactAutoFinishPathSegments, diff --git a/test/status.test.js b/test/status.test.js index 47ad270a..fcb9ba7b 100644 --- a/test/status.test.js +++ b/test/status.test.js @@ -116,7 +116,7 @@ exit 1 }); -test('warning-only degraded status avoids zero-error wording and improves scan hint', () => { +test('warning-only degraded status avoids zero-error wording and points humans at doctor', () => { const repoDir = initRepo(); let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); @@ -129,7 +129,56 @@ test('warning-only degraded status avoids zero-error wording and improves scan h assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Repo safety service: .*degraded \(\d+ warning\(s\)\)\./); assert.doesNotMatch(result.stdout, /0 error\(s\),/); - assert.match(result.stdout, /Run 'gitguardex scan' to review warning details\./); + assert.match( + result.stdout, + /Quick fix: run 'gx doctor' to repair drift, or review warning details with 'gx scan'\./, + ); +}); + + +test('default invocation on degraded repo stays status-only in non-interactive mode', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['config', 'core.hooksPath', '.bad-hooks'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode([], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match( + result.stdout, + /Quick fix: run 'gx doctor' to repair drift, or review warning details with 'gx scan'\./, + ); + assert.doesNotMatch(result.stdout, /Auto-repair: repo safety is degraded/); + + const hooksPath = runCmd('git', ['config', 'core.hooksPath'], repoDir); + assert.equal(hooksPath.status, 0, hooksPath.stderr || hooksPath.stdout); + assert.equal(hooksPath.stdout.trim(), '.bad-hooks'); +}); + + +test('default invocation can auto-run doctor when GUARDEX_AUTO_DOCTOR=yes', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['config', 'core.hooksPath', '.bad-hooks'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNodeWithEnv([], repoDir, { + GUARDEX_AUTO_DOCTOR: 'yes', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Auto-repair: repo safety is degraded\. Running 'gx doctor' now\./); + assert.match(result.stdout, /Doctor\/fix:/); + assert.match(result.stdout, /Repo is fully safe\./); + + const hooksPath = runCmd('git', ['config', 'core.hooksPath'], repoDir); + assert.equal(hooksPath.status, 0, hooksPath.stderr || hooksPath.stdout); + assert.equal(hooksPath.stdout.trim(), '.githooks'); });