Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/firstmate-coding-guidelines/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/fmx-respond/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
100 changes: 15 additions & 85 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ Bearings invocation examples:
- `/bearings file` replaces today's `data/status-report-<YYYY-MM-DD>.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

Expand Down
25 changes: 25 additions & 0 deletions bin/fm-doc-audience-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
# bin/fm-doc-audience-check.sh --root <repo> [--inventory <path>]
#
# 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

Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
18 changes: 18 additions & 0 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
176 changes: 176 additions & 0 deletions bin/fm-skill-index.sh
Original file line number Diff line number Diff line change
@@ -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 `/<skill>` invocation.
# Everything else (codex, opencode, pi, pi-signed, kimi, unknown) is emitted.
#
# Usage: fm-skill-index.sh [--harness <name>] [--force] [--list-agent-only]
# --harness <name> 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 <name>] [--force] [--list-agent-only]

Render the agent-only skill trigger index from each skill's frontmatter.
--harness <name> 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 "- <name> - <description>" 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
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading