Skip to content
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
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
130 changes: 114 additions & 16 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,104 @@ 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>
local id=$1 body=$2 origin=$3 stamp rest new_body tmp
origin=$(task_identity "$origin") || return $?
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=$(printf '%s\nCaptain hold origin: %s' "$stamp" "$origin")
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
[ "$#" -ge 1 ] || { usage >&2; exit 2; }
shift
while [ "$#" -gt 0 ]; do
Expand All @@ -827,8 +915,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,11 +978,14 @@ 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
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 \
tasks_axi hold "$id" --reason "$stored_reason" --kind captain --until "$until" >/dev/null \
|| fail "could not hold task $id for the captain"
else
tasks_axi hold "$id" --reason "$reason" --kind captain >/dev/null \
tasks_axi hold "$id" --reason "$stored_reason" --kind captain >/dev/null \
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
|| fail "could not hold task $id for the captain"
fi
task_show "$id" || fail "task $id disappeared while holding it"
Expand Down Expand Up @@ -1627,7 +1719,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 +1751,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 +1799,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
9 changes: 6 additions & 3 deletions bin/fm-fleet-snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ esac
. "$SCRIPT_DIR/fm-landed-lib.sh" # FM_LANDED_JQ_DEFS: the shared landed selector
# shellcheck source=bin/fm-merge-authority-lib.sh
. "$SCRIPT_DIR/fm-merge-authority-lib.sh"
# shellcheck source=bin/fm-hold-reason-lib.sh
. "$SCRIPT_DIR/fm-hold-reason-lib.sh"

usage() {
cat <<'EOF'
Expand Down Expand Up @@ -381,13 +383,14 @@ first_pr_url_in_file() { # <file>
grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1
}

backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG
backlog_json() ( # [<backlog-path>] - defaults to this home's $BACKLOG
local backlog=${1:-$BACKLOG}
if [ ! -f "$backlog" ]; then
jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}'
return 0
fi

set -o pipefail
# shellcheck disable=SC2094
jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \
--argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" '
Expand Down Expand Up @@ -570,8 +573,8 @@ backlog_json() { # [<backlog-path>] - defaults to this home's $BACKLOG
| .captain_actionable = (.hold_bucket == "live")
else . end)
| del(.section,.order)
' < "$backlog"
}
' < "$backlog" | fm_hold_reason_decode_stream json
)

SNAPSHOT_TASK_DIR=
SNAPSHOT_TASK_METAS=()
Expand Down
72 changes: 72 additions & 0 deletions bin/fm-hold-reason-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# fm-hold-reason-lib.sh - the one reversible encoding of a captain-hold reason.
#
# tasks-axi stores a hold reason as one markdown line inside a parenthesised tag,
# so its own `hold` refuses parentheses and line breaks. A decision reason is
# ordinary prose, so bin/fm-captain-hold.sh encodes the reason where it writes
# it and every reader that shows it decodes it again, instead of banning the
# characters. Stored reasons use the reserved fm-hold-v1: prefix followed by
# base64-encoded UTF-8 text. Unmarked reasons are plain text. Readers decode only
# the hold-reason field, once, and keep line breaks in quoted output strings.
#
# Source this file; it defines functions only.

# fm_hold_reason_encode <reason>: print the storable form, no trailing newline.
fm_hold_reason_encode() {
printf '%s' "$1" | perl -MMIME::Base64=encode_base64 -0777 -ne \
'print "fm-hold-v1:", encode_base64($_, "")'
}

# fm_hold_reason_decode_stream [toon|markdown|json]: decode marked reason fields.
fm_hold_reason_decode_stream() {
perl -MJSON::PP -MMIME::Base64=decode_base64 -MEncode=decode,FB_CROAK -e '
use strict;
use warnings;
binmode STDIN, ":encoding(UTF-8)";
binmode STDOUT, ":encoding(UTF-8)";
my $format = shift;
my $json = JSON::PP->new->allow_nonref;
sub decode_reason {
my ($value) = @_;
return $value unless defined($value) && $value =~ s/^fm-hold-v1://;
return decode("UTF-8", decode_base64($value), FB_CROAK);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Historical reasons can break reads

An older, literal hold reason beginning with fm-hold-v1: is treated as encoded data even if this encoder never wrote it. For example, fm-hold-v1:bm9ydGg= displays as north; if the suffix decodes to invalid UTF-8, public task reads fail and the fleet snapshot can fail to load.

}
sub decode_field {
my ($raw) = @_;
my $value = $raw =~ /^"/ ? $json->decode($raw) : $raw;
my $decoded = decode_reason($value);
return $decoded eq $value ? $raw : $json->encode($decoded);
}
if ($format eq "json") {
local $/;
my $snapshot = $json->decode(<STDIN>);
for my $record (@{$snapshot->{records}}) {
$record->{hold_reason} = decode_reason($record->{hold_reason})
if exists $record->{hold_reason};
}
print $json->encode($snapshot), "\n";
exit;
}
my ($column, $task);
while (my $line = <STDIN>) {
if ($format eq "markdown") {
$line =~ s{^([-*] .*\(hold:\s*)(fm-hold-v1:[A-Za-z0-9+/]*={0,2})(\).*)$}
{$1 . decode_field($2) . $3}e;
} elsif ($line =~ /^tasks\[\d+\]\{([^}]*)\}:\n?$/) {
my @names = split /,/, $1;
($column) = grep { $names[$_] eq "hold_reason" } 0 .. $#names;
$task = 0;
} elsif (defined($column) && $line =~ /^ (.*)\n?$/) {
my @fields = $1 =~ /("(?:[^"\\]|\\.)*"|[^,]+)/g;
$fields[$column] = decode_field($fields[$column]);
$line = " " . join(",", @fields) . "\n";
} elsif ($task && $line =~ /^ hold_reason: (.*)\n?$/) {
$line = " hold_reason: " . decode_field($1) . "\n";
} elsif ($line !~ /^ /) {
$column = undef;
$task = $line eq "task:\n";
}
print $line;
}
' "${1:-toon}"
}
10 changes: 6 additions & 4 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,8 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown)
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-line-cap-lib.sh
. "$SCRIPT_DIR/fm-line-cap-lib.sh"
# shellcheck source=bin/fm-hold-reason-lib.sh
. "$SCRIPT_DIR/fm-hold-reason-lib.sh"

# One tasks-axi compatibility verdict per session start. The probe costs three
# tasks-axi subprocesses and this digest needs the same answer twice - here for
Expand Down Expand Up @@ -470,7 +472,7 @@ print_backlog_manual_compact() {
}
}
}
' "$path"
' "$path" | fm_hold_reason_decode_stream markdown
}

# tasks-axi closes every listing with its own help block. This section composes
Expand Down Expand Up @@ -522,11 +524,11 @@ print_backlog_tasks_axi_compact() {
printf 'compact backlog listing (tasks-axi; done rows omitted; every in-flight, held, and blocked row shown in full; ready queued bounded to %s; task bodies omitted)\n' \
"$QUEUED_LIMIT"
printf '\nin flight:\n'
printf '%s\n' "$in_flight" | strip_axi_help
printf '%s\n' "$in_flight" | fm_hold_reason_decode_stream | strip_axi_help
printf '\nheld (captain- or time-gated; an in-flight item that is also held appears in both groups):\n'
printf '%s\n' "$held" | strip_axi_help
printf '%s\n' "$held" | fm_hold_reason_decode_stream | strip_axi_help
printf '\nblocked queued:\n'
printf '%s\n' "$blocked" | strip_axi_help
printf '%s\n' "$blocked" | fm_hold_reason_decode_stream | strip_axi_help
printf '\nready queued (dispatchable now):\n'
print_ready_queued_bounded "$ready"
return 0
Expand Down
Loading
Loading