Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-23
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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).
113 changes: 102 additions & 11 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const {
printToolLogsSummary,
usage,
formatElapsedDuration,
startTransientSpinner,
compactAutoFinishPathSegments,
detectRecoverableAutoFinishConflict,
printAutoFinishSummary,
Expand Down Expand Up @@ -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 '';
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -3322,17 +3413,17 @@ 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;
}
}

if (require.main === module) {
runFromBin();
void runFromBin();
}

module.exports = {
Expand Down
54 changes: 54 additions & 0 deletions src/output/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -456,6 +509,7 @@ module.exports = {
printToolLogsSummary,
usage,
formatElapsedDuration,
startTransientSpinner,
truncateMiddle,
truncateTail,
compactAutoFinishPathSegments,
Expand Down
Loading
Loading