From 3a2eb7d304ea39b8912c747fa6fd20e3cc65e635 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Fri, 25 Sep 2026 20:41:34 -0600 Subject: [PATCH] Add: high-level design doc with Mermaid diagrams docs/HLD.md explains how fledge works end to end, true to the code: command dispatch, fledge.toml, task and lane execution, the plugin system (install, manifest, registry, fledge-v1 protocol, WASM), work, release and the GitHub plugin, hooks, data, deployment, trust model, failure modes and decisions. 14 Mermaid diagrams, including sequence diagrams for fledge run, fledge lanes run, and plugin install and invocation. README gains a short Architecture section linking to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3ZZAEiUP7xRJPozhZb6rL --- README.md | 22 ++ docs/HLD.md | 877 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 899 insertions(+) create mode 100644 docs/HLD.md diff --git a/README.md b/README.md index 05ca821b..2a5f9492 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,28 @@ fledge ai status # show active provider/model and That is the whole core. Anything else is a plugin. +## Architecture + +One Rust binary. clap sends each core command to its own module, and any verb it does not +know is looked up as a plugin (`fledge-NAME`, native or sandboxed WASM). Tasks and lanes come +from the project's `fledge.toml`, and every `--json` output uses the same envelope. + +```mermaid +flowchart LR + user["Developer or agent"] --> cli["fledge"] + cli --> core["Core commands
run, lanes, work, release, spec, ai, ..."] + cli -->|"unknown verb"| plugins["Plugins
fledge-NAME, native or WASM"] + core --> toml[("fledge.toml
tasks, lanes, work, release")] + core --> tools["Project toolchain and git"] + core --> net["GitHub API, LLM providers"] + plugins --> reg[("plugins.toml
registry and grants")] +``` + +The high-level design in [docs/HLD.md](docs/HLD.md) covers command dispatch, `fledge.toml`, +task and lane execution, the plugin system and protocol, `work` and `release`, hooks, and the +trust model, with sequence diagrams for `fledge run`, `fledge lanes run`, and plugin install +and invocation. + ## Plugins Plugins extend fledge with community-built commands. Native plugins run as regular executables. **WASM plugins** run in a sandboxed Wasmtime runtime with no host access by default. diff --git a/docs/HLD.md b/docs/HLD.md new file mode 100644 index 00000000..40feec7c --- /dev/null +++ b/docs/HLD.md @@ -0,0 +1,877 @@ +# fledge: high-level design + +This document explains how fledge works end to end: how a command is dispatched, how +`fledge.toml` drives tasks and lanes, how plugins are installed and run, how the ship +commands (`work`, `release`, and the GitHub plugin) fit together, and where hooks fire. +It describes the code on `main` at version 1.7.2 (`Cargo.toml`). The code and the specs +in [`specs/`](../specs) are the source of truth. When they disagree with this page, they win, +and this page should be fixed. + +**Contents** + +1. [Purpose](#1-purpose) +2. [Context](#2-context) +3. [Components](#3-components) +4. [Key flows](#4-key-flows) +5. [Hooks](#5-hooks) +6. [Data](#6-data) +7. [Runtime and deployment](#7-runtime-and-deployment) +8. [Security and trust boundaries](#8-security-and-trust-boundaries) +9. [Failure modes and limits](#9-failure-modes-and-limits) +10. [Decisions](#10-decisions) +11. [Glossary](#11-glossary) + +## 1. Purpose + +fledge is a single Rust binary that gives every repository the same small set of dev-loop +commands, whatever language is inside: scaffold (`templates`), run (`run`, `lanes`, `watch`), +spec (`spec`), AI (`ai`, `ask`, `review`), ship (`work`, `release`, `changelog`) and extend +(`plugins`, `config`, `introspect`, `completions`, `doctor`). It is used by developers and, +just as much, by AI agents: every command that matters has a `--json` envelope and a +non-interactive mode. It works with nothing configured (it detects the project type), grows +through a checked-in `fledge.toml`, and keeps anything ecosystem-specific out of core in +plugins. The product intent is written down in [`INTENT.md`](../INTENT.md) and the +[`hi/`](../hi) criteria. The agent contract is in [`AGENTS.md`](../AGENTS.md). + +## 2. Context + +fledge runs on the user's machine, in the user's current directory. It reads the project, +shells out to the project's own toolchain and to `git`, and reaches the network only for +GitHub (search, import, publish, clone) and for LLM providers (AI commands). + +```mermaid +flowchart LR + dev["Developer"] --> cli + agent["AI agent
--json, FLEDGE_NON_INTERACTIVE=1"] --> cli + subgraph host["User machine"] + cli["fledge CLI
single Rust binary"] + proj[("Project directory
fledge.toml, .fledge/lanes, specs/")] + cfg[("config_dir/fledge
config.toml, plugins.toml, plugins/")] + sh["Project toolchain
via sh -c or cmd /C"] + git["git"] + native["Native plugins
fledge-NAME executables"] + wasm["WASM plugins
Wasmtime, in-process"] + specsync["specsync binary
optional"] + gh["gh CLI
optional"] + end + cli --> proj + cli --> cfg + cli --> sh + cli --> git + cli --> native + cli --> wasm + cli --> specsync + cli -. "token fallback" .-> gh + native -. "fledge-plugin-github" .-> gh + cli -->|"REST over HTTPS"| ghapi["GitHub API"] + gh --> ghapi + git -->|"clone, push"| ghrepos["github.com repos
plugins, templates, lanes"] + cli -->|"HTTP via corvid-ai"| llm["LLM providers
Anthropic, OpenAI-compatible, Ollama"] +``` + +| External | Used by | How | +|---|---|---| +| Project toolchain (`cargo`, `npm`, `go`, ...) | `run`, `lanes`, `watch` | Task strings run through `sh -c` (Unix) or `cmd /C` (Windows) in the project directory ([`src/run.rs`](../src/run.rs), [`src/lanes/execute.rs`](../src/lanes/execute.rs)) | +| `git` | `work`, `release`, `changelog`, `review`, plugin and template installs | Spawned as a subprocess ([`src/work.rs`](../src/work.rs), [`src/release/git.rs`](../src/release/git.rs), [`src/plugin/install.rs`](../src/plugin/install.rs), [`src/remote.rs`](../src/remote.rs)) | +| GitHub REST API | `plugins search`, `lanes search/import`, `templates search`, the `publish` commands | `ureq` with a 30 s timeout ([`src/github.rs`](../src/github.rs)) | +| github.com git remotes | plugin install/update, remote templates, publish | `git clone` / `git push`, with the GitHub token passed as an HTTP header for github.com URLs | +| LLM providers | `ai`, `ask`, `review`, `work commit --ai`, `spec lint --ai` | Plain HTTP through the [`corvid-ai`](https://crates.io/crates/corvid-ai) crate ([`src/llm.rs`](../src/llm.rs)) | +| `specsync` | `spec check` | Delegated to when it is on `PATH`, otherwise a built-in structural check ([`src/spec/engine.rs`](../src/spec/engine.rs)) | +| `gh` | token resolution | `gh auth token` is the last fallback for a GitHub token ([`src/config.rs`](../src/config.rs)). The GitHub commands themselves live in the separate `fledge-plugin-github` repo, whose own help says it drives the `gh` CLI | + +## 3. Components + +### 3.1 Module map + +Core is one crate. [`src/main.rs`](../src/main.rs) declares every module and holds the +top-level dispatch. [`src/cli.rs`](../src/cli.rs) holds the clap derive types. Folder modules +(`plugin/`, `lanes/`, `protocol/`, `spec/`, `release/`) split a pillar into files. + +```mermaid +flowchart LR + main["main.rs + cli.rs
clap parse, dispatch"] + subgraph runp["Run"] + run["run.rs"] + deps["deps.rs
task graph walk"] + lanes["lanes/"] + watch["watch.rs"] + end + subgraph scaffoldp["Scaffold"] + tinit["template_cmds.rs, init.rs"] + tpl["templates.rs, remote.rs"] + tauth["create_template.rs, validate.rs,
publish.rs, search.rs"] + end + subgraph specp["Spec"] + spec["spec/"] + end + subgraph aip["AI"] + ai["ai.rs, ask.rs, review.rs"] + llm["llm.rs"] + end + subgraph shipp["Ship"] + work["work.rs"] + release["release/"] + changelog["changelog.rs"] + end + subgraph extendp["Extend"] + plugin["plugin/"] + protocol["protocol/"] + config["config.rs, config_cmds.rs"] + intro["introspect.rs, doctor.rs"] + end + subgraph sharedp["Shared"] + envelope["envelope.rs"] + trust["trust.rs"] + github["github.rs"] + end + main --> runp & scaffoldp & specp & aip & shipp & extendp + run --> deps + lanes --> deps + watch --> run + watch --> lanes + release --> lanes + work --> plugin + tinit --> plugin + plugin --> protocol + plugin --> trust + lanes --> trust + ai --> llm + ai --> spec +``` + +| Module | Owns | +|---|---| +| [`main.rs`](../src/main.rs), [`cli.rs`](../src/cli.rs) | Parsing, the global `--non-interactive` flag, one match arm per command, the `External` catch-all that routes unknown verbs to plugins | +| [`run.rs`](../src/run.rs) | `fledge run`: reading `[tasks]`, auto-detecting a project type when there is no `fledge.toml`, building the shell command, `--json`/`--stream` output, `--init` | +| [`deps.rs`](../src/deps.rs) | `walk_task_graph`: the shared depth-first walk (cycle detection, diamond handling, a 1,000-level depth bound) used by `run` and `lanes` | +| [`lanes/`](../src/lanes) | `fledge lanes`: config loading and validation ([`mod.rs`](../src/lanes/mod.rs)), execution ([`execute.rs`](../src/lanes/execute.rs)), community search/import ([`community.rs`](../src/lanes/community.rs)), defaults, create, publish, validate | +| [`watch.rs`](../src/watch.rs) | File watching (`notify`) and re-running a task or lane | +| [`templates.rs`](../src/templates.rs), [`remote.rs`](../src/remote.rs), [`init.rs`](../src/init.rs), [`template_cmds.rs`](../src/template_cmds.rs) | Built-in templates embedded with `include_dir!`, remote templates cloned into the cache dir, Tera rendering, `post_create` hooks | +| [`create_template.rs`](../src/create_template.rs), [`validate.rs`](../src/validate.rs), [`publish.rs`](../src/publish.rs), [`search.rs`](../src/search.rs) | Template authoring, validation, publishing and GitHub search | +| [`spec/`](../src/spec) | `fledge spec`: parse, structural validation, delegation to `specsync`, `spec lint` | +| [`ai.rs`](../src/ai.rs), [`ask.rs`](../src/ask.rs), [`review.rs`](../src/review.rs), [`llm.rs`](../src/llm.rs) | Provider selection and precedence, spec-aware prompts, single and multi-model review (one thread per panel slot) | +| [`work.rs`](../src/work.rs) | Branch naming, conventional commits, guarded push, `work status` | +| [`release/`](../src/release) | Version resolution, file bumps, `CHANGELOG.md` entry, release commit, tag, push | +| [`changelog.rs`](../src/changelog.rs) | `fledge changelog`: read-only changelog from tags and commits | +| [`plugin/`](../src/plugin) | Plugin install, update, remove, list, audit, search, recommend, create, publish, validate, run, lifecycle hooks, WASM runtime ([`wasm.rs`](../src/plugin/wasm.rs)) | +| [`protocol/`](../src/protocol) | The `fledge-v1` plugin protocol: init message, message loop, capability-gated `exec`, `store`, `metadata`, and UI messages | +| [`trust.rs`](../src/trust.rs) | Trust-tier classification of a source (local, official, team, unverified) | +| [`config.rs`](../src/config.rs), [`config_cmds.rs`](../src/config_cmds.rs) | Global `config.toml`, GitHub token resolution, `fledge config` | +| [`envelope.rs`](../src/envelope.rs) | The `{schema_version, ...}` JSON envelope helpers (`resource`, `action`, `versioned`) | +| [`github.rs`](../src/github.rs) | GitHub REST GET helper, API and remote base URLs, `ensure_git_repo` | +| [`introspect.rs`](../src/introspect.rs), [`doctor.rs`](../src/doctor.rs) | Command tree as JSON (core commands only), environment diagnostics | +| [`utils.rs`](../src/utils.rs), [`prompts.rs`](../src/prompts.rs), [`spinner.rs`](../src/spinner.rs), [`versioning.rs`](../src/versioning.rs), [`meta.rs`](../src/meta.rs) | Non-interactive state, prompts, spinner, version parsing, project metadata | + +### 3.2 Command dispatch + +`main()` resets `SIGPIPE` to the default on Unix (so `fledge ... | head` exits quietly), then +`run()` reads `FLEDGE_NON_INTERACTIVE`, parses with clap, applies `--non-interactive` (`--ni`), +and matches on the `Commands` enum. Each arm converts the clap subcommand into the handler's +own action enum (`spec_action_from`, `lane_action_from`, `plugin_action_from`, ...) and calls it. +Any verb clap does not know lands in `Commands::External`, which is how plugin commands run. + +```mermaid +flowchart TD + argv["fledge ARGS"] --> parse["read FLEDGE_NON_INTERACTIVE,
clap parses Cli, apply --ni"] + parse -->|"bad flag or missing positional"| usage["clap usage error, exit 2"] + parse --> match{"Commands variant"} + match -->|"no subcommand"| help["print help, exit 0"] + match -->|"core command"| handler["module handler
run, lanes, work, release, plugins, ..."] + match -->|"External(args)"| resolve{"plugins/bin/fledge-NAME
or fledge-NAME on PATH?"} + resolve -->|"found"| prun["plugin::run with a Run action"] + resolve -->|"not found"| unk["unrecognized subcommand"] + handler --> result{"Result"} + prun --> result + unk --> fail + result -->|"Ok"| ok["exit 0"] + result -->|"Err"| fail["error on stderr, exit 1"] +``` + +Two extra routing rules: + +- `fledge lanes NAME` (no `run`) is accepted: `LaneSubcommand::External` is parsed by + `parse_external_lane_args` into the same `Run` action, honoring `--dry-run`, `--json` and `--from`. +- Aliases: `lane` for `lanes`, `plugin` for `plugins`, `template` for `templates`. + +Because plugins are dispatched as external subcommands, `fledge introspect --json` shows the +core surface only. `fledge plugins list --json` is the source for plugin verbs. + +### 3.3 Output contract + +Every `--json` output is built with [`envelope.rs`](../src/envelope.rs): either +`{schema_version, : [...]}` (list and query commands) or +`{schema_version, action: "", ...}` (everything else). `schema_version` is tracked per +command by constants such as `RUN_TASK_SCHEMA` and `LANES_RUN_SCHEMA`. Errors always go to +stderr as plain text, and the exit code is the contract (0 ok, 1 runtime error, 2 usage +error). Progress from hooks and plugin UI goes to stderr so a `--json` stdout stays parseable. +[`AGENTS.md`](../AGENTS.md) documents each envelope. + +## 4. Key flows + +### 4.1 `fledge run ` + +`fledge.toml` is read from the **current directory only** (no upward search). Without it, +fledge detects the project type from marker files and synthesizes tasks. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Main as main.rs + participant Run as run.rs + participant Deps as deps.rs + participant Shell as sh -c or cmd /C + User->>Main: fledge run test -- --release + Main->>Run: run(RunOptions) + alt fledge.toml in cwd + Run->>Run: parse [tasks], bail if empty + else no fledge.toml + Run->>Run: detect_project_type, auto_detect_tasks + end + Run->>Run: unknown task name? bail with the available list + Run->>Deps: walk_task_graph("test") + loop each task, dependencies first, each node once + Deps->>Deps: cycle check on the recursion stack, depth at most 1000 + Deps->>Run: on_ready(name) + Run->>Shell: spawn cmd in dir with env, pass-through args only for "test" + alt --json + Shell-->>Run: captured stdout, stderr, exit code + Run-->>User: one run_task envelope for this task + else human mode + Shell-->>User: inherited stdio, live output + end + opt non-zero exit + Run-->>Main: Err, the walk stops + end + end + Main-->>User: exit 0, or error on stderr and exit 1 +``` + +Details, all from [`src/run.rs`](../src/run.rs) and [`src/deps.rs`](../src/deps.rs): + +- **Task forms.** `name = "cmd"` (short) or a table with `cmd`, `deps`, `env`, `dir` and + `description`. `dir` is joined to the project directory. +- **Order.** `walk_task_graph` is a two-set DFS. `in_progress` (the recursion stack) detects a + real cycle and reports it as an ordered walk (`a → b → a`). `completed` makes a shared + dependency in a diamond run once. The walk is bounded at `MAX_TASK_DEPTH = 1000` so a + pathological chain errors instead of overflowing the stack. A dependency that is not defined + fails when the walk reaches it. +- **Pass-through args** (after `--`) apply to the named task only. On POSIX they are passed as + real positional parameters (`sh -c ' "$@"' fledge `), so they are never spliced + into the command string. If the command already references `$1`, `$@` or `$*`, `"$@"` is not + appended again. On Windows they are appended as separate argv entries. +- **`--json`** captures each task with `Command::output()` and prints one `run_task` envelope per + executed task, so a task with `deps` yields a stream of JSON objects, not one document. +- **`--stream`** (with `--json`) mirrors the child's output live to **stderr** while still + capturing it for the envelope, and lets the child keep stdin. `SIGPIPE` is ignored only while + mirroring. A broken mirror produces a warning, not a failed run. +- **Auto-detection** (first match wins): `Cargo.toml` rust, `package.json` node (runner from the + lockfile: bun, yarn, pnpm, else npm), `go.mod`, `pyproject.toml`/`setup.py`, `Gemfile`, + `build.gradle(.kts)`, `pom.xml`, `Package.swift`. `fledge run --init` writes a starter + `fledge.toml` from `task_defaults`. + +### 4.2 `fledge lanes run ` + +A lane is an ordered list of steps in `fledge.toml`. Lanes add ordering, parallel groups, +conditions, timeouts, retries and failure policy on top of tasks. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Lanes as lanes/mod.rs + participant Exec as lanes/execute.rs + participant Deps as deps.rs + participant Shell as sh -c or cmd /C + User->>Lanes: fledge lanes run ci + Lanes->>Lanes: load fledge.toml, merge .fledge/lanes/*.toml without overwriting + Lanes->>Lanes: validate_lane, every task ref exists and has no dep cycle + Lanes->>Lanes: resolve --from, a 1-based index or a step name + alt --dry-run + Lanes-->>User: plan, or the dry-run envelope + else run + Lanes->>Exec: execute_lane(lane, tasks, cwd) + loop each step in order + alt before --from, or its when condition is false + Exec->>Exec: record as skipped + else run the step, up to retries + 1 attempts + alt task step + Exec->>Deps: walk deps, run each task once + Deps->>Shell: task cmd with env and dir + else inline run step + Exec->>Shell: run string in the project dir + else parallel step + Exec->>Shell: one scoped thread per item + end + Shell-->>Exec: exit status, or a kill at the step deadline + end + opt step still failing after the last attempt + alt fail_fast (default true) + Exec-->>User: stop, lane failed at step N + else fail_fast = false + Exec->>Exec: record the failure, continue + end + end + end + Exec-->>User: summary or lane envelope, exit 1 if any step failed + end +``` + +Step semantics ([`src/lanes/mod.rs`](../src/lanes/mod.rs), [`src/lanes/execute.rs`](../src/lanes/execute.rs)): + +- **Step kinds.** `"task"` (a bare string), `{ task = "x", ... }`, `{ run = "cmd", ... }`, and + `{ parallel = ["a", { run = "cmd" }], ... }`. The full forms accept `when`, `timeout` + (seconds), `retries` and `retry_delay` (seconds, default 1). +- **`when`** is a comma-separated list, all of which must hold: `VAR` (set and non-empty), + `VAR=value`, `!VAR` (unset or empty) and `!VAR=value`. It reads the process environment. +- **Timeouts.** With a `timeout`, the child is spawned in its own process group (Unix) or a Job + Object (Windows), polled every 50 ms, and the whole tree is killed at the deadline. +- **Parallel groups** use `std::thread::scope`, one thread per item. All items run to completion + and their errors are collected into one step error. +- **Output.** In human mode task output is inherited. Under `--json` child stdout and stderr are + discarded and only the lane envelope is printed. `release --pre-lane --json` uses a silent + variant (`execute_lane_silent`) that prints nothing at all and always starts from step 1. +- **Imports.** `fledge lanes import owner/repo[/path][@ref]` fetches the remote `fledge.toml` + through the GitHub contents API and writes the lanes and missing tasks to + `.fledge/lanes/-.toml`. Existing names are never overwritten. A non-official + source needs a confirmation, or `--yes` in non-interactive mode. +- **`fledge watch`** re-invokes `lanes::run` or `run::run` in human mode on each debounced change. + +### 4.3 Plugin install + +`fledge plugins install ` accepts `owner/repo[@ref]`, any git URL, or a local path. +Install is transactional: every failure after the plugin directory is created removes it +again, and the registry is only written at the very end. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Inst as plugin/install.rs + participant Trust as trust.rs + participant Git as git + participant FS as config_dir/fledge + User->>Inst: fledge plugins install owner/repo@v1.0.0 + Inst->>Inst: parse source, local path or git, reject . and .. segments + Inst->>Trust: determine_trust_tier(source) + Trust-->>Inst: local, official, team or unverified + opt no --force or --yes, and interactive + Inst->>User: install this plugin? + end + alt local path + Inst->>FS: symlink plugins/NAME to the source dir, or copy with --copy + else git source + Inst->>Git: clone into plugins/NAME, depth 1 unless a ref is pinned + opt ref pinned + Inst->>Git: checkout ref + end + end + Inst->>FS: read plugins/NAME/plugin.toml + Inst->>Inst: unverified tier asking for exec or network? roll back and refuse + opt capabilities or hooks declared + Inst->>User: list them and ask to grant, auto-granted by --force + end + Inst->>Inst: run the build hook, or auto-build (cargo, swift, go, npm) + opt runtime = "wasm" + Inst->>Inst: pre-compile each module to a .cwasm cache + end + Inst->>FS: symlink plugins/bin/fledge-CMD for every declared command + opt post_install hook + Inst->>Inst: run it, a failure removes the links and the dir + end + Inst->>FS: write plugins.toml, temp file then rename + Inst-->>User: installed commands, or the install envelope +``` + +Notes from [`src/plugin/install.rs`](../src/plugin/install.rs) and +[`src/plugin/mod.rs`](../src/plugin/mod.rs): + +- `--defaults` installs `DEFAULT_PLUGINS` (currently `fledge-plugin-github@v0.4.0`, + `fledge-plugin-deps@v0.2.0`, `fledge-plugin-metrics@v0.2.1`), continues past individual + failures, and reports a per-plugin summary. +- Non-interactive mode implies `--force`, which both skips the confirmation and auto-grants the + requested capabilities (with a warning on stderr). +- `link_commands` rejects command names containing `/`, `\`, NUL, or a leading `.` or `-`, and + binaries whose path contains `..` or canonicalizes outside the plugin directory. +- The registry records the granted capabilities for protocol plugins and for any plugin with + hooks. Lifecycle hooks only fire when `exec` is recorded there (see [Hooks](#5-hooks)). +- `fledge plugins update` does `git pull --ff-only` for an unpinned plugin, then rebuilds and + relinks. A pinned plugin stays on its tag and the newer tag is reported, except under + `update --defaults`, which checks out the newest tag and records it as the new pin. A new + manifest that escalates capabilities is re-prompted and re-checked against the tier gate. + `post_install` is not re-run on update. + +### 4.4 Plugin invocation + +When clap does not recognize a verb, `main.rs` asks `resolve_plugin_command(name)` for +`plugins/bin/fledge-NAME` (plus `.exe`, `.bat`, `.cmd` on Windows), then for `fledge-NAME` on +`PATH`, git-style. The manifest's `protocol` and `runtime` then pick one of three execution modes. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Main as main.rs + participant RunP as plugin/ (mod.rs, run_plugin.rs) + participant Proto as protocol/mod.rs + participant Wasm as plugin/wasm.rs + participant Proc as plugin process + User->>Main: fledge deps --audit + Main->>RunP: resolve_plugin_command("deps") + RunP-->>Main: path to fledge-deps + Main->>RunP: run_plugin_cmd("deps", args) + RunP->>RunP: registry entry and plugin.toml give protocol, runtime, capabilities + alt no protocol declared + RunP->>Proc: exec binary with args and FLEDGE_PLUGIN_DIR, inherited stdio + Proc-->>User: output directly + else protocol fledge-v1, native + RunP->>Proto: run_protocol_plugin + Proto->>Proc: spawn with piped stdin and stdout, stderr inherited + Proto->>Proc: init message with args, project, plugin, fledge version, capabilities + loop one JSON message per stdout line until EOF + Proc->>Proto: prompt, confirm, select, progress, log, output, store, load, exec, metadata + Proto->>Proto: check the granted capability for store, load, exec, metadata + Proto-->>Proc: response line on stdin, or a denial + end + else protocol fledge-v1, runtime wasm + RunP->>Wasm: run_wasm_plugin + Wasm->>Wasm: load cached module, WASI preopens from the filesystem capability + Wasm->>Wasm: run with fuel limit, 60 s epoch deadline, 256 MB memory cap + end + RunP-->>Main: Ok, or Err with the plugin exit code + Main-->>User: exit status +``` + +The `fledge-v1` protocol ([`src/protocol/`](../src/protocol), +[`specs/plugin/plugin-protocol.spec.md`](../specs/plugin/plugin-protocol.spec.md)): + +- **Transport.** Newline-delimited JSON. fledge writes an `init` message, then answers the + plugin's requests with `{type: "response", id, value}`. Malformed lines are skipped with a + warning. The run ends when the plugin closes stdout and exits. +- **UI messages** (`prompt`, `confirm`, `select`, `multi_select`, `progress`, `log`, `output`) + are rendered by fledge, so plugins get the same prompts and progress bars as core. +- **Capability-gated messages.** `exec` runs `sh -c` with a `cwd` confined to the project or + plugin directory, a 30 s default and 300 s maximum timeout, and 10 MB per output stream. + `store`/`load` persist to `plugins/NAME/state.json` under a file lock (64 KB per value, 1 MB + total, 256 keys). `metadata` answers keys such as `fledge_config`, `git_tags` and `git_status`. + Denied requests get a well-formed denial (for `exec`, exit code 126) instead of hanging. +- **The init message** carries the project name, root, language and git context. Credentials + embedded in an `https://user:token@...` remote URL are stripped first + ([`src/protocol/detect.rs`](../src/protocol/detect.rs)). +- **WASM plugins** ([`src/plugin/wasm.rs`](../src/plugin/wasm.rs)) get the same init message and + message handling through host functions in the `fledge` import module (`send`, `recv`, `exit`, + and `exec`, `store_set`, `store_get`, `metadata` linked only when granted). `filesystem = + "project"` preopens the project read-only at `/project`. `"plugin"` adds a read-write + `/plugin` data dir. `network = true` inherits the host network. Interactive UI messages are + not supported in WASM. The runtime is a default cargo feature (`wasm`). + +### 4.5 Ship: `work`, GitHub, and `release` + +`fledge work` wraps the branch, commit and push loop with guardrails. PR, issue and CI +commands are **not in core**: since the v0.15 tight-core refactor they live in +`fledge-plugin-github`, reached as `fledge github ...` through plugin dispatch (4.4). +`fledge work pr` now prints a pointer to `fledge github prs create` and exits 1. + +```mermaid +sequenceDiagram + autonumber + actor User + participant Work as work.rs + participant Hooks as plugin lifecycle hooks + participant Git as git + participant GH as fledge github plugin + User->>Work: fledge work start add-search --issue 42 + Work->>Git: status --porcelain, must be clean + Work->>Work: branch from branch_format, e.g. author/feat/42-add-search + Work->>Git: checkout -b BRANCH BASE + Work->>Hooks: post_work_start, errors ignored + User->>Work: fledge work commit -m "add search index" + Work->>Git: add -A only with --all, then commit with a conventional prefix + User->>Work: fledge work push + Work->>Work: refuse the default branch, refuse 0 commits ahead + Work->>Hooks: pre_push, an error aborts the push + Work->>Git: push -u origin BRANCH, --force-with-lease only with --force + User->>GH: fledge github prs create --fill + GH-->>User: PR number and URL +``` + +- **Branch names** come from `[work].branch_format` (default `{author}/{type}/{name}`), with + `{issue}` available and `--issue N` prefixing the name as `N-name`. `{author}` comes from + `defaults.author` in the global config or git `user.name`. Types are checked against + `[work].branch_types` or the built-in list, unless `--prefix` is used. +- **Commits.** The type is `--type`, else inferred from the branch prefix, else + `[work].default_type`. A message that already has a conventional prefix (including `Add:`, + `Update:`, `Remove:`) is kept as is. `--ai` drafts the message from the staged diff. +- **`work status --json`** (schema v2) reports branch, default branch, ahead, behind and dirty count. + +`fledge release ` cuts a release locally. It does not publish binaries: that happens in CI +when the tag lands (section 7). + +```mermaid +sequenceDiagram + autonumber + actor User + participant Rel as release/mod.rs + participant Lanes as lanes + participant Files as version files + participant Git as git + User->>Rel: fledge release minor --pre-lane ci --push + Rel->>Git: preflight, .git exists and status is clean unless --allow-dirty + opt --pre-lane + Rel->>Lanes: run the lane, silently under --json + end + Rel->>Rel: target version, explicit or a bump of plugin.toml, the manifest, or the latest tag + alt --dry-run + Rel-->>User: plan or dry-run envelope, nothing written + else release + Rel->>Files: bump Cargo.toml, package.json, pyproject.toml and [release].files + Rel->>Git: log PREV_TAG..HEAD, prepend a CHANGELOG.md entry + Rel->>Git: add bumped files and CHANGELOG.md, commit "chore: release vX.Y.Z" + opt unless --no-tag + Rel->>Git: tag -a vX.Y.Z + end + opt --push + Rel->>Git: push, then push origin vX.Y.Z + end + Rel-->>User: summary or release envelope + end +``` + +`--no-bump` makes a tag-only release and `--no-changelog` skips the changelog. Files listed in +`[release].files` must stay inside the project directory. In each one, the first match of +`version = "X.Y.Z"` (or `version: X.Y.Z`) is rewritten. This repo lists `flake.nix` there. +Gitignored files are filtered out before `git add`. + +## 5. Hooks + +fledge has two hook families. **Plugin hooks** are declared in a plugin's `plugin.toml` +`[hooks]` table. **Template hooks** (`post_create`) are declared in a template's +`template.toml`. Plugin lifecycle hooks are global: they fire for every installed plugin that +declares the event, whichever project you are in. + +```mermaid +flowchart LR + subgraph ph["plugin.toml [hooks]"] + build["build"] + post_install["post_install"] + post_remove["post_remove"] + pre_init["pre_init"] + post_work_start["post_work_start"] + pre_push["pre_push"] + end + subgraph th["template.toml [hooks]"] + post_create["post_create"] + end + install["plugins install"] --> build + install --> post_install + update["plugins update"] --> build + remove["plugins remove"] --> post_remove + tinit["templates init"] --> pre_init + tinit --> post_create + wstart["work start"] --> post_work_start + wpush["work push"] --> pre_push +``` + +| Hook | Fires | On failure | Gate | +|---|---|---|---| +| `build` | install and update, in place of the auto-detected build | install rolls back, update fails | shown at install for approval | +| `post_install` | after commands are linked, before the registry is written | links and plugin dir are removed | shown at install for approval | +| `post_remove` | during `plugins remove`, before the plugin dir is deleted | removal stops (see section 9) | none beyond the original install | +| `pre_init` | start of `templates init` | ignored | registry entry must have `exec` granted | +| `post_work_start` | after `work start` creates the branch | ignored | registry entry must have `exec` granted | +| `pre_push` | before `work push` runs `git push` | the push is aborted | registry entry must have `exec` granted | +| `post_create` (template) | after a template is rendered, each command through `sh -c` in the new project | `templates init` fails and shows the hook's output | local templates: `--yes` or non-interactive auto-confirms. Remote templates: only `--trust-hooks` or `FLEDGE_TRUST_HOOKS=1`, otherwise skipped when non-interactive | + +How a plugin hook runs ([`src/plugin/run_plugin.rs`](../src/plugin/run_plugin.rs), `run_hook`): +if the hook value names a file inside the plugin directory it is executed (after checking it +does not escape the directory), otherwise it is split with `shell_words` and executed directly, +not through a shell. The working directory is the plugin directory, and the environment adds +`FLEDGE_PLUGIN_DIR` and `FLEDGE_REPO_ROOT` (the git toplevel of the invoking repo, falling back +to the current directory). Hook progress is written to stderr. + +## 6. Data + +fledge has no database. Its state is a handful of TOML and JSON files. + +### 6.1 `fledge.toml` (per project) + +One file configures tasks, lanes, `work` and `release`. Each module deserializes only the tables +it needs (`run.rs`, `lanes/mod.rs`, `work.rs`, `release/bump.rs`), so unknown tables are ignored. +Imported lane files in `.fledge/lanes/*.toml` have the same shape. + +```mermaid +erDiagram + FLEDGE_TOML ||--o{ TASK : "tasks table" + FLEDGE_TOML ||--o{ LANE : "lanes table" + FLEDGE_TOML ||--o| WORK : "work table" + FLEDGE_TOML ||--o| RELEASE : "release table" + LANE ||--|{ STEP : "steps" + STEP }o--o{ TASK : "references" + TASK }o--o{ TASK : "deps" + IMPORTED_LANES ||--o{ LANE : "adds, never overwrites" + IMPORTED_LANES ||--o{ TASK : "adds, never overwrites" + TASK { + string cmd "required, or the whole value in short form" + string_list deps + string_map env + string dir "relative to the project dir" + string description + } + LANE { + string description + bool fail_fast "default true" + } + STEP { + string kind "task, run or parallel" + string when "env condition" + int timeout "seconds" + int retries + int retry_delay "seconds, default 1" + } + WORK { + string branch_format + string default_type "default feat" + string_list branch_types + } + RELEASE { + string_list files "extra version files" + } +``` + +The full reference, with examples, is on the docs site under *fledge.toml* and in +[`specs/run/run.spec.md`](../specs/run/run.spec.md) and +[`specs/lanes/lanes.spec.md`](../specs/lanes/lanes.spec.md). + +### 6.2 Plugin manifest and registry + +```mermaid +classDiagram + direction LR + class PluginManifest { + PluginMeta plugin + List~PluginCommand~ commands + PluginHooks hooks + PluginCapabilities capabilities + } + class PluginMeta { + String name + String version + String description + String author + String protocol + String runtime + } + class PluginCommand { + String name + String binary + String description + } + class PluginHooks { + String build + String post_install + String post_remove + String pre_init + String post_work_start + String pre_push + } + class PluginCapabilities { + bool exec + bool store + bool metadata + String filesystem + bool network + } + class PluginsRegistry { + List~PluginEntry~ plugins + } + class PluginEntry { + String name + String source + String version + String installed + List~String~ commands + String pinned_ref + PluginCapabilities capabilities + String runtime + } + PluginManifest --> PluginMeta + PluginManifest --> PluginCommand + PluginManifest --> PluginHooks + PluginManifest --> PluginCapabilities + PluginsRegistry --> PluginEntry + PluginEntry ..> PluginCapabilities : granted copy +``` + +`plugin.toml` lives in each plugin's directory and is re-read on every invocation. +`plugins.toml` is the registry of what is installed and what was granted. `protocol` is +`"fledge-v1"` or absent. `runtime` is `"wasm"` or absent (`"native"` is accepted by validation). +`filesystem` is `"none"`, `"project"` or `"plugin"`. + +### 6.3 File locations + +`config_dir` is the platform config directory from the `dirs` crate: `~/Library/Application +Support` on macOS, `~/.config` on Linux, `%APPDATA%` on Windows. + +| Path | Contents | Written by | +|---|---|---| +| `./fledge.toml` | tasks, lanes, work, release | the user, `run --init`, `lanes init` | +| `./.fledge/lanes/-.toml` | imported lanes and tasks, headed `# Imported from ...` | `lanes import` | +| `config_dir/fledge/config.toml` | global config (author, GitHub token and org, AI providers, trust orgs and users, template repos). `FLEDGE_CONFIG_DIR` overrides the directory for this file only | `fledge config`, `ai use` | +| `config_dir/fledge/plugins.toml` | plugin registry | install, update, remove (atomic rename) | +| `config_dir/fledge/plugins/NAME/` | plugin checkout or symlink, `plugin.toml`, protocol `state.json`, WASM `data/`, `.cwasm` caches | install, update, the plugin itself | +| `config_dir/fledge/plugins/bin/fledge-CMD` | symlinks to plugin command binaries (a copy on Windows if symlinks are not allowed) | install, update | +| `cache_dir/fledge/templates//` | cached remote template clones | `templates init owner/repo` | + +## 7. Runtime and deployment + +fledge is a local CLI. Nothing runs as a service. Distribution is binaries and crates. + +```mermaid +flowchart LR + rel["fledge release X --push
pushes tag vX.Y.Z"] --> test + subgraph wf["release.yml, on v* tags"] + test["test gate
fmt, clippy, test"] --> build["build matrix
Linux musl x86_64 and aarch64,
macOS x86_64 and aarch64, Windows x86_64"] + build --> ghrel["GitHub Release
binaries and .sha256 sidecars"] + ghrel --> pub["cargo publish,
then read crates.io back"] + end + ghrel --> brew["post-release-formula.yml
updates CorvidLabs/homebrew-tap"] + ghrel --> dl["install.sh and the
CorvidLabs/fledge action"] + pub --> crates["cargo install fledge"] +``` + +- **CI** ([`.github/workflows/ci.yml`](../.github/workflows/ci.yml)) runs on every PR and push to + `main` with no path filter, because its jobs are required checks: `cargo test` on Linux, macOS + and Windows, fmt and clippy, `cargo audit`, an integration job that builds fledge and runs + `fledge lanes run check` and the default plugins, the SpecSync action plus + `specsync change check`, and `hi check`. +- **Trust gate** ([`.github/workflows/trust.yml`](../.github/workflows/trust.yml)) runs the + CorvidLabs Trust action on PRs and `main`. Locally the same gate is `fledge trust verify`, + whose lifecycle command is `fledge lanes run verify-native` ([`.trust.toml`](../.trust.toml)). +- **Release** ([`.github/workflows/release.yml`](../.github/workflows/release.yml)) is described + above. The publish job needs the `CARGO_REGISTRY_TOKEN` repository secret and fails loudly + without it. Release steps for maintainers are in [`CONTRIBUTING.md`](../CONTRIBUTING.md). +- **GitHub Action** ([`action.yml`](../action.yml)) installs a released binary in CI and verifies + it against the `.sha256` sidecar. +- **GitHub Pages** ([`.github/workflows/pages.yml`](../.github/workflows/pages.yml)) builds the + Astro project in [`site/`](../site). That site is now **redirect-only**: every route redirects + to the CorvidLabs hub, where the user docs live + ([`site/src/data/hub.ts`](../site/src/data/hub.ts), + [`site/src/components/Redirect.astro`](../site/src/components/Redirect.astro)). The Pages + build still publishes the atlas spec-coverage badges used by the README. This HLD is not + published there; read it here on GitHub, which renders the Mermaid diagrams. +- **Build features.** `wasm` (Wasmtime and WASI) is on by default. `--no-default-features` builds + without it, and WASM plugins then fail with a clear error. + +## 8. Security and trust boundaries + +The user's own project and config are trusted. Everything fetched from elsewhere is classified +by trust tier before it can run, and the prompts and gates scale with the tier. +[`SECURITY.md`](../SECURITY.md) is the full policy. + +```mermaid +flowchart TD + src["source string"] --> isLocal{"local path?
./ ../ / . .. or local:"} + isLocal -->|"yes"| local["local"] + isLocal -->|"no"| norm["strip github.com URL prefix and .git"] + norm --> trav{"a . or .. path segment?"} + trav -->|"yes"| unv["unverified"] + trav -->|"no"| owner{"owner"} + owner -->|"CorvidLabs"| off["official"] + owner -->|"built-in team list,
or trust.orgs / trust.users"| team["team"] + owner -->|"anyone else"| unv + unv --> refuse["plugin asking for exec or network:
install refused"] + unv --> warn["lanes, hooks: warning and confirmation"] + off --> allowed["install proceeds,
capabilities still shown"] + team --> allowed + local --> allowed +``` + +- **Trust tiers** ([`src/trust.rs`](../src/trust.rs)) apply to plugins, imported lanes and + search results. `fledge config add trust.orgs ` promotes an owner to `team`. Install + rejects `.` and `..` segments outright so a source like `CorvidLabs/../x/y` cannot borrow the + official tier (git would collapse the path while classification keyed on the first segment). +- **Native plugins are not sandboxed.** A native plugin is arbitrary code running as the user. + Capabilities gate only the `fledge-v1` protocol requests (`exec`, `store`, `metadata`), not + what the process itself can do. Granting `exec` means unrestricted shell access. +- **WASM plugins are sandboxed** by Wasmtime: no filesystem, network or exec unless granted, + fuel and a 60 s wall clock bound compute, and linear memory is capped at 256 MB. Compiled + `.cwasm` caches are validated against the SHA-256 of the `.wasm` and the Wasmtime version. +- **Hooks and tasks run shell code.** Lane imports, remote template `post_create` hooks and plugin + hooks all need explicit consent. Non-interactive mode never silently authorizes remote template + hooks (that needs `--trust-hooks`). It does auto-confirm plugin installs and auto-grant their + capabilities, so agents should install only sources they would install by hand. The + unverified-tier refusal of `exec` and `network` still applies with `--force`. +- **Secrets.** The GitHub token is resolved from `FLEDGE_GITHUB_TOKEN`, then `GITHUB_TOKEN`, then + `github.token` in `config.toml`, then `gh auth token`. It is sent as an `Authorization` header + (never embedded in a URL) for GitHub API calls and github.com clones. AI keys come from + `_API_KEY` or `ai..api_key`. `fledge config` knows which keys are secret + (`Config::is_secret_key`). +- **Supply chain.** Release binaries ship with `.sha256` sidecars. The GitHub Action verifies + them. `install.sh` downloads the release binary over HTTPS but does not check the sidecar. + `cargo audit` runs in CI. + +## 9. Failure modes and limits + +| Situation | Behavior | +|---|---| +| No `fledge.toml` and an unrecognized project | `run` fails with a hint to run `fledge run --init`. `lanes` always needs a `fledge.toml` | +| `fledge.toml` has `[tasks]` but it is empty | `run` fails with "No tasks defined" | +| Swift project with no `fledge.toml` | detected as `swift`, but `auto_detect_tasks` has no Swift entry, so no tasks are listed. `run --init` still writes Swift defaults | +| Task dependency cycle | error naming the ordered cycle. `lanes run` checks every step before starting. `run` finds it during the walk | +| Dependency chain deeper than 1,000 | error instead of a stack overflow (`MAX_TASK_DEPTH`) | +| Task or step exits non-zero | `run` stops at that task. A lane stops (`fail_fast = true`) or records it and continues. Exit 1 either way | +| Step `timeout` reached | the whole process tree is killed, the step fails with "step timed out", retries apply | +| Lane under `--json` | child output is discarded, so diagnose with a human-mode run | +| `plugins.toml` unreadable or mid-write | one 50 ms retry, then a warning. Plugin verbs resolve as unrecognized for that invocation | +| Plugin install fails at any step | the plugin dir (and links, after linking) are removed. The registry is untouched | +| Failing `post_remove` hook | `plugins remove` returns the error after the command links were already deleted, leaving the plugin dir and registry entry in place | +| Plugin requires an unknown `protocol` | refused with "update fledge" | +| Protocol plugin sends malformed JSON | the line is skipped with a warning | +| Plugin `exec` request | 30 s default, 300 s maximum, 10 MB per stream | +| Plugin state store | 256-byte keys, 64 KB values, 1 MB total, 256 keys | +| WASM plugin runs too long or too big | fuel exhaustion, a 60 s epoch deadline, or the 256 MB memory limit terminates it | +| GitHub API | 30 s timeout per request. Status errors (for example rate limits) become readable messages | +| LLM call | timeout from `FLEDGE_AI_TIMEOUT` or provider config. In a multi-model review a failing slot reports its own `error` and the others still return | +| Prompt needed in non-interactive mode | confirmations behave as `--yes`. Prompts without a default fail fast with the flag to pass | +| Broken pipe on stdout | fledge exits quietly (default `SIGPIPE`), except while `--stream` is mirroring | + +## 10. Decisions + +The design decisions live next to each module's spec as `specs//context.md`. The ones +that shape the architecture: + +- **Tight core, plugins for the rest.** GitHub, dependency audits and metrics moved out of core + in v0.15 and came back as the default plugins (`DEFAULT_PLUGINS` in + [`src/plugin/mod.rs`](../src/plugin/mod.rs)). +- **Git-style plugin commands.** `fledge NAME` resolves to `fledge-NAME`, with symlinks in + `plugins/bin/` so PATH is never modified ([`specs/plugin/context.md`](../specs/plugin/context.md)). +- **One file for tasks and lanes.** Lanes share `fledge.toml`, parallel groups use threads rather + than async, and imports never overwrite ([`specs/lanes/context.md`](../specs/lanes/context.md)). +- **One graph walk.** `run`, `lanes run` and `lanes validate` share `walk_task_graph`, so they + cannot disagree about diamonds, cycles or depth ([`specs/run/context.md`](../specs/run/context.md)). +- **stdout belongs to envelopes.** `--stream` mirrors to stderr and hook progress goes to stderr, + so `--json` output stays machine-readable ([`specs/run/context.md`](../specs/run/context.md)). +- **Sandbox by default for WASM.** Capabilities are opt-in and prompted + ([`specs/plugin/context-wasm.md`](../specs/plugin/context-wasm.md), + [`specs/plugin/plugin-wasm.spec.md`](../specs/plugin/plugin-wasm.spec.md)). +- **AI over plain HTTP.** Providers moved from a CLI to HTTP in 1.5.0 ([`MIGRATION.md`](../MIGRATION.md)). +- **Specs are the contract.** SpecSync governs `src/` and `templates/` + ([`.specsync/config.toml`](../.specsync/config.toml)), and the change history is in + [`CHANGELOG.md`](../CHANGELOG.md). + +## 11. Glossary + +| Term | Meaning | +|---|---| +| Task | A named shell command in `[tasks]`, optionally with deps, env and dir | +| Lane | A named, ordered pipeline of steps in `[lanes.NAME]` | +| Step | One entry in a lane: a task ref, an inline `run`, or a `parallel` group | +| Envelope | The `{schema_version, ...}` JSON object a `--json` command prints | +| Native plugin | An executable plugin, run as the user, unsandboxed | +| Protocol plugin | A plugin with `protocol = "fledge-v1"`, which talks JSON lines with fledge | +| WASM plugin | A protocol plugin with `runtime = "wasm"`, run inside Wasmtime | +| Capability | A permission a plugin requests (`exec`, `store`, `metadata`, `filesystem`, `network`) and the user grants at install | +| Trust tier | `local`, `official`, `team` or `unverified`, derived from a source's path or owner | +| Lifecycle hook | A plugin hook fired by a core command (`pre_init`, `post_work_start`, `pre_push`) | +| Spec | A `specs//*.spec.md` contract, validated by SpecSync | +| hi | Human Intent: the plain-language criteria in [`hi/`](../hi) |