@supporterino/ai-factory 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14682,7 +14682,7 @@ var require_dist = __commonJS(function(exports, module) {
14682
14682
  });
14683
14683
 
14684
14684
  // src/cli/main.ts
14685
- import { resolve as resolve5 } from "node:path";
14685
+ import { resolve as resolve6 } from "node:path";
14686
14686
  import { createInterface } from "node:readline/promises";
14687
14687
 
14688
14688
  // src/cli/exit-codes.ts
@@ -14743,7 +14743,7 @@ class TransientError extends Error {
14743
14743
  }
14744
14744
 
14745
14745
  // src/version.ts
14746
- var FACTORY_VERSION = "0.2.0";
14746
+ var FACTORY_VERSION = "0.3.1";
14747
14747
  var PACKAGE_NAME = "@supporterino/ai-factory";
14748
14748
  var runVersion = (version) => `npx ${PACKAGE_NAME}@${version}`;
14749
14749
 
@@ -22304,13 +22304,49 @@ var PONYTAIL = {
22304
22304
  };
22305
22305
 
22306
22306
  // src/config/schema.ts
22307
- var CONFIG_SCHEMA_VERSION = 2;
22307
+ var CONFIG_SCHEMA_VERSION = 3;
22308
22308
  var CONFIG_FILE = "factory.config.jsonc";
22309
- var LANGUAGES = ["typescript", "react", "python", "go", "rust", "helm", "tauri"];
22309
+ var LANGUAGES = [
22310
+ "typescript",
22311
+ "react",
22312
+ "python",
22313
+ "go",
22314
+ "rust",
22315
+ "helm",
22316
+ "tauri",
22317
+ "swift"
22318
+ ];
22310
22319
  var Language = _enum(LANGUAGES);
22320
+ var ROLES = [
22321
+ "orchestrator",
22322
+ "architect",
22323
+ "reviewer",
22324
+ "contractReviewer",
22325
+ "implementer",
22326
+ "tester",
22327
+ "explorer"
22328
+ ];
22329
+ var AGENT_PHASES = [
22330
+ "orchestrator",
22331
+ "architect",
22332
+ "explorer",
22333
+ "reviewer",
22334
+ "contract-reviewer",
22335
+ "tester",
22336
+ "implementer"
22337
+ ];
22311
22338
  var GATE_TYPES = ["format", "lint", "types", "test", "testAffected"];
22312
22339
  var GateType = _enum(GATE_TYPES);
22313
- var CONTRACT_KINDS = ["openapi", "proto", "json-schema", "helm-values"];
22340
+ var CONTRACT_KINDS = [
22341
+ "openapi",
22342
+ "proto",
22343
+ "json-schema",
22344
+ "helm-values",
22345
+ "generated",
22346
+ "crd",
22347
+ "ipc",
22348
+ "custom"
22349
+ ];
22314
22350
  var ContractKind = _enum(CONTRACT_KINDS);
22315
22351
  var RepoName = string2().regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters, digits and dashes, starting alphanumeric");
22316
22352
  var RelPath = string2().min(1).refine((p) => p === "." || !p.startsWith("/") && !p.includes("\\") && !p.split("/").includes(".."), "must be '.' or a relative POSIX path inside the workspace");
@@ -22330,6 +22366,7 @@ var RepoConfig = strictObject({
22330
22366
  var repoLanguages = (repo) => [
22331
22367
  ...new Set([...repo.languages, ...(repo.subPaths ?? []).flatMap((s) => s.languages)])
22332
22368
  ];
22369
+ var ModelByRole = partialRecord(_enum(ROLES), ModelId);
22333
22370
  var ModelsConfig = strictObject({
22334
22371
  orchestrator: ModelId,
22335
22372
  architect: ModelId,
@@ -22337,7 +22374,54 @@ var ModelsConfig = strictObject({
22337
22374
  contractReviewer: ModelId.optional(),
22338
22375
  implementer: ModelId,
22339
22376
  tester: ModelId,
22340
- explorer: ModelId
22377
+ explorer: ModelId,
22378
+ roles: ModelByRole.default({}),
22379
+ repos: record(string2(), ModelByRole).default({}),
22380
+ agents: record(string2(), ModelId).default({}),
22381
+ fallback: array(ModelId).default([])
22382
+ });
22383
+ var InstructionsConfig = strictObject({
22384
+ workspace: array(RelPath).default([]),
22385
+ repos: record(string2(), array(RelPath)).default({})
22386
+ });
22387
+ var SkillsConfig = strictObject({
22388
+ sources: array(RelPath).default([".opencode/skills"]),
22389
+ roles: partialRecord(_enum(ROLES), array(string2().min(1))).default({}),
22390
+ repos: record(string2(), array(string2().min(1))).default({})
22391
+ });
22392
+ var McpConfig = strictObject({
22393
+ roles: partialRecord(_enum(ROLES), array(string2().min(1))).default({})
22394
+ });
22395
+ var CustomAgent = strictObject({
22396
+ id: string2().regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters, digits and dashes, starting alphanumeric"),
22397
+ phase: _enum(AGENT_PHASES),
22398
+ description: string2().min(1).default("custom ai-factory agent"),
22399
+ model: ModelId.optional(),
22400
+ mode: _enum(["primary", "subagent", "all"]).default("subagent"),
22401
+ prompt: string2().min(1).optional(),
22402
+ promptFile: RelPath.optional(),
22403
+ skills: array(string2().min(1)).default([]),
22404
+ edits: array(string2().min(1)).default([]),
22405
+ shell: array(string2().min(1)).default([]),
22406
+ web: boolean2().default(false),
22407
+ mcp: array(string2().min(1)).default([])
22408
+ });
22409
+ var RoutingRule = strictObject({
22410
+ paths: array(string2().min(1)).min(1),
22411
+ agent: string2().min(1)
22412
+ });
22413
+ var ExtraGate = strictObject({
22414
+ name: string2().regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters, digits and dashes, starting alphanumeric"),
22415
+ type: _enum(GATE_TYPES).default("test"),
22416
+ phases: array(_enum(["task", "full"])).default(["task", "full"]),
22417
+ when: strictObject({ changed: array(string2().min(1)).default([]) }).default({ changed: [] }),
22418
+ cwd: RelPath.default("."),
22419
+ command: string2().min(1),
22420
+ env: record(string2(), string2()).default({}),
22421
+ setup: string2().min(1).optional(),
22422
+ outputs: array(RelPath).default([]),
22423
+ checkOnly: boolean2().default(true),
22424
+ timeoutMs: int().min(1).optional()
22341
22425
  });
22342
22426
  var GateCommands = partialRecord(GateType, string2().min(1));
22343
22427
  var GatesConfig = strictObject({
@@ -22349,13 +22433,16 @@ var GatesConfig = strictObject({
22349
22433
  test: "test",
22350
22434
  testAffected: "test:affected"
22351
22435
  }),
22352
- overrides: record(string2(), GateCommands).default({})
22436
+ overrides: record(string2(), GateCommands).default({}),
22437
+ extra: array(ExtraGate).default([])
22353
22438
  });
22354
22439
  var ContractConfig = strictObject({
22355
22440
  kind: ContractKind,
22356
22441
  provider: RepoName,
22357
22442
  path: RelPath,
22358
22443
  consumers: array(RepoName),
22444
+ command: string2().min(1).optional(),
22445
+ outputs: array(RelPath).optional(),
22359
22446
  allowBreaking: boolean2().optional(),
22360
22447
  validate: record(RepoName, string2().min(1)).optional()
22361
22448
  });
@@ -22400,12 +22487,32 @@ var SecurityConfig = strictObject({
22400
22487
  var GraphConfig = strictObject({
22401
22488
  exclude: array(string2().min(1)).default([...DEFAULT_GRAPH_EXCLUDE])
22402
22489
  });
22490
+ var CommitsConfig = strictObject({
22491
+ style: _enum(["conventional", "gitmoji"]).default("conventional"),
22492
+ scopes: boolean2().default(false)
22493
+ });
22494
+ var QuickConfig = strictObject({
22495
+ enabled: boolean2().default(true),
22496
+ branchPrefix: string2().min(1).default("task/")
22497
+ });
22498
+ var WorkflowConfig = strictObject({
22499
+ refine: boolean2().default(true),
22500
+ quick: QuickConfig.prefault({})
22501
+ });
22502
+ var ReleaseConfig = strictObject({
22503
+ detect: boolean2().default(true)
22504
+ });
22403
22505
  var FactoryConfigBase = strictObject({
22404
22506
  $schema: string2().optional(),
22405
- schemaVersion: literal(CONFIG_SCHEMA_VERSION),
22507
+ schemaVersion: union([literal(2), literal(CONFIG_SCHEMA_VERSION)]),
22406
22508
  layout: _enum(["single", "submodules"]),
22407
22509
  repos: array(RepoConfig).min(1),
22408
22510
  models: ModelsConfig,
22511
+ instructions: InstructionsConfig.prefault({}),
22512
+ skills: SkillsConfig.prefault({}),
22513
+ mcp: McpConfig.prefault({}),
22514
+ agents: array(CustomAgent).default([]),
22515
+ routing: array(RoutingRule).default([]),
22409
22516
  gates: GatesConfig.prefault({}),
22410
22517
  contracts: array(ContractConfig).default([]),
22411
22518
  parallel: ParallelConfig.prefault({}),
@@ -22414,6 +22521,9 @@ var FactoryConfigBase = strictObject({
22414
22521
  logs: LogsConfig.prefault({}),
22415
22522
  graph: GraphConfig.prefault({}),
22416
22523
  security: SecurityConfig.prefault({}),
22524
+ commits: CommitsConfig.prefault({}),
22525
+ workflow: WorkflowConfig.prefault({}),
22526
+ release: ReleaseConfig.prefault({}),
22417
22527
  unmanaged: array(RelPath).default([])
22418
22528
  });
22419
22529
  var FactoryConfig = FactoryConfigBase.superRefine((cfg, ctx) => {
@@ -22465,17 +22575,126 @@ var FactoryConfig = FactoryConfigBase.superRefine((cfg, ctx) => {
22465
22575
  issue(["contracts", i, "consumers", j], "a provider cannot consume its own contract");
22466
22576
  }
22467
22577
  });
22578
+ if (["generated", "crd", "ipc", "custom"].includes(contract.kind) && contract.command === undefined) {
22579
+ issue(["contracts", i, "command"], `${contract.kind} contracts need a command`);
22580
+ }
22468
22581
  });
22469
22582
  for (const repoName of Object.keys(cfg.gates.overrides)) {
22470
22583
  if (!codeRepos.has(repoName)) {
22471
22584
  issue(["gates", "overrides", repoName], `unknown code repo "${repoName}"`);
22472
22585
  }
22473
22586
  }
22587
+ for (const key of ["instructions", "skills"]) {
22588
+ for (const repoName of Object.keys(cfg[key].repos)) {
22589
+ if (!codeRepos.has(repoName)) {
22590
+ issue([key, "repos", repoName], `unknown code repo "${repoName}"`);
22591
+ }
22592
+ }
22593
+ }
22594
+ for (const repoName of Object.keys(cfg.models.repos)) {
22595
+ if (!codeRepos.has(repoName)) {
22596
+ issue(["models", "repos", repoName], `unknown code repo "${repoName}"`);
22597
+ }
22598
+ }
22599
+ const agentIds = new Set;
22600
+ for (const [i, agent] of cfg.agents.entries()) {
22601
+ if (agentIds.has(agent.id))
22602
+ issue(["agents", i, "id"], `duplicate agent id "${agent.id}"`);
22603
+ if (agent.prompt === undefined && agent.promptFile === undefined) {
22604
+ issue(["agents", i], "a custom agent needs `prompt` or `promptFile`");
22605
+ }
22606
+ if (agent.prompt !== undefined && agent.promptFile !== undefined) {
22607
+ issue(["agents", i], "use either `prompt` or `promptFile`, not both");
22608
+ }
22609
+ agentIds.add(agent.id);
22610
+ }
22611
+ const builtin = new Set([
22612
+ "orchestrator",
22613
+ "architect",
22614
+ "explorer",
22615
+ "reviewer",
22616
+ "contract-reviewer",
22617
+ "tester",
22618
+ ...cfg.repos.filter((r) => r.role === "code").map((r) => `implementer-${r.name}`)
22619
+ ]);
22620
+ for (const [i, agent] of cfg.agents.entries()) {
22621
+ if (builtin.has(agent.id)) {
22622
+ issue(["agents", i, "id"], `"${agent.id}" is a built-in agent id; choose another id`);
22623
+ }
22624
+ }
22625
+ for (const [i, rule] of cfg.routing.entries()) {
22626
+ if (!agentIds.has(rule.agent) && !builtin.has(rule.agent)) {
22627
+ issue(["routing", i, "agent"], `unknown agent "${rule.agent}"`);
22628
+ }
22629
+ }
22630
+ for (const agentId of Object.keys(cfg.models.agents)) {
22631
+ if (!agentIds.has(agentId)) {
22632
+ issue(["models", "agents", agentId], `unknown custom agent "${agentId}"`);
22633
+ }
22634
+ }
22474
22635
  });
22475
- function modelFor(config, role) {
22476
- if (role === "contractReviewer")
22477
- return config.models.contractReviewer ?? config.models.reviewer;
22478
- return config.models[role];
22636
+ function modelFor(config, role, options = {}) {
22637
+ const flat = role === "contractReviewer" ? config.models.contractReviewer ?? config.models.reviewer : config.models[role];
22638
+ if (options.agent !== undefined) {
22639
+ const agent = config.models.agents[options.agent];
22640
+ if (agent !== undefined)
22641
+ return agent;
22642
+ }
22643
+ if (options.repo !== undefined) {
22644
+ const repo = config.models.repos[options.repo]?.[role];
22645
+ if (repo !== undefined)
22646
+ return repo;
22647
+ }
22648
+ return config.models.roles[role] ?? flat;
22649
+ }
22650
+ function modelEntries(config) {
22651
+ const out = [];
22652
+ for (const role of ROLES) {
22653
+ if (role === "contractReviewer")
22654
+ continue;
22655
+ const model = config.models[role];
22656
+ if (typeof model === "string")
22657
+ out.push([role, model]);
22658
+ }
22659
+ if (config.models.contractReviewer !== undefined)
22660
+ out.push(["contractReviewer", config.models.contractReviewer]);
22661
+ for (const [role, model] of Object.entries(config.models.roles)) {
22662
+ if (model !== undefined)
22663
+ out.push([`${role} (role)`, model]);
22664
+ }
22665
+ for (const [repo, byRole] of Object.entries(config.models.repos)) {
22666
+ for (const [role, model] of Object.entries(byRole)) {
22667
+ if (model !== undefined)
22668
+ out.push([`${role} (${repo})`, model]);
22669
+ }
22670
+ }
22671
+ for (const [agent, model] of Object.entries(config.models.agents)) {
22672
+ out.push([`agent ${agent}`, model]);
22673
+ }
22674
+ return out;
22675
+ }
22676
+ function allModels(config) {
22677
+ const out = new Set;
22678
+ for (const role of ROLES) {
22679
+ const flat = config.models[role];
22680
+ if (typeof flat === "string")
22681
+ out.add(flat);
22682
+ }
22683
+ if (config.models.contractReviewer !== undefined)
22684
+ out.add(config.models.contractReviewer);
22685
+ for (const model of Object.values(config.models.roles))
22686
+ if (model !== undefined)
22687
+ out.add(model);
22688
+ for (const byRole of Object.values(config.models.repos)) {
22689
+ for (const model of Object.values(byRole))
22690
+ if (model !== undefined)
22691
+ out.add(model);
22692
+ }
22693
+ for (const model of Object.values(config.models.agents))
22694
+ out.add(model);
22695
+ for (const model of config.models.fallback)
22696
+ out.add(model);
22697
+ return [...out].sort();
22479
22698
  }
22480
22699
 
22481
22700
  // src/config/load.ts
@@ -22515,9 +22734,12 @@ function parseConfigText(text, file) {
22515
22734
  throw new ConfigError(`${file} is not valid JSONC`, errors);
22516
22735
  }
22517
22736
  const version = value?.schemaVersion;
22518
- if (typeof version === "number" && version < CONFIG_SCHEMA_VERSION) {
22737
+ if (typeof version === "number" && version < 2) {
22519
22738
  throw new ConfigError(`${file} uses config schemaVersion ${version}; this factory needs ${CONFIG_SCHEMA_VERSION}`, ["run `ai-factory update` to migrate it"]);
22520
22739
  }
22740
+ if (typeof version === "number" && version > CONFIG_SCHEMA_VERSION) {
22741
+ throw new ConfigError(`${file} uses config schemaVersion ${version}, newer than this factory (${CONFIG_SCHEMA_VERSION})`, ["install a newer ai-factory"]);
22742
+ }
22521
22743
  const parsed = FactoryConfig.safeParse(value);
22522
22744
  if (!parsed.success) {
22523
22745
  throw new ConfigError(`${file} is invalid`, formatZodIssues(parsed.error));
@@ -23208,7 +23430,7 @@ function benchCommand(cli) {
23208
23430
 
23209
23431
  // src/cli/commands/detect.ts
23210
23432
  import { existsSync as existsSync13, renameSync as renameSync5, writeFileSync as writeFileSync7 } from "node:fs";
23211
- import { join as join16 } from "node:path";
23433
+ import { join as join17 } from "node:path";
23212
23434
 
23213
23435
  // src/detect/contracts.ts
23214
23436
  import { readFileSync as readFileSync5 } from "node:fs";
@@ -23360,6 +23582,57 @@ function findProvided(repo) {
23360
23582
  const chartDirs = new Set(candidates.filter((f) => baseOf(f.path) === "Chart.yaml").map((f) => dirOf(f.path)));
23361
23583
  const protoDirs = [];
23362
23584
  const protoPackages = new Set;
23585
+ const pkgText = read(repo.root, "package.json");
23586
+ let scripts = {};
23587
+ if (pkgText !== undefined) {
23588
+ try {
23589
+ scripts = JSON.parse(pkgText).scripts ?? {};
23590
+ } catch {
23591
+ scripts = {};
23592
+ }
23593
+ }
23594
+ const runner = /bun\.lockb?/.test(repo.files.map((f) => f.path).join(`
23595
+ `)) ? "bun run" : "npm run";
23596
+ for (const binding of ["src/bindings.ts", "bindings.ts"]) {
23597
+ if (candidates.some((f) => f.path === binding) && typeof scripts["gen:bindings"] === "string") {
23598
+ found.push({
23599
+ kind: "ipc",
23600
+ path: binding,
23601
+ needles: [binding],
23602
+ command: `${runner} gen:bindings`,
23603
+ outputs: [binding]
23604
+ });
23605
+ }
23606
+ }
23607
+ if (candidates.some((f) => f.path === "src/routeTree.gen.ts") && typeof scripts.build === "string") {
23608
+ found.push({
23609
+ kind: "generated",
23610
+ path: "src/routeTree.gen.ts",
23611
+ needles: ["src/routeTree.gen.ts"],
23612
+ command: `${runner} build`,
23613
+ outputs: ["src/routeTree.gen.ts"]
23614
+ });
23615
+ }
23616
+ for (const dir of ["operator/config/crd/bases", "config/crd/bases"]) {
23617
+ if (!candidates.some((f) => f.path.startsWith(`${dir}/`)))
23618
+ continue;
23619
+ const taskfile = read(repo.root, "Taskfile.yml") ?? read(repo.root, "Taskfile.yaml");
23620
+ if (taskfile !== undefined && /^ {2}manifests:/m.test(taskfile)) {
23621
+ const root = dir.includes("/config/crd/bases") ? dir.slice(0, dir.indexOf("/config/crd/bases")) : ".";
23622
+ const outputs = [
23623
+ dir,
23624
+ ...["config/rbac", "api/v1alpha1/zz_generated.deepcopy.go"].map((path) => root === "." ? path : `${root}/${path}`)
23625
+ ].filter((path) => candidates.some((f) => f.path === path || f.path.startsWith(`${path}/`)));
23626
+ found.push({
23627
+ kind: "crd",
23628
+ path: dir,
23629
+ needles: [],
23630
+ command: "task manifests generate",
23631
+ outputs
23632
+ });
23633
+ break;
23634
+ }
23635
+ }
23363
23636
  for (const file of candidates) {
23364
23637
  const name = baseOf(file.path);
23365
23638
  if (name === "values.schema.json" && chartDirs.has(dirOf(file.path))) {
@@ -23413,7 +23686,10 @@ function detectContracts(repos) {
23413
23686
  kind: found.kind,
23414
23687
  provider: provider.name,
23415
23688
  path: found.path,
23416
- consumers: consumers.sort()
23689
+ consumers: consumers.sort(),
23690
+ ...found.command === undefined ? {} : { command: found.command },
23691
+ ...found.outputs === undefined ? {} : { outputs: found.outputs },
23692
+ ...found.validate === undefined ? {} : { validate: found.validate }
23417
23693
  });
23418
23694
  }
23419
23695
  }
@@ -23456,7 +23732,7 @@ function applyChanges(text, config, changes) {
23456
23732
  }
23457
23733
 
23458
23734
  // src/detect/workspace.ts
23459
- import { existsSync as existsSync6 } from "node:fs";
23735
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
23460
23736
  import { basename, join as join9 } from "node:path";
23461
23737
 
23462
23738
  // src/detect/languages.ts
@@ -23605,6 +23881,62 @@ async function defaultBranchOf(dir) {
23605
23881
  function isInitialized(dir) {
23606
23882
  return existsSync6(join9(dir, ".git"));
23607
23883
  }
23884
+ function readScripts(dir) {
23885
+ try {
23886
+ const pkg = JSON.parse(readFileSync7(join9(dir, "package.json"), "utf8"));
23887
+ return Object.fromEntries(Object.entries(pkg.scripts ?? {}).filter((entry) => typeof entry[1] === "string"));
23888
+ } catch {
23889
+ return {};
23890
+ }
23891
+ }
23892
+ function packageRunner(dir) {
23893
+ if (existsSync6(join9(dir, "bun.lock")) || existsSync6(join9(dir, "bun.lockb")))
23894
+ return "bun run";
23895
+ if (existsSync6(join9(dir, "pnpm-lock.yaml")))
23896
+ return "pnpm run";
23897
+ if (existsSync6(join9(dir, "yarn.lock")))
23898
+ return "yarn";
23899
+ return "npm run";
23900
+ }
23901
+ function e2eScript(dir) {
23902
+ const scripts = readScripts(dir);
23903
+ const name = scripts["test:e2e"] !== undefined ? "test:e2e" : scripts.e2e !== undefined ? "e2e" : undefined;
23904
+ return name === undefined ? undefined : `${packageRunner(dir)} ${name}`;
23905
+ }
23906
+ function taskNames(dir) {
23907
+ for (const file of ["Taskfile.yml", "Taskfile.yaml"]) {
23908
+ const path = join9(dir, file);
23909
+ if (!existsSync6(path))
23910
+ continue;
23911
+ try {
23912
+ const doc = $parse(readFileSync7(path, "utf8"), { logLevel: "silent" });
23913
+ return Object.keys(doc?.tasks ?? {}).sort();
23914
+ } catch {
23915
+ return [];
23916
+ }
23917
+ }
23918
+ return [];
23919
+ }
23920
+ function readInstructions(dir) {
23921
+ for (const file of [
23922
+ "opencode.json",
23923
+ "opencode.jsonc",
23924
+ ".opencode/opencode.json",
23925
+ ".opencode/opencode.jsonc"
23926
+ ]) {
23927
+ if (!existsSync6(join9(dir, file)))
23928
+ continue;
23929
+ try {
23930
+ const text = readFileSync7(join9(dir, file), "utf8");
23931
+ const value = parseJsonc(text).value;
23932
+ const list = Array.isArray(value?.instructions) ? value.instructions : [];
23933
+ return list.filter((v) => typeof v === "string" && !v.startsWith("/") && !v.startsWith("~") && !v.includes("://") && !v.split("/").includes(".."));
23934
+ } catch {
23935
+ return [];
23936
+ }
23937
+ }
23938
+ return [];
23939
+ }
23608
23940
  function withOptional(base, subPaths, defaultBranch) {
23609
23941
  return {
23610
23942
  ...base,
@@ -23617,6 +23949,12 @@ async function detectWorkspace(root) {
23617
23949
  const repos = [];
23618
23950
  const scanned = [];
23619
23951
  const taken = new Set;
23952
+ const instructions = {
23953
+ workspace: readInstructions(root),
23954
+ repos: {}
23955
+ };
23956
+ const e2e = [];
23957
+ const tasks = [];
23620
23958
  const submodules = existsSync6(join9(root, ".gitmodules")) ? await readSubmodules(root) : [];
23621
23959
  const layout = submodules.length > 0 ? "submodules" : "single";
23622
23960
  if (layout === "single") {
@@ -23625,6 +23963,10 @@ async function detectWorkspace(root) {
23625
23963
  const name = uniqueName(repoNameFrom(root), taken);
23626
23964
  repos.push(withOptional({ name, path: ".", role: "code", languages, initialized: true }, subPaths, await defaultBranchOf(root)));
23627
23965
  scanned.push({ name, root, files });
23966
+ const singleE2e = e2eScript(root);
23967
+ if (singleE2e !== undefined)
23968
+ e2e.push({ repo: name, command: singleE2e });
23969
+ tasks.push({ repo: name, names: taskNames(root) });
23628
23970
  if (languages.length === 0)
23629
23971
  notes.push("no language markers found in the repository");
23630
23972
  } else {
@@ -23643,10 +23985,25 @@ async function detectWorkspace(root) {
23643
23985
  if (languages.length === 0)
23644
23986
  notes.push(`no language markers found in ${sub.path}`);
23645
23987
  repos.push(withOptional({ name, path: sub.path, role: "code", languages, initialized: true }, subPaths, await defaultBranchOf(dir)));
23988
+ const inherited = readInstructions(dir);
23989
+ if (inherited.length > 0)
23990
+ instructions.repos[name] = inherited;
23991
+ const repoE2e = e2eScript(dir);
23992
+ if (repoE2e !== undefined)
23993
+ e2e.push({ repo: name, command: repoE2e });
23994
+ tasks.push({ repo: name, names: taskNames(dir) });
23646
23995
  scanned.push({ name, root: dir, files });
23647
23996
  }
23648
23997
  }
23649
- return { layout, repos, contracts: detectContracts(scanned), notes };
23998
+ return {
23999
+ layout,
24000
+ repos,
24001
+ contracts: detectContracts(scanned),
24002
+ instructions,
24003
+ e2e,
24004
+ tasks,
24005
+ notes
24006
+ };
23650
24007
  }
23651
24008
  function toRepoConfig(repo) {
23652
24009
  const { initialized: _initialized, ...rest } = repo;
@@ -23750,7 +24107,7 @@ function formatChange(change) {
23750
24107
  }
23751
24108
 
23752
24109
  // src/lock/lock.ts
23753
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
24110
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
23754
24111
  import { join as join10 } from "node:path";
23755
24112
 
23756
24113
  // src/tools.ts
@@ -23888,7 +24245,7 @@ function readLock(root) {
23888
24245
  return;
23889
24246
  let value;
23890
24247
  try {
23891
- value = JSON.parse(readFileSync7(file, "utf8"));
24248
+ value = JSON.parse(readFileSync8(file, "utf8"));
23892
24249
  } catch (err) {
23893
24250
  throw new ConfigError(`${LOCK_FILE} is not valid JSON`, [String(err)]);
23894
24251
  }
@@ -23910,7 +24267,7 @@ function ensureNotNewer(lock, allow = false) {
23910
24267
 
23911
24268
  // src/workspace/sync.ts
23912
24269
  import { existsSync as existsSync12, writeFileSync as writeFileSync6 } from "node:fs";
23913
- import { join as join15 } from "node:path";
24270
+ import { join as join16 } from "node:path";
23914
24271
 
23915
24272
  // templates/addons/ponytail/.opencode/command/ponytail-review.md
23916
24273
  var ponytail_review_default = `---
@@ -24258,11 +24615,24 @@ var TYPE_BY_VERB = [
24258
24615
  [/^(bump|upgrade|pin)\b/i, "chore"],
24259
24616
  [/^(speed up|optimi[sz]e|cache)\b/i, "perf"]
24260
24617
  ];
24261
- function conventionalSubject(title, type) {
24618
+ var GITMOJI = {
24619
+ feat: "✨",
24620
+ fix: "\uD83D\uDC1B",
24621
+ test: "✅",
24622
+ refactor: "♻️",
24623
+ docs: "\uD83D\uDCDD",
24624
+ chore: "\uD83D\uDD27",
24625
+ perf: "⚡️",
24626
+ ci: "\uD83D\uDC77",
24627
+ build: "\uD83D\uDD27"
24628
+ };
24629
+ function conventionalSubject(title, type, options = {}) {
24262
24630
  const clean = title.trim().replace(/\s+/g, " ").replace(/\.$/, "");
24263
24631
  const kind = type ?? TYPE_BY_VERB.find(([re]) => re.test(clean))?.[1] ?? "feat";
24264
24632
  const lowered = clean.charAt(0).toLowerCase() + clean.slice(1);
24265
- return `${kind}: ${lowered}`;
24633
+ const scope = options.scope === undefined ? "" : `(${options.scope})`;
24634
+ const emoji = options.style === "gitmoji" ? `${GITMOJI[kind] ?? "\uD83D\uDD27"} ` : "";
24635
+ return `${kind}${scope}: ${emoji}${lowered}`;
24266
24636
  }
24267
24637
  function message(subject, trailers) {
24268
24638
  const block = Object.entries(trailers).map(([k, v]) => `${k}: ${v}`);
@@ -24292,12 +24662,15 @@ async function commitPaths(dir, paths, subject, trailers = {}) {
24292
24662
  });
24293
24663
  return gitOk(["rev-parse", "HEAD"], { cwd: dir });
24294
24664
  }
24295
- function commitTask(worktree, id, task, title, type) {
24296
- return commitAll(worktree, conventionalSubject(title, type), { "Change-Id": id, Task: task });
24665
+ function commitTask(worktree, id, task, title, type, style = {}) {
24666
+ return commitAll(worktree, conventionalSubject(title, type, style), {
24667
+ "Change-Id": id,
24668
+ Task: task
24669
+ });
24297
24670
  }
24298
24671
 
24299
24672
  // src/git/hooks.ts
24300
- import { chmodSync, existsSync as existsSync9, mkdirSync as mkdirSync4, readFileSync as readFileSync8, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
24673
+ import { chmodSync, existsSync as existsSync9, mkdirSync as mkdirSync4, readFileSync as readFileSync9, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
24301
24674
  import { join as join12, relative as relative2 } from "node:path";
24302
24675
  var HOOK_MARKER = "# ai-factory-hook v1";
24303
24676
  var CHAINED_HOOK = "pre-push.ai-factory-chained";
@@ -24341,7 +24714,7 @@ async function installPrePush(repoDir) {
24341
24714
  const path = join12(hooks, "pre-push");
24342
24715
  let status = "installed";
24343
24716
  if (existsSync9(path)) {
24344
- const current = readFileSync8(path, "utf8");
24717
+ const current = readFileSync9(path, "utf8");
24345
24718
  if (current === PRE_PUSH_HOOK)
24346
24719
  return { status: "unchanged", path };
24347
24720
  if (current.includes(HOOK_MARKER)) {
@@ -24370,7 +24743,7 @@ async function prePushState(repoDir) {
24370
24743
  const path = join12(hooks, "pre-push");
24371
24744
  if (!existsSync9(path))
24372
24745
  return "missing";
24373
- const text = readFileSync8(path, "utf8");
24746
+ const text = readFileSync9(path, "utf8");
24374
24747
  if (text === PRE_PUSH_HOOK)
24375
24748
  return "installed";
24376
24749
  return text.includes("# ai-factory-hook") ? "outdated" : "missing";
@@ -24392,13 +24765,17 @@ import {
24392
24765
  existsSync as existsSync10,
24393
24766
  mkdirSync as mkdirSync5,
24394
24767
  mkdtempSync as mkdtempSync2,
24395
- readFileSync as readFileSync9,
24768
+ readFileSync as readFileSync11,
24396
24769
  renameSync as renameSync4,
24397
24770
  rmSync as rmSync3,
24398
24771
  writeFileSync as writeFileSync5
24399
24772
  } from "node:fs";
24400
24773
  import { tmpdir as tmpdir2 } from "node:os";
24401
- import { dirname as dirname4, join as join13 } from "node:path";
24774
+ import { dirname as dirname4, join as join14 } from "node:path";
24775
+
24776
+ // src/render/workspace.ts
24777
+ import { readFileSync as readFileSync10 } from "node:fs";
24778
+ import { join as join13 } from "node:path";
24402
24779
 
24403
24780
  // templates/agents/architect.md
24404
24781
  var architect_default = "# Role: architect\n\nYou write the OpenSpec change for an approved intake: proposal, delta specs with scenarios, design and tasks. You do not write code.\n\n## Inputs\n\nThe task message contains the change id, the request summary, explorer summaries per repo, the workspace repos and the contracts. Start from those; query the graph for anything they do not cover.\n\n## Where you may write\n\nOnly `openspec/changes/<change-id>/` and `.factory/context/<change-id>/`. Everything else is read-only for you.\n\n## Proposal (`proposal.md`)\n\n`## Why` (the problem, 2–5 sentences), `## What Changes` (bullets), `## Capabilities` (`### New Capabilities` / `### Modified Capabilities`), `## Impact` (repos, APIs, contracts, data).\n\n## Specs (`specs/<capability>/spec.md`)\n\n- Use `## ADDED|MODIFIED|REMOVED Requirements`, `### Requirement: <name>` with SHALL/MUST, and at least one `#### Scenario: <name>` per requirement with `- **WHEN** …` / `- **THEN** …` lines.\n- Scenarios are concrete and testable: inputs, observable outputs, error cases. Every scenario will get a test.\n\n## Design (`design.md`)\n\nContext, decisions with the alternatives you rejected, contract changes, risks. Keep it short.\n\n## Tasks (`tasks.md`)\n\nGroup by repo. Every task is one checkbox line followed by three metadata lines, exactly:\n```\n## 1. backend\n- [ ] 1.1 Add health handler\n - repo: backend\n - deps: none\n - touches: internal/http/**, cmd/server/main.go\n```\n\n- Each task names exactly one repo from the workspace list.\n- `deps` lists task ids it needs (`1.1, 2.3`) or `none`. No cycles.\n- `touches` lists the file globs (relative to that repo) the task may change. Tasks in the same repo with overlapping globs run one after another, so keep globs tight.\n- Contract changes (API schemas, events, Helm values) are their own tasks, first, and consumer tasks depend on them so they can run in parallel.\n- Size tasks so one agent can finish one in a single focused session. Tests for a task's behaviour belong to that task.\n- Never put `[ ]` anywhere except on the task line itself.\n\n## Context packs (after approval only)\n\nWhen asked to write context packs, write one file per task to `.factory/context/<change-id>/<task-id>.md`, under the token cap in the task message, containing: the task, the spec scenarios it covers, the contract excerpts it needs, the target symbols with file paths (line ranges are hints only), and the gate commands.\n\nUse `task: \"design\"` or `task: \"context-packs\"` in your handoff and list every file you wrote.\n";
@@ -27252,6 +27629,31 @@ export {
27252
27629
  };
27253
27630
  `;
27254
27631
 
27632
+ // src/config/instructions.ts
27633
+ import { relative as relative3 } from "node:path";
27634
+ var repoOf = (config, name) => config.repos.find((r) => r.name === name)?.path ?? name;
27635
+ function instructionPaths(config, repo) {
27636
+ const repoPaths = repo === undefined ? [] : (config.instructions.repos[repo] ?? []).map((path) => {
27637
+ const base = repoOf(config, repo);
27638
+ return base === "." ? path : `${base}/${path}`;
27639
+ });
27640
+ return [...new Set([...config.instructions.workspace, ...repoPaths])];
27641
+ }
27642
+ function sessionInstructionPaths(config, repoName, repoPath) {
27643
+ return instructionPaths(config, repoName).map((path) => {
27644
+ const rel = relative3(repoPath, path);
27645
+ return rel === "" ? path : rel;
27646
+ });
27647
+ }
27648
+ function allInstructionPaths(config) {
27649
+ return [
27650
+ ...new Set([
27651
+ ...config.instructions.workspace,
27652
+ ...Object.keys(config.instructions.repos).flatMap((repo) => instructionPaths(config, repo))
27653
+ ])
27654
+ ];
27655
+ }
27656
+
27255
27657
  // src/handoff/schema.ts
27256
27658
  var Finding = strictObject({
27257
27659
  severity: _enum(["must", "should", "nice"]),
@@ -27374,7 +27776,7 @@ var READ_TOOLS = [
27374
27776
  rule("glob", "*", "allow"),
27375
27777
  rule("grep", "*", "allow")
27376
27778
  ];
27377
- function agentRules(role, testGlobs = []) {
27779
+ function agentRules(role, testGlobs = [], options = {}) {
27378
27780
  const rules = ACTIONS.map((a) => rule(a, "*", "deny"));
27379
27781
  switch (role) {
27380
27782
  case "orchestrator":
@@ -27384,6 +27786,7 @@ function agentRules(role, testGlobs = []) {
27384
27786
  break;
27385
27787
  case "architect":
27386
27788
  rules.push(...READ_TOOLS, ...allowAll("shell", GRAPH_QUERY_COMMANDS), rule("edit", "openspec/changes/*", "allow"));
27789
+ rules.push(...optionalTools(options));
27387
27790
  return [...rules, ...alwaysDeny([".factory/context/*"])];
27388
27791
  case "reviewer":
27389
27792
  case "contract-reviewer":
@@ -27396,8 +27799,32 @@ function agentRules(role, testGlobs = []) {
27396
27799
  rules.push(...READ_TOOLS, rule("shell", "*", "allow"), rule("edit", "*", "allow"));
27397
27800
  break;
27398
27801
  }
27802
+ rules.push(...optionalTools(options));
27399
27803
  return [...rules, ...alwaysDeny()];
27400
27804
  }
27805
+ function customAgentRules(phase, options) {
27806
+ const rules = ACTIONS.map((a) => rule(a, "*", "deny"));
27807
+ rules.push(...READ_TOOLS);
27808
+ if (phase === "reviewer" || phase === "contract-reviewer") {
27809
+ rules.push(...allowAll("shell", [...GRAPH_QUERY_COMMANDS, ...READ_ONLY_GIT]));
27810
+ } else {
27811
+ rules.push(rule("shell", "*", "allow"));
27812
+ }
27813
+ rules.push(...allowAll("shell", options.shell ?? []));
27814
+ rules.push(...allowAll("edit", options.edits ?? []));
27815
+ rules.push(...optionalTools(options));
27816
+ return [...rules, ...alwaysDeny()];
27817
+ }
27818
+ function optionalTools(options) {
27819
+ const out = [];
27820
+ if (options.skills === true)
27821
+ out.push(rule("skill", "*", "allow"));
27822
+ if (options.web === true)
27823
+ out.push(rule("webfetch", "*", "allow"), rule("websearch", "*", "allow"));
27824
+ for (const action of options.mcp ?? [])
27825
+ out.push(rule(action, "*", "allow"));
27826
+ return out;
27827
+ }
27401
27828
  function alwaysDeny(reallow = []) {
27402
27829
  return [
27403
27830
  ...denyAll("edit", PROTECTED_PATHS),
@@ -28105,6 +28532,84 @@ var rust_default = `# Rust standard
28105
28532
  - One behaviour per \`#[test]\`; every spec scenario maps to at least one test named after it (\`fn rejects_expired_token()\`). <!-- factory -->
28106
28533
  `;
28107
28534
 
28535
+ // templates/standards/swift.md
28536
+ var swift_default = `# Swift standard
28537
+
28538
+ <!-- Sources:
28539
+ [api] Swift API Design Guidelines — https://www.swift.org/documentation/api-design-guidelines/
28540
+ [style] Swift.org: Swift Style Guide — https://www.swift.org/documentation/api-design-guidelines/#fundamentals
28541
+ [format] SwiftFormat rules — https://github.com/nicklockwood/SwiftFormat/blob/master/Rules.md
28542
+ [lint] SwiftLint rules — https://realm.github.io/SwiftLint/rule-directory.html
28543
+ [testing] Swift Testing — https://developer.apple.com/documentation/testing
28544
+ [xcode] Xcode Build Settings Reference — https://developer.apple.com/documentation/xcode/build-settings-reference
28545
+ -->
28546
+
28547
+ ## Summary
28548
+
28549
+ - Keep the package/plugin layout and deployment target the repository already declares.
28550
+ - \`swiftformat --lint\` and \`swiftlint\` are gates; never suppress a rule without a reason.
28551
+ - Names read as prose at the call site; types are nouns, methods are verbs, booleans are assertions.
28552
+ - Prefer values and \`let\`; use \`var\` only when mutation is part of the design.
28553
+ - Handle errors with \`throws\`/\`Result\`; do not trap or force-unwrap outside tests and proven invariants.
28554
+ - Public APIs have documentation comments and explicit access control; implementation stays internal.
28555
+ - Test behaviour with Swift Testing (or the repo's XCTest suite); one test per spec scenario.
28556
+
28557
+ ## Layout and access
28558
+
28559
+ <!-- [api] Fundamentals; [xcode] Packaging -->
28560
+ - Keep the repository's existing package and plugin directories; don't introduce a second source root. <!-- factory -->
28561
+ - One primary type per file, file named after the type; extensions that add protocol conformance live beside it.
28562
+ - Default to \`internal\`. Add \`public\` only for the module's real surface; mark implementation details \`private\` or \`fileprivate\`.
28563
+ - Keep platform code behind \`#if os(iOS)\` / \`#if os(macOS)\` and shared code platform-neutral. <!-- factory -->
28564
+
28565
+ ## Naming and API shape
28566
+
28567
+ <!-- [api] Naming; [style] Naming -->
28568
+ - Types and protocols are \`UpperCamelCase\`; functions, properties and cases are \`lowerCamelCase\`.
28569
+ - Methods read as a sentence at the call site: \`move(from:to:)\`, not \`moveFromTo(_:_:)\`.
28570
+ - Booleans assert a fact: \`isEnabled\`, \`hasPermission\`, \`canRetry\`.
28571
+ - Omit needless words; the argument label carries the role (\`add(_ element:)\`, \`remove(at:)\`).
28572
+ - Acronyms keep a single case (\`URLSession\`, \`userID\`); don't abbreviate domain words.
28573
+
28574
+ ## Values, optionals and errors
28575
+
28576
+ <!-- [api] Fundamentals; [testing] Error Handling -->
28577
+ - Prefer \`let\`; use \`var\` only where mutation is required and scoped.
28578
+ - Avoid force unwrap (\`!\`), \`try!\` and \`as!\` outside tests; use \`guard let\`, \`if let\`, or throw.
28579
+ - Use \`throws\` for recoverable failure and typed errors for cases callers branch on; never discard an error.
28580
+ - \`fatalError\` only for a broken invariant at startup, with a message naming the invariant. <!-- factory -->
28581
+
28582
+ ## Concurrency
28583
+
28584
+ <!-- [api] Concurrency; [lint] Concurrency rules -->
28585
+ - Keep UI work on the main actor; mark views and view models \`@MainActor\` where the framework expects it.
28586
+ - Use structured concurrency (\`async let\`, task groups); do not detach a task without an owner and cancellation path.
28587
+ - Shared mutable state is an actor or guarded by a lock; document the isolation boundary.
28588
+ - Never block the main actor with file, network or database I/O. <!-- factory -->
28589
+
28590
+ ## Tauri plugins and iOS/macOS glue
28591
+
28592
+ <!-- [xcode] Frameworks; [api] Documentation -->
28593
+ - A Tauri plugin exposes a typed Rust API; Swift stays behind that boundary and never becomes the public contract.
28594
+ - Request permission strings and entitlements from the security owner; do not edit capability files here. <!-- factory -->
28595
+ - Verify the host build and the iOS target compile; report which SDK/target you used. <!-- factory -->
28596
+
28597
+ ## Testing
28598
+
28599
+ <!-- [testing] Test declarations; [lint] Test rules -->
28600
+ - Use Swift Testing (\`@Test\`, \`#expect\`) in new code; keep the repo's XCTest suite where it already exists.
28601
+ - Test behaviour through the public API; avoid snapshot-only tests and sleeps.
28602
+ - Name tests after the scenario they cover; one scenario can have several focused tests.
28603
+ - Keep tests deterministic: inject clocks, file systems and network clients at the boundary. <!-- factory -->
28604
+
28605
+ ## Formatting and verification
28606
+
28607
+ <!-- [format] Rules; [lint] Configuration -->
28608
+ - \`swiftformat --lint\` must pass; do not reformat code outside the task.
28609
+ - \`swiftlint lint\` must pass with the repository's config; fix the cause instead of adding \`// swiftlint:disable\`.
28610
+ - \`swift build\` (and the iOS target build where relevant) must pass before the task is done. <!-- factory -->
28611
+ `;
28612
+
28108
28613
  // templates/standards/tauri.md
28109
28614
  var tauri_default = `# Tauri v2 standard
28110
28615
 
@@ -28527,6 +29032,24 @@ var PROFILES = {
28527
29032
  },
28528
29033
  install: [{ lockfile: "go.sum", command: "go mod download" }]
28529
29034
  },
29035
+ swift: {
29036
+ id: "swift",
29037
+ title: "Swift",
29038
+ prose: swift_default,
29039
+ testGlobs: ["*Tests.swift", "*/Tests/*", "Tests/*"],
29040
+ markers: ["Package.swift"],
29041
+ targets: "dir",
29042
+ extensions: [".swift"],
29043
+ binDirs: [],
29044
+ gates: {
29045
+ format: [{ command: "swiftformat --lint .", scoped: "swiftformat --lint {files}" }],
29046
+ lint: [{ command: "swiftlint lint", scoped: "swiftlint lint --path {files}" }],
29047
+ types: [{ command: "swift build" }],
29048
+ test: [{ command: "swift test" }],
29049
+ testAffected: [{ command: "swift test" }]
29050
+ },
29051
+ install: [{ lockfile: "Package.resolved", command: "swift package resolve" }]
29052
+ },
28530
29053
  rust: {
28531
29054
  id: "rust",
28532
29055
  title: "Rust",
@@ -28699,9 +29222,9 @@ var AGENTS = [
28699
29222
  function frontmatter(spec) {
28700
29223
  const data = {
28701
29224
  description: spec.description,
28702
- mode: "primary",
29225
+ mode: spec.mode ?? "primary",
28703
29226
  model: spec.model,
28704
- permissions: agentRules(spec.role, spec.testGlobs)
29227
+ permissions: spec.permissions ?? agentRules(spec.role, spec.testGlobs)
28705
29228
  };
28706
29229
  const doc = new $Document(data);
28707
29230
  const rules = doc.get("permissions", true);
@@ -28721,10 +29244,55 @@ ${parts.sections.map((s) => s.trim()).join(`
28721
29244
  `)}
28722
29245
  `;
28723
29246
  }
29247
+ var ROLE_FOR_PHASE = {
29248
+ orchestrator: "orchestrator",
29249
+ architect: "architect",
29250
+ explorer: "explorer",
29251
+ reviewer: "reviewer",
29252
+ "contract-reviewer": "contractReviewer",
29253
+ tester: "tester",
29254
+ implementer: "implementer"
29255
+ };
29256
+ function skillNames(config, role, repo) {
29257
+ return [
29258
+ ...new Set([
29259
+ ...config.skills.roles[role] ?? [],
29260
+ ...repo === undefined ? [] : config.skills.repos[repo] ?? []
29261
+ ])
29262
+ ];
29263
+ }
29264
+ function skillsSection(names) {
29265
+ if (names.length === 0)
29266
+ return;
29267
+ return `## Skills
29268
+
29269
+ Load these skills first: ${names.map((n) => `\`${n}\``).join(", ")}. Use the skill tool before you start; follow the loaded skill over your own habits.`;
29270
+ }
29271
+ function instructionsSection(config, repo) {
29272
+ const paths = instructionPaths(config, repo);
29273
+ if (paths.length === 0)
29274
+ return;
29275
+ return `## Project instructions
29276
+
29277
+ Read these files before you change anything; they are the project's own bar and take precedence over your general knowledge. Paths are workspace-relative; from a worktree or repo checkout, open \`$AI_FACTORY_WORKSPACE/<path>\`:
29278
+ ${paths.map((p) => `- \`${p}\``).join(`
29279
+ `)}`;
29280
+ }
29281
+ function sharedSections(role) {
29282
+ return role === "orchestrator" ? [untrusted_default, handoff_default] : [untrusted_default, graph_default, handoff_default];
29283
+ }
28724
29284
  function renderAgent(config, spec) {
28725
- const shared = spec.role === "orchestrator" ? [untrusted_default, handoff_default] : [untrusted_default, graph_default, handoff_default];
29285
+ const skillList = skillNames(config, spec.modelRole);
29286
+ const web = spec.role === "reviewer" || spec.role === "contract-reviewer";
29287
+ const options = {
29288
+ ...skillList.length > 0 ? { skills: true } : {},
29289
+ ...web ? { web: true } : {},
29290
+ ...config.mcp.roles[spec.modelRole] === undefined ? {} : { mcp: config.mcp.roles[spec.modelRole] }
29291
+ };
28726
29292
  const section = spec.role === "reviewer" ? ponytailSection(config, "reviewer") : undefined;
28727
29293
  const addon = section === undefined ? [] : [section];
29294
+ const skills = skillsSection(skillList);
29295
+ const instructions = instructionsSection(config);
28728
29296
  return {
28729
29297
  path: `.opencode/agents/${spec.id}.md`,
28730
29298
  template: `agents/${spec.id}`,
@@ -28734,9 +29302,16 @@ function renderAgent(config, spec) {
28734
29302
  description: spec.description,
28735
29303
  model: modelFor(config, spec.modelRole),
28736
29304
  role: spec.role,
28737
- ...spec.role === "tester" ? { testGlobs: workspaceTestGlobs(config) } : {}
29305
+ ...spec.role === "tester" ? { testGlobs: workspaceTestGlobs(config) } : {},
29306
+ permissions: agentRules(spec.role, spec.role === "tester" ? workspaceTestGlobs(config) : [], options)
28738
29307
  }),
28739
- sections: [spec.body, ...addon, ...shared]
29308
+ sections: [
29309
+ spec.body,
29310
+ ...instructions === undefined ? [] : [instructions],
29311
+ ...skills === undefined ? [] : [skills],
29312
+ ...addon,
29313
+ ...sharedSections(spec.role)
29314
+ ]
28740
29315
  })
28741
29316
  };
28742
29317
  }
@@ -28752,6 +29327,8 @@ function renderImplementer(config, repo) {
28752
29327
  const standards = profilesFor(languages).map((p) => renderStandard(p, { comments: false }));
28753
29328
  const langs = languages.length > 0 ? languages.join(", ") : "none detected";
28754
29329
  const addon = ponytailSection(config, "implementer");
29330
+ const skills = skillsSection(skillNames(config, "implementer", repo.name));
29331
+ const instructions = instructionsSection(config, repo.name);
28755
29332
  return {
28756
29333
  path: `.opencode/agents/implementer-${repo.name}.md`,
28757
29334
  template: "agents/implementer",
@@ -28759,11 +29336,19 @@ function renderImplementer(config, repo) {
28759
29336
  content: agentFile({
28760
29337
  head: frontmatter({
28761
29338
  description: `ai-factory implementer for ${repo.name} (${repo.path}; ${langs}).`,
28762
- model: modelFor(config, "implementer"),
28763
- role: "implementer"
29339
+ model: modelFor(config, "implementer", { repo: repo.name }),
29340
+ role: "implementer",
29341
+ ...skills === undefined && config.mcp.roles.implementer === undefined ? {} : {
29342
+ permissions: agentRules("implementer", [], {
29343
+ skills: skills !== undefined,
29344
+ ...config.mcp.roles.implementer === undefined ? {} : { mcp: config.mcp.roles.implementer }
29345
+ })
29346
+ }
28764
29347
  }),
28765
29348
  sections: [
28766
29349
  implementer_default.replaceAll("{{repo}}", repo.name),
29350
+ ...instructions === undefined ? [] : [instructions],
29351
+ ...skills === undefined ? [] : [skills],
28767
29352
  ...standards,
28768
29353
  ...addon === undefined ? [] : [addon],
28769
29354
  untrusted_default,
@@ -28773,6 +29358,41 @@ function renderImplementer(config, repo) {
28773
29358
  })
28774
29359
  };
28775
29360
  }
29361
+ function renderCustomAgent(config, agent, root) {
29362
+ const role = ROLE_FOR_PHASE[agent.phase] ?? "implementer";
29363
+ const agentRole = agent.phase;
29364
+ const body = agent.prompt ?? (agent.promptFile !== undefined && root !== undefined ? readFileSync10(join13(root, agent.promptFile), "utf8") : `# Role: ${agent.id}
29365
+
29366
+ ${agent.description}`);
29367
+ const skills = skillsSection(agent.skills);
29368
+ const instructions = instructionsSection(config);
29369
+ return {
29370
+ path: `.opencode/agents/${agent.id}.md`,
29371
+ template: `agents/custom/${agent.id}`,
29372
+ mode: "file",
29373
+ content: agentFile({
29374
+ head: frontmatter({
29375
+ description: agent.description,
29376
+ model: agent.model ?? modelFor(config, role, { agent: agent.id }),
29377
+ role: agentRole,
29378
+ mode: agent.mode,
29379
+ permissions: customAgentRules(agentRole, {
29380
+ edits: agent.edits,
29381
+ shell: agent.shell,
29382
+ skills: agent.skills.length > 0,
29383
+ web: agent.web,
29384
+ mcp: agent.mcp
29385
+ })
29386
+ }),
29387
+ sections: [
29388
+ body,
29389
+ ...instructions === undefined ? [] : [instructions],
29390
+ ...skills === undefined ? [] : [skills],
29391
+ ...sharedSections(agentRole)
29392
+ ]
29393
+ })
29394
+ };
29395
+ }
28776
29396
  function renderAgentsMd(config) {
28777
29397
  const markers = BLOCK_MARKERS["AGENTS.md"];
28778
29398
  if (markers === undefined)
@@ -28811,8 +29431,13 @@ ${config.graph.exclude.join(`
28811
29431
  `)}
28812
29432
  `;
28813
29433
  }
28814
- function renderOpencodeConfig() {
28815
- const config = { $schema: "https://opencode.ai/config.json", update: "disable" };
29434
+ function renderOpencodeConfig(factory) {
29435
+ const instructions = allInstructionPaths(factory);
29436
+ const config = {
29437
+ $schema: "https://opencode.ai/config.json",
29438
+ update: "disable",
29439
+ ...instructions.length > 0 ? { instructions } : {}
29440
+ };
28816
29441
  return `// ${MANAGED_HEADER}
28817
29442
  // Agents and their permissions are in .opencode/agents/.
28818
29443
  ${JSON.stringify(config, null, 2)}
@@ -28881,7 +29506,7 @@ function ponytailFiles(config) {
28881
29506
  }
28882
29507
  ];
28883
29508
  }
28884
- function renderWorkspace(config) {
29509
+ function renderWorkspace(config, options = {}) {
28885
29510
  const files = [
28886
29511
  { path: "AGENTS.md", template: "agents-md", mode: "block", content: renderAgentsMd(config) },
28887
29512
  { path: ".gitignore", template: "gitignore", mode: "block", content: renderGitignore() },
@@ -28895,7 +29520,7 @@ function renderWorkspace(config) {
28895
29520
  path: ".opencode/opencode.jsonc",
28896
29521
  template: "opencode-config",
28897
29522
  mode: "file",
28898
- content: renderOpencodeConfig()
29523
+ content: renderOpencodeConfig(config)
28899
29524
  },
28900
29525
  {
28901
29526
  path: ".opencode/plugins/factory.js",
@@ -28929,7 +29554,8 @@ function renderWorkspace(config) {
28929
29554
  })),
28930
29555
  ...ponytailFiles(config),
28931
29556
  ...AGENTS.map((spec) => renderAgent(config, spec)),
28932
- ...config.repos.filter((r) => r.role === "code").map((r) => renderImplementer(config, r))
29557
+ ...config.repos.filter((r) => r.role === "code").map((r) => renderImplementer(config, r)),
29558
+ ...config.agents.map((agent) => renderCustomAgent(config, agent, options.root))
28933
29559
  ];
28934
29560
  return files.sort((a, b) => a.path.localeCompare(b.path));
28935
29561
  }
@@ -28954,10 +29580,10 @@ function findBlock(text, markers) {
28954
29580
  return { start, end: endMarker + markers.end.length };
28955
29581
  }
28956
29582
  function readOptional(abs) {
28957
- return existsSync10(abs) ? readFileSync9(abs, "utf8") : undefined;
29583
+ return existsSync10(abs) ? readFileSync11(abs, "utf8") : undefined;
28958
29584
  }
28959
29585
  function currentOwned(root, path, mode) {
28960
- const text = readOptional(join13(root, path));
29586
+ const text = readOptional(join14(root, path));
28961
29587
  if (text === undefined || mode === "file")
28962
29588
  return text;
28963
29589
  const block = findBlock(text, markersFor(path));
@@ -29025,12 +29651,12 @@ ${block}
29025
29651
  writeAtomic(abs, next);
29026
29652
  }
29027
29653
  function removeOwned(root, path, mode) {
29028
- const abs = join13(root, path);
29654
+ const abs = join14(root, path);
29029
29655
  if (mode === "file") {
29030
29656
  rmSync3(abs, { force: true });
29031
29657
  return;
29032
29658
  }
29033
- const text = readFileSync9(abs, "utf8");
29659
+ const text = readFileSync11(abs, "utf8");
29034
29660
  const range = findBlock(text, markersFor(path));
29035
29661
  if (range === undefined)
29036
29662
  return;
@@ -29047,9 +29673,9 @@ async function blobText(root, blob) {
29047
29673
  return res.code === 0 ? res.stdout : undefined;
29048
29674
  }
29049
29675
  async function mergeThree(root, ours, base, theirs) {
29050
- const dir = mkdtempSync2(join13(tmpdir2(), "ai-factory-merge-"));
29676
+ const dir = mkdtempSync2(join14(tmpdir2(), "ai-factory-merge-"));
29051
29677
  try {
29052
- const files = { ours: join13(dir, "ours"), base: join13(dir, "base"), theirs: join13(dir, "theirs") };
29678
+ const files = { ours: join14(dir, "ours"), base: join14(dir, "base"), theirs: join14(dir, "theirs") };
29053
29679
  writeFileSync5(files.ours, ours);
29054
29680
  writeFileSync5(files.base, base);
29055
29681
  writeFileSync5(files.theirs, theirs);
@@ -29096,7 +29722,7 @@ async function executeSync(root, plan, previous, options = {}) {
29096
29722
  if (file === undefined)
29097
29723
  throw new Error(`no render for ${change.path}`);
29098
29724
  if (change.action !== "unchanged") {
29099
- const abs = join13(root, file.path);
29725
+ const abs = join14(root, file.path);
29100
29726
  if (file.mode === "block")
29101
29727
  writeBlock(abs, file.path, file.content);
29102
29728
  else
@@ -29114,7 +29740,7 @@ async function executeSync(root, plan, previous, options = {}) {
29114
29740
  break;
29115
29741
  }
29116
29742
  const result = await mergeThree(root, ours, base, file.content);
29117
- const abs = join13(root, file.path);
29743
+ const abs = join14(root, file.path);
29118
29744
  if (file.mode === "block")
29119
29745
  writeBlock(abs, file.path, result.text.replace(/\n$/, ""));
29120
29746
  else
@@ -29146,12 +29772,12 @@ import {
29146
29772
  mkdirSync as mkdirSync6,
29147
29773
  mkdtempSync as mkdtempSync3,
29148
29774
  readdirSync as readdirSync5,
29149
- readFileSync as readFileSync10,
29775
+ readFileSync as readFileSync12,
29150
29776
  rmSync as rmSync4,
29151
29777
  statSync as statSync4
29152
29778
  } from "node:fs";
29153
29779
  import { tmpdir as tmpdir3 } from "node:os";
29154
- import { join as join14, relative as relative3, sep } from "node:path";
29780
+ import { join as join15, relative as relative4, sep } from "node:path";
29155
29781
 
29156
29782
  // src/tasks/glob.ts
29157
29783
  var cache = new Map;
@@ -29441,14 +30067,14 @@ function normalizeCheckboxes(text) {
29441
30067
 
29442
30068
  // src/openspec/cli.ts
29443
30069
  function env(root) {
29444
- const xdg = join14(root, ".factory", "cache", "openspec-xdg");
30070
+ const xdg = join15(root, ".factory", "cache", "openspec-xdg");
29445
30071
  mkdirSync6(xdg, { recursive: true });
29446
30072
  return { ...toolEnv("openspec"), XDG_CONFIG_HOME: xdg };
29447
30073
  }
29448
30074
  async function openspec(root, cwd, args) {
29449
30075
  return exec([toolBin("openspec"), ...args], { cwd, env: env(root), timeoutMs: 120000 });
29450
30076
  }
29451
- var changePath = (dir, id) => join14(dir, "openspec", "changes", id);
30077
+ var changePath = (dir, id) => join15(dir, "openspec", "changes", id);
29452
30078
  async function newChange(root, dir, id) {
29453
30079
  if (existsSync11(changePath(dir, id)))
29454
30080
  return;
@@ -29470,10 +30096,10 @@ async function validateChange(root, dir, id) {
29470
30096
  return item.valid ? [] : item.issues.map((i) => `openspec ${i.level.toLowerCase()}: ${i.path ? `${i.path}: ` : ""}${i.message}`);
29471
30097
  }
29472
30098
  function checkProposal(dir, id) {
29473
- const file = join14(changePath(dir, id), "proposal.md");
30099
+ const file = join15(changePath(dir, id), "proposal.md");
29474
30100
  if (!existsSync11(file))
29475
30101
  return ["proposal.md is missing"];
29476
- const text = readFileSync10(file, "utf8");
30102
+ const text = readFileSync12(file, "utf8");
29477
30103
  const issues = [];
29478
30104
  for (const heading of ["## Why", "## What Changes"]) {
29479
30105
  if (!text.split(`
@@ -29487,10 +30113,10 @@ function checkProposal(dir, id) {
29487
30113
  }
29488
30114
  async function checkDesign(root, dir, id, codeRepos, contracts = []) {
29489
30115
  const issues = [...checkProposal(dir, id), ...await validateChange(root, dir, id)];
29490
- const tasksFile = join14(changePath(dir, id), "tasks.md");
30116
+ const tasksFile = join15(changePath(dir, id), "tasks.md");
29491
30117
  if (!existsSync11(tasksFile))
29492
30118
  return { issues: [...issues, "tasks.md is missing"], tasks: [] };
29493
- const parsed = parseTasks(readFileSync10(tasksFile, "utf8"));
30119
+ const parsed = parseTasks(readFileSync12(tasksFile, "utf8"));
29494
30120
  issues.push(...parsed.issues.map((i) => `tasks.md:${i.line}: ${i.message}`));
29495
30121
  if (parsed.tasks.length === 0)
29496
30122
  issues.push("tasks.md has no tasks");
@@ -29508,7 +30134,7 @@ async function archiveChange(root, dir, id) {
29508
30134
  }
29509
30135
  var OPENSPEC_TOOLS_TEMPLATE = "openspec-tools";
29510
30136
  async function renderOpenspecTools(root) {
29511
- const scratch = mkdtempSync3(join14(tmpdir3(), "ai-factory-openspec-"));
30137
+ const scratch = mkdtempSync3(join15(tmpdir3(), "ai-factory-openspec-"));
29512
30138
  try {
29513
30139
  const res = await exec([
29514
30140
  toolBin("openspec"),
@@ -29522,21 +30148,21 @@ async function renderOpenspecTools(root) {
29522
30148
  ], { cwd: scratch, env: env(root), timeoutMs: 120000 });
29523
30149
  if (res.code !== 0)
29524
30150
  throw new ExecError("openspec init failed", res);
29525
- const base = join14(scratch, ".opencode");
30151
+ const base = join15(scratch, ".opencode");
29526
30152
  if (!existsSync11(base))
29527
30153
  return [];
29528
30154
  const files = [];
29529
30155
  const walk = (dir) => {
29530
30156
  for (const name of readdirSync5(dir).sort()) {
29531
- const abs = join14(dir, name);
30157
+ const abs = join15(dir, name);
29532
30158
  if (statSync4(abs).isDirectory())
29533
30159
  walk(abs);
29534
30160
  else
29535
30161
  files.push({
29536
- path: relative3(scratch, abs).split(sep).join("/"),
30162
+ path: relative4(scratch, abs).split(sep).join("/"),
29537
30163
  template: OPENSPEC_TOOLS_TEMPLATE,
29538
30164
  mode: "file",
29539
- content: readFileSync10(abs, "utf8")
30165
+ content: readFileSync12(abs, "utf8")
29540
30166
  });
29541
30167
  }
29542
30168
  };
@@ -29567,7 +30193,7 @@ function carriedTools(previous, options) {
29567
30193
  function planWorkspace(root, config, options = {}) {
29568
30194
  const previous = readLock(root)?.managed ?? {};
29569
30195
  const tools = options.tools ?? [];
29570
- const plan = planSync(root, [...renderWorkspace(config), ...tools], previous, {
30196
+ const plan = planSync(root, [...renderWorkspace(config, { root }), ...tools], previous, {
29571
30197
  unmanaged: config.unmanaged,
29572
30198
  ...options,
29573
30199
  carry: Object.keys(carriedTools(previous, options)),
@@ -29608,10 +30234,10 @@ async function syncWorkspace(root, config, options = {}) {
29608
30234
  files: [...PONYTAIL.files]
29609
30235
  }
29610
30236
  } : {});
29611
- writeFileSync6(join15(root, LOCK_FILE), serializeLock(lock));
30237
+ writeFileSync6(join16(root, LOCK_FILE), serializeLock(lock));
29612
30238
  const hooks = [];
29613
30239
  for (const repo of config.repos) {
29614
- hooks.push({ repo: repo.name, result: await installPrePush(join15(root, repo.path)) });
30240
+ hooks.push({ repo: repo.name, result: await installPrePush(join16(root, repo.path)) });
29615
30241
  }
29616
30242
  return { plan, lock, hooks, merged, conflicts };
29617
30243
  }
@@ -29623,7 +30249,7 @@ async function dirtyInstallPaths(root, ignore = []) {
29623
30249
  `).filter((l) => l.trim() !== "").map((l) => l.slice(3)).filter((p) => !ignore.includes(p));
29624
30250
  }
29625
30251
  function commitInstall(root, message) {
29626
- return commitPaths(root, INSTALL_PATHS.filter((p) => existsSync12(join15(root, p))), message);
30252
+ return commitPaths(root, INSTALL_PATHS.filter((p) => existsSync12(join16(root, p))), message);
29627
30253
  }
29628
30254
 
29629
30255
  // src/cli/commands/detect.ts
@@ -29658,7 +30284,7 @@ var detectCommand = {
29658
30284
  "run ai-factory from the workspace (AI repo or single repo)"
29659
30285
  ]);
29660
30286
  }
29661
- const hasConfig = existsSync13(join16(root, CONFIG_FILE));
30287
+ const hasConfig = existsSync13(join17(root, CONFIG_FILE));
29662
30288
  const loaded = hasConfig ? loadConfig(root) : undefined;
29663
30289
  if (values.apply && loaded === undefined) {
29664
30290
  throw new PreconditionError(`no ${CONFIG_FILE} to apply to`, ["run `ai-factory init` first"]);
@@ -29734,21 +30360,21 @@ var detectCommand = {
29734
30360
  };
29735
30361
 
29736
30362
  // src/doctor/doctor.ts
29737
- import { existsSync as existsSync20, lstatSync as lstatSync2, mkdtempSync as mkdtempSync4, readdirSync as readdirSync7, readFileSync as readFileSync16, rmSync as rmSync8 } from "node:fs";
30363
+ import { existsSync as existsSync20, lstatSync as lstatSync2, mkdtempSync as mkdtempSync4, readdirSync as readdirSync7, readFileSync as readFileSync18, rmSync as rmSync8 } from "node:fs";
29738
30364
  import { tmpdir as tmpdir4 } from "node:os";
29739
- import { delimiter, extname, join as join26 } from "node:path";
30365
+ import { delimiter, extname, join as join27 } from "node:path";
29740
30366
 
29741
30367
  // src/gates/resolve.ts
29742
- import { existsSync as existsSync16, readFileSync as readFileSync13 } from "node:fs";
29743
- import { join as join19 } from "node:path";
30368
+ import { existsSync as existsSync16, readFileSync as readFileSync15 } from "node:fs";
30369
+ import { join as join20 } from "node:path";
29744
30370
 
29745
30371
  // src/gates/affected.ts
29746
- import { existsSync as existsSync15, readFileSync as readFileSync12 } from "node:fs";
29747
- import { join as join18, relative as relative4 } from "node:path";
30372
+ import { existsSync as existsSync15, readFileSync as readFileSync14 } from "node:fs";
30373
+ import { join as join19, relative as relative5 } from "node:path";
29748
30374
 
29749
30375
  // src/gates/targets.ts
29750
- import { existsSync as existsSync14, readFileSync as readFileSync11 } from "node:fs";
29751
- import { join as join17 } from "node:path";
30376
+ import { existsSync as existsSync14, readFileSync as readFileSync13 } from "node:fs";
30377
+ import { join as join18 } from "node:path";
29752
30378
  var WALK2 = { maxDepth: 8, maxFiles: 50000 };
29753
30379
  function worktreeFiles(worktree) {
29754
30380
  return listFiles(worktree, WALK2).map((f) => f.path);
@@ -29789,7 +30415,7 @@ function installDirs(profile, files) {
29789
30415
  }
29790
30416
  function packageDependencies(abs) {
29791
30417
  try {
29792
- const pkg = JSON.parse(readFileSync11(join17(abs, "package.json"), "utf8"));
30418
+ const pkg = JSON.parse(readFileSync13(join18(abs, "package.json"), "utf8"));
29793
30419
  const names = new Set;
29794
30420
  for (const key of [
29795
30421
  "dependencies",
@@ -29810,9 +30436,9 @@ function packageDependencies(abs) {
29810
30436
  function requirementHolds(req, worktree, dir) {
29811
30437
  if (req === undefined)
29812
30438
  return true;
29813
- const dirs = dir === "." ? [worktree] : [join17(worktree, dir), worktree];
30439
+ const dirs = dir === "." ? [worktree] : [join18(worktree, dir), worktree];
29814
30440
  if ("file" in req)
29815
- return dirs.some((d) => req.file.some((f) => existsSync14(join17(d, f))));
30441
+ return dirs.some((d) => req.file.some((f) => existsSync14(join18(d, f))));
29816
30442
  if ("dependency" in req) {
29817
30443
  return dirs.some((d) => {
29818
30444
  const deps = packageDependencies(d);
@@ -29821,7 +30447,7 @@ function requirementHolds(req, worktree, dir) {
29821
30447
  }
29822
30448
  return dirs.some((d) => {
29823
30449
  try {
29824
- return readFileSync11(join17(d, req.fileContains.file), "utf8").includes(req.fileContains.text);
30450
+ return readFileSync13(join18(d, req.fileContains.file), "utf8").includes(req.fileContains.text);
29825
30451
  } catch {
29826
30452
  return false;
29827
30453
  }
@@ -29852,12 +30478,12 @@ var stem = (path) => {
29852
30478
  return dot === -1 ? base : base.slice(0, dot);
29853
30479
  };
29854
30480
  function targetChanges(target, changed) {
29855
- const roots = target.charts.length > 0 ? target.charts.map((c) => join18(target.dir, c)) : [target.dir];
30481
+ const roots = target.charts.length > 0 ? target.charts.map((c) => join19(target.dir, c)) : [target.dir];
29856
30482
  return changed.filter((f) => roots.some((r) => within(f, r)) && (target.charts.length > 0 || hasExt(target.profile, f)));
29857
30483
  }
29858
30484
  function crateName(cargoToml) {
29859
30485
  try {
29860
- const text = readFileSync12(cargoToml, "utf8");
30486
+ const text = readFileSync14(cargoToml, "utf8");
29861
30487
  const pkg = text.split(/^\[/m).find((section) => section.startsWith("package]"));
29862
30488
  return pkg?.match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1];
29863
30489
  } catch {
@@ -29872,9 +30498,9 @@ function ancestorDirs(path) {
29872
30498
  return [...dirs, "."];
29873
30499
  }
29874
30500
  function placeholderValues(worktree, target, changed, allFiles, base, mode, related = []) {
29875
- const rel = (p) => target.dir === "." ? p : relative4(target.dir, p);
30501
+ const rel = (p) => target.dir === "." ? p : relative5(target.dir, p);
29876
30502
  const mine = targetChanges(target, changed);
29877
- const existing = mine.filter((f) => existsSync15(join18(worktree, f)));
30503
+ const existing = mine.filter((f) => existsSync15(join19(worktree, f)));
29878
30504
  const tests = new Set(existing.filter((f) => isTestFile(target.profile, f)));
29879
30505
  const stems = new Set(existing.filter((f) => !isTestFile(target.profile, f)).map(stem));
29880
30506
  const dependents = new Set(related);
@@ -29887,11 +30513,11 @@ function placeholderValues(worktree, target, changed, allFiles, base, mode, rela
29887
30513
  const packages = [...new Set(existing.filter((f) => f.endsWith(".go")).map((f) => rel(f)))].map((f) => f.includes("/") ? `./${f.slice(0, f.lastIndexOf("/"))}` : ".").filter((p, i, all) => all.indexOf(p) === i);
29888
30514
  const crates = new Set;
29889
30515
  for (const f of existing.filter((x) => x.endsWith(".rs"))) {
29890
- const name = ancestorDirs(f).map((dir) => crateName(join18(worktree, dir, "Cargo.toml"))).find((n) => n !== undefined);
30516
+ const name = ancestorDirs(f).map((dir) => crateName(join19(worktree, dir, "Cargo.toml"))).find((n) => n !== undefined);
29891
30517
  if (name)
29892
30518
  crates.add(name);
29893
30519
  }
29894
- const charts = mode === "full" ? [...target.charts] : target.charts.filter((c) => mine.some((f) => within(f, join18(target.dir, c))));
30520
+ const charts = mode === "full" ? [...target.charts] : target.charts.filter((c) => mine.some((f) => within(f, join19(target.dir, c))));
29895
30521
  return {
29896
30522
  files: existing.map(rel),
29897
30523
  testFiles: [...tests].sort().map(rel),
@@ -29923,22 +30549,22 @@ var GATES_BY_MODE = {
29923
30549
  var SKIP = "skip";
29924
30550
  function readText2(path) {
29925
30551
  try {
29926
- return readFileSync13(path, "utf8");
30552
+ return readFileSync15(path, "utf8");
29927
30553
  } catch {
29928
30554
  return;
29929
30555
  }
29930
30556
  }
29931
30557
  function scriptRunner(worktree) {
29932
- if (existsSync16(join19(worktree, "bun.lock")) || existsSync16(join19(worktree, "bun.lockb")))
30558
+ if (existsSync16(join20(worktree, "bun.lock")) || existsSync16(join20(worktree, "bun.lockb")))
29933
30559
  return "bun run";
29934
- if (existsSync16(join19(worktree, "pnpm-lock.yaml")))
30560
+ if (existsSync16(join20(worktree, "pnpm-lock.yaml")))
29935
30561
  return "pnpm run";
29936
- if (existsSync16(join19(worktree, "yarn.lock")))
30562
+ if (existsSync16(join20(worktree, "yarn.lock")))
29937
30563
  return "yarn run";
29938
30564
  return "npm run --silent";
29939
30565
  }
29940
30566
  function findScript(worktree, name) {
29941
- const pkg = readText2(join19(worktree, "package.json"));
30567
+ const pkg = readText2(join20(worktree, "package.json"));
29942
30568
  if (pkg !== undefined) {
29943
30569
  try {
29944
30570
  const scripts = JSON.parse(pkg).scripts;
@@ -29947,14 +30573,14 @@ function findScript(worktree, name) {
29947
30573
  } catch {}
29948
30574
  }
29949
30575
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29950
- const make = readText2(join19(worktree, "Makefile"));
30576
+ const make = readText2(join20(worktree, "Makefile"));
29951
30577
  if (make !== undefined && new RegExp(`^${escaped}\\s*:(?!=)`, "m").test(make))
29952
30578
  return "Makefile";
29953
- const just = readText2(join19(worktree, "justfile")) ?? readText2(join19(worktree, "Justfile"));
30579
+ const just = readText2(join20(worktree, "justfile")) ?? readText2(join20(worktree, "Justfile"));
29954
30580
  if (just !== undefined && new RegExp(`^@?${escaped}(\\s[^:]*)?:(?!=)`, "m").test(just))
29955
30581
  return "justfile";
29956
30582
  for (const file of ["Taskfile.yml", "Taskfile.yaml"]) {
29957
- const task = readText2(join19(worktree, file));
30583
+ const task = readText2(join20(worktree, file));
29958
30584
  if (task === undefined)
29959
30585
  continue;
29960
30586
  try {
@@ -30004,7 +30630,7 @@ function profileGate(input, type, target) {
30004
30630
  cwd: target.dir,
30005
30631
  source: "profile",
30006
30632
  profile: target.profile.id,
30007
- binDirs: target.profile.binDirs.map((b) => target.dir === "." ? b : join19(target.dir, b))
30633
+ binDirs: target.profile.binDirs.map((b) => target.dir === "." ? b : join20(target.dir, b))
30008
30634
  };
30009
30635
  }
30010
30636
  var isGap = (x) => ("reason" in x);
@@ -30035,7 +30661,7 @@ function resolveGates(input) {
30035
30661
  const scriptName = input.config.gates.scripts[type];
30036
30662
  const host = scriptName === undefined ? undefined : findScript(input.worktree, scriptName);
30037
30663
  if (host !== undefined && scriptName !== undefined) {
30038
- const files = type === "testAffected" ? input.changed.filter((f) => existsSync16(join19(input.worktree, f))) : [];
30664
+ const files = type === "testAffected" ? input.changed.filter((f) => existsSync16(join20(input.worktree, f))) : [];
30039
30665
  if (type === "testAffected" && files.length === 0) {
30040
30666
  plan.skipped.push({ type, cwd: ".", reason: "skip: nothing affected" });
30041
30667
  } else {
@@ -30069,13 +30695,50 @@ function resolveGates(input) {
30069
30695
  plan.missing.push(result);
30070
30696
  }
30071
30697
  }
30698
+ for (const extra of input.config.gates.extra) {
30699
+ if (!extra.phases.includes(input.mode))
30700
+ continue;
30701
+ const wanted = extra.when.changed;
30702
+ if (wanted.length > 0 && input.mode === "task" && !input.changed.some((file) => wanted.some((glob) => opencodeGlobRegExp(glob).test(file)))) {
30703
+ plan.skipped.push({
30704
+ type: extra.type,
30705
+ cwd: extra.cwd,
30706
+ reason: `skip: ${extra.name} does not match the changed paths`
30707
+ });
30708
+ continue;
30709
+ }
30710
+ const values = repoPlaceholders(input, targets);
30711
+ const command = fillPlaceholders(extra.command, values);
30712
+ if (command === undefined) {
30713
+ plan.skipped.push({
30714
+ type: extra.type,
30715
+ cwd: extra.cwd,
30716
+ reason: `skip: nothing affected for ${extra.name}`
30717
+ });
30718
+ continue;
30719
+ }
30720
+ const setup = extra.setup === undefined ? undefined : fillPlaceholders(extra.setup, values);
30721
+ plan.gates.push({
30722
+ type: extra.type,
30723
+ name: extra.name,
30724
+ command,
30725
+ cwd: extra.cwd,
30726
+ source: "extra",
30727
+ binDirs: rootBins,
30728
+ ...Object.keys(extra.env).length > 0 ? { env: extra.env } : {},
30729
+ ...setup === undefined ? {} : { setup },
30730
+ ...extra.outputs.length > 0 ? { outputs: extra.outputs } : {},
30731
+ checkOnly: extra.checkOnly,
30732
+ ...extra.timeoutMs === undefined ? {} : { timeoutMs: extra.timeoutMs }
30733
+ });
30734
+ }
30072
30735
  return plan;
30073
30736
  }
30074
30737
  function repoPlaceholders(input, targets) {
30075
- const perTarget = targets.map((t) => placeholderValues(input.worktree, { profile: t.profile, dir: ".", charts: t.charts.map((c) => join19(t.dir, c)) }, input.changed, input.files, input.base, input.mode));
30738
+ const perTarget = targets.map((t) => placeholderValues(input.worktree, { profile: t.profile, dir: ".", charts: t.charts.map((c) => join20(t.dir, c)) }, input.changed, input.files, input.base, input.mode));
30076
30739
  const union = (key) => [...new Set(perTarget.flatMap((v) => v[key]))].sort();
30077
30740
  return {
30078
- files: input.changed.filter((f) => existsSync16(join19(input.worktree, f))),
30741
+ files: input.changed.filter((f) => existsSync16(join20(input.worktree, f))),
30079
30742
  testFiles: union("testFiles"),
30080
30743
  packages: union("packages"),
30081
30744
  crates: union("crates"),
@@ -30139,8 +30802,8 @@ async function resolveBranchRef(dir, branch, options = {}) {
30139
30802
  }
30140
30803
 
30141
30804
  // src/graph/graph.ts
30142
- import { existsSync as existsSync18, readFileSync as readFileSync14, rmSync as rmSync6, statSync as statSync6 } from "node:fs";
30143
- import { join as join21 } from "node:path";
30805
+ import { existsSync as existsSync18, readFileSync as readFileSync16, rmSync as rmSync6, statSync as statSync6 } from "node:fs";
30806
+ import { join as join22 } from "node:path";
30144
30807
 
30145
30808
  // src/logs/log.ts
30146
30809
  import {
@@ -30152,7 +30815,7 @@ import {
30152
30815
  rmSync as rmSync5,
30153
30816
  statSync as statSync5
30154
30817
  } from "node:fs";
30155
- import { join as join20 } from "node:path";
30818
+ import { join as join21 } from "node:path";
30156
30819
 
30157
30820
  // src/logs/redact.ts
30158
30821
  var MARK = (kind) => `[REDACTED:${kind}]`;
@@ -30210,7 +30873,7 @@ function redactValue(value, env, heuristic) {
30210
30873
  return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(v, env, heuristic)]));
30211
30874
  return value;
30212
30875
  }
30213
- var logDir = (root, changeId) => join20(root, ".factory", "logs", changeId);
30876
+ var logDir = (root, changeId) => join21(root, ".factory", "logs", changeId);
30214
30877
  function appendLog(root, changeId, entry, options = {}) {
30215
30878
  const dir = logDir(root, changeId);
30216
30879
  mkdirSync7(dir, { recursive: true });
@@ -30220,21 +30883,21 @@ function appendLog(root, changeId, entry, options = {}) {
30220
30883
  at: at.toISOString(),
30221
30884
  ...redactValue(entry, env, options.redact ?? true)
30222
30885
  });
30223
- appendFileSync(join20(dir, `${at.toISOString().slice(0, 10)}.jsonl`), `${line}
30886
+ appendFileSync(join21(dir, `${at.toISOString().slice(0, 10)}.jsonl`), `${line}
30224
30887
  `);
30225
30888
  }
30226
30889
  function pruneLogs(root, retentionDays, now = Date.now()) {
30227
- const base = join20(root, ".factory", "logs");
30890
+ const base = join21(root, ".factory", "logs");
30228
30891
  if (!existsSync17(base))
30229
30892
  return [];
30230
30893
  const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
30231
30894
  const removed = [];
30232
30895
  for (const change of readdirSync6(base)) {
30233
- const dir = join20(base, change);
30896
+ const dir = join21(base, change);
30234
30897
  if (!statSync5(dir).isDirectory())
30235
30898
  continue;
30236
30899
  for (const file of readdirSync6(dir)) {
30237
- const abs = join20(dir, file);
30900
+ const abs = join21(dir, file);
30238
30901
  if (statSync5(abs).mtimeMs < cutoff) {
30239
30902
  rmSync5(abs, { force: true });
30240
30903
  removed.push(abs);
@@ -30247,12 +30910,12 @@ function pruneLogs(root, retentionDays, now = Date.now()) {
30247
30910
  }
30248
30911
 
30249
30912
  // src/graph/graph.ts
30250
- var graphDir = (root) => join21(root, "graphify-out");
30251
- var graphFile = (root) => join21(graphDir(root), "graph.json");
30913
+ var graphDir = (root) => join22(root, "graphify-out");
30914
+ var graphFile = (root) => join22(graphDir(root), "graph.json");
30252
30915
  var metaFile = (root) => factoryDir(root, "graph-meta.json");
30253
30916
  function readGraph(root) {
30254
30917
  try {
30255
- return JSON.parse(readFileSync14(graphFile(root), "utf8"));
30918
+ return JSON.parse(readFileSync16(graphFile(root), "utf8"));
30256
30919
  } catch {
30257
30920
  return;
30258
30921
  }
@@ -30353,12 +31016,12 @@ function dependentFiles(root, files) {
30353
31016
  }
30354
31017
 
30355
31018
  // src/policy/sandbox.ts
30356
- import { existsSync as existsSync19, readFileSync as readFileSync15 } from "node:fs";
30357
- import { isAbsolute, join as join23, resolve as resolve3 } from "node:path";
31019
+ import { existsSync as existsSync19, readFileSync as readFileSync17 } from "node:fs";
31020
+ import { isAbsolute, join as join24, resolve as resolve3 } from "node:path";
30358
31021
 
30359
31022
  // src/policy/realpath.ts
30360
31023
  import { lstatSync, readlinkSync, realpathSync as realpathSync2 } from "node:fs";
30361
- import { basename as basename2, dirname as dirname5, join as join22, resolve as resolve2 } from "node:path";
31024
+ import { basename as basename2, dirname as dirname5, join as join23, resolve as resolve2 } from "node:path";
30362
31025
  function realPathOf(abs, depth = 0) {
30363
31026
  if (depth > 40)
30364
31027
  return abs;
@@ -30368,11 +31031,11 @@ function realPathOf(abs, depth = 0) {
30368
31031
  const st = lstatSync(current, { throwIfNoEntry: false });
30369
31032
  if (st !== undefined) {
30370
31033
  try {
30371
- return join22(realpathSync2.native(current), ...missing.reverse());
31034
+ return join23(realpathSync2.native(current), ...missing.reverse());
30372
31035
  } catch {
30373
31036
  if (st.isSymbolicLink()) {
30374
31037
  const target = resolve2(dirname5(current), readlinkSync(current));
30375
- return realPathOf(join22(target, ...missing.reverse()), depth + 1);
31038
+ return realPathOf(join23(target, ...missing.reverse()), depth + 1);
30376
31039
  }
30377
31040
  return abs;
30378
31041
  }
@@ -30474,8 +31137,8 @@ function seatbeltProfile(policy) {
30474
31137
  if ((policy.writableInside ?? []).length > 0)
30475
31138
  lines.push(`(allow file-write* ${(policy.writableInside ?? []).map(subpath).join(" ")})`);
30476
31139
  if (home !== undefined) {
30477
- lines.push(`(deny file-write* ${HOME_WRITE_DENY.map((p) => subpath(join23(home, p))).join(" ")})`);
30478
- lines.push(`(deny file-read* ${HOME_READ_DENY.map((p) => subpath(join23(home, p))).join(" ")})`);
31140
+ lines.push(`(deny file-write* ${HOME_WRITE_DENY.map((p) => subpath(join24(home, p))).join(" ")})`);
31141
+ lines.push(`(deny file-read* ${HOME_READ_DENY.map((p) => subpath(join24(home, p))).join(" ")})`);
30479
31142
  }
30480
31143
  lines.push(`(deny file-write* ${SYSTEM_WRITE_DENY.map(subpath).join(" ")})`);
30481
31144
  if (policy.denySecretReads === true)
@@ -30489,22 +31152,22 @@ function sandboxArgv(profile, argv) {
30489
31152
  return [SANDBOX_EXEC, "-p", profile, ...argv];
30490
31153
  }
30491
31154
  function gitWritableDirs(worktree) {
30492
- const dotGit = join23(worktree, ".git");
31155
+ const dotGit = join24(worktree, ".git");
30493
31156
  let gitDir = dotGit;
30494
31157
  try {
30495
- const text = readFileSync15(dotGit, "utf8");
31158
+ const text = readFileSync17(dotGit, "utf8");
30496
31159
  const m = text.match(/^gitdir:\s*(.+)$/m);
30497
31160
  if (m?.[1])
30498
31161
  gitDir = isAbsolute(m[1].trim()) ? m[1].trim() : resolve3(worktree, m[1].trim());
30499
31162
  } catch {
30500
- return [join23(dotGit, "index"), join23(dotGit, "index.lock"), join23(dotGit, "objects")];
31163
+ return [join24(dotGit, "index"), join24(dotGit, "index.lock"), join24(dotGit, "objects")];
30501
31164
  }
30502
31165
  let common = gitDir;
30503
31166
  try {
30504
- const rel = readFileSync15(join23(gitDir, "commondir"), "utf8").trim();
31167
+ const rel = readFileSync17(join24(gitDir, "commondir"), "utf8").trim();
30505
31168
  common = isAbsolute(rel) ? rel : resolve3(gitDir, rel);
30506
31169
  } catch {}
30507
- return [gitDir, join23(common, "objects")];
31170
+ return [gitDir, join24(common, "objects")];
30508
31171
  }
30509
31172
  function protectedInWorktree(worktree) {
30510
31173
  return [
@@ -30516,7 +31179,7 @@ function protectedInWorktree(worktree) {
30516
31179
  "factory.lock.json",
30517
31180
  "openspec/specs",
30518
31181
  ".gitmodules"
30519
- ].map((p) => join23(worktree, p));
31182
+ ].map((p) => join24(worktree, p));
30520
31183
  }
30521
31184
  function checkoutPolicy(workspace, dir, extra = [], home = process.env.HOME) {
30522
31185
  return {
@@ -30907,11 +31570,11 @@ import { spawn as spawn2 } from "node:child_process";
30907
31570
  import { randomBytes } from "node:crypto";
30908
31571
  import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync8 } from "node:fs";
30909
31572
  import { createServer } from "node:net";
30910
- import { join as join25 } from "node:path";
31573
+ import { join as join26 } from "node:path";
30911
31574
 
30912
31575
  // src/policy/session-files.ts
30913
- import { join as join24 } from "node:path";
30914
- var sessionContextFile = (workspace, sessionId) => join24(workspace, ".factory", "sessions", `${sessionId}.json`);
31576
+ import { join as join25 } from "node:path";
31577
+ var sessionContextFile = (workspace, sessionId) => join25(workspace, ".factory", "sessions", `${sessionId}.json`);
30915
31578
 
30916
31579
  // src/runtime/credentials.ts
30917
31580
  var PROVIDER_ENV = {
@@ -30984,8 +31647,7 @@ var PROXY_ENV = [
30984
31647
  "no_proxy"
30985
31648
  ];
30986
31649
  function providersOf(config) {
30987
- const models = Object.values(config.models).filter((m) => typeof m === "string");
30988
- return [...new Set(models.map((m) => m.split("/")[0] ?? ""))].filter(Boolean);
31650
+ return [...new Set(allModels(config).map((m) => m.split("/")[0] ?? ""))].filter(Boolean);
30989
31651
  }
30990
31652
  function serverEnv(root, config, password, base = process.env) {
30991
31653
  const env = {};
@@ -31003,11 +31665,11 @@ function serverEnv(root, config, password, base = process.env) {
31003
31665
  ...env,
31004
31666
  ...agentGitEnv({}),
31005
31667
  OPENCODE_PASSWORD: password,
31006
- OPENCODE_DB: join25(root, ".factory", "opencode", "opencode.db"),
31668
+ OPENCODE_DB: join26(root, ".factory", "opencode", "opencode.db"),
31007
31669
  AI_FACTORY_WORKSPACE: root,
31008
31670
  AI_FACTORY_SCRUB: scrubList().join(","),
31009
31671
  AI_FACTORY_SANDBOX: sandboxEnabled(config.security.sandbox) ? "seatbelt" : "off",
31010
- AI_FACTORY_GRAPH: join25(root, "graphify-out", "graph.json"),
31672
+ AI_FACTORY_GRAPH: join26(root, "graphify-out", "graph.json"),
31011
31673
  NO_COLOR: "1"
31012
31674
  };
31013
31675
  }
@@ -31045,6 +31707,19 @@ var minus = (a, b) => ({
31045
31707
  cacheWrite: a.cacheWrite - b.cacheWrite,
31046
31708
  cost: a.cost - b.cost
31047
31709
  });
31710
+ var WAIT_POLL_MS = 60000;
31711
+ function isTimeout(err) {
31712
+ const seen = new Set;
31713
+ let current = err;
31714
+ while (current !== null && typeof current === "object" && !seen.has(current)) {
31715
+ seen.add(current);
31716
+ const { name, cause } = current;
31717
+ if (name === "TimeoutError" || name === "HeadersTimeoutError" || name === "BodyTimeoutError")
31718
+ return true;
31719
+ current = cause;
31720
+ }
31721
+ return false;
31722
+ }
31048
31723
 
31049
31724
  class OpencodeRuntime {
31050
31725
  #opts;
@@ -31071,7 +31746,10 @@ class OpencodeRuntime {
31071
31746
  } catch (err) {
31072
31747
  if (signal?.aborted)
31073
31748
  throw err;
31074
- throw new TransientError(`opencode server unreachable${this.#alive() ? "" : " (it exited)"}: ${err instanceof Error ? err.message : String(err)}`);
31749
+ const detail = err instanceof Error ? err.message : String(err);
31750
+ if (isTimeout(err))
31751
+ throw new TransientError(`opencode client timed out during ${method} ${path}${this.#alive() ? "" : " (the server exited)"}: ${detail}`);
31752
+ throw new TransientError(`opencode server unreachable${this.#alive() ? "" : " (it exited)"}: ${detail}`);
31075
31753
  }
31076
31754
  const text = await res.text();
31077
31755
  let json = text;
@@ -31097,7 +31775,7 @@ class OpencodeRuntime {
31097
31775
  }
31098
31776
  const password = randomBytes(18).toString("base64url");
31099
31777
  const port = await freePort();
31100
- mkdirSync8(join25(this.#opts.root, ".factory", "opencode"), { recursive: true });
31778
+ mkdirSync8(join26(this.#opts.root, ".factory", "opencode"), { recursive: true });
31101
31779
  this.#base = `http://127.0.0.1:${port}`;
31102
31780
  this.#auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
31103
31781
  this.#proc = spawn2(toolBin("opencode"), ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
@@ -31206,7 +31884,7 @@ class OpencodeRuntime {
31206
31884
  throw new Error(`opencode session create failed (${created.status}): ${JSON.stringify(created.data)}`);
31207
31885
  }
31208
31886
  const file = sessionContextFile(this.#opts.root, id);
31209
- mkdirSync8(join25(this.#opts.root, ".factory", "sessions"), { recursive: true });
31887
+ mkdirSync8(join26(this.#opts.root, ".factory", "sessions"), { recursive: true });
31210
31888
  writeFileSync8(file, JSON.stringify({ sessionId: id, ...spec.context }, null, 2));
31211
31889
  return {
31212
31890
  id,
@@ -31225,8 +31903,15 @@ class OpencodeRuntime {
31225
31903
  return cut === -1 ? messages : messages.slice(0, cut);
31226
31904
  }
31227
31905
  async#awaitTurn(id, marker, stop) {
31906
+ const pollMs = this.#opts.waitPollMs ?? WAIT_POLL_MS;
31228
31907
  for (;; ) {
31229
- await this.#api("POST", `/api/experimental/session/${id}/wait`, undefined, stop);
31908
+ const poll = AbortSignal.any([stop, AbortSignal.timeout(pollMs)]);
31909
+ try {
31910
+ await this.#api("POST", `/api/experimental/session/${id}/wait`, undefined, poll);
31911
+ } catch (err) {
31912
+ if (stop.aborted || !poll.aborted)
31913
+ throw err;
31914
+ }
31230
31915
  const all = await this.#recent(id);
31231
31916
  const fresh = this.#since(all, marker);
31232
31917
  const markerSeen = marker === undefined || all.some((m) => m.id === marker);
@@ -31254,7 +31939,9 @@ class OpencodeRuntime {
31254
31939
  throw err;
31255
31940
  interrupted = true;
31256
31941
  await this.#api("POST", `/api/session/${id}/interrupt`);
31257
- await this.#api("POST", `/api/experimental/session/${id}/wait`);
31942
+ try {
31943
+ await this.#api("POST", `/api/experimental/session/${id}/wait`, undefined, AbortSignal.timeout(this.#opts.waitPollMs ?? WAIT_POLL_MS));
31944
+ } catch {}
31258
31945
  messages = this.#since(await this.#recent(id), marker);
31259
31946
  }
31260
31947
  const after = await this.#session(id);
@@ -31425,8 +32112,8 @@ function gateExecutables(command) {
31425
32112
  }
31426
32113
  function onPath(name, dirs) {
31427
32114
  if (name.includes("/"))
31428
- return dirs.some((d) => existsSync20(join26(d, name)));
31429
- return dirs.some((d) => d !== "" && existsSync20(join26(d, name)));
32115
+ return dirs.some((d) => existsSync20(join27(d, name)));
32116
+ return dirs.some((d) => d !== "" && existsSync20(join27(d, name)));
31430
32117
  }
31431
32118
  function dirSize(path) {
31432
32119
  let total = 0;
@@ -31440,7 +32127,7 @@ function dirSize(path) {
31440
32127
  continue;
31441
32128
  }
31442
32129
  for (const name of entries) {
31443
- const child = join26(dir, name);
32130
+ const child = join27(dir, name);
31444
32131
  const st = lstatSync2(child, { throwIfNoEntry: false });
31445
32132
  if (st === undefined || st.isSymbolicLink())
31446
32133
  continue;
@@ -31558,12 +32245,12 @@ async function checkRepos(c, root, config, uninitialised) {
31558
32245
  }
31559
32246
  const checkouts = [{ name: "AI repo", dir: root }];
31560
32247
  for (const repo of config.repos) {
31561
- const dir = join26(root, repo.path);
32248
+ const dir = join27(root, repo.path);
31562
32249
  if (repo.path === ".") {
31563
32250
  ready.push(repo);
31564
32251
  continue;
31565
32252
  }
31566
- if (!existsSync20(join26(dir, ".git"))) {
32253
+ if (!existsSync20(join27(dir, ".git"))) {
31567
32254
  if (!uninitialised.includes(repo.path))
31568
32255
  c.add("warn", repo.name, `${repo.path} is not a git checkout`);
31569
32256
  continue;
@@ -31584,12 +32271,36 @@ async function checkRepos(c, root, config, uninitialised) {
31584
32271
  }
31585
32272
  return ready;
31586
32273
  }
32274
+ function packageScript(dir, name) {
32275
+ try {
32276
+ const pkg = JSON.parse(readFileSync18(join27(dir, "package.json"), "utf8"));
32277
+ const value = pkg.scripts?.[name];
32278
+ return typeof value === "string" ? value : undefined;
32279
+ } catch {
32280
+ return;
32281
+ }
32282
+ }
32283
+ var WRITE_FLAG = /(^|\s)(--write|--fix|--apply|-w)(\s|$)/;
32284
+ function checkWriteModeScripts(c, root, config, repos) {
32285
+ c.in("gates");
32286
+ for (const repo of repos.filter((r) => r.role === "code")) {
32287
+ const dir = join27(root, repo.path);
32288
+ for (const [type, name] of Object.entries(config.gates.scripts)) {
32289
+ if (typeof name !== "string")
32290
+ continue;
32291
+ const script = packageScript(dir, name);
32292
+ if (script !== undefined && WRITE_FLAG.test(script)) {
32293
+ c.add("warn", `${repo.name} ${type} script`, `\`${name}\` writes files: ${script}`, `point gates.scripts.${type} at a check-only variant, or set gates.overrides.${repo.name}.${type}`);
32294
+ }
32295
+ }
32296
+ }
32297
+ }
31587
32298
  function checkGates(c, root, config, repos) {
31588
32299
  c.in("gates");
31589
32300
  const rows = [];
31590
32301
  const pathDirs = (process.env.PATH ?? "").split(delimiter);
31591
32302
  for (const repo of repos.filter((r) => r.role === "code")) {
31592
- const dir = join26(root, repo.path);
32303
+ const dir = join27(root, repo.path);
31593
32304
  const plan = resolveGates({
31594
32305
  config,
31595
32306
  repo,
@@ -31604,11 +32315,12 @@ function checkGates(c, root, config, repos) {
31604
32315
  rows.push({
31605
32316
  repo: repo.name,
31606
32317
  type: g.type,
32318
+ ...g.name === undefined ? {} : { name: g.name },
31607
32319
  cwd: g.cwd,
31608
32320
  command: g.command,
31609
32321
  source: g.source
31610
32322
  });
31611
- const dirs = [...g.binDirs.map((b) => join26(dir, g.cwd, b)), join26(dir, g.cwd), ...pathDirs];
32323
+ const dirs = [...g.binDirs.map((b) => join27(dir, g.cwd, b)), join27(dir, g.cwd), ...pathDirs];
31612
32324
  for (const exe of gateExecutables(g.command))
31613
32325
  if (!onPath(exe, dirs))
31614
32326
  missingTools.add(exe);
@@ -31636,10 +32348,10 @@ function looseTsconfig(dir, gates) {
31636
32348
  const gate = gates.find((g) => g.type === "types");
31637
32349
  if (gate === undefined || /--strict\b/.test(gate.command))
31638
32350
  return;
31639
- const rel = join26(gate.cwd, "tsconfig.json");
32351
+ const rel = join27(gate.cwd, "tsconfig.json");
31640
32352
  let text;
31641
32353
  try {
31642
- text = readFileSync16(join26(dir, rel), "utf8");
32354
+ text = readFileSync18(join27(dir, rel), "utf8");
31643
32355
  } catch {
31644
32356
  return;
31645
32357
  }
@@ -31648,14 +32360,81 @@ function looseTsconfig(dir, gates) {
31648
32360
  return;
31649
32361
  return tsconfig.compilerOptions?.strict === true ? undefined : rel;
31650
32362
  }
32363
+ function checkContext(c, root, config) {
32364
+ c.in("context");
32365
+ const paths = allInstructionPaths(config);
32366
+ const missing = paths.filter((p) => !existsSync20(join27(root, p)));
32367
+ if (missing.length > 0)
32368
+ c.add("warn", "instructions", `missing: ${missing.join(", ")}`, "create the files or remove them from instructions");
32369
+ else
32370
+ c.add("ok", "instructions", `${paths.length} path(s) injected into sessions`);
32371
+ const names = [
32372
+ ...Object.values(config.skills.roles).flat(),
32373
+ ...Object.values(config.skills.repos).flat()
32374
+ ];
32375
+ if (names.length > 0) {
32376
+ const found = new Set;
32377
+ for (const source of config.skills.sources) {
32378
+ const dir = join27(root, source);
32379
+ if (!existsSync20(dir))
32380
+ continue;
32381
+ try {
32382
+ for (const name of readdirSync7(dir)) {
32383
+ const skill = join27(dir, name, "SKILL.md");
32384
+ if (existsSync20(skill))
32385
+ found.add(name);
32386
+ }
32387
+ } catch {}
32388
+ }
32389
+ const unknown = [...new Set(names)].filter((n) => !found.has(n));
32390
+ if (unknown.length > 0)
32391
+ c.add("warn", "skills", `not found in ${config.skills.sources.join(", ")}: ${unknown.join(", ")}`, "add the skill under .opencode/skills/ or remove it from skills.roles");
32392
+ else
32393
+ c.add("ok", "skills", `${new Set(names).size} skill(s) configured`);
32394
+ }
32395
+ const legacy = join27(root, ".factory", "legacy");
32396
+ if (existsSync20(legacy)) {
32397
+ const stamps = readdirSync7(legacy).sort();
32398
+ c.add("ok", "legacy setup", `${stamps.length} archived set(s) under .factory/legacy/`);
32399
+ }
32400
+ }
32401
+ function checkRelease(c, root, config) {
32402
+ c.in("release");
32403
+ if (!config.release.detect) {
32404
+ c.add("ok", "release", "detection disabled");
32405
+ return;
32406
+ }
32407
+ const found = [];
32408
+ for (const file of [
32409
+ ".release-please-config.json",
32410
+ ".goreleaser.yaml",
32411
+ ".goreleaser.yml",
32412
+ ".changeset/config.json",
32413
+ ".github/workflows",
32414
+ ".gitlab-ci.yml"
32415
+ ]) {
32416
+ if (existsSync20(join27(root, file)))
32417
+ found.push(file);
32418
+ }
32419
+ for (const repo of config.repos.filter((r) => r.path !== ".")) {
32420
+ for (const file of ["release-please-config.json", ".goreleaser.yaml", ".gitlab-ci.yml"]) {
32421
+ if (existsSync20(join27(root, repo.path, file)))
32422
+ found.push(`${repo.path}/${file}`);
32423
+ }
32424
+ }
32425
+ if (found.length === 0)
32426
+ c.add("ok", "release", "no release tooling detected");
32427
+ else
32428
+ c.add("ok", "release", `detected ${found.join(", ")}; commits stay local and the factory does not edit release workflows`);
32429
+ }
31651
32430
  function checkCaches(c, root) {
31652
32431
  c.in("caches");
31653
- const factory = join26(root, ".factory");
32432
+ const factory = join27(root, ".factory");
31654
32433
  for (const [name, path] of [
31655
- ["dependency caches", join26(factory, "cache")],
31656
- ["worktrees", join26(factory, "worktrees")],
31657
- ["logs", join26(factory, "logs")],
31658
- ["backups", join26(factory, "backups")]
32434
+ ["dependency caches", join27(factory, "cache")],
32435
+ ["worktrees", join27(factory, "worktrees")],
32436
+ ["logs", join27(factory, "logs")],
32437
+ ["backups", join27(factory, "backups")]
31659
32438
  ]) {
31660
32439
  if (!existsSync20(path))
31661
32440
  continue;
@@ -31664,11 +32443,11 @@ function checkCaches(c, root) {
31664
32443
  }
31665
32444
  async function checkModels(c, root, config) {
31666
32445
  c.in("models");
31667
- const configured = Object.entries(config.models).filter((e) => typeof e[1] === "string");
31668
- const tmp = mkdtempSync4(join26(tmpdir4(), "ai-factory-doctor-"));
32446
+ const configured = modelEntries(config);
32447
+ const tmp = mkdtempSync4(join27(tmpdir4(), "ai-factory-doctor-"));
31669
32448
  const res = await exec([toolBin("opencode"), "models", "--standalone"], {
31670
32449
  cwd: root,
31671
- env: { ...serverEnv(root, config, "doctor"), OPENCODE_DB: join26(tmp, "opencode.db") },
32450
+ env: { ...serverEnv(root, config, "doctor"), OPENCODE_DB: join27(tmp, "opencode.db") },
31672
32451
  replaceEnv: true,
31673
32452
  timeoutMs: 60000
31674
32453
  }).finally(() => rmSync8(tmp, { recursive: true, force: true }));
@@ -31684,7 +32463,7 @@ async function checkModels(c, root, config) {
31684
32463
  if (available.has(id))
31685
32464
  c.add("ok", role, model);
31686
32465
  else
31687
- c.add("fail", role, `${model} is not available to opencode`, `set the provider's credentials (env or \`opencode auth login\`) or change models.${role}`);
32466
+ c.add("fail", role, `${model} is not available to opencode`, `set the provider's credentials (env or \`opencode auth login\`) or change the model in factory.config.jsonc`);
31688
32467
  }
31689
32468
  }
31690
32469
  var CODE_EXTENSIONS = new Set(Object.values(PROFILES).flatMap((p) => p.extensions));
@@ -31799,7 +32578,10 @@ async function runDoctor(root) {
31799
32578
  checkEnvironment(c, process.env);
31800
32579
  checkManaged(c, root, config);
31801
32580
  const repos = await checkRepos(c, root, config, uninitialised);
32581
+ checkWriteModeScripts(c, root, config, repos);
31802
32582
  const gates = checkGates(c, root, config, repos);
32583
+ checkContext(c, root, config);
32584
+ checkRelease(c, root, config);
31803
32585
  checkCaches(c, root);
31804
32586
  await checkModels(c, root, config);
31805
32587
  await checkGraph(c, root, config, lock);
@@ -31822,11 +32604,12 @@ function print(io, report) {
31822
32604
  }
31823
32605
  if (report.gates.length > 0) {
31824
32606
  io.out("gate table (full mode; per task, scoped commands and affected tests are used)");
31825
- const width = Math.max(...report.gates.map((g) => `${g.repo} ${g.type}`.length));
32607
+ const label = (g) => `${g.repo} ${g.name ?? g.type}`;
32608
+ const width = Math.max(...report.gates.map((g) => label(g).length));
31826
32609
  for (const g of report.gates) {
31827
32610
  const where = g.cwd === "." ? "" : ` [${g.cwd}]`;
31828
32611
  const what = g.command ? `${g.command} (${g.source})` : g.problem ?? "";
31829
- io.out(` ${`${g.repo} ${g.type}`.padEnd(width)}${where} ${what}`);
32612
+ io.out(` ${label(g).padEnd(width)}${where} ${what}`);
31830
32613
  }
31831
32614
  }
31832
32615
  const fails = report.checks.filter((c) => c.status === "fail").length;
@@ -31878,14 +32661,14 @@ Exit code ${ExitCode.Precondition} when a check fails; warnings exit 0.`,
31878
32661
 
31879
32662
  // src/cli/commands/gate.ts
31880
32663
  import { existsSync as existsSync25, readdirSync as readdirSync9 } from "node:fs";
31881
- import { join as join33 } from "node:path";
32664
+ import { join as join34 } from "node:path";
31882
32665
 
31883
32666
  // src/gates/run.ts
31884
- import { delimiter as delimiter2, join as join30 } from "node:path";
32667
+ import { delimiter as delimiter2, join as join31 } from "node:path";
31885
32668
 
31886
32669
  // src/addons/shortcuts.ts
31887
- import { readFileSync as readFileSync17 } from "node:fs";
31888
- import { join as join27 } from "node:path";
32670
+ import { readFileSync as readFileSync19 } from "node:fs";
32671
+ import { join as join28 } from "node:path";
31889
32672
  var SHORTCUT = /(?:\/\/|#|\/\*|\*|--|<!--|;)\s*(?:shortcut|ponytail):\s*(.+?)\s*(?:\*\/|-->)?\s*$/i;
31890
32673
  function shortcutIn(line) {
31891
32674
  return line.match(SHORTCUT)?.[1];
@@ -31922,7 +32705,7 @@ async function newShortcuts(dir, base) {
31922
32705
  `).filter(Boolean)) {
31923
32706
  let text;
31924
32707
  try {
31925
- text = readFileSync17(join27(dir, file), "utf8");
32708
+ text = readFileSync19(join28(dir, file), "utf8");
31926
32709
  } catch {
31927
32710
  continue;
31928
32711
  }
@@ -31990,8 +32773,8 @@ function compactOutput(raw, maxLines) {
31990
32773
 
31991
32774
  // src/gates/guard.ts
31992
32775
  import { createHash as createHash3 } from "node:crypto";
31993
- import { existsSync as existsSync21, mkdirSync as mkdirSync9, readFileSync as readFileSync18, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "node:fs";
31994
- import { dirname as dirname6, join as join28 } from "node:path";
32776
+ import { existsSync as existsSync21, mkdirSync as mkdirSync9, readFileSync as readFileSync20, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "node:fs";
32777
+ import { dirname as dirname6, join as join29 } from "node:path";
31995
32778
  var MISSING = "missing";
31996
32779
  async function dirtyPaths2(worktree) {
31997
32780
  const res = await git(["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], {
@@ -32004,13 +32787,13 @@ async function dirtyPaths2(worktree) {
32004
32787
  function hashFile(abs) {
32005
32788
  if (!existsSync21(abs))
32006
32789
  return { hash: MISSING };
32007
- const content = readFileSync18(abs);
32790
+ const content = readFileSync20(abs);
32008
32791
  return { hash: createHash3("sha256").update(content).digest("hex"), content };
32009
32792
  }
32010
32793
  async function snapshot(worktree) {
32011
32794
  const entries = new Map;
32012
32795
  for (const path of await dirtyPaths2(worktree))
32013
- entries.set(path, hashFile(join28(worktree, path)));
32796
+ entries.set(path, hashFile(join29(worktree, path)));
32014
32797
  return { worktree, head: await gitOk(["rev-parse", "HEAD"], { cwd: worktree }), entries };
32015
32798
  }
32016
32799
  async function changedSince(before) {
@@ -32033,7 +32816,7 @@ async function restore(before, paths) {
32033
32816
  await gitOk(["reset", "-q", "--mixed", before.head], { cwd: before.worktree });
32034
32817
  }
32035
32818
  for (const path of paths.filter((p) => p !== "HEAD")) {
32036
- const abs = join28(before.worktree, path);
32819
+ const abs = join29(before.worktree, path);
32037
32820
  const entry = before.entries.get(path);
32038
32821
  if (entry === undefined) {
32039
32822
  const tracked = await gitOk(["ls-files", "--", path], { cwd: before.worktree });
@@ -32052,19 +32835,19 @@ async function restore(before, paths) {
32052
32835
 
32053
32836
  // src/gates/install.ts
32054
32837
  import { existsSync as existsSync22, mkdirSync as mkdirSync10 } from "node:fs";
32055
- import { join as join29 } from "node:path";
32838
+ import { join as join30 } from "node:path";
32056
32839
  function cacheEnv(root, repoName) {
32057
- const cache = join29(root, ".factory", "cache");
32840
+ const cache = join30(root, ".factory", "cache");
32058
32841
  const env = {
32059
- npm_config_cache: join29(cache, "npm"),
32060
- npm_config_store_dir: join29(cache, "pnpm-store"),
32061
- BUN_INSTALL_CACHE_DIR: join29(cache, "bun"),
32062
- YARN_CACHE_FOLDER: join29(cache, "yarn"),
32063
- UV_CACHE_DIR: join29(cache, "uv"),
32064
- GOMODCACHE: join29(cache, "go", "mod"),
32065
- GOCACHE: join29(cache, "go", "build"),
32066
- GOLANGCI_LINT_CACHE: join29(cache, "golangci-lint"),
32067
- CARGO_TARGET_DIR: join29(cache, "cargo-target", repoName)
32842
+ npm_config_cache: join30(cache, "npm"),
32843
+ npm_config_store_dir: join30(cache, "pnpm-store"),
32844
+ BUN_INSTALL_CACHE_DIR: join30(cache, "bun"),
32845
+ YARN_CACHE_FOLDER: join30(cache, "yarn"),
32846
+ UV_CACHE_DIR: join30(cache, "uv"),
32847
+ GOMODCACHE: join30(cache, "go", "mod"),
32848
+ GOCACHE: join30(cache, "go", "build"),
32849
+ GOLANGCI_LINT_CACHE: join30(cache, "golangci-lint"),
32850
+ CARGO_TARGET_DIR: join30(cache, "cargo-target", repoName)
32068
32851
  };
32069
32852
  for (const dir of Object.values(env))
32070
32853
  mkdirSync10(dir, { recursive: true });
@@ -32083,7 +32866,7 @@ async function installDeps(root, worktree, repo, timeoutMs = 15 * 60000) {
32083
32866
  const done = new Set;
32084
32867
  for (const profile of profilesFor(repoLanguages(repo))) {
32085
32868
  for (const dir of installDirs(profile, files)) {
32086
- const step = profile.install.find((s) => existsSync22(join29(worktree, dir, s.lockfile)));
32869
+ const step = profile.install.find((s) => existsSync22(join30(worktree, dir, s.lockfile)));
32087
32870
  const key = `${dir}\x00${step?.command ?? profile.id}`;
32088
32871
  if (done.has(key))
32089
32872
  continue;
@@ -32098,7 +32881,7 @@ async function installDeps(root, worktree, repo, timeoutMs = 15 * 60000) {
32098
32881
  continue;
32099
32882
  }
32100
32883
  const res = await exec(["sh", "-c", step.command], {
32101
- cwd: join29(worktree, dir),
32884
+ cwd: join30(worktree, dir),
32102
32885
  env,
32103
32886
  replaceEnv: true,
32104
32887
  timeoutMs
@@ -32120,22 +32903,30 @@ var FAILING = new Set(["fail", "missing", "not-check-only", "timeout"]);
32120
32903
  var TEST_GATES = new Set(["test", "testAffected"]);
32121
32904
  async function runOne(input, gate) {
32122
32905
  const before = await snapshot(input.worktree);
32123
- const bins = gate.binDirs.map((b) => join30(input.worktree, b));
32906
+ const bins = gate.binDirs.map((b) => join31(input.worktree, b));
32124
32907
  const env = {
32125
32908
  ...agentProcessEnv(),
32126
32909
  ...cacheEnv(input.root, input.repo.name),
32127
32910
  ...QUIET_ENV,
32911
+ ...gate.env ?? {},
32128
32912
  PATH: [...bins, process.env.PATH ?? ""].join(delimiter2)
32129
32913
  };
32130
- const argv = ["sh", "-c", gate.command];
32131
- const res = await exec(sandboxEnabled(input.config.security.sandbox) ? sandboxArgv(seatbeltProfile(checkoutPolicy(input.root, input.worktree, [join30(input.root, ".factory", "cache")])), argv) : argv, {
32132
- cwd: join30(input.worktree, gate.cwd),
32914
+ const argv = [
32915
+ "sh",
32916
+ "-c",
32917
+ gate.setup === undefined ? gate.command : `set -e
32918
+ ${gate.setup}
32919
+ ${gate.command}`
32920
+ ];
32921
+ const res = await exec(sandboxEnabled(input.config.security.sandbox) ? sandboxArgv(seatbeltProfile(checkoutPolicy(input.root, input.worktree, [join31(input.root, ".factory", "cache")])), argv) : argv, {
32922
+ cwd: join31(input.worktree, gate.cwd),
32133
32923
  env,
32134
32924
  replaceEnv: true,
32135
- timeoutMs: input.timeoutMs ?? 15 * 60000
32925
+ timeoutMs: gate.timeoutMs ?? input.timeoutMs ?? 15 * 60000
32136
32926
  });
32137
32927
  const base = {
32138
32928
  type: gate.type,
32929
+ ...gate.name === undefined ? {} : { name: gate.name },
32139
32930
  cwd: gate.cwd,
32140
32931
  command: gate.command,
32141
32932
  source: gate.source,
@@ -32143,8 +32934,46 @@ async function runOne(input, gate) {
32143
32934
  durationMs: res.durationMs
32144
32935
  };
32145
32936
  const max = input.config.tokens.gateOutputMaxLines;
32937
+ const raw = `${res.stdout}
32938
+ ${res.stderr}`;
32939
+ const output = redactProcess(compactOutput(raw, max));
32146
32940
  const modified = await changedSince(before);
32147
- if (modified.length > 0) {
32941
+ const outputs = gate.outputs ?? [];
32942
+ if (outputs.length > 0) {
32943
+ const drifted = modified.filter((path) => outputs.some((glob) => opencodeGlobRegExp(glob).test(path)));
32944
+ if (modified.length > 0)
32945
+ await restore(before, modified);
32946
+ if (res.timedOut)
32947
+ return { ...base, status: "timeout", output: `gate timed out: \`${gate.command}\`` };
32948
+ if (res.code !== 0)
32949
+ return { ...base, status: "fail", output: output || `gate failed: \`${gate.command}\`` };
32950
+ if (modified.includes("HEAD")) {
32951
+ return {
32952
+ ...base,
32953
+ status: "fail",
32954
+ output: `generated gate moved HEAD: \`${gate.command}\` must not commit (restored)`
32955
+ };
32956
+ }
32957
+ const extraWrites = modified.filter((path) => !drifted.includes(path));
32958
+ if (extraWrites.length > 0) {
32959
+ return {
32960
+ ...base,
32961
+ status: "fail",
32962
+ modified: extraWrites,
32963
+ output: `generated gate wrote outside its outputs: ${extraWrites.join(", ")} (restored)`
32964
+ };
32965
+ }
32966
+ if (drifted.length > 0) {
32967
+ return {
32968
+ ...base,
32969
+ status: "fail",
32970
+ modified: drifted,
32971
+ output: `generated artifacts are stale: ${drifted.join(", ")} (regenerate and commit them)`
32972
+ };
32973
+ }
32974
+ return { ...base, status: "pass", output: "" };
32975
+ }
32976
+ if (modified.length > 0 && gate.checkOnly !== false) {
32148
32977
  await restore(before, modified);
32149
32978
  return {
32150
32979
  ...base,
@@ -32155,12 +32984,10 @@ async function runOne(input, gate) {
32155
32984
  }
32156
32985
  if (res.timedOut)
32157
32986
  return { ...base, status: "timeout", output: `gate timed out: \`${gate.command}\`` };
32158
- const raw = `${res.stdout}
32159
- ${res.stderr}`;
32160
32987
  return {
32161
32988
  ...base,
32162
32989
  status: res.code === 0 ? "pass" : "fail",
32163
- output: res.code === 0 ? "" : redactProcess(compactOutput(raw, max))
32990
+ output: res.code === 0 ? "" : output
32164
32991
  };
32165
32992
  }
32166
32993
  function relatedFiles(root, repoPath, changed) {
@@ -32243,13 +33070,13 @@ async function runGates(input) {
32243
33070
 
32244
33071
  // src/git/worktrees.ts
32245
33072
  import { existsSync as existsSync23, mkdirSync as mkdirSync11 } from "node:fs";
32246
- import { dirname as dirname7, join as join31 } from "node:path";
33073
+ import { dirname as dirname7, join as join32 } from "node:path";
32247
33074
  var taskBranch = (id, task) => `${changeBranch(id)}--${task}`;
32248
- var worktreePath = (root, id, repo, task) => join31(root, ".factory", "worktrees", id, repo, task);
33075
+ var worktreePath = (root, id, repo, task) => join32(root, ".factory", "worktrees", id, repo, task);
32249
33076
  async function addTaskWorktree(root, repoDir, repoName, id, task) {
32250
33077
  const path = worktreePath(root, id, repoName, task);
32251
33078
  const branch = taskBranch(id, task);
32252
- if (existsSync23(join31(path, ".git")))
33079
+ if (existsSync23(join32(path, ".git")))
32253
33080
  return { path, branch, created: false };
32254
33081
  mkdirSync11(dirname7(path), { recursive: true });
32255
33082
  await gitOk(["worktree", "prune"], { cwd: repoDir });
@@ -32268,20 +33095,27 @@ async function removeTaskWorktree(repoDir, wt) {
32268
33095
  }
32269
33096
 
32270
33097
  // src/update/snapshot.ts
32271
- import { cpSync, existsSync as existsSync24, mkdirSync as mkdirSync12, readdirSync as readdirSync8, readFileSync as readFileSync19, rmSync as rmSync10 } from "node:fs";
32272
- import { join as join32 } from "node:path";
32273
- var gateFile = (root, id) => join32(root, ".factory", "runs", id, "snapshot", "gates.json");
33098
+ import { cpSync, existsSync as existsSync24, mkdirSync as mkdirSync12, readdirSync as readdirSync8, readFileSync as readFileSync21, rmSync as rmSync10 } from "node:fs";
33099
+ import { join as join33 } from "node:path";
33100
+ var gateFile = (root, id) => join33(root, ".factory", "runs", id, "snapshot", "gates.json");
32274
33101
  function snapshotDefinitions(root, id, config, factoryVersion, replace = false) {
32275
- const agents = join32(root, ".opencode", "agents");
32276
- const target = join32(root, ".factory", "worktrees", id, ".opencode", "agents");
33102
+ const snapshotRoot = join33(root, ".factory", "worktrees", id, ".opencode");
33103
+ const agents = join33(root, ".opencode", "agents");
33104
+ const target = join33(snapshotRoot, "agents");
33105
+ const skills = join33(root, ".opencode", "skills");
33106
+ const skillTarget = join33(snapshotRoot, "skills");
32277
33107
  if (replace) {
32278
- rmSync10(target, { recursive: true, force: true });
33108
+ rmSync10(snapshotRoot, { recursive: true, force: true });
32279
33109
  rmSync10(gateFile(root, id), { force: true });
32280
33110
  }
32281
33111
  if (existsSync24(agents) && !existsSync24(target)) {
32282
33112
  mkdirSync12(target, { recursive: true });
32283
33113
  cpSync(agents, target, { recursive: true });
32284
33114
  }
33115
+ if (existsSync24(skills) && !existsSync24(skillTarget)) {
33116
+ mkdirSync12(skillTarget, { recursive: true });
33117
+ cpSync(skills, skillTarget, { recursive: true });
33118
+ }
32285
33119
  if (!existsSync24(gateFile(root, id))) {
32286
33120
  const tables = Object.fromEntries(Object.keys(PROFILES).map((l) => [l, PROFILES[l].gates]));
32287
33121
  writeJsonAtomic(gateFile(root, id), {
@@ -32293,15 +33127,15 @@ function snapshotDefinitions(root, id, config, factoryVersion, replace = false)
32293
33127
  }
32294
33128
  function loadGateSnapshot(root, id) {
32295
33129
  const file = gateFile(root, id);
32296
- return existsSync24(file) ? JSON.parse(readFileSync19(file, "utf8")) : undefined;
33130
+ return existsSync24(file) ? JSON.parse(readFileSync21(file, "utf8")) : undefined;
32297
33131
  }
32298
33132
  function dropAgentSnapshot(root, id) {
32299
- const dir = join32(root, ".factory", "worktrees", id);
32300
- rmSync10(join32(dir, ".opencode"), { recursive: true, force: true });
33133
+ const dir = join33(root, ".factory", "worktrees", id);
33134
+ rmSync10(join33(dir, ".opencode"), { recursive: true, force: true });
32301
33135
  if (!existsSync24(dir))
32302
33136
  return;
32303
33137
  for (const repo of readdirSync8(dir)) {
32304
- const repoDir = join32(dir, repo);
33138
+ const repoDir = join33(dir, repo);
32305
33139
  if (readdirSync8(repoDir).length === 0)
32306
33140
  rmSync10(repoDir, { recursive: true });
32307
33141
  }
@@ -32372,7 +33206,7 @@ async function baseOf2(dir, ref) {
32372
33206
  return res.stdout.trim();
32373
33207
  }
32374
33208
  function findTaskWorktree(root, id, task, repoName) {
32375
- const changeDir = join33(root, ".factory", "worktrees", id);
33209
+ const changeDir = join34(root, ".factory", "worktrees", id);
32376
33210
  const repos = repoName ? [repoName] : existsSync25(changeDir) ? readdirSync9(changeDir) : [];
32377
33211
  const hits = repos.map((r) => ({ repo: r, dir: worktreePath(root, id, r, task) })).filter((h) => existsSync25(h.dir));
32378
33212
  const hit = hits[0];
@@ -32387,7 +33221,7 @@ function printReport(io, report) {
32387
33221
  const where = r.cwd === "." ? "" : ` [${r.cwd}]`;
32388
33222
  const what = r.command ? ` ${r.command}` : "";
32389
33223
  const time = r.durationMs > 0 ? ` ${(r.durationMs / 1000).toFixed(1)}s` : "";
32390
- io.out(` ${MARK3[r.status]} ${r.type}${where}${what}${time}${r.status === "skipped" ? ` (${r.output})` : ""}`);
33224
+ io.out(` ${MARK3[r.status]} ${r.name ?? r.type}${where}${what}${time}${r.status === "skipped" ? ` (${r.output})` : ""}`);
32391
33225
  if (r.status !== "pass" && r.status !== "skipped" && r.output) {
32392
33226
  for (const line of r.output.split(`
32393
33227
  `))
@@ -32436,7 +33270,7 @@ var gateCommand = {
32436
33270
  } else {
32437
33271
  const repos = values.repo !== undefined ? [pick(values.repo)] : codeRepos;
32438
33272
  for (const repo of repos) {
32439
- const dir = join33(root, repo.path);
33273
+ const dir = join34(root, repo.path);
32440
33274
  const branch = (await git(["branch", "--show-current"], { cwd: dir })).stdout.trim();
32441
33275
  if (branch !== `change/${id}`) {
32442
33276
  if (values.repo !== undefined) {
@@ -32471,7 +33305,7 @@ var gateCommand = {
32471
33305
 
32472
33306
  // src/cli/commands/graph.ts
32473
33307
  import { existsSync as existsSync26 } from "node:fs";
32474
- import { join as join34 } from "node:path";
33308
+ import { join as join35 } from "node:path";
32475
33309
  var graphCommand = {
32476
33310
  name: "graph",
32477
33311
  summary: "Build or incrementally update the workspace graph",
@@ -32488,7 +33322,7 @@ The pipeline also updates it after every task merge, so this is rarely needed by
32488
33322
  const root = await findWorkspaceRoot(io.cwd);
32489
33323
  if (root === undefined)
32490
33324
  throw new PreconditionError("not inside a git repository");
32491
- if (!existsSync26(join34(root, LOCK_FILE)))
33325
+ if (!existsSync26(join35(root, LOCK_FILE)))
32492
33326
  throw new PreconditionError("ai-factory is not installed here", ["run `ai-factory init`"]);
32493
33327
  const tool = await checkTool("graphify", root);
32494
33328
  if (!tool.ok)
@@ -32508,8 +33342,8 @@ The pipeline also updates it after every task merge, so this is rarely needed by
32508
33342
  };
32509
33343
 
32510
33344
  // src/cli/commands/init.ts
32511
- import { existsSync as existsSync27, writeFileSync as writeFileSync10 } from "node:fs";
32512
- import { join as join35 } from "node:path";
33345
+ import { existsSync as existsSync28, writeFileSync as writeFileSync10 } from "node:fs";
33346
+ import { join as join37 } from "node:path";
32513
33347
 
32514
33348
  // src/config/render.ts
32515
33349
  var DEFAULT_MODELS = {
@@ -32521,6 +33355,72 @@ var DEFAULT_MODELS = {
32521
33355
  explorer: "anthropic/claude-haiku-5-5"
32522
33356
  };
32523
33357
  var json = (value) => JSON.stringify(value);
33358
+ function suggestedExtraGates(detection) {
33359
+ const out = [];
33360
+ for (const repo of detection.repos) {
33361
+ if (repo.languages.includes("tauri")) {
33362
+ out.push({
33363
+ name: "ios-compile",
33364
+ type: "types",
33365
+ phases: ["task", "full"],
33366
+ when: { changed: ["src-tauri/**"] },
33367
+ command: "cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-apple-ios"
33368
+ });
33369
+ }
33370
+ }
33371
+ for (const suite of detection.e2e) {
33372
+ out.push({
33373
+ name: `e2e-${suite.repo}`,
33374
+ type: "test",
33375
+ phases: ["full"],
33376
+ command: suite.command
33377
+ });
33378
+ }
33379
+ const wholeRepo = new Set(["docs", "kustomize", "chart", "verify"]);
33380
+ for (const repo of detection.tasks) {
33381
+ for (const name of repo.names) {
33382
+ if (!wholeRepo.has(name))
33383
+ continue;
33384
+ out.push({
33385
+ name: `${name}-${repo.repo}`,
33386
+ type: "test",
33387
+ phases: ["full"],
33388
+ command: `task ${name}`
33389
+ });
33390
+ }
33391
+ }
33392
+ for (const contract of detection.contracts) {
33393
+ if (contract.command === undefined)
33394
+ continue;
33395
+ if (contract.kind === "ipc") {
33396
+ out.push({
33397
+ name: "generated-bindings",
33398
+ type: "test",
33399
+ phases: ["task", "full"],
33400
+ when: { changed: ["src-tauri/**", "src/**"] },
33401
+ command: contract.command,
33402
+ outputs: contract.outputs ?? [contract.path]
33403
+ });
33404
+ } else if (contract.kind === "crd") {
33405
+ out.push({
33406
+ name: "generated-crd",
33407
+ type: "test",
33408
+ phases: ["full"],
33409
+ command: contract.command,
33410
+ outputs: contract.outputs ?? [contract.path]
33411
+ });
33412
+ } else if (contract.kind === "generated") {
33413
+ out.push({
33414
+ name: "generated-artifacts",
33415
+ type: "test",
33416
+ phases: ["full"],
33417
+ command: contract.command,
33418
+ outputs: contract.outputs ?? [contract.path]
33419
+ });
33420
+ }
33421
+ }
33422
+ return out;
33423
+ }
32524
33424
  function renderInitialConfig(detection) {
32525
33425
  const repos = detection.repos.map((r) => ` ${json(toRepoConfig(r))}`).join(`,
32526
33426
  `);
@@ -32541,16 +33441,29 @@ ${repos}
32541
33441
  ],
32542
33442
 
32543
33443
  // opencode model ids ("provider/model") per role. contractReviewer defaults to reviewer.
33444
+ // "roles", "repos" and "agents" can override these; the flat entries stay the fallback.
32544
33445
  "models": {
32545
33446
  ${models}
32546
33447
  },
32547
33448
 
33449
+ // Project bars injected into every session. Add docs/security.md, docs/go.md, etc.
33450
+ "instructions": ${json(detection.instructions)},
33451
+ // Skills the agents should load first, from .opencode/skills or a custom source.
33452
+ "skills": { "sources": [".opencode/skills"], "roles": {}, "repos": {} },
33453
+ // MCP tool action patterns per role (e.g. "reviewer": ["mantine_*"]).
33454
+ "mcp": { "roles": {} },
33455
+ // Optional specialist agents and task routing by touched paths.
33456
+ "agents": [],
33457
+ "routing": [],
33458
+
32548
33459
  "gates": {
32549
33460
  "maxRetries": 3,
32550
33461
  // Gate type -> script/target name looked up in package.json, Makefile, justfile, Taskfile.
32551
33462
  "scripts": { "format": "format:check", "lint": "lint", "types": "typecheck", "test": "test", "testAffected": "test:affected" },
32552
33463
  // Repo name -> gate type -> literal command; wins over scripts and profile defaults.
32553
- "overrides": {}
33464
+ "overrides": {},
33465
+ // Detected stack gates: iOS compile and generated-artifact drift. Edit or remove freely.
33466
+ "extra": ${json(suggestedExtraGates(detection))}
32554
33467
  },
32555
33468
 
32556
33469
  // Proposed by detection: confirm providers and consumers.
@@ -32573,6 +33486,12 @@ ${contracts}
32573
33486
  "graph": { "exclude": ${json(DEFAULT_GRAPH_EXCLUDE)} },
32574
33487
  // Agent shells and gates run in an OS sandbox where available ("auto"; macOS sandbox-exec).
32575
33488
  "security": { "sandbox": "auto" },
33489
+ // Task commits: "conventional" or "gitmoji"; scopes prefixes the repo name.
33490
+ "commits": { "style": "conventional", "scopes": false },
33491
+ // Clarification before design and the no-spec quick task.
33492
+ "workflow": { "refine": true, "quick": { "enabled": true, "branchPrefix": "task/" } },
33493
+ // Detect release-please, goreleaser, changesets and forge CI (read-only).
33494
+ "release": { "detect": true },
32576
33495
 
32577
33496
  // Managed files the factory must never overwrite (workspace-relative paths).
32578
33497
  "unmanaged": []
@@ -32580,9 +33499,51 @@ ${contracts}
32580
33499
  `;
32581
33500
  }
32582
33501
 
33502
+ // src/workspace/legacy.ts
33503
+ import { existsSync as existsSync27, mkdirSync as mkdirSync13, readdirSync as readdirSync10, renameSync as renameSync6, rmdirSync as rmdirSync2 } from "node:fs";
33504
+ import { dirname as dirname8, join as join36 } from "node:path";
33505
+ var OPENSPEC_PREFIX = /^(opsx-|openspec-)/;
33506
+ function move(root, rel, stamp, archived) {
33507
+ const from = join36(root, rel);
33508
+ if (!existsSync27(from))
33509
+ return;
33510
+ const to = join36(root, ".factory", "legacy", stamp, rel);
33511
+ mkdirSync13(dirname8(to), { recursive: true });
33512
+ renameSync6(from, to);
33513
+ archived.push(rel);
33514
+ }
33515
+ function moveEntries(root, dir, keep, stamp, archived) {
33516
+ const abs = join36(root, dir);
33517
+ if (!existsSync27(abs))
33518
+ return;
33519
+ for (const name of readdirSync10(abs)) {
33520
+ if (keep(name))
33521
+ continue;
33522
+ move(root, `${dir}/${name}`, stamp, archived);
33523
+ }
33524
+ try {
33525
+ if (readdirSync10(abs).length === 0)
33526
+ rmdirSync2(abs);
33527
+ } catch {}
33528
+ }
33529
+ function archiveLegacySetups(root, repoPaths) {
33530
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
33531
+ const archived = [];
33532
+ const dirs = [".opencode", ...repoPaths.filter((p) => p !== ".").map((p) => `${p}/.opencode`)];
33533
+ for (const base of dirs) {
33534
+ for (const name of ["agent", "agents"]) {
33535
+ moveEntries(root, `${base}/${name}`, () => false, stamp, archived);
33536
+ }
33537
+ moveEntries(root, `${base}/commands`, (n) => OPENSPEC_PREFIX.test(n), stamp, archived);
33538
+ moveEntries(root, `${base}/command`, () => false, stamp, archived);
33539
+ moveEntries(root, `${base}/skills`, (n) => OPENSPEC_PREFIX.test(n), stamp, archived);
33540
+ }
33541
+ return { stamp, archived };
33542
+ }
33543
+
32583
33544
  // src/cli/commands/init.ts
32584
33545
  var OPENSPEC_CONFIG = "openspec/config.yaml";
32585
- var HELP4 = `Usage: ai-factory init [--force] [--allow-downgrade] [--no-commit] [--no-graph] [--json]
33546
+ var HELP4 = `Usage: ai-factory init [--plan] [--force] [--allow-downgrade] [--no-commit] [--no-graph] [--json]
32586
33547
 
32587
33548
  Installs the factory into the workspace: detects repos, languages and contracts, checks
32588
33549
  the tools, runs \`openspec init\` for opencode, renders agents, standards and config,
@@ -32591,6 +33552,7 @@ and commits the result in the AI repo. Safe to run again: an unchanged workspace
32591
33552
  no diff and no commit.
32592
33553
 
32593
33554
  Options:
33555
+ --plan Show what would be created, archived or replaced; write nothing
32594
33556
  --force Overwrite existing files that ai-factory did not create
32595
33557
  --allow-downgrade Let this (older) version rewrite a workspace a newer one installed
32596
33558
  --no-commit Leave the changes uncommitted
@@ -32617,6 +33579,7 @@ var initCommand = {
32617
33579
  "allow-downgrade": { type: "boolean", default: false },
32618
33580
  "no-commit": { type: "boolean", default: false },
32619
33581
  "no-graph": { type: "boolean", default: false },
33582
+ plan: { type: "boolean", default: false },
32620
33583
  json: { type: "boolean", default: false }
32621
33584
  });
32622
33585
  const root = await findWorkspaceRoot(io.cwd);
@@ -32635,7 +33598,7 @@ var initCommand = {
32635
33598
  if (missing.length > 0) {
32636
33599
  throw new PreconditionError("required tools are missing or unsupported", missing.map((t) => t.ok ? "" : `${t.tool}: ${t.problem} → ${t.fix}`));
32637
33600
  }
32638
- if (!values["no-commit"]) {
33601
+ if (!values.plan && !values["no-commit"]) {
32639
33602
  const dirty = await dirtyInstallPaths(root, [CONFIG_FILE]);
32640
33603
  if (dirty.length > 0) {
32641
33604
  throw new PreconditionError("uncommitted changes in files that init commits", [
@@ -32644,14 +33607,33 @@ var initCommand = {
32644
33607
  ]);
32645
33608
  }
32646
33609
  }
32647
- const configPath = join35(root, CONFIG_FILE);
32648
- const newConfig = !existsSync27(configPath);
33610
+ const configPath = join37(root, CONFIG_FILE);
33611
+ const newConfig = !existsSync28(configPath);
32649
33612
  const detection = newConfig ? await detectWorkspace(root) : undefined;
32650
- const keepOpenspec = newConfig && !values.force && existsSync27(join35(root, OPENSPEC_CONFIG)) && readLock(root) === undefined;
33613
+ const legacy = !values.plan && readLock(root) === undefined && detection !== undefined ? archiveLegacySetups(root, detection.repos.map((r) => r.path)) : undefined;
33614
+ const keepOpenspec = newConfig && !values.force && existsSync28(join37(root, OPENSPEC_CONFIG)) && readLock(root) === undefined;
32651
33615
  const rendered = detection !== undefined ? renderInitialConfig(detection) : undefined;
32652
33616
  const configText = rendered !== undefined && keepOpenspec ? setJsonc(rendered, ["unmanaged"], [OPENSPEC_CONFIG]) : rendered;
32653
33617
  const config = configText !== undefined ? parseConfigText(configText, CONFIG_FILE) : loadConfig(root).config;
32654
33618
  const openspecFiles = await renderOpenspecTools(root);
33619
+ if (values.plan) {
33620
+ const planned = planSync(root, [...renderWorkspace(config, { root }), ...openspecFiles], readLock(root)?.managed ?? {}, { unmanaged: config.unmanaged, force: values.force });
33621
+ if (values.json) {
33622
+ io.out(JSON.stringify({
33623
+ root,
33624
+ newConfig,
33625
+ notes: detection?.notes ?? [],
33626
+ plan: planned.map(({ path, action }) => ({ path, action }))
33627
+ }, null, 2));
33628
+ } else {
33629
+ io.out(`ai-factory ${FACTORY_VERSION} plan for ${root}`);
33630
+ if (newConfig)
33631
+ io.out(`would write ${CONFIG_FILE}`);
33632
+ for (const change of planned)
33633
+ io.out(`${REPORTED[change.action] ?? change.action}: ${change.path}`);
33634
+ }
33635
+ return ExitCode.Ok;
33636
+ }
32655
33637
  planWorkspace(root, config, { force: values.force, tools: openspecFiles });
32656
33638
  if (configText !== undefined)
32657
33639
  writeFileSync10(configPath, configText);
@@ -32660,7 +33642,7 @@ var initCommand = {
32660
33642
  tools: openspecFiles
32661
33643
  });
32662
33644
  const commit = values["no-commit"] ? undefined : await commitInstall(root, `chore(factory): init v${FACTORY_VERSION}`);
32663
- const graph = values["no-graph"] ? undefined : await updateGraph(root, { full: !existsSync27(graphFile(root)) });
33645
+ const graph = values["no-graph"] ? undefined : await updateGraph(root, { full: !existsSync28(graphFile(root)) });
32664
33646
  const changed = result.plan.filter((c) => REPORTED[c.action] !== undefined);
32665
33647
  if (values.json) {
32666
33648
  io.out(JSON.stringify({
@@ -32679,6 +33661,9 @@ var initCommand = {
32679
33661
  io.out(`tools: ${tools.map((t) => `${t.tool} ${t.ok ? t.version : "?"}`).join(", ")}`);
32680
33662
  for (const note of detection?.notes ?? [])
32681
33663
  io.out(`note: ${note}`);
33664
+ if (legacy !== undefined && legacy.archived.length > 0) {
33665
+ io.out(`legacy setup archived under .factory/legacy/${legacy.stamp}/ (${legacy.archived.length} path${legacy.archived.length === 1 ? "" : "s"})`);
33666
+ }
32682
33667
  if (keepOpenspec)
32683
33668
  io.out(`kept your ${OPENSPEC_CONFIG} (listed under "unmanaged"); add the factory's workspace context to it by hand if you want the architect to see it`);
32684
33669
  if (newConfig)
@@ -32704,17 +33689,17 @@ var initCommand = {
32704
33689
  };
32705
33690
 
32706
33691
  // src/cli/commands/pipeline-commands.ts
32707
- import { existsSync as existsSync33 } from "node:fs";
32708
- import { join as join46 } from "node:path";
33692
+ import { existsSync as existsSync34, readFileSync as readFileSync27 } from "node:fs";
33693
+ import { join as join49, resolve as resolve5 } from "node:path";
32709
33694
 
32710
33695
  // src/pipeline/approve.ts
32711
- import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync11 } from "node:fs";
32712
- import { dirname as dirname8 } from "node:path";
33696
+ import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync11 } from "node:fs";
33697
+ import { dirname as dirname9 } from "node:path";
32713
33698
 
32714
33699
  // src/approval/approval.ts
32715
33700
  import { createHash as createHash4 } from "node:crypto";
32716
- import { existsSync as existsSync28, readdirSync as readdirSync10, readFileSync as readFileSync20, statSync as statSync7 } from "node:fs";
32717
- import { join as join36 } from "node:path";
33701
+ import { existsSync as existsSync29, readdirSync as readdirSync11, readFileSync as readFileSync22, statSync as statSync7 } from "node:fs";
33702
+ import { join as join38 } from "node:path";
32718
33703
  function parseApproval(value) {
32719
33704
  if (typeof value !== "object" || value === null)
32720
33705
  return;
@@ -32722,28 +33707,28 @@ function parseApproval(value) {
32722
33707
  const ok = typeof v.changeId === "string" && typeof v.approver === "string" && typeof v.specCommit === "string" && /^[0-9a-f]{40,64}$/.test(v.specCommit) && typeof v.hash === "string" && /^[0-9a-f]{64}$/.test(v.hash) && typeof v.approvedAt === "string";
32723
33708
  return ok ? value : undefined;
32724
33709
  }
32725
- var changeDir = (root, id) => join36(root, "openspec", "changes", id);
32726
- var approvalFile = (root, id) => join36(root, ".factory", "approvals", `${id}.json`);
33710
+ var changeDir = (root, id) => join38(root, "openspec", "changes", id);
33711
+ var approvalFile = (root, id) => join38(root, ".factory", "approvals", `${id}.json`);
32727
33712
  function filesUnder(dir, prefix = "") {
32728
- if (!existsSync28(dir))
33713
+ if (!existsSync29(dir))
32729
33714
  return [];
32730
- return readdirSync10(dir).sort().flatMap((name) => {
33715
+ return readdirSync11(dir).sort().flatMap((name) => {
32731
33716
  const rel = prefix === "" ? name : `${prefix}/${name}`;
32732
- const abs = join36(dir, name);
33717
+ const abs = join38(dir, name);
32733
33718
  return statSync7(abs).isDirectory() ? filesUnder(abs, rel) : [rel];
32734
33719
  });
32735
33720
  }
32736
33721
  function approvedFiles(root, id) {
32737
33722
  const dir = changeDir(root, id);
32738
- const top = ["proposal.md", "design.md", "tasks.md"].filter((f) => existsSync28(join36(dir, f)));
32739
- const specs = filesUnder(join36(dir, "specs"), "specs").filter((f) => f.endsWith(".md"));
33723
+ const top = ["proposal.md", "design.md", "tasks.md"].filter((f) => existsSync29(join38(dir, f)));
33724
+ const specs = filesUnder(join38(dir, "specs"), "specs").filter((f) => f.endsWith(".md"));
32740
33725
  return [...top, ...specs].sort();
32741
33726
  }
32742
33727
  function specHash(root, id) {
32743
33728
  const hash = createHash4("sha256");
32744
33729
  const dir = changeDir(root, id);
32745
33730
  for (const rel of approvedFiles(root, id)) {
32746
- let content = readFileSync20(join36(dir, rel), "utf8").replace(/\r\n/g, `
33731
+ let content = readFileSync22(join38(dir, rel), "utf8").replace(/\r\n/g, `
32747
33732
  `);
32748
33733
  if (rel === "tasks.md")
32749
33734
  content = normalizeCheckboxes(content);
@@ -32753,10 +33738,10 @@ function specHash(root, id) {
32753
33738
  }
32754
33739
  function readApproval(root, id) {
32755
33740
  const file = approvalFile(root, id);
32756
- if (!existsSync28(file))
33741
+ if (!existsSync29(file))
32757
33742
  return;
32758
33743
  try {
32759
- return parseApproval(JSON.parse(readFileSync20(file, "utf8")));
33744
+ return parseApproval(JSON.parse(readFileSync22(file, "utf8")));
32760
33745
  } catch {
32761
33746
  return;
32762
33747
  }
@@ -32774,12 +33759,12 @@ function checkApprovalHash(root, id) {
32774
33759
  }
32775
33760
 
32776
33761
  // src/pipeline/design.ts
32777
- import { existsSync as existsSync30 } from "node:fs";
32778
- import { join as join39 } from "node:path";
33762
+ import { existsSync as existsSync31 } from "node:fs";
33763
+ import { join as join41 } from "node:path";
32779
33764
 
32780
33765
  // src/git/branching.ts
32781
- import { existsSync as existsSync29 } from "node:fs";
32782
- import { join as join37 } from "node:path";
33766
+ import { existsSync as existsSync30 } from "node:fs";
33767
+ import { join as join39 } from "node:path";
32783
33768
  async function switchToChangeBranch(dir, branch, start) {
32784
33769
  if (await currentBranch(dir) === branch)
32785
33770
  return { created: false };
@@ -32816,8 +33801,8 @@ async function trackedBranch(root, path) {
32816
33801
  return;
32817
33802
  }
32818
33803
  async function branchSubmodule(root, repo, id) {
32819
- const dir = join37(root, repo.path);
32820
- if (!existsSync29(join37(dir, ".git"))) {
33804
+ const dir = join39(root, repo.path);
33805
+ if (!existsSync30(join39(dir, ".git"))) {
32821
33806
  throw new PreconditionError(`submodule ${repo.path} is not initialised`, [
32822
33807
  `run \`git submodule update --init ${repo.path}\``
32823
33808
  ]);
@@ -32889,7 +33874,7 @@ function decisionValues(handoff) {
32889
33874
  }
32890
33875
 
32891
33876
  // src/pipeline/context.ts
32892
- import { join as join38 } from "node:path";
33877
+ import { join as join40 } from "node:path";
32893
33878
 
32894
33879
  // src/runtime/rate-limit.ts
32895
33880
  function sleep(ms, signal) {
@@ -32987,7 +33972,7 @@ class Pipeline {
32987
33972
  return repo;
32988
33973
  }
32989
33974
  repoDir(repo) {
32990
- return join38(this.root, repo.path);
33975
+ return join40(this.root, repo.path);
32991
33976
  }
32992
33977
  out(line) {
32993
33978
  this.deps.out(line);
@@ -33026,14 +34011,31 @@ class Pipeline {
33026
34011
  }
33027
34012
  }
33028
34013
  async#call(call, signal) {
33029
- const model = modelFor(this.config, call.modelRole);
33030
- const provider = model.split("/")[0] ?? model;
33031
- const raw = await this.deps.runtime.open({
33032
- agent: call.agent,
33033
- model,
33034
- title: call.title,
33035
- context: { ...call.context, changeId: call.changeId, role: call.role }
34014
+ const first = modelFor(this.config, call.modelRole, {
34015
+ ...call.context.repo === undefined ? {} : { repo: call.context.repo },
34016
+ agent: call.agent
33036
34017
  });
34018
+ let model = first;
34019
+ let raw;
34020
+ let lastError;
34021
+ for (const candidate of [first, ...this.config.models.fallback]) {
34022
+ try {
34023
+ raw = await this.deps.runtime.open({
34024
+ agent: call.agent,
34025
+ model: candidate,
34026
+ title: call.title,
34027
+ context: { ...call.context, changeId: call.changeId, role: call.role }
34028
+ });
34029
+ model = candidate;
34030
+ break;
34031
+ } catch (err) {
34032
+ lastError = err;
34033
+ }
34034
+ }
34035
+ if (raw === undefined) {
34036
+ throw lastError instanceof Error ? lastError : new Error(`could not open a session for ${call.agent}`);
34037
+ }
34038
+ const provider = model.split("/")[0] ?? model;
33037
34039
  const session = limited(raw, this.limiter, provider);
33038
34040
  let turn = await promptForHandoff(session, call.prompt, signal);
33039
34041
  let usage = turn.usage;
@@ -33113,6 +34115,8 @@ ${text}`).join(`
33113
34115
 
33114
34116
  `);
33115
34117
  const contracts = input.contracts.map((c) => `- ${c.kind} ${c.provider}:${c.path} → ${c.consumers.join(", ") || "no consumers"}`);
34118
+ const instructions = input.instructions === undefined || input.instructions.length === 0 ? "- none configured" : input.instructions.map((p) => `- \`${p}\``).join(`
34119
+ `);
33116
34120
  return `Write the OpenSpec change \`${input.id}\` in \`openspec/changes/${input.id}/\` (the folder exists; fill proposal.md, specs/<capability>/spec.md, design.md and tasks.md).
33117
34121
 
33118
34122
  Request:
@@ -33128,6 +34132,9 @@ Contracts:
33128
34132
  ${contracts.length > 0 ? contracts.join(`
33129
34133
  `) : "- none configured"}
33130
34134
 
34135
+ Project instructions (read these before writing the spec):
34136
+ ${instructions}
34137
+
33131
34138
  Explorer findings:
33132
34139
  ${explored || "(none)"}
33133
34140
 
@@ -33169,18 +34176,28 @@ ${conflicts.map((f) => `- ${f}`).join(`
33169
34176
  Resolve every conflict so both changes are kept and correct. Remove all conflict markers. Do not run git commands that change history; the factory continues the rebase. End with a handoff (\`task: "${task.id}"\`).`;
33170
34177
  }
33171
34178
  function reviewPrompt(input) {
34179
+ const instructions = input.instructions === undefined || input.instructions.length === 0 ? "" : `
34180
+ Project instructions (read these before judging the diff; paths are relative to this checkout):
34181
+ ${input.instructions.map((p) => `- \`${p}\``).join(`
34182
+ `)}
34183
+ `;
33172
34184
  return `Review repo \`${input.repo.name}\` for this change: \`git diff ${input.base}..${input.head}\` (run it in this directory), plus the code around it.
33173
- Standards in force: ${input.profiles.join(", ") || "none"} (see .opencode/standards/ in the workspace).
33174
-
34185
+ Standards in force: ${input.profiles.join(", ") || "none"} (the full standards are in the workspace's .opencode/standards/).
34186
+ ${instructions}
33175
34187
  Spec of the change:
33176
34188
  ${input.spec}
33177
34189
 
33178
34190
  Use \`task: "review:${input.repo.name}"\`.`;
33179
34191
  }
33180
34192
  function testerPrompt(input) {
34193
+ const instructions = input.instructions === undefined || input.instructions.length === 0 ? "" : `
34194
+ Project instructions (follow these test conventions; paths are workspace-relative, so open \`$AI_FACTORY_WORKSPACE/<path>\` from a worktree):
34195
+ ${input.instructions.map((p) => `- \`${p}\``).join(`
34196
+ `)}
34197
+ `;
33181
34198
  return `Task ${input.task.id}: make sure every scenario below that concerns repo \`${input.task.repo}\` has a test.
33182
34199
  You may only edit test files (${input.testGlobs.join(", ")}).
33183
-
34200
+ ${instructions}
33184
34201
  Spec of the change:
33185
34202
  ${input.spec}
33186
34203
 
@@ -33203,6 +34220,25 @@ function findingPack(f) {
33203
34220
  - Impact if skipped: ${f.impact}
33204
34221
  ${f.file ? `- Location: ${f.file}${f.line ? `:${f.line}` : ""}` : ""}`;
33205
34222
  }
34223
+ function quickPrompt(input) {
34224
+ return `Quick task in repo \`${input.repo.name}\` (no OpenSpec change):
34225
+ """
34226
+ ${input.task}
34227
+ """
34228
+ Make the smallest complete change that satisfies the task. You may edit any file in this
34229
+ repository except the factory-managed files and secrets. Tests for the change belong with it.
34230
+ The factory runs the repository's gates after you finish; report the result in your handoff.
34231
+ Use \`task: "quick"\`.`;
34232
+ }
34233
+ function quickReviewPrompt(input) {
34234
+ return `Review the uncommitted quick-task change in repo \`${input.repo.name}\`:
34235
+ """
34236
+ ${input.task}
34237
+ """
34238
+ Run \`git diff\` in this repository and read the code around the changes. Report only real
34239
+ bugs, security problems, missing tests for risky logic, and contract/convention violations.
34240
+ Use \`task: "quick-review"\` and the normal findings handoff.`;
34241
+ }
33206
34242
  function contractReviewPrompt(input) {
33207
34243
  return `One cross-repo review of the contracts this change touches. The deterministic contract gate already ran:
33208
34244
  ${input.contracts.map((c) => c.summary).join(`
@@ -33217,14 +34253,14 @@ Report only real mismatches between providers and consumers as findings, with \`
33217
34253
 
33218
34254
  // src/pipeline/design.ts
33219
34255
  function specDir(p, draft) {
33220
- return draft === undefined ? p.root : join39(p.root, draft);
34256
+ return draft === undefined ? p.root : join41(p.root, draft);
33221
34257
  }
33222
34258
  function slug(text) {
33223
34259
  const words = text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(Boolean).slice(0, 5);
33224
34260
  return words.join("-") || "change";
33225
34261
  }
33226
34262
  function uniqueId(p, wanted) {
33227
- const taken = (id) => loadRun(p.root, id) !== undefined || existsSync30(join39(p.root, "openspec", "changes", id)) || existsSync30(join39(p.root, ".factory", "drafts", id));
34263
+ const taken = (id) => loadRun(p.root, id) !== undefined || existsSync31(join41(p.root, "openspec", "changes", id)) || existsSync31(join41(p.root, ".factory", "drafts", id));
33228
34264
  let id = wanted;
33229
34265
  for (let n = 2;taken(id); n++)
33230
34266
  id = `${wanted}-${n}`;
@@ -33239,8 +34275,8 @@ async function branchForDesign(p, id) {
33239
34275
  }
33240
34276
  const base = loadRun(p.root, occupiedBy)?.branches["."]?.base ?? await revParse(p.root, "HEAD");
33241
34277
  const draft = `.factory/drafts/${id}`;
33242
- if (!existsSync30(join39(p.root, draft))) {
33243
- const args = await refExists(p.root, `refs/heads/${changeBranch(id)}`) ? ["worktree", "add", "-q", join39(p.root, draft), changeBranch(id)] : ["worktree", "add", "-q", "-b", changeBranch(id), join39(p.root, draft), base];
34278
+ if (!existsSync31(join41(p.root, draft))) {
34279
+ const args = await refExists(p.root, `refs/heads/${changeBranch(id)}`) ? ["worktree", "add", "-q", join41(p.root, draft), changeBranch(id)] : ["worktree", "add", "-q", "-b", changeBranch(id), join41(p.root, draft), base];
33244
34280
  await gitOk(args, { cwd: p.root });
33245
34281
  }
33246
34282
  return { base, draft };
@@ -33348,7 +34384,8 @@ async function design(p, id) {
33348
34384
  request: run.request,
33349
34385
  explorations,
33350
34386
  repos: p.codeRepos(),
33351
- contracts: p.config.contracts
34387
+ contracts: p.config.contracts,
34388
+ instructions: allInstructionPaths(p.config)
33352
34389
  }),
33353
34390
  retry: async (_handoff, attempt) => {
33354
34391
  if (attempt > p.config.gates.maxRetries)
@@ -33401,7 +34438,7 @@ async function approve(p, id) {
33401
34438
  await p.update(id, (d) => {
33402
34439
  assertApprovable(id, d);
33403
34440
  const file = approvalFile(p.root, id);
33404
- mkdirSync13(dirname8(file), { recursive: true });
34441
+ mkdirSync14(dirname9(file), { recursive: true });
33405
34442
  writeFileSync11(file, `${JSON.stringify(approval, null, 2)}
33406
34443
  `);
33407
34444
  d.phase = "queued";
@@ -33459,15 +34496,15 @@ async function verifyApproval(p, id, dir) {
33459
34496
  }
33460
34497
 
33461
34498
  // src/pipeline/contracts.ts
33462
- import { mkdirSync as mkdirSync15, writeFileSync as writeFileSync13 } from "node:fs";
33463
- import { join as join43 } from "node:path";
34499
+ import { mkdirSync as mkdirSync16, writeFileSync as writeFileSync13 } from "node:fs";
34500
+ import { join as join45 } from "node:path";
33464
34501
 
33465
34502
  // src/contracts/gate.ts
33466
34503
  var import_ajv = __toESM(require_ajv(), 1);
33467
34504
  var import__2020 = __toESM(require__2020(), 1);
33468
34505
  var import_ajv_formats = __toESM(require_dist(), 1);
33469
- import { readFileSync as readFileSync21 } from "node:fs";
33470
- import { join as join40 } from "node:path";
34506
+ import { readFileSync as readFileSync23 } from "node:fs";
34507
+ import { join as join42 } from "node:path";
33471
34508
 
33472
34509
  // src/contracts/schema-diff.ts
33473
34510
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -34003,7 +35040,7 @@ var SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|go|py|rs|java|kt|swift|rb|php|cs|vue|svel
34003
35040
  function consumerSources(dir) {
34004
35041
  return listFiles(dir, { maxDepth: 8, maxFiles: 5000 }).filter((f) => SOURCE.test(f.path) && f.size <= 256 * 1024).map((f) => {
34005
35042
  try {
34006
- return readFileSync21(join40(dir, f.path), "utf8");
35043
+ return readFileSync23(join42(dir, f.path), "utf8");
34007
35044
  } catch {
34008
35045
  return "";
34009
35046
  }
@@ -34027,21 +35064,50 @@ async function runValidate(command, dir, contractAbs, maxLines) {
34027
35064
  ${compactOutput(`${res.stdout}
34028
35065
  ${res.stderr}`, maxLines)}`;
34029
35066
  }
34030
- function repoOf(config, name) {
35067
+ function repoOf2(config, name) {
34031
35068
  const repo = config.repos.find((r) => r.name === name);
34032
35069
  if (repo === undefined)
34033
35070
  throw new Error(`unknown repo ${name}`);
34034
35071
  return repo;
34035
35072
  }
34036
35073
  async function checkContract(input, contract) {
34037
- const provider = repoOf(input.config, contract.provider);
34038
- const providerDir = join40(input.root, provider.path);
35074
+ const provider = repoOf2(input.config, contract.provider);
35075
+ const providerDir = join42(input.root, provider.path);
34039
35076
  const head = await headRef(providerDir, input.id);
34040
35077
  const base = input.bases[provider.name] ?? head;
34041
35078
  const breaking = [];
34042
35079
  const consumerIssues = [];
34043
35080
  const notes = [];
34044
- if (contract.kind === "proto") {
35081
+ if (contract.kind === "generated" || contract.kind === "crd" || contract.kind === "ipc" || contract.kind === "custom") {
35082
+ if (contract.command === undefined) {
35083
+ consumerIssues.push(`${contract.kind} contract has no command`);
35084
+ } else if (contract.kind === "custom") {
35085
+ const issue = await runValidate(contract.command, providerDir, join42(providerDir, contract.path), input.gateOutputMaxLines);
35086
+ if (issue)
35087
+ consumerIssues.push(`${provider.name}: ${issue}`);
35088
+ } else {
35089
+ const before = await snapshot(providerDir);
35090
+ const res = await exec(["sh", "-c", contract.command], {
35091
+ cwd: providerDir,
35092
+ env: { CI: "1", NO_COLOR: "1" },
35093
+ timeoutMs: 15 * 60000
35094
+ });
35095
+ const modified = await changedSince(before);
35096
+ if (modified.length > 0)
35097
+ await restore(before, modified);
35098
+ if (res.code !== 0) {
35099
+ consumerIssues.push(`\`${contract.command}\` failed:
35100
+ ${compactOutput(`${res.stdout}
35101
+ ${res.stderr}`, input.gateOutputMaxLines)}`);
35102
+ } else {
35103
+ const outputs = contract.outputs ?? [contract.path];
35104
+ const drifted = modified.filter((file) => outputs.some((glob) => file === glob || file.startsWith(`${glob.replace(/\/$/, "")}/`) || opencodeGlobRegExp(glob).test(file)));
35105
+ if (drifted.length > 0) {
35106
+ consumerIssues.push(`generated artifacts are stale: ${drifted.join(", ")}; regenerate and commit them`);
35107
+ }
35108
+ }
35109
+ }
35110
+ } else if (contract.kind === "proto") {
34045
35111
  const oldFiles = await filesAt(providerDir, base, contract.path, ".proto");
34046
35112
  const newFiles = await filesAt(providerDir, head, contract.path, ".proto");
34047
35113
  if (newFiles.length === 0)
@@ -34064,7 +35130,7 @@ async function checkContract(input, contract) {
34064
35130
  else {
34065
35131
  breaking.push(...diffOpenApi(oldDoc, newDoc));
34066
35132
  for (const consumer of contract.consumers) {
34067
- const dir = join40(input.root, repoOf(input.config, consumer).path);
35133
+ const dir = join42(input.root, repoOf2(input.config, consumer).path);
34068
35134
  for (const path of referencedRemovedPaths(oldDoc, newDoc, consumerSources(dir))) {
34069
35135
  consumerIssues.push(`${consumer} still references ${path}, which the new contract no longer has`);
34070
35136
  }
@@ -34096,8 +35162,8 @@ async function checkContract(input, contract) {
34096
35162
  }
34097
35163
  }
34098
35164
  for (const [consumer, command] of Object.entries(contract.validate ?? {})) {
34099
- const dir = join40(input.root, repoOf(input.config, consumer).path);
34100
- const issue = await runValidate(command, dir, join40(providerDir, contract.path), input.gateOutputMaxLines);
35165
+ const dir = join42(input.root, repoOf2(input.config, consumer).path);
35166
+ const issue = await runValidate(command, dir, join42(providerDir, contract.path), input.gateOutputMaxLines);
34101
35167
  if (issue)
34102
35168
  consumerIssues.push(`${consumer}: ${issue}`);
34103
35169
  }
@@ -34128,23 +35194,23 @@ function touchedContracts(config, repos) {
34128
35194
 
34129
35195
  // src/pipeline/implement.ts
34130
35196
  import {
34131
- existsSync as existsSync31,
34132
- mkdirSync as mkdirSync14,
34133
- readdirSync as readdirSync11,
34134
- readFileSync as readFileSync23,
35197
+ existsSync as existsSync32,
35198
+ mkdirSync as mkdirSync15,
35199
+ readdirSync as readdirSync12,
35200
+ readFileSync as readFileSync25,
34135
35201
  rmSync as rmSync11,
34136
35202
  statSync as statSync8,
34137
35203
  writeFileSync as writeFileSync12
34138
35204
  } from "node:fs";
34139
- import { join as join42 } from "node:path";
35205
+ import { join as join44 } from "node:path";
34140
35206
 
34141
35207
  // src/gates/definitions.ts
34142
- import { readFileSync as readFileSync22 } from "node:fs";
34143
- import { join as join41 } from "node:path";
35208
+ import { readFileSync as readFileSync24 } from "node:fs";
35209
+ import { join as join43 } from "node:path";
34144
35210
  var HOSTS = ["package.json", "Makefile", "justfile", "Justfile", "Taskfile.yml", "Taskfile.yaml"];
34145
35211
  function current(dir, file) {
34146
35212
  try {
34147
- return readFileSync22(join41(dir, file), "utf8");
35213
+ return readFileSync24(join43(dir, file), "utf8");
34148
35214
  } catch {
34149
35215
  return;
34150
35216
  }
@@ -34323,7 +35389,7 @@ class GraphUpdater {
34323
35389
  }
34324
35390
 
34325
35391
  // src/policy/scope.ts
34326
- import { isAbsolute as isAbsolute2, normalize, relative as relative5, resolve as resolve4 } from "node:path";
35392
+ import { isAbsolute as isAbsolute2, normalize, relative as relative6, resolve as resolve4 } from "node:path";
34327
35393
 
34328
35394
  // src/policy/paths.ts
34329
35395
  var PROTECTED = [
@@ -34378,7 +35444,7 @@ var architectDirs = (changeId) => [
34378
35444
  var IMPLEMENTING_ROLES = new Set(["implementer", "tester"]);
34379
35445
  function checkEdit(ctx, path, approval) {
34380
35446
  const abs = normalize(isAbsolute2(path) ? path : resolve4(ctx.directory, path));
34381
- const rel = relative5(ctx.directory, abs);
35447
+ const rel = relative6(ctx.directory, abs);
34382
35448
  if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
34383
35449
  return {
34384
35450
  allowed: false,
@@ -34753,6 +35819,25 @@ class Scheduler {
34753
35819
  }
34754
35820
  }
34755
35821
 
35822
+ // src/tasks/routing.ts
35823
+ function agentForTask(config, repo, touches) {
35824
+ for (const rule of config.routing) {
35825
+ const agent = config.agents.find((a) => a.id === rule.agent);
35826
+ if (agent === undefined || agent.phase !== "implementer")
35827
+ continue;
35828
+ const hit = rule.paths.some((path) => touches.some((touch) => {
35829
+ try {
35830
+ return opencodeGlobRegExp(path).test(touch) || opencodeGlobRegExp(touch).test(path);
35831
+ } catch {
35832
+ return false;
35833
+ }
35834
+ }));
35835
+ if (hit)
35836
+ return { agent: agent.id, role: "implementer", custom: true };
35837
+ }
35838
+ return { agent: `implementer-${repo}`, role: "implementer", custom: false };
35839
+ }
35840
+
34756
35841
  // src/util/cap.ts
34757
35842
  var CHARS_PER_TOKEN = 4;
34758
35843
  var estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);
@@ -34775,7 +35860,7 @@ function gateDefinitions(p, id) {
34775
35860
  return { config: p.config };
34776
35861
  return { config: { ...p.config, gates: snapshot.gates }, gateTables: snapshot.tables };
34777
35862
  }
34778
- var packPath = (root, id, task) => join42(root, ".factory", "context", id, `${task}.md`);
35863
+ var packPath = (root, id, task) => join44(root, ".factory", "context", id, `${task}.md`);
34779
35864
  async function activate(p, id) {
34780
35865
  const claim = await claimActive(p.root, id);
34781
35866
  if (claim.status === "busy") {
@@ -34792,7 +35877,7 @@ async function activate(p, id) {
34792
35877
  const run = p.run(id);
34793
35878
  const branch = changeBranch(id);
34794
35879
  if (run.draft !== undefined) {
34795
- await gitOk(["worktree", "remove", join42(p.root, run.draft)], { cwd: p.root });
35880
+ await gitOk(["worktree", "remove", join44(p.root, run.draft)], { cwd: p.root });
34796
35881
  await p.update(id, (d) => {
34797
35882
  delete d.draft;
34798
35883
  });
@@ -34824,18 +35909,18 @@ async function activate(p, id) {
34824
35909
  return { status: "active" };
34825
35910
  }
34826
35911
  function specText(p, id) {
34827
- const dir = join42(p.root, "openspec", "changes", id, "specs");
34828
- if (!existsSync31(dir))
35912
+ const dir = join44(p.root, "openspec", "changes", id, "specs");
35913
+ if (!existsSync32(dir))
34829
35914
  return "";
34830
35915
  const out = [];
34831
35916
  const walk = (d, rel) => {
34832
- for (const name of readdirSync11(d).sort()) {
34833
- const abs = join42(d, name);
35917
+ for (const name of readdirSync12(d).sort()) {
35918
+ const abs = join44(d, name);
34834
35919
  if (statSync8(abs).isDirectory())
34835
35920
  walk(abs, `${rel}${name}/`);
34836
35921
  else if (name.endsWith(".md"))
34837
35922
  out.push(`### specs/${rel}${name}
34838
- ${readFileSync23(abs, "utf8").trim()}`);
35923
+ ${readFileSync25(abs, "utf8").trim()}`);
34839
35924
  }
34840
35925
  };
34841
35926
  walk(dir, "");
@@ -34861,10 +35946,10 @@ function cappedSpec(p, id) {
34861
35946
  async function ensureContextPacks(p, id) {
34862
35947
  const cap = p.config.tokens.contextPackMaxTokens;
34863
35948
  const tasks = p.run(id).tasks.filter((t) => t.kind === "task" && t.status === "pending");
34864
- const missing = tasks.filter((t) => !existsSync31(packPath(p.root, id, t.id)));
35949
+ const missing = tasks.filter((t) => !existsSync32(packPath(p.root, id, t.id)));
34865
35950
  if (missing.length > 0) {
34866
35951
  p.out(`▶ context packs (${missing.length})`);
34867
- mkdirSync14(join42(p.root, ".factory", "context", id), { recursive: true });
35952
+ mkdirSync15(join44(p.root, ".factory", "context", id), { recursive: true });
34868
35953
  const res = await p.call({
34869
35954
  changeId: id,
34870
35955
  agent: "architect",
@@ -34883,7 +35968,7 @@ async function ensureContextPacks(p, id) {
34883
35968
  }
34884
35969
  for (const t of tasks) {
34885
35970
  const file = packPath(p.root, id, t.id);
34886
- const text = existsSync31(file) ? capPack(readFileSync23(file, "utf8"), cap) : fallbackPack(p, id, t, cap);
35971
+ const text = existsSync32(file) ? capPack(readFileSync25(file, "utf8"), cap) : fallbackPack(p, id, t, cap);
34887
35972
  writeFileSync12(file, text);
34888
35973
  }
34889
35974
  }
@@ -35017,7 +36102,7 @@ class PipelineTaskRunner {
35017
36102
  await gitOk(["checkout", "-q", "HEAD", "--", v.path], { cwd: dir });
35018
36103
  else {
35019
36104
  await git(["rm", "-q", "--cached", "--ignore-unmatch", "--", v.path], { cwd: dir });
35020
- rmSync11(join42(dir, v.path), { force: true, recursive: true });
36105
+ rmSync11(join44(dir, v.path), { force: true, recursive: true });
35021
36106
  }
35022
36107
  }
35023
36108
  return violations.map((v) => `${v.path}: ${v.reason}`);
@@ -35043,14 +36128,15 @@ class PipelineTaskRunner {
35043
36128
  t.installed = true;
35044
36129
  });
35045
36130
  }
35046
- const pack = existsSync31(packPath(this.p.root, this.id, task.id)) ? readFileSync23(packPath(this.p.root, this.id, task.id), "utf8") : "";
36131
+ const pack = existsSync32(packPath(this.p.root, this.id, task.id)) ? readFileSync25(packPath(this.p.root, this.id, task.id), "utf8") : "";
35047
36132
  const gates = gateCommands(this.p, task, dir);
35048
36133
  const isTest = task.kind === "test";
35049
36134
  const prompt = isTest ? testerPrompt({
35050
36135
  task,
35051
36136
  spec: cappedSpec(this.p, this.id),
35052
36137
  testGlobs: this.#context(task, dir).testGlobs ?? [],
35053
- gates
36138
+ gates,
36139
+ instructions: instructionPaths(this.p.config, task.repo)
35054
36140
  }) : taskPrompt({
35055
36141
  task,
35056
36142
  pack,
@@ -35058,9 +36144,10 @@ class PipelineTaskRunner {
35058
36144
  ...ctx.feedback === undefined ? {} : { feedback: ctx.feedback }
35059
36145
  });
35060
36146
  const headBefore = (await gitOk(["rev-parse", "HEAD"], { cwd: dir })).trim();
36147
+ const routed = agentForTask(this.p.config, task.repo, task.touches);
35061
36148
  const res = await this.p.call({
35062
36149
  changeId: this.id,
35063
- agent: isTest ? "tester" : `implementer-${task.repo}`,
36150
+ agent: isTest ? "tester" : routed.agent,
35064
36151
  role: isTest ? "tester" : "implementer",
35065
36152
  modelRole: isTest ? "tester" : "implementer",
35066
36153
  context: this.#context(task, dir),
@@ -35111,7 +36198,7 @@ ${redefined.join(`
35111
36198
  }
35112
36199
  const report = await this.#gates(task, dir);
35113
36200
  if (!report.passed) {
35114
- const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.type}] ${r.command ?? ""}
36201
+ const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.command ?? ""}
35115
36202
  ${r.output}`).join(`
35116
36203
 
35117
36204
  `);
@@ -35122,7 +36209,10 @@ ${output}` : output
35122
36209
  };
35123
36210
  }
35124
36211
  const type = task.kind === "test" ? "test" : task.kind === "task" ? undefined : "fix";
35125
- const commit = await commitTask(dir, this.id, task.id, task.title, type);
36212
+ const commit = await commitTask(dir, this.id, task.id, task.title, type, {
36213
+ style: this.p.config.commits.style,
36214
+ ...this.p.config.commits.scopes ? { scope: task.repo } : {}
36215
+ });
35126
36216
  return { status: "passed", ...commit ? { commit } : {} };
35127
36217
  }
35128
36218
  async merge(task) {
@@ -35137,7 +36227,7 @@ ${output}` : output
35137
36227
  }
35138
36228
  const branch = taskBranch(this.id, task.id);
35139
36229
  const path = worktreePath(this.p.root, this.id, repo.name, task.id);
35140
- if (!existsSync31(path)) {
36230
+ if (!existsSync32(path)) {
35141
36231
  const merged = !await refExists(repoDir, `refs/heads/${branch}`) || (await git(["merge-base", "--is-ancestor", branch, changeBranch(this.id)], {
35142
36232
  cwd: repoDir
35143
36233
  })).code === 0;
@@ -35162,16 +36252,17 @@ ${output}` : output
35162
36252
  if (outcome.status === "conflict")
35163
36253
  return { status: "conflict", conflicts: outcome.conflicts };
35164
36254
  const report = this.#lastRegate.get(task.id);
35165
- const output = report?.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.type}] ${r.output}`).join(`
36255
+ const output = report?.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.output}`).join(`
35166
36256
 
35167
36257
  `) ?? "gates failed after the rebase";
35168
36258
  return { status: "gates-failed", output };
35169
36259
  }
35170
36260
  async resolveConflict(task, conflicts) {
35171
36261
  const path = worktreePath(this.p.root, this.id, task.repo, task.id);
36262
+ const routed = agentForTask(this.p.config, task.repo, task.touches);
35172
36263
  const res = await this.p.call({
35173
36264
  changeId: this.id,
35174
- agent: `implementer-${task.repo}`,
36265
+ agent: routed.agent,
35175
36266
  role: "implementer",
35176
36267
  modelRole: "implementer",
35177
36268
  context: { ...this.#context(task, path), touches: ["**"] },
@@ -35183,8 +36274,8 @@ ${output}` : output
35183
36274
  if (res.handoff?.status !== "done")
35184
36275
  return false;
35185
36276
  for (const file of conflicts) {
35186
- const abs = join42(path, file);
35187
- if (existsSync31(abs) && /^(<{7}|={7}|>{7})( |$)/m.test(readFileSync23(abs, "utf8")))
36277
+ const abs = join44(path, file);
36278
+ if (existsSync32(abs) && /^(<{7}|={7}|>{7})( |$)/m.test(readFileSync25(abs, "utf8")))
35188
36279
  return false;
35189
36280
  }
35190
36281
  return true;
@@ -35193,7 +36284,7 @@ ${output}` : output
35193
36284
  const repo = this.p.repo(task.repo);
35194
36285
  const repoDir = this.p.repoDir(repo);
35195
36286
  const path = worktreePath(this.p.root, this.id, repo.name, task.id);
35196
- if (existsSync31(path)) {
36287
+ if (existsSync32(path)) {
35197
36288
  await abortRebase(path);
35198
36289
  await gitOk(["worktree", "remove", "--force", path], { cwd: repoDir });
35199
36290
  }
@@ -35257,7 +36348,7 @@ function addFixTask(p, draft, id, task) {
35257
36348
  });
35258
36349
  recordTask(state, "added", "contract-fix");
35259
36350
  draft.tasks.push(state);
35260
- mkdirSync15(join43(p.root, ".factory", "context", id), { recursive: true });
36351
+ mkdirSync16(join45(p.root, ".factory", "context", id), { recursive: true });
35261
36352
  writeFileSync13(packPath(p.root, id, task.id), task.pack);
35262
36353
  }
35263
36354
  async function contractPhase(p, id) {
@@ -35369,11 +36460,11 @@ ${summarize(r)}
35369
36460
  }
35370
36461
 
35371
36462
  // src/pipeline/finish.ts
35372
- import { existsSync as existsSync32, mkdirSync as mkdirSync16, readdirSync as readdirSync12, readFileSync as readFileSync24, writeFileSync as writeFileSync14 } from "node:fs";
35373
- import { join as join45 } from "node:path";
36463
+ import { existsSync as existsSync33, mkdirSync as mkdirSync17, readdirSync as readdirSync13, readFileSync as readFileSync26, writeFileSync as writeFileSync14 } from "node:fs";
36464
+ import { join as join47 } from "node:path";
35374
36465
 
35375
36466
  // src/git/pointers.ts
35376
- import { join as join44 } from "node:path";
36467
+ import { join as join46 } from "node:path";
35377
36468
  var PROVISIONAL_SUBJECT = (id) => `chore(submodules): bump for ${id} [provisional]`;
35378
36469
  async function bumpPointers(root, id, repos) {
35379
36470
  const paths = repos.map((r) => r.path);
@@ -35472,7 +36563,7 @@ async function repoint(root, config, id) {
35472
36563
  for (const pointer of bump.pointers) {
35473
36564
  const repo = config.repos.find((r) => r.path === pointer.path);
35474
36565
  const name = repo?.name ?? pointer.path;
35475
- const dir = join44(root, pointer.path);
36566
+ const dir = join46(root, pointer.path);
35476
36567
  const base = { repo: name, path: pointer.path, from: pointer.to };
35477
36568
  const current = (await git(["rev-parse", `HEAD:${pointer.path}`], { cwd: root })).stdout.trim();
35478
36569
  if (current !== pointer.to) {
@@ -35558,7 +36649,8 @@ async function review(p, id) {
35558
36649
  base,
35559
36650
  head: changeBranch(id),
35560
36651
  spec,
35561
- profiles: profilesFor(repoLanguages(repo)).map((pr) => pr.title)
36652
+ profiles: profilesFor(repoLanguages(repo)).map((pr) => pr.title),
36653
+ instructions: sessionInstructionPaths(p.config, name, repo.path)
35562
36654
  })
35563
36655
  });
35564
36656
  if (res.handoff === undefined) {
@@ -35583,7 +36675,7 @@ async function review(p, id) {
35583
36675
  kind: "review-fix"
35584
36676
  });
35585
36677
  added++;
35586
- mkdirSync16(join45(p.root, ".factory", "context", id), { recursive: true });
36678
+ mkdirSync17(join47(p.root, ".factory", "context", id), { recursive: true });
35587
36679
  writeFileSync14(packPath(p.root, id, taskId), findingPack(f));
35588
36680
  }
35589
36681
  addTask(d, {
@@ -35626,7 +36718,7 @@ async function fullSuite(p, id) {
35626
36718
  command: r.command
35627
36719
  }))
35628
36720
  });
35629
- const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.type}] ${r.command ?? ""}
36721
+ const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.command ?? ""}
35630
36722
  ${r.output}`).join(`
35631
36723
 
35632
36724
  `);
@@ -35675,7 +36767,7 @@ function harvest(run) {
35675
36767
  }
35676
36768
  return { risks, questions, findings };
35677
36769
  }
35678
- var reportFile = (root, id, kind) => join45(root, ".factory", "reports", kind === "final" ? `${id}.md` : `${id}-paused.md`);
36770
+ var reportFile = (root, id, kind) => join47(root, ".factory", "reports", kind === "final" ? `${id}.md` : `${id}-paused.md`);
35679
36771
  function writePauseReport(p, id, summary) {
35680
36772
  const run = p.run(id);
35681
36773
  const failed = run.tasks.filter((t) => t.status === "failed");
@@ -35706,18 +36798,18 @@ function writePauseReport(p, id, summary) {
35706
36798
  ...usageReport(summarizeUsage(p.root, id))
35707
36799
  ];
35708
36800
  const file = reportFile(p.root, id, "paused");
35709
- mkdirSync16(join45(p.root, ".factory", "reports"), { recursive: true });
36801
+ mkdirSync17(join47(p.root, ".factory", "reports"), { recursive: true });
35710
36802
  writeFileSync14(file, lines.join(`
35711
36803
  `));
35712
36804
  return file;
35713
36805
  }
35714
36806
  function archivedName(root, id) {
35715
- if (existsSync32(join45(root, "openspec", "changes", id)))
36807
+ if (existsSync33(join47(root, "openspec", "changes", id)))
35716
36808
  return;
35717
- const archive = join45(root, "openspec", "changes", "archive");
35718
- if (!existsSync32(archive))
36809
+ const archive = join47(root, "openspec", "changes", "archive");
36810
+ if (!existsSync33(archive))
35719
36811
  return;
35720
- return readdirSync12(archive).filter((n) => n === id || n.endsWith(`-${id}`)).sort().at(-1);
36812
+ return readdirSync13(archive).filter((n) => n === id || n.endsWith(`-${id}`)).sort().at(-1);
35721
36813
  }
35722
36814
  async function close(p, id) {
35723
36815
  const run = p.run(id);
@@ -35725,9 +36817,9 @@ async function close(p, id) {
35725
36817
  if (open.length > 0)
35726
36818
  throw new PreconditionError(`cannot close ${id}: tasks not merged: ${open.map((t) => t.id).join(", ")}`);
35727
36819
  p.out("▶ close");
35728
- const tasksFile = join45(p.root, "openspec", "changes", id, "tasks.md");
35729
- if (existsSync32(tasksFile)) {
35730
- let text = readFileSync24(tasksFile, "utf8");
36820
+ const tasksFile = join47(p.root, "openspec", "changes", id, "tasks.md");
36821
+ if (existsSync33(tasksFile)) {
36822
+ let text = readFileSync26(tasksFile, "utf8");
35731
36823
  for (const t of run.tasks.filter((x) => x.kind === "task"))
35732
36824
  text = setTaskChecked(text, t.id, true);
35733
36825
  writeFileSync14(tasksFile, text);
@@ -35818,7 +36910,7 @@ Archived as \`openspec/changes/archive/${archivedAs}\`.`,
35818
36910
  ...usageReport(summarizeUsage(p.root, id))
35819
36911
  ];
35820
36912
  const file = reportFile(p.root, id, "final");
35821
- mkdirSync16(join45(p.root, ".factory", "reports"), { recursive: true });
36913
+ mkdirSync17(join47(p.root, ".factory", "reports"), { recursive: true });
35822
36914
  writeFileSync14(file, lines.join(`
35823
36915
  `));
35824
36916
  await p.update(id, (d) => {
@@ -36009,12 +37101,72 @@ async function step(p, id) {
36009
37101
  }
36010
37102
  }
36011
37103
 
37104
+ // src/pipeline/refine.ts
37105
+ import { mkdirSync as mkdirSync18, writeFileSync as writeFileSync15 } from "node:fs";
37106
+ import { join as join48 } from "node:path";
37107
+ var QUESTIONS = [
37108
+ ["scope", "Which repos, modules or capabilities does this touch?", ""],
37109
+ ["out-of-scope", "What is explicitly out of scope?", "nothing stated"],
37110
+ [
37111
+ "verification",
37112
+ "How should this be verified (tests, gates, manual checks)?",
37113
+ "the repository's normal gates"
37114
+ ],
37115
+ ["constraints", "Any contracts, compatibility or platform constraints?", "none stated"],
37116
+ ["extra", "Anything else the spec must know?", "none"]
37117
+ ];
37118
+ function requestSlug(text) {
37119
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 8).join("-");
37120
+ return slug === "" ? "request" : slug;
37121
+ }
37122
+ async function refineRequest(io, request) {
37123
+ if (io.ask === undefined) {
37124
+ throw new PreconditionError("cannot ask questions in this environment", [
37125
+ "run `ai-factory refine` from an interactive terminal, or pass a complete request to `run`"
37126
+ ]);
37127
+ }
37128
+ const answers = [];
37129
+ for (const [key, question, initial] of QUESTIONS) {
37130
+ const answer = await io.ask(question, initial);
37131
+ if (answer === undefined) {
37132
+ throw new PreconditionError("refinement was cancelled");
37133
+ }
37134
+ if (answer.trim() !== "" && answer !== initial)
37135
+ answers.push([key, answer.trim()]);
37136
+ }
37137
+ const text = `# Refined request
37138
+
37139
+ ## Original
37140
+
37141
+ ${request.trim()}
37142
+
37143
+ ## Clarifications
37144
+
37145
+ ${answers.map(([key, answer]) => `- **${key}**: ${answer}`).join(`
37146
+ `)}
37147
+
37148
+ ## Instructions for the architect
37149
+
37150
+ Treat the clarifications as part of the request. If they conflict with the original, the
37151
+ clarification wins. Keep anything not named here out of scope, and put open questions into
37152
+ \`openspec/changes/<id>/questions.md\` rather than guessing.
37153
+ `;
37154
+ const root = await findWorkspaceRoot(io.cwd);
37155
+ if (root === undefined)
37156
+ throw new PreconditionError("not inside a git repository");
37157
+ const dir = join48(root, ".factory", "requests");
37158
+ mkdirSync18(dir, { recursive: true });
37159
+ const path = join48(dir, `${requestSlug(request)}.md`);
37160
+ writeFileSync15(path, text);
37161
+ return { path, text };
37162
+ }
37163
+
36012
37164
  // src/cli/commands/pipeline-commands.ts
36013
37165
  async function setup(io) {
36014
37166
  const root = await findWorkspaceRoot(io.cwd);
36015
37167
  if (root === undefined)
36016
37168
  throw new PreconditionError("not inside a git repository");
36017
- if (!existsSync33(join46(root, LOCK_FILE)))
37169
+ if (!existsSync34(join49(root, LOCK_FILE)))
36018
37170
  throw new PreconditionError("ai-factory is not installed here", ["run `ai-factory init`"]);
36019
37171
  const { config } = loadConfig(root);
36020
37172
  pruneLogs(root, config.logs.retentionDays);
@@ -36062,18 +37214,35 @@ function changeIdArg(command, positionals) {
36062
37214
  var runCommand = {
36063
37215
  name: "run",
36064
37216
  summary: "Start a change from a request and run it up to spec approval",
36065
- help: `Usage: ai-factory run "<request>"
37217
+ help: `Usage: ai-factory run "<request>" [--request <file>] [--interactive]
36066
37218
 
36067
37219
  Intake (change id and summary), a change branch in the AI repo, explorers per likely repo,
36068
37220
  then the architect writes the OpenSpec change. Stops at spec approval: review
36069
37221
  openspec/changes/<id>/ and run \`ai-factory approve <id>\`. If another change is active, the
36070
- new one is drafted in .factory/drafts/<id> and queued after approval.`,
37222
+ new one is drafted in .factory/drafts/<id> and queued after approval.
37223
+
37224
+ --request <file> Read the request from a file (e.g. one written by \`ai-factory refine\`)
37225
+ --interactive Ask scope/verification questions before intake`,
36071
37226
  async run(args, io) {
36072
- const { positionals } = parseCommandArgs("run", args, {}, true);
36073
- const request = positionals.join(" ").trim();
37227
+ const { values, positionals } = parseCommandArgs("run", args, {
37228
+ request: { type: "string" },
37229
+ interactive: { type: "boolean", default: false }
37230
+ }, true);
37231
+ let request = positionals.join(" ").trim();
37232
+ if (values.request !== undefined) {
37233
+ request = readFileSync27(resolve5(io.cwd, values.request), "utf8").trim();
37234
+ }
36074
37235
  if (request === "")
36075
37236
  throw new UsageError('run: expected a request, e.g. ai-factory run "add a health endpoint"');
36076
37237
  return withPipeline(io, "intake", async (p) => {
37238
+ if (values.interactive) {
37239
+ if (!p.config.workflow.refine) {
37240
+ throw new PreconditionError("workflow.refine is disabled");
37241
+ }
37242
+ const refined = await refineRequest(io, request);
37243
+ request = refined.text;
37244
+ io.out(`refined request: ${refined.path}`);
37245
+ }
36077
37246
  const id = await intake(p, request);
36078
37247
  const result = await design(p, id);
36079
37248
  if (!result.ok) {
@@ -36131,8 +37300,282 @@ kept worktrees, finished ones stay; a queued change starts when the workspace is
36131
37300
  }
36132
37301
  };
36133
37302
 
37303
+ // src/pipeline/quick.ts
37304
+ import { rmSync as rmSync12 } from "node:fs";
37305
+ import { join as join50 } from "node:path";
37306
+ async function revert(dir, paths) {
37307
+ for (const path of paths) {
37308
+ const inHead = (await git(["cat-file", "-e", `HEAD:${path}`], { cwd: dir })).code === 0;
37309
+ if (inHead)
37310
+ await gitOk(["checkout", "-q", "HEAD", "--", path], { cwd: dir });
37311
+ else {
37312
+ await git(["rm", "-q", "--cached", "--ignore-unmatch", "--", path], { cwd: dir });
37313
+ rmSync12(join50(dir, path), { force: true, recursive: true });
37314
+ }
37315
+ }
37316
+ }
37317
+ function gateOutput(report) {
37318
+ return report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.command ?? ""}
37319
+ ${r.output}`).join(`
37320
+
37321
+ `);
37322
+ }
37323
+ async function quickTask(p, input) {
37324
+ if (!p.config.workflow.quick.enabled) {
37325
+ throw new PreconditionError("workflow.quick.enabled is false");
37326
+ }
37327
+ const code = p.codeRepos();
37328
+ const repo = input.repo === undefined ? code.length === 1 ? code[0] : undefined : code.find((r) => r.name === input.repo);
37329
+ if (repo === undefined) {
37330
+ throw new PreconditionError(input.repo === undefined ? "quick: this workspace has several code repos; pass --repo <name>" : `quick: unknown repo "${input.repo}"`, code.map((r) => ` ${r.name} (${r.path})`));
37331
+ }
37332
+ const dir = p.repoDir(repo);
37333
+ await ensureClean(dir, repo.name);
37334
+ const slug = requestSlug(input.task);
37335
+ const branch = `${p.config.workflow.quick.branchPrefix}${slug}`;
37336
+ if (await currentBranch(dir) === branch) {
37337
+ throw new PreconditionError(`already on ${branch}`);
37338
+ }
37339
+ if ((await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: dir })).code === 0) {
37340
+ throw new PreconditionError(`branch ${branch} already exists`);
37341
+ }
37342
+ const base = (await gitOk(["rev-parse", "HEAD"], { cwd: dir })).trim();
37343
+ await gitOk(["switch", "-q", "-c", branch], { cwd: dir });
37344
+ const installed = await installDeps(p.root, dir, repo);
37345
+ const installFailure = installed.find((s) => s.status === "failed");
37346
+ if (installFailure !== undefined) {
37347
+ return {
37348
+ repo: repo.name,
37349
+ branch,
37350
+ ok: false,
37351
+ detail: `install failed: ${installFailure.detail ?? installFailure.dir}`
37352
+ };
37353
+ }
37354
+ const explicit = input.agent === undefined ? undefined : p.config.agents.find((a) => a.id === input.agent);
37355
+ if (input.agent !== undefined && explicit === undefined) {
37356
+ throw new PreconditionError(`quick: unknown agent "${input.agent}"`);
37357
+ }
37358
+ if (explicit !== undefined && explicit.phase !== "implementer") {
37359
+ throw new PreconditionError(`quick: agent "${explicit.id}" is not an implementer`);
37360
+ }
37361
+ const routed = explicit === undefined ? { agent: `implementer-${repo.name}`, role: "implementer", custom: false } : { agent: explicit.id, role: "implementer", custom: true };
37362
+ const context = {
37363
+ directory: dir,
37364
+ workspace: p.root,
37365
+ repo: repo.name,
37366
+ task: "quick",
37367
+ touches: ["**"]
37368
+ };
37369
+ let feedback;
37370
+ let passed = false;
37371
+ let output = "";
37372
+ for (let attempt = 1;attempt <= 3 && !passed; attempt++) {
37373
+ const res = await p.call({
37374
+ changeId: `quick-${slug}`,
37375
+ agent: routed.agent,
37376
+ role: "implementer",
37377
+ modelRole: "implementer",
37378
+ context,
37379
+ title: `quick ${slug} attempt ${attempt}`,
37380
+ step: "quick",
37381
+ kind: "attempt",
37382
+ attempt,
37383
+ prompt: feedback === undefined ? quickPrompt({ task: input.task, repo }) : `${quickPrompt({ task: input.task, repo })}
37384
+
37385
+ Previous attempt failed:
37386
+ ${feedback}`
37387
+ });
37388
+ if (res.handoff === undefined || res.handoff.status !== "done") {
37389
+ return {
37390
+ repo: repo.name,
37391
+ branch,
37392
+ ok: false,
37393
+ detail: res.handoff?.openQuestions.join("; ") ?? res.problem ?? "agent did not finish"
37394
+ };
37395
+ }
37396
+ const changed = await changedFiles(dir, base);
37397
+ const violations = auditChanges({
37398
+ sessionId: "quick-audit",
37399
+ changeId: `quick-${slug}`,
37400
+ role: "implementer",
37401
+ ...context
37402
+ }, changed, true);
37403
+ if (violations.length > 0) {
37404
+ await revert(dir, violations.map((v) => v.path));
37405
+ return {
37406
+ repo: repo.name,
37407
+ branch,
37408
+ ok: false,
37409
+ detail: `edits outside the quick scope were reverted:
37410
+ ${violations.map((v) => `${v.path}: ${v.reason}`).join(`
37411
+ `)}`
37412
+ };
37413
+ }
37414
+ const report = await runGates({
37415
+ root: p.root,
37416
+ config: p.config,
37417
+ repo,
37418
+ worktree: dir,
37419
+ mode: "task",
37420
+ base
37421
+ });
37422
+ if (report.passed) {
37423
+ passed = true;
37424
+ break;
37425
+ }
37426
+ output = gateOutput(report);
37427
+ feedback = output;
37428
+ }
37429
+ if (!passed) {
37430
+ return { repo: repo.name, branch, ok: false, detail: `gates failed:
37431
+ ${output}` };
37432
+ }
37433
+ if (input.review) {
37434
+ let findings = [];
37435
+ for (let round = 1;round <= 3; round++) {
37436
+ const review = await p.call({
37437
+ changeId: `quick-${slug}`,
37438
+ agent: "reviewer",
37439
+ role: "reviewer",
37440
+ modelRole: "reviewer",
37441
+ context: { directory: dir, workspace: p.root, repo: repo.name },
37442
+ title: `quick ${slug} review ${round}`,
37443
+ step: "quick-review",
37444
+ kind: "step",
37445
+ prompt: quickReviewPrompt({ task: input.task, repo })
37446
+ });
37447
+ if (review.handoff === undefined) {
37448
+ return {
37449
+ repo: repo.name,
37450
+ branch,
37451
+ ok: false,
37452
+ detail: review.problem ?? "review produced no handoff"
37453
+ };
37454
+ }
37455
+ findings = (review.handoff.findings ?? []).filter((f) => f.severity !== "nice").map((f) => `- [${f.severity}] ${f.file ?? ""}${f.line ? `:${f.line}` : ""} ${f.problem}
37456
+ fix: ${f.fix}`);
37457
+ if (findings.length === 0)
37458
+ break;
37459
+ const fix = await p.call({
37460
+ changeId: `quick-${slug}`,
37461
+ agent: routed.agent,
37462
+ role: "implementer",
37463
+ modelRole: "implementer",
37464
+ context,
37465
+ title: `quick ${slug} fix ${round}`,
37466
+ step: "quick",
37467
+ kind: "attempt",
37468
+ attempt: 4 + round,
37469
+ prompt: `Fix these review findings for the quick task:
37470
+ ${findings.join(`
37471
+ `)}
37472
+
37473
+ ${quickPrompt({ task: input.task, repo })}`
37474
+ });
37475
+ if (fix.handoff?.status !== "done") {
37476
+ return { repo: repo.name, branch, ok: false, detail: "review fix did not finish" };
37477
+ }
37478
+ const report = await runGates({
37479
+ root: p.root,
37480
+ config: p.config,
37481
+ repo,
37482
+ worktree: dir,
37483
+ mode: "task",
37484
+ base
37485
+ });
37486
+ if (!report.passed) {
37487
+ return {
37488
+ repo: repo.name,
37489
+ branch,
37490
+ ok: false,
37491
+ detail: `gates failed after review fix:
37492
+ ${gateOutput(report)}`
37493
+ };
37494
+ }
37495
+ if (round === 3) {
37496
+ return {
37497
+ repo: repo.name,
37498
+ branch,
37499
+ ok: false,
37500
+ detail: `review findings remain:
37501
+ ${findings.join(`
37502
+ `)}`
37503
+ };
37504
+ }
37505
+ }
37506
+ }
37507
+ const subject = conventionalSubject(input.task, undefined, {
37508
+ style: p.config.commits.style,
37509
+ ...p.config.commits.scopes ? { scope: repo.name } : {}
37510
+ });
37511
+ const commit = await commitAll(dir, subject, { "Change-Id": `quick-${slug}` });
37512
+ return {
37513
+ repo: repo.name,
37514
+ branch,
37515
+ ...commit === undefined ? {} : { commit },
37516
+ ok: true,
37517
+ detail: commit === undefined ? "nothing to commit" : `committed ${commit.slice(0, 12)}`
37518
+ };
37519
+ }
37520
+
37521
+ // src/cli/commands/quick.ts
37522
+ var HELP5 = `Usage: ai-factory quick "<task>" [--repo <name>] [--agent <id>] [--no-review]
37523
+
37524
+ The no-OpenSpec path: create a task/<slug> branch, implement, run the repository gates, run
37525
+ one review pass, then make one local commit. No spec, no approval hash, and no push.
37526
+ --agent selects a configured custom implementer. The branch stays checked out so you can
37527
+ inspect or amend it.`;
37528
+ var quickCommand = {
37529
+ name: "quick",
37530
+ summary: "Implement a small task on a branch without an OpenSpec change",
37531
+ help: HELP5,
37532
+ async run(args, io) {
37533
+ const { values, positionals } = parseCommandArgs("quick", args, {
37534
+ repo: { type: "string" },
37535
+ agent: { type: "string" },
37536
+ "no-review": { type: "boolean", default: false }
37537
+ }, true);
37538
+ const task = positionals.join(" ").trim();
37539
+ if (task === "")
37540
+ throw new UsageError('quick: expected a task, e.g. "fix the login typo"');
37541
+ return withPipeline(io, "quick", async (p) => {
37542
+ const result = await quickTask(p, {
37543
+ task,
37544
+ ...values.repo === undefined ? {} : { repo: values.repo },
37545
+ ...values.agent === undefined ? {} : { agent: values.agent },
37546
+ review: !values["no-review"]
37547
+ });
37548
+ io.out(`${result.ok ? "✓" : "✗"} ${result.repo} ${result.branch}: ${result.detail}`);
37549
+ return result.ok ? ExitCode.Ok : ExitCode.GateFailed;
37550
+ });
37551
+ }
37552
+ };
37553
+
37554
+ // src/cli/commands/refine.ts
37555
+ var HELP6 = `Usage: ai-factory refine "<request>"
37556
+
37557
+ Ask a few scope, verification and constraint questions, then write a refined request to
37558
+ .factory/requests/<slug>.md. Nothing is designed or implemented. Feed it to the pipeline
37559
+ with \`ai-factory run --request .factory/requests/<slug>.md\`, or use \`run --interactive\`
37560
+ to refine in the same command.`;
37561
+ var refineCommand = {
37562
+ name: "refine",
37563
+ summary: "Clarify a vague request before it becomes a spec",
37564
+ help: HELP6,
37565
+ async run(args, io) {
37566
+ const { positionals } = parseCommandArgs("refine", args, {}, true);
37567
+ const request = positionals.join(" ").trim();
37568
+ if (request === "")
37569
+ throw new UsageError('refine: expected a request, e.g. "add search"');
37570
+ const { path } = await refineRequest(io, request);
37571
+ io.out(`refined request: ${path}`);
37572
+ io.out(`next: ai-factory run --request ${path}`);
37573
+ return ExitCode.Ok;
37574
+ }
37575
+ };
37576
+
36134
37577
  // src/cli/commands/repoint.ts
36135
- var HELP5 = `Usage: ai-factory repoint <change-id> [--json]
37578
+ var HELP7 = `Usage: ai-factory repoint <change-id> [--json]
36136
37579
 
36137
37580
  After the submodule MRs of a change are merged upstream: fetches each submodule, finds the
36138
37581
  merged commit (fast-forward, merge commit, Change-Id trailer, or the squash commit with the
@@ -36143,7 +37586,7 @@ Exits ${ExitCode.Precondition} when any pointer is still provisional.`;
36143
37586
  var repointCommand = {
36144
37587
  name: "repoint",
36145
37588
  summary: "Replace provisional submodule pointers after the MRs merged",
36146
- help: HELP5,
37589
+ help: HELP7,
36147
37590
  async run(args, io) {
36148
37591
  const { values, positionals } = parseCommandArgs("repoint", args, { json: { type: "boolean", default: false } }, true);
36149
37592
  const id = positionals[0];
@@ -36172,8 +37615,8 @@ var repointCommand = {
36172
37615
  };
36173
37616
 
36174
37617
  // src/cli/commands/state-commands.ts
36175
- import { existsSync as existsSync34, readdirSync as readdirSync13 } from "node:fs";
36176
- import { join as join47 } from "node:path";
37618
+ import { existsSync as existsSync35, readdirSync as readdirSync14 } from "node:fs";
37619
+ import { join as join51 } from "node:path";
36177
37620
  async function workspace(io) {
36178
37621
  const root = await findWorkspaceRoot(io.cwd);
36179
37622
  if (root === undefined)
@@ -36222,9 +37665,9 @@ function taskDetail(run, task) {
36222
37665
  }
36223
37666
  function worktreesOf(root, id) {
36224
37667
  const dir = factoryDir(root, "worktrees", id);
36225
- if (!existsSync34(dir))
37668
+ if (!existsSync35(dir))
36226
37669
  return [];
36227
- return readdirSync13(dir).filter((repo) => repo !== ".opencode").flatMap((repo) => readdirSync13(join47(dir, repo)).map((task) => `.factory/worktrees/${id}/${repo}/${task}`));
37670
+ return readdirSync14(dir).filter((repo) => repo !== ".opencode").flatMap((repo) => readdirSync14(join51(dir, repo)).map((task) => `.factory/worktrees/${id}/${repo}/${task}`));
36228
37671
  }
36229
37672
  function printRun(io, root, run, active, queuePos) {
36230
37673
  const role = active?.changeId === run.id ? `active, ${driverAlive(active) ? `driven by pid ${active.pid}` : "no process driving it (resume to continue)"}` : queuePos >= 0 ? `queued #${queuePos + 1}` : "not active";
@@ -36382,20 +37825,20 @@ that were cancelled because of it. Continue the change with \`ai-factory resume
36382
37825
  };
36383
37826
 
36384
37827
  // src/cli/commands/update.ts
36385
- import { rmSync as rmSync13, writeFileSync as writeFileSync16 } from "node:fs";
36386
- import { join as join49 } from "node:path";
37828
+ import { rmSync as rmSync14, writeFileSync as writeFileSync17 } from "node:fs";
37829
+ import { join as join53 } from "node:path";
36387
37830
 
36388
37831
  // src/update/update.ts
36389
37832
  import {
36390
37833
  cpSync as cpSync2,
36391
- existsSync as existsSync35,
36392
- mkdirSync as mkdirSync17,
36393
- readdirSync as readdirSync14,
36394
- readFileSync as readFileSync25,
36395
- rmSync as rmSync12,
36396
- writeFileSync as writeFileSync15
37834
+ existsSync as existsSync36,
37835
+ mkdirSync as mkdirSync19,
37836
+ readdirSync as readdirSync15,
37837
+ readFileSync as readFileSync28,
37838
+ rmSync as rmSync13,
37839
+ writeFileSync as writeFileSync16
36397
37840
  } from "node:fs";
36398
- import { dirname as dirname9, join as join48 } from "node:path";
37841
+ import { dirname as dirname10, join as join52 } from "node:path";
36399
37842
 
36400
37843
  // src/migrations/001-graph-exclude-defaults.ts
36401
37844
  var V1_GRAPH_EXCLUDE = [
@@ -36421,8 +37864,19 @@ var graphExcludeDefaults = {
36421
37864
  }
36422
37865
  };
36423
37866
 
37867
+ // src/migrations/002-config-v3.ts
37868
+ var configV3 = {
37869
+ from: 2,
37870
+ to: 3,
37871
+ name: "config-v3",
37872
+ migrate(text) {
37873
+ parseJsonc(text);
37874
+ return setJsonc(text, ["schemaVersion"], 3);
37875
+ }
37876
+ };
37877
+
36424
37878
  // src/migrations/index.ts
36425
- var MIGRATIONS = [graphExcludeDefaults];
37879
+ var MIGRATIONS = [graphExcludeDefaults, configV3];
36426
37880
  function schemaVersionOf(text) {
36427
37881
  const { value } = parseJsonc(text);
36428
37882
  const v = value?.schemaVersion;
@@ -36484,7 +37938,7 @@ function changesInProgress(root) {
36484
37938
  return [...ids].sort();
36485
37939
  }
36486
37940
  async function planUpdate(root, lock, options) {
36487
- const original = readFileSync25(join48(root, CONFIG_FILE), "utf8");
37941
+ const original = readFileSync28(join52(root, CONFIG_FILE), "utf8");
36488
37942
  const migration = migrateConfigText(original);
36489
37943
  const config = parseConfigText(migration.text, CONFIG_FILE);
36490
37944
  const plan = planWorkspace(root, config, {
@@ -36544,20 +37998,20 @@ function formatPlan(plan) {
36544
37998
  var backupsDir = (root) => factoryDir(root, "backups");
36545
37999
  function backup(root, plan) {
36546
38000
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
36547
- const dir = join48(backupsDir(root), stamp);
38001
+ const dir = join52(backupsDir(root), stamp);
36548
38002
  const files = [];
36549
38003
  const created = [];
36550
38004
  for (const rel of plan.touched) {
36551
- const abs = join48(root, rel);
36552
- if (!existsSync35(abs)) {
38005
+ const abs = join52(root, rel);
38006
+ if (!existsSync36(abs)) {
36553
38007
  created.push(rel);
36554
38008
  continue;
36555
38009
  }
36556
- mkdirSync17(dirname9(join48(dir, "files", rel)), { recursive: true });
36557
- cpSync2(abs, join48(dir, "files", rel), { recursive: true });
38010
+ mkdirSync19(dirname10(join52(dir, "files", rel)), { recursive: true });
38011
+ cpSync2(abs, join52(dir, "files", rel), { recursive: true });
36558
38012
  files.push(rel);
36559
38013
  }
36560
- writeJsonAtomic(join48(dir, "manifest.json"), {
38014
+ writeJsonAtomic(join52(dir, "manifest.json"), {
36561
38015
  at: new Date().toISOString(),
36562
38016
  fromVersion: plan.fromVersion,
36563
38017
  toVersion: plan.toVersion,
@@ -36568,28 +38022,28 @@ function backup(root, plan) {
36568
38022
  }
36569
38023
  function latestBackup(root) {
36570
38024
  const base = backupsDir(root);
36571
- if (!existsSync35(base))
38025
+ if (!existsSync36(base))
36572
38026
  return;
36573
- for (const name of readdirSync14(base).sort().reverse()) {
36574
- const file = join48(base, name, "manifest.json");
36575
- if (!existsSync35(file))
38027
+ for (const name of readdirSync15(base).sort().reverse()) {
38028
+ const file = join52(base, name, "manifest.json");
38029
+ if (!existsSync36(file))
36576
38030
  continue;
36577
- const manifest = JSON.parse(readFileSync25(file, "utf8"));
38031
+ const manifest = JSON.parse(readFileSync28(file, "utf8"));
36578
38032
  if (!manifest.restored)
36579
- return { dir: join48(base, name), manifest };
38033
+ return { dir: join52(base, name), manifest };
36580
38034
  }
36581
38035
  return;
36582
38036
  }
36583
38037
  async function restoreBackup(root, dir, manifest) {
36584
38038
  for (const rel of manifest.created)
36585
- rmSync12(join48(root, rel), { recursive: true, force: true });
38039
+ rmSync13(join52(root, rel), { recursive: true, force: true });
36586
38040
  for (const rel of manifest.files) {
36587
- const target = join48(root, rel);
36588
- rmSync12(target, { recursive: true, force: true });
36589
- mkdirSync17(dirname9(target), { recursive: true });
36590
- cpSync2(join48(dir, "files", rel), target, { recursive: true });
38041
+ const target = join52(root, rel);
38042
+ rmSync13(target, { recursive: true, force: true });
38043
+ mkdirSync19(dirname10(target), { recursive: true });
38044
+ cpSync2(join52(dir, "files", rel), target, { recursive: true });
36591
38045
  }
36592
- writeFileSync15(join48(dir, "manifest.json"), `${JSON.stringify({ ...manifest, restored: true }, null, 2)}
38046
+ writeFileSync16(join52(dir, "manifest.json"), `${JSON.stringify({ ...manifest, restored: true }, null, 2)}
36593
38047
  `);
36594
38048
  return [...manifest.files, ...manifest.created];
36595
38049
  }
@@ -36683,7 +38137,7 @@ Upgrades this workspace to the installed factory version, as one local commit
36683
38137
  if (!t.ok)
36684
38138
  io.out(`warning: ${t.tool}: ${t.problem} → ${t.fix}`);
36685
38139
  const backupDir = backup(root, plan);
36686
- writeFileSync16(join49(root, CONFIG_FILE), plan.configText);
38140
+ writeFileSync17(join53(root, CONFIG_FILE), plan.configText);
36687
38141
  const result = await syncWorkspace(root, plan.config, {
36688
38142
  merge: true,
36689
38143
  ...values.restore ? { restore: true } : {},
@@ -36698,7 +38152,7 @@ Upgrades this workspace to the installed factory version, as one local commit
36698
38152
  }
36699
38153
  const commit = await commitInstall(root, `chore(factory): update to v${FACTORY_VERSION}`);
36700
38154
  if (commit === undefined)
36701
- rmSync13(backupDir, { recursive: true, force: true });
38155
+ rmSync14(backupDir, { recursive: true, force: true });
36702
38156
  if (plan.graphRebuild) {
36703
38157
  const graph = await updateGraph(root, { full: true });
36704
38158
  io.out(graph.ok ? "graph: rebuilt" : `graph: rebuild failed (${graph.error ?? ""}); run \`ai-factory graph --full\``);
@@ -36752,7 +38206,9 @@ var COMMANDS = [
36752
38206
  rollbackCommand,
36753
38207
  doctorCommand,
36754
38208
  detectCommand,
38209
+ refineCommand,
36755
38210
  runCommand,
38211
+ quickCommand,
36756
38212
  approveCommand,
36757
38213
  resumeCommand,
36758
38214
  statusCommand,
@@ -36799,6 +38255,16 @@ async function askYesNo(question) {
36799
38255
  rl.close();
36800
38256
  }
36801
38257
  }
38258
+ async function askText(question, initial) {
38259
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
38260
+ try {
38261
+ const suffix = initial === undefined || initial === "" ? "" : ` (${initial})`;
38262
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
38263
+ return answer === "" ? initial : answer;
38264
+ } finally {
38265
+ rl.close();
38266
+ }
38267
+ }
36802
38268
  async function runCli(argv, io) {
36803
38269
  let cwd = io.cwd;
36804
38270
  let i = 0;
@@ -36810,7 +38276,7 @@ async function runCli(argv, io) {
36810
38276
  io.err("error: -C needs a directory");
36811
38277
  return ExitCode.Usage;
36812
38278
  }
36813
- cwd = resolve5(cwd, dir);
38279
+ cwd = resolve6(cwd, dir);
36814
38280
  } else if (arg === "-V" || arg === "--version") {
36815
38281
  io.out(FACTORY_VERSION);
36816
38282
  return ExitCode.Ok;
@@ -36852,6 +38318,6 @@ var code = await runCli(process.argv.slice(2), {
36852
38318
  `),
36853
38319
  err: (text) => process.stderr.write(`${text}
36854
38320
  `),
36855
- ...process.stdin.isTTY ? { confirm: askYesNo } : {}
38321
+ ...process.stdin.isTTY ? { confirm: askYesNo, ask: askText } : {}
36856
38322
  });
36857
38323
  process.exit(code);