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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,12 @@ enforced rather than hoped for:
- **It never touches your working tree.** Local builds use a throwaway git
worktree; hosted builds a shallow clone in a scratch directory. The branch
survives; the workspace does not.
- **It reviews its own diff before asking you to.** Between verify and the PR,
a read-only pass reads the change against the spec and puts its findings in
the PR body — advisory, never blocking, and an empty list is a real answer.
- **Effort is per station.** Builds and reviews run at `xhigh`, spec and
grounding at `high`, indexing at `low`; one project setting moves all of them
([`knowledge/decisions/0021-…`](knowledge/decisions/0021-effort-is-per-station-and-builds-review-themselves.md)).
- **Every run starts from a clean branch.** `spec/<ID>-<slug>` is cut from the
default branch each time, so a rebuild replaces the last attempt rather than
stacking on it. The PR is titled `[<ID>] - <Title>`.
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/agents/anthropic.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Anthropic from '@anthropic-ai/sdk';
import { Injectable } from '@nestjs/common';
import { MODELS, type ModelId, type TokenUsage } from '@specd/shared';
import { MODELS, type ModelId, type TokenUsage, STATION_EFFORT, type Effort } from '@specd/shared';
import { AiNotConfigured } from '../common/errors.js';

export interface ModelCallOptions {
Expand All @@ -11,7 +11,7 @@ export interface ModelCallOptions {
/** JSON Schema. When set, the reply is guaranteed to match it. */
schema?: Record<string, unknown>;
maxTokens?: number;
effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
effort?: Effort;
/** Called with every text delta, so a run log can stream while it thinks. */
onDelta?: (text: string) => void;
}
Expand Down Expand Up @@ -71,7 +71,7 @@ export class AnthropicService {
// `effort` sets the ceiling on total spend.
thinking: { type: 'adaptive' },
output_config: {
effort: opts.effort ?? 'high',
effort: opts.effort ?? STATION_EFFORT.spec,
...(opts.schema
? { format: { type: 'json_schema' as const, schema: opts.schema } }
: {}),
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/agents/build.agent.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { SpecView } from '@specd/shared';
import { buildPrBody, looksUnrunnable } from './build.agent.js';

/**
Expand Down Expand Up @@ -99,3 +100,71 @@ describe('the PR body when evidence moved', () => {
expect(body).toContain('did not');
});
});


describe('the review section of a pull request body', () => {
const spec = {
ticketKey: 'E-101',
title: 'Add CSV export',
version: 2,
approvedBy: 'jpark',
approvedAt: '2026-08-18T00:00:00.000Z',
content: { requirements: [], design: [], tasks: [] },
} as unknown as SpecView;

const base = { commits: 3, verifyPassed: true, verifyCommand: 'pnpm test', asBuilt: 'k/s/E-101.md' };

it('says nothing at all when the pass could not run', () => {
// An absent review must read as absent. A "no findings" banner for a pass
// that never happened is the same lie as a green verify for tests that
// never ran.
expect(buildPrBody(spec, base)).not.toMatch(/Review pass/);
expect(buildPrBody(spec, { ...base, review: null })).not.toMatch(/Review pass/);
expect(
buildPrBody(spec, { ...base, review: { verdict: 'unreviewed', findings: [] } }),
).not.toMatch(/Review pass/);
});

it('reports a clean pass, because silence would be indistinguishable from not running', () => {
const body = buildPrBody(spec, {
...base,
review: { verdict: 'clean', summary: 'Does what the spec approved.', findings: [] },
});
expect(body).toMatch(/found nothing to raise/);
expect(body).toContain('Does what the spec approved.');
});

it('leads with blocking findings and says they did not block', () => {
const body = buildPrBody(spec, {
...base,
review: {
verdict: 'findings',
summary: 'Exports, but drops a column.',
findings: [
{ where: 'src/a.ts:12', severity: 'nit', what: 'Stray import.' },
{ where: 'src/b.ts:40', severity: 'blocking', what: 'Header row omitted.', against: 'SHALL include a header' },
{ where: 'src/c.ts:7', severity: 'consider', what: 'Could stream instead.' },
],
},
});

// Ordered by severity: a reviewer scanning the top of the list must hit
// the blocking one first, whatever order the model emitted them in.
expect(body.indexOf('src/b.ts:40')).toBeLessThan(body.indexOf('src/c.ts:7'));
expect(body.indexOf('src/c.ts:7')).toBeLessThan(body.indexOf('src/a.ts:12'));

expect(body).toMatch(/3 finding\(s\), 1 of them blocking/);
expect(body).toMatch(/advisory — they did not stop the build/);
expect(body).toContain('SHALL include a header');
});

it('keeps the acceptance criteria below the findings', () => {
// The findings are the new reading; the criteria are what the reader
// checks them against. Reversing that buries the part nobody has seen.
const body = buildPrBody(spec, {
...base,
review: { verdict: 'findings', summary: 's', findings: [{ where: 'a:1', severity: 'nit', what: 'x' }] },
});
expect(body.indexOf('### Review pass')).toBeLessThan(body.indexOf('### Acceptance criteria'));
});
});
146 changes: 139 additions & 7 deletions apps/api/src/agents/build.agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
type CitationDrift,
type ModelId,
type SpecTask,
type SpecView,
type SpecView, STATION_EFFORT,
type Effort,
} from '@specd/shared';
import type { DetectedStack } from '@specd/templates';
import { ClaudeCodeProvider } from './claude-code.provider.js';
import { ReviewAgent, type ReviewFindings } from './review.agent.js';
import { WorkspaceService } from '../vcs/workspace.js';
import type { RunHandle } from '../runs/runs.service.js';

Expand All @@ -29,6 +31,8 @@ export interface BuildResult {
asBuiltPath: string;
/** Where to review it. A PR on hosted providers; null in local mode. */
reviewUrl: string | null;
/** What the review pass found, or null when it could not run. */
review: ReviewFindings | null;
}

/** One task, with its prompt already rendered so a runner needs no spec knowledge. */
Expand All @@ -49,6 +53,10 @@ export interface PreparedBuildTask {
export interface PreparedBuild {
system: string;
model: ModelId;
/** Travels to a runner so a dispatched build works as hard as a local one. */
effort: Effort;
/** Rendered here, executed wherever the workspace is. */
review: { system: string; brief: string; schema: Record<string, unknown> };
branch: string;
asBuiltPath: string;
asBuiltCommitMessage: string;
Expand All @@ -74,6 +82,13 @@ export interface BuildRunnerReport {
commits: number;
verifyPassed: boolean | null;
verifyOutput: string | null;
/**
* The review the runner ran on its own machine, if it did. It has to happen
* there: the workspace is on the runner's disk and gone by the time this
* report arrives, so the alternative is that dispatched builds silently ship
* unreviewed while local ones do not.
*/
review?: ReviewFindings | null;
}

/**
Expand Down Expand Up @@ -101,6 +116,7 @@ export class BuildAgent {
constructor(
private readonly claudeCode: ClaudeCodeProvider,
private readonly workspaces: WorkspaceService,
private readonly reviewer: ReviewAgent,
) {}

/**
Expand All @@ -117,6 +133,8 @@ export class BuildAgent {
projectName: string;
knowledgeExcerpts: string;
model: ModelId;
/** Defaults to the build station's level. */
effort?: Effort;
drifted?: CitationDrift[];
}): Promise<PreparedBuild> {
const { repo, spec, model } = input;
Expand All @@ -136,6 +154,8 @@ export class BuildAgent {
return {
system: BUILD_SYSTEM_PROMPT,
model,
effort: input.effort ?? STATION_EFFORT.build,
review: this.reviewer.prepare(spec),
branch,
asBuiltPath: asBuilt,
drifted: input.drifted ?? [],
Expand Down Expand Up @@ -195,15 +215,71 @@ export class BuildAgent {
verifyOutput: report.verifyOutput,
asBuiltPath: prepared.asBuiltPath,
reviewUrl: published.url,
// The runner built on its own machine and its workspace is gone by the
// time this runs, so there is nothing here to read. `runner-review`
// covers that path instead of this one.
review: report.review ?? null,
};
}

/**
* Read the branch back and say what is wrong with it.
*
* Best-effort throughout: the commits exist and verify has already spoken,
* so a review that cannot run costs an opinion, not the build. That is the
* same posture the review *surface* takes one step later — nothing after the
* work is allowed to lose the work.
*/
private async reviewOwnWork(input: {
spec: SpecView;
model: ModelId;
effort: Effort;
workspace: { dir: string; baseBranch: string };
run: RunHandle;
}): Promise<ReviewFindings | null> {
const { spec, model, effort, workspace, run } = input;
try {
const diff = await this.workspaces.diff(workspace.dir, workspace.baseBranch);
if (!diff.trim()) return null;

await run.log(`reviewing the diff at effort ${effort}`);
const findings = await this.reviewer.run({
spec,
model,
effort,
workspaceDir: workspace.dir,
diff,
});

if (findings.verdict === 'unreviewed') {
await run.log('the review pass did not answer — opening the PR without it', 'warn');
return null;
}

const blocking = findings.findings.filter((f) => f.severity === 'blocking').length;
await run.log(
findings.findings.length === 0
? 'review found nothing to raise'
: `review raised ${findings.findings.length} finding(s), ${blocking} blocking — advisory, in the PR body`,
blocking > 0 ? 'warn' : 'info',
);
return findings;
} catch (err) {
await run.log(
`the review pass failed (${err instanceof Error ? err.message : String(err)}) — opening the PR without it`,
'warn',
);
return null;
}
}

async run(input: {
repo: Repository;
spec: SpecView;
projectName: string;
knowledgeExcerpts: string;
model: ModelId;
effort?: Effort;
run: RunHandle;
}): Promise<BuildResult> {
const { repo, spec, run, model } = input;
Expand All @@ -228,6 +304,7 @@ export class BuildAgent {

const result = await this.claudeCode.code({
model,
effort: plan.effort,
workspaceDir: workspace.dir,
system: plan.system,
user: task.prompt,
Expand Down Expand Up @@ -288,18 +365,31 @@ export class BuildAgent {

const commits = await this.workspaces.commitCount(workspace.dir, workspace.baseBranch);

// Station 05b. After verify, because a review of code whose tests do not
// run is mostly noise about the tests; before publish, because the point
// is for the findings to reach the pull request rather than a run log
// somebody would have to go and find.
const review = await this.reviewOwnWork({
spec,
model,
effort: plan.effort,
workspace,
run,
});

// Hand the branch to wherever this team reviews. On a hosted provider the
// workspace is a temporary clone about to be deleted, so a branch that is
// never pushed is a build that produced nothing.
const published = await workspace.publish({
title: specPrTitle(spec.ticketKey, spec.title),
body: buildPrBody(spec, {
commits,
verifyPassed,
verifyCommand,
asBuilt,
drifted: plan.drifted ?? [],
}),
commits,
verifyPassed,
verifyCommand,
asBuilt,
drifted: plan.drifted ?? [],
review,
}),
});

await run.log(`branch ${branch} ready · ${commits} commit(s) · ${published.reviewHint}`);
Expand All @@ -313,6 +403,7 @@ export class BuildAgent {
verifyOutput,
asBuiltPath: asBuilt,
reviewUrl: published.url,
review,
};
} finally {
// The worktree goes; the branch it produced stays.
Expand Down Expand Up @@ -417,6 +508,43 @@ ${
Implement it in the current directory.`;
}

/**
* The review section of the pull request body.
*
* Advisory, and says so in the text: these findings did not block the build and
* are not a second gate. What they are is the reading a human would otherwise
* do cold — so they sit above the acceptance criteria, where that reading
* starts, rather than in a run log nobody opens.
*/
function renderReview(review: ReviewFindings | null | undefined): string[] {
if (!review || review.verdict === 'unreviewed') return [];

if (review.findings.length === 0) {
return ['', `> **Review pass found nothing to raise.** ${review.summary ?? ''}`.trimEnd()];
}

const order = { blocking: 0, consider: 1, nit: 2 } as const;
const sorted = [...review.findings].sort((a, b) => order[a.severity] - order[b.severity]);
const blocking = sorted.filter((f) => f.severity === 'blocking').length;

return [
'',
'### Review pass',
'',
review.summary ?? '',
'',
`${sorted.length} finding(s)${blocking ? `, ${blocking} of them blocking` : ''}. ` +
'These are advisory — they did not stop the build, and nothing here has been ' +
'acted on. A human decides which are real.',
'',
...sorted.map(
(f) =>
`- **${f.severity}** \`${f.where}\` — ${f.what}` +
(f.against ? `\n _against:_ ${f.against}` : ''),
),
];
}

/** Runs the repo's own verify command. Never a model-supplied string. */
function runShell(
command: string,
Expand Down Expand Up @@ -469,6 +597,7 @@ export function buildPrBody(
verifyCommand: string | null;
asBuilt: string;
drifted?: CitationDrift[];
review?: ReviewFindings | null;
},
): string {
const verify =
Expand Down Expand Up @@ -501,6 +630,8 @@ export function buildPrBody(
]
: [];

const review = renderReview(meta.review);

return [
`Built from **${spec.ticketKey} v${spec.version}** — ${spec.title}`,
'',
Expand All @@ -510,6 +641,7 @@ export function buildPrBody(
`- As-built spec filed at \`${meta.asBuilt}\``,
`- ${verify}`,
...drift,
...review,
'',
'---',
'',
Expand Down
Loading