Skip to content
Open
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
18 changes: 18 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -182,6 +182,24 @@ Details worth knowing:
- **Retention** is automatic: files older than 14 days are pruned, and the directory is capped at 50 MB.
- **Tuning:** `CODEV_LOG_LEVEL` (`debug` by default; `silent` disables logging), `CODEV_LOG_DIR` relocates the directory.

## Agent readiness

Run `codev readiness` from a Git repository to choose Claude Code, Codex, or

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — wrong binary name; this command doesn't exist.

package.json declares exactly one bin: codevhub. codev is the CoDev Code shim, so codev readiness hands readiness to CoDev Code as a project path rather than reaching the hub. Every other one of the README's 32 CLI references uses codevhub, and help.ts plus the usage string in index.tsx are both correct.

Affects line 194 as well (codev readiness --model <model-id>).

OpenCode and start a headless, read-only readiness evaluation. CoDev validates
the agent's evidence against its versioned rubric, computes scores locally,
rejects scans that change the working tree, and uploads the validated report.
The report is a local agent evaluation, not a server-verified audit.

Readiness uses each harness's configured default model for Claude Code and
Codex; `codev readiness --model <model-id>` provides a one-off override for
those harnesses. OpenCode readiness always uses
`aigateway/MiniMax/MiniMax-M2.7`. Repair, timeout, and output limits are bounded
product safeguards rather than user configuration. The selected harness must
first be configured through `codevhub install`.

The rubric version is deliberately not configurable: it identifies the actual
report schema and scoring rubric shared by CLI, proxy, and dashboard.

## Development

```bash
Expand Down
315 changes: 315 additions & 0 deletions src/ReadinessApp.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
import { Box, Text, useApp, useInput } from "ink";
import Spinner from "ink-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Banner } from "@/components/Banner.js";
import { Frame } from "@/components/Frame.js";
import { PasteBackPrompt, usePasteBack } from "@/components/PasteBack.js";
import {
ReadinessAgentSelect,
readinessAgentSelectTitle,
} from "@/components/ReadinessAgentSelect.js";
import {
ReadinessProfileSelect,
readinessProfileSelectTitle,
} from "@/components/ReadinessProfileSelect.js";
import { Step } from "@/components/Step.js";
import {
type ReadinessOptions,
type ReadinessRunResult,
runReadiness,
} from "@/lib/readiness.js";
import {
isReadinessAgentAvailable,
READINESS_AGENTS,
type ReadinessAgent,
} from "@/lib/readiness-agent.js";
import {
fetchReadinessProfiles,
type ReadinessProfile,
type ReadinessProfileSession,
selectReadinessProfile,
} from "@/lib/readiness-profile.js";

type Phase =
| "loading-profiles"
| "select-profile"
| "select-agent"
| "running"
| "done"
| "failed";
const EMPTY_READINESS_OPTIONS: ReadinessOptions = {};

interface ReadinessAppProps {
available?: Record<ReadinessAgent, boolean>;
run?: typeof runReadiness;
options?: ReadinessOptions;
profileSelector?: string;
requestedAgent?: ReadinessAgent;
loadProfiles?: typeof fetchReadinessProfiles;
}

export function ReadinessApp({
available,
run = runReadiness,
options = EMPTY_READINESS_OPTIONS,
profileSelector,
requestedAgent,
loadProfiles = fetchReadinessProfiles,
}: ReadinessAppProps) {
const { exit } = useApp();
const detected = useMemo(
() =>
available ??
(Object.fromEntries(
READINESS_AGENTS.map((agent) => [
agent,
isReadinessAgentAvailable(agent),
]),
) as Record<ReadinessAgent, boolean>),
[available],
);
const [phase, setPhase] = useState<Phase>("loading-profiles");
const [agent, setAgent] = useState<ReadinessAgent | null>(null);
const [progress, setProgress] = useState("Loading readiness profiles");
const [loginUrl, setLoginUrl] = useState<string | null>(null);
const [result, setResult] = useState<ReadinessRunResult | null>(null);
const [session, setSession] = useState<ReadinessProfileSession | null>(null);
const [profile, setProfile] = useState<ReadinessProfile | null>(null);
const profileFetchMs = useRef(0);
// Keep stdin referenced across the async SSO-to-selector transition. Without
// continuous input ownership, Ink can emit `beforeExit` after the callback
// server closes and unmount just as the agent selector becomes interactive.
useInput(() => undefined, {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — useInput without useCanType().

Per CLAUDE.md: "Components must ask Ink, not the process… gate on useCanType(), and smoke-test it with piped stdin, not only under vitest."isActive here only tracks phase, so raw mode is requested on mount regardless of whether the terminal can supply keystrokes, and Ink throws from the mount effect.

constcanType=useCanType();useInput(()=>undefined,{isActive: canType&&phase!=="done"&&phase!=="failed",});

No test covers this path either — tests/helpers/raw-mode.tsx#renderWithoutRawMode exists for exactly this and isn't used by any readiness test.

isActive: phase !== "done" && phase !== "failed",
});
const paste = usePasteBack(
loginUrl !== null && phase !== "failed" && phase !== "done",
);
const hasAvailableAgent = READINESS_AGENTS.some(
(candidate) => detected[candidate],
);

const startRun = useCallback(
(
choice: ReadinessAgent,
chosen: ReadinessProfile,
loaded: ReadinessProfileSession,
) => {
if (!detected[choice]) {
setResult({
exitCode: 1,
message: `${choice} is not available on PATH.`,
});
setPhase("failed");
return;
}
setAgent(choice);
setLoginUrl(null);
setPhase("running");
run(choice, setProgress, {
...options,
profile: chosen,
auth: loaded.auth,
profileFetchMs: profileFetchMs.current,
onLoginUrl: setLoginUrl,
onManualSubmit: (submit) => {
paste.submitRef.current = submit;
},
onLoginDone: () => setLoginUrl(null),
})
.then((next) => {
setResult(next);
setPhase(next.exitCode === 0 ? "done" : "failed");
})
.catch((error) => {
setResult({
exitCode: 1,
message: error instanceof Error ? error.message : String(error),
});
setPhase("failed");
});
},
[detected, run, options, paste.submitRef],
);
const chooseProfile = useCallback(
(chosen: ReadinessProfile, loaded = session) => {
if (!loaded) return;
setProfile(chosen);
if (requestedAgent) startRun(requestedAgent, chosen, loaded);
else setPhase("select-agent");
},
[requestedAgent, session, startRun],
);
const selectAgent = useCallback(
(choice: ReadinessAgent) => {
if (profile && session) startRun(choice, profile, session);
},
[profile, session, startRun],
);

useEffect(() => {
let active = true;
const started = Date.now();
loadProfiles(setProgress, {
onLoginUrl: setLoginUrl,
onManualSubmit: (submit) => {
paste.submitRef.current = submit;
},
})
.then((loaded) => {
if (!active) return;
profileFetchMs.current = Date.now() - started;
setLoginUrl(null);
setSession(loaded);
const chosen =
profileSelector || loaded.profiles.length === 1
? selectReadinessProfile(loaded.profiles, profileSelector)
: undefined;
if (chosen) {
setProfile(chosen);
if (requestedAgent) startRun(requestedAgent, chosen, loaded);
else setPhase("select-agent");
} else setPhase("select-profile");
})
.catch((error) => {
if (!active) return;
setResult({
exitCode: 1,
message: error instanceof Error ? error.message : String(error),
});
setPhase("failed");
});
return () => {
active = false;
};
}, [
loadProfiles,
profileSelector,
requestedAgent,
startRun,
paste.submitRef,
]);

useEffect(() => {
if (phase !== "done" && phase !== "failed") return;
const timer = setTimeout(() => {
if (phase === "failed")
exit(new Error(result?.message ?? "Readiness failed."));
else exit();
}, 50);
return () => clearTimeout(timer);
}, [phase, result, exit]);

return (
<Box flexDirection="column">
<Banner />
<Frame tag="AGENT READINESS">
{phase === "loading-profiles" && (
<Box flexDirection="column">
<Box>
<Text color="cyan">
<Spinner />
</Text>
<Text>{` ${progress}...`}</Text>
</Box>
{loginUrl && !paste.submitting && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
{"If the browser didn't open, visit this URL manually:"}
</Text>
<Text>{loginUrl}</Text>
<PasteBackPrompt
pasteValue={paste.pasteValue}
pasteError={paste.pasteError}
submitting={paste.submitting}
/>
</Box>
)}
</Box>
)}
{phase !== "loading-profiles" && !hasAvailableAgent && (
<Text color="red">
No supported coding agent is available. Run `codevhub install`
first.
</Text>
)}
{session && profile === null && phase === "select-profile" && (
<Step active title={readinessProfileSelectTitle(false)}>
<ReadinessProfileSelect
profiles={session.profiles}
onSelect={chooseProfile}
/>
</Step>
)}
{profile && (
<Step active={false} title={readinessProfileSelectTitle(true)}>
<ReadinessProfileSelect
profiles={[profile]}
selected={profile}
readOnly
onSelect={() => {}}
/>
</Step>
)}
{profile &&
phase !== "loading-profiles" &&
phase !== "select-profile" && (
<Step
active={phase === "select-agent"}
title={readinessAgentSelectTitle(phase !== "select-agent")}
>
<ReadinessAgentSelect
available={detected}
selected={agent}
readOnly={phase !== "select-agent" || !hasAvailableAgent}
onSelect={selectAgent}
/>
</Step>
)}
{["running", "done", "failed"].includes(phase) && profile && (
<Step
active={phase === "running"}
title={<Text bold>Evaluate repository</Text>}
>
{phase === "running" ? (
<Box flexDirection="column">
<Box>
<Text color="cyan">
<Spinner />
</Text>
<Text>{` ${progress}...`}</Text>
</Box>
{loginUrl && !paste.submitting && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
{"If the browser didn't open, visit this URL manually:"}
</Text>
<Text>{loginUrl}</Text>
<PasteBackPrompt
pasteValue={paste.pasteValue}
pasteError={paste.pasteError}
submitting={paste.submitting}
/>
</Box>
)}
</Box>
) : (

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — the failure message renders twice.

When phase === "failed" and a profile has been chosen, this Step renders ✗ {result?.message}, and the block at lines 304-310 renders ✗ {result?.message ?? "Readiness failed."} again. The user sees the same error twice, back to back.

<Text color={phase === "done" ? "green" : "red"}>
{phase === "done" ? "✓ " : "✗ "}
{result?.message}
</Text>
)}
</Step>
)}
{phase === "failed" && (
<Box flexDirection="column">
<Text color="red">✗ {result?.message ?? "Readiness failed."}</Text>
<Text dimColor>
Fix the issue above and rerun `codevhub readiness`.
</Text>
</Box>
)}
</Frame>
</Box>
);
}
Loading
Loading