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: 18 additions & 0 deletions .factory/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@
"permissionMode": "plan"
}
},
"stepStages": {
"intent": "triage",
"spec": "plan",
"plan": "plan",
"decide": "plan",
"tests": "build",
"build": "build",
"fix": "build",
"harden": "build",
"refactor": "build",
"bump": "build",
"release": "build",
"review": "review",
"ci": "verify",
"eval": "verify",
"reproduce": "verify"
},
"spend": { "capUsd": 10, "stopAt": 0.75 },
"baselineModel": "claude-opus-5-5",
"tiers": {
"high": "claude-opus-5-5",
Expand Down
26 changes: 26 additions & 0 deletions .factory/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# What a repo supplies for the factory engine to run on it. Names only: no key value lives here.
gate: [make, check]
tests:
unit: [npx, vitest, run]
e2e: [npx, playwright, test, tests/e2e]
eval: [npx, tsx, api/src/eval.ts]
prose: [npx, tsx, scripts/check-prose.ts, docs]
prepush: [npx, tsx, scripts/check-affected.ts, --base, origin/main]
mutation: [npx, stryker, run]
paths:
contract: tests/contract
holdout: .factory/holdout
source: shared/src
# Where the engine finds the model key: the variable name and the file that may define it.
keySource:
env: ANTHROPIC_API_KEY
file: api/.env
# A plan may never list these, on top of the engine's own gate-owned files.
protected: [".github/**", ".claude/**", ".factory/**", "factory/**", "scripts/**"]
# One brief for every plan step; a workflow's plan step that sets none uses it.
planBrief: >-
Reply with the plan and nothing else: one repo path per line for every file you will create or
change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those.
A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of
any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later
stages may touch only the files listed.
7 changes: 1 addition & 6 deletions .factory/workflows/bug-tdd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ method: TDD with a locked test
source: Anthropic Claude Code best practices
for: bugs
steps:
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: reproduce,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/contract.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ method: contract test first
source: consumer-driven contracts
for: api and web boundary
steps:
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: contract,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/deps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,7 @@ steps:
writes: intent.md,
brief: "For each package named in the ticket, report its age, maintainer and whether a sibling app pins another major version. Reply with the findings only.",
}
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: bump,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ method: prose check
source: engineering standard
for: documentation
steps:
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: write,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/feature-atdd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,7 @@ steps:
gate: human,
brief: "Write concrete examples, one per line as 'Given <state>, when <action>, then <result>' with real values. Reply with the examples only.",
}
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: tests,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/feature-bdd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,7 @@ steps:
brief: "Read the scenarios as product, developer and tester. List missing cases and ambiguous steps. Change nothing.",
gate: human,
}
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: red,
agent: builder,
Expand Down
9 changes: 1 addition & 8 deletions .factory/workflows/feature-sdd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,7 @@ steps:
writes: clarify.md,
brief: "List every requirement in specs/<issue>/spec.md that two readers could build differently, each with the single reading the plan will assume. Reply with that list only, or the single line 'No ambiguity.'",
}
- {
stage: plan,
agent: planner,
writes: plan.md,
gate: human,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
checks: [plan-drift],
}
- { stage: plan, agent: planner, writes: plan.md, gate: human, checks: [plan-drift] }
- {
stage: tasks,
agent: planner,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/incident.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ steps:
writes: intent.md,
brief: "From the incident in the ticket, state the failing behaviour, the likely cause and the evidence. Change nothing. Reply with the record only.",
}
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: eval,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/llm-eval.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ method: eval rows red first, no regression
source: Anthropic AI-native SDLC playbook
for: prompts, models, agents and any .claude change
steps:
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: rows,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/refactor.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ method: characterization then behaviour-unchanged
source: Working Effectively with Legacy Code
for: refactors
steps:
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change; a new source file also needs its owner entry, so list levels.json. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: characterize,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ steps:
writes: intent.md,
brief: "Name the attacker, the asset and the entry point for the ticket in STRIDE terms. Do not write exploit detail. Reply with the threat model only.",
}
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: reproduce,
agent: builder,
Expand Down
7 changes: 1 addition & 6 deletions .factory/workflows/ui-visual.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ steps:
writes: intent.md,
brief: "Write a short design contract for the ticket: the screen job, the primary action, the states it needs, the responsive rules, and the patterns to reject. Reply with the contract only.",
}
- {
stage: plan,
writes: plan.md,
agent: planner,
brief: "Reply with the plan and nothing else: one repo path per line for every file you will create or change, never a script you only run. Do not list files under specs/<issue>/; the runner allows those. A new file needs an owner in levels.json, so list levels.json whenever you add a file. Commentary of any kind makes the plan unusable. The runner saves your reply as specs/<issue>/plan.md, and later stages may touch only the files listed.",
}
- { stage: plan, writes: plan.md, agent: planner }
- {
stage: build,
agent: builder,
Expand Down
7 changes: 7 additions & 0 deletions factory/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export const configSchema = z
cacheWrite: z.number().nonnegative(),
}),
),
// Which config stage a workflow step takes its model from. A step missing here and not named like a
// stage falls back to the agent's frontmatter.
stepStages: z.record(z.string(), z.enum(STAGE_NAMES)).default({}),
// Run-level spend: the run stops once it has spent `stopAt` of `capUsd`, and no call may pass the cap.
spend: z
.object({ capUsd: z.number().positive(), stopAt: z.number().gt(0).max(1) })
.default({ capUsd: 10, stopAt: 0.75 }),
baselineModel: z.string(),
tiers: z.object({ high: z.string(), mid: z.string(), low: z.string() }),
routes: z.record(z.string(), z.record(z.string(), z.string())),
Expand Down
12 changes: 10 additions & 2 deletions factory/engine/artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { globToRegExp } from "./portable.ts";
import type { Host } from "./types.ts";

// What each stage commits for the next one to read. The commit chain is the audit trail.
Expand All @@ -8,6 +9,7 @@ export const ARTIFACTS = [
"plan.md",
"review.json",
"run.log",
"cost.json",
"incident.md",
"lock.json",
"constitution.md",
Expand Down Expand Up @@ -71,14 +73,20 @@ const PLAN_PATH = /^[A-Za-z0-9_][A-Za-z0-9_.@/-]*$/;
// lock check already fails any later edit to it. Snapshots and other golden files stay gate-owned.
// levels.json is not here: a new source file needs an owner entry there, so a plan must be able to list it.
const GATE_OWNED =
/^((\.github|\.claude|\.factory|factory|scripts)\/|specs\/[^/]+\/golden\/(?![^/]+\.test\.ts$))|(^|\/)(makefile|package(-lock)?\.json|biome\.json|\.npmrc|stryker[^/]*|vitest[^/]*|tsconfig[^/]*|playwright\.config[^/]*)$/i;
/^((\.github|\.claude|\.factory|factory|scripts)\/|specs\/[^/]+\/golden\/(?![^/]+\.test\.ts$))|^api\/src\/eval\.ts$|(^|\/)(makefile|package(-lock)?\.json|biome\.json|\.npmrc|stryker[^/]*|vitest[^/]*|tsconfig[^/]*|playwright\.config[^/]*)$/i;
const MANIFEST = /(^|\/)package(-lock)?\.json$/;
// A dependency update must edit the manifests, so that workflow alone may list them; the manifest-deps
// check then holds the edit to dependency fields.
export function badPlanLines(plan: string, manifests = false): string[] {
export function badPlanLines(
plan: string,
manifests = false,
protectedGlobs: string[] = [],
): string[] {
const extra = protectedGlobs.map(globToRegExp);
return planLines(plan).filter(
(l) =>
!PLAN_PATH.test(l) ||
extra.some((g) => g.test(l)) ||
l.split("/").includes("..") ||
(GATE_OWNED.test(l) && !(manifests && MANIFEST.test(l))),
);
Expand Down
6 changes: 4 additions & 2 deletions factory/engine/checks/exec-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import type { Check, CheckResult, Ctx, TestSize } from "../types.ts";
const tail = (s: string) => s.trim().split("\n").slice(-12).join("\n");

// A check that is only a command: it passes on exit 0 and the evidence is the end of its output.
export function commandCheck(size: TestSize, cmd: string, args: (n: number) => string[]): Check {
// `argv` returns the whole command line, read from .factory/config.yaml when the check runs.
export function commandCheck(size: TestSize, argv: (n: number) => string[]): Check {
return {
size,
async run({ host, n }: Ctx): Promise<CheckResult> {
const r = await host.exec(cmd, args(n));
const [cmd = "", ...args] = argv(n);
const r = await host.exec(cmd, args);
return { ok: r.code === 0, evidence: tail(r.output) || `exit ${r.code}` };
},
};
Expand Down
8 changes: 6 additions & 2 deletions factory/engine/checks/holdout.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { portable } from "../portable.ts";
import type { Check } from "../types.ts";
import { commandCheck } from "./exec-check.ts";

const cases = commandCheck("medium", "npx", (n) => ["vitest", "run", `.factory/holdout/${n}`]);
const cases = commandCheck("medium", (n) => [
...portable().tests.unit,
`${portable().paths.holdout}/${n}`,
]);

// Holdout cases are added by a human; vitest exits 1 on an empty folder, which would fail every round.
export const holdout: Check = {
size: "medium",
async run(ctx) {
const dir = `.factory/holdout/${ctx.n}`;
const dir = `${portable().paths.holdout}/${ctx.n}`;
if ((await ctx.host.list(`${dir}/`)).length === 0)
return { ok: true, evidence: `no holdout cases at ${dir}, nothing graded` };
return cases.run(ctx);
Expand Down
36 changes: 14 additions & 22 deletions factory/engine/checks/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { portable } from "../portable.ts";
import type { Check } from "../types.ts";
import { commandCheck } from "./exec-check.ts";
import { holdout } from "./holdout.ts";
Expand All @@ -7,7 +8,8 @@ import { mutation } from "./mutation.ts";
import { planDriftCheck } from "./plan-drift.ts";
import { red } from "./red.ts";

const vitestFor = (dir: (n: number) => string) => (n: number) => ["vitest", "run", dir(n)];
const t = () => portable().tests;
const unit = (dir: (n: number) => string) => (n: number) => [...t().unit, dir(n)];

// Every check declares its test size: prepush runs the small ones, CI runs all of them.
export const CHECKS: Record<string, Check> = {
Expand All @@ -16,42 +18,32 @@ export const CHECKS: Record<string, Check> = {
"plan-drift": planDriftCheck,
green: commandCheck(
"small",
"npx",
vitestFor((n) => `specs/${n}`),
unit((n) => `specs/${n}`),
),
acceptance: commandCheck(
"medium",
"npx",
vitestFor((n) => `specs/${n}`),
unit((n) => `specs/${n}`),
),
// Holdout cases live where the builder's tools cannot read them; the runner alone executes them.
holdout,
properties: commandCheck(
"medium",
"npx",
vitestFor((n) => `specs/${n}/properties`),
unit((n) => `specs/${n}/properties`),
),
"behaviour-unchanged": commandCheck(
"medium",
"npx",
vitestFor((n) => `specs/${n}/golden`),
unit((n) => `specs/${n}/golden`),
),
mutation,
"manifest-deps": manifestDeps,
contract: commandCheck(
"medium",
"npx",
vitestFor(() => "tests/contract"),
unit(() => portable().paths.contract),
),
visual: commandCheck("large", "npx", () => ["playwright", "test", "tests/e2e"]),
eval: commandCheck("large", "npx", () => ["tsx", "api/src/eval.ts"]),
gate: commandCheck("large", "make", () => ["check"]),
prepush: commandCheck("small", "npx", () => [
"tsx",
"scripts/check-affected.ts",
"--base",
"origin/main",
]),
ci: commandCheck("large", "gh", () => ["pr", "checks", "--watch"]),
prose: commandCheck("small", "npx", () => ["tsx", "scripts/check-prose.ts", "docs"]),
visual: commandCheck("large", () => t().e2e),
eval: commandCheck("large", () => t().eval),
gate: commandCheck("large", () => portable().gate),
prepush: commandCheck("small", () => t().prepush),
ci: commandCheck("large", () => ["gh", "pr", "checks", "--watch"]),
prose: commandCheck("small", () => t().prose),
};
Loading
Loading