diff --git a/README.md b/README.md index ff8b62f75..4dea361fc 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ user opts in with `/plugin enable`; an existing install is never flipped by cata ### Autonomy -- [`autonomy`](plugins/autonomy) — Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, and return-accounting contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, and wires human-attested return capture at the task boundary. +- [`autonomy`](plugins/autonomy) — Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, and trigger-dispatch contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, and wires signal adapters with one governed dispatch entrypoint. ### Security diff --git a/docs/topics/ai-ladder-wp4-trigger-dispatch/PLAN.md b/docs/topics/ai-ladder-wp4-trigger-dispatch/PLAN.md index a09d662db..a3c93fcc3 100644 --- a/docs/topics/ai-ladder-wp4-trigger-dispatch/PLAN.md +++ b/docs/topics/ai-ladder-wp4-trigger-dispatch/PLAN.md @@ -109,7 +109,7 @@ admission binding exists (absent policy → every dispatch human-gated), and WP5 policy surface that relaxes it — no dead link in either direction, no dispatch window ever runs ungoverned. -### Phase 1: Trigger-dispatch contract doc [TODO] +### Phase 1: Trigger-dispatch contract doc [DONE] | File | Action | What changes | |---|---|---| @@ -126,7 +126,7 @@ runs ungoverned. - Vendor+fleet deny-list grep empty over the doc (`node scripts/validate-plugin-contracts.mjs` exit 0) - lychee lane passes -### Phase 2: Guided-setup trigger/dispatch slice [TODO] +### Phase 2: Guided-setup trigger/dispatch slice [DONE] Extends the `setup` skill (discovery-first per D7/WP1 D7). First work item — fresh-docs mandate (repo CLAUDE.md): re-fetch the official docs for any vendor surface the slice names @@ -150,7 +150,7 @@ mandate (repo CLAUDE.md): re-fetch the official docs for any vendor surface the - `node plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs` (no args) exits 2 with usage - Fleet-name sweep (`validate-plugin-contracts.mjs`) exit 0 -### Phase 3: Conforming-path demonstration [TODO] +### Phase 3: Conforming-path demonstration [DONE] Acceptance probe on a scratch consumer repo (NOT this repo, WP2 Phase 3 precedent — the ban there is on FLEET bindings/plugins, not on composing a sibling CAPABILITY: the scratch repo @@ -174,7 +174,7 @@ binding means no classification rules, so the item carries no `signal.work_class - No second dispatch path exercised anywhere in the demo; no paid service touched - Demo transcript attached to the PR body -### Phase 4: Gates [TODO] +### Phase 4: Gates [DONE] Full in-repo gate run (WP2 Phase 4 roster): `scripts/validate-plugins.sh`, `scripts/run-plugin-tests.sh`, `node scripts/validate-plugin-contracts.mjs`, markdown/typos/ diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 1e65eb0cc..c6d54c55a 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.3.0", - "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, and return-accounting contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, and wires human-attested return capture at the task boundary.", + "version": "0.4.0", + "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, and trigger-dispatch contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, and wires signal adapters with one governed dispatch entrypoint.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/autonomy/README.md b/plugins/autonomy/README.md index eb88c5dd5..06b06d283 100644 --- a/plugins/autonomy/README.md +++ b/plugins/autonomy/README.md @@ -5,7 +5,7 @@ AI-adoption-ladder contract set: it ships the tool-agnostic contracts an adoptin its own repositories, tools, and policies, plus a guided-setup skill that discovers the org's state and records that binding. -## Shipped capability (0.3.0) +## Shipped capability (0.4.0) - **Topology contracts** (`reference/`): role topology for the repositories an adoption spans, the binding-seam shape that maps contract roles to an org's real instances, and the @@ -19,6 +19,12 @@ state and records that binding. autonomous-class work, joinable to cost telemetry by the join attribute — plus the setup capture slice and its close-boundary templates. Agents prompt and aggregate; they never estimate the human fields. +- **Trigger-dispatch contract** (`reference/trigger-dispatch.md`): four signal-surface + classes normalized by adapters into the governed work-item queue under six class-generic + obligations, a schema-versioned signal envelope, security-surface work-class stamping, and + one dispatch entrypoint (push kick + scheduled drain through the queue seam's race-safe + lease) — plus the setup trigger/dispatch slice, its adapter and acknowledgment templates, + and the signal-envelope conformance check. - **Guided setup** (`/autonomy:setup`): discovery-first interview of the adopting org's state — role homes, substrate availability, budget posture — writing a schema-versioned binding under `.claude/autonomy/` as reviewable changes. Never assumes any particular org or repo shape. @@ -30,7 +36,7 @@ locked (no step-skipping — trust before scale). | Capability | Trigger | |---|---| -| Trigger/dispatch adapters | Trigger-layer work package build lands. | +| Fleet adapter materializations (reusable workflows, labels, drain routine) | Work-item backlog, post trigger-package graduation. | | Guardrail matrix + sandbox-ladder binding | Guardrails work package build lands. | | Standing-routine catalog + v1 definitions | Routines work package build lands. | | Runner charter execution pack | The runner build trigger fires (charter's own conditions). | diff --git a/plugins/autonomy/reference/trigger-dispatch.md b/plugins/autonomy/reference/trigger-dispatch.md new file mode 100644 index 000000000..d396def5e --- /dev/null +++ b/plugins/autonomy/reference/trigger-dispatch.md @@ -0,0 +1,161 @@ +# Trigger dispatch + +Normative contract for signal adapters and autonomous dispatch: adapters normalize signals +from four surface classes into the governed work-item queue; one dispatch entrypoint drains +it; the executor is swappable behind the invocation-adapter seam. The contract fixes +vocabulary, obligations, and invariants; every concrete instance (which surfaces exist, +which tracker holds the queue, where the executor runs) is an org-binding outcome. + +## Signal-surface classes + +Four classes, ALL contract-active. Per-org availability is a binding outcome — a surface the +org lacks, or an entitlement gap on a surface it has, routes to the advisory path; it is +never a contract deferral. + +| Class token | Surface | +|---|---| +| `tracker-vcs-event` | Tracker/VCS-host events: label applied, assignment, @-mention, PR event | +| `temporal` | Schedules and poll-fallback detectors for push-less surfaces | +| `agent-internal` | A session emits follow-up work while executing | +| `channel-feed` | Chat mention and continuous channel/data-feed monitoring | + +Carried research gaps, stated in surface-class vocabulary (vendor specifics live in the +setup skill, never here): whether a channel-monitor may ambiently INITIATE work versus only +notify is UNVERIFIED; the channel-agent surfaces this class relies on are alpha/beta moving +targets; one major chat platform has no first-party trigger (UNVERIFIED-absence — re-verify +at wire time). + +## Recorded signal attributes + +Two attributes are recorded on every queued signal: + +- **Initiator provenance** — `human` | `agent` | `system`. Audit data and guardrail-matrix + input; recorded, never trusted as an isolation axis (provenance is claimable; isolation + decisions key on the work class and surface verdicts, not on who claims to have asked). +- **Transport** — `push` | `push-lifecycle` | `poll`. Push preferred where the surface + offers it; poll is the universal fallback via the `temporal` class. `push-lifecycle` + carries subscription obligations: expiry tracking, renewal, and the platform's validation + handshake. Expiry semantics are normative: every `push-lifecycle` wiring is backed by a + `temporal` poll-detector backstop for the same surface, or the subscription-health lapse + fail-closes — it files a human-gated alert item — so a lapsed subscription can never + silently drop signals. + +## Adapter obligations + +Six class-generic obligations bind every adapter: + +1. **Normalize and enqueue only.** An adapter never executes work and never bypasses the + queue. No second path from signal to execution exists. +2. **Idempotent dedup**, keyed on `signal.identity`. The identity is the surface-native + unique event id where the surface issues one. The FALLBACK identity is never a bare + content hash: it composes source scope (surface class + origin locator) + an + event-instance discriminator (delivery id or event timestamp) + the content hash, so two + legitimate repeated signals with identical payloads stay distinct instances. State-based + poll detectors that re-observe a continuing condition have no instance identity; their + dedup retention is BOUNDED to items still open — the same finding may re-enqueue once its + prior item closes (a re-detected regression is a new signal). Enforcement is not a bare + read-then-write: concurrent at-least-once deliveries can both pass a search before either + item exists, so the adapter uses an atomic identity-keyed create/upsert or queue-side + uniqueness guarantee where the tracker offers one; otherwise search-before-create is + backed by create-then-reconcile — after creating, re-search by `signal.identity` and, on + finding an older item with the same identity, close the newer one as an audited duplicate + (oldest wins, deterministically). A drain-side guard scoped to LIVE duplicates completes + the defense: the drain never claims an item whose `signal.identity` matches another + currently-open item, while completed items are excluded from the guard so re-detections + execute. +3. **Provenance capture and a durable raw-signal link** (`signal.raw_link`) on the item. +4. **Trace-context propagation.** The adapter injects `signal.traceparent` so the telemetry + contract's causal tree spans trigger → CI → agent session. +5. **Admission enforcement at the seam.** Admission-policy CONTENT is owned by the guardrail + matrix and bound on the org's security governance surface; the adapter ENFORCES it, + never defines it. An unadmitted signal becomes a human-gated item or an audited + rejection — never a silent drop. An ABSENT admission binding fail-closes: everything + enqueues human-gated. +6. **Closed-loop acknowledgment.** Bidirectional surfaces echo the queued item reference + back to the source (tracker comment, chat thread reply); reply-less surfaces satisfy the + obligation through `signal.raw_link` alone. + +## Work-class classification + +Admission and the whole guardrail matrix key on the risk class (`C1`–`C5`), so a queued item +needs one. The adapter STAMPS `signal.work_class` from the classification rules on the org's +SECURITY governance surface — the adapter stamps, never defines, and no repo-local +(agent-writable) surface may supply the class used for admission: + +- `tracker-vcs-event` resolves through the security-bound label→class rules. +- `temporal` signals carry the class their bound routine/detector definition derives. +- `agent-internal` items must PROVE protected provenance: the envelope serializes the + emitting session's own admitted source item as `signal.parent_item`, and the admission + seam verifies the session-to-parent association against protected dispatch data — the + queue's own lease record of which item the emitting session was dispatched on. An + agent-supplied URL alone proves nothing (any session could cite an unrelated low-class + item to launder higher-risk follow-up work); an association the seam cannot verify is NO + provenance. Admission then resolves the verified parent's class from its own protected + classification rather than trusting the stamped value: the effective class is the HIGHER + of the inherited class and the class the security-surface rules derive for the target. +- `channel-feed`, and any signal the rules cannot resolve, stays UNCLASSIFIED. + +Unclassified → fail-closed human-gated, always. + +## Signal envelope + +Serialization is a JSON-fenced marker record on the queued item (the return-accounting +convention's marker-record precedent): the marker `` plus one +fenced JSON block holding the record, written by the adapter at enqueue. `schema_version` +starts at `"1.0"` with additive evolution under the same reviewed-migration governance as +every contract schema. Keys: + +| Key | Value | +|---|---| +| `signal.class` | surface-class token | +| `signal.transport` | `push` \| `push-lifecycle` \| `poll` | +| `signal.provenance` | `human` \| `agent` \| `system` | +| `signal.identity` | dedup identity per obligation 2 | +| `signal.raw_link` | durable absolute reference to the source event; form branched by origin — web-origin signals carry an absolute https URL with query and fragment PRESERVED (the telemetry contract's strip rule applies only to the work-item join key); a temporal signal from a local-scheduler surface may carry a durable local/artifact URI (absolute `file:` URI or org artifact-store locator); relative or ephemeral references conform on no branch | +| `signal.traceparent` | W3C trace context from the trigger hop | +| `signal.work_class` | optional; the stamped risk class per the classification rules — absent = unclassified = human-gated | +| `signal.parent_item` | REQUIRED when `signal.class` is `agent-internal`: canonical URL of the emitting session's admitted source item, verified against the queue's lease record | +| `signal.source_surface` | REQUIRED when `signal.class` is `temporal`: the originating scheduling surface's id as recorded in the org's trigger/routine binding — the discriminator raw-link form validation branches on | + +## Dispatch + +Push kick where the platform offers it (an event-fired job on enqueue) plus a standing +scheduled drain as the universal fallback and catch-up net for ENQUEUED items. The drain's +default cadence is hourly (org-bindable); the drain never re-scans a source surface — +missed enqueues are the poll-detector backstop's job. + +**One-entrypoint invariant.** Every kick funnels into the work-item queue capability's +existing autonomous drain mode via the invocation-adapter seam. The seam's race-safe lease +makes concurrent kicks harmless. No second claim or dispatch mechanism exists anywhere. + +**Execution-surface attestation.** Every kick/drain wiring records its named execution +surface, but the recorded id is repo-local convenience only: the admission/executor seam +derives the ACTUAL execution-surface identity from trusted dispatch/runner context — +platform-attested runtime metadata matched against the per-surface identifying markers the +security binding's isolation entries declare — and verifies it against the recorded id, +consulting the ACTUAL surface's isolation verdict. A mismatch, an unattestable actual +surface, or a surface without the required isolation binding each fail-close to +human-gated; rewriting the recorded id cannot launder execution onto an unbound runner. + +Concurrency and per-run item caps are guardrail-policy knobs: this contract names them +descriptively; their serialized tokens (`autonomous_concurrency`, `items_per_run`) are +owned by the admission policy on the security surface. + +## Executor surface classes + +Two classes, imported unchanged from the runner charter: **self-operated** CLI/SDK +executors — including SDK-embedded pull/drain daemons — and **vendor-hosted** executors, +whose merge policy caps at human-gated. The executor-class determination that gates merge +policy is SECURITY-surface data (the security binding's `executor_class`), never a +repo-local value. Other executor hosting configuration is deployment-owned per the hosting +stance: this contract fixes only the isolation floor (L2+ for unattended execution), +credential scoping, and the queue contract. + +## Constraints + +- No queue bypass, no second dispatch mechanism, no second claim path. +- The contract never invents an event bus and never raises domain events; the adopting + org's own systems own event definition and raising. +- No new cost by default: paid surfaces are advisory with cost surfaced, explicit opt-in. +- Vendor and fleet names never appear in this contract's normative text; mechanisms are + named as classes with vendor specifics in binding docs and the setup skill. diff --git a/plugins/autonomy/skills/setup/SKILL.md b/plugins/autonomy/skills/setup/SKILL.md index e9e1df402..2ac27dbde 100644 --- a/plugins/autonomy/skills/setup/SKILL.md +++ b/plugins/autonomy/skills/setup/SKILL.md @@ -261,10 +261,79 @@ entitlement-gated surfaces get advisory steps with cost surfaced. attested); requester-less surfaces with resolved routing entries may still wire — each unbound value is reported, not hidden. +## Trigger/dispatch slice + +Wires the signal-adapter and dispatch state of +[`${CLAUDE_PLUGIN_ROOT}/reference/trigger-dispatch.md`](${CLAUDE_PLUGIN_ROOT}/reference/trigger-dispatch.md), +discovery-first. Everything lands as reviewable changes; plan-gated surfaces are advisory + +explicit opt-in with cost surfaced. Vendor event names and invocation flags live in THIS +slice and its templates — the contract stays surface-class vocabulary only. + +1. **Discover signal surfaces per class** — interview + repo/org inspection for which of the + contract's four surface classes exist here, the transport each surface actually offers + (`push` / `push-lifecycle` / `poll`), and entitlements. Per-org absence of a class is a + binding outcome, never a blocker; an entitlement gap routes that surface to the advisory + step. The contract's carried research gaps are re-verified at wire time against current + vendor docs (fresh-docs mandate), not assumed still true. +2. **Wire the DIY floor as reviewable changes** — the kick and the drain: + - *Kick* (`tracker-vcs-event`): a platform event workflow on the tracker/VCS host running + the adapter shape from + [`templates/trigger-adapters.md`](templates/trigger-adapters.md). Marked example, on + the GitHub Actions class of CI: `issues` (types `labeled`, `assigned`), + `issue_comment` (type `created`) for @-mention forms, `pull_request` for PR events — + all verified against the official events reference at wire time; event-trigger + workflows must exist on the default branch to fire. + - *Drain* (`temporal`): a scheduled workflow invoking the queue drain. Marked example: + `schedule` cron (shortest interval 5 minutes; runs may be delayed under load; public + repos auto-disable schedules after 60 days without activity — surface both caveats) + plus `workflow_dispatch` for manual kicks. Poll-detector backstops for + `push-lifecycle` wirings ride the same scheduled surface. + - *`channel-feed`* (where wanted): a chat-platform bot + events subscription, or a plain + inbound webhook receiver, normalizing into the same adapter shape — DIY floor only; + vendor-hosted channel agents are step 3's advisory path. + - *`agent-internal`*: no wiring — sessions file follow-up work through the queue seam + directly; the slice records the surface as active and states the `signal.parent_item` + provenance obligation. + - *Executor invocation* (marked example, self-operated CLI class): headless `claude -p` + with `--bare` for deterministic CI context, tool allowlisting via `--allowedTools` / + `--permission-mode` — verified against the official headless reference at wire time. +3. **Advise plan-gated native integrations** — vendor-hosted channel agents and native + tracker automations that carry a plan/seat cost: steps + cost surfaced, explicit opt-in, + never the default path. Zero paid dependencies on the default path. +4. **Bind the drain cadence** — default hourly, org override recorded in the binding. The + drain funnels into the work-item queue capability's autonomous drain mode via the + invocation-adapter seam — one entrypoint, no second dispatch mechanism; the seam's + race-safe lease makes concurrent kicks harmless. +5. **Record execution surfaces** — EVERY kick/drain wiring records its named execution + surface id, the same id the guardrail security binding's per-surface isolation entries + key on. The recorded id is repo-local (agent-writable) convenience only: per the + contract's execution-surface attestation rule, the admission/executor seam derives the + ACTUAL surface identity from platform-attested runtime metadata and verifies it against + the recorded id — a mismatch, an unattestable surface, or a surface without an L2+ + isolation binding fail-closes to human-gated. The slice states this next to every + recorded id so no reader mistakes the record for the enforcement. +6. **Admission enforcement wiring** — every adapter shape points at the guardrail admission + seam (the admission policy bound on the org's security governance surface). With NO + admission binding present the wiring fail-closes: every signal enqueues human-gated, + never dropped, never auto-dispatched. This slice wires the enforcement point; it never + defines policy content. +7. **Record the binding** — the `triggers` section of the schema-versioned binding + (additive, absent-section tolerance, no major bump), with these serialized keys: + + | Key | Value | + |---|---| + | `surfaces` | object keyed by surface id — each entry `{"class": "", "transport": "push"\|"push-lifecycle"\|"poll", "scheduler_class": "ci-cron"\|"local-scheduler", "execution_surface": ""}`; `scheduler_class` is REQUIRED on temporal surfaces (and only there) — the discriminator `signal.raw_link` form validation branches on; a `local-scheduler` surface using an org artifact store additionally declares `artifact_schemes` (array of URI schemes) — undeclared non-`file:`/non-`https:` schemes never conform. Any later additive section that records scheduling surfaces (routines) uses the same `surfaces` map shape, so envelope validation resolves `signal.source_surface` against every section uniformly | + | `drain` | `{"cadence": "", "execution_surface": ""}` | + +8. **Conformance** — run + [`scripts/check-signal-envelope.mjs`](scripts/check-signal-envelope.mjs) against a queued + item's body (with `--binding` pointing at the resolved binding) to verify the envelope + marker record before declaring the wired state reached. + ## What this skill does NOT do -- Wire capability slices that have not shipped yet (adapters) — each lands with its - own work package and extends this skill. +- Wire capability slices that have not shipped yet (guardrail matrix, routines) — each lands + with its own work package and extends this skill. - Estimate, impute, or backfill the two human-attested return fields — ever. - Mutate platform settings, user settings, or `pluginConfigs`. - Assume the shape of any particular org or fleet — a run against an unknown repo asks or diff --git a/plugins/autonomy/skills/setup/evals/evals.json b/plugins/autonomy/skills/setup/evals/evals.json index 8cdfe3930..423dc177e 100644 --- a/plugins/autonomy/skills/setup/evals/evals.json +++ b/plugins/autonomy/skills/setup/evals/evals.json @@ -170,6 +170,71 @@ "attestation_source maps to a same-named native field, not a comment marker", "States that absent a verifiable provenance signal the comment floor is retained, per the record-integrity rule" ] + }, + { + "id": 14, + "name": "trigger-discovery-interviews-surfaces-per-class", + "prompt": "/autonomy:setup apply\n\nWire triggers and dispatch.", + "expected_output": "Runs surface discovery before wiring anything: inspects and interviews which of the four contract surface classes exist for this org, the transport each surface actually offers (push / push-lifecycle / poll), and entitlements — recording per-org absence as a binding outcome rather than treating it as a blocker, and routing entitlement-gapped surfaces to the advisory path. Vendor event specifics are re-verified against current official docs at wire time, not recalled.", + "files": [], + "expectations": [ + "Discovers surfaces per surface class before any wiring", + "Records transport capability per surface, not one global assumption", + "Per-org class absence recorded as a binding outcome, never a contract deferral or a blocker", + "Re-verifies vendor event/schedule specifics against current docs at wire time" + ] + }, + { + "id": 15, + "name": "trigger-diy-floor-single-entrypoint", + "prompt": "/autonomy:setup apply\n\nWire the free trigger path: label events should enqueue work and something should drain the queue.", + "expected_output": "Wires the DIY floor as reviewable changes: a platform event kick running the tracker-vcs-event adapter shape (normalize + enqueue with the full signal envelope, dedup identity, raw link, traceparent, ack echo) and a scheduled drain defaulting to hourly — both funneling into the work-item queue capability's autonomous drain mode via the invocation-adapter seam. No second dispatch or claim path is created; every kick/drain wiring records its named execution surface id with the attestation caveat stated.", + "files": [], + "expectations": [ + "Event kick and scheduled drain both funnel into the one queue-drain entrypoint", + "Adapter shape carries the envelope obligations (identity, raw link, traceparent, ack)", + "Drain cadence defaults to hourly with the org override recorded in the binding", + "Execution-surface id recorded on every wiring with the fail-closed attestation caveat stated" + ] + }, + { + "id": 16, + "name": "trigger-plan-gated-surface-advisory-not-default", + "prompt": "/autonomy:setup apply\n\nJust turn on the native chat-platform agent integration for channel triggers, whatever plan it needs.", + "expected_output": "Refuses to default to a plan-gated surface: surfaces that vendor-hosted channel integrations carry a plan/seat cost, presents the cost explicitly before any opt-in, offers the DIY floor (bot + events subscription or plain inbound webhook) as the zero-cost alternative, and notes the contract's carried research gaps on channel surfaces rather than asserting stale capabilities.", + "files": [], + "expectations": [ + "Plan-gated channel integration treated as advisory + explicit opt-in, never default", + "Cost surfaced before any opt-in question", + "DIY floor (bot/webhook) offered as the zero-cost alternative", + "Channel-surface research gaps carried visibly, not asserted as settled" + ] + }, + { + "id": 17, + "name": "trigger-absent-admission-binding-fail-closed", + "prompt": "/autonomy:setup apply\n\nWe have no guardrail or admission policy yet — can triggered work still run autonomously?", + "expected_output": "States the fail-closed rule: with no admission binding present, every signal still enqueues but human-gated — nothing is dropped and nothing auto-dispatches; the admission seam is wired as the enforcement point while policy content stays owned by the guardrail matrix, and autonomous dispatch relaxes only when that policy lands.", + "files": [], + "expectations": [ + "Absent admission binding fail-closes to human-gated enqueue, never silent drop", + "No autonomous dispatch without an admission binding", + "Admission enforcement wired at the seam; policy content never defined by setup", + "States that a later guardrail policy is what relaxes the gate" + ] + }, + { + "id": 18, + "name": "trigger-non-interactive-apply", + "prompt": "/autonomy:setup apply --org-policy-home none --budget-posture free\n\nWire the trigger slice non-interactively with the DIY floor defaults.", + "expected_output": "Completes without prompting: wires the DIY floor with headless defaults (event kick where the platform offers one, hourly scheduled drain), records the triggers section (surfaces map with class/transport/execution_surface, drain cadence) additively in the schema-versioned binding, names check-signal-envelope.mjs as the conformance step, and reports what was wired, advised, and skipped.", + "files": [], + "expectations": [ + "Zero interactive prompts issued", + "Triggers section recorded additively with surfaces map and drain cadence", + "check-signal-envelope named as the conformance verification step", + "Reports wired vs advised vs skipped transparently" + ] } ] } diff --git a/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs b/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs new file mode 100755 index 000000000..df5d80427 --- /dev/null +++ b/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node + +// Signal-envelope check for the trigger-dispatch contract: validates the +// JSON-fenced `` marker record on a queued item's +// body. This is the contract's enforcement surface — adopters and the +// conforming-path demo run it against created queue items. +// +// Usage: node check-signal-envelope.mjs [...more] [--binding ] +// Exit 0 = conformant; 1 = findings; 2 = usage/environment error. +// +// The binding input is the resolved schema-versioned autonomy binding: a +// temporal signal's `signal.source_surface` must resolve to a surface recorded +// there, and the surface's `scheduler_class` deterministically branches the +// `signal.raw_link` form (local-scheduler surfaces have no web origin, so a +// durable local/artifact URI is legal there and only there). Every additive +// binding section that records scheduling surfaces (triggers, routines) uses +// the same `surfaces` map shape; the resolver reads them all uniformly. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +const MARKER = ""; +// Each supported minor version is added here together with its validation +// rules — an unknown 1.x must not certify semantics this checker cannot see. +const SUPPORTED_SCHEMA_VERSIONS = new Set(["1.0"]); +const SURFACE_CLASSES = new Set(["tracker-vcs-event", "temporal", "agent-internal", "channel-feed"]); +const TRANSPORTS = new Set(["push", "push-lifecycle", "poll"]); +const PROVENANCES = new Set(["human", "agent", "system"]); +const WORK_CLASSES = new Set(["C1", "C2", "C3", "C4", "C5"]); +const REQUIRED_KEYS = [ + "signal.class", + "signal.transport", + "signal.provenance", + "signal.identity", + "signal.raw_link", + "signal.traceparent", +]; +// W3C Trace Context traceparent: this contract supports version "00" only +// (and "ff" is forbidden by the spec outright); all-zero trace-id/parent-id +// invalid; version-00 trace flags define only the sampled bit (00 or 01 — +// reserved bits must not be set by the authoring adapter). +const TRACEPARENT = /^00-(?!0{32})[0-9a-f]{32}-(?!0{16})[0-9a-f]{16}-0[01]$/; + +const findings = []; + +function parseUrl(value) { + if (typeof value !== "string" || /\s/.test(value) || value.length === 0) return null; + try { + return new URL(value); + } catch { + return null; + } +} + +// Absolute https URL with a host; query and fragment PRESERVED (permalinks and +// comment anchors need them — the telemetry strip rule is the join key's, not +// the raw link's). +function isAbsoluteHttpsUrl(value) { + const url = parseUrl(value); + return url !== null && url.protocol === "https:" && url.hostname.length > 0; +} + +// Schemes that can never be durable artifact locators — rejected even when a +// binding declares them (a declaration cannot make data:/javascript: durable). +const NON_DURABLE_SCHEMES = new Set(["data", "javascript", "blob", "about", "http", "mailto", "tel", "vbscript"]); + +// Durable local/artifact URI for local-scheduler-origin temporal signals: +// file: and https: qualify by contract; an org artifact-store scheme qualifies +// only when the binding's surface entry DECLARES it (artifact_schemes) AND the +// scheme is not in the non-durable set — an undeclared scheme never conforms. +function isDurableLocalUri(value, surfaceEntry) { + const url = parseUrl(value); + if (url === null) return false; + if (url.protocol === "file:" || url.protocol === "https:") return true; + const declared = Array.isArray(surfaceEntry?.artifact_schemes) ? surfaceEntry.artifact_schemes : []; + return declared.some( + (scheme) => !NON_DURABLE_SCHEMES.has(scheme) && url.protocol === `${scheme}:`, + ); +} + +// The normalized canonical item URL per the telemetry contract's strip rule: +// https, non-empty host, no query/fragment/trailing slash, parser round-trip. +function isNormalizedCanonicalUrl(value) { + const url = parseUrl(value); + return ( + url !== null && + url.protocol === "https:" && + url.hostname.length > 0 && + url.search === "" && + url.hash === "" && + url.href === value && + !value.includes("?") && + !value.includes("#") && + !value.endsWith("/") + ); +} + +function extractEnvelope(body, where) { + const markerIndex = body.indexOf(MARKER); + if (markerIndex === -1) { + findings.push(`${where}: no ${MARKER} marker record found`); + return null; + } + // The contract is ONE marker plus ONE fenced record bound to it — a second + // marker is a conflict to surface for repair, never something to certify. + if (body.indexOf(MARKER, markerIndex + MARKER.length) !== -1) { + findings.push(`${where}: multiple ${MARKER} markers found — exactly one marker record is allowed`); + return null; + } + const fence = body.slice(markerIndex + MARKER.length).match(/^\s*```json\s*\n([\s\S]*?)\n```/); + if (!fence) { + findings.push(`${where}: marker is not immediately followed by its fenced JSON record`); + return null; + } + try { + return JSON.parse(fence[1]); + } catch { + findings.push(`${where}: fenced record after the marker is not valid JSON`); + return null; + } +} + +// Merge every `surfaces` map any top-level binding section records (triggers +// today, routines when that section lands). An id recorded by more than one +// section is AMBIGUOUS — resolution refuses it rather than silently picking a +// winner whose scheduler class may differ. +function collectSurfaces(binding) { + const surfaces = new Map(); + const duplicates = new Set(); + for (const section of Object.values(binding ?? {})) { + if (typeof section !== "object" || section === null) continue; + const map = section.surfaces; + if (typeof map !== "object" || map === null) continue; + for (const [id, entry] of Object.entries(map)) { + if (surfaces.has(id)) duplicates.add(id); + else surfaces.set(id, entry); + } + } + return { surfaces, duplicates }; +} + +function checkEnvelope(envelope, where, { surfaces, duplicates }, bindingSupplied) { + const version = envelope.schema_version; + if (!SUPPORTED_SCHEMA_VERSIONS.has(version)) { + findings.push( + `${where}: schema_version ${JSON.stringify(version)} is not a supported version (${[...SUPPORTED_SCHEMA_VERSIONS].join(", ")})`, + ); + } + for (const key of REQUIRED_KEYS) { + const value = envelope[key]; + if (typeof value !== "string" || value.length === 0) { + findings.push(`${where}: required key ${key} missing or empty`); + } + } + const signalClass = envelope["signal.class"]; + if (typeof signalClass === "string" && !SURFACE_CLASSES.has(signalClass)) { + findings.push(`${where}: signal.class ${JSON.stringify(signalClass)} is not a surface-class token`); + } + const transport = envelope["signal.transport"]; + if (typeof transport === "string" && !TRANSPORTS.has(transport)) { + findings.push(`${where}: signal.transport ${JSON.stringify(transport)} is not a transport token`); + } + const provenance = envelope["signal.provenance"]; + if (typeof provenance === "string" && !PROVENANCES.has(provenance)) { + findings.push(`${where}: signal.provenance ${JSON.stringify(provenance)} is not a provenance token`); + } + const workClass = envelope["signal.work_class"]; + if (workClass !== undefined && !WORK_CLASSES.has(workClass)) { + findings.push(`${where}: signal.work_class ${JSON.stringify(workClass)} is not C1-C5 (omit the key when unclassified)`); + } + const traceparent = envelope["signal.traceparent"]; + if (typeof traceparent === "string" && traceparent.length > 0 && !TRACEPARENT.test(traceparent)) { + findings.push(`${where}: signal.traceparent ${JSON.stringify(traceparent)} is not a valid W3C traceparent`); + } + + // agent-internal: serialized provenance is REQUIRED — an unverifiable + // self-stamped class would bypass admission. + const parentItem = envelope["signal.parent_item"]; + if (signalClass === "agent-internal") { + if (!isNormalizedCanonicalUrl(parentItem)) { + findings.push( + `${where}: signal.parent_item ${JSON.stringify(parentItem)} must be the emitting session's admitted source item as a normalized canonical https URL (required for agent-internal)`, + ); + } + } + + // raw_link form branches DETERMINISTICALLY on the serialized origin. + const rawLink = envelope["signal.raw_link"]; + const sourceSurface = envelope["signal.source_surface"]; + let localScheduler = false; + let surfaceEntry = null; + if (signalClass === "temporal") { + if (typeof sourceSurface !== "string" || sourceSurface.length === 0) { + findings.push(`${where}: signal.source_surface missing (required for temporal signals)`); + } else if (!bindingSupplied) { + findings.push(`${where}: temporal signal requires --binding to resolve signal.source_surface`); + } else if (duplicates.has(sourceSurface)) { + findings.push( + `${where}: signal.source_surface ${JSON.stringify(sourceSurface)} is recorded by more than one binding section — ambiguous, fix the binding`, + ); + } else if (!surfaces.has(sourceSurface)) { + findings.push(`${where}: signal.source_surface ${JSON.stringify(sourceSurface)} is not recorded in any binding surfaces map`); + } else { + const entry = surfaces.get(sourceSurface); + if (entry?.class !== "temporal") { + findings.push( + `${where}: signal.source_surface ${JSON.stringify(sourceSurface)} resolves to a ${JSON.stringify(entry?.class)} surface — a temporal signal's source must be a temporal scheduling surface`, + ); + } else if (entry.scheduler_class !== "ci-cron" && entry.scheduler_class !== "local-scheduler") { + findings.push( + `${where}: surface ${JSON.stringify(sourceSurface)} must declare scheduler_class "ci-cron" or "local-scheduler" (found ${JSON.stringify(entry.scheduler_class)}) — the required discriminator for temporal surfaces`, + ); + } else { + surfaceEntry = entry; + localScheduler = entry.scheduler_class === "local-scheduler"; + } + } + } + if (typeof rawLink === "string" && rawLink.length > 0) { + if (localScheduler ? !isDurableLocalUri(rawLink, surfaceEntry) : !isAbsoluteHttpsUrl(rawLink)) { + findings.push( + `${where}: signal.raw_link ${JSON.stringify(rawLink)} is not a durable absolute reference (${localScheduler ? "local-scheduler origin allows file:, https:, or a binding-declared artifact scheme" : "this origin requires an absolute https URL"})`, + ); + } + } +} + +function filesUnder(target) { + if (statSync(target).isDirectory()) { + return readdirSync(target) + .map((entry) => join(target, entry)) + .flatMap(filesUnder); + } + return [target]; +} + +const args = process.argv.slice(2); +const targets = []; +let bindingPath = null; +for (let i = 0; i < args.length; i += 1) { + if (args[i] === "--binding") { + bindingPath = args[i + 1]; + i += 1; + } else { + targets.push(args[i]); + } +} +if (targets.length === 0) { + console.error("usage: check-signal-envelope.mjs [...more] [--binding ]"); + process.exit(2); +} + +let resolver = { surfaces: new Map(), duplicates: new Set() }; +if (bindingPath !== null) { + try { + resolver = collectSurfaces(JSON.parse(readFileSync(bindingPath, "utf8"))); + } catch (error) { + console.error(`cannot read binding ${bindingPath}: ${error.message}`); + process.exit(2); + } +} + +let envelopesChecked = 0; +try { + for (const target of targets.flatMap(filesUnder)) { + const envelope = extractEnvelope(readFileSync(target, "utf8"), target); + if (envelope === null) continue; + envelopesChecked += 1; + checkEnvelope(envelope, target, resolver, bindingPath !== null); + } +} catch (error) { + // A missing or unreadable target is an environment error (exit 2), never a + // conformance finding. + console.error(`cannot read target: ${error.message}`); + process.exit(2); +} + +if (envelopesChecked === 0) { + findings.push("no signal envelopes found under the given targets — nothing was verified"); +} + +if (findings.length > 0) { + console.error("Signal-envelope conformance FAILED:"); + for (const finding of findings) console.error(`- ${finding}`); + process.exit(1); +} +console.log(`Signal-envelope conformance OK: ${envelopesChecked} envelope(s) checked.`); diff --git a/plugins/autonomy/skills/setup/templates/ack-reply.md b/plugins/autonomy/skills/setup/templates/ack-reply.md new file mode 100644 index 000000000..f30964ac4 --- /dev/null +++ b/plugins/autonomy/skills/setup/templates/ack-reply.md @@ -0,0 +1,13 @@ +# Acknowledgment template + +Closed-loop acknowledgment (contract obligation 6) for bidirectional surfaces — one line, +item URL first (it is the join key an auditor follows), class token for audit: + +```text +Queued as (autonomy: signal) +``` + +Posted as a tracker comment on the source event, a chat thread reply, or the surface's +native response form. Reply-less surfaces (temporal schedules, plain webhooks with no +response channel) satisfy the obligation through `signal.raw_link` alone — no synthetic +reply surface is invented. diff --git a/plugins/autonomy/skills/setup/templates/trigger-adapters.md b/plugins/autonomy/skills/setup/templates/trigger-adapters.md new file mode 100644 index 000000000..6f78809ef --- /dev/null +++ b/plugins/autonomy/skills/setup/templates/trigger-adapters.md @@ -0,0 +1,108 @@ +# Trigger-adapter templates + +Adapter shapes the trigger/dispatch slice wires, one per surface class. `<...>` placeholders +resolve from the binding at wire time; no org, fleet, or vendor value is baked in — vendor +event names appear only as marked examples. Every shape carries the contract's six adapter +obligations inline: normalize+enqueue only, idempotent dedup, provenance + raw link, +traceparent injection, admission enforcement, acknowledgment. Every shape STAMPS +`signal.work_class` from the security-surface classification rules where they resolve, and +leaves it absent (unclassified → human-gated) where they do not. + +## Signal envelope (all classes) + +The enqueue step writes the contract's marker record into the created item body — the +marker line plus one fenced JSON block: + +```markdown + +``` + +```json +{ + "schema_version": "1.0", + "signal.class": "", + "signal.transport": "", + "signal.provenance": "", + "signal.identity": "", + "signal.raw_link": "", + "signal.traceparent": "", + "signal.work_class": "", + "signal.parent_item": "", + "signal.source_surface": "" +} +``` + +Dedup identity derivation (obligation 2): use the surface-native unique event id where the +surface issues one (delivery id, event id). FALLBACK — never a bare content hash — compose +`:::`. The +enqueue is an atomic identity-keyed create/upsert where the tracker offers one; otherwise +search-before-create backed by create-then-reconcile (re-search after create; oldest wins, +close the newer as an audited duplicate). + +## tracker-vcs-event — event kick → enqueue + +A platform event workflow (marked example, GitHub Actions class: `on: issues` types +`labeled`/`assigned`, `on: issue_comment` type `created` for @-mention forms, +`on: pull_request` — the workflow file must exist on the default branch to fire): + +1. Filter to the signal condition (`` applied, assignment to + ``, @-mention token). +2. Derive `signal.identity` from the platform delivery/event id. +3. Stamp `signal.work_class` via the security-bound label→class rules; unresolvable → omit. +4. Enqueue via the bound work-item capability (create the queue item carrying the envelope); + `signal.raw_link` = the triggering event's permalink (query/fragment preserved); + `signal.provenance` = `human` for a human actor, `agent`/`system` per the acting + identity; inject `signal.traceparent`. +5. Admission enforcement: no admission binding → the item is created human-gated (the + fail-closed floor); never dropped. +6. Acknowledge: comment the item reference back on the source event per + [`ack-reply.md`](ack-reply.md). +7. Kick the drain: after enqueue + ack, invoke the SAME queue-drain entrypoint (the + work-item queue capability's autonomous drain mode via the invocation-adapter seam) so + push-originated work gets an event-fired dispatch attempt instead of waiting for the + scheduled catch-up — one entrypoint, concurrent kicks harmless via the seam lease; + admission still governs what the drain may execute (absent binding → the item stays + human-gated). + +## temporal — scheduled drain + poll-detector + +Two shapes on the same scheduled surface (marked example: `schedule` cron — shortest +interval 5 minutes, delays under load, 60-day public-repo auto-disable — plus +`workflow_dispatch` for manual kicks): + +- **Drain** (dispatch, not an adapter): invoke the work-item queue capability's autonomous + drain mode via the invocation-adapter seam — the seam lease claims race-safely; the drain + never re-scans source surfaces, and never claims an item whose `signal.identity` matches + another currently-open item (live-duplicate guard). +- **Poll-detector** (adapter): observe the push-less or `push-lifecycle`-backstopped + surface, and for each detected condition enqueue the envelope with + `signal.transport: "poll"`, `signal.source_surface` = this surface's id in the binding's + `surfaces` map, `signal.raw_link` = a durable reference to the observed state (https + permalink; a local-scheduler surface may use an absolute `file:` or artifact-store URI). + State-based detections with no instance identity bound dedup retention to open items — + re-detection after closure is a new signal. + +## agent-internal — session files follow-up via the queue seam + +No standing wiring: an executing session files follow-up work through the queue seam +directly, carrying the envelope with `signal.provenance: "agent"` and +`signal.parent_item` = the canonical URL of the item the session was dispatched on +(REQUIRED — the admission seam verifies the session-to-parent association against the +queue's own lease record; an unverifiable association is NO provenance → unclassified → +human-gated). `signal.raw_link` = a durable reference to the emitting context (the parent +item or its run permalink). Dedup identity composes the parent item + the follow-up's +content hash + the filing timestamp. + +## channel-feed — webhook receiver → enqueue + +A chat-platform bot events subscription or a plain inbound webhook receiver (DIY floor; +vendor-hosted channel agents are advisory, plan-gated): + +1. Validate the subscription handshake where the platform requires one; `push-lifecycle` + transports record expiry and are backed by a temporal poll-detector for the same + surface, or lapse fail-closes to a human-gated alert item. +2. Derive `signal.identity` from the platform's event/delivery id. +3. Enqueue the envelope; `signal.work_class` stays absent (channel-feed is UNCLASSIFIED → + human-gated) unless security-bound rules resolve it; `signal.raw_link` = the message/ + event permalink. +4. Acknowledge in-thread per [`ack-reply.md`](ack-reply.md).