Vancetope — Shooty (Guard System)
Shooty is the cross-engine Guard system (Evolved from the Completion Guard; codename from H2G2 — Shooty & Bang Bang, the space cops). A Guard is a JS script that runs at a Point (hook point) and makes imperative decisions — judging via LightLlm, doc reads, real checks — and acts imperatively. The mechanism is generic: script execution via the shared
ScriptExecutor, scratch stores, round cap, opt-in no-op; only the Point and the fail strategy differ:
Point fires when typical action Fail Strategy STOP/TERMINATEEngine yield point continueWith(prompt)— Follow-up to its own queue, Engine continues workingfail-open STARTonce per real user turn activateSkill(...)— e.g., automatically activate Skillsfail-open COMMANDbefore each Engine command dispatch deny(reason)— e.g., judge if a command is safe via LightLlmfail-closed, hard The codename refers to the subsystem (
ShootyGuardService, this spec); the interfaces remainguard:vance.guard.*in the script API, the//guardverb, theguard:Recipe block (public contract, like “Inbox” remains the visible name alongside Maximegalon).
See also: recipes ( guard:block)engine-commands (Runtime verb guard, COMMAND gate in the Dispatcher)script-document-api §7a ( vance.guard.*)light-llm-service (Judge backend for Guard scripts) frankie-engine / arthur-engine (Yield and Turn Start points)
Example (deterministic Stop Guard script):
// _vance/guards/dev-done.js — no LLM, purely imperative
if (!vance.guard.loopValues.get('askedBuild')) { // ask only once per loop
vance.guard.loopValues.set('askedBuild', true);
if (!/BUILD OK/.test(vance.guard.output)) {
vance.guard.continueWith("Please run build and report the result.");
}
}
Examples for the new Points:
// trigger: start — activate Skill appropriate to the request; the script
// runs per real user turn, the script decides (here: only the first time).
if (/release|deploy/i.test(vance.guard.task) && !vance.guard.sessionValues.get('releaseSkl')) {
vance.guard.sessionValues.set('releaseSkl', true);
vance.guard.activateSkill('release-checklist');
}
// trigger: command — check command for safety with LightLlm (Fail-closed!)
const v = vance.llm.callForJson("command-guard", "Is this command safe?", {
command: vance.guard.command.name, args: vance.guard.command.args });
if (!v || !v.safe) vance.guard.deny(v.reason || "judged unsafe by the command guard");
1. Terms and Delimitation
- Guard = Script. Judge and action are not a config field — they are control flow within the script. A Guard script is a JS document (Document Cascade) or inline.
- Generic, not engine-specific. A common
ShootyGuardService(script execution + cap + scratch) and a generic verb familyguard— no//frankie.guard. Every Process can carry Guards, at every Point. - Point instead of “just Yield”. The STOP/TERMINATE Guard fires only where an Engine signals “done” and would deliver — not per turn. The START Guard fires per real user turn, the COMMAND Guard per command dispatch.
- Recipe-only, opt-in. Without a
guard:block (or Runtime override), every Point is a pure no-op, zero cost. The v2 Completion Guard semantics remain unchanged. - The Guard does not guard itself. Everything that arises from a Guard run (LightLlm calls from the script, commands from a Skill activation, future hooks) is outside Shooty’s judgment — implemented as a re-entrance marker (see §6).
2. Points and their Anchors
2.1 STOP / TERMINATE — Yield Point (unchanged from v2)
Each Engine calls
ShootyGuardService.evaluate(process, finalOutput, naturalStop) at its yield point.
| Engine | Yield Point |
|---|---|
| Frankie | the two stop paths (Natural Stop / Tool Terminate) |
| Arthur | a turn with chat output that does not wait for the user (DELEGATE/WAIT delivery, before the Process goes to IDLE) |
| Eddie | analogous to Arthur |
Arthur and Eddie share the base class StructuredActionEngine; the
call lives there once (runCompletionGuard). Gate (conscious v2
decision): the Guard fires only on appendedChat &&
!awaitingUserInput — a conversational reply parks the Process as
await-user (BLOCKED) — challenging every “hi!” with “really done?”
would be exactly what the gate prevents. The
DELEGATE/WAIT yields, however, would otherwise go empty to IDLE — precisely there,
continueWith gives the Process more work. Frankie has no gate: its Natural
Stop always delivers.
2.2 START — per real User Turn
ShootyGuardService.guardsOnTurnStart(process, inbox) — the common anchor
for all Engines: first resetIfUserTurn (budget reset + loop scratch wipe),
then runStartGuards (order is binding — the script starts on
a clean slate; the order is in this single method so it cannot
drift per Engine):
| Engine | Anchor |
|---|---|
| Arthur / Eddie | StructuredActionEngine.guardsOnTurnStart (delegates to the service anchor) |
| Ford | runTurnFor, Turn head — the steer() input runs through the same method |
| Frankie | runTurn, directly after drainPending and before prompt construction — a Start Guard that activates a Skill brings it into the same turn |
| Trillian | inherits FrankieEngine.runTurn — START runs with Frankie’s anchor |
Semantics (intentionally so): no config field “Spawn or Turn” — the START Point
fires once per real user turn (detection like resetIfUserTurn:
fromUser ≠ _guard), and the script decides what it does
(“every turn” vs. “only the first time” via Scratch). Consequence:
- “Once per Process” flags belong in
sessionValues(survives the reset). - Guard-injected turns fire nothing — otherwise, each Completion Guard round would multiply the Start Guards.
2.3 COMMAND — Gate before Dispatch
ShootyGuardService.gateCommand(process, command) — called in the
EngineCommandDispatcher after handler lookup, before handler.handle
(unknown verbs are not checked — they execute nothing). Thus runs on
the Lane with a fresh Process.
Exceptions to the Gate:
- Verb
guard— Self-management: otherwise, a defective COMMAND Guard locks the user out of//guard clear, the only way to disable it. - Re-entrance marker active (Guard origin, §6) — a Start Guard that activates Skills must not run through its own COMMAND Gate (and generated LightLlm calls not at all: they are not commands).
v1 boundary: The Gate only judges Engine Commands (Control Plane:
//verb, Skill sequences, planned LLM/Scheduler sources). LLM Tool Calls
(file_*, exec_* — ToolDispatcher) are a possible later Point
(tool), intentionally not part of v1.
3. Configuration
Two sources, additive (both apply simultaneously; Guards are checked sequentially — at the Yield Point, the first hit wins; at the COMMAND Point, the first denial refuses):
3.1 Recipe Block guard: (Spawn Default)
guard:
- script: _vance/guards/dev-done.js # Document Cascade path OR inline scriptBody
params: # optional inputs → vance.params.*
threshold: 3
trigger: stop # start | command | stop | terminate | both (Default: stop)
maxRounds: 2 # Cap against the guardRounds of the Process (only stop/terminate)
allowTools: false # false (Default): Supervisor Surface; true: full Process Tools
| Field | Type | Required | Meaning |
|---|---|---|---|
script |
String |
one of two | Guard script path (Document Cascade). |
scriptBody |
String |
one of two | Inline Guard script. Exactly one of script/scriptBody. |
params |
Map |
no | Inputs that the script sees as vance.params.*. |
trigger |
String |
no (stop) |
The Point: start \| command \| stop \| terminate \| both (both = Stop+Terminate, legacy shorthand). One Guard per Point — multiple Points = multiple entries. |
maxRounds |
int |
no | Hard cap against the guardRounds counter of the Process. Only evaluated at the STOP/TERMINATE Point — START runs once per turn, COMMAND once per command; there, the backstop is the script timeout (30s). |
allowTools |
bool |
no (false) |
Tool Surface of the script (see §5.4). |
3.2 Runtime Guard (via Command during operation)
An additional Guard alongside the Recipe Guards, installable via the generic
guard verb family — engine-agnostic,
persisted per-Process. The Runtime Guard is a STOP Guard (runs with
trigger=stop and the default maxRounds):
| Command | Effect |
|---|---|
//guard script <path> |
Set Runtime Guard = script path |
//guard inline <script> |
Set Runtime Guard = inline body (mutually exclusive with script) |
//guard get |
Display Runtime Guard + Recipe Guards |
//guard clear |
Discard Runtime Guard |
//guard status [session] [set/del/clear …] |
Inspect/edit Guard Scratch |
A typical way to activate is a Skill that fires //guard script … in its
activate: sequence and //guard clear in deactivate: — see
skills §2a.
4. Mechanics
4.1 Script Execution (all Points)
A synchronous run via the shared ScriptExecutor
(GraalJS, no Process spawn, no Lane lock):
- Load body:
script→ Document Cascade (DocumentService.lookupCascade), otherwise inlinescriptBody. - Bindings:
paramsasvance.params.*; the Guard interface asvance.guard.*(see §5). - Wall-clock timeout (30s) + statement limit; a long Guard blocks
the Lane — for real long verification
vance.process.spawn(...)(async). - Shared runtime environment: the Scratch stores are keyed per Process
or per Session, not per Point. A START Guard sets
skillsActivated=true, a STOP Guard reads it.
4.2 Actions — per Point
vance.guard.continueWith(prompt)(STOP/TERMINATE): cap-aware — becomes a no-op aftermaxRounds(returnsfalse).guardRoundsis atomically incremented, the prompt is appended as a Pending Message (prefix[completion-guard], sender_guard), a turn is scheduled. “Fired” is derived from this. Outside the yield point, the action throws (script bug).vance.guard.activateSkill(name, args?)(any Point): sticky, auto-trigger-like (SkillSteerProcessor.activate,oneShot=false,runAction=false) — no separate action turn; at the START Point before prompt construction, the Skill takes effect in the same turn. Runs with the re-entrance marker set (§6), the Activate sequence bypasses the COMMAND Gate.vance.guard.setTurnPrompt(text)(START): replaces the system prompt of this turn completely — not additive: Recipe prompt, Skill blocks, and date context are gone for the turn; what the Guard wants (including Skills it activated itself) must be folded into the text. One text per turn (last call wins), cleared on the next real user turn — if no START Guard sets it, the prompt is not manipulated (default). A_guardfollow-up turn (continueWith round) inherits the replacement: the same unit of work. Implemented via aTurnContextHandler(GuardTurnContextHandler): ephemeral request augmentation before every LLM request — the replacement never ends up in history, replay, or compaction. Engine coverage is free because every START Engine sends its requests through theTurnContextHandlerRegistryanyway. The handler runs last (@Order(LOWEST_PRECEDENCE)): “replaces completely” is the final word — system messages from request-augmenting handlers that ran before (Research-Pressure-Nudge) are also replaced. A handler that needed to survive a replacement would require higher precedence and a documented reason; there is none.vance.guard.deny(reason)(COMMAND): hard denies the command; result isEngineCommandResult{outcome=ERROR, deniedByGuard=true}with the reason in the message. Outside the COMMAND Point, the action throws.- Client direction:
vance.process.notify(...)/progress(...)— as before.
4.3 COMMAND: Fail-closed, hard — and Sequence Abort
If a COMMAND Guard is defined and fails (script error, timeout,
script not found) or denies (deny): the command call
fails hard. For the caller, veto/failed is the same hard error
(deniedByGuard), and the sequence loop stops: SkillCommandRunner
aborts the remaining commands of a Skill activate:/deactivate: sequence
(the existing best-effort rule for regular handler errors remains —
especially for deactivate: cleanup sequences; a Guard denial is a
conscious veto, not a transient error).
Thus, “the Guard never blocks” only applies to STOP/START (there, fail-open: script error ⇒ skip, allow Yield/Turn normally, WARN + metric; an already performed injection remains valid). At the COMMAND Point, blocking is the task — the fail strategy is a per-Point rule.
No round cap at the COMMAND Point: the Point fires exactly once per command; a continueWith spiral cannot occur there; the backstop is the script timeout.
4.4 Tool Surface (all Points, unchanged)
A Guard is a supervisor, not a worker:
allowTools: false(default) — Supervisor Surface:vance.llm,vance.documents,vance.process(notify/progress/spawn) are present;vance.toolsis limited to{process_spawn}.allowTools: true— the full Tool Surface of the Process (ThinkEngineService.newContext(process).tools()), including exec/file.
5. vance.guard.* — the Script Interface
Only available in Guard runs (otherwise null). Full reference in
script-document-api.md §7a.
Context (read-only): guard.task (first user message; at the START Point: the
user input of this turn), guard.output (final output; '' at
START/COMMAND Point), guard.round / guard.maxRounds (only meaningful at the Yield Point),
guard.naturalStop, guard.point
('start' | 'command' | 'stop' | 'terminate'), guard.command (at the
COMMAND Point {name, args}, otherwise null).
Actions: guard.continueWith(prompt) → boolean (STOP/TERMINATE);
guard.deny(reason) (COMMAND); guard.activateSkill(name, args?) → boolean
(any Point); guard.setTurnPrompt(text) (START — completely replaces the system prompt
of the turn, one text per turn, default: no manipulation).
Unavailable actions throw ScriptHostException.
Scratch — “already asked”: guard.loopValues (per Process/Loop; reset on
a real user turn) and guard.sessionValues (per Session;
survives the reset), each a store with get() / get(key) / set(key,value) /
has(key) / remove(key). Transient, in-memory, not persistent —
shared across all Points of the Process.
6. Re-entrance: The Guard does not guard itself
For the duration of each Guard script run, the service sets a re-entrance marker (InheritableThreadLocal — inheritable because GraalJS evaluates on a watchdog child thread; host actions run there). The COMMAND Gate skips everything with an active marker:
- LightLlm calls from the script — never subject to the Gate (not commands), and thus never self-guarding.
- Commands fired by the script — currently: the Activate sequence of a
Skill activation from
vance.guard.activateSkill. Otherwise, Guard scripts do not fire Engine Commands (the Script API has no command sending); should that arise, the marker automatically applies. - Spawned child processes are not covered — a child is a new Process with its own Recipe; its Guards run normally (intended).
7. Loop Safety and guardRounds Reset (STOP/TERMINATE, unchanged)
guardRounds-Cap (int onThinkProcessDocument, atomic$incbefore injection, persistent) againstmaxRounds.- Trigger Match: a
stopGuard does not fire on an explicit Terminate and vice versa; START/COMMAND Guards are not consulted at Yield Points at all. resetIfUserTurnruns at Turn Start and resetsguardRoundsto 0 and clears the Loop Scratch as soon as the drained Inbox contains real user input (fromUser ≠ _guard). After that, the START Guards run. Thus, each new user request starts with a full budget and a clean slate; the guard’s own injection deliberately does not replenish the budget.
8. Data Model (generic on ThinkProcessDocument)
guardRounds(int) — Cap counter, atomic$inc, reset viaresetGuardRounds. Persistent.guardScriptOverride/guardScriptBodyOverride(nullable String) — the Runtime Guard (STOP Point).- Loop-/Session-Scratch — not on the Document: in-memory in the
ShootyGuardService(bounded LRU, non-persistent).
9. Visibility & Metrics
Firings and denials are logged and counted via vance.guard.evaluations{outcome}
(fired / denied / passed / script_error — a Command Gate that
fail-closed denies on a script error counts script_error). passed
means: at least one Guard ran and none failed — a
script error counts script_error, never additionally passed (fail-open means
“the Engine continues”, not “the Guard agreed”). A
COMMAND denial is seen by the caller as a regular command error response with the
denial message (outcome=ERROR), including the //-client. Guard scripts can
additionally send vance.process.notify(...) / progress(...).
10. Anchors (Current Code)
- Service:
vance-brain/.../guard/ShootyGuardService.java(evaluate/runStartGuards/gateCommand/guardsOnTurnStart= Reset + Start /resetIfUserTurn/ Re-entrance markerinGuardRun). - Script Interface:
vance-brain/.../script/VanceScriptApi.java(ScriptGuardApiwithpoint/command+ScriptGuardScratchApi), CallbackGuardScriptHost(continueWith/deny/activateSkill/setTurnPrompt). - Turn Prompt Replacement:
vance-brain/.../guard/GuardTurnContextHandler.java(TurnContextHandler, hooked into theTurnContextHandlerRegistry— Engine coverage without further wiring). - Yield Points:
vance-brain/.../frankie/FrankieEngine.java(both stop paths);vance-brain/.../thinkengine/action/StructuredActionEngine.java(runCompletionGuard+guardsOnTurnStart). - Turn Start (START):
ShootyGuardService.guardsOnTurnStart(Reset + Start, in one place) — called byStructuredActionEngine.guardsOnTurnStart(Arthur/Eddie),Ford.runTurnFor(Turn head) andFrankieEngine.runTurn(afterdrainPending). - COMMAND Gate:
vance-brain/.../command/EngineCommandDispatcher.java(gate, exempt: verbguard+ Re-entrance);EngineCommandResult.deniedByGuard; Sequence Abort invance-brain/.../skill/SkillCommandRunner.java. - Recipe Block:
guard:list →ResolvedRecipe.guards(GuardConfig+GuardPoint). - Runtime Command:
vance-brain/.../command/GuardCommandHandler.java. - Bundled reusable Guard:
vance-defaults/_vance/guards/llm-judge.js. - Author Manual:
vance-defaults/_vance/manuals/completion-guards.md(manual_read('completion-guards')). - Design History: planning/shooty.md + planning/archive/completion-guard.md.