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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ Hard rules, in priority order:
1. **Never write to a project.**
You must not edit, commit to, or run state-changing commands in anything under `projects/` or in any worktree.
You read projects to understand them; crewmates change them.
Six sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3, 7, and 8), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), and approved `local-only` merge via `bin/fm-merge-local.sh` (section 7).
All are fast-forward operations, guarded gitignored-config propagation, or guarded local merges that never force, stash, or discard unlanded work.
Seven sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3, 7, and 8), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), approved `local-only` merge via `bin/fm-merge-local.sh` (section 7), and the teardown harvest of crew-generated untracked files into the project via `bin/fm-teardown.sh` (section 7).
All are fast-forward operations, guarded gitignored-config propagation, guarded local merges that never force, stash, or discard unlanded work, or the purely additive teardown harvest that never overwrites an existing project file and makes no git-state change.
Project `AGENTS.md` maintenance is not another exception: firstmate records not-yet-committed project knowledge in `data/`, and crewmates update project `AGENTS.md` through normal delivery (section 6).
2. **Never merge a PR without the captain's explicit word.**
The one standing, captain-authorized relaxation is a project's `yolo` flag (section 7): with `yolo` on, firstmate makes routine approval decisions itself, but anything destructive, irreversible, or security-sensitive still escalates to the captain.
Expand Down Expand Up @@ -590,6 +590,7 @@ bin/fm-teardown.sh <id>
```

The script refuses if the worktree holds uncommitted changes or committed work that has not landed; treat a refusal as a stop-and-investigate, not an obstacle.
Before that check, teardown harvests every untracked, non-ignored file the crew generated into the project's primary checkout at the same relative path (never overwriting an existing file) and removes it from the worktree, so generated notes, docs, and scratch survive the worktree hard-reset; this is why leftover untracked files no longer refuse a ship teardown, while committed-but-unlanded work still does.
"Landed" is broader than remote-reachable: for a normal ship task whose commits are not reachable from any remote-tracking branch, the script also accepts the work when its PR is merged and GitHub reports a PR head that contains the current local work, or when its content is already present in the up-to-date default branch.
Containment means local `HEAD` is the PR head, local `HEAD` is an ancestor of the PR head, or the unpushed local patches have matching patch IDs in that PR head after no-mistakes replayed the branch.
This recognizes the common squash-merge-then-delete-branch flow, where the branch's own commits live nowhere on a remote yet the change is fully in `main`; a merged-and-deleted branch now tears down cleanly instead of false-refusing.
Expand Down
64 changes: 64 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
# A gh lookup error falls back to the content check; if that is also inconclusive,
# teardown refuses rather than risk discarding unlanded work.
# Uncommitted changes are never landed.
# Before the safety check and removal, a ship task's worktree is harvested: every
# untracked, non-ignored file the crew generated is copied into the project's
# primary checkout (never overwriting an existing path) and then removed from the
# worktree, so generated notes/docs/scratch survive the hard-reset and the tree is
# clean for the safety check. This is a captain-requested write into the project
# (AGENTS.md section 1 write exception); it is purely additive, makes no git-state
# change, and never touches committed-but-unlanded work (which still refuses). Scout
# (scratch worktree, report is the deliverable) and secondmate teardowns are exempt.
# local-only projects additionally accept work merged into the local default
# branch (firstmate performs that merge on the captain's approval) as a fallback
# for the common case where there is no remote at all.
Expand Down Expand Up @@ -555,6 +563,53 @@ teardown_treehouse_return() {
return 1
}

# Harvest crew-generated files before the worktree is destroyed. treehouse return
# hard-resets the worktree, so any file the crew left untracked - generated notes,
# docs, scratch outputs - would be lost with the pool slot. Copy every untracked,
# non-ignored file into the project's primary checkout at the same relative path,
# then remove it from the worktree so the existing dirty check sees a clean tree and
# teardown proceeds instead of refusing on the untracked files. Purely additive at
# the destination: a path that already exists is NEVER overwritten (the worktree
# copy is dropped with the worktree, exactly as it would be without this feature),
# and no git-state change is made to either repo. --exclude-standard drops
# firstmate's own gitignored hook files, so they are never harvested. A file that
# cannot be copied is left in place, so a copy failure surfaces as the normal
# dirty-worktree refusal rather than a silent loss. Committed-but-unlanded work is
# untouched here and still blocks teardown via the landed-work check.
# This is the captain-requested teardown harvest (AGENTS.md section 1 write
# exception); it runs for ship tasks only, never for scout (scratch worktree, the
# report is the deliverable) or secondmate (a home, not a project worktree).
harvest_untracked_into_project() {
local wt=$1 proj=$2 rel src dst copied=0 skipped=0 wt_abs proj_abs
[ -n "$wt" ] && [ -d "$wt" ] || return 0
[ -n "$proj" ] && [ -d "$proj" ] || return 0
wt_abs=$(cd "$wt" 2>/dev/null && pwd -P) || return 0
proj_abs=$(cd "$proj" 2>/dev/null && pwd -P) || return 0
[ "$wt_abs" != "$proj_abs" ] || return 0
git -C "$wt" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
while IFS= read -r rel; do
[ -n "$rel" ] || continue
src="$wt/$rel"
[ -f "$src" ] || continue
dst="$proj/$rel"
if [ -e "$dst" ]; then
skipped=$((skipped + 1))
echo "harvest: skip (already at destination) $rel" >&2
elif mkdir -p "$(dirname "$dst")" 2>/dev/null && cp "$src" "$dst" 2>/dev/null; then
copied=$((copied + 1))
echo "harvest: kept $rel" >&2
else
echo "harvest: could not copy $rel (leaving it in the worktree)" >&2
continue
fi
rm -f "$src" 2>/dev/null || true
done < <(git -C "$wt" ls-files --others --exclude-standard 2>/dev/null)
if [ "$copied" -gt 0 ] || [ "$skipped" -gt 0 ]; then
echo "harvest: $copied file(s) copied into $proj, $skipped already present" >&2
fi
return 0
}

validate_worktree_teardown_safety() {
local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch
[ -d "$WT" ] || return 0
Expand Down Expand Up @@ -976,6 +1031,15 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] &&
ORCA_PATH_MATCH_VERIFIED=1
fi

# Harvest crew-generated untracked files into the project before any safety check
# or removal, so they survive the worktree being hard-reset. Runs on every ship
# teardown (including --force). Removing the harvested files leaves the worktree
# clean of non-hook untracked files, so the safety check below no longer refuses on
# them; committed-but-unlanded work is untouched and still refuses.
if [ "$KIND" != secondmate ] && [ "$KIND" != scout ]; then
harvest_untracked_into_project "$WT" "$PROJ"
fi

if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then
if validate_worktree_teardown_safety; then
:
Expand Down
74 changes: 74 additions & 0 deletions tests/fm-teardown.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1028,3 +1028,77 @@ test_lsof_error_never_clears_index_lock
test_stale_index_lock_cleanup_rechecks_dirty_worktree
test_non_linked_index_lock_path_is_checked_from_worktree
test_index_lock_mtime_read_failure_refuses

# --- teardown harvest of crew-generated untracked files ---------------------
# On a ship teardown, every untracked non-ignored file the crew left is copied
# into the project's primary checkout (never overwriting) and removed from the
# worktree, so leftover untracked files no longer refuse teardown and generated
# work survives the hard-reset. Scout/secondmate worktrees are exempt.

test_harvest_copies_untracked_into_project() {
local case_dir rc
case_dir=$(make_case harvest-copy)
write_meta "$case_dir" no-mistakes ship
wt_commit "$case_dir" "shippable work"
git -C "$case_dir/wt" push -q origin fm/task-x1
git -C "$case_dir/project" fetch -q origin
printf 'notes\n' > "$case_dir/wt/NOTES.md"
mkdir -p "$case_dir/wt/e2e/flows"
printf 'flow\n' > "$case_dir/wt/e2e/flows/a.yaml"

set +e
run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
rc=$?
set -e

expect_code 0 "$rc" "harvest-copy: teardown should succeed (untracked harvested, work landed)"
! grep -q REFUSED "$case_dir/stderr" || fail "harvest-copy: teardown printed REFUSED"
assert_present "$case_dir/project/NOTES.md" "harvest-copy: NOTES.md should be copied into project"
assert_present "$case_dir/project/e2e/flows/a.yaml" "harvest-copy: nested file should be copied into project"
assert_absent "$case_dir/wt/NOTES.md" "harvest-copy: source should be removed from worktree"
grep -q "harvest: kept NOTES.md" "$case_dir/stderr" || fail "harvest-copy: should log kept NOTES.md"
pass "harvest copies untracked crew files into project and allows an otherwise-dirty teardown"
}

test_harvest_no_clobber_preserves_existing() {
local case_dir rc
case_dir=$(make_case harvest-clobber)
write_meta "$case_dir" no-mistakes ship
wt_commit "$case_dir" "shippable work"
git -C "$case_dir/wt" push -q origin fm/task-x1
git -C "$case_dir/project" fetch -q origin
printf 'ORIGINAL\n' > "$case_dir/project/KEEP.md" # pre-existing at destination
printf 'NEW\n' > "$case_dir/wt/KEEP.md" # crew's untracked version

set +e
run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr"
rc=$?
set -e

expect_code 0 "$rc" "harvest-clobber: teardown should succeed"
[ "$(cat "$case_dir/project/KEEP.md")" = "ORIGINAL" ] \
|| fail "harvest-clobber: existing project file must NOT be overwritten"
grep -q "harvest: skip (already at destination) KEEP.md" "$case_dir/stderr" \
|| fail "harvest-clobber: should log the no-clobber skip"
pass "harvest never overwrites an existing project file (no-clobber)"
}

test_harvest_skipped_for_scout() {
local case_dir rc
case_dir=$(make_case harvest-scout)
write_meta "$case_dir" no-mistakes scout
printf 'scratch\n' > "$case_dir/wt/SCRATCH.md"

set +e
run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr"
rc=$?
set -e

expect_code 0 "$rc" "harvest-scout: scout teardown should succeed with --force"
assert_absent "$case_dir/project/SCRATCH.md" "harvest-scout: scout scratch must NOT be harvested"
pass "scout teardown does not harvest (scratch-worktree exemption)"
}

test_harvest_copies_untracked_into_project
test_harvest_no_clobber_preserves_existing
test_harvest_skipped_for_scout