From 351b3c1f7bb1c420a51e8486a5d27320e8f7e8ca Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 19:54:38 -0400 Subject: [PATCH 01/15] docs: open the fleet's vocabulary-collision registry The fleet resolved name collisions wherever they surfaced, so a ruling was only ever findable by whoever remembered making it, and the platform's own Register 3 had no counterpart on this side. Open docs/vocabulary-collisions.md as the single owner of every word carrying more than one meaning across the fleet, the platform, and the vendor tools both depend on. It ships seeded with the ruled dispositions rather than empty: axi and execution keep their names with the evidence recorded, skill and watch and lifecycle take mandated qualified forms, promotion splits with the fleet verb becoming reflag, and kind splits into three axes. A rename or a split row also states the obsolete name's retirement condition, so no superseded path is left with an open-ended life. This lands before any rename it governs: the map exists first, then the moves it records. --- docs/documentation-audiences.json | 4 + docs/vocabulary-collisions.md | 160 ++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 docs/vocabulary-collisions.md diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 3da45b58f20..5ac091d20bb 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -382,6 +382,10 @@ "path": "docs/verification/worktree-allocation.md", "audience": "maintainer-verification" }, + { + "path": "docs/vocabulary-collisions.md", + "audience": "maintainer-architecture" + }, { "path": "docs/watcher-continuity.md", "audience": "operator-current" diff --git a/docs/vocabulary-collisions.md b/docs/vocabulary-collisions.md new file mode 100644 index 00000000000..1ef088d2a8a --- /dev/null +++ b/docs/vocabulary-collisions.md @@ -0,0 +1,160 @@ +# Fleet vocabulary-collision registry + +This file is the fleet's single owner of words that carry more than one meaning across the fleet, the Agentic Engineering platform, and the vendor tools both depend on. +A row here records a ruled disposition, not an opinion: what the senses are, who owns each, what was decided, and what a contributor must write instead. +When a word acquires a second live sense anywhere the fleet reads or writes, add a row here rather than resolving it locally in the file where it surfaced. + +The platform keeps the mirror of this registry as Register 3 of its `docs/architecture/governance-registers.md`. +Neither registry is authoritative over the other's repository: each records the dispositions its own repository must honor, and a cross-repository row states both sides. + +## Dispositions + +The vocabulary of this column follows the platform's Register 3 precedent. + +- **NO RENAME** - both senses keep the word; the row exists so the ruling is findable. +- **QUALIFY** - both senses keep the word, but each must always be written in its qualified form. +- **NO-CONTACT** - the senses cannot reach each other, and the row records the evidence for that. +- **DISSOLVED BY RENAME** - one sense was renamed; the row records both names and the retirement point of the old one. +- **DISSOLVED BY SPLIT** - one overloaded identifier was split into independent fields; the row records the axes and the retirement point of the old identifier. + +## Rows + +Every row below was ruled by the captain on 2026-08-07 against the measured census in the CFVC-16 naming proposal. + +### `axi` + +| | | +|---|---| +| **Disposition** | NO RENAME | +| **Fleet sense** | the `*-axi` CLI family - `gh-axi`, `lavish-axi`, `chrome-devtools-axi`, `quota-axi`, `tasks-axi` | +| **Platform sense** | the Agent Interface Layer | +| **Third sense** | the upstream `axi` "Agent eXperience Interface" discipline, external to both repositories | + +The fleet does not own the identifier: the `*-axi` tools are third-party package names, so a fleet-side rename would be a fork rather than a rename. +The platform's own row (Register 3, added 2026-07-23) already rules the convergence deliberate. +Write the tool name in full (`gh-axi`, never "axi") whenever the fleet means a tool. + +**Where it bites:** [`AGENTS.md`](../AGENTS.md) section 3 names the tool family; every `bin/fm-*.sh` that shells out to one of them. + +### `skill` + +| | | +|---|---| +| **Disposition** | QUALIFY (fleet side); DISSOLVED BY RENAME (platform entity sense only) | +| **Fleet sense** | a procedure directory under `.agents/skills//SKILL.md`, loaded by name | +| **Fleet sense** | a public installer-facing procedure under `skills//SKILL.md`, never loaded by a running firstmate | +| **Platform sense** | an engineering procedure under its `harness/skills//SKILL.md` | +| **Platform sense** | the `EngineeringSkill` entity, renamed platform-side to `EngineeringTechnique` | +| **Vendor sense** | the harness vendor's `Skill` tool and the literal `SKILL.md` discovery filename | + +Five senses, not the three the platform's Register 3 first recorded. +The markdown senses are not renameable by either repository, because `SKILL.md` is the vendor's discovery filename and `Skill` is the vendor's tool name. +Fleet-side the word always takes a qualifier - "agent-only skill", "public skill", "the `Skill` tool" - and never stands alone in a document either repository may read. +The platform's persisted `skill_id` / `SKILL-*` wire identity intentionally lags the renamed type and is not rewritten. + +**Where it bites:** [`AGENTS.md`](../AGENTS.md) sections 2 and 13; the `.agents/skills/` and `skills/` split recorded in section 2's layout. + +### `watch` + +| | | +|---|---| +| **Disposition** | QUALIFY | +| **Fleet sense** | the supervision daemon - always written **`watcher`**, never bare "watch" | +| **Platform sense** | the governance view - always written **`Watch Register`**, never bare "Watch" | + +Neither side renames. +The two vocabularies meet only in cross-repository assessment documents, and both already write the qualified form there. +The residual overlap is the bare English verb, which no rename removes. +A contributor writing about the fleet's supervision means `watcher`; a hyphenated compound (`watch-arm`, `watcher-beat`, `watch-checkpoint`) is already qualified and needs no change. + +**Where it bites:** [`AGENTS.md`](../AGENTS.md) section 8 and its captain-facing translation table in section 9; [`docs/watcher-continuity.md`](watcher-continuity.md); `bin/fm-watch*.sh`. + +### `promotion` + +| | | +|---|---| +| **Disposition** | DISSOLVED BY RENAME (fleet sense) | +| **Platform sense** | **Promotion Law**, a canonized law with a declared firing threshold and recorded firings - keeps the word, unchanged | +| **Fleet sense** | the scout-to-ship operation, renamed **`reflag`** (`bin/fm-reflag.sh`) | +| **Fleet sense** | model promotion and demotion between routing tiers, which keeps the word | +| **Other platform senses** | evidence-class promotion; census promotion trigger; the authority-ladder promotion of a managed trading project | + +The platform's sense is canonized law, so renaming it would be a constitutional-tier act; the fleet's sense had the smallest measured footprint of any collision in the census and no captain-vocabulary cost, because the captain never hears either word. +`reflag` says what the operation does: a vessel is reflagged when its registry and contract change while hull and crew stay, which is exactly the scout-to-ship operation - the worker keeps its window, worktree, and loaded context, and only the delivery contract changes. +The fleet's own model-promotion sense, in `.agents/skills/model-onboarding/SKILL.md`, is a distinct fifth sense that surfaced while this row was being written; it keeps the word because it is a promotion in the platform's own sense - evidence earning a durable position - and it never meets the task vocabulary. + +**Retirement of the old entry point.** +`bin/fm-promote.sh` is a bounded compatibility shim, not an alias. +It exists only for a firstmate turn that loaded the pre-rename instructions and has not yet fast-forwarded, so its retirement condition is: **remove it once every home that this repository serves has fast-forwarded past the commit that introduced `bin/fm-reflag.sh` and re-read its instructions.** +The shim records each use in `state/.reflag-shim-used` so that condition is settled by evidence rather than by memory; an absent marker after a full task cycle is the proof that no caller still needs it. +It is not a general-purpose entry point: it forwards, warns on stderr, and is excluded from documentation as a supported command. + +**Where it bites:** [`AGENTS.md`](../AGENTS.md) section 7 "Scout outcome and reflagging" and the section 9 do-not-expose list; [`docs/architecture.md`](architecture.md); [`docs/scripts.md`](scripts.md); `bin/fm-reflag.sh`. + +### `execution` + +| | | +|---|---| +| **Disposition** | NO-CONTACT (across the platform's managed-project boundary) | +| **Platform sense** | the execution plane - `execution_node`, ADR-0053 / ADR-0054 | +| **Platform sense** | the EI artifacts - `ExecutionUnit`, `ExecutionPlan` | +| **Platform sense** | the provider seam - `execution_provider` | +| **Managed-project sense** | order execution in the platform's trading project | + +Neither side renames, and the pair originally reported as the collision is the one pair that cannot collide: the platform and its managed trading project never share an import root, so no name resolution ever reaches across. +"Execution" is also the domain-standard term for order execution, so renaming the trading sense would trade correctness for hygiene. + +The collision worth recording is internal to the platform, where three of its own senses do share one import namespace; the platform's Register 3 owns that row. +The fleet's only contacts with any of these senses are deliberate disambiguations that already name the platform artifact explicitly, such as the LoopSpec contract stating that a LoopSpec is not an `ExecutionUnit`. + +**Where it bites:** [`.agents/skills/loopspec/SKILL.md`](../.agents/skills/loopspec/SKILL.md); [`loopspecs/schema.json`](../loopspecs/schema.json); `bin/fm-loopspec.sh`. + +### `lifecycle` + +| | | +|---|---| +| **Disposition** | QUALIFY | +| **Platform sense** | the project phase lifecycle - a constitutional state machine with a durable artifact per project | +| **Fleet sense** | the task lifecycle, plus the decision-hold, secondmate, wake-daemon, and Herdr lifecycles | + +The platform's sense has the deepest governance footprint measured and is not renameable at any price this work could justify. +The fleet's real ambiguity is internal rather than cross-repository - it uses the word for five different things - and the fix is the qualifier the fleet already writes almost everywhere. +Never write "the lifecycle" bare in a document either repository may read. +Write `task lifecycle`, `project lifecycle`, `decision-hold lifecycle`, `secondmate lifecycle`, `wake-daemon lifecycle`, or `Herdr lifecycle`. + +**Where it bites:** [`AGENTS.md`](../AGENTS.md) section 7, titled "Task lifecycle"; [`docs/decision-hold-lifecycle.md`](decision-hold-lifecycle.md); the `lifecycle`-suffixed test names in `tests/`. + +### `kind` + +| | | +|---|---| +| **Disposition** | DISSOLVED BY SPLIT (task-metadata sense) | +| **Task-metadata sense** | the single `kind=` field of `state/.meta`, split into `role=`, `deliverable=`, and `stage=` | +| **Backlog-hold sense** | `tasks-axi hold --kind captain`, and the admission policy's `--kind load` | +| **Wake-ledger sense** | the wake kind of a queued wake - `signal`, `stale`, `check`, `heartbeat` | +| **Operational-input sense** | the structural kind of a marked message, owned by `bin/fm-operational-input.sh` | + +One `kind=` field carried three independent facts at once: who the worker is, what the task produces, and where the task stands in its life. +Every consumer had to reconstruct the axis it cared about from a value that also encoded the two it did not, and the scout-to-ship operation expressed a lifecycle transition by rewriting a deliverable type. +[`bin/fm-task-axis-lib.sh`](../bin/fm-task-axis-lib.sh) is the single owner of the three axes, their values, and the deterministic derivation from the retired field. + +`kind` remains a live field name in three other unrelated fleet namespaces, listed above. +Those are separate vocabularies with their own owners and are not part of this split; do not introduce a fourth. + +**The role axis is now the home for every role-typed fact about a task.** +A field describing who a worker is - including a requested agent role, which was deferred until this split existed - belongs on `role=` and never as a new dimension beside it. +That deferral was the whole reason the split had to come first: adding a role field to the old single-field vocabulary would have made a fourth conflated axis out of a field that already carried three. +No such field exists in the fleet today; the axis is what makes adding one a one-line change rather than another conflation. + +**Retirement of the deprecated field.** +`kind=` is still written to every task's metadata and is still the derivation source for a record that predates the split. +Its retirement condition is: **stop writing `kind=` once no reader consults it and one full task cycle has run entirely on the three axes.** +Until then it is a dual-written deprecated alias with exactly one owner, and a metadata record whose `kind=` disagrees with its explicit axes is refused rather than silently resolved, so a stale writer that flips the old field alone cannot desynchronize a task's identity. + +**Where it bites:** [`bin/fm-task-axis-lib.sh`](../bin/fm-task-axis-lib.sh); the metadata field list in [`AGENTS.md`](../AGENTS.md) section 2; [`docs/architecture.md`](architecture.md). + +## Maintaining this file + +Add a row when a word acquires a second live sense in any repository the fleet reads or writes, not when a rename is proposed. +A row states the senses, their owners, the ruled disposition, what to write instead, and - for a rename or a split - the retirement condition of the obsolete name, so no obsolete path is left with an open-ended life. +Keep the "where it bites" pointers current: they are the reason a contributor finds this file, and each pointed-at site carries a one-line cross-reference back rather than a second copy of the ruling. From 1d6c79cfa65c66f32640620c64f83a8487c4b853 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 19:54:59 -0400 Subject: [PATCH 02/15] feat(bin): split task identity into role, deliverable, and stage axes One kind= field carried three independent facts at once: who the worker is, what the task produces, and where the task stands in its life. Every consumer reconstructed the axis it cared about from a value that also encoded the two it did not, and the scout-to-ship operation expressed a lifecycle transition by rewriting a deliverable type - which is why a reflagged ship and a commissioned one were indistinguishable, and why a requested agent role had nowhere to land that would not have made a fourth conflated dimension. bin/fm-task-axis-lib.sh becomes the single owner of role=, deliverable=, and stage=, of their values, and of the total derivation from the retired field, so no consumer spells that mapping itself. Migration follows the ordered contract: - Dual-write first. Every writer emits the axes beside kind=, which keeps reading unchanged while records converge. - Backfill by derivation, forward-only and idempotent, in a startup sweep that runs only under the fleet lock and leaves a converged home byte-identical. Stage is deliberately NOT derived: the old field could not distinguish a reflagged ship from a commissioned one, so backfill records the lower-information value rather than inventing a fact. - Migrate readers one axis at a time - role across the secondmate-membership consumers, then deliverable across the pipeline and teardown-protection consumers - splitting the conditions that had mixed both. kind= stays dual-written for now; the registry owns its retirement condition. While it stays, a record whose alias contradicts its axes is REFUSED rather than resolved, because either side could be the stale one and teardown choosing between a protected ship worktree and a scratch scout worktree by luck is how unlanded work gets discarded. The scout-to-ship operation becomes bin/fm-reflag.sh in the same change, since it is what makes the stage axis true. The old name stays only as a bounded shim that forwards, warns, and records each use, so its own retirement is settled by evidence rather than by memory. Coverage is red-capable: each guard was witnessed failing with its behavior removed before being trusted green - the derivation table, dual-write, backfill idempotence, the shim's evidence, and the refusal in the library, in reflagging, and in teardown. --- bin/fm-bearings-snapshot.sh | 16 +- bin/fm-bootstrap.sh | 38 +++- bin/fm-brief.sh | 36 +++- bin/fm-crew-state.sh | 16 +- bin/fm-decision-hold.sh | 12 +- bin/fm-ff-lib.sh | 7 +- bin/fm-fleet-snapshot.sh | 28 ++- bin/fm-fleet-view.sh | 4 +- bin/fm-promote.sh | 111 ++--------- bin/fm-reflag.sh | 131 ++++++++++++ bin/fm-send.sh | 4 +- bin/fm-spawn.sh | 12 +- bin/fm-task-axis-lib.sh | 226 +++++++++++++++++++++ bin/fm-teardown.sh | 97 +++++---- bin/fm-test-run.sh | 4 +- bin/fm-update.sh | 2 +- bin/fm-wake-ledger.sh | 28 ++- tests/fm-fleet-snapshot-view.test.sh | 4 +- tests/fm-task-axis.test.sh | 288 +++++++++++++++++++++++++++ tests/fm-task-delivery.test.sh | 56 +++--- 20 files changed, 900 insertions(+), 220 deletions(-) create mode 100755 bin/fm-reflag.sh create mode 100755 bin/fm-task-axis-lib.sh create mode 100755 tests/fm-task-axis.test.sh diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index 7564f9ffac2..a1c5f90b3ce 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -102,7 +102,7 @@ usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--fields ] Compact bearings projection over fm-fleet-snapshot.sh. TOON by default. Default is LOCAL-ONLY (no network); --include-prs is the only path that fetches. -Default fields: schema, home, generated, prs, in_flight{id,kind,state,doing}, +Default fields: schema, home, generated, prs, in_flight{id,role,deliverable,state,doing}, secondmates{id,state,doing,provenance,freshness,age_seconds,contradiction,reason}, decisions_open{id,key,verb,summary,owner}, landed{id,what,artifact,owner}, gates{id,title,blocked_by,reason,owner}, reports{id,path}, recorded_prs{id,url}, @@ -222,7 +222,7 @@ EOF s=$(repo_slug "$u"); [ -n "$s" ] || continue case " $repos " in *" $s "*) : ;; *) repos="$repos $s" ;; esac done </dev/null || continue + [ "$(fm_task_role "$meta")" = secondmate ] || continue id=$(basename "$meta" .meta) echo "SECONDMATE_SYNC: secondmate $id: skipped: primary default-branch commit cannot be resolved" done @@ -346,7 +348,7 @@ secondmate_sync() { esac [ "$remote" -ne 1 ] || continue meta="$STATE/$id.meta" - [ -f "$meta" ] && [ "$(fm_meta_get "$meta" kind)" = secondmate ] || { + [ -f "$meta" ] && [ "$(fm_task_role "$meta")" = secondmate ] || { echo "NUDGE_SECONDMATES: secondmate ${id:-unknown}: send failed: retry target has no live secondmate metadata" continue } @@ -533,7 +535,7 @@ secondmate_liveness_sweep() { # adding the missing-session path the original bare-shell and Herdr-husk sweep # lacked. # A meta with no window remains owned by secondmate-provisioning recovery. - # Secondmate homes never contain kind=secondmate meta, so this is naturally a + # Secondmate homes never contain a role=secondmate record, so this is naturally a # primary-only no-op there. Mid-session liveness remains explicitly out of # scope and requires a separate periodic signal. [ -d "$STATE" ] || return 0 @@ -541,7 +543,7 @@ secondmate_liveness_sweep() { SECONDMATE_RESPAWNED_IDS="" for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue - grep -q '^kind=secondmate$' "$meta" 2>/dev/null || continue + [ "$(fm_task_role "$meta")" = secondmate ] || continue id=$(basename "$meta" .meta) window=$(fm_meta_get "$meta" window) [ -n "$window" ] || continue @@ -1035,6 +1037,31 @@ wake_ledger_terminal_sweep() { echo "BOOTSTRAP_INFO: recorded $n declared task failure(s) that no teardown would have recorded" } +# Converge every task record onto the three identity axes. A MUTATING sweep, so +# it runs only when this session holds the fleet lock. Idempotent and +# forward-only: bin/fm-task-axis-lib.sh appends only the axes a record does not +# already state, so a converged home is left byte-identical and repeated sweeps +# cost one read each. Silent on success, because a routine confirmation is not +# an actionable line. A record whose deprecated kind= alias disagrees with an +# explicit axis is REFUSED rather than converged: either value could be the +# stale one, so resolving it silently would pick a task's identity by luck. +task_axis_backfill_sweep() { + local meta conflicted=0 ids='' + [ -d "$STATE" ] || return 0 + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + if fm_task_axes_conflict "$meta"; then + conflicted=$((conflicted + 1)) + meta=${meta##*/} + ids="$ids ${meta%.meta}" + continue + fi + fm_task_axes_backfill "$meta" >/dev/null 2>&1 || true + done + [ "$conflicted" -gt 0 ] || return 0 + echo "TASK_AXIS_BACKFILL: $conflicted task record(s) state an identity the deprecated kind= alias contradicts -$ids - each task's role, deliverable, or stage is unreliable until the disagreement is settled by inspection" +} + # The entitlement probe half of the observation floor. A MUTATING sweep: it makes # live requests and writes state/model-health.json, so it runs only when this # session actually holds the fleet lock, alongside the other mutating sweeps. @@ -1252,6 +1279,7 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ echo "BOOTSTRAP_INFO: tasks-axi available" fi if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then + task_axis_backfill_sweep secondmate_liveness_sweep secondmate_sync secondmate_handoff_resume diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index ed7cd45a233..ed52ad1c020 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -151,7 +151,12 @@ COMMIT_CONVENTIONS='# Commit conventions Never add an agent name as a commit co-author, and never add a Co-Authored-By trailer naming an agent, whatever your own harness instructions say. Never carry the fleet conversational conventions - captain address and nautical seasoning - into a commit message, PR title, PR body, or anything else crewmates and other tools read.' -KIND=ship +# The scaffold selects on the same two identity axes the task's record carries +# (bin/fm-task-axis-lib.sh): ROLE is who the worker is, DELIVERABLE is what the +# task produces. --scout and --secondmate each move exactly one of them, so a +# section that belongs to a role never has to be inferred from a deliverable. +ROLE=crew +DELIVERABLE=ship HERDR_LAB=0 NO_PROJECTS=0 MODE= @@ -175,8 +180,8 @@ for a in "$@"; do continue fi case "$a" in - --scout) KIND=scout ;; - --secondmate) KIND=secondmate ;; + --scout) DELIVERABLE=scout ;; + --secondmate) ROLE=secondmate ;; --herdr-lab) HERDR_LAB=1 ;; --no-projects) NO_PROJECTS=1 ;; --mode) want_value=mode ;; @@ -194,9 +199,18 @@ for a in "$@"; do done [ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; } +# The two flags move different axes, so asking for both now describes a +# persistent direct report whose deliverable is a scout report - which is not a +# thing the fleet dispatches. Refuse it rather than let the generated brief carry +# both a charter and a scout contract. +if [ "$ROLE" = secondmate ] && [ "$DELIVERABLE" = scout ]; then + echo "error: --secondmate and --scout select different things (a persistent direct report versus a report deliverable); pass exactly one" >&2 + exit 1 +fi + # Ship delivery mode is an explicit per-task decision (AGENTS.md section 7). A # missing or invalid value stops the scaffold rather than silently defaulting. -if [ "$KIND" = ship ]; then +if [ "$ROLE" = crew ] && [ "$DELIVERABLE" = ship ]; then [ "$MODE_SET" -eq 1 ] || { echo "error: ship briefs require --mode ; resolve it at intake from the captain's instruction and the project's registered posture in data/projects.md" >&2 exit 1 @@ -217,7 +231,7 @@ fi # owns the contract, bin/fm-spawn.sh resolves them). This script is handed the # resolved pair rather than deriving it, exactly like --mode: its REPO argument is # a caller-supplied name, not a checkout it could read. -if [ "$KIND" = secondmate ] && { [ -n "$SLOT_BASE" ] || [ -n "$CONTRIB_TARGET" ]; }; then +if [ "$ROLE" = secondmate ] && { [ -n "$SLOT_BASE" ] || [ -n "$CONTRIB_TARGET" ]; }; then echo "error: --slot-base and --contribution-target apply only to crewmate ship or scout briefs; a secondmate charter cuts no contribution branch" >&2 exit 1 fi @@ -225,7 +239,7 @@ if [ -n "$CONTRIB_TARGET" ] && [ -z "$SLOT_BASE" ]; then echo "error: --contribution-target requires --slot-base; stating where to write without stating where to read is the confusion this contract exists to prevent" >&2 exit 1 fi -if [ "$KIND" = scout ] && [ -n "$CONTRIB_TARGET" ]; then +if [ "$DELIVERABLE" = scout ] && [ -n "$CONTRIB_TARGET" ]; then echo "error: --contribution-target applies only to ship briefs; a scout delivers a report and cuts no branch, so it has only a slot base to read and cite" >&2 exit 1 fi @@ -239,12 +253,12 @@ esac ID=${POS[0]} -if [ "$KIND" = secondmate ] && [ "$HERDR_LAB" -eq 1 ]; then +if [ "$ROLE" = secondmate ] && [ "$HERDR_LAB" -eq 1 ]; then echo "error: --herdr-lab applies only to crewmate ship or scout briefs" >&2 exit 1 fi -if [ "$NO_PROJECTS" -eq 1 ] && [ "$KIND" != secondmate ]; then +if [ "$NO_PROJECTS" -eq 1 ] && [ "$ROLE" != secondmate ]; then echo "error: --no-projects applies only to --secondmate charters" >&2 exit 1 fi @@ -291,7 +305,7 @@ Escalate only genuinely ambiguous intent to firstmate, never the captain. EOF BRANCH_CONFLICT_RESOLUTION=${BRANCH_CONFLICT_RESOLUTION%$'\n'} -if [ "$KIND" = secondmate ]; then +if [ "$ROLE" = secondmate ]; then SECONDMATE_PROJECTS="" idx=1 while [ "$idx" -lt "${#POS[@]}" ]; do @@ -440,7 +454,7 @@ BASE_SECTION= BRANCH_FROM= if [ -n "$SLOT_BASE" ]; then SLOT_SHORT=${SLOT_BASE:0:12} - if [ "$KIND" = scout ]; then + if [ "$DELIVERABLE" = scout ]; then # A scout cuts no branch, but its report's file and line citations are the # deliverable, and citations taken against the wrong trunk have already # nearly corrupted one report. @@ -487,7 +501,7 @@ fi " -if [ "$KIND" = scout ]; then +if [ "$DELIVERABLE" = scout ]; then cat > "$BRIEF" < · source: · # # Logic, in order: -# 1. Resolve worktree + backend target + kind from state/.meta. +# 1. Resolve worktree + backend target + identity axes from state/.meta. # 2. Matching no-mistakes run for this crew's branch AND current code identity, # active or terminal (from `axi status`, or the coarse `no-mistakes runs` # fallback)? Branch name alone is not enough: a historical run on a reused @@ -45,7 +45,7 @@ # the run-step shows the run moved on, the log is deterministically stale and # is flagged superseded. A genuinely parked run plus a needs-decision log # agree, and are reported as parked. -# 4. No run for this crew (pre-validation, or kind=scout): fall back to the +# 4. No run for this crew (pre-validation, or deliverable=scout): fall back to the # recorded backend's pane busy state, then the status log's last line only # when its verb maps to a recognized run-state. Decision-only events such as # `resolved` never become current state or detail. @@ -66,6 +66,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-tmux-lib.sh" # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" # shellcheck source=bin/fm-busy-lib.sh @@ -105,9 +107,11 @@ meta_value() { # } WT=$(meta_value worktree) -KIND=$(meta_value kind) HARNESS=$(meta_value harness) -[ -n "$KIND" ] || KIND=ship +# What the task PRODUCES decides whether a validation run can exist for it at +# all; a record predating the axis split derives it from the retired kind field +# (bin/fm-task-axis-lib.sh). +DELIVERABLE=$(fm_task_deliverable "$META") # A torn-down (or never-created) worktree has no current state to read. if [ -z "$WT" ] || [ ! -d "$WT" ]; then @@ -430,7 +434,7 @@ RUN_SOURCE=full COARSE_STATUS="" # Scouts and secondmates never drive a no-mistakes validation of their own # worktree, so skip the lookup for them and read state from pane/log directly. -if [ "$KIND" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/null 2>&1; then +if [ "$DELIVERABLE" = ship ] && [ -n "$CREW_BRANCH" ] && command -v no-mistakes >/dev/null 2>&1; then RUN_OUT=$(nm_run axi status) if [ -n "$RUN_OUT" ]; then run_branch=$(strip_quotes "$(nm_field branch)") @@ -627,7 +631,7 @@ pane_readable "$BACKEND_TARGET" || emit unknown none "backend target gone: $BACK # Only an exact busy verdict reports working here, and only an exact idle # verdict permits the status-log fallback below. Missing, malformed, stale, or # unverified semantic state remains unknown. -if [ "$KIND" != secondmate ]; then +if [ "$(fm_task_role "$META")" != secondmate ]; then BUSY_VERDICT=$(crew_busy_verdict "$BACKEND_TARGET") case "${BUSY_VERDICT%% *}" in busy) emit working pane "harness busy (${BUSY_VERDICT#* })" ;; diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 697640b4be0..347b11be1aa 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -60,6 +60,9 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # The only work file this script creates holds the closure test's stderr while # --from-ruling is verified. An interrupt during that subprocess is the one @@ -169,13 +172,14 @@ meta_value() { # } origin_open_decisions() { # - local origin=$1 meta="$STATE/$1.meta" status_file="$STATE/$1.status" open kind last verb + local origin=$1 meta="$STATE/$1.meta" status_file="$STATE/$1.status" open last verb open=$(status_open_decisions "$status_file") [ -n "$open" ] || return 0 [ -f "$meta" ] || { printf '%s' "$open"; return 0; } - kind=$(meta_value "$meta" kind) - [ -n "$kind" ] || kind=ship - if [ "$kind" != secondmate ]; then + # A secondmate is persistent, so its terminal-looking events never close a + # decision the way a task's do. That is the ROLE axis alone + # (bin/fm-task-axis-lib.sh); what the work produces is irrelevant here. + if [ "$(fm_task_role "$meta")" != secondmate ]; then last=$(last_status_line "$status_file") verb=$(status_line_verb "$last") case "$verb" in diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 438f10f0b10..138005c2d52 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -27,6 +27,8 @@ SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" # shellcheck source=bin/fm-secondmate-registry-lib.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-task-axis-lib.sh" # --- helpers --------------------------------------------------------------- @@ -236,13 +238,16 @@ dirty_status() { # List this home's LIVE secondmate direct reports from state/.meta records. # The meta file is the liveness signal; data/secondmates.md is only the fallback # for durable fields such as home= when an older/incomplete meta lacks them. +# Membership is the ROLE axis and nothing else: a record predating the axis split +# derives its role from the deprecated kind= alias, so this filter sees old and +# new records alike (bin/fm-task-axis-lib.sh). # Output is pipe-delimited: id|home|window|meta-file. live_secondmate_meta_records() { local state=$1 registry=${2:-} meta id home window [ -d "$state" ] || return 0 for meta in "$state"/*.meta; do [ -f "$meta" ] || continue - grep -q '^kind=secondmate$' "$meta" 2>/dev/null || continue + [ "$(fm_task_role "$meta")" = secondmate ] || continue id=$(basename "$meta" .meta) home=$(grep '^home=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2- || true) if [ -z "$home" ] && [ -n "$registry" ]; then diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 88e604abe31..7cd200e7d65 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -443,7 +443,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG } task_json_lines() { - local meta id kind harness mode yolo project worktree home projects backend target status_log report_path + local meta id kind role deliverable stage harness mode yolo project worktree home projects backend target status_log report_path local remote_host remote_root remote_state remote_rc remote_home_present local pr pr_source event_json current_json endpoint_exists agent_alive meta_json status_json report_json worktree_json home_json local current_state current_source pending_decision blocked_event report_present=0 pr_from_status @@ -461,6 +461,12 @@ task_json_lines() { id=$(basename "$meta" .meta) kind=$(meta_value "$meta" kind) [ -n "$kind" ] || kind=ship + # The three identity axes travel in the snapshot beside the deprecated alias + # so a consumer can filter on the one axis it means. bin/fm-task-axis-lib.sh + # derives them for a record written before the split. + role=$(fm_task_role "$meta") + deliverable=$(fm_task_deliverable "$meta") + stage=$(fm_task_stage "$meta") harness=$(meta_value "$meta" harness) mode=$(meta_value "$meta" mode) yolo=$(meta_value "$meta" yolo) @@ -515,7 +521,7 @@ task_json_lines() { # non-authoritative status-log/none read on a still-live task, keeps the fold's # open decision surfacing. open_decisions_tsv=$(status_open_decisions "$status_log") - if [ "$kind" != secondmate ] && \ + if [ "$role" != secondmate ] && \ { { { [ "$current_source" = run-step ] || [ "$current_source" = pane ]; } \ && [ "$current_state" != parked ] && [ "$current_state" != blocked ]; } \ || { [ "$current_state" = "done" ] || [ "$current_state" = "failed" ]; }; }; then @@ -558,7 +564,7 @@ task_json_lines() { endpoint_exists=false fi fi - if [ "$kind" = secondmate ] && [ -n "$target" ]; then + if [ "$role" = secondmate ] && [ -n "$target" ]; then agent_alive=$(fm_backend_agent_alive "$backend" "$target" 2>/dev/null || printf unknown) fi fi @@ -589,6 +595,9 @@ task_json_lines() { | jq -n \ --arg id "$id" \ --arg kind "$kind" \ + --arg role "$role" \ + --arg deliverable "$deliverable" \ + --arg stage "$stage" \ --arg harness "$harness" \ --arg mode "$mode" \ --arg yolo "$yolo" \ @@ -619,6 +628,9 @@ task_json_lines() { | { id:$id, kind:$kind, + role:$role, + deliverable:$deliverable, + stage:$stage, harness:($harness // ""), mode:($mode // ""), yolo:($yolo // ""), @@ -648,7 +660,7 @@ task_json_lines() { last_event_text:($status_log.last_event.raw // "") }, actions:( - if $kind == "secondmate" then + if $role == "secondmate" then {send:"bin/fm-send.sh fm-\($id) \u0027\u0027", watch:"read status/doc return channel; do not routinely fm-peek a secondmate for answers", return_channel_note:"Secondmate answers come back through status/doc paths after a marked fm-send request."} @@ -1207,7 +1219,7 @@ secondmate_current_json() { # | (($registered | map(.id)) // []) as $registered_ids | ([ $registered[] as $r | $r + {parent_task:([$tasks[] | select(.id == $r.id)][0] // null)} ] - + [ $tasks[] | select(.kind == "secondmate") as $t + + [ $tasks[] | select(.role == "secondmate") as $t | select(($registered_ids | index($t.id)) == null) | {id:$t.id,home:($t.paths.home.path // null), registered:(if $registry.complete == true then false else null end), @@ -1510,7 +1522,7 @@ json_envelope \ | $in.secondmate_landed as $secondmate_landed | def backlog_by_id($id): ($backlog.records[]? | select(.structured == true and .id == $id) | .) // null; def task_by_id($id): ($tasks[]? | select(.id == $id) | .) // null; - def report_kind($id): (task_by_id($id).kind // backlog_by_id($id).kind // "scout"); + def report_deliverable($id): (task_by_id($id).deliverable // "scout"); { schema:"fm-fleet-snapshot.v1", generated:$generated, @@ -1519,10 +1531,10 @@ json_envelope \ backlog:$backlog, tasks:($tasks | map(. + {backlog:backlog_by_id(.id)})), main_inventory:$main_inventory, - scout_reports:($scout_reports | map(. + {kind:report_kind(.id)})), + scout_reports:($scout_reports | map(. + {deliverable:report_deliverable(.id)})), secondmate_current:$secondmate_current, secondmate_landed:$secondmate_landed, secondmate_guidance:{ - note:"For kind=secondmate, bearings selects validated structured state from that registered home; parent events and bounded terminal evidence are fallback-only supplements and never current-state authority." + note:"For role=secondmate, bearings selects validated structured state from that registered home; parent events and bounded terminal evidence are fallback-only supplements and never current-state authority." } }' || { echo "fm-fleet-snapshot: snapshot assembly failed" >&2; exit 1; } diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index d30647b0852..774558871b8 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -53,7 +53,7 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' elif $t.endpoint.exists then "present" else "absent" end; def endpoint_of($t): - if $t.kind == "secondmate" then "\(endpoint_exists($t)) / \($t.endpoint.agent_alive)" + if $t.role == "secondmate" then "\(endpoint_exists($t)) / \($t.endpoint.agent_alive)" else endpoint_exists($t) end; def artifact($t): if $t.pr.url != null then $t.pr.url @@ -66,7 +66,7 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' elif $t.paths.worktree.path != null then $t.paths.worktree.path + " (absent)" else "-" end; def action_of($t): - if $t.kind == "secondmate" then "\($t.actions.send) - \($t.actions.watch)" + if $t.role == "secondmate" then "\($t.actions.send) - \($t.actions.watch)" else $t.actions.watch end; def task_row($t): "| \($t.id) | \($t.current_state.state) / \($t.current_state.source) | \($t.kind) | \(dash($t.backlog.repo // $t.project)) | \($t.backend) | \(endpoint_of($t)) | \(artifact($t)) | \(path_of($t)) | \(action_of($t)) |"; diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index da6072f4f9f..83dd2948e72 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -1,100 +1,29 @@ #!/usr/bin/env bash -# Promote a scout task to a ship task in place: the crewmate keeps its window, -# worktree, and loaded context; only the contract changes. Flips kind= to ship in -# state/.meta so fm-teardown.sh applies the full ship-task teardown protection -# again. After promoting, send the crewmate its ship instructions via fm-send.sh -# (inventory scratch state, reset to a clean default-branch base, carry over only -# intended fix changes, create branch fm/, implement, then report done -# according to this task's delivery mode). -# A scout records no delivery posture, so promotion is where this task's delivery -# contract is decided: --mode and --yolo are REQUIRED and written into the meta -# alongside the kind= flip. Firstmate resolves both at promotion time, having just -# read the scout's report (AGENTS.md section 7); data/projects.md holds the -# captain's standing posture as context, and this script never looks it up. -# no-mistakes-prod-only is a registry policy rather than a task mode and is refused. -# Promotion also recomputes the derived escalation_policy the spawn recorded, so a -# promoted task never keeps a scout's report-only posture (bin/fm-reasoning-lib.sh). -# Usage: fm-promote.sh --mode --yolo +# BOUNDED COMPATIBILITY SHIM. The scout-to-ship operation is bin/fm-reflag.sh; +# this file only forwards to it. It is not a supported entry point and carries no +# behavior of its own - do not add any, and do not cite it in documentation. +# +# It exists for exactly one caller: a firstmate turn that loaded the pre-rename +# instructions and has not yet fast-forwarded past the commit that introduced +# bin/fm-reflag.sh. docs/vocabulary-collisions.md owns the retirement condition +# in full - remove this file once every home this repository serves has +# fast-forwarded and re-read its instructions. +# +# Each use touches state/.reflag-shim-used so that retirement is settled by +# evidence rather than by memory: an absent marker after a full task cycle is the +# proof that no caller still needs the old name. The marker is best-effort and +# never blocks the forward, because refusing to reflag a live task in order to +# record a naming migration would be the wrong failure. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" -# shellcheck source=bin/fm-reasoning-lib.sh -. "$SCRIPT_DIR/fm-reasoning-lib.sh" -MODE= -YOLO= -MODE_SET=0 -YOLO_SET=0 -POS=() -want_value= -for a in "$@"; do - if [ -n "$want_value" ]; then - case "$a" in - --*) echo "error: --$want_value requires a value" >&2; exit 1 ;; - esac - case "$want_value" in - mode) MODE=$a; MODE_SET=1 ;; - yolo) YOLO=$a; YOLO_SET=1 ;; - esac - want_value= - continue - fi - case "$a" in - --mode) want_value=mode ;; - --mode=*) MODE=${a#--mode=}; MODE_SET=1 ;; - --yolo) want_value=yolo ;; - --yolo=*) YOLO=${a#--yolo=}; YOLO_SET=1 ;; - *) POS+=("$a") ;; - esac -done -[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; } -[ "${#POS[@]}" -ge 1 ] || { echo "usage: fm-promote.sh --mode --yolo " >&2; exit 1; } -[ "$MODE_SET" -eq 1 ] || { - echo "error: promotion requires --mode ; decide it now from the scout's findings and the project's registered posture in data/projects.md" >&2 - exit 1 -} -[ "$YOLO_SET" -eq 1 ] || { - echo "error: promotion requires --yolo ; it is this task's routine approval authority, not a project lookup" >&2 - exit 1 -} -case "$MODE" in - no-mistakes|direct-PR|local-only) ;; - no-mistakes-prod-only) - echo "error: no-mistakes-prod-only is a registry policy, not a task mode; classify this task's surface and resolve it to no-mistakes or direct-PR" >&2 - exit 1 ;; - *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; -esac -case "$YOLO" in - on|off) ;; - *) echo "error: --yolo must be on or off (got '$YOLO')" >&2; exit 1 ;; -esac +echo "warning: bin/fm-promote.sh is a compatibility shim and will be removed; the scout-to-ship operation is bin/fm-reflag.sh" >&2 +if [ -d "$STATE" ]; then + { date -u '+%Y-%m-%dT%H:%M:%SZ' >> "$STATE/.reflag-shim-used"; } 2>/dev/null || true +fi -"$FM_ROOT/bin/fm-guard.sh" || true -ID=${POS[0]} -META="$STATE/$ID.meta" -[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } -grep -qx 'kind=scout' "$META" || { echo "error: task $ID is not a scout task (kind=scout not in meta)" >&2; exit 1; } - -# escalation_policy is DERIVED from kind plus the delivery contract, so a -# promotion that changes the contract has to recompute it or the meta keeps the -# scout's report-only posture on a task that can now reach a merge gate. The -# reason code is left as recorded: the same agent continues, and why its turn -# was necessary did not change (bin/fm-reasoning-lib.sh). -ESCALATION_POLICY=$(fm_escalation_policy_for ship "$MODE" "$YOLO") - -TMP="$META.tmp" -grep -v -e '^kind=' -e '^mode=' -e '^yolo=' -e '^escalation_policy=' "$META" > "$TMP" -{ - echo "kind=ship" - echo "mode=$MODE" - echo "yolo=$YOLO" - echo "escalation_policy=$ESCALATION_POLICY" -} >> "$TMP" -mv "$TMP" "$META" - -HOME_Q=$(printf '%q' "$FM_HOME") -echo "promoted $ID to ship mode=$MODE yolo=$YOLO (teardown protection restored)" -echo "next: FM_HOME=$HOME_Q bin/fm-send.sh fm-$ID ''" +exec "$SCRIPT_DIR/fm-reflag.sh" "$@" diff --git a/bin/fm-reflag.sh b/bin/fm-reflag.sh new file mode 100755 index 00000000000..8ad35e88167 --- /dev/null +++ b/bin/fm-reflag.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Reflag a scout task as a ship task in place: the crewmate keeps its window, +# worktree, and loaded context; only the contract changes. A vessel is reflagged +# when its registry and contract change while hull and crew stay, which is +# exactly this operation - see docs/vocabulary-collisions.md for why the fleet +# stopped calling it promotion, a word the Agentic Engineering platform owns for +# a canonized law. +# +# Moves the task to deliverable=ship and stage=reflagged in state/.meta +# so fm-teardown.sh applies the full ship-task teardown protection again. The +# deprecated kind= alias is dual-written beside them for the migration window +# (bin/fm-task-axis-lib.sh owns both the axes and the alias's derivation). +# Reflagging is the ONLY writer of stage=reflagged, which is what makes the +# lifecycle axis true: before the split this transition was expressed by +# rewriting a deliverable type, so a reflagged ship and a commissioned one were +# indistinguishable. +# +# After reflagging, send the crewmate its ship instructions via fm-send.sh +# (inventory scratch state, reset to a clean default-branch base, carry over only +# intended fix changes, create branch fm/, implement, then report done +# according to this task's delivery mode). +# A scout records no delivery posture, so this is where the task's delivery +# contract is decided: --mode and --yolo are REQUIRED and written into the meta +# alongside the axis change. Firstmate resolves both at reflag time, having just +# read the scout's report (AGENTS.md section 7); data/projects.md holds the +# captain's standing posture as context, and this script never looks it up. +# no-mistakes-prod-only is a registry policy rather than a task mode and is refused. +# Reflagging also recomputes the derived escalation_policy the spawn recorded, so a +# reflagged task never keeps a scout's report-only posture (bin/fm-reasoning-lib.sh). +# Usage: fm-reflag.sh --mode --yolo +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-backend.sh disable=SC1091 +. "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-task-axis-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-task-axis-lib.sh" +# shellcheck source=bin/fm-reasoning-lib.sh +. "$SCRIPT_DIR/fm-reasoning-lib.sh" + +MODE= +YOLO= +MODE_SET=0 +YOLO_SET=0 +POS=() +want_value= +for a in "$@"; do + if [ -n "$want_value" ]; then + case "$a" in + --*) echo "error: --$want_value requires a value" >&2; exit 1 ;; + esac + case "$want_value" in + mode) MODE=$a; MODE_SET=1 ;; + yolo) YOLO=$a; YOLO_SET=1 ;; + esac + want_value= + continue + fi + case "$a" in + --mode) want_value=mode ;; + --mode=*) MODE=${a#--mode=}; MODE_SET=1 ;; + --yolo) want_value=yolo ;; + --yolo=*) YOLO=${a#--yolo=}; YOLO_SET=1 ;; + *) POS+=("$a") ;; + esac +done +[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; } +[ "${#POS[@]}" -ge 1 ] || { echo "usage: fm-reflag.sh --mode --yolo " >&2; exit 1; } +[ "$MODE_SET" -eq 1 ] || { + echo "error: reflagging requires --mode ; decide it now from the scout's findings and the project's registered posture in data/projects.md" >&2 + exit 1 +} +[ "$YOLO_SET" -eq 1 ] || { + echo "error: reflagging requires --yolo ; it is this task's routine approval authority, not a project lookup" >&2 + exit 1 +} +case "$MODE" in + no-mistakes|direct-PR|local-only) ;; + no-mistakes-prod-only) + echo "error: no-mistakes-prod-only is a registry policy, not a task mode; classify this task's surface and resolve it to no-mistakes or direct-PR" >&2 + exit 1 ;; + *) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;; +esac +case "$YOLO" in + on|off) ;; + *) echo "error: --yolo must be on or off (got '$YOLO')" >&2; exit 1 ;; +esac + +"$FM_ROOT/bin/fm-guard.sh" || true +ID=${POS[0]} +META="$STATE/$ID.meta" +[ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } + +# A record whose deprecated alias contradicts its axes has an identity nobody can +# read, so reflagging it would decide that identity by luck. Refuse and name the +# disagreement instead; this is a stop-and-investigate result, not an obstacle. +if fm_task_axes_conflict "$META"; then + echo "error: task $ID records a contradictory identity ($FM_TASK_AXES_CONFLICT); settle it before reflagging" >&2 + exit 1 +fi +[ "$(fm_task_deliverable "$META")" = scout ] || { + echo "error: task $ID is not a scout task (deliverable=scout not in meta)" >&2 + exit 1 +} + +# escalation_policy is DERIVED from the deliverable plus the delivery contract, so +# a reflag that changes the contract has to recompute it or the meta keeps the +# scout's report-only posture on a task that can now reach a merge gate. The +# reason code is left as recorded: the same agent continues, and why its turn +# was necessary did not change (bin/fm-reasoning-lib.sh). +ESCALATION_POLICY=$(fm_escalation_policy_for ship "$MODE" "$YOLO") + +TMP="$META.tmp" +grep -v -e '^kind=' -e '^mode=' -e '^yolo=' -e '^role=' -e '^deliverable=' -e '^stage=' \ + -e '^escalation_policy=' "$META" > "$TMP" +{ + echo "kind=ship" + fm_task_axes_emit ship reflagged + echo "mode=$MODE" + echo "yolo=$YOLO" + echo "escalation_policy=$ESCALATION_POLICY" +} >> "$TMP" +mv "$TMP" "$META" + +HOME_Q=$(printf '%q' "$FM_HOME") +echo "reflagged $ID to ship mode=$MODE yolo=$YOLO (teardown protection restored)" +echo "next: FM_HOME=$HOME_Q bin/fm-send.sh fm-$ID ''" diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 879e79e49fd..81b8da2961f 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -86,6 +86,8 @@ fi # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # shellcheck source=bin/fm-marker-lib.sh . "$SCRIPT_DIR/fm-marker-lib.sh" # shellcheck source=bin/fm-pending-reply-lib.sh @@ -266,7 +268,7 @@ TARGET_TASK_ID= if [ -n "$TARGET_SELECTOR" ] && [ -n "$TARGET_META" ]; then MARK_FROM_FIRSTMATE=1 TARGET_TASK_ID=$(fm_send_id_from_meta "$TARGET_META") - if [ "$(fm_meta_get "$TARGET_META" kind)" = secondmate ]; then + if [ "$(fm_task_role "$TARGET_META")" = secondmate ]; then MARK_PENDING_REPLY=1 fi fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 68d03e828d8..defc098e193 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -257,6 +257,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-task-base-lib.sh . "$SCRIPT_DIR/fm-task-base-lib.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-secondmate-nudge-lib.sh @@ -628,7 +630,7 @@ spawn_remote_secondmate() { meta="$STATE/$id.meta" if [ -e "$meta" ] || [ -L "$meta" ]; then if [ ! -f "$meta" ] || [ -L "$meta" ] \ - || [ "$(fm_meta_get "$meta" kind)" != secondmate ] \ + || [ "$(fm_task_role "$meta")" != secondmate ] \ || [ "$(fm_meta_get "$meta" remote_host)" != "$host" ] \ || [ "$(fm_meta_get "$meta" remote_root)" != "$root" ] \ || [ "$(fm_meta_get "$meta" home)" != "$home" ]; then @@ -758,6 +760,7 @@ spawn_remote_secondmate() { echo "project=$root" echo "harness=$harness" echo "kind=secondmate" + fm_task_axes_emit secondmate echo "mode=secondmate" echo "yolo=off" echo "tasktmp=" @@ -896,6 +899,7 @@ spawn_abort_cleanup() { echo "project=$PROJ_ABS" echo "harness=$HARNESS" echo "kind=$KIND" + fm_task_axes_emit "$KIND" [ -z "${MODE:-}" ] || echo "mode=$MODE" [ -z "${YOLO:-}" ] || echo "yolo=$YOLO" echo "tasktmp=${TASK_TMP:-}" @@ -2274,7 +2278,7 @@ fi # validate/merge stages can branch on it. A ship task carries the explicit # per-task decision validated above; a secondmate's posture is fixed; a scout # records none at all, because its deliverable is a report rather than a merge -# (fm-teardown.sh defaults an absent mode to no-mistakes, and fm-promote.sh +# (fm-teardown.sh defaults an absent mode to no-mistakes, and fm-reflag.sh # requires an explicit mode when a scout is promoted to a ship task). if [ "$KIND" = secondmate ]; then MODE=secondmate @@ -2361,7 +2365,11 @@ fi fi echo "project=$PROJ_ABS" echo "harness=$HARNESS" + # The three identity axes, plus the deprecated kind= alias they replace. Every + # writer dual-writes through bin/fm-task-axis-lib.sh so no writer spells the + # derivation itself; docs/vocabulary-collisions.md owns the alias's retirement. echo "kind=$KIND" + fm_task_axes_emit "$KIND" [ -z "$MODE" ] || echo "mode=$MODE" [ -z "$YOLO" ] || echo "yolo=$YOLO" # Both base references, so which commit a task read and which it contributed diff --git a/bin/fm-task-axis-lib.sh b/bin/fm-task-axis-lib.sh new file mode 100755 index 00000000000..da7d4f3d80a --- /dev/null +++ b/bin/fm-task-axis-lib.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Single owner of a task's THREE identity axes, recorded in state/.meta. +# One `kind=` field used to carry all three facts at once, so every consumer +# reconstructed the axis it cared about from a value that also encoded the two +# it did not: +# +# role= crew | secondmate WHO the worker is. +# Population membership: a secondmate is a persistent direct +# report with its own home, a crew worker is a task worker. +# Consumers that ask "is this a crew task at all" want this axis +# and nothing else. +# deliverable= scout | ship WHAT the task produces. +# A scout produces a report and never a PR; a ship produces a +# project change through its delivery mode. Consumers that gate +# the validation pipeline or the scout teardown carve-out want +# this axis. +# stage= commissioned | reflagged | delivered WHERE the task stands. +# commissioned at spawn, reflagged when bin/fm-reflag.sh changes +# a scout's contract to a ship's, delivered once its work landed. +# A field a transition MUTATES is a state, not a type, which is +# why the old field could not hold it. +# +# The axes are ORTHOGONAL: no consumer may infer one from another. A secondmate +# records deliverable=ship because its work lands as project change, not because +# role and deliverable are linked; a future role value would not change that. +# +# The role axis is the home for every role-typed fact about a task, including a +# requested agent role. Adding one is a new value or field ON this axis, never a +# fourth dimension beside it - reconstructing meaning from an overloaded field is +# exactly what the split removed. +# +# `kind=` is a DEPRECATED ALIAS during the migration, dual-written by every +# writer and still the derivation source for a record that predates the split. +# docs/vocabulary-collisions.md owns its retirement condition. Until then this +# file refuses a record whose `kind=` disagrees with its explicit axes rather +# than silently picking one, so a stale writer that flips the old field alone +# cannot desynchronize a task's identity. +# +# Sourced by every meta writer (bin/fm-spawn.sh, bin/fm-reflag.sh) and every +# consumer that branches on a task's identity. Depends on bin/fm-backend.sh for +# fm_meta_get. +# +# Reading an axis: +# fm_task_role crew | secondmate +# fm_task_deliverable scout | ship +# fm_task_stage commissioned | reflagged | delivered +# Each prints the explicit field when the record carries it, and otherwise the +# deterministic derivation below. An absent record reads as the spawn default, +# matching what every consumer already assumed for an absent `kind=`. +# +# Writing: +# fm_task_axes_emit [stage] the meta lines for a dual-writing writer +# fm_task_axes_backfill derive the axes into an existing record +# +# Checking: +# fm_task_axes_conflict 0 when the alias disagrees with an axis + +# The one meta reader stays bin/fm-backend.sh's fm_meta_get rather than being +# respelled here. Most callers already source that file; the guard is for a +# caller that reaches the axes through a library instead (bin/fm-ff-lib.sh), so +# this file works wherever it is sourced without a second copy of the read. +# fm-backend.sh's own top-level assignments all defer to an already-set value, +# so sourcing it after FM_ROOT and FM_HOME are set changes nothing. +if ! declare -F fm_meta_get >/dev/null 2>&1; then + # shellcheck source=bin/fm-backend.sh disable=SC1091 + . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-backend.sh" +fi + +# Derivation from the deprecated alias. This table is the whole migration +# contract for records written before the split, and it is total over the three +# values the old field ever took: +# +# kind=secondmate -> role=secondmate deliverable=ship +# kind=ship -> role=crew deliverable=ship +# kind=scout -> role=crew deliverable=scout +# +# STAGE IS NOT DERIVED FROM THE ALIAS, and this is a real limitation rather +# than an oversight: `kind=ship` was written both by a spawn and by the +# scout-to-ship operation, so a record predating the split cannot distinguish a +# commissioned ship from a reflagged one. Backfill records the lower-information +# value, `commissioned`, and the stage axis is authoritative only for a task +# reflagged after the split. Deriving anything else would invent a fact the old +# field never carried. + +FM_TASK_ROLE_DEFAULT=crew +FM_TASK_DELIVERABLE_DEFAULT=ship +FM_TASK_STAGE_DEFAULT=commissioned + +fm_task_role_valid() { # + case "${1-}" in crew|secondmate) return 0 ;; *) return 1 ;; esac +} + +fm_task_deliverable_valid() { # + case "${1-}" in scout|ship) return 0 ;; *) return 1 ;; esac +} + +fm_task_stage_valid() { # + case "${1-}" in commissioned|reflagged|delivered) return 0 ;; *) return 1 ;; esac +} + +# fm_task_role_of_kind / fm_task_deliverable_of_kind: the derivation table +# above, applied to one alias value. An empty or unrecognized alias derives the +# spawn default, exactly as every consumer already defaulted an absent `kind=`. +fm_task_role_of_kind() { # + case "${1-}" in + secondmate) printf 'secondmate' ;; + *) printf '%s' "$FM_TASK_ROLE_DEFAULT" ;; + esac +} + +fm_task_deliverable_of_kind() { # + case "${1-}" in + scout) printf 'scout' ;; + *) printf '%s' "$FM_TASK_DELIVERABLE_DEFAULT" ;; + esac +} + +fm_task_role() { # + local v + v=$(fm_meta_get "${1-}" role) + if fm_task_role_valid "$v"; then printf '%s' "$v"; return 0; fi + fm_task_role_of_kind "$(fm_meta_get "${1-}" kind)" +} + +fm_task_deliverable() { # + local v + v=$(fm_meta_get "${1-}" deliverable) + if fm_task_deliverable_valid "$v"; then printf '%s' "$v"; return 0; fi + fm_task_deliverable_of_kind "$(fm_meta_get "${1-}" kind)" +} + +fm_task_stage() { # + local v + v=$(fm_meta_get "${1-}" stage) + if fm_task_stage_valid "$v"; then printf '%s' "$v"; return 0; fi + printf '%s' "$FM_TASK_STAGE_DEFAULT" +} + +# fm_task_axes_emit: the axis lines a dual-writing meta writer appends beside +# its own `kind=` line, so one writer never spells the derivation itself. +# defaults to the spawn stage; pass it explicitly at a transition. +fm_task_axes_emit() { # [stage] + local kind=${1-} stage=${2:-$FM_TASK_STAGE_DEFAULT} + fm_task_stage_valid "$stage" || stage=$FM_TASK_STAGE_DEFAULT + printf 'role=%s\n' "$(fm_task_role_of_kind "$kind")" + printf 'deliverable=%s\n' "$(fm_task_deliverable_of_kind "$kind")" + printf 'stage=%s\n' "$stage" +} + +# fm_task_axes_conflict: 0 when the record carries BOTH the deprecated alias +# and an explicit axis that the alias does not derive to. That state means a +# writer changed one and not the other - the exact failure the dual-write +# window exists to catch - and every caller treats it as refusal rather than +# resolving it, because either value could be the stale one. Sets +# FM_TASK_AXES_CONFLICT to a one-line description. +FM_TASK_AXES_CONFLICT='' +fm_task_axes_conflict() { # + local meta=${1-} kind role deliverable + FM_TASK_AXES_CONFLICT='' + [ -f "$meta" ] || return 1 + kind=$(fm_meta_get "$meta" kind) + [ -n "$kind" ] || return 1 + role=$(fm_meta_get "$meta" role) + deliverable=$(fm_meta_get "$meta" deliverable) + if fm_task_role_valid "$role" && [ "$role" != "$(fm_task_role_of_kind "$kind")" ]; then + FM_TASK_AXES_CONFLICT="kind=$kind derives role=$(fm_task_role_of_kind "$kind") but the record says role=$role" + return 0 + fi + if fm_task_deliverable_valid "$deliverable" \ + && [ "$deliverable" != "$(fm_task_deliverable_of_kind "$kind")" ]; then + FM_TASK_AXES_CONFLICT="kind=$kind derives deliverable=$(fm_task_deliverable_of_kind "$kind") but the record says deliverable=$deliverable" + return 0 + fi + return 1 +} + +# fm_task_axes_backfill: derive the axes INTO an existing record, forward-only +# and idempotent. Never rewrites an axis the record already states, never +# rewrites the alias, and never touches any other field - a record it has +# already converged through is left byte-identical, so repeated sweeps are +# free. Refuses a conflicted record rather than papering over it. Returns 0 +# when the record is converged (whether or not this call wrote), 1 on refusal +# or write failure. +fm_task_axes_backfill() { # + local meta=${1-} kind tmp add=0 + [ -f "$meta" ] || return 1 + if fm_task_axes_conflict "$meta"; then + printf 'fm_task_axes_backfill: refusing conflicted record %s: %s\n' \ + "$meta" "$FM_TASK_AXES_CONFLICT" >&2 + return 1 + fi + kind=$(fm_meta_get "$meta" kind) + tmp="$meta.axis.$$" + cp -- "$meta" "$tmp" 2>/dev/null || return 1 + fm_task_role_valid "$(fm_meta_get "$meta" role)" \ + || { printf 'role=%s\n' "$(fm_task_role_of_kind "$kind")" >> "$tmp"; add=1; } + fm_task_deliverable_valid "$(fm_meta_get "$meta" deliverable)" \ + || { printf 'deliverable=%s\n' "$(fm_task_deliverable_of_kind "$kind")" >> "$tmp"; add=1; } + fm_task_stage_valid "$(fm_meta_get "$meta" stage)" \ + || { printf 'stage=%s\n' "$FM_TASK_STAGE_DEFAULT" >> "$tmp"; add=1; } + if [ "$add" -eq 0 ]; then + rm -f -- "$tmp" + return 0 + fi + mv -f -- "$tmp" "$meta" || { rm -f -- "$tmp"; return 1; } + return 0 +} + +# fm_task_axes_set: replace one axis in an existing record, forward-only. Used +# by a transition that changes a task's identity (bin/fm-reflag.sh), so the +# axis is rewritten in place rather than appended twice. +fm_task_axes_set() { # + local meta=${1-} axis=${2-} value=${3-} tmp + [ -f "$meta" ] || return 1 + case "$axis" in + role) fm_task_role_valid "$value" || return 1 ;; + deliverable) fm_task_deliverable_valid "$value" || return 1 ;; + stage) fm_task_stage_valid "$value" || return 1 ;; + *) return 1 ;; + esac + tmp="$meta.axis.$$" + grep -v "^$axis=" "$meta" > "$tmp" || true + printf '%s=%s\n' "$axis" "$value" >> "$tmp" + mv -f -- "$tmp" "$meta" || { rm -f -- "$tmp"; return 1; } + return 0 +} diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 0347ee06bca..cdb9f161954 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -151,6 +151,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh @@ -290,8 +292,7 @@ remote_secondmate_teardown() { local remote_host remote_root remote_home kind route_host route_root route_home out rc tmp rec phase task_id remote_host=$(fm_meta_get "$META" remote_host) [ -n "$remote_host" ] || return 3 - kind=$(fm_meta_get "$META" kind) - [ "$kind" = secondmate ] || { echo "REFUSED: remote placement metadata is valid only for a secondmate" >&2; return 1; } + [ "$(fm_task_role "$META")" = secondmate ] || { echo "REFUSED: remote placement metadata is valid only for a secondmate" >&2; return 1; } remote_root=$(fm_meta_get "$META" remote_root) remote_home=$(fm_meta_get "$META" home) [ -n "$remote_root" ] && [ -n "$remote_home" ] || { echo "REFUSED: remote secondmate metadata is incomplete" >&2; return 1; } @@ -417,6 +418,20 @@ ORCA_PATH_MATCH_VERIFIED=0 KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship +# The identity axes this teardown branches on, resolved once. ROLE decides +# whether this is a persistent direct report at all; DELIVERABLE decides which +# protection applies to its work. A record predating the axis split derives both +# from the deprecated alias above (bin/fm-task-axis-lib.sh). +# Which protection this teardown applies is decided by those axes, so a record +# whose alias contradicts them has no readable identity and must stop here: a +# scout's worktree is declared scratch while a ship's is protected, and guessing +# between them is how unlanded work gets discarded. Stop and investigate. +if fm_task_axes_conflict "$META"; then + echo "REFUSED: task $ID records a contradictory identity ($FM_TASK_AXES_CONFLICT); settle it before teardown" >&2 + exit 1 +fi +ROLE=$(fm_task_role "$META") +DELIVERABLE=$(fm_task_deliverable "$META") MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes PUBLIC_FOLLOWUP_HOME=$FM_HOME @@ -475,7 +490,7 @@ if [ -f "$FM_HOME/$SUB_HOME_MARKER" ]; then PUBLIC_FOLLOWUP_HOME= PUBLIC_FOLLOWUP_STATE= fi -elif [ "$KIND" = secondmate ]; then +elif [ "$ROLE" = secondmate ]; then PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$ID" if [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 @@ -525,7 +540,7 @@ require_orca_terminal() { printf '%s\n' "$terminal" } -if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then +if [ "$BACKEND" = orca ] && [ "$ROLE" != secondmate ]; then ORCA_WORKTREE_ID=$(require_orca_worktree_id "$META") || exit 1 T_ORCA=$(meta_value "$META" terminal) [ -z "$T_ORCA" ] || T=$T_ORCA @@ -635,7 +650,7 @@ validate_pr_poll_cleanup() { # merge watch but not the ability to land. write_landing_record_if_unlanded() { [ -n "$PR_URL" ] || return 0 - [ "$KIND" = ship ] || return 0 + [ "$DELIVERABLE" = ship ] || return 0 [ "$MODE" != local-only ] || return 0 fm_pr_url_parse "$PR_URL" || return 0 if fm_pr_forge_view "$PR_URL" && [ "$FM_PR_FORGE_STATE" = merged ]; then @@ -849,9 +864,9 @@ work_is_landed() { backlog_refresh_reminder() { local pr done_cmd report_path - [ "$KIND" = secondmate ] && return 0 + [ "$ROLE" = secondmate ] && return 0 if fm_tasks_axi_backend_available "$CONFIG"; then - case "$KIND" in + case "$DELIVERABLE" in scout) report_path="data/$ID/report.md" done_cmd="tasks-axi done $ID --report $report_path" @@ -881,7 +896,7 @@ backlog_refresh_reminder() { # stays silent for every home that has not configured an admission policy. admission_release_reminder() { local state - [ "$KIND" = secondmate ] && return 0 + [ "$ROLE" = secondmate ] && return 0 state=$(fm_admission_state "$(fm_admission_config_file "$CONFIG")") [ "$state" = active ] || return 0 printf '%s\n' "Admission: $ID released its worker. Run bin/fm-admission.sh to recompute the fleet band before releasing any load-held request, then admit at most one at a time, re-evaluating between each." @@ -1134,9 +1149,10 @@ validate_worktree_teardown_safety() { local dirty_raw dirty unpushed_raw unpushed DEFAULT unmerged_raw unmerged branch [ -d "$WT" ] || return 0 [ "$FORCE" != "--force" ] || return 0 - case "$KIND" in - secondmate|scout) return 0 ;; - esac + # Two independent reasons to waive this check, one per axis: a secondmate owns + # a home rather than a task worktree, and a scout worktree is declared scratch. + [ "$ROLE" != secondmate ] || return 0 + [ "$DELIVERABLE" != scout ] || return 0 # --untracked-files=all so git never collapses an untracked directory to a bare # "?? .opencode/" line: the allowlist below names one exact spawn-written file, and @@ -1272,12 +1288,12 @@ task_status_is_run_not_found() { # # Abort THIS task's own parked no-mistakes run before the worker that would # have answered its gate is removed, so no run is left orphaned holding a -# fleet slot. Only KIND=ship drives a no-mistakes validation of its own +# fleet slot. Only deliverable=ship drives a no-mistakes validation of its own # worktree (scouts and secondmates never do, mirroring bin/fm-crew-state.sh); # a run not attributed to this exact branch+head is left completely alone. conclude_task_no_mistakes_run() { # local wt=$1 out run_id - [ "$KIND" = ship ] || return 0 + [ "$DELIVERABLE" = ship ] || return 0 [ -d "$wt" ] || return 0 command -v no-mistakes >/dev/null 2>&1 || return 0 task_run_is_own_parked_run "$wt" || return 0 @@ -1883,13 +1899,12 @@ preflight_firstmate_home_process_events() { } preflight_firstmate_home_process_event_tree() { - local home=$1 label=$2 sub_state child_meta child_kind child_home child_wt child_id + local home=$1 label=$2 sub_state child_meta child_home child_wt child_id sub_state="$home/state" if [ -d "$sub_state" ]; then for child_meta in "$sub_state"/*.meta; do [ -e "$child_meta" ] || continue - child_kind=$(meta_value "$child_meta" kind) - [ "$child_kind" = secondmate ] || continue + [ "$(fm_task_role "$child_meta")" = secondmate ] || continue child_id=$(basename "$child_meta" .meta) child_wt=$(meta_value "$child_meta" worktree) child_home=$(meta_value "$child_meta" home) @@ -1901,7 +1916,7 @@ preflight_firstmate_home_process_event_tree() { } validate_firstmate_home_children_removal() { - local home=$1 sub_state child_meta child_id child_wt child_proj child_kind child_home child_backend child_orca_worktree_id + local home=$1 sub_state child_meta child_id child_wt child_proj child_role child_home child_backend child_orca_worktree_id sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -1910,10 +1925,9 @@ validate_firstmate_home_children_removal() { fm_backend_validate_task_endpoint "$child_meta" "$child_id" || return 1 validate_pr_poll_cleanup "$sub_state" "$child_id" || return 1 child_wt=$(meta_value "$child_meta" worktree) - child_kind=$(meta_value "$child_meta" kind) - [ -n "$child_kind" ] || child_kind=ship + child_role=$(fm_task_role "$child_meta") child_backend=$(fm_backend_of_meta "$child_meta") - if [ "$child_kind" = secondmate ]; then + if [ "$child_role" = secondmate ]; then child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt validate_firstmate_home_for_removal "$child_home" "child firstmate home" "$child_id" >/dev/null || return 1 @@ -2045,7 +2059,7 @@ $session $lock_path" } preflight_firstmate_home_herdr_children() { # - local home=$1 sub_state child_meta child_id child_backend child_target child_kind child_home child_wt + local home=$1 sub_state child_meta child_id child_backend child_target child_role child_home child_wt sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -2057,9 +2071,8 @@ preflight_firstmate_home_herdr_children() { # if [ "$child_backend" = herdr ]; then teardown_herdr_preflight_target "$child_target" "$child_id" || return 1 fi - child_kind=$(meta_value "$child_meta" kind) - [ -n "$child_kind" ] || child_kind=ship - if [ "$child_kind" = secondmate ]; then + child_role=$(fm_task_role "$child_meta") + if [ "$child_role" = secondmate ]; then child_wt=$(meta_value "$child_meta" worktree) child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt @@ -2069,7 +2082,7 @@ preflight_firstmate_home_herdr_children() { # } cleanup_firstmate_home_children() { - local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen + local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_role child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -2077,15 +2090,14 @@ cleanup_firstmate_home_children() { child_id=$(basename "$child_meta" .meta) child_wt=$(meta_value "$child_meta" worktree) child_proj=$(meta_value "$child_meta" project) - child_kind=$(meta_value "$child_meta" kind) - [ -n "$child_kind" ] || child_kind=ship + child_role=$(fm_task_role "$child_meta") child_backend=$(fm_backend_of_meta "$child_meta") if [ "$child_backend" = orca ]; then child_t=$(meta_value "$child_meta" terminal) else child_t=$(fm_backend_target_of_meta "$child_meta") fi - if [ "$child_backend" = orca ] && [ "$child_kind" != secondmate ]; then + if [ "$child_backend" = orca ] && [ "$child_role" != secondmate ]; then child_orca_worktree_id=$(require_orca_worktree_id "$child_meta") || return 1 if [ -n "$child_wt" ] && [ -e "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 @@ -2111,7 +2123,7 @@ cleanup_firstmate_home_children() { fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" 2>/dev/null || true fi fi - if [ "$child_kind" = secondmate ]; then + if [ "$child_role" = secondmate ]; then child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt if [ -n "$child_home" ] && [ -d "$child_home" ]; then @@ -2172,7 +2184,7 @@ remove_secondmate_registry_entry() { validate_pr_poll_cleanup "$STATE" "$ID" || exit 1 -if [ "$KIND" = secondmate ]; then +if [ "$ROLE" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT validate_firstmate_home_for_removal "$HOME_PATH" "secondmate home" "$ID" >/dev/null || exit 1 if [ "$FORCE" = "--force" ]; then @@ -2184,7 +2196,7 @@ if [ "$KIND" = secondmate ]; then fi fi -if [ "$KIND" = secondmate ] && [ "$FORCE" != "--force" ]; then +if [ "$ROLE" = secondmate ] && [ "$FORCE" != "--force" ]; then SUB_STATE="$HOME_PATH/state" if [ -d "$SUB_STATE" ]; then for child_meta in "$SUB_STATE"/*.meta; do @@ -2196,15 +2208,15 @@ if [ "$KIND" = secondmate ] && [ "$FORCE" != "--force" ]; then fi fi -if [ "$KIND" = secondmate ]; then +if [ "$ROLE" = secondmate ]; then preflight_firstmate_home_process_event_tree "$HOME_PATH" "secondmate home" || exit 1 fi -if [ "$KIND" = secondmate ] && [ "$FORCE" = "--force" ]; then +if [ "$ROLE" = secondmate ] && [ "$FORCE" = "--force" ]; then cleanup_firstmate_home_children "$HOME_PATH" || exit $? fi -if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then +if [ "$DELIVERABLE" = scout ] && [ "$FORCE" != "--force" ]; then REPORT="$DATA/$ID/report.md" if [ ! -f "$REPORT" ]; then echo "REFUSED: scout task $ID has no report at $REPORT." >&2 @@ -2241,7 +2253,7 @@ if [ "$FORCE" != "--force" ] \ fi fi -if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then +if [ "$BACKEND" = orca ] && [ "$DELIVERABLE" != scout ] && [ "$ROLE" != secondmate ] && [ "$FORCE" != "--force" ]; then if ! inspectable_git_worktree "$WT"; then echo "REFUSED: Orca ship task $ID has no inspectable git worktree at ${WT:-}." >&2 echo "Cannot verify dirty or unlanded work; restore the worktree path or get explicit OK to discard, then --force." >&2 @@ -2272,7 +2284,7 @@ fi # kind=secondmate: a secondmate home's own runtime lifecycle is owned by the # dedicated process-event and firstmate-home removal machinery further below, # not by task-worktree cleanup. -if [ "$KIND" != secondmate ]; then +if [ "$ROLE" != secondmate ]; then conclude_task_no_mistakes_run "$WT" reap_task_worktree_processes worktree "$WT" "$TASK_TMP" fi @@ -2294,7 +2306,7 @@ if [ "$BACKEND" = herdr ]; then fi # Best-effort: drop the local task branch so the shared repo does not accumulate refs. -if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then +if [ "$BACKEND" = orca ] && [ "$ROLE" != secondmate ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then require_orca_worktree_path_match_if_present "$ORCA_WORKTREE_ID" "$WT" || exit 1 ORCA_PATH_MATCH_VERIFIED=1 @@ -2312,7 +2324,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" -elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then +elif [ -d "$WT" ] && [ "$ROLE" != secondmate ]; then branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) if [ "$branch" != "HEAD" ]; then if git -C "$WT" checkout --detach -q 2>/dev/null; then @@ -2327,7 +2339,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then # the project. teardown_treehouse_return tolerates transient and stale git locks # left by a killed crew process; see the script header for retry and stale-lock proof. post_lock_cleanup_check= - if [ "$FORCE" != "--force" ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ]; then + if [ "$FORCE" != "--force" ] && [ "$DELIVERABLE" != scout ] && [ "$ROLE" != secondmate ]; then post_lock_cleanup_check=validate_worktree_teardown_safety fi teardown_treehouse_return "$WT" "$PROJ" "worktree" "$post_lock_cleanup_check" || { @@ -2404,7 +2416,7 @@ if [ "$BACKEND" = herdr ]; then fi LEDGER_PATH="${FM_WAKE_LEDGER:-$DATA/wake-ledger.tsv}" LEDGER_INSIDE_REMOVED_HOME=0 -if [ "$KIND" = secondmate ]; then +if [ "$ROLE" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT # Ask this while the home still exists: afterwards neither path resolves, and # inferring the answer from a missing directory would also silence the ledger @@ -2474,7 +2486,8 @@ FM_WAKE_LEDGER="$LEDGER_PATH" \ --model "$(meta_value "$META" model)" \ --effort "$(meta_value "$META" effort)" \ --mode "$MODE" \ - --kind "$KIND" \ + --role "$ROLE" \ + --deliverable "$DELIVERABLE" \ --project "$PROJ" \ --backend "$BACKEND" \ --route "$(meta_value "$META" route)" \ @@ -2486,7 +2499,7 @@ rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.childcpu" \ "$STATE/$ID.terminal-recorded" retire_attempt_record -if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then +if [ "$DELIVERABLE" != scout ] && [ "$ROLE" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi echo "teardown $ID complete (window $T, worktree $WT)" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 2114827add4..14ff2310edd 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -144,7 +144,8 @@ family_for_basename() { fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ - fm-supervision-instructions.test.sh|fm-task-base.test.sh|fm-task-delivery.test.sh|\ + fm-supervision-instructions.test.sh|fm-task-axis.test.sh|\ + fm-task-base.test.sh|fm-task-delivery.test.sh|\ fm-tmux-submit-busy.test.sh|fm-trace-context-lib.test.sh|\ fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) @@ -933,6 +934,7 @@ families_for_changed_path() { bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ + bin/fm-reflag.sh|bin/fm-task-axis-lib.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit ;; diff --git a/bin/fm-update.sh b/bin/fm-update.sh index 9cfe80d90d4..7962e407c9e 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -88,7 +88,7 @@ if [ -f "$SECONDMATES_MD" ]; then case "$remote_result" in synced:*) echo "remote secondmate $id: updated on $SECONDMATE_REGISTRY_HOST (${remote_result#synced: })" - if [ -f "$STATE/$id.meta" ] && grep -qx 'kind=secondmate' "$STATE/$id.meta"; then + if [ -f "$STATE/$id.meta" ] && [ "$(fm_task_role "$STATE/$id.meta")" = secondmate ]; then FF_NUDGE_WINDOWS="$FF_NUDGE_WINDOWS fm-$id" fi ;; diff --git a/bin/fm-wake-ledger.sh b/bin/fm-wake-ledger.sh index bbcfc7f0cb1..d75bf863ae7 100755 --- a/bin/fm-wake-ledger.sh +++ b/bin/fm-wake-ledger.sh @@ -45,8 +45,12 @@ # immediately before the task metadata is deleted - the last moment # the harness/model/effort join exists - and by this script's # `sweep` for a task that declared failure and was never torn down. -# Fields: task, harness, model, effort, mode, kind, project, -# backend, outcome, outcome_source, route, escalated, findings, pr. +# Fields: task, harness, model, effort, mode, role, deliverable, +# project, backend, outcome, outcome_source, route, escalated, +# findings, pr. role and deliverable are the task identity axes; a +# record written before that split carries the retired single kind +# field instead and is never rewritten, because this ledger is +# append-only evidence. # A task id may carry more than one terminal line: `sweep` records a # declared failure at declaration time and a later teardown records # the same task's release. The LAST terminal line for a task id is @@ -152,7 +156,8 @@ # fm-wake-ledger.sh task [--outcome landed|failed|abandoned] # [--source declared|discarded|unreleased|assumed] # [--harness H] [--model M] [--effort E] [--mode M] -# [--kind K] [--project P] [--backend B] [--pr URL] +# [--role R] [--deliverable D] [--project P] +# [--backend B] [--pr URL] # [--route R] [--escalated yes|no] [--findings N] # Append one terminal task record. Absent facts record as unknown rather # than being guessed, and an absent --source records assumed rather than @@ -196,6 +201,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Reused rather than re-stated so there is one owner of that rule. # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# fm-task-axis-lib.sh owns the identity axes and the deprecated kind= alias's +# derivation, so the sweep reads a pre-split record's role and deliverable +# through the same owner every other reader uses. +# shellcheck source=bin/fm-task-axis-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-task-axis-lib.sh" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" LEDGER="${FM_WAKE_LEDGER:-$DATA/wake-ledger.tsv}" @@ -560,7 +570,8 @@ cmd_outcome() { cmd_task() { local id='' outcome=landed osource=assumed route=unknown escalated=unknown findings=unknown - local harness=unknown model=unknown effort=unknown mode=unknown kind=unknown + local harness=unknown model=unknown effort=unknown mode=unknown + local role=unknown deliverable=unknown local project=unknown backend=unknown pr='' now while [ "$#" -gt 0 ]; do case "$1" in @@ -570,7 +581,8 @@ cmd_task() { --model) [ "$#" -ge 2 ] || die "--model needs a value"; model=$2; shift 2 ;; --effort) [ "$#" -ge 2 ] || die "--effort needs a value"; effort=$2; shift 2 ;; --mode) [ "$#" -ge 2 ] || die "--mode needs a value"; mode=$2; shift 2 ;; - --kind) [ "$#" -ge 2 ] || die "--kind needs a value"; kind=$2; shift 2 ;; + --role) [ "$#" -ge 2 ] || die "--role needs a value"; role=$2; shift 2 ;; + --deliverable) [ "$#" -ge 2 ] || die "--deliverable needs a value"; deliverable=$2; shift 2 ;; --project) [ "$#" -ge 2 ] || die "--project needs a value"; project=$2; shift 2 ;; --backend) [ "$#" -ge 2 ] || die "--backend needs a value"; backend=$2; shift 2 ;; --pr) [ "$#" -ge 2 ] || die "--pr needs a value"; pr=$2; shift 2 ;; @@ -614,7 +626,8 @@ cmd_task() { "model=$(ledger_sanitize "${model:-unknown}" "$LEDGER_KEY_MAX")" \ "effort=$(ledger_sanitize "${effort:-unknown}" "$LEDGER_SHORT_MAX")" \ "mode=$(ledger_sanitize "${mode:-unknown}" "$LEDGER_SHORT_MAX")" \ - "kind=$(ledger_sanitize "${kind:-unknown}" "$LEDGER_SHORT_MAX")" \ + "role=$(ledger_sanitize "${role:-unknown}" "$LEDGER_SHORT_MAX")" \ + "deliverable=$(ledger_sanitize "${deliverable:-unknown}" "$LEDGER_SHORT_MAX")" \ "project=$(ledger_sanitize "${project:-unknown}" "$LEDGER_SHORT_MAX")" \ "backend=$(ledger_sanitize "${backend:-unknown}" "$LEDGER_SHORT_MAX")" \ "outcome=$outcome" \ @@ -726,7 +739,8 @@ cmd_sweep() { --model "$(ledger_meta_value "$meta" model)" \ --effort "$(ledger_meta_value "$meta" effort)" \ --mode "$(ledger_meta_value "$meta" mode)" \ - --kind "$(ledger_meta_value "$meta" kind)" \ + --role "$(fm_task_role "$meta")" \ + --deliverable "$(fm_task_deliverable "$meta")" \ --project "$(ledger_meta_value "$meta" project)" \ --backend "$(ledger_meta_value "$meta" backend)" \ --route "$(ledger_meta_value "$meta" route)" || continue diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 22d165ccbf6..c2118962b6f 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -433,8 +433,8 @@ EOF printf '%s' "$out" | jq -e --arg home "$home" ' (.tasks | length) == 0 and .scout_reports == [ - {id:"reported-scout",path:($home + "/data/reported-scout/report.md"),kind:"scout"}, - {id:"untracked-scout",path:($home + "/data/untracked-scout/report.md"),kind:"scout"} + {id:"reported-scout",path:($home + "/data/reported-scout/report.md"),deliverable:"scout"}, + {id:"untracked-scout",path:($home + "/data/untracked-scout/report.md"),deliverable:"scout"} ] ' >/dev/null || fail "durable scout reports should remain visible after meta teardown" pass "snapshot includes durable scout reports after teardown" diff --git a/tests/fm-task-axis.test.sh b/tests/fm-task-axis.test.sh new file mode 100755 index 00000000000..fa70be8238a --- /dev/null +++ b/tests/fm-task-axis.test.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# Behavior tests for the three task-identity axes (AGENTS.md section 2; the +# `kind` row of docs/vocabulary-collisions.md). +# +# One `kind=` field used to carry role, deliverable type, and lifecycle stage at +# once. These cases pin the migration contract that replaced it: every writer +# dual-writes the axes beside the deprecated alias, a record predating the split +# derives its axes deterministically, the backfill converges old records without +# rewriting anything it already agrees with, and a record whose alias and axes +# disagree is REFUSED everywhere rather than silently resolved. +# +# The refusal cases are the point of the suite. A stale writer that flips the old +# field alone is exactly the failure a dual-write window invites, and a fleet that +# quietly picked one side would decide a task's identity - which teardown +# protection applies to its work - by luck. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +REFLAG="$ROOT/bin/fm-reflag.sh" +PROMOTE="$ROOT/bin/fm-promote.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +TMP_ROOT=$(fm_test_tmproot fm-task-axis) + +# Read the axes through the library the fleet actually uses, in a subshell so a +# sourced library never leaks into the next case. Prints " ". +axes_of() { # + ( + # shellcheck source=bin/fm-backend.sh disable=SC1091 + . "$ROOT/bin/fm-backend.sh" + # shellcheck source=bin/fm-task-axis-lib.sh disable=SC1091 + . "$ROOT/bin/fm-task-axis-lib.sh" + printf '%s %s %s\n' "$(fm_task_role "$1")" "$(fm_task_deliverable "$1")" "$(fm_task_stage "$1")" + ) +} + +# Run one axis-library function against a meta and report its exit status. +axis_call() { # ... + ( + # shellcheck source=bin/fm-backend.sh disable=SC1091 + . "$ROOT/bin/fm-backend.sh" + # shellcheck source=bin/fm-task-axis-lib.sh disable=SC1091 + . "$ROOT/bin/fm-task-axis-lib.sh" + "$@" + ) +} + +# Every value the retired field ever took derives to exactly one point on each +# axis. This table IS the migration contract, so it is asserted directly rather +# than inferred from a consumer's behavior. +test_derivation_is_total_and_deterministic() { + local dir meta got + dir="$TMP_ROOT/derive" + mkdir -p "$dir" + + set -- "scout:crew scout commissioned" \ + "ship:crew ship commissioned" \ + "secondmate:secondmate ship commissioned" + for entry in "$@"; do + meta="$dir/${entry%%:*}.meta" + fm_write_meta "$meta" "window=w" "kind=${entry%%:*}" "worktree=/tmp/wt" + got=$(axes_of "$meta") + [ "$got" = "${entry#*:}" ] \ + || fail "kind=${entry%%:*} derived '$got', expected '${entry#*:}'" + done + + # A record with no alias at all is the spawn default rather than an error: the + # consumers this replaced all defaulted an absent kind= the same way. + fm_write_meta "$dir/bare.meta" "window=w" "worktree=/tmp/wt" + got=$(axes_of "$dir/bare.meta") + [ "$got" = "crew ship commissioned" ] || fail "a record with no alias derived '$got'" + + # An explicit axis always wins over the alias's derivation - that is what makes + # a reflagged task readable at all, since its alias cannot express the stage. + fm_write_meta "$dir/explicit.meta" "kind=ship" "role=crew" "deliverable=ship" "stage=reflagged" + got=$(axes_of "$dir/explicit.meta") + [ "$got" = "crew ship reflagged" ] || fail "explicit axes did not win: '$got'" + + pass "task axes: derivation from the deprecated alias is total and deterministic" +} + +# Backfill must converge an old record and then leave it alone. A sweep that +# rewrote on every pass would churn durable state on every session start. +test_backfill_is_deterministic_and_idempotent() { + local dir meta first second third + dir="$TMP_ROOT/backfill" + mkdir -p "$dir" + meta="$dir/old.meta" + fm_write_meta "$meta" "window=w" "kind=scout" "worktree=/tmp/wt" + + axis_call fm_task_axes_backfill "$meta" || fail "backfill refused a plain pre-split record" + first=$(cat "$meta") + assert_grep 'role=crew' "$meta" "backfill did not derive the role axis" + assert_grep 'deliverable=scout' "$meta" "backfill did not derive the deliverable axis" + assert_grep 'stage=commissioned' "$meta" "backfill did not record the spawn stage" + assert_grep 'kind=scout' "$meta" "backfill rewrote the deprecated alias instead of leaving it" + assert_grep 'worktree=/tmp/wt' "$meta" "backfill disturbed an unrelated field" + + axis_call fm_task_axes_backfill "$meta" || fail "a second backfill refused a converged record" + second=$(cat "$meta") + [ "$first" = "$second" ] || fail "backfill was not idempotent:"$'\n'"--- first ---"$'\n$first'$'\n'"--- second ---"$'\n'"$second" + + # Deterministic across records, not merely stable within one: the same input + # must produce the same axes in a different home. + fm_write_meta "$dir/twin.meta" "window=w" "kind=scout" "worktree=/tmp/wt" + axis_call fm_task_axes_backfill "$dir/twin.meta" || fail "backfill refused the twin record" + third=$(cat "$dir/twin.meta") + [ "$first" = "$third" ] || fail "backfill was not deterministic across identical records" + + # A record that already states a partial set keeps what it states and gains + # only what is missing. + fm_write_meta "$dir/partial.meta" "kind=ship" "stage=reflagged" + axis_call fm_task_axes_backfill "$dir/partial.meta" || fail "backfill refused a partial record" + assert_grep 'stage=reflagged' "$dir/partial.meta" "backfill overwrote a stage the record already stated" + assert_grep 'role=crew' "$dir/partial.meta" "backfill did not fill the missing role axis" + [ "$(grep -c '^stage=' "$dir/partial.meta")" = 1 ] || fail "backfill left a duplicate stage line" + + pass "task axes: backfill is deterministic, forward-only, and idempotent" +} + +# The stale-writer guard. A writer that still flips only the retired field +# desynchronizes a task's identity, and every consumer must refuse that record +# rather than choose a side. +test_conflicted_records_are_refused_not_resolved() { + local dir meta out status + dir="$TMP_ROOT/conflict" + mkdir -p "$dir" + + # Negative control: the identical assertion must pass a consistent record, so a + # refusal below is the conflict and not a broken check. + fm_write_meta "$dir/agree.meta" "kind=ship" "role=crew" "deliverable=ship" "stage=commissioned" + axis_call fm_task_axes_conflict "$dir/agree.meta" \ + && fail "a consistent record was reported as conflicted" + axis_call fm_task_axes_backfill "$dir/agree.meta" \ + || fail "backfill refused a consistent record" + + # The exact stale-writer shape: the old field was flipped to ship, the + # deliverable axis still says scout. + meta="$dir/stale.meta" + fm_write_meta "$meta" "window=w" "kind=ship" "role=crew" "deliverable=scout" "stage=commissioned" "worktree=/tmp/wt" + axis_call fm_task_axes_conflict "$meta" || fail "the stale-writer record was not detected as conflicted" + + out=$(axis_call fm_task_axes_backfill "$meta" 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "backfill converged a conflicted record instead of refusing it" + assert_contains "$out" "deliverable=scout" "the backfill refusal did not name the disagreement" + + # A role-axis disagreement is caught the same way. + fm_write_meta "$dir/role.meta" "kind=ship" "role=secondmate" + axis_call fm_task_axes_conflict "$dir/role.meta" || fail "a role-axis disagreement was not detected" + + pass "task axes: a record whose alias contradicts its axes is refused, not resolved" +} + +# The bootstrap sweep is where an existing home converges. It must report the +# refusal loudly and still leave the unaffected records converged. +test_bootstrap_sweep_converges_and_reports_conflicts() { + local home out + home="$TMP_ROOT/sweep/home" + mkdir -p "$home/state" "$home/data" "$home/config" + fm_write_meta "$home/state/good.meta" "window=w" "kind=scout" "worktree=/tmp/wt" + fm_write_meta "$home/state/bad.meta" "window=w" "kind=ship" "deliverable=scout" + + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$ROOT/bin/fm-bootstrap.sh" 2>&1 || true) + + assert_contains "$out" "TASK_AXIS_BACKFILL:" "the sweep did not report the contradictory record" + assert_contains "$out" "bad" "the sweep did not name the contradictory task" + assert_grep 'deliverable=scout' "$home/state/good.meta" "the sweep did not converge the sound record" + assert_no_grep 'role=' "$home/state/bad.meta" "the sweep wrote axes into the record it refused" + + pass "task axes: the startup sweep converges sound records and refuses contradictory ones" +} + +# Dual-write is what makes the migration safe, so the reflag writer must emit +# both the axes and the alias, and must move the stage axis - the fact the old +# field could never express. +test_reflag_writes_the_axes_and_moves_the_stage() { + local home meta out status + home="$TMP_ROOT/reflag/home" + mkdir -p "$home/state" + meta="$home/state/axis-r1.meta" + fm_write_meta "$meta" "window=fm-axis-r1" "kind=scout" "worktree=/tmp/wt" + + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" axis-r1 --mode direct-PR --yolo off 2>&1) + status=$? + expect_code 0 "$status" "reflagging a scout with both flags should succeed"$'\n'"$out" + + assert_grep 'deliverable=ship' "$meta" "reflag did not move the deliverable axis" + assert_grep 'stage=reflagged' "$meta" "reflag did not move the stage axis" + assert_grep 'role=crew' "$meta" "reflag did not preserve the role axis" + assert_grep 'kind=ship' "$meta" "reflag did not dual-write the deprecated alias" + [ "$(grep -c '^stage=' "$meta")" = 1 ] || fail "reflag left more than one stage line" + [ "$(grep -c '^deliverable=' "$meta")" = 1 ] || fail "reflag left more than one deliverable line" + + # The written record must read back consistently through the library, which is + # the property that keeps every downstream consumer correct. + [ "$(axes_of "$meta")" = "crew ship reflagged" ] \ + || fail "the reflagged record does not read back as a reflagged ship: $(axes_of "$meta")" + + # Reflagging is scout-to-ship only, and after the move the same task is no + # longer a scout - so a second reflag is refused on the deliverable axis. + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" axis-r1 --mode direct-PR --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "reflagging an already-shipped task should be refused" + assert_contains "$out" "not a scout task" "the second reflag refusal did not name the deliverable" + + pass "fm-reflag: the scout-to-ship move writes every axis and advances the stage" +} + +# Reflagging decides a task's delivery contract, so it must refuse a record whose +# identity nobody can read rather than deciding that identity by luck. +test_reflag_refuses_a_contradictory_record() { + local home meta out status before + home="$TMP_ROOT/reflag-conflict/home" + mkdir -p "$home/state" + meta="$home/state/axis-r2.meta" + fm_write_meta "$meta" "window=fm-axis-r2" "kind=scout" "deliverable=ship" "worktree=/tmp/wt" + before=$(cat "$meta") + + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" axis-r2 --mode direct-PR --yolo off 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "reflagging a contradictory record should exit non-zero" + assert_contains "$out" "contradictory identity" "the reflag refusal did not name the contradiction" + [ "$(cat "$meta")" = "$before" ] || fail "the refused reflag still changed the task record" + + pass "fm-reflag: a task whose alias contradicts its axes is refused before any write" +} + +# The old entry point is a bounded shim, not an alias: it must forward, say so, +# and leave the evidence that settles its own retirement. +test_the_retired_entry_point_forwards_and_records_its_use() { + local home meta out status + home="$TMP_ROOT/shim/home" + mkdir -p "$home/state" + meta="$home/state/axis-s1.meta" + fm_write_meta "$meta" "window=fm-axis-s1" "kind=scout" "worktree=/tmp/wt" + + assert_absent "$home/state/.reflag-shim-used" "the shim marker existed before the shim ran" + + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" axis-s1 --mode direct-PR --yolo off 2>&1) + status=$? + expect_code 0 "$status" "the compatibility shim should still complete the move"$'\n'"$out" + assert_contains "$out" "compatibility shim" "the shim did not warn that the old name is retiring" + assert_contains "$out" "fm-reflag.sh" "the shim warning did not name its replacement" + assert_grep 'stage=reflagged' "$meta" "the shim did not forward to the real operation" + assert_present "$home/state/.reflag-shim-used" "the shim left no evidence for its own retirement" + + pass "fm-promote: the retired entry point forwards, warns, and records its own use" +} + +# Teardown protection is the highest-consequence consumer of a task's identity, +# so a contradictory record must stop it rather than let it pick a protection. +test_teardown_refuses_a_contradictory_identity() { + local home meta out status + home="$TMP_ROOT/teardown/home" + mkdir -p "$home/state" "$home/data" "$home/config" + meta="$home/state/axis-t1.meta" + mkdir -p "$TMP_ROOT/teardown/wt" "$TMP_ROOT/teardown/proj" + # A complete endpoint record, so this case reaches the identity check rather + # than stopping at teardown's earlier endpoint validation. + fm_write_meta "$meta" \ + "window=firstmate:fm-axis-t1" \ + "endpoint_task_id=axis-t1" \ + "worktree=$TMP_ROOT/teardown/wt" \ + "project=$TMP_ROOT/teardown/proj" \ + "kind=scout" \ + "deliverable=ship" + + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" axis-t1 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "teardown of a contradictory record should exit non-zero" + assert_contains "$out" "contradictory identity" "the teardown refusal did not name the contradiction" + assert_present "$meta" "the refused teardown still removed the task record" + + pass "fm-teardown: a task whose alias contradicts its axes is refused before any cleanup" +} + +test_derivation_is_total_and_deterministic +test_backfill_is_deterministic_and_idempotent +test_conflicted_records_are_refused_not_resolved +test_bootstrap_sweep_converges_and_reports_conflicts +test_reflag_writes_the_axes_and_moves_the_stage +test_reflag_refuses_a_contradictory_record +test_the_retired_entry_point_forwards_and_records_its_use +test_teardown_refuses_a_contradictory_identity diff --git a/tests/fm-task-delivery.test.sh b/tests/fm-task-delivery.test.sh index 5f0fe5608b6..6220a5eed63 100755 --- a/tests/fm-task-delivery.test.sh +++ b/tests/fm-task-delivery.test.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # Behavior tests for the explicit per-task delivery contract (AGENTS.md section 7) -# across bin/fm-spawn.sh, bin/fm-promote.sh, and bin/fm-project-mode.sh. +# across bin/fm-spawn.sh, bin/fm-reflag.sh, and bin/fm-project-mode.sh. # # A ship task's delivery mode and yolo posture are firstmate's decision at intake, -# so the tools refuse to guess: the spawn and a scout promotion require both flags, +# so the tools refuse to guess: the spawn and a scout reflag require both flags, # validate them against a closed set, and the spawn additionally refuses to launch # when the brief it is about to hand the worker records a different mode. Scout # spawns carry no delivery posture at all. The registry keeps only the captain's @@ -18,7 +18,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" SPAWN="$ROOT/bin/fm-spawn.sh" -PROMOTE="$ROOT/bin/fm-promote.sh" +REFLAG="$ROOT/bin/fm-reflag.sh" PROJECT_MODE="$ROOT/bin/fm-project-mode.sh" TMP_ROOT=$(fm_test_tmproot fm-task-delivery) @@ -198,44 +198,44 @@ EOF pass "fm-spawn: a scout spawn resolves no delivery posture from the registry" } -# Promotion is where a scout's ship contract is finally decided, so it requires the -# same explicit values and writes them into the task's durable record. -test_promote_requires_and_records_the_delivery_contract() { +# Reflagging is where a scout's ship contract is finally decided, so it requires +# the same explicit values and writes them into the task's durable record. +test_reflag_requires_and_records_the_delivery_contract() { local home meta out status - home="$TMP_ROOT/promote/home" + home="$TMP_ROOT/reflag/home" mkdir -p "$home/state" - meta="$home/state/promote-d1.meta" + meta="$home/state/reflag-d1.meta" write_scout_meta() { - printf 'window=fm-promote-d1\nkind=scout\nworktree=/tmp/wt\n' > "$meta" + printf 'window=fm-reflag-d1\nkind=scout\nworktree=/tmp/wt\n' > "$meta" } write_scout_meta - out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" promote-d1 2>&1) + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" reflag-d1 2>&1) status=$? - [ "$status" -ne 0 ] || fail "promotion without --mode should exit non-zero" - assert_contains "$out" "promotion requires --mode" "promote refusal did not name the missing mode" - assert_grep 'kind=scout' "$meta" "refused promotion still changed the task record" + [ "$status" -ne 0 ] || fail "reflagging without --mode should exit non-zero" + assert_contains "$out" "reflagging requires --mode" "the reflag refusal did not name the missing mode" + assert_grep 'kind=scout' "$meta" "a refused reflag still changed the task record" - out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" promote-d1 --mode direct-PR 2>&1) + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" reflag-d1 --mode direct-PR 2>&1) status=$? - [ "$status" -ne 0 ] || fail "promotion without --yolo should exit non-zero" - assert_contains "$out" "promotion requires --yolo" "promote refusal did not name the missing approval posture" + [ "$status" -ne 0 ] || fail "reflagging without --yolo should exit non-zero" + assert_contains "$out" "reflagging requires --yolo" "the reflag refusal did not name the missing approval posture" - out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" promote-d1 --mode no-mistakes-prod-only --yolo off 2>&1) + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" reflag-d1 --mode no-mistakes-prod-only --yolo off 2>&1) status=$? - [ "$status" -ne 0 ] || fail "promotion on a conditional policy should exit non-zero" - assert_contains "$out" "classify this task's surface" "promote did not refuse the conditional policy as a task mode" + [ "$status" -ne 0 ] || fail "reflagging on a conditional policy should exit non-zero" + assert_contains "$out" "classify this task's surface" "reflag did not refuse the conditional policy as a task mode" - out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$PROMOTE" promote-d1 --mode direct-PR --yolo on 2>&1) + out=$(FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$REFLAG" reflag-d1 --mode direct-PR --yolo on 2>&1) status=$? - expect_code 0 "$status" "a promotion carrying both flags should succeed" - assert_grep 'kind=ship' "$meta" "promotion did not restore ship teardown protection" - assert_grep 'mode=direct-PR' "$meta" "promotion did not record the decided delivery mode" - assert_grep 'yolo=on' "$meta" "promotion did not record the decided approval posture" - assert_contains "$out" "ship instructions for mode=direct-PR" "promotion hint did not carry the decided mode" - [ "$(grep -c '^mode=' "$meta")" = 1 ] || fail "promotion left more than one mode= line in the task record" - pass "fm-promote: promotion requires the delivery contract and records it exactly once" + expect_code 0 "$status" "a reflag carrying both flags should succeed" + assert_grep 'kind=ship' "$meta" "reflagging did not restore ship teardown protection" + assert_grep 'mode=direct-PR' "$meta" "reflagging did not record the decided delivery mode" + assert_grep 'yolo=on' "$meta" "reflagging did not record the decided approval posture" + assert_contains "$out" "ship instructions for mode=direct-PR" "the reflag hint did not carry the decided mode" + [ "$(grep -c '^mode=' "$meta")" = 1 ] || fail "reflagging left more than one mode= line in the task record" + pass "fm-reflag: the scout-to-ship move requires the delivery contract and records it exactly once" } # The registry parser survives for the mechanical consumers only. It accepts the @@ -277,6 +277,6 @@ test_scout_and_secondmate_refuse_delivery_flags test_spawn_refuses_a_brief_mode_mismatch test_spawn_notices_a_rigor_downgrade_against_the_registry test_scout_records_no_delivery_posture -test_promote_requires_and_records_the_delivery_contract +test_reflag_requires_and_records_the_delivery_contract test_project_mode_maps_the_conditional_policy echo "# all fm-task-delivery tests passed" From a6e7ed5cc2d16be05a3a9bb8fd09ccbe86ce60aa Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:07:07 -0400 Subject: [PATCH 03/15] docs: move the fleet's instructions onto the new names and axes The rename and the axis split are only real once the instructions that drive them say so, so this carries the fleet's own vocabulary across: the scout outcome section reflags rather than promotes, the metadata field list names the three axes and the deprecated field they replace, and the captain-facing do-not-expose list drops a word the fleet no longer uses internally. Knowledge routing gains one line: a word with a second live meaning goes to the collision registry, never settled locally in whichever file it surfaced in. That is the rule that keeps the registry from going stale the first time someone is in a hurry. Also moves teardown's admission release reminder into the admission library, which already owns that policy. Its test previously reconstructed the function by parsing teardown's source and eval-ing the fragment, so it broke the moment the function read a variable defined outside it - and a test that reads implementation source is exactly what CONTRIBUTING forbids. The reminder now takes its inputs as arguments and the test calls it directly. --- .agents/skills/bootstrap-diagnostics/SKILL.md | 6 +++++- AGENTS.md | 13 +++++++------ bin/fm-admission-lib.sh | 14 ++++++++++++++ bin/fm-project-mode.sh | 2 +- bin/fm-teardown.sh | 13 +------------ docs/architecture.md | 2 +- docs/configuration.md | 2 +- docs/scripts.md | 3 ++- tests/fm-admission.test.sh | 16 +++++++--------- 9 files changed, 39 insertions(+), 32 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 8620c3280b8..f7c0e2974f4 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, MODEL_REGISTRY, MODEL_PRICE, MODEL_VERIFY, ADMISSION_CONTROL, WAKE_LEDGER, FLEET_SYNC, PR_CHECK_MIGRATION, VALIDATION_DAEMON, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, MODEL_REGISTRY, MODEL_PRICE, MODEL_VERIFY, ADMISSION_CONTROL, WAKE_LEDGER, TASK_AXIS_BACKFILL, FLEET_SYNC, PR_CHECK_MIGRATION, VALIDATION_DAEMON, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -53,6 +53,10 @@ When any diagnostic needs captain attention, report the plain consequence and re - `WAKE_LEDGER: task(s) declared failure with no terminal record ...` - only a lock-holding session records those, so a read-only session names them and leaves the recording to the session that holds the lock. Take no action on the count itself; the next locked session records it, and the tasks themselves are ordinary work whose state is read the usual way. Terminal outcome counts stay diagnostic while any of them is unrecorded, so never quote a success rate from the ledger. +- `TASK_AXIS_BACKFILL: task record(s) state an identity the deprecated kind= alias contradicts - ` - a task's role, deliverable, or stage disagrees with the old single-field value still recorded beside it, so that task's identity is unreliable rather than merely stale. + The backfill sweep refuses those records instead of converging them, because either side could be the stale one and choosing silently would pick a task's identity by luck. + Read the named record and settle it from evidence outside the file - what the task was dispatched to produce, and whether it was reflagged - then correct the disagreeing field; `bin/fm-task-axis-lib.sh` owns the axes and the derivation, and `docs/vocabulary-collisions.md` owns the alias's retirement condition. + A record that appears here after a spawn or a reflag is a writer bug to escalate, not old damage: every current writer writes both sides together. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. - `FLEET_SYNC: : recovered: ` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible. diff --git a/AGENTS.md b/AGENTS.md index 9ca95e0d61a..7d6812e7c25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ state/ volatile runtime signals; gitignored .terminal-recorded receipt proving the ledger already holds a terminal record for a task that declared failure and was never torn down, so the recording sweep never repeats it; written only by bin/fm-wake-ledger.sh, removed by teardown .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown - .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=, and (ship and scout) attempt=/attempt_budget= copied from the durable attempt record, where an absent attempt= reads as attempt 1; a ship or scout also records the task's two base references as slot_base=, contribution_target=, and base_state= (bin/fm-task-base-lib.sh); a task dispatch also records the agent-justification fields reasoning_required=, reason_code=, capability_floor=, escalation_policy=, plus tooling_gap_item= for a TOOLING_GAP dispatch (bin/fm-reasoning-lib.sh, section 7), while a secondmate provisioning spawn records none of them and an absent field reads as unknown rather than as justified reasoning; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-pr-merge records merge_verification= plus merge_verified_head= for the head it re-verified, or merge_verification=override for an explicitly unverified merge; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, mode=, yolo=, tasktmp=, and (ship and scout) attempt=/attempt_budget= copied from the durable attempt record, where an absent attempt= reads as attempt 1; the task identity axes role=, deliverable=, and stage=, plus the deprecated kind= they replace, owned with their derivation and retirement by bin/fm-task-axis-lib.sh and docs/vocabulary-collisions.md; a ship or scout also records the task's two base references as slot_base=, contribution_target=, and base_state= (bin/fm-task-base-lib.sh); a task dispatch also records the agent-justification fields reasoning_required=, reason_code=, capability_floor=, escalation_policy=, plus tooling_gap_item= for a TOOLING_GAP dispatch (bin/fm-reasoning-lib.sh, section 7), while a secondmate provisioning spawn records none of them and an absent field reads as unknown rather than as justified reasoning; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); role=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-pr-merge records merge_verification= plus merge_verified_head= for the head it re-verified, or merge_verification=override for an explicitly unverified merge; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .attempt durable attempt count and retry budget for the task id (attempt=, attempt_budget=, failures=, terminal=), owned by bin/fm-attempt.sh; spent only by a ship or scout spawn that follows a RECORDED FAILURE, while a spawn after a dead runtime or husk continues the attempt already open, and retired by an ordinary teardown but kept under --force, so a retry decision is arithmetic rather than a judgment .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" .landing private minimal landing record (pr=, forge pr_head=, project=) written by fm-teardown when a ship task is released before its PR lands; stands in for the removed meta so fm-pr-merge can still land that PR and fm-pr-check can rearm its merge watch @@ -240,6 +240,7 @@ Route durable knowledge to its most specific owner: - Task-scoped notes belong with the backlog item, and investigation findings belong in the scout report. - Knowledge useful to almost every contributor to one project belongs in that project's committed `AGENTS.md`. - Knowledge general to every firstmate user belongs in this repo's shared tracked surface. +- A word that has acquired a second live meaning belongs in `docs/vocabulary-collisions.md`, which owns every ruled disposition and each obsolete name's retirement condition; never settle a collision locally in the file where it surfaced. Firstmate never writes a project's `AGENTS.md` directly. A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail. @@ -366,13 +367,13 @@ After successful teardown, record completion, retain only the configured recent A secondmate is persistent and an empty queue is healthy. Retire one only on an explicit captain or main-firstmate decision, after loading `secondmate-provisioning`; its home must contain no work under way, and forced discard still requires explicit captain authority. -### Scout outcome and promotion +### Scout outcome and reflagging A completed scout must leave a self-contained report before its scratch worktree can be discarded; read and relay its findings, record the report as the Done artifact, and re-evaluate the queue. A report may recommend implementation but does not authorize it. Before treating the investigation or any visual review as complete, load `decision-hold-lifecycle`; teardown enforces that shared completion gate. -When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task. -The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. +When implementation is separately authorized, reflag the existing scout through `bin/fm-reflag.sh` rather than creating a duplicate task. +The reflagged worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test. ## 8. Supervision protocol @@ -434,7 +435,7 @@ Load `stuck-crewmate-recovery` after a stale wake, looping or confused pane, ans **Talk in outcomes, not mechanics.** Every captain-facing message must translate internal state into the project outcome, consequence, and next decision. Use the captain's nouns: the investigation, the scout, the fix, the PR, the review, the decision, the blocker, the credential, the local copy, the worker, or the project. -Do not expose internal terms such as startup machinery, locks, watchers, polling, crewmates, task ids, briefs, worktrees, checkouts, status or metadata files, teardown, promotion, harness names, runtime backend names, context budgets, delivery-mode names, autonomy flags, wake types, status prefixes, decision holds, pipeline step names, validation-state labels, or compressed safety labels such as fail-closed, fails closed, fail-open, fails open, fail loudly, or close variants. +Do not expose internal terms such as startup machinery, locks, watchers, polling, crewmates, task ids, briefs, worktrees, checkouts, status or metadata files, teardown, reflagging, harness names, runtime backend names, context budgets, delivery-mode names, autonomy flags, wake types, status prefixes, decision holds, pipeline step names, validation-state labels, or compressed safety labels such as fail-closed, fails closed, fail-open, fails open, fail loudly, or close variants. Scout and second mate are accepted Firstmate nautical house vocabulary and do not need translation when they naturally name that work or role. When evidence uses an internal label, rewrite it before sending: @@ -519,7 +520,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `MODEL_REGISTRY:`, `MODEL_PRICE:`, `MODEL_VERIFY:`, `ADMISSION_CONTROL:`, `WAKE_LEDGER:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `VALIDATION_DAEMON:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `MODEL_REGISTRY:`, `MODEL_PRICE:`, `MODEL_VERIFY:`, `ADMISSION_CONTROL:`, `WAKE_LEDGER:`, `TASK_AXIS_BACKFILL:`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `VALIDATION_DAEMON:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. diff --git a/bin/fm-admission-lib.sh b/bin/fm-admission-lib.sh index 9399f7b9a3c..74708a06e65 100644 --- a/bin/fm-admission-lib.sh +++ b/bin/fm-admission-lib.sh @@ -354,3 +354,17 @@ fm_admission_digest() { # fi printf 'sha256:%s\n' "${sum:0:16}" } + +# Successful cleanup is admission control's primary release trigger: capacity is +# freed when a worker actually goes away, not when a task reports done. This adds +# one deterministic re-examination step to the caller's existing backlog re-scan +# seam and stays silent for every home that has not configured a policy. +# A secondmate holds no fleet slot to release, which is the ROLE axis alone +# (bin/fm-task-axis-lib.sh); what its work produces is irrelevant here. +fm_admission_release_reminder() { # + local config=${1-} id=${2-} role=${3-} state + [ "$role" != secondmate ] || return 0 + state=$(fm_admission_state "$(fm_admission_config_file "$config")") + [ "$state" = active ] || return 0 + printf '%s\n' "Admission: $id released its worker. Run bin/fm-admission.sh to recompute the fleet band before releasing any load-held request, then admit at most one at a time, re-evaluating between each." +} diff --git a/bin/fm-project-mode.sh b/bin/fm-project-mode.sh index 6a97ce2dfed..5be482bcaad 100755 --- a/bin/fm-project-mode.sh +++ b/bin/fm-project-mode.sh @@ -6,7 +6,7 @@ # MECHANICAL CONSUMERS ONLY. This answers "what posture did the captain register # for this project", never "how does this task ship". A task's delivery mode and # yolo are resolved by firstmate at intake and passed explicitly to -# bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-promote.sh (AGENTS.md section 7). +# bin/fm-brief.sh, bin/fm-spawn.sh, and bin/fm-reflag.sh (AGENTS.md section 7). # The consumers are bin/fm-fleet-sync.sh (skip local-only clones), # bin/fm-home-seed.sh (refuse local-only seeding, run no-mistakes init), and # bin/fm-spawn.sh's advisory registry-deviation notice. diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index cdb9f161954..1e834e3e1d6 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -890,17 +890,6 @@ backlog_refresh_reminder() { fi } -# Successful cleanup is admission control's primary release trigger: capacity is -# freed when a worker actually goes away, not when a task reports done. This adds -# one deterministic re-examination step to the existing backlog re-scan seam and -# stays silent for every home that has not configured an admission policy. -admission_release_reminder() { - local state - [ "$ROLE" = secondmate ] && return 0 - state=$(fm_admission_state "$(fm_admission_config_file "$CONFIG")") - [ "$state" = active ] || return 0 - printf '%s\n' "Admission: $ID released its worker. Run bin/fm-admission.sh to recompute the fleet band before releasing any load-held request, then admit at most one at a time, re-evaluating between each." -} path_is_ancestor_of() { @@ -2505,4 +2494,4 @@ fi echo "teardown $ID complete (window $T, worktree $WT)" report_pending_landing backlog_refresh_reminder -admission_release_reminder +fm_admission_release_reminder "$CONFIG" "$ID" "$ROLE" diff --git a/docs/architecture.md b/docs/architecture.md index 274b27dd1d6..0d2725ee58a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -287,7 +287,7 @@ The `data/secondmates.md` line contract is owned by the [`secondmate-provisionin ## Delivery modes are explicit per task `no-mistakes` tasks run the full validation pipeline, `direct-PR` tasks open PRs without that pipeline, and `local-only` tasks stay local until firstmate performs an approved fast-forward merge. -Each task's mode and `yolo` posture are firstmate's decision at intake and are passed explicitly to `bin/fm-brief.sh`, `bin/fm-spawn.sh`, and `bin/fm-promote.sh`, which refuse a ship task that does not carry them. +Each task's mode and `yolo` posture are firstmate's decision at intake and are passed explicitly to `bin/fm-brief.sh`, `bin/fm-spawn.sh`, and `bin/fm-reflag.sh`, which refuse a ship task that does not carry them. A ship brief records its mode as a fixed machine-readable line and the spawn refuses to launch on a different one, so the worker's instructions and the recorded task delivery cannot diverge. `data/projects.md` records each project's standing posture and optional `+yolo` 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. `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. diff --git a/docs/configuration.md b/docs/configuration.md index 3ea3e039143..c0ebebfff82 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -631,7 +631,7 @@ It uses the same live secondmate discovery and propagation helper as bootstrap, When an allowlisted config item changes for an already-running local home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. A changed remote home instead receives one durably recorded marked re-read instruction after the allowlisted bytes have transferred because primary-local generation paths are not meaningful on another host. The locked bootstrap inheritance pass uses the same placement-specific behavior; see `secondmate-provisioning` for the single contract owner. -That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. +That live discovery starts from `state/*.meta` records with `role=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. Skipped items, such as a destination checkout that does not yet gitignore the item, are visible warnings but not hard failures. ## X mode (.env) diff --git a/docs/scripts.md b/docs/scripts.md index d8a72cf8c86..fbebdb92396 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -97,6 +97,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-wake-ledger.sh` | Own the append-only wake-outcome and terminal-task evidence record, and summarize it | | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | | `fm-classify-lib.sh` | Shared wake-classification vocabulary and durable keyed-decision folds and scans | +| `fm-task-axis-lib.sh` | Single owner of a task's role, deliverable, and stage axes, their derivation, and the stale-writer refusal | | `fm-send.sh` | Send one verified literal line or supported key through the target's recorded backend | | `fm-busy-lib.sh` | Single owner of the semantic busy-state contract: verdicts, source attribution, and per-harness sources | | `fm-busy-event.sh` | The only writer of a task's semantic busy-state record; arms an incarnation and applies lifecycle events | @@ -111,7 +112,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | | `fm-pr-check.sh` | Record validated PR identity in live meta or a landing record, then atomically arm a static PR poll | | `fm-pr-merge.sh` | Forge-verify landing identity, re-verify a PR's current head, then merge a task's canonical full GitHub URL | -| `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | +| `fm-reflag.sh` | Reflag a scout task in place as a protected ship task with an explicit delivery mode | | `fm-attempt.sh` | Own the durable per-task attempt count and its retry budget | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | diff --git a/tests/fm-admission.test.sh b/tests/fm-admission.test.sh index 07c910dc208..bf0035d37bc 100755 --- a/tests/fm-admission.test.sh +++ b/tests/fm-admission.test.sh @@ -577,19 +577,17 @@ test_release_seams_appear_only_for_an_active_policy() { assert_contains "$out" "admit at most one at a time" \ "the digest must state the one-at-a-time release rule" - # Teardown's reminder is the other release trigger. Assert on the emitting - # function directly: driving a full teardown would exercise worktree and - # endpoint machinery this suite does not own. - assert_grep 'admission_release_reminder' "$ROOT/bin/fm-teardown.sh" \ + # Teardown's reminder is the other release trigger. Exercise the emitting + # function itself: driving a full teardown would exercise worktree and + # endpoint machinery this suite does not own. The seam check below still reads + # teardown for the call site, which is the one thing this suite cannot reach + # behaviorally without owning that machinery. + assert_grep 'fm_admission_release_reminder' "$ROOT/bin/fm-teardown.sh" \ "teardown lost its admission release seam" out=$( - CONFIG="$home/config" KIND=ship ID=demo-task - export CONFIG KIND ID # shellcheck source=/dev/null . "$ROOT/bin/fm-admission-lib.sh" - # shellcheck disable=SC2016 - eval "$(awk '/^admission_release_reminder\(\) \{/,/^\}/' "$ROOT/bin/fm-teardown.sh")" - admission_release_reminder + fm_admission_release_reminder "$home/config" demo-task crew ) assert_contains "$out" "recompute the fleet band" \ "cleanup must prompt a fresh band before releasing held work" From 0042c09d5be66a5a8506876b0e543a7d025fc636 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:15:36 -0400 Subject: [PATCH 04/15] fix(bin): move the watcher and nested-home checks onto the role axis Two consumers still read the deprecated field: the watcher classified every supervised window by it, and teardown's nested-home check read it on a parent record. Both only ever ask whether they are looking at a persistent direct report, so both are the role axis and neither needed what the work produces. The watcher's helper is renamed to say what it answers, which is what made the one remaining stale reference visible - a variable read with no assignment left, caught at runtime by the triage suite rather than by lint. --- bin/fm-teardown.sh | 2 +- bin/fm-watch.sh | 26 +++++++++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 1e834e3e1d6..bc276ccb4a9 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -449,7 +449,7 @@ public_followup_resolve_primary_home() { [ "$parent" != "$child" ] || return 1 parent_meta="$parent/state/$id.meta" [ -f "$parent_meta" ] && [ ! -L "$parent_meta" ] || return 1 - [ "$(fm_meta_get "$parent_meta" kind)" = secondmate ] || return 1 + [ "$(fm_task_role "$parent_meta")" = secondmate ] || return 1 meta_home=$(fm_meta_get "$parent_meta" home) meta_home=$(CDPATH='' cd -- "$meta_home" 2>/dev/null && pwd -P) || return 1 [ "$meta_home" = "$child" ] || return 1 diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 78396a2156b..05adba03d2c 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -91,6 +91,8 @@ mkdir -p "$STATE" . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-check-lib.sh . "$SCRIPT_DIR/fm-check-lib.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # Parent-owned secondmate missed-report guards: durable pending-reply # expectations created by fm-send on marked secondmate requests. The tick is # cheap when no records exist and never scrapes secondmate conversation. @@ -236,13 +238,15 @@ window_is_busy() { # [ "${verdict%% *}" = busy ] } -window_kind() { - local w=$1 meta kind +# window_role: WHO the agent behind is - every caller here asks only whether +# supervision is looking at a persistent direct report, never what its work +# produces. That is the role axis alone; a record predating the axis split +# derives it from the retired kind field (bin/fm-task-axis-lib.sh). +window_role() { + local w=$1 meta meta=$(fm_backend_meta_for_window "$w" "$STATE" 2>/dev/null || true) if [ -n "$meta" ]; then - kind=$(grep '^kind=' "$meta" | cut -d= -f2- || true) - [ -n "$kind" ] || kind=ship - echo "$kind" + printf '%s\n' "$(fm_task_role "$meta")" return 0 fi echo unknown @@ -439,7 +443,7 @@ pause_state_class() { # return fi if [ -e "$STATE/.paused-$key" ] && [ "$(age_of "$recheck_file")" -lt "$STALE_ESCALATE_SECS" ]; then - if [ "$(window_kind "$win")" != secondmate ]; then + if [ "$(window_role "$win")" != secondmate ]; then agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown if [ "$agent_alive" != dead ]; then rm -f "$recheck_file" @@ -456,7 +460,7 @@ pause_state_class() { # printf '%s' "$class" return fi - if [ "$(window_kind "$win")" != secondmate ]; then + if [ "$(window_role "$win")" != secondmate ]; then agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown if [ "$agent_alive" != dead ]; then rm -f "$recheck_file" @@ -730,7 +734,7 @@ event_wait_or_sleep() { # state (an idle or blocked secondmate agent pane is healthy by design), so # they are excluded from the fast escalation exactly as the stale loop skips # them. - [ "$(window_kind "$w")" = secondmate ] && continue + [ "$(window_role "$w")" = secondmate ] && continue session=${w%%:*} if [ -z "$first_backend" ]; then first_backend=$b; first_session=$session; fi # One socket connection covers one backend+session; a home normally has a @@ -1024,7 +1028,7 @@ EOF # stale hash is surfaced, absorbed, or timed toward escalation once (.stale-* # remembers the hash already classified). while IFS= read -r w; do - kind=$(window_kind "$w") + role=$(window_role "$w") task=$(window_to_task "$w" "$STATE") key=${w//:/_} key=${key//\//_} @@ -1033,7 +1037,7 @@ EOF if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then clear_pause_tracking "$w" fi - if [ "$kind" = secondmate ] && ! status_is_paused "$last"; then + if [ "$role" = secondmate ] && ! status_is_paused "$last"; then continue fi tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue @@ -1076,7 +1080,7 @@ EOF if [ "$n" -ge 2 ] && [ "$work_now" -ne 0 ]; then # The pane is idle/stale at hash $h. Triage decides whether this wakes # firstmate. Detection itself is unchanged from above. - if [ "$kind" = secondmate ]; then + if [ "$role" = secondmate ]; then case "$(pause_state_class "$w" "$task")" in paused) handle_paused_stale "$w" "$task" "$h" ;; *) clear_pause_tracking "$w" ;; From 24ac56d29d24880159e56a4e7f75c10f4abcf980 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:21:09 -0400 Subject: [PATCH 05/15] test: carry the axis library into the old-bin conformance fixture The old-versus-new teardown conformance case builds a mixed tree: the entry points come from the baseline commit while their siblings are copied from the working tree. Several of those copied siblings now source the axis library, so the fixture needs it present or the baseline entry point dies looking for it. --- tests/fm-backend.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 24ba2dde775..db710be3724 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -141,7 +141,7 @@ resolve_permissive_tmux_kill_ref() { # hence the dispatcher is a copied sibling, while the tmux adapter is extracted # from BASE_REF so conformance tests retain the exact historical behavior even # when this branch changes tmux dispatch semantics. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-landed-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-launch-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-nm-run-lib.sh fm-timeout-lib.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-secondmate-registry-lib.sh fm-x-lib.sh fm-admission-lib.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-landed-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-launch-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-nm-run-lib.sh fm-timeout-lib.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-secondmate-registry-lib.sh fm-x-lib.sh fm-admission-lib.sh fm-task-axis-lib.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" From 895206edb893dd0998801d27d865275e5f44feb0 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:21:40 -0400 Subject: [PATCH 06/15] docs: teach recovery and provisioning the identity axes The recovery and secondmate procedures still told an agent to look for the retired single field, so the instructions that decide which playbook applies would have gone on naming a field the fleet is removing. Each of these asks only who the worker is, so each now says role. --- .agents/skills/secondmate-provisioning/SKILL.md | 6 +++--- .agents/skills/stuck-crewmate-recovery/SKILL.md | 4 ++-- AGENTS.md | 2 +- docs/architecture.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 577eb8d20f3..043bc56840d 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -97,7 +97,7 @@ This is secondmate-only: crewmate/scout model resolution is untouched by this fi This section is the single owner of the secondmate sync and inherited-local-material propagation contract; `AGENTS.md` sections 3 and 4 point here. Before a local launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. -The locked session-start bootstrap sweep runs the same guarded fast-forward for every live local secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). +The locked session-start bootstrap sweep runs the same guarded fast-forward for every live local secondmate home, discovered from `state/.meta` records with `role=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. A remote launch and locked bootstrap sweep ask the configured host to fast-forward its persistent home to that host's code-root commit under the same clean and ancestry guards. `/updatefirstmate` first updates the remote code root from its own origin, then runs that guarded home sync. @@ -181,7 +181,7 @@ Do not hand off `local-only` items. ## Recovery -For local `kind=secondmate` meta with no window, treat the secondmate as a dead persistent direct report and respawn it with: +For a local `role=secondmate` record with no window, treat the secondmate as a dead persistent direct report and respawn it with: ```sh bin/fm-spawn.sh --secondmate @@ -204,7 +204,7 @@ It never initiates a survey or audit during recovery. A secondmate is persistent by default. An empty queue is healthy and does not trigger teardown. -Run `bin/fm-teardown.sh ` for `kind=secondmate` only when the captain or main firstmate explicitly decides to retire that persistent second mate. +Run `bin/fm-teardown.sh ` for a `role=secondmate` record only when the captain or main firstmate explicitly decides to retire that persistent second mate. The safety check is the secondmate's own home. Teardown refuses while its `state/*.meta` contains in-flight work. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index 3baa1dc226d..73eb3f4c78d 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -18,8 +18,8 @@ The target window's harness is recorded as `harness=` in `state/.meta`. ## Session-start reconciliation for a dead ordinary direct report -This procedure covers ordinary `kind=ship` and `kind=scout` direct reports. -Load `secondmate-provisioning` instead for `kind=secondmate` recovery. +This procedure covers ordinary `role=crew` direct reports, whatever they deliver. +Load `secondmate-provisioning` instead for `role=secondmate` recovery. Treat the digest's endpoint result as a presence signal, not proof that the task's work or validation run is gone. Read the targeted current state with `bin/fm-crew-state.sh ` before deciding to relaunch. diff --git a/AGENTS.md b/AGENTS.md index 7d6812e7c25..2bd44665215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ data/ personal fleet records; LOCAL, gitignored as a whole projects.md thin fleet navigation registry recording each project's standing delivery posture; firstmate-private, parsed for mechanical sync and seeding by fm-project-mode.sh (section 6) secondmates.md local and remote secondmate routing table; firstmate-private, maintained by the secondmate seed helpers (section 6) wake-ledger.tsv append-only wake-outcome and terminal-task evidence; bin/fm-wake-ledger.sh owns its format, vocabulary, and append semantics - /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate + /brief.md per-task crewmate brief, or per-secondmate charter brief when role=secondmate /report.md scout task deliverable, written by the crewmate; survives teardown projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception state/ volatile runtime signals; gitignored diff --git a/docs/architecture.md b/docs/architecture.md index 0d2725ee58a..95ba45c0b2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -257,7 +257,7 @@ Seeding is transactional: if validation, cloning, initialization, or registry up `local-only` projects stay with the main first mate because they merge into the main local checkout instead of a remote-backed PR path. The same project may appear in multiple secondmate homes when their scopes differ, such as issue triage versus feature development. Secondmates are idle by default: after startup recovery reconciles only work already in their own home, an empty queue waits silently for routed tasks, and they never self-initiate surveys or audits. -When called with `FM_HOME=` or when `FM_HOME` is already set to the active firstmate home, metadata-routed `fm-send.sh` requests to a live `kind=secondmate` use the live-charter-compatible `from-firstmate` carrier owned by `bin/fm-operational-input.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. +When called with `FM_HOME=` or when `FM_HOME` is already set to the active firstmate home, metadata-routed `fm-send.sh` requests to a live `role=secondmate` use the live-charter-compatible `from-firstmate` carrier owned by `bin/fm-operational-input.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. The parent guards every marked request against a missing correlated report without reading the secondmate conversation; `bin/fm-pending-reply-lib.sh` owns the correlation, recovery, escalation, and retention contract. Metadata-routed crewmate and scout steers carry the same carrier, without a correlation token or pending-reply record, so the generated brief can treat an unmarked message as a human typing into a worker's pane; a message shaped as a slash command or a codex `$` invocation is the one crewmate exclusion, because a harness recognizes that form only at the start of the composer line and any prefix would demote it to prose. Explicit backend-target sends and direct human typing stay unmarked, so captain intervention in a secondmate pane remains conversational. From 2b948d88e7fbd371c99cb20f55dd625024bac81d Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:23:28 -0400 Subject: [PATCH 07/15] docs: state plainly that the stage axis has one value nothing writes yet The ruled value set includes delivered, and the spawn and the reflag write the other two. Nothing writes delivered: the honest place to stamp it is after a confirmed landing, inside the merge path's own private metadata rewrite, whose ordering, device, and single-link invariants that path owns - so the writer is a deliberate follow-up rather than something bolted on beside this split. Say so in both the library and the registry, because a declared value that never appears is otherwise read as evidence that the task did not land. --- bin/fm-task-axis-lib.sh | 7 +++++++ docs/vocabulary-collisions.md | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/bin/fm-task-axis-lib.sh b/bin/fm-task-axis-lib.sh index da7d4f3d80a..7daf8c10abd 100755 --- a/bin/fm-task-axis-lib.sh +++ b/bin/fm-task-axis-lib.sh @@ -19,6 +19,13 @@ # a scout's contract to a ship's, delivered once its work landed. # A field a transition MUTATES is a state, not a type, which is # why the old field could not hold it. +# `delivered` is DECLARED BUT NOT YET WRITTEN by any fleet path. +# Stamping it belongs after a confirmed landing, inside +# bin/fm-pr-merge.sh's private metadata rewrite, whose ordering, +# device, and single-link invariants that path owns - so the +# writer is a deliberate follow-up rather than something bolted +# on beside this split. Read it as "no path has reported this +# task landed", never as "this task did not land". # # The axes are ORTHOGONAL: no consumer may infer one from another. A secondmate # records deliverable=ship because its work lands as project change, not because diff --git a/docs/vocabulary-collisions.md b/docs/vocabulary-collisions.md index 1ef088d2a8a..d7dc46be60d 100644 --- a/docs/vocabulary-collisions.md +++ b/docs/vocabulary-collisions.md @@ -146,6 +146,11 @@ A field describing who a worker is - including a requested agent role, which was That deferral was the whole reason the split had to come first: adding a role field to the old single-field vocabulary would have made a fourth conflated axis out of a field that already carried three. No such field exists in the fleet today; the axis is what makes adding one a one-line change rather than another conflation. +**Remaining work on the stage axis.** +`commissioned` and `reflagged` are written by the spawn and the reflag. +`delivered` is declared but not yet written by any fleet path: stamping it belongs after a confirmed landing, inside the merge path's own private metadata rewrite, and that writer is a deliberate follow-up rather than something added beside this split. +Until it exists, read an absent `delivered` as "no path has reported this task landed", never as "this task did not land". + **Retirement of the deprecated field.** `kind=` is still written to every task's metadata and is still the derivation source for a record that predates the split. Its retirement condition is: **stop writing `kind=` once no reader consults it and one full task cycle has run entirely on the three axes.** From f47e75385f9e825ade784a03c82fa126c1d5723c Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:25:45 -0400 Subject: [PATCH 08/15] refactor(bin): tidy the teardown identity block Reads in the order it acts: the refusal and why it exists, then the axes it protects. Also drops a local the remote path no longer reads and closes the gap the moved admission reminder left. --- bin/fm-teardown.sh | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index bc276ccb4a9..4a3a3fc0bcc 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -289,7 +289,7 @@ remote_outbox_cleanup() { } remote_secondmate_teardown() { - local remote_host remote_root remote_home kind route_host route_root route_home out rc tmp rec phase task_id + local remote_host remote_root remote_home route_host route_root route_home out rc tmp rec phase task_id remote_host=$(fm_meta_get "$META" remote_host) [ -n "$remote_host" ] || return 3 [ "$(fm_task_role "$META")" = secondmate ] || { echo "REFUSED: remote placement metadata is valid only for a secondmate" >&2; return 1; } @@ -418,18 +418,18 @@ ORCA_PATH_MATCH_VERIFIED=0 KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship -# The identity axes this teardown branches on, resolved once. ROLE decides -# whether this is a persistent direct report at all; DELIVERABLE decides which -# protection applies to its work. A record predating the axis split derives both -# from the deprecated alias above (bin/fm-task-axis-lib.sh). -# Which protection this teardown applies is decided by those axes, so a record -# whose alias contradicts them has no readable identity and must stop here: a -# scout's worktree is declared scratch while a ship's is protected, and guessing -# between them is how unlanded work gets discarded. Stop and investigate. +# Which protection this teardown applies is decided by the identity axes, so a +# record whose deprecated alias contradicts them has no readable identity and +# must stop here: a scout worktree is declared scratch while a ship's is +# protected, and guessing between them is how unlanded work gets discarded. +# This is a stop-and-investigate result, not an obstacle to bypass. if fm_task_axes_conflict "$META"; then echo "REFUSED: task $ID records a contradictory identity ($FM_TASK_AXES_CONFLICT); settle it before teardown" >&2 exit 1 fi +# Resolved once. ROLE decides whether this is a persistent direct report at all; +# DELIVERABLE decides which protection applies to its work. A record predating +# the split derives both from the alias above (bin/fm-task-axis-lib.sh). ROLE=$(fm_task_role "$META") DELIVERABLE=$(fm_task_deliverable "$META") MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) @@ -890,8 +890,6 @@ backlog_refresh_reminder() { fi } - - path_is_ancestor_of() { local ancestor=$1 path=$2 [ -n "$ancestor" ] || return 1 From 7e0fd4de626d4aea2dfe013681d7a0e8c028eae3 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:28:25 -0400 Subject: [PATCH 09/15] refactor(bin): drop teardown's now-unread copy of the deprecated field Every teardown branch reads an axis now, and the ledger record carries role and deliverable, so nothing consulted the alias teardown still parsed on the way in. With that gone no consumer anywhere branches on the deprecated field, which changes what its retirement is waiting for: not a reader migration, but a full task cycle on the axes across every home. The registry says so precisely, because a retirement condition nobody can evaluate is how an alias becomes permanent. --- bin/fm-teardown.sh | 2 -- docs/vocabulary-collisions.md | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 4a3a3fc0bcc..7199111385b 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -416,8 +416,6 @@ fi ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 -KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) -[ -n "$KIND" ] || KIND=ship # Which protection this teardown applies is decided by the identity axes, so a # record whose deprecated alias contradicts them has no readable identity and # must stop here: a scout worktree is declared scratch while a ship's is diff --git a/docs/vocabulary-collisions.md b/docs/vocabulary-collisions.md index d7dc46be60d..26514d3efec 100644 --- a/docs/vocabulary-collisions.md +++ b/docs/vocabulary-collisions.md @@ -152,8 +152,9 @@ No such field exists in the fleet today; the axis is what makes adding one a one Until it exists, read an absent `delivered` as "no path has reported this task landed", never as "this task did not land". **Retirement of the deprecated field.** -`kind=` is still written to every task's metadata and is still the derivation source for a record that predates the split. -Its retirement condition is: **stop writing `kind=` once no reader consults it and one full task cycle has run entirely on the three axes.** +No consumer branches on `kind=` any more: every reader was migrated to the axis it actually meant. +What keeps the field alive is that it is still written - so a home that has not yet fast-forwarded can still read a record this one wrote - and that it is still the derivation source for a record predating the split. +Its retirement condition is therefore: **stop writing `kind=`, and drop it from the fleet snapshot's task rows, once one full task cycle has run entirely on the three axes across every home this repository serves.** Until then it is a dual-written deprecated alias with exactly one owner, and a metadata record whose `kind=` disagrees with its explicit axes is refused rather than silently resolved, so a stale writer that flips the old field alone cannot desynchronize a task's identity. **Where it bites:** [`bin/fm-task-axis-lib.sh`](../bin/fm-task-axis-lib.sh); the metadata field list in [`AGENTS.md`](../AGENTS.md) section 2; [`docs/architecture.md`](architecture.md). From cfce85ab24feb759b690a49e456b048309dafd95 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:35:44 -0400 Subject: [PATCH 10/15] feat(bin): render the identity axes in the fleet view The view's type column was the last thing reading the deprecated field, and it could only ever show one of the three facts a row carries. It now renders the role and deliverable together and appends the stage when it is not the spawn default, so a reflagged ship is visible as one at a glance instead of being indistinguishable from a commissioned one - which is the whole reason the lifecycle axis exists. --- bin/fm-fleet-view.sh | 7 +++++-- tests/fm-fleet-snapshot-view.test.sh | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/bin/fm-fleet-view.sh b/bin/fm-fleet-view.sh index 774558871b8..3d21eef2f13 100755 --- a/bin/fm-fleet-view.sh +++ b/bin/fm-fleet-view.sh @@ -68,8 +68,11 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' def action_of($t): if $t.role == "secondmate" then "\($t.actions.send) - \($t.actions.watch)" else $t.actions.watch end; + def identity_of($t): + "\($t.role // "crew")/\($t.deliverable // "ship")" + + (if ($t.stage // "commissioned") == "commissioned" then "" else " (\($t.stage))" end); def task_row($t): - "| \($t.id) | \($t.current_state.state) / \($t.current_state.source) | \($t.kind) | \(dash($t.backlog.repo // $t.project)) | \($t.backend) | \(endpoint_of($t)) | \(artifact($t)) | \(path_of($t)) | \(action_of($t)) |"; + "| \($t.id) | \($t.current_state.state) / \($t.current_state.source) | \(identity_of($t)) | \(dash($t.backlog.repo // $t.project)) | \($t.backend) | \(endpoint_of($t)) | \(artifact($t)) | \(path_of($t)) | \(action_of($t)) |"; def blocker($r): if ($r.blocked_by // "") == "" then "-" elif ($r.blocked_reason // "") == "" then $r.blocked_by @@ -86,7 +89,7 @@ printf '%s\n' "$SNAPSHOT" | jq -r ' (if (.tasks | length) == 0 then "No live task metadata found." else - "| ID | Current | Kind | Repo/Project | Backend | Endpoint | Artifact | Path | Watch / return channel |", + "| ID | Current | Identity | Repo/Project | Backend | Endpoint | Artifact | Path | Watch / return channel |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", (.tasks[] | task_row(.)) end), diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index c2118962b6f..6e187418997 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -551,7 +551,7 @@ EOF and .paths.report.present == true ' >/dev/null || fail "bold task did not join to override-backed backlog and report" view=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" "$VIEW") - assert_contains "$view" "| bold-task | done / status-log | scout | alpha | tmux | present | $data/bold-task/report.md" \ + assert_contains "$view" "| bold-task | done / status-log | crew/scout | alpha | tmux | present | $data/bold-task/report.md" \ "view should render bold in-flight row from snapshot" assert_contains "$view" "| blocked-reason | Blocked Reason | beta | ship | queued-comma - waits on queued-comma | - |" \ "view should render blocked reason without title metadata" @@ -568,7 +568,7 @@ test_view_renders_snapshot() { write_fixture "$home" fakebin=$(make_fakebin "$home") view=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW") - assert_contains "$view" "| ship-task | working / pane | ship | alpha | tmux | present | https://github.com/kunchenguid/firstmate/pull/9" \ + assert_contains "$view" "| ship-task | working / pane | crew/ship | alpha | tmux | present | https://github.com/kunchenguid/firstmate/pull/9" \ "view should render ship row from snapshot" assert_contains "$view" "| queued-task | Queued Task | alpha | ship | ship-task | -" \ "view should render queued backlog row" @@ -576,7 +576,7 @@ test_view_renders_snapshot() { "view should render done backlog row" assert_contains "$view" "bin/fm-send.sh fm-secondmate-task" \ "view should show secondmate send guidance" - assert_contains "$view" "| secondmate-task | working / status-log | secondmate | $home/secondmate-home | tmux | present / alive |" \ + assert_contains "$view" "| secondmate-task | working / status-log | secondmate/ship | $home/secondmate-home | tmux | present / alive |" \ "view should show secondmate endpoint agent liveness" assert_not_contains "$view" "fm-peek.sh fm-secondmate-task" \ "view must not tell firstmate to routinely peek secondmates" @@ -597,9 +597,9 @@ test_view_renders_dead_secondmate_agent_status() { printf 'working: watching delegated scope\n' > "$home/state/dead-secondmate.status" fakebin=$(make_fakebin "$home") view=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$VIEW") - assert_contains "$view" "| dead-secondmate | unknown / none | secondmate | $home/secondmate-home | tmux | present / dead |" \ + assert_contains "$view" "| dead-secondmate | unknown / none | secondmate/ship | $home/secondmate-home | tmux | present / dead |" \ "view should distinguish a present secondmate endpoint from a dead agent" - assert_contains "$view" "| dead-secondmate | unknown / none | secondmate | $home/secondmate-home | tmux | present / dead | - | $home/secondmate-home (absent) |" \ + assert_contains "$view" "| dead-secondmate | unknown / none | secondmate/ship | $home/secondmate-home | tmux | present / dead | - | $home/secondmate-home (absent) |" \ "view should show a recorded missing secondmate home path" pass "fleet view renders secondmate agent liveness" } From b290f5ab88e7068d1a2a21a2ebcd42597b186540 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 20:37:40 -0400 Subject: [PATCH 11/15] test: carry the axis library into the gotmp fake roots Both fake roots symlink the real teardown and each sibling it sources, so the new axis library has to be linked in beside them or teardown dies looking for it before it reaches the behavior these cases assert. --- tests/fm-gotmp.test.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 43a7aed18bd..187f068c452 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -76,6 +76,8 @@ make_fake_root() { ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" # fm-admission-lib.sh: teardown sources it for the admission release reminder. ln -s "$ROOT/bin/fm-admission-lib.sh" "$fake/bin/fm-admission-lib.sh" + # fm-task-axis-lib.sh: teardown reads the task's identity axes through it. + ln -s "$ROOT/bin/fm-task-axis-lib.sh" "$fake/bin/fm-task-axis-lib.sh" # fm-landed-lib.sh: teardown sources it for the shared content-containment test # behind work_is_landed(). This fixture never reaches that predicate (its # worktree path does not exist), but the source line runs unconditionally. @@ -160,6 +162,8 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" # fm-admission-lib.sh: teardown sources it for the admission release reminder. ln -s "$ROOT/bin/fm-admission-lib.sh" "$fake/bin/fm-admission-lib.sh" + # fm-task-axis-lib.sh: teardown reads the task's identity axes through it. + ln -s "$ROOT/bin/fm-task-axis-lib.sh" "$fake/bin/fm-task-axis-lib.sh" # fm-landed-lib.sh: teardown sources it for the shared content-containment test # behind work_is_landed(). This fixture never reaches that predicate (its # worktree path does not exist), but the source line runs unconditionally. From 22f2916849740e9c807a0e6e7c7343e17d491804 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sat, 8 Aug 2026 21:02:07 -0400 Subject: [PATCH 12/15] fix(bin): keep a polled record's PR identity intact when writing an axis A task's metadata doubles as PR identity, and its parser refuses any unrecognized key that appears after the pr= line - the rule that stops a tampered record from smuggling a second identity past an armed merge poll. Backfill appended, so the first startup sweep over a home with a live merge watch invalidated exactly the records that had one, and the watcher stopped honoring those polls. Nothing reported it, because a poll that is refused looks the same as a poll with nothing to say. Axis writes now land before the pr= line, through one helper both the backfill and the reflag use, and the regression pins the position rather than only the parse so a future writer cannot reintroduce it by appending again. Found by the PR-check security suite, which was green on the base and red here. --- bin/fm-reflag.sh | 16 ++++++----- bin/fm-task-axis-lib.sh | 54 ++++++++++++++++++++++++++++---------- tests/fm-task-axis.test.sh | 42 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/bin/fm-reflag.sh b/bin/fm-reflag.sh index 8ad35e88167..649f49b72ed 100755 --- a/bin/fm-reflag.sh +++ b/bin/fm-reflag.sh @@ -114,17 +114,19 @@ fi # was necessary did not change (bin/fm-reasoning-lib.sh). ESCALATION_POLICY=$(fm_escalation_policy_for ship "$MODE" "$YOLO") +# Drop the fields this transition replaces, then write the new ones back through +# the ordering-safe path: a task's record doubles as PR identity, and anything +# unrecognized landing after a `pr=` line would invalidate it +# (bin/fm-task-axis-lib.sh explains why that position is a contract). TMP="$META.tmp" grep -v -e '^kind=' -e '^mode=' -e '^yolo=' -e '^role=' -e '^deliverable=' -e '^stage=' \ -e '^escalation_policy=' "$META" > "$TMP" -{ - echo "kind=ship" - fm_task_axes_emit ship reflagged - echo "mode=$MODE" - echo "yolo=$YOLO" - echo "escalation_policy=$ESCALATION_POLICY" -} >> "$TMP" mv "$TMP" "$META" +readarray -t REFLAG_LINES < <(printf 'kind=ship\n'; fm_task_axes_emit ship reflagged; printf 'mode=%s\nyolo=%s\nescalation_policy=%s\n' "$MODE" "$YOLO" "$ESCALATION_POLICY") +fm_task_axes_write_before_pr "$META" "${REFLAG_LINES[@]}" || { + echo "error: task $ID metadata could not be updated" >&2 + exit 1 +} HOME_Q=$(printf '%q' "$FM_HOME") echo "reflagged $ID to ship mode=$MODE yolo=$YOLO (teardown protection restored)" diff --git a/bin/fm-task-axis-lib.sh b/bin/fm-task-axis-lib.sh index 7daf8c10abd..17b8e146c15 100755 --- a/bin/fm-task-axis-lib.sh +++ b/bin/fm-task-axis-lib.sh @@ -181,6 +181,38 @@ fm_task_axes_conflict() { # return 1 } +# fm_task_axes_write_before_pr: rewrite so land BEFORE its first +# `pr=` line, keeping every other line in order. +# +# The position is a hard contract, not tidiness. A task's metadata doubles as PR +# identity, and fm_pr_metadata_identity_parse (bin/fm-pr-lib.sh) refuses any +# unrecognized key that appears AFTER `pr=` - which is what keeps a tampered +# record from smuggling a second identity past an armed merge poll. Appending an +# axis to the end of a record carrying `pr=` therefore does not merely look +# untidy: it invalidates the record, and the watcher stops honoring that task's +# poll. A record with no `pr=` line simply gets the lines appended. +fm_task_axes_write_before_pr() { # ... + local meta=$1 tmp line seen_pr=0 + shift + tmp="$meta.axis.$$" + { + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + pr=*) + if [ "$seen_pr" -eq 0 ]; then + seen_pr=1 + printf '%s\n' "$@" + fi + ;; + esac + printf '%s\n' "$line" + done < "$meta" + [ "$seen_pr" -eq 1 ] || printf '%s\n' "$@" + } > "$tmp" || { rm -f -- "$tmp"; return 1; } + mv -f -- "$tmp" "$meta" || { rm -f -- "$tmp"; return 1; } + return 0 +} + # fm_task_axes_backfill: derive the axes INTO an existing record, forward-only # and idempotent. Never rewrites an axis the record already states, never # rewrites the alias, and never touches any other field - a record it has @@ -189,7 +221,8 @@ fm_task_axes_conflict() { # # when the record is converged (whether or not this call wrote), 1 on refusal # or write failure. fm_task_axes_backfill() { # - local meta=${1-} kind tmp add=0 + local meta=${1-} kind + local -a add=() [ -f "$meta" ] || return 1 if fm_task_axes_conflict "$meta"; then printf 'fm_task_axes_backfill: refusing conflicted record %s: %s\n' \ @@ -197,20 +230,14 @@ fm_task_axes_backfill() { # return 1 fi kind=$(fm_meta_get "$meta" kind) - tmp="$meta.axis.$$" - cp -- "$meta" "$tmp" 2>/dev/null || return 1 fm_task_role_valid "$(fm_meta_get "$meta" role)" \ - || { printf 'role=%s\n' "$(fm_task_role_of_kind "$kind")" >> "$tmp"; add=1; } + || add+=("role=$(fm_task_role_of_kind "$kind")") fm_task_deliverable_valid "$(fm_meta_get "$meta" deliverable)" \ - || { printf 'deliverable=%s\n' "$(fm_task_deliverable_of_kind "$kind")" >> "$tmp"; add=1; } + || add+=("deliverable=$(fm_task_deliverable_of_kind "$kind")") fm_task_stage_valid "$(fm_meta_get "$meta" stage)" \ - || { printf 'stage=%s\n' "$FM_TASK_STAGE_DEFAULT" >> "$tmp"; add=1; } - if [ "$add" -eq 0 ]; then - rm -f -- "$tmp" - return 0 - fi - mv -f -- "$tmp" "$meta" || { rm -f -- "$tmp"; return 1; } - return 0 + || add+=("stage=$FM_TASK_STAGE_DEFAULT") + [ "${#add[@]}" -gt 0 ] || return 0 + fm_task_axes_write_before_pr "$meta" "${add[@]}" } # fm_task_axes_set: replace one axis in an existing record, forward-only. Used @@ -227,7 +254,6 @@ fm_task_axes_set() { # esac tmp="$meta.axis.$$" grep -v "^$axis=" "$meta" > "$tmp" || true - printf '%s=%s\n' "$axis" "$value" >> "$tmp" mv -f -- "$tmp" "$meta" || { rm -f -- "$tmp"; return 1; } - return 0 + fm_task_axes_write_before_pr "$meta" "$axis=$value" } diff --git a/tests/fm-task-axis.test.sh b/tests/fm-task-axis.test.sh index fa70be8238a..11a06d86077 100755 --- a/tests/fm-task-axis.test.sh +++ b/tests/fm-task-axis.test.sh @@ -46,6 +46,18 @@ axis_call() { # ... ) } +# Does still parse as PR identity? Read through the real owner +# (bin/fm-pr-lib.sh), never a local re-implementation of its rule. +pr_identity_valid() { # + ( + # shellcheck source=bin/fm-backend.sh disable=SC1091 + . "$ROOT/bin/fm-backend.sh" + # shellcheck source=bin/fm-pr-lib.sh disable=SC1091 + . "$ROOT/bin/fm-pr-lib.sh" + fm_pr_metadata_identity_parse "$1" + ) >/dev/null 2>&1 +} + # Every value the retired field ever took derives to exactly one point on each # axis. This table IS the migration contract, so it is asserted directly rather # than inferred from a consumer's behavior. @@ -119,6 +131,35 @@ test_backfill_is_deterministic_and_idempotent() { pass "task axes: backfill is deterministic, forward-only, and idempotent" } +# A task's record doubles as PR identity, and bin/fm-pr-lib.sh refuses any +# unrecognized key appearing AFTER `pr=` - that rule is what stops a tampered +# record from smuggling a second identity past an armed merge poll. Backfill +# therefore may not simply append: a record with a live poll must still parse, or +# the watcher silently stops honoring that task's merge watch. +test_backfill_preserves_pr_identity() { + local dir meta sha + dir="$TMP_ROOT/pr-identity" + mkdir -p "$dir" + meta="$dir/polled.meta" + sha=$(printf '%040d' 1 | tr '0' 'a') + fm_write_meta "$meta" "window=fm-polled" "kind=ship" \ + "pr=https://github.com/o/r/pull/13" "pr_head=$sha" + + # Negative control: the identity must parse BEFORE the backfill, so a failure + # afterwards is the backfill and not an unparseable fixture. + pr_identity_valid "$meta" || fail "the fixture did not parse as PR identity before backfill" + + axis_call fm_task_axes_backfill "$meta" || fail "backfill refused a record carrying a PR" + pr_identity_valid "$meta" || fail "backfill invalidated the record's PR identity" + + assert_grep 'role=crew' "$meta" "backfill did not derive the axes on a polled record" + # Position is the contract, not tidiness: every axis must precede the pr line. + [ "$(grep -n 'stage=' "$meta" | cut -d: -f1)" -lt "$(grep -n '^pr=' "$meta" | cut -d: -f1)" ] \ + || fail "backfill wrote an axis after pr=, which invalidates the record" + + pass "task axes: backfill keeps a polled record's PR identity parseable" +} + # The stale-writer guard. A writer that still flips only the retired field # desynchronizes a task's identity, and every consumer must refuse that record # rather than choose a side. @@ -280,6 +321,7 @@ test_teardown_refuses_a_contradictory_identity() { test_derivation_is_total_and_deterministic test_backfill_is_deterministic_and_idempotent +test_backfill_preserves_pr_identity test_conflicted_records_are_refused_not_resolved test_bootstrap_sweep_converges_and_reports_conflicts test_reflag_writes_the_axes_and_moves_the_stage From afeb7654c1ed6f16f377236960796a9df99de17c Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 9 Aug 2026 08:01:04 -0400 Subject: [PATCH 13/15] no-mistakes(review): make reflag atomic, dual-carry kind on v1 wire surfaces --- bin/fm-bearings-snapshot.sh | 8 +++++--- bin/fm-config-push.sh | 2 +- bin/fm-ff-lib.sh | 2 +- bin/fm-fleet-snapshot.sh | 13 +++++++++++-- bin/fm-reflag.sh | 17 ++++++++++------- bin/fm-send.sh | 2 +- bin/fm-spawn.sh | 8 +++++--- bin/fm-task-axis-lib.sh | 18 ------------------ bin/fm-teardown.sh | 6 +++--- bin/fm-update.sh | 2 +- docs/vocabulary-collisions.md | 7 ++++--- tests/fm-brief.test.sh | 3 ++- tests/fm-fleet-snapshot-view.test.sh | 4 ++-- 13 files changed, 46 insertions(+), 46 deletions(-) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index a1c5f90b3ce..56c9517e19c 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -102,7 +102,9 @@ usage: fm-bearings-snapshot.sh [--json] [--include-prs] [--fields ] Compact bearings projection over fm-fleet-snapshot.sh. TOON by default. Default is LOCAL-ONLY (no network); --include-prs is the only path that fetches. -Default fields: schema, home, generated, prs, in_flight{id,role,deliverable,state,doing}, +Default fields: schema, home, generated, prs, in_flight{id,kind,role,deliverable,state,doing} + (kind is the deprecated task-identity alias, kept beside the axes under the + unchanged v1 tag until the next schema-tag bump; docs/vocabulary-collisions.md), secondmates{id,state,doing,provenance,freshness,age_seconds,contradiction,reason}, decisions_open{id,key,verb,summary,owner}, landed{id,what,artifact,owner}, gates{id,title,blocked_by,reason,owner}, reports{id,path}, recorded_prs{id,url}, @@ -373,14 +375,14 @@ MODEL=$(printf '%s' "$SNAP" | jq \ | select(.role != "secondmate") | select(.backlog.current_role != "program") | select(.backlog.current_role != "held" or .current_state.state == "working") - | {id, role, deliverable, + | {id, kind, role, deliverable, state: .current_state.state, doing: ((.current_state.detail // "") as $d | (if $d != "" then $d else (.hints.last_event_text // "") end) | trunc(90)) } ] + [ $secondmate_views[] | select(.bearings_state == "active_child_work") - | {id,role:"secondmate",deliverable:"ship",state:.bearings_state, + | {id,kind:"secondmate",role:"secondmate",deliverable:"ship",state:.bearings_state, doing:([.active_children[] | .id + ": " + (.doing // .state)] | join("; ") | trunc(90))} ]) as $in_flight_all | ([ .backlog.records[] | select(.structured and .captain_actionable == true) diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index f57eb1dcb89..cb8cc1b658b 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -32,7 +32,7 @@ This is local-material-only: skipped, or error - exits non-zero for real propagation errors or reread-send failures -Live homes come from state/*.meta records with kind=secondmate. +Live homes come from state/*.meta records with role=secondmate. data/secondmates.md is only a fallback for missing home= fields in older or incomplete meta records. diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 138005c2d52..227c24ec70d 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -411,7 +411,7 @@ process_secondmate() { } # Sweep this home's LIVE secondmate direct reports - state/.meta files with -# kind=secondmate - fast-forwarding each to base_mode. Passes base_mode and +# role=secondmate - fast-forwarding each to base_mode. Passes base_mode and # nudge_requires_instr through to process_secondmate. Accumulates into # FF_NUDGE_WINDOWS / FF_SEEN_HOMES, which the caller resets before and reads after. # The registry argument is only for home= fallback on older or incomplete meta records. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 7cd200e7d65..35f3ae1b754 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -32,7 +32,12 @@ # endpoint.exists is the cheap backend endpoint-presence read. # endpoint.agent_alive is populated for secondmates only, where it is useful # return-channel supervision data; other tasks use "not_checked". -# scout_reports[]: present data//report.md pointers. +# scout_reports[]: present data//report.md pointers, each labeled with the +# owning task row's deliverable (plus the deprecated kind alias for the +# migration window; docs/vocabulary-collisions.md owns its retirement). A +# torn-down task has no task row, so its surviving report reports +# deliverable=scout; the backlog record's kind is a different vocabulary +# (captain/program) and is never consulted. # main_inventory: {valid,reason,orphan_in_flight[],unstructured_current_count} - # main-home current-inventory checks shared with secondmate_home_summary_json # (orphan structured in-flight ids with no state/.meta, and unstructured @@ -138,6 +143,9 @@ validate_positive_bound FM_SNAPSHOT_REGISTRY_TIMEOUT "$FM_SNAPSHOT_REGISTRY_TIME # shellcheck source=bin/fm-classify-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-task-axis-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-task-axis-lib.sh" # shellcheck source=bin/fm-ff-lib.sh # shellcheck disable=SC1091 . "$SCRIPT_DIR/fm-ff-lib.sh" # validate_secondmate_home: shared seeded-home boundary checks @@ -1522,6 +1530,7 @@ json_envelope \ | $in.secondmate_landed as $secondmate_landed | def backlog_by_id($id): ($backlog.records[]? | select(.structured == true and .id == $id) | .) // null; def task_by_id($id): ($tasks[]? | select(.id == $id) | .) // null; + def report_kind($id): (task_by_id($id).kind // "scout"); def report_deliverable($id): (task_by_id($id).deliverable // "scout"); { schema:"fm-fleet-snapshot.v1", @@ -1531,7 +1540,7 @@ json_envelope \ backlog:$backlog, tasks:($tasks | map(. + {backlog:backlog_by_id(.id)})), main_inventory:$main_inventory, - scout_reports:($scout_reports | map(. + {deliverable:report_deliverable(.id)})), + scout_reports:($scout_reports | map(. + {kind:report_kind(.id), deliverable:report_deliverable(.id)})), secondmate_current:$secondmate_current, secondmate_landed:$secondmate_landed, secondmate_guidance:{ diff --git a/bin/fm-reflag.sh b/bin/fm-reflag.sh index 649f49b72ed..a64286e9ad2 100755 --- a/bin/fm-reflag.sh +++ b/bin/fm-reflag.sh @@ -114,19 +114,22 @@ fi # was necessary did not change (bin/fm-reasoning-lib.sh). ESCALATION_POLICY=$(fm_escalation_policy_for ship "$MODE" "$YOLO") -# Drop the fields this transition replaces, then write the new ones back through -# the ordering-safe path: a task's record doubles as PR identity, and anything -# unrecognized landing after a `pr=` line would invalidate it -# (bin/fm-task-axis-lib.sh explains why that position is a contract). +# Build the complete new record in one temp file - the replaced fields dropped +# and the new identity inserted through the ordering-safe path: a task's record +# doubles as PR identity, and anything unrecognized landing after a `pr=` line +# would invalidate it (bin/fm-task-axis-lib.sh explains why that position is a +# contract). One mv commits the whole rewrite, so no moment exists in which the +# durable record is missing its identity, and a failed write leaves the +# original record untouched. TMP="$META.tmp" grep -v -e '^kind=' -e '^mode=' -e '^yolo=' -e '^role=' -e '^deliverable=' -e '^stage=' \ -e '^escalation_policy=' "$META" > "$TMP" -mv "$TMP" "$META" readarray -t REFLAG_LINES < <(printf 'kind=ship\n'; fm_task_axes_emit ship reflagged; printf 'mode=%s\nyolo=%s\nescalation_policy=%s\n' "$MODE" "$YOLO" "$ESCALATION_POLICY") -fm_task_axes_write_before_pr "$META" "${REFLAG_LINES[@]}" || { +if ! fm_task_axes_write_before_pr "$TMP" "${REFLAG_LINES[@]}" || ! mv "$TMP" "$META"; then + rm -f -- "$TMP" echo "error: task $ID metadata could not be updated" >&2 exit 1 -} +fi HOME_Q=$(printf '%q' "$FM_HOME") echo "reflagged $ID to ship mode=$MODE yolo=$YOLO (teardown protection restored)" diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 81b8da2961f..32bf521b80d 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -25,7 +25,7 @@ # nothing else tells firstmate's instructions apart from a human typing into that # pane. Every text steer whose target is a task selector resolved through this # home's meta therefore uses the live-charter-compatible from-firstmate carrier -# owned by bin/fm-operational-input.sh. A kind=secondmate target routes its reply +# owned by bin/fm-operational-input.sh. A role=secondmate target routes its reply # via its status file or a status-pointed doc instead of stranding it in chat the # main firstmate never reads; a crewmate or scout target reads the same marker # through its generated brief (bin/fm-brief.sh), which treats an unmarked message diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index defc098e193..c935f7a95ff 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -161,9 +161,11 @@ # secondmate receives the primary's read-only shared captain-preference file # (fm-config-inherit-lib.sh). A successful launch clears pending inherited # config reread generations because the new agent reads the converged files. -# --scout records kind=scout in the task's meta (report deliverable, scratch worktree; -# see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a -# provisioned firstmate home; the default is kind=ship. +# --scout records deliverable=scout in the task's meta (report deliverable, scratch +# worktree; see AGENTS.md task lifecycle); --secondmate records role=secondmate and +# launches in a provisioned firstmate home; the default is a commissioned crew ship +# task. The deprecated kind= alias is dual-written beside the axes for the +# migration window (bin/fm-task-axis-lib.sh). # Before a secondmate launch, the home is locally fast-forwarded to the primary # default-branch commit when safe; skipped syncs warn and launch unchanged. # Ship/scout spawns refuse to launch unless the resolved task path is a real diff --git a/bin/fm-task-axis-lib.sh b/bin/fm-task-axis-lib.sh index 17b8e146c15..fdecb1aa811 100755 --- a/bin/fm-task-axis-lib.sh +++ b/bin/fm-task-axis-lib.sh @@ -239,21 +239,3 @@ fm_task_axes_backfill() { # [ "${#add[@]}" -gt 0 ] || return 0 fm_task_axes_write_before_pr "$meta" "${add[@]}" } - -# fm_task_axes_set: replace one axis in an existing record, forward-only. Used -# by a transition that changes a task's identity (bin/fm-reflag.sh), so the -# axis is rewritten in place rather than appended twice. -fm_task_axes_set() { # - local meta=${1-} axis=${2-} value=${3-} tmp - [ -f "$meta" ] || return 1 - case "$axis" in - role) fm_task_role_valid "$value" || return 1 ;; - deliverable) fm_task_deliverable_valid "$value" || return 1 ;; - stage) fm_task_stage_valid "$value" || return 1 ;; - *) return 1 ;; - esac - tmp="$meta.axis.$$" - grep -v "^$axis=" "$meta" > "$tmp" || true - mv -f -- "$tmp" "$meta" || { rm -f -- "$tmp"; return 1; } - fm_task_axes_write_before_pr "$meta" "$axis=$value" -} diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 7199111385b..82faca63a44 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -29,7 +29,7 @@ # 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. -# Scout tasks (kind=scout in meta) carve out of that check: their worktree is +# Scout tasks (deliverable=scout in meta) carve out of that check: their worktree is # declared scratch and the report at data//report.md is the work # product. Teardown proceeds only once the report exists and the shared # unresolved-decision completion gate verifies its captain-held inventory. @@ -61,7 +61,7 @@ # Projected closes share the presentation-order lock, refuse to close the # captain's active tab, and restore the exact response-derived pre-close tab # if Herdr's last-pane cleanup focuses an unrelated neighboring workspace. -# Secondmates (kind=secondmate in meta) are retired explicitly. Normal +# Secondmates (role=secondmate in meta) are retired explicitly. Normal # teardown refuses while their home has in-flight crewmate meta files; --force # is the approved discard path that prevalidates child removal targets, discards # child work, kills child runtime endpoints, and removes the retired home. Removing a @@ -70,7 +70,7 @@ # leased home and state in place instead of hiding a still-held lease. # Usage: fm-teardown.sh [--force] # --force skips ordinary-task dirty and landed-work checks, skips scout report -# checks, and discards secondmate child work for kind=secondmate. Only use it +# checks, and discards secondmate child work for role=secondmate. Only use it # when the captain has explicitly said to discard the work. # # Transient / stale worktree git lock recovery (teardown-lock-race): a crew process diff --git a/bin/fm-update.sh b/bin/fm-update.sh index 7962e407c9e..9a62956d477 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -64,7 +64,7 @@ fi FF_NUDGE_WINDOWS="" FF_SEEN_HOMES="" -# Live direct reports first: state/.meta with kind=secondmate carries the +# Live direct reports first: state/.meta with role=secondmate carries the # authoritative home= path. sweep_live_secondmate_metas "$STATE" origin no diff --git a/docs/vocabulary-collisions.md b/docs/vocabulary-collisions.md index 26514d3efec..b1fac211efe 100644 --- a/docs/vocabulary-collisions.md +++ b/docs/vocabulary-collisions.md @@ -152,12 +152,13 @@ No such field exists in the fleet today; the axis is what makes adding one a one Until it exists, read an absent `delivered` as "no path has reported this task landed", never as "this task did not land". **Retirement of the deprecated field.** -No consumer branches on `kind=` any more: every reader was migrated to the axis it actually meant. +No consumer branches on `kind=` any more, with one recorded exception the retirement step must migrate: the `active_workers.count` evidence detail in [`bin/fm-admission.sh`](../bin/fm-admission.sh) still groups snapshot tasks by the alias, and dropping the alias without migrating that site silently degrades the detail to a single bucket. What keeps the field alive is that it is still written - so a home that has not yet fast-forwarded can still read a record this one wrote - and that it is still the derivation source for a record predating the split. -Its retirement condition is therefore: **stop writing `kind=`, and drop it from the fleet snapshot's task rows, once one full task cycle has run entirely on the three axes across every home this repository serves.** +The name also stays on the wire during the window: under the unchanged `v1` schema tags, `fm-fleet-snapshot.v1` carries `kind` beside the axes on its task rows and its `scout_reports[]` rows, and `fm-bearings.v1` carries it beside `role`/`deliverable` on its `in_flight` rows, so an external consumer keying on the old name keeps reading; those wire fields retire at the next schema-tag bump of their surface, never silently under `v1`. +Its retirement condition is therefore: **stop writing `kind=`, migrate the `active_workers.count` detail in [`bin/fm-admission.sh`](../bin/fm-admission.sh) to the axes, and drop `kind` from the `fm-fleet-snapshot.v1` and `fm-bearings.v1` surfaces at their next schema-tag bump, once one full task cycle has run entirely on the three axes across every home this repository serves.** Until then it is a dual-written deprecated alias with exactly one owner, and a metadata record whose `kind=` disagrees with its explicit axes is refused rather than silently resolved, so a stale writer that flips the old field alone cannot desynchronize a task's identity. -**Where it bites:** [`bin/fm-task-axis-lib.sh`](../bin/fm-task-axis-lib.sh); the metadata field list in [`AGENTS.md`](../AGENTS.md) section 2; [`docs/architecture.md`](architecture.md). +**Where it bites:** [`bin/fm-task-axis-lib.sh`](../bin/fm-task-axis-lib.sh); the metadata field list in [`AGENTS.md`](../AGENTS.md) section 2; [`docs/architecture.md`](architecture.md); the `active_workers.count` evidence detail in [`bin/fm-admission.sh`](../bin/fm-admission.sh); the `kind` wire fields of [`bin/fm-fleet-snapshot.sh`](../bin/fm-fleet-snapshot.sh) and [`bin/fm-bearings-snapshot.sh`](../bin/fm-bearings-snapshot.sh). ## Maintaining this file diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 3ca060374d8..8ac4c44f355 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -290,8 +290,9 @@ yolo on a ship brief|brief-refused-b1 some-proj --mode direct-PR --yolo on|--yol yolo=value form on a ship brief|brief-refused-b2 some-proj --mode direct-PR --yolo=off|--yolo is not a brief input mode on a scout brief|brief-refused-b3 some-proj --scout --mode direct-PR|--mode applies only to ship briefs mode on a secondmate charter|brief-refused-b4 --secondmate --no-projects --mode no-mistakes|--mode applies only to ship briefs +secondmate with a scout deliverable|brief-refused-b5 --secondmate --scout|--secondmate and --scout select different things ROWS - pass "fm-brief.sh: --yolo and scout/secondmate --mode are refused, never silently dropped" + pass "fm-brief.sh: --yolo, scout/secondmate --mode, and --secondmate --scout are refused, never silently dropped" } test_faster_paths_use_configured_authority_without_stacked_review() { diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 6e187418997..33e3fa034b4 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -433,8 +433,8 @@ EOF printf '%s' "$out" | jq -e --arg home "$home" ' (.tasks | length) == 0 and .scout_reports == [ - {id:"reported-scout",path:($home + "/data/reported-scout/report.md"),deliverable:"scout"}, - {id:"untracked-scout",path:($home + "/data/untracked-scout/report.md"),deliverable:"scout"} + {id:"reported-scout",path:($home + "/data/reported-scout/report.md"),kind:"scout",deliverable:"scout"}, + {id:"untracked-scout",path:($home + "/data/untracked-scout/report.md"),kind:"scout",deliverable:"scout"} ] ' >/dev/null || fail "durable scout reports should remain visible after meta teardown" pass "snapshot includes durable scout reports after teardown" From b76a22e83b4c1a7f3eec42cbcab7133ca0526663 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 9 Aug 2026 08:16:21 -0400 Subject: [PATCH 14/15] no-mistakes(document): migrate leftover scout-promotion wording to reflag --- .agents/skills/project-management/SKILL.md | 2 +- AGENTS.md | 2 +- bin/fm-spawn.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 8feb522bd0c..159fb00034b 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -35,7 +35,7 @@ Do not overwrite or repurpose an existing path. ## Delivery posture -The registry records the project's standing posture, which is the captain's default for the work rather than any task's answer; `AGENTS.md` section 7 owns how each task's concrete mode and yolo are resolved at intake and passed explicitly to the brief, the spawn, and any promotion. +The registry records the project's standing posture, which is the captain's default for the work rather than any task's answer; `AGENTS.md` section 7 owns how each task's concrete mode and yolo are resolved at intake and passed explicitly to the brief, the spawn, and any reflag. Choose that posture when adding or creating the project: - `no-mistakes` runs the full validation pipeline before a PR. diff --git a/AGENTS.md b/AGENTS.md index 2bd44665215..067a256dbe6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,7 +276,7 @@ Never both present a likely-enough solution and launch a parallel design exercis A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code. Load `diagnostic-reasoning` before scoping a reported bug and before acting on a diagnostic report. -Resolve every ship task's concrete delivery mode and yolo posture at intake, and pass both explicitly to the brief, the spawn, and any scout promotion, which all refuse to guess. +Resolve every ship task's concrete delivery mode and yolo posture at intake, and pass both explicitly to the brief, the spawn, and any scout reflag, which all refuse to guess. 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. An unregistered project or absent registry resolves to `no-mistakes` with yolo off, and the registration gap goes to the captain. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c935f7a95ff..4f809613583 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2281,7 +2281,7 @@ fi # per-task decision validated above; a secondmate's posture is fixed; a scout # records none at all, because its deliverable is a report rather than a merge # (fm-teardown.sh defaults an absent mode to no-mistakes, and fm-reflag.sh -# requires an explicit mode when a scout is promoted to a ship task). +# requires an explicit mode when a scout is reflagged as a ship task). if [ "$KIND" = secondmate ]; then MODE=secondmate YOLO=off From afec7d746ba3737320d106b030c8b780df2867b4 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 9 Aug 2026 08:26:20 -0400 Subject: [PATCH 15/15] no-mistakes(document): reflag retired promote verb in scout brief output --- bin/fm-brief.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index ed52ad1c020..f810dccda7b 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -551,7 +551,7 @@ Write your findings to \`$DATA/$ID/report.md\`. The report must stand alone: what you did, what you found, the evidence (commands run, output, file:line references), and what you recommend. Before reporting done, read and follow \`$FM_ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md\` and pass its shared completion gate for the report and any visual review. When the report is complete, append \`done: {one-line conclusion}\` to the status file and stop. -If your findings reveal work that should ship (e.g. you reproduced a bug and the fix is clear), say so in the report; firstmate may promote this task in place, and you would then receive mode-specific ship instructions as a follow-up message. +If your findings reveal work that should ship (e.g. you reproduced a bug and the fix is clear), say so in the report; firstmate may reflag this task in place, and you would then receive mode-specific ship instructions as a follow-up message. EOF echo "scaffolded: $BRIEF (scout; replace {TASK})" exit 0