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
18 changes: 10 additions & 8 deletions docs/loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,31 @@ Progress considers merge risk, decay risk, security risk, weak-test findings, pr

`codedecay loop` never prints an unqualified "safe" verdict. Clean outcomes are always qualified by evidence depth.

The loop can only report a `merge-safe-*` verdict when all of these are true:
The loop can only report a `verified` or `shallow-proof` verdict when all of these are true:

- final risk is at or below the configured safe threshold, `low` by default
- weak-test findings are zero
- security score is at or below the configured threshold, `0` by default
- no high-severity findings remain in deterministic analysis
- configured checks exist and pass

If no checks are configured, the best possible terminal status is `unverified`, not a `merge-safe-*` verdict.
If no checks are configured, the best possible terminal status is `unverified`, not a verified verdict.

`merge-safe-verified` means configured checks passed, deterministic security matchers were clean, Semgrep was enabled and clean, and coverage/mutation evidence was available if configured.
`verified` means configured checks passed, deterministic security matchers were clean, Semgrep was enabled and clean, and coverage/mutation evidence was available if configured. Legacy alias: `merge-safe-verified`.

`merge-safe-shallow` means the gates passed, but one or more deeper evidence streams were missing. Treat it as heuristic clean, not as deep verification. Run `codedecay doctor` to configure OSS adapters such as Semgrep, coverage, and StrykerJS.
`shallow-proof` means the gates passed, but one or more deeper evidence streams were missing. Treat it as heuristic clean, not as deep verification. Run `codedecay doctor` to configure OSS adapters such as Semgrep, coverage, and StrykerJS. Legacy alias: `merge-safe-shallow`.

Terminal statuses:

- `merge-safe-verified`: configured and enabled checks found nothing at the selected thresholds, including available security/coverage/mutation depth
- `merge-safe-shallow`: risk, weak-test, security-score, and configured-check gates passed, but depth evidence such as Semgrep, coverage, or mutation testing is missing
- `verified`: configured and enabled checks found nothing at the selected thresholds, including available security/coverage/mutation depth
- `shallow-proof`: risk, weak-test, security-score, and configured-check gates passed, but depth evidence such as Semgrep, coverage, or mutation testing is missing
- `unverified`: risk and weak-test evidence are clean, but no configured checks proved the result
- `plan-only`: no agent command was configured
- `stuck`: the agent made no progress for two rounds
- `stuck`: the agent made no progress for two rounds, oscillated, or widened scope unsafely
- `budget-exhausted`: round, wall-time, model-call, or changed-file budget exhausted
- `unsafe-change`: verifier edited files, or protected/out-of-scope paths changed
- `needs-human`: max rounds were reached
- `agent-error`: the agent command failed, timed out, was skipped, or was blocked by safety policy
- `builder-error` / `verifier-error` / `agent-error`: role command failed, timed out, was skipped, or was blocked by safety policy

## Example

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export async function runLoopCommand(
safeRiskLevel: options.safeRiskLevel,
securityScoreThreshold: options.securityScoreThreshold,
agentTimeoutMs: loadedConfig.config.safety.commandTimeoutMs,
maxWallTimeMs: options.maxWallTimeMs,
maxChangedFiles: options.maxChangedFiles,
maxModelCalls: options.maxModelCalls,
allowedPathPrefixes: options.allowedPaths,
protectedPathPrefixes: options.protectedPaths,
resumeFromAuditPath: options.resumeFrom,
runId: options.runId,
commandSafety: {
allowCommands: loadedConfig.config.safety.allowCommands,
capabilityPolicy: loadedConfig.config.safety.capabilityPolicy
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/docs/command-docs/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,13 @@ export const ORCHESTRATION_COMMAND_DOCS: Record<string, CommandDoc> = {
{ flag: "--verifier-id <id>", description: "Identity label recorded for the verifier role" },
{ flag: "--safe-risk <level>", description: "Maximum acceptable risk level: low, medium, or high (default: low)" },
{ flag: "--max-security-score <score>", description: "Maximum acceptable security score from deterministic analysis, 0-100 (default: 0)" },
{ flag: "--max-wall-time-ms <ms>", description: "Stop with budget-exhausted when wall time exceeds this limit" },
{ flag: "--max-changed-files <n>", description: "Stop with budget-exhausted when changed files exceed this limit" },
{ flag: "--max-model-calls <n>", description: "Stop with budget-exhausted after this many builder/verifier invocations" },
{ flag: "--allowed-path <prefix>", description: "Restrict builder edits to this path prefix (repeatable)" },
{ flag: "--protected-path <prefix>", description: "Treat edits under this path prefix as unsafe-change (repeatable)" },
{ flag: "--resume-from <path>", description: "Resume budget counters from a prior loop audit JSONL file" },
{ flag: "--run-id <id>", description: "Stable run id used for .codedecay/local/loop-audit/<id>.jsonl" },
{ flag: "--task <text>", description: "Task description used with a structured requirements artifact" },
{ flag: "--requirements <path>", description: "Preserve acceptance-criteria IDs and trace status across loop rounds" },
{ flag: "--format <format>", description: "json or markdown (default: markdown)" },
Expand All @@ -272,8 +279,8 @@ export const ORCHESTRATION_COMMAND_DOCS: Record<string, CommandDoc> = {
"Verifier output can propose hypotheses and proof tasks, but only trusted deterministic, OSS-tool, or runtime evidence can verify criteria.",
"The loop never auto-commits or auto-pushes. It leaves edits in the working tree for human review.",
"Agent output is untrusted. CodeDecay re-runs deterministic analysis and configured checks after each agent action.",
"Terminal clean verdicts are always qualified: merge-safe-verified has configured checks plus security/coverage/mutation depth, while merge-safe-shallow passed gates but is missing deeper evidence.",
"Exit codes: 0 for merge-safe-verified, merge-safe-shallow, or plan-only report generation; 1 for unverified, needs-human, budget-exhausted, unsafe-change, stuck, builder-error, verifier-error, or agent-error; and 2 for CLI/internal errors."
"Terminal clean verdicts are always qualified: verified has configured checks plus security/coverage/mutation depth, while shallow-proof passed gates but is missing deeper evidence.",
"Exit codes: 0 for verified, shallow-proof, or plan-only report generation; 1 for unverified, needs-human, budget-exhausted, unsafe-change, stuck, builder-error, verifier-error, or agent-error; and 2 for CLI/internal errors."
]
},
doctor: {
Expand Down
78 changes: 78 additions & 0 deletions packages/cli/src/parsers/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,76 @@ export function parseLoopArgs(args: string[]): LoopOptions {
continue;
}

if (arg.startsWith("--max-wall-time-ms=")) {
options.maxWallTimeMs = parsePositiveInt(arg.slice("--max-wall-time-ms=".length), "--max-wall-time-ms");
continue;
}
if (arg === "--max-wall-time-ms") {
options.maxWallTimeMs = parsePositiveInt(requireValue(args, index, arg), "--max-wall-time-ms");
index += 1;
continue;
}

if (arg.startsWith("--max-changed-files=")) {
options.maxChangedFiles = parsePositiveInt(arg.slice("--max-changed-files=".length), "--max-changed-files");
continue;
}
if (arg === "--max-changed-files") {
options.maxChangedFiles = parsePositiveInt(requireValue(args, index, arg), "--max-changed-files");
index += 1;
continue;
}

if (arg.startsWith("--max-model-calls=")) {
options.maxModelCalls = parsePositiveInt(arg.slice("--max-model-calls=".length), "--max-model-calls");
continue;
}
if (arg === "--max-model-calls") {
options.maxModelCalls = parsePositiveInt(requireValue(args, index, arg), "--max-model-calls");
index += 1;
continue;
}

if (arg.startsWith("--allowed-path=")) {
options.allowedPaths = [...(options.allowedPaths ?? []), arg.slice("--allowed-path=".length)];
continue;
}
if (arg === "--allowed-path") {
options.allowedPaths = [...(options.allowedPaths ?? []), requireValue(args, index, arg)];
index += 1;
continue;
}

if (arg.startsWith("--protected-path=")) {
options.protectedPaths = [...(options.protectedPaths ?? []), arg.slice("--protected-path=".length)];
continue;
}
if (arg === "--protected-path") {
options.protectedPaths = [...(options.protectedPaths ?? []), requireValue(args, index, arg)];
index += 1;
continue;
}

if (arg.startsWith("--resume-from=")) {
options.resumeFrom = arg.slice("--resume-from=".length);
continue;
}
if (arg === "--resume-from") {
options.resumeFrom = requireValue(args, index, arg);
index += 1;
continue;
}

if (arg.startsWith("--run-id=")) {
options.runId = arg.slice("--run-id=".length);
continue;
}
if (arg === "--run-id") {
options.runId = requireValue(args, index, arg);
index += 1;
continue;
}

throwUnknownOption(arg, "loop");
}

Expand Down Expand Up @@ -217,3 +287,11 @@ function parseSecurityScoreThreshold(value: string): number {

throw new Error(`Invalid --max-security-score "${value}". Expected a number from 0 to 100.`);
}

function parsePositiveInt(value: string, flag: string): number {
const parsed = Number(value);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
}
throw new Error(`Invalid ${flag} "${value}". Expected a positive integer.`);
}
7 changes: 7 additions & 0 deletions packages/cli/src/types/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,11 @@ export interface LoopOptions {
securityScoreThreshold: number;
task?: string | undefined;
requirements?: string | undefined;
maxWallTimeMs?: number | undefined;
maxChangedFiles?: number | undefined;
maxModelCalls?: number | undefined;
allowedPaths?: string[] | undefined;
protectedPaths?: string[] | undefined;
resumeFrom?: string | undefined;
runId?: string | undefined;
}
6 changes: 3 additions & 3 deletions packages/cli/test/loop-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("codedecay loop real edit convergence", () => {
const report = JSON.parse(result.stdout) as LoopReport;

expect(result.exitCode).toBe(0);
expect(report.status.startsWith("merge-safe-")).toBe(true);
expect(["verified", "shallow-proof"]).toContain(report.status);
expect(report.rounds).toHaveLength(1);
expect(report.rounds[0]?.agent?.madeChanges).toBe(true);
expect(report.rounds[0]?.postAgentVerification).toMatchObject({
Expand All @@ -42,7 +42,7 @@ describe("codedecay loop real edit convergence", () => {
expect(readFileSync(join(repo, ".git/codedecay-agent-runs"), "utf8")).toBe("x");
});

it("drives a deterministic agent script from weak test to merge-safe-*", async () => {
it("drives a deterministic agent script from weak test to verified/shallow-proof", async () => {
const repo = createLoopConvergenceRepo();

const result = await run([
Expand All @@ -62,7 +62,7 @@ describe("codedecay loop real edit convergence", () => {

expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
expect(report.status.startsWith("merge-safe-")).toBe(true);
expect(["verified", "shallow-proof"]).toContain(report.status);
expect(report.rounds.length).toBeGreaterThanOrEqual(2);
expect(report.rounds[0]?.weakTestFindings).toBeGreaterThan(report.rounds.at(-1)?.weakTestFindings ?? 0);
expect(report.rounds[0]?.mergeRiskScore).toBeGreaterThan(report.rounds.at(-1)?.mergeRiskScore ?? 0);
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/test/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import {
} from "./helpers";

describe("codedecay loop CLI contract", () => {
it("reports merge-safe-shallow with low risk and passing configured checks when depth evidence is missing", async () => {
it("reports shallow-proof with low risk and passing configured checks when depth evidence is missing", async () => {
const repo = createLowRiskRepoWithPassingCheck();

const result = await run(["loop", "--format", "json"], repo);
const report = JSON.parse(result.stdout);

expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(report.status).toBe("merge-safe-shallow");
expect(report.status).toBe("shallow-proof");
expect(report.roundsRun).toBe(1);
expect(report.finalCheckStatus).toBe("passed");
expect(report.verdict.missingDepth).toEqual(
Expand All @@ -29,15 +29,15 @@ describe("codedecay loop CLI contract", () => {
expect(report.safety.commandsExecuted).toBe(true);
});

it("carries Semgrep, coverage, and mutation evidence into a merge-safe-verified verdict", async () => {
it("carries Semgrep, coverage, and mutation evidence into a verified verdict", async () => {
const repo = createLowRiskRepoWithVerifiedChecks();

const result = await run(["loop", "--format", "json"], repo);
const report = JSON.parse(result.stdout);

expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(report.status).toBe("merge-safe-verified");
expect(report.status).toBe("verified");
expect(report.rounds[0].checkStatus).toBe("passed");
expect(report.verdict.verifiedBy).toEqual(
expect.arrayContaining(["Semgrep (0 findings)", "coverage evidence (100%)", "mutation evidence (100%)"])
Expand Down Expand Up @@ -161,7 +161,7 @@ describe("codedecay loop CLI contract", () => {

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("## CodeDecay Loop Report");
expect(result.stdout).toContain("**Status:** merge safe shallow");
expect(result.stdout).toContain("**Status:** shallow proof");
expect(result.stdout).toContain("### Verdict Evidence");
expect(result.stdout).toContain("### Roles");
expect(result.stdout).toContain("### Loop State");
Expand Down
139 changes: 139 additions & 0 deletions packages/harness/src/loop/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { LoopReport, LoopRoundSnapshot, LoopStatus } from "./types";

export interface LoopAuditRoundRecord {
schemaVersion: 1;
runId: string;
round: number;
timestamp: string;
statusHint?: LoopStatus | undefined;
agentIdentity?: string | undefined;
verifierIdentity?: string | undefined;
evidenceIds: string[];
commands: string[];
changedPaths: string[];
decisions: Array<{ phase: string; actor: string; summary: string }>;
budgets: {
modelCalls: number;
wallTimeMs: number;
fingerprintCount: number;
};
stopReason?: string | undefined;
}

export interface LoopAuditResumeState {
runId: string;
completedRounds: number;
lastFingerprint?: string | undefined;
stopReason?: string | undefined;
modelCalls: number;
records: LoopAuditRoundRecord[];
}

export function defaultLoopAuditPath(cwd: string, runId: string): string {
return join(cwd, ".codedecay", "local", "loop-audit", `${runId}.jsonl`);
}

export function appendLoopAuditRecord(path: string, record: LoopAuditRoundRecord): void {
mkdirSync(dirname(path), { recursive: true });
appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
}

export function writeLoopAuditSummary(path: string, report: LoopReport): void {
const summaryPath = path.endsWith(".jsonl") ? `${path.slice(0, -6)}.summary.json` : `${path}.summary.json`;
mkdirSync(dirname(summaryPath), { recursive: true });
writeFileSync(
summaryPath,
`${JSON.stringify(
{
schemaVersion: 1,
status: report.status,
roundsRun: report.roundsRun,
stopReason: report.stateMachine.decisions.at(-1)?.summary,
generatedAt: report.generatedAt,
verdict: report.verdict.status,
roles: report.roles
},
null,
2
)}\n`,
"utf8"
);
}

export function loadLoopAuditResumeState(path: string): LoopAuditResumeState | undefined {
if (!existsSync(path)) {
return undefined;
}
const lines = readFileSync(path, "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const records: LoopAuditRoundRecord[] = [];
for (const line of lines) {
try {
records.push(JSON.parse(line) as LoopAuditRoundRecord);
} catch {
// Ignore corrupt trailing lines; resume must stay deterministic and safe.
}
}
if (records.length === 0) {
return undefined;
}
const last = records[records.length - 1]!;
return {
runId: last.runId,
completedRounds: records.length,
lastFingerprint: undefined,
stopReason: last.stopReason,
modelCalls: last.budgets.modelCalls,
records
};
}

export function createAuditRecordFromRound(input: {
runId: string;
round: LoopRoundSnapshot;
timestamp: string;
modelCalls: number;
wallTimeMs: number;
fingerprintCount: number;
stopReason?: string | undefined;
statusHint?: LoopStatus | undefined;
}): LoopAuditRoundRecord {
const commands = [
input.round.builder?.command,
input.round.verifier?.command,
input.round.agent?.command
].filter((value): value is string => Boolean(value));
const changedPaths = [
...(input.round.builder?.changedFiles ?? []),
...(input.round.verifier?.changedFiles ?? []),
...(input.round.agent?.changedFiles ?? [])
];
const evidenceIds = input.round.stateMachine?.decisions.flatMap((decision) => decision.evidenceIds) ?? [];
return {
schemaVersion: 1,
runId: input.runId,
round: input.round.round,
timestamp: input.timestamp,
statusHint: input.statusHint,
agentIdentity: input.round.builder?.identity ?? input.round.agent?.identity,
verifierIdentity: input.round.verifier?.identity,
evidenceIds,
commands: [...new Set(commands)],
changedPaths: [...new Set(changedPaths)],
decisions: (input.round.stateMachine?.decisions ?? []).map((decision) => ({
phase: decision.phase,
actor: decision.actor,
summary: decision.summary
})),
budgets: {
modelCalls: input.modelCalls,
wallTimeMs: input.wallTimeMs,
fingerprintCount: input.fingerprintCount
},
stopReason: input.stopReason
};
}
Loading
Loading