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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/captain-hold-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The agent performs the semantic inventory because scripts must not infer captain
Every unresolved question that belongs to the captain and is discovered while producing, reading, presenting, or ending an investigation or visual review must be carried by a captain-held task in the authoritative backlog of the home that owns the originating work before that work or review may be treated as complete.
For a Lavish board-backed handoff, pass the reply through `bin/fm-procevent-lavish.sh arm --agent-reply-file` before appending the status; the adapter owns version-specific acceptance ordering.
Prefer holding the work item the question gates over minting a new row; create a new task only when no work item exists to hold.
The originating investigation or review is never its own inventory entry, so hold a separate task for the call and pass `--origin <origin-id>` so `complete` can check it.
Put the question and its options in the hold reason, and keep one held task per genuine gate: a multi-question review is one held task pointing at its report, not a row per question. Represent that task with exactly one board card that consolidates its questions and options; never fan one task id into duplicate same-key cards.
Register or re-hold through `bin/fm-captain-hold.sh hold`, which is idempotent per task id.
After inventorying the whole report and review surface, run `bin/fm-captain-hold.sh complete` with every captain-held task id, or with `--none` only when the reviewed surface leaves nothing waiting on the captain.
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/harness-adapters/references/harness/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ The router's Detection section owns how launch markers and ancestry select betwe
Keep the instructions as one positional argument.
Multiple positional arguments become separate queued messages; the spawn template already preserves the one-argument shape.

A project trust dialog can appear on the first Pi run in any not-yet-trusted directory, including a clean worktree.
A project trust dialog can appear on the first Pi run in any not-yet-trusted directory that holds a trust-requiring resource such as `.pi/extensions/`, including a clean worktree and a freshly seeded secondmate home.
Accept it with Enter and verify the instructions begin processing.
The decision persists per path in `~/.pi/agent/trust.json`, or in the pinned root's `trust.json` under a worker account pin, so later spawns in the same pooled slot under that root skip it.
For unattended seeded-secondmate launches, `../../../bin/fm-spawn.sh --help` owns the capability-gated project-trust approval mechanics; [runtime verification](../../../../../docs/verification/runtime-backends.md#pi-seeded-secondmate-project-trust) owns the regression evidence.

## Worker turn-end extension

Expand Down
7 changes: 6 additions & 1 deletion bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3363,6 +3363,11 @@ fm_backend_herdr_proof_lines() { # <text>
# viewport is the one bound that always contains the composer.
# Styled capture is preferred. An empty or failed styled read falls through to
# the plain capture so a missing ANSI format does not look like an empty draft.
# This read serves only the Claude payload proof, so the grok-tuned
# dark-truecolor ghost strip is off (FM_COMPOSER_GHOST_LUMA_MAX=0): Claude
# 2.1.283 draws a typed slash command in muted grey 38;2;112;112;112 (verified
# live), which that strip dropped, judging a typed /exit unsent. Claude's own
# ghost suggestion is SGR-2 dim and is still stripped.
fm_backend_herdr_composer_content() { # <target>
local target=$1 cap caps
if cap=$(fm_backend_herdr_visible_capture_ansi "$target" 2>/dev/null) && [ -n "$cap" ]; then
Expand All @@ -3372,7 +3377,7 @@ fm_backend_herdr_composer_content() { # <target>
else
return 1
fi
fm_composer_extract_selected_content "$caps" "$cap"
FM_COMPOSER_GHOST_LUMA_MAX=0 fm_composer_extract_selected_content "$caps" "$cap"
}

# fm_backend_herdr_composer_payload_shown: 0 when <after>, read from a
Expand Down
5 changes: 4 additions & 1 deletion bin/fm-afk-return.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ RETURN_GRACE=${FM_GUARD_GRACE:-300}
# shellcheck source=bin/fm-afk-contract.sh
. "$SCRIPT_DIR/fm-afk-contract.sh"
CONTRACT="$SCRIPT_DIR/fm-afk-contract.sh"
# Functions only: decodes the stored hold reasons the catch-up listing shows.
# shellcheck source=bin/fm-hold-reason-lib.sh
. "$SCRIPT_DIR/fm-hold-reason-lib.sh"

usage() {
sed -n '2,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
Expand Down Expand Up @@ -591,7 +594,7 @@ render_return_brief() { # <evidence-file> <blockers-file> <since-epoch> <drain-
elif [ -n "$rows" ]; then
count=$((count + 1))
printf ' held in the backlog:\n'
printf '%s\n' "$rows" | sed 's/^/ /'
printf '%s\n' "$rows" | fm_hold_reason_decode_stream | sed 's/^/ /'
fi
else
held_err=$(printf '%s' "$held" | head -1 | clean_field)
Expand Down
146 changes: 128 additions & 18 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
# a UTC `Captain hold set:` timestamp in the task body: repeating an active
# hold preserves the existing timestamp, while re-holding released work starts
# a new lifecycle. A task already closed is refused rather than reopened.
# `--origin` also records the origin on a `Captain hold origin:` body line, which
# `complete` and `verify` check. The reason may hold parentheses and line breaks:
# tasks-axi refuses them, so `hold` escapes them where it writes the reason and
# readers decode them (bin/fm-hold-reason-lib.sh owns the encoding).
# `--until` records the captain's own deferral date through `tasks-axi hold
# --until`, so a "revisit later" answer is stored as a date instead of a live
# card.
Expand Down Expand Up @@ -134,15 +138,19 @@
# `--none` is an explicit semantic attestation that the just-reviewed surface
# has no unresolved captain call, and is refused while the origin still has an
# open keyed status decision. With a non-empty inventory, every listed task is
# verified durable (actively captain-held, or closed with a recorded answer),
# verified durable (captain-held, or carrying a recorded resolution),
# is never the origin itself, and, when `hold --origin` recorded one, was held
# for this origin; a hold with no recorded origin is accepted on durability
# alone and named in the output,
# the inventory is unioned idempotently into the metadata, and every still-open
# keyed status decision is transferred to its durable owner with a
# `captain-held [key=...]` status close naming the inventory. Later review
# passes may add ids. A post-teardown visual review can complete against the
# surviving report and tasks without recreating task state.
# `verify` is read-only and is called by scout teardown, so teardown cannot
# erase a source before this gate has succeeded: every recorded inventory
# entry must still be durable and no keyed status decision may be open.
# entry must still satisfy the same durability and origin checks as `complete`,
# and no keyed status decision may be open.
# Metadata compatibility: the attestation keeps the historical
# `decisions_reviewed=1` and `decision_keys=` keys, and an inventory entry that
# names no existing task resolves through the legacy `<origin>-decision-<entry>`
Expand Down Expand Up @@ -223,6 +231,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
# shellcheck source=bin/fm-wake-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-hold-reason-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-hold-reason-lib.sh"
# shellcheck source=bin/fm-parent-channel-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-parent-channel-lib.sh"
Expand Down Expand Up @@ -792,27 +803,106 @@ write_hold_set_stamp() { # <task-id> <shown-body> <timestamp> <preserve-existin
rm -f -- "$tmp"
}

# The origin a hold was recorded for lives in the held task's own body, on a
# line of its own, so `complete` can tell a call held for this origin from one
# held for another. Omitting --origin leaves any existing association intact.
body_hold_origin() { # <decoded-task-body>
printf '%s\n' "$1" | sed -n 's/^Captain hold origin: \(.*\)$/\1/p' | head -1
}

task_identity() {
local id=$1
if task_show "$id"; then
id=$(show_field_value "$TASK_SHOW_OUTPUT" id)
validate_slug backend-task-id "$id"
elif ! printf '%s\n' "$TASK_SHOW_OUTPUT" | grep -q '^code: NOT_FOUND$'; then
fail "could not resolve the backend identity of $id"
fi
printf '%s' "$id"
}

write_hold_origin() { # <task-id> <shown-body> <origin-or-empty>
local id=$1 body=$2 origin=$3 stamp rest new_body tmp
body=$(decode_shown_value "$body") \
|| fail "could not decode the existing body for $id"
stamp=$(printf '%s\n' "$body" | sed -n 1p)
[ -n "$(body_hold_set_timestamp "$body")" ] \
|| fail "task $id lost its hold-set stamp before its origin was recorded"
rest=$(printf '%s\n' "$body" | sed 1d | awk '!/^Captain hold origin: /' \
| awk 'NF || started { started = 1; print }')
new_body=$stamp
if [ -n "$origin" ]; then
new_body=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin")
fi
if [ -n "$rest" ]; then
new_body=$(printf '%s\n\n%s' "$new_body" "$rest")
fi
tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-origin.XXXXXX") \
|| fail "cannot stage the hold origin"
if ! printf '%s\n' "$new_body" > "$tmp"; then
rm -f -- "$tmp"
fail "cannot stage the hold origin for $id"
fi
if ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then
rm -f -- "$tmp"
fail "could not record the hold origin on $id"
fi
rm -f -- "$tmp"
}

refuse_self_inventory() {
local origin=$1 entry=$2 meta="$STATE/$1.meta"
if list_has_key "$(meta_value "$meta" decision_keys)" "$entry"; then
fail "origin $origin cannot be its own captain-call inventory entry; historical decision_keys in $meta still contains $entry; hold a separate captain task with --origin $origin, replace only $entry in the final decision_keys= line with that task id while preserving all other entries, then re-run complete $origin <task-id>"
fi
fail "origin $origin cannot be its own captain-call inventory entry; hold a separate captain task for the call and list that task"
}

# Resolve one entry and verify the row it names is durably captain-held. A
# resolution failure that is not the read bound keeps resolve_entry's own
# status - its stderr already named the entry; 124 means the backend never
# answered, which is not the same as an unknown entry and must not be spent
# as absence. On success prints "<id> <how>" so the caller can keep the
# attestation evidence.
verify_entry_durable() { # <origin-or-empty> <entry>; prints "<id> <how>"
local origin=$1 entry=$2 resolved resolve_status=0
# as absence. The result carries the attestation evidence and whether an
# origin was recorded, so completion can disclose the legacy fallback.
verify_entry_durable() { # <origin-or-empty> <entry>; prints "<id> <how> <origin-state>"
local origin=$1 entry=$2 resolved resolve_status=0 id how stored origin_state=unrecorded origin_id stored_id
# The origin task is never its own captain-call inventory: it is the work the
# calls were found in, so accepting it would let a refused hold look recorded.
if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ] && [ "$entry" = "$origin" ]; then
refuse_self_inventory "$origin" "$entry"
fi
resolved=$(resolve_entry "$origin" "$entry") || resolve_status=$?
if [ "$resolve_status" -ne 0 ]; then
[ "$resolve_status" -ne 124 ] \
|| fail "the backlog backend exceeded its read bound resolving $entry"
exit "$resolve_status"
fi
printf '%s\n' "$resolved"
verify_hold_durable "${resolved%% *}"
id=${resolved%% *}
how=${resolved##* }
verify_hold_durable "$id"
id=$(show_field_value "$TASK_SHOW_OUTPUT" id)
validate_slug backend-task-id "$id"
stored=$(body_hold_origin "$(decode_shown_value "$(show_field "$TASK_SHOW_OUTPUT" body)")")
origin_id=$origin
if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then
origin_id=$(task_identity "$origin") || exit $?
[ "$id" != "$origin_id" ] || refuse_self_inventory "$origin" "$entry"
fi
if [ -n "$stored" ]; then
if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then
stored_id=$(task_identity "$stored") || exit $?
if [ "$stored_id" != "$origin_id" ]; then
fail "captain-held task $id was held for origin $stored, not $origin; hold a task for $origin or list the right one"
fi
fi
origin_state=recorded
fi
printf '%s %s %s\n' "$id" "$how" "$origin_state"
}

command_hold() {
local id=${1:-} title='' reason='' repo='' origin='' until='' show state existing_title body='' hold_kind hold_set occurrence
local existing_hold_kind='' existing_held='' preserve_hold_set=0
local existing_hold_kind='' existing_held='' preserve_hold_set=0 stored_reason previous_origin='' hold_status=0
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
shift
while [ "$#" -gt 0 ]; do
Expand All @@ -827,8 +917,9 @@ command_hold() {
shift
done
validate_slug task-id "$id"
validate_one_line reason "$reason"
case "$reason" in *'('*|*')'*) fail "reason must not contain parentheses (tasks-axi hold contract)" ;; esac
[ -n "$reason" ] || fail "reason must not be empty"
# bin/fm-hold-reason-lib.sh owns the storage constraint and reversible encoding.
stored_reason=$(fm_hold_reason_encode "$reason") || fail "could not encode the hold reason"
if [ -n "$origin" ]; then
validate_slug origin-id "$origin"
fi
Expand Down Expand Up @@ -889,12 +980,25 @@ command_hold() {
task_show_or_fail "$id" "task $id disappeared while recording its hold-set stamp"
[ -n "$(body_hold_set_timestamp "$(show_field_value "$show" body)")" ] \
|| fail "task $id did not retain its hold-set stamp"
if [ -n "$origin" ]; then
origin=$(task_identity "$origin") || exit $?
previous_origin=$(body_hold_origin "$(show_field_value "$show" body)")
write_hold_origin "$id" "$(show_field "$show" body)" "$origin" || exit $?
fi
if [ -n "$until" ]; then
tasks_axi hold "$id" --reason "$reason" --kind captain --until "$until" >/dev/null \
|| fail "could not hold task $id for the captain"
tasks_axi hold "$id" --reason "$stored_reason" --kind captain --until "$until" >/dev/null \
|| hold_status=$?
else
tasks_axi hold "$id" --reason "$reason" --kind captain >/dev/null \
|| fail "could not hold task $id for the captain"
tasks_axi hold "$id" --reason "$stored_reason" --kind captain >/dev/null \
|| hold_status=$?
fi
if [ "$hold_status" -ne 0 ]; then
# A refused re-hold must not associate the previous hold or answer with a
# new origin. Restore the old line verbatim, without resolving it again.
if [ -n "$origin" ]; then
write_hold_origin "$id" "$(show_field "$show" body)" "$previous_origin" || exit $?
fi
fail "could not hold task $id for the captain"
fi
task_show "$id" || fail "task $id disappeared while holding it"
show=$TASK_SHOW_OUTPUT
Expand Down Expand Up @@ -1627,7 +1731,7 @@ reconcile_note() {

command_complete() {
local origin=${1:-} meta previous='' supplied='' keys='' entry key status_file open has_meta=0 transfer_rc transfers=() resolved
local resolved_how attested_by_prefix=''
local resolved_how attested_by_prefix='' origin_state unrecorded_origin=''
[ "$#" -ge 2 ] || { usage >&2; exit 2; }
validate_slug origin-id "$origin"
shift
Expand Down Expand Up @@ -1659,8 +1763,13 @@ command_complete() {
while IFS= read -r entry; do
[ -n "$entry" ] || continue
resolved=$(verify_entry_durable "$origin" "$entry") || exit $?
origin_state=${resolved##* }
resolved=${resolved% *}
resolved_how=${resolved##* }
resolved=${resolved%% *}
if [ "$origin_state" = unrecorded ]; then
unrecorded_origin="${unrecorded_origin}${unrecorded_origin:+ }$resolved"
fi
if [ "$resolved_how" = migrated-prefix ]; then
attested_by_prefix="${attested_by_prefix}${attested_by_prefix:+ }$entry=$resolved"
fi
Expand Down Expand Up @@ -1702,8 +1811,9 @@ EOF
fi
fi
fi
printf 'complete: %s captain-call inventory reviewed%s%s\n' "$origin" "${keys:+ ($keys)}" \
"${attested_by_prefix:+ [attested through the configured prefix: $attested_by_prefix]}"
printf 'complete: %s captain-call inventory reviewed%s%s%s\n' "$origin" "${keys:+ ($keys)}" \
"${attested_by_prefix:+ [attested through the configured prefix: $attested_by_prefix]}" \
"${unrecorded_origin:+ [no recorded origin on: $unrecorded_origin; not checked against $origin]}"
}

command_verify() {
Expand Down
16 changes: 16 additions & 0 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,22 @@ if [ "$ACTIONABLE" -eq 1 ]; then
[ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true
exit 2
fi
if [ "$HOST_MODE" -eq 1 ] && fm_autoarm_still_owner "$STATE" "$MY_GEN" \
&& fm_recovery_marker_snapshot "$STATE/.watcher-down" \
&& [[ "$FM_RECOVERY_MARKER_TOKEN" == pending:handling:* || "$FM_RECOVERY_MARKER_TOKEN" == announced:handling:* ]] \
&& ! fm_watcher_healthy "$STATE" "$SCRIPT_DIR/fm-watch.sh" "$GRACE" "$FM_HOME"; then
LOST_HANDBACK_COMMITTED=0
if [ ! -e "$FAILURE_NOTICE" ]; then
printf 'firstmate watcher auto-arm FAILED - the supervision host returned an actionable wake, but its rewake could not be committed.\n' >&2
autoarm_commit failed "$FAILURE_NOTICE" && LOST_HANDBACK_COMMITTED=1
else
autoarm_commit failed-suppressed && LOST_HANDBACK_COMMITTED=1
fi
if [ "$LOST_HANDBACK_COMMITTED" -eq 1 ]; then
[ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true
exit 2
fi
fi
[ -z "$OUT" ] || rm -f "$OUT" 2>/dev/null || true
exit 0
fi
Expand Down
Loading
Loading