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
6 changes: 3 additions & 3 deletions bin/fm-backlog-handoff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<id>...]); 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
Expand Down
26 changes: 17 additions & 9 deletions bin/fm-tasks-axi-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<id>...] 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
Expand All @@ -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() {
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `[<id>...]` 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 `[<id>...]` 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 <id> --full`, then replace the considered body with `tasks-axi update <id> --body-file <path>`, 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.
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-backend-tmux-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
100 changes: 100 additions & 0 deletions tests/fm-backlog-handoff.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
61 changes: 44 additions & 17 deletions tests/fm-bootstrap.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
Expand All @@ -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 <path>'
[ "$show_full" = yes ] && full_line=' --full'
mv_usage='usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>'
[ "$multi_id" = yes ] || mv_usage='usage: tasks-axi mv <id> --to <path-or-dir>'
cat > "$fakebin/tasks-axi" <<SH
Expand All @@ -94,9 +99,14 @@ if [ "\${1:-}" = --version ]; then
printf '%s\n' '$version'
exit 0
fi
if [ "\${1:-}" = show ] && [ "\${2:-}" = --help ]; then
printf '%s\n' 'usage: tasks-axi show <id> [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 <id> [flags]'
printf '%s\n' ' --body-file <path>'
[ -z '$body_file_line' ] || printf '%s\n' '$body_file_line'
[ -z '$archive_line' ] || printf '%s\n' '$archive_line'
exit 0
fi
Expand Down Expand Up @@ -224,7 +234,8 @@ assert_timeout_report() {
# mode=exact -> output must equal <expect>
# mode=grep -> output must contain <expect> (fixed string); <notcontains> 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
Expand All @@ -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"
Expand All @@ -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"
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/fm-cd-pretool-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading