Skip to content

Commit 4d45067

Browse files
committed
Add Migrator director for reversible data migrations
1 parent bf79cc0 commit 4d45067

9 files changed

Lines changed: 175 additions & 10 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { migratorPackage } from "./package.js";
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { migratorPackage } from "./package.js";
3+
4+
describe("migratorPackage", () => {
5+
test("id matches directory / registry id", () => {
6+
expect(migratorPackage.id).toBe("migrator");
7+
});
8+
9+
test("systemPrompt is non-empty and not a Placeholder", () => {
10+
expect(migratorPackage.systemPrompt.length).toBeGreaterThan(0);
11+
expect(migratorPackage.systemPrompt.startsWith("Placeholder")).toBe(false);
12+
});
13+
14+
test("systemPrompt identity is the reversible-migration leaf", () => {
15+
const p = migratorPackage.systemPrompt;
16+
expect(p).toContain("You are Migrator");
17+
expect(p).toMatch(/reversible-migration leaf/);
18+
expect(p).toContain("PRIMARY INTENT");
19+
});
20+
21+
test("systemPrompt owns only settings/config/session-state data changes", () => {
22+
const p = migratorPackage.systemPrompt;
23+
expect(p).toMatch(/settings-schema/);
24+
expect(p).toMatch(/config-key/);
25+
expect(p).toMatch(/run\.json/);
26+
expect(p).toMatch(/context-store-layout/);
27+
expect(p).toMatch(/never bulk renames, never features/);
28+
});
29+
30+
test("systemPrompt requires the three migration artifacts", () => {
31+
const p = migratorPackage.systemPrompt;
32+
expect(p).toMatch(/dry-run output/);
33+
expect(p).toMatch(/forward migration path/);
34+
expect(p).toMatch(/rollback path/);
35+
expect(p).toMatch(/in-flight sessions/);
36+
});
37+
38+
test("systemPrompt verifies rollback by execution and stops when irreversible", () => {
39+
const p = migratorPackage.systemPrompt;
40+
expect(p).toMatch(/scratch copy/);
41+
expect(p).toMatch(/not by inspection/);
42+
expect(p).toMatch(/say so plainly and stop/);
43+
expect(p).toMatch(/do not ship it/);
44+
});
45+
46+
test("systemPrompt states the report shape", () => {
47+
const p = migratorPackage.systemPrompt;
48+
expect(p).toMatch(
49+
/Report: dry-run output, forward path, rollback path, in-flight impact/,
50+
);
51+
});
52+
53+
test("tools.allow is exactly read_file/grep/lsp/run_shell in order", () => {
54+
expect(migratorPackage.tools?.allow).toEqual([
55+
"read_file",
56+
"grep",
57+
"lsp",
58+
"run_shell",
59+
]);
60+
});
61+
62+
test("tools.allow carries no fleet verbs and no path writes", () => {
63+
const allow = migratorPackage.tools?.allow ?? [];
64+
for (const verb of [
65+
"spawn_agent",
66+
"send_input",
67+
"list_agents",
68+
"search_agents",
69+
"wait_agents",
70+
] as const) {
71+
expect(allow).not.toContain(verb);
72+
}
73+
for (const tool of ["write_file", "edit_file", "delete_file"] as const) {
74+
expect(allow).not.toContain(tool);
75+
}
76+
});
77+
78+
test("spawn.maySpawn is false with no allowlist (leaf)", () => {
79+
expect(migratorPackage.spawn.maySpawn).toBe(false);
80+
expect(migratorPackage.spawn.allowlist).toBeUndefined();
81+
expect(migratorPackage.tier).toBe("leaf");
82+
});
83+
84+
test("modelRole is plan", () => {
85+
expect(migratorPackage.modelRole).toBe("plan");
86+
});
87+
88+
test("primaryIntent ships reversible migrations with evidence and rollback", () => {
89+
expect(migratorPackage.primaryIntent).toBe(
90+
"Ship reversible data migrations with dry-run evidence and a tested rollback path",
91+
);
92+
});
93+
94+
test("outOfLane refuses renames, features, irreversible breaks, orchestration", () => {
95+
expect(migratorPackage.outOfLane).toEqual([
96+
"bulk code renames (ast-grep / refactor skill territory)",
97+
"product features",
98+
"API renames",
99+
"irreversible schema breaks without a rollback path",
100+
"orchestration or spawning workers",
101+
]);
102+
});
103+
104+
test("description names the reversible-migration lane", () => {
105+
expect(migratorPackage.description).toBe(
106+
"Reversible settings, config, and session-state migrations — forward path, rollback path, dry-run evidence",
107+
);
108+
});
109+
110+
test("optionalSkills is empty", () => {
111+
expect(migratorPackage.optionalSkills).toEqual([]);
112+
});
113+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { DirectorPackage } from "../types.js";
2+
3+
/**
4+
* Migrator worker (CL-7671).
5+
* Reversible settings/config/session-state data migrations only — forward
6+
* path + rollback path + dry-run evidence + in-flight session impact.
7+
* Never bulk renames, never features.
8+
*/
9+
export const migratorPackage: DirectorPackage = {
10+
id: "migrator",
11+
primaryIntent:
12+
"Ship reversible data migrations with dry-run evidence and a tested rollback path",
13+
outOfLane: [
14+
"bulk code renames (ast-grep / refactor skill territory)",
15+
"product features",
16+
"API renames",
17+
"irreversible schema breaks without a rollback path",
18+
"orchestration or spawning workers",
19+
],
20+
description:
21+
"Reversible settings, config, and session-state migrations — forward path, rollback path, dry-run evidence",
22+
optionalSkills: [],
23+
tools: { allow: ["read_file", "grep", "lsp", "run_shell"] },
24+
spawn: { maySpawn: false },
25+
tier: "leaf",
26+
modelRole: "plan",
27+
systemPrompt: `PRIMARY INTENT: Ship reversible data migrations with dry-run evidence and a tested rollback path.
28+
29+
You are Migrator, the reversible-migration leaf. You own settings-schema, config-key, run.json, and context-store-layout data changes ONLY — never bulk renames, never features. Every change ships three artifacts: (1) dry-run output showing exactly what would change, (2) the forward migration path, (3) the rollback path back to the prior shape. State what happens to in-flight sessions on both paths. Verify the rollback by executing it in a scratch copy (temporary test, cleaned up afterwards), not by inspection. If a change cannot be rolled back, say so plainly and stop — do not ship it. Report: dry-run output, forward path, rollback path, in-flight impact.`,
30+
};

‎src/agent/directors/registry.test.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import {
1313
} from "./registry.js";
1414

1515
describe("director registry", () => {
16-
test("closed set has exactly 16 directors", () => {
17-
expect(DIRECTOR_IDS).toHaveLength(16);
18-
expect(listDirectors()).toHaveLength(16);
16+
test("closed set has exactly 17 directors", () => {
17+
expect(DIRECTOR_IDS).toHaveLength(17);
18+
expect(listDirectors()).toHaveLength(17);
1919
for (const id of DIRECTOR_IDS) {
2020
expect(DIRECTOR_REGISTRY[id].id).toBe(id);
2121
}
@@ -116,8 +116,8 @@ describe("director registry", () => {
116116

117117
test("directorProfiles is the spawn catalog (closed set minus skywalker)", () => {
118118
const profiles = directorProfiles();
119-
expect(profiles).toHaveLength(15);
120-
expect(new Set(profiles.map((p) => p.id)).size).toBe(15);
119+
expect(profiles).toHaveLength(16);
120+
expect(new Set(profiles.map((p) => p.id)).size).toBe(16);
121121
expect(profiles.map((p) => p.id)).not.toContain("skywalker");
122122
});
123123

@@ -133,6 +133,19 @@ describe("director registry", () => {
133133
expect(packageToProfile(g).orchestrator).toBe(true);
134134
});
135135

136+
test("migrator is a read-only leaf with no fleet verbs (CL-7671)", () => {
137+
const m = DIRECTOR_REGISTRY.migrator;
138+
expect(m.id).toBe("migrator");
139+
expect(m.tier).toBe("leaf");
140+
expect(m.spawn.maySpawn).toBe(false);
141+
expect(m.modelRole).toBe("plan");
142+
expect(m.tools?.allow).toEqual(["read_file", "grep", "lsp", "run_shell"]);
143+
expect(packageToProfile(m).orchestrator).toBe(false);
144+
const r = resolveDirector({ agentId: "migrator" });
145+
expect(r.ok).toBe(true);
146+
if (r.ok) expect(r.package.id).toBe("migrator");
147+
});
148+
136149
test("closed directors mount product write tools", () => {
137150
for (const id of [
138151
"critic",
@@ -194,11 +207,11 @@ describe("director registry", () => {
194207
expect(s.tools?.allow).toContain("write_file");
195208
expect(s.tools?.allow).toContain("edit_file");
196209
expect(s.tools?.allow).toContain("delete_file");
197-
expect(s.spawn.allowlist).toHaveLength(15);
210+
expect(s.spawn.allowlist).toHaveLength(16);
198211
});
199212

200213
// CL-6941: tier and spawn.maySpawn independently encode "may this package
201-
// spawn", hand-set across 16 files. This pins their agreement so drift
214+
// spawn", hand-set across 17 files. This pins their agreement so drift
202215
// (adding maySpawn: true without bumping tier, or vice versa) fails a test
203216
// instead of surfacing as an unexplained FleetAuthorityError at dispatch.
204217
test("tier agrees with spawn.maySpawn for every director", () => {

‎src/agent/directors/registry.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { gaasbotPackage } from "./gaasbot/index.js";
99
import { greybeardPackage } from "./greybeard/index.js";
1010
import { builderPackage } from "./builder/index.js";
1111
import { internPackage } from "./intern/index.js";
12+
import { migratorPackage } from "./migrator/index.js";
1213
import { neckbeardPackage } from "./neckbeard/index.js";
1314
import { counselPackage } from "./counsel/index.js";
1415
import { shakespearePackage } from "./shakespeare/index.js";
@@ -58,6 +59,7 @@ export const DIRECTOR_REGISTRY: Readonly<Record<DirectorId, DirectorPackage>> =
5859
shakespeare: shakespearePackage,
5960
testsmith: testsmithPackage,
6061
tester: testerPackage,
62+
migrator: migratorPackage,
6163
};
6264

6365
export function isDirectorId(value: unknown): value is DirectorId {

‎src/agent/directors/skywalker/package.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe("skywalkerPackage", () => {
2828

2929
test("maySpawn true with full closed allowlist", () => {
3030
expect(skywalkerPackage.spawn.maySpawn).toBe(true);
31-
expect(skywalkerPackage.spawn.allowlist).toHaveLength(15);
31+
expect(skywalkerPackage.spawn.allowlist).toHaveLength(16);
3232
expect(skywalkerPackage.spawn.allowlist).toEqual([
3333
"builder",
3434
"explorer",
@@ -45,6 +45,7 @@ describe("skywalkerPackage", () => {
4545
"shakespeare",
4646
"testsmith",
4747
"tester",
48+
"migrator",
4849
]);
4950
});
5051

‎src/agent/directors/skywalker/package.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Example chains:
3131
- feature: explorer → plan → implement → critic
3232
- "why / how / is this stalled": answer yourself; at most one explorer if a single unknown blocks you
3333
34-
Closed directors (use search_agents / registry; each id is a spawn agent= target): builder, explorer, counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, rand, shakespeare, testsmith, tester.
34+
Closed directors (use search_agents / registry; each id is a spawn agent= target): builder, explorer, counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, rand, shakespeare, testsmith, tester, migrator.
3535
No catch-all worker. If unsure, reclassify — do not spawn a blob agent.
3636
3737
Quick routing:
@@ -43,6 +43,7 @@ Quick routing:
4343
- neckbeard = hygiene / pedantry with receipts
4444
- tester = run the suite / repro
4545
- testsmith = design permanent test cases
46+
- migrator = reversible settings/config/session-state migrations
4647
- shakespeare = PRODUCT/ARCHITECTURE/IMPLEMENTATION docs
4748
- rand = DESIGN.md only
4849
- draper = visual/CBS review
@@ -144,7 +145,7 @@ Do not reclassify COMMUNICATION as ORCHESTRATION just to justify parallel spawn
144145
# Spawn graph
145146
146147
Skywalker = full closed set. Greybeard = limited spawn only (intern/explorer/critic) — not a second primary.
147-
You may spawn: builder, explorer, counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, rand, shakespeare, testsmith, tester.
148+
You may spawn: builder, explorer, counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot, draper, emil, rand, shakespeare, testsmith, tester, migrator.
148149
149150
When spawning, pass a typed brief. success_criteria is required for implement/review and their default directors; recommended otherwise:
150151
- intent — explore | implement | plan | review
@@ -204,6 +205,7 @@ export const skywalkerPackage: DirectorPackage = {
204205
"shakespeare",
205206
"testsmith",
206207
"tester",
208+
"migrator",
207209
],
208210
},
209211
modelRole: "orchestrator",

‎src/agent/directors/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const DIRECTOR_IDS = [
2020
"shakespeare",
2121
"testsmith",
2222
"tester",
23+
"migrator",
2324
] as const;
2425

2526
export type DirectorId = (typeof DIRECTOR_IDS)[number];

‎src/agent/prompt-sizes.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const CHAR_BUDGET: Record<DirectorId, number> = {
3333
shakespeare: 54700,
3434
testsmith: 16200,
3535
tester: 13900,
36+
migrator: 12300,
3637
};
3738

3839
const BYTE_BUDGET: Record<DirectorId, number> = {
@@ -54,6 +55,7 @@ const BYTE_BUDGET: Record<DirectorId, number> = {
5455
shakespeare: 55900,
5556
testsmith: 17200,
5657
tester: 15000,
58+
migrator: 13400,
5759
};
5860

5961
function budgetMessage(

0 commit comments

Comments
 (0)