From d8529d396209218c3082aa210f81b7a1211bdd42 Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 5 Oct 2026 17:29:35 +0800 Subject: [PATCH 1/4] fix(bin): guard backlog bodies against silent replacement `fm-tasks-axi.sh update|edit --body|--body-file` replaced the whole body, so a caller meaning to add evidence could silently drop the prior text. Add a wrapper-owned `append-note` that keeps the prior body and verifies the stored result, and refuse a replace that would drop a non-empty body unless `--archive-body` keeps the old text recoverable. --- .agents/skills/stow/SKILL.md | 5 +- AGENTS.md | 2 +- bin/fm-tasks-axi.sh | 212 +++++++++++++++++++++++++++++- bin/fm-test-run.sh | 2 +- docs/architecture.md | 2 +- tests/fm-tasks-axi.test.sh | 243 +++++++++++++++++++++++++++++++++++ 6 files changed, 459 insertions(+), 7 deletions(-) create mode 100755 tests/fm-tasks-axi.test.sh diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index b1a50800c63..4948ee8f509 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -57,9 +57,8 @@ 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 item with `bin/fm-tasks-axi.sh show --full`, classify the change as new, duplicate, superseding, or obsolete, then add text with `bin/fm-tasks-axi.sh append-note` or write a considered replacement body through `bin/fm-tasks-axi.sh update --body-file --archive-body`. + A replace that drops a non-empty body is refused without `--archive-body`; the script header owns the contract. - 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..3409f530c63 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; add to it with `bin/fm-tasks-axi.sh append-note`, and replace a considered body only with `--archive-body`, since the wrapper refuses a replace that would drop a non-empty body. 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..bacff03bd1c 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -2,6 +2,7 @@ # 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 ) [--json] # fm-tasks-axi.sh --help # # Every routine firstmate backlog read or mutation goes through this command @@ -32,6 +33,22 @@ # 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. +# +# Because `update`/`edit --body|--body-file` replaces the whole body, this +# wrapper guards it: without `--archive-body` it 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 +62,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 +92,32 @@ fail() { exit 2 } +append_note_usage() { + cat <<'EOF' +Usage: fm-tasks-axi.sh append-note (--body | --body-file ) [--json] + +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 +179,172 @@ 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 ) [--json] +cmd_append_note() { + local id= have_body=0 have_file=0 body_text= body_file= json_flag=0 + 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=*) + have_body=1 + body_text=${arg#*=} + ;; + --body-file) + have_file=1 + i=$((i + 1)) + body_file=${ARGS[i]-} + ;; + --body-file=*) + have_file=1 + body_file=${arg#*=} + ;; + --json) + json_flag=1 + ;; + -*) + 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" + local -a update_args=(update "$id" --body-file "$APPEND_TMP") + [ "$json_flag" = 0 ] || update_args+=(--json) + status=0 + tasks-axi "${update_args[@]}" || 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=${ARGS[1]-} body_seen=0 archive_seen=0 body_file= new_text= prior i arg + [ "${id#-}" = "$id" ] || return 0 + for ((i = 2; 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#*=} ;; + esac + done + [ "$body_seen" = 1 ] || return 0 + [ "$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 + ;; +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..1f4a31e03a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -398,7 +398,7 @@ 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. +Task-scoped notes use `bin/fm-tasks-axi.sh show --full` followed by `bin/fm-tasks-axi.sh append-note` to add text or `update --body-file --archive-body` to replace a considered body; the script header owns the details. 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..a9a9bd8a88c --- /dev/null +++ b/tests/fm-tasks-axi.test.sh @@ -0,0 +1,243 @@ +#!/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" +} + +# --- 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" +} + +# --- 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_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 + +printf 'ok - fm-tasks-axi: all cases passed\n' From fdef921995a6f1d7f3e7737e269fa58c6fa35492 Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 5 Oct 2026 19:16:35 +0800 Subject: [PATCH 2/4] fix(bin): quote empty locals in fm-tasks-axi.sh for shellcheck --- bin/fm-tasks-axi.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/fm-tasks-axi.sh b/bin/fm-tasks-axi.sh index bacff03bd1c..e8838f1b42e 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -232,7 +232,7 @@ append_cleanup() { # append-note (--body | --body-file ) [--json] cmd_append_note() { - local id= have_body=0 have_file=0 body_text= body_file= json_flag=0 + local id='' have_body=0 have_file=0 body_text='' body_file='' json_flag=0 local i arg new_text prior new_body status stored for ((i = 1; i < ${#ARGS[@]}; i++)); do arg=${ARGS[i]} @@ -311,7 +311,7 @@ cmd_append_note() { # 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=${ARGS[1]-} body_seen=0 archive_seen=0 body_file= new_text= prior i arg + local id=${ARGS[1]-} body_seen=0 archive_seen=0 body_file='' new_text='' prior i arg [ "${id#-}" = "$id" ] || return 0 for ((i = 2; i < ${#ARGS[@]}; i++)); do arg=${ARGS[i]} From 26be64124fd0727a72e7e1a5d31cae953426c7fb Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 5 Oct 2026 20:24:29 +0800 Subject: [PATCH 3/4] no-mistakes(review): Close body replacement bypasses and simplify append options --- bin/fm-tasks-axi.sh | 46 +++++++++++++------------- tests/fm-tasks-axi.test.sh | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/bin/fm-tasks-axi.sh b/bin/fm-tasks-axi.sh index e8838f1b42e..80bfa23a812 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -2,7 +2,7 @@ # 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 ) [--json] +# 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 @@ -94,7 +94,7 @@ fail() { append_note_usage() { cat <<'EOF' -Usage: fm-tasks-axi.sh append-note (--body | --body-file ) [--json] +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 @@ -230,9 +230,9 @@ append_cleanup() { [ -z "$APPEND_TMP" ] || rm -f -- "$APPEND_TMP" } -# append-note (--body | --body-file ) [--json] +# append-note (--body | --body-file ) cmd_append_note() { - local id='' have_body=0 have_file=0 body_text='' body_file='' json_flag=0 + 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]} @@ -242,22 +242,11 @@ cmd_append_note() { i=$((i + 1)) body_text=${ARGS[i]-} ;; - --body=*) - have_body=1 - body_text=${arg#*=} - ;; --body-file) have_file=1 i=$((i + 1)) body_file=${ARGS[i]-} ;; - --body-file=*) - have_file=1 - body_file=${arg#*=} - ;; - --json) - json_flag=1 - ;; -*) append_note_usage >&2 fail "append-note: unknown flag $arg" @@ -291,10 +280,8 @@ cmd_append_note() { || 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" - local -a update_args=(update "$id" --body-file "$APPEND_TMP") - [ "$json_flag" = 0 ] || update_args+=(--json) status=0 - tasks-axi "${update_args[@]}" || status=$? + tasks-axi update "$id" --body-file "$APPEND_TMP" || status=$? [ "$status" -eq 0 ] || exit "$status" stored=$(read_prior_body "$id") || exit $? @@ -311,9 +298,8 @@ cmd_append_note() { # 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=${ARGS[1]-} body_seen=0 archive_seen=0 body_file='' new_text='' prior i arg - [ "${id#-}" = "$id" ] || return 0 - for ((i = 2; i < ${#ARGS[@]}; i++)); do + 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 ;; @@ -321,9 +307,20 @@ guard_body_replace() { --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. @@ -343,7 +340,12 @@ case "${ARGS[0]-}" in exit $? ;; update|edit) - guard_body_replace + guard_body_replace 1 + ;; + task) + case "${ARGS[1]-}" in + update|edit) guard_body_replace 2 ;; + esac ;; esac diff --git a/tests/fm-tasks-axi.test.sh b/tests/fm-tasks-axi.test.sh index a9a9bd8a88c..02d71b5e447 100755 --- a/tests/fm-tasks-axi.test.sh +++ b/tests/fm-tasks-axi.test.sh @@ -139,6 +139,28 @@ test_append_note_on_a_missing_id_fails_and_writes_nothing() { 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() { @@ -227,17 +249,61 @@ test_update_of_an_empty_body_proceeds_without_the_flag() { 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' From 3f7725210e3600096a1cd6a69e2f64a30de4fedc Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 5 Oct 2026 20:34:19 +0800 Subject: [PATCH 4/4] no-mistakes(document): Clarify backlog note documentation ownership and replacement guidance --- .agents/skills/stow/SKILL.md | 3 +-- AGENTS.md | 2 +- bin/fm-tasks-axi.sh | 10 ++++++---- docs/architecture.md | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 4948ee8f509..ad98b79fc14 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -57,8 +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 add text with `bin/fm-tasks-axi.sh append-note` or write a considered replacement body through `bin/fm-tasks-axi.sh update --body-file --archive-body`. - A replace that drops a non-empty body is refused without `--archive-body`; the script header owns the contract. + - 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 3409f530c63..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 changing it; add to it with `bin/fm-tasks-axi.sh append-note`, and replace a considered body only with `--archive-body`, since the wrapper refuses a replace that would drop a non-empty body. +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 80bfa23a812..0dffde28948 100755 --- a/bin/fm-tasks-axi.sh +++ b/bin/fm-tasks-axi.sh @@ -7,8 +7,9 @@ # # 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 @@ -39,8 +40,9 @@ # 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. # -# Because `update`/`edit --body|--body-file` replaces the whole body, this -# wrapper guards it: without `--archive-body` it reads the current body first +# 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 diff --git a/docs/architecture.md b/docs/architecture.md index 1f4a31e03a3..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 append-note` to add text or `update --body-file --archive-body` to replace a considered body; the script header owns the details. +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