Skip to content
Open
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Hard rules, in priority order:

1. **Never write to a project.**
Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them.
The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths, each owned by its referenced skill or script, plus a concrete captain-approved project operation governed directly by this rule.
The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, approved `local-only` merge, and the purely additive teardown harvest of crew-generated untracked files into the project (`bin/fm-teardown.sh`, never overwriting an existing project file) paths, each owned by its referenced skill or script, plus a concrete captain-approved project operation governed directly by this rule.
Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`.
Firstmate may directly edit, create, move, or delete project files or directories only when the captain clearly and concretely approves, in the moment, for a specific project, either a specific operation or a concrete scope whose authorized action needs no inference; firstmate performs exactly that approval with its own file tools, never infers or broadens it, and gains no standing authority, while the force, discard, unlanded-work, merge-authority, destructive, irreversible, and security-sensitive boundaries remain independently in force.
2. **Never merge a PR without the captain's explicit word.**
Expand Down Expand Up @@ -327,7 +327,7 @@ Fill the task subsections according to section 11.
### Dispatch and supervision handoff

Spawn only through `bin/fm-spawn.sh` after the profile and backend checks in section 4.
The spawn must resolve a genuine isolated task worktree distinct from the primary checkout; a failed isolation assertion stops the task.
The spawn must resolve a genuine isolated task worktree distinct from the primary checkout and belonging to that same project, so an unrelated git repo the pane transiently sits in is never mistaken for the task worktree (`bin/fm-spawn.sh`'s header states the full rule); a failed isolation assertion stops the task.
When the configured tasks-axi backlog gate applies, the spawn itself moves the work item to In flight and refuses rather than dispatching work this home has no item for, so recording the dispatch is never a separate step to remember; a manual-backend home retains the hand-editing contract in `docs/configuration.md`.
After spawning, confirm the worker is processing the brief and handle any trust dialog through `harness-adapters`.
A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item.
Expand Down Expand Up @@ -400,6 +400,7 @@ Retire a custom check only through `bin/fm-check-unregister.sh <id>` (or `bin/fm

Tear down a ship task only after landing is confirmed.
A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass.
Before that check, a ship 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; leftover untracked files therefore no longer refuse a ship teardown, while committed-but-unlanded work still does.
Never force teardown without explicit discard authority.
After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared.

Expand Down Expand Up @@ -454,7 +455,7 @@ A forced repair must use the home-scoped owner path emitted by supervision instr

Guard warnings do not replace the contract.
Queued wakes must be presented before other action and acknowledged only after handling, stale liveness must be repaired through the emitted protocol, and the worktree-tangle warning must be resolved without touching unlanded work.
The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree, never the primary checkout.
The spawn assertion and generated ship brief must both enforce that project work starts in an isolated disposable worktree of the project being spawned into, never the primary checkout or an unrelated repo.
Harness-aware turn-end guards are structural backstops, not permission to omit the live cycle.

### Away-mode and quiet-mode stub
Expand Down Expand Up @@ -528,6 +529,7 @@ Batch non-urgent updates into the next natural reply.
Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface.
Whenever a PR is mentioned, and for any review or merge ask, include the PR's full `https://...` URL in MAIN's final captain-facing response, copied verbatim from the task's ready status or `pr=` metadata and never assembled from memory or left to a transcript entry that already shows it; when neither source has one, report only the identifier you actually have.
Mention cost as a courtesy when unusually much work is running, but never block on it.
When a claude crewmate or scout finishes, report its exact token usage with `bin/fm-token-usage.sh <id>` (claude-harness only; see `docs/token-usage.md`).

## 10. Backlog contract

Expand Down
68 changes: 64 additions & 4 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@
# itself a linked worktree of the project repository still launches. A pane
# that never reaches an isolated worktree refuses at the end of that wait,
# naming the last path seen and why it was rejected.
# Isolation alone is not enough: the path must also be a worktree of that
# SAME project (matching --git-common-dir), so an unrelated git repo the pane
# transiently sits in can never be mistaken for the task worktree.
# FM_SPAWN_WORKTREE_TIMEOUT (default 60) bounds, in seconds, how long the
# post-`treehouse get` poll waits for that worktree before giving up.
# That placement is proven only at launch. Every ship or scout pane therefore
# also receives `export FM_TASK_ID=<task-id>` before the launch command, on
# the same channel as GOTMPDIR, and bin/fm-test-run.sh refuses to execute the
Expand Down Expand Up @@ -2827,6 +2832,48 @@ real_path_or_raw() { # <path>
fi
}

# The project repo's shared git dir. Every linked worktree of this project -
# which is exactly what `treehouse get` hands out - reports this same path as
# its --git-common-dir, while an unrelated repo reports its own. That is the
# property that identifies a real task worktree.
PROJ_GIT_COMMON=$(git -C "$PROJ_ABS" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
# Fail-open is deliberate (see is_project_worktree), but it must never be
# silent: without this line nothing reveals that the worktree-identity check is
# inert until another agent launches somewhere it should not have. Any failure
# of the rev-parse above lands here, so the message names every cause it can
# have rather than asserting one. Emitted once per spawn, here rather than
# inside the predicate, because the discovery poll calls it up to
# FM_SPAWN_WORKTREE_TIMEOUT times.
if [ -z "$PROJ_GIT_COMMON" ] && [ "$KIND" != secondmate ]; then
echo "warning: could not determine the git common dir of $PROJ_ABS (git may be missing from PATH, the directory may not be a git repo, git may have refused it over safe.directory ownership, or git may predate 2.31's rev-parse --path-format); the worktree-identity check is DISABLED for this spawn, which proceeds on the isolation check alone" >&2
fi

# is_project_worktree: true when <path> is a worktree of the SAME repository as
# the project.
#
# Why this exists: the worktree-discovery poll below waits for the pane's
# foreground cwd to differ from the project, then treats whatever it sees as
# the task worktree. That is too weak. oh-my-zsh's oh-my-zsh.sh does
# `builtin cd -q "$ZSH"` on EVERY shell startup to read its revision for the
# zcompdump stamp, so a freshly spawned pane transiently reports
# ~/.oh-my-zsh as its foreground cwd. The poll latched onto it and launched
# the agent there. The old isolation guard passed it too, because ~/.oh-my-zsh
# IS a git repo and IS not the primary checkout - the guard tested "some other
# git repo" when it meant "a worktree treehouse gave us".
is_project_worktree() { # <candidate-path>
# NB: the local is deliberately NOT named `path`. In zsh `path` is tied to
# PATH, so `local path=...` empties PATH for the function's scope and every
# command lookup inside it fails silently. This script is bash, but the
# surrounding fleet is zsh and the same helper shape gets copied around.
local candidate=$1 common
[ -n "$candidate" ] || return 1
# cannot tell; do not block the spawn - the condition is reported once at
# spawn time where PROJ_GIT_COMMON is resolved.
[ -n "$PROJ_GIT_COMMON" ] || return 0
common=$(git -C "$candidate" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)
[ -n "$common" ] && [ "$common" = "$PROJ_GIT_COMMON" ]
}

# Session-provider container-ensure + task creation. tmux stays exactly as P1
# left it (same session-name / new-window sequence, see bin/backends/tmux.sh);
# a herdr spawn goes through the version-gated, workspace-per-HOME,
Expand All @@ -2838,7 +2885,9 @@ real_path_or_raw() { # <path>

# True when <path> is an isolated worktree of the spawning project: a real
# directory that is its own worktree root, is not the spawning project itself,
# and does not share the project repository's common git dir. SPAWN_WT_TOP is
# does not use the project repository's common git dir as its own git dir, and
# reports that same common git dir as its --git-common-dir (so it belongs to
# this project and is not some unrelated repo; see is_project_worktree). SPAWN_WT_TOP is
# left holding the worktree root the check read, and SPAWN_WT_REASON a short
# phrase naming why a rejected path failed, both for the refusal messages.
#
Expand Down Expand Up @@ -2900,13 +2949,20 @@ spawn_worktree_isolated() { # <path>
SPAWN_WT_REASON="it is the repository's primary checkout (its git dir is the spawning project's common git dir)"
return 1
fi
# Being *a* git repo that is not the primary is not enough: it must be a
# worktree of THIS project. Without this, any unrelated repo the pane
# transiently sits in passes (see is_project_worktree).
if ! is_project_worktree "$wt_real"; then
SPAWN_WT_REASON="it is a git repo but NOT a worktree of the spawning project (its git common dir is '$(git -C "$wt_real" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo unknown)', expected '$PROJ_GIT_COMMON')"
return 1
fi
return 0
}

validate_spawn_worktree() { # <source> <inspect-target>
local source=$1 inspect_target=$2
if ! spawn_worktree_isolated "$WT"; then
echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${SPAWN_WT_TOP:-none}'; spawning project '$PROJ_ABS'); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2
echo "error: $source did not yield an isolated worktree (resolved '$WT'; worktree root '${SPAWN_WT_TOP:-none}'; spawning project '$PROJ_ABS'; ${SPAWN_WT_REASON:-rejected}); refusing to launch to avoid tangling the primary checkout. Inspect target $inspect_target" >&2
exit 1
fi
}
Expand Down Expand Up @@ -3874,10 +3930,14 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
# misconfiguration would need machinery this path does not want - so the
# refusal has to be self-explaining instead: carry the last path seen and the
# reason it was rejected, and report both at the deadline.
# A wrong path is never terminal - it may simply be transient - so the loop
# waits it out and FM_SPAWN_WORKTREE_TIMEOUT (default 60) is the only give-up
# point.
candidate=""
last_seen=""
last_reason="the pane reported no path"
for _ in $(seq 1 60); do
SPAWN_WT_TIMEOUT=${FM_SPAWN_WORKTREE_TIMEOUT:-60}
for _ in $(seq 1 "$SPAWN_WT_TIMEOUT"); do
p=$(spawn_current_path "$WT_TARGET" || true)
[ -z "$p" ] || last_seen="$p"
if [ -n "$p" ] && spawn_worktree_isolated "$p"; then
Expand All @@ -3895,7 +3955,7 @@ elif [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
sleep 1
done
if [ -z "$WT" ]; then
echo "error: treehouse get did not enter an isolated worktree within 60s (last seen '${last_seen:-none}': $last_reason; spawning project '$PROJ_ABS'); inspect window $T" >&2
echo "error: treehouse get did not enter an isolated worktree of $PROJ_ABS within ${SPAWN_WT_TIMEOUT}s (last seen '${last_seen:-none}': $last_reason; spawning project '$PROJ_ABS'); inspect window $T" >&2
exit 1
fi

Expand Down
66 changes: 66 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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 after configured approval) as a fallback
# for the common case where there is no remote at all.
Expand Down Expand Up @@ -1777,6 +1785,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 @@ -3340,6 +3395,17 @@ 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) whose recorded slot this teardown still owns; a
# slot reassigned to another task is never read or touched. 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 ] && teardown_owns_worktree; then
harvest_untracked_into_project "$WT" "$PROJ"
fi

if teardown_owns_worktree && [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then
if validate_worktree_teardown_safety; then
:
Expand Down
Loading
Loading