From 9140b021e1bb34ec1cf44a59fdce207a290740e9 Mon Sep 17 00:00:00 2001 From: irene Date: Sat, 19 Sep 2026 08:02:34 +0900 Subject: [PATCH 01/19] feat: gate fresh CLAUDE.md pointer creation on Claude Code version >= 2.1.277 --- bin/fm-ensure-agents-md.sh | 54 +++++++++-- tests/fm-ensure-agents-md.test.sh | 147 ++++++++++++++++++++++++++++-- 2 files changed, 186 insertions(+), 15 deletions(-) diff --git a/bin/fm-ensure-agents-md.sh b/bin/fm-ensure-agents-md.sh index b164b5d2137..69e742a2835 100755 --- a/bin/fm-ensure-agents-md.sh +++ b/bin/fm-ensure-agents-md.sh @@ -128,6 +128,36 @@ is_canonical_claude_pointer() { claude_pointer_content | cmp -s - "$CLAUDE" } +fm_version_at_least() { # + local candidate=${1:-} floor=${2:-} c f + candidate=${candidate%%[-+]*} + case "$candidate" in ''|*[!0-9.]*) return 1 ;; esac + while [ -n "$floor" ]; do + c=${candidate%%.*} + f=${floor%%.*} + [ -n "$c" ] || c=0 + [ "$c" -gt "$f" ] 2>/dev/null && return 0 + [ "$c" -lt "$f" ] 2>/dev/null && return 1 + case "$candidate" in *.*) candidate=${candidate#*.} ;; *) candidate= ;; esac + case "$floor" in *.*) floor=${floor#*.} ;; *) floor= ;; esac + done + return 0 +} + +# Returns 0 if local Claude Code is installed and version is >= 2.1.277. +# Returns 1 if missing, unparseable, command errors, or version < 2.1.277. +claude_supports_native_agents_md() { + command -v claude >/dev/null 2>&1 || return 1 + local ver_str ver + ver_str=$(claude --version 2>/dev/null) || return 1 + if [[ "$ver_str" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then + ver="${BASH_REMATCH[1]}" + else + return 1 + fi + fm_version_at_least "$ver" "2.1.277" +} + # Write the canonical pointer as a regular file. Unlink a symlink first so the # write cannot follow it and destroy AGENTS.md. Never overwrite a distinct real # file; callers classify that as a conflict before invoking this. @@ -209,11 +239,19 @@ if [ -e "$AGENTS" ]; then fi if [ ! -e "$CLAUDE" ]; then ensure_maintenance_section - install_claude_pointer - if [ "$MAINT_INJECTED" -eq 1 ]; then - echo "updated: added ## Maintaining this file to AGENTS.md and wrote CLAUDE.md @AGENTS.md pointer in $DIR" + if claude_supports_native_agents_md; then + if [ "$MAINT_INJECTED" -eq 1 ]; then + echo "updated: added ## Maintaining this file to AGENTS.md in $DIR" + else + echo "unchanged: AGENTS.md in $DIR" + fi else - echo "wrote: CLAUDE.md @AGENTS.md pointer in $DIR" + install_claude_pointer + if [ "$MAINT_INJECTED" -eq 1 ]; then + echo "updated: added ## Maintaining this file to AGENTS.md and wrote CLAUDE.md @AGENTS.md pointer in $DIR" + else + echo "wrote: CLAUDE.md @AGENTS.md pointer in $DIR" + fi fi exit 0 fi @@ -263,5 +301,9 @@ if [ -e "$CLAUDE" ]; then fi write_skeleton -install_claude_pointer -echo "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer in $DIR" +if claude_supports_native_agents_md; then + echo "created: AGENTS.md in $DIR" +else + install_claude_pointer + echo "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer in $DIR" +fi diff --git a/tests/fm-ensure-agents-md.test.sh b/tests/fm-ensure-agents-md.test.sh index b66c2553c51..a2f13412ea5 100755 --- a/tests/fm-ensure-agents-md.test.sh +++ b/tests/fm-ensure-agents-md.test.sh @@ -26,11 +26,40 @@ write_fixture_claude_pointer() { EOF } +with_mock_claude() { + local output=$1 code=${2:-0} mock_dir + shift 2 + mock_dir=$(mktemp -d "$TMP_ROOT/mock-claude.XXXXXX") + cat > "$mock_dir/claude" </dev/null) + if [ -n "$claude_path" ]; then + claude_dir=$(dirname "$claude_path") + new_path=$(echo "$PATH" | tr ':' '\n' | grep -v -Fx "$claude_dir" | tr '\n' ':') + PATH="$new_path" "$@" + else + "$@" + fi +} + test_created_agents_md_includes_self_governance() { local repo agents repo="$TMP_ROOT/new-project" mkdir -p "$repo" - "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 || fail "fm-ensure-agents-md.sh failed for empty project" + with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 || fail "fm-ensure-agents-md.sh failed for empty project" agents="$repo/AGENTS.md" assert_present "$agents" "AGENTS.md was not created" assert_claude_pointer "$repo/CLAUDE.md" @@ -50,7 +79,7 @@ test_fresh_setup_writes_real_claude_pointer() { local repo out repo="$TMP_ROOT/fresh-pointer-project" mkdir -p "$repo" - out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "fm-ensure-agents-md.sh failed creating a fresh pointer" assert_contains "$out" "created:" "fresh setup did not report created" assert_claude_pointer "$repo/CLAUDE.md" @@ -159,7 +188,7 @@ test_existing_agents_md_without_claude_gains_section_and_pointer() { mkdir -p "$repo" printf '# Existing agent memory\n\nDeploy with kubectl.\n' > "$repo/AGENTS.md" agents="$repo/AGENTS.md" - out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "fm-ensure-agents-md.sh failed for existing AGENTS.md without CLAUDE.md" assert_contains "$out" "updated:" "injection without CLAUDE.md did not report an update" assert_claude_pointer "$repo/CLAUDE.md" @@ -174,13 +203,13 @@ test_existing_agents_md_with_section_reports_unchanged() { repo="$TMP_ROOT/fully-formed-project" mkdir -p "$repo" # Build a fully-formed project (AGENTS.md with the section + canonical pointer). - "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "fm-ensure-agents-md.sh failed building the fully-formed fixture" agents="$repo/AGENTS.md" assert_claude_pointer "$repo/CLAUDE.md" cp "$agents" "$repo/.before" cp "$repo/CLAUDE.md" "$repo/.claude-before" - out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "fm-ensure-agents-md.sh failed on already-formed project" assert_contains "$out" "unchanged:" "already-formed project was not reported unchanged" diff "$repo/.before" "$agents" >/dev/null \ @@ -206,12 +235,12 @@ test_marked_project_guidance_stays_unchanged() { symlink) ln -s AGENTS.md "$repo/CLAUDE.md" ;; promotion) mv "$repo/AGENTS.md" "$repo/CLAUDE.md" ;; esac - "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed for marked project ($route)" cmp -s "$repo/.before" "$repo/AGENTS.md" \ || fail "marked project guidance was modified ($route)" assert_claude_pointer "$repo/CLAUDE.md" - out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "ensure failed on marked project re-run ($route)" assert_contains "$out" "unchanged:" "marked project re-run did not report unchanged" cmp -s "$repo/.before" "$repo/AGENTS.md" \ @@ -235,7 +264,7 @@ test_reworded_guidance_requires_first_line_marker() { 'Keep broadly useful knowledge concise; link to sources and rewrite stale entries.' \ 'Preserve these rules for every agent.' | while IFS= read -r line; do printf '%s%s' "$line" "$eol"; done > "$repo/AGENTS.md" - "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed for unmarked reworded guidance" # AGENTS.md is the helper's generated output contract, not implementation source. assert_grep '## Editing these notes' "$repo/AGENTS.md" "ensure removed project guidance" @@ -243,7 +272,7 @@ test_reworded_guidance_requires_first_line_marker() { [ "$count" -eq 1 ] || fail "guidance without a first-line mark did not gain the canonical section" assert_claude_pointer "$repo/CLAUDE.md" cp "$repo/AGENTS.md" "$repo/.after-first" - "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed on unmarked project re-run" cmp -s "$repo/.after-first" "$repo/AGENTS.md" \ || fail "unmarked project re-run modified guidance" @@ -415,8 +444,107 @@ test_lowercase_agents_md_refuses_case_fragile_pointer() { pass "fm-ensure-agents-md.sh: refuses a case-variant lowercase agents.md (issue #389)" } +test_fresh_setup_version_below_cutoff_writes_pointer() { + local repo out + repo="$TMP_ROOT/fresh-below-cutoff" + mkdir -p "$repo" + out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed for version below cutoff" + assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not report created pointer" + assert_claude_pointer "$repo/CLAUDE.md" + pass "fm-ensure-agents-md.sh: version below cutoff writes pointer" +} + +test_fresh_setup_version_at_cutoff_skips_pointer() { + local repo out + repo="$TMP_ROOT/fresh-at-cutoff" + mkdir -p "$repo" + out=$(with_mock_claude "2.1.277" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed for version at cutoff" + assert_contains "$out" "created: AGENTS.md in" "did not report created AGENTS.md without pointer" + assert_absent "$repo/CLAUDE.md" "CLAUDE.md pointer was created at version cutoff" + pass "fm-ensure-agents-md.sh: version at cutoff skips pointer" +} + +test_fresh_setup_version_above_cutoff_skips_pointer() { + local repo out + repo="$TMP_ROOT/fresh-above-cutoff" + mkdir -p "$repo" + out=$(with_mock_claude "2.2.0 (Claude Code)" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed for version above cutoff" + assert_contains "$out" "created: AGENTS.md in" "did not report created AGENTS.md without pointer" + assert_absent "$repo/CLAUDE.md" "CLAUDE.md pointer was created above version cutoff" + pass "fm-ensure-agents-md.sh: version above cutoff skips pointer" +} + +test_fresh_setup_claude_missing_writes_pointer_fallback() { + local repo out + repo="$TMP_ROOT/fresh-no-claude" + mkdir -p "$repo" + out=$(with_no_claude "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed when claude is missing" + assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not write pointer as fallback" + assert_claude_pointer "$repo/CLAUDE.md" + pass "fm-ensure-agents-md.sh: missing claude falls back to writing pointer" +} + +test_fresh_setup_version_unparseable_writes_pointer_fallback() { + local repo out + repo="$TMP_ROOT/fresh-unparseable" + mkdir -p "$repo" + out=$(with_mock_claude "invalid-version-string" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed when version is unparseable" + assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not write pointer on unparseable version" + assert_claude_pointer "$repo/CLAUDE.md" + pass "fm-ensure-agents-md.sh: unparseable version falls back to writing pointer" +} + +test_fresh_setup_claude_error_writes_pointer_fallback() { + local repo out + repo="$TMP_ROOT/fresh-claude-error" + mkdir -p "$repo" + out=$(with_mock_claude "" 1 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed when claude errors" + assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not write pointer when claude errors" + assert_claude_pointer "$repo/CLAUDE.md" + pass "fm-ensure-agents-md.sh: claude command error falls back to writing pointer" +} + +test_existing_pointer_untouched_at_or_above_cutoff() { + local repo out + repo="$TMP_ROOT/existing-pointer-at-cutoff" + mkdir -p "$repo" + printf '# Project agent memory\n\n## Maintaining this file\n' > "$repo/AGENTS.md" + write_fixture_claude_pointer "$repo" + out=$(with_mock_claude "2.1.277" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed for existing pointer at cutoff" + assert_contains "$out" "unchanged:" "did not report unchanged" + assert_claude_pointer "$repo/CLAUDE.md" + pass "fm-ensure-agents-md.sh: existing pointer is untouched at cutoff" +} + +test_existing_agents_md_without_claude_at_or_above_cutoff() { + local repo out + repo="$TMP_ROOT/existing-bare-at-cutoff" + mkdir -p "$repo" + printf '# Existing agent memory\n\nDeploy with kubectl.\n' > "$repo/AGENTS.md" + out=$(with_mock_claude "2.1.277" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed for bare AGENTS.md at cutoff" + assert_contains "$out" "updated: added ## Maintaining this file to AGENTS.md in" "did not report updated AGENTS.md" + assert_absent "$repo/CLAUDE.md" "CLAUDE.md pointer was created for bare AGENTS.md at cutoff" + pass "fm-ensure-agents-md.sh: bare AGENTS.md at cutoff gains section and skips pointer" +} + test_created_agents_md_includes_self_governance test_fresh_setup_writes_real_claude_pointer +test_fresh_setup_version_below_cutoff_writes_pointer +test_fresh_setup_version_at_cutoff_skips_pointer +test_fresh_setup_version_above_cutoff_skips_pointer +test_fresh_setup_claude_missing_writes_pointer_fallback +test_fresh_setup_version_unparseable_writes_pointer_fallback +test_fresh_setup_claude_error_writes_pointer_fallback +test_existing_pointer_untouched_at_or_above_cutoff +test_existing_agents_md_without_claude_at_or_above_cutoff test_promoted_claude_md_includes_self_governance test_promoted_claude_md_without_trailing_newline_keeps_blank_separator test_existing_agents_md_with_symlink_gains_self_governance @@ -433,3 +561,4 @@ test_agents_md_symlink_is_refused test_wrong_target_symlink_is_refused test_non_regular_claude_md_is_refused test_lowercase_agents_md_refuses_case_fragile_pointer + From c023f898f2dd51b09b766dd5df909693c2b8a7e8 Mon Sep 17 00:00:00 2001 From: irene Date: Sat, 19 Sep 2026 08:14:48 +0900 Subject: [PATCH 02/19] no-mistakes(document): Document version gate on CLAUDE.md pointer creation in script header --- bin/fm-ensure-agents-md.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/fm-ensure-agents-md.sh b/bin/fm-ensure-agents-md.sh index 69e742a2835..cccf9929af2 100755 --- a/bin/fm-ensure-agents-md.sh +++ b/bin/fm-ensure-agents-md.sh @@ -6,7 +6,11 @@ # when neither file exists, promotes a real CLAUDE.md file when it is the only # file present (unless it is already the canonical pointer), converts a correct # CLAUDE.md -> AGENTS.md symlink into the pointer file, and refuses to clobber -# distinct real files or wrong symlinks. +# distinct real files or wrong symlinks. Skips writing a brand-new CLAUDE.md +# pointer (neither file existed, or AGENTS.md existed with no CLAUDE.md) when +# the invoking Claude Code is >= 2.1.277, which reads AGENTS.md natively; see +# claude_supports_native_agents_md. Existing pointers and symlink conversions +# are left alone by this gate for now. # Owns the canonical "## Maintaining this file" self-governance wording for # project AGENTS.md files, injecting it idempotently into created skeletons, # promoted CLAUDE.md files, and existing AGENTS.md files lacking both the exact From c6ce6924000037ef5bc926b956dc0084d3e135cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 19 Sep 2026 16:53:17 +0900 Subject: [PATCH 03/19] feat: remove existing CLAUDE.md pointers, stop creating fresh ones (stage 2) (#2) * feat(bin): complete stage 2 CLAUDE.md pointer removal * no-mistakes(review): Restore column-0 heredoc regression fixture with generic content * no-mistakes(review): Remove stale CLAUDE.md pointer claim from updatefirstmate skill --------- Co-authored-by: irene --- .agents/skills/updatefirstmate/SKILL.md | 2 +- .github/workflows/ci.yml | 7 -- AGENTS.md | 2 +- CLAUDE.md | 2 - CONTRIBUTING.md | 6 +- bin/fm-brief.sh | 2 +- bin/fm-dod-lib.sh | 2 +- bin/fm-ensure-agents-md.sh | 25 +--- bin/fm-ff-lib.sh | 2 +- bin/fm-test-run.sh | 2 +- docs/architecture.md | 4 +- docs/documentation-audiences.json | 4 - docs/scripts.md | 2 +- tests/fm-ensure-agents-md.test.sh | 151 +++++------------------- tests/fm-lint-workflows.test.sh | 5 +- 15 files changed, 45 insertions(+), 173 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 9c0c5a71f18..2ddfcb16b07 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -54,7 +54,7 @@ This touches only the firstmate repo and its own worktrees, never anything under 2. **Re-read AGENTS.md if your own instructions changed.** When the updater printed `reread-firstmate: yes`, the tracked instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) just advanced under you. - **Read `AGENTS.md` now** (CLAUDE.md is a real `@AGENTS.md` pointer to it) to refresh your operating instructions before doing anything else, so you are acting on the new instructions rather than the stale ones you were started with. + **Read `AGENTS.md` now** to refresh your operating instructions before doing anything else, so you are acting on the new instructions rather than the stale ones you were started with. When it printed `reread-firstmate: no`, nothing changed for you - skip the re-read. 3. **Restart every second mate the updater named.** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8436d065119..b8ce45e6d1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -487,13 +487,6 @@ jobs: - name: Compatibility pointers must stay intact run: | set -eu - [ ! -L CLAUDE.md ] || { echo "::error::CLAUDE.md must be a real @AGENTS.md pointer file, not a symlink"; exit 1; } - tmp=$(mktemp) - trap 'rm -f "$tmp"' EXIT - printf '%s\n' \ - '' \ - '@AGENTS.md' >"$tmp" - cmp -s CLAUDE.md "$tmp" || { echo "::error::CLAUDE.md must be the canonical @AGENTS.md pointer"; exit 1; } [ "$(readlink .claude/skills)" = "../.agents/skills" ] || { echo "::error::.claude/skills must be a symlink to ../.agents/skills"; exit 1; } - name: Personal fleet paths must not be tracked run: | diff --git a/AGENTS.md b/AGENTS.md index c4f62af7dbc..918caf7bc74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Each secondmate has a persistent isolated `FM_HOME`, including its own state, ba Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds 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. ``` -AGENTS.md this file (CLAUDE.md is a real @AGENTS.md pointer to it) +AGENTS.md this file CONTRIBUTING.md contributor workflow and repo conventions README.md public overview and development notes .github/workflows/ shared CI and PR enforcement, committed diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a9d4d2694af..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,2 +0,0 @@ - -@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2fd860cafd..dd8e33d8f65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ Coordinate any workflow rollback with its required-check names so a retired chec ## Repo conventions - This repo is a template for running a firstmate orchestrator agent. - [`AGENTS.md`](AGENTS.md) owns the supervisor contract, role boundary, and bundled firstmate skill triggers; `CLAUDE.md` is a real `@AGENTS.md` pointer to it, and `.claude/skills` is a symlink to `.agents/skills`. + [`AGENTS.md`](AGENTS.md) owns the supervisor contract, role boundary, and bundled firstmate skill triggers, 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"). `.claude/mods/` holds Claude Code mods, plugins whose behavior lives in one function-hooks module; each is reached through an `.agents/skills/` symlink because Claude Code adopts project plugins only from `.claude/skills`, carries no `SKILL.md` so every other harness's skill loader ignores that entry, and imports only files physically inside its own folder because Claude Code refuses anything else. @@ -114,10 +114,6 @@ bin/fm-test-run.sh --all # deliberate complete regression (optional local full bin/fm-test-isolation-proof.sh --list # proven portable parallel candidate set bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-isolation-proof.json # re-run the portable candidate proof bin/fm-test-isolation-proof.sh --pool watcher-wake-lock --jobs 4 # re-run an admitted family proof -[ ! -L CLAUDE.md ] && cmp -s CLAUDE.md - <<'EOF' - -@AGENTS.md -EOF [ "$(readlink .claude/skills)" = "../.agents/skills" ] tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal) ``` diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 264126f6d99..86e8d3f2761 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -506,7 +506,7 @@ $ASK_USER_BLOCK $INBOX_SECTION # Project memory -If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. +If \`AGENTS.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. Record only project knowledge useful to almost every future session. For anything the codebase already shows, prefer a pointer to the authoritative file, command, or doc over copying the detail. If you touch a project \`AGENTS.md\`, follow \`$FM_ROOT/bin/fm-ensure-agents-md.sh\`'s self-governance contract in the same pass. diff --git a/bin/fm-dod-lib.sh b/bin/fm-dod-lib.sh index b1cf1fd80d7..d11456d0e3e 100755 --- a/bin/fm-dod-lib.sh +++ b/bin/fm-dod-lib.sh @@ -50,7 +50,7 @@ EOF printf "Your steering inbox is \`%s/%s.inbox\`; this exact path belongs to your current task even when it is outside the worktree or under the supervising firstmate home, so read and acknowledge its messages and do not reject it as another home's state.\n" "$state" "$task_id" cat <<'EOF' Never inspect or change any other home's endpoint namespace; this authorization is limited to the exact task paths named by this brief. -When this task works on Firstmate itself, the repository root `AGENTS.md` (also imported by `CLAUDE.md`) is project content and the supervisor contract for the firstmate managing you: follow this brief instead of that supervisor contract. +When this task works on Firstmate itself, the repository root `AGENTS.md` is project content and the supervisor contract for the firstmate managing you: follow this brief instead of that supervisor contract. Project instructions still govern the work wherever they do not conflict with this worker identity, including `CONTRIBUTING.md` and `firstmate-coding-guidelines` for Firstmate changes. EOF } diff --git a/bin/fm-ensure-agents-md.sh b/bin/fm-ensure-agents-md.sh index cccf9929af2..5a1d6820737 100755 --- a/bin/fm-ensure-agents-md.sh +++ b/bin/fm-ensure-agents-md.sh @@ -243,19 +243,10 @@ if [ -e "$AGENTS" ]; then fi if [ ! -e "$CLAUDE" ]; then ensure_maintenance_section - if claude_supports_native_agents_md; then - if [ "$MAINT_INJECTED" -eq 1 ]; then - echo "updated: added ## Maintaining this file to AGENTS.md in $DIR" - else - echo "unchanged: AGENTS.md in $DIR" - fi + if [ "$MAINT_INJECTED" -eq 1 ]; then + echo "updated: added ## Maintaining this file to AGENTS.md in $DIR" else - install_claude_pointer - if [ "$MAINT_INJECTED" -eq 1 ]; then - echo "updated: added ## Maintaining this file to AGENTS.md and wrote CLAUDE.md @AGENTS.md pointer in $DIR" - else - echo "wrote: CLAUDE.md @AGENTS.md pointer in $DIR" - fi + echo "unchanged: AGENTS.md in $DIR" fi exit 0 fi @@ -296,8 +287,7 @@ if [ -e "$CLAUDE" ]; then fi mv "$CLAUDE" "$AGENTS" ensure_maintenance_section - install_claude_pointer - echo "promoted: moved CLAUDE.md to AGENTS.md and wrote CLAUDE.md @AGENTS.md pointer in $DIR" + echo "promoted: moved CLAUDE.md to AGENTS.md in $DIR" exit 0 fi echo "conflict: CLAUDE.md exists in $DIR but is not a regular file or symlink" >&2 @@ -305,9 +295,4 @@ if [ -e "$CLAUDE" ]; then fi write_skeleton -if claude_supports_native_agents_md; then - echo "created: AGENTS.md in $DIR" -else - install_claude_pointer - echo "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer in $DIR" -fi +echo "created: AGENTS.md in $DIR" diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 52bfdc9055b..259a45a13f5 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -224,7 +224,7 @@ fetch_once() { # Which watched instruction paths changed between HEAD and BASE (comma list). # These are the files a running agent actually reads or runs: its instructions -# (AGENTS.md, which CLAUDE.md imports via @AGENTS.md), its agent-loaded skills +# (AGENTS.md), its agent-loaded skills # (.agents/skills/), and its tooling (bin/). Public skills/ is installer-facing # and intentionally not part of this watched instruction surface. changed_instr() { diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 44e8da93bcc..8645e4c9297 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1601,7 +1601,7 @@ families_for_changed_path() { docs/fm-test-isolation-proof.json) printf '%s\n' pure-contract-unit ;; - .github/*|.gitattributes|.tasks.toml|AGENTS.md|CLAUDE.md|CONTRIBUTING.md|\ + .github/*|.gitattributes|.tasks.toml|AGENTS.md|CONTRIBUTING.md|\ docs/configuration.md|docs/supervision-protocols/*) printf '%s\n' pure-contract-unit ;; diff --git a/docs/architecture.md b/docs/architecture.md index 7cdc3ff6d5e..347264b5336 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -411,10 +411,10 @@ The [Relay configuration reference](configuration.md#promised-public-replies-sta ## Project memory belongs to projects -Durable project-intrinsic agent knowledge lives in each project's committed `AGENTS.md`, with `CLAUDE.md` as a real `@AGENTS.md` import pointer. +Durable project-intrinsic agent knowledge lives in each project's committed `AGENTS.md`. Ship briefs prompt crewmates to create or update those files through the normal delivery path; `data/projects.md` stays a thin private registry. Each project `AGENTS.md` carries self-governance guidance; [`bin/fm-ensure-agents-md.sh`](../bin/fm-ensure-agents-md.sh) owns the canonical wording and idempotent insertion, while its header and help document the explicit mark for equivalent project-owned guidance. -It refuses a case-variant real memory file such as a lowercase `agents.md`, so the pointer's `@AGENTS.md` import resolves to a real `AGENTS.md` on a case-sensitive filesystem, and surfaces the mismatch for manual reconciliation. +It refuses a case-variant real memory file such as a lowercase `agents.md`, and surfaces the mismatch for manual reconciliation. The full ownership rule - what is project-intrinsic versus fleet-private, and how firstmate keeps the two apart without writing into project clones - is owned by [`AGENTS.md`](../AGENTS.md) (project and knowledge management). ## Operational memory routing diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index e459e95006a..28be71621bc 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -264,10 +264,6 @@ "path": "AGENTS.md", "audience": "agent-runtime" }, - { - "path": "CLAUDE.md", - "audience": "agent-runtime" - }, { "path": "CONTRIBUTING.md", "audience": "maintainer-architecture" diff --git a/docs/scripts.md b/docs/scripts.md index 5b8ceb56d3e..9c50c712d83 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -41,7 +41,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane | | `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, bounded concurrency, budgets, coverage guard, timing/JSON; refuses to execute in the repository primary checkout when `FM_TASK_ID` marks a task worker | | `fm-test-isolation-proof.sh` | Concurrent isolation harness and portable candidate set owner | -| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and self-governance guidance (explicit project mark documented in the helper's header and help) | +| `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md` and self-governance guidance (explicit project mark documented in the helper's header and help) | | `fm-guard.sh` | Warn on primary-checkout tangles, main-session pending wakes, and unhealthy supervision | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | | `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | diff --git a/tests/fm-ensure-agents-md.test.sh b/tests/fm-ensure-agents-md.test.sh index a2f13412ea5..2efc4da4ede 100755 --- a/tests/fm-ensure-agents-md.test.sh +++ b/tests/fm-ensure-agents-md.test.sh @@ -62,7 +62,7 @@ test_created_agents_md_includes_self_governance() { with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 || fail "fm-ensure-agents-md.sh failed for empty project" agents="$repo/AGENTS.md" assert_present "$agents" "AGENTS.md was not created" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "fresh setup created a CLAUDE.md file" assert_grep "## Maintaining this file" "$agents" "self-governance section heading missing" assert_grep "Keep this file for knowledge useful to almost every future agent session in this project." "$agents" \ "self-governance section lost the future-session bar" @@ -75,16 +75,16 @@ test_created_agents_md_includes_self_governance() { pass "fm-ensure-agents-md.sh: created AGENTS.md includes self-governance section" } -test_fresh_setup_writes_real_claude_pointer() { +test_fresh_setup_does_not_write_claude_pointer() { local repo out repo="$TMP_ROOT/fresh-pointer-project" mkdir -p "$repo" - out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed creating a fresh pointer" + out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + || fail "fm-ensure-agents-md.sh failed creating a fresh setup" assert_contains "$out" "created:" "fresh setup did not report created" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "fresh setup created a CLAUDE.md file" [ ! -L "$repo/CLAUDE.md" ] || fail "fresh setup created a CLAUDE.md symlink" - pass "fm-ensure-agents-md.sh: fresh setup writes a real @AGENTS.md pointer" + pass "fm-ensure-agents-md.sh: fresh setup does not write a CLAUDE.md pointer" } test_promoted_claude_md_includes_self_governance() { @@ -99,7 +99,7 @@ EOF "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 || fail "fm-ensure-agents-md.sh failed for CLAUDE.md promotion" agents="$repo/AGENTS.md" assert_present "$agents" "AGENTS.md was not created during promotion" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "promotion recreated a CLAUDE.md pointer" assert_grep "Run tests with \`make test\`." "$agents" \ "promotion lost existing CLAUDE.md content" count=$(grep -Fc "## Maintaining this file" "$agents") @@ -122,7 +122,7 @@ test_promoted_claude_md_without_trailing_newline_keeps_blank_separator() { "newline-less promotion did not append the self-governance section" before=$(grep -B1 -Fx '## Maintaining this file' "$agents" | head -n 1) [ -z "$before" ] || fail "self-governance heading not preceded by a blank line (got: $before)" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "promotion recreated a CLAUDE.md pointer" pass "fm-ensure-agents-md.sh: newline-less promotion keeps a blank separator line" } @@ -182,7 +182,7 @@ test_correct_symlink_migrates_to_pointer_without_clobbering_agents() { pass "fm-ensure-agents-md.sh: correct symlink migrates to pointer without clobbering AGENTS.md" } -test_existing_agents_md_without_claude_gains_section_and_pointer() { +test_existing_agents_md_without_claude_gains_section() { local repo agents out count repo="$TMP_ROOT/existing-bare-project" mkdir -p "$repo" @@ -191,31 +191,29 @@ test_existing_agents_md_without_claude_gains_section_and_pointer() { out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "fm-ensure-agents-md.sh failed for existing AGENTS.md without CLAUDE.md" assert_contains "$out" "updated:" "injection without CLAUDE.md did not report an update" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "injection created a CLAUDE.md file" assert_grep "Deploy with kubectl." "$agents" "injection dropped existing AGENTS.md content" count=$(grep -Fc "## Maintaining this file" "$agents") [ "$count" -eq 1 ] || fail "injection wrote $count self-governance sections" - pass "fm-ensure-agents-md.sh: existing AGENTS.md without CLAUDE.md gains section and pointer" + pass "fm-ensure-agents-md.sh: existing AGENTS.md without CLAUDE.md gains section" } test_existing_agents_md_with_section_reports_unchanged() { local repo agents out repo="$TMP_ROOT/fully-formed-project" mkdir -p "$repo" - # Build a fully-formed project (AGENTS.md with the section + canonical pointer). - with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + # Build a fully-formed project (AGENTS.md with the section). + "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "fm-ensure-agents-md.sh failed building the fully-formed fixture" agents="$repo/AGENTS.md" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "fixture building created CLAUDE.md" cp "$agents" "$repo/.before" - cp "$repo/CLAUDE.md" "$repo/.claude-before" - out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "fm-ensure-agents-md.sh failed on already-formed project" assert_contains "$out" "unchanged:" "already-formed project was not reported unchanged" diff "$repo/.before" "$agents" >/dev/null \ || fail "already-formed AGENTS.md was modified" - cmp -s "$repo/.claude-before" "$repo/CLAUDE.md" \ - || fail "already-formed CLAUDE.md was modified" + assert_absent "$repo/CLAUDE.md" "re-run created CLAUDE.md" pass "fm-ensure-agents-md.sh: AGENTS.md that already has the section stays unchanged" } @@ -239,13 +237,19 @@ test_marked_project_guidance_stays_unchanged() { || fail "ensure failed for marked project ($route)" cmp -s "$repo/.before" "$repo/AGENTS.md" \ || fail "marked project guidance was modified ($route)" - assert_claude_pointer "$repo/CLAUDE.md" - out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + case "$route" in + pointer|symlink) assert_claude_pointer "$repo/CLAUDE.md" ;; + *) assert_absent "$repo/CLAUDE.md" ;; + esac + out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "ensure failed on marked project re-run ($route)" assert_contains "$out" "unchanged:" "marked project re-run did not report unchanged" cmp -s "$repo/.before" "$repo/AGENTS.md" \ || fail "marked project re-run modified guidance ($route)" - assert_claude_pointer "$repo/CLAUDE.md" + case "$route" in + pointer|symlink) assert_claude_pointer "$repo/CLAUDE.md" ;; + *) assert_absent "$repo/CLAUDE.md" ;; + esac done done pass "fm-ensure-agents-md.sh: marked project guidance is preserved across ensure paths and line endings" @@ -270,7 +274,7 @@ test_reworded_guidance_requires_first_line_marker() { assert_grep '## Editing these notes' "$repo/AGENTS.md" "ensure removed project guidance" count=$(grep -Fxc "## Maintaining this file${eol%$'\n'}" "$repo/AGENTS.md") [ "$count" -eq 1 ] || fail "guidance without a first-line mark did not gain the canonical section" - assert_claude_pointer "$repo/CLAUDE.md" + assert_absent "$repo/CLAUDE.md" "ensure created a CLAUDE.md file" cp "$repo/AGENTS.md" "$repo/.after-first" with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed on unmarked project re-run" @@ -444,112 +448,13 @@ test_lowercase_agents_md_refuses_case_fragile_pointer() { pass "fm-ensure-agents-md.sh: refuses a case-variant lowercase agents.md (issue #389)" } -test_fresh_setup_version_below_cutoff_writes_pointer() { - local repo out - repo="$TMP_ROOT/fresh-below-cutoff" - mkdir -p "$repo" - out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed for version below cutoff" - assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not report created pointer" - assert_claude_pointer "$repo/CLAUDE.md" - pass "fm-ensure-agents-md.sh: version below cutoff writes pointer" -} - -test_fresh_setup_version_at_cutoff_skips_pointer() { - local repo out - repo="$TMP_ROOT/fresh-at-cutoff" - mkdir -p "$repo" - out=$(with_mock_claude "2.1.277" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed for version at cutoff" - assert_contains "$out" "created: AGENTS.md in" "did not report created AGENTS.md without pointer" - assert_absent "$repo/CLAUDE.md" "CLAUDE.md pointer was created at version cutoff" - pass "fm-ensure-agents-md.sh: version at cutoff skips pointer" -} - -test_fresh_setup_version_above_cutoff_skips_pointer() { - local repo out - repo="$TMP_ROOT/fresh-above-cutoff" - mkdir -p "$repo" - out=$(with_mock_claude "2.2.0 (Claude Code)" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed for version above cutoff" - assert_contains "$out" "created: AGENTS.md in" "did not report created AGENTS.md without pointer" - assert_absent "$repo/CLAUDE.md" "CLAUDE.md pointer was created above version cutoff" - pass "fm-ensure-agents-md.sh: version above cutoff skips pointer" -} - -test_fresh_setup_claude_missing_writes_pointer_fallback() { - local repo out - repo="$TMP_ROOT/fresh-no-claude" - mkdir -p "$repo" - out=$(with_no_claude "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed when claude is missing" - assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not write pointer as fallback" - assert_claude_pointer "$repo/CLAUDE.md" - pass "fm-ensure-agents-md.sh: missing claude falls back to writing pointer" -} - -test_fresh_setup_version_unparseable_writes_pointer_fallback() { - local repo out - repo="$TMP_ROOT/fresh-unparseable" - mkdir -p "$repo" - out=$(with_mock_claude "invalid-version-string" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed when version is unparseable" - assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not write pointer on unparseable version" - assert_claude_pointer "$repo/CLAUDE.md" - pass "fm-ensure-agents-md.sh: unparseable version falls back to writing pointer" -} - -test_fresh_setup_claude_error_writes_pointer_fallback() { - local repo out - repo="$TMP_ROOT/fresh-claude-error" - mkdir -p "$repo" - out=$(with_mock_claude "" 1 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed when claude errors" - assert_contains "$out" "created: AGENTS.md and CLAUDE.md @AGENTS.md pointer" "did not write pointer when claude errors" - assert_claude_pointer "$repo/CLAUDE.md" - pass "fm-ensure-agents-md.sh: claude command error falls back to writing pointer" -} - -test_existing_pointer_untouched_at_or_above_cutoff() { - local repo out - repo="$TMP_ROOT/existing-pointer-at-cutoff" - mkdir -p "$repo" - printf '# Project agent memory\n\n## Maintaining this file\n' > "$repo/AGENTS.md" - write_fixture_claude_pointer "$repo" - out=$(with_mock_claude "2.1.277" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed for existing pointer at cutoff" - assert_contains "$out" "unchanged:" "did not report unchanged" - assert_claude_pointer "$repo/CLAUDE.md" - pass "fm-ensure-agents-md.sh: existing pointer is untouched at cutoff" -} - -test_existing_agents_md_without_claude_at_or_above_cutoff() { - local repo out - repo="$TMP_ROOT/existing-bare-at-cutoff" - mkdir -p "$repo" - printf '# Existing agent memory\n\nDeploy with kubectl.\n' > "$repo/AGENTS.md" - out=$(with_mock_claude "2.1.277" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ - || fail "fm-ensure-agents-md.sh failed for bare AGENTS.md at cutoff" - assert_contains "$out" "updated: added ## Maintaining this file to AGENTS.md in" "did not report updated AGENTS.md" - assert_absent "$repo/CLAUDE.md" "CLAUDE.md pointer was created for bare AGENTS.md at cutoff" - pass "fm-ensure-agents-md.sh: bare AGENTS.md at cutoff gains section and skips pointer" -} - test_created_agents_md_includes_self_governance -test_fresh_setup_writes_real_claude_pointer -test_fresh_setup_version_below_cutoff_writes_pointer -test_fresh_setup_version_at_cutoff_skips_pointer -test_fresh_setup_version_above_cutoff_skips_pointer -test_fresh_setup_claude_missing_writes_pointer_fallback -test_fresh_setup_version_unparseable_writes_pointer_fallback -test_fresh_setup_claude_error_writes_pointer_fallback -test_existing_pointer_untouched_at_or_above_cutoff -test_existing_agents_md_without_claude_at_or_above_cutoff +test_fresh_setup_does_not_write_claude_pointer test_promoted_claude_md_includes_self_governance test_promoted_claude_md_without_trailing_newline_keeps_blank_separator test_existing_agents_md_with_symlink_gains_self_governance test_correct_symlink_migrates_to_pointer_without_clobbering_agents -test_existing_agents_md_without_claude_gains_section_and_pointer +test_existing_agents_md_without_claude_gains_section test_existing_agents_md_with_section_reports_unchanged test_existing_crlf_agents_md_with_section_stays_unchanged test_existing_crlf_agents_md_without_section_preserves_crlf diff --git a/tests/fm-lint-workflows.test.sh b/tests/fm-lint-workflows.test.sh index 18bce373176..ab59a4d9199 100755 --- a/tests/fm-lint-workflows.test.sh +++ b/tests/fm-lint-workflows.test.sh @@ -158,9 +158,8 @@ jobs: - name: Compatibility pointers must stay intact run: | set -eu - cmp -s CLAUDE.md - <<'EOF' || exit 1 - -@AGENTS.md + cmp -s expected.txt - <<'EOF' || exit 1 +this line sits at column 0 inside a run: | block EOF echo ok YAML From 1fcfac3450a54e77b04d1b1ad91261d768e2d367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 19 Sep 2026 17:29:22 +0900 Subject: [PATCH 04/19] fix: correct stage3 branch, remove dead version-gate code for real (#3) Prior commits on this branch regressed past stage2, restoring the unconditional CLAUDE.md pointer-write logic stage2 removed. Reset to fork/main (stage2's merged head) and redo stage3 correctly: delete the now-dead fm_version_at_least/claude_supports_native_agents_md functions and their header-comment reference, and drop the now-vestigial with_mock_claude/with_no_claude test helpers (the script no longer reads claude --version at all). Co-authored-by: irene --- bin/fm-ensure-agents-md.sh | 39 ++++--------------------------- tests/fm-ensure-agents-md.test.sh | 39 ++++--------------------------- 2 files changed, 9 insertions(+), 69 deletions(-) diff --git a/bin/fm-ensure-agents-md.sh b/bin/fm-ensure-agents-md.sh index 5a1d6820737..968275c8723 100755 --- a/bin/fm-ensure-agents-md.sh +++ b/bin/fm-ensure-agents-md.sh @@ -6,11 +6,10 @@ # when neither file exists, promotes a real CLAUDE.md file when it is the only # file present (unless it is already the canonical pointer), converts a correct # CLAUDE.md -> AGENTS.md symlink into the pointer file, and refuses to clobber -# distinct real files or wrong symlinks. Skips writing a brand-new CLAUDE.md -# pointer (neither file existed, or AGENTS.md existed with no CLAUDE.md) when -# the invoking Claude Code is >= 2.1.277, which reads AGENTS.md natively; see -# claude_supports_native_agents_md. Existing pointers and symlink conversions -# are left alone by this gate for now. +# distinct real files or wrong symlinks. Never writes a brand-new CLAUDE.md +# pointer for a fresh project (neither file existed, or AGENTS.md existed with +# no CLAUDE.md); Claude Code reads AGENTS.md natively fleet-wide. Existing +# pointers and symlink conversions are left alone. # Owns the canonical "## Maintaining this file" self-governance wording for # project AGENTS.md files, injecting it idempotently into created skeletons, # promoted CLAUDE.md files, and existing AGENTS.md files lacking both the exact @@ -132,36 +131,6 @@ is_canonical_claude_pointer() { claude_pointer_content | cmp -s - "$CLAUDE" } -fm_version_at_least() { # - local candidate=${1:-} floor=${2:-} c f - candidate=${candidate%%[-+]*} - case "$candidate" in ''|*[!0-9.]*) return 1 ;; esac - while [ -n "$floor" ]; do - c=${candidate%%.*} - f=${floor%%.*} - [ -n "$c" ] || c=0 - [ "$c" -gt "$f" ] 2>/dev/null && return 0 - [ "$c" -lt "$f" ] 2>/dev/null && return 1 - case "$candidate" in *.*) candidate=${candidate#*.} ;; *) candidate= ;; esac - case "$floor" in *.*) floor=${floor#*.} ;; *) floor= ;; esac - done - return 0 -} - -# Returns 0 if local Claude Code is installed and version is >= 2.1.277. -# Returns 1 if missing, unparseable, command errors, or version < 2.1.277. -claude_supports_native_agents_md() { - command -v claude >/dev/null 2>&1 || return 1 - local ver_str ver - ver_str=$(claude --version 2>/dev/null) || return 1 - if [[ "$ver_str" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then - ver="${BASH_REMATCH[1]}" - else - return 1 - fi - fm_version_at_least "$ver" "2.1.277" -} - # Write the canonical pointer as a regular file. Unlink a symlink first so the # write cannot follow it and destroy AGENTS.md. Never overwrite a distinct real # file; callers classify that as a conflict before invoking this. diff --git a/tests/fm-ensure-agents-md.test.sh b/tests/fm-ensure-agents-md.test.sh index 2efc4da4ede..a80e03053da 100755 --- a/tests/fm-ensure-agents-md.test.sh +++ b/tests/fm-ensure-agents-md.test.sh @@ -26,40 +26,11 @@ write_fixture_claude_pointer() { EOF } -with_mock_claude() { - local output=$1 code=${2:-0} mock_dir - shift 2 - mock_dir=$(mktemp -d "$TMP_ROOT/mock-claude.XXXXXX") - cat > "$mock_dir/claude" </dev/null) - if [ -n "$claude_path" ]; then - claude_dir=$(dirname "$claude_path") - new_path=$(echo "$PATH" | tr ':' '\n' | grep -v -Fx "$claude_dir" | tr '\n' ':') - PATH="$new_path" "$@" - else - "$@" - fi -} - test_created_agents_md_includes_self_governance() { local repo agents repo="$TMP_ROOT/new-project" mkdir -p "$repo" - with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 || fail "fm-ensure-agents-md.sh failed for empty project" + "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 || fail "fm-ensure-agents-md.sh failed for empty project" agents="$repo/AGENTS.md" assert_present "$agents" "AGENTS.md was not created" assert_absent "$repo/CLAUDE.md" "fresh setup created a CLAUDE.md file" @@ -188,7 +159,7 @@ test_existing_agents_md_without_claude_gains_section() { mkdir -p "$repo" printf '# Existing agent memory\n\nDeploy with kubectl.\n' > "$repo/AGENTS.md" agents="$repo/AGENTS.md" - out=$(with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ + out=$("$ROOT/bin/fm-ensure-agents-md.sh" "$repo" 2>&1) \ || fail "fm-ensure-agents-md.sh failed for existing AGENTS.md without CLAUDE.md" assert_contains "$out" "updated:" "injection without CLAUDE.md did not report an update" assert_absent "$repo/CLAUDE.md" "injection created a CLAUDE.md file" @@ -233,7 +204,7 @@ test_marked_project_guidance_stays_unchanged() { symlink) ln -s AGENTS.md "$repo/CLAUDE.md" ;; promotion) mv "$repo/AGENTS.md" "$repo/CLAUDE.md" ;; esac - with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed for marked project ($route)" cmp -s "$repo/.before" "$repo/AGENTS.md" \ || fail "marked project guidance was modified ($route)" @@ -268,7 +239,7 @@ test_reworded_guidance_requires_first_line_marker() { 'Keep broadly useful knowledge concise; link to sources and rewrite stale entries.' \ 'Preserve these rules for every agent.' | while IFS= read -r line; do printf '%s%s' "$line" "$eol"; done > "$repo/AGENTS.md" - with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed for unmarked reworded guidance" # AGENTS.md is the helper's generated output contract, not implementation source. assert_grep '## Editing these notes' "$repo/AGENTS.md" "ensure removed project guidance" @@ -276,7 +247,7 @@ test_reworded_guidance_requires_first_line_marker() { [ "$count" -eq 1 ] || fail "guidance without a first-line mark did not gain the canonical section" assert_absent "$repo/CLAUDE.md" "ensure created a CLAUDE.md file" cp "$repo/AGENTS.md" "$repo/.after-first" - with_mock_claude "2.0.0" 0 "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ + "$ROOT/bin/fm-ensure-agents-md.sh" "$repo" >/dev/null 2>&1 \ || fail "ensure failed on unmarked project re-run" cmp -s "$repo/.after-first" "$repo/AGENTS.md" \ || fail "unmarked project re-run modified guidance" From f81702c2ce6dba0e0fb14e3290f41021e12bf2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 19 Sep 2026 18:02:32 +0900 Subject: [PATCH 05/19] feat(agents): recover specialist-tools, firstmate-layout, and task-steering skills (#4) * feat: add lazy specialist tool routing Expose ECC, paperthin, and ultrawork as captain-approved specialist paths while keeping Firstmate intake and lifecycle authority. Load only the selected skill or mode and keep ECC hooks, MCP, and legacy sync opt-in. * docs(agents): recover firstmate-layout and task-steering skills These two skills existed only on an orphaned local branch, never pushed. firstmate-layout is re-extracted from AGENTS.md section 2's current (much larger) layout tree rather than reusing the stale 2026-09-14 snapshot. task-steering's underlying AGENTS.md paragraph was byte-identical to the 2026-09-14 extraction, so it is reused as-is. Both get a one-line trigger in section 13 and a documentation-audiences.json entry, matching how specialist-tools (recovered earlier on this branch) is registered. --------- Co-authored-by: irene --- .agents/skills/firstmate-layout/SKILL.md | 113 ++++++++++++++++++++++ .agents/skills/specialist-tools/SKILL.md | 43 +++++++++ .agents/skills/task-steering/SKILL.md | 23 +++++ .backpassrc.json | 35 +++++++ AGENTS.md | 114 ++--------------------- docs/documentation-audiences.json | 12 +++ 6 files changed, 233 insertions(+), 107 deletions(-) create mode 100644 .agents/skills/firstmate-layout/SKILL.md create mode 100644 .agents/skills/specialist-tools/SKILL.md create mode 100644 .agents/skills/task-steering/SKILL.md create mode 100644 .backpassrc.json diff --git a/.agents/skills/firstmate-layout/SKILL.md b/.agents/skills/firstmate-layout/SKILL.md new file mode 100644 index 00000000000..421d8cedf4b --- /dev/null +++ b/.agents/skills/firstmate-layout/SKILL.md @@ -0,0 +1,113 @@ +--- +name: firstmate-layout +description: >- Full FM_HOME directory and file layout reference, naming every tracked path, config/ override, data/ record, and state/ runtime file and its owner. Load before inspecting, debugging, or reasoning about a specific file or directory under FM_HOME whose purpose AGENTS.md section 2 does not already name, or before hand-writing a path into a script or check. +user-invocable: false +metadata: + internal: true +--- + +# firstmate-layout-reference + +`docs/configuration.md` remains the single owner of the top-level layout and +configuration schemas; this skill is the exhaustive per-path lookup table for +what each producing script's header and help would otherwise require reading +individually. + +``` +AGENTS.md this file +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 +.claude/mods/ Claude Code mods (function-hooks plugins), committed; Calm's module may load through CLAUDE_CODE_ENABLE_FUNCTION_HOOKS or tengu_plugin_hooks_modules, but activates only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is exactly "1" and is otherwise a complete no-op (docs/calm.md) +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 Relay pairing token (presence-gates section 14), mail-plane credentials (schema: docs/configuration.md "Mail plane"), and typed dispatch resolution key TYPESAFE_API_KEY (presence-gates bin/fm-dispatch-resolve.sh; docs/configuration.md "Typed dispatch resolution"); LOCAL, gitignored +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/claude-permission-mode optional one-token permission posture for every Claude worker launch: absent or "bypass" keeps --dangerously-skip-permissions, "auto" launches with --permission-mode auto; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Claude permission mode" +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). 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" = the configured 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), herdr has its own required CI lane (docs/herdr-backend.md), while zellij, orca, and cmux remain experimental with no dedicated real-backend CI lane (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 Calm presentation preference shared by the Pi extension and the Claude Code mod; LOCAL, gitignored, and not inherited; see docs/configuration.md "Calm preference" +config/supervision-branch-model config/supervision-branch-effort Pi supervision-branch model and reasoning-effort pins written by /supervision-model; LOCAL, gitignored, independently settable, and not inherited; see docs/configuration.md "Pi supervision branch model and effort" +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/stow-pass-horizon optional presence flag opting this home in to /stow's default-off pass-count decay horizon; LOCAL, gitignored, and not inherited; see docs/configuration.md "Stow pass horizon" +config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" +config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md +config/turnend-churn-absorb optional presence flag opting this home into the default-off absorb of bare turn-end wakes on pane churn; LOCAL, gitignored, and not inherited; see docs/configuration.md "Turn-end pane-churn absorb" +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/watched-tools.json optional list of the tools this home depends on, read by the update check armed with bin/fm-tool-update-check.sh; LOCAL, gitignored, firstmate-maintained but human-editable, and NOT inherited by secondmate homes; see docs/configuration.md "Watched tool updates" +config/x-mode.env generated Relay 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 recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6) + secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (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/ runtime records and signals; gitignored + .status appended by crewmates: ": " wake-event lines, not current-state truth + .turn-ended touched by turn-end hooks + .progress touched for observed native-harness activity inside one Pi turn; bin/fm-busy-event.sh owns its generation binding and bin/fm-watch.sh reads it beside turn-ended for the busy-age bound only, never as a completed turn + .busy-state .busy-gen semantic busy-state record (one line, atomically replaced) and its per-incarnation gen sidecar; bin/fm-busy-event.sh is the only writer and bin/fm-busy-lib.sh owns the record format and classification; arming again replaces the previous incarnation so late events carrying its gen are rejected as stale; removed by retire and teardown + .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 + .gemini-settings.json firstmate-owned per-task Gemini settings carrying the busy-state and turn-end hooks, reached through GEMINI_CLI_SYSTEM_SETTINGS_PATH so nothing is written into the project's own .gemini/; removed by teardown + .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown + .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown + .reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window + .backlog-close the exact backlog transition a teardown recorded before removing the task's record, so an interrupted cleanup can still be finished at the next session start; bin/fm-backlog-transition-lib.sh owns its format and replay, and a landed transition removes it + .inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, with ordinary records re-rung and escalated by the watcher while explicit fire-and-forget records are excluded from that ladder, and removed by teardown (bin/fm-task-inbox-lib.sh) + .meta task metadata; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details + .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" + .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified Relay 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 + .merge-authority private canonical-PR-bound authority persisted after firstmate's forge merge request is accepted and consumed by a later merged poll; bin/fm-merge-authority-lib.sh owns its format and lifecycle + .pr-poll-merge-notified canonical PR identity of the last merge outcome delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement + branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, and their recovery marker; bin/fm-branch-outcome.sh owns the formats + branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) + .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract + .lease- per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce + x-watch.check.sh generated Relay poll shim; present only when opted in (section 14) + tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll + mail.check.sh generated received-mail poll shim and its .check-trust binding; present only after bin/fm-mail-check.sh arm; report record .mail-check (mail schema: docs/configuration.md "Mail plane") + .mail-seen .mail-woken .mail-retry .mail-retry-pos .mail-turn .mail-seen.lock mail-plane poll cursor, emission journal, transient-fetch retry set, retry-scan position, contended-slot turn flag, and overlapping-poll lock; written only by bin/fm-mail.sh (mail schema: docs/configuration.md "Mail plane") + pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh + procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) + procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line + decision-bindings/ private records marking a captured-answer source as feeding the keyed-answer intake, with a legacy origin on pre-collapse records; written only by bin/fm-captain-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/captain-hold-lifecycle.md) + reconcile-requests/ private open obligations to re-check a captain call whose board selection was `reconcile`; written only by bin/fm-captain-hold.sh, retired by its verify-then-decide outcomes or a normal answer that settles the call (section 13; docs/captain-hold-lifecycle.md) + when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) + inbox/ captain notes captured out of band by bin/fm-inbox.sh, including the voice handover's queued requests; each note appends one `check` wake and stays pending until acknowledged with `bin/fm-inbox.sh drain --ack `, which moves it to inbox/handled/ (docs/voice-relay.md) + x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) + x-context/ generated Relay 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 Relay 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: retained open-loop registrations, typed terminal-result inbox, results staged for an owning home on another machine, accepted/rejected ledgers, and retirement receipts (section 14; bin/fm-public-followup.sh) + x-poll.error x-poll.claim-error generated Relay and offer-claim diagnostic dedupe markers + .startup-network.* status, report, per-step elapsed timings, inline-print claim, and lock for the deferred startup stage that runs network checks and the inactive-outcome scan off the digest's blocking path; bin/fm-startup-network.sh + .wake-queue durable queued wakes retained until post-handling acknowledgement: epochseqkindkeypayload + .watcher-down private generation-bound recovery state coupling watcher downtime, durable wake presentation, and post-handling acknowledgement; never touch + ..open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) + .status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown + .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) + afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window + .afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh + .watch.lock .wake-queue.lock watcher singleton and queue serialization locks + .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch + .cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch + .hash-* .count-* .stale-* .stale-since-* .churn-since-* .paused-* .wedge-escalations-* .dead-reported-* .writing-* .waiting-* .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 +``` diff --git a/.agents/skills/specialist-tools/SKILL.md b/.agents/skills/specialist-tools/SKILL.md new file mode 100644 index 00000000000..9edbf43babb --- /dev/null +++ b/.agents/skills/specialist-tools/SKILL.md @@ -0,0 +1,43 @@ +--- +name: specialist-tools +description: >- + Route one task to the captain-approved ECC, paperthin, or ultrawork specialist path without loading an entire tool catalog into context. +user-invocable: false +metadata: + internal: true +--- + +# Specialist tool routing + +Load this skill before selecting a specialist path for a task. +Select at most one specialist by default. +Keep Firstmate as the owner of intake, worktree, quota, approval, state, review, delivery, and merge authority. + +## Selection + +| tool | select for | load behavior | +| --- | --- | --- | +| `ecc` | focused code review, security review, TDD, architecture, or verification workflow | Discover the matching skill in the installed native ECC catalog, then load only that `SKILL.md`. Do not load the catalog, all agents, hooks, MCP definitions, or memory into the prompt. | +| `paperthin` | code hygiene, scope reduction, SSOT, fact checking, memory curation, or repository cleanup | Discover one matching installed paperthin skill such as `re0-*`, `ssotize`, `dedash`, `reorder`, `debloat`, `factchk`, `mandela`, or `readchk`, then load only that skill. | +| `ultrawork` (`lazy codex`) | captain-requested maximum verification or work with an explicit heavy verification need | Invoke the existing `/ultrawork` or `/ulw` path once. Do not additionally load ECC or paperthin unless the captain explicitly requests a comparison. | + +If no specialist matches, use the ordinary Firstmate path. +Do not select a specialist merely because it is installed. + +## ECC boundary + +Use the native `ecc@ecc` Codex plugin installation. +Never run ECC's deprecated legacy sync into `~/.codex`. +Treat ECC hooks, MCP servers, and guided global configuration as separate opt-in surfaces; do not trust, start, or configure them as part of ordinary task intake. +Installing the full package does not authorize those surfaces. + +Resolve the ECC skill path from the current plugin registration or its cache; never hardcode a user-specific absolute path. +Search names and descriptions, choose the smallest matching skill, and read its body only after selection. +Record the selected tool and skill in the task brief so review can reproduce the route. +If the plugin is missing or its catalog cannot be resolved, report `MISSING_MANUAL` and stop specialist dispatch rather than falling back silently. + +## Cost boundary + +Do not preload any specialist catalog, agent collection, reference tree, or full tool description. +Pass the selected skill name and one-sentence reason to the worker. +Reuse the current Firstmate quota and harness gates; specialist selection never bypasses authentication, quota, isolation, or independent review requirements. diff --git a/.agents/skills/task-steering/SKILL.md b/.agents/skills/task-steering/SKILL.md new file mode 100644 index 00000000000..74f58ac0db4 --- /dev/null +++ b/.agents/skills/task-steering/SKILL.md @@ -0,0 +1,23 @@ +--- +name: task-steering +description: >- Agent-only reference for steering a live worker and driving its lifecycle. Load before sending ordinary text to a worker, resending after an unconfirmed remote delivery, closing an open keyed decision with an answer, or interrupting, exiting, or relaunching a worker. +user-invocable: false +metadata: + internal: true +--- + +# task-steering + +This skill is the single owner of the full steering and lifecycle-control +mechanics, including the remote-secondmate transport and pending-reply +correlation contract. +`AGENTS.md` section 7 owns only the always-loaded command names and the +never-mix-planes boundary. + +Steer a worker with ordinary text through fail-closed `fm-send`: the message becomes a durable record in the task's steering inbox (multi-line text is legal, local and remote alike) and the worker's terminal receives only a constant doorbell line, with the watcher re-ringing an unacknowledged local message and escalating a stuck one (`../../../bin/fm-task-inbox-lib.sh`; `../../../bin/fm-send.sh` owns the typed-plane carve-outs). +A remote secondmate steer rides the same durable-inbox model through the remote transport; after an unconfirmed delivery, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe because it preserves the request body for remote enqueue deduplication (`fm-send.sh` header). +When a steer answers an open keyed decision or blocker, pass `fm-send`'s `--resolve-key` so the answer itself closes that decision record at answer time, identically for local and remote workers (contract: `fm-send.sh` header). +`fm-send` is the data plane for text the worker should read; never use its key or text paths for interrupt, exit, or other lifecycle control, because routing-marked lifecycle text becomes chat the worker reasons about instead of executing. +Drive a worker's lifecycle through `../../../bin/fm-control.sh interrupt|exit|relaunch`, which owns the per-runtime mechanics, verifies each action, and never tears down or discards anything (`../../../docs/agent-control.md`). +A secondmate's routed reply returns through status or a document pointer, not by firstmate peeking into its chat. +For the parent-owned correlation, recovery, and escalation contract on marked secondmate requests, see `../../../bin/fm-pending-reply-lib.sh`. diff --git a/.backpassrc.json b/.backpassrc.json new file mode 100644 index 00000000000..262a3dae267 --- /dev/null +++ b/.backpassrc.json @@ -0,0 +1,35 @@ +{ + "memoryFiles": [ + "AGENTS.md", + "CLAUDE.md" + ], + "budgetTokens": 5000, + "skillsDir": ".agents/skills", + "minGapEvidence": 2, + "maxTranscripts": 100, + "analysis": { + "agent": null, + "model": null, + "effort": null + }, + "synthesis": { + "agent": null, + "model": null, + "effort": null + }, + "discovery": { + "harnesses": [ + "claude", + "codex", + "pi", + "opencode", + "grok", + "cursor", + "hermes" + ], + "since": "30d", + "worktreeGlobs": [], + "minUserTurns": 2 + }, + "jobs": 4 +} diff --git a/AGENTS.md b/AGENTS.md index 918caf7bc74..fcc8b7ac6bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,106 +56,7 @@ 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 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. - -``` -AGENTS.md this file -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 -.claude/mods/ Claude Code mods (function-hooks plugins), committed; Calm's module may load through CLAUDE_CODE_ENABLE_FUNCTION_HOOKS or tengu_plugin_hooks_modules, but activates only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is exactly "1" and is otherwise a complete no-op (docs/calm.md) -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 Relay pairing token (presence-gates section 14), mail-plane credentials (schema: docs/configuration.md "Mail plane"), and typed dispatch resolution key TYPESAFE_API_KEY (presence-gates bin/fm-dispatch-resolve.sh; docs/configuration.md "Typed dispatch resolution"); LOCAL, gitignored -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/claude-permission-mode optional one-token permission posture for every Claude worker launch: absent or "bypass" keeps --dangerously-skip-permissions, "auto" launches with --permission-mode auto; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Claude permission mode" -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). 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" = the configured 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), herdr has its own required CI lane (docs/herdr-backend.md), while zellij, orca, and cmux remain experimental with no dedicated real-backend CI lane (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 Calm presentation preference shared by the Pi extension and the Claude Code mod; LOCAL, gitignored, and not inherited; see docs/configuration.md "Calm preference" -config/supervision-branch-model config/supervision-branch-effort Pi supervision-branch model and reasoning-effort pins written by /supervision-model; LOCAL, gitignored, independently settable, and not inherited; see docs/configuration.md "Pi supervision branch model and effort" -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/stow-pass-horizon optional presence flag opting this home in to /stow's default-off pass-count decay horizon; LOCAL, gitignored, and not inherited; see docs/configuration.md "Stow pass horizon" -config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" -config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md -config/turnend-churn-absorb optional presence flag opting this home into the default-off absorb of bare turn-end wakes on pane churn; LOCAL, gitignored, and not inherited; see docs/configuration.md "Turn-end pane-churn absorb" -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/watched-tools.json optional list of the tools this home depends on, read by the update check armed with bin/fm-tool-update-check.sh; LOCAL, gitignored, firstmate-maintained but human-editable, and NOT inherited by secondmate homes; see docs/configuration.md "Watched tool updates" -config/x-mode.env generated Relay 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 recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6) - secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (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/ runtime records and signals; gitignored - .status appended by crewmates: ": " wake-event lines, not current-state truth - .turn-ended touched by turn-end hooks - .progress touched for observed native-harness activity inside one Pi turn; bin/fm-busy-event.sh owns its generation binding and bin/fm-watch.sh reads it beside turn-ended for the busy-age bound only, never as a completed turn - .busy-state .busy-gen semantic busy-state record (one line, atomically replaced) and its per-incarnation gen sidecar; bin/fm-busy-event.sh is the only writer and bin/fm-busy-lib.sh owns the record format and classification; arming again replaces the previous incarnation so late events carrying its gen are rejected as stale; removed by retire and teardown - .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 - .gemini-settings.json firstmate-owned per-task Gemini settings carrying the busy-state and turn-end hooks, reached through GEMINI_CLI_SYSTEM_SETTINGS_PATH so nothing is written into the project's own .gemini/; removed by teardown - .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown - .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown - .reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window - .backlog-close the exact backlog transition a teardown recorded before removing the task's record, so an interrupted cleanup can still be finished at the next session start; bin/fm-backlog-transition-lib.sh owns its format and replay, and a landed transition removes it - .inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, with ordinary records re-rung and escalated by the watcher while explicit fire-and-forget records are excluded from that ladder, and removed by teardown (bin/fm-task-inbox-lib.sh) - .meta task metadata; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details - .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" - .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified Relay 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 - .merge-authority private canonical-PR-bound authority persisted after firstmate's forge merge request is accepted and consumed by a later merged poll; bin/fm-merge-authority-lib.sh owns its format and lifecycle - .pr-poll-merge-notified canonical PR identity of the last merge outcome delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement - branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, and their recovery marker; bin/fm-branch-outcome.sh owns the formats - branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) - .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract - .lease- per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce - x-watch.check.sh generated Relay poll shim; present only when opted in (section 14) - tool-updates.check.sh generated watched-tool update poll shim and its .check-trust binding; present only after bin/fm-tool-update-check.sh arm; its report record .tool-updates is what keeps one pending update from being reported on every poll - mail.check.sh generated received-mail poll shim and its .check-trust binding; present only after bin/fm-mail-check.sh arm; report record .mail-check (mail schema: docs/configuration.md "Mail plane") - .mail-seen .mail-woken .mail-retry .mail-retry-pos .mail-turn .mail-seen.lock mail-plane poll cursor, emission journal, transient-fetch retry set, retry-scan position, contended-slot turn flag, and overlapping-poll lock; written only by bin/fm-mail.sh (mail schema: docs/configuration.md "Mail plane") - pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh - procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) - procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line - decision-bindings/ private records marking a captured-answer source as feeding the keyed-answer intake, with a legacy origin on pre-collapse records; written only by bin/fm-captain-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/captain-hold-lifecycle.md) - reconcile-requests/ private open obligations to re-check a captain call whose board selection was `reconcile`; written only by bin/fm-captain-hold.sh, retired by its verify-then-decide outcomes or a normal answer that settles the call (section 13; docs/captain-hold-lifecycle.md) - when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) - inbox/ captain notes captured out of band by bin/fm-inbox.sh, including the voice handover's queued requests; each note appends one `check` wake and stays pending until acknowledged with `bin/fm-inbox.sh drain --ack `, which moves it to inbox/handled/ (docs/voice-relay.md) - x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) - x-context/ generated Relay 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 Relay 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: retained open-loop registrations, typed terminal-result inbox, results staged for an owning home on another machine, accepted/rejected ledgers, and retirement receipts (section 14; bin/fm-public-followup.sh) - x-poll.error x-poll.claim-error generated Relay and offer-claim diagnostic dedupe markers - .startup-network.* status, report, per-step elapsed timings, inline-print claim, and lock for the deferred startup stage that runs network checks and the inactive-outcome scan off the digest's blocking path; bin/fm-startup-network.sh - .wake-queue durable queued wakes retained until post-handling acknowledgement: epochseqkindkeypayload - .watcher-down private generation-bound recovery state coupling watcher downtime, durable wake presentation, and post-handling acknowledgement; never touch - ..open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) - .status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown - .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) - afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window - .afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh - .watch.lock .wake-queue.lock watcher singleton and queue serialization locks - .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch - .cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch - .hash-* .count-* .stale-* .stale-since-* .churn-since-* .paused-* .wedge-escalations-* .dead-reported-* .writing-* .waiting-* .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 -``` - +Load `firstmate-layout` before reasoning about any more specific path. 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. @@ -328,13 +229,9 @@ When the configured tasks-axi backlog gate applies, the spawn itself moves the w After spawning, confirm the worker is processing the brief and handle any trust dialog through `harness-adapters`. A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item. -Steer a worker with ordinary text through fail-closed `fm-send`: the message becomes a durable record in the task's steering inbox (multi-line text is legal, local and remote alike) and the worker's terminal receives only a constant doorbell line, with the watcher re-ringing an unacknowledged local message and escalating a stuck one (`bin/fm-task-inbox-lib.sh`; `bin/fm-send.sh` owns the typed-plane carve-outs). -A remote secondmate steer rides the same durable-inbox model through the remote transport; after an unconfirmed delivery, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe because it preserves the request body for remote enqueue deduplication (`bin/fm-send.sh` header). -When a steer answers an open keyed decision or blocker, pass `fm-send`'s `--resolve-key` so the answer itself closes that decision record at answer time, identically for local and remote workers (contract: `bin/fm-send.sh` header). -`fm-send` is the data plane for text the worker should read; never use its key or text paths for interrupt, exit, or other lifecycle control, because routing-marked lifecycle text becomes chat the worker reasons about instead of executing. -Drive a worker's lifecycle through `bin/fm-control.sh interrupt|exit|relaunch`, which owns the per-runtime mechanics, verifies each action, and never tears down or discards anything ([`docs/agent-control.md`](docs/agent-control.md)). -A secondmate's routed reply returns through status or a document pointer, not by firstmate peeking into its chat. -For the parent-owned correlation, recovery, and escalation contract on marked secondmate requests, see `bin/fm-pending-reply-lib.sh`. +Steer a worker with ordinary text through fail-closed `fm-send`; never use its key or text paths for interrupt, exit, or other lifecycle control. +Drive a worker's lifecycle through `bin/fm-control.sh interrupt|exit|relaunch` instead. +Load `task-steering` before either action; it is the single owner of the full steering and lifecycle-control mechanics, including the remote-secondmate transport and pending-reply correlation contract. Supervise all live work under section 8. ### Selected delivery path and merge authority @@ -588,6 +485,9 @@ These skills are not captain-invocable; load them only at their precise triggers - `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the Relay configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for a Relay-linked task before posting its completion follow-up; relevant only when Relay 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. +- `specialist-tools` - load before selecting a captain-approved `ecc`, `paperthin`, or `ultrawork` (`lazy codex`) path; select one only when the task fits and load only the selected skill or mode. +- `firstmate-layout` - load before inspecting, debugging, or reasoning about a specific file or directory under `FM_HOME` whose purpose section 2 does not already name, or before hand-writing a path into a script or check. +- `task-steering` - load before sending ordinary text to a worker, resending after an unconfirmed remote delivery, closing an open keyed decision with an answer, or interrupting, exiting, or relaunching a worker. ## 14. Relay diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 28be71621bc..a4a9a5eb752 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -148,6 +148,10 @@ "path": ".agents/skills/firstmate-coding-guidelines/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/firstmate-layout/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/firstmate-orca/SKILL.md", "audience": "agent-runtime" @@ -252,6 +256,14 @@ "path": ".agents/skills/stuck-crewmate-recovery/SKILL.md", "audience": "agent-runtime" }, + { + "path": ".agents/skills/specialist-tools/SKILL.md", + "audience": "agent-runtime" + }, + { + "path": ".agents/skills/task-steering/SKILL.md", + "audience": "agent-runtime" + }, { "path": ".agents/skills/updatefirstmate/SKILL.md", "audience": "agent-runtime" From 03bf275987a3715b5b50f8cfe94c2ab573e0bbdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 19 Sep 2026 18:41:42 +0900 Subject: [PATCH 06/19] fix(bin): reap inactive terminal crew endpoints and processes (#5) Co-authored-by: irene --- bin/fm-inactive-reconcile.sh | 33 +++++++++++++++++++++++++++-- tests/fm-inactive-reconcile.test.sh | 12 ++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 5cbaf9e63d2..553ac262397 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -475,6 +475,30 @@ report_child() { # report_child_ledger_locked "$id" "$meta" } +reap_terminal_child_locked() { # + local id=$1 meta=$2 backend target pids pid + [ -f "$meta" ] && [ ! -L "$meta" ] || return 0 + backend=$(clean_field "$(meta_field "$meta" backend)") + [ -n "$backend" ] || backend=tmux + target=$(clean_field "$(meta_field "$meta" window)") + [ -n "$target" ] || return 0 + if [ "$backend" = tmux ] && command -v tmux >/dev/null 2>&1; then + pids=$(tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null || true) + for pid in $pids; do + if [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null; then + kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + fi + done + fi + if [ -f "$SCRIPT_DIR/fm-backend.sh" ]; then + # shellcheck source=bin/fm-backend.sh + . "$SCRIPT_DIR/fm-backend.sh" + fm_backend_kill "$backend" "$target" 2>/dev/null || true + elif [ "$backend" = tmux ] && command -v tmux >/dev/null 2>&1; then + tmux kill-window -t "$target" 2>/dev/null || true + fi +} + reconcile_direct_child_locked() { # local id=$1 meta=$2 self=${3:-} timeout=$4 status turn last age state_line state pr incarnation fingerprint outcome_key payload kind state_rc=0 [ -f "$meta" ] && [ ! -L "$meta" ] || return 0 @@ -504,7 +528,7 @@ reconcile_direct_child_locked() { # diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 0c57b55691e..7a86902f496 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -39,6 +39,7 @@ SH case "${1:-}" in display-message) printf '%%1\n' ;; capture-pane) printf 'idle\n> \n' ;; + kill-window) [ -z "${FM_TMUX_KILL_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_TMUX_KILL_LOG" ;; esac SH local tool @@ -101,7 +102,7 @@ run_reconcile() { # [--startup] PATH="$WORLD/fakebin:$PATH" FM_ROOT_OVERRIDE="$WORLD/root" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ FM_INACTIVE_RECONCILE_SECS=60 FM_INACTIVE_CREW_STATE_BIN="$WORLD/fakebin/fm-crew-state.sh" \ - FM_FORGE_LOG="$WORLD/forge.log" "$RECON" scan ${option:+"$option"} + FM_FORGE_LOG="$WORLD/forge.log" FM_TMUX_KILL_LOG="${FM_TMUX_KILL_LOG:-}" "$RECON" scan ${option:+"$option"} } # The teardown-side entry point: deliver one child's terminal ledger line for a @@ -894,6 +895,14 @@ SH pass "reconciliation state reads set no-forge mode" } +test_inactive_terminal_child_endpoint_is_reaped() { + local kill_log="$WORLD/tmux_kill.log" + make_world reaper; write_child "$MAIN" dead-child 'done: finished' + FM_TMUX_KILL_LOG="$kill_log" FM_FAKE_CREW_STATE='done' run_reconcile "$MAIN" --startup + grep -q 'firstmate:=fm-dead-child' "$kill_log" 2>/dev/null || fail "terminal child endpoint was not reaped" + pass "inactive terminal child endpoint is reaped" +} + test_main_direct_terminal_presentation_receipt test_local_secondmate_delivers_terminal_ledger_line test_secondmate_multiline_terminal_outcome_is_delivered_once @@ -926,5 +935,6 @@ test_notice_recovery_does_not_duplicate_wake test_missing_parent_binding_names_itself test_reconciliation_never_calls_forge test_reconciliation_sets_no_forge_mode_for_state_read +test_inactive_terminal_child_endpoint_is_reaped echo "all inactive reconciliation tests passed" From 2f0cb70fe5eadba565e0cf7c6f823a4f566ed85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=8F=89=EC=8B=9D?= Date: Sat, 19 Sep 2026 20:12:58 +0900 Subject: [PATCH 07/19] chore: gitignore the treehouse worktree-pool state directory (#7) .treehouse/ holds only runtime pool bookkeeping (treehouse-state.json, treehouse-state.lock), never captain work, but its absence from .gitignore makes it show up as an untracked dirty-tree blocker for bin/fm-update.sh's self-update fast-forward check. Co-authored-by: irene --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3eece43c35f..edf05b424a7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ data/ scratchpad* .no-mistakes/ .lavish/ +.treehouse/ .fm-secondmate-home .fm-secondmate-parent .DS_Store From 9e5fdbfd7e862bf5a41070ebb9cc09bc28c90f67 Mon Sep 17 00:00:00 2001 From: irene Date: Sat, 19 Sep 2026 20:30:34 +0900 Subject: [PATCH 08/19] fix(quota): accept top-level unknown provider status with known sub-scopes --- bin/fm-quota-axi-lib.sh | 2 -- tests/fm-quota-choose.test.sh | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh index 7a2df68a440..af4e0f8ea84 100644 --- a/bin/fm-quota-axi-lib.sh +++ b/bin/fm-quota-axi-lib.sh @@ -62,8 +62,6 @@ fm_quota_json_valid() { all(.quotaSemantics.effectiveAvailability[]; .status == "known" or .status == "unknown" )) - elif $semantics_status == "unknown" then - all(.quotaSemantics.effectiveAvailability[]; .status == "unknown") else true end) and all(.quotaSemantics.effectiveAvailability[]; diff --git a/tests/fm-quota-choose.test.sh b/tests/fm-quota-choose.test.sh index 095e292365f..9df56c90ad2 100755 --- a/tests/fm-quota-choose.test.sh +++ b/tests/fm-quota-choose.test.sh @@ -22,6 +22,7 @@ UNKNOWN_EXHAUSTED="$LAB/unknown-exhausted.json" KNOWN_UNKNOWN="$LAB/known-unknown.json" KNOWN_EMPTY="$LAB/known-empty.json" SEMANTICS_MISMATCH="$LAB/semantics-mismatch.json" +MALFORMED_UNKNOWN_ROW="$LAB/malformed-unknown-row.json" PARTIAL="$LAB/partial.json" NO_APPLICABLE="$LAB/no-applicable.json" APPLICABLE_VETO="$LAB/applicable-veto.json" @@ -284,11 +285,18 @@ ok "known-empty quota fails closed" jq '(.providers[] | select(.provider == "claude").quotaSemantics.status) = "unknown"' \ "$LAB/captured.json" > "$SEMANTICS_MISMATCH" -if err=$(call_choose --snapshot "$SEMANTICS_MISMATCH" --candidate claude:default 2>&1); then - fail "unknown semantics with known entries unexpectedly dispatched" -fi -[ "$err" = "error: invalid quota-axi provider data" ] || fail "semantics mismatch returned: $err" -ok "semantics and availability statuses must agree" +out=$(call_choose --snapshot "$SEMANTICS_MISMATCH" --candidate claude:default) +[ "$out" = "claude default" ] || fail "unknown top-level status with known entries returned: $out" +ok "top-level unknown status permits known availability entries" + +jq '(.providers[] | select(.provider == "claude").quotaSemantics.status) = "unknown" | + (.providers[] | select(.provider == "claude").quotaSemantics.effectiveAvailability[0]) = {"scope":"all_models","status":"known"}' \ + "$LAB/captured.json" > "$MALFORMED_UNKNOWN_ROW" +if err=$(call_choose --snapshot "$MALFORMED_UNKNOWN_ROW" --candidate claude:default 2>&1); then + fail "malformed known row under unknown provider status unexpectedly dispatched" +fi +[ "$err" = "error: invalid quota-axi provider data" ] || fail "malformed known row returned: $err" +ok "malformed known row under unknown provider status fails closed" jq '(.providers[] | select(.provider == "claude").quotaSemantics.effectiveAvailability) = [{"scope":"all_models","status":"unknown","runway":{"status":"exhausted_now"}}]' \ "$LAB/captured.json" > "$UNKNOWN_EXHAUSTED" From 2829003dcfe2e74ecf8a7a73ba41a2f3c5c585f4 Mon Sep 17 00:00:00 2001 From: irene Date: Sat, 19 Sep 2026 21:50:48 +0900 Subject: [PATCH 09/19] fix(dispatch): rank known AGY quota scopes --- bin/fm-dispatch-resolve.sh | 13 +++++++++---- tests/fm-dispatch-resolve.test.sh | 7 +++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/bin/fm-dispatch-resolve.sh b/bin/fm-dispatch-resolve.sh index 12f67dbcb00..3c075bd73b6 100755 --- a/bin/fm-dispatch-resolve.sh +++ b/bin/fm-dispatch-resolve.sh @@ -274,12 +274,17 @@ RESULT=$(jq -n --arg floor "$CONFIDENCE_FLOOR" --argjson lat "$LAT_MS" --arg non def bare($m): ($m | split("/") | last); def provider_of($c): ($c.provider // $pmap[$c.harness] // null); def measured($p): - (prov($p) != null and (["known", "partial"] | index(prov($p).quotaSemantics.status)) != null); + (prov($p) != null and + ((["known", "partial"] | index(prov($p).quotaSemantics.status)) != null or + (prov($p).quotaSemantics.status == "unknown" and + any((prov($p).quotaSemantics.effectiveAvailability // [])[]; .status == "known")))); + def scope_applies($p; $scope; $m): + $scope == "all_models" or $scope == "all_products" or + ($p == "agy" and $scope == "gemini_only") or + ($m != "" and ($scope == ("model:" + (bare($m))) or $scope == ("product:" + (bare($m))))); def applicable($p; $m): - (bare($m)) as $bare | [rows($p)[] | select( - .scope == "all_models" or .scope == "all_products" or - ($m != "" and (.scope == ("model:" + $bare) or .scope == ("product:" + $bare))) + scope_applies($p; .scope; $m) )]; def floor_state($f; $p): if $f == null then "none" diff --git a/tests/fm-dispatch-resolve.test.sh b/tests/fm-dispatch-resolve.test.sh index 0c4c28c71ad..3445813085c 100755 --- a/tests/fm-dispatch-resolve.test.sh +++ b/tests/fm-dispatch-resolve.test.sh @@ -310,6 +310,13 @@ TYPESAFE_API_KEY=$KEY run code out err "$BRIEF" assert_contains "$out" 'candidate: agy:- provider=agy scope=all_models remaining=64% spendPriority=0.4 runway=through_reset -> eligible' "agy uses its resolver-only authoritative quota provider" assert_contains "$out" " profile: --harness 'agy'" "provider-less agy rule resolves" +TOP_UNKNOWN_AGY="$TMP_ROOT/top-unknown-agy.json" +jq '(.providers[] | select(.provider == "agy") | .quotaSemantics) |= (.status = "unknown" | .effectiveAvailability[0].scope = "gemini_only")' "$QUOTA" > "$TOP_UNKNOWN_AGY" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$TOP_UNKNOWN_AGY" run code out err "$BRIEF" +assert_contains "$out" 'candidate: agy:- provider=agy scope=gemini_only remaining=64% spendPriority=0.4 runway=through_reset -> eligible' "known Agy scope remains rankable under top-level unknown semantics" +assert_contains "$out" " profile: --harness 'agy'" "known Agy sub-scope can win dispatch" + GEMINI_RULE="$TMP_ROOT/gemini-rule.json" printf '%s\n' '{"rules":[{"when":"Gemini work.","use":{"harness":"gemini","model":"gemini-3.8-flash-high","provider":"google"}}]}' > "$GEMINI_RULE" cp "$GEMINI_RULE" "$RULES" From 883b3dcae88916fbf3aa43c532e80aaf37800a16 Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 02:13:49 +0900 Subject: [PATCH 10/19] feat: enable advisory Jev review assist --- .no-mistakes.yaml | 3 +++ docs/configuration.md | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 3f3aad29dd5..dd3b4a53019 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -35,6 +35,9 @@ commands: # Publish each run's test evidence to the orphan no-mistakes/evidence branch linked from the PR. # The evidence is not committed to the feature or default branch. +jev: + review_assist: true + test: evidence: store_in_repo: true diff --git a/docs/configuration.md b/docs/configuration.md index 67b868a49d0..b7a33c92e26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -220,7 +220,8 @@ The flag is a home-local supervision-noise preference and is not inherited by se ## Gate defaults (.no-mistakes.yaml) -The tracked `.no-mistakes.yaml` sets `test.evidence.store_in_repo: true` and pins `commands.lint` to `bin/fm-lint.sh`, the same owner CI invokes. +The tracked `.no-mistakes.yaml` enables `jev.review_assist: true` for advisory pre-brief context in the review step only, sets `test.evidence.store_in_repo: true`, and pins `commands.lint` to `bin/fm-lint.sh`, the same owner CI invokes. +The existing review remains the merge-gate decision point; Jev does not gate delivery. Storing evidence in the repo publishes each run's test artifacts to the orphan `no-mistakes/evidence` branch and links them from the PR body, instead of keeping them on local disk under the no-mistakes home. That branch shares no history with code branches, so evidence never enters a pushed feature branch or the default branch; the worktree's `.no-mistakes/` stays local and CI rejects tracked entries under that path. The [`firstmate-coding-guidelines` skill](../.agents/skills/firstmate-coding-guidelines/SKILL.md#no-mistakes-test-configuration) owns why `commands.test` stays absent and targeted validation belongs to the evidence path. From 747b13565ab8669475c256fb8bce18ed17a42126 Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 18:16:49 +0900 Subject: [PATCH 11/19] no-mistakes(review): Fix fail-closed cleanup and CLAUDE migration trigger --- bin/backends/cmux.sh | 23 +++++++++++++++++++++++ bin/backends/herdr.sh | 13 ++++++++----- bin/backends/orca.sh | 6 +++++- bin/backends/tmux.sh | 14 ++++++++++++++ bin/backends/zellij.sh | 18 ++++++++++++++++++ bin/fm-backend.sh | 15 +++++++++++++++ bin/fm-brief.sh | 2 +- bin/fm-inactive-reconcile.sh | 19 ++++++++++++++----- tests/fm-inactive-reconcile.test.sh | 24 +++++++++++++++++++++++- 9 files changed, 121 insertions(+), 13 deletions(-) diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 747d3fc2ccd..925981f2565 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -632,6 +632,29 @@ fm_backend_cmux_kill() { # [unused] [expected-label] fm_backend_cmux_cli close-workspace --workspace "$wsid" >/dev/null 2>&1 || true } +fm_backend_cmux_endpoint_confirmed_gone() { + local target=$1 expected_label=${3:-} workspaces panes workspace_count surface_count expected_title + fm_backend_cmux_parse_target "$target" || return 1 + workspaces=$(fm_backend_cmux_cli workspace list --json --id-format uuids 2>/dev/null) || return 1 + if [ -n "$expected_label" ]; then + expected_title=$(fm_backend_cmux_scoped_title "$expected_label") + workspace_count=$(printf '%s' "$workspaces" | jq -er --arg title "$expected_title" \ + '[.workspaces[]? | select(.title == $title)] | length' 2>/dev/null) || return 1 + [ "$workspace_count" -eq 0 ] || return 1 + workspace_count=$(printf '%s' "$workspaces" | jq -er --arg w "$FM_BACKEND_CMUX_WORKSPACE" \ + '[.workspaces[]? | select(.id == $w)] | length' 2>/dev/null) || return 1 + [ "$workspace_count" -eq 0 ] && return 0 + return 1 + fi + workspace_count=$(printf '%s' "$workspaces" | jq -er --arg w "$FM_BACKEND_CMUX_WORKSPACE" \ + '[.workspaces[]? | select(.id == $w)] | length' 2>/dev/null) || return 1 + [ "$workspace_count" -eq 0 ] && return 0 + panes=$(fm_backend_cmux_cli list-panes --workspace "$FM_BACKEND_CMUX_WORKSPACE" --json --id-format uuids 2>/dev/null) || return 1 + surface_count=$(printf '%s' "$panes" | jq -er --arg s "$FM_BACKEND_CMUX_SURFACE" \ + '[.panes[]? | select(.surface_ids // [] | index($s))] | length' 2>/dev/null) || return 1 + [ "$surface_count" -eq 0 ] +} + # fm_backend_cmux_list_live: recovery/orphan discovery. Lists every workspace # whose title is scoped to this firstmate home, by TITLE - never by trusting a # stored uuid, since workspace ids do NOT survive an app relaunch (finding #5). diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 88a8cb61492..d8df261fc06 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -363,7 +363,7 @@ fm_backend_herdr_workspace_label() { id=$(tr -d '[:space:]' < "$marker" 2>/dev/null) if [ -n "$id" ]; then printf '2ndmate-%s' "$id" - return 0 + return "$close_failed" fi fi printf 'firstmate' @@ -3332,14 +3332,14 @@ fm_backend_herdr_kill_serialized() { # fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true fi fm_backend_herdr_projection_focus_restore "$session" "$before" "task kill" || true - return 0 + return "$close_failed" fi fi - fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || true + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" } fm_backend_herdr_kill() { # - fm_backend_herdr_target_ready "$1" || return 0 + fm_backend_herdr_target_ready "$1" || return 1 local session=$FM_BACKEND_HERDR_SESSION pane=$FM_BACKEND_HERDR_PANE local lock_path attempt=0 lock_held=0 if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then @@ -3357,10 +3357,13 @@ fm_backend_herdr_kill() { # done fi if [ "$lock_held" = 1 ]; then - fm_backend_herdr_kill_serialized "$session" "$pane" + local rc + fm_backend_herdr_kill_serialized "$session" "$pane"; rc=$? fm_lock_release "$lock_path" || true + return "$rc" else echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2 + return 1 fi } diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index ffea7bdfadf..cee038146c3 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -294,5 +294,9 @@ fm_backend_orca_send_text_submit() { # fm_backend_orca_tool_check || return 1 - orca terminal close --terminal "$1" --json >/dev/null 2>&1 || true + orca terminal close --terminal "$1" --json >/dev/null 2>&1 +} + +fm_backend_orca_endpoint_confirmed_gone() { + return 1 } diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 2bc1c8aa0d7..9634c0978d2 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -203,6 +203,20 @@ fm_backend_tmux_kill() { # return 1 } +fm_backend_tmux_endpoint_confirmed_gone() { + local target=$1 session window windows inventory_status + case "$target" in + *:*) session=${target%%:*}; window=${target#*:} ;; + *) return 1 ;; + esac + case "$session:$window" in :*|*:|*:*:*) return 1 ;; esac + windows=$(fm_backend_tmux_window_inventory "=$session") + inventory_status=$? + [ "$inventory_status" -eq 2 ] && return 0 + [ "$inventory_status" -eq 0 ] || return 1 + ! printf '%s\n' "$windows" | grep -qxF -- "$window" +} + # fm_backend_tmux_current_command: 's live foreground process name - # tmux's own `#{pane_current_command}`, already resolved from the pty's # foreground process group (verified empirically with real tmux 3.6a: a diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index a90247899f1..c2a99574a01 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -621,6 +621,24 @@ fm_backend_zellij_kill() { # [tab_id] [expected_label] fi } +fm_backend_zellij_endpoint_confirmed_gone() { + local target=$1 expected_label=${3:-} sessions panes count tabs scoped + fm_backend_zellij_parse_target "$target" || return 1 + sessions=$(zellij list-sessions --short --no-formatting 2>/dev/null) || return 1 + printf '%s\n' "$sessions" | grep -qxF -- "$FM_BACKEND_ZELLIJ_SESSION" || return 0 + if [ -n "$expected_label" ]; then + scoped=$(fm_backend_zellij_scoped_title "$expected_label") + tabs=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-tabs --json 2>/dev/null) || return 1 + count=$(printf '%s' "$tabs" | jq -er --arg scoped "$scoped" --arg bare "$expected_label" \ + '[.[]? | select(.name == $scoped or .name == $bare)] | length' 2>/dev/null) || return 1 + [ "$count" -eq 0 ] || return 1 + fi + panes=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-panes --json 2>/dev/null) || return 1 + count=$(printf '%s' "$panes" | jq -er --argjson p "$FM_BACKEND_ZELLIJ_PANE" \ + '[.[]? | select(.id == $p and .is_plugin == false)] | length' 2>/dev/null) || return 1 + [ "$count" -eq 0 ] +} + # fm_backend_zellij_list_live: recovery/orphan discovery. Lists every tab in # whose title carries THIS firstmate home's own tag # (fm--, fm_backend_zellij_home_label) - never any other home's diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 9e6a5730a5f..ee2059b73e4 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -817,6 +817,21 @@ fm_backend_kill() { # cmux) fm_backend_cmux_kill "$@" ;; *) echo "error: no kill implementation for backend '$backend'" >&2; return 1 ;; esac + fm_backend_endpoint_confirmed_gone "$backend" "$@" && return 0 + return 1 +} + +fm_backend_endpoint_confirmed_gone() { + local backend=$1 + shift + case "$backend" in + tmux) fm_backend_tmux_endpoint_confirmed_gone "$@" ;; + herdr) fm_backend_herdr_endpoint_confirmed_gone "$@" ;; + zellij) fm_backend_zellij_endpoint_confirmed_gone "$@" ;; + orca) fm_backend_orca_endpoint_confirmed_gone "$@" ;; + cmux) fm_backend_cmux_endpoint_confirmed_gone "$@" ;; + *) return 1 ;; + esac } fm_backend_remove_worktree() { # diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 86e8d3f2761..264126f6d99 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -506,7 +506,7 @@ $ASK_USER_BLOCK $INBOX_SECTION # Project memory -If \`AGENTS.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. +If \`AGENTS.md\` or \`CLAUDE.md\` already exists, or if this task produced durable project-intrinsic knowledge, run \`$FM_ROOT/bin/fm-ensure-agents-md.sh .\` in the worktree. Record only project knowledge useful to almost every future session. For anything the codebase already shows, prefer a pointer to the authoritative file, command, or doc over copying the detail. If you touch a project \`AGENTS.md\`, follow \`$FM_ROOT/bin/fm-ensure-agents-md.sh\`'s self-governance contract in the same pass. diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 553ac262397..c1fb036b69e 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -493,9 +493,9 @@ reap_terminal_child_locked() { # if [ -f "$SCRIPT_DIR/fm-backend.sh" ]; then # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" - fm_backend_kill "$backend" "$target" 2>/dev/null || true + fm_backend_kill "$backend" "$target" 2>/dev/null elif [ "$backend" = tmux ] && command -v tmux >/dev/null 2>&1; then - tmux kill-window -t "$target" 2>/dev/null || true + tmux kill-window -t "$target" 2>/dev/null fi } @@ -537,8 +537,19 @@ reconcile_direct_child_locked() { # diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 7a86902f496..4ceb7ca2acb 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -39,7 +39,13 @@ SH case "${1:-}" in display-message) printf '%%1\n' ;; capture-pane) printf 'idle\n> \n' ;; - kill-window) [ -z "${FM_TMUX_KILL_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_TMUX_KILL_LOG" ;; + kill-window) + if [ "${FM_TMUX_CLOSE_FAIL:-0}" = 1 ]; then exit 1; fi + [ -z "${FM_TMUX_KILL_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_TMUX_KILL_LOG" + ;; + list-windows) + if [ "${FM_TMUX_CLOSE_FAIL:-0}" = 1 ]; then printf 'fm-dead-child\n'; fi + ;; esac SH local tool @@ -903,6 +909,21 @@ test_inactive_terminal_child_endpoint_is_reaped() { pass "inactive terminal child endpoint is reaped" } +test_failed_reap_retains_actionable_obligation() { + local out + make_world reaper-fail; write_child "$MAIN" dead-child 'done: finished' + if out=$(FM_TMUX_CLOSE_FAIL=1 FM_FAKE_CREW_STATE='done' run_reconcile "$MAIN" --startup); then + fail "reconciliation reported success after endpoint cleanup failed" + fi + [ "$(wake_count "$MAIN" 'inactive-reconcile:')" = 1 ] \ + || fail "cleanup failure did not queue one actionable notice: $out" + [ "$(outcome_count "$MAIN" pending)" = 1 ] \ + || fail "cleanup failure did not retain a pending outcome receipt" + [ "$(wake_count "$MAIN" 'inactive-outcome:')" = 0 ] \ + || fail "terminal presentation was queued before cleanup succeeded" + pass "failed terminal cleanup remains pending and actionable" +} + test_main_direct_terminal_presentation_receipt test_local_secondmate_delivers_terminal_ledger_line test_secondmate_multiline_terminal_outcome_is_delivered_once @@ -936,5 +957,6 @@ test_missing_parent_binding_names_itself test_reconciliation_never_calls_forge test_reconciliation_sets_no_forge_mode_for_state_read test_inactive_terminal_child_endpoint_is_reaped +test_failed_reap_retains_actionable_obligation echo "all inactive reconciliation tests passed" From 4d4809eaa2655dbae3c13448706dbb7bc984bae3 Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 18:43:45 +0900 Subject: [PATCH 12/19] no-mistakes(review): Fix terminal cleanup proof and backend label regression --- bin/backends/herdr.sh | 2 +- bin/backends/orca.sh | 20 +++++++-------- bin/fm-backend.sh | 4 --- bin/fm-inactive-reconcile.sh | 46 +++++++++++++++++++++++++---------- tests/fm-backend-orca.test.sh | 15 +++++++++--- 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index d8df261fc06..c2e3beb84eb 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -363,7 +363,7 @@ fm_backend_herdr_workspace_label() { id=$(tr -d '[:space:]' < "$marker" 2>/dev/null) if [ -n "$id" ]; then printf '2ndmate-%s' "$id" - return "$close_failed" + return 0 fi fi printf 'firstmate' diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index cee038146c3..26b0baff2ed 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -284,19 +284,19 @@ fm_backend_orca_send_text_submit() { # fm_backend_orca_tool_check || return 1 - orca terminal close --terminal "$1" --json >/dev/null 2>&1 + orca terminal close --terminal "$1" --json >/dev/null 2>&1 || true } fm_backend_orca_endpoint_confirmed_gone() { - return 1 + local output + fm_backend_orca_tool_check || return 1 + output=$(orca terminal read --terminal "$1" --limit 1 --json 2>&1) || true + printf '%s' "$output" | node -e ' +const fs = require("fs"); +let value; +try { value = JSON.parse(fs.readFileSync(0, "utf8")); } catch (_) { process.exit(1); } +process.exit(value && value.ok === false && value.error && value.error.code === "terminal_handle_stale" ? 0 : 1); +' } diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index ee2059b73e4..a1b3547b855 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -798,10 +798,6 @@ fm_backend_send_text_submit() { # diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index c1fb036b69e..e6e0f5fa322 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -410,6 +410,16 @@ report_child_ledger_locked() { # fingerprint=$(sha256_text "$incarnation|$id|$state|ledger|$last") outcome_key="child-outcome-$id-$state-${fingerprint:0:8}" ensure_record "$fingerprint" "$id" "$incarnation" "$state" "$outcome_key" direct upstream "$pr" || return 1 + if ! reap_terminal_child_locked "$id" "$meta"; then + if [ -n "$RECORD_PENDING" ]; then + notice_parent_report_failed "$RECORD_PENDING" "$fingerprint" \ + "child terminal cleanup needs retry before parent report: child=$id state=$state" + else + publish_actionable "inactive-reconcile:$fingerprint" \ + "child terminal cleanup needs retry before parent report: child=$id state=$state" || true + fi + return 1 + fi [ -n "$RECORD_PENDING" ] || return 0 last_status_line "$status" previous >/dev/null predecessor_head=$(sha256_text "$previous") @@ -476,12 +486,13 @@ report_child() { # } reap_terminal_child_locked() { # - local id=$1 meta=$2 backend target pids pid - [ -f "$meta" ] && [ ! -L "$meta" ] || return 0 - backend=$(clean_field "$(meta_field "$meta" backend)") - [ -n "$backend" ] || backend=tmux - target=$(clean_field "$(meta_field "$meta" window)") - [ -n "$target" ] || return 0 + local id=$1 meta=$2 backend target pids pid tab_id expected_label + [ -f "$SCRIPT_DIR/fm-backend.sh" ] || return 1 + # shellcheck source=bin/fm-backend.sh + . "$SCRIPT_DIR/fm-backend.sh" + fm_backend_validate_task_endpoint "$meta" "$id" >/dev/null 2>&1 || return 1 + backend=$FM_BACKEND_VALIDATED_BACKEND + target=$FM_BACKEND_VALIDATED_TARGET if [ "$backend" = tmux ] && command -v tmux >/dev/null 2>&1; then pids=$(tmux list-panes -t "$target" -F '#{pane_pid}' 2>/dev/null || true) for pid in $pids; do @@ -490,13 +501,19 @@ reap_terminal_child_locked() { # fi done fi - if [ -f "$SCRIPT_DIR/fm-backend.sh" ]; then - # shellcheck source=bin/fm-backend.sh - . "$SCRIPT_DIR/fm-backend.sh" - fm_backend_kill "$backend" "$target" 2>/dev/null - elif [ "$backend" = tmux ] && command -v tmux >/dev/null 2>&1; then - tmux kill-window -t "$target" 2>/dev/null - fi + expected_label="fm-$id" + case "$backend" in + zellij) + tab_id=$(clean_field "$(meta_field "$meta" zellij_tab_id)") + fm_backend_kill "$backend" "$target" "$tab_id" "$expected_label" 2>/dev/null + ;; + cmux) + fm_backend_kill "$backend" "$target" '' "$expected_label" 2>/dev/null + ;; + *) + fm_backend_kill "$backend" "$target" 2>/dev/null + ;; + esac } reconcile_direct_child_locked() { # @@ -546,6 +563,9 @@ reconcile_direct_child_locked() { # "$COUNT_FILE" if [ -f "$RESP/$n.exit" ]; then exit "$(cat "$RESP/$n.exit")" fi -[ -f "$RESP/$n.out" ] && cat "$RESP/$n.out" +if [ -f "$RESP/$n.out" ]; then + cat "$RESP/$n.out" +fi exit 0 SH chmod +x "$fb/orca" @@ -962,7 +969,8 @@ test_teardown_preserves_metadata_when_orca_remove_error_json() { "decisions_reviewed=1" "decision_keys=" orca_case remove-error-teardown printf '{"ok":true,"result":{}}\n' > "$RESP/1.out" - printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/2.out" + printf '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal handle stale"}}\n' > "$RESP/2.out" + printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/3.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -1235,7 +1243,8 @@ test_secondmate_force_teardown_removes_orca_child_via_orca() { printf '{"ok":true,"result":{"worktree":{"id":"wt-child-cleanup::/orca/wt-child-cleanup","path":"%s"}}}\n' "$childwt" > "$RESP/1.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-child-cleanup::/orca/wt-child-cleanup","path":"%s"}}}\n' "$childwt" > "$RESP/2.out" printf '{"ok":true,"result":{}}\n' > "$RESP/3.out" - printf '{"ok":true,"result":{}}\n' > "$RESP/4.out" + printf '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal handle stale"}}\n' > "$RESP/4.out" + printf '{"ok":true,"result":{}}\n' > "$RESP/5.out" add_tmux_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e From dc20da3d11558388f2cb06e2fd555197d4f5f6a7 Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 19:02:56 +0900 Subject: [PATCH 13/19] no-mistakes(review): Harden home-scoped Zellij and cmux cleanup proof --- bin/backends/cmux.sh | 22 +++++++--------------- bin/backends/zellij.sh | 13 ++++++------- bin/fm-bootstrap.sh | 15 ++++++++++++++- tests/fm-backend-zellij.test.sh | 4 ++++ 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 925981f2565..836d0ac8e57 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -633,26 +633,18 @@ fm_backend_cmux_kill() { # [unused] [expected-label] } fm_backend_cmux_endpoint_confirmed_gone() { - local target=$1 expected_label=${3:-} workspaces panes workspace_count surface_count expected_title + local target=$1 expected_label=${3:-} workspaces workspace_count expected_title + [ -n "$expected_label" ] || return 1 fm_backend_cmux_parse_target "$target" || return 1 workspaces=$(fm_backend_cmux_cli workspace list --json --id-format uuids 2>/dev/null) || return 1 - if [ -n "$expected_label" ]; then - expected_title=$(fm_backend_cmux_scoped_title "$expected_label") - workspace_count=$(printf '%s' "$workspaces" | jq -er --arg title "$expected_title" \ - '[.workspaces[]? | select(.title == $title)] | length' 2>/dev/null) || return 1 - [ "$workspace_count" -eq 0 ] || return 1 - workspace_count=$(printf '%s' "$workspaces" | jq -er --arg w "$FM_BACKEND_CMUX_WORKSPACE" \ - '[.workspaces[]? | select(.id == $w)] | length' 2>/dev/null) || return 1 - [ "$workspace_count" -eq 0 ] && return 0 - return 1 - fi + expected_title=$(fm_backend_cmux_scoped_title "$expected_label") + workspace_count=$(printf '%s' "$workspaces" | jq -er --arg title "$expected_title" \ + '[.workspaces[]? | select(.title == $title)] | length' 2>/dev/null) || return 1 + [ "$workspace_count" -eq 0 ] || return 1 workspace_count=$(printf '%s' "$workspaces" | jq -er --arg w "$FM_BACKEND_CMUX_WORKSPACE" \ '[.workspaces[]? | select(.id == $w)] | length' 2>/dev/null) || return 1 [ "$workspace_count" -eq 0 ] && return 0 - panes=$(fm_backend_cmux_cli list-panes --workspace "$FM_BACKEND_CMUX_WORKSPACE" --json --id-format uuids 2>/dev/null) || return 1 - surface_count=$(printf '%s' "$panes" | jq -er --arg s "$FM_BACKEND_CMUX_SURFACE" \ - '[.panes[]? | select(.surface_ids // [] | index($s))] | length' 2>/dev/null) || return 1 - [ "$surface_count" -eq 0 ] + return 1 } # fm_backend_cmux_list_live: recovery/orphan discovery. Lists every workspace diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index c2a99574a01..f9e7d0293da 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -626,13 +626,12 @@ fm_backend_zellij_endpoint_confirmed_gone() { fm_backend_zellij_parse_target "$target" || return 1 sessions=$(zellij list-sessions --short --no-formatting 2>/dev/null) || return 1 printf '%s\n' "$sessions" | grep -qxF -- "$FM_BACKEND_ZELLIJ_SESSION" || return 0 - if [ -n "$expected_label" ]; then - scoped=$(fm_backend_zellij_scoped_title "$expected_label") - tabs=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-tabs --json 2>/dev/null) || return 1 - count=$(printf '%s' "$tabs" | jq -er --arg scoped "$scoped" --arg bare "$expected_label" \ - '[.[]? | select(.name == $scoped or .name == $bare)] | length' 2>/dev/null) || return 1 - [ "$count" -eq 0 ] || return 1 - fi + [ -n "$expected_label" ] || return 1 + scoped=$(fm_backend_zellij_scoped_title "$expected_label") + tabs=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-tabs --json 2>/dev/null) || return 1 + count=$(printf '%s' "$tabs" | jq -er --arg scoped "$scoped" --arg bare "$expected_label" \ + '[.[]? | select(.name == $scoped or .name == $bare)] | length' 2>/dev/null) || return 1 + [ "$count" -eq 0 ] || return 1 panes=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action list-panes --json 2>/dev/null) || return 1 count=$(printf '%s' "$panes" | jq -er --argjson p "$FM_BACKEND_ZELLIJ_PANE" \ '[.[]? | select(.id == $p and .is_plugin == false)] | length' 2>/dev/null) || return 1 diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 31792fa37ba..0d2130d74fb 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -824,7 +824,20 @@ secondmate_liveness_one() { # dead|missing) if [ "$agent_state" = dead ]; then cause="confirmed agent absence on existing endpoint" - fm_backend_kill "$backend" "$target" 2>/dev/null || true + case "$backend" in + zellij) + fm_backend_kill "$backend" "$target" "$(fm_meta_get "$meta" zellij_tab_id)" "fm-$id" 2>/dev/null + ;; + cmux) + fm_backend_kill "$backend" "$target" '' "fm-$id" 2>/dev/null + ;; + *) + fm_backend_kill "$backend" "$target" 2>/dev/null + ;; + esac || { + echo "SECONDMATE_LIVENESS: secondmate $id: skipped: endpoint cleanup could not be confirmed (backend=$backend)" + return 0 + } else cause="recorded endpoint confidently missing" fi diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 4963b051314..0f418a82ab7 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -850,6 +850,8 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { "decision_keys=" printf '[]\n' > "$dir/responses/1.out" printf '[{"tab_id":3,"name":"fm-zghost"}]\n' > "$dir/responses/2.out" + printf '[]\n' > "$dir/responses/4.out" + printf '[]\n' > "$dir/responses/5.out" fb=$(make_zellij_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ @@ -896,6 +898,8 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { zellij_pane_response "$dir" 1 7 4 zellij_tab_response "$dir" 2 4 "$child_title" printf '[]\n' > "$dir/responses/3.out" + printf '[]\n' > "$dir/responses/4.out" + printf '[]\n' > "$dir/responses/5.out" fb=$(make_zellij_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_ROOT_OVERRIDE="$ROOT" \ From 350e2bc414ce8814da5403ea62fc7b05e13f561d Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 19:17:10 +0900 Subject: [PATCH 14/19] no-mistakes(review): Remove unmapped config and refresh cleanup contract --- .backpassrc.json | 35 --------------------------- docs/verification/runtime-backends.md | 17 +++++++------ 2 files changed, 10 insertions(+), 42 deletions(-) delete mode 100644 .backpassrc.json diff --git a/.backpassrc.json b/.backpassrc.json deleted file mode 100644 index 262a3dae267..00000000000 --- a/.backpassrc.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "memoryFiles": [ - "AGENTS.md", - "CLAUDE.md" - ], - "budgetTokens": 5000, - "skillsDir": ".agents/skills", - "minGapEvidence": 2, - "maxTranscripts": 100, - "analysis": { - "agent": null, - "model": null, - "effort": null - }, - "synthesis": { - "agent": null, - "model": null, - "effort": null - }, - "discovery": { - "harnesses": [ - "claude", - "codex", - "pi", - "opencode", - "grok", - "cursor", - "hermes" - ], - "since": "30d", - "worktreeGlobs": [], - "minUserTurns": 2 - }, - "jobs": 4 -} diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f870d561b89..8ba84ce4adc 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -358,13 +358,16 @@ The refusal is reached only through a close that could not do its job, and each | Backend | already gone | a close that failed | | --- | --- | --- | | tmux | 0, silent | 1, resolved by re-reading the window's exact recorded identity; a read that itself could not run refuses rather than passing for absence | -| orca | 0, silent | 1 when a missing CLI means no close was attempted; 0 for a close command that failed after the CLI accepted it | -| zellij | 0, silent | 0, not yet distinguishable | -| cmux | 0, silent | 0, not yet distinguishable | -| herdr | 0, silent | 0 from this arm; `bin/fm-teardown.sh` gates every Herdr record removal on `fm_backend_herdr_endpoint_confirmed_gone` instead | - -The three arms that still report 0 need a presence re-read taken after their own close, and the close-then-read timing that re-read depends on cannot be established without the real Zellij, Orca, and cmux binaries. -Guessing it is what a refusal must never rest on: a gate that refused an already-exited session would break ordinary cleanup on every task, which is a worse failure than the stranded endpoint it would be trying to prevent. +| orca | 0, silent when the terminal handle is definitively stale | 1 when the CLI, close, or stale-handle re-read cannot prove absence | +| zellij | 0, silent when the session is absent | 1 when the close or scoped tab/pane proof cannot prove absence | +| cmux | 0, silent when the recorded workspace is absent | 1 when the close or scoped workspace proof cannot prove absence | +| herdr | 0, silent when the exact recorded pane is confirmed dead | 1 when the exact pane presence is unknown or live | + +Every arm now performs its backend-specific absence proof after close. +Zellij requires the recorded task label and confirms that both the scoped task tab and recorded pane are absent. +cmux requires the recorded task label and confirms that both the scoped task workspace and recorded workspace are absent. +Orca accepts only the documented stale-handle read result. +Any missing, unreadable, or ambiguous proof returns 1 rather than passing for absence. tmux's re-read is deliberately exact - `=session` plus a whole-line window-name match - because a prefix match would read a neighboring window as this window's survivor, which is the same exactness the cleanup identity boundary above already requires. It is also deliberately conservative about the read itself, sharing `fm_backend_tmux_window_inventory` with `fm_backend_tmux_agent_state` so both mean the same thing by an absent session: only a definitive missing-session, missing-server, or connect-error response proves the window gone. Any other read failure - a momentarily unresponsive server, or a teardown PATH without tmux on it - refuses, because a read that could not run is not evidence of absence. From 4c1d9e3b1929c93951df6ba4d6b98cd3bd6c226c Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 19:29:26 +0900 Subject: [PATCH 15/19] fix(docs): correct jev.review_assist as per-operator global config Verified against no-mistakes v1.79.0's own e2e tests and upstream PR #1120: jev.review_assist is global-only and has no effect when set in a repo's tracked .no-mistakes.yaml. Revert that no-op change and document the real activation path, data sent, and data-boundary guidance instead. --- .no-mistakes.yaml | 3 --- docs/configuration.md | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index dd3b4a53019..3f3aad29dd5 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -35,9 +35,6 @@ commands: # Publish each run's test evidence to the orphan no-mistakes/evidence branch linked from the PR. # The evidence is not committed to the feature or default branch. -jev: - review_assist: true - test: evidence: store_in_repo: true diff --git a/docs/configuration.md b/docs/configuration.md index b7a33c92e26..c37dde5dc8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -220,8 +220,7 @@ The flag is a home-local supervision-noise preference and is not inherited by se ## Gate defaults (.no-mistakes.yaml) -The tracked `.no-mistakes.yaml` enables `jev.review_assist: true` for advisory pre-brief context in the review step only, sets `test.evidence.store_in_repo: true`, and pins `commands.lint` to `bin/fm-lint.sh`, the same owner CI invokes. -The existing review remains the merge-gate decision point; Jev does not gate delivery. +The tracked `.no-mistakes.yaml` sets `test.evidence.store_in_repo: true` and pins `commands.lint` to `bin/fm-lint.sh`, the same owner CI invokes. Storing evidence in the repo publishes each run's test artifacts to the orphan `no-mistakes/evidence` branch and links them from the PR body, instead of keeping them on local disk under the no-mistakes home. That branch shares no history with code branches, so evidence never enters a pushed feature branch or the default branch; the worktree's `.no-mistakes/` stays local and CI rejects tracked entries under that path. The [`firstmate-coding-guidelines` skill](../.agents/skills/firstmate-coding-guidelines/SKILL.md#no-mistakes-test-configuration) owns why `commands.test` stays absent and targeted validation belongs to the evidence path. @@ -229,6 +228,28 @@ The [`firstmate-coding-guidelines` skill](../.agents/skills/firstmate-coding-gui See [CONTRIBUTING.md](../CONTRIBUTING.md) for the firstmate-specific local test policy and entry points. Portable shard evidence and coverage rules are in [fm-test-portable-shards.md](fm-test-portable-shards.md); [herdr-backend.md](herdr-backend.md#destructive-lab-safety) owns the real-Herdr lane's isolation boundary, and [runtime-backends.md](verification/runtime-backends.md#herdr) owns active evidence. +## Jev review assist (optional, per-operator) + +no-mistakes v1.79.0+ can send each review turn's diff to TypeSafe's Jev model to rank which surrounding files are worth reading first; the ranked list only adds candidate paths to the review prompt, and the existing cold review remains the sole merge-gate decision point. +`jev.review_assist` is **global-only**: it does not exist in, and cannot be set from, a repo's tracked `.no-mistakes.yaml` (verified in no-mistakes' own end-to-end tests - a pushed or default-branch repo config setting this key never contacts TypeSafe). +An operator opts in locally, per machine, in their own `~/.no-mistakes/config.yaml`: + +```yaml +jev: + review_assist: true +``` + +It also needs `TYPESAFE_API_KEY` set in the daemon's environment; without a key the step log says so and review proceeds without a pre-brief, exactly as when the setting is off. +On every call failure, oversized reply, or missing key, no-mistakes falls back to the ordinary cold review with no pre-brief - this repo does not depend on Jev being reachable. + +**What is sent.** Per review turn, no-mistakes sends the diff of the files under review (never files matched by this repo's `ignore_patterns`, which are not "reviewable") plus up to 40 candidate file paths - paths only, never their content - ranked by name rarity and directory proximity. No project name, PR body, or brief text is part of this call. + +**Data boundary.** This repository's captain-private and gitignored paths (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) are untracked, so they can never appear in a git diff and are never reachable by this or any other diff-based review path. +The remaining operator responsibility is ordinary git hygiene: keep secrets out of tracked files, since no-mistakes has no Jev-specific secret redaction beyond the review step's existing findings pipeline. +An operator who wants tighter control over what becomes "reviewable" (and therefore diff-eligible for a Jev call) can extend this repo's own `ignore_patterns` for paths that should never enter any review, Jev-assisted or not. + +**Audit.** The review step log already records whether a pre-brief was requested, whether it was used, and the reason for any fallback (`no-mistakes axi logs --step review --full`); no separate Jev-specific audit log exists in this repo, since the call itself happens inside the no-mistakes daemon process, outside firstmate's own scripts. + ## Captain Preferences (data/captain.md / data/captain-shared.md) Domain-local preferences for one captain's fleet live locally in each home's `data/captain.md`; it is gitignored and printed in the session-start context digest after `data/projects.md` and optional `data/secondmates.md`. From cdbb9b40bc2e07f49702bdca61e36a714a535257 Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 19:43:10 +0900 Subject: [PATCH 16/19] no-mistakes(review): Fix inactive reconciliation PR fallback source --- bin/fm-inactive-reconcile.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index e6e0f5fa322..7157526d4ac 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -545,7 +545,7 @@ reconcile_direct_child_locked() { # Date: Sun, 20 Sep 2026 20:01:47 +0900 Subject: [PATCH 17/19] no-mistakes(document): Clarify Jev advisory configuration and data boundary --- docs/configuration.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c37dde5dc8b..27420e0b0f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -230,7 +230,7 @@ Portable shard evidence and coverage rules are in [fm-test-portable-shards.md](f ## Jev review assist (optional, per-operator) -no-mistakes v1.79.0+ can send each review turn's diff to TypeSafe's Jev model to rank which surrounding files are worth reading first; the ranked list only adds candidate paths to the review prompt, and the existing cold review remains the sole merge-gate decision point. +no-mistakes v1.79.0+ can send each review turn's diff to TypeSafe's Jev model to rank which surrounding files are worth reading first; the ranked list only enriches the review prompt, the ordinary cold complete review remains authoritative, and Jev is advisory only and never gates delivery. `jev.review_assist` is **global-only**: it does not exist in, and cannot be set from, a repo's tracked `.no-mistakes.yaml` (verified in no-mistakes' own end-to-end tests - a pushed or default-branch repo config setting this key never contacts TypeSafe). An operator opts in locally, per machine, in their own `~/.no-mistakes/config.yaml`: @@ -242,11 +242,10 @@ jev: It also needs `TYPESAFE_API_KEY` set in the daemon's environment; without a key the step log says so and review proceeds without a pre-brief, exactly as when the setting is off. On every call failure, oversized reply, or missing key, no-mistakes falls back to the ordinary cold review with no pre-brief - this repo does not depend on Jev being reachable. -**What is sent.** Per review turn, no-mistakes sends the diff of the files under review (never files matched by this repo's `ignore_patterns`, which are not "reviewable") plus up to 40 candidate file paths - paths only, never their content - ranked by name rarity and directory proximity. No project name, PR body, or brief text is part of this call. +**What is sent.** Per review turn, no-mistakes sends only the diff of reviewable files plus up to 40 candidate file paths - paths only, never their content - ranked by name rarity and directory proximity. No project name, PR body, or brief text is part of this call. **Data boundary.** This repository's captain-private and gitignored paths (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) are untracked, so they can never appear in a git diff and are never reachable by this or any other diff-based review path. The remaining operator responsibility is ordinary git hygiene: keep secrets out of tracked files, since no-mistakes has no Jev-specific secret redaction beyond the review step's existing findings pipeline. -An operator who wants tighter control over what becomes "reviewable" (and therefore diff-eligible for a Jev call) can extend this repo's own `ignore_patterns` for paths that should never enter any review, Jev-assisted or not. **Audit.** The review step log already records whether a pre-brief was requested, whether it was used, and the reason for any fallback (`no-mistakes axi logs --step review --full`); no separate Jev-specific audit log exists in this repo, since the call itself happens inside the no-mistakes daemon process, outside firstmate's own scripts. From 99ffd3ec92d48beb69e04984c228f1c000bcfbb0 Mon Sep 17 00:00:00 2001 From: irene Date: Sun, 20 Sep 2026 20:15:50 +0900 Subject: [PATCH 18/19] no-mistakes(document): Document cleanup, relaunch, and Jev contracts --- AGENTS.md | 2 +- README.md | 2 +- docs/architecture.md | 2 +- docs/cmux-backend.md | 1 + docs/configuration.md | 2 +- docs/secondmate-parent-channel.md | 12 ++++++------ docs/zellij-backend.md | 1 + 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcc8b7ac6bd..d3fca055ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ When that section reports its checks still in progress it names exactly what is 2. **Bootstrap** - detect-only checks (tool/version problems, the worktree-tangle check, harness override, dispatch-profile validation, backlog-backend status) always run, but routine confirmations stay silent by default. When the lock could not be acquired, the worktree-tangle check uses read-only advisory wording without a checkout repair command. Home-local stale Herdr projection cleanup and the six bootstrap MUTATING sweeps - same-home backlog reconciliation, fleet sync, secondmate convergence, secondmate liveness, pending remote handoff retry, and Relay artifact writes - run only when this session actually holds the lock from step 1; the four network ones among them run in the deferred stage rather than in this section. - The secondmate liveness sweep deterministically accounts for every registered secondmate: it relaunches only from the recovery-grade `dead` or `missing` states, preserves ambiguous, unreadable, or unreachable remote targets, and reports skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_state`; `docs/remote-secondmates.md`). + The secondmate liveness sweep deterministically accounts for every registered secondmate: it relaunches only from the recovery-grade `dead` or `missing` states after a dead local endpoint's cleanup is confirmed, preserves ambiguous, unreadable, or unreachable remote targets, and reports skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_state`; `docs/remote-secondmates.md`). 3. **Wake queue** - when locked, drains and presents the durable wake queue without running the inactive-outcome scan inline, and prints the raw records prominently as this turn's first work queue; a clearly labeled status-event annotation may follow a valid `signal` record and includes every status line still unread at the presentation cursor, but never replaces the raw record or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. Presented records remain durable until the handling turn runs the generation-bound acknowledgement printed by the drain. Every locked drain also prints a bounded fleet-wide `OPEN DECISIONS` section when durable decision records remain open, including when the queue itself is empty; reconcile those entries before continuing. diff --git a/README.md b/README.md index 89dc9a4fbaf..3fdf3c36b58 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Launching a supported harness inside it for your primary session instantiates yo - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. - **Optional Relay** - opt in with one local `.env` pairing token so firstmate can answer your public mentions on X and Discord alike, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-Relay behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live. - **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. -- **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. +- **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents after local endpoint cleanup is confirmed, and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). diff --git a/docs/architecture.md b/docs/architecture.md index 347264b5336..590c1d65855 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -452,7 +452,7 @@ The procedure and outcome vocabulary are owned by the [`/updatefirstmate` skill] Fleet state lives in each task's session-provider backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected), no-mistakes run records, status event logs, local markdown under `data/` including `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, and persistent secondmate homes. For herdr, respawning after a server-restored layout closes and replaces confirmed no-agent or dead task-tab husks instead of requiring manual tab cleanup. -At session start, confirmed-dead secondmate agent endpoints are closed and relaunched through the same secondmate spawn path, while ambiguous liveness reads are left untouched to avoid duplicate supervisors. +At session start, confirmed-dead secondmate agent endpoints are closed and relaunched through the same secondmate spawn path only after local endpoint cleanup is confirmed, while ambiguous liveness reads are left untouched to avoid duplicate supervisors. Use `/stow` before an intentional reset when the conversation may hold durable knowledge that has not yet been written to disk; after that, the next firstmate session can reconcile and carry on. ## Development notes diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 51618de5b5f..ad820a0ac3e 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -108,6 +108,7 @@ The sibling never carries an `fm-` title and is ignored by recovery. The exact window membership is re-read before this operation. A selected workspace that is not last closes normally; selection itself is not the trigger. Firstmate does not attempt to close the macOS window because cmux's socket cannot close a window holding a live terminal. +The shared cleanup path also requires a post-close proof that the recorded workspace and scoped task workspace are absent; an unreadable or ambiguous proof fails closed and retains the task identity. Real tests share the captain's running app rather than creating an isolated cmux session. `tests/cmux-test-safety.sh` permits cleanup only for an exact currently listed `fm-test-` workspace and never enumerates and closes unrelated workspaces or relaunches the app. diff --git a/docs/configuration.md b/docs/configuration.md index 27420e0b0f8..a37ad880d77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -520,7 +520,7 @@ Firstmate invokes the resolve path directly after writing the brief, without a p When on and at least one rule exists, the tool sends the project name and the whole brief as state and asks one Choice question whose options are every rule's `when` plus the fixed neutral option for no matching rule; the model never sees quota, catalogs, `why`, `use`, or approvals. An absent rules file, a default-only file, or `rules: []` returns the non-clear reason `no rules to match` without a model or quota request, leaving firstmate's existing routing in control; an existing but unreadable or malformed rules file, including a broken symlink, remains an actionable exit 2 configuration error. Everything after the answer runs in code: the confidence floor, the matched rule's `approval` and `floor`, each candidate's `provider` and `floor`, every applicable account-wide and model/product row from one `quota-axi --json` snapshot, and the numeric `spendPriority` argmax over candidates using each candidate's limiting row. -Known applicable rows from a provider with partial quota semantics remain rankable; rows whose own status is not known remain unrankable. +Known applicable rows from a provider with partial quota semantics, or from a provider whose top-level status is unknown but whose individual row status is known, remain rankable; rows whose own status is not known remain unrankable. Any applicable `exhausted_now` row or known zero bound makes that candidate ineligible, and a known profile-floor shortfall does the same before unrelated quota uncertainty is considered. Missing or nonnumeric `spendPriority` evidence is never ranked, and every candidate is printed beside its evidence or the reason it was not rankable, including on ambiguous and approval-gated outcomes that emit no profile. On the opted-in path, duplicate concrete profiles with the same harness, model, and effort inside one rule or the default array are configuration errors rather than ties. diff --git a/docs/secondmate-parent-channel.md b/docs/secondmate-parent-channel.md index a9e682c9945..b645d97c03b 100644 --- a/docs/secondmate-parent-channel.md +++ b/docs/secondmate-parent-channel.md @@ -18,17 +18,17 @@ The design goal is therefore: the parent channel must not depend on the model re ## The design The delivery rule has one sentence: the scripts report facts, the mate reports judgement. -Every captain-facing outcome that leaves durable evidence in the mate home is published on the channel by the script that records that evidence, at record time or on the next supervision poll, and the charter reserves the mate's own appends for judgement. +Every captain-facing outcome that leaves durable evidence in the mate home is published on the channel by the script that records that evidence, at record time or on the next supervision poll after any required endpoint cleanup is confirmed, and the charter reserves the mate's own appends for judgement. | Outcome | Durable evidence in the mate home | Published by | |---|---|---| -| Ship child PR ready | the child's `done: PR ...` line; `pr=` in the child's record once registered | `bin/fm-inactive-reconcile.sh` on the next poll with the child's line; `bin/fm-pr-check.sh` at registration with the canonical URL | -| Scout child findings | the child's `done:` line plus `data//report.md` | `bin/fm-inactive-reconcile.sh` on the next poll, with the report pointer | -| Child failed | the child's `failed:` line | `bin/fm-inactive-reconcile.sh` on the next poll | +| Ship child PR ready | the child's `done: PR ...` line; `pr=` in the child's record once registered | `bin/fm-inactive-reconcile.sh` after the next poll confirms endpoint cleanup; `bin/fm-pr-check.sh` at registration with the canonical URL | +| Scout child findings | the child's `done:` line plus `data//report.md` | `bin/fm-inactive-reconcile.sh` after the next poll confirms endpoint cleanup, with the report pointer | +| Child failed | the child's `failed:` line | `bin/fm-inactive-reconcile.sh` after the next poll confirms endpoint cleanup | | Child decision escalated to the captain | the task held for the captain in the mate backlog | `bin/fm-captain-hold.sh hold`, and its answer by `answer` | | PR merged | the merge poll or the mate's own merge | `bin/fm-merge-outcome-lib.sh` | | Child leaving the home | its final ledger line | `bin/fm-teardown.sh`, which refuses to remove the child while that line is undelivered | -| Child ended silently | terminal current state with a silent ledger | the existing inactive-outcome scan in `bin/fm-inactive-reconcile.sh` | +| Child ended silently | terminal current state with a silent ledger | the existing inactive-outcome scan in `bin/fm-inactive-reconcile.sh` after endpoint cleanup is confirmed | | Answer to a marked request | a correlated line guarded by the pending-reply record | `bin/fm-secondmate-report.sh`, which resolves the parent channel from the mate home; the pending-reply guard repairs a line stranded in the local mate's same-basename status file before recovery or escalation | | An outcome that exists only in the mate's reasoning | none | the charter and the `AGENTS.md` carve-outs only | @@ -49,7 +49,7 @@ A missed-reply escalation includes the complete first sighting path and line num ## Regression coverage -`tests/fm-inactive-reconcile.test.sh` covers the ledger delivery against real ledgers with no harness: immediate done and failed delivery with note, PR, mode, posture, and report pointer, once-only delivery across polls, a line still being appended, the remote route, the yield of the inactive path to a terminal ledger, and the real watcher poll driving it. +`tests/fm-inactive-reconcile.test.sh` covers the ledger delivery against real ledgers with no harness: immediate done and failed delivery with note, PR, mode, posture, and report pointer, once-only delivery across polls, a line still being appended, the remote route, endpoint reaping before terminal delivery, retained actionable state after failed cleanup, the yield of the inactive path to a terminal ledger, and the real watcher poll driving it. `tests/fm-captain-hold-lifecycle.test.sh` covers a mate home publishing a hold, its answer, and a distinct occurrence on re-hold, and a main home publishing nothing. `tests/fm-pr-merge.test.sh` covers the PR-ready line at registration and the merge outcome's upward report. `tests/fm-teardown.test.sh` covers teardown delivering a child's final line and refusing when the channel cannot be written. diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index fb58e52a621..326f8781f7c 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -89,6 +89,7 @@ A short viewport may expose fewer lines than requested. Closing a pane leaves an empty tab. Cleanup resolves and verifies the owning tab, then uses `close-tab-by-id` so both the task pane and tab disappear. +The shared cleanup path also requires a post-close proof that the recorded pane and scoped task tab are absent; an unreadable or ambiguous proof fails closed and retains the task identity. Real test cleanup uses only an isolated non-`firstmate` session and the guard in `tests/zellij-test-safety.sh`; it never calls all-session deletion commands. ## Active limits From 5af8f418679318259428d51936494b5088b3c23a Mon Sep 17 00:00:00 2001 From: irene Date: Mon, 21 Sep 2026 06:34:29 +0900 Subject: [PATCH 19/19] no-mistakes(review): Enabled Jev opt-in and bounded cleanup; focused tests pass --- .no-mistakes.yaml | 3 ++ bin/fm-inactive-reconcile.sh | 52 ++++++++++++++++++++++++------- docs/configuration.md | 5 ++- docs/secondmate-parent-channel.md | 2 +- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index 3f3aad29dd5..930dcba80b6 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -9,6 +9,9 @@ # HEAD-continuity guard; see docs/architecture.md "No-mistakes gate authority boundary." disable_project_settings: true +jev: + review_assist: true + # Trusted documentation placement policy for the Document step. # The audience inventory and coding guideline own the detail; keep this as a # pointer so gate instructions cannot become a second prose policy. diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 7157526d4ac..67f87fcd31a 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -400,8 +400,8 @@ claim_inactive_report_for_ledger() { # - local id=$1 meta=$2 status last previous state note pr mode yolo data incarnation fingerprint predecessor_head outcome_key line +report_child_ledger_locked() { + local id=$1 meta=$2 reap_timeout=${3:-} status last previous state note pr mode yolo data incarnation fingerprint predecessor_head outcome_key line reap_rc=0 status="$STATE/$id.status" last=$(child_terminal_ledger_line "$status") || return 0 state=$(status_line_verb "$last") @@ -410,7 +410,12 @@ report_child_ledger_locked() { # fingerprint=$(sha256_text "$incarnation|$id|$state|ledger|$last") outcome_key="child-outcome-$id-$state-${fingerprint:0:8}" ensure_record "$fingerprint" "$id" "$incarnation" "$state" "$outcome_key" direct upstream "$pr" || return 1 - if ! reap_terminal_child_locked "$id" "$meta"; then + if [ -n "$reap_timeout" ]; then + reap_terminal_child_bounded "$reap_timeout" "$id" "$meta" || reap_rc=$? + else + reap_terminal_child_locked "$id" "$meta" || reap_rc=$? + fi + if [ "$reap_rc" -ne 0 ]; then if [ -n "$RECORD_PENDING" ]; then notice_parent_report_failed "$RECORD_PENDING" "$fingerprint" \ "child terminal cleanup needs retry before parent report: child=$id state=$state" @@ -418,6 +423,7 @@ report_child_ledger_locked() { # publish_actionable "inactive-reconcile:$fingerprint" \ "child terminal cleanup needs retry before parent report: child=$id state=$state" || true fi + [ "$reap_rc" -eq 124 ] && return 124 return 1 fi [ -n "$RECORD_PENDING" ] || return 0 @@ -451,16 +457,14 @@ report_child_ledger_locked() { # return 1 } -# Every direct child's ledger, under its meta lock. Cheap file reads only, so -# it runs on every poll in a secondmate home; a delivery failure is already -# queued as a notice and never fails the scan. ledger_pass() { - local meta id lock + local deadline=$1 meta id lock remaining report_rc=0 for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue id=$(basename "$meta" .meta) valid_id "$id" || continue [ "$(meta_field "$meta" kind)" != secondmate ] || continue + [ "$(date +%s)" -lt "$deadline" ] || return 3 lock=$(fm_meta_lock_path "$meta") || continue fm_lock_try_acquire "$lock" || continue if [ ! -f "$meta" ] || [ -L "$meta" ] \ @@ -468,8 +472,16 @@ ledger_pass() { fm_lock_release "$lock" continue fi - report_child_ledger_locked "$id" "$meta" || true + remaining=$((deadline - $(date +%s))) + if [ "$remaining" -le 0 ]; then + fm_lock_release "$lock" + return 3 + fi + report_child_ledger_locked "$id" "$meta" "$remaining" || report_rc=$? fm_lock_release "$lock" + [ "$report_rc" -ne 124 ] || return 3 + report_rc=0 + [ "$(date +%s)" -lt "$deadline" ] || return 3 done } @@ -516,8 +528,14 @@ reap_terminal_child_locked() { # esac } +reap_terminal_child_bounded() { + local timeout=$1 id=$2 meta=$3 + [ "$timeout" -gt 0 ] || return 124 + fm_run_timed "$timeout" "$SCRIPT_DIR/fm-inactive-reconcile.sh" _reap-terminal-child "$id" "$meta" +} + reconcile_direct_child_locked() { # - local id=$1 meta=$2 self=${3:-} timeout=$4 status turn last age state_line state pr incarnation fingerprint outcome_key payload kind state_rc=0 + local id=$1 meta=$2 self=${3:-} timeout=$4 status turn last age state_line state pr incarnation fingerprint outcome_key payload kind state_rc=0 reap_rc=0 [ -f "$meta" ] && [ ! -L "$meta" ] || return 0 kind=$(meta_field "$meta" kind) [ "$kind" = secondmate ] && return 0 @@ -554,7 +572,8 @@ reconcile_direct_child_locked() { #