Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
936 changes: 936 additions & 0 deletions specs/TRAINING.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ src/cli/
├── composedLifecycleContractSet.ts # Closed machine command/contract inventory
├── evidenceExportHelperCommand.ts # Local helper construction command
├── compileBuildCommands.ts # `compile` and `build` command registration
├── trainCommand.ts # Canonical agent selection and Paideia CLI option forwarding
├── trainCommand.ts # Canonical plan/rewind selection and Paideia CLI option forwarding
├── paideiaDelegation.ts # Private versioned context handoff, child lifecycle and completion receipts
├── paideiaSupervisor.ts # Packaged group leader retaining identity until native child/group cleanup
├── lifecycleCommands.ts # Thin lifecycle/compile/build/run/publish/up/down registration composition
Expand Down
10 changes: 5 additions & 5 deletions src/cli/runCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
clearProjectModelFallbacks,
compileProject,
createTrainingContext,
createTrainingPlan,
createTrainingPlan, loadTrainingProject, resolveTrainingPlan, materializeTrainingMoment,
initProject,
listInitTemplates,
preflightCompilePlan,
Expand Down Expand Up @@ -77,9 +77,7 @@ import { delegatePaideiaTraining } from "./paideiaDelegation.js";
const packageJsonPath = new URL("../../package.json", import.meta.url);

const readPackageVersion = (): string => {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
version?: unknown;
};
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
};

Expand All @@ -105,6 +103,8 @@ const createDefaultRenderEnvironment = (): CliRenderEnvironment => ({
export interface CliHandlers {
createTrainingContext: typeof createTrainingContext;
createTrainingPlan: typeof createTrainingPlan;
loadTrainingProject: typeof loadTrainingProject; resolveTrainingPlan: typeof resolveTrainingPlan;
materializeTrainingMoment: typeof materializeTrainingMoment;
delegatePaideiaTraining: typeof delegatePaideiaTraining;
buildCompilePlan: typeof buildCompilePlan; buildOrganizationView: typeof buildOrganizationView;
buildProject: typeof buildProject; compileProject: typeof compileProject;
Expand Down Expand Up @@ -139,7 +139,7 @@ export interface CliHandlers {
}

const createDefaultHandlers = (): CliHandlers => ({
createTrainingContext, createTrainingPlan, delegatePaideiaTraining,
createTrainingContext, createTrainingPlan, loadTrainingProject, resolveTrainingPlan, materializeTrainingMoment, delegatePaideiaTraining,
buildCompilePlan, buildOrganizationView, buildProject, compileProject, publishProject,
addAgentProject, addProjectModelFallback, addProjectSurface,
addSubagentProject, addTeamProject, clearProjectModelFallbacks,
Expand Down
118 changes: 118 additions & 0 deletions src/cli/trainCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";

import { runCli, type CliHandlers } from "./runCli.js";
import type { DelegatePaideiaTrainingOptions } from "./paideiaDelegation.js";
import {
agentManifest, cleanTrainingProjects, dataFiles, example, trainingProject, volume
} from "../compiler/training/plan/fixtures.test-helper.js";

const directories: string[] = [];
afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); });
afterEach(cleanTrainingProjects);
async function project(): Promise<string> {
const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "spawnfile-train-command-")));
directories.push(root);
Expand All @@ -24,6 +28,120 @@ const emptyPlan = (root: string): Awaited<ReturnType<CliHandlers["createTraining
record: null, targets: [], version: "spawnfile.training-plan.v1"
});

describe("spawnfile train --materialize", () => {
it.each([example.id, `train:${example.id}`])("materializes %s locally without delegated training flags", async (selector) => {
const root = await trainingProject({ ...dataFiles, Spawnfile: agentManifest() });
const out = path.join(root, "rewound"), stdout: string[] = [], stderr: string[] = [];
const forbidden = vi.fn(async () => { throw Error("must not execute, authenticate, or compile output"); });
const code = await runCli(["train", path.join(root, "Spawnfile"), "--agent", "agent:writer",
"--materialize", selector, "--out", out], {
handlers: { createTrainingContext: forbidden, delegatePaideiaTraining: forbidden, compileProject: forbidden,
buildProject: forbidden, runProject: forbidden, upProject: forbidden, publishProject: forbidden,
importClaudeCodeAuth: forbidden, importCodexAuth: forbidden, importGrokAuth: forbidden,
requireAuthProfile: forbidden, provisionCredentials: forbidden },
streams: { stdout: (value) => stdout.push(value), stderr: (value) => stderr.push(value) }
});
expect(code).toBe(0);
expect(stderr).toEqual([]);
expect(stdout).toHaveLength(1);
const moment = JSON.parse(stdout[0]!);
expect(moment).toMatchObject({ status: "materialized", agent: "agent:writer",
example: { id: example.id, role: "train", dataset: "training/train.yaml" }, inputs: [],
record: { matched: false }, leakCheck: { ok: true, findings: 0 } });
expect(JSON.parse(await readFile(path.join(out, "moment.json"), "utf8"))).toEqual(moment);
expect(JSON.parse(await readFile(path.join(out, "task-clock.json"), "utf8"))).toEqual({
version: "spawnfile.task-clock.v1", origin: "2026-10-09T08:20:31Z"
});
expect(forbidden).not.toHaveBeenCalled();
});

it("lists ambiguous roles and materializes a qualified selection", async () => {
const root = await trainingProject({ ...dataFiles, Spawnfile: agentManifest(),
"training/test.yaml": { examples: [example] } });
const out = path.join(root, "rewound"), stderr: string[] = [];
const options = { streams: { stdout() {}, stderr: (value: string) => stderr.push(value) } };
const args = ["train", root, "--agent", "agent:writer", "--out", out, "--materialize"];
expect(await runCli([...args, example.id], options)).toBe(1);
expect(stderr).toHaveLength(1);
expect(stderr[0]).toContain(`train:${example.id} (training/train.yaml)`);
expect(stderr[0]).toContain(`test:${example.id} (training/test.yaml)`);
expect(await readdir(root)).not.toContain("rewound");
expect(await runCli([...args, `test:${example.id}`], options)).toBe(0);
expect(JSON.parse(await readFile(path.join(out, "moment.json"), "utf8")).example.role).toBe("test");
});

it("retains the plan's single-agent inference when --agent is omitted", async () => {
const root = await trainingProject({ ...dataFiles, Spawnfile: agentManifest() });
const stdout: string[] = [];
expect(await runCli(["train", root, "--materialize", example.id, "--out", path.join(root, "out")], {
streams: { stdout: (value) => stdout.push(value), stderr() {} }
})).toBe(0);
expect(JSON.parse(stdout[0]!).agent).toBe("agent:writer");
});

it("forwards a loaded project, resolved plan, history and the existing --out option", async () => {
const root = await trainingProject({ ...dataFiles, Spawnfile: agentManifest() });
const materializeTrainingMoment = vi.fn<CliHandlers["materializeTrainingMoment"]>(async ({ plan }) => ({
version: "spawnfile.materialized-moment.v1", status: "materialized", agent: plan.agent.id,
example: { id: example.id, role: "train", dataset: "training/train.yaml" }, at: example.at, effectiveAt: example.at,
inputs: [], record: { matched: false }, leakCheck: { ok: true, findings: 0 }
}));
const stdout: string[] = [];
expect(await runCli(["train", root, "--agent", "agent:writer", "--materialize", example.id,
"--history", "history with spaces", "--out", "out with spaces"], {
handlers: { materializeTrainingMoment }, streams: { stdout: (value) => stdout.push(value), stderr() {} }
})).toBe(0);
expect(materializeTrainingMoment).toHaveBeenCalledOnce();
expect(materializeTrainingMoment.mock.calls[0]![0]).toMatchObject({
project: { root, manifest: path.join(root, "Spawnfile") },
plan: { agent: { id: "agent:writer" }, producer: { version: packageVersion } },
example: example.id, historyDir: "history with spaces", out: "out with spaces"
});
expect(JSON.parse(stdout[0]!).status).toBe("materialized");
});

it("returns one-line stderr and exit 1 for a refused moment with report-only output", async () => {
const root = await trainingProject({ ...dataFiles, Spawnfile: agentManifest({
workspace: { docs: { system: "AGENTS.md" }, resources: [volume] }
}) });
const out = path.join(root, "refused"), stdout: string[] = [], stderr: string[] = [];
expect(await runCli(["train", root, "--agent", "agent:writer", "--materialize", example.id, "--out", out], {
streams: { stdout: (value) => stdout.push(value), stderr: (value) => stderr.push(value) }
})).toBe(1);
expect(stdout).toEqual([]);
expect(stderr).toHaveLength(1);
expect(stderr[0]).toMatch(/^error: Moment refused: 1 input refusal\(s\), 0 leak finding\(s\)/u);
expect(stderr[0]).not.toContain("\n");
expect(await readdir(out)).toEqual(["moment.json"]);
expect(JSON.parse(await readFile(path.join(out, "moment.json"), "utf8"))).toMatchObject({
status: "refused", inputs: [{ id: volume.id, refusal: expect.any(String) }]
});
expect((await readdir(root)).filter((name) => name.includes("staging"))).toEqual([]);
});

it.each([
["--materialize", example.id, "--agent", "agent:writer"],
["--materialize", example.id, "--agent", "agent:writer", "--out", "output", "--plan"],
["--history", "history", "--plan"]
])("rejects incomplete or conflicting local mode options %j before reads", async (...flags) => {
const forbidden = vi.fn(async () => { throw Error("must not read project"); });
expect(await runCli(["train", "/missing", ...flags], {
handlers: { loadTrainingProject: forbidden, createTrainingPlan: forbidden, materializeTrainingMoment: forbidden,
createTrainingContext: forbidden, delegatePaideiaTraining: forbidden }, streams: { stdout() {}, stderr() {} }
})).toBe(2);
expect(forbidden).not.toHaveBeenCalled();
});

it("registers --out exactly once alongside --materialize and --history", async () => {
const stdout: string[] = [];
expect(await runCli(["train", "--help"], { streams: { stdout: (value) => stdout.push(value), stderr() {} } })).toBe(0);
const help = stdout.join("\n");
expect(help.match(/--out <value>/gu)).toHaveLength(1);
expect(help).toContain("--materialize <example>");
expect(help).toContain("--history <directory>");
});
});

describe("spawnfile train --plan", () => {
it.each([false, true])("prints the real authoring plan without execution with dryRun=%s", async (dryRun) => {
const flags = dryRun ? ["--dry-run"] : [];
Expand Down
29 changes: 27 additions & 2 deletions src/cli/trainCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,48 @@ export const registerTrainCommand = (
signal?: AbortSignal
): void => {
const command = program.command("train")
.description("Train one canonical agent through Paideia's isolated native integration")
.description("Plan, rewind, or train one canonical agent through its declared training inputs")
.argument("[path]", "Canonical project directory or Spawnfile path", process.cwd())
.option("--agent <id>", "Exact canonical agent node id (inferred only for a single-agent project)")
.option("--paideia-command <executable>", "Installed Paideia executable; no shell or automatic install", "paideia")
.option("--training-image <immutable-image>", "Pinned image containing the complete training environment")
.option("--training-config <json>", "V2 image recipe and declared inputs, or advanced v1 local bindings")
.option("--plan", "Print the resolved training authoring plan without executing training")
.option("--materialize <example>", "Rewind an authored example into --out without running training")
.option("--history <directory>", "Local recorded history for --materialize")
.option("--dry-run", "Validate and estimate without compiling, authenticating or starting models")
.option("--repair-measurements <parent-out>", "Fork captured work into a fresh output for authorized measurement repair")
.option("--repair-witness <manifest>", "Explicit verified image witness for a legacy parent")
.option("--resume", "Resume the exact persisted training experiment in --out");
for (const name of forwarded) {
const flag = `--${name} <value>`;
if (repeated.has(name)) command.option(flag, `Paideia ${name}; repeatable`, (value: string, previous: string[]) => [...previous, value], []);
else command.option(flag, `Paideia ${name}`);
else command.option(flag, name === "out" ? "Output directory for training or moment materialization" : `Paideia ${name}`);
}
command.action(async (inputPath: string, options: Record<string, string | string[] | boolean | undefined>) => {
if (typeof options.materialize === "string") {
if (options.plan === true) {
throw new SpawnfileError("validation_error", "--materialize and --plan are mutually exclusive");
}
if (typeof options.out !== "string") {
throw new SpawnfileError("validation_error", "--materialize requires --out");
}
const project = await handlers.loadTrainingProject(inputPath);
const plan = handlers.resolveTrainingPlan(project, options.agent as string | undefined, packageVersion);
const moment = await handlers.materializeTrainingMoment({ plan, project, example: options.materialize,
historyDir: options.history as string | undefined, out: options.out });
if (moment.status === "refused") {
streams.stderr(`error: Moment refused: ${moment.inputs.filter((input) => input.refusal !== null).length} input refusal(s), ${moment.leakCheck.findings} leak finding(s); see moment.json in --out`);
setExitCode(1);
} else {
streams.stdout(JSON.stringify(moment, null, 2));
setExitCode(0);
}
return;
}
if (options.history !== undefined) {
throw new SpawnfileError("validation_error", "--history requires --materialize");
}
if (options.plan === true) {
const plan = await handlers.createTrainingPlan(inputPath, {
agent: options.agent as string | undefined, packageVersion
Expand Down
1 change: 1 addition & 0 deletions src/compiler/training/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- `context.ts` resolves the full compiler graph and pins source files without compiling or launching it.
- `resolution.ts` merges direct-member training metadata during traversal and preserves declaring sources.
- `plan/` owns local authoring-file loading, pure plan resolution, and the public training-plan schema.
- `rewind/` materializes authored moments from contained local history, with explicit fidelity and an independent disk leak check; it never runs trials.
- Preserve resolved inheritance and exact agent IDs. Never create a second agent declaration.
- Source mappings are project-relative editable files, not runtime-native destinations or flattened prompts.
- Do not serialize secret values, arbitrary environments or transport credentials.
Expand Down
1 change: 1 addition & 0 deletions src/compiler/training/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./contract.js";
export * from "./context.js";
export * from "./plan/index.js";
export * from "./rewind/index.js";
export type { ResolvedTraining, ResolvedTrainingGrader } from "./resolution.js";
export { parseTrainingMappedPreparation } from "./preparation/contract.js";
export type { TrainingMappedPreparation } from "./preparation/contract.js";
13 changes: 10 additions & 3 deletions src/compiler/training/plan/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCompilePlan } from "../../buildCompilePlan.js";
import { loadTrainingProject, createTrainingPlan } from "./load.js";
import { trainingPlanJsonSchema, trainingPlanSchema } from "./contract.js";
import { materializedMomentJsonSchema, leakCheckJsonSchema, taskClockJsonSchema } from "../rewind/contract.js";
import {
agentManifest, cleanTrainingProjects, dataFiles, example, target, teamManifest, training,
trainingProject, writeTrainingFile
Expand Down Expand Up @@ -76,10 +77,16 @@ describe("training authoring loader", () => {
expect((await createTrainingPlan(root, options)).targets[0].files[0].path).toBe("C:notes.md");
});

it("keeps the documented plan JSON Schema identical to the exported contract", async () => {
it.each([
["training-plan-schema", trainingPlanJsonSchema],
["materialized-moment-schema", materializedMomentJsonSchema],
["leak-check-schema", leakCheckJsonSchema],
["task-clock-schema", taskClockJsonSchema]
])("keeps the documented %s identical to the exported contract", async (marker, expected) => {
const doc = await readFile(new URL("../../../../specs/TRAINING.md", import.meta.url), "utf8");
const schema = doc.match(/<!-- training-plan-schema:start -->\n```json\n([\s\S]*?)\n```/u)?.[1];
expect(JSON.parse(schema!)).toEqual(trainingPlanJsonSchema);
const pattern = "<!-- " + marker + ":start -->\\n```json\\n([\\s\\S]*?)\\n```";
const schema = doc.match(new RegExp(pattern, "u"))?.[1];
expect(JSON.parse(schema!)).toEqual(expected);
});
});

Expand Down
Loading
Loading