diff --git a/packages/reactor-cli/examples/docs-synthesis/.gitignore b/packages/reactor-cli/examples/docs-synthesis/.gitignore new file mode 100644 index 00000000..279673b8 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/.gitignore @@ -0,0 +1,3 @@ +/.reactor/ +/connectors.cjs +.serve.log diff --git a/packages/reactor-cli/examples/docs-synthesis/PIPELINE-DESIGN.md b/packages/reactor-cli/examples/docs-synthesis/PIPELINE-DESIGN.md new file mode 100644 index 00000000..fae3b73c --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/PIPELINE-DESIGN.md @@ -0,0 +1,102 @@ +# docs-synthesis — Cost & Context Discipline, applied + +_Why this example is shaped the way it is. It is the worked example of the +**Cost and Context Discipline** authoring guidance +(`skills/open-prose/guidance/authoring.md`): a continuous, fan-out, high-event- +volume Reactor pipeline — the PR→docs shape that guidance was written for._ + +--- + +## The job + +Watch a code repo's merged PRs and keep a separate docs site current. The naive +version is one render per PR: "here is a merged PR, read the repo and the docs, and +update whatever is now stale." That render re-derives every downstream truth on +each event, wanders two repositories to do it, and runs on every PR whether or not +it touches anything documented. Cost scales with the *clock*, not with surprise — +and most PRs (tests, CI, refactors) document nothing. + +This example is the disciplined version. Every rule below maps to a bullet in the +authoring guidance. + +--- + +## Tier the work; let a cheap gatekeeper filter surprise + +`change-signal` is a narrow **classifier**, not a summarizer. It reads only a +merged PR's changed-file-path list (plus title and body) and maps paths to doc +sections, emitting at most a one-line typed signal per affected section — split into +five independently-subscribable facets: + +``` + repo-changes (gateway) ~free: fold the staged PR delta; no model, no git + | #changes + v + change-signal (classifier) CHEAP: inline paths -> per-section signals + | #cli-signal #sdk-signal #reactor-signal #devtools-signal #openprose-signal + v \ \ \ \ \ + cli-docs sdk-docs reactor-docs devtools-docs openprose-docs each: pending edits + \________ \___________ \______________ \_______________/ + | + docs-pr RARE: coalesced; opens one PR; reads only the pages it edits +``` + +A PR touching only `packages/reactor-cli/**` moves `#cli-signal` only. `cli-docs` +renders; the other four sections **memo-skip at zero cost** because their signal +facet never moved. A test-only PR moves nothing downstream at all. This is the +`guard` pattern made structural: expensive synthesis runs only on real change. + +## Bound each narrow render to its inline input + +The classifier and the five accumulators read ONLY the staged PR evidence and their +own prior world-model. Their `### Invariants` forbid running git, opening the diff, +or reading either repository. They emit **pointers** — "section `cli`, page +`telemetry`, stale re: PR #101" — not diffs. `max_turns` caps turns, not context +size; it is the *unscoped task* that explodes cost, so the task is scoped ("classify +into these five shapes," "append one pending edit") and the inputs are bounded. Each +accumulator even carries its section's fixed page list in-contract, so it names a +real target page without reading the docs repo. + +## Concentrate the expensive, effectful work in the rare terminal node + +`docs-pr` is the one render whose *job* is to act — and the guidance is explicit +that the discipline does not override the principle that a render which must explore +(or here, act) should do so. So `docs-pr` MAY read the specific `.mdx` pages its +pending edits name and the cited PRs' actual changes, draft the edits, and open one +PR. It is still bounded (only the named pages and cited PRs, never a repo crawl), +and it fires only when a pending-edit facet actually moved — so its cost is paid +only on genuine doc work, not on every PR. This is "keep the high-volume renders +small enough for a cheap model; reserve the heavier work for the rare node." + +## Validate the cost-shape; do not assume it + +The committed `replay/` plus the eval harness prove the property rather than +asserting it. The deterministic tier (offline, zero spend) checks selective wake: a +CLI-only PR renders `change-signal` + `cli-docs` and **skips** +sdk/reactor/devtools/openprose; a test-only PR renders nothing downstream. The +opt-in judge tier grades the produced pending edits and proposed PR against each +node's `### Maintains` postconditions (every edit cites a PR and names a real page; +the branch name is a deterministic function of the cited PR numbers). + +--- + +## The model-tiering reality (the same honest gap) + +`### Runtime: model` is valid OpenProse, so "a cheap model for the classifier, a +stronger model for the actuator that drafts prose" is *authorable*. But the shipped +CLI wires a single global `render_model` for every node and does not read per-node +`### Runtime` model — tracked as `RB-NodeModel`. So today: one cheap global model +(adequate for the upstream transforms), the actuator runs on it too, and a human +reviews the opened PR. When `RB-NodeModel` lands, the actuator is the obvious node +to point at a stronger model. + +--- + +## What this example is NOT + +It is not a generic "summarize the repo" agent. It does not re-derive the docs from +scratch on each PR, it does not scan two repositories per event, and it does not run +expensive work on PRs that document nothing. The whole point is that cost tracks +surprise: a quiet stream of test/CI/refactor PRs costs ~nothing past the cheap +classify, and a PR that genuinely changes a documented surface produces exactly one +reviewed docs PR. diff --git a/packages/reactor-cli/examples/docs-synthesis/README.md b/packages/reactor-cli/examples/docs-synthesis/README.md new file mode 100644 index 00000000..2b650f69 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/README.md @@ -0,0 +1,142 @@ +# docs-synthesis example + +Merged PRs in the prose monorepo piped into a docs-update pull request. A cheap +classifier turns each merged PR into small per-section *signals*, five first-class +section truths each subscribe to the one signal they care about and accumulate +pending edits, and a terminal actuator composes them into one PR against +`openprose/docs`. See `PIPELINE-DESIGN.md` for the why. + +``` +repo-changes (gateway) ~free: fold merged-PR deltas (paths + diffstat, no diff) + | + change-signal (cheap classifier: reads ONLY the inline changed-path list) + | emits per-section facets: + ├─ #cli-signal ───────► cli-docs + ├─ #sdk-signal ───────► sdk-docs + ├─ #reactor-signal ───► reactor-docs + ├─ #devtools-signal ──► devtools-docs + └─ #openprose-signal ─► openprose-docs + | each: pending edits (page + why + cited PR) + docs-pr (one coalesced PR against openprose/docs) +``` + +The point is cost control by design: each section subscribes to its **own** signal +facet, so a PR that only touched `packages/reactor-cli/**` moves `#cli-signal` +only — cli-docs renders, the other four sections **memo-skip at zero cost**. A PR +that touched no doc-facing source (tests, CI, an internal refactor) emits no signal +at all and costs nothing past the cheap classify. The actuator wakes once per burst +and opens a single PR. + +The five public-docs sections each track one source area of the watched repo: + +| signal facet | docs section (`content/docs/`) | source it tracks | +|---|---|---| +| `#cli-signal` | `cli/` | `packages/reactor-cli/**` | +| `#sdk-signal` | `sdk/` | `packages/reactor/**` public API surface | +| `#reactor-signal` | `reactor/` | `packages/reactor/**` harness behavior + concepts | +| `#devtools-signal` | `reactor-devtools/` | `packages/reactor-devtools/**` | +| `#openprose-signal` | `openprose/` | `skills/open-prose/**`, `references/*.md` | + +Cost levers (all in the contracts): granular per-section facets; stable materiality +(volatile fields like scan time dropped, so a re-seen PR never propagates); `### +Continuity` declares the wake source semantically (no hand-coded cron); and each +upstream render's `### Invariants` lock it to its inline input (never run git, never +read the repo) so it is a small bounded transform, not an open-ended agent loop. The +one render whose job IS to act — `docs-pr` — is allowed to read the specific pages +it edits and the cited PRs, still bounded, never a repo crawl. One cheap global +model is adequate for the upstream transforms; true per-node model tiering (a +stronger model for the actuator) is tracked as `RB-NodeModel`. + +## Inspect (keyless, read-only) + +These read what is already on disk and need no model key: `doctor`, +`compile --check`, `status`, `topology`, `receipts list|cost`, `inspect`, `logs`. + +Actually running the reactor (the compile sessions and the renders) needs a model +key. `REACTOR_OFFLINE=1` forces the provider closed; it is **not** a keyless run at +the CLI — `reactor run --offline` just writes a `failed` receipt, because the +hermetic fake render is a programmatic SDK test seam, not a CLI mode. So compile and +run/serve with a real key; the read-only commands above stay keyless. + +## Run it (needs OPENROUTER_API_KEY) + +Feed the gateway one of two ways: the built-in `static` connector (fixture PR +deltas, no network — a deterministic demo) or `connectors.cjs` (a real scan of +`git log`). `serve` polls the connector and stages arrivals; a one-shot `run` only +drains what is already staged, so use `serve` (or `trigger`) to bring the PRs in. + +```sh +export OPENROUTER_API_KEY=sk-or-... +CLI="node /Users/sl/code/openprose/platform/external/prose/packages/reactor-cli/dist/cli.js" + +$CLI compile --project . # compile the topology + canonicalizers to the IR cache +$CLI topology --project . # repo-changes -> change-signal -> 5 truths -> docs-pr +$CLI serve --project . # polls the static connector, stages the fixtures, + # classifies + renders each touched section; ctrl-c once quiescent + +# inspect what happened: +$CLI status --project . # dispositions + cost +$CLI receipts list --project . # which sections rendered vs memo-skipped +$CLI receipts cost --project . # cost rolled up by surprise cause +``` + +With the four shipped fixtures you should see: PR #101 (CLI-only) renders +`change-signal` + `cli-docs`; PR #102 (CLI+SDK) renders `cli-docs` + `sdk-docs`; PR +#103 (openprose) renders `openprose-docs`; PR #104 (test-only) renders the classifier +and then nothing — every section memo-skips. `docs-pr` coalesces the burst into one +proposed PR. + +### Scan your real merges + +The real scanner ships as `connectors.cjs.example` (opt-in, so the static fixtures +are the default deterministic demo). To enable it: + +```sh +cp connectors.cjs.example connectors.cjs # keyed `github`, matching source_id +export DOCS_SYNTH_REPO=/path/to/the/prose/repo # defaults to this repo's git toplevel +``` + +It runs `git log --first-parent` on the watched repo since a persisted cursor, +extracts the PR number from each merge subject/body, and emits one arrival per +merged PR with the changed-file-path list and diffstat — never the full diff. A +present `connectors.cjs` takes precedence over the static connector. Point `serve` +at it the same way; each merged PR wakes only the sections it touches. + +### The opened PR (the actuator) + +`docs-pr` is the one node with an external effect. Live, it drafts the `.mdx` +changes for the pages its pending edits name, writes them to a branch on a local +clone of `openprose/docs` (the path in `DOCS_SYNTH_DOCS_REPO`), and opens one PR +(via `git` + `gh`) listing the source PRs it addresses. The branch name is derived +from the cited PR numbers, so a re-run +updates the same branch instead of opening duplicates. It never pushes to the docs +default branch and never touches the watched code repo. Under `REACTOR_OFFLINE` — +or with `DOCS_SYNTH_DRY_RUN=1` — it runs dry: it writes the proposed unified diff +into its world-model instead of touching git/`gh`/network. The committed `replay/` +is produced by a keyed run with `DOCS_SYNTH_DRY_RUN=1`, so the renders are real but +no live PR is opened. + +## Validate without a live run: the eval harness + +The repeatable, **keyless** validation is the reactor eval harness +(`tools/eval-harness/`), not an ad-hoc live run. It turns a committed example run (a +`replay/` state-dir: receipts + world-models + topology) into a normalized +trajectory, applies a deterministic checker (no model — proves the selective-wake / +cost-scales-with-surprise property: a CLI-only PR renders cli-docs and **skips** +sdk/reactor/devtools/openprose; a test-only PR renders nothing downstream), and — +only when an OpenRouter key is resolvable and `REACTOR_OFFLINE` is unset — runs a +judge panel that grades the produced pending edits and proposed PR against this +example's `### Maintains` postconditions (every pending edit cites a PR and names a +real page; the branch name is a deterministic function of the cited PRs). + +```sh +# deterministic, offline, zero spend: +REACTOR_OFFLINE=1 node ../../../../tools/eval-harness/cli.mjs \ + --example docs-synthesis=./replay --scenarios cold_start,no_change_replay + +# add the LLM judge panel (needs key budget; off by default): +node ../../../../tools/eval-harness/cli.mjs --example docs-synthesis=./replay +``` + +The `replay/` is produced once from a `serve`/`run` of this project (needs a key); +thereafter the harness replays + judges it with no re-run. diff --git a/packages/reactor-cli/examples/docs-synthesis/change-signal.prose.md b/packages/reactor-cli/examples/docs-synthesis/change-signal.prose.md new file mode 100644 index 00000000..e411d008 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/change-signal.prose.md @@ -0,0 +1,95 @@ +--- +name: change-signal +kind: responsibility +--- + +### Goal + +For each changed PR, classify its evidence into small, typed, per-section +doc-impact signals so each downstream documentation section wakes only when its +own source was touched. This is the cheap gatekeeper of the pipeline: it does not +read the diff, draft prose, or open the repo — it maps the changed file paths (and +the title and body) to at most a one-line signal per affected doc section, or +nothing. + +Read the `changes` set from your upstream (repo-changes) by reference. For each PR +record, split its `files` field on `;` to get the changed file paths, and route by +those paths (the title and body are only tie-breakers). Your input is these +upstream records — never run `git`, open a diff, or scan the filesystem. + +The public docs site has five sections, each tracking one source area of the +watched repo. Route by changed path: + +- `packages/reactor-cli/**` → the `cli` section. +- `packages/reactor/**` that changes the public API surface (the package exports, + the front door, public type signatures) → the `sdk` section. +- `packages/reactor/**` that changes harness behavior or concepts (compile, the + world-model, fingerprints, the reconciler, receipts, continuity, ingestion) → + the `reactor` section. +- `packages/reactor-devtools/**` → the `reactor-devtools` section. +- `skills/open-prose/**` or `references/*.md` → the `openprose` section. + +A single PR may touch several sections (emit a signal for each) or none (emit +nothing). Changes confined to tests, fixtures, CI, build config, or internal +refactors with no doc-facing surface emit no signal at all. + +### Requires + +- the changes facet of repo-changes (each PR's change receipt: number, title, + body, the delimited changed-file-path list, and the diffstat) + +### Maintains + +A set of per-PR doc-impact signals, split into five independently-subscribable +facets so a PR that touches only one source area moves only that one facet. A +facet is absent for a PR when that PR touches no source for that section, so an +unrelated PR never moves an unrelated section. Material per signal: its PR number +and a single short line naming the doc-impact (what changed and which section is +now at risk of being stale). The fetch time and the diffstat byte counts are +immaterial and are excluded from the fingerprint. + +Postconditions: every emitted signal carries a PR number; a signal is emitted only +when the PR's changed paths actually touch that section's source; a PR whose +changes are purely test/CI/build emits no signals at all. + +#### cli-signal +Per PR, a one-line doc-impact for the `cli` section, when the PR touched +`packages/reactor-cli/**`. Absent otherwise. Subscribed to by cli-docs. + +#### sdk-signal +Per PR, a one-line doc-impact for the `sdk` section, when the PR changed the +public API surface of `packages/reactor/**` (exports, front door, public +signatures). Absent otherwise. Subscribed to by sdk-docs. + +#### reactor-signal +Per PR, a one-line doc-impact for the `reactor` section, when the PR changed +harness behavior or concepts in `packages/reactor/**` (compile, world-model, +fingerprints, reconciler, receipts, continuity, ingestion). Absent otherwise. +Subscribed to by reactor-docs. + +#### devtools-signal +Per PR, a one-line doc-impact for the `reactor-devtools` section, when the PR +touched `packages/reactor-devtools/**`. Absent otherwise. Subscribed to by +devtools-docs. + +#### openprose-signal +Per PR, a one-line doc-impact for the `openprose` section, when the PR touched +`skills/open-prose/**` or `references/*.md`. Absent otherwise. Subscribed to by +openprose-docs. + +### Continuity + +- input-driven: re-classify a PR when its fingerprint moves in the gateway set. A + PR whose fingerprint did not move is skipped at zero cost. + +### Invariants + +- This render is a bounded transform over the change receipt already carried in + the wake evidence. Read ONLY that inline evidence (the changed-file-path list, + the title, the body, the diffstat) and this node's prior world-model. Never run + git, open the diff, read the repository or node_modules, scan the filesystem, or + run shell commands. Complete in a few steps. +- Classify into the five fixed section shapes only; emit at most one short line per + facet per PR, and emit nothing for a section the PR's paths do not touch. Do not + draft documentation, do not summarize the diff. +- The only writable surface is this node's published world-model. diff --git a/packages/reactor-cli/examples/docs-synthesis/cli-docs.prose.md b/packages/reactor-cli/examples/docs-synthesis/cli-docs.prose.md new file mode 100644 index 00000000..b3e65cd6 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/cli-docs.prose.md @@ -0,0 +1,52 @@ +--- +name: cli-docs +kind: responsibility +--- + +### Goal + +Maintain the set of pending documentation edits for the `cli` section of the +public docs site (`content/docs/cli/`), each drawn from a merged PR that touched +the CLI source and each traceable to that PR. This node produces pointers — which +page is now at risk and why — not the edits themselves; drafting the actual `.mdx` +change is the actuator's job. + +The `cli` section has these pages (target a pending edit at one of them): overview, +quickstart, configuration, compile-run-serve, connectors-and-sandbox, +observability, telemetry, command-reference. + +### Requires + +- the cli-signal facet of change-signal (the only wake source: a cli doc-impact + signal moved) + +### Maintains + +The pending-edit set for the `cli` section. Material: the edit set and, per edit, +its stable id, the target page slug (one of the section's pages), a one-line +description of what is now stale and how to update it, and the PR number it was +derived from; plus the open count. The recorded time is immaterial. The set moves +only when an edit's target, description, or status changes, or an edit is added or +cleared; a PR that introduces no new cli doc-impact leaves the set unchanged. + +Postcondition: every pending edit cites the PR number it was derived from and names +a page that exists in the `cli` section. + +#### cli-pending +The pending edits for the `cli` section, each with its target page, description, +and cited PR. Subscribed to by docs-pr. + +### Continuity + +- input-driven: wake only when the cli-signal facet moves. A PR that touched no CLI + source never moves this input, so this section stays silent for it (memo-skip at + zero cost). + +### Invariants + +- This render is a bounded transform over the cli-signal it was woken with and this + node's prior pending-edit set. Add, update, or clear the one affected edit; pick + the target page from the section's known page list above. Never run git, read the + docs repository, scan the filesystem, or run shell commands. Complete in a few + steps. +- The only writable surface is this node's published world-model. diff --git a/packages/reactor-cli/examples/docs-synthesis/connectors.cjs.example b/packages/reactor-cli/examples/docs-synthesis/connectors.cjs.example new file mode 100644 index 00000000..9a0710da --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/connectors.cjs.example @@ -0,0 +1,144 @@ +// connectors.cjs — REAL merged-PR scanner over `git log` (opt-in live path). +// +// Keyed `github` to match `source_id: github` in reactor.yml. When this file is +// present alongside reactor.yml, the plugin connector for source_id `github` wins +// over the built-in `static` connector at run/serve time (except under the offline +// gate, which injects a fake fetch and ignores the plugin). +// +// fetch(req): run `git log` on the watched repo for first-parent merges to the +// default branch since a persisted cursor (last-seen commit sha), and return ONE +// arrival per merged PR with the inline evidence change-signal needs: number, +// title, body, the semicolon-delimited changed-file-path list, and a diffstat. +// It never returns the full diff. +// extract(payload): the arrivals are already { id, item }; pass them through. +// +// Plain Node, no dependencies. Point it at the repo whose merges drive the public +// docs: by default the prose monorepo this example lives in. Override with +// DOCS_SYNTH_REPO=/path/to/repo. + +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const CURSOR_PATH = path.join(__dirname, ".reactor", "git-scan-cursor.json"); +const BODY_MAX = 600; +const MAX_PRS_PER_POLL = 25; // cap a cold scan so one poll stays bounded + +function repoRoot() { + if (process.env.DOCS_SYNTH_REPO) return process.env.DOCS_SYNTH_REPO; + // default: the git toplevel containing this example + try { + return git(["rev-parse", "--show-toplevel"], __dirname).trim(); + } catch { + return __dirname; + } +} + +function git(args, cwd) { + return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); +} + +function loadCursor() { + try { + return JSON.parse(fs.readFileSync(CURSOR_PATH, "utf8")); + } catch { + return {}; + } +} + +function saveCursor(map) { + try { + fs.mkdirSync(path.dirname(CURSOR_PATH), { recursive: true }); + fs.writeFileSync(CURSOR_PATH, JSON.stringify(map, null, 2)); + } catch { + // best-effort cursor; a failed write just means a re-scan next poll + } +} + +// Pull a PR number out of a merge commit subject/body: +// "Merge pull request #123 from ..." or "Squash title (#123)" +function prNumber(subject, body) { + const m = (subject + "\n" + body).match(/(?:pull request #|\(#)(\d+)\)?/); + return m ? Number(m[1]) : null; +} + +function scan() { + const root = repoRoot(); + const prev = loadCursor(); + const sinceSha = prev[root]; + + // First-parent log: each entry is a merge (or squash) onto the default branch. + // Record separator \x1e, field separator \x1f. + const range = sinceSha ? `${sinceSha}..HEAD` : "-30"; // cold start: last 30 + const fmt = "%H%x1f%s%x1f%b%x1e"; + let out = ""; + try { + const args = ["log", "--first-parent", `--pretty=format:${fmt}`]; + if (sinceSha) args.push(range); + else args.push("-n", "30"); + out = git(args, root); + } catch { + return { arrivals: [], next: prev }; // not a git repo / git unavailable + } + + const records = out.split("\x1e").map((r) => r.trim()).filter(Boolean); + const arrivals = []; + let newestSha = sinceSha; + + for (const rec of records.slice(0, MAX_PRS_PER_POLL)) { + const [sha, subject = "", body = ""] = rec.split("\x1f"); + if (!newestSha) newestSha = sha; // records are newest-first + const number = prNumber(subject, body); + if (number === null) continue; // not a PR merge — skip + + let files = ""; + let diffstat = ""; + try { + files = git(["show", "--name-only", "--pretty=format:", sha], root) + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .join(";"); + const stat = git(["show", "--stat", "--pretty=format:", sha], root) + .trim() + .split("\n") + .filter(Boolean); + diffstat = stat.length ? stat[stat.length - 1].trim() : ""; + } catch { + // a malformed/empty commit — emit identity only + } + + arrivals.push({ + id: `pr-${number}`, + item: { + number, + title: subject, + body: body.slice(0, BODY_MAX), + files, + diffstat, + merge_sha: sha.slice(0, 7), + fingerprint: `pr-${number}:${sha.slice(0, 7)}`, + }, + }); + } + + const next = { ...prev }; + if (newestSha) next[root] = newestSha; + return { arrivals, next }; +} + +module.exports = { + connectors: { + github: { + // fetch returns the raw merged-PR arrivals; the cursor is persisted here so + // a re-poll dedups even before the host cursor sees it. + fetch: async () => { + const { arrivals, next } = scan(); + saveCursor(next); + return arrivals; // already in { id, item } shape + }, + // arrivals are already { id, item }; pass them through unchanged. + extract: (payload) => (Array.isArray(payload) ? payload : []), + }, + }, +}; diff --git a/packages/reactor-cli/examples/docs-synthesis/devtools-docs.prose.md b/packages/reactor-cli/examples/docs-synthesis/devtools-docs.prose.md new file mode 100644 index 00000000..4187cb1a --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/devtools-docs.prose.md @@ -0,0 +1,53 @@ +--- +name: devtools-docs +kind: responsibility +--- + +### Goal + +Maintain the set of pending documentation edits for the `reactor-devtools` section +of the public docs site (`content/docs/reactor-devtools/`), each drawn from a +merged PR that touched the devtools source and each traceable to that PR. This +node produces pointers — which page is now at risk and why — not the edits +themselves; drafting the actual `.mdx` change is the actuator's job. + +The `reactor-devtools` section has these pages (target a pending edit at one of +them): index, quickstart, state-dirs-and-replay, the-viewer, describe, recording, +reference. + +### Requires + +- the devtools-signal facet of change-signal (the only wake source: a devtools + doc-impact signal moved) + +### Maintains + +The pending-edit set for the `reactor-devtools` section. Material: the edit set +and, per edit, its stable id, the target page slug (one of the section's pages), a +one-line description of what is now stale and how to update it, and the PR number +it was derived from; plus the open count. The recorded time is immaterial. The set +moves only when an edit's target, description, or status changes, or an edit is +added or cleared; a PR that introduces no new devtools doc-impact leaves the set +unchanged. + +Postcondition: every pending edit cites the PR number it was derived from and names +a page that exists in the `reactor-devtools` section. + +#### devtools-pending +The pending edits for the `reactor-devtools` section, each with its target page, +description, and cited PR. Subscribed to by docs-pr. + +### Continuity + +- input-driven: wake only when the devtools-signal facet moves. A PR that touched + no devtools source never moves this input, so this section stays silent for it + (memo-skip at zero cost). + +### Invariants + +- This render is a bounded transform over the devtools-signal it was woken with and + this node's prior pending-edit set. Add, update, or clear the one affected edit; + pick the target page from the section's known page list above. Never run git, + read the docs repository, scan the filesystem, or run shell commands. Complete in + a few steps. +- The only writable surface is this node's published world-model. diff --git a/packages/reactor-cli/examples/docs-synthesis/docs-pr.prose.md b/packages/reactor-cli/examples/docs-synthesis/docs-pr.prose.md new file mode 100644 index 00000000..66c6177e --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/docs-pr.prose.md @@ -0,0 +1,84 @@ +--- +name: docs-pr +kind: responsibility +--- + +### Goal + +Turn the pending documentation edits from the five sections into one pull request +against the public docs repository (`openprose/docs`), coalesced once per burst. +This is the terminal actuator: unlike the upstream transforms, its job IS to act — +to read the specific pages its proposals name, draft the concrete `.mdx` changes, +and open a single PR. It runs only when there is real doc work, so its cost is paid +only on genuine surprise. + +Operate on a local clone of the docs repository at the path in the +`DOCS_SYNTH_DOCS_REPO` environment variable. For each pending edit, draft the +change to its target page under `content/docs/
/.mdx`, preserving the +Fumadocs frontmatter convention (a `title` and a `description` only) and the +section's existing voice. Then, when live (see the dry-run guard below): + +1. `cd "$DOCS_SYNTH_DOCS_REPO"` and ensure a clean checkout of the default branch. +2. Create one branch named `docs-synthesis/prs-` (e.g. + `docs-synthesis/prs-101-102-103`) — a deterministic function of the cited + source-PR numbers, so a re-run with the same pending edits reuses the branch + instead of opening a duplicate PR. +3. Write the drafted `.mdx` changes to the named pages, `git add` exactly those + pages, and commit with a message listing the source PRs. +4. `git push` the branch and open one PR with `gh pr create` (base = the docs + default branch), whose body lists each source PR and the page it touched. + +Capture the resulting branch name and PR url/number for the world-model. + +### Requires + +- the cli-pending facet of cli-docs +- the sdk-pending facet of sdk-docs +- the reactor-pending facet of reactor-docs +- the devtools-pending facet of devtools-docs +- the openprose-pending facet of openprose-docs + +### Maintains + +A docs-update pull request. Material: the content hash of the proposed unified diff +across the touched `.mdx` pages, the deterministic branch name (derived from the +set of cited source-PR numbers), and the fingerprint tuple of the pending-edit +facets consumed in this render. Once a PR has been opened for a branch, its number +is material too. The generated time and any local clone path are immaterial. The PR +is a derived projection: it is fingerprinted over the structured pending-edit +snapshot plus the diff hash, never over free-form prose, so a re-render of +identical pending edits produces an identical fingerprint and opens no second PR. + +Postcondition: the branch name is a deterministic function of the cited source-PR +numbers; every touched page exists under its section; the diff hash is computed +from the bytes actually drafted. + +#### opened-pr +The opened pull request: its branch name, the set of source-PR numbers it +addresses, and (once live and opened) its PR number and url. + +### Continuity + +- input-driven, coalesced: wake when any of the five pending-edits facets moves. + When several sections' pending edits move in the same burst, render once after + the upstream receipts settle, not once per section, so one burst yields one PR. +- memo-skip: if all subscribed input fingerprints are unchanged, open nothing and + publish nothing. + +### Invariants + +- This render's job is to act, so — unlike the upstream transforms — it MAY read + the specific `.mdx` pages its pending edits name and the cited source PRs' actual + changes. It is still bounded: read ONLY those named pages and cited PRs, never a + full docs-repo crawl, a full code-repo crawl, or node_modules. Compose the diff + FROM the pending-edit pointers it was woken with; do not re-derive the section + truths. +- The only writable external surface is a branch on a local clone of the docs + repository and the pull request opened from it. Never push to or modify the + watched code repository, and never push to the docs repository's default branch. +- Dry-run guard: when `REACTOR_OFFLINE` is set or `DOCS_SYNTH_DRY_RUN` is truthy, + do not run git, `gh`, or any network; instead write the proposed unified diff and + the deterministic branch name into this node's world-model as a dry-run plan. The + reactive core (classify, accumulate, coalesce) is identical in both modes; only + this terminal effect is gated. The committed `replay/` is produced by a keyed run + with `DOCS_SYNTH_DRY_RUN=1`, so the renders are real but no live PR is opened. diff --git a/packages/reactor-cli/examples/docs-synthesis/openprose-docs.prose.md b/packages/reactor-cli/examples/docs-synthesis/openprose-docs.prose.md new file mode 100644 index 00000000..ced3356a --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/openprose-docs.prose.md @@ -0,0 +1,52 @@ +--- +name: openprose-docs +kind: responsibility +--- + +### Goal + +Maintain the set of pending documentation edits for the `openprose` section of the +public docs site (`content/docs/openprose/`), each drawn from a merged PR that +touched the language skill or references and each traceable to that PR. This node +produces pointers — which page is now at risk and why — not the edits themselves; +drafting the actual `.mdx` change is the actuator's job. + +The `openprose` section has these pages (target a pending edit at one of them): +index, declare-outcomes, contracts, prosescript, typed-image, harness-agnostic, +setup. + +### Requires + +- the openprose-signal facet of change-signal (the only wake source: an openprose + doc-impact signal moved) + +### Maintains + +The pending-edit set for the `openprose` section. Material: the edit set and, per +edit, its stable id, the target page slug (one of the section's pages), a one-line +description of what is now stale and how to update it, and the PR number it was +derived from; plus the open count. The recorded time is immaterial. The set moves +only when an edit's target, description, or status changes, or an edit is added or +cleared; a PR that introduces no new openprose doc-impact leaves the set unchanged. + +Postcondition: every pending edit cites the PR number it was derived from and names +a page that exists in the `openprose` section. + +#### openprose-pending +The pending edits for the `openprose` section, each with its target page, +description, and cited PR. Subscribed to by docs-pr. + +### Continuity + +- input-driven: wake only when the openprose-signal facet moves. A PR that touched + no language skill or references never moves this input, so this section stays + silent for it (memo-skip at zero cost). + +### Invariants + +- This render is a bounded transform over the openprose-signal it was woken with + and this node's prior pending-edit set. Add, update, or clear the one affected + edit; pick the target page from the section's known page list above. Never run + git, read the docs repository, scan the filesystem, or run shell commands. + Complete in a few steps. +- The only writable surface is this node's published world-model. diff --git a/packages/reactor-cli/examples/docs-synthesis/reactor-docs.prose.md b/packages/reactor-cli/examples/docs-synthesis/reactor-docs.prose.md new file mode 100644 index 00000000..35417e65 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/reactor-docs.prose.md @@ -0,0 +1,52 @@ +--- +name: reactor-docs +kind: responsibility +--- + +### Goal + +Maintain the set of pending documentation edits for the `reactor` section of the +public docs site (`content/docs/reactor/`), each drawn from a merged PR that +changed harness behavior or concepts and each traceable to that PR. This node +produces pointers — which page is now at risk and why — not the edits themselves; +drafting the actual `.mdx` change is the actuator's job. + +The `reactor` section has these pages (target a pending edit at one of them): +index, the-dag-and-compile, world-model-and-fingerprints, reconciler-and-receipts, +continuity-and-ingestion. + +### Requires + +- the reactor-signal facet of change-signal (the only wake source: a reactor + doc-impact signal moved) + +### Maintains + +The pending-edit set for the `reactor` section. Material: the edit set and, per +edit, its stable id, the target page slug (one of the section's pages), a one-line +description of what is now stale and how to update it, and the PR number it was +derived from; plus the open count. The recorded time is immaterial. The set moves +only when an edit's target, description, or status changes, or an edit is added or +cleared; a PR that introduces no new reactor doc-impact leaves the set unchanged. + +Postcondition: every pending edit cites the PR number it was derived from and names +a page that exists in the `reactor` section. + +#### reactor-pending +The pending edits for the `reactor` section, each with its target page, +description, and cited PR. Subscribed to by docs-pr. + +### Continuity + +- input-driven: wake only when the reactor-signal facet moves. A PR that changed no + harness behavior never moves this input, so this section stays silent for it + (memo-skip at zero cost). + +### Invariants + +- This render is a bounded transform over the reactor-signal it was woken with and + this node's prior pending-edit set. Add, update, or clear the one affected edit; + pick the target page from the section's known page list above. Never run git, + read the docs repository, scan the filesystem, or run shell commands. Complete in + a few steps. +- The only writable surface is this node's published world-model. diff --git a/packages/reactor-cli/examples/docs-synthesis/reactor.yml b/packages/reactor-cli/examples/docs-synthesis/reactor.yml new file mode 100644 index 00000000..d71eb882 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/reactor.yml @@ -0,0 +1,61 @@ +# docs-synthesis — merged PRs in the prose monorepo piped into docs-update PRs. +# +# A `repo-changes` GATEWAY accepts merged-PR deltas (number, title, body, the +# changed-file-path list, and a diffstat — never the full diff). A `change-signal` +# responsibility classifies each PR's changed paths into per-section doc-impact +# signals, then five section truths (cli-docs, sdk-docs, reactor-docs, +# devtools-docs, openprose-docs) each subscribe to the one signal facet they care +# about and accumulate pending edits, and a `docs-pr` actuator composes the pending +# edits across sections into ONE pull request against openprose/docs. Unrelated PRs +# memo-skip the sections they do not touch. +# +# Offline: the built-in `static` connector below ships fixture PR deltas, so +# `reactor serve` (or `reactor trigger`) ingests them with no key and no network. +# Live: drop in connectors.cjs (keyed `github`) to read merged PRs from `git log`. +state: + dir: ./.reactor + +model: + # ONE cheap global model for the whole pipeline. The tiered design makes every + # upstream render a small typed transform (classify changed paths, append one + # pending edit, coalesce facets), so a fast, cheap model is adequate there. The + # terminal `docs-pr` actuator is the one render that drafts real .mdx prose and + # would most benefit from a stronger model — but per-node model selection (a + # node's `### Runtime` model) is NOT yet honored by the CLI (tracked as + # RB-NodeModel), so today the actuator runs on this same global model and a human + # reviews the opened PR. The llm-as-judge tier uses an expensive model instead + # (configured in the eval harness, not here). + provider: openai + # Cheap model for the high-volume RENDERS (classify, accumulate, coalesce). + # Compile is the rare phase (once per contract change) and needs stricter + # structured-output fidelity, so it uses the medium model — amortized across the + # whole life of a stable contract. + render_model: gpt-5.4-mini + compile_model: gpt-5.4 + temperature: 0 + # Bounded renders: each upstream render is a short transform, not an open-ended + # agent loop. The cap is the backstop; the contracts' ### Invariants do the real + # bounding (read only the inline input; never run git or scan the repo). The + # actuator is allowed to read the specific pages it edits, also bounded. + max_turns: 24 + +sandbox: + mode: none + shell_timeout_ms: 300000 + +gateways: + - node: repo-changes + source_id: github + poll: 60s + connector: + type: static + id_field: id + # NOTE: the CLI's reactor.yml parser accepts inline FLOW maps/lists for + # connector items (one line), with scalar fields only — block-style list + # items and nested maps/arrays do not parse here. So `files` is a + # semicolon-delimited path list (a scalar), which change-signal splits. + # Four fixtures exercise selective wake: a CLI-only PR (moves cli-docs only), + # a CLI+SDK PR (moves cli-docs + sdk-docs), an openprose PR (moves + # openprose-docs only), and a test-only no-op PR (moves nothing downstream). + # Add more {...} entries (same line) for a fuller demo. + items: [{ id: pr-101, number: 101, title: "feat(cli): add anonymous opt-out telemetry", body: "Adds a REACTOR_TELEMETRY=0 opt-out and an anonymous usage ping. New env var and a note in the run path.", files: "packages/reactor-cli/src/telemetry.ts;packages/reactor-cli/src/commands/run.ts", diffstat: "2 files changed, 64 insertions", merge_sha: "a1b2c3d", fingerprint: "pr-101:a1b2c3d" }, { id: pr-102, number: 102, title: "feat: bring-your-own LLM provider configuration", body: "Lets users configure a custom provider/base_url/api_key_env. Adds a public adapter export and a CLI config block.", files: "packages/reactor/src/adapters/provider.ts;packages/reactor/src/index.ts;packages/reactor-cli/src/config.ts", diffstat: "3 files changed, 121 insertions, 8 deletions", merge_sha: "b2c3d4e", fingerprint: "pr-102:b2c3d4e" }, { id: pr-103, number: 103, title: "feat(openprose): add the Typed Image authoring surface", body: "Introduces the Typed Image authoring surface to the language skill and the spec reference.", files: "skills/open-prose/SKILL.md;references/01-Language.md", diffstat: "2 files changed, 210 insertions", merge_sha: "c3d4e5f", fingerprint: "pr-103:c3d4e5f" }, { id: pr-104, number: 104, title: "test: stabilize reconciler flake", body: "Flaky same-epoch test; adds a deterministic clock to the test harness. No behavior or API change.", files: "packages/reactor/src/__tests__/reconciler.test.ts;.github/workflows/ci.yml", diffstat: "2 files changed, 30 insertions, 12 deletions", merge_sha: "d4e5f6a", fingerprint: "pr-104:d4e5f6a" }] diff --git a/packages/reactor-cli/examples/docs-synthesis/repo-changes.prose.md b/packages/reactor-cli/examples/docs-synthesis/repo-changes.prose.md new file mode 100644 index 00000000..dddbbe60 --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/repo-changes.prose.md @@ -0,0 +1,51 @@ +--- +name: repo-changes +kind: gateway +--- + +### Goal + +Fold the merged-PR records staged in this node's upstream inbox into a +materialized set of changed PRs that responsibilities can subscribe to. + +Your ONLY input is your upstream ingress, which publishes a single file +`inbox.json`: a JSON array of merged-PR records. Each record has `number`, +`title`, `body`, `files` (a semicolon-delimited list of changed file paths), +`diffstat`, and `merge_sha`. Read `inbox.json` from your upstream by reference +(use your upstream-list / upstream-read tools — the ingress is your upstream +producer even though it carries no `### Requires` line), and add or update each +record in the `changes` set, keyed by PR number, carrying its number, title, body, +files, and diffstat forward as the change receipt. There is NO git repository, no +source tree, and no working-directory file to inspect — `inbox.json` is the only +source of truth. + +### Maintains + +The set of changed PRs, folded from the external arrivals staged at the edge. +Material: the changed-PR set (unordered, keyed by PR number) and, for each PR, its +number and its change fingerprint. The scan time is immaterial and is excluded +from the fingerprint, so a re-poll that finds the same merged PR produces an +identical world-model fingerprint and the reconciler skips before any downstream +render runs. + +#### changes +The changed-PR set. Each PR is individually addressable by its number, so a +downstream node that subscribes to this set wakes when any PR's fingerprint moves. +Material per PR: the PR number, the change fingerprint, the title, the body, the +delimited changed-file-path list, and the diffstat — the inline evidence the +classifier needs and nothing more. + +### Continuity + +- external-driven: wake when a merged PR arrives at the gateway. A re-poll that + finds no moved fingerprint stages nothing and wakes nothing. + +### Invariants + +- This render is a bounded fold over `inbox.json` from your upstream and this + node's prior world-model. Read `inbox.json`, fold each PR record into `changes`, + and finish in a few steps. +- Do NOT run `git`, `ls`, `find`, `grep`, or any shell command to discover + changes, and do NOT scan the filesystem or the working directory: there is no + repository here. The staged `inbox.json` from your upstream is the only input. +- The only writable surface is this gateway's published world-model. diff --git a/packages/reactor-cli/examples/docs-synthesis/sdk-docs.prose.md b/packages/reactor-cli/examples/docs-synthesis/sdk-docs.prose.md new file mode 100644 index 00000000..12f6051c --- /dev/null +++ b/packages/reactor-cli/examples/docs-synthesis/sdk-docs.prose.md @@ -0,0 +1,51 @@ +--- +name: sdk-docs +kind: responsibility +--- + +### Goal + +Maintain the set of pending documentation edits for the `sdk` section of the +public docs site (`content/docs/sdk/`), each drawn from a merged PR that changed +the SDK's public API surface and each traceable to that PR. This node produces +pointers — which page is now at risk and why — not the edits themselves; drafting +the actual `.mdx` change is the actuator's job. + +The `sdk` section has these pages (target a pending edit at one of them): index, +front-door, agents, adapters, run, internals. + +### Requires + +- the sdk-signal facet of change-signal (the only wake source: an sdk doc-impact + signal moved) + +### Maintains + +The pending-edit set for the `sdk` section. Material: the edit set and, per edit, +its stable id, the target page slug (one of the section's pages), a one-line +description of what is now stale and how to update it, and the PR number it was +derived from; plus the open count. The recorded time is immaterial. The set moves +only when an edit's target, description, or status changes, or an edit is added or +cleared; a PR that introduces no new sdk doc-impact leaves the set unchanged. + +Postcondition: every pending edit cites the PR number it was derived from and names +a page that exists in the `sdk` section. + +#### sdk-pending +The pending edits for the `sdk` section, each with its target page, description, +and cited PR. Subscribed to by docs-pr. + +### Continuity + +- input-driven: wake only when the sdk-signal facet moves. A PR that changed no + public SDK surface never moves this input, so this section stays silent for it + (memo-skip at zero cost). + +### Invariants + +- This render is a bounded transform over the sdk-signal it was woken with and this + node's prior pending-edit set. Add, update, or clear the one affected edit; pick + the target page from the section's known page list above. Never run git, read the + docs repository, scan the filesystem, or run shell commands. Complete in a few + steps. +- The only writable surface is this node's published world-model.