Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ empty and is meant to stay empty.

## Workspace layout — where a change goes

Twenty-three crates, every one under the `crates/` directory (`crates/stella-core`,
Twenty-four crates, every one under the `crates/` directory (`crates/stella-core`,
`crates/stella-cli`, …; the two bench members stay under `bench/`). The
one-sentence rule of thumb below routes you to the right one; **each crate's
own `README.md`** (linked from the table) then covers its boundary, layout,
Expand All @@ -494,6 +494,7 @@ the files you must plan around (see below).
| Change REPL rendering / panels / keybindings | [`stella-tui`](crates/stella-tui/README.md) | Pure-fold ratatui REPL — the Command Deck, the default interactive shell on a TTY. |
| Touch shared types crossing a crate boundary | [`stella-protocol`](crates/stella-protocol/README.md) | **Zero logic, zero I/O — types only.** |
| Resolve where `~/.stella` is — home dir, stella home, the user-tier data dir | [`stella-home`](crates/stella-home/README.md) | **A leaf with NO dependencies at all**, which is what lets `stella-store`, `stella-observatory`, `stella-cli`, `stella-model` and `stella-tools` all share it (the observatory must not link the store). Every resolver has a pure `resolve_*` half that reads no environment. |
| Parse/validate a plugin's manifest — its declared say in the turn loop (participation grades, hook grants, `[oracle]`, `[subloop]`) | [`stella-plugin`](crates/stella-plugin/README.md) | **A leaf with NO workspace-crate dependencies** (#3245 slice A) — pure parsing/validation over borrowed text. The engine never learns plugins exist: the host binds these grants to the engine's gates, and `stella-core` must never depend on it. |
| Decide whether a human is present to see/answer a mid-run prompt | [`stella-tty`](crates/stella-tty/README.md) | **A leaf with NO dependencies at all** (#3036) — one pure `human_can_answer(interactive_output, stdin_is_terminal, prompt_is_visible)`, which is what lets `stella-cli`'s approval prompts and `stella-model`'s credential prompt share one derivation without `stella-model` depending on `stella-cli` (invariant 1). |
| Emit a diagnostic — a record explaining *why* the program did something | [`stella-diag`](crates/stella-diag/README.md) | **A leaf: `serde` only, so anything may depend on it.** Field values cannot hold a `String`, a `Path`, or model output — that is a compile error, not a review question. Design: [`docs/spec/diagnostics.md`](docs/spec/diagnostics.md). |
| Compute a line-oriented unified diff (`@@` hunks, git's exact shape) | [`stella-diff`](crates/stella-diff/README.md) | **A leaf with NO dependencies at all** (#1511) — pure functions over borrowed strings, which is what lets [`stella-observatory`](crates/stella-observatory/README.md) and [`stella-cli`](crates/stella-cli/README.md) share one differ without costing the observatory its isolation. |
Expand Down Expand Up @@ -566,7 +567,7 @@ a plan needs and the part that rarely changes:
| `stella-store` | `src/tests.rs`, `src/lib.rs`, `src/usage.rs` |
| `stella-tui` | `src/deck_ui.rs`, `src/views/engine.rs`, `src/views/session.rs`, `src/deck_render.rs` |

The other seventeen crates carry no god files — keep it that way. Each crate's
The other eighteen crates carry no god files — keep it that way. Each crate's
README repeats its own list under "God files — do not add lines", so the
constraint is in view wherever planning starts.

Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"crates/stella-diff",
"crates/stella-embed",
"crates/stella-home",
"crates/stella-plugin",
"crates/stella-tty",
"crates/stella-protocol",
"crates/stella-core",
Expand Down
22 changes: 22 additions & 0 deletions crates/stella-plugin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "stella-plugin"
description = "The plugin manifest: parsing and validation of a plugin's declared say in the turn loop — participation grades, hook grants, requirements, oracle, and subloop — pure functions over borrowed text, no I/O."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true

# A leaf with no workspace-crate dependencies, deliberately (the stella-diag
# shape): the engine never learns plugins exist (#3245 — the host binds the
# grants to the engine's gates), so this crate must not depend on stella-core,
# and stella-core must never depend on it. serde/toml/thiserror are the whole
# cost of admission.
[dependencies]
serde.workspace = true
thiserror.workspace = true
toml.workspace = true

[dev-dependencies]
serde_json.workspace = true
76 changes: 76 additions & 0 deletions crates/stella-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# stella-plugin

The plugin manifest: parsing and validation of a plugin's declared say in the
turn loop — slice A of #3245 (plugins as turn-loop participants). One
constructor vouches for a manifest:

```rust
let manifest = stella_plugin::PluginManifest::from_toml_str(text)?;
```

A value that came back `Ok` has passed every rule the epic states for the new
blocks: the `[loop]` participation ladder (`none` < `observer` < `steering` <
`arbiter`, monotone — each grade includes the ones below), hook grants
(`Stop` only at `arbiter`, no hooks below `steering`), `max_holds` and
`[requirements]` as arbiter-only powers, the host-run `[oracle]` contract,
and `[subloop]`/`[roles]` as declared stages with routing *intents* — never a
credential or a URL. Unknown keys, unknown hook names, and unknown grades are
load errors (`deny_unknown_fields` everywhere, the #1400 rule this crate
inherits).

The one function a host must never bypass is
`LoopGrant::permits_hook(event)` — the authoritative filter behind the
epic's rule that **an undeclared hook is never invoked**, even if the
plugin's process registers for it. It gates on both the grade and the
declared list, so even a hand-built grant cannot leak a dispatch.

## Boundary — does this change belong here?

This crate owns one decision: *what a manifest declares, and whether that
declaration is coherent*. Pure functions over borrowed text; no I/O, no
environment, and — like `stella-diag` — no workspace-crate dependencies, so
anything may depend on it and it depends on nothing.

Everything that *acts* on a manifest is out:

- Reading the manifest off disk, install consent, lifecycle states, overlay
and namespacing — the host (#1400's platform slices).
- Binding the grants to the engine's gates — the Stop gate, the hook runner,
the sub-agent primitive — is the host's job (#3245 slices B/C). The engine
itself never learns plugins exist: `stella-core` must never depend on this
crate, and this crate must never depend on `stella-core`.
- Clamping `max_holds`, resolving `[roles]` tiers against the user's BYOK
providers, running the oracle and tracking its flip — all host, all
elsewhere.

`HookEvent` here mirrors `stella-core::hooks::HookEvent` by name rather than
importing it, because the dependency is forbidden in both directions;
keeping the two sets identical is a review obligation tracked in #3310.

## God files — do not add lines

This crate has no god files: no file exceeds the gate's 1500-line ratchet
(`scripts/check-file-size.sh`), and none may appear — a new file crossing
1500 lines fails the gate outright, and `scripts/file-size-baseline.txt`
accepts no new entries. When a file here approaches the limit, split it
before it crosses.

## Layout

- `src/manifest.rs` — the types (`PluginManifest`, `LoopGrant`,
`Participation`, `HookEvent`, `Oracle`, `Subloop`, `Role`), parsing, and
every cross-field validation rule, each documented on the `ManifestError`
variant that enforces it.
- `src/error.rs` — `ManifestError`, typed per rule (invariant 5).
- `tests/manifest_grades.rs` + `tests/fixtures/*.toml` — slice A's
acceptance: one fixture per grade, round-tripped through both TOML and
`serde_json` (invariant 4), and the undeclared-hook filter proven against
the fixtures.

## Consumers

None shipping yet, deliberately: this is the first slice of #3245, and the
host that consumes it (manifest loading, install consent, Stop-gate binding
via the bounded verification loop, the subloop runner) arrives with slices
B–E of that epic. The crate exists first because every one of those slices
needs the same validated answer to "what did this plugin declare?".
189 changes: 189 additions & 0 deletions crates/stella-plugin/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! Why a manifest was rejected, as a typed answer (invariant 5).
//!
//! Every variant names the rule it enforces and carries the identifiers a
//! caller needs to point at the offending declaration. A host surfacing one
//! of these to a plugin author should be able to print it verbatim and have
//! the fix be obvious.

use crate::manifest::{HookEvent, Participation};

/// A manifest failed to parse or failed validation.
///
/// Parsing and validation are deliberately one error type: a caller loading a
/// manifest cannot act differently on "the TOML was malformed" versus "the
/// TOML was well-formed but claims a grant it may not have" — both mean the
/// plugin does not load, and both are the author's to fix. What a caller
/// *does* branch on is which rule failed, which is what the variants encode.
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
/// The TOML itself was rejected — syntax, an unknown key (every table
/// denies unknown fields, per the #1400 rule this crate inherits), a
/// hook name outside the shipped set, or a grade outside the ladder.
#[error("manifest is not a valid plugin manifest: {0}")]
Parse(#[from] toml::de::Error),

/// `[loop] hooks` listed the same event twice. A duplicate is always an
/// editing mistake, and silently deduplicating would hide it.
#[error("[loop] hooks declares {hook} more than once")]
DuplicateHook {
/// The event that appeared twice.
hook: HookEvent,
},

/// A grade below `steering` declared hook points. `none` is a content
/// bundle and `observer` may only watch the event stream — neither may
/// act at a hook point (#3245 §2).
#[error(
"[loop] participation = \"{participation}\" may not declare hooks; \
acting at a hook point requires \"steering\" or above"
)]
HooksRequireSteering {
/// The declared grade, below the one the hooks need.
participation: Participation,
},

/// A grade below `arbiter` declared the `Stop` hook. `Stop` is the
/// completion gate; touching completion is exactly what separates
/// `arbiter` from `steering` (#3245 §2).
#[error(
"[loop] hooks declares Stop, but participation = \"{participation}\"; \
binding the Stop gate requires \"arbiter\""
)]
StopHookRequiresArbiter {
/// The declared grade, below `arbiter`.
participation: Participation,
},

/// An `arbiter` did not declare the `Stop` hook. An arbiter's entire
/// additional power is the completion verdict, and an undeclared hook is
/// never invoked — so an arbiter without `Stop` is a contradiction, not
/// a quieter arbiter.
#[error(
"participation = \"arbiter\" requires the Stop hook in [loop] hooks: \
the completion verdict is what the grade grants, and an undeclared \
hook is never invoked"
)]
ArbiterMustDeclareStop,

/// `max_holds` was declared below `arbiter`. Only an arbiter can hold a
/// completion open, so the field has no meaning at any other grade and
/// its presence signals a misunderstood manifest.
#[error(
"[loop] max_holds is only meaningful at participation = \"arbiter\" \
(declared grade: \"{participation}\")"
)]
MaxHoldsRequiresArbiter {
/// The declared grade, below `arbiter`.
participation: Participation,
},

/// `max_holds = 0` — an arbiter that may never hold is a `steering`
/// plugin wearing the wrong grade; declare the grade that is meant.
#[error(
"[loop] max_holds must be at least 1; an arbiter that can never hold is not an arbiter"
)]
ZeroMaxHolds,

/// `[requirements]` was declared below `arbiter`. Requirements exist so
/// a hold is attributable to a named definition-of-done entry; without
/// the power to hold, there is nothing to attribute (#3245 §2).
#[error(
"[requirements] is only meaningful at participation = \"arbiter\" \
(declared grade: \"{participation}\")"
)]
RequirementsRequireArbiter {
/// The declared grade, below `arbiter`.
participation: Participation,
},

/// An `arbiter` declared no `[requirements]`, or an empty table. Every
/// hold must cite a named requirement, so an arbiter with none could
/// never hold attributably — the definition of done is enumerable,
/// never vibes (#3245 §2).
#[error(
"participation = \"arbiter\" requires a non-empty [requirements] \
table: every hold must cite a named requirement"
)]
ArbiterRequiresRequirements,

/// A `[requirements]` entry's description was empty. The description is
/// what the deck and the completion report show a human when the
/// requirement holds a turn open; an empty one is an unattributable hold.
#[error("[requirements] entry \"{name}\" has an empty description")]
EmptyRequirement {
/// The requirement key with the empty value.
name: String,
},

/// `[oracle]` was declared below `arbiter`. The oracle exists to decide
/// requirements (the fail→pass flip the host tracks); below `arbiter`
/// there are no requirements for it to decide. Conservative by design:
/// accepting it later at a lower grade widens the contract compatibly,
/// while rejecting it later would break shipped manifests.
#[error(
"[oracle] is only meaningful at participation = \"arbiter\" \
(declared grade: \"{participation}\")"
)]
OracleRequiresArbiter {
/// The declared grade, below `arbiter`.
participation: Participation,
},

/// `[oracle] command.argv` was empty — there is no program to run.
#[error("[oracle] command.argv must name a program: it is empty")]
EmptyOracleArgv,

/// `[oracle] command.timeout_secs = 0` — a zero timeout means the host
/// would kill the oracle before it ran, which can only be a mistake.
#[error("[oracle] command.timeout_secs must be at least 1")]
ZeroOracleTimeout,

/// `[subloop]` was declared below `steering`. Subloop stages run as
/// bounded child turns inside the host's loop — that is participation,
/// which `none` and `observer` have disclaimed.
#[error(
"[subloop] is only meaningful at participation = \"steering\" or \
above (declared grade: \"{participation}\")"
)]
SubloopRequiresSteering {
/// The declared grade, below `steering`.
participation: Participation,
},

/// `[subloop] stages` was empty. A subloop with no stages does nothing;
/// omit the table instead.
#[error("[subloop] stages must name at least one stage")]
EmptyStages,

/// `[subloop] stages` named the same stage twice. Order is the whole
/// point of the list, and a duplicated name makes the order ambiguous.
#[error("[subloop] stages declares \"{stage}\" more than once")]
DuplicateStage {
/// The stage name that appeared twice.
stage: String,
},

/// A `[subloop] stages` entry was empty.
#[error("[subloop] stages contains an empty stage name")]
EmptyStageName,

/// `[roles]` was declared without `[subloop]`. A role exists to be
/// resolved for a subloop stage; with no stages it is dead config, and
/// dead config in a consent document is a hazard, not clutter.
#[error("[roles] requires a [subloop]: a role is only resolved for a subloop stage")]
RolesRequireSubloop,

/// A `[roles.<name>]` entry declared an empty tier. The tier is the
/// intent the host resolves against the user's providers; an empty
/// intent resolves to nothing.
#[error("[roles.{name}] tier must not be empty")]
EmptyRoleTier {
/// The role whose tier was empty.
name: String,
},

/// The manifest's `name` was empty. The name is the identity every
/// grant, chip, and hold attribution hangs off.
#[error("manifest name must not be empty")]
EmptyName,
}
24 changes: 24 additions & 0 deletions crates/stella-plugin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! The plugin manifest crate — a plugin's declared say in the turn loop.
//!
//! Slice A of #3245 (plugins as turn-loop participants): parse and validate
//! the manifest blocks that grade a plugin's participation — `[loop]` with
//! its monotone ladder (`none` < `observer` < `steering` < `arbiter`),
//! `[requirements]`, `[oracle]`, `[subloop]`, and `[roles]` — as pure
//! functions over borrowed text. No I/O, no environment, no workspace-crate
//! dependencies: the engine never learns plugins exist, and the host that
//! binds these grants to the engine's gates (the Stop gate, the hook runner,
//! the sub-agent primitive) lives elsewhere and merely consumes this crate's
//! answers.
//!
//! The one function a host must not bypass is [`LoopGrant::permits_hook`]:
//! it is the authoritative filter behind the epic's rule that an undeclared
//! hook is never invoked, even if the plugin's process registers for it.

mod error;
mod manifest;

pub use error::ManifestError;
pub use manifest::{
FlipPolicy, HookEvent, LoopGrant, Oracle, OracleCommand, Participation, PluginManifest, Role,
Subloop, TamperPolicy,
};
Loading
Loading