Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "baton",
"description": "Manager-led development orchestrator for Claude Code. Routes substantial software work through a bounded subagent loop (discovery, planning, implementation, verification, recovery) with approval gates and an auditable run trail. A horizontal host: it composes with your domain skills and owns none of them.",
"version": "1.3.1",
"version": "1.3.2",
"author": { "name": "Andrew Wint" },
"homepage": "https://github.com/andrewwint/baton",
"repository": "https://github.com/andrewwint/baton",
Expand Down
6 changes: 4 additions & 2 deletions .claude/skills/baton/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Treat the orchestrator loop as the core feature. **Two paths:** work the routing

**Write the record on EVERY sensitive seam you review — even when a non-specialist lane cleared it (`REVIEWED-CLEAN`).** The most common way this gate is silently defeated is not a wrong verdict — it is _no record at all_: you review a sensitive seam through a `code-reviewer` lane, find nothing, and close without writing `disposition.json`, so the deriver never runs (a MISSING-RECORD, ~1/3 of sensitive-seam runs in eval). Do not skip the record because you found nothing and had no specialist. Record the seam with `contract_source: "none"` and let the deriver stamp `REVIEWED-CLEAN` — its own terminal for _"a real review lane looked at this sensitive seam and found no exposure, but no independent specialist contracted it."_ `REVIEWED-CLEAN` is **not** READY (there is no independent contract) and it is **not** the alarming `UNVERIFIED-SEAM` (a review genuinely ran) — it names the honest middle truthfully, so there is no reason to omit the record to dodge an over-loud verdict. It is not forgeable: the deriver grants it only when the fabrication sidecar recorded a **real review-lane spawn**, and only for a seam that _honestly_ recorded no specialist — a _rejected_ specialist claim keeps the full `UNVERIFIED-SEAM` penalty, so claiming a specialist you did not spawn is strictly worse than honest disclosure. Seed `seams_triaged` from the triage seam-list so every sensitive seam triage named reaches a recorded disposition. **This is no longer only a prose obligation.** Triage emits its sensitive seams as a machine-readable `TRIAGE-SEAMS:` line (see `agents/triage.md`), a forge-proof PostToolUse sidecar (`hooks/record_triaged_seams.py`) records them, and the close-out completeness gate cross-checks that ledger against the records: a sensitive seam class triage flagged that no `disposition.json` covers is stamped `MISSING-RECORD` — a present, non-READY, scorer-readable sentinel. So the skip that used to be invisible (no record ⇒ the deriver never ran) is now a detectable artifact; you cannot dodge the verdict by omitting the record.

**Inline-triaged seams must be machine-recorded too — the one gap the sidecar does not see.** The sidecar records seams a **triage lane returns** via `TRIAGE-SEAMS:`. When you triage a seam **inline** (no lane — permitted for light work), no such line is emitted through a tool, `triaged_seams.jsonl` stays empty, and the completeness gate is blind: the close-out would read a false "no sensitive seams → READY" over a seam you actually named. So the rule: **inline triage is for _non-seam_ work only. The moment you name a sensitive seam without a triage lane, machine-record it before implementation** — `python3 <skill>/hooks/record_seam.py <class>@<hint>` appends it through the same validated path the sidecar uses, arming the completeness gate exactly as a triage lane would (it can only _add_ the obligation, never clear one). There is deliberately **no close-out WARN** to catch a forgotten inline seam: a reliable one is not achievable, because the skill's own content (loaded into the session transcript) contains the `TRIAGE-SEAMS:` token and the seam examples, and `TRIAGE-SEAMS: none` is the clean-path signal — so any transcript scan would cry wolf on essentially every no-seam run and train you to ignore it. Recording the seam is therefore **your** responsibility, and the authoritative backstop is the **post-hoc scorer against the full stream** (which _can_ tell a real inline seam from example text), exactly as it is for spawn-fabrication. Do not rely on a close-out signal that cannot exist.

**A finding you NAME, you must RECORD — naming-then-dismissing is not a clean review.** `REVIEWED-CLEAN` is _not_ a way to launder a pre-existing exposure. If a review names a sensitive-seam exposure — a cross-tenant read, a missing gate, an unscoped mutation, an over-broad export route — then reasons it away on the _diff_ ("adds no new exposure", "not introduced here", "pre-existing"), that dismissal does **not** make the seam clean: the exposure the seam already carries is still live. Record any named finding as an `exposure` (which forces `IDENTIFIED-UNRESOLVED` until an independent party disposes it), or, if you list it in the seam's `named_exposures` or set `review_result: "exposure-found"`, the deriver reads that as a name-then-dismiss omission and derives `IDENTIFIED-UNRESOLVED` regardless — a review that found something cannot also be clean. A _genuinely_ clean review names nothing, so it clears to `REVIEWED-CLEAN`; only the seam that named-then-dropped a violation is caught. This is the diff-review-misses-the-pre-existing-violation failure the specialist consult already guards against, applied to the non-specialist review path.

**The record has a machine-checkable twin.** For any sensitive-seam work, write `.agents/runs/<runId>/disposition.json` at close-out alongside the prose (baton's own format — do not prescribe other skills' formats):
Expand All @@ -86,7 +88,7 @@ Treat the orchestrator loop as the core feature. **Two paths:** work the routing
"sensitive": true,
"locations": ["file:line"],
"contract_source": "specialist|external-plan|human|none",
"contract_lane": "<the independent lane/task id the contract came from, or null — a specialist source with no lane is self, not specialist>",
"contract_lane": "<the independent lane the contract came from — CITE ITS subagent_type as a token, e.g. \"security-review\" (optionally \"security-review:<note>\"). The deriver reconciles this against the spawn ledger by subagent_type NAME (task ids are not surfaced by Claude Code's PostToolUse, so they are usually null and are only a secondary match). So a genuine consult binds when a lane of that type really spawned, and a claimed specialist with NO real spawn of that type fails to UNVERIFIED-SEAM. This binds by TYPE, not per-instance — that a specific spawn did the security work is the post-hoc scorer's job, not the run-time gate's. A specialist source with no lane cited is self, not specialist>",
"contract_evidence": "<pointer to the invariants stated, or null>",
"review_result": "clean|exposure-found|null — the review's authoritative finding for this seam; exposure-found with no matching exposures[] entry is a name-then-dismiss omission",
"named_exposures": [
Expand Down Expand Up @@ -156,7 +158,7 @@ If a useful custom subagent is defined in `.claude/agents/`, prefer it over a ge
| research | focused external/library/API lookup | `researcher` |
| recovery | rollback or alternate-fix investigation | `researcher` (investigate) → `implementer` (apply) |

`triage`, `implementer`, `code-reviewer`, `researcher`, and `security-review` ship inside this skill at `agents/*.md`. The bundled runtime (`runtime/`) registers them in-process, so they need no `.claude/agents/` install. `security-review` is the independent security-contract lane the zero-trust verify step consults (loop step 5): it exists so a sensitive-seam contract is derived in a context that did **not** author the change. Spawn it as an Agent lane (`subagent_type: "security-review"`) — a real subagent Task with its own context — never via the in-context Skill tool, since a consult you run in your own conversation is not independent. A project may still route the seam to its own heavier security skill via `AGENTS.md`; the bundled lane is the portable floor. The `triage` lane is optional: for substantial intake where the routing decision itself benefits from a dedicated repo-scanning pass, delegate it; for light work, the manager triages inline (loop step 2) without opening a lane. For interactive sessions without the runtime, they resolve only if copied into `.claude/agents/` (run `tools/install.sh` from the Baton repo); otherwise these lanes fall back to `general-purpose`. `Explore` and `Plan` are built-ins.
`triage`, `implementer`, `code-reviewer`, `researcher`, and `security-review` ship inside this skill at `agents/*.md`. The bundled runtime (`runtime/`) registers them in-process, so they need no `.claude/agents/` install. `security-review` is the independent security-contract lane the zero-trust verify step consults (loop step 5): it exists so a sensitive-seam contract is derived in a context that did **not** author the change. Spawn it as an Agent lane (`subagent_type: "security-review"`) — a real subagent Task with its own context — never via the in-context Skill tool, since a consult you run in your own conversation is not independent. A project may still route the seam to its own heavier security skill via `AGENTS.md`; the bundled lane is the portable floor. The `triage` lane is optional: for substantial intake where the routing decision itself benefits from a dedicated repo-scanning pass, delegate it; for light work, the manager triages inline (loop step 2) without opening a lane — **but inline triage is for non-seam work; a sensitive seam named without a triage lane must be machine-recorded (`hooks/record_seam.py`) so the completeness gate arms** (see the emission-gate section in the verify step). For interactive sessions without the runtime, they resolve only if copied into `.claude/agents/` (run `tools/install.sh` from the Baton repo); otherwise these lanes fall back to `general-purpose`. `Explore` and `Plan` are built-ins.

## Delegation Policy

Expand Down
5 changes: 4 additions & 1 deletion .claude/skills/baton/agents/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ routes data/prompts to a hosted model is `data-egress` (and, where it provisions
not only the approval gate. "Nothing changed in the repo" is not "no seam." **Emit the line in exactly this grammar** — `<class>` or `<class>@<hint>`, seams separated by ` | `
(a space-padded pipe); a bare `|` inside a hint does not split. A malformed or unparseable line is **not**
silently dropped: it is treated as **seams indeterminate** and resolves to `UNVERIFIED-SEAM`, forcing human
attention — so a sloppy line costs more than a clean one, it never costs less. (This line is the coupled
attention — so a sloppy line costs more than a clean one, it never costs less. (This line is how a triage
**lane** records seams. When the manager triages a seam **inline** without a lane, the same machine record
is made via `hooks/record_seam.py <class>@<hint>` — see SKILL.md; a named seam must reach the machine ledger
either way, or the completeness gate cannot see it.) (This line is the coupled
`TRIAGE-SEAMS` shape; the forge-proof machine cross-check that reads it is runtime-bound — see
`docs/coupled-shape-spec.md`. baton emits and observes the shape; it does not run that gate.)
81 changes: 50 additions & 31 deletions .claude/skills/baton/hooks/disposition_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,23 +166,49 @@ def spawned_specialist_lanes(transcript_path):
return (True, lanes)


def sidecar_enforcement_active(settings_path=SETTINGS_PATH):
"""True iff the `record_lane_spawn` PostToolUse sidecar is wired. This is an INSTALL-time signal
(settings.json, not model-authored per run), so it is trustworthy: when the sidecar is wired, an
absent/empty spawn ledger genuinely means no independent lane spawned — so a claimed specialist is
a fabrication — whereas when it is NOT wired the deriver must fall back to record-only (it cannot
tell a missing ledger from an uninstalled hook)."""
try:
cfg = json.loads(open(settings_path).read())
except (OSError, ValueError):
return False
for entry in cfg.get("hooks", {}).get("PostToolUse", []) or []:
for h in entry.get("hooks", []) or []:
if "record_lane_spawn" in (h.get("command") or ""):
return True
def _candidate_settings(settings_path):
"""The settings.json file(s) to consult for a wired-hook signal. An EXPLICIT path (tests) is checked
ALONE. The production default (None) checks the PROJECT settings, the project-LOCAL settings, AND the
USER-GLOBAL `~/.claude/settings.json` — because the INTERACTIVE `/baton` path wires the enforcement
hooks GLOBALLY (via `hooks/wire_interactive.py`), not in a per-project settings.json. Reading only the
project file made the deriver believe the sidecars were unwired on every interactive run in an ordinary
repo, so `sidecar_real_spawns` returned None (record-only) and the completeness gate went silent — the
hooks fired and recorded, but the deriver could not SEE that they were wired, so it did not trust them
(observed live: `_sidecar_diag.enforcement_active: false` with a real spawn in `lane_spawns.jsonl`)."""
if settings_path is not None:
return [settings_path]
return [
SETTINGS_PATH, # project (cwd-relative)
os.path.join(".claude", "settings.local.json"), # project-local
os.path.join(os.path.expanduser("~"), ".claude", "settings.json"), # user-global (interactive path)
]


def _hook_wired(needle, settings_path):
"""True iff a PostToolUse hook command containing `needle` is wired in ANY candidate settings.json."""
for p in _candidate_settings(settings_path):
try:
cfg = json.loads(open(p).read())
except (OSError, ValueError):
continue
if not isinstance(cfg, dict):
continue
for entry in cfg.get("hooks", {}).get("PostToolUse", []) or []:
for h in (entry.get("hooks", []) if isinstance(entry, dict) else []) or []:
if isinstance(h, dict) and needle in (h.get("command") or ""):
return True
return False


def sidecar_enforcement_active(settings_path=None):
"""True iff the `record_lane_spawn` PostToolUse sidecar is wired (project, local, OR user-global). This
is an INSTALL-time signal (settings.json, not model-authored per run), so it is trustworthy: when the
sidecar is wired, an absent/empty spawn ledger genuinely means no independent lane spawned — so a claimed
specialist is a fabrication — whereas when it is NOT wired the deriver must fall back to record-only (it
cannot tell a missing ledger from an uninstalled hook)."""
return _hook_wired("record_lane_spawn", settings_path)


def recorded_spawns(path=LANE_SPAWNS_PATH):
"""All spawns from the append-only JSONL sidecar ledger, as `(subagent_type, task_id)` pairs —
RAW and unfiltered (generics included), so the deriver can RECONCILE a `contract_lane` to a specific
Expand Down Expand Up @@ -237,7 +263,7 @@ def reconciled_nongeneric(ref_l, spawns):
return False


def sidecar_real_spawns(settings_path=SETTINGS_PATH, ledger_path=LANE_SPAWNS_PATH):
def sidecar_real_spawns(settings_path=None, ledger_path=LANE_SPAWNS_PATH):
"""The recorded spawns to reconcile against, or None for record-only.
None -> sidecar not wired; honor a claimed specialist on record facts + the text guard (fallback).
list -> sidecar wired; honor a claimed specialist ONLY if its lane reference reconciles to a recorded
Expand All @@ -247,22 +273,15 @@ def sidecar_real_spawns(settings_path=SETTINGS_PATH, ledger_path=LANE_SPAWNS_PAT
return recorded_spawns(ledger_path)


def triage_sidecar_active(settings_path=SETTINGS_PATH):
"""True iff the `record_triaged_seams` PostToolUse sidecar is wired. Like `sidecar_enforcement_active`,
this is an INSTALL-time signal (settings.json, not model-authored per run), so it is trustworthy: when
the triage sidecar is wired, an absent/empty triage ledger genuinely means no sensitive seam was
triaged — so the completeness gate can safely stay silent. When it is NOT wired the gate must NOT fire
(it cannot tell an empty ledger from an uninstalled hook, exactly the over-fire trap the spawn sidecar
guards against)."""
try:
cfg = json.loads(open(settings_path).read())
except (OSError, ValueError):
return False
for entry in cfg.get("hooks", {}).get("PostToolUse", []) or []:
for h in entry.get("hooks", []) or []:
if "record_triaged_seams" in (h.get("command") or ""):
return True
return False
def triage_sidecar_active(settings_path=None):
"""True iff the `record_triaged_seams` PostToolUse sidecar is wired (project, local, OR user-global).
Like `sidecar_enforcement_active`, this is an INSTALL-time signal (settings.json, not model-authored per
run), so it is trustworthy: when the triage sidecar is wired, an absent/empty triage ledger genuinely
means no sensitive seam was triaged — so the completeness gate can safely stay silent. When it is NOT
wired the gate must NOT fire (it cannot tell an empty ledger from an uninstalled hook, exactly the
over-fire trap the spawn sidecar guards against). Checking the user-global settings too is what keeps the
completeness gate live on the INTERACTIVE path, where the sidecar is wired globally, not per-project."""
return _hook_wired("record_triaged_seams", settings_path)


def recorded_triaged_seams(path=TRIAGED_SEAMS_PATH):
Expand Down
23 changes: 23 additions & 0 deletions .claude/skills/baton/hooks/disposition_gate_selftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,29 @@ def test_sidecar_hook():
check("missing settings -> inactive",
dg.sidecar_enforcement_active(os.path.join(d, "none.json")), False)

# INTERACTIVE-PATH FIX: the hooks are wired in the USER-GLOBAL settings, not a per-project file. With
# the production default (settings_path=None), a global-only wiring MUST activate enforcement — else the
# deriver record-only's on every interactive run (observed live as enforcement_active:false + a real
# spawn). We drive it by pointing the candidate list at a temp "global" file (no real ~/.claude needed).
with tempfile.TemporaryDirectory() as d:
glob_wired = os.path.join(d, "global.json")
with open(glob_wired, "w") as f:
json.dump({"hooks": {"PostToolUse": [{"matcher": "Task|Agent", "hooks": [
{"type": "command", "command": "python3 /abs/hooks/record_lane_spawn.py"},
{"type": "command", "command": "python3 /abs/hooks/record_triaged_seams.py"}]}]}}, f)
saved = dg._candidate_settings
dg._candidate_settings = lambda sp: [sp] if sp is not None else [glob_wired]
try:
check("global-only wiring (default None) -> spawn sidecar ACTIVE", dg.sidecar_enforcement_active(), True)
check("global-only wiring (default None) -> triage sidecar ACTIVE", dg.triage_sidecar_active(), True)
check("explicit path still checked ALONE (ignores the global candidate)",
dg.sidecar_enforcement_active(os.path.join(d, "none.json")), False)
finally:
dg._candidate_settings = saved
check("default candidate list includes the user-global settings",
any(os.path.isabs(c) and c.endswith(os.path.join(".claude", "settings.json"))
for c in dg._candidate_settings(None)), True)

# sidecar_real_spawns wiring: None when inactive, a list of pairs when active
with tempfile.TemporaryDirectory() as d:
bare = os.path.join(d, "bare.json")
Expand Down
Loading
Loading