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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# Changelog

## v2.6.2

Any repo, safely. No new features: this closes the gaps a second repo (splitbill-demo plus
lwp-website) hit under one shared `FACTORY_HOME`.

- **Per-repo state.** `factory.db` and `workspaces/issue-N` now live under
`FACTORY_HOME/<owner>/<repo>/`, not one shared `FACTORY_HOME`; two repos each carrying an issue #3
no longer collide on one worktree or one DB row set. `src/paths.ts` is the one resolver. `factory doctor`
warns (never auto-fixes) when the pre-v2.6.2 shared `factory.db` or `workspaces/` is still on disk;
moving that history is a deliberate, manual step, not something a doctor run does for you.
- **`resettable: true` opt-in.** `factory reset` and `rebaseline` now refuse on a repo that hasn't opted
in; a live repo like lwp-website (which deploys on merge to `main`) can no longer have its issues and
branches wiped by a config that was only ever meant for a sandbox.
- **The base branch defaults to `origin/HEAD`,** not a hard-coded `"main"`. A repo cloned with a
non-`main` default (`trunk`, `develop`) gets the branch it actually has, unless a config names `base`
explicitly. `factory-verify` and `factory-comment`'s skill text now say "the base branch" instead of
quoting `main`.
- **A `setup` hook.** `.factory/config.json` can list `setup` commands (e.g. `npm ci`) that run once per
worktree, before any stage; a failure parks the issue with the log attached, instead of every stage
failing separately on a project that was never installed.
- **Issue forms and a PR template.** `install` now ships `.github/ISSUE_TEMPLATE/{bug,feature,docs}.yml`
(each applies only its own type label, never `factory:ready`) and `.github/pull_request_template.md`
(summary, plan, gate evidence, verify verdict, risk and rollback: the same sections `factory-pr`'s
fallback body now uses when a target repo has no template of its own). `install.sh`'s next-step message
now says `factory doctor --fix`, which is what actually creates the `factory:*` and type labels; it used
to say plain `factory doctor`, which only reports them missing. This is what makes the
`src/labels.ts:48` claim ("set by the issue form") true.
- **Carried over from v2.6.1:** the CI template gets a "set up your toolchain" placeholder step in
`run-issue`, `tick` and `manual` (not `scan`, which only runs `bun audit`); `factory scan` skips with a
clear message, never shelling out, on a repo with no `bun.lock`/`bun.lockb`; `teach/sessions.json` is
renumbered to this roadmap's versions. The Agents table's `overflow-x: auto` wrapper already covered the
375px-scroll item (verified, no change needed).

Not in v2.6.2:
- Model routing, machine concurrency, spend caps and `proof: check` for non-test work (v2.7).
- The lwp-website pilot itself, and anything that merges (v2.8).
- A live agent run: no stage's behavior changed here (only wording, config surface and file layout), so
the last live Claude run (v2.6.1) still stands as current evidence.

## v2.6.1

Residue fixes for v2.6.0. No new features.
Expand Down
43 changes: 33 additions & 10 deletions bin/factory
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { loadConfig, type FactoryConfig } from "../src/config";
import { EXIT, UsageError, failureJson, successJson } from "../src/cli-output";
import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } from "../src/watch";
import { ShellGateRunner } from "../src/gates";
import { ShellSetupRunner } from "../src/setup";
import { BinaryRunner, ClaudeRechecker } from "../src/recheck";
import { scan } from "../src/scan";
import { streamLogs } from "../src/logs";
Expand All @@ -22,7 +23,7 @@ import { reset, rebaseline } from "../src/reset";
import { runDoctor, fixDoctor } from "../src/doctor";
import { createDashboard } from "../dashboard/server";
import { versionOf, which } from "../src/probes";
import { workspacesDir as defaultWorkspacesDir } from "../src/paths";
import { workspacesDir as defaultWorkspacesDir, defaultStatePath } from "../src/paths";
import { LABEL } from "../src/labels";
import { ensureRepoClone } from "../src/repo";
import { helpText } from "../src/help";
Expand Down Expand Up @@ -75,12 +76,13 @@ function buildWatchDeps(cloneDir: string, config: FactoryConfig): WatchDeps {
return {
github: new GitHub(),
git: new Git(new GitCommandRunner()),
state: new FactoryState(flag("db") ?? DEFAULT_DB_PATH),
state: new FactoryState(flag("db") ?? process.env.FACTORY_DB_PATH ?? defaultStatePath(process.env, config.repo)),
executor: new CommandExecutor(config.agents, config.stages),
gateRunner: new ShellGateRunner(),
rechecker: verifierIsClaude(config) ? new ClaudeRechecker(new BinaryRunner("claude"), cloneDir) : undefined,
cloneDir,
workspacesDir: flag("workspaces") ?? defaultWorkspacesDir(),
workspacesDir: flag("workspaces") ?? defaultWorkspacesDir(process.env, config.repo),
setupRunner: new ShellSetupRunner(),
};
}

Expand Down Expand Up @@ -159,11 +161,16 @@ async function cmdPark(): Promise<void> {
async function cmdLogs(): Promise<void> {
const issue = Number(args[1]);
if (!Number.isInteger(issue) || issue < 1) throw new UsageError("logs: an issue number is required, e.g. `factory logs 12 --follow`");
const state = new FactoryState(flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH);
const repo = flag("repo") ?? process.env.FACTORY_REPO ?? state.listRuns().find((r) => r.issue === issue)?.repo ?? "";
const repoDir = flag("repo-dir");
// With no --repo/--repo-dir/FACTORY_REPO hint, this stays on the legacy
// shared DB (pre-v2.6.2 installs); with one, it opens that repo's own DB
// (per-repo since v2.6.2) instead of guessing at the shared one.
const repo = flag("repo") ?? process.env.FACTORY_REPO ?? (repoDir ? (await loadConfig(resolve(repoDir))).repo : undefined);
const state = new FactoryState(flag("db") ?? process.env.FACTORY_DB_PATH ?? (repo ? defaultStatePath(process.env, repo) : DEFAULT_DB_PATH));
const resolvedRepo = repo ?? state.listRuns().find((r) => r.issue === issue)?.repo ?? "";
const controller = new AbortController();
process.on("SIGINT", () => controller.abort());
await streamLogs(state, repo, issue, { follow: has("follow"), stage: flag("stage"), json: has("json"), signal: controller.signal });
await streamLogs(state, resolvedRepo, issue, { follow: has("follow"), stage: flag("stage"), json: has("json"), signal: controller.signal });
}

async function cmdInbox(): Promise<void> {
Expand Down Expand Up @@ -201,6 +208,10 @@ async function cmdScan(): Promise<void> {
},
};
const result = await scan({ github: new GitHub(), runner: bunRunner }, config.repo, cloneDir);
if (result.skippedReason) {
console.log(`factory scan: skipped — ${result.skippedReason}`);
return;
}
console.log(`factory scan: filed ${result.filed.length}, skipped ${result.skipped.length} (already open)`);
for (const t of result.filed) console.log(` + ${t}`);
}
Expand All @@ -215,8 +226,9 @@ async function cmdReset(): Promise<void> {
baselineTag: config.baselineTag,
base: config.base,
issuesDir: `${cloneDir}/.factory/issues`,
workspacesDir: flag("workspaces") ?? defaultWorkspacesDir(),
statePath: flag("db") ?? DEFAULT_DB_PATH,
workspacesDir: flag("workspaces") ?? defaultWorkspacesDir(process.env, config.repo),
statePath: flag("db") ?? process.env.FACTORY_DB_PATH ?? defaultStatePath(process.env, config.repo),
resettable: config.resettable,
allIssues: has("all-issues"),
};
const deps = { github: new GitHub(), git: new GitCommandRunner() };
Expand All @@ -237,6 +249,7 @@ async function cmdRebaseline(): Promise<void> {
issuesDir: "",
workspacesDir: "",
statePath: "",
resettable: config.resettable,
};
const moved = await rebaseline({ github: new GitHub(), git: new GitCommandRunner() }, ctx, dryRun);
console.log(`factory rebaseline${dryRun ? " --dry-run" : ""}: ${config.baselineTag} -> origin/${config.base} (${moved.length} commit(s))`);
Expand Down Expand Up @@ -286,7 +299,17 @@ async function cmdDoctor(): Promise<void> {
}
},
},
{ repo: config.repo, cloneDir, baselineTag: config.baselineTag, factoryMode: process.env.FACTORY_MODE, agents: config.agents, stages: config.stages, templateSkills: templateSkills() },
{
repo: config.repo,
cloneDir,
baselineTag: config.baselineTag,
factoryMode: process.env.FACTORY_MODE,
agents: config.agents,
stages: config.stages,
templateSkills: templateSkills(),
legacyStatePath: defaultStatePath(process.env),
legacyWorkspacesDir: defaultWorkspacesDir(process.env),
},
);
const allOk = checks.every((c) => c.ok || c.warn);
if (has("json")) console.log(successJson({ checks }, allOk));
Expand Down Expand Up @@ -319,10 +342,10 @@ function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoA
}

async function cmdDashboard(): Promise<void> {
const dbPath = flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH;
const repoDir = flag("repo-dir");
const config = repoDir ? await loadConfig(resolve(repoDir)) : undefined;
const repo = flag("repo") ?? process.env.FACTORY_REPO ?? config?.repo ?? "";
const dbPath = flag("db") ?? process.env.FACTORY_DB_PATH ?? (repo ? defaultStatePath(process.env, repo) : DEFAULT_DB_PATH);
const state = new FactoryState(dbPath);
const github = new GitHub();
serveDashboard(state, github, repo, false, config);
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -219,5 +219,5 @@ action="wrote"
echo ""
echo "install.sh: $action $wrote item(s), skipped $skipped existing item(s), $unchanged unchanged in $TARGET"
if [[ ! -e "$TARGET/.factory/config.json" ]]; then
echo "install.sh: next: cp .factory/config.example.json .factory/config.json, fill in every TODO (config.json, charter.md), then \`factory doctor --repo-dir $TARGET\`."
echo "install.sh: next: cp .factory/config.example.json .factory/config.json, fill in every TODO (config.json, charter.md), then \`factory doctor --fix --repo-dir $TARGET\` (creates the factory:* and type labels this repo needs)."
fi
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "software-factory",
"version": "2.6.1",
"version": "2.6.2",
"private": true,
"type": "module",
"description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR.",
Expand Down
32 changes: 32 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export interface FactoryConfig {
readonly stageTimeoutMinutes: number; // kills a stuck `claude` process (audit finding #15)
readonly maxToolCalls: number; // kills a runaway stage before it burns budget
readonly gates: readonly GateSpec[]; // read by .factory/gates.sh
// Opt-in: `reset`/`rebaseline` refuse on any repo where this is false (the
// default), so a live repo (lwp-website) can never be force-pushed back to
// a baseline tag by a stray `factory reset` (plan v2.6.2 item 2).
readonly resettable: boolean;
// Commands run once per fresh worktree, before any stage (e.g. "npm ci").
// Idempotent via a marker file in the worktree; a failure parks the issue.
readonly setup: readonly string[];
readonly agentCommands: AgentCommands;
// Named agents, each a preset or a command; `stages` says which one runs a stage.
readonly agents: Readonly<Record<string, AgentConfig>>;
Expand All @@ -71,6 +78,8 @@ export const DEFAULT_CONFIG: FactoryConfig = {
stageTimeoutMinutes: 15,
maxToolCalls: 60,
gates: [],
resettable: false,
setup: [],
agentCommands: { read: [], build: [], verify: [] },
agents: { claude: { preset: "claude" } },
stages: { default: "claude" },
Expand Down Expand Up @@ -106,6 +115,8 @@ const TOP_LEVEL: Record<keyof FactoryConfig | "riskCriteria", Kind> = {
stageTimeoutMinutes: "posInt",
maxToolCalls: "posInt",
gates: "object",
resettable: "boolean",
setup: "strings",
agentCommands: "object",
agents: "object",
stages: "object",
Expand Down Expand Up @@ -212,6 +223,14 @@ export async function loadConfig(targetRepoDir: string): Promise<FactoryConfig>
// GitHub calls follow config.repo but git pushes follow origin; a copied config must not aim one at the wrong repo.
const origin = originRepo(targetRepoDir);
if (config.repo === "" && origin) config = { ...config, repo: origin };
// "main" is only a fallback default (DEFAULT_CONFIG.base), never a guess:
// a config that omits "base" gets whatever origin/HEAD resolves to (a repo
// on "trunk" or "develop" must not be force-pushed at "main" by default).
// A config that names "base" explicitly, even "main", is never overridden.
if ((raw as Record<string, unknown>).base === undefined) {
const detected = defaultBranchOf(targetRepoDir);
if (detected) config = { ...config, base: detected };
}
if (!/^[^/\s]+\/[^/\s]+$/.test(config.repo)) {
throw new ConfigError(`${path}: "repo" must be "owner/name", got ${JSON.stringify(config.repo)}`);
}
Expand All @@ -233,3 +252,16 @@ function originRepo(dir: string): string | undefined {
const r = Bun.spawnSync(["git", "-C", dir, "remote", "get-url", "origin"], { stdout: "pipe", stderr: "ignore" });
return r.exitCode === 0 ? repoFromRemoteUrl(r.stdout.toString()) : undefined;
}

// The branch name behind origin/HEAD, e.g. "trunk" for a repo cloned with a
// non-"main" default. Mirrors ensureRepoClone's origin/HEAD resolution
// (src/repo.ts) for local `--repo-dir` mode, which never runs that clone
// path. Missing on a bare or freshly-inited repo (no remote fetch yet) --
// callers fall back to DEFAULT_CONFIG.base ("main") in that case.
function defaultBranchOf(dir: string): string | undefined {
if (!existsSync(`${dir}/.git`)) return undefined;
const r = Bun.spawnSync(["git", "-C", dir, "rev-parse", "--abbrev-ref", "origin/HEAD"], { stdout: "pipe", stderr: "ignore" });
if (r.exitCode !== 0) return undefined;
const ref = r.stdout.toString().trim();
return ref.startsWith("origin/") ? ref.slice("origin/".length) : undefined;
}
32 changes: 32 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export interface DoctorContext {
readonly stages?: StageAgents;
// Shipped skill files (path relative to the repo root -> content), to spot an install that predates this runner.
readonly templateSkills?: Record<string, string>;
// The pre-v2.6.2 shared paths (`defaultStatePath`/`workspacesDir` called
// with no `repo`), passed in so doctor can warn when they still exist.
// Never auto-migrated: that was an explicit design decision (a shared DB
// moved without asking could interleave two repos' history), so this
// check only names the path and leaves the move to the operator.
readonly legacyStatePath?: string;
readonly legacyWorkspacesDir?: string;
}

// Flags that let a CLI run headless without waiting on an approval prompt.
Expand Down Expand Up @@ -205,6 +212,31 @@ export async function runDoctor(deps: DoctorDeps, ctx: DoctorContext): Promise<D
fixable: true,
});

if (ctx.legacyStatePath) {
const exists = await deps.fileExists(ctx.legacyStatePath);
checks.push({
name: "no unmigrated pre-v2.6.2 shared factory.db",
ok: !exists,
detail: exists
? `${ctx.legacyStatePath} still exists; each repo now gets its own DB under FACTORY_HOME/<owner>/<repo>/factory.db. This is never migrated automatically: move any run history you want to keep, then remove it.`
: "none found",
fixable: false,
warn: true,
});
}
if (ctx.legacyWorkspacesDir) {
const exists = await deps.fileExists(ctx.legacyWorkspacesDir);
checks.push({
name: "no unmigrated pre-v2.6.2 shared workspaces/",
ok: !exists,
detail: exists
? `${ctx.legacyWorkspacesDir} still exists; each repo now gets its own workspaces/ under FACTORY_HOME/<owner>/<repo>/. This is never migrated automatically: move anything you need, then remove it.`
: "none found",
fixable: false,
warn: true,
});
}

return checks;
}

Expand Down
24 changes: 18 additions & 6 deletions src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,24 @@ export function factoryHome(env: NodeJS.ProcessEnv = process.env): string {
return resolve(env.FACTORY_HOME ?? `${env.HOME ?? "."}/.factory`);
}

export function workspacesDir(env: NodeJS.ProcessEnv = process.env): string {
return `${factoryHome(env)}/workspaces`;
// A second repo (splitbill-demo, lwp-website, ...) sharing one FACTORY_HOME
// must never share a workspace or a state DB with the first: two repos each
// carrying an issue #3 would otherwise collide on one worktree and one row
// set. `repo` ("owner/name") namespaces both under FACTORY_HOME; omitting it
// keeps the pre-v2.6.2 shared path, which DEFAULT_DB_PATH and existing
// fixtures still rely on.
function repoHome(repo: string, env: NodeJS.ProcessEnv): string {
const [owner, name] = repo.split("/");
if (!owner || !name) throw new Error(`repo must be "owner/name", got ${JSON.stringify(repo)}`);
return `${factoryHome(env)}/${owner}/${name}`;
}

export function defaultStatePath(env: NodeJS.ProcessEnv = process.env): string {
return `${factoryHome(env)}/factory.db`;
export function workspacesDir(env: NodeJS.ProcessEnv = process.env, repo?: string): string {
return repo ? `${repoHome(repo, env)}/workspaces` : `${factoryHome(env)}/workspaces`;
}

export function defaultStatePath(env: NodeJS.ProcessEnv = process.env, repo?: string): string {
return repo ? `${repoHome(repo, env)}/factory.db` : `${factoryHome(env)}/factory.db`;
}

export function reposDir(env: NodeJS.ProcessEnv = process.env): string {
Expand All @@ -27,6 +39,6 @@ export function reposDir(env: NodeJS.ProcessEnv = process.env): string {

// Where a given issue's worktree lives, always absolute regardless of what
// cwd the process was started from.
export function worktreePath(issue: number, env: NodeJS.ProcessEnv = process.env): string {
return `${workspacesDir(env)}/issue-${issue}`;
export function worktreePath(issue: number, env: NodeJS.ProcessEnv = process.env, repo?: string): string {
return `${workspacesDir(env, repo)}/issue-${issue}`;
}
13 changes: 13 additions & 0 deletions src/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export interface ResetContext {
readonly statePath: string;
// Close every open issue, not just the factory's and the seeded ones (a sandbox that holds nothing else).
readonly allIssues?: boolean;
// config.resettable, opt-in per repo. A live repo (lwp-website) never sets
// this, so a stray `factory reset`/`rebaseline` cannot force-push it back
// to a baseline tag (plan v2.6.2 item 2). Defaults closed: omitting the
// field refuses, the same as an explicit `false`.
readonly resettable?: boolean;
}

export class NotResettableError extends Error {
constructor(repo: string) {
super(`${repo} is not resettable: set "resettable": true in .factory/config.json to allow \`factory reset\`/\`rebaseline\` on it`);
}
}

export interface ResetDeps {
Expand Down Expand Up @@ -122,6 +133,7 @@ async function checked(deps: ResetDeps, ctx: ResetContext, args: string[]): Prom

// Move the baseline tag to origin/<base>: the "keep this merge" command.
export async function rebaseline(deps: ResetDeps, ctx: ResetContext, dryRun: boolean): Promise<string[]> {
if (!ctx.resettable) throw new NotResettableError(ctx.repo);
const moved = await commitsAheadOfTag(deps, ctx);
if (!dryRun && moved.length) {
await checked(deps, ctx, ["tag", "-f", ctx.baselineTag, `origin/${ctx.base}`]);
Expand Down Expand Up @@ -226,6 +238,7 @@ export interface ResetSummary {
}

export async function reset(deps: ResetDeps, ctx: ResetContext, dryRun: boolean): Promise<ResetSummary> {
if (!ctx.resettable) throw new NotResettableError(ctx.repo);
const actions = await planReset(deps, ctx);
if (!dryRun) {
for (const action of actions) {
Expand Down
Loading
Loading