Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8075463
feat(sdk): schema-2 flow-extension plugins from public GitHub — add, …
khaliqgant Sep 20, 2026
89be148
feat(sdk): compose schema-2 flow extensions onto the base flow at loa…
khaliqgant Sep 20, 2026
841f9d5
test(sdk): compare canonical realpaths for the composed extension ent…
khaliqgant Sep 20, 2026
45246a7
fix(sdk): extension graph nodes carry their own surface accessor
khaliqgant Sep 20, 2026
84e4c89
feat(sdk): flows plugin remove and update (A1)
khaliqgant Sep 20, 2026
f5bec9d
feat(sdk): seal flow-extension plugins in bundle lockfile v2 (A2)
khaliqgant Sep 20, 2026
49d16cf
feat(surface,sdk): compose f.hook implementations in lock order (A3)
khaliqgant Sep 20, 2026
83e850e
feat(sdk): send composed extensions on hosted deploy (A4-A6)
khaliqgant Sep 20, 2026
34bbbd6
feat(catalog): list babysitter as a fail-closed schema-2 plugin (D1)
khaliqgant Sep 20, 2026
d89e4ec
fix(sdk): type fake-github Response bodies without DOM BodyInit
khaliqgant Sep 20, 2026
1a75d20
fix(sdk): allow version/hooks headers and fail closed on review findings
khaliqgant Sep 21, 2026
9530e3c
fix(sdk): address remaining flows #528 review comments
khaliqgant Sep 21, 2026
bff1be8
fix(sdk): remaining #528 review comments (codes, verify, JSON plan)
khaliqgant Sep 21, 2026
7f04586
fix(sdk): tolerate partial authored definitions when reading hooks
khaliqgant Sep 21, 2026
3e91344
fix(sdk): close flow-extension preflight gaps
miyaontherelay Sep 21, 2026
be2c441
fix(sdk): recover plugin lock transactions
miyaontherelay Sep 21, 2026
b8e5265
fix(sdk): harden plugin transaction recovery
miyaontherelay Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions catalog/plugins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": 1,
"plugins": [
{
"name": "babysitter",
"description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate. Fail-closed: GitHub pull_request.ready_for_review, labeled, and unlabeled are not in the surface registry, so a manifest that declares them is refused plugin_event_unroutable until the relayfile adapter catalog grows.",
"source": { "owner": "AgentWorkforce", "repo": "flows", "path": "examples/babysitter" },
"ref": "05c3dff138883322e80cb793b1f5a097ad510572",
"digest": "ae6af3335eb6d4e54559327acc1465419244b47911d8ff356850b61f6228d862",
"compat": { "surface": "^2.0.22", "sdk": "^2.0.22", "base": ["software-factory"] },
"tier": "community",
"base": ["software-factory"]
}
]
}
11 changes: 11 additions & 0 deletions docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ An authored `.flow.ts` takes `--input` exactly as a local direct run does (an
existing JSON file, otherwise inline JSON), and travels as one self-contained
source with its pinned Surface authority.

Flow-extension plugins declared in `flows.json` (and extra `--plugin <github
ref>` on `flows deploy`) travel in the deploy/run body as `extensions[]`:
name, version, canonical ref, digest, manifest, and the plugin files (UTF-8
or base64). The extensions field is capped at 2 MB separately from the 256 KB
source cap. Cloud must materialize them at `.flows/plugins/<name>@sha256:<digest>/`
before the hosted CLI loads the source; until that Cloud slice lands, a
deployment that includes plugins is accepted by this CLI but not yet executed
as a composed graph on the hosted runner. Private repositories are
unsupported. `permissions.writes` is a reviewed declaration, unenforced
until gate 8.

## Code sync

```sh
Expand Down
68 changes: 68 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,74 @@ plugin code bundling/pinning, declarative-flow plugin preflight, and restart
recovery of an interrupted plugin effect. Plugin effects currently inherit the
internal authored executor's child-run lifecycle, not a resumable authored root.

### Flow extensions: schema 2, `kind: "flow-extension"`

The same `flows-plugin.json` file carries a second kind. A **helper** plugin
(`kind` absent) extends `Ctx` with verbs and installs from npm as above. A
**flow extension** (`"schema": 2, "kind": "flow-extension"`) is a directory in
a public GitHub repository whose `entry` default-exports `flow()` and declares
what it will contribute to a base flow — `extends.handlers` (its `.on()`
pairs), `extends.hooks` (named points the base calls), `triggers` (validated
against the surface event registry, refused with `plugin_event_unroutable`
otherwise), `permissions` (integrations, harnesses, mcp, declared-but-unenforced
`writes`, a budget ceiling), `compat` (semver ranges for surface and sdk, and
the base flows it extends), and the same mandatory `preflight`. Validation is
`packages/sdk/src/flow-extension-manifest.ts`; the worked Babysitter manifest is
`testdata/plugins/extension-babysitter/flows-plugin.json`.

```text
flows add github:<owner>/<repo>@<ref>#<path> # or https://github.com/<owner>/<repo>/tree/<ref>/<path>
flows plugin list [--json]
flows plugin verify [--json] [--offline]
flows plugin remove [--json] <name>
flows plugin update [--json] [--yes] [--to <ref>] [<name>]
```

`flows add` resolves the branch, tag, or commit to a 40-hex sha through
unauthenticated public GitHub reads (a private repository answers 404 and is
reported as `plugin_source_unresolved`), enumerates the tree at that commit —
refusing symlinks, submodules, traversal, a truncated listing, files over
256 KB, or plugins over 2 MB — downloads each blob pinned to the sha, checks
byte counts, and computes the content digest as the sha256 of the same
canonical `[{bytes,path,sha256}]` manifest a sealed bundle uses. The bytes are
materialized under `.flows/plugins/<name>@sha256:<digest>/`; `flows.json.plugins`
gains the canonical `github:<owner>/<repo>@<sha>#<path>` (a branch or tag is
never persisted); and `flows.lock.json` (version 2) records name, version,
source, digest, manifest hash, and the declaration order that will be the
composition order. `flows plugin verify` re-hashes the store against the lock
and, unless `--offline`, re-fetches the pinned commit; any difference is
`plugin_source_drift`, exit 2.

**Composition.** `loadAuthoredFlow` (the path under `flows check`, `flows run`,
and the authored root) composes the project's extensions onto the base flow
(`packages/sdk/src/flow-extension-loader.ts`), in this fixed order: the
declaration and the lockfile must agree; the store is re-hashed against the
lock's digest and the manifest bytes against its manifest hash — nothing under
`.flows/plugins` is read as code before that passes; the manifest is validated
and its `compat` checked against the runtime and the base flow (`FlowHeader.version`
is matched when present; without it only `*` is satisfiable; a budget ceiling
above the base is `plugin_incompatible`); only then is the entry imported, its
handlers checked against the manifest's declared triggers (an entry cannot
subscribe to more than it declared), and appended **after** the base's own
handlers in lockfile order. Named `hooks` exports are matched to
`extends.hooks` and to the base header's `hooks` list; `f.hook` AND-composes
them in lock order. Nothing replaces, reorders, or widens a base handler, and
the base's definition object is untouched. `flows check` prints one `EXTENSION`
line per composed extension. Not composed by this release, and refused with
`plugin_unsupported` rather than ignored: an entry `use:` header, schedule
triggers, and gates; a generic `webhook(...)` handler is refused as
undeclared. Cloud deploy and hosted runs send composed extensions in the request body
(`extensions[]`, 2 MB cap, `--plugin` is send-only). Handler bodies still
execute nowhere (#301); what composition changes today is the declared
trigger set that `flows check`, requirements, and future dispatch read.

GitHub `pull_request.ready_for_review`, `pull_request.labeled`, and
`pull_request.unlabeled` are **not** in the surface registry. The registry is
generated from the pinned relayfile adapter mappings (`scripts/generate-triggers.mjs`);
this repo cannot add those actions without an adapter-package change. A
Babysitter manifest that declares them is refused `plugin_event_unroutable`
until that upstream catalog grows.

## 4. Build: the immutable bundle

`flows build` seals a flow into a content-addressed, immutable bundle: canonical spec JSON, compiled TS with pinned deps, helper/plugin lockfile, assets, preflight declaration, identity signature — `flow@sha256:…`, pushed to a bucket/registry. `flows deploy` points a trigger at a digest; `flows run flow@sha256:…` executes from the bucket on any cell, no checkout. Preflight runs at build time for everything build-provable and again at deploy time for environment facts (credentials, workers, MCP servers). The working tree is for authoring; **production only ever runs digests.**
Expand Down
8 changes: 8 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ flow straight from this repo, shows its steps, and asks you to connect whatever

Or from a checkout: `flows deploy <flow.ts> --repo <owner/name> --on <provider[:k=v]> --approver <you>`.

Install a plugin onto a base flow (Babysitter on Software Garden):

```text
flows add github:AgentWorkforce/flows@<sha>#examples/babysitter
```

The plugin is recorded in `flows.json` / `flows.lock.json` and composed at load time. Hosted deploy accepts `--plugin <github ref>` as a send-only extra. Private repositories are unsupported. `permissions.writes` is declared, not enforced.

## Gallery status

**3 of 4 gallery entries pass; one is blocked.** The blocked entry fails
Expand Down
34 changes: 32 additions & 2 deletions examples/software-factory/software-factory.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ const WORK = ".relayflow";
// test result, the exit code does. Skips honestly when there is nothing to run.
const TEST = 'if [ -f package.json ] && node -e \'p=require("./package.json");process.exit(p.scripts&&p.scripts.test?0:1)\'; then npm ci --no-audit --no-fund && npm test; else echo "no test script; skipping"; fi';

export default flow<Input>("software-factory", { budget: { dollars: 10, wallclock: "1h" } }, async (f, input) => {
export default flow<Input>("software-factory", {
version: "2.0.22",
hooks: ["pre-implement", "post-review", "merge-gate"],
Comment thread
miyaontherelay marked this conversation as resolved.
budget: { dollars: 10, wallclock: "1h" },
}, async (f, input) => {
const { issue } = input;
if (!issue?.title?.trim()) {
// Parked, not canceled: a body cannot declare a kernel outcome, and the
Expand All @@ -43,6 +47,11 @@ export default flow<Input>("software-factory", { budget: { dollars: 10, wallcloc
// Fresh work dir, excluded from git, no leftover verdicts.
await f.run(`rm -rf ${WORK} && mkdir -p ${WORK} && { grep -qxF '${WORK}/' .git/info/exclude 2>/dev/null || echo '${WORK}/' >> .git/info/exclude; }`);

if (!await f.hook("pre-implement", { title, issue })) {
await f.run("echo 'Stopped: pre-implement hook refused this ticket.' >&2");
return f.done("declined");
}

await f.agent("implementer", {
cli: "claude",
task: `Implement this ticket in the current repository, on the current branch, with regression tests. Commit as you go.\n` +
Expand All @@ -62,14 +71,35 @@ export default flow<Input>("software-factory", { budget: { dollars: 10, wallcloc

await f.run(TEST, { timeout: "15m" });

if (!await f.hook("post-review", { title })) {
await f.run(`{ cat ${WORK}/summary.md; printf '\\n\\n## post-review: blocked\\n\\n'; } > ${WORK}/pr-body.md`);
await f.run("git add -A && (git diff --cached --quiet || git commit -qm 'Software factory: implementation and review fixes')");
await f.run("git push --set-upstream origin HEAD");
await f.run(`gh pr create --draft --title ${shellWord(`[blocked] ${title}`)} --body-file ${WORK}/pr-body.md`);
return f.done("step_failed");
}

// Passed means exactly one verdict, and it is the pass marker.
const verdict = await f.run(`if [ -f ${WORK}/review.passed ] && [ ! -f ${WORK}/review.blocked ]; then echo PASSED; else echo BLOCKED; fi`);
await f.run("git add -A && (git diff --cached --quiet || git commit -qm 'Software factory: implementation and review fixes')");
await f.run("git push --set-upstream origin HEAD");

// Deterministic step, not an agent decision: the PR is opened either way,
// but a blocked review opens it as a draft with the findings attached.
// but a blocked review or a false merge-gate opens it as a draft.
if (verdict.trim() === "PASSED") {
const origin = (await f.run("git remote get-url origin")).trim();
const headSha = (await f.run("git rev-parse HEAD")).trim();
const matched = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/.exec(origin);
const allowed = await f.hook("merge-gate", {
owner: matched?.[1] ?? "",
repo: matched?.[2] ?? "",
headSha,
});
if (!allowed) {
await f.run(`{ cat ${WORK}/summary.md; printf '\\n\\n## merge-gate: blocked\\n\\n'; } > ${WORK}/pr-body.md`);
await f.run(`gh pr create --draft --title ${shellWord(`[blocked] ${title}`)} --body-file ${WORK}/pr-body.md`);
return f.done("step_failed");
}
Comment thread
cursor[bot] marked this conversation as resolved.
await f.run(`gh pr create --title ${shellWord(title)} --body-file ${WORK}/summary.md`);
return f.done("success");
}
Expand Down
37 changes: 36 additions & 1 deletion packages/sdk/src/authored-flow-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
} from './authored-flow-operation.js';
import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js';
import { JournalClient } from './journal-client.js';
import { createHookEvaluator } from './authored-hooks.js';
import { probeFlowExtension, type LoadedFlowExtension } from './flow-extension-loader.js';
import type {
CompletionReason as ProtocolCompletionReason,
RunCompletionReason as ProtocolRunCompletionReason,
Expand Down Expand Up @@ -148,6 +150,8 @@ export interface ExecuteAuthoredFlowOptions {
readonly localAgentStream?: string;
/** Durable kernel root that owns this body's child admission identities. */
readonly rootRunId?: string;
/** Installed flow-extension plugins, in lock order, so `f.hook` can AND-compose them. */
readonly extensions?: readonly LoadedFlowExtension[];
}

export async function executeAuthoredFlow<Input = undefined>(
Expand All @@ -171,7 +175,7 @@ export async function executeAuthoredFlow<Input = undefined>(
...(options.onWait !== undefined ? { onWait: options.onWait } : {}),
};
const definition = getDefinition<Input>(handle);
const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory');
const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory' && key !== 'version' && key !== 'hooks');
if (definition.header.tools && Object.keys(definition.header.tools).some(key => !['mcp', ...helperProviders.map(p => p.namespace)].includes(key))) headerFields.push('tools');
if (definition.header.tools?.relayfile !== undefined) headerFields.push('tools.relayfile');
const helperPreflight = checkSlackHelpers(definition);
Expand All @@ -188,6 +192,9 @@ export async function executeAuthoredFlow<Input = undefined>(

const checkedMcp = await checkMcpHeader(definition, flowPath);
if (!checkedMcp.report.ok) throw new McpPreflightError(checkedMcp.report);
for (const extension of options.extensions ?? []) {
if (extension.manifest !== undefined) await probeFlowExtension(extension.manifest);
}

const budget = new AuthoredBudget(definition.header.budget);
if (definition.header.memory?.agent === true) {
Expand Down Expand Up @@ -349,6 +356,17 @@ export async function executeAuthoredFlow<Input = undefined>(
));
}

const evaluateHook = createHookEvaluator({
journal,
...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
flowName: definition.name,
declared: definition.header?.hooks ?? [],
extensions: options.extensions ?? [],
peekStep: () => nextStep,
restoreStep: (step) => { nextStep = step; },
...(options.signal === undefined ? {} : { signal: options.signal }),
});

const context: Ctx = {
...createHelpers(<T>(call: HelperCall): Step<T> => {
const verb = `${call.provider}.${call.verb}`;
Expand Down Expand Up @@ -477,6 +495,23 @@ export async function executeAuthoredFlow<Input = undefined>(
assertOperationAllowed('dispatch', definition.name, requestedCompletion);
throw unsupportedVerb('dispatch');
},
hook(name, input) {
assertOperationAllowed('hook', definition.name, requestedCompletion);
const id = `hook-${nextStep++}`;
const snapshot = snapshotJsonValue(input, 'f.hook input');
return trackStep(authoredSteps, new AuthoredFlowOperation<boolean>(
id, 'hook',
() => assertOperationAllowed('hook', definition.name, requestedCompletion),
async () => {
const verdict = await evaluateHook(id, name, snapshot, context);
const record = { hook: name, step: id, verdict: verdict ? 'pass' : 'fail' };
const literal = `'${JSON.stringify(record).replaceAll("'", "'\\''")}'`;
await observeStep(id, 'deterministic', () => lowerDeterministic(id, `printf '%s' ${literal}`, false), options.onProgress);
return verdict;
},
lifecycle,
));
},
done(reason) {
if (!isSurfaceFlowCompletionReason(reason)) {
throw new AuthoredFlowExecutionError(
Expand Down
Loading
Loading