Skip to content

Commit f3659d6

Browse files
Add Migrator director for reversible data migrations (#963)
* Add Migrator director for reversible data migrations * Scope Migrator shell to dry-run and scratch-copy execution run_shell is dry-run/scratch-copy ONLY: never execute the forward migration against live state, no background shells (shell_collect unmounted; foreground with timeouts only). Scratch copies live under tmp/, are cleaned up afterwards, and the scratch path is reported in the delivery. Rename the registry leaf test to dry-run-scoped and raise the migrator prompt budgets to 12800 chars / 13900 bytes (measured-max + 2000/+3000, ceiling 100). * Measure migrator budgets off the final scoped prompt * Use MigratorDirector identity in migrator system prompt * Add Migrator eng-lane docs rows; fleet counts 18 → 19
1 parent 5c7acf0 commit f3659d6

12 files changed

Lines changed: 215 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Corbits Code keeps repository guidance and the closed director fleet separate:
198198
- `src/agent/directors/` — closed spawn catalog (`directorProfiles()`). Skywalker
199199
is the primary orchestrator; spawnable directors include builder, explorer,
200200
counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot, draper,
201-
emil, rand, shakespeare, testsmith, tester, gauntlet, and prober. Closed ids cannot be
201+
emil, rand, shakespeare, testsmith, tester, gauntlet, prober, and migrator. Closed ids cannot be
202202
overridden by plugins or local files.
203203
- `.agents/agents/` — optional local profile additions; this directory is not
204204
required and may be absent

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ Enforcement is runtime code at the existing tool-mount point, not prompt wording
244244

245245
#### Closed director fleet (`src/agent/directors/`)
246246

247-
Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`, fleet authority `tier`) registered in a **closed** set of 18 ids. There is no catch-all worker: `spawn_agent` without `agent` or non-general `intent`, and `spawn_agent(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `spawn_agent` dispatch time (not prompt-only). Skywalker is the primary session identity: `spawn_agent(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog.
247+
Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`, fleet authority `tier`) registered in a **closed** set of 19 ids. There is no catch-all worker: `spawn_agent` without `agent` or non-general `intent`, and `spawn_agent(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `spawn_agent` dispatch time (not prompt-only). Skywalker is the primary session identity: `spawn_agent(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog.
248248

249249
**Primary**
250250

@@ -265,6 +265,7 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP
265265
| neckbeard | Adversarial hygiene / refactor stress | Real review substitute |
266266
| bruckheimer | Product discovery → PRODUCT/ARCHITECTURE/IMPLEMENTATION-oriented briefs | Eng plan, code |
267267
| gaasbot | Quick CTO opinion voice | Formal review gate, implement |
268+
| migrator | Reversible settings/config/session-state migrations | Live-state execution, new features |
268269

269270
**Design trio (dev perspective)**
270271

‎docs/PRODUCT.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,14 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t
155155

156156
## Multi-agent (fleet agents)
157157

158-
The primary session is always **orchestrator** (single-agent mode is gone). Its identity is **Skywalker** (product name remains Corbits Code; when asked its name, answer Skywalker): classify work, DIY tiny/single-file/one-route product edits, dispatch a **closed fleet of 18 directors** for substantial work, track the fleet, and synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are mounted on the primary (CORE / `SKYWALKER_TOOLS`) — path tools are the DIY surface; spawn remains the default for substantial, multi-file, parallel, or specialist work. Shell file-writes stay denied. MCP tools are not re-filtered by a product-write deny list (that list is gone). There is no static per-package write-path declaration (CL-6952 removed it — no shipped director ever set one). A concurrent dispatch landing on the same working directory as another still-running lane is recorded as a `conflict` intervention, not blocked. Operator slash recipes (`/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`) tell Skywalker which directors to spawn for substantial work; tiny/bounded edits may run on the primary.
159-
160-
| Lane | Directors |
161-
| --------- | -------------------------------------------------------------------------------------- |
162-
| Primary | skywalker |
163-
| Eng | builder, explorer, counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot |
164-
| Design | draper, emil, rand |
165-
| Docs / QA | shakespeare, testsmith, tester, gauntlet, prober |
158+
The primary session is always **orchestrator** (single-agent mode is gone). Its identity is **Skywalker** (product name remains Corbits Code; when asked its name, answer Skywalker): classify work, DIY tiny/single-file/one-route product edits, dispatch a **closed fleet of 19 directors** for substantial work, track the fleet, and synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are mounted on the primary (CORE / `SKYWALKER_TOOLS`) — path tools are the DIY surface; spawn remains the default for substantial, multi-file, parallel, or specialist work. Shell file-writes stay denied. MCP tools are not re-filtered by a product-write deny list (that list is gone). There is no static per-package write-path declaration (CL-6952 removed it — no shipped director ever set one). A concurrent dispatch landing on the same working directory as another still-running lane is recorded as a `conflict` intervention, not blocked. Operator slash recipes (`/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`) tell Skywalker which directors to spawn for substantial work; tiny/bounded edits may run on the primary.
159+
160+
| Lane | Directors |
161+
| --------- | ------------------------------------------------------------------------------------------------ |
162+
| Primary | skywalker |
163+
| Eng | builder, explorer, counsel, intern, critic, greybeard, neckbeard, bruckheimer, gaasbot, migrator |
164+
| Design | draper, emil, rand |
165+
| Docs / QA | shakespeare, testsmith, tester, gauntlet, prober |
166166

167167
There is **no catch-all worker**. `spawn_agent` requires `agent=…` or a non-general `intent` (implement/explore/plan/review→critic); bare dispatch and `intent=general` are refused. Named `spawn_agent(agent=…)` selects a director package without requiring a plugin profile, except `skywalker` which is the primary session identity and is refused as a spawned worker. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explorer/critic) may spawn; other workers have no fleet tools. Primary omits an allowlist so plugin profiles remain reachable from the main session.
168168

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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 MigratorDirector (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 scopes run_shell to dry-run/scratch-only, forbids live execution", () => {
47+
const p = migratorPackage.systemPrompt;
48+
expect(p).toMatch(/dry-run and scratch-copy execution ONLY/);
49+
expect(p).toMatch(/never execute the forward migration/);
50+
expect(p).toMatch(/live state/);
51+
});
52+
53+
test("systemPrompt forbids background shells — foreground with timeouts only", () => {
54+
const p = migratorPackage.systemPrompt;
55+
expect(p).toMatch(/Background shells are forbidden/);
56+
expect(p).toMatch(/background: true/);
57+
expect(p).toMatch(/shell_collect/);
58+
expect(p).toMatch(/foreground/);
59+
expect(p).toMatch(/timeouts only/);
60+
});
61+
62+
test("systemPrompt makes scratch auditable under tmp/ with reported path", () => {
63+
const p = migratorPackage.systemPrompt;
64+
expect(p).toMatch(/tmp\//);
65+
expect(p).toMatch(/clean them up afterwards/);
66+
expect(p).toMatch(/scratch path in the delivery/);
67+
});
68+
69+
test("systemPrompt states the report shape", () => {
70+
const p = migratorPackage.systemPrompt;
71+
expect(p).toMatch(
72+
/Report: dry-run output, forward path, rollback path, in-flight impact/,
73+
);
74+
});
75+
76+
test("tools.allow is exactly read_file/grep/lsp/run_shell in order", () => {
77+
expect(migratorPackage.tools?.allow).toEqual([
78+
"read_file",
79+
"grep",
80+
"lsp",
81+
"run_shell",
82+
]);
83+
});
84+
85+
test("tools.allow carries no fleet verbs and no path writes", () => {
86+
const allow = migratorPackage.tools?.allow ?? [];
87+
for (const verb of [
88+
"spawn_agent",
89+
"send_input",
90+
"list_agents",
91+
"search_agents",
92+
"wait_agents",
93+
] as const) {
94+
expect(allow).not.toContain(verb);
95+
}
96+
for (const tool of ["write_file", "edit_file", "delete_file"] as const) {
97+
expect(allow).not.toContain(tool);
98+
}
99+
});
100+
101+
test("spawn.maySpawn is false with no allowlist (leaf)", () => {
102+
expect(migratorPackage.spawn.maySpawn).toBe(false);
103+
expect(migratorPackage.spawn.allowlist).toBeUndefined();
104+
expect(migratorPackage.tier).toBe("leaf");
105+
});
106+
107+
test("modelRole is plan", () => {
108+
expect(migratorPackage.modelRole).toBe("plan");
109+
});
110+
111+
test("primaryIntent ships reversible migrations with evidence and rollback", () => {
112+
expect(migratorPackage.primaryIntent).toBe(
113+
"Ship reversible data migrations with dry-run evidence and a tested rollback path",
114+
);
115+
});
116+
117+
test("outOfLane refuses renames, features, irreversible breaks, orchestration", () => {
118+
expect(migratorPackage.outOfLane).toEqual([
119+
"bulk code renames (ast-grep / refactor skill territory)",
120+
"product features",
121+
"API renames",
122+
"irreversible schema breaks without a rollback path",
123+
"orchestration or spawning workers",
124+
]);
125+
});
126+
127+
test("description names the reversible-migration lane", () => {
128+
expect(migratorPackage.description).toBe(
129+
"Reversible settings, config, and session-state migrations — forward path, rollback path, dry-run evidence",
130+
);
131+
});
132+
133+
test("optionalSkills is empty", () => {
134+
expect(migratorPackage.optionalSkills).toEqual([]);
135+
});
136+
});
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 MigratorDirector (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. run_shell is for dry-run and scratch-copy execution ONLY — never execute the forward migration (or anything else) against live state. Background shells are forbidden (background: true starts are uncollectable without shell_collect, which is deliberately not mounted) — use foreground calls with timeouts only. Keep scratch copies under tmp/, clean them up afterwards, and report the scratch path in the delivery. 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: 22 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 18 directors", () => {
17-
expect(DIRECTOR_IDS).toHaveLength(18);
18-
expect(listDirectors()).toHaveLength(18);
16+
test("closed set has exactly 19 directors", () => {
17+
expect(DIRECTOR_IDS).toHaveLength(19);
18+
expect(listDirectors()).toHaveLength(19);
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(17);
120-
expect(new Set(profiles.map((p) => p.id)).size).toBe(17);
119+
expect(profiles).toHaveLength(18);
120+
expect(new Set(profiles.map((p) => p.id)).size).toBe(18);
121121
expect(profiles.map((p) => p.id)).not.toContain("skywalker");
122122
});
123123

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

136+
test("migrator is a dry-run-scoped 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+
expect(m.systemPrompt).toMatch(/dry-run and scratch-copy execution ONLY/);
145+
expect(m.systemPrompt).toMatch(/Background shells are forbidden/);
146+
const r = resolveDirector({ agentId: "migrator" });
147+
expect(r.ok).toBe(true);
148+
if (r.ok) expect(r.package.id).toBe("migrator");
149+
});
150+
136151
test("closed directors mount product write tools", () => {
137152
for (const id of [
138153
"critic",
@@ -196,11 +211,11 @@ describe("director registry", () => {
196211
expect(s.tools?.allow).toContain("write_file");
197212
expect(s.tools?.allow).toContain("edit_file");
198213
expect(s.tools?.allow).toContain("delete_file");
199-
expect(s.spawn.allowlist).toHaveLength(17);
214+
expect(s.spawn.allowlist).toHaveLength(18);
200215
});
201216

202217
// CL-6941: tier and spawn.maySpawn independently encode "may this package
203-
// spawn", hand-set across 18 files. This pins their agreement so drift
218+
// spawn", hand-set across 19 files. This pins their agreement so drift
204219
// (adding maySpawn: true without bumping tier, or vice versa) fails a test
205220
// instead of surfacing as an unexplained FleetAuthorityError at dispatch.
206221
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";
@@ -62,6 +63,7 @@ export const DIRECTOR_REGISTRY: Readonly<Record<DirectorId, DirectorPackage>> =
6263
tester: testerPackage,
6364
gauntlet: gauntletPackage,
6465
prober: proberPackage,
66+
migrator: migratorPackage,
6567
};
6668

6769
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(17);
31+
expect(skywalkerPackage.spawn.allowlist).toHaveLength(18);
3232
expect(skywalkerPackage.spawn.allowlist).toEqual([
3333
"builder",
3434
"explorer",
@@ -47,6 +47,7 @@ describe("skywalkerPackage", () => {
4747
"tester",
4848
"gauntlet",
4949
"prober",
50+
"migrator",
5051
]);
5152
});
5253

0 commit comments

Comments
 (0)