diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index 89bcb2dbb1..3f8952a55b 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -22,7 +22,7 @@ Before writing a new fact anywhere in this repo, ask where it belongs, in this o 1. Does the firstmate AGENT need this on every session or every turn to operate? If yes: `AGENTS.md`, inline. 2. Does the agent need it only in a nameable situation - a spawn, a recovery, a specific wake type, a specific lifecycle step? - If yes: an agent-only skill under `.agents/skills/`, plus a one-line trigger pointer left inline in `AGENTS.md` (usually section 13). + If yes: an agent-only skill under `.agents/skills/`, whose frontmatter `description:` states the trigger; `AGENTS.md` gains an inline pointer only when the skill must load on a wake an operating section already owns. 3. Is it public product, setup, or user/operator reference? If yes: the surface classified for that audience in [`docs/documentation-audiences.md`](../../../docs/documentation-audiences.md), limited to current behavior, setup, supported limits, stable invariants, concise rationale, and current verification entry points. 4. Is it contributor/maintainer architecture? @@ -66,7 +66,8 @@ When in doubt, write the fact into the skill or doc first, and add only the one- ## Trigger hygiene A new skill is dead weight if nothing loads it. -Every new skill needs its load trigger declared inline: section 13 for agent-only reference skills, or the relevant operating section for anything else. +Every new skill needs its load trigger declared once, in its own frontmatter `description:`. +That single declaration is what `bin/fm-skill-index.sh` renders into the session-start digest for harnesses that do not inject skill descriptions, so an agent-only skill needs no second roster entry; add an `AGENTS.md` operating-section pointer only when the skill must load on a wake that section owns. State the trigger as a condition ("load before X", "load on Y wake"), never as a vague pointer. Briefs for tasks that touch firstmate's own tracked material should tell the crewmate to load this skill. `bin/fm-brief.sh`'s `REPO` argument is a caller-supplied string with no reliable signal that it names firstmate's own repo, unlike a project registered in `data/projects.md`, so there is no clean point inside the scaffold to detect this case automatically. diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 94beca00a1..59e3222462 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -191,7 +191,7 @@ A non-final dry-run follow-up increments `x_followups` and keeps the link while ## Completion follow-up (posted on milestone and done wakes, not this turn) When an actionable request spawned a task and you linked it (step 2c), progress and the **outcome** are delivered later as follow-up replies, not in this turn. -This skill is the sole owner of the completion-follow-up procedure below; AGENTS.md §13 declares the load trigger for X-mode-linked milestone or terminal wakes, and AGENTS.md §8 reinforces the terminal final-follow-up step before teardown. +This skill is the sole owner of the completion-follow-up procedure below; AGENTS.md §14 declares the load trigger for X-mode-linked milestone or terminal wakes, and AGENTS.md §8 reinforces the terminal final-follow-up step before teardown. This skill's own responsibility during the mention-handling turn is linking the task in step 2c; the full completion path is: - Firstmate has **up to three** follow-ups per mention, within a 7-day window, chained in the same thread - it spends them only on genuine milestones the captain would want surfaced (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. diff --git a/AGENTS.md b/AGENTS.md index dbb90c483d..91c8c3e3a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,74 +53,18 @@ Each secondmate has a persistent isolated `FM_HOME`, including its own state, ba `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate except under hard rule 1's concrete captain-approved project operation exception. +`CLAUDE.md` is a symlink to this file and `.claude/skills` is a symlink to `.agents/skills`, so an alias and its target are one file, never two places to edit. +Read `docs/configuration.md` before reasoning about any `config/`, `state/`, or `data/` artifact this section does not name, and read the producing script's header and help before changing how one is written. + +These state facts must survive with no other document loaded: + +- A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. +- Never touch the watcher, Claude auto-arm, and sub-supervisor internals under `state/` - the `.hash-*`, `.count-*`, `.stale-*`, `.paused-*`, `.seen-*`, `.last-*`, `.heartbeat-streak`, `.wedge-escalations-*`, `.hb-surfaced-*`, `.claude-autoarm-*`, `.turnend-claude-blocks`, `.subsuper-*`, and `.supervise-daemon.*` records, plus the watcher and wake-queue locks. + Their owning scripts reconcile them, and hand-editing or deleting one silently breaks supervision rather than failing visibly. +- `state/.check.sh` is executed by the watcher only from trusted repository scripts or a hash-validated private snapshot, so a custom check stays inert until section 7's registration binds its bytes. +- `data//report.md` is a scout's deliverable and survives teardown. +- `data/secondmates.md` is created lazily by `bin/fm-home-seed.sh` when the first secondmate is registered, so its absence means no secondmate is registered rather than a lost registry. -``` -AGENTS.md this file (CLAUDE.md is a symlink to it) -CONTRIBUTING.md contributor workflow and repo conventions -README.md public overview and development notes -.github/workflows/ shared CI and PR enforcement, committed -.tasks.toml tracked tasks-axi markdown backend config for the default backlog backend (section 10) -.agents/skills/ firstmate-loaded internal skills, committed; each carries metadata.internal=true for installers -.claude/skills symlink to .agents/skills for claude compatibility -skills/ standalone public installer-facing skills, committed; not loaded by firstmate -bin/ helper scripts, committed; read each script's header before first use -.env optional X-mode pairing token; LOCAL, gitignored; presence-gates section 14 -config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) -config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Also carries the optional inert-by-default `_scheduling.admission_control` fleet-admission policy (section 7). Inherited by secondmate homes -config/models.json optional model registry and enforced zero-budget allowlist; LOCAL, gitignored; binds every routed model to verified cost-class and entitlement evidence (section 4; docs/configuration.md). Inherited by secondmate homes -config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) -config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) -config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning -config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" -config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" -config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" -config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") -config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md -config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present -data/ personal fleet records; LOCAL, gitignored as a whole - backlog.md task queue, dependencies, history - captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update - captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning - learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store - projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6) - secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) - /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate - /report.md scout task deliverable, written by the crewmate; survives teardown -projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception -state/ volatile runtime signals; gitignored - .status appended by crewmates: ": " wake-event lines, not current-state truth - .turn-ended touched by turn-end hooks - .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown - .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) - .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" - .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution - .check-trust private content binding created by fm-check-register.sh for an intentional custom check - .pr-poll private validated data sidecar for the byte-static PR merge poll - .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication - .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire - .pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration - .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations - .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs - x-watch.check.sh generated X-mode relay poll shim; present only when opted in (section 14) - pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh - x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) - x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) - x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) - public-followup/ generated private transport for promised public replies: commitment registrations, typed terminal-result inbox, accepted/rejected ledgers (section 14; bin/fm-public-followup.sh) - x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers - .wake-queue durable queued wakes: epochseqkindkeypayload - .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) - .watch.lock .wake-queue.lock watcher singleton and queue serialization locks - .claude-autoarm.lock .claude-autoarm-epoch .turnend-claude-blocks Claude Stop auto-arm single-flight, epoch, and guard-budget records; never touch - .hash-* .count-* .stale-* .stale-since-* .paused-* .pr-dirty-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch - .watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete - .last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it - .subsuper-* .supervise-daemon.* sub-supervisor internals; never touch -.no-mistakes/ local validation state and evidence; gitignored -``` - -A `state/.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation. Treat `data/captain.md` as the domain-local record of captain preferences, optional `data/captain-shared.md` as the main-authoritative shared captain-preference file for secondmate inheritance, and `data/learnings.md` as curated home-local knowledge, regardless of harness memory. ## 3. Session start (run once at every session start) @@ -150,6 +94,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai 5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. 6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness. + On a harness that does not inject skill descriptions, an agent-only skill trigger index follows that block; treat it as section 13's roster for this session. The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders. The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. @@ -481,24 +426,9 @@ It performs guarded fast-forward updates of firstmate and registered secondmate ## 13. Agent-only reference skills -These skills are not captain-invocable; load them only at their precise triggers. - -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `MODEL_REGISTRY:`, `MODEL_PRICE:`, `MODEL_VERIFY:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. -- `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. -- `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. -- `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. -- `model-onboarding` - load before adding or changing a model in routing config, before probing a model, before acting on an entitlement or price-drift alarm, and before deciding a model promotion or demotion. -- `fleet-admission` - load at intake before dispatching new work in a home with an active admission policy, and whenever an admission band other than preferred is returned, released, or overridden. -- `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. -- `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. -- `project-management` - load before adding, creating, removing, or initializing a project. - Cloning or registering a project is add intake and uses the same trigger. -- `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. -- `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. -- `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. -- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. -- `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. -- `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. +These skills are not captain-invocable; load one only at its precise trigger, and the trigger is the skill's own frontmatter description rather than a roster kept here. +`bin/fm-skill-index.sh` is the single owner of that roster: session start renders it from the skill frontmatter on any harness that does not already inject skill descriptions, and stays silent on one that does. +A new agent-only skill is therefore registered once, in its own frontmatter; add a trigger pointer to an operating section above only when the skill must load on a wake that section owns. ## 14. X mode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c6b83fba4..29b8d1246d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star ## Repo conventions - This repo is a template for running a firstmate orchestrator agent. - `AGENTS.md` is the agent's main job description and names when to load bundled firstmate skills; `CLAUDE.md` is a symlink to it, and `.claude/skills` is a symlink to `.agents/skills`. + `AGENTS.md` is the agent's main job description; `CLAUDE.md` is a symlink to it, and `.claude/skills` is a symlink to `.agents/skills`. - Only shared material is tracked: `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and `skills/`. `.agents/skills/` holds agent-loaded skills that assume a live firstmate home and carry `metadata.internal: true` so installers such as [skills.sh](https://skills.sh) hide them from discovery; `skills/` holds standalone, installer-facing public skills with no firstmate dependency (see the README's "Two-tier skill layout"). Everything personal to one captain's fleet (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) is gitignored; never commit it. diff --git a/README.md b/README.md index 41fe122cfe..6bafd16d0a 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,8 @@ Bearings invocation examples: - `/bearings file` replaces today's `data/status-report-.md` from scratch and links it from the four-section chat digest. - `/bearings file include PRs` combines the dated report with live PR enrichment. -Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). +Agent-only reference skills live under `.agents/skills/` and are marked `user-invocable: false`; firstmate loads each at the trigger stated in that skill's own frontmatter `description:`. +Harnesses that inject skill descriptions surface those triggers on their own, and on any harness that does not, `bin/fm-skill-index.sh` renders the same triggers into the session-start digest. ### Two-tier skill layout diff --git a/bin/fm-doc-audience-check.sh b/bin/fm-doc-audience-check.sh index 3551a7f043..c5bec745f1 100755 --- a/bin/fm-doc-audience-check.sh +++ b/bin/fm-doc-audience-check.sh @@ -6,6 +6,12 @@ # bin/fm-doc-audience-check.sh --root [--inventory ] # # The inventory owns classification and setup routing. +# Every surface must declare audience (who reads it), injection (how it reaches +# a reader), and responsibility (the one sentence it uniquely owns). All three +# are REQUIRED: an unclassifiable surface is the signal that it owns too much or +# has no reason to exist, so the check refuses rather than defaulting a field. +# The responsibility must be exactly one sentence - if it needs two, split the +# document instead of widening the field. # This check validates structure only and does not keyword-lint prose. set -eu @@ -27,6 +33,10 @@ from urllib.parse import unquote, urlsplit MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") HTML_LINK_RE = re.compile(r"\b(?:href|src)=[\"']([^\"']+)[\"']", re.IGNORECASE) REQUIRED_TRACKED_PATTERNS = ["*.md", "*.mdx", "*.rst", "*.txt", "docs/examples/*"] +# A sentence terminator followed by more prose means the responsibility is not +# one sentence. A dot inside a path or filename (bin/fm-lint.sh) has no +# following whitespace, so it does not match. +SENTENCE_BREAK_RE = re.compile(r"[.!?]\s+\S") class CheckError(Exception): @@ -149,6 +159,7 @@ def validate(root: Path, inventory_path: Path) -> tuple[int, int]: setup_audiences = set(list_of_strings(data.get("setupAudiences"), "setupAudiences")) if not setup_audiences <= audiences: fail("setupAudiences contains an audience outside allowedAudiences") + injections = set(list_of_strings(data.get("allowedInjections"), "allowedInjections")) surfaces = data.get("surfaces") if not isinstance(surfaces, list): @@ -160,10 +171,24 @@ def validate(root: Path, inventory_path: Path) -> tuple[int, int]: fail(f"surfaces[{index}] must be an object") path = entry.get("path") audience = entry.get("audience") + injection = entry.get("injection") + responsibility = entry.get("responsibility") if not isinstance(path, str) or not path: fail(f"surfaces[{index}].path must be a non-empty string") if audience not in audiences: fail(f"{path}: unsupported audience {audience!r}") + if injection not in injections: + fail(f"{path}: unsupported injection {injection!r}") + if not isinstance(responsibility, str) or not responsibility.strip(): + fail(f"{path}: responsibility must be a non-empty one-sentence string") + responsibility = responsibility.strip() + if not responsibility.endswith("."): + fail(f"{path}: responsibility must be one sentence ending in a period") + if SENTENCE_BREAK_RE.search(responsibility): + fail( + f"{path}: responsibility must be exactly one sentence - " + "split the document instead of widening the field" + ) paths.append(path) classifications[path] = audience diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 9341416549..a22416e4d6 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -46,6 +46,12 @@ # script points back to the emitted harness supervision # block and deliberately never arms the watcher itself. # +# The supervision-block step (4) also emits the agent-only skill trigger index +# via fm-skill-index.sh, which owns both its content and the emit/suppress +# decision: silent on a harness that already injects skill descriptions, full +# index everywhere else. That replaced AGENTS.md section 13's hand-maintained +# roster, so a new agent-only skill is registered only in its own frontmatter. +# # On a Pi primary, the supervision-block step also checks whether Pi's two # tracked primary extensions are loaded and prints a PI_WATCH_EXTENSION # reminder line when one is missing. @@ -344,6 +350,18 @@ fi --afk "$AFK_PRESENT" \ --x-mode "$X_MODE_PRESENT" +# Agent-only skill trigger index, rendered from each skill's own frontmatter. +# fm-skill-index.sh owns both the content and the emit/suppress decision: it +# prints nothing on a harness that already injects skill descriptions (so the +# index is never a duplicate) and prints the full index everywhere else (so a +# harness without that injection still sees every trigger). AGENTS.md keeps only +# the one-line rule, not the roster. +SKILL_INDEX_OUT=$("$SCRIPT_DIR/fm-skill-index.sh" --harness "$PRIMARY_HARNESS" 2>/dev/null) +if [ -n "$SKILL_INDEX_OUT" ]; then + subsection "AGENT-ONLY SKILLS" + printf '%s\n' "$SKILL_INDEX_OUT" +fi + # --- 4. context digest ----------------------------------------------------- section "CONTEXT" print_file_or_absent "$DATA/projects.md" "data/projects.md" diff --git a/bin/fm-skill-index.sh b/bin/fm-skill-index.sh new file mode 100755 index 0000000000..c3b22da13f --- /dev/null +++ b/bin/fm-skill-index.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# fm-skill-index.sh - render the agent-only skill trigger index from skill frontmatter. +# +# This script is the single owner of the trigger index's CONTENT and of the +# decision about WHEN to emit it. AGENTS.md keeps only the one-line rule that an +# agent-only skill loads at its declared trigger; the triggers themselves live in +# each skill's own frontmatter `description:` and are rendered from there, so a +# new skill is registered in exactly one place instead of two. +# +# WHICH SKILLS: every `.agents/skills/*/SKILL.md` whose frontmatter carries +# `user-invocable: false`. That marker already distinguishes the agent-only +# reference skills from the captain-invocable ones (/afk, /ahoy, /bearings, +# /stow, /updatefirstmate), so no second roster is maintained here. +# +# WHEN IT IS EMITTED, and why the default is to emit: +# Some harnesses inject every skill's frontmatter description into the session +# prompt unprompted. On those, a rendered index is a measured duplicate and is +# suppressed. On every other harness the index may be the only trigger listing +# the session ever sees, so it is emitted. +# Suppression therefore requires POSITIVE evidence that the harness injects +# descriptions. An unverified or unknown harness always gets the index: a +# redundant listing costs bytes, while a wrongly suppressed one silently +# removes every agent-only skill's load trigger. Record new evidence in +# `.agents/skills/harness-adapters/SKILL.md` before adding a harness here. +# +# Verified injecting harnesses (suppressed): +# claude - the harness lists every skill with its description in the system +# prompt; observed directly in a live claude primary session. +# grok - `harness-adapters` records "firstmate skills are discovered" and a +# verified end-to-end `/` invocation. +# Everything else (codex, opencode, pi, pi-signed, kimi, unknown) is emitted. +# +# Usage: fm-skill-index.sh [--harness ] [--force] [--list-agent-only] +# --harness render for this harness; default is the detected primary. +# --force emit even for a suppressed harness (tests and inspection). +# --list-agent-only print just the agent-only skill names, one per line. +# Always exits 0 when it can read the skill directory: this is a reporting +# command composed into the session-start digest, never a gate. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SKILL_DIR="${FM_SKILL_DIR_OVERRIDE:-$REPO_ROOT/.agents/skills}" + +HARNESS= +FORCE=0 +LIST_ONLY=0 + +usage() { + cat <<'EOF' +Usage: fm-skill-index.sh [--harness ] [--force] [--list-agent-only] + +Render the agent-only skill trigger index from each skill's frontmatter. + --harness render for this harness; default is the detected primary. + --force emit even for a suppressed harness (tests and inspection). + --list-agent-only print just the agent-only skill names, one per line. + +Prints nothing on a harness that already injects skill descriptions (claude, +grok), so the index is never a duplicate. Prints the full index on every other +harness, including an unknown one, because suppressing it there would silently +remove every agent-only skill's load trigger. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --harness) + [ "$#" -gt 1 ] || { echo "error: --harness requires a value" >&2; exit 2; } + HARNESS=$2 + shift 2 + ;; + --force) + FORCE=1 + shift + ;; + --list-agent-only) + LIST_ONLY=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +[ -n "$HARNESS" ] || HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) + +# Positive-evidence suppression list; see the header before adding a harness. +harness_injects_descriptions() { + case "$1" in + claude|grok) return 0 ;; + *) return 1 ;; + esac +} + +[ -d "$SKILL_DIR" ] || exit 0 + +if [ "$LIST_ONLY" -eq 0 ] && [ "$FORCE" -eq 0 ] && harness_injects_descriptions "$HARNESS"; then + exit 0 +fi + +# Render each agent-only skill as one "- - " line. +# The frontmatter is the first --- ... --- block. `description:` may be an inline +# scalar or a folded/literal block whose indented continuation lines join with +# single spaces; both forms collapse to one line here. +render() { + local file + for file in "$SKILL_DIR"/*/SKILL.md; do + [ -f "$file" ] || continue + awk ' + BEGIN { in_fm = 0; seen_open = 0; agent_only = 0; name = ""; desc = ""; collecting = 0 } + NR == 1 && $0 == "---" { in_fm = 1; seen_open = 1; next } + in_fm && $0 == "---" { in_fm = 0; next } + !in_fm { next } + + # A non-indented "key:" line ends any block scalar being collected. + /^[^[:space:]#][^:]*:/ { collecting = 0 } + + /^name:[[:space:]]*/ { + name = $0 + sub(/^name:[[:space:]]*/, "", name) + next + } + /^user-invocable:[[:space:]]*/ { + v = $0 + sub(/^user-invocable:[[:space:]]*/, "", v) + gsub(/[[:space:]]+$/, "", v) + if (v == "false") agent_only = 1 + next + } + /^description:[[:space:]]*/ { + v = $0 + sub(/^description:[[:space:]]*/, "", v) + gsub(/[[:space:]]+$/, "", v) + if (v == ">-" || v == ">" || v == "|" || v == "|-") { + collecting = 1 + } else { + desc = v + } + next + } + collecting && /^[[:space:]]+/ { + line = $0 + gsub(/^[[:space:]]+/, "", line) + gsub(/[[:space:]]+$/, "", line) + if (line != "") desc = (desc == "" ? line : desc " " line) + next + } + + END { + if (!seen_open || !agent_only || name == "") exit 0 + if (list_only) { print name; exit 0 } + if (desc == "") desc = "(no description in frontmatter)" + printf "- %s - %s\n", name, desc + } + ' list_only="$LIST_ONLY" "$file" + done +} + +if [ "$LIST_ONLY" -eq 1 ]; then + render | LC_ALL=C sort + exit 0 +fi + +BODY=$(render | LC_ALL=C sort) +[ -n "$BODY" ] || exit 0 + +printf 'AGENT-ONLY SKILL TRIGGERS (harness %s does not inject skill descriptions)\n' "$HARNESS" +printf 'These skills are not captain-invocable; load one only at its trigger below.\n\n' +printf '%s\n' "$BODY" +exit 0 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 7feaa8bdb3..818db6c7eb 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -127,7 +127,7 @@ family_for_basename() { fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-launch-lib.test.sh|fm-lint.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ - fm-subagent-pretool-check.test.sh|\ + fm-skill-index.test.sh|fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) printf '%s\n' pure-contract-unit diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 8e11e8d2e8..c68f0f9e06 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -18,6 +18,13 @@ "maintainer-verification", "agent-runtime" ], + "allowedInjections": [ + "always", + "lazy", + "generated", + "referenced", + "never" + ], "setupAudiences": [ "public-product", "operator-current", @@ -106,259 +113,387 @@ "surfaces": [ { "path": ".agents/skills/afk/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Runs the away-mode daemon lifecycle and its return gate." }, { "path": ".agents/skills/ahoy/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Recaps visible session events since the prior real captain message." }, { "path": ".agents/skills/ask-user-authority/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Decides whether an ask-user finding is a correction within accepted intent or a contract expansion needing the captain." }, { "path": ".agents/skills/bearings/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Renders a pick-up-where-I-left-off fleet digest from live fleet state." }, { "path": ".agents/skills/bootstrap-diagnostics/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Maps each actionable session-start bootstrap diagnostic line to its owner procedure." }, { "path": ".agents/skills/decision-hold-lifecycle/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Guarantees an unresolved captain decision found during investigation reaches the backlog before completion." }, { "path": ".agents/skills/diagnostic-reasoning/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Owns end-user-aligned reproduction and causal separation for a reported bug." }, { "path": ".agents/skills/firstmate-codexapp/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Coordinates a visible Codex Desktop thread without treating it as a selectable spawn backend." }, { "path": ".agents/skills/firstmate-coding-guidelines/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Owns knowledge placement and repo style for changes to firstmate's own shared tracked material." }, { "path": ".agents/skills/firstmate-orca/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Operator checklist for spawning, supervising, and reconciling work on the Orca runtime backend." }, { "path": ".agents/skills/fleet-admission/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Owns the fleet admission-control decision at intake, the third layer above routing and scheduling." }, { "path": ".agents/skills/fmx-respond/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Handles one X-mode mention or milestone from classification through public reply, task linking, and follow-ups." }, { "path": ".agents/skills/harness-adapters/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Holds the verified per-harness spawn, interrupt, resume, dialog, and skill-invocation facts for every supported harness." }, { "path": ".agents/skills/model-onboarding/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Owns the admission policy for routing a new model and the promotion and demotion authority for models already routed." }, { "path": ".agents/skills/project-management/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Owns project add, create, clone, remove, and initialize plus the project registry and delivery-mode choice." }, { "path": ".agents/skills/quota-array-dispatch/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Chooses among a matched dispatch-profile array using current quota data and quota-window pace." }, { "path": ".agents/skills/secondmate-provisioning/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Owns every mutation to a secondmate home's existence, seeding, harness pin, inherited material, and retirement." }, { "path": ".agents/skills/stow/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Sweeps a session for uncaptured durable knowledge and files it under the startup-memory bound." }, { "path": ".agents/skills/stuck-crewmate-recovery/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Reconciles a stuck or missing ordinary direct report before escalating to relaunch or failure." }, { "path": ".agents/skills/updatefirstmate/SKILL.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "lazy", + "responsibility": "Fast-forwards this firstmate home and every registered secondmate home from origin." }, { "path": "AGENTS.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "always", + "responsibility": "States who firstmate is and the operating contract every session needs before it can act." }, { "path": "CLAUDE.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "always", + "responsibility": "Symlink alias that lets a Claude-compatible harness auto-load AGENTS.md; it holds no content of its own." }, { "path": "CONTRIBUTING.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "never", + "responsibility": "States the contributor workflow and repo conventions for people changing firstmate itself." }, { "path": "README.md", - "audience": "public-product" + "audience": "public-product", + "injection": "never", + "responsibility": "Introduces the product and routes a new operator to setup." }, { "path": "docs/architecture.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "referenced", + "responsibility": "Explains firstmate's supervision, spawn, and state mechanisms and the boundaries between them." }, { "path": "docs/arm-pretool-check.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "referenced", + "responsibility": "States the human-readable contract for the watcher arm PreToolUse seatbelt whose semantic owner is bin/fm-arm-command-policy.mjs." }, { "path": "docs/calm-mode-feasibility.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the version-scoped feasibility evidence and supported-API boundaries behind Pi calm mode." }, { "path": "docs/calm.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns the current user-facing /calm usage and limitation contract." }, { "path": "docs/cd-guard.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "referenced", + "responsibility": "States the human-readable contract for the cd-guard PreToolUse seatbelt whose decision owner is bin/fm-cd-command-policy.mjs." }, { "path": "docs/cmux-backend.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns cmux backend setup, limits, and runtime-detection behavior." }, { "path": "docs/codex-app-backend.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Records why Codex App is not a selectable runtime backend and what the host-tool boundary actually supports." }, { "path": "docs/configuration.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Single owner of the operational-home layout and every configuration schema an operator sets." }, { "path": "docs/decision-hold-lifecycle.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "referenced", + "responsibility": "Records the deterministic mechanism and regression evidence behind the decision-hold policy the skill owns." }, { "path": "docs/documentation-audiences.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "referenced", + "responsibility": "Explains the audience classes and points at their machine-consumed owner." }, { "path": "docs/examples/crew-dispatch.json", - "audience": "operator-example" + "audience": "operator-example", + "injection": "referenced", + "responsibility": "Supplies a copyable crew-dispatch profile example an operator adapts into config/crew-dispatch.json." }, { "path": "docs/examples/models.json", - "audience": "operator-example" + "audience": "operator-example", + "injection": "referenced", + "responsibility": "Supplies a copyable model-registry example an operator adapts into config/models.json with their own verified evidence." }, { "path": "docs/examples/wedge-alarm", - "audience": "operator-example" + "audience": "operator-example", + "injection": "referenced", + "responsibility": "Supplies copyable wedge-alarm channel directives an operator adapts into config/wedge-alarm." }, { "path": "docs/fm-test-isolation-proof.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the concurrent isolation proof for the portable parallel test candidate set." }, { "path": "docs/fm-test-portable-shards.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records which test shards are portable and how the portable lane is composed." }, { "path": "docs/gitlab-merge-watch.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the empirical merge-watch verification for GitLab alongside the GitHub watch." }, { "path": "docs/herdr-backend.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns Herdr backend setup, supported protocol versions, limits, and projection behavior." }, { "path": "docs/launcher.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns the current user-facing contract for the bin/fm-launch.sh fleet launcher menu and its limits." }, { "path": "docs/orca-backend.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns Orca backend setup and the worktree-plus-endpoint ownership model it implies." }, { "path": "docs/scripts.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Gives one purpose clause per bin/ script and routes the reader to each script's own header for behavior." }, { "path": "docs/sessionstart-nudge.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns what the tracked native session-open adapters do and how they stay compatible with the digest." }, { "path": "docs/subagent-guard.md", - "audience": "maintainer-architecture" + "audience": "maintainer-architecture", + "injection": "referenced", + "responsibility": "States the contract for the guard that stops a firstmate primary from delegating work outside the fleet." }, { "path": "docs/supervision-protocols/claude.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "generated", + "responsibility": "Supplies the Claude Stop-hook supervision block that session start renders for a Claude primary." }, { "path": "docs/supervision-protocols/codex.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "generated", + "responsibility": "Supplies the Codex foreground-checkpoint supervision block that session start renders for a Codex primary." }, { "path": "docs/supervision-protocols/grok.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "generated", + "responsibility": "Supplies the Grok background-notify supervision block that session start renders for a Grok primary." }, { "path": "docs/supervision-protocols/opencode.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "generated", + "responsibility": "Supplies the OpenCode plugin background-wake supervision block that session start renders for an OpenCode primary." }, { "path": "docs/supervision-protocols/pi.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "generated", + "responsibility": "Supplies the Pi extension background-wake supervision block that session start renders for a Pi primary." }, { "path": "docs/supervision-protocols/unknown.md", - "audience": "agent-runtime" + "audience": "agent-runtime", + "injection": "generated", + "responsibility": "Supplies the fallback supervision block session start renders when the primary harness has no verified wake adapter." }, { "path": "docs/tmux-backend.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns tmux backend behavior as the verified reference runtime and secondmate baseline." }, { "path": "docs/turnend-guard.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "States the current contract for the no-turn-ends-blind primary turn-end backstop." }, { "path": "docs/verification/dispatch-auth.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the evidence supporting the selected-surface authentication guarantee for quota-informed dispatch." }, { "path": "docs/verification/public-followup.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the evidence supporting the promised-public-reply durability and opt-in-cost guarantees." }, { "path": "docs/verification/runtime-backends.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records reusable version-scoped evidence for the active runtime-backend guarantees." }, { "path": "docs/verification/stow-memory.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the evidence supporting the bounded-memory and whole-file curation guarantees for /stow." }, { "path": "docs/verification/supervision.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the evidence supporting current session-start, turn-end, watcher-continuity, and wedge-alarm guarantees." }, { "path": "docs/verification/worktree-allocation.md", - "audience": "maintainer-verification" + "audience": "maintainer-verification", + "injection": "referenced", + "responsibility": "Records the measured treehouse behavior the pre-allocation guard in bin/fm-worktree-guard.sh depends on." }, { "path": "docs/watcher-continuity.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns how must-work watcher continuity survives above the one-shot watcher process boundary." }, { "path": "docs/wedge-alarm.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns the away-mode injection wedge alarm and its configurable alert channels." }, { "path": "docs/zellij-backend.md", - "audience": "operator-current" + "audience": "operator-current", + "injection": "referenced", + "responsibility": "Owns Zellij backend setup and its explicit-only selection rule." }, { "path": "skills/stow/SKILL.md", - "audience": "public-product" + "audience": "public-product", + "injection": "never", + "responsibility": "The standalone installer-facing stow skill for users who do not run a firstmate home." } ] } diff --git a/docs/documentation-audiences.md b/docs/documentation-audiences.md index ca569041a0..13b7a9c768 100644 --- a/docs/documentation-audiences.md +++ b/docs/documentation-audiences.md @@ -4,6 +4,15 @@ `bin/fm-doc-audience-check.sh` validates exact inventory coverage, README setup routing, required owner pointers, and local link targets. Audience metadata is centralized there rather than copied into front matter on every page. +Every surface declares three required fields, and the check refuses a surface that omits any of them rather than defaulting one: + +- `audience` - who reads it, from the classes below. +- `injection` - how it reaches a reader: `always` (in context at every session with no action), `lazy` (loaded at a named trigger), `generated` (reaches a reader only through a renderer), `referenced` (cited and read on demand), or `never` (never loaded into an agent session). +- `responsibility` - the single sentence this surface uniquely owns. + +The one-sentence limit on `responsibility` is the enforcement, not a style preference: a surface that needs two sentences owns too much, and the fix is to split the document rather than widen the field. +Because `injection` is declared per surface, the always-loaded set is a queryable list instead of a claim, so growth in it is visible in review. + The audience classes have one placement purpose each: - `public-product` introduces the product or provides standalone public material. diff --git a/docs/scripts.md b/docs/scripts.md index ea4a262822..056ff6038d 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -40,6 +40,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) | | `fm-subagent-pretool-check.sh` | Primary-home delegation-shape PreToolUse guard (docs/subagent-guard.md) | | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | +| `fm-skill-index.sh` | Render the agent-only skill trigger index from skill frontmatter, suppressed on harnesses that already inject descriptions | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | | `fm-worktree-guard.sh` | Refuse Treehouse allocation when an available pool slot is not demonstrably empty | diff --git a/tests/fm-documentation-audiences.test.sh b/tests/fm-documentation-audiences.test.sh index 90222802f6..51fd080f7b 100755 --- a/tests/fm-documentation-audiences.test.sh +++ b/tests/fm-documentation-audiences.test.sh @@ -45,6 +45,14 @@ elif mode.name == "missing-owner-pointer": } elif mode.name == "shrink-scope": data["scope"]["trackedPatterns"] = ["README.md"] +elif mode.name == "missing-injection": + del data["surfaces"][0]["injection"] +elif mode.name == "bad-injection": + data["surfaces"][0]["injection"] = "sometimes" +elif mode.name == "missing-responsibility": + del data["surfaces"][0]["responsibility"] +elif mode.name == "two-sentence-responsibility": + data["surfaces"][0]["responsibility"] = "Owns one thing. It also owns another thing." else: raise SystemExit(f"unknown mode: {mode.name}") destination.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") @@ -77,6 +85,48 @@ test_duplicate_and_setup_classification_fail() { pass "classification, setup routing, and maintained-prose scope fail safely" } +test_injection_and_responsibility_are_required() { + local missing_injection="$TMP_ROOT/missing-injection.json" + local bad_injection="$TMP_ROOT/bad-injection.json" + local missing_responsibility="$TMP_ROOT/missing-responsibility.json" + local two_sentence="$TMP_ROOT/two-sentence.json" + mutate_inventory "$INVENTORY" "$missing_injection" missing-injection + mutate_inventory "$INVENTORY" "$bad_injection" bad-injection + mutate_inventory "$INVENTORY" "$missing_responsibility" missing-responsibility + mutate_inventory "$INVENTORY" "$two_sentence" two-sentence-responsibility + run_expect_failure "unsupported injection" \ + "$CHECK" --inventory "$missing_injection" + run_expect_failure "unsupported injection 'sometimes'" \ + "$CHECK" --inventory "$bad_injection" + run_expect_failure "responsibility must be a non-empty one-sentence string" \ + "$CHECK" --inventory "$missing_responsibility" + run_expect_failure "responsibility must be exactly one sentence" \ + "$CHECK" --inventory "$two_sentence" + pass "every surface must declare a supported injection and exactly one sentence of responsibility" +} + +test_always_loaded_set_is_queryable() { + local always + always=$(python3 - "$INVENTORY" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +for entry in data["surfaces"]: + if entry["injection"] == "always": + print(entry["path"]) +PY +) || fail "could not query the always-loaded set" + assert_contains "$always" "AGENTS.md" \ + "the always-loaded set does not name the operating contract" + # The always-loaded plane is the scarcest resource in the fleet; a surface + # joining it should be a deliberate, visible review event, not a default. + [ "$(printf '%s\n' "$always" | grep -c .)" -le 2 ] \ + || fail "the always-loaded prose set grew beyond AGENTS.md and its symlink alias" + pass "the always-loaded prose set is a queryable list, not a claim" +} + test_required_pointer_fails() { local missing_pointer="$TMP_ROOT/missing-pointer.json" mutate_inventory "$INVENTORY" "$missing_pointer" missing-owner-pointer @@ -92,16 +142,21 @@ write_fixture_inventory() { "version": 1, "scope": {"trackedPatterns": ["*.md", "*.mdx", "*.rst", "*.txt", "docs/examples/*"]}, "allowedAudiences": ["public-product", "operator-current", "maintainer-verification"], + "allowedInjections": ["always", "lazy", "generated", "referenced", "never"], "setupAudiences": ["public-product", "operator-current"], "readmeSetupTargets": ["docs/setup.md"], "requiredOwnerPointers": [ {"source": "README.md", "target": "docs/policy.md"} ], "surfaces": [ - {"path": "README.md", "audience": "public-product"}, - {"path": "docs/evidence.md", "audience": "maintainer-verification"}, - {"path": "docs/policy.md", "audience": "operator-current"}, - {"path": "docs/setup.md", "audience": "operator-current"} + {"path": "README.md", "audience": "public-product", "injection": "never", + "responsibility": "Introduces the fixture product."}, + {"path": "docs/evidence.md", "audience": "maintainer-verification", "injection": "referenced", + "responsibility": "Records fixture verification evidence."}, + {"path": "docs/policy.md", "audience": "operator-current", "injection": "referenced", + "responsibility": "Owns the fixture policy."}, + {"path": "docs/setup.md", "audience": "operator-current", "injection": "referenced", + "responsibility": "Owns fixture setup."} ] } JSON @@ -137,5 +192,7 @@ MD test_repository_inventory_passes test_duplicate_and_setup_classification_fail +test_injection_and_responsibility_are_required +test_always_loaded_set_is_queryable test_required_pointer_fails test_local_links_and_no_keyword_heuristic diff --git a/tests/fm-skill-index.test.sh b/tests/fm-skill-index.test.sh new file mode 100755 index 0000000000..6f41b8f62b --- /dev/null +++ b/tests/fm-skill-index.test.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# tests/fm-skill-index.test.sh - behavior tests for bin/fm-skill-index.sh, the +# generated agent-only skill trigger index that replaced AGENTS.md section 13's +# hand-maintained roster. +# +# Coverage: +# - suppressed on a harness that injects skill descriptions (claude, grok) +# - emitted on a harness that does not (opencode, pi, pi-signed, codex, kimi) +# - an unknown or unverified harness EMITS: suppression requires positive +# evidence, because a wrongly suppressed index silently removes every +# agent-only skill's load trigger +# - the roster is exactly the user-invocable:false skills, so captain-invocable +# skills never leak into it and a new agent-only skill needs no second entry +# - each rendered trigger is the skill's own frontmatter description, including +# folded multi-line descriptions collapsed to one line +# - --force renders even for a suppressed harness +# - the session-start digest composes the real script: suppressed under a +# claude primary, present under an opencode primary +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +INDEX="$ROOT/bin/fm-skill-index.sh" +SESSION_START="$ROOT/bin/fm-session-start.sh" +SKILL_DIR="$ROOT/.agents/skills" +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-skill-index.XXXXXX") +trap 'rm -rf "$TMP_ROOT"' EXIT + +INJECTING_HARNESSES="claude grok" +NON_INJECTING_HARNESSES="opencode pi pi-signed codex kimi unknown" + +test_suppressed_for_injecting_harnesses() { + local harness out + for harness in $INJECTING_HARNESSES; do + out=$("$INDEX" --harness "$harness") \ + || fail "index exited non-zero for $harness" + [ -z "$out" ] \ + || fail "index must stay silent on $harness, which injects skill descriptions; got: $out" + done + pass "index is suppressed on every harness that already injects skill descriptions" +} + +test_emitted_for_non_injecting_harnesses() { + local harness out + for harness in $NON_INJECTING_HARNESSES; do + out=$("$INDEX" --harness "$harness") \ + || fail "index exited non-zero for $harness" + assert_contains "$out" "AGENT-ONLY SKILL TRIGGERS" \ + "index was not emitted for $harness" + assert_contains "$out" "- harness-adapters - " \ + "index for $harness omitted a known agent-only skill" + done + pass "index is emitted for every harness without verified description injection, including unknown" +} + +test_roster_is_exactly_the_agent_only_skills() { + local rendered expected agent_only user_invocable name + rendered=$("$INDEX" --harness opencode | sed -n 's/^- \([a-z0-9-]*\) - .*/\1/p' | LC_ALL=C sort) + expected=$("$INDEX" --list-agent-only) + [ "$rendered" = "$expected" ] \ + || fail "rendered roster does not match --list-agent-only" + + # Derive the truth independently from the skill frontmatter, not from the + # script's own listing, so a regression in the selector is visible here. + agent_only="" + for dir in "$SKILL_DIR"/*/; do + name=$(basename "$dir") + [ -f "$dir/SKILL.md" ] || continue + user_invocable=$(grep -m1 '^user-invocable:' "$dir/SKILL.md" | awk '{print $2}') + [ "$user_invocable" = "false" ] || continue + agent_only="$agent_only$name +" + done + agent_only=$(printf '%s' "$agent_only" | LC_ALL=C sort) + [ "$rendered" = "$agent_only" ] \ + || fail "roster is not exactly the user-invocable:false skills" + + # A captain-invocable skill must never appear in the agent-only index. + assert_not_contains "$rendered" "updatefirstmate" \ + "captain-invocable skill leaked into the agent-only index" + assert_not_contains "$rendered" "bearings" \ + "captain-invocable skill leaked into the agent-only index" + pass "roster is exactly the user-invocable:false skills, derived from frontmatter" +} + +test_trigger_text_comes_from_frontmatter() { + local out line + out=$("$INDEX" --harness pi) + # bootstrap-diagnostics' trigger detail lives in the second sentence of a + # folded description; a naive first-line parse would drop it. + line=$(printf '%s\n' "$out" | grep '^- bootstrap-diagnostics - ') + assert_contains "$line" "actionable diagnostic line" \ + "folded description was truncated before its trigger condition" + assert_contains "$line" "BOOTSTRAP_INFO" \ + "folded description lost its final no-load qualification" + # Folding must produce exactly one line per skill. + [ "$(printf '%s\n' "$line" | wc -l)" -eq 1 ] \ + || fail "a folded description rendered as more than one line" + pass "rendered triggers are the skills' own folded frontmatter descriptions" +} + +test_force_overrides_suppression() { + local out + out=$("$INDEX" --harness claude --force) || fail "--force exited non-zero" + assert_contains "$out" "AGENT-ONLY SKILL TRIGGERS" \ + "--force did not render for a suppressed harness" + pass "--force renders the index even for a suppressed harness" +} + +# A fake `ps` reporting every queried pid as one harness, so ancestry detection +# in bin/fm-harness.sh resolves deterministically for the markerless harnesses. +# Mirrors tests/fm-session-start.test.sh's make_fake_ps_harness. +make_fake_ps_harness() { + local fakebin=$1 harness=$2 + mkdir -p "$fakebin" + cat > "$fakebin/ps" <&1) \ + || fail "session start failed under a claude primary" + assert_contains "$digest" "SESSION START" "claude digest did not render" + assert_not_contains "$digest" "AGENT-ONLY SKILL TRIGGERS" \ + "claude digest carried a duplicate skill index" + + # opencode is markerless, so drop every harness env marker and pin ancestry. + make_fake_ps_harness "$fakebin" opencode + digest=$(env -u CLAUDECODE -u PI_CODING_AGENT -u FM_PI_HARNESS -u GROK_AGENT \ + FM_HOME="$home" PATH="$fakebin:$base_path" "$SESSION_START" 2>&1) \ + || fail "session start failed under an opencode primary" + assert_contains "$digest" "AGENT-ONLY SKILL TRIGGERS" \ + "opencode digest lost the agent-only skill index" + assert_contains "$digest" "- stuck-crewmate-recovery - " \ + "opencode digest index omitted a known agent-only skill" + pass "session start emits the index for opencode and suppresses it for claude" +} + +test_suppressed_for_injecting_harnesses +test_emitted_for_non_injecting_harnesses +test_roster_is_exactly_the_agent_only_skills +test_trigger_text_comes_from_frontmatter +test_force_overrides_suppression +test_session_start_composes_the_real_index