From 5dc6a11d39b16b55eeed674406a0efcd0ac822df Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Tue, 25 Aug 2026 20:00:32 +0800 Subject: [PATCH 1/9] fix(bin): resolve captain calls against the closed-task archive The completion gate resolved every entry in a task's decision_keys against data/backlog.md alone. tasks-axi prune moves closed tasks out of the backlog and into data/done-archive.md, so a finished investigation whose captain calls were correctly answered failed its own gate permanently and its cleanup was refused for good. Observed on a real archived call whose answer was recorded in full. Both gates now look for each entry in the home's configured closed-task archive as well as its live backlog. Only the lookup is widened: what counts as answered is still decided by verify_hold_durable, so an archived call closed with no recorded captain answer keeps failing exactly as it did while live. An archive that exists but cannot be read refuses both gates by name, because 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. The archive rows are handed to tasks-axi to parse rather than read by a second parser here, so the row format keeps one owner. --- bin/fm-captain-hold.sh | 92 ++++++++++++++++-- docs/captain-hold-lifecycle.md | 4 +- tests/fm-captain-hold-lifecycle.test.sh | 120 ++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 7 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index cb429d95238..2e7897fc4cf 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -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. # @@ -228,6 +238,73 @@ task_show() { # 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. +archive_path() { + local configured='' + if [ -f "$FM_HOME/.tasks.toml" ]; then + configured=$(sed -n 's/^[[:space:]]*archive[[:space:]]*=[[: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() { # + 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() { # + local out + if out=$(task_show "$1"); then + printf '%s\n' "$out" + return 0 + fi + archived_task_show "$1" +} + show_field() { # local output=$1 field=$2 printf '%s\n' "$output" | sed -n "s/^ $field: //p" | head -1 @@ -344,7 +421,8 @@ resolution_block() { # # surviving even when a date gate has expired) or a recorded captain answer. verify_hold_durable() { # 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) @@ -361,19 +439,19 @@ verify_hold_durable() { # # exact task id when it exists, else the legacy derived identity. resolve_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() { @@ -723,7 +801,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) @@ -796,6 +874,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")" @@ -850,6 +929,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")" diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index cb8d5cea29a..a9c3dfd19ed 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -26,6 +26,8 @@ With a non-empty inventory it appends a `captain-held [key=]: tracked by /dev/null \ + || fail "could not create the answered-call origin" + write_origin_meta "$home" "$answered" + printf 'done: report complete\n' > "$home/state/$answered.status" + printf '# Archived review\n\nOne captain choice remained.\n' > "$home/data/$answered/report.md" + run_captain "$home" hold sample-archived-call \ + --title "Choose the archived option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not register the answered captain call" + run_captain "$home" complete "$answered" sample-archived-call >/dev/null \ + || fail "completion failed while the captain call was still live" + printf 'Take the northern route.\n' > "$home/answer.txt" + run_captain "$home" answer sample-archived-call --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not record the captain answer" + + # Direction two: held, inventoried, then closed with no recorded captain + # answer - the question the captain still owes. + dropped=sample-dropped-review + mkdir -p "$home/data/$dropped" + tasks_in "$home" add "$dropped" "Review the dropped path" \ + --kind scout --repo sample --start >/dev/null \ + || fail "could not create the dropped-call origin" + write_origin_meta "$home" "$dropped" + printf 'done: report complete\n' > "$home/state/$dropped.status" + printf '# Dropped review\n\nOne captain choice remained.\n' > "$home/data/$dropped/report.md" + run_captain "$home" hold sample-dropped-call \ + --title "Choose the dropped option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not register the dropped captain call" + run_captain "$home" complete "$dropped" sample-dropped-call >/dev/null \ + || fail "completion failed while the dropped call was still live" + tasks_in "$home" "done" sample-dropped-call >/dev/null \ + || fail "could not close the dropped call outside the answer path" + + tasks_in "$home" prune --keep 0 --state "done" >/dev/null \ + || fail "could not archive the closed captain calls" + if tasks_in "$home" show sample-archived-call >/dev/null 2>&1; then + fail "setup error: the answered call is still in the live backlog" + fi + assert_grep "sample-archived-call" "$home/data/done-archive.md" "the answered call was not archived" + assert_grep "sample-dropped-call" "$home/data/done-archive.md" "the dropped call was not archived" + + run_captain "$home" verify "$answered" >/dev/null 2> "$home/verify-answered.err" \ + || fail "an answered, archived captain call failed its own completion gate: $(cat "$home/verify-answered.err")" + run_captain "$home" complete "$answered" sample-archived-call >/dev/null 2>&1 \ + || fail "a later completion pass failed on an answered, archived captain call" + run_teardown "$home" "$answered" >/dev/null 2> "$home/teardown.err" \ + || fail "cleanup was refused for an answered, archived captain call: $(cat "$home/teardown.err")" + + set +e + run_captain "$home" verify "$dropped" > "$home/verify-dropped.out" 2> "$home/verify-dropped.err" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "an archived captain call closed with no recorded answer passed the gate: $(cat "$home/verify-dropped.out")" + assert_grep "recorded captain answer" "$home/verify-dropped.err" \ + "the refusal must name the missing captain answer" + set +e + run_teardown "$home" "$dropped" > "$home/teardown-dropped.out" 2> "$home/teardown-dropped.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "cleanup erased an investigation whose archived captain call was never answered" + assert_grep "fm-captain-hold.sh" "$home/teardown-dropped.err" \ + "cleanup must be refused by the captain-call gate, not incidentally" + + # An archive that cannot be read is not evidence that everything was + # answered. The gate must refuse and say so. + unreadable=sample-unreadable-review + mkdir -p "$home/data/$unreadable" + tasks_in "$home" add "$unreadable" "Review the unreadable path" \ + --kind scout --repo sample --start >/dev/null \ + || fail "could not create the unreadable-archive origin" + write_origin_meta "$home" "$unreadable" + printf 'done: report complete\n' > "$home/state/$unreadable.status" + run_captain "$home" hold sample-unreadable-call \ + --title "Choose the unreadable option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not register the unreadable-archive captain call" + run_captain "$home" complete "$unreadable" sample-unreadable-call >/dev/null \ + || fail "completion failed while the unreadable-archive call was still live" + printf 'Take the southern route.\n' > "$home/answer2.txt" + run_captain "$home" answer sample-unreadable-call --decision-file "$home/answer2.txt" >/dev/null \ + || fail "could not record the second captain answer" + tasks_in "$home" prune --keep 0 --state "done" >/dev/null \ + || fail "could not archive the second answered call" + run_captain "$home" verify "$unreadable" >/dev/null 2>&1 \ + || fail "setup error: the second answered call should pass with a readable archive" + chmod 000 "$home/data/done-archive.md" + if [ -r "$home/data/done-archive.md" ]; then + chmod 644 "$home/data/done-archive.md" + echo "skip: this user can read a mode-000 archive; unreadable-archive case not exercised" + else + set +e + run_captain "$home" verify "$unreadable" > "$home/verify-unreadable.out" 2> "$home/verify-unreadable.err" + rc=$? + set -e + chmod 644 "$home/data/done-archive.md" + [ "$rc" -ne 0 ] \ + || fail "an unreadable archive was read as proof the captain owed nothing: $(cat "$home/verify-unreadable.out")" + assert_grep "cannot read the closed-task archive" "$home/verify-unreadable.err" \ + "the refusal must say the archive could not be read, not that the call was absent" + fi + pass "archived captain calls resolve, unanswered and unreadable ones still refuse" +} + test_uninventoried_report_decision_refuses_completion test_completion_gate_attests_and_transfers test_answer_records_and_closes @@ -1185,3 +1304,4 @@ test_chat_channel_feeds_the_same_keyed_answer_intake test_origin_slug_validation_precedes_path_construction test_status_resolution_over_an_open_hold_is_signalled test_legitimate_holds_produce_no_divergence_signal +test_archived_captain_calls_resolve_without_waving_work_through From 2a958ff103b7aa797aced12ed0d39eff2ee436eb Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Tue, 25 Aug 2026 20:19:29 +0800 Subject: [PATCH 2/9] no-mistakes(document): docs: refresh captain-hold verification date --- docs/captain-hold-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index a9c3dfd19ed..d1ccfef17bd 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -86,7 +86,7 @@ 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); 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); 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). From 88fe1b9aed057e9a32c6afc4c8b5c695c457731f Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Tue, 25 Aug 2026 22:06:14 +0800 Subject: [PATCH 3/9] fix(bin): read the configured archive the way tasks-axi reads it archive_path matched only a double-quoted value with nothing after it, so a home configuring its archive as a TOML literal string, or with an inline comment, silently fell back to data/done-archive.md. The gate then resolved captain calls against a different file than the one tasks-axi prunes into, and a correctly answered, pruned call failed complete, verify, and non-forced teardown anyway - the same defect this lookup exists to remove, wearing a config-parsing disguise. Verified against tasks-axi 0.2.5 which form it actually honours: basic (double-quoted) strings, literal (single-quoted) strings, and an inline comment after either all select the configured archive. All four are now read. The value is matched by its own quote delimiters rather than to end-of-line, so a '#' inside the path is not mistaken for a comment. The new regression drives the real prune through every honoured form and asserts the gate and teardown both accept a call archived at the configured path. It fails on the previous parser at the single-quoted form, reporting the default path in its refusal. --- bin/fm-captain-hold.sh | 15 +++++- tests/fm-captain-hold-lifecycle.test.sh | 68 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 2e7897fc4cf..7d3185426e2 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -241,10 +241,23 @@ task_show() { # # 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 's/^[[:space:]]*archive[[:space:]]*=[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' \ + 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 diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index aebd7f636ef..32fdd5d0343 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1287,6 +1287,73 @@ test_archived_captain_calls_resolve_without_waving_work_through() { pass "archived captain calls resolve, unanswered and unreadable ones still refuse" } +# The archive the gate resolves against is whichever one tasks-axi actually +# writes, so this home must read that setting the way tasks-axi does. tasks-axi +# accepts a double-quoted path, a single-quoted (TOML literal) path, and an +# inline comment after either. Reading only the first form makes the gate fall +# back to the default path and miss a correctly answered, pruned call - the very +# failure this suite exists to prevent, reappearing as a config misparse. +test_configured_archive_is_read_the_way_tasks_axi_reads_it() { + local home origin call form line archive rc + form=0 + while IFS='|' read -r line archive; do + [ -n "$line" ] || continue + form=$((form + 1)) + home=$(make_home "archive-toml-$form") + cat > "$home/.tasks.toml" </dev/null \ + || fail "form $form: could not create the origin" + write_origin_meta "$home" "$origin" + printf 'done: report complete\n' > "$home/state/$origin.status" + printf '# Configured archive review\n\nOne captain choice remained.\n' \ + > "$home/data/$origin/report.md" + run_captain "$home" hold "$call" \ + --title "Choose the configured option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "form $form: could not register the captain call" + run_captain "$home" complete "$origin" "$call" >/dev/null \ + || fail "form $form: completion failed while the call was still live" + printf 'Take the configured route.\n' > "$home/answer.txt" + run_captain "$home" answer "$call" --decision-file "$home/answer.txt" >/dev/null \ + || fail "form $form: could not record the captain answer" + tasks_in "$home" prune --keep 0 --state "done" >/dev/null \ + || fail "form $form: could not archive the answered call" + + # tasks-axi honours this form: it must have written the configured archive, + # not the default one. If this fails the fixture is wrong, not the gate. + assert_present "$home/$archive" "form $form: tasks-axi did not write the configured archive" + if [ -e "$home/data/done-archive.md" ]; then + fail "form $form: setup error - tasks-axi used the default archive path" + fi + + set +e + run_captain "$home" verify "$origin" > "$home/verify.out" 2> "$home/verify.err" + rc=$? + set -e + [ "$rc" -eq 0 ] \ + || fail "form $form ($line): an answered call archived at the CONFIGURED path failed the gate: $(cat "$home/verify.err")" + run_teardown "$home" "$origin" >/dev/null 2> "$home/teardown.err" \ + || fail "form $form ($line): cleanup was refused for a call archived at the configured path: $(cat "$home/teardown.err")" + done <<'EOF' +archive = "data/arc-basic.md"|data/arc-basic.md +archive = 'data/arc-literal.md'|data/arc-literal.md +archive = "data/arc-comment.md" # where closed tasks go|data/arc-comment.md +archive = 'data/arc-literal-comment.md' # where closed tasks go|data/arc-literal-comment.md +EOF + [ "$form" -eq 4 ] || fail "expected 4 configured-archive forms, exercised $form" + pass "the configured archive is read for every TOML form tasks-axi honours" +} + test_uninventoried_report_decision_refuses_completion test_completion_gate_attests_and_transfers test_answer_records_and_closes @@ -1305,3 +1372,4 @@ test_origin_slug_validation_precedes_path_construction test_status_resolution_over_an_open_hold_is_signalled test_legitimate_holds_produce_no_divergence_signal test_archived_captain_calls_resolve_without_waving_work_through +test_configured_archive_is_read_the_way_tasks_axi_reads_it From b8b1dc540902d43fcd052b87e739be459e3bf32b Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Tue, 25 Aug 2026 22:26:41 +0800 Subject: [PATCH 4/9] no-mistakes(document): document configured archive lookup in captain-hold lifecycle --- docs/captain-hold-lifecycle.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index d1ccfef17bd..5b7d328cd12 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -28,6 +28,7 @@ Scout teardown calls the read-only `verify` subcommand after checking for the re `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 @@ -89,7 +90,7 @@ The shim recognizes an exact replay of a pre-collapse routed resolution by its h 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); 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); 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. From 6fee41870500288048a8c81579ae073ce61bee8d Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Wed, 26 Aug 2026 08:42:04 +0800 Subject: [PATCH 5/9] no-mistakes(review): resolve archived captain calls to their newest archived row --- bin/fm-captain-hold.sh | 18 +++++- docs/captain-hold-lifecycle.md | 3 +- tests/fm-captain-hold-lifecycle.test.sh | 73 +++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index 7d3185426e2..e2e2db89752 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -284,13 +284,29 @@ require_readable_archive() { # 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. +# +# An id can appear in more than one section: prune appends a section per run, +# and a home is free to reuse an id for a later call. tasks-axi returns the +# FIRST row carrying an id, so the sections are restaged newest-first. The +# newest row is the one describing that call's current durable state; resolving +# to an older one would let a long-settled answer stand in for a question the +# captain still owes. Only an unindented `## ` line starts a section - row +# bodies are indented - so splitting on that leaves each row with its body. +restage_archive_newest_first() { # + awk ' + /^## / { section++; next } + { block[section] = block[section] $0 "\n" } + END { for (i = section; i >= 0; i--) printf "%s", block[i] } + ' "$1" +} + archived_task_show() { # 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 + if ! { printf '## In flight\n\n## Queued\n\n## Done\n'; restage_archive_newest_first "$archive"; } > "$tmp" 2>/dev/null; then rm -f -- "$tmp" fail "cannot stage the closed-task archive $archive for lookup" fi diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 5b7d328cd12..e0397a700ed 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -28,6 +28,7 @@ Scout teardown calls the read-only `verify` subcommand after checking for the re `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. +An id can be reused once its earlier call is archived, so the archive can hold more than one row for it; the lookup resolves to the newest row, because that is the one describing the call's current durable state and an older answered row would otherwise wave through a question the captain still owes. 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. @@ -90,7 +91,7 @@ The shim recognizes an exact replay of a pre-collapse routed resolution by its h 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); 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). +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, an unreadable archive refuses by name, and a reused id resolves to its newest archived row rather than a stale answered one); 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. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 32fdd5d0343..b81324eed75 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1354,6 +1354,78 @@ EOF pass "the configured archive is read for every TOML form tasks-axi honours" } +# An id can carry more than one captain call over a home's life: prune appends +# a section per run, and a home is free to reuse an id once the earlier call is +# archived. tasks-axi returns the FIRST row carrying an id, so an archive read +# oldest-first answers with the long-settled call and its recorded answer - +# waving through a later question the captain never answered. The newest row is +# the one that describes the call's current durable state. +test_reused_id_resolves_to_the_newest_archived_row() { + local home first second call rc + home=$(make_home reused-archived-id) + call=sample-reused-call + + # The first life of the id: held, answered, closed and archived. + first=sample-first-review + mkdir -p "$home/data/$first" + tasks_in "$home" add "$first" "Review the first path" \ + --kind scout --repo sample --start >/dev/null \ + || fail "could not create the first origin" + write_origin_meta "$home" "$first" + printf 'done: report complete\n' > "$home/state/$first.status" + printf '# First review\n\nOne captain choice remained.\n' > "$home/data/$first/report.md" + run_captain "$home" hold "$call" \ + --title "Choose the first option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not register the first captain call" + run_captain "$home" complete "$first" "$call" >/dev/null \ + || fail "completion failed while the first call was still live" + printf 'Take the northern route.\n' > "$home/answer.txt" + run_captain "$home" answer "$call" --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not record the first captain answer" + tasks_in "$home" prune --keep 0 --state "done" >/dev/null \ + || fail "could not archive the first captain call" + + # The second life of the same id: held for a later investigation, never + # answered, closed outside the answer path and archived in its own section. + second=sample-second-review + mkdir -p "$home/data/$second" + tasks_in "$home" add "$second" "Review the second path" \ + --kind scout --repo sample --start >/dev/null \ + || fail "could not create the second origin" + write_origin_meta "$home" "$second" + printf 'done: report complete\n' > "$home/state/$second.status" + printf '# Second review\n\nOne captain choice remained.\n' > "$home/data/$second/report.md" + run_captain "$home" hold "$call" \ + --title "Choose the second option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not re-register the reused captain call" + run_captain "$home" complete "$second" "$call" >/dev/null \ + || fail "completion failed while the second call was still live" + tasks_in "$home" "done" "$call" >/dev/null \ + || fail "could not close the second call outside the answer path" + tasks_in "$home" prune --keep 0 --state "done" >/dev/null \ + || fail "could not archive the second captain call" + + [ "$(grep -c "^- \[x\] $call " "$home/data/done-archive.md")" -eq 2 ] \ + || fail "setup error: the archive does not hold two rows for the reused id" + + set +e + run_captain "$home" verify "$second" > "$home/verify-second.out" 2> "$home/verify-second.err" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "a stale archived answer waved through a captain call that was never answered: $(cat "$home/verify-second.out")" + assert_grep "recorded captain answer" "$home/verify-second.err" \ + "the refusal must name the missing captain answer" + + set +e + run_teardown "$home" "$second" > "$home/teardown-second.out" 2> "$home/teardown-second.err" + rc=$? + set -e + [ "$rc" -ne 0 ] \ + || fail "cleanup erased an investigation whose reused captain call was never answered" + pass "a reused archived id resolves to its newest row, not a stale answered one" +} + test_uninventoried_report_decision_refuses_completion test_completion_gate_attests_and_transfers test_answer_records_and_closes @@ -1373,3 +1445,4 @@ test_status_resolution_over_an_open_hold_is_signalled test_legitimate_holds_produce_no_divergence_signal test_archived_captain_calls_resolve_without_waving_work_through test_configured_archive_is_read_the_way_tasks_axi_reads_it +test_reused_id_resolves_to_the_newest_archived_row From 61a67f3fd3b78f43335d86fcdf6147bdb24dc077 Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Wed, 26 Aug 2026 08:55:58 +0800 Subject: [PATCH 6/9] no-mistakes(document): refresh captain-hold verification date for newest-archived-row cover --- docs/captain-hold-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index e0397a700ed..7b322684bd6 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -88,7 +88,7 @@ The shim recognizes an exact replay of a pre-collapse routed resolution by its h ## Verification record -Verification date: 2026-08-25. +Verification date: 2026-08-26. 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); 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, an unreadable archive refuses by name, and a reused id resolves to its newest archived row rather than a stale answered one); 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). From 0caf4ac2543f2fa1eea6569950505a417844c106 Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Wed, 26 Aug 2026 09:07:56 +0800 Subject: [PATCH 7/9] fix(bin): read the archive setting from the [markdown] table only An `archive` key belongs to whichever TOML table heading precedes it, so a home is free to carry one in another table or in the root table above the first heading, and tasks-axi ignores both. `archive_path` took the first `archive` line in .tasks.toml regardless of heading, so such a config pointed the completion gate at a file tasks-axi never prunes into: a correctly answered, pruned captain call was not found and `complete`, `verify`, and non-forced teardown failed forever - the defect this lookup exists to remove, reappearing as a table-scope misparse. The setting is now read only from lines inside `[markdown]`, which is where tasks-axi reads it from. Verified against tasks-axi 0.2.5: a root-table `archive` and an `archive` in another table are both ignored by tasks-axi, which prunes into `[markdown] archive`. The new regression drives a real prune with decoy `archive` keys in the root table and in an earlier table, and asserts the gate and teardown accept the call archived at the `[markdown]` path. It was watched failing against the previous parser, refused while naming the root decoy path. --- bin/fm-captain-hold.sh | 21 ++++++++- docs/captain-hold-lifecycle.md | 3 +- tests/fm-captain-hold-lifecycle.test.sh | 63 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/bin/fm-captain-hold.sh b/bin/fm-captain-hold.sh index e2e2db89752..24620729a80 100755 --- a/bin/fm-captain-hold.sh +++ b/bin/fm-captain-hold.sh @@ -252,13 +252,30 @@ task_show() { # # 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. +# +# The setting also has to be read from the table tasks-axi reads it from. An +# `archive` key belongs to whichever table heading precedes it, so any other +# table - or the root table above the first heading - is free to carry one of +# its own, and tasks-axi ignores those. Taking the first `archive` line in the +# file regardless of heading would point the gate at that unrelated value and +# reopen the same miss, so only lines inside `[markdown]` are considered here. archive_path() { local configured='' if [ -f "$FM_HOME/.tasks.toml" ]; then - configured=$(sed -n \ + configured=$(awk ' + /^[[:space:]]*\[/ { + heading = $0 + sub(/^[[:space:]]*\[/, "", heading) + sub(/\].*$/, "", heading) + gsub(/[[:space:]]/, "", heading) + in_markdown = (heading == "markdown") + next + } + in_markdown { print } + ' "$FM_HOME/.tasks.toml" | sed -n \ -e 's/^[[:space:]]*archive[[:space:]]*=[[:space:]]*"\([^"]*\)".*$/\1/p' \ -e "s/^[[:space:]]*archive[[:space:]]*=[[:space:]]*'\([^']*\)'.*\$/\1/p" \ - "$FM_HOME/.tasks.toml" | head -1) + | head -1) fi [ -n "$configured" ] || configured=data/done-archive.md case "$configured" in diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index 7b322684bd6..4afe2fa026f 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -30,6 +30,7 @@ Both gates look for each entry in the home's closed-task archive as well as its 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. An id can be reused once its earlier call is archived, so the archive can hold more than one row for it; the lookup resolves to the newest row, because that is the one describing the call's current durable state and an older answered row would otherwise wave through a question the captain still owes. 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. +That setting is read from the `[markdown]` table only, because an `archive` key belongs to the table heading above it: one in any other table, or in the root table before the first heading, is ignored by tasks-axi and reading it here would point the gate at a file tasks-axi never prunes into. The `--force` path remains the explicit captain-approved discard escape hatch. ## Answer-time closure @@ -91,7 +92,7 @@ The shim recognizes an exact replay of a pre-collapse routed resolution by its h Verification date: 2026-08-26. 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); 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, an unreadable archive refuses by name, and a reused id resolves to its newest archived row rather than a stale answered one); 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). +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, an unreadable archive refuses by name, and a reused id resolves to its newest archived row rather than a stale answered one); 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; the archive setting is read from the `[markdown]` table only, with decoy `archive` keys in the root table and in another table left ignored exactly as tasks-axi ignores them; 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. diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index b81324eed75..6959c3a4a41 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -1354,6 +1354,68 @@ EOF pass "the configured archive is read for every TOML form tasks-axi honours" } +# An `archive` key belongs to the table heading above it, so a home is free to +# carry one in some other table - or in the root table before the first heading +# - and tasks-axi ignores both. Reading the first `archive` line in the file +# regardless of heading points the gate at that unrelated value, so a correctly +# answered call pruned into the REAL archive is not found and fails the gate: +# the same miss this suite exists to prevent, this time through a decoy key. +test_archive_setting_is_read_from_the_markdown_table_only() { + local home origin call rc + home=$(make_home archive-toml-table-scope) + cat > "$home/.tasks.toml" <<'EOF' +backend = "markdown" +archive = "data/root-decoy-archive.md" + +[other] +archive = "data/other-decoy-archive.md" + +[markdown] +path = "data/backlog.md" +archive = "data/scoped-archive.md" +done_keep = 10 +EOF + origin=sample-scoped-review + call=sample-scoped-call + mkdir -p "$home/data/$origin" + tasks_in "$home" add "$origin" "Review the scoped archive" \ + --kind scout --repo sample --start >/dev/null \ + || fail "could not create the table-scope origin" + write_origin_meta "$home" "$origin" + printf 'done: report complete\n' > "$home/state/$origin.status" + printf '# Scoped archive review\n\nOne captain choice remained.\n' \ + > "$home/data/$origin/report.md" + run_captain "$home" hold "$call" \ + --title "Choose the scoped option" --reason "captain choice pending" --repo sample >/dev/null \ + || fail "could not register the table-scope captain call" + run_captain "$home" complete "$origin" "$call" >/dev/null \ + || fail "completion failed while the table-scope call was still live" + printf 'Take the scoped route.\n' > "$home/answer.txt" + run_captain "$home" answer "$call" --decision-file "$home/answer.txt" >/dev/null \ + || fail "could not record the table-scope captain answer" + tasks_in "$home" prune --keep 0 --state "done" >/dev/null \ + || fail "could not archive the table-scope answered call" + + # tasks-axi ignores both decoys and prunes into `[markdown] archive`. If this + # fails the fixture no longer matches tasks-axi, not the gate. + assert_present "$home/data/scoped-archive.md" \ + "tasks-axi did not prune into the [markdown] archive" + assert_absent "$home/data/root-decoy-archive.md" \ + "setup error - tasks-axi honoured a root-table archive key" + assert_absent "$home/data/other-decoy-archive.md" \ + "setup error - tasks-axi honoured another table's archive key" + + set +e + run_captain "$home" verify "$origin" > "$home/verify.out" 2> "$home/verify.err" + rc=$? + set -e + [ "$rc" -eq 0 ] \ + || fail "a decoy archive key outside [markdown] made the gate miss an answered, pruned call: $(cat "$home/verify.err")" + run_teardown "$home" "$origin" >/dev/null 2> "$home/teardown.err" \ + || fail "cleanup was refused because a decoy archive key outside [markdown] was read: $(cat "$home/teardown.err")" + pass "the archive setting is read from the [markdown] table only" +} + # An id can carry more than one captain call over a home's life: prune appends # a section per run, and a home is free to reuse an id once the earlier call is # archived. tasks-axi returns the FIRST row carrying an id, so an archive read @@ -1445,4 +1507,5 @@ test_status_resolution_over_an_open_hold_is_signalled test_legitimate_holds_produce_no_divergence_signal test_archived_captain_calls_resolve_without_waving_work_through test_configured_archive_is_read_the_way_tasks_axi_reads_it +test_archive_setting_is_read_from_the_markdown_table_only test_reused_id_resolves_to_the_newest_archived_row From 830a44c6b70e25ccba0b6364b148e13ef8118429 Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Wed, 26 Aug 2026 10:04:57 +0800 Subject: [PATCH 8/9] no-mistakes: apply CI fixes --- .tmp-ax/.tasks.toml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .tmp-ax/.tasks.toml diff --git a/.tmp-ax/.tasks.toml b/.tmp-ax/.tasks.toml new file mode 100644 index 00000000000..37076b0527f --- /dev/null +++ b/.tmp-ax/.tasks.toml @@ -0,0 +1,5 @@ +backend = "markdown" + +[markdown] +path = "data/backlog.md" +done_keep = 10 From 8888809d38ff4d3c7535b7d3ab84c89c6037abfe Mon Sep 17 00:00:00 2001 From: Adam Gardner Date: Wed, 26 Aug 2026 12:45:55 +0800 Subject: [PATCH 9/9] fix: drop the scratch .tmp-ax/.tasks.toml from the branch `.tmp-ax/.tasks.toml` was added by an automated CI-fix commit. It is a bare tasks-axi probe config - backend, path and done_keep, with no `archive` key - of the same shape a throwaway fixture takes while probing tasks-axi behaviour, and it appears to have leaked out of a temporary directory created inside the repository. Nothing uses it. There is no reference to `.tmp-ax` anywhere in the tree, the path had never appeared in history before that commit, it exists in no checkout on this machine, and none of the tasks-axi, no-mistakes, lavish-axi or quota-axi binaries contain the string. Every test that needs a backlog config copies the tracked root `.tasks.toml` into its own fixture home. Its absence was proven harmless rather than assumed: the tree was extracted at the commit that added it, the path was deleted, and tests/fm-captain-hold-lifecycle.test.sh was run against that tree - 21 passes, exit 0. No ignore rule is added for it. Nothing in this repository or its tooling creates `.tmp-ax`, so a rule would be a guess with no verifiable reason behind it, and an ignore rule would hide a recurrence rather than surface it. Agent scratch belongs outside the repository, which is already the convention. --- .tmp-ax/.tasks.toml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .tmp-ax/.tasks.toml diff --git a/.tmp-ax/.tasks.toml b/.tmp-ax/.tasks.toml deleted file mode 100644 index 37076b0527f..00000000000 --- a/.tmp-ax/.tasks.toml +++ /dev/null @@ -1,5 +0,0 @@ -backend = "markdown" - -[markdown] -path = "data/backlog.md" -done_keep = 10