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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,15 @@ It composes today's `fm-lock.sh`, `fm-bootstrap.sh`, and `fm-wake-drain.sh` - ca
When the lock could not be acquired, the queue is left untouched because another session owns it, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands.
4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, and `data/learnings.md`, each clearly delimited.
A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use this template's defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.).
5. **Fleet-state digest** - the full `data/backlog.md`; every `state/<id>.meta`; a bounded tail of each task's `state/<id>.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint.
5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/<id>.meta`; a bounded tail of each task's `state/<id>.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint.
That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh <id>` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded.
6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness.
The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders.
The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism.

**Everything in this digest is read exactly once, at session start.**
Do not separately run `bin/fm-bootstrap.sh`, `bin/fm-lock.sh`, or `bin/fm-wake-drain.sh`, and do not separately read `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, `data/backlog.md`, or any `state/*.meta` afterward - they were just printed in full, and re-reading them defeats the entire point of collapsing session start into one command.
Do not separately run `bin/fm-bootstrap.sh`, `bin/fm-lock.sh`, or `bin/fm-wake-drain.sh`, and do not separately read `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, or any `state/*.meta` afterward - they were just printed in full, and re-reading them defeats the entire point of collapsing session start into one command.
Do not bulk-read `data/backlog.md` afterward either: the compact identity/metadata listing was just printed (owned by `bin/fm-session-start.sh`); use `tasks-axi show <id> --full` or a targeted file read only when a full body is needed.
Do not bulk-read `state/*.status` afterward either: the digest printed bounded tails with full log paths for targeted follow-up when older wake-event history is actually needed.
Re-read a file only if the digest flagged it `ABSENT` (then rebuild or create it per the guidance in this section and section 6), its contents looked unparseable or corrupt, or an individual full status log is needed for older wake-event history.
This read-once rule does not block a targeted current-state read immediately before a workflow writes one of these files, such as `/stow`'s inspect-then-update pass or a backlog backend mutation.
Expand Down Expand Up @@ -349,7 +350,7 @@ Reconcile reality with your records before doing anything else, working from the

1. The digest's lock section already tells you whether this session acquired the lock or is operating read-only; act on that exactly as section 3 describes.
2. The digest's wake-queue section already printed the drained records; keep them as the first work queue for this recovery turn.
3. The digest's fleet-state section already printed `data/backlog.md`, `data/secondmates.md` (from the context section), every `state/*.meta`, and a bounded tail of every `state/*.status`.
3. The digest's fleet-state section already printed the compact backlog listing (see section 3), every `state/*.meta`, and a bounded tail of every `state/*.status` (`data/secondmates.md` came from the context section).
Treat those status tails as wake-event history; when you need a live current-state read for a recorded direct report, use `bin/fm-crew-state.sh <id>` instead of inferring from the last status line.
If older wake-event history matters, read the individual full status log named in the digest instead of bulk-reading every status file.
4. Use the `window=` values from the digest's `state/*.meta` entries as the live direct-report set, and read the digest's per-task `endpoint: alive|dead` line for each - that cheap check is already done; do not re-probe it yourself.
Expand Down
109 changes: 104 additions & 5 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@
# including read-only, afk, and X-mode context.
# 5. context digest - data/projects.md, data/secondmates.md, data/captain.md,
# data/learnings.md: read-only, always safe, always runs.
# 6. fleet digest - data/backlog.md, every state/*.meta, a bounded
# state/*.status tail, state/.afk, and a cheap
# per-task endpoint-liveness read: read-only, always runs.
# 6. fleet digest - a compact data/backlog.md identity/metadata listing,
# every state/*.meta, a bounded state/*.status tail,
# state/.afk, and a cheap per-task endpoint-liveness read:
# read-only, always runs.
# 7. closing reminder - points back to the emitted harness supervision block
# and deliberately never starts supervision itself.
#
Expand All @@ -65,6 +66,20 @@
# The context and fleet-state digests
# below are always read-only, so they run unconditionally in both modes.
#
# BACKLOG DIGEST: FM_SESSION_START_BACKLOG_LIMIT bounds the startup backlog
# listing, default 80 items.
# When compatible tasks-axi is selected and available, the shared tasks-axi
# backend probe remains the compatibility owner and this script asks
# `tasks-axi list` for the compact identity fields plus blocked_by, hold_kind,
# and hold_reason, never body.
# When manual mode is selected, or tasks-axi is unavailable or incompatible,
# this script prints only backlog section headings and item title lines, so
# title-line hold and blocked-by metadata remain visible while indented bodies
# stay out of the startup digest.
# Full bodies are targeted follow-up only: `tasks-axi show <id> --full` when
# compatible tasks-axi is available, or `data/backlog.md` when the file body is
# truly needed.
#
# Usage: fm-session-start.sh
# Prints the full ordered digest to stdout and always exits 0: this is a
# reporting command, not a gate. A lock refusal is reported as a loud
Expand All @@ -82,9 +97,13 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown)

# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-tasks-axi-lib.sh
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"

STATUS_TAIL=${FM_SESSION_START_STATUS_TAIL:-5}
case "$STATUS_TAIL" in ''|*[!0-9]*) STATUS_TAIL=5 ;; esac
BACKLOG_LIMIT=${FM_SESSION_START_BACKLOG_LIMIT:-80}
case "$BACKLOG_LIMIT" in ''|*[!0-9]*|0) BACKLOG_LIMIT=80 ;; esac

RULE='================================================================================'
SUBRULE='--------------------------------------------------------------------------------'
Expand Down Expand Up @@ -112,6 +131,84 @@ print_file_or_absent() {
fi
}

print_backlog_pointer() {
printf 'Full task bodies remain available on demand: tasks-axi show <id> --full when compatible tasks-axi is available, or data/backlog.md.\n'
}

print_backlog_manual_compact() {
local path=$1 reason=$2
printf 'compact backlog listing (%s; max %s item(s); indented task bodies omitted)\n' "$reason" "$BACKLOG_LIMIT"
awk -v max="$BACKLOG_LIMIT" '
function state_for_heading(line, heading) {
heading = line
sub(/^##[[:space:]]+/, "", heading)
sub(/[[:space:]]+$/, "", heading)
if (heading == "In flight") return "in_flight"
if (heading == "Queued") return "queued"
if (heading == "Done") return "done"
return ""
}
/^##[[:space:]]+/ {
state = state_for_heading($0)
if (state != "") print $0
next
}
state != "" && /^[-*][[:space:]]+/ {
total++
if (shown < max) {
print $0
shown++
}
next
}
END {
if (total == 0) {
print "(no backlog item title lines found)"
} else {
printf "(shown %d of %d backlog item title line(s))\n", shown, total
if (total > shown) {
printf "(truncated %d item(s); increase FM_SESSION_START_BACKLOG_LIMIT for a larger startup listing)\n", total - shown
}
}
}
' "$path"
}

print_backlog_tasks_axi_compact() {
local path=$1 out rc
printf 'compact backlog listing (tasks-axi; max %s item(s); task bodies omitted)\n' "$BACKLOG_LIMIT"
out=$(tasks-axi list --file "$path" --limit "$BACKLOG_LIMIT" --fields blocked_by,hold_kind,hold_reason 2>&1)
rc=$?
if [ "$rc" -eq 0 ]; then
printf '%s\n' "$out"
else
printf 'tasks-axi compact listing failed; falling back to title-line rendering.\n'
printf '%s\n' "$out"
print_backlog_manual_compact "$path" "fallback"
fi
}

print_backlog_compact() {
local path=$1 label=$2
subsection "$label"
if [ -f "$path" ]; then
if [ -s "$path" ]; then
if fm_tasks_axi_backend_available "$CONFIG"; then
print_backlog_tasks_axi_compact "$path"
elif fm_backlog_backend_manual "$CONFIG"; then
print_backlog_manual_compact "$path" "manual backend"
else
print_backlog_manual_compact "$path" "tasks-axi unavailable or incompatible"
fi
print_backlog_pointer
else
printf '(present, empty)\n'
fi
else
printf 'ABSENT\n'
fi
}

print_status_tail() {
local status=$1
printf 'status tail (last %s line(s), wake-EVENT history, not current state; full log: %s):\n' "$STATUS_TAIL" "$status"
Expand Down Expand Up @@ -235,7 +332,7 @@ print_file_or_absent "$DATA/learnings.md" "data/learnings.md"

# --- 6. fleet-state digest ---------------------------------------------
section "FLEET STATE"
print_file_or_absent "$DATA/backlog.md" "data/backlog.md"
print_backlog_compact "$DATA/backlog.md" "data/backlog.md"

subsection "In-flight tasks (state/*.meta)"
META_FOUND=0
Expand Down Expand Up @@ -320,7 +417,9 @@ fi
cat <<'EOF'
The digest above is complete for this session start. Do NOT re-read
data/projects.md, data/secondmates.md, data/captain.md, data/learnings.md,
data/backlog.md, or state/*.meta now - they were just printed in full.
or state/*.meta now - they were just printed in full.
Do NOT bulk-read data/backlog.md now either: the compact identity/metadata
listing was just printed with a pointer for targeted full-body follow-up.
Do NOT bulk-read state/*.status now either: their bounded tails were just
printed with full log paths for targeted follow-up when older wake-event
history is actually needed. Re-reading everything defeats the entire point
Expand Down
162 changes: 162 additions & 0 deletions tests/fm-session-start.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,56 @@ SH
printf '%s\n' manual > "${fakebin%/*}/home-placeholder" 2>/dev/null || true
}

make_fake_tasks_axi_compact() {
local fakebin=$1
cat > "$fakebin/tasks-axi" <<'SH'
#!/usr/bin/env bash
set -u
log=${FM_FAKE_TASKS_AXI_LOG:-}
[ -n "$log" ] && printf '%s\n' "$*" >> "$log"
case "${1:-}" in
--version|-v|-V)
printf '%s\n' '0.2.3'
exit 0
;;
update)
if [ "${2:-}" = --help ]; then
printf '%s\n' 'usage: tasks-axi update <id> [--archive-body]'
exit 0
fi
;;
mv)
if [ "${2:-}" = --help ]; then
printf '%s\n' 'usage: tasks-axi mv <dest> [<id>...]'
exit 0
fi
;;
list)
case "$*" in
*'--fields '*'body'*|*'--fields='*'body'*)
printf '%s\n' 'unexpected body field requested' >&2
exit 9
;;
esac
case "$*" in *'--limit 80'*) : ;; *) printf '%s\n' 'missing compact limit' >&2; exit 9 ;; esac
case "$*" in *'--file '*) : ;; *) printf '%s\n' 'missing explicit backlog file' >&2; exit 9 ;; esac
cat <<'OUT'
count: 2
tasks[2]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}:
compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending
blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-"
help[2]:
- Run `tasks-axi show <id> --full` for full notes on a task
- Run `tasks-axi ready` to see unblocked queued work
OUT
exit 0
;;
esac
exit 1
SH
chmod +x "$fakebin/tasks-axi"
}

# make_fake_ps_claude <fakebin>: harness_pid()/holder_alive() (fm-lock.sh) walk
# `ps` output looking for a harness command name; this fake reports EVERY
# queried pid as a live `claude` harness, so the very first ancestry check
Expand Down Expand Up @@ -512,6 +562,115 @@ EOF
pass "fm-session-start.sh composes the real fm-lock.sh, fm-bootstrap.sh, and fm-wake-drain.sh output verbatim"
}

# --- fleet-state digest: compact backlog rendering --------------------------

write_long_body_backlog() {
local path=$1
cat > "$path" <<'EOF'
# Backlog

## In flight
- [ ] compact-startup - Compact startup digest (repo: firstmate) (kind: ship) (since 2026-07-15) (hold: captain choice pending) (hold-kind: captain)
OVERSIZED-BODY-LINE current startup leaks task note bodies into the session digest.
Another long body line that should not be printed after the fix.

## Queued
- [ ] blocked-followup - Follow compact startup blocked-by: compact-startup - waits for implementation (repo: firstmate) (kind: scout) (since 2026-07-15)
QUEUED-BODY-LINE this is another long multiline note.

## Done
EOF
}

test_backlog_compact_tasks_axi_omits_bodies_and_keeps_metadata() {
local rec root home fakebin out log
rec=$(new_world backlog-compact-tasks-axi)
IFS='|' read -r root home fakebin <<EOF
$rec
EOF
make_fake_toolchain "$fakebin"
make_fake_tasks_axi_compact "$fakebin"
make_fake_ps_claude "$fakebin"
write_long_body_backlog "$home/data/backlog.md"
mkdir -p "$home/projects/firstmate"
printf 'window=fm-sess:compact\nworktree=%s\nproject=firstmate\nkind=ship\n' "$home/projects/firstmate" \
> "$home/state/compact-startup.meta"
log="$home/tasks-axi.log"

out=$(FM_FAKE_TASKS_AXI_LOG="$log" run_session_start "$home" "$root" "$fakebin:$BASE_PATH")

assert_contains "$out" "compact backlog listing (tasks-axi; max 80 item(s); task bodies omitted)" \
"compatible tasks-axi backend did not render the compact backlog listing"
assert_contains "$out" "tasks[2]{id,state,kind,repo,title,blocked_by,hold_kind,hold_reason}:" \
"tasks-axi compact listing omitted the expected structured field header"
assert_contains "$out" "compact-startup,in_flight,ship,firstmate,Compact startup digest,none,captain,captain choice pending" \
"tasks-axi compact listing omitted in-flight identity, state, or hold metadata"
assert_contains "$out" 'blocked-followup,queued,scout,firstmate,Follow compact startup,compact-startup,"-","-"' \
"tasks-axi compact listing omitted blocked-by metadata"
assert_not_contains "$out" "OVERSIZED-BODY-LINE" "tasks-axi compact digest leaked an in-flight task body"
assert_not_contains "$out" "QUEUED-BODY-LINE" "tasks-axi compact digest leaked a queued task body"
assert_contains "$out" "--- compact-startup ---" "in-flight meta identity disappeared from startup recovery digest"
assert_contains "$out" "worktree=$home/projects/firstmate" "in-flight recovery worktree identity disappeared from startup digest"
assert_contains "$out" "Full task bodies remain available on demand: tasks-axi show <id> --full" \
"compact digest omitted the full-body lookup pointer"
assert_grep "list --file $home/data/backlog.md --limit 80 --fields blocked_by,hold_kind,hold_reason" "$log" \
"session start did not ask tasks-axi for the bounded compact field set"

pass "compatible tasks-axi backlog rendering is compact, bounded, and preserves recovery metadata"
}

test_backlog_compact_manual_backend_skips_indented_bodies() {
local rec root home fakebin out
rec=$(new_world backlog-compact-manual)
IFS='|' read -r root home fakebin <<EOF
$rec
EOF
make_fake_toolchain "$fakebin"
make_fake_ps_claude "$fakebin"
printf '%s\n' manual > "$home/config/backlog-backend"
write_long_body_backlog "$home/data/backlog.md"

out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH")

assert_contains "$out" "compact backlog listing (manual backend; max 80 item(s); indented task bodies omitted)" \
"manual backend did not use compact title-line rendering"
assert_contains "$out" "## In flight" "manual compact rendering omitted the in-flight section heading"
assert_contains "$out" "- [ ] compact-startup - Compact startup digest" \
"manual compact rendering omitted the in-flight title line"
assert_contains "$out" "(hold: captain choice pending) (hold-kind: captain)" \
"manual compact rendering omitted hold metadata"
assert_contains "$out" "blocked-by: compact-startup - waits for implementation" \
"manual compact rendering omitted blocker metadata"
assert_not_contains "$out" "OVERSIZED-BODY-LINE" "manual compact digest leaked an in-flight task body"
assert_not_contains "$out" "QUEUED-BODY-LINE" "manual compact digest leaked a queued task body"
assert_contains "$out" "(shown 2 of 2 backlog item title line(s))" \
"manual compact rendering did not report its bound accounting"
assert_contains "$out" "or data/backlog.md" "manual compact digest omitted the data/backlog.md full-body pointer"

pass "manual backlog rendering prints only title lines with hold and blocker metadata"
}

test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback() {
local rec root home fakebin out
rec=$(new_world backlog-compact-unavailable)
IFS='|' read -r root home fakebin <<EOF
$rec
EOF
make_fake_toolchain "$fakebin"
make_fake_ps_claude "$fakebin"
write_long_body_backlog "$home/data/backlog.md"

out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH")

assert_contains "$out" "compact backlog listing (tasks-axi unavailable or incompatible; max 80 item(s); indented task bodies omitted)" \
"unavailable tasks-axi did not fall back to compact title-line rendering"
assert_contains "$out" "- [ ] compact-startup - Compact startup digest" \
"unavailable tasks-axi fallback omitted a backlog title line"
assert_not_contains "$out" "OVERSIZED-BODY-LINE" "unavailable tasks-axi fallback leaked an in-flight task body"

pass "unavailable or incompatible tasks-axi falls back to compact manual backlog rendering"
}

# --- fleet-state digest: no in-flight tasks ----------------------------------

test_fleet_digest_empty_fleet() {
Expand Down Expand Up @@ -711,6 +870,9 @@ test_orphan_status_logs_are_printed
test_endpoint_liveness_tmux
test_endpoint_liveness_herdr
test_composition_invokes_real_scripts
test_backlog_compact_tasks_axi_omits_bodies_and_keeps_metadata
test_backlog_compact_manual_backend_skips_indented_bodies
test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback
test_fleet_digest_empty_fleet
test_next_step_sources_x_mode_cadence
test_next_step_afk_delegates_to_daemon
Expand Down