@supporterino/ai-factory 0.2.0 → 0.3.0

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.0";
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
  }
@@ -31097,7 +31759,7 @@ class OpencodeRuntime {
31097
31759
  }
31098
31760
  const password = randomBytes(18).toString("base64url");
31099
31761
  const port = await freePort();
31100
- mkdirSync8(join25(this.#opts.root, ".factory", "opencode"), { recursive: true });
31762
+ mkdirSync8(join26(this.#opts.root, ".factory", "opencode"), { recursive: true });
31101
31763
  this.#base = `http://127.0.0.1:${port}`;
31102
31764
  this.#auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
31103
31765
  this.#proc = spawn2(toolBin("opencode"), ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
@@ -31206,7 +31868,7 @@ class OpencodeRuntime {
31206
31868
  throw new Error(`opencode session create failed (${created.status}): ${JSON.stringify(created.data)}`);
31207
31869
  }
31208
31870
  const file = sessionContextFile(this.#opts.root, id);
31209
- mkdirSync8(join25(this.#opts.root, ".factory", "sessions"), { recursive: true });
31871
+ mkdirSync8(join26(this.#opts.root, ".factory", "sessions"), { recursive: true });
31210
31872
  writeFileSync8(file, JSON.stringify({ sessionId: id, ...spec.context }, null, 2));
31211
31873
  return {
31212
31874
  id,
@@ -31425,8 +32087,8 @@ function gateExecutables(command) {
31425
32087
  }
31426
32088
  function onPath(name, dirs) {
31427
32089
  if (name.includes("/"))
31428
- return dirs.some((d) => existsSync20(join26(d, name)));
31429
- return dirs.some((d) => d !== "" && existsSync20(join26(d, name)));
32090
+ return dirs.some((d) => existsSync20(join27(d, name)));
32091
+ return dirs.some((d) => d !== "" && existsSync20(join27(d, name)));
31430
32092
  }
31431
32093
  function dirSize(path) {
31432
32094
  let total = 0;
@@ -31440,7 +32102,7 @@ function dirSize(path) {
31440
32102
  continue;
31441
32103
  }
31442
32104
  for (const name of entries) {
31443
- const child = join26(dir, name);
32105
+ const child = join27(dir, name);
31444
32106
  const st = lstatSync2(child, { throwIfNoEntry: false });
31445
32107
  if (st === undefined || st.isSymbolicLink())
31446
32108
  continue;
@@ -31558,12 +32220,12 @@ async function checkRepos(c, root, config, uninitialised) {
31558
32220
  }
31559
32221
  const checkouts = [{ name: "AI repo", dir: root }];
31560
32222
  for (const repo of config.repos) {
31561
- const dir = join26(root, repo.path);
32223
+ const dir = join27(root, repo.path);
31562
32224
  if (repo.path === ".") {
31563
32225
  ready.push(repo);
31564
32226
  continue;
31565
32227
  }
31566
- if (!existsSync20(join26(dir, ".git"))) {
32228
+ if (!existsSync20(join27(dir, ".git"))) {
31567
32229
  if (!uninitialised.includes(repo.path))
31568
32230
  c.add("warn", repo.name, `${repo.path} is not a git checkout`);
31569
32231
  continue;
@@ -31584,12 +32246,36 @@ async function checkRepos(c, root, config, uninitialised) {
31584
32246
  }
31585
32247
  return ready;
31586
32248
  }
32249
+ function packageScript(dir, name) {
32250
+ try {
32251
+ const pkg = JSON.parse(readFileSync18(join27(dir, "package.json"), "utf8"));
32252
+ const value = pkg.scripts?.[name];
32253
+ return typeof value === "string" ? value : undefined;
32254
+ } catch {
32255
+ return;
32256
+ }
32257
+ }
32258
+ var WRITE_FLAG = /(^|\s)(--write|--fix|--apply|-w)(\s|$)/;
32259
+ function checkWriteModeScripts(c, root, config, repos) {
32260
+ c.in("gates");
32261
+ for (const repo of repos.filter((r) => r.role === "code")) {
32262
+ const dir = join27(root, repo.path);
32263
+ for (const [type, name] of Object.entries(config.gates.scripts)) {
32264
+ if (typeof name !== "string")
32265
+ continue;
32266
+ const script = packageScript(dir, name);
32267
+ if (script !== undefined && WRITE_FLAG.test(script)) {
32268
+ 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}`);
32269
+ }
32270
+ }
32271
+ }
32272
+ }
31587
32273
  function checkGates(c, root, config, repos) {
31588
32274
  c.in("gates");
31589
32275
  const rows = [];
31590
32276
  const pathDirs = (process.env.PATH ?? "").split(delimiter);
31591
32277
  for (const repo of repos.filter((r) => r.role === "code")) {
31592
- const dir = join26(root, repo.path);
32278
+ const dir = join27(root, repo.path);
31593
32279
  const plan = resolveGates({
31594
32280
  config,
31595
32281
  repo,
@@ -31604,11 +32290,12 @@ function checkGates(c, root, config, repos) {
31604
32290
  rows.push({
31605
32291
  repo: repo.name,
31606
32292
  type: g.type,
32293
+ ...g.name === undefined ? {} : { name: g.name },
31607
32294
  cwd: g.cwd,
31608
32295
  command: g.command,
31609
32296
  source: g.source
31610
32297
  });
31611
- const dirs = [...g.binDirs.map((b) => join26(dir, g.cwd, b)), join26(dir, g.cwd), ...pathDirs];
32298
+ const dirs = [...g.binDirs.map((b) => join27(dir, g.cwd, b)), join27(dir, g.cwd), ...pathDirs];
31612
32299
  for (const exe of gateExecutables(g.command))
31613
32300
  if (!onPath(exe, dirs))
31614
32301
  missingTools.add(exe);
@@ -31636,10 +32323,10 @@ function looseTsconfig(dir, gates) {
31636
32323
  const gate = gates.find((g) => g.type === "types");
31637
32324
  if (gate === undefined || /--strict\b/.test(gate.command))
31638
32325
  return;
31639
- const rel = join26(gate.cwd, "tsconfig.json");
32326
+ const rel = join27(gate.cwd, "tsconfig.json");
31640
32327
  let text;
31641
32328
  try {
31642
- text = readFileSync16(join26(dir, rel), "utf8");
32329
+ text = readFileSync18(join27(dir, rel), "utf8");
31643
32330
  } catch {
31644
32331
  return;
31645
32332
  }
@@ -31648,14 +32335,81 @@ function looseTsconfig(dir, gates) {
31648
32335
  return;
31649
32336
  return tsconfig.compilerOptions?.strict === true ? undefined : rel;
31650
32337
  }
32338
+ function checkContext(c, root, config) {
32339
+ c.in("context");
32340
+ const paths = allInstructionPaths(config);
32341
+ const missing = paths.filter((p) => !existsSync20(join27(root, p)));
32342
+ if (missing.length > 0)
32343
+ c.add("warn", "instructions", `missing: ${missing.join(", ")}`, "create the files or remove them from instructions");
32344
+ else
32345
+ c.add("ok", "instructions", `${paths.length} path(s) injected into sessions`);
32346
+ const names = [
32347
+ ...Object.values(config.skills.roles).flat(),
32348
+ ...Object.values(config.skills.repos).flat()
32349
+ ];
32350
+ if (names.length > 0) {
32351
+ const found = new Set;
32352
+ for (const source of config.skills.sources) {
32353
+ const dir = join27(root, source);
32354
+ if (!existsSync20(dir))
32355
+ continue;
32356
+ try {
32357
+ for (const name of readdirSync7(dir)) {
32358
+ const skill = join27(dir, name, "SKILL.md");
32359
+ if (existsSync20(skill))
32360
+ found.add(name);
32361
+ }
32362
+ } catch {}
32363
+ }
32364
+ const unknown = [...new Set(names)].filter((n) => !found.has(n));
32365
+ if (unknown.length > 0)
32366
+ 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");
32367
+ else
32368
+ c.add("ok", "skills", `${new Set(names).size} skill(s) configured`);
32369
+ }
32370
+ const legacy = join27(root, ".factory", "legacy");
32371
+ if (existsSync20(legacy)) {
32372
+ const stamps = readdirSync7(legacy).sort();
32373
+ c.add("ok", "legacy setup", `${stamps.length} archived set(s) under .factory/legacy/`);
32374
+ }
32375
+ }
32376
+ function checkRelease(c, root, config) {
32377
+ c.in("release");
32378
+ if (!config.release.detect) {
32379
+ c.add("ok", "release", "detection disabled");
32380
+ return;
32381
+ }
32382
+ const found = [];
32383
+ for (const file of [
32384
+ ".release-please-config.json",
32385
+ ".goreleaser.yaml",
32386
+ ".goreleaser.yml",
32387
+ ".changeset/config.json",
32388
+ ".github/workflows",
32389
+ ".gitlab-ci.yml"
32390
+ ]) {
32391
+ if (existsSync20(join27(root, file)))
32392
+ found.push(file);
32393
+ }
32394
+ for (const repo of config.repos.filter((r) => r.path !== ".")) {
32395
+ for (const file of ["release-please-config.json", ".goreleaser.yaml", ".gitlab-ci.yml"]) {
32396
+ if (existsSync20(join27(root, repo.path, file)))
32397
+ found.push(`${repo.path}/${file}`);
32398
+ }
32399
+ }
32400
+ if (found.length === 0)
32401
+ c.add("ok", "release", "no release tooling detected");
32402
+ else
32403
+ c.add("ok", "release", `detected ${found.join(", ")}; commits stay local and the factory does not edit release workflows`);
32404
+ }
31651
32405
  function checkCaches(c, root) {
31652
32406
  c.in("caches");
31653
- const factory = join26(root, ".factory");
32407
+ const factory = join27(root, ".factory");
31654
32408
  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")]
32409
+ ["dependency caches", join27(factory, "cache")],
32410
+ ["worktrees", join27(factory, "worktrees")],
32411
+ ["logs", join27(factory, "logs")],
32412
+ ["backups", join27(factory, "backups")]
31659
32413
  ]) {
31660
32414
  if (!existsSync20(path))
31661
32415
  continue;
@@ -31664,11 +32418,11 @@ function checkCaches(c, root) {
31664
32418
  }
31665
32419
  async function checkModels(c, root, config) {
31666
32420
  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-"));
32421
+ const configured = modelEntries(config);
32422
+ const tmp = mkdtempSync4(join27(tmpdir4(), "ai-factory-doctor-"));
31669
32423
  const res = await exec([toolBin("opencode"), "models", "--standalone"], {
31670
32424
  cwd: root,
31671
- env: { ...serverEnv(root, config, "doctor"), OPENCODE_DB: join26(tmp, "opencode.db") },
32425
+ env: { ...serverEnv(root, config, "doctor"), OPENCODE_DB: join27(tmp, "opencode.db") },
31672
32426
  replaceEnv: true,
31673
32427
  timeoutMs: 60000
31674
32428
  }).finally(() => rmSync8(tmp, { recursive: true, force: true }));
@@ -31684,7 +32438,7 @@ async function checkModels(c, root, config) {
31684
32438
  if (available.has(id))
31685
32439
  c.add("ok", role, model);
31686
32440
  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}`);
32441
+ 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
32442
  }
31689
32443
  }
31690
32444
  var CODE_EXTENSIONS = new Set(Object.values(PROFILES).flatMap((p) => p.extensions));
@@ -31799,7 +32553,10 @@ async function runDoctor(root) {
31799
32553
  checkEnvironment(c, process.env);
31800
32554
  checkManaged(c, root, config);
31801
32555
  const repos = await checkRepos(c, root, config, uninitialised);
32556
+ checkWriteModeScripts(c, root, config, repos);
31802
32557
  const gates = checkGates(c, root, config, repos);
32558
+ checkContext(c, root, config);
32559
+ checkRelease(c, root, config);
31803
32560
  checkCaches(c, root);
31804
32561
  await checkModels(c, root, config);
31805
32562
  await checkGraph(c, root, config, lock);
@@ -31822,11 +32579,12 @@ function print(io, report) {
31822
32579
  }
31823
32580
  if (report.gates.length > 0) {
31824
32581
  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));
32582
+ const label = (g) => `${g.repo} ${g.name ?? g.type}`;
32583
+ const width = Math.max(...report.gates.map((g) => label(g).length));
31826
32584
  for (const g of report.gates) {
31827
32585
  const where = g.cwd === "." ? "" : ` [${g.cwd}]`;
31828
32586
  const what = g.command ? `${g.command} (${g.source})` : g.problem ?? "";
31829
- io.out(` ${`${g.repo} ${g.type}`.padEnd(width)}${where} ${what}`);
32587
+ io.out(` ${label(g).padEnd(width)}${where} ${what}`);
31830
32588
  }
31831
32589
  }
31832
32590
  const fails = report.checks.filter((c) => c.status === "fail").length;
@@ -31878,14 +32636,14 @@ Exit code ${ExitCode.Precondition} when a check fails; warnings exit 0.`,
31878
32636
 
31879
32637
  // src/cli/commands/gate.ts
31880
32638
  import { existsSync as existsSync25, readdirSync as readdirSync9 } from "node:fs";
31881
- import { join as join33 } from "node:path";
32639
+ import { join as join34 } from "node:path";
31882
32640
 
31883
32641
  // src/gates/run.ts
31884
- import { delimiter as delimiter2, join as join30 } from "node:path";
32642
+ import { delimiter as delimiter2, join as join31 } from "node:path";
31885
32643
 
31886
32644
  // src/addons/shortcuts.ts
31887
- import { readFileSync as readFileSync17 } from "node:fs";
31888
- import { join as join27 } from "node:path";
32645
+ import { readFileSync as readFileSync19 } from "node:fs";
32646
+ import { join as join28 } from "node:path";
31889
32647
  var SHORTCUT = /(?:\/\/|#|\/\*|\*|--|<!--|;)\s*(?:shortcut|ponytail):\s*(.+?)\s*(?:\*\/|-->)?\s*$/i;
31890
32648
  function shortcutIn(line) {
31891
32649
  return line.match(SHORTCUT)?.[1];
@@ -31922,7 +32680,7 @@ async function newShortcuts(dir, base) {
31922
32680
  `).filter(Boolean)) {
31923
32681
  let text;
31924
32682
  try {
31925
- text = readFileSync17(join27(dir, file), "utf8");
32683
+ text = readFileSync19(join28(dir, file), "utf8");
31926
32684
  } catch {
31927
32685
  continue;
31928
32686
  }
@@ -31990,8 +32748,8 @@ function compactOutput(raw, maxLines) {
31990
32748
 
31991
32749
  // src/gates/guard.ts
31992
32750
  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";
32751
+ import { existsSync as existsSync21, mkdirSync as mkdirSync9, readFileSync as readFileSync20, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "node:fs";
32752
+ import { dirname as dirname6, join as join29 } from "node:path";
31995
32753
  var MISSING = "missing";
31996
32754
  async function dirtyPaths2(worktree) {
31997
32755
  const res = await git(["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], {
@@ -32004,13 +32762,13 @@ async function dirtyPaths2(worktree) {
32004
32762
  function hashFile(abs) {
32005
32763
  if (!existsSync21(abs))
32006
32764
  return { hash: MISSING };
32007
- const content = readFileSync18(abs);
32765
+ const content = readFileSync20(abs);
32008
32766
  return { hash: createHash3("sha256").update(content).digest("hex"), content };
32009
32767
  }
32010
32768
  async function snapshot(worktree) {
32011
32769
  const entries = new Map;
32012
32770
  for (const path of await dirtyPaths2(worktree))
32013
- entries.set(path, hashFile(join28(worktree, path)));
32771
+ entries.set(path, hashFile(join29(worktree, path)));
32014
32772
  return { worktree, head: await gitOk(["rev-parse", "HEAD"], { cwd: worktree }), entries };
32015
32773
  }
32016
32774
  async function changedSince(before) {
@@ -32033,7 +32791,7 @@ async function restore(before, paths) {
32033
32791
  await gitOk(["reset", "-q", "--mixed", before.head], { cwd: before.worktree });
32034
32792
  }
32035
32793
  for (const path of paths.filter((p) => p !== "HEAD")) {
32036
- const abs = join28(before.worktree, path);
32794
+ const abs = join29(before.worktree, path);
32037
32795
  const entry = before.entries.get(path);
32038
32796
  if (entry === undefined) {
32039
32797
  const tracked = await gitOk(["ls-files", "--", path], { cwd: before.worktree });
@@ -32052,19 +32810,19 @@ async function restore(before, paths) {
32052
32810
 
32053
32811
  // src/gates/install.ts
32054
32812
  import { existsSync as existsSync22, mkdirSync as mkdirSync10 } from "node:fs";
32055
- import { join as join29 } from "node:path";
32813
+ import { join as join30 } from "node:path";
32056
32814
  function cacheEnv(root, repoName) {
32057
- const cache = join29(root, ".factory", "cache");
32815
+ const cache = join30(root, ".factory", "cache");
32058
32816
  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)
32817
+ npm_config_cache: join30(cache, "npm"),
32818
+ npm_config_store_dir: join30(cache, "pnpm-store"),
32819
+ BUN_INSTALL_CACHE_DIR: join30(cache, "bun"),
32820
+ YARN_CACHE_FOLDER: join30(cache, "yarn"),
32821
+ UV_CACHE_DIR: join30(cache, "uv"),
32822
+ GOMODCACHE: join30(cache, "go", "mod"),
32823
+ GOCACHE: join30(cache, "go", "build"),
32824
+ GOLANGCI_LINT_CACHE: join30(cache, "golangci-lint"),
32825
+ CARGO_TARGET_DIR: join30(cache, "cargo-target", repoName)
32068
32826
  };
32069
32827
  for (const dir of Object.values(env))
32070
32828
  mkdirSync10(dir, { recursive: true });
@@ -32083,7 +32841,7 @@ async function installDeps(root, worktree, repo, timeoutMs = 15 * 60000) {
32083
32841
  const done = new Set;
32084
32842
  for (const profile of profilesFor(repoLanguages(repo))) {
32085
32843
  for (const dir of installDirs(profile, files)) {
32086
- const step = profile.install.find((s) => existsSync22(join29(worktree, dir, s.lockfile)));
32844
+ const step = profile.install.find((s) => existsSync22(join30(worktree, dir, s.lockfile)));
32087
32845
  const key = `${dir}\x00${step?.command ?? profile.id}`;
32088
32846
  if (done.has(key))
32089
32847
  continue;
@@ -32098,7 +32856,7 @@ async function installDeps(root, worktree, repo, timeoutMs = 15 * 60000) {
32098
32856
  continue;
32099
32857
  }
32100
32858
  const res = await exec(["sh", "-c", step.command], {
32101
- cwd: join29(worktree, dir),
32859
+ cwd: join30(worktree, dir),
32102
32860
  env,
32103
32861
  replaceEnv: true,
32104
32862
  timeoutMs
@@ -32120,22 +32878,30 @@ var FAILING = new Set(["fail", "missing", "not-check-only", "timeout"]);
32120
32878
  var TEST_GATES = new Set(["test", "testAffected"]);
32121
32879
  async function runOne(input, gate) {
32122
32880
  const before = await snapshot(input.worktree);
32123
- const bins = gate.binDirs.map((b) => join30(input.worktree, b));
32881
+ const bins = gate.binDirs.map((b) => join31(input.worktree, b));
32124
32882
  const env = {
32125
32883
  ...agentProcessEnv(),
32126
32884
  ...cacheEnv(input.root, input.repo.name),
32127
32885
  ...QUIET_ENV,
32886
+ ...gate.env ?? {},
32128
32887
  PATH: [...bins, process.env.PATH ?? ""].join(delimiter2)
32129
32888
  };
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),
32889
+ const argv = [
32890
+ "sh",
32891
+ "-c",
32892
+ gate.setup === undefined ? gate.command : `set -e
32893
+ ${gate.setup}
32894
+ ${gate.command}`
32895
+ ];
32896
+ const res = await exec(sandboxEnabled(input.config.security.sandbox) ? sandboxArgv(seatbeltProfile(checkoutPolicy(input.root, input.worktree, [join31(input.root, ".factory", "cache")])), argv) : argv, {
32897
+ cwd: join31(input.worktree, gate.cwd),
32133
32898
  env,
32134
32899
  replaceEnv: true,
32135
- timeoutMs: input.timeoutMs ?? 15 * 60000
32900
+ timeoutMs: gate.timeoutMs ?? input.timeoutMs ?? 15 * 60000
32136
32901
  });
32137
32902
  const base = {
32138
32903
  type: gate.type,
32904
+ ...gate.name === undefined ? {} : { name: gate.name },
32139
32905
  cwd: gate.cwd,
32140
32906
  command: gate.command,
32141
32907
  source: gate.source,
@@ -32143,8 +32909,46 @@ async function runOne(input, gate) {
32143
32909
  durationMs: res.durationMs
32144
32910
  };
32145
32911
  const max = input.config.tokens.gateOutputMaxLines;
32912
+ const raw = `${res.stdout}
32913
+ ${res.stderr}`;
32914
+ const output = redactProcess(compactOutput(raw, max));
32146
32915
  const modified = await changedSince(before);
32147
- if (modified.length > 0) {
32916
+ const outputs = gate.outputs ?? [];
32917
+ if (outputs.length > 0) {
32918
+ const drifted = modified.filter((path) => outputs.some((glob) => opencodeGlobRegExp(glob).test(path)));
32919
+ if (modified.length > 0)
32920
+ await restore(before, modified);
32921
+ if (res.timedOut)
32922
+ return { ...base, status: "timeout", output: `gate timed out: \`${gate.command}\`` };
32923
+ if (res.code !== 0)
32924
+ return { ...base, status: "fail", output: output || `gate failed: \`${gate.command}\`` };
32925
+ if (modified.includes("HEAD")) {
32926
+ return {
32927
+ ...base,
32928
+ status: "fail",
32929
+ output: `generated gate moved HEAD: \`${gate.command}\` must not commit (restored)`
32930
+ };
32931
+ }
32932
+ const extraWrites = modified.filter((path) => !drifted.includes(path));
32933
+ if (extraWrites.length > 0) {
32934
+ return {
32935
+ ...base,
32936
+ status: "fail",
32937
+ modified: extraWrites,
32938
+ output: `generated gate wrote outside its outputs: ${extraWrites.join(", ")} (restored)`
32939
+ };
32940
+ }
32941
+ if (drifted.length > 0) {
32942
+ return {
32943
+ ...base,
32944
+ status: "fail",
32945
+ modified: drifted,
32946
+ output: `generated artifacts are stale: ${drifted.join(", ")} (regenerate and commit them)`
32947
+ };
32948
+ }
32949
+ return { ...base, status: "pass", output: "" };
32950
+ }
32951
+ if (modified.length > 0 && gate.checkOnly !== false) {
32148
32952
  await restore(before, modified);
32149
32953
  return {
32150
32954
  ...base,
@@ -32155,12 +32959,10 @@ async function runOne(input, gate) {
32155
32959
  }
32156
32960
  if (res.timedOut)
32157
32961
  return { ...base, status: "timeout", output: `gate timed out: \`${gate.command}\`` };
32158
- const raw = `${res.stdout}
32159
- ${res.stderr}`;
32160
32962
  return {
32161
32963
  ...base,
32162
32964
  status: res.code === 0 ? "pass" : "fail",
32163
- output: res.code === 0 ? "" : redactProcess(compactOutput(raw, max))
32965
+ output: res.code === 0 ? "" : output
32164
32966
  };
32165
32967
  }
32166
32968
  function relatedFiles(root, repoPath, changed) {
@@ -32243,13 +33045,13 @@ async function runGates(input) {
32243
33045
 
32244
33046
  // src/git/worktrees.ts
32245
33047
  import { existsSync as existsSync23, mkdirSync as mkdirSync11 } from "node:fs";
32246
- import { dirname as dirname7, join as join31 } from "node:path";
33048
+ import { dirname as dirname7, join as join32 } from "node:path";
32247
33049
  var taskBranch = (id, task) => `${changeBranch(id)}--${task}`;
32248
- var worktreePath = (root, id, repo, task) => join31(root, ".factory", "worktrees", id, repo, task);
33050
+ var worktreePath = (root, id, repo, task) => join32(root, ".factory", "worktrees", id, repo, task);
32249
33051
  async function addTaskWorktree(root, repoDir, repoName, id, task) {
32250
33052
  const path = worktreePath(root, id, repoName, task);
32251
33053
  const branch = taskBranch(id, task);
32252
- if (existsSync23(join31(path, ".git")))
33054
+ if (existsSync23(join32(path, ".git")))
32253
33055
  return { path, branch, created: false };
32254
33056
  mkdirSync11(dirname7(path), { recursive: true });
32255
33057
  await gitOk(["worktree", "prune"], { cwd: repoDir });
@@ -32268,20 +33070,27 @@ async function removeTaskWorktree(repoDir, wt) {
32268
33070
  }
32269
33071
 
32270
33072
  // 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");
33073
+ import { cpSync, existsSync as existsSync24, mkdirSync as mkdirSync12, readdirSync as readdirSync8, readFileSync as readFileSync21, rmSync as rmSync10 } from "node:fs";
33074
+ import { join as join33 } from "node:path";
33075
+ var gateFile = (root, id) => join33(root, ".factory", "runs", id, "snapshot", "gates.json");
32274
33076
  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");
33077
+ const snapshotRoot = join33(root, ".factory", "worktrees", id, ".opencode");
33078
+ const agents = join33(root, ".opencode", "agents");
33079
+ const target = join33(snapshotRoot, "agents");
33080
+ const skills = join33(root, ".opencode", "skills");
33081
+ const skillTarget = join33(snapshotRoot, "skills");
32277
33082
  if (replace) {
32278
- rmSync10(target, { recursive: true, force: true });
33083
+ rmSync10(snapshotRoot, { recursive: true, force: true });
32279
33084
  rmSync10(gateFile(root, id), { force: true });
32280
33085
  }
32281
33086
  if (existsSync24(agents) && !existsSync24(target)) {
32282
33087
  mkdirSync12(target, { recursive: true });
32283
33088
  cpSync(agents, target, { recursive: true });
32284
33089
  }
33090
+ if (existsSync24(skills) && !existsSync24(skillTarget)) {
33091
+ mkdirSync12(skillTarget, { recursive: true });
33092
+ cpSync(skills, skillTarget, { recursive: true });
33093
+ }
32285
33094
  if (!existsSync24(gateFile(root, id))) {
32286
33095
  const tables = Object.fromEntries(Object.keys(PROFILES).map((l) => [l, PROFILES[l].gates]));
32287
33096
  writeJsonAtomic(gateFile(root, id), {
@@ -32293,15 +33102,15 @@ function snapshotDefinitions(root, id, config, factoryVersion, replace = false)
32293
33102
  }
32294
33103
  function loadGateSnapshot(root, id) {
32295
33104
  const file = gateFile(root, id);
32296
- return existsSync24(file) ? JSON.parse(readFileSync19(file, "utf8")) : undefined;
33105
+ return existsSync24(file) ? JSON.parse(readFileSync21(file, "utf8")) : undefined;
32297
33106
  }
32298
33107
  function dropAgentSnapshot(root, id) {
32299
- const dir = join32(root, ".factory", "worktrees", id);
32300
- rmSync10(join32(dir, ".opencode"), { recursive: true, force: true });
33108
+ const dir = join33(root, ".factory", "worktrees", id);
33109
+ rmSync10(join33(dir, ".opencode"), { recursive: true, force: true });
32301
33110
  if (!existsSync24(dir))
32302
33111
  return;
32303
33112
  for (const repo of readdirSync8(dir)) {
32304
- const repoDir = join32(dir, repo);
33113
+ const repoDir = join33(dir, repo);
32305
33114
  if (readdirSync8(repoDir).length === 0)
32306
33115
  rmSync10(repoDir, { recursive: true });
32307
33116
  }
@@ -32372,7 +33181,7 @@ async function baseOf2(dir, ref) {
32372
33181
  return res.stdout.trim();
32373
33182
  }
32374
33183
  function findTaskWorktree(root, id, task, repoName) {
32375
- const changeDir = join33(root, ".factory", "worktrees", id);
33184
+ const changeDir = join34(root, ".factory", "worktrees", id);
32376
33185
  const repos = repoName ? [repoName] : existsSync25(changeDir) ? readdirSync9(changeDir) : [];
32377
33186
  const hits = repos.map((r) => ({ repo: r, dir: worktreePath(root, id, r, task) })).filter((h) => existsSync25(h.dir));
32378
33187
  const hit = hits[0];
@@ -32387,7 +33196,7 @@ function printReport(io, report) {
32387
33196
  const where = r.cwd === "." ? "" : ` [${r.cwd}]`;
32388
33197
  const what = r.command ? ` ${r.command}` : "";
32389
33198
  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})` : ""}`);
33199
+ io.out(` ${MARK3[r.status]} ${r.name ?? r.type}${where}${what}${time}${r.status === "skipped" ? ` (${r.output})` : ""}`);
32391
33200
  if (r.status !== "pass" && r.status !== "skipped" && r.output) {
32392
33201
  for (const line of r.output.split(`
32393
33202
  `))
@@ -32436,7 +33245,7 @@ var gateCommand = {
32436
33245
  } else {
32437
33246
  const repos = values.repo !== undefined ? [pick(values.repo)] : codeRepos;
32438
33247
  for (const repo of repos) {
32439
- const dir = join33(root, repo.path);
33248
+ const dir = join34(root, repo.path);
32440
33249
  const branch = (await git(["branch", "--show-current"], { cwd: dir })).stdout.trim();
32441
33250
  if (branch !== `change/${id}`) {
32442
33251
  if (values.repo !== undefined) {
@@ -32471,7 +33280,7 @@ var gateCommand = {
32471
33280
 
32472
33281
  // src/cli/commands/graph.ts
32473
33282
  import { existsSync as existsSync26 } from "node:fs";
32474
- import { join as join34 } from "node:path";
33283
+ import { join as join35 } from "node:path";
32475
33284
  var graphCommand = {
32476
33285
  name: "graph",
32477
33286
  summary: "Build or incrementally update the workspace graph",
@@ -32488,7 +33297,7 @@ The pipeline also updates it after every task merge, so this is rarely needed by
32488
33297
  const root = await findWorkspaceRoot(io.cwd);
32489
33298
  if (root === undefined)
32490
33299
  throw new PreconditionError("not inside a git repository");
32491
- if (!existsSync26(join34(root, LOCK_FILE)))
33300
+ if (!existsSync26(join35(root, LOCK_FILE)))
32492
33301
  throw new PreconditionError("ai-factory is not installed here", ["run `ai-factory init`"]);
32493
33302
  const tool = await checkTool("graphify", root);
32494
33303
  if (!tool.ok)
@@ -32508,8 +33317,8 @@ The pipeline also updates it after every task merge, so this is rarely needed by
32508
33317
  };
32509
33318
 
32510
33319
  // src/cli/commands/init.ts
32511
- import { existsSync as existsSync27, writeFileSync as writeFileSync10 } from "node:fs";
32512
- import { join as join35 } from "node:path";
33320
+ import { existsSync as existsSync28, writeFileSync as writeFileSync10 } from "node:fs";
33321
+ import { join as join37 } from "node:path";
32513
33322
 
32514
33323
  // src/config/render.ts
32515
33324
  var DEFAULT_MODELS = {
@@ -32521,6 +33330,72 @@ var DEFAULT_MODELS = {
32521
33330
  explorer: "anthropic/claude-haiku-5-5"
32522
33331
  };
32523
33332
  var json = (value) => JSON.stringify(value);
33333
+ function suggestedExtraGates(detection) {
33334
+ const out = [];
33335
+ for (const repo of detection.repos) {
33336
+ if (repo.languages.includes("tauri")) {
33337
+ out.push({
33338
+ name: "ios-compile",
33339
+ type: "types",
33340
+ phases: ["task", "full"],
33341
+ when: { changed: ["src-tauri/**"] },
33342
+ command: "cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-apple-ios"
33343
+ });
33344
+ }
33345
+ }
33346
+ for (const suite of detection.e2e) {
33347
+ out.push({
33348
+ name: `e2e-${suite.repo}`,
33349
+ type: "test",
33350
+ phases: ["full"],
33351
+ command: suite.command
33352
+ });
33353
+ }
33354
+ const wholeRepo = new Set(["docs", "kustomize", "chart", "verify"]);
33355
+ for (const repo of detection.tasks) {
33356
+ for (const name of repo.names) {
33357
+ if (!wholeRepo.has(name))
33358
+ continue;
33359
+ out.push({
33360
+ name: `${name}-${repo.repo}`,
33361
+ type: "test",
33362
+ phases: ["full"],
33363
+ command: `task ${name}`
33364
+ });
33365
+ }
33366
+ }
33367
+ for (const contract of detection.contracts) {
33368
+ if (contract.command === undefined)
33369
+ continue;
33370
+ if (contract.kind === "ipc") {
33371
+ out.push({
33372
+ name: "generated-bindings",
33373
+ type: "test",
33374
+ phases: ["task", "full"],
33375
+ when: { changed: ["src-tauri/**", "src/**"] },
33376
+ command: contract.command,
33377
+ outputs: contract.outputs ?? [contract.path]
33378
+ });
33379
+ } else if (contract.kind === "crd") {
33380
+ out.push({
33381
+ name: "generated-crd",
33382
+ type: "test",
33383
+ phases: ["full"],
33384
+ command: contract.command,
33385
+ outputs: contract.outputs ?? [contract.path]
33386
+ });
33387
+ } else if (contract.kind === "generated") {
33388
+ out.push({
33389
+ name: "generated-artifacts",
33390
+ type: "test",
33391
+ phases: ["full"],
33392
+ command: contract.command,
33393
+ outputs: contract.outputs ?? [contract.path]
33394
+ });
33395
+ }
33396
+ }
33397
+ return out;
33398
+ }
32524
33399
  function renderInitialConfig(detection) {
32525
33400
  const repos = detection.repos.map((r) => ` ${json(toRepoConfig(r))}`).join(`,
32526
33401
  `);
@@ -32541,16 +33416,29 @@ ${repos}
32541
33416
  ],
32542
33417
 
32543
33418
  // opencode model ids ("provider/model") per role. contractReviewer defaults to reviewer.
33419
+ // "roles", "repos" and "agents" can override these; the flat entries stay the fallback.
32544
33420
  "models": {
32545
33421
  ${models}
32546
33422
  },
32547
33423
 
33424
+ // Project bars injected into every session. Add docs/security.md, docs/go.md, etc.
33425
+ "instructions": ${json(detection.instructions)},
33426
+ // Skills the agents should load first, from .opencode/skills or a custom source.
33427
+ "skills": { "sources": [".opencode/skills"], "roles": {}, "repos": {} },
33428
+ // MCP tool action patterns per role (e.g. "reviewer": ["mantine_*"]).
33429
+ "mcp": { "roles": {} },
33430
+ // Optional specialist agents and task routing by touched paths.
33431
+ "agents": [],
33432
+ "routing": [],
33433
+
32548
33434
  "gates": {
32549
33435
  "maxRetries": 3,
32550
33436
  // Gate type -> script/target name looked up in package.json, Makefile, justfile, Taskfile.
32551
33437
  "scripts": { "format": "format:check", "lint": "lint", "types": "typecheck", "test": "test", "testAffected": "test:affected" },
32552
33438
  // Repo name -> gate type -> literal command; wins over scripts and profile defaults.
32553
- "overrides": {}
33439
+ "overrides": {},
33440
+ // Detected stack gates: iOS compile and generated-artifact drift. Edit or remove freely.
33441
+ "extra": ${json(suggestedExtraGates(detection))}
32554
33442
  },
32555
33443
 
32556
33444
  // Proposed by detection: confirm providers and consumers.
@@ -32573,6 +33461,12 @@ ${contracts}
32573
33461
  "graph": { "exclude": ${json(DEFAULT_GRAPH_EXCLUDE)} },
32574
33462
  // Agent shells and gates run in an OS sandbox where available ("auto"; macOS sandbox-exec).
32575
33463
  "security": { "sandbox": "auto" },
33464
+ // Task commits: "conventional" or "gitmoji"; scopes prefixes the repo name.
33465
+ "commits": { "style": "conventional", "scopes": false },
33466
+ // Clarification before design and the no-spec quick task.
33467
+ "workflow": { "refine": true, "quick": { "enabled": true, "branchPrefix": "task/" } },
33468
+ // Detect release-please, goreleaser, changesets and forge CI (read-only).
33469
+ "release": { "detect": true },
32576
33470
 
32577
33471
  // Managed files the factory must never overwrite (workspace-relative paths).
32578
33472
  "unmanaged": []
@@ -32580,9 +33474,51 @@ ${contracts}
32580
33474
  `;
32581
33475
  }
32582
33476
 
33477
+ // src/workspace/legacy.ts
33478
+ import { existsSync as existsSync27, mkdirSync as mkdirSync13, readdirSync as readdirSync10, renameSync as renameSync6, rmdirSync as rmdirSync2 } from "node:fs";
33479
+ import { dirname as dirname8, join as join36 } from "node:path";
33480
+ var OPENSPEC_PREFIX = /^(opsx-|openspec-)/;
33481
+ function move(root, rel, stamp, archived) {
33482
+ const from = join36(root, rel);
33483
+ if (!existsSync27(from))
33484
+ return;
33485
+ const to = join36(root, ".factory", "legacy", stamp, rel);
33486
+ mkdirSync13(dirname8(to), { recursive: true });
33487
+ renameSync6(from, to);
33488
+ archived.push(rel);
33489
+ }
33490
+ function moveEntries(root, dir, keep, stamp, archived) {
33491
+ const abs = join36(root, dir);
33492
+ if (!existsSync27(abs))
33493
+ return;
33494
+ for (const name of readdirSync10(abs)) {
33495
+ if (keep(name))
33496
+ continue;
33497
+ move(root, `${dir}/${name}`, stamp, archived);
33498
+ }
33499
+ try {
33500
+ if (readdirSync10(abs).length === 0)
33501
+ rmdirSync2(abs);
33502
+ } catch {}
33503
+ }
33504
+ function archiveLegacySetups(root, repoPaths) {
33505
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
33506
+ const archived = [];
33507
+ const dirs = [".opencode", ...repoPaths.filter((p) => p !== ".").map((p) => `${p}/.opencode`)];
33508
+ for (const base of dirs) {
33509
+ for (const name of ["agent", "agents"]) {
33510
+ moveEntries(root, `${base}/${name}`, () => false, stamp, archived);
33511
+ }
33512
+ moveEntries(root, `${base}/commands`, (n) => OPENSPEC_PREFIX.test(n), stamp, archived);
33513
+ moveEntries(root, `${base}/command`, () => false, stamp, archived);
33514
+ moveEntries(root, `${base}/skills`, (n) => OPENSPEC_PREFIX.test(n), stamp, archived);
33515
+ }
33516
+ return { stamp, archived };
33517
+ }
33518
+
32583
33519
  // src/cli/commands/init.ts
32584
33520
  var OPENSPEC_CONFIG = "openspec/config.yaml";
32585
- var HELP4 = `Usage: ai-factory init [--force] [--allow-downgrade] [--no-commit] [--no-graph] [--json]
33521
+ var HELP4 = `Usage: ai-factory init [--plan] [--force] [--allow-downgrade] [--no-commit] [--no-graph] [--json]
32586
33522
 
32587
33523
  Installs the factory into the workspace: detects repos, languages and contracts, checks
32588
33524
  the tools, runs \`openspec init\` for opencode, renders agents, standards and config,
@@ -32591,6 +33527,7 @@ and commits the result in the AI repo. Safe to run again: an unchanged workspace
32591
33527
  no diff and no commit.
32592
33528
 
32593
33529
  Options:
33530
+ --plan Show what would be created, archived or replaced; write nothing
32594
33531
  --force Overwrite existing files that ai-factory did not create
32595
33532
  --allow-downgrade Let this (older) version rewrite a workspace a newer one installed
32596
33533
  --no-commit Leave the changes uncommitted
@@ -32617,6 +33554,7 @@ var initCommand = {
32617
33554
  "allow-downgrade": { type: "boolean", default: false },
32618
33555
  "no-commit": { type: "boolean", default: false },
32619
33556
  "no-graph": { type: "boolean", default: false },
33557
+ plan: { type: "boolean", default: false },
32620
33558
  json: { type: "boolean", default: false }
32621
33559
  });
32622
33560
  const root = await findWorkspaceRoot(io.cwd);
@@ -32635,7 +33573,7 @@ var initCommand = {
32635
33573
  if (missing.length > 0) {
32636
33574
  throw new PreconditionError("required tools are missing or unsupported", missing.map((t) => t.ok ? "" : `${t.tool}: ${t.problem} → ${t.fix}`));
32637
33575
  }
32638
- if (!values["no-commit"]) {
33576
+ if (!values.plan && !values["no-commit"]) {
32639
33577
  const dirty = await dirtyInstallPaths(root, [CONFIG_FILE]);
32640
33578
  if (dirty.length > 0) {
32641
33579
  throw new PreconditionError("uncommitted changes in files that init commits", [
@@ -32644,14 +33582,33 @@ var initCommand = {
32644
33582
  ]);
32645
33583
  }
32646
33584
  }
32647
- const configPath = join35(root, CONFIG_FILE);
32648
- const newConfig = !existsSync27(configPath);
33585
+ const configPath = join37(root, CONFIG_FILE);
33586
+ const newConfig = !existsSync28(configPath);
32649
33587
  const detection = newConfig ? await detectWorkspace(root) : undefined;
32650
- const keepOpenspec = newConfig && !values.force && existsSync27(join35(root, OPENSPEC_CONFIG)) && readLock(root) === undefined;
33588
+ const legacy = !values.plan && readLock(root) === undefined && detection !== undefined ? archiveLegacySetups(root, detection.repos.map((r) => r.path)) : undefined;
33589
+ const keepOpenspec = newConfig && !values.force && existsSync28(join37(root, OPENSPEC_CONFIG)) && readLock(root) === undefined;
32651
33590
  const rendered = detection !== undefined ? renderInitialConfig(detection) : undefined;
32652
33591
  const configText = rendered !== undefined && keepOpenspec ? setJsonc(rendered, ["unmanaged"], [OPENSPEC_CONFIG]) : rendered;
32653
33592
  const config = configText !== undefined ? parseConfigText(configText, CONFIG_FILE) : loadConfig(root).config;
32654
33593
  const openspecFiles = await renderOpenspecTools(root);
33594
+ if (values.plan) {
33595
+ const planned = planSync(root, [...renderWorkspace(config, { root }), ...openspecFiles], readLock(root)?.managed ?? {}, { unmanaged: config.unmanaged, force: values.force });
33596
+ if (values.json) {
33597
+ io.out(JSON.stringify({
33598
+ root,
33599
+ newConfig,
33600
+ notes: detection?.notes ?? [],
33601
+ plan: planned.map(({ path, action }) => ({ path, action }))
33602
+ }, null, 2));
33603
+ } else {
33604
+ io.out(`ai-factory ${FACTORY_VERSION} plan for ${root}`);
33605
+ if (newConfig)
33606
+ io.out(`would write ${CONFIG_FILE}`);
33607
+ for (const change of planned)
33608
+ io.out(`${REPORTED[change.action] ?? change.action}: ${change.path}`);
33609
+ }
33610
+ return ExitCode.Ok;
33611
+ }
32655
33612
  planWorkspace(root, config, { force: values.force, tools: openspecFiles });
32656
33613
  if (configText !== undefined)
32657
33614
  writeFileSync10(configPath, configText);
@@ -32660,7 +33617,7 @@ var initCommand = {
32660
33617
  tools: openspecFiles
32661
33618
  });
32662
33619
  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)) });
33620
+ const graph = values["no-graph"] ? undefined : await updateGraph(root, { full: !existsSync28(graphFile(root)) });
32664
33621
  const changed = result.plan.filter((c) => REPORTED[c.action] !== undefined);
32665
33622
  if (values.json) {
32666
33623
  io.out(JSON.stringify({
@@ -32679,6 +33636,9 @@ var initCommand = {
32679
33636
  io.out(`tools: ${tools.map((t) => `${t.tool} ${t.ok ? t.version : "?"}`).join(", ")}`);
32680
33637
  for (const note of detection?.notes ?? [])
32681
33638
  io.out(`note: ${note}`);
33639
+ if (legacy !== undefined && legacy.archived.length > 0) {
33640
+ io.out(`legacy setup archived under .factory/legacy/${legacy.stamp}/ (${legacy.archived.length} path${legacy.archived.length === 1 ? "" : "s"})`);
33641
+ }
32682
33642
  if (keepOpenspec)
32683
33643
  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
33644
  if (newConfig)
@@ -32704,17 +33664,17 @@ var initCommand = {
32704
33664
  };
32705
33665
 
32706
33666
  // src/cli/commands/pipeline-commands.ts
32707
- import { existsSync as existsSync33 } from "node:fs";
32708
- import { join as join46 } from "node:path";
33667
+ import { existsSync as existsSync34, readFileSync as readFileSync27 } from "node:fs";
33668
+ import { join as join49, resolve as resolve5 } from "node:path";
32709
33669
 
32710
33670
  // src/pipeline/approve.ts
32711
- import { mkdirSync as mkdirSync13, writeFileSync as writeFileSync11 } from "node:fs";
32712
- import { dirname as dirname8 } from "node:path";
33671
+ import { mkdirSync as mkdirSync14, writeFileSync as writeFileSync11 } from "node:fs";
33672
+ import { dirname as dirname9 } from "node:path";
32713
33673
 
32714
33674
  // src/approval/approval.ts
32715
33675
  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";
33676
+ import { existsSync as existsSync29, readdirSync as readdirSync11, readFileSync as readFileSync22, statSync as statSync7 } from "node:fs";
33677
+ import { join as join38 } from "node:path";
32718
33678
  function parseApproval(value) {
32719
33679
  if (typeof value !== "object" || value === null)
32720
33680
  return;
@@ -32722,28 +33682,28 @@ function parseApproval(value) {
32722
33682
  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
33683
  return ok ? value : undefined;
32724
33684
  }
32725
- var changeDir = (root, id) => join36(root, "openspec", "changes", id);
32726
- var approvalFile = (root, id) => join36(root, ".factory", "approvals", `${id}.json`);
33685
+ var changeDir = (root, id) => join38(root, "openspec", "changes", id);
33686
+ var approvalFile = (root, id) => join38(root, ".factory", "approvals", `${id}.json`);
32727
33687
  function filesUnder(dir, prefix = "") {
32728
- if (!existsSync28(dir))
33688
+ if (!existsSync29(dir))
32729
33689
  return [];
32730
- return readdirSync10(dir).sort().flatMap((name) => {
33690
+ return readdirSync11(dir).sort().flatMap((name) => {
32731
33691
  const rel = prefix === "" ? name : `${prefix}/${name}`;
32732
- const abs = join36(dir, name);
33692
+ const abs = join38(dir, name);
32733
33693
  return statSync7(abs).isDirectory() ? filesUnder(abs, rel) : [rel];
32734
33694
  });
32735
33695
  }
32736
33696
  function approvedFiles(root, id) {
32737
33697
  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"));
33698
+ const top = ["proposal.md", "design.md", "tasks.md"].filter((f) => existsSync29(join38(dir, f)));
33699
+ const specs = filesUnder(join38(dir, "specs"), "specs").filter((f) => f.endsWith(".md"));
32740
33700
  return [...top, ...specs].sort();
32741
33701
  }
32742
33702
  function specHash(root, id) {
32743
33703
  const hash = createHash4("sha256");
32744
33704
  const dir = changeDir(root, id);
32745
33705
  for (const rel of approvedFiles(root, id)) {
32746
- let content = readFileSync20(join36(dir, rel), "utf8").replace(/\r\n/g, `
33706
+ let content = readFileSync22(join38(dir, rel), "utf8").replace(/\r\n/g, `
32747
33707
  `);
32748
33708
  if (rel === "tasks.md")
32749
33709
  content = normalizeCheckboxes(content);
@@ -32753,10 +33713,10 @@ function specHash(root, id) {
32753
33713
  }
32754
33714
  function readApproval(root, id) {
32755
33715
  const file = approvalFile(root, id);
32756
- if (!existsSync28(file))
33716
+ if (!existsSync29(file))
32757
33717
  return;
32758
33718
  try {
32759
- return parseApproval(JSON.parse(readFileSync20(file, "utf8")));
33719
+ return parseApproval(JSON.parse(readFileSync22(file, "utf8")));
32760
33720
  } catch {
32761
33721
  return;
32762
33722
  }
@@ -32774,12 +33734,12 @@ function checkApprovalHash(root, id) {
32774
33734
  }
32775
33735
 
32776
33736
  // src/pipeline/design.ts
32777
- import { existsSync as existsSync30 } from "node:fs";
32778
- import { join as join39 } from "node:path";
33737
+ import { existsSync as existsSync31 } from "node:fs";
33738
+ import { join as join41 } from "node:path";
32779
33739
 
32780
33740
  // src/git/branching.ts
32781
- import { existsSync as existsSync29 } from "node:fs";
32782
- import { join as join37 } from "node:path";
33741
+ import { existsSync as existsSync30 } from "node:fs";
33742
+ import { join as join39 } from "node:path";
32783
33743
  async function switchToChangeBranch(dir, branch, start) {
32784
33744
  if (await currentBranch(dir) === branch)
32785
33745
  return { created: false };
@@ -32816,8 +33776,8 @@ async function trackedBranch(root, path) {
32816
33776
  return;
32817
33777
  }
32818
33778
  async function branchSubmodule(root, repo, id) {
32819
- const dir = join37(root, repo.path);
32820
- if (!existsSync29(join37(dir, ".git"))) {
33779
+ const dir = join39(root, repo.path);
33780
+ if (!existsSync30(join39(dir, ".git"))) {
32821
33781
  throw new PreconditionError(`submodule ${repo.path} is not initialised`, [
32822
33782
  `run \`git submodule update --init ${repo.path}\``
32823
33783
  ]);
@@ -32889,7 +33849,7 @@ function decisionValues(handoff) {
32889
33849
  }
32890
33850
 
32891
33851
  // src/pipeline/context.ts
32892
- import { join as join38 } from "node:path";
33852
+ import { join as join40 } from "node:path";
32893
33853
 
32894
33854
  // src/runtime/rate-limit.ts
32895
33855
  function sleep(ms, signal) {
@@ -32987,7 +33947,7 @@ class Pipeline {
32987
33947
  return repo;
32988
33948
  }
32989
33949
  repoDir(repo) {
32990
- return join38(this.root, repo.path);
33950
+ return join40(this.root, repo.path);
32991
33951
  }
32992
33952
  out(line) {
32993
33953
  this.deps.out(line);
@@ -33026,14 +33986,31 @@ class Pipeline {
33026
33986
  }
33027
33987
  }
33028
33988
  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 }
33989
+ const first = modelFor(this.config, call.modelRole, {
33990
+ ...call.context.repo === undefined ? {} : { repo: call.context.repo },
33991
+ agent: call.agent
33036
33992
  });
33993
+ let model = first;
33994
+ let raw;
33995
+ let lastError;
33996
+ for (const candidate of [first, ...this.config.models.fallback]) {
33997
+ try {
33998
+ raw = await this.deps.runtime.open({
33999
+ agent: call.agent,
34000
+ model: candidate,
34001
+ title: call.title,
34002
+ context: { ...call.context, changeId: call.changeId, role: call.role }
34003
+ });
34004
+ model = candidate;
34005
+ break;
34006
+ } catch (err) {
34007
+ lastError = err;
34008
+ }
34009
+ }
34010
+ if (raw === undefined) {
34011
+ throw lastError instanceof Error ? lastError : new Error(`could not open a session for ${call.agent}`);
34012
+ }
34013
+ const provider = model.split("/")[0] ?? model;
33037
34014
  const session = limited(raw, this.limiter, provider);
33038
34015
  let turn = await promptForHandoff(session, call.prompt, signal);
33039
34016
  let usage = turn.usage;
@@ -33113,6 +34090,8 @@ ${text}`).join(`
33113
34090
 
33114
34091
  `);
33115
34092
  const contracts = input.contracts.map((c) => `- ${c.kind} ${c.provider}:${c.path} → ${c.consumers.join(", ") || "no consumers"}`);
34093
+ const instructions = input.instructions === undefined || input.instructions.length === 0 ? "- none configured" : input.instructions.map((p) => `- \`${p}\``).join(`
34094
+ `);
33116
34095
  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
34096
 
33118
34097
  Request:
@@ -33128,6 +34107,9 @@ Contracts:
33128
34107
  ${contracts.length > 0 ? contracts.join(`
33129
34108
  `) : "- none configured"}
33130
34109
 
34110
+ Project instructions (read these before writing the spec):
34111
+ ${instructions}
34112
+
33131
34113
  Explorer findings:
33132
34114
  ${explored || "(none)"}
33133
34115
 
@@ -33169,18 +34151,28 @@ ${conflicts.map((f) => `- ${f}`).join(`
33169
34151
  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
34152
  }
33171
34153
  function reviewPrompt(input) {
34154
+ const instructions = input.instructions === undefined || input.instructions.length === 0 ? "" : `
34155
+ Project instructions (read these before judging the diff; paths are relative to this checkout):
34156
+ ${input.instructions.map((p) => `- \`${p}\``).join(`
34157
+ `)}
34158
+ `;
33172
34159
  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
-
34160
+ Standards in force: ${input.profiles.join(", ") || "none"} (the full standards are in the workspace's .opencode/standards/).
34161
+ ${instructions}
33175
34162
  Spec of the change:
33176
34163
  ${input.spec}
33177
34164
 
33178
34165
  Use \`task: "review:${input.repo.name}"\`.`;
33179
34166
  }
33180
34167
  function testerPrompt(input) {
34168
+ const instructions = input.instructions === undefined || input.instructions.length === 0 ? "" : `
34169
+ Project instructions (follow these test conventions; paths are workspace-relative, so open \`$AI_FACTORY_WORKSPACE/<path>\` from a worktree):
34170
+ ${input.instructions.map((p) => `- \`${p}\``).join(`
34171
+ `)}
34172
+ `;
33181
34173
  return `Task ${input.task.id}: make sure every scenario below that concerns repo \`${input.task.repo}\` has a test.
33182
34174
  You may only edit test files (${input.testGlobs.join(", ")}).
33183
-
34175
+ ${instructions}
33184
34176
  Spec of the change:
33185
34177
  ${input.spec}
33186
34178
 
@@ -33203,6 +34195,25 @@ function findingPack(f) {
33203
34195
  - Impact if skipped: ${f.impact}
33204
34196
  ${f.file ? `- Location: ${f.file}${f.line ? `:${f.line}` : ""}` : ""}`;
33205
34197
  }
34198
+ function quickPrompt(input) {
34199
+ return `Quick task in repo \`${input.repo.name}\` (no OpenSpec change):
34200
+ """
34201
+ ${input.task}
34202
+ """
34203
+ Make the smallest complete change that satisfies the task. You may edit any file in this
34204
+ repository except the factory-managed files and secrets. Tests for the change belong with it.
34205
+ The factory runs the repository's gates after you finish; report the result in your handoff.
34206
+ Use \`task: "quick"\`.`;
34207
+ }
34208
+ function quickReviewPrompt(input) {
34209
+ return `Review the uncommitted quick-task change in repo \`${input.repo.name}\`:
34210
+ """
34211
+ ${input.task}
34212
+ """
34213
+ Run \`git diff\` in this repository and read the code around the changes. Report only real
34214
+ bugs, security problems, missing tests for risky logic, and contract/convention violations.
34215
+ Use \`task: "quick-review"\` and the normal findings handoff.`;
34216
+ }
33206
34217
  function contractReviewPrompt(input) {
33207
34218
  return `One cross-repo review of the contracts this change touches. The deterministic contract gate already ran:
33208
34219
  ${input.contracts.map((c) => c.summary).join(`
@@ -33217,14 +34228,14 @@ Report only real mismatches between providers and consumers as findings, with \`
33217
34228
 
33218
34229
  // src/pipeline/design.ts
33219
34230
  function specDir(p, draft) {
33220
- return draft === undefined ? p.root : join39(p.root, draft);
34231
+ return draft === undefined ? p.root : join41(p.root, draft);
33221
34232
  }
33222
34233
  function slug(text) {
33223
34234
  const words = text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter(Boolean).slice(0, 5);
33224
34235
  return words.join("-") || "change";
33225
34236
  }
33226
34237
  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));
34238
+ const taken = (id) => loadRun(p.root, id) !== undefined || existsSync31(join41(p.root, "openspec", "changes", id)) || existsSync31(join41(p.root, ".factory", "drafts", id));
33228
34239
  let id = wanted;
33229
34240
  for (let n = 2;taken(id); n++)
33230
34241
  id = `${wanted}-${n}`;
@@ -33239,8 +34250,8 @@ async function branchForDesign(p, id) {
33239
34250
  }
33240
34251
  const base = loadRun(p.root, occupiedBy)?.branches["."]?.base ?? await revParse(p.root, "HEAD");
33241
34252
  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];
34253
+ if (!existsSync31(join41(p.root, draft))) {
34254
+ 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
34255
  await gitOk(args, { cwd: p.root });
33245
34256
  }
33246
34257
  return { base, draft };
@@ -33348,7 +34359,8 @@ async function design(p, id) {
33348
34359
  request: run.request,
33349
34360
  explorations,
33350
34361
  repos: p.codeRepos(),
33351
- contracts: p.config.contracts
34362
+ contracts: p.config.contracts,
34363
+ instructions: allInstructionPaths(p.config)
33352
34364
  }),
33353
34365
  retry: async (_handoff, attempt) => {
33354
34366
  if (attempt > p.config.gates.maxRetries)
@@ -33401,7 +34413,7 @@ async function approve(p, id) {
33401
34413
  await p.update(id, (d) => {
33402
34414
  assertApprovable(id, d);
33403
34415
  const file = approvalFile(p.root, id);
33404
- mkdirSync13(dirname8(file), { recursive: true });
34416
+ mkdirSync14(dirname9(file), { recursive: true });
33405
34417
  writeFileSync11(file, `${JSON.stringify(approval, null, 2)}
33406
34418
  `);
33407
34419
  d.phase = "queued";
@@ -33459,15 +34471,15 @@ async function verifyApproval(p, id, dir) {
33459
34471
  }
33460
34472
 
33461
34473
  // src/pipeline/contracts.ts
33462
- import { mkdirSync as mkdirSync15, writeFileSync as writeFileSync13 } from "node:fs";
33463
- import { join as join43 } from "node:path";
34474
+ import { mkdirSync as mkdirSync16, writeFileSync as writeFileSync13 } from "node:fs";
34475
+ import { join as join45 } from "node:path";
33464
34476
 
33465
34477
  // src/contracts/gate.ts
33466
34478
  var import_ajv = __toESM(require_ajv(), 1);
33467
34479
  var import__2020 = __toESM(require__2020(), 1);
33468
34480
  var import_ajv_formats = __toESM(require_dist(), 1);
33469
- import { readFileSync as readFileSync21 } from "node:fs";
33470
- import { join as join40 } from "node:path";
34481
+ import { readFileSync as readFileSync23 } from "node:fs";
34482
+ import { join as join42 } from "node:path";
33471
34483
 
33472
34484
  // src/contracts/schema-diff.ts
33473
34485
  var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -34003,7 +35015,7 @@ var SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|go|py|rs|java|kt|swift|rb|php|cs|vue|svel
34003
35015
  function consumerSources(dir) {
34004
35016
  return listFiles(dir, { maxDepth: 8, maxFiles: 5000 }).filter((f) => SOURCE.test(f.path) && f.size <= 256 * 1024).map((f) => {
34005
35017
  try {
34006
- return readFileSync21(join40(dir, f.path), "utf8");
35018
+ return readFileSync23(join42(dir, f.path), "utf8");
34007
35019
  } catch {
34008
35020
  return "";
34009
35021
  }
@@ -34027,21 +35039,50 @@ async function runValidate(command, dir, contractAbs, maxLines) {
34027
35039
  ${compactOutput(`${res.stdout}
34028
35040
  ${res.stderr}`, maxLines)}`;
34029
35041
  }
34030
- function repoOf(config, name) {
35042
+ function repoOf2(config, name) {
34031
35043
  const repo = config.repos.find((r) => r.name === name);
34032
35044
  if (repo === undefined)
34033
35045
  throw new Error(`unknown repo ${name}`);
34034
35046
  return repo;
34035
35047
  }
34036
35048
  async function checkContract(input, contract) {
34037
- const provider = repoOf(input.config, contract.provider);
34038
- const providerDir = join40(input.root, provider.path);
35049
+ const provider = repoOf2(input.config, contract.provider);
35050
+ const providerDir = join42(input.root, provider.path);
34039
35051
  const head = await headRef(providerDir, input.id);
34040
35052
  const base = input.bases[provider.name] ?? head;
34041
35053
  const breaking = [];
34042
35054
  const consumerIssues = [];
34043
35055
  const notes = [];
34044
- if (contract.kind === "proto") {
35056
+ if (contract.kind === "generated" || contract.kind === "crd" || contract.kind === "ipc" || contract.kind === "custom") {
35057
+ if (contract.command === undefined) {
35058
+ consumerIssues.push(`${contract.kind} contract has no command`);
35059
+ } else if (contract.kind === "custom") {
35060
+ const issue = await runValidate(contract.command, providerDir, join42(providerDir, contract.path), input.gateOutputMaxLines);
35061
+ if (issue)
35062
+ consumerIssues.push(`${provider.name}: ${issue}`);
35063
+ } else {
35064
+ const before = await snapshot(providerDir);
35065
+ const res = await exec(["sh", "-c", contract.command], {
35066
+ cwd: providerDir,
35067
+ env: { CI: "1", NO_COLOR: "1" },
35068
+ timeoutMs: 15 * 60000
35069
+ });
35070
+ const modified = await changedSince(before);
35071
+ if (modified.length > 0)
35072
+ await restore(before, modified);
35073
+ if (res.code !== 0) {
35074
+ consumerIssues.push(`\`${contract.command}\` failed:
35075
+ ${compactOutput(`${res.stdout}
35076
+ ${res.stderr}`, input.gateOutputMaxLines)}`);
35077
+ } else {
35078
+ const outputs = contract.outputs ?? [contract.path];
35079
+ const drifted = modified.filter((file) => outputs.some((glob) => file === glob || file.startsWith(`${glob.replace(/\/$/, "")}/`) || opencodeGlobRegExp(glob).test(file)));
35080
+ if (drifted.length > 0) {
35081
+ consumerIssues.push(`generated artifacts are stale: ${drifted.join(", ")}; regenerate and commit them`);
35082
+ }
35083
+ }
35084
+ }
35085
+ } else if (contract.kind === "proto") {
34045
35086
  const oldFiles = await filesAt(providerDir, base, contract.path, ".proto");
34046
35087
  const newFiles = await filesAt(providerDir, head, contract.path, ".proto");
34047
35088
  if (newFiles.length === 0)
@@ -34064,7 +35105,7 @@ async function checkContract(input, contract) {
34064
35105
  else {
34065
35106
  breaking.push(...diffOpenApi(oldDoc, newDoc));
34066
35107
  for (const consumer of contract.consumers) {
34067
- const dir = join40(input.root, repoOf(input.config, consumer).path);
35108
+ const dir = join42(input.root, repoOf2(input.config, consumer).path);
34068
35109
  for (const path of referencedRemovedPaths(oldDoc, newDoc, consumerSources(dir))) {
34069
35110
  consumerIssues.push(`${consumer} still references ${path}, which the new contract no longer has`);
34070
35111
  }
@@ -34096,8 +35137,8 @@ async function checkContract(input, contract) {
34096
35137
  }
34097
35138
  }
34098
35139
  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);
35140
+ const dir = join42(input.root, repoOf2(input.config, consumer).path);
35141
+ const issue = await runValidate(command, dir, join42(providerDir, contract.path), input.gateOutputMaxLines);
34101
35142
  if (issue)
34102
35143
  consumerIssues.push(`${consumer}: ${issue}`);
34103
35144
  }
@@ -34128,23 +35169,23 @@ function touchedContracts(config, repos) {
34128
35169
 
34129
35170
  // src/pipeline/implement.ts
34130
35171
  import {
34131
- existsSync as existsSync31,
34132
- mkdirSync as mkdirSync14,
34133
- readdirSync as readdirSync11,
34134
- readFileSync as readFileSync23,
35172
+ existsSync as existsSync32,
35173
+ mkdirSync as mkdirSync15,
35174
+ readdirSync as readdirSync12,
35175
+ readFileSync as readFileSync25,
34135
35176
  rmSync as rmSync11,
34136
35177
  statSync as statSync8,
34137
35178
  writeFileSync as writeFileSync12
34138
35179
  } from "node:fs";
34139
- import { join as join42 } from "node:path";
35180
+ import { join as join44 } from "node:path";
34140
35181
 
34141
35182
  // src/gates/definitions.ts
34142
- import { readFileSync as readFileSync22 } from "node:fs";
34143
- import { join as join41 } from "node:path";
35183
+ import { readFileSync as readFileSync24 } from "node:fs";
35184
+ import { join as join43 } from "node:path";
34144
35185
  var HOSTS = ["package.json", "Makefile", "justfile", "Justfile", "Taskfile.yml", "Taskfile.yaml"];
34145
35186
  function current(dir, file) {
34146
35187
  try {
34147
- return readFileSync22(join41(dir, file), "utf8");
35188
+ return readFileSync24(join43(dir, file), "utf8");
34148
35189
  } catch {
34149
35190
  return;
34150
35191
  }
@@ -34323,7 +35364,7 @@ class GraphUpdater {
34323
35364
  }
34324
35365
 
34325
35366
  // src/policy/scope.ts
34326
- import { isAbsolute as isAbsolute2, normalize, relative as relative5, resolve as resolve4 } from "node:path";
35367
+ import { isAbsolute as isAbsolute2, normalize, relative as relative6, resolve as resolve4 } from "node:path";
34327
35368
 
34328
35369
  // src/policy/paths.ts
34329
35370
  var PROTECTED = [
@@ -34378,7 +35419,7 @@ var architectDirs = (changeId) => [
34378
35419
  var IMPLEMENTING_ROLES = new Set(["implementer", "tester"]);
34379
35420
  function checkEdit(ctx, path, approval) {
34380
35421
  const abs = normalize(isAbsolute2(path) ? path : resolve4(ctx.directory, path));
34381
- const rel = relative5(ctx.directory, abs);
35422
+ const rel = relative6(ctx.directory, abs);
34382
35423
  if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
34383
35424
  return {
34384
35425
  allowed: false,
@@ -34753,6 +35794,25 @@ class Scheduler {
34753
35794
  }
34754
35795
  }
34755
35796
 
35797
+ // src/tasks/routing.ts
35798
+ function agentForTask(config, repo, touches) {
35799
+ for (const rule of config.routing) {
35800
+ const agent = config.agents.find((a) => a.id === rule.agent);
35801
+ if (agent === undefined || agent.phase !== "implementer")
35802
+ continue;
35803
+ const hit = rule.paths.some((path) => touches.some((touch) => {
35804
+ try {
35805
+ return opencodeGlobRegExp(path).test(touch) || opencodeGlobRegExp(touch).test(path);
35806
+ } catch {
35807
+ return false;
35808
+ }
35809
+ }));
35810
+ if (hit)
35811
+ return { agent: agent.id, role: "implementer", custom: true };
35812
+ }
35813
+ return { agent: `implementer-${repo}`, role: "implementer", custom: false };
35814
+ }
35815
+
34756
35816
  // src/util/cap.ts
34757
35817
  var CHARS_PER_TOKEN = 4;
34758
35818
  var estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);
@@ -34775,7 +35835,7 @@ function gateDefinitions(p, id) {
34775
35835
  return { config: p.config };
34776
35836
  return { config: { ...p.config, gates: snapshot.gates }, gateTables: snapshot.tables };
34777
35837
  }
34778
- var packPath = (root, id, task) => join42(root, ".factory", "context", id, `${task}.md`);
35838
+ var packPath = (root, id, task) => join44(root, ".factory", "context", id, `${task}.md`);
34779
35839
  async function activate(p, id) {
34780
35840
  const claim = await claimActive(p.root, id);
34781
35841
  if (claim.status === "busy") {
@@ -34792,7 +35852,7 @@ async function activate(p, id) {
34792
35852
  const run = p.run(id);
34793
35853
  const branch = changeBranch(id);
34794
35854
  if (run.draft !== undefined) {
34795
- await gitOk(["worktree", "remove", join42(p.root, run.draft)], { cwd: p.root });
35855
+ await gitOk(["worktree", "remove", join44(p.root, run.draft)], { cwd: p.root });
34796
35856
  await p.update(id, (d) => {
34797
35857
  delete d.draft;
34798
35858
  });
@@ -34824,18 +35884,18 @@ async function activate(p, id) {
34824
35884
  return { status: "active" };
34825
35885
  }
34826
35886
  function specText(p, id) {
34827
- const dir = join42(p.root, "openspec", "changes", id, "specs");
34828
- if (!existsSync31(dir))
35887
+ const dir = join44(p.root, "openspec", "changes", id, "specs");
35888
+ if (!existsSync32(dir))
34829
35889
  return "";
34830
35890
  const out = [];
34831
35891
  const walk = (d, rel) => {
34832
- for (const name of readdirSync11(d).sort()) {
34833
- const abs = join42(d, name);
35892
+ for (const name of readdirSync12(d).sort()) {
35893
+ const abs = join44(d, name);
34834
35894
  if (statSync8(abs).isDirectory())
34835
35895
  walk(abs, `${rel}${name}/`);
34836
35896
  else if (name.endsWith(".md"))
34837
35897
  out.push(`### specs/${rel}${name}
34838
- ${readFileSync23(abs, "utf8").trim()}`);
35898
+ ${readFileSync25(abs, "utf8").trim()}`);
34839
35899
  }
34840
35900
  };
34841
35901
  walk(dir, "");
@@ -34861,10 +35921,10 @@ function cappedSpec(p, id) {
34861
35921
  async function ensureContextPacks(p, id) {
34862
35922
  const cap = p.config.tokens.contextPackMaxTokens;
34863
35923
  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)));
35924
+ const missing = tasks.filter((t) => !existsSync32(packPath(p.root, id, t.id)));
34865
35925
  if (missing.length > 0) {
34866
35926
  p.out(`▶ context packs (${missing.length})`);
34867
- mkdirSync14(join42(p.root, ".factory", "context", id), { recursive: true });
35927
+ mkdirSync15(join44(p.root, ".factory", "context", id), { recursive: true });
34868
35928
  const res = await p.call({
34869
35929
  changeId: id,
34870
35930
  agent: "architect",
@@ -34883,7 +35943,7 @@ async function ensureContextPacks(p, id) {
34883
35943
  }
34884
35944
  for (const t of tasks) {
34885
35945
  const file = packPath(p.root, id, t.id);
34886
- const text = existsSync31(file) ? capPack(readFileSync23(file, "utf8"), cap) : fallbackPack(p, id, t, cap);
35946
+ const text = existsSync32(file) ? capPack(readFileSync25(file, "utf8"), cap) : fallbackPack(p, id, t, cap);
34887
35947
  writeFileSync12(file, text);
34888
35948
  }
34889
35949
  }
@@ -35017,7 +36077,7 @@ class PipelineTaskRunner {
35017
36077
  await gitOk(["checkout", "-q", "HEAD", "--", v.path], { cwd: dir });
35018
36078
  else {
35019
36079
  await git(["rm", "-q", "--cached", "--ignore-unmatch", "--", v.path], { cwd: dir });
35020
- rmSync11(join42(dir, v.path), { force: true, recursive: true });
36080
+ rmSync11(join44(dir, v.path), { force: true, recursive: true });
35021
36081
  }
35022
36082
  }
35023
36083
  return violations.map((v) => `${v.path}: ${v.reason}`);
@@ -35043,14 +36103,15 @@ class PipelineTaskRunner {
35043
36103
  t.installed = true;
35044
36104
  });
35045
36105
  }
35046
- const pack = existsSync31(packPath(this.p.root, this.id, task.id)) ? readFileSync23(packPath(this.p.root, this.id, task.id), "utf8") : "";
36106
+ const pack = existsSync32(packPath(this.p.root, this.id, task.id)) ? readFileSync25(packPath(this.p.root, this.id, task.id), "utf8") : "";
35047
36107
  const gates = gateCommands(this.p, task, dir);
35048
36108
  const isTest = task.kind === "test";
35049
36109
  const prompt = isTest ? testerPrompt({
35050
36110
  task,
35051
36111
  spec: cappedSpec(this.p, this.id),
35052
36112
  testGlobs: this.#context(task, dir).testGlobs ?? [],
35053
- gates
36113
+ gates,
36114
+ instructions: instructionPaths(this.p.config, task.repo)
35054
36115
  }) : taskPrompt({
35055
36116
  task,
35056
36117
  pack,
@@ -35058,9 +36119,10 @@ class PipelineTaskRunner {
35058
36119
  ...ctx.feedback === undefined ? {} : { feedback: ctx.feedback }
35059
36120
  });
35060
36121
  const headBefore = (await gitOk(["rev-parse", "HEAD"], { cwd: dir })).trim();
36122
+ const routed = agentForTask(this.p.config, task.repo, task.touches);
35061
36123
  const res = await this.p.call({
35062
36124
  changeId: this.id,
35063
- agent: isTest ? "tester" : `implementer-${task.repo}`,
36125
+ agent: isTest ? "tester" : routed.agent,
35064
36126
  role: isTest ? "tester" : "implementer",
35065
36127
  modelRole: isTest ? "tester" : "implementer",
35066
36128
  context: this.#context(task, dir),
@@ -35111,7 +36173,7 @@ ${redefined.join(`
35111
36173
  }
35112
36174
  const report = await this.#gates(task, dir);
35113
36175
  if (!report.passed) {
35114
- const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.type}] ${r.command ?? ""}
36176
+ const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.command ?? ""}
35115
36177
  ${r.output}`).join(`
35116
36178
 
35117
36179
  `);
@@ -35122,7 +36184,10 @@ ${output}` : output
35122
36184
  };
35123
36185
  }
35124
36186
  const type = task.kind === "test" ? "test" : task.kind === "task" ? undefined : "fix";
35125
- const commit = await commitTask(dir, this.id, task.id, task.title, type);
36187
+ const commit = await commitTask(dir, this.id, task.id, task.title, type, {
36188
+ style: this.p.config.commits.style,
36189
+ ...this.p.config.commits.scopes ? { scope: task.repo } : {}
36190
+ });
35126
36191
  return { status: "passed", ...commit ? { commit } : {} };
35127
36192
  }
35128
36193
  async merge(task) {
@@ -35137,7 +36202,7 @@ ${output}` : output
35137
36202
  }
35138
36203
  const branch = taskBranch(this.id, task.id);
35139
36204
  const path = worktreePath(this.p.root, this.id, repo.name, task.id);
35140
- if (!existsSync31(path)) {
36205
+ if (!existsSync32(path)) {
35141
36206
  const merged = !await refExists(repoDir, `refs/heads/${branch}`) || (await git(["merge-base", "--is-ancestor", branch, changeBranch(this.id)], {
35142
36207
  cwd: repoDir
35143
36208
  })).code === 0;
@@ -35162,16 +36227,17 @@ ${output}` : output
35162
36227
  if (outcome.status === "conflict")
35163
36228
  return { status: "conflict", conflicts: outcome.conflicts };
35164
36229
  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(`
36230
+ const output = report?.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.output}`).join(`
35166
36231
 
35167
36232
  `) ?? "gates failed after the rebase";
35168
36233
  return { status: "gates-failed", output };
35169
36234
  }
35170
36235
  async resolveConflict(task, conflicts) {
35171
36236
  const path = worktreePath(this.p.root, this.id, task.repo, task.id);
36237
+ const routed = agentForTask(this.p.config, task.repo, task.touches);
35172
36238
  const res = await this.p.call({
35173
36239
  changeId: this.id,
35174
- agent: `implementer-${task.repo}`,
36240
+ agent: routed.agent,
35175
36241
  role: "implementer",
35176
36242
  modelRole: "implementer",
35177
36243
  context: { ...this.#context(task, path), touches: ["**"] },
@@ -35183,8 +36249,8 @@ ${output}` : output
35183
36249
  if (res.handoff?.status !== "done")
35184
36250
  return false;
35185
36251
  for (const file of conflicts) {
35186
- const abs = join42(path, file);
35187
- if (existsSync31(abs) && /^(<{7}|={7}|>{7})( |$)/m.test(readFileSync23(abs, "utf8")))
36252
+ const abs = join44(path, file);
36253
+ if (existsSync32(abs) && /^(<{7}|={7}|>{7})( |$)/m.test(readFileSync25(abs, "utf8")))
35188
36254
  return false;
35189
36255
  }
35190
36256
  return true;
@@ -35193,7 +36259,7 @@ ${output}` : output
35193
36259
  const repo = this.p.repo(task.repo);
35194
36260
  const repoDir = this.p.repoDir(repo);
35195
36261
  const path = worktreePath(this.p.root, this.id, repo.name, task.id);
35196
- if (existsSync31(path)) {
36262
+ if (existsSync32(path)) {
35197
36263
  await abortRebase(path);
35198
36264
  await gitOk(["worktree", "remove", "--force", path], { cwd: repoDir });
35199
36265
  }
@@ -35257,7 +36323,7 @@ function addFixTask(p, draft, id, task) {
35257
36323
  });
35258
36324
  recordTask(state, "added", "contract-fix");
35259
36325
  draft.tasks.push(state);
35260
- mkdirSync15(join43(p.root, ".factory", "context", id), { recursive: true });
36326
+ mkdirSync16(join45(p.root, ".factory", "context", id), { recursive: true });
35261
36327
  writeFileSync13(packPath(p.root, id, task.id), task.pack);
35262
36328
  }
35263
36329
  async function contractPhase(p, id) {
@@ -35369,11 +36435,11 @@ ${summarize(r)}
35369
36435
  }
35370
36436
 
35371
36437
  // 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";
36438
+ import { existsSync as existsSync33, mkdirSync as mkdirSync17, readdirSync as readdirSync13, readFileSync as readFileSync26, writeFileSync as writeFileSync14 } from "node:fs";
36439
+ import { join as join47 } from "node:path";
35374
36440
 
35375
36441
  // src/git/pointers.ts
35376
- import { join as join44 } from "node:path";
36442
+ import { join as join46 } from "node:path";
35377
36443
  var PROVISIONAL_SUBJECT = (id) => `chore(submodules): bump for ${id} [provisional]`;
35378
36444
  async function bumpPointers(root, id, repos) {
35379
36445
  const paths = repos.map((r) => r.path);
@@ -35472,7 +36538,7 @@ async function repoint(root, config, id) {
35472
36538
  for (const pointer of bump.pointers) {
35473
36539
  const repo = config.repos.find((r) => r.path === pointer.path);
35474
36540
  const name = repo?.name ?? pointer.path;
35475
- const dir = join44(root, pointer.path);
36541
+ const dir = join46(root, pointer.path);
35476
36542
  const base = { repo: name, path: pointer.path, from: pointer.to };
35477
36543
  const current = (await git(["rev-parse", `HEAD:${pointer.path}`], { cwd: root })).stdout.trim();
35478
36544
  if (current !== pointer.to) {
@@ -35558,7 +36624,8 @@ async function review(p, id) {
35558
36624
  base,
35559
36625
  head: changeBranch(id),
35560
36626
  spec,
35561
- profiles: profilesFor(repoLanguages(repo)).map((pr) => pr.title)
36627
+ profiles: profilesFor(repoLanguages(repo)).map((pr) => pr.title),
36628
+ instructions: sessionInstructionPaths(p.config, name, repo.path)
35562
36629
  })
35563
36630
  });
35564
36631
  if (res.handoff === undefined) {
@@ -35583,7 +36650,7 @@ async function review(p, id) {
35583
36650
  kind: "review-fix"
35584
36651
  });
35585
36652
  added++;
35586
- mkdirSync16(join45(p.root, ".factory", "context", id), { recursive: true });
36653
+ mkdirSync17(join47(p.root, ".factory", "context", id), { recursive: true });
35587
36654
  writeFileSync14(packPath(p.root, id, taskId), findingPack(f));
35588
36655
  }
35589
36656
  addTask(d, {
@@ -35626,7 +36693,7 @@ async function fullSuite(p, id) {
35626
36693
  command: r.command
35627
36694
  }))
35628
36695
  });
35629
- const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.type}] ${r.command ?? ""}
36696
+ const output = report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.command ?? ""}
35630
36697
  ${r.output}`).join(`
35631
36698
 
35632
36699
  `);
@@ -35675,7 +36742,7 @@ function harvest(run) {
35675
36742
  }
35676
36743
  return { risks, questions, findings };
35677
36744
  }
35678
- var reportFile = (root, id, kind) => join45(root, ".factory", "reports", kind === "final" ? `${id}.md` : `${id}-paused.md`);
36745
+ var reportFile = (root, id, kind) => join47(root, ".factory", "reports", kind === "final" ? `${id}.md` : `${id}-paused.md`);
35679
36746
  function writePauseReport(p, id, summary) {
35680
36747
  const run = p.run(id);
35681
36748
  const failed = run.tasks.filter((t) => t.status === "failed");
@@ -35706,18 +36773,18 @@ function writePauseReport(p, id, summary) {
35706
36773
  ...usageReport(summarizeUsage(p.root, id))
35707
36774
  ];
35708
36775
  const file = reportFile(p.root, id, "paused");
35709
- mkdirSync16(join45(p.root, ".factory", "reports"), { recursive: true });
36776
+ mkdirSync17(join47(p.root, ".factory", "reports"), { recursive: true });
35710
36777
  writeFileSync14(file, lines.join(`
35711
36778
  `));
35712
36779
  return file;
35713
36780
  }
35714
36781
  function archivedName(root, id) {
35715
- if (existsSync32(join45(root, "openspec", "changes", id)))
36782
+ if (existsSync33(join47(root, "openspec", "changes", id)))
35716
36783
  return;
35717
- const archive = join45(root, "openspec", "changes", "archive");
35718
- if (!existsSync32(archive))
36784
+ const archive = join47(root, "openspec", "changes", "archive");
36785
+ if (!existsSync33(archive))
35719
36786
  return;
35720
- return readdirSync12(archive).filter((n) => n === id || n.endsWith(`-${id}`)).sort().at(-1);
36787
+ return readdirSync13(archive).filter((n) => n === id || n.endsWith(`-${id}`)).sort().at(-1);
35721
36788
  }
35722
36789
  async function close(p, id) {
35723
36790
  const run = p.run(id);
@@ -35725,9 +36792,9 @@ async function close(p, id) {
35725
36792
  if (open.length > 0)
35726
36793
  throw new PreconditionError(`cannot close ${id}: tasks not merged: ${open.map((t) => t.id).join(", ")}`);
35727
36794
  p.out("▶ close");
35728
- const tasksFile = join45(p.root, "openspec", "changes", id, "tasks.md");
35729
- if (existsSync32(tasksFile)) {
35730
- let text = readFileSync24(tasksFile, "utf8");
36795
+ const tasksFile = join47(p.root, "openspec", "changes", id, "tasks.md");
36796
+ if (existsSync33(tasksFile)) {
36797
+ let text = readFileSync26(tasksFile, "utf8");
35731
36798
  for (const t of run.tasks.filter((x) => x.kind === "task"))
35732
36799
  text = setTaskChecked(text, t.id, true);
35733
36800
  writeFileSync14(tasksFile, text);
@@ -35818,7 +36885,7 @@ Archived as \`openspec/changes/archive/${archivedAs}\`.`,
35818
36885
  ...usageReport(summarizeUsage(p.root, id))
35819
36886
  ];
35820
36887
  const file = reportFile(p.root, id, "final");
35821
- mkdirSync16(join45(p.root, ".factory", "reports"), { recursive: true });
36888
+ mkdirSync17(join47(p.root, ".factory", "reports"), { recursive: true });
35822
36889
  writeFileSync14(file, lines.join(`
35823
36890
  `));
35824
36891
  await p.update(id, (d) => {
@@ -36009,12 +37076,72 @@ async function step(p, id) {
36009
37076
  }
36010
37077
  }
36011
37078
 
37079
+ // src/pipeline/refine.ts
37080
+ import { mkdirSync as mkdirSync18, writeFileSync as writeFileSync15 } from "node:fs";
37081
+ import { join as join48 } from "node:path";
37082
+ var QUESTIONS = [
37083
+ ["scope", "Which repos, modules or capabilities does this touch?", ""],
37084
+ ["out-of-scope", "What is explicitly out of scope?", "nothing stated"],
37085
+ [
37086
+ "verification",
37087
+ "How should this be verified (tests, gates, manual checks)?",
37088
+ "the repository's normal gates"
37089
+ ],
37090
+ ["constraints", "Any contracts, compatibility or platform constraints?", "none stated"],
37091
+ ["extra", "Anything else the spec must know?", "none"]
37092
+ ];
37093
+ function requestSlug(text) {
37094
+ const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 8).join("-");
37095
+ return slug === "" ? "request" : slug;
37096
+ }
37097
+ async function refineRequest(io, request) {
37098
+ if (io.ask === undefined) {
37099
+ throw new PreconditionError("cannot ask questions in this environment", [
37100
+ "run `ai-factory refine` from an interactive terminal, or pass a complete request to `run`"
37101
+ ]);
37102
+ }
37103
+ const answers = [];
37104
+ for (const [key, question, initial] of QUESTIONS) {
37105
+ const answer = await io.ask(question, initial);
37106
+ if (answer === undefined) {
37107
+ throw new PreconditionError("refinement was cancelled");
37108
+ }
37109
+ if (answer.trim() !== "" && answer !== initial)
37110
+ answers.push([key, answer.trim()]);
37111
+ }
37112
+ const text = `# Refined request
37113
+
37114
+ ## Original
37115
+
37116
+ ${request.trim()}
37117
+
37118
+ ## Clarifications
37119
+
37120
+ ${answers.map(([key, answer]) => `- **${key}**: ${answer}`).join(`
37121
+ `)}
37122
+
37123
+ ## Instructions for the architect
37124
+
37125
+ Treat the clarifications as part of the request. If they conflict with the original, the
37126
+ clarification wins. Keep anything not named here out of scope, and put open questions into
37127
+ \`openspec/changes/<id>/questions.md\` rather than guessing.
37128
+ `;
37129
+ const root = await findWorkspaceRoot(io.cwd);
37130
+ if (root === undefined)
37131
+ throw new PreconditionError("not inside a git repository");
37132
+ const dir = join48(root, ".factory", "requests");
37133
+ mkdirSync18(dir, { recursive: true });
37134
+ const path = join48(dir, `${requestSlug(request)}.md`);
37135
+ writeFileSync15(path, text);
37136
+ return { path, text };
37137
+ }
37138
+
36012
37139
  // src/cli/commands/pipeline-commands.ts
36013
37140
  async function setup(io) {
36014
37141
  const root = await findWorkspaceRoot(io.cwd);
36015
37142
  if (root === undefined)
36016
37143
  throw new PreconditionError("not inside a git repository");
36017
- if (!existsSync33(join46(root, LOCK_FILE)))
37144
+ if (!existsSync34(join49(root, LOCK_FILE)))
36018
37145
  throw new PreconditionError("ai-factory is not installed here", ["run `ai-factory init`"]);
36019
37146
  const { config } = loadConfig(root);
36020
37147
  pruneLogs(root, config.logs.retentionDays);
@@ -36062,18 +37189,35 @@ function changeIdArg(command, positionals) {
36062
37189
  var runCommand = {
36063
37190
  name: "run",
36064
37191
  summary: "Start a change from a request and run it up to spec approval",
36065
- help: `Usage: ai-factory run "<request>"
37192
+ help: `Usage: ai-factory run "<request>" [--request <file>] [--interactive]
36066
37193
 
36067
37194
  Intake (change id and summary), a change branch in the AI repo, explorers per likely repo,
36068
37195
  then the architect writes the OpenSpec change. Stops at spec approval: review
36069
37196
  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.`,
37197
+ new one is drafted in .factory/drafts/<id> and queued after approval.
37198
+
37199
+ --request <file> Read the request from a file (e.g. one written by \`ai-factory refine\`)
37200
+ --interactive Ask scope/verification questions before intake`,
36071
37201
  async run(args, io) {
36072
- const { positionals } = parseCommandArgs("run", args, {}, true);
36073
- const request = positionals.join(" ").trim();
37202
+ const { values, positionals } = parseCommandArgs("run", args, {
37203
+ request: { type: "string" },
37204
+ interactive: { type: "boolean", default: false }
37205
+ }, true);
37206
+ let request = positionals.join(" ").trim();
37207
+ if (values.request !== undefined) {
37208
+ request = readFileSync27(resolve5(io.cwd, values.request), "utf8").trim();
37209
+ }
36074
37210
  if (request === "")
36075
37211
  throw new UsageError('run: expected a request, e.g. ai-factory run "add a health endpoint"');
36076
37212
  return withPipeline(io, "intake", async (p) => {
37213
+ if (values.interactive) {
37214
+ if (!p.config.workflow.refine) {
37215
+ throw new PreconditionError("workflow.refine is disabled");
37216
+ }
37217
+ const refined = await refineRequest(io, request);
37218
+ request = refined.text;
37219
+ io.out(`refined request: ${refined.path}`);
37220
+ }
36077
37221
  const id = await intake(p, request);
36078
37222
  const result = await design(p, id);
36079
37223
  if (!result.ok) {
@@ -36131,8 +37275,282 @@ kept worktrees, finished ones stay; a queued change starts when the workspace is
36131
37275
  }
36132
37276
  };
36133
37277
 
37278
+ // src/pipeline/quick.ts
37279
+ import { rmSync as rmSync12 } from "node:fs";
37280
+ import { join as join50 } from "node:path";
37281
+ async function revert(dir, paths) {
37282
+ for (const path of paths) {
37283
+ const inHead = (await git(["cat-file", "-e", `HEAD:${path}`], { cwd: dir })).code === 0;
37284
+ if (inHead)
37285
+ await gitOk(["checkout", "-q", "HEAD", "--", path], { cwd: dir });
37286
+ else {
37287
+ await git(["rm", "-q", "--cached", "--ignore-unmatch", "--", path], { cwd: dir });
37288
+ rmSync12(join50(dir, path), { force: true, recursive: true });
37289
+ }
37290
+ }
37291
+ }
37292
+ function gateOutput(report) {
37293
+ return report.results.filter((r) => r.status !== "pass" && r.status !== "skipped").map((r) => `[${r.name ?? r.type}] ${r.command ?? ""}
37294
+ ${r.output}`).join(`
37295
+
37296
+ `);
37297
+ }
37298
+ async function quickTask(p, input) {
37299
+ if (!p.config.workflow.quick.enabled) {
37300
+ throw new PreconditionError("workflow.quick.enabled is false");
37301
+ }
37302
+ const code = p.codeRepos();
37303
+ const repo = input.repo === undefined ? code.length === 1 ? code[0] : undefined : code.find((r) => r.name === input.repo);
37304
+ if (repo === undefined) {
37305
+ 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})`));
37306
+ }
37307
+ const dir = p.repoDir(repo);
37308
+ await ensureClean(dir, repo.name);
37309
+ const slug = requestSlug(input.task);
37310
+ const branch = `${p.config.workflow.quick.branchPrefix}${slug}`;
37311
+ if (await currentBranch(dir) === branch) {
37312
+ throw new PreconditionError(`already on ${branch}`);
37313
+ }
37314
+ if ((await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: dir })).code === 0) {
37315
+ throw new PreconditionError(`branch ${branch} already exists`);
37316
+ }
37317
+ const base = (await gitOk(["rev-parse", "HEAD"], { cwd: dir })).trim();
37318
+ await gitOk(["switch", "-q", "-c", branch], { cwd: dir });
37319
+ const installed = await installDeps(p.root, dir, repo);
37320
+ const installFailure = installed.find((s) => s.status === "failed");
37321
+ if (installFailure !== undefined) {
37322
+ return {
37323
+ repo: repo.name,
37324
+ branch,
37325
+ ok: false,
37326
+ detail: `install failed: ${installFailure.detail ?? installFailure.dir}`
37327
+ };
37328
+ }
37329
+ const explicit = input.agent === undefined ? undefined : p.config.agents.find((a) => a.id === input.agent);
37330
+ if (input.agent !== undefined && explicit === undefined) {
37331
+ throw new PreconditionError(`quick: unknown agent "${input.agent}"`);
37332
+ }
37333
+ if (explicit !== undefined && explicit.phase !== "implementer") {
37334
+ throw new PreconditionError(`quick: agent "${explicit.id}" is not an implementer`);
37335
+ }
37336
+ const routed = explicit === undefined ? { agent: `implementer-${repo.name}`, role: "implementer", custom: false } : { agent: explicit.id, role: "implementer", custom: true };
37337
+ const context = {
37338
+ directory: dir,
37339
+ workspace: p.root,
37340
+ repo: repo.name,
37341
+ task: "quick",
37342
+ touches: ["**"]
37343
+ };
37344
+ let feedback;
37345
+ let passed = false;
37346
+ let output = "";
37347
+ for (let attempt = 1;attempt <= 3 && !passed; attempt++) {
37348
+ const res = await p.call({
37349
+ changeId: `quick-${slug}`,
37350
+ agent: routed.agent,
37351
+ role: "implementer",
37352
+ modelRole: "implementer",
37353
+ context,
37354
+ title: `quick ${slug} attempt ${attempt}`,
37355
+ step: "quick",
37356
+ kind: "attempt",
37357
+ attempt,
37358
+ prompt: feedback === undefined ? quickPrompt({ task: input.task, repo }) : `${quickPrompt({ task: input.task, repo })}
37359
+
37360
+ Previous attempt failed:
37361
+ ${feedback}`
37362
+ });
37363
+ if (res.handoff === undefined || res.handoff.status !== "done") {
37364
+ return {
37365
+ repo: repo.name,
37366
+ branch,
37367
+ ok: false,
37368
+ detail: res.handoff?.openQuestions.join("; ") ?? res.problem ?? "agent did not finish"
37369
+ };
37370
+ }
37371
+ const changed = await changedFiles(dir, base);
37372
+ const violations = auditChanges({
37373
+ sessionId: "quick-audit",
37374
+ changeId: `quick-${slug}`,
37375
+ role: "implementer",
37376
+ ...context
37377
+ }, changed, true);
37378
+ if (violations.length > 0) {
37379
+ await revert(dir, violations.map((v) => v.path));
37380
+ return {
37381
+ repo: repo.name,
37382
+ branch,
37383
+ ok: false,
37384
+ detail: `edits outside the quick scope were reverted:
37385
+ ${violations.map((v) => `${v.path}: ${v.reason}`).join(`
37386
+ `)}`
37387
+ };
37388
+ }
37389
+ const report = await runGates({
37390
+ root: p.root,
37391
+ config: p.config,
37392
+ repo,
37393
+ worktree: dir,
37394
+ mode: "task",
37395
+ base
37396
+ });
37397
+ if (report.passed) {
37398
+ passed = true;
37399
+ break;
37400
+ }
37401
+ output = gateOutput(report);
37402
+ feedback = output;
37403
+ }
37404
+ if (!passed) {
37405
+ return { repo: repo.name, branch, ok: false, detail: `gates failed:
37406
+ ${output}` };
37407
+ }
37408
+ if (input.review) {
37409
+ let findings = [];
37410
+ for (let round = 1;round <= 3; round++) {
37411
+ const review = await p.call({
37412
+ changeId: `quick-${slug}`,
37413
+ agent: "reviewer",
37414
+ role: "reviewer",
37415
+ modelRole: "reviewer",
37416
+ context: { directory: dir, workspace: p.root, repo: repo.name },
37417
+ title: `quick ${slug} review ${round}`,
37418
+ step: "quick-review",
37419
+ kind: "step",
37420
+ prompt: quickReviewPrompt({ task: input.task, repo })
37421
+ });
37422
+ if (review.handoff === undefined) {
37423
+ return {
37424
+ repo: repo.name,
37425
+ branch,
37426
+ ok: false,
37427
+ detail: review.problem ?? "review produced no handoff"
37428
+ };
37429
+ }
37430
+ findings = (review.handoff.findings ?? []).filter((f) => f.severity !== "nice").map((f) => `- [${f.severity}] ${f.file ?? ""}${f.line ? `:${f.line}` : ""} ${f.problem}
37431
+ fix: ${f.fix}`);
37432
+ if (findings.length === 0)
37433
+ break;
37434
+ const fix = await p.call({
37435
+ changeId: `quick-${slug}`,
37436
+ agent: routed.agent,
37437
+ role: "implementer",
37438
+ modelRole: "implementer",
37439
+ context,
37440
+ title: `quick ${slug} fix ${round}`,
37441
+ step: "quick",
37442
+ kind: "attempt",
37443
+ attempt: 4 + round,
37444
+ prompt: `Fix these review findings for the quick task:
37445
+ ${findings.join(`
37446
+ `)}
37447
+
37448
+ ${quickPrompt({ task: input.task, repo })}`
37449
+ });
37450
+ if (fix.handoff?.status !== "done") {
37451
+ return { repo: repo.name, branch, ok: false, detail: "review fix did not finish" };
37452
+ }
37453
+ const report = await runGates({
37454
+ root: p.root,
37455
+ config: p.config,
37456
+ repo,
37457
+ worktree: dir,
37458
+ mode: "task",
37459
+ base
37460
+ });
37461
+ if (!report.passed) {
37462
+ return {
37463
+ repo: repo.name,
37464
+ branch,
37465
+ ok: false,
37466
+ detail: `gates failed after review fix:
37467
+ ${gateOutput(report)}`
37468
+ };
37469
+ }
37470
+ if (round === 3) {
37471
+ return {
37472
+ repo: repo.name,
37473
+ branch,
37474
+ ok: false,
37475
+ detail: `review findings remain:
37476
+ ${findings.join(`
37477
+ `)}`
37478
+ };
37479
+ }
37480
+ }
37481
+ }
37482
+ const subject = conventionalSubject(input.task, undefined, {
37483
+ style: p.config.commits.style,
37484
+ ...p.config.commits.scopes ? { scope: repo.name } : {}
37485
+ });
37486
+ const commit = await commitAll(dir, subject, { "Change-Id": `quick-${slug}` });
37487
+ return {
37488
+ repo: repo.name,
37489
+ branch,
37490
+ ...commit === undefined ? {} : { commit },
37491
+ ok: true,
37492
+ detail: commit === undefined ? "nothing to commit" : `committed ${commit.slice(0, 12)}`
37493
+ };
37494
+ }
37495
+
37496
+ // src/cli/commands/quick.ts
37497
+ var HELP5 = `Usage: ai-factory quick "<task>" [--repo <name>] [--agent <id>] [--no-review]
37498
+
37499
+ The no-OpenSpec path: create a task/<slug> branch, implement, run the repository gates, run
37500
+ one review pass, then make one local commit. No spec, no approval hash, and no push.
37501
+ --agent selects a configured custom implementer. The branch stays checked out so you can
37502
+ inspect or amend it.`;
37503
+ var quickCommand = {
37504
+ name: "quick",
37505
+ summary: "Implement a small task on a branch without an OpenSpec change",
37506
+ help: HELP5,
37507
+ async run(args, io) {
37508
+ const { values, positionals } = parseCommandArgs("quick", args, {
37509
+ repo: { type: "string" },
37510
+ agent: { type: "string" },
37511
+ "no-review": { type: "boolean", default: false }
37512
+ }, true);
37513
+ const task = positionals.join(" ").trim();
37514
+ if (task === "")
37515
+ throw new UsageError('quick: expected a task, e.g. "fix the login typo"');
37516
+ return withPipeline(io, "quick", async (p) => {
37517
+ const result = await quickTask(p, {
37518
+ task,
37519
+ ...values.repo === undefined ? {} : { repo: values.repo },
37520
+ ...values.agent === undefined ? {} : { agent: values.agent },
37521
+ review: !values["no-review"]
37522
+ });
37523
+ io.out(`${result.ok ? "✓" : "✗"} ${result.repo} ${result.branch}: ${result.detail}`);
37524
+ return result.ok ? ExitCode.Ok : ExitCode.GateFailed;
37525
+ });
37526
+ }
37527
+ };
37528
+
37529
+ // src/cli/commands/refine.ts
37530
+ var HELP6 = `Usage: ai-factory refine "<request>"
37531
+
37532
+ Ask a few scope, verification and constraint questions, then write a refined request to
37533
+ .factory/requests/<slug>.md. Nothing is designed or implemented. Feed it to the pipeline
37534
+ with \`ai-factory run --request .factory/requests/<slug>.md\`, or use \`run --interactive\`
37535
+ to refine in the same command.`;
37536
+ var refineCommand = {
37537
+ name: "refine",
37538
+ summary: "Clarify a vague request before it becomes a spec",
37539
+ help: HELP6,
37540
+ async run(args, io) {
37541
+ const { positionals } = parseCommandArgs("refine", args, {}, true);
37542
+ const request = positionals.join(" ").trim();
37543
+ if (request === "")
37544
+ throw new UsageError('refine: expected a request, e.g. "add search"');
37545
+ const { path } = await refineRequest(io, request);
37546
+ io.out(`refined request: ${path}`);
37547
+ io.out(`next: ai-factory run --request ${path}`);
37548
+ return ExitCode.Ok;
37549
+ }
37550
+ };
37551
+
36134
37552
  // src/cli/commands/repoint.ts
36135
- var HELP5 = `Usage: ai-factory repoint <change-id> [--json]
37553
+ var HELP7 = `Usage: ai-factory repoint <change-id> [--json]
36136
37554
 
36137
37555
  After the submodule MRs of a change are merged upstream: fetches each submodule, finds the
36138
37556
  merged commit (fast-forward, merge commit, Change-Id trailer, or the squash commit with the
@@ -36143,7 +37561,7 @@ Exits ${ExitCode.Precondition} when any pointer is still provisional.`;
36143
37561
  var repointCommand = {
36144
37562
  name: "repoint",
36145
37563
  summary: "Replace provisional submodule pointers after the MRs merged",
36146
- help: HELP5,
37564
+ help: HELP7,
36147
37565
  async run(args, io) {
36148
37566
  const { values, positionals } = parseCommandArgs("repoint", args, { json: { type: "boolean", default: false } }, true);
36149
37567
  const id = positionals[0];
@@ -36172,8 +37590,8 @@ var repointCommand = {
36172
37590
  };
36173
37591
 
36174
37592
  // 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";
37593
+ import { existsSync as existsSync35, readdirSync as readdirSync14 } from "node:fs";
37594
+ import { join as join51 } from "node:path";
36177
37595
  async function workspace(io) {
36178
37596
  const root = await findWorkspaceRoot(io.cwd);
36179
37597
  if (root === undefined)
@@ -36222,9 +37640,9 @@ function taskDetail(run, task) {
36222
37640
  }
36223
37641
  function worktreesOf(root, id) {
36224
37642
  const dir = factoryDir(root, "worktrees", id);
36225
- if (!existsSync34(dir))
37643
+ if (!existsSync35(dir))
36226
37644
  return [];
36227
- return readdirSync13(dir).filter((repo) => repo !== ".opencode").flatMap((repo) => readdirSync13(join47(dir, repo)).map((task) => `.factory/worktrees/${id}/${repo}/${task}`));
37645
+ return readdirSync14(dir).filter((repo) => repo !== ".opencode").flatMap((repo) => readdirSync14(join51(dir, repo)).map((task) => `.factory/worktrees/${id}/${repo}/${task}`));
36228
37646
  }
36229
37647
  function printRun(io, root, run, active, queuePos) {
36230
37648
  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 +37800,20 @@ that were cancelled because of it. Continue the change with \`ai-factory resume
36382
37800
  };
36383
37801
 
36384
37802
  // src/cli/commands/update.ts
36385
- import { rmSync as rmSync13, writeFileSync as writeFileSync16 } from "node:fs";
36386
- import { join as join49 } from "node:path";
37803
+ import { rmSync as rmSync14, writeFileSync as writeFileSync17 } from "node:fs";
37804
+ import { join as join53 } from "node:path";
36387
37805
 
36388
37806
  // src/update/update.ts
36389
37807
  import {
36390
37808
  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
37809
+ existsSync as existsSync36,
37810
+ mkdirSync as mkdirSync19,
37811
+ readdirSync as readdirSync15,
37812
+ readFileSync as readFileSync28,
37813
+ rmSync as rmSync13,
37814
+ writeFileSync as writeFileSync16
36397
37815
  } from "node:fs";
36398
- import { dirname as dirname9, join as join48 } from "node:path";
37816
+ import { dirname as dirname10, join as join52 } from "node:path";
36399
37817
 
36400
37818
  // src/migrations/001-graph-exclude-defaults.ts
36401
37819
  var V1_GRAPH_EXCLUDE = [
@@ -36421,8 +37839,19 @@ var graphExcludeDefaults = {
36421
37839
  }
36422
37840
  };
36423
37841
 
37842
+ // src/migrations/002-config-v3.ts
37843
+ var configV3 = {
37844
+ from: 2,
37845
+ to: 3,
37846
+ name: "config-v3",
37847
+ migrate(text) {
37848
+ parseJsonc(text);
37849
+ return setJsonc(text, ["schemaVersion"], 3);
37850
+ }
37851
+ };
37852
+
36424
37853
  // src/migrations/index.ts
36425
- var MIGRATIONS = [graphExcludeDefaults];
37854
+ var MIGRATIONS = [graphExcludeDefaults, configV3];
36426
37855
  function schemaVersionOf(text) {
36427
37856
  const { value } = parseJsonc(text);
36428
37857
  const v = value?.schemaVersion;
@@ -36484,7 +37913,7 @@ function changesInProgress(root) {
36484
37913
  return [...ids].sort();
36485
37914
  }
36486
37915
  async function planUpdate(root, lock, options) {
36487
- const original = readFileSync25(join48(root, CONFIG_FILE), "utf8");
37916
+ const original = readFileSync28(join52(root, CONFIG_FILE), "utf8");
36488
37917
  const migration = migrateConfigText(original);
36489
37918
  const config = parseConfigText(migration.text, CONFIG_FILE);
36490
37919
  const plan = planWorkspace(root, config, {
@@ -36544,20 +37973,20 @@ function formatPlan(plan) {
36544
37973
  var backupsDir = (root) => factoryDir(root, "backups");
36545
37974
  function backup(root, plan) {
36546
37975
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
36547
- const dir = join48(backupsDir(root), stamp);
37976
+ const dir = join52(backupsDir(root), stamp);
36548
37977
  const files = [];
36549
37978
  const created = [];
36550
37979
  for (const rel of plan.touched) {
36551
- const abs = join48(root, rel);
36552
- if (!existsSync35(abs)) {
37980
+ const abs = join52(root, rel);
37981
+ if (!existsSync36(abs)) {
36553
37982
  created.push(rel);
36554
37983
  continue;
36555
37984
  }
36556
- mkdirSync17(dirname9(join48(dir, "files", rel)), { recursive: true });
36557
- cpSync2(abs, join48(dir, "files", rel), { recursive: true });
37985
+ mkdirSync19(dirname10(join52(dir, "files", rel)), { recursive: true });
37986
+ cpSync2(abs, join52(dir, "files", rel), { recursive: true });
36558
37987
  files.push(rel);
36559
37988
  }
36560
- writeJsonAtomic(join48(dir, "manifest.json"), {
37989
+ writeJsonAtomic(join52(dir, "manifest.json"), {
36561
37990
  at: new Date().toISOString(),
36562
37991
  fromVersion: plan.fromVersion,
36563
37992
  toVersion: plan.toVersion,
@@ -36568,28 +37997,28 @@ function backup(root, plan) {
36568
37997
  }
36569
37998
  function latestBackup(root) {
36570
37999
  const base = backupsDir(root);
36571
- if (!existsSync35(base))
38000
+ if (!existsSync36(base))
36572
38001
  return;
36573
- for (const name of readdirSync14(base).sort().reverse()) {
36574
- const file = join48(base, name, "manifest.json");
36575
- if (!existsSync35(file))
38002
+ for (const name of readdirSync15(base).sort().reverse()) {
38003
+ const file = join52(base, name, "manifest.json");
38004
+ if (!existsSync36(file))
36576
38005
  continue;
36577
- const manifest = JSON.parse(readFileSync25(file, "utf8"));
38006
+ const manifest = JSON.parse(readFileSync28(file, "utf8"));
36578
38007
  if (!manifest.restored)
36579
- return { dir: join48(base, name), manifest };
38008
+ return { dir: join52(base, name), manifest };
36580
38009
  }
36581
38010
  return;
36582
38011
  }
36583
38012
  async function restoreBackup(root, dir, manifest) {
36584
38013
  for (const rel of manifest.created)
36585
- rmSync12(join48(root, rel), { recursive: true, force: true });
38014
+ rmSync13(join52(root, rel), { recursive: true, force: true });
36586
38015
  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 });
38016
+ const target = join52(root, rel);
38017
+ rmSync13(target, { recursive: true, force: true });
38018
+ mkdirSync19(dirname10(target), { recursive: true });
38019
+ cpSync2(join52(dir, "files", rel), target, { recursive: true });
36591
38020
  }
36592
- writeFileSync15(join48(dir, "manifest.json"), `${JSON.stringify({ ...manifest, restored: true }, null, 2)}
38021
+ writeFileSync16(join52(dir, "manifest.json"), `${JSON.stringify({ ...manifest, restored: true }, null, 2)}
36593
38022
  `);
36594
38023
  return [...manifest.files, ...manifest.created];
36595
38024
  }
@@ -36683,7 +38112,7 @@ Upgrades this workspace to the installed factory version, as one local commit
36683
38112
  if (!t.ok)
36684
38113
  io.out(`warning: ${t.tool}: ${t.problem} → ${t.fix}`);
36685
38114
  const backupDir = backup(root, plan);
36686
- writeFileSync16(join49(root, CONFIG_FILE), plan.configText);
38115
+ writeFileSync17(join53(root, CONFIG_FILE), plan.configText);
36687
38116
  const result = await syncWorkspace(root, plan.config, {
36688
38117
  merge: true,
36689
38118
  ...values.restore ? { restore: true } : {},
@@ -36698,7 +38127,7 @@ Upgrades this workspace to the installed factory version, as one local commit
36698
38127
  }
36699
38128
  const commit = await commitInstall(root, `chore(factory): update to v${FACTORY_VERSION}`);
36700
38129
  if (commit === undefined)
36701
- rmSync13(backupDir, { recursive: true, force: true });
38130
+ rmSync14(backupDir, { recursive: true, force: true });
36702
38131
  if (plan.graphRebuild) {
36703
38132
  const graph = await updateGraph(root, { full: true });
36704
38133
  io.out(graph.ok ? "graph: rebuilt" : `graph: rebuild failed (${graph.error ?? ""}); run \`ai-factory graph --full\``);
@@ -36752,7 +38181,9 @@ var COMMANDS = [
36752
38181
  rollbackCommand,
36753
38182
  doctorCommand,
36754
38183
  detectCommand,
38184
+ refineCommand,
36755
38185
  runCommand,
38186
+ quickCommand,
36756
38187
  approveCommand,
36757
38188
  resumeCommand,
36758
38189
  statusCommand,
@@ -36799,6 +38230,16 @@ async function askYesNo(question) {
36799
38230
  rl.close();
36800
38231
  }
36801
38232
  }
38233
+ async function askText(question, initial) {
38234
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
38235
+ try {
38236
+ const suffix = initial === undefined || initial === "" ? "" : ` (${initial})`;
38237
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
38238
+ return answer === "" ? initial : answer;
38239
+ } finally {
38240
+ rl.close();
38241
+ }
38242
+ }
36802
38243
  async function runCli(argv, io) {
36803
38244
  let cwd = io.cwd;
36804
38245
  let i = 0;
@@ -36810,7 +38251,7 @@ async function runCli(argv, io) {
36810
38251
  io.err("error: -C needs a directory");
36811
38252
  return ExitCode.Usage;
36812
38253
  }
36813
- cwd = resolve5(cwd, dir);
38254
+ cwd = resolve6(cwd, dir);
36814
38255
  } else if (arg === "-V" || arg === "--version") {
36815
38256
  io.out(FACTORY_VERSION);
36816
38257
  return ExitCode.Ok;
@@ -36852,6 +38293,6 @@ var code = await runCli(process.argv.slice(2), {
36852
38293
  `),
36853
38294
  err: (text) => process.stderr.write(`${text}
36854
38295
  `),
36855
- ...process.stdin.isTTY ? { confirm: askYesNo } : {}
38296
+ ...process.stdin.isTTY ? { confirm: askYesNo, ask: askText } : {}
36856
38297
  });
36857
38298
  process.exit(code);