Skip to content
Open
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ tyto compile wiki-status # one model session → a ve
tyto run wiki-status --species "Tiger" # {"status":"Endangered",…} in ~0.3 s, no model
```

A recipe that can't find what it expects returns a **MISS** (exit 3) instead of guessing. Recipes that use your
saved logins run only after `tyto recipes approve`. For one-off pages, `tyto open <url>` prints the brief.
A recipe that can't find what it expects returns a **MISS** (exit 3) instead of guessing, and prints the
`tyto repair …` command that fixes it (one model session; the fix must still pass every input that worked
before). Recipes that use your saved logins run only after `tyto recipes approve`. For one-off pages,
`tyto open <url>` prints the brief.

## Measured (2026-09-29, M4 Max)

Expand Down
11 changes: 11 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,21 @@ themselves (`querySelectorAll(...)[n-1]`); never submit or delete unless the tra
inputs (original plus two varied), each confirmed independently on the live page.

### Slice 7 — repair
`tyto repair <name> --p v` reruns the input; on a MISS it sends the current recipe, the miss (reason fenced as
untrusted), and the regression inputs to the compiler with a repair addendum. The candidate must keep the name,
param names, and origins, pass lint, and hit on the missed input and every regression input before it replaces
the old recipe. Repaired recipes are drafts; `auth` recipes skip live validation and must be approved again.
Successful `tyto run`s append their inputs to `regression` (first case kept, most recent kept, max 10); a MISS
prints a ready-to-run `tyto repair` command on stderr.
- `on a miss, repair receives the missed inputs with reasons and the regression inputs`
- `a repaired recipe keeps its name and params and must pass every regression input before replacing the old one`
- `passing inputs are appended to regression, deduplicated, capped at 10`
- `repair output that fails lint leaves the old recipe unchanged`
- `a repaired recipe must keep its name and param names`; `a repaired recipe may not open new origins`
- `a successful repair replaces the recipe and remembers the missed input`
- `an input the recipe already answers needs no repair`
- `a repaired auth recipe is saved as a draft that needs approval again`
- `tyto run records passing inputs in the regression list`; `tyto run prints a repair hint on a miss`

### Slice 8 — install and doctor
- `install writes the tyto skill for Claude Code and Cursor without overwriting user edits`
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export class UsageError extends Error {}

/** `--name value` / `--name=value` pairs. */
export function parseParams(args: readonly string[]): Record<string, string> {
const params: Record<string, string> = {};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
const m = /^--(\w+)(?:=(.*))?$/s.exec(arg);
if (!m?.[1]) throw new UsageError(`expected --param value, got ${arg}`);
if (m[2] !== undefined) params[m[1]] = m[2];
else {
const value = args[i + 1];
if (value === undefined) throw new UsageError(`--${m[1]} needs a value`);
params[m[1]] = value;
i += 1;
}
}
return params;
}

export function subset(expected: Readonly<Record<string, unknown>>, actual: Record<string, unknown>): boolean {
return Object.entries(expected).every(([k, v]) => JSON.stringify(actual[k]) === JSON.stringify(v));
}

export function asStrings(params: Readonly<Record<string, string | number>>): Record<string, string> {
return Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]));
}

export function paramFlags(params: Readonly<Record<string, string | number>>): string {
return Object.entries(params)
.map(([k, v]) => `--${k} ${JSON.stringify(String(v))}`)
.join(" ");
}
54 changes: 54 additions & 0 deletions packages/cli/src/compile/repair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { EXIT, appendRegression, executeRecipe, repairRecipe, type Compiler, type ExecDeps, type RecipeStore } from "@tyto/core";
import { UsageError, asStrings, parseParams, subset } from "../args.ts";

export type RepairDeps = { store: RecipeStore; compiler: Compiler; exec: ExecDeps };
type Io = { out: (line: string) => void; err: (line: string) => void };

/** Repair a recipe that misses on an input; replace it only if every known input then passes. */
export async function repair(args: readonly string[], deps: RepairDeps, io: Io): Promise<number> {
const name = args[0];
if (!name) throw new UsageError("tyto repair needs a recipe name and the input that missed");
const recipe = await deps.store.get(name);
if (!recipe) {
io.err(`unknown recipe: ${name}`);
return EXIT.usage;
}
const params = parseParams(args.slice(1));
const first = await executeRecipe(recipe, params, deps.exec);
if (first.kind === "hit") {
await deps.store.save(appendRegression(recipe, asStrings(first.params)));
io.out(`${name} already answers ${JSON.stringify(params)}; nothing to repair`);
return EXIT.hit;
}
if (first.kind === "error") {
io.err(first.message);
return first.code;
}
io.out(`${name} missed (${first.miss}); repairing in one model session…`);
const repaired = await repairRecipe(recipe, [{ params, reason: first.miss }], deps.compiler);
if (!repaired.ok) {
io.err(`repair rejected; the old recipe is unchanged:\n${repaired.errors.map((e) => `- ${e}`).join("\n")}`);
return EXIT.invalid;
}
const candidate = repaired.recipe;
if (candidate.auth) {
await deps.store.save(appendRegression(candidate, params));
io.out(`repaired ${name} and saved it as a draft. It uses your logins, so review and approve it again:`);
io.out(` tyto recipes approve ${name} then tyto test ${name}`);
return EXIT.hit;
}
const cases = [{ params, expect: {} }, ...recipe.regression];
const failures: string[] = [];
for (const c of cases) {
const o = await executeRecipe(candidate, c.params, deps.exec);
if (o.kind === "hit" && subset(c.expect, o.result)) continue;
failures.push(`${JSON.stringify(c.params)} → ${o.kind === "miss" ? o.miss : o.kind === "error" ? o.message : "unexpected result"}`);
}
if (failures.length) {
io.out(`repair rejected; the old recipe is unchanged. The repaired version failed:\n${failures.map((f) => `- ${f}`).join("\n")}`);
return EXIT.miss;
}
await deps.store.save(appendRegression(candidate, params));
io.out(`repaired ${name}: all ${cases.length} known inputs pass`);
return EXIT.hit;
}
35 changes: 10 additions & 25 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { EXIT, executeRecipe, type Compiler, type ExecDeps, type RecipeStore, type TraceStore } from "@tyto/core";
import { EXIT, appendRegression, executeRecipe, type Compiler, type ExecDeps, type RecipeStore, type TraceStore } from "@tyto/core";
import { approve, compile, compileTool, type CompileToolDeps } from "./compile/commands.ts";
import { repair } from "./compile/repair.ts";
import { UsageError, asStrings, paramFlags, parseParams, subset } from "./args.ts";
import { ACTIONS, act, brief, find, open, type BrowseDeps } from "./browse.ts";
import { learnCommand, type LearnDeps } from "./learn/commands.ts";

Expand Down Expand Up @@ -27,36 +29,14 @@ export const USAGE = `usage: tyto <command>
tyto learn status <name> typed inputs recorded so far (names only)
tyto learn stop <name> --task "…" [--param input_N=name ...] save the trace
tyto compile <name> turn a recorded trace into a draft recipe (one model session)
tyto repair <recipe> [--param value ...] fix a recipe that missed on that input (one model session)
tyto run <recipe> [--param value ...] replay a recipe with no model (exit 0 hit, 3 miss)
tyto test <recipe> run the recipe's regression cases
tyto recipes [--json] list recipes
tyto recipes show <recipe> print a recipe
tyto recipes approve <recipe> [--yes] allow a recipe to run (required for recipes that use your logins)
tyto recipes rm <recipe> delete a recipe`;

class UsageError extends Error {}

function parseParams(args: readonly string[]): Record<string, string> {
const params: Record<string, string> = {};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i] ?? "";
const m = /^--(\w+)(?:=(.*))?$/s.exec(arg);
if (!m?.[1]) throw new UsageError(`expected --param value, got ${arg}`);
if (m[2] !== undefined) params[m[1]] = m[2];
else {
const value = args[i + 1];
if (value === undefined) throw new UsageError(`--${m[1]} needs a value`);
params[m[1]] = value;
i += 1;
}
}
return params;
}

function subset(expected: Readonly<Record<string, unknown>>, actual: Record<string, unknown>): boolean {
return Object.entries(expected).every(([k, v]) => JSON.stringify(actual[k]) === JSON.stringify(v));
}

async function run(args: readonly string[], deps: CliDeps): Promise<number> {
const [name, ...rest] = args;
if (!name) throw new UsageError("tyto run needs a recipe name");
Expand All @@ -65,13 +45,16 @@ async function run(args: readonly string[], deps: CliDeps): Promise<number> {
deps.err(`unknown recipe: ${name} (see tyto recipes)`);
return EXIT.usage;
}
const outcome = await executeRecipe(recipe, parseParams(rest), deps.exec);
const params = parseParams(rest);
const outcome = await executeRecipe(recipe, params, deps.exec);
switch (outcome.kind) {
case "hit":
deps.out(JSON.stringify(outcome.result));
await deps.store.save(appendRegression(recipe, asStrings(outcome.params))).catch(() => undefined);
return EXIT.hit;
case "miss":
deps.out(JSON.stringify({ miss: outcome.miss, step: outcome.step }));
deps.err(`repair: tyto repair ${name} ${paramFlags(params)}`.trimEnd());
return EXIT.miss;
case "error":
deps.err(outcome.message);
Expand Down Expand Up @@ -153,6 +136,8 @@ export async function main(argv: readonly string[], deps: CliDeps): Promise<numb
return await test(args, deps);
case "recipes":
return await recipes(args, deps);
case "repair":
return await repair(args, deps, deps);
case "compile":
return await compile(args, deps, deps);
case "compile-tool":
Expand Down
132 changes: 132 additions & 0 deletions packages/cli/test/repair.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { SecretRedactor, parseRecipe, type BatchStepResult, type Recipe } from "@tyto/core";
import {
FakeBrowserRunner,
FakeCompiler,
FakeSessionLock,
MemoryLogMarks,
MemoryRecipeStore,
MemoryTraceStore,
} from "@tyto/core/testing";
import { main, type CliDeps } from "../src/index.ts";

const OLD_EVAL = "JSON.stringify({ tag: document.title === 'node' ? '' : 'v1', url: location.href, version: 'old' })";
const NEW_EVAL = "JSON.stringify({ tag: 'v1', url: location.href, version: 'new' })";

function recipe(overrides: Record<string, unknown> = {}): Recipe {
const parsed = parseRecipe({
name: "gh-release",
version: 1,
status: "draft",
intent: "Latest release tag.",
origins: ["https://github.com"],
params: { repo: { type: "string", description: "owner/name", example: "a/b" } },
steps: [["open", "https://github.com/{{repo|path}}/releases"], ["eval", OLD_EVAL]],
verify: { required: ["tag"] },
regression: [{ params: { repo: "a/b" }, expect: {} }],
...overrides,
});
if (!parsed.ok) throw new Error(parsed.errors.join("; "));
return parsed.recipe;
}

const decode = (b64: string): string => new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));

/**
* Old recipe: misses on nodejs/node. New recipe: hits everything, unless `newBreaks` names a repo it misses.
*/
function browser(newBreaks?: string): FakeBrowserRunner {
return new FakeBrowserRunner((steps): BatchStepResult[] => {
const url = steps[0]?.[1] ?? "";
const code = decode(steps.at(-1)?.[2] ?? "");
const isNew = code.includes("'new'");
const miss = isNew ? (newBreaks !== undefined && url.includes(newBreaks)) : url.includes("nodejs/node");
return steps.map((command, i) => ({
command,
success: true,
result: i === steps.length - 1 ? { result: JSON.stringify({ tag: miss ? "" : "v1", url }) } : {},
error: null,
}));
});
}

function harness(opts: { reply?: string; newBreaks?: string; old?: Recipe } = {}) {
const runner = browser(opts.newBreaks);
const store = new MemoryRecipeStore(opts.old ?? recipe());
const repaired = { ...JSON.parse(JSON.stringify(opts.old ?? recipe())), steps: [["open", "https://github.com/{{repo|path}}/releases"], ["eval", NEW_EVAL]] };
const compiler = new FakeCompiler(opts.reply ?? "```json\n" + JSON.stringify(repaired) + "\n```");
const out: string[] = [];
const deps: CliDeps = {
store,
exec: { runner, lock: new FakeSessionLock(), paths: { config: "/c", policy: "/p" } },
browse: { runner, marks: new MemoryLogMarks(), redactor: new SecretRedactor(), session: "default", now: () => 0 },
learn: { spawnListener: async () => undefined, control: async () => ({ ok: false }) },
traces: new MemoryTraceStore(),
compiler,
compileTool: { dir: undefined, readStdin: async () => "" },
confirm: async () => false,
out: (s) => out.push(s),
err: (s) => out.push(`ERR ${s}`),
};
return { deps, out, store, compiler };
}

describe("tyto repair", () => {
it("on a miss, repair receives the missed inputs with reasons and the regression inputs", async () => {
const h = harness();
await main(["repair", "gh-release", "--repo", "nodejs/node"], h.deps);
const prompt = h.compiler.requests[0]?.prompt ?? "";
expect(prompt).toMatch(/nodejs\/node[\s\S]*required field empty: tag/);
expect(prompt).toMatch(/Previously passing inputs[\s\S]*a\/b/);
});

it("a successful repair replaces the recipe and remembers the missed input", async () => {
const h = harness();
expect(await main(["repair", "gh-release", "--repo", "nodejs/node"], h.deps)).toBe(0);
const saved = await h.store.get("gh-release");
expect(saved?.steps[1]?.[1]).toBe(NEW_EVAL);
expect(saved?.regression.map((c) => c.params.repo)).toEqual(["a/b", "nodejs/node"]);
});

it("a repaired recipe must pass every regression input before replacing the old one", async () => {
const h = harness({ newBreaks: "a/b" });
expect(await main(["repair", "gh-release", "--repo", "nodejs/node"], h.deps)).toBe(3);
expect((await h.store.get("gh-release"))?.steps[1]?.[1]).toBe(OLD_EVAL);
expect(h.out.join("\n")).toMatch(/a\/b/);
});

it("repair output that fails lint leaves the old recipe unchanged", async () => {
const bad = { ...JSON.parse(JSON.stringify(recipe())), steps: [["open", "https://github.com/"], ["eval", "fetch('/x')"]] };
const h = harness({ reply: "```json\n" + JSON.stringify(bad) + "\n```" });
expect(await main(["repair", "gh-release", "--repo", "nodejs/node"], h.deps)).toBe(65);
expect((await h.store.get("gh-release"))?.steps[1]?.[1]).toBe(OLD_EVAL);
});

it("an input the recipe already answers needs no repair", async () => {
const h = harness();
expect(await main(["repair", "gh-release", "--repo", "a/b"], h.deps)).toBe(0);
expect(h.compiler.requests).toHaveLength(0);
});

it("a repaired auth recipe is saved as a draft that needs approval again", async () => {
const h = harness({ old: recipe({ auth: true, status: "approved" }) });
expect(await main(["repair", "gh-release", "--repo", "nodejs/node"], h.deps)).toBe(0);
const saved = await h.store.get("gh-release");
expect(saved?.status).toBe("draft");
expect(h.out.join("\n")).toMatch(/tyto recipes approve gh-release/);
});
});

describe("tyto run and regression", () => {
it("tyto run records passing inputs in the regression list", async () => {
const h = harness();
expect(await main(["run", "gh-release", "--repo", "vercel/next.js"], h.deps)).toBe(0);
expect((await h.store.get("gh-release"))?.regression.map((c) => c.params.repo)).toEqual(["a/b", "vercel/next.js"]);
});

it("tyto run prints a repair hint on a miss", async () => {
const h = harness();
expect(await main(["run", "gh-release", "--repo", "nodejs/node"], h.deps)).toBe(3);
expect(h.out.join("\n")).toMatch(/tyto repair gh-release --repo "nodejs\/node"/);
});
});
Loading
Loading