Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .agents/skills/stow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --full`, classify the change as new, duplicate, superseding, or obsolete, then use a considered replacement body through `bin/fm-tasks-axi.sh update <id> --body-file <path>`.
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.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
220 changes: 217 additions & 3 deletions bin/fm-tasks-axi.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 [<tasks-axi command> [args...]]
# fm-tasks-axi.sh append-note <id> (--body <text> | --body-file <path>)
# 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 <command> --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
Expand All @@ -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
# <data>/note-archive.md; to add text, use `append-note`. The guard reads the
# prior body from `tasks-axi show <id> --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
Expand All @@ -45,7 +64,11 @@
# cannot be read (bin/fm-tasks-axi-lib.sh owns that diagnostic);
# - a markdown `<data>/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

Expand All @@ -71,11 +94,32 @@ fail() {
exit 2
}

append_note_usage() {
cat <<'EOF'
Usage: fm-tasks-axi.sh append-note <id> (--body <text> | --body-file <path>)

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 <id> --body-file <path> --archive-body` so the
old body is archived into <data>/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)
Expand Down Expand Up @@ -137,4 +181,174 @@ else
fi

cd "$FM_BACKLOG_AXI_ROOT" || fail "cannot enter the backlog root $FM_BACKLOG_AXI_ROOT"

# read_prior_body <id>: 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 <id> (--body <text> | --body-file <path>)
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[@]}"}
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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|\
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --full` followed by `bin/fm-tasks-axi.sh update <id> --body-file <path>`, 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
Expand Down
Loading
Loading