From f7860dd13f71a9a865d94c5d1d914a866fb57fef Mon Sep 17 00:00:00 2001 From: KhalilouKarl Date: Fri, 2 Oct 2026 01:13:02 +0200 Subject: [PATCH 1/9] feat(bin): read a non-markdown tasks-axi backlog through the shared backlog owner Add fm_backlog_rows_json beside fm_backlog_row_list: one bounded list read with a count-driven re-read, a strict table decoder and the tasks-axi link grammar, returning an error object instead of an empty inventory. --- bin/fm-backlog-transition-lib.sh | 297 +++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 7d73826034f..eedecd4bbd8 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -423,6 +423,303 @@ fm_backlog_row_list() { # [flag...] fi } +# READING THE WHOLE BACKLOG FOR THE FLEET SNAPSHOT. bin/fm-fleet-snapshot.sh +# reads a home's markdown backlog file itself; for any other configured adapter +# it asks this owner for the rows, so the structured fleet view shows the +# configured backend's work instead of the empty shadow file next to it. +# fm_backlog_rows_backend names the backend that addressing resolves, and +# fm_backlog_rows_json prints one JSON object, always with exit 0 unless jq is +# missing: {ok:true,rows:[...]} or {ok:false,error:"..."}. An unavailable, +# unreadable, malformed or incomplete read is ok:false with a diagnostic and is +# never an empty inventory. +# One bounded `tasks-axi list` runs with every state selected (the default) and +# the fields below; tasks-axi 0.2.5 returns every row when no --limit is given +# and prints `count: N of M total` only for an explicit smaller limit, so a +# count marker with N < M is incomplete and earns at most one re-read with +# --limit M. There is no pagination and no per-row `show`. +# The decoder accepts exactly one envelope: `count:` line, a `tasks[N]{columns}:` +# header whose columns equal FM_BACKLOG_ROWS_COLUMNS, N rows of JSON-style quoted +# or bare cells, then a `help[k]:` block; any other shape is malformed output. +# Only the title is ever truncated by `list` (with a visible marker): the raw +# cell stays in title_raw and the title shown ends with an ellipsis. +# Identifiers are validated against tasks-axi's slug rule. The links cell is read +# with tasks-axi's own link grammar (a pr: item ends at the first +# https?://\S+?/pull/, a report: item at the first data/\S+?/report.md, items +# are comma separated) and must be consumed entirely; a cell that is not, or +# that holds a doc: URL which itself contains another item separator, keeps +# links_raw, sets links_ambiguous and yields no parsed artifacts. +# `blocked_by` lists only unresolved blockers, so blocker ids come from `deps`, +# which carries every edge. The list output has no hold-set timestamp or +# in-flight start date, so those stay null rather than being guessed from the +# creation date. +FM_BACKLOG_ROWS_FIELDS=blocked,blocked_by,closed,created,deps,held,hold_kind,hold_reason,hold_until,links,priority +FM_BACKLOG_ROWS_COLUMNS='["id","state","kind","repo","title","blocked","blocked_by","closed","created","deps","held","hold_kind","hold_reason","hold_until","links","priority"]' +# shellcheck disable=SC2016 # jq program text, expanded by jq not the shell. +FM_BACKLOG_ROWS_JQ=' +# Strict decoder for one `tasks-axi list --fields ` TOON table (see fm-backlog-transition-lib.sh). +# Input: the complete stdout text (raw, slurped). --argjson cols: the exact expected header column list. +# Output: {ok:true,rows:[...]} | {ok:false,incomplete:true,count,total,error} | {ok:false,error:"..."}. +def bad($m): {ok: false, error: $m}; +def state_ok: . == "queued" or . == "in_flight" or . == "done"; +def yesno_ok: . == "yes" or . == "no"; +def id_ok: test("^[A-Za-z0-9._-]+$"); +def date_ok: . == "-" or test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$"); +def dash_null: if . == "-" then null else . end; +def single_line_ok: test("^[^\\n\\r]*$"); +def cap($re): [capture($re)] | .[0]; + +# One table row -> cells. A cell is a JSON-style quoted string or a bare token containing no quote and no comma. +def row_cells: + { rest: ., cells: [], err: null, fin: false } + | until(.fin or .err != null; + (.rest | cap("^(?\"(?:[^\"\\\\]|\\\\.)*\")(?,|$)")) as $q + | if $q != null then + (try ($q.cell | fromjson) catch null) as $v + | if $v == null then .err = "invalid quoted cell" + else .cells += [$v] | .rest = (.rest | .[($q.cell | length) + ($q.sep | length):]) | .fin = ($q.sep == "") end + else + (.rest | cap("^(?[^\",]*)(?,|$)")) as $b + | if $b == null then .err = "malformed cell" + else .cells += [$b.cell] | .rest = (.rest | .[($b.cell | length) + ($b.sep | length):]) | .fin = ($b.sep == "") end + end) + | if .err != null then {err: .err} else {cells: .cells} end; + +# Title truncation marker written by `list` (the only truncated column). +def title_parts: + (cap("^(?[\\s\\S]*?)\\n\\.\\.\\. \\(truncated, (?[0-9]+) chars total - use show [^ ]+ --full to see complete text\\)$")) as $m + | if $m == null then {visible: ., truncated: false, total: null} + else {visible: $m.visible, truncated: true, total: ($m.total | tonumber)} end; + +def display_title($t): + ($t.visible + | gsub("https?://\\S*$"; "") + | gsub("?"; "") + | gsub("[[:space:]]+data/[^[:space:])]+/report\\.md$"; "") + | gsub("[[:space:]]+-[[:space:]]+local main$"; "") | gsub("[[:space:]]+local main$"; "") + | gsub("[[:space:]]+-[[:space:]]*$"; "") + | gsub("[[:space:]]+"; " ") + | gsub("^[[:space:]]+|[[:space:]]+$"; "")) as $v + | if $t.truncated then ($v + "…") else $v end; + +# Links cell: the tasks-axi first-match link grammar with strict full consumption. +def parse_links: + if . == "none" or . == "-" then {ok: true, items: []} + else + { rest: ., items: [], err: null, fin: false } + | until(.fin or .err != null; + if (.rest | startswith("pr:")) then + ((.rest | cap("^pr:(?https?://\\S+?/pull/[0-9]+)")) // null) as $m + | if $m == null then .err = "pr link" else .items += [{kind: "pr", value: $m.v}] | .rest = (.rest | .[3 + ($m.v | length):]) end + elif (.rest | startswith("report:")) then + ((.rest | cap("^report:(?data/\\S+?/report\\.md\\b)")) // null) as $m + | if $m == null then .err = "report link" else .items += [{kind: "report", value: $m.v}] | .rest = (.rest | .[7 + ($m.v | length):]) end + elif (.rest | startswith("doc:")) then + ((.rest | cap("^doc:(?https?://\\S+)")) // null) as $m + | if $m == null then .err = "doc link" else .items += [{kind: "doc", value: $m.v}] | .rest = (.rest | .[4 + ($m.v | length):]) end + else .err = "unknown link item" end + | if .err != null then . + elif .rest == "" then .fin = true + elif (.rest | startswith(",")) then .rest = .rest[1:] + else .err = "trailing text" end) + | if .err != null then {ok: false, items: []} + # more than one doc item: a generic URL may itself contain ",doc:", so the split is not provable + elif ([.items[] | select(.kind == "doc" and (.value | test(",(doc|pr|report):")))] | length) > 0 then {ok: false, items: []} + else {ok: true, items: .items} end + end; + +def build_row($c; $cols; $order): + ([range(0; $cols | length)] | map({key: $cols[.], value: $c[.]}) | from_entries) as $f + | ($f.title | title_parts) as $tp + | ($f.links | parse_links) as $lk + | ($f.deps | if . == "none" then [] else split(",") end) as $deps + # `blocked_by` lists only unresolved blockers; `deps` carries every edge, which is what a blocker record resolves against + | ([$deps[] | select(startswith("blocked-by:")) | sub("^blocked-by:"; "")] + | reduce .[] as $id ([]; if index($id) == null then . + [$id] else . end)) as $blockers + | ([$lk.items[] | select(.kind == "pr") | .value][0] // null) as $pr + | ([$lk.items[] | select(.kind == "report") | .value][0] // null) as $report + | (if $f.state == "done" and $tp.visible != null and ($tp.visible | test("[[:space:]]local main$")) then "local main" else null end) as $local_note + | (if $f.state != "done" then {verb: null, date: null} + elif $pr != null then {verb: "merged", date: ($f.closed | dash_null)} + elif $report != null and $f.kind == "scout" then {verb: "reported", date: ($f.closed | dash_null)} + elif $local_note != null then {verb: "done", date: ($f.closed | dash_null)} + else {verb: null, date: ($f.closed | dash_null)} end) as $completion + | {order: $order, state: $f.state, structured: true, id: $f.id, checked: ($f.state == "done"), + title: display_title($tp), title_raw: $f.title, title_truncated: $tp.truncated, title_chars_total: $tp.total, + repo: ($f.repo | dash_null), kind: ($f.kind | dash_null), priority: ($f.priority | dash_null), + hold_reason: ($f.hold_reason | dash_null), hold_kind: ($f.hold_kind | dash_null), hold_until: ($f.hold_until | dash_null), + hold_set: null, + blocked_by: ($blockers[0] // null), blocked_by_ids: $blockers, blocked_reason: null, + since: null, merged: (if $completion.verb == "merged" then $completion.date else null end), + reported: (if $completion.verb == "reported" then $completion.date else null end), + done: (if $completion.verb == "done" then $completion.date else null end), + completion: $completion, + links: (if $lk.ok then [$lk.items[] | select(.kind == "pr" or .kind == "doc") | .value] else [] end), + pr_url: (if $lk.ok then $pr else null end), + report_path: (if $lk.ok then $report else null end), + local_note: $local_note, + links_raw: $f.links, links_ambiguous: ($lk.ok | not), + created: ($f.created | dash_null), closed: ($f.closed | dash_null), + raw: ("- " + $f.id + " - " + $f.title), + body_lines: [], body_excerpt: null, + _deps: $deps, _held: $f.held, _blocked: $f.blocked}; + +def validate_row($r): + if ($r.id | id_ok | not) then "invalid id" + elif ($r.state | state_ok | not) then "invalid state" + elif ($r.held | yesno_ok | not) or ($r.blocked | yesno_ok | not) then "invalid held or blocked flag" + elif ($r.created | date_ok | not) or ($r.closed | date_ok | not) or ($r.hold_until | date_ok | not) then "invalid date" + elif ($r.priority != "-" and ($r.priority | test("^[0-4]$") | not)) then "invalid priority" + elif ($r.blocked_by != "none" and ($r.blocked_by | split(",") | all(id_ok) | not)) then "invalid blocked_by" + elif ($r.deps != "none" and ($r.deps | split(",") | all(test("^(blocked-by|parent|discovered-from):[A-Za-z0-9._-]+$")) | not)) then "invalid deps" + elif ([$r.kind, $r.repo, $r.hold_kind, $r.links] | all(single_line_ok) | not) then "invalid text" + else null end; + +. as $raw +| ($raw | split("\n")) as $all +| (if ($all | length) > 0 and $all[-1] == "" then $all[:-1] else $all end) as $lines +| if ($lines | length) == 0 then bad("empty adapter output") + elif ($lines[0] | test("^error: ")) then + bad(($lines[0] | sub("^error: "; "") | (try fromjson catch .)) as $e + | ([$lines[] | select(test("^code: "))][0] // "" | sub("^code: "; "")) as $code + | ($e | tostring) + (if $code != "" then " (" + $code + ")" else "" end)) + else + ($lines[0] | cap("^count: (?[0-9]+)(?: of (?[0-9]+) total)?$")) as $cnt + | if $cnt == null then bad("malformed adapter output: no count line") + else + ($cnt.n | tonumber) as $n + | (if $cnt.m == null then $n else ($cnt.m | tonumber) end) as $total + | if $n != $total then {ok: false, incomplete: true, count: $n, total: $total, error: "incomplete adapter output: \($n) of \($total) rows"} + elif $n == 0 and ($lines | length) >= 2 and ($lines[1] | test("^tasks: 0 tasks in this backlog$")) then {ok: true, rows: []} + else + ($lines[1] // "" | cap("^tasks\\[(?[0-9]+)\\]\\{(?[a-z_,]+)\\}:$")) as $hdr + | if $hdr == null then bad("malformed adapter output: no table header") + elif ($hdr.k | tonumber) != $n then bad("malformed adapter output: header row count differs from count line") + elif ($hdr.cols | split(",")) != $cols then bad("malformed adapter output: unexpected columns") + elif ($lines | length) < (2 + $n) then bad("malformed adapter output: fewer rows than the count line") + else + ($lines[2: 2 + $n]) as $rows + | ($lines[2 + $n:]) as $tail + | if ($tail | length) > 0 and (($tail[0] | test("^help\\[[0-9]+\\]:( .*)?$") | not) or ($tail[1:] | all(test("^ - .*$")) | not)) then + bad("malformed adapter output: unexpected trailing lines") + elif ($tail | length) == 0 then bad("malformed adapter output: no help block") + else + ([range(0; $rows | length) as $i + | $rows[$i] as $line + | if ($line | startswith(" ") | not) or ($line | startswith(" ")) then {i: $i, err: "row indentation"} + else ($line[2:] | row_cells) as $rc + | if $rc.err != null then {i: $i, err: $rc.err} + elif ($rc.cells | length) != ($cols | length) then {i: $i, err: "cell count"} + else + ([range(0; $cols | length)] | map({key: $cols[.], value: $rc.cells[.]}) | from_entries) as $f + | (validate_row($f)) as $why + | if $why != null then {i: $i, err: $why} else {i: $i, cells: $rc.cells} end + end + end]) as $parsed + | ([$parsed[] | select(.err != null)][0]) as $first_err + | if $first_err != null then bad("malformed adapter output: row \($first_err.i + 1): \($first_err.err)") + else + ([$parsed[] | . as $p | build_row($p.cells; $cols; $p.i + 1)]) as $out + | if ([$out[].id] | unique | length) != ($out | length) then bad("malformed adapter output: duplicate task id") + else {ok: true, rows: $out} end + end + end + end + end + end + end +' + +fm_backlog_rows_backend() { # -> markdown | + local data root backend + data=$(fm_backlog_data_absolute "$1") || { + printf 'data directory cannot be resolved: %s\n' "$1" + return 2 + } + root=$(fm_backlog_root "$data") || { + printf 'backlog root cannot be resolved for %s\n' "$data" + return 2 + } + backend=$(fm_tasks_axi_backend "$root" 2>&1) || { + printf '%s\n' "$backend" + return 2 + } + printf '%s\n' "$backend" +} + +fm_backlog_rows_list_once() { # [flag...] sets FM_BACKLOG_ROWS_OUT, FM_BACKLOG_ROWS_ERR + local secs=${FM_BACKLOG_ROWS_TIMEOUT_SECS:-30} errfile status + case "$secs" in ''|*[!0-9]*) secs=30 ;; esac + [ "$secs" -gt 0 ] 2>/dev/null || secs=30 + FM_BACKLOG_ROWS_OUT= + FM_BACKLOG_ROWS_ERR= + errfile=$(mktemp "${TMPDIR:-/tmp}/fm-backlog-rows.XXXXXX") || { + FM_BACKLOG_ROWS_ERR='cannot create a temporary file for the backlog read' + return 1 + } + # shellcheck disable=SC2016 # Expansion is deliberately deferred to the child shell. + FM_BACKLOG_ROWS_OUT=$(fm_run_timed "$secs" bash -c 'cd "$1" 2>/dev/null || exit 1; shift; exec tasks-axi list "$@"' \ + _ "$FM_BACKLOG_AXI_ROOT" --fields "$FM_BACKLOG_ROWS_FIELDS" "$@" 2>"$errfile") + status=$? + FM_BACKLOG_ROWS_ERR=$(sed -n '1p' "$errfile" 2>/dev/null) + rm -f -- "$errfile" + if [ "$status" -eq 124 ]; then + FM_BACKLOG_ROWS_ERR="tasks-axi list exceeded its ${secs}s backlog read bound" + elif [ "$status" -eq 127 ]; then + FM_BACKLOG_ROWS_ERR='tasks-axi is not installed or not on PATH' + fi + return "$status" +} + +fm_backlog_rows_decode() { # + printf '%s\n' "$1" | jq -Rs --argjson cols "$FM_BACKLOG_ROWS_COLUMNS" "$FM_BACKLOG_ROWS_JQ" +} + +fm_backlog_rows_json() { # + local data=$1 status decoded total diagnostic + command -v jq >/dev/null 2>&1 || return 1 + FM_BACKLOG_TRANSITION_ERROR= + fm_backlog_tasks_axi_addressing "$data" + status=$? + if [ "$status" -ne 0 ]; then + diagnostic=${FM_BACKLOG_TRANSITION_ERROR:-tasks-axi backlog addressing failed} + jq -n --arg error "$diagnostic" '{ok:false,error:$error}' + return 0 + fi + if [ -n "$FM_BACKLOG_AXI_FILE" ]; then + jq -n '{ok:false,error:"the markdown backend is read from its backlog file"}' + return 0 + fi + fm_backlog_rows_list_once + status=$? + decoded=$(fm_backlog_rows_decode "$FM_BACKLOG_ROWS_OUT") || decoded= + if [ "$status" -ne 0 ]; then + # A failing list reports its diagnostic as TOON on stdout or text on stderr. + diagnostic=$(printf '%s' "$decoded" | jq -r 'select(.ok == false) | .error' 2>/dev/null) + case "$diagnostic" in + ''|malformed*|empty*) diagnostic=${FM_BACKLOG_ROWS_ERR:-} ;; + esac + [ -n "$diagnostic" ] || diagnostic="tasks-axi list failed with status $status" + jq -n --arg error "$diagnostic" '{ok:false,error:$error}' + return 0 + fi + if [ "$(printf '%s' "$decoded" | jq -r '.incomplete // false' 2>/dev/null)" = true ]; then + total=$(printf '%s' "$decoded" | jq -r '.total') + fm_backlog_rows_list_once --limit "$total" + status=$? + if [ "$status" -ne 0 ]; then + diagnostic=${FM_BACKLOG_ROWS_ERR:-tasks-axi list failed with status $status} + jq -n --arg error "$diagnostic" '{ok:false,error:$error}' + return 0 + fi + decoded=$(fm_backlog_rows_decode "$FM_BACKLOG_ROWS_OUT") || decoded= + fi + if [ -z "$decoded" ]; then + jq -n '{ok:false,error:"malformed adapter output: the table could not be decoded"}' + return 0 + fi + printf '%s\n' "$decoded" +} + fm_backlog_row_probe() { # local data authorized_data=$1 id=$2 out state held blocked hold_kind command_status source_status if ! data=$(fm_backlog_data_absolute "$1"); then From 2afb45852b200b86543d17f024f2a68af0269bc5 Mon Sep 17 00:00:00 2001 From: KhalilouKarl Date: Fri, 2 Oct 2026 01:13:03 +0200 Subject: [PATCH 2/9] fix(bin): show the configured backlog backend in the fleet snapshot, view and bearings A home whose tasks-axi backend is not markdown had its backlog read from the empty data/backlog.md next to it, so the snapshot reported no work and a valid inventory. The snapshot now reads the adapter through the shared reader, and an unavailable, malformed or incomplete read is present:false with a diagnostic that main_inventory reports instead of an empty valid backlog. --- bin/fm-bearings-snapshot.sh | 2 + bin/fm-fleet-snapshot.sh | 195 +++++++++++++++++++++++++----------- bin/fm-fleet-view.sh | 13 ++- 3 files changed, 147 insertions(+), 63 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 1ea900dfa2a..f9c1b3a569d 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -671,6 +671,8 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | if $n > 0 then {surface:("main in-flight backlog item(s) have no child metadata: \($n)"), reveal:"inspect main data/backlog.md In flight vs state/*.meta"} else empty end), ((($snap.main_inventory.unstructured_current_count // 0)) as $n | if $n > 0 then {surface:("main unstructured current backlog row(s): \($n)"), reveal:"inspect main data/backlog.md In flight and Queued free-form rows"} else empty end), + ((($snap.main_inventory.links_ambiguous_ids // []) | length) as $n + | if $n > 0 then {surface:("main backlog row(s) with ambiguous artifact links, parsed links withheld: \($n)"), reveal:"read those rows with tasks-axi show --full"} else empty end), (if $all_in_flight == 0 and ($in_flight_all | length) > $in_flight_n then {surface:("in_flight showing \($in_flight_n) of \($in_flight_all | length)"), reveal:"--all-in-flight"} else empty end), (($snap.secondmate_current.records // [])[] as $m | ([($m.omitted // [])[] | select(.surface == "active_children") | .count] | add // 0) as $n diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7f830bff272..0d61c132098 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -16,6 +16,20 @@ # roots: resolved root/config/data/state/projects directories. # backlog: {path,present,records[]} where records are ordered as written in # data/backlog.md and cover In flight, Queued, and Done. +# When the home's configured tasks-axi backend is not markdown, the rows +# come from one bounded adapter read through fm-backlog-transition-lib.sh +# (fm_backlog_rows_json owns the read, its completeness rule and the strict +# decoder) and the object also carries source "tasks-axi", backend and error. +# An unreadable configuration or an unavailable, malformed or incomplete +# read is present:false with error set, a backlog_error that main_inventory +# reports as "Backlog unavailable: ...", and never an empty valid backlog; +# a stale data/backlog.md beside such an adapter is ignored. Titles keep the +# raw cell in title_raw and end with an ellipsis when the adapter truncated +# them; a links cell the adapter grammar cannot consume entirely keeps +# links_raw, sets links_ambiguous, withholds parsed artifacts and is listed +# in main_inventory.links_ambiguous_ids. The adapter list carries no hold-set +# time or in-flight start date, so those stay null and an undated hold is +# never aged. # Canonical tasks-axi rows are structured; free-form non-empty lines in # those sections are preserved as unstructured records. # Structured rows preserve captain-hold metadata such as hold_kind, @@ -69,7 +83,8 @@ # useful return-channel supervision data; remote secondmates use "unknown" # without a probe, and other tasks use "not_checked". # scout_reports[]: present data//report.md pointers. -# main_inventory: {valid,reason,orphan_in_flight[],unstructured_current_count} - +# main_inventory: {valid,reason,orphan_in_flight[],unstructured_current_count, +# backlog_error,links_ambiguous_ids[]} - # main-home current-inventory checks shared with secondmate_home_summary_json # (orphan structured in-flight ids with no state/.meta, and unstructured # current backlog rows). Does not invent live tasks; meta remains truth for @@ -224,6 +239,12 @@ esac # shellcheck source=bin/fm-timeout-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-timeout-lib.sh" # fm_run_timed: the shared hard bound +# shellcheck source=bin/fm-tasks-axi-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # fm_tasks_axi_backend, fm_backlog_backend_manual: the configured backend +# shellcheck source=bin/fm-backlog-transition-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-backlog-transition-lib.sh" # fm_backlog_rows_json: the shared backlog reader # shellcheck source=bin/fm-landed-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-landed-lib.sh" # FM_LANDED_JQ_DEFS: the shared landed selector @@ -383,17 +404,11 @@ first_pr_url_in_file() { # grep -Eo 'https?://[^[:space:])"]+/pull/[0-9]+' "$1" 2>/dev/null | head -1 } -backlog_json() ( # [] - defaults to this home's $BACKLOG - local backlog=${1:-$BACKLOG} - if [ ! -f "$backlog" ]; then - jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}' - return 0 - fi - - set -o pipefail - # shellcheck disable=SC2094 - jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ - --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" ' +# jq definitions shared by the markdown parser and the tasks-axi adapter reader, so every record, +# whichever backend produced it, gets the same blocker, role, hold-bucket and actionability fields. +# It reads $today, $now and $age_days, which each caller binds. +# shellcheck disable=SC2016 # jq program text, expanded by jq and not by the shell. +BACKLOG_JQ_COMMON=' def trim: gsub("^[[:space:]]+|[[:space:]]+$"; ""); def timestamp_epoch($d): if ($d | type) != "string" then null @@ -404,14 +419,102 @@ backlog_json() ( # [] - defaults to this home's $BACKLOG | (timestamp_epoch($to)) as $b | if $a == null or $b == null then null else (($b - $a) / 86400 | floor) end; + def cap($rest; $re): + (((($rest | capture($re)?) // {}) | .v) // null) as $v + | if $v == null then null else ($v | trim) end; + def finish_records: + .records |= map( + if (.body_lines | length) > 0 then + .hold_set = cap(.body_lines[0]; "^Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)$") + | .local_note = (.local_note + // (if any(.body_lines[]; + test("^Resolution recorded by fm-(captain|decision)-hold\\.$")) + then null + else cap(.body_lines[-1]; "^(?local main)$") + end)) + | .body_excerpt = ((.body_lines | join(" "))[:240]) + else . end) + | .records as $records + | (reduce ($records[] | select(.structured)) as $record ({}; + .[$record.id] = ((.[$record.id] // true) and ($record.state == "done")))) as $resolved_ids + | .records |= map( + if .structured then + . as $record + | .unresolved_blocker_ids = [ + $record.blocked_by_ids[] as $blocker + | select($resolved_ids[$blocker] != true) + | $blocker + ] + | .current_role = + (if .state == "in_flight" and .hold_reason != null and .hold_kind != null then "held" + elif .state == "in_flight" and .kind == "program" then "program" + elif .state == "in_flight" then "worker" + elif .state == "queued" then "queued" + else "done" end) + | .requires_child_metadata = (.current_role == "worker") + | .hold_age_days = days_between((.hold_set // .since); $now) + | .hold_bucket = + (if .hold_kind != "captain" or .hold_reason == null or .state == "done" then null + elif (.unresolved_blocker_ids | length) > 0 then "blocked" + elif .hold_until != null and .hold_until > $today then "dated" + elif .hold_until == null and .hold_age_days != null + and .hold_age_days >= $age_days then "aged" + else "live" end) + | .captain_actionable = (.hold_bucket == "live") + else . end) + | del(.section,.order); +' + +# This home's backlog when its configured tasks-axi backend is not markdown: one bounded read of the +# adapter through the shared backlog reader, shaped like the markdown records. Returns 1 when the markdown +# file is the backlog (the default, config/backlog-backend=manual, or no data directory), so that path +# stays exactly as it was. An unreadable configuration or an unavailable, malformed or incomplete adapter +# read is present:false with an error and never an empty valid backlog. +backlog_adapter_json() { + local root backend status rows + fm_backlog_backend_manual "$CONFIG" && return 1 + root=$(CDPATH='' cd -- "$(dirname -- "$DATA")" 2>/dev/null && pwd -P) || return 1 + backend=$(fm_tasks_axi_backend "$root" 2>&1) + status=$? + if [ "$status" -ne 0 ]; then + rows=$(jq -n --arg error "$backend" '{ok:false,error:$error}') || return 2 + backend=unavailable + elif [ "$backend" = markdown ]; then + return 1 + elif [ ! -d "$DATA" ]; then + rows=$(jq -n --arg error "backlog data directory is not a directory at $DATA" '{ok:false,error:$error}') || return 2 + else + rows=$(fm_backlog_rows_json "$DATA") || return 2 + fi + printf '%s\n' "$rows" | jq -c --arg path "$BACKLOG" --arg backend "$backend" --arg today "$SNAPSHOT_TODAY" \ + --arg now "$SNAPSHOT_NOW" --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" "$BACKLOG_JQ_COMMON"' + {path:$path,present:(.ok == true),source:"tasks-axi",backend:$backend, + error:(if .ok == true then null else (.error // "backlog read failed") end), + records:(if .ok == true then [.rows[] | del(._deps,._held,._blocked)] else [] end)} + | finish_records' || return 2 +} + +backlog_json() ( # [] - defaults to this home's $BACKLOG + local backlog=${1:-$BACKLOG} adapter_status + if [ -z "${1:-}" ]; then + backlog_adapter_json + adapter_status=$? + [ "$adapter_status" -eq 1 ] || return "$adapter_status" + fi + if [ ! -f "$backlog" ]; then + jq -n --arg path "$backlog" '{path:$path,present:false,records:[]}' + return 0 + fi + + set -o pipefail + # shellcheck disable=SC2094 + jq -Rn --arg path "$backlog" --arg today "$SNAPSHOT_TODAY" --arg now "$SNAPSHOT_NOW" \ + --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" "$BACKLOG_JQ_COMMON"' def section_state: if . == "In flight" then "in_flight" elif . == "Queued" then "queued" elif . == "Done" then "done" else null end; - def cap($rest; $re): - (((($rest | capture($re)?) // {}) | .v) // null) as $v - | if $v == null then null else ($v | trim) end; def metadata($rest; $key): cap($rest; ".*(?:\\(|,[[:space:]]*)" + $key + ":[[:space:]]*(?[^,)]*)"); # LOAD-BEARING, do not remove as a duplicate definition of the kind field. @@ -533,46 +636,7 @@ backlog_json() ( # [] - defaults to this home's $BACKLOG .order += 1 | .records += [{order:.order,state:.section,structured:false,id:null,raw:$line,body_lines:[],body_excerpt:null}] end) - | .records |= map( - if (.body_lines | length) > 0 then - .hold_set = cap(.body_lines[0]; "^Captain hold set:[[:space:]]*(?[0-9]{4}-[0-9]{2}-[0-9]{2}(?:T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)?)$") - | .local_note = (.local_note - // (if any(.body_lines[]; - test("^Resolution recorded by fm-(captain|decision)-hold\\.$")) - then null - else cap(.body_lines[-1]; "^(?local main)$") - end)) - | .body_excerpt = ((.body_lines | join(" "))[:240]) - else . end) - | .records as $records - | (reduce ($records[] | select(.structured)) as $record ({}; - .[$record.id] = ((.[$record.id] // true) and ($record.state == "done")))) as $resolved_ids - | .records |= map( - if .structured then - . as $record - | .unresolved_blocker_ids = [ - $record.blocked_by_ids[] as $blocker - | select($resolved_ids[$blocker] != true) - | $blocker - ] - | .current_role = - (if .state == "in_flight" and .hold_reason != null and .hold_kind != null then "held" - elif .state == "in_flight" and .kind == "program" then "program" - elif .state == "in_flight" then "worker" - elif .state == "queued" then "queued" - else "done" end) - | .requires_child_metadata = (.current_role == "worker") - | .hold_age_days = days_between((.hold_set // .since); $now) - | .hold_bucket = - (if .hold_kind != "captain" or .hold_reason == null or .state == "done" then null - elif (.unresolved_blocker_ids | length) > 0 then "blocked" - elif .hold_until != null and .hold_until > $today then "dated" - elif .hold_until == null and .hold_age_days != null - and .hold_age_days >= $age_days then "aged" - else "live" end) - | .captain_actionable = (.hold_bucket == "live") - else . end) - | del(.section,.order) + | finish_records ' < "$backlog" | fm_hold_reason_decode_stream json ) @@ -952,16 +1016,25 @@ main_inventory_json() { # | ([ $owned_in_flight[] | select(.id as $id | [$tasks[].id] | index($id) | not) | .id ]) as $orphan_in_flight - | (($unstructured_current | length) == 0 - and ($orphan_in_flight | length) == 0) as $valid - | (if ($unstructured_current | length) > 0 then "unstructured current backlog row" + | ($backlog.error // null) as $backlog_error + | ([ $backlog.records[]? | select(.links_ambiguous == true) | .id ]) as $links_ambiguous + | (($backlog_error == null) + and ($unstructured_current | length) == 0 + and ($orphan_in_flight | length) == 0 + and ($links_ambiguous | length) == 0) as $valid + | (if $backlog_error != null then "Backlog unavailable: " + $backlog_error + elif ($unstructured_current | length) > 0 then "unstructured current backlog row" elif ($orphan_in_flight | length) > 0 then "in-flight backlog item has no child metadata" + elif ($links_ambiguous | length) > 0 then + "backlog artifact links ambiguous or unparseable, parsed links withheld: " + ($links_ambiguous | join(", ")) else null end) as $reason | { valid:$valid, reason:$reason, orphan_in_flight:$orphan_in_flight, - unstructured_current_count:($unstructured_current | length) + unstructured_current_count:($unstructured_current | length), + backlog_error:$backlog_error, + links_ambiguous_ids:$links_ambiguous }' } @@ -1033,7 +1106,9 @@ secondmate_home_summary_json() { # | select(.id == $work.id and (.current_state.state == "done" or .current_state.state == "failed")) | {id,state:.current_state.state} ]) as $terminal_in_flight | ([if $backlog.present != true then - {kind:"missing_backlog",ids:[],reason:"missing structured backlog"} + {kind:"missing_backlog",ids:[], + reason:(if ($backlog.error // null) != null then "Backlog unavailable: " + $backlog.error + else "missing structured backlog" end)} else empty end, if ($unstructured_current | length) > 0 then {kind:"unstructured_current",ids:[],reason:"unstructured current backlog row"} diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index 909c792b294..562ca3a1d7f 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -56,8 +56,11 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' if ($r.blocked_by // "") == "" then "-" elif ($r.blocked_reason // "") == "" then $r.blocked_by else "\($r.blocked_by) - \($r.blocked_reason)" end; + def backlog_artifact($r): + if $r.links_ambiguous == true then "links ambiguous: \($r.links_raw)" + else dash($r.pr_url // $r.report_path // $r.local_note) end; def backlog_row($r): - "| \($r.id // "-") | \(dash($r.title // $r.raw)) | \(dash($r.repo)) | \(dash($r.kind)) | \(blocker($r)) | \(dash($r.pr_url // $r.report_path // $r.local_note)) |"; + "| \($r.id // "-") | \(dash($r.title // $r.raw)) | \(dash($r.repo)) | \(dash($r.kind)) | \(blocker($r)) | \(backlog_artifact($r)) |"; "# Fleet View", "", @@ -74,7 +77,9 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' end), "", "## Queued", - (if ([.backlog.records[]? | select(.state == "queued")] | length) == 0 then + (if (.backlog.error // null) != null then + "Backlog unavailable: \(.backlog.error)" + elif ([.backlog.records[]? | select(.state == "queued")] | length) == 0 then "No queued backlog records found." else "| ID | Title | Repo | Kind | Blocked By | Artifact |", @@ -83,7 +88,9 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' end), "", "## Done", - (if ([.backlog.records[]? | select(.state == "done")] | length) == 0 then + (if (.backlog.error // null) != null then + "Backlog unavailable: \(.backlog.error)" + elif ([.backlog.records[]? | select(.state == "done")] | length) == 0 then "No done backlog records found." else "| ID | Title | Repo | Kind | Blocked By | Artifact |", From 78614b84e39bb1e35a59fe736bdda24b4c385d04 Mon Sep 17 00:00:00 2001 From: KhalilouKarl Date: Fri, 2 Oct 2026 01:13:03 +0200 Subject: [PATCH 3/9] test: cover the fleet backlog against the real tasks-axi/Beads adapter and captured output --- docs/documentation-audiences.json | 4 + tests/captures/tasks-axi-0.2.5/README.md | 20 + .../tasks-axi-0.2.5/error-unavailable.toon | 3 + .../tasks-axi-0.2.5/list-all-states.toon | 12 + .../captures/tasks-axi-0.2.5/list-empty.toon | 5 + .../tasks-axi-0.2.5/list-limited.toon | 8 + tests/fm-fleet-snapshot-view.test.sh | 401 ++++++++++++++++++ 7 files changed, 453 insertions(+) create mode 100644 tests/captures/tasks-axi-0.2.5/README.md create mode 100644 tests/captures/tasks-axi-0.2.5/error-unavailable.toon create mode 100644 tests/captures/tasks-axi-0.2.5/list-all-states.toon create mode 100644 tests/captures/tasks-axi-0.2.5/list-empty.toon create mode 100644 tests/captures/tasks-axi-0.2.5/list-limited.toon diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index a064b1c51a4..8a866832468 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -548,6 +548,10 @@ "path": "tests/captures/no-mistakes-v1.70.1/README.md", "audience": "maintainer-verification" }, + { + "path": "tests/captures/tasks-axi-0.2.5/README.md", + "audience": "maintainer-verification" + }, { "path": ".agents/skills/agent-skill-trigger-index/SKILL.md", "audience": "agent-runtime" diff --git a/tests/captures/tasks-axi-0.2.5/README.md b/tests/captures/tasks-axi-0.2.5/README.md new file mode 100644 index 00000000000..6075f6197cf --- /dev/null +++ b/tests/captures/tasks-axi-0.2.5/README.md @@ -0,0 +1,20 @@ +# tasks-axi list captures + +These files own recorded `tasks-axi list` stdout for the adapter cases in `../../fm-fleet-snapshot-view.test.sh`. +They were captured on 2026-10-02 with `tasks-axi 0.2.5` and the Beads backend (`br 0.7.3`) in an isolated fixture home with fixture-only rows. +They are replay inputs for shapes a real adapter cannot be made to emit on demand, not evidence that the composed scenarios were driven live; the same test file also drives the real installed `tasks-axi` and `br` when both are present. + +## Capture provenance + +Every list file is unchanged stdout from `tasks-axi list --fields blocked,blocked_by,closed,created,deps,held,hold_kind,hold_reason,hold_until,links,priority` run from the fixture home that holds the Beads workspace. +The snapshot reader asks for exactly these fields, so the header columns are `id,state,kind,repo,title` followed by them in that order. +Stderr was empty and the exit status was zero for every list capture. + +| File | Observed state | +| --- | --- | +| `list-all-states.toon` | Seven rows: in flight, queued with a blocker and a PR link, a captain hold with a long reason, a done row with a PR link, a long truncated title, two PR links, and a PR URL and report path that both contain commas | +| `list-limited.toon` | The same home read with `--limit 3`, which prints `count: 3 of 7 total` | +| `list-empty.toon` | A genuinely empty initialized backend | +| `error-unavailable.toon` | A configured Beads binary that is not on `PATH`: TOON diagnostic on stdout, exit status 1 | + +The test edits copies of `list-all-states.toon` to build malformed and unparseable-link cases; the captures themselves stay unchanged. diff --git a/tests/captures/tasks-axi-0.2.5/error-unavailable.toon b/tests/captures/tasks-axi-0.2.5/error-unavailable.toon new file mode 100644 index 00000000000..781f3a88eec --- /dev/null +++ b/tests/captures/tasks-axi-0.2.5/error-unavailable.toon @@ -0,0 +1,3 @@ +error: "beads binary \"/nonexistent/fleet-probe-br\" is not on PATH" +code: UNSUPPORTED +help[1]: "Install beads_rust (br) or set `[beads] binary` in .tasks.toml" diff --git a/tests/captures/tasks-axi-0.2.5/list-all-states.toon b/tests/captures/tasks-axi-0.2.5/list-all-states.toon new file mode 100644 index 00000000000..b57f1d005bf --- /dev/null +++ b/tests/captures/tasks-axi-0.2.5/list-all-states.toon @@ -0,0 +1,12 @@ +count: 7 +tasks[7]{id,state,kind,repo,title,blocked,blocked_by,closed,created,deps,held,hold_kind,hold_reason,hold_until,links,priority}: + odd-links,queued,ship,fixture,"Odd links https://github.com/o/a,b/pull/7 data/a,report:b/report.md",no,none,"-",2026-10-02,none,no,"-","-","-","pr:https://github.com/o/a,b/pull/7,report:data/a,report:b/report.md","-" + two-prs,queued,ship,fixture,"Two PRs https://github.com/o/r/pull/1 https://github.com/o/r/pull/2",no,none,"-",2026-10-02,none,no,"-","-","-","pr:https://github.com/o/r/pull/1,pr:https://github.com/o/r/pull/2","-" + long-title,queued,ship,fixture,"Long title xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n... (truncated, 211 chars total - use show long-title --full to see complete text)",no,none,"-",2026-10-02,none,no,"-","-","-",none,"-" + done-work,done,ship,fixture,"Fixture landed https://github.com/o/r/pull/338",no,none,2026-10-02,2026-10-02,none,no,"-","-","-","pr:https://github.com/o/r/pull/338","-" + held-choice,queued,captain,fixture,Fixture captain decision,no,none,"-",2026-10-02,none,yes,captain,"Fixture decision pending, with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason with a long reason","-",none,"-" + queued-work,queued,ship,fixture,"Fixture queued, with comma and \"quote\" and unicode äö https://github.com/o/r/pul\n... (truncated, 85 chars total - use show queued-work --full to see complete text)",yes,in-flight-work,"-",2026-10-02,"blocked-by:in-flight-work",no,"-","-","-","pr:https://github.com/o/r/pull/339","-" + in-flight-work,in_flight,ship,fixture,Fixture underway,no,none,"-",2026-10-02,none,no,"-","-","-",none,"-" +help[2]: + - Run `tasks-axi show ` for full notes on a task + - Run `tasks-axi ready` to see unblocked queued work diff --git a/tests/captures/tasks-axi-0.2.5/list-empty.toon b/tests/captures/tasks-axi-0.2.5/list-empty.toon new file mode 100644 index 00000000000..85a2c8c3278 --- /dev/null +++ b/tests/captures/tasks-axi-0.2.5/list-empty.toon @@ -0,0 +1,5 @@ +count: 0 +tasks: 0 tasks in this backlog +help[2]: + - "Run `tasks-axi add \"\"` to add a task" + - Run `tasks-axi list --state done` to see completed work diff --git a/tests/captures/tasks-axi-0.2.5/list-limited.toon b/tests/captures/tasks-axi-0.2.5/list-limited.toon new file mode 100644 index 00000000000..43d9b86e114 --- /dev/null +++ b/tests/captures/tasks-axi-0.2.5/list-limited.toon @@ -0,0 +1,8 @@ +count: 3 of 7 total +tasks[3]{id,state,kind,repo,title,blocked,blocked_by,closed,created,deps,held,hold_kind,hold_reason,hold_until,links,priority}: + odd-links,queued,ship,fixture,"Odd links https://github.com/o/a,b/pull/7 data/a,report:b/report.md",no,none,"-",2026-10-02,none,no,"-","-","-","pr:https://github.com/o/a,b/pull/7,report:data/a,report:b/report.md","-" + two-prs,queued,ship,fixture,"Two PRs https://github.com/o/r/pull/1 https://github.com/o/r/pull/2",no,none,"-",2026-10-02,none,no,"-","-","-","pr:https://github.com/o/r/pull/1,pr:https://github.com/o/r/pull/2","-" + long-title,queued,ship,fixture,"Long title xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n... (truncated, 211 chars total - use show long-title --full to see complete text)",no,none,"-",2026-10-02,none,no,"-","-","-",none,"-" +help[2]: + - Run `tasks-axi show <id>` for full notes on a task + - Run `tasks-axi ready` to see unblocked queued work diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 1238568f31f..d32d52d0f44 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1152,6 +1152,403 @@ EOF pass "home-summary excludes kind=secondmate from unowned_current and terminal_in_flight" } +# --- Backlogs kept by a non-markdown tasks-axi backend ---------------------------------- +# The structured snapshot must show the configured backend's work, never the empty +# shadow data/backlog.md beside it, and an adapter that cannot be read is a +# diagnostic rather than an empty valid inventory. The real installed tasks-axi and +# br (Beads) are exercised in isolated fixture homes behind a guarded br and +# refusing endpoint stubs; captured real output and edited copies of it cover the +# shapes a real adapter cannot be made to emit on demand. + +ADAPTER_CAPTURES="$ROOT/tests/captures/tasks-axi-0.2.5" + +real_adapter_available() { # prints the explicit not-run line when tasks-axi or br is absent + if command -v tasks-axi >/dev/null 2>&1 && command -v br >/dev/null 2>&1; then + return 0 + fi + printf 'skip: real tasks-axi and br (Beads) are not installed; adapter checks not run\n' + return 1 +} + +adapter_guard() { # <name> - a fixture directory with a guarded br and refusing endpoint stubs + local guard=$TMP_ROOT/$1 tool real_br + mkdir -p "$guard/guard-bin" + real_br=$(command -v br 2>/dev/null || printf '%s' /nonexistent/br) + cat > "$guard/guard-bin/br" <<SH +#!/usr/bin/env bash +# Guarded: only inside this test's temporary root and never with an explicit database path. +case "\$PWD/" in "$TMP_ROOT"/*) ;; *) echo "guarded br: refusing cwd \$PWD" >&2; exit 90 ;; esac +for arg in "\$@"; do case "\$arg" in --db|--db=*) echo "guarded br: refusing --db" >&2; exit 91 ;; esac; done +exec "$real_br" "\$@" +SH + for tool in gh gh-axi curl ssh; do + cat > "$guard/guard-bin/$tool" <<SH +#!/usr/bin/env bash +echo "\$0 \$*" >> "$guard/endpoint-calls" +echo "fixture refuses endpoint tool $tool" >&2 +exit 91 +SH + done + chmod +x "$guard/guard-bin"/* + make_fakebin "$guard" > /dev/null + printf '%s\n' "$guard" +} + +adapter_run() { # <home> <guard> <command...> - one command against one fixture home only + local home=$1 guard=$2 + shift 2 + PATH="$guard/guard-bin:$guard/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" FM_PROJECTS_OVERRIDE="$home/projects" \ + FM_SNAPSHOT_NOW=2026-10-02T00:00:00Z "$@" +} + +adapter_axi() { # <home> <guard> <tasks-axi arguments...> + local home=$1 guard=$2 + shift 2 + (cd "$home" && adapter_run "$home" "$guard" tasks-axi "$@" > /dev/null) \ + || fail "fixture tasks-axi $* failed in $home" +} + +adapter_home() { # <name> <guard> beads|markdown|shim [br-binary] - a fixture home with its own addressing root + local home guard=$2 kind=$3 binary=${4:-$2/guard-bin/br} + home=$(make_home "$1") + case "$kind" in + beads) + mkdir -p "$home/backend/.beads" + printf 'backend="beads"\n[beads]\npath="%s"\nbinary="%s"\nactor="fixture"\n' "$home/backend" "$binary" > "$home/.tasks.toml" + (cd "$home/backend" && PATH="$guard/guard-bin:$PATH" br init --prefix fx --json > /dev/null 2>&1) \ + || fail "br init failed for $home" + ;; + markdown) + printf 'backend="markdown"\n[markdown]\npath="data/backlog.md"\n' > "$home/.tasks.toml" + ;; + shim) + printf 'backend="beads"\n[beads]\npath="%s"\nbinary="br"\nactor="fixture"\n' "$home/backend" > "$home/.tasks.toml" + ;; + esac + printf '%s\n' "$home" +} + +adapter_populate() { # <home> <guard> - the same logical rows whichever backend keeps them + local home=$1 guard=$2 reason + reason="Fixture decision pending, with a comma and a long reason $(printf 'abcdefghij%.0s' $(seq 1 32))" + mkdir -p "$home/data/done-report" + printf '# Report\n' > "$home/data/done-report/report.md" + adapter_axi "$home" "$guard" add in-flight-work "Fixture underway" --kind ship --repo fixture --start + adapter_axi "$home" "$guard" add queued-work 'Fixture queued, with comma and "quote" and unicode äö' --kind ship --repo fixture \ + --blocked-by in-flight-work --pr https://github.com/o/r/pull/339 + adapter_axi "$home" "$guard" add held-choice "Fixture captain decision" --kind captain --repo fixture + adapter_axi "$home" "$guard" hold held-choice --reason "$reason" --kind captain + adapter_axi "$home" "$guard" add dated-hold "Fixture deferred decision" --kind captain --repo fixture + adapter_axi "$home" "$guard" hold dated-hold --reason "Deferred to later" --kind captain --until 2099-01-01 + adapter_axi "$home" "$guard" add done-work "Fixture landed" --kind ship --repo fixture + adapter_axi "$home" "$guard" "done" done-work --pr https://github.com/o/r/pull/338 --no-prune + adapter_axi "$home" "$guard" add done-report "Fixture scout" --kind scout --repo fixture + adapter_axi "$home" "$guard" "done" done-report --report data/done-report/report.md --no-prune + adapter_axi "$home" "$guard" add done-local "Fixture local landing local main" --kind ship --repo fixture + adapter_axi "$home" "$guard" "done" done-local --no-prune + adapter_axi "$home" "$guard" add resolved-blocker "Fixture blocker" --kind ship --repo fixture + adapter_axi "$home" "$guard" add after-blocker "Fixture waits on a finished blocker" --kind ship --repo fixture --blocked-by resolved-blocker + adapter_axi "$home" "$guard" "done" resolved-blocker --no-prune +} + +adapter_snapshot() { # <home> <guard> + adapter_run "$1" "$2" "$SNAPSHOT" --json +} + +test_adapter_backlog_reaches_snapshot_view_and_bearings() { + real_adapter_available || return 0 + local guard beads markdown shadow_data snap md_snap view bearings listed total pad n + guard=$(adapter_guard adapter-real-guard) + beads=$(adapter_home adapter-beads "$guard" beads) + markdown=$(adapter_home adapter-markdown "$guard" markdown) + adapter_populate "$beads" "$guard" + adapter_populate "$markdown" "$guard" + adapter_axi "$beads" "$guard" add long-title "Long title $(printf 'x%.0s' $(seq 1 200))" --kind ship --repo fixture + # More rows than any small page, plus a stale Markdown shadow that must not mask the adapter. + for n in $(seq 1 24); do + adapter_axi "$beads" "$guard" add "bulk-$(printf '%02d' "$n")" "Bulk row $n" --kind ship --repo fixture + done + printf '## Queued\n- [ ] shadow-only - Stale shadow row (repo: fixture) (kind: ship)\n' > "$beads/data/backlog.md" + + snap=$(adapter_snapshot "$beads" "$guard") || fail "adapter-backed snapshot failed" + md_snap=$(adapter_snapshot "$markdown" "$guard") || fail "markdown control snapshot failed" + listed=$(cd "$beads" && adapter_run "$beads" "$guard" tasks-axi list --fields links | sed -n 's/^count: \([0-9][0-9]*\)$/\1/p') + [ -n "$listed" ] && [ "$listed" -ge 30 ] || fail "fixture should hold more than a small page of rows, got '$listed'" + total=$(printf '%s' "$snap" | jq '.backlog.records | length') + [ "$total" = "$listed" ] || fail "snapshot has $total records but tasks-axi lists $listed" + printf '%s' "$snap" | jq -e ' + .backlog.source == "tasks-axi" and .backlog.present == true and .backlog.error == null + and ([.backlog.records[].id] | index("shadow-only") | not) + and (.main_inventory.backlog_error == null) + ' > /dev/null || fail "adapter backlog was masked by the stale shadow file or flagged unavailable" + printf '%s' "$snap" | jq -e --arg reason "Fixture decision pending, with a comma and a long reason $(printf 'abcdefghij%.0s' $(seq 1 32))" ' + def rec($id): .backlog.records[] | select(.id == $id); + (rec("in-flight-work") | .state == "in_flight" and .structured == true and .repo == "fixture" and .kind == "ship") + and (rec("queued-work") | .state == "queued" and .blocked_by_ids == ["in-flight-work"] + and .unresolved_blocker_ids == ["in-flight-work"] + and .pr_url == "https://github.com/o/r/pull/339" and .links == ["https://github.com/o/r/pull/339"] + and (.title | startswith("Fixture queued, with comma and \"quote\" and unicode äö"))) + and (rec("held-choice") | .hold_kind == "captain" and .hold_reason == $reason and .hold_bucket == "live" + and .captain_actionable == true) + and (rec("dated-hold") | .hold_bucket == "dated" and .hold_until == "2099-01-01" and .captain_actionable == false) + and (rec("done-work") | .state == "done" and .completion.verb == "merged" and .pr_url == "https://github.com/o/r/pull/338") + and (rec("done-report") | .state == "done" and .kind == "scout" and .completion.verb == "reported" + and .report_path == "data/done-report/report.md") + and (rec("done-local") | .completion.verb == "done" and .local_note == "local main") + and (rec("after-blocker") | .blocked_by_ids == ["resolved-blocker"] and .unresolved_blocker_ids == []) + ' > /dev/null || fail "adapter rows did not keep their states, holds, dependencies, links or long values" + printf '%s' "$snap" | jq -e ' + .backlog.records[] | select(.id == "long-title") + | .title_truncated == true and (.title | endswith("…")) and ((.title | contains("--full")) | not) + and ((.title | contains("tasks-axi")) | not) and (.title_raw | contains("(truncated, ")) + and .title_chars_total == 211 + ' > /dev/null || fail "a truncated title must render with an ellipsis and keep the raw cell and its length" + # The same logical rows classify identically under either backend. + jq -n --argjson a "$snap" --argjson b "$md_snap" ' + def pick($s; $id): $s.backlog.records[] | select(.id == $id) + | {state,current_role,hold_bucket,captain_actionable,unresolved_blocker_ids,kind,repo}; + ["in-flight-work","queued-work","held-choice","dated-hold","done-work","after-blocker"] + | all(.[]; . as $id | pick($a; $id) == pick($b; $id)) + ' | grep -qx true || fail "markdown control and adapter-backed rows should classify identically" + printf '%s' "$snap" | jq -e ' + .main_inventory.valid == false and .main_inventory.orphan_in_flight == ["in-flight-work"] + and .main_inventory.reason == "in-flight backlog item has no child metadata" + ' > /dev/null || fail "an adapter in-flight row without child metadata must disclose the orphan" + + view=$(adapter_run "$beads" "$guard" "$VIEW") || fail "fleet view failed on the adapter-backed home" + assert_contains "$view" "| queued-work | Fixture queued, with comma and \"quote\" and unicode äö… | fixture | ship | in-flight-work | https://github.com/o/r/pull/339 |" \ + "view should render the adapter queued row with its blocker and artifact" + assert_contains "$view" "| done-work | Fixture landed | fixture | ship | - | https://github.com/o/r/pull/338 |" \ + "view should render the adapter done row" + assert_not_contains "$view" "No queued backlog records found." "view must not call a populated adapter backlog empty" + assert_not_contains "$view" "shadow-only" "view must not render the stale shadow row" + assert_not_contains "$view" "tasks-axi show" "view must not print backend help text inside a title" + + bearings=$(adapter_run "$beads" "$guard" "$ROOT/bin/fm-bearings-snapshot.sh" --json --all-queued --all-landed) \ + || fail "bearings failed on the adapter-backed home" + printf '%s' "$bearings" | jq -e ' + ([.decisions_open[].id] | index("held-choice") != null) + and ([.decisions_open[].id] | index("dated-hold") == null) + and ([.gates[].id] | index("dated-hold") != null) + and ([.gates[].id] | index("queued-work") != null) + and ([.gates[].id] | index("(main-inventory)") != null) + and ([.landed[].id] | index("done-work") != null) + and ([.omitted[].surface] | map(select(startswith("main in-flight backlog item(s) have no child metadata"))) | length == 1) + ' > /dev/null || fail "bearings should surface the adapter held call, dated gate, landed row and orphan disclosure" + [ ! -e "$guard/endpoint-calls" ] || fail "fixture homes must never reach an endpoint tool: $(cat "$guard/endpoint-calls")" + pass "a real tasks-axi/Beads backlog reaches the snapshot, view and bearings, ignoring a stale shadow file" +} + +test_adapter_unavailable_unreadable_and_empty_backlogs() { + real_adapter_available || return 0 + local guard missing unreadable empty snap view bearings + guard=$(adapter_guard adapter-failure-guard) + missing=$(adapter_home adapter-missing-binary "$guard" beads /nonexistent/fleet-test-br) + unreadable=$(adapter_home adapter-unreadable "$guard" markdown) + rm -f "$unreadable/.tasks.toml" + mkdir "$unreadable/.tasks.toml" + empty=$(adapter_home adapter-empty "$guard" beads) + printf '## Queued\n- [ ] shadow-only - Stale shadow row (repo: fixture) (kind: ship)\n' > "$missing/data/backlog.md" + + snap=$(adapter_snapshot "$missing" "$guard") || fail "snapshot must report an unavailable adapter, not fail" + printf '%s' "$snap" | jq -e ' + .backlog.present == false and .backlog.records == [] and (.backlog.error | contains("is not on PATH")) + and .main_inventory.valid == false and (.main_inventory.reason | startswith("Backlog unavailable: ")) + ' > /dev/null || fail "an unavailable adapter must be a diagnostic, not an empty valid backlog" + view=$(adapter_run "$missing" "$guard" "$VIEW") || fail "fleet view must render an unavailable adapter" + assert_contains "$view" "Backlog unavailable: " "view should say the backlog is unavailable" + assert_not_contains "$view" "No queued backlog records found." "an unavailable backlog is not an empty queue" + assert_not_contains "$view" "shadow-only" "the shadow file must not stand in for an unavailable adapter" + bearings=$(adapter_run "$missing" "$guard" "$ROOT/bin/fm-bearings-snapshot.sh" --json) || fail "bearings failed on an unavailable adapter" + printf '%s' "$bearings" | jq -e ' + .gates[] | select(.id == "(main-inventory)") | (.title | startswith("Backlog unavailable")) + ' > /dev/null || fail "bearings should gate on the unavailable backlog through the main inventory" + + snap=$(adapter_snapshot "$unreadable" "$guard") || fail "snapshot must report an unreadable configuration, not fail" + printf '%s' "$snap" | jq -e ' + .backlog.present == false and (.backlog.error | contains("configuration cannot be read")) + and .main_inventory.valid == false and (.main_inventory.reason | startswith("Backlog unavailable: ")) + ' > /dev/null || fail "an unreadable tasks-axi configuration must be a diagnostic" + + snap=$(adapter_snapshot "$empty" "$guard") || fail "snapshot failed on an empty initialized adapter" + printf '%s' "$snap" | jq -e ' + .backlog.present == true and .backlog.source == "tasks-axi" and .backlog.error == null and .backlog.records == [] + and .main_inventory.valid == true + ' > /dev/null || fail "a genuinely empty initialized adapter stays a valid empty inventory" + view=$(adapter_run "$empty" "$guard" "$VIEW") + assert_contains "$view" "No queued backlog records found." "an empty initialized adapter is an honest empty queue" + pass "unavailable, unreadable and empty adapter backlogs are told apart" +} + +test_adapter_link_values_parse_faithfully_with_the_adapter_grammar() { + real_adapter_available || return 0 + local guard home snap + guard=$(adapter_guard adapter-links-guard) + home=$(adapter_home adapter-links "$guard" beads) + adapter_axi "$home" "$guard" add pr-comma "PR with comma path" --kind ship --repo fixture --pr 'https://github.com/o/a,b/pull/7' + adapter_axi "$home" "$guard" add report-comma "Report with commas" --kind scout --repo fixture --report 'data/a,report:b/report.md' + adapter_axi "$home" "$guard" add pair "PR and report" --kind ship --repo fixture --pr https://github.com/o/r/pull/4 --report data/pair/report.md + adapter_axi "$home" "$guard" add two-prs "Two PRs" --kind ship --repo fixture + adapter_axi "$home" "$guard" update two-prs --pr https://github.com/o/r/pull/1 + adapter_axi "$home" "$guard" update two-prs --pr https://github.com/o/r/pull/2 + adapter_axi "$home" "$guard" add two-reports "Two reports" --kind scout --repo fixture --report data/two-reports/report.md + adapter_axi "$home" "$guard" update two-reports --report data/other/report.md + adapter_axi "$home" "$guard" add quote-report "Report with a quote" --kind scout --repo fixture --report 'data/we"ird/report.md' + adapter_axi "$home" "$guard" add generic-urls "See https://example.com/a,b and https://example.org/c" --kind ship --repo fixture + snap=$(adapter_snapshot "$home" "$guard") || fail "adapter-backed snapshot failed" + printf '%s' "$snap" | jq -e ' + def rec($id): .backlog.records[] | select(.id == $id); + (rec("pr-comma") | .links_ambiguous == false and .pr_url == "https://github.com/o/a,b/pull/7") + and (rec("report-comma") | .links_ambiguous == false and .report_path == "data/a,report:b/report.md") + and (rec("pair") | .pr_url == "https://github.com/o/r/pull/4" and .report_path == "data/pair/report.md") + and (rec("two-prs") | .links == ["https://github.com/o/r/pull/1", "https://github.com/o/r/pull/2"]) + and (rec("two-reports") | .report_path == "data/two-reports/report.md" and .links_ambiguous == false) + and (rec("quote-report") | .report_path == "data/we\"ird/report.md") + and (rec("generic-urls") | .links_ambiguous == true and .pr_url == null and .report_path == null and .links == [] + and (.links_raw | contains("doc:https://example.com/a,b,doc:https://example.org/c"))) + and .main_inventory.valid == false + and (.main_inventory.reason | contains("links ambiguous or unparseable") and contains("generic-urls")) + and .main_inventory.links_ambiguous_ids == ["generic-urls"] + ' > /dev/null || fail "link values must parse with the adapter grammar and an unprovable cell must withhold its artifacts" + pass "real adapter link cells parse faithfully, and an unprovable doc-link cell is withheld with a disclosure" +} + +# A tasks-axi stand-in that replays captured real output, for the shapes a real adapter +# cannot be made to emit on demand. It logs every call so reads can be counted. +adapter_shim() { # <guard> + local guard=$1 + cat > "$guard/guard-bin/tasks-axi" <<SH +#!/usr/bin/env bash +echo "\$*" >> "$guard/shim-calls" +if [ "\${1:-}" != list ]; then echo "shim: only list is replayed" >&2; exit 90; fi +mode=\$(cat "$guard/shim-mode") +case "\$mode" in + sleep) sleep 5 ;; +esac +case "\$*" in + *--limit*) cat "$guard/shim-limited" 2>/dev/null || cat "$guard/shim-out" ;; + *) cat "$guard/shim-out" ;; +esac +case "\$mode" in + stderr-only) echo "shim: backend configuration cannot be read" >&2; exit 2 ;; +esac +exit "\$(cat "$guard/shim-exit" 2>/dev/null || echo 0)" +SH + chmod +x "$guard/guard-bin/tasks-axi" + : > "$guard/shim-calls" +} + +shim_set() { # <guard> <mode> <stdout-file|-> [exit-status] + printf '%s\n' "$2" > "$1/shim-mode" + if [ "$3" = - ]; then : > "$1/shim-out"; else cp "$3" "$1/shim-out"; fi + printf '%s\n' "${4:-0}" > "$1/shim-exit" + rm -f "$1/shim-limited" + : > "$1/shim-calls" +} + +test_adapter_reads_are_bounded_complete_and_strictly_decoded() { + local guard home snap variant out calls start end + guard=$(adapter_guard adapter-shim-guard) + adapter_shim "$guard" + home=$(adapter_home adapter-shim "$guard" shim) + + shim_set "$guard" ok "$ADAPTER_CAPTURES/list-all-states.toon" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot failed on captured adapter output" + printf '%s' "$snap" | jq -e ' + (.backlog.records | length) == 7 and .backlog.present == true + and (.backlog.records[] | select(.id == "odd-links") | .pr_url == "https://github.com/o/a,b/pull/7" + and .report_path == "data/a,report:b/report.md") + and (.backlog.records[] | select(.id == "two-prs") | .links | length == 2) + and (.backlog.records[] | select(.id == "long-title") | .title_truncated and (.title | endswith("…"))) + ' > /dev/null || fail "captured real list output should decode into records" + [ "$(wc -l < "$guard/shim-calls" | tr -d ' ')" = 1 ] || fail "a complete read is exactly one list call" + grep -q ' show ' "$guard/shim-calls" && fail "the reader must not issue per-row show reads" + + # An explicit count marker is incomplete: one bounded re-read with the reported total, never a loop. + # The first call (no --limit) answers with the limited capture, the re-read (--limit 7) with the full one. + shim_set "$guard" ok "$ADAPTER_CAPTURES/list-limited.toon" + cp "$ADAPTER_CAPTURES/list-all-states.toon" "$guard/shim-limited" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot failed on an incomplete first read" + printf '%s' "$snap" | jq -e '.backlog.present == true and (.backlog.records | length) == 7' > /dev/null \ + || fail "an incomplete first read should be completed by one --limit re-read" + [ "$(wc -l < "$guard/shim-calls" | tr -d ' ')" = 2 ] || fail "incomplete read should cost exactly two list calls: $(cat "$guard/shim-calls")" + sed -n '2p' "$guard/shim-calls" | grep -q -- '--limit 7' || fail "the re-read must ask for the reported total" + + rm -f "$guard/shim-limited" + shim_set "$guard" ok "$ADAPTER_CAPTURES/list-limited.toon" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot failed on persistently incomplete output" + printf '%s' "$snap" | jq -e ' + .backlog.present == false and .backlog.records == [] and (.backlog.error | contains("incomplete adapter output: 3 of 7 rows")) + and .main_inventory.valid == false and (.main_inventory.reason | startswith("Backlog unavailable: ")) + ' > /dev/null || fail "a persistently incomplete read must be a diagnostic, not a partial inventory" + [ "$(wc -l < "$guard/shim-calls" | tr -d ' ')" = 2 ] || fail "incomplete reads must stop after one re-read" + + # Malformed envelopes are unavailable, never an empty or partial backlog. + for variant in count header columns short extra quote state duplicate id help; do + out=$TMP_ROOT/shim-malformed-$variant.toon + case "$variant" in + count) sed '1s/^count: 7$/count: 6/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + header) sed 's/^tasks\[7\]/tasks[8]/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + columns) sed '2s/,priority}/,priority,extra}/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + short) sed '9d' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + extra) { sed -n '1,9p' "$ADAPTER_CAPTURES/list-all-states.toon"; printf ' stray,queued,ship,fixture,Stray\n'; sed -n '10,$p' "$ADAPTER_CAPTURES/list-all-states.toon"; } > "$out" ;; + quote) sed '3s/"Odd links/"Odd "links/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + state) sed '9s/,in_flight,/,flying,/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + duplicate) sed '9s/^ in-flight-work,/ odd-links,/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + id) sed '9s/^ in-flight-work,/ in flight work,/' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + help) sed '/^help\[/,$d' "$ADAPTER_CAPTURES/list-all-states.toon" > "$out" ;; + esac + shim_set "$guard" ok "$out" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot must survive malformed output ($variant)" + printf '%s' "$snap" | jq -e ' + .backlog.present == false and .backlog.records == [] and (.backlog.error | startswith("malformed adapter output")) + and .main_inventory.valid == false + ' > /dev/null || fail "malformed adapter output ($variant) must be a diagnostic, got: $(printf '%s' "$snap" | jq -c '.backlog.error')" + done + + # A link cell the grammar cannot consume entirely never fabricates an artifact. + sed '3s#"pr:https://github.com/o/a,b/pull/7,report:data/a,report:b/report.md"#"pr:https://h/pull/1,pr:https://h/pull/2/pull/3"#' \ + "$ADAPTER_CAPTURES/list-all-states.toon" > "$TMP_ROOT/shim-ambiguous.toon" + shim_set "$guard" ok "$TMP_ROOT/shim-ambiguous.toon" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot failed on an unparseable link cell" + printf '%s' "$snap" | jq -e ' + (.backlog.records[] | select(.id == "odd-links") + | .links_ambiguous == true and .links_raw == "pr:https://h/pull/1,pr:https://h/pull/2/pull/3" + and .pr_url == null and .report_path == null and .links == [] and .state == "queued" and .kind == "ship") + and (.backlog.records[] | select(.id == "done-work") | .pr_url == "https://github.com/o/r/pull/338") + and .main_inventory.valid == false and .main_inventory.links_ambiguous_ids == ["odd-links"] + ' > /dev/null || fail "an unconsumable link cell must keep raw evidence and withhold parsed artifacts" + view=$(adapter_run "$home" "$guard" "$VIEW") + assert_contains "$view" "links ambiguous: pr:https://h/pull/1,pr:https://h/pull/2/pull/3" "view should show the raw ambiguous cell" + + # Failure shapes: TOON diagnostic on stdout, text on stderr only, and a read past its bound. + shim_set "$guard" ok "$ADAPTER_CAPTURES/error-unavailable.toon" 1 + snap=$(adapter_snapshot "$home" "$guard") + printf '%s' "$snap" | jq -e '.backlog.present == false and (.backlog.error | contains("is not on PATH (UNSUPPORTED)"))' > /dev/null \ + || fail "a TOON error on stdout should become the diagnostic" + shim_set "$guard" stderr-only - 2 + snap=$(adapter_snapshot "$home" "$guard") + printf '%s' "$snap" | jq -e '.backlog.present == false and (.backlog.error | length > 0)' > /dev/null \ + || fail "an empty failing read should still be a diagnostic" + shim_set "$guard" sleep "$ADAPTER_CAPTURES/list-all-states.toon" + start=$(date +%s) + snap=$(FM_BACKLOG_ROWS_TIMEOUT_SECS=1 adapter_snapshot "$home" "$guard") + end=$(date +%s) + printf '%s' "$snap" | jq -e '.backlog.present == false and (.backlog.error | contains("exceeded its 1s backlog read bound"))' > /dev/null \ + || fail "a wedged adapter read must be bounded and reported" + [ $((end - start)) -lt 5 ] || fail "the read bound must cut a wedged adapter short" + + # A markdown home never calls tasks-axi at all. + home=$(make_home adapter-shim-markdown) + printf '## Queued\n- [ ] only-row - Only row (repo: fixture) (kind: ship)\n' > "$home/data/backlog.md" + : > "$guard/shim-calls" + snap=$(adapter_snapshot "$home" "$guard") || fail "markdown home snapshot failed" + printf '%s' "$snap" | jq -e '.backlog.records | length == 1' > /dev/null || fail "markdown home should keep its file" + [ ! -s "$guard/shim-calls" ] || fail "a markdown backlog must not call tasks-axi: $(cat "$guard/shim-calls")" + pass "adapter reads are bounded, complete or diagnosed, strictly decoded, and markdown homes are untouched" +} + test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory @@ -1170,3 +1567,7 @@ test_scout_reports_include_teardown_reports test_backlog_tasks_axi_forms_and_overrides test_view_renders_snapshot test_view_renders_dead_secondmate_agent_status +test_adapter_reads_are_bounded_complete_and_strictly_decoded +test_adapter_backlog_reaches_snapshot_view_and_bearings +test_adapter_unavailable_unreadable_and_empty_backlogs +test_adapter_link_values_parse_faithfully_with_the_adapter_grammar From 55632910ef919945be44448534de212b8a5ba20c Mon Sep 17 00:00:00 2001 From: KhalilouKarl <k.ounzarni@gmail.com> Date: Fri, 2 Oct 2026 01:54:21 +0200 Subject: [PATCH 4/9] no-mistakes(test): Decode fm-hold-v1 hold reasons on the tasks-axi backlog path --- bin/fm-fleet-snapshot.sh | 6 ++++-- tests/fm-fleet-snapshot-view.test.sh | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 0d61c132098..dc427b8214c 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -469,7 +469,8 @@ BACKLOG_JQ_COMMON=' # adapter through the shared backlog reader, shaped like the markdown records. Returns 1 when the markdown # file is the backlog (the default, config/backlog-backend=manual, or no data directory), so that path # stays exactly as it was. An unreadable configuration or an unavailable, malformed or incomplete adapter -# read is present:false with an error and never an empty valid backlog. +# read is present:false with an error and never an empty valid backlog. Hold reasons come back from +# tasks-axi still in their stored fm-hold-v1 form, so they are decoded here exactly as for markdown. backlog_adapter_json() { local root backend status rows fm_backlog_backend_manual "$CONFIG" && return 1 @@ -486,12 +487,13 @@ backlog_adapter_json() { else rows=$(fm_backlog_rows_json "$DATA") || return 2 fi + set -o pipefail # runs in backlog_json's subshell printf '%s\n' "$rows" | jq -c --arg path "$BACKLOG" --arg backend "$backend" --arg today "$SNAPSHOT_TODAY" \ --arg now "$SNAPSHOT_NOW" --argjson age_days "$FM_SNAPSHOT_UNDATED_HOLD_AGE_DAYS" "$BACKLOG_JQ_COMMON"' {path:$path,present:(.ok == true),source:"tasks-axi",backend:$backend, error:(if .ok == true then null else (.error // "backlog read failed") end), records:(if .ok == true then [.rows[] | del(._deps,._held,._blocked)] else [] end)} - | finish_records' || return 2 + | finish_records' | fm_hold_reason_decode_stream json || return 2 } backlog_json() ( # [<backlog-path>] - defaults to this home's $BACKLOG diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index d32d52d0f44..2640fa95dde 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1258,12 +1258,16 @@ adapter_snapshot() { # <home> <guard> test_adapter_backlog_reaches_snapshot_view_and_bearings() { real_adapter_available || return 0 - local guard beads markdown shadow_data snap md_snap view bearings listed total pad n + local guard beads markdown shadow_data snap md_snap view bearings listed total pad n encoded guard=$(adapter_guard adapter-real-guard) beads=$(adapter_home adapter-beads "$guard" beads) markdown=$(adapter_home adapter-markdown "$guard" markdown) adapter_populate "$beads" "$guard" adapter_populate "$markdown" "$guard" + # A captain hold stores its reason as fm-hold-v1 text (bin/fm-captain-hold.sh); the snapshot decodes it like markdown does. + encoded=$(. "$ROOT/bin/fm-hold-reason-lib.sh" && fm_hold_reason_encode $'Pick (A) or (B)\n100% sure?') + adapter_axi "$beads" "$guard" add encoded-hold "Fixture encoded decision" --kind captain --repo fixture + adapter_axi "$beads" "$guard" hold encoded-hold --reason "$encoded" --kind captain adapter_axi "$beads" "$guard" add long-title "Long title $(printf 'x%.0s' $(seq 1 200))" --kind ship --repo fixture # More rows than any small page, plus a stale Markdown shadow that must not mask the adapter. for n in $(seq 1 24); do @@ -1298,6 +1302,9 @@ test_adapter_backlog_reaches_snapshot_view_and_bearings() { and (rec("done-local") | .completion.verb == "done" and .local_note == "local main") and (rec("after-blocker") | .blocked_by_ids == ["resolved-blocker"] and .unresolved_blocker_ids == []) ' > /dev/null || fail "adapter rows did not keep their states, holds, dependencies, links or long values" + printf '%s' "$snap" | jq -e ' + .backlog.records[] | select(.id == "encoded-hold") | .hold_reason == "Pick (A) or (B)\n100% sure?" + ' > /dev/null || fail "an fm-hold-v1 hold reason on the adapter backend must be decoded: $(printf '%s' "$snap" | jq -c '.backlog.records[] | select(.id == "encoded-hold") | .hold_reason')" printf '%s' "$snap" | jq -e ' .backlog.records[] | select(.id == "long-title") | .title_truncated == true and (.title | endswith("…")) and ((.title | contains("--full")) | not) From 47c5d9221bbf2bb1d98784417bfb79b5cb73930f Mon Sep 17 00:00:00 2001 From: KhalilouKarl <k.ounzarni@gmail.com> Date: Fri, 2 Oct 2026 01:59:28 +0200 Subject: [PATCH 5/9] no-mistakes(document): Document configured backlog backend in fleet snapshot --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4b2b6f9cbfe..b3cf1b8670c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,7 @@ Published-contribution records, PR verdict freshness against the observed curren GitHub PRs and issues are observed; unsupported forges remain disclosed as unmeasured coverage rather than fleet work. The existing Bearings Captain's Call consumes that coverage, and its skill owns supervisor triage through existing captain holds and durable check wakes. -For whole-fleet review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, local current crew state, supervision-owned endpoint evidence, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. +For whole-fleet review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the configured backlog backend (the script header owns how a non-markdown tasks-axi backend is read and how an unavailable one is disclosed), task metadata, local current crew state, supervision-owned endpoint evidence, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. Each home atomically publishes that bounded home summary with freshness epoch metadata at `state/home-summary.json` after a locked session start, a watcher-observed status change, task spawn, task teardown, and on a recurring live-watcher cadence; `bin/fm-home-summary-refresh.sh` owns the publication mechanics. The fleet snapshot and Bearings paths use the concurrent remote-ledger collection, cache, unreadable-home disclosure, and remote-liveness boundary owned by `bin/fm-fleet-snapshot.sh`'s header. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. From e36d55abb7f23591ab921ce8714f9852448de6ee Mon Sep 17 00:00:00 2001 From: KhalilouKarl <k.ounzarni@gmail.com> Date: Fri, 2 Oct 2026 02:03:18 +0200 Subject: [PATCH 6/9] test: add adapter hold-reason decode parity regression Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- tests/fm-fleet-snapshot-view.test.sh | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 2640fa95dde..f4a3d3d8887 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1556,6 +1556,63 @@ test_adapter_reads_are_bounded_complete_and_strictly_decoded() { pass "adapter reads are bounded, complete or diagnosed, strictly decoded, and markdown homes are untouched" } +test_adapter_hold_reasons_decode_like_markdown() { + real_adapter_available || return 0 + local guard beads markdown home owner reason encoded stored snap md_snap view bearings + reason=$'Pick (A) or (B)\n100% sure?' + guard=$(adapter_guard adapter-hold-guard) + beads=$(adapter_home adapter-hold-beads "$guard" beads) + markdown=$(adapter_home adapter-hold-markdown "$guard" markdown) + # bin/fm-captain-hold.sh is the owner of the stored form and needs a tasks-axi at or above + # FM_TASKS_AXI_MIN; below it the owner's encoder and the real tasks-axi hold write the same stored value. + owner=encoder + if (. "$ROOT/bin/fm-tasks-axi-lib.sh" && PATH="$guard/guard-bin:$PATH" fm_tasks_axi_compatible); then + owner=script + fi + for home in "$beads" "$markdown"; do + if [ "$owner" = script ]; then + adapter_run "$home" "$guard" bash "$ROOT/bin/fm-captain-hold.sh" hold encoded-call \ + --title "Fixture encoded call" --repo fixture --reason "$reason" > /dev/null \ + || fail "fm-captain-hold.sh hold failed for the encoded reason in $home" + adapter_run "$home" "$guard" bash "$ROOT/bin/fm-captain-hold.sh" hold plain-call \ + --title "Fixture plain call" --repo fixture --reason "Plain reason, with a comma" > /dev/null \ + || fail "fm-captain-hold.sh hold failed for the plain reason in $home" + else + encoded=$(. "$ROOT/bin/fm-hold-reason-lib.sh" && fm_hold_reason_encode "$reason") + adapter_axi "$home" "$guard" add encoded-call "Fixture encoded call" --kind captain --repo fixture + adapter_axi "$home" "$guard" hold encoded-call --reason "$encoded" --kind captain + adapter_axi "$home" "$guard" add plain-call "Fixture plain call" --kind captain --repo fixture + adapter_axi "$home" "$guard" hold plain-call --reason "Plain reason, with a comma" --kind captain + fi + done + # The reason really is stored encoded on the adapter backend, so a decode that never ran cannot pass. + stored=$(cd "$beads" && adapter_run "$beads" "$guard" tasks-axi list --fields hold_reason | grep '^ encoded-call,') + case "$stored" in + *fm-hold-v1:*) ;; + *) fail "the adapter backend should store the encoded reason, got: $stored" ;; + esac + snap=$(adapter_snapshot "$beads" "$guard") || fail "adapter-backed snapshot failed" + md_snap=$(adapter_snapshot "$markdown" "$guard") || fail "markdown control snapshot failed" + printf '%s' "$snap" | jq -e --arg reason "$reason" ' + def rec($id): .backlog.records[] | select(.id == $id); + (rec("encoded-call") | .hold_reason == $reason and .hold_kind == "captain" and .hold_bucket == "live" + and .captain_actionable == true) + and (rec("plain-call") | .hold_reason == "Plain reason, with a comma" and .captain_actionable == true) + ' > /dev/null || fail "adapter hold reasons should be decoded once and plain reasons left alone: $(printf '%s' "$snap" | jq -c '[.backlog.records[] | {id,hold_reason}]')" + jq -n --argjson a "$snap" --argjson b "$md_snap" ' + def pick($s; $id): $s.backlog.records[] | select(.id == $id) | {hold_reason,hold_kind,hold_bucket,captain_actionable}; + all(["encoded-call","plain-call"][]; . as $id | pick($a; $id) == pick($b; $id)) + ' | grep -qx true || fail "adapter and markdown homes should expose identical hold reasons" + view=$(adapter_run "$beads" "$guard" "$VIEW") || fail "fleet view failed on the adapter-backed home" + assert_not_contains "$view" "fm-hold-v1:" "the fleet view must not show the stored hold encoding" + bearings=$(adapter_run "$beads" "$guard" "$ROOT/bin/fm-bearings-snapshot.sh" --json) || fail "bearings failed on the adapter-backed home" + printf '%s' "$bearings" | jq -e ' + ([.decisions_open[] | select(.id == "encoded-call") | .summary | contains("Pick (A) or (B)") and (contains("fm-hold-v1") | not)] == [true]) + and ([.decisions_open[] | select(.id == "plain-call") | .summary | contains("Plain reason, with a comma")] == [true]) + ' > /dev/null || fail "bearings should show the decoded reason in its open decisions" + pass "captain hold reasons on a real tasks-axi/Beads home decode like markdown, plain reasons unchanged (owner: $owner)" +} + test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory @@ -1578,3 +1635,4 @@ test_adapter_reads_are_bounded_complete_and_strictly_decoded test_adapter_backlog_reaches_snapshot_view_and_bearings test_adapter_unavailable_unreadable_and_empty_backlogs test_adapter_link_values_parse_faithfully_with_the_adapter_grammar +test_adapter_hold_reasons_decode_like_markdown From 971194e54d6af483711237c8b6038bca99719492 Mon Sep 17 00:00:00 2001 From: KhalilouKarl <k.ounzarni@gmail.com> Date: Fri, 2 Oct 2026 02:04:30 +0200 Subject: [PATCH 7/9] no-mistakes(lint): Fix hold-reason test grep indentation and jq continuation --- tests/fm-fleet-snapshot-view.test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index f4a3d3d8887..69a51d003b4 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1586,7 +1586,7 @@ test_adapter_hold_reasons_decode_like_markdown() { fi done # The reason really is stored encoded on the adapter backend, so a decode that never ran cannot pass. - stored=$(cd "$beads" && adapter_run "$beads" "$guard" tasks-axi list --fields hold_reason | grep '^ encoded-call,') + stored=$(cd "$beads" && adapter_run "$beads" "$guard" tasks-axi list --fields hold_reason | grep '^ encoded-call,') case "$stored" in *fm-hold-v1:*) ;; *) fail "the adapter backend should store the encoded reason, got: $stored" ;; @@ -1596,7 +1596,7 @@ test_adapter_hold_reasons_decode_like_markdown() { printf '%s' "$snap" | jq -e --arg reason "$reason" ' def rec($id): .backlog.records[] | select(.id == $id); (rec("encoded-call") | .hold_reason == $reason and .hold_kind == "captain" and .hold_bucket == "live" - and .captain_actionable == true) + and .captain_actionable == true) and (rec("plain-call") | .hold_reason == "Plain reason, with a comma" and .captain_actionable == true) ' > /dev/null || fail "adapter hold reasons should be decoded once and plain reasons left alone: $(printf '%s' "$snap" | jq -c '[.backlog.records[] | {id,hold_reason}]')" jq -n --argjson a "$snap" --argjson b "$md_snap" ' From 339cf84051eb9afa098044571528891a945cfbae Mon Sep 17 00:00:00 2001 From: KhalilouKarl <k.ounzarni@gmail.com> Date: Fri, 2 Oct 2026 02:46:22 +0200 Subject: [PATCH 8/9] fix(fleet): clear TASKS_AXI_FILE on backlog reads, require complete empty response, escape pipes - fm_backlog_rows_list_once runs tasks-axi list with TASKS_AXI_FILE cleared, like bin/fm-tasks-axi.sh. - The count: 0 branch accepts only the full captured empty response (tasks line plus help block). - Fixture helpers unset TASKS_AXI_FILE so fixtures stay isolated. - The fleet view escapes | in the raw ambiguous link value. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- bin/fm-backlog-transition-lib.sh | 5 +- bin/fm-fleet-view.sh | 2 +- tests/fm-fleet-snapshot-view.test.sh | 89 +++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index eedecd4bbd8..5ff72d67c03 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -589,7 +589,8 @@ def validate_row($r): ($cnt.n | tonumber) as $n | (if $cnt.m == null then $n else ($cnt.m | tonumber) end) as $total | if $n != $total then {ok: false, incomplete: true, count: $n, total: $total, error: "incomplete adapter output: \($n) of \($total) rows"} - elif $n == 0 and ($lines | length) >= 2 and ($lines[1] | test("^tasks: 0 tasks in this backlog$")) then {ok: true, rows: []} + elif $n == 0 and ($lines | length) >= 4 and ($lines[1] | test("^tasks: 0 tasks in this backlog$")) + and ($lines[2] | test("^help\\[[0-9]+\\]:( .*)?$")) and ($lines[3:] | all(test("^ - .*$"))) then {ok: true, rows: []} else ($lines[1] // "" | cap("^tasks\\[(?<k>[0-9]+)\\]\\{(?<cols>[a-z_,]+)\\}:$")) as $hdr | if $hdr == null then bad("malformed adapter output: no table header") @@ -657,7 +658,7 @@ fm_backlog_rows_list_once() { # [flag...] sets FM_BACKLOG_ROWS_OUT, FM_BACKLOG_ return 1 } # shellcheck disable=SC2016 # Expansion is deliberately deferred to the child shell. - FM_BACKLOG_ROWS_OUT=$(fm_run_timed "$secs" bash -c 'cd "$1" 2>/dev/null || exit 1; shift; exec tasks-axi list "$@"' \ + FM_BACKLOG_ROWS_OUT=$(fm_run_timed "$secs" bash -c 'cd "$1" 2>/dev/null || exit 1; shift; exec env -u TASKS_AXI_FILE tasks-axi list "$@"' \ _ "$FM_BACKLOG_AXI_ROOT" --fields "$FM_BACKLOG_ROWS_FIELDS" "$@" 2>"$errfile") status=$? FM_BACKLOG_ROWS_ERR=$(sed -n '1p' "$errfile" 2>/dev/null) diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index 562ca3a1d7f..cf86a50946b 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -57,7 +57,7 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' elif ($r.blocked_reason // "") == "" then $r.blocked_by else "\($r.blocked_by) - \($r.blocked_reason)" end; def backlog_artifact($r): - if $r.links_ambiguous == true then "links ambiguous: \($r.links_raw)" + if $r.links_ambiguous == true then "links ambiguous: \($r.links_raw | gsub("\\|"; "\\|"))" else dash($r.pr_url // $r.report_path // $r.local_note) end; def backlog_row($r): "| \($r.id // "-") | \(dash($r.title // $r.raw)) | \(dash($r.repo)) | \(dash($r.kind)) | \(blocker($r)) | \(backlog_artifact($r)) |"; diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 69a51d003b4..b79425b94b7 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1197,7 +1197,7 @@ SH adapter_run() { # <home> <guard> <command...> - one command against one fixture home only local home=$1 guard=$2 shift 2 - PATH="$guard/guard-bin:$guard/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ + env -u TASKS_AXI_FILE PATH="$guard/guard-bin:$guard/fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" \ FM_DATA_OVERRIDE="$home/data" FM_CONFIG_OVERRIDE="$home/config" FM_PROJECTS_OVERRIDE="$home/projects" \ FM_SNAPSHOT_NOW=2026-10-02T00:00:00Z "$@" } @@ -1428,6 +1428,7 @@ adapter_shim() { # <guard> cat > "$guard/guard-bin/tasks-axi" <<SH #!/usr/bin/env bash echo "\$*" >> "$guard/shim-calls" +echo "TASKS_AXI_FILE=\${TASKS_AXI_FILE-unset}" > "$guard/shim-env" if [ "\${1:-}" != list ]; then echo "shim: only list is replayed" >&2; exit 90; fi mode=\$(cat "$guard/shim-mode") case "\$mode" in @@ -1613,6 +1614,88 @@ test_adapter_hold_reasons_decode_like_markdown() { pass "captain hold reasons on a real tasks-axi/Beads home decode like markdown, plain reasons unchanged (owner: $owner)" } +test_adapter_fixtures_ignore_an_inherited_tasks_axi_file() { + real_adapter_available || return 0 + local guard home decoy decoy_before snap + guard=$(adapter_guard adapter-axi-file-guard) + home=$(adapter_home adapter-axi-file "$guard" markdown) + decoy=$TMP_ROOT/adapter-axi-file-decoy.md + printf '## Queued\n- [ ] decoy-row - Decoy row (repo: decoy) (kind: ship)\n' > "$decoy" + decoy_before=$(cat "$decoy") + + # A markdown fixture is where an inherited override would redirect a write. + export TASKS_AXI_FILE="$decoy" + adapter_axi "$home" "$guard" add fixture-row "Fixture row" --kind ship --repo fixture + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot failed with an inherited TASKS_AXI_FILE" + unset TASKS_AXI_FILE + [ "$(cat "$decoy")" = "$decoy_before" ] || fail "fixture helpers must not write through an inherited TASKS_AXI_FILE" + printf '%s' "$snap" | jq -e '[.backlog.records[].id] == ["fixture-row"]' > /dev/null \ + || fail "fixture helpers should write the fixture backlog, got: $(printf '%s' "$snap" | jq -c '[.backlog.records[].id]')" + pass "an inherited TASKS_AXI_FILE does not redirect the fixture helpers" +} + +test_adapter_list_clears_an_inherited_tasks_axi_file() { + local guard home snap + guard=$(adapter_guard adapter-list-axi-file-guard) + adapter_shim "$guard" + home=$(adapter_home adapter-list-axi-file "$guard" shim) + shim_set "$guard" ok "$ADAPTER_CAPTURES/list-all-states.toon" + snap=$(adapter_run "$home" "$guard" env TASKS_AXI_FILE="$TMP_ROOT/adapter-list-decoy.md" "$SNAPSHOT" --json) \ + || fail "snapshot failed with a decoy TASKS_AXI_FILE" + printf '%s' "$snap" | jq -e '.backlog.present == true and (.backlog.records | length) == 7' > /dev/null \ + || fail "the snapshot should show the adapter rows" + [ "$(cat "$guard/shim-env")" = "TASKS_AXI_FILE=unset" ] \ + || fail "tasks-axi list must run with TASKS_AXI_FILE cleared, saw: $(cat "$guard/shim-env")" + pass "the adapter read runs with an inherited TASKS_AXI_FILE cleared" +} + +test_adapter_empty_backlog_requires_the_complete_response() { + local guard home snap variant out + guard=$(adapter_guard adapter-empty-shim-guard) + adapter_shim "$guard" + home=$(adapter_home adapter-empty-shim "$guard" shim) + + shim_set "$guard" ok "$ADAPTER_CAPTURES/list-empty.toon" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot failed on the captured empty response" + printf '%s' "$snap" | jq -e '.backlog.present == true and .backlog.error == null and .backlog.records == [] and .main_inventory.valid == true' > /dev/null \ + || fail "the complete captured empty response stays a valid empty inventory" + + for variant in count-only no-help bad-help trailing-garbage bad-help-line; do + out=$TMP_ROOT/shim-empty-$variant.toon + case "$variant" in + count-only) sed -n '1p' "$ADAPTER_CAPTURES/list-empty.toon" > "$out" ;; + no-help) sed -n '1,2p' "$ADAPTER_CAPTURES/list-empty.toon" > "$out" ;; + bad-help) { sed -n '1,2p' "$ADAPTER_CAPTURES/list-empty.toon"; printf 'garbage\n'; } > "$out" ;; + trailing-garbage) { cat "$ADAPTER_CAPTURES/list-empty.toon"; printf 'garbage\n'; } > "$out" ;; + bad-help-line) { cat "$ADAPTER_CAPTURES/list-empty.toon"; printf 'tasks: 0 tasks in this backlog\n'; } > "$out" ;; + esac + shim_set "$guard" ok "$out" + snap=$(adapter_snapshot "$home" "$guard") || fail "snapshot must survive a bad empty response ($variant)" + printf '%s' "$snap" | jq -e ' + .backlog.present == false and .backlog.records == [] and (.backlog.error | startswith("malformed adapter output")) + and .main_inventory.valid == false and (.main_inventory.reason | startswith("Backlog unavailable: ")) + ' > /dev/null || fail "an incomplete empty response ($variant) must be Backlog unavailable, got: $(printf '%s' "$snap" | jq -c '[.backlog.present, .backlog.error]')" + done + pass "only the complete captured empty response is a valid empty backlog" +} + +test_view_escapes_pipes_in_ambiguous_link_values() { + local guard home view row cells + guard=$(adapter_guard adapter-pipe-guard) + adapter_shim "$guard" + home=$(adapter_home adapter-pipe "$guard" shim) + sed '3s#"pr:https://github.com/o/a,b/pull/7,report:data/a,report:b/report.md"#"pr:https://h/pull/1|x,pr:https://h/pull/2/pull/3"#' \ + "$ADAPTER_CAPTURES/list-all-states.toon" > "$TMP_ROOT/shim-pipe.toon" + shim_set "$guard" ok "$TMP_ROOT/shim-pipe.toon" + view=$(adapter_run "$home" "$guard" "$VIEW") || fail "fleet view failed on an ambiguous link value with a pipe" + row=$(printf '%s\n' "$view" | grep '^| odd-links |') + [ -n "$row" ] || fail "view should render the odd-links row" + assert_contains "$row" 'links ambiguous: pr:https://h/pull/1\|x,' "the raw ambiguous value should keep its pipe, escaped" + cells=$(printf '%s' "$row" | sed 's/\\|//g' | tr -cd '|' | wc -c | tr -d ' ') + [ "$cells" = 7 ] || fail "an ambiguous value with a pipe must keep the six-column table row, got $((cells - 1)) cells: $row" + pass "a pipe in an ambiguous link value does not add a table column" +} + test_empty_fleet_json test_fixture_snapshot_json test_home_summary_excludes_secondmate_from_child_inventory @@ -1636,3 +1719,7 @@ test_adapter_backlog_reaches_snapshot_view_and_bearings test_adapter_unavailable_unreadable_and_empty_backlogs test_adapter_link_values_parse_faithfully_with_the_adapter_grammar test_adapter_hold_reasons_decode_like_markdown +test_adapter_fixtures_ignore_an_inherited_tasks_axi_file +test_adapter_list_clears_an_inherited_tasks_axi_file +test_adapter_empty_backlog_requires_the_complete_response +test_view_escapes_pipes_in_ambiguous_link_values From cacf227278fa8dace5b14d0dd508b6b94aec9894 Mon Sep 17 00:00:00 2001 From: KhalilouKarl <k.ounzarni@gmail.com> Date: Fri, 2 Oct 2026 03:22:26 +0200 Subject: [PATCH 9/9] fix(fleet): do not label an adapter row merged from a PR link alone tasks-axi done --pr links a PR without checking merge state, so a closed row with a PR link proves closure and an artifact, not a merge. Keep the row, link and close date; leave the completion verb unset so it is not reported as landed. Explicit Markdown merged metadata is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- bin/fm-backlog-transition-lib.sh | 2 +- tests/fm-fleet-snapshot-view.test.sh | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/fm-backlog-transition-lib.sh b/bin/fm-backlog-transition-lib.sh index 5ff72d67c03..739a619f739 100644 --- a/bin/fm-backlog-transition-lib.sh +++ b/bin/fm-backlog-transition-lib.sh @@ -539,7 +539,7 @@ def build_row($c; $cols; $order): | ([$lk.items[] | select(.kind == "report") | .value][0] // null) as $report | (if $f.state == "done" and $tp.visible != null and ($tp.visible | test("[[:space:]]local main$")) then "local main" else null end) as $local_note | (if $f.state != "done" then {verb: null, date: null} - elif $pr != null then {verb: "merged", date: ($f.closed | dash_null)} + # tasks-axi records no merge state: `done --pr` links a PR without checking it, so a link is never proof of a merge elif $report != null and $f.kind == "scout" then {verb: "reported", date: ($f.closed | dash_null)} elif $local_note != null then {verb: "done", date: ($f.closed | dash_null)} else {verb: null, date: ($f.closed | dash_null)} end) as $completion diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index b79425b94b7..0f7fba9f5fd 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -1296,7 +1296,8 @@ test_adapter_backlog_reaches_snapshot_view_and_bearings() { and (rec("held-choice") | .hold_kind == "captain" and .hold_reason == $reason and .hold_bucket == "live" and .captain_actionable == true) and (rec("dated-hold") | .hold_bucket == "dated" and .hold_until == "2099-01-01" and .captain_actionable == false) - and (rec("done-work") | .state == "done" and .completion.verb == "merged" and .pr_url == "https://github.com/o/r/pull/338") + and (rec("done-work") | .state == "done" and .completion.verb == null and .merged == null + and .completion.date != null and .closed == .completion.date and .pr_url == "https://github.com/o/r/pull/338") and (rec("done-report") | .state == "done" and .kind == "scout" and .completion.verb == "reported" and .report_path == "data/done-report/report.md") and (rec("done-local") | .completion.verb == "done" and .local_note == "local main") @@ -1340,9 +1341,14 @@ test_adapter_backlog_reaches_snapshot_view_and_bearings() { and ([.gates[].id] | index("dated-hold") != null) and ([.gates[].id] | index("queued-work") != null) and ([.gates[].id] | index("(main-inventory)") != null) - and ([.landed[].id] | index("done-work") != null) + and ([.landed[].id] | index("done-work") == null) + and ([.landed[].id] | index("done-report") != null) and ([.omitted[].surface] | map(select(startswith("main in-flight backlog item(s) have no child metadata"))) | length == 1) - ' > /dev/null || fail "bearings should surface the adapter held call, dated gate, landed row and orphan disclosure" + ' > /dev/null || fail "bearings should surface the adapter held call, dated gate and orphan disclosure, and must not report a PR-linked closed row as landed" + # tasks-axi records no merge state, so a closed row with a PR link is closed work with an unverified merge; explicit Markdown merged metadata still lands. + printf '%s' "$md_snap" | jq -e ' + .backlog.records[] | select(.id == "done-work") | .completion.verb == "merged" and .pr_url == "https://github.com/o/r/pull/338" + ' > /dev/null || fail "the Markdown control should still report its explicitly merged row as merged" [ ! -e "$guard/endpoint-calls" ] || fail "fixture homes must never reach an endpoint tool: $(cat "$guard/endpoint-calls")" pass "a real tasks-axi/Beads backlog reaches the snapshot, view and bearings, ignoring a stale shadow file" }