diff --git a/.cursor/skills/dag-task-runner/SKILL.md b/.cursor/skills/dag-task-runner/SKILL.md deleted file mode 100644 index af19dbfa..00000000 --- a/.cursor/skills/dag-task-runner/SKILL.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: dag-task-runner -description: DEPRECATED ALIAS — the DAG task runner has been promoted to the workspace package @flatbread/proof. Use the `proof` skill (.cursor/skills/proof/SKILL.md) for new work; this entry only exists to redirect agents that still reference the old name. ---- - -# DAG Task Runner — moved to `proof` - -This skill has been renamed and promoted from a copy-into-skill bundle to a first-class Flatbread monorepo package. - -## What changed - -| Before | After | -| -------------------------------------------------------- | -------------------------------------------- | -| Skill name `dag-task-runner` | Skill name `proof` | -| Runtime in `.cursor/skills/dag-task-runner/scripts/*.ts` | Runtime in `packages/proof/src/*.ts` | -| Run via `tsx .cursor/skills/.../run_dag.ts` | Run via `pnpm exec proof` | -| Supervisor `tsx .../run_dag_supervisor.ts` | Supervisor `pnpm exec proof-supervisor` | -| Default state dir `.dag-runner/` | Default state dir `.proof/` | -| Log prefix `[dag-runner]` / `[dag-runner-supervisor]` | Log prefix `[proof]` / `[proof-supervisor]` | -| Examples at `.cursor/skills/dag-task-runner/examples/` | Examples at `.cursor/skills/proof/examples/` | - -CLI flag names, the DAG JSON schema, the `.canvas.tsx` shape, oracle / pause / convergence semantics, and the public library API are all unchanged. Existing DAG JSON files and persisted run-state files (move them from `.dag-runner/` to `.proof/` if you want to resume) work as-is. - -## What to do - -1. Open `.cursor/skills/proof/SKILL.md` for the canonical workflow. -2. Replace any hardcoded `.cursor/skills/dag-task-runner/scripts/run_dag.ts` paths in your prompts / playbooks with the `pnpm exec proof` invocation. -3. If you have an in-flight run with `.dag-runner/run-state.json`, either rename the directory to `.proof/` or pass the old path explicitly via `--state-path`. - -## Why - -`dag-task-runner` was always a copy-into-project bundle, which meant every project carried its own bit-rotted snapshot of the runtime. Promoting it to `@flatbread/proof` lets the runtime evolve in lockstep with the rest of the Flatbread monorepo (tsup builds, lint, type checks) and gives downstream tooling a stable `import { parseDAG, computeRanks, ... } from '@flatbread/proof'` library surface alongside the CLI. diff --git a/.cursor/skills/proof/SKILL.md b/.cursor/skills/proof/SKILL.md deleted file mode 100644 index 6bafdfe4..00000000 --- a/.cursor/skills/proof/SKILL.md +++ /dev/null @@ -1,270 +0,0 @@ ---- -name: proof -description: Decompose a user's task into a DAG of subtasks and execute them with Cursor SDK local subagents in topological order, rendering live streaming status to a canvas. Each task has a complexity (HIGH/MED/LOW) that maps to a model. Use when the user asks to fan out work, decompose a task into a DAG, run subagents in parallel, or break a large task into a dependency graph. ---- - -# Proof - -Decomposes a user-described task into a JSON DAG, then runs each node as a Cursor SDK local subagent (with parents' outputs stitched into the child's prompt). Live DAG state — including each running subagent's streaming output — is rendered into a `.canvas.tsx` that the runner rewrites on every status transition; the IDE hot-recompiles so the user sees subagents move through `PENDING -> RUNNING -> FINISHED/ERROR` in real time. - -The runtime ships as the workspace package `@flatbread/proof` (`packages/proof`). It exposes two CLIs — `proof` (runner) and `proof-supervisor` (self-hosting wrapper) — plus a public library API for tooling that wants to author or inspect DAGs programmatically. - -## When to use - -Trigger when the user says any of: - -- "decompose this task", "break this into a DAG", "fan out subagents" -- "run this as a graph of subtasks" -- a multi-step request where some steps clearly depend on others and others can run in parallel - -Skip when the task is a single-shot edit, a quick question, or already linear enough that one agent turn would handle it. - -## Workflow - -### Step 1 — Generate a DAG JSON - -You (the parent agent) author the DAG inline using your understanding of the user's task. Schema: - -```json -{ - "title": "", - "models": { - "HIGH": { - "id": "gpt-5.4", - "params": [{ "id": "reasoning", "value": "high" }] - }, - "MED": "composer-2", - "LOW": { - "id": "gpt-5.4-nano", - "params": [{ "id": "reasoning", "value": "low" }] - } - }, - "tasks": [ - { - "id": "", - "depends_on": ["", "..."], - "complexity": "HIGH | MED | LOW", - "subtask_prompt": "" - } - ] -} -``` - -Rules: - -- Every `depends_on` entry must reference another task's `id`. -- No cycles. The runner rejects cyclic DAGs at parse time. -- `complexity` controls the model the subagent uses (see table below). Pick `HIGH` for novel/complex reasoning, `MED` for typical implementation, `LOW` for mechanical/lookup tasks. -- Optional top-level `models` can override the default complexity → model map for this DAG. Values can be plain SDK model id strings or model selection objects of the shape `{ "id": "...", "params": [{ "id": "...", "value": "..." }] }`, with `params` omitted when unused. -- `subtask_prompt` should read like a standalone request — the runner automatically prepends a short summary of upstream task outputs, so you do not need to repeat them. -- Do **not** put two tasks that write to the same file in the same rank (siblings within a rank run concurrently and would race). - -#### Maximize parallelism — this is the whole point of the runner - -The runner executes tasks within a rank **concurrently** via `Promise.all`. A linear `A → B → C → D` DAG wastes that capability. Before finalizing the DAG, actively decompose the problem to surface independent work: - -1. **Default to no dependencies.** Add a `depends_on` entry **only** when the child task literally cannot start without the parent's output. "Logically follows" is not a dependency. -2. **Split read-only research and discovery into a wide first rank.** Codebase grepping, doc reading, dependency scans, schema lookups, test inventory — these almost always share rank 1 with no edges between them. -3. **Fan out post-implementation work.** Tests, docs, changelog entries, type updates, lint fixes typically all depend on the same implementation task and on nothing else — put them in one rank, not a chain. -4. **Use diamonds, not lines.** If two tasks both feed into a third, model that explicitly: rank 1 has the two parents, rank 2 is the merge. -5. **Same-rank file-write safety.** The one hard constraint: don't put two tasks in the same rank if they would write the same file. Either serialize them with a `depends_on`, or merge them into one task. - -Quality bar: when you sketch the rank structure (rank 1 → rank 2 → …), at least one rank should contain more than one task in any non-trivial problem. If your DAG is a single chain of 1-task ranks, you almost certainly missed parallelism — go back and look again. - -The example shipped with the skill (`.cursor/skills/proof/examples/example_dag.json`) demonstrates the pattern: rank 1 fans out to two read-only research tasks, rank 2 merges them into a design, rank 3 implements, and rank 4 fans out again to tests + docs. - -Write the JSON to a temp file **and immediately generate the initial canvas** so the user can open it while subagents spin up. Run all of the following in a single shell block: - -```bash -# 0. Pick a canvas path -CANVAS_PATH="$HOME/.cursor/projects//canvases/dag-.canvas.tsx" - -# 1. Write the DAG JSON -cat > /tmp/dag-.json <<'JSON' -{ "title": "...", "tasks": [ ... ] } -JSON - -# 2. Build the @flatbread/proof package once per workspace install -# (skipped if dist/ is already present; safe to re-run). -[ -f "$(git rev-parse --show-toplevel)/packages/proof/dist/run_dag.js" ] || \ - pnpm -F @flatbread/proof build - -# 3. Generate the initial all-PENDING canvas (no CURSOR_API_KEY needed) -pnpm exec proof \ - --init-only \ - --dag /tmp/dag-.json \ - --canvas-path "$CANVAS_PATH" - -# 4. Best-effort auto-open of the canvas file; ignore failure in headless/non-macOS environments -open "$CANVAS_PATH" >/dev/null 2>&1 || true -``` - -The canvas path is: - -``` -~/.cursor/projects//canvases/dag-.canvas.tsx -``` - -`` is derived from the cwd's absolute path by stripping the leading `/`, replacing path separators with `-`, and sanitizing other non-alphanumeric characters within each path segment to `-`. Example: cwd `/Users/me/Code/myapp` → slug `Users-me-Code-myapp`. Use the same `` you used for the DAG JSON filename so they're easy to correlate. - -### Step 2 — Surface the canvas link in chat - -Now that the file exists on disk, post a Markdown hyperlink with the exact text `Open Canvas` and a `file://` URL, plus the absolute path for fallback: - -> I created a live canvas: [Open Canvas](file:///Users//.cursor/projects//canvases/dag-.canvas.tsx) -> Fallback path: `/Users//.cursor/projects//canvases/dag-.canvas.tsx` - -Always use the link text `Open Canvas`. Use the absolute path in both the `file://` URL and fallback path, never `~/`. Do this **before** Step 3 so the user can open the canvas while subagents are still spinning up. The Step 1 shell block already attempts to auto-open the canvas with `open`; if that fails, continue and rely on the chat link. - -### Step 3 — Run the DAG - -Ensure `CURSOR_API_KEY` is set (the runner fails fast if missing), then launch: - -```bash -[ -n "$CURSOR_API_KEY" ] || { [ -f .env ] && set -a && source .env && set +a; } - -pnpm exec proof \ - --dag /tmp/dag-.json \ - --canvas-path "$CANVAS_PATH" -``` - -If the DAG is expected to edit the runner itself (`packages/proof/src/**`), launch through the supervisor instead so source edits take effect at a process boundary: - -```bash -pnpm exec proof-supervisor \ - --dag /tmp/dag-.json \ - --canvas-path "$CANVAS_PATH" \ - --state-path "$HOME/.cursor/projects//dag-state/.json" -``` - -The supervisor passes `--restart-on-runner-change` to the runner. When runner runtime files change after a rank or convergence iteration, the child runner persists state, marks the canvas `RESTARTING RUNNER`, exits `75`, and the supervisor relaunches with `--resume-state` so pending tasks continue under the new source. After editing `packages/proof/src/**`, run `pnpm -F @flatbread/proof build` so the relaunch picks up the new code. - -Same `--canvas-path` as Step 1. The runner: - -1. Validates the DAG and reuses the existing canvas file. -2. For each rank (Kahn topo-sort), launches ready tasks concurrently as local Cursor SDK agents and rewrites the canvas as each one transitions, streaming assistant text into each task card live. -3. Automatically skips tasks whose upstream dependencies failed (marks them `ERROR` with a "Skipped: upstream task(s) … failed" message). -4. Captures each subagent's final assistant text, status, token usage, and duration. -5. Writes a final canvas with summary stats. -6. Artifact output (default, suppress with `--no-artifacts` or override path with `--full-output-dir`; skipped entirely for `--init-only` and `--dry-check-cmds`): - - **At run start:** writes `_dag.json` (the original DAG definition) to the artifacts directory. - - **As each task finishes:** writes `${taskId}.md` (full transcript for `kind: task`, `oracle`, and `pause`). - - **At run end:** best-effort `_index.md` (run summary table with timestamps, outcome, and per-task links for transcripts that exist); write failures are logged as `[proof]` warnings rather than crashing the runner. -7. On SIGINT/SIGTERM/SIGHUP, cancels all in-flight subagents before finalizing the canvas. - -#### CLI knobs - -| Flag | Default | Purpose | -| ------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--models-file ` | — | JSON file containing a partial complexity → model override map. | -| `--state-path ` | — | Persist resumable state after rank boundaries. | -| `--resume-state ` | — | Resume from a persisted state file. | -| `--restart-on-runner-change` | `false` | Exit `75` after runner runtime files change so a supervisor can relaunch. | -| `--task-timeout-ms ` | `1200000` (20 min) | Marks a task `ERROR` if it runs too long. | -| `--stream-publish-ms ` | `500` | Throttles live canvas streaming writes. | -| `--stream-idle-timeout-ms ` | `300000` (5 min) | Marks a task `ERROR` if no stream events arrive. | -| `--debounce ` | `200` | Canvas write debounce interval. | -| `--full-output-dir ` | computed default | Per-task transcripts + `_index.md` + `_dag.json`. Default: `/.flatbread/artifacts/dag--/`. Override path or suppress with `--no-artifacts`. | -| `--no-artifacts` | `false` | Suppresses per-task transcripts, `_index.md`, and `_dag.json`; does **not** suppress `--findings-dir` JSON sidecars (separate code path). Canvas is still written. | - -### Step 4 — Summarize - -After the runner exits, briefly summarize what completed/failed and re-link the canvas with the exact text `[Open Canvas](file:///Users//.cursor/projects//canvases/dag-.canvas.tsx)` so the user can scroll back to it. Include the absolute fallback path only if useful. - -## Complexity → model - -| Complexity | Model | -| ---------- | ----------------- | -| HIGH | `claude-opus-4-7` | -| MED | `composer-2` | -| LOW | `gpt-5.4-nano` | - -Override any subset inline with top-level DAG `models`, or pass a reusable profile with `--models-file `. Values can be plain SDK model id strings or SDK model selections with `params`. At run time, Proof calls `Cursor.models.list()`, validates ids and param values, and expands partial selections by requiring requested params to match a catalog variant, then choosing the valid variant whose omitted params best match the model's default variant. Precedence is defaults < DAG `models` < `--models-file`. The Cursor model catalog can vary by account. - -To use a cheaper high-capability GPT model, use the base SDK id plus params, not a suffix-style id: - -```json -{ - "models": { - "HIGH": { - "id": "gpt-5.4", - "params": [{ "id": "reasoning", "value": "high" }] - } - } -} -``` - -### Discovering valid model ids - -Many Cursor CLI catalog models encode reasoning effort and Max Mode as **slug suffixes** (e.g. `claude-opus-4-7-thinking-max`, `gpt-5.5-extra-high`, `gpt-5.3-codex-xhigh`), but the Cursor SDK may accept only base slugs plus `params`. Do not compose SDK model ids from CLI suffixes by hand: use `{ "id": "gpt-5.4", "params": [{ "id": "reasoning", "value": "high" }] }`, not `gpt-5.4-high`. For SDK-bound code, prefer `Cursor.models.list()` or the SDK's `ConfigurationError` catalog over `cursor-agent --list-models`. - -Ways to enumerate model ids: - -```bash -# CLI catalog — useful for CLI runs, not authoritative for @cursor/sdk -cursor-agent --list-models - -# SDK-flavored alternative — also prints any per-model `parameters` and preset `variants` -pnpm -F @flatbread/proof models:list # all ids -pnpm -F @flatbread/proof models:list # detail for one model -pnpm -F @flatbread/proof models:list --grep # case-insensitive filter -pnpm -F @flatbread/proof models:list --json -``` - -## Auth - -The runner reads `CURSOR_API_KEY` from the environment. Set it however you usually manage secrets: - -```bash -export CURSOR_API_KEY=crsr_... -``` - -If the current workspace has a `.env` containing it, source that first: - -```bash -set -a && source .env && set +a -``` - -## CLI options - -| Flag | Default | Notes | -| ---------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--dag` | required | Path to the DAG JSON file. | -| `--canvas-path` | composed from below | Full path to the canvas file. Preferred as an absolute path for parent-managed flow; relative paths are accepted and resolve from the runner process cwd, not `--cwd`. | -| `--canvas` | — | Canvas filename stem (no `.canvas.tsx`). Used only if `--canvas-path` is omitted. | -| `--canvases-dir` | derived from cwd | Override the canvases output directory. Used only with `--canvas`. | -| `--cwd` | `process.cwd()` | Working dir each subagent operates in. | -| `--models-file` | — | JSON file containing a partial complexity → model override map. | -| `--debounce` | `200` (ms) | Canvas write debounce interval. | -| `--init-only` | `false` | Write the initial all-`PENDING` canvas and exit. No `CURSOR_API_KEY` required. | -| `--full-output-dir` | computed default | Per-task transcripts as `${taskId}.md` plus `_index.md` and `_dag.json`. Defaults to `/.flatbread/artifacts/dag--/`. Override with an explicit path or suppress with `--no-artifacts`. | -| `--no-artifacts` | `false` | Suppresses per-task transcripts, `_index.md`, and `_dag.json`; does **not** suppress `--findings-dir` JSON sidecars (separate code path). Canvas is still written. | -| `--findings-dir` | — | Per-task JSON sidecars as `${taskId}.findings.json` for original runs and `${taskId}.iter.findings.json` for convergence re-runs. Schema: `{ taskId, iteration, status, durationMs, sections }`. | -| `--state-path` | — | Persist resumable runner state. Defaults to `.proof/run-state.json` when `--restart-on-runner-change` is set. | -| `--resume-state` | — | Load a persisted `RunState` and skip already terminal tasks. | -| `--restart-on-runner-change` | `false` | Detect runner runtime file changes after safe boundaries and exit `75` for supervisor restart. | -| `--max-runner-restarts` | `20` | Supervisor-only cap for relaunches from `proof-supervisor`. | -| `--task-timeout-ms` | `1200000` (20 min) | Marks a task `ERROR` if it exceeds this duration. | -| `--stream-publish-ms` | `500` (ms) | Throttles live canvas streaming writes to avoid excessive cloning. | -| `--stream-idle-timeout-ms` | `300000` (5 min) | Marks a task `ERROR` if no stream events arrive within this window. | - -## Caveats - -- Per-task markdown transcripts, a run index (`_index.md`), and the DAG definition (`_dag.json`) are written under **`/.flatbread/artifacts/`** by default on **full DAG runs** (not `--init-only` or `--dry-check-cmds`). Pass `--no-artifacts` to suppress transcripts/index/DAG JSON, or `--full-output-dir` to override the path. `_index.md` links only transcripts that exist; if an individual transcript write fails, that row is marked as a missing transcript. **`--no-artifacts` does not disable `--findings-dir`** — for fully clean disk output, omit `--findings-dir` as well. In CI or read-only workspaces you may want `--no-artifacts` or a writable `--full-output-dir`. -- When using `proof-supervisor`, each **child runner process** recomputes the default artifacts path with a new timestamp unless you pin a stable directory. The supervisor forwards the full argv to each child (only `--max-runner-restarts` is stripped), so put **`--full-output-dir ` on the supervisor invocation** if every restart should write into the same artifacts folder. -- `--resume-state` creates a new artifact directory for the resumed session; tasks completed in prior sessions do not have transcripts in the new directory. -- Local runtime only — every subagent runs against `--cwd` (defaults to wherever you invoke the runner). -- Sibling tasks in the same rank run in parallel; do not let them write the same files. -- Inline MCP servers and sub-sub-agents are not configured by this runner. -- A failed upstream task skips downstream dependents (`ERROR` with `Skipped:` when any upstream is **`ERROR`** or **`BUDGET-EXCEEDED`**). -- Canvas-inlined streamed text stays bounded (**`CANVAS_DISPLAY_CAP = 4000`** tail per task plus the existing `[...truncated N earlier chars...]` banner). For `kind: 'task'`, child prompts, in-process convergence loops, findings sidecars, and artifact markdown use a separate **execution transcript**; resumed runs can reconstruct it when the same `--full-output-dir` is reused and `transcriptPath` points at the mirrored stream file. Pause/oracle tasks still use their bounded status/output text. Upstream excerpts default to the same **2000-char section-aware policy** as before, now with explicit counted banners when trimming. Set **`DAG.outputPolicy.upstream`** to **`"full"`** to stitch full parent transcripts (mind model context limits). -- Timed-out tasks are marked `ERROR` instead of staying indefinitely in `RUNNING`. -- SIGINT/SIGTERM/SIGHUP gracefully cancel all in-flight subagents and finalize the canvas before exiting. -- Unexpected unhandled rejections from SDK internals are suppressed to prevent runner crashes; uncaught exceptions are logged and trigger a clean shutdown. - -## Reference - -- Package: `@flatbread/proof` at `packages/proof` -- DAG schema example: `.cursor/skills/proof/examples/example_dag.json` -- Library exports: `import { parseDAG, computeRanks, ... } from '@flatbread/proof'` -- Cursor SDK docs: https://cursor.com/docs/api/sdk/typescript diff --git a/.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json b/.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json deleted file mode 100644 index ec07e91c..00000000 --- a/.cursor/skills/proof/examples/dag-flatbread-flow-pmf-audit.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "title": "Flatbread Flow PMF Audit (no sub-sub-agents)", - "framing": "Treat Flatbread as Git-native relational content for TypeScript apps, backed by flat files. GraphQL is one interface, not the whole product identity.", - "models": { - "HIGH": { "id": "claude-opus-4-7" }, - "MED": { "id": "gpt-5.5" }, - "LOW": { "id": "gpt-5.4-mini" } - }, - "tasks": [ - { - "id": "map-current-flow", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files (frontmatter `readonly: true` is advisory in DAG runs).\n\nMap the current end-to-end flow: how developers define content models, sources, transformers, generated APIs/types, querying, examples, and runtime usage. Distinguish where GraphQL is structurally required vs. one of several interfaces. Read repo docs and source as needed. For `## Current contract` capture: data source config shape, root query naming, ID/ref semantics, filter capabilities, generated TypeScript shape, CLI behavior, and obvious developer-path friction. Reference files as `path/to/file.ts:line`." - }, - { - "id": "relational-content-needs", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nIndependently audit what a developer who wants Git-native relational content would need from Flatbread. Stay in Flatbread's vocabulary (`Content`, `BaseContentNode`, `Source`, `Transformer`, `Override` per `packages/core/src/types.ts`) — do NOT import database vocabulary like tables/foreign keys/joins/constraints/indexes/import-export. Compare needs against what the repo provides today: content collections, refs between collections, query/filter ergonomics, type safety, validation, the local edit/query loop, codegen, and example integration. Map needs to the schema's headings: `## Current contract` is what exists today, `## Proposed contract` is what would close the highest-leverage gaps, `## Migration impact` is what users would have to change, `## Validation plan` is how to prove each gap closure works." - }, - { - "id": "docs-onboarding-audit", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nAudit the repository docs, examples, tests, package scripts, and README/onboarding path for a first-time developer. Focus on whether the relational content promise is obvious, whether the first success path is short, and where the developer is forced into GraphQL-specific concepts before they get value. `## Current contract` is the documented promise + steps; `## Proposed contract` is what the docs should promise instead; `## Migration impact` is the docs/example surface area to touch." - }, - { - "id": "market-positioning-audit", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nUsing the repo as the primary evidence plus general product reasoning, audit Flatbread's likely product-market fit for developers who want Git-native relational content for TypeScript apps. Consider adjacent alternatives: Contentlayer, Velite, Keystatic, MDX-based content layers, Sanity/Contentful-style headless CMSes, Astro Content Collections, and (only when honest) embedded databases like SQLite. `## Current contract` is the implicit positioning today; `## Proposed contract` is the sharpest defensible positioning; `## Migration impact` is what the README/landing copy would need to say." - }, - { - "id": "synthesize-pmf-gaps", - "depends_on": [ - "map-current-flow", - "relational-content-needs", - "docs-onboarding-audit", - "market-positioning-audit" - ], - "complexity": "HIGH", - "subtask_prompt": "You are acting as `flatbread-architecture-planner` operating as the rank-2 merge node. Follow its output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`.\n\nSynthesize upstream audit findings into a prioritized PMF gap report. Call out GraphQL coupling honestly only when upstream evidence supports it. For each gap include: severity (P0/P1/P2), evidence (file refs from upstream), product implication, and the contract change it implies.\n\nIMPORTANT for survival under the 2000-char downstream stitch: keep `## Current contract` to a single 1-2 line summary so the gap table at the top of `## Proposed contract` lands within the first 2000 chars for `recommend-roadmap`." - }, - { - "id": "recommend-roadmap", - "depends_on": ["synthesize-pmf-gaps"], - "complexity": "HIGH", - "subtask_prompt": "You are acting as `flatbread-architecture-planner` producing a roadmap recommendation. Follow its output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`.\n\nBased on the synthesized PMF gaps, recommend a concise product direction and roadmap. Output a sharper positioning statement, 3-5 product primitives to add or clarify (each with file/package anchor), near-term experiments, and what not to build yet. `## Migration impact` should map each recommendation to the affected packages so a follow-up `flatbread-major-migration` DAG (template at `.cursor/skills/proof/examples/flatbread/dag-schema-migration.json`) can be authored from this output without re-deriving scope." - } - ] -} diff --git a/.cursor/skills/proof/examples/example_dag.json b/.cursor/skills/proof/examples/example_dag.json deleted file mode 100644 index 26167c20..00000000 --- a/.cursor/skills/proof/examples/example_dag.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "title": "Build a browser graphing calculator", - "tasks": [ - { - "id": "research-calculator-scope", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "Sketch the smallest reasonable feature set for a dependency-free browser graphing calculator. Include expression input, x/y viewport controls, canvas plotting, error states, and a few sample expressions. Output as markdown bullets only — do not write any code yet." - }, - { - "id": "research-expression-safety", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "Summarize a conservative approach for evaluating math expressions in a small browser-only graphing calculator without third-party dependencies. Cover supported functions, variable handling for x, validation, and user-facing parse errors. Output as markdown bullets only — do not write code." - }, - { - "id": "design", - "depends_on": ["research-calculator-scope", "research-expression-safety"], - "complexity": "MED", - "subtask_prompt": "Combine the upstream research into a one-page implementation plan for the graphing calculator. Specify file paths, DOM structure, core function signatures, expression evaluation strategy, coordinate transforms, and error handling. Output a markdown design doc — still no code." - }, - { - "id": "implement", - "depends_on": ["design"], - "complexity": "MED", - "subtask_prompt": "Implement the design as `index.html` in the current working directory. It must be a single dependency-free HTML file with embedded CSS and JavaScript, draw function graphs on a canvas, expose viewport controls, and show validation errors without crashing. After writing the file, describe how to open and use it." - }, - { - "id": "tests", - "depends_on": ["implement"], - "complexity": "LOW", - "subtask_prompt": "Add a `test_graphing_calculator.mjs` script in the cwd that checks the pure expression parsing/evaluation helpers and coordinate transform helpers from `index.html`. Use only Node built-ins such as `node:test`, `node:assert`, and `node:fs`. Run it with `node --test test_graphing_calculator.mjs` and include the output in your reply." - }, - { - "id": "docs", - "depends_on": ["implement"], - "complexity": "LOW", - "subtask_prompt": "Write a short `README.md` in the cwd describing what the graphing calculator does, how to open `index.html`, supported math syntax with examples, viewport controls, and known limitations. Do not modify `index.html`." - } - ] -} diff --git a/.cursor/skills/proof/examples/flatbread/dag-codegen-change.json b/.cursor/skills/proof/examples/flatbread/dag-codegen-change.json deleted file mode 100644 index e34e5091..00000000 --- a/.cursor/skills/proof/examples/flatbread/dag-codegen-change.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "title": "Flatbread codegen-only change (no sub-sub-agents)", - "framing": "Treat Flatbread as Git-native relational content for TypeScript apps, backed by flat files. GraphQL is one interface, not the whole product identity.", - "models": { - "HIGH": { "id": "claude-opus-4-7" }, - "MED": { "id": "gpt-5.5" }, - "LOW": { "id": "gpt-5.4-mini" } - }, - "tasks": [ - { - "id": "diag-core-types", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files (frontmatter `readonly: true` is advisory in DAG runs).\n\nDiagnose every `@flatbread/core` type that `packages/codegen` consumes (e.g. `CodegenOptions`, `CodegenResult`, `CodegenStrategy`, `Content`, `BaseContentNode`, `Override`, `Source`, `Transformer`). Note which generated artifact in `examples/nextjs/generated/**` is downstream of each. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-codegen-input", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose how `packages/codegen/**` currently consumes inputs from `packages/core` for the proposed change: . Capture introspection vs. type sourcing, document discovery, generated artifact paths landed into `examples/nextjs/generated/**`. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-generated-output", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose the current generated output shape consumed by `examples/nextjs`. Capture every generated file path, every TS export name the example imports, and every GraphQL document the example references. Reference files as `path/to/file.ts:line`." - }, - { - "id": "contract-synth", - "depends_on": [ - "diag-core-types", - "diag-codegen-input", - "diag-generated-output" - ], - "complexity": "HIGH", - "subtask_prompt": "You are acting as `flatbread-architecture-planner` operating as the rank-2 merge node. Follow its output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`.\n\nProduce the codegen before/after contract for the proposed change: . `## Proposed contract` must lead with an executor-actionable diff: changed file paths grouped by directory, changed TS export names, changed GraphQL document shape. `## Human checkpoints` must call out DevEx Validation gate before release." - }, - { - "id": "wait-contract-approval", - "depends_on": ["contract-synth"], - "kind": "pause" - }, - { - "id": "impl-codegen", - "depends_on": ["wait-contract-approval"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`. Group multi-file references under brace expansion.\n\nImplement the contract from the upstream synthesis exactly. Do not expand scope. Touch only `packages/codegen/**` and the generation pipeline. Run `pnpm --filter @flatbread/codegen test` and lint edited files." - }, - { - "id": "impl-example-regen", - "depends_on": ["impl-codegen"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`.\n\nRegenerate `examples/nextjs` GraphQL artifacts via `pnpm --filter nextjs exec flatbread codegen` (the `--filter` is required because `flatbread.config.js` only exists at `examples/nextjs/flatbread.config.js`; `loadConfig` does not search up). Do NOT use `pnpm codegen`, which is `--watch` per `examples/nextjs/package.json:7` and would hang the DAG node. Do not hand-edit generated files. List every generated file that changed (group under brace expansion) plus any example source file that needed an import or query update." - }, - { - "id": "verify-codegen-tests", - "depends_on": ["impl-codegen"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nRun `pnpm --filter @flatbread/codegen test`. Report failures, snapshot diffs, and any contract drift between the synthesized contract and what landed. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches the parent can append directly." - }, - { - "id": "verify-example-build", - "depends_on": ["impl-example-regen"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nRun `pnpm --filter nextjs build` (binds port `5057` via `flatbread start -- next build` per `examples/nextjs/package.json:8`). Stop the build before exit. Report TS errors, missing imports, or query/document mismatches caused by the regenerated artifacts. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches." - }, - { - "id": "browser-verify", - "depends_on": ["verify-example-build"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-browser-verifier`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Commands run`, `## Routes checked`, `## Observed behavior`, `## Mismatches`, `## Screenshots`, `## Residual risk`.\n\nRun `pnpm browser:doctor` first to fail fast if the browser CLI is unavailable. Start the example dev server in the background: `pnpm --filter nextjs dev` (binds port `5057` HTTP, `5058` HTTPS per `packages/flatbread/src/cli/index.ts:128-135`); the upstream `verify-example-build` task already finished and freed the port. Wait for the server to come up before driving `pnpm exec agent-browser`. Verify documented queries and the rendered example pages still match the README. Tear the dev server down before completing the task. If the browser CLI is unavailable, your `## Residual risk` MUST lead with `BROWSER UNAVAILABLE`." - } - ] -} diff --git a/.cursor/skills/proof/examples/flatbread/dag-docs-sync.json b/.cursor/skills/proof/examples/flatbread/dag-docs-sync.json deleted file mode 100644 index a564f641..00000000 --- a/.cursor/skills/proof/examples/flatbread/dag-docs-sync.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "title": "Flatbread docs / README sync (no sub-sub-agents)", - "framing": "Treat Flatbread as Git-native relational content for TypeScript apps, backed by flat files. GraphQL is one interface, not the whole product identity.", - "models": { - "HIGH": { "id": "claude-opus-4-7" }, - "MED": { "id": "gpt-5.5" }, - "LOW": { "id": "gpt-5.4-mini" } - }, - "tasks": [ - { - "id": "diag-readme-claims", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files (frontmatter `readonly: true` is advisory in DAG runs).\n\nList every claim, command, code snippet, and example query in the root `README.md` and each `packages/*/README.md`. For each, mark whether the current implementation still matches. Reference files as `path/to/file.md:line`." - }, - { - "id": "diag-example-paths", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nWalk `examples/nextjs` end-to-end and capture the actual first-success path a developer sees: setup commands, codegen invocation (note `package.json:7` is `--watch`; the docs should point users at the appropriate command), dev command, port (`5057` HTTP, `5058` HTTPS sibling), sample query, sample edit. Compare against what the docs claim." - }, - { - "id": "diag-positioning", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nAudit positioning language in `README.md`, package READMEs, and any landing copy. Flag any phrasing that overclaims database-replacement (tables, foreign keys, joins, constraints, indexes, import/export) or treats GraphQL as the entire product identity." - }, - { - "id": "docs-plan", - "depends_on": [ - "diag-readme-claims", - "diag-example-paths", - "diag-positioning" - ], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner` operating as the rank-2 merge node. Follow its output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`.\n\nProduce a concrete docs-edit plan. `## Proposed contract` must lead with a flat list of `path/to/file.md:line — change` (group adjacent edits) so the executor can apply edits without re-reading the diagnoses." - }, - { - "id": "impl-docs", - "depends_on": ["docs-plan"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`. Group multi-file references under brace expansion.\n\nApply the docs plan exactly. Touch only `*.md` files. Do not modify code. Run the project's lint/format on changed files." - }, - { - "id": "review-docs", - "depends_on": ["impl-docs"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nReview the docs diff for: stale commands, drifting code snippets, broken cross-links, and any positioning that overclaims database semantics. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches the parent can append directly." - } - ] -} diff --git a/.cursor/skills/proof/examples/flatbread/dag-schema-migration.json b/.cursor/skills/proof/examples/flatbread/dag-schema-migration.json deleted file mode 100644 index 7f1e374c..00000000 --- a/.cursor/skills/proof/examples/flatbread/dag-schema-migration.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "title": "Flatbread schema-breaking migration (no sub-sub-agents; pause at human checkpoint after contract-synth)", - "framing": "Treat Flatbread as Git-native relational content for TypeScript apps, backed by flat files. GraphQL is one interface, not the whole product identity.", - "models": { - "HIGH": { "id": "claude-opus-4-7" }, - "MED": { "id": "gpt-5.5" }, - "LOW": { "id": "gpt-5.4-mini" } - }, - "tasks": [ - { - "id": "diag-schema", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files (frontmatter `readonly: true` is advisory in DAG runs).\n\nDiagnose `packages/core/src/generators/schema.ts` for the proposed change: . For `## Current contract` capture root query naming, ID/ref semantics, filter capabilities, and any user-visible GraphQL surface this file generates. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-resolvers", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/core/src/resolvers/arguments.ts` for the proposed change: . For `## Current contract` capture filter shape, supported operators, and any internal contracts other resolvers depend on. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-types", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/core/src/types.ts` for the proposed change: . Capture which exported types cross package boundaries to `@flatbread/codegen`, the CLI, transformers, and examples. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-codegen", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/codegen/**` for the proposed change: . Capture how generated TypeScript and GraphQL documents are produced, which inputs from `@flatbread/core` they depend on, and how the generated artifacts land in `examples/nextjs`. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-cli", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/flatbread/src/cli/index.ts` and the GraphQL server wiring for the proposed change: . Capture the Flatbread start command behavior, codegen invocation, `/graphql` endpoint, port `5057` (HTTP) and `5058` (HTTPS sibling at `packages/flatbread/src/cli/index.ts:128-135`). Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-examples", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `examples/nextjs` for the proposed change: . Capture which generated GraphQL documents and types the example consumes, which queries would need to change, and which README/docs snippets would drift. Note: `examples/nextjs/package.json:7` defines `pnpm codegen` as `flatbread codegen --watch` (hangs in DAG runs); list non-`--watch` invocations instead." - }, - { - "id": "diag-docs", - "depends_on": [], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `README.md` and each `packages/*/README.md` for the proposed change: . List every snippet, command, or claim that would no longer be true. Reference files as `path/to/file.md:line`." - }, - { - "id": "diag-transformers", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/transformer-markdown/**` and `packages/transformer-yaml/**` for the proposed change: . Capture how each implements `Transformer` (interface at `packages/core/src/types.ts:73-82`), especially `preknownSchemaFragments` (`packages/core/src/types.ts:79`), and which extensions they own. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-source-plugins", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/source-filesystem/**` for the proposed change: . Capture how it implements `Source` (interface at `packages/core/src/types.ts:95-101`), especially `fetch` and `fetchByType`, and which file-discovery assumptions would shift. Reference files as `path/to/file.ts:line`." - }, - { - "id": "diag-config", - "depends_on": [], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-architecture-planner`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`. Do not edit files.\n\nDiagnose `packages/config/**` (especially `packages/config/src/validate.ts`) for the proposed change: . Capture the validated `FlatbreadConfig` shape, every required field, and how validation diagnostics surface to the CLI. Reference files as `path/to/file.ts:line`." - }, - { - "id": "contract-synth", - "depends_on": [ - "diag-schema", - "diag-resolvers", - "diag-types", - "diag-codegen", - "diag-cli", - "diag-examples", - "diag-docs", - "diag-transformers", - "diag-source-plugins", - "diag-config" - ], - "complexity": "HIGH", - "subtask_prompt": "You are acting as `flatbread-architecture-planner` operating as the rank-2 contract synthesis node. Follow its output schema. Output must lead with these `##` headings verbatim: `## Current contract`, `## Proposed contract`, `## Migration impact`, `## Validation plan`, `## Human checkpoints`.\n\nMerge upstream diagnoses into a single before/after contract for IDs, refs, filters, root query names, generated TypeScript, config shape, transformer/source contracts, and CLI behavior. `## Proposed contract` must lead with the literal markdown table `| field | before | after | breaking? | files_to_change |` as the first content under the heading, followed by executor-actionable details (changed file paths grouped by directory, changed export/type names, changed CLI flags) before any prose. `## Human checkpoints` must explicitly call out Schema Contract approval before any executor runs.\n\nIMPORTANT for survival under the 2000-char downstream stitch: keep `## Current contract` to a single 1-2 line summary so the executor-actionable diff at the top of `## Proposed contract` lands within the first 2000 chars for every downstream `impl-*` task." - }, - { - "id": "wait-contract-approval", - "depends_on": ["contract-synth"], - "kind": "pause" - }, - { - "id": "impl-core", - "depends_on": ["wait-contract-approval"], - "complexity": "HIGH", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`. Group multi-file references under brace expansion (e.g. `packages/core/src/{generators/schema.ts,resolvers/arguments.ts,types.ts}`).\n\nImplement the contract from `contract-synth` exactly. Do not expand scope. Touch only `packages/core/src/**`. Serialize edits in this order inside this single task: schema.ts → arguments.ts → types.ts. Run `pnpm --filter @flatbread/core build` to confirm the package compiles (note: `packages/core/package.json` has no `test` script as of this writing) and lint edited files. Record the build outcome under `## Checks run`." - }, - { - "id": "impl-docs", - "depends_on": ["wait-contract-approval"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`.\n\nUpdate `README.md`, every `packages/*/README.md`, and any migration notes the contract requires. Touch only `*.md` files — no code edits. Group changes under brace expansion in `## Files changed`." - }, - { - "id": "impl-codegen", - "depends_on": ["impl-core"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`.\n\nImplement the codegen-side of the contract. Touch only `packages/codegen/**`. Run `pnpm --filter @flatbread/codegen test` and lint edited files." - }, - { - "id": "impl-cli", - "depends_on": ["impl-core"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`.\n\nImplement the CLI-side of the contract. Touch only `packages/flatbread/src/**`. Do not run `flatbread start` (port 5057 is reserved for the rank-7 `verify-cli` task). Lint edited files." - }, - { - "id": "impl-examples", - "depends_on": ["impl-codegen"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-migration-executor`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Files changed`, `## Contract implemented`, `## Checks run`, `## Checks skipped`, `## Residual risk`, `## Release gate state`.\n\nRegenerate `examples/nextjs` GraphQL artifacts via `pnpm --filter nextjs exec flatbread codegen` (the `--filter` is required because `flatbread.config.js` only exists at `examples/nextjs/flatbread.config.js`; `loadConfig` does not search up). Do NOT use `pnpm codegen`, which is `--watch` per `examples/nextjs/package.json:7` and would hang the DAG node. Update any example source file whose imports or queries broke. Group generated paths under brace expansion in `## Files changed`." - }, - { - "id": "verify-schema-snap", - "depends_on": ["impl-core"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nDiff the generated GraphQL schema against the synthesized contract. Flag any drift. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches the parent can append directly." - }, - { - "id": "verify-codegen", - "depends_on": ["impl-codegen"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nRun `pnpm --filter @flatbread/codegen test` and report failures. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches." - }, - { - "id": "verify-readme", - "depends_on": ["impl-docs"], - "complexity": "LOW", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nDiff every README example and command against actual runtime behavior implied by the synthesized contract. Flag stale snippets, broken links, and any positioning drift. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches." - }, - { - "id": "verify-cli", - "depends_on": ["impl-cli", "verify-codegen"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-adversarial-reviewer`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Blockers`, `## High-severity findings`, `## Medium-severity findings`, `## Low-severity findings`, `## Residual risk`, `## Recommended next DAG tasks`.\n\nSmoke-test `pnpm --filter nextjs dev` and the `/graphql` endpoint on port `5057` (HTTP) and `5058` (HTTPS). This task is the sole port-5057 occupant of its rank — no other task may bind that port concurrently. Stop the server before exit so `browser-verify` can take the port. If anything fails, populate `## Recommended next DAG tasks` with `id` + one-line subtask_prompt sketches." - }, - { - "id": "browser-verify", - "depends_on": ["impl-examples", "verify-cli"], - "complexity": "MED", - "subtask_prompt": "You are acting as `flatbread-browser-verifier`. Follow its responsibilities and output schema. Output must lead with these `##` headings verbatim: `## Commands run`, `## Routes checked`, `## Observed behavior`, `## Mismatches`, `## Screenshots`, `## Residual risk`.\n\nRun `pnpm browser:doctor` first to fail fast if the browser CLI is unavailable. Start the example dev server in the background: `pnpm --filter nextjs dev` (binds port `5057` HTTP, `5058` HTTPS per `packages/flatbread/src/cli/index.ts:128-135`); the upstream `verify-cli` task already stopped its server before exit, so the port is free. Wait for the server to come up before driving `pnpm exec agent-browser`. Verify documented queries and rendered example pages still match READMEs. Tear the dev server down before completing the task. If the browser CLI is unavailable, your `## Residual risk` MUST lead with `BROWSER UNAVAILABLE` so the parent re-queues. This is the terminal release-gate node." - } - ] -} diff --git a/.gitignore b/.gitignore index ec928b8e..0a5ba9ab 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,5 @@ yarn-error.log .pnpm-debug.log **/.flatbread-efforts/.journal/ **/.flatbread/effort-graph/read-cache/ -# proof / local DAG artifacts +# Local Proof DAG artifacts (external Proof tool may write here) .flatbread/artifacts/ diff --git a/AGENTS.md b/AGENTS.md index cdbbc9ff..7908798e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,9 +45,9 @@ See `CONTRIBUTING.md` for full details. Quick reference: - **Lint**: `pnpm lint` (prettier) - **Lint fix (after edits)**: `pnpm lint:fix:fast` (writes formatting repo-wide to match `pnpm lint`; staged-only: `pnpm lint:fix`, also runs via `.husky/pre-commit`) - **Typecheck**: `pnpm typecheck` -- **Test**: `pnpm test` (builds, then runs ava + vitest suites, including `@flatbread/proof` bounded-loop coverage). For the focused proof loop suite: `pnpm -F @flatbread/proof test`. Vitest packages use `pnpm -F @flatbread/utils exec vitest run` / `pnpm -F @flatbread/codegen exec vitest run` (`run` avoids watch mode). +- **Test**: `pnpm test` (builds, then runs ava + vitest suites). Vitest packages use `pnpm -F @flatbread/utils exec vitest run` / `pnpm -F @flatbread/codegen exec vitest run` (`run` avoids watch mode). - **Full verify**: `pnpm verify` (lint + typecheck + build + test) -- **Proof loop contract**: explicit `DAG.loops[].reexecute.tasks` subsets must be dependency-closed, multiple loops must have disjoint re-execution sets, and `DAG.loops` must not be combined with `--converge-on`. +- **Proof**: the DAG task runner now lives at https://github.com/FlatbreadLabs/proof. - **Dev server**: `pnpm play` (GraphQL on port 5057, Next.js on port 3000). From `examples/nextjs`, prefer `pnpm exec flatbread start -- next dev --turbopack`. Use `flatbread start` — `flatbread dev` is not a CLI command. ### Mergify Stacks @@ -60,7 +60,6 @@ The repo uses Mergify stacks for PR management. The `mergify-cli` is installed v ### Gotchas -- **`@flatbread/proof` requires `CURSOR_RIPGREP_PATH`.** The proof package uses `@cursor/sdk` which expects a bundled ripgrep. In Cloud Agent VMs, set `export CURSOR_RIPGREP_PATH=/usr/bin/rg` to use the system ripgrep (included in the update script). - **Native build scripts are approved in `pnpm-workspace.yaml`.** The `onlyBuiltDependencies` list allows esbuild, sharp, @swc/core, etc. to run their postinstall scripts automatically during `pnpm install`. - **Vitest packages run in watch mode by default.** Always use `vitest run` (not bare `vitest`) to get a single run and exit. - **`flatbread` CLI is not on PATH globally.** From `examples/nextjs`, prefer `pnpm exec flatbread …` (local binary), or `npx flatbread` from a shell. The `pnpm play` script from the root handles this automatically. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ebf0137..abc055ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,19 +73,19 @@ pnpm build - Negative: invalid inputs, edge cases, and error handling/failure modes. - Place tests in the relevant package and use its existing runner/config. - Root `pnpm test` builds the workspace, runs the AVA suite configured by `ava.config.js`, then runs the package-local Vitest suites. - - Bounded-loop coverage for `@flatbread/proof` is exercised by both `pnpm test` and `pnpm -F @flatbread/proof test`. - - That focused proof suite is the quickest check for loop parser/runtime guards such as explicit rerun validation, overlapping-loop rejection, and convergence iteration accounting. - Vitest is currently used by `@flatbread/codegen` and `@flatbread/utils`. - - `@flatbread/proof` exposes a package-local AVA entrypoint for the loop schema suite; most other packages are covered by the root AVA suite or do not yet expose a package-local `test` script. + - Most other packages are covered by the root AVA suite or do not yet expose a package-local `test` script. - `pnpm lint` is the enforced Prettier formatting gate. After editing, run `pnpm lint:fix:fast` so formatting matches CI (Cursor agents: see `.cursor/rules/post-edit-lint-fix.mdc`). On commit, `.husky/pre-commit` runs `pnpm lint:fix` (Pretty Quick on staged files). `pnpm lint:eslint` is an optional/manual root ESLint check until the linting stack is modernized. - Helpful commands: - Local CI parity: `pnpm verify` - Root test suite: `pnpm test` - - Proof bounded-loop suite: `pnpm -F @flatbread/proof test` - Package-local test scripts where present: `pnpm -r --if-present test` - Single package: `pnpm -F test` - Watch (where supported): `pnpm -F test:watch` +Proof (the DAG task runner for Cursor agents) now lives at +https://github.com/FlatbreadLabs/proof. + ## Releasing packages There are two steps: diff --git a/ava.config.js b/ava.config.js index 531f2ef9..8d03af88 100644 --- a/ava.config.js +++ b/ava.config.js @@ -7,10 +7,10 @@ export default { concurrency: 4, files: [ 'packages/**/*.test.(j|t)s', + 'scripts/**/*.test.(j|t)s', // Codegen + utils use Vitest under src/__tests__. Keep those out of the - // root AVA run, but allow AVA-owned proof coverage under the same folder - // layout so `pnpm test` exercises the proof bounded-loop suite and its - // parser/runtime guardrails. + // root AVA run so `pnpm test` only exercises AVA-owned package and root + // script coverage. '!packages/codegen/src/__tests__/**', '!packages/utils/src/__tests__/**', // Explorer SPA uses Node's built-in test runner (see package scripts). diff --git a/package.json b/package.json index 54fb5e3a..3b00968d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint:fix": "pnpm lint:fix:prettier", "lint:fix:fast": "prettier --write --plugin-search-dir=. .", "lint:fix:prettier": "pretty-quick --staged", - "typecheck": "pnpm --filter @flatbread/proof --filter @flatbread/explorer typecheck", + "typecheck": "pnpm --filter @flatbread/explorer typecheck", "play": "cd examples/nextjs && pnpm dev", "preplay:efforts": "pnpm --filter @flatbread/explorer build", "play:efforts": "pnpm exec flatbread start --watch --open", @@ -40,7 +40,7 @@ "test:explorer": "pnpm --filter @flatbread/explorer test", "test": "pnpm build && pnpm test:ava && pnpm test:vitest && pnpm test:explorer", "verify": "pnpm skills:check && pnpm skills:pack-check && pnpm lint && pnpm typecheck && pnpm build && pnpm test", - "cursor:fetch-cloud-agent": "pnpm --filter @flatbread/proof exec node scripts/fetch-cloud-agent-conversation.mjs", + "cursor:fetch-cloud-agent": "node scripts/fetch-cloud-agent-conversation.mjs", "dev:test": "ava --watch --verbose", "prepare": "husky install" }, @@ -58,7 +58,6 @@ "@flatbread/codegen": "workspace:*", "@flatbread/config": "workspace:*", "@flatbread/core": "workspace:*", - "@flatbread/proof": "workspace:*", "@flatbread/resolver-svimg": "workspace:*", "@flatbread/source-filesystem": "workspace:*", "@flatbread/transformer-markdown": "workspace:*", @@ -66,6 +65,7 @@ }, "devDependencies": { "@ava/typescript": "3.0.1", + "@cursor/sdk": "^1.0.9", "@flatbread/effort-graph": "workspace:*", "@nrwl/workspace": "14.4.3", "@types/inquirer": "8.2.1", diff --git a/packages/explorer/tsconfig.json b/packages/explorer/tsconfig.json index 1aafd509..d021a351 100644 --- a/packages/explorer/tsconfig.json +++ b/packages/explorer/tsconfig.json @@ -15,7 +15,6 @@ "@flatbread/codegen": ["./packages/codegen/src/index.ts"], "@flatbread/core": ["./packages/core/src/index.ts"], "@flatbread/config": ["./packages/config/src/index.ts"], - "@flatbread/proof": ["./packages/proof/src/index.ts"], "@flatbread/resolver-svimg": ["./packages/resolver-svimg/src/index.ts"], "@flatbread/utils": ["./packages/utils/src/index.ts"], "@flatbread/explorer": ["./packages/explorer/src/node/index.ts"], diff --git a/packages/proof/README.md b/packages/proof/README.md deleted file mode 100644 index c58f8921..00000000 --- a/packages/proof/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# `@flatbread/proof` - -Git-native memory for coding agents. Installing this package gives you three -things: - -- **Record types.** An agent records the work it is doing as an **Effort**, - then writes what it learns against that Effort: **Issues**, **Findings**, - **Decisions**, **Constraints**, **Risks**, **Citations**, and **Blobs**. -- **Write operations.** A typed mutation turns into Markdown files on disk, and - the writer checks the links between records before it commits them. -- **A Flatbread content model.** `proofContent()` adds those eight record - types to a Flatbread configuration, so the same files come back as a typed - graph you can query and page through. - -Every record is a Markdown file in your repository, so you commit, diff, -review, and revert an agent's reasoning the same way you handle code, and the -next session can read it back. - -Writes go through a journal, so a change that touches several files either -finishes in full or leaves nothing behind: if the process dies mid-write, the -next run restores the earlier contents of the unfinished change. - -Version 1 supports these actions: `CreateEffort`, `SetEffortStatus`, -`WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, -`WriteCitation`, `WriteBlob`, `Supersede`, `Invalidate`, `ResolveIssue`, -`AcceptDecision`, `MitigateRisk`, and `SetRiskState`. - -An Issue, Finding, Decision, Constraint, or Risk may name Citation ids in -`cites` (Flatbread `refs`). A Citation body alone is valid (e.g. a URL); an -optional `blob` ref attaches a long payload such as a document, JSON, or image. - -## Where records live, and what to ignore - -`proofContent()` stores the graph under `.flatbread-proof` in your -project root. Pass a path to choose another root: -`proofContent('path/to/graph')`. - -Two paths hold working state that Git should not track: the write journal at -`/.journal`, and the derived read cache at -`.flatbread/proof/read-cache`. Nothing adds them to `.gitignore` for -you, so add these lines yourself: - -```gitignore -**/.flatbread-proof/.journal/ -**/.flatbread/proof/read-cache/ -``` - -For a custom root, replace `.flatbread-proof` with that root. The read cache -path stays the same. - -`flatbread proof bootstrap` reports what is still missing — the config entry -or either ignore rule. `flatbread proof bootstrap --verify` reports the same -and exits nonzero when anything is missing, which makes it usable in CI. - -## The domain model and the packaged skill - -Read [`skills/proof/glossary.md`](./skills/proof/glossary.md) for -the portable Proof domain model. - -The packaged Agent Skill is in `skills/proof/`. The repository copy in -`.agents/skills/proof/` is generated from those files. Run -`pnpm skills:sync` from the repository root after changing the skill. - -## Install the Proof skill - -Install from a release tag, then activate the skill for setup: - -```bash -npx skills add https://github.com/FlatbreadLabs/flatbread/tree//packages/proof/skills/proof --skill proof -npm install --save-dev flatbread@ -``` - -The tag and version come from `gitTag` and `flatbreadVersion` in -`skills/proof/release.json`. See `skills/proof/setup.md` for the -equivalent `pnpm`, `yarn`, and `bun` commands. diff --git a/packages/proof/bin/proof-supervisor.js b/packages/proof/bin/proof-supervisor.js deleted file mode 100755 index 468147a8..00000000 --- a/packages/proof/bin/proof-supervisor.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import { resolve } from 'path'; -import { existsSync } from 'fs'; - -if (process.env.FLATBREAD_CI) { - const cliPath = resolve( - process.cwd(), - 'node_modules', - '@flatbread', - 'proof', - 'dist', - 'run_dag_supervisor.js' - ); - - if (existsSync(cliPath)) { - import('../dist/run_dag_supervisor.js'); - } else { - console.log('@flatbread/proof supervisor CLI is not available'); - } -} else { - import('../dist/run_dag_supervisor.js'); -} diff --git a/packages/proof/bin/proof.js b/packages/proof/bin/proof.js deleted file mode 100755 index b350074a..00000000 --- a/packages/proof/bin/proof.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -import { resolve } from 'path'; -import { existsSync } from 'fs'; - -if (process.env.FLATBREAD_CI) { - const cliPath = resolve( - process.cwd(), - 'node_modules', - '@flatbread', - 'proof', - 'dist', - 'run_dag.js' - ); - - if (existsSync(cliPath)) { - import('../dist/run_dag.js'); - } else { - console.log('@flatbread/proof CLI is not available'); - } -} else { - import('../dist/run_dag.js'); -} diff --git a/packages/proof/package.json b/packages/proof/package.json deleted file mode 100644 index 6c4ceccf..00000000 --- a/packages/proof/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@flatbread/proof", - "version": "1.0.0", - "description": "Bounded DAG task runner for Cursor agents. Runs subagent tasks in topological order with a live canvas, oracle and pause gates, and bounded re-execution loops.", - "type": "module", - "scripts": { - "build": "tsup", - "dev": "tsup --watch src", - "test": "pnpm --dir ../.. exec ava \"packages/proof/src/__tests__/**/*.test.ts\"", - "test:watch": "pnpm --dir ../.. exec ava --watch \"packages/proof/src/__tests__/**/*.test.ts\"", - "typecheck": "tsc -p tsconfig.json --noEmit", - "models:list": "tsx src/list_models.ts", - "cursor:fetch-cloud-agent": "node scripts/fetch-cloud-agent-conversation.mjs" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/FlatbreadLabs/flatbread.git", - "directory": "packages/proof" - }, - "homepage": "https://github.com/FlatbreadLabs/flatbread/tree/main/packages/proof#readme", - "author": "Tony Ketcham ", - "license": "MIT", - "bugs": { - "url": "https://github.com/FlatbreadLabs/flatbread/issues" - }, - "exports": { - ".": "./dist/index.js" - }, - "main": "dist/index.js", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "proof": "bin/proof.js", - "proof-supervisor": "bin/proof-supervisor.js" - }, - "files": [ - "bin", - "dist", - "*.d.ts" - ], - "engines": { - "node": ">=20.19" - }, - "dependencies": { - "@cursor/sdk": "^1.0.9" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "tsup": "^8.3.0", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } -} diff --git a/packages/proof/src/__tests__/loops.test.ts b/packages/proof/src/__tests__/loops.test.ts deleted file mode 100644 index b3078cb4..00000000 --- a/packages/proof/src/__tests__/loops.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -import test from 'ava'; -import { - parseDAG, - resolveConvergenceLoops, - type DAG, - type DAGConvergenceLoop, - type RawTask, -} from '../index.js'; -import { resolveLoopReexecuteIds } from '../converge_loop.js'; - -const baseTasks: RawTask[] = [ - { - id: 'research', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'research', - kind: 'task', - }, - { - id: 'design', - depends_on: ['research'], - complexity: 'MED', - subtask_prompt: 'design', - kind: 'task', - }, - { - id: 'implement', - depends_on: ['design'], - complexity: 'MED', - subtask_prompt: 'implement', - kind: 'task', - }, - { - id: 'review', - depends_on: ['implement'], - complexity: 'HIGH', - subtask_prompt: 'review', - kind: 'task', - }, -]; - -function dagWith(loops: unknown): unknown { - return { - title: 'loop-tests', - tasks: baseTasks.map((t) => ({ - id: t.id, - depends_on: t.depends_on, - complexity: t.complexity, - subtask_prompt: t.subtask_prompt, - })), - loops, - }; -} - -test('parseDAG accepts a minimal loops entry with defaults', (t) => { - const dag = parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 2 }])); - t.truthy(dag.loops); - t.is(dag.loops!.length, 1); - t.is(dag.loops![0].convergeOn, 'review'); - t.is(dag.loops![0].maxIterations, 2); -}); - -test('resolveConvergenceLoops fills defaults', (t) => { - const resolved = resolveConvergenceLoops([ - { convergeOn: 'review', maxIterations: 2 }, - ]); - t.is(resolved[0].id, 'loop-review'); - t.deepEqual(resolved[0].reexecute, { kind: 'ancestors' }); -}); - -test('parseDAG rejects convergeOn referencing unknown task id', (t) => { - t.throws( - () => parseDAG(dagWith([{ convergeOn: 'nope', maxIterations: 2 }])), - { message: /not a task id/ } - ); -}); - -test('parseDAG rejects non-positive maxIterations', (t) => { - t.throws( - () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 0 }])), - { message: /maxIterations must be a positive integer/ } - ); - t.throws( - () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: -1 }])), - { message: /maxIterations must be a positive integer/ } - ); - t.throws( - () => parseDAG(dagWith([{ convergeOn: 'review', maxIterations: 1.5 }])), - { message: /maxIterations must be a positive integer/ } - ); -}); - -test('parseDAG rejects two loops with the same convergeOn', (t) => { - t.throws( - () => - parseDAG( - dagWith([ - { convergeOn: 'review', maxIterations: 2 }, - { convergeOn: 'review', maxIterations: 3 }, - ]) - ), - { message: /duplicate convergeOn/ } - ); -}); - -test('parseDAG rejects two loops with the same explicit id', (t) => { - t.throws( - () => - parseDAG( - dagWith([ - { id: 'shared', convergeOn: 'review', maxIterations: 2 }, - { id: 'shared', convergeOn: 'design', maxIterations: 2 }, - ]) - ), - { message: /resolved loop id.*shared.*collides/ } - ); -}); - -test("parseDAG rejects loops whose resolved ids collide (explicit id matches another loop's default)", (t) => { - // Loop 0 has no explicit id: resolves to 'loop-review' via default. - // Loop 1 explicitly sets id: 'loop-review', convergeOn a different task. - // Before the fix these two loops silently produced duplicate resolved ids; - // after the fix parseDAG must throw. - t.throws( - () => - parseDAG( - dagWith([ - { convergeOn: 'review', maxIterations: 2 }, - { id: 'loop-review', convergeOn: 'implement', maxIterations: 2 }, - ]) - ), - { message: /resolved loop id.*loop-review.*collides/ } - ); -}); - -test('parseDAG rejects explicit ids that collide with defaulted loop ids', (t) => { - t.throws( - () => - parseDAG( - dagWith([ - { convergeOn: 'review', maxIterations: 2 }, - { id: 'loop-review', convergeOn: 'design', maxIterations: 2 }, - ]) - ), - { message: /duplicate loop id/ } - ); -}); - -test('parseDAG accepts explicit reexecute.tasks when the subset is dependency-closed', (t) => { - const dag = parseDAG( - dagWith([ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { - kind: 'tasks', - tasks: ['research', 'design', 'implement'], - }, - }, - ]) - ); - const reexec = dag.loops![0].reexecute!; - t.is(reexec.kind, 'tasks'); - if (reexec.kind === 'tasks') { - // convergeOn is injected so the loop body always re-runs the - // convergence task itself after upstream re-execution. - t.deepEqual([...reexec.tasks].sort(), [ - 'design', - 'implement', - 'research', - 'review', - ]); - } -}); - -test('parseDAG deduplicates convergeOn from reexecute.tasks when caller includes it explicitly', (t) => { - const dag = parseDAG( - dagWith([ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { - kind: 'tasks', - tasks: ['research', 'design', 'implement', 'review'], - }, // review = convergeOn - }, - ]) - ); - const reexec = dag.loops![0].reexecute!; - t.is(reexec.kind, 'tasks'); - if (reexec.kind === 'tasks') { - // 'review' must appear exactly once despite being both the convergeOn and explicit in the list - t.deepEqual([...reexec.tasks].sort(), [ - 'design', - 'implement', - 'research', - 'review', - ]); - } -}); - -test('parseDAG accepts a pause task as convergeOn (behavior: allowed, convergence semantics may be vacuous)', (t) => { - const raw = { - title: 'pause-convergeOn', - tasks: [ - { id: 'gate', depends_on: [], subtask_prompt: 'wait', kind: 'pause' }, - ], - loops: [{ convergeOn: 'gate', maxIterations: 1 }], - }; - const dag = parseDAG(raw); - t.is(dag.loops![0].convergeOn, 'gate'); -}); - -test('parseDAG rejects reexecute.tasks outside the ancestor cone', (t) => { - // 'review' depends on 'implement' which depends on 'design' which depends - // on 'research'. A task `unrelated` that is not in that cone should be - // rejected (we synthesize one off the side of the DAG). - const raw = { - title: 'cone-test', - tasks: [ - ...baseTasks.map((t) => ({ - id: t.id, - depends_on: t.depends_on, - complexity: t.complexity, - subtask_prompt: t.subtask_prompt, - })), - { - id: 'sibling', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'sibling', - }, - ], - loops: [ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { kind: 'tasks', tasks: ['sibling'] }, - }, - ], - }; - t.throws(() => parseDAG(raw), { - message: /not the convergeOn task and is not a transitive ancestor/, - }); -}); - -test('parseDAG rejects reexecute.tasks containing unknown task ids', (t) => { - t.throws( - () => - parseDAG( - dagWith([ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { kind: 'tasks', tasks: ['ghost'] }, - }, - ]) - ), - { message: /unknown task id/ } - ); -}); - -test('parseDAG rejects non-closed reexecute.tasks subsets', (t) => { - t.throws( - () => - parseDAG( - dagWith([ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { kind: 'tasks', tasks: ['implement'] }, - }, - ]) - ), - { message: /must be dependency-closed/ } - ); -}); - -test('parseDAG rejects unknown reexecute.kind', (t) => { - t.throws( - () => - parseDAG( - dagWith([ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { kind: 'all', tasks: [] }, - }, - ]) - ), - { message: /reexecute\.kind must be one of/ } - ); -}); - -test('parseDAG with no loops still works', (t) => { - const dag = parseDAG({ - title: 'no-loops', - tasks: [ - { - id: 'only', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'x', - }, - ], - }); - t.is(dag.loops, undefined); -}); - -test('resolveLoopReexecuteIds with ancestors returns the full cone', (t) => { - const dag = parseDAG( - dagWith([{ convergeOn: 'review', maxIterations: 2 }]) - ) as DAG; - const resolved = resolveConvergenceLoops(dag.loops!); - const ids = resolveLoopReexecuteIds(resolved[0], dag); - t.deepEqual([...ids].sort(), ['design', 'implement', 'research', 'review']); -}); - -test('resolveLoopReexecuteIds with explicit tasks honors the allow-list', (t) => { - const dag = parseDAG( - dagWith([ - { - convergeOn: 'review', - maxIterations: 2, - reexecute: { - kind: 'tasks', - tasks: ['research', 'design', 'implement'], - }, - }, - ]) - ) as DAG; - const resolved = resolveConvergenceLoops(dag.loops!); - const ids = resolveLoopReexecuteIds(resolved[0], dag); - // Only the explicit allow-list + convergence task itself. - t.deepEqual([...ids].sort(), ['design', 'implement', 'research', 'review']); -}); - -test('resolveConvergenceLoops preserves user-provided id when set', (t) => { - const dag = parseDAG( - dagWith([{ id: 'review-loop', convergeOn: 'review', maxIterations: 3 }]) - ); - const resolved = resolveConvergenceLoops(dag.loops!); - t.is(resolved[0].id, 'review-loop'); - t.is(resolved[0].maxIterations, 3); -}); - -test('parseDAG accepts multiple loops when their re-execution sets are disjoint', (t) => { - const tasks = [ - { - id: 'research', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'r', - }, - { - id: 'docs', - depends_on: [], - complexity: 'MED', - subtask_prompt: 'd', - }, - { - id: 'docs-review', - depends_on: ['docs'], - complexity: 'HIGH', - subtask_prompt: 'dr', - }, - { - id: 'impl', - depends_on: [], - complexity: 'MED', - subtask_prompt: 'i', - }, - { - id: 'impl-review', - depends_on: ['impl'], - complexity: 'HIGH', - subtask_prompt: 'ir', - }, - ]; - const dag = parseDAG({ - title: 'multi-loop', - tasks, - loops: [ - { convergeOn: 'docs-review', maxIterations: 2 }, - { convergeOn: 'impl-review', maxIterations: 2 }, - ], - }); - t.is(dag.loops!.length, 2); - const resolved = resolveConvergenceLoops(dag.loops!); - t.deepEqual( - resolved.map((l) => l.id), - ['loop-docs-review', 'loop-impl-review'] - ); -}); - -test('parseDAG rejects loops with overlapping re-execution sets', (t) => { - t.throws( - () => - parseDAG({ - title: 'overlap', - tasks: [ - { - id: 'shared', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'shared', - }, - { - id: 'docs', - depends_on: ['shared'], - complexity: 'MED', - subtask_prompt: 'docs', - }, - { - id: 'docs-review', - depends_on: ['docs'], - complexity: 'HIGH', - subtask_prompt: 'docs review', - }, - { - id: 'impl', - depends_on: ['shared'], - complexity: 'MED', - subtask_prompt: 'impl', - }, - { - id: 'impl-review', - depends_on: ['impl'], - complexity: 'HIGH', - subtask_prompt: 'impl review', - }, - ], - loops: [ - { convergeOn: 'docs-review', maxIterations: 2 }, - { convergeOn: 'impl-review', maxIterations: 2 }, - ], - }), - { message: /must have disjoint re-execution sets/ } - ); -}); - -test('parseDAG rejects non-array loops', (t) => { - t.throws(() => parseDAG(dagWith({ convergeOn: 'review' })), { - message: /must be an array/, - }); -}); - -test('DAGConvergenceLoop type round-trips through resolveConvergenceLoops', (t) => { - const declared: DAGConvergenceLoop[] = [ - { - id: 'r', - convergeOn: 'review', - maxIterations: 5, - reexecute: { - kind: 'tasks', - tasks: ['research', 'design', 'implement', 'review'], - }, - }, - ]; - const resolved = resolveConvergenceLoops(declared); - t.deepEqual(resolved[0], { - id: 'r', - convergeOn: 'review', - maxIterations: 5, - reexecute: { - kind: 'tasks', - tasks: ['research', 'design', 'implement', 'review'], - }, - }); -}); diff --git a/packages/proof/src/__tests__/output-retention-phase1.test.ts b/packages/proof/src/__tests__/output-retention-phase1.test.ts deleted file mode 100644 index 8c6c7894..00000000 --- a/packages/proof/src/__tests__/output-retention-phase1.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import test from 'ava'; - -import { parseDAG } from '../dag.js'; -import { - buildConvergenceContext, - extractConvergenceFindings, -} from '../converge_loop.js'; -import { - TaskTranscriptStore, - taskStreamArtifactRelPath, -} from '../task_transcript.js'; -import type { TaskState } from '../canvas_writer.js'; -import { - CANVAS_DISPLAY_CAP, - excerptUpstreamForPrompt, - parseUpstreamSections, - renderUpstreamSections, - summarizeUpstreamForPrompt, - UPSTREAM_SNIPPET_CAP, -} from '../upstream_policy.js'; -import { renderCanvasSource, initialRunState } from '../canvas_writer.js'; -import { writeFindingsSidecar } from '../findings_sidecar.js'; - -test('parseDAG accepts DAG.outputPolicy.upstream', (t) => { - const dag = parseDAG({ - title: 'pol', - outputPolicy: { upstream: 'full' }, - tasks: [ - { - id: 'a', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'do', - }, - ], - }); - t.is(dag.outputPolicy?.upstream, 'full'); -}); - -test('parseDAG rejects invalid outputPolicy upstream value', (t) => { - t.throws( - () => - parseDAG({ - title: 'bad', - outputPolicy: { upstream: 'everything' }, - tasks: [ - { - id: 'a', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'do', - }, - ], - }), - { message: /upstream must be/ } - ); -}); - -test('parseDAG rejects unknown outputPolicy keys', (t) => { - t.throws( - () => - parseDAG({ - title: 'bad-key', - outputPolicy: { upstram: 'full' }, - tasks: [ - { - id: 'a', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'do', - }, - ], - }), - { message: /DAG\.outputPolicy\.upstram is not supported/ } - ); -}); - -test('summarize upstream attaches counted excerpt banner instead of omitting rationale', (t) => { - const filler = 'y'.repeat(5000); - const { excerpt } = summarizeUpstreamForPrompt(filler, UPSTREAM_SNIPPET_CAP); - t.true( - excerpt.includes('[...upstream excerpt:') && - excerpt.includes('parent output was 5000 chars') - ); - t.false(/^[^\n]+\u2026$/u.test(excerpt.trim().split(/\n/).pop() ?? '')); -}); - -test('full upstream excerpt includes late marker past multi-kchar parents', (t) => { - const preamble = 'z'.repeat(2800); - const tailMarker = `${'x'.repeat(9100)}MARKER_LATE`; - const blob = `${preamble}\n## Section one\nstuff\n## Blockers\n${tailMarker}`; - const full = excerptUpstreamForPrompt(blob, 'full'); - t.true(full.includes('MARKER_LATE')); -}); - -test('convergence extract sees late section beyond legacy STREAM cap window', (t) => { - const long = `${'p'.repeat(6000)}\n## Blockers\n- late blocker\n`; - const f = extractConvergenceFindings(long); - t.true(f.hasIssues); - t.true(f.blockerLines.some((l) => l.includes('late blocker'))); -}); - -test('convergence extraContext carries late blockers under full upstream excerpt mode', (t) => { - const long = `${'p'.repeat(6000)}\n## Blockers\n- still broken\n`; - const ctx = buildConvergenceContext('reviewer', 2, long, 'full'); - t.true(ctx.includes('## Blockers')); - t.true(ctx.includes('still broken')); -}); - -test('findings sidecar uses parseSource (full transcript) over bounded resultText', async (t) => { - const dir = mkdtempSync(join(tmpdir(), 'proof-sidecar-')); - try { - const ts: TaskState = { - id: 'task-a', - depends_on: [], - complexity: 'LOW', - subtask_prompt: 'x', - status: 'FINISHED', - model: 'gpt-5.4', - resultText: '## Blockers\n(none)', - }; - const longTruth = `${'z'.repeat(5000)}\n## Blockers\n- deep blocker line\n`; - await writeFindingsSidecar(dir, ts, { parseSource: longTruth }); - const raw = readFileSync(join(dir, 'task-a.findings.json'), 'utf8'); - const parsed = JSON.parse(raw) as { sections: Record }; - t.true(parsed.sections.Blockers?.includes('deep blocker line')); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('upstream section parsing keeps canvas truncation banner before headings', (t) => { - const line = '[...truncated 9000 earlier chars...]'; - const body = `${line}\n## Blockers\nhit\n`; - const sections = parseUpstreamSections(body); - t.true(sections.some((s) => s.heading === 'Upstream truncation notice')); - const rendered = renderUpstreamSections(sections); - t.true(rendered.includes(line)); -}); - -test('upstream section parsing keeps freeform preamble before headings', (t) => { - const body = `Important preface before headings.\nStill preface.\n## Findings\nhit\n## Proposed contract\nkeep\n`; - const sections = parseUpstreamSections(body); - t.is(sections[0]?.heading, 'Upstream preamble'); - const rendered = renderUpstreamSections(sections); - t.true(rendered.includes('Important preface before headings.')); -}); - -test('summarize upstream does not rewrite author-owned trailing ellipsis', (t) => { - const body = [ - '## Summary', - 'This sentence intentionally trails off…', - '', - '## Current contract', - 'drop me '.repeat(500), - '', - '## Findings', - 'keep this section', - ].join('\n'); - const { excerpt } = summarizeUpstreamForPrompt(body, 500); - t.true(excerpt.includes('trails off…')); - t.false(excerpt.includes('[...truncated in excerpt body at char cap …]')); -}); - -test('task transcript mirror serializes overlapping flushes in append order', async (t) => { - const dir = mkdtempSync(join(tmpdir(), 'proof-stream-')); - const store = new TaskTranscriptStore(); - try { - await store.beginMirroredAppend('task-a', dir); - store.append('task-a', 'a'); - const first = store.flushStreamMirror('task-a'); - store.append('task-a', 'b'); - const second = store.flushStreamMirror('task-a'); - await Promise.all([first, second]); - await store.flushStreamMirror('task-a'); - const raw = readFileSync( - join(dir, taskStreamArtifactRelPath('task-a')), - 'utf8' - ); - t.is(raw, 'ab'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('task transcript store reads existing mirror files after resume', (t) => { - const dir = mkdtempSync(join(tmpdir(), 'proof-stream-resume-')); - const store = new TaskTranscriptStore(); - try { - const rel = taskStreamArtifactRelPath('task-a'); - writeFileSync(join(dir, rel), 'full transcript from prior process', 'utf8'); - store.registerExistingMirror('task-a', dir, rel); - t.is(store.getJoined('task-a'), 'full transcript from prior process'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('canvas render growth stays bounded by display-sized tails versus megabyte dumps', (t) => { - const tasks = Array.from({ length: 5 }, (_, i) => ({ - id: `t${i}`, - depends_on: [] as string[], - complexity: 'LOW' as const, - subtask_prompt: `${'prompt:'.repeat(200)}\n`, - })); - const dag = parseDAG({ title: 'canvas-env', tasks }); - const fresh = (): ReturnType => - initialRunState(dag, () => ({ - id: 'gpt-5.4', - })); - - const baselineLen = renderCanvasSource(fresh()).length; - - const cappedState = fresh(); - cappedState.tasks.forEach((st) => { - st.resultText = `[...truncated 800000 earlier chars...]\n${'a'.repeat( - CANVAS_DISPLAY_CAP - )}`; - }); - const cappedLen = renderCanvasSource(cappedState).length; - - const leakyState = fresh(); - leakyState.tasks.forEach((st) => { - st.resultText = `[...truncated 800000 earlier chars...]\n${'b'.repeat( - 12000 - )}`; - }); - const uncappedLen = renderCanvasSource(leakyState).length; - - t.true(cappedLen < baselineLen + 5 * CANVAS_DISPLAY_CAP + 96000); - - /** Longer fake transcripts should substantially grow the inlined JSON blob. */ - t.true( - uncappedLen - cappedLen > 35000, - 'expected materially larger stringify when payloads stay long' - ); -}); - -test('runOne skips children when upstream is BUDGET-EXCEEDED (guard in run_dag)', (t) => { - const path = join(dirname(fileURLToPath(import.meta.url)), '../run_dag.ts'); - const src = readFileSync(path, 'utf8'); - const idx = src.indexOf('failedDeps = task.depends_on.filter'); - t.not(idx, -1); - const snippet = src.slice(idx, idx + 450); - t.true(snippet.includes("'BUDGET-EXCEEDED'")); -}); diff --git a/packages/proof/src/canvas_writer.ts b/packages/proof/src/canvas_writer.ts deleted file mode 100644 index ea35ad46..00000000 --- a/packages/proof/src/canvas_writer.ts +++ /dev/null @@ -1,973 +0,0 @@ -/** - * Renders the runner's in-memory state into a self-contained `.canvas.tsx` - * file. The IDE hot-recompiles on file change, so calling write() repeatedly - * gives the user a live view of the DAG run. - * - * The canvas is fully static React + cursor/canvas — all state is inlined as - * a `const STATE = {...}` literal. Only that literal changes between writes; - * the rendered template is identical. - */ - -import { writeFile, mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import { - formatModelSelection, - normalizeModelSelection, - type Complexity, - type DAG, - type ModelSelection, - type ModelSpec, - type TaskKind, -} from './dag.js'; - -export type TaskStatus = - | 'PENDING' - | 'RUNNING' - | 'FINISHED' - | 'ERROR' - | 'AWAITING_APPROVAL' - | 'BUDGET-EXCEEDED'; - -export interface TaskState { - id: string; - depends_on: string[]; - complexity: Complexity; - subtask_prompt: string; - status: TaskStatus; - model: string; - modelSelection?: ModelSelection; - /** `'task'` (default), `'pause'`, or `'oracle'`. Undefined is normalized to `'task'`. */ - kind?: TaskKind; - /** - * Shell command for `kind: 'oracle'` tasks. Surfaced in the canvas so the - * gate's pass/fail criterion is visible without reading the result body. - * Undefined for every other kind. - */ - command?: string; - /** Regex source the oracle's output is matched against (defaults to `.*`). */ - expect?: string; - startedAt?: number; - finishedAt?: number; - resultText?: string; - /** - * Relative path (under the run artifact directory) to the append-only stream - * mirror for this task's full assistant transcript. Canvas shows bounded - * `resultText`; this pointer is for locating the authoritative stream file. - */ - transcriptPath?: string; - errorMessage?: string; - inputTokens?: number; - outputTokens?: number; - durationMs?: number; - /** - * Convergence-loop re-execution counter. 0/undefined = original run; bumped - * by 1 each time `--converge-on` re-runs this task to address upstream - * reviewer findings. - */ - iteration?: number; - /** - * Absolute path to the sentinel file the runner created for a `kind: 'pause'` - * task. Set when status === `AWAITING_APPROVAL`; persisted afterwards so the - * canvas can show "approved by removing ". - */ - checkpointPath?: string; -} - -export interface RunState { - title: string; - startedAt: number; - finishedAt?: number; - /** - * Aggregate outcome of the entire run. - * - * - `SUCCESS` — every task finished cleanly. - * - `FAILED` — at least one task ended in `ERROR`. - * - `INTERRUPTED` — the runner caught a fatal signal (SIGINT/SIGTERM/SIGHUP). - * - `BUDGET_EXCEEDED` — a budget ceiling was crossed: either the - * `--converge-on` loop exhausted `--max-iterations` with the convergence - * task still reporting blockers, OR `dag.budget.maxTokensTotal` was - * exceeded. Both paths exit with `EXIT_BUDGET_EXCEEDED` (4) so - * wrappers can branch on budget overflows without parsing logs. Hyphen - * form (`BUDGET-EXCEEDED`) is reserved for the per-task `TaskStatus`; - * the run-level field uses underscores to match the rest of this enum. - * - `RESTARTING_RUNNER` — runner runtime files changed mid-run; the - * supervisor should relaunch the runner from persisted state so the next - * process executes the newly edited source. - */ - runOutcome?: - | 'SUCCESS' - | 'FAILED' - | 'INTERRUPTED' - | 'BUDGET_EXCEEDED' - | 'RESTARTING_RUNNER'; - runMessage?: string; - tasks: TaskState[]; -} - -export function initialRunState( - dag: DAG, - modelFor: (c: Complexity) => ModelSpec -): RunState { - return { - title: dag.title, - startedAt: Date.now(), - tasks: dag.tasks.map((t) => { - const modelSelection = normalizeModelSelection( - modelFor(t.complexity), - `model for task ${t.id}` - ); - return { - id: t.id, - depends_on: t.depends_on, - complexity: t.complexity, - subtask_prompt: t.subtask_prompt, - status: 'PENDING', - model: formatModelSelection(modelSelection), - modelSelection, - // Normalize undefined kind → 'task' so downstream consumers (canvas - // template, runner dispatcher) never have to ?? again. - kind: t.kind ?? 'task', - // Surface oracle-only fields so the canvas can render the gate's - // command / expectation without reading the streamed result body. - ...(t.kind === 'oracle' - ? { command: t.command, expect: t.expect } - : {}), - }; - }), - }; -} - -/** - * Debounced writer. Multiple write() calls inside the debounce window collapse - * into one filesystem write — the latest state always wins. - */ -export class CanvasWriter { - private pending: RunState | null = null; - private timer: NodeJS.Timeout | null = null; - private inFlight: Promise = Promise.resolve(); - private writeSeq = 0; - private lastFailedWriteSeq = 0; - private lastWriteError: unknown = null; - - constructor( - private readonly canvasPath: string, - private readonly debounceMs: number = 200 - ) {} - - schedule(state: RunState): void { - this.pending = state; - if (this.timer) return; - this.timer = setTimeout(() => { - this.timer = null; - const snapshot = this.pending; - this.pending = null; - if (snapshot) { - this.enqueueWrite(snapshot); - } - }, this.debounceMs); - } - - /** Force-flush any pending write and await disk completion. */ - async flush(): Promise { - if (this.timer) { - clearTimeout(this.timer); - this.timer = null; - } - const snapshot = this.pending; - this.pending = null; - const targetWriteSeq = snapshot - ? this.enqueueWrite(snapshot) - : this.writeSeq; - await this.inFlight; - if (targetWriteSeq > 0 && this.lastFailedWriteSeq === targetWriteSeq) { - throw this.lastWriteError; - } - } - - private enqueueWrite(state: RunState): number { - const seq = ++this.writeSeq; - this.inFlight = this.inFlight.then(async () => { - try { - await this.writeNow(state); - if (this.lastFailedWriteSeq < seq) { - this.lastWriteError = null; - } - } catch (err) { - this.lastFailedWriteSeq = seq; - this.lastWriteError = err; - } - }); - return seq; - } - - private async writeNow(state: RunState): Promise { - const source = renderCanvasSource(state); - await mkdir(dirname(this.canvasPath), { recursive: true }); - await writeFile(this.canvasPath, source, 'utf8'); - } -} - -export function renderCanvasSource(state: RunState): string { - const stateLiteral = JSON.stringify(state, null, 2); - return `${HEADER}\n\nconst STATE: RunState = ${stateLiteral};\n\n${BODY}\n`; -} - -const HEADER = `/* AUTO-GENERATED by @flatbread/proof. Do not edit by hand — the runner overwrites this file. */ -import { - Card, - CardBody, - CardHeader, - Divider, - H1, - H2, - Pill, - Stack, - Stat, - Text, - computeDAGLayout, - useHostTheme, -} from 'cursor/canvas'; -import { useEffect, useMemo, useState } from 'react'; - -type TaskStatus = - | 'PENDING' - | 'RUNNING' - | 'FINISHED' - | 'ERROR' - | 'AWAITING_APPROVAL' - | 'BUDGET-EXCEEDED'; -type Complexity = 'HIGH' | 'MED' | 'LOW'; -type TaskKind = 'task' | 'pause' | 'oracle'; - -// Keep in sync with ModelParameterValue / ModelSelection in dag.ts. -interface ModelParameterValue { - id: string; - value: string; -} - -interface ModelSelection { - id: string; - params?: ModelParameterValue[]; -} - -interface TaskState { - id: string; - depends_on: string[]; - complexity: Complexity; - subtask_prompt: string; - status: TaskStatus; - model: string; - modelSelection?: ModelSelection; - kind?: TaskKind; - command?: string; - expect?: string; - startedAt?: number; - finishedAt?: number; - resultText?: string; - /** - * Relative path (artifact dir) for the authoritative stream transcript. - * Canvas shows bounded resultText strings; transcriptPath reveals the mirror file path. - */ - transcriptPath?: string; - errorMessage?: string; - inputTokens?: number; - outputTokens?: number; - durationMs?: number; - iteration?: number; - checkpointPath?: string; -} - -interface RunState { - title: string; - startedAt: number; - finishedAt?: number; - runOutcome?: - | 'SUCCESS' - | 'FAILED' - | 'INTERRUPTED' - | 'BUDGET_EXCEEDED' - | 'RESTARTING_RUNNER'; - runMessage?: string; - tasks: TaskState[]; -}`; - -const BODY = String.raw`const NODE_H = 64; -const SCROLL_STORAGE_KEY = '@flatbread/proof:scroll-y'; -const COMPLETED_DOT_COLOR = '#22c55e'; -const AWAITING_DOT_COLOR = '#f59e0b'; -const BUDGET_DOT_COLOR = '#ef4444'; -const COMPACT_BREAKPOINT_PX = 720; - -function effectiveKind(t: TaskState): TaskKind { - return t.kind ?? 'task'; -} - -function pillToneFor(status: TaskStatus): 'neutral' | 'info' | 'success' | 'warning' { - switch (status) { - case 'PENDING': - return 'neutral'; - case 'RUNNING': - return 'info'; - case 'FINISHED': - return 'success'; - case 'ERROR': - return 'warning'; - case 'AWAITING_APPROVAL': - return 'warning'; - case 'BUDGET-EXCEEDED': - return 'warning'; - } -} - -function complexityTone(c: Complexity): 'neutral' | 'info' | 'warning' { - switch (c) { - case 'HIGH': - return 'warning'; - case 'MED': - return 'info'; - case 'LOW': - return 'neutral'; - } -} - -function formatDuration(ms?: number): string { - if (ms === undefined) return '—'; - if (ms < 1000) return ms + 'ms'; - const s = ms / 1000; - if (s < 60) return s.toFixed(1) + 's'; - const m = Math.floor(s / 60); - const rem = Math.round(s - m * 60); - return m + 'm ' + rem + 's'; -} - -function elapsed(state: RunState): number { - const end = state.finishedAt ?? Date.now(); - return end - state.startedAt; -} - -function totalTokens(state: RunState): { input: number; output: number } { - let input = 0; - let output = 0; - for (const t of state.tasks) { - input += t.inputTokens ?? 0; - output += t.outputTokens ?? 0; - } - return { input, output }; -} - -function taskElementId(taskId: string): string { - return 'task-card-' + taskId; -} - -function useViewportWidth(): number { - const [width, setWidth] = useState(1024); - - useEffect(() => { - if (typeof window === 'undefined') return; - const update = (): void => setWidth(window.innerWidth); - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - }, []); - - return width; -} - -function getScrollY(): number { - if (typeof window === 'undefined') return 0; - return Math.max(window.scrollY ?? 0, 0); -} - -function saveScrollY(): void { - if (typeof window === 'undefined') return; - try { - window.sessionStorage.setItem(SCROLL_STORAGE_KEY, String(getScrollY())); - } catch { - // ignore storage failures - } -} - -function restoreScrollY(): void { - if (typeof window === 'undefined') return; - let target = 0; - try { - const raw = window.sessionStorage.getItem(SCROLL_STORAGE_KEY); - if (!raw) return; - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) return; - target = Math.floor(parsed); - } catch { - return; - } - - // Retry because hot-reload can run before content height has settled. - let attempts = 0; - const maxAttempts = 8; - const tick = (): void => { - attempts += 1; - const scrollHeight = Math.max( - document.documentElement?.scrollHeight ?? 0, - document.body?.scrollHeight ?? 0, - ); - const maxY = Math.max(scrollHeight - window.innerHeight, 0); - if (maxY <= 0) { - if (attempts < maxAttempts) window.requestAnimationFrame(tick); - return; - } - const desiredY = Math.min(target, maxY); - window.scrollTo({ top: desiredY, behavior: 'auto' }); - if (attempts < maxAttempts && Math.abs(getScrollY() - desiredY) > 2) { - window.requestAnimationFrame(tick); - } - }; - window.requestAnimationFrame(tick); -} - -function DAGGraph({ - state, - onNodeClick, -}: { - state: RunState; - onNodeClick?: (taskId: string) => void; -}): JSX.Element { - const theme = useHostTheme(); - const viewportWidth = useViewportWidth(); - const isCompact = viewportWidth < COMPACT_BREAKPOINT_PX; - const nodeWidth = isCompact ? 168 : 200; - const nodeGap = isCompact ? 24 : 40; - const rankGap = isCompact ? 60 : 72; - const layoutPadding = isCompact ? 12 : 24; - const titleLimit = Math.max(12, Math.floor((nodeWidth - 44) / 7)); - const layout = computeDAGLayout({ - nodes: state.tasks.map((t) => ({ id: t.id })), - edges: state.tasks.flatMap((t) => - t.depends_on.map((d) => ({ from: d, to: t.id })), - ), - direction: 'vertical', - nodeWidth, - nodeHeight: NODE_H, - rankGap, - nodeGap, - padding: layoutPadding, - }); - - const byId = new Map(state.tasks.map((t) => [t.id, t])); - - function nodeFill(status: TaskStatus): string { - switch (status) { - case 'PENDING': - return theme.fill.tertiary; - case 'RUNNING': - return theme.fill.secondary; - case 'FINISHED': - return theme.fill.secondary; - case 'ERROR': - return theme.fill.secondary; - case 'AWAITING_APPROVAL': - return theme.fill.secondary; - case 'BUDGET-EXCEEDED': - return theme.fill.secondary; - } - } - - function nodeStroke(status: TaskStatus): string { - switch (status) { - case 'PENDING': - return theme.stroke.tertiary; - case 'RUNNING': - return theme.accent.primary; - case 'FINISHED': - return COMPLETED_DOT_COLOR; - case 'ERROR': - return theme.stroke.primary; - case 'AWAITING_APPROVAL': - return AWAITING_DOT_COLOR; - case 'BUDGET-EXCEEDED': - return BUDGET_DOT_COLOR; - } - } - - function statusGlyph(status: TaskStatus): string { - switch (status) { - case 'PENDING': - return '○'; - case 'RUNNING': - return '◐'; - case 'FINISHED': - return '●'; - case 'ERROR': - return '×'; - case 'AWAITING_APPROVAL': - return '⏸'; - case 'BUDGET-EXCEEDED': - return '⊘'; - } - } - - function statusGlyphColor(status: TaskStatus): string { - switch (status) { - case 'PENDING': - return theme.text.tertiary; - case 'RUNNING': - return theme.accent.primary; - case 'FINISHED': - return COMPLETED_DOT_COLOR; - case 'ERROR': - return theme.text.primary; - case 'AWAITING_APPROVAL': - return AWAITING_DOT_COLOR; - case 'BUDGET-EXCEEDED': - return BUDGET_DOT_COLOR; - } - } - - return ( -
- - - - - - - {layout.edges.map((e, i) => ( - - ))} - {layout.nodes.map((n) => { - const t = byId.get(n.id); - if (!t) return null; - return ( - onNodeClick?.(n.id)} - style={{ cursor: onNodeClick ? 'pointer' : 'default' }} - > - - - {statusGlyph(t.status)} - - - {t.id.length > titleLimit ? t.id.slice(0, titleLimit - 1) + '…' : t.id} - - - {effectiveKind(t) === 'pause' - ? 'human checkpoint' - : effectiveKind(t) === 'oracle' - ? 'oracle gate' - : t.complexity + ' · ' + t.model} - - - {t.status === 'FINISHED' || t.status === 'ERROR' - ? (effectiveKind(t) === 'oracle' - ? (t.status === 'FINISHED' ? 'pass · ' : 'fail · ') + formatDuration(t.durationMs) - : formatDuration(t.durationMs)) + - ((t.iteration ?? 0) > 0 ? ' · iter ' + t.iteration : '') - : t.status === 'RUNNING' - ? 'running…' + ((t.iteration ?? 0) > 0 ? ' · iter ' + t.iteration : '') - : t.status === 'AWAITING_APPROVAL' - ? 'awaiting approval' - : t.status === 'BUDGET-EXCEEDED' - ? 'budget exceeded' + ((t.iteration ?? 0) > 0 ? ' · iter ' + t.iteration : '') - : 'pending'} - - - ); - })} - -
- ); -} - -function SummaryStats({ - counts, -}: { - counts: { - total: number; - pending: number; - running: number; - finished: number; - error: number; - awaiting: number; - }; -}): JSX.Element { - return ( -
- - - 0 ? 'info' : undefined} /> - 0 ? 'warning' : undefined} /> - 0 ? 'success' : undefined} /> - 0 ? 'danger' : undefined} /> -
- ); -} - -function TaskList({ - state, - forcedOpenVersionByTaskId, -}: { - state: RunState; - forcedOpenVersionByTaskId: Record; -}): JSX.Element { - const theme = useHostTheme(); - return ( - - {state.tasks.map((t) => { - const trailing = ( -
- - {t.complexity} - - - {t.status} - -
- ); - return ( -
- 0} - > - {t.id} - - - - {effectiveKind(t) === 'pause' - ? 'Human checkpoint' - : effectiveKind(t) === 'oracle' - ? 'Oracle gate (deterministic — no model)' - : 'Model ' + t.model} - {t.depends_on.length > 0 ? ' · depends on ' + t.depends_on.join(', ') : ''} - {t.durationMs !== undefined ? ' · ' + formatDuration(t.durationMs) : ''} - {t.inputTokens !== undefined || t.outputTokens !== undefined - ? ' · ' + (t.inputTokens ?? 0) + ' in / ' + (t.outputTokens ?? 0) + ' out tokens' - : ''} - {(t.iteration ?? 0) > 0 ? ' · iteration ' + t.iteration : ''} - - {t.modelSelection?.params && t.modelSelection.params.length > 0 ? ( - - {'Params: ' + - t.modelSelection.params.map((p) => p.id + '=' + p.value).join(', ')} - - ) : null} - {effectiveKind(t) === 'pause' && t.checkpointPath ? ( - - - {t.status === 'AWAITING_APPROVAL' ? 'Pending approval — delete this file to release the gate:' : 'Approved checkpoint:'} - -
-                      rm '{t.checkpointPath}'
-                    
-
- ) : null} - {effectiveKind(t) === 'oracle' ? ( - - - Command: - {t.command ?? '(no command)'} - - - Expect: - /{t.expect ?? '.*'}/ - - - ) : ( - - {effectiveKind(t) === 'pause' ? 'Description: ' : 'Prompt: '} - {t.subtask_prompt || (effectiveKind(t) === 'pause' ? '(no description)' : '')} - - )} - {t.resultText ? ( - - - {t.status === 'RUNNING' - ? 'Streaming output' - : t.status === 'AWAITING_APPROVAL' - ? 'Pause status' - : effectiveKind(t) === 'oracle' - ? t.status === 'FINISHED' - ? 'Oracle pass' - : 'Oracle fail' - : 'Result'} - -
-                      {t.resultText}
-                      {t.status === 'RUNNING' ? '\u2588' : ''}
-                    
- {t.transcriptPath ? ( - - Full transcript file (relative to artifact dir):{' '} - {t.transcriptPath} - - ) : null} -
- ) : t.status === 'RUNNING' ? ( - - Waiting for first token… - - ) : null} - {t.errorMessage ? ( - - Error - {t.errorMessage} - - ) : null} -
-
-
-
- ); - })} -
- ); -} - -export default function DagRun(): JSX.Element { - const [forcedOpenVersionByTaskId, setForcedOpenVersionByTaskId] = useState>({}); - const taskIds = useMemo(() => new Set(STATE.tasks.map((t) => t.id)), []); - - useEffect(() => { - restoreScrollY(); - const onScroll = (): void => saveScrollY(); - window.addEventListener('scroll', onScroll, { passive: true }); - return () => { - saveScrollY(); - window.removeEventListener('scroll', onScroll); - }; - }, []); - - const handleNodeClick = (taskId: string): void => { - if (!taskIds.has(taskId)) return; - - setForcedOpenVersionByTaskId((prev) => ({ - ...prev, - [taskId]: (prev[taskId] ?? 0) + 1, - })); - - const targetId = taskElementId(taskId); - const scrollToTask = (): void => { - const el = document.getElementById(targetId); - if (!el) return; - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - - // Wait one frame so the forced-open remount lands before we scroll. - window.requestAnimationFrame(scrollToTask); - }; - - const counts = STATE.tasks.reduce( - (acc, t) => { - acc.total += 1; - switch (t.status) { - case 'PENDING': - acc.pending += 1; - break; - case 'RUNNING': - acc.running += 1; - break; - case 'FINISHED': - acc.finished += 1; - break; - case 'ERROR': - acc.error += 1; - break; - case 'AWAITING_APPROVAL': - acc.awaiting += 1; - break; - case 'BUDGET-EXCEEDED': - // Surfaced via the per-task pill / glyph; bucketed under errored - // here so the summary counts stay stable. - acc.error += 1; - break; - } - return acc; - }, - { total: 0, pending: 0, running: 0, finished: 0, error: 0, awaiting: 0 }, - ); - const tokens = totalTokens(STATE); - const isFinal = STATE.finishedAt !== undefined; - const statusLabel = - STATE.runOutcome === 'INTERRUPTED' - ? 'INTERRUPTED' - : STATE.runOutcome === 'FAILED' - ? 'FAILED' - : STATE.runOutcome === 'BUDGET_EXCEEDED' - ? 'BUDGET-EXCEEDED' - : STATE.runOutcome === 'RESTARTING_RUNNER' - ? 'RESTARTING RUNNER' - : isFinal - ? 'COMPLETE' - : 'RUNNING'; - const statusTone = - STATE.runOutcome === 'INTERRUPTED' || - STATE.runOutcome === 'FAILED' || - STATE.runOutcome === 'BUDGET_EXCEEDED' - ? 'danger' - : STATE.runOutcome === 'RESTARTING_RUNNER' - ? 'warning' - : isFinal - ? 'success' - : 'info'; - - return ( -
- - -
-

{STATE.title}

-
-
- - {statusLabel} - - - {counts.total} tasks · elapsed {formatDuration(elapsed(STATE))} - {tokens.input + tokens.output > 0 - ? ' · ' + tokens.input + ' in / ' + tokens.output + ' out tokens' - : ''} - -
- {STATE.runMessage ? ( - - {STATE.runMessage} - - ) : null} -
- - - - - - -

Graph

- -
- - - - -

Tasks

- -
-
-
- ); -}`; diff --git a/packages/proof/src/converge_loop.ts b/packages/proof/src/converge_loop.ts deleted file mode 100644 index 961b43e8..00000000 --- a/packages/proof/src/converge_loop.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * --converge-on + --max-iterations loop helpers. - * - * The convergence task is expected to be a `flatbread-adversarial-reviewer` - * style node — its bounded canvas `resultText` follows the schema: - * - * ## Blockers - * … - * ## High-severity findings - * … - * ## Medium-severity findings - * … - * - * `extractConvergenceFindings` parses that result text. If `## Blockers` or - * `## High-severity findings` contain meaningful content (anything beyond - * `none`/`(none)`/`n/a` placeholders), we mark the run as having issues and - * the parent runner re-executes the ancestor subtree. - * - * `transitiveAncestors` returns the closed set of ancestor task ids in the - * DAG (the union of `depends_on` reached by repeated traversal). The runner - * filters its existing rank ordering to that set so re-execution preserves - * the same topological order as the original run. - */ - -import { - transitiveAncestorIds, - type DAG, - type ResolvedConvergenceLoop, -} from './dag.js'; -import { - type UpstreamPolicyMode, - excerptUpstreamForPrompt, -} from './upstream_policy.js'; - -export interface ConvergenceFindings { - hasIssues: boolean; - blockerLines: string[]; - highSeverityLines: string[]; -} - -export function extractConvergenceFindings( - text: string | undefined -): ConvergenceFindings { - if (!text) { - return { hasIssues: false, blockerLines: [], highSeverityLines: [] }; - } - const sections = parseSections(text); - const blockerLines = filterMeaningful(sections.get('blockers') ?? []); - const highSeverityLines = filterMeaningful( - sections.get('high-severity findings') ?? [] - ); - return { - hasIssues: blockerLines.length > 0 || highSeverityLines.length > 0, - blockerLines, - highSeverityLines, - }; -} - -/** - * Splits text into sections keyed by `## ` heading text (lower-cased, - * trimmed). Sub-headings (`### …`) are kept inside their parent section. - */ -function parseSections(text: string): Map { - const out = new Map(); - // Anchor on lines that start with exactly two `#` (not three+) followed by - // a space — matches `## Blockers` but skips `### Sub-section`. - const HEADING_RE = /^##(?!#)\s*(.+?)\s*$/; - const lines = text.split(/\r?\n/); - let currentHeading: string | null = null; - let currentLines: string[] = []; - for (const line of lines) { - const m = HEADING_RE.exec(line); - if (m) { - if (currentHeading !== null) out.set(currentHeading, currentLines); - currentHeading = m[1].trim().toLowerCase(); - currentLines = []; - } else if (currentHeading !== null) { - currentLines.push(line); - } - } - if (currentHeading !== null) out.set(currentHeading, currentLines); - return out; -} - -/** - * Returns lines that look like real findings — drops blanks, plain - * placeholder text like `(none)` / `none.` / `n/a`, and decorative - * separators. - */ -function filterMeaningful(lines: string[]): string[] { - const out: string[] = []; - for (const raw of lines) { - const line = raw.trim(); - if (line === '') continue; - if (isPlaceholderLine(line)) continue; - if (/^[-*_]{3,}$/.test(line)) continue; // hr separators - out.push(line); - } - return out; -} - -function isPlaceholderLine(line: string): boolean { - // Strip leading bullets, formatting punctuation, and surrounding parens — - // anything that survives gets compared against a tiny placeholder vocabulary. - const stripped = line.replace(/[-_*()[\]\s.,;:!?'"`>]/g, '').toLowerCase(); - if (stripped === '') return true; - return PLACEHOLDER_WORDS.has(stripped); -} - -const PLACEHOLDER_WORDS = new Set([ - 'none', - 'na', - 'noneobserved', - 'nonefound', - 'nonenoted', - 'nothing', - 'nothingtoreport', - 'noissues', - 'noissuesfound', - 'noblockers', - 'noblockersfound', - 'nohighseverityfindings', - 'nohighseverityissues', -]); - -export function transitiveAncestors(taskId: string, dag: DAG): Set { - return transitiveAncestorIds(taskId, dag.tasks); -} - -/** - * Resolves a single loop's `reexecute` selector into the concrete set of - * task ids the runner re-executes per iteration. Always includes the - * convergence task itself so the loop body can re-run it after upstream - * re-execution. Pure function — does not mutate the DAG or the loop. - * - * - `{ kind: 'ancestors' }` → `transitiveAncestors(convergeOn) ∪ {convergeOn}`, - * matching the legacy `--converge-on` behavior. - * - `{ kind: 'tasks'; tasks: [...] }` → the validated allow-list (already - * guaranteed at parse time to lie inside the convergence ancestor cone). - * The convergence task id is added defensively even though `parseDAG` - * already injects it during validation. - */ -export function resolveLoopReexecuteIds( - loop: ResolvedConvergenceLoop, - dag: DAG -): Set { - if (loop.reexecute.kind === 'ancestors') { - const ids = transitiveAncestors(loop.convergeOn, dag); - ids.add(loop.convergeOn); - return ids; - } - const ids = new Set(loop.reexecute.tasks); - ids.add(loop.convergeOn); - return ids; -} - -/** - * Renders the convergence task's reviewer transcript into the standard "extra - * upstream context" preamble we stitch into ancestor prompts on re-run. The - * iteration index lets re-runs distinguish their feedback from any future - * iterations. The body is excerpted via the same upstream policy as child - * `buildUpstreamContext` — never silently truncated mid-review. - */ -export function buildConvergenceContext( - convergeTaskId: string, - iteration: number, - reviewerTranscript: string | undefined, - upstreamMode: UpstreamPolicyMode = 'summarize' -): string { - const trimmed = (reviewerTranscript ?? '').trim(); - if (trimmed === '') { - return [ - `Convergence feedback from "${convergeTaskId}" (iteration ${ - iteration - 1 - }):`, - '', - '(empty result text)', - ].join('\n'); - } - const body = excerptUpstreamForPrompt(trimmed, upstreamMode); - return [ - `Convergence feedback from "${convergeTaskId}" (iteration ${ - iteration - 1 - }):`, - '', - body, - ].join('\n'); -} diff --git a/packages/proof/src/dag.test.ts b/packages/proof/src/dag.test.ts deleted file mode 100644 index 42d7e3dc..00000000 --- a/packages/proof/src/dag.test.ts +++ /dev/null @@ -1,474 +0,0 @@ -import test from 'ava'; - -import { - createModelSelectionResolver, - normalizeModelSelection, - parseDAG, - resolveModelSelectionFromCatalog, - validateModelMap, - type ModelCatalogItem, - type ModelSpec, - type ModelSelection, -} from './dag.js'; - -function resolveSelection( - selection: ModelSelection, - variants: NonNullable -): ModelSelection { - const catalog: ModelCatalogItem[] = [ - { id: 'composer-2', displayName: 'Composer 2', variants }, - ]; - return resolveModelSelectionFromCatalog(selection, catalog, 'test model'); -} - -test('resolveModelSelectionFromCatalog prefers highest-scoring variant among matches', (t) => { - const resolved = resolveSelection( - { id: 'composer-2', params: [{ id: 'effort', value: 'max' }] }, - [ - { - displayName: 'Default medium concise', - isDefault: true, - params: [ - { id: 'effort', value: 'medium' }, - { id: 'style', value: 'concise' }, - ], - }, - { - displayName: 'Max concise', - params: [ - { id: 'effort', value: 'max' }, - { id: 'style', value: 'concise' }, - ], - }, - { - displayName: 'Max verbose', - params: [ - { id: 'effort', value: 'max' }, - { id: 'style', value: 'verbose' }, - ], - }, - ] - ); - - t.deepEqual(resolved, { - id: 'composer-2', - params: [ - { id: 'effort', value: 'max' }, - { id: 'style', value: 'concise' }, - ], - }); -}); - -test('resolveModelSelectionFromCatalog breaks equal-score ties to catalog default variant', (t) => { - const resolved = resolveSelection( - { id: 'composer-2', params: [{ id: 'effort', value: 'max' }] }, - [ - { - displayName: 'Max with style override', - params: [ - { id: 'effort', value: 'max' }, - { id: 'style', value: 'verbose' }, - ], - }, - { - displayName: 'Default max', - isDefault: true, - params: [{ id: 'effort', value: 'max' }], - }, - ] - ); - - t.deepEqual(resolved, { - id: 'composer-2', - params: [{ id: 'effort', value: 'max' }], - }); -}); - -test('resolveModelSelectionFromCatalog throws a descriptive error when no variant matches', (t) => { - const err = t.throws(() => - resolveSelection( - { id: 'composer-2', params: [{ id: 'effort', value: 'max' }] }, - [ - { - displayName: 'Default medium', - isDefault: true, - params: [{ id: 'effort', value: 'medium' }], - }, - ] - ) - ); - - if (!err) { - t.fail('Expected no-match variant selection to throw.'); - return; - } - t.regex( - err.message, - /does not match any Cursor SDK preset variant\. Valid variants:/ - ); -}); - -test('resolveModelSelectionFromCatalog returns default variant when no params requested', (t) => { - const resolved = resolveSelection({ id: 'composer-2' }, [ - { - displayName: 'Fast', - params: [{ id: 'effort', value: 'low' }], - }, - { - displayName: 'Default', - isDefault: true, - params: [{ id: 'effort', value: 'medium' }], - }, - ]); - - t.deepEqual(resolved, { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }); -}); - -test('resolveModelSelectionFromCatalog falls back to first variant when no default is flagged', (t) => { - const resolved = resolveSelection({ id: 'composer-2' }, [ - { - displayName: 'Fast', - params: [{ id: 'effort', value: 'low' }], - }, - { - displayName: 'Careful', - params: [{ id: 'effort', value: 'high' }], - }, - ]); - - t.deepEqual(resolved, { - id: 'composer-2', - params: [{ id: 'effort', value: 'low' }], - }); -}); - -test('resolveModelSelectionFromCatalog treats empty params as no params requested', (t) => { - const resolved = resolveSelection({ id: 'composer-2', params: [] }, [ - { - displayName: 'Fast', - params: [{ id: 'effort', value: 'low' }], - }, - { - displayName: 'Default', - isDefault: true, - params: [{ id: 'effort', value: 'medium' }], - }, - ]); - - t.deepEqual(resolved, { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }); -}); - -test('resolveModelSelectionFromCatalog throws when no variant fully matches requested params', (t) => { - const err = t.throws(() => - resolveSelection( - { - id: 'composer-2', - params: [ - { id: 'effort', value: 'max' }, - { id: 'style', value: 'verbose' }, - ], - }, - [ - { - displayName: 'Max concise', - params: [ - { id: 'effort', value: 'max' }, - { id: 'style', value: 'concise' }, - ], - }, - { - displayName: 'Medium verbose', - params: [ - { id: 'effort', value: 'medium' }, - { id: 'style', value: 'verbose' }, - ], - }, - ] - ) - ); - - if (!err) { - t.fail('Expected partial variant match to throw.'); - return; - } - t.regex(err.message, /does not match any Cursor SDK preset variant/); -}); - -test('resolveModelSelectionFromCatalog throws on unknown model id', (t) => { - const catalog: ModelCatalogItem[] = [ - { id: 'composer-2', displayName: 'Composer 2' }, - ]; - const err = t.throws(() => - resolveModelSelectionFromCatalog({ id: 'unknown-model' }, catalog, 'test') - ); - - if (!err) { - t.fail('Expected unknown model id to throw.'); - return; - } - t.regex(err.message, /uses unknown Cursor SDK model/); -}); - -test('resolveModelSelectionFromCatalog passes through selection when model has no variants', (t) => { - const catalog: ModelCatalogItem[] = [ - { id: 'composer-2', displayName: 'Composer 2' }, - ]; - const selection: ModelSelection = { - id: 'composer-2', - }; - const resolved = resolveModelSelectionFromCatalog(selection, catalog, 'test'); - - t.deepEqual(resolved, selection); - t.not(resolved, selection); -}); - -test('resolveModelSelectionFromCatalog accepts valid params declared by catalog parameters', (t) => { - const catalog: ModelCatalogItem[] = [ - { - id: 'composer-2', - displayName: 'Composer 2', - parameters: [ - { - id: 'effort', - values: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }], - }, - ], - }, - ]; - const selection: ModelSelection = { - id: 'composer-2', - params: [{ id: 'effort', value: 'high' }], - }; - - const resolved = resolveModelSelectionFromCatalog(selection, catalog, 'test'); - - t.deepEqual(resolved, selection); - t.not(resolved, selection); -}); - -test('resolveModelSelectionFromCatalog throws when catalog parameters reject a param id', (t) => { - const catalog: ModelCatalogItem[] = [ - { - id: 'composer-2', - displayName: 'Composer 2', - parameters: [{ id: 'effort', values: [{ value: 'medium' }] }], - }, - ]; - const err = t.throws(() => - resolveModelSelectionFromCatalog( - { id: 'composer-2', params: [{ id: 'style', value: 'concise' }] }, - catalog, - 'test' - ) - ); - - if (!err) { - t.fail('Expected unknown parameter id to throw.'); - return; - } - t.regex(err.message, /does not support param "style"/); -}); - -test('resolveModelSelectionFromCatalog throws when catalog parameters reject a param value', (t) => { - const catalog: ModelCatalogItem[] = [ - { - id: 'composer-2', - displayName: 'Composer 2', - parameters: [{ id: 'effort', values: [{ value: 'medium' }] }], - }, - ]; - const err = t.throws(() => - resolveModelSelectionFromCatalog( - { id: 'composer-2', params: [{ id: 'effort', value: 'max' }] }, - catalog, - 'test' - ) - ); - - if (!err) { - t.fail('Expected unsupported parameter value to throw.'); - return; - } - t.regex(err.message, /param "effort" does not support value "max"/); -}); - -test('resolveModelSelectionFromCatalog throws when explicit params have no catalog declaration', (t) => { - const catalog: ModelCatalogItem[] = [ - { id: 'composer-2', displayName: 'Composer 2' }, - ]; - const err = t.throws(() => - resolveModelSelectionFromCatalog( - { id: 'composer-2', params: [{ id: 'effort', value: 'medium' }] }, - catalog, - 'test' - ) - ); - - if (!err) { - t.fail('Expected undeclared parameters to throw.'); - return; - } - t.regex(err.message, /does not declare parameters or preset variants/); -}); - -test('normalizeModelSelection trims string model ids', (t) => { - t.deepEqual(normalizeModelSelection(' composer-2 '), { id: 'composer-2' }); -}); - -test('normalizeModelSelection normalizes valid object model specs', (t) => { - const input: ModelSelection = { - id: ' composer-2 ', - params: [{ id: ' effort ', value: ' medium ' }], - }; - const result = normalizeModelSelection(input, 'test model'); - - t.deepEqual(result, { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }); - t.not(result, input); - t.not(result.params, input.params); -}); - -test('normalizeModelSelection throws label-prefixed errors for invalid model specs', (t) => { - for (const raw of ['', ' ', 42, null]) { - const err = t.throws(() => - normalizeModelSelection(raw as unknown as ModelSpec, 'test model') - ); - - if (!err) { - t.fail(`Expected invalid model spec ${String(raw)} to throw.`); - continue; - } - t.regex(err.message, /^test model must be /); - } -}); - -test('normalizeModelSelection throws label-prefixed errors for invalid param values', (t) => { - for (const value of ['', ' ', 42]) { - const err = t.throws(() => - normalizeModelSelection( - { - id: 'composer-2', - params: [{ id: 'effort', value }], - } as unknown as ModelSpec, - 'test model' - ) - ); - - if (!err) { - t.fail(`Expected invalid param value ${String(value)} to throw.`); - continue; - } - t.is(err.message, 'test model.params[0].value must be a non-empty string.'); - } -}); - -test('normalizeModelSelection throws on duplicate param ids', (t) => { - const err = t.throws(() => - normalizeModelSelection( - { - id: 'composer-2', - params: [ - { id: 'effort', value: 'low' }, - { id: 'effort', value: 'high' }, - ], - }, - 'test model' - ) - ); - - if (!err) { - t.fail('Expected duplicate param id to throw.'); - return; - } - t.regex(err.message, /duplicate id: effort/); -}); - -test('validateModelMap accepts plain string model ids', (t) => { - t.deepEqual( - validateModelMap( - { - HIGH: ' claude-opus-4-7 ', - LOW: 'gpt-5.4-nano', - }, - 'test models' - ), - { - HIGH: { id: 'claude-opus-4-7' }, - LOW: { id: 'gpt-5.4-nano' }, - } - ); -}); - -test('validateModelMap accepts model selection objects with params', (t) => { - t.deepEqual( - validateModelMap( - { - MED: { - id: 'composer-2', - params: [{ id: 'effort', value: 'max' }], - }, - }, - 'test models' - ), - { - MED: { - id: 'composer-2', - params: [{ id: 'effort', value: 'max' }], - }, - } - ); -}); - -test('createModelSelectionResolver normalizes mixed override shapes', (t) => { - const modelFor = createModelSelectionResolver({ - HIGH: 'claude-opus-4-7', - MED: { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }, - }); - - t.deepEqual(modelFor('HIGH'), { id: 'claude-opus-4-7' }); - t.deepEqual(modelFor('MED'), { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }); - t.deepEqual(modelFor('LOW'), { id: 'gpt-5.4-nano' }); -}); - -test('parseDAG normalizes mixed model override shapes', (t) => { - const dag = parseDAG({ - title: 'Mixed model overrides', - models: { - HIGH: 'claude-opus-4-7', - MED: { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }, - }, - tasks: [ - { - id: 'review', - depends_on: [], - complexity: 'HIGH', - subtask_prompt: 'Review the change.', - }, - ], - }); - - t.deepEqual(dag.models, { - HIGH: { id: 'claude-opus-4-7' }, - MED: { - id: 'composer-2', - params: [{ id: 'effort', value: 'medium' }], - }, - }); -}); diff --git a/packages/proof/src/dag.ts b/packages/proof/src/dag.ts deleted file mode 100644 index d7c2f902..00000000 --- a/packages/proof/src/dag.ts +++ /dev/null @@ -1,1206 +0,0 @@ -/** - * DAG schema parsing, validation, and topological ranking for the runner. - * - * The DAG file shape is intentionally tiny — see ../examples/example_dag.json. - */ - -export type Complexity = 'HIGH' | 'MED' | 'LOW'; -export interface ModelParameterValue { - id: string; - value: string; -} - -export interface ModelSelection { - id: string; - params?: ModelParameterValue[]; -} - -export type ModelSpec = string | ModelSelection; -export type ModelMap = Record; -export type ModelMapOverride = Partial>; -export type ResolvedModelMap = Record; - -export interface ModelCatalogItem { - id: string; - displayName: string; - parameters?: Array<{ - id: string; - displayName?: string; - values: Array<{ value: string; displayName?: string }>; - }>; - variants?: Array<{ - params: ModelParameterValue[]; - displayName: string; - description?: string; - isDefault?: boolean; - }>; -} - -/** - * Discriminator separating LLM-backed work from non-LLM gate nodes. - * - * - `task` (default) — a normal subagent invocation; uses `complexity` to - * select a model and treats `subtask_prompt` as the LLM prompt. - * - `pause` — a no-LLM rendezvous node. The runner blocks downstream tasks - * until an out-of-band signal (sentinel file removal, timeout, etc.) is - * observed. `complexity` is irrelevant and rejected at parse time; - * `subtask_prompt` is optional and surfaced as the canvas description. - * - `oracle` — a no-LLM deterministic gate. The runner executes `command` - * and pass/fails on whether stdout/stderr matches `expect` (regex, - * defaults to `'.*'`). `complexity`, `subtask_prompt`, and any explicit - * `model` field are rejected at parse time because no model is invoked. - */ -export type TaskKind = 'task' | 'pause' | 'oracle'; - -export interface RawTask { - id: string; - depends_on: string[]; - complexity: Complexity; - subtask_prompt: string; - /** - * Optional discriminator. Absent in legacy DAG JSON, in which case the - * parser treats the task as `'task'` so every existing template keeps - * parsing untouched. Non-LLM kinds (`'pause'`, `'oracle'`) get a synthetic - * `complexity` (`'LOW'`) attached so the structural type is satisfied — - * the runner must branch on `kind` before consuming `complexity` or - * `subtask_prompt`. - */ - kind?: TaskKind; - /** - * Required for `kind: 'oracle'`. Shell command the runner executes to - * decide pass/fail. Ignored on every other kind and rejected at parse - * time if set on a non-oracle task. - */ - command?: string; - /** - * Optional for `kind: 'oracle'`. Regex applied to the command's combined - * stdout/stderr; a match is required for pass. Defaults to `'.*'` (any - * output, even empty, matches). Rejected on every other kind. - * - * Note: by default the pass predicate ALSO requires `exit code === 0`. - * Set `allowNonZeroExit: true` to opt out of that requirement (only useful - * when asserting on the output of an intentionally failing command). - */ - expect?: string; - /** - * Optional for `kind: 'oracle'`. When `true`, an oracle passes on regex - * match alone, regardless of the command's exit code. Defaults to `false` - * — exit 0 is required by default because the historical regex-only - * contract silently passed `&&`-chained commands that exited non-zero. - * Rejected on every other kind. - */ - allowNonZeroExit?: boolean; -} - -/** - * Optional per-DAG policy for how parent task output is excerpted into child - * prompts and convergence `extraContext`. Phase 1 defaults match historical - * behavior (`summarize` with a 2000-char section-aware cap plus explicit banners). - */ -export interface DAGOutputPolicy { - /** When `full`, upstream snippets are not structurally capped (still subject to model context). */ - upstream?: 'full' | 'summarize'; -} - -export interface DAG { - title: string; - models?: ModelMapOverride; - framing?: string; - budget?: DAGBudget; - /** How much of each parent transcript is stitched into downstream prompts. */ - outputPolicy?: DAGOutputPolicy; - tasks: RawTask[]; - /** - * Optional first-class bounded convergence loops. Each entry generalizes - * the legacy CLI `--converge-on`/`--max-iterations` pair into a DAG-native - * declaration so the same JSON file is reproducibly runnable without - * remembering the right flags. - * - * Loops execute sequentially in declaration order after the main rank loop - * completes. `--converge-on` may not be combined with `loops`; the runner - * errors at startup if both are set. Loop re-execution sets must also be - * disjoint so one loop cannot silently invalidate another loop's already - * converged outcome. - */ - loops?: DAGConvergenceLoop[]; -} - -export interface DAGBudget { - maxIterations?: number; - maxTokensTotal?: number; -} - -/** - * Selector for which tasks a convergence loop re-executes per iteration. - * - * - `{ kind: 'ancestors' }` — default, mirrors the legacy CLI behavior: - * re-runs every transitive ancestor of `convergeOn` plus `convergeOn` - * itself. - * - `{ kind: 'tasks'; tasks: [...] }` — explicit allow-list. Every id must - * be a known task, must lie inside the convergence ancestor cone - * (`transitiveAncestors(convergeOn) ∪ {convergeOn}`); ids outside that - * cone are rejected at parse time because re-running them would break - * topological ordering of the filtered re-execution ranks. The explicit - * list must also be dependency-closed for every non-`convergeOn` task it - * names so the runner never mixes a fresh task with stale upstream inputs. - */ -export type LoopReexecute = - | { kind: 'ancestors' } - | { kind: 'tasks'; tasks: string[] }; -/** - * First-class bounded convergence loop. Generalizes the singleton CLI - * `--converge-on`/`--max-iterations` pair into a DAG-native config so a - * single run can stack multiple convergence tasks (e.g. one for the - * implementation reviewer, one for the docs reviewer) and so DAG-emitting - * tooling can declare loop intent reproducibly. - */ -export interface DAGConvergenceLoop { - /** Stable id for canvas/log display. Defaults to `loop-${convergeOn}` when omitted. */ - id?: string; - /** Task whose `## Blockers` / `## High-severity findings` drive the loop. */ - convergeOn: string; - /** Iteration ceiling. Iteration 0 is the original main-rank run. */ - maxIterations: number; - /** What to re-execute per iteration. Defaults to `{ kind: 'ancestors' }`. */ - reexecute?: LoopReexecute; -} - -/** Loop config with all defaults filled in — what the runner actually consumes. */ -export interface ResolvedConvergenceLoop { - id: string; - convergeOn: string; - maxIterations: number; - reexecute: LoopReexecute; -} - -const LOOP_REEXECUTE_KINDS = new Set([ - 'ancestors', - 'tasks', -]); -const COMPLEXITY_VALUES = new Set(['HIGH', 'MED', 'LOW']); -export const COMPLEXITY_KEYS: readonly Complexity[] = [ - 'HIGH', - 'MED', - 'LOW', -] as const; -const TASK_KIND_VALUES = new Set(['task', 'pause', 'oracle']); -/** Synthetic placeholder so non-LLM tasks (pause, oracle) satisfy the existing structural type. The runner must branch on `kind` before consuming this. */ -const NON_LLM_SYNTHETIC_COMPLEXITY: Complexity = 'LOW'; -/** Default `expect` regex for `kind: 'oracle'` — any output (even empty) matches. */ -const DEFAULT_ORACLE_EXPECT = '.*'; - -/** Type guard — pause tasks must be detected by `kind` before any model-bound code path runs. */ -export function isPauseTask(task: RawTask): boolean { - return task.kind === 'pause'; -} - -/** Type guard — oracle tasks must be detected by `kind` before any model-bound code path runs. */ -export function isOracleTask(task: RawTask): boolean { - return task.kind === 'oracle'; -} - -/** - * Model IDs are validated at runtime by the Cursor SDK (NOT the `cursor-agent` - * CLI). The two catalogs differ: the CLI exposes reasoning-effort suffixes - * like `gpt-5.4-low` and `claude-opus-4-7-thinking-medium`; the SDK only - * accepts base slugs and rejects suffixed variants with - * `ConfigurationError: Cannot use this model`. - * - * The defaults below were cross-checked against the SDK's own error-message - * catalog (which `assertModelIdInList` enumerates verbatim) on 2026-05-07: - * - * default, composer-2, composer-1.5, gpt-5.3-codex, claude-sonnet-4-6, - * gpt-5.5, claude-opus-4-7, gpt-5.4, claude-opus-4-6, claude-opus-4-5, - * gpt-5.2, gemini-3.1-pro, gpt-5.4-mini, gpt-5.4-nano, claude-haiku-4-5, - * gpt-5.3-codex-spark, grok-4.3, claude-sonnet-4-5, gpt-5.2-codex, - * gpt-5.1-codex-max, gpt-5.1, gemini-3-flash, gpt-5.1-codex-mini, - * claude-sonnet-4, gpt-5-mini, gemini-2.5-flash, kimi-k2.5 - * - * To re-validate: trigger any LOW task with a deliberately-bad model id and - * read the SDK's error-message catalog; do NOT trust `cursor-agent --list-models`. - */ -export const DEFAULT_MODEL_MAP: ModelMap = { - HIGH: { id: 'claude-opus-4-7' }, - MED: { id: 'composer-2' }, - LOW: { id: 'gpt-5.4-nano' }, -}; - -export function parseDAG(raw: unknown): DAG { - if (!raw || typeof raw !== 'object') { - throw new Error('DAG file must be a JSON object.'); - } - const obj = raw as Record; - if (typeof obj.title !== 'string' || obj.title.trim() === '') { - throw new Error('DAG.title must be a non-empty string.'); - } - if (!Array.isArray(obj.tasks) || obj.tasks.length === 0) { - throw new Error('DAG.tasks must be a non-empty array.'); - } - - const tasks: RawTask[] = obj.tasks.map((t, i) => validateTask(t, i)); - const ids = new Set(); - for (const t of tasks) { - if (ids.has(t.id)) { - throw new Error(`Duplicate task id: ${t.id}`); - } - ids.add(t.id); - } - for (const t of tasks) { - for (const dep of t.depends_on) { - if (!ids.has(dep)) { - throw new Error(`Task ${t.id} depends_on unknown id: ${dep}`); - } - if (dep === t.id) { - throw new Error(`Task ${t.id} depends on itself.`); - } - } - } - - detectCycle(tasks); - - const models = - obj.models === undefined - ? undefined - : validateModelMap(obj.models, 'DAG.models'); - const framing = - obj.framing === undefined ? undefined : validateFraming(obj.framing); - const budget = - obj.budget === undefined ? undefined : validateBudget(obj.budget); - const outputPolicy = - obj.outputPolicy === undefined - ? undefined - : validateOutputPolicy(obj.outputPolicy); - const loops = - obj.loops === undefined ? undefined : validateLoops(obj.loops, tasks); - - return { - title: obj.title, - models, - framing, - budget, - outputPolicy, - tasks, - loops, - }; -} - -function validateOutputPolicy(raw: unknown): DAGOutputPolicy { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error('DAG.outputPolicy must be a JSON object when set.'); - } - const obj = raw as Record; - const allowedKeys = new Set(['upstream']); - for (const key of Object.keys(obj)) { - if (!allowedKeys.has(key)) { - throw new Error( - `DAG.outputPolicy.${key} is not supported. Supported keys: upstream.` - ); - } - } - const upstream = obj.upstream; - if (upstream === undefined) { - return {}; - } - if (upstream !== 'full' && upstream !== 'summarize') { - throw new Error( - 'DAG.outputPolicy.upstream must be "full" or "summarize" when set.' - ); - } - return { upstream }; -} - -/** - * Returns the closed set of transitive ancestor ids for `taskId` in the - * given task list (the union of `depends_on` reached by repeated - * traversal). Canonical transitive-ancestor traversal shared with - * `converge_loop.ts`. Defined here (takes `RawTask[]` not a full `DAG` - * object) so `parseDAG` can validate `loops.reexecute.tasks` without a - * circular module import; `converge_loop.ts:transitiveAncestors` delegates - * to this function. - */ -export function transitiveAncestorIds( - taskId: string, - tasks: RawTask[] -): Set { - const byId = new Map(tasks.map((t) => [t.id, t])); - const visited = new Set(); - const start = byId.get(taskId); - if (!start) return visited; - const stack: string[] = [...start.depends_on]; - while (stack.length > 0) { - const id = stack.pop()!; - if (visited.has(id)) continue; - visited.add(id); - const t = byId.get(id); - if (!t) continue; - for (const dep of t.depends_on) stack.push(dep); - } - return visited; -} - -function validateLoops(raw: unknown, tasks: RawTask[]): DAGConvergenceLoop[] { - if (!Array.isArray(raw)) { - throw new Error('DAG.loops must be an array of loop config objects.'); - } - const taskIds = new Set(tasks.map((t) => t.id)); - const loops: DAGConvergenceLoop[] = []; - const seenConvergeOn = new Set(); - const seenResolvedIds = new Set(); - for (let i = 0; i < raw.length; i++) { - const loop = validateLoop(raw[i], i, taskIds, tasks); - if (seenConvergeOn.has(loop.convergeOn)) { - throw new Error( - `DAG.loops[${i}]: duplicate convergeOn "${loop.convergeOn}" — each loop must drive a distinct task.` - ); - } - seenConvergeOn.add(loop.convergeOn); - const resolvedId = loop.id ?? `loop-${loop.convergeOn}`; - if (seenResolvedIds.has(resolvedId)) { - throw new Error( - `DAG.loops[${i}]: duplicate loop id; resolved loop id "${resolvedId}" collides with a previous loop's id. ` + - `Set an explicit \`id\` on one of the colliding loops to disambiguate.` - ); - } - seenResolvedIds.add(resolvedId); - loops.push(loop); - } - validateLoopInteractions(loops, tasks); - return loops; -} - -function validateLoop( - raw: unknown, - index: number, - taskIds: Set, - tasks: RawTask[] -): DAGConvergenceLoop { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(`DAG.loops[${index}] must be a JSON object.`); - } - const obj = raw as Record; - const convergeOn = obj.convergeOn; - if (typeof convergeOn !== 'string' || convergeOn.trim() === '') { - throw new Error( - `DAG.loops[${index}].convergeOn must be a non-empty string.` - ); - } - if (!taskIds.has(convergeOn)) { - throw new Error( - `DAG.loops[${index}].convergeOn "${convergeOn}" is not a task id in this DAG.` - ); - } - const maxIterations = obj.maxIterations; - if ( - typeof maxIterations !== 'number' || - !Number.isSafeInteger(maxIterations) || - maxIterations <= 0 - ) { - throw new Error( - `DAG.loops[${index}].maxIterations must be a positive integer.` - ); - } - let id: string | undefined; - if (obj.id !== undefined) { - if (typeof obj.id !== 'string' || obj.id.trim() === '') { - throw new Error( - `DAG.loops[${index}].id must be a non-empty string when set.` - ); - } - id = obj.id; - } - let reexecute: LoopReexecute | undefined; - if (obj.reexecute !== undefined) { - reexecute = validateReexecute( - obj.reexecute, - index, - taskIds, - convergeOn, - tasks - ); - } - const loop: DAGConvergenceLoop = { convergeOn, maxIterations }; - if (id !== undefined) loop.id = id; - if (reexecute !== undefined) loop.reexecute = reexecute; - return loop; -} - -function validateReexecute( - raw: unknown, - loopIndex: number, - taskIds: Set, - convergeOn: string, - tasks: RawTask[] -): LoopReexecute { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error( - `DAG.loops[${loopIndex}].reexecute must be a JSON object when set.` - ); - } - const obj = raw as Record; - const kind = obj.kind; - if ( - typeof kind !== 'string' || - !LOOP_REEXECUTE_KINDS.has(kind as LoopReexecute['kind']) - ) { - throw new Error( - `DAG.loops[${loopIndex}].reexecute.kind must be one of: ${[ - ...LOOP_REEXECUTE_KINDS, - ].join(' | ')}.` - ); - } - if (kind === 'ancestors') { - return { kind: 'ancestors' }; - } - const list = obj.tasks; - if ( - !Array.isArray(list) || - list.length === 0 || - list.some((t) => typeof t !== 'string' || t.trim() === '') - ) { - throw new Error( - `DAG.loops[${loopIndex}].reexecute.tasks must be a non-empty array of task id strings.` - ); - } - const requested = list as string[]; - for (const id of requested) { - if (!taskIds.has(id)) { - throw new Error( - `DAG.loops[${loopIndex}].reexecute.tasks contains unknown task id "${id}".` - ); - } - } - // The re-execution set must be a subset of the convergence ancestor cone - // (ancestors of convergeOn ∪ convergeOn itself). Re-running a task that - // is not a transitive dependency of the convergence task would break the - // filtered topological order: the runner re-executes ranks in the - // convergence task's downward causal chain, so an unrelated task would - // either run out of order or not at all. - const cone = transitiveAncestorIds(convergeOn, tasks); - cone.add(convergeOn); - for (const id of requested) { - if (!cone.has(id)) { - throw new Error( - `DAG.loops[${loopIndex}].reexecute.tasks contains "${id}" which is not the convergeOn task and is not a transitive ancestor of "${convergeOn}".` - ); - } - } - const selected = new Set(requested); - for (const id of requested) { - if (id === convergeOn) continue; - const missingAncestors = [...transitiveAncestorIds(id, tasks)].filter( - (ancestorId) => cone.has(ancestorId) && !selected.has(ancestorId) - ); - if (missingAncestors.length > 0) { - throw new Error( - `DAG.loops[${loopIndex}].reexecute.tasks must be dependency-closed. Task "${id}" also requires its ancestor(s): ${missingAncestors.join( - ', ' - )}. Add them or remove "${id}".` - ); - } - } - // Always include the convergence task itself so the loop body can re-run - // it after upstream re-execution. De-dupe while preserving caller order. - const seen = new Set(); - const tasksOut: string[] = []; - for (const id of [...requested, convergeOn]) { - if (seen.has(id)) continue; - seen.add(id); - tasksOut.push(id); - } - return { kind: 'tasks', tasks: tasksOut }; -} - -/** - * Fills in defaults (`id`, `reexecute`) for each declared loop so the runner - * can consume a single canonical shape regardless of which fields the DAG - * author left implicit. Pure function — does not access the DAG task list. - * Defaults align with the legacy `--converge-on` behavior: re-execute the - * full ancestor cone and stop when the convergence task's `## Blockers` / - * `## High-severity findings` are both empty. - */ -export function resolveConvergenceLoops( - loops: readonly DAGConvergenceLoop[] -): ResolvedConvergenceLoop[] { - return loops.map((loop) => ({ - id: loop.id ?? `loop-${loop.convergeOn}`, - convergeOn: loop.convergeOn, - maxIterations: loop.maxIterations, - reexecute: loop.reexecute ?? { kind: 'ancestors' }, - })); -} - -function validateLoopInteractions( - loops: readonly DAGConvergenceLoop[], - tasks: RawTask[] -): void { - const reExecSets = loops.map((loop) => ({ - id: loop.id ?? `loop-${loop.convergeOn}`, - taskIds: computeLoopReexecuteIds(loop, tasks), - })); - for (let i = 0; i < reExecSets.length; i++) { - for (let j = i + 1; j < reExecSets.length; j++) { - const overlap = [...reExecSets[i].taskIds].filter((id) => - reExecSets[j].taskIds.has(id) - ); - if (overlap.length === 0) continue; - throw new Error( - `DAG.loops must have disjoint re-execution sets. "${ - reExecSets[i].id - }" and "${reExecSets[j].id}" both re-run: ${overlap.join( - ', ' - )}. Split the DAG so each loop owns a separate task cone, or collapse the work into one loop.` - ); - } - } -} - -function computeLoopReexecuteIds( - loop: DAGConvergenceLoop, - tasks: RawTask[] -): Set { - const ids = new Set(); - if (loop.reexecute?.kind === 'tasks') { - for (const id of loop.reexecute.tasks) ids.add(id); - ids.add(loop.convergeOn); - return ids; - } - for (const id of transitiveAncestorIds(loop.convergeOn, tasks)) ids.add(id); - ids.add(loop.convergeOn); - return ids; -} -function validateFraming(raw: unknown): string { - if (typeof raw !== 'string') { - throw new Error('DAG.framing must be a string when set.'); - } - return raw; -} - -function validateBudget(raw: unknown): DAGBudget { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error('DAG.budget must be a JSON object when set.'); - } - const obj = raw as Record; - const budget: DAGBudget = {}; - if (obj.maxIterations !== undefined) { - validateBudgetNumber(obj.maxIterations, 'DAG.budget.maxIterations'); - budget.maxIterations = obj.maxIterations; - } - if (obj.maxTokensTotal !== undefined) { - validateBudgetNumber(obj.maxTokensTotal, 'DAG.budget.maxTokensTotal'); - budget.maxTokensTotal = obj.maxTokensTotal; - } - return budget; -} - -function validateBudgetNumber( - raw: unknown, - label: string -): asserts raw is number { - if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) { - throw new Error(`${label} must be a non-negative integer when set.`); - } -} - -function validateTask(raw: unknown, index: number): RawTask { - if (!raw || typeof raw !== 'object') { - throw new Error(`tasks[${index}] must be an object.`); - } - const t = raw as Record; - - const id = t.id; - if (typeof id !== 'string' || id.trim() === '') { - throw new Error(`tasks[${index}].id must be a non-empty string.`); - } - - const kind = resolveTaskKind(t.kind, index); - - const depends_on = t.depends_on ?? []; - if ( - !Array.isArray(depends_on) || - depends_on.some((d) => typeof d !== 'string') - ) { - throw new Error(`tasks[${index}].depends_on must be an array of strings.`); - } - const dedupedDepends = [...new Set(depends_on as string[])]; - - if (kind === 'pause') { - if (t.complexity !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="pause" and must not set complexity (no LLM is invoked).` - ); - } - if (t.command !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="pause" and must not set command (only kind="oracle" runs a shell command).` - ); - } - if (t.expect !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="pause" and must not set expect (only kind="oracle" matches output).` - ); - } - if (t.allowNonZeroExit !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="pause" and must not set allowNonZeroExit (only kind="oracle" runs a command).` - ); - } - let subtask_prompt = ''; - if (t.subtask_prompt !== undefined) { - if (typeof t.subtask_prompt !== 'string') { - throw new Error( - `tasks[${index}].subtask_prompt must be a string when set on a pause task.` - ); - } - subtask_prompt = t.subtask_prompt; - } - return { - id, - depends_on: dedupedDepends, - complexity: NON_LLM_SYNTHETIC_COMPLEXITY, - subtask_prompt, - kind: 'pause', - }; - } - - if (kind === 'oracle') { - if (t.complexity !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="oracle" and must not set complexity (no LLM is invoked).` - ); - } - if (t.subtask_prompt !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="oracle" and must not set subtask_prompt (oracle tasks run a shell command, not an LLM prompt).` - ); - } - if (t.model !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="oracle" and must not set model (no model is invoked).` - ); - } - if (typeof t.command !== 'string' || t.command.trim() === '') { - throw new Error( - `tasks[${index}] (id="${id}") is kind="oracle" and requires a non-empty string command.` - ); - } - let expect: string = DEFAULT_ORACLE_EXPECT; - if (t.expect !== undefined) { - if (typeof t.expect !== 'string') { - throw new Error( - `tasks[${index}].expect must be a string when set on an oracle task.` - ); - } - try { - new RegExp(t.expect); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error( - `tasks[${index}].expect must be a valid regex (got ${JSON.stringify( - t.expect - )}: ${reason}).` - ); - } - expect = t.expect; - } - let allowNonZeroExit = false; - if (t.allowNonZeroExit !== undefined) { - if (typeof t.allowNonZeroExit !== 'boolean') { - throw new Error( - `tasks[${index}].allowNonZeroExit must be a boolean when set on an oracle task.` - ); - } - allowNonZeroExit = t.allowNonZeroExit; - } - return { - id, - depends_on: dedupedDepends, - complexity: NON_LLM_SYNTHETIC_COMPLEXITY, - subtask_prompt: '', - kind: 'oracle', - command: t.command, - expect, - allowNonZeroExit, - }; - } - - if (t.command !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="task" and must not set command (only kind="oracle" runs a shell command).` - ); - } - if (t.expect !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="task" and must not set expect (only kind="oracle" matches output).` - ); - } - if (t.allowNonZeroExit !== undefined) { - throw new Error( - `tasks[${index}] (id="${id}") is kind="task" and must not set allowNonZeroExit (only kind="oracle" runs a command).` - ); - } - const complexity = t.complexity; - if ( - typeof complexity !== 'string' || - !COMPLEXITY_VALUES.has(complexity as Complexity) - ) { - throw new Error( - `tasks[${index}].complexity must be one of HIGH | MED | LOW.` - ); - } - const subtask_prompt = t.subtask_prompt; - if (typeof subtask_prompt !== 'string' || subtask_prompt.trim() === '') { - throw new Error( - `tasks[${index}].subtask_prompt must be a non-empty string.` - ); - } - return { - id, - depends_on: dedupedDepends, - complexity: complexity as Complexity, - subtask_prompt, - kind: 'task', - }; -} - -function resolveTaskKind(raw: unknown, index: number): TaskKind { - if (raw === undefined) return 'task'; - if (typeof raw === 'string' && TASK_KIND_VALUES.has(raw as TaskKind)) { - return raw as TaskKind; - } - throw new Error( - `tasks[${index}].kind must be one of 'task' | 'pause' | 'oracle' when set (got ${JSON.stringify( - raw - )}).` - ); -} - -/** Throws on the first cycle found. Uses iterative DFS with a recursion stack. */ -function detectCycle(tasks: RawTask[]): void { - const adj = new Map(); - for (const t of tasks) adj.set(t.id, []); - for (const t of tasks) { - for (const dep of t.depends_on) { - adj.get(dep)!.push(t.id); - } - } - - const WHITE = 0; - const GRAY = 1; - const BLACK = 2; - const color = new Map(); - for (const t of tasks) color.set(t.id, WHITE); - - for (const start of tasks) { - if (color.get(start.id) !== WHITE) continue; - const stack: Array<{ id: string; childIdx: number; pathIdx: number }> = [ - { id: start.id, childIdx: 0, pathIdx: 0 }, - ]; - const path: string[] = []; - color.set(start.id, GRAY); - path.push(start.id); - - while (stack.length > 0) { - const top = stack[stack.length - 1]; - const children = adj.get(top.id)!; - if (top.childIdx >= children.length) { - color.set(top.id, BLACK); - path.pop(); - stack.pop(); - continue; - } - const child = children[top.childIdx++]; - const cColor = color.get(child) ?? WHITE; - if (cColor === GRAY) { - const cycleStart = path.indexOf(child); - const cycle = [...path.slice(cycleStart), child].join(' -> '); - throw new Error(`Cycle detected: ${cycle}`); - } - if (cColor === WHITE) { - color.set(child, GRAY); - path.push(child); - stack.push({ id: child, childIdx: 0, pathIdx: path.length - 1 }); - } - } - } -} - -/** - * Kahn's algorithm — return tasks grouped into ranks. Tasks within a rank - * have no inter-dependencies and can run in parallel. - */ -export function computeRanks(dag: DAG): RawTask[][] { - const remaining = new Map(); - const byId = new Map(); - for (const t of dag.tasks) { - remaining.set(t.id, t.depends_on.length); - byId.set(t.id, t); - } - const dependents = new Map(); - for (const t of dag.tasks) dependents.set(t.id, []); - for (const t of dag.tasks) { - for (const dep of t.depends_on) { - dependents.get(dep)!.push(t.id); - } - } - - const ranks: RawTask[][] = []; - let frontier = dag.tasks.filter((t) => remaining.get(t.id) === 0); - while (frontier.length > 0) { - ranks.push(frontier); - const next: RawTask[] = []; - for (const t of frontier) { - for (const child of dependents.get(t.id)!) { - const r = remaining.get(child)! - 1; - remaining.set(child, r); - if (r === 0) next.push(byId.get(child)!); - } - } - frontier = next; - } - - const placed = ranks.reduce((n, r) => n + r.length, 0); - if (placed !== dag.tasks.length) { - throw new Error('Topological sort failed — DAG contains a cycle.'); - } - return ranks; -} - -export function validateModelMap( - raw: unknown, - label = 'model map' -): ModelMapOverride { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(`${label} must be a JSON object.`); - } - const obj = raw as Record; - const models: ModelMapOverride = {}; - for (const [key, value] of Object.entries(obj)) { - if (!COMPLEXITY_VALUES.has(key as Complexity)) { - throw new Error(`${label} contains unknown complexity key: ${key}`); - } - models[key as Complexity] = normalizeModelSelection( - value as ModelSpec, - `${label}.${key}` - ); - } - return models; -} - -export function createModelSelectionResolver( - overrides: ModelMapOverride = {} -): (c: Complexity) => ModelSelection { - const models = resolveModelMap(overrides); - return (c: Complexity): ModelSelection => { - assertKnownComplexity(c); - return cloneModelSelection(models[c]); - }; -} - -export function createCatalogBackedModelResolver( - modelFor: (c: Complexity) => ModelSelection, - catalog: readonly ModelCatalogItem[] -): (c: Complexity) => ModelSelection { - const cache = new Map(); - return (c: Complexity): ModelSelection => { - const cached = cache.get(c); - if (cached) return cloneModelSelection(cached); - const resolved = resolveModelSelectionFromCatalog( - modelFor(c), - catalog, - `model for ${c}` - ); - cache.set(c, resolved); - return cloneModelSelection(resolved); - }; -} - -/** Validate a JSON model selection object. */ -export function validateModelSelection( - raw: unknown, - label = 'model' -): ModelSelection { - const obj = validateModelSelectionObject(raw, label); - const id = validateNonEmptyString(obj.id, `${label}.id`); - const params = validateModelParams(obj.params, label); - return createModelSelection(id, params); -} - -function validateModelSelectionObject( - raw: unknown, - label: string -): Record { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(`${label} must be a model object.`); - } - return raw as Record; -} - -function validateNonEmptyString(raw: unknown, label: string): string { - if (typeof raw !== 'string' || raw.trim() === '') { - throw new Error(`${label} must be a non-empty string.`); - } - return raw.trim(); -} - -function validateModelParams( - raw: unknown, - label: string -): ModelParameterValue[] { - if (raw === undefined) return []; - if (!Array.isArray(raw)) { - throw new Error(`${label}.params must be an array when set.`); - } - - const params: ModelParameterValue[] = []; - const seen = new Set(); - for (let i = 0; i < raw.length; i++) { - const param = validateModelParam(raw[i], label, i); - const paramId = param.id; - if (seen.has(paramId)) { - throw new Error(`${label}.params contains duplicate id: ${paramId}`); - } - seen.add(paramId); - params.push(param); - } - return params; -} - -function validateModelParam( - raw: unknown, - label: string, - index: number -): ModelParameterValue { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(`${label}.params[${index}] must be an object.`); - } - const param = raw as Record; - return { - id: validateNonEmptyString(param.id, `${label}.params[${index}].id`), - value: validateNonEmptyString( - param.value, - `${label}.params[${index}].value` - ), - }; -} - -export function normalizeModelSelection( - raw: ModelSpec, - label = 'model' -): ModelSelection { - if (typeof raw === 'string') { - return createModelSelection(validateNonEmptyString(raw, label)); - } - return validateModelSelection(raw, label); -} - -export function formatModelSelection(model: ModelSelection): string { - const params = model.params ?? []; - if (params.length === 0) return model.id; - return `${model.id} (${params.map((p) => `${p.id}=${p.value}`).join(', ')})`; -} - -export function resolveModelSelectionFromCatalog( - selection: ModelSelection, - catalog: readonly ModelCatalogItem[], - label = 'model' -): ModelSelection { - const catalogItem = catalog.find((model) => model.id === selection.id); - if (!catalogItem) { - const ids = catalog.map((model) => model.id).sort(); - throw new Error( - `${label} uses unknown Cursor SDK model "${ - selection.id - }". Known models:\n ${ids.join('\n ')}` - ); - } - - validateRequestedParams(selection, catalogItem, label); - - const variants = catalogItem.variants ?? []; - if (variants.length === 0) { - return cloneModelSelection(selection); - } - - const requestedParams = selection.params ?? []; - const chosenVariant = - requestedParams.length === 0 - ? defaultVariant(variants) - : chooseMatchingVariant(requestedParams, variants); - - if (!chosenVariant) { - throw new Error( - `${label} ${formatModelSelection( - selection - )} does not match any Cursor SDK preset variant. Valid variants:\n ${formatVariants( - variants - )}` - ); - } - - const params = chosenVariant.params.map((param) => ({ ...param })); - return params.length > 0 - ? { id: selection.id, params } - : { id: selection.id }; -} - -function validateRequestedParams( - selection: ModelSelection, - catalogItem: ModelCatalogItem, - label: string -): void { - const requestedParams = selection.params ?? []; - if (requestedParams.length === 0) return; - - const paramDefs = catalogItem.parameters ?? []; - if (paramDefs.length > 0) { - const definitions = new Map(paramDefs.map((param) => [param.id, param])); - for (const param of requestedParams) { - const definition = definitions.get(param.id); - if (!definition) { - const supported = [...definitions.keys()].sort(); - throw new Error( - `${label} ${selection.id} does not support param "${ - param.id - }". Supported params: ${ - supported.length > 0 ? supported.join(', ') : '(none)' - }` - ); - } - const allowed = new Set(definition.values.map((value) => value.value)); - if (!allowed.has(param.value)) { - throw new Error( - `${label} ${selection.id} param "${ - param.id - }" does not support value "${param.value}". Supported values: ${[ - ...allowed, - ].join(', ')}` - ); - } - } - return; - } - - const variants = catalogItem.variants ?? []; - if (variants.length > 0) { - const chosenVariant = chooseMatchingVariant(requestedParams, variants); - if (!chosenVariant) { - throw new Error( - `${label} ${formatModelSelection( - selection - )} does not match any Cursor SDK preset variant. Valid variants:\n ${formatVariants( - variants - )}` - ); - } - return; - } - - throw new Error( - `${label} ${selection.id} does not declare parameters or preset variants in the Cursor SDK catalog; remove explicit params from this model selection.` - ); -} - -type ModelCatalogVariant = NonNullable[number]; - -function defaultVariant( - variants: ReadonlyArray -): ModelCatalogVariant { - return variants.find((variant) => variant.isDefault) ?? variants[0]; -} - -function assertKnownComplexity(c: Complexity): void { - if (!COMPLEXITY_KEYS.includes(c)) { - throw new Error(`Unknown complexity: ${c}`); - } -} - -function resolveModelMap(overrides: ModelMapOverride = {}): ModelMap { - return { - HIGH: normalizeModelSelection(overrides.HIGH ?? DEFAULT_MODEL_MAP.HIGH), - MED: normalizeModelSelection(overrides.MED ?? DEFAULT_MODEL_MAP.MED), - LOW: normalizeModelSelection(overrides.LOW ?? DEFAULT_MODEL_MAP.LOW), - }; -} - -function chooseMatchingVariant( - requestedParams: readonly ModelParameterValue[], - variants: ReadonlyArray -): ModelCatalogVariant | undefined { - const matches = variants.filter((variant) => - paramsContainAll(variant.params, requestedParams) - ); - if (matches.length === 0) return undefined; - - const defaultVar = defaultVariant(variants); - const defaultParams = new Map( - defaultVar.params.map((param) => [param.id, param.value]) - ); - const requestedIds = new Set(requestedParams.map((param) => param.id)); - let best = matches[0]; - let bestScore = scoreVariant(best.params, defaultParams, requestedIds); - // Ties break to the catalog-declared default variant; otherwise first match wins. - for (const match of matches.slice(1)) { - const score = scoreVariant(match.params, defaultParams, requestedIds); - if (score > bestScore) { - best = match; - bestScore = score; - } else if ( - score === bestScore && - match === defaultVar && - best !== defaultVar - ) { - best = match; - } - } - return best; -} - -function paramsContainAll( - candidateParams: readonly ModelParameterValue[], - requestedParams: readonly ModelParameterValue[] -): boolean { - const candidate = new Map( - candidateParams.map((param) => [param.id, param.value]) - ); - return requestedParams.every( - (param) => candidate.get(param.id) === param.value - ); -} - -function scoreVariant( - params: readonly ModelParameterValue[], - defaultParams: ReadonlyMap, - requestedIds: ReadonlySet -): number { - let score = 0; - for (const param of params) { - if (requestedIds.has(param.id)) continue; - if (defaultParams.get(param.id) === param.value) score++; - } - return score; -} - -function formatVariants(variants: ReadonlyArray): string { - return variants - .map((variant) => { - const params = variant.params - .map((param) => `${param.id}=${param.value}`) - .join(', '); - const suffix = variant.isDefault ? ' [default]' : ''; - return `${variant.displayName}${suffix}: ${params || '(no params)'}`; - }) - .join('\n '); -} - -function createModelSelection( - id: string, - params: readonly ModelParameterValue[] = [] -): ModelSelection { - return params.length > 0 - ? { id, params: params.map((param) => ({ ...param })) } - : { id }; -} - -function cloneModelSelection(selection: ModelSelection): ModelSelection { - return createModelSelection(selection.id, selection.params ?? []); -} diff --git a/packages/proof/src/dry_check_cmds.ts b/packages/proof/src/dry_check_cmds.ts deleted file mode 100644 index 49fdedbc..00000000 --- a/packages/proof/src/dry_check_cmds.ts +++ /dev/null @@ -1,512 +0,0 @@ -/** - * --dry-check-cmds mode: walks every `subtask_prompt`, regex-extracts shell - * commands, validates them against the workspace, and prints a structured - * report. No `CURSOR_API_KEY` required. - * - * Validation focuses on patterns that have caused real DAG-runtime failures: - * - * - `pnpm --filter ...` where `` is not a known workspace - * package → DIRTY (the filter resolves to nothing and the command no-ops - * or errors). - * - `pnpm exec flatbread ` without `--filter ` → DIRTY - * (`loadConfig` does not search up; `flatbread.config.js` only exists in - * example dirs, so a top-level invocation never finds it). This is the - * historical regression the runner is asked to detect. - * - `pnpm codegen` (top-level) without an explicit `--filter` → DIRTY - * (`codegen` is a `--watch` script in `examples/nextjs/package.json`; - * it would hang the DAG node). - * - `pnpm --filter codegen` where `pkg` defines `codegen` as a - * `--watch` script → DIRTY (same hang risk). - * - * Backticked references that appear in a *negation context* ("Do NOT use", - * "instead of", "would hang", "avoid") are tagged `INFO` and excluded from - * the dirty count — they are documentation of anti-patterns the prompt - * already steers the agent away from. - */ - -import { readdir, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; - -import type { DAG, RawTask } from './dag.js'; - -export type Verdict = 'OK' | 'DIRTY' | 'WARN' | 'INFO'; - -export interface CommandFinding { - taskId: string; - /** Raw command extracted from the prompt (backtick contents, trimmed). */ - command: string; - /** First non-flag token, e.g. `pnpm`, `flatbread`. */ - verb: string; - verdict: Verdict; - reason: string; - /** Was the command preceded by a "do NOT" / "instead of" cue in the prompt? */ - negated: boolean; -} - -export interface DryCheckReport { - title: string; - totalTasks: number; - totalCommands: number; - ok: number; - warn: number; - dirty: number; - info: number; - findings: CommandFinding[]; - /** True when at least one finding is `DIRTY`. Drives exit code. */ - isDirty: boolean; -} - -interface WorkspaceFacts { - /** All workspace package `name` fields (`@flatbread/core`, `nextjs`, …). */ - packageNames: Set; - /** Map of workspace package name → package.json `scripts` table. */ - scriptsByPackage: Map>; - /** Directory names under `packages/` and `examples/` (for `--filter` shorthand). */ - packageDirs: Set; - /** Map of dir basename → package name. Used to interpret `--filter `. */ - packageNameByDir: Map; - /** Absolute path to workspace root (the `--cwd` we resolve against). */ - cwd: string; -} - -/** Verbs we consider "shell commands" worth validating. */ -const SHELL_VERBS = new Set([ - 'pnpm', - 'npm', - 'yarn', - 'npx', - 'node', - 'tsx', - 'flatbread', - 'git', - 'cd', - 'mkdir', - 'mv', - 'cp', - 'rm', - 'cat', - 'echo', - 'bash', - 'sh', - 'cursor-agent', - 'agent-browser', - 'set', - 'source', - 'export', - 'kill', - 'open', - 'curl', - 'wget', - 'ls', -]); - -/** - * Cues in the preceding 80 chars that flip a finding from DIRTY/WARN to INFO. - * - * Three families: - * - * - Negation: prompt explicitly tells the agent NOT to run the command - * (`Do NOT use \`pnpm codegen\`…`). - * - Citation: command is quoted as a reference to existing config / docs - * rather than an instruction (`binds port 5057 via \`flatbread start …\` - * per \`examples/nextjs/package.json:8\``). - * - Backgrounding: command is explicitly intended to be spawned in the - * background and torn down later (`Start the example dev server in the - * background: \`pnpm --filter nextjs dev\``). - */ -const NEUTRALIZING_CUES = [ - // Negation - 'do not use', - "don't use", - 'do not run', - "don't run", - 'instead of', - 'would hang', - 'avoid', - 'never use', - "won't use", - 'rather than', - 'would block', - // Citation / documentation reference - ' via ', - ' per ', - ' from ', - ' see ', - 'defined as', - 'defined in', - 'package.json:', - 'binds port', - // Backgrounding (legitimate long-running spawn-then-teardown pattern) - 'in the background', - 'background:', - 'background.', - ' background ', - ' nohup ', -]; - -/** Single-backtick-delimited tokens. We deliberately ignore triple-backtick fences (none in current prompts) and HTML codeblocks. */ -const BACKTICK_RE = /`([^`\n]+)`/g; - -export async function loadWorkspaceFacts(cwd: string): Promise { - const workspaceRoot = resolveWorkspaceRoot(cwd); - const facts: WorkspaceFacts = { - packageNames: new Set(), - scriptsByPackage: new Map(), - packageDirs: new Set(), - packageNameByDir: new Map(), - cwd: workspaceRoot, - }; - - for (const parent of ['packages', 'examples']) { - const parentAbs = join(workspaceRoot, parent); - if (!existsSync(parentAbs)) continue; - const entries = await readdir(parentAbs, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const pkgJsonPath = join(parentAbs, entry.name, 'package.json'); - if (!existsSync(pkgJsonPath)) continue; - try { - const raw = JSON.parse(await readFile(pkgJsonPath, 'utf8')) as { - name?: unknown; - scripts?: unknown; - }; - if (typeof raw.name !== 'string' || raw.name.trim() === '') continue; - const name = raw.name.trim(); - facts.packageNames.add(name); - facts.packageDirs.add(entry.name); - facts.packageNameByDir.set(entry.name, name); - const scripts: Record = {}; - if (raw.scripts && typeof raw.scripts === 'object') { - for (const [k, v] of Object.entries( - raw.scripts as Record - )) { - if (typeof v === 'string') scripts[k] = v; - } - } - facts.scriptsByPackage.set(name, scripts); - } catch { - // ignore malformed package.json — not our job to lint here - } - } - } - - return facts; -} - -function resolveWorkspaceRoot(cwd: string): string { - let current = resolve(cwd); - while (true) { - if (existsSync(join(current, 'pnpm-workspace.yaml'))) return current; - const parent = dirname(current); - if (parent === current) return resolve(cwd); - current = parent; - } -} - -export function runDryCheck(dag: DAG, facts: WorkspaceFacts): DryCheckReport { - const findings: CommandFinding[] = []; - for (const task of dag.tasks) { - if (!task.subtask_prompt) continue; - for (const extracted of extractCommands(task.subtask_prompt)) { - findings.push(validateCommand(task, extracted, facts)); - } - } - - let ok = 0; - let warn = 0; - let dirty = 0; - let info = 0; - for (const f of findings) { - if (f.verdict === 'OK') ok++; - else if (f.verdict === 'WARN') warn++; - else if (f.verdict === 'DIRTY') dirty++; - else info++; - } - - return { - title: dag.title, - totalTasks: dag.tasks.length, - totalCommands: findings.length, - ok, - warn, - dirty, - info, - findings, - isDirty: dirty > 0, - }; -} - -interface ExtractedCommand { - command: string; - verb: string; - /** Up to 80 chars before the opening backtick, lowercased — used to detect negation. */ - precedingContext: string; -} - -function extractCommands(prompt: string): ExtractedCommand[] { - const out: ExtractedCommand[] = []; - BACKTICK_RE.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = BACKTICK_RE.exec(prompt))) { - const inner = m[1].trim(); - if (inner === '') continue; - const verb = inner.split(/\s+/, 1)[0]; - if (!SHELL_VERBS.has(verb)) continue; - const ctxStart = Math.max(0, m.index - 80); - const precedingContext = prompt.slice(ctxStart, m.index).toLowerCase(); - out.push({ command: inner, verb, precedingContext }); - } - return out; -} - -function isNeutralized(precedingContext: string): boolean { - return NEUTRALIZING_CUES.some((cue) => precedingContext.includes(cue)); -} - -function validateCommand( - task: RawTask, - extracted: ExtractedCommand, - facts: WorkspaceFacts -): CommandFinding { - const negated = isNeutralized(extracted.precedingContext); - const base: Omit = { - taskId: task.id, - command: extracted.command, - verb: extracted.verb, - negated, - }; - - let raw: CommandFinding; - if (extracted.verb === 'pnpm') { - raw = validatePnpmCommand(task, extracted.command, facts, base); - } else if (extracted.verb === 'flatbread') { - raw = { - ...base, - verdict: 'WARN', - reason: - 'Bare `flatbread …` invocation — unless run with `pnpm --filter exec` from a dir containing `flatbread.config.js`, `loadConfig` will not find a config.', - }; - } else { - raw = { - ...base, - verdict: 'OK', - reason: - 'No workspace-specific check; verb not in pnpm/flatbread risk family.', - }; - } - - // Downgrade DIRTY / WARN to INFO when surrounding prompt text already - // contains a negation / citation / backgrounding cue that handles the risk. - // Genuinely OK findings are passed through unchanged so they stay visible. - if (negated && (raw.verdict === 'DIRTY' || raw.verdict === 'WARN')) { - return { - ...raw, - verdict: 'INFO', - reason: `${raw.reason} — neutralized by surrounding prompt context (negation, citation, or background-spawn cue).`, - }; - } - return raw; -} - -function validatePnpmCommand( - task: RawTask, - command: string, - facts: WorkspaceFacts, - base: Omit -): CommandFinding { - const tokens = command.split(/\s+/); - // tokens[0] === 'pnpm' - let i = 1; - - // Short-circuit: `pnpm --silent`, `pnpm install …` etc. — strip leading flags - // before the first sub-command but preserve `--filter ` and `--dir `. - let filterPkg: string | null = null; - let filterDir: string | null = null; - let dirArg: string | null = null; - while (i < tokens.length && tokens[i].startsWith('--')) { - const flag = tokens[i]; - if (flag === '--filter' || flag === '-F') { - const arg = tokens[i + 1]; - if (arg) { - if (facts.packageDirs.has(arg)) { - filterDir = arg; - filterPkg = facts.packageNameByDir.get(arg) ?? null; - } else { - filterPkg = arg; - } - i += 2; - continue; - } - } - if (flag === '--dir' || flag === '-C') { - dirArg = tokens[i + 1] ?? null; - i += 2; - continue; - } - if (flag === '--silent' || flag === '--prefer-offline') { - i += 1; - continue; - } - // Unknown leading flag — treat the rest as opaque, still capture sub-cmd. - i += 1; - } - - const sub = tokens[i]; - const subArgs = tokens.slice(i + 1); - - // Validate filter target if provided. - if (filterPkg !== null) { - if ( - !facts.packageNames.has(filterPkg) && - !facts.packageDirs.has(filterDir ?? filterPkg) - ) { - return { - ...base, - verdict: 'DIRTY', - reason: `pnpm --filter target "${filterPkg}" is not a workspace package or dir under packages/ or examples/.`, - }; - } - } - - if (sub === 'exec' && subArgs[0] === 'flatbread') { - const flatbreadSub = subArgs[1] ?? ''; - if (filterPkg === null && dirArg === null) { - return { - ...base, - verdict: 'DIRTY', - reason: - `\`pnpm exec flatbread ${flatbreadSub}\` runs from the workspace root, where no flatbread.config.js exists; ` + - "flatbread's loadConfig does not search up. Use `pnpm --filter exec flatbread …` from an example dir instead.", - }; - } - return { - ...base, - verdict: 'OK', - reason: `pnpm --filter ${ - filterPkg ?? dirArg - } exec flatbread ${flatbreadSub}: filter targets a workspace package containing a flatbread.config.js.`, - }; - } - - // `pnpm codegen` / `pnpm dev` / `pnpm