diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 43e667e5c..31e54a008 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -36,8 +36,8 @@ # Item bodies must use at least two leading spaces. The helper refuses a selected # item with a single-space or tab-indented continuation rather than risk leaving # it orphaned, because tasks-axi treats only two-or-more-space lines as body. -# The move needs compatible `tasks-axi` on PATH, including atomic multi-ID `mv` -# (introduced in 0.2.2). Bootstrap requires it fleet-wide, so this works +# The move needs compatible `tasks-axi` on PATH; docs/configuration.md "Backlog +# backend" owns that definition. Bootstrap requires it fleet-wide, so this works # everywhere; the `config/backlog-backend=manual` knob only governs firstmate's # own hand-editing of its own backlog, not this validated helper. Idempotent: # re-running converges. Atomic: on any move failure nothing moves. @@ -296,7 +296,7 @@ if [ "$FAILED" -ne 0 ]; then fi if ! fm_tasks_axi_compatible; then - echo "error: tasks-axi with atomic multi-ID mv support (0.2.2+) is required to move backlog items" >&2 + echo "error: tasks-axi 0.2.2+ with lossless body replacement and atomic multi-ID mv is required to move backlog items" >&2 exit 1 fi diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index f8f767637..eb79f6aad 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -47,9 +47,9 @@ # no-mistakes is also MISSING when its installed version is older than # 1.31.2. # tasks-axi and quota-axi are required bootstrap tools (same class as -# lavish-axi). tasks-axi is also version and feature gated (0.1.1+ -# with update --archive-body and mv [...]); an installed but incompatible build -# reports MISSING like no-mistakes. When +# lavish-axi). docs/configuration.md "Backlog backend" owns the +# tasks-axi compatibility definition; an installed but incompatible +# build reports MISSING like no-mistakes. When # config/backlog-backend is not manual and tasks-axi is compatible, # bootstrap prints TASKS_AXI: available. quota-axi is required because # crew-dispatch quota-balanced may call it; fm-dispatch-select.sh still diff --git a/bin/fm-tasks-axi-lib.sh b/bin/fm-tasks-axi-lib.sh index 54d02fcc9..a02d7f437 100644 --- a/bin/fm-tasks-axi-lib.sh +++ b/bin/fm-tasks-axi-lib.sh @@ -2,10 +2,8 @@ # Shared tasks-axi backend selection and compatibility probe for bootstrap, # teardown, and secondmate backlog handoff. # Usage: . bin/fm-tasks-axi-lib.sh -# Compatible means tasks-axi --version reports 0.1.1 or newer, -# `tasks-axi update --help` exposes --archive-body for recoverable note rewrites, -# and `tasks-axi mv --help` exposes [...] for atomic multi-ID moves required -# by secondmate handoffs (introduced in tasks-axi 0.2.2). +# docs/configuration.md "Backlog backend" owns the compatibility definition; +# this library implements its shared version-and-feature probe. # `config/backlog-backend=manual` opts out of tasks-axi for routine firstmate # backlog mutations, but validated secondmate handoffs always use `tasks-axi mv`. # Absent or any other value keeps the default tasks-axi backend path, falling @@ -30,19 +28,29 @@ fm_tasks_axi_compatible() { patch=${rest##* } if [ "$major" -gt 0 ] || - { [ "$major" -eq 0 ] && [ "$minor" -gt 1 ]; } || - { [ "$major" -eq 0 ] && [ "$minor" -eq 1 ] && [ "$patch" -ge 1 ]; }; then - fm_tasks_axi_update_has_archive_body && fm_tasks_axi_mv_has_multi_id + { [ "$major" -eq 0 ] && [ "$minor" -gt 2 ]; } || + { [ "$major" -eq 0 ] && [ "$minor" -eq 2 ] && [ "$patch" -ge 2 ]; }; then + fm_tasks_axi_show_has_full && + fm_tasks_axi_update_has_lossless_body && + fm_tasks_axi_mv_has_multi_id return $? fi return 1 } -fm_tasks_axi_update_has_archive_body() { +fm_tasks_axi_show_has_full() { + local output + command -v tasks-axi >/dev/null 2>&1 || return 1 + output=$(tasks-axi show --help 2>&1) || return 1 + printf '%s\n' "$output" | grep -F -- '--full' >/dev/null +} + +fm_tasks_axi_update_has_lossless_body() { local output command -v tasks-axi >/dev/null 2>&1 || return 1 output=$(tasks-axi update --help 2>&1) || return 1 - printf '%s\n' "$output" | grep -F -- '--archive-body' >/dev/null + printf '%s\n' "$output" | grep -F -- '--body-file' >/dev/null && + printf '%s\n' "$output" | grep -F -- '--archive-body' >/dev/null } fm_tasks_axi_mv_has_multi_id() { diff --git a/docs/configuration.md b/docs/configuration.md index abdd1b281..3cfc71cc8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,8 +30,9 @@ Secondmate handoffs are separate and unconditional: `fm-backlog-handoff.sh` keep It moves in-scope `## Queued` items only and refuses `## In flight` and historical `## Done` records, which stay with their home for pruning or archiving. Handoff item bodies must use at least two leading spaces, and the helper refuses a selected item with a single-space or tab-indented continuation rather than risk orphaning it. Because bootstrap requires `tasks-axi` on `PATH` on every profile, that delegation works fleet-wide, and the `config/backlog-backend=manual` knob governs firstmate's own hand-editing of its backlog, not this validated helper. -Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.1.1 or newer, `tasks-axi update --help` exposes `--archive-body`, and `tasks-axi mv --help` exposes `[...]` for the atomic multi-ID move introduced in 0.2.2 and required by handoff delegation. +Compatible means the shared bootstrap probe accepts `tasks-axi --version` as 0.2.2 or newer, `tasks-axi show --help` exposes `--full` for lossless reads, `tasks-axi update --help` exposes lossless `--body-file` replacement plus `--archive-body`, and `tasks-axi mv --help` exposes `[...]` for atomic multi-ID handoffs. That sentence is the single owner of the tasks-axi compatibility definition; every other document points here instead of restating the version gates. +The compatible CLI has no `update --append` contract: inspect with `tasks-axi show --full`, then replace the considered body with `tasks-axi update --body-file `, adding `--archive-body` when the superseded body should remain recoverable. Bootstrap requires compatible `tasks-axi` on every profile; see "Toolchain" below for missing-tool reporting and `TASKS_AXI: available` behavior. Set the local, gitignored `config/backlog-backend` file to `manual` to force manual backlog editing and suppress `TASKS_AXI: available`, not missing-tool reporting. Absent or `tasks-axi` selects the default tasks-axi backend. diff --git a/tests/fm-backend-tmux-smoke.test.sh b/tests/fm-backend-tmux-smoke.test.sh index 0d47b83ec..1e02d4755 100755 --- a/tests/fm-backend-tmux-smoke.test.sh +++ b/tests/fm-backend-tmux-smoke.test.sh @@ -46,7 +46,7 @@ TARGET="$SESSION:$WINDOW" # --- create session ---------------------------------------------------------- -tmux new-session -d -s "$SESSION" -x 200 -y 50 \ +SHELL=/bin/sh tmux new-session -d -s "$SESSION" -x 200 -y 50 \ || fail "real tmux: new-session failed" fm_backend_tmux_create_task "$SESSION" "$WINDOW" "$HOME" \ || fail "fm_backend_tmux_create_task failed to create the task window" diff --git a/tests/fm-backlog-handoff.test.sh b/tests/fm-backlog-handoff.test.sh index 9e10703e8..8ffe5502a 100755 --- a/tests/fm-backlog-handoff.test.sh +++ b/tests/fm-backlog-handoff.test.sh @@ -55,6 +55,12 @@ assert_block_equals() { fi } +task_detail() { + local file=$1 key=$2 + tasks-axi show "$key" --full --file "$file" | + awk '/^help\[[0-9]+\]:/ { exit } { print }' +} + test_body_moves_when_followed_by_another_item() { local home="$TMP_ROOT/body-next-item-main" local sub="$TMP_ROOT/body-next-item-sub" @@ -472,6 +478,99 @@ EOF pass "multi-paragraph body with internal blank lines moves whole and is idempotent" } +test_structured_task_round_trip_preserves_fields_and_neighbors() { + local home="$TMP_ROOT/structured-roundtrip-main" + local sub="$TMP_ROOT/structured-roundtrip-sub" + local source target body_file + setup_homes "$home" "$sub" + source="$home/data/backlog.md" + target="$sub/data/backlog.md" + body_file="$TMP_ROOT/structured-roundtrip-body.md" + + cat > "$source" <<'EOF' +## In flight + +## Queued +- [ ] before-guard - Before neighbor https://example.test/before (repo: before-repo) (kind: docs) (since 2026-07-15) + before body only +- [ ] blocker-anchor - Dependency anchor (repo: dependency-repo) (kind: ship) (since 2026-07-15) + blocker body only +- [ ] structured-roundtrip - Adversarial target https://example.test/spec https://github.com/acme/alpha/pull/42 (repo: alpha) (kind: scout) (priority: 3) (since 2026-07-15) (hold: captain says wait) (hold-kind: captain) (hold-until: 2099-12-31) blocked-by: blocker-anchor - waits for the anchor + stale body that full replacement must remove +- [ ] after-guard - After neighbor https://example.test/after (repo: after-repo) (kind: ship) (since 2026-07-15) + after body only + +## Done +EOF + cat > "$body_file" <<'EOF' +First paragraph. + +## Intent +Preserve structured-looking body text: (repo: body-only) (kind: body-kind). + +- blocked-by: body-only - this is body, not a dependency +EOF + + tasks-axi update structured-roundtrip --file "$source" --body-file "$body_file" >/dev/null \ + || fail "lossless full-body replacement setup failed" + + local target_before blocker_before before_neighbor_before after_neighbor_before + target_before=$(task_detail "$source" structured-roundtrip) + blocker_before=$(task_detail "$source" blocker-anchor) + before_neighbor_before=$(task_detail "$source" before-guard) + after_neighbor_before=$(task_detail "$source" after-guard) + + FM_HOME="$home" "$ROOT/bin/fm-backlog-handoff.sh" \ + design blocker-anchor structured-roundtrip >/dev/null \ + || fail "structured blocker/dependent handoff failed" + + local target_destination blocker_destination + target_destination=$(task_detail "$target" structured-roundtrip) + blocker_destination=$(task_detail "$target" blocker-anchor) + assert_block_equals "structured target fields changed during handoff" \ + "$target_before" "$target_destination" + assert_block_equals "structured blocker fields changed during handoff" \ + "$blocker_before" "$blocker_destination" + assert_contains "$target_destination" 'repo: alpha' "handoff lost target repo" + assert_contains "$target_destination" 'kind: scout' "handoff lost target kind" + assert_contains "$target_destination" 'blocked_by: blocker-anchor' "handoff lost target dependency" + assert_contains "$target_destination" 'held: yes' "handoff lost target hold state" + assert_contains "$target_destination" 'hold_kind: captain' "handoff lost target hold kind" + assert_contains "$target_destination" 'hold_until: 2099-12-31' "handoff lost target hold date" + assert_contains "$target_destination" 'pr:https://github.com/acme/alpha/pull/42' "handoff lost target PR link" + assert_contains "$target_destination" 'doc:https://example.test/spec' "handoff lost target documentation link" + assert_contains "$target_destination" 'Preserve structured-looking body text' "handoff lost replacement body" + assert_grep 'blocked-by: blocker-anchor - waits for the anchor' "$target" \ + "handoff lost dependency reason" + + assert_block_equals "preceding neighbor changed when target moved out" \ + "$before_neighbor_before" "$(task_detail "$source" before-guard)" + assert_block_equals "following neighbor absorbed target fields or body" \ + "$after_neighbor_before" "$(task_detail "$source" after-guard)" + assert_no_grep 'body-only' "$source" "target body accumulated into an adjacent source task" + assert_no_grep 'stale body' "$source" "superseded body survived full replacement" + + tasks-axi mv blocker-anchor structured-roundtrip --file "$target" --to "$source" >/dev/null \ + || fail "structured blocker/dependent move-back failed" + + assert_block_equals "structured target fields changed after move-back" \ + "$target_before" "$(task_detail "$source" structured-roundtrip)" + assert_block_equals "structured blocker fields changed after move-back" \ + "$blocker_before" "$(task_detail "$source" blocker-anchor)" + assert_block_equals "preceding neighbor changed after round trip" \ + "$before_neighbor_before" "$(task_detail "$source" before-guard)" + assert_block_equals "following neighbor changed after round trip" \ + "$after_neighbor_before" "$(task_detail "$source" after-guard)" + + local count + count=$(grep -cF -- 'Preserve structured-looking body text' "$source") + [ "$count" -eq 1 ] || fail "round trip duplicated or lost the target body (count=$count)" + count=$(grep -cF -- 'blocked-by: blocker-anchor - waits for the anchor' "$source") + [ "$count" -eq 1 ] || fail "round trip duplicated or lost the dependency reason (count=$count)" + + pass "structured task round trip preserves body, fields, links, hold, dependencies, and neighbors" +} + test_body_moves_when_followed_by_another_item test_body_moves_when_followed_by_section_heading test_multi_paragraph_body_with_internal_blanks_moves_whole @@ -481,5 +580,6 @@ test_untouched_eof_line_preserves_terminator test_body_handoff_is_idempotent test_noncanonical_indented_continuations_refuse_without_changes test_indented_heading_is_not_section_boundary +test_structured_task_round_trip_preserves_fields_and_neighbors echo "ALL TESTS PASSED" diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 2b3399e58..9ab6c0361 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -7,10 +7,10 @@ # 'TASKS_AXI: available' lines, so those contracts are pinned verbatim. The cases # are table-driven over the inputs that vary: whether `treehouse get --help` # advertises --lease, which (if any) tasks-axi version is on PATH, whether -# tasks-axi update advertises --archive-body, whether its mv help advertises -# multi-ID moves, whether quota-axi is on PATH, -# whether the local backend config opts out of tasks-axi backlog mutations, and -# which no-mistakes version is on PATH. +# tasks-axi show advertises lossless --full reads, whether update advertises +# lossless --body-file replacement and --archive-body, whether mv advertises +# multi-ID moves, whether quota-axi is on PATH, whether the local backend config +# opts out of tasks-axi backlog mutations, and which no-mistakes version is on PATH. # Dedicated fleet-sync cases pin the computed bootstrap timeout, explicit # override, blank-env defaulting, partial-output relay, and pre-launch timeout # scan. @@ -68,7 +68,7 @@ fi exit 0 SH chmod +x "$fakebin/no-mistakes" - add_tasks_axi "$fakebin" "0.1.1" + add_tasks_axi "$fakebin" "0.2.2" add_quota_axi "$fakebin" printf '%s\n' "$fakebin" } @@ -83,9 +83,14 @@ SH } add_tasks_axi() { - local fakebin=$1 version=$2 archive_body=${3:-yes} multi_id=${4:-yes} archive_line mv_usage + local fakebin=$1 version=$2 archive_body=${3:-yes} multi_id=${4:-yes} body_file=${5:-yes} + local show_full=${6:-yes} archive_line body_file_line full_line mv_usage archive_line="" + body_file_line="" + full_line="" [ "$archive_body" = yes ] && archive_line=' --archive-body' + [ "$body_file" = yes ] && body_file_line=' --body-file ' + [ "$show_full" = yes ] && full_line=' --full' mv_usage='usage: tasks-axi mv [...] --to ' [ "$multi_id" = yes ] || mv_usage='usage: tasks-axi mv --to ' cat > "$fakebin/tasks-axi" < [flags]' + [ -z '$full_line' ] || printf '%s\n' '$full_line' + exit 0 +fi if [ "\${1:-}" = update ] && [ "\${2:-}" = --help ]; then printf '%s\n' 'usage: tasks-axi update [flags]' - printf '%s\n' ' --body-file ' + [ -z '$body_file_line' ] || printf '%s\n' '$body_file_line' [ -z '$archive_line' ] || printf '%s\n' '$archive_line' exit 0 fi @@ -224,7 +234,8 @@ assert_timeout_report() { # mode=exact -> output must equal # mode=grep -> output must contain (fixed string); must not appear test_bootstrap_reporting() { - local label lease tasks quota backend mode expect notcontains case_dir fakebin out n archive_body multi_id + local label lease tasks quota backend mode expect notcontains case_dir fakebin out n + local archive_body body_file multi_id show_full n=0 while IFS='^' read -r label lease tasks quota backend mode expect notcontains; do [ -n "$label" ] || continue @@ -240,20 +251,34 @@ test_bootstrap_reporting() { rm -f "$fakebin/tasks-axi" else archive_body=yes + body_file=yes multi_id=yes + show_full=yes case "$tasks" in *:noarchive) archive_body=no tasks=${tasks%:noarchive} ;; esac + case "$tasks" in + *:nobodyfile) + body_file=no + tasks=${tasks%:nobodyfile} + ;; + esac + case "$tasks" in + *:nofull) + show_full=no + tasks=${tasks%:nofull} + ;; + esac case "$tasks" in *:nomulti) multi_id=no tasks=${tasks%:nomulti} ;; esac - add_tasks_axi "$fakebin" "$tasks" "$archive_body" "$multi_id" + add_tasks_axi "$fakebin" "$tasks" "$archive_body" "$multi_id" "$body_file" "$show_full" fi if [ "$quota" = "0" ]; then rm -f "$fakebin/quota-axi" @@ -276,16 +301,18 @@ test_bootstrap_reporting() { ;; esac done <<'ROWS' -treehouse --lease support is accepted silently^1^0.1.1^1^manual^empty^^ -treehouse without --lease reports an upgrade, gh auth is fine^0^0.1.1^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH -compatible tasks-axi is reported available by default^1^0.1.1^1^-^exact^TASKS_AXI: available^ +treehouse --lease support is accepted silently^1^0.2.2^1^manual^empty^^ +treehouse without --lease reports an upgrade, gh auth is fine^0^0.2.2^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH +compatible tasks-axi is reported available by default^1^0.2.2^1^-^exact^TASKS_AXI: available^ missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -tasks-axi without archive-body is required by default^1^0.1.2:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +incompatible tasks-axi is required by default^1^0.2.1^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +tasks-axi without full show is required by default^1^0.2.2:nofull^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +tasks-axi without archive-body is required by default^1^0.2.2:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +tasks-axi without body-file replacement is required by default^1^0.2.2:nobodyfile^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ tasks-axi without multi-id mv is required by default^1^0.2.2:nomulti^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -missing quota-axi is required by default^1^0.1.1^0^manual^exact^MISSING: quota-axi (install: npm install -g quota-axi)^ +missing quota-axi is required by default^1^0.2.2^0^manual^exact^MISSING: quota-axi (install: npm install -g quota-axi)^ manual backlog backend still requires missing tasks-axi^1^-^1^manual^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -manual backlog backend suppresses tasks-axi availability^1^0.1.1^1^manual^empty^^ +manual backlog backend suppresses tasks-axi availability^1^0.2.2^1^manual^empty^^ ROWS pass "bootstrap reports treehouse lease + tasks-axi/quota-axi bootstrap contracts" } @@ -302,7 +329,7 @@ test_no_mistakes_min_version() { mkdir -p "$case_dir/home/config" printf '%s\n' manual > "$case_dir/home/config/backlog-backend" fakebin=$(make_fake_toolchain "$case_dir") - add_tasks_axi "$fakebin" "0.1.1" + add_tasks_axi "$fakebin" "0.2.2" out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_FAKE_NO_MISTAKES_VERSION="$version" "$ROOT/bin/fm-bootstrap.sh") case "$mode" in diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index d34bdda3a..f623430b1 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -435,6 +435,7 @@ test_pi_wiring() { } test_scripts_are_shellcheck_clean() { + command -v shellcheck >/dev/null 2>&1 || { pass "shellcheck not installed, skipping"; return; } shellcheck "$ROOT/bin/fm-cd-pretool-check.sh" >/dev/null 2>&1 \ || fail "bin/fm-cd-pretool-check.sh is not shellcheck-clean" pass "bin/fm-cd-pretool-check.sh is shellcheck-clean" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 7d48deba8..2df1c8bc3 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -92,9 +92,15 @@ case "${1:-}" in printf '%s\n' '0.2.3' exit 0 ;; + show) + if [ "${2:-}" = --help ]; then + printf '%s\n' 'usage: tasks-axi show [--full]' + exit 0 + fi + ;; update) if [ "${2:-}" = --help ]; then - printf '%s\n' 'usage: tasks-axi update [--archive-body]' + printf '%s\n' 'usage: tasks-axi update [--body-file ] [--archive-body]' exit 0 fi ;; diff --git a/tests/fm-stow-contract.test.sh b/tests/fm-stow-contract.test.sh index c43f47c4d..c13fb8948 100755 --- a/tests/fm-stow-contract.test.sh +++ b/tests/fm-stow-contract.test.sh @@ -12,6 +12,7 @@ test_stow_skill_task_note_contract() { assert_grep 'tasks-axi update --body-file ' "$stow" "stow skill does not require task body replacement" assert_grep '--archive-body' "$stow" "stow skill does not document recoverable task body archival" assert_grep 'Never append.' "$stow" "stow skill does not forbid append-first task notes" + assert_no_grep 'tasks-axi update --append' "$stow" "stow skill still documents the removed tasks-axi append flag" assert_no_grep 'carry that context into the replacement body' "$stow" "stow skill still preserves archive-only context in the replacement body" pass "stow skill task-note contract includes recoverable body archival" } diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 95b7c006c..93d5a4c56 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -133,7 +133,12 @@ add_compatible_tasks_axi() { cat > "$case_dir/fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' '0.1.1' + printf '%s\n' '0.2.2' + exit 0 +fi +if [ "${1:-}" = show ] && [ "${2:-}" = --help ]; then + printf '%s\n' 'usage: tasks-axi show [flags]' + printf '%s\n' ' --full' exit 0 fi if [ "${1:-}" = update ] && [ "${2:-}" = --help ]; then