From fd5a6d98ab5335d87597a5ce8b600164ac2984cb Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Thu, 30 Jul 2026 18:48:03 -0400 Subject: [PATCH 1/3] docs(fleet): consolidate AGENTS.md layout and skill index, enforce doc ownership Lane B of the documentation single-source-of-truth commission: reduce the always-loaded contract to what only it can own, and convert the review's ownership table from prose into a mechanically enforced artifact. B1/B4 - AGENTS.md section 2 becomes a pointer plus safety lines. The 9,407-byte fenced home tree restated docs/configuration.md, which already declares itself the layout owner and explicitly pushes exact child fields to each producing script's header. Every artifact the tree named was verified to have such an owner before removal. What survives is what has no other owner: the wake-event-not-truth rule, the never-touch watcher/auto-arm/sub-supervisor classes, the check.sh execution-trust boundary, the scout report's survival of teardown, and the corrected secondmates.md wording (created lazily by fm-home-seed.sh, so absence means no secondmate is registered). B2 - section 13 becomes a generated trigger index. bin/fm-skill-index.sh renders the roster from each skill's own frontmatter, selecting on user-invocable:false. It stays silent on harnesses that already inject skill descriptions (claude, grok - both verified) and emits everywhere else, including unknown harnesses: suppression requires positive evidence, because a wrongly suppressed index silently removes every load trigger. A new agent-only skill is now registered once, in its own frontmatter. B3 - documentation-audiences.json gains injection and responsibility per surface, and fm-doc-audience-check.sh requires both. The one-sentence limit on responsibility is the enforcement: a surface needing two sentences owns too much. The always-loaded set is now a queryable list rather than a claim. Measured byte deltas (always-loaded plane): AGENTS.md 55,237 -> 45,071 (-10,166) section 2 10,777 -> 2,758 (-8,019; plan target -6,907) section 13 2,913 -> 605 (-2,308; plan target -2,927) Generated index costs 5,329 bytes only on non-injecting harnesses, so the net is -10,166 on claude/grok and -4,837 elsewhere. documentation-audiences.json grows 8,344 -> 17,081, never injected. Certification: session-start smoke read under both a claude and an opencode primary confirms no operator action lost its instruction; new tests/fm-skill-index.test.sh (6 cases) covers suppression, emission, roster derivation, folded-description rendering, and digest composition; tests/fm-documentation-audiences.test.sh gains 2 cases for the required fields; fm-lint.sh and fm-doc-audience-check.sh green. --- .../firstmate-coding-guidelines/SKILL.md | 5 +- .agents/skills/fmx-respond/SKILL.md | 2 +- AGENTS.md | 100 ++----- README.md | 3 +- bin/fm-doc-audience-check.sh | 25 ++ bin/fm-session-start.sh | 18 ++ bin/fm-skill-index.sh | 176 +++++++++++++ bin/fm-test-run.sh | 2 +- docs/documentation-audiences.json | 243 +++++++++++++----- docs/documentation-audiences.md | 9 + docs/scripts.md | 1 + tests/fm-documentation-audiences.test.sh | 65 ++++- tests/fm-skill-index.test.sh | 161 ++++++++++++ 13 files changed, 657 insertions(+), 153 deletions(-) create mode 100755 bin/fm-skill-index.sh create mode 100755 tests/fm-skill-index.test.sh 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/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..77a848bcba 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,43 +113,63 @@ "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", @@ -150,11 +177,15 @@ }, { "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", @@ -162,87 +193,129 @@ }, { "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", @@ -250,23 +323,33 @@ }, { "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", @@ -274,71 +357,105 @@ }, { "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", @@ -346,19 +463,27 @@ }, { "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 From ee351701d4fbee4f6790a25191ed32c683037382 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Thu, 30 Jul 2026 18:59:32 -0400 Subject: [PATCH 2/3] no-mistakes(document): drop stale CONTRIBUTING claim that AGENTS.md names skill triggers --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 2c06fed8620bb98a9a0789349659bf50e20562c4 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 2 Aug 2026 14:45:44 -0400 Subject: [PATCH 3/3] docs: classify the trunk surfaces the new audience check now requires The consolidated doc-ownership check requires an injection and responsibility on every inventoried surface. Five surfaces added on the fork trunk after this branch was cut carried only an audience, so the check failed once the two were combined even though each side passed alone. Classify them by their audience's established value: the two agent-only reference skills lazy, the operator example, operator guide, and maintainer verification record referenced. --- docs/documentation-audiences.json | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 77a848bcba..c68f0f9e06 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -173,7 +173,9 @@ }, { "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", @@ -189,7 +191,9 @@ }, { "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", @@ -319,7 +323,9 @@ }, { "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", @@ -353,7 +359,9 @@ }, { "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", @@ -459,7 +467,9 @@ }, { "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",