diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index b1a50800c63..ad98b79fc14 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -57,9 +57,7 @@ Never describe the session as reset-safe while the memory total is over budget o - Project-intrinsic knowledge never goes directly into a project's `AGENTS.md`. Route it through a normal ship task so a crewmate records it with `bin/fm-ensure-agents-md.sh` and the project's delivery path. - Knowledge general to every Firstmate user belongs in this repo's shared tracked material through the normal branch, no-mistakes, PR, and captain-merge path. - - For task-scoped notes, inspect the item with `bin/fm-tasks-axi.sh show --full`, classify the change as new, duplicate, superseding, or obsolete, then use a considered replacement body through `bin/fm-tasks-axi.sh update --body-file `. - Use `--archive-body` when recoverability matters. - Never append. + - For task-scoped notes, inspect the current item and classify the change as new, duplicate, superseding, or obsolete, then follow the [`bin/fm-tasks-axi.sh` header](../../../bin/fm-tasks-axi.sh) for `append-note` or a considered body replacement. - File each undone next step as a queued backlog item with a genuine `blocked-by` dependency when applicable. 4. **Use inspect-then-update.** For every retained fact, ask which current statement it supersedes, whether it can be a one-sentence rewrite, and whether a stale entry should be deleted, retired, or routed to an existing stronger owner. diff --git a/AGENTS.md b/AGENTS.md index b011c737887..bfbd01cb1dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -382,7 +382,7 @@ Use compatible `tasks-axi` when the configured backend selects it, always throug `secondmate-provisioning` and `bin/fm-backlog-handoff.sh` own cross-home handoff safety. Keep free-form notes free of temporary paths, moving versions, ephemeral identifiers, and copied state that will rot. -Inspect the current task note before replacing its considered body, and archive the superseded body when recoverability matters rather than appending by default. +Inspect the current task note before changing it; the `bin/fm-tasks-axi.sh` header owns safe additions with `append-note` and considered body replacements with `--archive-body` when needed. Verify volatile details against their authoritative config, live system, or API before acting, and correct or delete stale prose immediately. Preserve durable structured identifiers, dependencies, and completion artifact links, and route reusable knowledge to section 6 rather than scattering it through task notes. diff --git a/bin/fm-tasks-axi.sh b/bin/fm-tasks-axi.sh index b773014a115..0dffde28948 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -2,12 +2,14 @@ # fm-tasks-axi.sh - run tasks-axi against THIS home's backlog from any working directory. # # Usage: fm-tasks-axi.sh [ [args...]] +# fm-tasks-axi.sh append-note (--body | --body-file ) # fm-tasks-axi.sh --help # # Every routine firstmate backlog read or mutation goes through this command # rather than a bare `tasks-axi`; `fm-tasks-axi.sh --help` prints -# tasks-axi's own help. Arguments reach tasks-axi as given, apart from one -# rewrite that keeps file arguments meaning what the caller meant: a relative +# tasks-axi's own help, except for the wrapper-owned `append-note` command. +# Forwarded arguments reach tasks-axi as given after wrapper checks, apart +# from one rewrite that keeps file arguments meaning what the caller meant: a relative # value of `--to` or any `--*-file` flag (`--body-file`, `--relation-file`, ...) # is made absolute against the caller's working directory, because tasks-axi # starts from the backlog root instead. `--report` stays as given: tasks-axi @@ -32,6 +34,23 @@ # The data directory is FM_DATA_OVERRIDE, else $FM_HOME/data, else the code # root's data/ (FM_HOME unset keeps the single-home layout unchanged). # +# `append-note` is a wrapper-owned command, not a tasks-axi verb: it adds text +# to a task's existing body (joined by a blank line; appended to an empty body +# it becomes the whole body) instead of replacing it, then re-reads the stored +# body and verifies the write landed as intended. Use it for evidence and +# follow-up notes; the prior body is kept inline, so no archive entry is made. +# +# Whole-body replacement with `update`/`edit --body|--body-file` (including +# `task update`/`task edit`) is guarded: without `--archive-body` the wrapper +# reads the current body first +# and refuses (exit 2, nothing written) when that body is non-empty and the +# new text does not contain it verbatim. To genuinely replace a considered +# body, re-run with `--archive-body` so tasks-axi preserves the old body in +# /note-archive.md; to add text, use `append-note`. The guard reads the +# prior body from `tasks-axi show --full` - the only exact read surface, +# since `show` and `list` have no `--json` - and fails closed when that read +# cannot yield exactly one `body:` line or its value cannot be decoded. +# # Refusals (exit 2, nothing run): # - tasks-axi missing from PATH; # - a caller-supplied --file, because this command owns the addressing and @@ -45,7 +64,11 @@ # cannot be read (bin/fm-tasks-axi-lib.sh owns that diagnostic); # - a markdown `/backlog.md` that is itself a symlink, because the # first write would replace the link with a private copy, exactly the fork -# this command exists to prevent. Lifecycle transitions refuse the same file. +# this command exists to prevent. Lifecycle transitions refuse the same file; +# - `update`/`edit` with `--body`/`--body-file` and no `--archive-body` when +# the stored body is non-empty and the new text does not contain it - use +# `append-note` to add text, or `--archive-body` to replace while +# archiving the old body. # Otherwise the exit status is tasks-axi's own. set -u @@ -71,11 +94,32 @@ fail() { exit 2 } +append_note_usage() { + cat <<'EOF' +Usage: fm-tasks-axi.sh append-note (--body | --body-file ) + +Append text to a task's existing body, joined by a blank line, instead of +replacing the body the way `update --body`/`--body-file` does. To replace a +considered body, run `update --body-file --archive-body` so the +old body is archived into /note-archive.md. +EOF +} + case "${1:-}" in -h|--help) usage exit 0 ;; + append-note) + for arg in "${@:2}"; do + case "$arg" in + -h|--help) + append_note_usage + exit 0 + ;; + esac + done + ;; esac CALLER_DIR=$(pwd) @@ -137,4 +181,174 @@ else fi cd "$FM_BACKLOG_AXI_ROOT" || fail "cannot enter the backlog root $FM_BACKLOG_AXI_ROOT" + +# read_prior_body : print the task's current decoded body to stdout. +# `show --full` is the only exact read (show/list reject --json); its `body:` +# line is a bare value or a JSON-quoted string, decoded with node's JSON.parse +# because jq is optional. Fails closed: a failed show exits with its status, +# anything but exactly one decodable body line exits 2. +read_prior_body() { + local id=$1 out status count line decoded + out=$(tasks-axi show "$id" --full 2>&1) || { + status=$? + printf '%s\n' "$out" >&2 + exit "$status" + } + count=$(printf '%s\n' "$out" | grep -c '^ body: ' || :) + line=$(printf '%s\n' "$out" | sed -n 's/^ body: //p') + if [ "$count" -ne 1 ]; then + printf 'fm-tasks-axi: cannot read the current body of %s; refusing to write\n' "$id" >&2 + exit 2 + fi + case "$line" in + \"*) + if ! decoded=$(printf '%s' "$line" | node -e ' + let s = ""; + process.stdin.on("data", (d) => { s += d; }); + process.stdin.on("end", () => { process.stdout.write(JSON.parse(s)); }); + '); then + printf 'fm-tasks-axi: cannot read the current body of %s; refusing to write\n' "$id" >&2 + exit 2 + fi + ;; + *) + decoded=$line + ;; + esac + printf '%s' "$decoded" +} + +strip_trailing_newlines() { # stdin -> stdout + local s + s=$(cat) + while [ "$s" != "${s%$'\n'}" ]; do + s=${s%$'\n'} + done + printf '%s' "$s" +} + +APPEND_TMP= +append_cleanup() { + [ -z "$APPEND_TMP" ] || rm -f -- "$APPEND_TMP" +} + +# append-note (--body | --body-file ) +cmd_append_note() { + local id='' have_body=0 have_file=0 body_text='' body_file='' + local i arg new_text prior new_body status stored + for ((i = 1; i < ${#ARGS[@]}; i++)); do + arg=${ARGS[i]} + case "$arg" in + --body) + have_body=1 + i=$((i + 1)) + body_text=${ARGS[i]-} + ;; + --body-file) + have_file=1 + i=$((i + 1)) + body_file=${ARGS[i]-} + ;; + -*) + append_note_usage >&2 + fail "append-note: unknown flag $arg" + ;; + *) + [ -z "$id" ] || { append_note_usage >&2; fail "append-note: unexpected extra argument $arg"; } + id=$arg + ;; + esac + done + if [ -z "$id" ] || [ "$have_body" = "$have_file" ]; then + append_note_usage >&2 + exit 2 + fi + if [ "$have_file" = 1 ]; then + [ -r "$body_file" ] || fail "append-note: cannot read --body-file $body_file" + new_text=$(cat -- "$body_file") + else + new_text=$(printf '%s' "$body_text" | strip_trailing_newlines) + fi + [ -n "$new_text" ] || fail "append-note: the note text is empty" + + prior=$(read_prior_body "$id") || exit $? + if [ -n "$prior" ]; then + new_body=$(printf '%s\n\n%s' "$prior" "$new_text") + else + new_body=$new_text + fi + + APPEND_TMP=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-tasks-axi-append.XXXXXX") \ + || fail "append-note: cannot stage the new body" + trap append_cleanup EXIT + printf '%s\n' "$new_body" > "$APPEND_TMP" || fail "append-note: cannot stage the new body" + status=0 + tasks-axi update "$id" --body-file "$APPEND_TMP" || status=$? + [ "$status" -eq 0 ] || exit "$status" + + stored=$(read_prior_body "$id") || exit $? + if [ "$stored" != "$new_body" ]; then + { + printf 'fm-tasks-axi: append-note wrote %s but the stored body does not match what was intended; nothing is lost - the prior body was:\n' "$id" + printf '%s\n' "$prior" + } >&2 + exit 2 + fi +} + +# Refuse `update`/`edit --body|--body-file` (without --archive-body) when the +# stored body is non-empty and the new text does not contain it verbatim - the +# tell-tale shape of a caller that meant to append. +guard_body_replace() { + local id='' body_seen=0 archive_seen=0 body_file='' new_text='' prior i arg + for ((i = $1; i < ${#ARGS[@]}; i++)); do + arg=${ARGS[i]} + case "$arg" in + --archive-body) archive_seen=1 ;; + --body) body_seen=1; i=$((i + 1)); new_text=${ARGS[i]-} ;; + --body=*) body_seen=1; new_text=${arg#*=} ;; + --body-file) body_seen=1; i=$((i + 1)); body_file=${ARGS[i]-} ;; + --body-file=*) body_seen=1; body_file=${arg#*=} ;; + --json|-h|--help|-v|-V|--version) ;; + --backend|--title|--repo|--kind|--priority|--pr|--report) + i=$((i + 1)) + ;; + --backend=*|--title=*|--repo=*|--kind=*|--priority=*|--pr=*|--report=*) ;; + -*) fail "cannot safely resolve the task ID with unsupported flag $arg" ;; + *) + [ -z "$id" ] || fail "unexpected extra argument $arg" + id=$arg + ;; + esac + done + [ "$body_seen" = 1 ] || return 0 + [ -n "$id" ] || fail "body replacement requires a task ID" + [ "$archive_seen" = 0 ] || return 0 + if [ -n "$body_file" ]; then + # An unreadable body file is tasks-axi's own error to report. + [ -r "$body_file" ] || return 0 + new_text=$(cat -- "$body_file" 2>/dev/null) || return 0 + fi + prior=$(read_prior_body "$id") || exit $? + [ -n "$prior" ] || return 0 + [[ $new_text == *"$prior"* ]] && return 0 + printf 'fm-tasks-axi: refusing to replace the non-empty body of %s: the new body does not contain the existing text. Use append-note to add text, or re-run with --archive-body to replace it while archiving the old body.\n' "$id" >&2 + exit 2 +} + +case "${ARGS[0]-}" in + append-note) + cmd_append_note + exit $? + ;; + update|edit) + guard_body_replace 1 + ;; + task) + case "${ARGS[1]-}" in + update|edit) guard_body_replace 2 ;; + esac + ;; +esac + exec tasks-axi ${ARGS[@]+"${ARGS[@]}"} diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index c1493cf004e..f7836ff7e05 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -145,7 +145,7 @@ family_for_basename() { fm-pi-compatible-family.test.sh|fm-pi-primary-types.test.sh|fm-reflect-skill.test.sh|\ fm-cleanup-skill.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ - fm-todo-project.test.sh|\ + fm-tasks-axi.test.sh|fm-todo-project.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-task-delivery.test.sh|\ fm-tmux-submit-busy.test.sh|fm-trace-context-lib.test.sh|\ diff --git a/docs/architecture.md b/docs/architecture.md index c232ff47582..e098c926b1c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -397,8 +397,8 @@ The full ownership rule - what is project-intrinsic versus fleet-private, and ho `/stow` sweeps the current session for durable knowledge that only exists in conversation and routes each finding to the most specific disk home. Home-domain captain preferences go to `data/captain.md`, cross-domain shared captain preferences go to the primary home's `data/captain-shared.md`, fleet-local operational facts and gotchas go to home-local `data/learnings.md`, project-intrinsic knowledge goes through normal crewmate delivery into that project's committed `AGENTS.md`, and task-scoped notes or undone next steps go to the backlog. -Memory writes use inspect-then-update: read the current destination first, then rewrite or prune matching bullets or notes in place instead of appending by default. -Task-scoped notes use `bin/fm-tasks-axi.sh show --full` followed by `bin/fm-tasks-axi.sh update --body-file `, adding `--archive-body` when the prior body should remain recoverable. +Memory writes use inspect-then-update: read the current destination first, then rewrite or prune matching memory bullets in place instead of appending by default. +The [`bin/fm-tasks-axi.sh` header](../bin/fm-tasks-axi.sh) owns task-note reads, additions with `append-note`, and considered body replacements. Generalizable firstmate knowledge goes to shared tracked docs through the normal PR pipeline; the firstmate-internal `/stow` deliberately never stores findings in either skill directory. ## Local clones stay fresh diff --git a/tests/fm-tasks-axi.test.sh b/tests/fm-tasks-axi.test.sh new file mode 100755 index 00000000000..02d71b5e447 --- /dev/null +++ b/tests/fm-tasks-axi.test.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-tasks-axi.sh's body-safety contract: +# +# append-note (--body|--body-file) adds text to the existing body +# (joined by a blank line) instead of replacing it, and verifies the stored +# result. +# +# update|edit --body|--body-file without --archive-body refuses (exit 2, +# nothing written) when the stored body is non-empty and the new text does +# not contain it verbatim; --archive-body replaces while archiving, and a +# replace that keeps the prior text proceeds unflagged. +# +# The tests drive the real wrapper and real tasks-axi CLI against a fixture +# home with a markdown backlog, asserting stored bodies via `show --full` - +# never the wrapper's source. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# An exported TASKS_AXI_BACKEND would outrank each case's .tasks.toml fixture. +unset TASKS_AXI_BACKEND || : + +TASKS="$ROOT/bin/fm-tasks-axi.sh" +TMP_ROOT=$(fm_test_tmproot fm-tasks-axi) + +command -v tasks-axi >/dev/null 2>&1 || { + printf 'ok - skipped (tasks-axi is not installed; the body guard and append-note are inert without it)\n' + exit 0 +} + +# --- fixture ---------------------------------------------------------------- + +make_home() { # -> + local home="$TMP_ROOT/$1/home" + mkdir -p "$home/data" + printf '%s\n' '# Backlog' '' '## In flight' '' '## Queued' '' '## Done' \ + > "$home/data/backlog.md" + cat > "$home/.tasks.toml" <<'EOF' +backend = "markdown" + +[markdown] +path = "data/backlog.md" +EOF + printf '%s\n' "$home" +} + +add_item() { # [body] + local home=$1 id=$2 body=${3-} + if [ $# -ge 3 ]; then + FM_HOME=$home "$TASKS" add "$id" "item for $id" --kind ship --body "$body" >/dev/null \ + || fail "could not add $id" + else + FM_HOME=$home "$TASKS" add "$id" "item for $id" --kind ship >/dev/null \ + || fail "could not add $id" + fi +} + +# Decoded body via the wrapper's own read surface (`show --full`). +body_of() { # + local out line + out=$(FM_HOME=$1 "$TASKS" show "$2" --full) || fail "show --full failed for $2: $out" + line=$(printf '%s\n' "$out" | sed -n 's/^ body: //p') + case "$line" in + \"*) + printf '%s' "$line" | node -e ' + let s = ""; + process.stdin.on("data", (d) => { s += d; }); + process.stdin.on("end", () => { process.stdout.write(JSON.parse(s)); }); + ' || fail "could not decode the stored body of $2" + ;; + *) printf '%s' "$line" ;; + esac +} + +# --- append-note ------------------------------------------------------------ + +test_append_note_keeps_the_prior_body_and_adds_text() { + local home id prior new_text body + home=$(make_home append-keeps) + id=append-keep-t1 + prior='a: "b" + +multi line' + new_text='evidence: all green' + add_item "$home" "$id" "$prior" + + FM_HOME=$home "$TASKS" append-note "$id" --body "$new_text" >/dev/null \ + || fail "append-note failed" + + body=$(body_of "$home" "$id") + assert_contains "$body" "$prior" "append-note dropped the prior body" + assert_contains "$body" "$new_text" "append-note did not store the new text" + case "$body" in + *"$prior"$'\n\n'"$new_text") : ;; + *) fail "append-note did not join prior and new text in order: $body" ;; + esac + assert_absent "$home/data/note-archive.md" "append-note must not archive the kept body" + pass "append-note keeps the prior body and appends the new text" +} + +test_append_note_to_an_empty_body_stores_just_the_text() { + local home id body + home=$(make_home append-empty) + id=append-empty-t2 + add_item "$home" "$id" + + FM_HOME=$home "$TASKS" append-note "$id" --body 'first note' >/dev/null \ + || fail "append-note on an empty body failed" + body=$(body_of "$home" "$id") + assert_equals 'first note' "$body" "append-note on an empty body stored the wrong text" + pass "append-note on an empty body stores just the new text" +} + +test_append_note_body_file_resolves_against_the_caller_directory() { + local home id caller body + home=$(make_home append-file) + id=append-file-t3 + caller="$TMP_ROOT/append-file/caller" + mkdir -p "$caller" + printf 'file note text\n' > "$caller/note.md" + add_item "$home" "$id" 'existing body' + + (cd "$caller" && FM_HOME=$home "$TASKS" append-note "$id" --body-file note.md) >/dev/null \ + || fail "append-note --body-file with a relative path failed" + body=$(body_of "$home" "$id") + assert_equals "$(printf 'existing body\n\nfile note text')" "$body" \ + "append-note --body-file stored the wrong body" + pass "append-note resolves a relative --body-file against the caller directory" +} + +test_append_note_on_a_missing_id_fails_and_writes_nothing() { + local home out status=0 + home=$(make_home append-missing) + out=$(FM_HOME=$home "$TASKS" append-note nope-t4 --body 'x' 2>&1) || status=$? + [ "$status" -ne 0 ] || fail "append-note on a missing id succeeded" + assert_no_grep 'nope-t4' "$home/data/backlog.md" "append-note wrote a row for a missing id" + assert_absent "$home/data/note-archive.md" "append-note archived on a missing id" + pass "append-note on a missing id fails and writes nothing" +} + +test_append_note_rejects_removed_options() { + local home id option status out + home=$(make_home append-options) + id=append-options-t11 + add_item "$home" "$id" 'considered body' + printf 'new note\n' > "$TMP_ROOT/append-options/note.md" + for option in --json '--body=new note' "--body-file=$TMP_ROOT/append-options/note.md"; do + status=0 + out=$(FM_HOME=$home "$TASKS" append-note "$id" --body 'new note' "$option" 2>&1) || status=$? + expect_code 2 "$status" "append-note accepted removed option $option" + assert_contains "$out" 'unknown flag' "append-note did not reject $option as an unknown flag" + assert_equals 'considered body' "$(body_of "$home" "$id")" "rejected option changed the body" + assert_absent "$home/data/note-archive.md" "rejected option created an archive" + done + out=$("$TASKS" append-note --help) + assert_contains "$out" '--body | --body-file ' "append help omitted the supported inputs" + [[ $out != *--json* ]] || fail "append help still advertises --json" + out=$("$TASKS" --help) + [[ $out != *'[--json]'* ]] || fail "wrapper help still advertises append JSON output" + pass "append-note rejects removed options and advertises space-separated inputs" +} + +# --- replace guard ---------------------------------------------------------- + +test_update_body_file_dropping_the_body_is_refused() { + local home id status=0 out body + home=$(make_home refuse-file) + id=refuse-file-t5 + add_item "$home" "$id" 'considered body' + printf 'unrelated replacement\n' > "$TMP_ROOT/refuse-file/new.md" + + out=$(FM_HOME=$home "$TASKS" update "$id" --body-file "$TMP_ROOT/refuse-file/new.md" 2>&1) || status=$? + expect_code 2 "$status" "update --body-file dropping a non-empty body" + assert_contains "$out" "append-note" "refusal did not point at append-note" + assert_contains "$out" "--archive-body" "refusal did not name --archive-body" + body=$(body_of "$home" "$id") + assert_equals 'considered body' "$body" "a refused replace still changed the body" + assert_absent "$home/data/note-archive.md" "a refused replace left an archive" + pass "update --body-file dropping a non-empty body is refused" +} + +test_update_body_text_dropping_the_body_is_refused() { + local home id status=0 body + home=$(make_home refuse-body) + id=refuse-body-t6 + add_item "$home" "$id" 'considered body' + + FM_HOME=$home "$TASKS" update "$id" --body 'unrelated replacement' >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "update --body dropping a non-empty body" + body=$(body_of "$home" "$id") + assert_equals 'considered body' "$body" "a refused --body replace still changed the body" + pass "update --body dropping a non-empty body is refused" +} + +test_edit_alias_dropping_the_body_is_refused() { + local home id status=0 body + home=$(make_home refuse-edit) + id=refuse-edit-t7 + add_item "$home" "$id" 'considered body' + + FM_HOME=$home "$TASKS" edit "$id" --body 'unrelated replacement' >/dev/null 2>&1 || status=$? + expect_code 2 "$status" "edit --body dropping a non-empty body" + body=$(body_of "$home" "$id") + assert_equals 'considered body' "$body" "a refused edit still changed the body" + pass "edit --body dropping a non-empty body is refused" +} + +test_update_with_archive_body_replaces_and_archives() { + local home id body + home=$(make_home archive-replace) + id=archive-t8 + add_item "$home" "$id" 'considered body' + printf 'unrelated replacement\n' > "$TMP_ROOT/archive-replace/new.md" + + FM_HOME=$home "$TASKS" update "$id" --body-file "$TMP_ROOT/archive-replace/new.md" --archive-body >/dev/null \ + || fail "update --archive-body was refused or failed" + body=$(body_of "$home" "$id") + assert_equals 'unrelated replacement' "$body" "--archive-body did not store the new body" + assert_grep 'considered body' "$home/data/note-archive.md" \ + "--archive-body did not archive the prior body" + pass "update --archive-body replaces the body and archives the old one" +} + +test_update_keeping_the_prior_body_proceeds_without_the_flag() { + local home id body + home=$(make_home keep-prior) + id=keep-prior-t9 + add_item "$home" "$id" 'considered body' + + FM_HOME=$home "$TASKS" update "$id" --body "$(printf 'considered body\n\nand more')" >/dev/null \ + || fail "a replace containing the prior body was refused" + body=$(body_of "$home" "$id") + assert_equals "$(printf 'considered body\n\nand more')" "$body" \ + "a containing replace stored the wrong body" + pass "a replace whose new text contains the prior body proceeds" +} + +test_update_of_an_empty_body_proceeds_without_the_flag() { + local home id body + home=$(make_home empty-replace) + id=empty-replace-t10 + add_item "$home" "$id" + + FM_HOME=$home "$TASKS" update "$id" --body 'fresh body' >/dev/null \ + || fail "a replace of an empty body was refused" + body=$(body_of "$home" "$id") + assert_equals 'fresh body' "$body" "an empty-body replace stored the wrong body" + pass "a replace of an empty body proceeds without --archive-body" +} + +test_replace_guard_resolves_flags_and_task_aliases() { + local home id verb prefix input status text path out + local -a command_args body_args + for prefix in direct task; do + for verb in update edit; do + for input in body body-equals file file-equals; do + home=$(make_home "resolve-$prefix-$verb-$input") + id="resolve-$prefix-$verb-$input" + path="$home/new.md" + add_item "$home" "$id" 'considered body' + command_args=("$verb") + [ "$prefix" != task ] || command_args=(task "$verb") + for text in 'replacement' 'considered body plus evidence'; do + printf '%s\n' "$text" > "$path" + case "$input" in + body) body_args=(--body "$text") ;; + body-equals) body_args=("--body=$text") ;; + file) body_args=(--body-file "$path") ;; + file-equals) body_args=("--body-file=$path") ;; + esac + status=0 + out=$(FM_HOME=$home "$TASKS" "${command_args[@]}" --json --title 'updated title' \ + "${body_args[@]}" "$id" 2>&1) || status=$? + if [ "$text" = replacement ]; then + expect_code 2 "$status" "$prefix $verb $input bypassed the guard with flags before ID" + assert_equals 'considered body' "$(body_of "$home" "$id")" "refused replace changed the body" + else + expect_code 0 "$status" "$prefix $verb $input refused a preserving replace: $out" + assert_equals "$text" "$(body_of "$home" "$id")" "preserving replace stored the wrong body" + fi + assert_absent "$home/data/note-archive.md" "unarchived replace created an archive" + done + FM_HOME=$home "$TASKS" "${command_args[@]}" --archive-body --json "$id" \ + --body 'archived replacement' >/dev/null || fail "$prefix $verb refused an archived replace" + assert_equals 'archived replacement' "$(body_of "$home" "$id")" "archived replace stored the wrong body" + assert_grep 'considered body plus evidence' "$home/data/note-archive.md" "archived replace lost the prior body" + done + done + done + pass "replace guards cover both verbs, task aliases, body inputs, and flags before ID" +} + +# --- runner ----------------------------------------------------------------- + +test_append_note_keeps_the_prior_body_and_adds_text +test_append_note_to_an_empty_body_stores_just_the_text +test_append_note_body_file_resolves_against_the_caller_directory +test_append_note_on_a_missing_id_fails_and_writes_nothing +test_append_note_rejects_removed_options +test_update_body_file_dropping_the_body_is_refused +test_update_body_text_dropping_the_body_is_refused +test_edit_alias_dropping_the_body_is_refused +test_update_with_archive_body_replaces_and_archives +test_update_keeping_the_prior_body_proceeds_without_the_flag +test_update_of_an_empty_body_proceeds_without_the_flag +test_replace_guard_resolves_flags_and_task_aliases + +printf 'ok - fm-tasks-axi: all cases passed\n'