diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 9ea4112153c..0ae4ee05b52 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -54,7 +54,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, `pi-signed`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`opencode`, `pi`, and `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks. +Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract. Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. `docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. @@ -126,6 +127,8 @@ The supported launch-profile flags below are verified locally; each row records | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | +The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. + ### Model support discovery Treat model and provider knowledge as current source-of-truth discovery, not as a permanent namespace or provider mapping. @@ -343,13 +346,13 @@ This keeps the hook outside the worktree, needs no trust grant, and writes only `fm-teardown` removes the worktree pointer before returning a pooled worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). -**Primary-session guard fact (verified 2026-07-08, Grok 0.2.91).** +**Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** The firstmate PRIMARY's own `.grok/hooks/fm-primary-turnend-guard.json` invokes `bin/fm-turnend-guard-grok.sh`. -Grok Stop hooks are passive for this purpose: exit 2 does not make the model continue. -The adapter therefore runs the shared predicate and, when it returns 2, forces one same-session follow-up with `grok --resume -p ` while setting `GROK_TURNEND_GUARD_ACTIVE=1` so the nested Stop hook does not recurse. -It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions. +Grok 0.2.112 exposes native same-process Stop continuation in its running payload, while the genuine pre-native 0.2.73 payload omits that capability and still needs one guarded `grok --resume`. +The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. +The tracked Claude Stop hooks skip themselves under `GROK_AGENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. -Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. +Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. ## kimi (VERIFIED 2026-07-25, kimi 0.29.1) diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index a5fe06d6ec6..c384553a859 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -12,159 +12,54 @@ metadata: # quota-array-dispatch This skill is the single owner of the pace-aware profile-array selection procedure. -The concise always-loaded intake boundary remains in `AGENTS.md` section 4. -`docs/configuration.md` owns the `config/crew-dispatch.json` schema only. +`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. +`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. `quota-axi` remains data-only and never recommends a route. -Firstmate owns the judgment. Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. -## When to load +## Collect facts -Load this skill whenever a matched dispatch rule or the configured default resolves to a profile array (more than one candidate), before choosing the concrete `--harness`, `--model`, and `--effort` passed to `fm-spawn`. -Keep using `harness-adapters` for harness verification, model/provider discovery, and effort fallback. +Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: -## Intake boundary this skill does not relax +- task/profile fit and required reasoning class +- raw applicable headroom (`effectivePercentRemaining` or tightest applicable percentage) +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) +- whether applicable windows/summary are ahead, or pace is `unknown` +- schema note when pace fields are absent -1. Explicit per-task captain overrides still win over configured profiles. -2. Configured profile matching precedence is unchanged: best-fit rule, then configured default, then static crewmate harness. -3. Malformed `config/crew-dispatch.json` remains an actionable error; never select around it. -4. Every configured candidate in the matched array must be accounted for. -5. If any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. -6. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. -7. Genuine ties must remain free of array-order or harness bias. +Stale raw windows are diagnostic, never headroom. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds`. -## Collect inspectable facts for every candidate +## Pace semantics -For each candidate profile: +`reservePercentPoints = percentRemaining - timeRemainingPercent`. +Negative reserve means usage is ahead of reset pace and creates conservation pressure. +Positive reserve means usage is behind reset pace. +`on_pace` is neutral. +Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. -1. Establish the harness/model/provider relationship from current authoritative discovery owned by `harness-adapters`. - Fail loudly on an unresolved relationship. -2. Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -3. Require a current provider report with known quota semantics and a known applicable effective-availability record for that candidate's provider and model scope. - Stale raw windows remain diagnostic evidence only and are never current headroom. -4. Read every bounding window relevant to that candidate, including windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds` on the effective record. -5. Record these inspectable facts, never a hidden score: - - task/profile fit - - reasoning class required by the captain request or task ambiguity - - raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) - - effective pace status when present - - signed reserve for each applicable window and the effective worst reserve when present - - whether any applicable window or effective summary is ahead of reset - - whether any applicable pace is `unknown` - - schema compatibility note when pace fields are absent +## Selection order -## Pace signals +Apply only among candidates satisfying required fit and strongest reasoning class. +Never use pace or raw headroom to silently replace that reasoning class. -quota-axi `schemaVersion` 3 window pace uses: - -- `reservePercentPoints = percentRemaining - timeRemainingPercent` -- Negative reserve means usage is ahead of reset pace and creates conservation pressure. -- Positive reserve means usage is behind reset pace. -- `on_pace` is neutral. - -Effective-availability pace summaries may report `ahead`, `behind`, `on_pace`, `mixed`, or `unknown`. - -Treat conservation pressure as present when: - -- effective pace status is `ahead`, or -- effective pace status is `mixed` and any `aheadWindowIds` remain, or -- any applicable bounding window itself has pace status `ahead`. - -An effective `mixed` result is never healthy merely because one window is behind. -Any remaining `aheadWindowIds` keep conservation pressure. - -Signed reserve comparison uses the worst applicable reserve, preferring the producer field `worstReservePercentPoints` when present and otherwise the minimum signed reserve across applicable bounding windows. - -## Selection procedure - -Apply these steps only among candidates that already satisfy required task/profile fit and the strongest reasoning class the request genuinely needs. -Never use pace or raw headroom to silently replace that reasoning class with a weaker one. - -1. **Unresolved relationship or quota data** - Stop and report the blocked candidate. -2. **Strongest-reasoning / all-tight** - If every remaining candidate is tight, keep the strongest-reasoning class and either dispatch inside that class or stop and report that the tight choice cannot proceed. - Do not conserve quota through an unapproved downgrade. -3. **Conservation pressure vs sustainable pace** - When fit and reasoning class are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. -4. **Among pressured candidates** - Prefer the least-negative worst applicable reserve. - Example: worst reserve `-4` is safer than `-18` when other inspectable facts are comparable. -5. **Among sustainable candidates** - Use known behind/on-pace evidence plus raw headroom transparently. +1. Unresolved relationship or quota: stop and report the tuple and concrete evidence. +2. All-tight: keep strongest reasoning; dispatch inside it or report if blocked. +3. Comparable fit/reasoning: prefer no ahead pressure over pressure, even with higher raw headroom. +4. Among pressured candidates, prefer the least-negative worst applicable reserve. +5. Sustainable candidates: use known pace plus raw headroom. + Prefer known sustainable evidence over `unknown` when comparable. Do not collapse those facts into an opaque composite score. - Prefer known sustainable evidence over `unknown` pace when otherwise comparable. - Between known sustainable candidates, prefer the clearly better inspectable pair of pace reserve and raw headroom; state both facts in the choice rationale. -6. **Unknown pace** - `unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. - Inspect `unknownWindowIds` and each window's pace `reason` so the rationale preserves the producer's stated uncertainty. - Prefer known sustainable evidence when otherwise comparable. - If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. -7. **Absent pace / older schema** - `schemaVersion` 2 payloads or missing pace fields must degrade explicitly and safely. - Do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. - Compare raw applicable headroom only, using known effective availability rather than stale or isolated window percentages, state that pace is unavailable, and keep every other safety rule above. -8. **Genuine ties** - If every inspectable selection fact is equal, stop and report every tied candidate for captain choice. +6. If unresolved pace changes the choice, report uncertainty. +7. Absent pace or older schema: do not crash, fabricate pace, or treat absence as healthy/`on_pace`. + Compare raw headroom only, state pace is unavailable, and keep safety rules. +8. Genuine ties: stop and report every tied candidate for captain choice. Do not select by array order, harness name, or another arbitrary identity ordering. Report duplicate concrete profiles as a configuration error. -The intake rationale must name the inspectable facts used for every candidate. +Name the inspectable facts used for every candidate. +After selecting, check auth only through that tuple's surface; another harness CLI cannot block it. +A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. Never conclude with an unexplained "best quota" label. - -## Acceptance scenarios - -These scenarios are normative examples of the procedure above. - -### Higher raw quota but materially ahead vs lower raw quota on/behind pace - -Candidate A has higher `effectivePercentRemaining` but conservation pressure from an ahead bounding window. -Candidate B has lower raw headroom, no conservation pressure, and known behind or on-pace evidence. -Choose B when fit and reasoning class are comparable. - -### Mixed effective pace with an ahead bound - -Effective pace status is `mixed` and `aheadWindowIds` is non-empty. -Treat the candidate as conservation-pressured even if another window is behind or on pace. - -### Both candidates ahead with different worst reserves - -Both candidates have conservation pressure. -Choose the least-negative worst applicable reserve when fit and reasoning class are comparable. - -### Known sustainable versus unknown - -Candidate A has known behind or on-pace evidence. -Candidate B has comparable fit, reasoning class, and raw headroom but `unknown` pace. -Prefer A. -If the only way to prefer one side depends on unresolved pace and no known sustainable candidate remains, report the uncertainty. - -### Every candidate tight while strongest-reasoning applies - -All candidates are tight on real headroom. -Keep the strongest reasoning class required by the request. -Do not pick a weaker class only to save quota. -Dispatch inside that class or stop and report that the tight strongest-class choice cannot proceed. - -### Genuine tie without array-order or harness bias - -Two candidates match on fit, reasoning class, conservation pressure, worst reserve, pace class, raw headroom, and unknown flags. -Choosing either array order or a standing harness preference is forbidden. -Stop and report both tied candidates for captain choice. - -### schemaVersion 2 or absent-pace compatibility - -Older quota-axi output or missing pace fields still allow array resolution. -Compare raw headroom only, state that pace is unavailable, and do not invent ahead/behind/on_pace. - -## Sanitized producer shape - -Validate consumers against a sanitized `schemaVersion` 3 shape derived from quota-axi 0.1.15: - -- top level: `schemaVersion`, `generatedAt`, `providers[]` -- each provider: `provider`, `state`, `windows[]`, and optional `quotaSemantics` with `status` and `effectiveAvailability[]` -- each window: `id`, `label`, `kind`, and optional `percentRemaining` and `pace`; pace has `status` plus optional `reason`, `timeRemainingPercent`, and `reservePercentPoints` -- each effective-availability entry: `scope`, `status`, `boundedBy`, optional `effectivePercentRemaining`, optional `limitingWindowIds`, and optional pace summary -- each effective pace summary: `status` plus optional `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, `worstReservePercentPoints`, and `worstReserveWindowId` - -Never persist live provider balances, reset timestamps, account identifiers, or other private account details in tracked fixtures. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index ecc364939ac..f9e68937ab9 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -78,10 +78,13 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before 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 secondmate home, discovered from `state/.meta` records with `kind=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. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. +Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. +A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. +Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. @@ -96,7 +99,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. diff --git a/.claude/settings.json b/.claude/settings.json index e77613c98a4..0be379c46b7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -40,11 +40,11 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" }, { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", "asyncRewake": true, "timeout": 28800 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a866d004295..314d8058d87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -322,7 +322,14 @@ jobs: esac /bin/bash --version | head -1 command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } - /bin/bash -n bin/fm-fleet-snapshot.sh + + shell_inventory="$RUNNER_TEMP/fm-shell-inventory" + bin/fm-lint.sh --list-files > "$shell_inventory" + parse_fail=0 + while IFS= read -r f; do + /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } + done < "$shell_inventory" + [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 8b98340cfa2..433edb80ab4 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -4,7 +4,11 @@ import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; -const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000; +const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS); const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index eb009fd8e3b..f78c1b5acd9 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,11 +1,13 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // -// Compatibility boundary: Pi 0.81.1 and 0.82.0 expose built-in ToolDefinitions, per-slot +// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot // renderers, renderShell: "self", session_start replacement reasons, // ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and -// setHiddenThinkingLabel(). The focused tests pin those assumptions. Version-bounded -// presentation adapters cover collapsed assistant thinking and operational user rows; -// Pi still exposes no global renderer for arbitrary built-in or custom rows. +// setHiddenThinkingLabel(). The focused tests pin those assumptions but never reject a +// newer Pi solely for its version. The collapsed-thinking and operational-user +// presentation adapters probe the exact API they patch and degrade independently with a +// diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi +// still exposes no global renderer for arbitrary built-in or custom rows. // docs/configuration.md owns the home-local Calm preference contract. import { randomUUID } from "node:crypto"; import { @@ -74,9 +76,20 @@ const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); +// Each presentation adapter probes the exact Pi API it patches. If a future Pi removes +// that API, only the affected adapter degrades; the rest of Calm keeps working. +function installCalmPresentationAdapter(name: string, install: () => void): void { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + export default function (pi: ExtensionAPI) { - installCalmAssistantLayout(); - installCalmOperationalUserLayout(); + installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 92c39e4fd2f..9d5124aff2d 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -88,7 +88,13 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); -const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const armReadyTimeoutMs = positiveInteger( + "FM_PI_ARM_READY_TIMEOUT_MS", + process.platform === "win32" ? 35000 : 12000, +); const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index dbb5b2dd5f6..33be71095ed 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,7 +1,12 @@ -import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an +// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic +// instead of blocking Calm or Pi. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; -type AssistantMessage = Parameters[0]; +type AssistantMessage = Parameters[0]; type AssistantMessagePresentationState = { hiddenThinkingLabel: string; @@ -31,6 +36,10 @@ export function installCalmAssistantLayout(): void { } const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; if (typeof originalUpdateContent !== "function") { throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts index 82c69eda01f..ca9b0bbcc0a 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,13 +1,14 @@ -// Pi 0.81.1 and 0.82.0 add the ordinary-user spacer and row together. -// This version-bounded adapter changes only that presentation and never message delivery. -import { - InteractiveMode, - UserMessageComponent, -} from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// together via InteractiveMode.addMessageToChat. This adapter probes that exact method +// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never +// message delivery. +import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; -type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageConstructorArgs = ConstructorParameters; type UserMessageLike = { role: string; content: unknown; @@ -18,7 +19,7 @@ type AddMessageOptions = { type InteractiveModePresentation = { chatContainer: { children: unknown[]; - addChild(component: UserMessageComponent): void; + addChild(component: PiUserMessageComponent): void; }; editor: { addToHistory?(text: string): void; @@ -81,12 +82,20 @@ export function installCalmOperationalUserLayout(): void { hidesOperationalInput, isOperationalInput, }; + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; const originalAddMessageToChat = prototype.addMessageToChat; if (typeof originalAddMessageToChat !== "function") { throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); } + const UserMessageComponent = PiCodingAgent.UserMessageComponent; + if (typeof UserMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi UserMessageComponent"); + } class CalmOperationalUserMessageComponent extends UserMessageComponent { private readonly hasLeadingSpacer: boolean; diff --git a/AGENTS.md b/AGENTS.md index f838dfb27ca..82bcbe8e55a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) -config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes +config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") @@ -88,7 +88,7 @@ state/ volatile runtime signals; gitignored .turn-ended touched by turn-end hooks .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=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; 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-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=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; 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-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) .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90f002029..7c3a1cab183 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even whe Check and test the toolbelt before pushing: ```sh -for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt +while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the canonical shell surface bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) @@ -93,7 +93,7 @@ Its header and `--help` own the flags, family labels, lanes, and changed-file ma Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. -CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and macOS snapshot compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index ef80953fa7f..bbba2661ca9 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -1834,7 +1834,15 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} # Known bare (unbordered) prompt glyphs a composer row may start with: ❯ # (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX +# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match +# individual BYTES rather than whole multibyte characters, so `[❯›]` silently +# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches +# ANY multibyte glyph in that range - including box-drawing corners like ╰, +# misclassifying a bordered composer's bottom border row as the bare shape. +# An alternation's branches are matched as whole literal byte sequences and +# stay correct regardless of locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -1998,7 +2006,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index fe0ed716a42..f8da21bf0de 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -117,10 +117,22 @@ fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" } -# fm_backend_tmux_kill: remove the task's window, best-effort. Mirrors -# fm-teardown.sh's `tmux kill-window -t "$T" 2>/dev/null || true`. +# fm_backend_tmux_kill: remove one explicitly named task window, best-effort. +# Empty, omitted, and malformed targets return nonzero before invoking tmux so +# tmux can never interpret an empty target as the caller's current window. fm_backend_tmux_kill() { # - tmux kill-window -t "$1" 2>/dev/null || true + local target=${1:-} session window + case "$target" in + *:*) + session=${target%%:*} + window=${target#*:} + ;; + *) return 1 ;; + esac + case "$session:$window" in + :*|*:|*:*:*) return 1 ;; + esac + tmux kill-window -t "=$session:=$window" 2>/dev/null || true } # fm_backend_tmux_current_command: 's live foreground process name - diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 2112a53279d..e505b99f757 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -360,6 +360,177 @@ fm_backend_target_of_meta() { # [ -n "$window" ] && printf '%s' "$window" } +# fm_backend_validate_task_endpoint: validate a task cleanup record entirely +# from its durable metadata before any runtime command or cleanup mutation. +# The validation binds the exact task id, selected backend, target, project, +# and worktree. New non-tmux records carry endpoint_task_id because their +# opaque runtime ids do not encode the task label. Legacy tmux records remain +# valid only when their window name itself is exactly fm-. +# On success, sets FM_BACKEND_VALIDATED_BACKEND and +# FM_BACKEND_VALIDATED_TARGET. On failure, prints one refusal and returns 1. +fm_backend_meta_exact_value() { # + local meta=$1 key=$2 count value + count=$(grep -c "^$key=" "$meta" 2>/dev/null || true) + [ "$count" -eq 1 ] || return 1 + value=$(grep "^$key=" "$meta" | cut -d= -f2-) + [ -n "$value" ] || return 1 + printf '%s' "$value" +} + +fm_backend_endpoint_atom_valid() { # + case "$1" in + ''|*[!A-Za-z0-9._@%+-]*) return 1 ;; + esac +} + +fm_backend_validate_task_endpoint() { # + local meta=$1 id=$2 backend_count backend window worktree project binding_count binding + local session pane recorded_session workspace tab terminal worktree_id surface + FM_BACKEND_VALIDATED_BACKEND= + FM_BACKEND_VALIDATED_TARGET= + [ -f "$meta" ] && [ ! -L "$meta" ] || { + echo "REFUSED: task $id has no regular endpoint metadata at $meta; preserving task state." >&2 + return 1 + } + case "$id" in ''|*[!A-Za-z0-9._-]*) + echo "REFUSED: task endpoint identity has an invalid task id; preserving task state." >&2 + return 1 + esac + window=$(fm_backend_meta_exact_value "$meta" window) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous window endpoint; preserving task state." >&2 + return 1 + } + worktree=$(fm_backend_meta_exact_value "$meta" worktree) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous worktree identity; preserving task state." >&2 + return 1 + } + project=$(fm_backend_meta_exact_value "$meta" project) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous project identity; preserving task state." >&2 + return 1 + } + case "$worktree$project$window" in *$'\n'*|*$'\r'*|*$'\t'*) + echo "REFUSED: task $id has malformed endpoint metadata; preserving task state." >&2 + return 1 + esac + backend_count=$(grep -c '^backend=' "$meta" 2>/dev/null || true) + case "$backend_count" in + 0) backend=tmux ;; + 1) backend=$(fm_backend_meta_exact_value "$meta" backend) || backend= ;; + *) backend= ;; + esac + if [ -z "$backend" ] || ! fm_backend_is_known "$backend"; then + echo "REFUSED: task $id has a missing, ambiguous, or unknown backend identity; preserving task state." >&2 + return 1 + fi + binding_count=$(grep -c '^endpoint_task_id=' "$meta" 2>/dev/null || true) + case "$binding_count" in + 0) binding= ;; + 1) + binding=$(fm_backend_meta_exact_value "$meta" endpoint_task_id) || { + echo "REFUSED: task $id has an empty endpoint task binding; preserving task state." >&2 + return 1 + } + ;; + *) + echo "REFUSED: task $id has an ambiguous endpoint task binding; preserving task state." >&2 + return 1 + ;; + esac + if [ -n "$binding" ] && [ "$binding" != "$id" ]; then + echo "REFUSED: endpoint metadata belongs to task $binding, not $id; preserving task state." >&2 + return 1 + fi + + case "$backend" in + tmux) + session=${window%%:*} + pane=${window#*:} + if [ "$pane" = "$window" ] || [ "$pane" != "fm-$id" ] \ + || [ -z "$session" ]; then + echo "REFUSED: tmux endpoint '$window' is malformed or does not belong to task $id; preserving task state." >&2 + return 1 + fi + ;; + herdr) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Herdr endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" herdr_session) || recorded_session= + workspace=$(fm_backend_meta_exact_value "$meta" herdr_workspace_id) || workspace= + tab=$(fm_backend_meta_exact_value "$meta" herdr_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" herdr_pane_id) || pane= + if [ -z "$recorded_session" ] || [ -z "$workspace" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session" \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "${tab//:/_}" \ + || ! fm_backend_endpoint_atom_valid "${pane//:/_}"; then + echo "REFUSED: Herdr endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + zellij) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Zellij endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" zellij_session) || recorded_session= + tab=$(fm_backend_meta_exact_value "$meta" zellij_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" zellij_pane_id) || pane= + case "$tab:$pane" in *[!0-9:]*) tab= ;; esac + if [ -z "$recorded_session" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session"; then + echo "REFUSED: Zellij endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + orca) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Orca endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + terminal=$(fm_backend_meta_exact_value "$meta" terminal) || terminal= + worktree_id=$(fm_backend_meta_exact_value "$meta" orca_worktree_id) || worktree_id= + [ -n "$terminal" ] || { + echo "REFUSED: missing terminal in $meta; cannot close Orca endpoint; preserving task state." >&2 + return 1 + } + [ -n "$worktree_id" ] || { + echo "REFUSED: missing orca_worktree_id in $meta; cannot remove Orca worktree; preserving task state." >&2 + return 1 + } + if [ "$window" != "fm-$id" ] \ + || ! fm_backend_endpoint_atom_valid "$terminal" \ + || ! fm_backend_endpoint_atom_valid "$worktree_id"; then + echo "REFUSED: Orca endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + window=$terminal + ;; + cmux) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy cmux endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + workspace=$(fm_backend_meta_exact_value "$meta" cmux_workspace_id) || workspace= + surface=$(fm_backend_meta_exact_value "$meta" cmux_surface_id) || surface= + if [ -z "$workspace" ] || [ -z "$surface" ] || [ "$window" != "$workspace:$surface" ] \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "$surface"; then + echo "REFUSED: cmux endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + esac + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_BACKEND=$backend + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_TARGET=$window + return 0 +} + fm_backend_meta_for_window() { # local target=$1 state=$2 meta window terminal for meta in "$state"/*.meta; do @@ -573,6 +744,7 @@ fm_backend_send_text_submit() { # local backend=$1 shift + [ -n "${1:-}" ] || { echo "error: refusing empty backend kill target" >&2; return 1; } fm_backend_source "$backend" || return 1 case "$backend" in tmux) fm_backend_tmux_kill "$@" ;; diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 00ea34ddabe..8125aa2e9e4 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -217,13 +217,13 @@ HERDR_SECTION=$(printf '%s\n' \ 'Never bypass the helper, even for a read-only lifecycle probe or cleanup after failure.' \ 'The captain fleet uses the running `default` session.') else -HERDR_SECTION=$(cat <<'EOF' +IFS= read -r -d '' HERDR_SECTION <<'EOF' || true # Herdr lifecycle declaration - NOT ENABLED **HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later. If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch. Do not add Herdr lifecycle commands to this unguarded brief by hand. EOF -) +HERDR_SECTION=${HERDR_SECTION%$'\n'} fi if [ "$KIND" = scout ]; then @@ -285,19 +285,18 @@ case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' - DOD=$(cat < "$BRIEF" < [path]... override bounded worker count # fm-lint.sh --telemetry ... write a quiet metrics snapshot # fm-lint.sh --required-version print the ShellCheck pin +# fm-lint.sh --list-files print the canonical file set # fm-lint.sh --help print this usage set -u @@ -83,11 +84,12 @@ if [ "${1:-}" = "--required-version" ]; then fi fm_lint_usage() { - sed -n '2,25{s/^# \{0,1\}//;p;}' "$SELF" + sed -n '2,26{s/^# \{0,1\}//;p;}' "$SELF" } JOBS=${FM_LINT_JOBS:-2} TELEMETRY=${FM_LINT_TELEMETRY:-} +LIST_FILES=0 while [ "$#" -gt 0 ]; do case "$1" in --jobs) @@ -108,6 +110,10 @@ while [ "$#" -gt 0 ]; do TELEMETRY=${1#*=} shift ;; + --list-files) + LIST_FILES=1 + shift + ;; --help|-h) fm_lint_usage exit 0 @@ -125,6 +131,22 @@ case "$JOBS" in *) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;; esac +if [ "$#" -gt 0 ]; then + ROOTS=("$@") +else + ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) +fi +ROOT_COUNT=${#ROOTS[@]} + +if [ "$LIST_FILES" -eq 1 ]; then + [ "$#" -eq 0 ] || { + printf 'fm-lint.sh: --list-files does not accept explicit paths.\n' >&2 + exit 2 + } + printf '%s\n' "${ROOTS[@]}" + exit 0 +fi + if ! command -v shellcheck >/dev/null 2>&1; then printf 'fm-lint.sh: ShellCheck not found; install ShellCheck %s for CI parity.\n' \ "$REQUIRED_SHELLCHECK" >&2 @@ -144,15 +166,6 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then exit 1 fi -if [ "$#" -gt 0 ]; then - ROOTS=("$@") -else - # Canonical file set: the one authoritative definition. Callers never repeat - # these globs, and every adapter and test shell remains an independent root. - ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) -fi -ROOT_COUNT=${#ROOTS[@]} - if [ -n "$TELEMETRY" ]; then telemetry_parent=$(dirname "$TELEMETRY") [ -d "$telemetry_parent" ] || { diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index a7e86e3d706..af4ea177d63 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -22,8 +22,45 @@ FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' # Pids printed here are WINDOWS pids, which is the correct identity to persist: # the MSYS pid belongs to a transient tool shell, while the Windows pid is the # harness process that outlives the whole session. + +# Match rule for one already-built ancestry chain, innermost hop first. Input +# lines are "\t"; the chosen pid is printed. +# +# This is the POSIX walk's rule, kept deliberately identical so the two +# platforms cannot disagree about which pid owns a lock. First match wins, +# EXCEPT that a claude-named match keeps extending through a CONTIGUOUS run of +# claude-named ancestors and returns the outermost of that run: Claude Code's +# Stop hook fires as a bg-spare worker several levels below the session that +# actually holds the lock (hook shell -> claude bg-spare -> claude bg-pty-host +# -> claude -> claude), so first-match-wins resolves to the worker and the +# auto-arm then treats its own session as an unrelated live owner and never +# arms. The run is bounded by the first non-match rather than by the top of the +# ancestry, so an unrelated claude further up the real process tree is never +# absorbed into this session's chain. +fm_harness_pick_from_chain() { # + local chain=$1 pid text base best='' extending=0 + while IFS=$'\t' read -r pid text; do + [ -n "$pid" ] || continue + base=${text##*/} + base=${base%% *} + base=${base%.[Ee][Xx][Ee]} + if printf '%s' "$base" | grep -qE "$FM_HARNESS_RE"; then + best=$pid + case "$base" in + *claude*) extending=1; continue ;; + esac + break + fi + [ "$extending" -eq 1 ] && break + done <\t" before the shared match rule. while IFS=$'\t' read -r _ mwin mcmd; do [ -n "$mwin" ] || continue - mbase=${mcmd##*/} - mbase=${mbase%% *} - mbase=${mbase%.[Ee][Xx][Ee]} - if printf '%s' "$mbase" | grep -qE "$FM_HARNESS_RE"; then - printf '%s\n' "$mwin" - return 0 - fi + msys_norm="$msys_norm$mwin"$'\t'"$mcmd"$'\n' done </dev/null) || return 1 + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then - echo "$pid"; return 0 + bc=$(basename "$comm") + hit=0; is_claude=0 + if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$bc" in *claude*) is_claude=1 ;; esac + else + # Bare interpreter (e.g. node): match the harness name in its script path. + case "$comm" in + *node*|*python*) + if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$args" in *claude*) is_claude=1 ;; esac + fi + ;; + esac + fi + if [ "$hit" -eq 1 ]; then + best="$pid" + if [ "$is_claude" -eq 1 ]; then + extending=1 + else + break + fi + elif [ "$extending" -eq 1 ]; then + break fi - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" && { echo "$pid"; return 0; } ;; - esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + [ -n "$pid" ] && [ "$pid" -gt 1 ] || break done + [ -n "$best" ] && { echo "$best"; return 0; } return 1 } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 08a61699a0f..df0398eeab0 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1574,6 +1574,7 @@ META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W { echo "window=$META_WINDOW" + echo "endpoint_task_id=$ID" echo "worktree=$WT" echo "project=$PROJ_ABS" echo "harness=$HARNESS" @@ -1632,6 +1633,16 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} +# Crewmate panes are created by a long-lived tmux/herdr daemon that does not +# inherit firstmate's current environment, so a bare `claude` in the pane falls +# back to the default ~/.claude store even when firstmate itself runs under a +# different CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split). +# Forward firstmate's own resolved store onto the claude launch so the crewmate +# uses the same credential/config firstmate is authenticated with. Only when set; +# an unset value is the single-store default and needs no prefix. +if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then + LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" +fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-subagent-pretool-check.sh b/bin/fm-subagent-pretool-check.sh index 169e3079d21..8edb507218b 100755 --- a/bin/fm-subagent-pretool-check.sh +++ b/bin/fm-subagent-pretool-check.sh @@ -65,6 +65,19 @@ DELEGATION_STEMS='agent subagent task workflow cron schedul worktree delegate sp # reason a runaway task cannot be stopped. OBSERVE_ONLY_TOOLS='taskoutput taskstop taskget tasklist cronlist bashoutput killshell' +# Exact lowercase tool names that match a stem above but create no RUNNABLE +# work. These write only the harness's session-local todo list, which has no +# executor: it spawns no agent, allocates no worktree, registers no schedule, +# and starts nothing that could outlive the session or escape a firstmate +# guard. Denying them stops the primary tracking its own plan while granting no +# delegation power, and the deny text would tell it to run bin/fm-brief.sh for a +# todo entry, so the stem match here is a false positive rather than a policy. +# This is a separate list from OBSERVE_ONLY_TOOLS on purpose: these tools WRITE, +# so folding them into a list documented as observe-or-stop would make that +# contract untrue. Both lists are exact-name, never substring, so neither can +# widen by accident. +PLAN_ONLY_TOOLS='taskcreate taskupdate' + TOOL="" TOOL_SET=0 CLAUDE_MODE=0 @@ -139,7 +152,7 @@ case "$TOOL" in mcp__*) exit 0 ;; esac -for allowed in $OBSERVE_ONLY_TOOLS; do +for allowed in $OBSERVE_ONLY_TOOLS $PLAN_ONLY_TOOLS; do [ "$NORMALIZED" != "$allowed" ] || exit 0 done diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 550a09a39a0..6164ebdd785 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -116,18 +116,20 @@ FORCE=${2:-} # down a worktree (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent FM_LOCK_LOG_PREFIX=teardown -"$FM_ROOT/bin/fm-guard.sh" || true META="$STATE/$ID.meta" [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } -WT=$(grep '^worktree=' "$META" | cut -d= -f2-) -T=$(grep '^window=' "$META" | cut -d= -f2-) -PROJ=$(grep '^project=' "$META" | cut -d= -f2-) -BACKEND=$(fm_backend_of_meta "$META") -if [ "$BACKEND" = orca ]; then - T_ORCA=$(grep '^terminal=' "$META" | tail -1 | cut -d= -f2- || true) - [ -n "$T_ORCA" ] && T=$T_ORCA -fi +# This is the first cleanup authorization check. It is metadata-only and must +# complete before fm-guard, a backend command, file removal, branch deletion, +# worktree return, registry change, or process termination can run. +fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 +BACKEND=$FM_BACKEND_VALIDATED_BACKEND +T=$FM_BACKEND_VALIDATED_TARGET +WT=$(fm_meta_get "$META" worktree) +PROJ=$(fm_meta_get "$META" project) +T_ORCA= +[ "$BACKEND" != orca ] || T_ORCA=$T +"$FM_ROOT/bin/fm-guard.sh" || true HOME_PATH=$(grep '^home=' "$META" | cut -d= -f2- || true) PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) # tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root @@ -944,6 +946,7 @@ validate_firstmate_home_children_removal() { for child_meta in "$sub_state"/*.meta; do [ -e "$child_meta" ] || continue child_id=$(basename "$child_meta" .meta) + 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) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 75a7c58c378..21b70a62a30 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -194,13 +194,14 @@ family_for_basename() { ;; fm-afk-pi-herdr-return-e2e.test.sh|\ fm-codex-continuity-live-e2e.test.sh|fm-grok-continuity-live-e2e.test.sh|\ - fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ + fm-grok-stop-live-e2e.test.sh|fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ fm-send-secondmate-marker-herdr-e2e.test.sh) printf '%s\n' live-harness-optin ;; fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ fm-herdr-session-cleanup.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ - fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh) + fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh|\ + fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch ;; fm-pr-check-security.test.sh|fm-pr-merge.test.sh|fm-review-diff.test.sh|\ diff --git a/bin/fm-turnend-guard-grok.sh b/bin/fm-turnend-guard-grok.sh index 5cc33e9c54e..3dcfd6f2f64 100755 --- a/bin/fm-turnend-guard-grok.sh +++ b/bin/fm-turnend-guard-grok.sh @@ -1,30 +1,69 @@ #!/usr/bin/env bash # Grok Stop-hook adapter for the firstmate PRIMARY turn-end guard. # -# Grok Stop hooks are passive: exit 2 does not block or feed stderr back to the -# model. This adapter still uses the shared primary-scoped predicate in -# fm-turnend-guard.sh. When that predicate says the primary would end blind, the -# adapter forces one same-session follow-up by running `grok --resume ` -# with a guard instruction. GROK_TURNEND_GUARD_ACTIVE is the loop guard: the -# nested turn's own Stop hook exits without spawning another nested turn. +# The exact running Stop payload selects one path. A typed native capability +# field delegates the shared guard's exit status and stderr directly back to +# that Grok process. Field absence preserves the pre-native one-resume fallback. +# Invalid or unreadable input starts neither path. Camel case has typed +# precedence over the legacy snake-case spelling when both are present. set -u PAYLOAD=$(cat 2>/dev/null || true) [ -n "$PAYLOAD" ] || exit 0 -[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +printf '%s' "$PAYLOAD" | jq -n --stream -e ' + reduce inputs as $item ( + {}; + if ( + ($item | length) == 2 + and ($item[0] | length) > 0 + and ( + $item[0][0] == "sessionId" + or $item[0][0] == "stopHookActive" + or $item[0][0] == "stop_hook_active" + ) + ) then + .[$item[0][0]] = ((.[$item[0][0]] // 0) + 1) + else + . + end + ) + | all(.[]; . == 1) +' >/dev/null 2>&1 || exit 0 +CAPABILITY=$(printf '%s' "$PAYLOAD" | jq -ser ' + if length != 1 then error("payload count") + elif ((.[0] | type) != "object") then error("payload") + else .[0] | + if has("stopHookActive") then + if ((.stopHookActive | type) == "boolean") then "native" else error("stopHookActive") end + elif has("stop_hook_active") then + if ((.stop_hook_active | type) == "boolean") then "native" else error("stop_hook_active") end + else "legacy" + end + end +' 2>/dev/null) || exit 0 ROOT=${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-}} [ -n "$ROOT" ] || exit 0 ROOT=${ROOT%/} [ -x "$ROOT/bin/fm-turnend-guard.sh" ] || exit 0 -if ! command -v jq >/dev/null 2>&1; then - exit 0 +if [ "$CAPABILITY" = native ]; then + printf '%s' "$PAYLOAD" | "$ROOT/bin/fm-turnend-guard.sh" + RC=$? + case "$RC" in + 0|2) exit "$RC" ;; + *) exit 0 ;; + esac fi -SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -r '.sessionId // empty' 2>/dev/null) || exit 0 -[ -n "$SESSION_ID" ] || exit 0 +# Only a genuine pre-native payload reaches this bounded compatibility path. +[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 +SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -er ' + .sessionId | select(type == "string" and length > 0) +' 2>/dev/null) || exit 0 +command -v grok >/dev/null 2>&1 || exit 0 ERR=$(mktemp "${TMPDIR:-/tmp}/fm-turnend-grok.XXXXXX") || exit 0 trap 'rm -f "$ERR"' EXIT diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 515a859cd2b..2e96fb33e48 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -11,8 +11,10 @@ # This script is push-based: verified harness turn-end hooks invoke it every time # the primary is about to end a turn. # Claude and codex can block directly by preserving exit status 2 and stderr. -# OpenCode, pi, and grok adapters use the same predicate and force one bounded -# follow-up because their turn-end events are passive. +# OpenCode and pi adapters use the same predicate and force one bounded +# follow-up because their turn-end events are passive. Grok delegates native +# blocking when its running Stop payload advertises that capability, with one +# bounded resume fallback for payloads from pre-native processes. # See docs/turnend-guard.md for the per-harness mechanics, validation evidence, # and fail-open tradeoffs. # @@ -26,10 +28,10 @@ # primary checkout - the main home or a genuinely marked secondmate home - and # stay a silent, fast no-op inside child task worktrees. # -# Loop-guard, codex (default) mode: never block twice in the same turn. Codex -# Stop payloads carry stop_hook_active=true when the CURRENT stop attempt was -# itself already forced by an earlier block this turn; on that signal we always -# allow the stop, whether or not watcher supervision actually got resumed. +# Loop-guard, codex/Grok (default) mode: never block twice in the same turn. +# Codex uses stop_hook_active and Grok uses stopHookActive; typed camel-case +# takes precedence when both spellings are present. A true value means the +# current stop attempt already follows a block, so this guard always allows it. # Passive harness adapters provide their own one-follow-up guard before calling # this script. # That bounds those harnesses to at most one forced continuation per turn - @@ -94,7 +96,15 @@ PAYLOAD=$(cat 2>/dev/null || true) # loop-guard field, so we must never block - fail open, not noisy. command -v jq >/dev/null 2>&1 || exit 0 -STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r '.stop_hook_active // false' 2>/dev/null) || exit 0 +STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r ' + if type != "object" then error("payload") + elif has("stopHookActive") then + if ((.stopHookActive | type) == "boolean") then .stopHookActive else error("stopHookActive") end + elif has("stop_hook_active") then + if ((.stop_hook_active | type) == "boolean") then .stop_hook_active else error("stop_hook_active") end + else false + end +' 2>/dev/null) || exit 0 if [ "$CLAUDE_MODE" -eq 0 ] && [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 3ed33a6f8bc..742c17e999a 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -14,6 +14,10 @@ STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}" +# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s +# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on +# the platform (Git Bash/MSYS) that already pays the highest fork price. +_FM_UNAME=$(uname 2>/dev/null || echo unknown) mkdir -p "$STATE" fm_current_pid() { @@ -29,17 +33,19 @@ fm_pid_alive() { } fm_pid_identity() { - local pid=$1 out proc_root stat_line starttime cmdline_hex + local pid=$1 out proc_root stat_line starttime cmdline_hex identity_key local -a stat_fields case "$pid" in ''|*[!0-9]*) return 1 ;; esac proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} - # Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is + # Prefer a Linux-compatible /proc when present: stat field 22 (starttime, clock ticks since boot) is # immune to the wall-clock steps that re-render the ps lstart fallback's date # (observed as WSL2 btime drift) and would evict a live watcher; combining the # full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision. - if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then + # Git Bash/MSYS exposes these compatible files but its Cygwin ps rejects the + # portable fallback's -o fields, so capability detection must not key on uname. + if [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 # After the final comm delimiter, array index 19 is proc stat field 22. read -r -a stat_fields <<< "${stat_line##*)}" @@ -50,7 +56,9 @@ fm_pid_identity() { esac cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1 [ -n "$cmdline_hex" ] || return 1 - printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex" + identity_key=proc-starttime + [ "$_FM_UNAME" != Linux ] || identity_key=linux-starttime + printf '%s=%s cmdline-hex=%s\n' "$identity_key" "$starttime" "$cmdline_hex" return 0 fi # Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is @@ -62,7 +70,7 @@ fm_pid_identity() { } fm_path_mtime() { - if [ "$(uname)" = Darwin ]; then + if [ "$_FM_UNAME" = Darwin ]; then stat -f %m "$1" 2>/dev/null else stat -c %Y "$1" 2>/dev/null diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 0a783ce2874..3c2df49c891 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -68,7 +68,13 @@ BEAT="$STATE/.last-watcher-beat" # "Fresh" reuses the guard's threshold so there is one definition of liveness. GRACE=${FM_GUARD_GRACE:-300} # How long to wait for a freshly forked watcher to acquire the lock and beat. -CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-10} +# Git Bash/MSYS pays a much higher fork cost while the watcher completes its +# required pre-lock migration, so its bounded default covers that cold start. +case "${OSTYPE:-}" in + msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;; + *) ARM_CONFIRM_DEFAULT=10 ;; +esac +CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-$ARM_CONFIRM_DEFAULT} # Poll interval while attached to an existing healthy watcher. ATTACH_POLL=${FM_ARM_ATTACH_POLL:-0.5} CYCLE_LOG="$STATE/.watch-cycle-exits.log" diff --git a/docs/architecture.md b/docs/architecture.md index da1519a0d44..d1bcb83c565 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,7 +89,7 @@ On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable cat The runtime backend is the session-provider layer below firstmate's scripts. It owns task endpoint creation, bounded capture, text/key sends, current-path reads for spawn-time worktree discovery when the backend does not create the worktree itself, live-window fallback lookup, agent-process liveness probes where verified, and endpoint teardown. -`bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), and `bin/backends/herdr.sh` (P2), `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) are experimental task-spawn adapters. +`bin/fm-backend.sh` centralizes backend selection, `state/.meta` helpers, metadata-only cleanup identity validation, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), and `bin/backends/herdr.sh` (P2), `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) are experimental task-spawn adapters. New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1`, which wins over cmux's primary `CMUX_WORKSPACE_ID` marker and documented fallback signals; auto-detected herdr or cmux prints a one-time opt-out notice, auto-detected tmux stays silent, and zellij and orca are never auto-detected (only explicit selection). Unknown backend names fail loudly. diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index c4a051b9cc6..b94b6a6aef9 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -9,9 +9,17 @@ A qualifying implementation must auto-load from the trusted project, persist the The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and Pi's native working activity. Changing persisted context to remove hidden content, filtering provider context, patching installed harness code, or claiming coverage outside a supported renderer does not satisfy that boundary. +## Compatibility evidence + +[`calm.md`](calm.md#pi-compatibility) owns the current Pi compatibility contract. +Pi 0.81.1 was installed when Calm was first built, and Pi 0.82.0 was the later reverification target. +The inspected Pi CHANGELOG shows no relevant presentation API introduced at either version, so those versions remain verification evidence rather than compatibility bounds. +The exported classes used by the adapters (`AssistantMessageComponent` and `InteractiveMode`) are undocumented internals with no stated version guarantee. +`tests/fm-calm-pi-extension.test.sh` records the installed Pi version as evidence without gating on it and covers both newer synthetic versions and an unavailable adapter seam. + ## Pi 0.81.1 end-to-end reproduction -The current installed and regression-supported Pi version was verified on 2026-07-22. +The Pi version installed at the time was verified on 2026-07-22. ```text $ pi --version @@ -57,7 +65,8 @@ The single-thinking, tool-call-only, tool-result, Calm-off, and `clearOnShrink` PR 927 made Calm persistent and described controlled rows as gapless while retaining a documented unsupported boundary for collapsed-thinking spacing. PR 936 removed the unsafe operational-input reroute and preserved legacy zero-height entries but did not change assistant-message layout. -The fix installs one idempotent Pi 0.81.1 through 0.82.0 presentation adapter on the exported `AssistantMessageComponent.updateContent` method. +The fix installs one idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `AssistantMessageComponent.updateContent` method. +The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. Only while Calm is active and Pi has collapsed thinking does the adapter pass a shallow thinking-free presentation copy into Pi's ordinary layout calculation, then retain the original message on the component for invalidation and thinking expansion. The persisted assistant message, provider context, tool execution, export data, and expansion history remain unchanged. Collapsed thinking-only assistant messages now render zero rows, thinking before visible assistant text adds no spacing beyond the text-only baseline, and expanding thinking still renders the original reasoning. @@ -114,7 +123,8 @@ The real Pi viewport moved the unchanged assistant text from row 7 to row 2, ren The leading cause would have been falsified if the row or height remained, the provider lost or duplicated the message, or the persisted role or bytes changed. None occurred. -The fix installs a separate idempotent Pi 0.81.1 through 0.82.0 presentation adapter on the exported `InteractiveMode.addMessageToChat` method. +The fix installs a separate idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `InteractiveMode.addMessageToChat` method. +The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. It delegates current recognition to `bin/fm-operational-input.sh`, adds only the evidence-backed bare-U+2063 `Supervisor escalate (` presentation compatibility shape, mounts a `UserMessageComponent` subclass that preserves Pi's stock row plus leading spacer while Calm is off, and returns zero rendered lines while Calm is on. It never intercepts the input event, rewrites the message, changes its role, filters model context, or changes session data. Messages containing an image are left on Pi's ordinary path even when their text equals an operational envelope because Firstmate's authoritative producers are text-only. @@ -148,7 +158,7 @@ Serialized session data and Pi 0.81.1's sidebar tree also retain legacy hidden o The taxonomy was derived from Pi 0.81.1's installed public declarations, documentation, examples, `interactive-mode.js`, and its exported component implementations. The test fixture enumerates every class below through the centralized policy, and the interactive fixture exercises the screenshot classes, current user-role operational input, and legacy synthetic presentation entries. -| Policy class | Pi transcript path | Calm result on Pi 0.81.1 through 0.82.0 | +| Policy class | Pi transcript path | Calm result (verified on Pi 0.81.1 through 0.82.0) | | --- | --- | --- | | `genuine-user-prompt` | `UserMessageComponent` | Visible, including every tested operational near miss. | | `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible. | @@ -167,12 +177,12 @@ The test fixture enumerates every class below through the centralized policy, an | `system-notice` | `showStatus`, `showError`, compaction, retry, and startup warning rows | Unsupported boundary; remains visible. | | `cache-notice` | Non-persisted cache-miss `Text` row | Unsupported boundary; remains visible. | | `project-trust-warning` | Non-persisted startup `Text` row | Unsupported boundary; remains visible. | -| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height Pi 0.81.1 through 0.82.0 adapter under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | +| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height adapter (verified on Pi 0.81.1 through 0.82.0) under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | | `synthetic-assistant` | No authoritative Firstmate source found | Policy-hidden, but Pi exposes no generic assistant-role renderer. | | `unknown` | Future or unclassified transcript component | Policy-hidden, but no generic renderer exists; never claimed as covered. | The installed extension API has no supported global transcript filter, user-message renderer, assistant-message renderer, chat-container API, or generic custom-tool wrapper. -Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate version-bounded, idempotent adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged. +Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate idempotent, API-probed adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged; see the [compatibility contract](calm.md#pi-compatibility) for how a future Pi lacking one of those exports is handled. General component replacement, ANSI cursor erasure, provider-context mutation, and installed-file patching remain rejected as unsupported or preservation-breaking workarounds. ## Cross-harness verification record @@ -197,7 +207,7 @@ grok 0.2.106 (bde89716f679) | Claude Code 2.1.218 | Not feasible through the inspected supported project surface. | Project hooks can observe lifecycle and tool events, while the plugin CLI packages supported components; neither inspected surface exposes a transcript-row renderer or transcript-wide redraw API. | | Codex CLI 0.144.6 | Not feasible through the inspected supported project surface. | The tracked hooks expose session, pre-tool, and stop handling, while the plugin and feature inventories expose no TUI tool-row renderer or transcript redraw control. | | OpenCode 1.17.18 | Not feasible without violating the preservation boundary. | Plugins expose events and tool execution hooks, not a built-in transcript-row renderer; same-name tool replacement changes execution rather than presentation alone. | -| Pi 0.81.1 through 0.82.0 | Partially feasible with two version-bounded exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the version-pinned collapsed-thinking and operational-user layout boundaries, while generic user, tool, and status filtering remains unavailable. | +| Pi (verified 0.81.1 through 0.82.0) | Partially feasible with two API-probed exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking and operational-user layout boundaries, gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | | Grok CLI 0.2.106 | Not feasible through the inspected supported project surface. | Project hooks expose lifecycle and tool interception, while the plugin CLI exposes no row-renderer contract; `--minimal` changes the whole screen mode rather than selected transcript rows. | These conclusions are deliberately limited to the named versions and supported surfaces. @@ -259,8 +269,8 @@ skip: set FM_PI_LIVE_E2E=1 to run the isolated interactive Pi regression ## 2026-07-26 Pi 0.82.0 compatibility verification -Pi 0.82.0 preserved both version-bounded presentation seams and every deterministic Calm TUI guarantee. -The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that lower supported boundary while the real CLI exercised 0.82.0. +Pi 0.82.0 preserved both API-probed presentation seams and every deterministic Calm TUI guarantee. +The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that earlier declaration-evidence version while the real CLI exercised 0.82.0. ```text $ pi --version diff --git a/docs/calm.md b/docs/calm.md index 6a2c1d14b9c..8d63b6d0b56 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -18,6 +18,12 @@ Pi's supported presentation API does not expose a global transcript filter. Expanded reasoning and its reserved spacing, built-in tool images, user-bash rows, skill and summary rows, generic status notices, and arbitrary custom-tool or extension rows remain visible. These are supported-API boundaries rather than hidden-content failures. +## Pi compatibility + +Calm has no numeric Pi version minimum or maximum and never refuses Pi solely because its version is newer than a previously verified version. +The collapsed-thinking and operational-user-row presentation adapters probe the exact Pi API seam they patch when Calm loads. +If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter; `/calm`, the other adapter, and unrelated Pi extensions remain available. + [`calm-mode-feasibility.md`](calm-mode-feasibility.md) owns the version-scoped renderer taxonomy and empirical evidence. [`configuration.md`](configuration.md#pi-calm-preference-configcalm) owns the persisted preference file and resolution rules. `.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, and `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter. diff --git a/docs/configuration.md b/docs/configuration.md index 7f23866c334..c5215512b20 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,7 @@ A zellij spawn additionally version-gates against the installed `zellij` binary' A cmux spawn additionally version-gates against the installed `cmux` binary's version, requires `jq`, and requires the control socket to be reachable and accessible (see [`docs/cmux-backend.md`](cmux-backend.md) "Setup" for the one-time socket-access configuration this needs; Automation mode is the recommended socket control mode, with Password mode supported via `config/cmux-socket-password`), refusing loudly and non-retryably on a `cmuxOnly`/unauthenticated socket. A backend spawn refusal from a missing dependency, version gate, or unauthenticated socket is terminal for that selected backend; firstmate surfaces it as a blocker instead of silently retrying another backend. Task meta records `backend=` only for a non-default backend; an absent `backend=` means `tmux`, preserving existing default-path meta files. +Every new task records `endpoint_task_id=` as the cleanup binding between the metadata filename and its opaque runtime endpoint. A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`. A zellij task additionally records `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. An Orca task additionally records `orca_worktree_id=` and `terminal=`, with `window=fm-` kept as the shared firstmate alias. @@ -77,7 +78,9 @@ Otherwise an exact task id matching `state/.meta` wins before the legacy `fm A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. These five sentences are the single owner of the task-selector vocabulary; backend guides and other documents point here instead of restating the resolution order. -`fm-teardown.sh ` takes a task id directly and uses the same recorded backend target fields after loading `state/.meta`. +`fm-teardown.sh ` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. +Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused. +Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. By default, Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. The default-container spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. The optional local `config/herdr-presentation-spaces` presence flag instead enables Herdr's default-off disposable single-task visual projection; [Optional presentation spaces](herdr-backend.md#optional-presentation-spaces) owns its behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children. @@ -90,7 +93,7 @@ Use the guarded cleanup path described in [`docs/zellij-backend.md`](zellij-back cmux has no session layer at all - one workspace per task, in whatever cmux window is open - and its socket password (when configured) is read from local, gitignored `config/cmux-socket-password` under the effective config directory, never committed. The caller-facing label remains `fm-`, but the actual cmux workspace title is scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. Test cleanup must use the guarded path in [`docs/cmux-backend.md`](cmux-backend.md#current-operation-and-safety), never enumerate-and-close every workspace. -The `config/backend` file is not inherited by secondmate homes. +`config/backend` is inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). ## Away-mode supervisor backend (FM_SUPERVISOR_BACKEND / FM_SUPERVISOR_TARGET) @@ -280,7 +283,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a registered secondmate is skipped or its relaunch fails; already-live and successfully relaunched secondmates are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running 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. The locked bootstrap inheritance pass uses the same per-home changed-set and reread path for already-running homes; 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. @@ -376,7 +379,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; an alternation, not a `[...]` bracket expression, so a C-locale byte-decomposed match can never misfire on an unrelated multibyte glyph; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Current transport behavior") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline @@ -413,10 +416,10 @@ FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for the Stop auto-arm's claim, health, or fresh rewake epoch before re-blocking FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm rewake outcome counts as this event epoch's owned recovery FM_CLAUDE_TURNEND_BLOCK_BUDGET=3 # consecutive --claude guard re-blocks before a degraded allow; safely below Claude Code's 8-block override -FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED +FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED; default 30 on Git Bash/MSYS FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle -FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure -FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached +FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure; default 35000 on Windows to stay above the MSYS confirm budget +FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; default 35000 on Windows to stay above the MSYS confirm budget FM_WATCH_ARM_RETIRE_TIMEOUT_MS=1000 # milliseconds Pi/OpenCode wait for an unready successor arm to exit before abandoning retries FM_WATCH_REARM_RETRY_BASE_MS=250 # Pi/OpenCode adapter base delay for continuity restoration retries FM_WATCH_REARM_RETRY_MAX_MS=4000 # Pi/OpenCode adapter cap for exponential continuity retry delay diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index ef21cea1323..7830dfcb3b5 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -12,7 +12,7 @@ It sources `bin/fm-gate-refuse-lib.sh` and stays silent for a no-mistakes gate a It shares `bin/fm-primary-scope-lib.sh` with `bin/fm-turnend-guard.sh`, so the hooks use one primary-detection owner. The Shared Predicate section of [`turnend-guard.md`](turnend-guard.md#shared-predicate) owns marker validation, plain-checkout detection, and required Firstmate-shaped paths. -Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid, matching `bin/fm-lock.sh` and Pi's `lockOwnership()` ancestry depth. +Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. Every path exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. diff --git a/docs/subagent-guard.md b/docs/subagent-guard.md index 87f194d9d12..47aaf10e0f3 100644 --- a/docs/subagent-guard.md +++ b/docs/subagent-guard.md @@ -47,14 +47,22 @@ agent subagent task workflow cron schedul worktree delegate spawn dispatch handoff remote sendmessage monitor ``` -Two exclusions keep the shape test from producing false positives. +Three exclusions keep the shape test from producing false positives. - A name beginning `mcp__` is never classified. An MCP server chooses its own tool names, a task or agent noun there is common, and it has no bearing on fleet dispatch. -- The exact names `taskoutput`, `taskstop`, `taskget`, `tasklist`, `cronlist`, `bashoutput`, and `killshell` are allowed. +- `OBSERVE_ONLY_TOOLS`: the exact names `taskoutput`, `taskstop`, `taskget`, `tasklist`, `cronlist`, `bashoutput`, and `killshell` are allowed. These observe or stop work that already exists rather than creating it, and denying them at this layer could strand already-running work with no way to inspect or end it. A Claude primary's optional local deny list may still remove them from the schema. The shipped guard stays narrower on purpose so it can never be the reason a runaway task cannot be stopped. +- `PLAN_ONLY_TOOLS`: the exact names `taskcreate` and `taskupdate` are allowed. + These write, which is why they are a separate list rather than more entries in the observe-or-stop one, but what they write is the harness's session-local todo list. + That list has no executor: it spawns no agent, allocates no worktree, registers no schedule, and starts nothing that could outlive the session or escape a firstmate guard. + So it is not the "work, agent, schedule, or isolated workspace that firstmate would not know about" the guard exists to stop, and the stem match on `task` is a false positive rather than a policy. + The cost of the false positive was concrete: the primary could not track its own plan, and the deny text told it to run `bin/fm-brief.sh` and `bin/fm-spawn.sh` to create a todo entry. + +Both exclusion lists match the whole normalized name, never a substring, so neither can widen by accident: `TaskCreateAgent` and `RemoteTaskCreate` stay denied. +Folding the two lists together would be the drift risk, because the observe-or-stop rationale is not true of a tool that writes. The shipped guard fires on every delegation-shaped name that reaches it, including future names that no deny list knows about yet. That future-name behavior is the reason the tracked matcher must match all tools and let the script filter. @@ -79,10 +87,8 @@ Claude primaries should add this deny list in untracked per-home local settings, "CronCreate", "CronDelete", "CronList", - "TaskCreate", "TaskGet", "TaskList", - "TaskUpdate", "TaskStop", "TaskOutput" ] @@ -103,8 +109,11 @@ It is not tracked for two reasons. The width of the list remains a captain-owned decision, because denying some of these changes how the captain works with the primary session. Keep it as one flat local array that is reviewable at a glance and narrowable in one line. -In particular `TaskOutput`, `TaskStop`, `TaskGet`, `TaskList`, and `CronList` only observe or stop work that already exists, but the recommended local deny list still removes them by default. -The hook deliberately allows those names, so the shipped guard can never strand a runaway task with no way to inspect or end it. +In particular `TaskOutput`, `TaskStop`, `TaskGet`, `TaskList`, and `CronList` only observe or stop work that already exists, yet the recommended local deny list still removes all five by default. +The hook deliberately allows those five, so the shipped guard can never strand a runaway task with no way to inspect or end it, and it allows `TaskCreate` and `TaskUpdate` too, so it can never be the reason the primary cannot track its own plan. +The two session-local todo tools are no longer recommended for local denial at all, because they write only the harness's session-local todo list, which has no executor and spawns nothing, so removing them from the schema removes no delegation power. +Denying them there would instead reproduce at a stronger layer the exact false positive the shipped guard now avoids, leaving anyone who adopts this list verbatim unable to let a primary track its own plan. +Narrowing the list further, including the five observe-or-stop names, is the captain's call, and this local list is the only layer that can remove a todo tool from the primary's schema. `permissions.allow` is a pre-approval list, not an availability list, so there is no fail-closed positive allowlist available. That is why any fixed deny list is fail-open against future tools and why the shape-based guard still exists. @@ -171,7 +180,7 @@ Applicability turns on one question: does the harness expose built-in delegation | Harness | Delegation surface | Status | | --- | --- | --- | -| Claude | 18 known tools, listed above | Scoped guard wired and live-verified; untracked local deny list verified and recommended. | +| Claude | 16 known tools, listed above | Scoped guard wired and live-verified; untracked local deny list verified and recommended. | | Codex | none | Not applicable, verified empirically below. Codex 0.144.1 exposes no subagent, sub-task, or delegated-agent tool, so there is nothing to remove or intercept. `.codex/hooks.json` is unchanged. | | Grok | present, exact tokens unconfirmed | Not wired pending live verification. See below. | | OpenCode | present, exact tokens unconfirmed | Not wired pending live verification. See below. | @@ -285,8 +294,8 @@ This distinction matters when reading the next result: a tool absent from a plai ### Local deny-list hardening -Run in a scratch firstmate-shaped project containing `AGENTS.md`, `state/`, a full copy of `bin/`, and a Claude settings file containing the recommended local deny-list JSON above. -The result validates the recommended local deny-list JSON above, not tracked repo state. +Run in a scratch firstmate-shaped project containing `AGENTS.md`, `state/`, a full copy of `bin/`, and a Claude settings file containing the local deny list exactly as recommended on that date, which was the 18-name form that still included `TaskCreate` and `TaskUpdate`. +The result validates that local deny list rather than tracked repo state, and the recommendation above has since dropped those two session-local todo tools. Asking for deferred entries explicitly returned: ```text @@ -344,7 +353,7 @@ The live consequence is confirmed by the shipped-guard result above: Claude hono ## Automated validation `tests/fm-subagent-pretool-check.test.sh` owns the acceptance matrix and is registered in the `pure-contract-unit` family in `bin/fm-test-run.sh`. -It covers the tracked Claude settings boundary that forbids a `permissions` key; the match-all Claude hook registration; denial of every work-creating delegation tool by shape; denial of twelve hypothetical future tool names that appear on no list; the observe-or-stop and MCP exclusions; the scout-present and scout-absent message variants; the escape hatch including its fail-closed values; inertness in a linked task worktree and in a non-firstmate repo; in-scope enforcement for a marked secondmate home; both stdin transports; the empty-stdout requirement; fail-open transport behavior; and the preserved `Bash` seatbelts and `Stop` guard. +It covers the tracked Claude settings boundary that forbids a `permissions` key; the match-all Claude hook registration; denial of every work-creating delegation tool by shape; denial of twelve hypothetical future tool names that appear on no list; the observe-or-stop, plan-only, and MCP exclusions; the exactness of the plan-only exclusion against six near-miss names a substring or shorter-stem widening would release; the scout-present and scout-absent message variants; the escape hatch including its fail-closed values; inertness in a linked task worktree and in a non-firstmate repo; in-scope enforcement for a marked secondmate home; both stdin transports; the empty-stdout requirement; fail-open transport behavior; and the preserved `Bash` seatbelts and `Stop` guard. Run: diff --git a/docs/supervision-protocols/grok.md b/docs/supervision-protocols/grok.md index a250edd205a..22444b2bd7f 100644 --- a/docs/supervision-protocols/grok.md +++ b/docs/supervision-protocols/grok.md @@ -30,10 +30,9 @@ When you see a background-task-completed system reminder for the arm: Re-arm attaches to an existing healthy cycle when one is already present and follows its verified successor chain. See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and clean-close failure contract. -Grok Stop hooks are passive. -The primary project hook runs `bin/fm-turnend-guard-grok.sh`, which forces at most one same-session follow-up via `grok --resume` when a turn would end blind. -That is a backstop, not the normal wake path. -After any forced follow-up, arm the watcher with the background protocol above. +The primary project Stop hook runs `bin/fm-turnend-guard-grok.sh` as a backstop, not the normal wake path. +[`turnend-guard.md`](../turnend-guard.md) owns its running-payload capability selection between native same-process blocking and the pre-native bounded resume fallback. +After any forced continuation, arm the watcher with the background protocol above. Interactive TUI primary sessions are the supported supervision host. Headless `grok -p` may wait for background process exit but does not reliably surface full auto-wake model output; do not run the primary firstmate as a one-shot headless process. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 30690bb887e..8ee750de397 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -42,8 +42,8 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - Codex registers a `Stop` hook in `.codex/hooks.json`, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard. - OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2. - Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. -- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and uses `bin/fm-turnend-guard-grok.sh` to resume the reported session once when the shared guard returns 2. - The adapter intentionally omits `--permission-mode`, so a passive hook cannot grant stronger permissions than the resumed session default. +- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and delegates capability selection to `bin/fm-turnend-guard-grok.sh`. + The tracked Claude Stop entries are inert when `GROK_AGENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. Claude and Codex can block a Stop directly with exit status 2 and stderr. Both payloads carry `stop_hook_active`. @@ -55,15 +55,22 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_BUDGET` times (default 3, below Claude's 8-block override), then allows degraded with a visible `systemMessage`. Any allow resets the budget. -OpenCode, Pi, pi-signed, and Grok expose passive callbacks for this purpose. +OpenCode, Pi, and pi-signed expose passive callbacks for this purpose. Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks. The generated prompts use the canonical `turn-end-guard` kind after the U+2063 `FIRSTMATE_OP: ` prefix, so Ahoy does not treat them as captain messages. -Each adapter owns a loop latch. +Each passive adapter owns a loop latch. Pi keeps the latch across internal tool turns and clears it only when the generated follow-up settles or delivery fails. -Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`. -If a passive adapter cannot invoke its SDK, find `grok`, or recover a Grok session id, the next pull-based `fm-guard.sh` call reports the problem. +Grok makes exactly one typed capability decision from each running Stop payload. +A boolean `stopHookActive` selects native blocking, including both false on the initial stop and true on the bounded continuation. +The camel-case field has precedence when both spellings appear; when it is absent, a boolean `stop_hook_active` selects the same native path for compatibility. +The native path returns the shared guard's status and stderr to the same Grok process and never starts `grok --resume`. +When both capability spellings are absent, the adapter preserves one pre-native `grok --resume` fallback guarded by `GROK_TURNEND_GUARD_ACTIVE` and intentionally omits `--permission-mode`. +Malformed JSON, a selected field with a non-boolean type, missing `jq`, missing hook prerequisites, or an already-active legacy guard allows the stop without starting either continuation path. +Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`; genuine pre-native builds can run the same tracked hook from an isolated global hook directory. + +If a passive adapter cannot invoke its SDK, or the Grok legacy fallback cannot find `grok` or a session id, the next pull-based `fm-guard.sh` call reports the problem. That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it always points to the active harness protocol rather than embedding another repair command. ## Compatibility limits @@ -83,7 +90,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## Regression coverage -`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety. +`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index a711d84ee5b..65152100f4e 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -89,6 +89,34 @@ tests/fm-tmux-submit-busy.test.sh Expected structural matrix: real text on any content row is pending; all-empty complete boxes are empty; unreadable, incomplete, or unsafe boxes are unknown; and non-bordered panes retain cursor-row compatibility. Expected submit matrix: proven pending plus busy is accepted as queued; proven pending plus idle remains pending; ambiguous pending is never converted by the busy exception; and only a proven empty composer succeeds directly. +### Cleanup endpoint identity + +The cleanup identity boundary was validated on 2026-07-28 with tmux 3.6a and metadata fixtures for every supported backend. + +```sh +tests/fm-teardown-endpoint-safety.test.sh +tests/fm-teardown.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-backend-zellij.test.sh +tests/fm-backend-orca.test.sh +tests/fm-backend-cmux.test.sh +``` + +Bounded output from the incident regression: + +```text +ok - fm-teardown: missing, empty, malformed, ambiguous, and task-mismatched endpoints refuse before every mutation or runtime call +ok - cleanup identity: valid tmux, Herdr, Zellij, Orca, and cmux records validate while every empty backend target refuses +ok - tmux backend: direct empty target returns nonzero without invoking tmux +ok - process cleanup: creation-time PID identity removes only the exact child and preserves the control child +ok - fm-teardown: dedicated-socket invalid cleanup preserves target/control and valid cleanup removes only the exact target +``` + +The dedicated tmux cell removed ambient tmux variables, required a socket-bound wrapper, kept one target and one independent control window, and proved the wrapper was not called for invalid metadata or a direct empty target. +Valid cleanup removed only the exact task-bound target and left the control window live. +The metadata-only validation covers tmux, Herdr, Zellij, Orca, and cmux before backend dispatch. +Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi share that backend cleanup boundary; their harness-specific hook files and token cleanup run only after it, so no harness needs a separate endpoint parser. + ## Herdr The compatibility floor is protocol 14. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 6945b3491dc..a364f8db042 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -71,10 +71,30 @@ The direct and passive mechanisms were validated across all five harnesses on 20 | Codex | 0.142.1 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout and one continuation ran. | | OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. | | Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. | -| Grok | 0.2.93 | Passive `Stop` plus bounded resume | Project hook ran under trust, resumed once without inherited bypass permissions, and the environment latch prevented recursion. | +| Grok | 0.2.112 native and 0.2.73 pre-native | Running-payload adaptive `Stop` | Native false-to-true continuation stayed in one process with two model turns and zero resume launches; the field-absent pre-native process launched exactly one guarded resume. | + +The Grok adaptive matrix ran on 2026-07-28 with separate scratch repositories and homes, dedicated tmux sockets, one target plus one control window, ambient tmux variables removed, and a socket-bound wrapper first in `PATH`. + +```sh +FM_GROK_STOP_LIVE_E2E=1 \ + FM_GROK_NATIVE_BIN="$native_grok_0_2_112" \ + FM_GROK_LEGACY_BIN="$official_pre_native_grok_0_2_73" \ + tests/fm-grok-stop-live-e2e.test.sh +``` + +Observed bounded output: + +```text +ok - grok 0.2.112 (9bbd559437aa) [stable] native Stop kept one session across false->true, two model turns, and zero resume processes +ok - grok 0.2.73 (9ff14c43bbe5) [stable] legacy Stop omitted capability, resumed exactly once, and stopped normally +ok - Grok adaptive Stop real-process matrix passed with exact target cleanup and control-window survival +``` + +The same run proved the Claude-compatible Stop entries stay inert under `GROK_AGENT`, the legacy resume carries `GROK_TURNEND_GUARD_ACTIVE=1`, and every replacement root is removed after exact target cleanup while its control window survives. The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. +On 2026-07-28 with Claude Code 2.1.205, `fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh` was fixed to resolve the outermost pid of a contiguous nested-harness run instead of the first match, so the Stop auto-arm correctly reaches the session's true lock owner through Claude Code's multi-level `bg-spare` hook worker chain. The Claude product live path ran with Claude Code 2.1.219 on 2026-07-24: @@ -96,6 +116,7 @@ Current entry points: tests/fm-turnend-guard.test.sh tests/fm-supervision-instructions.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh +FM_GROK_STOP_LIVE_E2E=1 FM_GROK_NATIVE_BIN="$native_grok" FM_GROK_LEGACY_BIN="$pre_native_grok" tests/fm-grok-stop-live-e2e.test.sh ``` ## Watcher continuity diff --git a/tests/fixtures/quota-array-dispatch/cases.json b/tests/fixtures/quota-array-dispatch/cases.json index 23d097be463..c6fc3c3a867 100644 --- a/tests/fixtures/quota-array-dispatch/cases.json +++ b/tests/fixtures/quota-array-dispatch/cases.json @@ -199,6 +199,48 @@ } ] }, + { + "id": "select-pi-xai-before-authentication", + "expect": "pi-xai", + "reason": "an unauthenticated standalone Grok candidate cannot block selected authenticated Pi/xAI", + "candidates": [ + { + "id": "pi-xai", + "harness": "pi", + "model": "xai/grok-4.5", + "provider": "xai", + "authenticationSurface": "Pi xAI OAuth", + "authAvailable": true, + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 55, + "paceStatus": "behind", + "aheadWindowIds": [], + "worstReserve": 15.0, + "unknownPace": false, + "paceAvailable": true + }, + { + "id": "standalone-grok", + "harness": "grok", + "model": "grok-4.5", + "provider": "grok", + "authenticationSurface": "Grok Build CLI", + "authAvailable": false, + "authFailure": "Grok Build CLI login missing", + "fit": "comparable", + "reasoningClass": "strong", + "tight": false, + "rawHeadroom": 80, + "paceStatus": "ahead", + "aheadWindowIds": ["weekly"], + "worstReserve": -12.0, + "unknownPace": false, + "paceAvailable": true + } + ] + }, { "id": "all-tight-strongest-reasoning", "expect": "A", diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 194d2053ce9..158e943211c 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -866,7 +866,7 @@ touch "$SECOND_HOME_A/state/.last-watcher-beat" "$SECOND_HOME_B/state/.last-watc # may write config/herdr-presentation-spaces. git -C "$SECOND_HOME_A" init -q git -C "$SECOND_HOME_B" init -q -printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\n' \ +printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\n' \ > "$SECOND_HOME_A/.gitignore" cp "$SECOND_HOME_A/.gitignore" "$SECOND_HOME_B/.gitignore" git -C "$SECOND_HOME_A" add .gitignore diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 66c3dd36535..a54e448d108 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -702,7 +702,7 @@ test_peek_send_and_crew_state_route_through_orca_meta() { fm_git_init_commit "$wt" state="$TMP_ROOT/io-state"; mkdir -p "$state" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-io" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-io" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" touch "$state/.last-watcher-beat" orca_case io-path neutral=$(neutral_fm_root "$CASE_DIR/neutral") @@ -739,7 +739,7 @@ test_peek_and_crew_state_fail_closed_on_orca_error_json() { fm_git_init_commit "$wt" state="$TMP_ROOT/read-error-state"; mkdir -p "$state" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-stale" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-stale" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" touch "$state/.last-watcher-beat" orca_case read-error-json neutral=$(neutral_fm_root "$CASE_DIR/neutral") @@ -785,7 +785,7 @@ test_scout_teardown_removes_orca_worktree_via_helper() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-teardown" \ "decisions_reviewed=1" "decision_keys=" @@ -822,7 +822,7 @@ test_scout_teardown_refuses_orca_id_path_mismatch() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-scout-mismatch" \ "decisions_reviewed=1" "decision_keys=" @@ -858,7 +858,7 @@ test_teardown_removes_orca_worktree_when_path_missing() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-path" \ "decisions_reviewed=1" "decision_keys=" @@ -891,12 +891,13 @@ test_teardown_preserves_metadata_when_orca_remove_error_json() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-remove-error" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-remove-error" \ "decisions_reviewed=1" "decision_keys=" orca_case remove-error-teardown - printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/1.out" + printf '{"ok":true,"result":{}}\n' > "$RESP/1.out" + printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/2.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -921,7 +922,7 @@ test_scout_teardown_refuses_orca_missing_report_when_path_missing() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-report" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-report" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-report" orca_case missing-report @@ -951,7 +952,7 @@ test_ship_teardown_refuses_orca_missing_worktree_path() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-ship" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-ship" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-ship" orca_case missing-ship-path @@ -982,7 +983,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-match" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-match" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-match" orca_case ship-match @@ -1017,7 +1018,7 @@ test_ship_teardown_refuses_orca_unresolvable_worktree_id() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-unresolved" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-unresolved" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-unresolved" orca_case ship-unresolved @@ -1056,7 +1057,7 @@ test_ship_teardown_refuses_orca_id_path_mismatch() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-mismatch" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-mismatch" orca_case ship-mismatch @@ -1094,7 +1095,7 @@ test_teardown_refuses_orca_missing_worktree_id() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" "backend=orca" \ "decisions_reviewed=1" "decision_keys=" orca_case missing-id @@ -1112,7 +1113,7 @@ test_teardown_refuses_orca_missing_worktree_id() { pass "fm-teardown.sh backend=orca: refuses missing worktree ids before cleanup" } -test_teardown_removes_orca_worktree_without_terminal_handle() { +test_teardown_refuses_orca_worktree_without_terminal_handle() { local proj wt data state config id out rc neutral id="orcanotermz0" proj="$TMP_ROOT/no-terminal-project" @@ -1125,12 +1126,11 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-no-terminal" \ "decisions_reviewed=1" "decision_keys=" orca_case no-terminal - printf '{"ok":true,"result":{"worktree":{"id":"wt-no-terminal","path":"%s"}}}\n' "$wt" > "$RESP/1.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -1138,13 +1138,11 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? set -e - expect_code 0 "$rc" "Orca teardown should remove a worktree even when no terminal was ever recorded"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-no-terminal'$'\x1f''--force'$'\x1f''--json' \ - "teardown did not remove the partial Orca worktree" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ - "teardown should not close a terminal when no terminal handle is recorded" - assert_absent "$state/$id.meta" "successful partial cleanup should remove task metadata" - pass "fm-teardown.sh backend=orca: removes partial worktree-only metadata" + [ "$rc" -ne 0 ] || fail "Orca teardown accepted metadata without a terminal handle" + assert_contains "$out" "missing terminal" "teardown did not explain the incomplete Orca endpoint" + [ ! -s "$LOG" ] || fail "teardown dispatched to Orca before rejecting the incomplete endpoint" + assert_present "$state/$id.meta" "missing-terminal refusal removed task metadata" + pass "fm-teardown.sh backend=orca: refuses incomplete worktree-only endpoint metadata before runtime dispatch" } test_secondmate_force_teardown_removes_orca_child_via_orca() { @@ -1164,7 +1162,8 @@ test_secondmate_force_teardown_removes_orca_child_via_orca() { printf '%s\n' "- domain - Orca child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "terminal=term-child-cleanup" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "terminal=term-child-cleanup" "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-child-cleanup" orca_case secondmate-child-cleanup @@ -1206,7 +1205,8 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { printf '%s\n' "- domain - Orca child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "terminal=term-child-mismatch" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "terminal=term-child-mismatch" "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-child-mismatch" orca_case secondmate-child-mismatch @@ -1229,7 +1229,7 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { pass "fm-teardown.sh --force: refuses Orca child id/path mismatches" } -test_secondmate_force_teardown_removes_partial_orca_child() { +test_secondmate_force_teardown_refuses_partial_orca_child() { local home subhome childproj childwt child_id neutral out rc home="$TMP_ROOT/orca-partial-child-parent" subhome="$TMP_ROOT/orca-partial-child-secondmate" @@ -1246,11 +1246,11 @@ test_secondmate_force_teardown_removes_partial_orca_child() { printf '%s\n' "- domain - Orca partial child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-partial-child" orca_case secondmate-partial-child-cleanup - printf '{"ok":true,"result":{"worktree":{"id":"wt-partial-child","path":"%s"}}}\n' "$childwt" > "$RESP/1.out" add_tmux_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -1258,13 +1258,12 @@ test_secondmate_force_teardown_removes_partial_orca_child() { FM_ROOT_OVERRIDE="$neutral" FM_HOME="$home" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) rc=$? set -e - expect_code 0 "$rc" "forced secondmate teardown should remove partial Orca child state"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-partial-child'$'\x1f''--force'$'\x1f''--json' \ - "partial child cleanup did not remove the Orca worktree through orca worktree rm" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ - "partial child cleanup should not close a terminal when no terminal handle is recorded" - assert_absent "$home/state/domain.meta" "parent metadata should be removed after forced partial cleanup" - pass "fm-teardown.sh --force: removes partial Orca secondmate children" + [ "$rc" -ne 0 ] || fail "forced secondmate teardown accepted a child with no terminal identity" + assert_contains "$out" "missing terminal" "partial child refusal did not explain the incomplete endpoint" + [ ! -s "$LOG" ] || fail "partial child refusal dispatched to Orca or tmux" + assert_present "$home/state/domain.meta" "partial child refusal removed parent metadata" + assert_present "$subhome/state/$child_id.meta" "partial child refusal removed child metadata" + pass "fm-teardown.sh --force: refuses partial Orca secondmate children before runtime dispatch" } test_dispatcher_sources_orca_and_routes_primitives() { @@ -1323,7 +1322,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches test_ship_teardown_refuses_orca_unresolvable_worktree_id test_ship_teardown_refuses_orca_id_path_mismatch test_teardown_refuses_orca_missing_worktree_id -test_teardown_removes_orca_worktree_without_terminal_handle +test_teardown_refuses_orca_worktree_without_terminal_handle test_secondmate_force_teardown_removes_orca_child_via_orca test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch -test_secondmate_force_teardown_removes_partial_orca_child +test_secondmate_force_teardown_refuses_partial_orca_child diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 3844252c93a..ae4be257bdf 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -796,8 +796,11 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { printf 'report\n' > "$data/zghost/report.md" fm_write_meta "$state/zghost.meta" \ "window=firstmate:7" \ + "endpoint_task_id=zghost" \ "backend=zellij" \ + "zellij_session=firstmate" \ "zellij_tab_id=3" \ + "zellij_pane_id=7" \ "worktree=$dir/missing-worktree" \ "project=$project" \ "kind=scout" \ @@ -827,7 +830,11 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { printf 'smz\n' > "$home/.fm-secondmate-home" fm_write_meta "$state/smz.meta" \ "window=firstmate:99" \ + "endpoint_task_id=smz" \ "backend=zellij" \ + "zellij_session=firstmate" \ + "zellij_tab_id=99" \ + "zellij_pane_id=99" \ "worktree=$home" \ "project=$home" \ "kind=secondmate" \ @@ -835,8 +842,11 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { "home=$home" fm_write_meta "$home/state/childz.meta" \ "window=firstmate:7" \ + "endpoint_task_id=childz" \ "backend=zellij" \ + "zellij_session=firstmate" \ "zellij_tab_id=4" \ + "zellij_pane_id=7" \ "worktree=$dir/missing-child-worktree" \ "project=$project" \ "kind=scout" diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 7b206fd3bf1..644e5b1f751 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -12,7 +12,10 @@ # binaries and fixtures as the REFACTORED versions in this checkout, then # diffs the two command logs byte-for-byte - the report's P1 checklist # item "run current main scripts and refactored scripts against the same -# fake tools and compare command logs". +# fake tools and compare command logs". The teardown old-vs-new case also +# overlays a content-historical permissive tmux kill fixture: after the +# exact-selector change lands on the default branch, merge-base with main +# collapses to HEAD and can no longer supply that baseline. # 3. Asserts the `--backend`/`FM_BACKEND` selection refuses unknown backends # and the blocked `codex-app` backend loudly. # @@ -80,6 +83,9 @@ SH } # The commit this branch started from - the P1 "current main" baseline. +# Suitable for byte-identical old-vs-new checks while a branch still diverges +# from main. After a squash lands, merge-base(HEAD, main) collapses to HEAD, so +# callers that need a true pre-change fixture must not rely on this alone. resolve_base_ref() { local ref base for ref in main refs/heads/main origin/main refs/remotes/origin/main origin/HEAD refs/remotes/origin/HEAD; do @@ -95,6 +101,30 @@ resolve_base_ref() { BASE_REF=$(resolve_base_ref) \ || fail "fm-backend baseline requires local main or origin/main; fetch the default branch before running this test" +# Newest first-parent revision whose bin/backends/tmux.sh still uses the +# pre-exact permissive kill-window target. Content-addressed from history so the +# fixture stays historical on default-branch CI and on branches cut after the +# exact-selector change, where merge-base with main is self-referential. +resolve_permissive_tmux_kill_ref() { + local commit body + while IFS= read -r commit; do + [ -n "$commit" ] || continue + body=$(git -C "$ROOT" show "$commit:bin/backends/tmux.sh" 2>/dev/null) || continue + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "=$session:=$window"'*) continue ;; + esac + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) + printf '%s\n' "$commit" + return 0 + ;; + esac + done < <(git -C "$ROOT" log --first-parent --format='%H' HEAD -- bin/backends/tmux.sh) + return 1 +} + # --- shared: a pre-refactor bin/ shim -------------------------------------- # # build_old_bin echoes a directory whose bin/ subdir holds the PRE-REFACTOR @@ -108,10 +138,14 @@ BASE_REF=$(resolve_base_ref) \ # fm-backend.sh (and its bin/backends/ adapters) is the dispatcher every one # of the five REFACTORED scripts sources; it must be a real, reachable file in # the old bin/ too or `. "$SCRIPT_DIR/fm-backend.sh"` aborts under set -eu - -# hence it is a copied sibling, not an extracted-from-BASE_REF file: for a -# tmux-only conformance run the tmux adapter's behavior is what is under test, -# and that is unchanged by any later (e.g. non-tmux backend) addition to -# fm-backend.sh's own dispatch surface. +# 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. +# +# The five leading entries are this branch's additions, required by the +# transitive-source-closure guard in tests/fm-lint.test.sh: fm-platform-lib.sh +# is new here, and the other four were already-unsatisfied sources the guard +# surfaced. OLD_BIN_UNCHANGED_SIBLINGS="fm-platform-lib.sh fm-check-lib.sh fm-push-transition-lib.sh fm-transition-lib.sh fm-x-lib.sh fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-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-decision-hold.sh fm-backend.sh fm-operational-input.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" @@ -130,6 +164,7 @@ build_old_bin() { # -> echoes root dir (root/bin/