@agentplate/cli 1.0.0 → 1.2.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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Auto-merge: land a completed worker's branch onto the canonical branch without
3
+ * an operator running `agentplate merge`. Gated by `config.merge.autoMerge`:
4
+ *
5
+ * off → never (manual merge by the operator/coordinator)
6
+ * on-gates-pass → merge only when the task's quality gates passed cleanly
7
+ * on-complete → merge as soon as the agent finished without error
8
+ *
9
+ * This reuses the exact same path as the manual `merge` command — the merge queue
10
+ * (audit), the cross-process merge lock (so parallel auto-merges serialize), and
11
+ * {@link mergeBranch} (clean-merge / auto-resolve per `aiResolveEnabled`). Outcomes
12
+ * are reported to the parent/coordinator over mail (`merged` / `merge_failed`), so
13
+ * the existing coordination flow still sees every landing and handles conflicts.
14
+ *
15
+ * Pulled out of `sling` into its own unit so it can be tested against a real temp
16
+ * git repo without driving an agent turn.
17
+ */
18
+
19
+ import { createMailClient } from "../mail/client.ts";
20
+ import { mergeDbPath } from "../paths.ts";
21
+ import type { AutoMergeMode, Capability, MergeStatus, MergeTier, OutcomeStatus } from "../types.ts";
22
+ import { withMergeLock } from "./lock.ts";
23
+ import { createMergeQueue } from "./queue.ts";
24
+ import { mergeBranch } from "./resolver.ts";
25
+
26
+ /** Capabilities that never produce a branch worth landing on the canonical branch. */
27
+ const NON_MERGING_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>(["scout", "merger"]);
28
+
29
+ export interface AutoMergeParams {
30
+ root: string;
31
+ branchName: string;
32
+ targetBranch: string;
33
+ capability: Capability;
34
+ agentName: string;
35
+ taskId: string;
36
+ /** Who to notify of the outcome (falls back to the coordinator). */
37
+ parent: string | null;
38
+ mode: AutoMergeMode;
39
+ /** Conflict strategy passed through to `mergeBranch`. */
40
+ aiResolveEnabled: boolean;
41
+ /** The task's quality-gate outcome, or null if gates did not run. */
42
+ gateStatus: OutcomeStatus | null;
43
+ /** Mail surface for reporting the outcome (injectable for tests). */
44
+ mail?: { send: (m: Parameters<ReturnType<typeof createMailClient>["send"]>[0]) => unknown };
45
+ }
46
+
47
+ export type AutoMergeOutcome =
48
+ | { merged: true; status: MergeStatus; tier: MergeTier | null }
49
+ | {
50
+ merged: false;
51
+ reason: "disabled" | "capability-skipped" | "gates-not-passed" | "merge-failed";
52
+ conflictFiles?: string[];
53
+ };
54
+
55
+ /**
56
+ * Decide whether to auto-merge `branchName` and, if so, do it under the merge lock.
57
+ * Never throws on a merge conflict — it reports `merge_failed` and returns a
58
+ * non-merged outcome so the spawn that called it is never failed by a landing.
59
+ */
60
+ export async function maybeAutoMerge(params: AutoMergeParams): Promise<AutoMergeOutcome> {
61
+ const mail = params.mail ?? createMailClient(params.root);
62
+ const to = params.parent ?? "coordinator";
63
+
64
+ if (params.mode === "off") return { merged: false, reason: "disabled" };
65
+ if (NON_MERGING_CAPABILITIES.has(params.capability)) {
66
+ return { merged: false, reason: "capability-skipped" };
67
+ }
68
+ // Fail closed: on-gates-pass merges ONLY on a clean pass. A null status (gates
69
+ // not configured / not run) or any non-success holds the merge for a human.
70
+ if (params.mode === "on-gates-pass" && params.gateStatus !== "success") {
71
+ mail.send({
72
+ from: params.agentName,
73
+ to,
74
+ subject: `Auto-merge held: ${params.branchName}`,
75
+ body: `Quality gates did not pass (status: ${params.gateStatus ?? "not run"}); not merging. Review and merge manually.`,
76
+ type: "status",
77
+ });
78
+ return { merged: false, reason: "gates-not-passed" };
79
+ }
80
+
81
+ const queue = createMergeQueue(mergeDbPath(params.root));
82
+ try {
83
+ const entry = queue.enqueue({
84
+ branchName: params.branchName,
85
+ agentName: params.agentName,
86
+ taskId: params.taskId,
87
+ targetBranch: params.targetBranch,
88
+ });
89
+ const result = await withMergeLock(params.root, () =>
90
+ mergeBranch(params.root, params.branchName, params.targetBranch, {
91
+ autoResolve: params.aiResolveEnabled,
92
+ }),
93
+ );
94
+ queue.markStatus(entry.id, result.status);
95
+
96
+ if (result.status === "merged") {
97
+ mail.send({
98
+ from: params.agentName,
99
+ to,
100
+ subject: `Auto-merged: ${params.branchName} → ${params.targetBranch}`,
101
+ body: `Landed via ${result.tier ?? "merge"}.`,
102
+ type: "merged",
103
+ });
104
+ return { merged: true, status: result.status, tier: result.tier };
105
+ }
106
+
107
+ mail.send({
108
+ from: params.agentName,
109
+ to,
110
+ subject: `Auto-merge failed: ${params.branchName}`,
111
+ body: `Conflicts in: ${result.conflictFiles.join(", ") || "unknown"}. Merge manually.`,
112
+ type: "merge_failed",
113
+ });
114
+ return { merged: false, reason: "merge-failed", conflictFiles: result.conflictFiles };
115
+ } finally {
116
+ queue.close();
117
+ }
118
+ }
package/src/paths.ts CHANGED
@@ -22,8 +22,9 @@ export const agentStateDir = (root: string, agentName: string): string =>
22
22
  join(root, AGENTPLATE_DIR, "agents", agentName);
23
23
  export const appliedSkillsPath = (root: string, agentName: string): string =>
24
24
  join(agentStateDir(root, agentName), "applied-skills.json");
25
+ export const specsDir = (root: string): string => join(root, AGENTPLATE_DIR, "specs");
25
26
  export const specPath = (root: string, taskId: string): string =>
26
- join(root, AGENTPLATE_DIR, "specs", `${taskId}.md`);
27
+ join(specsDir(root), `${taskId}.md`);
27
28
 
28
29
  /**
29
30
  * Path to a bundled base agent definition shipped with the package
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tests for resolveModel — focused on per-capability model tiering. Auth-mode is
3
+ * set to "none" so no secret is read; we assert which model id is chosen.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { DEFAULT_CONFIG } from "../config.ts";
8
+ import type { AgentplateConfig, Capability } from "../types.ts";
9
+ import { resolveModel } from "./resolve.ts";
10
+
11
+ /** A config whose active provider has a default model + optional tiering map. */
12
+ function configWith(
13
+ model: string | undefined,
14
+ models?: Partial<Record<Capability, string>>,
15
+ ): AgentplateConfig {
16
+ const cfg = structuredClone(DEFAULT_CONFIG);
17
+ cfg.activeProvider = "anthropic";
18
+ cfg.providers.anthropic = { type: "native", authMode: "none", model, models };
19
+ return cfg;
20
+ }
21
+
22
+ const ROOT = "/tmp/agentplate-resolve-test"; // never read (authMode "none")
23
+
24
+ describe("resolveModel — tiering", () => {
25
+ test("uses the provider default model when no capability is given", () => {
26
+ const { model } = resolveModel(configWith("claude-opus-4-8"), ROOT, "alias");
27
+ expect(model).toBe("claude-opus-4-8");
28
+ });
29
+
30
+ test("a per-capability override wins over the provider default", () => {
31
+ const cfg = configWith("claude-opus-4-8", {
32
+ scout: "claude-haiku-4-5",
33
+ reviewer: "claude-haiku-4-5",
34
+ });
35
+ expect(resolveModel(cfg, ROOT, "alias", "scout").model).toBe("claude-haiku-4-5");
36
+ expect(resolveModel(cfg, ROOT, "alias", "reviewer").model).toBe("claude-haiku-4-5");
37
+ });
38
+
39
+ test("a capability without an override falls back to the provider default", () => {
40
+ const cfg = configWith("claude-opus-4-8", { scout: "claude-haiku-4-5" });
41
+ expect(resolveModel(cfg, ROOT, "alias", "builder").model).toBe("claude-opus-4-8");
42
+ });
43
+
44
+ test("falls back to the manifest alias when the provider has no model", () => {
45
+ expect(resolveModel(configWith(undefined), ROOT, "manifest-alias", "builder").model).toBe(
46
+ "manifest-alias",
47
+ );
48
+ });
49
+ });
@@ -2,11 +2,12 @@
2
2
  * Model resolution — bridge between the agent manifest (which names models by
3
3
  * alias) and a concrete {@link ResolvedModel} the runtime can spawn.
4
4
  *
5
- * Phase 2 keeps this deliberately simple: the active provider's configured
6
- * `model` (set by `agentplate setup`) is the concrete model, and the provider's
7
- * `authTokenEnv` secret is injected as an env var. Per-capability model tiering
8
- * (opus/sonnet/haiku) is a later refinement; for now the manifest alias is the
9
- * fallback when no provider model is configured.
5
+ * The active provider's configured `model` (set by `agentplate setup`) is the
6
+ * concrete model, and the provider's `authTokenEnv` secret is injected as an env
7
+ * var. Per-capability **model tiering** is supported: when a `capability` is given
8
+ * and the provider has a `models[capability]` override (e.g. a fast model for
9
+ * `scout`/`reviewer`), that wins; otherwise the provider `model`, otherwise the
10
+ * manifest alias.
10
11
  *
11
12
  * Auth mode matters here: only `api-key`/`env` providers inject a credential env
12
13
  * var. `subscription` providers delegate to the runtime CLI's own login (e.g. a
@@ -15,7 +16,7 @@
15
16
  */
16
17
 
17
18
  import { getSecret } from "../secrets.ts";
18
- import type { AgentplateConfig, ResolvedModel } from "../types.ts";
19
+ import type { AgentplateConfig, Capability, ResolvedModel } from "../types.ts";
19
20
 
20
21
  /**
21
22
  * Resolve the concrete model + provider env for a manifest model alias.
@@ -23,14 +24,17 @@ import type { AgentplateConfig, ResolvedModel } from "../types.ts";
23
24
  * @param config loaded Agentplate config
24
25
  * @param root project root (to read the gitignored secret value)
25
26
  * @param manifestModel the model alias/id from the agent definition
27
+ * @param capability optional agent capability for per-capability model tiering
26
28
  */
27
29
  export function resolveModel(
28
30
  config: AgentplateConfig,
29
31
  root: string,
30
32
  manifestModel: string,
33
+ capability?: Capability,
31
34
  ): ResolvedModel {
32
35
  const provider = config.providers[config.activeProvider];
33
- const model = provider?.model ?? manifestModel;
36
+ const tiered = capability ? provider?.models?.[capability] : undefined;
37
+ const model = tiered ?? provider?.model ?? manifestModel;
34
38
  const env: Record<string, string> = {};
35
39
 
36
40
  // Default to "api-key" for legacy configs written before authMode existed.
@@ -318,6 +318,19 @@ describe("createSessionStore — countActive", () => {
318
318
  store.updateSessionState(s.id, "completed");
319
319
  expect(store.countActive("run-x")).toBe(0);
320
320
  });
321
+
322
+ test("countActiveByParent counts a parent's active children only", () => {
323
+ store.upsertSession(makeSession({ runId: "r", parentAgent: "lead-1", state: "working" }));
324
+ store.upsertSession(makeSession({ runId: "r", parentAgent: "lead-1", state: "booting" }));
325
+ const done = makeSession({ runId: "r", parentAgent: "lead-1", state: "completed" });
326
+ store.upsertSession(done);
327
+ store.upsertSession(makeSession({ runId: "r", parentAgent: "lead-2", state: "working" }));
328
+
329
+ expect(store.countActiveByParent("lead-1", "r")).toBe(2); // excludes the completed child
330
+ expect(store.countActiveByParent("lead-2", "r")).toBe(1);
331
+ expect(store.countActiveByParent("lead-1", "other-run")).toBe(0);
332
+ expect(store.countActiveByParent("nobody")).toBe(0);
333
+ });
321
334
  });
322
335
 
323
336
  describe("createSessionStore — file-backed database", () => {
@@ -57,6 +57,7 @@ export interface SessionStore {
57
57
  setRuntimeSessionId(id: string, runtimeSessionId: string): void;
58
58
  touch(id: string): void;
59
59
  countActive(runId?: string): number;
60
+ countActiveByParent(parentAgent: string, runId?: string): number;
60
61
  // --- Lifecycle ---
61
62
  close(): void;
62
63
  }
@@ -328,6 +329,24 @@ export function createSessionStore(dbPath: string): SessionStore {
328
329
  return row.n;
329
330
  }
330
331
 
332
+ function countActiveByParent(parentAgent: string, runId?: string): number {
333
+ // Active children of a given parent (for the per-lead child cap).
334
+ if (runId !== undefined) {
335
+ const row = db
336
+ .query(
337
+ `SELECT COUNT(*) AS n FROM sessions WHERE parent_agent = ? AND run_id = ? AND state IN ${ACTIVE_STATES_SQL}`,
338
+ )
339
+ .get(parentAgent, runId) as { n: number };
340
+ return row.n;
341
+ }
342
+ const row = db
343
+ .query(
344
+ `SELECT COUNT(*) AS n FROM sessions WHERE parent_agent = ? AND state IN ${ACTIVE_STATES_SQL}`,
345
+ )
346
+ .get(parentAgent) as { n: number };
347
+ return row.n;
348
+ }
349
+
331
350
  function close(): void {
332
351
  db.close();
333
352
  }
@@ -345,6 +364,7 @@ export function createSessionStore(dbPath: string): SessionStore {
345
364
  setRuntimeSessionId,
346
365
  touch,
347
366
  countActive,
367
+ countActiveByParent,
348
368
  close,
349
369
  };
350
370
  }
package/src/types.ts CHANGED
@@ -127,6 +127,11 @@ export interface ProviderConfig {
127
127
  authTokenEnv?: string;
128
128
  /** Default model id for this provider. */
129
129
  model?: string;
130
+ /**
131
+ * Per-capability model overrides (tiering): e.g. a fast/cheap model for
132
+ * `scout`/`reviewer` and a strong model for `builder`. Falls back to `model`.
133
+ */
134
+ models?: Partial<Record<Capability, string>>;
130
135
  }
131
136
 
132
137
  /** Orchestration limits and agent registry locations. */
@@ -150,10 +155,20 @@ export interface AgentsConfig {
150
155
  idleTimeoutMinutes: number;
151
156
  }
152
157
 
153
- /** Conflict-resolution behavior for merging agent branches. */
158
+ /**
159
+ * When a completed worker's branch is auto-merged into the canonical branch.
160
+ * - `off`: never (the operator/coordinator merges manually) — the default.
161
+ * - `on-gates-pass`: merge only if the task's quality gates pass.
162
+ * - `on-complete`: merge as soon as the agent finishes without error.
163
+ */
164
+ export type AutoMergeMode = "off" | "on-gates-pass" | "on-complete";
165
+
166
+ /** Conflict-resolution and auto-merge behavior for merging agent branches. */
154
167
  export interface MergeConfig {
155
168
  /** Allow AI-assisted resolution of semantic conflicts. */
156
169
  aiResolveEnabled: boolean;
170
+ /** When to auto-merge a completed worker's branch into the canonical branch. */
171
+ autoMerge: AutoMergeMode;
157
172
  }
158
173
 
159
174
  /** Logging behavior. */
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * Kept as a plain constant (not read from package.json at runtime) so the value
5
5
  * is available without filesystem access and survives bundling.
6
6
  */
7
- export const VERSION = "1.0.0";
7
+ export const VERSION = "1.2.0";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Wizard helper tests. The interactive prompts themselves need a TTY, but the
3
+ * pure pieces are unit-tested directly. `detectQualityGates` reads a project's
4
+ * package.json to suggest gate commands.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { detectQualityGates } from "./setup.ts";
12
+
13
+ let dir: string;
14
+
15
+ beforeEach(() => {
16
+ dir = mkdtempSync(join(tmpdir(), "agentplate-wizard-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("detectQualityGates", () => {
24
+ test("falls back to stack defaults when there is no package.json", () => {
25
+ const gates = detectQualityGates(dir);
26
+ expect(gates).toEqual([
27
+ { name: "test", command: "bun test" },
28
+ { name: "lint", command: "biome check ." },
29
+ { name: "typecheck", command: "tsc --noEmit" },
30
+ ]);
31
+ });
32
+
33
+ test("prefers `bun run <script>` for scripts the project defines", () => {
34
+ writeFileSync(
35
+ join(dir, "package.json"),
36
+ JSON.stringify({ scripts: { test: "vitest", typecheck: "tsc -p ." } }),
37
+ "utf8",
38
+ );
39
+ const gates = detectQualityGates(dir);
40
+ const byName = Object.fromEntries(gates.map((g) => [g.name, g.command]));
41
+ expect(byName.test).toBe("bun run test"); // script present → run it
42
+ expect(byName.typecheck).toBe("bun run typecheck"); // script present
43
+ expect(byName.lint).toBe("biome check ."); // no lint script → fallback
44
+ });
45
+ });
@@ -7,10 +7,18 @@
7
7
  * module owns only the interactive I/O.
8
8
  */
9
9
 
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
10
12
  import * as p from "@clack/prompts";
11
13
  import { applyProviderSelection, buildProviderConfig } from "../providers/apply.ts";
12
14
  import { getProviderSpec, listProviders, meetsContextFloor } from "../providers/registry.ts";
13
- import type { AgentplateConfig, AuthMode } from "../types.ts";
15
+ import type {
16
+ AgentplateConfig,
17
+ AuthMode,
18
+ AutoMergeMode,
19
+ Capability,
20
+ QualityGate,
21
+ } from "../types.ts";
14
22
  import { commandOnPath, detectDefaultRuntime } from "../utils/detect.ts";
15
23
 
16
24
  /** Runtimes the wizard can offer. */
@@ -34,6 +42,32 @@ export interface WizardResult {
34
42
  secret?: { key: string; value: string };
35
43
  }
36
44
 
45
+ /**
46
+ * Suggest quality gates for a project. Prefers the project's own package.json
47
+ * scripts (`bun run <script>`) when present, falling back to the Bun/Biome/TS
48
+ * defaults this stack uses. The user confirms/deselects in the wizard.
49
+ */
50
+ export function detectQualityGates(root: string): QualityGate[] {
51
+ let scripts: Record<string, string> = {};
52
+ try {
53
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
54
+ scripts?: Record<string, string>;
55
+ };
56
+ scripts = pkg.scripts ?? {};
57
+ } catch {
58
+ // No package.json (or unreadable) — fall back to stack defaults below.
59
+ }
60
+ const gate = (name: string, fallback: string): QualityGate => ({
61
+ name,
62
+ command: scripts[name] ? `bun run ${name}` : fallback,
63
+ });
64
+ return [
65
+ gate("test", "bun test"),
66
+ gate("lint", "biome check ."),
67
+ gate("typecheck", "tsc --noEmit"),
68
+ ];
69
+ }
70
+
37
71
  /** Abort the wizard cleanly if the user cancelled a prompt. */
38
72
  function ensure<T>(value: T | symbol): T {
39
73
  if (p.isCancel(value)) {
@@ -72,7 +106,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
72
106
  await p.text({
73
107
  message: "Base URL for the endpoint",
74
108
  placeholder: "https://my-endpoint.example.com/v1",
75
- validate: (v) => (v.trim().length === 0 ? "A base URL is required" : undefined),
109
+ validate: (v) => (!v || v.trim().length === 0 ? "A base URL is required" : undefined),
76
110
  }),
77
111
  );
78
112
  } else {
@@ -148,7 +182,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
148
182
  const key = ensure(
149
183
  await p.password({
150
184
  message: `Enter your ${spec.label} API key (${spec.authEnvVar})`,
151
- validate: (v) => (v.trim().length === 0 ? "An API key is required" : undefined),
185
+ validate: (v) => (!v || v.trim().length === 0 ? "An API key is required" : undefined),
152
186
  }),
153
187
  );
154
188
  secret = { key: spec.authEnvVar, value: key.trim() };
@@ -178,7 +212,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
178
212
  ? ensure(
179
213
  await p.text({
180
214
  message: "Model id",
181
- validate: (v) => (v.trim().length === 0 ? "A model id is required" : undefined),
215
+ validate: (v) => (!v || v.trim().length === 0 ? "A model id is required" : undefined),
182
216
  }),
183
217
  ).trim()
184
218
  : choice;
@@ -187,11 +221,36 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
187
221
  await p.text({
188
222
  message: "Model id",
189
223
  placeholder: "provider/model-name",
190
- validate: (v) => (v.trim().length === 0 ? "A model id is required" : undefined),
224
+ validate: (v) => (!v || v.trim().length === 0 ? "A model id is required" : undefined),
191
225
  }),
192
226
  ).trim();
193
227
  }
194
228
 
229
+ // 4b. Model tiering — optionally use a faster/cheaper model for read-only
230
+ // roles (scout, reviewer), keeping the chosen model for the rest.
231
+ let modelsByCapability: Partial<Record<Capability, string>> | undefined;
232
+ if (eligible.length > 1) {
233
+ const wantTiering = ensure(
234
+ await p.confirm({
235
+ message: "Use a faster/cheaper model for read-only roles (scout & reviewer)?",
236
+ initialValue: false,
237
+ }),
238
+ );
239
+ if (wantTiering) {
240
+ const fast = ensure(
241
+ await p.select({
242
+ message: "Fast model for scout & reviewer",
243
+ options: eligible.map((m) => ({
244
+ value: m.id,
245
+ label: m.label,
246
+ hint: `${Math.round(m.contextWindow / 1000)}k context`,
247
+ })),
248
+ }),
249
+ ) as string;
250
+ modelsByCapability = { scout: fast, reviewer: fast };
251
+ }
252
+ }
253
+
195
254
  // 5. Runtime -----------------------------------------------------------
196
255
  const detected = await detectDefaultRuntime();
197
256
  const installed = new Set<string>();
@@ -219,7 +278,50 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
219
278
  }),
220
279
  );
221
280
 
222
- // 6. Summary -----------------------------------------------------------
281
+ // 6. Orchestration & merge --------------------------------------------
282
+ const canonicalBranch = currentConfig.project.canonicalBranch;
283
+ const suggestedGates = detectQualityGates(currentConfig.project.root || ".");
284
+ const chosenGateNames = ensure(
285
+ await p.multiselect({
286
+ message:
287
+ "Quality gates to run on a worker's output (used by skill distillation and 'on gates pass' auto-merge)",
288
+ options: suggestedGates.map((g) => ({ value: g.name, label: g.name, hint: g.command })),
289
+ initialValues: suggestedGates.map((g) => g.name),
290
+ required: false,
291
+ }),
292
+ ) as string[];
293
+ const qualityGates = suggestedGates.filter((g) => chosenGateNames.includes(g.name));
294
+
295
+ const autoMerge = ensure(
296
+ await p.select({
297
+ message: `Auto-merge a worker's branch into ${canonicalBranch} when it finishes?`,
298
+ initialValue: "off",
299
+ options: [
300
+ { value: "off", label: "Never — merge manually", hint: "default; safest" },
301
+ {
302
+ value: "on-gates-pass",
303
+ label: "On quality gates pass",
304
+ hint: qualityGates.length
305
+ ? "merge only when gates are green"
306
+ : "needs gates — will hold otherwise",
307
+ },
308
+ {
309
+ value: "on-complete",
310
+ label: "On complete",
311
+ hint: "merge as soon as the agent finishes (no gate check)",
312
+ },
313
+ ],
314
+ }),
315
+ ) as AutoMergeMode;
316
+
317
+ if (autoMerge === "on-gates-pass" && qualityGates.length === 0) {
318
+ p.note(
319
+ "No quality gates selected, so 'on gates pass' will hold every merge for manual review.\nAdd gates or pick a different mode to actually auto-merge.",
320
+ "Heads up",
321
+ );
322
+ }
323
+
324
+ // 7. Summary -----------------------------------------------------------
223
325
  const previewProvider = buildProviderConfig(spec, model, authMode, baseUrl);
224
326
  const authSummary: Record<AuthMode, string> = {
225
327
  subscription: `subscription / ${runtimeCli(spec.subscriptionRuntime ?? runtime)} login (no key stored)`,
@@ -234,6 +336,11 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
234
336
  `runtime: ${runtime}`,
235
337
  `auth: ${authSummary[authMode]}`,
236
338
  previewProvider.baseUrl ? `base URL: ${previewProvider.baseUrl}` : undefined,
339
+ `gates: ${qualityGates.length ? qualityGates.map((g) => g.name).join(", ") : "none"}`,
340
+ `auto-merge:${autoMerge}`,
341
+ modelsByCapability?.scout
342
+ ? `fast model: ${modelsByCapability.scout} (scout, reviewer)`
343
+ : undefined,
237
344
  ]
238
345
  .filter(Boolean)
239
346
  .join("\n"),
@@ -248,6 +355,12 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
248
355
  baseUrl,
249
356
  runtime,
250
357
  });
358
+ config.merge = { ...config.merge, autoMerge };
359
+ if (qualityGates.length) config.project = { ...config.project, qualityGates };
360
+ if (modelsByCapability) {
361
+ const pc = config.providers[providerId];
362
+ if (pc) config.providers[providerId] = { ...pc, models: modelsByCapability };
363
+ }
251
364
 
252
365
  p.outro("Ready to write configuration.");
253
366
  return secret ? { config, secret } : { config };
@@ -0,0 +1 @@
1
+ :root{--bg:#08090f;--bg-rail:#0b0c14;--bg-topbar:#0c0e16;--bg-card:#14161f;--bg-card-2:#1b1e2a;--bg-input:#1d2030;--border:#262a38;--border-soft:#1c1f2b;--text:#eef0f6;--text-dim:#a3a9bd;--text-faint:#6b7188;--accent:#fb4b38;--accent-2:#ff6a4d;--accent-soft:#fb4b3824;--accent-border:#fb4b3873;--grad-accent:linear-gradient(135deg, #ff6a4d 0%, #fb4b38 55%, #e0245e 100%);--grad-brand:linear-gradient(135deg, #ff7a3d, #fb4b38);--ok:#2dd4a7;--ok-soft:#2dd4a724;--grad-ok:linear-gradient(135deg, #34e7b4, #10b981);--warn:#fbbf24;--warn-soft:#fbbf2424;--grad-warn:linear-gradient(135deg, #fcd34d, #f59e0b);--err:#fb4b38;--err-soft:#fb4b3824;--info:#4f8cff;--info-soft:#4f8cff24;--grad-info:linear-gradient(135deg, #6aa8ff, #3b6cf6);--violet:#a78bfa;--violet-soft:#a78bfa24;--grad-violet:linear-gradient(135deg, #c4b5fd, #8b5cf6);--cyan:#22d3ee;--cyan-soft:#22d3ee24;--grad-cyan:linear-gradient(135deg, #67e8f9, #06b6d4);--rail-w:80px;--topbar-h:54px;--statusbar-h:30px;--radius:16px;--radius-sm:10px;--shadow-card:0 1px 0 #ffffff08 inset, 0 8px 24px #00000047;--glow-accent:0 6px 20px #fb4b3859}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;background-image:radial-gradient(900px 500px at 100% -5%,#fb4b381a,#0000 60%),radial-gradient(800px 500px at -5% 0,#7c5cf61a,#0000 55%),radial-gradient(700px 600px at 50% 110%,#22d3ee0f,#0000 60%);background-attachment:fixed;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Inter,Roboto,Helvetica,Arial,sans-serif;font-size:14px}.os{grid-template-columns:var(--rail-w) 1fr;grid-template-rows:var(--topbar-h) 1fr var(--statusbar-h);grid-template-areas:"rail topbar""rail main""rail status";height:100vh;display:grid;overflow:hidden}.rail{background:var(--bg-rail);border-right:1px solid var(--border);flex-direction:column;grid-area:rail;align-items:center;gap:4px;padding:10px 0;display:flex;overflow-y:auto}.rail::-webkit-scrollbar{width:0}.rail-logo{color:#fff;background:var(--grad-brand);width:42px;height:42px;box-shadow:var(--glow-accent);border-radius:13px;place-items:center;margin-bottom:12px;display:grid}.rail-item{width:62px;color:var(--text-faint);cursor:pointer;font:inherit;background:0 0;border:none;border-radius:14px;flex-direction:column;align-items:center;gap:5px;padding:9px 0 7px;transition:color .15s,background .15s,transform .1s;display:flex;position:relative}.rail-item:hover{color:var(--text);background:var(--bg-card)}.rail-item:active{transform:scale(.95)}.rail-item.active{color:#fff;background:var(--accent-soft)}.rail-item.active:before{content:"";background:var(--grad-accent);border-radius:0 4px 4px 0;width:4px;height:22px;position:absolute;top:50%;left:-10px;transform:translateY(-50%)}.rail-item.active .ri-icon{color:var(--accent-2);filter:drop-shadow(0 0 8px #fb4b388c)}.rail-item .ri-icon{place-items:center;line-height:1;display:grid}.rail-item .ri-label{letter-spacing:.01em;font-size:10px;font-weight:600}.topbar{background:var(--bg-topbar);border-bottom:1px solid var(--border);grid-area:topbar;align-items:center;gap:14px;padding:0 16px;display:flex}.topbar-brand{align-items:center;gap:9px;font-size:15px;font-weight:700;display:flex}.topbar-brand .brand-glyph{color:var(--accent-2);place-items:center;display:grid}.ver-pill{color:#fff;background:var(--grad-accent);letter-spacing:.04em;border-radius:7px;padding:2px 7px;font-size:10px;font-weight:800;box-shadow:0 2px 8px #fb4b3866}.topbar-spacer{flex:1}.topbar-search{background:var(--bg-input);border:1px solid var(--border);width:min(420px,38vw);color:var(--text-faint);font:inherit;text-align:left;cursor:pointer;border-radius:11px;align-items:center;gap:9px;padding:8px 12px;font-size:13px;transition:border-color .15s,box-shadow .15s;display:flex}.topbar-search span{flex:1}.topbar-search:hover{border-color:var(--accent-border);box-shadow:0 0 0 3px var(--accent-soft)}.topbar-search kbd{background:var(--bg-card-2);border:1px solid var(--border);color:var(--text-dim);border-radius:5px;margin-left:auto;padding:1px 5px;font-size:11px}.topbar-icon{width:34px;height:34px;color:var(--text-dim);cursor:pointer;background:0 0;border:none;border-radius:9px;place-items:center;font-size:16px;display:grid;position:relative}.topbar-icon:hover{background:var(--bg-card);color:var(--text)}.topbar-badge{background:var(--accent);color:#fff;text-align:center;min-width:15px;height:15px;box-shadow:0 0 0 2px var(--bg-topbar,#0d0e11);border-radius:8px;padding:0 4px;font-size:9.5px;font-weight:800;line-height:15px;position:absolute;top:1px;right:1px}.notif-wrap{display:inline-flex;position:relative}.notif-panel{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);z-index:60;width:360px;max-height:460px;position:absolute;top:calc(100% + 8px);right:0;overflow-y:auto;box-shadow:0 16px 40px #00000080}.notif-head{border-bottom:1px solid var(--border-soft);background:var(--bg-card);justify-content:space-between;align-items:center;padding:12px 14px;display:flex;position:sticky;top:0}.notif-title{font-size:13.5px;font-weight:800}.notif-count{color:var(--text-faint);font-size:12px}.notif-empty{text-align:center;color:var(--text-faint);padding:28px 16px;font-size:13px}.notif-list{margin:0;padding:0;list-style:none}.notif-item{border-bottom:1px solid var(--border-soft);align-items:flex-start;gap:10px;padding:11px 14px;display:flex}.notif-item.clickable{cursor:pointer}.notif-item.clickable:hover{background:var(--bg-card-2)}.notif-item.fresh{background:#f5402d0f}.notif-dot{border-radius:50%;flex:none;width:9px;height:9px;margin-top:4px}.notif-body{flex:1;min-width:0}.notif-row1{align-items:baseline;gap:8px;display:flex}.notif-agent{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700;overflow:hidden}.notif-label{color:var(--text-faint);letter-spacing:.02em;font-size:10.5px}.notif-ago{color:var(--text-faint);white-space:nowrap;margin-left:auto;font-size:11px}.notif-summary{color:var(--text-dim);word-break:break-word;margin-top:2px;font-size:12.5px}.topbar-user{border-radius:18px;align-items:center;gap:8px;padding:4px 8px 4px 4px;display:flex}.avatar{background:var(--grad-accent);color:#fff;width:30px;height:30px;box-shadow:var(--glow-accent);border-radius:50%;place-items:center;font-size:13px;font-weight:800;display:grid}.main{grid-area:main;padding:26px 30px 40px;overflow-y:auto}.statusbar{background:var(--bg-topbar);border-top:1px solid var(--border);color:var(--text-dim);grid-area:status;align-items:center;gap:20px;padding:0 16px;font-size:11.5px;display:flex}.sb-item{align-items:center;gap:6px;display:flex}.sb-key{color:var(--text-faint);letter-spacing:.02em;font-weight:600}.sb-val{color:var(--text);font-weight:600}.sb-bar{background:var(--bg-card-2);border-radius:3px;width:46px;height:5px;overflow:hidden}.sb-bar>span{border-radius:3px;height:100%;display:block}.sb-spacer{flex:1}.sb-dot{border-radius:50%;width:7px;height:7px;display:inline-block}.page-head{margin-bottom:22px}.page-title{letter-spacing:-.02em;align-items:center;gap:12px;margin:0;font-size:30px;font-weight:800;display:flex}.page-sub{color:var(--text-dim);margin:6px 0 0;font-size:14px}.head-row{justify-content:space-between;align-items:flex-start;gap:16px;display:flex}.stat-grid{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:22px;display:grid}.stat{background:var(--bg-card);border:1px solid var(--border-soft);border-radius:var(--radius);min-height:150px;box-shadow:var(--shadow-card);flex-direction:column;padding:18px 20px;transition:transform .15s,border-color .15s;display:flex;position:relative;overflow:hidden}.stat:after{content:"";background:var(--grad-accent);opacity:.7;height:2px;position:absolute;inset:0 0 auto}.stat:hover{border-color:var(--border);transform:translateY(-2px)}.stat-top{justify-content:space-between;align-items:center;display:flex}.stat-label{color:var(--text-dim);font-size:14px;font-weight:500}.stat-icon{color:#fff;background:var(--grad-accent);border-radius:11px;place-items:center;width:38px;height:38px;display:grid;box-shadow:0 4px 12px #00000040}.stat-icon.ok{background:var(--grad-ok)}.stat-icon.warn{background:var(--grad-warn)}.stat-icon.info{background:var(--grad-info)}.stat-icon.violet{background:var(--grad-violet)}.stat-icon.cyan{background:var(--grad-cyan)}.stat-value{letter-spacing:-.02em;margin-top:auto;font-size:42px;font-weight:800;line-height:1}.stat-value.sm{font-size:30px}.stat-value.accent{color:var(--accent-2)}.stat-value.ok{color:var(--ok)}.stat-sub{color:var(--text-faint);margin-top:6px;font-size:12px}.title-icon{color:#fff;background:var(--grad-accent);width:40px;height:40px;box-shadow:var(--glow-accent);border-radius:13px;flex:none;place-items:center;display:grid}.title-icon.ok{background:var(--grad-ok);box-shadow:0 6px 18px #10b98152}.title-icon.warn{background:var(--grad-warn);box-shadow:0 6px 18px #f59e0b52}.title-icon.info{background:var(--grad-info);box-shadow:0 6px 18px #3b6cf652}.title-icon.violet{background:var(--grad-violet);box-shadow:0 6px 18px #8b5cf652}.title-icon.cyan{background:var(--grad-cyan);box-shadow:0 6px 18px #06b6d452}.card{background:var(--bg-card);border:1px solid var(--border-soft);border-radius:var(--radius);box-shadow:var(--shadow-card);padding:20px}.card-head{justify-content:space-between;align-items:center;margin-bottom:16px;display:flex}.card-title{align-items:center;gap:9px;font-size:16px;font-weight:700;display:flex}.card-title svg{color:var(--accent-2)}.card-meta{color:var(--text-faint);font-size:12px}.btn{border:1px solid var(--border);background:var(--bg-card-2);color:var(--text);font:inherit;cursor:pointer;border-radius:9px;align-items:center;gap:7px;padding:8px 14px;font-size:13px;font-weight:600;display:inline-flex}.btn:hover:not(:disabled){border-color:var(--accent-border)}.btn.primary{background:var(--grad-accent);color:#fff;box-shadow:var(--glow-accent);border-color:#0000}.btn.primary:hover:not(:disabled){filter:brightness(1.08);border-color:#0000}.btn:disabled{opacity:.5;cursor:not-allowed}.seg{background:var(--bg-card-2);border:1px solid var(--border);border-radius:10px;gap:2px;padding:3px;display:inline-flex}.seg button{color:var(--text-dim);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:7px;padding:6px 14px;font-size:13px;font-weight:600}.seg button.active{background:var(--grad-accent);color:#fff;box-shadow:0 2px 10px #fb4b3859}.badge{border:1px solid #0000;border-radius:7px;align-items:center;gap:5px;padding:3px 9px;font-size:11.5px;font-weight:600;display:inline-flex}.badge .bdot{background:currentColor;border-radius:50%;width:6px;height:6px}.badge.ok{color:var(--ok);background:var(--ok-soft);border-color:#34d3994d}.badge.warn{color:var(--warn);background:var(--warn-soft);border-color:#fbbf244d}.badge.err{color:var(--err);background:var(--err-soft);border-color:var(--accent-border)}.badge.info{color:var(--info);background:var(--info-soft);border-color:#60a5fa4d}.badge.accent{color:var(--accent-2);background:var(--accent-soft);border-color:var(--accent-border)}.badge.neutral{color:var(--text-dim);background:var(--bg-card-2);border-color:var(--border)}.badge.violet{color:var(--violet);background:#a78bfa1f;border-color:#a78bfa4d}.bar{background:var(--bg-card-2);border-radius:4px;height:7px;overflow:hidden}.bar>span{background:var(--grad-accent);border-radius:4px;height:100%;display:block}.grid-wrap{overflow-x:auto}table.grid{border-collapse:collapse;width:100%;font-size:13px}table.grid th{text-align:left;color:var(--text-faint);letter-spacing:.05em;text-transform:uppercase;border-bottom:1px solid var(--border);padding:8px 12px;font-size:11px;font-weight:600}table.grid td{border-bottom:1px solid var(--border-soft);color:var(--text);padding:11px 12px}table.grid tr:last-child td{border-bottom:none}.row-click{cursor:pointer}.row-click:hover{background:var(--bg-card-2)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.dim{color:var(--text-dim)}.faint{color:var(--text-faint)}.nowrap{white-space:nowrap}.empty{color:var(--text-faint);text-align:center;padding:24px}.row-2col{grid-template-columns:1fr 1fr;gap:16px;display:grid}@media (width<=1000px){.row-2col{grid-template-columns:1fr}}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--border);border-radius:6px}::-webkit-scrollbar-track{background:0 0}.cmdk-backdrop{-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:80;background:#0405098c;justify-content:center;align-items:flex-start;padding-top:12vh;display:flex;position:fixed;inset:0}.cmdk{background:var(--bg-card);border:1px solid var(--border);border-radius:16px;flex-direction:column;width:min(560px,92vw);display:flex;overflow:hidden;box-shadow:0 24px 70px #0000008c}.cmdk-input{border:none;border-bottom:1px solid var(--border-soft);width:100%;color:var(--text);font:inherit;background:0 0;outline:none;padding:16px 18px;font-size:16px}.cmdk-input::placeholder{color:var(--text-faint)}.cmdk-list{max-height:50vh;padding:6px;overflow-y:auto}.cmdk-section{letter-spacing:.08em;text-transform:uppercase;color:var(--text-faint);padding:10px 10px 4px;font-size:10.5px;font-weight:700}.cmdk-item{width:100%;color:var(--text);font:inherit;cursor:pointer;text-align:left;background:0 0;border:none;border-radius:10px;align-items:center;gap:11px;padding:9px 11px;font-size:14px;display:flex}.cmdk-item.active{background:var(--accent-soft)}.cmdk-icon{color:var(--text-dim);flex:none;place-items:center;display:grid}.cmdk-item.active .cmdk-icon{color:var(--accent-2)}.cmdk-label{white-space:nowrap;text-overflow:ellipsis;flex:1;font-weight:600;overflow:hidden}.cmdk-kind{color:var(--text-faint);border:1px solid var(--border);border-radius:6px;padding:1px 6px;font-size:11px}.cmdk-empty{text-align:center;color:var(--text-faint);padding:26px}.cmdk-foot{border-top:1px solid var(--border-soft);color:var(--text-faint);gap:16px;padding:9px 14px;font-size:11.5px;display:flex}.cmdk-foot kbd{background:var(--bg-card-2);border:1px solid var(--border);border-radius:5px;margin-right:3px;padding:1px 5px;font-size:10.5px}