diff --git a/.agents/skills/captain-hold-lifecycle/SKILL.md b/.agents/skills/captain-hold-lifecycle/SKILL.md index eaea0c27411..438f6353b2a 100644 --- a/.agents/skills/captain-hold-lifecycle/SKILL.md +++ b/.agents/skills/captain-hold-lifecycle/SKILL.md @@ -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 ` 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. diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 99e6bc86ac7..b162e6bba40 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -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\}//' @@ -591,7 +594,7 @@ render_return_brief() { # -decision-` @@ -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" @@ -792,27 +803,106 @@ write_hold_set_stamp() { # + 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() { # + 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 " + 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 " " so the caller can keep the -# attestation evidence. -verify_entry_durable() { # ; prints " " - 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() { # ; prints " " + 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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() { diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 666d03b8d6c..7f830bff272 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -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' @@ -381,13 +383,14 @@ first_pr_url_in_file() { # grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1 } -backlog_json() { # [] - defaults to this home's $BACKLOG +backlog_json() ( # [] - 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" ' @@ -570,8 +573,8 @@ backlog_json() { # [] - 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=() diff --git a/bin/fm-hold-reason-lib.sh b/bin/fm-hold-reason-lib.sh new file mode 100644 index 00000000000..6b6b6a6d9ff --- /dev/null +++ b/bin/fm-hold-reason-lib.sh @@ -0,0 +1,78 @@ +#!/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 : 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=encode_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 =~ /^fm-hold-v1:(.*)\z/s; + my $payload = $1; + my $bytes = decode_base64($payload); + return $value unless encode_base64($bytes, "") eq $payload; + # Historical literals with valid base64 and UTF-8 remain indistinguishable + # from encoded reasons; malformed payloads retain their stored text. + my $decoded = eval { decode("UTF-8", $bytes, FB_CROAK) }; + return $@ ? $value : $decoded; + } + 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(); + 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 = ) { + 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}" +} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 9ddaadc88ba..67f25704265 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -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 @@ -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 @@ -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 diff --git a/bin/fm-tasks-axi.sh b/bin/fm-tasks-axi.sh index b773014a115..7f0dc1822c9 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -14,6 +14,10 @@ # stores it verbatim as a link, which lifecycle transitions record relative to # that same root. # +# `show` (including `view`) and `list` decode stored captain-hold reasons +# through bin/fm-hold-reason-lib.sh, which owns the field-only decoding contract. +# Decoded reasons use quoted strings so embedded line breaks remain intact. +# # Why it exists: a bare `tasks-axi` resolves the tracked `.tasks.toml` paths # against its working directory, so from the code root it forks the queue # whenever the home lives elsewhere; docs/configuration.md ("Backlog backend") @@ -46,7 +50,8 @@ # - a markdown `/backlog.md` that is itself a symlink, because the # first write would replace the link with a private copy, exactly the fork # this command exists to prevent. Lifecycle transitions refuse the same file. -# Otherwise the exit status is tasks-axi's own. +# Otherwise the exit status is tasks-axi's own, unless decoding a read fails; +# in that case the decoder's nonzero status is returned. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -57,6 +62,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backlog-transition-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-backlog-transition-lib.sh" +# shellcheck source=bin/fm-hold-reason-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-hold-reason-lib.sh" usage() { awk ' @@ -137,4 +144,11 @@ else fi cd "$FM_BACKLOG_AXI_ROOT" || fail "cannot enter the backlog root $FM_BACKLOG_AXI_ROOT" +case "${1:-}" in + show|view|list) + set -o pipefail + tasks-axi ${ARGS[@]+"${ARGS[@]}"} | fm_hold_reason_decode_stream + exit $? + ;; +esac exec tasks-axi ${ARGS[@]+"${ARGS[@]}"} diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 4702f403f7f..cd4fd0e33f6 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -1650,7 +1650,7 @@ families_for_changed_path() { bin/fm-lint.sh|bin/fm-lint-workflows.sh|bin/fm-install-shellcheck.sh|\ bin/fm-install-actionlint.sh|\ bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ - bin/fm-captain-hold.sh|bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ + bin/fm-captain-hold.sh|bin/fm-hold-reason-lib.sh|bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-forge-detect.sh|bin/fm-promote.sh|\ diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 7a186a4e6af..5c19372265c 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -51,8 +51,9 @@ It works in this order: 1. It uses an existing task, or creates one when nothing exists to hold. 2. It records the task's UTC hold-set timestamp as the leading line of the task body. -3. It invokes the underlying tasks-axi hold operation. -4. It verifies both records. +3. When `--origin` is supplied, it records the origin on the task, replacing any previous association. +4. It invokes the underlying tasks-axi hold operation. +5. It verifies the hold and timestamp. Publishing the stamp first ensures a snapshot cannot observe a newly captain-held task without the timestamp that defines its age. @@ -62,6 +63,10 @@ Repeat and edge cases: - Re-holding released work starts a new timestamped lifecycle. - A closed task is refused rather than reopened. - `--until` stores the captain's own deferral date through tasks-axi's date gate. +- Before the backend hold runs, `--origin` records the origin the call is held for on its own `Captain hold origin:` body line, which `complete` and `verify` check using backend identities rather than alias spellings. + If that write fails, the backend hold is not attempted. +- The reason may contain parentheses, semicolons, quotes, and line breaks. + [`bin/fm-hold-reason-lib.sh`](../bin/fm-hold-reason-lib.sh) owns the storage encoding and compatibility rules; [`bin/fm-tasks-axi.sh --help`](../bin/fm-tasks-axi.sh) owns the public read commands and output contract. ### Answering a call (`answer`) @@ -103,6 +108,10 @@ A post-teardown visual review can complete against the surviving report and dura `complete` accepts `--none` as an explicit semantic inventory result. `--none` is refused while the origin still has a lifecycle-open keyed status decision. Before recording completion, `complete` verifies every listed task against tasks-axi. +The origin is never its own inventory entry, so a hold that failed cannot be vouched for by the origin row. +For a historical inventory that names its own origin, hold a separate captain task with `--origin`, replace only the invalid entry in the final `decision_keys=` line of the origin metadata with that task id while preserving all other entries, and re-run `complete`. +An entry whose recorded origin differs from the one being completed is refused. +An entry with no recorded origin, such as a hold made before origins were recorded or without `--origin`, is accepted on the durability check alone and named in the output. With a non-empty inventory, `complete` appends a `captain-held [key=]` transfer event for every still-open keyed status decision. The event names the reviewed inventory. @@ -114,7 +123,7 @@ Scout teardown calls the read-only `verify` subcommand after checking for the re `verify` checks three things: - The recorded attestation exists. -- Every recorded inventory entry is still durable: actively captain-held, or carrying a recorded answer. +- Every recorded inventory entry still passes the [completion inventory checks](#recording-a-reviewed-inventory-complete). - No keyed status decision opened after the last `complete`. A keyed status decision opened after the last `complete` makes `verify` fail, and re-running `complete` is the repair. @@ -479,7 +488,7 @@ It then finishes any still-recorded dependency-edge cleanup without rewriting th ## Verification record -The focused end-to-end regression suite is `tests/fm-captain-hold-lifecycle.test.sh`, using only synthetic `sample` identities and decision text. +The focused end-to-end regression suite is `tests/fm-captain-hold-lifecycle.test.sh`, using only synthetic identities and decision text. It proves the behaviors below. The suite does not test the accepted merge-to-cleanup re-hold window or asynchronous queued-forge landing because those events occur after the locally serialized merge command has returned. @@ -528,7 +537,8 @@ The suite does not test the accepted merge-to-cleanup re-hold window or asynchro ### Legacy paths -- Every legacy path works: composed identities through the shim, pre-collapse `decision_keys=` metadata, routed-resolution replay, and a concrete-origin binding. +- Composed identities through the shim, valid pre-collapse `decision_keys=` inventories, routed-resolution replay, and a concrete-origin binding remain supported. + Historical self-inventories require the [documented repair](#recording-a-reviewed-inventory-complete). ### Task-body read-back cases diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index b66ead8c0da..fd589be2965 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -35,6 +35,7 @@ install_runner() { # cp "$ROOT/bin/fm-afk-contract.sh" "$dir/bin/" cp "$ROOT/bin/fm-branch-outcome.sh" "$dir/bin/" cp "$ROOT/bin/fm-tasks-axi-lib.sh" "$dir/bin/" + cp "$ROOT/bin/fm-hold-reason-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-backlog-transition-lib.sh" "$dir/bin/" # The merge-notification marker reader behind the brief's landed section. cp "$ROOT/bin/fm-pr-lib.sh" "$dir/bin/" diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index a20a8c5de52..2864a3e30eb 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -88,6 +88,15 @@ run_captain() { # FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-captain-hold.sh" "$@" } +# Completes 's captain-call inventory through a separate held task, because +# the origin task is never accepted as its own inventory entry. +complete_through_sibling() { # + local home=$1 id=$2 + run_captain "$home" hold "$id-call" --title "Sibling captain call for $id" \ + --reason "captain must decide the sibling call" --repo sample --origin "$id" >/dev/null \ + && run_captain "$home" complete "$id" "$id-call" +} + request_reconciles() { # ... local home=$1 source_id=$2 id shift 2 @@ -357,7 +366,7 @@ case "${1:-}" in show) case "${2:-}" in @KNOWN@) ;; - *) printf 'error: no task %s in this backlog\n' "${2:-}" >&2; exit 1 ;; + *) printf 'error: no task %s in this backlog\ncode: NOT_FOUND\n' "${2:-}" >&2; exit 1 ;; esac printf '%s\n' 'task:' printf ' id: %s\n' "$2" @@ -2575,7 +2584,7 @@ test_teardown_never_closes_a_captain_held_task() { run_captain "$home" hold "$id" \ --reason "captain must choose inline or by-reference attachments" >/dev/null \ || fail "could not hold the originating work item for the captain" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed with the origin as its own captain call" run_teardown "$home" "$id" > "$home/teardown.out" 2> "$home/teardown.err" \ @@ -2666,7 +2675,7 @@ test_retained_row_artifacts_survive_captain_answers() { > "$home/data/$retained_id/report.md" run_captain "$home" hold "$retained_id" --reason "captain must choose the report follow-up" \ >/dev/null || fail "could not hold the retained report" - run_captain "$home" complete "$retained_id" "$retained_id" >/dev/null \ + complete_through_sibling "$home" "$retained_id" >/dev/null \ || fail "completion gate failed for the retained report" run_teardown "$home" "$retained_id" > "$home/retained-teardown.out" \ 2> "$home/report-teardown.err" \ @@ -2687,7 +2696,7 @@ test_retained_row_artifacts_survive_captain_answers() { run_captain "$home" hold "$precedence_id" \ --reason "captain must choose the report follow-up" >/dev/null \ || fail "could not hold the report precedence fixture" - run_captain "$home" complete "$precedence_id" "$precedence_id" >/dev/null \ + complete_through_sibling "$home" "$precedence_id" >/dev/null \ || fail "completion gate failed for the report precedence fixture" run_teardown "$home" "$precedence_id" > "$home/precedence-teardown.out" \ 2> "$home/precedence-teardown.err" \ @@ -2815,7 +2824,7 @@ test_retained_row_artifacts_survive_captain_answers() { printf '# Released report\n' > "$home/data/$released_id/report.md" run_captain "$home" hold "$released_id" --reason "captain report release pending" \ >/dev/null || fail "could not hold the released report" - run_captain "$home" complete "$released_id" "$released_id" >/dev/null \ + complete_through_sibling "$home" "$released_id" >/dev/null \ || fail "completion gate failed for the released report" printf 'Release the completed report.\n' > "$home/released-answer.txt" run_captain "$home" answer "$released_id" --release \ @@ -2920,7 +2929,7 @@ test_interrupted_cleanup_keeps_the_captain_call_recoverable() { printf '# Failed cleanup\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after cleanup retry" >/dev/null \ || fail "could not hold the cleanup-failure fixture" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed for the cleanup-failure fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -2979,7 +2988,7 @@ test_answer_before_cleanup_replay_preserves_the_retained_report() { printf '# Interrupted cleanup\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after interrupted cleanup" \ >/dev/null || fail "could not hold the answer-before-replay fixture" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed for the answer-before-replay fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -3090,7 +3099,7 @@ test_unusable_pending_close_record_names_its_reason() { printf '# Unusable pending close\n\nThe captain call remains open.\n' > "$home/data/$id/report.md" run_captain "$home" hold "$id" --reason "captain must choose after interrupted cleanup" \ >/dev/null || fail "could not hold the unusable pending-close fixture" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "completion gate failed for the unusable pending-close fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -3154,7 +3163,12 @@ EOF || fail "could not hold the relocated answer-before-replay fixture" PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ - "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id" >/dev/null \ + "$ROOT/bin/fm-captain-hold.sh" hold "$id-call" --title "Sibling captain call" \ + --reason "captain must decide the sibling call" --repo sample --origin "$id" >/dev/null \ + || fail "could not hold the sibling captain call" + PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id-call" >/dev/null \ || fail "completion gate failed for the relocated answer-before-replay fixture" cat > "$home/fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -3231,7 +3245,12 @@ EOF || fail "could not hold the relocated work item" PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ - "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id" >/dev/null \ + "$ROOT/bin/fm-captain-hold.sh" hold "$id-call" --title "Sibling captain call" \ + --reason "captain must decide the sibling call" --repo sample --origin "$id" >/dev/null \ + || fail "could not hold the sibling captain call" + PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-captain-hold.sh" complete "$id" "$id-call" >/dev/null \ || fail "completion gate failed for the relocated captain hold" PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ @@ -4061,7 +4080,7 @@ PM > "$home/data/$scout/report.md" run_captain "$home" hold "$scout" --reason "captain must choose" >/dev/null \ || fail "could not hold the investigation for the captain" - run_captain "$home" complete "$scout" "$scout" >/dev/null \ + complete_through_sibling "$home" "$scout" >/dev/null \ || fail "the completion gate failed with the origin as its own captain call" PERL5LIB="$shim" PERL5OPT=-MFmNoNonrefDefault \ run_teardown "$home" "$scout" > "$home/nonref.out" 2> "$home/nonref.err" \ @@ -4099,7 +4118,7 @@ retain_row_with_body() { # || fail "could not give $id a body carrying non-ASCII characters" run_captain "$home" hold "$id" --reason "captain must choose" >/dev/null \ || fail "could not hold $id for the captain" - run_captain "$home" complete "$id" "$id" >/dev/null \ + complete_through_sibling "$home" "$id" >/dev/null \ || fail "the completion gate failed for $id" run_teardown "$home" "$id" > "$home/$id.out" 2> "$home/$id.err" \ || fail "cleanup of captain-held $id failed: $(cat "$home/$id.err")" @@ -4137,6 +4156,485 @@ test_retained_body_keeps_its_utf8_bytes() { pass "cleanup preserves every byte of a retained body's non-ASCII characters" } +# A refused hold must never read as a recorded one. The gate used to accept the +# origin as its own inventory whenever the origin row looked durable, so a hold +# that failed just before `complete ` left a satisfied gate +# with no captain call recorded. +test_origin_is_never_its_own_inventory_entry() { + local home id + home=$(make_home origin-self-inventory) + id=sample-self-review + mkdir -p "$home/data/$id" + tasks_in "$home" add "$id" "Investigate sample self review" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the investigation fixture" + write_origin_meta "$home" "$id" + printf 'done: report complete\n' > "$home/state/$id.status" + if run_captain "$home" hold "$id" --reason "" >/dev/null 2> "$home/hold.err"; then + fail "hold accepted an empty reason" + fi + if run_captain "$home" complete "$id" "$id" > "$home/self.out" 2> "$home/self.err"; then + fail "complete accepted the origin as its own inventory after a failed hold" + fi + assert_grep "cannot be its own captain-call inventory entry" "$home/self.err" \ + "the refusal does not say why the origin was rejected" + assert_no_grep "decisions_reviewed=1" "$home/state/$id.meta" \ + "the refused completion recorded an inventory attestation" + + # Holding the origin row itself must not let it vouch for itself either. + run_captain "$home" hold "$id" --reason "captain must choose" >/dev/null \ + || fail "could not hold the origin row" + if run_captain "$home" complete "$id" "$id" > "$home/held.out" 2> "$home/held.err"; then + fail "complete accepted a held origin row as its own inventory" + fi + pass "complete refuses the origin as its own captain-call inventory" +} + +# `hold --origin` records which origin a call was held for, and `complete` +# refuses a task held for a different origin. A hold recorded before that +# record existed, or without --origin, still verifies and is flagged. +test_complete_refuses_an_entry_held_for_another_origin() { + local home id other o out + home=$(make_home origin-mismatch) + id=sample-first-review + other=sample-second-review + for o in "$id" "$other"; do + mkdir -p "$home/data/$o" + tasks_in "$home" add "$o" "Investigate $o" --kind scout --repo sample --start >/dev/null \ + || fail "could not create the $o fixture" + write_origin_meta "$home" "$o" + printf 'done: report complete\n' > "$home/state/$o.status" + done + run_captain "$home" hold sample-other-call --title "Call for the second review" \ + --reason "captain must decide" --repo sample --origin "$other" >/dev/null \ + || fail "could not hold the call recorded for the second review" + if run_captain "$home" complete "$id" sample-other-call > "$home/mismatch.out" 2> "$home/mismatch.err"; then + fail "complete accepted an entry held for a different origin" + fi + assert_grep "was held for origin $other, not $id" "$home/mismatch.err" \ + "the refusal does not name both origins" + assert_no_grep "decisions_reviewed=1" "$home/state/$id.meta" \ + "the refused completion recorded an inventory attestation" + + run_captain "$home" hold sample-own-call --title "Call for the first review" \ + --reason "captain must decide" --repo sample --origin "$id" >/dev/null \ + || fail "could not hold the call recorded for the first review" + out=$(run_captain "$home" complete "$id" sample-own-call) \ + || fail "complete refused an entry held for its own origin" + assert_not_contains "$out" "no recorded origin" \ + "an entry with a recorded origin was flagged as unrecorded" + + tasks_in "$home" add sample-old-call "Call held before origins were recorded" --kind captain --repo sample >/dev/null \ + || fail "could not create the older call" + tasks_in "$home" hold sample-old-call --reason "captain must decide" --kind captain >/dev/null \ + || fail "could not hold the older call" + out=$(run_captain "$home" complete "$other" sample-old-call) \ + || fail "complete refused an older hold with no recorded origin" + assert_contains "$out" "no recorded origin on: sample-old-call" \ + "an older hold with no recorded origin was not flagged" + pass "complete refuses an entry held for another origin and flags one with none recorded" +} + +test_hold_origins_precede_backend_holds() { + local home phase timing failure id shown origin until_args=() + for phase in new active released; do + for timing in plain dated; do + home=$(make_home "origin-failure-$phase-$timing") + id=sample-call + for origin in origin-a origin-b; do + tasks_in "$home" add "$origin" "Review $origin" --kind scout --repo sample >/dev/null \ + || fail "could not create $origin" + write_origin_meta "$home" "$origin" + done + if [ "$phase" != new ]; then + run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for A" \ + --origin origin-a >/dev/null || fail "could not establish the original association" + fi + if [ "$phase" = released ]; then + printf 'Release this work.\n' > "$home/answer.txt" + run_captain "$home" answer "$id" --release --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not release the original hold" + fi + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = show ] && [ "${2:-}" = origin-b ] && [ -f "$FM_HOME/fail-lookup" ]; then + : > "$FM_HOME/lookup-refused" + printf 'error: origin read failed\ncode: READ_FAILED\n' >&2 + exit 2 +fi +if [ "${1:-}" = update ] && [ -f "$FM_HOME/fail-write" ]; then + previous='' + for arg in "$@"; do + if [ "$previous" = --body-file ] && grep -qx 'Captain hold origin: origin-b' "$arg"; then + : > "$FM_HOME/write-refused" + exit 9 + fi + previous=$arg + done +fi +if [ "${1:-}" = hold ] && [ "${2:-}" != --help ]; then + "$REAL_TASKS_AXI" show "$2" --full > "$FM_HOME/before-backend-hold" || exit $? + if [ -f "$FM_HOME/fail-hold" ]; then + : > "$FM_HOME/hold-refused" + exit 9 + fi +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + until_args=() + [ "$timing" != dated ] || until_args=(--until 2099-01-01) + for failure in lookup write hold; do + : > "$home/fail-$failure" + if run_captain "$home" hold "$id" --title "Separate call" --reason "Choose for B" \ + --origin origin-b ${until_args[@]+"${until_args[@]}"} > "$home/hold.out" 2> "$home/hold.err"; then + fail "$phase $timing hold succeeded despite an origin $failure failure" + fi + assert_present "$home/$failure-refused" "the failure did not reach the origin $failure" + if [ "$failure" = hold ]; then + assert_present "$home/before-backend-hold" "$phase $timing failure never reached the backend hold" + assert_grep 'Captain hold origin: origin-b' "$home/before-backend-hold" \ + "the failed backend hold did not see the new association" + rm "$home/before-backend-hold" + else + assert_absent "$home/before-backend-hold" "$phase $timing origin $failure failure reached the backend hold" + fi + shown=$(tasks_in "$home" show "$id" --full) + assert_not_contains "$shown" 'Captain hold origin: origin-b' \ + "$phase $timing origin $failure failure published the new association" + if [ "$phase" = active ]; then + assert_contains "$shown" 'held: yes' "an origin $failure failure lifted an existing hold" + else + assert_contains "$shown" 'held: no' "$phase $timing origin $failure failure left the task held" + fi + if [ "$phase" != new ]; then + assert_contains "$shown" 'Captain hold origin: origin-a' \ + "$phase $timing origin $failure failure lost the original association" + fi + rm "$home/fail-$failure" + if [ "$phase" = new ] && run_captain "$home" complete origin-a "$id" \ + > "$home/unrelated.out" 2> "$home/unrelated.err"; then + fail "$timing origin $failure failure satisfied an unrelated inventory" + fi + if run_captain "$home" complete origin-b "$id" > "$home/complete.out" 2> "$home/complete.err"; then + fail "$phase $timing origin $failure failure satisfied completion for B" + fi + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$id" >> "$home/state/origin-b.meta" + if run_captain "$home" verify origin-b > "$home/verify.out" 2> "$home/verify.err"; then + fail "$phase $timing origin $failure failure verified an inventory for B" + fi + if [ "$phase" != new ]; then + run_captain "$home" complete origin-a "$id" >/dev/null \ + || fail "$phase $timing origin $failure failure invalidated completion for A" + run_captain "$home" verify origin-a >/dev/null \ + || fail "$phase $timing origin $failure failure invalidated verification for A" + fi + done + run_captain "$home" hold "$id" --reason "Choose for B" --origin origin-b \ + ${until_args[@]+"${until_args[@]}"} >/dev/null || fail "$phase $timing successful retry failed" + assert_present "$home/before-backend-hold" "the successful retry did not reach the backend hold" + shown=$(cat "$home/before-backend-hold") + assert_contains "$shown" 'Captain hold origin: origin-b' "the backend hold ran before the new origin was recorded" + assert_not_contains "$shown" 'Captain hold origin: origin-a' "the backend hold ran with the old association" + shown=$(tasks_in "$home" show "$id" --full) + assert_contains "$shown" 'held: yes' "the successful retry did not hold the task" + assert_contains "$shown" 'Captain hold origin: origin-b' "a successful hold lost its association" + assert_not_contains "$shown" 'Captain hold origin: origin-a' "a successful hold retained the old association" + run_captain "$home" complete origin-b "$id" >/dev/null \ + || fail "a successful hold could not complete B" + run_captain "$home" verify origin-b >/dev/null || fail "a successful hold could not verify B" + if run_captain "$home" complete origin-a "$id" >/dev/null 2> "$home/old-origin.err"; then + fail "a successful reassociation still certified A" + fi + done + done + pass "new, active, and released holds require the origin first with and without deferral" +} + +test_historical_self_inventory_has_workable_repair() { + local home origin=sample-review keep=retained-call replacement=repair-call meta before out + home=$(make_home historical-self-inventory) + run_captain "$home" hold "$origin" --title "Old review call" --reason "Choose" >/dev/null \ + || fail "could not create the historical origin" + write_origin_meta "$home" "$origin" + for out in "$keep" "$replacement"; do + run_captain "$home" hold "$out" --title "Call $out" --reason "Choose" --origin "$origin" >/dev/null \ + || fail "could not create $out" + done + meta="$home/state/$origin.meta" + printf 'decisions_reviewed=1\ndecision_keys=%s,%s\n' "$origin" "$keep" >> "$meta" + before=$(cat "$meta") + for out in "$replacement" --none; do + if run_captain "$home" complete "$origin" "$out" > "$home/complete.out" 2> "$home/complete.err"; then + fail "complete accepted the historical self-inventory" + fi + assert_grep "historical decision_keys in $meta still contains $origin" "$home/complete.err" \ + "the historical refusal did not identify the persisted entry" + assert_grep 'replace only' "$home/complete.err" "the refusal omitted the repair instruction" + done + if run_captain "$home" verify "$origin" > "$home/verify.out" 2> "$home/verify.err"; then + fail "verify accepted the historical self-inventory" + fi + assert_grep "historical decision_keys in $meta still contains $origin" "$home/verify.err" \ + "verify omitted the historical repair instruction" + assert_equals "$before" "$(cat "$meta")" "refusing a historical inventory changed it" + sed "s/^decision_keys=$origin,$keep$/decision_keys=$replacement,$keep/" "$meta" > "$meta.repaired" + mv "$meta.repaired" "$meta" + run_captain "$home" complete "$origin" "$replacement" >/dev/null \ + || fail "the documented historical repair did not allow completion" + run_captain "$home" verify "$origin" >/dev/null || fail "the repaired inventory did not verify" + assert_equals "decision_keys=$replacement,$keep" "$(grep '^decision_keys=' "$meta" | tail -1)" \ + "repair lost a sibling inventory entry" + if run_captain "$home" complete "$origin" "$origin" >/dev/null 2> "$home/self.err"; then + fail "repair allowed a new self-inventory" + fi + pass "historical self-inventories name a workable repair that preserves sibling entries" +} + +test_inventory_compares_backend_identities() { + local home origin entry shown before + home=$(make_home backend-identities) + run_captain "$home" hold fm-o --title "Origin" --reason "Choose" >/dev/null \ + || fail "could not create the canonical origin" + tasks_in "$home" add fm-other "Other origin" --kind scout --repo sample >/dev/null \ + || fail "could not create the other origin" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = show ] && [ "${2:-}" = o ] && [ -f "$FM_HOME/fail-identity" ]; then + printf 'error: origin read failed\ncode: READ_FAILED\n' >&2 + exit 2 +fi +if [ "$#" -ge 2 ]; then + case "$2" in + o|call|other) set -- "$1" "fm-$2" "${@:3}" ;; + esac +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + for origin in fm-o o; do + for entry in fm-o o; do + write_origin_meta "$home" "$origin" + if run_captain "$home" complete "$origin" "$entry" > "$home/self.out" 2> "$home/self.err"; then + fail "complete accepted aliased self-inventory $origin/$entry" + fi + assert_grep 'cannot be its own captain-call inventory entry' "$home/self.err" \ + "the alias refusal did not identify self-inventory" + printf 'decisions_reviewed=1\ndecision_keys=%s\n' "$entry" >> "$home/state/$origin.meta" + if run_captain "$home" verify "$origin" > "$home/verify.out" 2> "$home/verify.err"; then + fail "verify accepted aliased self-inventory $origin/$entry" + fi + assert_grep 'historical decision_keys' "$home/verify.err" "the alias repair diagnostic was missing" + done + write_origin_meta "$home" "$origin" + done + run_captain "$home" hold fm-call --title "Separate call" --reason "Choose" --origin o >/dev/null \ + || fail "could not hold a call using the origin alias" + shown=$(tasks_in "$home" show fm-call --full) + assert_contains "$shown" 'Captain hold origin: fm-o' "hold did not store the backend origin identity" + printf '%s\n' "$shown" | sed -n 's/^ body: //p' | jq -r . \ + | sed 's/^Captain hold origin: fm-o$/Captain hold origin: o/' > "$home/legacy-origin.txt" + tasks_in "$home" update fm-call --body-file "$home/legacy-origin.txt" >/dev/null \ + || fail "could not create a legacy stored alias" + for origin in fm-o o; do + for entry in fm-call call; do + run_captain "$home" complete "$origin" "$entry" >/dev/null \ + || fail "complete refused equivalent origin spellings for $origin/$entry" + run_captain "$home" verify "$origin" >/dev/null \ + || fail "verify refused equivalent origin spellings for $origin/$entry" + done + done + for origin in fm-other other; do + write_origin_meta "$home" "$origin" + if run_captain "$home" complete "$origin" call > "$home/other.out" 2> "$home/other.err"; then + fail "complete accepted another origin through $origin" + fi + assert_grep "was held for origin o, not $origin" "$home/other.err" "the alias mismatch was not identified" + printf 'decisions_reviewed=1\ndecision_keys=call\n' >> "$home/state/$origin.meta" + if run_captain "$home" verify "$origin" >/dev/null 2> "$home/other-verify.err"; then + fail "verify accepted another origin through $origin" + fi + done + : > "$home/fail-identity" + before=$(cat "$home/state/o.meta") + if run_captain "$home" complete o fm-call >/dev/null 2> "$home/read.err"; then + fail "an unreadable backend identity was treated as an absent origin" + fi + assert_grep 'could not resolve the backend identity of o' "$home/read.err" "the identity read failure was hidden" + assert_equals "$before" "$(cat "$home/state/o.meta")" "a failed identity read changed the inventory" + rm "$home/fail-identity" + write_origin_meta "$home" report-only + run_captain "$home" hold report-call --title "Report call" --reason "Choose" --origin report-only >/dev/null \ + || fail "an origin with metadata but no backlog row could not record a call" + run_captain "$home" complete report-only report-call >/dev/null \ + || fail "an origin with metadata but no backlog row could not complete" + run_captain "$home" verify report-only >/dev/null \ + || fail "an origin with metadata but no backlog row could not verify" + pass "completion and verification compare backend identities for entries and current or stored origins" +} + +# tasks-axi refuses parentheses and line breaks in a hold reason and stores the +# rest on one markdown line. The reason is encoded where it is written and +# decoded wherever it is shown, so prose with every awkward character survives. +test_hold_reason_round_trips_awkward_characters() { + local home id reason stored json shown start verb fields out raw rc raw_rc mode + local title legacy body quoted_reason quoted_title quoted_legacy expected_reason until_args=() + local malformed index=0 malformed_reasons=( + 'fm-hold-v1:/w==' 'fm-hold-v1:bm9ydGg=$' 'fm-hold-v1:bm9ydGg' 'fm-hold-v1:Zh==' + ) + home=$(make_home reason-round-trip) + title='Investigate literal %28, "fm-hold-v1:bm9ydGg="' + legacy='Visit https://example.test/%28literal%29 and %0A; fm-hold-v1:bm9ydGg=' + body=$'fm-hold-v1:bm9ydGg=\n hold_reason: "%28"\n' + quoted_title=$(jq -cn --arg value "$title" '$value') + quoted_legacy=$(jq -cn --arg value "$legacy" '$value') + tasks_in "$home" add sample-legacy-call "$title" --kind captain --repo sample >/dev/null \ + || fail "could not create the legacy call" + tasks_in "$home" hold sample-legacy-call --reason "$legacy" --kind captain >/dev/null \ + || fail "could not hold the legacy call" + printf '%s' "$body" > "$home/legacy-body.txt" + tasks_in "$home" update sample-legacy-call --body-file "$home/legacy-body.txt" >/dev/null \ + || fail "could not write the legacy body" + + # Historical literal reasons are persisted input, not encoder output. + for malformed in "${malformed_reasons[@]}"; do + id="sample-malformed-$index" + index=$((index + 1)) + tasks_in "$home" add "$id" "Historical reason $index" --kind captain --repo sample >/dev/null \ + || fail "could not create $id" + tasks_in "$home" hold "$id" --reason "$malformed" --kind captain >/dev/null \ + || fail "could not store the historical literal reason" + for verb in show view; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" "$verb" "$id" --full) \ + || fail "public $verb failed on historical literal $malformed" + raw=$(tasks_in "$home" "$verb" "$id" --full) + assert_equals "$raw" "$out" "public $verb changed historical literal $malformed" + done + done + + for id in sample-reason-call sample-dated-call; do + until_args=() + reason=$' Pick route (north); say "yes" or \'no\' - 100% sure %28x%29, café\t\\slash\r\nSecond line\n\n' + if [ "$id" = sample-dated-call ]; then + until_args=(--until 2099-01-01) + reason='fm-hold-v1:bm9ydGg=' + fi + quoted_reason=$(jq -cn --arg value "$reason" '$value') + run_captain "$home" hold "$id" --title "$title" --reason "$reason" \ + --repo sample ${until_args[@]+"${until_args[@]}"} >/dev/null \ + || fail "hold refused the reason for $id" + stored=$(grep "^- \[ \] $id " "$home/data/backlog.md") \ + || fail "the held row is not on one backlog line" + assert_contains "$stored" "(hold: fm-hold-v1:" "the persisted reason has no encoding marker" + assert_contains "$stored" "(hold-kind: captain)" "the reason broke the hold-kind tag" + + for verb in show view; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" "$verb" "$id") \ + || fail "public $verb failed for $id" + shown=$(printf '%s\n' "$out" | sed -n 's/^ hold_reason: //p') + printf '%s\n' "$shown" | jq -e --arg reason "$reason" '. == $reason' >/dev/null \ + || fail "public $verb changed the reason for $id" + assert_contains "$out" " title: $quoted_title" "public $verb changed the title" + done + for fields in hold_reason,body body,hold_reason,hold_until; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" list --fields "$fields") \ + || fail "public list failed with $fields" + assert_contains "$out" "$quoted_reason" "public list changed the reason with $fields" + assert_contains "$out" "$quoted_title" "public list changed the title with $fields" + raw=$(tasks_in "$home" list --fields "$fields" | grep '^ sample-legacy-call,') + shown=$(printf '%s\n' "$out" | grep '^ sample-legacy-call,') + assert_equals "$raw" "$shown" "public list changed legacy or unrelated fields" + for malformed in "${malformed_reasons[@]}"; do + assert_contains "$out" "$malformed" "public list changed historical literal $malformed" + done + done + json=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-fleet-snapshot.sh" --json) || fail "fleet snapshot failed" + printf '%s' "$json" | jq -e --arg id "$id" --arg reason "$reason" --arg title "$title" \ + '.backlog.records[] | select(.id == $id) | .hold_reason == $reason and .title == $title' >/dev/null \ + || fail "fleet changed the reason or title for $id" + printf '%s' "$json" | jq -e --arg reason "$legacy" --arg title "$title" \ + '.backlog.records[] | select(.id == "sample-legacy-call") | + .hold_reason == $reason and .title == $title and .body_lines[0] == "fm-hold-v1:bm9ydGg="' >/dev/null \ + || fail "fleet changed legacy or unrelated fields" + for malformed in "${malformed_reasons[@]}"; do + printf '%s' "$json" | jq -e --arg reason "$malformed" \ + 'any(.backlog.records[]; .hold_reason == $reason)' >/dev/null \ + || fail "fleet changed historical literal $malformed" + done + done + + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" show sample-legacy-call --full) + raw=$(tasks_in "$home" show sample-legacy-call --full) + assert_equals "$raw" "$out" "public show changed legacy or unrelated fields" + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" list) + raw=$(tasks_in "$home" list) + assert_equals "$raw" "$out" "public list changed output with no reason column" + for verb in show list; do + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" "$verb" --help) + raw=$(tasks_in "$home" "$verb" --help) + assert_equals "$raw" "$out" "public $verb changed help output" + done + out=$(FM_HOME="$home" "$ROOT/bin/fm-tasks-axi.sh" show nonexistent-call 2>&1) + rc=$? + raw=$(tasks_in "$home" show nonexistent-call 2>&1) + raw_rc=$? + [ "$raw_rc" -ne 0 ] || fail "the missing-task fixture unexpectedly exists" + expect_code "$raw_rc" "$rc" "public show missing task" + assert_equals "$raw" "$out" "public show changed a read error" + + expected_reason=$' Pick route (north); say "yes" or \'no\' - 100% sure %28x%29, café\t\\slash\r\nSecond line\n\n' + quoted_reason=$(jq -cn --arg value "$expected_reason" '$value') + for mode in tool manual fallback; do + case "$mode" in + manual) printf 'manual\n' > "$home/config/backlog-backend" ;; + fallback) + rm "$home/config/backlog-backend" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = list ]; then + printf 'read failed: literal %%28 and fm-hold-v1:bm9ydGg=\n' >&2 + exit 1 +fi +exec "$REAL_TASKS_AXI" "$@" +SH + chmod +x "$home/fakebin/tasks-axi" + ;; + esac + start=$(PATH="$home/fakebin:$PATH" REAL_TASKS_AXI="$TASKS_AXI_BIN" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + FM_BOOTSTRAP_NETWORK=skip "$ROOT/bin/fm-session-start.sh" 2>&1 || true) + assert_contains "$start" "$quoted_reason" "startup $mode changed the encoded reason" + assert_contains "$start" 'Investigate literal %28' "startup $mode changed the title" + assert_contains "$start" "$legacy" "startup $mode changed the legacy reason" + assert_contains "$start" '"fm-hold-v1:bm9ydGg="' "startup $mode decoded a reason twice" + for malformed in "${malformed_reasons[@]}"; do + assert_contains "$start" "$malformed" "startup $mode changed historical literal $malformed" + done + if [ "$mode" = fallback ]; then + assert_contains "$start" 'read failed: literal %28 and fm-hold-v1:bm9ydGg=' \ + "startup changed unrelated error text" + fi + done + rm "$home/fakebin/tasks-axi" + out=$(PATH="$home/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" \ + "$ROOT/bin/fm-afk-return.sh" check 2>&1 || true) + assert_contains "$out" "$quoted_reason" "return brief changed the encoded reason" + assert_contains "$out" "$quoted_title" "return brief changed the title" + assert_contains "$out" "$quoted_legacy" "return brief changed the legacy reason" + for malformed in "${malformed_reasons[@]}"; do + assert_contains "$out" "$malformed" "return brief changed historical literal $malformed" + done + pass "marked hold reasons round-trip through public reads, fleet, startup, and return without changing other fields" +} + +test_hold_reason_round_trips_awkward_characters +test_hold_origins_precede_backend_holds +test_historical_self_inventory_has_workable_repair +test_inventory_compares_backend_identities +test_origin_is_never_its_own_inventory_entry +test_complete_refuses_an_entry_held_for_another_origin test_uninventoried_report_decision_refuses_completion test_hold_decodes_a_bare_scalar_body_without_the_nonref_default test_retained_body_keeps_its_utf8_bytes