diff --git a/AGENTS.md b/AGENTS.md index a6af6bc6b3f..58bbf17935b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,6 +315,7 @@ Resolve every ship task's concrete delivery mode and `yolo` merge posture at int Pass the mode explicitly to the brief, and pass both values explicitly to the spawn and any scout promotion; each command refuses to guess the values it consumes. A current explicit captain instruction wins; otherwise the project's registry entry is the captain's standing posture, and dropping below its rigor needs a reason you can state. On a `no-mistakes-prod-only` project, classify the task's surface: internal-only tooling, automation, contributor or operator process, and release or submission work ships `direct-PR`, while product-facing, mixed, and uncertain work ships `no-mistakes`; never infer internal-only from file location or project name. +`fm-spawn.sh` refuses `direct-PR` on a project whose CI requires PRs raised via no-mistakes, so choose `no-mistakes` there. An unregistered project or absent registry resolves to `no-mistakes` with yolo off, and the registration gap goes to the captain. Record the resulting mode, `yolo` merge posture, and the one-line reason for any deviation in the backlog item note. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4cf13b28330..d19c00cae44 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -22,7 +22,9 @@ # the explicit mode carries less rigor than the project's standing posture, a # loud one-line deviation notice is printed and the spawn continues. # no-mistakes-prod-only is a registry policy rather than a task mode and is -# refused as a flag value. +# refused as a flag value. --mode direct-PR is also refused when the project's +# own checkout carries a workflow whose "PR must be raised via no-mistakes" +# check would fail a directly opened PR; use --mode no-mistakes there. # Ship/scout launches always supply fm-dod-lib.sh's current worker role scope # using the same private launch-brief overlay. This never rewrites a project's # instruction files or a secondmate's charter. @@ -573,6 +575,31 @@ case "$EFFORT" in *) echo "error: --effort must be one of low, medium, high, xhigh, max, ultra" >&2; exit 1 ;; esac +resolve_project_dir_arg() { + local path=$1 + case "$path" in + projects/*) printf '%s/%s\n' "$PROJECTS" "${path#projects/}" ;; + *) printf '%s\n' "$path" ;; + esac +} + +# A repository whose CI requires PRs to be raised via no-mistakes fails every +# directly opened PR, so a direct-PR ship spawn there is refused before anything +# is created. The requirement is read from the project's local checkout by the +# check's defining job name, never from the workflow file name, with no network. +# Prints the matching workflow's project-relative path, or nothing. +nm_attestation_workflow() { + local project=$1 wf + for wf in "$project"/.github/workflows/*.yml "$project"/.github/workflows/*.yaml; do + [ -f "$wf" ] || continue + if grep -qF -- 'PR must be raised via no-mistakes' "$wf" 2>/dev/null; then + printf '.github/workflows/%s\n' "${wf##*/}" + return 0 + fi + done + return 0 +} + # --relaunch reuses an existing task's endpoint, worktree, project, and kind, # so every axis this block resolves for a fresh spawn instead comes from that # task's own durable record below. Contradicting it on the command line is a @@ -603,6 +630,13 @@ else exit 1 ;; *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; esac + if [ "$MODE" = direct-PR ] && [ -n "${POS[1]:-}" ]; then + NM_REQUIRED_WORKFLOW=$(nm_attestation_workflow "$(resolve_project_dir_arg "${POS[1]}")") + if [ -n "$NM_REQUIRED_WORKFLOW" ]; then + echo "error: this project's $NM_REQUIRED_WORKFLOW requires PRs to be raised via no-mistakes, so a direct-PR pull request is guaranteed to fail that check; spawn with --mode no-mistakes" >&2 + exit 1 + fi + fi case "$YOLO" in on|off) ;; *) echo "error: --yolo must be on or off (got '$YOLO')" >&2; exit 1 ;; @@ -2096,14 +2130,6 @@ resolved_existing_dir() { cd "$path" && pwd -P } -resolve_project_dir_arg() { - local path=$1 - case "$path" in - projects/*) printf '%s/%s\n' "$PROJECTS" "${path#projects/}" ;; - *) printf '%s\n' "$path" ;; - esac -} - path_is_ancestor_of() { local ancestor=$1 path=$2 [ -n "$ancestor" ] || return 1 diff --git a/docs/architecture.md b/docs/architecture.md index 9a7b97ab5aa..7ec33f1987c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -310,6 +310,7 @@ A ship brief records its mode as a fixed machine-readable line and the spawn ref `bin/fm-dod-lib.sh` is the one owner of that mode's definition of done, rendered both into a generated ship brief and into the ship instructions a promoted scout receives, so a promoted worker cannot be handed a weaker contract than a briefed one. It is also the one owner of the no-mistakes `--intent` contract those workers follow. `data/projects.md` records each project's standing posture and optional `+yolo` merge flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy; a ship spawn that drops below the registered rigor prints a deviation notice and continues. +A `--mode direct-PR` ship spawn is instead refused when the project's own checkout carries a workflow with the `PR must be raised via no-mistakes` check, because a directly opened PR is guaranteed to fail it; the refusal names that workflow and asks for `--mode no-mistakes`. `bin/fm-project-mode.sh` remains the one registry parser for the mechanical consumers that have no task in hand: fleet sync's `local-only` skip and home seeding's refusal and no-mistakes initialization. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index aaa52e9b3b5..080b789419f 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -64,7 +64,7 @@ run_spawn() { # shift 2 FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ - FM_PROJECTS_OVERRIDE="$TMP_ROOT/projects-unused" FM_CONFIG_OVERRIDE="$home/config" \ + FM_PROJECTS_OVERRIDE="${SPAWN_PROJECTS_DIR:-$TMP_ROOT/projects-unused}" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_BACKEND=tmux PATH="$fakebin:$PATH" \ "$SPAWN" "$@" 2>&1 } @@ -794,7 +794,44 @@ EOF pass "fm-spawn: every legacy worker receives scoped role instructions without changing project or primary instructions" } +test_spawn_refuses_direct_pr_where_ci_requires_no_mistakes() { + local rec home proj fakebin out status + rec=$(make_home attested) + IFS='|' read -r home proj fakebin < "$proj/.github/workflows/attestation-gate.yml" + + out=$(run_spawn "$home" "$fakebin" attested-a1 "$proj" claude --mode direct-PR --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "direct-PR should be refused when the workflow is present" + assert_contains "$out" "attestation-gate.yml" "refusal did not name the workflow" + assert_contains "$out" "--mode no-mistakes" "refusal did not name the fix" + assert_absent "$home/state/attested-a1.meta" "refused spawn wrote task metadata" + + write_brief "$home" attested-a4 direct-PR + out=$(SPAWN_PROJECTS_DIR=${proj%/*} run_spawn "$home" "$fakebin" attested-a4 "projects/${proj##*/}" claude --mode direct-PR --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "direct-PR should be refused for the projects/ spelling" + assert_contains "$out" "attestation-gate.yml" "projects/ spelling bypassed the guard" + + write_brief "$home" attested-a2 no-mistakes + out=$(run_spawn "$home" "$fakebin" attested-a2 "$proj" claude --mode no-mistakes --yolo off) + assert_not_contains "$out" "requires PRs to be raised via no-mistakes" "no-mistakes mode was refused" + write_brief "$home" attested-a3 local-only + out=$(run_spawn "$home" "$fakebin" attested-a3 "$proj" claude --mode local-only --yolo off) + assert_not_contains "$out" "requires PRs to be raised via no-mistakes" "local-only mode was refused" + pass "fm-spawn: direct-PR is refused only where CI requires PRs raised via no-mistakes" +} + test_spawn_refreshes_legacy_worker_roles +test_spawn_refuses_direct_pr_where_ci_requires_no_mistakes test_ship_spawn_requires_a_valid_delivery_contract test_scout_and_secondmate_refuse_delivery_flags test_spawn_refuses_a_brief_mode_mismatch