@agentplate/cli 1.1.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.
- package/CHANGELOG.md +24 -0
- package/agents/coordinator.md +6 -0
- package/agents/lead.md +3 -1
- package/package.json +1 -1
- package/src/agents/capacity.test.ts +55 -0
- package/src/agents/capacity.ts +50 -0
- package/src/agents/drive.test.ts +155 -0
- package/src/agents/drive.ts +200 -0
- package/src/commands/sling.ts +32 -114
- package/src/commands/turn.test.ts +101 -0
- package/src/commands/turn.ts +113 -0
- package/src/config.test.ts +18 -0
- package/src/config.ts +6 -1
- package/src/errors.ts +11 -0
- package/src/index.ts +2 -0
- package/src/insights/quality-gates.test.ts +43 -0
- package/src/insights/quality-gates.ts +30 -31
- package/src/merge/auto.test.ts +157 -0
- package/src/merge/auto.ts +118 -0
- package/src/runtimes/resolve.test.ts +49 -0
- package/src/runtimes/resolve.ts +11 -7
- package/src/sessions/store.test.ts +13 -0
- package/src/sessions/store.ts +20 -0
- package/src/types.ts +16 -1
- package/src/version.ts +1 -1
- package/src/wizard/setup.test.ts +45 -0
- package/src/wizard/setup.ts +115 -2
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auto-merge (maybeAutoMerge).
|
|
3
|
+
*
|
|
4
|
+
* Real git repos in temp dirs (per "never mock what you can use for real"), the
|
|
5
|
+
* real merge queue + lock, and a recording mail stub so we can assert the outcome
|
|
6
|
+
* notifications without a mail DB. Covers the mode gate, the capability skip, the
|
|
7
|
+
* gates-pass fail-closed rule, a clean landing, and a reported conflict.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { Capability, NewMail } from "../types.ts";
|
|
15
|
+
import { type AutoMergeParams, maybeAutoMerge } from "./auto.ts";
|
|
16
|
+
|
|
17
|
+
let repo: string;
|
|
18
|
+
|
|
19
|
+
async function git(...args: string[]): Promise<string> {
|
|
20
|
+
const proc = Bun.spawn(["git", ...args], { cwd: repo, stdout: "pipe", stderr: "pipe" });
|
|
21
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
22
|
+
new Response(proc.stdout).text(),
|
|
23
|
+
new Response(proc.stderr).text(),
|
|
24
|
+
proc.exited,
|
|
25
|
+
]);
|
|
26
|
+
if (exitCode !== 0) throw new Error(`git ${args.join(" ")} failed (${exitCode}): ${stderr}`);
|
|
27
|
+
return stdout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Recording mail stub satisfying the `{ send }` surface maybeAutoMerge needs. */
|
|
31
|
+
function recordingMail() {
|
|
32
|
+
const sent: Array<{ type: string; subject: string; to: string }> = [];
|
|
33
|
+
return {
|
|
34
|
+
sent,
|
|
35
|
+
send: (m: NewMail) => {
|
|
36
|
+
sent.push({ type: m.type, subject: m.subject, to: m.to });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build params with sensible defaults; override per test. */
|
|
42
|
+
function params(
|
|
43
|
+
over: Partial<AutoMergeParams> & { mail: ReturnType<typeof recordingMail> },
|
|
44
|
+
): AutoMergeParams {
|
|
45
|
+
return {
|
|
46
|
+
root: repo,
|
|
47
|
+
branchName: "agentplate/builder-x",
|
|
48
|
+
targetBranch: "main",
|
|
49
|
+
capability: "builder" as Capability,
|
|
50
|
+
agentName: "builder-x",
|
|
51
|
+
taskId: "task-x",
|
|
52
|
+
parent: "lead-1",
|
|
53
|
+
mode: "on-complete",
|
|
54
|
+
aiResolveEnabled: true,
|
|
55
|
+
gateStatus: null,
|
|
56
|
+
...over,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
repo = mkdtempSync(join(tmpdir(), "agentplate-automerge-"));
|
|
62
|
+
mkdirSync(join(repo, ".agentplate"), { recursive: true });
|
|
63
|
+
await git("init", "-q");
|
|
64
|
+
await git("config", "user.email", "test@agentplate.dev");
|
|
65
|
+
await git("config", "user.name", "Agentplate Test");
|
|
66
|
+
await git("checkout", "-q", "-b", "main");
|
|
67
|
+
await Bun.write(join(repo, "base.txt"), "base\n");
|
|
68
|
+
await git("add", "-A");
|
|
69
|
+
await git("commit", "-q", "-m", "initial");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
rmSync(repo, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Create a worker branch with a new-file commit, then return to main. */
|
|
77
|
+
async function workerBranchWithCommit(branch: string, file: string): Promise<void> {
|
|
78
|
+
await git("checkout", "-q", "-b", branch);
|
|
79
|
+
await Bun.write(join(repo, file), "from worker\n");
|
|
80
|
+
await git("add", "-A");
|
|
81
|
+
await git("commit", "-q", "-m", `add ${file}`);
|
|
82
|
+
await git("checkout", "-q", "main");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("maybeAutoMerge — gating", () => {
|
|
86
|
+
test("mode 'off' never merges", async () => {
|
|
87
|
+
const mail = recordingMail();
|
|
88
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
89
|
+
const out = await maybeAutoMerge(params({ mail, mode: "off" }));
|
|
90
|
+
expect(out).toEqual({ merged: false, reason: "disabled" });
|
|
91
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(false);
|
|
92
|
+
expect(mail.sent).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("read-only capabilities are skipped", async () => {
|
|
96
|
+
const mail = recordingMail();
|
|
97
|
+
for (const capability of ["scout", "merger"] as Capability[]) {
|
|
98
|
+
const out = await maybeAutoMerge(params({ mail, capability }));
|
|
99
|
+
expect(out).toEqual({ merged: false, reason: "capability-skipped" });
|
|
100
|
+
}
|
|
101
|
+
expect(mail.sent).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("on-gates-pass holds (fail-closed) when gates did not pass", async () => {
|
|
105
|
+
const mail = recordingMail();
|
|
106
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
107
|
+
for (const gateStatus of [null, "failure", "partial"] as const) {
|
|
108
|
+
const out = await maybeAutoMerge(params({ mail, mode: "on-gates-pass", gateStatus }));
|
|
109
|
+
expect(out).toEqual({ merged: false, reason: "gates-not-passed" });
|
|
110
|
+
}
|
|
111
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(false);
|
|
112
|
+
expect(mail.sent.every((m) => m.type === "status")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("maybeAutoMerge — landing", () => {
|
|
117
|
+
test("on-complete lands the branch and reports 'merged'", async () => {
|
|
118
|
+
const mail = recordingMail();
|
|
119
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
120
|
+
const out = await maybeAutoMerge(params({ mail, mode: "on-complete" }));
|
|
121
|
+
expect(out).toEqual({ merged: true, status: "merged", tier: "clean-merge" });
|
|
122
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(true); // landed on main
|
|
123
|
+
expect(mail.sent).toHaveLength(1);
|
|
124
|
+
expect(mail.sent[0]).toMatchObject({ type: "merged", to: "lead-1" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("on-gates-pass merges on a clean gate success", async () => {
|
|
128
|
+
const mail = recordingMail();
|
|
129
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
130
|
+
const out = await maybeAutoMerge(
|
|
131
|
+
params({ mail, mode: "on-gates-pass", gateStatus: "success" }),
|
|
132
|
+
);
|
|
133
|
+
expect(out).toMatchObject({ merged: true, status: "merged" });
|
|
134
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("an unresolved conflict reports 'merge_failed' (never throws)", async () => {
|
|
138
|
+
const mail = recordingMail();
|
|
139
|
+
// Both branches edit base.txt differently -> conflict; aiResolveEnabled=false
|
|
140
|
+
// makes mergeBranch abort and fail.
|
|
141
|
+
await git("checkout", "-q", "-b", "agentplate/builder-x");
|
|
142
|
+
await Bun.write(join(repo, "base.txt"), "worker change\n");
|
|
143
|
+
await git("add", "-A");
|
|
144
|
+
await git("commit", "-q", "-m", "worker edits base");
|
|
145
|
+
await git("checkout", "-q", "main");
|
|
146
|
+
await Bun.write(join(repo, "base.txt"), "main change\n");
|
|
147
|
+
await git("add", "-A");
|
|
148
|
+
await git("commit", "-q", "-m", "main edits base");
|
|
149
|
+
|
|
150
|
+
const out = await maybeAutoMerge(
|
|
151
|
+
params({ mail, mode: "on-complete", aiResolveEnabled: false }),
|
|
152
|
+
);
|
|
153
|
+
expect(out.merged).toBe(false);
|
|
154
|
+
expect(out).toMatchObject({ reason: "merge-failed" });
|
|
155
|
+
expect(mail.sent[0]).toMatchObject({ type: "merge_failed" });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|
package/src/runtimes/resolve.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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", () => {
|
package/src/sessions/store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
@@ -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
|
+
});
|
package/src/wizard/setup.ts
CHANGED
|
@@ -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 {
|
|
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)) {
|
|
@@ -192,6 +226,31 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
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.
|
|
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 };
|