Skip to content
Open
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
105 changes: 99 additions & 6 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@
# identity, so pre-collapse metadata written by fm-decision-hold.sh verifies
# unchanged. An entry that exists as a task id is always that task.
#
# Both gates resolve every entry against this home's closed-task archive
# (tasks-axi's `[markdown] archive`) as well as its live backlog, because
# `tasks-axi prune` moves closed tasks out of the backlog and an answered
# captain call is exactly as durable once it has moved. Resolution is the only
# thing widened: a call closed with no recorded captain answer still fails the
# gate wherever it lives. An archive this home cannot READ refuses both gates
# by name, since a check that could not look must never be recorded as a check
# that found nothing outstanding; an archive that does not exist yet is not an
# error and simply carries no tasks.
#
# `diverged` is the read-only guard over the seam between the two records of
# one captain call. See "record divergence" beside command_diverged below.
#
Expand Down Expand Up @@ -228,6 +238,86 @@ task_show() { # <id>
tasks_axi show "$1" --full 2>/dev/null
}

# The closed-task archive beside this home's backlog, as an absolute path.
# tasks-axi's own `[markdown] archive` setting is the owner; the default below
# only covers a home whose config omits it.
#
# This must read the setting the way tasks-axi does, or the gate resolves
# against a DIFFERENT file than the one tasks-axi prunes into and a correctly
# answered call fails the gate anyway - the same defect this lookup exists to
# remove, wearing a config-parsing disguise. tasks-axi honours a basic
# (double-quoted) string, a literal (single-quoted) string, and an inline
# comment after either, so all of those are read here. Matching the value by
# its own quote delimiters rather than to end-of-line is what keeps a `#`
# INSIDE the path from being mistaken for the start of a comment. An escaped
# quote within a basic string is not decoded; that needs a real TOML parser,
# and no such path has ever been configured here.
archive_path() {
local configured=''
if [ -f "$FM_HOME/.tasks.toml" ]; then
configured=$(sed -n \
-e 's/^[[:space:]]*archive[[:space:]]*=[[:space:]]*"\([^"]*\)".*$/\1/p' \
-e "s/^[[:space:]]*archive[[:space:]]*=[[:space:]]*'\([^']*\)'.*\$/\1/p" \
"$FM_HOME/.tasks.toml" | head -1)
fi
[ -n "$configured" ] || configured=data/done-archive.md
case "$configured" in
/*) printf '%s' "$configured" ;;
*) printf '%s/%s' "$FM_HOME" "$configured" ;;
esac
}

# An unreadable archive is missing evidence, not evidence of absence. Every
# path that resolves captain calls against the archive calls this first, so
# "the gate could not look" can never be recorded as "the captain owes
# nothing". An archive that simply does not exist is not an error: this home
# has pruned nothing yet, and the lookup below correctly finds no task.
require_readable_archive() {
local archive
archive=$(archive_path)
[ -e "$archive" ] || return 0
[ -r "$archive" ] \
|| fail "cannot read the closed-task archive $archive; refusing to resolve captain calls against an archive this home cannot read"
}

# One task from the archive, parsed by tasks-axi rather than by a second reader
# here, so the row format keeps exactly one owner. The archive holds the same
# rows under dated `## Archived` headings, so they are restaged under the
# section heading tasks-axi reads before it is asked for the task.
archived_task_show() { # <id>
local id=$1 archive tmp out
archive=$(archive_path)
[ -r "$archive" ] || return 1
tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-captain-hold-archive.XXXXXX") \
|| fail "cannot stage the closed-task archive for lookup"
if ! { printf '## In flight\n\n## Queued\n\n## Done\n'; sed '/^## /d' "$archive"; } > "$tmp" 2>/dev/null; then
rm -f -- "$tmp"
fail "cannot stage the closed-task archive $archive for lookup"
fi
if out=$(tasks_axi show "$id" --file "$tmp" --full 2>/dev/null); then
rm -f -- "$tmp"
printf '%s\n' "$out"
return 0
fi
rm -f -- "$tmp"
return 1
}

# The task carrying an id wherever it durably lives. An answered captain call
# does not stay in the live backlog forever - tasks-axi prune moves closed
# tasks into the archive - and it is exactly as durable after that move. This
# widens WHERE a call is looked up and nothing else: what counts as answered is
# still decided by verify_hold_durable, so an archived call closed with no
# recorded captain answer keeps failing the gate exactly as it did while live.
task_show_durable() { # <id>
local out
if out=$(task_show "$1"); then
printf '%s\n' "$out"
return 0
fi
archived_task_show "$1"
}

show_field() { # <show-output> <field>
local output=$1 field=$2
printf '%s\n' "$output" | sed -n "s/^ $field: //p" | head -1
Expand Down Expand Up @@ -344,7 +434,8 @@ resolution_block() { # <mode>
# surviving even when a date gate has expired) or a recorded captain answer.
verify_hold_durable() { # <task-id>
local id=$1 show state hold_kind body
show=$(task_show "$id") || fail "captain-held task $id is absent from $FM_HOME/data/backlog.md"
show=$(task_show_durable "$id") \
|| fail "captain-held task $id is absent from $FM_HOME/data/backlog.md and from $(archive_path)"
state=$(show_field "$show" state)
hold_kind=$(show_field_value "$show" hold_kind)
body=$(show_field "$show" body)
Expand All @@ -361,19 +452,19 @@ verify_hold_durable() { # <task-id>
# exact task id when it exists, else the legacy derived identity.
resolve_entry() { # <origin-or-empty> <entry>; prints the resolved id or fails
local origin=$1 entry=$2 legacy
if task_show "$entry" >/dev/null 2>&1; then
if task_show_durable "$entry" >/dev/null 2>&1; then
printf '%s' "$entry"
return 0
fi
if [ -n "$origin" ] && [ "$origin" != "$BINDING_ANY" ]; then
legacy=$(legacy_hold_id "$origin" "$entry")
if task_show "$legacy" >/dev/null 2>&1; then
if task_show_durable "$legacy" >/dev/null 2>&1; then
printf '%s' "$legacy"
return 0
fi
fail "no captain-held task $entry and no legacy identity $legacy in $FM_HOME/data/backlog.md"
fail "no captain-held task $entry and no legacy identity $legacy in $FM_HOME/data/backlog.md or $(archive_path)"
fi
fail "no captain-held task $entry in $FM_HOME/data/backlog.md"
fail "no captain-held task $entry in $FM_HOME/data/backlog.md or $(archive_path)"
}

command_hold() {
Expand Down Expand Up @@ -723,7 +814,7 @@ command_answers() {
if [ -n "$legacy_key" ]; then
legacy_digest=$(sha256_text "$(legacy_keyed_decision_text "$source" "$legacy_key" "$answer" "$label")")
fi
show=$(task_show "$id") || { printf 'skipped: %s (absent)\n' "$id"; skipped=$((skipped + 1)); continue; }
show=$(task_show_durable "$id") || { printf 'skipped: %s (absent)\n' "$id"; skipped=$((skipped + 1)); continue; }
state=$(show_field "$show" state)
hold_kind=$(show_field_value "$show" hold_kind)
body=$(show_field "$show" body)
Expand Down Expand Up @@ -796,6 +887,7 @@ command_complete() {
fi
keys=$(sorted_key_union "$previous" "$supplied")
if [ -n "$keys" ]; then
require_readable_archive
while IFS= read -r entry; do
[ -n "$entry" ] || continue
verify_hold_durable "$(resolve_entry "$origin" "$entry")"
Expand Down Expand Up @@ -850,6 +942,7 @@ command_verify() {
[ "$reviewed" = 1 ] || fail "origin $origin has no completed captain-call inventory"
keys=$(meta_value "$meta" decision_keys)
if [ -n "$keys" ]; then
require_readable_archive
while IFS= read -r entry; do
[ -n "$entry" ] || continue
verify_hold_durable "$(resolve_entry "$origin" "$entry")"
Expand Down
7 changes: 5 additions & 2 deletions docs/captain-hold-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ With a non-empty inventory it appends a `captain-held [key=<key>]: tracked by <i

Scout teardown calls the read-only `verify` subcommand after checking for the report and before removing any source state.
`verify` requires the recorded attestation, requires every recorded inventory entry to still be durable (actively captain-held, or carrying a recorded answer), and fails on any keyed status decision that opened after the last `complete`, which makes re-running `complete` the repair.
Both gates look for each entry in the home's closed-task archive as well as its live backlog, because `tasks-axi prune` moves closed tasks out of the backlog while an answered captain call stays exactly as durable, and resolving against the backlog alone made a correctly answered investigation fail its own gate forever.
Only the lookup is widened, so an archived entry still has to carry a recorded answer to satisfy the gate; an archive the home cannot read refuses both gates by name rather than passing for want of evidence.
The archive is whichever file tasks-axi prunes into, read from its own `[markdown] archive` setting - a double- or single-quoted path, with or without an inline comment - and defaulting to `data/done-archive.md` when the setting is absent, so the gate never resolves against a different file than the one tasks-axi writes.
The `--force` path remains the explicit captain-approved discard escape hatch.

## Answer-time closure
Expand Down Expand Up @@ -84,10 +87,10 @@ The shim recognizes an exact replay of a pre-collapse routed resolution by its h

## Verification record

Verification date: 2026-08-21.
Verification date: 2026-08-25.

The focused end-to-end regression suite is `tests/fm-captain-hold-lifecycle.test.sh`, using only synthetic `sample` identities and decision text.
It proves: the reconstructed silent-divergence case is signalled - a status resolution over a still-open captain-held task reaches both `diverged` and the drain's `RECORD DIVERGENCE` section, under the collapsed and the legacy identity alike, while the backlog task, its hold, and the status log all survive the report unchanged and the printed hint names both reconciliation directions; the false-signal boundary holds - a captain call with no routed work item, a verified `captain-held` transfer, a still-open status decision, an already answered call, and an ordinary task whose keyed question was answered all stay silent; a report-only unresolved captain call refuses `--none` completion before teardown can erase the source; non-forced scout teardown always requires the durable inventory verification; the recorded-answer guard (a bare `tasks-axi done` close fails `verify` until `answer` records the captain's word, and an ordinary finished task cannot be dressed up as an answered call); answer-time closure through a bound channel with task-id keys, including the `release` close mode, mode-matched replay idempotence, and the refusal of drifted, mode-mismatched, absent, unheld, and already-closed keys; the chat channel reaching the same intake; deferral through `--until` leaving `captain_actionable` false until due; and every legacy path (composed identities through the shim, pre-collapse `decision_keys=` metadata, routed-resolution replay, and a concrete-origin binding).
It proves: the reconstructed silent-divergence case is signalled - a status resolution over a still-open captain-held task reaches both `diverged` and the drain's `RECORD DIVERGENCE` section, under the collapsed and the legacy identity alike, while the backlog task, its hold, and the status log all survive the report unchanged and the printed hint names both reconciliation directions; the false-signal boundary holds - a captain call with no routed work item, a verified `captain-held` transfer, a still-open status decision, an already answered call, and an ordinary task whose keyed question was answered all stay silent; a report-only unresolved captain call refuses `--none` completion before teardown can erase the source; non-forced scout teardown always requires the durable inventory verification; the recorded-answer guard (a bare `tasks-axi done` close fails `verify` until `answer` records the captain's word, and an ordinary finished task cannot be dressed up as an answered call); archive resolution in both directions (an answered call still passes `complete`, `verify`, and teardown after `prune` archives it, an archived call closed with no recorded answer still fails, and an unreadable archive refuses by name); the configured archive is read for every TOML form tasks-axi honours, with a real `prune` into each configured path still satisfying the gate and teardown; answer-time closure through a bound channel with task-id keys, including the `release` close mode, mode-matched replay idempotence, and the refusal of drifted, mode-mismatched, absent, unheld, and already-closed keys; the chat channel reaching the same intake; deferral through `--until` leaving `captain_actionable` false until due; and every legacy path (composed identities through the shim, pre-collapse `decision_keys=` metadata, routed-resolution replay, and a concrete-origin binding).

`tests/fm-classify-decision-key.test.sh` pins `status_key_closing_verb` itself: it separates a resolution from the durable-transfer close and from a still-open key, reports the last real transition across re-openings and both key positions, and treats a prose mention as no transition.

Expand Down
Loading
Loading