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: 3 additions & 1 deletion bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,9 @@ If the top-level path is the primary checkout or not the worktree you were launc

# Rules
$RULE1
2. Stay inside this worktree; modify nothing outside it.
2. Keep project edits inside this worktree; keep proof and scratch output outside it, under \`$DATA/$ID/\` or a temporary directory.
Outside the worktree, write only that task material and the status and steering-inbox records authorized below.
Leave the worktree clean before reporting done.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations.
4. Report status by appending one line:
\`$STATUS_APPEND\`
Expand Down
4 changes: 4 additions & 0 deletions bin/fm-promote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ This task is now kind=ship with mode=$MODE$PROMOTE_FORGE_WORDS.
This section supersedes every earlier brief instruction about delivery mode.
These current ship instructions supersede the scout delivery rules and report-based Definition of done.
Any earlier "Never push" or scout-only delivery language in this file is superseded.
This replaces the scout rule limiting outside-worktree writes to the report and status file.
Keep project edits inside this worktree; keep proof and scratch output outside it, under \`$DATA/$ID/\` or a temporary directory.
Outside the worktree, write only that task material and the status and steering-inbox records authorized below.
Leave the worktree clean before reporting done.
The mode-specific Definition of done below is the current delivery contract.

# Current ship safety rule
Expand Down
26 changes: 22 additions & 4 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
# by itself causes a false refusal of landed work.
# 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.
# Uncommitted changes are never landed; dirty refusals distinguish untracked-only
# leftovers from tracked edits and list at most ten non-exempt untracked paths.
# 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 @@ -1864,6 +1865,23 @@ teardown_treehouse_return() {
return 1
}

report_worktree_dirt() {
# Use the same porcelain snapshot and exemptions as the refusal predicate.
printf '%s\n' "$1" | awk '
/^\?\? / { if (++untracked <= 10) paths = paths " " substr($0, 4) "\n"; next }
NF { tracked = 1 }
END {
if (tracked) print "uncommitted changes present (includes tracked edits)"
else print "uncommitted changes present (untracked-only leftovers)"
if (untracked) {
print "untracked paths (up to 10):"
printf "%s", paths
if (untracked > 10) print " ... additional untracked paths omitted"
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
' >&2
}

validate_worktree_teardown_safety() {
local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch
[ -d "$WT" ] || return 0
Expand All @@ -1880,7 +1898,7 @@ validate_worktree_teardown_safety() {
echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2
return 1
fi
dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi)-turnend$)' | head -1 || true)
dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-(grok|kimi)-turnend$)' || true)

if ! unpushed_raw=$(git -C "$WT" log --oneline HEAD --not --remotes -- 2>/dev/null); then
if worktree_safety_blocked_by_lock "commits not on a remote"; then
Expand All @@ -1905,14 +1923,14 @@ validate_worktree_teardown_safety() {
unmerged=$(printf '%s\n' "$unmerged_raw" | head -5)
if [ -n "$dirty" ] || [ -n "$unmerged" ]; then
echo "REFUSED: local-only worktree $WT has work not yet merged into $DEFAULT and not on any remote." >&2
[ -n "$dirty" ] && echo "uncommitted changes present" >&2
[ -n "$dirty" ] && report_worktree_dirt "$dirty"
[ -n "$unmerged" ] && printf 'commits not yet on %s:\n%s\n' "$DEFAULT" "$unmerged" >&2
echo "Merge the branch into local $DEFAULT first (bin/fm-merge-local.sh after the captain approves), or push to a fork/remote, or get the captain's explicit OK to discard, then --force." >&2
return 1
fi
elif [ -n "$dirty" ]; then
echo "REFUSED: worktree $WT has uncommitted changes." >&2
echo "uncommitted changes present" >&2
report_worktree_dirt "$dirty"
echo "Commit them (or get the captain's explicit OK to discard, then --force)." >&2
return 1
elif [ -n "$unpushed" ]; then
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ firstmate's supervisor contract and routing index for conditional procedures is

## Event-driven supervision

The declared-wait vocabulary, including the legacy "external wait" label, is owned by [`bin/fm-classify-lib.sh`](../bin/fm-classify-lib.sh); worker declaration instructions are owned by [`bin/fm-brief.sh`](../bin/fm-brief.sh).
The declared-wait vocabulary, including the legacy "external wait" label, is owned by [`bin/fm-classify-lib.sh`](../bin/fm-classify-lib.sh); worker declaration instructions and the ship worker's scratch-location and clean-worktree contract are owned by [`bin/fm-brief.sh`](../bin/fm-brief.sh).

A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable.
Actionable wakes include captain-relevant status signals, no-verb signals without positive evidence that their crew is still executing, authenticated check output such as PR merge polling or a Relay mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS` with no wait their own worker declared, no writes to their own task worktree, and - in a home that armed `config/wedge-defer-parked-gate` - no validation gate of their own awaiting an unanswered supervisor decision, declared external waits and attended captain-held transfers that remain declared past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits.
Expand Down
14 changes: 13 additions & 1 deletion tests/fm-task-delivery.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ test_promote_refuses_a_symlinked_task_record() {
# prints against a capturing fm-send.sh, and asserts on the message the worker would
# actually receive - for every supported mode.
test_promotion_delivers_the_real_definition_of_done() {
local home meta out sendroot payload mode id brief_dod delivered_dod
local home meta out sendroot payload mode id brief_dod delivered_dod contract
home="$TMP_ROOT/promote-dod/home"
sendroot="$TMP_ROOT/promote-dod/sendroot"
mkdir -p "$home/state" "$sendroot/bin"
Expand Down Expand Up @@ -356,6 +356,18 @@ STUB
assert_grep "## Firstmate spec" "$payload" \
"$mode: promoted worker did not receive the Firstmate spec subsection"

# Both the delivered prompt and persisted relaunch brief are public outputs.
for contract in "$payload" "$home/data/$id/brief.md"; do
assert_grep "This replaces the scout rule limiting outside-worktree writes to the report and status file." "$contract" \
"$mode: $contract retained the scout-only write restriction"
assert_grep "Keep project edits inside this worktree; keep proof and scratch output outside it, under \`$home/data/$id/\` or a temporary directory." "$contract" \
"$mode: $contract omitted the ship scratch-location rule"
assert_grep "Outside the worktree, write only that task material and the status and steering-inbox records authorized below." "$contract" \
"$mode: $contract omitted the ship outside-worktree write boundary"
assert_grep "Leave the worktree clean before reporting done." "$contract" \
"$mode: $contract omitted the clean-before-done rule"
done

# Compare the public outputs of both real generation paths. The promoted
# payload ends at its Definition of done, as does an ordinary generated
# brief, so identical suffixes prove both workers receive the same contract.
Expand Down
73 changes: 73 additions & 0 deletions tests/fm-teardown.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,76 @@ test_dirty_worktree_refuses() {
pass "dirty worktree is refused even when its committed work has landed (dirty always wins)"
}

assert_dirty_diagnostic() {
local kind=$1 mode=$2 case_dir rc before n
case_dir=$(make_case "dirty-$kind-$mode")
write_meta "$case_dir" "$mode" ship
wt_commit_file "$case_dir" feature.txt hello
# Exercise both dirty refusal sites: remote-reachable work and local-only
# work merged into local main but absent from every remote.
if [ "$mode" = local-only ]; then
git -C "$case_dir/project" merge -q --ff-only fm/task-x1
else
git -C "$case_dir/wt" push -q origin fm/task-x1
fi
if [ "$kind" != untracked ]; then
printf '%s\n' 'uncommitted edit' > "$case_dir/wt/feature.txt"
# Cover index edits as well as unstaged edits.
[ "$mode" != local-only ] || git -C "$case_dir/wt" add feature.txt
fi
if [ "$kind" != tracked ]; then
mkdir "$case_dir/wt/00 proof scratch"
printf '%s\n' 'manual server log' > "$case_dir/wt/00 proof scratch/server.log"
for n in 01 02 03 04 05 06 07 08 09 10 11; do
touch "$case_dir/wt/$n-scratch.txt"
done
# Preserve the existing exemptions without counting them as leftovers.
mkdir "$case_dir/wt/.claude"
touch "$case_dir/wt/.claude/settings.local.json" "$case_dir/wt/.fm-grok-turnend"
fi
before=$(git -C "$case_dir/wt" status --porcelain)
rc=0
run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$?
expect_code 1 "$rc" "$kind/$mode: dirty teardown must still refuse"
grep -q REFUSED "$case_dir/stderr" || fail "$kind/$mode: no refusal"
if [ "$kind" = untracked ]; then
grep -Fq 'uncommitted changes present (untracked-only leftovers)' "$case_dir/stderr" \
|| fail "$kind/$mode: missing untracked-only classification"
! grep -q 'includes tracked edits' "$case_dir/stderr" || fail "$kind/$mode: misclassified as tracked"
else
grep -Fq 'uncommitted changes present (includes tracked edits)' "$case_dir/stderr" \
|| fail "$kind/$mode: missing tracked-edit classification"
! grep -q 'untracked-only' "$case_dir/stderr" || fail "$kind/$mode: misclassified as untracked-only"
fi
if [ "$kind" != tracked ]; then
grep -Fq '00 proof scratch/' "$case_dir/stderr" || fail "$kind/$mode: scratch folder not named"
grep -Fxq ' 09-scratch.txt' "$case_dir/stderr" || fail "$kind/$mode: tenth path missing"
! grep -q '10-scratch.txt\|11-scratch.txt\|\.claude/\|\.fm-grok-turnend' "$case_dir/stderr" \
|| fail "$kind/$mode: path list exceeded its bound or included exempt files"
grep -Fq 'additional untracked paths omitted' "$case_dir/stderr" || fail "$kind/$mode: no truncation notice"
else
! grep -q 'untracked paths' "$case_dir/stderr" || fail "$kind/$mode: invented untracked paths"
fi
[ -f "$case_dir/state/task-x1.meta" ] || fail "$kind/$mode: task metadata removed"
[ "$before" = "$(git -C "$case_dir/wt" status --porcelain)" ] || fail "$kind/$mode: worktree changed"
pass "$kind/$mode: dirty refusal classifies leftovers and preserves work"
}

test_untracked_only_refusal_diagnostic() {
assert_dirty_diagnostic untracked no-mistakes
assert_dirty_diagnostic untracked local-only
}

test_tracked_edit_refusal_diagnostic() {
assert_dirty_diagnostic tracked no-mistakes
assert_dirty_diagnostic tracked local-only
}

test_mixed_refusal_diagnostic() {
assert_dirty_diagnostic mixed no-mistakes
assert_dirty_diagnostic mixed local-only
}

test_gh_error_and_content_absent_refuses() {
local case_dir rc
case_dir=$(make_case gh-error)
Expand Down Expand Up @@ -4337,6 +4407,9 @@ test_pr_check_records_remote_head_when_local_lags
test_content_in_default_fallback_allows
test_content_fallback_refreshes_stale_origin_ref
test_dirty_worktree_refuses
test_untracked_only_refusal_diagnostic
test_tracked_edit_refusal_diagnostic
test_mixed_refusal_diagnostic
test_gh_error_and_content_absent_refuses
test_legacy_record_without_the_flag_refuses
test_windowless_legacy_record_with_gone_worktree_tears_down
Expand Down
Loading