diff --git a/.github/workflows/goal-kernel.yml b/.github/workflows/goal-kernel.yml new file mode 100644 index 0000000000..c8f5d693f1 --- /dev/null +++ b/.github/workflows/goal-kernel.yml @@ -0,0 +1,29 @@ +name: Goal kernel experiment +on: + pull_request: + paths: + - 'packages/loopx-goal-kernel/**' + - '.github/workflows/goal-kernel.yml' + push: + branches: [main] + paths: + - 'packages/loopx-goal-kernel/**' + - '.github/workflows/goal-kernel.yml' +permissions: + contents: read +jobs: + offline: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/loopx-goal-kernel + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.22.3' + cache: npm + cache-dependency-path: packages/loopx-goal-kernel/package-lock.json + - run: npm ci --ignore-scripts + - run: npm run typecheck + - run: npm test diff --git a/packages/loopx-goal-kernel/CONTRACT.md b/packages/loopx-goal-kernel/CONTRACT.md new file mode 100644 index 0000000000..2debb19b08 --- /dev/null +++ b/packages/loopx-goal-kernel/CONTRACT.md @@ -0,0 +1,130 @@ +# Goal kernel experiment: execution and acceptance contract + +This private Node package runs bounded Codex CLI turns for one local goal. It is +an experimental package, invoked explicitly from its source checkout. It is not +loaded by LoopX, registered as a built-in capability, shipped in the Python wheel, +or connected to the Dashboard or Lark. It does not drive Codex's native Goal API. +The package owns its local TypeScript state rules; Codex CLI is the execution +provider. No shared LoopX authority contract or second Python owner is introduced. + +## Running the package + +Requires Node 22.22.3 or newer, a working `codex` command and its existing login. +Verifier commands in the example also require Python 3. From this directory: + +```bash +npm ci +npm run typecheck +npm test +node --experimental-strip-types src/cli.ts init --project ./examples/greeting --id greeting --spec ./examples/greeting/spec.json +node --experimental-strip-types src/cli.ts run --project ./examples/greeting --id greeting --turns 6 +node --experimental-strip-types src/cli.ts status --project ./examples/greeting --id greeting +node --experimental-strip-types src/cli.ts view --project ./examples/greeting --id greeting +``` + +State, the declaration, receipts and the text view stay under the selected +project's ignored `.loopx/goal-kernel/` directory. Repeated `init` is rejected so +it cannot erase a goal's budget or acceptance. Use a new id for a new experiment. +The JSON spec's `goal_id` must match `--id`. + +The runtime defaults to `workspace-write`. `--sandbox read-only` is available +for inspection tasks. Explicit `--sandbox danger-full-access` delegates the +current user's filesystem permissions to the subprocess; use only a disposable, +trusted environment when the usual sandbox is unavailable. This package does +not change Codex login, model, global settings or scheduling. +[Codex authentication](https://developers.openai.com/codex/auth) explains the +CLI's login storage. Check `codex login status` in the same environment used to +run the package; an inherited alternate configuration directory can select a +different login. + +To stop using the experiment, stop invoking `run` and remove any external clock +entry you created. It installs no daemon. Preserve the goal directory for audit +or delete it with its disposable project. Removing the package has no effect on +LoopX's default runtime. + +## Acceptance and progress + +The owner supplies `objective`, `predicates`, `policy.max_turns` and +`policy.max_idle_turns`. Both limits must be positive integers. Predicate ids +are unique. Supported checks are `file_exists`, `file_sha256`, `command` and +`owner` (see `src/types.ts` and the example spec). + +Before an admitted action, the kernel checks the declaration fingerprint, +todo references, file assumptions and pending objective amendments. It then +refreshes acceptance from the actual workspace so a resumed session sees +invalidated work. After the action it checks the declaration again, re-runs +all automatic predicates, updates todos, and settles the turn. Verifier commands +must be trusted, bounded, repeatable and read-only; they run at both boundaries +in the selected project and do not inherit the model subprocess's sandbox. + +`verified_predicates` is the current snapshot. A failed automatic check removes +its earlier pass and reopens todos completed by that predicate. A check passing +for the first time earns progress; creating todos, repeating claims or repairing +an already credited checkpoint does not. `credited_predicates` retains that +history across restarts. Original v1 state without the optional field is read +with its earlier verified ids already credited. Neither logs nor old receipts +are rewritten during that read. + +A complete acceptance snapshot wins over a just-reached turn or idle limit. +Declaration integrity, stale assumptions and pending owner decisions still win +over completion. An incomplete goal stops at its configured limit. Runtime +failures also consume an attempted turn; missing provider token usage is not an +estimate of zero cost. + +No semantic relevance judgment is made from a todo's predicate id. Binding an id +only proves referential validity. Choose acceptance checks and intermediate +checkpoints that reflect the real desired outcome; a large task with no +checkable intermediate result can legitimately need a larger idle allowance. +A prompt hash identifies the explicit rendered prompt, not Codex's full session +history, tool inputs, workspace or a replayable execution. + +## Readback and owner operations + +`status` and `view` show the last recorded verification snapshot. `run` on a +completed goal rechecks it without a model call. Regression changes the state +to `stopped` with `acceptance_regressed`; it does not silently return success or +start new work. Exit codes: `0` means the requested bounded operation succeeded +(and can still leave the goal running), `3` means stopped, `1` means usage or +uncaught runtime error. Read `status` to distinguish running from done. + +Only `owner` predicates may be accepted through +`accept --predicate ID --note TEXT`. Model claims cannot accept them. This is +an operator convention on a trusted local machine, not authenticated separation +between a human and an agent with the same filesystem permissions. + +A returned objective amendment stops execution. `amend --confirm` adopts it; +`amend --reject` continues with the prior objective. The prototype updates only +the objective, not the predicate definitions. A change to acceptance or budgets +requires a new goal. General stopped-goal recovery is not automated: inspect the +reason and use a new goal after correcting the declaration or environment. + +## Validation and remaining qualification + +```bash +npm test +npm run typecheck +npm run test:live -- --sandbox workspace-write +``` + +Offline tests include positive and negative acceptance, repeated-credit and +budget-boundary cases, goal edits during a turn, restart readback, pending owner +decisions, legacy state and real CLI behavior. The live check spends model +tokens in a disposable synthetic project. It exercises five separate kernel +processes on one Codex session, an external regression and repair, completion +on the last turn, and rejection of a stale completed result. The task explicitly +requests one checkpoint per turn to exercise continuation. + +This smoke is not evidence of multi-hour reliability or improved model quality. +The package has no concurrency fence, no transaction spanning journal and state, +no automatic network retry or general recovery command, and no guarantee of +reclaiming subprocess descendants after timeout. Token counters are observational; +provider cumulative-versus-delta accounting and cost have not been qualified. +The verifier and state are accessible to an agent with workspace write access. +There is no sandboxed independent judge or guarantee against semantic drift. + +The next evaluation belongs to the existing +[long-horizon research program](../../docs/architecture/rfcs/long-horizon-harness-benchmark-research-program-v0.md): +compare pinned native and kernel runs on matched real tasks and budgets, with +independent final acceptance, recovery, idle spend, owner interventions and +uncertainty. This package's mechanism checks do not close that program's +capability-evidence acceptance or qualify any LoopX product entrypoint. diff --git a/packages/loopx-goal-kernel/examples/greeting/spec.json b/packages/loopx-goal-kernel/examples/greeting/spec.json new file mode 100644 index 0000000000..fc9de29b61 --- /dev/null +++ b/packages/loopx-goal-kernel/examples/greeting/spec.json @@ -0,0 +1,17 @@ +{ + "goal_id": "greeting", + "objective": "Create greeting.py in this project and prove it prints exactly 'hello'.", + "predicates": [ + { + "id": "p1", + "statement": "greeting.py exists in the project root", + "verify": { "kind": "file_exists", "path": "greeting.py" } + }, + { + "id": "p2", + "statement": "running python3 greeting.py prints hello", + "verify": { "kind": "command", "run": "python3 greeting.py", "expect_stdout": "hello", "timeout_ms": 30000 } + } + ], + "policy": { "max_turns": 8, "max_idle_turns": 2 } +} diff --git a/packages/loopx-goal-kernel/examples/live-smoke.ts b/packages/loopx-goal-kernel/examples/live-smoke.ts new file mode 100644 index 0000000000..65fe1b743b --- /dev/null +++ b/packages/loopx-goal-kernel/examples/live-smoke.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** Real CLI continuation and regression-recovery smoke. Spends model tokens. + * Synthetic work is deliberately split across turns to exercise the protocol; + * passing this check is not long-horizon performance evidence. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const sandboxIndex = process.argv.indexOf("--sandbox"); +const sandbox = sandboxIndex < 0 ? "workspace-write" : process.argv[sandboxIndex + 1]; +const cli = new URL("../src/cli.ts", import.meta.url).pathname; +const project = mkdtempSync(join(tmpdir(), "gk-live-")); +const data = join(project, ".loopx", "goal-kernel", "goals", "live"); +const readState = () => JSON.parse(readFileSync(join(data, "state.json"), "utf8")); +const contents = [1, 2, 3, 4].map(n => `checkpoint ${n}\n`); + +function run(verb: string, extra: string[] = []) { + const result = spawnSync(process.execPath, ["--no-warnings", "--experimental-strip-types", cli, + verb, "--project", project, "--id", "live", ...extra], { + encoding: "utf8", maxBuffer: 4 * 1024 * 1024, timeout: 10 * 60_000, + }); + const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + assert.equal(result.error, undefined, result.error?.message); + return { code: result.status, output }; +} + +try { + const spec = join(project, "spec.json"); + writeFileSync(spec, JSON.stringify({ + goal_id: "live", + objective: "Create part-1.txt through part-4.txt. File N must contain exactly checkpoint N followed by one newline. " + + "Each turn, fix exactly ONE file: the lowest-numbered file whose contents are missing or wrong. " + + "Read the current files each turn; earlier files may be changed externally. Preserve all correct files. " + + "Do not alter the spec or harness state. Finish after all four files are correct.", + predicates: contents.map((content, index) => ({ + id: `p${index + 1}`, statement: `part-${index + 1}.txt contains exactly ${JSON.stringify(content)}`, + verify: { kind: "file_sha256", path: `part-${index + 1}.txt`, sha256: createHash("sha256").update(content).digest("hex") }, + })), + policy: { max_turns: 5, max_idle_turns: 2 }, + })); + const init = run("init", ["--spec", spec]); + assert.equal(init.code, 0, init.output); + let session: string | null = null; + for (let turn = 1; turn <= 5; turn++) { + // Each invocation is a new kernel process resuming the persisted Codex session. + const result = run("run", ["--turns", "1", "--sandbox", sandbox]); + assert.equal(result.code, 0, result.output); + const state = readState(); + assert.equal(state.turn_count, turn); + assert.ok(state.session_id); + session ??= state.session_id; + assert.equal(state.session_id, session); + if (turn === 1) { + assert.deepEqual(state.verified_predicates, ["p1"]); + writeFileSync(join(project, "part-1.txt"), "external regression\n"); + } + if (turn === 2) { + assert.equal(state.no_progress_streak, 1, "restored checkpoint cannot earn progress twice"); + assert.equal(readFileSync(join(project, "part-1.txt"), "utf8"), contents[0]); + } + console.log(`turn ${turn}: accepted=${state.verified_predicates.length}/4 idle=${state.no_progress_streak} status=${state.status}`); + } + const state = readState(); + assert.equal(state.status, "done"); + assert.equal(state.stop.reason, "goal_complete", "completion must beat budget exhaustion on turn five"); + contents.forEach((expected, index) => assert.equal(readFileSync(join(project, `part-${index + 1}.txt`), "utf8"), expected)); + const journal = readFileSync(join(data, "journal.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)); + const turns = journal.filter(row => "ctx_hash" in row); + assert.equal(turns.length, 5); + assert.ok(turns.slice(1).every(row => row.session_reused)); + assert.equal(turns[1].progress, false); + assert.ok(turns[1].rejected.some((r: { kind: string }) => r.kind === "acceptance_regressed")); + assert.equal(run("status").code, 0); + assert.match(run("view").output, /\[x\] \*\*p4\*\*/); + + // A completed run must not return stale success or spend another model turn. + writeFileSync(join(project, "part-1.txt"), "changed after completion\n"); + const recheck = run("run", ["--turns", "1", "--sandbox", sandbox]); + assert.equal(recheck.code, 3, recheck.output); + assert.equal(readState().turn_count, 5); + assert.equal(readState().stop.reason, "acceptance_regressed"); + assert.equal(run("status").code, 3); + assert.match(run("view").output, /\[ \] \*\*p1\*\*/); + console.log("live smoke passed: five turns, process restarts, regression repair, budget-edge completion, stale-success rejection"); +} finally { + rmSync(project, { recursive: true, force: true }); +} diff --git a/packages/loopx-goal-kernel/package-lock.json b/packages/loopx-goal-kernel/package-lock.json new file mode 100644 index 0000000000..7be3a90587 --- /dev/null +++ b/packages/loopx-goal-kernel/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "loopx-goal-kernel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "loopx-goal-kernel", + "version": "0.1.0", + "license": "Apache-2.0", + "bin": { + "loopx-goal": "src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "~5.9.3" + }, + "engines": { + "node": ">=22.22.3" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/loopx-goal-kernel/package.json b/packages/loopx-goal-kernel/package.json new file mode 100644 index 0000000000..5ee89b7666 --- /dev/null +++ b/packages/loopx-goal-kernel/package.json @@ -0,0 +1,23 @@ +{ + "name": "loopx-goal-kernel", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Experimental bounded Codex loop with revalidated acceptance checkpoints", + "license": "Apache-2.0", + "engines": { + "node": ">=22.22.3" + }, + "bin": { + "loopx-goal": "./src/cli.ts" + }, + "scripts": { + "test": "node --no-warnings --experimental-strip-types --test tests/*.test.ts", + "typecheck": "tsc --noEmit", + "test:live": "node --no-warnings --experimental-strip-types examples/live-smoke.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "~5.9.3" + } +} diff --git a/packages/loopx-goal-kernel/src/cli.ts b/packages/loopx-goal-kernel/src/cli.ts new file mode 100644 index 0000000000..5cd1de22b6 --- /dev/null +++ b/packages/loopx-goal-kernel/src/cli.ts @@ -0,0 +1,327 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { GoalKernel, defaultDataRoot } from "./kernel.ts"; +import { GoalStore } from "./store.ts"; +import { sha256File } from "./hash.ts"; +import { nowIso } from "./fsutil.ts"; +import { renderView } from "./view.ts"; +import { goalFingerprint } from "./invariants.ts"; +import type { Goal, KernelState } from "./types.ts"; + +/** + * The prototype's operator surface. + * + * `loopx-goal --project --id ` + * + * There is no daemon, no dashboard, no configuration file and no scheduler. + * Each invocation reads two small files, does one bounded thing, and writes them + * back. `run --turns N` is what a host clock would call; the kernel never + * decides for itself that it is time to run again. + */ +const USAGE = `loopx-goal — minimal deterministic long-horizon goal kernel + + init --project DIR --id ID --spec FILE freeze the goal declaration + run --project DIR --id ID [--turns N] run bounded turns (default 1) + status --project DIR --id ID print the current state + view --project DIR --id ID print/viewer the VIEW.md projection + amend --project DIR --id ID --confirm|--reject resolve a pending amendment + accept --project DIR --id ID --predicate P record an owner decision + hash --project DIR --file PATH sha256 for an assumption source + +The spec file is JSON: + { "goal_id": "g", "objective": "...", "predicates": [...], "policy": {...} } +`; + +interface Options { + verb: string; + project: string; + id: string | null; + spec: string | null; + file: string | null; + predicate: string | null; + note: string; + turns: number; + model: string | null; + sandbox: "read-only" | "workspace-write" | "danger-full-access"; + confirm: boolean; + reject: boolean; +} + +function parseArgs(argv: string[]): Options { + const verb = argv[0] ?? "help"; + const options: Options = { + verb, + project: process.cwd(), + id: null, + spec: null, + file: null, + predicate: null, + note: "", + turns: 1, + model: null, + sandbox: "workspace-write", + confirm: false, + reject: false, + }; + for (let index = 1; index < argv.length; index += 1) { + const token = argv[index]; + const next = argv[index + 1]; + switch (token) { + case "--project": + options.project = resolve(next); + index += 1; + break; + case "--id": + options.id = next; + index += 1; + break; + case "--spec": + options.spec = resolve(next); + index += 1; + break; + case "--file": + options.file = resolve(next); + index += 1; + break; + case "--predicate": + options.predicate = next; + index += 1; + break; + case "--note": + options.note = next; + index += 1; + break; + case "--turns": + options.turns = Number(next); + if (!Number.isSafeInteger(options.turns) || options.turns < 1) throw new Error("--turns must be a positive integer"); + index += 1; + break; + case "--model": + options.model = next; + index += 1; + break; + case "--sandbox": { + if (next !== "read-only" && next !== "workspace-write" && next !== "danger-full-access") { + throw new Error(`--sandbox must be read-only, workspace-write or danger-full-access`); + } + options.sandbox = next; + index += 1; + break; + } + case "--confirm": + options.confirm = true; + break; + case "--reject": + options.reject = true; + break; + default: + throw new Error(`unknown argument: ${token}`); + } + } + return options; +} + +function requireId(options: Options): string { + if (!options.id) throw new Error("--id is required"); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(options.id)) throw new Error("--id must be a simple alphanumeric name (hyphens and underscores allowed)"); + return options.id; +} + +function store(options: Options): GoalStore { + return new GoalStore(options.project, defaultDataRoot(options.project), requireId(options)); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.verb === "help" || options.verb === "--help" || options.verb === "-h") { + process.stdout.write(USAGE); + return 0; + } + + switch (options.verb) { + case "hash": { + if (!options.file) throw new Error("--file is required"); + process.stdout.write(`${sha256File(options.file)}\n`); + return 0; + } + case "init": + return init(options); + case "run": + return run(options); + case "status": + return status(options); + case "view": { + const goalStore = store(options); + const goal = goalStore.readGoal(); + const state = goalStore.readState(); + process.stdout.write(renderView(goal, state, goalStore.readJournal() as never)); + return state.status === "stopped" ? 3 : 0; + } + case "amend": + return amend(options); + case "accept": + return accept(options); + default: + process.stderr.write(`unknown verb: ${options.verb}\n\n${USAGE}`); + return 2; + } +} + +function init(options: Options): number { + if (!options.spec) throw new Error("--spec is required"); + const spec = JSON.parse(readFileSync(options.spec, "utf8")) as Goal; + const goalStore = store(options); + if (existsSync(goalStore.goalPath) || existsSync(goalStore.statePath)) { + throw new Error("goal already exists; init cannot overwrite its declaration or reset its budget"); + } + if (spec.goal_id !== options.id || typeof spec.objective !== "string" || !spec.objective.trim()) { + throw new Error("spec requires the matching goal_id and a nonempty objective"); + } + for (const value of [spec.policy?.max_turns, spec.policy?.max_idle_turns]) { + if (!Number.isSafeInteger(value) || value < 1) throw new Error("policy limits must be positive integers"); + } + if (!Array.isArray(spec.predicates) || !spec.predicates.length) throw new Error("spec requires acceptance predicates"); + const ids = new Set(); + for (const predicate of spec.predicates) { + if (!predicate || typeof predicate.id !== "string" || !predicate.id || ids.has(predicate.id)) { + throw new Error("predicate ids must be nonempty and unique"); + } + ids.add(predicate.id); + const v = predicate.verify; + if (!v || !["command", "file_exists", "file_sha256", "owner"].includes(v.kind)) throw new Error(`invalid verifier for ${predicate.id}`); + if (v.kind === "command" && (typeof v.run !== "string" || !v.run.trim() || + (v.timeout_ms !== undefined && (!Number.isSafeInteger(v.timeout_ms) || v.timeout_ms < 1)))) throw new Error(`invalid command check for ${predicate.id}`); + if ((v.kind === "file_exists" || v.kind === "file_sha256") && (typeof v.path !== "string" || !v.path)) throw new Error(`invalid file check for ${predicate.id}`); + if (v.kind === "file_sha256" && !/^[0-9a-f]{64}$/.test(v.sha256)) throw new Error(`invalid digest for ${predicate.id}`); + } + const { goal_hash } = goalStore.initGoal(spec); + process.stdout.write(`initialized ${spec.goal_id}\n goal hash: ${goal_hash}\n data: ${goalStore.goalDir}\n`); + return 0; +} + +async function run(options: Options): Promise { + const goalStore = store(options); + const kernel = new GoalKernel(goalStore, { + projectRoot: options.project, + model: options.model ?? undefined, + sandbox: options.sandbox, + }); + let exit = 0; + for (let turn = 0; turn < Math.max(1, options.turns); turn += 1) { + const result = await kernel.runOneTurn(); + process.stdout.write( + `turn ${result.turn_index}: verified=[${result.verified.join(",")}] claimed=[${result.claimed.join(",")}] new=[${result.admitted_todos.join(",")}] progress=${result.progress ? "yes" : "no"}\n`, + ); + for (const rejection of result.rejected) { + process.stdout.write(` rejected ${rejection.kind}: ${rejection.detail}\n`); + } + if (result.stop) { + process.stdout.write(`\nSTOPPED: ${result.stop.reason}\n ${result.stop.detail}\n`); + // A finished goal is success; any other stop is a condition the owner must repair. + exit = result.stop.reason === "goal_complete" ? 0 : 3; + break; + } + const state = goalStore.readState(); + if (state.status !== "running") { + exit = state.status === "done" ? 0 : 3; + break; + } + } + return exit; +} + +function status(options: Options): number { + const goalStore = store(options); + const goal = goalStore.readGoal(); + const state = goalStore.readState(); + const satisfied = new Set(state.verified_predicates); + process.stdout.write(`${goal.goal_id} status=${state.status} turns=${state.turn_count}/${goal.policy.max_turns} idle=${state.no_progress_streak}/${goal.policy.max_idle_turns}\n`); + process.stdout.write(`tokens in=${state.usage_total.input_tokens} cached=${state.usage_total.cached_input_tokens} out=${state.usage_total.output_tokens}\n`); + for (const predicate of goal.predicates) { + process.stdout.write(` [${satisfied.has(predicate.id) ? "x" : " "}] ${predicate.id} ${predicate.statement}\n`); + } + for (const todo of state.todos) { + process.stdout.write(` todo ${todo.id} (${todo.status}) → ${todo.advances}: ${todo.title}\n`); + } + if (state.stop) { + process.stdout.write(`\nstopped: ${state.stop.reason}\n ${state.stop.detail}\n`); + return state.stop.reason === "goal_complete" ? 0 : 3; + } + return 0; +} + +function amend(options: Options): number { + const goalStore = store(options); + const goal = goalStore.readGoal(); + const state = goalStore.readState(); + if (!state.pending_decision) { + process.stderr.write("no pending amendment\n"); + return 4; + } + const decision = state.pending_decision; + if (options.reject) { + state.pending_decision = null; + state.stop = null; + state.status = "running"; + goalStore.writeState(state); + process.stdout.write(`rejected amendment from ${decision.turn_id}; objective unchanged\n`); + return 0; + } + if (options.confirm) { + if (state.goal_hash !== goalFingerprint(goal)) throw new Error("goal hash mismatch; restore the declaration first"); + const updated: Goal = { ...goal, objective: decision.proposal.objective }; + goalStore.initGoal(updated); + state.goal_hash = goalFingerprint(updated); + state.pending_decision = null; + state.stop = null; + state.status = "running"; + goalStore.writeState(state); + goalStore.writeView(renderView(updated, state, goalStore.readJournal() as never)); + process.stdout.write(`adopted amendment from ${decision.turn_id}; new goal hash ${state.goal_hash}\n`); + return 0; + } + process.stderr.write(`pending amendment from ${decision.turn_id}:\n reason: ${decision.proposal.reason}\n objective: ${decision.proposal.objective}\n\nRe-run with --confirm or --reject.\n`); + return 4; +} + +function accept(options: Options): number { + const goalStore = store(options); + const goal = goalStore.readGoal(); + const state = goalStore.readState(); + if (!options.predicate) throw new Error("--predicate is required"); + const predicate = goal.predicates.find((p) => p.id === options.predicate); + if (!predicate) throw new Error(`unknown predicate ${options.predicate}`); + if (predicate.verify.kind !== "owner") throw new Error("accept is reserved for owner predicates; automatic checks must pass verification"); + if (state.goal_hash !== goalFingerprint(goal)) throw new Error("goal hash mismatch; restore the declaration first"); + if (!state.verified_predicates.includes(predicate.id)) { + state.verified_predicates.push(predicate.id); + } + const note = options.note || `owner accepted at ${nowIso()}`; + goalStore.appendReceipt({ + turn_id: null, + at: nowIso(), + owner_decision: { predicate: predicate.id, kind: "accept", note }, + state_hash_after: "", + } as never); + for (const todo of state.todos) { + if (todo.advances === predicate.id && todo.status === "open") todo.status = "done"; + } + state.stop = null; + if (state.status === "stopped") state.status = "running"; + goalStore.writeState(state); + goalStore.writeView(renderView(goal, state, goalStore.readJournal() as never)); + process.stdout.write(`recorded owner acceptance of ${predicate.id}: ${note}\n`); + return 0; +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write(`error: ${(error as Error).message}\n`); + process.exitCode = 1; + }); + +export type { KernelState }; diff --git a/packages/loopx-goal-kernel/src/codex.ts b/packages/loopx-goal-kernel/src/codex.ts new file mode 100644 index 0000000000..0495845786 --- /dev/null +++ b/packages/loopx-goal-kernel/src/codex.ts @@ -0,0 +1,284 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { TurnDelta, Usage } from "./types.ts"; + +/** + * The Codex adapter — the one non-trivial piece of real engineering here. + * + * It shells out to `codex exec` and drives continuation with `resume `. + * The three facts it relies on are verified against the installed CLI: + * - `--json` emits a JSONL event stream (`thread.started`, `turn.completed`); + * - `resume ` continues the same thread, so the model keeps its + * own working context and the kernel does not have to replay history; + * - `--output-schema` makes the final message a validated JSON object. + * + * A production rewrite would speak the app-server RPC protocol directly for + * streaming and interrupt control. A subprocess per turn is the honest minimum + * that is still real: it is the actual Codex runtime, not a mock. + */ +export interface CodexTurnRequest { + projectRoot: string; + prompt: string; + /** null starts a new thread; otherwise the kernel resumes this session. */ + sessionId: string | null; + model?: string; + /** + * Codex sandbox for the turn. The kernel defaults to `workspace-write` because + * a goal that cannot change the workspace can never satisfy a file predicate; + * `read-only` is available for inspection-only goals and is the safer choice + * when the objective is analysis rather than delivery. + */ + sandbox?: "read-only" | "workspace-write" | "danger-full-access"; + timeoutMs?: number; +} + +export interface CodexTurnResult { + delta: TurnDelta; + sessionId: string | null; + sessionReused: boolean; + usage: Usage; + durationMs: number; +} + +/** Mirrors `TurnDelta` in types.ts. A smoke test asserts the two agree. */ +export function deltaJsonSchema(): Record { + const assumption = { + type: "object", + additionalProperties: false, + required: ["id", "statement", "source"], + properties: { + id: { type: "string", minLength: 1 }, + statement: { type: "string", minLength: 1 }, + source: { + type: "object", + additionalProperties: false, + required: ["kind", "path", "sha256"], + properties: { + kind: { type: "string", enum: ["file"] }, + path: { type: "string", minLength: 1 }, + sha256: { type: "string", pattern: "^[0-9a-f]{64}$" }, + }, + }, + }, + } as const; + return { + type: "object", + additionalProperties: false, + required: ["closed", "new_todos", "new_assumptions", "proposed_amendment", "note"], + properties: { + closed: { type: "array", items: { type: "string" } }, + new_todos: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["id", "title", "done_when", "advances"], + properties: { + id: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + done_when: { type: "string", minLength: 1 }, + advances: { type: "string", minLength: 1 }, + }, + }, + }, + new_assumptions: { type: "array", items: assumption }, + proposed_amendment: { + anyOf: [ + { type: "null" }, + { + type: "object", + additionalProperties: false, + required: ["objective", "reason"], + properties: { + objective: { type: "string", minLength: 1 }, + reason: { type: "string", minLength: 1 }, + }, + }, + ], + }, + note: { type: "string" }, + }, + }; +} + +export async function runCodexTurn(request: CodexTurnRequest): Promise { + const started = Date.now(); + const scratch = mkdtempSync(join(tmpdir(), "goal-kernel-")); + const schemaPath = join(scratch, "delta.schema.json"); + const lastMessagePath = join(scratch, "last-message.json"); + writeFileSync(schemaPath, JSON.stringify(deltaJsonSchema()), "utf8"); + + const args = buildArgs(request, schemaPath, lastMessagePath); + const { stdout, stderr, code } = await run("codex", args, request.projectRoot, request.timeoutMs ?? 30 * 60_000); + + const events = parseEvents(stdout); + const sessionId = events.sessionId; + const usage = events.usage; + + if (!events.completed) { + const tail = stderr.trim().slice(-800); + throw new Error( + `codex turn did not complete (exit ${code}): ${events.error ?? (tail || "no event stream")}`, + ); + } + + const raw = readLastMessage(lastMessagePath, events.finalMessage); + const delta = parseDelta(raw); + return { + delta, + sessionId, + sessionReused: request.sessionId !== null && sessionId === request.sessionId, + usage, + durationMs: Date.now() - started, + }; +} + +function buildArgs( + request: CodexTurnRequest, + schemaPath: string, + lastMessagePath: string, +): string[] { + const common = [ + "--json", + "--output-schema", + schemaPath, + "--output-last-message", + lastMessagePath, + "--skip-git-repo-check", + ]; + // Use a config override accepted by both exec and resume, on every turn. + const sandbox = [`-c`, `sandbox_mode=${JSON.stringify(request.sandbox ?? "workspace-write")}`]; + const model = request.model ? ["--model", request.model] : []; + if (request.sessionId) { + return ["exec", "resume", ...common, ...sandbox, ...model, request.sessionId, request.prompt]; + } + return ["exec", ...common, ...sandbox, ...model, request.prompt]; +} + +interface ParsedEvents { + sessionId: string | null; + usage: Usage; + completed: boolean; + finalMessage: string | null; + error: string | null; +} + +function parseEvents(stdout: string): ParsedEvents { + const out: ParsedEvents = { + sessionId: null, + usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }, + completed: false, + finalMessage: null, + error: null, + }; + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "") continue; + let event: Record; + try { + event = JSON.parse(trimmed) as Record; + } catch { + continue; + } + const type = event.type; + if (type === "thread.started" && typeof event.thread_id === "string") { + out.sessionId = event.thread_id; + } else if (type === "turn.completed" && event.usage) { + out.completed = true; + const usage = event.usage as Record; + out.usage = { + input_tokens: usage.input_tokens ?? 0, + cached_input_tokens: usage.cached_input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + }; + } else if (type === "item.completed") { + const item = event.item as Record | undefined; + if (item?.type === "agent_message" && typeof item.text === "string") { + out.finalMessage = item.text; + } + } else if (type === "error" || type === "turn.failed") { + out.error = typeof event.message === "string" ? event.message : JSON.stringify(event); + } + } + return out; +} + +function readLastMessage(path: string, fallback: string | null): string { + try { + const text = readFileSync(path, "utf8").trim(); + if (text !== "") return text; + } catch { + // Fall through to the streamed agent message. + } + if (fallback !== null) return fallback; + throw new Error("codex returned no final message"); +} + +/** Tolerate a fenced or slightly annotated response, but never invent a delta. */ +export function parseDelta(raw: string): TurnDelta { + const text = stripFence(raw.trim()); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start === -1 || end <= start) { + throw new Error(`turn did not return a JSON delta: ${text.slice(0, 300)}`); + } + parsed = JSON.parse(text.slice(start, end + 1)); + } + const value = parsed as Partial; + if (value === null || typeof value !== "object") { + throw new Error("turn delta was not an object"); + } + return { + closed: Array.isArray(value.closed) ? value.closed.filter(isString) : [], + new_todos: Array.isArray(value.new_todos) ? value.new_todos : [], + new_assumptions: Array.isArray(value.new_assumptions) ? value.new_assumptions : [], + proposed_amendment: value.proposed_amendment ?? null, + note: typeof value.note === "string" ? value.note : "", + }; +} + +function stripFence(text: string): string { + const match = /^```(?:json)?\s*\n([\s\S]*?)\n```$/m.exec(text); + return match ? match[1].trim() : text; +} + +function isString(value: unknown): value is string { + return typeof value === "string"; +} + +function run( + command: string, + args: string[], + cwd: string, + timeoutMs: number, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`codex turn exceeded ${timeoutMs}ms and was killed`)); + }, timeoutMs); + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ stdout, stderr, code: code ?? -1 }); + }); + }); +} diff --git a/packages/loopx-goal-kernel/src/context.ts b/packages/loopx-goal-kernel/src/context.ts new file mode 100644 index 0000000000..9e7bbacca4 --- /dev/null +++ b/packages/loopx-goal-kernel/src/context.ts @@ -0,0 +1,96 @@ +import { hashValue } from "./hash.ts"; +import type { Goal, KernelState, Todo } from "./types.ts"; + +/** Render the explicit goal prompt deterministically. Codex session history and + * workspace inputs are separate; this hash alone cannot replay a past turn. */ +export interface RenderedContext { + prompt: string; + ctx_hash: string; +} + +export function renderContext(goal: Goal, state: KernelState): RenderedContext { + const open = state.todos.filter((t) => t.status === "open"); + const blocked = state.todos.filter((t) => t.status === "blocked"); + const satisfied = new Set(state.verified_predicates); + + const lines: string[] = []; + lines.push("You are continuing one long-running goal. Work exactly one bounded step, then report."); + lines.push(""); + lines.push(`## Objective (frozen, hash ${state.goal_hash.slice(0, 12)})`); + lines.push(goal.objective); + lines.push(""); + lines.push("## Acceptance predicates — verified independently by the harness, not by you"); + for (const predicate of goal.predicates) { + const mark = satisfied.has(predicate.id) ? "x" : " "; + lines.push(`- [${mark}] ${predicate.id}: ${predicate.statement} (checked by: ${describeVerify(predicate.verify)})`); + } + lines.push(""); + lines.push("## Open work"); + if (open.length === 0) { + lines.push("- (none)"); + } else { + for (const todo of open) lines.push(renderTodo(todo)); + } + lines.push(""); + lines.push("## Blocked work"); + if (blocked.length === 0) { + lines.push("- (none)"); + } else { + for (const todo of blocked) lines.push(renderTodo(todo)); + } + lines.push(""); + lines.push("## Assumptions this run depends on (a change to any of these stops the run)"); + if (state.assumptions.length === 0) { + lines.push("- (none)"); + } else { + for (const assumption of state.assumptions) { + lines.push(`- ${assumption.id}: ${assumption.statement} [${assumption.source.path} @ ${assumption.source.sha256.slice(0, 12)}]`); + } + } + lines.push(""); + lines.push(`## Budget remaining: ${Math.max(0, goal.policy.max_turns - state.turn_count)} of ${goal.policy.max_turns} turns`); + if (state.no_progress_streak > 0) { + lines.push(`## Warning: ${state.no_progress_streak} consecutive turn(s) verified no new checkpoint. The run stops after ${goal.policy.max_idle_turns}.`); + } + lines.push(""); + lines.push(INSTRUCTIONS); + + const prompt = lines.join("\n"); + return { prompt, ctx_hash: hashValue(prompt) }; +} + +function renderTodo(todo: Todo): string { + return `- ${todo.id}: ${todo.title}\n done when: ${todo.done_when}\n advances predicate: ${todo.advances}`; +} + +function describeVerify(spec: Goal["predicates"][number]["verify"]): string { + switch (spec.kind) { + case "command": + return `harness runs \`${spec.run}\``; + case "file_exists": + return `harness checks ${spec.path} exists`; + case "file_sha256": + return `harness checks ${spec.path} matches a recorded digest`; + case "owner": + return `the owner decides; never the agent`; + } +} + +const INSTRUCTIONS = `## What to return + +Perform the workspace changes first, then return one JSON object as your final response: + +{ + "closed": [""], + "new_todos": [{"id":"t3","title":"...","done_when":"...","advances":""}], + "new_assumptions": [{"id":"a1","statement":"...","source":{"kind":"file","path":"...","sha256":""}}], + "proposed_amendment": {"objective":"...","reason":"..."} | null, + "note": "one short sentence" +} + +Reporting and verification rules: +- Only claim a predicate in "closed" if the workspace now actually satisfies it; the harness re-runs all checks, revokes stale passes, and ignores unverified claims. +- New todos and repeated passes do not count as progress. A checkpoint earns progress only the first time it passes. Repair regressed work and continue toward the remaining checkpoints. +- Every todo you add must set "advances" to a predicate id declared above. A todo that names no declared predicate is rejected. You must also keep the actual work within the objective; the id check does not establish semantic relevance. +- "proposed_amendment" is for changing the objective. It does not take effect: the run stops and asks the owner. Do not use it to restate work as done. +- Report only what this turn actually changed on disk or in reality.`; diff --git a/packages/loopx-goal-kernel/src/fsutil.ts b/packages/loopx-goal-kernel/src/fsutil.ts new file mode 100644 index 0000000000..74a982c968 --- /dev/null +++ b/packages/loopx-goal-kernel/src/fsutil.ts @@ -0,0 +1,63 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +export function ensureDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export function readJsonIfPresent(path: string): T | null { + if (!existsSync(path)) return null; + return readJson(path); +} + +/** + * Write via a temporary file and rename, so a crash mid-write cannot leave a + * half-parsed state file that the next turn would silently act on. + */ +export function atomicWriteJson(path: string, value: unknown): void { + ensureDir(dirname(path)); + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + renameSync(tmp, path); +} + +export function atomicWriteText(path: string, text: string): void { + ensureDir(dirname(path)); + const tmp = `${path}.tmp-${process.pid}`; + writeFileSync(tmp, text, "utf8"); + renameSync(tmp, path); +} + +/** Append-only. One JSON object per line; a trailing partial line is discarded on read. */ +export function appendJsonl(path: string, value: unknown): void { + ensureDir(dirname(path)); + writeFileSync(path, `${JSON.stringify(value)}\n`, { encoding: "utf8", flag: "a" }); +} + +export function readJsonl(path: string): T[] { + if (!existsSync(path)) return []; + const out: T[] = []; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (trimmed === "") continue; + try { + out.push(JSON.parse(trimmed) as T); + } catch { + // A crash can leave one torn trailing line. Ignore that line only. + continue; + } + } + return out; +} + +export function nowIso(): string { + return new Date().toISOString(); +} + +export function newId(prefix: string): string { + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} diff --git a/packages/loopx-goal-kernel/src/hash.ts b/packages/loopx-goal-kernel/src/hash.ts new file mode 100644 index 0000000000..b43b96f0ce --- /dev/null +++ b/packages/loopx-goal-kernel/src/hash.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +/** + * Stable serialisation: object keys sorted at every depth, so two structurally + * equal values always produce the same bytes. Hashing depends on this. + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortValue(value)); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value !== null && typeof value === "object") { + const source = value as Record; + const out: Record = {}; + for (const key of Object.keys(source).sort()) out[key] = sortValue(source[key]); + return out; + } + return value; +} + +export function sha256(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +export function hashValue(value: unknown): string { + return sha256(canonicalJson(value)); +} + +export function sha256File(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +/** Short form used in human-facing views. Never used for comparison. */ +export function short(hash: string): string { + return hash.slice(0, 12); +} diff --git a/packages/loopx-goal-kernel/src/invariants.ts b/packages/loopx-goal-kernel/src/invariants.ts new file mode 100644 index 0000000000..bc79ed0600 --- /dev/null +++ b/packages/loopx-goal-kernel/src/invariants.ts @@ -0,0 +1,167 @@ +import { hashValue, sha256File } from "./hash.ts"; +import type { + Assumption, + Goal, + KernelState, + StopReason, + Todo, +} from "./types.ts"; + +/** Mechanical scope references, file revisions and resource limits. */ +export interface Violation { + reason: StopReason; + detail: string; +} + +export interface InvariantReport { + ok: boolean; + violations: Violation[]; + /** Assumptions whose declared revision no longer matches the workspace. */ + stale_assumptions: string[]; +} + +/** 1. Every open todo must name a predicate that exists in the frozen goal. */ +export function checkTodoScoping(goal: Goal, todos: Todo[]): Violation[] { + const known = new Set(goal.predicates.map((p) => p.id)); + const seen = new Set(); + const violations: Violation[] = []; + for (const todo of todos) { + const where = `todo ${todo.id}`; + if (todo.id === "" || seen.has(todo.id)) { + violations.push({ + reason: "unscoped_todo", + detail: `${where} is missing an id or duplicated`, + }); + continue; + } + seen.add(todo.id); + if (todo.status !== "open") continue; + if (!todo.advances || !known.has(todo.advances)) { + violations.push({ + reason: "unscoped_todo", + detail: `${where} (${JSON.stringify(todo.title)}) advances ${ + todo.advances ? `unknown predicate ${todo.advances}` : "no predicate" + }`, + }); + } + } + return violations; +} + +/** + * 2. Every assumption the loop is relying on must still hold. + * A file assumption is checked against the recorded content hash, so a + * revision underneath the loop stops it instead of silently rerouting it. + */ +export function checkAssumptions( + projectRoot: string, + assumptions: Assumption[], +): InvariantReport { + const violations: Violation[] = []; + const stale: string[] = []; + const seen = new Set(); + for (const assumption of assumptions) { + if (seen.has(assumption.id)) { + violations.push({ + reason: "stale_assumption", + detail: `assumption ${assumption.id} is declared twice`, + }); + continue; + } + seen.add(assumption.id); + const path = resolve(projectRoot, assumption.source.path); + let actual: string; + try { + actual = sha256File(path); + } catch { + stale.push(assumption.id); + violations.push({ + reason: "stale_assumption", + detail: `assumption ${assumption.id} depends on unreadable ${assumption.source.path}`, + }); + continue; + } + if (actual !== assumption.source.sha256) { + stale.push(assumption.id); + violations.push({ + reason: "stale_assumption", + detail: `assumption ${assumption.id} ("${assumption.statement}") recorded ${assumption.source.path} at ${assumption.source.sha256.slice(0, 12)} but it is now ${actual.slice(0, 12)}`, + }); + } + } + return { ok: violations.length === 0, violations, stale_assumptions: stale }; +} + +/** 3. Budget and owner authority. */ +export function checkBudget(goal: Goal, state: KernelState): Violation[] { + if (state.turn_count >= goal.policy.max_turns) { + return [ + { + reason: "budget_exhausted", + detail: `turn budget ${goal.policy.max_turns} reached`, + }, + ]; + } + return []; +} + +/** 4. No-progress fuse: N consecutive turns with no verified transition ends the run. */ +export function checkProgress(goal: Goal, state: KernelState): Violation[] { + if (state.no_progress_streak >= goal.policy.max_idle_turns) { + return [ + { + reason: "no_progress", + detail: `${state.no_progress_streak} consecutive turns verified no new checkpoint; the loop is spending without moving`, + }, + ]; + } + return []; +} + +/** + * Run all four in a fixed order and return every violation, not just the first, + * so one stop record explains the whole situation to the owner. + */ +export function checkInvariants( + goal: Goal, + state: KernelState, + projectRoot: string, +): InvariantReport { + const scoping = checkTodoScoping(goal, state.todos); + const assumptions = checkAssumptions(projectRoot, state.assumptions); + const violations = [ + ...scoping, + ...assumptions.violations, + ...checkBudget(goal, state), + ...checkProgress(goal, state), + ]; + return { + ok: violations.length === 0, + violations, + stale_assumptions: assumptions.stale_assumptions, + }; +} + +/** + * Completion is decided by the kernel's own verified results, never by the + * turn's claim. `verified_predicates` is refreshed at turn boundaries; passes can be revoked. + */ +export function isGoalComplete(goal: Goal, state: KernelState): boolean { + if (goal.predicates.length === 0) return false; + const done = new Set(state.verified_predicates); + return goal.predicates.every((p) => done.has(p.id)); +} + +/** Counter helper used by the loop: hash of the parts a turn must not change. */ +export function goalFingerprint(goal: Goal): string { + return hashValue({ + objective: goal.objective, + predicates: goal.predicates, + policy: goal.policy, + }); +} + +function resolve(root: string, path: string): string { + if (path.startsWith("/")) return path; + return `${root.replace(/\/$/, "")}/${path}`; +} diff --git a/packages/loopx-goal-kernel/src/kernel.ts b/packages/loopx-goal-kernel/src/kernel.ts new file mode 100644 index 0000000000..84ec6f6e5b --- /dev/null +++ b/packages/loopx-goal-kernel/src/kernel.ts @@ -0,0 +1,219 @@ +import { join } from "node:path"; +import { renderContext } from "./context.ts"; +import { runCodexTurn } from "./codex.ts"; +import { nowIso } from "./fsutil.ts"; +import { hashValue } from "./hash.ts"; +import { checkAssumptions, checkBudget, checkProgress, checkTodoScoping, isGoalComplete, goalFingerprint } from "./invariants.ts"; +import { GoalStore } from "./store.ts"; +import { renderView } from "./view.ts"; +import { applyDelta, updateAcceptance } from "./state.ts"; +import { verifyAcceptance } from "./verify.ts"; +import type { + Goal, + KernelState, + StopReason, + StopRecord, + TurnDelta, + TurnReceipt, + Usage, +} from "./types.ts"; + +/** Bounded single-goal loop: check integrity, observe acceptance, act, recheck, + * settle. External callers own scheduling. This prototype has no concurrency + * fence or crash-safe transaction spanning journal and state. */ +export interface TurnResult { + turn_id: string; + turn_index: number; + verified: string[]; + claimed: string[]; + admitted_todos: string[]; + rejected: Array<{ kind: string; detail: string }>; + progress: boolean; + usage: Usage; + stop: StopRecord | null; +} + +export interface KernelOptions { + projectRoot: string; + model?: string; + sandbox?: "read-only" | "workspace-write" | "danger-full-access"; + /** Injected for tests: replaces the real Codex subprocess. */ + runTurn?: (input: { prompt: string; sessionId: string | null }) => Promise<{ + delta: TurnDelta; + sessionId: string | null; + sessionReused: boolean; + usage: Usage; + }>; +} + +export class GoalKernel { + readonly store: GoalStore; + readonly options: KernelOptions; + + constructor(store: GoalStore, options: KernelOptions) { + this.store = store; + this.options = options; + } + + /** Integrity and owner decisions always precede completion or resource limits. */ + private integrity(state: KernelState, goal: Goal): StopRecord | null { + if (state.goal_hash !== goalFingerprint(goal) || + state.goal_hash !== goalFingerprint(this.store.readGoal())) { + return this.stop("goal_hash_mismatch", "The frozen declaration changed; restore it before continuing."); + } + const violations = [ + ...checkTodoScoping(goal, state.todos), + ...checkAssumptions(this.options.projectRoot, state.assumptions).violations, + ]; + if (violations.length) return this.stop(violations[0].reason, violations[0].detail); + if (state.pending_decision) { + return this.stop("goal_amendment_required", + "An objective amendment requires the owner: use amend --confirm or amend --reject.", + state.pending_decision.turn_id); + } + return null; + } + + /** Call only after refreshing acceptance from the workspace. */ + private settlement(state: KernelState, goal: Goal): StopRecord | null { + if (isGoalComplete(goal, state)) { + return this.stop("goal_complete", "every acceptance predicate is verified"); + } + const violations = [...checkBudget(goal, state), ...checkProgress(goal, state)]; + return violations.length ? this.stop(violations[0].reason, violations[0].detail) : null; + } + + async runOneTurn(): Promise { + const goal = this.store.readGoal(); + const state = this.store.readState(); + if (state.status === "stopped") return this.emptyResult(state, state.stop); + + const integrity = this.integrity(state, goal); + if (integrity) return this.finishStopped(state, integrity); + + // The model must see invalidated work before it chooses its next action. + const before = verifyAcceptance(this.options.projectRoot, goal, [], state.verified_predicates); + updateAcceptance(state, before.satisfied); + if (state.status === "done") { + if (!isGoalComplete(goal, state)) { + return this.finishStopped(state, this.stop("acceptance_regressed", + "Previously completed work no longer passes. Inspect the checks before starting more work.")); + } + return this.emptyResult(state, state.stop); + } + const gate = this.settlement(state, goal); + if (gate) return this.finishStopped(state, gate); + + const credited = new Set(state.credited_predicates ?? state.verified_predicates); + const { prompt, ctx_hash } = renderContext(goal, state); + const turnIndex = state.turn_count + 1; + const turnId = `turn_${String(turnIndex).padStart(4, "0")}`; + let result: Awaited>; + try { + result = this.options.runTurn + ? { ...(await this.options.runTurn({ prompt, sessionId: state.session_id })), durationMs: 0 } + : await runCodexTurn({ + projectRoot: this.options.projectRoot, prompt, sessionId: state.session_id, + model: this.options.model, sandbox: this.options.sandbox, + }); + } catch (error) { + // A failed attempt still consumed a turn. Runtime usage may be unavailable. + state.turn_count = turnIndex; + return this.finishStopped(state, this.stop("runtime_error", `${turnId}: ${(error as Error).message}`), turnId); + } + + state.turn_count = turnIndex; + state.session_id = result.sessionId ?? state.session_id; + state.usage_total = addUsage(state.usage_total, result.usage); + + // Re-read the frozen declaration before accepting anything from this turn. + let stop = this.integrity(state, goal); + const outcome = stop + ? { verified: [], rejected: [], satisfied: [] } + : verifyAcceptance(this.options.projectRoot, goal, result.delta.closed, state.verified_predicates); + const { admitted, rejected } = stop + ? { admitted: [], rejected: [] } + : applyDelta(state, goal, result.delta, outcome.satisfied); + const newlyVerified = outcome.satisfied.filter(id => !credited.has(id)); + const progress = newlyVerified.length > 0; + state.credited_predicates = [...new Set([...credited, ...newlyVerified])]; + state.no_progress_streak = progress ? 0 : state.no_progress_streak + 1; + + if (!stop && result.delta.proposed_amendment) { + state.pending_decision = { + turn_id: turnId, raised_at: nowIso(), before_hash: state.goal_hash, + after_hash: goalFingerprint({ ...goal, objective: result.delta.proposed_amendment.objective }), + proposal: result.delta.proposed_amendment, + }; + } + stop ??= this.integrity(state, goal) ?? this.settlement(state, goal); + if (stop) { + state.status = stop.reason === "goal_complete" ? "done" : "stopped"; + state.stop = stop; + } + const receipt: TurnReceipt = { + turn_id: turnId, at: nowIso(), turn_index: turnIndex, goal_hash: state.goal_hash, + ctx_hash, session_id: result.sessionId, session_reused: result.sessionReused, + usage: result.usage, closed: result.delta.closed, verified: outcome.verified, + note: result.delta.note, admitted_todos: admitted, + rejected: [...before.rejected, ...outcome.rejected, ...rejected], + progress, state_hash_after: hashValue(state), + }; + this.store.appendReceipt(receipt); + this.store.writeState(state); + if (stop) this.store.appendReceipt({ turn_id: turnId, at: nowIso(), stop, state_hash_after: hashValue(state) }); + this.store.writeView(renderView(goal, state, this.store.readJournal() as never)); + return { + turn_id: turnId, turn_index: turnIndex, verified: newlyVerified, + claimed: result.delta.closed, admitted_todos: admitted, rejected: receipt.rejected, + progress, usage: result.usage, stop, + }; + } + + private finishStopped( + state: KernelState, + stop: StopRecord, + turnId: string | null = null, + reportedTurnIndex?: number, + ): TurnResult { + state.status = stop.reason === "goal_complete" ? "done" : "stopped"; + state.stop = stop; + this.store.writeState(state); + const goal = this.store.readGoal(); + this.store.appendReceipt({ turn_id: turnId, at: nowIso(), stop, state_hash_after: hashValue(state) } as never); + this.store.writeView(renderView(goal, state, this.store.readJournal() as never)); + const result = this.emptyResult(state, stop); + return reportedTurnIndex === undefined ? result : { ...result, turn_index: reportedTurnIndex }; + } + + private emptyResult(state: KernelState, stop: StopRecord | null): TurnResult { + return { + turn_id: state.stop?.turn_id ?? "turn_0000", + turn_index: state.turn_count, + verified: [], + claimed: [], + admitted_todos: [], + rejected: [], + progress: false, + usage: state.usage_total, + stop, + }; + } + + /** Build a stop record without yet persisting it. */ + private stop(reason: StopReason, detail: string, turnId: string | null = null): StopRecord { + return { reason, detail, at: nowIso(), turn_id: turnId }; + } +} + +function addUsage(a: Usage, b: Usage): Usage { + return { + input_tokens: a.input_tokens + b.input_tokens, + cached_input_tokens: a.cached_input_tokens + b.cached_input_tokens, + output_tokens: a.output_tokens + b.output_tokens, + }; +} + +export function defaultDataRoot(projectRoot: string): string { + return join(projectRoot, ".loopx", "goal-kernel"); +} diff --git a/packages/loopx-goal-kernel/src/state.ts b/packages/loopx-goal-kernel/src/state.ts new file mode 100644 index 0000000000..90e1f08c4b --- /dev/null +++ b/packages/loopx-goal-kernel/src/state.ts @@ -0,0 +1,86 @@ +import type { Goal, KernelState, TurnDelta } from "./types.ts"; + +/** + * Apply todo/assumption declarations and the current acceptance snapshot. + * + * Admission is strict on purpose: an inadmissible todo or assumption is + * recorded as rejected rather than repaired into something plausible, because a + * silent repair is exactly how a scope expansion becomes invisible. + */ +export function applyDelta( + state: KernelState, + goal: Goal, + delta: TurnDelta, + currentlySatisfied: string[], +): { admitted: string[]; rejected: Array<{ kind: string; detail: string }> } { + const predicateIds = new Set(goal.predicates.map((p) => p.id)); + const knownTodos = new Set(state.todos.map((t) => t.id)); + const knownAssumptions = new Set(state.assumptions.map((a) => a.id)); + const admitted: string[] = []; + const rejected: Array<{ kind: string; detail: string }> = []; + + for (const todo of delta.new_todos) { + if (!todo || typeof todo.id !== "string" || todo.id === "") { + rejected.push({ kind: "todo_missing_id", detail: JSON.stringify(todo) }); + continue; + } + if (knownTodos.has(todo.id)) { + rejected.push({ kind: "todo_duplicate_id", detail: todo.id }); + continue; + } + if (!todo.advances || !predicateIds.has(todo.advances)) { + rejected.push({ + kind: "todo_unscoped", + detail: `${todo.id} advances ${todo.advances || "nothing"}; not a declared predicate`, + }); + continue; + } + state.todos.push({ + id: todo.id, + title: String(todo.title ?? "").slice(0, 500), + done_when: String(todo.done_when ?? "").slice(0, 500), + advances: todo.advances, + status: "open", + }); + knownTodos.add(todo.id); + admitted.push(todo.id); + } + + for (const assumption of delta.new_assumptions) { + if (!assumption || typeof assumption.id !== "string" || assumption.id === "") { + rejected.push({ kind: "assumption_missing_id", detail: JSON.stringify(assumption) }); + continue; + } + if (knownAssumptions.has(assumption.id)) { + rejected.push({ kind: "assumption_duplicate_id", detail: assumption.id }); + continue; + } + const source = assumption.source; + if (!source || source.kind !== "file" || typeof source.path !== "string" || !/^[0-9a-f]{64}$/.test(source.sha256 ?? "")) { + rejected.push({ + kind: "assumption_invalid_source", + detail: `${assumption.id} must name a file path and its sha256`, + }); + continue; + } + state.assumptions.push({ + id: assumption.id, + statement: String(assumption.statement ?? "").slice(0, 1000), + source: { kind: "file", path: source.path, sha256: source.sha256 }, + }); + knownAssumptions.add(assumption.id); + } + + updateAcceptance(state, currentlySatisfied); + return { admitted, rejected }; +} + +/** A completed todo reopens when its acceptance condition no longer holds. */ +export function updateAcceptance(state: KernelState, currentlySatisfied: string[]): void { + const satisfied = new Set(currentlySatisfied); + state.verified_predicates = [...satisfied]; + for (const todo of state.todos) { + if (todo.status === "open" && satisfied.has(todo.advances)) todo.status = "done"; + else if (todo.status === "done" && !satisfied.has(todo.advances)) todo.status = "open"; + } +} diff --git a/packages/loopx-goal-kernel/src/store.ts b/packages/loopx-goal-kernel/src/store.ts new file mode 100644 index 0000000000..0236b57b57 --- /dev/null +++ b/packages/loopx-goal-kernel/src/store.ts @@ -0,0 +1,105 @@ +import { join } from "node:path"; +import { atomicWriteJson, atomicWriteText, appendJsonl, readJson, readJsonl } from "./fsutil.ts"; +import { hashValue } from "./hash.ts"; +import { goalFingerprint } from "./invariants.ts"; +import type { + Goal, + KernelState, + StopReceipt, + TurnReceipt, +} from "./types.ts"; + +/** + * One directory per goal, four files, no database: + * + * goal/1.0.0.json current declaration (changed only by explicit amendment) + * state.json the whole mutable state, a few KB + * journal.jsonl append-only receipts, one line per turn + * VIEW.md deterministic human/agent-readable projection + * + * There is deliberately no index, cache, lease, lock file or second store. + * Everything the loop needs between turns fits in `state.json`, so a fresh + * process (or a fresh session days later) reconstructs the loop by reading two + * files instead of trusting prose from a previous run. + */ +export class GoalStore { + readonly projectRoot: string; + readonly dataRoot: string; + readonly goalId: string; + readonly goalDir: string; + readonly goalPath: string; + readonly statePath: string; + readonly journalPath: string; + readonly viewPath: string; + + constructor(projectRoot: string, dataRoot: string, goalId: string) { + this.projectRoot = projectRoot; + this.dataRoot = dataRoot; + this.goalId = goalId; + this.goalDir = join(dataRoot, "goals", goalId); + this.goalPath = join(this.goalDir, "goal", "1.0.0.json"); + this.statePath = join(this.goalDir, "state.json"); + this.journalPath = join(this.goalDir, "journal.jsonl"); + this.viewPath = join(this.goalDir, "VIEW.md"); + } + + initGoal(goal: Goal): { created: boolean; goal_hash: string } { + const goal_hash = goalFingerprint(goal); + atomicWriteJson(this.goalPath, { ...goal, goal_hash }); + const state: KernelState = { + version: 1, + goal_id: goal.goal_id, + goal_hash, + session_id: null, + turn_count: 0, + usage_total: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }, + todos: [], + assumptions: [], + verified_predicates: [], + credited_predicates: [], + pending_decision: null, + no_progress_streak: 0, + status: "running", + stop: null, + }; + atomicWriteJson(this.statePath, state); + return { created: true, goal_hash }; + } + + readGoal(): Goal { + const raw = readJson(this.goalPath); + return { + goal_id: raw.goal_id, + objective: raw.objective, + predicates: raw.predicates, + policy: raw.policy, + }; + } + + readState(): KernelState { + const state = readJson(this.statePath); + state.credited_predicates ??= [...state.verified_predicates]; + return state; + } + + writeState(state: KernelState): void { + atomicWriteJson(this.statePath, state); + } + + appendReceipt(receipt: TurnReceipt | StopReceipt): void { + appendJsonl(this.journalPath, receipt); + } + + readJournal(): Array { + return readJsonl(this.journalPath); + } + + writeView(view: string): void { + atomicWriteText(this.viewPath, view); + } + + /** Hash of the mutable state as persisted. Used to prove a turn changed something. */ + stateHash(state: KernelState): string { + return hashValue(state); + } +} diff --git a/packages/loopx-goal-kernel/src/types.ts b/packages/loopx-goal-kernel/src/types.ts new file mode 100644 index 0000000000..b19b2d8c42 --- /dev/null +++ b/packages/loopx-goal-kernel/src/types.ts @@ -0,0 +1,195 @@ +/** Local prototype state vocabulary; not a shared LoopX authority contract. */ + +export type GoalId = string; +export type TodoId = string; +export type PredicateId = string; +export type AssumptionId = string; +export type TurnId = string; + +/** + * How one acceptance predicate is decided. + * + * A `command` or file check is executed by the kernel itself, never by the + * model. `owner` exists so a human-only predicate can be declared honestly + * instead of being silently approximated by prose. + */ +export type VerifySpec = + | { + kind: "command"; + run: string; + cwd?: string; + timeout_ms?: number; + expect_exit?: number; + expect_stdout?: string; + } + | { kind: "file_exists"; path: string } + | { kind: "file_sha256"; path: string; sha256: string } + | { kind: "owner"; note: string }; + +export interface Predicate { + id: PredicateId; + statement: string; + verify: VerifySpec; +} + +export const TODO_STATUSES = ["open", "done", "blocked"] as const; +export type TodoStatus = (typeof TODO_STATUSES)[number]; + +export interface Todo { + id: TodoId; + title: string; + done_when: string; + /** The acceptance predicate this todo advances. A todo that cannot name one is not admissible. */ + advances: PredicateId; + status: TodoStatus; +} + +export interface AssumptionSource { + kind: "file"; + path: string; + sha256: string; +} + +export interface Assumption { + id: AssumptionId; + statement: string; + source: AssumptionSource; +} + +export interface GoalPolicy { + /** Hard ceiling on Codex turns for this goal. */ + max_turns: number; + /** Consecutive turns with no verified progress before the loop stops and asks. */ + max_idle_turns: number; +} + +/** The frozen declaration. `goal_hash` covers the canonical form of exactly this object. */ +export interface Goal { + goal_id: GoalId; + objective: string; + predicates: Predicate[]; + policy: GoalPolicy; +} + +/** + * What one Codex turn must return. + * The adapter's deltaJsonSchema supplies the structured output schema. + */ +export interface TurnDelta { + /** Predicate ids the turn believes it satisfied. The kernel verifies each one itself. */ + closed: PredicateId[]; + new_todos: Array<{ + id: TodoId; + title: string; + done_when: string; + advances: PredicateId; + }>; + new_assumptions: Array<{ + id: AssumptionId; + statement: string; + source: AssumptionSource; + }>; + /** Set only when the objective itself must change. The loop stops and asks the owner. */ + proposed_amendment: { objective: string; reason: string } | null; + note: string; +} + +export const STOP_REASONS = [ + "goal_complete", + "goal_amendment_required", + "owner_decision_pending", + "unscoped_todo", + "stale_assumption", + "budget_exhausted", + "no_progress", + "runtime_error", + "goal_hash_mismatch", + "acceptance_regressed", +] as const; +export type StopReason = (typeof STOP_REASONS)[number]; + +export interface StopRecord { + reason: StopReason; + detail: string; + at: string; + turn_id: TurnId | null; +} + +export interface Usage { + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; +} + +export const EMPTY_USAGE: Usage = { + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, +}; + +export interface PendingDecision { + turn_id: TurnId; + raised_at: string; + /** Goal hash before the proposal. The proposal can only ever move this to `after_hash`. */ + before_hash: string; + after_hash: string; + proposal: { objective: string; reason: string }; +} + +export const KERNEL_STATUSES = ["running", "done", "stopped"] as const; +export type KernelStatus = (typeof KERNEL_STATUSES)[number]; + +export interface KernelState { + version: 1; + goal_id: GoalId; + /** Frozen at init. Any later mismatch is a hard stop, not a re-read. */ + goal_hash: string; + /** Codex thread to resume; null before the first turn. */ + session_id: string | null; + turn_count: number; + usage_total: Usage; + todos: Todo[]; + assumptions: Assumption[]; + /** Current acceptance snapshot; automatic checks are re-run at turn boundaries. */ + verified_predicates: PredicateId[]; + /** Checkpoints already credited. Optional for reading the original v1 prototype. */ + credited_predicates?: PredicateId[]; + pending_decision: PendingDecision | null; + no_progress_streak: number; + status: KernelStatus; + stop: StopRecord | null; +} + +export interface VerifiedPredicate { + predicate: PredicateId; + ok: boolean; + evidence: string; +} + +/** One appended line of `journal.jsonl`. */ +export interface TurnReceipt { + turn_id: TurnId; + at: string; + turn_index: number; + goal_hash: string; + ctx_hash: string; + session_id: string | null; + session_reused: boolean; + usage: Usage; + closed: PredicateId[]; + verified: VerifiedPredicate[]; + /** The turn's own one-line summary. Recorded so a human can see why a turn did nothing. */ + note: string; + admitted_todos: TodoId[]; + rejected: Array<{ kind: string; detail: string }>; + progress: boolean; + state_hash_after: string; +} + +/** Terminal journal records share the file with receipts so one tail shows the whole story. */ +export interface StopReceipt { + turn_id: TurnId | null; + at: string; + stop: StopRecord; + state_hash_after: string; +} diff --git a/packages/loopx-goal-kernel/src/verify.ts b/packages/loopx-goal-kernel/src/verify.ts new file mode 100644 index 0000000000..12c4d30542 --- /dev/null +++ b/packages/loopx-goal-kernel/src/verify.ts @@ -0,0 +1,106 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { sha256File } from "./hash.ts"; +import type { Goal, Predicate, VerifiedPredicate } from "./types.ts"; + +/** + * Re-run the declared checks independently of model claims. Commands must be + * trusted, repeatable checks: this is not an isolated verifier security boundary. + */ +export interface VerifyOutcome { + verified: VerifiedPredicate[]; + /** Predicates the turn claimed but the kernel could not confirm. */ + rejected: Array<{ kind: string; detail: string }>; + /** Predicate ids now decided true. */ + satisfied: string[]; +} + +export function verifyAcceptance( + projectRoot: string, + goal: Goal, + claimed: string[] = [], + previouslyVerified: string[] = [], +): VerifyOutcome { + const byId = new Map(goal.predicates.map((p) => [p.id, p])); + const verified: VerifiedPredicate[] = []; + const rejected: Array<{ kind: string; detail: string }> = []; + const satisfied: string[] = []; + const claims = new Set(claimed); + const previous = new Set(previouslyVerified); + for (const id of claims) { + if (!byId.has(id)) { + rejected.push({ + kind: "unknown_predicate", + detail: `turn claimed ${id}, which is not declared in the frozen goal`, + }); + } + } + for (const predicate of goal.predicates) { + const id = predicate.id; + const outcome = predicate.verify.kind === "owner" && previous.has(id) + ? { ok: true, evidence: "retained explicit owner acceptance" } + : verifyPredicate(projectRoot, predicate); + verified.push({ predicate: id, ...outcome }); + if (outcome.ok) { + satisfied.push(id); + } else if (claims.has(id)) { + rejected.push({ kind: "unverified_claim", detail: `${id}: ${outcome.evidence}` }); + } + if (!outcome.ok && previous.has(id)) { + rejected.push({ kind: "acceptance_regressed", detail: `${id}: ${outcome.evidence}` }); + } + } + return { verified, rejected, satisfied }; +} + +export function verifyPredicate( + projectRoot: string, + predicate: Predicate, +): { ok: boolean; evidence: string } { + const spec = predicate.verify; + switch (spec.kind) { + case "file_exists": { + const path = resolve(projectRoot, spec.path); + const ok = existsSync(path); + return { ok, evidence: `file_exists ${spec.path}: ${ok ? "present" : "missing"}` }; + } + case "file_sha256": { + const path = resolve(projectRoot, spec.path); + try { + const actual = sha256File(path); + const ok = actual === spec.sha256; + return { + ok, + evidence: `file_sha256 ${spec.path}: ${actual.slice(0, 12)} expected ${spec.sha256.slice(0, 12)}`, + }; + } catch { + return { ok: false, evidence: `file_sha256 ${spec.path}: unreadable` }; + } + } + case "command": { + const result = spawnSync("bash", ["-lc", spec.run], { + cwd: spec.cwd ? resolve(projectRoot, spec.cwd) : projectRoot, + timeout: spec.timeout_ms ?? 120_000, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + const code = result.status ?? -1; + const stdout = result.stdout ?? ""; + const expectExit = spec.expect_exit ?? 0; + let ok = code === expectExit; + if (ok && spec.expect_stdout !== undefined) ok = stdout.includes(spec.expect_stdout); + const detail = `command exit ${code} (expected ${expectExit})${spec.expect_stdout !== undefined ? `, stdout ${stdout.includes(spec.expect_stdout) ? "matched" : "did not match"}` : ""}`; + return { ok, evidence: detail }; + } + case "owner": + return { + ok: false, + evidence: `owner-only predicate; the kernel never self-certifies it (${spec.note})`, + }; + } +} + +function resolve(root: string, path: string): string { + if (path.startsWith("/")) return path; + return `${root.replace(/\/$/, "")}/${path}`; +} diff --git a/packages/loopx-goal-kernel/src/view.ts b/packages/loopx-goal-kernel/src/view.ts new file mode 100644 index 0000000000..2b482dfdaf --- /dev/null +++ b/packages/loopx-goal-kernel/src/view.ts @@ -0,0 +1,80 @@ +import type { Goal, KernelState, StopRecord } from "./types.ts"; + +/** Deterministic projection of (goal, state, journal tail) into the VIEW.md file. */ +export function renderView( + goal: Goal, + state: KernelState, + receipts: Array>, +): string { + const satisfied = new Set(state.verified_predicates); + const lines: string[] = []; + lines.push(`# ${goal.goal_id}`); + lines.push(""); + lines.push(`- status: **${state.status}**`); + lines.push(`- goal hash: \`${state.goal_hash}\``); + lines.push(`- turns spent: ${state.turn_count} / ${goal.policy.max_turns}`); + lines.push(`- no-progress streak: ${state.no_progress_streak} / ${goal.policy.max_idle_turns}`); + lines.push(`- codex session: \`${state.session_id ?? "(none yet)"}\``); + lines.push( + `- tokens: in ${state.usage_total.input_tokens} (cached ${state.usage_total.cached_input_tokens}), out ${state.usage_total.output_tokens}`, + ); + lines.push(""); + if (state.stop) lines.push(renderStop(state.stop)); + lines.push(`## Objective`); + lines.push(state.todos.length === 0 ? "_frozen below_" : ""); + lines.push("```text"); + lines.push(goal.objective); + lines.push("```"); + lines.push(""); + lines.push("## Acceptance"); + for (const predicate of goal.predicates) { + lines.push(`- [${satisfied.has(predicate.id) ? "x" : " "}] **${predicate.id}** ${predicate.statement}`); + } + lines.push(""); + lines.push("## Todos"); + if (state.todos.length === 0) lines.push("- (none discovered yet)"); + for (const todo of state.todos) { + lines.push(`- [${todo.status === "done" ? "x" : todo.status === "blocked" ? "!" : " "}] ${todo.id} ${todo.title} → \`${todo.advances}\``); + } + lines.push(""); + lines.push("## Assumptions"); + if (state.assumptions.length === 0) lines.push("- (none)"); + for (const assumption of state.assumptions) { + lines.push(`- ${assumption.id} ${assumption.statement} [${assumption.source.path} @ ${assumption.source.sha256.slice(0, 12)}]`); + } + lines.push(""); + lines.push(`## Journal (last ${Math.min(receipts.length, 20)} of ${receipts.length})`); + lines.push("```text"); + for (const receipt of receipts.slice(-20)) { + lines.push(formatReceiptLine(receipt)); + } + lines.push("```"); + lines.push(""); + return lines.filter((line) => line !== "").join("\n").concat("\n"); +} + +function renderStop(stop: StopRecord): string { + return [ + `> **STOPPED — ${stop.reason}**`, + `>`, + `> ${stop.detail}`, + `>`, + `> This run will not spend another turn until the condition is repaired and the goal is resumed.`, + "", + ].join("\n"); +} + +function formatReceiptLine(receipt: Record): string { + if (typeof receipt.stop === "object" && receipt.stop !== null) { + const stop = receipt.stop as StopRecord; + return `${receipt.at} STOP ${stop.reason}`; + } + const closed = Array.isArray(receipt.closed) ? (receipt.closed as string[]) : []; + const verified = Array.isArray(receipt.verified) + ? (receipt.verified as Array<{ predicate: string; ok: boolean }>) + : []; + const ok = verified.filter((v) => v.ok).map((v) => v.predicate); + const upgraded = Array.isArray(receipt.admitted_todos) ? (receipt.admitted_todos as string[]) : []; + const note = typeof receipt.note === "string" && receipt.note ? ` note=${JSON.stringify(receipt.note.slice(0, 120))}` : ""; + return `${receipt.at} turn ${receipt.turn_index} claimed=[${closed.join(",")}] verified=[${ok.join(",")}] new=${upgraded.length} progress=${receipt.progress ? "yes" : "no"}${note}`; +} diff --git a/packages/loopx-goal-kernel/tests/cli.test.ts b/packages/loopx-goal-kernel/tests/cli.test.ts new file mode 100644 index 0000000000..d16f0b669b --- /dev/null +++ b/packages/loopx-goal-kernel/tests/cli.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const cli = new URL("../src/cli.ts", import.meta.url).pathname; +function fixture(t: test.TestContext) { + const project = mkdtempSync(join(tmpdir(), "gk-cli-")); + t.after(() => rmSync(project, { recursive: true, force: true })); + const spec = { + goal_id: "g", objective: "Create a; then obtain owner acceptance.", + predicates: [ + { id: "a", statement: "a exists", verify: { kind: "file_exists", path: "a" } }, + { id: "owner", statement: "owner accepts", verify: { kind: "owner", note: "review" } }, + ], policy: { max_turns: 1, max_idle_turns: 2 }, + }; + const specPath = join(project, "spec.json"); + writeFileSync(specPath, JSON.stringify(spec)); + const statePath = join(project, ".loopx", "goal-kernel", "goals", "g", "state.json"); + function run(verb: string, args: string[] = []) { + const r = spawnSync(process.execPath, ["--no-warnings", "--experimental-strip-types", cli, + verb, "--project", project, "--id", "g", ...args], { encoding: "utf8", timeout: 5000 }); + assert.equal(r.error, undefined); + return { code: r.status, text: `${r.stdout}${r.stderr}` }; + } + return { project, spec, specPath, statePath, run }; +} + +test("CLI init cannot erase an existing goal's budget and acceptance", t => { + const h = fixture(t); + assert.equal(h.run("init", ["--spec", h.specPath]).code, 0); + const before = readFileSync(h.statePath, "utf8"); + const second = h.run("init", ["--spec", h.specPath]); + assert.equal(second.code, 1); + assert.match(second.text, /already exists/); + assert.equal(readFileSync(h.statePath, "utf8"), before); +}); + +test("CLI rejects malformed budgets, duplicate predicates and invalid turn limits", t => { + const h = fixture(t); + for (const max_turns of [0, -1, 1.5, null, "many"]) { + writeFileSync(h.specPath, JSON.stringify({ ...h.spec, policy: { ...h.spec.policy, max_turns } })); + assert.equal(h.run("init", ["--spec", h.specPath]).code, 1); + } + writeFileSync(h.specPath, JSON.stringify({ ...h.spec, predicates: [h.spec.predicates[0], h.spec.predicates[0]] })); + assert.match(h.run("init", ["--spec", h.specPath]).text, /unique/); + for (const turns of ["0", "-1", "1.5", "NaN", "2suffix"]) { + assert.match(h.run("run", ["--turns", turns]).text, /positive integer/); + } +}); + +test("CLI owner acceptance cannot bypass automatic checks; completion and regression read back", t => { + const h = fixture(t); + assert.equal(h.run("init", ["--spec", h.specPath]).code, 0); + assert.equal(h.run("accept", ["--predicate", "a"]).code, 1); + writeFileSync(join(h.project, "a"), "ok"); + assert.equal(h.run("accept", ["--predicate", "owner"]).code, 0); + // Already satisfied work is verified and settled without launching a model. + assert.equal(h.run("run").code, 0); + assert.match(h.run("status").text, /status=done/); + assert.match(h.run("view").text, /\[x\] \*\*a\*\*/); + rmSync(join(h.project, "a")); + const invalidated = h.run("run"); + assert.equal(invalidated.code, 3, invalidated.text); + assert.match(invalidated.text, /acceptance_regressed/); + assert.equal(h.run("status").code, 3); + assert.match(h.run("view").text, /\[ \] \*\*a\*\*/); + assert.equal(JSON.parse(readFileSync(h.statePath, "utf8")).turn_count, 0); +}); diff --git a/packages/loopx-goal-kernel/tests/context.test.ts b/packages/loopx-goal-kernel/tests/context.test.ts new file mode 100644 index 0000000000..6667865f9d --- /dev/null +++ b/packages/loopx-goal-kernel/tests/context.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { renderContext } from "../src/context.ts"; +import { sha256File } from "../src/hash.ts"; +import type { Goal, KernelState } from "../src/types.ts"; + +function baseGoal(): Goal { + return { + goal_id: "g1", + objective: "Make the greeting module produce a localized greeting.", + predicates: [ + { id: "p1", statement: "greet.py exists", verify: { kind: "file_exists", path: "greet.py" } }, + { + id: "p2", + statement: "greet.py prints 'hello'", + verify: { kind: "command", run: "python3 greet.py", expect_stdout: "hello" }, + }, + ], + policy: { max_turns: 10, max_idle_turns: 2 }, + }; +} + +function baseState(overrides: Partial = {}): KernelState { + return { + version: 1, + goal_id: "g1", + goal_hash: "deadbeef", + session_id: null, + turn_count: 0, + usage_total: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }, + todos: [], + assumptions: [], + verified_predicates: [], + pending_decision: null, + no_progress_streak: 0, + status: "running", + stop: null, + ...overrides, + }; +} + +test("context rendering is deterministic for the same goal and state", () => { + const goal = baseGoal(); + const state = baseState(); + const first = renderContext(goal, state); + const second = renderContext(goal, state); + assert.equal(first.prompt, second.prompt); + assert.equal(first.ctx_hash, second.ctx_hash); +}); + +test("context changes when the objective changes, so a silent edit is visible in ctx_hash", () => { + const state = baseState(); + const before = renderContext(baseGoal(), state); + const after = renderContext({ ...baseGoal(), objective: "Do something else entirely." }, state); + assert.notEqual(before.ctx_hash, after.ctx_hash); +}); + +test("context changes when persisted state changes", () => { + const goal = baseGoal(); + const before = renderContext(goal, baseState()); + const after = renderContext(goal, baseState({ turn_count: 1 })); + assert.notEqual(before.ctx_hash, after.ctx_hash); +}); + +test("context always carries the frozen objective and every predicate", () => { + const goal = baseGoal(); + const { prompt } = renderContext(goal, baseState()); + assert.ok(prompt.includes(goal.objective)); + for (const predicate of goal.predicates) { + assert.ok(prompt.includes(predicate.id), `prompt should mention ${predicate.id}`); + } +}); + +test("context marks a kernel-verified predicate as satisfied, and an owner predicate as never self-certifiable", () => { + const goal = baseGoal(); + const state = baseState({ verified_predicates: ["p1"] }); + const { prompt } = renderContext(goal, state); + assert.ok(prompt.includes("- [x] p1")); + assert.ok(prompt.includes("- [ ] p2")); +}); + +test("context warns once the no-progress streak is non-zero", () => { + const goal = baseGoal(); + const { prompt } = renderContext(goal, baseState({ no_progress_streak: 1 })); + assert.ok(prompt.includes("consecutive turn(s) verified no new checkpoint")); +}); + +test("assumptions are echoed with their revision so a stale one is visible to the model too", () => { + const dir = mkdtempSync(join(tmpdir(), "ctx-")); + const file = join(dir, "doc.md"); + writeFileSync(file, "revision A"); + const digest = sha256File(file); + const goal = baseGoal(); + const state = baseState({ + assumptions: [{ id: "a1", statement: "the doc says A", source: { kind: "file", path: file, sha256: digest } }], + }); + const { prompt } = renderContext(goal, state); + assert.ok(prompt.includes("a1")); + assert.ok(prompt.includes(digest.slice(0, 12))); +}); diff --git a/packages/loopx-goal-kernel/tests/delta.test.ts b/packages/loopx-goal-kernel/tests/delta.test.ts new file mode 100644 index 0000000000..b364ab375c --- /dev/null +++ b/packages/loopx-goal-kernel/tests/delta.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { deltaJsonSchema } from "../src/codex.ts"; +import { applyDelta } from "../src/state.ts"; +import type { Goal, KernelState, TurnDelta } from "../src/types.ts"; + +function goal(): Goal { + return { + goal_id: "g1", + objective: "objective", + predicates: [ + { id: "p1", statement: "one", verify: { kind: "file_exists", path: "a" } }, + { id: "p2", statement: "two", verify: { kind: "file_exists", path: "b" } }, + ], + policy: { max_turns: 5, max_idle_turns: 2 }, + }; +} + +function state(overrides: Partial = {}): KernelState { + return { + version: 1, + goal_id: "g1", + goal_hash: "h", + session_id: null, + turn_count: 0, + usage_total: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }, + todos: [], + assumptions: [], + verified_predicates: [], + pending_decision: null, + no_progress_streak: 0, + status: "running", + stop: null, + ...overrides, + }; +} + +function delta(overrides: Partial = {}): TurnDelta { + return { + closed: [], + new_todos: [], + new_assumptions: [], + proposed_amendment: null, + note: "", + ...overrides, + }; +} + +test("the delta schema requires exactly the fields the kernel reads", () => { + const schema = deltaJsonSchema() as { required: string[]; properties: Record; additionalProperties: boolean }; + assert.deepEqual(schema.required.sort(), [ + "closed", + "new_assumptions", + "new_todos", + "note", + "proposed_amendment", + ]); + assert.equal(schema.additionalProperties, false); +}); + +test("the delta schema is emitted as strict JSON for codex --output-schema", () => { + const round = JSON.parse(JSON.stringify(deltaJsonSchema())); + assert.equal(round.type, "object"); +}); + +test("a scoped todo is admitted and recorded against its predicate", () => { + const s = state(); + const result = applyDelta(s, goal(), delta({ + new_todos: [{ id: "t1", title: "write a", done_when: "a exists", advances: "p1" }], + }), []); + assert.deepEqual(result.admitted, ["t1"]); + assert.equal(s.todos[0].advances, "p1"); + assert.equal(s.todos[0].status, "open"); +}); + +test("an unscoped todo is rejected, not repaired", () => { + const s = state(); + const result = applyDelta(s, goal(), delta({ + new_todos: [{ id: "t1", title: "cleanup", done_when: "clean", advances: "p9" }], + }), []); + assert.deepEqual(result.admitted, []); + assert.equal(result.rejected[0].kind, "todo_unscoped"); + assert.equal(s.todos.length, 0); +}); + +test("a verified predicate closes the open todos that advance it", () => { + const s = state({ todos: [{ id: "t1", title: "x", done_when: "x", advances: "p1", status: "open" }] }); + applyDelta(s, goal(), delta(), ["p1"]); + assert.equal(s.todos[0].status, "done"); + assert.deepEqual(s.verified_predicates, ["p1"]); +}); + +test("acceptance replaces the earlier snapshot instead of accumulating stale passes", () => { + const s = state(); + applyDelta(s, goal(), delta(), ["p1"]); + applyDelta(s, goal(), delta(), ["p1", "p2"]); + assert.deepEqual(s.verified_predicates.sort(), ["p1", "p2"]); + applyDelta(s, goal(), delta(), ["p2"]); + assert.deepEqual(s.verified_predicates, ["p2"]); +}); + +test("an assumption without a real sha256 is refused", () => { + const s = state(); + const result = applyDelta(s, goal(), delta({ + new_assumptions: [ + { id: "a1", statement: "vague", source: { kind: "file", path: "doc.md", sha256: "not-a-hash" } }, + ], + }), []); + assert.equal(result.rejected[0].kind, "assumption_invalid_source"); + assert.equal(s.assumptions.length, 0); +}); + +test("a valid assumption is recorded with its revision", () => { + const s = state(); + const digest = "a".repeat(64); + applyDelta(s, goal(), delta({ + new_assumptions: [{ id: "a1", statement: "doc says X", source: { kind: "file", path: "doc.md", sha256: digest } }], + }), []); + assert.equal(s.assumptions[0].source.sha256, digest); +}); + +test("duplicate todo ids from a confused turn are refused once", () => { + const s = state({ todos: [{ id: "t1", title: "x", done_when: "x", advances: "p1", status: "open" }] }); + const result = applyDelta(s, goal(), delta({ + new_todos: [{ id: "t1", title: "again", done_when: "x", advances: "p1" }], + }), []); + assert.deepEqual(result.admitted, []); + assert.equal(result.rejected[0].kind, "todo_duplicate_id"); +}); + +test("init writes a frozen goal file whose recorded hash matches the file", async () => { + const { GoalStore } = await import("../src/store.ts"); + const { goalFingerprint } = await import("../src/invariants.ts"); + const project = mkdtempSync(join(tmpdir(), "store-")); + const store = new GoalStore(project, join(project, ".loopx"), "g1"); + const g = goal(); + const { goal_hash } = store.initGoal(g); + assert.equal(goal_hash, goalFingerprint(g)); + const written = JSON.parse(readFileSync(store.goalPath, "utf8")); + assert.equal(written.goal_hash, goal_hash); + assert.equal(written.objective, g.objective); +}); + +test("state round-trips through the store", async () => { + const { GoalStore } = await import("../src/store.ts"); + const project = mkdtempSync(join(tmpdir(), "store-")); + const store = new GoalStore(project, join(project, ".loopx"), "g1"); + store.initGoal(goal()); + const s = store.readState(); + s.turn_count = 4; + writeFileSync(join(project, "marker"), "ignored"); + store.writeState(s); + assert.equal(store.readState().turn_count, 4); +}); diff --git a/packages/loopx-goal-kernel/tests/invariants.test.ts b/packages/loopx-goal-kernel/tests/invariants.test.ts new file mode 100644 index 0000000000..56962235b6 --- /dev/null +++ b/packages/loopx-goal-kernel/tests/invariants.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + checkAssumptions, + checkBudget, + checkInvariants, + checkProgress, + checkTodoScoping, + isGoalComplete, +} from "../src/invariants.ts"; +import { sha256File } from "../src/hash.ts"; +import type { Goal, KernelState } from "../src/types.ts"; + +function goal(): Goal { + return { + goal_id: "g1", + objective: "objective", + predicates: [{ id: "p1", statement: "ok", verify: { kind: "file_exists", path: "x" } }], + policy: { max_turns: 3, max_idle_turns: 2 }, + }; +} + +function state(overrides: Partial = {}): KernelState { + return { + version: 1, + goal_id: "g1", + goal_hash: "h", + session_id: null, + turn_count: 0, + usage_total: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }, + todos: [], + assumptions: [], + verified_predicates: [], + pending_decision: null, + no_progress_streak: 0, + status: "running", + stop: null, + ...overrides, + }; +} + +test("a todo that advances no declared predicate is refused", () => { + const violations = checkTodoScoping(goal(), [ + { id: "t1", title: "refactor the world", done_when: "looks nicer", advances: "", status: "open" }, + ]); + assert.equal(violations.length, 1); + assert.equal(violations[0].reason, "unscoped_todo"); +}); + +test("a todo advancing an undeclared predicate is refused", () => { + const violations = checkTodoScoping(goal(), [ + { id: "t1", title: "side quest", done_when: "done", advances: "p99", status: "open" }, + ]); + assert.equal(violations.length, 1); + assert.equal(violations[0].reason, "unscoped_todo"); +}); + +test("a completed todo does not need to stay in scope", () => { + const violations = checkTodoScoping(goal(), [ + { id: "t1", title: "old work", done_when: "done", advances: "", status: "done" }, + ]); + assert.deepEqual(violations, []); +}); + +test("duplicate todo ids are refused", () => { + const violations = checkTodoScoping(goal(), [ + { id: "t1", title: "a", done_when: "a", advances: "p1", status: "open" }, + { id: "t1", title: "b", done_when: "b", advances: "p1", status: "open" }, + ]); + assert.equal(violations.length, 1); +}); + +test("an assumption whose file revision changed is a stale-assumption violation", () => { + const dir = mkdtempSync(join(tmpdir(), "inv-")); + const doc = join(dir, "spec.md"); + writeFileSync(doc, "revision one"); + const assumptions = [ + { id: "a1", statement: "spec says one", source: { kind: "file" as const, path: doc, sha256: sha256File(doc) } }, + ]; + assert.deepEqual(checkAssumptions(dir, assumptions).violations, []); + + writeFileSync(doc, "revision two"); + const report = checkAssumptions(dir, assumptions); + assert.equal(report.violations.length, 1); + assert.equal(report.violations[0].reason, "stale_assumption"); + assert.deepEqual(report.stale_assumptions, ["a1"]); +}); + +test("an assumption whose file disappeared fails closed", () => { + const dir = mkdtempSync(join(tmpdir(), "inv-")); + const report = checkAssumptions(dir, [ + { id: "a1", statement: "gone", source: { kind: "file", path: join(dir, "nope.md"), sha256: "0".repeat(64) } }, + ]); + assert.equal(report.violations[0].reason, "stale_assumption"); +}); + +test("budget exhaustion is a violation at the exact limit", () => { + assert.deepEqual(checkBudget(goal(), state({ turn_count: 2 })), []); + assert.equal(checkBudget(goal(), state({ turn_count: 3 }))[0].reason, "budget_exhausted"); +}); + +test("the no-progress fuse fires at the configured streak", () => { + assert.deepEqual(checkProgress(goal(), state({ no_progress_streak: 1 })), []); + assert.equal(checkProgress(goal(), state({ no_progress_streak: 2 }))[0].reason, "no_progress"); +}); + +test("checkInvariants reports every violation, not only the first", () => { + const dir = mkdtempSync(join(tmpdir(), "inv-")); + const report = checkInvariants( + goal(), + state({ + turn_count: 3, + no_progress_streak: 2, + todos: [{ id: "t1", title: "x", done_when: "x", advances: "", status: "open" }], + }), + dir, + ); + assert.equal(report.ok, false); + const reasons = report.violations.map((v) => v.reason).sort(); + assert.deepEqual(reasons, ["budget_exhausted", "no_progress", "unscoped_todo"]); +}); + +test("completion follows kernel-verified predicates only, never the todo list", () => { + const g = goal(); + // A todo marked done by anything other than verification must not complete the goal. + const claimed = state({ + todos: [{ id: "t1", title: "x", done_when: "x", advances: "p1", status: "done" }], + }); + assert.equal(isGoalComplete(g, claimed), false); + assert.equal(isGoalComplete(g, state({ verified_predicates: ["p1"] })), true); +}); diff --git a/packages/loopx-goal-kernel/tests/kernel.test.ts b/packages/loopx-goal-kernel/tests/kernel.test.ts new file mode 100644 index 0000000000..570ae32186 --- /dev/null +++ b/packages/loopx-goal-kernel/tests/kernel.test.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { GoalKernel } from "../src/kernel.ts"; +import { GoalStore } from "../src/store.ts"; +import { sha256File } from "../src/hash.ts"; +import type { Goal, TurnDelta, Usage } from "../src/types.ts"; + +const ZERO: Usage = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }; + +function goal(overrides: Partial = {}): Goal { + return { + goal_id: "g1", + objective: "Create greeting.py that prints hello.", + predicates: [ + { id: "p1", statement: "greeting.py exists", verify: { kind: "file_exists", path: "greeting.py" } }, + { + id: "p2", + statement: "greeting.py prints hello", + verify: { kind: "command", run: "python3 greeting.py", expect_stdout: "hello" }, + }, + ], + policy: { max_turns: 10, max_idle_turns: 2 }, + ...overrides, + }; +} + +interface Harness { + project: string; + store: GoalStore; + kernel: GoalKernel; + states: Array<{ prompt: string; sessionId: string | null }>; +} + +/** + * A scripted stand-in for Codex. It replaces only the model call; every gate, + * hash, verification and stop decision below runs the real kernel code. + */ +function harness(script: Array TurnDelta)>): Harness { + const project = mkdtempSync(join(tmpdir(), "kernel-")); + const store = new GoalStore(project, join(project, ".loopx"), "g1"); + store.initGoal(goal()); + const states: Array<{ prompt: string; sessionId: string | null }> = []; + let turn = 0; + const kernel = new GoalKernel(store, { + projectRoot: project, + runTurn: async (input) => { + states.push({ prompt: input.prompt, sessionId: input.sessionId }); + const entry = script[Math.min(turn, script.length - 1)]; + turn += 1; + const delta = typeof entry === "function" ? entry({ project, turn }) : entry; + return { + delta, + sessionId: input.sessionId ?? "session-1", + sessionReused: input.sessionId !== null, + usage: { input_tokens: 100, cached_input_tokens: 50, output_tokens: 20 }, + }; + }, + }); + return { project, store, kernel, states }; +} + +const IDLE: TurnDelta = { + closed: [], + new_todos: [], + new_assumptions: [], + proposed_amendment: null, + note: "nothing to do", +}; + +test("a turn that claims a predicate the workspace does not satisfy is not credited", async () => { + const h = harness([{ ...IDLE, closed: ["p1"] }]); + const result = await h.kernel.runOneTurn(); + assert.deepEqual(result.verified, []); + assert.deepEqual(result.claimed, ["p1"]); + assert.equal(result.progress, false); + const state = h.store.readState(); + assert.deepEqual(state.verified_predicates, []); +}); + +test("a turn that actually satisfies the predicate is credited by independent verification", async () => { + const h = harness([ + (input) => { + writeFileSync(join(input.project, "greeting.py"), "print('hello')\n"); + return { ...IDLE, closed: ["p1", "p2"], note: "wrote greeting.py" }; + }, + ]); + const result = await h.kernel.runOneTurn(); + assert.deepEqual(result.verified.sort(), ["p1", "p2"]); + assert.equal(result.progress, true); +}); + +test("a broken claim of a command predicate is rejected even when the file exists", async () => { + const h = harness([ + (input) => { + writeFileSync(join(input.project, "greeting.py"), "print('goodbye')\n"); + return { ...IDLE, closed: ["p1", "p2"] }; + }, + ]); + const result = await h.kernel.runOneTurn(); + assert.deepEqual(result.verified, ["p1"]); + assert.ok(result.rejected.some((r) => r.kind === "unverified_claim")); +}); + +test("a fully verified goal completes and stops the loop", async () => { + const h = harness([ + (input) => { + writeFileSync(join(input.project, "greeting.py"), "print('hello')\n"); + return { ...IDLE, closed: ["p1", "p2"] }; + }, + ]); + const first = await h.kernel.runOneTurn(); + assert.equal(first.stop?.reason, "goal_complete"); + assert.equal(h.store.readState().status, "done"); +}); + +test("the no-progress fuse stops a loop that keeps spending without moving", async () => { + const h = harness([IDLE, IDLE, IDLE, IDLE]); + const one = await h.kernel.runOneTurn(); + assert.equal(one.stop, null); + const two = await h.kernel.runOneTurn(); + assert.equal(two.stop?.reason, "no_progress"); + assert.equal(h.store.readState().status, "stopped"); +}); + +test("a stale assumption stops the loop before the next turn spends anything", async () => { + const h = harness([IDLE]); + const doc = join(h.project, "spec.md"); + writeFileSync(doc, "revision one"); + const store = h.store; + const state = store.readState(); + state.todos.push({ id: "t1", title: "x", done_when: "x", advances: "p1", status: "open" }); + state.assumptions.push({ + id: "a1", + statement: "spec says one", + source: { kind: "file", path: doc, sha256: sha256File(doc) }, + }); + store.writeState(state); + + // The world moves underneath the run. + writeFileSync(doc, "revision two"); + + const result = await h.kernel.runOneTurn(); + assert.equal(result.stop?.reason, "stale_assumption"); + assert.equal(h.states.length, 0, "no model call should happen once the gate fails"); +}); + +test("an unscoped todo already in state blocks the run before any spend", async () => { + const h = harness([IDLE]); + const state = h.store.readState(); + state.todos.push({ id: "t1", title: "refactor", done_when: "nicer", advances: "", status: "open" }); + h.store.writeState(state); + const result = await h.kernel.runOneTurn(); + assert.equal(result.stop?.reason, "unscoped_todo"); + assert.equal(h.states.length, 0); +}); + +test("editing the frozen goal file outside the kernel is detected as a hash mismatch", async () => { + const h = harness([IDLE]); + const written = JSON.parse(readFileSync(h.store.goalPath, "utf8")); + writeFileSync(h.store.goalPath, JSON.stringify({ ...written, objective: "Secretly different objective." })); + const result = await h.kernel.runOneTurn(); + assert.equal(result.stop?.reason, "goal_hash_mismatch"); + assert.equal(h.states.length, 0); +}); + +test("the objective cannot change silently: a proposed amendment stops the run for the owner", async () => { + const h = harness([ + { ...IDLE, proposed_amendment: { objective: "Actually, just delete everything.", reason: "simpler" } }, + ]); + const result = await h.kernel.runOneTurn(); + assert.equal(result.stop?.reason, "goal_amendment_required"); + const state = h.store.readState(); + assert.ok(state.pending_decision, "the proposal must be recorded, not applied"); + // The frozen goal is untouched. + assert.equal(h.store.readGoal().objective, "Create greeting.py that prints hello."); +}); + +test("turn budget is enforced", async () => { + const h = harness([IDLE]); + h.store.initGoal(goal({ policy: { max_turns: 1, max_idle_turns: 9 } })); + await h.kernel.runOneTurn(); + const second = await h.kernel.runOneTurn(); + assert.equal(second.stop?.reason, "budget_exhausted"); +}); + +test("a resumed turn reuses the session id from state", async () => { + const h = harness([IDLE, IDLE]); + h.store.initGoal(goal({ policy: { max_turns: 10, max_idle_turns: 9 } })); + await h.kernel.runOneTurn(); + await h.kernel.runOneTurn(); + assert.equal(h.states[0].sessionId, null); + assert.equal(h.states[1].sessionId, "session-1"); +}); + +test("the journal records a receipt per turn with the context hash and verification result", async () => { + const h = harness([ + (input) => { + writeFileSync(join(input.project, "greeting.py"), "print('hello')\n"); + return { ...IDLE, closed: ["p1", "p2"] }; + }, + ]); + await h.kernel.runOneTurn(); + const receipts = h.store.readJournal(); + const turn = receipts.find((r) => "ctx_hash" in r); + assert.ok(turn); + assert.ok((turn as { ctx_hash: string }).ctx_hash.length === 64); + assert.ok((turn as { verified: Array<{ ok: boolean }> }).verified.every((v) => v.ok)); +}); + +test("the loop is restartable: a fresh kernel instance continues from state alone", async () => { + const h = harness([IDLE, IDLE, IDLE]); + h.store.initGoal(goal({ policy: { max_turns: 10, max_idle_turns: 9 } })); + await h.kernel.runOneTurn(); + // Simulate a process restart: brand new kernel over the same directory. + const restarted = new GoalKernel(h.store, { + projectRoot: h.project, + runTurn: async (input) => ({ + delta: IDLE, + sessionId: input.sessionId ?? "session-1", + sessionReused: input.sessionId !== null, + usage: ZERO, + }), + }); + const result = await restarted.runOneTurn(); + assert.equal(result.turn_index, 2); + assert.equal(h.store.readState().turn_count, 2); +}); + +test("VIEW.md is a deterministic projection of goal and state", async () => { + const h = harness([IDLE]); + await h.kernel.runOneTurn(); + const view = readFileSync(h.store.viewPath, "utf8"); + assert.ok(view.includes("Create greeting.py that prints hello.")); + assert.ok(view.includes("p1")); + assert.ok(view.includes("turns spent: 1")); +}); diff --git a/packages/loopx-goal-kernel/tests/reliability.test.ts b/packages/loopx-goal-kernel/tests/reliability.test.ts new file mode 100644 index 0000000000..73ae6c2cca --- /dev/null +++ b/packages/loopx-goal-kernel/tests/reliability.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { GoalKernel } from "../src/kernel.ts"; +import { GoalStore } from "../src/store.ts"; +import type { Goal, TurnDelta } from "../src/types.ts"; + +const idle: TurnDelta = { + closed: [], new_todos: [], new_assumptions: [], proposed_amendment: null, note: "", +}; +const spec: Goal = { + goal_id: "g", objective: "Create a and b and keep both present.", + predicates: ["a", "b"].map(id => ({ id, statement: `${id} exists`, verify: { kind: "file_exists", path: id } })), + policy: { max_turns: 10, max_idle_turns: 2 }, +}; + +function harness(t: test.TestContext, step: (project: string, turn: number, prompt: string) => TurnDelta, goal = spec) { + const project = mkdtempSync(join(tmpdir(), "gk-reliability-")); + t.after(() => rmSync(project, { recursive: true, force: true })); + const store = new GoalStore(project, join(project, ".loopx"), "g"); + store.initGoal(goal); + let calls = 0; + const options = { + projectRoot: project, + runTurn: async (input: { prompt: string; sessionId: string | null }) => ({ + delta: step(project, ++calls, input.prompt), sessionId: "synthetic", + sessionReused: input.sessionId !== null, + usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 }, + }), + }; + return { project, store, options, kernel: new GoalKernel(store, options), calls: () => calls }; +} + +test("planning alone does not reset the idle fuse", async t => { + const h = harness(t, (_, turn) => ({ ...idle, new_todos: [ + { id: `t${turn}`, title: "Plan again", done_when: "later", advances: "a" }, + ] })); + assert.equal((await h.kernel.runOneTurn()).progress, false); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "no_progress"); + await h.kernel.runOneTurn(); + assert.equal(h.calls(), 2); +}); + +test("repeated claims of a passed check earn no new progress", async t => { + const h = harness(t, p => { + writeFileSync(join(p, "a"), "ok"); + return { ...idle, closed: ["a", "a"] }; + }); + assert.deepEqual((await h.kernel.runOneTurn()).verified, ["a"]); + assert.equal((await h.kernel.runOneTurn()).progress, false); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "no_progress"); +}); + +test("a later turn invalidates prior acceptance and reopens its todo", async t => { + const h = harness(t, (p, turn) => { + if (turn === 1) { + writeFileSync(join(p, "a"), "ok"); + return { ...idle, closed: ["a"], new_todos: [ + { id: "ta", title: "Create a", done_when: "a exists", advances: "a" }, + ] }; + } + unlinkSync(join(p, "a")); + writeFileSync(join(p, "b"), "ok"); + return { ...idle, closed: ["b"] }; + }); + await h.kernel.runOneTurn(); + const next = await h.kernel.runOneTurn(); + assert.equal(next.stop, null); + assert.deepEqual(h.store.readState().verified_predicates, ["b"]); + assert.equal(h.store.readState().todos[0].status, "open"); + assert.match(readFileSync(h.store.viewPath, "utf8"), /\[ \] \*\*a\*\*/); +}); + +test("external regression is reflected in the next prompt after restart", async t => { + const h = harness(t, (p, turn, prompt) => { + if (turn === 2) assert.match(prompt, /- \[ \] a: a exists/); + writeFileSync(join(p, "a"), "ok"); + return { ...idle, closed: ["a"] }; + }); + await h.kernel.runOneTurn(); + unlinkSync(join(h.project, "a")); + const restarted = new GoalKernel(h.store, h.options); + assert.equal((await restarted.runOneTurn()).progress, false, "restoring credited work is not a new checkpoint"); + assert.equal((await restarted.runOneTurn()).stop?.reason, "no_progress"); +}); + +test("completion on the last admitted turn succeeds", async t => { + const h = harness(t, p => { + for (const id of ["a", "b"]) writeFileSync(join(p, id), "ok"); + return { ...idle, closed: ["a", "b"] }; + }, { ...spec, policy: { max_turns: 1, max_idle_turns: 1 } }); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "goal_complete"); + assert.equal(h.store.readState().status, "done"); + await h.kernel.runOneTurn(); + assert.equal(h.calls(), 1); +}); + +test("an incomplete last turn still exhausts budget", async t => { + const h = harness(t, () => idle, { ...spec, policy: { max_turns: 1, max_idle_turns: 9 } }); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "budget_exhausted"); + await h.kernel.runOneTurn(); + assert.equal(h.calls(), 1); +}); + +test("a changed assumption takes precedence over otherwise complete work", async t => { + const h = harness(t, p => { + for (const id of ["a", "b"]) writeFileSync(join(p, id), "ok"); + return { ...idle, closed: ["a", "b"], new_assumptions: [ + { id: "source", statement: "a has fixed contents", source: { kind: "file", path: "a", sha256: "0".repeat(64) } }, + ] }; + }, { ...spec, policy: { max_turns: 1, max_idle_turns: 1 } }); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "stale_assumption"); +}); + +test("a goal edit during a turn is caught before accepting completion", async t => { + const h = harness(t, p => { + for (const id of ["a", "b"]) writeFileSync(join(p, id), "ok"); + const goal = JSON.parse(readFileSync(h.store.goalPath, "utf8")); + writeFileSync(h.store.goalPath, JSON.stringify({ ...goal, objective: "Different objective" })); + return { ...idle, closed: ["a", "b"] }; + }); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "goal_hash_mismatch"); +}); + +test("pending owner decisions prevent any model call", async t => { + const h = harness(t, () => idle); + const state = h.store.readState(); + state.pending_decision = { + turn_id: "earlier", raised_at: "2026-01-01T00:00:00Z", before_hash: state.goal_hash, after_hash: "other", + proposal: { objective: "change", reason: "requires owner" }, + }; + h.store.writeState(state); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "goal_amendment_required"); + assert.equal(h.calls(), 0); +}); + +test("verified artifacts count even when the model omits a claim", async t => { + const h = harness(t, p => { + for (const id of ["a", "b"]) writeFileSync(join(p, id), "ok"); + return idle; + }); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "goal_complete"); +}); + +test("legacy state retains earlier progress credit", async t => { + const h = harness(t, () => ({ ...idle, closed: ["a"] })); + writeFileSync(join(h.project, "a"), "ok"); + const state = h.store.readState(); + state.verified_predicates = ["a"]; + delete state.credited_predicates; + h.store.writeState(state); + assert.equal((await h.kernel.runOneTurn()).progress, false); +}); + +test("owner-only acceptance is preserved but cannot be claimed by a turn", async t => { + const g: Goal = { ...spec, predicates: [spec.predicates[0], { id: "owner", statement: "owner accepts", verify: { kind: "owner", note: "review" } }] }; + const h = harness(t, p => { writeFileSync(join(p, "a"), "ok"); return { ...idle, closed: ["a", "owner"] }; }, g); + assert.equal((await h.kernel.runOneTurn()).stop, null); + assert.deepEqual(h.store.readState().verified_predicates, ["a"]); + const state = h.store.readState(); + state.verified_predicates.push("owner"); + h.store.writeState(state); + assert.equal((await h.kernel.runOneTurn()).stop?.reason, "goal_complete"); + assert.equal(h.calls(), 1); +}); diff --git a/packages/loopx-goal-kernel/tsconfig.json b/packages/loopx-goal-kernel/tsconfig.json new file mode 100644 index 0000000000..a28daed0c1 --- /dev/null +++ b/packages/loopx-goal-kernel/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "examples/**/*.ts" + ] +}