diff --git a/.claude/skills/loopflow/SKILL.md b/.claude/skills/loopflow/SKILL.md index 26f8407..6ced0c1 100644 --- a/.claude/skills/loopflow/SKILL.md +++ b/.claude/skills/loopflow/SKILL.md @@ -40,6 +40,8 @@ look at: , and the last failure context to read before acting allow edits automatically, but ask me before action policy each cycle: plan, then act, then observe the repeated steps (any subset, in order) also: , extra finishing passes after the goal is met +use skills: , named skills the loop may invoke during plan/act +remember in "" cross-run memory: read lessons on start, append an outcome on stop when it fails: reflect on , then plan again when it passes and the goal is met: stop when blocked: ask a human @@ -63,6 +65,8 @@ done when the test "file.spec.ts::name" passes done when "pnpm test" passes # shell command, exit 0 done when "semgrep --severity=high" finds nothing # empty output done when a human confirms "looks right" +done when the skill "email-review" approves # a review skill: approved / not +done when the skill "email-review" scores 8 or more # a review skill: numeric threshold ``` Rules: indentation is structural (two spaces); `loop`/`pipeline` at column 0; comments @@ -183,10 +187,19 @@ the user explicitly asks for the headless runner.) 1. **Read the file.** (Or, if the `loop` CLI is installed: `loop parse --json` to get the structured spec.) 2. **Execute the loop's semantics, narrating each step:** + - **memory** — if the loop has `remember in ""`, read that file first (skip if it + doesn't exist yet) and let its lessons inform your first plan. When the loop stops, + append a dated entry: `## — ` with the goal, attempts, and the run's + lesson (your last reflection). This is how the loop improves across runs. - **plan** — inspect the `look at:` files; decide the smallest change toward the goal. + If the loop declares `use skills:`, you may invoke those named skills (via the Skill + tool) to do the work — coordinate them rather than re-deriving everything inline. - **act** — make the edits. Honor the policy: for `ask me before `, ask the user before doing X (migrations, pushes, etc.); auto classes you may do directly. - - **observe** — run the `done when` command (or named test) and read pass/fail. + - **observe** — run the `done when` check and read pass/fail. For a command or named test, + run it. For `the skill "" approves` / `scores N or more`, invoke that review skill + on the work and read back its verdict (approved/rejected, or a score vs. the threshold) — + this is how an abstract goal gets a verifiable check. - on **fail** → **reflect** on why (use the failure output), then **plan again** (the back-edge). Repeat. - **stop** when `done when` passes, or after the thrash guard's N tries (state the diff --git a/AGENTS.md b/AGENTS.md index 5616cab..ea3eb55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,16 @@ bug fixes with a test, refactors gated by a check, an epic broken into stories, migration with a verification step. Don't write one for a one-off question or a trivial edit — just do those directly. +Before building a loop, run the four-condition test — build one only when all four hold: + +1. **Does the task repeat?** A one-time task is just a normal prompt. +2. **Is there a clear definition of "done"?** You must be able to verify completion — a + `done when` predicate (a test, a command, or a review skill). No check, no loop. +3. **Can you afford the iterations?** A loop re-prompts itself until done; that costs tokens. + Keep the `done when` check fast and add an `after N tries` thrash guard. +4. **Does the loop have the tools to verify itself?** It needs a way to implement *and* check + its own work — the predicate command or the review skill must actually be runnable. + **Interview the user before writing it.** Walk the five decisions, asking the high-leverage questions and offering defaults for the rest: (1) the **goal**; (2) the **`done when`** check (test / command / scan finds-nothing / human); @@ -48,6 +58,8 @@ look at: , and the last failure context the agent reads before acting ( allow edits automatically, but ask me before action policy each cycle: plan, then act, then observe the repeated steps (any subset, in order) also: , extra finishing passes run after the goal is met +use skills: , named skills the loop may invoke during plan/act +remember in "" cross-run memory: read lessons on start, append an outcome on stop reflect turn a failure into context for the next plan (the back-edge) when it passes and the goal is met: stop @@ -72,11 +84,49 @@ done when the test "billing.spec.ts::apostrophe" passes # a named test done when "pnpm test" passes # a shell command, exit 0 done when "semgrep --severity=high" finds nothing # a shell command, empty output done when a human confirms "looks right at 375px" # a human check +done when the skill "email-review" approves # a review skill: approved / not +done when the skill "email-review" scores 8 or more # a review skill: numeric threshold ``` The command in a predicate runs in the user's shell with their privileges (like an npm script). It IS meant to be a real command. Prefer a fast, deterministic check. +The **skill** predicate bridges an abstract goal to a verifiable one: when "done" isn't a +test or a command (a good email, a sound design), have a review skill return an +approved/rejected verdict or a numeric score. Build that review skill manually first and +confirm it judges well, then wire it in as the loop's check. + +### `use skills` — coordinate proven skills + +Instead of one giant prompt, a loop can name skills it may call while planning and acting: + +```loop +loop "decide whether to cancel the morning run": + goal: a clear go / no-go call the runner trusts + use skills: check-weather, analyze-workout + done when the skill "workout-review" approves +``` + +This is **skill-driven development**: build and battle-test each skill on its own first, +then have the loop coordinate them. Don't invent a loop around skills that don't exist yet — +prove the skill manually, then wire it in (as an execution skill via `use skills:`, or as a +verifier via `done when the skill "…" approves`). See `examples/skills_memory.loop`. + +### `remember in` — cross-run memory + +A loop forgets everything between runs unless you give it a memory file. `remember in` makes +the loop read the file's lessons into its first plan and append a dated outcome entry when it +stops — so it improves run over run instead of repeating mistakes. + +```loop +loop "...": + goal: ... + remember in "morning-run.memory.md" +``` + +`reflect` is *within-run* memory (a failure feeds the next plan); `remember` is its +*across-run* counterpart. The file is plain markdown — readable and editable by a human. + ### `flow` — chaining loops across files A `flow` sequences multiple `.loop` files. Each step runs the whole file (plan→act→observe diff --git a/README.md b/README.md index 9c9513b..50f3a23 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@

Stop tuning prompts. Start editing the loop.

+

A Loop turning a failing test green: plan → act → observe (FAIL) → reflect → plan → act → observe (PASS) → done

+

License: Apache-2.0 npm @@ -104,13 +106,34 @@ pipeline "ship feature": a human reviews before stopping ``` +## Skills and memory + +Two knobs make a loop coordinate proven work and learn over time: + +```loop +loop "decide whether to cancel the morning run": + goal: a clear go / no-go call the runner trusts + use skills: check-weather, analyze-workout # coordinate battle-tested skills + remember in "morning-run.memory.md" # cross-run history + lessons learned + each cycle: plan, then act, then observe + done when the skill "workout-review" approves # bridge the abstract to the verifiable +``` + +- **`use skills:`** names skills the loop may invoke while planning and acting — compose + proven skills instead of one mega-prompt (skill-driven development). +- **`done when the skill "…" approves`** (or `scores N or more`) lets a review skill verify + a goal that isn't a test or command — a good email, a sound design, a sensible call. +- **`remember in ""`** gives the loop a markdown memory: it reads past lessons into its + first plan and appends an outcome entry when it stops. `reflect` is within-run memory; + `remember` is its across-run counterpart. See [`examples/skills_memory.loop`](examples/skills_memory.loop). + ## Compose loops Compose loops into **pipelines** (stages in order, fail-fast), chain whole files with **`flow`**, and fan out over a plan with **`for each`** — humans wired in where judgment lives. Full grammar with worked examples: the [tutorial](https://loopflow.live) and the [manual](docs/MANUAL.md). ## The vocabulary (~15 words — learn it once) -`pipeline` · `stage` · `loop` · `flow` · `for each … in …` · `run … then …` · `each cycle` · `goal` · `done when` · `look at` · `allow…/ask me before…` · `also` · `when…` · `reflect` · `a human…` · `stop` · `use` · `schedule` · `git` +`pipeline` · `stage` · `loop` · `flow` · `for each … in …` · `run … then …` · `each cycle` · `goal` · `done when` · `look at` · `allow…/ask me before…` · `also` · `use skills` · `remember in` · `when…` · `reflect` · `a human…` · `stop` · `use` · `schedule` · `git` Power comes from **composition**, not keyword count. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index a9244e8..b62373f 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -216,6 +216,8 @@ BMAD flow (discover → design → for each story) as one example method. | `allow edits automatically, but ask me before ` | Action policy. Auto classes run unattended; confirm classes pause for you. Classes: `edit`, `migrate`, `push`, `deploy`, `delete`. | | `each cycle: plan, then act, then observe` | The repeated steps — any subset of `plan` / `act` / `observe`, in order. | | `also: , ` | Extra finishing passes run **after** the goal is met (skipped on failure). | +| `use skills: , ` | Named skills the loop may invoke during `plan`/`act` — coordinate proven skills instead of one mega-prompt. | +| `remember in ""` | Cross-run memory: read the file's lessons into the first plan, append a dated outcome entry on stop. (`keep a memory in ""` also works.) | | `when it passes and the goal is met: stop` | Success transition. | | `when it fails: reflect on , then plan again` | The feedback edge — reflect, then re-enter the cycle. | | `when blocked: ask a human` | Pause for a person when stuck. | @@ -237,11 +239,19 @@ done when the test "billing.spec.ts::apostrophe" passes # a named test done when "pnpm test" passes # shell command, exit 0 (`succeeds` also works) done when "semgrep --severity=high" finds nothing # shell command, empty stdout done when a human confirms "looks right at 375px" # a human check +done when the skill "email-review" approves # a review skill: approved / not +done when the skill "email-review" scores 8 or more # a review skill: numeric threshold ``` The command runs in your shell with your privileges (like an npm script). Keep it fast and deterministic. +The **skill** predicate bridges an abstract goal to a verifiable one. When "done" isn't a +test or a command — a good email, a sound design, a sensible go/no-go call — a review skill +returns an approved/rejected verdict or a 0–10 score, and the loop treats that as its check. +Build the review skill manually first and confirm it judges well, then wire it in. See +`examples/skills_memory.loop` and `examples/email_review.loop`. + ### Config tier (top of file) ```loop diff --git a/docs/assets/loop-demo.svg b/docs/assets/loop-demo.svg new file mode 100644 index 0000000..0c76215 --- /dev/null +++ b/docs/assets/loop-demo.svg @@ -0,0 +1,56 @@ + + + + + + +loop — fix_test.loop + +$ loop run examples/fix_test.loop +↻ loop "the checkout tax test passes" + • plan read cart/total.ts, tax.ts + • act edit total.ts + • observe ✗ FAIL checkout.spec.ts::tax expected 8.25, got 8.20 + ↺ reflect tax is applied after rounding — apply it before + • plan tax the subtotal, then round + • act edit total.ts + • observe ✓ PASS checkout.spec.ts::tax + ■ stop goal met · 2 tries · no human needed +✓ done + + + diff --git a/examples/email_review.loop b/examples/email_review.loop new file mode 100644 index 0000000..c262c47 --- /dev/null +++ b/examples/email_review.loop @@ -0,0 +1,11 @@ +# Skill-as-verifier with a numeric threshold: an email isn't "done" until a review +# skill scores it 8 or more out of 10. +loop "draft the launch announcement email": + goal: a launch email that is clear, on-brand, and ready to send + use skills: write-email + remember in "launch-email.memory.md" + each cycle: plan, then act, then observe + done when the skill "email-review" scores 8 or more + when it fails: reflect on the lowest-scoring dimension, then plan again + a human reviews before stopping + after 5 tries: stop and warn "review won't reach 8 — needs a human" diff --git a/examples/morning-run.memory.md b/examples/morning-run.memory.md new file mode 100644 index 0000000..74acee4 --- /dev/null +++ b/examples/morning-run.memory.md @@ -0,0 +1,8 @@ +# morning-run — memory + +Run history and lessons the loop reads on start and appends to on stop. + +## 2026-06-18 — done +- goal: a clear go / no-go call the runner trusts, with a one-line reason +- outcome: satisfied after 2 tries +- lesson: light drizzle below 2mm/h is fine for this runner — don't cancel on rain alone, check intensity. diff --git a/examples/skills_memory.loop b/examples/skills_memory.loop new file mode 100644 index 0000000..1099fc8 --- /dev/null +++ b/examples/skills_memory.loop @@ -0,0 +1,10 @@ +# Skills + memory: a non-technical loop that coordinates skills, verifies with a +# review skill, and remembers what it learns across runs. +loop "decide whether to cancel the morning run": + goal: a clear go / no-go call the runner trusts, with a one-line reason + use skills: check-weather, analyze-workout # battle-tested skills the loop coordinates + remember in "morning-run.memory.md" # cross-run history + lessons learned + each cycle: plan, then act, then observe + done when the skill "workout-review" approves # bridge the abstract to the verifiable + when it fails: reflect on what the review flagged, then plan again + after 4 tries: stop and warn "no call the review would approve" diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index 1e46c0b..a18810d 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -115,6 +115,14 @@ function parsePredicate(s: string, lineNo: number): Predicate { m = text.match(/^a human confirms\s+"([^"]+)"$/i); if (m) return { type: "human", description: m[1] }; + // the skill "X" scores N or more -> skill verdict with a numeric threshold + m = text.match(/^the skill\s+"([^"]+)"\s+scores\s+(\d+)(?:\s+or more)?$/i); + if (m) return { type: "skill", skill: m[1], expect: "approve", minScore: parseInt(m[2], 10) }; + + // the skill "X" approves -> skill verdict (approved / not) + m = text.match(/^the skill\s+"([^"]+)"\s+approves$/i); + if (m) return { type: "skill", skill: m[1], expect: "approve" }; + throw new ParseError(`could not understand "done when ${text}"`, lineNo); } @@ -325,6 +333,17 @@ function interpretLoopBody(name: string | null, body: Line[]): LoopBodyResult { .filter((s) => s.length > 0); i++; continue; } + if ((m = t.match(/^use skills?:\s*(.+)$/i))) { + loop.skills = m[1] + .split(/,|\band\b/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + i++; continue; + } + if ((m = t.match(/^(?:remember|keep a memory)\s+in\s+"([^"]+)"$/i))) { + loop.memory = { file: m[1].trim() }; + i++; continue; + } if ((m = t.match(/^plan from "([^"]+)"$/i))) { loop.planSource = { type: "file", path: m[1] }; i++; continue; diff --git a/packages/parser/src/types.ts b/packages/parser/src/types.ts index 7e13365..eb3e522 100644 --- a/packages/parser/src/types.ts +++ b/packages/parser/src/types.ts @@ -67,6 +67,10 @@ export interface Loop { cycle: CycleStep[]; /** Extra finishing passes run after the goal is met (e.g. "polish", "run a security check"). */ also?: string[]; + /** Named execution skills the loop may invoke during plan/act (e.g. ["check-weather"]). */ + skills?: string[]; + /** A markdown file the loop reads on start and appends to on stop — cross-run memory. */ + memory?: LoopMemory; planSource?: PlanSource; humanPlan?: boolean; humanReviewBeforeStop?: boolean; @@ -88,6 +92,11 @@ export interface Policy { confirm?: string[]; } +export interface LoopMemory { + /** Markdown file, resolved relative to the loop file. */ + file: string; +} + /** * Where a loop's `plan` step gets its plan. Omitted = the agent writes the plan. * `plan from ""` reads the plan from a file instead (e.g. a hand-written @@ -102,7 +111,8 @@ export interface PlanSource { export type Predicate = | { type: "test"; target: string } | { type: "command"; command: string; expect?: "exit-zero" | "empty" } - | { type: "human"; description: string }; + | { type: "human"; description: string } + | { type: "skill"; skill: string; expect: "approve"; minScore?: number }; export interface Transition { on: "pass" | "fail" | "blocked" | "attempts"; diff --git a/packages/parser/test/parser.test.js b/packages/parser/test/parser.test.js index a53732f..18869d9 100644 --- a/packages/parser/test/parser.test.js +++ b/packages/parser/test/parser.test.js @@ -188,3 +188,32 @@ test("models: per-phase overrides all regardless of order", () => { const b = parse(`loop "y":\n goal: g\n done when "true" passes\n models: all strong, act fast\n`); assert.deepEqual(b.definitions[0].models, { phases: { plan: "strong", act: "fast", reflect: "strong", also: "strong" } }); }); + +// ---- skills + memory ---- + +test("skills + memory: use skills, remember, and a skill predicate", () => { + const file = parse(read("skills_memory.loop")); + const loop = file.definitions[0]; + assert.equal(loop.name, "decide whether to cancel the morning run"); + assert.deepEqual(loop.skills, ["check-weather", "analyze-workout"]); + assert.deepEqual(loop.memory, { file: "morning-run.memory.md" }); + assert.deepEqual(loop.doneWhen, { type: "skill", skill: "workout-review", expect: "approve" }); +}); + +test("skill predicate: scores N or more carries a minScore", () => { + const file = parse(read("email_review.loop")); + const loop = file.definitions[0]; + assert.deepEqual(loop.skills, ["write-email"]); + assert.deepEqual(loop.memory, { file: "launch-email.memory.md" }); + assert.deepEqual(loop.doneWhen, { type: "skill", skill: "email-review", expect: "approve", minScore: 8 }); +}); + +test("use skills: also accepts 'and' as a separator", () => { + const loop = parse('loop "x":\n goal: g\n use skills: a and b, c').definitions[0]; + assert.deepEqual(loop.skills, ["a", "b", "c"]); +}); + +test("memory: 'keep a memory in' is an accepted alias", () => { + const loop = parse('loop "x":\n goal: g\n keep a memory in "notes.md"').definitions[0]; + assert.deepEqual(loop.memory, { file: "notes.md" }); +}); diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index fababa7..aa97c67 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { appendFileSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; import { dirname, resolve, relative } from "node:path"; import { createInterface } from "node:readline"; import { parse } from "@loop-lang/parser"; @@ -48,6 +48,12 @@ function render(e: LoopEvent): string { return ` ↺ back to ${e.to}`; case "also": return ` + also: ${e.action} → ${e.ok ? "done" : "skipped"}`; + case "memory-read": + return `↻ memory: read ${e.file} (${e.bytes}b)`; + case "memory-write": + return ` ▎ memory: wrote ${e.file}`; + case "skill-verify": + return ` = skill "${e.skill}" → ${e.passed ? "APPROVED" : "rejected"}${e.detail ? ` — ${e.detail.split("\n").pop()!.slice(0, 80)}` : ""}`; case "human": return ` ? human ${e.kind}: ${e.prompt}`; case "stop": @@ -133,6 +139,8 @@ async function main() { Promise.resolve(parse(readFileSync(resolve(dir, ref), "utf8"))); const readText = (ref: string, dir: string) => Promise.resolve(readFileSync(resolve(dir, ref), "utf8")); + const writeText = (ref: string, content: string, dir: string) => + Promise.resolve(appendFileSync(resolve(dir, ref), content, "utf8")); const src = readFileSync(path, "utf8"); let file = parse(src); @@ -187,6 +195,7 @@ async function main() { baseDir: target, loadFile, readText, + writeText, flowStack: [path], modelPolicy: file.config?.models, cliModel: model, @@ -207,6 +216,7 @@ async function main() { baseDir: target, loadFile, readText, + writeText, flowStack: [path], modelPolicy: file.config?.models, cliModel: model, diff --git a/packages/runtime/src/engine.ts b/packages/runtime/src/engine.ts index b554ec4..b2e8551 100644 --- a/packages/runtime/src/engine.ts +++ b/packages/runtime/src/engine.ts @@ -66,7 +66,44 @@ async function executeLoop(loop: Loop, opts: RunOptions): Promise { let reflection: string | null = null; let lastPlan = ""; let lastOutput = ""; + let lastActSummary = ""; let attempts = 0; + // Reflections gathered this run — the last one becomes the memory "lesson". + const reflections: string[] = []; + + // Cross-run memory: read the markdown file (if any) so past lessons feed the first plan. + const memoryFile = loop.memory?.file; + let memoryIn: string | undefined; + if (memoryFile && opts.readText) { + try { + memoryIn = await opts.readText(memoryFile, opts.baseDir); + emit(opts, { type: "memory-read", file: memoryFile, bytes: memoryIn.length }); + } catch { + memoryIn = undefined; // first run: the memory file doesn't exist yet + } + } + + const writeMemory = async (satisfied: boolean, reason: StopReason): Promise => { + if (!memoryFile || !opts.writeText) return; + const lesson = reflections.length + ? reflections[reflections.length - 1] + : satisfied + ? "Goal met cleanly; no correction needed." + : "No reflection was recorded."; + const date = new Date().toISOString().slice(0, 10); + const tries = `${attempts} ${attempts === 1 ? "try" : "tries"}`; + const entry = + [ + "", + `## ${date} — ${reason}`, + `- goal: ${loop.goal}`, + `- outcome: ${satisfied ? "satisfied" : "not satisfied"} after ${tries}`, + `- lesson: ${lesson}`, + "", + ].join("\n"); + await opts.writeText(memoryFile, entry, opts.baseDir); + emit(opts, { type: "memory-write", file: memoryFile, bytes: entry.length }); + }; // Confirm-class grants are asked once and remembered for the loop's lifetime. const grantedConfirm = new Set(); let confirmAsked = false; @@ -81,7 +118,8 @@ async function executeLoop(loop: Loop, opts: RunOptions): Promise { ? ["plan", ...(loop.cycle as CycleNode[])] : (loop.cycle as CycleNode[]); - const finish = (satisfied: boolean, reason: StopReason, warn?: string): LoopOutcome => { + const finish = async (satisfied: boolean, reason: StopReason, warn?: string): Promise => { + await writeMemory(satisfied, reason); emit(opts, { type: "stop", reason, ...(warn ? { warn } : {}) }); emit(opts, { type: "loop-end", name: loop.name, satisfied }); return { satisfied, reason, attempts, summary: lastOutput }; @@ -113,6 +151,8 @@ async function executeLoop(loop: Loop, opts: RunOptions): Promise { includeLastFailure, reflection, upstream: opts.upstream, + skills: loop.skills, + memory: memoryIn, baseDir: opts.baseDir, model: pick("plan"), }); @@ -147,14 +187,37 @@ async function executeLoop(loop: Loop, opts: RunOptions): Promise { goal: loop.goal, plan: lastPlan, allowedClasses, + skills: loop.skills, baseDir: opts.baseDir, model: pick("act"), }); + lastActSummary = res.summary; if (res.blocked) blocked = true; emit(opts, { type: "node-exit", node: step, attempt: attempts, ok: !res.blocked, detail: res.summary }); } else { // observe - const v = await opts.verifier.verify(loop.doneWhen, opts.baseDir); + let v: { passed: boolean; output: string }; + if (loop.doneWhen?.type === "skill") { + // Skill predicate: a review skill judges the goal (approved / score) — the + // "bridge the abstract to the verifiable" path, routed to the runner not the shell. + if (!opts.runner.runSkill) { + throw new Error( + `loop "${loop.name ?? ""}" verifies with the skill "${loop.doneWhen.skill}" but the runner has no runSkill` + ); + } + const r = await opts.runner.runSkill({ + skill: loop.doneWhen.skill, + goal: loop.goal, + context: lastActSummary, + expect: loop.doneWhen.expect, + minScore: loop.doneWhen.minScore, + baseDir: opts.baseDir, + }); + emit(opts, { type: "skill-verify", skill: loop.doneWhen.skill, passed: r.passed, detail: r.detail }); + v = { passed: r.passed, output: r.detail }; + } else { + v = await opts.verifier.verify(loop.doneWhen, opts.baseDir); + } observed = true; passed = v.passed; observeOutput = v.output; @@ -177,9 +240,17 @@ async function executeLoop(loop: Loop, opts: RunOptions): Promise { const stop = await applyActions(t.do, loop, opts, { goalMet, output: observeOutput, - setReflection: (r) => (reflection = r), + setReflection: (r) => { + reflection = r; + reflections.push(r); + }, }); - if (stop) return stop; + if (stop) { + // applyActions builds the terminal outcome directly (it owns the warn/veto logic), + // so record memory here rather than in finish(). + await writeMemory(stop.satisfied, stop.reason); + return stop; + } continue; // re-enter the cycle (the back-edge) } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 975ef7b..78902dc 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -6,7 +6,7 @@ export { CliHumanIO, ScriptedHumanIO } from "./human.js"; export type { ScriptedAnswers } from "./human.js"; export { MockRunner } from "./runners/mock.js"; export type { MockRunnerOptions } from "./runners/mock.js"; -export { ClaudeCodeRunner, interpretStreamLine, buildPlanPrompt, claudeArgs } from "./runners/claudeCode.js"; +export { ClaudeCodeRunner, interpretStreamLine, buildPlanPrompt, claudeArgs, parseSkillVerdict } from "./runners/claudeCode.js"; export type { ClaudeCodeRunnerOptions, AgentNode } from "./runners/claudeCode.js"; export { IpcHumanIO } from "./ipc.js"; export type { HumanRequest } from "./ipc.js"; diff --git a/packages/runtime/src/runners/claudeCode.ts b/packages/runtime/src/runners/claudeCode.ts index 7370c55..5caffb5 100644 --- a/packages/runtime/src/runners/claudeCode.ts +++ b/packages/runtime/src/runners/claudeCode.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import type { ActInput, ActResult, PlanInput, ReflectInput, Runner } from "../types.js"; +import type { ActInput, ActResult, PlanInput, ReflectInput, Runner, SkillVerifyInput } from "../types.js"; /** Maps loop action-classes to Claude Code tool grants. Read tools are always allowed. */ const READ_TOOLS = ["Read", "Grep", "Glob"]; @@ -58,6 +58,8 @@ export function buildPlanPrompt(input: PlanInput): string { const ctx: string[] = []; if (input.files.length) ctx.push(`Relevant context (each item is a file or a description of one — locate the actual file(s) first): ${input.files.join(", ")}.`); if (input.includeLastFailure) ctx.push("Account for the most recent failure."); + if (input.skills?.length) ctx.push(`You may use these skills to do the work: ${input.skills.join(", ")}.`); + if (input.memory) ctx.push(`Lessons from past runs (do not repeat these mistakes):\n${input.memory}`); if (input.reflection) ctx.push(`From the last attempt: ${input.reflection}`); if (input.upstream) ctx.push(`From the previous step in the flow:\n${input.upstream}`); return [ @@ -194,11 +196,14 @@ export class ClaudeCodeRunner implements Runner { for (const t of CLASS_TOOLS[cls] ?? []) tools.add(t); if (cls === "edit") canEdit = true; } + // Grant the Skill tool so the agent can actually invoke the loop's `use skills:` skills. + if (input.skills?.length) tools.add("Skill"); const mode = canEdit ? "acceptEdits" : "default"; const prompt = [ `Goal: ${input.goal}.`, `Execute this plan, making the smallest coherent change:`, input.plan, + ...(input.skills?.length ? [`You may use these skills: ${input.skills.join(", ")}.`] : []), `Only use the capabilities you have been granted.`, ].join("\n"); const summary = await this.run( @@ -221,6 +226,45 @@ export class ClaudeCodeRunner implements Runner { ].join("\n"); return this.run(prompt, ["--permission-mode", "plan", "--allowedTools", ...READ_TOOLS], input.baseDir, "reflect", input.model); } + + async runSkill(input: SkillVerifyInput): Promise<{ passed: boolean; detail: string }> { + const verdictLine = + input.minScore !== undefined + ? `End with a line exactly "SCORE: N" where N is 0-10. It passes when N >= ${input.minScore}.` + : `End with a line exactly "VERDICT: APPROVED" or "VERDICT: REJECTED".`; + const prompt = [ + `Use the "${input.skill}" skill to review this against the goal.`, + `Goal: ${input.goal}.`, + `What to review:`, + input.context || "(no output captured)", + verdictLine, + ].join("\n"); + const out = await this.run( + prompt, + // Grant the Skill tool so the named review skill can actually run. + ["--permission-mode", "plan", "--allowedTools", ...READ_TOOLS, "Skill"], + input.baseDir, + "reflect" + ); + return parseSkillVerdict(out, input.minScore); + } +} + +/** Parse a review skill's reply into a pass/fail verdict. Pure + exported for tests. */ +export function parseSkillVerdict(out: string, minScore?: number): { passed: boolean; detail: string } { + const detail = out.trim(); + if (minScore !== undefined) { + const m = detail.match(/SCORE:\s*(\d+(?:\.\d+)?)/i); + const score = m ? parseFloat(m[1]) : NaN; + return { passed: Number.isFinite(score) && score >= minScore, detail }; + } + // Prefer the explicit verdict line we ask for; fall back to a negation-aware heuristic so a + // reply like "not approved" isn't misread as an approval. + if (/VERDICT:\s*APPROVED/i.test(detail)) return { passed: true, detail }; + if (/VERDICT:\s*REJECTED/i.test(detail)) return { passed: false, detail }; + const negated = /\b(not|never|reject\w*|disapprov\w*|denied|deny)\b/i.test(detail); + const affirmed = /\bapprove[ds]?\b/i.test(detail) || /\blgtm\b/i.test(detail) || /looks good/i.test(detail); + return { passed: affirmed && !negated, detail }; } function extractResult(stdout: string): string { diff --git a/packages/runtime/src/runners/mock.ts b/packages/runtime/src/runners/mock.ts index c6d50da..6ffaece 100644 --- a/packages/runtime/src/runners/mock.ts +++ b/packages/runtime/src/runners/mock.ts @@ -1,9 +1,10 @@ -import type { ActInput, ActResult, PlanInput, ReflectInput, Runner } from "../types.js"; +import type { ActInput, ActResult, PlanInput, ReflectInput, Runner, SkillVerifyInput } from "../types.js"; export interface MockRunnerOptions { planText?: (input: PlanInput) => string; act?: (input: ActInput) => ActResult; reflectText?: (input: ReflectInput) => string; + skill?: (input: SkillVerifyInput) => { passed: boolean; detail: string }; } /** @@ -15,6 +16,7 @@ export class MockRunner implements Runner { public readonly planCalls: PlanInput[] = []; public readonly actCalls: ActInput[] = []; public readonly reflectCalls: ReflectInput[] = []; + public readonly skillCalls: SkillVerifyInput[] = []; constructor(private opts: MockRunnerOptions = {}) {} @@ -30,4 +32,8 @@ export class MockRunner implements Runner { this.reflectCalls.push(input); return this.opts.reflectText?.(input) ?? `reflection (${input.focus ?? "general"})`; } + async runSkill(input: SkillVerifyInput): Promise<{ passed: boolean; detail: string }> { + this.skillCalls.push(input); + return this.opts.skill?.(input) ?? { passed: true, detail: `${input.skill}: APPROVED` }; + } } diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index e3b6183..9bca66d 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -14,6 +14,9 @@ export type LoopEvent = | { type: "reflect"; focus?: string; text: string } | { type: "loop-back"; to: string } | { type: "also"; action: string; ok: boolean; detail?: string } + | { type: "memory-read"; file: string; bytes: number } + | { type: "memory-write"; file: string; bytes: number } + | { type: "skill-verify"; skill: string; passed: boolean; detail: string } | { type: "human"; kind: "plan" | "review" | "gate" | "confirm" | "ask"; prompt: string; answer?: string } | { type: "stop"; reason: StopReason; warn?: string } | { type: "loop-end"; name: string | null; satisfied: boolean } @@ -39,6 +42,10 @@ export interface PlanInput { reflection: string | null; /** Text summary handed from the previous step of a flow (set by executeFlow). */ upstream?: string; + /** Named execution skills the loop may use (the loop's `use skills:` list). */ + skills?: string[]; + /** Lessons from past runs, read from the loop's memory file. */ + memory?: string; baseDir: string; /** Model alias/id for this call; set by the engine from the model policy. */ model?: string; @@ -48,11 +55,26 @@ export interface ActInput { goal: string; plan: string; allowedClasses: string[]; + /** Named execution skills the loop may use while acting. */ + skills?: string[]; baseDir: string; /** Model alias/id for this call; set by the engine from the model policy. */ model?: string; } +export interface SkillVerifyInput { + /** The review skill to invoke. */ + skill: string; + goal: string; + /** The work to judge — typically the last act summary. */ + context: string; + /** "approve" requires an approving verdict. */ + expect: "approve"; + /** When set, the verdict passes only if the skill's numeric score is at least this. */ + minScore?: number; + baseDir: string; +} + export interface ActResult { summary: string; blocked?: boolean; @@ -72,6 +94,8 @@ export interface Runner { plan(input: PlanInput): Promise; act(input: ActInput): Promise; reflect(input: ReflectInput): Promise; + /** Invoke a review skill to judge the goal — the `done when the skill "X" approves` predicate. */ + runSkill?(input: SkillVerifyInput): Promise<{ passed: boolean; detail: string }>; } export interface VerifyResult { @@ -108,8 +132,10 @@ export interface RunOptions { hardCap?: number; /** Loads + parses a referenced .loop file. Required only when a `flow` runs. */ loadFile?(path: string, baseDir: string): Promise; - /** Reads the raw text of a `for each` source data file. Required only when a flow uses `for each`. */ + /** Reads the raw text of a `for each` source data file, or a loop's memory file. */ readText?(path: string, baseDir: string): Promise; + /** Appends to a loop's memory file. Required only when a loop declares `remember in`. */ + writeText?(path: string, content: string, baseDir: string): Promise; /** Upstream handoff text injected into each plan step (set by executeFlow). */ upstream?: string; /** Resolved file paths currently executing — for flow cycle detection. */ diff --git a/packages/runtime/src/verify.ts b/packages/runtime/src/verify.ts index c83bbd6..67dd531 100644 --- a/packages/runtime/src/verify.ts +++ b/packages/runtime/src/verify.ts @@ -47,6 +47,12 @@ export class ShellVerifier implements Verifier { return { passed: false, output: `human check required: ${predicate.description}` }; } + if (predicate.type === "skill") { + // The engine routes skill predicates to the runner's runSkill before reaching here; + // if one arrives, the runner can't judge it, so it stays unsatisfied. + return { passed: false, output: `skill review required: ${predicate.skill}` }; + } + let command: string; let expectEmpty = false; if (predicate.type === "test") { diff --git a/packages/runtime/test/engine.test.js b/packages/runtime/test/engine.test.js index fab915e..cdced58 100644 --- a/packages/runtime/test/engine.test.js +++ b/packages/runtime/test/engine.test.js @@ -477,3 +477,165 @@ test("models: flow sub-file's own models: block is honored (not silently dropped assert.equal(runner.actCalls[0].model, "opus", "act should use the sub-file's strong model"); assert.equal(runner.planCalls[0].model, "haiku", "plan should use the sub-file's fast model"); }); + +// ---- skills + memory ---- + +/** In-memory text store for memory read/write without touching disk. */ +function memStore(initial = {}) { + const files = { ...initial }; + return { + files, + readText: async (ref) => { + if (!(ref in files)) throw new Error("no such file " + ref); + return files[ref]; + }, + writeText: async (ref, content) => { + files[ref] = (files[ref] ?? "") + content; + }, + }; +} + +test("memory: lessons are read into the first plan and an entry is appended on stop", async () => { + const def = parse(read("skills_memory.loop")).definitions[0]; + const runner = new MockRunner(); + const store = memStore({ "morning-run.memory.md": "lesson: light drizzle is fine" }); + const { events, onEvent } = collect(); + const outcome = await runDefinition(def, { + runner, + verifier: new SeqVerifier([true]), // unused; skill predicate routes to runner + human: new ScriptedHumanIO(), + baseDir: process.cwd(), + readText: store.readText, + writeText: store.writeText, + onEvent, + }); + assert.equal(outcome.satisfied, true); + // memory threaded into the first plan + assert.match(runner.planCalls[0].memory, /light drizzle is fine/); + assert.ok(events.some((e) => e.type === "memory-read")); + // an entry was appended on stop + assert.ok(events.some((e) => e.type === "memory-write")); + assert.match(store.files["morning-run.memory.md"], /## \d{4}-\d{2}-\d{2} — done/); + assert.match(store.files["morning-run.memory.md"], /outcome: satisfied/); +}); + +test("memory: first run with no file still records an entry on stop", async () => { + const def = parse(read("skills_memory.loop")).definitions[0]; + const runner = new MockRunner(); + const store = memStore(); // no memory file yet + await runDefinition(def, { + runner, + verifier: new SeqVerifier([true]), + human: new ScriptedHumanIO(), + baseDir: process.cwd(), + readText: store.readText, + writeText: store.writeText, + }); + assert.equal(runner.planCalls[0].memory, undefined, "no memory to thread on a first run"); + assert.match(store.files["morning-run.memory.md"], /outcome: satisfied/, "memory created on stop"); +}); + +test("memory: a thrash run records the last reflection as the lesson", async () => { + const def = parse(read("skills_memory.loop")).definitions[0]; + // skill always rejects -> the loop reflects + replans until the thrash guard fires. + const runner = new MockRunner({ + reflectText: () => "the review wanted a firmer reason", + skill: (input) => ({ passed: false, detail: `${input.skill}: REJECTED` }), + }); + const store = memStore(); + const outcome = await runDefinition(def, { + runner, + verifier: new SeqVerifier([false]), + human: new ScriptedHumanIO(), + baseDir: process.cwd(), + readText: store.readText, + writeText: store.writeText, + }); + assert.equal(outcome.satisfied, false); + assert.equal(outcome.reason, "thrash"); + assert.match(store.files["morning-run.memory.md"], /lesson: the review wanted a firmer reason/); + assert.match(store.files["morning-run.memory.md"], /## \d{4}-\d{2}-\d{2} — thrash/); +}); + +test("skill predicate: routes to runSkill and its verdict drives the loop", async () => { + const def = parse(read("skills_memory.loop")).definitions[0]; + // reject twice, then approve + const seq = [false, false, true]; + let i = 0; + const runner = new MockRunner({ skill: (input) => { + const passed = seq[Math.min(i++, seq.length - 1)]; + return { passed, detail: `${input.skill}: ${passed ? "APPROVED" : "REJECTED"}` }; + }}); + const store = memStore(); + const { events, onEvent } = collect(); + const outcome = await runDefinition(def, { + runner, + verifier: new SeqVerifier([false]), // would fail if the skill path were bypassed + human: new ScriptedHumanIO(), + baseDir: process.cwd(), + readText: store.readText, + writeText: store.writeText, + onEvent, + }); + assert.equal(outcome.satisfied, true); + assert.equal(outcome.reason, "done"); + assert.equal(runner.skillCalls.length, 3, "the review skill was invoked each cycle"); + assert.equal(runner.skillCalls[0].skill, "workout-review"); + assert.ok(events.some((e) => e.type === "skill-verify" && e.passed === true)); + // the act summary was handed to the review as context + assert.match(runner.skillCalls.at(-1).context, /acted on/); +}); + +test("skill predicate: a missing runSkill throws a clear error", async () => { + const def = parse(read("skills_memory.loop")).definitions[0]; + const runner = new MockRunner(); + // remove runSkill to simulate a runner that doesn't support it + runner.runSkill = undefined; + await assert.rejects( + () => runDefinition(def, { + runner, + verifier: new SeqVerifier([true]), + human: new ScriptedHumanIO(), + baseDir: process.cwd(), + }), + /has no runSkill/ + ); +}); + +test("skill predicate: minScore threshold passes via parseSkillVerdict", async () => { + const def = parse(read("email_review.loop")).definitions[0]; + const runner = new MockRunner({ skill: (input) => ({ + passed: input.minScore !== undefined, + detail: `SCORE: 9 (min ${input.minScore})`, + })}); + const outcome = await runDefinition(def, { + runner, + verifier: new SeqVerifier([false]), + human: new ScriptedHumanIO({ defaults: { review: true } }), + baseDir: process.cwd(), + readText: async () => { throw new Error("none"); }, + writeText: async () => {}, + }); + assert.equal(runner.skillCalls[0].minScore, 8, "minScore threaded from the predicate"); + assert.equal(outcome.satisfied, true); +}); + +test("regression: a loop with no skills/memory behaves exactly as before", async () => { + const def = parse(read("fix_test.loop")).definitions[0]; + const runner = new MockRunner(); + const store = memStore(); + const { events, onEvent } = collect(); + const outcome = await runDefinition(def, { + runner, + verifier: new SeqVerifier([false, false, true]), + human: new ScriptedHumanIO(), + baseDir: process.cwd(), + writeText: store.writeText, + onEvent, + }); + assert.equal(outcome.satisfied, true); + assert.equal(runner.skillCalls.length, 0, "no skill calls without a skill predicate"); + assert.equal(runner.planCalls[0].memory, undefined, "no memory threaded"); + assert.ok(!events.some((e) => e.type === "memory-read" || e.type === "memory-write")); + assert.deepEqual(store.files, {}, "no memory file written"); +}); diff --git a/packages/runtime/test/runners.test.js b/packages/runtime/test/runners.test.js index 876752b..1132616 100644 --- a/packages/runtime/test/runners.test.js +++ b/packages/runtime/test/runners.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildPlanPrompt, claudeArgs } from "../dist/index.js"; +import { buildPlanPrompt, claudeArgs, parseSkillVerdict } from "../dist/index.js"; test("buildPlanPrompt includes the upstream handoff when present", () => { const p = buildPlanPrompt({ goal: "g", files: [], includeLastFailure: false, reflection: null, upstream: "[build] satisfied\nok", baseDir: "." }); @@ -27,3 +27,28 @@ test("claudeArgs includes --model only when a model is given", () => { const noModel = claudeArgs({ stream: false, baseDir: "/x", flags: [] }); assert.ok(!noModel.includes("--model")); }); + +test("buildPlanPrompt surfaces skills and memory blocks", () => { + const p = buildPlanPrompt({ + goal: "g", files: [], includeLastFailure: false, reflection: null, + skills: ["check-weather", "analyze-workout"], memory: "lesson: drizzle is fine", baseDir: ".", + }); + assert.match(p, /you may use these skills.*check-weather, analyze-workout/i); + assert.match(p, /lessons from past runs/i); + assert.match(p, /drizzle is fine/); +}); + +test("parseSkillVerdict: VERDICT: APPROVED passes, REJECTED fails", () => { + assert.equal(parseSkillVerdict("looks good\nVERDICT: APPROVED").passed, true); + assert.equal(parseSkillVerdict("not yet\nVERDICT: REJECTED").passed, false); +}); + +test("parseSkillVerdict: 'not approved' is not misread as an approval", () => { + assert.equal(parseSkillVerdict("this is not approved").passed, false); +}); + +test("parseSkillVerdict: a SCORE meeting the threshold passes", () => { + assert.equal(parseSkillVerdict("SCORE: 9", 8).passed, true); + assert.equal(parseSkillVerdict("SCORE: 7", 8).passed, false); + assert.equal(parseSkillVerdict("no number here", 8).passed, false); +}); diff --git a/packages/viz/src/render.ts b/packages/viz/src/render.ts index b9562bb..4ec0fe0 100644 --- a/packages/viz/src/render.ts +++ b/packages/viz/src/render.ts @@ -118,7 +118,7 @@ function chip(x,y,text,color){var w=7.2*text.length+14; return [V("rect",{x:x,y:y,width:w,height:17,rx:8.5,fill:"none",stroke:color,"stroke-width":1}) +E("text",{x:x+w/2,y:y+12,"text-anchor":"middle",class:"chip",fill:color},esc(text)),w]; } -function doneLabel(dw){if(!dw)return "awaiting human";if(dw.type==="test")return "test "+dw.target;if(dw.type==="command")return (dw.expect==="empty"?"empty: ":"")+dw.command;return "human: "+(dw.description||"");} +function doneLabel(dw){if(!dw)return "awaiting human";if(dw.type==="test")return "test "+dw.target;if(dw.type==="command")return (dw.expect==="empty"?"empty: ":"")+dw.command;if(dw.type==="skill")return "skill "+dw.skill+(dw.minScore!==undefined?" ≥"+dw.minScore:"");return "human: "+(dw.description||"");} function loopRow(loop,ox,oy){ var s="",cyc=loop.cycle&&loop.cycle.length?loop.cycle:["plan","act","observe"],xs=[],i; for(i=0;i" passes` · `done when "" finds nothing`.' }, + { label: "done when", insert: 'done when the test "${1:file::name}" passes', detail: "verification predicate", doc: 'How the loop verifies itself: `done when the test "x" passes` · `done when "" passes` · `done when "" finds nothing` · `done when the skill "review" approves`.' }, + { label: "done when the skill", insert: 'done when the skill "${1:review}" approves', detail: "skill-as-verifier", doc: 'A review skill judges the goal and returns a verdict: `done when the skill "x" approves` or `done when the skill "x" scores 8 or more`. Bridges an abstract goal to a verifiable check.' }, { label: "look at:", insert: "look at: ${1:fileA, fileB}, and the last failure", detail: "context to read", doc: "Files the agent should read before acting — scope it to your architecture." }, + { label: "use skills:", insert: "use skills: ${1:skill-a, skill-b}", detail: "execution skills", doc: "Named skills the loop may invoke during plan/act — coordinate proven skills instead of one mega-prompt." }, + { label: "remember in", insert: 'remember in "${1:loop.memory.md}"', detail: "cross-run memory", doc: "A markdown file the loop reads on start (its lessons feed the first plan) and appends an outcome entry to on stop." }, { label: "allow / ask", insert: "allow edits automatically, but ask me before ${1:migrations or pushes}", detail: "action policy", doc: "Which actions run automatically vs pause for confirmation." }, { label: "each cycle:", insert: "each cycle: plan, then act, then observe", detail: "the repeated steps", doc: "The cycle, any subset of plan / act / observe, in order." }, { label: "also:", insert: "also: ${1:polish, run a security check}", detail: "finishing passes", doc: "Extra passes run after the goal is met (skipped on failure)." }, @@ -172,8 +175,11 @@ const HOVERS: Record = { pipeline: "**pipeline** — stages run in order; a failing stage halts the rest. Epic → pipeline, story → stage.", stage: "**stage** — one stage of a pipeline; its body is a loop.", goal: "**goal** — what 'done' means, in plain language.", - done: "**done when** — the predicate the loop checks: a test, a shell command (`passes` / `finds nothing`), or a human.", + done: "**done when** — the predicate the loop checks: a test, a shell command (`passes` / `finds nothing`), a human, or a review skill (`the skill \"x\" approves` / `scores N or more`).", + skill: "**the skill \"x\"** — a review skill judges the goal and returns a verdict (approved / a score), bridging an abstract goal to a verifiable check.", "look": "**look at** — files the agent reads before acting; scope it to the relevant module.", + skills: "**use skills** — named skills the loop may invoke during plan/act; coordinate proven skills instead of one mega-prompt.", + remember: "**remember in** — a markdown file the loop reads on start (past lessons feed the first plan) and appends an outcome entry to on stop. Cross-run memory.", each: "**each cycle** — the repeated steps (plan / act / observe, any subset, in order).", also: "**also** — extra finishing passes run after the goal is met (skipped on failure).", when: "**when …** — a transition evaluated after observe: `when it fails`, `when it passes …`, `when blocked`.", diff --git a/packages/vscode/syntaxes/loop.tmLanguage.json b/packages/vscode/syntaxes/loop.tmLanguage.json index 1e4f3d7..865b5a6 100644 --- a/packages/vscode/syntaxes/loop.tmLanguage.json +++ b/packages/vscode/syntaxes/loop.tmLanguage.json @@ -27,11 +27,11 @@ "name": "keyword.control.structure.loop" }, "sections": { - "match": "(?i)\\b(goal|done when|look at|each cycle|plan from)\\b", + "match": "(?i)\\b(goal|done when|look at|each cycle|plan from|use skills?|remember in|keep a memory in|also)\\b", "name": "keyword.other.section.loop" }, "flow": { - "match": "(?i)\\b(when|after|reflect|stop|warn|plan|act|observe|tries|run|then|each|in)\\b", + "match": "(?i)\\b(when|after|reflect|stop|warn|plan|act|observe|tries|run|then|each|in|the skill|approves|scores|or more)\\b", "name": "keyword.control.flow.loop" }, "human": { diff --git a/spec/loop-spec.schema.json b/spec/loop-spec.schema.json index c78f294..c0d9a88 100644 --- a/spec/loop-spec.schema.json +++ b/spec/loop-spec.schema.json @@ -177,6 +177,20 @@ "description": "Extra finishing passes run in order after the goal is met (e.g. \"polish\", \"run a security check\", \"update the docs\"). Lightweight agent passes; promote to a stage when one needs its own verification.", "items": { "type": "string" } }, + "skills": { + "type": "array", + "description": "Named execution skills the loop may invoke during plan/act (e.g. [\"check-weather\", \"analyze-workout\"]). The runner surfaces them as available capabilities to coordinate, instead of one mega-prompt.", + "items": { "type": "string" } + }, + "memory": { + "description": "Cross-run memory: a markdown file the loop reads on start (its lessons feed the first plan) and appends an outcome entry to on stop.", + "type": "object", + "additionalProperties": false, + "required": ["file"], + "properties": { + "file": { "type": "string", "description": "Markdown file, resolved relative to the loop file." } + } + }, "planSource": { "description": "Where the `plan` step gets its plan. Default (omitted) = the agent generates it. \"file\" reads the plan from a file (`plan from \"\"`), so the loop executes a plan you control. The `type` enum is open to future sources (a URL, a command).", "type": "object", @@ -216,20 +230,28 @@ "properties": { "type": { "type": "string", - "enum": ["test", "command", "human"] + "enum": ["test", "command", "human", "skill"] }, "target": { "type": "string", "description": "For type=test: the test id, e.g. \"billing.spec.ts::apostrophe\"." }, + "skill": { + "type": "string", + "description": "For type=skill: the review skill name. It is invoked to judge the goal and return an approved/score verdict (\"bridge the abstract to the verifiable\")." + }, + "minScore": { + "type": "integer", + "description": "For type=skill: when present (\"scores N or more\"), the verdict passes only when the skill's numeric score is at least this. Absent means a plain approve/reject." + }, "command": { "type": "string", "description": "For type=command: the shell command. Default success = exit 0." }, "expect": { "type": "string", - "enum": ["exit-zero", "empty"], - "description": "For type=command: \"empty\" encodes \"finds nothing\" (no stdout). Defaults to exit-zero.", + "enum": ["exit-zero", "empty", "approve"], + "description": "For type=command: \"empty\" encodes \"finds nothing\" (no stdout); defaults to exit-zero. For type=skill: \"approve\" (the verdict must be approving).", "default": "exit-zero" }, "description": {