From 20a75a2377b49762f6eb89b2d518a91552227227 Mon Sep 17 00:00:00 2001 From: Bchue Date: Fri, 7 Aug 2026 00:01:52 -0400 Subject: [PATCH 1/4] feat(bin): publish real work outcomes into herdr as signal + durable metadata Adds bin/fm-herdr-outcome-publish.sh, a reusable decoration-only publisher that maps a firstmate outcome onto herdr's four existing WorkspaceSignalKind values and writes both a momentary report-signal and a durable report-metadata patch, targeting the workspace recorded in the task's own state/.meta. Herdr cannot derive outcomes itself (only firstmate talks to GitHub with identity and intent), and the two-call split matches data/herdr-event-channel-research/report.md section D's recommendation to keep the momentary and durable channels decoupled. Wires it into the two real outcome-learning moments in firstmate's own lifecycle: a PR merge (fm-pr-merge.sh, after gh-axi pr merge succeeds) and a landed teardown (fm-teardown.sh, before meta removal, skipped on --force since a forced discard is not landed work). Any unresolvable herdr target is a silent no-op; the publisher never blocks or fails the operation it is attached to. --- bin/fm-herdr-outcome-publish.sh | 111 +++++++++++ bin/fm-pr-merge.sh | 5 + bin/fm-teardown.sh | 9 + tests/fm-herdr-outcome-publish.test.sh | 252 +++++++++++++++++++++++++ tests/fm-pr-merge.test.sh | 41 ++++ tests/fm-teardown.test.sh | 73 +++++++ 6 files changed, 491 insertions(+) create mode 100755 bin/fm-herdr-outcome-publish.sh create mode 100755 tests/fm-herdr-outcome-publish.test.sh diff --git a/bin/fm-herdr-outcome-publish.sh b/bin/fm-herdr-outcome-publish.sh new file mode 100755 index 0000000000..74d3d4c93c --- /dev/null +++ b/bin/fm-herdr-outcome-publish.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Publish a real work outcome firstmate has just learned (a PR merged, a +# validation failed, a task landed) into herdr, so the fleet sidebar can show +# it. Herdr cannot derive outcomes itself - every event it sees on its own is +# mechanical (a pane appeared, a process exited); only firstmate talks to +# GitHub with identity and intent, so only firstmate knows a PR merged or CI +# went red. See data/herdr-event-channel-research/report.md sections C and D +# for the design this follows; nothing here re-derives that reasoning. +# +# Two herdr CLI calls, matching report.md section D's "two calls, not one +# fused method" recommendation so the momentary and durable channels keep +# their deliberately different retention rules decoupled: +# 1. `herdr workspace report-signal` - momentary, fire-and-forget, exactly +# the four existing WorkspaceSignalKind values (transfer/completed/ +# failed/idle). Never widen this vocabulary here - map the caller's own +# outcome onto one of the four; widening the herdr-side enum is a +# separate, out-of-scope design decision (report.md section C.1). +# 2. `herdr workspace report-metadata` - durable, per-workspace token +# ledger, written as outcome= and (when given) summary=. +# +# The workspace targeted is resolved ONLY from the task's own +# state/.meta (herdr_session=, herdr_workspace_id=, written by +# fm-spawn.sh when backend=herdr) - never a second identity scheme. +# +# This is a decoration, never a blocker: every unresolvable target (no task +# meta, a non-herdr task, no recorded herdr session/workspace, the herdr or +# jq tools missing, the CLI call itself failing) is a silent no-op that exits +# 0. A dropped report costs nothing - herdr's own report-signal and +# report-metadata already answer success on an unknown workspace or a stale +# sequence (report.md section C.1 live evidence). Callers may still append +# `|| true` for defense in depth, but this script never needs it to stay +# non-blocking on its own. +# +# A malformed call (wrong argument count, an outcome kind outside the four +# WorkspaceSignalKind values) is the one case treated as a caller bug: it +# prints a usage error and exits 2, so a broken call site is caught in +# testing rather than silently swallowed forever. +# +# Usage: fm-herdr-outcome-publish.sh [summary] +# one of: transfer completed failed idle +# short durable token value, e.g. pr_merged, landed, failed +# optional short human-readable context +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +# FM_HOME resolution, including the refusal on an ambiently inherited home, +# has one owner: bin/fm-home-anchor-lib.sh. +# shellcheck source=bin/fm-home-anchor-lib.sh +. "$SCRIPT_DIR/fm-home-anchor-lib.sh" +fm_home_anchor_resolve "$FM_ROOT" >/dev/null 2>&1 || exit 0 +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + +if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then + echo "usage: fm-herdr-outcome-publish.sh [summary]" >&2 + exit 2 +fi +ID=$1 +KIND=$2 +OUTCOME=$3 +SUMMARY=${4:-} + +case "$KIND" in + transfer|completed|failed|idle) ;; + *) + echo "usage: fm-herdr-outcome-publish.sh [summary]" >&2 + exit 2 + ;; +esac +if ! fm_task_id_path_safe "$ID" || [ -z "$OUTCOME" ]; then + echo "usage: fm-herdr-outcome-publish.sh [summary]" >&2 + exit 2 +fi + +# Everything below is target resolution and the CLI calls themselves: any +# failure here is a decoration dropped, never a caller-visible error. +META="$STATE/$ID.meta" +[ -f "$META" ] && [ ! -L "$META" ] || exit 0 + +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" + +[ "$(grep -c '^backend=' "$META" 2>/dev/null || true)" = 1 ] || exit 0 +BACKEND=$(fm_backend_meta_exact_value "$META" backend) || exit 0 +[ "$BACKEND" = herdr ] || exit 0 + +SESSION=$(fm_backend_meta_exact_value "$META" herdr_session) || exit 0 +WORKSPACE=$(fm_backend_meta_exact_value "$META" herdr_workspace_id) || exit 0 + +fm_backend_source herdr >/dev/null 2>&1 || exit 0 +fm_backend_herdr_tool_check >/dev/null 2>&1 || exit 0 + +# transfer lands on the receiver (--to); completed/failed/idle leave from the +# reporter (--from) - report.md section C.1. +DIRECTION_FLAG=--from +[ "$KIND" != transfer ] || DIRECTION_FLAG=--to + +fm_backend_herdr_cli "$SESSION" workspace report-signal \ + --source firstmate --kind "$KIND" "$DIRECTION_FLAG" "$WORKSPACE" \ + >/dev/null 2>&1 || true + +TOKENS=(--token "outcome=$OUTCOME") +[ -z "$SUMMARY" ] || TOKENS+=(--token "summary=$SUMMARY") +fm_backend_herdr_cli "$SESSION" workspace report-metadata \ + --source firstmate "${TOKENS[@]}" "$WORKSPACE" \ + >/dev/null 2>&1 || true + +exit 0 diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 493055bcbd..3fa9ce8a57 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -86,3 +86,8 @@ if ! caller_has_merge_method "$@"; then fi gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@" + +# Decoration only, never a blocker: publishes the merge into herdr as a +# signal and durable metadata when the task's own meta names a herdr +# workspace target. See bin/fm-herdr-outcome-publish.sh's header. +"$SCRIPT_DIR/fm-herdr-outcome-publish.sh" "$ID" completed pr_merged "PR #$PR_NUMBER merged" || true diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index b8929ceb44..b2981efe46 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1236,6 +1236,15 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 +if [ "$FORCE" != "--force" ]; then + # Decoration only, never a blocker: publishes the landed teardown into + # herdr as a signal and durable metadata when the task's own meta names a + # herdr workspace target. Read before the meta removal below, since that + # target lives only in this task's own state/.meta. See + # bin/fm-herdr-outcome-publish.sh's header. --force means an explicitly + # authorized discard, not landed work, so it publishes nothing. + "$SCRIPT_DIR/fm-herdr-outcome-publish.sh" "$ID" completed landed "task $ID landed" || true +fi rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" diff --git a/tests/fm-herdr-outcome-publish.test.sh b/tests/fm-herdr-outcome-publish.test.sh new file mode 100755 index 0000000000..6a976cdc2b --- /dev/null +++ b/tests/fm-herdr-outcome-publish.test.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +# Tests for bin/fm-herdr-outcome-publish.sh: the reusable publisher that +# turns a real work outcome firstmate has just learned into a herdr signal +# (report-signal) plus a durable metadata patch (report-metadata), targeting +# the workspace recorded in the task's own state/.meta. +# +# Fake-herdr-CLI unit tests (mirrors tests/fm-backend-herdr.test.sh's +# fakebin/command-log convention): a `herdr` stub that logs every invocation +# and exits with a configurable code, so assertions are on what got called, +# never on the script's implementation bytes. +# +# Matrix: +# (a) herdr task -> both report-signal and report-metadata called with the +# resolved session/workspace, the mapped kind, and outcome+summary tokens +# (b) no summary given -> report-metadata carries only the outcome token +# (c) transfer kind addresses the workspace with --to, not --from +# (d) non-herdr task (no backend= line) -> no herdr call, exit 0 +# (e) herdr task with backend= present but no herdr_session/workspace_id -> no call, exit 0 +# (f) missing task meta -> no call, exit 0 +# (g) invalid signal kind -> usage error, exit 2, no herdr call +# (h) wrong argument count -> usage error, exit 2 +# (i) the herdr CLI itself failing never fails the publisher (exit 0) +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } + +PUBLISH="$ROOT/bin/fm-herdr-outcome-publish.sh" +TMP_ROOT=$(fm_test_tmproot fm-herdr-outcome-publish-tests) + +# Build a fresh sandbox: a state dir and a fakebin with a herdr stub that logs +# every invocation ("HERDR_SESSION= ARGS=", one line per call) to +# $case_dir/herdr.log and exits 0 unless $case_dir/herdr-exit overrides it. +# Echoes the case dir. +make_case() { + local name=$1 case_dir fakebin + case_dir="$TMP_ROOT/$name" + fakebin="$case_dir/fakebin" + mkdir -p "$case_dir/state" "$fakebin" + cat > "$fakebin/herdr" <<'SH' +#!/usr/bin/env bash +printf 'HERDR_SESSION=%s ARGS=%s\n' "${HERDR_SESSION:-}" "$*" >> "${FM_TEST_HERDR_LOG:?}" +exit "$(cat "${FM_TEST_HERDR_EXIT_FILE:?}" 2>/dev/null || echo 0)" +SH + chmod +x "$fakebin/herdr" + : > "$case_dir/herdr.log" + printf '0\n' > "$case_dir/herdr-exit" + printf '%s\n' "$case_dir" +} + +write_herdr_meta() { + local case_dir=$1 + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fmtest:w1:p2" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" \ + "backend=herdr" \ + "herdr_session=fmtest" \ + "herdr_workspace_id=w1" \ + "herdr_tab_id=w1:t2" \ + "herdr_pane_id=w1:p2" +} + +run_publish() { + local case_dir=$1; shift + FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + FM_TEST_HERDR_LOG="$case_dir/herdr.log" \ + FM_TEST_HERDR_EXIT_FILE="$case_dir/herdr-exit" \ + PATH="$case_dir/fakebin:$PATH" \ + "$PUBLISH" "$@" +} + +test_herdr_task_publishes_signal_and_metadata() { + local case_dir rc + case_dir=$(make_case herdr-task) + write_herdr_meta "$case_dir" + + set +e + run_publish "$case_dir" task-x1 completed pr_merged "PR #9 merged" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "herdr-task: publish should succeed" + assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-signal --source firstmate --kind completed --from w1 --session fmtest' \ + "$case_dir/herdr.log" "herdr-task: report-signal was not called as expected" + assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-metadata --source firstmate --token outcome=pr_merged --token summary=PR #9 merged w1 --session fmtest' \ + "$case_dir/herdr.log" "herdr-task: report-metadata was not called as expected" + pass "fm-herdr-outcome-publish publishes both a signal and durable metadata for a herdr task" +} + +test_no_summary_omits_summary_token() { + local case_dir rc + case_dir=$(make_case no-summary) + write_herdr_meta "$case_dir" + + set +e + run_publish "$case_dir" task-x1 failed validation_failed \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "no-summary: publish should succeed" + assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-metadata --source firstmate --token outcome=validation_failed w1 --session fmtest' \ + "$case_dir/herdr.log" "no-summary: report-metadata should carry only the outcome token" + assert_no_grep 'summary=' "$case_dir/herdr.log" \ + "no-summary: report-metadata should not carry a summary token when none was given" + pass "fm-herdr-outcome-publish omits the summary token when no summary is given" +} + +test_transfer_kind_addresses_receiver() { + local case_dir rc + case_dir=$(make_case transfer-kind) + write_herdr_meta "$case_dir" + + set +e + run_publish "$case_dir" task-x1 transfer handoff "handed off" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "transfer-kind: publish should succeed" + assert_grep 'ARGS=workspace report-signal --source firstmate --kind transfer --to w1 --session fmtest' \ + "$case_dir/herdr.log" "transfer-kind: report-signal should address the workspace with --to" + assert_no_grep '--from w1' "$case_dir/herdr.log" \ + "transfer-kind: report-signal should not use --from for a transfer" + pass "fm-herdr-outcome-publish addresses a transfer signal with --to, not --from" +} + +test_non_herdr_task_publishes_nothing() { + local case_dir rc + case_dir=$(make_case non-herdr) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=firstmate:fm-task-x1" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" + + set +e + run_publish "$case_dir" task-x1 completed pr_merged "PR merged" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "non-herdr: publish should succeed quietly" + [ ! -s "$case_dir/herdr.log" ] || fail "non-herdr: herdr was called for a non-herdr task" + pass "fm-herdr-outcome-publish is a silent no-op for a non-herdr task" +} + +test_herdr_task_missing_session_fields_publishes_nothing() { + local case_dir rc + case_dir=$(make_case missing-fields) + fm_write_meta "$case_dir/state/task-x1.meta" \ + "window=fmtest:w1:p2" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=no-mistakes" \ + "backend=herdr" + + set +e + run_publish "$case_dir" task-x1 completed pr_merged "PR merged" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "missing-fields: publish should succeed quietly" + [ ! -s "$case_dir/herdr.log" ] || fail "missing-fields: herdr was called with no resolvable session/workspace" + pass "fm-herdr-outcome-publish is a silent no-op when herdr session/workspace fields are absent" +} + +test_missing_meta_publishes_nothing() { + local case_dir rc + case_dir=$(make_case missing-meta) + + set +e + run_publish "$case_dir" task-x1 completed pr_merged "PR merged" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "missing-meta: publish should succeed quietly" + [ ! -s "$case_dir/herdr.log" ] || fail "missing-meta: herdr was called for a task with no meta" + pass "fm-herdr-outcome-publish is a silent no-op when the task has no meta" +} + +test_invalid_kind_is_a_usage_error() { + local case_dir rc + case_dir=$(make_case invalid-kind) + write_herdr_meta "$case_dir" + + set +e + run_publish "$case_dir" task-x1 bogus pr_merged "PR merged" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 2 "$rc" "invalid-kind: an unknown signal kind should be refused" + assert_grep 'usage:' "$case_dir/stderr" "invalid-kind: refusal did not explain usage" + [ ! -s "$case_dir/herdr.log" ] || fail "invalid-kind: herdr was called despite an invalid kind" + pass "fm-herdr-outcome-publish refuses a signal kind outside the four WorkspaceSignalKind values" +} + +test_wrong_arg_count_is_a_usage_error() { + local case_dir rc + case_dir=$(make_case wrong-arg-count) + write_herdr_meta "$case_dir" + + set +e + run_publish "$case_dir" task-x1 completed \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 2 "$rc" "wrong-arg-count: a missing outcome argument should be refused" + assert_grep 'usage:' "$case_dir/stderr" "wrong-arg-count: refusal did not explain usage" + [ ! -s "$case_dir/herdr.log" ] || fail "wrong-arg-count: herdr was called despite a missing argument" + pass "fm-herdr-outcome-publish refuses a call with too few arguments" +} + +test_herdr_cli_failure_never_fails_the_publisher() { + local case_dir rc + case_dir=$(make_case cli-failure) + write_herdr_meta "$case_dir" + printf '1\n' > "$case_dir/herdr-exit" + + set +e + run_publish "$case_dir" task-x1 completed pr_merged "PR merged" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "cli-failure: a failing herdr CLI call should never fail the publisher" + assert_grep 'report-signal' "$case_dir/herdr.log" "cli-failure: report-signal was not attempted" + pass "fm-herdr-outcome-publish never fails when the underlying herdr CLI call fails" +} + +test_herdr_task_publishes_signal_and_metadata +test_no_summary_omits_summary_token +test_transfer_kind_addresses_receiver +test_non_herdr_task_publishes_nothing +test_herdr_task_missing_session_fields_publishes_nothing +test_missing_meta_publishes_nothing +test_invalid_kind_is_a_usage_error +test_wrong_arg_count_is_a_usage_error +test_herdr_cli_failure_never_fails_the_publisher diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index a064b6919b..c0bb211fe3 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -65,6 +65,23 @@ SH chmod +x "$case_dir/fakebin/gh-axi" "$case_dir/fakebin/gh" } +# Append herdr backend fields to a case's task meta and add a herdr fakebin +# stub that logs every invocation to $case_dir/herdr.log. Args: case_dir +add_herdr_target() { + local case_dir=$1 + printf '%s\n' \ + "backend=herdr" \ + "herdr_session=fmtest" \ + "herdr_workspace_id=w1" >> "$case_dir/state/task-x1.meta" + cat > "$case_dir/fakebin/herdr" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_TEST_HERDR_LOG:?}" +exit 0 +SH + chmod +x "$case_dir/fakebin/herdr" + : > "$case_dir/herdr.log" +} + # gh-axi mock that fails the merge call but succeeds everything else, so a # real merge failure is distinguishable from the recording step. add_gh_mocks_merge_fails() { @@ -89,6 +106,7 @@ run_pr_merge() { FM_ROOT_OVERRIDE="$ROOT" \ FM_STATE_OVERRIDE="$case_dir/state" \ FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \ + FM_TEST_HERDR_LOG="$case_dir/herdr.log" \ PATH="$case_dir/fakebin:$PATH" \ "$PR_MERGE" "$@" rc=$? @@ -122,6 +140,28 @@ test_records_pr_and_head_before_merging() { pass "fm-pr-merge records pr= and pr_head= before invoking gh-axi pr merge" } +test_publishes_herdr_outcome_on_merge() { + local case_dir rc + case_dir=$(make_case herdr-outcome) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" 1111111111111111111111111111111111111111 + add_herdr_target "$case_dir" + : > "$case_dir/gh-axi.log" + + set +e + run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/42 \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "herdr-outcome: fm-pr-merge should succeed" + assert_grep 'workspace report-signal --source firstmate --kind completed --from w1 --session fmtest' \ + "$case_dir/herdr.log" "herdr-outcome: the merge did not publish a herdr signal" + assert_grep 'workspace report-metadata --source firstmate --token outcome=pr_merged --token summary=PR #42 merged w1 --session fmtest' \ + "$case_dir/herdr.log" "herdr-outcome: the merge did not publish durable herdr metadata" + pass "fm-pr-merge publishes the merge outcome into herdr for a herdr-backed task" +} + test_merge_failure_propagates_after_recording() { local case_dir rc case_dir=$(make_case merge-fails) @@ -302,6 +342,7 @@ test_parses_pr_url_for_gh_axi() { } test_records_pr_and_head_before_merging +test_publishes_herdr_outcome_on_merge test_merge_failure_propagates_after_recording test_extra_merge_args_forwarded test_missing_meta_refuses_before_merge diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a57a08f6b3..2dc5dba07f 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -515,6 +515,77 @@ test_local_only_fork_remote_allows() { pass "local-only worktree with HEAD on a fork remote is torn down (fix holds)" } +test_landed_teardown_publishes_herdr_outcome() { + local case_dir rc + case_dir=$(make_case herdr-landed-outcome) + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "fix the thing" + add_fork_with_pushed_branch "$case_dir" + sed -i.bak 's/^window=.*/window=fmtest:w1:p2/' "$case_dir/state/task-x1.meta" + rm -f "$case_dir/state/task-x1.meta.bak" + printf '%s\n' \ + 'backend=herdr' \ + 'herdr_session=fmtest' \ + 'herdr_workspace_id=w1' \ + 'herdr_tab_id=w1:t2' \ + 'herdr_pane_id=w1:p2' >> "$case_dir/state/task-x1.meta" + cat > "$case_dir/fakebin/herdr" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_TEST_HERDR_LOG:?}" +exit 0 +SH + chmod +x "$case_dir/fakebin/herdr" + : > "$case_dir/herdr.log" + + set +e + FM_TEST_HERDR_LOG="$case_dir/herdr.log" run_teardown "$case_dir" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "herdr-landed-outcome: teardown should succeed" + assert_grep 'workspace report-signal --source firstmate --kind completed --from w1 --session fmtest' \ + "$case_dir/herdr.log" "herdr-landed-outcome: landed teardown did not publish a herdr signal" + assert_grep 'workspace report-metadata --source firstmate --token outcome=landed --token summary=task task-x1 landed w1 --session fmtest' \ + "$case_dir/herdr.log" "herdr-landed-outcome: landed teardown did not publish durable herdr metadata" + pass "fm-teardown publishes the landed outcome into herdr for a herdr-backed task" +} + +test_forced_teardown_publishes_no_herdr_outcome() { + local case_dir rc + case_dir=$(make_case herdr-forced-no-outcome) + write_meta "$case_dir" local-only ship + wt_commit "$case_dir" "unpushed work" + sed -i.bak 's/^window=.*/window=fmtest:w1:p2/' "$case_dir/state/task-x1.meta" + rm -f "$case_dir/state/task-x1.meta.bak" + printf '%s\n' \ + 'backend=herdr' \ + 'herdr_session=fmtest' \ + 'herdr_workspace_id=w1' \ + 'herdr_tab_id=w1:t2' \ + 'herdr_pane_id=w1:p2' >> "$case_dir/state/task-x1.meta" + cat > "$case_dir/fakebin/herdr" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_TEST_HERDR_LOG:?}" +exit 0 +SH + chmod +x "$case_dir/fakebin/herdr" + : > "$case_dir/herdr.log" + + set +e + FM_TEST_HERDR_LOG="$case_dir/herdr.log" run_teardown "$case_dir" --force \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "herdr-forced-no-outcome: forced teardown should succeed" + assert_no_grep 'report-signal' "$case_dir/herdr.log" \ + "herdr-forced-no-outcome: a forced discard must not publish a landed outcome" + assert_no_grep 'report-metadata' "$case_dir/herdr.log" \ + "herdr-forced-no-outcome: a forced discard must not publish a landed outcome" + pass "fm-teardown publishes no herdr outcome for a forced (discarded) teardown" +} + test_teardown_prompts_tasks_axi_done_when_compatible() { local case_dir out case_dir=$(make_case tasks-axi-reminder) @@ -1378,6 +1449,8 @@ test_herdr_projection_teardown_retains_journal_when_close_unconfirmed() { } test_local_only_fork_remote_allows +test_landed_teardown_publishes_herdr_outcome +test_forced_teardown_publishes_no_herdr_outcome test_teardown_prompts_tasks_axi_done_when_compatible test_teardown_manual_backend_prompts_hand_edit_even_when_tasks_axi_present test_local_only_truly_unpushed_refuses From dd3041355d4b620fdf3d141edf941ed4dad82dfd Mon Sep 17 00:00:00 2001 From: Bchue Date: Fri, 7 Aug 2026 10:19:18 -0400 Subject: [PATCH 2/4] fix(bin): put report-metadata's workspace-id positional before its options The installed herdr CLI (0.8.0) rejects the WORKSPACE_ID positional when it trails --source/--token options ("unknown option: ") - verified empirically against the real binary with both orderings. report-signal has no positional argument (--from/--to are options) and was unaffected. --- bin/fm-herdr-outcome-publish.sh | 5 ++++- tests/fm-herdr-outcome-publish.test.sh | 4 ++-- tests/fm-pr-merge.test.sh | 2 +- tests/fm-teardown.test.sh | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bin/fm-herdr-outcome-publish.sh b/bin/fm-herdr-outcome-publish.sh index 74d3d4c93c..f40509f595 100755 --- a/bin/fm-herdr-outcome-publish.sh +++ b/bin/fm-herdr-outcome-publish.sh @@ -104,8 +104,11 @@ fm_backend_herdr_cli "$SESSION" workspace report-signal \ TOKENS=(--token "outcome=$OUTCOME") [ -z "$SUMMARY" ] || TOKENS+=(--token "summary=$SUMMARY") +# The WORKSPACE_ID positional must come first: the installed herdr CLI +# (0.8.0) does not accept it after --source/--token options - verified +# empirically (`unknown option: firstmate` when the positional trails). fm_backend_herdr_cli "$SESSION" workspace report-metadata \ - --source firstmate "${TOKENS[@]}" "$WORKSPACE" \ + "$WORKSPACE" --source firstmate "${TOKENS[@]}" \ >/dev/null 2>&1 || true exit 0 diff --git a/tests/fm-herdr-outcome-publish.test.sh b/tests/fm-herdr-outcome-publish.test.sh index 6a976cdc2b..032dff8d1a 100755 --- a/tests/fm-herdr-outcome-publish.test.sh +++ b/tests/fm-herdr-outcome-publish.test.sh @@ -89,7 +89,7 @@ test_herdr_task_publishes_signal_and_metadata() { expect_code 0 "$rc" "herdr-task: publish should succeed" assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-signal --source firstmate --kind completed --from w1 --session fmtest' \ "$case_dir/herdr.log" "herdr-task: report-signal was not called as expected" - assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-metadata --source firstmate --token outcome=pr_merged --token summary=PR #9 merged w1 --session fmtest' \ + assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-metadata w1 --source firstmate --token outcome=pr_merged --token summary=PR #9 merged --session fmtest' \ "$case_dir/herdr.log" "herdr-task: report-metadata was not called as expected" pass "fm-herdr-outcome-publish publishes both a signal and durable metadata for a herdr task" } @@ -106,7 +106,7 @@ test_no_summary_omits_summary_token() { set -e expect_code 0 "$rc" "no-summary: publish should succeed" - assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-metadata --source firstmate --token outcome=validation_failed w1 --session fmtest' \ + assert_grep 'HERDR_SESSION=fmtest ARGS=workspace report-metadata w1 --source firstmate --token outcome=validation_failed --session fmtest' \ "$case_dir/herdr.log" "no-summary: report-metadata should carry only the outcome token" assert_no_grep 'summary=' "$case_dir/herdr.log" \ "no-summary: report-metadata should not carry a summary token when none was given" diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index c0bb211fe3..68a6fa3a44 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -157,7 +157,7 @@ test_publishes_herdr_outcome_on_merge() { expect_code 0 "$rc" "herdr-outcome: fm-pr-merge should succeed" assert_grep 'workspace report-signal --source firstmate --kind completed --from w1 --session fmtest' \ "$case_dir/herdr.log" "herdr-outcome: the merge did not publish a herdr signal" - assert_grep 'workspace report-metadata --source firstmate --token outcome=pr_merged --token summary=PR #42 merged w1 --session fmtest' \ + assert_grep 'workspace report-metadata w1 --source firstmate --token outcome=pr_merged --token summary=PR #42 merged --session fmtest' \ "$case_dir/herdr.log" "herdr-outcome: the merge did not publish durable herdr metadata" pass "fm-pr-merge publishes the merge outcome into herdr for a herdr-backed task" } diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 2dc5dba07f..f1f90e7f63 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -546,7 +546,7 @@ SH expect_code 0 "$rc" "herdr-landed-outcome: teardown should succeed" assert_grep 'workspace report-signal --source firstmate --kind completed --from w1 --session fmtest' \ "$case_dir/herdr.log" "herdr-landed-outcome: landed teardown did not publish a herdr signal" - assert_grep 'workspace report-metadata --source firstmate --token outcome=landed --token summary=task task-x1 landed w1 --session fmtest' \ + assert_grep 'workspace report-metadata w1 --source firstmate --token outcome=landed --token summary=task task-x1 landed --session fmtest' \ "$case_dir/herdr.log" "herdr-landed-outcome: landed teardown did not publish durable herdr metadata" pass "fm-teardown publishes the landed outcome into herdr for a herdr-backed task" } From f6fc675222fdb464f5dc353892d83cae3a3b748d Mon Sep 17 00:00:00 2001 From: Bchue Date: Fri, 7 Aug 2026 14:56:29 -0400 Subject: [PATCH 3/4] no-mistakes(review): Clarify report.md citation points to private gitignored data/ --- bin/fm-herdr-outcome-publish.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bin/fm-herdr-outcome-publish.sh b/bin/fm-herdr-outcome-publish.sh index f40509f595..8e0b5163b2 100755 --- a/bin/fm-herdr-outcome-publish.sh +++ b/bin/fm-herdr-outcome-publish.sh @@ -5,7 +5,11 @@ # mechanical (a pane appeared, a process exited); only firstmate talks to # GitHub with identity and intent, so only firstmate knows a PR merged or CI # went red. See data/herdr-event-channel-research/report.md sections C and D -# for the design this follows; nothing here re-derives that reasoning. +# for the design this follows; nothing here re-derives that reasoning. That +# path is this captain's own private fleet record under data/ (gitignored +# per AGENTS.md section 2, never committed to this repo's tracked tree), so +# it will not appear in `git log` or a checkout of this repo - the citations +# below summarize its conclusions rather than pointing at trackable history. # # Two herdr CLI calls, matching report.md section D's "two calls, not one # fused method" recommendation so the momentary and durable channels keep From d869a5af0bc922765c1eb4cfa370f0ffdde245f2 Mon Sep 17 00:00:00 2001 From: Bchue Date: Fri, 7 Aug 2026 15:00:38 -0400 Subject: [PATCH 4/4] no-mistakes(document): Add fm-herdr-outcome-publish.sh to bin/ inventory in docs/scripts.md --- docs/scripts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/scripts.md b/docs/scripts.md index af4b3099f4..0cafd650a5 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -86,6 +86,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | +| `fm-herdr-outcome-publish.sh` | Decoration-only: publish a real learned work outcome (PR merged, task landed) into herdr as a signal plus durable metadata, when the task's own meta names a herdr workspace target | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | | `fm-lock.sh` | Per-home firstmate session lock | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers |